Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
a173d4b
Add design spec for iOS Address Element dismissal changes
gbirch-stripe Jul 30, 2026
e74dd6e
Add implementation plan for iOS Address Element dismissal changes
gbirch-stripe Jul 30, 2026
2a3872d
Make Address Element 'X' a cancel and block swipe-to-dismiss
gbirch-stripe Jul 30, 2026
273d01f
update alert messaging
gbirch-stripe Jul 30, 2026
6736c26
remove llm notes
gbirch-stripe Jul 30, 2026
ee13c45
update comments
gbirch-stripe Jul 31, 2026
2a9209c
snapshot retaken on save/presentation
gbirch-stripe Jul 31, 2026
9623915
actually discard changes when customer selects 'discard'
gbirch-stripe Jul 31, 2026
2570d0c
remove extraneous swipe to dismiss disabler
gbirch-stripe Jul 31, 2026
32040d0
fix test
gbirch-stripe Jul 31, 2026
263bbec
remove dead code
gbirch-stripe Jul 31, 2026
b66d45a
remove public API removal
gbirch-stripe Jul 31, 2026
69ce024
update test to reflect change
gbirch-stripe Jul 31, 2026
8e2cd6f
add localizations
gbirch-stripe Aug 3, 2026
c015c86
save handler
gbirch-stripe Aug 4, 2026
e370977
pr feedback
gbirch-stripe Aug 4, 2026
9bbebf2
localization download batch
gbirch-stripe Aug 5, 2026
c48c8c3
Merge branch 'master' of github.com:stripe/stripe-ios into gbirch/ae-…
gbirch-stripe Aug 10, 2026
14fba4b
upload latest strings
gbirch-stripe Aug 10, 2026
5fc65f7
fix selectedAutoCompleteResult tracking, fix tests, adjust DidFinish(…
gbirch-stripe Aug 10, 2026
5362562
update tests
gbirch-stripe Aug 11, 2026
7d33af6
remove unused string
gbirch-stripe Aug 11, 2026
9849a18
Merge branch 'gbirch/ae-dismissal-changes' of github.com:stripe/strip…
gbirch-stripe Aug 11, 2026
7c8f9d3
presentation implementation
gbirch-stripe Aug 7, 2026
3ea76bf
update playground to use SAE instead of legacy address element
gbirch-stripe Aug 11, 2026
d21e996
remove extraneous tests
gbirch-stripe Aug 11, 2026
058de4c
fix bad rebase
gbirch-stripe Aug 11, 2026
31a383a
simplify presentation
gbirch-stripe Aug 11, 2026
7b0524c
implement sae address saving
gbirch-stripe Aug 12, 2026
0c77bbf
add snapshot test for loading state
gbirch-stripe Aug 12, 2026
1608b77
Update snapshot reference images
Aug 12, 2026
d6a5f9a
Merge remote-tracking branch 'origin/gbirch/sae-save' into gbirch/sae…
gbirch-stripe Aug 12, 2026
886ca79
Merge branch 'master' of github.com:stripe/stripe-ios into gbirch/sae…
gbirch-stripe Aug 12, 2026
aa021e8
replace localizations folder with master
gbirch-stripe Aug 12, 2026
1e8bf6b
implemented analytics
gbirch-stripe Aug 12, 2026
249f9d4
add error parameters
gbirch-stripe Aug 12, 2026
66f682f
updat test
gbirch-stripe Aug 12, 2026
f1d42d8
add handling for multiple simultaneous presentations
gbirch-stripe Aug 12, 2026
c3e9afe
Merge remote-tracking branch 'origin/gbirch/sae-save' into gbirch/sae…
gbirch-stripe Aug 12, 2026
94987be
Merge branch 'master' of github.com:stripe/stripe-ios into gbirch/sae…
gbirch-stripe Aug 13, 2026
9feaad9
implement sae address saving
gbirch-stripe Aug 12, 2026
9db5c01
add snapshot test for loading state
gbirch-stripe Aug 12, 2026
07afa20
Update snapshot reference images
Aug 12, 2026
ec0fdaa
updat test
gbirch-stripe Aug 12, 2026
017d867
fix duplication logic
gbirch-stripe Aug 13, 2026
9815b48
Merge branch 'gbirch/sae-save' of github.com:stripe/stripe-ios into g…
gbirch-stripe Aug 13, 2026
57d027e
fix merge
gbirch-stripe Aug 13, 2026
0f6cf6c
Merge remote-tracking branch 'origin/master' into gbirch/sae-analytics
gbirch-stripe Aug 13, 2026
d8d18a4
fix legacy AE logging completion on cancellation; fix tests
gbirch-stripe Aug 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -360,4 +360,11 @@ import Foundation
case adaptivePricingCurrencyToggledFailed = "elements.adaptive_pricing.currency_toggled.failed"
case adaptivePricingFlagImageLoadFailed = "elements.adaptive_pricing.flag_image_load.failed"

// MARK: - Shipping Address Element
case shippingAddressElementShown = "elements.shipping_address.shown"
case shippingAddressElementCanceled = "elements.shipping_address.canceled"
case shippingAddressElementSaveStarted = "elements.shipping_address.save_started"
case shippingAddressElementSaveFailed = "elements.shipping_address.save_failed"
case shippingAddressElementSaveCompleted = "elements.shipping_address.save_completed"

}
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,26 @@ extension STPAnalyticsClient {
self.logAddressControllerEvent(event: .csbillingAddressCompleted, addressAnalyticData: analyticData, apiClient: apiClient)
}

