Repo cloned

This commit is contained in:
Fr4nz D13trich 2025-12-29 13:18:34 +01:00
commit 496ae75f58
7988 changed files with 1451097 additions and 0 deletions

View file

@ -0,0 +1,26 @@
plugins {
id("signal-library")
id("kotlin-parcelize")
}
android {
namespace = "org.signal.donations"
buildFeatures {
buildConfig = true
}
}
dependencies {
implementation(project(":core-util"))
implementation(libs.kotlin.reflect)
implementation(libs.jackson.module.kotlin)
implementation(libs.jackson.core)
testImplementation(testLibs.robolectric.robolectric) {
exclude(group = "com.google.protobuf", module = "protobuf-java")
}
api(libs.square.okhttp3)
}

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest
xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>

View file

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

View file

@ -0,0 +1,13 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.donations
class IDEALPaymentSource(
val idealData: StripeApi.IDEALData
) : PaymentSource {
override val type: PaymentSourceType = PaymentSourceType.Stripe.IDEAL
override fun email(): String? = null
}

View file

@ -0,0 +1,41 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.donations
import org.signal.core.util.Serializer
enum class InAppPaymentType(val code: Int, val recurring: Boolean) {
/**
* Used explicitly for mapping DonationErrorSource. Writing this value
* into an InAppPayment is an error.
*/
UNKNOWN(-1, false),
/**
* This payment is for a gift badge
*/
ONE_TIME_GIFT(0, false),
/**
* This payment is for a one-time donation
*/
ONE_TIME_DONATION(1, false),
/**
* This payment is for a recurring donation
*/
RECURRING_DONATION(2, true),
/**
* This payment is for a recurring backup payment
*/
RECURRING_BACKUP(3, true);
companion object : Serializer<InAppPaymentType, Int> {
override fun serialize(data: InAppPaymentType): Int = data.code
override fun deserialize(input: Int): InAppPaymentType = entries.first { it.code == input }
}
}

View file

@ -0,0 +1,10 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.donations
class PayPalPaymentSource : PaymentSource {
override val type: PaymentSourceType = PaymentSourceType.PayPal
}

View file

@ -0,0 +1,19 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.donations
import org.json.JSONObject
/**
* A PaymentSource, being something that can be used to perform a
* transaction. See [PaymentSourceType].
*/
interface PaymentSource {
val type: PaymentSourceType
fun parameterize(): JSONObject = error("Unsupported by $type.")
fun getTokenId(): String = error("Unsupported by $type.")
fun email(): String? = error("Unsupported by $type.")
}

View file

@ -0,0 +1,72 @@
package org.signal.donations
sealed class PaymentSourceType {
abstract val code: String
open val isBankTransfer: Boolean = false
data object Unknown : PaymentSourceType() {
override val code: String = Codes.UNKNOWN.code
}
data object GooglePlayBilling : PaymentSourceType() {
override val code: String = Codes.GOOGLE_PLAY_BILLING.code
}
data object PayPal : PaymentSourceType() {
override val code: String = Codes.PAY_PAL.code
}
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.
*/
data object CreditCard : Stripe(Codes.CREDIT_CARD.code, "CARD", false)
/**
* Google Pay should happen instantaneously but can take up to 1 day to process.
*/
data object GooglePay : Stripe(Codes.GOOGLE_PAY.code, "CARD", false)
/**
* SEPA Debits can take up to 14 bank days to process.
*/
data object SEPADebit : Stripe(Codes.SEPA_DEBIT.code, "SEPA_DEBIT", true)
/**
* iDEAL Bank transfers happen instantaneously for 1:1 transactions, but do not do so for subscriptions, as Stripe
* will utilize SEPA under the hood.
*/
data 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) {
UNKNOWN("unknown"),
PAY_PAL("paypal"),
CREDIT_CARD("credit_card"),
GOOGLE_PAY("google_pay"),
SEPA_DEBIT("sepa_debit"),
IDEAL("ideal"),
GOOGLE_PLAY_BILLING("google_play_billing")
}
companion object {
fun fromCode(code: String?): PaymentSourceType {
return when (Codes.entries.firstOrNull { it.code == code } ?: Codes.UNKNOWN) {
Codes.UNKNOWN -> Unknown
Codes.PAY_PAL -> PayPal
Codes.CREDIT_CARD -> Stripe.CreditCard
Codes.GOOGLE_PAY -> Stripe.GooglePay
Codes.SEPA_DEBIT -> Stripe.SEPADebit
Codes.IDEAL -> Stripe.IDEAL
Codes.GOOGLE_PLAY_BILLING -> GooglePlayBilling
}
}
}
}

