mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-24 02:39:55 +01:00
Migrate paypal and stripe interactions to durable background jobs.
This commit is contained in:
committed by
Cody Henthorne
parent
ad00e7c5ab
commit
7cc4677120
@@ -13,8 +13,8 @@ import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsActivity
|
||||
import org.thoughtcrime.securesms.components.settings.app.chats.folders.CreateFoldersFragmentArgs
|
||||
import org.thoughtcrime.securesms.components.settings.app.notifications.profiles.EditNotificationProfileScheduleFragmentArgs
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentComponent
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.StripeRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.GooglePayComponent
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.GooglePayRepository
|
||||
import org.thoughtcrime.securesms.help.HelpFragment
|
||||
import org.thoughtcrime.securesms.keyvalue.SettingsValues
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
@@ -31,12 +31,12 @@ private const val NOTIFICATION_CATEGORY = "android.intent.category.NOTIFICATION_
|
||||
private const val STATE_WAS_CONFIGURATION_UPDATED = "app.settings.state.configuration.updated"
|
||||
private const val EXTRA_PERFORM_ACTION_ON_CREATE = "extra_perform_action_on_create"
|
||||
|
||||
class AppSettingsActivity : DSLSettingsActivity(), InAppPaymentComponent {
|
||||
class AppSettingsActivity : DSLSettingsActivity(), GooglePayComponent {
|
||||
|
||||
private var wasConfigurationUpdated = false
|
||||
|
||||
override val stripeRepository: StripeRepository by lazy { StripeRepository(this) }
|
||||
override val googlePayResultPublisher: Subject<InAppPaymentComponent.GooglePayResult> = PublishSubject.create()
|
||||
override val googlePayRepository: GooglePayRepository by lazy { GooglePayRepository(this) }
|
||||
override val googlePayResultPublisher: Subject<GooglePayComponent.GooglePayResult> = PublishSubject.create()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {
|
||||
if (intent?.hasExtra(ARG_NAV_GRAPH) != true) {
|
||||
@@ -134,7 +134,7 @@ class AppSettingsActivity : DSLSettingsActivity(), InAppPaymentComponent {
|
||||
@Suppress("DEPRECATION")
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
super.onActivityResult(requestCode, resultCode, data)
|
||||
googlePayResultPublisher.onNext(InAppPaymentComponent.GooglePayResult(requestCode, resultCode, data))
|
||||
googlePayResultPublisher.onNext(GooglePayComponent.GooglePayResult(requestCode, resultCode, data))
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -5,8 +5,8 @@ import android.os.Parcelable
|
||||
import io.reactivex.rxjava3.subjects.Subject
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
interface InAppPaymentComponent {
|
||||
val stripeRepository: StripeRepository
|
||||
interface GooglePayComponent {
|
||||
val googlePayRepository: GooglePayRepository
|
||||
val googlePayResultPublisher: Subject<GooglePayResult>
|
||||
|
||||
@Parcelize
|
||||
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import io.reactivex.rxjava3.core.Completable
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.money.FiatMoney
|
||||
import org.signal.donations.GooglePayApi
|
||||
import org.signal.donations.StripeApi
|
||||
import org.thoughtcrime.securesms.util.Environment
|
||||
|
||||
/**
|
||||
* Extraction of components that only deal with GooglePay from StripeRepository
|
||||
*/
|
||||
class GooglePayRepository(activity: Activity) {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(GooglePayRepository::class)
|
||||
}
|
||||
|
||||
private val googlePayApi = GooglePayApi(activity, StripeApi.Gateway(Environment.Donations.STRIPE_CONFIGURATION), Environment.Donations.GOOGLE_PAY_CONFIGURATION)
|
||||
|
||||
fun isGooglePayAvailable(): Completable {
|
||||
return googlePayApi.queryIsReadyToPay()
|
||||
}
|
||||
|
||||
fun requestTokenFromGooglePay(price: FiatMoney, label: String, requestCode: Int) {
|
||||
Log.d(TAG, "Requesting a token from google pay...")
|
||||
googlePayApi.requestPayment(price, label, requestCode)
|
||||
}
|
||||
|
||||
fun onActivityResult(
|
||||
requestCode: Int,
|
||||
resultCode: Int,
|
||||
data: Intent?,
|
||||
expectedRequestCode: Int,
|
||||
paymentsRequestCallback: GooglePayApi.PaymentRequestCallback
|
||||
) {
|
||||
Log.d(TAG, "Processing possible google pay result...")
|
||||
googlePayApi.onActivityResult(requestCode, resultCode, data, expectedRequestCode, paymentsRequestCallback)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription
|
||||
|
||||
import org.signal.donations.CreditCardPaymentSource
|
||||
import org.signal.donations.GooglePayPaymentSource
|
||||
import org.signal.donations.IDEALPaymentSource
|
||||
import org.signal.donations.PayPalPaymentSource
|
||||
import org.signal.donations.PaymentSource
|
||||
import org.signal.donations.PaymentSourceType
|
||||
import org.signal.donations.SEPADebitPaymentSource
|
||||
import org.signal.donations.StripeApi
|
||||
import org.signal.donations.TokenPaymentSource
|
||||
import org.thoughtcrime.securesms.jobs.protos.InAppPaymentSourceData
|
||||
|
||||
fun PaymentSourceType.toInAppPaymentSourceDataCode(): InAppPaymentSourceData.Code {
|
||||
return when (this) {
|
||||
PaymentSourceType.Unknown -> InAppPaymentSourceData.Code.UNKNOWN
|
||||
PaymentSourceType.GooglePlayBilling -> InAppPaymentSourceData.Code.GOOGLE_PLAY_BILLING
|
||||
PaymentSourceType.PayPal -> InAppPaymentSourceData.Code.PAY_PAL
|
||||
PaymentSourceType.Stripe.CreditCard -> InAppPaymentSourceData.Code.CREDIT_CARD
|
||||
PaymentSourceType.Stripe.GooglePay -> InAppPaymentSourceData.Code.GOOGLE_PAY
|
||||
PaymentSourceType.Stripe.IDEAL -> InAppPaymentSourceData.Code.IDEAL
|
||||
PaymentSourceType.Stripe.SEPADebit -> InAppPaymentSourceData.Code.SEPA_DEBIT
|
||||
}
|
||||
}
|
||||
|
||||
fun PaymentSource.toProto(): InAppPaymentSourceData {
|
||||
return InAppPaymentSourceData(
|
||||
code = type.toInAppPaymentSourceDataCode(),
|
||||
idealData = if (this is IDEALPaymentSource) {
|
||||
InAppPaymentSourceData.IDEALData(
|
||||
bank = idealData.bank,
|
||||
name = idealData.name,
|
||||
email = idealData.email
|
||||
)
|
||||
} else null,
|
||||
sepaData = if (this is SEPADebitPaymentSource) {
|
||||
InAppPaymentSourceData.SEPAData(
|
||||
iban = sepaDebitData.iban,
|
||||
name = sepaDebitData.name,
|
||||
email = sepaDebitData.email
|
||||
)
|
||||
} else null,
|
||||
tokenData = if (this is CreditCardPaymentSource || this is GooglePayPaymentSource) {
|
||||
InAppPaymentSourceData.TokenData(
|
||||
parameters = parameterize().toString(),
|
||||
tokenId = getTokenId(),
|
||||
email = email()
|
||||
)
|
||||
} else null
|
||||
)
|
||||
}
|
||||
|
||||
fun InAppPaymentSourceData.toPaymentSource(): PaymentSource {
|
||||
return when (code) {
|
||||
InAppPaymentSourceData.Code.CREDIT_CARD, InAppPaymentSourceData.Code.GOOGLE_PAY -> {
|
||||
TokenPaymentSource(
|
||||
type = if (code == InAppPaymentSourceData.Code.CREDIT_CARD) PaymentSourceType.Stripe.CreditCard else PaymentSourceType.Stripe.GooglePay,
|
||||
parameters = tokenData!!.parameters,
|
||||
token = tokenData.tokenId,
|
||||
email = tokenData.email
|
||||
)
|
||||
}
|
||||
InAppPaymentSourceData.Code.SEPA_DEBIT -> {
|
||||
SEPADebitPaymentSource(
|
||||
StripeApi.SEPADebitData(
|
||||
iban = sepaData!!.iban,
|
||||
name = sepaData.name,
|
||||
email = sepaData.email
|
||||
)
|
||||
)
|
||||
}
|
||||
InAppPaymentSourceData.Code.IDEAL -> {
|
||||
IDEALPaymentSource(
|
||||
StripeApi.IDEALData(
|
||||
bank = idealData!!.bank,
|
||||
name = idealData.name,
|
||||
email = idealData.email
|
||||
)
|
||||
)
|
||||
}
|
||||
InAppPaymentSourceData.Code.PAY_PAL -> {
|
||||
PayPalPaymentSource()
|
||||
}
|
||||
else -> error("Unexpected code $code")
|
||||
}
|
||||
}
|
||||
@@ -516,13 +516,13 @@ object InAppPaymentsRepository {
|
||||
|
||||
val value = when (inAppPayment.state) {
|
||||
InAppPaymentTable.State.CREATED -> error("This should have been filtered out.")
|
||||
InAppPaymentTable.State.WAITING_FOR_AUTHORIZATION -> {
|
||||
InAppPaymentTable.State.WAITING_FOR_AUTHORIZATION, InAppPaymentTable.State.REQUIRES_ACTION -> {
|
||||
DonationRedemptionJobStatus.PendingExternalVerification(
|
||||
pendingOneTimeDonation = inAppPayment.toPendingOneTimeDonation(),
|
||||
nonVerifiedMonthlyDonation = inAppPayment.toNonVerifiedMonthlyDonation()
|
||||
)
|
||||
}
|
||||
InAppPaymentTable.State.PENDING -> {
|
||||
InAppPaymentTable.State.PENDING, InAppPaymentTable.State.TRANSACTING, InAppPaymentTable.State.REQUIRED_ACTION_COMPLETED -> {
|
||||
if (inAppPayment.data.redemption?.keepAlive == true) {
|
||||
DonationRedemptionJobStatus.PendingKeepAlive
|
||||
} else if (inAppPayment.data.redemption?.stage == InAppPaymentData.RedemptionState.Stage.REDEMPTION_STARTED) {
|
||||
@@ -590,7 +590,7 @@ object InAppPaymentsRepository {
|
||||
timestamp = insertedAt.inWholeMilliseconds,
|
||||
price = data.amount!!.toFiatMoney(),
|
||||
level = data.level.toInt(),
|
||||
checkedVerification = data.waitForAuth!!.checkedVerification
|
||||
checkedVerification = data.waitForAuth?.checkedVerification ?: false
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription
|
||||
|
||||
import io.reactivex.rxjava3.core.Completable
|
||||
import androidx.annotation.WorkerThread
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
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.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
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError.BadgeRedemptionError
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
|
||||
import org.thoughtcrime.securesms.database.InAppPaymentTable
|
||||
import org.thoughtcrime.securesms.database.RecipientTable
|
||||
@@ -23,8 +20,10 @@ import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import java.util.Currency
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Shared one-time payment methods that apply to both Stripe and PayPal payments.
|
||||
*/
|
||||
object OneTimeInAppPaymentRepository {
|
||||
|
||||
private val TAG = Log.tag(OneTimeInAppPaymentRepository::class.java)
|
||||
@@ -34,35 +33,41 @@ object OneTimeInAppPaymentRepository {
|
||||
*
|
||||
* 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> {
|
||||
fun handleCreatePaymentIntentErrorSync(throwable: Throwable, badgeRecipient: RecipientId, paymentSourceType: PaymentSourceType): Throwable {
|
||||
return if (throwable is DonationError) {
|
||||
Single.error(throwable)
|
||||
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))
|
||||
DonationError.getPaymentSetupError(errorSource, throwable, paymentSourceType)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Passthrough Rx wrapper for [handleCreatePaymentIntentErrorSync]. This does not dispatch to a thread-pool.
|
||||
*/
|
||||
fun <T : Any> handleCreatePaymentIntentError(throwable: Throwable, badgeRecipient: RecipientId, paymentSourceType: PaymentSourceType): Single<T> {
|
||||
return Single.error(handleCreatePaymentIntentErrorSync(throwable, badgeRecipient, 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)
|
||||
@WorkerThread
|
||||
fun verifyRecipientIsAllowedToReceiveAGiftSync(badgeRecipient: RecipientId) {
|
||||
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.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())
|
||||
if (!recipient.isIndividual || recipient.registered != RecipientTable.RegisteredState.REGISTERED) {
|
||||
Log.w(TAG, "Invalid badge recipient $badgeRecipient. Verification failed.", true)
|
||||
throw DonationError.GiftRecipientVerificationError.SelectedRecipientIsInvalid
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -82,6 +87,9 @@ object OneTimeInAppPaymentRepository {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the one-time donation badge from the Signal service
|
||||
*/
|
||||
fun getBoostBadge(): Single<Badge> {
|
||||
return Single
|
||||
.fromCallable {
|
||||
@@ -93,6 +101,10 @@ object OneTimeInAppPaymentRepository {
|
||||
.map { it.getBoostBadges().first() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a map of [Currency] to [FiatMoney] representing minimum donation amounts from the
|
||||
* signal service. This is scheduled on the io thread-pool.
|
||||
*/
|
||||
fun getMinimumDonationAmounts(): Single<Map<Currency, FiatMoney>> {
|
||||
return Single.fromCallable { AppDependencies.donationsService.getDonationsConfiguration(Locale.getDefault()) }
|
||||
.flatMap { it.flattenResult() }
|
||||
@@ -100,46 +112,28 @@ object OneTimeInAppPaymentRepository {
|
||||
.map { it.getMinimumDonationAmounts() }
|
||||
}
|
||||
|
||||
fun waitForOneTimeRedemption(
|
||||
/**
|
||||
* Submits the required jobs to redeem the given [InAppPaymentTable.InAppPayment]
|
||||
*
|
||||
* This job does mutate the in-app payment but since this is the final action during setup,
|
||||
* returning that data is useless.
|
||||
*/
|
||||
fun submitRedemptionJobChain(
|
||||
inAppPayment: InAppPaymentTable.InAppPayment,
|
||||
paymentIntentId: String
|
||||
): Completable {
|
||||
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
|
||||
|
||||
val timeoutError: DonationError = if (isLongRunning) {
|
||||
BadgeRedemptionError.DonationPending(donationErrorSource, inAppPayment)
|
||||
} else {
|
||||
BadgeRedemptionError.TimeoutWaitingForTokenError(donationErrorSource)
|
||||
}
|
||||
|
||||
return Single.fromCallable {
|
||||
Log.d(TAG, "Confirmed payment intent. Submitting badge reimbursement job chain.", true)
|
||||
SignalDatabase.inAppPayments.update(
|
||||
inAppPayment = inAppPayment.copy(
|
||||
data = inAppPayment.data.copy(
|
||||
redemption = InAppPaymentData.RedemptionState(
|
||||
stage = InAppPaymentData.RedemptionState.Stage.INIT,
|
||||
paymentIntentId = paymentIntentId
|
||||
)
|
||||
) {
|
||||
Log.d(TAG, "Confirmed payment intent. Submitting badge reimbursement job chain.", true)
|
||||
SignalDatabase.inAppPayments.update(
|
||||
inAppPayment = inAppPayment.copy(
|
||||
data = inAppPayment.data.newBuilder().redemption(
|
||||
redemption = InAppPaymentData.RedemptionState(
|
||||
stage = InAppPaymentData.RedemptionState.Stage.INIT,
|
||||
paymentIntentId = paymentIntentId
|
||||
)
|
||||
)
|
||||
).build()
|
||||
)
|
||||
)
|
||||
|
||||
InAppPaymentOneTimeContextJob.createJobChain(inAppPayment).enqueue()
|
||||
inAppPayment.id
|
||||
}.flatMap { inAppPaymentId ->
|
||||
Log.d(TAG, "Awaiting completion of redemption chain for up to 10 seconds.", true)
|
||||
InAppPaymentsRepository.observeUpdates(inAppPaymentId).filter {
|
||||
it.state == InAppPaymentTable.State.END
|
||||
}.take(1).firstOrError().timeout(10, TimeUnit.SECONDS, Single.error(timeoutError))
|
||||
}.map {
|
||||
if (it.data.error != null) {
|
||||
Log.d(TAG, "Failure during redemption chain: ${it.data.error}", true)
|
||||
throw InAppPaymentError(it.data.error)
|
||||
}
|
||||
it
|
||||
}.ignoreElement()
|
||||
InAppPaymentOneTimeContextJob.createJobChain(inAppPayment).enqueue()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription
|
||||
|
||||
import io.reactivex.rxjava3.core.Completable
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import androidx.annotation.WorkerThread
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.money.FiatMoney
|
||||
import org.signal.donations.PaymentSourceType
|
||||
@@ -31,44 +29,47 @@ class PayPalRepository(private val donationsService: DonationsService) {
|
||||
private val TAG = Log.tag(PayPalRepository::class.java)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a one-time payment intent that the user can use to make a donation.
|
||||
*/
|
||||
@WorkerThread
|
||||
fun createOneTimePaymentIntent(
|
||||
amount: FiatMoney,
|
||||
badgeRecipient: RecipientId,
|
||||
badgeLevel: Long
|
||||
): Single<PayPalCreatePaymentIntentResponse> {
|
||||
return Single.fromCallable {
|
||||
donationsService
|
||||
.createPayPalOneTimePaymentIntent(
|
||||
Locale.getDefault(),
|
||||
amount.currency.currencyCode,
|
||||
amount.minimumUnitPrecisionString,
|
||||
badgeLevel,
|
||||
ONE_TIME_RETURN_URL,
|
||||
CANCEL_URL
|
||||
)
|
||||
): PayPalCreatePaymentIntentResponse {
|
||||
return try {
|
||||
donationsService.createPayPalOneTimePaymentIntent(
|
||||
Locale.getDefault(),
|
||||
amount.currency.currencyCode,
|
||||
amount.minimumUnitPrecisionString,
|
||||
badgeLevel,
|
||||
ONE_TIME_RETURN_URL,
|
||||
CANCEL_URL
|
||||
).resultOrThrow
|
||||
} catch (e: Exception) {
|
||||
throw OneTimeInAppPaymentRepository.handleCreatePaymentIntentErrorSync(e, badgeRecipient, PaymentSourceType.PayPal)
|
||||
}
|
||||
.flatMap { it.flattenResult() }
|
||||
.onErrorResumeNext { OneTimeInAppPaymentRepository.handleCreatePaymentIntentError(it, badgeRecipient, PaymentSourceType.PayPal) }
|
||||
.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirms a one-time payment via the Signal Service to complete a donation.
|
||||
*/
|
||||
@WorkerThread
|
||||
fun confirmOneTimePaymentIntent(
|
||||
amount: FiatMoney,
|
||||
badgeLevel: Long,
|
||||
paypalConfirmationResult: PayPalConfirmationResult
|
||||
): Single<PayPalConfirmPaymentIntentResponse> {
|
||||
return Single.fromCallable {
|
||||
Log.d(TAG, "Confirming one-time payment intent...", true)
|
||||
donationsService
|
||||
.confirmPayPalOneTimePaymentIntent(
|
||||
amount.currency.currencyCode,
|
||||
amount.minimumUnitPrecisionString,
|
||||
badgeLevel,
|
||||
paypalConfirmationResult.payerId,
|
||||
paypalConfirmationResult.paymentId,
|
||||
paypalConfirmationResult.paymentToken
|
||||
)
|
||||
}.flatMap { it.flattenResult() }.subscribeOn(Schedulers.io())
|
||||
): PayPalConfirmPaymentIntentResponse {
|
||||
Log.d(TAG, "Confirming one-time payment intent...", true)
|
||||
return donationsService.confirmPayPalOneTimePaymentIntent(
|
||||
amount.currency.currencyCode,
|
||||
amount.minimumUnitPrecisionString,
|
||||
badgeLevel,
|
||||
paypalConfirmationResult.payerId,
|
||||
paypalConfirmationResult.paymentId,
|
||||
paypalConfirmationResult.paymentToken
|
||||
).resultOrThrow
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -76,42 +77,42 @@ class PayPalRepository(private val donationsService: DonationsService) {
|
||||
* it means that the PaymentMethod is already tied to a Stripe account. We can retry in this
|
||||
* situation by simply deleting the old subscriber id on the service and replacing it.
|
||||
*/
|
||||
fun createPaymentMethod(subscriberType: InAppPaymentSubscriberRecord.Type, retryOn409: Boolean = true): Single<PayPalCreatePaymentMethodResponse> {
|
||||
return Single.fromCallable {
|
||||
donationsService.createPayPalPaymentMethod(
|
||||
Locale.getDefault(),
|
||||
InAppPaymentsRepository.requireSubscriber(subscriberType).subscriberId,
|
||||
MONTHLY_RETURN_URL,
|
||||
CANCEL_URL
|
||||
)
|
||||
}.flatMap { serviceResponse ->
|
||||
if (retryOn409 && serviceResponse.status == 409) {
|
||||
RecurringInAppPaymentRepository.rotateSubscriberId(subscriberType).andThen(createPaymentMethod(subscriberType, retryOn409 = false))
|
||||
} else {
|
||||
serviceResponse.flattenResult()
|
||||
}
|
||||
}.subscribeOn(Schedulers.io())
|
||||
@WorkerThread
|
||||
fun createPaymentMethod(subscriberType: InAppPaymentSubscriberRecord.Type, retryOn409: Boolean = true): PayPalCreatePaymentMethodResponse {
|
||||
val response = donationsService.createPayPalPaymentMethod(
|
||||
Locale.getDefault(),
|
||||
InAppPaymentsRepository.requireSubscriber(subscriberType).subscriberId,
|
||||
MONTHLY_RETURN_URL,
|
||||
CANCEL_URL
|
||||
)
|
||||
|
||||
return if (retryOn409 && response.status == 409) {
|
||||
RecurringInAppPaymentRepository.rotateSubscriberIdSync(subscriberType)
|
||||
createPaymentMethod(subscriberType, retryOn409 = false)
|
||||
} else {
|
||||
response.resultOrThrow
|
||||
}
|
||||
}
|
||||
|
||||
fun setDefaultPaymentMethod(subscriberType: InAppPaymentSubscriberRecord.Type, paymentMethodId: String): Completable {
|
||||
return Single
|
||||
.fromCallable { InAppPaymentsRepository.requireSubscriber(subscriberType) }
|
||||
.flatMapCompletable { subscriberRecord ->
|
||||
Single.fromCallable {
|
||||
Log.d(TAG, "Setting default payment method...", true)
|
||||
donationsService.setDefaultPayPalPaymentMethod(
|
||||
subscriberRecord.subscriberId,
|
||||
paymentMethodId
|
||||
)
|
||||
}.flatMap { it.flattenResult() }.ignoreElement().doOnComplete {
|
||||
Log.d(TAG, "Set default payment method.", true)
|
||||
Log.d(TAG, "Storing the subscription payment source type locally.", true)
|
||||
/**
|
||||
* Sets the default payment method via the Signal Service.
|
||||
*/
|
||||
@WorkerThread
|
||||
fun setDefaultPaymentMethod(subscriberType: InAppPaymentSubscriberRecord.Type, paymentMethodId: String) {
|
||||
val subscriber = InAppPaymentsRepository.requireSubscriber(subscriberType)
|
||||
|
||||
SignalDatabase.inAppPaymentSubscribers.setPaymentMethod(
|
||||
subscriberRecord.subscriberId,
|
||||
InAppPaymentData.PaymentMethodType.PAYPAL
|
||||
)
|
||||
}
|
||||
}.subscribeOn(Schedulers.io())
|
||||
Log.d(TAG, "Setting default payment method...", true)
|
||||
donationsService.setDefaultPayPalPaymentMethod(
|
||||
subscriber.subscriberId,
|
||||
paymentMethodId
|
||||
).resultOrThrow
|
||||
|
||||
Log.d(TAG, "Set default payment method.", true)
|
||||
Log.d(TAG, "Storing the subscription payment source type locally.", true)
|
||||
|
||||
SignalDatabase.inAppPaymentSubscribers.setPaymentMethod(
|
||||
subscriber.subscriberId,
|
||||
InAppPaymentData.PaymentMethodType.PAYPAL
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,10 +9,7 @@ import org.signal.core.util.logging.Log
|
||||
import org.signal.donations.PaymentSourceType
|
||||
import org.thoughtcrime.securesms.badges.Badges
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository.requireSubscriberType
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository.toErrorSource
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository.toPaymentSourceType
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.InAppPaymentError
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError.BadgeRedemptionError
|
||||
import org.thoughtcrime.securesms.database.InAppPaymentTable
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
|
||||
@@ -34,12 +31,10 @@ import org.whispersystems.signalservice.api.subscriptions.SubscriberId
|
||||
import org.whispersystems.signalservice.internal.EmptyResponse
|
||||
import org.whispersystems.signalservice.internal.ServiceResponse
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
/**
|
||||
* Repository which can query for the user's active subscription as well as a list of available subscriptions,
|
||||
* in the currency indicated.
|
||||
* Shared methods for operating on recurring subscriptions, shared between donations and backups.
|
||||
*/
|
||||
object RecurringInAppPaymentRepository {
|
||||
|
||||
@@ -47,12 +42,19 @@ object RecurringInAppPaymentRepository {
|
||||
|
||||
private val donationsService = AppDependencies.donationsService
|
||||
|
||||
/**
|
||||
* Passthrough Rx wrapper for [getActiveSubscriptionSync] dispatching on io thread-pool.
|
||||
*/
|
||||
@CheckResult
|
||||
fun getActiveSubscription(type: InAppPaymentSubscriberRecord.Type): Single<ActiveSubscription> {
|
||||
return Single.fromCallable {
|
||||
getActiveSubscriptionSync(type).getOrThrow()
|
||||
}.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the active subscription if it exists for the given [InAppPaymentSubscriberRecord.Type]
|
||||
*/
|
||||
@WorkerThread
|
||||
fun getActiveSubscriptionSync(type: InAppPaymentSubscriberRecord.Type): Result<ActiveSubscription> {
|
||||
val response = InAppPaymentsRepository.getSubscriber(type)?.let {
|
||||
@@ -71,6 +73,10 @@ object RecurringInAppPaymentRepository {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a list of subscriptions available via the donations configuration.
|
||||
*/
|
||||
@CheckResult
|
||||
fun getSubscriptions(): Single<List<Subscription>> {
|
||||
return Single
|
||||
.fromCallable { donationsService.getDonationsConfiguration(Locale.getDefault()) }
|
||||
@@ -88,6 +94,10 @@ object RecurringInAppPaymentRepository {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Syncs the user account record, dispatches on the io thread-pool
|
||||
*/
|
||||
@CheckResult
|
||||
fun syncAccountRecord(): Completable {
|
||||
return Completable.fromAction {
|
||||
SignalDatabase.recipients.markNeedsSync(Recipient.self().id)
|
||||
@@ -99,60 +109,72 @@ object RecurringInAppPaymentRepository {
|
||||
* Since PayPal and Stripe can't interoperate, we need to be able to rotate the subscriber ID
|
||||
* in case of failures.
|
||||
*/
|
||||
fun rotateSubscriberId(subscriberType: InAppPaymentSubscriberRecord.Type): Completable {
|
||||
@WorkerThread
|
||||
fun rotateSubscriberIdSync(subscriberType: InAppPaymentSubscriberRecord.Type) {
|
||||
Log.d(TAG, "Rotating SubscriberId due to alternate payment processor...", true)
|
||||
val cancelCompletable: Completable = if (InAppPaymentsRepository.getSubscriber(subscriberType) != null) {
|
||||
cancelActiveSubscription(subscriberType).andThen(updateLocalSubscriptionStateAndScheduleDataSync(subscriberType))
|
||||
} else {
|
||||
Completable.complete()
|
||||
if (InAppPaymentsRepository.getSubscriber(subscriberType) != null) {
|
||||
cancelActiveSubscriptionSync(subscriberType)
|
||||
updateLocalSubscriptionStateAndScheduleDataSync(subscriberType)
|
||||
}
|
||||
|
||||
return cancelCompletable.andThen(ensureSubscriberId(subscriberType, isRotation = true))
|
||||
ensureSubscriberIdSync(subscriberType, isRotation = true)
|
||||
}
|
||||
|
||||
fun ensureSubscriberId(subscriberType: InAppPaymentSubscriberRecord.Type, isRotation: Boolean = false, iapSubscriptionId: IAPSubscriptionId? = null): Completable {
|
||||
return Single.fromCallable {
|
||||
Log.d(TAG, "Ensuring SubscriberId for type $subscriberType exists on Signal service {isRotation?$isRotation}...", true)
|
||||
/**
|
||||
* Passthrough Rx wrapper for [rotateSubscriberIdSync] dispatching on io thread-pool.
|
||||
*/
|
||||
@CheckResult
|
||||
fun rotateSubscriberId(subscriberType: InAppPaymentSubscriberRecord.Type): Completable {
|
||||
return Completable.fromAction {
|
||||
rotateSubscriberIdSync(subscriberType)
|
||||
}.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
if (isRotation) {
|
||||
SubscriberId.generate()
|
||||
} else {
|
||||
InAppPaymentsRepository.getSubscriber(subscriberType)?.subscriberId ?: SubscriberId.generate()
|
||||
}
|
||||
}.flatMap { subscriberId ->
|
||||
Single
|
||||
.fromCallable {
|
||||
donationsService.putSubscription(subscriberId)
|
||||
}
|
||||
.flatMap(ServiceResponse<EmptyResponse>::flattenResult)
|
||||
.map { subscriberId }
|
||||
}.doOnSuccess { subscriberId ->
|
||||
Log.d(TAG, "Successfully set SubscriberId exists on Signal service.", true)
|
||||
/**
|
||||
* Ensures that the given [InAppPaymentSubscriberRecord.Type] has a [SubscriberId] that has been sent to the Signal Service.
|
||||
* Will also record and synchronize this data with storage sync.
|
||||
*/
|
||||
@WorkerThread
|
||||
fun ensureSubscriberIdSync(subscriberType: InAppPaymentSubscriberRecord.Type, isRotation: Boolean = false, iapSubscriptionId: IAPSubscriptionId? = null) {
|
||||
Log.d(TAG, "Ensuring SubscriberId for type $subscriberType exists on Signal service {isRotation?$isRotation}...", true)
|
||||
|
||||
InAppPaymentsRepository.setSubscriber(
|
||||
InAppPaymentSubscriberRecord(
|
||||
subscriberId = subscriberId,
|
||||
currency = if (subscriberType == InAppPaymentSubscriberRecord.Type.DONATION) {
|
||||
SignalStore.inAppPayments.getRecurringDonationCurrency()
|
||||
} else {
|
||||
null
|
||||
},
|
||||
type = subscriberType,
|
||||
requiresCancel = false,
|
||||
paymentMethodType = if (subscriberType == InAppPaymentSubscriberRecord.Type.BACKUP) {
|
||||
InAppPaymentData.PaymentMethodType.GOOGLE_PLAY_BILLING
|
||||
} else {
|
||||
InAppPaymentData.PaymentMethodType.UNKNOWN
|
||||
},
|
||||
iapSubscriptionId = iapSubscriptionId
|
||||
)
|
||||
val subscriberId = if (isRotation) {
|
||||
SubscriberId.generate()
|
||||
} else {
|
||||
InAppPaymentsRepository.getSubscriber(subscriberType)?.subscriberId ?: SubscriberId.generate()
|
||||
}
|
||||
|
||||
donationsService.putSubscription(subscriberId).resultOrThrow
|
||||
|
||||
Log.d(TAG, "Successfully set SubscriberId exists on Signal service.", true)
|
||||
|
||||
InAppPaymentsRepository.setSubscriber(
|
||||
InAppPaymentSubscriberRecord(
|
||||
subscriberId = subscriberId,
|
||||
currency = if (subscriberType == InAppPaymentSubscriberRecord.Type.DONATION) {
|
||||
SignalStore.inAppPayments.getRecurringDonationCurrency()
|
||||
} else {
|
||||
null
|
||||
},
|
||||
type = subscriberType,
|
||||
requiresCancel = false,
|
||||
paymentMethodType = if (subscriberType == InAppPaymentSubscriberRecord.Type.BACKUP) {
|
||||
InAppPaymentData.PaymentMethodType.GOOGLE_PLAY_BILLING
|
||||
} else {
|
||||
InAppPaymentData.PaymentMethodType.UNKNOWN
|
||||
},
|
||||
iapSubscriptionId = iapSubscriptionId
|
||||
)
|
||||
)
|
||||
|
||||
SignalDatabase.recipients.markNeedsSync(Recipient.self().id)
|
||||
StorageSyncHelper.scheduleSyncForDataChange()
|
||||
}.ignoreElement().subscribeOn(Schedulers.io())
|
||||
SignalDatabase.recipients.markNeedsSync(Recipient.self().id)
|
||||
StorageSyncHelper.scheduleSyncForDataChange()
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels the active subscription via the Signal service.
|
||||
*/
|
||||
@WorkerThread
|
||||
fun cancelActiveSubscriptionSync(subscriberType: InAppPaymentSubscriberRecord.Type) {
|
||||
Log.d(TAG, "Canceling active subscription...", true)
|
||||
val localSubscriber = InAppPaymentsRepository.requireSubscriber(subscriberType)
|
||||
@@ -166,6 +188,9 @@ object RecurringInAppPaymentRepository {
|
||||
InAppPaymentsRepository.scheduleSyncForAccountRecordChange()
|
||||
}
|
||||
|
||||
/**
|
||||
* Passthrough Rx wrapper for [cancelActiveSubscriptionSync] dispatching on io thread-pool.
|
||||
*/
|
||||
@CheckResult
|
||||
fun cancelActiveSubscription(subscriberType: InAppPaymentSubscriberRecord.Type): Completable {
|
||||
return Completable
|
||||
@@ -173,107 +198,103 @@ object RecurringInAppPaymentRepository {
|
||||
.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
fun cancelActiveSubscriptionIfNecessary(subscriberType: InAppPaymentSubscriberRecord.Type): Completable {
|
||||
return Single.fromCallable { InAppPaymentsRepository.getShouldCancelSubscriptionBeforeNextSubscribeAttempt(subscriberType) }.flatMapCompletable {
|
||||
if (it) {
|
||||
cancelActiveSubscription(subscriberType).doOnComplete {
|
||||
SignalStore.inAppPayments.updateLocalStateForManualCancellation(subscriberType)
|
||||
MultiDeviceSubscriptionSyncRequestJob.enqueue()
|
||||
}
|
||||
} else {
|
||||
Completable.complete()
|
||||
}
|
||||
}.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
fun getPaymentSourceTypeOfLatestSubscription(subscriberType: InAppPaymentSubscriberRecord.Type): Single<PaymentSourceType> {
|
||||
return Single.fromCallable {
|
||||
InAppPaymentsRepository.getLatestPaymentMethodType(subscriberType).toPaymentSourceType()
|
||||
/**
|
||||
* If the subscriber of the given type has been marked as "requires cancel", this method will perform the cancellation and
|
||||
* sync the appropriate data.
|
||||
*/
|
||||
@WorkerThread
|
||||
fun cancelActiveSubscriptionIfNecessarySync(subscriberType: InAppPaymentSubscriberRecord.Type) {
|
||||
val shouldCancel = InAppPaymentsRepository.getShouldCancelSubscriptionBeforeNextSubscribeAttempt(subscriberType)
|
||||
if (shouldCancel) {
|
||||
cancelActiveSubscriptionSync(subscriberType)
|
||||
SignalStore.inAppPayments.updateLocalStateForManualCancellation(subscriberType)
|
||||
MultiDeviceSubscriptionSyncRequestJob.enqueue()
|
||||
}
|
||||
}
|
||||
|
||||
fun setSubscriptionLevel(inAppPayment: InAppPaymentTable.InAppPayment, paymentSourceType: PaymentSourceType): Completable {
|
||||
/**
|
||||
* Passthrough Rx wrapper for [cancelActiveSubscriptionIfNecessarySync] dispatching on io thread-pool.
|
||||
*/
|
||||
@CheckResult
|
||||
fun cancelActiveSubscriptionIfNecessary(subscriberType: InAppPaymentSubscriberRecord.Type): Completable {
|
||||
return Completable.fromAction {
|
||||
cancelActiveSubscriptionIfNecessarySync(subscriberType)
|
||||
}.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
/**
|
||||
* Passthrough Rx wrapper for [InAppPaymentsRepository.getLatestPaymentMethodType] dispatching on io thread-pool.
|
||||
*/
|
||||
@CheckResult
|
||||
fun getPaymentSourceTypeOfLatestSubscription(subscriberType: InAppPaymentSubscriberRecord.Type): Single<PaymentSourceType> {
|
||||
return Single.fromCallable {
|
||||
InAppPaymentsRepository.getLatestPaymentMethodType(subscriberType).toPaymentSourceType()
|
||||
}.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the subscription level as per the data in the InAppPayment.
|
||||
*
|
||||
* This method mutates the [InAppPaymentTable.InAppPayment] and thus returns a new instance.
|
||||
*/
|
||||
@CheckResult
|
||||
@WorkerThread
|
||||
fun setSubscriptionLevelSync(inAppPayment: InAppPaymentTable.InAppPayment): InAppPaymentTable.InAppPayment {
|
||||
val subscriptionLevel = inAppPayment.data.level.toString()
|
||||
val isLongRunning = paymentSourceType.isBankTransfer
|
||||
val subscriberType = inAppPayment.type.requireSubscriberType()
|
||||
val errorSource = subscriberType.inAppPaymentType.toErrorSource()
|
||||
val subscriber = InAppPaymentsRepository.requireSubscriber(subscriberType)
|
||||
|
||||
return getOrCreateLevelUpdateOperation(subscriptionLevel)
|
||||
.flatMapCompletable { levelUpdateOperation ->
|
||||
val subscriber = InAppPaymentsRepository.requireSubscriber(subscriberType)
|
||||
SignalDatabase.inAppPayments.update(
|
||||
inAppPayment = inAppPayment.copy(
|
||||
subscriberId = subscriber.subscriberId,
|
||||
data = inAppPayment.data.copy(
|
||||
redemption = InAppPaymentData.RedemptionState(
|
||||
stage = InAppPaymentData.RedemptionState.Stage.INIT
|
||||
)
|
||||
getOrCreateLevelUpdateOperation(TAG, subscriptionLevel).use { operation ->
|
||||
SignalDatabase.inAppPayments.update(
|
||||
inAppPayment = inAppPayment.copy(
|
||||
subscriberId = subscriber.subscriberId,
|
||||
data = inAppPayment.data.newBuilder().redemption(
|
||||
redemption = InAppPaymentData.RedemptionState(
|
||||
stage = InAppPaymentData.RedemptionState.Stage.INIT
|
||||
)
|
||||
)
|
||||
).build()
|
||||
)
|
||||
)
|
||||
|
||||
val timeoutError = if (isLongRunning) {
|
||||
BadgeRedemptionError.DonationPending(errorSource, inAppPayment)
|
||||
Log.d(TAG, "Attempting to set user subscription level to $subscriptionLevel", true)
|
||||
|
||||
val response = AppDependencies.donationsService.updateSubscriptionLevel(
|
||||
subscriber.subscriberId,
|
||||
subscriptionLevel,
|
||||
subscriber.currency!!.currencyCode,
|
||||
operation.idempotencyKey.serialize(),
|
||||
subscriberType.lock
|
||||
)
|
||||
|
||||
if (response.status == 200 || response.status == 204) {
|
||||
Log.d(TAG, "Successfully set user subscription to level $subscriptionLevel with response code ${response.status}", true)
|
||||
SignalStore.inAppPayments.updateLocalStateForLocalSubscribe(subscriberType)
|
||||
syncAccountRecord().subscribe()
|
||||
} else {
|
||||
if (response.applicationError.isPresent) {
|
||||
Log.w(TAG, "Failed to set user subscription to level $subscriptionLevel with response code ${response.status}", response.applicationError.get(), true)
|
||||
SignalStore.inAppPayments.clearLevelOperations()
|
||||
} else {
|
||||
BadgeRedemptionError.TimeoutWaitingForTokenError(errorSource)
|
||||
Log.w(TAG, "Failed to set user subscription to level $subscriptionLevel", response.executionError.orElse(null), true)
|
||||
}
|
||||
|
||||
Log.d(TAG, "Attempting to set user subscription level to $subscriptionLevel", true)
|
||||
Single
|
||||
.fromCallable {
|
||||
AppDependencies.donationsService.updateSubscriptionLevel(
|
||||
subscriber.subscriberId,
|
||||
subscriptionLevel,
|
||||
subscriber.currency!!.currencyCode,
|
||||
levelUpdateOperation.idempotencyKey.serialize(),
|
||||
subscriberType.lock
|
||||
)
|
||||
}
|
||||
.flatMapCompletable {
|
||||
if (it.status == 200 || it.status == 204) {
|
||||
Log.d(TAG, "Successfully set user subscription to level $subscriptionLevel with response code ${it.status}", true)
|
||||
SignalStore.inAppPayments.updateLocalStateForLocalSubscribe(subscriberType)
|
||||
syncAccountRecord().subscribe()
|
||||
LevelUpdate.updateProcessingState(false)
|
||||
Completable.complete()
|
||||
} else {
|
||||
if (it.applicationError.isPresent) {
|
||||
Log.w(TAG, "Failed to set user subscription to level $subscriptionLevel with response code ${it.status}", it.applicationError.get(), true)
|
||||
SignalStore.inAppPayments.clearLevelOperations()
|
||||
} else {
|
||||
Log.w(TAG, "Failed to set user subscription to level $subscriptionLevel", it.executionError.orElse(null), true)
|
||||
}
|
||||
response.resultOrThrow
|
||||
error("Should never get here.")
|
||||
}
|
||||
}
|
||||
|
||||
LevelUpdate.updateProcessingState(false)
|
||||
it.flattenResult().ignoreElement()
|
||||
}
|
||||
}.andThen(
|
||||
Single.fromCallable {
|
||||
Log.d(TAG, "Enqueuing request response job chain.", true)
|
||||
val freshPayment = SignalDatabase.inAppPayments.getById(inAppPayment.id)!!
|
||||
InAppPaymentRecurringContextJob.createJobChain(freshPayment).enqueue()
|
||||
}.flatMap {
|
||||
Log.d(TAG, "Awaiting completion of redemption chain for up to 10 seconds.", true)
|
||||
InAppPaymentsRepository.observeUpdates(inAppPayment.id).filter {
|
||||
it.state == InAppPaymentTable.State.END
|
||||
}.take(1).map {
|
||||
if (it.data.error != null) {
|
||||
Log.d(TAG, "Failure during redemption chain: ${it.data.error}", true)
|
||||
throw InAppPaymentError(it.data.error)
|
||||
}
|
||||
it
|
||||
}.firstOrError()
|
||||
}.timeout(10, TimeUnit.SECONDS, Single.error(timeoutError)).ignoreElement()
|
||||
)
|
||||
}.doOnError {
|
||||
LevelUpdate.updateProcessingState(false)
|
||||
}.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
private fun getOrCreateLevelUpdateOperation(subscriptionLevel: String): Single<LevelUpdateOperation> = Single.fromCallable {
|
||||
getOrCreateLevelUpdateOperation(TAG, subscriptionLevel)
|
||||
Log.d(TAG, "Enqueuing request response job chain.", true)
|
||||
val freshPayment = SignalDatabase.inAppPayments.getById(inAppPayment.id)!!
|
||||
InAppPaymentRecurringContextJob.createJobChain(freshPayment).enqueue()
|
||||
|
||||
return freshPayment
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create a [LevelUpdateOperation]
|
||||
*
|
||||
* This allows us to ensure the same idempotency key is used across multiple attempts for the same level.
|
||||
*/
|
||||
fun getOrCreateLevelUpdateOperation(tag: String, subscriptionLevel: String): LevelUpdateOperation {
|
||||
Log.d(tag, "Retrieving level update operation for $subscriptionLevel")
|
||||
val levelUpdateOperation = SignalStore.inAppPayments.getLevelOperation(subscriptionLevel)
|
||||
@@ -298,13 +319,12 @@ object RecurringInAppPaymentRepository {
|
||||
* Update local state information and schedule a storage sync for the change. This method
|
||||
* assumes you've already properly called the DELETE method for the stored ID on the server.
|
||||
*/
|
||||
private fun updateLocalSubscriptionStateAndScheduleDataSync(subscriberType: InAppPaymentSubscriberRecord.Type): Completable {
|
||||
return Completable.fromAction {
|
||||
Log.d(TAG, "Marking subscription cancelled...", true)
|
||||
SignalStore.inAppPayments.updateLocalStateForManualCancellation(subscriberType)
|
||||
MultiDeviceSubscriptionSyncRequestJob.enqueue()
|
||||
SignalDatabase.recipients.markNeedsSync(Recipient.self().id)
|
||||
StorageSyncHelper.scheduleSyncForDataChange()
|
||||
}
|
||||
@WorkerThread
|
||||
private fun updateLocalSubscriptionStateAndScheduleDataSync(subscriberType: InAppPaymentSubscriberRecord.Type) {
|
||||
Log.d(TAG, "Marking subscription cancelled...", true)
|
||||
SignalStore.inAppPayments.updateLocalStateForManualCancellation(subscriberType)
|
||||
MultiDeviceSubscriptionSyncRequestJob.enqueue()
|
||||
SignalDatabase.recipients.markNeedsSync(Recipient.self().id)
|
||||
StorageSyncHelper.scheduleSyncForDataChange()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import io.reactivex.rxjava3.core.Completable
|
||||
import androidx.annotation.WorkerThread
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.money.FiatMoney
|
||||
import org.signal.donations.GooglePayApi
|
||||
import org.signal.donations.InAppPaymentType
|
||||
import org.signal.donations.PaymentSource
|
||||
import org.signal.donations.PaymentSourceType
|
||||
import org.signal.donations.StripeApi
|
||||
import org.signal.donations.StripeIntentAccessor
|
||||
@@ -25,8 +22,6 @@ import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.util.Environment
|
||||
import org.whispersystems.signalservice.api.subscriptions.StripeClientSecret
|
||||
import org.whispersystems.signalservice.internal.EmptyResponse
|
||||
import org.whispersystems.signalservice.internal.ServiceResponse
|
||||
|
||||
/**
|
||||
* Manages bindings with payment APIs
|
||||
@@ -45,149 +40,117 @@ import org.whispersystems.signalservice.internal.ServiceResponse
|
||||
* 1. Create a PaymentMethod vai the Stripe API, utilizing the token from Google Pay
|
||||
* 1. Confirm the PaymentIntent via the Stripe API
|
||||
*/
|
||||
class StripeRepository(
|
||||
activity: Activity
|
||||
) : StripeApi.PaymentIntentFetcher, StripeApi.SetupIntentHelper {
|
||||
object StripeRepository : StripeApi.PaymentIntentFetcher, StripeApi.SetupIntentHelper {
|
||||
|
||||
private val TAG = Log.tag(StripeRepository::class.java)
|
||||
|
||||
private val googlePayApi = GooglePayApi(activity, StripeApi.Gateway(Environment.Donations.STRIPE_CONFIGURATION), Environment.Donations.GOOGLE_PAY_CONFIGURATION)
|
||||
private val stripeApi = StripeApi(Environment.Donations.STRIPE_CONFIGURATION, this, this, AppDependencies.okHttpClient)
|
||||
|
||||
fun isGooglePayAvailable(): Completable {
|
||||
return googlePayApi.queryIsReadyToPay()
|
||||
}
|
||||
|
||||
fun requestTokenFromGooglePay(price: FiatMoney, label: String, requestCode: Int) {
|
||||
Log.d(TAG, "Requesting a token from google pay...")
|
||||
googlePayApi.requestPayment(price, label, requestCode)
|
||||
}
|
||||
|
||||
fun onActivityResult(
|
||||
requestCode: Int,
|
||||
resultCode: Int,
|
||||
data: Intent?,
|
||||
expectedRequestCode: Int,
|
||||
paymentsRequestCallback: GooglePayApi.PaymentRequestCallback
|
||||
) {
|
||||
Log.d(TAG, "Processing possible google pay result...")
|
||||
googlePayApi.onActivityResult(requestCode, resultCode, data, expectedRequestCode, paymentsRequestCallback)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param price The amount to charce the local user
|
||||
* @param badgeRecipient Who will be getting the badge
|
||||
* Utilize the [StripeApi] to create a payment intent
|
||||
*
|
||||
* @return a [StripeIntentAccessor] that can be used to address the payment intent.
|
||||
*/
|
||||
fun continuePayment(
|
||||
@WorkerThread
|
||||
fun createPaymentIntent(
|
||||
price: FiatMoney,
|
||||
badgeRecipient: RecipientId,
|
||||
badgeLevel: Long,
|
||||
paymentSourceType: PaymentSourceType
|
||||
): Single<StripeIntentAccessor> {
|
||||
): StripeIntentAccessor {
|
||||
check(paymentSourceType is PaymentSourceType.Stripe)
|
||||
|
||||
Log.d(TAG, "Creating payment intent for $price...", true)
|
||||
|
||||
return stripeApi.createPaymentIntent(price, badgeLevel, paymentSourceType)
|
||||
.onErrorResumeNext {
|
||||
OneTimeInAppPaymentRepository.handleCreatePaymentIntentError(it, badgeRecipient, paymentSourceType)
|
||||
}
|
||||
.flatMap { result ->
|
||||
val recipient = Recipient.resolved(badgeRecipient)
|
||||
val errorSource = if (recipient.isSelf) DonationErrorSource.ONE_TIME else DonationErrorSource.GIFT
|
||||
val result: StripeApi.CreatePaymentIntentResult = try {
|
||||
stripeApi.createPaymentIntent(price, badgeLevel, paymentSourceType)
|
||||
} catch (e: Exception) {
|
||||
throw OneTimeInAppPaymentRepository.handleCreatePaymentIntentErrorSync(e, badgeRecipient, paymentSourceType)
|
||||
}
|
||||
|
||||
Log.d(TAG, "Created payment intent for $price.", true)
|
||||
when (result) {
|
||||
is StripeApi.CreatePaymentIntentResult.AmountIsTooSmall -> Single.error(OneTimeDonationError.AmountTooSmallError(errorSource))
|
||||
is StripeApi.CreatePaymentIntentResult.AmountIsTooLarge -> Single.error(OneTimeDonationError.AmountTooLargeError(errorSource))
|
||||
is StripeApi.CreatePaymentIntentResult.CurrencyIsNotSupported -> Single.error(OneTimeDonationError.InvalidCurrencyError(errorSource))
|
||||
is StripeApi.CreatePaymentIntentResult.Success -> Single.just(result.paymentIntent)
|
||||
}
|
||||
}.subscribeOn(Schedulers.io())
|
||||
val recipient = Recipient.resolved(badgeRecipient)
|
||||
val errorSource = if (recipient.isSelf) DonationErrorSource.ONE_TIME else DonationErrorSource.GIFT
|
||||
|
||||
Log.d(TAG, "Created payment intent for $price.", true)
|
||||
when (result) {
|
||||
is StripeApi.CreatePaymentIntentResult.AmountIsTooSmall -> throw OneTimeDonationError.AmountTooSmallError(errorSource)
|
||||
is StripeApi.CreatePaymentIntentResult.AmountIsTooLarge -> throw OneTimeDonationError.AmountTooLargeError(errorSource)
|
||||
is StripeApi.CreatePaymentIntentResult.CurrencyIsNotSupported -> throw OneTimeDonationError.InvalidCurrencyError(errorSource)
|
||||
is StripeApi.CreatePaymentIntentResult.Success -> return result.paymentIntent
|
||||
}
|
||||
}
|
||||
|
||||
fun createAndConfirmSetupIntent(
|
||||
inAppPaymentType: InAppPaymentType,
|
||||
paymentSource: StripeApi.PaymentSource,
|
||||
paymentSourceType: PaymentSourceType.Stripe
|
||||
): Single<StripeApi.Secure3DSAction> {
|
||||
Log.d(TAG, "Continuing subscription setup...", true)
|
||||
return stripeApi.createSetupIntent(inAppPaymentType, paymentSourceType)
|
||||
.flatMap { result ->
|
||||
Log.d(TAG, "Retrieved SetupIntent, confirming...", true)
|
||||
stripeApi.confirmSetupIntent(paymentSource, result.setupIntent)
|
||||
}
|
||||
}
|
||||
|
||||
fun confirmPayment(
|
||||
paymentSource: StripeApi.PaymentSource,
|
||||
/**
|
||||
* Confirms the given payment [paymentIntent] via the Stripe API
|
||||
*
|
||||
* @return a required action, if necessary. This can be the case for some credit cards as well as iDEAL transactions.
|
||||
*/
|
||||
@WorkerThread
|
||||
fun confirmPaymentIntent(
|
||||
paymentSource: PaymentSource,
|
||||
paymentIntent: StripeIntentAccessor,
|
||||
badgeRecipient: RecipientId
|
||||
): Single<StripeApi.Secure3DSAction> {
|
||||
): StripeApi.Secure3DSAction {
|
||||
val isBoost = badgeRecipient == Recipient.self().id
|
||||
val donationErrorSource: DonationErrorSource = if (isBoost) DonationErrorSource.ONE_TIME else DonationErrorSource.GIFT
|
||||
|
||||
Log.d(TAG, "Confirming payment intent...", true)
|
||||
return stripeApi.confirmPaymentIntent(paymentSource, paymentIntent)
|
||||
.onErrorResumeNext {
|
||||
Single.error(DonationError.getPaymentSetupError(donationErrorSource, it, paymentSource.type))
|
||||
}
|
||||
}
|
||||
|
||||
override fun fetchPaymentIntent(price: FiatMoney, level: Long, sourceType: PaymentSourceType.Stripe): Single<StripeIntentAccessor> {
|
||||
Log.d(TAG, "Fetching payment intent from Signal service for $price... (Locale.US minimum precision: ${price.minimumUnitPrecisionString})")
|
||||
return Single
|
||||
.fromCallable {
|
||||
AppDependencies
|
||||
.donationsService
|
||||
.createDonationIntentWithAmount(price.minimumUnitPrecisionString, price.currency.currencyCode, level, sourceType.paymentMethod)
|
||||
}
|
||||
.flatMap(ServiceResponse<StripeClientSecret>::flattenResult)
|
||||
.map {
|
||||
StripeIntentAccessor(
|
||||
objectType = StripeIntentAccessor.ObjectType.PAYMENT_INTENT,
|
||||
intentId = it.id,
|
||||
intentClientSecret = it.clientSecret
|
||||
)
|
||||
}.doOnSuccess {
|
||||
Log.d(TAG, "Got payment intent from Signal service!")
|
||||
}
|
||||
try {
|
||||
return stripeApi.confirmPaymentIntent(paymentSource, paymentIntent)
|
||||
} catch (e: Exception) {
|
||||
throw DonationError.getPaymentSetupError(donationErrorSource, e, paymentSource.type)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the PaymentMethod via the Signal Service. Note that if the operation fails with a 409,
|
||||
* it means that the PaymentMethod is already tied to a PayPal account. We can retry in this
|
||||
* situation by simply deleting the old subscriber id on the service and replacing it.
|
||||
* Creates and confirms a setup intent for a new subscription via the [StripeApi].
|
||||
*
|
||||
* @return a required action, if necessary. This can be the case for some credit cards as well as iDEAL transactions.
|
||||
*/
|
||||
private fun createPaymentMethod(subscriberType: InAppPaymentSubscriberRecord.Type, paymentSourceType: PaymentSourceType.Stripe, retryOn409: Boolean = true): Single<StripeClientSecret> {
|
||||
return Single.fromCallable { InAppPaymentsRepository.requireSubscriber(subscriberType) }
|
||||
.flatMap {
|
||||
Single.fromCallable {
|
||||
AppDependencies
|
||||
.donationsService
|
||||
.createStripeSubscriptionPaymentMethod(it.subscriberId, paymentSourceType.paymentMethod)
|
||||
}
|
||||
}
|
||||
.flatMap { serviceResponse ->
|
||||
if (retryOn409 && serviceResponse.status == 409) {
|
||||
RecurringInAppPaymentRepository.rotateSubscriberId(subscriberType).andThen(createPaymentMethod(subscriberType, paymentSourceType, retryOn409 = false))
|
||||
} else {
|
||||
serviceResponse.flattenResult()
|
||||
}
|
||||
}
|
||||
@WorkerThread
|
||||
fun createAndConfirmSetupIntent(
|
||||
inAppPaymentType: InAppPaymentType,
|
||||
paymentSource: PaymentSource,
|
||||
paymentSourceType: PaymentSourceType.Stripe
|
||||
): StripeApi.Secure3DSAction {
|
||||
Log.d(TAG, "Continuing subscription setup...", true)
|
||||
val result: StripeApi.CreateSetupIntentResult = stripeApi.createSetupIntent(inAppPaymentType, paymentSourceType)
|
||||
|
||||
Log.d(TAG, "Retrieved SetupIntent, confirming...", true)
|
||||
return stripeApi.confirmSetupIntent(paymentSource, result.setupIntent)
|
||||
}
|
||||
|
||||
override fun fetchSetupIntent(inAppPaymentType: InAppPaymentType, sourceType: PaymentSourceType.Stripe): Single<StripeIntentAccessor> {
|
||||
@WorkerThread
|
||||
override fun fetchPaymentIntent(price: FiatMoney, level: Long, sourceType: PaymentSourceType.Stripe): StripeIntentAccessor {
|
||||
Log.d(TAG, "Fetching payment intent from Signal service for $price... (Locale.US minimum precision: ${price.minimumUnitPrecisionString})")
|
||||
val response = AppDependencies
|
||||
.donationsService
|
||||
.createDonationIntentWithAmount(price.minimumUnitPrecisionString, price.currency.currencyCode, level, sourceType.paymentMethod)
|
||||
.resultOrThrow
|
||||
|
||||
val accessor = StripeIntentAccessor(
|
||||
objectType = StripeIntentAccessor.ObjectType.PAYMENT_INTENT,
|
||||
intentId = response.id,
|
||||
intentClientSecret = response.clientSecret
|
||||
)
|
||||
|
||||
Log.d(TAG, "Got payment intent from Signal service!")
|
||||
return accessor
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
override fun fetchSetupIntent(inAppPaymentType: InAppPaymentType, sourceType: PaymentSourceType.Stripe): StripeIntentAccessor {
|
||||
Log.d(TAG, "Fetching setup intent from Signal service...")
|
||||
return createPaymentMethod(inAppPaymentType.requireSubscriberType(), sourceType)
|
||||
.map {
|
||||
StripeIntentAccessor(
|
||||
objectType = StripeIntentAccessor.ObjectType.SETUP_INTENT,
|
||||
intentId = it.id,
|
||||
intentClientSecret = it.clientSecret
|
||||
)
|
||||
}
|
||||
.doOnSuccess {
|
||||
Log.d(TAG, "Got setup intent from Signal service!")
|
||||
}
|
||||
val clientSecret = createPaymentMethod(inAppPaymentType.requireSubscriberType(), sourceType)
|
||||
val accessor = StripeIntentAccessor(
|
||||
objectType = StripeIntentAccessor.ObjectType.SETUP_INTENT,
|
||||
intentId = clientSecret.id,
|
||||
intentClientSecret = clientSecret.clientSecret
|
||||
)
|
||||
|
||||
Log.d(TAG, "Got setup intent from Signal service!")
|
||||
|
||||
return accessor
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -195,57 +158,60 @@ class StripeRepository(
|
||||
* that we are successful and proceed as normal. If the payment didn't actually succeed, then we
|
||||
* expect an error later in the chain to inform us of this.
|
||||
*/
|
||||
@WorkerThread
|
||||
fun getStatusAndPaymentMethodId(
|
||||
stripeIntentAccessor: StripeIntentAccessor,
|
||||
paymentMethodId: String?
|
||||
): Single<StatusAndPaymentMethodId> {
|
||||
return Single.fromCallable {
|
||||
when (stripeIntentAccessor.objectType) {
|
||||
StripeIntentAccessor.ObjectType.NONE -> StatusAndPaymentMethodId(stripeIntentAccessor.intentId, StripeIntentStatus.SUCCEEDED, paymentMethodId)
|
||||
StripeIntentAccessor.ObjectType.PAYMENT_INTENT -> stripeApi.getPaymentIntent(stripeIntentAccessor).let {
|
||||
if (it.status == null) {
|
||||
Log.d(TAG, "Returned payment intent had a null status.", true)
|
||||
}
|
||||
StatusAndPaymentMethodId(stripeIntentAccessor.intentId, it.status ?: StripeIntentStatus.SUCCEEDED, it.paymentMethod)
|
||||
): StatusAndPaymentMethodId {
|
||||
return when (stripeIntentAccessor.objectType) {
|
||||
StripeIntentAccessor.ObjectType.NONE -> StatusAndPaymentMethodId(stripeIntentAccessor.intentId, StripeIntentStatus.SUCCEEDED, paymentMethodId)
|
||||
StripeIntentAccessor.ObjectType.PAYMENT_INTENT -> stripeApi.getPaymentIntent(stripeIntentAccessor).let {
|
||||
if (it.status == null) {
|
||||
Log.d(TAG, "Returned payment intent had a null status.", true)
|
||||
}
|
||||
StatusAndPaymentMethodId(stripeIntentAccessor.intentId, it.status ?: StripeIntentStatus.SUCCEEDED, it.paymentMethod)
|
||||
}
|
||||
|
||||
StripeIntentAccessor.ObjectType.SETUP_INTENT -> stripeApi.getSetupIntent(stripeIntentAccessor).let {
|
||||
StatusAndPaymentMethodId(stripeIntentAccessor.intentId, it.status, it.paymentMethodId)
|
||||
}
|
||||
StripeIntentAccessor.ObjectType.SETUP_INTENT -> stripeApi.getSetupIntent(stripeIntentAccessor).let {
|
||||
StatusAndPaymentMethodId(stripeIntentAccessor.intentId, it.status, it.paymentMethodId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the default payment method for the given subscriber type via Signal Service
|
||||
* which will ensure that the user's subscription can be set up properly.
|
||||
*/
|
||||
@WorkerThread
|
||||
fun setDefaultPaymentMethod(
|
||||
paymentMethodId: String,
|
||||
setupIntentId: String,
|
||||
subscriberType: InAppPaymentSubscriberRecord.Type,
|
||||
paymentSourceType: PaymentSourceType
|
||||
): Completable {
|
||||
return Single.fromCallable {
|
||||
Log.d(TAG, "Getting the subscriber...")
|
||||
InAppPaymentsRepository.requireSubscriber(subscriberType)
|
||||
}.flatMapCompletable { subscriberRecord ->
|
||||
Log.d(TAG, "Setting default payment method via Signal service...")
|
||||
Single.fromCallable {
|
||||
if (paymentSourceType == PaymentSourceType.Stripe.IDEAL) {
|
||||
AppDependencies
|
||||
.donationsService
|
||||
.setDefaultIdealPaymentMethod(subscriberRecord.subscriberId, setupIntentId)
|
||||
} else {
|
||||
AppDependencies
|
||||
.donationsService
|
||||
.setDefaultStripePaymentMethod(subscriberRecord.subscriberId, paymentMethodId)
|
||||
}
|
||||
}.flatMap(ServiceResponse<EmptyResponse>::flattenResult).ignoreElement().doOnComplete {
|
||||
Log.d(TAG, "Set default payment method via Signal service!")
|
||||
Log.d(TAG, "Storing the subscription payment source type locally.")
|
||||
SignalDatabase.inAppPaymentSubscribers.setPaymentMethod(subscriberRecord.subscriberId, paymentSourceType.toPaymentMethodType())
|
||||
}
|
||||
}
|
||||
) {
|
||||
Log.d(TAG, "Getting the subscriber...")
|
||||
val subscriber = InAppPaymentsRepository.requireSubscriber(subscriberType)
|
||||
|
||||
Log.d(TAG, "Setting default payment method via Signal service...")
|
||||
if (paymentSourceType == PaymentSourceType.Stripe.IDEAL) {
|
||||
AppDependencies
|
||||
.donationsService
|
||||
.setDefaultIdealPaymentMethod(subscriber.subscriberId, setupIntentId)
|
||||
} else {
|
||||
AppDependencies
|
||||
.donationsService
|
||||
.setDefaultStripePaymentMethod(subscriber.subscriberId, paymentMethodId)
|
||||
}.resultOrThrow
|
||||
|
||||
Log.d(TAG, "Set default payment method via Signal service!")
|
||||
Log.d(TAG, "Storing the subscription payment source type locally.")
|
||||
SignalDatabase.inAppPaymentSubscribers.setPaymentMethod(subscriber.subscriberId, paymentSourceType.toPaymentMethodType())
|
||||
}
|
||||
|
||||
fun createCreditCardPaymentSource(donationErrorSource: DonationErrorSource, cardData: StripeApi.CardData): Single<StripeApi.PaymentSource> {
|
||||
/**
|
||||
* Utilizes the StripeApi to create a [PaymentSource] for the given [StripeApi.CardData]
|
||||
*/
|
||||
fun createCreditCardPaymentSource(donationErrorSource: DonationErrorSource, cardData: StripeApi.CardData): Single<PaymentSource> {
|
||||
Log.d(TAG, "Creating credit card payment source via Stripe api...")
|
||||
return stripeApi.createPaymentSourceFromCardData(cardData).map {
|
||||
when (it) {
|
||||
@@ -255,23 +221,44 @@ class StripeRepository(
|
||||
}
|
||||
}
|
||||
|
||||
fun createSEPADebitPaymentSource(sepaDebitData: StripeApi.SEPADebitData): Single<StripeApi.PaymentSource> {
|
||||
/**
|
||||
* Utilizes the StripeApi to create a [PaymentSource] for the given [StripeApi.SEPADebitData]
|
||||
*/
|
||||
fun createSEPADebitPaymentSource(sepaDebitData: StripeApi.SEPADebitData): Single<PaymentSource> {
|
||||
Log.d(TAG, "Creating SEPA Debit payment source via Stripe api...")
|
||||
return stripeApi.createPaymentSourceFromSEPADebitData(sepaDebitData)
|
||||
}
|
||||
|
||||
fun createIdealPaymentSource(idealData: StripeApi.IDEALData): Single<StripeApi.PaymentSource> {
|
||||
/**
|
||||
* Utilizes the StripeApi to create a [PaymentSource] for the given [StripeApi.IDEALData]
|
||||
*/
|
||||
fun createIdealPaymentSource(idealData: StripeApi.IDEALData): Single<PaymentSource> {
|
||||
Log.d(TAG, "Creating iDEAL payment source via Stripe api...")
|
||||
return stripeApi.createPaymentSourceFromIDEALData(idealData)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the PaymentMethod via the Signal Service. Note that if the operation fails with a 409,
|
||||
* it means that the PaymentMethod is already tied to a PayPal account. We can retry in this
|
||||
* situation by simply deleting the old subscriber id on the service and replacing it.
|
||||
*/
|
||||
private fun createPaymentMethod(subscriberType: InAppPaymentSubscriberRecord.Type, paymentSourceType: PaymentSourceType.Stripe, retryOn409: Boolean = true): StripeClientSecret {
|
||||
val subscriber = InAppPaymentsRepository.requireSubscriber(subscriberType)
|
||||
val response = AppDependencies
|
||||
.donationsService
|
||||
.createStripeSubscriptionPaymentMethod(subscriber.subscriberId, paymentSourceType.paymentMethod)
|
||||
|
||||
return if (retryOn409 && response.status == 409) {
|
||||
RecurringInAppPaymentRepository.rotateSubscriberIdSync(subscriberType)
|
||||
createPaymentMethod(subscriberType, paymentSourceType, retryOn409 = false)
|
||||
} else {
|
||||
response.resultOrThrow
|
||||
}
|
||||
}
|
||||
|
||||
data class StatusAndPaymentMethodId(
|
||||
val intentId: String,
|
||||
val status: StripeIntentStatus,
|
||||
val paymentMethod: String?
|
||||
)
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(StripeRepository::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,13 +17,13 @@ import org.signal.core.util.getParcelableExtraCompat
|
||||
import org.signal.core.util.getSerializableCompat
|
||||
import org.signal.donations.InAppPaymentType
|
||||
import org.thoughtcrime.securesms.components.FragmentWrapperActivity
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentComponent
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.StripeRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.GooglePayComponent
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.GooglePayRepository
|
||||
|
||||
/**
|
||||
* Home base for all checkout flows.
|
||||
*/
|
||||
class CheckoutFlowActivity : FragmentWrapperActivity(), InAppPaymentComponent {
|
||||
class CheckoutFlowActivity : FragmentWrapperActivity(), GooglePayComponent {
|
||||
|
||||
companion object {
|
||||
private const val ARG_IN_APP_PAYMENT_TYPE = "in_app_payment_type"
|
||||
@@ -34,8 +34,8 @@ class CheckoutFlowActivity : FragmentWrapperActivity(), InAppPaymentComponent {
|
||||
}
|
||||
}
|
||||
|
||||
override val stripeRepository: StripeRepository by lazy { StripeRepository(this) }
|
||||
override val googlePayResultPublisher: Subject<InAppPaymentComponent.GooglePayResult> = PublishSubject.create()
|
||||
override val googlePayRepository: GooglePayRepository by lazy { GooglePayRepository(this) }
|
||||
override val googlePayResultPublisher: Subject<GooglePayComponent.GooglePayResult> = PublishSubject.create()
|
||||
|
||||
private val inAppPaymentType: InAppPaymentType by lazy {
|
||||
intent.extras!!.getSerializableCompat(ARG_IN_APP_PAYMENT_TYPE, InAppPaymentType::class.java)!!
|
||||
@@ -48,7 +48,7 @@ class CheckoutFlowActivity : FragmentWrapperActivity(), InAppPaymentComponent {
|
||||
@Suppress("DEPRECATION")
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
super.onActivityResult(requestCode, resultCode, data)
|
||||
googlePayResultPublisher.onNext(InAppPaymentComponent.GooglePayResult(requestCode, resultCode, data))
|
||||
googlePayResultPublisher.onNext(GooglePayComponent.GooglePayResult(requestCode, resultCode, data))
|
||||
}
|
||||
|
||||
class Contract : ActivityResultContract<InAppPaymentType, Result?>() {
|
||||
|
||||
@@ -24,8 +24,8 @@ import org.signal.donations.GooglePayApi
|
||||
import org.signal.donations.InAppPaymentType
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toFiatMoney
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.GooglePayComponent
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppDonations
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentComponent
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository.toPaymentSourceType
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.card.CreditCardFragment
|
||||
@@ -54,15 +54,12 @@ class InAppPaymentCheckoutDelegate(
|
||||
private val TAG = Log.tag(InAppPaymentCheckoutDelegate::class.java)
|
||||
}
|
||||
|
||||
private val inAppPaymentComponent: InAppPaymentComponent by lazy { fragment.requireListener() }
|
||||
private val googlePayComponent: GooglePayComponent by lazy { fragment.requireListener() }
|
||||
private val disposables = LifecycleDisposable()
|
||||
private val viewModel: DonationCheckoutViewModel by fragment.viewModels()
|
||||
|
||||
private val stripePaymentViewModel: StripePaymentInProgressViewModel by fragment.navGraphViewModels(
|
||||
R.id.checkout_flow,
|
||||
factoryProducer = {
|
||||
StripePaymentInProgressViewModel.Factory(inAppPaymentComponent.stripeRepository)
|
||||
}
|
||||
R.id.checkout_flow
|
||||
)
|
||||
|
||||
init {
|
||||
@@ -158,7 +155,7 @@ class InAppPaymentCheckoutDelegate(
|
||||
|
||||
private fun launchGooglePay(inAppPayment: InAppPaymentTable.InAppPayment) {
|
||||
viewModel.provideGatewayRequestForGooglePay(inAppPayment)
|
||||
inAppPaymentComponent.stripeRepository.requestTokenFromGooglePay(
|
||||
googlePayComponent.googlePayRepository.requestTokenFromGooglePay(
|
||||
price = inAppPayment.data.amount!!.toFiatMoney(),
|
||||
label = InAppDonations.resolveLabel(fragment.requireContext(), inAppPayment.type, inAppPayment.data.level),
|
||||
requestCode = InAppPaymentsRepository.getGooglePayRequestCode(inAppPayment.type)
|
||||
@@ -178,10 +175,10 @@ class InAppPaymentCheckoutDelegate(
|
||||
}
|
||||
|
||||
private fun registerGooglePayCallback() {
|
||||
disposables += inAppPaymentComponent.googlePayResultPublisher.subscribeBy(
|
||||
disposables += googlePayComponent.googlePayResultPublisher.subscribeBy(
|
||||
onNext = { paymentResult ->
|
||||
viewModel.consumeGatewayRequestForGooglePay()?.let {
|
||||
inAppPaymentComponent.stripeRepository.onActivityResult(
|
||||
googlePayComponent.googlePayRepository.onActivityResult(
|
||||
paymentResult.requestCode,
|
||||
paymentResult.resultCode,
|
||||
paymentResult.data,
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.donate
|
||||
|
||||
import androidx.annotation.CheckResult
|
||||
import io.reactivex.rxjava3.core.Completable
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.donations.InAppPaymentType
|
||||
import org.signal.donations.PaymentSource
|
||||
import org.signal.donations.PaymentSourceType
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError.BadgeRedemptionError
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
|
||||
import org.thoughtcrime.securesms.database.InAppPaymentTable
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.jobs.InAppPaymentPayPalOneTimeSetupJob
|
||||
import org.thoughtcrime.securesms.jobs.InAppPaymentPayPalRecurringSetupJob
|
||||
import org.thoughtcrime.securesms.jobs.InAppPaymentStripeOneTimeSetupJob
|
||||
import org.thoughtcrime.securesms.jobs.InAppPaymentStripeRecurringSetupJob
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Allows a fragment to display UI to the user to complete some action. When the action is completed,
|
||||
* it is expected that the InAppPayment for the given id is in the [InAppPaymentTable.State.REQUIRED_ACTION_COMPLETED]
|
||||
* state with the appropriate [InAppPaymentData] completion field set.
|
||||
*/
|
||||
typealias RequiredActionHandler = (InAppPaymentTable.InAppPaymentId) -> Completable
|
||||
|
||||
/**
|
||||
* Shared logic between paypal and stripe for initiating transactions and awaiting token
|
||||
* redemption.
|
||||
*/
|
||||
object SharedInAppPaymentPipeline {
|
||||
|
||||
private val TAG = Log.tag(SharedInAppPaymentPipeline::class)
|
||||
|
||||
/**
|
||||
* Awaits completion of the transaction with Stripe.
|
||||
*
|
||||
* This method will enqueue the proper setup job based off the type of [InAppPaymentTable.InAppPayment] and then
|
||||
* await for either [InAppPaymentTable.State.PENDING], [InAppPaymentTable.State.REQUIRES_ACTION] or [InAppPaymentTable.State.END]
|
||||
* before moving further, handling each state appropriately.
|
||||
*
|
||||
* @param requiredActionHandler Dispatch method for handling PayPal input, 3DS, iDEAL, etc.
|
||||
*/
|
||||
@CheckResult
|
||||
fun awaitTransaction(
|
||||
inAppPayment: InAppPaymentTable.InAppPayment,
|
||||
paymentSource: PaymentSource,
|
||||
requiredActionHandler: RequiredActionHandler
|
||||
): Completable {
|
||||
return InAppPaymentsRepository.observeUpdates(inAppPayment.id)
|
||||
.doOnSubscribe {
|
||||
val job = if (inAppPayment.type.recurring) {
|
||||
if (inAppPayment.data.paymentMethodType == InAppPaymentData.PaymentMethodType.PAYPAL) {
|
||||
InAppPaymentPayPalRecurringSetupJob.create(inAppPayment, paymentSource)
|
||||
} else {
|
||||
InAppPaymentStripeRecurringSetupJob.create(inAppPayment, paymentSource)
|
||||
}
|
||||
} else {
|
||||
if (inAppPayment.data.paymentMethodType == InAppPaymentData.PaymentMethodType.PAYPAL) {
|
||||
InAppPaymentPayPalOneTimeSetupJob.create(inAppPayment, paymentSource)
|
||||
} else {
|
||||
InAppPaymentStripeOneTimeSetupJob.create(inAppPayment, paymentSource)
|
||||
}
|
||||
}
|
||||
|
||||
AppDependencies.jobManager.add(job)
|
||||
}
|
||||
.skipWhile { it.state != InAppPaymentTable.State.PENDING && it.state != InAppPaymentTable.State.REQUIRES_ACTION && it.state != InAppPaymentTable.State.END }
|
||||
.firstOrError()
|
||||
.flatMapCompletable { iap ->
|
||||
when (iap.state) {
|
||||
InAppPaymentTable.State.PENDING -> {
|
||||
Log.w(TAG, "Payment of type ${inAppPayment.type} is pending. Awaiting completion.")
|
||||
awaitRedemption(iap, paymentSource.type)
|
||||
}
|
||||
|
||||
InAppPaymentTable.State.REQUIRES_ACTION -> {
|
||||
Log.d(TAG, "Payment of type ${inAppPayment.type} requires user action to set up.", true)
|
||||
requiredActionHandler(iap.id).andThen(awaitTransaction(iap, paymentSource, requiredActionHandler))
|
||||
}
|
||||
|
||||
InAppPaymentTable.State.END -> {
|
||||
if (iap.data.error != null) {
|
||||
Log.d(TAG, "IAP error detected.", true)
|
||||
Completable.error(InAppPaymentError(iap.data.error))
|
||||
} else {
|
||||
Log.d(TAG, "Unexpected early end state. Possible payment failure.", true)
|
||||
Completable.error(DonationError.genericPaymentFailure(DonationErrorSource.MONTHLY))
|
||||
}
|
||||
}
|
||||
|
||||
else -> error("Unexpected state ${iap.state}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits 10 seconds for the redemption to complete, and fails with a temporary error afterwards.
|
||||
*/
|
||||
@CheckResult
|
||||
fun awaitRedemption(inAppPayment: InAppPaymentTable.InAppPayment, paymentSourceType: PaymentSourceType): Completable {
|
||||
val isLongRunning = paymentSourceType.isBankTransfer
|
||||
val errorSource = when (inAppPayment.type) {
|
||||
InAppPaymentType.UNKNOWN -> error("Unsupported type UNKNOWN.")
|
||||
InAppPaymentType.ONE_TIME_GIFT -> DonationErrorSource.GIFT
|
||||
InAppPaymentType.ONE_TIME_DONATION -> DonationErrorSource.ONE_TIME
|
||||
InAppPaymentType.RECURRING_DONATION -> DonationErrorSource.MONTHLY
|
||||
InAppPaymentType.RECURRING_BACKUP -> error("Unsupported BACKUP.")
|
||||
}
|
||||
|
||||
val timeoutError = if (isLongRunning) {
|
||||
BadgeRedemptionError.DonationPending(errorSource, inAppPayment)
|
||||
} else {
|
||||
BadgeRedemptionError.TimeoutWaitingForTokenError(errorSource)
|
||||
}
|
||||
|
||||
return Single.fromCallable {
|
||||
Log.d(TAG, "Awaiting completion of redemption chain for up to 10 seconds.", true)
|
||||
InAppPaymentsRepository.observeUpdates(inAppPayment.id).filter {
|
||||
it.state == InAppPaymentTable.State.END
|
||||
}.take(1).map {
|
||||
if (it.data.error != null) {
|
||||
Log.d(TAG, "Failure during redemption chain: ${it.data.error}", true)
|
||||
throw InAppPaymentError(it.data.error)
|
||||
}
|
||||
it
|
||||
}.firstOrError()
|
||||
}.timeout(10, TimeUnit.SECONDS, Single.error(timeoutError)).ignoreElement()
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic error handling for donations.
|
||||
*/
|
||||
fun handleError(
|
||||
throwable: Throwable,
|
||||
inAppPaymentId: InAppPaymentTable.InAppPaymentId,
|
||||
paymentSourceType: PaymentSourceType,
|
||||
donationErrorSource: DonationErrorSource
|
||||
) {
|
||||
Log.w(TAG, "Failure in $donationErrorSource payment pipeline...", throwable, true)
|
||||
InAppPaymentsRepository.handlePipelineError(inAppPaymentId, donationErrorSource, paymentSourceType, throwable)
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,6 @@ import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.TemporaryScreenshotSecurity
|
||||
import org.thoughtcrime.securesms.components.ViewBinderDelegate
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toFiatMoney
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentComponent
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.InAppPaymentCheckoutDelegate
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.InAppPaymentProcessorAction
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.InAppPaymentProcessorActionResult
|
||||
@@ -30,7 +29,6 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.donate.st
|
||||
import org.thoughtcrime.securesms.databinding.CreditCardFragmentBinding
|
||||
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
|
||||
import org.thoughtcrime.securesms.util.ViewUtil
|
||||
import org.thoughtcrime.securesms.util.fragments.requireListener
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
|
||||
class CreditCardFragment : Fragment(R.layout.credit_card_fragment) {
|
||||
@@ -40,10 +38,7 @@ class CreditCardFragment : Fragment(R.layout.credit_card_fragment) {
|
||||
private val viewModel: CreditCardViewModel by viewModels()
|
||||
private val lifecycleDisposable = LifecycleDisposable()
|
||||
private val stripePaymentViewModel: StripePaymentInProgressViewModel by navGraphViewModels(
|
||||
R.id.checkout_flow,
|
||||
factoryProducer = {
|
||||
StripePaymentInProgressViewModel.Factory(requireListener<InAppPaymentComponent>().stripeRepository)
|
||||
}
|
||||
R.id.checkout_flow
|
||||
)
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
|
||||
@@ -21,7 +21,7 @@ import org.thoughtcrime.securesms.components.settings.DSLSettingsIcon
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
|
||||
import org.thoughtcrime.securesms.components.settings.NO_TINT
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toFiatMoney
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentComponent
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.GooglePayComponent
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.models.GooglePayButton
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.models.PayPalButton
|
||||
import org.thoughtcrime.securesms.components.settings.configure
|
||||
@@ -42,7 +42,7 @@ class GatewaySelectorBottomSheet : DSLSettingsBottomSheetFragment() {
|
||||
private val args: GatewaySelectorBottomSheetArgs by navArgs()
|
||||
|
||||
private val viewModel: GatewaySelectorViewModel by viewModels(factoryProducer = {
|
||||
GatewaySelectorViewModel.Factory(args, requireListener<InAppPaymentComponent>().stripeRepository)
|
||||
GatewaySelectorViewModel.Factory(args, requireListener<GooglePayComponent>().googlePayRepository)
|
||||
})
|
||||
|
||||
override fun bindAdapter(adapter: DSLSettingsAdapter) {
|
||||
|
||||
@@ -8,8 +8,8 @@ import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import io.reactivex.rxjava3.kotlin.plusAssign
|
||||
import io.reactivex.rxjava3.kotlin.subscribeBy
|
||||
import org.signal.donations.PaymentSourceType
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.GooglePayRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppDonations
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.StripeRepository
|
||||
import org.thoughtcrime.securesms.database.InAppPaymentTable
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
@@ -18,7 +18,7 @@ import org.thoughtcrime.securesms.util.rx.RxStore
|
||||
|
||||
class GatewaySelectorViewModel(
|
||||
args: GatewaySelectorBottomSheetArgs,
|
||||
repository: StripeRepository,
|
||||
repository: GooglePayRepository,
|
||||
private val gatewaySelectorRepository: GatewaySelectorRepository
|
||||
) : ViewModel() {
|
||||
|
||||
@@ -68,7 +68,7 @@ class GatewaySelectorViewModel(
|
||||
|
||||
class Factory(
|
||||
private val args: GatewaySelectorBottomSheetArgs,
|
||||
private val repository: StripeRepository,
|
||||
private val repository: GooglePayRepository,
|
||||
private val gatewaySelectorRepository: GatewaySelectorRepository = GatewaySelectorRepository(AppDependencies.donationsService)
|
||||
) : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
|
||||
@@ -14,6 +14,7 @@ import androidx.navigation.fragment.findNavController
|
||||
import androidx.navigation.fragment.navArgs
|
||||
import androidx.navigation.navGraphViewModels
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.core.Completable
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.kotlin.subscribeBy
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
@@ -29,6 +30,9 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.donate.In
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.InAppPaymentProcessorActionResult
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.InAppPaymentProcessorStage
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError
|
||||
import org.thoughtcrime.securesms.database.InAppPaymentTable
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
|
||||
import org.thoughtcrime.securesms.databinding.DonationInProgressFragmentBinding
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
import org.whispersystems.signalservice.api.subscriptions.PayPalCreatePaymentIntentResponse
|
||||
@@ -62,7 +66,14 @@ class PayPalPaymentInProgressFragment : DialogFragment(R.layout.donation_in_prog
|
||||
viewModel.onBeginNewAction()
|
||||
when (args.action) {
|
||||
InAppPaymentProcessorAction.PROCESS_NEW_IN_APP_PAYMENT -> {
|
||||
viewModel.processNewDonation(args.inAppPayment!!, this::oneTimeConfirmationPipeline, this::monthlyConfirmationPipeline)
|
||||
viewModel.processNewDonation(
|
||||
args.inAppPayment!!,
|
||||
if (args.inAppPaymentType.recurring) {
|
||||
this::monthlyConfirmationPipeline
|
||||
} else {
|
||||
this::oneTimeConfirmationPipeline
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
InAppPaymentProcessorAction.UPDATE_SUBSCRIPTION -> {
|
||||
@@ -129,12 +140,66 @@ class PayPalPaymentInProgressFragment : DialogFragment(R.layout.donation_in_prog
|
||||
}
|
||||
}
|
||||
|
||||
private fun oneTimeConfirmationPipeline(createPaymentIntentResponse: PayPalCreatePaymentIntentResponse): Single<PayPalConfirmationResult> {
|
||||
return routeToOneTimeConfirmation(createPaymentIntentResponse)
|
||||
private fun oneTimeConfirmationPipeline(inAppPaymentId: InAppPaymentTable.InAppPaymentId): Completable {
|
||||
return Single.fromCallable {
|
||||
SignalDatabase.inAppPayments.getById(inAppPaymentId)!!
|
||||
}.map { inAppPayment ->
|
||||
val requiresAction: InAppPaymentData.PayPalRequiresActionState = inAppPayment.data.payPalRequiresAction ?: error("InAppPayment is missing requiresAction data")
|
||||
PayPalCreatePaymentIntentResponse(
|
||||
requiresAction.approvalUrl,
|
||||
requiresAction.token
|
||||
)
|
||||
}.flatMap {
|
||||
routeToOneTimeConfirmation(it)
|
||||
}.flatMapCompletable {
|
||||
Completable.fromAction {
|
||||
Log.d(TAG, "User confirmed action. Updating intent accessors and resubmitting job.")
|
||||
val postNextActionPayment = SignalDatabase.inAppPayments.getById(inAppPaymentId)!!
|
||||
|
||||
SignalDatabase.inAppPayments.update(
|
||||
postNextActionPayment.copy(
|
||||
state = InAppPaymentTable.State.REQUIRED_ACTION_COMPLETED,
|
||||
data = postNextActionPayment.data.newBuilder().payPalActionComplete(
|
||||
payPalActionComplete = InAppPaymentData.PayPalActionCompleteState(
|
||||
paymentId = it.paymentId ?: "",
|
||||
paymentToken = it.paymentToken,
|
||||
payerId = it.payerId
|
||||
)
|
||||
).build()
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun monthlyConfirmationPipeline(createPaymentIntentResponse: PayPalCreatePaymentMethodResponse): Single<PayPalPaymentMethodId> {
|
||||
return routeToMonthlyConfirmation(createPaymentIntentResponse)
|
||||
private fun monthlyConfirmationPipeline(inAppPaymentId: InAppPaymentTable.InAppPaymentId): Completable {
|
||||
return Single.fromCallable {
|
||||
SignalDatabase.inAppPayments.getById(inAppPaymentId)!!
|
||||
}.map { inAppPayment ->
|
||||
val requiresAction: InAppPaymentData.PayPalRequiresActionState = inAppPayment.data.payPalRequiresAction ?: error("InAppPayment is missing requiresAction data")
|
||||
PayPalCreatePaymentMethodResponse(
|
||||
requiresAction.approvalUrl,
|
||||
requiresAction.token
|
||||
)
|
||||
}.flatMap {
|
||||
routeToMonthlyConfirmation(it)
|
||||
}.flatMapCompletable {
|
||||
Completable.fromAction {
|
||||
Log.d(TAG, "User confirmed action. Updating intent accessors and resubmitting job.")
|
||||
val postNextActionPayment = SignalDatabase.inAppPayments.getById(inAppPaymentId)!!
|
||||
|
||||
SignalDatabase.inAppPayments.update(
|
||||
postNextActionPayment.copy(
|
||||
state = InAppPaymentTable.State.REQUIRED_ACTION_COMPLETED,
|
||||
data = postNextActionPayment.data.newBuilder().payPalActionComplete(
|
||||
payPalActionComplete = InAppPaymentData.PayPalActionCompleteState(
|
||||
paymentId = it.paymentId
|
||||
)
|
||||
).build()
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun routeToOneTimeConfirmation(createPaymentIntentResponse: PayPalCreatePaymentIntentResponse): Single<PayPalConfirmationResult> {
|
||||
|
||||
@@ -3,36 +3,32 @@ package org.thoughtcrime.securesms.components.settings.app.subscription.donate.p
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.core.Completable
|
||||
import io.reactivex.rxjava3.core.Flowable
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.core.SingleSource
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import io.reactivex.rxjava3.kotlin.plusAssign
|
||||
import io.reactivex.rxjava3.kotlin.subscribeBy
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.donations.InAppPaymentType
|
||||
import org.signal.donations.PayPalPaymentSource
|
||||
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.toErrorSource
|
||||
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
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.InAppPaymentProcessorStage
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.RequiredActionHandler
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.SharedInAppPaymentPipeline
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
|
||||
import org.thoughtcrime.securesms.database.InAppPaymentTable
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.jobs.MultiDeviceSubscriptionSyncRequestJob
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.util.rx.RxStore
|
||||
import org.whispersystems.signalservice.api.subscriptions.PayPalCreatePaymentIntentResponse
|
||||
import org.whispersystems.signalservice.api.subscriptions.PayPalCreatePaymentMethodResponse
|
||||
import org.whispersystems.signalservice.api.util.Preconditions
|
||||
|
||||
class PayPalPaymentInProgressViewModel(
|
||||
@@ -67,36 +63,28 @@ class PayPalPaymentInProgressViewModel(
|
||||
disposables.clear()
|
||||
}
|
||||
|
||||
fun processNewDonation(
|
||||
inAppPayment: InAppPaymentTable.InAppPayment,
|
||||
routeToOneTimeConfirmation: (PayPalCreatePaymentIntentResponse) -> Single<PayPalConfirmationResult>,
|
||||
routeToMonthlyConfirmation: (PayPalCreatePaymentMethodResponse) -> Single<PayPalPaymentMethodId>
|
||||
) {
|
||||
Log.d(TAG, "Proceeding with donation...", true)
|
||||
|
||||
return if (inAppPayment.type.recurring) {
|
||||
proceedMonthly(inAppPayment, routeToMonthlyConfirmation)
|
||||
} else {
|
||||
proceedOneTime(inAppPayment, routeToOneTimeConfirmation)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateSubscription(inAppPayment: InAppPaymentTable.InAppPayment) {
|
||||
Log.d(TAG, "Beginning subscription update...", true)
|
||||
|
||||
store.update { InAppPaymentProcessorStage.PAYMENT_PIPELINE }
|
||||
disposables += RecurringInAppPaymentRepository.cancelActiveSubscriptionIfNecessary(inAppPayment.type.requireSubscriberType()).andThen(RecurringInAppPaymentRepository.setSubscriptionLevel(inAppPayment, PaymentSourceType.PayPal))
|
||||
.subscribeBy(
|
||||
onComplete = {
|
||||
Log.w(TAG, "Completed subscription update", true)
|
||||
store.update { InAppPaymentProcessorStage.COMPLETE }
|
||||
},
|
||||
onError = { throwable ->
|
||||
Log.w(TAG, "Failed to update subscription", throwable, true)
|
||||
store.update { InAppPaymentProcessorStage.FAILED }
|
||||
InAppPaymentsRepository.handlePipelineError(inAppPayment.id, DonationErrorSource.MONTHLY, PaymentSourceType.PayPal, throwable)
|
||||
}
|
||||
)
|
||||
disposables += RecurringInAppPaymentRepository.cancelActiveSubscriptionIfNecessary(inAppPayment.type.requireSubscriberType()).andThen(
|
||||
SingleSource<InAppPaymentTable.InAppPayment> {
|
||||
val freshPayment = SignalDatabase.inAppPayments.moveToTransacting(inAppPayment.id)!!
|
||||
RecurringInAppPaymentRepository.setSubscriptionLevelSync(freshPayment)
|
||||
}
|
||||
).flatMapCompletable {
|
||||
SharedInAppPaymentPipeline.awaitRedemption(it, PaymentSourceType.PayPal)
|
||||
}.subscribeBy(
|
||||
onComplete = {
|
||||
Log.w(TAG, "Completed subscription update", true)
|
||||
store.update { InAppPaymentProcessorStage.COMPLETE }
|
||||
},
|
||||
onError = { throwable ->
|
||||
Log.w(TAG, "Failed to update subscription", throwable, true)
|
||||
store.update { InAppPaymentProcessorStage.FAILED }
|
||||
InAppPaymentsRepository.handlePipelineError(inAppPayment.id, DonationErrorSource.MONTHLY, PaymentSourceType.PayPal, throwable)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun cancelSubscription(subscriberType: InAppPaymentSubscriberRecord.Type) {
|
||||
@@ -118,79 +106,27 @@ class PayPalPaymentInProgressViewModel(
|
||||
)
|
||||
}
|
||||
|
||||
private fun proceedOneTime(
|
||||
inAppPayment: InAppPaymentTable.InAppPayment,
|
||||
routeToPaypalConfirmation: (PayPalCreatePaymentIntentResponse) -> Single<PayPalConfirmationResult>
|
||||
) {
|
||||
fun processNewDonation(inAppPayment: InAppPaymentTable.InAppPayment, requiredActionHandler: RequiredActionHandler) {
|
||||
Log.d(TAG, "Proceeding with InAppPayment::${inAppPayment.id} of type ${inAppPayment.type}...", true)
|
||||
|
||||
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) {
|
||||
OneTimeInAppPaymentRepository.verifyRecipientIsAllowedToReceiveAGift(RecipientId.from(inAppPayment.data.recipientId!!))
|
||||
} else {
|
||||
Completable.complete()
|
||||
}
|
||||
|
||||
disposables += verifyUser
|
||||
.andThen(
|
||||
payPalRepository
|
||||
.createOneTimePaymentIntent(
|
||||
amount = inAppPayment.data.amount!!.toFiatMoney(),
|
||||
badgeRecipient = inAppPayment.data.recipientId?.let { RecipientId.from(it) } ?: Recipient.self().id,
|
||||
badgeLevel = inAppPayment.data.level
|
||||
)
|
||||
)
|
||||
.flatMap(routeToPaypalConfirmation)
|
||||
.flatMap { result ->
|
||||
payPalRepository.confirmOneTimePaymentIntent(
|
||||
amount = inAppPayment.data.amount.toFiatMoney(),
|
||||
badgeLevel = inAppPayment.data.level,
|
||||
paypalConfirmationResult = result
|
||||
)
|
||||
disposables += SharedInAppPaymentPipeline.awaitTransaction(
|
||||
inAppPayment,
|
||||
PayPalPaymentSource(),
|
||||
requiredActionHandler
|
||||
).subscribeOn(Schedulers.io()).subscribeBy(
|
||||
onComplete = {
|
||||
Log.d(TAG, "Finished ${inAppPayment.type} payment pipeline...", true)
|
||||
store.update { InAppPaymentProcessorStage.COMPLETE }
|
||||
},
|
||||
onError = {
|
||||
store.update { InAppPaymentProcessorStage.FAILED }
|
||||
SharedInAppPaymentPipeline.handleError(it, inAppPayment.id, PaymentSourceType.PayPal, inAppPayment.type.toErrorSource())
|
||||
}
|
||||
.flatMapCompletable { response ->
|
||||
OneTimeInAppPaymentRepository.waitForOneTimeRedemption(
|
||||
inAppPayment = inAppPayment,
|
||||
paymentIntentId = response.paymentId
|
||||
)
|
||||
}
|
||||
.subscribeOn(Schedulers.io())
|
||||
.subscribeBy(
|
||||
onError = { throwable ->
|
||||
Log.w(TAG, "Failure in one-time payment pipeline...", throwable, true)
|
||||
store.update { InAppPaymentProcessorStage.FAILED }
|
||||
InAppPaymentsRepository.handlePipelineError(inAppPayment.id, DonationErrorSource.ONE_TIME, PaymentSourceType.PayPal, throwable)
|
||||
},
|
||||
onComplete = {
|
||||
Log.d(TAG, "Finished one-time payment pipeline...", true)
|
||||
store.update { InAppPaymentProcessorStage.COMPLETE }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun proceedMonthly(inAppPayment: InAppPaymentTable.InAppPayment, routeToPaypalConfirmation: (PayPalCreatePaymentMethodResponse) -> Single<PayPalPaymentMethodId>) {
|
||||
Log.d(TAG, "Proceeding with monthly payment pipeline for InAppPayment::${inAppPayment.id} of type ${inAppPayment.type}...", true)
|
||||
|
||||
val setup = RecurringInAppPaymentRepository.ensureSubscriberId(inAppPayment.type.requireSubscriberType())
|
||||
.andThen(RecurringInAppPaymentRepository.cancelActiveSubscriptionIfNecessary(inAppPayment.type.requireSubscriberType()))
|
||||
.andThen(payPalRepository.createPaymentMethod(inAppPayment.type.requireSubscriberType()))
|
||||
.flatMap(routeToPaypalConfirmation)
|
||||
.flatMapCompletable { payPalRepository.setDefaultPaymentMethod(inAppPayment.type.requireSubscriberType(), it.paymentId) }
|
||||
.onErrorResumeNext { Completable.error(DonationError.getPaymentSetupError(DonationErrorSource.MONTHLY, it, PaymentSourceType.PayPal)) }
|
||||
|
||||
disposables += setup.andThen(RecurringInAppPaymentRepository.setSubscriptionLevel(inAppPayment, PaymentSourceType.PayPal))
|
||||
.subscribeBy(
|
||||
onError = { throwable ->
|
||||
Log.w(TAG, "Failure in monthly payment pipeline...", throwable, true)
|
||||
store.update { InAppPaymentProcessorStage.FAILED }
|
||||
InAppPaymentsRepository.handlePipelineError(inAppPayment.id, DonationErrorSource.MONTHLY, PaymentSourceType.PayPal, throwable)
|
||||
},
|
||||
onComplete = {
|
||||
Log.d(TAG, "Finished subscription payment pipeline...", true)
|
||||
store.update { InAppPaymentProcessorStage.COMPLETE }
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
class Factory(
|
||||
|
||||
@@ -110,6 +110,10 @@ class Stripe3DSDialogFragment : DialogFragment(R.layout.donation_webview_fragmen
|
||||
}
|
||||
|
||||
private fun handleLaunchExternal(intent: Intent) {
|
||||
if (isDetached) {
|
||||
return
|
||||
}
|
||||
|
||||
startActivity(intent)
|
||||
|
||||
SignalExecutors.BOUNDED_IO.execute {
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe
|
||||
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import org.signal.donations.StripeApi
|
||||
import org.signal.donations.StripeIntentAccessor
|
||||
import org.thoughtcrime.securesms.database.InAppPaymentTable
|
||||
|
||||
fun interface StripeNextActionHandler {
|
||||
fun handle(
|
||||
action: StripeApi.Secure3DSAction,
|
||||
inAppPayment: InAppPaymentTable.InAppPayment
|
||||
): Single<StripeIntentAccessor>
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.components.settings.app.subscription.donate.s
|
||||
import android.app.Dialog
|
||||
import android.graphics.Color
|
||||
import android.graphics.drawable.ColorDrawable
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.core.os.bundleOf
|
||||
@@ -13,6 +14,7 @@ import androidx.navigation.fragment.findNavController
|
||||
import androidx.navigation.fragment.navArgs
|
||||
import androidx.navigation.navGraphViewModels
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.core.Completable
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.kotlin.subscribeBy
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
@@ -24,7 +26,7 @@ import org.signal.donations.StripeApi
|
||||
import org.signal.donations.StripeIntentAccessor
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.ViewBinderDelegate
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentComponent
|
||||
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.toErrorSource
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.InAppPaymentProcessorAction
|
||||
@@ -32,8 +34,9 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.donate.In
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.InAppPaymentProcessorStage
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError
|
||||
import org.thoughtcrime.securesms.database.InAppPaymentTable
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
|
||||
import org.thoughtcrime.securesms.databinding.DonationInProgressFragmentBinding
|
||||
import org.thoughtcrime.securesms.util.fragments.requireListener
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
|
||||
class StripePaymentInProgressFragment : DialogFragment(R.layout.donation_in_progress_fragment) {
|
||||
@@ -49,10 +52,7 @@ class StripePaymentInProgressFragment : DialogFragment(R.layout.donation_in_prog
|
||||
private val disposables = LifecycleDisposable()
|
||||
|
||||
private val viewModel: StripePaymentInProgressViewModel by navGraphViewModels(
|
||||
R.id.checkout_flow,
|
||||
factoryProducer = {
|
||||
StripePaymentInProgressViewModel.Factory(requireListener<InAppPaymentComponent>().stripeRepository)
|
||||
}
|
||||
R.id.checkout_flow
|
||||
)
|
||||
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||
@@ -67,7 +67,7 @@ class StripePaymentInProgressFragment : DialogFragment(R.layout.donation_in_prog
|
||||
viewModel.onBeginNewAction()
|
||||
when (args.action) {
|
||||
InAppPaymentProcessorAction.PROCESS_NEW_IN_APP_PAYMENT -> {
|
||||
viewModel.processNewDonation(args.inAppPayment!!, this::handleSecure3dsAction)
|
||||
viewModel.processNewDonation(args.inAppPayment!!, this::handleRequiredAction)
|
||||
}
|
||||
|
||||
InAppPaymentProcessorAction.UPDATE_SUBSCRIPTION -> {
|
||||
@@ -133,38 +133,91 @@ class StripePaymentInProgressFragment : DialogFragment(R.layout.donation_in_prog
|
||||
getString(R.string.InAppPaymentInProgressFragment__processing_donation)
|
||||
}
|
||||
}
|
||||
private fun handleSecure3dsAction(secure3dsAction: StripeApi.Secure3DSAction, inAppPayment: InAppPaymentTable.InAppPayment): Single<StripeIntentAccessor> {
|
||||
return when (secure3dsAction) {
|
||||
is StripeApi.Secure3DSAction.NotNeeded -> {
|
||||
Log.d(TAG, "No 3DS action required.")
|
||||
Single.just(StripeIntentAccessor.NO_ACTION_REQUIRED)
|
||||
}
|
||||
|
||||
is StripeApi.Secure3DSAction.ConfirmRequired -> {
|
||||
Log.d(TAG, "3DS action required. Displaying dialog...")
|
||||
Single.create { emitter ->
|
||||
val listener = FragmentResultListener { _, bundle ->
|
||||
val result: StripeIntentAccessor? = bundle.getParcelableCompat(Stripe3DSDialogFragment.REQUEST_KEY, StripeIntentAccessor::class.java)
|
||||
if (result != null) {
|
||||
emitter.onSuccess(result)
|
||||
private fun handleRequiredAction(inAppPaymentId: InAppPaymentTable.InAppPaymentId): Completable {
|
||||
return Single.fromCallable {
|
||||
SignalDatabase.inAppPayments.getById(inAppPaymentId)!!
|
||||
}.map { inAppPayment ->
|
||||
val requiresAction: InAppPaymentData.StripeRequiresActionState = inAppPayment.data.stripeRequiresAction ?: error("REQUIRES_ACTION without action data")
|
||||
inAppPayment to StripeApi.Secure3DSAction.from(
|
||||
uri = Uri.parse(requiresAction.uri),
|
||||
returnUri = Uri.parse(requiresAction.returnUri),
|
||||
stripeIntentAccessor = StripeIntentAccessor(
|
||||
objectType = if (inAppPayment.type.recurring) {
|
||||
StripeIntentAccessor.ObjectType.SETUP_INTENT
|
||||
} else {
|
||||
StripeIntentAccessor.ObjectType.PAYMENT_INTENT
|
||||
},
|
||||
intentId = requiresAction.stripeIntentId,
|
||||
intentClientSecret = requiresAction.stripeClientSecret
|
||||
)
|
||||
)
|
||||
}.flatMap { (originalPayment, secure3dsAction) ->
|
||||
when (secure3dsAction) {
|
||||
is StripeApi.Secure3DSAction.NotNeeded -> {
|
||||
Log.d(TAG, "No 3DS action required.")
|
||||
Single.just(StripeIntentAccessor.NO_ACTION_REQUIRED)
|
||||
}
|
||||
|
||||
is StripeApi.Secure3DSAction.ConfirmRequired -> {
|
||||
Log.d(TAG, "3DS action required. Displaying dialog...")
|
||||
|
||||
val waitingForAuthPayment = originalPayment.copy(
|
||||
subscriberId = if (originalPayment.type.recurring) {
|
||||
InAppPaymentsRepository.requireSubscriber(originalPayment.type.requireSubscriberType()).subscriberId
|
||||
} else {
|
||||
val didLaunchExternal = bundle.getBoolean(Stripe3DSDialogFragment.LAUNCHED_EXTERNAL, false)
|
||||
if (didLaunchExternal) {
|
||||
emitter.onError(DonationError.UserLaunchedExternalApplication(args.inAppPaymentType.toErrorSource()))
|
||||
null
|
||||
},
|
||||
state = InAppPaymentTable.State.WAITING_FOR_AUTHORIZATION,
|
||||
data = originalPayment.data.newBuilder().waitForAuth(
|
||||
waitForAuth = InAppPaymentData.WaitingForAuthorizationState(
|
||||
stripeIntentId = secure3dsAction.stripeIntentAccessor.intentId,
|
||||
stripeClientSecret = secure3dsAction.stripeIntentAccessor.intentClientSecret
|
||||
)
|
||||
).build()
|
||||
)
|
||||
|
||||
Single.create { emitter ->
|
||||
val listener = FragmentResultListener { _, bundle ->
|
||||
val result: StripeIntentAccessor? = bundle.getParcelableCompat(Stripe3DSDialogFragment.REQUEST_KEY, StripeIntentAccessor::class.java)
|
||||
if (result != null) {
|
||||
emitter.onSuccess(result)
|
||||
} else {
|
||||
emitter.onError(DonationError.UserCancelledPaymentError(args.inAppPaymentType.toErrorSource()))
|
||||
val didLaunchExternal = bundle.getBoolean(Stripe3DSDialogFragment.LAUNCHED_EXTERNAL, false)
|
||||
if (didLaunchExternal) {
|
||||
emitter.onError(DonationError.UserLaunchedExternalApplication(args.inAppPaymentType.toErrorSource()))
|
||||
} else {
|
||||
emitter.onError(DonationError.UserCancelledPaymentError(args.inAppPaymentType.toErrorSource()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
parentFragmentManager.setFragmentResultListener(Stripe3DSDialogFragment.REQUEST_KEY, this, listener)
|
||||
parentFragmentManager.setFragmentResultListener(Stripe3DSDialogFragment.REQUEST_KEY, this, listener)
|
||||
|
||||
findNavController().safeNavigate(StripePaymentInProgressFragmentDirections.actionStripePaymentInProgressFragmentToStripe3dsDialogFragment(secure3dsAction.uri, secure3dsAction.returnUri, inAppPayment))
|
||||
findNavController().safeNavigate(StripePaymentInProgressFragmentDirections.actionStripePaymentInProgressFragmentToStripe3dsDialogFragment(secure3dsAction.uri, secure3dsAction.returnUri, waitingForAuthPayment))
|
||||
|
||||
emitter.setCancellable {
|
||||
parentFragmentManager.clearFragmentResultListener(Stripe3DSDialogFragment.REQUEST_KEY)
|
||||
}
|
||||
}.subscribeOn(AndroidSchedulers.mainThread()).observeOn(Schedulers.io())
|
||||
emitter.setCancellable {
|
||||
parentFragmentManager.clearFragmentResultListener(Stripe3DSDialogFragment.REQUEST_KEY)
|
||||
}
|
||||
}.subscribeOn(AndroidSchedulers.mainThread()).observeOn(Schedulers.io())
|
||||
}
|
||||
}
|
||||
}.flatMapCompletable { stripeIntentAccessor ->
|
||||
Completable.fromAction {
|
||||
Log.d(TAG, "User confirmed action. Updating intent accessors and resubmitting job.")
|
||||
val postNextActionPayment = SignalDatabase.inAppPayments.getById(inAppPaymentId)!!
|
||||
|
||||
SignalDatabase.inAppPayments.update(
|
||||
postNextActionPayment.copy(
|
||||
state = InAppPaymentTable.State.REQUIRED_ACTION_COMPLETED,
|
||||
data = postNextActionPayment.data.newBuilder().stripeActionComplete(
|
||||
stripeActionComplete = InAppPaymentData.StripeActionCompleteState(
|
||||
stripeIntentId = stripeIntentAccessor.intentId,
|
||||
stripeClientSecret = stripeIntentAccessor.intentClientSecret
|
||||
)
|
||||
).build()
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,46 +1,37 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import com.google.android.gms.wallet.PaymentData
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.core.Completable
|
||||
import io.reactivex.rxjava3.core.Flowable
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import io.reactivex.rxjava3.kotlin.plusAssign
|
||||
import io.reactivex.rxjava3.kotlin.subscribeBy
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import org.signal.core.util.concurrent.SignalExecutors
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.donations.GooglePayPaymentSource
|
||||
import org.signal.donations.InAppPaymentType
|
||||
import org.signal.donations.PaymentSource
|
||||
import org.signal.donations.PaymentSourceType
|
||||
import org.signal.donations.StripeApi
|
||||
import org.signal.donations.StripeIntentAccessor
|
||||
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.toErrorSource
|
||||
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.RecurringInAppPaymentRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.StripeRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.InAppPaymentProcessorStage
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.RequiredActionHandler
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.SharedInAppPaymentPipeline
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.toDonationError
|
||||
import org.thoughtcrime.securesms.database.InAppPaymentTable
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.util.rx.RxStore
|
||||
import org.whispersystems.signalservice.api.util.Preconditions
|
||||
import org.whispersystems.signalservice.internal.push.exceptions.InAppPaymentProcessorError
|
||||
|
||||
class StripePaymentInProgressViewModel(
|
||||
private val stripeRepository: StripeRepository
|
||||
) : ViewModel() {
|
||||
class StripePaymentInProgressViewModel : ViewModel() {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(StripePaymentInProgressViewModel::class.java)
|
||||
@@ -73,38 +64,53 @@ class StripePaymentInProgressViewModel(
|
||||
disposables.clear()
|
||||
}
|
||||
|
||||
fun processNewDonation(inAppPayment: InAppPaymentTable.InAppPayment, nextActionHandler: StripeNextActionHandler) {
|
||||
fun processNewDonation(inAppPayment: InAppPaymentTable.InAppPayment, requiredActionHandler: RequiredActionHandler) {
|
||||
Log.d(TAG, "Proceeding with InAppPayment::${inAppPayment.id} of type ${inAppPayment.type}...", true)
|
||||
|
||||
val paymentSourceProvider: PaymentSourceProvider = resolvePaymentSourceProvider(inAppPayment.type.toErrorSource())
|
||||
|
||||
return if (inAppPayment.type.recurring) {
|
||||
proceedMonthly(inAppPayment, paymentSourceProvider, nextActionHandler)
|
||||
} else {
|
||||
proceedOneTime(inAppPayment, paymentSourceProvider, nextActionHandler)
|
||||
}
|
||||
check(inAppPayment.data.paymentMethodType.toPaymentSourceType() == paymentSourceProvider.paymentSourceType)
|
||||
|
||||
store.update { InAppPaymentProcessorStage.PAYMENT_PIPELINE }
|
||||
|
||||
disposables += paymentSourceProvider.paymentSource.flatMapCompletable { paymentSource ->
|
||||
SharedInAppPaymentPipeline.awaitTransaction(
|
||||
inAppPayment,
|
||||
paymentSource,
|
||||
requiredActionHandler
|
||||
)
|
||||
}.subscribeOn(Schedulers.io()).subscribeBy(
|
||||
onComplete = {
|
||||
Log.d(TAG, "Finished ${inAppPayment.type} payment pipeline...", true)
|
||||
store.update { InAppPaymentProcessorStage.COMPLETE }
|
||||
},
|
||||
onError = {
|
||||
store.update { InAppPaymentProcessorStage.FAILED }
|
||||
SharedInAppPaymentPipeline.handleError(it, inAppPayment.id, paymentSourceProvider.paymentSourceType, inAppPayment.type.toErrorSource())
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun resolvePaymentSourceProvider(errorSource: DonationErrorSource): PaymentSourceProvider {
|
||||
return when (val data = stripePaymentData) {
|
||||
is StripePaymentData.GooglePay -> PaymentSourceProvider(
|
||||
PaymentSourceType.Stripe.GooglePay,
|
||||
Single.just<StripeApi.PaymentSource>(GooglePayPaymentSource(data.paymentData)).doAfterTerminate { clearPaymentInformation() }
|
||||
Single.just<PaymentSource>(GooglePayPaymentSource(data.paymentData)).doAfterTerminate { clearPaymentInformation() }
|
||||
)
|
||||
|
||||
is StripePaymentData.CreditCard -> PaymentSourceProvider(
|
||||
PaymentSourceType.Stripe.CreditCard,
|
||||
stripeRepository.createCreditCardPaymentSource(errorSource, data.cardData).doAfterTerminate { clearPaymentInformation() }
|
||||
StripeRepository.createCreditCardPaymentSource(errorSource, data.cardData).doAfterTerminate { clearPaymentInformation() }
|
||||
)
|
||||
|
||||
is StripePaymentData.SEPADebit -> PaymentSourceProvider(
|
||||
PaymentSourceType.Stripe.SEPADebit,
|
||||
stripeRepository.createSEPADebitPaymentSource(data.sepaDebitData).doAfterTerminate { clearPaymentInformation() }
|
||||
StripeRepository.createSEPADebitPaymentSource(data.sepaDebitData).doAfterTerminate { clearPaymentInformation() }
|
||||
)
|
||||
|
||||
is StripePaymentData.IDEAL -> PaymentSourceProvider(
|
||||
PaymentSourceType.Stripe.IDEAL,
|
||||
stripeRepository.createIdealPaymentSource(data.idealData).doAfterTerminate { clearPaymentInformation() }
|
||||
StripeRepository.createIdealPaymentSource(data.idealData).doAfterTerminate { clearPaymentInformation() }
|
||||
)
|
||||
|
||||
else -> error("This should never happen.")
|
||||
@@ -140,117 +146,6 @@ class StripePaymentInProgressViewModel(
|
||||
stripePaymentData = null
|
||||
}
|
||||
|
||||
private fun proceedMonthly(inAppPayment: InAppPaymentTable.InAppPayment, paymentSourceProvider: PaymentSourceProvider, nextActionHandler: StripeNextActionHandler) {
|
||||
val ensureSubscriberId: Completable = RecurringInAppPaymentRepository.ensureSubscriberId(inAppPayment.type.requireSubscriberType())
|
||||
val createAndConfirmSetupIntent: Single<StripeApi.Secure3DSAction> = paymentSourceProvider.paymentSource.flatMap {
|
||||
stripeRepository.createAndConfirmSetupIntent(inAppPayment.type, it, paymentSourceProvider.paymentSourceType as PaymentSourceType.Stripe)
|
||||
}
|
||||
|
||||
val setLevel: Completable = RecurringInAppPaymentRepository.setSubscriptionLevel(inAppPayment, paymentSourceProvider.paymentSourceType)
|
||||
|
||||
Log.d(TAG, "Starting subscription payment pipeline...", true)
|
||||
store.update { InAppPaymentProcessorStage.PAYMENT_PIPELINE }
|
||||
|
||||
val setup: Completable = ensureSubscriberId
|
||||
.andThen(RecurringInAppPaymentRepository.cancelActiveSubscriptionIfNecessary(inAppPayment.type.requireSubscriberType()))
|
||||
.andThen(createAndConfirmSetupIntent)
|
||||
.flatMap { secure3DSAction ->
|
||||
nextActionHandler.handle(
|
||||
action = secure3DSAction,
|
||||
inAppPayment = inAppPayment.copy(
|
||||
subscriberId = InAppPaymentsRepository.requireSubscriber(inAppPayment.type.requireSubscriberType()).subscriberId,
|
||||
state = InAppPaymentTable.State.WAITING_FOR_AUTHORIZATION,
|
||||
data = inAppPayment.data.copy(
|
||||
redemption = null,
|
||||
waitForAuth = InAppPaymentData.WaitingForAuthorizationState(
|
||||
stripeIntentId = secure3DSAction.stripeIntentAccessor.intentId,
|
||||
stripeClientSecret = secure3DSAction.stripeIntentAccessor.intentClientSecret
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
.flatMap { secure3DSResult -> stripeRepository.getStatusAndPaymentMethodId(secure3DSResult, secure3DSAction.paymentMethodId) }
|
||||
}
|
||||
.flatMapCompletable { stripeRepository.setDefaultPaymentMethod(it.paymentMethod!!, it.intentId, inAppPayment.type.requireSubscriberType(), paymentSourceProvider.paymentSourceType) }
|
||||
.onErrorResumeNext {
|
||||
when (it) {
|
||||
is DonationError -> Completable.error(it)
|
||||
is InAppPaymentProcessorError -> Completable.error(it.toDonationError(DonationErrorSource.MONTHLY, paymentSourceProvider.paymentSourceType))
|
||||
else -> Completable.error(DonationError.getPaymentSetupError(DonationErrorSource.MONTHLY, it, paymentSourceProvider.paymentSourceType))
|
||||
}
|
||||
}
|
||||
|
||||
disposables += setup.andThen(setLevel).subscribeBy(
|
||||
onError = { throwable ->
|
||||
Log.w(TAG, "Failure in subscription payment pipeline...", throwable, true)
|
||||
store.update { InAppPaymentProcessorStage.FAILED }
|
||||
InAppPaymentsRepository.handlePipelineError(inAppPayment.id, DonationErrorSource.MONTHLY, paymentSourceProvider.paymentSourceType, throwable)
|
||||
},
|
||||
onComplete = {
|
||||
Log.d(TAG, "Finished subscription payment pipeline...", true)
|
||||
store.update { InAppPaymentProcessorStage.COMPLETE }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun proceedOneTime(
|
||||
inAppPayment: InAppPaymentTable.InAppPayment,
|
||||
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()
|
||||
val recipientId = inAppPayment.data.recipientId?.let { RecipientId.from(it) } ?: Recipient.self().id
|
||||
val verifyUser = if (inAppPayment.type == InAppPaymentType.ONE_TIME_GIFT) {
|
||||
OneTimeInAppPaymentRepository.verifyRecipientIsAllowedToReceiveAGift(recipientId)
|
||||
} else {
|
||||
Completable.complete()
|
||||
}
|
||||
|
||||
val continuePayment: Single<StripeIntentAccessor> = verifyUser.andThen(stripeRepository.continuePayment(amount, recipientId, inAppPayment.data.level, paymentSourceProvider.paymentSourceType))
|
||||
val intentAndSource: Single<Pair<StripeIntentAccessor, StripeApi.PaymentSource>> = Single.zip(continuePayment, paymentSourceProvider.paymentSource, ::Pair)
|
||||
|
||||
disposables += intentAndSource.flatMapCompletable { (paymentIntent, paymentSource) ->
|
||||
stripeRepository.confirmPayment(paymentSource, paymentIntent, recipientId)
|
||||
.flatMap { action ->
|
||||
nextActionHandler
|
||||
.handle(
|
||||
action = action,
|
||||
inAppPayment = inAppPayment.copy(
|
||||
state = InAppPaymentTable.State.WAITING_FOR_AUTHORIZATION,
|
||||
data = inAppPayment.data.copy(
|
||||
redemption = null,
|
||||
waitForAuth = InAppPaymentData.WaitingForAuthorizationState(
|
||||
stripeIntentId = action.stripeIntentAccessor.intentId,
|
||||
stripeClientSecret = action.stripeIntentAccessor.intentClientSecret
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
.flatMap { stripeRepository.getStatusAndPaymentMethodId(it, action.paymentMethodId) }
|
||||
}
|
||||
.flatMapCompletable {
|
||||
OneTimeInAppPaymentRepository.waitForOneTimeRedemption(
|
||||
inAppPayment = inAppPayment,
|
||||
paymentIntentId = paymentIntent.intentId
|
||||
)
|
||||
}
|
||||
}.subscribeBy(
|
||||
onError = { throwable ->
|
||||
Log.w(TAG, "Failure in one-time payment pipeline...", throwable, true)
|
||||
store.update { InAppPaymentProcessorStage.FAILED }
|
||||
InAppPaymentsRepository.handlePipelineError(inAppPayment.id, DonationErrorSource.ONE_TIME, paymentSourceProvider.paymentSourceType, throwable)
|
||||
},
|
||||
onComplete = {
|
||||
Log.w(TAG, "Completed one-time payment pipeline...", true)
|
||||
store.update { InAppPaymentProcessorStage.COMPLETE }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun cancelSubscription(subscriberType: InAppPaymentSubscriberRecord.Type) {
|
||||
Log.d(TAG, "Beginning cancellation...", true)
|
||||
|
||||
@@ -273,7 +168,14 @@ class StripePaymentInProgressViewModel(
|
||||
disposables += RecurringInAppPaymentRepository
|
||||
.cancelActiveSubscriptionIfNecessary(inAppPayment.type.requireSubscriberType())
|
||||
.andThen(RecurringInAppPaymentRepository.getPaymentSourceTypeOfLatestSubscription(inAppPayment.type.requireSubscriberType()))
|
||||
.flatMapCompletable { paymentSourceType -> RecurringInAppPaymentRepository.setSubscriptionLevel(inAppPayment, paymentSourceType) }
|
||||
.flatMapCompletable { paymentSourceType ->
|
||||
val freshPayment = SignalDatabase.inAppPayments.moveToTransacting(inAppPayment.id)!!
|
||||
|
||||
Single.fromCallable {
|
||||
RecurringInAppPaymentRepository.setSubscriptionLevelSync(freshPayment)
|
||||
}.flatMapCompletable { SharedInAppPaymentPipeline.awaitRedemption(it, paymentSourceType) }
|
||||
}
|
||||
.subscribeOn(Schedulers.io())
|
||||
.subscribeBy(
|
||||
onComplete = {
|
||||
Log.w(TAG, "Completed subscription update", true)
|
||||
@@ -292,7 +194,7 @@ class StripePaymentInProgressViewModel(
|
||||
|
||||
private data class PaymentSourceProvider(
|
||||
val paymentSourceType: PaymentSourceType,
|
||||
val paymentSource: Single<StripeApi.PaymentSource>
|
||||
val paymentSource: Single<PaymentSource>
|
||||
)
|
||||
|
||||
private sealed interface StripePaymentData {
|
||||
@@ -301,12 +203,4 @@ class StripePaymentInProgressViewModel(
|
||||
class SEPADebit(val sepaDebitData: StripeApi.SEPADebitData) : StripePaymentData
|
||||
class IDEAL(val idealData: StripeApi.IDEALData) : StripePaymentData
|
||||
}
|
||||
|
||||
class Factory(
|
||||
private val stripeRepository: StripeRepository
|
||||
) : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
return modelClass.cast(StripePaymentInProgressViewModel(stripeRepository)) as T
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,7 +56,6 @@ import org.signal.core.util.getParcelableCompat
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.TemporaryScreenshotSecurity
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toFiatMoney
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentComponent
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.InAppPaymentCheckoutDelegate
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.InAppPaymentProcessorAction
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.InAppPaymentProcessorActionResult
|
||||
@@ -68,7 +67,6 @@ import org.thoughtcrime.securesms.compose.ComposeFragment
|
||||
import org.thoughtcrime.securesms.database.InAppPaymentTable
|
||||
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
|
||||
import org.thoughtcrime.securesms.util.SpanUtil
|
||||
import org.thoughtcrime.securesms.util.fragments.requireListener
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
|
||||
/**
|
||||
@@ -80,10 +78,7 @@ class BankTransferDetailsFragment : ComposeFragment(), InAppPaymentCheckoutDeleg
|
||||
private val viewModel: BankTransferDetailsViewModel by viewModels()
|
||||
|
||||
private val stripePaymentViewModel: StripePaymentInProgressViewModel by navGraphViewModels(
|
||||
R.id.checkout_flow,
|
||||
factoryProducer = {
|
||||
StripePaymentInProgressViewModel.Factory(requireListener<InAppPaymentComponent>().stripeRepository)
|
||||
}
|
||||
R.id.checkout_flow
|
||||
)
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
|
||||
@@ -57,7 +57,6 @@ import org.signal.core.util.getParcelableCompat
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.TemporaryScreenshotSecurity
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toFiatMoney
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentComponent
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.InAppPaymentCheckoutDelegate
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.InAppPaymentProcessorAction
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.InAppPaymentProcessorActionResult
|
||||
@@ -69,7 +68,6 @@ import org.thoughtcrime.securesms.compose.ComposeFragment
|
||||
import org.thoughtcrime.securesms.database.InAppPaymentTable
|
||||
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
|
||||
import org.thoughtcrime.securesms.util.SpanUtil
|
||||
import org.thoughtcrime.securesms.util.fragments.requireListener
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
import org.thoughtcrime.securesms.util.viewModel
|
||||
|
||||
@@ -84,10 +82,7 @@ class IdealTransferDetailsFragment : ComposeFragment(), InAppPaymentCheckoutDele
|
||||
}
|
||||
|
||||
private val stripePaymentViewModel: StripePaymentInProgressViewModel by navGraphViewModels(
|
||||
R.id.checkout_flow,
|
||||
factoryProducer = {
|
||||
StripePaymentInProgressViewModel.Factory(requireListener<InAppPaymentComponent>().stripeRepository)
|
||||
}
|
||||
R.id.checkout_flow
|
||||
)
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
|
||||
Reference in New Issue
Block a user