From 4d640ec467fa8be8d708ac76dfbafe7f572c30d5 Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Wed, 27 Sep 2023 15:24:45 -0400 Subject: [PATCH] Donation CreatePaymentMethod 409 error recovery. --- .../subscription/MonthlyDonationRepository.kt | 40 +++++++++++++++++-- .../app/subscription/PayPalRepository.kt | 17 +++++++- .../app/subscription/StripeRepository.kt | 23 +++++++++-- 3 files changed, 72 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/MonthlyDonationRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/MonthlyDonationRepository.kt index 058bd522d9..e29dc6b294 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/MonthlyDonationRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/MonthlyDonationRepository.kt @@ -79,9 +79,29 @@ class MonthlyDonationRepository(private val donationsService: DonationsService) }.subscribeOn(Schedulers.io()) } - fun ensureSubscriberId(): Completable { - Log.d(TAG, "Ensuring SubscriberId exists on Signal service...", true) - val subscriberId = SignalStore.donationsValues().getSubscriber()?.subscriberId ?: SubscriberId.generate() + /** + * Since PayPal and Stripe can't interoperate, we need to be able to rotate the subscriber ID + * in case of failures. + */ + fun rotateSubscriberId(): Completable { + Log.d(TAG, "Rotating SubscriberId due to alternate payment processor...", true) + val cancelCompletable: Completable = if (SignalStore.donationsValues().getSubscriber() != null) { + cancelActiveSubscription().andThen(updateLocalSubscriptionStateAndScheduleDataSync()) + } else { + Completable.complete() + } + + return cancelCompletable.andThen(ensureSubscriberId(isRotation = true)) + } + + fun ensureSubscriberId(isRotation: Boolean = false): Completable { + Log.d(TAG, "Ensuring SubscriberId exists on Signal service {isRotation?$isRotation}...", true) + val subscriberId: SubscriberId = if (isRotation) { + SubscriberId.generate() + } else { + SignalStore.donationsValues().getSubscriber()?.subscriberId ?: SubscriberId.generate() + } + return Single .fromCallable { donationsService.putSubscription(subscriberId) @@ -222,4 +242,18 @@ class MonthlyDonationRepository(private val donationsService: DonationsService) levelUpdateOperation } } + + /** + * 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(): Completable { + return Completable.fromAction { + Log.d(TAG, "Marking subscription cancelled...", true) + SignalStore.donationsValues().updateLocalStateForManualCancellation() + MultiDeviceSubscriptionSyncRequestJob.enqueue() + SignalDatabase.recipients.markNeedsSync(Recipient.self().id) + StorageSyncHelper.scheduleSyncForDataChange() + } + } } 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 76254c834e..2bc0572bd6 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 @@ -29,6 +29,8 @@ class PayPalRepository(private val donationsService: DonationsService) { private val TAG = Log.tag(PayPalRepository::class.java) } + private val monthlyDonationRepository = MonthlyDonationRepository(donationsService) + fun createOneTimePaymentIntent( amount: FiatMoney, badgeRecipient: RecipientId, @@ -69,7 +71,12 @@ class PayPalRepository(private val donationsService: DonationsService) { }.flatMap { it.flattenResult() }.subscribeOn(Schedulers.io()) } - fun createPaymentMethod(): Single { + /** + * 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 Stripe account. We can retry in this + * situation by simply deleting the old subscriber id on the service and replacing it. + */ + fun createPaymentMethod(retryOn409: Boolean = true): Single { return Single.fromCallable { donationsService.createPayPalPaymentMethod( Locale.getDefault(), @@ -77,7 +84,13 @@ class PayPalRepository(private val donationsService: DonationsService) { MONTHLY_RETURN_URL, CANCEL_URL ) - }.flatMap { it.flattenResult() }.subscribeOn(Schedulers.io()) + }.flatMap { serviceResponse -> + if (retryOn409 && serviceResponse.status == 409) { + monthlyDonationRepository.rotateSubscriberId().andThen(createPaymentMethod(retryOn409 = false)) + } else { + serviceResponse.flattenResult() + } + }.subscribeOn(Schedulers.io()) } fun setDefaultPaymentMethod(paymentMethodId: String): Completable { 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 9b630bd2d4..e33337bb28 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 @@ -47,6 +47,7 @@ class StripeRepository(activity: Activity) : StripeApi.PaymentIntentFetcher, Str 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, ApplicationDependencies.getOkHttpClient()) + private val monthlyDonationRepository = MonthlyDonationRepository(ApplicationDependencies.getDonationsService()) fun isGooglePayAvailable(): Completable { return googlePayApi.queryIsReadyToPay() @@ -153,8 +154,12 @@ class StripeRepository(activity: Activity) : StripeApi.PaymentIntentFetcher, Str } } - override fun fetchSetupIntent(): Single { - Log.d(TAG, "Fetching setup intent from Signal service...") + /** + * 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(retryOn409: Boolean = true): Single { return Single.fromCallable { SignalStore.donationsValues().requireSubscriber() } .flatMap { Single.fromCallable { @@ -163,7 +168,18 @@ class StripeRepository(activity: Activity) : StripeApi.PaymentIntentFetcher, Str .createStripeSubscriptionPaymentMethod(it.subscriberId) } } - .flatMap(ServiceResponse::flattenResult) + .flatMap { serviceResponse -> + if (retryOn409 && serviceResponse.status == 409) { + monthlyDonationRepository.rotateSubscriberId().andThen(createPaymentMethod(retryOn409 = false)) + } else { + serviceResponse.flattenResult() + } + } + } + + override fun fetchSetupIntent(): Single { + Log.d(TAG, "Fetching setup intent from Signal service...") + return createPaymentMethod() .map { StripeIntentAccessor( objectType = StripeIntentAccessor.ObjectType.SETUP_INTENT, @@ -191,6 +207,7 @@ class StripeRepository(activity: Activity) : StripeApi.PaymentIntentFetcher, Str } StatusAndPaymentMethodId(it.status ?: StripeIntentStatus.SUCCEEDED, it.paymentMethod) } + StripeIntentAccessor.ObjectType.SETUP_INTENT -> stripeApi.getSetupIntent(stripeIntentAccessor).let { StatusAndPaymentMethodId(it.status, it.paymentMethod) }