View file

@ -0,0 +1,27 @@
package org.signal.donations
import com.fasterxml.jackson.core.type.TypeReference
import com.fasterxml.jackson.databind.ObjectMapper
import org.signal.core.util.logging.Log
import java.io.IOException
internal object ResponseFieldLogger {
private val TAG = Log.tag(ResponseFieldLogger::class.java)
fun logFields(objectMapper: ObjectMapper, json: String?) {
if (json == null) {
Log.w(TAG, "Response body was null. No keys to print.")
return
}
try {
val mapType = object : TypeReference<Map<String, Any>>() {}
val map = objectMapper.readValue(json, mapType)
Log.w(TAG, "Map keys (${map.size}): ${map.keys.joinToString()}", true)
} catch (e: IOException) {
Log.w(TAG, "Failed to produce key map.", true)
}
}
}

View file

@ -0,0 +1,13 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.donations
class SEPADebitPaymentSource(
val sepaDebitData: StripeApi.SEPADebitData
) : PaymentSource {
override val type: PaymentSourceType = PaymentSourceType.Stripe.SEPADebit
override fun email(): String? = null
}

View file

@ -0,0 +1,616 @@
package org.signal.donations
import android.net.Uri
import android.os.Parcelable
import androidx.annotation.WorkerThread
import com.fasterxml.jackson.databind.exc.InvalidDefinitionException
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
import okhttp3.FormBody
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okio.ByteString.Companion.encodeUtf8
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
class StripeApi(
private val configuration: Configuration,
private val paymentIntentFetcher: PaymentIntentFetcher,
private val setupIntentHelper: SetupIntentHelper,
private val okHttpClient: OkHttpClient
) {
private val objectMapper = jsonMapper {
addModule(kotlinModule())
}
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]"
const val RETURN_URL_SCHEME = "sgnlpay"
private const val RETURN_URL_3DS = "$RETURN_URL_SCHEME://3DS"
const val RETURN_URL_IDEAL = "https://signaldonations.org/stripe/return/ideal"
}
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: StripeIntentAccessor) : CreatePaymentIntentResult()
}
data class CreateSetupIntentResult(val setupIntent: StripeIntentAccessor)
sealed class CreatePaymentSourceFromCardDataResult {
data class Success(val paymentSource: PaymentSource) : CreatePaymentSourceFromCardDataResult()
data class Failure(val reason: Throwable) : CreatePaymentSourceFromCardDataResult()
}
@WorkerThread
fun createSetupIntent(inAppPaymentType: InAppPaymentType, sourceType: PaymentSourceType.Stripe): CreateSetupIntentResult {
return setupIntentHelper
.fetchSetupIntent(inAppPaymentType, sourceType)
.let { CreateSetupIntentResult(it) }
}
@WorkerThread
fun confirmSetupIntent(paymentSource: PaymentSource, setupIntent: StripeIntentAccessor): Secure3DSAction {
val paymentMethodId = createPaymentMethodAndParseId(paymentSource)
val parameters = mutableMapOf(
"client_secret" to setupIntent.intentClientSecret,
"payment_method" to paymentMethodId,
"return_url" to if (paymentSource is IDEALPaymentSource) RETURN_URL_IDEAL else RETURN_URL_3DS
)
if (paymentSource.type.isBankTransfer) {
parameters["mandate_data[customer_acceptance][type]"] = "online"
parameters["mandate_data[customer_acceptance][online][infer_from_client]"] = "true"
}
val (nextActionUri, returnUri) = postForm(StripePaths.getSetupIntentConfirmationPath(setupIntent.intentId), parameters).use { response ->
getNextAction(response)
}
return Secure3DSAction.from(nextActionUri, returnUri, setupIntent, paymentMethodId)
}
@WorkerThread
fun createPaymentIntent(price: FiatMoney, level: Long, sourceType: PaymentSourceType.Stripe): CreatePaymentIntentResult {
@Suppress("CascadeIf")
return if (Validation.isAmountTooSmall(price)) {
CreatePaymentIntentResult.AmountIsTooSmall(price)
} else if (Validation.isAmountTooLarge(price)) {
CreatePaymentIntentResult.AmountIsTooLarge(price)
} else {
if (!Validation.supportedCurrencyCodes.contains(price.currency.currencyCode.uppercase(Locale.ROOT))) {
CreatePaymentIntentResult.CurrencyIsNotSupported(price.currency.currencyCode)
} else {
paymentIntentFetcher
.fetchPaymentIntent(price, level, sourceType)
.let { CreatePaymentIntentResult.Success(it) }
}
}
}
/**
* Confirm a PaymentIntent
*
* This method will create a PaymentMethod with the given PaymentSource and then confirm the
* PaymentIntent.
*
* @return A Secure3DSAction
*/
@WorkerThread
fun confirmPaymentIntent(paymentSource: PaymentSource, paymentIntent: StripeIntentAccessor): Secure3DSAction {
val paymentMethodId = createPaymentMethodAndParseId(paymentSource)
val parameters = mutableMapOf(
"client_secret" to paymentIntent.intentClientSecret,
"payment_method" to paymentMethodId,
"return_url" to if (paymentSource is IDEALPaymentSource) RETURN_URL_IDEAL else RETURN_URL_3DS
)
if (paymentSource.type.isBankTransfer) {
parameters["mandate_data[customer_acceptance][type]"] = "online"
parameters["mandate_data[customer_acceptance][online][infer_from_client]"] = "true"
}
val (nextActionUri, returnUri) = postForm(StripePaths.getPaymentIntentConfirmationPath(paymentIntent.intentId), parameters).use { response ->
getNextAction(response)
}
return Secure3DSAction.from(nextActionUri, returnUri, paymentIntent)
}
/**
* Retrieve the setup intent pointed to by the given accessor.
*/
fun getSetupIntent(stripeIntentAccessor: StripeIntentAccessor): StripeSetupIntent {
return when (stripeIntentAccessor.objectType) {
StripeIntentAccessor.ObjectType.SETUP_INTENT -> get(StripePaths.getSetupIntentPath(stripeIntentAccessor.intentId, stripeIntentAccessor.intentClientSecret)).use {
val body = it.body?.string()
try {
objectMapper.readValue(body!!)
} catch (e: InvalidDefinitionException) {
Log.w(TAG, "Failed to parse JSON for StripeSetupIntent.")
ResponseFieldLogger.logFields(objectMapper, body)
throw StripeError.FailedToParseSetupIntentResponseError(e)
} catch (e: Exception) {
Log.w(TAG, "Failed to read value from JSON.", e, true)
throw StripeError.FailedToParseSetupIntentResponseError(null)
}
}
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(StripePaths.getPaymentIntentPath(stripeIntentAccessor.intentId, 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.")
ResponseFieldLogger.logFields(objectMapper, body)
throw StripeError.FailedToParsePaymentIntentResponseError(e)
} catch (e: Exception) {
Log.w(TAG, "Failed to read value from JSON.", e, true)
throw StripeError.FailedToParsePaymentIntentResponseError(null)
}
}
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")) {
val nextAction = bodyJson.getJSONObject("next_action")
if (BuildConfig.DEBUG) {
Log.d(TAG, "[getNextAction] Next Action found:\n$nextAction")
}
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 to Uri.EMPTY
}
}
fun createPaymentSourceFromCardData(cardData: CardData): Single<CreatePaymentSourceFromCardDataResult> {
return Single.fromCallable<CreatePaymentSourceFromCardDataResult> {
CreatePaymentSourceFromCardDataResult.Success(createPaymentSourceFromCardDataSync(cardData))
}.onErrorReturn {
CreatePaymentSourceFromCardDataResult.Failure(it)
}.subscribeOn(Schedulers.io())
}
fun createPaymentSourceFromSEPADebitData(sepaDebitData: SEPADebitData): Single<PaymentSource> {
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(
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
)
postForm(StripePaths.getTokensPath(), parameters).use { response ->
val body = response.body ?: throw StripeError.FailedToCreatePaymentSourceFromCardData
return CreditCardPaymentSource(JSONObject(body.string()))
}
}
private fun createPaymentMethodAndParseId(paymentSource: PaymentSource): String {
val paymentMethodResponse = when (paymentSource) {
is SEPADebitPaymentSource -> createPaymentMethodForSEPADebit(paymentSource)
is IDEALPaymentSource -> createPaymentMethodForIDEAL(paymentSource)
is PayPalPaymentSource -> error("Stripe cannot interact with PayPal payment source.")
else -> createPaymentMethodForToken(paymentSource)
}
return paymentMethodResponse.use { response ->
val body = response.body ?: throw StripeError.FailedToParsePaymentMethodResponseError
val paymentMethodObject = body.string().replace("\n", "").let { JSONObject(it) }
paymentMethodObject.getString("id")
}
}
private fun createPaymentMethodForSEPADebit(paymentSource: SEPADebitPaymentSource): Response {
val parameters = mutableMapOf(
"type" to "sepa_debit",
"sepa_debit[iban]" to paymentSource.sepaDebitData.iban,
"billing_details[email]" to paymentSource.sepaDebitData.email,
"billing_details[name]" to paymentSource.sepaDebitData.name
)
return postForm(StripePaths.getPaymentMethodsPath(), parameters)
}
private fun createPaymentMethodForIDEAL(paymentSource: IDEALPaymentSource): Response {
val parameters = mutableMapOf(
"type" to "ideal",
"billing_details[email]" to paymentSource.idealData.email,
"billing_details[name]" to paymentSource.idealData.name
)
return postForm(StripePaths.getPaymentMethodsPath(), parameters)
}
private fun createPaymentMethodForToken(paymentSource: PaymentSource): Response {
val tokenId = paymentSource.getTokenId()
val parameters = mutableMapOf(
"card[token]" to tokenId,
"type" to "card"
)
return postForm(StripePaths.getPaymentMethodsPath(), 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 = 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 ${"${configuration.publishableKey}:".encodeUtf8().base64()}")
}
private fun checkResponseForErrors(response: Response): Response {
if (response.isSuccessful) {
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)
if (failureCode is StripeFailureCode.Known) {
throw StripeError.PostError.Failed(response.code, failureCode)
} else if (declineCode is StripeDeclineCode.Known) {
throw StripeError.PostError.Declined(response.code, declineCode)
} else {
throw StripeError.PostError.Generic(response.code, errorCode)
}
}
}
private fun parseErrorCode(body: String?): String? {
if (body == null) {
Log.d(TAG, "parseErrorCode: No body.", true)
return null
}
return try {
JSONObject(body).getJSONObject("error").getString("code")
} catch (e: Exception) {
Log.d(TAG, "parseErrorCode: Failed to parse error.", e, true)
null
}
}
private fun parseDeclineCode(body: String?): StripeDeclineCode? {
if (body == null) {
Log.d(TAG, "parseDeclineCode: No body.", true)
return null
}
return try {
StripeDeclineCode.getFromCode(JSONObject(body).getJSONObject("error").getString("decline_code"))
} catch (e: Exception) {
Log.d(TAG, "parseDeclineCode: Failed to parse decline code.", null, true)
null
}
}
private fun parseFailureCode(body: String?): StripeFailureCode? {
if (body == null) {
Log.d(TAG, "parseFailureCode: No body.", true)
return null
}
return try {
StripeFailureCode.getFromCode(JSONObject(body).getJSONObject("error").getString("failure_code"))
} catch (e: Exception) {
Log.d(TAG, "parseFailureCode: Failed to parse failure code.", null, true)
null
}
}
object Validation {
private val MAX_AMOUNT = BigDecimal(99_999_999)
fun isAmountTooLarge(fiatMoney: FiatMoney): Boolean {
return fiatMoney.minimumUnitPrecisionString.toBigDecimal() > MAX_AMOUNT
}
fun isAmountTooSmall(fiatMoney: FiatMoney): Boolean {
return fiatMoney.minimumUnitPrecisionString.toBigDecimal() < BigDecimal(minimumIntegralChargePerCurrencyCode[fiatMoney.currency.currencyCode] ?: 50)
}
private val minimumIntegralChargePerCurrencyCode: Map<String, Int> = mapOf(
"USD" to 50,
"AED" to 200,
"AUD" to 50,
"BGN" to 100,
"BRL" to 50,
"CAD" to 50,
"CHF" to 50,
"CZK" to 1500,
"DKK" to 250,
"EUR" to 50,
"GBP" to 30,
"HKD" to 400,
"HUF" to 17500,
"INR" to 50,
"JPY" to 50,
"MXN" to 10,
"MYR" to 2,
"NOK" to 300,
"NZD" to 50,
"PLN" to 200,
"RON" to 200,
"SEK" to 300,
"SGD" to 50
)
val supportedCurrencyCodes: List<String> = listOf(
"USD",
"AED",
"AFN",
"ALL",
"AMD",
"ANG",
"AOA",
"ARS",
"AUD",
"AWG",
"AZN",
"BAM",
"BBD",
"BDT",
"BGN",
"BIF",
"BMD",
"BND",
"BOB",
"BRL",
"BSD",
"BWP",
"BZD",
"CAD",
"CDF",
"CHF",
"CLP",
"CNY",
"COP",
"CRC",
"CVE",
"CZK",
"DJF",
"DKK",
"DOP",
"DZD",
"EGP",
"ETB",
"EUR",
"FJD",
"FKP",
"GBP",
"GEL",
"GIP",
"GMD",
"GNF",
"GTQ",
"GYD",
"HKD",
"HNL",
"HRK",
"HTG",
"HUF",
"IDR",
"ILS",
"INR",
"ISK",
"JMD",
"JPY",
"KES",
"KGS",
"KHR",
"KMF",
"KRW",
"KYD",
"KZT",
"LAK",
"LBP",
"LKR",
"LRD",
"LSL",
"MAD",
"MDL",
"MGA",
"MKD",
"MMK",
"MNT",
"MOP",
"MRO",
"MUR",
"MVR",
"MWK",
"MXN",
"MYR",
"MZN",
"NAD",
"NGN",
"NIO",
"NOK",
"NPR",
"NZD",
"PAB",
"PEN",
"PGK",
"PHP",
"PKR",
"PLN",
"PYG",
"QAR",
"RON",
"RSD",
"RUB",
"RWF",
"SAR",
"SBD",
"SCR",
"SEK",
"SGD",
"SHP",
"SLL",
"SOS",
"SRD",
"STD",
"SZL",
"THB",
"TJS",
"TOP",
"TRY",
"TTD",
"TWD",
"TZS",
"UAH",
"UGX",
"UYU",
"UZS",
"VND",
"VUV",
"WST",
"XAF",
"XCD",
"XOF",
"XPF",
"YER",
"ZAR",
"ZMW"
)
}
data class Configuration(
val publishableKey: String,
val baseUrl: String = "https://api.stripe.com/v1",
val version: String = "2018-10-31"
)
interface PaymentIntentFetcher {
@WorkerThread
fun fetchPaymentIntent(
price: FiatMoney,
level: Long,
sourceType: PaymentSourceType.Stripe
): StripeIntentAccessor
}
interface SetupIntentHelper {
@WorkerThread
fun fetchSetupIntent(
inAppPaymentType: InAppPaymentType,
sourceType: PaymentSourceType.Stripe
): StripeIntentAccessor
}
@Parcelize
data class CardData(
val number: String,
val month: Int,
val year: Int,
val cvc: String
) : Parcelable
@Parcelize
data class SEPADebitData(
val iban: String,
val name: String,
val email: String
) : Parcelable
@Parcelize
data class IDEALData(
val name: String,
val email: String
) : Parcelable
sealed interface Secure3DSAction {
data class ConfirmRequired(val uri: Uri, val returnUri: Uri, override val stripeIntentAccessor: StripeIntentAccessor, override val paymentMethodId: String?) : Secure3DSAction
data class NotNeeded(override val paymentMethodId: String?, override val stripeIntentAccessor: StripeIntentAccessor) : Secure3DSAction
val paymentMethodId: String?
val stripeIntentAccessor: StripeIntentAccessor
companion object {
fun from(
uri: Uri,
returnUri: Uri,
stripeIntentAccessor: StripeIntentAccessor,
paymentMethodId: String? = null
): Secure3DSAction {
return if (uri == Uri.EMPTY) {
NotNeeded(paymentMethodId, stripeIntentAccessor)
} else {
ConfirmRequired(uri, returnUri, stripeIntentAccessor, paymentMethodId)
}
}
}
}
}

