Skip to content

Commit

Permalink
Support confirmation of save LinkPaymentDetails (#10041)
Browse files Browse the repository at this point in the history
toluo-stripe authored Jan 30, 2025
1 parent 863cdeb commit 9839d43
Showing 8 changed files with 247 additions and 14 deletions.
Original file line number Diff line number Diff line change
@@ -3,9 +3,14 @@ package com.stripe.android.link.confirmation
import com.stripe.android.core.Logger
import com.stripe.android.core.strings.resolvableString
import com.stripe.android.link.LinkConfiguration
import com.stripe.android.link.LinkPaymentDetails
import com.stripe.android.link.model.LinkAccount
import com.stripe.android.model.ConfirmPaymentIntentParams
import com.stripe.android.model.ConsumerPaymentDetails
import com.stripe.android.model.PaymentMethod
import com.stripe.android.model.PaymentMethodCreateParams
import com.stripe.android.model.PaymentMethodOptionsParams
import com.stripe.android.model.wallets.Wallet
import com.stripe.android.paymentelement.confirmation.ConfirmationHandler
import com.stripe.android.paymentelement.confirmation.PaymentMethodConfirmationOption
import com.stripe.android.paymentsheet.PaymentSheet
@@ -21,9 +26,35 @@ internal class DefaultLinkConfirmationHandler @Inject constructor(
paymentDetails: ConsumerPaymentDetails.PaymentDetails,
linkAccount: LinkAccount,
cvc: String?
): Result {
return confirm {
newConfirmationArgs(
paymentDetails = paymentDetails,
linkAccount = linkAccount,
cvc = cvc
)
}
}

override suspend fun confirm(
paymentDetails: LinkPaymentDetails,
linkAccount: LinkAccount,
cvc: String?
): Result {
return confirm {
confirmationArgs(
paymentDetails = paymentDetails,
linkAccount = linkAccount,
cvc = cvc
)
}
}

private suspend fun confirm(
createArgs: () -> ConfirmationHandler.Args
): Result {
return runCatching {
val args = confirmationArgs(paymentDetails, linkAccount, cvc)
val args = createArgs()
confirmationHandler.start(args)
val result = confirmationHandler.awaitResult()
transformResult(result)
@@ -55,6 +86,28 @@ internal class DefaultLinkConfirmationHandler @Inject constructor(
}

private fun confirmationArgs(
paymentDetails: LinkPaymentDetails,
linkAccount: LinkAccount,
cvc: String?
): ConfirmationHandler.Args {
return when (paymentDetails) {
is LinkPaymentDetails.New -> {
newConfirmationArgs(
paymentDetails = paymentDetails.paymentDetails,
linkAccount = linkAccount,
cvc = cvc
)
}
is LinkPaymentDetails.Saved -> {
savedConfirmationArgs(
paymentDetails = paymentDetails,
cvc = cvc
)
}
}
}

private fun newConfirmationArgs(
paymentDetails: ConsumerPaymentDetails.PaymentDetails,
linkAccount: LinkAccount,
cvc: String?
@@ -76,6 +129,37 @@ internal class DefaultLinkConfirmationHandler @Inject constructor(
)
}

private fun savedConfirmationArgs(
paymentDetails: LinkPaymentDetails,
cvc: String?
): ConfirmationHandler.Args {
return ConfirmationHandler.Args(
intent = configuration.stripeIntent,
confirmationOption = PaymentMethodConfirmationOption.Saved(
paymentMethod = PaymentMethod.Builder()
.setId(paymentDetails.paymentDetails.id)
.setCode(paymentDetails.paymentMethodCreateParams.typeCode)
.setCard(
PaymentMethod.Card(
last4 = paymentDetails.paymentDetails.last4,
wallet = Wallet.LinkWallet(paymentDetails.paymentDetails.last4),
)
)
.setType(PaymentMethod.Type.Card)
.build(),
optionsParams = PaymentMethodOptionsParams.Card(
setupFutureUsage = ConfirmPaymentIntentParams.SetupFutureUsage.OffSession,
cvc = cvc?.takeIf {
configuration.passthroughModeEnabled.not()
}
)
),
appearance = PaymentSheet.Appearance(),
initializationMode = configuration.initializationMode,
shippingDetails = configuration.shippingDetails
)
}

private fun createPaymentMethodCreateParams(
selectedPaymentDetails: ConsumerPaymentDetails.PaymentDetails,
linkAccount: LinkAccount,
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.stripe.android.link.confirmation

import com.stripe.android.core.strings.ResolvableString
import com.stripe.android.link.LinkPaymentDetails
import com.stripe.android.link.model.LinkAccount
import com.stripe.android.model.ConsumerPaymentDetails
import com.stripe.android.paymentelement.confirmation.ConfirmationHandler
@@ -12,6 +13,12 @@ internal interface LinkConfirmationHandler {
cvc: String? = null
): Result

suspend fun confirm(
paymentDetails: LinkPaymentDetails,
linkAccount: LinkAccount,
cvc: String? = null
): Result

fun interface Factory {
fun create(confirmationHandler: ConfirmationHandler): LinkConfirmationHandler
}
Original file line number Diff line number Diff line change
@@ -9,6 +9,7 @@ import com.stripe.android.common.exception.stripeErrorMessage
import com.stripe.android.core.Logger
import com.stripe.android.link.LinkActivityResult
import com.stripe.android.link.LinkConfiguration
import com.stripe.android.link.LinkPaymentDetails
import com.stripe.android.link.account.LinkAccountManager
import com.stripe.android.link.confirmation.LinkConfirmationHandler
import com.stripe.android.link.confirmation.Result
@@ -17,7 +18,6 @@ import com.stripe.android.link.model.LinkAccount
import com.stripe.android.link.ui.PrimaryButtonState
import com.stripe.android.link.ui.completePaymentButtonLabel
import com.stripe.android.lpmfoundations.paymentmethod.PaymentMethodMetadata
import com.stripe.android.model.ConsumerPaymentDetails
import com.stripe.android.model.PaymentMethod
import com.stripe.android.paymentsheet.DefaultFormHelper
import com.stripe.android.paymentsheet.FormHelper
@@ -78,7 +78,7 @@ internal class PaymentMethodViewModel @Inject constructor(
onSuccess = { linkPaymentDetails ->
val cardMap = paymentMethodCreateParams.toParamMap()["card"] as? Map<*, *>?
performConfirmation(
paymentDetails = linkPaymentDetails.paymentDetails,
paymentDetails = linkPaymentDetails,
cvc = cardMap?.get("cvc") as? String?
)
updateButtonState(PrimaryButtonState.Enabled)
@@ -100,7 +100,7 @@ internal class PaymentMethodViewModel @Inject constructor(
}

private suspend fun performConfirmation(
paymentDetails: ConsumerPaymentDetails.PaymentDetails,
paymentDetails: LinkPaymentDetails,
cvc: String?
) {
val result = linkConfirmationHandler.confirm(
Original file line number Diff line number Diff line change
@@ -116,6 +116,11 @@ internal object TestFactory {
originalParams = mock()
)

val LINK_SAVED_PAYMENT_DETAILS = LinkPaymentDetails.Saved(
paymentDetails = CONSUMER_PAYMENT_DETAILS_CARD,
paymentMethodCreateParams = PAYMENT_METHOD_CREATE_PARAMS,
)

val LINK_ACCOUNT = LinkAccount(CONSUMER_SESSION)

val CONSUMER_PAYMENT_DETAILS: ConsumerPaymentDetails = ConsumerPaymentDetails(
Original file line number Diff line number Diff line change
@@ -4,10 +4,12 @@ import com.google.common.truth.Truth.assertThat
import com.stripe.android.core.Logger
import com.stripe.android.core.strings.resolvableString
import com.stripe.android.link.LinkConfiguration
import com.stripe.android.link.LinkPaymentDetails
import com.stripe.android.link.TestFactory
import com.stripe.android.link.model.LinkAccount
import com.stripe.android.model.ConsumerPaymentDetails
import com.stripe.android.model.PaymentMethodCreateParams
import com.stripe.android.model.PaymentMethodOptionsParams
import com.stripe.android.model.SetupIntentFixtures
import com.stripe.android.paymentelement.confirmation.ConfirmationHandler
import com.stripe.android.paymentelement.confirmation.FakeConfirmationHandler
@@ -169,6 +171,98 @@ internal class DefaultLinkConfirmationHandlerTest {
.containsExactly("DefaultLinkConfirmationHandler: Payment confirmation returned null" to null)
}

@Test
fun `confirm with New LinkPaymentDetails calls uses correct confirmation args`() = runTest(dispatcher) {
val configuration = TestFactory.LINK_CONFIGURATION
val confirmationHandler = FakeConfirmationHandler()
val handler = createHandler(
confirmationHandler = confirmationHandler,
configuration = configuration
)

confirmationHandler.awaitResultTurbine.add(
item = ConfirmationHandler.Result.Succeeded(
intent = configuration.stripeIntent,
deferredIntentConfirmationType = null
)
)

val result = handler.confirm(
paymentDetails = TestFactory.LINK_NEW_PAYMENT_DETAILS,
linkAccount = TestFactory.LINK_ACCOUNT,
cvc = CVC
)

assertThat(result).isEqualTo(Result.Succeeded)
confirmationHandler.startTurbine.awaitItem().assertConfirmationArgs(
configuration = configuration,
linkAccount = TestFactory.LINK_ACCOUNT,
paymentDetails = TestFactory.CONSUMER_PAYMENT_DETAILS_CARD,
cvc = CVC
)
}

@Test
fun `confirm with saved LinkPaymentDetails creates correct confirmation args`() = runTest(dispatcher) {
val configuration = TestFactory.LINK_CONFIGURATION
val confirmationHandler = FakeConfirmationHandler()
val handler = createHandler(
confirmationHandler = confirmationHandler,
configuration = configuration
)

confirmationHandler.awaitResultTurbine.add(
item = ConfirmationHandler.Result.Succeeded(
intent = configuration.stripeIntent,
deferredIntentConfirmationType = null
)
)

val savedPaymentDetails = TestFactory.LINK_SAVED_PAYMENT_DETAILS
val result = handler.confirm(
paymentDetails = savedPaymentDetails,
linkAccount = TestFactory.LINK_ACCOUNT,
cvc = CVC
)

assertThat(result).isEqualTo(Result.Succeeded)
confirmationHandler.startTurbine.awaitItem().assertSavedConfirmationArgs(
configuration = configuration,
paymentDetails = TestFactory.LINK_SAVED_PAYMENT_DETAILS,
cvc = CVC
)
}

@Test
fun `confirm with saved LinkPaymentDetails in passthrough mode omits CVC`() = runTest(dispatcher) {
val configuration = TestFactory.LINK_CONFIGURATION.copy(passthroughModeEnabled = true)
val confirmationHandler = FakeConfirmationHandler()
val handler = createHandler(
confirmationHandler = confirmationHandler,
configuration = configuration
)

confirmationHandler.awaitResultTurbine.add(
item = ConfirmationHandler.Result.Succeeded(
intent = configuration.stripeIntent,
deferredIntentConfirmationType = null
)
)

val result = handler.confirm(
paymentDetails = TestFactory.LINK_SAVED_PAYMENT_DETAILS,
linkAccount = TestFactory.LINK_ACCOUNT,
cvc = CVC
)

assertThat(result).isEqualTo(Result.Succeeded)
confirmationHandler.startTurbine.awaitItem().assertSavedConfirmationArgs(
configuration = configuration,
paymentDetails = TestFactory.LINK_SAVED_PAYMENT_DETAILS,
cvc = null
)
}

private fun ConfirmationHandler.Args.assertConfirmationArgs(
configuration: LinkConfiguration,
paymentDetails: ConsumerPaymentDetails.PaymentDetails,
@@ -188,6 +282,21 @@ internal class DefaultLinkConfirmationHandlerTest {
assertThat(initializationMode).isEqualTo(configuration.initializationMode)
}

private fun ConfirmationHandler.Args.assertSavedConfirmationArgs(
configuration: LinkConfiguration,
paymentDetails: LinkPaymentDetails.Saved,
cvc: String?,
) {
assertThat(intent).isEqualTo(configuration.stripeIntent)
val option = confirmationOption as PaymentMethodConfirmationOption.Saved
assertThat(option.paymentMethod.id).isEqualTo(paymentDetails.paymentDetails.id)

val optionsCard = option.optionsParams as? PaymentMethodOptionsParams.Card
assertThat(optionsCard?.cvc).isEqualTo(cvc)
assertThat(shippingDetails).isEqualTo(configuration.shippingDetails)
assertThat(initializationMode).isEqualTo(configuration.initializationMode)
}

private fun createHandler(
configuration: LinkConfiguration = TestFactory.LINK_CONFIGURATION,
logger: Logger = FakeLogger(),
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
package com.stripe.android.link.confirmation

import com.stripe.android.link.LinkPaymentDetails
import com.stripe.android.link.model.LinkAccount
import com.stripe.android.model.ConsumerPaymentDetails

internal class FakeLinkConfirmationHandler : LinkConfirmationHandler {
var confirmResult: Result = Result.Succeeded
var confirmWithLinkPaymentDetailsResult: Result = Result.Succeeded
val calls = arrayListOf<Call>()
val confirmWithLinkPaymentDetailsCall = arrayListOf<ConfirmWithLinkPaymentDetailsCall>()

override suspend fun confirm(
paymentDetails: ConsumerPaymentDetails.PaymentDetails,
@@ -22,9 +25,30 @@ internal class FakeLinkConfirmationHandler : LinkConfirmationHandler {
return confirmResult
}

override suspend fun confirm(
paymentDetails: LinkPaymentDetails,
linkAccount: LinkAccount,
cvc: String?
): Result {
confirmWithLinkPaymentDetailsCall.add(
element = ConfirmWithLinkPaymentDetailsCall(
paymentDetails = paymentDetails,
linkAccount = linkAccount,
cvc = cvc
)
)
return confirmWithLinkPaymentDetailsResult
}

data class Call(
val paymentDetails: ConsumerPaymentDetails.PaymentDetails,
val linkAccount: LinkAccount,
val cvc: String?
)

data class ConfirmWithLinkPaymentDetailsCall(
val paymentDetails: LinkPaymentDetails,
val linkAccount: LinkAccount,
val cvc: String?
)
}
Original file line number Diff line number Diff line change
@@ -134,7 +134,8 @@ internal class PaymentMethodScreenTest {

fillCardDetails()

linkConfirmationHandler.confirmResult = LinkConfirmationResult.Failed("oops".resolvableString)
linkConfirmationHandler.confirmWithLinkPaymentDetailsResult =
LinkConfirmationResult.Failed("oops".resolvableString)

onPayButton()
.scrollToAndAssertDisplayed()
Original file line number Diff line number Diff line change
@@ -112,9 +112,11 @@ class PaymentMethodViewModelTest {

viewModel.onPayClicked()

assertThat(linkConfirmationHandler.calls.first().paymentDetails)
.isEqualTo(TestFactory.LINK_NEW_PAYMENT_DETAILS.paymentDetails)
assertThat(linkConfirmationHandler.calls.first().cvc).isEqualTo("111")
assertThat(linkConfirmationHandler.confirmWithLinkPaymentDetailsCall).hasSize(1)
val call = linkConfirmationHandler.confirmWithLinkPaymentDetailsCall.first()
assertThat(call.paymentDetails)
.isEqualTo(TestFactory.LINK_NEW_PAYMENT_DETAILS)
assertThat(call.cvc).isEqualTo("111")
assertThat(result).isEqualTo(LinkActivityResult.Completed)
assertThat(viewModel.state.value.primaryButtonState).isEqualTo(PrimaryButtonState.Enabled)
}
@@ -142,7 +144,7 @@ class PaymentMethodViewModelTest {

viewModel.onPayClicked()

assertThat(linkConfirmationHandler.calls).isEmpty()
assertThat(linkConfirmationHandler.confirmWithLinkPaymentDetailsCall).isEmpty()

assertThat(result).isEqualTo(null)
assertThat(viewModel.state.value.primaryButtonState).isEqualTo(PrimaryButtonState.Enabled)
@@ -156,7 +158,8 @@ class PaymentMethodViewModelTest {
fun `onPayClicked handles confirmation failure`() = runTest {
val linkConfirmationHandler = FakeLinkConfirmationHandler()

linkConfirmationHandler.confirmResult = LinkConfirmationResult.Failed("Payment failed".resolvableString)
linkConfirmationHandler.confirmWithLinkPaymentDetailsResult =
LinkConfirmationResult.Failed("Payment failed".resolvableString)

val viewModel = createViewModel(linkConfirmationHandler = linkConfirmationHandler)

@@ -166,15 +169,15 @@ class PaymentMethodViewModelTest {

viewModel.onPayClicked()

assertThat(linkConfirmationHandler.calls).hasSize(1)
assertThat(linkConfirmationHandler.confirmWithLinkPaymentDetailsCall).hasSize(1)
assertThat(viewModel.state.value.primaryButtonState).isEqualTo(PrimaryButtonState.Enabled)
assertThat(viewModel.state.value.errorMessage).isEqualTo("Payment failed".resolvableString)
}

@Test
fun `onPayClicked handles cancellation`() = runTest {
val linkConfirmationHandler = FakeLinkConfirmationHandler()
linkConfirmationHandler.confirmResult = LinkConfirmationResult.Canceled
linkConfirmationHandler.confirmWithLinkPaymentDetailsResult = LinkConfirmationResult.Canceled

val viewModel = createViewModel(linkConfirmationHandler = linkConfirmationHandler)

@@ -184,7 +187,7 @@ class PaymentMethodViewModelTest {

viewModel.onPayClicked()

assertThat(linkConfirmationHandler.calls).hasSize(1)
assertThat(linkConfirmationHandler.confirmWithLinkPaymentDetailsCall).hasSize(1)
assertThat(viewModel.state.value.primaryButtonState).isEqualTo(PrimaryButtonState.Enabled)
assertThat(viewModel.state.value.errorMessage).isNull()
}
@@ -201,7 +204,7 @@ class PaymentMethodViewModelTest {

viewModel.onPayClicked()

assertThat(linkConfirmationHandler.calls).isEmpty()
assertThat(linkConfirmationHandler.confirmWithLinkPaymentDetailsCall).isEmpty()
assertThat(viewModel.state.value.primaryButtonState).isEqualTo(PrimaryButtonState.Disabled)
assertThat(logger.errorLogs)
.containsExactly("PaymentMethodViewModel: onPayClicked without paymentMethodCreateParams" to null)

0 comments on commit 9839d43

Please sign in to comment.