diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/jobs/InAppPaymentSetupJobTest.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/jobs/InAppPaymentSetupJobTest.kt new file mode 100644 index 0000000000..bf0d5c149d --- /dev/null +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/jobs/InAppPaymentSetupJobTest.kt @@ -0,0 +1,288 @@ +package org.thoughtcrime.securesms.jobs + +import android.net.Uri +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isNotNull +import org.junit.Rule +import org.junit.Test +import org.signal.donations.InAppPaymentType +import org.signal.donations.StripeApi +import org.signal.donations.StripeIntentAccessor +import org.thoughtcrime.securesms.database.InAppPaymentTable +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData +import org.thoughtcrime.securesms.jobmanager.Job +import org.thoughtcrime.securesms.jobs.protos.InAppPaymentSetupJobData +import org.thoughtcrime.securesms.jobs.protos.InAppPaymentSourceData +import org.thoughtcrime.securesms.testing.SignalDatabaseRule + +/** + * Core test logic for [InAppPaymentSetupJob] + */ +class InAppPaymentSetupJobTest { + + @get:Rule + val signalDatabaseRule = SignalDatabaseRule() + + @Test + fun givenAnInAppPaymentThatDoesntExist_whenIRun_thenIExpectFailure() { + val testData = InAppPaymentSetupJobData( + inAppPaymentId = 1L, + inAppPaymentSource = InAppPaymentSourceData.Builder() + .code(InAppPaymentSourceData.Code.CREDIT_CARD) + .tokenData(InAppPaymentSourceData.TokenData()) + .build() + ) + + val testJob = TestInAppPaymentSetupJob(testData) + + val result = testJob.run() + + assertThat(result.isFailure).isEqualTo(true) + } + + @Test + fun givenAnInAppPaymentInEndState_whenIRun_thenIExpectFailure() { + val id = insertInAppPayment(state = InAppPaymentTable.State.END) + val testData = InAppPaymentSetupJobData( + inAppPaymentId = id.rowId, + inAppPaymentSource = InAppPaymentSourceData.Builder() + .code(InAppPaymentSourceData.Code.CREDIT_CARD) + .tokenData(InAppPaymentSourceData.TokenData()) + .build() + ) + + val testJob = TestInAppPaymentSetupJob(testData) + + val result = testJob.run() + + assertThat(result.isFailure).isEqualTo(true) + } + + @Test + fun givenAnInAppPaymentInRequiredActionCompletedWithoutCompletedState_whenIRun_thenIExpectFailure() { + val id = insertInAppPayment( + state = InAppPaymentTable.State.REQUIRED_ACTION_COMPLETED + ) + + val testData = InAppPaymentSetupJobData( + inAppPaymentId = id.rowId, + inAppPaymentSource = InAppPaymentSourceData.Builder() + .code(InAppPaymentSourceData.Code.CREDIT_CARD) + .tokenData(InAppPaymentSourceData.TokenData()) + .build() + ) + + val testJob = TestInAppPaymentSetupJob(testData) + + val result = testJob.run() + + assertThat(result.isFailure).isEqualTo(true) + assertThat(SignalDatabase.inAppPayments.getById(id)?.state).isEqualTo(InAppPaymentTable.State.END) + } + + @Test + fun givenAStripeInAppPaymentInRequiredActionCompletedWithCompletedState_whenIRun_thenIExpectSuccess() { + val id = insertInAppPayment( + state = InAppPaymentTable.State.REQUIRED_ACTION_COMPLETED, + data = InAppPaymentData.Builder() + .paymentMethodType(InAppPaymentData.PaymentMethodType.CARD) + .stripeActionComplete(InAppPaymentData.StripeActionCompleteState()) + .build() + ) + + val testData = InAppPaymentSetupJobData( + inAppPaymentId = id.rowId, + inAppPaymentSource = InAppPaymentSourceData.Builder() + .code(InAppPaymentSourceData.Code.CREDIT_CARD) + .build() + ) + + val testJob = TestInAppPaymentSetupJob(testData) + + val result = testJob.run() + + assertThat(result.isSuccess).isEqualTo(true) + } + + @Test + fun givenAPayPalInAppPaymentInRequiredActionCompletedWithCompletedState_whenIRun_thenIExpectSuccess() { + val id = insertInAppPayment( + state = InAppPaymentTable.State.REQUIRED_ACTION_COMPLETED, + data = InAppPaymentData.Builder() + .paymentMethodType(InAppPaymentData.PaymentMethodType.PAYPAL) + .payPalActionComplete(InAppPaymentData.PayPalActionCompleteState()) + .build() + ) + + val testData = InAppPaymentSetupJobData( + inAppPaymentId = id.rowId, + inAppPaymentSource = InAppPaymentSourceData.Builder() + .code(InAppPaymentSourceData.Code.PAY_PAL) + .build() + ) + + val testJob = TestInAppPaymentSetupJob(testData) + + val result = testJob.run() + + assertThat(result.isSuccess).isEqualTo(true) + } + + @Test + fun givenRequiredActionComplete_whenIRun_thenIBypassPerformPreUserAction() { + val id = insertInAppPayment( + state = InAppPaymentTable.State.REQUIRED_ACTION_COMPLETED, + data = InAppPaymentData.Builder() + .paymentMethodType(InAppPaymentData.PaymentMethodType.PAYPAL) + .payPalActionComplete(InAppPaymentData.PayPalActionCompleteState()) + .build() + ) + + val testData = InAppPaymentSetupJobData( + inAppPaymentId = id.rowId, + inAppPaymentSource = InAppPaymentSourceData.Builder() + .code(InAppPaymentSourceData.Code.PAY_PAL) + .build() + ) + + val testJob = TestInAppPaymentSetupJob( + data = testData, + requiredUserAction = { error("Unexpected call to requiredUserAction") }, + postUserActionResult = { + assertThat(SignalDatabase.inAppPayments.getById(id)?.state).isEqualTo(InAppPaymentTable.State.TRANSACTING) + Job.Result.success() + } + ) + + val result = testJob.run() + + assertThat(result.isSuccess).isEqualTo(true) + } + + @Test + fun givenPayPalUserActionRequired_whenIRun_thenIDoNotPerformPostUserActionResult() { + val id = insertInAppPayment( + state = InAppPaymentTable.State.CREATED, + data = InAppPaymentData.Builder() + .paymentMethodType(InAppPaymentData.PaymentMethodType.PAYPAL) + .build() + ) + + val testData = InAppPaymentSetupJobData( + inAppPaymentId = id.rowId, + inAppPaymentSource = InAppPaymentSourceData.Builder() + .code(InAppPaymentSourceData.Code.PAY_PAL) + .build() + ) + + val testJob = TestInAppPaymentSetupJob( + data = testData, + requiredUserAction = { + InAppPaymentSetupJob.RequiredUserAction.PayPalActionRequired("", "") + }, + postUserActionResult = { + error("Unexpected call to postUserActionResult") + } + ) + + val result = testJob.run() + + assertThat(result.isFailure).isEqualTo(true) + + val fresh = SignalDatabase.inAppPayments.getById(id)!! + assertThat(fresh.state).isEqualTo(InAppPaymentTable.State.REQUIRES_ACTION) + assertThat(fresh.data.payPalRequiresAction).isNotNull() + } + + @Test + fun givenStripeUserActionRequired_whenIRun_thenIDoNotPerformPostUserActionResult() { + val id = insertInAppPayment( + state = InAppPaymentTable.State.CREATED, + data = InAppPaymentData.Builder() + .paymentMethodType(InAppPaymentData.PaymentMethodType.CARD) + .build() + ) + + val testData = InAppPaymentSetupJobData( + inAppPaymentId = id.rowId, + inAppPaymentSource = InAppPaymentSourceData.Builder() + .code(InAppPaymentSourceData.Code.CREDIT_CARD) + .build() + ) + + val testJob = TestInAppPaymentSetupJob( + data = testData, + requiredUserAction = { + InAppPaymentSetupJob.RequiredUserAction.StripeActionRequired( + StripeApi.Secure3DSAction.ConfirmRequired( + uri = Uri.EMPTY, + returnUri = Uri.EMPTY, + stripeIntentAccessor = StripeIntentAccessor( + objectType = StripeIntentAccessor.ObjectType.PAYMENT_INTENT, + intentId = "", + intentClientSecret = "" + ), + paymentMethodId = null + ) + ) + }, + postUserActionResult = { + error("Unexpected call to postUserActionResult") + } + ) + + val result = testJob.run() + + assertThat(result.isFailure).isEqualTo(true) + + val fresh = SignalDatabase.inAppPayments.getById(id)!! + assertThat(fresh.state).isEqualTo(InAppPaymentTable.State.REQUIRES_ACTION) + assertThat(fresh.data.stripeRequiresAction).isNotNull() + } + + private fun insertInAppPayment( + state: InAppPaymentTable.State = InAppPaymentTable.State.CREATED, + data: InAppPaymentData = InAppPaymentData() + ): InAppPaymentTable.InAppPaymentId { + return SignalDatabase.inAppPayments.insert( + type = InAppPaymentType.ONE_TIME_DONATION, + state = state, + subscriberId = null, + endOfPeriod = null, + inAppPaymentData = data + ) + } + + private class TestInAppPaymentSetupJob( + data: InAppPaymentSetupJobData, + val requiredUserAction: () -> RequiredUserAction = { + RequiredUserAction.StripeActionNotRequired( + StripeApi.Secure3DSAction.NotNeeded( + paymentMethodId = "", + stripeIntentAccessor = StripeIntentAccessor( + objectType = StripeIntentAccessor.ObjectType.PAYMENT_INTENT, + intentId = "", + intentClientSecret = "" + ) + ) + ) + }, + val postUserActionResult: () -> Result = { Result.success() } + ) : InAppPaymentSetupJob(data, Parameters.Builder().build()) { + override fun performPreUserAction(inAppPayment: InAppPaymentTable.InAppPayment): RequiredUserAction { + return requiredUserAction() + } + + override fun performPostUserAction(inAppPayment: InAppPaymentTable.InAppPayment): Result { + return postUserActionResult() + } + + override fun getFactoryKey(): String = error("Not used.") + + override fun run(): Result { + return performTransaction() + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowViewModel.kt index 90d7261e0d..44bed153c6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowViewModel.kt @@ -262,7 +262,7 @@ class MessageBackupsFlowViewModel( Log.d(TAG, "Setting purchase token data on InAppPayment and InAppPaymentSubscriber.") val iapSubscriptionId = IAPSubscriptionId.GooglePlayBillingPurchaseToken(result.purchaseToken) - RecurringInAppPaymentRepository.ensureSubscriberId(InAppPaymentSubscriberRecord.Type.BACKUP, iapSubscriptionId = iapSubscriptionId, isRotation = true).blockingAwait() + RecurringInAppPaymentRepository.ensureSubscriberIdSync(InAppPaymentSubscriberRecord.Type.BACKUP, iapSubscriptionId = iapSubscriptionId, isRotation = true) val inAppPayment = SignalDatabase.inAppPayments.getById(inAppPaymentId)!! SignalDatabase.inAppPayments.update( diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsActivity.kt index 3dc65e06db..62a51ca070 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsActivity.kt @@ -13,8 +13,8 @@ import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.components.settings.DSLSettingsActivity import org.thoughtcrime.securesms.components.settings.app.chats.folders.CreateFoldersFragmentArgs import org.thoughtcrime.securesms.components.settings.app.notifications.profiles.EditNotificationProfileScheduleFragmentArgs -import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentComponent -import org.thoughtcrime.securesms.components.settings.app.subscription.StripeRepository +import org.thoughtcrime.securesms.components.settings.app.subscription.GooglePayComponent +import org.thoughtcrime.securesms.components.settings.app.subscription.GooglePayRepository import org.thoughtcrime.securesms.help.HelpFragment import org.thoughtcrime.securesms.keyvalue.SettingsValues import org.thoughtcrime.securesms.keyvalue.SignalStore @@ -31,12 +31,12 @@ private const val NOTIFICATION_CATEGORY = "android.intent.category.NOTIFICATION_ private const val STATE_WAS_CONFIGURATION_UPDATED = "app.settings.state.configuration.updated" private const val EXTRA_PERFORM_ACTION_ON_CREATE = "extra_perform_action_on_create" -class AppSettingsActivity : DSLSettingsActivity(), InAppPaymentComponent { +class AppSettingsActivity : DSLSettingsActivity(), GooglePayComponent { private var wasConfigurationUpdated = false - override val stripeRepository: StripeRepository by lazy { StripeRepository(this) } - override val googlePayResultPublisher: Subject = PublishSubject.create() + override val googlePayRepository: GooglePayRepository by lazy { GooglePayRepository(this) } + override val googlePayResultPublisher: Subject = PublishSubject.create() override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) { if (intent?.hasExtra(ARG_NAV_GRAPH) != true) { @@ -134,7 +134,7 @@ class AppSettingsActivity : DSLSettingsActivity(), InAppPaymentComponent { @Suppress("DEPRECATION") override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { super.onActivityResult(requestCode, resultCode, data) - googlePayResultPublisher.onNext(InAppPaymentComponent.GooglePayResult(requestCode, resultCode, data)) + googlePayResultPublisher.onNext(GooglePayComponent.GooglePayResult(requestCode, resultCode, data)) } companion object { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/InAppPaymentComponent.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/GooglePayComponent.kt similarity index 83% rename from app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/InAppPaymentComponent.kt rename to app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/GooglePayComponent.kt index 7ef70220ca..4ce25e6c2e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/InAppPaymentComponent.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/GooglePayComponent.kt @@ -5,8 +5,8 @@ import android.os.Parcelable import io.reactivex.rxjava3.subjects.Subject import kotlinx.parcelize.Parcelize -interface InAppPaymentComponent { - val stripeRepository: StripeRepository +interface GooglePayComponent { + val googlePayRepository: GooglePayRepository val googlePayResultPublisher: Subject @Parcelize diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/GooglePayRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/GooglePayRepository.kt new file mode 100644 index 0000000000..9f518c4e1d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/GooglePayRepository.kt @@ -0,0 +1,47 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.app.subscription + +import android.app.Activity +import android.content.Intent +import io.reactivex.rxjava3.core.Completable +import org.signal.core.util.logging.Log +import org.signal.core.util.money.FiatMoney +import org.signal.donations.GooglePayApi +import org.signal.donations.StripeApi +import org.thoughtcrime.securesms.util.Environment + +/** + * Extraction of components that only deal with GooglePay from StripeRepository + */ +class GooglePayRepository(activity: Activity) { + + companion object { + private val TAG = Log.tag(GooglePayRepository::class) + } + + private val googlePayApi = GooglePayApi(activity, StripeApi.Gateway(Environment.Donations.STRIPE_CONFIGURATION), Environment.Donations.GOOGLE_PAY_CONFIGURATION) + + fun isGooglePayAvailable(): Completable { + return googlePayApi.queryIsReadyToPay() + } + + fun requestTokenFromGooglePay(price: FiatMoney, label: String, requestCode: Int) { + Log.d(TAG, "Requesting a token from google pay...") + googlePayApi.requestPayment(price, label, requestCode) + } + + fun onActivityResult( + requestCode: Int, + resultCode: Int, + data: Intent?, + expectedRequestCode: Int, + paymentsRequestCallback: GooglePayApi.PaymentRequestCallback + ) { + Log.d(TAG, "Processing possible google pay result...") + googlePayApi.onActivityResult(requestCode, resultCode, data, expectedRequestCode, paymentsRequestCallback) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/InAppPaymentsExtensions.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/InAppPaymentsExtensions.kt new file mode 100644 index 0000000000..6baa8a2efd --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/InAppPaymentsExtensions.kt @@ -0,0 +1,91 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.app.subscription + +import org.signal.donations.CreditCardPaymentSource +import org.signal.donations.GooglePayPaymentSource +import org.signal.donations.IDEALPaymentSource +import org.signal.donations.PayPalPaymentSource +import org.signal.donations.PaymentSource +import org.signal.donations.PaymentSourceType +import org.signal.donations.SEPADebitPaymentSource +import org.signal.donations.StripeApi +import org.signal.donations.TokenPaymentSource +import org.thoughtcrime.securesms.jobs.protos.InAppPaymentSourceData + +fun PaymentSourceType.toInAppPaymentSourceDataCode(): InAppPaymentSourceData.Code { + return when (this) { + PaymentSourceType.Unknown -> InAppPaymentSourceData.Code.UNKNOWN + PaymentSourceType.GooglePlayBilling -> InAppPaymentSourceData.Code.GOOGLE_PLAY_BILLING + PaymentSourceType.PayPal -> InAppPaymentSourceData.Code.PAY_PAL + PaymentSourceType.Stripe.CreditCard -> InAppPaymentSourceData.Code.CREDIT_CARD + PaymentSourceType.Stripe.GooglePay -> InAppPaymentSourceData.Code.GOOGLE_PAY + PaymentSourceType.Stripe.IDEAL -> InAppPaymentSourceData.Code.IDEAL + PaymentSourceType.Stripe.SEPADebit -> InAppPaymentSourceData.Code.SEPA_DEBIT + } +} + +fun PaymentSource.toProto(): InAppPaymentSourceData { + return InAppPaymentSourceData( + code = type.toInAppPaymentSourceDataCode(), + idealData = if (this is IDEALPaymentSource) { + InAppPaymentSourceData.IDEALData( + bank = idealData.bank, + name = idealData.name, + email = idealData.email + ) + } else null, + sepaData = if (this is SEPADebitPaymentSource) { + InAppPaymentSourceData.SEPAData( + iban = sepaDebitData.iban, + name = sepaDebitData.name, + email = sepaDebitData.email + ) + } else null, + tokenData = if (this is CreditCardPaymentSource || this is GooglePayPaymentSource) { + InAppPaymentSourceData.TokenData( + parameters = parameterize().toString(), + tokenId = getTokenId(), + email = email() + ) + } else null + ) +} + +fun InAppPaymentSourceData.toPaymentSource(): PaymentSource { + return when (code) { + InAppPaymentSourceData.Code.CREDIT_CARD, InAppPaymentSourceData.Code.GOOGLE_PAY -> { + TokenPaymentSource( + type = if (code == InAppPaymentSourceData.Code.CREDIT_CARD) PaymentSourceType.Stripe.CreditCard else PaymentSourceType.Stripe.GooglePay, + parameters = tokenData!!.parameters, + token = tokenData.tokenId, + email = tokenData.email + ) + } + InAppPaymentSourceData.Code.SEPA_DEBIT -> { + SEPADebitPaymentSource( + StripeApi.SEPADebitData( + iban = sepaData!!.iban, + name = sepaData.name, + email = sepaData.email + ) + ) + } + InAppPaymentSourceData.Code.IDEAL -> { + IDEALPaymentSource( + StripeApi.IDEALData( + bank = idealData!!.bank, + name = idealData.name, + email = idealData.email + ) + ) + } + InAppPaymentSourceData.Code.PAY_PAL -> { + PayPalPaymentSource() + } + else -> error("Unexpected code $code") + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/InAppPaymentsRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/InAppPaymentsRepository.kt index 2f88606fb7..84a9632ef9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/InAppPaymentsRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/InAppPaymentsRepository.kt @@ -516,13 +516,13 @@ object InAppPaymentsRepository { val value = when (inAppPayment.state) { InAppPaymentTable.State.CREATED -> error("This should have been filtered out.") - InAppPaymentTable.State.WAITING_FOR_AUTHORIZATION -> { + InAppPaymentTable.State.WAITING_FOR_AUTHORIZATION, InAppPaymentTable.State.REQUIRES_ACTION -> { DonationRedemptionJobStatus.PendingExternalVerification( pendingOneTimeDonation = inAppPayment.toPendingOneTimeDonation(), nonVerifiedMonthlyDonation = inAppPayment.toNonVerifiedMonthlyDonation() ) } - InAppPaymentTable.State.PENDING -> { + InAppPaymentTable.State.PENDING, InAppPaymentTable.State.TRANSACTING, InAppPaymentTable.State.REQUIRED_ACTION_COMPLETED -> { if (inAppPayment.data.redemption?.keepAlive == true) { DonationRedemptionJobStatus.PendingKeepAlive } else if (inAppPayment.data.redemption?.stage == InAppPaymentData.RedemptionState.Stage.REDEMPTION_STARTED) { @@ -590,7 +590,7 @@ object InAppPaymentsRepository { timestamp = insertedAt.inWholeMilliseconds, price = data.amount!!.toFiatMoney(), level = data.level.toInt(), - checkedVerification = data.waitForAuth!!.checkedVerification + checkedVerification = data.waitForAuth?.checkedVerification ?: false ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/OneTimeInAppPaymentRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/OneTimeInAppPaymentRepository.kt index 6600889eee..5f69ba1292 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/OneTimeInAppPaymentRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/OneTimeInAppPaymentRepository.kt @@ -1,17 +1,14 @@ package org.thoughtcrime.securesms.components.settings.app.subscription -import io.reactivex.rxjava3.core.Completable +import androidx.annotation.WorkerThread import io.reactivex.rxjava3.core.Single import io.reactivex.rxjava3.schedulers.Schedulers import org.signal.core.util.logging.Log import org.signal.core.util.money.FiatMoney import org.signal.donations.PaymentSourceType import org.thoughtcrime.securesms.badges.models.Badge -import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository.toPaymentSourceType import org.thoughtcrime.securesms.components.settings.app.subscription.boost.Boost -import org.thoughtcrime.securesms.components.settings.app.subscription.donate.InAppPaymentError import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError -import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError.BadgeRedemptionError import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource import org.thoughtcrime.securesms.database.InAppPaymentTable import org.thoughtcrime.securesms.database.RecipientTable @@ -23,8 +20,10 @@ import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.RecipientId import java.util.Currency import java.util.Locale -import java.util.concurrent.TimeUnit +/** + * Shared one-time payment methods that apply to both Stripe and PayPal payments. + */ object OneTimeInAppPaymentRepository { private val TAG = Log.tag(OneTimeInAppPaymentRepository::class.java) @@ -34,35 +33,41 @@ object OneTimeInAppPaymentRepository { * * If the throwable is already a DonationError, it's returned as is. Otherwise we will return an adequate payment setup error. */ - fun handleCreatePaymentIntentError(throwable: Throwable, badgeRecipient: RecipientId, paymentSourceType: PaymentSourceType): Single { + fun handleCreatePaymentIntentErrorSync(throwable: Throwable, badgeRecipient: RecipientId, paymentSourceType: PaymentSourceType): Throwable { return if (throwable is DonationError) { - Single.error(throwable) + throwable } else { val recipient = Recipient.resolved(badgeRecipient) val errorSource = if (recipient.isSelf) DonationErrorSource.ONE_TIME else DonationErrorSource.GIFT - Single.error(DonationError.getPaymentSetupError(errorSource, throwable, paymentSourceType)) + DonationError.getPaymentSetupError(errorSource, throwable, paymentSourceType) } } + /** + * Passthrough Rx wrapper for [handleCreatePaymentIntentErrorSync]. This does not dispatch to a thread-pool. + */ + fun handleCreatePaymentIntentError(throwable: Throwable, badgeRecipient: RecipientId, paymentSourceType: PaymentSourceType): Single { + return Single.error(handleCreatePaymentIntentErrorSync(throwable, badgeRecipient, paymentSourceType)) + } + /** * Checks whether the recipient for the given ID is allowed to receive a gift. Returns * normally if they are and emits an error otherwise. */ - fun verifyRecipientIsAllowedToReceiveAGift(badgeRecipient: RecipientId): Completable { - return Completable.fromAction { - Log.d(TAG, "Verifying badge recipient $badgeRecipient", true) - val recipient = Recipient.resolved(badgeRecipient) + @WorkerThread + fun verifyRecipientIsAllowedToReceiveAGiftSync(badgeRecipient: RecipientId) { + Log.d(TAG, "Verifying badge recipient $badgeRecipient", true) + val recipient = Recipient.resolved(badgeRecipient) - if (recipient.isSelf) { - Log.d(TAG, "Cannot send a gift to self.", true) - throw DonationError.GiftRecipientVerificationError.SelectedRecipientIsInvalid - } + if (recipient.isSelf) { + Log.d(TAG, "Cannot send a gift to self.", true) + throw DonationError.GiftRecipientVerificationError.SelectedRecipientIsInvalid + } - if (!recipient.isIndividual || recipient.registered != RecipientTable.RegisteredState.REGISTERED) { - Log.w(TAG, "Invalid badge recipient $badgeRecipient. Verification failed.", true) - throw DonationError.GiftRecipientVerificationError.SelectedRecipientIsInvalid - } - }.subscribeOn(Schedulers.io()) + if (!recipient.isIndividual || recipient.registered != RecipientTable.RegisteredState.REGISTERED) { + Log.w(TAG, "Invalid badge recipient $badgeRecipient. Verification failed.", true) + throw DonationError.GiftRecipientVerificationError.SelectedRecipientIsInvalid + } } /** @@ -82,6 +87,9 @@ object OneTimeInAppPaymentRepository { } } + /** + * Get the one-time donation badge from the Signal service + */ fun getBoostBadge(): Single { return Single .fromCallable { @@ -93,6 +101,10 @@ object OneTimeInAppPaymentRepository { .map { it.getBoostBadges().first() } } + /** + * Get a map of [Currency] to [FiatMoney] representing minimum donation amounts from the + * signal service. This is scheduled on the io thread-pool. + */ fun getMinimumDonationAmounts(): Single> { return Single.fromCallable { AppDependencies.donationsService.getDonationsConfiguration(Locale.getDefault()) } .flatMap { it.flattenResult() } @@ -100,46 +112,28 @@ object OneTimeInAppPaymentRepository { .map { it.getMinimumDonationAmounts() } } - fun waitForOneTimeRedemption( + /** + * Submits the required jobs to redeem the given [InAppPaymentTable.InAppPayment] + * + * This job does mutate the in-app payment but since this is the final action during setup, + * returning that data is useless. + */ + fun submitRedemptionJobChain( inAppPayment: InAppPaymentTable.InAppPayment, paymentIntentId: String - ): Completable { - val isLongRunning = inAppPayment.data.paymentMethodType.toPaymentSourceType() == PaymentSourceType.Stripe.SEPADebit - val isBoost = inAppPayment.data.recipientId?.let { RecipientId.from(it) } == Recipient.self().id - val donationErrorSource: DonationErrorSource = if (isBoost) DonationErrorSource.ONE_TIME else DonationErrorSource.GIFT - - val timeoutError: DonationError = if (isLongRunning) { - BadgeRedemptionError.DonationPending(donationErrorSource, inAppPayment) - } else { - BadgeRedemptionError.TimeoutWaitingForTokenError(donationErrorSource) - } - - return Single.fromCallable { - Log.d(TAG, "Confirmed payment intent. Submitting badge reimbursement job chain.", true) - SignalDatabase.inAppPayments.update( - inAppPayment = inAppPayment.copy( - data = inAppPayment.data.copy( - redemption = InAppPaymentData.RedemptionState( - stage = InAppPaymentData.RedemptionState.Stage.INIT, - paymentIntentId = paymentIntentId - ) + ) { + Log.d(TAG, "Confirmed payment intent. Submitting badge reimbursement job chain.", true) + SignalDatabase.inAppPayments.update( + inAppPayment = inAppPayment.copy( + data = inAppPayment.data.newBuilder().redemption( + redemption = InAppPaymentData.RedemptionState( + stage = InAppPaymentData.RedemptionState.Stage.INIT, + paymentIntentId = paymentIntentId ) - ) + ).build() ) + ) - InAppPaymentOneTimeContextJob.createJobChain(inAppPayment).enqueue() - inAppPayment.id - }.flatMap { inAppPaymentId -> - Log.d(TAG, "Awaiting completion of redemption chain for up to 10 seconds.", true) - InAppPaymentsRepository.observeUpdates(inAppPaymentId).filter { - it.state == InAppPaymentTable.State.END - }.take(1).firstOrError().timeout(10, TimeUnit.SECONDS, Single.error(timeoutError)) - }.map { - if (it.data.error != null) { - Log.d(TAG, "Failure during redemption chain: ${it.data.error}", true) - throw InAppPaymentError(it.data.error) - } - it - }.ignoreElement() + InAppPaymentOneTimeContextJob.createJobChain(inAppPayment).enqueue() } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/PayPalRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/PayPalRepository.kt index 44ae1c86dd..3df3929ede 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/PayPalRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/PayPalRepository.kt @@ -1,8 +1,6 @@ package org.thoughtcrime.securesms.components.settings.app.subscription -import io.reactivex.rxjava3.core.Completable -import io.reactivex.rxjava3.core.Single -import io.reactivex.rxjava3.schedulers.Schedulers +import androidx.annotation.WorkerThread import org.signal.core.util.logging.Log import org.signal.core.util.money.FiatMoney import org.signal.donations.PaymentSourceType @@ -31,44 +29,47 @@ class PayPalRepository(private val donationsService: DonationsService) { private val TAG = Log.tag(PayPalRepository::class.java) } + /** + * Creates a one-time payment intent that the user can use to make a donation. + */ + @WorkerThread fun createOneTimePaymentIntent( amount: FiatMoney, badgeRecipient: RecipientId, badgeLevel: Long - ): Single { - return Single.fromCallable { - donationsService - .createPayPalOneTimePaymentIntent( - Locale.getDefault(), - amount.currency.currencyCode, - amount.minimumUnitPrecisionString, - badgeLevel, - ONE_TIME_RETURN_URL, - CANCEL_URL - ) + ): PayPalCreatePaymentIntentResponse { + return try { + donationsService.createPayPalOneTimePaymentIntent( + Locale.getDefault(), + amount.currency.currencyCode, + amount.minimumUnitPrecisionString, + badgeLevel, + ONE_TIME_RETURN_URL, + CANCEL_URL + ).resultOrThrow + } catch (e: Exception) { + throw OneTimeInAppPaymentRepository.handleCreatePaymentIntentErrorSync(e, badgeRecipient, PaymentSourceType.PayPal) } - .flatMap { it.flattenResult() } - .onErrorResumeNext { OneTimeInAppPaymentRepository.handleCreatePaymentIntentError(it, badgeRecipient, PaymentSourceType.PayPal) } - .subscribeOn(Schedulers.io()) } + /** + * Confirms a one-time payment via the Signal Service to complete a donation. + */ + @WorkerThread fun confirmOneTimePaymentIntent( amount: FiatMoney, badgeLevel: Long, paypalConfirmationResult: PayPalConfirmationResult - ): Single { - return Single.fromCallable { - Log.d(TAG, "Confirming one-time payment intent...", true) - donationsService - .confirmPayPalOneTimePaymentIntent( - amount.currency.currencyCode, - amount.minimumUnitPrecisionString, - badgeLevel, - paypalConfirmationResult.payerId, - paypalConfirmationResult.paymentId, - paypalConfirmationResult.paymentToken - ) - }.flatMap { it.flattenResult() }.subscribeOn(Schedulers.io()) + ): PayPalConfirmPaymentIntentResponse { + Log.d(TAG, "Confirming one-time payment intent...", true) + return donationsService.confirmPayPalOneTimePaymentIntent( + amount.currency.currencyCode, + amount.minimumUnitPrecisionString, + badgeLevel, + paypalConfirmationResult.payerId, + paypalConfirmationResult.paymentId, + paypalConfirmationResult.paymentToken + ).resultOrThrow } /** @@ -76,42 +77,42 @@ class PayPalRepository(private val donationsService: DonationsService) { * it means that the PaymentMethod is already tied to a Stripe account. We can retry in this * situation by simply deleting the old subscriber id on the service and replacing it. */ - fun createPaymentMethod(subscriberType: InAppPaymentSubscriberRecord.Type, retryOn409: Boolean = true): Single { - return Single.fromCallable { - donationsService.createPayPalPaymentMethod( - Locale.getDefault(), - InAppPaymentsRepository.requireSubscriber(subscriberType).subscriberId, - MONTHLY_RETURN_URL, - CANCEL_URL - ) - }.flatMap { serviceResponse -> - if (retryOn409 && serviceResponse.status == 409) { - RecurringInAppPaymentRepository.rotateSubscriberId(subscriberType).andThen(createPaymentMethod(subscriberType, retryOn409 = false)) - } else { - serviceResponse.flattenResult() - } - }.subscribeOn(Schedulers.io()) + @WorkerThread + fun createPaymentMethod(subscriberType: InAppPaymentSubscriberRecord.Type, retryOn409: Boolean = true): PayPalCreatePaymentMethodResponse { + val response = donationsService.createPayPalPaymentMethod( + Locale.getDefault(), + InAppPaymentsRepository.requireSubscriber(subscriberType).subscriberId, + MONTHLY_RETURN_URL, + CANCEL_URL + ) + + return if (retryOn409 && response.status == 409) { + RecurringInAppPaymentRepository.rotateSubscriberIdSync(subscriberType) + createPaymentMethod(subscriberType, retryOn409 = false) + } else { + response.resultOrThrow + } } - fun setDefaultPaymentMethod(subscriberType: InAppPaymentSubscriberRecord.Type, paymentMethodId: String): Completable { - return Single - .fromCallable { InAppPaymentsRepository.requireSubscriber(subscriberType) } - .flatMapCompletable { subscriberRecord -> - Single.fromCallable { - Log.d(TAG, "Setting default payment method...", true) - donationsService.setDefaultPayPalPaymentMethod( - subscriberRecord.subscriberId, - paymentMethodId - ) - }.flatMap { it.flattenResult() }.ignoreElement().doOnComplete { - Log.d(TAG, "Set default payment method.", true) - Log.d(TAG, "Storing the subscription payment source type locally.", true) + /** + * Sets the default payment method via the Signal Service. + */ + @WorkerThread + fun setDefaultPaymentMethod(subscriberType: InAppPaymentSubscriberRecord.Type, paymentMethodId: String) { + val subscriber = InAppPaymentsRepository.requireSubscriber(subscriberType) - SignalDatabase.inAppPaymentSubscribers.setPaymentMethod( - subscriberRecord.subscriberId, - InAppPaymentData.PaymentMethodType.PAYPAL - ) - } - }.subscribeOn(Schedulers.io()) + Log.d(TAG, "Setting default payment method...", true) + donationsService.setDefaultPayPalPaymentMethod( + subscriber.subscriberId, + paymentMethodId + ).resultOrThrow + + Log.d(TAG, "Set default payment method.", true) + Log.d(TAG, "Storing the subscription payment source type locally.", true) + + SignalDatabase.inAppPaymentSubscribers.setPaymentMethod( + subscriber.subscriberId, + InAppPaymentData.PaymentMethodType.PAYPAL + ) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/RecurringInAppPaymentRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/RecurringInAppPaymentRepository.kt index c0e49df740..12eb672dcb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/RecurringInAppPaymentRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/RecurringInAppPaymentRepository.kt @@ -9,10 +9,7 @@ import org.signal.core.util.logging.Log import org.signal.donations.PaymentSourceType import org.thoughtcrime.securesms.badges.Badges import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository.requireSubscriberType -import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository.toErrorSource import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository.toPaymentSourceType -import org.thoughtcrime.securesms.components.settings.app.subscription.donate.InAppPaymentError -import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError.BadgeRedemptionError import org.thoughtcrime.securesms.database.InAppPaymentTable import org.thoughtcrime.securesms.database.SignalDatabase import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord @@ -34,12 +31,10 @@ import org.whispersystems.signalservice.api.subscriptions.SubscriberId import org.whispersystems.signalservice.internal.EmptyResponse import org.whispersystems.signalservice.internal.ServiceResponse import java.util.Locale -import java.util.concurrent.TimeUnit import kotlin.time.Duration.Companion.milliseconds /** - * Repository which can query for the user's active subscription as well as a list of available subscriptions, - * in the currency indicated. + * Shared methods for operating on recurring subscriptions, shared between donations and backups. */ object RecurringInAppPaymentRepository { @@ -47,12 +42,19 @@ object RecurringInAppPaymentRepository { private val donationsService = AppDependencies.donationsService + /** + * Passthrough Rx wrapper for [getActiveSubscriptionSync] dispatching on io thread-pool. + */ + @CheckResult fun getActiveSubscription(type: InAppPaymentSubscriberRecord.Type): Single { return Single.fromCallable { getActiveSubscriptionSync(type).getOrThrow() }.subscribeOn(Schedulers.io()) } + /** + * Gets the active subscription if it exists for the given [InAppPaymentSubscriberRecord.Type] + */ @WorkerThread fun getActiveSubscriptionSync(type: InAppPaymentSubscriberRecord.Type): Result { val response = InAppPaymentsRepository.getSubscriber(type)?.let { @@ -71,6 +73,10 @@ object RecurringInAppPaymentRepository { } } + /** + * Gets a list of subscriptions available via the donations configuration. + */ + @CheckResult fun getSubscriptions(): Single> { return Single .fromCallable { donationsService.getDonationsConfiguration(Locale.getDefault()) } @@ -88,6 +94,10 @@ object RecurringInAppPaymentRepository { } } + /** + * Syncs the user account record, dispatches on the io thread-pool + */ + @CheckResult fun syncAccountRecord(): Completable { return Completable.fromAction { SignalDatabase.recipients.markNeedsSync(Recipient.self().id) @@ -99,60 +109,72 @@ object RecurringInAppPaymentRepository { * Since PayPal and Stripe can't interoperate, we need to be able to rotate the subscriber ID * in case of failures. */ - fun rotateSubscriberId(subscriberType: InAppPaymentSubscriberRecord.Type): Completable { + @WorkerThread + fun rotateSubscriberIdSync(subscriberType: InAppPaymentSubscriberRecord.Type) { Log.d(TAG, "Rotating SubscriberId due to alternate payment processor...", true) - val cancelCompletable: Completable = if (InAppPaymentsRepository.getSubscriber(subscriberType) != null) { - cancelActiveSubscription(subscriberType).andThen(updateLocalSubscriptionStateAndScheduleDataSync(subscriberType)) - } else { - Completable.complete() + if (InAppPaymentsRepository.getSubscriber(subscriberType) != null) { + cancelActiveSubscriptionSync(subscriberType) + updateLocalSubscriptionStateAndScheduleDataSync(subscriberType) } - return cancelCompletable.andThen(ensureSubscriberId(subscriberType, isRotation = true)) + ensureSubscriberIdSync(subscriberType, isRotation = true) } - fun ensureSubscriberId(subscriberType: InAppPaymentSubscriberRecord.Type, isRotation: Boolean = false, iapSubscriptionId: IAPSubscriptionId? = null): Completable { - return Single.fromCallable { - Log.d(TAG, "Ensuring SubscriberId for type $subscriberType exists on Signal service {isRotation?$isRotation}...", true) + /** + * Passthrough Rx wrapper for [rotateSubscriberIdSync] dispatching on io thread-pool. + */ + @CheckResult + fun rotateSubscriberId(subscriberType: InAppPaymentSubscriberRecord.Type): Completable { + return Completable.fromAction { + rotateSubscriberIdSync(subscriberType) + }.subscribeOn(Schedulers.io()) + } - if (isRotation) { - SubscriberId.generate() - } else { - InAppPaymentsRepository.getSubscriber(subscriberType)?.subscriberId ?: SubscriberId.generate() - } - }.flatMap { subscriberId -> - Single - .fromCallable { - donationsService.putSubscription(subscriberId) - } - .flatMap(ServiceResponse::flattenResult) - .map { subscriberId } - }.doOnSuccess { subscriberId -> - Log.d(TAG, "Successfully set SubscriberId exists on Signal service.", true) + /** + * Ensures that the given [InAppPaymentSubscriberRecord.Type] has a [SubscriberId] that has been sent to the Signal Service. + * Will also record and synchronize this data with storage sync. + */ + @WorkerThread + fun ensureSubscriberIdSync(subscriberType: InAppPaymentSubscriberRecord.Type, isRotation: Boolean = false, iapSubscriptionId: IAPSubscriptionId? = null) { + Log.d(TAG, "Ensuring SubscriberId for type $subscriberType exists on Signal service {isRotation?$isRotation}...", true) - InAppPaymentsRepository.setSubscriber( - InAppPaymentSubscriberRecord( - subscriberId = subscriberId, - currency = if (subscriberType == InAppPaymentSubscriberRecord.Type.DONATION) { - SignalStore.inAppPayments.getRecurringDonationCurrency() - } else { - null - }, - type = subscriberType, - requiresCancel = false, - paymentMethodType = if (subscriberType == InAppPaymentSubscriberRecord.Type.BACKUP) { - InAppPaymentData.PaymentMethodType.GOOGLE_PLAY_BILLING - } else { - InAppPaymentData.PaymentMethodType.UNKNOWN - }, - iapSubscriptionId = iapSubscriptionId - ) + val subscriberId = if (isRotation) { + SubscriberId.generate() + } else { + InAppPaymentsRepository.getSubscriber(subscriberType)?.subscriberId ?: SubscriberId.generate() + } + + donationsService.putSubscription(subscriberId).resultOrThrow + + Log.d(TAG, "Successfully set SubscriberId exists on Signal service.", true) + + InAppPaymentsRepository.setSubscriber( + InAppPaymentSubscriberRecord( + subscriberId = subscriberId, + currency = if (subscriberType == InAppPaymentSubscriberRecord.Type.DONATION) { + SignalStore.inAppPayments.getRecurringDonationCurrency() + } else { + null + }, + type = subscriberType, + requiresCancel = false, + paymentMethodType = if (subscriberType == InAppPaymentSubscriberRecord.Type.BACKUP) { + InAppPaymentData.PaymentMethodType.GOOGLE_PLAY_BILLING + } else { + InAppPaymentData.PaymentMethodType.UNKNOWN + }, + iapSubscriptionId = iapSubscriptionId ) + ) - SignalDatabase.recipients.markNeedsSync(Recipient.self().id) - StorageSyncHelper.scheduleSyncForDataChange() - }.ignoreElement().subscribeOn(Schedulers.io()) + SignalDatabase.recipients.markNeedsSync(Recipient.self().id) + StorageSyncHelper.scheduleSyncForDataChange() } + /** + * Cancels the active subscription via the Signal service. + */ + @WorkerThread fun cancelActiveSubscriptionSync(subscriberType: InAppPaymentSubscriberRecord.Type) { Log.d(TAG, "Canceling active subscription...", true) val localSubscriber = InAppPaymentsRepository.requireSubscriber(subscriberType) @@ -166,6 +188,9 @@ object RecurringInAppPaymentRepository { InAppPaymentsRepository.scheduleSyncForAccountRecordChange() } + /** + * Passthrough Rx wrapper for [cancelActiveSubscriptionSync] dispatching on io thread-pool. + */ @CheckResult fun cancelActiveSubscription(subscriberType: InAppPaymentSubscriberRecord.Type): Completable { return Completable @@ -173,107 +198,103 @@ object RecurringInAppPaymentRepository { .subscribeOn(Schedulers.io()) } - fun cancelActiveSubscriptionIfNecessary(subscriberType: InAppPaymentSubscriberRecord.Type): Completable { - return Single.fromCallable { InAppPaymentsRepository.getShouldCancelSubscriptionBeforeNextSubscribeAttempt(subscriberType) }.flatMapCompletable { - if (it) { - cancelActiveSubscription(subscriberType).doOnComplete { - SignalStore.inAppPayments.updateLocalStateForManualCancellation(subscriberType) - MultiDeviceSubscriptionSyncRequestJob.enqueue() - } - } else { - Completable.complete() - } - }.subscribeOn(Schedulers.io()) - } - - fun getPaymentSourceTypeOfLatestSubscription(subscriberType: InAppPaymentSubscriberRecord.Type): Single { - return Single.fromCallable { - InAppPaymentsRepository.getLatestPaymentMethodType(subscriberType).toPaymentSourceType() + /** + * If the subscriber of the given type has been marked as "requires cancel", this method will perform the cancellation and + * sync the appropriate data. + */ + @WorkerThread + fun cancelActiveSubscriptionIfNecessarySync(subscriberType: InAppPaymentSubscriberRecord.Type) { + val shouldCancel = InAppPaymentsRepository.getShouldCancelSubscriptionBeforeNextSubscribeAttempt(subscriberType) + if (shouldCancel) { + cancelActiveSubscriptionSync(subscriberType) + SignalStore.inAppPayments.updateLocalStateForManualCancellation(subscriberType) + MultiDeviceSubscriptionSyncRequestJob.enqueue() } } - fun setSubscriptionLevel(inAppPayment: InAppPaymentTable.InAppPayment, paymentSourceType: PaymentSourceType): Completable { + /** + * Passthrough Rx wrapper for [cancelActiveSubscriptionIfNecessarySync] dispatching on io thread-pool. + */ + @CheckResult + fun cancelActiveSubscriptionIfNecessary(subscriberType: InAppPaymentSubscriberRecord.Type): Completable { + return Completable.fromAction { + cancelActiveSubscriptionIfNecessarySync(subscriberType) + }.subscribeOn(Schedulers.io()) + } + + /** + * Passthrough Rx wrapper for [InAppPaymentsRepository.getLatestPaymentMethodType] dispatching on io thread-pool. + */ + @CheckResult + fun getPaymentSourceTypeOfLatestSubscription(subscriberType: InAppPaymentSubscriberRecord.Type): Single { + return Single.fromCallable { + InAppPaymentsRepository.getLatestPaymentMethodType(subscriberType).toPaymentSourceType() + }.subscribeOn(Schedulers.io()) + } + + /** + * Sets the subscription level as per the data in the InAppPayment. + * + * This method mutates the [InAppPaymentTable.InAppPayment] and thus returns a new instance. + */ + @CheckResult + @WorkerThread + fun setSubscriptionLevelSync(inAppPayment: InAppPaymentTable.InAppPayment): InAppPaymentTable.InAppPayment { val subscriptionLevel = inAppPayment.data.level.toString() - val isLongRunning = paymentSourceType.isBankTransfer val subscriberType = inAppPayment.type.requireSubscriberType() - val errorSource = subscriberType.inAppPaymentType.toErrorSource() + val subscriber = InAppPaymentsRepository.requireSubscriber(subscriberType) - return getOrCreateLevelUpdateOperation(subscriptionLevel) - .flatMapCompletable { levelUpdateOperation -> - val subscriber = InAppPaymentsRepository.requireSubscriber(subscriberType) - SignalDatabase.inAppPayments.update( - inAppPayment = inAppPayment.copy( - subscriberId = subscriber.subscriberId, - data = inAppPayment.data.copy( - redemption = InAppPaymentData.RedemptionState( - stage = InAppPaymentData.RedemptionState.Stage.INIT - ) + getOrCreateLevelUpdateOperation(TAG, subscriptionLevel).use { operation -> + SignalDatabase.inAppPayments.update( + inAppPayment = inAppPayment.copy( + subscriberId = subscriber.subscriberId, + data = inAppPayment.data.newBuilder().redemption( + redemption = InAppPaymentData.RedemptionState( + stage = InAppPaymentData.RedemptionState.Stage.INIT ) - ) + ).build() ) + ) - val timeoutError = if (isLongRunning) { - BadgeRedemptionError.DonationPending(errorSource, inAppPayment) + Log.d(TAG, "Attempting to set user subscription level to $subscriptionLevel", true) + + val response = AppDependencies.donationsService.updateSubscriptionLevel( + subscriber.subscriberId, + subscriptionLevel, + subscriber.currency!!.currencyCode, + operation.idempotencyKey.serialize(), + subscriberType.lock + ) + + if (response.status == 200 || response.status == 204) { + Log.d(TAG, "Successfully set user subscription to level $subscriptionLevel with response code ${response.status}", true) + SignalStore.inAppPayments.updateLocalStateForLocalSubscribe(subscriberType) + syncAccountRecord().subscribe() + } else { + if (response.applicationError.isPresent) { + Log.w(TAG, "Failed to set user subscription to level $subscriptionLevel with response code ${response.status}", response.applicationError.get(), true) + SignalStore.inAppPayments.clearLevelOperations() } else { - BadgeRedemptionError.TimeoutWaitingForTokenError(errorSource) + Log.w(TAG, "Failed to set user subscription to level $subscriptionLevel", response.executionError.orElse(null), true) } - Log.d(TAG, "Attempting to set user subscription level to $subscriptionLevel", true) - Single - .fromCallable { - AppDependencies.donationsService.updateSubscriptionLevel( - subscriber.subscriberId, - subscriptionLevel, - subscriber.currency!!.currencyCode, - levelUpdateOperation.idempotencyKey.serialize(), - subscriberType.lock - ) - } - .flatMapCompletable { - if (it.status == 200 || it.status == 204) { - Log.d(TAG, "Successfully set user subscription to level $subscriptionLevel with response code ${it.status}", true) - SignalStore.inAppPayments.updateLocalStateForLocalSubscribe(subscriberType) - syncAccountRecord().subscribe() - LevelUpdate.updateProcessingState(false) - Completable.complete() - } else { - if (it.applicationError.isPresent) { - Log.w(TAG, "Failed to set user subscription to level $subscriptionLevel with response code ${it.status}", it.applicationError.get(), true) - SignalStore.inAppPayments.clearLevelOperations() - } else { - Log.w(TAG, "Failed to set user subscription to level $subscriptionLevel", it.executionError.orElse(null), true) - } + response.resultOrThrow + error("Should never get here.") + } + } - LevelUpdate.updateProcessingState(false) - it.flattenResult().ignoreElement() - } - }.andThen( - Single.fromCallable { - Log.d(TAG, "Enqueuing request response job chain.", true) - val freshPayment = SignalDatabase.inAppPayments.getById(inAppPayment.id)!! - InAppPaymentRecurringContextJob.createJobChain(freshPayment).enqueue() - }.flatMap { - Log.d(TAG, "Awaiting completion of redemption chain for up to 10 seconds.", true) - InAppPaymentsRepository.observeUpdates(inAppPayment.id).filter { - it.state == InAppPaymentTable.State.END - }.take(1).map { - if (it.data.error != null) { - Log.d(TAG, "Failure during redemption chain: ${it.data.error}", true) - throw InAppPaymentError(it.data.error) - } - it - }.firstOrError() - }.timeout(10, TimeUnit.SECONDS, Single.error(timeoutError)).ignoreElement() - ) - }.doOnError { - LevelUpdate.updateProcessingState(false) - }.subscribeOn(Schedulers.io()) - } - - private fun getOrCreateLevelUpdateOperation(subscriptionLevel: String): Single = Single.fromCallable { - getOrCreateLevelUpdateOperation(TAG, subscriptionLevel) + Log.d(TAG, "Enqueuing request response job chain.", true) + val freshPayment = SignalDatabase.inAppPayments.getById(inAppPayment.id)!! + InAppPaymentRecurringContextJob.createJobChain(freshPayment).enqueue() + + return freshPayment } + /** + * Get or create a [LevelUpdateOperation] + * + * This allows us to ensure the same idempotency key is used across multiple attempts for the same level. + */ fun getOrCreateLevelUpdateOperation(tag: String, subscriptionLevel: String): LevelUpdateOperation { Log.d(tag, "Retrieving level update operation for $subscriptionLevel") val levelUpdateOperation = SignalStore.inAppPayments.getLevelOperation(subscriptionLevel) @@ -298,13 +319,12 @@ object RecurringInAppPaymentRepository { * Update local state information and schedule a storage sync for the change. This method * assumes you've already properly called the DELETE method for the stored ID on the server. */ - private fun updateLocalSubscriptionStateAndScheduleDataSync(subscriberType: InAppPaymentSubscriberRecord.Type): Completable { - return Completable.fromAction { - Log.d(TAG, "Marking subscription cancelled...", true) - SignalStore.inAppPayments.updateLocalStateForManualCancellation(subscriberType) - MultiDeviceSubscriptionSyncRequestJob.enqueue() - SignalDatabase.recipients.markNeedsSync(Recipient.self().id) - StorageSyncHelper.scheduleSyncForDataChange() - } + @WorkerThread + private fun updateLocalSubscriptionStateAndScheduleDataSync(subscriberType: InAppPaymentSubscriberRecord.Type) { + Log.d(TAG, "Marking subscription cancelled...", true) + SignalStore.inAppPayments.updateLocalStateForManualCancellation(subscriberType) + MultiDeviceSubscriptionSyncRequestJob.enqueue() + SignalDatabase.recipients.markNeedsSync(Recipient.self().id) + StorageSyncHelper.scheduleSyncForDataChange() } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/StripeRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/StripeRepository.kt index 81426b9555..c6f3a598fe 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/StripeRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/StripeRepository.kt @@ -1,14 +1,11 @@ package org.thoughtcrime.securesms.components.settings.app.subscription -import android.app.Activity -import android.content.Intent -import io.reactivex.rxjava3.core.Completable +import androidx.annotation.WorkerThread import io.reactivex.rxjava3.core.Single -import io.reactivex.rxjava3.schedulers.Schedulers import org.signal.core.util.logging.Log import org.signal.core.util.money.FiatMoney -import org.signal.donations.GooglePayApi import org.signal.donations.InAppPaymentType +import org.signal.donations.PaymentSource import org.signal.donations.PaymentSourceType import org.signal.donations.StripeApi import org.signal.donations.StripeIntentAccessor @@ -25,8 +22,6 @@ import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.util.Environment import org.whispersystems.signalservice.api.subscriptions.StripeClientSecret -import org.whispersystems.signalservice.internal.EmptyResponse -import org.whispersystems.signalservice.internal.ServiceResponse /** * Manages bindings with payment APIs @@ -45,149 +40,117 @@ import org.whispersystems.signalservice.internal.ServiceResponse * 1. Create a PaymentMethod vai the Stripe API, utilizing the token from Google Pay * 1. Confirm the PaymentIntent via the Stripe API */ -class StripeRepository( - activity: Activity -) : StripeApi.PaymentIntentFetcher, StripeApi.SetupIntentHelper { +object StripeRepository : StripeApi.PaymentIntentFetcher, StripeApi.SetupIntentHelper { + + private val TAG = Log.tag(StripeRepository::class.java) - private val googlePayApi = GooglePayApi(activity, StripeApi.Gateway(Environment.Donations.STRIPE_CONFIGURATION), Environment.Donations.GOOGLE_PAY_CONFIGURATION) private val stripeApi = StripeApi(Environment.Donations.STRIPE_CONFIGURATION, this, this, AppDependencies.okHttpClient) - fun isGooglePayAvailable(): Completable { - return googlePayApi.queryIsReadyToPay() - } - - fun requestTokenFromGooglePay(price: FiatMoney, label: String, requestCode: Int) { - Log.d(TAG, "Requesting a token from google pay...") - googlePayApi.requestPayment(price, label, requestCode) - } - - fun onActivityResult( - requestCode: Int, - resultCode: Int, - data: Intent?, - expectedRequestCode: Int, - paymentsRequestCallback: GooglePayApi.PaymentRequestCallback - ) { - Log.d(TAG, "Processing possible google pay result...") - googlePayApi.onActivityResult(requestCode, resultCode, data, expectedRequestCode, paymentsRequestCallback) - } - /** - * @param price The amount to charce the local user - * @param badgeRecipient Who will be getting the badge + * Utilize the [StripeApi] to create a payment intent + * + * @return a [StripeIntentAccessor] that can be used to address the payment intent. */ - fun continuePayment( + @WorkerThread + fun createPaymentIntent( price: FiatMoney, badgeRecipient: RecipientId, badgeLevel: Long, paymentSourceType: PaymentSourceType - ): Single { + ): StripeIntentAccessor { check(paymentSourceType is PaymentSourceType.Stripe) - Log.d(TAG, "Creating payment intent for $price...", true) - return stripeApi.createPaymentIntent(price, badgeLevel, paymentSourceType) - .onErrorResumeNext { - OneTimeInAppPaymentRepository.handleCreatePaymentIntentError(it, badgeRecipient, paymentSourceType) - } - .flatMap { result -> - val recipient = Recipient.resolved(badgeRecipient) - val errorSource = if (recipient.isSelf) DonationErrorSource.ONE_TIME else DonationErrorSource.GIFT + val result: StripeApi.CreatePaymentIntentResult = try { + stripeApi.createPaymentIntent(price, badgeLevel, paymentSourceType) + } catch (e: Exception) { + throw OneTimeInAppPaymentRepository.handleCreatePaymentIntentErrorSync(e, badgeRecipient, paymentSourceType) + } - Log.d(TAG, "Created payment intent for $price.", true) - when (result) { - is StripeApi.CreatePaymentIntentResult.AmountIsTooSmall -> Single.error(OneTimeDonationError.AmountTooSmallError(errorSource)) - is StripeApi.CreatePaymentIntentResult.AmountIsTooLarge -> Single.error(OneTimeDonationError.AmountTooLargeError(errorSource)) - is StripeApi.CreatePaymentIntentResult.CurrencyIsNotSupported -> Single.error(OneTimeDonationError.InvalidCurrencyError(errorSource)) - is StripeApi.CreatePaymentIntentResult.Success -> Single.just(result.paymentIntent) - } - }.subscribeOn(Schedulers.io()) + val recipient = Recipient.resolved(badgeRecipient) + val errorSource = if (recipient.isSelf) DonationErrorSource.ONE_TIME else DonationErrorSource.GIFT + + Log.d(TAG, "Created payment intent for $price.", true) + when (result) { + is StripeApi.CreatePaymentIntentResult.AmountIsTooSmall -> throw OneTimeDonationError.AmountTooSmallError(errorSource) + is StripeApi.CreatePaymentIntentResult.AmountIsTooLarge -> throw OneTimeDonationError.AmountTooLargeError(errorSource) + is StripeApi.CreatePaymentIntentResult.CurrencyIsNotSupported -> throw OneTimeDonationError.InvalidCurrencyError(errorSource) + is StripeApi.CreatePaymentIntentResult.Success -> return result.paymentIntent + } } - fun createAndConfirmSetupIntent( - inAppPaymentType: InAppPaymentType, - paymentSource: StripeApi.PaymentSource, - paymentSourceType: PaymentSourceType.Stripe - ): Single { - Log.d(TAG, "Continuing subscription setup...", true) - return stripeApi.createSetupIntent(inAppPaymentType, paymentSourceType) - .flatMap { result -> - Log.d(TAG, "Retrieved SetupIntent, confirming...", true) - stripeApi.confirmSetupIntent(paymentSource, result.setupIntent) - } - } - - fun confirmPayment( - paymentSource: StripeApi.PaymentSource, + /** + * Confirms the given payment [paymentIntent] via the Stripe API + * + * @return a required action, if necessary. This can be the case for some credit cards as well as iDEAL transactions. + */ + @WorkerThread + fun confirmPaymentIntent( + paymentSource: PaymentSource, paymentIntent: StripeIntentAccessor, badgeRecipient: RecipientId - ): Single { + ): StripeApi.Secure3DSAction { val isBoost = badgeRecipient == Recipient.self().id val donationErrorSource: DonationErrorSource = if (isBoost) DonationErrorSource.ONE_TIME else DonationErrorSource.GIFT Log.d(TAG, "Confirming payment intent...", true) - return stripeApi.confirmPaymentIntent(paymentSource, paymentIntent) - .onErrorResumeNext { - Single.error(DonationError.getPaymentSetupError(donationErrorSource, it, paymentSource.type)) - } - } - override fun fetchPaymentIntent(price: FiatMoney, level: Long, sourceType: PaymentSourceType.Stripe): Single { - Log.d(TAG, "Fetching payment intent from Signal service for $price... (Locale.US minimum precision: ${price.minimumUnitPrecisionString})") - return Single - .fromCallable { - AppDependencies - .donationsService - .createDonationIntentWithAmount(price.minimumUnitPrecisionString, price.currency.currencyCode, level, sourceType.paymentMethod) - } - .flatMap(ServiceResponse::flattenResult) - .map { - StripeIntentAccessor( - objectType = StripeIntentAccessor.ObjectType.PAYMENT_INTENT, - intentId = it.id, - intentClientSecret = it.clientSecret - ) - }.doOnSuccess { - Log.d(TAG, "Got payment intent from Signal service!") - } + try { + return stripeApi.confirmPaymentIntent(paymentSource, paymentIntent) + } catch (e: Exception) { + throw DonationError.getPaymentSetupError(donationErrorSource, e, paymentSource.type) + } } /** - * Creates the PaymentMethod via the Signal Service. Note that if the operation fails with a 409, - * it means that the PaymentMethod is already tied to a PayPal account. We can retry in this - * situation by simply deleting the old subscriber id on the service and replacing it. + * Creates and confirms a setup intent for a new subscription via the [StripeApi]. + * + * @return a required action, if necessary. This can be the case for some credit cards as well as iDEAL transactions. */ - private fun createPaymentMethod(subscriberType: InAppPaymentSubscriberRecord.Type, paymentSourceType: PaymentSourceType.Stripe, retryOn409: Boolean = true): Single { - return Single.fromCallable { InAppPaymentsRepository.requireSubscriber(subscriberType) } - .flatMap { - Single.fromCallable { - AppDependencies - .donationsService - .createStripeSubscriptionPaymentMethod(it.subscriberId, paymentSourceType.paymentMethod) - } - } - .flatMap { serviceResponse -> - if (retryOn409 && serviceResponse.status == 409) { - RecurringInAppPaymentRepository.rotateSubscriberId(subscriberType).andThen(createPaymentMethod(subscriberType, paymentSourceType, retryOn409 = false)) - } else { - serviceResponse.flattenResult() - } - } + @WorkerThread + fun createAndConfirmSetupIntent( + inAppPaymentType: InAppPaymentType, + paymentSource: PaymentSource, + paymentSourceType: PaymentSourceType.Stripe + ): StripeApi.Secure3DSAction { + Log.d(TAG, "Continuing subscription setup...", true) + val result: StripeApi.CreateSetupIntentResult = stripeApi.createSetupIntent(inAppPaymentType, paymentSourceType) + + Log.d(TAG, "Retrieved SetupIntent, confirming...", true) + return stripeApi.confirmSetupIntent(paymentSource, result.setupIntent) } - override fun fetchSetupIntent(inAppPaymentType: InAppPaymentType, sourceType: PaymentSourceType.Stripe): Single { + @WorkerThread + override fun fetchPaymentIntent(price: FiatMoney, level: Long, sourceType: PaymentSourceType.Stripe): StripeIntentAccessor { + Log.d(TAG, "Fetching payment intent from Signal service for $price... (Locale.US minimum precision: ${price.minimumUnitPrecisionString})") + val response = AppDependencies + .donationsService + .createDonationIntentWithAmount(price.minimumUnitPrecisionString, price.currency.currencyCode, level, sourceType.paymentMethod) + .resultOrThrow + + val accessor = StripeIntentAccessor( + objectType = StripeIntentAccessor.ObjectType.PAYMENT_INTENT, + intentId = response.id, + intentClientSecret = response.clientSecret + ) + + Log.d(TAG, "Got payment intent from Signal service!") + return accessor + } + + @WorkerThread + override fun fetchSetupIntent(inAppPaymentType: InAppPaymentType, sourceType: PaymentSourceType.Stripe): StripeIntentAccessor { Log.d(TAG, "Fetching setup intent from Signal service...") - return createPaymentMethod(inAppPaymentType.requireSubscriberType(), sourceType) - .map { - StripeIntentAccessor( - objectType = StripeIntentAccessor.ObjectType.SETUP_INTENT, - intentId = it.id, - intentClientSecret = it.clientSecret - ) - } - .doOnSuccess { - Log.d(TAG, "Got setup intent from Signal service!") - } + val clientSecret = createPaymentMethod(inAppPaymentType.requireSubscriberType(), sourceType) + val accessor = StripeIntentAccessor( + objectType = StripeIntentAccessor.ObjectType.SETUP_INTENT, + intentId = clientSecret.id, + intentClientSecret = clientSecret.clientSecret + ) + + Log.d(TAG, "Got setup intent from Signal service!") + + return accessor } /** @@ -195,57 +158,60 @@ class StripeRepository( * that we are successful and proceed as normal. If the payment didn't actually succeed, then we * expect an error later in the chain to inform us of this. */ + @WorkerThread fun getStatusAndPaymentMethodId( stripeIntentAccessor: StripeIntentAccessor, paymentMethodId: String? - ): Single { - return Single.fromCallable { - when (stripeIntentAccessor.objectType) { - StripeIntentAccessor.ObjectType.NONE -> StatusAndPaymentMethodId(stripeIntentAccessor.intentId, StripeIntentStatus.SUCCEEDED, paymentMethodId) - StripeIntentAccessor.ObjectType.PAYMENT_INTENT -> stripeApi.getPaymentIntent(stripeIntentAccessor).let { - if (it.status == null) { - Log.d(TAG, "Returned payment intent had a null status.", true) - } - StatusAndPaymentMethodId(stripeIntentAccessor.intentId, it.status ?: StripeIntentStatus.SUCCEEDED, it.paymentMethod) + ): StatusAndPaymentMethodId { + return when (stripeIntentAccessor.objectType) { + StripeIntentAccessor.ObjectType.NONE -> StatusAndPaymentMethodId(stripeIntentAccessor.intentId, StripeIntentStatus.SUCCEEDED, paymentMethodId) + StripeIntentAccessor.ObjectType.PAYMENT_INTENT -> stripeApi.getPaymentIntent(stripeIntentAccessor).let { + if (it.status == null) { + Log.d(TAG, "Returned payment intent had a null status.", true) } + StatusAndPaymentMethodId(stripeIntentAccessor.intentId, it.status ?: StripeIntentStatus.SUCCEEDED, it.paymentMethod) + } - StripeIntentAccessor.ObjectType.SETUP_INTENT -> stripeApi.getSetupIntent(stripeIntentAccessor).let { - StatusAndPaymentMethodId(stripeIntentAccessor.intentId, it.status, it.paymentMethodId) - } + StripeIntentAccessor.ObjectType.SETUP_INTENT -> stripeApi.getSetupIntent(stripeIntentAccessor).let { + StatusAndPaymentMethodId(stripeIntentAccessor.intentId, it.status, it.paymentMethodId) } } } + /** + * Sets the default payment method for the given subscriber type via Signal Service + * which will ensure that the user's subscription can be set up properly. + */ + @WorkerThread fun setDefaultPaymentMethod( paymentMethodId: String, setupIntentId: String, subscriberType: InAppPaymentSubscriberRecord.Type, paymentSourceType: PaymentSourceType - ): Completable { - return Single.fromCallable { - Log.d(TAG, "Getting the subscriber...") - InAppPaymentsRepository.requireSubscriber(subscriberType) - }.flatMapCompletable { subscriberRecord -> - Log.d(TAG, "Setting default payment method via Signal service...") - Single.fromCallable { - if (paymentSourceType == PaymentSourceType.Stripe.IDEAL) { - AppDependencies - .donationsService - .setDefaultIdealPaymentMethod(subscriberRecord.subscriberId, setupIntentId) - } else { - AppDependencies - .donationsService - .setDefaultStripePaymentMethod(subscriberRecord.subscriberId, paymentMethodId) - } - }.flatMap(ServiceResponse::flattenResult).ignoreElement().doOnComplete { - Log.d(TAG, "Set default payment method via Signal service!") - Log.d(TAG, "Storing the subscription payment source type locally.") - SignalDatabase.inAppPaymentSubscribers.setPaymentMethod(subscriberRecord.subscriberId, paymentSourceType.toPaymentMethodType()) - } - } + ) { + Log.d(TAG, "Getting the subscriber...") + val subscriber = InAppPaymentsRepository.requireSubscriber(subscriberType) + + Log.d(TAG, "Setting default payment method via Signal service...") + if (paymentSourceType == PaymentSourceType.Stripe.IDEAL) { + AppDependencies + .donationsService + .setDefaultIdealPaymentMethod(subscriber.subscriberId, setupIntentId) + } else { + AppDependencies + .donationsService + .setDefaultStripePaymentMethod(subscriber.subscriberId, paymentMethodId) + }.resultOrThrow + + Log.d(TAG, "Set default payment method via Signal service!") + Log.d(TAG, "Storing the subscription payment source type locally.") + SignalDatabase.inAppPaymentSubscribers.setPaymentMethod(subscriber.subscriberId, paymentSourceType.toPaymentMethodType()) } - fun createCreditCardPaymentSource(donationErrorSource: DonationErrorSource, cardData: StripeApi.CardData): Single { + /** + * Utilizes the StripeApi to create a [PaymentSource] for the given [StripeApi.CardData] + */ + fun createCreditCardPaymentSource(donationErrorSource: DonationErrorSource, cardData: StripeApi.CardData): Single { Log.d(TAG, "Creating credit card payment source via Stripe api...") return stripeApi.createPaymentSourceFromCardData(cardData).map { when (it) { @@ -255,23 +221,44 @@ class StripeRepository( } } - fun createSEPADebitPaymentSource(sepaDebitData: StripeApi.SEPADebitData): Single { + /** + * Utilizes the StripeApi to create a [PaymentSource] for the given [StripeApi.SEPADebitData] + */ + fun createSEPADebitPaymentSource(sepaDebitData: StripeApi.SEPADebitData): Single { Log.d(TAG, "Creating SEPA Debit payment source via Stripe api...") return stripeApi.createPaymentSourceFromSEPADebitData(sepaDebitData) } - fun createIdealPaymentSource(idealData: StripeApi.IDEALData): Single { + /** + * Utilizes the StripeApi to create a [PaymentSource] for the given [StripeApi.IDEALData] + */ + fun createIdealPaymentSource(idealData: StripeApi.IDEALData): Single { Log.d(TAG, "Creating iDEAL payment source via Stripe api...") return stripeApi.createPaymentSourceFromIDEALData(idealData) } + /** + * Creates the PaymentMethod via the Signal Service. Note that if the operation fails with a 409, + * it means that the PaymentMethod is already tied to a PayPal account. We can retry in this + * situation by simply deleting the old subscriber id on the service and replacing it. + */ + private fun createPaymentMethod(subscriberType: InAppPaymentSubscriberRecord.Type, paymentSourceType: PaymentSourceType.Stripe, retryOn409: Boolean = true): StripeClientSecret { + val subscriber = InAppPaymentsRepository.requireSubscriber(subscriberType) + val response = AppDependencies + .donationsService + .createStripeSubscriptionPaymentMethod(subscriber.subscriberId, paymentSourceType.paymentMethod) + + return if (retryOn409 && response.status == 409) { + RecurringInAppPaymentRepository.rotateSubscriberIdSync(subscriberType) + createPaymentMethod(subscriberType, paymentSourceType, retryOn409 = false) + } else { + response.resultOrThrow + } + } + data class StatusAndPaymentMethodId( val intentId: String, val status: StripeIntentStatus, val paymentMethod: String? ) - - companion object { - private val TAG = Log.tag(StripeRepository::class.java) - } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/CheckoutFlowActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/CheckoutFlowActivity.kt index f532da7c34..75712a087e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/CheckoutFlowActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/CheckoutFlowActivity.kt @@ -17,13 +17,13 @@ import org.signal.core.util.getParcelableExtraCompat import org.signal.core.util.getSerializableCompat import org.signal.donations.InAppPaymentType import org.thoughtcrime.securesms.components.FragmentWrapperActivity -import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentComponent -import org.thoughtcrime.securesms.components.settings.app.subscription.StripeRepository +import org.thoughtcrime.securesms.components.settings.app.subscription.GooglePayComponent +import org.thoughtcrime.securesms.components.settings.app.subscription.GooglePayRepository /** * Home base for all checkout flows. */ -class CheckoutFlowActivity : FragmentWrapperActivity(), InAppPaymentComponent { +class CheckoutFlowActivity : FragmentWrapperActivity(), GooglePayComponent { companion object { private const val ARG_IN_APP_PAYMENT_TYPE = "in_app_payment_type" @@ -34,8 +34,8 @@ class CheckoutFlowActivity : FragmentWrapperActivity(), InAppPaymentComponent { } } - override val stripeRepository: StripeRepository by lazy { StripeRepository(this) } - override val googlePayResultPublisher: Subject = PublishSubject.create() + override val googlePayRepository: GooglePayRepository by lazy { GooglePayRepository(this) } + override val googlePayResultPublisher: Subject = PublishSubject.create() private val inAppPaymentType: InAppPaymentType by lazy { intent.extras!!.getSerializableCompat(ARG_IN_APP_PAYMENT_TYPE, InAppPaymentType::class.java)!! @@ -48,7 +48,7 @@ class CheckoutFlowActivity : FragmentWrapperActivity(), InAppPaymentComponent { @Suppress("DEPRECATION") override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { super.onActivityResult(requestCode, resultCode, data) - googlePayResultPublisher.onNext(InAppPaymentComponent.GooglePayResult(requestCode, resultCode, data)) + googlePayResultPublisher.onNext(GooglePayComponent.GooglePayResult(requestCode, resultCode, data)) } class Contract : ActivityResultContract() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/InAppPaymentCheckoutDelegate.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/InAppPaymentCheckoutDelegate.kt index ac26f7ed46..e2940d79a1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/InAppPaymentCheckoutDelegate.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/InAppPaymentCheckoutDelegate.kt @@ -24,8 +24,8 @@ import org.signal.donations.GooglePayApi import org.signal.donations.InAppPaymentType import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toFiatMoney +import org.thoughtcrime.securesms.components.settings.app.subscription.GooglePayComponent import org.thoughtcrime.securesms.components.settings.app.subscription.InAppDonations -import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentComponent import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository.toPaymentSourceType import org.thoughtcrime.securesms.components.settings.app.subscription.donate.card.CreditCardFragment @@ -54,15 +54,12 @@ class InAppPaymentCheckoutDelegate( private val TAG = Log.tag(InAppPaymentCheckoutDelegate::class.java) } - private val inAppPaymentComponent: InAppPaymentComponent by lazy { fragment.requireListener() } + private val googlePayComponent: GooglePayComponent by lazy { fragment.requireListener() } private val disposables = LifecycleDisposable() private val viewModel: DonationCheckoutViewModel by fragment.viewModels() private val stripePaymentViewModel: StripePaymentInProgressViewModel by fragment.navGraphViewModels( - R.id.checkout_flow, - factoryProducer = { - StripePaymentInProgressViewModel.Factory(inAppPaymentComponent.stripeRepository) - } + R.id.checkout_flow ) init { @@ -158,7 +155,7 @@ class InAppPaymentCheckoutDelegate( private fun launchGooglePay(inAppPayment: InAppPaymentTable.InAppPayment) { viewModel.provideGatewayRequestForGooglePay(inAppPayment) - inAppPaymentComponent.stripeRepository.requestTokenFromGooglePay( + googlePayComponent.googlePayRepository.requestTokenFromGooglePay( price = inAppPayment.data.amount!!.toFiatMoney(), label = InAppDonations.resolveLabel(fragment.requireContext(), inAppPayment.type, inAppPayment.data.level), requestCode = InAppPaymentsRepository.getGooglePayRequestCode(inAppPayment.type) @@ -178,10 +175,10 @@ class InAppPaymentCheckoutDelegate( } private fun registerGooglePayCallback() { - disposables += inAppPaymentComponent.googlePayResultPublisher.subscribeBy( + disposables += googlePayComponent.googlePayResultPublisher.subscribeBy( onNext = { paymentResult -> viewModel.consumeGatewayRequestForGooglePay()?.let { - inAppPaymentComponent.stripeRepository.onActivityResult( + googlePayComponent.googlePayRepository.onActivityResult( paymentResult.requestCode, paymentResult.resultCode, paymentResult.data, diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/SharedInAppPaymentPipeline.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/SharedInAppPaymentPipeline.kt new file mode 100644 index 0000000000..b873ca3397 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/SharedInAppPaymentPipeline.kt @@ -0,0 +1,151 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.app.subscription.donate + +import androidx.annotation.CheckResult +import io.reactivex.rxjava3.core.Completable +import io.reactivex.rxjava3.core.Single +import org.signal.core.util.logging.Log +import org.signal.donations.InAppPaymentType +import org.signal.donations.PaymentSource +import org.signal.donations.PaymentSourceType +import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository +import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError +import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError.BadgeRedemptionError +import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource +import org.thoughtcrime.securesms.database.InAppPaymentTable +import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData +import org.thoughtcrime.securesms.dependencies.AppDependencies +import org.thoughtcrime.securesms.jobs.InAppPaymentPayPalOneTimeSetupJob +import org.thoughtcrime.securesms.jobs.InAppPaymentPayPalRecurringSetupJob +import org.thoughtcrime.securesms.jobs.InAppPaymentStripeOneTimeSetupJob +import org.thoughtcrime.securesms.jobs.InAppPaymentStripeRecurringSetupJob +import java.util.concurrent.TimeUnit + +/** + * Allows a fragment to display UI to the user to complete some action. When the action is completed, + * it is expected that the InAppPayment for the given id is in the [InAppPaymentTable.State.REQUIRED_ACTION_COMPLETED] + * state with the appropriate [InAppPaymentData] completion field set. + */ +typealias RequiredActionHandler = (InAppPaymentTable.InAppPaymentId) -> Completable + +/** + * Shared logic between paypal and stripe for initiating transactions and awaiting token + * redemption. + */ +object SharedInAppPaymentPipeline { + + private val TAG = Log.tag(SharedInAppPaymentPipeline::class) + + /** + * Awaits completion of the transaction with Stripe. + * + * This method will enqueue the proper setup job based off the type of [InAppPaymentTable.InAppPayment] and then + * await for either [InAppPaymentTable.State.PENDING], [InAppPaymentTable.State.REQUIRES_ACTION] or [InAppPaymentTable.State.END] + * before moving further, handling each state appropriately. + * + * @param requiredActionHandler Dispatch method for handling PayPal input, 3DS, iDEAL, etc. + */ + @CheckResult + fun awaitTransaction( + inAppPayment: InAppPaymentTable.InAppPayment, + paymentSource: PaymentSource, + requiredActionHandler: RequiredActionHandler + ): Completable { + return InAppPaymentsRepository.observeUpdates(inAppPayment.id) + .doOnSubscribe { + val job = if (inAppPayment.type.recurring) { + if (inAppPayment.data.paymentMethodType == InAppPaymentData.PaymentMethodType.PAYPAL) { + InAppPaymentPayPalRecurringSetupJob.create(inAppPayment, paymentSource) + } else { + InAppPaymentStripeRecurringSetupJob.create(inAppPayment, paymentSource) + } + } else { + if (inAppPayment.data.paymentMethodType == InAppPaymentData.PaymentMethodType.PAYPAL) { + InAppPaymentPayPalOneTimeSetupJob.create(inAppPayment, paymentSource) + } else { + InAppPaymentStripeOneTimeSetupJob.create(inAppPayment, paymentSource) + } + } + + AppDependencies.jobManager.add(job) + } + .skipWhile { it.state != InAppPaymentTable.State.PENDING && it.state != InAppPaymentTable.State.REQUIRES_ACTION && it.state != InAppPaymentTable.State.END } + .firstOrError() + .flatMapCompletable { iap -> + when (iap.state) { + InAppPaymentTable.State.PENDING -> { + Log.w(TAG, "Payment of type ${inAppPayment.type} is pending. Awaiting completion.") + awaitRedemption(iap, paymentSource.type) + } + + InAppPaymentTable.State.REQUIRES_ACTION -> { + Log.d(TAG, "Payment of type ${inAppPayment.type} requires user action to set up.", true) + requiredActionHandler(iap.id).andThen(awaitTransaction(iap, paymentSource, requiredActionHandler)) + } + + InAppPaymentTable.State.END -> { + if (iap.data.error != null) { + Log.d(TAG, "IAP error detected.", true) + Completable.error(InAppPaymentError(iap.data.error)) + } else { + Log.d(TAG, "Unexpected early end state. Possible payment failure.", true) + Completable.error(DonationError.genericPaymentFailure(DonationErrorSource.MONTHLY)) + } + } + + else -> error("Unexpected state ${iap.state}") + } + } + } + + /** + * Waits 10 seconds for the redemption to complete, and fails with a temporary error afterwards. + */ + @CheckResult + fun awaitRedemption(inAppPayment: InAppPaymentTable.InAppPayment, paymentSourceType: PaymentSourceType): Completable { + val isLongRunning = paymentSourceType.isBankTransfer + val errorSource = when (inAppPayment.type) { + InAppPaymentType.UNKNOWN -> error("Unsupported type UNKNOWN.") + InAppPaymentType.ONE_TIME_GIFT -> DonationErrorSource.GIFT + InAppPaymentType.ONE_TIME_DONATION -> DonationErrorSource.ONE_TIME + InAppPaymentType.RECURRING_DONATION -> DonationErrorSource.MONTHLY + InAppPaymentType.RECURRING_BACKUP -> error("Unsupported BACKUP.") + } + + val timeoutError = if (isLongRunning) { + BadgeRedemptionError.DonationPending(errorSource, inAppPayment) + } else { + BadgeRedemptionError.TimeoutWaitingForTokenError(errorSource) + } + + return Single.fromCallable { + Log.d(TAG, "Awaiting completion of redemption chain for up to 10 seconds.", true) + InAppPaymentsRepository.observeUpdates(inAppPayment.id).filter { + it.state == InAppPaymentTable.State.END + }.take(1).map { + if (it.data.error != null) { + Log.d(TAG, "Failure during redemption chain: ${it.data.error}", true) + throw InAppPaymentError(it.data.error) + } + it + }.firstOrError() + }.timeout(10, TimeUnit.SECONDS, Single.error(timeoutError)).ignoreElement() + } + + /** + * Generic error handling for donations. + */ + fun handleError( + throwable: Throwable, + inAppPaymentId: InAppPaymentTable.InAppPaymentId, + paymentSourceType: PaymentSourceType, + donationErrorSource: DonationErrorSource + ) { + Log.w(TAG, "Failure in $donationErrorSource payment pipeline...", throwable, true) + InAppPaymentsRepository.handlePipelineError(inAppPaymentId, donationErrorSource, paymentSourceType, throwable) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/card/CreditCardFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/card/CreditCardFragment.kt index a999bdeb61..ba2409de48 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/card/CreditCardFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/card/CreditCardFragment.kt @@ -21,7 +21,6 @@ import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.components.TemporaryScreenshotSecurity import org.thoughtcrime.securesms.components.ViewBinderDelegate import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toFiatMoney -import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentComponent import org.thoughtcrime.securesms.components.settings.app.subscription.donate.InAppPaymentCheckoutDelegate import org.thoughtcrime.securesms.components.settings.app.subscription.donate.InAppPaymentProcessorAction import org.thoughtcrime.securesms.components.settings.app.subscription.donate.InAppPaymentProcessorActionResult @@ -30,7 +29,6 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.donate.st import org.thoughtcrime.securesms.databinding.CreditCardFragmentBinding import org.thoughtcrime.securesms.payments.FiatMoneyUtil import org.thoughtcrime.securesms.util.ViewUtil -import org.thoughtcrime.securesms.util.fragments.requireListener import org.thoughtcrime.securesms.util.navigation.safeNavigate class CreditCardFragment : Fragment(R.layout.credit_card_fragment) { @@ -40,10 +38,7 @@ class CreditCardFragment : Fragment(R.layout.credit_card_fragment) { private val viewModel: CreditCardViewModel by viewModels() private val lifecycleDisposable = LifecycleDisposable() private val stripePaymentViewModel: StripePaymentInProgressViewModel by navGraphViewModels( - R.id.checkout_flow, - factoryProducer = { - StripePaymentInProgressViewModel.Factory(requireListener().stripeRepository) - } + R.id.checkout_flow ) override fun onViewCreated(view: View, savedInstanceState: Bundle?) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewaySelectorBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewaySelectorBottomSheet.kt index cdec689053..870cb8290d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewaySelectorBottomSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewaySelectorBottomSheet.kt @@ -21,7 +21,7 @@ import org.thoughtcrime.securesms.components.settings.DSLSettingsIcon import org.thoughtcrime.securesms.components.settings.DSLSettingsText import org.thoughtcrime.securesms.components.settings.NO_TINT import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toFiatMoney -import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentComponent +import org.thoughtcrime.securesms.components.settings.app.subscription.GooglePayComponent import org.thoughtcrime.securesms.components.settings.app.subscription.models.GooglePayButton import org.thoughtcrime.securesms.components.settings.app.subscription.models.PayPalButton import org.thoughtcrime.securesms.components.settings.configure @@ -42,7 +42,7 @@ class GatewaySelectorBottomSheet : DSLSettingsBottomSheetFragment() { private val args: GatewaySelectorBottomSheetArgs by navArgs() private val viewModel: GatewaySelectorViewModel by viewModels(factoryProducer = { - GatewaySelectorViewModel.Factory(args, requireListener().stripeRepository) + GatewaySelectorViewModel.Factory(args, requireListener().googlePayRepository) }) override fun bindAdapter(adapter: DSLSettingsAdapter) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewaySelectorViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewaySelectorViewModel.kt index 8d17a5431b..d0815514f4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewaySelectorViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewaySelectorViewModel.kt @@ -8,8 +8,8 @@ import io.reactivex.rxjava3.disposables.CompositeDisposable import io.reactivex.rxjava3.kotlin.plusAssign import io.reactivex.rxjava3.kotlin.subscribeBy import org.signal.donations.PaymentSourceType +import org.thoughtcrime.securesms.components.settings.app.subscription.GooglePayRepository import org.thoughtcrime.securesms.components.settings.app.subscription.InAppDonations -import org.thoughtcrime.securesms.components.settings.app.subscription.StripeRepository import org.thoughtcrime.securesms.database.InAppPaymentTable import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData import org.thoughtcrime.securesms.dependencies.AppDependencies @@ -18,7 +18,7 @@ import org.thoughtcrime.securesms.util.rx.RxStore class GatewaySelectorViewModel( args: GatewaySelectorBottomSheetArgs, - repository: StripeRepository, + repository: GooglePayRepository, private val gatewaySelectorRepository: GatewaySelectorRepository ) : ViewModel() { @@ -68,7 +68,7 @@ class GatewaySelectorViewModel( class Factory( private val args: GatewaySelectorBottomSheetArgs, - private val repository: StripeRepository, + private val repository: GooglePayRepository, private val gatewaySelectorRepository: GatewaySelectorRepository = GatewaySelectorRepository(AppDependencies.donationsService) ) : ViewModelProvider.Factory { override fun create(modelClass: Class): T { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/paypal/PayPalPaymentInProgressFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/paypal/PayPalPaymentInProgressFragment.kt index 25d3a2a9f1..d300cab5ef 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/paypal/PayPalPaymentInProgressFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/paypal/PayPalPaymentInProgressFragment.kt @@ -14,6 +14,7 @@ import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.navArgs import androidx.navigation.navGraphViewModels import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.core.Completable import io.reactivex.rxjava3.core.Single import io.reactivex.rxjava3.kotlin.subscribeBy import io.reactivex.rxjava3.schedulers.Schedulers @@ -29,6 +30,9 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.donate.In import org.thoughtcrime.securesms.components.settings.app.subscription.donate.InAppPaymentProcessorActionResult import org.thoughtcrime.securesms.components.settings.app.subscription.donate.InAppPaymentProcessorStage import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError +import org.thoughtcrime.securesms.database.InAppPaymentTable +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData import org.thoughtcrime.securesms.databinding.DonationInProgressFragmentBinding import org.thoughtcrime.securesms.util.navigation.safeNavigate import org.whispersystems.signalservice.api.subscriptions.PayPalCreatePaymentIntentResponse @@ -62,7 +66,14 @@ class PayPalPaymentInProgressFragment : DialogFragment(R.layout.donation_in_prog viewModel.onBeginNewAction() when (args.action) { InAppPaymentProcessorAction.PROCESS_NEW_IN_APP_PAYMENT -> { - viewModel.processNewDonation(args.inAppPayment!!, this::oneTimeConfirmationPipeline, this::monthlyConfirmationPipeline) + viewModel.processNewDonation( + args.inAppPayment!!, + if (args.inAppPaymentType.recurring) { + this::monthlyConfirmationPipeline + } else { + this::oneTimeConfirmationPipeline + } + ) } InAppPaymentProcessorAction.UPDATE_SUBSCRIPTION -> { @@ -129,12 +140,66 @@ class PayPalPaymentInProgressFragment : DialogFragment(R.layout.donation_in_prog } } - private fun oneTimeConfirmationPipeline(createPaymentIntentResponse: PayPalCreatePaymentIntentResponse): Single { - return routeToOneTimeConfirmation(createPaymentIntentResponse) + private fun oneTimeConfirmationPipeline(inAppPaymentId: InAppPaymentTable.InAppPaymentId): Completable { + return Single.fromCallable { + SignalDatabase.inAppPayments.getById(inAppPaymentId)!! + }.map { inAppPayment -> + val requiresAction: InAppPaymentData.PayPalRequiresActionState = inAppPayment.data.payPalRequiresAction ?: error("InAppPayment is missing requiresAction data") + PayPalCreatePaymentIntentResponse( + requiresAction.approvalUrl, + requiresAction.token + ) + }.flatMap { + routeToOneTimeConfirmation(it) + }.flatMapCompletable { + Completable.fromAction { + Log.d(TAG, "User confirmed action. Updating intent accessors and resubmitting job.") + val postNextActionPayment = SignalDatabase.inAppPayments.getById(inAppPaymentId)!! + + SignalDatabase.inAppPayments.update( + postNextActionPayment.copy( + state = InAppPaymentTable.State.REQUIRED_ACTION_COMPLETED, + data = postNextActionPayment.data.newBuilder().payPalActionComplete( + payPalActionComplete = InAppPaymentData.PayPalActionCompleteState( + paymentId = it.paymentId ?: "", + paymentToken = it.paymentToken, + payerId = it.payerId + ) + ).build() + ) + ) + } + } } - private fun monthlyConfirmationPipeline(createPaymentIntentResponse: PayPalCreatePaymentMethodResponse): Single { - return routeToMonthlyConfirmation(createPaymentIntentResponse) + private fun monthlyConfirmationPipeline(inAppPaymentId: InAppPaymentTable.InAppPaymentId): Completable { + return Single.fromCallable { + SignalDatabase.inAppPayments.getById(inAppPaymentId)!! + }.map { inAppPayment -> + val requiresAction: InAppPaymentData.PayPalRequiresActionState = inAppPayment.data.payPalRequiresAction ?: error("InAppPayment is missing requiresAction data") + PayPalCreatePaymentMethodResponse( + requiresAction.approvalUrl, + requiresAction.token + ) + }.flatMap { + routeToMonthlyConfirmation(it) + }.flatMapCompletable { + Completable.fromAction { + Log.d(TAG, "User confirmed action. Updating intent accessors and resubmitting job.") + val postNextActionPayment = SignalDatabase.inAppPayments.getById(inAppPaymentId)!! + + SignalDatabase.inAppPayments.update( + postNextActionPayment.copy( + state = InAppPaymentTable.State.REQUIRED_ACTION_COMPLETED, + data = postNextActionPayment.data.newBuilder().payPalActionComplete( + payPalActionComplete = InAppPaymentData.PayPalActionCompleteState( + paymentId = it.paymentId + ) + ).build() + ) + ) + } + } } private fun routeToOneTimeConfirmation(createPaymentIntentResponse: PayPalCreatePaymentIntentResponse): Single { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/paypal/PayPalPaymentInProgressViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/paypal/PayPalPaymentInProgressViewModel.kt index 9ca5bb15e7..94359f2119 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/paypal/PayPalPaymentInProgressViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/paypal/PayPalPaymentInProgressViewModel.kt @@ -3,36 +3,32 @@ package org.thoughtcrime.securesms.components.settings.app.subscription.donate.p import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers -import io.reactivex.rxjava3.core.Completable import io.reactivex.rxjava3.core.Flowable -import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.core.SingleSource import io.reactivex.rxjava3.disposables.CompositeDisposable import io.reactivex.rxjava3.kotlin.plusAssign import io.reactivex.rxjava3.kotlin.subscribeBy import io.reactivex.rxjava3.schedulers.Schedulers import org.signal.core.util.logging.Log -import org.signal.donations.InAppPaymentType +import org.signal.donations.PayPalPaymentSource import org.signal.donations.PaymentSourceType -import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toFiatMoney import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository.requireSubscriberType +import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository.toErrorSource import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository.toPaymentSourceType -import org.thoughtcrime.securesms.components.settings.app.subscription.OneTimeInAppPaymentRepository import org.thoughtcrime.securesms.components.settings.app.subscription.PayPalRepository import org.thoughtcrime.securesms.components.settings.app.subscription.RecurringInAppPaymentRepository import org.thoughtcrime.securesms.components.settings.app.subscription.donate.InAppPaymentProcessorStage -import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError +import org.thoughtcrime.securesms.components.settings.app.subscription.donate.RequiredActionHandler +import org.thoughtcrime.securesms.components.settings.app.subscription.donate.SharedInAppPaymentPipeline import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource import org.thoughtcrime.securesms.database.InAppPaymentTable +import org.thoughtcrime.securesms.database.SignalDatabase import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord import org.thoughtcrime.securesms.dependencies.AppDependencies import org.thoughtcrime.securesms.jobs.MultiDeviceSubscriptionSyncRequestJob import org.thoughtcrime.securesms.keyvalue.SignalStore -import org.thoughtcrime.securesms.recipients.Recipient -import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.util.rx.RxStore -import org.whispersystems.signalservice.api.subscriptions.PayPalCreatePaymentIntentResponse -import org.whispersystems.signalservice.api.subscriptions.PayPalCreatePaymentMethodResponse import org.whispersystems.signalservice.api.util.Preconditions class PayPalPaymentInProgressViewModel( @@ -67,36 +63,28 @@ class PayPalPaymentInProgressViewModel( disposables.clear() } - fun processNewDonation( - inAppPayment: InAppPaymentTable.InAppPayment, - routeToOneTimeConfirmation: (PayPalCreatePaymentIntentResponse) -> Single, - routeToMonthlyConfirmation: (PayPalCreatePaymentMethodResponse) -> Single - ) { - Log.d(TAG, "Proceeding with donation...", true) - - return if (inAppPayment.type.recurring) { - proceedMonthly(inAppPayment, routeToMonthlyConfirmation) - } else { - proceedOneTime(inAppPayment, routeToOneTimeConfirmation) - } - } - fun updateSubscription(inAppPayment: InAppPaymentTable.InAppPayment) { Log.d(TAG, "Beginning subscription update...", true) store.update { InAppPaymentProcessorStage.PAYMENT_PIPELINE } - disposables += RecurringInAppPaymentRepository.cancelActiveSubscriptionIfNecessary(inAppPayment.type.requireSubscriberType()).andThen(RecurringInAppPaymentRepository.setSubscriptionLevel(inAppPayment, PaymentSourceType.PayPal)) - .subscribeBy( - onComplete = { - Log.w(TAG, "Completed subscription update", true) - store.update { InAppPaymentProcessorStage.COMPLETE } - }, - onError = { throwable -> - Log.w(TAG, "Failed to update subscription", throwable, true) - store.update { InAppPaymentProcessorStage.FAILED } - InAppPaymentsRepository.handlePipelineError(inAppPayment.id, DonationErrorSource.MONTHLY, PaymentSourceType.PayPal, throwable) - } - ) + disposables += RecurringInAppPaymentRepository.cancelActiveSubscriptionIfNecessary(inAppPayment.type.requireSubscriberType()).andThen( + SingleSource { + val freshPayment = SignalDatabase.inAppPayments.moveToTransacting(inAppPayment.id)!! + RecurringInAppPaymentRepository.setSubscriptionLevelSync(freshPayment) + } + ).flatMapCompletable { + SharedInAppPaymentPipeline.awaitRedemption(it, PaymentSourceType.PayPal) + }.subscribeBy( + onComplete = { + Log.w(TAG, "Completed subscription update", true) + store.update { InAppPaymentProcessorStage.COMPLETE } + }, + onError = { throwable -> + Log.w(TAG, "Failed to update subscription", throwable, true) + store.update { InAppPaymentProcessorStage.FAILED } + InAppPaymentsRepository.handlePipelineError(inAppPayment.id, DonationErrorSource.MONTHLY, PaymentSourceType.PayPal, throwable) + } + ) } fun cancelSubscription(subscriberType: InAppPaymentSubscriberRecord.Type) { @@ -118,79 +106,27 @@ class PayPalPaymentInProgressViewModel( ) } - private fun proceedOneTime( - inAppPayment: InAppPaymentTable.InAppPayment, - routeToPaypalConfirmation: (PayPalCreatePaymentIntentResponse) -> Single - ) { + fun processNewDonation(inAppPayment: InAppPaymentTable.InAppPayment, requiredActionHandler: RequiredActionHandler) { + Log.d(TAG, "Proceeding with InAppPayment::${inAppPayment.id} of type ${inAppPayment.type}...", true) + check(inAppPayment.data.paymentMethodType.toPaymentSourceType() == PaymentSourceType.PayPal) - Log.d(TAG, "Proceeding with one-time payment pipeline...", true) store.update { InAppPaymentProcessorStage.PAYMENT_PIPELINE } - val verifyUser = if (inAppPayment.type == InAppPaymentType.ONE_TIME_GIFT) { - OneTimeInAppPaymentRepository.verifyRecipientIsAllowedToReceiveAGift(RecipientId.from(inAppPayment.data.recipientId!!)) - } else { - Completable.complete() - } - disposables += verifyUser - .andThen( - payPalRepository - .createOneTimePaymentIntent( - amount = inAppPayment.data.amount!!.toFiatMoney(), - badgeRecipient = inAppPayment.data.recipientId?.let { RecipientId.from(it) } ?: Recipient.self().id, - badgeLevel = inAppPayment.data.level - ) - ) - .flatMap(routeToPaypalConfirmation) - .flatMap { result -> - payPalRepository.confirmOneTimePaymentIntent( - amount = inAppPayment.data.amount.toFiatMoney(), - badgeLevel = inAppPayment.data.level, - paypalConfirmationResult = result - ) + disposables += SharedInAppPaymentPipeline.awaitTransaction( + inAppPayment, + PayPalPaymentSource(), + requiredActionHandler + ).subscribeOn(Schedulers.io()).subscribeBy( + onComplete = { + Log.d(TAG, "Finished ${inAppPayment.type} payment pipeline...", true) + store.update { InAppPaymentProcessorStage.COMPLETE } + }, + onError = { + store.update { InAppPaymentProcessorStage.FAILED } + SharedInAppPaymentPipeline.handleError(it, inAppPayment.id, PaymentSourceType.PayPal, inAppPayment.type.toErrorSource()) } - .flatMapCompletable { response -> - OneTimeInAppPaymentRepository.waitForOneTimeRedemption( - inAppPayment = inAppPayment, - paymentIntentId = response.paymentId - ) - } - .subscribeOn(Schedulers.io()) - .subscribeBy( - onError = { throwable -> - Log.w(TAG, "Failure in one-time payment pipeline...", throwable, true) - store.update { InAppPaymentProcessorStage.FAILED } - InAppPaymentsRepository.handlePipelineError(inAppPayment.id, DonationErrorSource.ONE_TIME, PaymentSourceType.PayPal, throwable) - }, - onComplete = { - Log.d(TAG, "Finished one-time payment pipeline...", true) - store.update { InAppPaymentProcessorStage.COMPLETE } - } - ) - } - - private fun proceedMonthly(inAppPayment: InAppPaymentTable.InAppPayment, routeToPaypalConfirmation: (PayPalCreatePaymentMethodResponse) -> Single) { - Log.d(TAG, "Proceeding with monthly payment pipeline for InAppPayment::${inAppPayment.id} of type ${inAppPayment.type}...", true) - - val setup = RecurringInAppPaymentRepository.ensureSubscriberId(inAppPayment.type.requireSubscriberType()) - .andThen(RecurringInAppPaymentRepository.cancelActiveSubscriptionIfNecessary(inAppPayment.type.requireSubscriberType())) - .andThen(payPalRepository.createPaymentMethod(inAppPayment.type.requireSubscriberType())) - .flatMap(routeToPaypalConfirmation) - .flatMapCompletable { payPalRepository.setDefaultPaymentMethod(inAppPayment.type.requireSubscriberType(), it.paymentId) } - .onErrorResumeNext { Completable.error(DonationError.getPaymentSetupError(DonationErrorSource.MONTHLY, it, PaymentSourceType.PayPal)) } - - disposables += setup.andThen(RecurringInAppPaymentRepository.setSubscriptionLevel(inAppPayment, PaymentSourceType.PayPal)) - .subscribeBy( - onError = { throwable -> - Log.w(TAG, "Failure in monthly payment pipeline...", throwable, true) - store.update { InAppPaymentProcessorStage.FAILED } - InAppPaymentsRepository.handlePipelineError(inAppPayment.id, DonationErrorSource.MONTHLY, PaymentSourceType.PayPal, throwable) - }, - onComplete = { - Log.d(TAG, "Finished subscription payment pipeline...", true) - store.update { InAppPaymentProcessorStage.COMPLETE } - } - ) + ) } class Factory( diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/Stripe3DSDialogFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/Stripe3DSDialogFragment.kt index 8fbe6e1778..7f208a93be 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/Stripe3DSDialogFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/Stripe3DSDialogFragment.kt @@ -110,6 +110,10 @@ class Stripe3DSDialogFragment : DialogFragment(R.layout.donation_webview_fragmen } private fun handleLaunchExternal(intent: Intent) { + if (isDetached) { + return + } + startActivity(intent) SignalExecutors.BOUNDED_IO.execute { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/StripeNextActionHandler.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/StripeNextActionHandler.kt deleted file mode 100644 index d670ae2d08..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/StripeNextActionHandler.kt +++ /dev/null @@ -1,18 +0,0 @@ -/* - * Copyright 2023 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe - -import io.reactivex.rxjava3.core.Single -import org.signal.donations.StripeApi -import org.signal.donations.StripeIntentAccessor -import org.thoughtcrime.securesms.database.InAppPaymentTable - -fun interface StripeNextActionHandler { - fun handle( - action: StripeApi.Secure3DSAction, - inAppPayment: InAppPaymentTable.InAppPayment - ): Single -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/StripePaymentInProgressFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/StripePaymentInProgressFragment.kt index cd9441f124..21fa7a328d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/StripePaymentInProgressFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/StripePaymentInProgressFragment.kt @@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.components.settings.app.subscription.donate.s import android.app.Dialog import android.graphics.Color import android.graphics.drawable.ColorDrawable +import android.net.Uri import android.os.Bundle import android.view.View import androidx.core.os.bundleOf @@ -13,6 +14,7 @@ import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.navArgs import androidx.navigation.navGraphViewModels import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.core.Completable import io.reactivex.rxjava3.core.Single import io.reactivex.rxjava3.kotlin.subscribeBy import io.reactivex.rxjava3.schedulers.Schedulers @@ -24,7 +26,7 @@ import org.signal.donations.StripeApi import org.signal.donations.StripeIntentAccessor import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.components.ViewBinderDelegate -import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentComponent +import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository.requireSubscriberType import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository.toErrorSource import org.thoughtcrime.securesms.components.settings.app.subscription.donate.InAppPaymentProcessorAction @@ -32,8 +34,9 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.donate.In import org.thoughtcrime.securesms.components.settings.app.subscription.donate.InAppPaymentProcessorStage import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError import org.thoughtcrime.securesms.database.InAppPaymentTable +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData import org.thoughtcrime.securesms.databinding.DonationInProgressFragmentBinding -import org.thoughtcrime.securesms.util.fragments.requireListener import org.thoughtcrime.securesms.util.navigation.safeNavigate class StripePaymentInProgressFragment : DialogFragment(R.layout.donation_in_progress_fragment) { @@ -49,10 +52,7 @@ class StripePaymentInProgressFragment : DialogFragment(R.layout.donation_in_prog private val disposables = LifecycleDisposable() private val viewModel: StripePaymentInProgressViewModel by navGraphViewModels( - R.id.checkout_flow, - factoryProducer = { - StripePaymentInProgressViewModel.Factory(requireListener().stripeRepository) - } + R.id.checkout_flow ) override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { @@ -67,7 +67,7 @@ class StripePaymentInProgressFragment : DialogFragment(R.layout.donation_in_prog viewModel.onBeginNewAction() when (args.action) { InAppPaymentProcessorAction.PROCESS_NEW_IN_APP_PAYMENT -> { - viewModel.processNewDonation(args.inAppPayment!!, this::handleSecure3dsAction) + viewModel.processNewDonation(args.inAppPayment!!, this::handleRequiredAction) } InAppPaymentProcessorAction.UPDATE_SUBSCRIPTION -> { @@ -133,38 +133,91 @@ class StripePaymentInProgressFragment : DialogFragment(R.layout.donation_in_prog getString(R.string.InAppPaymentInProgressFragment__processing_donation) } } - private fun handleSecure3dsAction(secure3dsAction: StripeApi.Secure3DSAction, inAppPayment: InAppPaymentTable.InAppPayment): Single { - return when (secure3dsAction) { - is StripeApi.Secure3DSAction.NotNeeded -> { - Log.d(TAG, "No 3DS action required.") - Single.just(StripeIntentAccessor.NO_ACTION_REQUIRED) - } - is StripeApi.Secure3DSAction.ConfirmRequired -> { - Log.d(TAG, "3DS action required. Displaying dialog...") - Single.create { emitter -> - val listener = FragmentResultListener { _, bundle -> - val result: StripeIntentAccessor? = bundle.getParcelableCompat(Stripe3DSDialogFragment.REQUEST_KEY, StripeIntentAccessor::class.java) - if (result != null) { - emitter.onSuccess(result) + private fun handleRequiredAction(inAppPaymentId: InAppPaymentTable.InAppPaymentId): Completable { + return Single.fromCallable { + SignalDatabase.inAppPayments.getById(inAppPaymentId)!! + }.map { inAppPayment -> + val requiresAction: InAppPaymentData.StripeRequiresActionState = inAppPayment.data.stripeRequiresAction ?: error("REQUIRES_ACTION without action data") + inAppPayment to StripeApi.Secure3DSAction.from( + uri = Uri.parse(requiresAction.uri), + returnUri = Uri.parse(requiresAction.returnUri), + stripeIntentAccessor = StripeIntentAccessor( + objectType = if (inAppPayment.type.recurring) { + StripeIntentAccessor.ObjectType.SETUP_INTENT + } else { + StripeIntentAccessor.ObjectType.PAYMENT_INTENT + }, + intentId = requiresAction.stripeIntentId, + intentClientSecret = requiresAction.stripeClientSecret + ) + ) + }.flatMap { (originalPayment, secure3dsAction) -> + when (secure3dsAction) { + is StripeApi.Secure3DSAction.NotNeeded -> { + Log.d(TAG, "No 3DS action required.") + Single.just(StripeIntentAccessor.NO_ACTION_REQUIRED) + } + + is StripeApi.Secure3DSAction.ConfirmRequired -> { + Log.d(TAG, "3DS action required. Displaying dialog...") + + val waitingForAuthPayment = originalPayment.copy( + subscriberId = if (originalPayment.type.recurring) { + InAppPaymentsRepository.requireSubscriber(originalPayment.type.requireSubscriberType()).subscriberId } else { - val didLaunchExternal = bundle.getBoolean(Stripe3DSDialogFragment.LAUNCHED_EXTERNAL, false) - if (didLaunchExternal) { - emitter.onError(DonationError.UserLaunchedExternalApplication(args.inAppPaymentType.toErrorSource())) + null + }, + state = InAppPaymentTable.State.WAITING_FOR_AUTHORIZATION, + data = originalPayment.data.newBuilder().waitForAuth( + waitForAuth = InAppPaymentData.WaitingForAuthorizationState( + stripeIntentId = secure3dsAction.stripeIntentAccessor.intentId, + stripeClientSecret = secure3dsAction.stripeIntentAccessor.intentClientSecret + ) + ).build() + ) + + Single.create { emitter -> + val listener = FragmentResultListener { _, bundle -> + val result: StripeIntentAccessor? = bundle.getParcelableCompat(Stripe3DSDialogFragment.REQUEST_KEY, StripeIntentAccessor::class.java) + if (result != null) { + emitter.onSuccess(result) } else { - emitter.onError(DonationError.UserCancelledPaymentError(args.inAppPaymentType.toErrorSource())) + val didLaunchExternal = bundle.getBoolean(Stripe3DSDialogFragment.LAUNCHED_EXTERNAL, false) + if (didLaunchExternal) { + emitter.onError(DonationError.UserLaunchedExternalApplication(args.inAppPaymentType.toErrorSource())) + } else { + emitter.onError(DonationError.UserCancelledPaymentError(args.inAppPaymentType.toErrorSource())) + } } } - } - parentFragmentManager.setFragmentResultListener(Stripe3DSDialogFragment.REQUEST_KEY, this, listener) + parentFragmentManager.setFragmentResultListener(Stripe3DSDialogFragment.REQUEST_KEY, this, listener) - findNavController().safeNavigate(StripePaymentInProgressFragmentDirections.actionStripePaymentInProgressFragmentToStripe3dsDialogFragment(secure3dsAction.uri, secure3dsAction.returnUri, inAppPayment)) + findNavController().safeNavigate(StripePaymentInProgressFragmentDirections.actionStripePaymentInProgressFragmentToStripe3dsDialogFragment(secure3dsAction.uri, secure3dsAction.returnUri, waitingForAuthPayment)) - emitter.setCancellable { - parentFragmentManager.clearFragmentResultListener(Stripe3DSDialogFragment.REQUEST_KEY) - } - }.subscribeOn(AndroidSchedulers.mainThread()).observeOn(Schedulers.io()) + emitter.setCancellable { + parentFragmentManager.clearFragmentResultListener(Stripe3DSDialogFragment.REQUEST_KEY) + } + }.subscribeOn(AndroidSchedulers.mainThread()).observeOn(Schedulers.io()) + } + } + }.flatMapCompletable { stripeIntentAccessor -> + Completable.fromAction { + Log.d(TAG, "User confirmed action. Updating intent accessors and resubmitting job.") + val postNextActionPayment = SignalDatabase.inAppPayments.getById(inAppPaymentId)!! + + SignalDatabase.inAppPayments.update( + postNextActionPayment.copy( + state = InAppPaymentTable.State.REQUIRED_ACTION_COMPLETED, + data = postNextActionPayment.data.newBuilder().stripeActionComplete( + stripeActionComplete = InAppPaymentData.StripeActionCompleteState( + stripeIntentId = stripeIntentAccessor.intentId, + stripeClientSecret = stripeIntentAccessor.intentClientSecret + ) + ).build() + ) + ) } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/StripePaymentInProgressViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/StripePaymentInProgressViewModel.kt index ee1ece7223..04d7f52515 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/StripePaymentInProgressViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/StripePaymentInProgressViewModel.kt @@ -1,46 +1,37 @@ package org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider import com.google.android.gms.wallet.PaymentData import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers -import io.reactivex.rxjava3.core.Completable import io.reactivex.rxjava3.core.Flowable import io.reactivex.rxjava3.core.Single import io.reactivex.rxjava3.disposables.CompositeDisposable import io.reactivex.rxjava3.kotlin.plusAssign import io.reactivex.rxjava3.kotlin.subscribeBy +import io.reactivex.rxjava3.schedulers.Schedulers import org.signal.core.util.concurrent.SignalExecutors import org.signal.core.util.logging.Log import org.signal.donations.GooglePayPaymentSource -import org.signal.donations.InAppPaymentType +import org.signal.donations.PaymentSource import org.signal.donations.PaymentSourceType import org.signal.donations.StripeApi -import org.signal.donations.StripeIntentAccessor -import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toFiatMoney import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository.requireSubscriberType import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository.toErrorSource import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository.toPaymentSourceType -import org.thoughtcrime.securesms.components.settings.app.subscription.OneTimeInAppPaymentRepository import org.thoughtcrime.securesms.components.settings.app.subscription.RecurringInAppPaymentRepository import org.thoughtcrime.securesms.components.settings.app.subscription.StripeRepository import org.thoughtcrime.securesms.components.settings.app.subscription.donate.InAppPaymentProcessorStage -import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError +import org.thoughtcrime.securesms.components.settings.app.subscription.donate.RequiredActionHandler +import org.thoughtcrime.securesms.components.settings.app.subscription.donate.SharedInAppPaymentPipeline import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource -import org.thoughtcrime.securesms.components.settings.app.subscription.errors.toDonationError import org.thoughtcrime.securesms.database.InAppPaymentTable +import org.thoughtcrime.securesms.database.SignalDatabase import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord -import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData -import org.thoughtcrime.securesms.recipients.Recipient -import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.util.rx.RxStore import org.whispersystems.signalservice.api.util.Preconditions -import org.whispersystems.signalservice.internal.push.exceptions.InAppPaymentProcessorError -class StripePaymentInProgressViewModel( - private val stripeRepository: StripeRepository -) : ViewModel() { +class StripePaymentInProgressViewModel : ViewModel() { companion object { private val TAG = Log.tag(StripePaymentInProgressViewModel::class.java) @@ -73,38 +64,53 @@ class StripePaymentInProgressViewModel( disposables.clear() } - fun processNewDonation(inAppPayment: InAppPaymentTable.InAppPayment, nextActionHandler: StripeNextActionHandler) { + fun processNewDonation(inAppPayment: InAppPaymentTable.InAppPayment, requiredActionHandler: RequiredActionHandler) { Log.d(TAG, "Proceeding with InAppPayment::${inAppPayment.id} of type ${inAppPayment.type}...", true) val paymentSourceProvider: PaymentSourceProvider = resolvePaymentSourceProvider(inAppPayment.type.toErrorSource()) - return if (inAppPayment.type.recurring) { - proceedMonthly(inAppPayment, paymentSourceProvider, nextActionHandler) - } else { - proceedOneTime(inAppPayment, paymentSourceProvider, nextActionHandler) - } + check(inAppPayment.data.paymentMethodType.toPaymentSourceType() == paymentSourceProvider.paymentSourceType) + + store.update { InAppPaymentProcessorStage.PAYMENT_PIPELINE } + + disposables += paymentSourceProvider.paymentSource.flatMapCompletable { paymentSource -> + SharedInAppPaymentPipeline.awaitTransaction( + inAppPayment, + paymentSource, + requiredActionHandler + ) + }.subscribeOn(Schedulers.io()).subscribeBy( + onComplete = { + Log.d(TAG, "Finished ${inAppPayment.type} payment pipeline...", true) + store.update { InAppPaymentProcessorStage.COMPLETE } + }, + onError = { + store.update { InAppPaymentProcessorStage.FAILED } + SharedInAppPaymentPipeline.handleError(it, inAppPayment.id, paymentSourceProvider.paymentSourceType, inAppPayment.type.toErrorSource()) + } + ) } private fun resolvePaymentSourceProvider(errorSource: DonationErrorSource): PaymentSourceProvider { return when (val data = stripePaymentData) { is StripePaymentData.GooglePay -> PaymentSourceProvider( PaymentSourceType.Stripe.GooglePay, - Single.just(GooglePayPaymentSource(data.paymentData)).doAfterTerminate { clearPaymentInformation() } + Single.just(GooglePayPaymentSource(data.paymentData)).doAfterTerminate { clearPaymentInformation() } ) is StripePaymentData.CreditCard -> PaymentSourceProvider( PaymentSourceType.Stripe.CreditCard, - stripeRepository.createCreditCardPaymentSource(errorSource, data.cardData).doAfterTerminate { clearPaymentInformation() } + StripeRepository.createCreditCardPaymentSource(errorSource, data.cardData).doAfterTerminate { clearPaymentInformation() } ) is StripePaymentData.SEPADebit -> PaymentSourceProvider( PaymentSourceType.Stripe.SEPADebit, - stripeRepository.createSEPADebitPaymentSource(data.sepaDebitData).doAfterTerminate { clearPaymentInformation() } + StripeRepository.createSEPADebitPaymentSource(data.sepaDebitData).doAfterTerminate { clearPaymentInformation() } ) is StripePaymentData.IDEAL -> PaymentSourceProvider( PaymentSourceType.Stripe.IDEAL, - stripeRepository.createIdealPaymentSource(data.idealData).doAfterTerminate { clearPaymentInformation() } + StripeRepository.createIdealPaymentSource(data.idealData).doAfterTerminate { clearPaymentInformation() } ) else -> error("This should never happen.") @@ -140,117 +146,6 @@ class StripePaymentInProgressViewModel( stripePaymentData = null } - private fun proceedMonthly(inAppPayment: InAppPaymentTable.InAppPayment, paymentSourceProvider: PaymentSourceProvider, nextActionHandler: StripeNextActionHandler) { - val ensureSubscriberId: Completable = RecurringInAppPaymentRepository.ensureSubscriberId(inAppPayment.type.requireSubscriberType()) - val createAndConfirmSetupIntent: Single = paymentSourceProvider.paymentSource.flatMap { - stripeRepository.createAndConfirmSetupIntent(inAppPayment.type, it, paymentSourceProvider.paymentSourceType as PaymentSourceType.Stripe) - } - - val setLevel: Completable = RecurringInAppPaymentRepository.setSubscriptionLevel(inAppPayment, paymentSourceProvider.paymentSourceType) - - Log.d(TAG, "Starting subscription payment pipeline...", true) - store.update { InAppPaymentProcessorStage.PAYMENT_PIPELINE } - - val setup: Completable = ensureSubscriberId - .andThen(RecurringInAppPaymentRepository.cancelActiveSubscriptionIfNecessary(inAppPayment.type.requireSubscriberType())) - .andThen(createAndConfirmSetupIntent) - .flatMap { secure3DSAction -> - nextActionHandler.handle( - action = secure3DSAction, - inAppPayment = inAppPayment.copy( - subscriberId = InAppPaymentsRepository.requireSubscriber(inAppPayment.type.requireSubscriberType()).subscriberId, - state = InAppPaymentTable.State.WAITING_FOR_AUTHORIZATION, - data = inAppPayment.data.copy( - redemption = null, - waitForAuth = InAppPaymentData.WaitingForAuthorizationState( - stripeIntentId = secure3DSAction.stripeIntentAccessor.intentId, - stripeClientSecret = secure3DSAction.stripeIntentAccessor.intentClientSecret - ) - ) - ) - ) - .flatMap { secure3DSResult -> stripeRepository.getStatusAndPaymentMethodId(secure3DSResult, secure3DSAction.paymentMethodId) } - } - .flatMapCompletable { stripeRepository.setDefaultPaymentMethod(it.paymentMethod!!, it.intentId, inAppPayment.type.requireSubscriberType(), paymentSourceProvider.paymentSourceType) } - .onErrorResumeNext { - when (it) { - is DonationError -> Completable.error(it) - is InAppPaymentProcessorError -> Completable.error(it.toDonationError(DonationErrorSource.MONTHLY, paymentSourceProvider.paymentSourceType)) - else -> Completable.error(DonationError.getPaymentSetupError(DonationErrorSource.MONTHLY, it, paymentSourceProvider.paymentSourceType)) - } - } - - disposables += setup.andThen(setLevel).subscribeBy( - onError = { throwable -> - Log.w(TAG, "Failure in subscription payment pipeline...", throwable, true) - store.update { InAppPaymentProcessorStage.FAILED } - InAppPaymentsRepository.handlePipelineError(inAppPayment.id, DonationErrorSource.MONTHLY, paymentSourceProvider.paymentSourceType, throwable) - }, - onComplete = { - Log.d(TAG, "Finished subscription payment pipeline...", true) - store.update { InAppPaymentProcessorStage.COMPLETE } - } - ) - } - - private fun proceedOneTime( - inAppPayment: InAppPaymentTable.InAppPayment, - paymentSourceProvider: PaymentSourceProvider, - nextActionHandler: StripeNextActionHandler - ) { - check(inAppPayment.data.paymentMethodType.toPaymentSourceType() == paymentSourceProvider.paymentSourceType) - - Log.w(TAG, "Beginning one-time payment pipeline...", true) - - val amount = inAppPayment.data.amount!!.toFiatMoney() - val recipientId = inAppPayment.data.recipientId?.let { RecipientId.from(it) } ?: Recipient.self().id - val verifyUser = if (inAppPayment.type == InAppPaymentType.ONE_TIME_GIFT) { - OneTimeInAppPaymentRepository.verifyRecipientIsAllowedToReceiveAGift(recipientId) - } else { - Completable.complete() - } - - val continuePayment: Single = verifyUser.andThen(stripeRepository.continuePayment(amount, recipientId, inAppPayment.data.level, paymentSourceProvider.paymentSourceType)) - val intentAndSource: Single> = Single.zip(continuePayment, paymentSourceProvider.paymentSource, ::Pair) - - disposables += intentAndSource.flatMapCompletable { (paymentIntent, paymentSource) -> - stripeRepository.confirmPayment(paymentSource, paymentIntent, recipientId) - .flatMap { action -> - nextActionHandler - .handle( - action = action, - inAppPayment = inAppPayment.copy( - state = InAppPaymentTable.State.WAITING_FOR_AUTHORIZATION, - data = inAppPayment.data.copy( - redemption = null, - waitForAuth = InAppPaymentData.WaitingForAuthorizationState( - stripeIntentId = action.stripeIntentAccessor.intentId, - stripeClientSecret = action.stripeIntentAccessor.intentClientSecret - ) - ) - ) - ) - .flatMap { stripeRepository.getStatusAndPaymentMethodId(it, action.paymentMethodId) } - } - .flatMapCompletable { - OneTimeInAppPaymentRepository.waitForOneTimeRedemption( - inAppPayment = inAppPayment, - paymentIntentId = paymentIntent.intentId - ) - } - }.subscribeBy( - onError = { throwable -> - Log.w(TAG, "Failure in one-time payment pipeline...", throwable, true) - store.update { InAppPaymentProcessorStage.FAILED } - InAppPaymentsRepository.handlePipelineError(inAppPayment.id, DonationErrorSource.ONE_TIME, paymentSourceProvider.paymentSourceType, throwable) - }, - onComplete = { - Log.w(TAG, "Completed one-time payment pipeline...", true) - store.update { InAppPaymentProcessorStage.COMPLETE } - } - ) - } - fun cancelSubscription(subscriberType: InAppPaymentSubscriberRecord.Type) { Log.d(TAG, "Beginning cancellation...", true) @@ -273,7 +168,14 @@ class StripePaymentInProgressViewModel( disposables += RecurringInAppPaymentRepository .cancelActiveSubscriptionIfNecessary(inAppPayment.type.requireSubscriberType()) .andThen(RecurringInAppPaymentRepository.getPaymentSourceTypeOfLatestSubscription(inAppPayment.type.requireSubscriberType())) - .flatMapCompletable { paymentSourceType -> RecurringInAppPaymentRepository.setSubscriptionLevel(inAppPayment, paymentSourceType) } + .flatMapCompletable { paymentSourceType -> + val freshPayment = SignalDatabase.inAppPayments.moveToTransacting(inAppPayment.id)!! + + Single.fromCallable { + RecurringInAppPaymentRepository.setSubscriptionLevelSync(freshPayment) + }.flatMapCompletable { SharedInAppPaymentPipeline.awaitRedemption(it, paymentSourceType) } + } + .subscribeOn(Schedulers.io()) .subscribeBy( onComplete = { Log.w(TAG, "Completed subscription update", true) @@ -292,7 +194,7 @@ class StripePaymentInProgressViewModel( private data class PaymentSourceProvider( val paymentSourceType: PaymentSourceType, - val paymentSource: Single + val paymentSource: Single ) private sealed interface StripePaymentData { @@ -301,12 +203,4 @@ class StripePaymentInProgressViewModel( class SEPADebit(val sepaDebitData: StripeApi.SEPADebitData) : StripePaymentData class IDEAL(val idealData: StripeApi.IDEALData) : StripePaymentData } - - class Factory( - private val stripeRepository: StripeRepository - ) : ViewModelProvider.Factory { - override fun create(modelClass: Class): T { - return modelClass.cast(StripePaymentInProgressViewModel(stripeRepository)) as T - } - } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/details/BankTransferDetailsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/details/BankTransferDetailsFragment.kt index 9ebf02ea83..b3b13e6a53 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/details/BankTransferDetailsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/details/BankTransferDetailsFragment.kt @@ -56,7 +56,6 @@ import org.signal.core.util.getParcelableCompat import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.components.TemporaryScreenshotSecurity import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toFiatMoney -import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentComponent import org.thoughtcrime.securesms.components.settings.app.subscription.donate.InAppPaymentCheckoutDelegate import org.thoughtcrime.securesms.components.settings.app.subscription.donate.InAppPaymentProcessorAction import org.thoughtcrime.securesms.components.settings.app.subscription.donate.InAppPaymentProcessorActionResult @@ -68,7 +67,6 @@ import org.thoughtcrime.securesms.compose.ComposeFragment import org.thoughtcrime.securesms.database.InAppPaymentTable import org.thoughtcrime.securesms.payments.FiatMoneyUtil import org.thoughtcrime.securesms.util.SpanUtil -import org.thoughtcrime.securesms.util.fragments.requireListener import org.thoughtcrime.securesms.util.navigation.safeNavigate /** @@ -80,10 +78,7 @@ class BankTransferDetailsFragment : ComposeFragment(), InAppPaymentCheckoutDeleg private val viewModel: BankTransferDetailsViewModel by viewModels() private val stripePaymentViewModel: StripePaymentInProgressViewModel by navGraphViewModels( - R.id.checkout_flow, - factoryProducer = { - StripePaymentInProgressViewModel.Factory(requireListener().stripeRepository) - } + R.id.checkout_flow ) override fun onViewCreated(view: View, savedInstanceState: Bundle?) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/ideal/IdealTransferDetailsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/ideal/IdealTransferDetailsFragment.kt index 0e17329520..5f3ee89075 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/ideal/IdealTransferDetailsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/ideal/IdealTransferDetailsFragment.kt @@ -57,7 +57,6 @@ import org.signal.core.util.getParcelableCompat import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.components.TemporaryScreenshotSecurity import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toFiatMoney -import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentComponent import org.thoughtcrime.securesms.components.settings.app.subscription.donate.InAppPaymentCheckoutDelegate import org.thoughtcrime.securesms.components.settings.app.subscription.donate.InAppPaymentProcessorAction import org.thoughtcrime.securesms.components.settings.app.subscription.donate.InAppPaymentProcessorActionResult @@ -69,7 +68,6 @@ import org.thoughtcrime.securesms.compose.ComposeFragment import org.thoughtcrime.securesms.database.InAppPaymentTable import org.thoughtcrime.securesms.payments.FiatMoneyUtil import org.thoughtcrime.securesms.util.SpanUtil -import org.thoughtcrime.securesms.util.fragments.requireListener import org.thoughtcrime.securesms.util.navigation.safeNavigate import org.thoughtcrime.securesms.util.viewModel @@ -84,10 +82,7 @@ class IdealTransferDetailsFragment : ComposeFragment(), InAppPaymentCheckoutDele } private val stripePaymentViewModel: StripePaymentInProgressViewModel by navGraphViewModels( - R.id.checkout_flow, - factoryProducer = { - StripePaymentInProgressViewModel.Factory(requireListener().stripeRepository) - } + R.id.checkout_flow ) override fun onViewCreated(view: View, savedInstanceState: Bundle?) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivity.kt index 838cd8b48d..5d5d7779b0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivity.kt @@ -12,8 +12,8 @@ import org.signal.core.util.logging.Log import org.signal.core.util.logging.Log.tag import org.thoughtcrime.securesms.PassphraseRequiredActivity import org.thoughtcrime.securesms.R -import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentComponent -import org.thoughtcrime.securesms.components.settings.app.subscription.StripeRepository +import org.thoughtcrime.securesms.components.settings.app.subscription.GooglePayComponent +import org.thoughtcrime.securesms.components.settings.app.subscription.GooglePayRepository import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaController import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaControllerOwner import org.thoughtcrime.securesms.conversation.ConversationIntents @@ -25,7 +25,7 @@ import java.util.concurrent.TimeUnit /** * Wrapper activity for ConversationFragment. */ -open class ConversationActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner, InAppPaymentComponent { +open class ConversationActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner, GooglePayComponent { companion object { private val TAG = tag(ConversationActivity::class.java) @@ -38,8 +38,8 @@ open class ConversationActivity : PassphraseRequiredActivity(), VoiceNoteMediaCo override val voiceNoteMediaController = VoiceNoteMediaController(this, true) - override val stripeRepository: StripeRepository by lazy { StripeRepository(this) } - override val googlePayResultPublisher: Subject = PublishSubject.create() + override val googlePayRepository: GooglePayRepository by lazy { GooglePayRepository(this) } + override val googlePayResultPublisher: Subject = PublishSubject.create() private val motionEventRelay: MotionEventRelay by viewModels() private val shareDataTimestampViewModel: ShareDataTimestampViewModel by viewModels() @@ -100,7 +100,7 @@ open class ConversationActivity : PassphraseRequiredActivity(), VoiceNoteMediaCo @Suppress("DEPRECATION") override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { super.onActivityResult(requestCode, resultCode, data) - googlePayResultPublisher.onNext(InAppPaymentComponent.GooglePayResult(requestCode, resultCode, data)) + googlePayResultPublisher.onNext(GooglePayComponent.GooglePayResult(requestCode, resultCode, data)) } override fun onConfigurationChanged(newConfiguration: Configuration) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/InAppPaymentTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/InAppPaymentTable.kt index cbd02e499a..c971e2a500 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/InAppPaymentTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/InAppPaymentTable.kt @@ -9,6 +9,7 @@ import android.content.ContentValues import android.content.Context import android.database.Cursor import android.os.Parcelable +import androidx.annotation.CheckResult import androidx.core.content.contentValuesOf import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.Parcelize @@ -153,6 +154,21 @@ class InAppPaymentTable(context: Context, databaseHelper: SignalDatabase) : Data .let { InAppPaymentId(it) } } + @CheckResult + fun moveToTransacting(inAppPaymentId: InAppPaymentId): InAppPayment? { + writableDatabase.update(TABLE_NAME) + .values(STATE to State.serialize(State.TRANSACTING)) + .where(ID_WHERE, inAppPaymentId) + .run() + + val fresh = getById(inAppPaymentId) + if (fresh != null) { + AppDependencies.databaseObserver.notifyInAppPaymentsObservers(fresh) + } + + return fresh + } + fun update( inAppPayment: InAppPayment ) { @@ -168,6 +184,24 @@ class InAppPaymentTable(context: Context, databaseHelper: SignalDatabase) : Data AppDependencies.databaseObserver.notifyInAppPaymentsObservers(inAppPayment) } + /** + * Returns true if the user has submitted a pre-pending recurring donation. + * In this state, the user would have had to cancel their subscription or be in the process of trying + * to update, so we should not try to run the keep-alive job. + */ + fun hasPrePendingRecurringTransaction(type: InAppPaymentType): Boolean { + return readableDatabase + .exists(TABLE_NAME) + .where( + "($STATE = ? OR $STATE = ? OR $STATE = ?) AND $TYPE = ?", + State.serialize(State.REQUIRES_ACTION), + State.serialize(State.WAITING_FOR_AUTHORIZATION), + State.serialize(State.TRANSACTING), + InAppPaymentType.serialize(type) + ) + .run() + } + fun hasWaitingForAuth(): Boolean { return readableDatabase .exists(TABLE_NAME) @@ -400,8 +434,11 @@ class InAppPaymentTable(context: Context, databaseHelper: SignalDatabase) : Data * * ```mermaid * flowchart TD - * CREATED -- Auth required --> WAITING_FOR_AUTHORIZATION - * CREATED -- Auth not required --> PENDING + * CREATED --> TRANSACTING + * TRANSACTING -- Auth required --> REQUIRES_ACTION + * TRANSACTING -- Auth not required --> PENDING + * REQUIRES_ACTION -- User completes auth in app --> TRANSACTING + * REQUIRES_ACTION -- User launches external application --> WAITING_FOR_AUTHORIZATION * WAITING_FOR_AUTHORIZATION -- User completes auth --> PENDING * WAITING_FOR_AUTHORIZATION -- User does not complete auth --> END * PENDING --> END @@ -418,20 +455,35 @@ class InAppPaymentTable(context: Context, databaseHelper: SignalDatabase) : Data CREATED(0), /** - * This payment is awaiting the user to return from an external authorization2 + * This payment is awaiting the user to return from an external authorization * such as a 3DS flow or IDEAL confirmation. */ WAITING_FOR_AUTHORIZATION(1), /** - * This payment is authorized and is waiting to be processed. + * This payment is transacted and is performing receipt redemption. */ PENDING(2), /** * This payment pipeline has been completed. Check the data to see the state. */ - END(3); + END(3), + + /** + * Requires user action via 3DS or iDEAL + */ + REQUIRES_ACTION(4), + + /** + * User has completed the required action and the transaction should be finished. + */ + REQUIRED_ACTION_COMPLETED(5), + + /** + * Performing monetary transaction + */ + TRANSACTING(6); companion object : Serializer { override fun serialize(data: State): Int = data.code diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/BackupSubscriptionCheckJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/BackupSubscriptionCheckJob.kt index e73d232c0e..55d1780e03 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/BackupSubscriptionCheckJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/BackupSubscriptionCheckJob.kt @@ -142,11 +142,11 @@ class BackupSubscriptionCheckJob private constructor(parameters: Parameters) : C } private fun enqueueRedemptionForNewToken(localDevicePurchaseToken: String, localProductPrice: FiatMoney) { - RecurringInAppPaymentRepository.ensureSubscriberId( + RecurringInAppPaymentRepository.ensureSubscriberIdSync( subscriberType = InAppPaymentSubscriberRecord.Type.BACKUP, isRotation = true, iapSubscriptionId = IAPSubscriptionId.GooglePlayBillingPurchaseToken(localDevicePurchaseToken) - ).blockingAwait() + ) SignalDatabase.inAppPayments.clearCreated() diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentAuthCheckJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentAuthCheckJob.kt index 3a2294aace..98a25b4a5c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentAuthCheckJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentAuthCheckJob.kt @@ -6,7 +6,6 @@ package org.thoughtcrime.securesms.jobs import androidx.annotation.VisibleForTesting -import io.reactivex.rxjava3.core.Single import org.signal.core.util.logging.Log import org.signal.core.util.money.FiatMoney import org.signal.donations.InAppPaymentType @@ -161,13 +160,12 @@ class InAppPaymentAuthCheckJob private constructor(parameters: Parameters) : Bas SignalDatabase.inAppPayments.update( inAppPayment = inAppPayment.copy( state = InAppPaymentTable.State.PENDING, - data = inAppPayment.data.copy( - waitForAuth = null, + data = inAppPayment.data.newBuilder().redemption( redemption = InAppPaymentData.RedemptionState( stage = InAppPaymentData.RedemptionState.Stage.INIT, paymentIntentId = inAppPayment.data.waitForAuth.stripeIntentId ) - ) + ).build() ) ) @@ -272,12 +270,11 @@ class InAppPaymentAuthCheckJob private constructor(parameters: Parameters) : Bas SignalDatabase.inAppPayments.update( inAppPayment = inAppPayment.copy( state = InAppPaymentTable.State.PENDING, - data = inAppPayment.data.copy( - waitForAuth = null, + data = inAppPayment.data.newBuilder().redemption( redemption = InAppPaymentData.RedemptionState( stage = InAppPaymentData.RedemptionState.Stage.INIT ) - ) + ).build() ) ) @@ -347,7 +344,11 @@ class InAppPaymentAuthCheckJob private constructor(parameters: Parameters) : Bas data_ = errorData ), waitForAuth = InAppPaymentData.WaitingForAuthorizationState("", ""), - redemption = null + redemption = null, + stripeActionComplete = null, + payPalActionComplete = null, + payPalRequiresAction = null, + stripeRequiresAction = null ) ) ) @@ -367,11 +368,11 @@ class InAppPaymentAuthCheckJob private constructor(parameters: Parameters) : Bas } override fun onShouldRetry(e: Exception): Boolean = e is InAppPaymentRetryException - override fun fetchPaymentIntent(price: FiatMoney, level: Long, sourceType: PaymentSourceType.Stripe): Single { + override fun fetchPaymentIntent(price: FiatMoney, level: Long, sourceType: PaymentSourceType.Stripe): StripeIntentAccessor { error("Not needed, this job should not be creating intents.") } - override fun fetchSetupIntent(inAppPaymentType: InAppPaymentType, sourceType: PaymentSourceType.Stripe): Single { + override fun fetchSetupIntent(inAppPaymentType: InAppPaymentType, sourceType: PaymentSourceType.Stripe): StripeIntentAccessor { error("Not needed, this job should not be creating intents.") } diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentKeepAliveJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentKeepAliveJob.kt index cfcc83f894..f3f50c4afb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentKeepAliveJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentKeepAliveJob.kt @@ -100,6 +100,11 @@ class InAppPaymentKeepAliveJob private constructor( return } + if (SignalDatabase.inAppPayments.hasPrePendingRecurringTransaction(type.inAppPaymentType)) { + info(type, "We are currently processing a transaction for this type. Skipping.") + return + } + val subscriber: InAppPaymentSubscriberRecord? = InAppPaymentsRepository.getSubscriber(type) if (subscriber == null) { info(type, "Subscriber not present. Skipping.") @@ -146,12 +151,12 @@ class InAppPaymentKeepAliveJob private constructor( InAppPaymentData.RedemptionState.Stage.INIT -> { info(type, "Transitioning payment from INIT to CONVERSION_STARTED and generating a request credential") val payment = activeInAppPayment.copy( - data = activeInAppPayment.data.copy( + data = activeInAppPayment.data.newBuilder().redemption( redemption = activeInAppPayment.data.redemption.copy( stage = InAppPaymentData.RedemptionState.Stage.CONVERSION_STARTED, receiptCredentialRequestContext = InAppPaymentsRepository.generateRequestCredential().serialize().toByteString() ) - ) + ).build() ) SignalDatabase.inAppPayments.update(payment) @@ -212,7 +217,7 @@ class InAppPaymentKeepAliveJob private constructor( val oldInAppPayment = SignalDatabase.inAppPayments.getByLatestEndOfPeriod(type.inAppPaymentType) val oldEndOfPeriod = oldInAppPayment?.endOfPeriod ?: InAppPaymentsRepository.getFallbackLastEndOfPeriod(type) if (oldEndOfPeriod > endOfCurrentPeriod) { - warn(type, "Active subscription returned an old end-of-period. Exiting. (old: $oldEndOfPeriod, new: $endOfCurrentPeriod)") + warn(type, "Active subscription returned an old end-of-period. Exiting. (old: ${oldEndOfPeriod.inWholeSeconds}, new: ${endOfCurrentPeriod.inWholeSeconds})") return null } @@ -235,7 +240,7 @@ class InAppPaymentKeepAliveJob private constructor( oldInAppPayment.data.badge } - info(type, "End of period has changed. Requesting receipt refresh. (old: $oldEndOfPeriod, new: $endOfCurrentPeriod)") + info(type, "End of period has changed. Requesting receipt refresh. (old: ${oldEndOfPeriod.inWholeSeconds}, new: ${endOfCurrentPeriod.inWholeSeconds})") if (type == InAppPaymentSubscriberRecord.Type.DONATION) { SignalStore.inAppPayments.setLastEndOfPeriod(endOfCurrentPeriod.inWholeSeconds) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentOneTimeContextJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentOneTimeContextJob.kt index af35960523..6626144407 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentOneTimeContextJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentOneTimeContextJob.kt @@ -152,6 +152,11 @@ class InAppPaymentOneTimeContextJob private constructor( SignalDatabase.inAppPayments.update( inAppPayment.copy( data = inAppPayment.data.copy( + waitForAuth = null, + stripeActionComplete = null, + payPalActionComplete = null, + payPalRequiresAction = null, + stripeRequiresAction = null, redemption = InAppPaymentData.RedemptionState( stage = InAppPaymentData.RedemptionState.Stage.REDEMPTION_STARTED, receiptCredentialPresentation = receiptCredentialPresentation.serialize().toByteString() @@ -196,7 +201,7 @@ class InAppPaymentOneTimeContextJob private constructor( if (inAppPayment.state != InAppPaymentTable.State.PENDING) { warning("Invalid state: ${inAppPayment.state} but expected PENDING") - if (inAppPayment.state == InAppPaymentTable.State.CREATED) { + if (inAppPayment.state == InAppPaymentTable.State.TRANSACTING) { warning("onAdded failed to update payment state to PENDING. Updating now as long as the payment is valid otherwise.") } else { throw IOException("InAppPayment is in an invalid state: ${inAppPayment.state}") @@ -225,6 +230,11 @@ class InAppPaymentOneTimeContextJob private constructor( val updatedPayment = inAppPayment.copy( state = InAppPaymentTable.State.PENDING, data = inAppPayment.data.copy( + waitForAuth = null, + stripeActionComplete = null, + payPalActionComplete = null, + payPalRequiresAction = null, + stripeRequiresAction = null, redemption = inAppPayment.data.redemption.copy( stage = InAppPaymentData.RedemptionState.Stage.CONVERSION_STARTED, receiptCredentialRequestContext = requestContext.serialize().toByteString() diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentPayPalOneTimeSetupJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentPayPalOneTimeSetupJob.kt new file mode 100644 index 0000000000..bce60e0f48 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentPayPalOneTimeSetupJob.kt @@ -0,0 +1,100 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.jobs + +import org.signal.donations.InAppPaymentType +import org.signal.donations.PaymentSource +import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toFiatMoney +import org.thoughtcrime.securesms.components.settings.app.subscription.OneTimeInAppPaymentRepository +import org.thoughtcrime.securesms.components.settings.app.subscription.PayPalRepository +import org.thoughtcrime.securesms.components.settings.app.subscription.donate.paypal.PayPalConfirmationResult +import org.thoughtcrime.securesms.database.InAppPaymentTable +import org.thoughtcrime.securesms.dependencies.AppDependencies +import org.thoughtcrime.securesms.jobmanager.Job +import org.thoughtcrime.securesms.jobs.protos.InAppPaymentSetupJobData +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.recipients.RecipientId +import org.whispersystems.signalservice.api.subscriptions.PayPalCreatePaymentIntentResponse + +class InAppPaymentPayPalOneTimeSetupJob private constructor(data: InAppPaymentSetupJobData, parameters: Parameters) : InAppPaymentSetupJob(data, parameters) { + + companion object { + const val KEY = "InAppPaymentPayPalOneTimeSetupJob" + + /** + * Creates a new job for performing stripe recurring payment setup. Note that + * we do not require network for this job, as if the network is not present, we + * should treat that as an immediate error and fail the job. + */ + fun create( + inAppPayment: InAppPaymentTable.InAppPayment, + paymentSource: PaymentSource + ): InAppPaymentPayPalOneTimeSetupJob { + return InAppPaymentPayPalOneTimeSetupJob( + getJobData(inAppPayment, paymentSource), + getParameters(inAppPayment) + ) + } + } + + private val payPalRepository = PayPalRepository(AppDependencies.donationsService) + + override fun performPreUserAction(inAppPayment: InAppPaymentTable.InAppPayment): RequiredUserAction { + info("Beginning one-time payment pipeline.") + val amount = inAppPayment.data.amount!!.toFiatMoney() + val recipientId = inAppPayment.data.recipientId?.let { RecipientId.from(it) } ?: Recipient.self().id + if (inAppPayment.type == InAppPaymentType.ONE_TIME_GIFT) { + info("Verifying recipient $recipientId can receive gift.") + OneTimeInAppPaymentRepository.verifyRecipientIsAllowedToReceiveAGiftSync(recipientId) + } + + info("Creating one-time payment intent...") + val response: PayPalCreatePaymentIntentResponse = payPalRepository.createOneTimePaymentIntent( + amount = amount, + badgeRecipient = recipientId, + badgeLevel = inAppPayment.data.level + ) + + return RequiredUserAction.PayPalActionRequired( + approvalUrl = response.approvalUrl, + tokenOrPaymentId = response.paymentId + ) + } + + override fun performPostUserAction(inAppPayment: InAppPaymentTable.InAppPayment): Result { + val result = PayPalConfirmationResult( + payerId = inAppPayment.data.payPalActionComplete!!.payerId, + paymentId = inAppPayment.data.payPalActionComplete.paymentId.takeIf { it.isNotBlank() }, + paymentToken = inAppPayment.data.payPalActionComplete.paymentToken + ) + + info("Confirming payment intent...") + val response = payPalRepository.confirmOneTimePaymentIntent( + amount = inAppPayment.data.amount!!.toFiatMoney(), + badgeLevel = inAppPayment.data.level, + paypalConfirmationResult = result + ) + + info("Confirmed payment intent. Submitting redemption job chain.") + OneTimeInAppPaymentRepository.submitRedemptionJobChain(inAppPayment, response.paymentId) + + return Result.success() + } + + override fun getFactoryKey(): String = KEY + + override fun run(): Result { + return performTransaction() + } + + class Factory : Job.Factory { + override fun create(parameters: Parameters, serializedData: ByteArray?): InAppPaymentPayPalOneTimeSetupJob { + val data = serializedData?.let { InAppPaymentSetupJobData.ADAPTER.decode(it) } ?: error("Missing job data!") + + return InAppPaymentPayPalOneTimeSetupJob(data, parameters) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentPayPalRecurringSetupJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentPayPalRecurringSetupJob.kt new file mode 100644 index 0000000000..980ae0f3ac --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentPayPalRecurringSetupJob.kt @@ -0,0 +1,81 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.jobs + +import android.annotation.SuppressLint +import org.signal.donations.PaymentSource +import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository +import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository.requireSubscriberType +import org.thoughtcrime.securesms.components.settings.app.subscription.PayPalRepository +import org.thoughtcrime.securesms.components.settings.app.subscription.RecurringInAppPaymentRepository +import org.thoughtcrime.securesms.database.InAppPaymentTable +import org.thoughtcrime.securesms.dependencies.AppDependencies +import org.thoughtcrime.securesms.jobmanager.Job +import org.thoughtcrime.securesms.jobs.protos.InAppPaymentSetupJobData + +class InAppPaymentPayPalRecurringSetupJob private constructor(data: InAppPaymentSetupJobData, parameters: Parameters) : InAppPaymentSetupJob(data, parameters) { + + companion object { + const val KEY = "InAppPaymentPayPalRecurringSetupJob" + + /** + * Creates a new job for performing stripe recurring payment setup. Note that + * we do not require network for this job, as if the network is not present, we + * should treat that as an immediate error and fail the job. + */ + fun create( + inAppPayment: InAppPaymentTable.InAppPayment, + paymentSource: PaymentSource + ): InAppPaymentPayPalRecurringSetupJob { + return InAppPaymentPayPalRecurringSetupJob( + getJobData(inAppPayment, paymentSource), + getParameters(inAppPayment) + ) + } + } + + private val payPalRepository = PayPalRepository(AppDependencies.donationsService) + + override fun performPreUserAction(inAppPayment: InAppPaymentTable.InAppPayment): RequiredUserAction { + info("Ensuring the subscriber id is set on the server.") + RecurringInAppPaymentRepository.ensureSubscriberIdSync(inAppPayment.type.requireSubscriberType()) + info("Canceling active subscription (if necessary).") + RecurringInAppPaymentRepository.cancelActiveSubscriptionIfNecessarySync(inAppPayment.type.requireSubscriberType()) + info("Creating payment method") + val response = payPalRepository.createPaymentMethod(inAppPayment.type.requireSubscriberType()) + return RequiredUserAction.PayPalActionRequired( + approvalUrl = response.approvalUrl, + tokenOrPaymentId = response.token + ) + } + + @SuppressLint("CheckResult") + override fun performPostUserAction(inAppPayment: InAppPaymentTable.InAppPayment): Result { + val paymentMethodId = inAppPayment.data.payPalActionComplete!!.paymentId + info("Setting default payment method.") + payPalRepository.setDefaultPaymentMethod(inAppPayment.type.requireSubscriberType(), paymentMethodId) + info("Setting subscription level.") + RecurringInAppPaymentRepository.setSubscriptionLevelSync(inAppPayment) + + return Result.success() + } + + override fun getFactoryKey(): String = KEY + + override fun run(): Result { + return synchronized(InAppPaymentsRepository.resolveLock(InAppPaymentTable.InAppPaymentId(data.inAppPaymentId))) { + performTransaction() + } + } + + class Factory : Job.Factory { + override fun create(parameters: Parameters, serializedData: ByteArray?): InAppPaymentPayPalRecurringSetupJob { + val data = serializedData?.let { InAppPaymentSetupJobData.ADAPTER.decode(it) } ?: error("Missing job data!") + + return InAppPaymentPayPalRecurringSetupJob(data, parameters) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentPurchaseTokenJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentPurchaseTokenJob.kt index 7329e1af5e..ff0d06bf87 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentPurchaseTokenJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentPurchaseTokenJob.kt @@ -186,7 +186,7 @@ class InAppPaymentPurchaseTokenJob private constructor( try { info("Generating a new subscriber id.") - RecurringInAppPaymentRepository.ensureSubscriberId(InAppPaymentSubscriberRecord.Type.BACKUP, true).blockingAwait() + RecurringInAppPaymentRepository.ensureSubscriberIdSync(InAppPaymentSubscriberRecord.Type.BACKUP, true) info("Writing the new subscriber id to the InAppPayment.") val latest = SignalDatabase.inAppPayments.getById(inAppPaymentId)!! diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentRecurringContextJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentRecurringContextJob.kt index 6f86705be3..c746fe6109 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentRecurringContextJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentRecurringContextJob.kt @@ -85,7 +85,7 @@ class InAppPaymentRecurringContextJob private constructor( override fun onAdded() { val inAppPayment = SignalDatabase.inAppPayments.getById(inAppPaymentId) info("Added context job for payment with state ${inAppPayment?.state}") - if (inAppPayment?.state == InAppPaymentTable.State.CREATED) { + if (inAppPayment?.state == InAppPaymentTable.State.TRANSACTING) { SignalDatabase.inAppPayments.update( inAppPayment.copy( state = InAppPaymentTable.State.PENDING @@ -155,21 +155,21 @@ class InAppPaymentRecurringContextJob private constructor( info("Subscription is valid, proceeding with request for ReceiptCredentialResponse") val updatedInAppPayment: InAppPaymentTable.InAppPayment = if (inAppPayment.data.redemption!!.stage != InAppPaymentData.RedemptionState.Stage.CONVERSION_STARTED || inAppPayment.endOfPeriod.inWholeMilliseconds <= 0) { - info("Updating payment state with endOfCurrentPeriod and proper stage.") + info("Updating payment state with endOfCurrentPeriod (${subscription.endOfCurrentPeriod}) and proper stage.") if (inAppPayment.type.requireSubscriberType() == InAppPaymentSubscriberRecord.Type.DONATION) { - info("Recording last end of period.") + info("Recording last end of period (${subscription.endOfCurrentPeriod}).") SignalStore.inAppPayments.setLastEndOfPeriod(subscription.endOfCurrentPeriod) } SignalDatabase.inAppPayments.update( inAppPayment.copy( endOfPeriod = subscription.endOfCurrentPeriod.seconds, - data = inAppPayment.data.copy( + data = inAppPayment.data.newBuilder().redemption( redemption = inAppPayment.data.redemption.copy( stage = InAppPaymentData.RedemptionState.Stage.CONVERSION_STARTED ) - ) + ).build() ) ) @@ -180,7 +180,7 @@ class InAppPaymentRecurringContextJob private constructor( if (hasEntitlementAlready(updatedInAppPayment, subscription.endOfCurrentPeriod)) { info("Already have entitlement for this badge. Marking complete.") - markInAppPaymentCompleted(updatedInAppPayment) + markInAppPaymentCompleted(updatedInAppPayment, subscription) } else { submitAndValidateCredentials(updatedInAppPayment, subscription, requestContext) } @@ -227,13 +227,15 @@ class InAppPaymentRecurringContextJob private constructor( return (code >= 500 || code == 429) && code != 508 } - private fun markInAppPaymentCompleted(inAppPayment: InAppPaymentTable.InAppPayment) { + private fun markInAppPaymentCompleted(inAppPayment: InAppPaymentTable.InAppPayment, subscription: Subscription) { + SignalDatabase.donationReceipts.addReceipt(InAppPaymentReceiptRecord.createForSubscription(subscription)) + SignalDatabase.inAppPayments.update( inAppPayment = inAppPayment.copy( state = InAppPaymentTable.State.END, - data = inAppPayment.data.copy( + data = inAppPayment.data.newBuilder().redemption( redemption = InAppPaymentData.RedemptionState(stage = InAppPaymentData.RedemptionState.Stage.REDEEMED) - ) + ).build() ) ) } @@ -248,7 +250,7 @@ class InAppPaymentRecurringContextJob private constructor( if (inAppPayment.state != InAppPaymentTable.State.PENDING) { warning("Unexpected state. Got ${inAppPayment.state} but expected PENDING") - if (inAppPayment.state == InAppPaymentTable.State.CREATED) { + if (inAppPayment.state == InAppPaymentTable.State.TRANSACTING) { warning("onAdded failed to update payment state to PENDING. Updating now as long as the payment is valid otherwise.") } else { throw IOException("InAppPayment is in an invalid state: ${inAppPayment.state}") @@ -279,12 +281,12 @@ class InAppPaymentRecurringContextJob private constructor( val requestContext = InAppPaymentsRepository.generateRequestCredential() val updatedPayment = inAppPayment.copy( state = InAppPaymentTable.State.PENDING, - data = inAppPayment.data.copy( + data = inAppPayment.data.newBuilder().redemption( redemption = inAppPayment.data.redemption.copy( stage = InAppPaymentData.RedemptionState.Stage.CONVERSION_STARTED, receiptCredentialRequestContext = requestContext.serialize().toByteString() ) - ) + ).build() ) SignalDatabase.inAppPayments.update(updatedPayment) @@ -550,12 +552,12 @@ class InAppPaymentRecurringContextJob private constructor( SignalDatabase.donationReceipts.addReceipt(InAppPaymentReceiptRecord.createForSubscription(subscription)) SignalDatabase.inAppPayments.update( inAppPayment = inAppPayment.copy( - data = inAppPayment.data.copy( + data = inAppPayment.data.newBuilder().redemption( redemption = inAppPayment.data.redemption!!.copy( stage = InAppPaymentData.RedemptionState.Stage.REDEMPTION_STARTED, receiptCredentialPresentation = receiptCredentialPresentation.serialize().toByteString() ) - ) + ).build() ) ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentRedemptionJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentRedemptionJob.kt index fd14307967..8465763ae6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentRedemptionJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentRedemptionJob.kt @@ -255,6 +255,11 @@ class InAppPaymentRedemptionJob private constructor( notified = !jobData.isFromAuthCheck, state = InAppPaymentTable.State.END, data = inAppPayment.data.copy( + waitForAuth = null, + stripeActionComplete = null, + payPalActionComplete = null, + payPalRequiresAction = null, + stripeRequiresAction = null, redemption = inAppPayment.data.redemption.copy( stage = InAppPaymentData.RedemptionState.Stage.REDEEMED ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentSetupJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentSetupJob.kt new file mode 100644 index 0000000000..bbe77a1ea2 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentSetupJob.kt @@ -0,0 +1,236 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.jobs + +import org.signal.core.util.logging.Log +import org.signal.donations.PaymentSource +import org.signal.donations.PaymentSourceType +import org.signal.donations.StripeApi +import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository +import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository.toErrorSource +import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository.toPaymentSourceType +import org.thoughtcrime.securesms.components.settings.app.subscription.donate.InAppPaymentError +import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError +import org.thoughtcrime.securesms.components.settings.app.subscription.errors.toDonationError +import org.thoughtcrime.securesms.components.settings.app.subscription.toProto +import org.thoughtcrime.securesms.database.InAppPaymentTable +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData +import org.thoughtcrime.securesms.jobmanager.Job +import org.thoughtcrime.securesms.jobs.protos.InAppPaymentSetupJobData +import org.whispersystems.signalservice.internal.push.exceptions.InAppPaymentProcessorError + +/** + * Handles common pipeline logic between one-time and recurring transactions. + */ +abstract class InAppPaymentSetupJob( + val data: InAppPaymentSetupJobData, + parameters: Parameters +) : Job(parameters) { + + sealed interface RequiredUserAction { + data class StripeActionNotRequired(val action: StripeApi.Secure3DSAction.NotNeeded) : RequiredUserAction + data class StripeActionRequired(val action: StripeApi.Secure3DSAction.ConfirmRequired) : RequiredUserAction + data class PayPalActionRequired(val approvalUrl: String, val tokenOrPaymentId: String) : RequiredUserAction + } + + companion object { + private val TAG = Log.tag(InAppPaymentSetupJob::class) + + @JvmStatic + protected fun getParameters(inAppPayment: InAppPaymentTable.InAppPayment): Parameters { + return Parameters.Builder() + .setQueue(InAppPaymentsRepository.resolveJobQueueKey(inAppPayment)) + .setLifespan(InAppPaymentsRepository.resolveContextJobLifespan(inAppPayment).inWholeMilliseconds) + .setMaxAttempts(Parameters.UNLIMITED) + .build() + } + + @JvmStatic + protected fun getJobData( + inAppPayment: InAppPaymentTable.InAppPayment, + paymentSource: PaymentSource + ): InAppPaymentSetupJobData { + return InAppPaymentSetupJobData( + inAppPaymentId = inAppPayment.id.rowId, + inAppPaymentSource = paymentSource.toProto() + ) + } + } + + override fun serialize(): ByteArray = data.encode() + + /** + * Given we have explicit exception handling, this is intentionally blank. + */ + override fun onFailure() = Unit + + protected fun performTransaction(): Result { + val inAppPaymentId = InAppPaymentTable.InAppPaymentId(data.inAppPaymentId) + val inAppPayment = getAndValidateInAppPayment(inAppPaymentId) + if (inAppPayment == null) { + warning("No such payment, or payment was invalid. Failing.") + return Result.failure() + } + + if (data.inAppPaymentSource == null) { + warning("No payment source attached to job. Failing.") + handleFailure(inAppPaymentId, DonationError.getPaymentSetupError(inAppPayment.type.toErrorSource(), Exception(), inAppPayment.data.paymentMethodType.toPaymentSourceType())) + return Result.failure() + } + + if (inAppPayment.state == InAppPaymentTable.State.REQUIRED_ACTION_COMPLETED) { + info("In REQUIRED_ACTION_COMPLETED state, performing post-3ds work.") + + info("Moving payment to TRANSACTING state.") + val freshPayment = SignalDatabase.inAppPayments.moveToTransacting(inAppPaymentId)!! + + return try { + performPostUserAction(freshPayment) + } catch (e: Exception) { + handleFailure(inAppPaymentId, e) + Result.failure() + } + } + + try { + info("Moving payment to TRANSACTING state.") + val freshPayment = SignalDatabase.inAppPayments.moveToTransacting(inAppPaymentId)!! + + when (val action = performPreUserAction(freshPayment)) { + is RequiredUserAction.StripeActionRequired -> { + info("Stripe requires an action. Moving InAppPayment to REQUIRES_ACTION state.") + + val stripeSecure3DSAction = action.action + val freshInAppPayment = SignalDatabase.inAppPayments.getById(inAppPaymentId)!! + SignalDatabase.inAppPayments.update( + inAppPayment = freshInAppPayment.copy( + state = InAppPaymentTable.State.REQUIRES_ACTION, + data = freshInAppPayment.data.newBuilder().stripeRequiresAction( + stripeRequiresAction = InAppPaymentData.StripeRequiresActionState( + uri = stripeSecure3DSAction.uri.toString(), + returnUri = stripeSecure3DSAction.returnUri.toString(), + paymentMethodId = stripeSecure3DSAction.paymentMethodId.toString(), + stripeIntentId = stripeSecure3DSAction.stripeIntentAccessor.intentId, + stripeClientSecret = stripeSecure3DSAction.stripeIntentAccessor.intentClientSecret + ) + ).build() + ) + ) + + return Result.failure() + } + + is RequiredUserAction.StripeActionNotRequired -> { + val iap = SignalDatabase.inAppPayments.getById(inAppPaymentId)!! + val withCompletionData = iap.copy( + data = iap.data.newBuilder().stripeActionComplete( + stripeActionComplete = InAppPaymentData.StripeActionCompleteState( + stripeIntentId = action.action.stripeIntentAccessor.intentId, + stripeClientSecret = action.action.stripeIntentAccessor.intentClientSecret, + paymentMethodId = action.action.paymentMethodId + ) + ).build() + ) + + SignalDatabase.inAppPayments.update(withCompletionData) + return performPostUserAction( + inAppPayment = withCompletionData + ) + } + + is RequiredUserAction.PayPalActionRequired -> { + val freshInAppPayment = SignalDatabase.inAppPayments.getById(inAppPaymentId)!! + SignalDatabase.inAppPayments.update( + inAppPayment = freshInAppPayment.copy( + state = InAppPaymentTable.State.REQUIRES_ACTION, + data = freshInAppPayment.data.newBuilder().payPalRequiresAction( + payPalRequiresAction = InAppPaymentData.PayPalRequiresActionState( + approvalUrl = action.approvalUrl, + token = action.tokenOrPaymentId + ) + ).build() + ) + ) + + return Result.failure() + } + } + } catch (e: Exception) { + handleFailure(inAppPaymentId, e) + return Result.failure() + } + } + + abstract fun performPreUserAction(inAppPayment: InAppPaymentTable.InAppPayment): RequiredUserAction + + abstract fun performPostUserAction(inAppPayment: InAppPaymentTable.InAppPayment): Result + + private fun getAndValidateInAppPayment(inAppPaymentId: InAppPaymentTable.InAppPaymentId): InAppPaymentTable.InAppPayment? { + val inAppPayment = SignalDatabase.inAppPayments.getById(inAppPaymentId) + + val isValid: Boolean = if (inAppPayment?.state == InAppPaymentTable.State.CREATED || inAppPayment?.state == InAppPaymentTable.State.REQUIRED_ACTION_COMPLETED) { + val isStripeAndHasStripeCompletionData = inAppPayment.data.paymentMethodType.toPaymentSourceType() is PaymentSourceType.Stripe && inAppPayment.data.stripeActionComplete != null + val isPayPalAndHAsPayPalCompletionData = inAppPayment.data.paymentMethodType.toPaymentSourceType() is PaymentSourceType.PayPal && inAppPayment.data.payPalActionComplete != null + + if (inAppPayment.state == InAppPaymentTable.State.REQUIRED_ACTION_COMPLETED && !isPayPalAndHAsPayPalCompletionData && !isStripeAndHasStripeCompletionData) { + warning("Missing action data for REQUIRED_ACTION_COMPLETED state.") + false + } else { + true + } + } else { + warning("Missing or invalid in-app-payment in state ${inAppPayment?.state}") + false + } + + return if (!isValid) { + if (inAppPayment != null) { + handleFailure( + inAppPaymentId, + DonationError.getPaymentSetupError( + source = inAppPayment.type.toErrorSource(), + throwable = Exception(), + method = inAppPayment.data.paymentMethodType.toPaymentSourceType() + ) + ) + } + null + } else { + inAppPayment + } + } + + private fun handleFailure(inAppPaymentId: InAppPaymentTable.InAppPaymentId, exception: Exception) { + warning("Failed to process transaction.", exception) + + val freshPayment = SignalDatabase.inAppPayments.getById(inAppPaymentId)!! + + val donationError: DonationError = when (exception) { + is DonationError -> exception + is InAppPaymentProcessorError -> exception.toDonationError(freshPayment.type.toErrorSource(), freshPayment.data.paymentMethodType.toPaymentSourceType()) + else -> DonationError.genericBadgeRedemptionFailure(freshPayment.type.toErrorSource()) + } + + SignalDatabase.inAppPayments.update( + freshPayment.copy( + notified = false, + state = InAppPaymentTable.State.END, + data = freshPayment.data.copy( + error = InAppPaymentError.fromDonationError(donationError)?.inAppPaymentDataError + ) + ) + ) + } + + protected fun info(message: String, throwable: Throwable? = null) { + Log.i(TAG, "InAppPayment[${data.inAppPaymentId}]: $message", throwable, true) + } + + protected fun warning(message: String, throwable: Throwable? = null) { + Log.w(TAG, "InAppPayment[${data.inAppPaymentId}]: $message", throwable, true) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentStripeOneTimeSetupJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentStripeOneTimeSetupJob.kt new file mode 100644 index 0000000000..ab7b8939e8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentStripeOneTimeSetupJob.kt @@ -0,0 +1,98 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.jobs + +import org.signal.donations.InAppPaymentType +import org.signal.donations.PaymentSource +import org.signal.donations.StripeApi +import org.signal.donations.StripeIntentAccessor +import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toFiatMoney +import org.thoughtcrime.securesms.components.settings.app.subscription.OneTimeInAppPaymentRepository +import org.thoughtcrime.securesms.components.settings.app.subscription.StripeRepository +import org.thoughtcrime.securesms.components.settings.app.subscription.toPaymentSource +import org.thoughtcrime.securesms.database.InAppPaymentTable +import org.thoughtcrime.securesms.jobmanager.Job +import org.thoughtcrime.securesms.jobs.protos.InAppPaymentSetupJobData +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.recipients.RecipientId + +/** + * Handles one-time Stripe transactions. + */ +class InAppPaymentStripeOneTimeSetupJob private constructor( + data: InAppPaymentSetupJobData, + parameters: Parameters +) : InAppPaymentSetupJob(data, parameters) { + + companion object { + const val KEY = "InAppPaymentStripeOneTimeSetupJob" + + /** + * Creates a new job for performing stripe recurring payment setup. Note that + * we do not require network for this job, as if the network is not present, we + * should treat that as an immediate error and fail the job. + */ + fun create( + inAppPayment: InAppPaymentTable.InAppPayment, + paymentSource: PaymentSource + ): InAppPaymentStripeOneTimeSetupJob { + return InAppPaymentStripeOneTimeSetupJob( + getJobData(inAppPayment, paymentSource), + getParameters(inAppPayment) + ) + } + } + + override fun performPreUserAction(inAppPayment: InAppPaymentTable.InAppPayment): RequiredUserAction { + info("Beginning one-time payment pipeline.") + val amount = inAppPayment.data.amount!!.toFiatMoney() + val recipientId = inAppPayment.data.recipientId?.let { RecipientId.from(it) } ?: Recipient.self().id + if (inAppPayment.type == InAppPaymentType.ONE_TIME_GIFT) { + info("Verifying recipient $recipientId can receive gift.") + OneTimeInAppPaymentRepository.verifyRecipientIsAllowedToReceiveAGiftSync(recipientId) + } + + info("Continuing payment...") + val intentAccessor = StripeRepository.createPaymentIntent(amount, recipientId, inAppPayment.data.level, data.inAppPaymentSource!!.toPaymentSource().type) + + info("Confirming payment...") + return when (val action = StripeRepository.confirmPaymentIntent(data.inAppPaymentSource.toPaymentSource(), intentAccessor, recipientId)) { + is StripeApi.Secure3DSAction.ConfirmRequired -> RequiredUserAction.StripeActionRequired(action) + is StripeApi.Secure3DSAction.NotNeeded -> RequiredUserAction.StripeActionNotRequired(action) + } + } + + override fun performPostUserAction(inAppPayment: InAppPaymentTable.InAppPayment): Result { + val paymentMethodId = inAppPayment.data.stripeActionComplete!!.paymentMethodId + val intentAccessor = StripeIntentAccessor( + objectType = StripeIntentAccessor.ObjectType.PAYMENT_INTENT, + intentId = inAppPayment.data.stripeActionComplete.stripeIntentId, + intentClientSecret = inAppPayment.data.stripeActionComplete.stripeClientSecret + ) + + info("Getting status and payment method id from stripe.") + StripeRepository.getStatusAndPaymentMethodId(intentAccessor, paymentMethodId) + + info("Received status and payment method id. Submitting redemption job chain.") + OneTimeInAppPaymentRepository.submitRedemptionJobChain(inAppPayment, intentAccessor.intentId) + + return Result.success() + } + + override fun getFactoryKey(): String = KEY + + override fun run(): Result { + return performTransaction() + } + + class Factory : Job.Factory { + override fun create(parameters: Parameters, serializedData: ByteArray?): InAppPaymentStripeOneTimeSetupJob { + val data = serializedData?.let { InAppPaymentSetupJobData.ADAPTER.decode(it) } ?: error("Missing job data!") + + return InAppPaymentStripeOneTimeSetupJob(data, parameters) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentStripeRecurringSetupJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentStripeRecurringSetupJob.kt new file mode 100644 index 0000000000..bb978460dc --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentStripeRecurringSetupJob.kt @@ -0,0 +1,105 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.jobs + +import android.annotation.SuppressLint +import org.signal.core.util.logging.Log +import org.signal.donations.PaymentSource +import org.signal.donations.PaymentSourceType +import org.signal.donations.StripeApi +import org.signal.donations.StripeIntentAccessor +import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository +import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository.requireSubscriberType +import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository.toPaymentSourceType +import org.thoughtcrime.securesms.components.settings.app.subscription.RecurringInAppPaymentRepository +import org.thoughtcrime.securesms.components.settings.app.subscription.StripeRepository +import org.thoughtcrime.securesms.components.settings.app.subscription.toPaymentSource +import org.thoughtcrime.securesms.database.InAppPaymentTable +import org.thoughtcrime.securesms.jobmanager.Job +import org.thoughtcrime.securesms.jobs.protos.InAppPaymentSetupJobData + +/** + * Handles setup of recurring Stripe transactions. + */ +class InAppPaymentStripeRecurringSetupJob private constructor( + data: InAppPaymentSetupJobData, + parameters: Parameters +) : InAppPaymentSetupJob(data, parameters) { + + companion object { + const val KEY = "InAppPaymentStripeRecurringSetupJob" + private val TAG = Log.tag(InAppPaymentStripeRecurringSetupJob::class) + + /** + * Creates a new job for performing stripe recurring payment setup. Note that + * we do not require network for this job, as if the network is not present, we + * should treat that as an immediate error and fail the job. + */ + fun create( + inAppPayment: InAppPaymentTable.InAppPayment, + paymentSource: PaymentSource + ): InAppPaymentStripeRecurringSetupJob { + return InAppPaymentStripeRecurringSetupJob( + getJobData(inAppPayment, paymentSource), + getParameters(inAppPayment) + ) + } + } + + override fun run(): Result { + return synchronized(InAppPaymentsRepository.resolveLock(InAppPaymentTable.InAppPaymentId(data.inAppPaymentId))) { + performTransaction() + } + } + + override fun performPreUserAction(inAppPayment: InAppPaymentTable.InAppPayment): RequiredUserAction { + info("Ensuring the subscriber id is set on the server.") + RecurringInAppPaymentRepository.ensureSubscriberIdSync(inAppPayment.type.requireSubscriberType()) + info("Canceling active subscription (if necessary).") + RecurringInAppPaymentRepository.cancelActiveSubscriptionIfNecessarySync(inAppPayment.type.requireSubscriberType()) + info("Creating and confirming setup intent.") + return when (val action = StripeRepository.createAndConfirmSetupIntent(inAppPayment.type, data.inAppPaymentSource!!.toPaymentSource(), inAppPayment.data.paymentMethodType.toPaymentSourceType() as PaymentSourceType.Stripe)) { + is StripeApi.Secure3DSAction.ConfirmRequired -> RequiredUserAction.StripeActionRequired(action) + is StripeApi.Secure3DSAction.NotNeeded -> RequiredUserAction.StripeActionNotRequired(action) + } + } + + @SuppressLint("CheckResult") + override fun performPostUserAction(inAppPayment: InAppPaymentTable.InAppPayment): Result { + val paymentMethodId = inAppPayment.data.stripeActionComplete!!.paymentMethodId + val intentAccessor = StripeIntentAccessor( + objectType = StripeIntentAccessor.ObjectType.SETUP_INTENT, + intentId = inAppPayment.data.stripeActionComplete.stripeIntentId, + intentClientSecret = inAppPayment.data.stripeActionComplete.stripeClientSecret + ) + + info("Requesting status and payment method id from stripe service.") + val statusAndPaymentMethodId = StripeRepository.getStatusAndPaymentMethodId(intentAccessor, paymentMethodId) + + info("Setting default payment method.") + StripeRepository.setDefaultPaymentMethod( + paymentMethodId = statusAndPaymentMethodId.paymentMethod!!, + setupIntentId = intentAccessor.intentId, + subscriberType = inAppPayment.type.requireSubscriberType(), + paymentSourceType = inAppPayment.data.paymentMethodType.toPaymentSourceType() + ) + + info("Setting subscription level.") + RecurringInAppPaymentRepository.setSubscriptionLevelSync(inAppPayment) + + return Result.success() + } + + override fun getFactoryKey(): String = KEY + + class Factory : Job.Factory { + override fun create(parameters: Parameters, serializedData: ByteArray?): InAppPaymentStripeRecurringSetupJob { + val data = serializedData?.let { InAppPaymentSetupJobData.ADAPTER.decode(it) } ?: error("Missing job data!") + + return InAppPaymentStripeRecurringSetupJob(data, parameters) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java index b8b9aa760d..41c99acf3f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java @@ -168,6 +168,10 @@ public final class JobManagerFactories { put(InAppPaymentRecurringContextJob.KEY, new InAppPaymentRecurringContextJob.Factory()); put(InAppPaymentOneTimeContextJob.KEY, new InAppPaymentOneTimeContextJob.Factory()); put(InAppPaymentRedemptionJob.KEY, new InAppPaymentRedemptionJob.Factory()); + put(InAppPaymentPayPalOneTimeSetupJob.KEY, new InAppPaymentPayPalOneTimeSetupJob.Factory()); + put(InAppPaymentPayPalRecurringSetupJob.KEY, new InAppPaymentPayPalRecurringSetupJob.Factory()); + put(InAppPaymentStripeOneTimeSetupJob.KEY, new InAppPaymentStripeOneTimeSetupJob.Factory()); + put(InAppPaymentStripeRecurringSetupJob.KEY, new InAppPaymentStripeRecurringSetupJob.Factory()); put(IndividualSendJob.KEY, new IndividualSendJob.Factory()); put(LeaveGroupV2Job.KEY, new LeaveGroupV2Job.Factory()); put(LeaveGroupV2WorkerJob.KEY, new LeaveGroupV2WorkerJob.Factory()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/subscription/LevelUpdateOperation.kt b/app/src/main/java/org/thoughtcrime/securesms/subscription/LevelUpdateOperation.kt index 13e9c5e381..24b4b7ac6c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/subscription/LevelUpdateOperation.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/subscription/LevelUpdateOperation.kt @@ -1,6 +1,7 @@ package org.thoughtcrime.securesms.subscription import org.whispersystems.signalservice.api.subscriptions.IdempotencyKey +import java.io.Closeable /** * Binds a Subscription level update with an idempotency key. @@ -10,4 +11,8 @@ import org.whispersystems.signalservice.api.subscriptions.IdempotencyKey data class LevelUpdateOperation( val idempotencyKey: IdempotencyKey, val level: String -) +) : Closeable { + override fun close() { + LevelUpdate.updateProcessingState(false) + } +} diff --git a/app/src/main/protowire/Database.proto b/app/src/main/protowire/Database.proto index 0f1c7e19a2..e57edc911e 100644 --- a/app/src/main/protowire/Database.proto +++ b/app/src/main/protowire/Database.proto @@ -379,6 +379,31 @@ message InAppPaymentData { optional bytes receiptCredentialPresentation = 5; // Redeemable presentation } + message StripeRequiresActionState { + string uri = 1; // URI to navigate the user to in a webview + string returnUri = 2; // URI to return to the app with if the user moves to an external location + string stripeIntentId = 3; // Passed to same field in waiting for auth + string stripeClientSecret = 4; // Passed to same field in waiting for auth + optional string paymentMethodId = 5; // Nullable payment method id + } + + message StripeActionCompleteState { + string stripeIntentId = 3; // Passed to same field in waiting for auth + string stripeClientSecret = 4; // Passed to same field in waiting for auth + optional string paymentMethodId = 5; // Nullable payment method id + } + + message PayPalRequiresActionState { + string approvalUrl = 1; + string token = 2; + } + + message PayPalActionCompleteState { + string payerId = 1; + string paymentId = 2; + string paymentToken = 3; + } + message Error { enum Type { UNKNOWN = 0; // A generic, untyped error. Check log for details. @@ -413,10 +438,13 @@ message InAppPaymentData { PaymentMethodType paymentMethodType = 9; // The method through which this in app payment was made oneof redemptionState { - WaitingForAuthorizationState waitForAuth = 10; // Waiting on user authorization from an external source (3DS, iDEAL) - RedemptionState redemption = 11; // Waiting on processing of token + WaitingForAuthorizationState waitForAuth = 10; // Waiting on user authorization from an external source (3DS, iDEAL) + RedemptionState redemption = 11; // Waiting on processing of token + StripeRequiresActionState stripeRequiresAction = 12; // Waiting on required user action, user has not navigated away from app. + StripeActionCompleteState stripeActionComplete = 13; // Stripe action completed. + PayPalRequiresActionState payPalRequiresAction = 14; // Waiting on required user action, user has not navigated away from app. + PayPalActionCompleteState payPalActionComplete = 15; // PayPal action completed. } - } // DEPRECATED -- Move to TokenTransactionData diff --git a/app/src/main/protowire/JobData.proto b/app/src/main/protowire/JobData.proto index d17805dc00..3a8b4ffd9d 100644 --- a/app/src/main/protowire/JobData.proto +++ b/app/src/main/protowire/JobData.proto @@ -170,3 +170,46 @@ message MultiDeviceAttachmentBackfillUpdateJobData { signalservice.ConversationIdentifier targetConversation = 2; uint64 messageId = 3; } + +message InAppPaymentSourceData { + enum Code { + UNKNOWN = 0; + PAY_PAL = 1; + CREDIT_CARD = 2; + GOOGLE_PAY = 3; + SEPA_DEBIT = 4; + IDEAL = 5; + GOOGLE_PLAY_BILLING = 6; + } + + message TokenData { + string parameters = 1; + string tokenId = 2; + optional string email = 3; + } + + message IDEALData { + string bank = 1; + string name = 2; + string email = 3; + } + + message SEPAData { + string iban = 1; + string name = 2; + string email = 3; + } + + Code code = 1; + + oneof data { + TokenData tokenData = 2; + IDEALData idealData = 3; + SEPAData sepaData = 4; + } +} + +message InAppPaymentSetupJobData { + uint64 inAppPaymentId = 1; + InAppPaymentSourceData inAppPaymentSource = 2; +} \ No newline at end of file diff --git a/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/subscription/OneTimeInAppPaymentRepositoryTest.kt b/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/subscription/OneTimeInAppPaymentRepositoryTest.kt index 06fe6ed4bc..f6653fc927 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/subscription/OneTimeInAppPaymentRepositoryTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/subscription/OneTimeInAppPaymentRepositoryTest.kt @@ -4,26 +4,21 @@ import android.app.Application import io.mockk.every import io.mockk.mockk import io.mockk.mockkStatic -import io.reactivex.rxjava3.core.Flowable import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config -import org.signal.donations.InAppPaymentType import org.signal.donations.PaymentSourceType import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource -import org.thoughtcrime.securesms.database.InAppPaymentTable import org.thoughtcrime.securesms.database.RecipientTable -import org.thoughtcrime.securesms.database.SignalDatabase import org.thoughtcrime.securesms.database.model.DistributionListId import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId import org.thoughtcrime.securesms.testutil.MockAppDependenciesRule import org.thoughtcrime.securesms.testutil.RxPluginsRule -import java.util.concurrent.TimeUnit @RunWith(RobolectricTestRunner::class) @Config(manifest = Config.NONE, application = Application::class) @@ -80,7 +75,7 @@ class OneTimeInAppPaymentRepositoryTest { } @Test - fun `Given a registered non-self individual, when I verifyRecipientIsAllowedToReceiveAGift, then I expect completion`() { + fun `Given a registered non-self individual, when I verifyRecipientIsAllowedToReceiveAGift, then I expect no error`() { val recipientId = RecipientId.from(1L) val recipient = Recipient( id = recipientId, @@ -91,13 +86,10 @@ class OneTimeInAppPaymentRepositoryTest { mockkStatic(Recipient::class) every { Recipient.resolved(recipientId) } returns recipient - val testObserver = OneTimeInAppPaymentRepository.verifyRecipientIsAllowedToReceiveAGift(recipientId).test() - rxRule.defaultScheduler.triggerActions() - - testObserver.assertComplete() + OneTimeInAppPaymentRepository.verifyRecipientIsAllowedToReceiveAGiftSync(recipientId) } - @Test + @Test(expected = DonationError.GiftRecipientVerificationError.SelectedRecipientIsInvalid::class) fun `Given self, when I verifyRecipientIsAllowedToReceiveAGift, then I expect SelectedRecipientIsInvalid`() { val recipientId = RecipientId.from(1L) val recipient = Recipient( @@ -108,7 +100,7 @@ class OneTimeInAppPaymentRepositoryTest { verifyRecipientIsNotAllowedToBeGiftedBadges(recipient) } - @Test + @Test(expected = DonationError.GiftRecipientVerificationError.SelectedRecipientIsInvalid::class) fun `Given an unregistered individual, when I verifyRecipientIsAllowedToReceiveAGift, then I expect SelectedRecipientIsInvalid`() { val recipientId = RecipientId.from(1L) val recipient = Recipient( @@ -120,7 +112,7 @@ class OneTimeInAppPaymentRepositoryTest { verifyRecipientIsNotAllowedToBeGiftedBadges(recipient) } - @Test + @Test(expected = DonationError.GiftRecipientVerificationError.SelectedRecipientIsInvalid::class) fun `Given a group, when I verifyRecipientIsAllowedToReceiveAGift, then I expect SelectedRecipientIsInvalid`() { val recipientId = RecipientId.from(1L) val recipient = Recipient( @@ -133,7 +125,7 @@ class OneTimeInAppPaymentRepositoryTest { verifyRecipientIsNotAllowedToBeGiftedBadges(recipient) } - @Test + @Test(expected = DonationError.GiftRecipientVerificationError.SelectedRecipientIsInvalid::class) fun `Given a call link, when I verifyRecipientIsAllowedToReceiveAGift, then I expect SelectedRecipientIsInvalid`() { val recipientId = RecipientId.from(1L) val recipient = Recipient( @@ -146,7 +138,7 @@ class OneTimeInAppPaymentRepositoryTest { verifyRecipientIsNotAllowedToBeGiftedBadges(recipient) } - @Test + @Test(expected = DonationError.GiftRecipientVerificationError.SelectedRecipientIsInvalid::class) fun `Given a distribution list, when I verifyRecipientIsAllowedToReceiveAGift, then I expect SelectedRecipientIsInvalid`() { val recipientId = RecipientId.from(1L) val recipient = Recipient( @@ -159,7 +151,7 @@ class OneTimeInAppPaymentRepositoryTest { verifyRecipientIsNotAllowedToBeGiftedBadges(recipient) } - @Test + @Test(expected = DonationError.GiftRecipientVerificationError.SelectedRecipientIsInvalid::class) fun `Given release notes, when I verifyRecipientIsAllowedToReceiveAGift, then I expect SelectedRecipientIsInvalid`() { val recipientId = RecipientId.from(1L) val recipient = Recipient( @@ -210,68 +202,10 @@ class OneTimeInAppPaymentRepositoryTest { .assertComplete() } - @Test - fun `Given a long running transaction, when I waitForOneTimeRedemption, then I expect DonationPending`() { - val inAppPayment = inAppPaymentsTestRule.createInAppPayment(InAppPaymentType.ONE_TIME_DONATION, PaymentSourceType.Stripe.SEPADebit) - - every { SignalDatabase.Companion.inAppPayments.getById(inAppPayment.id) } returns inAppPayment - - val testObserver = OneTimeInAppPaymentRepository - .waitForOneTimeRedemption(inAppPayment, "test-intent-id") - .test() - - rxRule.defaultScheduler.triggerActions() - rxRule.defaultScheduler.advanceTimeBy(10, TimeUnit.SECONDS) - rxRule.defaultScheduler.triggerActions() - - testObserver - .assertError { it is DonationError.BadgeRedemptionError.DonationPending } - } - - @Test - fun `Given a non long running transaction, when I waitForOneTimeRedemption, then I expect TimeoutWaitingForTokenError`() { - val inAppPayment = inAppPaymentsTestRule.createInAppPayment(InAppPaymentType.ONE_TIME_DONATION, PaymentSourceType.Stripe.CreditCard) - - every { SignalDatabase.Companion.inAppPayments.getById(inAppPayment.id) } returns inAppPayment - - val testObserver = OneTimeInAppPaymentRepository - .waitForOneTimeRedemption(inAppPayment, "test-intent-id") - .test() - - rxRule.defaultScheduler.triggerActions() - rxRule.defaultScheduler.advanceTimeBy(10, TimeUnit.SECONDS) - rxRule.defaultScheduler.triggerActions() - - testObserver - .assertError { it is DonationError.BadgeRedemptionError.TimeoutWaitingForTokenError } - } - - @Test - fun `Given no delays, when I waitForOneTimeRedemption, then I expect happy path`() { - val inAppPayment = inAppPaymentsTestRule.createInAppPayment(InAppPaymentType.ONE_TIME_DONATION, PaymentSourceType.Stripe.CreditCard) - - every { InAppPaymentsRepository.observeUpdates(inAppPayment.id) } returns Flowable.just(inAppPayment.copy(state = InAppPaymentTable.State.END)) - every { SignalDatabase.Companion.inAppPayments.getById(inAppPayment.id) } returns inAppPayment - - val testObserver = OneTimeInAppPaymentRepository - .waitForOneTimeRedemption(inAppPayment, "test-intent-id") - .test() - - rxRule.defaultScheduler.triggerActions() - - testObserver - .assertComplete() - } - private fun verifyRecipientIsNotAllowedToBeGiftedBadges(recipient: Recipient) { mockkStatic(Recipient::class) every { Recipient.resolved(recipient.id) } returns recipient - val testObserver = OneTimeInAppPaymentRepository.verifyRecipientIsAllowedToReceiveAGift(recipient.id).test() - rxRule.defaultScheduler.triggerActions() - - testObserver.assertError { - it is DonationError.GiftRecipientVerificationError.SelectedRecipientIsInvalid - } + OneTimeInAppPaymentRepository.verifyRecipientIsAllowedToReceiveAGiftSync(recipient.id) } } diff --git a/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/subscription/RecurringInAppPaymentRepositoryTest.kt b/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/subscription/RecurringInAppPaymentRepositoryTest.kt index 31c0f089b4..980fb32695 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/subscription/RecurringInAppPaymentRepositoryTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/subscription/RecurringInAppPaymentRepositoryTest.kt @@ -9,7 +9,6 @@ import io.mockk.mockkObject import io.mockk.mockkStatic import io.mockk.unmockkAll import io.mockk.verify -import io.reactivex.rxjava3.core.Flowable import org.junit.After import org.junit.Before import org.junit.Rule @@ -17,10 +16,6 @@ import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config -import org.signal.donations.InAppPaymentType -import org.signal.donations.PaymentSourceType -import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError -import org.thoughtcrime.securesms.database.InAppPaymentTable import org.thoughtcrime.securesms.database.SignalDatabase import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData @@ -28,17 +23,12 @@ import org.thoughtcrime.securesms.dependencies.AppDependencies import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.storage.StorageSyncHelper -import org.thoughtcrime.securesms.subscription.LevelUpdate -import org.thoughtcrime.securesms.subscription.LevelUpdateOperation import org.thoughtcrime.securesms.testutil.MockAppDependenciesRule import org.thoughtcrime.securesms.testutil.RxPluginsRule -import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException -import org.whispersystems.signalservice.api.subscriptions.IdempotencyKey import org.whispersystems.signalservice.api.subscriptions.SubscriberId import org.whispersystems.signalservice.internal.EmptyResponse import org.whispersystems.signalservice.internal.ServiceResponse import java.util.Currency -import java.util.concurrent.TimeUnit @RunWith(RobolectricTestRunner::class) @Config(manifest = Config.NONE, application = Application::class) @@ -98,13 +88,10 @@ class RecurringInAppPaymentRepositoryTest { val initialSubscriber = createSubscriber() val ref = InAppPaymentsTestRule.mockLocalSubscriberAccess(initialSubscriber) - val testObserver = RecurringInAppPaymentRepository.ensureSubscriberId( + RecurringInAppPaymentRepository.ensureSubscriberIdSync( subscriberType = InAppPaymentSubscriberRecord.Type.DONATION, isRotation = false - ).test() - - rxRule.defaultScheduler.triggerActions() - testObserver.assertComplete() + ) val newSubscriber = ref.get() @@ -116,13 +103,10 @@ class RecurringInAppPaymentRepositoryTest { val initialSubscriber = createSubscriber() val ref = InAppPaymentsTestRule.mockLocalSubscriberAccess(initialSubscriber) - val testObserver = RecurringInAppPaymentRepository.ensureSubscriberId( + RecurringInAppPaymentRepository.ensureSubscriberIdSync( subscriberType = InAppPaymentSubscriberRecord.Type.DONATION, isRotation = true - ).test() - - rxRule.defaultScheduler.triggerActions() - testObserver.assertComplete() + ) val newSubscriber = ref.get() @@ -160,98 +144,6 @@ class RecurringInAppPaymentRepositoryTest { } } - @Test - fun `given no delays, when I setSubscriptionLevel, then I expect happy path`() { - val paymentSourceType = PaymentSourceType.Stripe.CreditCard - val inAppPayment = inAppPaymentsTestRule.createInAppPayment(InAppPaymentType.RECURRING_DONATION, paymentSourceType) - InAppPaymentsTestRule.mockLocalSubscriberAccess(createSubscriber()) - - every { SignalStore.inAppPayments.getLevelOperation("500") } returns LevelUpdateOperation(IdempotencyKey.generate(), "500") - every { SignalDatabase.inAppPayments.getById(any()) } returns inAppPayment - every { InAppPaymentsRepository.observeUpdates(inAppPayment.id) } returns Flowable.just(inAppPayment.copy(state = InAppPaymentTable.State.END)) - - val testObserver = RecurringInAppPaymentRepository.setSubscriptionLevel(inAppPayment, paymentSourceType).test() - val processingUpdate = LevelUpdate.isProcessing.test() - - rxRule.defaultScheduler.triggerActions() - - processingUpdate.assertValues(false, true, false) - - testObserver.assertComplete() - } - - @Test - fun `given 10s delay, when I setSubscriptionLevel, then I expect timeout`() { - val paymentSourceType = PaymentSourceType.Stripe.CreditCard - val inAppPayment = inAppPaymentsTestRule.createInAppPayment(InAppPaymentType.RECURRING_DONATION, paymentSourceType) - InAppPaymentsTestRule.mockLocalSubscriberAccess(createSubscriber()) - - every { SignalStore.inAppPayments.getLevelOperation("500") } returns LevelUpdateOperation(IdempotencyKey.generate(), "500") - every { SignalDatabase.inAppPayments.getById(any()) } returns inAppPayment - - val testObserver = RecurringInAppPaymentRepository.setSubscriptionLevel(inAppPayment, paymentSourceType).test() - val processingUpdate = LevelUpdate.isProcessing.test() - - rxRule.defaultScheduler.triggerActions() - - processingUpdate.assertValues(false, true, false) - - rxRule.defaultScheduler.advanceTimeBy(10, TimeUnit.SECONDS) - rxRule.defaultScheduler.triggerActions() - - testObserver.assertError { - it is DonationError.BadgeRedemptionError.TimeoutWaitingForTokenError - } - } - - @Test - fun `given long running payment type with 10s delay, when I setSubscriptionLevel, then I expect pending`() { - val paymentSourceType = PaymentSourceType.Stripe.SEPADebit - val inAppPayment = inAppPaymentsTestRule.createInAppPayment(InAppPaymentType.RECURRING_DONATION, paymentSourceType) - InAppPaymentsTestRule.mockLocalSubscriberAccess(createSubscriber()) - - every { SignalStore.inAppPayments.getLevelOperation("500") } returns LevelUpdateOperation(IdempotencyKey.generate(), "500") - every { SignalDatabase.inAppPayments.getById(any()) } returns inAppPayment - - val testObserver = RecurringInAppPaymentRepository.setSubscriptionLevel(inAppPayment, paymentSourceType).test() - val processingUpdate = LevelUpdate.isProcessing.test() - - rxRule.defaultScheduler.triggerActions() - - processingUpdate.assertValues(false, true, false) - - rxRule.defaultScheduler.advanceTimeBy(10, TimeUnit.SECONDS) - rxRule.defaultScheduler.triggerActions() - - testObserver.assertError { - it is DonationError.BadgeRedemptionError.DonationPending - } - } - - @Test - fun `given an execution error, when I setSubscriptionLevel, then I expect the same error`() { - val expected = NonSuccessfulResponseCodeException(404) - val paymentSourceType = PaymentSourceType.Stripe.SEPADebit - val inAppPayment = inAppPaymentsTestRule.createInAppPayment(InAppPaymentType.RECURRING_DONATION, paymentSourceType) - InAppPaymentsTestRule.mockLocalSubscriberAccess(createSubscriber()) - - every { SignalStore.inAppPayments.getLevelOperation("500") } returns LevelUpdateOperation(IdempotencyKey.generate(), "500") - every { SignalDatabase.inAppPayments.getById(any()) } returns inAppPayment - every { AppDependencies.donationsService.updateSubscriptionLevel(any(), any(), any(), any(), any()) } returns ServiceResponse.forExecutionError(expected) - - val testObserver = RecurringInAppPaymentRepository.setSubscriptionLevel(inAppPayment, paymentSourceType).test() - val processingUpdate = LevelUpdate.isProcessing.distinctUntilChanged().test() - - rxRule.defaultScheduler.triggerActions() - - processingUpdate.assertValues(false, true, false) - - rxRule.defaultScheduler.advanceTimeBy(10, TimeUnit.SECONDS) - rxRule.defaultScheduler.triggerActions() - - testObserver.assertError(expected) - } - private fun createSubscriber(): InAppPaymentSubscriberRecord { return InAppPaymentSubscriberRecord( subscriberId = SubscriberId.generate(), diff --git a/app/src/test/java/org/thoughtcrime/securesms/jobs/InAppPaymentRecurringContextJobTest.kt b/app/src/test/java/org/thoughtcrime/securesms/jobs/InAppPaymentRecurringContextJobTest.kt index 8a2ab672f7..d9d9fdac8c 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/jobs/InAppPaymentRecurringContextJobTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/jobs/InAppPaymentRecurringContextJobTest.kt @@ -534,7 +534,7 @@ class InAppPaymentRecurringContextJobTest { private fun insertInAppPayment( type: InAppPaymentType = InAppPaymentType.RECURRING_DONATION, - state: InAppPaymentTable.State = InAppPaymentTable.State.CREATED, + state: InAppPaymentTable.State = InAppPaymentTable.State.TRANSACTING, subscriberId: SubscriberId? = SubscriberId.generate(), paymentSourceType: PaymentSourceType = PaymentSourceType.Stripe.CreditCard, badge: BadgeList.Badge? = null, @@ -551,6 +551,11 @@ class InAppPaymentRecurringContextJobTest { endOfPeriod = null, inAppPaymentData = iap.data.copy( badge = badge, + waitForAuth = null, + stripeActionComplete = null, + payPalActionComplete = null, + payPalRequiresAction = null, + stripeRequiresAction = null, redemption = redemptionState ) ) diff --git a/donations/lib/src/main/java/org/signal/donations/CreditCardPaymentSource.kt b/donations/lib/src/main/java/org/signal/donations/CreditCardPaymentSource.kt index 6ae00b921c..cf851a1a92 100644 --- a/donations/lib/src/main/java/org/signal/donations/CreditCardPaymentSource.kt +++ b/donations/lib/src/main/java/org/signal/donations/CreditCardPaymentSource.kt @@ -7,7 +7,7 @@ import org.json.JSONObject */ class CreditCardPaymentSource( private val payload: JSONObject -) : StripeApi.PaymentSource { +) : PaymentSource { override val type = PaymentSourceType.Stripe.CreditCard override fun parameterize(): JSONObject = payload override fun getTokenId(): String = parameterize().getString("id") diff --git a/donations/lib/src/main/java/org/signal/donations/GooglePayPaymentSource.kt b/donations/lib/src/main/java/org/signal/donations/GooglePayPaymentSource.kt index 1b5d1afcd0..9043212240 100644 --- a/donations/lib/src/main/java/org/signal/donations/GooglePayPaymentSource.kt +++ b/donations/lib/src/main/java/org/signal/donations/GooglePayPaymentSource.kt @@ -3,7 +3,7 @@ package org.signal.donations import com.google.android.gms.wallet.PaymentData import org.json.JSONObject -class GooglePayPaymentSource(private val paymentData: PaymentData) : StripeApi.PaymentSource { +class GooglePayPaymentSource(private val paymentData: PaymentData) : PaymentSource { override val type = PaymentSourceType.Stripe.GooglePay override fun parameterize(): JSONObject { diff --git a/donations/lib/src/main/java/org/signal/donations/IDEALPaymentSource.kt b/donations/lib/src/main/java/org/signal/donations/IDEALPaymentSource.kt index 15596e0a76..1911c44621 100644 --- a/donations/lib/src/main/java/org/signal/donations/IDEALPaymentSource.kt +++ b/donations/lib/src/main/java/org/signal/donations/IDEALPaymentSource.kt @@ -4,15 +4,10 @@ */ package org.signal.donations -import org.json.JSONObject - class IDEALPaymentSource( val idealData: StripeApi.IDEALData -) : StripeApi.PaymentSource { +) : PaymentSource { override val type: PaymentSourceType = PaymentSourceType.Stripe.IDEAL - override fun parameterize(): JSONObject = error("iDEAL does not support tokenization") - - override fun getTokenId(): String = error("iDEAL does not support tokenization") override fun email(): String? = null } diff --git a/donations/lib/src/main/java/org/signal/donations/PayPalPaymentSource.kt b/donations/lib/src/main/java/org/signal/donations/PayPalPaymentSource.kt new file mode 100644 index 0000000000..13216753e0 --- /dev/null +++ b/donations/lib/src/main/java/org/signal/donations/PayPalPaymentSource.kt @@ -0,0 +1,10 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.donations + +class PayPalPaymentSource : PaymentSource { + override val type: PaymentSourceType = PaymentSourceType.PayPal +} diff --git a/donations/lib/src/main/java/org/signal/donations/PaymentSource.kt b/donations/lib/src/main/java/org/signal/donations/PaymentSource.kt new file mode 100644 index 0000000000..8fb5f044ce --- /dev/null +++ b/donations/lib/src/main/java/org/signal/donations/PaymentSource.kt @@ -0,0 +1,19 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.donations + +import org.json.JSONObject + +/** + * A PaymentSource, being something that can be used to perform a + * transaction. See [PaymentSourceType]. + */ +interface PaymentSource { + val type: PaymentSourceType + fun parameterize(): JSONObject = error("Unsupported by $type.") + fun getTokenId(): String = error("Unsupported by $type.") + fun email(): String? = error("Unsupported by $type.") +} diff --git a/donations/lib/src/main/java/org/signal/donations/SEPADebitPaymentSource.kt b/donations/lib/src/main/java/org/signal/donations/SEPADebitPaymentSource.kt index 73cb7694ac..eb2fad9cc6 100644 --- a/donations/lib/src/main/java/org/signal/donations/SEPADebitPaymentSource.kt +++ b/donations/lib/src/main/java/org/signal/donations/SEPADebitPaymentSource.kt @@ -4,15 +4,10 @@ */ package org.signal.donations -import org.json.JSONObject - class SEPADebitPaymentSource( val sepaDebitData: StripeApi.SEPADebitData -) : StripeApi.PaymentSource { +) : PaymentSource { override val type: PaymentSourceType = PaymentSourceType.Stripe.SEPADebit - override fun parameterize(): JSONObject = error("SEPA Debit does not support tokenization") - - override fun getTokenId(): String = error("SEPA Debit does not support tokenization") override fun email(): String? = null } diff --git a/donations/lib/src/main/java/org/signal/donations/StripeApi.kt b/donations/lib/src/main/java/org/signal/donations/StripeApi.kt index 0e5544debf..9a672ba69c 100644 --- a/donations/lib/src/main/java/org/signal/donations/StripeApi.kt +++ b/donations/lib/src/main/java/org/signal/donations/StripeApi.kt @@ -62,50 +62,50 @@ class StripeApi( data class Failure(val reason: Throwable) : CreatePaymentSourceFromCardDataResult() } - fun createSetupIntent(inAppPaymentType: InAppPaymentType, sourceType: PaymentSourceType.Stripe): Single { + @WorkerThread + fun createSetupIntent(inAppPaymentType: InAppPaymentType, sourceType: PaymentSourceType.Stripe): CreateSetupIntentResult { return setupIntentHelper .fetchSetupIntent(inAppPaymentType, sourceType) - .map { CreateSetupIntentResult(it) } - .subscribeOn(Schedulers.io()) + .let { CreateSetupIntentResult(it) } } - fun confirmSetupIntent(paymentSource: PaymentSource, setupIntent: StripeIntentAccessor): Single { - return Single.fromCallable { - val paymentMethodId = createPaymentMethodAndParseId(paymentSource) + @WorkerThread + fun confirmSetupIntent(paymentSource: PaymentSource, setupIntent: StripeIntentAccessor): Secure3DSAction { + val paymentMethodId = createPaymentMethodAndParseId(paymentSource) - val parameters = mutableMapOf( - "client_secret" to setupIntent.intentClientSecret, - "payment_method" to paymentMethodId, - "return_url" to if (paymentSource is IDEALPaymentSource) RETURN_URL_IDEAL else RETURN_URL_3DS - ) + val parameters = mutableMapOf( + "client_secret" to setupIntent.intentClientSecret, + "payment_method" to paymentMethodId, + "return_url" to if (paymentSource is IDEALPaymentSource) RETURN_URL_IDEAL else RETURN_URL_3DS + ) - if (paymentSource.type.isBankTransfer) { - parameters["mandate_data[customer_acceptance][type]"] = "online" - parameters["mandate_data[customer_acceptance][online][infer_from_client]"] = "true" - } - - val (nextActionUri, returnUri) = postForm(StripePaths.getSetupIntentConfirmationPath(setupIntent.intentId), parameters).use { response -> - getNextAction(response) - } - - Secure3DSAction.from(nextActionUri, returnUri, setupIntent, paymentMethodId) + if (paymentSource.type.isBankTransfer) { + parameters["mandate_data[customer_acceptance][type]"] = "online" + parameters["mandate_data[customer_acceptance][online][infer_from_client]"] = "true" } + + val (nextActionUri, returnUri) = postForm(StripePaths.getSetupIntentConfirmationPath(setupIntent.intentId), parameters).use { response -> + getNextAction(response) + } + + return Secure3DSAction.from(nextActionUri, returnUri, setupIntent, paymentMethodId) } - fun createPaymentIntent(price: FiatMoney, level: Long, sourceType: PaymentSourceType.Stripe): Single { + @WorkerThread + fun createPaymentIntent(price: FiatMoney, level: Long, sourceType: PaymentSourceType.Stripe): CreatePaymentIntentResult { @Suppress("CascadeIf") return if (Validation.isAmountTooSmall(price)) { - Single.just(CreatePaymentIntentResult.AmountIsTooSmall(price)) + CreatePaymentIntentResult.AmountIsTooSmall(price) } else if (Validation.isAmountTooLarge(price)) { - Single.just(CreatePaymentIntentResult.AmountIsTooLarge(price)) + CreatePaymentIntentResult.AmountIsTooLarge(price) } else { if (!Validation.supportedCurrencyCodes.contains(price.currency.currencyCode.uppercase(Locale.ROOT))) { - Single.just(CreatePaymentIntentResult.CurrencyIsNotSupported(price.currency.currencyCode)) + CreatePaymentIntentResult.CurrencyIsNotSupported(price.currency.currencyCode) } else { paymentIntentFetcher .fetchPaymentIntent(price, level, sourceType) - .map { CreatePaymentIntentResult.Success(it) } - }.subscribeOn(Schedulers.io()) + .let { CreatePaymentIntentResult.Success(it) } + } } } @@ -117,27 +117,26 @@ class StripeApi( * * @return A Secure3DSAction */ - fun confirmPaymentIntent(paymentSource: PaymentSource, paymentIntent: StripeIntentAccessor): Single { - return Single.fromCallable { - val paymentMethodId = createPaymentMethodAndParseId(paymentSource) + @WorkerThread + fun confirmPaymentIntent(paymentSource: PaymentSource, paymentIntent: StripeIntentAccessor): Secure3DSAction { + val paymentMethodId = createPaymentMethodAndParseId(paymentSource) - val parameters = mutableMapOf( - "client_secret" to paymentIntent.intentClientSecret, - "payment_method" to paymentMethodId, - "return_url" to if (paymentSource is IDEALPaymentSource) RETURN_URL_IDEAL else RETURN_URL_3DS - ) + val parameters = mutableMapOf( + "client_secret" to paymentIntent.intentClientSecret, + "payment_method" to paymentMethodId, + "return_url" to if (paymentSource is IDEALPaymentSource) RETURN_URL_IDEAL else RETURN_URL_3DS + ) - if (paymentSource.type.isBankTransfer) { - parameters["mandate_data[customer_acceptance][type]"] = "online" - parameters["mandate_data[customer_acceptance][online][infer_from_client]"] = "true" - } + if (paymentSource.type.isBankTransfer) { + parameters["mandate_data[customer_acceptance][type]"] = "online" + parameters["mandate_data[customer_acceptance][online][infer_from_client]"] = "true" + } - val (nextActionUri, returnUri) = postForm(StripePaths.getPaymentIntentConfirmationPath(paymentIntent.intentId), parameters).use { response -> - getNextAction(response) - } + val (nextActionUri, returnUri) = postForm(StripePaths.getPaymentIntentConfirmationPath(paymentIntent.intentId), parameters).use { response -> + getNextAction(response) + } - Secure3DSAction.from(nextActionUri, returnUri, paymentIntent) - }.subscribeOn(Schedulers.io()) + return Secure3DSAction.from(nextActionUri, returnUri, paymentIntent) } /** @@ -158,6 +157,7 @@ class StripeApi( throw StripeError.FailedToParseSetupIntentResponseError(null) } } + else -> error("Unsupported type") } } @@ -181,6 +181,7 @@ class StripeApi( throw StripeError.FailedToParsePaymentIntentResponseError(null) } } + else -> error("Unsupported type") } } @@ -239,6 +240,7 @@ class StripeApi( val paymentMethodResponse = when (paymentSource) { is SEPADebitPaymentSource -> createPaymentMethodForSEPADebit(paymentSource) is IDEALPaymentSource -> createPaymentMethodForIDEAL(paymentSource) + is PayPalPaymentSource -> error("Stripe cannot interact with PayPal payment source.") else -> createPaymentMethodForToken(paymentSource) } @@ -571,18 +573,20 @@ class StripeApi( ) interface PaymentIntentFetcher { + @WorkerThread fun fetchPaymentIntent( price: FiatMoney, level: Long, sourceType: PaymentSourceType.Stripe - ): Single + ): StripeIntentAccessor } interface SetupIntentHelper { + @WorkerThread fun fetchSetupIntent( inAppPaymentType: InAppPaymentType, sourceType: PaymentSourceType.Stripe - ): Single + ): StripeIntentAccessor } @Parcelize @@ -607,13 +611,6 @@ class StripeApi( val email: String ) : Parcelable - interface PaymentSource { - val type: PaymentSourceType - fun parameterize(): JSONObject - fun getTokenId(): String - fun email(): String? - } - sealed interface Secure3DSAction { data class ConfirmRequired(val uri: Uri, val returnUri: Uri, override val stripeIntentAccessor: StripeIntentAccessor, override val paymentMethodId: String?) : Secure3DSAction data class NotNeeded(override val paymentMethodId: String?, override val stripeIntentAccessor: StripeIntentAccessor) : Secure3DSAction diff --git a/donations/lib/src/main/java/org/signal/donations/TokenPaymentSource.kt b/donations/lib/src/main/java/org/signal/donations/TokenPaymentSource.kt new file mode 100644 index 0000000000..af4b2fe0ff --- /dev/null +++ b/donations/lib/src/main/java/org/signal/donations/TokenPaymentSource.kt @@ -0,0 +1,22 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.donations + +import org.json.JSONObject + +class TokenPaymentSource( + override val type: PaymentSourceType, + val parameters: String, + val token: String, + val email: String? +) : PaymentSource { + + override fun parameterize(): JSONObject = JSONObject(parameters) + + override fun getTokenId(): String = token + + override fun email(): String? = email +}