diff --git a/link/api/link.api b/link/api/link.api index ad86b4a825a..e0dea44cbc9 100644 --- a/link/api/link.api +++ b/link/api/link.api @@ -178,10 +178,6 @@ public final class com/stripe/android/link/LinkPaymentDetails$Saved$Creator : an public synthetic fun newArray (I)[Ljava/lang/Object; } -public final class com/stripe/android/link/LinkPaymentLauncher$Companion { - public final fun getLINK_ENABLED ()Z -} - public final class com/stripe/android/link/LinkPaymentLauncher_Factory { public fun (Ljavax/inject/Provider;Ljavax/inject/Provider;Ljavax/inject/Provider;Ljavax/inject/Provider;Ljavax/inject/Provider;Ljavax/inject/Provider;Ljavax/inject/Provider;Ljavax/inject/Provider;Ljavax/inject/Provider;Ljavax/inject/Provider;Ljavax/inject/Provider;)V public static fun create (Ljavax/inject/Provider;Ljavax/inject/Provider;Ljavax/inject/Provider;Ljavax/inject/Provider;Ljavax/inject/Provider;Ljavax/inject/Provider;Ljavax/inject/Provider;Ljavax/inject/Provider;Ljavax/inject/Provider;Ljavax/inject/Provider;Ljavax/inject/Provider;)Lcom/stripe/android/link/LinkPaymentLauncher_Factory; diff --git a/link/build.gradle b/link/build.gradle index 680bab7ac20..702d4879fa6 100644 --- a/link/build.gradle +++ b/link/build.gradle @@ -22,6 +22,7 @@ dependencies { implementation project(':payments-core') implementation project(':stripe-core') implementation project(':payments-ui-core') + implementation project(':financial-connections') implementation "androidx.appcompat:appcompat:$androidxAppcompatVersion" implementation "androidx.constraintlayout:constraintlayout:$androidxConstraintlayoutVersion" diff --git a/link/src/androidTest/java/com/stripe/android/link/ui/paymentmethod/PaymentMethodScreenTest.kt b/link/src/androidTest/java/com/stripe/android/link/ui/paymentmethod/PaymentMethodScreenTest.kt index ec67ddab33a..ffba2090c6c 100644 --- a/link/src/androidTest/java/com/stripe/android/link/ui/paymentmethod/PaymentMethodScreenTest.kt +++ b/link/src/androidTest/java/com/stripe/android/link/ui/paymentmethod/PaymentMethodScreenTest.kt @@ -24,6 +24,37 @@ internal class PaymentMethodScreenTest { private val primaryButtonLabel = "Pay $10.99" private val secondaryButtonLabel = "Cancel" + @Test + fun when_multiple_payment_methods_supported_then_shows_them_in_cells() { + setContent() + + onCard().assertExists() + onBank().assertExists() + } + + @Test + fun when_single_payment_method_supported_then_shows_no_cells() { + setContent(supportedPaymentMethods = listOf(SupportedPaymentMethod.Card)) + + onCard().assertDoesNotExist() + onBank().assertDoesNotExist() + } + + @Test + fun selecting_payment_method_triggers_callback() { + var selectedPaymentMethod: SupportedPaymentMethod? = null + setContent( + onPaymentMethodSelected = { + selectedPaymentMethod = it + } + ) + + onBank().performClick() + assertThat(selectedPaymentMethod).isEqualTo(SupportedPaymentMethod.BankAccount) + onCard().performClick() + assertThat(selectedPaymentMethod).isEqualTo(SupportedPaymentMethod.Card) + } + @Test fun primary_button_shows_progress_indicator_when_processing() { setContent(primaryButtonState = PrimaryButtonState.Processing) @@ -92,17 +123,23 @@ internal class PaymentMethodScreenTest { } private fun setContent( + supportedPaymentMethods: List = SupportedPaymentMethod.allValues, + selectedPaymentMethod: SupportedPaymentMethod = SupportedPaymentMethod.Card, primaryButtonState: PrimaryButtonState = PrimaryButtonState.Enabled, errorMessage: ErrorMessage? = null, + onPaymentMethodSelected: (SupportedPaymentMethod) -> Unit = {}, onPayButtonClick: () -> Unit = {}, onSecondaryButtonClick: () -> Unit = {} ) = composeTestRule.setContent { DefaultLinkTheme { PaymentMethodBody( + supportedPaymentMethods = supportedPaymentMethods, + selectedPaymentMethod = selectedPaymentMethod, primaryButtonLabel = primaryButtonLabel, primaryButtonState = primaryButtonState, secondaryButtonLabel = secondaryButtonLabel, errorMessage = errorMessage, + onPaymentMethodSelected = onPaymentMethodSelected, onPrimaryButtonClick = onPayButtonClick, onSecondaryButtonClick = onSecondaryButtonClick, formContent = {} @@ -114,4 +151,6 @@ internal class PaymentMethodScreenTest { private fun onSecondaryButton() = composeTestRule.onNodeWithText(secondaryButtonLabel) private fun onProgressIndicator() = composeTestRule.onNodeWithTag(progressIndicatorTestTag) private fun onCompletedIcon() = composeTestRule.onNodeWithTag(completedIconTestTag, true) + private fun onCard() = composeTestRule.onNodeWithText("Card") + private fun onBank() = composeTestRule.onNodeWithText("Bank") } diff --git a/link/src/androidTest/java/com/stripe/android/link/ui/wallet/WalletScreenTest.kt b/link/src/androidTest/java/com/stripe/android/link/ui/wallet/WalletScreenTest.kt index ebb8df08443..85881d12023 100644 --- a/link/src/androidTest/java/com/stripe/android/link/ui/wallet/WalletScreenTest.kt +++ b/link/src/androidTest/java/com/stripe/android/link/ui/wallet/WalletScreenTest.kt @@ -97,7 +97,10 @@ internal class WalletScreenTest { @Test fun selected_payment_method_is_shown_when_collapsed() { val initiallySelectedItem = paymentDetails[4] - setContent(selectedItem = initiallySelectedItem) + setContent( + isExpanded = false, + selectedItem = initiallySelectedItem + ) composeTestRule.onNodeWithText("Payment").onParent().onChildren() .filter(hasText(initiallySelectedItem.label, substring = true)) @@ -105,9 +108,31 @@ internal class WalletScreenTest { } @Test - fun when_no_payment_option_is_selected_then_list_is_expanded() { - setContent(selectedItem = null) + fun expand_list_triggers_callback() { + var expanded: Boolean? = null + setContent( + isExpanded = false, + setExpanded = { + expanded = it + } + ) + assertCollapsed() + composeTestRule.onNodeWithText("Payment").performClick() + assertThat(expanded).isTrue() + } + + @Test + fun collapse_list_triggers_callback() { + var expanded: Boolean? = null + setContent( + isExpanded = true, + setExpanded = { + expanded = it + } + ) assertExpanded() + composeTestRule.onNodeWithText("Payment methods").performClick() + assertThat(expanded).isFalse() } @Test @@ -357,8 +382,10 @@ internal class WalletScreenTest { private fun setContent( supportedTypes: Set = SupportedPaymentMethod.allTypes, selectedItem: ConsumerPaymentDetails.PaymentDetails? = paymentDetails.first(), + isExpanded: Boolean = true, primaryButtonState: PrimaryButtonState = PrimaryButtonState.Enabled, errorMessage: ErrorMessage? = null, + setExpanded: (Boolean) -> Unit = {}, onItemSelected: (ConsumerPaymentDetails.PaymentDetails) -> Unit = {}, onAddNewPaymentMethodClick: () -> Unit = {}, onEditPaymentMethod: (ConsumerPaymentDetails.PaymentDetails) -> Unit = {}, @@ -395,9 +422,11 @@ internal class WalletScreenTest { paymentDetailsList = paymentDetailsList, supportedTypes = supportedTypes, selectedItem = selectedItem, + isExpanded = isExpanded, primaryButtonLabel = primaryButtonLabel, primaryButtonState = primaryButtonState, errorMessage = errorMessage, + setExpanded = setExpanded, onItemSelected = onItemSelected, onAddNewPaymentMethodClick = onAddNewPaymentMethodClick, onEditPaymentMethod = onEditPaymentMethod, diff --git a/link/src/main/java/com/stripe/android/link/LinkActivity.kt b/link/src/main/java/com/stripe/android/link/LinkActivity.kt index 544b848f11c..9cd54c0930a 100644 --- a/link/src/main/java/com/stripe/android/link/LinkActivity.kt +++ b/link/src/main/java/com/stripe/android/link/LinkActivity.kt @@ -43,6 +43,7 @@ import com.stripe.android.link.model.AccountStatus import com.stripe.android.link.model.isOnRootScreen import com.stripe.android.link.theme.DefaultLinkTheme import com.stripe.android.link.theme.linkColors +import com.stripe.android.link.theme.linkShapes import com.stripe.android.link.ui.BottomSheetContent import com.stripe.android.link.ui.LinkAppBar import com.stripe.android.link.ui.cardedit.CardEditBody @@ -101,7 +102,7 @@ internal class LinkActivity : ComponentActivity() { }, modifier = Modifier.fillMaxHeight(), sheetState = sheetState, - sheetShape = MaterialTheme.shapes.large.copy( + sheetShape = MaterialTheme.linkShapes.large.copy( bottomStart = CornerSize(0.dp), bottomEnd = CornerSize(0.dp) ), diff --git a/link/src/main/java/com/stripe/android/link/LinkPaymentLauncher.kt b/link/src/main/java/com/stripe/android/link/LinkPaymentLauncher.kt index 6a42ff280d5..005bf17ed6f 100644 --- a/link/src/main/java/com/stripe/android/link/LinkPaymentLauncher.kt +++ b/link/src/main/java/com/stripe/android/link/LinkPaymentLauncher.kt @@ -237,7 +237,9 @@ class LinkPaymentLauncher @AssistedInject internal constructor( WeakMapInjectorRegistry.register(injector, injectorKey) } + @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) companion object { val LINK_ENABLED = BuildConfig.DEBUG + val supportedFundingSources = SupportedPaymentMethod.allTypes } } diff --git a/link/src/main/java/com/stripe/android/link/theme/LinkShapes.kt b/link/src/main/java/com/stripe/android/link/theme/LinkShapes.kt new file mode 100644 index 00000000000..a45121f807e --- /dev/null +++ b/link/src/main/java/com/stripe/android/link/theme/LinkShapes.kt @@ -0,0 +1,11 @@ +package com.stripe.android.link.theme + +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.ui.unit.dp + +internal object LinkShapes { + val extraSmall = RoundedCornerShape(4.dp) + val small = RoundedCornerShape(8.dp) + val medium = RoundedCornerShape(12.dp) + val large = RoundedCornerShape(14.dp) +} diff --git a/link/src/main/java/com/stripe/android/link/theme/Shape.kt b/link/src/main/java/com/stripe/android/link/theme/Shape.kt deleted file mode 100644 index 110ee5ee9f3..00000000000 --- a/link/src/main/java/com/stripe/android/link/theme/Shape.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.stripe.android.link.theme - -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.Shapes -import androidx.compose.ui.unit.dp - -internal val Shapes = Shapes( - small = RoundedCornerShape(4.dp), - medium = RoundedCornerShape(12.dp), - large = RoundedCornerShape(14.dp) -) diff --git a/link/src/main/java/com/stripe/android/link/theme/Theme.kt b/link/src/main/java/com/stripe/android/link/theme/Theme.kt index e216b964aa5..1c9d49f3ee2 100644 --- a/link/src/main/java/com/stripe/android/link/theme/Theme.kt +++ b/link/src/main/java/com/stripe/android/link/theme/Theme.kt @@ -26,7 +26,7 @@ internal fun DefaultLinkTheme( MaterialTheme( colors = colors.materialColors, typography = Typography, - shapes = Shapes, + shapes = MaterialTheme.shapes, content = content ) } @@ -36,3 +36,8 @@ internal val MaterialTheme.linkColors: LinkColors @Composable @ReadOnlyComposable get() = LocalColors.current + +internal val MaterialTheme.linkShapes: LinkShapes + @Composable + @ReadOnlyComposable + get() = LinkShapes diff --git a/link/src/main/java/com/stripe/android/link/ui/LinkButtonView.kt b/link/src/main/java/com/stripe/android/link/ui/LinkButtonView.kt index 7db39a58bcd..8213817e3c7 100644 --- a/link/src/main/java/com/stripe/android/link/ui/LinkButtonView.kt +++ b/link/src/main/java/com/stripe/android/link/ui/LinkButtonView.kt @@ -35,6 +35,7 @@ import com.stripe.android.link.LinkPaymentLauncher import com.stripe.android.link.R import com.stripe.android.link.theme.DefaultLinkTheme import com.stripe.android.link.theme.linkColors +import com.stripe.android.link.theme.linkShapes private val LinkButtonVerticalPadding = 6.dp private val LinkButtonHorizontalPadding = 10.dp @@ -106,7 +107,7 @@ private fun LinkButton( modifier = Modifier .background( color = Color.Black.copy(alpha = 0.05f), - shape = MaterialTheme.shapes.small + shape = MaterialTheme.linkShapes.extraSmall ) ) { Text( diff --git a/link/src/main/java/com/stripe/android/link/ui/PrimaryButton.kt b/link/src/main/java/com/stripe/android/link/ui/PrimaryButton.kt index dc7d5067813..d3f9ce74ae7 100644 --- a/link/src/main/java/com/stripe/android/link/ui/PrimaryButton.kt +++ b/link/src/main/java/com/stripe/android/link/ui/PrimaryButton.kt @@ -32,6 +32,7 @@ import com.stripe.android.link.R import com.stripe.android.link.theme.DefaultLinkTheme import com.stripe.android.link.theme.PrimaryButtonHeight import com.stripe.android.link.theme.linkColors +import com.stripe.android.link.theme.linkShapes import com.stripe.android.model.PaymentIntent import com.stripe.android.model.SetupIntent import com.stripe.android.model.StripeIntent @@ -104,7 +105,7 @@ internal fun PrimaryButton( .fillMaxWidth(), enabled = state == PrimaryButtonState.Enabled, elevation = ButtonDefaults.elevation(0.dp, 0.dp, 0.dp, 0.dp, 0.dp), - shape = MaterialTheme.shapes.medium, + shape = MaterialTheme.linkShapes.medium, colors = ButtonDefaults.buttonColors( backgroundColor = MaterialTheme.colors.primary, disabledBackgroundColor = MaterialTheme.colors.primary @@ -185,7 +186,7 @@ internal fun SecondaryButton( .fillMaxWidth() .height(PrimaryButtonHeight), enabled = enabled, - shape = MaterialTheme.shapes.medium, + shape = MaterialTheme.linkShapes.medium, colors = ButtonDefaults.buttonColors( backgroundColor = MaterialTheme.colors.secondary, disabledBackgroundColor = MaterialTheme.colors.secondary diff --git a/link/src/main/java/com/stripe/android/link/ui/inline/LinkInlineSignupView.kt b/link/src/main/java/com/stripe/android/link/ui/inline/LinkInlineSignupView.kt index dde270696c8..0e0867ded76 100644 --- a/link/src/main/java/com/stripe/android/link/ui/inline/LinkInlineSignupView.kt +++ b/link/src/main/java/com/stripe/android/link/ui/inline/LinkInlineSignupView.kt @@ -37,6 +37,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel import com.stripe.android.link.LinkPaymentLauncher import com.stripe.android.link.R import com.stripe.android.link.theme.DefaultLinkTheme +import com.stripe.android.link.theme.linkShapes import com.stripe.android.link.ui.LinkTerms import com.stripe.android.link.ui.signup.EmailCollectionSection import com.stripe.android.link.ui.signup.SignUpState @@ -131,11 +132,11 @@ internal fun LinkInlineSignup( .fillMaxWidth() .border( border = MaterialTheme.getBorderStroke(isSelected = false), - shape = MaterialTheme.shapes.medium + shape = MaterialTheme.linkShapes.medium ) .background( color = MaterialTheme.paymentsColors.component, - shape = MaterialTheme.shapes.medium + shape = MaterialTheme.linkShapes.medium ) ) { Row( diff --git a/link/src/main/java/com/stripe/android/link/ui/paymentmethod/PaymentMethodBody.kt b/link/src/main/java/com/stripe/android/link/ui/paymentmethod/PaymentMethodBody.kt index deafeefc581..d9491d2c422 100644 --- a/link/src/main/java/com/stripe/android/link/ui/paymentmethod/PaymentMethodBody.kt +++ b/link/src/main/java/com/stripe/android/link/ui/paymentmethod/PaymentMethodBody.kt @@ -1,38 +1,56 @@ package com.stripe.android.link.ui.paymentmethod +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.LocalContentAlpha import androidx.compose.material.MaterialTheme import androidx.compose.material.Surface import androidx.compose.material.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel +import com.stripe.android.financialconnections.FinancialConnectionsSheet +import com.stripe.android.financialconnections.launcher.FinancialConnectionsSheetActivityArgs +import com.stripe.android.financialconnections.launcher.FinancialConnectionsSheetForLinkContract import com.stripe.android.link.R import com.stripe.android.link.model.LinkAccount import com.stripe.android.link.theme.DefaultLinkTheme import com.stripe.android.link.theme.PaymentsThemeForLink +import com.stripe.android.link.theme.linkColors +import com.stripe.android.link.theme.linkShapes import com.stripe.android.link.ui.ErrorMessage import com.stripe.android.link.ui.ErrorText import com.stripe.android.link.ui.PrimaryButton import com.stripe.android.link.ui.PrimaryButtonState import com.stripe.android.link.ui.ScrollableTopLevelColumn import com.stripe.android.link.ui.SecondaryButton -import com.stripe.android.link.ui.completePaymentButtonLabel import com.stripe.android.link.ui.forms.Form import com.stripe.android.ui.core.injection.NonFallbackInjector @@ -42,10 +60,13 @@ private fun PaymentMethodBodyPreview() { DefaultLinkTheme { Surface { PaymentMethodBody( + supportedPaymentMethods = SupportedPaymentMethod.values().toList(), + selectedPaymentMethod = SupportedPaymentMethod.Card, primaryButtonLabel = "Pay $10.99", primaryButtonState = PrimaryButtonState.Enabled, secondaryButtonLabel = "Cancel", errorMessage = null, + onPaymentMethodSelected = {}, onPrimaryButtonClick = {}, onSecondaryButtonClick = {} ) {} @@ -63,9 +84,60 @@ internal fun PaymentMethodBody( factory = PaymentMethodViewModel.Factory(linkAccount, injector, loadFromArgs) ) + val activityResultLauncher = rememberLauncherForActivityResult( + contract = FinancialConnectionsSheetForLinkContract(), + onResult = viewModel::onFinancialConnectionsAccountLinked + ) + + val clientSecret by viewModel.financialConnectionsSessionClientSecret.collectAsState() + + clientSecret?.let { secret -> + LaunchedEffect(secret) { + activityResultLauncher.launch( + FinancialConnectionsSheetActivityArgs.ForLink( + FinancialConnectionsSheet.Configuration( + financialConnectionsSessionClientSecret = secret, + publishableKey = viewModel.publishableKey + ) + ) + ) + } + } + val formController by viewModel.formController.collectAsState() - if (formController == null) { + formController?.let { controller -> + val formValues by controller.completeFormValues.collectAsState(null) + val primaryButtonState by viewModel.primaryButtonState.collectAsState() + val errorMessage by viewModel.errorMessage.collectAsState() + val paymentMethod by viewModel.paymentMethod.collectAsState() + + PaymentMethodBody( + supportedPaymentMethods = viewModel.supportedTypes, + selectedPaymentMethod = paymentMethod, + primaryButtonLabel = paymentMethod.primaryButtonLabel( + viewModel.args.stripeIntent, + LocalContext.current.resources + ), + primaryButtonState = primaryButtonState.takeIf { formValues != null } + ?: PrimaryButtonState.Disabled, + secondaryButtonLabel = stringResource(id = viewModel.secondaryButtonLabel), + errorMessage = errorMessage, + onPaymentMethodSelected = viewModel::onPaymentMethodSelected, + onPrimaryButtonClick = { + formValues?.let { + viewModel.startPayment(it) + } + }, + onSecondaryButtonClick = viewModel::onSecondaryButtonClick, + formContent = { + Form( + controller, + viewModel.isEnabled + ) + } + ) + } ?: run { Box( modifier = Modifier .fillMaxHeight() @@ -74,43 +146,18 @@ internal fun PaymentMethodBody( ) { CircularProgressIndicator() } - } else { - formController?.let { - val formValues by it.completeFormValues.collectAsState(null) - val primaryButtonState by viewModel.primaryButtonState.collectAsState() - val errorMessage by viewModel.errorMessage.collectAsState() - - PaymentMethodBody( - primaryButtonLabel = completePaymentButtonLabel( - viewModel.args.stripeIntent, - LocalContext.current.resources - ), - primaryButtonState = primaryButtonState.takeIf { formValues != null } - ?: PrimaryButtonState.Disabled, - secondaryButtonLabel = stringResource(id = viewModel.secondaryButtonLabel), - errorMessage = errorMessage, - onPrimaryButtonClick = { - formValues?.let { - viewModel.startPayment(it) - } - }, - onSecondaryButtonClick = viewModel::onSecondaryButtonClick - ) { - Form( - it, - viewModel.isEnabled - ) - } - } } } @Composable internal fun PaymentMethodBody( + supportedPaymentMethods: List, + selectedPaymentMethod: SupportedPaymentMethod, primaryButtonLabel: String, primaryButtonState: PrimaryButtonState, secondaryButtonLabel: String, errorMessage: ErrorMessage?, + onPaymentMethodSelected: (SupportedPaymentMethod) -> Unit, onPrimaryButtonClick: () -> Unit, onSecondaryButtonClick: () -> Unit, formContent: @Composable ColumnScope.() -> Unit @@ -124,10 +171,32 @@ internal fun PaymentMethodBody( style = MaterialTheme.typography.h2, color = MaterialTheme.colors.onPrimary ) - PaymentsThemeForLink { - formContent() + if (supportedPaymentMethods.size > 1) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp), + horizontalArrangement = Arrangement.spacedBy(20.dp) + ) { + supportedPaymentMethods.forEach { paymentMethod -> + PaymentMethodTypeCell( + paymentMethod = paymentMethod, + selected = paymentMethod == selectedPaymentMethod, + enabled = !primaryButtonState.isBlocking, + onSelected = { + onPaymentMethodSelected(paymentMethod) + } + ) + } + } + } + if (selectedPaymentMethod.showsForm) { + Spacer(modifier = Modifier.height(4.dp)) + PaymentsThemeForLink { + formContent() + } + Spacer(modifier = Modifier.height(8.dp)) } - Spacer(modifier = Modifier.height(8.dp)) errorMessage?.let { ErrorText( text = it.getMessage(LocalContext.current.resources), @@ -138,7 +207,8 @@ internal fun PaymentMethodBody( label = primaryButtonLabel, state = primaryButtonState, onButtonClick = onPrimaryButtonClick, - iconEnd = R.drawable.stripe_ic_lock + iconStart = selectedPaymentMethod.primaryButtonStartIconResourceId, + iconEnd = selectedPaymentMethod.primaryButtonEndIconResourceId ) SecondaryButton( enabled = !primaryButtonState.isBlocking, @@ -147,3 +217,70 @@ internal fun PaymentMethodBody( ) } } + +@Composable +private fun RowScope.PaymentMethodTypeCell( + paymentMethod: SupportedPaymentMethod, + selected: Boolean, + enabled: Boolean, + onSelected: () -> Unit, + modifier: Modifier = Modifier +) { + CompositionLocalProvider(LocalContentAlpha provides if (enabled) 1f else 0.6f) { + Surface( + modifier = modifier + .height(56.dp) + .weight(1f), + shape = MaterialTheme.linkShapes.small, + color = MaterialTheme.linkColors.componentBackground, + border = BorderStroke( + width = if (selected) { + 2.dp + } else { + 1.dp + }, + color = if (selected) { + MaterialTheme.colors.primary + } else { + MaterialTheme.linkColors.componentBorder + } + ) + ) { + Row( + modifier = Modifier + .fillMaxSize() + .clickable( + enabled = enabled, + onClick = onSelected + ), + verticalAlignment = Alignment.CenterVertically + ) { + Image( + painter = painterResource(id = paymentMethod.iconResourceId), + contentDescription = null, + modifier = Modifier + .width(50.dp) + .padding(horizontal = 16.dp), + alpha = LocalContentAlpha.current, + colorFilter = ColorFilter.tint( + color = if (selected) { + MaterialTheme.colors.primary + } else { + MaterialTheme.colors.onSecondary + } + ) + ) + Text( + text = stringResource(id = paymentMethod.nameResourceId), + modifier = Modifier.padding(end = 16.dp), + color = if (selected) { + MaterialTheme.colors.onPrimary + } else { + MaterialTheme.colors.onSecondary + }, + style = MaterialTheme.typography.h6 + ) + } + } + } +} diff --git a/link/src/main/java/com/stripe/android/link/ui/paymentmethod/PaymentMethodViewModel.kt b/link/src/main/java/com/stripe/android/link/ui/paymentmethod/PaymentMethodViewModel.kt index 037db25bb02..c230106f724 100644 --- a/link/src/main/java/com/stripe/android/link/ui/paymentmethod/PaymentMethodViewModel.kt +++ b/link/src/main/java/com/stripe/android/link/ui/paymentmethod/PaymentMethodViewModel.kt @@ -4,9 +4,11 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope import com.stripe.android.core.Logger +import com.stripe.android.financialconnections.launcher.FinancialConnectionsSheetLinkResult import com.stripe.android.link.LinkActivityContract import com.stripe.android.link.LinkActivityResult import com.stripe.android.link.LinkPaymentDetails +import com.stripe.android.link.LinkScreen import com.stripe.android.link.R import com.stripe.android.link.account.LinkAccountManager import com.stripe.android.link.confirmation.ConfirmStripeIntentParamsFactory @@ -14,9 +16,12 @@ import com.stripe.android.link.confirmation.ConfirmationManager import com.stripe.android.link.injection.SignedInViewModelSubcomponent import com.stripe.android.link.model.LinkAccount import com.stripe.android.link.model.Navigator +import com.stripe.android.link.model.supportedPaymentMethodTypes import com.stripe.android.link.ui.ErrorMessage import com.stripe.android.link.ui.PrimaryButtonState import com.stripe.android.link.ui.getErrorMessage +import com.stripe.android.link.ui.wallet.PaymentDetailsResult +import com.stripe.android.model.ConsumerPaymentDetails import com.stripe.android.payments.paymentlauncher.PaymentResult import com.stripe.android.ui.core.FieldValuesToParamsMapConverter import com.stripe.android.ui.core.FormController @@ -67,9 +72,23 @@ internal class PaymentMethodViewModel @Inject constructor( R.string.cancel } - val paymentMethod = SupportedPaymentMethod.Card + val supportedTypes = args.stripeIntent.supportedPaymentMethodTypes(linkAccount) + .let { supportedTypes -> + SupportedPaymentMethod.values().filter { supportedTypes.contains(it.type) } + } + + private val _paymentMethod = MutableStateFlow(supportedTypes.first()) + val paymentMethod: StateFlow = _paymentMethod val formController = MutableStateFlow(null) + private val formControllersCache = mutableMapOf() + + private val _financialConnectionsSessionClientSecret = MutableStateFlow(null) + val financialConnectionsSessionClientSecret: StateFlow = + _financialConnectionsSessionClientSecret + + // User must be signed in when Wallet Screen is loaded, so [consumerPublishableKey] is not null + val publishableKey = requireNotNull(linkAccountManager.consumerPublishableKey) fun init(loadFromArgs: Boolean) { val cardMap = args.prefilledCardParams?.toParamMap() @@ -78,38 +97,50 @@ internal class PaymentMethodViewModel @Inject constructor( ?: emptyMap() val initialValuesMap = args.initialFormValuesMap ?: emptyMap() - val combinedMap = cardMap + initialValuesMap - formController.value = - formControllerProvider.get() - .formSpec(LayoutSpec(paymentMethod.formSpec)) - .viewOnlyFields(emptySet()) - .viewModelScope(viewModelScope) - .initialValues(combinedMap) - .stripeIntent(args.stripeIntent) - .merchantName(args.merchantName) - .build().formController + updateFormController(cardMap + initialValuesMap) + } + + fun onPaymentMethodSelected(paymentMethod: SupportedPaymentMethod) { + _paymentMethod.value = paymentMethod + updateFormController() } fun startPayment(formValues: Map) { clearError() setState(PrimaryButtonState.Processing) - val paymentMethodCreateParams = - FieldValuesToParamsMapConverter.transformToPaymentMethodCreateParams( - formValues, - paymentMethod.type, - false - ) + when (paymentMethod.value) { + SupportedPaymentMethod.Card -> { + val paymentMethodCreateParams = + FieldValuesToParamsMapConverter.transformToPaymentMethodCreateParams( + formValues, + paymentMethod.value.type, + false + ) - viewModelScope.launch { - linkAccountManager.createCardPaymentDetails( - paymentMethodCreateParams, - linkAccount.email, - args.stripeIntent - ).fold( - onSuccess = ::completePayment, - onFailure = ::onError - ) + viewModelScope.launch { + linkAccountManager.createCardPaymentDetails( + paymentMethodCreateParams, + linkAccount.email, + args.stripeIntent + ).fold( + onSuccess = ::completePayment, + onFailure = ::onError + ) + } + } + SupportedPaymentMethod.BankAccount -> { + viewModelScope.launch { + linkAccountManager.createFinancialConnectionsSession() + .mapCatching { requireNotNull(it.clientSecret) } + .fold( + onSuccess = { + _financialConnectionsSessionClientSecret.value = it + }, + onFailure = ::onError + ) + } + } } } @@ -121,6 +152,50 @@ internal class PaymentMethodViewModel @Inject constructor( } } + fun onFinancialConnectionsAccountLinked(result: FinancialConnectionsSheetLinkResult) { + when (result) { + is FinancialConnectionsSheetLinkResult.Canceled -> setState(PrimaryButtonState.Enabled) + is FinancialConnectionsSheetLinkResult.Failed -> onError(result.error) + is FinancialConnectionsSheetLinkResult.Completed -> { + viewModelScope.launch { + linkAccountManager.createBankAccountPaymentDetails(result.linkedAccountId) + .fold( + onSuccess = ::navigateToWallet, + onFailure = ::onError + ) + } + } + } + } + + private fun updateFormController( + initialValues: Map = emptyMap() + ) { + formController.value = + formControllersCache[paymentMethod.value] ?: formControllerProvider.get() + .formSpec(LayoutSpec(paymentMethod.value.formSpec)) + .viewOnlyFields(emptySet()) + .viewModelScope(viewModelScope) + .initialValues(initialValues) + .stripeIntent(args.stripeIntent) + .merchantName(args.merchantName) + .build() + .formController + .also { formControllersCache[paymentMethod.value] = it } + } + + private fun navigateToWallet(selectedAccount: ConsumerPaymentDetails.BankAccount) { + if (navigator.isOnRootScreen() == false) { + navigator.setResult( + PaymentDetailsResult.KEY, + PaymentDetailsResult.Success(selectedAccount.id) + ) + navigator.onBack(userInitiated = false) + } else { + navigator.navigateTo(LinkScreen.Wallet, clearBackStack = true) + } + } + private fun payAnotherWay() { clearError() navigator.dismiss() diff --git a/link/src/main/java/com/stripe/android/link/ui/verification/VerificationDialog.kt b/link/src/main/java/com/stripe/android/link/ui/verification/VerificationDialog.kt index af51c9c6a15..6416d9af589 100644 --- a/link/src/main/java/com/stripe/android/link/ui/verification/VerificationDialog.kt +++ b/link/src/main/java/com/stripe/android/link/ui/verification/VerificationDialog.kt @@ -24,6 +24,7 @@ import com.stripe.android.link.LinkPaymentLauncher import com.stripe.android.link.LinkScreen import com.stripe.android.link.R import com.stripe.android.link.theme.DefaultLinkTheme +import com.stripe.android.link.theme.linkShapes import com.stripe.android.link.ui.LinkAppBar import com.stripe.android.link.ui.rememberLinkAppBarState @@ -74,7 +75,7 @@ fun LinkVerificationDialog( modifier = Modifier .fillMaxWidth() .padding(16.dp), - shape = MaterialTheme.shapes.medium + shape = MaterialTheme.linkShapes.medium ) { Column { val appBarState = rememberLinkAppBarState( diff --git a/link/src/main/java/com/stripe/android/link/ui/verification/VerificationScreen.kt b/link/src/main/java/com/stripe/android/link/ui/verification/VerificationScreen.kt index d86a87618e4..a5c0bd87759 100644 --- a/link/src/main/java/com/stripe/android/link/ui/verification/VerificationScreen.kt +++ b/link/src/main/java/com/stripe/android/link/ui/verification/VerificationScreen.kt @@ -35,6 +35,7 @@ import com.stripe.android.link.R import com.stripe.android.link.model.LinkAccount import com.stripe.android.link.theme.DefaultLinkTheme import com.stripe.android.link.theme.linkColors +import com.stripe.android.link.theme.linkShapes import com.stripe.android.link.ui.ErrorMessage import com.stripe.android.link.ui.ErrorText import com.stripe.android.link.ui.ScrollableTopLevelColumn @@ -209,7 +210,7 @@ internal fun VerificationBody( .border( width = 1.dp, color = MaterialTheme.linkColors.componentBorder, - shape = MaterialTheme.shapes.small + shape = MaterialTheme.linkShapes.extraSmall ) .clickable( enabled = !isProcessing, diff --git a/link/src/main/java/com/stripe/android/link/ui/wallet/PaymentDetails.kt b/link/src/main/java/com/stripe/android/link/ui/wallet/PaymentDetails.kt index a06076b6c27..8be7c62df19 100644 --- a/link/src/main/java/com/stripe/android/link/ui/wallet/PaymentDetails.kt +++ b/link/src/main/java/com/stripe/android/link/ui/wallet/PaymentDetails.kt @@ -35,6 +35,7 @@ import androidx.compose.ui.unit.sp import com.stripe.android.link.R import com.stripe.android.link.model.icon import com.stripe.android.link.theme.linkColors +import com.stripe.android.link.theme.linkShapes import com.stripe.android.link.ui.ErrorText import com.stripe.android.link.ui.ErrorTextStyle import com.stripe.android.model.ConsumerPaymentDetails @@ -81,7 +82,7 @@ internal fun PaymentDetailsListItem( .height(20.dp) .background( color = MaterialTheme.colors.secondary, - shape = MaterialTheme.shapes.small + shape = MaterialTheme.linkShapes.extraSmall ), contentAlignment = Alignment.Center ) { diff --git a/link/src/main/java/com/stripe/android/link/ui/wallet/WalletScreen.kt b/link/src/main/java/com/stripe/android/link/ui/wallet/WalletScreen.kt index 152c34b68be..9041e3c734b 100644 --- a/link/src/main/java/com/stripe/android/link/ui/wallet/WalletScreen.kt +++ b/link/src/main/java/com/stripe/android/link/ui/wallet/WalletScreen.kt @@ -22,7 +22,6 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -42,6 +41,7 @@ import com.stripe.android.link.model.LinkAccount import com.stripe.android.link.theme.DefaultLinkTheme import com.stripe.android.link.theme.HorizontalPadding import com.stripe.android.link.theme.linkColors +import com.stripe.android.link.theme.linkShapes import com.stripe.android.link.ui.BottomSheetContent import com.stripe.android.link.ui.ErrorMessage import com.stripe.android.link.ui.ErrorText @@ -55,7 +55,6 @@ import com.stripe.android.model.CardBrand import com.stripe.android.model.ConsumerPaymentDetails import com.stripe.android.ui.core.elements.Html import com.stripe.android.ui.core.injection.NonFallbackInjector -import com.stripe.android.ui.core.paymentsColors @Preview @Composable @@ -83,9 +82,11 @@ private fun WalletBodyPreview() { ), supportedTypes = SupportedPaymentMethod.allTypes, selectedItem = null, + isExpanded = true, primaryButtonLabel = "Pay $10.99", primaryButtonState = PrimaryButtonState.Enabled, errorMessage = null, + setExpanded = {}, onItemSelected = {}, onAddNewPaymentMethodClick = {}, onEditPaymentMethod = {}, @@ -115,6 +116,7 @@ internal fun WalletBody( val primaryButtonState by viewModel.primaryButtonState.collectAsState() val selectedItem by viewModel.selectedItem.collectAsState() val errorMessage by viewModel.errorMessage.collectAsState() + val isExpanded by viewModel.isExpanded.collectAsState() if (paymentDetailsList.isEmpty()) { Box( @@ -130,12 +132,14 @@ internal fun WalletBody( paymentDetailsList = paymentDetailsList, supportedTypes = viewModel.supportedTypes, selectedItem = selectedItem, + isExpanded = isExpanded, primaryButtonLabel = completePaymentButtonLabel( viewModel.args.stripeIntent, LocalContext.current.resources ), primaryButtonState = primaryButtonState, errorMessage = errorMessage, + setExpanded = viewModel::setExpanded, onItemSelected = viewModel::onItemSelected, onAddNewPaymentMethodClick = viewModel::addNewPaymentMethod, onEditPaymentMethod = viewModel::editPaymentMethod, @@ -152,9 +156,11 @@ internal fun WalletBody( paymentDetailsList: List, supportedTypes: Set, selectedItem: ConsumerPaymentDetails.PaymentDetails?, + isExpanded: Boolean, primaryButtonLabel: String, primaryButtonState: PrimaryButtonState, errorMessage: ErrorMessage?, + setExpanded: (Boolean) -> Unit, onItemSelected: (ConsumerPaymentDetails.PaymentDetails) -> Unit, onAddNewPaymentMethodClick: () -> Unit, onEditPaymentMethod: (ConsumerPaymentDetails.PaymentDetails) -> Unit, @@ -164,7 +170,6 @@ internal fun WalletBody( showBottomSheetContent: (BottomSheetContent?) -> Unit ) { val selectedItemIsValid = selectedItem?.let { supportedTypes.contains(it.type) } ?: false - var isWalletExpanded by rememberSaveable { mutableStateOf(!selectedItemIsValid) } var itemBeingRemoved by remember { mutableStateOf(null) } @@ -192,8 +197,8 @@ internal fun WalletBody( ScrollableTopLevelColumn { Spacer(modifier = Modifier.height(12.dp)) - if (isWalletExpanded || !selectedItemIsValid) { - isWalletExpanded = true + if (isExpanded || !selectedItemIsValid) { + setExpanded(true) ExpandedPaymentDetails( paymentDetailsList = paymentDetailsList, supportedTypes = supportedTypes, @@ -201,7 +206,7 @@ internal fun WalletBody( enabled = !primaryButtonState.isBlocking, onItemSelected = { onItemSelected(it) - isWalletExpanded = false + setExpanded(false) }, onMenuButtonClick = { showBottomSheetContent { @@ -223,7 +228,7 @@ internal fun WalletBody( }, onAddNewPaymentMethodClick = onAddNewPaymentMethodClick, onCollapse = { - isWalletExpanded = false + setExpanded(false) } ) } else { @@ -231,7 +236,7 @@ internal fun WalletBody( selectedPaymentMethod = selectedItem!!, enabled = !primaryButtonState.isBlocking, onClick = { - isWalletExpanded = true + setExpanded(true) } ) } @@ -239,7 +244,7 @@ internal fun WalletBody( Html( html = stringResource(R.string.wallet_bank_account_terms), imageGetter = emptyMap(), - color = MaterialTheme.paymentsColors.placeholderText, + color = MaterialTheme.colors.onSecondary, style = MaterialTheme.typography.caption, modifier = Modifier .fillMaxWidth() @@ -287,11 +292,11 @@ internal fun CollapsedPaymentDetails( .border( width = 1.dp, color = MaterialTheme.linkColors.componentBorder, - shape = MaterialTheme.shapes.large + shape = MaterialTheme.linkShapes.large ) .background( color = MaterialTheme.linkColors.componentBackground, - shape = MaterialTheme.shapes.large + shape = MaterialTheme.linkShapes.large ) .clickable( enabled = enabled, @@ -339,11 +344,11 @@ private fun ExpandedPaymentDetails( .border( width = 1.dp, color = MaterialTheme.linkColors.componentBorder, - shape = MaterialTheme.shapes.large + shape = MaterialTheme.linkShapes.large ) .background( color = MaterialTheme.linkColors.componentBackground, - shape = MaterialTheme.shapes.large + shape = MaterialTheme.linkShapes.large ) ) { Row( @@ -375,7 +380,7 @@ private fun ExpandedPaymentDetails( // TODO(brnunes-stripe): Use LazyColumn, will need to write custom shape for the border // https://juliensalvi.medium.com/custom-shape-with-jetpack-compose-1cb48a991d42 - paymentDetailsList.forEachIndexed { index, item -> + paymentDetailsList.forEach { item -> PaymentDetailsListItem( paymentDetails = item, enabled = enabled, @@ -398,7 +403,7 @@ private fun ExpandedPaymentDetails( verticalAlignment = Alignment.CenterVertically ) { Icon( - painter = painterResource(id = R.drawable.ic_link_add), + painter = painterResource(id = R.drawable.ic_link_add_green), contentDescription = null, modifier = Modifier.padding(start = HorizontalPadding, end = 12.dp), tint = Color.Unspecified diff --git a/link/src/main/java/com/stripe/android/link/ui/wallet/WalletViewModel.kt b/link/src/main/java/com/stripe/android/link/ui/wallet/WalletViewModel.kt index 9527731278e..9a939ed2496 100644 --- a/link/src/main/java/com/stripe/android/link/ui/wallet/WalletViewModel.kt +++ b/link/src/main/java/com/stripe/android/link/ui/wallet/WalletViewModel.kt @@ -46,6 +46,9 @@ internal class WalletViewModel @Inject constructor( requireNotNull(linkAccountManager.linkAccount.value) ) + private val _isExpanded = MutableStateFlow(false) + val isExpanded: StateFlow = _isExpanded + private val _selectedItem = MutableStateFlow(null) val selectedItem: StateFlow = _selectedItem @@ -59,14 +62,14 @@ internal class WalletViewModel @Inject constructor( loadPaymentDetails(true) viewModelScope.launch { - navigator.getResultFlow(PaymentDetailsResult.KEY) - ?.collect { - when (it) { - is PaymentDetailsResult.Success -> loadPaymentDetails() - PaymentDetailsResult.Cancelled -> {} - is PaymentDetailsResult.Failure -> onError(it.error) - } + navigator.getResultFlow(PaymentDetailsResult.KEY)?.collect { + when (it) { + is PaymentDetailsResult.Success -> + loadPaymentDetails(selectedItem = it.itemId) + PaymentDetailsResult.Cancelled -> {} + is PaymentDetailsResult.Failure -> onError(it.error) } + } } } @@ -113,6 +116,10 @@ internal class WalletViewModel @Inject constructor( ) } + fun setExpanded(expanded: Boolean) { + _isExpanded.value = expanded + } + fun payAnotherWay() { navigator.dismiss() linkAccountManager.logout() @@ -147,7 +154,10 @@ internal class WalletViewModel @Inject constructor( _selectedItem.value = item } - private fun loadPaymentDetails(initialSetup: Boolean = false) { + private fun loadPaymentDetails( + initialSetup: Boolean = false, + selectedItem: String? = null + ) { setState(PrimaryButtonState.Processing) viewModelScope.launch { linkAccountManager.listPaymentDetails().fold( @@ -155,11 +165,15 @@ internal class WalletViewModel @Inject constructor( setState(PrimaryButtonState.Enabled) _paymentDetailsList.value = response.paymentDetails - _selectedItem.value = _selectedItem.value?.let { previouslySelectedItem -> - // If currently selected item is still available, keep it selected - response.paymentDetails.firstOrNull { it.id == previouslySelectedItem.id } + // Select selectedItem if provided, otherwise the previously selected item + _selectedItem.value = (selectedItem ?: _selectedItem.value?.id)?.let { itemId -> + response.paymentDetails.firstOrNull { it.id == itemId } } ?: getDefaultItemSelection(response.paymentDetails) + if (_selectedItem.value?.id == selectedItem) { + _isExpanded.value = false + } + if (initialSetup && args.prefilledCardParams != null) { // User has already pre-filled the payment details navigator.navigateTo( diff --git a/link/src/test/java/com/stripe/android/link/ui/paymentmethod/PaymentMethodViewModelTest.kt b/link/src/test/java/com/stripe/android/link/ui/paymentmethod/PaymentMethodViewModelTest.kt index a497c5a12ed..76171fdf4d1 100644 --- a/link/src/test/java/com/stripe/android/link/ui/paymentmethod/PaymentMethodViewModelTest.kt +++ b/link/src/test/java/com/stripe/android/link/ui/paymentmethod/PaymentMethodViewModelTest.kt @@ -7,9 +7,12 @@ import androidx.savedstate.SavedStateRegistryOwner import com.google.common.truth.Truth.assertThat import com.stripe.android.core.Logger import com.stripe.android.core.injection.Injectable +import com.stripe.android.financialconnections.launcher.FinancialConnectionsSheetLinkResult +import com.stripe.android.financialconnections.model.FinancialConnectionsAccount import com.stripe.android.link.LinkActivityContract import com.stripe.android.link.LinkActivityResult import com.stripe.android.link.LinkPaymentDetails +import com.stripe.android.link.LinkScreen import com.stripe.android.link.R import com.stripe.android.link.account.LinkAccountManager import com.stripe.android.link.confirmation.ConfirmationManager @@ -21,11 +24,15 @@ import com.stripe.android.link.model.PaymentDetailsFixtures import com.stripe.android.link.model.StripeIntentFixtures import com.stripe.android.link.ui.ErrorMessage import com.stripe.android.link.ui.PrimaryButtonState +import com.stripe.android.link.ui.wallet.PaymentDetailsResult import com.stripe.android.model.ConfirmStripeIntentParams +import com.stripe.android.model.ConsumerPaymentDetails +import com.stripe.android.model.FinancialConnectionsSession import com.stripe.android.model.PaymentMethodCreateParams import com.stripe.android.payments.paymentlauncher.PaymentResult import com.stripe.android.ui.core.FieldValuesToParamsMapConverter import com.stripe.android.ui.core.elements.IdentifierSpec +import com.stripe.android.ui.core.elements.LayoutSpec import com.stripe.android.ui.core.forms.FormFieldEntry import com.stripe.android.ui.core.injection.FormControllerSubcomponent import com.stripe.android.ui.core.injection.NonFallbackInjector @@ -45,6 +52,7 @@ import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.argWhere import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.spy import org.mockito.kotlin.verify @@ -90,8 +98,14 @@ class PaymentMethodViewModelTest { @Before fun before() { Dispatchers.setMain(UnconfinedTestDispatcher()) - linkAccountManager = mock() - whenever(args.stripeIntent).thenReturn(StripeIntentFixtures.PI_SUCCEEDED) + linkAccountManager = mock().apply { + whenever(consumerPublishableKey).thenReturn("consumerPublishableKey") + } + whenever(args.stripeIntent).thenReturn( + StripeIntentFixtures.PI_SUCCEEDED.copy( + linkFundingSources = listOf("card", "bank_account") + ) + ) } @After @@ -100,13 +114,20 @@ class PaymentMethodViewModelTest { } @Test - fun `startPayment creates PaymentDetails`() = runTest { + fun `onPaymentMethodSelected updates form`() { + val viewModel = createViewModel() + assertThat(viewModel.paymentMethod.value).isEqualTo(SupportedPaymentMethod.Card) + verify(formControllerSubcomponentBuilder).formSpec(eq(LayoutSpec(SupportedPaymentMethod.Card.formSpec))) + + viewModel.onPaymentMethodSelected(SupportedPaymentMethod.BankAccount) + assertThat(viewModel.paymentMethod.value).isEqualTo(SupportedPaymentMethod.BankAccount) + verify(formControllerSubcomponentBuilder).formSpec(eq(LayoutSpec(SupportedPaymentMethod.BankAccount.formSpec))) + } + + @Test + fun `startPayment for card creates PaymentDetails`() = runTest { whenever( - linkAccountManager.createCardPaymentDetails( - anyOrNull(), - anyOrNull(), - anyOrNull() - ) + linkAccountManager.createCardPaymentDetails(anyOrNull(), anyOrNull(), anyOrNull()) ).thenReturn(Result.success(createLinkPaymentDetails())) createViewModel().startPayment(cardFormFieldValues) @@ -138,15 +159,11 @@ class PaymentMethodViewModelTest { } @Test - fun `startPayment completes payment when PaymentDetails creation succeeds and completePayment is true`() = + fun `startPayment for card completes payment when PaymentDetails creation succeeds and completePayment is true`() = runTest { val value = createLinkPaymentDetails() whenever( - linkAccountManager.createCardPaymentDetails( - anyOrNull(), - anyOrNull(), - anyOrNull() - ) + linkAccountManager.createCardPaymentDetails(anyOrNull(), anyOrNull(), anyOrNull()) ).thenReturn(Result.success(value)) createViewModel().startPayment(cardFormFieldValues) @@ -183,13 +200,9 @@ class PaymentMethodViewModelTest { } @Test - fun `startPayment dismisses Link on success`() = runTest { + fun `startPayment for card dismisses Link on success`() = runTest { whenever( - linkAccountManager.createCardPaymentDetails( - anyOrNull(), - anyOrNull(), - anyOrNull() - ) + linkAccountManager.createCardPaymentDetails(anyOrNull(), anyOrNull(), anyOrNull()) ).thenReturn(Result.success(createLinkPaymentDetails())) var callback: PaymentConfirmationCallback? = null @@ -216,13 +229,9 @@ class PaymentMethodViewModelTest { } @Test - fun `startPayment starts processing`() = runTest { + fun `startPayment for card starts processing`() = runTest { whenever( - linkAccountManager.createCardPaymentDetails( - anyOrNull(), - anyOrNull(), - anyOrNull() - ) + linkAccountManager.createCardPaymentDetails(anyOrNull(), anyOrNull(), anyOrNull()) ).thenReturn(Result.success(createLinkPaymentDetails())) val viewModel = createViewModel() @@ -238,13 +247,9 @@ class PaymentMethodViewModelTest { } @Test - fun `startPayment stops processing on error`() = runTest { + fun `startPayment for card stops processing on error`() = runTest { whenever( - linkAccountManager.createCardPaymentDetails( - anyOrNull(), - anyOrNull(), - anyOrNull() - ) + linkAccountManager.createCardPaymentDetails(anyOrNull(), anyOrNull(), anyOrNull()) ).thenReturn(Result.success(createLinkPaymentDetails())) var callback: PaymentConfirmationCallback? = null @@ -276,11 +281,7 @@ class PaymentMethodViewModelTest { fun `when startPayment fails then an error message is shown`() = runTest { val errorMessage = "Error message" whenever( - linkAccountManager.createCardPaymentDetails( - anyOrNull(), - anyOrNull(), - anyOrNull() - ) + linkAccountManager.createCardPaymentDetails(anyOrNull(), anyOrNull(), anyOrNull()) ).thenReturn(Result.failure(RuntimeException(errorMessage))) val viewModel = createViewModel() @@ -290,6 +291,78 @@ class PaymentMethodViewModelTest { assertThat(viewModel.errorMessage.value).isEqualTo(ErrorMessage.Raw(errorMessage)) } + @Test + fun `startPayment for bank account creates FinancialConnectionsSession`() = runTest { + val clientSecret = "secret" + whenever(linkAccountManager.createFinancialConnectionsSession()).thenReturn( + Result.success(FinancialConnectionsSession(clientSecret, "id")) + ) + val viewModel = createViewModel() + viewModel.onPaymentMethodSelected(SupportedPaymentMethod.BankAccount) + viewModel.startPayment(emptyMap()) + + assertThat(viewModel.financialConnectionsSessionClientSecret.value).isEqualTo(clientSecret) + } + + @Test + fun `onFinancialConnectionsAccountLinked cancelled then state is reset`() = runTest { + val viewModel = createViewModel() + viewModel.onFinancialConnectionsAccountLinked( + FinancialConnectionsSheetLinkResult.Canceled + ) + + assertThat(viewModel.primaryButtonState.value).isEqualTo(PrimaryButtonState.Enabled) + } + + @Test + fun `onFinancialConnectionsAccountLinked error then shows error message`() = runTest { + val errorMessage = "error" + + val viewModel = createViewModel() + viewModel.onFinancialConnectionsAccountLinked( + FinancialConnectionsSheetLinkResult.Failed(Exception(errorMessage)) + ) + + assertThat(viewModel.errorMessage.value).isEqualTo(ErrorMessage.Raw(errorMessage)) + } + + @Test + fun `when account linked at root screen then it navigates to wallet`() = runTest { + val sessionId = "session_id" + val account = mock() + whenever(linkAccountManager.createBankAccountPaymentDetails(any())).thenReturn(Result.success(account)) + whenever(navigator.isOnRootScreen()).thenReturn(true) + + val viewModel = createViewModel() + viewModel.onFinancialConnectionsAccountLinked( + FinancialConnectionsSheetLinkResult.Completed(sessionId) + ) + + verify(navigator).navigateTo(LinkScreen.Wallet, true) + } + + @Test + fun `when account linked not at root screen then result is set`() = runTest { + val sessionId = "session_id" + val accountId = "account_id" + val account = mock().apply { + whenever(id).thenReturn(accountId) + } + whenever(linkAccountManager.createBankAccountPaymentDetails(any())).thenReturn(Result.success(account)) + whenever(navigator.isOnRootScreen()).thenReturn(false) + + val viewModel = createViewModel() + viewModel.onFinancialConnectionsAccountLinked( + FinancialConnectionsSheetLinkResult.Completed(sessionId) + ) + + verify(navigator).setResult( + eq(PaymentDetailsResult.KEY), + argWhere { it is PaymentDetailsResult.Success && it.itemId == accountId } + ) + verify(navigator).onBack(any()) + } + @Test fun `when loading from arguments then form is prefilled`() = runTest { whenever(args.prefilledCardParams).thenReturn(createLinkPaymentDetails().originalParams) @@ -407,6 +480,14 @@ class PaymentMethodViewModelTest { ) } + private fun createFinancialConnectionsAccount(id: String = "id") = FinancialConnectionsAccount( + created = 1, + id = id, + institutionName = "name", + livemode = false, + supportedPaymentMethodTypes = listOf() + ) + companion object { const val CLIENT_SECRET = "client_secret" } diff --git a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/PostalCodeConfig.kt b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/PostalCodeConfig.kt index 84c062ced9d..b365197b7ca 100644 --- a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/PostalCodeConfig.kt +++ b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/PostalCodeConfig.kt @@ -27,8 +27,9 @@ internal class PostalCodeConfig( override fun isValid(): Boolean { return when (format) { is CountryPostalFormat.Other -> input.isNotBlank() - else -> input.length in format.minimumLength..format.maximumLength && - input.matches(format.regexPattern) + else -> + input.length in format.minimumLength..format.maximumLength && + input.matches(format.regexPattern) } } @@ -63,11 +64,13 @@ internal class PostalCodeConfig( maximumLength = 6, regexPattern = Regex("[a-zA-Z]\\d[a-zA-Z][\\s-]?\\d[a-zA-Z]\\d") ) + object US : CountryPostalFormat( minimumLength = 5, maximumLength = 5, regexPattern = Regex("\\d+") ) + object Other : CountryPostalFormat( minimumLength = 1, maximumLength = Int.MAX_VALUE, diff --git a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/BaseAddPaymentMethodFragment.kt b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/BaseAddPaymentMethodFragment.kt index d989bf5d05b..653769a8085 100644 --- a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/BaseAddPaymentMethodFragment.kt +++ b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/BaseAddPaymentMethodFragment.kt @@ -219,6 +219,8 @@ internal abstract class BaseAddPaymentMethodFragment : Fragment() { private fun updateLinkInlineSignupVisibility(selectedPaymentMethod: SupportedPaymentMethod) { showLinkInlineSignup = sheetViewModel.isLinkEnabled.value == true && + sheetViewModel.stripeIntent.value + ?.linkFundingSources?.contains(PaymentMethod.Type.Card.code) ?: false && selectedPaymentMethod.code == PaymentMethod.Type.Card.code && sheetViewModel.linkLauncher.accountStatus.value == AccountStatus.SignedOut diff --git a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/PaymentSheetViewModel.kt b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/PaymentSheetViewModel.kt index 06663c62496..1e70d91f06c 100644 --- a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/PaymentSheetViewModel.kt +++ b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/PaymentSheetViewModel.kt @@ -29,6 +29,7 @@ import com.stripe.android.googlepaylauncher.injection.GooglePayPaymentMethodLaun import com.stripe.android.link.LinkActivityContract import com.stripe.android.link.LinkActivityResult import com.stripe.android.link.LinkPaymentDetails +import com.stripe.android.link.LinkPaymentLauncher import com.stripe.android.link.LinkPaymentLauncher.Companion.LINK_ENABLED import com.stripe.android.link.injection.LinkPaymentLauncherFactory import com.stripe.android.link.model.AccountStatus @@ -430,7 +431,9 @@ internal class PaymentSheetViewModel @Inject internal constructor( override fun setupLink(stripeIntent: StripeIntent) { if (LINK_ENABLED && - stripeIntent.paymentMethodTypes.contains(PaymentMethod.Type.Link.code) + stripeIntent.paymentMethodTypes.contains(PaymentMethod.Type.Link.code) && + stripeIntent.linkFundingSources.intersect(LinkPaymentLauncher.supportedFundingSources) + .isNotEmpty() ) { viewModelScope.launch { val accountStatus = linkLauncher.setup(stripeIntent, this)