Isolated tests for OneTimeInAppPaymentRepository.

This commit is contained in:
Alex Hart
2024-12-20 10:15:01 -04:00
committed by Greyson Parrelli
parent e650223487
commit c31780050f
8 changed files with 460 additions and 130 deletions

View File

@@ -7,7 +7,7 @@ import org.signal.core.util.logging.Log
import org.signal.core.util.money.FiatMoney
import org.signal.donations.PaymentSourceType
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toFiatMoney
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository.toPaymentSourceType
import org.thoughtcrime.securesms.components.settings.app.subscription.boost.Boost
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.InAppPaymentError
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError
@@ -16,52 +16,61 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.errors.Do
import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.thoughtcrime.securesms.database.RecipientTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.InAppPaymentReceiptRecord
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobs.InAppPaymentOneTimeContextJob
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.whispersystems.signalservice.api.services.DonationsService
import java.util.Currency
import java.util.Locale
import java.util.concurrent.TimeUnit
class OneTimeInAppPaymentRepository(private val donationsService: DonationsService) {
object OneTimeInAppPaymentRepository {
companion object {
private val TAG = Log.tag(OneTimeInAppPaymentRepository::class.java)
private val TAG = Log.tag(OneTimeInAppPaymentRepository::class.java)
fun <T : Any> handleCreatePaymentIntentError(throwable: Throwable, badgeRecipient: RecipientId, paymentSourceType: PaymentSourceType): Single<T> {
return if (throwable is DonationError) {
Single.error(throwable)
} else {
val recipient = Recipient.resolved(badgeRecipient)
val errorSource = if (recipient.isSelf) DonationErrorSource.ONE_TIME else DonationErrorSource.GIFT
Single.error(DonationError.getPaymentSetupError(errorSource, throwable, paymentSourceType))
}
}
fun verifyRecipientIsAllowedToReceiveAGift(badgeRecipient: RecipientId): Completable {
return Completable.fromAction {
Log.d(TAG, "Verifying badge recipient $badgeRecipient", true)
val recipient = Recipient.resolved(badgeRecipient)
if (recipient.isSelf) {
Log.d(TAG, "Cannot send a gift to self.", true)
throw DonationError.GiftRecipientVerificationError.SelectedRecipientIsInvalid
}
if (recipient.isGroup || recipient.isDistributionList || recipient.registered != RecipientTable.RegisteredState.REGISTERED) {
Log.w(TAG, "Invalid badge recipient $badgeRecipient. Verification failed.", true)
throw DonationError.GiftRecipientVerificationError.SelectedRecipientIsInvalid
}
}.subscribeOn(Schedulers.io())
/**
* Translates the given Throwable into a DonationError
*
* If the throwable is already a DonationError, it's returned as is. Otherwise we will return an adequate payment setup error.
*/
fun <T : Any> handleCreatePaymentIntentError(throwable: Throwable, badgeRecipient: RecipientId, paymentSourceType: PaymentSourceType): Single<T> {
return if (throwable is DonationError) {
Single.error(throwable)
} else {
val recipient = Recipient.resolved(badgeRecipient)
val errorSource = if (recipient.isSelf) DonationErrorSource.ONE_TIME else DonationErrorSource.GIFT
Single.error(DonationError.getPaymentSetupError(errorSource, throwable, paymentSourceType))
}
}
/**
* Checks whether the recipient for the given ID is allowed to receive a gift. Returns
* normally if they are and emits an error otherwise.
*/
fun verifyRecipientIsAllowedToReceiveAGift(badgeRecipient: RecipientId): Completable {
return Completable.fromAction {
Log.d(TAG, "Verifying badge recipient $badgeRecipient", true)
val recipient = Recipient.resolved(badgeRecipient)
if (recipient.isSelf) {
Log.d(TAG, "Cannot send a gift to self.", true)
throw DonationError.GiftRecipientVerificationError.SelectedRecipientIsInvalid
}
if (!recipient.isIndividual || recipient.registered != RecipientTable.RegisteredState.REGISTERED) {
Log.w(TAG, "Invalid badge recipient $badgeRecipient. Verification failed.", true)
throw DonationError.GiftRecipientVerificationError.SelectedRecipientIsInvalid
}
}.subscribeOn(Schedulers.io())
}
/**
* Parses the donations configuration and returns any boost information from it. Also maps and filters out currencies
* based on platform and payment method availability.
*/
fun getBoosts(): Single<Map<Currency, List<Boost>>> {
return Single.fromCallable { donationsService.getDonationsConfiguration(Locale.getDefault()) }
return Single.fromCallable { AppDependencies.donationsService.getDonationsConfiguration(Locale.getDefault()) }
.subscribeOn(Schedulers.io())
.flatMap { it.flattenResult() }
.map { config ->
@@ -85,7 +94,7 @@ class OneTimeInAppPaymentRepository(private val donationsService: DonationsServi
}
fun getMinimumDonationAmounts(): Single<Map<Currency, FiatMoney>> {
return Single.fromCallable { donationsService.getDonationsConfiguration(Locale.getDefault()) }
return Single.fromCallable { AppDependencies.donationsService.getDonationsConfiguration(Locale.getDefault()) }
.flatMap { it.flattenResult() }
.subscribeOn(Schedulers.io())
.map { it.getMinimumDonationAmounts() }
@@ -93,10 +102,9 @@ class OneTimeInAppPaymentRepository(private val donationsService: DonationsServi
fun waitForOneTimeRedemption(
inAppPayment: InAppPaymentTable.InAppPayment,
paymentIntentId: String,
paymentSourceType: PaymentSourceType
paymentIntentId: String
): Completable {
val isLongRunning = paymentSourceType == PaymentSourceType.Stripe.SEPADebit
val isLongRunning = inAppPayment.data.paymentMethodType.toPaymentSourceType() == PaymentSourceType.Stripe.SEPADebit
val isBoost = inAppPayment.data.recipientId?.let { RecipientId.from(it) } == Recipient.self().id
val donationErrorSource: DonationErrorSource = if (isBoost) DonationErrorSource.ONE_TIME else DonationErrorSource.GIFT
@@ -107,17 +115,7 @@ class OneTimeInAppPaymentRepository(private val donationsService: DonationsServi
}
return Single.fromCallable {
val inAppPaymentReceiptRecord = if (isBoost) {
InAppPaymentReceiptRecord.createForBoost(inAppPayment.data.amount!!.toFiatMoney())
} else {
InAppPaymentReceiptRecord.createForGift(inAppPayment.data.amount!!.toFiatMoney())
}
val donationTypeLabel = inAppPaymentReceiptRecord.type.code.replaceFirstChar { c -> if (c.isLowerCase()) c.titlecase(Locale.US) else c.toString() }
Log.d(TAG, "Confirmed payment intent. Recording $donationTypeLabel receipt and submitting badge reimbursement job chain.", true)
SignalDatabase.donationReceipts.addReceipt(inAppPaymentReceiptRecord)
Log.d(TAG, "Confirmed payment intent. Submitting badge reimbursement job chain.", true)
SignalDatabase.inAppPayments.update(
inAppPayment = inAppPayment.copy(
data = inAppPayment.data.copy(

View File

@@ -30,7 +30,6 @@ import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
import org.thoughtcrime.securesms.database.model.databaseprotos.PendingOneTimeDonation
import org.thoughtcrime.securesms.database.model.isExpired
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.subscription.LevelUpdate
@@ -51,8 +50,7 @@ import java.util.Optional
* only in charge of rendering our "current view of the world."
*/
class DonateToSignalViewModel(
startType: InAppPaymentType,
private val oneTimeInAppPaymentRepository: OneTimeInAppPaymentRepository
startType: InAppPaymentType
) : ViewModel() {
companion object {
@@ -73,7 +71,7 @@ class DonateToSignalViewModel(
val inAppPaymentId: Flowable<InAppPaymentTable.InAppPaymentId> = _inAppPaymentId.onBackpressureLatest().distinctUntilChanged()
init {
initializeOneTimeDonationState(oneTimeInAppPaymentRepository)
initializeOneTimeDonationState(OneTimeInAppPaymentRepository)
initializeMonthlyDonationState(RecurringInAppPaymentRepository)
networkDisposable += InternetConnectionObserver
@@ -97,7 +95,7 @@ class DonateToSignalViewModel(
fun retryOneTimeDonationState() {
if (!oneTimeDonationDisposables.isDisposed && store.state.oneTimeDonationState.donationStage == DonateToSignalState.DonationStage.FAILURE) {
store.update { it.copy(oneTimeDonationState = it.oneTimeDonationState.copy(donationStage = DonateToSignalState.DonationStage.INIT)) }
initializeOneTimeDonationState(oneTimeInAppPaymentRepository)
initializeOneTimeDonationState(OneTimeInAppPaymentRepository)
}
}
@@ -428,11 +426,10 @@ class DonateToSignalViewModel(
}
class Factory(
private val startType: InAppPaymentType,
private val oneTimeInAppPaymentRepository: OneTimeInAppPaymentRepository = OneTimeInAppPaymentRepository(AppDependencies.donationsService)
private val startType: InAppPaymentType
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return modelClass.cast(DonateToSignalViewModel(startType, oneTimeInAppPaymentRepository)) as T
return modelClass.cast(DonateToSignalViewModel(startType)) as T
}
}
}

View File

@@ -16,6 +16,7 @@ import org.signal.donations.PaymentSourceType
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toFiatMoney
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository.requireSubscriberType
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository.toPaymentSourceType
import org.thoughtcrime.securesms.components.settings.app.subscription.OneTimeInAppPaymentRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.PayPalRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.RecurringInAppPaymentRepository
@@ -35,8 +36,7 @@ import org.whispersystems.signalservice.api.subscriptions.PayPalCreatePaymentMet
import org.whispersystems.signalservice.api.util.Preconditions
class PayPalPaymentInProgressViewModel(
private val payPalRepository: PayPalRepository,
private val oneTimeInAppPaymentRepository: OneTimeInAppPaymentRepository
private val payPalRepository: PayPalRepository
) : ViewModel() {
companion object {
@@ -122,6 +122,8 @@ class PayPalPaymentInProgressViewModel(
inAppPayment: InAppPaymentTable.InAppPayment,
routeToPaypalConfirmation: (PayPalCreatePaymentIntentResponse) -> Single<PayPalConfirmationResult>
) {
check(inAppPayment.data.paymentMethodType.toPaymentSourceType() == PaymentSourceType.PayPal)
Log.d(TAG, "Proceeding with one-time payment pipeline...", true)
store.update { InAppPaymentProcessorStage.PAYMENT_PIPELINE }
val verifyUser = if (inAppPayment.type == InAppPaymentType.ONE_TIME_GIFT) {
@@ -148,10 +150,9 @@ class PayPalPaymentInProgressViewModel(
)
}
.flatMapCompletable { response ->
oneTimeInAppPaymentRepository.waitForOneTimeRedemption(
OneTimeInAppPaymentRepository.waitForOneTimeRedemption(
inAppPayment = inAppPayment,
paymentIntentId = response.paymentId,
paymentSourceType = PaymentSourceType.PayPal
paymentIntentId = response.paymentId
)
}
.subscribeOn(Schedulers.io())
@@ -193,11 +194,10 @@ class PayPalPaymentInProgressViewModel(
}
class Factory(
private val payPalRepository: PayPalRepository = PayPalRepository(AppDependencies.donationsService),
private val oneTimeInAppPaymentRepository: OneTimeInAppPaymentRepository = OneTimeInAppPaymentRepository(AppDependencies.donationsService)
private val payPalRepository: PayPalRepository = PayPalRepository(AppDependencies.donationsService)
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return modelClass.cast(PayPalPaymentInProgressViewModel(payPalRepository, oneTimeInAppPaymentRepository)) as T
return modelClass.cast(PayPalPaymentInProgressViewModel(payPalRepository)) as T
}
}
}

View File

@@ -32,7 +32,6 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.errors.to
import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.rx.RxStore
@@ -40,8 +39,7 @@ import org.whispersystems.signalservice.api.util.Preconditions
import org.whispersystems.signalservice.internal.push.exceptions.InAppPaymentProcessorError
class StripePaymentInProgressViewModel(
private val stripeRepository: StripeRepository,
private val oneTimeInAppPaymentRepository: OneTimeInAppPaymentRepository
private val stripeRepository: StripeRepository
) : ViewModel() {
companion object {
@@ -200,6 +198,8 @@ class StripePaymentInProgressViewModel(
paymentSourceProvider: PaymentSourceProvider,
nextActionHandler: StripeNextActionHandler
) {
check(inAppPayment.data.paymentMethodType.toPaymentSourceType() == paymentSourceProvider.paymentSourceType)
Log.w(TAG, "Beginning one-time payment pipeline...", true)
val amount = inAppPayment.data.amount!!.toFiatMoney()
@@ -233,10 +233,9 @@ class StripePaymentInProgressViewModel(
.flatMap { stripeRepository.getStatusAndPaymentMethodId(it, action.paymentMethodId) }
}
.flatMapCompletable {
oneTimeInAppPaymentRepository.waitForOneTimeRedemption(
OneTimeInAppPaymentRepository.waitForOneTimeRedemption(
inAppPayment = inAppPayment,
paymentIntentId = paymentIntent.intentId,
paymentSourceType = paymentSource.type
paymentIntentId = paymentIntent.intentId
)
}
}.subscribeBy(
@@ -304,11 +303,10 @@ class StripePaymentInProgressViewModel(
}
class Factory(
private val stripeRepository: StripeRepository,
private val oneTimeInAppPaymentRepository: OneTimeInAppPaymentRepository = OneTimeInAppPaymentRepository(AppDependencies.donationsService)
private val stripeRepository: StripeRepository
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return modelClass.cast(StripePaymentInProgressViewModel(stripeRepository, oneTimeInAppPaymentRepository)) as T
return modelClass.cast(StripePaymentInProgressViewModel(stripeRepository)) as T
}
}
}

View File

@@ -12,10 +12,12 @@ import org.signal.donations.InAppPaymentType
import org.signal.libsignal.zkgroup.VerificationFailedException
import org.signal.libsignal.zkgroup.receipts.ReceiptCredential
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialRequestContext
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toFiatMoney
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository.toDonationProcessor
import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.InAppPaymentReceiptRecord
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobmanager.Job
@@ -138,7 +140,15 @@ class InAppPaymentOneTimeContextJob private constructor(
throw InAppPaymentRetryException(e)
}
info("Got presentation. Updating state and completing.")
info("Got presentation. Updating state, recording receipt, and completing.")
val inAppPaymentReceiptRecord = if (inAppPayment.type == InAppPaymentType.ONE_TIME_DONATION) {
InAppPaymentReceiptRecord.createForBoost(inAppPayment.data.amount!!.toFiatMoney())
} else {
InAppPaymentReceiptRecord.createForGift(inAppPayment.data.amount!!.toFiatMoney())
}
SignalDatabase.donationReceipts.addReceipt(inAppPaymentReceiptRecord)
SignalDatabase.inAppPayments.update(
inAppPayment.copy(
data = inAppPayment.data.copy(