Add basic 3DS support for credit cards.

main
Alex Hart 2022-10-25 16:59:52 -03:00 zatwierdzone przez Cody Henthorne
rodzic c686d33a46
commit 2cfa685ae2
21 zmienionych plików z 543 dodań i 72 usunięć

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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