Migrate paypal and stripe interactions to durable background jobs.

This commit is contained in:
Alex Hart
2025-03-14 14:02:19 -03:00
committed by Cody Henthorne
parent ad00e7c5ab
commit 7cc4677120
54 changed files with 2221 additions and 1082 deletions

View File

@@ -0,0 +1,288 @@
package org.thoughtcrime.securesms.jobs
import android.net.Uri
import assertk.assertThat
import assertk.assertions.isEqualTo
import assertk.assertions.isNotNull
import org.junit.Rule
import org.junit.Test
import org.signal.donations.InAppPaymentType
import org.signal.donations.StripeApi
import org.signal.donations.StripeIntentAccessor
import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
import org.thoughtcrime.securesms.jobmanager.Job
import org.thoughtcrime.securesms.jobs.protos.InAppPaymentSetupJobData
import org.thoughtcrime.securesms.jobs.protos.InAppPaymentSourceData
import org.thoughtcrime.securesms.testing.SignalDatabaseRule
/**
* Core test logic for [InAppPaymentSetupJob]
*/
class InAppPaymentSetupJobTest {
@get:Rule
val signalDatabaseRule = SignalDatabaseRule()
@Test
fun givenAnInAppPaymentThatDoesntExist_whenIRun_thenIExpectFailure() {
val testData = InAppPaymentSetupJobData(
inAppPaymentId = 1L,
inAppPaymentSource = InAppPaymentSourceData.Builder()
.code(InAppPaymentSourceData.Code.CREDIT_CARD)
.tokenData(InAppPaymentSourceData.TokenData())
.build()
)
val testJob = TestInAppPaymentSetupJob(testData)
val result = testJob.run()
assertThat(result.isFailure).isEqualTo(true)
}
@Test
fun givenAnInAppPaymentInEndState_whenIRun_thenIExpectFailure() {
val id = insertInAppPayment(state = InAppPaymentTable.State.END)
val testData = InAppPaymentSetupJobData(
inAppPaymentId = id.rowId,
inAppPaymentSource = InAppPaymentSourceData.Builder()
.code(InAppPaymentSourceData.Code.CREDIT_CARD)
.tokenData(InAppPaymentSourceData.TokenData())
.build()
)
val testJob = TestInAppPaymentSetupJob(testData)
val result = testJob.run()
assertThat(result.isFailure).isEqualTo(true)
}
@Test
fun givenAnInAppPaymentInRequiredActionCompletedWithoutCompletedState_whenIRun_thenIExpectFailure() {
val id = insertInAppPayment(
state = InAppPaymentTable.State.REQUIRED_ACTION_COMPLETED
)
val testData = InAppPaymentSetupJobData(
inAppPaymentId = id.rowId,
inAppPaymentSource = InAppPaymentSourceData.Builder()
.code(InAppPaymentSourceData.Code.CREDIT_CARD)
.tokenData(InAppPaymentSourceData.TokenData())
.build()
)
val testJob = TestInAppPaymentSetupJob(testData)
val result = testJob.run()
assertThat(result.isFailure).isEqualTo(true)
assertThat(SignalDatabase.inAppPayments.getById(id)?.state).isEqualTo(InAppPaymentTable.State.END)
}
@Test
fun givenAStripeInAppPaymentInRequiredActionCompletedWithCompletedState_whenIRun_thenIExpectSuccess() {
val id = insertInAppPayment(
state = InAppPaymentTable.State.REQUIRED_ACTION_COMPLETED,
data = InAppPaymentData.Builder()
.paymentMethodType(InAppPaymentData.PaymentMethodType.CARD)
.stripeActionComplete(InAppPaymentData.StripeActionCompleteState())
.build()
)
val testData = InAppPaymentSetupJobData(
inAppPaymentId = id.rowId,
inAppPaymentSource = InAppPaymentSourceData.Builder()
.code(InAppPaymentSourceData.Code.CREDIT_CARD)
.build()
)
val testJob = TestInAppPaymentSetupJob(testData)
val result = testJob.run()
assertThat(result.isSuccess).isEqualTo(true)
}
@Test
fun givenAPayPalInAppPaymentInRequiredActionCompletedWithCompletedState_whenIRun_thenIExpectSuccess() {
val id = insertInAppPayment(
state = InAppPaymentTable.State.REQUIRED_ACTION_COMPLETED,
data = InAppPaymentData.Builder()
.paymentMethodType(InAppPaymentData.PaymentMethodType.PAYPAL)
.payPalActionComplete(InAppPaymentData.PayPalActionCompleteState())
.build()
)
val testData = InAppPaymentSetupJobData(
inAppPaymentId = id.rowId,
inAppPaymentSource = InAppPaymentSourceData.Builder()
.code(InAppPaymentSourceData.Code.PAY_PAL)
.build()
)
val testJob = TestInAppPaymentSetupJob(testData)
val result = testJob.run()
assertThat(result.isSuccess).isEqualTo(true)
}
@Test
fun givenRequiredActionComplete_whenIRun_thenIBypassPerformPreUserAction() {
val id = insertInAppPayment(
state = InAppPaymentTable.State.REQUIRED_ACTION_COMPLETED,
data = InAppPaymentData.Builder()
.paymentMethodType(InAppPaymentData.PaymentMethodType.PAYPAL)
.payPalActionComplete(InAppPaymentData.PayPalActionCompleteState())
.build()
)
val testData = InAppPaymentSetupJobData(
inAppPaymentId = id.rowId,
inAppPaymentSource = InAppPaymentSourceData.Builder()
.code(InAppPaymentSourceData.Code.PAY_PAL)
.build()
)
val testJob = TestInAppPaymentSetupJob(
data = testData,
requiredUserAction = { error("Unexpected call to requiredUserAction") },
postUserActionResult = {
assertThat(SignalDatabase.inAppPayments.getById(id)?.state).isEqualTo(InAppPaymentTable.State.TRANSACTING)
Job.Result.success()
}
)
val result = testJob.run()
assertThat(result.isSuccess).isEqualTo(true)
}
@Test
fun givenPayPalUserActionRequired_whenIRun_thenIDoNotPerformPostUserActionResult() {
val id = insertInAppPayment(
state = InAppPaymentTable.State.CREATED,
data = InAppPaymentData.Builder()
.paymentMethodType(InAppPaymentData.PaymentMethodType.PAYPAL)
.build()
)
val testData = InAppPaymentSetupJobData(
inAppPaymentId = id.rowId,
inAppPaymentSource = InAppPaymentSourceData.Builder()
.code(InAppPaymentSourceData.Code.PAY_PAL)
.build()
)
val testJob = TestInAppPaymentSetupJob(
data = testData,
requiredUserAction = {
InAppPaymentSetupJob.RequiredUserAction.PayPalActionRequired("", "")
},
postUserActionResult = {
error("Unexpected call to postUserActionResult")
}
)
val result = testJob.run()
assertThat(result.isFailure).isEqualTo(true)
val fresh = SignalDatabase.inAppPayments.getById(id)!!
assertThat(fresh.state).isEqualTo(InAppPaymentTable.State.REQUIRES_ACTION)
assertThat(fresh.data.payPalRequiresAction).isNotNull()
}
@Test
fun givenStripeUserActionRequired_whenIRun_thenIDoNotPerformPostUserActionResult() {
val id = insertInAppPayment(
state = InAppPaymentTable.State.CREATED,
data = InAppPaymentData.Builder()
.paymentMethodType(InAppPaymentData.PaymentMethodType.CARD)
.build()
)
val testData = InAppPaymentSetupJobData(
inAppPaymentId = id.rowId,
inAppPaymentSource = InAppPaymentSourceData.Builder()
.code(InAppPaymentSourceData.Code.CREDIT_CARD)
.build()
)
val testJob = TestInAppPaymentSetupJob(
data = testData,
requiredUserAction = {
InAppPaymentSetupJob.RequiredUserAction.StripeActionRequired(
StripeApi.Secure3DSAction.ConfirmRequired(
uri = Uri.EMPTY,
returnUri = Uri.EMPTY,
stripeIntentAccessor = StripeIntentAccessor(
objectType = StripeIntentAccessor.ObjectType.PAYMENT_INTENT,
intentId = "",
intentClientSecret = ""
),
paymentMethodId = null
)
)
},
postUserActionResult = {
error("Unexpected call to postUserActionResult")
}
)
val result = testJob.run()
assertThat(result.isFailure).isEqualTo(true)
val fresh = SignalDatabase.inAppPayments.getById(id)!!
assertThat(fresh.state).isEqualTo(InAppPaymentTable.State.REQUIRES_ACTION)
assertThat(fresh.data.stripeRequiresAction).isNotNull()
}
private fun insertInAppPayment(
state: InAppPaymentTable.State = InAppPaymentTable.State.CREATED,
data: InAppPaymentData = InAppPaymentData()
): InAppPaymentTable.InAppPaymentId {
return SignalDatabase.inAppPayments.insert(
type = InAppPaymentType.ONE_TIME_DONATION,
state = state,
subscriberId = null,
endOfPeriod = null,
inAppPaymentData = data
)
}
private class TestInAppPaymentSetupJob(
data: InAppPaymentSetupJobData,
val requiredUserAction: () -> RequiredUserAction = {
RequiredUserAction.StripeActionNotRequired(
StripeApi.Secure3DSAction.NotNeeded(
paymentMethodId = "",
stripeIntentAccessor = StripeIntentAccessor(
objectType = StripeIntentAccessor.ObjectType.PAYMENT_INTENT,
intentId = "",
intentClientSecret = ""
)
)
)
},
val postUserActionResult: () -> Result = { Result.success() }
) : InAppPaymentSetupJob(data, Parameters.Builder().build()) {
override fun performPreUserAction(inAppPayment: InAppPaymentTable.InAppPayment): RequiredUserAction {
return requiredUserAction()
}
override fun performPostUserAction(inAppPayment: InAppPaymentTable.InAppPayment): Result {
return postUserActionResult()
}
override fun getFactoryKey(): String = error("Not used.")
override fun run(): Result {
return performTransaction()
}
}
}

View File