View file

@ -0,0 +1,65 @@
package org.signal.donations
/**
* Stripe Payment Processor decline codes
*/
sealed class StripeDeclineCode(val rawCode: String) {
data class Known(val code: Code) : StripeDeclineCode(code.code)
data class Unknown(val code: String) : StripeDeclineCode(code)
fun isKnown(): Boolean = this is Known
enum class Code(val code: String) {
AUTHENTICATION_REQUIRED("authentication_required"),
APPROVE_WITH_ID("approve_with_id"),
CALL_ISSUER("call_issuer"),
CARD_NOT_SUPPORTED("card_not_supported"),
CARD_VELOCITY_EXCEEDED("card_velocity_exceeded"),
CURRENCY_NOT_SUPPORTED("currency_not_supported"),
DO_NOT_HONOR("do_not_honor"),
DO_NOT_TRY_AGAIN("do_not_try_again"),
DUPLICATE_TRANSACTION("duplicate_transaction"),
EXPIRED_CARD("expired_card"),
FRAUDULENT("fraudulent"),
GENERIC_DECLINE("generic_decline"),
INCORRECT_NUMBER("incorrect_number"),
INCORRECT_CVC("incorrect_cvc"),
INSUFFICIENT_FUNDS("insufficient_funds"),
INVALID_ACCOUNT("invalid_account"),
INVALID_AMOUNT("invalid_amount"),
INVALID_CVC("invalid_cvc"),
INVALID_EXPIRY_MONTH("invalid_expiry_month"),
INVALID_EXPIRY_YEAR("invalid_expiry_year"),
INVALID_NUMBER("invalid_number"),
ISSUER_NOT_AVAILABLE("issuer_not_available"),
LOST_CARD("lost_card"),
MERCHANT_BLACKLIST("merchant_blacklist"),
NEW_ACCOUNT_INFORMATION_AVAILABLE("new_account_information_available"),
NO_ACTION_TAKEN("no_action_taken"),
NOT_PERMITTED("not_permitted"),
PROCESSING_ERROR("processing_error"),
REENTER_TRANSACTION("reenter_transaction"),
RESTRICTED_CARD("restricted_card"),
REVOCATION_OF_ALL_AUTHORIZATIONS("revocation_of_all_authorizations"),
REVOCATION_OF_AUTHORIZATION("revocation_of_authorization"),
SECURITY_VIOLATION("security_violation"),
SERVICE_NOT_ALLOWED("service_not_allowed"),
STOLEN_CARD("stolen_card"),
STOP_PAYMENT_ORDER("stop_payment_order"),
TRANSACTION_NOT_ALLOWED("transaction_not_allowed"),
TRY_AGAIN_LATER("try_again_later"),
WITHDRAWAL_COUNT_LIMIT_EXCEEDED("withdrawal_count_limit_exceeded")
}
companion object {
fun getFromCode(code: String?): StripeDeclineCode {
if (code == null) {
return Unknown("null")
}
val typedCode: Code? = Code.entries.firstOrNull { it.code == code }
return typedCode?.let { Known(typedCode) } ?: Unknown(code)
}
}
}