func logShippingAddressElementEvent(
event: STPAnalyticEvent,
addressAnalyticData: AddressAnalyticData,
checkoutSessionId: String,
error: Error? = nil,
apiClient: STPAPIClient
) {
assert(apiClient.publishableKey?.nonEmpty != nil) // A publishable key is required to be set at this point so we can send it in our analytics payload
var additionalParams: [String: Any] = ["checkout_session_id": checkoutSessionId]
if let error {
additionalParams.mergeAssertingOnOverwrites(error.serializeForV1Analytics())
}
logAddressControllerEvent(
event: event,
addressAnalyticData: addressAnalyticData,
additionalParams: additionalParams,
apiClient: apiClient
)
}

// MARK: - Autocomplete

func logAddressAutocompleteStart(addressCountryCode: String, sessionToken: String, apiClient: STPAPIClient) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,10 @@ public class AddressViewController: UIViewController {
/// Delegate provided by the integration entry point (legacy Address Element or Checkout Sessions Shipping Address Element) that handles address saving and analytics
@MainActor
protocol IntegrationDelegate: AnyObject {
/// Handles the address form being shown.
func didShow()
/// Handles cancellation without saving the address.
func didCancel()
/// Handles completion with the customer's collected address details.
func save(addressDetails: AddressDetails, setLoading: (Bool) -> Void) async throws
}
Expand Down Expand Up @@ -311,11 +315,7 @@ public class AddressViewController: UIViewController {

override public func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(true)
if !didLogAddressShow {
STPAnalyticsClient.sharedClient.logAddressShow(defaultCountryCode: addressSection?.selectedCountryCode ?? "", apiClient: configuration.apiClient)
didLogAddressShow = true
addressShowStart = Date()
}
integrationDelegate?.didShow()
// Ensure we receive dismissal callbacks even when presented modally inside a UINavigationController
navigationController?.presentationController?.delegate = self
addressSection?.beginEditing()
Expand Down Expand Up @@ -433,6 +433,7 @@ extension AddressViewController {
if hasChanges {
presentDiscardChangesAlert()
} else {
integrationDelegate?.didCancel()
delegate?.addressViewControllerDidFinish(self, with: initialAddressDetails)
}
}
Expand All @@ -456,6 +457,7 @@ extension AddressViewController {
// Revert the form to its as-opened state so a reused instance doesn't keep the discarded
// edits, then finish with the as-opened address (never the edited-but-abandoned values).
resetFormToInitialSnapshot()
integrationDelegate?.didCancel()
delegate?.addressViewControllerDidFinish(self, with: initialAddressDetails)
}

Expand Down Expand Up @@ -622,29 +624,75 @@ extension AddressViewController {
}
}

private func logAddressCompleted() {
var addressShowAnalyticData: AddressAnalyticData {
return AddressAnalyticData(
addressCountryCode: addressSection?.selectedCountryCode.nonEmpty
?? configuration.defaultValues.address.country?.nonEmpty
?? "",
autoCompleteResultedSelected: nil,
editDistance: nil
)
}

var currentAddressAnalyticData: AddressAnalyticData {
return makeAddressAnalyticData(address: addressDetails?.address)
}

func addressAnalyticData(for addressDetails: AddressDetails) -> AddressAnalyticData {
return makeAddressAnalyticData(address: addressDetails.address)
}

private func makeAddressAnalyticData(address: AddressDetails.Address?) -> AddressAnalyticData {
var editDistance: Int?
if let selectedAddress = addressDetails?.address, let autoCompleteAddress = selectedAutoCompleteResult {
editDistance = PaymentSheet.Address(from: selectedAddress).editDistance(from: autoCompleteAddress)
if let address, let autoCompleteAddress = selectedAutoCompleteResult {
editDistance = PaymentSheet.Address(from: address).editDistance(from: autoCompleteAddress)
}
let msToComplete = Date().timeIntervalSince(addressShowStart)

STPAnalyticsClient.sharedClient.logAddressCompleted(
addressCountyCode: addressSection?.selectedCountryCode ?? "",
return AddressAnalyticData(
addressCountryCode: address?.country.nonEmpty
?? addressSection?.selectedCountryCode.nonEmpty
?? configuration.defaultValues.address.country?.nonEmpty
?? "",
autoCompleteResultedSelected: selectedAutoCompleteResult != nil,
editDistance: editDistance,
msToComplete: msToComplete,
apiClient: configuration.apiClient
editDistance: editDistance
)
}
}

// MARK: - IntegrationDelegate
// Default implementation that logs completion and forwards address details to the merchant delegate
extension AddressViewController: AddressViewController.IntegrationDelegate {

func didShow() {
guard !didLogAddressShow else { return }
STPAnalyticsClient.sharedClient.logAddressShow(defaultCountryCode: addressSection?.selectedCountryCode ?? "", apiClient: configuration.apiClient)
didLogAddressShow = true
addressShowStart = Date()
}

func didCancel() {
}

func save(addressDetails: AddressDetails, setLoading: (Bool) -> Void) async throws {
logAddressCompleted()
}

private func logAddressCompleted() {
let analyticData = currentAddressAnalyticData
let msToComplete = Date().timeIntervalSince(addressShowStart)
STPAnalyticsClient.sharedClient.logAddressCompleted(
addressCountyCode: analyticData.addressCountryCode,
autoCompleteResultedSelected: analyticData.autoCompleteResultedSelected ?? false,
editDistance: analyticData.editDistance,
msToComplete: msToComplete,
apiClient: configuration.apiClient
)
}
}

extension AddressViewController.IntegrationDelegate {
func didShow() {}
func didCancel() {}
}

// MARK: - ElementDelegate
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,7 @@ public final class Checkout: ObservableObject {
configuration: configuration.shippingAddressElement,
initialShippingAddress: defaultShippingAddress ?? session.shippingAddress,
allowedCountries: session.allowedShippingCountries,
checkoutSessionId: session.id,
apiClient: configuration.apiClient,
useAutocompleteEndpoints: session.elementsSession.shouldUseAutocompleteProxyEndpoints
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ protocol ShippingAddressElementDelegate: AnyObject {
public final class ShippingAddressElement {

private(set) var addressViewController: AddressViewController!
private let checkoutSessionId: String
private var didLogAddressShow = false
private var isPresenting = false
private var presentationCompletion: (() -> Void)?
weak var delegate: ShippingAddressElementDelegate?
Expand All @@ -30,9 +32,11 @@ public final class ShippingAddressElement {
configuration: Configuration,
initialShippingAddress: Checkout.Session.ShippingAddress?,
allowedCountries: [String]?,
checkoutSessionId: String,
apiClient: STPAPIClient,
useAutocompleteEndpoints: Bool
) {
self.checkoutSessionId = checkoutSessionId
let addressViewControllerConfiguration = configuration.makeAddressViewControllerConfiguration(
shippingAddress: initialShippingAddress,
allowedCountries: allowedCountries,
Expand Down Expand Up @@ -110,11 +114,29 @@ public final class ShippingAddressElement {
let navigationController = UINavigationController(rootViewController: addressViewController)
isPresenting = true
presentationCompletion = completion
didLogAddressShow = false
presentingViewController.present(navigationController, animated: true)
}
}

extension ShippingAddressElement: AddressViewController.IntegrationDelegate {

func didShow() {
guard !didLogAddressShow else { return }
didLogAddressShow = true
log(
event: .shippingAddressElementShown,
addressAnalyticData: addressViewController.addressShowAnalyticData
)
}

func didCancel() {
log(
event: .shippingAddressElementCanceled,
addressAnalyticData: addressViewController.currentAddressAnalyticData
)
}

func save(
addressDetails: AddressViewController.AddressDetails,
setLoading: (Bool) -> Void
Expand All @@ -124,18 +146,44 @@ extension ShippingAddressElement: AddressViewController.IntegrationDelegate {
return
}

let addressAnalyticData = addressViewController.addressAnalyticData(for: addressDetails)
log(event: .shippingAddressElementSaveStarted, addressAnalyticData: addressAnalyticData)
setLoading(true)
defer { setLoading(false) }
try await delegate.updateShippingAddress(
name: addressDetails.name,
address: Checkout.Address(
country: addressDetails.address.country,
line1: addressDetails.address.line1.nonEmpty,
line2: addressDetails.address.line2,
city: addressDetails.address.city,
state: addressDetails.address.state,
postalCode: addressDetails.address.postalCode
do {
try await delegate.updateShippingAddress(
name: addressDetails.name,
address: Checkout.Address(
country: addressDetails.address.country,
line1: addressDetails.address.line1.nonEmpty,
line2: addressDetails.address.line2,
city: addressDetails.address.city,
state: addressDetails.address.state,
postalCode: addressDetails.address.postalCode
)
)
log(event: .shippingAddressElementSaveCompleted, addressAnalyticData: addressAnalyticData)
} catch {
log(
event: .shippingAddressElementSaveFailed,
addressAnalyticData: addressAnalyticData,
error: error
)
throw error
}
}

private func log(
event: STPAnalyticEvent,
addressAnalyticData: AddressAnalyticData,
error: Error? = nil
) {
STPAnalyticsClient.sharedClient.logShippingAddressElementEvent(
event: event,
addressAnalyticData: addressAnalyticData,
checkoutSessionId: checkoutSessionId,
error: error,
apiClient: addressViewController.configuration.apiClient
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,7 @@ class AddressViewControllerSnapshotTests: STPSnapshotTestCase {
)
),
allowedCountries: ["US"],
checkoutSessionId: "cs_test_123",
apiClient: .init(publishableKey: "pk_test_1234"),
useAutocompleteEndpoints: false
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,45 @@ final class AddressViewControllerTests: XCTestCase {
XCTAssertNotNil(addressCompletionEvent)
}

func testViewDidAppearAlwaysNotifiesCustomIntegrationDelegate() {
// Given
let integrationDelegate = IntegrationDelegate()
let viewController = makeViewController(
configuration: makeConfiguration(),
merchantDelegate: MerchantDelegate(),
integrationDelegate: integrationDelegate
)
viewController.loadViewIfNeeded()

// When
viewController.viewDidAppear(false)
viewController.viewDidAppear(false)

// Then
XCTAssertEqual(integrationDelegate.didShowCallCount, 2)
}

func testDefaultIntegrationDelegateLogsAddressShowOnce() {
// Given
let viewController = makeViewController(
configuration: makeConfiguration(),
merchantDelegate: MerchantDelegate()
)
viewController.loadViewIfNeeded()

// When
viewController.viewDidAppear(false)
viewController.viewDidAppear(false)

// Then
XCTAssertEqual(
STPAnalyticsClient.sharedClient._testLogHistory.filter {
$0["event"] as? String == "mc_address_show"
}.count,
1
)
}

func testDidContinueDisplaysIntegrationDelegateError() async {
// Given an AddressViewController whose integration delegate fails to save
let expectedError = NSError(
Expand Down Expand Up @@ -224,6 +263,7 @@ private final class MerchantDelegate: AddressViewControllerDelegate {

@MainActor
private final class IntegrationDelegate: AddressViewController.IntegrationDelegate {
var didShowCallCount = 0
var saveExpectation: XCTestExpectation?
var loadingExpectation: XCTestExpectation?
var receivedAddressDetails: [AddressViewController.AddressDetails] = []
Expand All @@ -235,6 +275,10 @@ private final class IntegrationDelegate: AddressViewController.IntegrationDelega
self.error = error
}

func didShow() {
didShowCallCount += 1
}

func save(
addressDetails: AddressViewController.AddressDetails,
setLoading: (Bool) -> Void
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ enum CheckoutTestHelpers {
let resolvedClientSecret = clientSecret ?? apiResponse.clientSecret ?? "cs_test_123_secret_abc"
let sessionId = Checkout.extractSessionId(from: resolvedClientSecret)
let apiClient = APIStubbedTestCase.stubbedAPIClient()
apiClient.publishableKey = "pk_test_123"

// Keep tests offline except for explicitly stubbed Checkout init work.
if stubAllOutgoingRequests {
Expand Down
Loading
Loading