Implement beginnings of support for iDEAL payments.

This commit is contained in:
Alex Hart
2023-10-18 14:30:09 -04:00
committed by Cody Henthorne
parent 280da481ee
commit 5e1025453a
35 changed files with 1018 additions and 125 deletions
@@ -0,0 +1,18 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.donations
import org.json.JSONObject
class IDEALPaymentSource(
val idealData: StripeApi.IDEALData
) : StripeApi.PaymentSource {
override val type: PaymentSourceType = PaymentSourceType.Stripe.IDEAL
override fun parameterize(): JSONObject = error("iDEAL does not support tokenization")
override fun getTokenId(): String = error("iDEAL does not support tokenization")
override fun email(): String? = null
}
@@ -2,7 +2,7 @@ package org.signal.donations
sealed class PaymentSourceType {
abstract val code: String
open val isLongRunning: Boolean = false
open val isBankTransfer: Boolean = false
object Unknown : PaymentSourceType() {
override val code: String = Codes.UNKNOWN.code
@@ -12,13 +12,34 @@ sealed class PaymentSourceType {
override val code: String = Codes.PAY_PAL.code
}
sealed class Stripe(override val code: String, val paymentMethod: String, override val isLongRunning: Boolean) : PaymentSourceType() {
sealed class Stripe(
override val code: String,
val paymentMethod: String,
override val isBankTransfer: Boolean
) : PaymentSourceType() {
/**
* Credit card should happen instantaneously but can take up to 1 day to process.
*/
object CreditCard : Stripe(Codes.CREDIT_CARD.code, "CARD", false)
/**
* Google Pay should happen instantaneously but can take up to 1 day to process.
*/
object GooglePay : Stripe(Codes.GOOGLE_PAY.code, "CARD", false)
/**
* SEPA Debits can take up to 14 days to process.
*/
object SEPADebit : Stripe(Codes.SEPA_DEBIT.code, "SEPA_DEBIT", true)
fun hasDeclineCodeSupport(): Boolean = this !is SEPADebit
fun hasFailureCodeSupport(): Boolean = this is SEPADebit
/**
* iDEAL Bank transfers happen instantaneously for 1:1 transactions, but do not do so for subscriptions, as Stripe
* will utilize SEPA under the hood.
*/
object IDEAL : Stripe(Codes.IDEAL.code, "IDEAL", true)
fun hasDeclineCodeSupport(): Boolean = !this.isBankTransfer
fun hasFailureCodeSupport(): Boolean = this.isBankTransfer
}
private enum class Codes(val code: String) {
@@ -26,7 +47,8 @@ sealed class PaymentSourceType {
PAY_PAL("paypal"),
CREDIT_CARD("credit_card"),
GOOGLE_PAY("google_pay"),
SEPA_DEBIT("sepa_debit")
SEPA_DEBIT("sepa_debit"),
IDEAL("ideal")
}
companion object {
@@ -37,6 +59,7 @@ sealed class PaymentSourceType {
Codes.CREDIT_CARD -> Stripe.CreditCard
Codes.GOOGLE_PAY -> Stripe.GooglePay
Codes.SEPA_DEBIT -> Stripe.SEPADebit
Codes.IDEAL -> Stripe.IDEAL
}
}
}
@@ -77,7 +77,7 @@ class StripeApi(
"return_url" to RETURN_URL_3DS
)
if (paymentSource.type == PaymentSourceType.Stripe.SEPADebit) {
if (paymentSource.type.isBankTransfer) {
parameters["mandate_data[customer_acceptance][type]"] = "online"
parameters["mandate_data[customer_acceptance][online][infer_from_client]"] = "true"
}
@@ -125,7 +125,7 @@ class StripeApi(
"return_url" to RETURN_URL_3DS
)
if (paymentSource.type == PaymentSourceType.Stripe.SEPADebit) {
if (paymentSource.type.isBankTransfer) {
parameters["mandate_data[customer_acceptance][type]"] = "online"
parameters["mandate_data[customer_acceptance][online][infer_from_client]"] = "true"
}
@@ -143,7 +143,7 @@ class StripeApi(
*/
fun getSetupIntent(stripeIntentAccessor: StripeIntentAccessor): StripeSetupIntent {
return when (stripeIntentAccessor.objectType) {
StripeIntentAccessor.ObjectType.SETUP_INTENT -> get("setup_intents/${stripeIntentAccessor.intentId}?client_secret=${stripeIntentAccessor.intentClientSecret}").use {
StripeIntentAccessor.ObjectType.SETUP_INTENT -> get("setup_intents/${stripeIntentAccessor.intentId}?client_secret=${stripeIntentAccessor.intentClientSecret}&expand[0]=latest_attempt").use {
val body = it.body()?.string()
try {
objectMapper.readValue(body!!)
@@ -168,6 +168,7 @@ class StripeApi(
StripeIntentAccessor.ObjectType.PAYMENT_INTENT -> get("payment_intents/${stripeIntentAccessor.intentId}?client_secret=${stripeIntentAccessor.intentClientSecret}").use {
val body = it.body()?.string()
try {
Log.d(TAG, "Reading StripePaymentIntent from JSON")
objectMapper.readValue(body!!)
} catch (e: InvalidDefinitionException) {
Log.w(TAG, "Failed to parse JSON for StripePaymentIntent.")
@@ -213,6 +214,10 @@ class StripeApi(
return Single.just(SEPADebitPaymentSource(sepaDebitData))
}
fun createPaymentSourceFromIDEALData(idealData: IDEALData): Single<PaymentSource> {
return Single.just(IDEALPaymentSource(idealData))
}
@WorkerThread
private fun createPaymentSourceFromCardDataSync(cardData: CardData): PaymentSource {
val parameters: Map<String, String> = mutableMapOf(
@@ -233,10 +238,10 @@ class StripeApi(
}
private fun createPaymentMethodAndParseId(paymentSource: PaymentSource): String {
val paymentMethodResponse = if (paymentSource is SEPADebitPaymentSource) {
createPaymentMethodForSEPADebit(paymentSource)
} else {
createPaymentMethodForToken(paymentSource)
val paymentMethodResponse = when (paymentSource) {
is SEPADebitPaymentSource -> createPaymentMethodForSEPADebit(paymentSource)
is IDEALPaymentSource -> createPaymentMethodForIDEAL(paymentSource)
else -> createPaymentMethodForToken(paymentSource)
}
return paymentMethodResponse.use { response ->
@@ -261,6 +266,17 @@ class StripeApi(
return postForm("payment_methods", parameters)
}
private fun createPaymentMethodForIDEAL(paymentSource: IDEALPaymentSource): Response {
val parameters = mutableMapOf(
"type" to "ideal",
"ideal[bank]" to paymentSource.idealData.bank,
"billing_details[email]" to paymentSource.idealData.email,
"billing_details[name]" to paymentSource.idealData.name
)
return postForm("payment_methods", parameters)
}
private fun createPaymentMethodForToken(paymentSource: PaymentSource): Response {
val tokenId = paymentSource.getTokenId()
val parameters = mutableMapOf(
@@ -303,6 +319,7 @@ class StripeApi(
return response
} else {
val body = response.body()?.string()
val errorCode = parseErrorCode(body)
val declineCode = parseDeclineCode(body) ?: StripeDeclineCode.getFromCode(errorCode)
val failureCode = parseFailureCode(body) ?: StripeFailureCode.getFromCode(errorCode)
@@ -340,7 +357,7 @@ class StripeApi(
return try {
StripeDeclineCode.getFromCode(JSONObject(body).getJSONObject("error").getString("decline_code"))
} catch (e: Exception) {
Log.d(TAG, "parseDeclineCode: Failed to parse decline code.", e, true)
Log.d(TAG, "parseDeclineCode: Failed to parse decline code.", null, true)
null
}
}
@@ -354,7 +371,7 @@ class StripeApi(
return try {
StripeFailureCode.getFromCode(JSONObject(body).getJSONObject("error").getString("failure_code"))
} catch (e: Exception) {
Log.d(TAG, "parseFailureCode: Failed to parse failure code.", e, true)
Log.d(TAG, "parseFailureCode: Failed to parse failure code.", null, true)
null
}
}
@@ -588,6 +605,13 @@ class StripeApi(
val email: String
) : Parcelable
@Parcelize
data class IDEALData(
val bank: String,
val name: String,
val email: String
) : Parcelable
interface PaymentSource {
val type: PaymentSourceType
fun parameterize(): JSONObject
@@ -15,5 +15,24 @@ data class StripeSetupIntent @JsonCreator constructor(
@JsonProperty("client_secret") val clientSecret: String,
@JsonProperty("status") val status: StripeIntentStatus,
@JsonProperty("payment_method") val paymentMethod: String?,
@JsonProperty("customer") val customer: String?
)
@JsonProperty("customer") val customer: String?,
@JsonProperty("latest_attempt") val latestAttempt: LatestAttempt?
) {
fun requireGeneratedSepaDebit(): String = latestAttempt!!.paymentMethodDetails!!.ideal!!.generatedSepaDebit!!
@JsonIgnoreProperties
data class LatestAttempt @JsonCreator constructor(
@JsonProperty("payment_method_details") val paymentMethodDetails: PaymentMethodDetails?
)
@JsonIgnoreProperties
data class PaymentMethodDetails @JsonCreator constructor(
@JsonProperty("ideal") val ideal: Ideal?
)
@JsonIgnoreProperties
data class Ideal @JsonCreator constructor(
@JsonProperty("generated_sepa_debit") val generatedSepaDebit: String?
)
}
@@ -0,0 +1,75 @@
package org.signal.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.StripePaymentIntent
@RunWith(RobolectricTestRunner::class)
@Config(application = Application::class, manifest = Config.NONE)
class StripePaymentIntentTest {
companion object {
private const val TEST_JSON = """
{
"id": "pi_A",
"object": "payment_intent",
"amount": 1000,
"amount_details": {
"tip": {}
},
"automatic_payment_methods": {
"allow_redirects": "always",
"enabled": true
},
"canceled_at": null,
"cancellation_reason": null,
"capture_method": "automatic",
"client_secret": "pi_client_secret",
"confirmation_method": "automatic",
"created": 1697568512,
"currency": "eur",
"description": "Thank you for supporting Signal. Your contribution helps fuel the mission of developing open source privacy technology that protects free expression and enables secure global communication for millions around the world. If youre a resident of the United States, please retain this receipt for your tax records. Signal Technology Foundation is a tax-exempt nonprofit organization in the United States under section 501c3 of the Internal Revenue Code. Our Federal Tax ID is 82-4506840.",
"last_payment_error": null,
"livemode": false,
"next_action": null,
"payment_method": "pm_A",
"payment_method_configuration_details": {
"id": "pmc_A",
"parent": null
},
"payment_method_types": [
"card",
"ideal",
"sepa_debit"
],
"processing": null,
"receipt_email": null,
"setup_future_usage": null,
"shipping": null,
"source": null,
"status": "succeeded"
}
"""
}
@Test
fun `Given TEST_DATA, when I readValue, then I expect properly set fields`() {
val mapper = jsonMapper {
addModule(kotlinModule())
}
val intent = mapper.readValue<StripePaymentIntent>(TEST_JSON)
assertEquals(intent.id, "pi_A")
assertEquals(intent.clientSecret, "pi_client_secret")
assertEquals(intent.paymentMethod, "pm_A")
assertEquals(intent.status, StripeIntentStatus.SUCCEEDED)
}
}