View file

@ -0,0 +1,17 @@
package org.signal.donations
import com.fasterxml.jackson.databind.exc.InvalidDefinitionException
sealed class StripeError(message: String) : Exception(message) {
class FailedToParsePaymentIntentResponseError(invalidDefCause: InvalidDefinitionException?) : StripeError("Failed to parse payment intent response: ${invalidDefCause?.type} ${invalidDefCause?.property} ${invalidDefCause?.beanDescription}")
class FailedToParseSetupIntentResponseError(invalidDefCause: InvalidDefinitionException?) : StripeError("Failed to parse setup intent response: ${invalidDefCause?.type} ${invalidDefCause?.property} ${invalidDefCause?.beanDescription}")
object FailedToParsePaymentMethodResponseError : StripeError("Failed to parse payment method response")
object FailedToCreatePaymentSourceFromCardData : StripeError("Failed to create payment source from card data")
sealed class PostError(
override val message: String
) : StripeError(message) {
class Generic(statusCode: Int, val errorCode: String?) : PostError("postForm failed with code: $statusCode errorCode: $errorCode")
class Declined(statusCode: Int, val declineCode: StripeDeclineCode) : PostError("postForm failed with code: $statusCode declineCode: $declineCode")
class Failed(statusCode: Int, val failureCode: StripeFailureCode) : PostError("postForm failed with code: $statusCode failureCode: $failureCode")
}
}

