diff --git a/Stripe/StripeiOSTests/STPPaymentHandlerTests.swift b/Stripe/StripeiOSTests/STPPaymentHandlerTests.swift index 5ea9a12cf9f..49be50866a5 100644 --- a/Stripe/StripeiOSTests/STPPaymentHandlerTests.swift +++ b/Stripe/StripeiOSTests/STPPaymentHandlerTests.swift @@ -263,6 +263,241 @@ class STPPaymentHandlerStubbedTests: STPNetworkStubbingTestCase { XCTAssertGreaterThan(finalCallDelay, 1.1, "Final call should happen after polling budget expires") XCTAssertLessThan(finalCallDelay, 1.3, "Final call should happen after polling budget expires but within a reasonable time") } + + // MARK: - Card Processing State Polling Tests (Orchestration/Multiprocessor) + + /// Tests that a card payment in processing state triggers polling and succeeds when status transitions + func testCardProcessingStatePollingSucceeds() { + let mockAPIClient = STPAPIClientPollingMock() + let paymentHandler = STPPaymentHandler(apiClient: mockAPIClient) + let expectation = self.expectation(description: "Card processing polling succeeds") + + var callCount = 0 + + let paymentIntent = STPFixtures.paymentIntent( + paymentMethodTypes: ["card"], + status: .processing, + paymentMethod: ["id": "pm_test", "type": "card", "created": Date().timeIntervalSince1970] + ) + + mockAPIClient.retrievePaymentIntentHandler = { _, _, completion in + callCount += 1 + // Return processing on first call, succeeded on second + let status: STPPaymentIntentStatus = callCount >= 2 ? .succeeded : .processing + + let responseDict = paymentIntent.allResponseFields.merging([ + "status": STPPaymentIntentStatus.string(from: status) + ]) { _, new in new } + + let updatedPI = STPPaymentIntent.decodedObject(fromAPIResponse: responseDict) + completion(updatedPI, nil) + } + + var completionStatus: STPPaymentHandlerActionStatus? + var completionError: NSError? + + let currentAction = STPPaymentHandlerPaymentIntentActionParams( + apiClient: mockAPIClient, + authenticationContext: self, + threeDSCustomizationSettings: STPThreeDSCustomizationSettings(), + paymentIntent: paymentIntent, + returnURL: nil + ) { status, _, error in + completionStatus = status + completionError = error as NSError? + expectation.fulfill() + } + + paymentHandler.currentAction = currentAction + // This should trigger card processing polling since status is .processing and payment method is card + let requiresAction = paymentHandler._handlePaymentIntentStatus(forAction: currentAction) + + XCTAssertFalse(requiresAction, "Should return false since polling is started") + + wait(for: [expectation], timeout: 20.0) + + XCTAssertEqual(completionStatus, .succeeded, "Card payment should succeed after polling") + XCTAssertNil(completionError, "No error expected on success") + XCTAssertGreaterThanOrEqual(callCount, 2, "Should have made at least 2 API calls") + } + + /// Tests that a card payment in processing state times out when status doesn't change + func testCardProcessingStatePollingTimesOut() { + let mockAPIClient = STPAPIClientPollingMock() + let paymentHandler = STPPaymentHandler(apiClient: mockAPIClient) + let expectation = self.expectation(description: "Card processing polling times out") + + var callCount = 0 + + let paymentIntent = STPFixtures.paymentIntent( + paymentMethodTypes: ["card"], + status: .processing, + paymentMethod: ["id": "pm_test", "type": "card", "created": Date().timeIntervalSince1970] + ) + + mockAPIClient.retrievePaymentIntentHandler = { _, _, completion in + callCount += 1 + // Always return processing to trigger timeout + let responseDict = paymentIntent.allResponseFields.merging([ + "status": STPPaymentIntentStatus.string(from: .processing) + ]) { _, new in new } + + let updatedPI = STPPaymentIntent.decodedObject(fromAPIResponse: responseDict) + completion(updatedPI, nil) + } + + var completionStatus: STPPaymentHandlerActionStatus? + var completionError: NSError? + + let currentAction = STPPaymentHandlerPaymentIntentActionParams( + apiClient: mockAPIClient, + authenticationContext: self, + threeDSCustomizationSettings: STPThreeDSCustomizationSettings(), + paymentIntent: paymentIntent, + returnURL: nil + ) { status, _, error in + completionStatus = status + completionError = error as NSError? + expectation.fulfill() + } + + paymentHandler.currentAction = currentAction + let requiresAction = paymentHandler._handlePaymentIntentStatus(forAction: currentAction) + + XCTAssertFalse(requiresAction, "Should return false since polling is started") + + // Polling budget for card is 15 seconds, so wait a bit longer + wait(for: [expectation], timeout: 20.0) + + XCTAssertEqual(completionStatus, .failed, "Card payment should fail after polling timeout") + XCTAssertNotNil(completionError, "Error expected on timeout") + XCTAssertEqual( + completionError?.domain, + STPPaymentHandler.errorDomain, + "Error should be from STPPaymentHandler" + ) + XCTAssertGreaterThan(callCount, 1, "Should have made multiple API calls before timeout") + } + + /// Tests that a card payment in processing state handles transition to requiresPaymentMethod (declined) + func testCardProcessingStatePollingDeclined() { + let mockAPIClient = STPAPIClientPollingMock() + let paymentHandler = STPPaymentHandler(apiClient: mockAPIClient) + let expectation = self.expectation(description: "Card processing polling handles declined") + + var callCount = 0 + + let paymentIntent = STPFixtures.paymentIntent( + paymentMethodTypes: ["card"], + status: .processing, + paymentMethod: ["id": "pm_test", "type": "card", "created": Date().timeIntervalSince1970] + ) + + mockAPIClient.retrievePaymentIntentHandler = { _, _, completion in + callCount += 1 + // Return processing on first call, requiresPaymentMethod (declined) on second + if callCount >= 2 { + let responseDict = paymentIntent.allResponseFields.merging([ + "status": STPPaymentIntentStatus.string(from: .requiresPaymentMethod), + "last_payment_error": [ + "code": "card_declined", + "message": "Your card was declined.", + "type": "card_error", + ], + ]) { _, new in new } + + let updatedPI = STPPaymentIntent.decodedObject(fromAPIResponse: responseDict) + completion(updatedPI, nil) + } else { + let responseDict = paymentIntent.allResponseFields.merging([ + "status": STPPaymentIntentStatus.string(from: .processing) + ]) { _, new in new } + + let updatedPI = STPPaymentIntent.decodedObject(fromAPIResponse: responseDict) + completion(updatedPI, nil) + } + } + + var completionStatus: STPPaymentHandlerActionStatus? + var completionError: NSError? + + let currentAction = STPPaymentHandlerPaymentIntentActionParams( + apiClient: mockAPIClient, + authenticationContext: self, + threeDSCustomizationSettings: STPThreeDSCustomizationSettings(), + paymentIntent: paymentIntent, + returnURL: nil + ) { status, _, error in + completionStatus = status + completionError = error as NSError? + expectation.fulfill() + } + + paymentHandler.currentAction = currentAction + let requiresAction = paymentHandler._handlePaymentIntentStatus(forAction: currentAction) + + XCTAssertFalse(requiresAction, "Should return false since polling is started") + + wait(for: [expectation], timeout: 20.0) + + XCTAssertEqual(completionStatus, .failed, "Card payment should fail when declined") + XCTAssertNotNil(completionError, "Error expected on decline") + XCTAssertEqual(callCount, 2, "Should have made exactly 2 API calls") + } + + /// Tests that a card payment in processing state handles requiresCapture (manual capture) + func testCardProcessingStatePollingRequiresCapture() { + let mockAPIClient = STPAPIClientPollingMock() + let paymentHandler = STPPaymentHandler(apiClient: mockAPIClient) + let expectation = self.expectation(description: "Card processing polling handles requiresCapture") + + var callCount = 0 + + let paymentIntent = STPFixtures.paymentIntent( + paymentMethodTypes: ["card"], + status: .processing, + paymentMethod: ["id": "pm_test", "type": "card", "created": Date().timeIntervalSince1970] + ) + + mockAPIClient.retrievePaymentIntentHandler = { _, _, completion in + callCount += 1 + // Return processing on first call, requiresCapture on second + let status: STPPaymentIntentStatus = callCount >= 2 ? .requiresCapture : .processing + + let responseDict = paymentIntent.allResponseFields.merging([ + "status": STPPaymentIntentStatus.string(from: status) + ]) { _, new in new } + + let updatedPI = STPPaymentIntent.decodedObject(fromAPIResponse: responseDict) + completion(updatedPI, nil) + } + + var completionStatus: STPPaymentHandlerActionStatus? + var completionError: NSError? + + let currentAction = STPPaymentHandlerPaymentIntentActionParams( + apiClient: mockAPIClient, + authenticationContext: self, + threeDSCustomizationSettings: STPThreeDSCustomizationSettings(), + paymentIntent: paymentIntent, + returnURL: nil + ) { status, _, error in + completionStatus = status + completionError = error as NSError? + expectation.fulfill() + } + + paymentHandler.currentAction = currentAction + let requiresAction = paymentHandler._handlePaymentIntentStatus(forAction: currentAction) + + XCTAssertFalse(requiresAction, "Should return false since polling is started") + + wait(for: [expectation], timeout: 20.0) + + XCTAssertEqual(completionStatus, .succeeded, "Card payment with manual capture should succeed") + XCTAssertNil(completionError, "No error expected on success") + XCTAssertEqual(callCount, 2, "Should have made exactly 2 API calls") + } } class STPPaymentHandlerTests: APIStubbedTestCase { diff --git a/StripePayments/StripePayments/Source/PaymentHandler/STPPaymentHandler.swift b/StripePayments/StripePayments/Source/PaymentHandler/STPPaymentHandler.swift index 78a0215b518..dbae83846e2 100644 --- a/StripePayments/StripePayments/Source/PaymentHandler/STPPaymentHandler.swift +++ b/StripePayments/StripePayments/Source/PaymentHandler/STPPaymentHandler.swift @@ -1062,6 +1062,13 @@ public class STPPaymentHandler: NSObject { STPPaymentHandler._isProcessingIntentSuccess(for: type) { action.complete(with: STPPaymentHandlerActionStatus.succeeded, error: nil) + } else if let type = paymentIntent.paymentMethod?.type, + type == .card, + let pollingBudget = PollingBudget(startDate: Date(), paymentMethodType: type) + { + // For cards in processing state (e.g., Orchestration/multiprocessor), + // poll until status changes or timeout + _pollForCardProcessingStatus(action: action, pollingBudget: pollingBudget) } else { action.complete( with: STPPaymentHandlerActionStatus.failed, @@ -1081,6 +1088,77 @@ public class STPPaymentHandler: NSObject { return false } + /// Polls for card payment status changes when in processing state (e.g., Orchestration/multiprocessor). + /// - Parameters: + /// - action: The current payment intent action params + /// - pollingBudget: Budget controlling polling duration and intervals + private func _pollForCardProcessingStatus( + action: STPPaymentHandlerPaymentIntentActionParams, + pollingBudget: PollingBudget + ) { + pollingBudget.pollAfter { [weak self] in + guard let self else { return } + + self.retrieveOrRefreshPaymentIntent( + currentAction: action, + timeout: pollingBudget.networkTimeout + ) { paymentIntent, error in + guard let paymentIntent, error == nil else { + // Network error - retry if budget allows + if pollingBudget.canPoll { + self._pollForCardProcessingStatus(action: action, pollingBudget: pollingBudget) + } else { + action.complete( + with: .failed, + error: error as NSError? ?? self._error(for: .timedOutErrorCode) + ) + } + return + } + + action.paymentIntent = paymentIntent + + switch paymentIntent.status { + case .succeeded, .requiresCapture: + action.complete(with: .succeeded, error: nil) + + case .processing: + if pollingBudget.canPoll { + self._pollForCardProcessingStatus(action: action, pollingBudget: pollingBudget) + } else { + // Polling timed out while still processing + action.complete( + with: .failed, + error: self._error(for: .timedOutErrorCode) + ) + } + + case .requiresPaymentMethod: + // Payment failed at the external processor + let lastError = paymentIntent.lastPaymentError + action.complete( + with: .failed, + error: self._error( + for: .paymentErrorCode, + apiErrorCode: lastError?.code, + localizedDescription: lastError?.message + ) + ) + + case .canceled: + action.complete(with: .canceled, error: nil) + + default: + // For any other status (e.g., requiresAction), delegate to standard handler + let requiresAction = self._handlePaymentIntentStatus(forAction: action) + if requiresAction { + self._handleAuthenticationForCurrentAction() + } + } + } + } + } + func _handleAuthenticationForCurrentAction() { guard let currentAction else { stpAssertionFailure("Calling _handleAuthenticationForCurrentAction without a currentAction")