Fix bad state with lapsed in app donation.

This commit is contained in:
Alex Hart
2026-08-25 14:48:16 -03:00
parent 8c37e6b2d4
commit dd510f2a06
3 changed files with 72 additions and 19 deletions
@@ -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()
)
}
)
}
}
}
@@ -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
}
@@ -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", "", "", "", "")