Skip to content

Commit e025939

Browse files
cttsai-stripecodex
andcommitted
Collect automatic tax billing address for new payment methods (MOBILESDK-4667)
Committed-By-Agent: codex Co-authored-by: codex <noreply@openai.com>
1 parent f286cb8 commit e025939

14 files changed

Lines changed: 500 additions & 69 deletions

File tree

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package com.stripe.android.ui.core.elements
2+
3+
import androidx.annotation.RestrictTo
4+
import com.stripe.android.uicore.elements.IdentifierSpec
5+
6+
/**
7+
* Billing address fields required in addition to country for automatic tax calculation.
8+
* Countries absent from this map require country only.
9+
*
10+
* Source: https://docs.stripe.com/tax/customer-locations
11+
*/
12+
private val additionalAutomaticTaxFieldsByCountry: Map<String, Set<IdentifierSpec>> = mapOf(
13+
"CA" to setOf(IdentifierSpec.PostalCode),
14+
"GB" to setOf(IdentifierSpec.PostalCode),
15+
"IN" to setOf(IdentifierSpec.PostalCode),
16+
"PR" to setOf(IdentifierSpec.Line1, IdentifierSpec.City, IdentifierSpec.PostalCode),
17+
"US" to setOf(IdentifierSpec.Line1, IdentifierSpec.City, IdentifierSpec.State, IdentifierSpec.PostalCode),
18+
)
19+
20+
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
21+
fun automaticTaxRequiredFields(countryCode: String): Set<IdentifierSpec> {
22+
return additionalAutomaticTaxFieldsByCountry[countryCode].orEmpty()
23+
}

payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/CardBillingAddressElement.kt

Lines changed: 32 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,10 @@ import com.stripe.android.uicore.utils.mapAsStateFlow
3030
import kotlinx.coroutines.flow.StateFlow
3131

