Skip to content

Commit

Permalink
Add wallets support to embedded. (#9917)
Browse files Browse the repository at this point in the history
  • Loading branch information
jaynewstrom-stripe authored Jan 15, 2025
1 parent 96e9ba3 commit f611efb
Show file tree
Hide file tree
Showing 11 changed files with 111 additions and 8 deletions.
2 changes: 0 additions & 2 deletions payments-ui-core/detekt-baseline.xml
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,6 @@
<ID>LongMethod:TransformGoogleToStripeAddressTest.kt$TransformGoogleToStripeAddressTest$@Test fun `test US address with sublocality`()</ID>
<ID>LongMethod:TransformGoogleToStripeAddressTest.kt$TransformGoogleToStripeAddressTest$@Test fun `test US address without sublocality`()</ID>
<ID>LongMethod:TransformGoogleToStripeAddressTest.kt$TransformGoogleToStripeAddressTest$@Test fun `test should not combine dependent locality - US`()</ID>
<ID>MagicNumber:CardDetailsElement.kt$2000</ID>
<ID>MagicNumber:CardDetailsElement.kt$4</ID>
<ID>MagicNumber:CardNumberVisualTransformation.kt$CardNumberVisualTransformation$14</ID>
<ID>MagicNumber:CardNumberVisualTransformation.kt$CardNumberVisualTransformation$15</ID>
<ID>MagicNumber:CardNumberVisualTransformation.kt$CardNumberVisualTransformation$16</ID>
Expand Down
1 change: 0 additions & 1 deletion paymentsheet-example/detekt-baseline.xml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
<ID>LongMethod:AppearanceBottomSheetDialogFragment.kt$@Composable private fun Colors( currentAppearance: PaymentSheet.Appearance, updateAppearance: (PaymentSheet.Appearance) -> Unit, )</ID>
<ID>LongMethod:AppearanceBottomSheetDialogFragment.kt$@Composable private fun EmbeddedPicker( embeddedAppearance: EmbeddedAppearance, updateEmbedded: (EmbeddedAppearance) -> Unit )</ID>
<ID>LongMethod:AppearanceBottomSheetDialogFragment.kt$@Composable private fun PrimaryButton( currentAppearance: PaymentSheet.Appearance, updateAppearance: (PaymentSheet.Appearance) -> Unit, )</ID>
<ID>LongMethod:Receipt.kt$@Composable fun Receipt( isLoading: Boolean, cartState: CartState, isEditable: Boolean = false, onQuantityChanged: (CartProduct.Id, Int) -> Unit = { _, _ -> }, bottomContent: @Composable () -> Unit, )</ID>
<ID>MagicNumber:AppearanceBottomSheetDialogFragment.kt$16</ID>
<ID>MagicNumber:AppearanceBottomSheetDialogFragment.kt$33</ID>
<ID>MagicNumber:CartProduct.kt$100</ID>
Expand Down
2 changes: 1 addition & 1 deletion paymentsheet/detekt-baseline.xml
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
<ID>LongMethod:AutocompleteScreen.kt$@Composable internal fun AutocompleteScreenUI(viewModel: AutocompleteViewModel)</ID>
<ID>LongMethod:CustomerSheetScreen.kt$@Composable internal fun SelectPaymentMethod( viewState: CustomerSheetViewState.SelectPaymentMethod, viewActionHandler: (CustomerSheetViewAction) -> Unit, paymentMethodNameProvider: (PaymentMethodCode?) -> ResolvableString, modifier: Modifier = Modifier, )</ID>
<ID>LongMethod:DefaultConfirmationHandlerTest.kt$DefaultConfirmationHandlerTest$private fun test( someDefinitionAction: ConfirmationDefinition.Action&lt;SomeConfirmationDefinition.LauncherArgs> = ConfirmationDefinition.Action.Fail( cause = IllegalStateException("Failed!"), message = R.string.stripe_something_went_wrong.resolvableString, errorType = ConfirmationHandler.Result.Failed.ErrorType.Internal, ), someDefinitionResult: ConfirmationDefinition.Result = ConfirmationDefinition.Result.Failed( cause = IllegalStateException("Failed!"), message = R.string.stripe_something_went_wrong.resolvableString, type = ConfirmationHandler.Result.Failed.ErrorType.Internal, ), someDefinitionIsConfirmable: Boolean = true, someOtherDefinitionAction: ConfirmationDefinition.Action&lt;SomeOtherConfirmationDefinition.LauncherArgs> = ConfirmationDefinition.Action.Fail( cause = IllegalStateException("Failed!"), message = R.string.stripe_something_went_wrong.resolvableString, errorType = ConfirmationHandler.Result.Failed.ErrorType.Internal, ), someOtherDefinitionResult: ConfirmationDefinition.Result = ConfirmationDefinition.Result.Failed( cause = IllegalStateException("Failed!"), message = R.string.stripe_something_went_wrong.resolvableString, type = ConfirmationHandler.Result.Failed.ErrorType.Internal, ), shouldRegister: Boolean = true, savedStateHandle: SavedStateHandle = SavedStateHandle(), dispatcher: CoroutineDispatcher = UnconfinedTestDispatcher(), scenarioTest: suspend Scenario.() -> Unit )</ID>
<ID>LongMethod:EmbeddedContentHelper.kt$DefaultEmbeddedContentHelper$private fun createInteractor( coroutineScope: CoroutineScope, paymentMethodMetadata: PaymentMethodMetadata, ): PaymentMethodVerticalLayoutInteractor</ID>
<ID>LongMethod:EmbeddedContentHelper.kt$DefaultEmbeddedContentHelper$private fun createInteractor( coroutineScope: CoroutineScope, paymentMethodMetadata: PaymentMethodMetadata, walletsState: StateFlow&lt;WalletsState?>, ): PaymentMethodVerticalLayoutInteractor</ID>
<ID>LongMethod:FormViewModelTest.kt$FormViewModelTest$@Test fun `Verify params are set when element address fields are complete`()</ID>
<ID>LongMethod:FormViewModelTest.kt$FormViewModelTest$@Test fun `Verify params are set when required address fields are complete`()</ID>
<ID>LongMethod:LinkInlineSignupFields.kt$@Composable internal fun LinkInlineSignupFields( sectionError: Int?, emailController: TextFieldController, phoneNumberController: PhoneNumberController, nameController: TextFieldController, signUpState: SignUpState, enabled: Boolean, isShowingPhoneFirst: Boolean, requiresNameCollection: Boolean, errorMessage: ErrorMessage?, didShowAllFields: Boolean, onShowingAllFields: () -> Unit, modifier: Modifier = Modifier, emailFocusRequester: FocusRequester = remember { FocusRequester() }, phoneFocusRequester: FocusRequester = remember { FocusRequester() }, nameFocusRequester: FocusRequester = remember { FocusRequester() }, )</ID>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ internal class EmbeddedConfirmationHelper(
val confirmationState = confirmationStateSupplier() ?: return null
val confirmationOption = confirmationState.selection?.toConfirmationOption(
configuration = confirmationState.configuration.asCommonConfiguration(),
linkConfiguration = null,
linkConfiguration = confirmationState.paymentMethodMetadata.linkState?.configuration,
) ?: return null

return ConfirmationHandler.Args(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import com.stripe.android.paymentsheet.SavedPaymentMethodMutator
import com.stripe.android.paymentsheet.analytics.EventReporter
import com.stripe.android.paymentsheet.model.PaymentSelection
import com.stripe.android.paymentsheet.repositories.CustomerRepository
import com.stripe.android.paymentsheet.state.WalletsState
import com.stripe.android.paymentsheet.verticalmode.DefaultPaymentMethodVerticalLayoutInteractor
import com.stripe.android.paymentsheet.verticalmode.DefaultPaymentMethodVerticalLayoutInteractor.FormType
import com.stripe.android.paymentsheet.verticalmode.PaymentMethodIncentiveInteractor
Expand Down Expand Up @@ -67,16 +68,19 @@ internal class DefaultEmbeddedContentHelper @AssistedInject constructor(
@IOContext private val workContext: CoroutineContext,
private val customerRepository: CustomerRepository,
private val selectionHolder: EmbeddedSelectionHolder,
private val embeddedWalletsHelper: EmbeddedWalletsHelper,
) : EmbeddedContentHelper {

private val mandate: StateFlow<ResolvableString?> = savedStateHandle.getStateFlow(
key = MANDATE_KEY_EMBEDDED_CONTENT,
initialValue = null,
)

private val state: StateFlow<State?> = savedStateHandle.getStateFlow(
key = STATE_KEY_EMBEDDED_CONTENT,
initialValue = null
)

private val _embeddedContent = MutableStateFlow<EmbeddedContent?>(null)
override val embeddedContent: StateFlow<EmbeddedContent?> = _embeddedContent.asStateFlow()

Expand All @@ -90,6 +94,7 @@ internal class DefaultEmbeddedContentHelper @AssistedInject constructor(
interactor = createInteractor(
coroutineScope = coroutineScope,
paymentMethodMetadata = state.paymentMethodMetadata,
walletsState = embeddedWalletsHelper.walletsState(state.paymentMethodMetadata),
),
rowStyle = state.rowStyle
)
Expand Down Expand Up @@ -122,6 +127,7 @@ internal class DefaultEmbeddedContentHelper @AssistedInject constructor(
private fun createInteractor(
coroutineScope: CoroutineScope,
paymentMethodMetadata: PaymentMethodMetadata,
walletsState: StateFlow<WalletsState?>,
): PaymentMethodVerticalLayoutInteractor {
val paymentMethodIncentiveInteractor = PaymentMethodIncentiveInteractor(
incentive = paymentMethodMetadata.paymentMethodIncentive,
Expand Down Expand Up @@ -169,8 +175,9 @@ internal class DefaultEmbeddedContentHelper @AssistedInject constructor(
onSelectSavedPaymentMethod = {
setSelection(PaymentSelection.Saved(it))
},
walletsState = stateFlowOf(null),
walletsState = walletsState,
canShowWalletsInline = true,
canShowWalletButtons = false,
onMandateTextUpdated = { updatedMandate ->
savedStateHandle[MANDATE_KEY_EMBEDDED_CONTENT] = updatedMandate
},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package com.stripe.android.paymentelement.embedded

import com.stripe.android.lpmfoundations.paymentmethod.PaymentMethodMetadata
import com.stripe.android.model.SetupIntent
import com.stripe.android.paymentsheet.LinkHandler
import com.stripe.android.paymentsheet.model.GooglePayButtonType
import com.stripe.android.paymentsheet.state.WalletsState
import com.stripe.android.uicore.utils.combineAsStateFlow
import kotlinx.coroutines.flow.StateFlow
import javax.inject.Inject

internal fun interface EmbeddedWalletsHelper {
fun walletsState(paymentMethodMetadata: PaymentMethodMetadata): StateFlow<WalletsState?>
}

internal class DefaultEmbeddedWalletsHelper @Inject constructor(
private val linkHandler: LinkHandler,
) : EmbeddedWalletsHelper {
override fun walletsState(paymentMethodMetadata: PaymentMethodMetadata): StateFlow<WalletsState?> {
linkHandler.setupLink(paymentMethodMetadata.linkState)

return combineAsStateFlow(
linkHandler.isLinkEnabled,
linkHandler.linkConfigurationCoordinator.emailFlow,
) { isLinkAvailable, linkEmail ->
WalletsState.create(
isLinkAvailable = isLinkAvailable,
linkEmail = linkEmail,
isGooglePayReady = paymentMethodMetadata.isGooglePayReady == true,
buttonsEnabled = true,
paymentMethodTypes = paymentMethodMetadata.supportedPaymentMethodTypes(),
googlePayLauncherConfig = null, // This isn't used for embedded.
googlePayButtonType = GooglePayButtonType.Pay, // The actual google pay button isn't shown for embedded.
onGooglePayPressed = { throw IllegalStateException("Not possible.") },
onLinkPressed = { throw IllegalStateException("Not possible.") },
isSetupIntent = paymentMethodMetadata.stripeIntent is SetupIntent
)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,9 @@ internal interface SharedPaymentElementViewModelModule {
handler: DefaultEmbeddedConfigurationHandler
): EmbeddedConfigurationHandler

@Binds
fun bindsWalletsHelper(helper: DefaultEmbeddedWalletsHelper): EmbeddedWalletsHelper

@Singleton
@Binds
fun bindsEventReporter(eventReporter: DefaultEventReporter): EventReporter
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ internal class DefaultPaymentMethodVerticalLayoutInteractor(
private val onSelectSavedPaymentMethod: (PaymentMethod) -> Unit,
private val walletsState: StateFlow<WalletsState?>,
private val canShowWalletsInline: Boolean,
private val canShowWalletButtons: Boolean,
private val onMandateTextUpdated: (ResolvableString?) -> Unit,
private val updateSelection: (PaymentSelection?) -> Unit,
private val isCurrentScreen: StateFlow<Boolean>,
Expand Down Expand Up @@ -152,6 +153,7 @@ internal class DefaultPaymentMethodVerticalLayoutInteractor(
onUpdatePaymentMethod = { viewModel.savedPaymentMethodMutator.updatePaymentMethod(it) },
walletsState = viewModel.walletsState,
canShowWalletsInline = !viewModel.isCompleteFlow,
canShowWalletButtons = true,
updateSelection = viewModel::updateSelection,
isCurrentScreen = viewModel.navigationHandler.currentScreen.mapAsStateFlow {
it is PaymentSheetScreen.VerticalMode
Expand Down Expand Up @@ -317,7 +319,7 @@ internal class DefaultPaymentMethodVerticalLayoutInteractor(
}

private fun showsWalletsInline(walletsState: WalletsState?): Boolean {
return canShowWalletsInline && walletsState != null && walletsState.googlePay != null
return canShowWalletsInline && walletsState != null && (walletsState.googlePay != null || !canShowWalletButtons)
}

private fun getDisplayedSavedPaymentMethod(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import com.stripe.android.paymentelement.ExperimentalEmbeddedPaymentElementApi
import com.stripe.android.paymentelement.embedded.DefaultEmbeddedContentHelper.Companion.MANDATE_KEY_EMBEDDED_CONTENT
import com.stripe.android.paymentelement.embedded.DefaultEmbeddedContentHelper.Companion.STATE_KEY_EMBEDDED_CONTENT
import com.stripe.android.paymentsheet.PaymentSheet.Appearance.Embedded
import com.stripe.android.uicore.utils.stateFlowOf
import com.stripe.android.utils.FakeCustomerRepository
import com.stripe.android.utils.FakeLinkConfigurationCoordinator
import com.stripe.android.utils.NullCardAccountRangeRepositoryFactory
Expand Down Expand Up @@ -145,6 +146,7 @@ internal class DefaultEmbeddedContentHelperTest {
workContext = Dispatchers.Unconfined,
customerRepository = FakeCustomerRepository(),
selectionHolder = EmbeddedSelectionHolder(savedStateHandle),
embeddedWalletsHelper = { stateFlowOf(null) },
)
Scenario(
embeddedContentHelper = embeddedContentHelper,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,13 @@ import com.stripe.android.paymentelement.EmbeddedPaymentElement
import com.stripe.android.paymentelement.ExperimentalEmbeddedPaymentElementApi
import com.stripe.android.paymentelement.confirmation.ConfirmationHandler
import com.stripe.android.paymentelement.confirmation.FakeConfirmationHandler
import com.stripe.android.paymentelement.confirmation.gpay.GooglePayConfirmationOption
import com.stripe.android.paymentelement.confirmation.link.LinkConfirmationOption
import com.stripe.android.paymentsheet.PaymentSheet
import com.stripe.android.paymentsheet.model.PaymentSelection
import com.stripe.android.paymentsheet.state.LinkState
import com.stripe.android.paymentsheet.state.PaymentElementLoader
import com.stripe.android.paymentsheet.utils.LinkTestUtils
import com.stripe.android.testing.CoroutineTestRule
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.test.runTest
Expand Down Expand Up @@ -70,7 +74,27 @@ internal class EmbeddedConfirmationHelperTest {
fun confirmCallsConfirmationHandlerStart() = testScenario {
assertThat(confirmationHandler.registerTurbine.awaitItem()).isNotNull()
confirmationHelper.confirm()
assertThat(confirmationHandler.startTurbine.awaitItem()).isNotNull()
val args = confirmationHandler.startTurbine.awaitItem()
assertThat(args.confirmationOption).isInstanceOf<GooglePayConfirmationOption>()
}

@Test
fun confirmCallsConfirmationHandlerStartWithLink() = testScenario(
loadedState = defaultLoadedState().copy(
selection = PaymentSelection.Link,
paymentMethodMetadata = PaymentMethodMetadataFactory.create(
linkState = LinkState(
configuration = LinkTestUtils.createLinkConfiguration(),
loginState = LinkState.LoginState.NeedsVerification,
signupMode = null,
)
)
)
) {
assertThat(confirmationHandler.registerTurbine.awaitItem()).isNotNull()
confirmationHelper.confirm()
val args = confirmationHandler.startTurbine.awaitItem()
assertThat(args.confirmationOption).isInstanceOf<LinkConfirmationOption>()
}

private fun defaultLoadedState(): EmbeddedConfirmationStateHolder.State {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -624,6 +624,32 @@ class DefaultPaymentMethodVerticalLayoutInteractorTest {
}
}

@Test
fun stateDoesReturnsWalletPaymentMethodsWhenInEmbeddedAndGooglePayIsNotAvailable() = runScenario(
paymentMethodMetadata = PaymentMethodMetadataFactory.create(
stripeIntent = PaymentIntentFixtures.PI_REQUIRES_PAYMENT_METHOD.copy(
paymentMethodTypes = listOf("card", "cashapp")
)
),
canShowWalletsInline = true,
canShowWalletButtons = false,
) {
walletsState.value = WalletsState(
link = WalletsState.Link("email@email.com"),
googlePay = null,
buttonsEnabled = true,
dividerTextResource = 0,
onGooglePayPressed = {},
onLinkPressed = {},
)
interactor.state.test {
awaitItem().run {
assertThat(displayablePaymentMethods.map { it.code })
.isEqualTo(listOf("card", "link", "cashapp"))
}
}
}

@Test
fun handleViewAction_PaymentMethodSelected_transitionsToFormScreen_whenFieldsAllowUserInteraction() {
var calledFormScreenFactory = false
Expand Down Expand Up @@ -1041,6 +1067,7 @@ class DefaultPaymentMethodVerticalLayoutInteractorTest {
onSelectSavedPaymentMethod: (PaymentMethod) -> Unit = { notImplemented() },
onUpdatePaymentMethod: (DisplayableSavedPaymentMethod) -> Unit = { notImplemented() },
canShowWalletsInline: Boolean = false,
canShowWalletButtons: Boolean = true,
updateSelection: (PaymentSelection?) -> Unit = { notImplemented() },
onMandateTextUpdated: (ResolvableString?) -> Unit = { notImplemented() },
reportPaymentMethodTypeSelected: (PaymentMethodCode) -> Unit = { notImplemented() },
Expand Down Expand Up @@ -1073,6 +1100,7 @@ class DefaultPaymentMethodVerticalLayoutInteractorTest {
onSelectSavedPaymentMethod = onSelectSavedPaymentMethod,
walletsState = walletsState,
canShowWalletsInline = canShowWalletsInline,
canShowWalletButtons = canShowWalletButtons,
onMandateTextUpdated = onMandateTextUpdated,
updateSelection = { paymentSelection ->
selection.value = paymentSelection
Expand Down

0 comments on commit f611efb

Please sign in to comment.