Add basic 3DS support for credit cards.

This commit is contained in:
Alex Hart
2022-10-25 16:59:52 -03:00
committed by Cody Henthorne
parent c686d33a46
commit 2cfa685ae2
21 changed files with 543 additions and 72 deletions

View File

@@ -1,6 +1,7 @@
plugins {
id 'com.android.library'
id 'kotlin-android'
id 'kotlin-parcelize'
}
android {

View File

@@ -0,0 +1,14 @@
package org.signal.donations
import org.json.JSONObject
/**
* Stripe payment source based off a manually entered credit card.
*/
class CreditCardPaymentSource(
private val payload: JSONObject
) : StripeApi.PaymentSource {
override fun parameterize(): JSONObject = payload
override fun getTokenId(): String = parameterize().getString("id")
override fun email(): String? = null
}

View File

@@ -10,6 +10,11 @@ class GooglePayPaymentSource(private val paymentData: PaymentData) : StripeApi.P
return paymentMethodJsonData.getJSONObject("tokenizationData")
}
override fun getTokenId(): String {
val serializedToken = parameterize().getString("token").replace("\n", "")
return JSONObject(serializedToken).getString("id")
}
override fun email(): String? {
val jsonData = JSONObject(paymentData.toJson())
return if (jsonData.has("email")) {

View File

@@ -1,8 +1,12 @@
package org.signal.donations
import android.net.Uri
import android.os.Parcelable
import androidx.annotation.WorkerThread
import io.reactivex.rxjava3.core.Completable
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
import kotlinx.parcelize.Parcelize
import okhttp3.FormBody
import okhttp3.OkHttpClient
import okhttp3.Request
@@ -23,6 +27,11 @@ class StripeApi(
companion object {
private val TAG = Log.tag(StripeApi::class.java)
private val CARD_NUMBER_KEY = "card[number]"
private val CARD_MONTH_KEY = "card[exp_month]"
private val CARD_YEAR_KEY = "card[exp_year]"
private val CARD_CVC_KEY = "card[cvc]"
}
sealed class CreatePaymentIntentResult {
@@ -34,6 +43,11 @@ class StripeApi(
data class CreateSetupIntentResult(val setupIntent: SetupIntent)
sealed class CreatePaymentSourceFromCardDataResult {
data class Success(val paymentSource: PaymentSource) : CreatePaymentSourceFromCardDataResult()
data class Failure(val reason: Throwable) : CreatePaymentSourceFromCardDataResult()
}
fun createSetupIntent(): Single<CreateSetupIntentResult> {
return setupIntentHelper
.fetchSetupIntent()
@@ -41,18 +55,21 @@ class StripeApi(
.subscribeOn(Schedulers.io())
}
fun confirmSetupIntent(paymentSource: PaymentSource, setupIntent: SetupIntent): Completable = Single.fromCallable {
val paymentMethodId = createPaymentMethodAndParseId(paymentSource)
fun confirmSetupIntent(paymentSource: PaymentSource, setupIntent: SetupIntent): Single<Secure3DSAction> {
return Single.fromCallable {
val paymentMethodId = createPaymentMethodAndParseId(paymentSource)
val parameters = mapOf(
"client_secret" to setupIntent.clientSecret,
"payment_method" to paymentMethodId
)
val parameters = mapOf(
"client_secret" to setupIntent.clientSecret,
"payment_method" to paymentMethodId
)
postForm("setup_intents/${setupIntent.id}/confirm", parameters)
paymentMethodId
}.flatMapCompletable {
setupIntentHelper.setDefaultPaymentMethod(it)
val nextAction = postForm("setup_intents/${setupIntent.id}/confirm", parameters).use { response ->
getNextAction(response)
}
Secure3DSAction.from(nextAction, paymentMethodId)
}
}
fun createPaymentIntent(price: FiatMoney, level: Long): Single<CreatePaymentIntentResult> {
@@ -70,16 +87,72 @@ class StripeApi(
}.subscribeOn(Schedulers.io())
}
fun confirmPaymentIntent(paymentSource: PaymentSource, paymentIntent: PaymentIntent): Completable = Completable.fromAction {
val paymentMethodId = createPaymentMethodAndParseId(paymentSource)
/**
* Confirm a PaymentIntent
*
* This method will create a PaymentMethod with the given PaymentSource and then confirm the
* PaymentIntent.
*
* @return A Secure3DSAction
*/
fun confirmPaymentIntent(paymentSource: PaymentSource, paymentIntent: PaymentIntent): Single<Secure3DSAction> {
return Single.fromCallable {
val paymentMethodId = createPaymentMethodAndParseId(paymentSource)
val parameters = mutableMapOf(
"client_secret" to paymentIntent.clientSecret,
"payment_method" to paymentMethodId
val parameters = mutableMapOf(
"client_secret" to paymentIntent.clientSecret,
"payment_method" to paymentMethodId
)
val nextAction = postForm("payment_intents/${paymentIntent.id}/confirm", parameters).use { response ->
getNextAction(response)
}
Secure3DSAction.from(nextAction)
}.subscribeOn(Schedulers.io())
}
private fun getNextAction(response: Response): Uri {
val responseBody = response.body()?.string()
val bodyJson = responseBody?.let { JSONObject(it) }
return if (bodyJson?.has("next_action") == true && !bodyJson.isNull("next_action")) {
val nextAction = bodyJson.getJSONObject("next_action")
if (BuildConfig.DEBUG) {
Log.d(TAG, "[getNextAction] Next Action found:\n$nextAction")
}
Uri.parse(nextAction.getJSONObject("use_stripe_sdk").getString("stripe_js"))
} else {
Uri.EMPTY
}
}
fun createPaymentSourceFromCardData(cardData: CardData): Single<CreatePaymentSourceFromCardDataResult> {
return Single.fromCallable<CreatePaymentSourceFromCardDataResult> {
CreatePaymentSourceFromCardDataResult.Success(createPaymentSourceFromCardDataSync(cardData))
}.onErrorReturn {
CreatePaymentSourceFromCardDataResult.Failure(it)
}.subscribeOn(Schedulers.io())
}
@WorkerThread
private fun createPaymentSourceFromCardDataSync(cardData: CardData): PaymentSource {
val parameters: Map<String, String> = mutableMapOf(
CARD_NUMBER_KEY to cardData.number,
CARD_MONTH_KEY to cardData.month.toString(),
CARD_YEAR_KEY to cardData.year.toString(),
CARD_CVC_KEY to cardData.cvc.toString()
)
postForm("payment_intents/${paymentIntent.id}/confirm", parameters)
}.subscribeOn(Schedulers.io())
postForm("tokens", parameters).use { response ->
val body = response.body()
if (body != null) {
return CreditCardPaymentSource(JSONObject(body.string()))
} else {
throw StripeError.FailedToCreatePaymentSourceFromCardData
}
}
}
private fun createPaymentMethodAndParseId(paymentSource: PaymentSource): String {
return createPaymentMethod(paymentSource).use { response ->
@@ -94,9 +167,9 @@ class StripeApi(
}
private fun createPaymentMethod(paymentSource: PaymentSource): Response {
val tokenizationData = paymentSource.parameterize()
val tokenId = paymentSource.getTokenId()
val parameters = mutableMapOf(
"card[token]" to JSONObject((tokenizationData.get("token") as String).replace("\n", "")).getString("id"),
"card[token]" to tokenId,
"type" to "card",
)
@@ -366,9 +439,16 @@ class StripeApi(
interface SetupIntentHelper {
fun fetchSetupIntent(): Single<SetupIntent>
fun setDefaultPaymentMethod(paymentMethodId: String): Completable
}
@Parcelize
data class CardData(
val number: String,
val month: Int,
val year: Int,
val cvc: Int
) : Parcelable
data class PaymentIntent(
val id: String,
val clientSecret: String
@@ -381,6 +461,24 @@ class StripeApi(
interface PaymentSource {
fun parameterize(): JSONObject
fun getTokenId(): String
fun email(): String?
}
sealed interface Secure3DSAction {
data class ConfirmRequired(val uri: Uri, override val paymentMethodId: String?) : Secure3DSAction
data class NotNeeded(override val paymentMethodId: String?): Secure3DSAction
val paymentMethodId: String?
companion object {
fun from(uri: Uri, paymentMethodId: String? = null): Secure3DSAction {
return if (uri == Uri.EMPTY) {
NotNeeded(paymentMethodId)
} else {
ConfirmRequired(uri, paymentMethodId)
}
}
}
}
}

View File

@@ -2,5 +2,6 @@ package org.signal.donations
sealed class StripeError(message: String) : Exception(message) {
object FailedToParsePaymentMethodResponseError : StripeError("Failed to parse payment method response")
object FailedToCreatePaymentSourceFromCardData : StripeError("Failed to create payment source from card data")
class PostError(val statusCode: Int, val errorCode: String?, val declineCode: StripeDeclineCode?) : StripeError("postForm failed with code: $statusCode. errorCode: $errorCode. declineCode: $declineCode")
}