From 7501a0eae6283e09f9a8b88219efea22800857fa Mon Sep 17 00:00:00 2001 From: fionnbarrett-stripe <137820346+fionnbarrett-stripe@users.noreply.github.com> Date: Thu, 31 Aug 2023 17:57:15 +0100 Subject: [PATCH] Adding new PaymentSheet FormElement for Blik payment method. (#7062) * Added pln currency support * BLIK Localization * Added BLIK to PaymentSheet * Discovery * UI Form * Add parameter isOptions to data class IdentifierSpec * Parse isOptions IdentifierSpec * Ignore isOptions in Params parser * passing PaymentMethodOptionsParams through layers * Add blik * formatting * set options to Parsed form * cleanup discovery commit * cleanup discovery commit test * undo accidental deletion * undo accidental comment formatting * use IdentifierSpec.Code to lookup fieldValuePairs entry * use enum RequestDestination to determine api parameter * rename Code to BlikCode * Move Enum to Top-Level * Remove redundant if branch * removed unused function overrides * Revert "BLIK Localization" This reverts commit 62cf223c718a802f6959a8193df6fa18ed908359. * run `./gradlew ktlint` * Formatting * Ran ./gradlew :stripe-ui-core:apiDump * Ran ./gradlew :stripe-ui-core:apiDump * Ran ./gradlew apiDump * Default null * use blik text * remove sectionFieldElement label * cleanup and pass parameters * remove unused import * remove unused parameter * restructure parameters * ran ./gradlew apiDump * Add data driven tests for BLIK * Fix data driven tests for BLIK * polling ctaText * using BLIK specific polling * Formatting * update test for multiple authenticators * fine tune validator * don't look for browser if polling * logic for PollingSucceedsAfterDelay * BLIK payment sheet test * Support PLN * Add Polling AuthorizeAction * Formatting and Grammar * Formatting * revert temp changes * Use polling specific logic when polling * Import success text * Make blik polling timeout 30 seconds * Specify blik instead of using automatic * remove waitPollingComplete * update logic to wait for polling to finish * update comment * format * revert temp debugging change * update changelog * remove redundant length check * make IdentifierSpec.BlikCode the default argument * remove comment * remove unused parameter * error instead of defaulting to UPI * remove comment * deduplicate declaration in if * validate argument of unregisterAuthenticator * mark Blik and BlikCode as RestrictTo.Scope.LIBRARY_GROUP * mark RequestDestination with RestrictTo.Scope.LIBRARY_GROUP * rename confirmPaymentMethodOptions to optionsParams * replace VALID_INPUT_RANGES with isDigit() * ktlint * fix MaximumLineLength exceeded * remove BLIK and RequestDestination * ktlint * rename RequestDestination to ApiParameterDestination * detekt * add @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) for transformToPaymentMethodOptionsParams and fix apiCheck * Fix BLIK PaymentSheet Changelog --- CHANGELOG.md | 3 + .../ConfirmStripeIntentParamsFactory.kt | 7 +- payments-ui-core/api/payments-ui-core.api | 16 +++ .../stripe_ic_paymentsheet_pm_blik.xml | 26 ++++ .../res/values/donottranslate.xml | 1 + payments-ui-core/src/main/assets/lpms.json | 9 ++ .../forms/PaymentMethodRequirements.kt | 6 + .../core/FieldValuesToParamsMapConverter.kt | 21 +++ .../android/ui/core/elements/BlikConfig.kt | 62 +++++++++ .../android/ui/core/elements/BlikElement.kt | 25 ++++ .../android/ui/core/elements/BlikSpec.kt | 20 +++ .../android/ui/core/elements/FormItemSpec.kt | 1 + .../ui/core/forms/TransformSpecToElements.kt | 2 + .../ui/core/forms/resources/LpmRepository.kt | 16 +++ .../ui/core/elements/BlikConfigTest.kt | 52 ++++++++ .../java/com/stripe/android/lpm/TestBlik.kt | 35 +++++ .../android/test/core/PlaygroundTestDriver.kt | 124 +++++++++++------- .../android/test/core/TestParameters.kt | 5 + .../stripe/android/test/core/ui/Selectors.kt | 3 + .../IntentConfirmationInterceptor.kt | 17 ++- .../IntentConfirmationInterceptorKtx.kt | 1 + .../extensions/StripePaymentLauncherKtx.kt | 7 + .../paymentsheet/model/PaymentSelection.kt | 14 +- .../ach/USBankAccountFormViewModel.kt | 1 + .../polling/PollingActivity.kt | 1 + .../polling/PollingAuthenticator.kt | 38 +++++- .../polling/PollingContract.kt | 2 + .../polling/PollingScreen.kt | 10 +- .../polling/PollingViewModel.kt | 5 +- .../paymentsheet/ui/AddPaymentMethod.kt | 21 ++- .../paymentsheet/PaymentSheetViewModelTest.kt | 13 +- .../polling/PollingActivityTest.kt | 3 + .../polling/PollingViewModelTest.kt | 2 + .../FakeIntentConfirmationInterceptor.kt | 2 + .../src/test/resources/blik-support.csv | 49 +++++++ stripe-ui-core/api/stripe-ui-core.api | 2 + .../android/uicore/elements/IdentifierSpec.kt | 15 ++- 37 files changed, 573 insertions(+), 64 deletions(-) create mode 100644 payments-ui-core/res/drawable/stripe_ic_paymentsheet_pm_blik.xml create mode 100644 payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/BlikConfig.kt create mode 100644 payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/BlikElement.kt create mode 100644 payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/BlikSpec.kt create mode 100644 payments-ui-core/src/test/java/com/stripe/android/ui/core/elements/BlikConfigTest.kt create mode 100644 paymentsheet-example/src/androidTest/java/com/stripe/android/lpm/TestBlik.kt create mode 100644 paymentsheet/src/test/resources/blik-support.csv diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e8c027abc8..cccecea0226 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## XX.XX.XX - 2023-XX-XX +### PaymentSheet +* [ADDED][7062](https://github.com/stripe/stripe-android/pull/7062) PaymentSheet now supports BLIK for PaymentIntents. + ## 20.29.0 - 2023-08-28 ### PaymentSheet diff --git a/payments-core/src/main/java/com/stripe/android/ConfirmStripeIntentParamsFactory.kt b/payments-core/src/main/java/com/stripe/android/ConfirmStripeIntentParamsFactory.kt index b821be692b7..3f70f20e045 100644 --- a/payments-core/src/main/java/com/stripe/android/ConfirmStripeIntentParamsFactory.kt +++ b/payments-core/src/main/java/com/stripe/android/ConfirmStripeIntentParamsFactory.kt @@ -21,6 +21,7 @@ sealed class ConfirmStripeIntentParamsFactory abstract fun create( createParams: PaymentMethodCreateParams, + optionsParams: PaymentMethodOptionsParams? = null, setupFutureUsage: ConfirmPaymentIntentParams.SetupFutureUsage? = null, ): T @@ -76,6 +77,7 @@ internal class ConfirmPaymentIntentParamsFactory( override fun create( createParams: PaymentMethodCreateParams, + optionsParams: PaymentMethodOptionsParams?, setupFutureUsage: ConfirmPaymentIntentParams.SetupFutureUsage?, ): ConfirmPaymentIntentParams { return ConfirmPaymentIntentParams.createWithPaymentMethodCreateParams( @@ -97,6 +99,9 @@ internal class ConfirmPaymentIntentParamsFactory( PaymentMethod.Type.USBankAccount.code -> { PaymentMethodOptionsParams.USBankAccount(setupFutureUsage = setupFutureUsage) } + PaymentMethod.Type.Blik.code -> { + optionsParams + } PaymentMethod.Type.Link.code -> { null } @@ -112,7 +117,6 @@ internal class ConfirmPaymentIntentParamsFactory( internal class ConfirmSetupIntentParamsFactory( private val clientSecret: String, ) : ConfirmStripeIntentParamsFactory() { - override fun create(paymentMethod: PaymentMethod): ConfirmSetupIntentParams { return ConfirmSetupIntentParams.create( paymentMethodId = paymentMethod.id.orEmpty(), @@ -125,6 +129,7 @@ internal class ConfirmSetupIntentParamsFactory( override fun create( createParams: PaymentMethodCreateParams, + optionsParams: PaymentMethodOptionsParams?, setupFutureUsage: ConfirmPaymentIntentParams.SetupFutureUsage?, ): ConfirmSetupIntentParams { return ConfirmSetupIntentParams.create( diff --git a/payments-ui-core/api/payments-ui-core.api b/payments-ui-core/api/payments-ui-core.api index 29b2d42de9f..b7ccf661246 100644 --- a/payments-ui-core/api/payments-ui-core.api +++ b/payments-ui-core/api/payments-ui-core.api @@ -100,6 +100,22 @@ public final class com/stripe/android/ui/core/elements/AuBankAccountNumberSpec$C public final class com/stripe/android/ui/core/elements/AuBecsDebitMandateElementUIKt { } +public final class com/stripe/android/ui/core/elements/BlikSpec$$serializer : kotlinx/serialization/internal/GeneratedSerializer { + public static final field $stable I + public static final field INSTANCE Lcom/stripe/android/ui/core/elements/BlikSpec$$serializer; + public fun childSerializers ()[Lkotlinx/serialization/KSerializer; + public fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lcom/stripe/android/ui/core/elements/BlikSpec; + public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object; + public fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor; + public fun serialize (Lkotlinx/serialization/encoding/Encoder;Lcom/stripe/android/ui/core/elements/BlikSpec;)V + public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V + public fun typeParametersSerializers ()[Lkotlinx/serialization/KSerializer; +} + +public final class com/stripe/android/ui/core/elements/BlikSpec$Companion { + public final fun serializer ()Lkotlinx/serialization/KSerializer; +} + public final class com/stripe/android/ui/core/elements/BsbElementUIKt { } diff --git a/payments-ui-core/res/drawable/stripe_ic_paymentsheet_pm_blik.xml b/payments-ui-core/res/drawable/stripe_ic_paymentsheet_pm_blik.xml new file mode 100644 index 00000000000..d410cbb2dc8 --- /dev/null +++ b/payments-ui-core/res/drawable/stripe_ic_paymentsheet_pm_blik.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/payments-ui-core/res/values/donottranslate.xml b/payments-ui-core/res/values/donottranslate.xml index c307b03b4c4..734d016ae8a 100644 --- a/payments-ui-core/res/values/donottranslate.xml +++ b/payments-ui-core/res/values/donottranslate.xml @@ -22,6 +22,7 @@ Amazon Pay MobilePay Zip + BLIK diff --git a/payments-ui-core/src/main/assets/lpms.json b/payments-ui-core/src/main/assets/lpms.json index edbabcc438f..7520efe3eeb 100644 --- a/payments-ui-core/src/main/assets/lpms.json +++ b/payments-ui-core/src/main/assets/lpms.json @@ -831,6 +831,15 @@ } ] }, + { + "type": "blik", + "async": false, + "fields": [ + { + "type": "blik" + } + ] + }, { "type": "us_bank_account", "async": true, diff --git a/payments-ui-core/src/main/java/com/stripe/android/paymentsheet/forms/PaymentMethodRequirements.kt b/payments-ui-core/src/main/java/com/stripe/android/paymentsheet/forms/PaymentMethodRequirements.kt index 0f838f19eac..4cd20d2eb02 100644 --- a/payments-ui-core/src/main/java/com/stripe/android/paymentsheet/forms/PaymentMethodRequirements.kt +++ b/payments-ui-core/src/main/java/com/stripe/android/paymentsheet/forms/PaymentMethodRequirements.kt @@ -320,6 +320,12 @@ internal val UpiRequirement = PaymentMethodRequirements( confirmPMFromCustomer = null ) +internal val BlikRequirement = PaymentMethodRequirements( + piRequirements = emptySet(), + siRequirements = null, + confirmPMFromCustomer = null +) + internal val CashAppPayRequirement = PaymentMethodRequirements( piRequirements = emptySet(), siRequirements = null, diff --git a/payments-ui-core/src/main/java/com/stripe/android/ui/core/FieldValuesToParamsMapConverter.kt b/payments-ui-core/src/main/java/com/stripe/android/ui/core/FieldValuesToParamsMapConverter.kt index 3dc8d41c2a1..fcca1d4367e 100644 --- a/payments-ui-core/src/main/java/com/stripe/android/ui/core/FieldValuesToParamsMapConverter.kt +++ b/payments-ui-core/src/main/java/com/stripe/android/ui/core/FieldValuesToParamsMapConverter.kt @@ -2,8 +2,10 @@ package com.stripe.android.ui.core import androidx.annotation.RestrictTo import androidx.annotation.VisibleForTesting +import com.stripe.android.model.PaymentMethod import com.stripe.android.model.PaymentMethodCode import com.stripe.android.model.PaymentMethodCreateParams +import com.stripe.android.model.PaymentMethodOptionsParams import com.stripe.android.uicore.elements.IdentifierSpec import com.stripe.android.uicore.forms.FormFieldEntry @@ -35,6 +37,25 @@ class FieldValuesToParamsMapConverter { ) } + /** + * This function will convert fieldValuePairs to PaymentMethodOptionsParams. + */ + @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) + fun transformToPaymentMethodOptionsParams( + fieldValuePairs: Map, + code: PaymentMethodCode, + ): PaymentMethodOptionsParams? { + if (code == PaymentMethod.Type.Blik.code) { + val blikCode = fieldValuePairs[IdentifierSpec.BlikCode]?.value + if (blikCode != null) { + return PaymentMethodOptionsParams.Blik( + blikCode + ) + } + } + return null + } + /** * This function will put the field values as defined in the fieldValuePairs into a map * according to their keys. diff --git a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/BlikConfig.kt b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/BlikConfig.kt new file mode 100644 index 00000000000..c11d288ea9e --- /dev/null +++ b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/BlikConfig.kt @@ -0,0 +1,62 @@ +package com.stripe.android.ui.core.elements + +import androidx.annotation.RestrictTo +import androidx.annotation.StringRes +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.VisualTransformation +import com.stripe.android.ui.core.R +import com.stripe.android.uicore.elements.TextFieldConfig +import com.stripe.android.uicore.elements.TextFieldIcon +import com.stripe.android.uicore.elements.TextFieldState +import com.stripe.android.uicore.elements.TextFieldStateConstants +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +private const val BLIK_MAX_LENGTH = 6 + +@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) +class BlikConfig : TextFieldConfig { + + private val blikPattern: Regex by lazy { + "^[0-9]{6}\$".toRegex() + } + + @StringRes + override val label: Int = R.string.stripe_blik_code + + override val capitalization: KeyboardCapitalization = KeyboardCapitalization.None + override val debugLabel: String = "blik_code" + override val keyboard: KeyboardType = KeyboardType.Number + override val visualTransformation: VisualTransformation? = null + override val trailingIcon: StateFlow = MutableStateFlow(value = null) + override val loading: StateFlow = MutableStateFlow(value = false) + + override fun determineState(input: String): TextFieldState { + val isValid = blikPattern.matches(input) + return if (input.isEmpty()) { + TextFieldStateConstants.Error.Blank + } else if (isValid) { + TextFieldStateConstants.Valid.Limitless + } else if (!input.all { it.isDigit() }) { + TextFieldStateConstants.Error.Invalid( + errorMessageResId = R.string.stripe_invalid_blik_code + ) + } else if (input.length < BLIK_MAX_LENGTH) { + TextFieldStateConstants.Error.Incomplete( + errorMessageResId = R.string.stripe_incomplete_blik_code + ) + } else { + TextFieldStateConstants.Error.Invalid( + errorMessageResId = R.string.stripe_invalid_blik_code + ) + } + } + + override fun filter(userTyped: String) = + userTyped.filter { it.isDigit() }.take(BLIK_MAX_LENGTH) + + override fun convertToRaw(displayName: String): String = displayName + + override fun convertFromRaw(rawValue: String): String = rawValue +} diff --git a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/BlikElement.kt b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/BlikElement.kt new file mode 100644 index 00000000000..78b0390c98a --- /dev/null +++ b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/BlikElement.kt @@ -0,0 +1,25 @@ +package com.stripe.android.ui.core.elements + +import androidx.annotation.RestrictTo +import com.stripe.android.uicore.elements.IdentifierSpec +import com.stripe.android.uicore.elements.InputController +import com.stripe.android.uicore.elements.SectionSingleFieldElement +import com.stripe.android.uicore.elements.SimpleTextFieldController +import com.stripe.android.uicore.forms.FormFieldEntry +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX) +class BlikElement( + override val identifier: IdentifierSpec = IdentifierSpec.BlikCode, + override val controller: InputController = SimpleTextFieldController( + textFieldConfig = BlikConfig() + ) +) : SectionSingleFieldElement(identifier = identifier) { + + override fun getFormFieldValueFlow(): Flow>> { + return controller.formFieldValue.map { entry -> + listOf(identifier to entry) + } + } +} diff --git a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/BlikSpec.kt b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/BlikSpec.kt new file mode 100644 index 00000000000..86a5d725a1f --- /dev/null +++ b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/BlikSpec.kt @@ -0,0 +1,20 @@ +package com.stripe.android.ui.core.elements + +import androidx.annotation.RestrictTo +import com.stripe.android.uicore.elements.IdentifierSpec +import com.stripe.android.uicore.elements.SectionElement +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX) +@Serializable +data class BlikSpec( + @SerialName("api_path") + override val apiPath: IdentifierSpec = IdentifierSpec.Blik +) : FormItemSpec() { + fun transform(): SectionElement { + return createSectionElement( + sectionFieldElement = BlikElement(), + ) + } +} diff --git a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/FormItemSpec.kt b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/FormItemSpec.kt index a1a0607f822..2581e10f0ed 100644 --- a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/FormItemSpec.kt +++ b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/FormItemSpec.kt @@ -56,6 +56,7 @@ object FormItemSpecSerializer : "card_details" -> CardDetailsSectionSpec.serializer() "card_billing" -> CardBillingSpec.serializer() "upi" -> UpiSpec.serializer() + "blik" -> BlikSpec.serializer() "placeholder" -> PlaceholderSpec.serializer() else -> EmptyFormSpec.serializer() } diff --git a/payments-ui-core/src/main/java/com/stripe/android/ui/core/forms/TransformSpecToElements.kt b/payments-ui-core/src/main/java/com/stripe/android/ui/core/forms/TransformSpecToElements.kt index 4f7cee597ac..bdf185bb5c0 100644 --- a/payments-ui-core/src/main/java/com/stripe/android/ui/core/forms/TransformSpecToElements.kt +++ b/payments-ui-core/src/main/java/com/stripe/android/ui/core/forms/TransformSpecToElements.kt @@ -8,6 +8,7 @@ import com.stripe.android.ui.core.elements.AffirmTextSpec import com.stripe.android.ui.core.elements.AfterpayClearpayTextSpec import com.stripe.android.ui.core.elements.AuBankAccountNumberSpec import com.stripe.android.ui.core.elements.AuBecsDebitMandateTextSpec +import com.stripe.android.ui.core.elements.BlikSpec import com.stripe.android.ui.core.elements.BsbSpec import com.stripe.android.ui.core.elements.CardBillingSpec import com.stripe.android.ui.core.elements.CardDetailsSectionSpec @@ -99,6 +100,7 @@ class TransformSpecToElements( ) is SepaMandateTextSpec -> it.transform(merchantName) is UpiSpec -> it.transform() + is BlikSpec -> it.transform() is ContactInformationSpec -> it.transform(initialValues) is PlaceholderSpec -> error("Placeholders should be processed before calling transform.") diff --git a/payments-ui-core/src/main/java/com/stripe/android/ui/core/forms/resources/LpmRepository.kt b/payments-ui-core/src/main/java/com/stripe/android/ui/core/forms/resources/LpmRepository.kt index ac3389e4d46..055be07fa33 100644 --- a/payments-ui-core/src/main/java/com/stripe/android/ui/core/forms/resources/LpmRepository.kt +++ b/payments-ui-core/src/main/java/com/stripe/android/ui/core/forms/resources/LpmRepository.kt @@ -19,6 +19,7 @@ import com.stripe.android.paymentsheet.forms.AfterpayClearpayRequirement import com.stripe.android.paymentsheet.forms.AmazonPayRequirement import com.stripe.android.paymentsheet.forms.AuBecsDebitRequirement import com.stripe.android.paymentsheet.forms.BancontactRequirement +import com.stripe.android.paymentsheet.forms.BlikRequirement import com.stripe.android.paymentsheet.forms.CardRequirement import com.stripe.android.paymentsheet.forms.CashAppPayRequirement import com.stripe.android.paymentsheet.forms.EpsRequirement @@ -94,6 +95,7 @@ class LpmRepository constructor( PaymentMethod.Type.Zip.code, PaymentMethod.Type.AuBecsDebit.code, PaymentMethod.Type.Upi.code, + PaymentMethod.Type.Blik.code, PaymentMethod.Type.CashAppPay.code, PaymentMethod.Type.GrabPay.code, PaymentMethod.Type.Fpx.code, @@ -507,6 +509,20 @@ class LpmRepository constructor( requirement = UpiRequirement, formSpec = LayoutSpec(sharedDataSpec.fields) ) + + PaymentMethod.Type.Blik.code -> SupportedPaymentMethod( + code = "blik", + requiresMandate = false, + mandateRequirement = MandateRequirement.Never, + displayNameResource = R.string.stripe_paymentsheet_payment_method_blik, + iconResource = R.drawable.stripe_ic_paymentsheet_pm_blik, + lightThemeIconUrl = sharedDataSpec.selectorIcon?.lightThemePng, + darkThemeIconUrl = sharedDataSpec.selectorIcon?.darkThemePng, + tintIconOnSelection = false, + requirement = BlikRequirement, + formSpec = LayoutSpec(sharedDataSpec.fields) + ) + PaymentMethod.Type.CashAppPay.code -> SupportedPaymentMethod( code = "cashapp", requiresMandate = false, diff --git a/payments-ui-core/src/test/java/com/stripe/android/ui/core/elements/BlikConfigTest.kt b/payments-ui-core/src/test/java/com/stripe/android/ui/core/elements/BlikConfigTest.kt new file mode 100644 index 00000000000..7644c1e2041 --- /dev/null +++ b/payments-ui-core/src/test/java/com/stripe/android/ui/core/elements/BlikConfigTest.kt @@ -0,0 +1,52 @@ +package com.stripe.android.ui.core.elements + +import com.google.common.truth.Truth.assertThat +import com.stripe.android.uicore.elements.TextFieldStateConstants.Error.Blank +import com.stripe.android.uicore.elements.TextFieldStateConstants.Error.Incomplete +import com.stripe.android.uicore.elements.TextFieldStateConstants.Error.Invalid +import com.stripe.android.uicore.elements.TextFieldStateConstants.Valid.Limitless +import org.junit.Test + +class BlikConfigTest { + private val config = BlikConfig() + + @Test + fun `Treats empty input as blank`() { + val state = config.determineState("") + assertThat(state).isEqualTo(Blank) + } + + @Test + fun `Rejects blank input`() { + assertThat(config.filter(" ")).isEqualTo("") + } + + @Test + fun `Rejects non-numeric input`() { + assertThat(config.filter(" ")).isEqualTo("") + } + + @Test + fun `Treats input with less than six digits as incomplete`() { + val state = config.determineState("12345") + assertThat(state).isInstanceOf(Incomplete::class.java) + } + + @Test + fun `Treats input more than six digits as invalid`() { + assertThat(config.determineState("1234567")).isInstanceOf(Invalid::class.java) + } + + @Test + fun `Treats non-numeric input as blank`() { + assertThat(config.determineState("12a456")).isInstanceOf(Invalid::class.java) + assertThat(config.determineState("abcdef")).isInstanceOf(Invalid::class.java) + assertThat(config.determineState("stripe.com")).isInstanceOf(Invalid::class.java) + } + + @Test + fun `Treats valid input as valid and limitless`() { + val state = config.determineState("123456") + assertThat(state).isInstanceOf(Limitless::class.java) + } +} diff --git a/paymentsheet-example/src/androidTest/java/com/stripe/android/lpm/TestBlik.kt b/paymentsheet-example/src/androidTest/java/com/stripe/android/lpm/TestBlik.kt new file mode 100644 index 00000000000..91c198bcf9a --- /dev/null +++ b/paymentsheet-example/src/androidTest/java/com/stripe/android/lpm/TestBlik.kt @@ -0,0 +1,35 @@ +package com.stripe.android.lpm + +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performTextInput +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.stripe.android.BaseLpmTest +import com.stripe.android.test.core.AuthorizeAction +import com.stripe.android.test.core.Currency +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +internal class TestBlik : BaseLpmTest() { + private val blik = newUser.copy( + paymentMethod = lpmRepository.fromCode("blik")!!, + currency = Currency.PLN, + merchantCountryCode = "FR", + authorizationAction = AuthorizeAction.PollingSucceedsAfterDelay, + supportedPaymentMethods = listOf("card", "blik"), + ) + + @Test + fun testBlik() { + testDriver.confirmNewOrGuestComplete( + testParameters = blik, + populateCustomLpmFields = { + rules.compose.onNodeWithText("BLIK code").apply { + performTextInput( + "123456" + ) + } + }, + ) + } +} diff --git a/paymentsheet-example/src/androidTest/java/com/stripe/android/test/core/PlaygroundTestDriver.kt b/paymentsheet-example/src/androidTest/java/com/stripe/android/test/core/PlaygroundTestDriver.kt index bb8dc326931..8fe8d2a315c 100644 --- a/paymentsheet-example/src/androidTest/java/com/stripe/android/test/core/PlaygroundTestDriver.kt +++ b/paymentsheet-example/src/androidTest/java/com/stripe/android/test/core/PlaygroundTestDriver.kt @@ -29,6 +29,8 @@ import com.stripe.android.test.core.ui.UiAutomatorText import org.junit.Assume import java.util.concurrent.Semaphore import java.util.concurrent.TimeUnit +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds /** * This drives the end to end payment sheet flow for any set of @@ -289,6 +291,28 @@ class PlaygroundTestDriver( composeTestRule.waitForIdle() } + /** + * Here we wait for PollingActivity to first come into view then wait for it to go away by checking if the Approve payment text is there + */ + private fun waitForPollingToFinish(timeout: Duration = 30.seconds) { + val className = "com.stripe.android.paymentsheet.paymentdatacollection.polling.PollingActivity" + while (currentActivity[0]?.componentName?.className != className) { + Thread.sleep(10) + } + + composeTestRule.waitUntil(timeoutMillis = timeout.inWholeMilliseconds) { + try { + composeTestRule + .onAllNodesWithText("Approve payment") + .fetchSemanticsNodes() + .isEmpty() + } catch (e: IllegalStateException) { + // PollingActivity was closed + true + } + } + } + private fun verifyDeviceSupportsTestAuthorization( authorizeAction: AuthorizeAction?, requestedBrowser: Browser? @@ -379,60 +403,70 @@ class PlaygroundTestDriver( private fun doAuthorization() { selectors.apply { - if (testParameters.authorizationAction != null && authorizeAction != null) { - // If a specific browser is requested we will use it, otherwise, we will - // select the first browser found - val selectedBrowser = getBrowser(BrowserUI.convert(testParameters.useBrowser)) - - // If there are multiple browser there is a browser selector window - selectBrowserPrompt.wait(4000) - if (selectBrowserPrompt.exists()) { - browserIconAtPrompt(selectedBrowser).click() - } + if (testParameters.authorizationAction != null) { + if (testParameters.authorizationAction != AuthorizeAction.PollingSucceedsAfterDelay) { + // If a specific browser is requested we will use it, otherwise, we will + // select the first browser found + val selectedBrowser = getBrowser(BrowserUI.convert(testParameters.useBrowser)) + + // If there are multiple browser there is a browser selector window + selectBrowserPrompt.wait(4000) + if (selectBrowserPrompt.exists()) { + browserIconAtPrompt(selectedBrowser).click() + } - assertThat(browserWindow(selectedBrowser)?.exists()).isTrue() + assertThat(browserWindow(selectedBrowser)?.exists()).isTrue() - blockUntilAuthorizationPageLoaded() + blockUntilAuthorizationPageLoaded() + } - if (authorizeAction.exists()) { - authorizeAction.click() - } else if (!authorizeAction.exists()) { - // Buttons aren't showing the same way each time in the web page. - object : UiAutomatorText( - label = requireNotNull(testParameters.authorizationAction).text, - className = "android.widget.TextView", - device = device - ) {}.click() - Log.e("Stripe", "Fail authorization was a text view not a button this time") + if (authorizeAction != null) { + if (authorizeAction.exists()) { + authorizeAction.click() + } else if (!authorizeAction.exists()) { + // Buttons aren't showing the same way each time in the web page. + object : UiAutomatorText( + label = requireNotNull(testParameters.authorizationAction).text, + className = "android.widget.TextView", + device = device + ) {}.click() + Log.e("Stripe", "Fail authorization was a text view not a button this time") + } } - when (val authAction = testParameters.authorizationAction) { - is AuthorizeAction.Authorize -> {} - is AuthorizeAction.Cancel -> { - buyButton.apply { - waitProcessingComplete() - isEnabled() - isDisplayed() + when (val authAction = testParameters.authorizationAction) { + is AuthorizeAction.Authorize -> {} + is AuthorizeAction.PollingSucceedsAfterDelay -> { + waitForPollingToFinish() } - } - is AuthorizeAction.Fail -> { - buyButton.apply { - waitProcessingComplete() - isEnabled() - isDisplayed() + + is AuthorizeAction.Cancel -> { + buyButton.apply { + waitProcessingComplete() + isEnabled() + isDisplayed() + } } - // The text comes after the buy button animation is complete - composeTestRule.waitUntil { - runCatching { - composeTestRule - .onNodeWithText(authAction.expectedError) - .assertIsDisplayed() - }.isSuccess + is AuthorizeAction.Fail -> { + buyButton.apply { + waitProcessingComplete() + isEnabled() + isDisplayed() + } + + // The text comes after the buy button animation is complete + composeTestRule.waitUntil { + runCatching { + composeTestRule + .onNodeWithText(authAction.expectedError) + .assertIsDisplayed() + }.isSuccess + } } + + null -> {} } - null -> {} - } } else { // Make sure there is no prompt and no browser window open assertThat(selectBrowserPrompt.exists()).isFalse() @@ -442,7 +476,7 @@ class PlaygroundTestDriver( } } - val isDone = testParameters.authorizationAction in setOf(AuthorizeAction.Authorize, null) + val isDone = testParameters.authorizationAction in setOf(AuthorizeAction.Authorize, AuthorizeAction.PollingSucceedsAfterDelay, null) if (isDone) { waitForPlaygroundActivity() diff --git a/paymentsheet-example/src/androidTest/java/com/stripe/android/test/core/TestParameters.kt b/paymentsheet-example/src/androidTest/java/com/stripe/android/test/core/TestParameters.kt index 3f37d4fb259..0c32f803d92 100644 --- a/paymentsheet-example/src/androidTest/java/com/stripe/android/test/core/TestParameters.kt +++ b/paymentsheet-example/src/androidTest/java/com/stripe/android/test/core/TestParameters.kt @@ -88,6 +88,10 @@ sealed interface AuthorizeAction { abstract val text: String + object PollingSucceedsAfterDelay : AuthorizeAction { + override val text: String = "POLLING SUCCEEDS AFTER DELAY" + } + object Authorize : AuthorizeAction { override val text: String = "AUTHORIZE TEST PAYMENT" } @@ -121,6 +125,7 @@ enum class Currency { GBP, INR, SGD, + PLN, MYR, } diff --git a/paymentsheet-example/src/androidTest/java/com/stripe/android/test/core/ui/Selectors.kt b/paymentsheet-example/src/androidTest/java/com/stripe/android/test/core/ui/Selectors.kt index 7f79aef0b1c..0728a9e213c 100644 --- a/paymentsheet-example/src/androidTest/java/com/stripe/android/test/core/ui/Selectors.kt +++ b/paymentsheet-example/src/androidTest/java/com/stripe/android/test/core/ui/Selectors.kt @@ -16,6 +16,7 @@ import com.google.common.truth.Truth.assertThat import com.stripe.android.core.model.CountryCode import com.stripe.android.core.model.CountryUtils import com.stripe.android.model.PaymentMethod.Type.CashAppPay +import com.stripe.android.model.PaymentMethod.Type.Blik import com.stripe.android.paymentsheet.PaymentSheet import com.stripe.android.paymentsheet.example.R import com.stripe.android.paymentsheet.example.playground.model.InitializationType @@ -138,6 +139,8 @@ class Selectors( // We're using a longer timeout for Cash App Pay until we fix an issue where we // needlessly poll after a canceled payment attempt. 15.seconds + } else if (testParameters.paymentMethod.code == Blik.code) { + 30.seconds } else { 5.seconds } diff --git a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/IntentConfirmationInterceptor.kt b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/IntentConfirmationInterceptor.kt index 5b43d4deff8..513371e0416 100644 --- a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/IntentConfirmationInterceptor.kt +++ b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/IntentConfirmationInterceptor.kt @@ -10,6 +10,7 @@ import com.stripe.android.model.ConfirmPaymentIntentParams.SetupFutureUsage.OffS import com.stripe.android.model.ConfirmStripeIntentParams import com.stripe.android.model.PaymentMethod import com.stripe.android.model.PaymentMethodCreateParams +import com.stripe.android.model.PaymentMethodOptionsParams import com.stripe.android.model.StripeIntent import com.stripe.android.networking.StripeRepository import com.stripe.android.paymentsheet.IntentConfirmationInterceptor.NextStep @@ -61,6 +62,7 @@ internal interface IntentConfirmationInterceptor { suspend fun intercept( initializationMode: PaymentSheet.InitializationMode, paymentMethodCreateParams: PaymentMethodCreateParams, + paymentMethodOptionsParams: PaymentMethodOptionsParams? = null, shippingValues: ConfirmPaymentIntentParams.Shipping?, setupForFutureUsage: ConfirmPaymentIntentParams.SetupFutureUsage?, ): NextStep @@ -105,6 +107,7 @@ internal class DefaultIntentConfirmationInterceptor @Inject constructor( override suspend fun intercept( initializationMode: PaymentSheet.InitializationMode, paymentMethodCreateParams: PaymentMethodCreateParams, + paymentMethodOptionsParams: PaymentMethodOptionsParams?, shippingValues: ConfirmPaymentIntentParams.Shipping?, setupForFutureUsage: ConfirmPaymentIntentParams.SetupFutureUsage?, ): NextStep { @@ -117,11 +120,13 @@ internal class DefaultIntentConfirmationInterceptor @Inject constructor( setupForFutureUsage = setupForFutureUsage, ) } + is PaymentSheet.InitializationMode.PaymentIntent -> { createConfirmStep( clientSecret = initializationMode.clientSecret, shippingValues = shippingValues, paymentMethodCreateParams = paymentMethodCreateParams, + paymentMethodOptionsParams = paymentMethodOptionsParams, setupForFutureUsage = setupForFutureUsage, ) } @@ -152,6 +157,7 @@ internal class DefaultIntentConfirmationInterceptor @Inject constructor( setupForFutureUsage = setupForFutureUsage, ) } + is PaymentSheet.InitializationMode.PaymentIntent -> { createConfirmStep( clientSecret = initializationMode.clientSecret, @@ -160,6 +166,7 @@ internal class DefaultIntentConfirmationInterceptor @Inject constructor( isDeferred = false, ) } + is PaymentSheet.InitializationMode.SetupIntent -> { createConfirmStep( clientSecret = initializationMode.clientSecret, @@ -222,6 +229,7 @@ internal class DefaultIntentConfirmationInterceptor @Inject constructor( shippingValues = shippingValues, ) } + else -> { error( "${CreateIntentCallback::class.java.simpleName} must be implemented " + @@ -265,6 +273,7 @@ internal class DefaultIntentConfirmationInterceptor @Inject constructor( ) } } + is CreateIntentResult.Failure -> { NextStep.Fail( cause = result.cause, @@ -326,13 +335,19 @@ internal class DefaultIntentConfirmationInterceptor @Inject constructor( clientSecret: String, shippingValues: ConfirmPaymentIntentParams.Shipping?, paymentMethodCreateParams: PaymentMethodCreateParams, + paymentMethodOptionsParams: PaymentMethodOptionsParams? = null, setupForFutureUsage: ConfirmPaymentIntentParams.SetupFutureUsage?, ): NextStep.Confirm { val paramsFactory = ConfirmStripeIntentParamsFactory.createFactory( clientSecret = clientSecret, shipping = shippingValues, ) - val confirmParams = paramsFactory.create(paymentMethodCreateParams, setupForFutureUsage) + val confirmParams = paramsFactory.create( + paymentMethodCreateParams, + paymentMethodOptionsParams, + setupForFutureUsage + ) + return NextStep.Confirm( confirmParams = confirmParams, isDeferred = false, diff --git a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/IntentConfirmationInterceptorKtx.kt b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/IntentConfirmationInterceptorKtx.kt index 4f0212eeb92..b67685b4016 100644 --- a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/IntentConfirmationInterceptorKtx.kt +++ b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/IntentConfirmationInterceptorKtx.kt @@ -20,6 +20,7 @@ internal suspend fun IntentConfirmationInterceptor.intercept( intercept( initializationMode = initializationMode, + paymentMethodOptionsParams = paymentSelection.paymentMethodOptionsParams, paymentMethodCreateParams = paymentSelection.paymentMethodCreateParams, shippingValues = shippingValues, setupForFutureUsage = setupFutureUsage, diff --git a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/extensions/StripePaymentLauncherKtx.kt b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/extensions/StripePaymentLauncherKtx.kt index 086fd266d4d..738a251444a 100644 --- a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/extensions/StripePaymentLauncherKtx.kt +++ b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/extensions/StripePaymentLauncherKtx.kt @@ -9,10 +9,17 @@ internal fun StripePaymentLauncher.registerPollingAuthenticator() { key = StripeIntent.NextActionData.UpiAwaitNotification::class.java, authenticator = PollingAuthenticator(), ) + authenticatorRegistry.registerAuthenticator( + key = StripeIntent.NextActionData.BlikAuthorize::class.java, + authenticator = PollingAuthenticator(), + ) } internal fun StripePaymentLauncher.unregisterPollingAuthenticator() { authenticatorRegistry.unregisterAuthenticator( key = StripeIntent.NextActionData.UpiAwaitNotification::class.java, ) + authenticatorRegistry.unregisterAuthenticator( + key = StripeIntent.NextActionData.BlikAuthorize::class.java, + ) } diff --git a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/model/PaymentSelection.kt b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/model/PaymentSelection.kt index 2443c18d65c..615386537ee 100644 --- a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/model/PaymentSelection.kt +++ b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/model/PaymentSelection.kt @@ -10,6 +10,7 @@ import com.stripe.android.model.ConsumerPaymentDetails import com.stripe.android.model.PaymentMethod import com.stripe.android.model.PaymentMethod.Type.USBankAccount import com.stripe.android.model.PaymentMethodCreateParams +import com.stripe.android.model.PaymentMethodOptionsParams import com.stripe.android.paymentsheet.R import com.stripe.android.paymentsheet.paymentdatacollection.ach.ACHText import com.stripe.android.paymentsheet.paymentdatacollection.ach.USBankAccountFormScreenState @@ -91,6 +92,7 @@ internal sealed class PaymentSelection : Parcelable { sealed class New : PaymentSelection() { abstract val paymentMethodCreateParams: PaymentMethodCreateParams + abstract val paymentMethodOptionsParams: PaymentMethodOptionsParams? abstract val customerRequestedSave: CustomerRequestedSave override val requiresConfirmation: Boolean @@ -108,7 +110,8 @@ internal sealed class PaymentSelection : Parcelable { data class Card( override val paymentMethodCreateParams: PaymentMethodCreateParams, val brand: CardBrand, - override val customerRequestedSave: CustomerRequestedSave + override val customerRequestedSave: CustomerRequestedSave, + override val paymentMethodOptionsParams: PaymentMethodOptionsParams? = null, ) : New() { @IgnoredOnParcel val last4: String = ( @@ -125,7 +128,8 @@ internal sealed class PaymentSelection : Parcelable { val input: Input, val screenState: USBankAccountFormScreenState, override val paymentMethodCreateParams: PaymentMethodCreateParams, - override val customerRequestedSave: CustomerRequestedSave + override val customerRequestedSave: CustomerRequestedSave, + override val paymentMethodOptionsParams: PaymentMethodOptionsParams? = null, ) : New() { @Parcelize @@ -149,6 +153,9 @@ internal sealed class PaymentSelection : Parcelable { @IgnoredOnParcel override val paymentMethodCreateParams = linkPaymentDetails.paymentMethodCreateParams + @IgnoredOnParcel + override val paymentMethodOptionsParams = null + @IgnoredOnParcel @DrawableRes val iconResource = R.drawable.stripe_ic_paymentsheet_link @@ -169,7 +176,8 @@ internal sealed class PaymentSelection : Parcelable { val lightThemeIconUrl: String?, val darkThemeIconUrl: String?, override val paymentMethodCreateParams: PaymentMethodCreateParams, - override val customerRequestedSave: CustomerRequestedSave + override val customerRequestedSave: CustomerRequestedSave, + override val paymentMethodOptionsParams: PaymentMethodOptionsParams? = null, ) : New() } } diff --git a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/paymentdatacollection/ach/USBankAccountFormViewModel.kt b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/paymentdatacollection/ach/USBankAccountFormViewModel.kt index 4e6aed12cdb..d6cc6097919 100644 --- a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/paymentdatacollection/ach/USBankAccountFormViewModel.kt +++ b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/paymentdatacollection/ach/USBankAccountFormViewModel.kt @@ -467,6 +467,7 @@ internal class USBankAccountFormViewModel @Inject internal constructor( address = address.value, ) ), + paymentMethodOptionsParams = null, customerRequestedSave = if (args.formArgs.showCheckbox) { if (saveForFutureUse.value) { PaymentSelection.CustomerRequestedSave.RequestReuse diff --git a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/paymentdatacollection/polling/PollingActivity.kt b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/paymentdatacollection/polling/PollingActivity.kt index 61cfe0db9c9..c275194c5fe 100644 --- a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/paymentdatacollection/polling/PollingActivity.kt +++ b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/paymentdatacollection/polling/PollingActivity.kt @@ -32,6 +32,7 @@ internal class PollingActivity : AppCompatActivity() { timeLimit = args.timeLimitInSeconds.seconds, initialDelay = args.initialDelayInSeconds.seconds, maxAttempts = args.maxAttempts, + ctaText = args.ctaText, ) } diff --git a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/paymentdatacollection/polling/PollingAuthenticator.kt b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/paymentdatacollection/polling/PollingAuthenticator.kt index 793c74ebb12..bcc7b6a7967 100644 --- a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/paymentdatacollection/polling/PollingAuthenticator.kt +++ b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/paymentdatacollection/polling/PollingAuthenticator.kt @@ -5,9 +5,11 @@ import androidx.activity.result.ActivityResultCaller import androidx.activity.result.ActivityResultLauncher import androidx.core.app.ActivityOptionsCompat import com.stripe.android.core.networking.ApiRequest +import com.stripe.android.model.PaymentMethod import com.stripe.android.model.StripeIntent import com.stripe.android.payments.PaymentFlowResult import com.stripe.android.payments.core.authentication.PaymentAuthenticator +import com.stripe.android.paymentsheet.R import com.stripe.android.utils.AnimationConstants import com.stripe.android.view.AuthActivityStarterHost import javax.inject.Singleton @@ -15,6 +17,9 @@ import javax.inject.Singleton private const val UPI_TIME_LIMIT_IN_SECONDS = 5 * 60 private const val UPI_INITIAL_DELAY_IN_SECONDS = 5 private const val UPI_MAX_ATTEMPTS = 12 +private const val BLIK_TIME_LIMIT_IN_SECONDS = 60 +private const val BLIK_INITIAL_DELAY_IN_SECONDS = 5 +private const val BLIK_MAX_ATTEMPTS = 12 @Singleton internal class PollingAuthenticator : PaymentAuthenticator() { @@ -26,13 +31,32 @@ internal class PollingAuthenticator : PaymentAuthenticator() { authenticatable: StripeIntent, requestOptions: ApiRequest.Options ) { - val args = PollingContract.Args( - clientSecret = requireNotNull(authenticatable.clientSecret), - statusBarColor = host.statusBarColor, - timeLimitInSeconds = UPI_TIME_LIMIT_IN_SECONDS, - initialDelayInSeconds = UPI_INITIAL_DELAY_IN_SECONDS, - maxAttempts = UPI_MAX_ATTEMPTS, - ) + val args = when (authenticatable.paymentMethod?.type) { + PaymentMethod.Type.Upi -> + PollingContract.Args( + clientSecret = requireNotNull(authenticatable.clientSecret), + statusBarColor = host.statusBarColor, + timeLimitInSeconds = UPI_TIME_LIMIT_IN_SECONDS, + initialDelayInSeconds = UPI_INITIAL_DELAY_IN_SECONDS, + maxAttempts = UPI_MAX_ATTEMPTS, + ctaText = R.string.stripe_upi_polling_message, + ) + PaymentMethod.Type.Blik -> + PollingContract.Args( + clientSecret = requireNotNull(authenticatable.clientSecret), + statusBarColor = host.statusBarColor, + timeLimitInSeconds = BLIK_TIME_LIMIT_IN_SECONDS, + initialDelayInSeconds = BLIK_INITIAL_DELAY_IN_SECONDS, + maxAttempts = BLIK_MAX_ATTEMPTS, + ctaText = R.string.stripe_blik_confirm_payment, + ) + else -> + error( + "Received invalid payment method type " + + "${authenticatable.paymentMethod?.type?.code} " + + "in PollingAuthenticator" + ) + } val options = ActivityOptionsCompat.makeCustomAnimation( host.application.applicationContext, diff --git a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/paymentdatacollection/polling/PollingContract.kt b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/paymentdatacollection/polling/PollingContract.kt index 89574656124..37f0cb2da46 100644 --- a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/paymentdatacollection/polling/PollingContract.kt +++ b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/paymentdatacollection/polling/PollingContract.kt @@ -5,6 +5,7 @@ import android.content.Intent import android.os.Parcelable import androidx.activity.result.contract.ActivityResultContract import androidx.annotation.ColorInt +import androidx.annotation.StringRes import androidx.core.os.bundleOf import com.stripe.android.payments.PaymentFlowResult import kotlinx.parcelize.Parcelize @@ -30,6 +31,7 @@ internal class PollingContract : val timeLimitInSeconds: Int, val initialDelayInSeconds: Int, val maxAttempts: Int, + @StringRes val ctaText: Int, ) : Parcelable { internal companion object { diff --git a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/paymentdatacollection/polling/PollingScreen.kt b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/paymentdatacollection/polling/PollingScreen.kt index 4b82f6dbb62..5763530579e 100644 --- a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/paymentdatacollection/polling/PollingScreen.kt +++ b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/paymentdatacollection/polling/PollingScreen.kt @@ -1,5 +1,6 @@ package com.stripe.android.paymentsheet.paymentdatacollection.polling +import androidx.annotation.StringRes import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -84,6 +85,7 @@ private fun PollingScreen( remainingDuration = uiState.durationRemaining, onCancel = onCancel, modifier = modifier, + ctaText = uiState.ctaText, ) } PollingState.Failed -> { @@ -100,6 +102,7 @@ private fun ActivePolling( remainingDuration: Duration, onCancel: () -> Unit, modifier: Modifier = Modifier, + @StringRes ctaText: Int, ) { Column( horizontalAlignment = Alignment.CenterHorizontally, @@ -124,7 +127,7 @@ private fun ActivePolling( ) Text( - text = rememberActivePollingMessage(remainingDuration), + text = rememberActivePollingMessage(remainingDuration, ctaText), textAlign = TextAlign.Center, lineHeight = MaterialTheme.typography.body1.fontSize * Spacing.lineHeightMultiplier, modifier = Modifier.padding(bottom = Spacing.normal), @@ -203,6 +206,7 @@ private fun FailedPolling( @Composable private fun rememberActivePollingMessage( remainingDuration: Duration, + @StringRes ctaText: Int ): String { val context = LocalContext.current @@ -211,7 +215,7 @@ private fun rememberActivePollingMessage( val paddedSeconds = seconds.toString().padStart(length = 2, padChar = '0') "$minutes:$paddedSeconds" } - context.getString(R.string.stripe_upi_polling_message, remainingTime) + context.getString(ctaText, remainingTime) } } @@ -237,6 +241,7 @@ private fun ActivePollingScreenPreview() { PollingScreen( uiState = PollingUiState( durationRemaining = 83.seconds, + ctaText = R.string.stripe_upi_polling_message, pollingState = PollingState.Active, ), onCancel = {}, @@ -253,6 +258,7 @@ private fun FailedPollingScreenPreview() { PollingScreen( uiState = PollingUiState( durationRemaining = 83.seconds, + ctaText = R.string.stripe_upi_polling_message, pollingState = PollingState.Failed, ), onCancel = {}, diff --git a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/paymentdatacollection/polling/PollingViewModel.kt b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/paymentdatacollection/polling/PollingViewModel.kt index 4953de6639e..3aac39d5f9a 100644 --- a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/paymentdatacollection/polling/PollingViewModel.kt +++ b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/paymentdatacollection/polling/PollingViewModel.kt @@ -1,6 +1,7 @@ package com.stripe.android.paymentsheet.paymentdatacollection.polling import android.os.SystemClock +import androidx.annotation.StringRes import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider @@ -76,6 +77,7 @@ internal fun PollingState.toFlowResult( internal data class PollingUiState( val durationRemaining: Duration, + @StringRes val ctaText: Int, val pollingState: PollingState = PollingState.Active, ) @@ -87,7 +89,7 @@ internal class PollingViewModel @Inject constructor( private val savedStateHandle: SavedStateHandle, ) : ViewModel() { - private val _uiState = MutableStateFlow(PollingUiState(durationRemaining = args.timeLimit)) + private val _uiState = MutableStateFlow(PollingUiState(durationRemaining = args.timeLimit, ctaText = args.ctaText)) val uiState: StateFlow = _uiState init { @@ -226,6 +228,7 @@ internal class PollingViewModel @Inject constructor( val timeLimit: Duration, val initialDelay: Duration, val maxAttempts: Int, + @StringRes val ctaText: Int, ) } diff --git a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/ui/AddPaymentMethod.kt b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/ui/AddPaymentMethod.kt index 6b61a943480..21dd9a7f3bb 100644 --- a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/ui/AddPaymentMethod.kt +++ b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/ui/AddPaymentMethod.kt @@ -20,11 +20,13 @@ import com.stripe.android.model.CardBrand import com.stripe.android.model.PaymentMethod import com.stripe.android.model.PaymentMethodCode import com.stripe.android.model.PaymentMethodCreateParams +import com.stripe.android.model.PaymentMethodOptionsParams import com.stripe.android.paymentsheet.forms.FormFieldValues import com.stripe.android.paymentsheet.model.PaymentSelection import com.stripe.android.paymentsheet.viewmodels.BaseSheetViewModel import com.stripe.android.ui.core.FieldValuesToParamsMapConverter import com.stripe.android.ui.core.forms.resources.LpmRepository +import com.stripe.android.uicore.elements.ApiParameterDestination import com.stripe.android.uicore.elements.IdentifierSpec import com.stripe.android.uicore.elements.LocalAutofillEventReporter import kotlinx.coroutines.flow.MutableStateFlow @@ -147,7 +149,9 @@ internal fun FormFieldValues.transformToPaymentMethodCreateParams( paymentMethod: LpmRepository.SupportedPaymentMethod ): PaymentMethodCreateParams { return FieldValuesToParamsMapConverter.transformToPaymentMethodCreateParams( - fieldValuePairs = fieldValuePairs.filterNot { entry -> + fieldValuePairs = fieldValuePairs.filter { entry -> + entry.key.apiParameterDestination == ApiParameterDestination.Params + }.filterNot { entry -> entry.key == IdentifierSpec.SaveForFutureUse || entry.key == IdentifierSpec.CardBrand }, code = paymentMethod.code, @@ -155,14 +159,26 @@ internal fun FormFieldValues.transformToPaymentMethodCreateParams( ) } +internal fun FormFieldValues.transformToPaymentMethodOptionsParams( + paymentMethod: LpmRepository.SupportedPaymentMethod +): PaymentMethodOptionsParams? { + return FieldValuesToParamsMapConverter.transformToPaymentMethodOptionsParams( + fieldValuePairs = fieldValuePairs.filter { entry -> + entry.key.apiParameterDestination == ApiParameterDestination.Options + }, + code = paymentMethod.code, + ) +} + internal fun FormFieldValues.transformToPaymentSelection( resources: Resources, paymentMethod: LpmRepository.SupportedPaymentMethod ): PaymentSelection.New { val params = transformToPaymentMethodCreateParams(paymentMethod) - + val options = transformToPaymentMethodOptionsParams(paymentMethod) return if (paymentMethod.code == PaymentMethod.Type.Card.code) { PaymentSelection.New.Card( + paymentMethodOptionsParams = options, paymentMethodCreateParams = params, brand = CardBrand.fromCode(fieldValuePairs[IdentifierSpec.CardBrand]?.value), customerRequestedSave = userRequestedReuse, @@ -174,6 +190,7 @@ internal fun FormFieldValues.transformToPaymentSelection( lightThemeIconUrl = paymentMethod.lightThemeIconUrl, darkThemeIconUrl = paymentMethod.darkThemeIconUrl, paymentMethodCreateParams = params, + paymentMethodOptionsParams = options, customerRequestedSave = userRequestedReuse, ) } diff --git a/paymentsheet/src/test/java/com/stripe/android/paymentsheet/PaymentSheetViewModelTest.kt b/paymentsheet/src/test/java/com/stripe/android/paymentsheet/PaymentSheetViewModelTest.kt index b05c2bfb623..fa0e7f0e3a0 100644 --- a/paymentsheet/src/test/java/com/stripe/android/paymentsheet/PaymentSheetViewModelTest.kt +++ b/paymentsheet/src/test/java/com/stripe/android/paymentsheet/PaymentSheetViewModelTest.kt @@ -32,6 +32,7 @@ import com.stripe.android.model.PaymentMethodFixtures.SEPA_DEBIT_PAYMENT_METHOD import com.stripe.android.model.PaymentMethodOptionsParams import com.stripe.android.model.SetupIntentFixtures import com.stripe.android.model.StripeIntent +import com.stripe.android.model.StripeIntent.NextActionData import com.stripe.android.payments.paymentlauncher.PaymentResult import com.stripe.android.payments.paymentlauncher.StripePaymentLauncher import com.stripe.android.payments.paymentlauncher.StripePaymentLauncherAssistedFactory @@ -70,12 +71,14 @@ import org.junit.runner.RunWith import org.mockito.MockitoAnnotations import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.doReturn import org.mockito.kotlin.eq import org.mockito.kotlin.isNull import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.spy +import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.verifyNoMoreInteractions import org.robolectric.RobolectricTestRunner @@ -83,6 +86,7 @@ import java.io.IOException import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test +import kotlin.test.assertEquals import kotlin.time.Duration @RunWith(RobolectricTestRunner::class) @@ -1356,7 +1360,14 @@ internal class PaymentSheetViewModelTest { viewModel.registerFromActivity(DummyActivityResultCaller(), lifecycleOwner) lifecycleOwner.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY) - verify(paymentLauncher.authenticatorRegistry).unregisterAuthenticator(any()) + val argumentCaptor = argumentCaptor>() + + // unregisterAuthenticator should be called for each call to registerAuthenticator. + verify(paymentLauncher.authenticatorRegistry, times(2)).unregisterAuthenticator(argumentCaptor.capture()) + + val capturedArguments = argumentCaptor.allValues + assertEquals(NextActionData.UpiAwaitNotification::class.java, capturedArguments[0]) + assertEquals(NextActionData.BlikAuthorize::class.java, capturedArguments[1]) } @Test diff --git a/paymentsheet/src/test/java/com/stripe/android/paymentsheet/paymentdatacollection/polling/PollingActivityTest.kt b/paymentsheet/src/test/java/com/stripe/android/paymentsheet/paymentdatacollection/polling/PollingActivityTest.kt index 9da4cb70a67..6465a7797a4 100644 --- a/paymentsheet/src/test/java/com/stripe/android/paymentsheet/paymentdatacollection/polling/PollingActivityTest.kt +++ b/paymentsheet/src/test/java/com/stripe/android/paymentsheet/paymentdatacollection/polling/PollingActivityTest.kt @@ -16,6 +16,7 @@ import com.google.common.truth.Truth.assertThat import com.stripe.android.StripeIntentResult import com.stripe.android.model.StripeIntent import com.stripe.android.payments.PaymentFlowResult +import com.stripe.android.paymentsheet.R import com.stripe.android.polling.IntentStatusPoller import com.stripe.android.utils.InjectableActivityScenario import com.stripe.android.utils.TestUtils @@ -156,6 +157,7 @@ internal class PollingActivityTest { args.timeLimitInSeconds.seconds, args.initialDelayInSeconds.seconds, args.maxAttempts, + args.ctaText, ), poller = poller, timeProvider = timeProvider, @@ -197,6 +199,7 @@ internal class PollingActivityTest { timeLimitInSeconds = 60, maxAttempts = 3, statusBarColor = null, + ctaText = R.string.stripe_upi_polling_message, ) } } diff --git a/paymentsheet/src/test/java/com/stripe/android/paymentsheet/paymentdatacollection/polling/PollingViewModelTest.kt b/paymentsheet/src/test/java/com/stripe/android/paymentsheet/paymentdatacollection/polling/PollingViewModelTest.kt index 94b2257ffea..98c179dd496 100644 --- a/paymentsheet/src/test/java/com/stripe/android/paymentsheet/paymentdatacollection/polling/PollingViewModelTest.kt +++ b/paymentsheet/src/test/java/com/stripe/android/paymentsheet/paymentdatacollection/polling/PollingViewModelTest.kt @@ -3,6 +3,7 @@ package com.stripe.android.paymentsheet.paymentdatacollection.polling import androidx.lifecycle.SavedStateHandle import com.google.common.truth.Truth.assertThat import com.stripe.android.model.StripeIntent +import com.stripe.android.paymentsheet.R import com.stripe.android.polling.IntentStatusPoller import kotlinx.coroutines.test.TestDispatcher import kotlinx.coroutines.test.TestScope @@ -215,6 +216,7 @@ private fun createPollingViewModel( timeLimit = timeLimit, initialDelay = initialDelay, maxAttempts = 10, + ctaText = R.string.stripe_upi_polling_message ), poller = poller, timeProvider = timeProvider, diff --git a/paymentsheet/src/test/java/com/stripe/android/utils/FakeIntentConfirmationInterceptor.kt b/paymentsheet/src/test/java/com/stripe/android/utils/FakeIntentConfirmationInterceptor.kt index fe0b575dc9a..acbdbd14585 100644 --- a/paymentsheet/src/test/java/com/stripe/android/utils/FakeIntentConfirmationInterceptor.kt +++ b/paymentsheet/src/test/java/com/stripe/android/utils/FakeIntentConfirmationInterceptor.kt @@ -4,6 +4,7 @@ import com.stripe.android.model.ConfirmPaymentIntentParams import com.stripe.android.model.ConfirmStripeIntentParams import com.stripe.android.model.PaymentMethod import com.stripe.android.model.PaymentMethodCreateParams +import com.stripe.android.model.PaymentMethodOptionsParams import com.stripe.android.paymentsheet.IntentConfirmationInterceptor import com.stripe.android.paymentsheet.PaymentSheet import kotlinx.coroutines.channels.Channel @@ -45,6 +46,7 @@ internal class FakeIntentConfirmationInterceptor : IntentConfirmationInterceptor override suspend fun intercept( initializationMode: PaymentSheet.InitializationMode, paymentMethodCreateParams: PaymentMethodCreateParams, + paymentMethodOptionsParams: PaymentMethodOptionsParams?, shippingValues: ConfirmPaymentIntentParams.Shipping?, setupForFutureUsage: ConfirmPaymentIntentParams.SetupFutureUsage?, ): IntentConfirmationInterceptor.NextStep { diff --git a/paymentsheet/src/test/resources/blik-support.csv b/paymentsheet/src/test/resources/blik-support.csv new file mode 100644 index 00000000000..cf419e945d7 --- /dev/null +++ b/paymentsheet/src/test/resources/blik-support.csv @@ -0,0 +1,49 @@ +lpm, hasCustomer, allowsDelayedPayment, intentSetupFutureUsage, intentHasShipping, intentLpms, supportCustomerSavedCard, formExists, formType, supportsAdding +blik, true, true, off_session, false, card/blik, false, false, not available, false +blik, true, true, off_session, false, card/eps/blik, false, false, not available, false +blik, true, false, off_session, false, card/blik, false, false, not available, false +blik, true, false, off_session, false, card/eps/blik, false, false, not available, false +blik, true, true, on_session, false, card/blik, false, false, not available, false +blik, true, true, on_session, false, card/eps/blik, false, false, not available, false +blik, true, false, on_session, false, card/blik, false, false, not available, false +blik, true, false, on_session, false, card/eps/blik, false, false, not available, false +blik, true, true, null, false, card/blik, false, true, oneTime, true +blik, true, true, null, false, card/eps/blik, false, true, oneTime, true +blik, true, false, null, false, card/blik, false, true, oneTime, true +blik, true, false, null, false, card/eps/blik, false, true, oneTime, true +blik, false, true, off_session, false, card/blik, false, false, not available, false +blik, false, true, off_session, false, card/eps/blik, false, false, not available, false +blik, false, false, off_session, false, card/blik, false, false, not available, false +blik, false, false, off_session, false, card/eps/blik, false, false, not available, false +blik, false, true, on_session, false, card/blik, false, false, not available, false +blik, false, true, on_session, false, card/eps/blik, false, false, not available, false +blik, false, false, on_session, false, card/blik, false, false, not available, false +blik, false, false, on_session, false, card/eps/blik, false, false, not available, false +blik, false, true, null, false, card/blik, false, true, oneTime, true +blik, false, true, null, false, card/eps/blik, false, true, oneTime, true +blik, false, false, null, false, card/blik, false, true, oneTime, true +blik, false, false, null, false, card/eps/blik, false, true, oneTime, true +blik, true, true, off_session, true, card/blik, false, false, not available, false +blik, true, true, off_session, true, card/eps/blik, false, false, not available, false +blik, true, false, off_session, true, card/blik, false, false, not available, false +blik, true, false, off_session, true, card/eps/blik, false, false, not available, false +blik, true, true, on_session, true, card/blik, false, false, not available, false +blik, true, true, on_session, true, card/eps/blik, false, false, not available, false +blik, true, false, on_session, true, card/blik, false, false, not available, false +blik, true, false, on_session, true, card/eps/blik, false, false, not available, false +blik, true, true, null, true, card/blik, false, true, oneTime, true +blik, true, true, null, true, card/eps/blik, false, true, oneTime, true +blik, true, false, null, true, card/blik, false, true, oneTime, true +blik, true, false, null, true, card/eps/blik, false, true, oneTime, true +blik, false, true, off_session, true, card/blik, false, false, not available, false +blik, false, true, off_session, true, card/eps/blik, false, false, not available, false +blik, false, false, off_session, true, card/blik, false, false, not available, false +blik, false, false, off_session, true, card/eps/blik, false, false, not available, false +blik, false, true, on_session, true, card/blik, false, false, not available, false +blik, false, true, on_session, true, card/eps/blik, false, false, not available, false +blik, false, false, on_session, true, card/blik, false, false, not available, false +blik, false, false, on_session, true, card/eps/blik, false, false, not available, false +blik, false, true, null, true, card/blik, false, true, oneTime, true +blik, false, true, null, true, card/eps/blik, false, true, oneTime, true +blik, false, false, null, true, card/blik, false, true, oneTime, true +blik, false, false, null, true, card/eps/blik, false, true, oneTime, true diff --git a/stripe-ui-core/api/stripe-ui-core.api b/stripe-ui-core/api/stripe-ui-core.api index e4dd861dc8e..5f6b20dbf95 100644 --- a/stripe-ui-core/api/stripe-ui-core.api +++ b/stripe-ui-core/api/stripe-ui-core.api @@ -126,6 +126,8 @@ public final class com/stripe/android/uicore/elements/IdentifierSpec$$serializer public final class com/stripe/android/uicore/elements/IdentifierSpec$Companion { public final fun Generic (Ljava/lang/String;)Lcom/stripe/android/uicore/elements/IdentifierSpec; + public final fun getBlik ()Lcom/stripe/android/uicore/elements/IdentifierSpec; + public final fun getBlikCode ()Lcom/stripe/android/uicore/elements/IdentifierSpec; public final fun getCardBrand ()Lcom/stripe/android/uicore/elements/IdentifierSpec; public final fun getCardCvc ()Lcom/stripe/android/uicore/elements/IdentifierSpec; public final fun getCardExpMonth ()Lcom/stripe/android/uicore/elements/IdentifierSpec; diff --git a/stripe-ui-core/src/main/java/com/stripe/android/uicore/elements/IdentifierSpec.kt b/stripe-ui-core/src/main/java/com/stripe/android/uicore/elements/IdentifierSpec.kt index 0c135b44a1d..8ca5bb03edc 100644 --- a/stripe-ui-core/src/main/java/com/stripe/android/uicore/elements/IdentifierSpec.kt +++ b/stripe-ui-core/src/main/java/com/stripe/android/uicore/elements/IdentifierSpec.kt @@ -5,6 +5,12 @@ import androidx.annotation.RestrictTo import kotlinx.parcelize.Parcelize import kotlinx.serialization.Serializable +@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) +enum class ApiParameterDestination { + Params, + Options +} + /** * This uniquely identifies a element in the form. The vals here are for identifier * specs that need to be found when pre-populating fields, or when extracting data. @@ -16,7 +22,8 @@ import kotlinx.serialization.Serializable @Parcelize data class IdentifierSpec( val v1: String, - val ignoreField: Boolean = false + val ignoreField: Boolean = false, + val apiParameterDestination: ApiParameterDestination = ApiParameterDestination.Params, ) : Parcelable { constructor() : this("") @@ -65,6 +72,12 @@ data class IdentifierSpec( val Upi = IdentifierSpec("upi") val Vpa = IdentifierSpec("upi[vpa]") + @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) + val Blik = IdentifierSpec("blik", apiParameterDestination = ApiParameterDestination.Options) + + @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) + val BlikCode = IdentifierSpec("blik[code]", apiParameterDestination = ApiParameterDestination.Options) + @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX) fun get(value: String) = when (value) { CardBrand.v1 -> CardBrand