Skip to content

Commit 50a6d0c

Browse files
gbirch-stripecodex
andcommitted
Normalize Checkout shipping defaults through SAE
Committed-By-Agent: codex Co-authored-by: codex <noreply@openai.com>
1 parent 4ad82d6 commit 50a6d0c

4 files changed

Lines changed: 211 additions & 15 deletions

File tree

StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/AddressViewController/AddressViewController.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -319,6 +319,12 @@ extension AddressViewController {
319319
// MARK: - Internal methods
320320
extension AddressViewController {
321321

322+
func initialAddressDetails() async -> AddressDetails? {
323+
await addressSpecProvider.loadAddressSpecs()
324+
loadViewIfNeeded()
325+
return addressDetails
326+
}
327+
322328
func didContinue() {
323329
logAddressCompleted()
324330
delegate?.addressViewControllerDidFinish(self, with: addressDetails)

StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/Checkout/Checkout.swift

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -133,10 +133,33 @@ public final class Checkout: ObservableObject {
133133
self.session = loadedSession
134134
self.nonisolatedSession = session // temporary hack
135135

136-
try await applyDefaults()
136+
let defaultShippingAddress: Session.ShippingAddress?
137+
if let shippingDetails = configuration.defaults.shippingDetails,
138+
let address = shippingDetails.address {
139+
defaultShippingAddress = Session.ShippingAddress(
140+
name: shippingDetails.name,
141+
address: address
142+
)
143+
} else {
144+
defaultShippingAddress = nil
145+
}
146+
147+
// Initialize the SAE with the raw default so its form can normalize the address.
148+
self.shippingAddressElement = ShippingAddressElement(
149+
checkout: self,
150+
initialShippingAddress: defaultShippingAddress ?? session.shippingAddress
151+
)
152+
let normalizedDefaultShippingAddress: Session.ShippingAddress?
153+
if defaultShippingAddress != nil {
154+
normalizedDefaultShippingAddress = await shippingAddressElement.normalizedInitialShippingAddress()
155+
} else {
156+
normalizedDefaultShippingAddress = nil
157+
}
158+
159+
// Apply the normalized address before initializing elements that read from the session.
160+
try await applyDefaults(shippingAddress: normalizedDefaultShippingAddress)
137161

138-
// Load elements
139-
self.shippingAddressElement = ShippingAddressElement(checkout: self)
162+
// Load remaining elements
140163
self.paymentElement = try await PaymentElement(checkout: self)
141164
let sessionSource = CheckoutSessionSource(initialSession: session, sessionPublisher: $session)
142165
self.expressCheckoutElement = ExpressCheckoutElement(
@@ -361,18 +384,17 @@ public final class Checkout: ObservableObject {
361384
// MARK: - Defaults
362385

363386
extension Checkout {
364-
func applyDefaults() async throws {
387+
func applyDefaults(shippingAddress: Session.ShippingAddress?) async throws {
365388
let defaults = configuration.defaults
366389

367390
if let billingDetails = defaults.billingDetails,
368391
let address = billingDetails.address {
369392
try await updateBillingTaxRegionIfNecessary(address: address)
370393
}
371-
if let shippingDetails = defaults.shippingDetails,
372-
let address = shippingDetails.address {
394+
if let shippingAddress {
373395
try await updateShippingAddress(
374-
name: shippingDetails.name,
375-
address: address
396+
name: shippingAddress.name,
397+
address: shippingAddress.address
376398
)
377399
}
378400
}

StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/Checkout/Shipping Address Element/ShippingAddressElement.swift

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,12 @@ public final class ShippingAddressElement {
1212

1313
private(set) var addressViewController: AddressViewController!
1414

15-
init(checkout: Checkout) {
15+
init(
16+
checkout: Checkout,
17+
initialShippingAddress: Checkout.Session.ShippingAddress?
18+
) {
1619
let configuration = checkout.configuration.shippingAddressElement.makeAddressViewControllerConfiguration(
17-
shippingAddress: checkout.session.shippingAddress,
20+
shippingAddress: initialShippingAddress,
1821
allowedCountries: checkout.session.allowedShippingCountries,
1922
apiClient: checkout.apiClient,
2023
useAutocompleteEndpoints: checkout.session.elementsSession.shouldUseAutocompleteProxyEndpoints
@@ -24,6 +27,24 @@ public final class ShippingAddressElement {
2427
delegate: self
2528
)
2629
}
30+
31+
func normalizedInitialShippingAddress() async -> Checkout.Session.ShippingAddress? {
32+
// Read the address back from the form to include its validation and normalization.
33+
guard let addressDetails = await addressViewController.initialAddressDetails() else {
34+
return nil
35+
}
36+
return Checkout.Session.ShippingAddress(
37+
name: addressDetails.name,
38+
address: Checkout.Address(
39+
country: addressDetails.address.country,
40+
line1: addressDetails.address.line1,
41+
line2: addressDetails.address.line2,
42+
city: addressDetails.address.city,
43+
state: addressDetails.address.state,
44+
postalCode: addressDetails.address.postalCode
45+
)
46+
)
47+
}
2748
}
2849

2950
extension ShippingAddressElement: AddressViewControllerDelegate {

StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/Checkout/CheckoutDefaultsInitializationTests.swift

Lines changed: 152 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import OHHTTPStubs
22
@testable @_spi(STP) import StripeCore
33
@testable @_spi(STP) import StripePaymentSheet
4+
@testable @_spi(STP) import StripeUICore
45
import XCTest
56

67
@MainActor
@@ -54,7 +55,7 @@ final class CheckoutDefaultsInitializationTests: XCTestCase {
5455
line1: "123 Shipping St",
5556
city: "San Francisco",
5657
state: "CA",
57-
postalCode: "94105"
58+
postalCode: "94105-1234"
5859
)
5960
configuration.defaults.shippingDetails = shippingDetails
6061

@@ -68,25 +69,171 @@ final class CheckoutDefaultsInitializationTests: XCTestCase {
6869
XCTAssertEqual(requests[1].params["tax_region[country]"], "US")
6970
XCTAssertEqual(requests[1].params["tax_region[line1]"], "123 Shipping St")
7071
XCTAssertEqual(requests[1].params["tax_region[city]"], "San Francisco")
72+
XCTAssertEqual(requests[1].params["tax_region[postal_code]"], "94105")
7173
XCTAssertEqual(checkout.session.shippingAddress?.name, "Shipping Name")
74+
XCTAssertEqual(checkout.session.shippingAddress?.address.postalCode, "94105")
75+
XCTAssertEqual(
76+
checkout.getPaymentElement().paymentSheetFlowController.configuration.shippingDetails()?.address.postalCode,
77+
"94105"
78+
)
79+
XCTAssertEqual(
80+
checkout.getPaymentElement().embeddedPaymentElement.configuration.shippingDetails()?.address.postalCode,
81+
"94105"
82+
)
83+
}
84+
85+
func testInitDoesNotApplyShippingDefaultWhenShippingAddressElementReturnsNil() async throws {
86+
// Given a Checkout Session that uses shipping for tax
87+
stubCheckoutSessionRequests(automaticTaxAddressSource: "shipping")
88+
89+
var configuration = Checkout.Configuration(clientSecret: clientSecret, returnURL: "stripe-ios-test://checkout-return")
90+
configuration.apiClient = STPAPIClient(publishableKey: "pk_test_123")
91+
var shippingDetails = Checkout.Configuration.Defaults.ShippingDetails()
92+
shippingDetails.name = "Shipping Name"
93+
shippingDetails.address = .init(
94+
country: "US",
95+
line1: "123 Shipping St",
96+
city: "San Francisco",
97+
state: "CA",
98+
postalCode: "12"
99+
)
100+
configuration.defaults.shippingDetails = shippingDetails
101+
102+
// When Checkout initializes
103+
let checkout = try await Checkout(configuration: configuration)
104+
105+
// Then the invalid shipping default remains in the SAE for correction but is not applied
106+
XCTAssertNotNil(checkout.getPaymentElement())
107+
XCTAssertEqual(requestRecorder.requests.map(\.kind), [.initSession])
108+
XCTAssertNil(checkout.session.shippingAddress)
109+
XCTAssertEqual(
110+
checkout.getShippingAddressElement().addressViewController.configuration.defaultValues.address.postalCode,
111+
"12"
112+
)
113+
let normalizedShippingAddress = await checkout.getShippingAddressElement().normalizedInitialShippingAddress()
114+
XCTAssertNil(normalizedShippingAddress)
115+
}
116+
117+
func testInitDropsStateWhenShippingAddressElementDoesNotCollectState() async throws {
118+
// Given an address with a state for a country whose form does not collect one
119+
stubCheckoutSessionRequests(automaticTaxAddressSource: "shipping", allowedShippingCountries: ["AT"])
120+
121+
var configuration = Checkout.Configuration(clientSecret: clientSecret, returnURL: "stripe-ios-test://checkout-return")
122+
configuration.apiClient = STPAPIClient(publishableKey: "pk_test_123")
123+
var shippingDetails = Checkout.Configuration.Defaults.ShippingDetails()
124+
shippingDetails.name = "Shipping Name"
125+
shippingDetails.address = .init(
126+
country: "AT",
127+
line1: "Karntner Strasse 1",
128+
city: "Vienna",
129+
state: "Vienna",
130+
postalCode: "1010"
131+
)
132+
configuration.defaults.shippingDetails = shippingDetails
133+
134+
// When Checkout initializes
135+
let checkout = try await Checkout(configuration: configuration)
136+
let requests = requestRecorder.requests
137+
138+
// Then the SAE does not show or return the provided state
139+
XCTAssertNil(checkout.getShippingAddressElement().addressViewController.addressSection?.state)
140+
XCTAssertEqual(requests.map(\.kind), [.initSession, .updateSession])
141+
XCTAssertNil(requests[1].params["tax_region[state]"])
142+
XCTAssertNil(checkout.session.shippingAddress?.address.state)
143+
}
144+
145+
func testInitLeavesStateBlankWhenShippingAddressElementDoesNotRecognizeState() async throws {
146+
// Given a Brazilian state name that does not match an SDK state name or code
147+
stubCheckoutSessionRequests(automaticTaxAddressSource: "shipping", allowedShippingCountries: ["BR"])
148+
149+
var configuration = Checkout.Configuration(clientSecret: clientSecret, returnURL: "stripe-ios-test://checkout-return")
150+
configuration.apiClient = STPAPIClient(publishableKey: "pk_test_123")
151+
var shippingDetails = Checkout.Configuration.Defaults.ShippingDetails()
152+
shippingDetails.name = "Shipping Name"
153+
shippingDetails.address = .init(
154+
country: "BR",
155+
line1: "Avenida Sete de Setembro 1",
156+
city: "Porto Velho",
157+
state: "Rondonia",
158+
postalCode: "76801-020"
159+
)
160+
configuration.defaults.shippingDetails = shippingDetails
161+
162+
// When Checkout initializes
163+
let checkout = try await Checkout(configuration: configuration)
164+
165+
// Then the required state dropdown remains blank and the invalid address is not applied
166+
XCTAssertEqual(checkout.getShippingAddressElement().addressViewController.addressSection?.state?.rawData, "")
167+
XCTAssertEqual(requestRecorder.requests.map(\.kind), [.initSession])
168+
XCTAssertNil(checkout.session.shippingAddress)
169+
}
170+
171+
func testInitDoesNotApplyDisallowedShippingDefault() async throws {
172+
// Given a Checkout Session that only allows US shipping addresses
173+
stubCheckoutSessionRequests(automaticTaxAddressSource: "shipping", allowedShippingCountries: ["US"])
174+
175+
var configuration = Checkout.Configuration(clientSecret: clientSecret, returnURL: "stripe-ios-test://checkout-return")
176+
configuration.apiClient = STPAPIClient(publishableKey: "pk_test_123")
177+
var shippingDetails = Checkout.Configuration.Defaults.ShippingDetails()
178+
shippingDetails.name = "Shipping Name"
179+
shippingDetails.address = .init(
180+
country: "CA",
181+
line1: "123 Front St",
182+
city: "Toronto",
183+
state: "ON",
184+
postalCode: "M5J 2N1"
185+
)
186+
configuration.defaults.shippingDetails = shippingDetails
187+
188+
// When Checkout initializes
189+
let checkout = try await Checkout(configuration: configuration)
190+
191+
// Then the SAE rejects the default and Checkout does not apply it
192+
XCTAssertNotNil(checkout.getPaymentElement())
193+
XCTAssertEqual(requestRecorder.requests.map(\.kind), [.initSession])
194+
XCTAssertNil(checkout.session.shippingAddress)
195+
XCTAssertTrue(
196+
checkout.getShippingAddressElement().addressViewController.configuration.defaultValues.address.isEmpty
197+
)
198+
}
199+
200+
func testInitWithoutShippingDefaultDoesNotPerformShippingUpdate() async throws {
201+
// Given a Checkout Session that uses shipping for tax
202+
stubCheckoutSessionRequests(automaticTaxAddressSource: "shipping")
203+
204+
var configuration = Checkout.Configuration(clientSecret: clientSecret, returnURL: "stripe-ios-test://checkout-return")
205+
configuration.apiClient = STPAPIClient(publishableKey: "pk_test_123")
206+
207+
// When Checkout initializes
208+
let checkout = try await Checkout(configuration: configuration)
209+
210+
// Then Checkout does not manufacture or apply a shipping default
211+
XCTAssertNotNil(checkout.getPaymentElement())
212+
XCTAssertEqual(requestRecorder.requests.map(\.kind), [.initSession])
213+
XCTAssertNil(checkout.session.shippingAddress)
72214
}
73215

74216
// MARK: - Stubs
75217

76218
private func stubCheckoutSessionRequests(
77-
automaticTaxAddressSource: String = "billing"
219+
automaticTaxAddressSource: String = "billing",
220+
allowedShippingCountries: [String] = ["US", "CA"]
78221
) {
79222
CheckoutTestHelpers.stubCheckoutSessionRequests(
80223
sessionId: sessionId,
81224
requestRecorder: requestRecorder,
82225
sessionJSON: { [self] in
83-
sessionJSON(automaticTaxAddressSource: automaticTaxAddressSource)
226+
sessionJSON(
227+
automaticTaxAddressSource: automaticTaxAddressSource,
228+
allowedShippingCountries: allowedShippingCountries
229+
)
84230
}
85231
)
86232
}
87233

88234
private func sessionJSON(
89-
automaticTaxAddressSource: String = "billing"
235+
automaticTaxAddressSource: String = "billing",
236+
allowedShippingCountries: [String] = ["US", "CA"]
90237
) -> [AnyHashable: Any] {
91238
var json = CheckoutTestHelpers.openSessionJSON
92239
json["session_id"] = sessionId
@@ -95,7 +242,7 @@ final class CheckoutDefaultsInitializationTests: XCTestCase {
95242
"automatic_tax_enabled": true,
96243
"automatic_tax_address_source": "session.\(automaticTaxAddressSource)",
97244
]
98-
json["shipping_address_collection"] = ["allowed_countries": ["US", "CA"]]
245+
json["shipping_address_collection"] = ["allowed_countries": allowedShippingCountries]
99246
return json
100247
}
101248
}

0 commit comments

Comments
 (0)