Skip to content

Commit 2737ab8

Browse files
cttsai-stripecodex
andcommitted
Generalize automatic-tax billing address primitives (MOBILESDK-4667)
Committed-By-Agent: codex Co-authored-by: codex <noreply@openai.com> Committed-By-Agent: codex Co-authored-by: codex <noreply@openai.com> Committed-By-Agent: codex Co-authored-by: codex <noreply@openai.com> Committed-By-Agent: codex Co-authored-by: codex <noreply@openai.com> Committed-By-Agent: codex Co-authored-by: codex <noreply@openai.com> Committed-By-Agent: codex Co-authored-by: codex <noreply@openai.com> Committed-By-Agent: codex Co-authored-by: codex <noreply@openai.com>
1 parent 3995430 commit 2737ab8

11 files changed

Lines changed: 169 additions & 93 deletions

File tree

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
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+
@get:RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
13+
val additionalAutomaticTaxFieldsByCountry: Map<String, Set<IdentifierSpec>> = mapOf(
14+
"CA" to setOf(IdentifierSpec.PostalCode),
15+
"GB" to setOf(IdentifierSpec.PostalCode),
16+
"IN" to setOf(IdentifierSpec.PostalCode),
17+
"PR" to setOf(IdentifierSpec.Line1, IdentifierSpec.City, IdentifierSpec.PostalCode),
18+
"US" to setOf(IdentifierSpec.Line1, IdentifierSpec.City, IdentifierSpec.State, IdentifierSpec.PostalCode),
19+
)
20+
21+
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
22+
fun automaticTaxRequiredFields(countryCode: String): Set<IdentifierSpec> {
23+
return additionalAutomaticTaxFieldsByCountry[countryCode].orEmpty()
24+
}

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

Lines changed: 50 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -29,27 +29,64 @@ import com.stripe.android.uicore.utils.flatMapLatestAsStateFlow
2929
import com.stripe.android.uicore.utils.mapAsStateFlow
3030
import kotlinx.coroutines.flow.StateFlow
3131

