Skip to content

Commit

Permalink
Support update functionality in PaymentSheet. (#7640)
Browse files Browse the repository at this point in the history
* Support update functionality in `PaymentSheet`.

* Add improvements to update functionality in `PaymentSheet`.
  • Loading branch information
samer-stripe authored Nov 17, 2023
1 parent b614347 commit 79cc4a3
Show file tree
Hide file tree
Showing 9 changed files with 231 additions and 10 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import java.util.Objects
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
@Parcelize
class PaymentMethodUpdateParams private constructor(
internal val paymentMethodId: String,
val paymentMethodId: String,
private val card: Card? = null,
private val billingDetails: PaymentMethod.BillingDetails? = null,
private val metadata: Map<String, String>? = null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import com.stripe.android.core.networking.ApiRequest
import com.stripe.android.model.Customer
import com.stripe.android.model.ListPaymentMethodsParams
import com.stripe.android.model.PaymentMethod
import com.stripe.android.model.PaymentMethodUpdateParams
import com.stripe.android.model.wallets.Wallet
import com.stripe.android.networking.StripeRepository
import com.stripe.android.payments.core.injection.PRODUCT_USAGE
Expand Down Expand Up @@ -129,4 +130,18 @@ internal class CustomerApiRepository @Inject constructor(
).onFailure {
logger.error("Failed to attach payment method $paymentMethodId.", it)
}

override suspend fun updatePaymentMethod(
customerConfig: PaymentSheet.CustomerConfiguration,
params: PaymentMethodUpdateParams
): Result<PaymentMethod> =
stripeRepository.updatePaymentMethod(
paymentMethodUpdateParams = params,
options = ApiRequest.Options(
apiKey = customerConfig.ephemeralKeySecret,
stripeAccount = lazyPaymentConfig.get().stripeAccountId,
)
).onFailure {
logger.error("Failed to update payment method ${params.paymentMethodId}.", it)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package com.stripe.android.paymentsheet.repositories

import com.stripe.android.model.Customer
import com.stripe.android.model.PaymentMethod
import com.stripe.android.model.PaymentMethodUpdateParams
import com.stripe.android.paymentsheet.PaymentSheet

/**
Expand Down Expand Up @@ -42,4 +43,9 @@ internal interface CustomerRepository {
customerConfig: PaymentSheet.CustomerConfiguration,
paymentMethodId: String
): Result<PaymentMethod>

suspend fun updatePaymentMethod(
customerConfig: PaymentSheet.CustomerConfiguration,
params: PaymentMethodUpdateParams
): Result<PaymentMethod>
}
Original file line number Diff line number Diff line change
Expand Up @@ -123,8 +123,6 @@ internal class DefaultEditPaymentMethodViewInteractor constructor(

updateResult.onSuccess { method ->
paymentMethod.emit(method)
}.onFailure {
// TODO(samer-stripe): Display toast on update method failure?
}

status.emit(EditPaymentMethodViewState.Status.Idle)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,11 @@ import com.stripe.android.core.Logger
import com.stripe.android.link.LinkConfigurationCoordinator
import com.stripe.android.link.ui.inline.InlineSignupViewState
import com.stripe.android.link.ui.inline.UserInput
import com.stripe.android.model.CardBrand
import com.stripe.android.model.PaymentIntent
import com.stripe.android.model.PaymentMethod
import com.stripe.android.model.PaymentMethodCode
import com.stripe.android.model.PaymentMethodUpdateParams
import com.stripe.android.model.SetupIntent
import com.stripe.android.model.StripeIntent
import com.stripe.android.payments.paymentlauncher.PaymentResult
Expand Down Expand Up @@ -462,17 +464,52 @@ internal abstract class BaseSheetViewModel(
delay(TEMP_DELAY)
true
},
updateExecutor = { _, _ ->
// TODO(samer-stripe): Replace with update operation
delay(TEMP_DELAY)

Result.success(paymentMethod)
},
updateExecutor = { method, brand ->
modifyCardPaymentMethod(method, brand)
}
)
)
)
}

private suspend fun modifyCardPaymentMethod(
paymentMethod: PaymentMethod,
brand: CardBrand
): Result<PaymentMethod> {
val customerConfig = config.customer
val paymentMethodId = paymentMethod.id

return customerRepository.updatePaymentMethod(
customerConfig = customerConfig!!,
params = PaymentMethodUpdateParams.createCard(
paymentMethodId = paymentMethodId!!,
networks = PaymentMethodUpdateParams.Card.Networks(
preferred = brand.code
)
)
).onSuccess { updatedMethod ->
savedStateHandle[SAVE_PAYMENT_METHODS] = paymentMethods
.value
?.let { savedPaymentMethods ->
savedPaymentMethods.map { savedMethod ->
val savedId = savedMethod.id
val updatedId = updatedMethod.id

if (updatedId != null && savedId != null && updatedId == savedId) {
updatedMethod
} else {
paymentMethod
}
}
}

handleBackPressed()
}.onFailure {
// TODO(samer-stripe): Localize error message with proper strings.
onError("Failed to update payment method")
}
}

private fun mapToHeaderTextResource(
screen: PaymentSheetScreen?,
isLinkAvailable: Boolean,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import com.stripe.android.model.PaymentMethodCreateParamsFixtures
import com.stripe.android.model.PaymentMethodFixtures
import com.stripe.android.model.PaymentMethodFixtures.SEPA_DEBIT_PAYMENT_METHOD
import com.stripe.android.model.PaymentMethodOptionsParams
import com.stripe.android.model.PaymentMethodUpdateParams
import com.stripe.android.model.SetupIntentFixtures
import com.stripe.android.model.StripeIntent
import com.stripe.android.payments.paymentlauncher.InternalPaymentResult
Expand All @@ -48,14 +49,18 @@ import com.stripe.android.paymentsheet.analytics.PaymentSheetConfirmationError
import com.stripe.android.paymentsheet.model.PaymentSelection
import com.stripe.android.paymentsheet.model.PaymentSheetViewState
import com.stripe.android.paymentsheet.model.SavedSelection
import com.stripe.android.paymentsheet.navigation.PaymentSheetScreen
import com.stripe.android.paymentsheet.navigation.PaymentSheetScreen.AddAnotherPaymentMethod
import com.stripe.android.paymentsheet.navigation.PaymentSheetScreen.AddFirstPaymentMethod
import com.stripe.android.paymentsheet.navigation.PaymentSheetScreen.SelectSavedPaymentMethods
import com.stripe.android.paymentsheet.paymentdatacollection.ach.USBankAccountFormScreenState
import com.stripe.android.paymentsheet.repositories.CustomerRepository
import com.stripe.android.paymentsheet.state.GooglePayState
import com.stripe.android.paymentsheet.state.LinkState
import com.stripe.android.paymentsheet.ui.EditPaymentMethodViewAction
import com.stripe.android.paymentsheet.ui.EditPaymentMethodViewState
import com.stripe.android.paymentsheet.ui.PrimaryButton
import com.stripe.android.paymentsheet.utils.FakeEditPaymentMethodInteractorFactory
import com.stripe.android.paymentsheet.viewmodels.BaseSheetViewModel.Companion.SAVE_PROCESSING
import com.stripe.android.paymentsheet.viewmodels.BaseSheetViewModel.UserErrorMessage
import com.stripe.android.testing.PaymentIntentFactory
Expand Down Expand Up @@ -137,6 +142,7 @@ internal class PaymentSheetViewModelTest {
on { create(any(), any(), any(), any(), any()) } doReturn googlePayLauncher
}
private val fakeIntentConfirmationInterceptor = FakeIntentConfirmationInterceptor()
private val fakeEditPaymentMethodInteractorFactory = FakeEditPaymentMethodInteractorFactory(testDispatcher)

private val linkConfigurationCoordinator = mock<LinkConfigurationCoordinator> {
on { getAccountStatusFlow(any()) } doReturn flowOf(AccountStatus.SignedOut)
Expand Down Expand Up @@ -180,6 +186,74 @@ internal class PaymentSheetViewModelTest {
)
}

@Test
fun `modifyPaymentMethod updates payment methods on successful update`() = runTest {
val paymentMethod = PaymentMethodFixtures.CARD_WITH_NETWORKS_PAYMENT_METHOD
val updatedPaymentMethod = paymentMethod.copy(
card = paymentMethod.card?.copy(
networks = paymentMethod.card?.networks?.copy(
preferred = CardBrand.Visa.code
)
)
)

val customerRepository = spy(
FakeCustomerRepository(
onUpdatePaymentMethod = {
Result.success(updatedPaymentMethod)
}
)
)
val viewModel = createViewModel(
customerPaymentMethods = listOf(paymentMethod),
customerRepository = customerRepository
)

viewModel.currentScreen.test {
awaitItem()

viewModel.modifyPaymentMethod(PaymentMethodFixtures.CARD_WITH_NETWORKS_PAYMENT_METHOD)

val currentScreen = awaitItem()

assertThat(currentScreen).isInstanceOf(PaymentSheetScreen.EditPaymentMethod::class.java)

if (currentScreen is PaymentSheetScreen.EditPaymentMethod) {
val interactor = currentScreen.interactor

interactor.handleViewAction(
EditPaymentMethodViewAction.OnBrandChoiceChanged(
EditPaymentMethodViewState.CardBrandChoice(CardBrand.Visa)
)
)

interactor.handleViewAction(EditPaymentMethodViewAction.OnUpdatePressed)
}

assertThat(awaitItem()).isInstanceOf(SelectSavedPaymentMethods::class.java)
}

val paramsCaptor = argumentCaptor<PaymentMethodUpdateParams>()

verify(customerRepository).updatePaymentMethod(
any(),
paramsCaptor.capture()
)

assertThat(
paramsCaptor.firstValue.toParamMap()
).isEqualTo(
PaymentMethodUpdateParams.createCard(
paymentMethodId = PaymentMethodFixtures.CARD_PAYMENT_METHOD.id!!,
networks = PaymentMethodUpdateParams.Card.Networks(
preferred = CardBrand.Visa.code
)
).toParamMap()
)

assertThat(viewModel.paymentMethods.value).contains(updatedPaymentMethod)
}

@Test
fun `checkout() should confirm saved card payment methods`() = runTest {
val stripeIntent = PAYMENT_INTENT
Expand Down Expand Up @@ -1801,7 +1875,7 @@ internal class PaymentSheetViewModelTest {
linkConfigurationCoordinator = linkInteractor,
intentConfirmationInterceptor = fakeIntentConfirmationInterceptor,
formViewModelSubComponentBuilderProvider = mock(),
editInteractorFactory = mock()
editInteractorFactory = fakeEditPaymentMethodInteractorFactory
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import com.stripe.android.core.Logger
import com.stripe.android.model.ListPaymentMethodsParams
import com.stripe.android.model.PaymentMethod
import com.stripe.android.model.PaymentMethodFixtures
import com.stripe.android.model.PaymentMethodUpdateParams
import com.stripe.android.model.wallets.Wallet
import com.stripe.android.networking.StripeRepository
import com.stripe.android.paymentsheet.PaymentSheet
Expand Down Expand Up @@ -305,6 +306,44 @@ internal class CustomerRepositoryTest {
assertThat(result).isEqualTo(error)
}

@Test
fun `updatePaymentMethod() should return payment method on success`() =
runTest {
val success = Result.success(PaymentMethodFixtures.CARD_PAYMENT_METHOD)
givenUpdatePaymentMethodReturns(success)

val result = repository.updatePaymentMethod(
PaymentSheet.CustomerConfiguration(
"customer_id",
"ephemeral_key"
),
PaymentMethodUpdateParams.createCard(
paymentMethodId = "payment_method_id"
)
)

assertThat(result).isEqualTo(success)
}

@Test
fun `updatePaymentMethod() should return failure`() =
runTest {
val error = Result.failure<PaymentMethod>(InvalidParameterException("error"))
givenUpdatePaymentMethodReturns(error)

val result = repository.updatePaymentMethod(
PaymentSheet.CustomerConfiguration(
"customer_id",
"ephemeral_key"
),
PaymentMethodUpdateParams.createCard(
paymentMethodId = "payment_method_id"
)
)

assertThat(result).isEqualTo(error)
}

private suspend fun failsOnceStripeRepository(): StripeRepository {
val repository = mock<StripeRepository>()
whenever(
Expand Down Expand Up @@ -361,4 +400,17 @@ internal class CustomerRepositoryTest {
}.doReturn(result)
}
}

private fun givenUpdatePaymentMethodReturns(
result: Result<PaymentMethod>
) {
stripeRepository.stub {
onBlocking {
updatePaymentMethod(
paymentMethodUpdateParams = any(),
options = any(),
)
}.doReturn(result)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package com.stripe.android.paymentsheet.utils

import com.stripe.android.model.PaymentMethod
import com.stripe.android.paymentsheet.ui.DefaultEditPaymentMethodViewInteractor
import com.stripe.android.paymentsheet.ui.ModifiableEditPaymentMethodViewInteractor
import com.stripe.android.paymentsheet.ui.PaymentMethodRemoveOperation
import com.stripe.android.paymentsheet.ui.PaymentMethodUpdateOperation
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharingStarted
import kotlin.coroutines.CoroutineContext

internal class FakeEditPaymentMethodInteractorFactory(
private val context: CoroutineContext = Dispatchers.Main
) : ModifiableEditPaymentMethodViewInteractor.Factory {
override fun create(
initialPaymentMethod: PaymentMethod,
removeExecutor: PaymentMethodRemoveOperation,
updateExecutor: PaymentMethodUpdateOperation,
displayName: String
): ModifiableEditPaymentMethodViewInteractor {
return DefaultEditPaymentMethodViewInteractor(
initialPaymentMethod = initialPaymentMethod,
removeExecutor = removeExecutor,
updateExecutor = updateExecutor,
displayName = displayName,
workContext = context,
viewStateSharingStarted = SharingStarted.Eagerly
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package com.stripe.android.utils

import com.stripe.android.model.Customer
import com.stripe.android.model.PaymentMethod
import com.stripe.android.model.PaymentMethodUpdateParams
import com.stripe.android.paymentsheet.PaymentSheet
import com.stripe.android.paymentsheet.repositories.CustomerRepository

Expand All @@ -14,6 +15,9 @@ internal open class FakeCustomerRepository(
private val onAttachPaymentMethod: () -> Result<PaymentMethod> = {
Result.failure(NotImplementedError())
},
private val onUpdatePaymentMethod: () -> Result<PaymentMethod> = {
Result.failure(NotImplementedError())
}
) : CustomerRepository {
lateinit var savedPaymentMethod: PaymentMethod
var error: Throwable? = null
Expand All @@ -38,4 +42,9 @@ internal open class FakeCustomerRepository(
customerConfig: PaymentSheet.CustomerConfiguration,
paymentMethodId: String
): Result<PaymentMethod> = onAttachPaymentMethod()

override suspend fun updatePaymentMethod(
customerConfig: PaymentSheet.CustomerConfiguration,
params: PaymentMethodUpdateParams
): Result<PaymentMethod> = onUpdatePaymentMethod()
}

0 comments on commit 79cc4a3

Please sign in to comment.