View file

@ -0,0 +1,43 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.donations
/**
* Bank Transfer failure codes, as detailed here:
* https://stripe.com/docs/payments/sepa-debit#failed-payments
*/
sealed class StripeFailureCode(val rawCode: String) {
data class Known(val code: Code) : StripeFailureCode(code.code)
data class Unknown(val code: String) : StripeFailureCode(code)
val isKnown get() = this is Known
enum class Code(val code: String) {
REFER_TO_CUSTOMER("refer_to_customer"),
INSUFFICIENT_FUNDS("insufficient_funds"),
DEBIT_DISPUTED("debit_disputed"),
AUTHORIZATION_REVOKED("authorization_revoked"),
DEBIT_NOT_AUTHORIZED("debit_not_authorized"),
ACCOUNT_CLOSED("account_closed"),
BANK_ACCOUNT_RESTRICTED("bank_account_restricted"),
DEBIT_AUTHORIZATION_NOT_MATCH("debit_authorization_not_match"),
RECIPIENT_DECEASED("recipient_deceased"),
BRANCH_DOES_NOT_EXIST("branch_does_not_exist"),
INCORRECT_ACCOUNT_HOLDER_NAME("incorrect_account_holder_name"),
INVALID_ACCOUNT_NUMBER("invalid_account_number"),
GENERIC_COULD_NOT_PROCESS("generic_could_not_process")
}
companion object {
fun getFromCode(code: String?): StripeFailureCode {
if (code == null) {
return Unknown("null")
}
val typedCode: Code? = Code.entries.firstOrNull { it.code == code }
return typedCode?.let { Known(typedCode) } ?: Unknown(code)
}
}
}

