Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[MOBILESDK 2683] Request for feedback on approach: Set As Default Payment Method Element in AddCard #10129

Closed
wants to merge 3 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions payments-ui-core/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,8 @@
<string name="stripe_save_for_future_payments_with_merchant_name">Save for future %s payments</string>
<!-- The label of a switch indicating whether to save the user's payment details for future payment -->
<string name="stripe_save_payment_details_to_merchant_name">Save payment details to %s for future purchases</string>
<!-- The label of a switch indicating whether to set the user's payment details as default -->
<string name="stripe_set_as_default_payment_method">Set as default payment method</string>
<!-- Button title to open camera to scan credit/debit card -->
<string name="stripe_scan_card">Scan card</string>
<!-- SEPA mandate text -->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ import com.stripe.android.ui.core.elements.MandateTextUI
import com.stripe.android.ui.core.elements.RenderableFormElement
import com.stripe.android.ui.core.elements.SaveForFutureUseElement
import com.stripe.android.ui.core.elements.SaveForFutureUseElementUI
import com.stripe.android.ui.core.elements.SetAsDefaultPaymentMethodElement
import com.stripe.android.ui.core.elements.SetAsDefaultPaymentMethodElementUI
import com.stripe.android.ui.core.elements.StaticTextElement
import com.stripe.android.ui.core.elements.StaticTextElementUI
import com.stripe.android.uicore.elements.CheckboxFieldElement
Expand Down Expand Up @@ -139,6 +141,15 @@ private fun FormUIElement(
enabled = enabled,
element = element,
)
is SetAsDefaultPaymentMethodElement -> SetAsDefaultPaymentMethodElementUI(
modifier = Modifier.formVerticalPadding(
maxIndex = maxIndex,
index = index,
vertical = 4.dp,
),
enabled = enabled,
element = element,
)
is SameAsShippingElement -> SameAsShippingElementUI(
controller = element.controller,
modifier = Modifier.formVerticalPadding(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package com.stripe.android.ui.core.elements

import androidx.annotation.RestrictTo
import com.stripe.android.ui.core.R
import com.stripe.android.uicore.elements.FieldError
import com.stripe.android.uicore.elements.InputController
import com.stripe.android.uicore.forms.FormFieldEntry
import com.stripe.android.uicore.utils.combineAsStateFlow
import com.stripe.android.uicore.utils.mapAsStateFlow
import com.stripe.android.uicore.utils.stateFlowOf
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow

@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
class SetAsDefaultPaymentMethodController(
setAsDefaultPaymentMethodInitialValue: Boolean = false
) : InputController {
override val label: StateFlow<Int> = MutableStateFlow(
R.string.stripe_set_as_default_payment_method
)

private val _setAsDefaultPaymentMethod = MutableStateFlow(setAsDefaultPaymentMethodInitialValue)
val setAsDefaultPaymentMethod: StateFlow<Boolean> = _setAsDefaultPaymentMethod

override val fieldValue: StateFlow<String> = setAsDefaultPaymentMethod.mapAsStateFlow { it.toString() }
override val rawFieldValue: StateFlow<String?> = fieldValue

override val error: StateFlow<FieldError?> = stateFlowOf(null)
override val showOptionalLabel: Boolean = false
override val isComplete: StateFlow<Boolean> = stateFlowOf(true)
override val formFieldValue: StateFlow<FormFieldEntry> =
combineAsStateFlow(isComplete, rawFieldValue) { complete, value ->
FormFieldEntry(value, complete)
}

fun onValueChange(setAsDefaultPaymentMethod: Boolean) {
_setAsDefaultPaymentMethod.value = setAsDefaultPaymentMethod
}

override fun onRawValueChange(rawValue: String) {
onValueChange(rawValue.toBooleanStrictOrNull() ?: true)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package com.stripe.android.ui.core.elements

import androidx.annotation.RestrictTo
import com.stripe.android.core.strings.ResolvableString
import com.stripe.android.uicore.elements.FormElement
import com.stripe.android.uicore.elements.IdentifierSpec
import com.stripe.android.uicore.forms.FormFieldEntry
import com.stripe.android.uicore.utils.mapAsStateFlow
import kotlinx.coroutines.flow.StateFlow

/**
* This is an element that will set elements
*/
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
data class SetAsDefaultPaymentMethodElement(
val initialValue: Boolean,
val shouldShowElementFlow: StateFlow<Boolean>
): FormElement {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This probably doesn't make sense as a data class since we are passing a StateFlow

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I assume that it should be a normal class instead then

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yup! That way we don't generate the additional equals and copy methods.

override val identifier: IdentifierSpec = IdentifierSpec.SetAsDefaultPaymentMethod

override val controller: SetAsDefaultPaymentMethodController = SetAsDefaultPaymentMethodController(
setAsDefaultPaymentMethodInitialValue = initialValue
)
override val allowsUserInteraction: Boolean = true

override val mandateText: ResolvableString? = null

override fun getFormFieldValueFlow(): StateFlow<List<Pair<IdentifierSpec, FormFieldEntry>>> =
controller.formFieldValue.mapAsStateFlow {
listOf(
identifier to it
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package com.stripe.android.ui.core.elements

import androidx.annotation.RestrictTo
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import com.stripe.android.uicore.elements.CheckboxElementUI
import com.stripe.android.uicore.utils.collectAsState

const val SET_AS_DEFAULT_PAYMENT_METHOD_TEST_TAG = "SET_AS_DEFAULT_PAYMENT_METHOD_TEST_TAG"

@Composable
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
fun SetAsDefaultPaymentMethodElementUI(
enabled: Boolean,
element: SetAsDefaultPaymentMethodElement,
modifier: Modifier = Modifier,
) {
val controller = element.controller
val checked by controller.setAsDefaultPaymentMethod.collectAsState()
val label by controller.label.collectAsState()
val resources = LocalContext.current.resources

val shouldShow = element.shouldShowElementFlow.collectAsState()

if (shouldShow.value) {
CheckboxElementUI(
automationTestTag = SET_AS_DEFAULT_PAYMENT_METHOD_TEST_TAG,
isChecked = checked,
label = resources.getString(label),
isEnabled = enabled,
modifier = modifier,
onValueChange = {
controller.onValueChange(!checked)
},
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,5 @@ import kotlinx.parcelize.Parcelize

@Parcelize
internal data class CustomerMetadata(
val hasCustomerConfiguration: Boolean,
val isPaymentMethodSetAsDefaultEnabled: Boolean
) : Parcelable
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ internal data class PaymentMethodMetadata(
val shippingDetails: AddressDetails?,
val sharedDataSpecs: List<SharedDataSpec>,
val externalPaymentMethodSpecs: List<ExternalPaymentMethodSpec>,
val customerMetadata: CustomerMetadata,
val customerMetadata: CustomerMetadata?,
val isGooglePayReady: Boolean,
val linkInlineConfiguration: LinkInlineConfiguration?,
val paymentMethodSaveConsentBehavior: PaymentMethodSaveConsentBehavior,
Expand Down Expand Up @@ -232,6 +232,13 @@ internal data class PaymentMethodMetadata(
}

internal companion object {
internal fun getDefaultPaymentMethodsEnabled(elementsSession: ElementsSession): Boolean {
val mobilePaymentElement = elementsSession.customer?.session?.components?.mobilePaymentElement
as? ElementsSession.Customer.Components.MobilePaymentElement.Enabled
return mobilePaymentElement?.isPaymentMethodSetAsDefaultEnabled
?: false
}

internal fun create(
elementsSession: ElementsSession,
configuration: CommonConfiguration,
Expand All @@ -242,6 +249,13 @@ internal data class PaymentMethodMetadata(
linkState: LinkState?,
): PaymentMethodMetadata {
val linkSettings = elementsSession.linkSettings
val customerMetadata = if (configuration.customer != null) {
CustomerMetadata(
isPaymentMethodSetAsDefaultEnabled = getDefaultPaymentMethodsEnabled(elementsSession)
)
} else {
null
}
return PaymentMethodMetadata(
stripeIntent = elementsSession.stripeIntent,
billingDetailsCollectionConfiguration = configuration.billingDetailsCollectionConfiguration,
Expand All @@ -256,9 +270,7 @@ internal data class PaymentMethodMetadata(
merchantName = configuration.merchantDisplayName,
defaultBillingDetails = configuration.defaultBillingDetails,
shippingDetails = configuration.shippingDetails,
customerMetadata = CustomerMetadata(
hasCustomerConfiguration = configuration.customer != null,
),
customerMetadata = customerMetadata,
sharedDataSpecs = sharedDataSpecs,
externalPaymentMethodSpecs = externalPaymentMethodSpecs,
paymentMethodSaveConsentBehavior = elementsSession.toPaymentSheetSaveConsentBehavior(),
Expand Down Expand Up @@ -293,7 +305,7 @@ internal data class PaymentMethodMetadata(
defaultBillingDetails = configuration.defaultBillingDetails,
shippingDetails = null,
customerMetadata = CustomerMetadata(
hasCustomerConfiguration = true,
isPaymentMethodSetAsDefaultEnabled = getDefaultPaymentMethodsEnabled(elementsSession)
),
sharedDataSpecs = sharedDataSpecs,
isGooglePayReady = isGooglePayReady,
Expand Down Expand Up @@ -326,7 +338,7 @@ internal data class PaymentMethodMetadata(
defaultBillingDetails = null,
shippingDetails = null,
customerMetadata = CustomerMetadata(
hasCustomerConfiguration = true,
isPaymentMethodSetAsDefaultEnabled = false
),
sharedDataSpecs = emptyList(),
externalPaymentMethodSpecs = emptyList(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import com.stripe.android.ui.core.elements.CardDetailsSectionElement
import com.stripe.android.ui.core.elements.EmailElement
import com.stripe.android.ui.core.elements.MandateTextElement
import com.stripe.android.ui.core.elements.SaveForFutureUseElement
import com.stripe.android.ui.core.elements.SetAsDefaultPaymentMethodElement
import com.stripe.android.uicore.elements.FormElement
import com.stripe.android.uicore.elements.IdentifierSpec
import com.stripe.android.uicore.elements.PhoneNumberController
Expand Down Expand Up @@ -109,10 +110,18 @@ private object CardUiDefinitionFactory : UiDefinitionFactory.Simple {

val canChangeSaveForFutureUsage = saveForFutureUsageIsChangeable(metadata)

val saveForFutureUseElement = SaveForFutureUseElement(arguments.saveForFutureUseInitialValue, arguments.merchantName)
val isSaveForFutureUseCheckedFlow = saveForFutureUseElement.controller.saveForFutureUse

if (canChangeSaveForFutureUsage) {
add(SaveForFutureUseElement(arguments.saveForFutureUseInitialValue, arguments.merchantName))
add(saveForFutureUseElement)
add(SetAsDefaultPaymentMethodElement(
initialValue = false,
shouldShowElementFlow = isSaveForFutureUseCheckedFlow
))
}


Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the approach is sound. It has its upsides and tradeoffs.

  • Don't need to create a combined element for managing the Save for future and Set as default checkboxes.
  • Spacing remains controlled by FormUI.

Tradeoffs here are that the element is not a serializable element and is coupled to the state of whatever decides to show the element (in this case SaveForFutureElement). It would make it harder to parcelize form elements if we need to though I imagine we would be serializing the values rather the elements themselves.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree that this approach lgtm! I do think we only care about serializing the values -- when do we parcelize the form elements themselves @samer-stripe?

val signupMode = if (
metadata.linkInlineConfiguration != null && arguments.linkConfigurationCoordinator != null
) {
Expand Down Expand Up @@ -159,7 +168,7 @@ private object CardUiDefinitionFactory : UiDefinitionFactory.Simple {
code = PaymentMethod.Type.Card.code,
intent = metadata.stripeIntent,
paymentMethodSaveConsentBehavior = metadata.paymentMethodSaveConsentBehavior,
hasCustomerConfiguration = metadata.customerMetadata.hasCustomerConfiguration,
hasCustomerConfiguration = metadata.customerMetadata != null,
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import com.stripe.android.paymentsheet.analytics.EventReporter
import com.stripe.android.paymentsheet.model.PaymentSelection
import com.stripe.android.paymentsheet.navigation.PaymentSheetScreen
import com.stripe.android.paymentsheet.repositories.CustomerRepository
import com.stripe.android.paymentsheet.state.CustomerState
import com.stripe.android.paymentsheet.ui.DefaultAddPaymentMethodInteractor
import com.stripe.android.paymentsheet.ui.DefaultUpdatePaymentMethodInteractor
import com.stripe.android.paymentsheet.ui.PaymentMethodRemovalDelayMillis
Expand Down Expand Up @@ -57,10 +56,16 @@ internal class SavedPaymentMethodMutator(
isLinkEnabled: StateFlow<Boolean?>,
isNotPaymentFlow: Boolean,
) {
val defaultPaymentMethodId: StateFlow<String?> = customerStateHolder.customer.mapAsStateFlow { customerState ->
when (val defaultPaymentMethodState = customerState?.defaultPaymentMethodState) {
is CustomerState.DefaultPaymentMethodState.Enabled -> defaultPaymentMethodState.defaultPaymentMethodId
is CustomerState.DefaultPaymentMethodState.Disabled, null -> null
val defaultPaymentMethodId: StateFlow<String?> = combineAsStateFlow(
customerStateHolder.customer,
paymentMethodMetadataFlow
) { customer, paymentMethodMetadata ->
paymentMethodMetadata?.customerMetadata?.isPaymentMethodSetAsDefaultEnabled?.let { isEnabled ->
if (isEnabled) {
customer?.defaultPaymentMethodId
} else {
null
}
}
}

Expand All @@ -72,6 +77,7 @@ internal class SavedPaymentMethodMutator(

private val paymentOptionsItemsMapper: PaymentOptionsItemsMapper by lazy {
PaymentOptionsItemsMapper(
customerMetadata = paymentMethodMetadataFlow.value?.customerMetadata,
customerState = customerStateHolder.customer,
isGooglePayReady = paymentMethodMetadataFlow.mapAsStateFlow { it?.isGooglePayReady == true },
isLinkEnabled = isLinkEnabled,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ internal class USBankAccountFormArguments(
code = selectedPaymentMethodCode,
intent = paymentMethodMetadata.stripeIntent,
paymentMethodSaveConsentBehavior = paymentMethodMetadata.paymentMethodSaveConsentBehavior,
hasCustomerConfiguration = paymentMethodMetadata.customerMetadata.hasCustomerConfiguration,
hasCustomerConfiguration = paymentMethodMetadata.customerMetadata != null,
)
val instantDebits = selectedPaymentMethodCode == PaymentMethod.Type.Link.code
val initializationMode = (viewModel as? PaymentSheetViewModel)
Expand Down Expand Up @@ -117,7 +117,7 @@ internal class USBankAccountFormArguments(
code = selectedPaymentMethodCode,
intent = paymentMethodMetadata.stripeIntent,
paymentMethodSaveConsentBehavior = paymentMethodMetadata.paymentMethodSaveConsentBehavior,
hasCustomerConfiguration = paymentMethodMetadata.customerMetadata.hasCustomerConfiguration,
hasCustomerConfiguration = paymentMethodMetadata.customerMetadata != null,
)
val instantDebits = selectedPaymentMethodCode == PaymentMethod.Type.Link.code
val bankFormInteractor = BankFormInteractor(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ internal data class CustomerState(
val customerSessionClientSecret: String?,
val paymentMethods: List<PaymentMethod>,
val permissions: Permissions,
val defaultPaymentMethodState: DefaultPaymentMethodState,
val defaultPaymentMethodId: String?,
) : Parcelable {
@Parcelize
data class Permissions(
Expand Down Expand Up @@ -62,18 +62,6 @@ internal data class CustomerState(
else -> false
}

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

val defaultPaymentMethodState = if (isSetAsDefaultFeatureEnabled) {
DefaultPaymentMethodState.Enabled(customer.defaultPaymentMethod)
} else {
DefaultPaymentMethodState.Disabled
}

return CustomerState(
id = customer.session.customerId,
ephemeralKeySecret = customer.session.apiKey,
Expand All @@ -87,7 +75,7 @@ internal data class CustomerState(
// Should always remove duplicates when using `customer_session`
canRemoveDuplicates = true,
),
defaultPaymentMethodState = defaultPaymentMethodState
defaultPaymentMethodId = customer.defaultPaymentMethod
)
}

Expand Down Expand Up @@ -131,7 +119,7 @@ internal data class CustomerState(
canRemoveDuplicates = false,
),
// This is a customer sessions only feature, so it's always disabled when using a legacy ephemeral key.
defaultPaymentMethodState = DefaultPaymentMethodState.Disabled
defaultPaymentMethodId = null
)
}
}
Expand Down
Loading
Loading