diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsViewModelTest.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsViewModelTest.kt new file mode 100644 index 0000000000..2433dce488 --- /dev/null +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsViewModelTest.kt @@ -0,0 +1,336 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.app.subscription.manage + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isNotNull +import assertk.assertions.isNull +import assertk.assertions.isTrue +import io.reactivex.rxjava3.observers.TestObserver +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.signal.core.util.deleteAll +import org.signal.core.util.money.FiatMoney +import org.signal.donations.InAppPaymentType +import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toFiatValue +import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository +import org.thoughtcrime.securesms.database.InAppPaymentTable +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.database.model.databaseprotos.BadgeList +import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData +import org.thoughtcrime.securesms.testing.SignalActivityRule +import java.math.BigDecimal +import java.util.Currency +import kotlin.time.Duration.Companion.seconds + +@RunWith(AndroidJUnit4::class) +class ManageDonationsViewModelTest { + + @get:Rule + val harness = SignalActivityRule() + + private val testAmount = FiatMoney(BigDecimal.valueOf(5), Currency.getInstance("USD")).toFiatValue() + private val testBadge = BadgeList.Badge(id = "test-badge") + + @Before + fun setUp() { + SignalDatabase.inAppPayments.writableDatabase.deleteAll(InAppPaymentTable.TABLE_NAME) + } + + @Test + fun givenEndRecordWithNoError_whenIQueryLatest_thenIGetActiveSubscription() { + SignalDatabase.inAppPayments.insert( + type = InAppPaymentType.RECURRING_DONATION, + state = InAppPaymentTable.State.END, + subscriberId = null, + endOfPeriod = 1000.seconds, + inAppPaymentData = InAppPaymentData( + level = 500L, + amount = testAmount, + badge = testBadge + ) + ) + + val latest = SignalDatabase.inAppPayments.getLatestInAppPaymentByType(InAppPaymentType.RECURRING_DONATION) + assertThat(latest).isNotNull() + assertThat(latest!!.state).isEqualTo(InAppPaymentTable.State.END) + assertThat(latest.data.cancellation).isNull() + } + + @Test + fun givenEmptyDatabase_whenIQueryLatest_thenIGetNull() { + val latest = SignalDatabase.inAppPayments.getLatestInAppPaymentByType(InAppPaymentType.RECURRING_DONATION) + assertThat(latest).isNull() + } + + @Test + fun givenTransactingRecord_whenIQueryLatest_thenItIsReturned() { + val id = SignalDatabase.inAppPayments.insert( + type = InAppPaymentType.RECURRING_DONATION, + state = InAppPaymentTable.State.CREATED, + subscriberId = null, + endOfPeriod = null, + inAppPaymentData = InAppPaymentData( + level = 500L, + amount = testAmount + ) + ) + + SignalDatabase.inAppPayments.moveToTransacting(id) + + val latest = SignalDatabase.inAppPayments.getLatestInAppPaymentByType(InAppPaymentType.RECURRING_DONATION) + assertThat(latest).isNotNull() + assertThat(latest!!.state).isEqualTo(InAppPaymentTable.State.TRANSACTING) + } + + @Test + fun givenCreatedRecord_whenIQueryLatest_thenItIsFiltered() { + SignalDatabase.inAppPayments.insert( + type = InAppPaymentType.RECURRING_DONATION, + state = InAppPaymentTable.State.CREATED, + subscriberId = null, + endOfPeriod = null, + inAppPaymentData = InAppPaymentData() + ) + + val latest = SignalDatabase.inAppPayments.getLatestInAppPaymentByType(InAppPaymentType.RECURRING_DONATION) + assertThat(latest).isNull() + } + + @Test + fun givenEndRecordWithError_whenIObserveRedemption_thenIGetFailedSubscription() { + SignalDatabase.inAppPayments.insert( + type = InAppPaymentType.RECURRING_DONATION, + state = InAppPaymentTable.State.END, + subscriberId = null, + endOfPeriod = 1000.seconds, + inAppPaymentData = InAppPaymentData( + level = 500L, + amount = testAmount, + error = InAppPaymentData.Error(type = InAppPaymentData.Error.Type.PAYMENT_PROCESSING) + ) + ) + + val observer = TestObserver() + InAppPaymentsRepository.observeInAppPaymentRedemption(InAppPaymentType.RECURRING_DONATION) + .take(1) + .subscribe(observer) + observer.awaitCount(1) + observer.assertValue(DonationRedemptionJobStatus.FailedSubscription) + } + + @Test + fun givenEndRecordWithCancellation_whenIObserveRedemption_thenIGetNone() { + SignalDatabase.inAppPayments.insert( + type = InAppPaymentType.RECURRING_DONATION, + state = InAppPaymentTable.State.END, + subscriberId = null, + endOfPeriod = 1000.seconds, + inAppPaymentData = InAppPaymentData( + level = 500L, + amount = testAmount, + cancellation = InAppPaymentData.Cancellation(reason = InAppPaymentData.Cancellation.Reason.CANCELED) + ) + ) + + val observer = TestObserver() + InAppPaymentsRepository.observeInAppPaymentRedemption(InAppPaymentType.RECURRING_DONATION) + .take(1) + .subscribe(observer) + observer.awaitCount(1) + observer.assertValue(DonationRedemptionJobStatus.None) + } + + @Test + fun givenPendingBankTransferRecord_whenIObserveRedemption_thenIGetPendingExternalVerification() { + SignalDatabase.inAppPayments.insert( + type = InAppPaymentType.RECURRING_DONATION, + state = InAppPaymentTable.State.WAITING_FOR_AUTHORIZATION, + subscriberId = null, + endOfPeriod = null, + inAppPaymentData = InAppPaymentData( + level = 500L, + amount = FiatMoney(BigDecimal.valueOf(5), Currency.getInstance("EUR")).toFiatValue(), + paymentMethodType = InAppPaymentData.PaymentMethodType.SEPA_DEBIT, + waitForAuth = InAppPaymentData.WaitingForAuthorizationState() + ) + ) + + val observer = TestObserver() + InAppPaymentsRepository.observeInAppPaymentRedemption(InAppPaymentType.RECURRING_DONATION) + .take(1) + .subscribe(observer) + observer.awaitCount(1) + + val status = observer.values().first() + assertThat(status is DonationRedemptionJobStatus.PendingExternalVerification).isTrue() + } + + @Test + fun givenKeepAlivePendingRecord_whenIObserveRedemption_thenIGetPendingKeepAlive() { + SignalDatabase.inAppPayments.insert( + type = InAppPaymentType.RECURRING_DONATION, + state = InAppPaymentTable.State.PENDING, + subscriberId = null, + endOfPeriod = 1000.seconds, + inAppPaymentData = InAppPaymentData( + level = 500L, + amount = testAmount, + redemption = InAppPaymentData.RedemptionState( + stage = InAppPaymentData.RedemptionState.Stage.INIT, + keepAlive = true + ) + ) + ) + + val observer = TestObserver() + InAppPaymentsRepository.observeInAppPaymentRedemption(InAppPaymentType.RECURRING_DONATION) + .take(1) + .subscribe(observer) + observer.awaitCount(1) + observer.assertValue(DonationRedemptionJobStatus.PendingKeepAlive) + } + + @Test + fun givenPendingOneTimeDonation_whenIObserveRedemption_thenIGetPendingStatus() { + SignalDatabase.inAppPayments.insert( + type = InAppPaymentType.ONE_TIME_DONATION, + state = InAppPaymentTable.State.PENDING, + subscriberId = null, + endOfPeriod = null, + inAppPaymentData = InAppPaymentData( + level = 1L, + amount = testAmount, + badge = testBadge, + redemption = InAppPaymentData.RedemptionState( + stage = InAppPaymentData.RedemptionState.Stage.INIT + ) + ) + ) + + val observer = TestObserver() + InAppPaymentsRepository.observeInAppPaymentRedemption(InAppPaymentType.ONE_TIME_DONATION) + .take(1) + .subscribe(observer) + observer.awaitCount(1) + observer.assertValue(DonationRedemptionJobStatus.PendingReceiptRequest) + } + + @Test + fun givenEndRecordWithNonRedemptionError_whenICheckPaymentFailure_thenItIsTrue() { + val id = SignalDatabase.inAppPayments.insert( + type = InAppPaymentType.RECURRING_DONATION, + state = InAppPaymentTable.State.END, + subscriberId = null, + endOfPeriod = 1000.seconds, + inAppPaymentData = InAppPaymentData( + level = 500L, + amount = testAmount, + error = InAppPaymentData.Error(type = InAppPaymentData.Error.Type.PAYMENT_PROCESSING) + ) + ) + + val payment = SignalDatabase.inAppPayments.getById(id)!! + val isPaymentFailure = payment.data.error?.let { + it.type != InAppPaymentData.Error.Type.REDEMPTION + } ?: false + + assertThat(isPaymentFailure).isTrue() + } + + @Test + fun givenEndRecordWithRedemptionError_whenICheckPaymentFailure_thenItIsFalse() { + val id = SignalDatabase.inAppPayments.insert( + type = InAppPaymentType.RECURRING_DONATION, + state = InAppPaymentTable.State.END, + subscriberId = null, + endOfPeriod = 1000.seconds, + inAppPaymentData = InAppPaymentData( + level = 500L, + amount = testAmount, + error = InAppPaymentData.Error(type = InAppPaymentData.Error.Type.REDEMPTION) + ) + ) + + val payment = SignalDatabase.inAppPayments.getById(id)!! + val isPaymentFailure = payment.data.error?.let { + it.type != InAppPaymentData.Error.Type.REDEMPTION + } ?: false + + assertThat(isPaymentFailure).isEqualTo(false) + } + + @Test + fun givenStateTransition_whenIUpdateRecord_thenObserverSeesNewState() { + val id = SignalDatabase.inAppPayments.insert( + type = InAppPaymentType.RECURRING_DONATION, + state = InAppPaymentTable.State.PENDING, + subscriberId = null, + endOfPeriod = 1000.seconds, + inAppPaymentData = InAppPaymentData( + level = 500L, + amount = testAmount, + redemption = InAppPaymentData.RedemptionState( + stage = InAppPaymentData.RedemptionState.Stage.INIT, + keepAlive = true + ) + ) + ) + + val observer = TestObserver() + InAppPaymentsRepository.observeInAppPaymentRedemption(InAppPaymentType.RECURRING_DONATION) + .take(2) + .subscribe(observer) + + observer.awaitCount(1) + assertThat(observer.values().first()).isEqualTo(DonationRedemptionJobStatus.PendingKeepAlive) + + val payment = SignalDatabase.inAppPayments.getById(id)!! + SignalDatabase.inAppPayments.update( + payment.copy( + state = InAppPaymentTable.State.END, + data = payment.data.copy( + error = InAppPaymentData.Error(type = InAppPaymentData.Error.Type.PAYMENT_PROCESSING) + ) + ) + ) + + observer.awaitCount(2) + assertThat(observer.values().last()).isEqualTo(DonationRedemptionJobStatus.FailedSubscription) + } + + @Test + fun givenNonVerifiedIdealRecurring_whenIObserveRedemption_thenIGetNonVerifiedMonthlyDonation() { + SignalDatabase.inAppPayments.insert( + type = InAppPaymentType.RECURRING_DONATION, + state = InAppPaymentTable.State.WAITING_FOR_AUTHORIZATION, + subscriberId = null, + endOfPeriod = null, + inAppPaymentData = InAppPaymentData( + level = 500L, + amount = FiatMoney(BigDecimal.valueOf(5), Currency.getInstance("EUR")).toFiatValue(), + paymentMethodType = InAppPaymentData.PaymentMethodType.IDEAL, + waitForAuth = InAppPaymentData.WaitingForAuthorizationState(checkedVerification = true) + ) + ) + + val observer = TestObserver() + InAppPaymentsRepository.observeInAppPaymentRedemption(InAppPaymentType.RECURRING_DONATION) + .take(1) + .subscribe(observer) + observer.awaitCount(1) + + val status = observer.values().first() + assertThat(status is DonationRedemptionJobStatus.PendingExternalVerification).isTrue() + val verification = status as DonationRedemptionJobStatus.PendingExternalVerification + assertThat(verification.nonVerifiedMonthlyDonation).isNotNull() + assertThat(verification.nonVerifiedMonthlyDonation!!.checkedVerification).isTrue() + } +} 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 727a3598c7..cfaf2489dc 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 @@ -81,15 +81,24 @@ object InAppPaymentsRepository { * This operation will only be performed if we find a latest payment for the given subscriber id in the END state without cancelation data */ fun updateBackupInAppPaymentWithCancelation(activeSubscription: ActiveSubscription) { - if (activeSubscription.isCanceled || activeSubscription.willCancelAtPeriodEnd()) { - val subscriber = getSubscriber(InAppPaymentSubscriberRecord.Type.BACKUP) ?: return + updateInAppPaymentWithCancelation(activeSubscription, InAppPaymentSubscriberRecord.Type.BACKUP) + } + + /** + * Updates the latest payment object for the given subscriber type with cancelation information as necessary. + * + * This operation will only be performed if we find a latest payment for the given subscriber id in the END state without cancelation data. + */ + 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 - Log.i(TAG, "Recording cancelation in the database. (has charge failure? ${chargeFailure != null})") + Log.i(TAG, "[$subscriberType] Recording cancelation in the database. (has charge failure? ${chargeFailure != null})") SignalDatabase.inAppPayments.update( payment.copy( data = payment.data.newBuilder() diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ActiveSubscriptionPreference.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ActiveSubscriptionPreference.kt index ecf56678bc..f24534a35c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ActiveSubscriptionPreference.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ActiveSubscriptionPreference.kt @@ -17,7 +17,6 @@ import org.thoughtcrime.securesms.util.adapter.mapping.BindingFactory import org.thoughtcrime.securesms.util.adapter.mapping.BindingViewHolder import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter import org.thoughtcrime.securesms.util.visible -import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription import java.util.Locale /** @@ -31,7 +30,7 @@ object ActiveSubscriptionPreference { val subscription: Subscription, val renewalTimestamp: Long = -1L, val redemptionState: ManageDonationsState.RedemptionState, - val activeSubscription: ActiveSubscription.Subscription?, + val isPaymentFailure: Boolean = false, val subscriberRequiresCancel: Boolean, val onContactSupport: () -> Unit, val onRowClick: (ManageDonationsState.RedemptionState) -> Unit @@ -46,7 +45,7 @@ object ActiveSubscriptionPreference { renewalTimestamp == newItem.renewalTimestamp && redemptionState == newItem.redemptionState && FiatMoney.equals(price, newItem.price) && - activeSubscription == newItem.activeSubscription + isPaymentFailure == newItem.isPaymentFailure } } @@ -112,7 +111,7 @@ object ActiveSubscriptionPreference { } private fun presentFailureState(model: Model) { - if (model.activeSubscription?.isFailedPayment == true || model.subscriberRequiresCancel) { + if (model.isPaymentFailure || model.subscriberRequiresCancel) { presentPaymentFailureState(model) } else { presentRedemptionFailureState(model) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsFragment.kt index 6dd01cb7ba..d60d52f3a5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsFragment.kt @@ -33,9 +33,12 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.models.Ne import org.thoughtcrime.securesms.components.settings.app.subscription.thanks.ThanksForYourSupportBottomSheetDialogFragment import org.thoughtcrime.securesms.components.settings.configure import org.thoughtcrime.securesms.components.settings.models.IndeterminateLoadingCircle +import org.thoughtcrime.securesms.database.InAppPaymentTable import org.thoughtcrime.securesms.database.model.databaseprotos.DonationErrorValue +import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData import org.thoughtcrime.securesms.database.model.databaseprotos.PendingOneTimeDonation import org.thoughtcrime.securesms.help.HelpFragment +import org.thoughtcrime.securesms.jobs.InAppPaymentKeepAliveJob import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.payments.FiatMoneyUtil import org.thoughtcrime.securesms.subscription.Subscription @@ -44,9 +47,7 @@ import org.thoughtcrime.securesms.util.Material3OnScrollHelper import org.thoughtcrime.securesms.util.SpanUtil import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter import org.thoughtcrime.securesms.util.navigation.safeNavigate -import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription -import java.util.Currency -import java.util.concurrent.TimeUnit +import kotlin.time.Duration.Companion.seconds import org.signal.core.ui.R as CoreUiR /** @@ -193,13 +194,16 @@ class ManageDonationsFragment : space(16.dp) - if (state.subscriptionTransactionState is ManageDonationsState.TransactionState.NotInTransaction) { - val activeSubscription = state.subscriptionTransactionState.activeSubscription.activeSubscription - - if (activeSubscription != null && !activeSubscription.isCanceled) { - val subscription: Subscription? = state.availableSubscriptions.firstOrNull { it.level == activeSubscription.level } + if (!state.isLoaded) { + customPref(IndeterminateLoadingCircle) + } else if (state.networkError) { + presentNetworkFailureSettings(state, state.hasReceipts) + } else { + val payment = state.activeSubscription + if (payment != null && payment.data.cancellation == null) { + val subscription: Subscription? = state.availableSubscriptions.firstOrNull { it.level == payment.data.level.toInt() } if (subscription != null) { - presentSubscriptionSettings(activeSubscription, subscription, state) + presentSubscriptionSettings(payment, subscription, state) } else { customPref(IndeterminateLoadingCircle) } @@ -215,10 +219,6 @@ class ManageDonationsFragment : } else { presentNotADonorSettings(state.hasReceipts) } - } else if (state.subscriptionTransactionState == ManageDonationsState.TransactionState.NetworkFailure) { - presentNetworkFailureSettings(state, state.hasReceipts) - } else { - customPref(IndeterminateLoadingCircle) } } } @@ -275,22 +275,28 @@ class ManageDonationsFragment : } private fun DSLConfiguration.presentSubscriptionSettings( - activeSubscription: ActiveSubscription.Subscription, + payment: InAppPaymentTable.InAppPayment, subscription: Subscription, state: ManageDonationsState ) { + val price = payment.data.amount!!.toFiatMoney() + val renewalTimestamp = payment.endOfPeriodSeconds.seconds.inWholeMilliseconds + val isPaymentFailure = payment.data.error?.let { + it.type != InAppPaymentData.Error.Type.REDEMPTION && it.data_ != InAppPaymentKeepAliveJob.KEEP_ALIVE + } ?: false + presentSubscriptionSettingsWithState(state) { customPref( ActiveSubscriptionPreference.Model( - price = FiatMoney.fromSignalNetworkAmount(activeSubscription.amount, Currency.getInstance(activeSubscription.currency)), + price = price, subscription = subscription, - renewalTimestamp = TimeUnit.SECONDS.toMillis(activeSubscription.endOfCurrentPeriod), - redemptionState = state.getMonthlyDonorRedemptionState(), + renewalTimestamp = renewalTimestamp, + redemptionState = state.subscriptionRedemptionState, onContactSupport = { requireActivity().finish() requireActivity().startActivity(AppSettingsActivity.help(requireContext(), HelpFragment.DONATION_INDEX)) }, - activeSubscription = activeSubscription, + isPaymentFailure = isPaymentFailure, subscriberRequiresCancel = state.subscriberRequiresCancel, onRowClick = { launcher.launch(InAppPaymentType.RECURRING_DONATION) @@ -312,7 +318,6 @@ class ManageDonationsFragment : subscription = subscription, redemptionState = ManageDonationsState.RedemptionState.IN_PROGRESS, onContactSupport = {}, - activeSubscription = null, subscriberRequiresCancel = state.subscriberRequiresCancel, onRowClick = {} ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsState.kt index b7127e8fc5..d8b6cc8037 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsState.kt @@ -1,47 +1,24 @@ package org.thoughtcrime.securesms.components.settings.app.subscription.manage import org.thoughtcrime.securesms.badges.models.Badge +import org.thoughtcrime.securesms.database.InAppPaymentTable import org.thoughtcrime.securesms.database.model.databaseprotos.PendingOneTimeDonation import org.thoughtcrime.securesms.subscription.Subscription -import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription data class ManageDonationsState( val hasOneTimeBadge: Boolean = false, val hasReceipts: Boolean = false, val featuredBadge: Badge? = null, - val subscriptionTransactionState: TransactionState = TransactionState.Init, + val isLoaded: Boolean = false, + val networkError: Boolean = false, val availableSubscriptions: List = emptyList(), + val activeSubscription: InAppPaymentTable.InAppPayment? = null, + val subscriptionRedemptionState: RedemptionState = RedemptionState.NONE, val pendingOneTimeDonation: PendingOneTimeDonation? = null, val nonVerifiedMonthlyDonation: NonVerifiedMonthlyDonation? = null, - val subscriberRequiresCancel: Boolean = false, - private val subscriptionRedemptionState: RedemptionState = RedemptionState.NONE + val subscriberRequiresCancel: Boolean = false ) { - fun getMonthlyDonorRedemptionState(): RedemptionState { - return when (subscriptionTransactionState) { - TransactionState.Init -> subscriptionRedemptionState - TransactionState.NetworkFailure -> subscriptionRedemptionState - TransactionState.InTransaction -> RedemptionState.IN_PROGRESS - is TransactionState.NotInTransaction -> getStateFromActiveSubscription(subscriptionTransactionState.activeSubscription) ?: subscriptionRedemptionState - } - } - - private fun getStateFromActiveSubscription(activeSubscription: ActiveSubscription): RedemptionState? { - return when { - activeSubscription.isFailedPayment && !activeSubscription.isPastDue -> RedemptionState.FAILED - activeSubscription.isPendingBankTransfer -> RedemptionState.IS_PENDING_BANK_TRANSFER - activeSubscription.isInProgress -> RedemptionState.IN_PROGRESS - else -> null - } - } - - sealed class TransactionState { - data object Init : TransactionState() - data object NetworkFailure : TransactionState() - data object InTransaction : TransactionState() - class NotInTransaction(val activeSubscription: ActiveSubscription) : TransactionState() - } - enum class RedemptionState { NONE, IN_PROGRESS, diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsViewModel.kt index 165699bc29..dd720f9e4e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsViewModel.kt @@ -3,33 +3,35 @@ package org.thoughtcrime.securesms.components.settings.app.subscription.manage import androidx.lifecycle.LiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import io.reactivex.rxjava3.core.Observable import io.reactivex.rxjava3.core.Single import io.reactivex.rxjava3.disposables.CompositeDisposable import io.reactivex.rxjava3.disposables.Disposable import io.reactivex.rxjava3.kotlin.plusAssign import io.reactivex.rxjava3.kotlin.subscribeBy import io.reactivex.rxjava3.schedulers.Schedulers +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch +import kotlinx.coroutines.rx3.asFlow import org.signal.core.util.logging.Log -import org.signal.core.util.orNull import org.signal.donations.InAppPaymentType import org.thoughtcrime.securesms.badges.Badges import org.thoughtcrime.securesms.badges.models.Badge import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository import org.thoughtcrime.securesms.components.settings.app.subscription.RecurringInAppPaymentRepository +import org.thoughtcrime.securesms.database.InAppPaymentTable import org.thoughtcrime.securesms.database.SignalDatabase import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord -import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.database.model.databaseprotos.DonationErrorValue +import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData +import org.thoughtcrime.securesms.database.model.databaseprotos.PendingOneTimeDonation +import org.thoughtcrime.securesms.jobs.InAppPaymentKeepAliveJob import org.thoughtcrime.securesms.recipients.Recipient -import org.thoughtcrime.securesms.subscription.LevelUpdate import org.thoughtcrime.securesms.util.InternetConnectionObserver import org.thoughtcrime.securesms.util.livedata.Store -import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription -import java.util.Optional +import kotlin.time.Duration.Companion.milliseconds class ManageDonationsViewModel : ViewModel() { @@ -62,6 +64,46 @@ class ManageDonationsViewModel : ViewModel() { internalDisplayThanksBottomSheetPulse.emit(Badges.fromDatabaseBadge(it.data.badge!!)) } } + + viewModelScope.launch(Dispatchers.IO) { + updateRecurringDonationState() + + InAppPaymentsRepository.observeInAppPaymentRedemption(InAppPaymentType.RECURRING_DONATION) + .asFlow() + .collect { redemptionStatus -> + val latestPayment = SignalDatabase.inAppPayments.getLatestInAppPaymentByType(InAppPaymentType.RECURRING_DONATION) + + val activeSubscription: InAppPaymentTable.InAppPayment? = latestPayment?.let { + if (it.data.cancellation == null) it else null + } + + store.update { manageDonationsState -> + manageDonationsState.copy( + nonVerifiedMonthlyDonation = if (redemptionStatus is DonationRedemptionJobStatus.PendingExternalVerification) redemptionStatus.nonVerifiedMonthlyDonation else null, + subscriptionRedemptionState = deriveRedemptionState(redemptionStatus, latestPayment), + activeSubscription = activeSubscription + ) + } + } + } + + viewModelScope.launch(Dispatchers.IO) { + InAppPaymentsRepository.observeInAppPaymentRedemption(InAppPaymentType.ONE_TIME_DONATION) + .asFlow() + .collect { redemptionStatus -> + val pendingOneTimeDonation = when (redemptionStatus) { + is DonationRedemptionJobStatus.PendingExternalVerification -> redemptionStatus.pendingOneTimeDonation + DonationRedemptionJobStatus.PendingReceiptRedemption, + DonationRedemptionJobStatus.PendingReceiptRequest -> { + val latestPayment = SignalDatabase.inAppPayments.getLatestInAppPaymentByType(InAppPaymentType.ONE_TIME_DONATION) + latestPayment?.toPendingOneTimeDonation() + } + else -> null + } + + store.update { it.copy(pendingOneTimeDonation = pendingOneTimeDonation) } + } + } } override fun onCleared() { @@ -69,8 +111,8 @@ class ManageDonationsViewModel : ViewModel() { } fun retry() { - if (!disposables.isDisposed && store.state.subscriptionTransactionState == ManageDonationsState.TransactionState.NetworkFailure) { - store.update { it.copy(subscriptionTransactionState = ManageDonationsState.TransactionState.Init) } + if (!disposables.isDisposed && store.state.networkError) { + store.update { it.copy(networkError = false) } refresh() } } @@ -78,16 +120,23 @@ class ManageDonationsViewModel : ViewModel() { fun refresh() { disposables.clear() - val levelUpdateOperationEdges: Observable = LevelUpdate.isProcessing.distinctUntilChanged() - val activeSubscription: Single = RecurringInAppPaymentRepository.getActiveSubscription(InAppPaymentSubscriberRecord.Type.DONATION) + InAppPaymentKeepAliveJob.enqueueAndTrackTime(System.currentTimeMillis().milliseconds) disposables += Single.fromCallable { InAppPaymentsRepository.getShouldCancelSubscriptionBeforeNextSubscribeAttempt(InAppPaymentSubscriberRecord.Type.DONATION) - }.subscribeOn(Schedulers.io()).subscribeBy { requiresCancel -> - store.update { - it.copy(subscriberRequiresCancel = requiresCancel) + }.subscribeOn(Schedulers.io()).subscribeBy( + onSuccess = { requiresCancel -> + store.update { + it.copy(subscriberRequiresCancel = requiresCancel) + } + }, + onError = { throwable -> + Log.w(TAG, "Error retrieving cancel state", throwable) + store.update { + it.copy(networkError = true) + } } - } + ) disposables += Recipient.observable(Recipient.self().id).map { it.badges }.subscribeBy { badges -> store.update { state -> @@ -101,53 +150,6 @@ class ManageDonationsViewModel : ViewModel() { store.update { it.copy(hasReceipts = hasReceipts) } } - disposables += InAppPaymentsRepository.observeInAppPaymentRedemption(InAppPaymentType.RECURRING_DONATION).subscribeBy { redemptionStatus -> - store.update { manageDonationsState -> - manageDonationsState.copy( - nonVerifiedMonthlyDonation = if (redemptionStatus is DonationRedemptionJobStatus.PendingExternalVerification) redemptionStatus.nonVerifiedMonthlyDonation else null, - subscriptionRedemptionState = mapStatusToRedemptionState(redemptionStatus) - ) - } - } - - disposables += Observable.combineLatest( - SignalStore.inAppPayments.observablePendingOneTimeDonation, - InAppPaymentsRepository.observeInAppPaymentRedemption(InAppPaymentType.ONE_TIME_DONATION) - ) { pendingFromStore, pendingFromJob -> - if (pendingFromStore.isPresent) { - pendingFromStore - } else if (pendingFromJob is DonationRedemptionJobStatus.PendingExternalVerification) { - Optional.ofNullable(pendingFromJob.pendingOneTimeDonation) - } else { - Optional.empty() - } - } - .distinctUntilChanged() - .subscribeBy { pending -> - store.update { it.copy(pendingOneTimeDonation = pending.orNull()) } - } - - disposables += levelUpdateOperationEdges.switchMapSingle { isProcessing -> - if (isProcessing) { - Single.just(ManageDonationsState.TransactionState.InTransaction) - } else { - activeSubscription.map { ManageDonationsState.TransactionState.NotInTransaction(it) } - } - }.subscribeBy( - onNext = { transactionState -> - store.update { - it.copy(subscriptionTransactionState = transactionState) - } - }, - onError = { throwable -> - Log.w(TAG, "Error retrieving subscription transaction state", throwable) - - store.update { - it.copy(subscriptionTransactionState = ManageDonationsState.TransactionState.NetworkFailure) - } - } - ) - disposables += RecurringInAppPaymentRepository.getSubscriptions().subscribeBy( onSuccess = { subs -> store.update { it.copy(availableSubscriptions = subs) } @@ -158,18 +160,70 @@ class ManageDonationsViewModel : ViewModel() { ) } - private fun mapStatusToRedemptionState(status: DonationRedemptionJobStatus): ManageDonationsState.RedemptionState { + private fun updateRecurringDonationState() { + val latestPayment = SignalDatabase.inAppPayments.getLatestInAppPaymentByType(InAppPaymentType.RECURRING_DONATION) + + val activeSubscription: InAppPaymentTable.InAppPayment? = latestPayment?.let { + if (it.data.cancellation == null) it else null + } + + store.update { manageDonationsState -> + manageDonationsState.copy( + isLoaded = true, + activeSubscription = activeSubscription + ) + } + } + + private fun deriveRedemptionState(status: DonationRedemptionJobStatus, latestPayment: InAppPaymentTable.InAppPayment?): ManageDonationsState.RedemptionState { return when (status) { - DonationRedemptionJobStatus.FailedSubscription -> ManageDonationsState.RedemptionState.FAILED DonationRedemptionJobStatus.None -> ManageDonationsState.RedemptionState.NONE DonationRedemptionJobStatus.PendingKeepAlive -> ManageDonationsState.RedemptionState.SUBSCRIPTION_REFRESH + DonationRedemptionJobStatus.FailedSubscription -> ManageDonationsState.RedemptionState.FAILED + + is DonationRedemptionJobStatus.PendingExternalVerification -> { + if (latestPayment != null && (latestPayment.data.paymentMethodType == InAppPaymentData.PaymentMethodType.SEPA_DEBIT || latestPayment.data.paymentMethodType == InAppPaymentData.PaymentMethodType.IDEAL)) { + ManageDonationsState.RedemptionState.IS_PENDING_BANK_TRANSFER + } else { + ManageDonationsState.RedemptionState.IN_PROGRESS + } + } - is DonationRedemptionJobStatus.PendingExternalVerification, DonationRedemptionJobStatus.PendingReceiptRedemption, DonationRedemptionJobStatus.PendingReceiptRequest -> ManageDonationsState.RedemptionState.IN_PROGRESS } } + private fun InAppPaymentTable.InAppPayment.toPendingOneTimeDonation(): PendingOneTimeDonation? { + if (type.recurring || data.amount == null || data.badge == null) { + return null + } + + return PendingOneTimeDonation( + paymentMethodType = when (data.paymentMethodType) { + InAppPaymentData.PaymentMethodType.UNKNOWN -> PendingOneTimeDonation.PaymentMethodType.CARD + InAppPaymentData.PaymentMethodType.GOOGLE_PAY -> PendingOneTimeDonation.PaymentMethodType.CARD + InAppPaymentData.PaymentMethodType.CARD -> PendingOneTimeDonation.PaymentMethodType.CARD + InAppPaymentData.PaymentMethodType.SEPA_DEBIT -> PendingOneTimeDonation.PaymentMethodType.SEPA_DEBIT + InAppPaymentData.PaymentMethodType.IDEAL -> PendingOneTimeDonation.PaymentMethodType.IDEAL + InAppPaymentData.PaymentMethodType.PAYPAL -> PendingOneTimeDonation.PaymentMethodType.PAYPAL + InAppPaymentData.PaymentMethodType.GOOGLE_PLAY_BILLING -> error("Not supported.") + }, + amount = data.amount, + badge = data.badge, + timestamp = insertedAt.inWholeMilliseconds, + error = data.error?.takeIf { it.data_ != InAppPaymentKeepAliveJob.KEEP_ALIVE }?.let { + DonationErrorValue( + type = when (it.type) { + InAppPaymentData.Error.Type.REDEMPTION -> DonationErrorValue.Type.REDEMPTION + InAppPaymentData.Error.Type.PAYMENT_PROCESSING -> DonationErrorValue.Type.PAYMENT + else -> DonationErrorValue.Type.PAYMENT + } + ) + } + ) + } + companion object { private val TAG = Log.tag(ManageDonationsViewModel::class.java) } 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 b480339d5c..1d9ee48478 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/InAppPaymentTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/InAppPaymentTable.kt @@ -355,10 +355,8 @@ class InAppPaymentTable(context: Context, databaseHelper: SignalDatabase) : Data return readableDatabase.select() .from(TABLE_NAME) .where( - "($STATE = ? OR $STATE = ? OR $STATE = ?) AND $TYPE = ?", - State.serialize(State.PENDING), - State.serialize(State.WAITING_FOR_AUTHORIZATION), - State.serialize(State.END), + "$STATE != ? AND $TYPE = ?", + State.serialize(State.CREATED), InAppPaymentType.serialize(type) ) .orderBy("$INSERTED_AT DESC") 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 01c2f5733c..a96e553f27 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentKeepAliveJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentKeepAliveJob.kt @@ -153,6 +153,12 @@ class InAppPaymentKeepAliveJob private constructor( return } + if (type == InAppPaymentSubscriberRecord.Type.DONATION && activeSubscription.isCanceled) { + info(type, "Subscription is canceled. Writing cancellation to DB.") + InAppPaymentsRepository.updateInAppPaymentWithCancelation(activeSubscription, type) + return + } + val activeInAppPayment = getActiveInAppPayment(subscriber, subscription) if (activeInAppPayment == null) { warn(type, "Failed to generate active in-app payment. Exiting") 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 95af40237b..d78d7a3dba 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/jobs/InAppPaymentKeepAliveJobTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/jobs/InAppPaymentKeepAliveJobTest.kt @@ -5,19 +5,67 @@ package org.thoughtcrime.securesms.jobs +import android.app.Application import assertk.assertThat import assertk.assertions.isTrue import io.mockk.every +import io.mockk.verify +import org.junit.Before 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.core.util.logging.Log +import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository +import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsTestRule +import org.thoughtcrime.securesms.database.SignalDatabase import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord +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.ChargeFailure +import org.whispersystems.signalservice.api.subscriptions.SubscriberId +import org.whispersystems.signalservice.internal.EmptyResponse +import org.whispersystems.signalservice.internal.ServiceResponse +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE, application = Application::class) class InAppPaymentKeepAliveJobTest { @get:Rule val mockSignalStore = MockSignalStoreRule() + @get:Rule + val appDependencies = MockAppDependenciesRule() + + @get:Rule + val inAppPaymentsTestRule = InAppPaymentsTestRule() + + @Before + fun setUp() { + Log.initialize(SystemOutLogger()) + + every { mockSignalStore.account.isRegistered } returns true + every { mockSignalStore.account.isLinkedDevice } returns false + + every { SignalDatabase.inAppPayments.getOldPendingPayments(any()) } returns emptyList() + every { SignalDatabase.inAppPayments.hasPrePendingRecurringTransaction(any()) } returns false + + val subscriber = InAppPaymentSubscriberRecord( + subscriberId = SubscriberId.generate(), + currency = java.util.Currency.getInstance("USD"), + type = InAppPaymentSubscriberRecord.Type.DONATION, + requiresCancel = false, + paymentMethodType = org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData.PaymentMethodType.CARD, + iapSubscriptionId = null + ) + every { InAppPaymentsRepository.getSubscriber(InAppPaymentSubscriberRecord.Type.DONATION) } returns subscriber + + every { AppDependencies.donationsService.putSubscription(any()) } returns ServiceResponse(200, "", EmptyResponse.INSTANCE, null, null) + } + @Test fun `Given an unregistered local user, when I run, then I expect skip`() { every { mockSignalStore.account.isRegistered } returns false @@ -28,4 +76,37 @@ class InAppPaymentKeepAliveJobTest { assertThat(result.isSuccess).isTrue() } + + @Test + fun `Given a canceled subscription, when I run, then I write cancellation and return early`() { + val activeSubscription = inAppPaymentsTestRule.createActiveSubscription( + status = "canceled", + isActive = false + ) + inAppPaymentsTestRule.initializeActiveSubscriptionMock(activeSubscription = activeSubscription) + every { InAppPaymentsRepository.updateInAppPaymentWithCancelation(any(), any()) } returns Unit + + val job = InAppPaymentKeepAliveJob.create(InAppPaymentSubscriberRecord.Type.DONATION) + val result = job.run() + + assertThat(result.isSuccess).isTrue() + verify(exactly = 1) { InAppPaymentsRepository.updateInAppPaymentWithCancelation(activeSubscription, InAppPaymentSubscriberRecord.Type.DONATION) } + } + + @Test + fun `Given a past-due subscription with charge failure, when I run, then I do not write cancellation`() { + val chargeFailure = ChargeFailure("test", "", "", "", "") + val activeSubscription = inAppPaymentsTestRule.createActiveSubscription( + status = "past_due", + isActive = false, + chargeFailure = chargeFailure + ) + inAppPaymentsTestRule.initializeActiveSubscriptionMock(activeSubscription = activeSubscription) + every { InAppPaymentsRepository.updateInAppPaymentWithCancelation(any(), any()) } returns Unit + + val job = InAppPaymentKeepAliveJob.create(InAppPaymentSubscriberRecord.Type.DONATION) + job.run() + + verify(exactly = 0) { InAppPaymentsRepository.updateInAppPaymentWithCancelation(any(), any()) } + } }