Utilize InAppPaymentTable as the SSOT for ManageDonationsFragment.

This commit is contained in:
Alex Hart
2026-03-09 16:35:32 -03:00
committed by jeffrey-signal
parent d06febd5b5
commit 7fb866fcfb
9 changed files with 588 additions and 123 deletions

View File

@@ -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()

View File

@@ -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)

View File

@@ -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 = {}
)

View File

@@ -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<Subscription> = 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,

View File

@@ -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<Boolean> = LevelUpdate.isProcessing.distinctUntilChanged()
val activeSubscription: Single<ActiveSubscription> = 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)
}

View File

@@ -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")

View File

@@ -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")