Add support for Credit Card 3DS during subscriptions.

main
Alex Hart 2022-11-03 12:29:09 -03:00 zatwierdzone przez Cody Henthorne
rodzic 844480786e
commit d1df069669
17 zmienionych plików z 429 dodań i 100 usunięć

Wyświetl plik

@ -19,6 +19,7 @@ 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.signal.donations.StripeApi
import org.signal.donations.StripeIntentAccessor
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
@ -168,8 +169,8 @@ class GiftFlowViewModel(
store.update { it.copy(stage = GiftFlowState.Stage.PAYMENT_PIPELINE) } store.update { it.copy(stage = GiftFlowState.Stage.PAYMENT_PIPELINE) }
val continuePayment: Single<StripeApi.PaymentIntent> = donationPaymentRepository.continuePayment(gift.price, recipient, gift.level) val continuePayment: Single<StripeIntentAccessor> = donationPaymentRepository.continuePayment(gift.price, recipient, gift.level)
val intentAndSource: Single<Pair<StripeApi.PaymentIntent, StripeApi.PaymentSource>> = Single.zip(continuePayment, Single.just(GooglePayPaymentSource(paymentData)), ::Pair) val intentAndSource: Single<Pair<StripeIntentAccessor, StripeApi.PaymentSource>> = Single.zip(continuePayment, Single.just(GooglePayPaymentSource(paymentData)), ::Pair)
disposables += intentAndSource.flatMapCompletable { (paymentIntent, paymentSource) -> disposables += intentAndSource.flatMapCompletable { (paymentIntent, paymentSource) ->
donationPaymentRepository.confirmPayment(paymentSource, paymentIntent, recipient) donationPaymentRepository.confirmPayment(paymentSource, paymentIntent, recipient)

Wyświetl plik

@ -10,6 +10,8 @@ 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.StripeApi import org.signal.donations.StripeApi
import org.signal.donations.StripeIntentAccessor
import org.signal.donations.json.StripeIntentStatus
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
import org.thoughtcrime.securesms.database.RecipientDatabase import org.thoughtcrime.securesms.database.RecipientDatabase
@ -133,7 +135,7 @@ class DonationPaymentRepository(activity: Activity) : StripeApi.PaymentIntentFet
price: FiatMoney, price: FiatMoney,
badgeRecipient: RecipientId, badgeRecipient: RecipientId,
badgeLevel: Long, badgeLevel: Long,
): Single<StripeApi.PaymentIntent> { ): Single<StripeIntentAccessor> {
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)
@ -207,7 +209,7 @@ class DonationPaymentRepository(activity: Activity) : StripeApi.PaymentIntentFet
fun confirmPayment( fun confirmPayment(
paymentSource: StripeApi.PaymentSource, paymentSource: StripeApi.PaymentSource,
paymentIntent: StripeApi.PaymentIntent, paymentIntent: StripeIntentAccessor,
badgeRecipient: RecipientId badgeRecipient: RecipientId
): Single<StripeApi.Secure3DSAction> { ): Single<StripeApi.Secure3DSAction> {
val isBoost = badgeRecipient == Recipient.self().id val isBoost = badgeRecipient == Recipient.self().id
@ -222,7 +224,7 @@ class DonationPaymentRepository(activity: Activity) : StripeApi.PaymentIntentFet
fun waitForOneTimeRedemption( fun waitForOneTimeRedemption(
price: FiatMoney, price: FiatMoney,
paymentIntent: StripeApi.PaymentIntent, paymentIntent: StripeIntentAccessor,
badgeRecipient: RecipientId, badgeRecipient: RecipientId,
additionalMessage: String?, additionalMessage: String?,
badgeLevel: Long, badgeLevel: Long,
@ -382,7 +384,7 @@ class DonationPaymentRepository(activity: Activity) : StripeApi.PaymentIntentFet
} }
} }
override fun fetchPaymentIntent(price: FiatMoney, level: Long): Single<StripeApi.PaymentIntent> { override fun fetchPaymentIntent(price: FiatMoney, level: Long): Single<StripeIntentAccessor> {
Log.d(TAG, "Fetching payment intent from Signal service for $price... (Locale.US minimum precision: ${price.minimumUnitPrecisionString})") Log.d(TAG, "Fetching payment intent from Signal service for $price... (Locale.US minimum precision: ${price.minimumUnitPrecisionString})")
return Single return Single
.fromCallable { .fromCallable {
@ -392,13 +394,17 @@ class DonationPaymentRepository(activity: Activity) : StripeApi.PaymentIntentFet
} }
.flatMap(ServiceResponse<SubscriptionClientSecret>::flattenResult) .flatMap(ServiceResponse<SubscriptionClientSecret>::flattenResult)
.map { .map {
StripeApi.PaymentIntent(it.id, it.clientSecret) StripeIntentAccessor(
objectType = StripeIntentAccessor.ObjectType.PAYMENT_INTENT,
intentId = it.id,
intentClientSecret = it.clientSecret
)
}.doOnSuccess { }.doOnSuccess {
Log.d(TAG, "Got payment intent from Signal service!") Log.d(TAG, "Got payment intent from Signal service!")
} }
} }
override fun fetchSetupIntent(): Single<StripeApi.SetupIntent> { override fun fetchSetupIntent(): Single<StripeIntentAccessor> {
Log.d(TAG, "Fetching setup intent from Signal service...") Log.d(TAG, "Fetching setup intent from Signal service...")
return Single.fromCallable { SignalStore.donationsValues().requireSubscriber() } return Single.fromCallable { SignalStore.donationsValues().requireSubscriber() }
.flatMap { .flatMap {
@ -409,12 +415,34 @@ class DonationPaymentRepository(activity: Activity) : StripeApi.PaymentIntentFet
} }
} }
.flatMap(ServiceResponse<SubscriptionClientSecret>::flattenResult) .flatMap(ServiceResponse<SubscriptionClientSecret>::flattenResult)
.map { StripeApi.SetupIntent(it.id, it.clientSecret) } .map {
StripeIntentAccessor(
objectType = StripeIntentAccessor.ObjectType.SETUP_INTENT,
intentId = it.id,
intentClientSecret = it.clientSecret
)
}
.doOnSuccess { .doOnSuccess {
Log.d(TAG, "Got setup intent from Signal service!") Log.d(TAG, "Got setup intent from Signal service!")
} }
} }
// We need to get the status and payment id from the intent.
fun getStatusAndPaymentMethodId(stripeIntentAccessor: StripeIntentAccessor): Single<StatusAndPaymentMethodId> {
return Single.fromCallable {
when (stripeIntentAccessor.objectType) {
StripeIntentAccessor.ObjectType.NONE -> StatusAndPaymentMethodId(StripeIntentStatus.SUCCEEDED, null)
StripeIntentAccessor.ObjectType.PAYMENT_INTENT -> stripeApi.getPaymentIntent(stripeIntentAccessor).let {
StatusAndPaymentMethodId(it.status, it.paymentMethod)
}
StripeIntentAccessor.ObjectType.SETUP_INTENT -> stripeApi.getSetupIntent(stripeIntentAccessor).let {
StatusAndPaymentMethodId(it.status, it.paymentMethod)
}
}
}
}
fun setDefaultPaymentMethod(paymentMethodId: String): Completable { fun setDefaultPaymentMethod(paymentMethodId: String): Completable {
return Single.fromCallable { return Single.fromCallable {
Log.d(TAG, "Getting the subscriber...") Log.d(TAG, "Getting the subscriber...")
@ -441,6 +469,11 @@ class DonationPaymentRepository(activity: Activity) : StripeApi.PaymentIntentFet
} }
} }
data class StatusAndPaymentMethodId(
val status: StripeIntentStatus,
val paymentMethod: String?
)
companion object { companion object {
private val TAG = Log.tag(DonationPaymentRepository::class.java) private val TAG = Log.tag(DonationPaymentRepository::class.java)
} }

Wyświetl plik

@ -1,5 +1,6 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.donate package org.thoughtcrime.securesms.components.settings.app.subscription.donate
import android.content.DialogInterface
import android.text.SpannableStringBuilder import android.text.SpannableStringBuilder
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
@ -92,6 +93,8 @@ class DonateToSignalFragment : DSLSettingsFragment(
} }
} }
private var errorDialog: DialogInterface? = null
private val args: DonateToSignalFragmentArgs by navArgs() private val args: DonateToSignalFragmentArgs by navArgs()
private val viewModel: DonateToSignalViewModel by viewModels(factoryProducer = { private val viewModel: DonateToSignalViewModel by viewModels(factoryProducer = {
DonateToSignalViewModel.Factory(args.startType) DonateToSignalViewModel.Factory(args.startType)
@ -462,7 +465,7 @@ class DonateToSignalFragment : DSLSettingsFragment(
} }
private fun registerGooglePayCallback() { private fun registerGooglePayCallback() {
donationPaymentComponent.googlePayResultPublisher.subscribeBy( disposables += donationPaymentComponent.googlePayResultPublisher.subscribeBy(
onNext = { paymentResult -> onNext = { paymentResult ->
viewModel.consumeGatewayRequestForGooglePay()?.let { viewModel.consumeGatewayRequestForGooglePay()?.let {
donationPaymentComponent.donationPaymentRepository.onActivityResult( donationPaymentComponent.donationPaymentRepository.onActivityResult(
@ -478,16 +481,21 @@ class DonateToSignalFragment : DSLSettingsFragment(
} }
private fun showErrorDialog(throwable: Throwable) { private fun showErrorDialog(throwable: Throwable) {
if (errorDialog != null) {
Log.d(TAG, "Already displaying an error dialog. Skipping.", throwable, true)
} else {
Log.d(TAG, "Displaying donation error dialog.", true) Log.d(TAG, "Displaying donation error dialog.", true)
DonationErrorDialogs.show( errorDialog = DonationErrorDialogs.show(
requireContext(), throwable, requireContext(), throwable,
object : DonationErrorDialogs.DialogCallback() { object : DonationErrorDialogs.DialogCallback() {
override fun onDialogDismissed() { override fun onDialogDismissed() {
errorDialog = null
findNavController().popBackStack() findNavController().popBackStack()
} }
} }
) )
} }
}
private fun startAnimationAboveSelectedBoost(view: View) { private fun startAnimationAboveSelectedBoost(view: View) {
val animationView = getAnimationContainer(view) val animationView = getAnimationContainer(view)

Wyświetl plik

@ -8,9 +8,11 @@ import android.view.View
import android.webkit.WebSettings import android.webkit.WebSettings
import android.webkit.WebView import android.webkit.WebView
import android.webkit.WebViewClient import android.webkit.WebViewClient
import androidx.core.os.bundleOf
import androidx.fragment.app.DialogFragment import androidx.fragment.app.DialogFragment
import androidx.fragment.app.setFragmentResult import androidx.fragment.app.setFragmentResult
import androidx.navigation.fragment.navArgs import androidx.navigation.fragment.navArgs
import org.signal.donations.StripeIntentAccessor
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.Stripe3dsDialogFragmentBinding import org.thoughtcrime.securesms.databinding.Stripe3dsDialogFragmentBinding
@ -23,7 +25,6 @@ class Stripe3DSDialogFragment : DialogFragment(R.layout.stripe_3ds_dialog_fragme
companion object { companion object {
const val REQUEST_KEY = "stripe_3ds_dialog_fragment" 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) { val binding by ViewBinderDelegate(Stripe3dsDialogFragmentBinding::bind) {
@ -33,6 +34,8 @@ class Stripe3DSDialogFragment : DialogFragment(R.layout.stripe_3ds_dialog_fragme
val args: Stripe3DSDialogFragmentArgs by navArgs() val args: Stripe3DSDialogFragmentArgs by navArgs()
var result: Bundle? = null
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setStyle(STYLE_NO_FRAME, R.style.Signal_DayNight_Dialog_FullScreen) setStyle(STYLE_NO_FRAME, R.style.Signal_DayNight_Dialog_FullScreen)
@ -47,7 +50,9 @@ class Stripe3DSDialogFragment : DialogFragment(R.layout.stripe_3ds_dialog_fragme
} }
override fun onDismiss(dialog: DialogInterface) { override fun onDismiss(dialog: DialogInterface) {
setFragmentResult(REQUEST_KEY, Bundle()) val result = this.result
this.result = null
setFragmentResult(REQUEST_KEY, result ?: Bundle())
} }
private inner class Stripe3DSWebClient : WebViewClient() { private inner class Stripe3DSWebClient : WebViewClient() {
@ -61,7 +66,10 @@ class Stripe3DSDialogFragment : DialogFragment(R.layout.stripe_3ds_dialog_fragme
} }
override fun onPageFinished(view: WebView?, url: String?) { override fun onPageFinished(view: WebView?, url: String?) {
if (url == STRIPE_3DS_COMPLETE) { if (url?.startsWith(args.returnUri.toString()) == true) {
val stripeIntentAccessor = StripeIntentAccessor.fromUri(url)
result = bundleOf(REQUEST_KEY to stripeIntentAccessor)
dismissAllowingStateLoss() dismissAllowingStateLoss()
} }
} }

Wyświetl plik

@ -13,11 +13,12 @@ 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.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Completable import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.kotlin.subscribeBy import io.reactivex.rxjava3.kotlin.subscribeBy
import io.reactivex.rxjava3.schedulers.Schedulers import io.reactivex.rxjava3.schedulers.Schedulers
import org.signal.core.util.logging.Log import org.signal.core.util.logging.Log
import org.signal.donations.StripeApi import org.signal.donations.StripeApi
import org.signal.donations.StripeIntentAccessor
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
@ -111,22 +112,27 @@ class StripePaymentInProgressFragment : DialogFragment(R.layout.stripe_payment_i
} }
} }
private fun handleSecure3dsAction(secure3dsAction: StripeApi.Secure3DSAction): Completable { private fun handleSecure3dsAction(secure3dsAction: StripeApi.Secure3DSAction): Single<StripeIntentAccessor> {
return when (secure3dsAction) { return when (secure3dsAction) {
is StripeApi.Secure3DSAction.NotNeeded -> { is StripeApi.Secure3DSAction.NotNeeded -> {
Log.d(TAG, "No 3DS action required.") Log.d(TAG, "No 3DS action required.")
Completable.complete() Single.just(StripeIntentAccessor.NO_ACTION_REQUIRED)
} }
is StripeApi.Secure3DSAction.ConfirmRequired -> { is StripeApi.Secure3DSAction.ConfirmRequired -> {
Log.d(TAG, "3DS action required. Displaying dialog...") Log.d(TAG, "3DS action required. Displaying dialog...")
Completable.create { emitter -> Single.create<StripeIntentAccessor> { emitter ->
val listener = FragmentResultListener { _, _ -> val listener = FragmentResultListener { _, bundle ->
emitter.onComplete() val result: StripeIntentAccessor? = bundle.getParcelable(Stripe3DSDialogFragment.REQUEST_KEY)
if (result != null) {
emitter.onSuccess(result)
} else {
emitter.onError(Exception("User did not complete 3DS Authorization."))
}
} }
parentFragmentManager.setFragmentResultListener(Stripe3DSDialogFragment.REQUEST_KEY, this, listener) parentFragmentManager.setFragmentResultListener(Stripe3DSDialogFragment.REQUEST_KEY, this, listener)
findNavController().safeNavigate(StripePaymentInProgressFragmentDirections.actionStripePaymentInProgressFragmentToStripe3dsDialogFragment(secure3dsAction.uri)) findNavController().safeNavigate(StripePaymentInProgressFragmentDirections.actionStripePaymentInProgressFragmentToStripe3dsDialogFragment(secure3dsAction.uri, secure3dsAction.returnUri))
emitter.setCancellable { emitter.setCancellable {
parentFragmentManager.clearFragmentResultListener(Stripe3DSDialogFragment.REQUEST_KEY) parentFragmentManager.clearFragmentResultListener(Stripe3DSDialogFragment.REQUEST_KEY)

Wyświetl plik

@ -13,6 +13,7 @@ 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.signal.donations.StripeApi
import org.signal.donations.StripeIntentAccessor
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
@ -62,7 +63,7 @@ class StripePaymentInProgressViewModel(
disposables.clear() disposables.clear()
} }
fun processNewDonation(request: GatewayRequest, nextActionHandler: (StripeApi.Secure3DSAction) -> Completable) { fun processNewDonation(request: GatewayRequest, nextActionHandler: (StripeApi.Secure3DSAction) -> Single<StripeIntentAccessor>) {
Log.d(TAG, "Proceeding with donation...", true) Log.d(TAG, "Proceeding with donation...", true)
val errorSource = when (request.donateToSignalType) { val errorSource = when (request.donateToSignalType) {
@ -112,7 +113,7 @@ class StripePaymentInProgressViewModel(
cardData = null cardData = null
} }
private fun proceedMonthly(request: GatewayRequest, paymentSourceProvider: Single<StripeApi.PaymentSource>, nextActionHandler: (StripeApi.Secure3DSAction) -> Completable) { private fun proceedMonthly(request: GatewayRequest, paymentSourceProvider: Single<StripeApi.PaymentSource>, nextActionHandler: (StripeApi.Secure3DSAction) -> Single<StripeIntentAccessor>) {
val ensureSubscriberId: Completable = donationPaymentRepository.ensureSubscriberId() val ensureSubscriberId: Completable = donationPaymentRepository.ensureSubscriberId()
val createAndConfirmSetupIntent: Single<StripeApi.Secure3DSAction> = paymentSourceProvider.flatMap { donationPaymentRepository.createAndConfirmSetupIntent(it) } val createAndConfirmSetupIntent: Single<StripeApi.Secure3DSAction> = paymentSourceProvider.flatMap { donationPaymentRepository.createAndConfirmSetupIntent(it) }
val setLevel: Completable = donationPaymentRepository.setSubscriptionLevel(request.level.toString()) val setLevel: Completable = donationPaymentRepository.setSubscriptionLevel(request.level.toString())
@ -123,7 +124,11 @@ class StripePaymentInProgressViewModel(
val setup: Completable = ensureSubscriberId val setup: Completable = ensureSubscriberId
.andThen(cancelActiveSubscriptionIfNecessary()) .andThen(cancelActiveSubscriptionIfNecessary())
.andThen(createAndConfirmSetupIntent) .andThen(createAndConfirmSetupIntent)
.flatMap { secure3DSAction -> nextActionHandler(secure3DSAction).andThen(Single.just(secure3DSAction.paymentMethodId!!)) } .flatMap { secure3DSAction ->
nextActionHandler(secure3DSAction)
.flatMap { secure3DSResult -> donationPaymentRepository.getStatusAndPaymentMethodId(secure3DSResult) }
.map { (_, paymentMethod) -> paymentMethod ?: secure3DSAction.paymentMethodId!! }
}
.flatMapCompletable { donationPaymentRepository.setDefaultPaymentMethod(it) } .flatMapCompletable { donationPaymentRepository.setDefaultPaymentMethod(it) }
.onErrorResumeNext { Completable.error(DonationError.getPaymentSetupError(DonationErrorSource.SUBSCRIPTION, it)) } .onErrorResumeNext { Completable.error(DonationError.getPaymentSetupError(DonationErrorSource.SUBSCRIPTION, it)) }
@ -163,7 +168,7 @@ class StripePaymentInProgressViewModel(
private fun proceedOneTime( private fun proceedOneTime(
request: GatewayRequest, request: GatewayRequest,
paymentSourceProvider: Single<StripeApi.PaymentSource>, paymentSourceProvider: Single<StripeApi.PaymentSource>,
nextActionHandler: (StripeApi.Secure3DSAction) -> Completable nextActionHandler: (StripeApi.Secure3DSAction) -> Single<StripeIntentAccessor>
) { ) {
Log.w(TAG, "Beginning one-time payment pipeline...", true) Log.w(TAG, "Beginning one-time payment pipeline...", true)
@ -171,13 +176,14 @@ class StripePaymentInProgressViewModel(
val recipient = Recipient.self().id val recipient = Recipient.self().id
val level = SubscriptionLevels.BOOST_LEVEL.toLong() val level = SubscriptionLevels.BOOST_LEVEL.toLong()
val continuePayment: Single<StripeApi.PaymentIntent> = donationPaymentRepository.continuePayment(amount, recipient, level) val continuePayment: Single<StripeIntentAccessor> = donationPaymentRepository.continuePayment(amount, recipient, level)
val intentAndSource: Single<Pair<StripeApi.PaymentIntent, StripeApi.PaymentSource>> = Single.zip(continuePayment, paymentSourceProvider, ::Pair) val intentAndSource: Single<Pair<StripeIntentAccessor, StripeApi.PaymentSource>> = Single.zip(continuePayment, paymentSourceProvider, ::Pair)
disposables += intentAndSource.flatMapCompletable { (paymentIntent, paymentSource) -> disposables += intentAndSource.flatMapCompletable { (paymentIntent, paymentSource) ->
donationPaymentRepository.confirmPayment(paymentSource, paymentIntent, recipient) donationPaymentRepository.confirmPayment(paymentSource, paymentIntent, recipient)
.flatMapCompletable { nextActionHandler(it) } .flatMap { nextActionHandler(it) }
.andThen(donationPaymentRepository.waitForOneTimeRedemption(amount, paymentIntent, recipient, null, level)) .flatMap { donationPaymentRepository.getStatusAndPaymentMethodId(it) }
.flatMapCompletable { donationPaymentRepository.waitForOneTimeRedemption(amount, paymentIntent, recipient, null, level) }
}.subscribeBy( }.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)

Wyświetl plik

@ -7,7 +7,7 @@ import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting; import androidx.annotation.VisibleForTesting;
import org.signal.core.util.logging.Log; import org.signal.core.util.logging.Log;
import org.signal.donations.StripeApi; import org.signal.donations.StripeIntentAccessor;
import org.signal.libsignal.zkgroup.InvalidInputException; import org.signal.libsignal.zkgroup.InvalidInputException;
import org.signal.libsignal.zkgroup.VerificationFailedException; import org.signal.libsignal.zkgroup.VerificationFailedException;
import org.signal.libsignal.zkgroup.receipts.ClientZkReceiptOperations; import org.signal.libsignal.zkgroup.receipts.ClientZkReceiptOperations;
@ -55,7 +55,7 @@ public class BoostReceiptRequestResponseJob extends BaseJob {
private final String paymentIntentId; private final String paymentIntentId;
private final long badgeLevel; private final long badgeLevel;
private static BoostReceiptRequestResponseJob createJob(StripeApi.PaymentIntent paymentIntent, DonationErrorSource donationErrorSource, long badgeLevel) { private static BoostReceiptRequestResponseJob createJob(StripeIntentAccessor paymentIntent, DonationErrorSource donationErrorSource, long badgeLevel) {
return new BoostReceiptRequestResponseJob( return new BoostReceiptRequestResponseJob(
new Parameters new Parameters
.Builder() .Builder()
@ -65,13 +65,13 @@ public class BoostReceiptRequestResponseJob extends BaseJob {
.setMaxAttempts(Parameters.UNLIMITED) .setMaxAttempts(Parameters.UNLIMITED)
.build(), .build(),
null, null,
paymentIntent.getId(), paymentIntent.getIntentId(),
donationErrorSource, donationErrorSource,
badgeLevel badgeLevel
); );
} }
public static JobManager.Chain createJobChainForBoost(@NonNull StripeApi.PaymentIntent paymentIntent) { public static JobManager.Chain createJobChainForBoost(@NonNull StripeIntentAccessor paymentIntent) {
BoostReceiptRequestResponseJob requestReceiptJob = createJob(paymentIntent, DonationErrorSource.BOOST, Long.parseLong(SubscriptionLevels.BOOST_LEVEL)); BoostReceiptRequestResponseJob requestReceiptJob = createJob(paymentIntent, DonationErrorSource.BOOST, Long.parseLong(SubscriptionLevels.BOOST_LEVEL));
DonationReceiptRedemptionJob redeemReceiptJob = DonationReceiptRedemptionJob.createJobForBoost(); DonationReceiptRedemptionJob redeemReceiptJob = DonationReceiptRedemptionJob.createJobForBoost();
RefreshOwnProfileJob refreshOwnProfileJob = RefreshOwnProfileJob.forBoost(); RefreshOwnProfileJob refreshOwnProfileJob = RefreshOwnProfileJob.forBoost();
@ -84,7 +84,7 @@ public class BoostReceiptRequestResponseJob extends BaseJob {
.then(multiDeviceProfileContentUpdateJob); .then(multiDeviceProfileContentUpdateJob);
} }
public static JobManager.Chain createJobChainForGift(@NonNull StripeApi.PaymentIntent paymentIntent, public static JobManager.Chain createJobChainForGift(@NonNull StripeIntentAccessor paymentIntent,
@NonNull RecipientId recipientId, @NonNull RecipientId recipientId,
@Nullable String additionalMessage, @Nullable String additionalMessage,
long badgeLevel) long badgeLevel)

Wyświetl plik

@ -135,6 +135,11 @@
android:name="uri" android:name="uri"
app:argType="android.net.Uri" app:argType="android.net.Uri"
app:nullable="false" /> app:nullable="false" />
<argument
android:name="return_uri"
app:argType="android.net.Uri"
app:nullable="false" />
</dialog> </dialog>
</navigation> </navigation>

Wyświetl plik

@ -40,6 +40,17 @@ dependencies {
implementation libs.androidx.annotation implementation libs.androidx.annotation
implementation libs.androidx.appcompat implementation libs.androidx.appcompat
implementation libs.kotlin.stdlib.jdk8
implementation libs.kotlin.reflect
implementation libs.jackson.module.kotlin
implementation libs.jackson.core
testImplementation testLibs.junit.junit
testImplementation testLibs.assertj.core
testImplementation (testLibs.robolectric.robolectric) {
exclude group: 'com.google.protobuf', module: 'protobuf-java'
}
api libs.google.play.services.wallet api libs.google.play.services.wallet
api libs.square.okhttp3 api libs.square.okhttp3
api libs.rxjava3.rxjava api libs.rxjava3.rxjava

Wyświetl plik

@ -3,7 +3,9 @@ package org.signal.donations
import android.net.Uri import android.net.Uri
import android.os.Parcelable import android.os.Parcelable
import androidx.annotation.WorkerThread import androidx.annotation.WorkerThread
import io.reactivex.rxjava3.core.Completable import com.fasterxml.jackson.module.kotlin.jsonMapper
import com.fasterxml.jackson.module.kotlin.kotlinModule
import com.fasterxml.jackson.module.kotlin.readValue
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 kotlinx.parcelize.Parcelize
@ -15,6 +17,8 @@ import okio.ByteString
import org.json.JSONObject import org.json.JSONObject
import org.signal.core.util.logging.Log 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.json.StripePaymentIntent
import org.signal.donations.json.StripeSetupIntent
import java.math.BigDecimal import java.math.BigDecimal
import java.util.Locale import java.util.Locale
@ -25,6 +29,10 @@ class StripeApi(
private val okHttpClient: OkHttpClient private val okHttpClient: OkHttpClient
) { ) {
private val objectMapper = jsonMapper {
addModule(kotlinModule())
}
companion object { companion object {
private val TAG = Log.tag(StripeApi::class.java) private val TAG = Log.tag(StripeApi::class.java)
@ -32,16 +40,18 @@ class StripeApi(
private val CARD_MONTH_KEY = "card[exp_month]" private val CARD_MONTH_KEY = "card[exp_month]"
private val CARD_YEAR_KEY = "card[exp_year]" private val CARD_YEAR_KEY = "card[exp_year]"
private val CARD_CVC_KEY = "card[cvc]" private val CARD_CVC_KEY = "card[cvc]"
private const val RETURN_URL_3DS = "sgnlpay://3DS"
} }
sealed class CreatePaymentIntentResult { sealed class CreatePaymentIntentResult {
data class AmountIsTooSmall(val amount: FiatMoney) : CreatePaymentIntentResult() data class AmountIsTooSmall(val amount: FiatMoney) : CreatePaymentIntentResult()
data class AmountIsTooLarge(val amount: FiatMoney) : CreatePaymentIntentResult() data class AmountIsTooLarge(val amount: FiatMoney) : CreatePaymentIntentResult()
data class CurrencyIsNotSupported(val currencyCode: String) : CreatePaymentIntentResult() data class CurrencyIsNotSupported(val currencyCode: String) : CreatePaymentIntentResult()
data class Success(val paymentIntent: PaymentIntent) : CreatePaymentIntentResult() data class Success(val paymentIntent: StripeIntentAccessor) : CreatePaymentIntentResult()
} }
data class CreateSetupIntentResult(val setupIntent: SetupIntent) data class CreateSetupIntentResult(val setupIntent: StripeIntentAccessor)
sealed class CreatePaymentSourceFromCardDataResult { sealed class CreatePaymentSourceFromCardDataResult {
data class Success(val paymentSource: PaymentSource) : CreatePaymentSourceFromCardDataResult() data class Success(val paymentSource: PaymentSource) : CreatePaymentSourceFromCardDataResult()
@ -55,20 +65,21 @@ class StripeApi(
.subscribeOn(Schedulers.io()) .subscribeOn(Schedulers.io())
} }
fun confirmSetupIntent(paymentSource: PaymentSource, setupIntent: SetupIntent): Single<Secure3DSAction> { fun confirmSetupIntent(paymentSource: PaymentSource, setupIntent: StripeIntentAccessor): Single<Secure3DSAction> {
return Single.fromCallable { return Single.fromCallable {
val paymentMethodId = createPaymentMethodAndParseId(paymentSource) val paymentMethodId = createPaymentMethodAndParseId(paymentSource)
val parameters = mapOf( val parameters = mapOf(
"client_secret" to setupIntent.clientSecret, "client_secret" to setupIntent.intentClientSecret,
"payment_method" to paymentMethodId "payment_method" to paymentMethodId,
"return_url" to RETURN_URL_3DS
) )
val nextAction = postForm("setup_intents/${setupIntent.id}/confirm", parameters).use { response -> val (nextActionUri, returnUri) = postForm("setup_intents/${setupIntent.intentId}/confirm", parameters).use { response ->
getNextAction(response) getNextAction(response)
} }
Secure3DSAction.from(nextAction, paymentMethodId) Secure3DSAction.from(nextActionUri, returnUri, paymentMethodId)
} }
} }
@ -95,24 +106,49 @@ class StripeApi(
* *
* @return A Secure3DSAction * @return A Secure3DSAction
*/ */
fun confirmPaymentIntent(paymentSource: PaymentSource, paymentIntent: PaymentIntent): Single<Secure3DSAction> { fun confirmPaymentIntent(paymentSource: PaymentSource, paymentIntent: StripeIntentAccessor): Single<Secure3DSAction> {
return Single.fromCallable { return Single.fromCallable {
val paymentMethodId = createPaymentMethodAndParseId(paymentSource) val paymentMethodId = createPaymentMethodAndParseId(paymentSource)
val parameters = mutableMapOf( val parameters = mutableMapOf(
"client_secret" to paymentIntent.clientSecret, "client_secret" to paymentIntent.intentClientSecret,
"payment_method" to paymentMethodId "payment_method" to paymentMethodId,
"return_url" to RETURN_URL_3DS
) )
val nextAction = postForm("payment_intents/${paymentIntent.id}/confirm", parameters).use { response -> val (nextActionUri, returnUri) = postForm("payment_intents/${paymentIntent.intentId}/confirm", parameters).use { response ->
getNextAction(response) getNextAction(response)
} }
Secure3DSAction.from(nextAction) Secure3DSAction.from(nextActionUri, returnUri)
}.subscribeOn(Schedulers.io()) }.subscribeOn(Schedulers.io())
} }
private fun getNextAction(response: Response): Uri { /**
* Retrieve the setup intent pointed to by the given accessor.
*/
fun getSetupIntent(stripeIntentAccessor: StripeIntentAccessor): StripeSetupIntent {
return when (stripeIntentAccessor.objectType) {
StripeIntentAccessor.ObjectType.SETUP_INTENT -> get("setup_intents/${stripeIntentAccessor.intentId}?client_secret=${stripeIntentAccessor.intentClientSecret}").use {
objectMapper.readValue(it.body()!!.string())
}
else -> error("Unsupported type")
}
}
/**
* Retrieve the payment intent pointed to by the given accessor.
*/
fun getPaymentIntent(stripeIntentAccessor: StripeIntentAccessor): StripePaymentIntent {
return when (stripeIntentAccessor.objectType) {
StripeIntentAccessor.ObjectType.PAYMENT_INTENT -> get("payment_intents/${stripeIntentAccessor.intentId}?client_secret=${stripeIntentAccessor.intentClientSecret}").use {
objectMapper.readValue(it.body()!!.string())
}
else -> error("Unsupported type")
}
}
private fun getNextAction(response: Response): Pair<Uri, Uri> {
val responseBody = response.body()?.string() val responseBody = response.body()?.string()
val bodyJson = responseBody?.let { JSONObject(it) } val bodyJson = responseBody?.let { JSONObject(it) }
return if (bodyJson?.has("next_action") == true && !bodyJson.isNull("next_action")) { return if (bodyJson?.has("next_action") == true && !bodyJson.isNull("next_action")) {
@ -121,9 +157,13 @@ class StripeApi(
Log.d(TAG, "[getNextAction] Next Action found:\n$nextAction") Log.d(TAG, "[getNextAction] Next Action found:\n$nextAction")
} }
Uri.parse(nextAction.getJSONObject("use_stripe_sdk").getString("stripe_js")) val redirectToUrl = nextAction.getJSONObject("redirect_to_url")
val nextActionUri = redirectToUrl.getString("url")
val returnUri = redirectToUrl.getString("return_url")
Uri.parse(nextActionUri) to Uri.parse(returnUri)
} else { } else {
Uri.EMPTY Uri.EMPTY to Uri.EMPTY
} }
} }
@ -176,20 +216,34 @@ class StripeApi(
return postForm("payment_methods", parameters) return postForm("payment_methods", parameters)
} }
private fun get(endpoint: String): Response {
val request = getRequestBuilder(endpoint).get().build()
val response = okHttpClient.newCall(request).execute()
return checkResponseForErrors(response)
}
private fun postForm(endpoint: String, parameters: Map<String, String>): Response { private fun postForm(endpoint: String, parameters: Map<String, String>): Response {
val formBodyBuilder = FormBody.Builder() val formBodyBuilder = FormBody.Builder()
parameters.forEach { (k, v) -> parameters.forEach { (k, v) ->
formBodyBuilder.add(k, v) formBodyBuilder.add(k, v)
} }
val request = Request.Builder() val request = getRequestBuilder(endpoint)
.url("${configuration.baseUrl}/$endpoint")
.addHeader("Authorization", "Basic ${ByteString.encodeUtf8("${configuration.publishableKey}:").base64()}")
.post(formBodyBuilder.build()) .post(formBodyBuilder.build())
.build() .build()
val response = okHttpClient.newCall(request).execute() val response = okHttpClient.newCall(request).execute()
return checkResponseForErrors(response)
}
private fun getRequestBuilder(endpoint: String): Request.Builder {
return Request.Builder()
.url("${configuration.baseUrl}/$endpoint")
.addHeader("Authorization", "Basic ${ByteString.encodeUtf8("${configuration.publishableKey}:").base64()}")
}
private fun checkResponseForErrors(response: Response): Response {
if (response.isSuccessful) { if (response.isSuccessful) {
return response return response
} else { } else {
@ -437,11 +491,11 @@ class StripeApi(
fun fetchPaymentIntent( fun fetchPaymentIntent(
price: FiatMoney, price: FiatMoney,
level: Long level: Long
): Single<PaymentIntent> ): Single<StripeIntentAccessor>
} }
interface SetupIntentHelper { interface SetupIntentHelper {
fun fetchSetupIntent(): Single<SetupIntent> fun fetchSetupIntent(): Single<StripeIntentAccessor>
} }
@Parcelize @Parcelize
@ -452,16 +506,6 @@ class StripeApi(
val cvc: Int val cvc: Int
) : Parcelable ) : Parcelable
data class PaymentIntent(
val id: String,
val clientSecret: String
)
data class SetupIntent(
val id: String,
val clientSecret: String
)
interface PaymentSource { interface PaymentSource {
fun parameterize(): JSONObject fun parameterize(): JSONObject
fun getTokenId(): String fun getTokenId(): String
@ -469,19 +513,24 @@ class StripeApi(
} }
sealed interface Secure3DSAction { sealed interface Secure3DSAction {
data class ConfirmRequired(val uri: Uri, override val paymentMethodId: String?) : Secure3DSAction data class ConfirmRequired(val uri: Uri, val returnUri: Uri, override val paymentMethodId: String?) : Secure3DSAction
data class NotNeeded(override val paymentMethodId: String?) : Secure3DSAction data class NotNeeded(override val paymentMethodId: String?) : Secure3DSAction
val paymentMethodId: String? val paymentMethodId: String?
companion object { companion object {
fun from(uri: Uri, paymentMethodId: String? = null): Secure3DSAction { fun from(
uri: Uri,
returnUri: Uri,
paymentMethodId: String? = null
): Secure3DSAction {
return if (uri == Uri.EMPTY) { return if (uri == Uri.EMPTY) {
NotNeeded(paymentMethodId) NotNeeded(paymentMethodId)
} else { } else {
ConfirmRequired(uri, paymentMethodId) ConfirmRequired(uri, returnUri, paymentMethodId)
} }
} }
} }
} }
} }

Wyświetl plik

@ -0,0 +1,54 @@
package org.signal.donations
import android.net.Uri
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
/**
* An object which wraps the necessary information to access a SetupIntent or PaymentIntent
* from the Stripe API
*/
@Parcelize
data class StripeIntentAccessor(
val objectType: ObjectType,
val intentId: String,
val intentClientSecret: String
) : Parcelable {
enum class ObjectType {
NONE,
PAYMENT_INTENT,
SETUP_INTENT
}
companion object {
/**
* noActionRequired is a safe default for when there was no 3DS required,
* in order to continue a reactive payment chain.
*/
val NO_ACTION_REQUIRED = StripeIntentAccessor(ObjectType.NONE,"", "")
private const val KEY_PAYMENT_INTENT = "payment_intent"
private const val KEY_PAYMENT_INTENT_CLIENT_SECRET = "payment_intent_client_secret"
private const val KEY_SETUP_INTENT = "setup_intent"
private const val KEY_SETUP_INTENT_CLIENT_SECRET = "setup_intent_client_secret"
fun fromUri(uri: String): StripeIntentAccessor {
val parsedUri = Uri.parse(uri)
return if (parsedUri.queryParameterNames.contains(KEY_PAYMENT_INTENT)) {
StripeIntentAccessor(
ObjectType.PAYMENT_INTENT,
parsedUri.getQueryParameter(KEY_PAYMENT_INTENT)!!,
parsedUri.getQueryParameter(KEY_PAYMENT_INTENT_CLIENT_SECRET)!!
)
} else {
StripeIntentAccessor(
ObjectType.SETUP_INTENT,
parsedUri.getQueryParameter(KEY_SETUP_INTENT)!!,
parsedUri.getQueryParameter(KEY_SETUP_INTENT_CLIENT_SECRET)!!
)
}
}
}
}

Wyświetl plik

@ -0,0 +1,27 @@
package org.signal.donations.json
import com.fasterxml.jackson.annotation.JsonCreator
/**
* Stripe intent status, from:
*
* https://stripe.com/docs/api/setup_intents/object?lang=curl#setup_intent_object-status
* https://stripe.com/docs/api/payment_intents/object?lang=curl#payment_intent_object-status
*
* Note: REQUIRES_CAPTURE is only ever valid for a SetupIntent
*/
enum class StripeIntentStatus(private val code: String) {
REQUIRES_PAYMENT_METHOD("requires_payment_method"),
REQUIRES_CONFIRMATION("requires_confirmation"),
REQUIRES_ACTION("requires_action"),
REQUIRES_CAPTURE("requires_capture"),
PROCESSING("processing"),
CANCELED("canceled"),
SUCCEEDED("succeeded");
companion object {
@JvmStatic
@JsonCreator
fun fromCode(code: String): StripeIntentStatus = StripeIntentStatus.values().first { it.code == code }
}
}

Wyświetl plik

@ -0,0 +1,17 @@
package org.signal.donations.json
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import com.fasterxml.jackson.annotation.JsonProperty
/**
* Represents a Stripe API PaymentIntent object.
*
* See: https://stripe.com/docs/api/payment_intents/object
*/
@JsonIgnoreProperties(ignoreUnknown = true)
data class StripePaymentIntent(
@JsonProperty("id") val id: String,
@JsonProperty("client_secret") val clientSecret: String,
@JsonProperty("status") val status: StripeIntentStatus,
@JsonProperty("payment_method") val paymentMethod: String?
)

Wyświetl plik

@ -0,0 +1,18 @@
package org.signal.donations.json
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import com.fasterxml.jackson.annotation.JsonProperty
/**
* Represents a Stripe API SetupIntent object.
*
* See: https://stripe.com/docs/api/setup_intents/object
*/
@JsonIgnoreProperties(ignoreUnknown = true)
data class StripeSetupIntent(
@JsonProperty("id") val id: String,
@JsonProperty("client_secret") val clientSecret: String,
@JsonProperty("status") val status: StripeIntentStatus,
@JsonProperty("payment_method") val paymentMethod: String?,
@JsonProperty("customer") val customer: String?
)

Wyświetl plik

@ -0,0 +1,40 @@
package donations
import android.app.Application
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.signal.donations.StripeIntentAccessor
@RunWith(RobolectricTestRunner::class)
@Config(application = Application::class)
class StripeIntentAccessorTest {
companion object {
private const val PAYMENT_INTENT_DATA = "pi_123"
private const val PAYMENT_INTENT_SECRET_DATA = "pisc_456"
private const val SETUP_INTENT_DATA = "si_123"
private const val SETUP_INTENT_SECRET_DATA = "sisc_456"
private const val PAYMENT_RESULT = "sgnlpay://3DS?payment_intent=$PAYMENT_INTENT_DATA&payment_intent_client_secret=$PAYMENT_INTENT_SECRET_DATA"
private const val SETUP_RESULT = "sgnlpay://3DS?setup_intent=$SETUP_INTENT_DATA&setup_intent_client_secret=$SETUP_INTENT_SECRET_DATA"
}
@Test
fun `Given a URL with payment data, when I fromUri, then I expect a Secure3DSResult with matching data`() {
val expected = StripeIntentAccessor(StripeIntentAccessor.ObjectType.PAYMENT_INTENT, PAYMENT_INTENT_DATA, PAYMENT_INTENT_SECRET_DATA)
val result = StripeIntentAccessor.fromUri(PAYMENT_RESULT)
assertEquals(expected, result)
}
@Test
fun `Given a URL with setup data, when I fromUri, then I expect a Secure3DSResult with matching data`() {
val expected = StripeIntentAccessor(StripeIntentAccessor.ObjectType.SETUP_INTENT, SETUP_INTENT_DATA, SETUP_INTENT_SECRET_DATA)
val result = StripeIntentAccessor.fromUri(SETUP_RESULT)
assertEquals(expected, result)
}
}

Wyświetl plik

@ -0,0 +1,70 @@
package donations
import android.app.Application
import com.fasterxml.jackson.module.kotlin.jsonMapper
import com.fasterxml.jackson.module.kotlin.kotlinModule
import com.fasterxml.jackson.module.kotlin.readValue
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.signal.donations.json.StripeIntentStatus
import org.signal.donations.json.StripeSetupIntent
@RunWith(RobolectricTestRunner::class)
@Config(application = Application::class, manifest = Config.NONE)
class StripeSetupIntentTest {
companion object {
private const val TEST_JSON = """
{
"id": "seti_1LyzgK2eZvKYlo2C3AhgI5IC",
"object": "setup_intent",
"application": null,
"cancellation_reason": null,
"client_secret": "seti_1LyzgK2eZvKYlo2C3AhgI5IC_secret_MiQXAjP1ZBdORqQWNuJOcLqk9570HkA",
"created": 1667229224,
"customer": "cus_Fh6d95jDS2fVSL",
"description": null,
"flow_directions": null,
"last_setup_error": null,
"latest_attempt": null,
"livemode": false,
"mandate": null,
"metadata": {},
"next_action": null,
"on_behalf_of": null,
"payment_method": "pm_sldalskdjhfalskjdhf",
"payment_method_options": {
"card": {
"mandate_options": null,
"network": null,
"request_three_d_secure": "automatic"
}
},
"payment_method_types": [
"card"
],
"redaction": null,
"single_use_mandate": null,
"status": "requires_payment_method",
"usage": "off_session"
}
"""
}
@Test
fun `Given TEST_DATA, when I readValue, then I expect properly set fields`() {
val mapper = jsonMapper {
addModule(kotlinModule())
}
val intent = mapper.readValue<StripeSetupIntent>(TEST_JSON)
assertEquals(intent.id, "seti_1LyzgK2eZvKYlo2C3AhgI5IC")
assertEquals(intent.clientSecret, "seti_1LyzgK2eZvKYlo2C3AhgI5IC_secret_MiQXAjP1ZBdORqQWNuJOcLqk9570HkA")
assertEquals(intent.paymentMethod, "pm_sldalskdjhfalskjdhf")
assertEquals(intent.status, StripeIntentStatus.REQUIRES_PAYMENT_METHOD)
assertEquals(intent.customer, "cus_Fh6d95jDS2fVSL")
}
}

Wyświetl plik

@ -1,24 +0,0 @@
package com.example.imageeditor.app
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.Assert.*
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
@Test
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("com.example.imageeditor.app", appContext.packageName)
}
}