From 3eea331e83f217636d912d0b91f2aabfca2959a9 Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Tue, 10 Dec 2024 11:34:17 -0400 Subject: [PATCH] Fix bad one-time-payment receipt creation for cancelled iDEAL. --- app/build.gradle.kts | 2 + .../jobs/InAppPaymentAuthCheckJobTest.kt | 109 ++++++++++++++++++ .../securesms/util/TestStripePaths.kt | 58 ++++++++++ .../database/DonationReceiptTable.kt | 4 +- .../jobs/InAppPaymentAuthCheckJob.kt | 9 +- .../securesms/util/Environment.kt | 1 + .../java/org/signal/donations/StripeApi.kt | 16 +-- .../java/org/signal/donations/StripePaths.kt | 51 ++++++++ .../donations/json/StripeIntentStatus.kt | 6 + 9 files changed, 245 insertions(+), 11 deletions(-) create mode 100644 app/src/androidTest/java/org/thoughtcrime/securesms/jobs/InAppPaymentAuthCheckJobTest.kt create mode 100644 app/src/androidTest/java/org/thoughtcrime/securesms/util/TestStripePaths.kt create mode 100644 donations/lib/src/main/java/org/signal/donations/StripePaths.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 24464b86d7..45dfee3aa4 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -228,6 +228,7 @@ android { buildConfigField("String", "BUILD_ENVIRONMENT_TYPE", "\"unset\"") buildConfigField("String", "BUILD_VARIANT_TYPE", "\"unset\"") 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("boolean", "TRACING_ENABLED", "false") buildConfigField("boolean", "MESSAGE_BACKUP_RESTORE_ENABLED", "false") @@ -301,6 +302,7 @@ android { applicationIdSuffix = ".instrumentation" buildConfigField("String", "BUILD_VARIANT_TYPE", "\"Instrumentation\"") + buildConfigField("String", "STRIPE_BASE_URL", "\"http://127.0.0.1:8080/stripe\"") } create("spinner") { diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/jobs/InAppPaymentAuthCheckJobTest.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/jobs/InAppPaymentAuthCheckJobTest.kt new file mode 100644 index 0000000000..4db908b04c --- /dev/null +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/jobs/InAppPaymentAuthCheckJobTest.kt @@ -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 + ) + ) + } + ) + } +} diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/util/TestStripePaths.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/util/TestStripePaths.kt new file mode 100644 index 0000000000..f83bf10a49 --- /dev/null +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/util/TestStripePaths.kt @@ -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" +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/DonationReceiptTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/DonationReceiptTable.kt index 4439c6f370..0ef549719a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/DonationReceiptTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/DonationReceiptTable.kt @@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.database import android.content.Context import android.database.Cursor +import androidx.annotation.VisibleForTesting import androidx.core.content.contentValuesOf import org.signal.core.util.CursorUtil import org.signal.core.util.SqlUtil @@ -12,7 +13,8 @@ import java.util.Currency class DonationReceiptTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTable(context, databaseHelper) { companion object { - private const val TABLE_NAME = "donation_receipt" + @VisibleForTesting + const val TABLE_NAME = "donation_receipt" private const val ID = "_id" private const val TYPE = "receipt_type" diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentAuthCheckJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentAuthCheckJob.kt index ca84584e0c..096a11395d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentAuthCheckJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentAuthCheckJob.kt @@ -5,6 +5,7 @@ package org.thoughtcrime.securesms.jobs +import androidx.annotation.VisibleForTesting import io.reactivex.rxjava3.core.Single import org.signal.core.util.logging.Log 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 { - private constructor() : this( + @VisibleForTesting + constructor() : this( Parameters.Builder() .addConstraint(NetworkConstraint.KEY) .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) val receipt = when (inAppPayment.type) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/Environment.kt b/app/src/main/java/org/thoughtcrime/securesms/util/Environment.kt index 355c630276..2c86044294 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/Environment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/Environment.kt @@ -29,6 +29,7 @@ object Environment { @JvmStatic @get:JvmName("getStripeConfiguration") val STRIPE_CONFIGURATION = StripeApi.Configuration( + baseUrl = BuildConfig.STRIPE_BASE_URL, publishableKey = BuildConfig.STRIPE_PUBLISHABLE_KEY ) } diff --git a/donations/lib/src/main/java/org/signal/donations/StripeApi.kt b/donations/lib/src/main/java/org/signal/donations/StripeApi.kt index 8559433518..0e5544debf 100644 --- a/donations/lib/src/main/java/org/signal/donations/StripeApi.kt +++ b/donations/lib/src/main/java/org/signal/donations/StripeApi.kt @@ -84,7 +84,7 @@ class StripeApi( 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) } @@ -132,7 +132,7 @@ class StripeApi( 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) } @@ -145,7 +145,7 @@ class StripeApi( */ fun getSetupIntent(stripeIntentAccessor: StripeIntentAccessor): StripeSetupIntent { return when (stripeIntentAccessor.objectType) { - StripeIntentAccessor.ObjectType.SETUP_INTENT -> get("setup_intents/${stripeIntentAccessor.intentId}?client_secret=${stripeIntentAccessor.intentClientSecret}&expand[0]=latest_attempt").use { + StripeIntentAccessor.ObjectType.SETUP_INTENT -> get(StripePaths.getSetupIntentPath(stripeIntentAccessor.intentId, stripeIntentAccessor.intentClientSecret)).use { val body = it.body?.string() try { objectMapper.readValue(body!!) @@ -167,7 +167,7 @@ class StripeApi( */ fun getPaymentIntent(stripeIntentAccessor: StripeIntentAccessor): StripePaymentIntent { 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() try { Log.d(TAG, "Reading StripePaymentIntent from JSON") @@ -229,7 +229,7 @@ class StripeApi( CARD_CVC_KEY to cardData.cvc ) - postForm("tokens", parameters).use { response -> + postForm(StripePaths.getTokensPath(), parameters).use { response -> val body = response.body ?: throw StripeError.FailedToCreatePaymentSourceFromCardData return CreditCardPaymentSource(JSONObject(body.string())) } @@ -257,7 +257,7 @@ class StripeApi( "billing_details[name]" to paymentSource.sepaDebitData.name ) - return postForm("payment_methods", parameters) + return postForm(StripePaths.getPaymentMethodsPath(), parameters) } private fun createPaymentMethodForIDEAL(paymentSource: IDEALPaymentSource): Response { @@ -268,7 +268,7 @@ class StripeApi( "billing_details[name]" to paymentSource.idealData.name ) - return postForm("payment_methods", parameters) + return postForm(StripePaths.getPaymentMethodsPath(), parameters) } private fun createPaymentMethodForToken(paymentSource: PaymentSource): Response { @@ -278,7 +278,7 @@ class StripeApi( "type" to "card" ) - return postForm("payment_methods", parameters) + return postForm(StripePaths.getPaymentMethodsPath(), parameters) } private fun get(endpoint: String): Response { diff --git a/donations/lib/src/main/java/org/signal/donations/StripePaths.kt b/donations/lib/src/main/java/org/signal/donations/StripePaths.kt new file mode 100644 index 0000000000..53e67cc977 --- /dev/null +++ b/donations/lib/src/main/java/org/signal/donations/StripePaths.kt @@ -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" +} diff --git a/donations/lib/src/main/java/org/signal/donations/json/StripeIntentStatus.kt b/donations/lib/src/main/java/org/signal/donations/json/StripeIntentStatus.kt index 6197bacf44..5976162559 100644 --- a/donations/lib/src/main/java/org/signal/donations/json/StripeIntentStatus.kt +++ b/donations/lib/src/main/java/org/signal/donations/json/StripeIntentStatus.kt @@ -1,6 +1,7 @@ package org.signal.donations.json import com.fasterxml.jackson.annotation.JsonCreator +import com.fasterxml.jackson.annotation.JsonValue /** * Stripe intent status, from: @@ -24,4 +25,9 @@ enum class StripeIntentStatus(private val code: String) { @JsonCreator fun fromCode(code: String): StripeIntentStatus = entries.first { it.code == code } } + + @JsonValue + fun toValue(): String { + return code + } }