Fix bad one-time-payment receipt creation for cancelled iDEAL.
This commit is contained in:
parent
fa72a1788b
commit
3eea331e83
9 changed files with 245 additions and 11 deletions
|
@ -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") {
|
||||
|
|
|
@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
}
|
|
@ -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"
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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"
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue