mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-05-08 09:18:39 +01:00
Implement beginnings of support for iDEAL payments.
This commit is contained in:
committed by
Cody Henthorne
parent
280da481ee
commit
5e1025453a
@@ -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 you’re 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user