diff --git a/CHANGELOG.md b/CHANGELOG.md index b8e74bc6dd6..b32594b2509 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ * [Fixed] Fixed an animation glitch when dismissing PaymentSheet in React Native. * [Fixed] Fixed an issue with FlowController in vertical layout where the payment method could incorrectly be preserved across a call to `update` when it's no longer valid. * [Fixed] Fixed a potential deadlock when `paymentOption` is accessed from Swift concurrency. - +* [Fixed] Fixed deferred intent validation to handle cloned payment methods ([#4195](https://github.com/stripe/stripe-ios/issues/4195) ## 23.32.0 2024-10-21 ### PaymentSheet diff --git a/StripeCore/StripeCore/Source/Analytics/STPAnalyticEvent.swift b/StripeCore/StripeCore/Source/Analytics/STPAnalyticEvent.swift index e575b4ed618..0c88c7bd953 100644 --- a/StripeCore/StripeCore/Source/Analytics/STPAnalyticEvent.swift +++ b/StripeCore/StripeCore/Source/Analytics/STPAnalyticEvent.swift @@ -216,7 +216,7 @@ import Foundation case paymentSheetFormInteracted = "mc_form_interacted" case paymentSheetFormCompleted = "mc_form_completed" case paymentSheetCardNumberCompleted = "mc_card_number_completed" - case paymentSheetDeferredIntentPaymentMethodIdMismatch = "mc_deferred_intent_payment_method_id_mismatch" + case paymentSheetDeferredIntentPaymentMethodMismatch = "mc_deferred_intent_payment_method_mismatch" // MARK: - v1/elements/session case paymentSheetElementsSessionLoadFailed = "mc_elements_session_load_failed" diff --git a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/PaymentSheet+DeferredAPI.swift b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/PaymentSheet+DeferredAPI.swift index d4ebc55269e..30e4daede2d 100644 --- a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/PaymentSheet+DeferredAPI.swift +++ b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/PaymentSheet+DeferredAPI.swift @@ -82,7 +82,7 @@ extension PaymentSheet { } } else { // 4b. Server-side confirmation - try PaymentSheetDeferredValidator.validatePaymentMethodId(intentPaymentMethod: paymentIntent.paymentMethod, paymentMethod: paymentMethod) + try PaymentSheetDeferredValidator.validatePaymentMethod(intentPaymentMethod: paymentIntent.paymentMethod, paymentMethod: paymentMethod) paymentHandler.handleNextAction( for: paymentIntent, with: authenticationContext, @@ -110,7 +110,7 @@ extension PaymentSheet { } } else { // 4b. Server-side confirmation - try PaymentSheetDeferredValidator.validatePaymentMethodId(intentPaymentMethod: setupIntent.paymentMethod, paymentMethod: paymentMethod) + try PaymentSheetDeferredValidator.validatePaymentMethod(intentPaymentMethod: setupIntent.paymentMethod, paymentMethod: paymentMethod) paymentHandler.handleNextAction( for: setupIntent, with: authenticationContext, diff --git a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/PaymentSheetDeferredValidator.swift b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/PaymentSheetDeferredValidator.swift index 1f1c1480ed2..f1f3f51291a 100644 --- a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/PaymentSheetDeferredValidator.swift +++ b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/PaymentSheetDeferredValidator.swift @@ -8,6 +8,7 @@ import Foundation import StripePayments @_spi(STP) import StripeCore +@_spi(STP) import StripePayments struct PaymentSheetDeferredValidator { /// Note: We don't validate amount (for any payment method) because there are use cases where the amount can change slightly between PM collection and confirmation. @@ -27,7 +28,7 @@ struct PaymentSheetDeferredValidator { guard paymentIntent.captureMethod == captureMethod else { throw PaymentSheetError.deferredIntentValidationFailed(message: "Your PaymentIntent captureMethod (\(paymentIntent.captureMethod)) does not match the PaymentSheet.IntentConfiguration amount (\(captureMethod)).") } - try validatePaymentMethodId(intentPaymentMethod: paymentIntent.paymentMethod, paymentMethod: paymentMethod) + try validatePaymentMethod(intentPaymentMethod: paymentIntent.paymentMethod, paymentMethod: paymentMethod) /* Manual confirmation is only available using FlowController because merchants own the final step of confirmation. Showing a successful payment in the complete flow may be misleading when merchants still need to do a final confirmation which could fail e.g., bad network @@ -36,7 +37,7 @@ struct PaymentSheetDeferredValidator { throw PaymentSheetError.deferredIntentValidationFailed(message: "Your PaymentIntent confirmationMethod (\(paymentIntent.confirmationMethod)) can only be used with PaymentSheet.FlowController.") } } - + static func validate(setupIntent: STPSetupIntent, intentConfiguration: PaymentSheet.IntentConfiguration, paymentMethod: STPPaymentMethod) throws { @@ -46,24 +47,55 @@ struct PaymentSheetDeferredValidator { guard setupIntent.usage == setupFutureUsage else { throw PaymentSheetError.deferredIntentValidationFailed(message: "Your SetupIntent usage (\(setupIntent.usage)) does not match the PaymentSheet.IntentConfiguration setupFutureUsage (\(String(describing: setupFutureUsage))).") } - try validatePaymentMethodId(intentPaymentMethod: setupIntent.paymentMethod, paymentMethod: paymentMethod) + try validatePaymentMethod(intentPaymentMethod: setupIntent.paymentMethod, paymentMethod: paymentMethod) } - - static func validatePaymentMethodId(intentPaymentMethod: STPPaymentMethod?, paymentMethod: STPPaymentMethod) throws { + + static func validatePaymentMethod(intentPaymentMethod: STPPaymentMethod?, paymentMethod: STPPaymentMethod) throws { guard let intentPaymentMethod = intentPaymentMethod else { return } guard intentPaymentMethod.stripeId == paymentMethod.stripeId else { + if intentPaymentMethod.type == paymentMethod.type { + // Payment methods of type card and us_bank_account can be cloned, leading to mismatched pm ids, but their fingerprints should still match + switch paymentMethod.type { + case .card: + try validateFingerprint(intentFingerprint: intentPaymentMethod.card?.fingerprint, fingerprint: paymentMethod.card?.fingerprint) + return + case .USBankAccount: + try validateFingerprint(intentFingerprint: intentPaymentMethod.usBankAccount?.fingerprint, fingerprint: paymentMethod.usBankAccount?.fingerprint) + return + default: + break + } + } let errorMessage = """ \nThere is a mismatch between the payment method ID on your Intent: \(intentPaymentMethod.stripeId) and the payment method passed into the `confirmHandler`: \(paymentMethod.stripeId). + + To resolve this issue, you can: + 1. Create a new Intent each time before you call the `confirmHandler`, or + 2. Update the existing Intent with the desired `paymentMethod` before calling the `confirmHandler`. + """ + let errorAnalytic = ErrorAnalytic(event: .paymentSheetDeferredIntentPaymentMethodMismatch, error: PaymentSheetError.unknown(debugDescription: errorMessage), additionalNonPIIParams: ["field": "payment method ID"]) + STPAnalyticsClient.sharedClient.log(analytic: errorAnalytic) + throw PaymentSheetError.deferredIntentValidationFailed(message: errorMessage) + } + } + + static func validateFingerprint(intentFingerprint: String?, fingerprint: String?) throws { + guard let intentFingerprint = intentFingerprint else { return } + guard let fingerprint = fingerprint else { return } + guard intentFingerprint == fingerprint else { + let errorMessage = """ + \nThere is a mismatch between the fingerprint of the payment method on your Intent: \(intentFingerprint) and the fingerprint of the payment method passed into the `confirmHandler`: \(fingerprint). To resolve this issue, you can: 1. Create a new Intent each time before you call the `confirmHandler`, or 2. Update the existing Intent with the desired `paymentMethod` before calling the `confirmHandler`. """ - let errorAnalytic = ErrorAnalytic(event: .paymentSheetDeferredIntentPaymentMethodIdMismatch, error: PaymentSheetError.unknown(debugDescription: errorMessage)) + let errorAnalytic = ErrorAnalytic(event: .paymentSheetDeferredIntentPaymentMethodMismatch, error: PaymentSheetError.unknown(debugDescription: errorMessage), additionalNonPIIParams: ["field": "fingerprint"]) STPAnalyticsClient.sharedClient.log(analytic: errorAnalytic) throw PaymentSheetError.deferredIntentValidationFailed(message: errorMessage) } } + } // MARK: - Validation helpers diff --git a/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/PaymentSheetDeferredValidatorTests.swift b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/PaymentSheetDeferredValidatorTests.swift index c1af5c635d6..7db9571ee9f 100644 --- a/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/PaymentSheetDeferredValidatorTests.swift +++ b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/PaymentSheetDeferredValidatorTests.swift @@ -79,7 +79,7 @@ final class PaymentSheetDeferredValidatorTests: XCTestCase { paymentMethodJson["id"] = testCard.stripeId let testCardPi = STPFixtures.makePaymentIntent(paymentMethodJson: paymentMethodJson) - XCTAssertNoThrow(try PaymentSheetDeferredValidator.validatePaymentMethodId(intentPaymentMethod: testCardPi.paymentMethod, + XCTAssertNoThrow(try PaymentSheetDeferredValidator.validatePaymentMethod(intentPaymentMethod: testCardPi.paymentMethod, paymentMethod: testCard)) } @@ -92,7 +92,7 @@ final class PaymentSheetDeferredValidatorTests: XCTestCase { guard let intentPaymentMethod = testCardPi.paymentMethod else { return } - XCTAssertThrowsError(try PaymentSheetDeferredValidator.validatePaymentMethodId(intentPaymentMethod: testCardPi.paymentMethod, + XCTAssertThrowsError(try PaymentSheetDeferredValidator.validatePaymentMethod(intentPaymentMethod: testCardPi.paymentMethod, paymentMethod: testUSBankAccount)) { error in XCTAssertEqual("\(error)", """ An error occurred in PaymentSheet. \nThere is a mismatch between the payment method ID on your Intent: \(intentPaymentMethod.stripeId) and the payment method passed into the `confirmHandler`: \(testUSBankAccount.stripeId). @@ -103,14 +103,94 @@ final class PaymentSheetDeferredValidatorTests: XCTestCase { """) } let analyticEvent = STPAnalyticsClient.sharedClient._testLogHistory.last - XCTAssertEqual(analyticEvent?["event"] as? String, STPAnalyticEvent.paymentSheetDeferredIntentPaymentMethodIdMismatch.rawValue) + XCTAssertEqual(analyticEvent?["event"] as? String, STPAnalyticEvent.paymentSheetDeferredIntentPaymentMethodMismatch.rawValue) + XCTAssertNotNil(analyticEvent?["error_code"] as? String) + } + + func testPaymentIntentMatchedCardFingerprint() throws { + let testCard = STPPaymentMethod._testCard() + var paymentMethodJson = STPPaymentMethod.paymentMethodJson + paymentMethodJson["id"] = "pm_mismatch_id" + paymentMethodJson["card"] = testCard.card + let testCardPi = STPFixtures.makePaymentIntent(paymentMethodJson: paymentMethodJson) + XCTAssertNoThrow(try PaymentSheetDeferredValidator.validatePaymentMethod(intentPaymentMethod: testCardPi.paymentMethod, + paymentMethod: testCard)) + } + + func testPaymentIntentMismatchedCardFingerprint() throws { + let testCard = STPPaymentMethod._testCard() + var paymentMethodJson = STPPaymentMethod.paymentMethodJson + paymentMethodJson["id"] = "pm_mismatch_id" + paymentMethodJson["card"] = ["fingerprint": "mismatch_fingerprint"] + let testCardPi = STPFixtures.makePaymentIntent(paymentMethodJson: paymentMethodJson) + guard let intentPaymentMethod = testCardPi.paymentMethod else { + return + } + guard let intentPaymentMethodFingerprint = intentPaymentMethod.card?.fingerprint else { + return + } + guard let testCardFingerprint = testCard.card?.fingerprint else { + return + } + XCTAssertThrowsError(try PaymentSheetDeferredValidator.validatePaymentMethod(intentPaymentMethod: testCardPi.paymentMethod, + paymentMethod: testCard)) { error in + XCTAssertEqual("\(error)", """ + An error occurred in PaymentSheet. \nThere is a mismatch between the fingerprint of the payment method on your Intent: \(intentPaymentMethodFingerprint) and the fingerprint of the payment method passed into the `confirmHandler`: \(testCardFingerprint). + + To resolve this issue, you can: + 1. Create a new Intent each time before you call the `confirmHandler`, or + 2. Update the existing Intent with the desired `paymentMethod` before calling the `confirmHandler`. + """) + } + let analyticEvent = STPAnalyticsClient.sharedClient._testLogHistory.last + XCTAssertEqual(analyticEvent?["event"] as? String, STPAnalyticEvent.paymentSheetDeferredIntentPaymentMethodMismatch.rawValue) + XCTAssertNotNil(analyticEvent?["error_code"] as? String) + } + + func testPaymentIntentMatchedUSBankAccountFingerprint() throws { + let testUSBankAccount = STPPaymentMethod._testUSBankAccount() + var paymentMethodJson = STPPaymentMethod.usBankAccountJson + paymentMethodJson["id"] = "pm_mismatch_id" + paymentMethodJson["us_bank_account"] = testUSBankAccount.usBankAccount + let testUSBankAccountPi = STPFixtures.makePaymentIntent(paymentMethodJson: paymentMethodJson) + XCTAssertNoThrow(try PaymentSheetDeferredValidator.validatePaymentMethod(intentPaymentMethod: testUSBankAccountPi.paymentMethod, + paymentMethod: testUSBankAccount)) + } + + func testPaymentIntentMismatchedUSBankAccountFingerprint() throws { + let testUSBankAccount = STPPaymentMethod._testUSBankAccount() + var paymentMethodJson = STPPaymentMethod.usBankAccountJson + paymentMethodJson["id"] = "pm_mismatch_id" + paymentMethodJson["us_bank_account"] = ["fingerprint": "mismatch_fingerprint"] + let testUSBankAccountPi = STPFixtures.makePaymentIntent(paymentMethodJson: paymentMethodJson) + guard let intentPaymentMethod = testUSBankAccountPi.paymentMethod else { + return + } + guard let intentPaymentMethodFingerprint = intentPaymentMethod.card?.fingerprint else { + return + } + guard let testUSBankAccountFingerprint = testUSBankAccount.usBankAccount?.fingerprint else { + return + } + XCTAssertThrowsError(try PaymentSheetDeferredValidator.validatePaymentMethod(intentPaymentMethod: testUSBankAccountPi.paymentMethod, + paymentMethod: testUSBankAccount)) { error in + XCTAssertEqual("\(error)", """ + An error occurred in PaymentSheet. \nThere is a mismatch between the fingerprint of the payment method on your Intent: \(intentPaymentMethodFingerprint) and the fingerprint of the payment method passed into the `confirmHandler`: \(testUSBankAccountFingerprint). + + To resolve this issue, you can: + 1. Create a new Intent each time before you call the `confirmHandler`, or + 2. Update the existing Intent with the desired `paymentMethod` before calling the `confirmHandler`. + """) + } + let analyticEvent = STPAnalyticsClient.sharedClient._testLogHistory.last + XCTAssertEqual(analyticEvent?["event"] as? String, STPAnalyticEvent.paymentSheetDeferredIntentPaymentMethodMismatch.rawValue) XCTAssertNotNil(analyticEvent?["error_code"] as? String) } func testPaymentIntentNilPaymentMethod() throws { let testCard = STPPaymentMethod._testCard() let nilPaymentMethodPi = STPFixtures.makePaymentIntent() - XCTAssertNoThrow(try PaymentSheetDeferredValidator.validatePaymentMethodId(intentPaymentMethod: nilPaymentMethodPi.paymentMethod, + XCTAssertNoThrow(try PaymentSheetDeferredValidator.validatePaymentMethod(intentPaymentMethod: nilPaymentMethodPi.paymentMethod, paymentMethod: testCard)) } @@ -120,7 +200,7 @@ final class PaymentSheetDeferredValidatorTests: XCTestCase { paymentMethodJson["id"] = testCard.stripeId let testCardSi = STPFixtures.makeSetupIntent(paymentMethodJson: paymentMethodJson) - XCTAssertNoThrow(try PaymentSheetDeferredValidator.validatePaymentMethodId(intentPaymentMethod: testCardSi.paymentMethod, + XCTAssertNoThrow(try PaymentSheetDeferredValidator.validatePaymentMethod(intentPaymentMethod: testCardSi.paymentMethod, paymentMethod: testCard)) } @@ -133,7 +213,7 @@ final class PaymentSheetDeferredValidatorTests: XCTestCase { guard let intentPaymentMethod = testCardSi.paymentMethod else { return } - XCTAssertThrowsError(try PaymentSheetDeferredValidator.validatePaymentMethodId(intentPaymentMethod: testCardSi.paymentMethod, + XCTAssertThrowsError(try PaymentSheetDeferredValidator.validatePaymentMethod(intentPaymentMethod: testCardSi.paymentMethod, paymentMethod: testUSBankAccount)) { error in XCTAssertEqual("\(error)", """ An error occurred in PaymentSheet. \nThere is a mismatch between the payment method ID on your Intent: \(intentPaymentMethod.stripeId) and the payment method passed into the `confirmHandler`: \(testUSBankAccount.stripeId). @@ -144,14 +224,14 @@ final class PaymentSheetDeferredValidatorTests: XCTestCase { """) } let analyticEvent = STPAnalyticsClient.sharedClient._testLogHistory.last - XCTAssertEqual(analyticEvent?["event"] as? String, STPAnalyticEvent.paymentSheetDeferredIntentPaymentMethodIdMismatch.rawValue) + XCTAssertEqual(analyticEvent?["event"] as? String, STPAnalyticEvent.paymentSheetDeferredIntentPaymentMethodMismatch.rawValue) XCTAssertNotNil(analyticEvent?["error_code"] as? String) } func testSetupIntentNilPaymentMethod() throws { let testCard = STPPaymentMethod._testCard() let nilPaymentMethodSi = STPFixtures.makeSetupIntent() - XCTAssertNoThrow(try PaymentSheetDeferredValidator.validatePaymentMethodId(intentPaymentMethod: nilPaymentMethodSi.paymentMethod, + XCTAssertNoThrow(try PaymentSheetDeferredValidator.validatePaymentMethod(intentPaymentMethod: nilPaymentMethodSi.paymentMethod, paymentMethod: testCard)) } diff --git a/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/STPFixtures+PaymentSheet.swift b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/STPFixtures+PaymentSheet.swift index fd15db852e4..c7167d4d47b 100644 --- a/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/STPFixtures+PaymentSheet.swift +++ b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/STPFixtures+PaymentSheet.swift @@ -159,6 +159,7 @@ extension STPPaymentMethod { "card": [ "last4": "4242", "brand": "visa", + "fingerprint": "B8XXs2y2JsVBtB9f", ], ])! } diff --git a/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/SavedPaymentMethodManagerTest.swift b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/SavedPaymentMethodManagerTest.swift index f6f591cdedc..d6699b8b498 100644 --- a/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/SavedPaymentMethodManagerTest.swift +++ b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/SavedPaymentMethodManagerTest.swift @@ -169,9 +169,31 @@ extension STPPaymentMethod { "card": [ "last4": "4242", "brand": "visa", + "fingerprint": "B8XXs2y2JsVBtB9f", ], ] } + + static var usBankAccountJson: [String: Any] { + return [ + "id": "pm_123", + "type": "us_bank_account", + "us_bank_account": [ + "account_holder_type": "individual", + "account_type": "checking", + "bank_name": "STRIPE TEST BANK", + "fingerprint": "ickfX9sbxIyAlbuh", + "last4": "6789", + "networks": [ + "preferred": "ach", + "supported": [ + "ach", + ], + ] as [String: Any], + "routing_number": "110000000", + ] as [String: Any], + ] + } static var paymentMethodsJson: [String: Any] = [ "data": [