32+
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
33+
sealed interface BillingAddressCollectionMode {
34+
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
35+
data object Never : BillingAddressCollectionMode
36+
37+
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
38+
data object Full : BillingAddressCollectionMode
39+
40+
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
41+
data class Country(
42+
val additionalFieldsByCountry: Map<String, Set<IdentifierSpec>>,
43+
) : BillingAddressCollectionMode
44+
}
45+
46+
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
47+
fun cardBillingAddressCollectionMode(
48+
addressCollectionMode: BillingDetailsCollectionConfiguration.AddressCollectionMode,
49+
requiresBillingAddressForAutomaticTax: Boolean,
50+
): BillingAddressCollectionMode {
51+
return when (addressCollectionMode) {
52+
BillingDetailsCollectionConfiguration.AddressCollectionMode.Never -> BillingAddressCollectionMode.Never
53+
BillingDetailsCollectionConfiguration.AddressCollectionMode.Full -> BillingAddressCollectionMode.Full
54+
BillingDetailsCollectionConfiguration.AddressCollectionMode.Automatic -> {
55+
val additionalFieldsByCountry = buildMap {
56+
listOf("US", "GB", "CA").forEach { countryCode ->
57+
put(countryCode, setOf(IdentifierSpec.PostalCode))
58+
}
59+
if (requiresBillingAddressForAutomaticTax) {
60+
additionalAutomaticTaxFieldsByCountry.forEach { (countryCode, fields) ->
61+
merge(countryCode, fields) { existing, additional -> existing + additional }
62+
}
63+
}
64+
}
65+
BillingAddressCollectionMode.Country(additionalFieldsByCountry)
66+
}
67+
}
68+
}
69+
3270
/**
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.
71+
* An address element that dynamically removes fields based on the selected country and collection mode.
3672
*/
3773
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
38-
class CardBillingAddressElement(
74+
class BillingAddressElement(
3975
override val identifier: IdentifierSpec,
4076
rawValuesMap: Map<IdentifierSpec, String?> = emptyMap(),
4177
countryCodes: Set<String> = emptySet(),
4278
countryDropdownFieldController: DropdownFieldController = DropdownFieldController(
4379
CountryConfig(countryCodes),
4480
rawValuesMap[IdentifierSpec.Country]
4581
),
82+
countryElementIdentifier: IdentifierSpec,
4683
autocompleteAddressInteractorFactory: AutocompleteAddressInteractor.Factory?,
4784
sameAsShippingElement: SameAsShippingElement?,
4885
shippingValuesMap: Map<IdentifierSpec, String?>?,
86+
private val addressCollectionMode: BillingAddressCollectionMode,
4987
private val collectionConfiguration: BillingDetailsCollectionConfiguration =
5088
BillingDetailsCollectionConfiguration(),
5189
private val shouldHideCountryOnNoAddressCollection: Boolean = true,
52-
private val requiresBillingAddressForAutomaticTax: Boolean = false,
5390
) : AddressFieldsElement {
5491
private val nameConfig = if (collectionConfiguration.collectName) {
5592
AddressFieldConfiguration.REQUIRED
@@ -71,7 +108,7 @@ class CardBillingAddressElement(
71108

72109
@VisibleForTesting
73110
val addressElement = autocompleteAddressInteractorFactory?.takeIf {
74-
collectionConfiguration.address == BillingDetailsCollectionConfiguration.AddressCollectionMode.Full
111+
addressCollectionMode == BillingAddressCollectionMode.Full
75112
}?.let { factory ->
76113
AutocompleteAddressElement(
77114
identifier = identifier,
@@ -96,13 +133,13 @@ class CardBillingAddressElement(
96133
emailConfig = emailConfig,
97134
),
98135
countryElement = CountryElement(
99-
identifier = IdentifierSpec.Country,
136+
identifier = countryElementIdentifier,
100137
controller = countryDropdownFieldController,
101138
),
102139
shippingValuesMap = shippingValuesMap,
103140
sameAsShippingElement = sameAsShippingElement,
104141
hideCountry = shouldHideCountryOnNoAddressCollection &&
105-
collectionConfiguration.address == BillingDetailsCollectionConfiguration.AddressCollectionMode.Never,
142+
addressCollectionMode == BillingAddressCollectionMode.Never,
106143
)
107144
}
108145

@@ -164,28 +201,18 @@ class CardBillingAddressElement(
164201
// card and achv2 uses save for future use
165202
val hiddenIdentifiers: StateFlow<Set<IdentifierSpec>> =
166203
countryDropdownFieldController.rawFieldValue.mapAsStateFlow { countryCode ->
167-
when (collectionConfiguration.address) {
168-
BillingDetailsCollectionConfiguration.AddressCollectionMode.Never -> {
204+
when (val mode = addressCollectionMode) {
205+
BillingAddressCollectionMode.Never -> {
169206
FieldType.entries
170207
.filterNot { it == FieldType.Name }
171208
.map { it.identifierSpec }
172209
.toSet()
173210
}
174-
BillingDetailsCollectionConfiguration.AddressCollectionMode.Full -> {
211+
BillingAddressCollectionMode.Full -> {
175212
emptySet()
176213
}
177-
BillingDetailsCollectionConfiguration.AddressCollectionMode.Automatic -> {
178-
val avsShownFields = when (countryCode) {
179-
"US", "GB", "CA" -> setOf(FieldType.PostalCode.identifierSpec)
180-
else -> emptySet()
181-
}
182-
val automaticTaxShownFields = if (requiresBillingAddressForAutomaticTax && countryCode != null) {
183-
automaticTaxRequiredFields(countryCode)
184-
} else {
185-
emptySet()
186-
}
187-
val shownFields = avsShownFields + automaticTaxShownFields
188-
214+
is BillingAddressCollectionMode.Country -> {
215+
val shownFields = mode.additionalFieldsByCountry[countryCode].orEmpty()
189216
FieldType.entries
190217
// Filtering name causes the field to be hidden even outside
191218
// of this form.
@@ -213,25 +240,3 @@ class CardBillingAddressElement(
213240
addressElement.onValidationStateChanged(isValidating)
214241
}
215242
}
216-
217-
/**
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.
224-
*/
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),
231-
)
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-
}

payments-ui-core/src/test/java/com/stripe/android/ui/core/elements/CardBillingAddressElementTest.kt

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,18 @@ internal class CardBillingAddressElementTest {
6363
}
6464
}
6565

66+
@Test
67+
fun `Verify that country-only collection does not apply card AVS fields`() = runTest {
68+
val element = createBillingAddressElement(
69+
addressCollectionMode = BillingAddressCollectionMode.Country(emptyMap()),
70+
)
71+
72+
element.hiddenIdentifiers.test {
73+
dropdownFieldController.onRawValueChange("US")
74+
expectMostRecentItem().verifyFieldsShown()
75+
}
76+
}
77+
6678
@Test
6779
fun `Verify that automatic tax fields are unioned with AVS defaults for IN`() = runTest {
6880
val element = createCardBillingAddressElement(requiresBillingAddressForAutomaticTax = true)
@@ -281,17 +293,31 @@ internal class CardBillingAddressElementTest {
281293
private fun createCardBillingAddressElement(
282294
requiresBillingAddressForAutomaticTax: Boolean = false,
283295
collectionConfiguration: BillingDetailsCollectionConfiguration = BillingDetailsCollectionConfiguration(),
284-
): CardBillingAddressElement {
285-
return CardBillingAddressElement(
296+
): BillingAddressElement {
297+
return createBillingAddressElement(
298+
collectionConfiguration = collectionConfiguration,
299+
addressCollectionMode = cardBillingAddressCollectionMode(
300+
addressCollectionMode = collectionConfiguration.address,
301+
requiresBillingAddressForAutomaticTax = requiresBillingAddressForAutomaticTax,
302+
),
303+
)
304+
}
305+
306+
private fun createBillingAddressElement(
307+
collectionConfiguration: BillingDetailsCollectionConfiguration = BillingDetailsCollectionConfiguration(),
308+
addressCollectionMode: BillingAddressCollectionMode,
309+
): BillingAddressElement {
310+
return BillingAddressElement(
286311
identifier = IdentifierSpec.Generic("billing_element"),
287312
rawValuesMap = emptyMap(),
288313
countryCodes = emptySet(),
289314
countryDropdownFieldController = dropdownFieldController,
315+
countryElementIdentifier = IdentifierSpec.Country,
290316
autocompleteAddressInteractorFactory = null,
291317
sameAsShippingElement = null,
292318
shippingValuesMap = null,
319+
addressCollectionMode = addressCollectionMode,
293320
collectionConfiguration = collectionConfiguration,
294-
requiresBillingAddressForAutomaticTax = requiresBillingAddressForAutomaticTax,
295321
)
296322
}
297323

@@ -349,14 +375,15 @@ internal class CardBillingAddressElementTest {
349375

350376
private fun autocompleteTest(
351377
configuration: BillingDetailsCollectionConfiguration,
352-
block: (CardBillingAddressElement) -> Unit,
378+
block: (BillingAddressElement) -> Unit,
353379
) = runTest {
354380
block(
355-
CardBillingAddressElement(
381+
BillingAddressElement(
356382
identifier = IdentifierSpec.Generic("billing_element"),
357383
rawValuesMap = emptyMap(),
358384
countryCodes = emptySet(),
359385
countryDropdownFieldController = dropdownFieldController,
386+
countryElementIdentifier = IdentifierSpec.Country,
360387
autocompleteAddressInteractorFactory = {
361388
object : AutocompleteAddressInteractor {
362389
override val autocompleteConfig: AutocompleteAddressInteractor.Config =
@@ -376,6 +403,10 @@ internal class CardBillingAddressElementTest {
376403
},
377404
sameAsShippingElement = null,
378405
shippingValuesMap = null,
406+
addressCollectionMode = cardBillingAddressCollectionMode(
407+
addressCollectionMode = configuration.address,
408+
requiresBillingAddressForAutomaticTax = false,
409+
),
379410
collectionConfiguration = configuration,
380411
)
381412
)

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

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,13 +27,14 @@ import com.stripe.android.model.PaymentMethod
2727
import com.stripe.android.paymentsheet.PaymentSheet
2828
import com.stripe.android.paymentsheet.model.PaymentMethodIncentive
2929
import com.stripe.android.ui.core.BillingDetailsCollectionConfiguration
30-
import com.stripe.android.ui.core.elements.CardBillingAddressElement
30+
import com.stripe.android.ui.core.elements.BillingAddressElement
3131
import com.stripe.android.ui.core.elements.CardDetailsAction
3232
import com.stripe.android.ui.core.elements.CardDetailsSectionElement
3333
import com.stripe.android.ui.core.elements.CardScanAction
3434
import com.stripe.android.ui.core.elements.Mandate
3535
import com.stripe.android.ui.core.elements.MandateTextElement
3636
import com.stripe.android.ui.core.elements.RenderableFormElement
37+
import com.stripe.android.ui.core.elements.cardBillingAddressCollectionMode
3738
import com.stripe.android.uicore.elements.AutocompleteAddressInteractor
3839
import com.stripe.android.uicore.elements.FormElement
3940
import com.stripe.android.uicore.elements.IdentifierSpec
@@ -285,15 +286,19 @@ private fun cardBillingElements(
285286
controller = SameAsShippingController(it)
286287
)
287288
}
288-
val addressElement = CardBillingAddressElement(
289+
val addressElement = BillingAddressElement(
289290
IdentifierSpec.Generic("credit_billing"),
290291
countryCodes = allowedCountries,
291292
rawValuesMap = initialValues,
293+
countryElementIdentifier = IdentifierSpec.Country,
292294
sameAsShippingElement = sameAsShippingElement,
293295
shippingValuesMap = shippingValues,
296+
addressCollectionMode = cardBillingAddressCollectionMode(
297+
addressCollectionMode = collectionConfiguration.address,
298+
requiresBillingAddressForAutomaticTax = requiresBillingAddressForAutomaticTax,
299+
),
294300
collectionConfiguration = collectionConfiguration,
295301
autocompleteAddressInteractorFactory = autocompleteAddressInteractorFactory,
296-
requiresBillingAddressForAutomaticTax = requiresBillingAddressForAutomaticTax,
297302
)
298303

299304
val title = when {

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

Lines changed: 2 additions & 2 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
@@ -60,7 +60,7 @@ internal class FormViewModel(
6060

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

6666
private var externalHiddenIdentifiers = MutableStateFlow(emptySet<IdentifierSpec>())

paymentsheet/src/main/java/com/stripe/android/paymentsheet/ui/BillingDetailsForm.kt

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ import com.stripe.android.model.PaymentMethod
66
import com.stripe.android.paymentsheet.PaymentSheet.BillingDetailsCollectionConfiguration.AddressCollectionMode
77
import com.stripe.android.ui.core.BillingDetailsCollectionConfiguration
88
import com.stripe.android.ui.core.R
9-
import com.stripe.android.ui.core.elements.CardBillingAddressElement
9+
import com.stripe.android.ui.core.elements.BillingAddressElement
10+
import com.stripe.android.ui.core.elements.cardBillingAddressCollectionMode
1011
import com.stripe.android.uicore.elements.AutocompleteAddressInteractor
1112
import com.stripe.android.uicore.elements.IdentifierSpec
1213
import com.stripe.android.uicore.elements.NameConfig
@@ -38,11 +39,12 @@ internal class BillingDetailsForm(
3839
null
3940
}
4041

41-
private val cardBillingAddressElement: CardBillingAddressElement = CardBillingAddressElement(
42+
private val cardBillingAddressElement: BillingAddressElement = BillingAddressElement(
4243
identifier = IdentifierSpec.BillingAddress,
4344
sameAsShippingElement = null,
4445
shippingValuesMap = null,
4546
countryCodes = allowedBillingCountries,
47+
countryElementIdentifier = IdentifierSpec.Country,
4648
collectionConfiguration = BillingDetailsCollectionConfiguration(
4749
address = when (addressCollectionMode) {
4850
AddressCollectionMode.Automatic ->
@@ -54,6 +56,15 @@ internal class BillingDetailsForm(
5456
collectEmail = collectEmail,
5557
collectPhone = collectPhone,
5658
),
59+
addressCollectionMode = cardBillingAddressCollectionMode(
60+
addressCollectionMode = when (addressCollectionMode) {
61+
AddressCollectionMode.Automatic ->
62+
BillingDetailsCollectionConfiguration.AddressCollectionMode.Automatic
63+
AddressCollectionMode.Never -> BillingDetailsCollectionConfiguration.AddressCollectionMode.Never
64+
AddressCollectionMode.Full -> BillingDetailsCollectionConfiguration.AddressCollectionMode.Full
65+
},
66+
requiresBillingAddressForAutomaticTax = false,
67+
),
5768
rawValuesMap = rawAddressValues(billingDetails),
5869
autocompleteAddressInteractorFactory = autocompleteAddressInteractorFactory,
5970
shouldHideCountryOnNoAddressCollection = false,

paymentsheet/src/test/java/com/stripe/android/customersheet/CustomerSheetViewModelTest.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ import com.stripe.android.testing.PaymentMethodFactory
5151
import com.stripe.android.testing.PaymentMethodFactory.update
5252
import com.stripe.android.testing.SetupIntentFactory
5353
import com.stripe.android.ui.core.cbc.CardBrandChoiceEligibility
54-
import com.stripe.android.ui.core.elements.CardBillingAddressElement
54+
import com.stripe.android.ui.core.elements.BillingAddressElement
5555
import com.stripe.android.ui.core.elements.CardDetailsSectionController
5656
import com.stripe.android.ui.core.elements.CardDetailsSectionElement
5757
import com.stripe.android.uicore.elements.FormElement
@@ -474,7 +474,7 @@ class CustomerSheetViewModelTest : CustomerSheetTestHelper {
474474
assertThat(formElements[0]).isInstanceOf<CardDetailsSectionElement>()
475475
assertThat(formElements[1]).isInstanceOf<SectionElement>()
476476
assertThat(formElements[1].asSectionElement().fields[0])
477-
.isInstanceOf<CardBillingAddressElement>()
477+
.isInstanceOf<BillingAddressElement>()
478478
}
479479
}
480480

0 commit comments

Comments
 (0)