3232
/**
33-
* This is a special type of AddressElement that
34-
* removes fields from the address based on the country. It
35-
* is only intended to be used with the card payment method.
33+
* An address element that dynamically removes fields based on the selected country and collection mode.
3634
*/
3735
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
38-
class CardBillingAddressElement(
36+
open class BillingAddressElement(
3937
override val identifier: IdentifierSpec,
4038
rawValuesMap: Map<IdentifierSpec, String?> = emptyMap(),
4139
countryCodes: Set<String> = emptySet(),
@@ -107,7 +105,7 @@ class CardBillingAddressElement(
107105
}
108106

109107
private val addressElementSectionController = addressElement.sectionFieldErrorController()
110-
private val cardBillingAddressElementSectionErrorController =
108+
private val billingAddressElementSectionErrorController =
111109
object : SectionFieldValidationController by addressElementSectionController, SectionFieldComposable {
112110
override val validationMessage: StateFlow<FieldValidationMessage?> =
113111
addressElement.addressController
@@ -203,7 +201,7 @@ class CardBillingAddressElement(
203201
override fun getFormFieldValueFlow() = addressElement.getFormFieldValueFlow()
204202

205203
override fun sectionFieldErrorController(): SectionFieldValidationController =
206-
cardBillingAddressElementSectionErrorController
204+
billingAddressElementSectionErrorController
207205

208206
override fun setRawValue(rawValuesMap: Map<IdentifierSpec, String?>) = addressElement.setRawValue(rawValuesMap)
209207

@@ -215,23 +213,33 @@ class CardBillingAddressElement(
215213
}
216214

217215
/**
218-
* Billing address fields required in addition to the country, for a Checkout Session using
219-
* automatic tax with the billing address as the tax source. Most countries only need the
220-
* country. Source: https://docs.stripe.com/tax/customer-locations
221-
*
222-
* Billing only - shipping is out of scope, since it's always collected in full for delivery
223-
* regardless of tax, so there's no omittable mode there for tax to rescue.
216+
* The card-specific name is retained for compatibility. New payment-method forms should use
217+
* [BillingAddressElement] directly.
224218
*/
225-
private val additionalAutomaticTaxFieldsByCountry: Map<String, Set<IdentifierSpec>> = mapOf(
226-
"CA" to setOf(IdentifierSpec.PostalCode),
227-
"GB" to setOf(IdentifierSpec.PostalCode),
228-
"IN" to setOf(IdentifierSpec.PostalCode),
229-
"PR" to setOf(IdentifierSpec.Line1, IdentifierSpec.City, IdentifierSpec.PostalCode),
230-
"US" to setOf(IdentifierSpec.Line1, IdentifierSpec.City, IdentifierSpec.State, IdentifierSpec.PostalCode),
219+
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
220+
class CardBillingAddressElement(
221+
identifier: IdentifierSpec,
222+
rawValuesMap: Map<IdentifierSpec, String?> = emptyMap(),
223+
countryCodes: Set<String> = emptySet(),
224+
countryDropdownFieldController: DropdownFieldController = DropdownFieldController(
225+
CountryConfig(countryCodes),
226+
rawValuesMap[IdentifierSpec.Country]
227+
),
228+
autocompleteAddressInteractorFactory: AutocompleteAddressInteractor.Factory?,
229+
sameAsShippingElement: SameAsShippingElement?,
230+
shippingValuesMap: Map<IdentifierSpec, String?>?,
231+
collectionConfiguration: BillingDetailsCollectionConfiguration = BillingDetailsCollectionConfiguration(),
232+
shouldHideCountryOnNoAddressCollection: Boolean = true,
233+
requiresBillingAddressForAutomaticTax: Boolean = false,
234+
) : BillingAddressElement(
235+
identifier = identifier,
236+
rawValuesMap = rawValuesMap,
237+
countryCodes = countryCodes,
238+
countryDropdownFieldController = countryDropdownFieldController,
239+
autocompleteAddressInteractorFactory = autocompleteAddressInteractorFactory,
240+
sameAsShippingElement = sameAsShippingElement,
241+
shippingValuesMap = shippingValuesMap,
242+
collectionConfiguration = collectionConfiguration,
243+
shouldHideCountryOnNoAddressCollection = shouldHideCountryOnNoAddressCollection,
244+
requiresBillingAddressForAutomaticTax = requiresBillingAddressForAutomaticTax,
231245
)
232-
233-
private fun automaticTaxRequiredFields(countryCode: String): Set<IdentifierSpec> {
234-
// Matches the raw, non-uppercased comparison the AVS check above uses - countryCode is
235-
// already an uppercase ISO code in practice (from CountryConfig).
236-
return additionalAutomaticTaxFieldsByCountry[countryCode].orEmpty()
237-
}

paymentsheet/src/main/java/com/stripe/android/customersheet/CustomerSheetViewModel.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -903,6 +903,7 @@ internal class CustomerSheetViewModel(
903903
sellerBusinessName = null,
904904
forceSetupFutureUseBehavior = false,
905905
clientAttributionMetadata = clientAttributionMetadata,
906+
requiresBillingAddressForAutomaticTax = false,
906907
)
907908
}
908909

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
package com.stripe.android.lpmfoundations.paymentmethod
2+
3+
import com.stripe.android.core.strings.resolvableString
4+
import com.stripe.android.model.PaymentMethod
5+
import com.stripe.android.paymentsheet.PaymentSheet
6+
import com.stripe.android.ui.core.BillingDetailsCollectionConfiguration
7+
import com.stripe.android.ui.core.R
8+
import com.stripe.android.ui.core.elements.BillingAddressElement
9+
import com.stripe.android.uicore.elements.AddressFieldsElement
10+
import com.stripe.android.uicore.elements.CountryConfig
11+
import com.stripe.android.uicore.elements.CountryElement
12+
import com.stripe.android.uicore.elements.DropdownFieldController
13+
import com.stripe.android.uicore.elements.FormElement
14+
import com.stripe.android.uicore.elements.IdentifierSpec
15+
import com.stripe.android.uicore.elements.SameAsShippingController
16+
import com.stripe.android.uicore.elements.SameAsShippingElement
17+
import com.stripe.android.uicore.elements.SectionController
18+
import com.stripe.android.uicore.elements.SectionElement
19+
20+
/**
21+
* Widens an existing country section for automatic tax, or appends a country-first billing
22+
* address when the payment method does not already collect one.
23+
*/
24+
internal fun List<FormElement>.withAutomaticTaxBillingAddressIfNecessary(
25+
paymentMethodCode: String,
26+
arguments: UiDefinitionFactory.Arguments,
27+
): List<FormElement> {
28+
val shouldCollectTaxAddress = arguments.requiresBillingAddressForAutomaticTax &&
29+
arguments.billingDetailsCollectionConfiguration.address ==
30+
PaymentSheet.BillingDetailsCollectionConfiguration.AddressCollectionMode.Automatic
31+
if (!shouldCollectTaxAddress || paymentMethodCode in separatelyRenderedPaymentMethodCodes) {
32+
return this
33+
}
34+
35+
val sections = filterIsInstance<SectionElement>()
36+
if (sections.any { section -> section.fields.any { it is AddressFieldsElement } }) {
37+
return this
38+
}
39+
40+
val countrySectionIndex = indexOfFirst { element ->
41+
element is SectionElement && element.fields.any { it is CountryElement }
42+
}
43+
if (countrySectionIndex >= 0) {
44+
val countrySection = this[countrySectionIndex] as SectionElement
45+
val widenedFields = countrySection.fields.map { field ->
46+
if (field is CountryElement) {
47+
createTaxBillingAddressElement(arguments, field.controller, sameAsShippingElement = null)
48+
} else {
49+
field
50+
}
51+
}
52+
val widenedSection = SectionElement(
53+
identifier = countrySection.identifier,
54+
fields = widenedFields,
55+
controller = SectionController(
56+
label = countrySection.controller.label ?: R.string.stripe_billing_details.resolvableString,
57+
sectionFieldValidationControllers = widenedFields.map { it.sectionFieldErrorController() },
58+
),
59+
)
60+
return toMutableList().apply { this[countrySectionIndex] = widenedSection }
61+
}
62+
63+
val sameAsShippingElement = arguments.shippingValues
64+
?.get(IdentifierSpec.SameAsShipping)
65+
?.toBooleanStrictOrNull()
66+
?.let { isSameAsShipping ->
67+
SameAsShippingElement(
68+
identifier = IdentifierSpec.SameAsShipping,
69+
controller = SameAsShippingController(isSameAsShipping),
70+
)
71+
}
72+
val addressElement = createTaxBillingAddressElement(
73+
arguments = arguments,
74+
countryDropdownFieldController = DropdownFieldController(
75+
config = CountryConfig(arguments.billingDetailsCollectionConfiguration.allowedBillingCountries),
76+
initialValue = arguments.initialValues[IdentifierSpec.Country],
77+
),
78+
sameAsShippingElement = sameAsShippingElement,
79+
)
80+
81+
return this + listOfNotNull(
82+
SectionElement.wrap(addressElement, R.string.stripe_billing_details.resolvableString),
83+
sameAsShippingElement,
84+
)
85+
}
86+
87+
private fun createTaxBillingAddressElement(
88+
arguments: UiDefinitionFactory.Arguments,
89+
countryDropdownFieldController: DropdownFieldController,
90+
sameAsShippingElement: SameAsShippingElement?,
91+
): BillingAddressElement {
92+
return BillingAddressElement(
93+
identifier = IdentifierSpec.Generic("billing_details[address]"),
94+
rawValuesMap = arguments.initialValues,
95+
countryCodes = arguments.billingDetailsCollectionConfiguration.allowedBillingCountries,
96+
countryDropdownFieldController = countryDropdownFieldController,
97+
autocompleteAddressInteractorFactory = null,
98+
sameAsShippingElement = sameAsShippingElement,
99+
shippingValuesMap = arguments.shippingValues,
100+
collectionConfiguration = BillingDetailsCollectionConfiguration(
101+
collectName = false,
102+
collectEmail = false,
103+
collectPhone = false,
104+
address = BillingDetailsCollectionConfiguration.AddressCollectionMode.Automatic,
105+
allowedCountries = arguments.billingDetailsCollectionConfiguration.allowedBillingCountries,
106+
),
107+
shouldHideCountryOnNoAddressCollection = false,
108+
requiresBillingAddressForAutomaticTax = true,
109+
)
110+
}
111+
112+
private val separatelyRenderedPaymentMethodCodes = setOf(
113+
PaymentMethod.Type.USBankAccount.code,
114+
PaymentMethod.Type.Link.code,
115+
)

paymentsheet/src/main/java/com/stripe/android/lpmfoundations/paymentmethod/PaymentMethodMetadata.kt

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -328,29 +328,37 @@ internal data class PaymentMethodMetadata(
328328
code: String,
329329
uiDefinitionFactoryArgumentsFactory: UiDefinitionFactory.Arguments.Factory,
330330
): List<FormElement>? {
331-
return if (isExternalPaymentMethod(code)) {
331+
val (formElements, arguments) = if (isExternalPaymentMethod(code)) {
332+
val arguments = uiDefinitionFactoryArgumentsFactory.create(this, requiresMandate = false)
332333
getUiDefinitionFactoryForExternalPaymentMethod(code)?.createFormElements(
333334
metadata = this,
334-
arguments = uiDefinitionFactoryArgumentsFactory.create(this, requiresMandate = false)
335-
)
335+
arguments = arguments,
336+
) to arguments
336337
} else if (isCustomPaymentMethod(code)) {
338+
val arguments = uiDefinitionFactoryArgumentsFactory.create(this, requiresMandate = false)
337339
getUiDefinitionFactoryForCustomPaymentMethod(code)?.createFormElements(
338340
metadata = this,
339-
arguments = uiDefinitionFactoryArgumentsFactory.create(this, requiresMandate = false)
340-
)
341+
arguments = arguments,
342+
) to arguments
341343
} else {
342344
val definition = supportedPaymentMethodDefinitions().firstOrNull { it.type.code == code } ?: return null
345+
val arguments = uiDefinitionFactoryArgumentsFactory.create(
346+
metadata = this,
347+
requiresMandate = definition.requiresMandate(this),
348+
)
343349

344350
definition.uiDefinitionFactory(this).formElements(
345351
metadata = this,
346352
definition = definition,
347353
sharedDataSpecs = sharedDataSpecs,
348-
arguments = uiDefinitionFactoryArgumentsFactory.create(
349-
metadata = this,
350-
requiresMandate = definition.requiresMandate(this),
351-
),
352-
)
354+
arguments = arguments,
355+
) to arguments
353356
}
357+
358+
return formElements?.withAutomaticTaxBillingAddressIfNecessary(
359+
paymentMethodCode = code,
360+
arguments = arguments,
361+
)
354362
}
355363

356364
fun allowRedisplay(

paymentsheet/src/main/java/com/stripe/android/paymentsheet/forms/FormViewModel.kt

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import androidx.lifecycle.viewModelScope
77
import com.stripe.android.paymentsheet.forms.PlaceholderHelper.connectBillingDetailsFields
88
import com.stripe.android.paymentsheet.model.PaymentSelection
99
import com.stripe.android.paymentsheet.paymentdatacollection.FormArguments
10-
import com.stripe.android.ui.core.elements.CardBillingAddressElement
10+
import com.stripe.android.ui.core.elements.BillingAddressElement
1111
import com.stripe.android.uicore.elements.FormElement
1212
import com.stripe.android.uicore.elements.IdentifierSpec
1313
import com.stripe.android.uicore.elements.SectionElement
@@ -58,9 +58,9 @@ internal class FormViewModel(
5858
}
5959
}
6060

61-
private val cardBillingElement = elements.filterIsInstance<SectionElement>()
61+
private val billingAddressElement = elements.filterIsInstance<SectionElement>()
6262
.flatMap { it.fields }
63-
.filterIsInstance<CardBillingAddressElement>()
63+
.filterIsInstance<BillingAddressElement>()
6464
.firstOrNull()
6565

6666
private var externalHiddenIdentifiers = MutableStateFlow(emptySet<IdentifierSpec>())
@@ -77,10 +77,10 @@ internal class FormViewModel(
7777
}
7878

7979
internal val hiddenIdentifiers = combineAsStateFlow(
80-
cardBillingElement?.hiddenIdentifiers ?: stateFlowOf(emptySet()),
80+
billingAddressElement?.hiddenIdentifiers ?: stateFlowOf(emptySet()),
8181
externalHiddenIdentifiers
82-
) { cardBillingIdentifiers, externalHiddenIdentifiers ->
83-
externalHiddenIdentifiers.plus(cardBillingIdentifiers)
82+
) { billingAddressIdentifiers, externalHiddenIdentifiers ->
83+
externalHiddenIdentifiers.plus(billingAddressIdentifiers)
8484
}
8585

8686
// This will convert the save for future use value into a CustomerRequestedSave operation

0 commit comments

Comments
 (0)