mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-28 04:34:21 +01:00
Add support for Credit Card 3DS during subscriptions.
This commit is contained in:
committed by
Cody Henthorne
parent
844480786e
commit
d1df069669
@@ -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<Secure3DSAction> {
|
||||
fun confirmSetupIntent(paymentSource: PaymentSource, setupIntent: StripeIntentAccessor): Single<Secure3DSAction> {
|
||||
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<Secure3DSAction> {
|
||||
fun confirmPaymentIntent(paymentSource: PaymentSource, paymentIntent: StripeIntentAccessor): Single<Secure3DSAction> {
|
||||
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<Uri, Uri> {
|
||||
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<String, String>): 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<PaymentIntent>
|
||||
): Single<StripeIntentAccessor>
|
||||
}
|
||||
|
||||
interface SetupIntentHelper {
|
||||
fun fetchSetupIntent(): Single<SetupIntent>
|
||||
fun fetchSetupIntent(): Single<StripeIntentAccessor>
|
||||
}
|
||||
|
||||
@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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user