Repo cloned
This commit is contained in:
commit
496ae75f58
7988 changed files with 1451097 additions and 0 deletions
26
donations/lib/build.gradle.kts
Normal file
26
donations/lib/build.gradle.kts
Normal 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)
|
||||
}
|
||||
5
donations/lib/src/main/AndroidManifest.xml
Normal file
5
donations/lib/src/main/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest
|
||||
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
</manifest>
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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.")
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
616
donations/lib/src/main/java/org/signal/donations/StripeApi.kt
Normal file
616
donations/lib/src/main/java/org/signal/donations/StripeApi.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,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"
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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?
|
||||
)
|
||||
|
|
@ -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?
|
||||
)
|
||||
|
|
@ -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>
|
||||
|
|
@ -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()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue