From dd510f2a06107df467be53a51614aa2386cd2025 Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Tue, 25 Aug 2026 14:48:16 -0300 Subject: [PATCH] Fix bad state with lapsed in app donation. --- .../subscription/InAppPaymentsRepository.kt | 51 ++++++++++++------- .../jobs/InAppPaymentKeepAliveJob.kt | 7 ++- .../jobs/InAppPaymentKeepAliveJobTest.kt | 33 ++++++++++++ 3 files changed, 72 insertions(+), 19 deletions(-) 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 51525a616b..7c4fcc8731 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 @@ -97,27 +97,42 @@ object InAppPaymentsRepository { */ fun updateInAppPaymentWithCancelation(activeSubscription: ActiveSubscription, subscriberType: InAppPaymentSubscriberRecord.Type) { if (activeSubscription.isCanceled || (subscriberType == InAppPaymentSubscriberRecord.Type.BACKUP && activeSubscription.willCancelAtPeriodEnd()) || activeSubscription.isFailedPayment) { - val subscriber = getSubscriber(subscriberType) ?: return - val latestPayment = SignalDatabase.inAppPayments.getLatestBySubscriberId(subscriber.subscriberId) ?: return - if (latestPayment.state == InAppPaymentTable.State.END && latestPayment.data.cancellation == null) { - synchronized(subscriber.type.lock) { - val payment = SignalDatabase.inAppPayments.getLatestBySubscriberId(subscriber.subscriberId) ?: return - val chargeFailure: ActiveSubscription.ChargeFailure? = activeSubscription.chargeFailure + writeCancelation(subscriberType, activeSubscription.chargeFailure) + } + } - Log.i(TAG, "[$subscriberType] Recording cancelation in the database. (has charge failure? ${chargeFailure != null})") - SignalDatabase.inAppPayments.update( - payment.copy( - data = payment.data.newBuilder() - .cancellation( - InAppPaymentData.Cancellation( - reason = if (chargeFailure != null) InAppPaymentData.Cancellation.Reason.PAST_DUE else InAppPaymentData.Cancellation.Reason.CANCELED, - chargeFailure = chargeFailure?.toInAppPaymentDataChargeFailure() - ) + /** + * Records a cancelation for a subscriber whose subscription is no longer present on the server at all. None of the + * predicates in [updateInAppPaymentWithCancelation] can detect that state, as every one of them requires a subscription + * object to inspect. Callers must have confirmed the subscription is gone rather than merely terminal. + * + * As with [updateInAppPaymentWithCancelation], this only writes if the latest payment is in the END state without + * cancelation data. + */ + fun updateInAppPaymentWithLapsedSubscription(subscriberType: InAppPaymentSubscriberRecord.Type) { + writeCancelation(subscriberType, chargeFailure = null) + } + + private fun writeCancelation(subscriberType: InAppPaymentSubscriberRecord.Type, chargeFailure: ActiveSubscription.ChargeFailure?) { + val subscriber = getSubscriber(subscriberType) ?: return + val latestPayment = SignalDatabase.inAppPayments.getLatestBySubscriberId(subscriber.subscriberId) ?: return + if (latestPayment.state == InAppPaymentTable.State.END && latestPayment.data.cancellation == null) { + synchronized(subscriber.type.lock) { + val payment = SignalDatabase.inAppPayments.getLatestBySubscriberId(subscriber.subscriberId) ?: return + + Log.i(TAG, "[$subscriberType] Recording cancelation in the database. (has charge failure? ${chargeFailure != null})") + SignalDatabase.inAppPayments.update( + payment.copy( + data = payment.data.newBuilder() + .cancellation( + InAppPaymentData.Cancellation( + reason = if (chargeFailure != null) InAppPaymentData.Cancellation.Reason.PAST_DUE else InAppPaymentData.Cancellation.Reason.CANCELED, + chargeFailure = chargeFailure?.toInAppPaymentDataChargeFailure() ) - .build() - ) + ) + .build() ) - } + ) } } } 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 a96e553f27..0740369488 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentKeepAliveJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentKeepAliveJob.kt @@ -149,7 +149,12 @@ class InAppPaymentKeepAliveJob private constructor( val subscription: ActiveSubscription.Subscription? = activeSubscription.activeSubscription if (subscription == null) { - info(type, "User does not have a subscription. Exiting.") + info(type, "User does not have a subscription.") + + if (type == InAppPaymentSubscriberRecord.Type.DONATION) { + InAppPaymentsRepository.updateInAppPaymentWithLapsedSubscription(type) + } + return } diff --git a/app/src/test/java/org/thoughtcrime/securesms/jobs/InAppPaymentKeepAliveJobTest.kt b/app/src/test/java/org/thoughtcrime/securesms/jobs/InAppPaymentKeepAliveJobTest.kt index d78d7a3dba..f46c22d28c 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/jobs/InAppPaymentKeepAliveJobTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/jobs/InAppPaymentKeepAliveJobTest.kt @@ -25,6 +25,7 @@ import org.thoughtcrime.securesms.dependencies.AppDependencies import org.thoughtcrime.securesms.testutil.MockAppDependenciesRule import org.thoughtcrime.securesms.testutil.MockSignalStoreRule import org.thoughtcrime.securesms.testutil.SystemOutLogger +import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription.ChargeFailure import org.whispersystems.signalservice.api.subscriptions.SubscriberId import org.whispersystems.signalservice.internal.EmptyResponse @@ -93,6 +94,38 @@ class InAppPaymentKeepAliveJobTest { verify(exactly = 1) { InAppPaymentsRepository.updateInAppPaymentWithCancelation(activeSubscription, InAppPaymentSubscriberRecord.Type.DONATION) } } + @Test + fun `Given no subscription on the server, when I run, then I write cancellation and return early`() { + inAppPaymentsTestRule.initializeActiveSubscriptionMock(activeSubscription = ActiveSubscription(null, null)) + every { InAppPaymentsRepository.updateInAppPaymentWithLapsedSubscription(any()) } returns Unit + + val job = InAppPaymentKeepAliveJob.create(InAppPaymentSubscriberRecord.Type.DONATION) + val result = job.run() + + assertThat(result.isSuccess).isTrue() + verify(exactly = 1) { InAppPaymentsRepository.updateInAppPaymentWithLapsedSubscription(InAppPaymentSubscriberRecord.Type.DONATION) } + } + + @Test + fun `Given no subscription on the server for a backup subscriber, when I run, then I do not write cancellation`() { + val backupSubscriber = InAppPaymentSubscriberRecord( + subscriberId = SubscriberId.generate(), + currency = java.util.Currency.getInstance("USD"), + type = InAppPaymentSubscriberRecord.Type.BACKUP, + requiresCancel = false, + paymentMethodType = org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData.PaymentMethodType.CARD, + iapSubscriptionId = null + ) + every { InAppPaymentsRepository.getSubscriber(InAppPaymentSubscriberRecord.Type.BACKUP) } returns backupSubscriber + inAppPaymentsTestRule.initializeActiveSubscriptionMock(activeSubscription = ActiveSubscription(null, null)) + every { InAppPaymentsRepository.updateInAppPaymentWithLapsedSubscription(any()) } returns Unit + + val job = InAppPaymentKeepAliveJob.create(InAppPaymentSubscriberRecord.Type.BACKUP) + job.run() + + verify(exactly = 0) { InAppPaymentsRepository.updateInAppPaymentWithLapsedSubscription(any()) } + } + @Test fun `Given a past-due subscription with charge failure, when I run, then I do not write cancellation`() { val chargeFailure = ChargeFailure("test", "", "", "", "")