View file

@ -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)!!
)
}
}
}
}

View file

@ -0,0 +1,51 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.donations
/**
* Endpoint generation class that assists in ensuring test code utilizes the same
* paths for data access as production code.
*/
object StripePaths {
/**
* Endpoint to retrieve data on the given payment intent
*/
fun getPaymentIntentPath(paymentIntentId: String, clientSecret: String): String {
return "payment_intents/$paymentIntentId?client_secret=$clientSecret"
}
/**
* Endpoint to confirm the given payment intent
*/
fun getPaymentIntentConfirmationPath(paymentIntentId: String): String {
return "payment_intents/$paymentIntentId/confirm"
}
/**
* Endpoint to retrieve data on the given setup intent
*/
fun getSetupIntentPath(setupIntentId: String, clientSecret: String): String {
return "setup_intents/$setupIntentId?client_secret=$clientSecret&expand[0]=latest_attempt"
}
/**
* Endpoint to confirm the given setup intent
*/
fun getSetupIntentConfirmationPath(setupIntentId: String): String {
return "setup_intents/$setupIntentId/confirm"
}
/**
* Endpoint to interact with payment methods
*/
fun getPaymentMethodsPath() = "payment_methods"
/**
* Endpoint to interact with tokens
*/
fun getTokensPath() = "tokens"
}

