Skip to content

Commit

Permalink
Implement payment_method_remove_last for PaymentSheet (#9708)
Browse files Browse the repository at this point in the history
* Implement `payment_method_remove_last` for `PaymentSheet`

* Add tests for `payment_method_remove_last`
  • Loading branch information
samer-stripe authored Dec 4, 2024
1 parent 046fe32 commit b77bce8
Show file tree
Hide file tree
Showing 21 changed files with 600 additions and 117 deletions.
2 changes: 1 addition & 1 deletion payments-core/detekt-baseline.xml
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
<ID>LongMethod:CardFormViewTest.kt$CardFormViewTest$@Test fun testCardValidCallback()</ID>
<ID>LongMethod:CardInputWidget.kt$CardInputWidget$private fun initView(attrs: AttributeSet?)</ID>
<ID>LongMethod:CollectBankAccountViewModel.kt$CollectBankAccountViewModel$private suspend fun createFinancialConnectionsSession()</ID>
<ID>LongMethod:ElementsSessionFixtures.kt$ElementsSessionFixtures$fun createPaymentIntentWithCustomerSession( allowRedisplay: String? = "limited" ): JSONObject</ID>
<ID>LongMethod:ElementsSessionFixtures.kt$ElementsSessionFixtures$fun createPaymentIntentWithCustomerSession( allowRedisplay: String? = "limited", paymentMethodRemoveFeature: String? = "enabled", paymentMethodRemoveLastFeature: String? = "enabled", ): JSONObject</ID>
<ID>LongMethod:ElementsSessionJsonParserTest.kt$ElementsSessionJsonParserTest$@Test fun `ElementsSession has expected customer session information in the response`()</ID>
<ID>LongMethod:GooglePayJsonFactoryTest.kt$GooglePayJsonFactoryTest$@Test fun testCreatePaymentMethodRequestJson()</ID>
<ID>LongMethod:PaymentAuthConfigTest.kt$PaymentAuthConfigTest$@Test fun testUiCustomizationWrapper()</ID>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ data class ElementsSession(
data class Enabled(
val isPaymentMethodSaveEnabled: Boolean,
val isPaymentMethodRemoveEnabled: Boolean,
val canRemoveLastPaymentMethod: Boolean,
val allowRedisplayOverride: PaymentMethod.AllowRedisplay?
) : MobilePaymentElement
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,7 @@ internal class ElementsSessionJsonParser(

val paymentMethodSaveFeature = paymentSheetFeatures.optString(FIELD_PAYMENT_METHOD_SAVE)
val paymentMethodRemoveFeature = paymentSheetFeatures.optString(FIELD_PAYMENT_METHOD_REMOVE)
val paymentMethodRemoveLastFeature = paymentSheetFeatures.optString(FIELD_PAYMENT_METHOD_REMOVE_LAST)
val allowRedisplayOverrideValue = paymentSheetFeatures
.optString(FIELD_PAYMENT_METHOD_ALLOW_REDISPLAY_OVERRIDE)

Expand All @@ -253,6 +254,7 @@ internal class ElementsSessionJsonParser(
ElementsSession.Customer.Components.MobilePaymentElement.Enabled(
isPaymentMethodSaveEnabled = paymentMethodSaveFeature == VALUE_ENABLED,
isPaymentMethodRemoveEnabled = paymentMethodRemoveFeature == VALUE_ENABLED,
canRemoveLastPaymentMethod = paymentMethodRemoveLastFeature == VALUE_ENABLED,
allowRedisplayOverride = allowRedisplayOverride,
)
} else {
Expand Down Expand Up @@ -349,6 +351,7 @@ internal class ElementsSessionJsonParser(
private const val FIELD_PAYMENT_METHOD_REMOVE = "payment_method_remove"
private const val FIELD_PAYMENT_METHOD_ALLOW_REDISPLAY_OVERRIDE =
"payment_method_save_allow_redisplay_override"
private const val FIELD_PAYMENT_METHOD_REMOVE_LAST = "payment_method_remove_last"
private const val VALUE_ENABLED = FIELD_ENABLED
const val FIELD_GOOGLE_PAY_PREFERENCE = "google_pay_preference"

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,9 @@ internal object ElementsSessionFixtures {
)

fun createPaymentIntentWithCustomerSession(
allowRedisplay: String? = "limited"
allowRedisplay: String? = "limited",
paymentMethodRemoveFeature: String? = "enabled",
paymentMethodRemoveLastFeature: String? = "enabled",
): JSONObject {
return JSONObject(
"""
Expand Down Expand Up @@ -322,8 +324,9 @@ internal object ElementsSessionFixtures {
"mobile_payment_element": {
"enabled": true,
"features": {
"payment_method_remove": "enabled",
"payment_method_remove": ${paymentMethodRemoveFeature ?: "enabled"},
"payment_method_save": "disabled",
"payment_method_remove_last": ${paymentMethodRemoveLastFeature ?: "enabled"},
"payment_method_save_allow_redisplay_override": ${allowRedisplay?.let { "\"$it\""} ?: "null"}
}
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -541,6 +541,7 @@ class ElementsSessionJsonParserTest {
mobilePaymentElement = ElementsSession.Customer.Components.MobilePaymentElement.Enabled(
isPaymentMethodSaveEnabled = false,
isPaymentMethodRemoveEnabled = true,
canRemoveLastPaymentMethod = true,
allowRedisplayOverride = PaymentMethod.AllowRedisplay.LIMITED,
),
customerSheet = ElementsSession.Customer.Components.CustomerSheet.Disabled
Expand Down Expand Up @@ -611,6 +612,54 @@ class ElementsSessionJsonParserTest {
)
}

@Test
fun `when 'payment_method_remove' is 'enabled' for MPE, 'canRemovePaymentMethods' should be true`() {
mobilePaymentElementPermissionsTest(
paymentMethodRemoveFeatureValue = "enabled",
canRemovePaymentMethods = true,
)
}

@Test
fun `when 'payment_method_remove' is 'disabled' for MPE, 'canRemovePaymentMethods' should be false`() {
mobilePaymentElementPermissionsTest(
paymentMethodRemoveFeatureValue = "disabled",
canRemovePaymentMethods = false,
)
}

@Test
fun `when 'payment_method_remove' is unknown value for MPE, 'canRemovePaymentMethods' should be false`() {
mobilePaymentElementPermissionsTest(
paymentMethodRemoveFeatureValue = "something",
canRemovePaymentMethods = false,
)
}

@Test
fun `when 'payment_method_remove_last' is 'enabled' for MPE, 'canRemoveLastPaymentMethod' should be true`() {
mobilePaymentElementPermissionsTest(
paymentMethodRemoveLastFeatureValue = "enabled",
canRemoveLastPaymentMethod = true,
)
}

@Test
fun `when 'payment_method_remove_last' is 'disabled' for MPE, 'canRemoveLastPaymentMethod' should be false`() {
mobilePaymentElementPermissionsTest(
paymentMethodRemoveLastFeatureValue = "disabled",
canRemoveLastPaymentMethod = false,
)
}

@Test
fun `when 'payment_method_remove_last' is unknown value for MPE, 'canRemoveLastPaymentMethod' should be false`() {
mobilePaymentElementPermissionsTest(
paymentMethodRemoveLastFeatureValue = "something",
canRemoveLastPaymentMethod = false,
)
}

@Test
fun `ElementsSession has expected customer session information with customer sheet component in the response`() {
val parser = ElementsSessionJsonParser(
Expand All @@ -636,7 +685,7 @@ class ElementsSessionJsonParserTest {
components = ElementsSession.Customer.Components(
mobilePaymentElement = ElementsSession.Customer.Components.MobilePaymentElement.Disabled,
customerSheet = ElementsSession.Customer.Components.CustomerSheet.Enabled(
isPaymentMethodRemoveEnabled = true
isPaymentMethodRemoveEnabled = true,
),
)
),
Expand Down Expand Up @@ -776,4 +825,38 @@ class ElementsSessionJsonParserTest {

assertThat(enabledComponent?.allowRedisplayOverride).isEqualTo(allowRedisplay)
}

private fun mobilePaymentElementPermissionsTest(
paymentMethodRemoveFeatureValue: String? = "enabled",
paymentMethodRemoveLastFeatureValue: String? = "enabled",
canRemovePaymentMethods: Boolean = true,
canRemoveLastPaymentMethod: Boolean = true,
) {
val parser = ElementsSessionJsonParser(
ElementsSessionParams.PaymentIntentType(
clientSecret = "secret",
customerSessionClientSecret = "customer_session_client_secret",
externalPaymentMethods = emptyList(),
),
isLiveMode = false,
)

val intent = createPaymentIntentWithCustomerSession(
paymentMethodRemoveFeature = paymentMethodRemoveFeatureValue,
paymentMethodRemoveLastFeature = paymentMethodRemoveLastFeatureValue,
)

val elementsSession = parser.parse(intent)

val mobilePaymentElementComponent = elementsSession?.customer?.session?.components?.mobilePaymentElement

assertThat(mobilePaymentElementComponent)
.isInstanceOf(ElementsSession.Customer.Components.MobilePaymentElement.Enabled::class.java)

val enabledComponent = mobilePaymentElementComponent as?
ElementsSession.Customer.Components.MobilePaymentElement.Enabled

assertThat(enabledComponent?.isPaymentMethodRemoveEnabled).isEqualTo(canRemovePaymentMethods)
assertThat(enabledComponent?.canRemoveLastPaymentMethod).isEqualTo(canRemoveLastPaymentMethod)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ class CheckoutRequest private constructor(
val paymentMethodSaveFeature: FeatureState?,
@SerialName("customer_session_payment_method_remove")
val paymentMethodRemoveFeature: FeatureState?,
@SerialName("customer_session_payment_method_remove_last")
val paymentMethodRemoveLastFeature: FeatureState?,
@SerialName("customer_session_payment_method_redisplay")
val paymentMethodRedisplayFeature: FeatureState?,
@SerialName("customer_session_payment_method_allow_redisplay_filters")
Expand Down Expand Up @@ -69,6 +71,7 @@ class CheckoutRequest private constructor(
private var requireCvcRecollection: Boolean? = null
private var paymentMethodSaveFeature: FeatureState = FeatureState.Enabled
private var paymentMethodRemoveFeature: FeatureState = FeatureState.Enabled
private var paymentMethodRemoveLastFeature: FeatureState = FeatureState.Enabled
private var paymentMethodRedisplayFeature: FeatureState = FeatureState.Enabled
private var paymentMethodRedisplayFilters: List<AllowRedisplayFilter> = listOf(
AllowRedisplayFilter.Unspecified,
Expand Down Expand Up @@ -129,6 +132,10 @@ class CheckoutRequest private constructor(
this.paymentMethodRemoveFeature = state
}

fun paymentMethodRemoveLastFeature(state: FeatureState) {
this.paymentMethodRemoveLastFeature = state
}

fun paymentMethodRedisplayFeature(state: FeatureState) {
this.paymentMethodRedisplayFeature = state
}
Expand Down Expand Up @@ -162,6 +169,7 @@ class CheckoutRequest private constructor(
customerSessionComponentName = "mobile_payment_element",
paymentMethodSaveFeature = paymentMethodSaveFeature,
paymentMethodRemoveFeature = paymentMethodRemoveFeature,
paymentMethodRemoveLastFeature = paymentMethodRemoveLastFeature,
paymentMethodRedisplayFeature = paymentMethodRedisplayFeature,
paymentMethodRedisplayFilters = paymentMethodRedisplayFilters,
paymentMethodOverrideRedisplay = paymentMethodOverrideRedisplay,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.stripe.android.paymentsheet.example.playground.settings

import com.stripe.android.paymentsheet.example.playground.model.CheckoutRequest
import com.stripe.android.paymentsheet.example.playground.model.FeatureState

internal object CustomerSessionRemoveLastSettingsDefinition : BooleanSettingsDefinition(
defaultValue = true,
displayName = "Customer Session Remove Last Payment Method",
key = "customer_session_payment_method_remove"
) {
override fun createOptions(
configurationData: PlaygroundConfigurationData
) = listOf(
PlaygroundSettingDefinition.Displayable.Option("Enabled", true),
PlaygroundSettingDefinition.Displayable.Option("Disabled", false),
)

override fun configure(value: Boolean, checkoutRequestBuilder: CheckoutRequest.Builder) {
if (value) {
checkoutRequestBuilder.paymentMethodRemoveLastFeature(FeatureState.Enabled)
} else {
checkoutRequestBuilder.paymentMethodRemoveLastFeature(FeatureState.Disabled)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -418,6 +418,7 @@ internal class PlaygroundSettings private constructor(
CustomerSessionSettingsDefinition,
CustomerSessionSaveSettingsDefinition,
CustomerSessionRemoveSettingsDefinition,
CustomerSessionRemoveLastSettingsDefinition,
CustomerSessionRedisplaySettingsDefinition,
CustomerSessionRedisplayFiltersSettingsDefinition,
CustomerSessionOverrideRedisplaySettingsDefinition,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@ internal class SavedPaymentMethodMutator(
private val coroutineScope: CoroutineScope,
private val workContext: CoroutineContext,
private val customerRepository: CustomerRepository,
private val allowsRemovalOfLastSavedPaymentMethod: Boolean,
private val selection: StateFlow<PaymentSelection?>,
val providePaymentMethodName: (PaymentMethodCode?) -> ResolvableString,
private val clearSelection: () -> Unit,
Expand Down Expand Up @@ -62,10 +61,11 @@ internal class SavedPaymentMethodMutator(
val canRemove: StateFlow<Boolean> = customerStateHolder.customer.mapAsStateFlow { customerState ->
customerState?.run {
val hasRemovePermissions = customerState.permissions.canRemovePaymentMethods
val hasRemoveLastPaymentMethodPermissions = customerState.permissions.canRemoveLastPaymentMethod

when (paymentMethods.size) {
0 -> false
1 -> allowsRemovalOfLastSavedPaymentMethod && hasRemovePermissions
1 -> hasRemoveLastPaymentMethodPermissions && hasRemovePermissions
else -> hasRemovePermissions
}
} ?: false
Expand Down Expand Up @@ -390,7 +390,6 @@ internal class SavedPaymentMethodMutator(
coroutineScope = viewModel.viewModelScope,
workContext = viewModel.workContext,
customerRepository = viewModel.customerRepository,
allowsRemovalOfLastSavedPaymentMethod = viewModel.config.allowsRemovalOfLastSavedPaymentMethod,
selection = viewModel.selection,
providePaymentMethodName = { code ->
code?.let {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.stripe.android.paymentsheet.state

import android.os.Parcelable
import com.stripe.android.common.model.CommonConfiguration
import com.stripe.android.model.ElementsSession
import com.stripe.android.model.PaymentMethod
import com.stripe.android.paymentsheet.PaymentSheet
Expand All @@ -16,6 +17,7 @@ internal data class CustomerState(
@Parcelize
data class Permissions(
val canRemovePaymentMethods: Boolean,
val canRemoveLastPaymentMethod: Boolean,
val canRemoveDuplicates: Boolean,
) : Parcelable

Expand All @@ -28,17 +30,26 @@ internal data class CustomerState(
* @return [CustomerState] instance using [ElementsSession.Customer] data
*/
internal fun createForCustomerSession(
configuration: CommonConfiguration,
customer: ElementsSession.Customer,
supportedSavedPaymentMethodTypes: List<PaymentMethod.Type>,
): CustomerState {
val canRemovePaymentMethods = when (
val mobilePaymentElementComponent = customer.session.components.mobilePaymentElement
) {
is ElementsSession.Customer.Components.MobilePaymentElement.Enabled ->
val mobilePaymentElementComponent = customer.session.components.mobilePaymentElement

val canRemovePaymentMethods = when (mobilePaymentElementComponent) {
is ElementsSession.Customer.Components.MobilePaymentElement.Enabled -> {
mobilePaymentElementComponent.isPaymentMethodRemoveEnabled
}
is ElementsSession.Customer.Components.MobilePaymentElement.Disabled -> false
}

val canRemoveLastPaymentMethod = when {
!configuration.allowsRemovalOfLastSavedPaymentMethod -> false
mobilePaymentElementComponent is ElementsSession.Customer.Components.MobilePaymentElement.Enabled ->
mobilePaymentElementComponent.canRemoveLastPaymentMethod
else -> false
}

return CustomerState(
id = customer.session.customerId,
ephemeralKeySecret = customer.session.apiKey,
Expand All @@ -47,6 +58,7 @@ internal data class CustomerState(
},
permissions = Permissions(
canRemovePaymentMethods = canRemovePaymentMethods,
canRemoveLastPaymentMethod = canRemoveLastPaymentMethod,
// Should always remove duplicates when using `customer_session`
canRemoveDuplicates = true,
)
Expand All @@ -63,6 +75,7 @@ internal data class CustomerState(
* @return [CustomerState] instance with legacy ephemeral key secrets
*/
internal fun createForLegacyEphemeralKey(
configuration: CommonConfiguration,
customerId: String,
accessType: PaymentSheet.CustomerAccessType.LegacyCustomerEphemeralKey,
paymentMethods: List<PaymentMethod>,
Expand All @@ -77,6 +90,13 @@ internal data class CustomerState(
* always be set to true.
*/
canRemovePaymentMethods = true,
/*
* Un-scoped legacy ephemeral keys normally have full permissions to remove the last payment
* method, however we do have client-side configuration option to configure this ability. This
* should eventually be removed in favor of the server-side option available with customer
* sessions.
*/
canRemoveLastPaymentMethod = configuration.allowsRemovalOfLastSavedPaymentMethod,
/*
* Removing duplicates is not applicable here since we don't filter out duplicates for for
* un-scoped ephemeral keys.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,7 @@ internal class DefaultPaymentElementLoader @Inject constructor(
customerInfo = customerInfo,
metadata = metadata.await(),
savedSelection = savedSelection,
configuration = configuration,
cardBrandFilter = PaymentSheetCardBrandFilter(configuration.cardBrandAcceptance)
)
}
Expand Down Expand Up @@ -313,6 +314,7 @@ internal class DefaultPaymentElementLoader @Inject constructor(
}

private suspend fun createCustomerState(
configuration: CommonConfiguration,
customerInfo: CustomerInfo?,
metadata: PaymentMethodMetadata,
savedSelection: Deferred<SavedSelection>,
Expand All @@ -322,13 +324,15 @@ internal class DefaultPaymentElementLoader @Inject constructor(
is CustomerInfo.CustomerSession -> {
CustomerState.createForCustomerSession(
customer = customerInfo.elementsSessionCustomer,
configuration = configuration,
supportedSavedPaymentMethodTypes = metadata.supportedSavedPaymentMethodTypes()
)
}
is CustomerInfo.Legacy -> {
CustomerState.createForLegacyEphemeralKey(
customerId = customerInfo.id,
accessType = customerInfo.accessType,
configuration = configuration,
paymentMethods = retrieveCustomerPaymentMethods(
metadata = metadata,
customerConfig = customerInfo.customerConfig,
Expand Down
Loading

0 comments on commit b77bce8

Please sign in to comment.