kopia lustrzana https://github.com/ryukoposting/Signal-Android
Add support for Credit Card 3DS during subscriptions.
rodzic
844480786e
commit
d1df069669
|
@ -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)
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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>
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
|
@ -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)!!
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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 }
|
||||||
|
}
|
||||||
|
}
|
|
@ -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?
|
||||||
|
)
|
|
@ -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?
|
||||||
|
)
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
Ładowanie…
Reference in New Issue