diff --git a/Samples/MobileBuyIntegration/Scripts/generate_entitlements.sh b/Samples/MobileBuyIntegration/Scripts/generate_entitlements.sh index 516ba7ca7..8a284e34e 100755 --- a/Samples/MobileBuyIntegration/Scripts/generate_entitlements.sh +++ b/Samples/MobileBuyIntegration/Scripts/generate_entitlements.sh @@ -3,7 +3,7 @@ set -e CONFIG_FILE="Storefront.xcconfig" -STOREFRONT_DOMAIN=$(grep '^STOREFRONT_DOMAIN' "$CONFIG_FILE" | cut -d '=' -f2 | tr -d ' ') +STOREFRONT_DOMAIN=$(grep '^[[:space:]]*STOREFRONT_DOMAIN' "$CONFIG_FILE" | cut -d '=' -f2 | tr -d ' ') TEMPLATE_FILE="MobileBuyIntegration/MobileBuyIntegration.entitlements.template" OUTPUT_FILE="MobileBuyIntegration/MobileBuyIntegration.entitlements" diff --git a/Sources/ShopifyAcceleratedCheckouts/Internal/StorefrontAPI/StorefrontAPI+Mutations.swift b/Sources/ShopifyAcceleratedCheckouts/Internal/StorefrontAPI/StorefrontAPI+Mutations.swift index 9e28b2989..33924c759 100644 --- a/Sources/ShopifyAcceleratedCheckouts/Internal/StorefrontAPI/StorefrontAPI+Mutations.swift +++ b/Sources/ShopifyAcceleratedCheckouts/Internal/StorefrontAPI/StorefrontAPI+Mutations.swift @@ -398,7 +398,6 @@ extension StorefrontAPI { /// Remove personal data from cart /// - Parameter id: Cart ID - /// Available since 2025-07 - must be called with a custom StorefrontAPI func cartRemovePersonalData(id: GraphQLScalars.ID) async throws { let variables: [String: Any] = [ "cartId": id.rawValue @@ -446,11 +445,13 @@ extension StorefrontAPI { throw GraphQLError.networkError( "Cart preparation throttled. Poll after: \(throttled.pollAfter.date)" ) - case let .notReady(notReady): - let errorMessages = notReady.errors.map { "\($0.code): \($0.message)" }.joined( - separator: ", " + case .notReady: + let errorMessages = payload.result.map { "\($0)" } ?? "unknown" + throw Errors.response( + requestName: "cartPrepareForCompletion", + message: "Cart not ready: \(errorMessages)", + payload: .cartPrepareForCompletion(payload) ) - throw GraphQLError.networkError("Cart not ready: \(errorMessages)") } } diff --git a/Sources/ShopifyAcceleratedCheckouts/Internal/StorefrontAPI/StorefrontAPI+Types.swift b/Sources/ShopifyAcceleratedCheckouts/Internal/StorefrontAPI/StorefrontAPI+Types.swift index 3dc59e441..4cb4547bc 100644 --- a/Sources/ShopifyAcceleratedCheckouts/Internal/StorefrontAPI/StorefrontAPI+Types.swift +++ b/Sources/ShopifyAcceleratedCheckouts/Internal/StorefrontAPI/StorefrontAPI+Types.swift @@ -594,86 +594,7 @@ extension StorefrontAPI { /// Cart status not ready struct CartStatusNotReady: Codable { let cart: Cart? - let errors: [CompletionError] - } - - /// Completion error - struct CompletionError: Codable { - let code: CompletionErrorCode - let message: String - } - - /// Completion error codes - enum CompletionErrorCode: String, Codable { - case error = "ERROR" - case inventoryLocationNotFound = "INVENTORY_LOCATION_NOT_FOUND" - case paymentAmountTooSmall = "PAYMENT_AMOUNT_TOO_SMALL" - case paymentCallIssuer = "PAYMENT_CALL_ISSUER" - case paymentCardDeclined = "PAYMENT_CARD_DECLINED" - case paymentError = "PAYMENT_ERROR" - case paymentGatewayNotEnabledForShop = "PAYMENT_GATEWAY_NOT_ENABLED_FOR_SHOP" - case paymentInsufficientFunds = "PAYMENT_INSUFFICIENT_FUNDS" - case paymentInvalidAmount = "PAYMENT_INVALID_AMOUNT" - case paymentInvalidBillingAddress = "PAYMENT_INVALID_BILLING_ADDRESS" - case paymentInvalidCreditCard = "PAYMENT_INVALID_CREDIT_CARD" - case paymentInvalidCurrency = "PAYMENT_INVALID_CURRENCY" - case paymentInvalidPaymentMethod = "PAYMENT_INVALID_PAYMENT_METHOD" - case paymentTransientError = "PAYMENT_TRANSIENT_ERROR" - case billingAddressInvalid = "BILLING_ADDRESS_INVALID" - case checkoutCompletionTargetInvalid = "CHECKOUT_COMPLETION_TARGET_INVALID" - case colorInvalid = "COLOR_INVALID" - case deliveryAddress1Invalid = "DELIVERY_ADDRESS1_INVALID" - case deliveryAddress1Missing = "DELIVERY_ADDRESS1_MISSING" - case deliveryAddress1TooLong = "DELIVERY_ADDRESS1_TOO_LONG" - case deliveryAddress2Invalid = "DELIVERY_ADDRESS2_INVALID" - case deliveryAddress2Required = "DELIVERY_ADDRESS2_REQUIRED" - case deliveryAddress2TooLong = "DELIVERY_ADDRESS2_TOO_LONG" - case deliveryAddressInvalid = "DELIVERY_ADDRESS_INVALID" - case deliveryAddressMissing = "DELIVERY_ADDRESS_MISSING" - case deliveryAddressRequired = "DELIVERY_ADDRESS_REQUIRED" - case deliveryOptionInvalid = "DELIVERY_OPTION_INVALID" - case deliveryOptionsMissing = "DELIVERY_OPTIONS_MISSING" - case deliveryPostalCodeInvalid = "DELIVERY_POSTAL_CODE_INVALID" - case deliveryPostalCodeRequired = "DELIVERY_POSTAL_CODE_REQUIRED" - case deliveryZoneNotFound = "DELIVERY_ZONE_NOT_FOUND" - case deliveryZoneRequiredForCountry = "DELIVERY_ZONE_REQUIRED_FOR_COUNTRY" - case deliveryCityInvalid = "DELIVERY_CITY_INVALID" - case deliveryCityRequired = "DELIVERY_CITY_REQUIRED" - case deliveryCityTooLong = "DELIVERY_CITY_TOO_LONG" - case deliveryCompanyInvalid = "DELIVERY_COMPANY_INVALID" - case deliveryCompanyRequired = "DELIVERY_COMPANY_REQUIRED" - case deliveryCompanyTooLong = "DELIVERY_COMPANY_TOO_LONG" - case deliveryCountryRequired = "DELIVERY_COUNTRY_REQUIRED" - case deliveryFirstNameInvalid = "DELIVERY_FIRST_NAME_INVALID" - case deliveryFirstNameRequired = "DELIVERY_FIRST_NAME_REQUIRED" - case deliveryFirstNameTooLong = "DELIVERY_FIRST_NAME_TOO_LONG" - case deliveryInvalidPostalCodeForCountry = "DELIVERY_INVALID_POSTAL_CODE_FOR_COUNTRY" - case deliveryInvalidPostalCodeForZone = "DELIVERY_INVALID_POSTAL_CODE_FOR_ZONE" - case deliveryLastNameInvalid = "DELIVERY_LAST_NAME_INVALID" - case deliveryLastNameTooLong = "DELIVERY_LAST_NAME_TOO_LONG" - case deliveryNoDeliveryAvailable = "DELIVERY_NO_DELIVERY_AVAILABLE" - case deliveryNoDeliveryAvailableForMerchandiseLine = "DELIVERY_NO_DELIVERY_AVAILABLE_FOR_MERCHANDISE_LINE" - case deliveryOptionsPhoneNumberInvalid = "DELIVERY_OPTIONS_PHONE_NUMBER_INVALID" - case deliveryOptionsPhoneNumberRequired = "DELIVERY_OPTIONS_PHONE_NUMBER_REQUIRED" - case deliveryPhoneNumberInvalid = "DELIVERY_PHONE_NUMBER_INVALID" - case deliveryPhoneNumberRequired = "DELIVERY_PHONE_NUMBER_REQUIRED" - case emailInvalid = "EMAIL_INVALID" - case firstNameInvalid = "FIRST_NAME_INVALID" - case firstNameRequired = "FIRST_NAME_REQUIRED" - case firstNameTooLong = "FIRST_NAME_TOO_LONG" - case functionInvalid = "FUNCTION_INVALID" - case invalidAddress = "INVALID_ADDRESS" - case lastNameInvalid = "LAST_NAME_INVALID" - case lastNameRequired = "LAST_NAME_REQUIRED" - case lastNameTooLong = "LAST_NAME_TOO_LONG" - case deliveryLastNameRequired = "DELIVERY_LAST_NAME_REQUIRED" - case noDeliveryGroupSelected = "NO_DELIVERY_GROUP_SELECTED" - case paymentBillingAddressInvalid = "PAYMENT_BILLING_ADDRESS_INVALID" - case paymentInvalid = "PAYMENT_INVALID" - case paymentMethodInvalid = "PAYMENT_METHOD_INVALID" - case paymentMethodRequired = "PAYMENT_METHOD_REQUIRED" - case phoneInvalid = "PHONE_INVALID" - case unknown = "UNKNOWN" + let errors: [CartCompletionError] } /// Cart submit for completion payload @@ -742,7 +663,7 @@ extension StorefrontAPI { /// Submit failed struct SubmitFailed: Codable { let checkoutUrl: GraphQLScalars.URL? - let errors: [SubmissionError] + let errors: [CartCompletionError] } /// Submit already accepted @@ -755,14 +676,32 @@ extension StorefrontAPI { let pollAfter: GraphQLScalars.DateTime } - /// Submission error - struct SubmissionError: Codable { - let code: SubmissionErrorCode + /// Cart completion error (used by both cartPrepareForCompletion and cartSubmitForCompletion) + struct CartCompletionError: Codable { + let code: CartCompletionErrorCode + let rawCode: String let message: String + + init(code: CartCompletionErrorCode, message: String) { + self.code = code + rawCode = code.rawValue + self.message = message + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + rawCode = try container.decode(String.self, forKey: .code) + code = CartCompletionErrorCode(rawValue: rawCode) ?? .unknownValue + message = try container.decode(String.self, forKey: .message) + } + + private enum CodingKeys: String, CodingKey { + case code, message + } } - /// Submission error codes for cart submit - enum SubmissionErrorCode: String, Codable { + /// Error codes for cart completion (shared by prepare and submit stages) + enum CartCompletionErrorCode: String, Codable { // Buyer identity errors case buyerIdentityEmailIsInvalid = "BUYER_IDENTITY_EMAIL_IS_INVALID" case buyerIdentityEmailRequired = "BUYER_IDENTITY_EMAIL_REQUIRED" @@ -883,7 +822,7 @@ extension StorefrontAPI { init(from decoder: Decoder) throws { let container = try decoder.singleValueContainer() let value = try container.decode(String.self) - self = SubmissionErrorCode(rawValue: value) ?? .unknownValue + self = CartCompletionErrorCode(rawValue: value) ?? .unknownValue } } diff --git a/Sources/ShopifyAcceleratedCheckouts/ShopifyAcceleratedCheckouts.swift b/Sources/ShopifyAcceleratedCheckouts/ShopifyAcceleratedCheckouts.swift index 87cd1d256..399ff0fe7 100644 --- a/Sources/ShopifyAcceleratedCheckouts/ShopifyAcceleratedCheckouts.swift +++ b/Sources/ShopifyAcceleratedCheckouts/ShopifyAcceleratedCheckouts.swift @@ -25,8 +25,7 @@ import ShopifyCheckoutSheetKit public enum ShopifyAcceleratedCheckouts { /// Storefront API version used for cart operations - /// Note: We also use `2025-07` for `cartRemovePersonalData` mutations. We are working towards migrating all requests to `2025-07`. - internal static let apiVersion = "2025-04" + internal static let apiVersion = "2025-07" internal static let name = "ShopifyAcceleratedCheckouts" diff --git a/Sources/ShopifyAcceleratedCheckouts/Wallets/ApplePay/ApplePayAuthorizationDelegate/ApplePayAuthorizationDelegate+Controller.swift b/Sources/ShopifyAcceleratedCheckouts/Wallets/ApplePay/ApplePayAuthorizationDelegate/ApplePayAuthorizationDelegate+Controller.swift index c86193f1d..2251f8401 100644 --- a/Sources/ShopifyAcceleratedCheckouts/Wallets/ApplePay/ApplePayAuthorizationDelegate/ApplePayAuthorizationDelegate+Controller.swift +++ b/Sources/ShopifyAcceleratedCheckouts/Wallets/ApplePay/ApplePayAuthorizationDelegate/ApplePayAuthorizationDelegate+Controller.swift @@ -62,11 +62,10 @@ extension ApplePayAuthorizationDelegate: PKPaymentAuthorizationControllerDelegat try await upsertShippingAddress(to: shippingAddress) - let result = try await controller.storefront.cartPrepareForCompletion(id: cartID) - try setCart(to: result.cart) + try await prepareCartForCompletion(id: cartID) // If address update cleared delivery groups, revert to previous cart and show error - if result.cart?.deliveryGroups.nodes.isEmpty == true, previousCart?.deliveryGroups.nodes.isEmpty == false { + if controller.cart?.deliveryGroups.nodes.isEmpty == true, previousCart?.deliveryGroups.nodes.isEmpty == false { try setCart(to: previousCart) ShopifyAcceleratedCheckouts.logger.error("ApplePay: didSelectShippingContact deliveryGroups were unexpectedly empty") @@ -120,8 +119,7 @@ extension ApplePayAuthorizationDelegate: PKPaymentAuthorizationControllerDelegat billingAddress: billingPostalAddress ) - let result = try await controller.storefront.cartPrepareForCompletion(id: cartID) - try setCart(to: result.cart) + try await prepareCartForCompletion(id: cartID) return pkDecoder.paymentRequestPaymentMethodUpdate() } catch { @@ -162,9 +160,7 @@ extension ApplePayAuthorizationDelegate: PKPaymentAuthorizationControllerDelegat deliveryOptionHandle: selectedDeliveryOptionHandle.rawValue ) - let result = try await controller.storefront.cartPrepareForCompletion(id: cartID) - - try setCart(to: result.cart) + try await prepareCartForCompletion(id: cartID) return pkDecoder.paymentRequestShippingMethodUpdate() } catch { @@ -198,8 +194,7 @@ extension ApplePayAuthorizationDelegate: PKPaymentAuthorizationControllerDelegat customerAccessToken: configuration.common.customer?.customerAccessToken ) ) - let result = try await controller.storefront.cartPrepareForCompletion(id: cartID) - try setCart(to: result.cart) + try await prepareCartForCompletion(id: cartID) } if try pkDecoder.isShippingRequired() { @@ -214,8 +209,7 @@ extension ApplePayAuthorizationDelegate: PKPaymentAuthorizationControllerDelegat try await reapplySelectedShippingMethod() } - let result = try await controller.storefront.cartPrepareForCompletion(id: cartID) - try setCart(to: result.cart) + try await prepareCartForCompletion(id: cartID) } else { // If the cart is entirely digital updating with a complete billingAddress // allows us to resolve pending terms on taxes prior to cartPaymentUpdate @@ -230,8 +224,7 @@ extension ApplePayAuthorizationDelegate: PKPaymentAuthorizationControllerDelegat billingAddress: billingPostalAddress ) - let result = try await controller.storefront.cartPrepareForCompletion(id: cartID) - try setCart(to: result.cart) + try await prepareCartForCompletion(id: cartID) } let totalAmount = try pkEncoder.totalAmount.get() @@ -301,7 +294,7 @@ extension ApplePayAuthorizationDelegate: PKPaymentAuthorizationControllerDelegat } let cartID = try pkEncoder.cartID.get() - try await controller.storefront.cartPrepareForCompletion(id: cartID) + _ = try await prepareCartForCompletion(id: cartID) try await controller.storefront.cartSelectedDeliveryOptionsUpdate( id: cartID, deliveryGroupId: deliveryGroupID, @@ -309,6 +302,34 @@ extension ApplePayAuthorizationDelegate: PKPaymentAuthorizationControllerDelegat ) } + // `prepareCartForCompletion` filters 'non-terminal' violations + // See: `ErrorHandler.filterApplePayResolvableViolations` + // + // Before `didAuthorizePayment`, Apple Pay redacts PII, these are expected to be resolved by submit + // After `didAuthorizePayment`, PII is available, but we continue filtering in `prepareForCompletion` so + // `cartSubmitForCompletion` can return all violations at once + // This gives the user the best chance to fix everything in a single pass. + // + @discardableResult + func prepareCartForCompletion(id: GraphQLScalars.ID) async throws -> StorefrontAPI.Cart? { + do { + let result = try await controller.storefront.cartPrepareForCompletion(id: id) + try setCart(to: result.cart) + return result.cart + } catch let error as StorefrontAPI.Errors { + if case let .response(_, _, .cartPrepareForCompletion(payload)) = error, + case let .notReady(notReady) = payload.result + { + let actionableErrors = ErrorHandler.filterApplePayResolvableViolations(errors: notReady.errors) + if actionableErrors.isEmpty { + try setCart(to: notReady.cart) + return notReady.cart + } + } + throw error + } + } + func handleError( error: Error, cart _: StorefrontAPI.Cart?, @@ -329,6 +350,8 @@ extension ApplePayAuthorizationDelegate: PKPaymentAuthorizationControllerDelegat self.checkoutURL = checkoutURL } return completion([abortError]) + case .continueFlow: + return completion([]) } } } diff --git a/Sources/ShopifyAcceleratedCheckouts/Wallets/ApplePay/ApplePayAuthorizationDelegate/ApplePayAuthorizationDelegate.swift b/Sources/ShopifyAcceleratedCheckouts/Wallets/ApplePay/ApplePayAuthorizationDelegate/ApplePayAuthorizationDelegate.swift index 51d597516..8be3d5751 100644 --- a/Sources/ShopifyAcceleratedCheckouts/Wallets/ApplePay/ApplePayAuthorizationDelegate/ApplePayAuthorizationDelegate.swift +++ b/Sources/ShopifyAcceleratedCheckouts/Wallets/ApplePay/ApplePayAuthorizationDelegate/ApplePayAuthorizationDelegate.swift @@ -174,7 +174,7 @@ class ApplePayAuthorizationDelegate: NSObject, ObservableObject { default: let cartID = try pkEncoder.cartID.get() try? await _Concurrency.Task.retrying(clock: clock) { - try await self.controller.storefrontJulyRelease.cartRemovePersonalData(id: cartID) + try await self.controller.storefront.cartRemovePersonalData(id: cartID) }.value ShopifyAcceleratedCheckouts.logger.debug("Cleared PII from cart") diff --git a/Sources/ShopifyAcceleratedCheckouts/Wallets/ApplePay/ApplePayViewController.swift b/Sources/ShopifyAcceleratedCheckouts/Wallets/ApplePay/ApplePayViewController.swift index 9ef83f6de..b00f9238c 100644 --- a/Sources/ShopifyAcceleratedCheckouts/Wallets/ApplePay/ApplePayViewController.swift +++ b/Sources/ShopifyAcceleratedCheckouts/Wallets/ApplePay/ApplePayViewController.swift @@ -29,8 +29,6 @@ import SwiftUI protocol PayController: AnyObject { var cart: StorefrontAPI.Types.Cart? { get set } var storefront: StorefrontAPIProtocol { get set } - /// Temporary workaround due to July release changing the validation strategy - var storefrontJulyRelease: StorefrontAPIProtocol { get set } /// Opens ShopifyCheckoutSheetKit func present(url: URL) async throws @@ -38,7 +36,6 @@ protocol PayController: AnyObject { @available(iOS 16.0, *) class ApplePayViewController: WalletController, PayController { - @Published var storefrontJulyRelease: StorefrontAPIProtocol @Published var paymentController: PKPaymentAuthorizationController? var cart: StorefrontAPI.Types.Cart? @@ -134,11 +131,6 @@ class ApplePayViewController: WalletController, PayController { identifier: CheckoutIdentifier, configuration: ApplePayConfigurationWrapper ) { - storefrontJulyRelease = StorefrontAPI( - storefrontDomain: configuration.common.storefrontDomain, - storefrontAccessToken: configuration.common.storefrontAccessToken, - apiVersion: "2025-07" - ) super.init( identifier: identifier, storefront: StorefrontAPI( diff --git a/Sources/ShopifyAcceleratedCheckouts/Wallets/ApplePay/ErrorHandler/ErrorHandler.swift b/Sources/ShopifyAcceleratedCheckouts/Wallets/ApplePay/ErrorHandler/ErrorHandler.swift index 5b9f9bb3e..12261a4c7 100644 --- a/Sources/ShopifyAcceleratedCheckouts/Wallets/ApplePay/ErrorHandler/ErrorHandler.swift +++ b/Sources/ShopifyAcceleratedCheckouts/Wallets/ApplePay/ErrorHandler/ErrorHandler.swift @@ -55,6 +55,7 @@ class ErrorHandler { enum PaymentSheetAction { case showError(errors: [Error]) case interrupt(reason: InterruptReason, checkoutURL: URL? = nil) + case continueFlow } static func useEmirate(shippingCountry: String?) -> Bool { @@ -99,9 +100,9 @@ class ErrorHandler { case let .response(_, _, payload): switch payload { case let .cartSubmitForCompletion(submitPayload): - return ErrorHandler.map(payload: submitPayload, shippingCountry: shippingCountry, requiredContactFields: requiredContactFields) + return ErrorHandler.map(stage: .submit(submitPayload), shippingCountry: shippingCountry, requiredContactFields: requiredContactFields) case let .cartPrepareForCompletion(preparePayload): - return ErrorHandler.map(payload: preparePayload) + return ErrorHandler.map(stage: .prepare(preparePayload), shippingCountry: shippingCountry, requiredContactFields: requiredContactFields) } case let .userError(userErrors, cart): return ErrorHandler.map(errors: userErrors, shippingCountry: shippingCountry, cart: cart, requiredContactFields: requiredContactFields) @@ -135,6 +136,8 @@ class ErrorHandler { return 1 case .showError: return 2 + case .continueFlow: + return 4 } } diff --git a/Sources/ShopifyAcceleratedCheckouts/Wallets/ApplePay/ErrorHandler/ErrorHandler_CartSubmitForCompletion.swift b/Sources/ShopifyAcceleratedCheckouts/Wallets/ApplePay/ErrorHandler/ErrorHandler_CartCompletion.swift similarity index 78% rename from Sources/ShopifyAcceleratedCheckouts/Wallets/ApplePay/ErrorHandler/ErrorHandler_CartSubmitForCompletion.swift rename to Sources/ShopifyAcceleratedCheckouts/Wallets/ApplePay/ErrorHandler/ErrorHandler_CartCompletion.swift index 27d52773e..4e4d6380b 100644 --- a/Sources/ShopifyAcceleratedCheckouts/Wallets/ApplePay/ErrorHandler/ErrorHandler_CartSubmitForCompletion.swift +++ b/Sources/ShopifyAcceleratedCheckouts/Wallets/ApplePay/ErrorHandler/ErrorHandler_CartCompletion.swift @@ -26,8 +26,66 @@ import PassKit @available(iOS 16.0, *) extension ErrorHandler { + enum CompletionStage { + case prepare(StorefrontAPI.CartPrepareForCompletionPayload) + case submit(StorefrontAPI.CartSubmitForCompletionPayload) + } + static func map( - payload: StorefrontAPI.CartSubmitForCompletionPayload, shippingCountry: String?, requiredContactFields: Set? = nil + stage: CompletionStage, shippingCountry: String?, requiredContactFields: Set? = nil + ) -> PaymentSheetAction { + switch stage { + case let .prepare(payload): + return map(payload: payload, shippingCountry: shippingCountry, requiredContactFields: requiredContactFields) + case let .submit(payload): + return map(payload: payload, shippingCountry: shippingCountry, requiredContactFields: requiredContactFields) + } + } + + // MARK: - Prepare + + private static func map( + payload: StorefrontAPI.CartPrepareForCompletionPayload, + shippingCountry: String?, + requiredContactFields: Set? + ) -> PaymentSheetAction { + guard let result = payload.result else { return PaymentSheetAction.interrupt(reason: .other) } + switch result { + case let .notReady(notReady): + guard !notReady.errors.isEmpty else { + return PaymentSheetAction.interrupt(reason: .cartNotReady) + } + let allCodes = notReady.errors.map { $0.rawCode }.joined(separator: ", ") + let actionableErrors = filterApplePayResolvableViolations(errors: notReady.errors) + if actionableErrors.isEmpty { + ShopifyAcceleratedCheckouts.logger.debug( + "ErrorHandler: prepare: all violations are Apple Pay resolvable, continuing flow. Filtered: [\(allCodes)]" + ) + return PaymentSheetAction.continueFlow + } + let actionableCodes = actionableErrors.map { $0.rawCode }.joined(separator: ", ") + ShopifyAcceleratedCheckouts.logger.error( + "ErrorHandler: prepare: actionable violations found: [\(actionableCodes)]. All violations: [\(allCodes)]" + ) + let filteredErrors = filterGenericViolations(errors: actionableErrors) + let actions = filteredErrors.map { + getErrorAction(error: $0, shippingCountry: shippingCountry, checkoutURL: nil, requiredContactFields: requiredContactFields) + } + return getHighestPriorityAction(actions: actions) + case .throttled: + return PaymentSheetAction.interrupt(reason: .cartThrottled) + case .ready: + ShopifyAcceleratedCheckouts.logger.error("ErrorHandler: map: received unexpected result type from Cart API on prepare") + return PaymentSheetAction.interrupt(reason: .other) + } + } + + // MARK: - Submit + + private static func map( + payload: StorefrontAPI.CartSubmitForCompletionPayload, + shippingCountry: String?, + requiredContactFields: Set? ) -> PaymentSheetAction { guard let result = payload.result else { return PaymentSheetAction.interrupt(reason: .other) } switch result { @@ -42,17 +100,20 @@ extension ErrorHandler { case .throttled: return PaymentSheetAction.interrupt(reason: .cartThrottled) case .success: - // No-op: error handler not called for success result - // Other response type are not possible ShopifyAcceleratedCheckouts.logger.error("ErrorHandler: map: received unexpected result type from Cart API on submit") return PaymentSheetAction.interrupt(reason: .other) } } + // MARK: - Shared Error Code Mapping + // swiftlint:disable:next cyclomatic_complexity - private static func getErrorAction(error: StorefrontAPI.SubmissionError, shippingCountry: String?, checkoutURL: URL?, requiredContactFields _: Set? = nil) - -> PaymentSheetAction - { + private static func getErrorAction( + error: StorefrontAPI.CartCompletionError, + shippingCountry: String?, + checkoutURL: URL?, + requiredContactFields _: Set? = nil + ) -> PaymentSheetAction { switch error.code { // --- Contact information --- @@ -353,28 +414,26 @@ extension ErrorHandler { // Address missing case .deliveryAddressRequired: - // No-op: We should not have called SubmitForCompletion with empty address return PaymentSheetAction.interrupt(reason: .unhandled, checkoutURL: checkoutURL) - // Phone number + // Phone number (on buyer identity, billing address, or delivery options — not fixable in Apple Pay) case .buyerIdentityPhoneIsInvalid, .deliveryOptionsPhoneNumberInvalid, .deliveryOptionsPhoneNumberRequired, .paymentsPhoneNumberInvalid, .paymentsPhoneNumberRequired: - // No-op: We save the phone number on delivery address, not buyer identity, billing address or delivery options return PaymentSheetAction.interrupt(reason: .unhandled, checkoutURL: checkoutURL) - // Company + // Company (not available in Apple Pay) case .deliveryCompanyRequired, .deliveryCompanyInvalid, .deliveryCompanyTooLong, .paymentsCompanyRequired, .paymentsCompanyInvalid, .paymentsCompanyTooLong: - // No-op: Not possible to get company field from Apple Pay return PaymentSheetAction.interrupt(reason: .unhandled, checkoutURL: checkoutURL) + // Credit card errors (not applicable to Apple Pay) case .paymentsCreditCardBaseExpired, .paymentsCreditCardBaseGatewayNotSupported, .paymentsCreditCardBaseInvalidStartDateOrIssueNumberForDebit, @@ -391,16 +450,14 @@ extension ErrorHandler { .paymentsCreditCardVerificationValueInvalidForCardType, .paymentsCreditCardYearExpired, .paymentsCreditCardYearInvalidExpiryYear: - // No-op: These are specific to direct payment methods, not Apple Pay return PaymentSheetAction.interrupt(reason: .unhandled, checkoutURL: checkoutURL) - // Payment Method Errors + // Payment method errors case .paymentsMethodRequired, .paymentsMethodUnavailable, .paymentsShopifyPaymentsRequired, .paymentsWalletContentMissing, .paymentCardDeclined: - // Payment method issues - not fixable by user input validation return PaymentSheetAction.interrupt(reason: .unhandled, checkoutURL: checkoutURL) case .paymentsUnacceptablePaymentAmount: @@ -417,7 +474,7 @@ extension ErrorHandler { .deliveryNoDeliveryAvailableForMerchandiseLine: return PaymentSheetAction.interrupt(reason: .outOfStock, checkoutURL: checkoutURL) - // Tax Errors + // Tax errors case .taxesDeliveryGroupIdNotFound, .taxesLineIdNotFound, .taxesMustBeDefined: @@ -427,7 +484,7 @@ extension ErrorHandler { case .validationCustom: return PaymentSheetAction.interrupt(reason: .other, checkoutURL: checkoutURL) - // Generic Errors + // Generic / unknown errors case .error, .redirectToCheckoutRequired, .unknownValue: @@ -435,12 +492,60 @@ extension ErrorHandler { } } - private static func filterGenericViolations(errors: [StorefrontAPI.SubmissionError]) -> [StorefrontAPI.SubmissionError] { - // If the only error is paymentsUnacceptablePaymentAmount, return it + // MARK: - Helpers + + private static let applePayResolvableViolationCodes: Set = [ + .deliveryAddress1Invalid, + .deliveryAddress1Required, + .deliveryAddress1TooLong, + .deliveryAddress2Invalid, + .deliveryAddress2Required, + .deliveryAddress2TooLong, + .deliveryAddressRequired, + .deliveryCityInvalid, + .deliveryCityRequired, + .deliveryCityTooLong, + .deliveryCompanyInvalid, + .deliveryCompanyRequired, + .deliveryCompanyTooLong, + .deliveryCountryRequired, + .deliveryFirstNameInvalid, + .deliveryFirstNameRequired, + .deliveryFirstNameTooLong, + .deliveryInvalidPostalCodeForCountry, + .deliveryInvalidPostalCodeForZone, + .deliveryLastNameInvalid, + .deliveryLastNameRequired, + .deliveryLastNameTooLong, + .deliveryOptionsPhoneNumberInvalid, + .deliveryOptionsPhoneNumberRequired, + .deliveryPhoneNumberInvalid, + .deliveryPhoneNumberRequired, + .deliveryPostalCodeInvalid, + .deliveryPostalCodeRequired, + .deliveryZoneNotFound, + .deliveryZoneRequiredForCountry, + .buyerIdentityEmailIsInvalid, + .buyerIdentityEmailRequired, + .buyerIdentityPhoneIsInvalid, + + // When default address is non-serviable supported then sheet immediately closes + // During #583 - These may be remapped to .showError during completion stage + .deliveryNoDeliveryAvailableForMerchandiseLine, .merchandiseNotApplicable + ] + + static func filterApplePayResolvableViolations( + errors: [StorefrontAPI.CartCompletionError] + ) -> [StorefrontAPI.CartCompletionError] { + errors.filter { error in + !applePayResolvableViolationCodes.contains(error.code) + } + } + + private static func filterGenericViolations(errors: [StorefrontAPI.CartCompletionError]) -> [StorefrontAPI.CartCompletionError] { if errors.count == 1, errors.first?.code == .paymentsUnacceptablePaymentAmount { return errors } - // Otherwise, filter out paymentsUnacceptablePaymentAmount return errors.filter { $0.code != .paymentsUnacceptablePaymentAmount } } } diff --git a/Sources/ShopifyAcceleratedCheckouts/Wallets/ApplePay/ErrorHandler/ErrorHandler_CartPrepareForCompletion.swift b/Sources/ShopifyAcceleratedCheckouts/Wallets/ApplePay/ErrorHandler/ErrorHandler_CartPrepareForCompletion.swift deleted file mode 100644 index 4ccb0824a..000000000 --- a/Sources/ShopifyAcceleratedCheckouts/Wallets/ApplePay/ErrorHandler/ErrorHandler_CartPrepareForCompletion.swift +++ /dev/null @@ -1,42 +0,0 @@ -/* - MIT License - - Copyright 2023 - Present, Shopify Inc. - - Permission is hereby granted, free of charge, to any person obtaining a copy - of this software and associated documentation files (the "Software"), to deal - in the Software without restriction, including without limitation the rights - to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - copies of the Software, and to permit persons to whom the Software is - furnished to do so, subject to the following conditions: - - The above copyright notice and this permission notice shall be included in all - copies or substantial portions of the Software. - - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ - -import Foundation -import PassKit - -@available(iOS 16.0, *) -extension ErrorHandler { - static func map(payload: StorefrontAPI.CartPrepareForCompletionPayload) -> PaymentSheetAction { - guard let result = payload.result else { return PaymentSheetAction.interrupt(reason: .other) } - switch result { - case .notReady: - return PaymentSheetAction.interrupt(reason: .cartNotReady) - case .throttled: - return PaymentSheetAction.interrupt(reason: .cartThrottled) - case .ready: - // No-op: error handler not called for success result - ShopifyAcceleratedCheckouts.logger.error("ErrorHandler: map: received unexpected result type from Cart API on prepare") - return PaymentSheetAction.interrupt(reason: .other) - } - } -} diff --git a/Tests/ShopifyAcceleratedCheckoutsTests/Internal/StorefrontAPI/StorefrontAPIMutationsTests.swift b/Tests/ShopifyAcceleratedCheckoutsTests/Internal/StorefrontAPI/StorefrontAPIMutationsTests.swift index 8a237c129..f93f00cc2 100644 --- a/Tests/ShopifyAcceleratedCheckoutsTests/Internal/StorefrontAPI/StorefrontAPIMutationsTests.swift +++ b/Tests/ShopifyAcceleratedCheckoutsTests/Internal/StorefrontAPI/StorefrontAPIMutationsTests.swift @@ -1201,22 +1201,23 @@ final class StorefrontAPIMutationsTests: XCTestCase { _ = try await storefrontAPI.cartPrepareForCompletion(id: GraphQLScalars.ID("gid://shopify/Cart/123")) XCTFail("Expected error to be thrown") } catch { - // Verify error type - XCTAssertTrue( - error is GraphQLError, - "Unexpected error type: \(type(of: error))" - ) - - guard case let .networkError(message) = error as? GraphQLError else { - XCTFail("Expected GraphQLError.networkError but got: \(error)") + guard case let .response(requestName, message, payload) = error as? StorefrontAPI.Errors else { + XCTFail("Expected StorefrontAPI.Errors.response but got: \(error)") return } - // Check that it contains the expected error format + XCTAssertEqual(requestName, "cartPrepareForCompletion") XCTAssertTrue(message.contains("Cart not ready"), "Error message should contain 'Cart not ready': \(message)") - // The error codes are converted to camelCase in the error message - XCTAssertTrue(message.contains("deliveryAddressRequired"), "Error message should contain 'deliveryAddressRequired': \(message)") - XCTAssertTrue(message.contains("paymentMethodRequired"), "Error message should contain 'paymentMethodRequired': \(message)") + + guard case let .cartPrepareForCompletion(preparePayload) = payload, + case let .notReady(notReady) = preparePayload.result + else { + XCTFail("Expected cartPrepareForCompletion payload with notReady result") + return + } + + XCTAssertEqual(notReady.errors.count, 2) + XCTAssertEqual(notReady.errors[0].code, .deliveryAddressRequired) } } diff --git a/Tests/ShopifyAcceleratedCheckoutsTests/ShopifyAcceleratedCheckoutsTests.swift b/Tests/ShopifyAcceleratedCheckoutsTests/ShopifyAcceleratedCheckoutsTests.swift index 3d3193b33..7b22f671c 100644 --- a/Tests/ShopifyAcceleratedCheckoutsTests/ShopifyAcceleratedCheckoutsTests.swift +++ b/Tests/ShopifyAcceleratedCheckoutsTests/ShopifyAcceleratedCheckoutsTests.swift @@ -41,7 +41,7 @@ class ShopifyAcceleratedCheckoutsTests: XCTestCase { } func test_apiVersion_whenAccessed_shouldBePublic() { - XCTAssertEqual(ShopifyAcceleratedCheckouts.apiVersion, "2025-04") + XCTAssertEqual(ShopifyAcceleratedCheckouts.apiVersion, "2025-07") } func test_logLevel_withDefaultConfiguration_shouldDefaultToError() { diff --git a/Tests/ShopifyAcceleratedCheckoutsTests/Wallets/ApplePay/ApplePayAuthorizationDelegate/ApplePayAuthorizationDelegateControllerTests.swift b/Tests/ShopifyAcceleratedCheckoutsTests/Wallets/ApplePay/ApplePayAuthorizationDelegate/ApplePayAuthorizationDelegateControllerTests.swift index 595195f19..29be0fb97 100644 --- a/Tests/ShopifyAcceleratedCheckoutsTests/Wallets/ApplePay/ApplePayAuthorizationDelegate/ApplePayAuthorizationDelegateControllerTests.swift +++ b/Tests/ShopifyAcceleratedCheckoutsTests/Wallets/ApplePay/ApplePayAuthorizationDelegate/ApplePayAuthorizationDelegateControllerTests.swift @@ -574,12 +574,10 @@ final class ApplePayAuthorizationDelegateControllerTests: XCTestCase { private class MockPayController: PayController { var cart: StorefrontAPI.Types.Cart? var storefront: StorefrontAPIProtocol - var storefrontJulyRelease: StorefrontAPIProtocol init() { let cfg = ShopifyAcceleratedCheckouts.Configuration.testConfiguration storefront = StorefrontAPI(storefrontDomain: cfg.storefrontDomain, storefrontAccessToken: cfg.storefrontAccessToken) - storefrontJulyRelease = storefront } func present(url _: URL) async throws {} diff --git a/Tests/ShopifyAcceleratedCheckoutsTests/Wallets/ApplePay/ApplePayAuthorizationDelegate/ApplePayAuthorizationDelegateTests.swift b/Tests/ShopifyAcceleratedCheckoutsTests/Wallets/ApplePay/ApplePayAuthorizationDelegate/ApplePayAuthorizationDelegateTests.swift index fc2a98d64..6cc9626c1 100644 --- a/Tests/ShopifyAcceleratedCheckoutsTests/Wallets/ApplePay/ApplePayAuthorizationDelegate/ApplePayAuthorizationDelegateTests.swift +++ b/Tests/ShopifyAcceleratedCheckoutsTests/Wallets/ApplePay/ApplePayAuthorizationDelegate/ApplePayAuthorizationDelegateTests.swift @@ -537,7 +537,6 @@ final class ApplePayAuthorizationDelegateTests: XCTestCase { ) // Note: We can't easily verify that cartRemovePersonalData was NOT called - // because it uses storefrontJulyRelease.cartRemovePersonalData which is hard to mock // But we can verify the happy path behavior } @@ -691,7 +690,6 @@ final class ApplePayAuthorizationDelegateTests: XCTestCase { private class MockPayController: PayController { var cart: StorefrontAPI.Types.Cart? var storefront: StorefrontAPIProtocol - var storefrontJulyRelease: StorefrontAPIProtocol var presentCallCount = 0 var presentCalledWith: URL? @@ -702,7 +700,6 @@ final class ApplePayAuthorizationDelegateTests: XCTestCase { storefrontDomain: config.storefrontDomain, storefrontAccessToken: config.storefrontAccessToken ) - storefrontJulyRelease = storefront } func present(url: URL) async throws { @@ -714,7 +711,6 @@ final class ApplePayAuthorizationDelegateTests: XCTestCase { private class FailingMockPayController: PayController { var cart: StorefrontAPI.Types.Cart? var storefront: StorefrontAPIProtocol - var storefrontJulyRelease: StorefrontAPIProtocol var presentCallCount = 0 @@ -724,7 +720,6 @@ final class ApplePayAuthorizationDelegateTests: XCTestCase { storefrontDomain: config.storefrontDomain, storefrontAccessToken: config.storefrontAccessToken ) - storefrontJulyRelease = storefront } func present(url _: URL) async throws { @@ -736,7 +731,6 @@ final class ApplePayAuthorizationDelegateTests: XCTestCase { private class SpyPayController: PayController { var cart: StorefrontAPI.Types.Cart? var storefront: StorefrontAPIProtocol - var storefrontJulyRelease: StorefrontAPIProtocol var presentCallCount = 0 var presentCalledWith: URL? @@ -747,7 +741,6 @@ final class ApplePayAuthorizationDelegateTests: XCTestCase { storefrontDomain: config.storefrontDomain, storefrontAccessToken: config.storefrontAccessToken ) - storefrontJulyRelease = storefront } func present(url: URL) async throws { diff --git a/Tests/ShopifyAcceleratedCheckoutsTests/Wallets/ApplePay/ErrorHandler/ErrorHandler_CartPrepareForCompletionTests.swift b/Tests/ShopifyAcceleratedCheckoutsTests/Wallets/ApplePay/ErrorHandler/ErrorHandler_CartPrepareForCompletionTests.swift index c295f6cf3..594638ede 100644 --- a/Tests/ShopifyAcceleratedCheckoutsTests/Wallets/ApplePay/ErrorHandler/ErrorHandler_CartPrepareForCompletionTests.swift +++ b/Tests/ShopifyAcceleratedCheckoutsTests/Wallets/ApplePay/ErrorHandler/ErrorHandler_CartPrepareForCompletionTests.swift @@ -32,7 +32,7 @@ class ErrorHandler_CartPrepareForCompletionTests: XCTestCase { userErrors: [] ) - let result = ErrorHandler.map(payload: payload) + let result = ErrorHandler.map(stage: .prepare(payload), shippingCountry: nil) switch result { case let .interrupt(reason, checkoutURL): @@ -53,7 +53,7 @@ class ErrorHandler_CartPrepareForCompletionTests: XCTestCase { userErrors: [] ) - let result = ErrorHandler.map(payload: payload) + let result = ErrorHandler.map(stage: .prepare(payload), shippingCountry: nil) switch result { case let .interrupt(reason, checkoutURL): @@ -73,7 +73,7 @@ class ErrorHandler_CartPrepareForCompletionTests: XCTestCase { userErrors: [] ) - let result = ErrorHandler.map(payload: payload) + let result = ErrorHandler.map(stage: .prepare(payload), shippingCountry: nil) switch result { case let .interrupt(reason, checkoutURL): @@ -94,7 +94,7 @@ class ErrorHandler_CartPrepareForCompletionTests: XCTestCase { userErrors: [] ) - let result = ErrorHandler.map(payload: payload) + let result = ErrorHandler.map(stage: .prepare(payload), shippingCountry: nil) switch result { case let .interrupt(reason, checkoutURL): @@ -105,6 +105,178 @@ class ErrorHandler_CartPrepareForCompletionTests: XCTestCase { } } - // Note: Testing for unknown types is not applicable with the current enum-based implementation - // as Swift's exhaustive pattern matching ensures all cases are handled + // MARK: - Apple Pay Resolvable Violation Filtering + + func testMap_whenNotReadyWithOnlyApplePayResolvableErrors_returnsContinueFlow() { + let errors: [StorefrontAPI.CartCompletionError] = [ + .init(code: .deliveryFirstNameRequired, message: "Enter a first name"), + .init(code: .deliveryLastNameRequired, message: "Enter a last name"), + .init(code: .deliveryAddress1Required, message: "Enter an address"), + .init(code: .deliveryPhoneNumberRequired, message: "Enter a phone number") + ] + let payload = StorefrontAPI.CartPrepareForCompletionPayload( + result: .notReady(StorefrontAPI.CartStatusNotReady(cart: nil, errors: errors)), + userErrors: [] + ) + + let result = ErrorHandler.map(stage: .prepare(payload), shippingCountry: nil) + + switch result { + case .continueFlow: + break + default: + XCTFail("Expected continueFlow when all errors are Apple Pay resolvable, got: \(result)") + } + } + + func testMap_whenNotReadyWithBuyerIdentityErrors_returnsContinueFlow() { + let errors: [StorefrontAPI.CartCompletionError] = [ + .init(code: .buyerIdentityEmailRequired, message: "Email required"), + .init(code: .buyerIdentityEmailIsInvalid, message: "Email invalid") + ] + let payload = StorefrontAPI.CartPrepareForCompletionPayload( + result: .notReady(StorefrontAPI.CartStatusNotReady(cart: nil, errors: errors)), + userErrors: [] + ) + + let result = ErrorHandler.map(stage: .prepare(payload), shippingCountry: nil) + + switch result { + case .continueFlow: + break + default: + XCTFail("Expected continueFlow when all errors are BUYER_IDENTITY_*, got: \(result)") + } + } + + func testMap_whenNotReadyWithMixOfApplePayResolvableAndActionableError_returnsActionForActionableError() { + let errors: [StorefrontAPI.CartCompletionError] = [ + .init(code: .deliveryFirstNameRequired, message: "Enter a first name"), + .init(code: .merchandiseOutOfStock, message: "Item out of stock") + ] + let payload = StorefrontAPI.CartPrepareForCompletionPayload( + result: .notReady(StorefrontAPI.CartStatusNotReady(cart: nil, errors: errors)), + userErrors: [] + ) + + let result = ErrorHandler.map(stage: .prepare(payload), shippingCountry: nil) + + switch result { + case let .interrupt(reason, _): + XCTAssertEqual(reason, .outOfStock) + default: + XCTFail("Expected interrupt with .outOfStock for actionable error") + } + } + + func testMap_whenNotReadyWithMultipleActionableErrors_returnsHighestPriorityAction() { + let errors: [StorefrontAPI.CartCompletionError] = [ + .init(code: .deliveryFirstNameRequired, message: "Apple Pay resolvable - filtered"), + .init(code: .merchandiseOutOfStock, message: "Out of stock"), + .init(code: .taxesMustBeDefined, message: "Tax error") + ] + let payload = StorefrontAPI.CartPrepareForCompletionPayload( + result: .notReady(StorefrontAPI.CartStatusNotReady(cart: nil, errors: errors)), + userErrors: [] + ) + + let result = ErrorHandler.map(stage: .prepare(payload), shippingCountry: nil) + + switch result { + case let .interrupt(reason, _): + XCTAssertEqual(reason, .outOfStock, "outOfStock should take priority over unhandled tax error") + default: + XCTFail("Expected interrupt for multiple actionable errors") + } + } + + func testMap_whenNotReadyWithMultipleDeceleration_returnsHighestPriorityInterrupt() { + let errors: [StorefrontAPI.CartCompletionError] = [ + .init(code: .merchandiseNotEnoughStockAvailable, message: "Not enough stock"), + .init(code: .paymentsUnacceptablePaymentAmount, message: "Payment amount issue") + ] + let payload = StorefrontAPI.CartPrepareForCompletionPayload( + result: .notReady(StorefrontAPI.CartStatusNotReady(cart: nil, errors: errors)), + userErrors: [] + ) + + let result = ErrorHandler.map(stage: .prepare(payload), shippingCountry: nil) + + switch result { + case let .interrupt(reason, _): + XCTAssertEqual(reason, .notEnoughStock, "notEnoughStock should win when paymentsUnacceptable is filtered by filterGenericViolations") + default: + XCTFail("Expected interrupt for multiple deceleration errors") + } + } + + func testFilterApplePayResolvableViolations_preservesNonResolvableErrors() { + let errors: [StorefrontAPI.CartCompletionError] = [ + .init(code: .deliveryFirstNameRequired, message: ""), + .init(code: .merchandiseOutOfStock, message: ""), + .init(code: .buyerIdentityEmailRequired, message: ""), + .init(code: .paymentsUnacceptablePaymentAmount, message: "") + ] + + let filtered = ErrorHandler.filterApplePayResolvableViolations(errors: errors) + + XCTAssertEqual(filtered.count, 2) + XCTAssertEqual(filtered[0].code, .merchandiseOutOfStock) + XCTAssertEqual(filtered[1].code, .paymentsUnacceptablePaymentAmount) + } + + func testFilterApplePayResolvableViolations_preservesUnknownDeliveryCodes() throws { + let json = """ + {"code": "DELIVERY_DETAIL_CHANGED", "message": "Delivery details changed"} + """ + let error = try JSONDecoder().decode(StorefrontAPI.CartCompletionError.self, from: Data(json.utf8)) + + XCTAssertEqual(error.code, .unknownValue) + XCTAssertEqual(error.rawCode, "DELIVERY_DETAIL_CHANGED") + + let filtered = ErrorHandler.filterApplePayResolvableViolations(errors: [error]) + XCTAssertEqual(filtered.count, 1, "Unknown DELIVERY_* codes should not be assumed Apple Pay resolvable") + } + + func testFilterApplePayResolvableViolations_filtersDeliveryNoDeliveryAvailableForMerchandiseLine() { + let errors: [StorefrontAPI.CartCompletionError] = [ + .init(code: .deliveryNoDeliveryAvailableForMerchandiseLine, message: "Can't ship to address") + ] + + let filtered = ErrorHandler.filterApplePayResolvableViolations(errors: errors) + + XCTAssertEqual(filtered.count, 0) + } + + func testFilterApplePayResolvableViolations_preservesDeliveryNoDeliveryAvailable() { + let errors: [StorefrontAPI.CartCompletionError] = [ + .init(code: .deliveryNoDeliveryAvailable, message: "No delivery available") + ] + + let filtered = ErrorHandler.filterApplePayResolvableViolations(errors: errors) + + XCTAssertEqual(filtered.count, 1) + XCTAssertEqual(filtered[0].code, .deliveryNoDeliveryAvailable) + } + + func testMap_whenNotReadyWithResolvableAndDeliveryNoDeliveryForMerchandiseLine_returnsContinueFlow() { + let errors: [StorefrontAPI.CartCompletionError] = [ + .init(code: .deliveryLastNameRequired, message: "Enter a last name"), + .init(code: .deliveryAddress1Required, message: "Enter an address"), + .init(code: .deliveryNoDeliveryAvailableForMerchandiseLine, message: "Can't be shipped to your address") + ] + let payload = StorefrontAPI.CartPrepareForCompletionPayload( + result: .notReady(StorefrontAPI.CartStatusNotReady(cart: nil, errors: errors)), + userErrors: [] + ) + + let result = ErrorHandler.map(stage: .prepare(payload), shippingCountry: nil) + + switch result { + case .continueFlow: + break + default: + XCTFail("Expected continueFlow when all errors are Apple Pay resolvable, got: \(result)") + } + } } diff --git a/Tests/ShopifyAcceleratedCheckoutsTests/Wallets/ApplePay/ErrorHandler/ErrorHandler_CartSubmitForCompletionTests.swift b/Tests/ShopifyAcceleratedCheckoutsTests/Wallets/ApplePay/ErrorHandler/ErrorHandler_CartSubmitForCompletionTests.swift index cda1081c3..6087b83ac 100644 --- a/Tests/ShopifyAcceleratedCheckoutsTests/Wallets/ApplePay/ErrorHandler/ErrorHandler_CartSubmitForCompletionTests.swift +++ b/Tests/ShopifyAcceleratedCheckoutsTests/Wallets/ApplePay/ErrorHandler/ErrorHandler_CartSubmitForCompletionTests.swift @@ -28,7 +28,7 @@ import XCTest @available(iOS 17.0, *) class ErrorHandler_CartSubmitForCompletionTests: XCTestCase { struct TestCase { - let errorCode: StorefrontAPI.SubmissionErrorCode + let errorCode: StorefrontAPI.CartCompletionErrorCode let country: String let expectedAction: ExpectedAction let expectedField: String? @@ -398,7 +398,7 @@ class ErrorHandler_CartSubmitForCompletionTests: XCTestCase { ] for testCase in testCases { - let error = StorefrontAPI.SubmissionError( + let error = StorefrontAPI.CartCompletionError( code: testCase.errorCode, message: "Test error message" ) @@ -412,7 +412,7 @@ class ErrorHandler_CartSubmitForCompletionTests: XCTestCase { userErrors: [] ) - let result = ErrorHandler.map(payload: payload, shippingCountry: testCase.country) + let result = ErrorHandler.map(stage: .submit(payload), shippingCountry: testCase.country) switch (testCase.expectedAction, result) { case let (.showError(.emailInvalid), .showError(errors)): @@ -456,7 +456,7 @@ class ErrorHandler_CartSubmitForCompletionTests: XCTestCase { userErrors: [] ) - let result = ErrorHandler.map(payload: payload, shippingCountry: "US") + let result = ErrorHandler.map(stage: .submit(payload), shippingCountry: "US") switch result { case let .interrupt(reason, checkoutURL): @@ -476,7 +476,7 @@ class ErrorHandler_CartSubmitForCompletionTests: XCTestCase { userErrors: [] ) - let result = ErrorHandler.map(payload: payload, shippingCountry: "US") + let result = ErrorHandler.map(stage: .submit(payload), shippingCountry: "US") switch result { case let .interrupt(reason, checkoutURL): @@ -496,7 +496,7 @@ class ErrorHandler_CartSubmitForCompletionTests: XCTestCase { userErrors: [] ) - let result = ErrorHandler.map(payload: payload, shippingCountry: "US") + let result = ErrorHandler.map(stage: .submit(payload), shippingCountry: "US") switch result { case let .interrupt(reason, checkoutURL): @@ -508,7 +508,7 @@ class ErrorHandler_CartSubmitForCompletionTests: XCTestCase { } func testFilterGenericViolations_withOnlyPaymentsUnacceptablePaymentAmount_returnsError() { - let error = StorefrontAPI.SubmissionError( + let error = StorefrontAPI.CartCompletionError( code: .paymentsUnacceptablePaymentAmount, message: "Test error" ) @@ -521,7 +521,7 @@ class ErrorHandler_CartSubmitForCompletionTests: XCTestCase { userErrors: [] ) - let result = ErrorHandler.map(payload: payload, shippingCountry: "US") + let result = ErrorHandler.map(stage: .submit(payload), shippingCountry: "US") switch result { case let .interrupt(reason, _): @@ -532,11 +532,11 @@ class ErrorHandler_CartSubmitForCompletionTests: XCTestCase { } func testFilterGenericViolations_withMultipleErrorsIncludingPaymentsUnacceptablePaymentAmount_filtersOutPaymentsUnacceptablePaymentAmount() { - let error1 = StorefrontAPI.SubmissionError( + let error1 = StorefrontAPI.CartCompletionError( code: .paymentsUnacceptablePaymentAmount, message: "Payment amount error" ) - let error2 = StorefrontAPI.SubmissionError( + let error2 = StorefrontAPI.CartCompletionError( code: .buyerIdentityEmailRequired, message: "Email required" ) @@ -549,7 +549,7 @@ class ErrorHandler_CartSubmitForCompletionTests: XCTestCase { userErrors: [] ) - let result = ErrorHandler.map(payload: payload, shippingCountry: "US") + let result = ErrorHandler.map(stage: .submit(payload), shippingCountry: "US") // Should return the email error, not the payment amount error switch result {