@@ -262,7 +262,7 @@ class MessageBackupsFlowViewModel(
Log.d(TAG, "Setting purchase token data on InAppPayment and InAppPaymentSubscriber.")
val iapSubscriptionId = IAPSubscriptionId.GooglePlayBillingPurchaseToken(result.purchaseToken)
RecurringInAppPaymentRepository.ensureSubscriberId(InAppPaymentSubscriberRecord.Type.BACKUP, iapSubscriptionId = iapSubscriptionId, isRotation = true).blockingAwait()
RecurringInAppPaymentRepository.ensureSubscriberIdSync(InAppPaymentSubscriberRecord.Type.BACKUP, iapSubscriptionId = iapSubscriptionId, isRotation = true)
val inAppPayment = SignalDatabase.inAppPayments.getById(inAppPaymentId)!!
SignalDatabase.inAppPayments.update(

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -12,8 +12,8 @@ import org.signal.core.util.logging.Log
import org.signal.core.util.logging.Log.tag
import org.thoughtcrime.securesms.PassphraseRequiredActivity
import org.thoughtcrime.securesms.R
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.components.voice.VoiceNoteMediaController
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaControllerOwner
import org.thoughtcrime.securesms.conversation.ConversationIntents
@@ -25,7 +25,7 @@ import java.util.concurrent.TimeUnit
/**
* Wrapper activity for ConversationFragment.
*/
open class ConversationActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner, InAppPaymentComponent {
open class ConversationActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner, GooglePayComponent {
companion object {
private val TAG = tag(ConversationActivity::class.java)
@@ -38,8 +38,8 @@ open class ConversationActivity : PassphraseRequiredActivity(), VoiceNoteMediaCo
override val voiceNoteMediaController = VoiceNoteMediaController(this, true)
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 motionEventRelay: MotionEventRelay by viewModels()
private val shareDataTimestampViewModel: ShareDataTimestampViewModel by viewModels()
@@ -100,7 +100,7 @@ open class ConversationActivity : PassphraseRequiredActivity(), VoiceNoteMediaCo
@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))
}
override fun onConfigurationChanged(newConfiguration: Configuration) {

View File

@@ -9,6 +9,7 @@ import android.content.ContentValues
import android.content.Context
import android.database.Cursor
import android.os.Parcelable
import androidx.annotation.CheckResult
import androidx.core.content.contentValuesOf
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
@@ -153,6 +154,21 @@ class InAppPaymentTable(context: Context, databaseHelper: SignalDatabase) : Data
.let { InAppPaymentId(it) }
}
@CheckResult
fun moveToTransacting(inAppPaymentId: InAppPaymentId): InAppPayment? {
writableDatabase.update(TABLE_NAME)
.values(STATE to State.serialize(State.TRANSACTING))
.where(ID_WHERE, inAppPaymentId)
.run()
val fresh = getById(inAppPaymentId)
if (fresh != null) {
AppDependencies.databaseObserver.notifyInAppPaymentsObservers(fresh)
}
return fresh
}
fun update(
inAppPayment: InAppPayment
) {
@@ -168,6 +184,24 @@ class InAppPaymentTable(context: Context, databaseHelper: SignalDatabase) : Data
AppDependencies.databaseObserver.notifyInAppPaymentsObservers(inAppPayment)
}
/**
* Returns true if the user has submitted a pre-pending recurring donation.
* In this state, the user would have had to cancel their subscription or be in the process of trying
* to update, so we should not try to run the keep-alive job.
*/
fun hasPrePendingRecurringTransaction(type: InAppPaymentType): Boolean {
return readableDatabase
.exists(TABLE_NAME)
.where(
"($STATE = ? OR $STATE = ? OR $STATE = ?) AND $TYPE = ?",
State.serialize(State.REQUIRES_ACTION),
State.serialize(State.WAITING_FOR_AUTHORIZATION),
State.serialize(State.TRANSACTING),
InAppPaymentType.serialize(type)
)
.run()
}
fun hasWaitingForAuth(): Boolean {
return readableDatabase
.exists(TABLE_NAME)
@@ -400,8 +434,11 @@ class InAppPaymentTable(context: Context, databaseHelper: SignalDatabase) : Data
*
* ```mermaid
* flowchart TD
* CREATED -- Auth required --> WAITING_FOR_AUTHORIZATION
* CREATED -- Auth not required --> PENDING
* CREATED --> TRANSACTING
* TRANSACTING -- Auth required --> REQUIRES_ACTION
* TRANSACTING -- Auth not required --> PENDING
* REQUIRES_ACTION -- User completes auth in app --> TRANSACTING
* REQUIRES_ACTION -- User launches external application --> WAITING_FOR_AUTHORIZATION
* WAITING_FOR_AUTHORIZATION -- User completes auth --> PENDING
* WAITING_FOR_AUTHORIZATION -- User does not complete auth --> END
* PENDING --> END
@@ -418,20 +455,35 @@ class InAppPaymentTable(context: Context, databaseHelper: SignalDatabase) : Data
CREATED(0),
/**
* This payment is awaiting the user to return from an external authorization2
* This payment is awaiting the user to return from an external authorization
* such as a 3DS flow or IDEAL confirmation.
*/
WAITING_FOR_AUTHORIZATION(1),
/**
* This payment is authorized and is waiting to be processed.
* This payment is transacted and is performing receipt redemption.
*/
PENDING(2),
/**
* This payment pipeline has been completed. Check the data to see the state.
*/
END(3);
END(3),
/**
* Requires user action via 3DS or iDEAL
*/
REQUIRES_ACTION(4),
/**
* User has completed the required action and the transaction should be finished.
*/
REQUIRED_ACTION_COMPLETED(5),
/**
* Performing monetary transaction
*/
TRANSACTING(6);
companion object : Serializer<State, Int> {
override fun serialize(data: State): Int = data.code

View File

@@ -142,11 +142,11 @@ class BackupSubscriptionCheckJob private constructor(parameters: Parameters) : C
}
private fun enqueueRedemptionForNewToken(localDevicePurchaseToken: String, localProductPrice: FiatMoney) {
RecurringInAppPaymentRepository.ensureSubscriberId(
RecurringInAppPaymentRepository.ensureSubscriberIdSync(
subscriberType = InAppPaymentSubscriberRecord.Type.BACKUP,
isRotation = true,
iapSubscriptionId = IAPSubscriptionId.GooglePlayBillingPurchaseToken(localDevicePurchaseToken)
).blockingAwait()
)
SignalDatabase.inAppPayments.clearCreated()

View File

@@ -6,7 +6,6 @@
package org.thoughtcrime.securesms.jobs
import androidx.annotation.VisibleForTesting
import io.reactivex.rxjava3.core.Single
import org.signal.core.util.logging.Log
import org.signal.core.util.money.FiatMoney
import org.signal.donations.InAppPaymentType
@@ -161,13 +160,12 @@ class InAppPaymentAuthCheckJob private constructor(parameters: Parameters) : Bas
SignalDatabase.inAppPayments.update(
inAppPayment = inAppPayment.copy(
state = InAppPaymentTable.State.PENDING,
data = inAppPayment.data.copy(
waitForAuth = null,
data = inAppPayment.data.newBuilder().redemption(
redemption = InAppPaymentData.RedemptionState(
stage = InAppPaymentData.RedemptionState.Stage.INIT,
paymentIntentId = inAppPayment.data.waitForAuth.stripeIntentId
)
)
).build()
)
)
@@ -272,12 +270,11 @@ class InAppPaymentAuthCheckJob private constructor(parameters: Parameters) : Bas
SignalDatabase.inAppPayments.update(
inAppPayment = inAppPayment.copy(
state = InAppPaymentTable.State.PENDING,
data = inAppPayment.data.copy(
waitForAuth = null,
data = inAppPayment.data.newBuilder().redemption(
redemption = InAppPaymentData.RedemptionState(
stage = InAppPaymentData.RedemptionState.Stage.INIT
)
)
).build()
)
)
@@ -347,7 +344,11 @@ class InAppPaymentAuthCheckJob private constructor(parameters: Parameters) : Bas
data_ = errorData
),
waitForAuth = InAppPaymentData.WaitingForAuthorizationState("", ""),
redemption = null
redemption = null,
stripeActionComplete = null,
payPalActionComplete = null,
payPalRequiresAction = null,
stripeRequiresAction = null
)
)
)
@@ -367,11 +368,11 @@ class InAppPaymentAuthCheckJob private constructor(parameters: Parameters) : Bas
}
override fun onShouldRetry(e: Exception): Boolean = e is InAppPaymentRetryException
override fun fetchPaymentIntent(price: FiatMoney, level: Long, sourceType: PaymentSourceType.Stripe): Single<StripeIntentAccessor> {
override fun fetchPaymentIntent(price: FiatMoney, level: Long, sourceType: PaymentSourceType.Stripe): StripeIntentAccessor {
error("Not needed, this job should not be creating intents.")
}
override fun fetchSetupIntent(inAppPaymentType: InAppPaymentType, sourceType: PaymentSourceType.Stripe): Single<StripeIntentAccessor> {
override fun fetchSetupIntent(inAppPaymentType: InAppPaymentType, sourceType: PaymentSourceType.Stripe): StripeIntentAccessor {
error("Not needed, this job should not be creating intents.")
}

View File

@@ -100,6 +100,11 @@ class InAppPaymentKeepAliveJob private constructor(
return
}
if (SignalDatabase.inAppPayments.hasPrePendingRecurringTransaction(type.inAppPaymentType)) {
info(type, "We are currently processing a transaction for this type. Skipping.")
return
}
val subscriber: InAppPaymentSubscriberRecord? = InAppPaymentsRepository.getSubscriber(type)
if (subscriber == null) {
info(type, "Subscriber not present. Skipping.")
@@ -146,12 +151,12 @@ class InAppPaymentKeepAliveJob private constructor(
InAppPaymentData.RedemptionState.Stage.INIT -> {
info(type, "Transitioning payment from INIT to CONVERSION_STARTED and generating a request credential")
val payment = activeInAppPayment.copy(
data = activeInAppPayment.data.copy(
data = activeInAppPayment.data.newBuilder().redemption(
redemption = activeInAppPayment.data.redemption.copy(
stage = InAppPaymentData.RedemptionState.Stage.CONVERSION_STARTED,
receiptCredentialRequestContext = InAppPaymentsRepository.generateRequestCredential().serialize().toByteString()
)
)
).build()
)
SignalDatabase.inAppPayments.update(payment)
@@ -212,7 +217,7 @@ class InAppPaymentKeepAliveJob private constructor(
val oldInAppPayment = SignalDatabase.inAppPayments.getByLatestEndOfPeriod(type.inAppPaymentType)
val oldEndOfPeriod = oldInAppPayment?.endOfPeriod ?: InAppPaymentsRepository.getFallbackLastEndOfPeriod(type)
if (oldEndOfPeriod > endOfCurrentPeriod) {
warn(type, "Active subscription returned an old end-of-period. Exiting. (old: $oldEndOfPeriod, new: $endOfCurrentPeriod)")
warn(type, "Active subscription returned an old end-of-period. Exiting. (old: ${oldEndOfPeriod.inWholeSeconds}, new: ${endOfCurrentPeriod.inWholeSeconds})")
return null
}
@@ -235,7 +240,7 @@ class InAppPaymentKeepAliveJob private constructor(
oldInAppPayment.data.badge
}
info(type, "End of period has changed. Requesting receipt refresh. (old: $oldEndOfPeriod, new: $endOfCurrentPeriod)")
info(type, "End of period has changed. Requesting receipt refresh. (old: ${oldEndOfPeriod.inWholeSeconds}, new: ${endOfCurrentPeriod.inWholeSeconds})")
if (type == InAppPaymentSubscriberRecord.Type.DONATION) {
SignalStore.inAppPayments.setLastEndOfPeriod(endOfCurrentPeriod.inWholeSeconds)
}

View File

@@ -152,6 +152,11 @@ class InAppPaymentOneTimeContextJob private constructor(
SignalDatabase.inAppPayments.update(
inAppPayment.copy(
data = inAppPayment.data.copy(
waitForAuth = null,
stripeActionComplete = null,
payPalActionComplete = null,
payPalRequiresAction = null,
stripeRequiresAction = null,
redemption = InAppPaymentData.RedemptionState(
stage = InAppPaymentData.RedemptionState.Stage.REDEMPTION_STARTED,
receiptCredentialPresentation = receiptCredentialPresentation.serialize().toByteString()
@@ -196,7 +201,7 @@ class InAppPaymentOneTimeContextJob private constructor(
if (inAppPayment.state != InAppPaymentTable.State.PENDING) {
warning("Invalid state: ${inAppPayment.state} but expected PENDING")
if (inAppPayment.state == InAppPaymentTable.State.CREATED) {
if (inAppPayment.state == InAppPaymentTable.State.TRANSACTING) {
warning("onAdded failed to update payment state to PENDING. Updating now as long as the payment is valid otherwise.")
} else {
throw IOException("InAppPayment is in an invalid state: ${inAppPayment.state}")
@@ -225,6 +230,11 @@ class InAppPaymentOneTimeContextJob private constructor(
val updatedPayment = inAppPayment.copy(
state = InAppPaymentTable.State.PENDING,
data = inAppPayment.data.copy(
waitForAuth = null,
stripeActionComplete = null,
payPalActionComplete = null,
payPalRequiresAction = null,
stripeRequiresAction = null,
redemption = inAppPayment.data.redemption.copy(
stage = InAppPaymentData.RedemptionState.Stage.CONVERSION_STARTED,
receiptCredentialRequestContext = requestContext.serialize().toByteString()

View File

@@ -0,0 +1,100 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.jobs
import org.signal.donations.InAppPaymentType
import org.signal.donations.PaymentSource
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toFiatMoney
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.donate.paypal.PayPalConfirmationResult
import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobmanager.Job
import org.thoughtcrime.securesms.jobs.protos.InAppPaymentSetupJobData
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.whispersystems.signalservice.api.subscriptions.PayPalCreatePaymentIntentResponse
class InAppPaymentPayPalOneTimeSetupJob private constructor(data: InAppPaymentSetupJobData, parameters: Parameters) : InAppPaymentSetupJob(data, parameters) {
companion object {
const val KEY = "InAppPaymentPayPalOneTimeSetupJob"
/**
* Creates a new job for performing stripe recurring payment setup. Note that
* we do not require network for this job, as if the network is not present, we
* should treat that as an immediate error and fail the job.
*/
fun create(
inAppPayment: InAppPaymentTable.InAppPayment,
paymentSource: PaymentSource
): InAppPaymentPayPalOneTimeSetupJob {
return InAppPaymentPayPalOneTimeSetupJob(
getJobData(inAppPayment, paymentSource),
getParameters(inAppPayment)
)
}
}
private val payPalRepository = PayPalRepository(AppDependencies.donationsService)
override fun performPreUserAction(inAppPayment: InAppPaymentTable.InAppPayment): RequiredUserAction {
info("Beginning one-time payment pipeline.")
val amount = inAppPayment.data.amount!!.toFiatMoney()
val recipientId = inAppPayment.data.recipientId?.let { RecipientId.from(it) } ?: Recipient.self().id
if (inAppPayment.type == InAppPaymentType.ONE_TIME_GIFT) {
info("Verifying recipient $recipientId can receive gift.")
OneTimeInAppPaymentRepository.verifyRecipientIsAllowedToReceiveAGiftSync(recipientId)
}
info("Creating one-time payment intent...")
val response: PayPalCreatePaymentIntentResponse = payPalRepository.createOneTimePaymentIntent(
amount = amount,
badgeRecipient = recipientId,
badgeLevel = inAppPayment.data.level
)
return RequiredUserAction.PayPalActionRequired(
approvalUrl = response.approvalUrl,
tokenOrPaymentId = response.paymentId
)
}
override fun performPostUserAction(inAppPayment: InAppPaymentTable.InAppPayment): Result {
val result = PayPalConfirmationResult(
payerId = inAppPayment.data.payPalActionComplete!!.payerId,
paymentId = inAppPayment.data.payPalActionComplete.paymentId.takeIf { it.isNotBlank() },
paymentToken = inAppPayment.data.payPalActionComplete.paymentToken
)
info("Confirming payment intent...")
val response = payPalRepository.confirmOneTimePaymentIntent(
amount = inAppPayment.data.amount!!.toFiatMoney(),
badgeLevel = inAppPayment.data.level,
paypalConfirmationResult = result
)
info("Confirmed payment intent. Submitting redemption job chain.")
OneTimeInAppPaymentRepository.submitRedemptionJobChain(inAppPayment, response.paymentId)
return Result.success()
}
override fun getFactoryKey(): String = KEY
override fun run(): Result {
return performTransaction()
}
class Factory : Job.Factory<InAppPaymentPayPalOneTimeSetupJob> {
override fun create(parameters: Parameters, serializedData: ByteArray?): InAppPaymentPayPalOneTimeSetupJob {
val data = serializedData?.let { InAppPaymentSetupJobData.ADAPTER.decode(it) } ?: error("Missing job data!")
return InAppPaymentPayPalOneTimeSetupJob(data, parameters)
}
}
}

View File

@@ -0,0 +1,81 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.jobs
import android.annotation.SuppressLint
import org.signal.donations.PaymentSource
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.PayPalRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.RecurringInAppPaymentRepository
import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobmanager.Job
import org.thoughtcrime.securesms.jobs.protos.InAppPaymentSetupJobData
class InAppPaymentPayPalRecurringSetupJob private constructor(data: InAppPaymentSetupJobData, parameters: Parameters) : InAppPaymentSetupJob(data, parameters) {
companion object {
const val KEY = "InAppPaymentPayPalRecurringSetupJob"
/**
* Creates a new job for performing stripe recurring payment setup. Note that
* we do not require network for this job, as if the network is not present, we
* should treat that as an immediate error and fail the job.
*/
fun create(
inAppPayment: InAppPaymentTable.InAppPayment,
paymentSource: PaymentSource
): InAppPaymentPayPalRecurringSetupJob {
return InAppPaymentPayPalRecurringSetupJob(
getJobData(inAppPayment, paymentSource),
getParameters(inAppPayment)
)
}
}
private val payPalRepository = PayPalRepository(AppDependencies.donationsService)
override fun performPreUserAction(inAppPayment: InAppPaymentTable.InAppPayment): RequiredUserAction {
info("Ensuring the subscriber id is set on the server.")
RecurringInAppPaymentRepository.ensureSubscriberIdSync(inAppPayment.type.requireSubscriberType())
info("Canceling active subscription (if necessary).")
RecurringInAppPaymentRepository.cancelActiveSubscriptionIfNecessarySync(inAppPayment.type.requireSubscriberType())
info("Creating payment method")
val response = payPalRepository.createPaymentMethod(inAppPayment.type.requireSubscriberType())
return RequiredUserAction.PayPalActionRequired(
approvalUrl = response.approvalUrl,
tokenOrPaymentId = response.token
)
}
@SuppressLint("CheckResult")
override fun performPostUserAction(inAppPayment: InAppPaymentTable.InAppPayment): Result {
val paymentMethodId = inAppPayment.data.payPalActionComplete!!.paymentId
info("Setting default payment method.")
payPalRepository.setDefaultPaymentMethod(inAppPayment.type.requireSubscriberType(), paymentMethodId)
info("Setting subscription level.")
RecurringInAppPaymentRepository.setSubscriptionLevelSync(inAppPayment)
return Result.success()
}
override fun getFactoryKey(): String = KEY
override fun run(): Result {
return synchronized(InAppPaymentsRepository.resolveLock(InAppPaymentTable.InAppPaymentId(data.inAppPaymentId))) {
performTransaction()
}
}
class Factory : Job.Factory<InAppPaymentPayPalRecurringSetupJob> {
override fun create(parameters: Parameters, serializedData: ByteArray?): InAppPaymentPayPalRecurringSetupJob {
val data = serializedData?.let { InAppPaymentSetupJobData.ADAPTER.decode(it) } ?: error("Missing job data!")
return InAppPaymentPayPalRecurringSetupJob(data, parameters)
}
}
}

View File

@@ -186,7 +186,7 @@ class InAppPaymentPurchaseTokenJob private constructor(
try {
info("Generating a new subscriber id.")
RecurringInAppPaymentRepository.ensureSubscriberId(InAppPaymentSubscriberRecord.Type.BACKUP, true).blockingAwait()
RecurringInAppPaymentRepository.ensureSubscriberIdSync(InAppPaymentSubscriberRecord.Type.BACKUP, true)
info("Writing the new subscriber id to the InAppPayment.")
val latest = SignalDatabase.inAppPayments.getById(inAppPaymentId)!!

View File

@@ -85,7 +85,7 @@ class InAppPaymentRecurringContextJob private constructor(
override fun onAdded() {
val inAppPayment = SignalDatabase.inAppPayments.getById(inAppPaymentId)
info("Added context job for payment with state ${inAppPayment?.state}")
if (inAppPayment?.state == InAppPaymentTable.State.CREATED) {
if (inAppPayment?.state == InAppPaymentTable.State.TRANSACTING) {
SignalDatabase.inAppPayments.update(
inAppPayment.copy(
state = InAppPaymentTable.State.PENDING
@@ -155,21 +155,21 @@ class InAppPaymentRecurringContextJob private constructor(
info("Subscription is valid, proceeding with request for ReceiptCredentialResponse")
val updatedInAppPayment: InAppPaymentTable.InAppPayment = if (inAppPayment.data.redemption!!.stage != InAppPaymentData.RedemptionState.Stage.CONVERSION_STARTED || inAppPayment.endOfPeriod.inWholeMilliseconds <= 0) {
info("Updating payment state with endOfCurrentPeriod and proper stage.")
info("Updating payment state with endOfCurrentPeriod (${subscription.endOfCurrentPeriod}) and proper stage.")
if (inAppPayment.type.requireSubscriberType() == InAppPaymentSubscriberRecord.Type.DONATION) {
info("Recording last end of period.")
info("Recording last end of period (${subscription.endOfCurrentPeriod}).")
SignalStore.inAppPayments.setLastEndOfPeriod(subscription.endOfCurrentPeriod)
}
SignalDatabase.inAppPayments.update(
inAppPayment.copy(
endOfPeriod = subscription.endOfCurrentPeriod.seconds,
data = inAppPayment.data.copy(
data = inAppPayment.data.newBuilder().redemption(
redemption = inAppPayment.data.redemption.copy(
stage = InAppPaymentData.RedemptionState.Stage.CONVERSION_STARTED
)
)
).build()
)
)
@@ -180,7 +180,7 @@ class InAppPaymentRecurringContextJob private constructor(
if (hasEntitlementAlready(updatedInAppPayment, subscription.endOfCurrentPeriod)) {
info("Already have entitlement for this badge. Marking complete.")
markInAppPaymentCompleted(updatedInAppPayment)
markInAppPaymentCompleted(updatedInAppPayment, subscription)
} else {
submitAndValidateCredentials(updatedInAppPayment, subscription, requestContext)
}
@@ -227,13 +227,15 @@ class InAppPaymentRecurringContextJob private constructor(
return (code >= 500 || code == 429) && code != 508
}
private fun markInAppPaymentCompleted(inAppPayment: InAppPaymentTable.InAppPayment) {
private fun markInAppPaymentCompleted(inAppPayment: InAppPaymentTable.InAppPayment, subscription: Subscription) {
SignalDatabase.donationReceipts.addReceipt(InAppPaymentReceiptRecord.createForSubscription(subscription))
SignalDatabase.inAppPayments.update(
inAppPayment = inAppPayment.copy(
state = InAppPaymentTable.State.END,
data = inAppPayment.data.copy(
data = inAppPayment.data.newBuilder().redemption(
redemption = InAppPaymentData.RedemptionState(stage = InAppPaymentData.RedemptionState.Stage.REDEEMED)
)
).build()
)
)
}
@@ -248,7 +250,7 @@ class InAppPaymentRecurringContextJob private constructor(
if (inAppPayment.state != InAppPaymentTable.State.PENDING) {
warning("Unexpected state. Got ${inAppPayment.state} but expected PENDING")
if (inAppPayment.state == InAppPaymentTable.State.CREATED) {
if (inAppPayment.state == InAppPaymentTable.State.TRANSACTING) {
warning("onAdded failed to update payment state to PENDING. Updating now as long as the payment is valid otherwise.")
} else {
throw IOException("InAppPayment is in an invalid state: ${inAppPayment.state}")
@@ -279,12 +281,12 @@ class InAppPaymentRecurringContextJob private constructor(
val requestContext = InAppPaymentsRepository.generateRequestCredential()
val updatedPayment = inAppPayment.copy(
state = InAppPaymentTable.State.PENDING,
data = inAppPayment.data.copy(
data = inAppPayment.data.newBuilder().redemption(
redemption = inAppPayment.data.redemption.copy(
stage = InAppPaymentData.RedemptionState.Stage.CONVERSION_STARTED,
receiptCredentialRequestContext = requestContext.serialize().toByteString()
)
)
).build()
)
SignalDatabase.inAppPayments.update(updatedPayment)
@@ -550,12 +552,12 @@ class InAppPaymentRecurringContextJob private constructor(
SignalDatabase.donationReceipts.addReceipt(InAppPaymentReceiptRecord.createForSubscription(subscription))
SignalDatabase.inAppPayments.update(
inAppPayment = inAppPayment.copy(
data = inAppPayment.data.copy(
data = inAppPayment.data.newBuilder().redemption(
redemption = inAppPayment.data.redemption!!.copy(
stage = InAppPaymentData.RedemptionState.Stage.REDEMPTION_STARTED,
receiptCredentialPresentation = receiptCredentialPresentation.serialize().toByteString()
)
)
).build()
)
)
}

View File

@@ -255,6 +255,11 @@ class InAppPaymentRedemptionJob private constructor(
notified = !jobData.isFromAuthCheck,
state = InAppPaymentTable.State.END,
data = inAppPayment.data.copy(
waitForAuth = null,
stripeActionComplete = null,
payPalActionComplete = null,
payPalRequiresAction = null,
stripeRequiresAction = null,
redemption = inAppPayment.data.redemption.copy(
stage = InAppPaymentData.RedemptionState.Stage.REDEEMED
)

View File

@@ -0,0 +1,236 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.jobs
import org.signal.core.util.logging.Log
import org.signal.donations.PaymentSource
import org.signal.donations.PaymentSourceType
import org.signal.donations.StripeApi
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository
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
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.toDonationError
import org.thoughtcrime.securesms.components.settings.app.subscription.toProto
import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
import org.thoughtcrime.securesms.jobmanager.Job
import org.thoughtcrime.securesms.jobs.protos.InAppPaymentSetupJobData
import org.whispersystems.signalservice.internal.push.exceptions.InAppPaymentProcessorError
/**
* Handles common pipeline logic between one-time and recurring transactions.
*/
abstract class InAppPaymentSetupJob(
val data: InAppPaymentSetupJobData,
parameters: Parameters
) : Job(parameters) {
sealed interface RequiredUserAction {
data class StripeActionNotRequired(val action: StripeApi.Secure3DSAction.NotNeeded) : RequiredUserAction
data class StripeActionRequired(val action: StripeApi.Secure3DSAction.ConfirmRequired) : RequiredUserAction
data class PayPalActionRequired(val approvalUrl: String, val tokenOrPaymentId: String) : RequiredUserAction
}
companion object {
private val TAG = Log.tag(InAppPaymentSetupJob::class)
@JvmStatic
protected fun getParameters(inAppPayment: InAppPaymentTable.InAppPayment): Parameters {
return Parameters.Builder()
.setQueue(InAppPaymentsRepository.resolveJobQueueKey(inAppPayment))
.setLifespan(InAppPaymentsRepository.resolveContextJobLifespan(inAppPayment).inWholeMilliseconds)
.setMaxAttempts(Parameters.UNLIMITED)
.build()
}
@JvmStatic
protected fun getJobData(
inAppPayment: InAppPaymentTable.InAppPayment,
paymentSource: PaymentSource
): InAppPaymentSetupJobData {
return InAppPaymentSetupJobData(
inAppPaymentId = inAppPayment.id.rowId,
inAppPaymentSource = paymentSource.toProto()
)
}
}
override fun serialize(): ByteArray = data.encode()
/**
* Given we have explicit exception handling, this is intentionally blank.
*/
override fun onFailure() = Unit
protected fun performTransaction(): Result {
val inAppPaymentId = InAppPaymentTable.InAppPaymentId(data.inAppPaymentId)
val inAppPayment = getAndValidateInAppPayment(inAppPaymentId)
if (inAppPayment == null) {
warning("No such payment, or payment was invalid. Failing.")
return Result.failure()
}
if (data.inAppPaymentSource == null) {
warning("No payment source attached to job. Failing.")
handleFailure(inAppPaymentId, DonationError.getPaymentSetupError(inAppPayment.type.toErrorSource(), Exception(), inAppPayment.data.paymentMethodType.toPaymentSourceType()))
return Result.failure()
}
if (inAppPayment.state == InAppPaymentTable.State.REQUIRED_ACTION_COMPLETED) {
info("In REQUIRED_ACTION_COMPLETED state, performing post-3ds work.")
info("Moving payment to TRANSACTING state.")
val freshPayment = SignalDatabase.inAppPayments.moveToTransacting(inAppPaymentId)!!
return try {
performPostUserAction(freshPayment)
} catch (e: Exception) {
handleFailure(inAppPaymentId, e)
Result.failure()
}
}
try {
info("Moving payment to TRANSACTING state.")
val freshPayment = SignalDatabase.inAppPayments.moveToTransacting(inAppPaymentId)!!
when (val action = performPreUserAction(freshPayment)) {
is RequiredUserAction.StripeActionRequired -> {
info("Stripe requires an action. Moving InAppPayment to REQUIRES_ACTION state.")
val stripeSecure3DSAction = action.action
val freshInAppPayment = SignalDatabase.inAppPayments.getById(inAppPaymentId)!!
SignalDatabase.inAppPayments.update(
inAppPayment = freshInAppPayment.copy(
state = InAppPaymentTable.State.REQUIRES_ACTION,
data = freshInAppPayment.data.newBuilder().stripeRequiresAction(
stripeRequiresAction = InAppPaymentData.StripeRequiresActionState(
uri = stripeSecure3DSAction.uri.toString(),
returnUri = stripeSecure3DSAction.returnUri.toString(),
paymentMethodId = stripeSecure3DSAction.paymentMethodId.toString(),
stripeIntentId = stripeSecure3DSAction.stripeIntentAccessor.intentId,
stripeClientSecret = stripeSecure3DSAction.stripeIntentAccessor.intentClientSecret
)
).build()
)
)
return Result.failure()
}
is RequiredUserAction.StripeActionNotRequired -> {
val iap = SignalDatabase.inAppPayments.getById(inAppPaymentId)!!
val withCompletionData = iap.copy(
data = iap.data.newBuilder().stripeActionComplete(
stripeActionComplete = InAppPaymentData.StripeActionCompleteState(
stripeIntentId = action.action.stripeIntentAccessor.intentId,
stripeClientSecret = action.action.stripeIntentAccessor.intentClientSecret,
paymentMethodId = action.action.paymentMethodId
)
).build()
)
SignalDatabase.inAppPayments.update(withCompletionData)
return performPostUserAction(
inAppPayment = withCompletionData
)
}
is RequiredUserAction.PayPalActionRequired -> {
val freshInAppPayment = SignalDatabase.inAppPayments.getById(inAppPaymentId)!!
SignalDatabase.inAppPayments.update(
inAppPayment = freshInAppPayment.copy(
state = InAppPaymentTable.State.REQUIRES_ACTION,
data = freshInAppPayment.data.newBuilder().payPalRequiresAction(
payPalRequiresAction = InAppPaymentData.PayPalRequiresActionState(
approvalUrl = action.approvalUrl,
token = action.tokenOrPaymentId
)
).build()
)
)
return Result.failure()
}
}
} catch (e: Exception) {
handleFailure(inAppPaymentId, e)
return Result.failure()
}
}
abstract fun performPreUserAction(inAppPayment: InAppPaymentTable.InAppPayment): RequiredUserAction
abstract fun performPostUserAction(inAppPayment: InAppPaymentTable.InAppPayment): Result
private fun getAndValidateInAppPayment(inAppPaymentId: InAppPaymentTable.InAppPaymentId): InAppPaymentTable.InAppPayment? {
val inAppPayment = SignalDatabase.inAppPayments.getById(inAppPaymentId)
val isValid: Boolean = if (inAppPayment?.state == InAppPaymentTable.State.CREATED || inAppPayment?.state == InAppPaymentTable.State.REQUIRED_ACTION_COMPLETED) {
val isStripeAndHasStripeCompletionData = inAppPayment.data.paymentMethodType.toPaymentSourceType() is PaymentSourceType.Stripe && inAppPayment.data.stripeActionComplete != null
val isPayPalAndHAsPayPalCompletionData = inAppPayment.data.paymentMethodType.toPaymentSourceType() is PaymentSourceType.PayPal && inAppPayment.data.payPalActionComplete != null
if (inAppPayment.state == InAppPaymentTable.State.REQUIRED_ACTION_COMPLETED && !isPayPalAndHAsPayPalCompletionData && !isStripeAndHasStripeCompletionData) {
warning("Missing action data for REQUIRED_ACTION_COMPLETED state.")
false
} else {
true
}
} else {
warning("Missing or invalid in-app-payment in state ${inAppPayment?.state}")
false
}
return if (!isValid) {
if (inAppPayment != null) {
handleFailure(
inAppPaymentId,
DonationError.getPaymentSetupError(
source = inAppPayment.type.toErrorSource(),
throwable = Exception(),
method = inAppPayment.data.paymentMethodType.toPaymentSourceType()
)
)
}
null
} else {
inAppPayment
}
}
private fun handleFailure(inAppPaymentId: InAppPaymentTable.InAppPaymentId, exception: Exception) {
warning("Failed to process transaction.", exception)
val freshPayment = SignalDatabase.inAppPayments.getById(inAppPaymentId)!!
val donationError: DonationError = when (exception) {
is DonationError -> exception
is InAppPaymentProcessorError -> exception.toDonationError(freshPayment.type.toErrorSource(), freshPayment.data.paymentMethodType.toPaymentSourceType())
else -> DonationError.genericBadgeRedemptionFailure(freshPayment.type.toErrorSource())
}
SignalDatabase.inAppPayments.update(
freshPayment.copy(
notified = false,
state = InAppPaymentTable.State.END,
data = freshPayment.data.copy(
error = InAppPaymentError.fromDonationError(donationError)?.inAppPaymentDataError
)
)
)
}
protected fun info(message: String, throwable: Throwable? = null) {
Log.i(TAG, "InAppPayment[${data.inAppPaymentId}]: $message", throwable, true)
}
protected fun warning(message: String, throwable: Throwable? = null) {
Log.w(TAG, "InAppPayment[${data.inAppPaymentId}]: $message", throwable, true)
}
}

View File

@@ -0,0 +1,98 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.jobs
import org.signal.donations.InAppPaymentType
import org.signal.donations.PaymentSource
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.OneTimeInAppPaymentRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.StripeRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.toPaymentSource
import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.thoughtcrime.securesms.jobmanager.Job
import org.thoughtcrime.securesms.jobs.protos.InAppPaymentSetupJobData
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
/**
* Handles one-time Stripe transactions.
*/
class InAppPaymentStripeOneTimeSetupJob private constructor(
data: InAppPaymentSetupJobData,
parameters: Parameters
) : InAppPaymentSetupJob(data, parameters) {
companion object {
const val KEY = "InAppPaymentStripeOneTimeSetupJob"
/**
* Creates a new job for performing stripe recurring payment setup. Note that
* we do not require network for this job, as if the network is not present, we
* should treat that as an immediate error and fail the job.
*/
fun create(
inAppPayment: InAppPaymentTable.InAppPayment,
paymentSource: PaymentSource
): InAppPaymentStripeOneTimeSetupJob {
return InAppPaymentStripeOneTimeSetupJob(
getJobData(inAppPayment, paymentSource),
getParameters(inAppPayment)
)
}
}
override fun performPreUserAction(inAppPayment: InAppPaymentTable.InAppPayment): RequiredUserAction {
info("Beginning one-time payment pipeline.")
val amount = inAppPayment.data.amount!!.toFiatMoney()
val recipientId = inAppPayment.data.recipientId?.let { RecipientId.from(it) } ?: Recipient.self().id
if (inAppPayment.type == InAppPaymentType.ONE_TIME_GIFT) {
info("Verifying recipient $recipientId can receive gift.")
OneTimeInAppPaymentRepository.verifyRecipientIsAllowedToReceiveAGiftSync(recipientId)
}
info("Continuing payment...")
val intentAccessor = StripeRepository.createPaymentIntent(amount, recipientId, inAppPayment.data.level, data.inAppPaymentSource!!.toPaymentSource().type)
info("Confirming payment...")
return when (val action = StripeRepository.confirmPaymentIntent(data.inAppPaymentSource.toPaymentSource(), intentAccessor, recipientId)) {
is StripeApi.Secure3DSAction.ConfirmRequired -> RequiredUserAction.StripeActionRequired(action)
is StripeApi.Secure3DSAction.NotNeeded -> RequiredUserAction.StripeActionNotRequired(action)
}
}
override fun performPostUserAction(inAppPayment: InAppPaymentTable.InAppPayment): Result {
val paymentMethodId = inAppPayment.data.stripeActionComplete!!.paymentMethodId
val intentAccessor = StripeIntentAccessor(
objectType = StripeIntentAccessor.ObjectType.PAYMENT_INTENT,
intentId = inAppPayment.data.stripeActionComplete.stripeIntentId,
intentClientSecret = inAppPayment.data.stripeActionComplete.stripeClientSecret
)
info("Getting status and payment method id from stripe.")
StripeRepository.getStatusAndPaymentMethodId(intentAccessor, paymentMethodId)
info("Received status and payment method id. Submitting redemption job chain.")
OneTimeInAppPaymentRepository.submitRedemptionJobChain(inAppPayment, intentAccessor.intentId)
return Result.success()
}
override fun getFactoryKey(): String = KEY
override fun run(): Result {
return performTransaction()
}
class Factory : Job.Factory<InAppPaymentStripeOneTimeSetupJob> {
override fun create(parameters: Parameters, serializedData: ByteArray?): InAppPaymentStripeOneTimeSetupJob {
val data = serializedData?.let { InAppPaymentSetupJobData.ADAPTER.decode(it) } ?: error("Missing job data!")
return InAppPaymentStripeOneTimeSetupJob(data, parameters)
}
}
}

View File

@@ -0,0 +1,105 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.jobs
import android.annotation.SuppressLint
import org.signal.core.util.logging.Log
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.InAppPaymentsRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository.requireSubscriberType
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository.toPaymentSourceType
import org.thoughtcrime.securesms.components.settings.app.subscription.RecurringInAppPaymentRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.StripeRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.toPaymentSource
import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.thoughtcrime.securesms.jobmanager.Job
import org.thoughtcrime.securesms.jobs.protos.InAppPaymentSetupJobData
/**
* Handles setup of recurring Stripe transactions.
*/
class InAppPaymentStripeRecurringSetupJob private constructor(
data: InAppPaymentSetupJobData,
parameters: Parameters
) : InAppPaymentSetupJob(data, parameters) {
companion object {
const val KEY = "InAppPaymentStripeRecurringSetupJob"
private val TAG = Log.tag(InAppPaymentStripeRecurringSetupJob::class)
/**
* Creates a new job for performing stripe recurring payment setup. Note that
* we do not require network for this job, as if the network is not present, we
* should treat that as an immediate error and fail the job.
*/
fun create(
inAppPayment: InAppPaymentTable.InAppPayment,
paymentSource: PaymentSource
): InAppPaymentStripeRecurringSetupJob {
return InAppPaymentStripeRecurringSetupJob(
getJobData(inAppPayment, paymentSource),
getParameters(inAppPayment)
)
}
}
override fun run(): Result {
return synchronized(InAppPaymentsRepository.resolveLock(InAppPaymentTable.InAppPaymentId(data.inAppPaymentId))) {
performTransaction()
}
}
override fun performPreUserAction(inAppPayment: InAppPaymentTable.InAppPayment): RequiredUserAction {
info("Ensuring the subscriber id is set on the server.")
RecurringInAppPaymentRepository.ensureSubscriberIdSync(inAppPayment.type.requireSubscriberType())
info("Canceling active subscription (if necessary).")
RecurringInAppPaymentRepository.cancelActiveSubscriptionIfNecessarySync(inAppPayment.type.requireSubscriberType())
info("Creating and confirming setup intent.")
return when (val action = StripeRepository.createAndConfirmSetupIntent(inAppPayment.type, data.inAppPaymentSource!!.toPaymentSource(), inAppPayment.data.paymentMethodType.toPaymentSourceType() as PaymentSourceType.Stripe)) {
is StripeApi.Secure3DSAction.ConfirmRequired -> RequiredUserAction.StripeActionRequired(action)
is StripeApi.Secure3DSAction.NotNeeded -> RequiredUserAction.StripeActionNotRequired(action)
}
}
@SuppressLint("CheckResult")
override fun performPostUserAction(inAppPayment: InAppPaymentTable.InAppPayment): Result {
val paymentMethodId = inAppPayment.data.stripeActionComplete!!.paymentMethodId
val intentAccessor = StripeIntentAccessor(
objectType = StripeIntentAccessor.ObjectType.SETUP_INTENT,
intentId = inAppPayment.data.stripeActionComplete.stripeIntentId,
intentClientSecret = inAppPayment.data.stripeActionComplete.stripeClientSecret
)
info("Requesting status and payment method id from stripe service.")
val statusAndPaymentMethodId = StripeRepository.getStatusAndPaymentMethodId(intentAccessor, paymentMethodId)
info("Setting default payment method.")
StripeRepository.setDefaultPaymentMethod(
paymentMethodId = statusAndPaymentMethodId.paymentMethod!!,
setupIntentId = intentAccessor.intentId,
subscriberType = inAppPayment.type.requireSubscriberType(),
paymentSourceType = inAppPayment.data.paymentMethodType.toPaymentSourceType()
)
info("Setting subscription level.")
RecurringInAppPaymentRepository.setSubscriptionLevelSync(inAppPayment)
return Result.success()
}
override fun getFactoryKey(): String = KEY
class Factory : Job.Factory<InAppPaymentStripeRecurringSetupJob> {
override fun create(parameters: Parameters, serializedData: ByteArray?): InAppPaymentStripeRecurringSetupJob {
val data = serializedData?.let { InAppPaymentSetupJobData.ADAPTER.decode(it) } ?: error("Missing job data!")
return InAppPaymentStripeRecurringSetupJob(data, parameters)
}
}
}

View File

@@ -168,6 +168,10 @@ public final class JobManagerFactories {
put(InAppPaymentRecurringContextJob.KEY, new InAppPaymentRecurringContextJob.Factory());
put(InAppPaymentOneTimeContextJob.KEY, new InAppPaymentOneTimeContextJob.Factory());
put(InAppPaymentRedemptionJob.KEY, new InAppPaymentRedemptionJob.Factory());
put(InAppPaymentPayPalOneTimeSetupJob.KEY, new InAppPaymentPayPalOneTimeSetupJob.Factory());
put(InAppPaymentPayPalRecurringSetupJob.KEY, new InAppPaymentPayPalRecurringSetupJob.Factory());
put(InAppPaymentStripeOneTimeSetupJob.KEY, new InAppPaymentStripeOneTimeSetupJob.Factory());
put(InAppPaymentStripeRecurringSetupJob.KEY, new InAppPaymentStripeRecurringSetupJob.Factory());
put(IndividualSendJob.KEY, new IndividualSendJob.Factory());
put(LeaveGroupV2Job.KEY, new LeaveGroupV2Job.Factory());
put(LeaveGroupV2WorkerJob.KEY, new LeaveGroupV2WorkerJob.Factory());

View File

@@ -1,6 +1,7 @@
package org.thoughtcrime.securesms.subscription
import org.whispersystems.signalservice.api.subscriptions.IdempotencyKey
import java.io.Closeable
/**
* Binds a Subscription level update with an idempotency key.
@@ -10,4 +11,8 @@ import org.whispersystems.signalservice.api.subscriptions.IdempotencyKey
data class LevelUpdateOperation(
val idempotencyKey: IdempotencyKey,
val level: String
)
) : Closeable {
override fun close() {
LevelUpdate.updateProcessingState(false)
}
}

View File

@@ -379,6 +379,31 @@ message InAppPaymentData {
optional bytes receiptCredentialPresentation = 5; // Redeemable presentation
}
message StripeRequiresActionState {
string uri = 1; // URI to navigate the user to in a webview
string returnUri = 2; // URI to return to the app with if the user moves to an external location
string stripeIntentId = 3; // Passed to same field in waiting for auth
string stripeClientSecret = 4; // Passed to same field in waiting for auth
optional string paymentMethodId = 5; // Nullable payment method id
}
message StripeActionCompleteState {
string stripeIntentId = 3; // Passed to same field in waiting for auth
string stripeClientSecret = 4; // Passed to same field in waiting for auth
optional string paymentMethodId = 5; // Nullable payment method id
}
message PayPalRequiresActionState {
string approvalUrl = 1;
string token = 2;
}
message PayPalActionCompleteState {
string payerId = 1;
string paymentId = 2;
string paymentToken = 3;
}
message Error {
enum Type {
UNKNOWN = 0; // A generic, untyped error. Check log for details.
@@ -413,10 +438,13 @@ message InAppPaymentData {
PaymentMethodType paymentMethodType = 9; // The method through which this in app payment was made
oneof redemptionState {
WaitingForAuthorizationState waitForAuth = 10; // Waiting on user authorization from an external source (3DS, iDEAL)
RedemptionState redemption = 11; // Waiting on processing of token
WaitingForAuthorizationState waitForAuth = 10; // Waiting on user authorization from an external source (3DS, iDEAL)
RedemptionState redemption = 11; // Waiting on processing of token
StripeRequiresActionState stripeRequiresAction = 12; // Waiting on required user action, user has not navigated away from app.
StripeActionCompleteState stripeActionComplete = 13; // Stripe action completed.
PayPalRequiresActionState payPalRequiresAction = 14; // Waiting on required user action, user has not navigated away from app.
PayPalActionCompleteState payPalActionComplete = 15; // PayPal action completed.
}
}
// DEPRECATED -- Move to TokenTransactionData

View File

@@ -170,3 +170,46 @@ message MultiDeviceAttachmentBackfillUpdateJobData {
signalservice.ConversationIdentifier targetConversation = 2;
uint64 messageId = 3;
}
message InAppPaymentSourceData {
enum Code {
UNKNOWN = 0;
PAY_PAL = 1;
CREDIT_CARD = 2;
GOOGLE_PAY = 3;
SEPA_DEBIT = 4;
IDEAL = 5;
GOOGLE_PLAY_BILLING = 6;
}
message TokenData {
string parameters = 1;
string tokenId = 2;
optional string email = 3;
}
message IDEALData {
string bank = 1;
string name = 2;
string email = 3;
}
message SEPAData {
string iban = 1;
string name = 2;
string email = 3;
}
Code code = 1;
oneof data {
TokenData tokenData = 2;
IDEALData idealData = 3;
SEPAData sepaData = 4;
}
}
message InAppPaymentSetupJobData {
uint64 inAppPaymentId = 1;
InAppPaymentSourceData inAppPaymentSource = 2;
}

View File

@@ -4,26 +4,21 @@ import android.app.Application
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.reactivex.rxjava3.core.Flowable
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.signal.donations.InAppPaymentType
import org.signal.donations.PaymentSourceType
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.thoughtcrime.securesms.database.RecipientTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.DistributionListId
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId
import org.thoughtcrime.securesms.testutil.MockAppDependenciesRule
import org.thoughtcrime.securesms.testutil.RxPluginsRule
import java.util.concurrent.TimeUnit
@RunWith(RobolectricTestRunner::class)
@Config(manifest = Config.NONE, application = Application::class)
@@ -80,7 +75,7 @@ class OneTimeInAppPaymentRepositoryTest {
}
@Test
fun `Given a registered non-self individual, when I verifyRecipientIsAllowedToReceiveAGift, then I expect completion`() {
fun `Given a registered non-self individual, when I verifyRecipientIsAllowedToReceiveAGift, then I expect no error`() {
val recipientId = RecipientId.from(1L)
val recipient = Recipient(
id = recipientId,
@@ -91,13 +86,10 @@ class OneTimeInAppPaymentRepositoryTest {
mockkStatic(Recipient::class)
every { Recipient.resolved(recipientId) } returns recipient
val testObserver = OneTimeInAppPaymentRepository.verifyRecipientIsAllowedToReceiveAGift(recipientId).test()
rxRule.defaultScheduler.triggerActions()
testObserver.assertComplete()
OneTimeInAppPaymentRepository.verifyRecipientIsAllowedToReceiveAGiftSync(recipientId)
}
@Test
@Test(expected = DonationError.GiftRecipientVerificationError.SelectedRecipientIsInvalid::class)
fun `Given self, when I verifyRecipientIsAllowedToReceiveAGift, then I expect SelectedRecipientIsInvalid`() {
val recipientId = RecipientId.from(1L)
val recipient = Recipient(
@@ -108,7 +100,7 @@ class OneTimeInAppPaymentRepositoryTest {
verifyRecipientIsNotAllowedToBeGiftedBadges(recipient)
}
@Test
@Test(expected = DonationError.GiftRecipientVerificationError.SelectedRecipientIsInvalid::class)
fun `Given an unregistered individual, when I verifyRecipientIsAllowedToReceiveAGift, then I expect SelectedRecipientIsInvalid`() {
val recipientId = RecipientId.from(1L)
val recipient = Recipient(
@@ -120,7 +112,7 @@ class OneTimeInAppPaymentRepositoryTest {
verifyRecipientIsNotAllowedToBeGiftedBadges(recipient)
}
@Test
@Test(expected = DonationError.GiftRecipientVerificationError.SelectedRecipientIsInvalid::class)
fun `Given a group, when I verifyRecipientIsAllowedToReceiveAGift, then I expect SelectedRecipientIsInvalid`() {
val recipientId = RecipientId.from(1L)
val recipient = Recipient(
@@ -133,7 +125,7 @@ class OneTimeInAppPaymentRepositoryTest {
verifyRecipientIsNotAllowedToBeGiftedBadges(recipient)
}
@Test
@Test(expected = DonationError.GiftRecipientVerificationError.SelectedRecipientIsInvalid::class)
fun `Given a call link, when I verifyRecipientIsAllowedToReceiveAGift, then I expect SelectedRecipientIsInvalid`() {
val recipientId = RecipientId.from(1L)
val recipient = Recipient(
@@ -146,7 +138,7 @@ class OneTimeInAppPaymentRepositoryTest {
verifyRecipientIsNotAllowedToBeGiftedBadges(recipient)
}
@Test
@Test(expected = DonationError.GiftRecipientVerificationError.SelectedRecipientIsInvalid::class)
fun `Given a distribution list, when I verifyRecipientIsAllowedToReceiveAGift, then I expect SelectedRecipientIsInvalid`() {
val recipientId = RecipientId.from(1L)
val recipient = Recipient(
@@ -159,7 +151,7 @@ class OneTimeInAppPaymentRepositoryTest {
verifyRecipientIsNotAllowedToBeGiftedBadges(recipient)
}
@Test
@Test(expected = DonationError.GiftRecipientVerificationError.SelectedRecipientIsInvalid::class)
fun `Given release notes, when I verifyRecipientIsAllowedToReceiveAGift, then I expect SelectedRecipientIsInvalid`() {
val recipientId = RecipientId.from(1L)
val recipient = Recipient(
@@ -210,68 +202,10 @@ class OneTimeInAppPaymentRepositoryTest {
.assertComplete()
}
@Test
fun `Given a long running transaction, when I waitForOneTimeRedemption, then I expect DonationPending`() {
val inAppPayment = inAppPaymentsTestRule.createInAppPayment(InAppPaymentType.ONE_TIME_DONATION, PaymentSourceType.Stripe.SEPADebit)
every { SignalDatabase.Companion.inAppPayments.getById(inAppPayment.id) } returns inAppPayment
val testObserver = OneTimeInAppPaymentRepository
.waitForOneTimeRedemption(inAppPayment, "test-intent-id")
.test()
rxRule.defaultScheduler.triggerActions()
rxRule.defaultScheduler.advanceTimeBy(10, TimeUnit.SECONDS)
rxRule.defaultScheduler.triggerActions()
testObserver
.assertError { it is DonationError.BadgeRedemptionError.DonationPending }
}
@Test
fun `Given a non long running transaction, when I waitForOneTimeRedemption, then I expect TimeoutWaitingForTokenError`() {
val inAppPayment = inAppPaymentsTestRule.createInAppPayment(InAppPaymentType.ONE_TIME_DONATION, PaymentSourceType.Stripe.CreditCard)
every { SignalDatabase.Companion.inAppPayments.getById(inAppPayment.id) } returns inAppPayment
val testObserver = OneTimeInAppPaymentRepository
.waitForOneTimeRedemption(inAppPayment, "test-intent-id")
.test()
rxRule.defaultScheduler.triggerActions()
rxRule.defaultScheduler.advanceTimeBy(10, TimeUnit.SECONDS)
rxRule.defaultScheduler.triggerActions()
testObserver
.assertError { it is DonationError.BadgeRedemptionError.TimeoutWaitingForTokenError }
}
@Test
fun `Given no delays, when I waitForOneTimeRedemption, then I expect happy path`() {
val inAppPayment = inAppPaymentsTestRule.createInAppPayment(InAppPaymentType.ONE_TIME_DONATION, PaymentSourceType.Stripe.CreditCard)
every { InAppPaymentsRepository.observeUpdates(inAppPayment.id) } returns Flowable.just(inAppPayment.copy(state = InAppPaymentTable.State.END))
every { SignalDatabase.Companion.inAppPayments.getById(inAppPayment.id) } returns inAppPayment
val testObserver = OneTimeInAppPaymentRepository
.waitForOneTimeRedemption(inAppPayment, "test-intent-id")
.test()
rxRule.defaultScheduler.triggerActions()
testObserver
.assertComplete()
}
private fun verifyRecipientIsNotAllowedToBeGiftedBadges(recipient: Recipient) {
mockkStatic(Recipient::class)
every { Recipient.resolved(recipient.id) } returns recipient
val testObserver = OneTimeInAppPaymentRepository.verifyRecipientIsAllowedToReceiveAGift(recipient.id).test()
rxRule.defaultScheduler.triggerActions()
testObserver.assertError {
it is DonationError.GiftRecipientVerificationError.SelectedRecipientIsInvalid
}
OneTimeInAppPaymentRepository.verifyRecipientIsAllowedToReceiveAGiftSync(recipient.id)
}
}

View File

@@ -9,7 +9,6 @@ import io.mockk.mockkObject
import io.mockk.mockkStatic
import io.mockk.unmockkAll
import io.mockk.verify
import io.reactivex.rxjava3.core.Flowable
import org.junit.After
import org.junit.Before
import org.junit.Rule
@@ -17,10 +16,6 @@ import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.signal.donations.InAppPaymentType
import org.signal.donations.PaymentSourceType
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.InAppPaymentSubscriberRecord
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
@@ -28,17 +23,12 @@ import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.storage.StorageSyncHelper
import org.thoughtcrime.securesms.subscription.LevelUpdate
import org.thoughtcrime.securesms.subscription.LevelUpdateOperation
import org.thoughtcrime.securesms.testutil.MockAppDependenciesRule
import org.thoughtcrime.securesms.testutil.RxPluginsRule
import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException
import org.whispersystems.signalservice.api.subscriptions.IdempotencyKey
import org.whispersystems.signalservice.api.subscriptions.SubscriberId
import org.whispersystems.signalservice.internal.EmptyResponse
import org.whispersystems.signalservice.internal.ServiceResponse
import java.util.Currency
import java.util.concurrent.TimeUnit
@RunWith(RobolectricTestRunner::class)
@Config(manifest = Config.NONE, application = Application::class)
@@ -98,13 +88,10 @@ class RecurringInAppPaymentRepositoryTest {
val initialSubscriber = createSubscriber()
val ref = InAppPaymentsTestRule.mockLocalSubscriberAccess(initialSubscriber)
val testObserver = RecurringInAppPaymentRepository.ensureSubscriberId(
RecurringInAppPaymentRepository.ensureSubscriberIdSync(
subscriberType = InAppPaymentSubscriberRecord.Type.DONATION,
isRotation = false
).test()
rxRule.defaultScheduler.triggerActions()
testObserver.assertComplete()
)
val newSubscriber = ref.get()
@@ -116,13 +103,10 @@ class RecurringInAppPaymentRepositoryTest {
val initialSubscriber = createSubscriber()
val ref = InAppPaymentsTestRule.mockLocalSubscriberAccess(initialSubscriber)
val testObserver = RecurringInAppPaymentRepository.ensureSubscriberId(
RecurringInAppPaymentRepository.ensureSubscriberIdSync(
subscriberType = InAppPaymentSubscriberRecord.Type.DONATION,
isRotation = true
).test()
rxRule.defaultScheduler.triggerActions()
testObserver.assertComplete()
)
val newSubscriber = ref.get()
@@ -160,98 +144,6 @@ class RecurringInAppPaymentRepositoryTest {
}
}
@Test
fun `given no delays, when I setSubscriptionLevel, then I expect happy path`() {
val paymentSourceType = PaymentSourceType.Stripe.CreditCard
val inAppPayment = inAppPaymentsTestRule.createInAppPayment(InAppPaymentType.RECURRING_DONATION, paymentSourceType)
InAppPaymentsTestRule.mockLocalSubscriberAccess(createSubscriber())
every { SignalStore.inAppPayments.getLevelOperation("500") } returns LevelUpdateOperation(IdempotencyKey.generate(), "500")
every { SignalDatabase.inAppPayments.getById(any()) } returns inAppPayment
every { InAppPaymentsRepository.observeUpdates(inAppPayment.id) } returns Flowable.just(inAppPayment.copy(state = InAppPaymentTable.State.END))
val testObserver = RecurringInAppPaymentRepository.setSubscriptionLevel(inAppPayment, paymentSourceType).test()
val processingUpdate = LevelUpdate.isProcessing.test()
rxRule.defaultScheduler.triggerActions()
processingUpdate.assertValues(false, true, false)
testObserver.assertComplete()
}
@Test
fun `given 10s delay, when I setSubscriptionLevel, then I expect timeout`() {
val paymentSourceType = PaymentSourceType.Stripe.CreditCard
val inAppPayment = inAppPaymentsTestRule.createInAppPayment(InAppPaymentType.RECURRING_DONATION, paymentSourceType)
InAppPaymentsTestRule.mockLocalSubscriberAccess(createSubscriber())
every { SignalStore.inAppPayments.getLevelOperation("500") } returns LevelUpdateOperation(IdempotencyKey.generate(), "500")
every { SignalDatabase.inAppPayments.getById(any()) } returns inAppPayment
val testObserver = RecurringInAppPaymentRepository.setSubscriptionLevel(inAppPayment, paymentSourceType).test()
val processingUpdate = LevelUpdate.isProcessing.test()
rxRule.defaultScheduler.triggerActions()
processingUpdate.assertValues(false, true, false)
rxRule.defaultScheduler.advanceTimeBy(10, TimeUnit.SECONDS)
rxRule.defaultScheduler.triggerActions()
testObserver.assertError {
it is DonationError.BadgeRedemptionError.TimeoutWaitingForTokenError
}
}
@Test
fun `given long running payment type with 10s delay, when I setSubscriptionLevel, then I expect pending`() {
val paymentSourceType = PaymentSourceType.Stripe.SEPADebit
val inAppPayment = inAppPaymentsTestRule.createInAppPayment(InAppPaymentType.RECURRING_DONATION, paymentSourceType)
InAppPaymentsTestRule.mockLocalSubscriberAccess(createSubscriber())
every { SignalStore.inAppPayments.getLevelOperation("500") } returns LevelUpdateOperation(IdempotencyKey.generate(), "500")
every { SignalDatabase.inAppPayments.getById(any()) } returns inAppPayment
val testObserver = RecurringInAppPaymentRepository.setSubscriptionLevel(inAppPayment, paymentSourceType).test()
val processingUpdate = LevelUpdate.isProcessing.test()
rxRule.defaultScheduler.triggerActions()
processingUpdate.assertValues(false, true, false)
rxRule.defaultScheduler.advanceTimeBy(10, TimeUnit.SECONDS)
rxRule.defaultScheduler.triggerActions()
testObserver.assertError {
it is DonationError.BadgeRedemptionError.DonationPending
}
}
@Test
fun `given an execution error, when I setSubscriptionLevel, then I expect the same error`() {
val expected = NonSuccessfulResponseCodeException(404)
val paymentSourceType = PaymentSourceType.Stripe.SEPADebit
val inAppPayment = inAppPaymentsTestRule.createInAppPayment(InAppPaymentType.RECURRING_DONATION, paymentSourceType)
InAppPaymentsTestRule.mockLocalSubscriberAccess(createSubscriber())
every { SignalStore.inAppPayments.getLevelOperation("500") } returns LevelUpdateOperation(IdempotencyKey.generate(), "500")
every { SignalDatabase.inAppPayments.getById(any()) } returns inAppPayment
every { AppDependencies.donationsService.updateSubscriptionLevel(any(), any(), any(), any(), any()) } returns ServiceResponse.forExecutionError(expected)
val testObserver = RecurringInAppPaymentRepository.setSubscriptionLevel(inAppPayment, paymentSourceType).test()
val processingUpdate = LevelUpdate.isProcessing.distinctUntilChanged().test()
rxRule.defaultScheduler.triggerActions()
processingUpdate.assertValues(false, true, false)
rxRule.defaultScheduler.advanceTimeBy(10, TimeUnit.SECONDS)
rxRule.defaultScheduler.triggerActions()
testObserver.assertError(expected)
}
private fun createSubscriber(): InAppPaymentSubscriberRecord {
return InAppPaymentSubscriberRecord(
subscriberId = SubscriberId.generate(),

View File

@@ -534,7 +534,7 @@ class InAppPaymentRecurringContextJobTest {
private fun insertInAppPayment(
type: InAppPaymentType = InAppPaymentType.RECURRING_DONATION,
state: InAppPaymentTable.State = InAppPaymentTable.State.CREATED,
state: InAppPaymentTable.State = InAppPaymentTable.State.TRANSACTING,
subscriberId: SubscriberId? = SubscriberId.generate(),
paymentSourceType: PaymentSourceType = PaymentSourceType.Stripe.CreditCard,
badge: BadgeList.Badge? = null,
@@ -551,6 +551,11 @@ class InAppPaymentRecurringContextJobTest {
endOfPeriod = null,
inAppPaymentData = iap.data.copy(
badge = badge,
waitForAuth = null,
stripeActionComplete = null,
payPalActionComplete = null,
payPalRequiresAction = null,
stripeRequiresAction = null,
redemption = redemptionState
)
)

View File

@@ -7,7 +7,7 @@ import org.json.JSONObject
*/
class CreditCardPaymentSource(
private val payload: JSONObject
) : StripeApi.PaymentSource {
) : PaymentSource {
override val type = PaymentSourceType.Stripe.CreditCard
override fun parameterize(): JSONObject = payload
override fun getTokenId(): String = parameterize().getString("id")

View File

@@ -3,7 +3,7 @@ package org.signal.donations
import com.google.android.gms.wallet.PaymentData
import org.json.JSONObject
class GooglePayPaymentSource(private val paymentData: PaymentData) : StripeApi.PaymentSource {
class GooglePayPaymentSource(private val paymentData: PaymentData) : PaymentSource {
override val type = PaymentSourceType.Stripe.GooglePay
override fun parameterize(): JSONObject {

View File

@@ -4,15 +4,10 @@
*/
package org.signal.donations
import org.json.JSONObject
class IDEALPaymentSource(
val idealData: StripeApi.IDEALData
) : StripeApi.PaymentSource {
) : PaymentSource {
override val type: PaymentSourceType = PaymentSourceType.Stripe.IDEAL
override fun parameterize(): JSONObject = error("iDEAL does not support tokenization")
override fun getTokenId(): String = error("iDEAL does not support tokenization")
override fun email(): String? = null
}

View File

@@ -0,0 +1,10 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.donations
class PayPalPaymentSource : PaymentSource {
override val type: PaymentSourceType = PaymentSourceType.PayPal
}

View File

@@ -0,0 +1,19 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.donations
import org.json.JSONObject
/**
* A PaymentSource, being something that can be used to perform a
* transaction. See [PaymentSourceType].
*/
interface PaymentSource {
val type: PaymentSourceType
fun parameterize(): JSONObject = error("Unsupported by $type.")
fun getTokenId(): String = error("Unsupported by $type.")
fun email(): String? = error("Unsupported by $type.")
}

View File

@@ -4,15 +4,10 @@
*/
package org.signal.donations
import org.json.JSONObject
class SEPADebitPaymentSource(
val sepaDebitData: StripeApi.SEPADebitData
) : StripeApi.PaymentSource {
) : PaymentSource {
override val type: PaymentSourceType = PaymentSourceType.Stripe.SEPADebit
override fun parameterize(): JSONObject = error("SEPA Debit does not support tokenization")
override fun getTokenId(): String = error("SEPA Debit does not support tokenization")
override fun email(): String? = null
}

View File

@@ -62,50 +62,50 @@ class StripeApi(
data class Failure(val reason: Throwable) : CreatePaymentSourceFromCardDataResult()
}
fun createSetupIntent(inAppPaymentType: InAppPaymentType, sourceType: PaymentSourceType.Stripe): Single<CreateSetupIntentResult> {
@WorkerThread
fun createSetupIntent(inAppPaymentType: InAppPaymentType, sourceType: PaymentSourceType.Stripe): CreateSetupIntentResult {
return setupIntentHelper
.fetchSetupIntent(inAppPaymentType, sourceType)
.map { CreateSetupIntentResult(it) }
.subscribeOn(Schedulers.io())
.let { CreateSetupIntentResult(it) }
}
fun confirmSetupIntent(paymentSource: PaymentSource, setupIntent: StripeIntentAccessor): Single<Secure3DSAction> {
return Single.fromCallable {
val paymentMethodId = createPaymentMethodAndParseId(paymentSource)
@WorkerThread
fun confirmSetupIntent(paymentSource: PaymentSource, setupIntent: StripeIntentAccessor): Secure3DSAction {
val paymentMethodId = createPaymentMethodAndParseId(paymentSource)
val parameters = mutableMapOf(
"client_secret" to setupIntent.intentClientSecret,
"payment_method" to paymentMethodId,
"return_url" to if (paymentSource is IDEALPaymentSource) RETURN_URL_IDEAL else RETURN_URL_3DS
)
val parameters = mutableMapOf(
"client_secret" to setupIntent.intentClientSecret,
"payment_method" to paymentMethodId,
"return_url" to if (paymentSource is IDEALPaymentSource) RETURN_URL_IDEAL else RETURN_URL_3DS
)
if (paymentSource.type.isBankTransfer) {
parameters["mandate_data[customer_acceptance][type]"] = "online"
parameters["mandate_data[customer_acceptance][online][infer_from_client]"] = "true"
}
val (nextActionUri, returnUri) = postForm(StripePaths.getSetupIntentConfirmationPath(setupIntent.intentId), parameters).use { response ->
getNextAction(response)
}
Secure3DSAction.from(nextActionUri, returnUri, setupIntent, paymentMethodId)
if (paymentSource.type.isBankTransfer) {
parameters["mandate_data[customer_acceptance][type]"] = "online"
parameters["mandate_data[customer_acceptance][online][infer_from_client]"] = "true"
}
val (nextActionUri, returnUri) = postForm(StripePaths.getSetupIntentConfirmationPath(setupIntent.intentId), parameters).use { response ->
getNextAction(response)
}
return Secure3DSAction.from(nextActionUri, returnUri, setupIntent, paymentMethodId)
}
fun createPaymentIntent(price: FiatMoney, level: Long, sourceType: PaymentSourceType.Stripe): Single<CreatePaymentIntentResult> {
@WorkerThread
fun createPaymentIntent(price: FiatMoney, level: Long, sourceType: PaymentSourceType.Stripe): CreatePaymentIntentResult {
@Suppress("CascadeIf")
return if (Validation.isAmountTooSmall(price)) {
Single.just(CreatePaymentIntentResult.AmountIsTooSmall(price))
CreatePaymentIntentResult.AmountIsTooSmall(price)
} else if (Validation.isAmountTooLarge(price)) {
Single.just(CreatePaymentIntentResult.AmountIsTooLarge(price))
CreatePaymentIntentResult.AmountIsTooLarge(price)
} else {
if (!Validation.supportedCurrencyCodes.contains(price.currency.currencyCode.uppercase(Locale.ROOT))) {
Single.just<CreatePaymentIntentResult>(CreatePaymentIntentResult.CurrencyIsNotSupported(price.currency.currencyCode))
CreatePaymentIntentResult.CurrencyIsNotSupported(price.currency.currencyCode)
} else {
paymentIntentFetcher
.fetchPaymentIntent(price, level, sourceType)
.map<CreatePaymentIntentResult> { CreatePaymentIntentResult.Success(it) }
}.subscribeOn(Schedulers.io())
.let { CreatePaymentIntentResult.Success(it) }
}
}
}
@@ -117,27 +117,26 @@ class StripeApi(
*
* @return A Secure3DSAction
*/
fun confirmPaymentIntent(paymentSource: PaymentSource, paymentIntent: StripeIntentAccessor): Single<Secure3DSAction> {
return Single.fromCallable {
val paymentMethodId = createPaymentMethodAndParseId(paymentSource)
@WorkerThread
fun confirmPaymentIntent(paymentSource: PaymentSource, paymentIntent: StripeIntentAccessor): Secure3DSAction {
val paymentMethodId = createPaymentMethodAndParseId(paymentSource)
val parameters = mutableMapOf(
"client_secret" to paymentIntent.intentClientSecret,
"payment_method" to paymentMethodId,
"return_url" to if (paymentSource is IDEALPaymentSource) RETURN_URL_IDEAL else RETURN_URL_3DS
)
val parameters = mutableMapOf(
"client_secret" to paymentIntent.intentClientSecret,
"payment_method" to paymentMethodId,
"return_url" to if (paymentSource is IDEALPaymentSource) RETURN_URL_IDEAL else RETURN_URL_3DS
)
if (paymentSource.type.isBankTransfer) {
parameters["mandate_data[customer_acceptance][type]"] = "online"
parameters["mandate_data[customer_acceptance][online][infer_from_client]"] = "true"
}
if (paymentSource.type.isBankTransfer) {
parameters["mandate_data[customer_acceptance][type]"] = "online"
parameters["mandate_data[customer_acceptance][online][infer_from_client]"] = "true"
}
val (nextActionUri, returnUri) = postForm(StripePaths.getPaymentIntentConfirmationPath(paymentIntent.intentId), parameters).use { response ->
getNextAction(response)
}
val (nextActionUri, returnUri) = postForm(StripePaths.getPaymentIntentConfirmationPath(paymentIntent.intentId), parameters).use { response ->
getNextAction(response)
}
Secure3DSAction.from(nextActionUri, returnUri, paymentIntent)
}.subscribeOn(Schedulers.io())
return Secure3DSAction.from(nextActionUri, returnUri, paymentIntent)
}
/**
@@ -158,6 +157,7 @@ class StripeApi(
throw StripeError.FailedToParseSetupIntentResponseError(null)
}
}
else -> error("Unsupported type")
}
}
@@ -181,6 +181,7 @@ class StripeApi(
throw StripeError.FailedToParsePaymentIntentResponseError(null)
}
}
else -> error("Unsupported type")
}
}
@@ -239,6 +240,7 @@ class StripeApi(
val paymentMethodResponse = when (paymentSource) {
is SEPADebitPaymentSource -> createPaymentMethodForSEPADebit(paymentSource)
is IDEALPaymentSource -> createPaymentMethodForIDEAL(paymentSource)
is PayPalPaymentSource -> error("Stripe cannot interact with PayPal payment source.")
else -> createPaymentMethodForToken(paymentSource)
}
@@ -571,18 +573,20 @@ class StripeApi(
)
interface PaymentIntentFetcher {
@WorkerThread
fun fetchPaymentIntent(
price: FiatMoney,
level: Long,
sourceType: PaymentSourceType.Stripe
): Single<StripeIntentAccessor>
): StripeIntentAccessor
}
interface SetupIntentHelper {
@WorkerThread
fun fetchSetupIntent(
inAppPaymentType: InAppPaymentType,
sourceType: PaymentSourceType.Stripe
): Single<StripeIntentAccessor>
): StripeIntentAccessor
}
@Parcelize
@@ -607,13 +611,6 @@ class StripeApi(
val email: String
) : Parcelable
interface PaymentSource {
val type: PaymentSourceType
fun parameterize(): JSONObject
fun getTokenId(): String
fun email(): String?
}
sealed interface Secure3DSAction {
data class ConfirmRequired(val uri: Uri, val returnUri: Uri, override val stripeIntentAccessor: StripeIntentAccessor, override val paymentMethodId: String?) : Secure3DSAction
data class NotNeeded(override val paymentMethodId: String?, override val stripeIntentAccessor: StripeIntentAccessor) : Secure3DSAction

View File

@@ -0,0 +1,22 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.donations
import org.json.JSONObject
class TokenPaymentSource(
override val type: PaymentSourceType,
val parameters: String,
val token: String,
val email: String?
) : PaymentSource {
override fun parameterize(): JSONObject = JSONObject(parameters)
override fun getTokenId(): String = token
override fun email(): String? = email
}