From d024be36652524b82d69ff492e178a06dac05dd9 Mon Sep 17 00:00:00 2001 From: Samer Alabi Date: Thu, 23 Apr 2026 02:14:35 -0400 Subject: [PATCH] Remove billing details update restriction for cards added with Tap to Add --- .../com/stripe/android/model/PaymentMethod.kt | 15 ++++- .../model/parsers/PaymentMethodJsonParser.kt | 4 +- .../parsers/PaymentMethodJsonParserTest.kt | 20 ++++++ .../ui/EditCardDetailsInteractor.kt | 17 ++++- .../ui/UpdatePaymentMethodInteractor.kt | 1 + .../DefaultEditCardDetailsInteractorTest.kt | 67 +++++++++++++++++++ ...efaultUpdatePaymentMethodInteractorTest.kt | 29 ++++++++ .../FakeEditCardDetailsInteractorFactory.kt | 4 ++ 8 files changed, 150 insertions(+), 7 deletions(-) diff --git a/payments-core/src/main/java/com/stripe/android/model/PaymentMethod.kt b/payments-core/src/main/java/com/stripe/android/model/PaymentMethod.kt index 8a2a12d6863..91c5399d067 100644 --- a/payments-core/src/main/java/com/stripe/android/model/PaymentMethod.kt +++ b/payments-core/src/main/java/com/stripe/android/model/PaymentMethod.kt @@ -908,7 +908,14 @@ constructor( val displayBrand: String? = null, @JvmField @field:RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) - val cardArt: CardArt? = null + val cardArt: CardArt? = null, + /** + * Indicates whether this payment method was created from a card_present payment method, indicate that a + * customer uses a Terminal device or application to save their card. + */ + @JvmField + @field:RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) + val createdFromCardPresent: Boolean = false, ) : TypeData() { override val type: Type get() = Type.Card @@ -926,7 +933,8 @@ constructor( wallet: Wallet? = this.wallet, networks: Networks? = this.networks, displayBrand: String? = this.displayBrand, - cardArt: CardArt? = this.cardArt + cardArt: CardArt? = this.cardArt, + createdFromCardPresent: Boolean = this.createdFromCardPresent ): Card { return Card( brand = brand, @@ -941,7 +949,8 @@ constructor( wallet = wallet, networks = networks, displayBrand = displayBrand, - cardArt = cardArt + cardArt = cardArt, + createdFromCardPresent = createdFromCardPresent ) } diff --git a/payments-core/src/main/java/com/stripe/android/model/parsers/PaymentMethodJsonParser.kt b/payments-core/src/main/java/com/stripe/android/model/parsers/PaymentMethodJsonParser.kt index 81cf74cc8e2..f73a7c2cfcd 100644 --- a/payments-core/src/main/java/com/stripe/android/model/parsers/PaymentMethodJsonParser.kt +++ b/payments-core/src/main/java/com/stripe/android/model/parsers/PaymentMethodJsonParser.kt @@ -141,7 +141,8 @@ class PaymentMethodJsonParser : ModelJsonParser { displayBrand = StripeJsonUtils.optString(json, FIELD_DISPLAY_BRAND), cardArt = json.optJSONObject(FIELD_CARD_ART)?.let { CardArtJsonParser().parse(it) - } + }, + createdFromCardPresent = json.has(FIELD_GENERATED_FROM), ) } @@ -357,6 +358,7 @@ class PaymentMethodJsonParser : ModelJsonParser { private const val FIELD_CUSTOMER = "customer" private const val FIELD_LIVEMODE = "livemode" private const val FIELD_ALLOW_REDISPLAY = "allow_redisplay" + private const val FIELD_GENERATED_FROM = "generated_from" private const val FIELD_TYPE = "type" } } diff --git a/payments-core/src/test/java/com/stripe/android/model/parsers/PaymentMethodJsonParserTest.kt b/payments-core/src/test/java/com/stripe/android/model/parsers/PaymentMethodJsonParserTest.kt index 5377cbbb9ee..522483b3bb6 100644 --- a/payments-core/src/test/java/com/stripe/android/model/parsers/PaymentMethodJsonParserTest.kt +++ b/payments-core/src/test/java/com/stripe/android/model/parsers/PaymentMethodJsonParserTest.kt @@ -4,6 +4,7 @@ import com.google.common.truth.Truth.assertThat import com.stripe.android.model.Address import com.stripe.android.model.PaymentMethod import com.stripe.android.model.PaymentMethodFixtures +import org.json.JSONObject import kotlin.test.Test class PaymentMethodJsonParserTest { @@ -194,4 +195,23 @@ class PaymentMethodJsonParserTest { assertThat(paymentMethod.card?.cardArt).isNull() } + + @Test + fun `parse card JSON without generated_from sets createdFromCardPresent to false`() { + val paymentMethod = PaymentMethodJsonParser().parse(PaymentMethodFixtures.CARD_JSON) + + assertThat(paymentMethod.card?.createdFromCardPresent).isFalse() + } + + @Test + fun `parse card JSON with generated_from key sets createdFromCardPresent to true`() { + val json = JSONObject(PaymentMethodFixtures.CARD_JSON.toString()) + + json.getJSONObject("card") + .put("generated_from", JSONObject()) + + val paymentMethod = PaymentMethodJsonParser().parse(json) + + assertThat(paymentMethod.card?.createdFromCardPresent).isTrue() + } } diff --git a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/ui/EditCardDetailsInteractor.kt b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/ui/EditCardDetailsInteractor.kt index 194200461d0..2c8ded09940 100644 --- a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/ui/EditCardDetailsInteractor.kt +++ b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/ui/EditCardDetailsInteractor.kt @@ -105,6 +105,9 @@ internal data class EditCardPayload( internal data class CardEditConfiguration( val cardBrandFilter: CardBrandFilter, val isCbcModifiable: Boolean, + // Flag that indicates if mandatory input of missing country & ZIP code + // are required for billing details in automatic mode. + val isAutomaticAddressInputMandatory: Boolean = true, // Local flag for whether expiry date and address can be edited. // This flag has no effect on Card Brand Choice. // It will be removed before release. @@ -228,11 +231,19 @@ internal class DefaultEditCardDetailsInteractor( ): CardUpdateParams? { val hasChanges = hasCardDetailsChanged(cardDetailsEntry) || hasBillingDetailsChanged(billingDetailsEntry) - val isComplete = (cardDetailsEntry?.isComplete() != false) && - billingDetailsEntry?.isComplete(billingDetailsCollectionConfiguration) != false + + val cardDetailsAreComplete = cardDetailsEntry?.isComplete() != false + val billingDetailsAreComplete = billingDetailsEntry + ?.isComplete(billingDetailsCollectionConfiguration) != false + + val addressInputNotRequired = + billingDetailsCollectionConfiguration.address == AddressCollectionMode.Automatic && + !(cardEditConfiguration?.isAutomaticAddressInputMandatory ?: true) + + val isComplete = cardDetailsAreComplete && (billingDetailsAreComplete || addressInputNotRequired) return if ((hasChanges || requiresModification.not()) && isComplete) { - toUpdateParams(cardDetailsEntry, billingDetailsEntry) + toUpdateParams(cardDetailsEntry, billingDetailsEntry.takeIf { billingDetailsAreComplete }) } else { null } diff --git a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/ui/UpdatePaymentMethodInteractor.kt b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/ui/UpdatePaymentMethodInteractor.kt index da5a9e9e3ab..d1e733ab007 100644 --- a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/ui/UpdatePaymentMethodInteractor.kt +++ b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/ui/UpdatePaymentMethodInteractor.kt @@ -174,6 +174,7 @@ internal class DefaultUpdatePaymentMethodInteractor( val payload = EditCardPayload.create(savedPaymentMethodCard.card, savedPaymentMethodCard.billingDetails) val cardEditConfiguration = CardEditConfiguration( cardBrandFilter = cardBrandFilter, + isAutomaticAddressInputMandatory = !savedPaymentMethodCard.card.createdFromCardPresent, isCbcModifiable = isModifiable && displayableSavedPaymentMethod.canChangeCbc(), areExpiryDateAndAddressModificationSupported = isModifiable && canUpdateFullPaymentMethodDetails, ) diff --git a/paymentsheet/src/test/java/com/stripe/android/paymentsheet/ui/DefaultEditCardDetailsInteractorTest.kt b/paymentsheet/src/test/java/com/stripe/android/paymentsheet/ui/DefaultEditCardDetailsInteractorTest.kt index 3518af22d8e..fe188a6af55 100644 --- a/paymentsheet/src/test/java/com/stripe/android/paymentsheet/ui/DefaultEditCardDetailsInteractorTest.kt +++ b/paymentsheet/src/test/java/com/stripe/android/paymentsheet/ui/DefaultEditCardDetailsInteractorTest.kt @@ -116,6 +116,71 @@ internal class DefaultEditCardDetailsInteractorTest { assertThat(state.cardDetailsState?.expiryDateState?.enabled).isFalse() } + @Test + fun `optional automatic address with complete billing includes address on card update params`() { + var cardUpdateParams: CardUpdateParams? = null + val handler = handler( + isAutomaticAddressInputMandatory = false, + onCardUpdateParamsChanged = { cardUpdateParams = it }, + ) + handler.updateBillingDetails( + PaymentSheetFixtures.billingDetailsFormState( + postalCode = FormFieldEntry("10001", isComplete = true), + country = FormFieldEntry("US", isComplete = true), + ) + ) + assertThat(cardUpdateParams).isNotNull() + assertThat(cardUpdateParams?.billingDetails?.address?.postalCode).isEqualTo("10001") + assertThat(cardUpdateParams?.billingDetails?.address?.country).isEqualTo("US") + } + + @Test + fun `optional automatic address with incomplete billing has no billing in card update params`() { + var capturedCardUpdateParams: CardUpdateParams? = null + val handler = handler( + isAutomaticAddressInputMandatory = false, + onCardUpdateParamsChanged = { + capturedCardUpdateParams = it + } + ) + + handler.handleViewAction( + EditCardDetailsInteractor.ViewAction.BillingDetailsChanged( + PaymentSheetFixtures.billingDetailsFormState( + line1 = null, + line2 = null, + city = null, + state = null, + postalCode = FormFieldEntry("", isComplete = false), + country = FormFieldEntry("", isComplete = false), + ) + ) + ) + + handler.updateCardBrand(CardBrand.Visa) + + assertThat(capturedCardUpdateParams).isNotNull() + assertThat(capturedCardUpdateParams?.cardBrand).isEqualTo(CardBrand.Visa) + // Billing is not included when automatic fields are incomplete; the card update API call omits it. + assertThat(capturedCardUpdateParams?.billingDetails).isNull() + } + + @Test + fun `full address mode puts line1 on card update params when line1 is changed`() { + var cardUpdateParams: CardUpdateParams? = null + val handler = handler( + addressCollectionMode = AddressCollectionMode.Full, + onCardUpdateParamsChanged = { cardUpdateParams = it }, + ) + handler.updateBillingDetails( + PaymentSheetFixtures.billingDetailsFormState( + line1 = FormFieldEntry("9 New Street", isComplete = true), + ) + ) + assertThat(cardUpdateParams).isNotNull() + assertThat(cardUpdateParams?.billingDetails?.address?.line1).isEqualTo("9 New Street") + } + @Test fun stateIsUpdateWhenNewCardBrandIsSelected() { val handler = handler() @@ -634,6 +699,7 @@ internal class DefaultEditCardDetailsInteractorTest { card: PaymentMethod.Card = PaymentMethodFixtures.CARD_WITH_NETWORKS, cardBrandFilter: CardBrandFilter = DefaultCardBrandFilter, isCbcModifiable: Boolean = true, + isAutomaticAddressInputMandatory: Boolean = true, areExpiryDateAndAddressModificationSupported: Boolean = true, addressCollectionMode: AddressCollectionMode = AddressCollectionMode.Automatic, nameCollection: CollectionMode = CollectionMode.Automatic, @@ -648,6 +714,7 @@ internal class DefaultEditCardDetailsInteractorTest { cardEditConfiguration = CardEditConfiguration( cardBrandFilter = cardBrandFilter, isCbcModifiable = isCbcModifiable, + isAutomaticAddressInputMandatory = isAutomaticAddressInputMandatory, areExpiryDateAndAddressModificationSupported = areExpiryDateAndAddressModificationSupported, ), requiresModification = requiresModification, diff --git a/paymentsheet/src/test/java/com/stripe/android/paymentsheet/ui/DefaultUpdatePaymentMethodInteractorTest.kt b/paymentsheet/src/test/java/com/stripe/android/paymentsheet/ui/DefaultUpdatePaymentMethodInteractorTest.kt index 38ac4a5105a..61f421e097b 100644 --- a/paymentsheet/src/test/java/com/stripe/android/paymentsheet/ui/DefaultUpdatePaymentMethodInteractorTest.kt +++ b/paymentsheet/src/test/java/com/stripe/android/paymentsheet/ui/DefaultUpdatePaymentMethodInteractorTest.kt @@ -127,6 +127,35 @@ class DefaultUpdatePaymentMethodInteractorTest { } } + @Test + fun `when card was created from card present then automatic address is not mandatory in card edit config`() { + val editFactory = FakeEditCardDetailsInteractorFactory() + val displayable = PaymentMethodFixtures.CARD_WITH_NETWORKS_PAYMENT_METHOD.copy( + card = PaymentMethodFixtures.CARD_WITH_NETWORKS.copy(createdFromCardPresent = true) + ).toDisplayableSavedPaymentMethod() + runScenario( + displayableSavedPaymentMethod = displayable, + editCardDetailsInteractorFactory = editFactory, + ) { + assertThat(interactor.editCardDetailsInteractor).isNotNull() + assertThat(editFactory.cardEditConfiguration?.isAutomaticAddressInputMandatory) + .isFalse() + } + } + + @Test + fun `when card was not created from card present then automatic address is mandatory in card edit config`() { + val editFactory = FakeEditCardDetailsInteractorFactory() + runScenario( + displayableSavedPaymentMethod = PaymentMethodFixtures.displayableCard(), + editCardDetailsInteractorFactory = editFactory, + ) { + assertThat(interactor.editCardDetailsInteractor).isNotNull() + assertThat(editFactory.cardEditConfiguration?.isAutomaticAddressInputMandatory) + .isTrue() + } + } + @Test fun cbcEligibleCard_isModifiablePaymentMethod() { runScenario( diff --git a/paymentsheet/src/test/java/com/stripe/android/paymentsheet/ui/FakeEditCardDetailsInteractorFactory.kt b/paymentsheet/src/test/java/com/stripe/android/paymentsheet/ui/FakeEditCardDetailsInteractorFactory.kt index 291d938952a..3c56590032c 100644 --- a/paymentsheet/src/test/java/com/stripe/android/paymentsheet/ui/FakeEditCardDetailsInteractorFactory.kt +++ b/paymentsheet/src/test/java/com/stripe/android/paymentsheet/ui/FakeEditCardDetailsInteractorFactory.kt @@ -7,6 +7,9 @@ internal class FakeEditCardDetailsInteractorFactory : EditCardDetailsInteractor. var billingDetailsCollectionConfiguration: PaymentSheet.BillingDetailsCollectionConfiguration? = null private set + var cardEditConfiguration: CardEditConfiguration? = null + private set + var onCardUpdateParamsChanged: CardUpdateParamsCallback? = null private set @@ -19,6 +22,7 @@ internal class FakeEditCardDetailsInteractorFactory : EditCardDetailsInteractor. onBrandChoiceChanged: CardBrandCallback, onCardUpdateParamsChanged: CardUpdateParamsCallback ): EditCardDetailsInteractor { + this.cardEditConfiguration = cardEditConfiguration this.onCardUpdateParamsChanged = onCardUpdateParamsChanged this.billingDetailsCollectionConfiguration = billingDetailsCollectionConfiguration return FakeEditCardDetailsInteractor(