kopia lustrzana https://github.com/ryukoposting/Signal-Android
Add basic 3DS support for credit cards.
rodzic
c686d33a46
commit
2cfa685ae2
|
@ -5,8 +5,10 @@ import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.ViewModelProvider
|
import androidx.lifecycle.ViewModelProvider
|
||||||
import com.google.android.gms.wallet.PaymentData
|
import com.google.android.gms.wallet.PaymentData
|
||||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||||
|
import io.reactivex.rxjava3.core.Completable
|
||||||
import io.reactivex.rxjava3.core.Flowable
|
import io.reactivex.rxjava3.core.Flowable
|
||||||
import io.reactivex.rxjava3.core.Observable
|
import io.reactivex.rxjava3.core.Observable
|
||||||
|
import io.reactivex.rxjava3.core.Single
|
||||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||||
import io.reactivex.rxjava3.disposables.Disposable
|
import io.reactivex.rxjava3.disposables.Disposable
|
||||||
import io.reactivex.rxjava3.kotlin.plusAssign
|
import io.reactivex.rxjava3.kotlin.plusAssign
|
||||||
|
@ -16,6 +18,7 @@ import org.signal.core.util.logging.Log
|
||||||
import org.signal.core.util.money.FiatMoney
|
import org.signal.core.util.money.FiatMoney
|
||||||
import org.signal.donations.GooglePayApi
|
import org.signal.donations.GooglePayApi
|
||||||
import org.signal.donations.GooglePayPaymentSource
|
import org.signal.donations.GooglePayPaymentSource
|
||||||
|
import org.signal.donations.StripeApi
|
||||||
import org.thoughtcrime.securesms.badges.gifts.Gifts
|
import org.thoughtcrime.securesms.badges.gifts.Gifts
|
||||||
import org.thoughtcrime.securesms.badges.models.Badge
|
import org.thoughtcrime.securesms.badges.models.Badge
|
||||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationEvent
|
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationEvent
|
||||||
|
@ -165,7 +168,14 @@ class GiftFlowViewModel(
|
||||||
|
|
||||||
store.update { it.copy(stage = GiftFlowState.Stage.PAYMENT_PIPELINE) }
|
store.update { it.copy(stage = GiftFlowState.Stage.PAYMENT_PIPELINE) }
|
||||||
|
|
||||||
donationPaymentRepository.continuePayment(gift.price, GooglePayPaymentSource(paymentData), recipient, store.state.additionalMessage?.toString(), gift.level).subscribeBy(
|
val continuePayment: Single<StripeApi.PaymentIntent> = donationPaymentRepository.continuePayment(gift.price, recipient, gift.level)
|
||||||
|
val intentAndSource: Single<Pair<StripeApi.PaymentIntent, StripeApi.PaymentSource>> = Single.zip(continuePayment, Single.just(GooglePayPaymentSource(paymentData)), ::Pair)
|
||||||
|
|
||||||
|
disposables += intentAndSource.flatMapCompletable { (paymentIntent, paymentSource) ->
|
||||||
|
donationPaymentRepository.confirmPayment(paymentSource, paymentIntent, recipient)
|
||||||
|
.flatMapCompletable { Completable.complete() } // We do not currently handle 3DS for gifts.
|
||||||
|
.andThen(donationPaymentRepository.waitForOneTimeRedemption(gift.price, paymentIntent, recipient, store.state.additionalMessage?.toString(), gift.level))
|
||||||
|
}.subscribeBy(
|
||||||
onError = this@GiftFlowViewModel::onPaymentFlowError,
|
onError = this@GiftFlowViewModel::onPaymentFlowError,
|
||||||
onComplete = {
|
onComplete = {
|
||||||
store.update { it.copy(stage = GiftFlowState.Stage.READY) }
|
store.update { it.copy(stage = GiftFlowState.Stage.READY) }
|
||||||
|
|
|
@ -127,17 +127,13 @@ class DonationPaymentRepository(activity: Activity) : StripeApi.PaymentIntentFet
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param price The amount to charce the local user
|
* @param price The amount to charce the local user
|
||||||
* @param paymentData PaymentData from Google Pay that describes the payment method
|
|
||||||
* @param badgeRecipient Who will be getting the badge
|
* @param badgeRecipient Who will be getting the badge
|
||||||
* @param additionalMessage An additional message to send along with the badge (only used if badge recipient is not self)
|
|
||||||
*/
|
*/
|
||||||
fun continuePayment(
|
fun continuePayment(
|
||||||
price: FiatMoney,
|
price: FiatMoney,
|
||||||
paymentSource: StripeApi.PaymentSource,
|
|
||||||
badgeRecipient: RecipientId,
|
badgeRecipient: RecipientId,
|
||||||
additionalMessage: String?,
|
badgeLevel: Long,
|
||||||
badgeLevel: Long
|
): Single<StripeApi.PaymentIntent> {
|
||||||
): Completable {
|
|
||||||
Log.d(TAG, "Creating payment intent for $price...", true)
|
Log.d(TAG, "Creating payment intent for $price...", true)
|
||||||
|
|
||||||
return stripeApi.createPaymentIntent(price, badgeLevel)
|
return stripeApi.createPaymentIntent(price, badgeLevel)
|
||||||
|
@ -150,28 +146,26 @@ class DonationPaymentRepository(activity: Activity) : StripeApi.PaymentIntentFet
|
||||||
Single.error(DonationError.getPaymentSetupError(errorSource, it))
|
Single.error(DonationError.getPaymentSetupError(errorSource, it))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.flatMapCompletable { result ->
|
.flatMap { result ->
|
||||||
val recipient = Recipient.resolved(badgeRecipient)
|
val recipient = Recipient.resolved(badgeRecipient)
|
||||||
val errorSource = if (recipient.isSelf) DonationErrorSource.BOOST else DonationErrorSource.GIFT
|
val errorSource = if (recipient.isSelf) DonationErrorSource.BOOST else DonationErrorSource.GIFT
|
||||||
|
|
||||||
Log.d(TAG, "Created payment intent for $price.", true)
|
Log.d(TAG, "Created payment intent for $price.", true)
|
||||||
when (result) {
|
when (result) {
|
||||||
is StripeApi.CreatePaymentIntentResult.AmountIsTooSmall -> Completable.error(DonationError.oneTimeDonationAmountTooSmall(errorSource))
|
is StripeApi.CreatePaymentIntentResult.AmountIsTooSmall -> Single.error(DonationError.oneTimeDonationAmountTooSmall(errorSource))
|
||||||
is StripeApi.CreatePaymentIntentResult.AmountIsTooLarge -> Completable.error(DonationError.oneTimeDonationAmountTooLarge(errorSource))
|
is StripeApi.CreatePaymentIntentResult.AmountIsTooLarge -> Single.error(DonationError.oneTimeDonationAmountTooLarge(errorSource))
|
||||||
is StripeApi.CreatePaymentIntentResult.CurrencyIsNotSupported -> Completable.error(DonationError.invalidCurrencyForOneTimeDonation(errorSource))
|
is StripeApi.CreatePaymentIntentResult.CurrencyIsNotSupported -> Single.error(DonationError.invalidCurrencyForOneTimeDonation(errorSource))
|
||||||
is StripeApi.CreatePaymentIntentResult.Success -> confirmPayment(price, paymentSource, result.paymentIntent, badgeRecipient, additionalMessage, badgeLevel)
|
is StripeApi.CreatePaymentIntentResult.Success -> Single.just(result.paymentIntent)
|
||||||
}
|
}
|
||||||
}.subscribeOn(Schedulers.io())
|
}.subscribeOn(Schedulers.io())
|
||||||
}
|
}
|
||||||
|
|
||||||
fun continueSubscriptionSetup(paymentSource: StripeApi.PaymentSource): Completable {
|
fun createAndConfirmSetupIntent(paymentSource: StripeApi.PaymentSource): Single<StripeApi.Secure3DSAction> {
|
||||||
Log.d(TAG, "Continuing subscription setup...", true)
|
Log.d(TAG, "Continuing subscription setup...", true)
|
||||||
return stripeApi.createSetupIntent()
|
return stripeApi.createSetupIntent()
|
||||||
.flatMapCompletable { result ->
|
.flatMap { result ->
|
||||||
Log.d(TAG, "Retrieved SetupIntent, confirming...", true)
|
Log.d(TAG, "Retrieved SetupIntent, confirming...", true)
|
||||||
stripeApi.confirmSetupIntent(paymentSource, result.setupIntent).doOnComplete {
|
stripeApi.confirmSetupIntent(paymentSource, result.setupIntent)
|
||||||
Log.d(TAG, "Confirmed SetupIntent...", true)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -211,14 +205,30 @@ class DonationPaymentRepository(activity: Activity) : StripeApi.PaymentIntentFet
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun confirmPayment(price: FiatMoney, paymentSource: StripeApi.PaymentSource, paymentIntent: StripeApi.PaymentIntent, badgeRecipient: RecipientId, additionalMessage: String?, badgeLevel: Long): Completable {
|
fun confirmPayment(
|
||||||
|
paymentSource: StripeApi.PaymentSource,
|
||||||
|
paymentIntent: StripeApi.PaymentIntent,
|
||||||
|
badgeRecipient: RecipientId
|
||||||
|
): Single<StripeApi.Secure3DSAction> {
|
||||||
val isBoost = badgeRecipient == Recipient.self().id
|
val isBoost = badgeRecipient == Recipient.self().id
|
||||||
val donationErrorSource: DonationErrorSource = if (isBoost) DonationErrorSource.BOOST else DonationErrorSource.GIFT
|
val donationErrorSource: DonationErrorSource = if (isBoost) DonationErrorSource.BOOST else DonationErrorSource.GIFT
|
||||||
|
|
||||||
Log.d(TAG, "Confirming payment intent...", true)
|
Log.d(TAG, "Confirming payment intent...", true)
|
||||||
val confirmPayment = stripeApi.confirmPaymentIntent(paymentSource, paymentIntent).onErrorResumeNext {
|
return stripeApi.confirmPaymentIntent(paymentSource, paymentIntent)
|
||||||
Completable.error(DonationError.getPaymentSetupError(donationErrorSource, it))
|
.onErrorResumeNext {
|
||||||
}
|
Single.error(DonationError.getPaymentSetupError(donationErrorSource, it))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun waitForOneTimeRedemption(
|
||||||
|
price: FiatMoney,
|
||||||
|
paymentIntent: StripeApi.PaymentIntent,
|
||||||
|
badgeRecipient: RecipientId,
|
||||||
|
additionalMessage: String?,
|
||||||
|
badgeLevel: Long,
|
||||||
|
): Completable {
|
||||||
|
val isBoost = badgeRecipient == Recipient.self().id
|
||||||
|
val donationErrorSource: DonationErrorSource = if (isBoost) DonationErrorSource.BOOST else DonationErrorSource.GIFT
|
||||||
|
|
||||||
val waitOnRedemption = Completable.create {
|
val waitOnRedemption = Completable.create {
|
||||||
val donationReceiptRecord = if (isBoost) {
|
val donationReceiptRecord = if (isBoost) {
|
||||||
|
@ -273,7 +283,7 @@ class DonationPaymentRepository(activity: Activity) : StripeApi.PaymentIntentFet
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return confirmPayment.andThen(waitOnRedemption)
|
return waitOnRedemption
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setSubscriptionLevel(subscriptionLevel: String): Completable {
|
fun setSubscriptionLevel(subscriptionLevel: String): Completable {
|
||||||
|
@ -405,11 +415,12 @@ class DonationPaymentRepository(activity: Activity) : StripeApi.PaymentIntentFet
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun setDefaultPaymentMethod(paymentMethodId: String): Completable {
|
fun setDefaultPaymentMethod(paymentMethodId: String): Completable {
|
||||||
Log.d(TAG, "Setting default payment method via Signal service...")
|
|
||||||
return Single.fromCallable {
|
return Single.fromCallable {
|
||||||
|
Log.d(TAG, "Getting the subscriber...")
|
||||||
SignalStore.donationsValues().requireSubscriber()
|
SignalStore.donationsValues().requireSubscriber()
|
||||||
}.flatMap {
|
}.flatMap {
|
||||||
|
Log.d(TAG, "Setting default payment method via Signal service...")
|
||||||
Single.fromCallable {
|
Single.fromCallable {
|
||||||
ApplicationDependencies
|
ApplicationDependencies
|
||||||
.getDonationsService()
|
.getDonationsService()
|
||||||
|
@ -420,6 +431,16 @@ class DonationPaymentRepository(activity: Activity) : StripeApi.PaymentIntentFet
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun createCreditCardPaymentSource(donationErrorSource: DonationErrorSource, cardData: StripeApi.CardData): Single<StripeApi.PaymentSource> {
|
||||||
|
Log.d(TAG, "Creating credit card payment source via Stripe api...")
|
||||||
|
return stripeApi.createPaymentSourceFromCardData(cardData).map {
|
||||||
|
when (it) {
|
||||||
|
is StripeApi.CreatePaymentSourceFromCardDataResult.Failure -> throw DonationError.getPaymentSetupError(donationErrorSource, it.reason)
|
||||||
|
is StripeApi.CreatePaymentSourceFromCardDataResult.Success -> it.paymentSource
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private val TAG = Log.tag(DonationPaymentRepository::class.java)
|
private val TAG = Log.tag(DonationPaymentRepository::class.java)
|
||||||
}
|
}
|
||||||
|
|
|
@ -34,6 +34,8 @@ import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
|
||||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
|
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
|
||||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentComponent
|
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentComponent
|
||||||
import org.thoughtcrime.securesms.components.settings.app.subscription.boost.Boost
|
import org.thoughtcrime.securesms.components.settings.app.subscription.boost.Boost
|
||||||
|
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.card.CreditCardFragment
|
||||||
|
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.card.CreditCardResult
|
||||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest
|
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest
|
||||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayResponse
|
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayResponse
|
||||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewaySelectorBottomSheet
|
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewaySelectorBottomSheet
|
||||||
|
@ -143,6 +145,11 @@ class DonateToSignalFragment : DSLSettingsFragment(
|
||||||
handleStripeActionResult(result)
|
handleStripeActionResult(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setFragmentResultListener(CreditCardFragment.REQUEST_KEY) { _, bundle ->
|
||||||
|
val result: CreditCardResult = bundle.getParcelable(CreditCardFragment.REQUEST_KEY)!!
|
||||||
|
handleCreditCardResult(result)
|
||||||
|
}
|
||||||
|
|
||||||
val recyclerView = this.recyclerView!!
|
val recyclerView = this.recyclerView!!
|
||||||
recyclerView.overScrollMode = RecyclerView.OVER_SCROLL_IF_CONTENT_SCROLLS
|
recyclerView.overScrollMode = RecyclerView.OVER_SCROLL_IF_CONTENT_SCROLLS
|
||||||
|
|
||||||
|
@ -400,6 +407,12 @@ class DonateToSignalFragment : DSLSettingsFragment(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun handleCreditCardResult(creditCardResult: CreditCardResult) {
|
||||||
|
Log.d(TAG, "Received credit card information from fragment.")
|
||||||
|
stripePaymentViewModel.provideCardData(creditCardResult.creditCardData)
|
||||||
|
findNavController().safeNavigate(DonateToSignalFragmentDirections.actionDonateToSignalFragmentToStripePaymentInProgressFragment(StripeAction.PROCESS_NEW_DONATION, creditCardResult.gatewayRequest))
|
||||||
|
}
|
||||||
|
|
||||||
private fun handleStripeActionResult(result: StripeActionResult) {
|
private fun handleStripeActionResult(result: StripeActionResult) {
|
||||||
when (result.status) {
|
when (result.status) {
|
||||||
StripeActionResult.Status.SUCCESS -> handleSuccessfulStripeActionResult(result)
|
StripeActionResult.Status.SUCCESS -> handleSuccessfulStripeActionResult(result)
|
||||||
|
|
|
@ -0,0 +1,69 @@
|
||||||
|
package org.thoughtcrime.securesms.components.settings.app.subscription.donate
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.content.DialogInterface
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.View
|
||||||
|
import android.webkit.WebSettings
|
||||||
|
import android.webkit.WebView
|
||||||
|
import android.webkit.WebViewClient
|
||||||
|
import androidx.fragment.app.DialogFragment
|
||||||
|
import androidx.fragment.app.setFragmentResult
|
||||||
|
import androidx.navigation.fragment.navArgs
|
||||||
|
import org.thoughtcrime.securesms.R
|
||||||
|
import org.thoughtcrime.securesms.components.ViewBinderDelegate
|
||||||
|
import org.thoughtcrime.securesms.databinding.Stripe3dsDialogFragmentBinding
|
||||||
|
import org.thoughtcrime.securesms.util.visible
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full-screen dialog for displaying Stripe 3DS confirmation.
|
||||||
|
*/
|
||||||
|
class Stripe3DSDialogFragment : DialogFragment(R.layout.stripe_3ds_dialog_fragment) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val REQUEST_KEY = "stripe_3ds_dialog_fragment"
|
||||||
|
private const val STRIPE_3DS_COMPLETE = "https://hooks.stripe.com/3d_secure/complete/tdsrc_complete"
|
||||||
|
}
|
||||||
|
|
||||||
|
val binding by ViewBinderDelegate(Stripe3dsDialogFragmentBinding::bind) {
|
||||||
|
it.webView.clearCache(true)
|
||||||
|
it.webView.clearHistory()
|
||||||
|
}
|
||||||
|
|
||||||
|
val args: Stripe3DSDialogFragmentArgs by navArgs()
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
setStyle(STYLE_NO_FRAME, R.style.Signal_DayNight_Dialog_FullScreen)
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("SetJavaScriptEnabled")
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
binding.webView.webViewClient = Stripe3DSWebClient()
|
||||||
|
binding.webView.settings.javaScriptEnabled = true
|
||||||
|
binding.webView.settings.cacheMode = WebSettings.LOAD_NO_CACHE
|
||||||
|
binding.webView.loadUrl(args.uri.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDismiss(dialog: DialogInterface) {
|
||||||
|
setFragmentResult(REQUEST_KEY, Bundle())
|
||||||
|
}
|
||||||
|
|
||||||
|
private inner class Stripe3DSWebClient : WebViewClient() {
|
||||||
|
|
||||||
|
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
|
||||||
|
binding.progress.visible = true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPageCommitVisible(view: WebView?, url: String?) {
|
||||||
|
binding.progress.visible = false
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPageFinished(view: WebView?, url: String?) {
|
||||||
|
if (url == STRIPE_3DS_COMPLETE) {
|
||||||
|
dismissAllowingStateLoss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,5 +1,7 @@
|
||||||
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.card
|
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.card
|
||||||
|
|
||||||
|
import org.signal.donations.StripeApi
|
||||||
|
|
||||||
data class CreditCardFormState(
|
data class CreditCardFormState(
|
||||||
val focusedField: FocusedField = FocusedField.NONE,
|
val focusedField: FocusedField = FocusedField.NONE,
|
||||||
val number: String = "",
|
val number: String = "",
|
||||||
|
@ -12,4 +14,13 @@ data class CreditCardFormState(
|
||||||
EXPIRATION,
|
EXPIRATION,
|
||||||
CODE
|
CODE
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun toCardData(): StripeApi.CardData {
|
||||||
|
return StripeApi.CardData(
|
||||||
|
number,
|
||||||
|
expiration.month.toInt(),
|
||||||
|
expiration.year.toInt(),
|
||||||
|
code.toInt()
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,13 +4,17 @@ import android.content.Context
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
|
import androidx.core.os.bundleOf
|
||||||
import androidx.core.widget.addTextChangedListener
|
import androidx.core.widget.addTextChangedListener
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
|
import androidx.fragment.app.setFragmentResult
|
||||||
import androidx.fragment.app.viewModels
|
import androidx.fragment.app.viewModels
|
||||||
|
import androidx.navigation.fragment.findNavController
|
||||||
import androidx.navigation.fragment.navArgs
|
import androidx.navigation.fragment.navArgs
|
||||||
import org.thoughtcrime.securesms.R
|
import org.thoughtcrime.securesms.R
|
||||||
import org.thoughtcrime.securesms.components.ViewBinderDelegate
|
import org.thoughtcrime.securesms.components.ViewBinderDelegate
|
||||||
import org.thoughtcrime.securesms.databinding.CreditCardFragmentBinding
|
import org.thoughtcrime.securesms.databinding.CreditCardFragmentBinding
|
||||||
|
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
|
||||||
import org.thoughtcrime.securesms.util.LifecycleDisposable
|
import org.thoughtcrime.securesms.util.LifecycleDisposable
|
||||||
import org.thoughtcrime.securesms.util.ViewUtil
|
import org.thoughtcrime.securesms.util.ViewUtil
|
||||||
|
|
||||||
|
@ -22,6 +26,9 @@ class CreditCardFragment : Fragment(R.layout.credit_card_fragment) {
|
||||||
private val lifecycleDisposable = LifecycleDisposable()
|
private val lifecycleDisposable = LifecycleDisposable()
|
||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
|
||||||
|
binding.title.text = getString(R.string.CreditCardFragment__donation_amount_s, FiatMoneyUtil.format(resources, args.request.fiat))
|
||||||
|
|
||||||
binding.cardNumber.addTextChangedListener(afterTextChanged = {
|
binding.cardNumber.addTextChangedListener(afterTextChanged = {
|
||||||
viewModel.onNumberChanged(it?.toString() ?: "")
|
viewModel.onNumberChanged(it?.toString() ?: "")
|
||||||
})
|
})
|
||||||
|
@ -46,10 +53,27 @@ class CreditCardFragment : Fragment(R.layout.credit_card_fragment) {
|
||||||
viewModel.onExpirationFocusChanged(hasFocus)
|
viewModel.onExpirationFocusChanged(hasFocus)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
binding.continueButton.setOnClickListener {
|
||||||
|
findNavController().popBackStack()
|
||||||
|
|
||||||
|
val resultBundle = bundleOf(
|
||||||
|
REQUEST_KEY to CreditCardResult(
|
||||||
|
args.request,
|
||||||
|
viewModel.getCardData()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
setFragmentResult(REQUEST_KEY, resultBundle)
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.toolbar.setNavigationOnClickListener {
|
||||||
|
findNavController().popBackStack()
|
||||||
|
}
|
||||||
|
|
||||||
lifecycleDisposable.bindTo(viewLifecycleOwner)
|
lifecycleDisposable.bindTo(viewLifecycleOwner)
|
||||||
lifecycleDisposable += viewModel.state.subscribe {
|
lifecycleDisposable += viewModel.state.subscribe {
|
||||||
// TODO [alex] -- type
|
// TODO [alex] -- type
|
||||||
// TODO [alex] -- all fields valid
|
presentContinue(it)
|
||||||
presentCardNumberWrapper(it.numberValidity)
|
presentCardNumberWrapper(it.numberValidity)
|
||||||
presentCardExpiryWrapper(it.expirationValidity)
|
presentCardExpiryWrapper(it.expirationValidity)
|
||||||
presentCardCodeWrapper(it.codeValidity)
|
presentCardCodeWrapper(it.codeValidity)
|
||||||
|
@ -67,6 +91,10 @@ class CreditCardFragment : Fragment(R.layout.credit_card_fragment) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun presentContinue(state: CreditCardValidationState) {
|
||||||
|
binding.continueButton.isEnabled = state.isValid
|
||||||
|
}
|
||||||
|
|
||||||
private fun presentCardNumberWrapper(validity: CreditCardNumberValidator.Validity) {
|
private fun presentCardNumberWrapper(validity: CreditCardNumberValidator.Validity) {
|
||||||
val errorState = when (validity) {
|
val errorState = when (validity) {
|
||||||
CreditCardNumberValidator.Validity.INVALID -> ErrorState(messageResId = R.string.CreditCardFragment__invalid_card_number)
|
CreditCardNumberValidator.Validity.INVALID -> ErrorState(messageResId = R.string.CreditCardFragment__invalid_card_number)
|
||||||
|
@ -116,6 +144,8 @@ class CreditCardFragment : Fragment(R.layout.credit_card_fragment) {
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
val REQUEST_KEY = "card.data"
|
||||||
|
|
||||||
private val NO_ERROR = ErrorState(false, -1)
|
private val NO_ERROR = ErrorState(false, -1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,16 @@
|
||||||
|
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.card
|
||||||
|
|
||||||
|
import android.os.Parcelable
|
||||||
|
import kotlinx.parcelize.Parcelize
|
||||||
|
import org.signal.donations.StripeApi
|
||||||
|
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encapsulates data returned from the credit card form that can be used
|
||||||
|
* for a credit card based donation payment.
|
||||||
|
*/
|
||||||
|
@Parcelize
|
||||||
|
data class CreditCardResult(
|
||||||
|
val gatewayRequest: GatewayRequest,
|
||||||
|
val creditCardData: StripeApi.CardData
|
||||||
|
) : Parcelable
|
|
@ -5,4 +5,9 @@ data class CreditCardValidationState(
|
||||||
val numberValidity: CreditCardNumberValidator.Validity,
|
val numberValidity: CreditCardNumberValidator.Validity,
|
||||||
val expirationValidity: CreditCardExpirationValidator.Validity,
|
val expirationValidity: CreditCardExpirationValidator.Validity,
|
||||||
val codeValidity: CreditCardCodeValidator.Validity
|
val codeValidity: CreditCardCodeValidator.Validity
|
||||||
)
|
) {
|
||||||
|
val isValid: Boolean =
|
||||||
|
numberValidity == CreditCardNumberValidator.Validity.FULLY_VALID &&
|
||||||
|
expirationValidity == CreditCardExpirationValidator.Validity.FULLY_VALID &&
|
||||||
|
codeValidity == CreditCardCodeValidator.Validity.FULLY_VALID
|
||||||
|
}
|
||||||
|
|
|
@ -6,6 +6,7 @@ import io.reactivex.rxjava3.core.Flowable
|
||||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||||
import io.reactivex.rxjava3.kotlin.plusAssign
|
import io.reactivex.rxjava3.kotlin.plusAssign
|
||||||
import io.reactivex.rxjava3.processors.BehaviorProcessor
|
import io.reactivex.rxjava3.processors.BehaviorProcessor
|
||||||
|
import org.signal.donations.StripeApi
|
||||||
import org.thoughtcrime.securesms.util.rx.RxStore
|
import org.thoughtcrime.securesms.util.rx.RxStore
|
||||||
import java.util.Calendar
|
import java.util.Calendar
|
||||||
|
|
||||||
|
@ -76,6 +77,10 @@ class CreditCardViewModel : ViewModel() {
|
||||||
updateFocus(CreditCardFormState.FocusedField.CODE, isFocused)
|
updateFocus(CreditCardFormState.FocusedField.CODE, isFocused)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun getCardData(): StripeApi.CardData {
|
||||||
|
return formStore.state.toCardData()
|
||||||
|
}
|
||||||
|
|
||||||
private fun updateFocus(
|
private fun updateFocus(
|
||||||
newFocusedField: CreditCardFormState.FocusedField,
|
newFocusedField: CreditCardFormState.FocusedField,
|
||||||
isFocused: Boolean
|
isFocused: Boolean
|
||||||
|
|
|
@ -7,21 +7,31 @@ import android.os.Bundle
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import androidx.core.os.bundleOf
|
import androidx.core.os.bundleOf
|
||||||
import androidx.fragment.app.DialogFragment
|
import androidx.fragment.app.DialogFragment
|
||||||
|
import androidx.fragment.app.FragmentResultListener
|
||||||
import androidx.fragment.app.setFragmentResult
|
import androidx.fragment.app.setFragmentResult
|
||||||
import androidx.navigation.fragment.findNavController
|
import androidx.navigation.fragment.findNavController
|
||||||
import androidx.navigation.fragment.navArgs
|
import androidx.navigation.fragment.navArgs
|
||||||
import androidx.navigation.navGraphViewModels
|
import androidx.navigation.navGraphViewModels
|
||||||
|
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||||
|
import io.reactivex.rxjava3.core.Completable
|
||||||
import io.reactivex.rxjava3.kotlin.subscribeBy
|
import io.reactivex.rxjava3.kotlin.subscribeBy
|
||||||
|
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||||
|
import org.signal.core.util.logging.Log
|
||||||
|
import org.signal.donations.StripeApi
|
||||||
import org.thoughtcrime.securesms.R
|
import org.thoughtcrime.securesms.R
|
||||||
import org.thoughtcrime.securesms.components.ViewBinderDelegate
|
import org.thoughtcrime.securesms.components.ViewBinderDelegate
|
||||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentComponent
|
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentComponent
|
||||||
|
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.Stripe3DSDialogFragment
|
||||||
import org.thoughtcrime.securesms.databinding.StripePaymentInProgressFragmentBinding
|
import org.thoughtcrime.securesms.databinding.StripePaymentInProgressFragmentBinding
|
||||||
import org.thoughtcrime.securesms.util.LifecycleDisposable
|
import org.thoughtcrime.securesms.util.LifecycleDisposable
|
||||||
import org.thoughtcrime.securesms.util.fragments.requireListener
|
import org.thoughtcrime.securesms.util.fragments.requireListener
|
||||||
|
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||||
|
|
||||||
class StripePaymentInProgressFragment : DialogFragment(R.layout.stripe_payment_in_progress_fragment) {
|
class StripePaymentInProgressFragment : DialogFragment(R.layout.stripe_payment_in_progress_fragment) {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
private val TAG = Log.tag(StripePaymentInProgressFragment::class.java)
|
||||||
|
|
||||||
const val REQUEST_KEY = "REQUEST_KEY"
|
const val REQUEST_KEY = "REQUEST_KEY"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -44,15 +54,11 @@ class StripePaymentInProgressFragment : DialogFragment(R.layout.stripe_payment_i
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
disposables.bindTo(viewLifecycleOwner)
|
|
||||||
disposables += viewModel.state.subscribeBy { stage ->
|
|
||||||
presentUiState(stage)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (savedInstanceState == null) {
|
if (savedInstanceState == null) {
|
||||||
|
viewModel.onBeginNewAction()
|
||||||
when (args.action) {
|
when (args.action) {
|
||||||
StripeAction.PROCESS_NEW_DONATION -> {
|
StripeAction.PROCESS_NEW_DONATION -> {
|
||||||
viewModel.processNewDonation(args.request)
|
viewModel.processNewDonation(args.request, this::handleSecure3dsAction)
|
||||||
}
|
}
|
||||||
StripeAction.UPDATE_SUBSCRIPTION -> {
|
StripeAction.UPDATE_SUBSCRIPTION -> {
|
||||||
viewModel.updateSubscription(args.request)
|
viewModel.updateSubscription(args.request)
|
||||||
|
@ -62,6 +68,11 @@ class StripePaymentInProgressFragment : DialogFragment(R.layout.stripe_payment_i
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
disposables.bindTo(viewLifecycleOwner)
|
||||||
|
disposables += viewModel.state.subscribeBy { stage ->
|
||||||
|
presentUiState(stage)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun presentUiState(stage: StripeStage) {
|
private fun presentUiState(stage: StripeStage) {
|
||||||
|
@ -69,6 +80,7 @@ class StripePaymentInProgressFragment : DialogFragment(R.layout.stripe_payment_i
|
||||||
StripeStage.INIT -> binding.progressCardStatus.setText(R.string.SubscribeFragment__processing_payment)
|
StripeStage.INIT -> binding.progressCardStatus.setText(R.string.SubscribeFragment__processing_payment)
|
||||||
StripeStage.PAYMENT_PIPELINE -> binding.progressCardStatus.setText(R.string.SubscribeFragment__processing_payment)
|
StripeStage.PAYMENT_PIPELINE -> binding.progressCardStatus.setText(R.string.SubscribeFragment__processing_payment)
|
||||||
StripeStage.FAILED -> {
|
StripeStage.FAILED -> {
|
||||||
|
viewModel.onEndAction()
|
||||||
findNavController().popBackStack()
|
findNavController().popBackStack()
|
||||||
setFragmentResult(
|
setFragmentResult(
|
||||||
REQUEST_KEY,
|
REQUEST_KEY,
|
||||||
|
@ -82,6 +94,7 @@ class StripePaymentInProgressFragment : DialogFragment(R.layout.stripe_payment_i
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
StripeStage.COMPLETE -> {
|
StripeStage.COMPLETE -> {
|
||||||
|
viewModel.onEndAction()
|
||||||
findNavController().popBackStack()
|
findNavController().popBackStack()
|
||||||
setFragmentResult(
|
setFragmentResult(
|
||||||
REQUEST_KEY,
|
REQUEST_KEY,
|
||||||
|
@ -97,4 +110,29 @@ class StripePaymentInProgressFragment : DialogFragment(R.layout.stripe_payment_i
|
||||||
StripeStage.CANCELLING -> binding.progressCardStatus.setText(R.string.StripePaymentInProgressFragment__cancelling)
|
StripeStage.CANCELLING -> binding.progressCardStatus.setText(R.string.StripePaymentInProgressFragment__cancelling)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun handleSecure3dsAction(secure3dsAction: StripeApi.Secure3DSAction): Completable {
|
||||||
|
return when (secure3dsAction) {
|
||||||
|
is StripeApi.Secure3DSAction.NotNeeded -> {
|
||||||
|
Log.d(TAG, "No 3DS action required.")
|
||||||
|
Completable.complete()
|
||||||
|
}
|
||||||
|
is StripeApi.Secure3DSAction.ConfirmRequired -> {
|
||||||
|
Log.d(TAG, "3DS action required. Displaying dialog...")
|
||||||
|
Completable.create { emitter ->
|
||||||
|
val listener = FragmentResultListener { _, _ ->
|
||||||
|
emitter.onComplete()
|
||||||
|
}
|
||||||
|
|
||||||
|
parentFragmentManager.setFragmentResultListener(Stripe3DSDialogFragment.REQUEST_KEY, this, listener)
|
||||||
|
|
||||||
|
findNavController().safeNavigate(StripePaymentInProgressFragmentDirections.actionStripePaymentInProgressFragmentToStripe3dsDialogFragment(secure3dsAction.uri))
|
||||||
|
|
||||||
|
emitter.setCancellable {
|
||||||
|
parentFragmentManager.clearFragmentResultListener(Stripe3DSDialogFragment.REQUEST_KEY)
|
||||||
|
}
|
||||||
|
}.subscribeOn(AndroidSchedulers.mainThread()).observeOn(Schedulers.io())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -12,6 +12,7 @@ import io.reactivex.rxjava3.kotlin.plusAssign
|
||||||
import io.reactivex.rxjava3.kotlin.subscribeBy
|
import io.reactivex.rxjava3.kotlin.subscribeBy
|
||||||
import org.signal.core.util.logging.Log
|
import org.signal.core.util.logging.Log
|
||||||
import org.signal.donations.GooglePayPaymentSource
|
import org.signal.donations.GooglePayPaymentSource
|
||||||
|
import org.signal.donations.StripeApi
|
||||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentRepository
|
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentRepository
|
||||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonateToSignalType
|
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonateToSignalType
|
||||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest
|
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest
|
||||||
|
@ -38,43 +39,95 @@ class StripePaymentInProgressViewModel(
|
||||||
|
|
||||||
private val disposables = CompositeDisposable()
|
private val disposables = CompositeDisposable()
|
||||||
private var paymentData: PaymentData? = null
|
private var paymentData: PaymentData? = null
|
||||||
|
private var cardData: StripeApi.CardData? = null
|
||||||
|
|
||||||
override fun onCleared() {
|
override fun onCleared() {
|
||||||
disposables.clear()
|
disposables.clear()
|
||||||
store.dispose()
|
store.dispose()
|
||||||
|
clearPaymentInformation()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun processNewDonation(request: GatewayRequest) {
|
fun onBeginNewAction() {
|
||||||
val paymentData = this.paymentData ?: error("Cannot process new donation without payment data")
|
Preconditions.checkState(!store.state.isInProgress)
|
||||||
this.paymentData = null
|
|
||||||
|
|
||||||
Preconditions.checkState(store.state != StripeStage.PAYMENT_PIPELINE)
|
Log.d(TAG, "Beginning a new action. Ensuring cleared state.", true)
|
||||||
Log.d(TAG, "Proceeding with donation...")
|
disposables.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onEndAction() {
|
||||||
|
Preconditions.checkState(store.state.isTerminal)
|
||||||
|
|
||||||
|
Log.d(TAG, "Ending current state. Clearing state and setting stage to INIT", true)
|
||||||
|
store.update { StripeStage.INIT }
|
||||||
|
disposables.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun processNewDonation(request: GatewayRequest, nextActionHandler: (StripeApi.Secure3DSAction) -> Completable) {
|
||||||
|
Log.d(TAG, "Proceeding with donation...", true)
|
||||||
|
|
||||||
|
val errorSource = when (request.donateToSignalType) {
|
||||||
|
DonateToSignalType.ONE_TIME -> DonationErrorSource.BOOST
|
||||||
|
DonateToSignalType.MONTHLY -> DonationErrorSource.SUBSCRIPTION
|
||||||
|
}
|
||||||
|
|
||||||
|
val paymentSourceProvider: Single<StripeApi.PaymentSource> = resolvePaymentSourceProvider(errorSource)
|
||||||
|
|
||||||
return when (request.donateToSignalType) {
|
return when (request.donateToSignalType) {
|
||||||
DonateToSignalType.MONTHLY -> proceedMonthly(request, paymentData)
|
DonateToSignalType.MONTHLY -> proceedMonthly(request, paymentSourceProvider, nextActionHandler)
|
||||||
DonateToSignalType.ONE_TIME -> proceedOneTime(request, paymentData)
|
DonateToSignalType.ONE_TIME -> proceedOneTime(request, paymentSourceProvider, nextActionHandler)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun resolvePaymentSourceProvider(errorSource: DonationErrorSource): Single<StripeApi.PaymentSource> {
|
||||||
|
val paymentData = this.paymentData
|
||||||
|
val cardData = this.cardData
|
||||||
|
|
||||||
|
return when {
|
||||||
|
paymentData == null && cardData == null -> error("No payment provider available.")
|
||||||
|
paymentData != null && cardData != null -> error("Too many providers available")
|
||||||
|
paymentData != null -> Single.just<StripeApi.PaymentSource>(GooglePayPaymentSource(paymentData))
|
||||||
|
cardData != null -> donationPaymentRepository.createCreditCardPaymentSource(errorSource, cardData)
|
||||||
|
else -> error("This should never happen.")
|
||||||
|
}.doAfterTerminate { clearPaymentInformation() }
|
||||||
|
}
|
||||||
|
|
||||||
fun providePaymentData(paymentData: PaymentData) {
|
fun providePaymentData(paymentData: PaymentData) {
|
||||||
|
requireNoPaymentInformation()
|
||||||
this.paymentData = paymentData
|
this.paymentData = paymentData
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun proceedMonthly(request: GatewayRequest, paymentData: PaymentData) {
|
fun provideCardData(cardData: StripeApi.CardData) {
|
||||||
val ensureSubscriberId = donationPaymentRepository.ensureSubscriberId()
|
requireNoPaymentInformation()
|
||||||
val continueSetup = donationPaymentRepository.continueSubscriptionSetup(GooglePayPaymentSource(paymentData))
|
this.cardData = cardData
|
||||||
val setLevel = donationPaymentRepository.setSubscriptionLevel(request.level.toString())
|
}
|
||||||
|
|
||||||
|
private fun requireNoPaymentInformation() {
|
||||||
|
require(paymentData == null)
|
||||||
|
require(cardData == null)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun clearPaymentInformation() {
|
||||||
|
Log.d(TAG, "Cleared payment information.", true)
|
||||||
|
paymentData = null
|
||||||
|
cardData = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun proceedMonthly(request: GatewayRequest, paymentSourceProvider: Single<StripeApi.PaymentSource>, nextActionHandler: (StripeApi.Secure3DSAction) -> Completable) {
|
||||||
|
val ensureSubscriberId: Completable = donationPaymentRepository.ensureSubscriberId()
|
||||||
|
val createAndConfirmSetupIntent: Single<StripeApi.Secure3DSAction> = paymentSourceProvider.flatMap { donationPaymentRepository.createAndConfirmSetupIntent(it) }
|
||||||
|
val setLevel: Completable = donationPaymentRepository.setSubscriptionLevel(request.level.toString())
|
||||||
|
|
||||||
Log.d(TAG, "Starting subscription payment pipeline...", true)
|
Log.d(TAG, "Starting subscription payment pipeline...", true)
|
||||||
store.update { StripeStage.PAYMENT_PIPELINE }
|
store.update { StripeStage.PAYMENT_PIPELINE }
|
||||||
|
|
||||||
val setup = ensureSubscriberId
|
val setup: Completable = ensureSubscriberId
|
||||||
.andThen(cancelActiveSubscriptionIfNecessary())
|
.andThen(cancelActiveSubscriptionIfNecessary())
|
||||||
.andThen(continueSetup)
|
.andThen(createAndConfirmSetupIntent)
|
||||||
|
.flatMap { secure3DSAction -> nextActionHandler(secure3DSAction).andThen(Single.just(secure3DSAction.paymentMethodId!!)) }
|
||||||
|
.flatMapCompletable { donationPaymentRepository.setDefaultPaymentMethod(it) }
|
||||||
.onErrorResumeNext { Completable.error(DonationError.getPaymentSetupError(DonationErrorSource.SUBSCRIPTION, it)) }
|
.onErrorResumeNext { Completable.error(DonationError.getPaymentSetupError(DonationErrorSource.SUBSCRIPTION, it)) }
|
||||||
|
|
||||||
setup.andThen(setLevel).subscribeBy(
|
disposables += setup.andThen(setLevel).subscribeBy(
|
||||||
onError = { throwable ->
|
onError = { throwable ->
|
||||||
Log.w(TAG, "Failure in subscription payment pipeline...", throwable, true)
|
Log.w(TAG, "Failure in subscription payment pipeline...", throwable, true)
|
||||||
store.update { StripeStage.FAILED }
|
store.update { StripeStage.FAILED }
|
||||||
|
@ -107,10 +160,25 @@ class StripePaymentInProgressViewModel(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun proceedOneTime(request: GatewayRequest, paymentData: PaymentData) {
|
private fun proceedOneTime(
|
||||||
|
request: GatewayRequest,
|
||||||
|
paymentSourceProvider: Single<StripeApi.PaymentSource>,
|
||||||
|
nextActionHandler: (StripeApi.Secure3DSAction) -> Completable
|
||||||
|
) {
|
||||||
Log.w(TAG, "Beginning one-time payment pipeline...", true)
|
Log.w(TAG, "Beginning one-time payment pipeline...", true)
|
||||||
|
|
||||||
donationPaymentRepository.continuePayment(request.fiat, GooglePayPaymentSource(paymentData), Recipient.self().id, null, SubscriptionLevels.BOOST_LEVEL.toLong()).subscribeBy(
|
val amount = request.fiat
|
||||||
|
val recipient = Recipient.self().id
|
||||||
|
val level = SubscriptionLevels.BOOST_LEVEL.toLong()
|
||||||
|
|
||||||
|
val continuePayment: Single<StripeApi.PaymentIntent> = donationPaymentRepository.continuePayment(amount, recipient, level)
|
||||||
|
val intentAndSource: Single<Pair<StripeApi.PaymentIntent, StripeApi.PaymentSource>> = Single.zip(continuePayment, paymentSourceProvider, ::Pair)
|
||||||
|
|
||||||
|
disposables += intentAndSource.flatMapCompletable { (paymentIntent, paymentSource) ->
|
||||||
|
donationPaymentRepository.confirmPayment(paymentSource, paymentIntent, recipient)
|
||||||
|
.flatMapCompletable { nextActionHandler(it) }
|
||||||
|
.andThen(donationPaymentRepository.waitForOneTimeRedemption(amount, paymentIntent, recipient, null, level))
|
||||||
|
}.subscribeBy(
|
||||||
onError = { throwable ->
|
onError = { throwable ->
|
||||||
Log.w(TAG, "Failure in one-time payment pipeline...", throwable, true)
|
Log.w(TAG, "Failure in one-time payment pipeline...", throwable, true)
|
||||||
store.update { StripeStage.FAILED }
|
store.update { StripeStage.FAILED }
|
||||||
|
@ -130,6 +198,8 @@ class StripePaymentInProgressViewModel(
|
||||||
}
|
}
|
||||||
|
|
||||||
fun cancelSubscription() {
|
fun cancelSubscription() {
|
||||||
|
Log.d(TAG, "Beginning cancellation...", true)
|
||||||
|
|
||||||
store.update { StripeStage.CANCELLING }
|
store.update { StripeStage.CANCELLING }
|
||||||
disposables += donationPaymentRepository.cancelActiveSubscription().subscribeBy(
|
disposables += donationPaymentRepository.cancelActiveSubscription().subscribeBy(
|
||||||
onComplete = {
|
onComplete = {
|
||||||
|
@ -147,8 +217,10 @@ class StripePaymentInProgressViewModel(
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateSubscription(request: GatewayRequest) {
|
fun updateSubscription(request: GatewayRequest) {
|
||||||
|
Log.d(TAG, "Beginning subscription update...", true)
|
||||||
|
|
||||||
store.update { StripeStage.PAYMENT_PIPELINE }
|
store.update { StripeStage.PAYMENT_PIPELINE }
|
||||||
cancelActiveSubscriptionIfNecessary().andThen(donationPaymentRepository.setSubscriptionLevel(request.level.toString()))
|
disposables += cancelActiveSubscriptionIfNecessary().andThen(donationPaymentRepository.setSubscriptionLevel(request.level.toString()))
|
||||||
.subscribeBy(
|
.subscribeBy(
|
||||||
onComplete = {
|
onComplete = {
|
||||||
Log.w(TAG, "Completed subscription update", true)
|
Log.w(TAG, "Completed subscription update", true)
|
||||||
|
|
|
@ -5,5 +5,8 @@ enum class StripeStage {
|
||||||
PAYMENT_PIPELINE,
|
PAYMENT_PIPELINE,
|
||||||
CANCELLING,
|
CANCELLING,
|
||||||
FAILED,
|
FAILED,
|
||||||
COMPLETE
|
COMPLETE;
|
||||||
|
|
||||||
|
val isInProgress: Boolean get() = this == PAYMENT_PIPELINE || this == CANCELLING
|
||||||
|
val isTerminal: Boolean get() = this == FAILED || this == COMPLETE
|
||||||
}
|
}
|
||||||
|
|
|
@ -114,4 +114,28 @@
|
||||||
|
|
||||||
</com.google.android.material.textfield.TextInputLayout>
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/continue_button"
|
||||||
|
style="@style/Signal.Widget.Button.Large.Primary"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginHorizontal="@dimen/dsl_settings_gutter"
|
||||||
|
android:enabled="false"
|
||||||
|
android:text="@string/CreditCardFragment__continue"
|
||||||
|
app:layout_constraintBottom_toTopOf="@id/notice"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/notice"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:paddingHorizontal="@dimen/dsl_settings_gutter"
|
||||||
|
android:paddingTop="16dp"
|
||||||
|
android:paddingBottom="20dp"
|
||||||
|
android:textAppearance="@style/Signal.Text.BodyMedium"
|
||||||
|
android:textColor="@color/signal_colorOnSurfaceVariant"
|
||||||
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
|
tools:text="Signal will never sell or trade your information to anyone. More of an explanation if needed." />
|
||||||
|
|
||||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
|
@ -0,0 +1,18 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent">
|
||||||
|
|
||||||
|
<WebView
|
||||||
|
android:id="@+id/web_view"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent" />
|
||||||
|
|
||||||
|
<com.google.android.material.progressindicator.LinearProgressIndicator
|
||||||
|
android:id="@+id/progress"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:indeterminate="true"
|
||||||
|
app:indeterminateAnimationType="disjoint" />
|
||||||
|
</FrameLayout>
|
|
@ -85,6 +85,9 @@
|
||||||
android:name="request"
|
android:name="request"
|
||||||
app:argType="org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest"
|
app:argType="org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest"
|
||||||
app:nullable="false" />
|
app:nullable="false" />
|
||||||
|
<action
|
||||||
|
android:id="@+id/action_stripePaymentInProgressFragment_to_stripe3dsDialogFragment"
|
||||||
|
app:destination="@id/stripe3dsDialogFragment" />
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
||||||
<dialog
|
<dialog
|
||||||
|
@ -100,8 +103,8 @@
|
||||||
|
|
||||||
<argument
|
<argument
|
||||||
android:name="isBoost"
|
android:name="isBoost"
|
||||||
app:argType="boolean"
|
android:defaultValue="false"
|
||||||
android:defaultValue="false" />
|
app:argType="boolean" />
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
||||||
<dialog
|
<dialog
|
||||||
|
@ -122,4 +125,16 @@
|
||||||
app:nullable="false" />
|
app:nullable="false" />
|
||||||
</fragment>
|
</fragment>
|
||||||
|
|
||||||
|
<dialog
|
||||||
|
android:id="@+id/stripe3dsDialogFragment"
|
||||||
|
android:name="org.thoughtcrime.securesms.components.settings.app.subscription.donate.Stripe3DSDialogFragment"
|
||||||
|
android:label="stripe_3ds_dialog_fragment"
|
||||||
|
tools:layout="@layout/stripe_3ds_dialog_fragment">
|
||||||
|
|
||||||
|
<argument
|
||||||
|
android:name="uri"
|
||||||
|
app:argType="android.net.Uri"
|
||||||
|
app:nullable="false" />
|
||||||
|
</dialog>
|
||||||
|
|
||||||
</navigation>
|
</navigation>
|
|
@ -152,6 +152,8 @@
|
||||||
<string name="CreditCardFragment__year_required">Year required</string>
|
<string name="CreditCardFragment__year_required">Year required</string>
|
||||||
<!-- Error displayed under the card expiry text field when the expiry year is invalid -->
|
<!-- Error displayed under the card expiry text field when the expiry year is invalid -->
|
||||||
<string name="CreditCardFragment__invalid_year">Invalid year</string>
|
<string name="CreditCardFragment__invalid_year">Invalid year</string>
|
||||||
|
<!-- Button label to confirm credit card input and proceed with payment -->
|
||||||
|
<string name="CreditCardFragment__continue">Continue</string>
|
||||||
|
|
||||||
<!-- BlockUnblockDialog -->
|
<!-- BlockUnblockDialog -->
|
||||||
<string name="BlockUnblockDialog_block_and_leave_s">Block and leave %1$s?</string>
|
<string name="BlockUnblockDialog_block_and_leave_s">Block and leave %1$s?</string>
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
plugins {
|
plugins {
|
||||||
id 'com.android.library'
|
id 'com.android.library'
|
||||||
id 'kotlin-android'
|
id 'kotlin-android'
|
||||||
|
id 'kotlin-parcelize'
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
|
|
|
@ -0,0 +1,14 @@
|
||||||
|
package org.signal.donations
|
||||||
|
|
||||||
|
import org.json.JSONObject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stripe payment source based off a manually entered credit card.
|
||||||
|
*/
|
||||||
|
class CreditCardPaymentSource(
|
||||||
|
private val payload: JSONObject
|
||||||
|
) : StripeApi.PaymentSource {
|
||||||
|
override fun parameterize(): JSONObject = payload
|
||||||
|
override fun getTokenId(): String = parameterize().getString("id")
|
||||||
|
override fun email(): String? = null
|
||||||
|
}
|
|
@ -10,6 +10,11 @@ class GooglePayPaymentSource(private val paymentData: PaymentData) : StripeApi.P
|
||||||
return paymentMethodJsonData.getJSONObject("tokenizationData")
|
return paymentMethodJsonData.getJSONObject("tokenizationData")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun getTokenId(): String {
|
||||||
|
val serializedToken = parameterize().getString("token").replace("\n", "")
|
||||||
|
return JSONObject(serializedToken).getString("id")
|
||||||
|
}
|
||||||
|
|
||||||
override fun email(): String? {
|
override fun email(): String? {
|
||||||
val jsonData = JSONObject(paymentData.toJson())
|
val jsonData = JSONObject(paymentData.toJson())
|
||||||
return if (jsonData.has("email")) {
|
return if (jsonData.has("email")) {
|
||||||
|
|
|
@ -1,8 +1,12 @@
|
||||||
package org.signal.donations
|
package org.signal.donations
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Parcelable
|
||||||
|
import androidx.annotation.WorkerThread
|
||||||
import io.reactivex.rxjava3.core.Completable
|
import io.reactivex.rxjava3.core.Completable
|
||||||
import io.reactivex.rxjava3.core.Single
|
import io.reactivex.rxjava3.core.Single
|
||||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||||
|
import kotlinx.parcelize.Parcelize
|
||||||
import okhttp3.FormBody
|
import okhttp3.FormBody
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
|
@ -23,6 +27,11 @@ class StripeApi(
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private val TAG = Log.tag(StripeApi::class.java)
|
private val TAG = Log.tag(StripeApi::class.java)
|
||||||
|
|
||||||
|
private val CARD_NUMBER_KEY = "card[number]"
|
||||||
|
private val CARD_MONTH_KEY = "card[exp_month]"
|
||||||
|
private val CARD_YEAR_KEY = "card[exp_year]"
|
||||||
|
private val CARD_CVC_KEY = "card[cvc]"
|
||||||
}
|
}
|
||||||
|
|
||||||
sealed class CreatePaymentIntentResult {
|
sealed class CreatePaymentIntentResult {
|
||||||
|
@ -34,6 +43,11 @@ class StripeApi(
|
||||||
|
|
||||||
data class CreateSetupIntentResult(val setupIntent: SetupIntent)
|
data class CreateSetupIntentResult(val setupIntent: SetupIntent)
|
||||||
|
|
||||||
|
sealed class CreatePaymentSourceFromCardDataResult {
|
||||||
|
data class Success(val paymentSource: PaymentSource) : CreatePaymentSourceFromCardDataResult()
|
||||||
|
data class Failure(val reason: Throwable) : CreatePaymentSourceFromCardDataResult()
|
||||||
|
}
|
||||||
|
|
||||||
fun createSetupIntent(): Single<CreateSetupIntentResult> {
|
fun createSetupIntent(): Single<CreateSetupIntentResult> {
|
||||||
return setupIntentHelper
|
return setupIntentHelper
|
||||||
.fetchSetupIntent()
|
.fetchSetupIntent()
|
||||||
|
@ -41,18 +55,21 @@ class StripeApi(
|
||||||
.subscribeOn(Schedulers.io())
|
.subscribeOn(Schedulers.io())
|
||||||
}
|
}
|
||||||
|
|
||||||
fun confirmSetupIntent(paymentSource: PaymentSource, setupIntent: SetupIntent): Completable = Single.fromCallable {
|
fun confirmSetupIntent(paymentSource: PaymentSource, setupIntent: SetupIntent): Single<Secure3DSAction> {
|
||||||
val paymentMethodId = createPaymentMethodAndParseId(paymentSource)
|
return Single.fromCallable {
|
||||||
|
val paymentMethodId = createPaymentMethodAndParseId(paymentSource)
|
||||||
|
|
||||||
val parameters = mapOf(
|
val parameters = mapOf(
|
||||||
"client_secret" to setupIntent.clientSecret,
|
"client_secret" to setupIntent.clientSecret,
|
||||||
"payment_method" to paymentMethodId
|
"payment_method" to paymentMethodId
|
||||||
)
|
)
|
||||||
|
|
||||||
postForm("setup_intents/${setupIntent.id}/confirm", parameters)
|
val nextAction = postForm("setup_intents/${setupIntent.id}/confirm", parameters).use { response ->
|
||||||
paymentMethodId
|
getNextAction(response)
|
||||||
}.flatMapCompletable {
|
}
|
||||||
setupIntentHelper.setDefaultPaymentMethod(it)
|
|
||||||
|
Secure3DSAction.from(nextAction, paymentMethodId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun createPaymentIntent(price: FiatMoney, level: Long): Single<CreatePaymentIntentResult> {
|
fun createPaymentIntent(price: FiatMoney, level: Long): Single<CreatePaymentIntentResult> {
|
||||||
|
@ -70,16 +87,72 @@ class StripeApi(
|
||||||
}.subscribeOn(Schedulers.io())
|
}.subscribeOn(Schedulers.io())
|
||||||
}
|
}
|
||||||
|
|
||||||
fun confirmPaymentIntent(paymentSource: PaymentSource, paymentIntent: PaymentIntent): Completable = Completable.fromAction {
|
/**
|
||||||
val paymentMethodId = createPaymentMethodAndParseId(paymentSource)
|
* Confirm a PaymentIntent
|
||||||
|
*
|
||||||
|
* This method will create a PaymentMethod with the given PaymentSource and then confirm the
|
||||||
|
* PaymentIntent.
|
||||||
|
*
|
||||||
|
* @return A Secure3DSAction
|
||||||
|
*/
|
||||||
|
fun confirmPaymentIntent(paymentSource: PaymentSource, paymentIntent: PaymentIntent): Single<Secure3DSAction> {
|
||||||
|
return Single.fromCallable {
|
||||||
|
val paymentMethodId = createPaymentMethodAndParseId(paymentSource)
|
||||||
|
|
||||||
val parameters = mutableMapOf(
|
val parameters = mutableMapOf(
|
||||||
"client_secret" to paymentIntent.clientSecret,
|
"client_secret" to paymentIntent.clientSecret,
|
||||||
"payment_method" to paymentMethodId
|
"payment_method" to paymentMethodId
|
||||||
|
)
|
||||||
|
|
||||||
|
val nextAction = postForm("payment_intents/${paymentIntent.id}/confirm", parameters).use { response ->
|
||||||
|
getNextAction(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
Secure3DSAction.from(nextAction)
|
||||||
|
}.subscribeOn(Schedulers.io())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getNextAction(response: Response): Uri {
|
||||||
|
val responseBody = response.body()?.string()
|
||||||
|
val bodyJson = responseBody?.let { JSONObject(it) }
|
||||||
|
return if (bodyJson?.has("next_action") == true && !bodyJson.isNull("next_action")) {
|
||||||
|
val nextAction = bodyJson.getJSONObject("next_action")
|
||||||
|
if (BuildConfig.DEBUG) {
|
||||||
|
Log.d(TAG, "[getNextAction] Next Action found:\n$nextAction")
|
||||||
|
}
|
||||||
|
|
||||||
|
Uri.parse(nextAction.getJSONObject("use_stripe_sdk").getString("stripe_js"))
|
||||||
|
} else {
|
||||||
|
Uri.EMPTY
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createPaymentSourceFromCardData(cardData: CardData): Single<CreatePaymentSourceFromCardDataResult> {
|
||||||
|
return Single.fromCallable<CreatePaymentSourceFromCardDataResult> {
|
||||||
|
CreatePaymentSourceFromCardDataResult.Success(createPaymentSourceFromCardDataSync(cardData))
|
||||||
|
}.onErrorReturn {
|
||||||
|
CreatePaymentSourceFromCardDataResult.Failure(it)
|
||||||
|
}.subscribeOn(Schedulers.io())
|
||||||
|
}
|
||||||
|
|
||||||
|
@WorkerThread
|
||||||
|
private fun createPaymentSourceFromCardDataSync(cardData: CardData): PaymentSource {
|
||||||
|
val parameters: Map<String, String> = mutableMapOf(
|
||||||
|
CARD_NUMBER_KEY to cardData.number,
|
||||||
|
CARD_MONTH_KEY to cardData.month.toString(),
|
||||||
|
CARD_YEAR_KEY to cardData.year.toString(),
|
||||||
|
CARD_CVC_KEY to cardData.cvc.toString()
|
||||||
)
|
)
|
||||||
|
|
||||||
postForm("payment_intents/${paymentIntent.id}/confirm", parameters)
|
postForm("tokens", parameters).use { response ->
|
||||||
}.subscribeOn(Schedulers.io())
|
val body = response.body()
|
||||||
|
if (body != null) {
|
||||||
|
return CreditCardPaymentSource(JSONObject(body.string()))
|
||||||
|
} else {
|
||||||
|
throw StripeError.FailedToCreatePaymentSourceFromCardData
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun createPaymentMethodAndParseId(paymentSource: PaymentSource): String {
|
private fun createPaymentMethodAndParseId(paymentSource: PaymentSource): String {
|
||||||
return createPaymentMethod(paymentSource).use { response ->
|
return createPaymentMethod(paymentSource).use { response ->
|
||||||
|
@ -94,9 +167,9 @@ class StripeApi(
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createPaymentMethod(paymentSource: PaymentSource): Response {
|
private fun createPaymentMethod(paymentSource: PaymentSource): Response {
|
||||||
val tokenizationData = paymentSource.parameterize()
|
val tokenId = paymentSource.getTokenId()
|
||||||
val parameters = mutableMapOf(
|
val parameters = mutableMapOf(
|
||||||
"card[token]" to JSONObject((tokenizationData.get("token") as String).replace("\n", "")).getString("id"),
|
"card[token]" to tokenId,
|
||||||
"type" to "card",
|
"type" to "card",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -366,9 +439,16 @@ class StripeApi(
|
||||||
|
|
||||||
interface SetupIntentHelper {
|
interface SetupIntentHelper {
|
||||||
fun fetchSetupIntent(): Single<SetupIntent>
|
fun fetchSetupIntent(): Single<SetupIntent>
|
||||||
fun setDefaultPaymentMethod(paymentMethodId: String): Completable
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Parcelize
|
||||||
|
data class CardData(
|
||||||
|
val number: String,
|
||||||
|
val month: Int,
|
||||||
|
val year: Int,
|
||||||
|
val cvc: Int
|
||||||
|
) : Parcelable
|
||||||
|
|
||||||
data class PaymentIntent(
|
data class PaymentIntent(
|
||||||
val id: String,
|
val id: String,
|
||||||
val clientSecret: String
|
val clientSecret: String
|
||||||
|
@ -381,6 +461,24 @@ class StripeApi(
|
||||||
|
|
||||||
interface PaymentSource {
|
interface PaymentSource {
|
||||||
fun parameterize(): JSONObject
|
fun parameterize(): JSONObject
|
||||||
|
fun getTokenId(): String
|
||||||
fun email(): String?
|
fun email(): String?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sealed interface Secure3DSAction {
|
||||||
|
data class ConfirmRequired(val uri: Uri, override val paymentMethodId: String?) : Secure3DSAction
|
||||||
|
data class NotNeeded(override val paymentMethodId: String?): Secure3DSAction
|
||||||
|
|
||||||
|
val paymentMethodId: String?
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun from(uri: Uri, paymentMethodId: String? = null): Secure3DSAction {
|
||||||
|
return if (uri == Uri.EMPTY) {
|
||||||
|
NotNeeded(paymentMethodId)
|
||||||
|
} else {
|
||||||
|
ConfirmRequired(uri, paymentMethodId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -2,5 +2,6 @@ package org.signal.donations
|
||||||
|
|
||||||
sealed class StripeError(message: String) : Exception(message) {
|
sealed class StripeError(message: String) : Exception(message) {
|
||||||
object FailedToParsePaymentMethodResponseError : StripeError("Failed to parse payment method response")
|
object FailedToParsePaymentMethodResponseError : StripeError("Failed to parse payment method response")
|
||||||
|
object FailedToCreatePaymentSourceFromCardData : StripeError("Failed to create payment source from card data")
|
||||||
class PostError(val statusCode: Int, val errorCode: String?, val declineCode: StripeDeclineCode?) : StripeError("postForm failed with code: $statusCode. errorCode: $errorCode. declineCode: $declineCode")
|
class PostError(val statusCode: Int, val errorCode: String?, val declineCode: StripeDeclineCode?) : StripeError("postForm failed with code: $statusCode. errorCode: $errorCode. declineCode: $declineCode")
|
||||||
}
|
}
|
||||||
|
|
Ładowanie…
Reference in New Issue