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