mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-02-22 02:36:55 +00:00
Add basic 3DS support for credit cards.
This commit is contained in:
committed by
Cody Henthorne
parent
c686d33a46
commit
2cfa685ae2
@@ -5,8 +5,10 @@ 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.Observable
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import io.reactivex.rxjava3.disposables.Disposable
|
||||
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.donations.GooglePayApi
|
||||
import org.signal.donations.GooglePayPaymentSource
|
||||
import org.signal.donations.StripeApi
|
||||
import org.thoughtcrime.securesms.badges.gifts.Gifts
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationEvent
|
||||
@@ -165,7 +168,14 @@ class GiftFlowViewModel(
|
||||
|
||||
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,
|
||||
onComplete = {
|
||||
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 paymentData PaymentData from Google Pay that describes the payment method
|
||||
* @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(
|
||||
price: FiatMoney,
|
||||
paymentSource: StripeApi.PaymentSource,
|
||||
badgeRecipient: RecipientId,
|
||||
additionalMessage: String?,
|
||||
badgeLevel: Long
|
||||
): Completable {
|
||||
badgeLevel: Long,
|
||||
): Single<StripeApi.PaymentIntent> {
|
||||
Log.d(TAG, "Creating payment intent for $price...", true)
|
||||
|
||||
return stripeApi.createPaymentIntent(price, badgeLevel)
|
||||
@@ -150,28 +146,26 @@ class DonationPaymentRepository(activity: Activity) : StripeApi.PaymentIntentFet
|
||||
Single.error(DonationError.getPaymentSetupError(errorSource, it))
|
||||
}
|
||||
}
|
||||
.flatMapCompletable { result ->
|
||||
.flatMap { result ->
|
||||
val recipient = Recipient.resolved(badgeRecipient)
|
||||
val errorSource = if (recipient.isSelf) DonationErrorSource.BOOST else DonationErrorSource.GIFT
|
||||
|
||||
Log.d(TAG, "Created payment intent for $price.", true)
|
||||
when (result) {
|
||||
is StripeApi.CreatePaymentIntentResult.AmountIsTooSmall -> Completable.error(DonationError.oneTimeDonationAmountTooSmall(errorSource))
|
||||
is StripeApi.CreatePaymentIntentResult.AmountIsTooLarge -> Completable.error(DonationError.oneTimeDonationAmountTooLarge(errorSource))
|
||||
is StripeApi.CreatePaymentIntentResult.CurrencyIsNotSupported -> Completable.error(DonationError.invalidCurrencyForOneTimeDonation(errorSource))
|
||||
is StripeApi.CreatePaymentIntentResult.Success -> confirmPayment(price, paymentSource, result.paymentIntent, badgeRecipient, additionalMessage, badgeLevel)
|
||||
is StripeApi.CreatePaymentIntentResult.AmountIsTooSmall -> Single.error(DonationError.oneTimeDonationAmountTooSmall(errorSource))
|
||||
is StripeApi.CreatePaymentIntentResult.AmountIsTooLarge -> Single.error(DonationError.oneTimeDonationAmountTooLarge(errorSource))
|
||||
is StripeApi.CreatePaymentIntentResult.CurrencyIsNotSupported -> Single.error(DonationError.invalidCurrencyForOneTimeDonation(errorSource))
|
||||
is StripeApi.CreatePaymentIntentResult.Success -> Single.just(result.paymentIntent)
|
||||
}
|
||||
}.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
fun continueSubscriptionSetup(paymentSource: StripeApi.PaymentSource): Completable {
|
||||
fun createAndConfirmSetupIntent(paymentSource: StripeApi.PaymentSource): Single<StripeApi.Secure3DSAction> {
|
||||
Log.d(TAG, "Continuing subscription setup...", true)
|
||||
return stripeApi.createSetupIntent()
|
||||
.flatMapCompletable { result ->
|
||||
.flatMap { result ->
|
||||
Log.d(TAG, "Retrieved SetupIntent, confirming...", true)
|
||||
stripeApi.confirmSetupIntent(paymentSource, result.setupIntent).doOnComplete {
|
||||
Log.d(TAG, "Confirmed SetupIntent...", true)
|
||||
}
|
||||
stripeApi.confirmSetupIntent(paymentSource, result.setupIntent)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 donationErrorSource: DonationErrorSource = if (isBoost) DonationErrorSource.BOOST else DonationErrorSource.GIFT
|
||||
|
||||
Log.d(TAG, "Confirming payment intent...", true)
|
||||
val confirmPayment = stripeApi.confirmPaymentIntent(paymentSource, paymentIntent).onErrorResumeNext {
|
||||
Completable.error(DonationError.getPaymentSetupError(donationErrorSource, it))
|
||||
}
|
||||
return stripeApi.confirmPaymentIntent(paymentSource, paymentIntent)
|
||||
.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 donationReceiptRecord = if (isBoost) {
|
||||
@@ -273,7 +283,7 @@ class DonationPaymentRepository(activity: Activity) : StripeApi.PaymentIntentFet
|
||||
}
|
||||
}
|
||||
|
||||
return confirmPayment.andThen(waitOnRedemption)
|
||||
return waitOnRedemption
|
||||
}
|
||||
|
||||
fun setSubscriptionLevel(subscriptionLevel: String): Completable {
|
||||
@@ -405,11 +415,12 @@ class DonationPaymentRepository(activity: Activity) : StripeApi.PaymentIntentFet
|
||||
}
|
||||
}
|
||||
|
||||
override fun setDefaultPaymentMethod(paymentMethodId: String): Completable {
|
||||
Log.d(TAG, "Setting default payment method via Signal service...")
|
||||
fun setDefaultPaymentMethod(paymentMethodId: String): Completable {
|
||||
return Single.fromCallable {
|
||||
Log.d(TAG, "Getting the subscriber...")
|
||||
SignalStore.donationsValues().requireSubscriber()
|
||||
}.flatMap {
|
||||
Log.d(TAG, "Setting default payment method via Signal service...")
|
||||
Single.fromCallable {
|
||||
ApplicationDependencies
|
||||
.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 {
|
||||
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.app.subscription.DonationPaymentComponent
|
||||
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.GatewayResponse
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewaySelectorBottomSheet
|
||||
@@ -143,6 +145,11 @@ class DonateToSignalFragment : DSLSettingsFragment(
|
||||
handleStripeActionResult(result)
|
||||
}
|
||||
|
||||
setFragmentResultListener(CreditCardFragment.REQUEST_KEY) { _, bundle ->
|
||||
val result: CreditCardResult = bundle.getParcelable(CreditCardFragment.REQUEST_KEY)!!
|
||||
handleCreditCardResult(result)
|
||||
}
|
||||
|
||||
val recyclerView = this.recyclerView!!
|
||||
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) {
|
||||
when (result.status) {
|
||||
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
|
||||
|
||||
import org.signal.donations.StripeApi
|
||||
|
||||
data class CreditCardFormState(
|
||||
val focusedField: FocusedField = FocusedField.NONE,
|
||||
val number: String = "",
|
||||
@@ -12,4 +14,13 @@ data class CreditCardFormState(
|
||||
EXPIRATION,
|
||||
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.view.View
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.core.widget.addTextChangedListener
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.setFragmentResult
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.navigation.fragment.navArgs
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.ViewBinderDelegate
|
||||
import org.thoughtcrime.securesms.databinding.CreditCardFragmentBinding
|
||||
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
|
||||
import org.thoughtcrime.securesms.util.LifecycleDisposable
|
||||
import org.thoughtcrime.securesms.util.ViewUtil
|
||||
|
||||
@@ -22,6 +26,9 @@ class CreditCardFragment : Fragment(R.layout.credit_card_fragment) {
|
||||
private val lifecycleDisposable = LifecycleDisposable()
|
||||
|
||||
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 = {
|
||||
viewModel.onNumberChanged(it?.toString() ?: "")
|
||||
})
|
||||
@@ -46,10 +53,27 @@ class CreditCardFragment : Fragment(R.layout.credit_card_fragment) {
|
||||
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 += viewModel.state.subscribe {
|
||||
// TODO [alex] -- type
|
||||
// TODO [alex] -- all fields valid
|
||||
presentContinue(it)
|
||||
presentCardNumberWrapper(it.numberValidity)
|
||||
presentCardExpiryWrapper(it.expirationValidity)
|
||||
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) {
|
||||
val errorState = when (validity) {
|
||||
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 {
|
||||
val REQUEST_KEY = "card.data"
|
||||
|
||||
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 expirationValidity: CreditCardExpirationValidator.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.kotlin.plusAssign
|
||||
import io.reactivex.rxjava3.processors.BehaviorProcessor
|
||||
import org.signal.donations.StripeApi
|
||||
import org.thoughtcrime.securesms.util.rx.RxStore
|
||||
import java.util.Calendar
|
||||
|
||||
@@ -76,6 +77,10 @@ class CreditCardViewModel : ViewModel() {
|
||||
updateFocus(CreditCardFormState.FocusedField.CODE, isFocused)
|
||||
}
|
||||
|
||||
fun getCardData(): StripeApi.CardData {
|
||||
return formStore.state.toCardData()
|
||||
}
|
||||
|
||||
private fun updateFocus(
|
||||
newFocusedField: CreditCardFormState.FocusedField,
|
||||
isFocused: Boolean
|
||||
|
||||
@@ -7,21 +7,31 @@ import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import androidx.fragment.app.FragmentResultListener
|
||||
import androidx.fragment.app.setFragmentResult
|
||||
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.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.components.ViewBinderDelegate
|
||||
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.util.LifecycleDisposable
|
||||
import org.thoughtcrime.securesms.util.fragments.requireListener
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
|
||||
class StripePaymentInProgressFragment : DialogFragment(R.layout.stripe_payment_in_progress_fragment) {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(StripePaymentInProgressFragment::class.java)
|
||||
|
||||
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?) {
|
||||
disposables.bindTo(viewLifecycleOwner)
|
||||
disposables += viewModel.state.subscribeBy { stage ->
|
||||
presentUiState(stage)
|
||||
}
|
||||
|
||||
if (savedInstanceState == null) {
|
||||
viewModel.onBeginNewAction()
|
||||
when (args.action) {
|
||||
StripeAction.PROCESS_NEW_DONATION -> {
|
||||
viewModel.processNewDonation(args.request)
|
||||
viewModel.processNewDonation(args.request, this::handleSecure3dsAction)
|
||||
}
|
||||
StripeAction.UPDATE_SUBSCRIPTION -> {
|
||||
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) {
|
||||
@@ -69,6 +80,7 @@ class StripePaymentInProgressFragment : DialogFragment(R.layout.stripe_payment_i
|
||||
StripeStage.INIT -> binding.progressCardStatus.setText(R.string.SubscribeFragment__processing_payment)
|
||||
StripeStage.PAYMENT_PIPELINE -> binding.progressCardStatus.setText(R.string.SubscribeFragment__processing_payment)
|
||||
StripeStage.FAILED -> {
|
||||
viewModel.onEndAction()
|
||||
findNavController().popBackStack()
|
||||
setFragmentResult(
|
||||
REQUEST_KEY,
|
||||
@@ -82,6 +94,7 @@ class StripePaymentInProgressFragment : DialogFragment(R.layout.stripe_payment_i
|
||||
)
|
||||
}
|
||||
StripeStage.COMPLETE -> {
|
||||
viewModel.onEndAction()
|
||||
findNavController().popBackStack()
|
||||
setFragmentResult(
|
||||
REQUEST_KEY,
|
||||
@@ -97,4 +110,29 @@ class StripePaymentInProgressFragment : DialogFragment(R.layout.stripe_payment_i
|
||||
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 org.signal.core.util.logging.Log
|
||||
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.donate.DonateToSignalType
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest
|
||||
@@ -38,43 +39,95 @@ class StripePaymentInProgressViewModel(
|
||||
|
||||
private val disposables = CompositeDisposable()
|
||||
private var paymentData: PaymentData? = null
|
||||
private var cardData: StripeApi.CardData? = null
|
||||
|
||||
override fun onCleared() {
|
||||
disposables.clear()
|
||||
store.dispose()
|
||||
clearPaymentInformation()
|
||||
}
|
||||
|
||||
fun processNewDonation(request: GatewayRequest) {
|
||||
val paymentData = this.paymentData ?: error("Cannot process new donation without payment data")
|
||||
this.paymentData = null
|
||||
fun onBeginNewAction() {
|
||||
Preconditions.checkState(!store.state.isInProgress)
|
||||
|
||||
Preconditions.checkState(store.state != StripeStage.PAYMENT_PIPELINE)
|
||||
Log.d(TAG, "Proceeding with donation...")
|
||||
Log.d(TAG, "Beginning a new action. Ensuring cleared state.", true)
|
||||
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) {
|
||||
DonateToSignalType.MONTHLY -> proceedMonthly(request, paymentData)
|
||||
DonateToSignalType.ONE_TIME -> proceedOneTime(request, paymentData)
|
||||
DonateToSignalType.MONTHLY -> proceedMonthly(request, paymentSourceProvider, nextActionHandler)
|
||||
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) {
|
||||
requireNoPaymentInformation()
|
||||
this.paymentData = paymentData
|
||||
}
|
||||
|
||||
private fun proceedMonthly(request: GatewayRequest, paymentData: PaymentData) {
|
||||
val ensureSubscriberId = donationPaymentRepository.ensureSubscriberId()
|
||||
val continueSetup = donationPaymentRepository.continueSubscriptionSetup(GooglePayPaymentSource(paymentData))
|
||||
val setLevel = donationPaymentRepository.setSubscriptionLevel(request.level.toString())
|
||||
fun provideCardData(cardData: StripeApi.CardData) {
|
||||
requireNoPaymentInformation()
|
||||
this.cardData = cardData
|
||||
}
|
||||
|
||||
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)
|
||||
store.update { StripeStage.PAYMENT_PIPELINE }
|
||||
|
||||
val setup = ensureSubscriberId
|
||||
val setup: Completable = ensureSubscriberId
|
||||
.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)) }
|
||||
|
||||
setup.andThen(setLevel).subscribeBy(
|
||||
disposables += setup.andThen(setLevel).subscribeBy(
|
||||
onError = { throwable ->
|
||||
Log.w(TAG, "Failure in subscription payment pipeline...", throwable, true)
|
||||
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)
|
||||
|
||||
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 ->
|
||||
Log.w(TAG, "Failure in one-time payment pipeline...", throwable, true)
|
||||
store.update { StripeStage.FAILED }
|
||||
@@ -130,6 +198,8 @@ class StripePaymentInProgressViewModel(
|
||||
}
|
||||
|
||||
fun cancelSubscription() {
|
||||
Log.d(TAG, "Beginning cancellation...", true)
|
||||
|
||||
store.update { StripeStage.CANCELLING }
|
||||
disposables += donationPaymentRepository.cancelActiveSubscription().subscribeBy(
|
||||
onComplete = {
|
||||
@@ -147,8 +217,10 @@ class StripePaymentInProgressViewModel(
|
||||
}
|
||||
|
||||
fun updateSubscription(request: GatewayRequest) {
|
||||
Log.d(TAG, "Beginning subscription update...", true)
|
||||
|
||||
store.update { StripeStage.PAYMENT_PIPELINE }
|
||||
cancelActiveSubscriptionIfNecessary().andThen(donationPaymentRepository.setSubscriptionLevel(request.level.toString()))
|
||||
disposables += cancelActiveSubscriptionIfNecessary().andThen(donationPaymentRepository.setSubscriptionLevel(request.level.toString()))
|
||||
.subscribeBy(
|
||||
onComplete = {
|
||||
Log.w(TAG, "Completed subscription update", true)
|
||||
|
||||
@@ -5,5 +5,8 @@ enum class StripeStage {
|
||||
PAYMENT_PIPELINE,
|
||||
CANCELLING,
|
||||
FAILED,
|
||||
COMPLETE
|
||||
COMPLETE;
|
||||
|
||||
val isInProgress: Boolean get() = this == PAYMENT_PIPELINE || this == CANCELLING
|
||||
val isTerminal: Boolean get() = this == FAILED || this == COMPLETE
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user