From f08482a201086f374bf1aa89f58d74e77aece4b1 Mon Sep 17 00:00:00 2001 From: Kieran Osgood Date: Thu, 10 Sep 2026 10:38:18 +0100 Subject: [PATCH 1/3] Forward accelerated checkout dismissal callbacks Assisted-By: devx/3f0e7f93-212d-45d8-b056-138b2da8389f --- .../ApplePayAuthorizationDelegate.swift | 4 +++ .../ApplePay/ApplePayViewController.swift | 8 +++++ ...AuthorizationDelegateControllerTests.swift | 1 + .../ApplePayAuthorizationDelegateTests.swift | 17 +++++++++-- .../ApplePayViewControllerTests.swift | 17 +++++++++++ .../ShopPay/ShopPayCallbackTests.swift | 30 +++++++++++++++++-- 6 files changed, 73 insertions(+), 4 deletions(-) diff --git a/platforms/swift/Sources/ShopifyAcceleratedCheckouts/Wallets/ApplePay/ApplePayAuthorizationDelegate/ApplePayAuthorizationDelegate.swift b/platforms/swift/Sources/ShopifyAcceleratedCheckouts/Wallets/ApplePay/ApplePayAuthorizationDelegate/ApplePayAuthorizationDelegate.swift index 2917d090e..44e32ec54 100644 --- a/platforms/swift/Sources/ShopifyAcceleratedCheckouts/Wallets/ApplePay/ApplePayAuthorizationDelegate/ApplePayAuthorizationDelegate.swift +++ b/platforms/swift/Sources/ShopifyAcceleratedCheckouts/Wallets/ApplePay/ApplePayAuthorizationDelegate/ApplePayAuthorizationDelegate.swift @@ -198,6 +198,10 @@ class ApplePayAuthorizationDelegate: NSObject, ObservableObject { case let .cartSubmittedForCompletion(redirectURL): try await transition(to: .presentingCheckoutKit(url: redirectURL)) + case .appleSheetPresented: + controller.onCheckoutDismiss?() + try await transition(to: .reset) + default: try await transition(to: .reset) } diff --git a/platforms/swift/Sources/ShopifyAcceleratedCheckouts/Wallets/ApplePay/ApplePayViewController.swift b/platforms/swift/Sources/ShopifyAcceleratedCheckouts/Wallets/ApplePay/ApplePayViewController.swift index 24755f4ae..18f0a2255 100644 --- a/platforms/swift/Sources/ShopifyAcceleratedCheckouts/Wallets/ApplePay/ApplePayViewController.swift +++ b/platforms/swift/Sources/ShopifyAcceleratedCheckouts/Wallets/ApplePay/ApplePayViewController.swift @@ -7,6 +7,7 @@ import SwiftUI protocol PayController: AnyObject { var cart: StorefrontAPI.Types.Cart? { get set } var storefront: StorefrontAPIProtocol { get set } + var onCheckoutDismiss: (() -> Void)? { get } /// Opens ShopifyCheckoutKit func present(url: URL) async throws @@ -150,4 +151,11 @@ class ApplePayViewController: WalletController, PayController { } super.checkoutDidComplete(event) } + + override func checkoutDidDismiss() { + Task { @MainActor in + try? await authorizationDelegate.transition(to: .completed) + } + super.checkoutDidDismiss() + } } diff --git a/platforms/swift/Tests/ShopifyAcceleratedCheckoutsTests/Wallets/ApplePay/ApplePayAuthorizationDelegate/ApplePayAuthorizationDelegateControllerTests.swift b/platforms/swift/Tests/ShopifyAcceleratedCheckoutsTests/Wallets/ApplePay/ApplePayAuthorizationDelegate/ApplePayAuthorizationDelegateControllerTests.swift index c7ed15aa2..cdc8b1dd1 100644 --- a/platforms/swift/Tests/ShopifyAcceleratedCheckoutsTests/Wallets/ApplePay/ApplePayAuthorizationDelegate/ApplePayAuthorizationDelegateControllerTests.swift +++ b/platforms/swift/Tests/ShopifyAcceleratedCheckoutsTests/Wallets/ApplePay/ApplePayAuthorizationDelegate/ApplePayAuthorizationDelegateControllerTests.swift @@ -554,6 +554,7 @@ final class ApplePayAuthorizationDelegateControllerTests: XCTestCase { private class MockPayController: PayController { var cart: StorefrontAPI.Types.Cart? var storefront: StorefrontAPIProtocol + var onCheckoutDismiss: (() -> Void)? init() { let cfg = ShopifyAcceleratedCheckouts.Configuration.testConfiguration diff --git a/platforms/swift/Tests/ShopifyAcceleratedCheckoutsTests/Wallets/ApplePay/ApplePayAuthorizationDelegate/ApplePayAuthorizationDelegateTests.swift b/platforms/swift/Tests/ShopifyAcceleratedCheckoutsTests/Wallets/ApplePay/ApplePayAuthorizationDelegate/ApplePayAuthorizationDelegateTests.swift index e9d5c312e..84be88da3 100644 --- a/platforms/swift/Tests/ShopifyAcceleratedCheckoutsTests/Wallets/ApplePay/ApplePayAuthorizationDelegate/ApplePayAuthorizationDelegateTests.swift +++ b/platforms/swift/Tests/ShopifyAcceleratedCheckoutsTests/Wallets/ApplePay/ApplePayAuthorizationDelegate/ApplePayAuthorizationDelegateTests.swift @@ -444,8 +444,8 @@ final class ApplePayAuthorizationDelegateTests: XCTestCase { } /// User cancels the sheet without authorizing payment - func test_onCompleted_withDefaultCase_shouldTransitionToReset() async throws { - // Start with appleSheetPresented (a state that falls into default case) + func test_onCompleted_withAppleSheetPresented_shouldTransitionToReset() async throws { + // Start with appleSheetPresented try await delegate.transition(to: .startPaymentRequest) XCTAssertEqual(delegate.state, .appleSheetPresented) @@ -459,6 +459,16 @@ final class ApplePayAuthorizationDelegateTests: XCTestCase { ) } + func test_onCompleted_withAppleSheetPresented_shouldNotifyDismissal() async throws { + var dismissalCount = 0 + mockController.onCheckoutDismiss = { dismissalCount += 1 } + + try await delegate.transition(to: .startPaymentRequest) + try await delegate.transition(to: .completed) + + XCTAssertEqual(dismissalCount, 1) + } + // MARK: onPresentingCheckoutKit() func test_onPresentingCheckoutKit_withValidURL_shouldCallPresentSuccessfully() async throws { @@ -670,6 +680,7 @@ final class ApplePayAuthorizationDelegateTests: XCTestCase { var presentCallCount = 0 var presentCalledWith: URL? + var onCheckoutDismiss: (() -> Void)? init() { let config = ShopifyAcceleratedCheckouts.Configuration.testConfiguration @@ -688,6 +699,7 @@ final class ApplePayAuthorizationDelegateTests: XCTestCase { private class FailingMockPayController: PayController { var cart: StorefrontAPI.Types.Cart? var storefront: StorefrontAPIProtocol + var onCheckoutDismiss: (() -> Void)? var presentCallCount = 0 @@ -708,6 +720,7 @@ final class ApplePayAuthorizationDelegateTests: XCTestCase { private class SpyPayController: PayController { var cart: StorefrontAPI.Types.Cart? var storefront: StorefrontAPIProtocol + var onCheckoutDismiss: (() -> Void)? var presentCallCount = 0 var presentCalledWith: URL? diff --git a/platforms/swift/Tests/ShopifyAcceleratedCheckoutsTests/Wallets/ApplePay/ApplePayViewControllerTests.swift b/platforms/swift/Tests/ShopifyAcceleratedCheckoutsTests/Wallets/ApplePay/ApplePayViewControllerTests.swift index c5d3b591e..993d90a84 100644 --- a/platforms/swift/Tests/ShopifyAcceleratedCheckoutsTests/Wallets/ApplePay/ApplePayViewControllerTests.swift +++ b/platforms/swift/Tests/ShopifyAcceleratedCheckoutsTests/Wallets/ApplePay/ApplePayViewControllerTests.swift @@ -176,6 +176,23 @@ class ApplePayViewControllerTests: XCTestCase { XCTAssertEqual(mockAuthorizationDelegate.transitionHistory, [.completed]) } + func test_checkoutDidDismiss_whenPresentedCheckoutDismisses_invokesOnDismissCallback() async throws { + let dismissCallbackExpectation = expectation(description: "Dismiss callback should be invoked") + viewController.eventHandlers.checkoutDidDismiss = { dismissCallbackExpectation.fulfill() } + viewController.mockTopViewController = UIViewController() + + let checkoutURL = try XCTUnwrap(URL(string: "https://test-shop.myshopify.com/checkout")) + try await viewController.present(url: checkoutURL) + + let checkoutViewController = try XCTUnwrap(viewController.checkoutViewController) + let webViewController = try XCTUnwrap( + checkoutViewController.viewControllers.first as? CheckoutWebViewController + ) + webViewController.close() + + await fulfillment(of: [dismissCallbackExpectation], timeout: 1.0) + } + // MARK: - WalletController Inheritance func test_configuration_whenInitialized_usesCorrectStorefront() { diff --git a/platforms/swift/Tests/ShopifyAcceleratedCheckoutsTests/Wallets/ShopPay/ShopPayCallbackTests.swift b/platforms/swift/Tests/ShopifyAcceleratedCheckoutsTests/Wallets/ShopPay/ShopPayCallbackTests.swift index d6f3769cd..de93ec6fb 100644 --- a/platforms/swift/Tests/ShopifyAcceleratedCheckoutsTests/Wallets/ShopPay/ShopPayCallbackTests.swift +++ b/platforms/swift/Tests/ShopifyAcceleratedCheckoutsTests/Wallets/ShopPay/ShopPayCallbackTests.swift @@ -7,12 +7,20 @@ import XCTest final class ShopPayCallbackTests: XCTestCase { // MARK: - Properties - var viewController: ShopPayViewController! + var viewController: MockShopPayViewController! var mockConfiguration: ShopifyAcceleratedCheckouts.Configuration! var mockIdentifier: CheckoutIdentifier! var errorExpectation: XCTestExpectation! var dismissExpectation: XCTestExpectation! + final class MockShopPayViewController: ShopPayViewController { + let mockTopViewController = UIViewController() + + override func getTopViewController() -> UIViewController? { + mockTopViewController + } + } + // MARK: - Setup override func setUp() async throws { @@ -25,7 +33,7 @@ final class ShopPayCallbackTests: XCTestCase { mockIdentifier = .cart(cartID: "gid://Shopify/Cart/test-cart-id") - viewController = ShopPayViewController( + viewController = MockShopPayViewController( identifier: mockIdentifier, configuration: mockConfiguration ) @@ -96,6 +104,24 @@ final class ShopPayCallbackTests: XCTestCase { XCTAssertTrue(true, "Should not crash when callback is nil") } + func testDismissCallbackInvokedWhenPresentedCheckoutDismisses() async throws { + let dismissExpectation = expectation(description: "Dismiss callback should be invoked") + viewController.eventHandlers = EventHandlers( + checkoutDidDismiss: { dismissExpectation.fulfill() } + ) + + let checkoutURL = try XCTUnwrap(URL(string: "https://test-shop.myshopify.com/checkout")) + try await viewController.present(url: checkoutURL) + + let checkoutViewController = try XCTUnwrap(viewController.checkoutViewController) + let webViewController = try XCTUnwrap( + checkoutViewController.viewControllers.first as? CheckoutWebViewController + ) + webViewController.close() + + await fulfillment(of: [dismissExpectation], timeout: 1.0) + } + // MARK: - Delegate Tests @MainActor From 33bcb0b0411b2e7e942e8b592da9e53bef7d4177 Mon Sep 17 00:00:00 2001 From: Kieran Osgood Date: Fri, 11 Sep 2026 15:49:49 +0100 Subject: [PATCH 2/3] Clarify Apple Pay completion routing Assisted-By: devx/3f0e7f93-212d-45d8-b056-138b2da8389f --- .../ApplePayAuthorizationDelegate.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/platforms/swift/Sources/ShopifyAcceleratedCheckouts/Wallets/ApplePay/ApplePayAuthorizationDelegate/ApplePayAuthorizationDelegate.swift b/platforms/swift/Sources/ShopifyAcceleratedCheckouts/Wallets/ApplePay/ApplePayAuthorizationDelegate/ApplePayAuthorizationDelegate.swift index 44e32ec54..b891ebb77 100644 --- a/platforms/swift/Sources/ShopifyAcceleratedCheckouts/Wallets/ApplePay/ApplePayAuthorizationDelegate/ApplePayAuthorizationDelegate.swift +++ b/platforms/swift/Sources/ShopifyAcceleratedCheckouts/Wallets/ApplePay/ApplePayAuthorizationDelegate/ApplePayAuthorizationDelegate.swift @@ -188,21 +188,27 @@ class ApplePayAuthorizationDelegate: NSObject, ObservableObject { try? await controller.present(url: url) } + /// Completion is a routing state and does not necessarily mean the buyer dismissed checkout. private func onCompleted(previousState: ApplePayState) async throws { switch previousState { case .paymentAuthorizationFailed, .unexpectedError, .interrupt: + // These states leave Apple Pay and recover in Checkout Kit, so the experience remains open. try await transition(to: .presentingCheckoutKit(url: createCheckoutKitURL(for: previousState))) case let .cartSubmittedForCompletion(redirectURL): + // Continue to the thank-you page or checkout fallback, so the experience remains open. try await transition(to: .presentingCheckoutKit(url: redirectURL)) case .appleSheetPresented: + // PassKit finishing before the state advances uniquely identifies native Apple Pay dismissal. controller.onCheckoutDismiss?() try await transition(to: .reset) default: + // Setup, error, and Checkout Kit lifecycle completion do not imply dismissal. + // Checkout Kit forwards actual dismissal through ApplePayViewController.checkoutDidDismiss(). try await transition(to: .reset) } } From b02476aa6b6cc8420253ad5f5ce02c09c10e8f8d Mon Sep 17 00:00:00 2001 From: Kieran Osgood Date: Wed, 7 Oct 2026 12:49:26 +0100 Subject: [PATCH 3/3] Skip Apple Pay completion when checkout already finished it Checkout completion resets the Apple Pay state machine to idle before the buyer closes the confirmation page. The later dismissal attempted idle -> completed and logged InvalidStateTransitionError. Assisted-By: omp --- .../Wallets/ApplePay/ApplePayViewController.swift | 2 ++ .../ApplePay/ApplePayViewControllerTests.swift | 14 ++++++++++++++ 2 files changed, 16 insertions(+) diff --git a/platforms/swift/Sources/ShopifyAcceleratedCheckouts/Wallets/ApplePay/ApplePayViewController.swift b/platforms/swift/Sources/ShopifyAcceleratedCheckouts/Wallets/ApplePay/ApplePayViewController.swift index 18f0a2255..efcf31418 100644 --- a/platforms/swift/Sources/ShopifyAcceleratedCheckouts/Wallets/ApplePay/ApplePayViewController.swift +++ b/platforms/swift/Sources/ShopifyAcceleratedCheckouts/Wallets/ApplePay/ApplePayViewController.swift @@ -154,6 +154,8 @@ class ApplePayViewController: WalletController, PayController { override func checkoutDidDismiss() { Task { @MainActor in + // Completion may already have finished the Apple Pay flow before the buyer closes checkout. + guard case .presentingCheckoutKit = authorizationDelegate.state else { return } try? await authorizationDelegate.transition(to: .completed) } super.checkoutDidDismiss() diff --git a/platforms/swift/Tests/ShopifyAcceleratedCheckoutsTests/Wallets/ApplePay/ApplePayViewControllerTests.swift b/platforms/swift/Tests/ShopifyAcceleratedCheckoutsTests/Wallets/ApplePay/ApplePayViewControllerTests.swift index 993d90a84..019cf9c5e 100644 --- a/platforms/swift/Tests/ShopifyAcceleratedCheckoutsTests/Wallets/ApplePay/ApplePayViewControllerTests.swift +++ b/platforms/swift/Tests/ShopifyAcceleratedCheckoutsTests/Wallets/ApplePay/ApplePayViewControllerTests.swift @@ -193,6 +193,20 @@ class ApplePayViewControllerTests: XCTestCase { await fulfillment(of: [dismissCallbackExpectation], timeout: 1.0) } + func test_checkoutDidDismiss_afterCompletionFinishedApplePay_forwardsDismissalWithoutTransitioning() async { + var dismissalCount = 0 + viewController.eventHandlers.checkoutDidDismiss = { dismissalCount += 1 } + XCTAssertEqual(mockAuthorizationDelegate.state, .idle) + + viewController.checkoutDidDismiss() + for _ in 0 ..< 10 { + await Task.yield() + } + + XCTAssertEqual(dismissalCount, 1) + XCTAssertEqual(mockAuthorizationDelegate.transitionHistory, []) + } + // MARK: - WalletController Inheritance func test_configuration_whenInitialized_usesCorrectStorefront() {