Fix bad one-time-payment receipt creation for cancelled iDEAL.

This commit is contained in:
Alex Hart 2024-12-10 11:34:17 -04:00 committed by Greyson Parrelli
parent fa72a1788b
commit 3eea331e83
9 changed files with 245 additions and 11 deletions

View file

@ -228,6 +228,7 @@ android {
buildConfigField("String", "BUILD_ENVIRONMENT_TYPE", "\"unset\"") buildConfigField("String", "BUILD_ENVIRONMENT_TYPE", "\"unset\"")
buildConfigField("String", "BUILD_VARIANT_TYPE", "\"unset\"") buildConfigField("String", "BUILD_VARIANT_TYPE", "\"unset\"")
buildConfigField("String", "BADGE_STATIC_ROOT", "\"https://updates2.signal.org/static/badges/\"") buildConfigField("String", "BADGE_STATIC_ROOT", "\"https://updates2.signal.org/static/badges/\"")
buildConfigField("String", "STRIPE_BASE_URL", "\"https://api.stripe.com/v1\"")
buildConfigField("String", "STRIPE_PUBLISHABLE_KEY", "\"pk_live_6cmGZopuTsV8novGgJJW9JpC00vLIgtQ1D\"") buildConfigField("String", "STRIPE_PUBLISHABLE_KEY", "\"pk_live_6cmGZopuTsV8novGgJJW9JpC00vLIgtQ1D\"")
buildConfigField("boolean", "TRACING_ENABLED", "false") buildConfigField("boolean", "TRACING_ENABLED", "false")
buildConfigField("boolean", "MESSAGE_BACKUP_RESTORE_ENABLED", "false") buildConfigField("boolean", "MESSAGE_BACKUP_RESTORE_ENABLED", "false")
@ -301,6 +302,7 @@ android {
applicationIdSuffix = ".instrumentation" applicationIdSuffix = ".instrumentation"
buildConfigField("String", "BUILD_VARIANT_TYPE", "\"Instrumentation\"") buildConfigField("String", "BUILD_VARIANT_TYPE", "\"Instrumentation\"")
buildConfigField("String", "STRIPE_BASE_URL", "\"http://127.0.0.1:8080/stripe\"")
} }
create("spinner") { create("spinner") {

View file

@ -0,0 +1,109 @@
package org.thoughtcrime.securesms.jobs
import androidx.test.ext.junit.runners.AndroidJUnit4
import okhttp3.mockwebserver.MockResponse
import org.hamcrest.Matchers
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.signal.core.util.deleteAll
import org.signal.core.util.money.FiatMoney
import org.signal.donations.InAppPaymentType
import org.signal.donations.json.StripeIntentStatus
import org.signal.donations.json.StripePaymentIntent
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toFiatValue
import org.thoughtcrime.securesms.database.DonationReceiptTable
import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.InAppPaymentReceiptRecord
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
import org.thoughtcrime.securesms.dependencies.InstrumentationApplicationDependencyProvider
import org.thoughtcrime.securesms.testing.Get
import org.thoughtcrime.securesms.testing.SignalActivityRule
import org.thoughtcrime.securesms.testing.assert
import org.thoughtcrime.securesms.testing.success
import org.thoughtcrime.securesms.util.TestStripePaths
import java.math.BigDecimal
import java.util.Currency
@RunWith(AndroidJUnit4::class)
class InAppPaymentAuthCheckJobTest {
companion object {
private const val TEST_INTENT_ID = "test-intent-id"
private const val TEST_CLIENT_SECRET = "test-client-secret"
}
@get:Rule
val harness = SignalActivityRule()
@Before
fun setUp() {
SignalDatabase.inAppPayments.writableDatabase.deleteAll(InAppPaymentTable.TABLE_NAME)
SignalDatabase.donationReceipts.writableDatabase.deleteAll(DonationReceiptTable.TABLE_NAME)
}
@Test
fun givenCanceledOneTimeAuthRequiredPayment_whenICheck_thenIDoNotExpectAReceipt() {
initializeMockGetPaymentIntent(status = StripeIntentStatus.CANCELED)
SignalDatabase.inAppPayments.insert(
type = InAppPaymentType.ONE_TIME_DONATION,
state = InAppPaymentTable.State.WAITING_FOR_AUTHORIZATION,
subscriberId = null,
endOfPeriod = null,
inAppPaymentData = InAppPaymentData(
amount = FiatMoney(BigDecimal.ONE, Currency.getInstance("USD")).toFiatValue(),
waitForAuth = InAppPaymentData.WaitingForAuthorizationState(
stripeIntentId = TEST_INTENT_ID,
stripeClientSecret = TEST_CLIENT_SECRET
)
)
)
InAppPaymentAuthCheckJob().run()
val receipts = SignalDatabase.donationReceipts.getReceipts(InAppPaymentReceiptRecord.Type.ONE_TIME_DONATION)
receipts assert Matchers.empty()
}
@Test
fun givenSuccessfulOneTimeAuthRequiredPayment_whenICheck_thenIExpectAReceipt() {
initializeMockGetPaymentIntent(status = StripeIntentStatus.SUCCEEDED)
SignalDatabase.inAppPayments.insert(
type = InAppPaymentType.ONE_TIME_DONATION,
state = InAppPaymentTable.State.WAITING_FOR_AUTHORIZATION,
subscriberId = null,
endOfPeriod = null,
inAppPaymentData = InAppPaymentData(
amount = FiatMoney(BigDecimal.ONE, Currency.getInstance("USD")).toFiatValue(),
waitForAuth = InAppPaymentData.WaitingForAuthorizationState(
stripeIntentId = TEST_INTENT_ID,
stripeClientSecret = TEST_CLIENT_SECRET
)
)
)
InAppPaymentAuthCheckJob().run()
val receipts = SignalDatabase.donationReceipts.getReceipts(InAppPaymentReceiptRecord.Type.ONE_TIME_DONATION)
receipts assert Matchers.hasSize(1)
}
private fun initializeMockGetPaymentIntent(status: StripeIntentStatus) {
InstrumentationApplicationDependencyProvider.addMockWebRequestHandlers(
Get(TestStripePaths.getPaymentIntentPath(TEST_INTENT_ID, TEST_CLIENT_SECRET)) {
MockResponse().success(
StripePaymentIntent(
id = TEST_INTENT_ID,
clientSecret = TEST_CLIENT_SECRET,
status = status,
paymentMethod = null
)
)
}
)
}
}

View file

@ -0,0 +1,58 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.util
import org.signal.donations.StripePaths
/**
* Stripe paths should be prefixed with 'stripe/' in order to access the proper namespacing in
* the mock server. This object serves as a convenience delegate to StripePaths.
*/
object TestStripePaths {
/**
* @see StripePaths.getPaymentIntentPath
*/
fun getPaymentIntentPath(paymentIntentId: String, clientSecret: String): String {
return withNamespace(StripePaths.getPaymentIntentPath(paymentIntentId, clientSecret))
}
/**
* @see StripePaths.getPaymentIntentConfirmationPath
*/
fun getPaymentIntentConfirmationPath(paymentIntentId: String): String {
return withNamespace(StripePaths.getPaymentIntentConfirmationPath(paymentIntentId))
}
/**
* @see StripePaths.getSetupIntentPath
*/
fun getSetupIntentPath(setupIntentId: String, clientSecret: String): String {
return withNamespace(StripePaths.getSetupIntentPath(setupIntentId, clientSecret))
}
/**
* @see StripePaths.getSetupIntentConfirmationPath
*/
fun getSetupIntentConfirmationPath(setupIntentId: String): String {
return withNamespace(StripePaths.getSetupIntentConfirmationPath(setupIntentId))
}
/**
* @see StripePaths.getPaymentIntentPath
*/
fun getPaymentMethodsPath(): String {
return withNamespace(StripePaths.getPaymentMethodsPath())
}
/**
* @see StripePaths.getTokensPath
*/
fun getTokensPath(): String {
return withNamespace(StripePaths.getTokensPath())
}
private fun withNamespace(path: String) = "stripe/$path"
}

View file

@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.database
import android.content.Context import android.content.Context
import android.database.Cursor import android.database.Cursor
import androidx.annotation.VisibleForTesting
import androidx.core.content.contentValuesOf import androidx.core.content.contentValuesOf
import org.signal.core.util.CursorUtil import org.signal.core.util.CursorUtil
import org.signal.core.util.SqlUtil import org.signal.core.util.SqlUtil
@ -12,7 +13,8 @@ import java.util.Currency
class DonationReceiptTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTable(context, databaseHelper) { class DonationReceiptTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTable(context, databaseHelper) {
companion object { companion object {
private const val TABLE_NAME = "donation_receipt" @VisibleForTesting
const val TABLE_NAME = "donation_receipt"
private const val ID = "_id" private const val ID = "_id"
private const val TYPE = "receipt_type" private const val TYPE = "receipt_type"

View file

@ -5,6 +5,7 @@
package org.thoughtcrime.securesms.jobs package org.thoughtcrime.securesms.jobs
import androidx.annotation.VisibleForTesting
import io.reactivex.rxjava3.core.Single import io.reactivex.rxjava3.core.Single
import org.signal.core.util.logging.Log import org.signal.core.util.logging.Log
import org.signal.core.util.money.FiatMoney import org.signal.core.util.money.FiatMoney
@ -40,7 +41,8 @@ import kotlin.time.Duration.Companion.days
*/ */
class InAppPaymentAuthCheckJob private constructor(parameters: Parameters) : BaseJob(parameters), StripeApi.PaymentIntentFetcher, StripeApi.SetupIntentHelper { class InAppPaymentAuthCheckJob private constructor(parameters: Parameters) : BaseJob(parameters), StripeApi.PaymentIntentFetcher, StripeApi.SetupIntentHelper {
private constructor() : this( @VisibleForTesting
constructor() : this(
Parameters.Builder() Parameters.Builder()
.addConstraint(NetworkConstraint.KEY) .addConstraint(NetworkConstraint.KEY)
.setMaxAttempts(Parameters.UNLIMITED) .setMaxAttempts(Parameters.UNLIMITED)
@ -138,7 +140,10 @@ class InAppPaymentAuthCheckJob private constructor(parameters: Parameters) : Bas
) )
) )
checkIntentStatus(stripeIntentData.status) val checkIntentStatusResult = checkIntentStatus(stripeIntentData.status)
if (checkIntentStatusResult !is CheckResult.Success) {
return checkIntentStatusResult
}
Log.i(TAG, "Creating and inserting receipt.", true) Log.i(TAG, "Creating and inserting receipt.", true)
val receipt = when (inAppPayment.type) { val receipt = when (inAppPayment.type) {

View file

@ -29,6 +29,7 @@ object Environment {
@JvmStatic @JvmStatic
@get:JvmName("getStripeConfiguration") @get:JvmName("getStripeConfiguration")
val STRIPE_CONFIGURATION = StripeApi.Configuration( val STRIPE_CONFIGURATION = StripeApi.Configuration(
baseUrl = BuildConfig.STRIPE_BASE_URL,
publishableKey = BuildConfig.STRIPE_PUBLISHABLE_KEY publishableKey = BuildConfig.STRIPE_PUBLISHABLE_KEY
) )
} }

View file

@ -84,7 +84,7 @@ class StripeApi(
parameters["mandate_data[customer_acceptance][online][infer_from_client]"] = "true" parameters["mandate_data[customer_acceptance][online][infer_from_client]"] = "true"
} }
val (nextActionUri, returnUri) = postForm("setup_intents/${setupIntent.intentId}/confirm", parameters).use { response -> val (nextActionUri, returnUri) = postForm(StripePaths.getSetupIntentConfirmationPath(setupIntent.intentId), parameters).use { response ->
getNextAction(response) getNextAction(response)
} }
@ -132,7 +132,7 @@ class StripeApi(
parameters["mandate_data[customer_acceptance][online][infer_from_client]"] = "true" parameters["mandate_data[customer_acceptance][online][infer_from_client]"] = "true"
} }
val (nextActionUri, returnUri) = postForm("payment_intents/${paymentIntent.intentId}/confirm", parameters).use { response -> val (nextActionUri, returnUri) = postForm(StripePaths.getPaymentIntentConfirmationPath(paymentIntent.intentId), parameters).use { response ->
getNextAction(response) getNextAction(response)
} }
@ -145,7 +145,7 @@ class StripeApi(
*/ */
fun getSetupIntent(stripeIntentAccessor: StripeIntentAccessor): StripeSetupIntent { fun getSetupIntent(stripeIntentAccessor: StripeIntentAccessor): StripeSetupIntent {
return when (stripeIntentAccessor.objectType) { return when (stripeIntentAccessor.objectType) {
StripeIntentAccessor.ObjectType.SETUP_INTENT -> get("setup_intents/${stripeIntentAccessor.intentId}?client_secret=${stripeIntentAccessor.intentClientSecret}&expand[0]=latest_attempt").use { StripeIntentAccessor.ObjectType.SETUP_INTENT -> get(StripePaths.getSetupIntentPath(stripeIntentAccessor.intentId, stripeIntentAccessor.intentClientSecret)).use {
val body = it.body?.string() val body = it.body?.string()
try { try {
objectMapper.readValue(body!!) objectMapper.readValue(body!!)
@ -167,7 +167,7 @@ class StripeApi(
*/ */
fun getPaymentIntent(stripeIntentAccessor: StripeIntentAccessor): StripePaymentIntent { fun getPaymentIntent(stripeIntentAccessor: StripeIntentAccessor): StripePaymentIntent {
return when (stripeIntentAccessor.objectType) { return when (stripeIntentAccessor.objectType) {
StripeIntentAccessor.ObjectType.PAYMENT_INTENT -> get("payment_intents/${stripeIntentAccessor.intentId}?client_secret=${stripeIntentAccessor.intentClientSecret}").use { StripeIntentAccessor.ObjectType.PAYMENT_INTENT -> get(StripePaths.getPaymentIntentPath(stripeIntentAccessor.intentId, stripeIntentAccessor.intentClientSecret)).use {
val body = it.body?.string() val body = it.body?.string()
try { try {
Log.d(TAG, "Reading StripePaymentIntent from JSON") Log.d(TAG, "Reading StripePaymentIntent from JSON")
@ -229,7 +229,7 @@ class StripeApi(
CARD_CVC_KEY to cardData.cvc CARD_CVC_KEY to cardData.cvc
) )
postForm("tokens", parameters).use { response -> postForm(StripePaths.getTokensPath(), parameters).use { response ->
val body = response.body ?: throw StripeError.FailedToCreatePaymentSourceFromCardData val body = response.body ?: throw StripeError.FailedToCreatePaymentSourceFromCardData
return CreditCardPaymentSource(JSONObject(body.string())) return CreditCardPaymentSource(JSONObject(body.string()))
} }
@ -257,7 +257,7 @@ class StripeApi(
"billing_details[name]" to paymentSource.sepaDebitData.name "billing_details[name]" to paymentSource.sepaDebitData.name
) )
return postForm("payment_methods", parameters) return postForm(StripePaths.getPaymentMethodsPath(), parameters)
} }
private fun createPaymentMethodForIDEAL(paymentSource: IDEALPaymentSource): Response { private fun createPaymentMethodForIDEAL(paymentSource: IDEALPaymentSource): Response {
@ -268,7 +268,7 @@ class StripeApi(
"billing_details[name]" to paymentSource.idealData.name "billing_details[name]" to paymentSource.idealData.name
) )
return postForm("payment_methods", parameters) return postForm(StripePaths.getPaymentMethodsPath(), parameters)
} }
private fun createPaymentMethodForToken(paymentSource: PaymentSource): Response { private fun createPaymentMethodForToken(paymentSource: PaymentSource): Response {
@ -278,7 +278,7 @@ class StripeApi(
"type" to "card" "type" to "card"
) )
return postForm("payment_methods", parameters) return postForm(StripePaths.getPaymentMethodsPath(), parameters)
} }
private fun get(endpoint: String): Response { private fun get(endpoint: String): Response {

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

@ -1,6 +1,7 @@
package org.signal.donations.json package org.signal.donations.json
import com.fasterxml.jackson.annotation.JsonCreator import com.fasterxml.jackson.annotation.JsonCreator
import com.fasterxml.jackson.annotation.JsonValue
/** /**
* Stripe intent status, from: * Stripe intent status, from:
@ -24,4 +25,9 @@ enum class StripeIntentStatus(private val code: String) {
@JsonCreator @JsonCreator
fun fromCode(code: String): StripeIntentStatus = entries.first { it.code == code } fun fromCode(code: String): StripeIntentStatus = entries.first { it.code == code }
} }
@JsonValue
fun toValue(): String {
return code
}
} }