From d1df0696696dde51d8a1256655050df86fdb5298 Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Thu, 3 Nov 2022 12:29:09 -0300 Subject: [PATCH] Add support for Credit Card 3DS during subscriptions. --- .../badges/gifts/flow/GiftFlowViewModel.kt | 5 +- .../subscription/DonationPaymentRepository.kt | 47 +++++-- .../donate/DonateToSignalFragment.kt | 26 ++-- .../donate/Stripe3DSDialogFragment.kt | 14 ++- .../stripe/StripePaymentInProgressFragment.kt | 20 +-- .../StripePaymentInProgressViewModel.kt | 22 ++-- .../jobs/BoostReceiptRequestResponseJob.java | 10 +- .../main/res/navigation/donate_to_signal.xml | 5 + donations/lib/build.gradle | 11 ++ .../java/org/signal/donations/StripeApi.kt | 119 ++++++++++++------ .../signal/donations/StripeIntentAccessor.kt | 54 ++++++++ .../donations/json/StripeIntentStatus.kt | 27 ++++ .../donations/json/StripePaymentIntent.kt | 17 +++ .../donations/json/StripeSetupIntent.kt | 18 +++ .../donations/StripeIntentAccessorTest.kt | 40 ++++++ .../java/donations/StripeSetupIntentTest.kt | 70 +++++++++++ .../app/ExampleInstrumentedTest.kt | 24 ---- 17 files changed, 429 insertions(+), 100 deletions(-) create mode 100644 donations/lib/src/main/java/org/signal/donations/StripeIntentAccessor.kt create mode 100644 donations/lib/src/main/java/org/signal/donations/json/StripeIntentStatus.kt create mode 100644 donations/lib/src/main/java/org/signal/donations/json/StripePaymentIntent.kt create mode 100644 donations/lib/src/main/java/org/signal/donations/json/StripeSetupIntent.kt create mode 100644 donations/lib/src/test/java/donations/StripeIntentAccessorTest.kt create mode 100644 donations/lib/src/test/java/donations/StripeSetupIntentTest.kt delete mode 100644 image-editor/app/src/androidTest/java/com/example/imageeditor/app/ExampleInstrumentedTest.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/GiftFlowViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/GiftFlowViewModel.kt index b3aaad27c..c40440f6f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/GiftFlowViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/GiftFlowViewModel.kt @@ -19,6 +19,7 @@ import org.signal.core.util.money.FiatMoney import org.signal.donations.GooglePayApi import org.signal.donations.GooglePayPaymentSource import org.signal.donations.StripeApi +import org.signal.donations.StripeIntentAccessor import org.thoughtcrime.securesms.badges.gifts.Gifts import org.thoughtcrime.securesms.badges.models.Badge import org.thoughtcrime.securesms.components.settings.app.subscription.DonationEvent @@ -168,8 +169,8 @@ class GiftFlowViewModel( store.update { it.copy(stage = GiftFlowState.Stage.PAYMENT_PIPELINE) } - val continuePayment: Single = donationPaymentRepository.continuePayment(gift.price, recipient, gift.level) - val intentAndSource: Single> = Single.zip(continuePayment, Single.just(GooglePayPaymentSource(paymentData)), ::Pair) + val continuePayment: Single = donationPaymentRepository.continuePayment(gift.price, recipient, gift.level) + val intentAndSource: Single> = Single.zip(continuePayment, Single.just(GooglePayPaymentSource(paymentData)), ::Pair) disposables += intentAndSource.flatMapCompletable { (paymentIntent, paymentSource) -> donationPaymentRepository.confirmPayment(paymentSource, paymentIntent, recipient) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/DonationPaymentRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/DonationPaymentRepository.kt index cec996675..b7658d92c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/DonationPaymentRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/DonationPaymentRepository.kt @@ -10,6 +10,8 @@ import org.signal.core.util.logging.Log import org.signal.core.util.money.FiatMoney import org.signal.donations.GooglePayApi 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.DonationErrorSource import org.thoughtcrime.securesms.database.RecipientDatabase @@ -133,7 +135,7 @@ class DonationPaymentRepository(activity: Activity) : StripeApi.PaymentIntentFet price: FiatMoney, badgeRecipient: RecipientId, badgeLevel: Long, - ): Single { + ): Single { Log.d(TAG, "Creating payment intent for $price...", true) return stripeApi.createPaymentIntent(price, badgeLevel) @@ -207,7 +209,7 @@ class DonationPaymentRepository(activity: Activity) : StripeApi.PaymentIntentFet fun confirmPayment( paymentSource: StripeApi.PaymentSource, - paymentIntent: StripeApi.PaymentIntent, + paymentIntent: StripeIntentAccessor, badgeRecipient: RecipientId ): Single { val isBoost = badgeRecipient == Recipient.self().id @@ -222,7 +224,7 @@ class DonationPaymentRepository(activity: Activity) : StripeApi.PaymentIntentFet fun waitForOneTimeRedemption( price: FiatMoney, - paymentIntent: StripeApi.PaymentIntent, + paymentIntent: StripeIntentAccessor, badgeRecipient: RecipientId, additionalMessage: String?, badgeLevel: Long, @@ -382,7 +384,7 @@ class DonationPaymentRepository(activity: Activity) : StripeApi.PaymentIntentFet } } - override fun fetchPaymentIntent(price: FiatMoney, level: Long): Single { + override fun fetchPaymentIntent(price: FiatMoney, level: Long): Single { Log.d(TAG, "Fetching payment intent from Signal service for $price... (Locale.US minimum precision: ${price.minimumUnitPrecisionString})") return Single .fromCallable { @@ -392,13 +394,17 @@ class DonationPaymentRepository(activity: Activity) : StripeApi.PaymentIntentFet } .flatMap(ServiceResponse::flattenResult) .map { - StripeApi.PaymentIntent(it.id, it.clientSecret) + StripeIntentAccessor( + objectType = StripeIntentAccessor.ObjectType.PAYMENT_INTENT, + intentId = it.id, + intentClientSecret = it.clientSecret + ) }.doOnSuccess { Log.d(TAG, "Got payment intent from Signal service!") } } - override fun fetchSetupIntent(): Single { + override fun fetchSetupIntent(): Single { Log.d(TAG, "Fetching setup intent from Signal service...") return Single.fromCallable { SignalStore.donationsValues().requireSubscriber() } .flatMap { @@ -409,12 +415,34 @@ class DonationPaymentRepository(activity: Activity) : StripeApi.PaymentIntentFet } } .flatMap(ServiceResponse::flattenResult) - .map { StripeApi.SetupIntent(it.id, it.clientSecret) } + .map { + StripeIntentAccessor( + objectType = StripeIntentAccessor.ObjectType.SETUP_INTENT, + intentId = it.id, + intentClientSecret = it.clientSecret + ) + } .doOnSuccess { 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 { + 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 { return Single.fromCallable { 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 { private val TAG = Log.tag(DonationPaymentRepository::class.java) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalFragment.kt index 85cbb4f46..9cc807789 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalFragment.kt @@ -1,5 +1,6 @@ package org.thoughtcrime.securesms.components.settings.app.subscription.donate +import android.content.DialogInterface import android.text.SpannableStringBuilder import android.view.View import android.view.ViewGroup @@ -92,6 +93,8 @@ class DonateToSignalFragment : DSLSettingsFragment( } } + private var errorDialog: DialogInterface? = null + private val args: DonateToSignalFragmentArgs by navArgs() private val viewModel: DonateToSignalViewModel by viewModels(factoryProducer = { DonateToSignalViewModel.Factory(args.startType) @@ -462,7 +465,7 @@ class DonateToSignalFragment : DSLSettingsFragment( } private fun registerGooglePayCallback() { - donationPaymentComponent.googlePayResultPublisher.subscribeBy( + disposables += donationPaymentComponent.googlePayResultPublisher.subscribeBy( onNext = { paymentResult -> viewModel.consumeGatewayRequestForGooglePay()?.let { donationPaymentComponent.donationPaymentRepository.onActivityResult( @@ -478,15 +481,20 @@ class DonateToSignalFragment : DSLSettingsFragment( } private fun showErrorDialog(throwable: Throwable) { - Log.d(TAG, "Displaying donation error dialog.", true) - DonationErrorDialogs.show( - requireContext(), throwable, - object : DonationErrorDialogs.DialogCallback() { - override fun onDialogDismissed() { - findNavController().popBackStack() + if (errorDialog != null) { + Log.d(TAG, "Already displaying an error dialog. Skipping.", throwable, true) + } else { + Log.d(TAG, "Displaying donation error dialog.", true) + errorDialog = DonationErrorDialogs.show( + requireContext(), throwable, + object : DonationErrorDialogs.DialogCallback() { + override fun onDialogDismissed() { + errorDialog = null + findNavController().popBackStack() + } } - } - ) + ) + } } private fun startAnimationAboveSelectedBoost(view: View) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/Stripe3DSDialogFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/Stripe3DSDialogFragment.kt index 9998e573a..0448e754c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/Stripe3DSDialogFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/Stripe3DSDialogFragment.kt @@ -8,9 +8,11 @@ import android.view.View import android.webkit.WebSettings import android.webkit.WebView import android.webkit.WebViewClient +import androidx.core.os.bundleOf import androidx.fragment.app.DialogFragment import androidx.fragment.app.setFragmentResult import androidx.navigation.fragment.navArgs +import org.signal.donations.StripeIntentAccessor import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.components.ViewBinderDelegate import org.thoughtcrime.securesms.databinding.Stripe3dsDialogFragmentBinding @@ -23,7 +25,6 @@ class Stripe3DSDialogFragment : DialogFragment(R.layout.stripe_3ds_dialog_fragme 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) { @@ -33,6 +34,8 @@ class Stripe3DSDialogFragment : DialogFragment(R.layout.stripe_3ds_dialog_fragme val args: Stripe3DSDialogFragmentArgs by navArgs() + var result: Bundle? = null + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) 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) { - setFragmentResult(REQUEST_KEY, Bundle()) + val result = this.result + this.result = null + setFragmentResult(REQUEST_KEY, result ?: Bundle()) } 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?) { - if (url == STRIPE_3DS_COMPLETE) { + if (url?.startsWith(args.returnUri.toString()) == true) { + val stripeIntentAccessor = StripeIntentAccessor.fromUri(url) + + result = bundleOf(REQUEST_KEY to stripeIntentAccessor) dismissAllowingStateLoss() } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/StripePaymentInProgressFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/StripePaymentInProgressFragment.kt index b594de57c..c554822f7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/StripePaymentInProgressFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/StripePaymentInProgressFragment.kt @@ -13,11 +13,12 @@ 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.core.Single 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.signal.donations.StripeIntentAccessor import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.components.ViewBinderDelegate 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 { return when (secure3dsAction) { is StripeApi.Secure3DSAction.NotNeeded -> { Log.d(TAG, "No 3DS action required.") - Completable.complete() + Single.just(StripeIntentAccessor.NO_ACTION_REQUIRED) } is StripeApi.Secure3DSAction.ConfirmRequired -> { Log.d(TAG, "3DS action required. Displaying dialog...") - Completable.create { emitter -> - val listener = FragmentResultListener { _, _ -> - emitter.onComplete() + Single.create { emitter -> + val listener = FragmentResultListener { _, bundle -> + 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) - findNavController().safeNavigate(StripePaymentInProgressFragmentDirections.actionStripePaymentInProgressFragmentToStripe3dsDialogFragment(secure3dsAction.uri)) + findNavController().safeNavigate(StripePaymentInProgressFragmentDirections.actionStripePaymentInProgressFragmentToStripe3dsDialogFragment(secure3dsAction.uri, secure3dsAction.returnUri)) emitter.setCancellable { parentFragmentManager.clearFragmentResultListener(Stripe3DSDialogFragment.REQUEST_KEY) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/StripePaymentInProgressViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/StripePaymentInProgressViewModel.kt index bd76a5540..d6fe4aaed 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/StripePaymentInProgressViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/StripePaymentInProgressViewModel.kt @@ -13,6 +13,7 @@ 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.signal.donations.StripeIntentAccessor 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 @@ -62,7 +63,7 @@ class StripePaymentInProgressViewModel( disposables.clear() } - fun processNewDonation(request: GatewayRequest, nextActionHandler: (StripeApi.Secure3DSAction) -> Completable) { + fun processNewDonation(request: GatewayRequest, nextActionHandler: (StripeApi.Secure3DSAction) -> Single) { Log.d(TAG, "Proceeding with donation...", true) val errorSource = when (request.donateToSignalType) { @@ -112,7 +113,7 @@ class StripePaymentInProgressViewModel( cardData = null } - private fun proceedMonthly(request: GatewayRequest, paymentSourceProvider: Single, nextActionHandler: (StripeApi.Secure3DSAction) -> Completable) { + private fun proceedMonthly(request: GatewayRequest, paymentSourceProvider: Single, nextActionHandler: (StripeApi.Secure3DSAction) -> Single) { val ensureSubscriberId: Completable = donationPaymentRepository.ensureSubscriberId() val createAndConfirmSetupIntent: Single = paymentSourceProvider.flatMap { donationPaymentRepository.createAndConfirmSetupIntent(it) } val setLevel: Completable = donationPaymentRepository.setSubscriptionLevel(request.level.toString()) @@ -123,7 +124,11 @@ class StripePaymentInProgressViewModel( val setup: Completable = ensureSubscriberId .andThen(cancelActiveSubscriptionIfNecessary()) .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) } .onErrorResumeNext { Completable.error(DonationError.getPaymentSetupError(DonationErrorSource.SUBSCRIPTION, it)) } @@ -163,7 +168,7 @@ class StripePaymentInProgressViewModel( private fun proceedOneTime( request: GatewayRequest, paymentSourceProvider: Single, - nextActionHandler: (StripeApi.Secure3DSAction) -> Completable + nextActionHandler: (StripeApi.Secure3DSAction) -> Single ) { Log.w(TAG, "Beginning one-time payment pipeline...", true) @@ -171,13 +176,14 @@ class StripePaymentInProgressViewModel( val recipient = Recipient.self().id val level = SubscriptionLevels.BOOST_LEVEL.toLong() - val continuePayment: Single = donationPaymentRepository.continuePayment(amount, recipient, level) - val intentAndSource: Single> = Single.zip(continuePayment, paymentSourceProvider, ::Pair) + val continuePayment: Single = donationPaymentRepository.continuePayment(amount, recipient, level) + val intentAndSource: Single> = 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)) + .flatMap { nextActionHandler(it) } + .flatMap { donationPaymentRepository.getStatusAndPaymentMethodId(it) } + .flatMapCompletable { donationPaymentRepository.waitForOneTimeRedemption(amount, paymentIntent, recipient, null, level) } }.subscribeBy( onError = { throwable -> Log.w(TAG, "Failure in one-time payment pipeline...", throwable, true) diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/BoostReceiptRequestResponseJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/BoostReceiptRequestResponseJob.java index a3bf0f8f2..e54c2f753 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/BoostReceiptRequestResponseJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/BoostReceiptRequestResponseJob.java @@ -7,7 +7,7 @@ import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; 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.VerificationFailedException; import org.signal.libsignal.zkgroup.receipts.ClientZkReceiptOperations; @@ -55,7 +55,7 @@ public class BoostReceiptRequestResponseJob extends BaseJob { private final String paymentIntentId; 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( new Parameters .Builder() @@ -65,13 +65,13 @@ public class BoostReceiptRequestResponseJob extends BaseJob { .setMaxAttempts(Parameters.UNLIMITED) .build(), null, - paymentIntent.getId(), + paymentIntent.getIntentId(), donationErrorSource, 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)); DonationReceiptRedemptionJob redeemReceiptJob = DonationReceiptRedemptionJob.createJobForBoost(); RefreshOwnProfileJob refreshOwnProfileJob = RefreshOwnProfileJob.forBoost(); @@ -84,7 +84,7 @@ public class BoostReceiptRequestResponseJob extends BaseJob { .then(multiDeviceProfileContentUpdateJob); } - public static JobManager.Chain createJobChainForGift(@NonNull StripeApi.PaymentIntent paymentIntent, + public static JobManager.Chain createJobChainForGift(@NonNull StripeIntentAccessor paymentIntent, @NonNull RecipientId recipientId, @Nullable String additionalMessage, long badgeLevel) diff --git a/app/src/main/res/navigation/donate_to_signal.xml b/app/src/main/res/navigation/donate_to_signal.xml index 39df265b2..bd73347dc 100644 --- a/app/src/main/res/navigation/donate_to_signal.xml +++ b/app/src/main/res/navigation/donate_to_signal.xml @@ -135,6 +135,11 @@ android:name="uri" app:argType="android.net.Uri" app:nullable="false" /> + + \ No newline at end of file diff --git a/donations/lib/build.gradle b/donations/lib/build.gradle index b96c84e2b..c1258e11b 100644 --- a/donations/lib/build.gradle +++ b/donations/lib/build.gradle @@ -40,6 +40,17 @@ dependencies { implementation libs.androidx.annotation 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.square.okhttp3 api libs.rxjava3.rxjava diff --git a/donations/lib/src/main/java/org/signal/donations/StripeApi.kt b/donations/lib/src/main/java/org/signal/donations/StripeApi.kt index 439c6008d..cca475d5c 100644 --- a/donations/lib/src/main/java/org/signal/donations/StripeApi.kt +++ b/donations/lib/src/main/java/org/signal/donations/StripeApi.kt @@ -3,7 +3,9 @@ package org.signal.donations import android.net.Uri import android.os.Parcelable 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.schedulers.Schedulers import kotlinx.parcelize.Parcelize @@ -15,6 +17,8 @@ import okio.ByteString import org.json.JSONObject import org.signal.core.util.logging.Log 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.util.Locale @@ -25,6 +29,10 @@ class StripeApi( private val okHttpClient: OkHttpClient ) { + private val objectMapper = jsonMapper { + addModule(kotlinModule()) + } + companion object { 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_YEAR_KEY = "card[exp_year]" private val CARD_CVC_KEY = "card[cvc]" + + private const val RETURN_URL_3DS = "sgnlpay://3DS" } sealed class CreatePaymentIntentResult { data class AmountIsTooSmall(val amount: FiatMoney) : CreatePaymentIntentResult() data class AmountIsTooLarge(val amount: FiatMoney) : 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 { data class Success(val paymentSource: PaymentSource) : CreatePaymentSourceFromCardDataResult() @@ -55,20 +65,21 @@ class StripeApi( .subscribeOn(Schedulers.io()) } - fun confirmSetupIntent(paymentSource: PaymentSource, setupIntent: SetupIntent): Single { + fun confirmSetupIntent(paymentSource: PaymentSource, setupIntent: StripeIntentAccessor): Single { return Single.fromCallable { val paymentMethodId = createPaymentMethodAndParseId(paymentSource) val parameters = mapOf( - "client_secret" to setupIntent.clientSecret, - "payment_method" to paymentMethodId + "client_secret" to setupIntent.intentClientSecret, + "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) } - Secure3DSAction.from(nextAction, paymentMethodId) + Secure3DSAction.from(nextActionUri, returnUri, paymentMethodId) } } @@ -95,24 +106,49 @@ class StripeApi( * * @return A Secure3DSAction */ - fun confirmPaymentIntent(paymentSource: PaymentSource, paymentIntent: PaymentIntent): Single { + fun confirmPaymentIntent(paymentSource: PaymentSource, paymentIntent: StripeIntentAccessor): Single { return Single.fromCallable { val paymentMethodId = createPaymentMethodAndParseId(paymentSource) val parameters = mutableMapOf( - "client_secret" to paymentIntent.clientSecret, - "payment_method" to paymentMethodId + "client_secret" to paymentIntent.intentClientSecret, + "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) } - Secure3DSAction.from(nextAction) + Secure3DSAction.from(nextActionUri, returnUri) }.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 { val responseBody = response.body()?.string() val bodyJson = responseBody?.let { JSONObject(it) } 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") } - 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 { - Uri.EMPTY + Uri.EMPTY to Uri.EMPTY } } @@ -176,20 +216,34 @@ class StripeApi( 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): Response { val formBodyBuilder = FormBody.Builder() parameters.forEach { (k, v) -> formBodyBuilder.add(k, v) } - val request = Request.Builder() - .url("${configuration.baseUrl}/$endpoint") - .addHeader("Authorization", "Basic ${ByteString.encodeUtf8("${configuration.publishableKey}:").base64()}") + val request = getRequestBuilder(endpoint) .post(formBodyBuilder.build()) .build() 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) { return response } else { @@ -437,11 +491,11 @@ class StripeApi( fun fetchPaymentIntent( price: FiatMoney, level: Long - ): Single + ): Single } interface SetupIntentHelper { - fun fetchSetupIntent(): Single + fun fetchSetupIntent(): Single } @Parcelize @@ -452,16 +506,6 @@ class StripeApi( val cvc: Int ) : Parcelable - data class PaymentIntent( - val id: String, - val clientSecret: String - ) - - data class SetupIntent( - val id: String, - val clientSecret: String - ) - interface PaymentSource { fun parameterize(): JSONObject fun getTokenId(): String @@ -469,19 +513,24 @@ class StripeApi( } sealed interface Secure3DSAction { - data class ConfirmRequired(val uri: Uri, override val paymentMethodId: String?) : Secure3DSAction - data class NotNeeded(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 val paymentMethodId: String? 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) { NotNeeded(paymentMethodId) } else { - ConfirmRequired(uri, paymentMethodId) + ConfirmRequired(uri, returnUri, paymentMethodId) } } } } + } \ No newline at end of file diff --git a/donations/lib/src/main/java/org/signal/donations/StripeIntentAccessor.kt b/donations/lib/src/main/java/org/signal/donations/StripeIntentAccessor.kt new file mode 100644 index 000000000..33fb6ca0d --- /dev/null +++ b/donations/lib/src/main/java/org/signal/donations/StripeIntentAccessor.kt @@ -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)!! + ) + } + } + } +} \ No newline at end of file diff --git a/donations/lib/src/main/java/org/signal/donations/json/StripeIntentStatus.kt b/donations/lib/src/main/java/org/signal/donations/json/StripeIntentStatus.kt new file mode 100644 index 000000000..13fcec908 --- /dev/null +++ b/donations/lib/src/main/java/org/signal/donations/json/StripeIntentStatus.kt @@ -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 } + } +} \ No newline at end of file diff --git a/donations/lib/src/main/java/org/signal/donations/json/StripePaymentIntent.kt b/donations/lib/src/main/java/org/signal/donations/json/StripePaymentIntent.kt new file mode 100644 index 000000000..6c81d081c --- /dev/null +++ b/donations/lib/src/main/java/org/signal/donations/json/StripePaymentIntent.kt @@ -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? +) \ No newline at end of file diff --git a/donations/lib/src/main/java/org/signal/donations/json/StripeSetupIntent.kt b/donations/lib/src/main/java/org/signal/donations/json/StripeSetupIntent.kt new file mode 100644 index 000000000..69a0cede1 --- /dev/null +++ b/donations/lib/src/main/java/org/signal/donations/json/StripeSetupIntent.kt @@ -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? +) \ No newline at end of file diff --git a/donations/lib/src/test/java/donations/StripeIntentAccessorTest.kt b/donations/lib/src/test/java/donations/StripeIntentAccessorTest.kt new file mode 100644 index 000000000..ab45bee43 --- /dev/null +++ b/donations/lib/src/test/java/donations/StripeIntentAccessorTest.kt @@ -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) + } +} \ No newline at end of file diff --git a/donations/lib/src/test/java/donations/StripeSetupIntentTest.kt b/donations/lib/src/test/java/donations/StripeSetupIntentTest.kt new file mode 100644 index 000000000..e6284b99d --- /dev/null +++ b/donations/lib/src/test/java/donations/StripeSetupIntentTest.kt @@ -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(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") + } +} \ No newline at end of file diff --git a/image-editor/app/src/androidTest/java/com/example/imageeditor/app/ExampleInstrumentedTest.kt b/image-editor/app/src/androidTest/java/com/example/imageeditor/app/ExampleInstrumentedTest.kt deleted file mode 100644 index f3e60adf1..000000000 --- a/image-editor/app/src/androidTest/java/com/example/imageeditor/app/ExampleInstrumentedTest.kt +++ /dev/null @@ -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) - } -} \ No newline at end of file