View file

@ -0,0 +1,22 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.donations
import org.json.JSONObject
class TokenPaymentSource(
override val type: PaymentSourceType,
val parameters: String,
val token: String,
val email: String?
) : PaymentSource {
override fun parameterize(): JSONObject = JSONObject(parameters)
override fun getTokenId(): String = token
override fun email(): String? = email
}

View file

@ -0,0 +1,37 @@
package org.signal.donations.json
import com.fasterxml.jackson.annotation.JsonCreator
import com.fasterxml.jackson.annotation.JsonValue
/**
* 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 = entries.first { it.code == code }
}
fun canProceed(): Boolean {
return this == PROCESSING || this == SUCCEEDED
}
@JsonValue
fun toValue(): String {
return code
}
}

View file

@ -0,0 +1,18 @@
package org.signal.donations.json
import com.fasterxml.jackson.annotation.JsonCreator
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 @JsonCreator constructor(
@JsonProperty("id") val id: String,
@JsonProperty("client_secret") val clientSecret: String,
@JsonProperty("status") val status: StripeIntentStatus?,
@JsonProperty("payment_method") val paymentMethod: String?
)

View file

@ -0,0 +1,19 @@
package org.signal.donations.json
import com.fasterxml.jackson.annotation.JsonCreator
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 @JsonCreator constructor(
@JsonProperty("id") val id: String,
@JsonProperty("client_secret") val clientSecret: String,
@JsonProperty("status") val status: StripeIntentStatus,
@JsonProperty("payment_method") val paymentMethodId: String?,
@JsonProperty("customer") val customer: String?
)

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
</LinearLayout>

View file

@ -0,0 +1,49 @@
package org.signal.donations
import com.fasterxml.jackson.databind.ObjectMapper
import org.junit.Before
import org.junit.Test
import org.signal.core.util.logging.Log
import org.signal.core.util.logging.Log.Logger
class ResponseFieldLoggerTest {
@Before
fun setUp() {
Log.initialize(object : Logger() {
override fun v(tag: String, message: String?, t: Throwable?, keepLonger: Boolean) = Unit
override fun d(tag: String, message: String?, t: Throwable?, keepLonger: Boolean) = Unit
override fun i(tag: String, message: String?, t: Throwable?, keepLonger: Boolean) = Unit
override fun w(tag: String, message: String?, t: Throwable?, keepLonger: Boolean) = println(message)
override fun e(tag: String, message: String?, t: Throwable?, keepLonger: Boolean) = Unit
override fun flush() = Unit
override fun clear() = Unit
})
}
@Test
fun `Given a null, when I logFields, then I expect no crash`() {
ResponseFieldLogger.logFields(ObjectMapper(), null)
}
@Test
fun `Given empty, when I logFields, then I expect no crash`() {
ResponseFieldLogger.logFields(ObjectMapper(), "{}")
}
@Test
fun `Given non-empty, when I logFields, then I expect no crash`() {
ResponseFieldLogger.logFields(
ObjectMapper(),
"""
{
"id": "asdf",
"client_secret": 12345,
"structured_obj": {
"a": "a"
}
}
""".trimIndent()
)
}
}

View file

@ -0,0 +1,39 @@
package org.signal.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
@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)
}
}

View file

@ -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)
}
}

View file

@ -0,0 +1,70 @@
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.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.paymentMethodId, "pm_sldalskdjhfalskjdhf")
assertEquals(intent.status, StripeIntentStatus.REQUIRES_PAYMENT_METHOD)
assertEquals(intent.customer, "cus_Fh6d95jDS2fVSL")
}
}