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

Wyświetl plik

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

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

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

Wyświetl plik

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

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -114,4 +114,28 @@
</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>

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"
app:argType="org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest"
app:nullable="false" />
<action
android:id="@+id/action_stripePaymentInProgressFragment_to_stripe3dsDialogFragment"
app:destination="@id/stripe3dsDialogFragment" />
</dialog>
<dialog
@ -100,8 +103,8 @@
<argument
android:name="isBoost"
app:argType="boolean"
android:defaultValue="false" />
android:defaultValue="false"
app:argType="boolean" />
</dialog>
<dialog
@ -122,4 +125,16 @@
app:nullable="false" />
</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>

Wyświetl plik

@ -152,6 +152,8 @@
<string name="CreditCardFragment__year_required">Year required</string>
<!-- Error displayed under the card expiry text field when the expiry year is invalid -->
<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 -->
<string name="BlockUnblockDialog_block_and_leave_s">Block and leave %1$s?</string>

Wyświetl plik

@ -1,6 +1,7 @@
plugins {
id 'com.android.library'
id 'kotlin-android'
id 'kotlin-parcelize'
}
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")
}
override fun getTokenId(): String {
val serializedToken = parameterize().getString("token").replace("\n", "")
return JSONObject(serializedToken).getString("id")
}
override fun email(): String? {
val jsonData = JSONObject(paymentData.toJson())
return if (jsonData.has("email")) {

Wyświetl plik

@ -1,8 +1,12 @@
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.Single
import io.reactivex.rxjava3.schedulers.Schedulers
import kotlinx.parcelize.Parcelize
import okhttp3.FormBody
import okhttp3.OkHttpClient
import okhttp3.Request
@ -23,6 +27,11 @@ class StripeApi(
companion object {
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 {
@ -34,6 +43,11 @@ class StripeApi(
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> {
return setupIntentHelper
.fetchSetupIntent()
@ -41,18 +55,21 @@ class StripeApi(
.subscribeOn(Schedulers.io())
}
fun confirmSetupIntent(paymentSource: PaymentSource, setupIntent: SetupIntent): Completable = Single.fromCallable {
val paymentMethodId = createPaymentMethodAndParseId(paymentSource)
fun confirmSetupIntent(paymentSource: PaymentSource, setupIntent: SetupIntent): Single<Secure3DSAction> {
return Single.fromCallable {
val paymentMethodId = createPaymentMethodAndParseId(paymentSource)
val parameters = mapOf(
"client_secret" to setupIntent.clientSecret,
"payment_method" to paymentMethodId
)
val parameters = mapOf(
"client_secret" to setupIntent.clientSecret,
"payment_method" to paymentMethodId
)
postForm("setup_intents/${setupIntent.id}/confirm", parameters)
paymentMethodId
}.flatMapCompletable {
setupIntentHelper.setDefaultPaymentMethod(it)
val nextAction = postForm("setup_intents/${setupIntent.id}/confirm", parameters).use { response ->
getNextAction(response)
}
Secure3DSAction.from(nextAction, paymentMethodId)
}
}
fun createPaymentIntent(price: FiatMoney, level: Long): Single<CreatePaymentIntentResult> {
@ -70,16 +87,72 @@ class StripeApi(
}.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(
"client_secret" to paymentIntent.clientSecret,
"payment_method" to paymentMethodId
val parameters = mutableMapOf(
"client_secret" to paymentIntent.clientSecret,
"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)
}.subscribeOn(Schedulers.io())
postForm("tokens", parameters).use { response ->
val body = response.body()
if (body != null) {
return CreditCardPaymentSource(JSONObject(body.string()))
} else {
throw StripeError.FailedToCreatePaymentSourceFromCardData
}
}
}
private fun createPaymentMethodAndParseId(paymentSource: PaymentSource): String {
return createPaymentMethod(paymentSource).use { response ->
@ -94,9 +167,9 @@ class StripeApi(
}
private fun createPaymentMethod(paymentSource: PaymentSource): Response {
val tokenizationData = paymentSource.parameterize()
val tokenId = paymentSource.getTokenId()
val parameters = mutableMapOf(
"card[token]" to JSONObject((tokenizationData.get("token") as String).replace("\n", "")).getString("id"),
"card[token]" to tokenId,
"type" to "card",
)
@ -366,9 +439,16 @@ class StripeApi(
interface SetupIntentHelper {
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(
val id: String,
val clientSecret: String
@ -381,6 +461,24 @@ class StripeApi(
interface PaymentSource {
fun parameterize(): JSONObject
fun getTokenId(): 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) {
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")
}