From 670fbef809ce202724036efb9faba1b78777b4b1 Mon Sep 17 00:00:00 2001 From: Ian Balen Date: Thu, 26 Mar 2026 09:11:09 +0100 Subject: [PATCH] Fix Apple Pay request type nil bug in PaymentSheet and add DeferredPaymentRequest support Fixes #1659 The PaymentSheet's paymentRequestHandler was passing the entire applePayParams dictionary to configureRequestType instead of the nested "request" sub-dictionary. This caused requestParams["type"] to be nil, making all Apple Pay request types (Recurring, AutomaticReload, MultiMerchant) silently fail with "request type nil is not supported". Additionally, adds PKDeferredPaymentRequest support (iOS 16.4+) to complement the existing Recurring, AutomaticReload, and MultiMerchant request types. This enables merchants to request merchant-specific payment tokens (MPANs) for deferred payment use cases like hotel bookings, pre-orders, and equipment rentals. Changes: - Fix: Extract applePayParams["request"] before passing to configureRequestType in StripeSdkImpl+PaymentSheet.swift - Add: buildDeferredPaymentRequest in ApplePayUtils.swift - Add: "Deferred" case in configureRequestType switch - Add: Unit tests for configureRequestType, buildDeferredPaymentRequest Co-Authored-By: Claude Opus 4.6 (1M context) --- ios/ApplePayUtils.swift | 32 ++++++ ios/StripeSdkImpl+PaymentSheet.swift | 2 +- ios/Tests/ApplePayUtilsTests.swift | 159 +++++++++++++++++++++++++++ 3 files changed, 192 insertions(+), 1 deletion(-) diff --git a/ios/ApplePayUtils.swift b/ios/ApplePayUtils.swift index 59d15ec6f2..0a2829963d 100644 --- a/ios/ApplePayUtils.swift +++ b/ios/ApplePayUtils.swift @@ -138,6 +138,32 @@ class ApplePayUtils { return request } + @available(iOS 16.4, *) + internal class func buildDeferredPaymentRequest(params: NSDictionary) throws -> PKDeferredPaymentRequest { + guard let description = params["paymentDescription"] as? String else { + throw ApplePayUtilsError.missingParameter(nil, "paymentDescription") + } + guard let urlString = params["managementUrl"] as? String else { + throw ApplePayUtilsError.missingParameter(nil, "managementUrl") + } + guard let url = URL(string: urlString) else { + throw ApplePayUtilsError.invalidUrl(urlString) + } + let deferredBilling = try ApplePayUtils.createDeferredPaymentSummaryItem(item: params["deferredBilling"] as? [String: Any] ?? [:]) + let request = PKDeferredPaymentRequest(paymentDescription: description, deferredBilling: deferredBilling, managementURL: url) + request.billingAgreement = params["billingAgreement"] as? String + if let tokenNotificationURL = params["tokenNotificationURL"] as? String { + request.tokenNotificationURL = URL(string: tokenNotificationURL) + } + if let freeCancellationTimestamp = params["freeCancellationDate"] as? Double { + request.freeCancellationDate = Date(timeIntervalSince1970: freeCancellationTimestamp) + if let tzIdentifier = params["freeCancellationDateTimeZone"] as? String { + request.freeCancellationDateTimeZone = TimeZone(identifier: tzIdentifier) + } + } + return request + } + @available(iOS 16.0, *) internal class func buildPaymentTokenContexts(items: [[String: Any]]) -> [PKPaymentTokenContext] { var result: [PKPaymentTokenContext] = [] @@ -437,6 +463,12 @@ extension PKPaymentRequest { self.automaticReloadPaymentRequest = try ApplePayUtils.buildAutomaticReloadPaymentRequest(params: requestParams) case "MultiMerchant": self.multiTokenContexts = ApplePayUtils.buildPaymentTokenContexts(items: requestParams["merchants"] as? [[String: Any]] ?? []) + case "Deferred": + if #available(iOS 16.4, *) { + self.deferredPaymentRequest = try ApplePayUtils.buildDeferredPaymentRequest(params: requestParams) + } else { + throw ApplePayUtilsError.invalidRequestType("Deferred (requires iOS 16.4+)") + } default: throw ApplePayUtilsError.invalidRequestType(String(describing: requestParams["type"])) } diff --git a/ios/StripeSdkImpl+PaymentSheet.swift b/ios/StripeSdkImpl+PaymentSheet.swift index d0289b8db6..6e41531670 100644 --- a/ios/StripeSdkImpl+PaymentSheet.swift +++ b/ios/StripeSdkImpl+PaymentSheet.swift @@ -450,7 +450,7 @@ extension StripeSdkImpl { }() return PaymentSheet.ApplePayConfiguration.Handlers(paymentRequestHandler: { request in do { - try request.configureRequestType(requestParams: applePayParams) + try request.configureRequestType(requestParams: applePayParams["request"] as? NSDictionary) } catch { // At this point, we can't resolve a promise with an error object, so our best option is to create a redbox error RCTMakeAndLogError(error.localizedDescription, nil, nil) diff --git a/ios/Tests/ApplePayUtilsTests.swift b/ios/Tests/ApplePayUtilsTests.swift index 7cfb2733e7..5a7cc8d41b 100644 --- a/ios/Tests/ApplePayUtilsTests.swift +++ b/ios/Tests/ApplePayUtilsTests.swift @@ -422,6 +422,138 @@ class ApplePayUtilsTests: XCTestCase { XCTAssertEqual(result[0].rawValue, "customNetwork") } + // MARK: - configureRequestType Tests + + @available(iOS 16.0, *) + func test_configureRequestType_recurringType_configuresRecurringRequest() throws { + let request = PKPaymentRequest() + try request.configureRequestType(requestParams: TestFixtures.RECURRING_REQUEST_PARAMS) + XCTAssertNotNil(request.recurringPaymentRequest) + } + + @available(iOS 16.4, *) + func test_configureRequestType_deferredType_configuresDeferredRequest() throws { + let request = PKPaymentRequest() + try request.configureRequestType(requestParams: TestFixtures.DEFERRED_REQUEST_PARAMS) + XCTAssertNotNil(request.deferredPaymentRequest) + } + + @available(iOS 16.0, *) + func test_configureRequestType_nilParams_doesNotThrow() throws { + let request = PKPaymentRequest() + XCTAssertNoThrow(try request.configureRequestType(requestParams: nil)) + } + + @available(iOS 16.0, *) + func test_configureRequestType_invalidType_throws() { + let request = PKPaymentRequest() + XCTAssertThrowsError( + try request.configureRequestType(requestParams: ["type": "InvalidType"]) + ) { error in + XCTAssertEqual( + error as! ApplePayUtilsError, ApplePayUtilsError.invalidRequestType("Optional(\"InvalidType\")") + ) + } + } + + // MARK: - buildDeferredPaymentRequest Tests + + @available(iOS 16.4, *) + func test_buildDeferredPaymentRequest_validParams_succeeds() throws { + let result = try ApplePayUtils.buildDeferredPaymentRequest(params: TestFixtures.DEFERRED_REQUEST_PARAMS) + XCTAssertEqual(result.paymentDescription, "Test deferred payment") + XCTAssertEqual(result.managementURL, URL(string: "https://example.com/manage")!) + XCTAssertEqual(result.billingAgreement, "You agree to be charged.") + XCTAssertNotNil(result.deferredBilling) + XCTAssertEqual(result.deferredBilling.label, "Test") + XCTAssertEqual(result.deferredBilling.amount, NSDecimalNumber(string: "9.99")) + } + + @available(iOS 16.4, *) + func test_buildDeferredPaymentRequest_missingDescription_throws() { + let params: NSDictionary = [ + "managementUrl": "https://example.com/manage", + "deferredBilling": [ + "paymentType": "Deferred", + "label": "Test", + "amount": "9.99", + "deferredDate": 1700000000 as NSNumber, + ] as [String: Any], + ] + XCTAssertThrowsError( + try ApplePayUtils.buildDeferredPaymentRequest(params: params) + ) { error in + XCTAssertEqual( + error as! ApplePayUtilsError, ApplePayUtilsError.missingParameter(nil, "paymentDescription") + ) + } + } + + @available(iOS 16.4, *) + func test_buildDeferredPaymentRequest_missingManagementUrl_throws() { + let params: NSDictionary = [ + "paymentDescription": "Test deferred payment", + "deferredBilling": [ + "paymentType": "Deferred", + "label": "Test", + "amount": "9.99", + "deferredDate": 1700000000 as NSNumber, + ] as [String: Any], + ] + XCTAssertThrowsError( + try ApplePayUtils.buildDeferredPaymentRequest(params: params) + ) { error in + XCTAssertEqual( + error as! ApplePayUtilsError, ApplePayUtilsError.missingParameter(nil, "managementUrl") + ) + } + } + + @available(iOS 16.4, *) + func test_buildDeferredPaymentRequest_invalidUrl_throws() { + let params: NSDictionary = [ + "paymentDescription": "Test deferred payment", + "managementUrl": "", + "deferredBilling": [ + "paymentType": "Deferred", + "label": "Test", + "amount": "9.99", + "deferredDate": 1700000000 as NSNumber, + ] as [String: Any], + ] + XCTAssertThrowsError( + try ApplePayUtils.buildDeferredPaymentRequest(params: params) + ) { error in + XCTAssertEqual( + error as! ApplePayUtilsError, ApplePayUtilsError.invalidUrl("") + ) + } + } + + @available(iOS 16.4, *) + func test_buildDeferredPaymentRequest_optionalFields() throws { + let params: NSDictionary = [ + "type": "Deferred", + "paymentDescription": "Test deferred payment", + "managementUrl": "https://example.com/manage", + "billingAgreement": "You agree to be charged.", + "tokenNotificationURL": "https://example.com/notify", + "freeCancellationDate": 1700000000.0, + "freeCancellationDateTimeZone": "America/New_York", + "deferredBilling": [ + "paymentType": "Deferred", + "label": "Test", + "amount": "9.99", + "deferredDate": 1700000000 as NSNumber, + ] as [String: Any], + ] + let result = try ApplePayUtils.buildDeferredPaymentRequest(params: params) + XCTAssertEqual(result.billingAgreement, "You agree to be charged.") + XCTAssertEqual(result.tokenNotificationURL, URL(string: "https://example.com/notify")!) + XCTAssertEqual(result.freeCancellationDate, Date(timeIntervalSince1970: 1700000000)) + XCTAssertEqual(result.freeCancellationDateTimeZone, TimeZone(identifier: "America/New_York")) + } + private struct TestFixtures { static let MERCHANT_ID = "merchant.com.id" static let COUNTRY_CODE = "US" @@ -454,5 +586,32 @@ class ApplePayUtilsTests: XCTestCase { "label": "immediate label", "amount": "2.00", ] as [String: Any] + + static let DEFERRED_REQUEST_PARAMS: NSDictionary = [ + "type": "Deferred", + "paymentDescription": "Test deferred payment", + "managementUrl": "https://example.com/manage", + "billingAgreement": "You agree to be charged.", + "deferredBilling": [ + "paymentType": "Deferred", + "label": "Test", + "amount": "9.99", + "deferredDate": 1700000000 as NSNumber, + ] as [String: Any], + ] + + static let RECURRING_REQUEST_PARAMS: NSDictionary = [ + "type": "Recurring", + "description": "Test recurring payment", + "managementUrl": "https://example.com/manage", + "billingAgreement": "Monthly subscription.", + "billing": [ + "paymentType": "Recurring", + "label": "Monthly", + "amount": "9.99", + "intervalUnit": "month", + "intervalCount": 1, + ] as [String: Any], + ] } }