diff --git a/Example/PaymentSheet Example/PaymentSheetUITest/CustomerSheetUITest.swift b/Example/PaymentSheet Example/PaymentSheetUITest/CustomerSheetUITest.swift index 68791874153..e1355e2e154 100644 --- a/Example/PaymentSheet Example/PaymentSheetUITest/CustomerSheetUITest.swift +++ b/Example/PaymentSheet Example/PaymentSheetUITest/CustomerSheetUITest.swift @@ -118,7 +118,7 @@ class CustomerSheetUITest: XCTestCase { XCTAssertTrue(editButton.waitForExistence(timeout: 60.0)) editButton.tap() - removeFirstPaymentMethodInList() + removeFirstPaymentMethodInList(alertBodyText: "Remove Visa ending in 4242") let doneButton = app.staticTexts["Done"] XCTAssertTrue(doneButton.waitForExistence(timeout: 60.0)) @@ -312,10 +312,10 @@ class CustomerSheetUITest: XCTestCase { settings ) - presentCSAndAddCardFrom(buttonLabel: "None") - presentCSAndAddCardFrom(buttonLabel: "••••4242") + presentCSAndAddCardFrom(buttonLabel: "None", cardNumber: "4242424242424242") + presentCSAndAddCardFrom(buttonLabel: "••••4242", cardNumber: "5555555555554444") - let selectButton = app.staticTexts["••••4242"] + let selectButton = app.staticTexts["••••4444"] XCTAssertTrue(selectButton.waitForExistence(timeout: 60.0)) selectButton.tap() @@ -323,8 +323,8 @@ class CustomerSheetUITest: XCTestCase { XCTAssertTrue(editButton.waitForExistence(timeout: 60.0)) editButton.tap() - removeFirstPaymentMethodInList() - removeFirstPaymentMethodInList() + removeFirstPaymentMethodInList(alertBodyText: "Remove Mastercard ending in 4444") + removeFirstPaymentMethodInList(alertBodyText: "Remove Visa ending in 4242") let doneButton = app.staticTexts["Done"] XCTAssertTrue(doneButton.waitForExistence(timeout: 60.0)) @@ -349,10 +349,10 @@ class CustomerSheetUITest: XCTestCase { settings ) - presentCSAndAddCardFrom(buttonLabel: "None", tapAdd: false) - presentCSAndAddCardFrom(buttonLabel: "••••4242") + presentCSAndAddCardFrom(buttonLabel: "None", cardNumber: "4242424242424242", tapAdd: false) + presentCSAndAddCardFrom(buttonLabel: "••••4242", cardNumber: "5555555555554444") - let selectButton = app.staticTexts["••••4242"] + let selectButton = app.staticTexts["••••4444"] XCTAssertTrue(selectButton.waitForExistence(timeout: 60.0)) selectButton.tap() @@ -360,8 +360,8 @@ class CustomerSheetUITest: XCTestCase { XCTAssertTrue(editButton.waitForExistence(timeout: 60.0)) editButton.tap() - removeFirstPaymentMethodInList() - removeFirstPaymentMethodInList() + removeFirstPaymentMethodInList(alertBodyText: "Remove Mastercard ending in 4444") + removeFirstPaymentMethodInList(alertBodyText: "Remove Visa ending in 4242") let doneButton = app.staticTexts["Done"] XCTAssertTrue(doneButton.waitForExistence(timeout: 60.0)) @@ -661,7 +661,7 @@ class CustomerSheetUITest: XCTestCase { XCTAssertTrue(last4Selectedlabel.waitForExistence(timeout: 10.0)) } - func presentCSAndAddCardFrom(buttonLabel: String, tapAdd: Bool = true) { + func presentCSAndAddCardFrom(buttonLabel: String, cardNumber: String? = nil, tapAdd: Bool = true) { let selectButton = app.staticTexts[buttonLabel] XCTAssertTrue(selectButton.waitForExistence(timeout: 60.0)) selectButton.tap() @@ -670,20 +670,20 @@ class CustomerSheetUITest: XCTestCase { app.staticTexts["+ Add"].tap() } - try! fillCardData(app, postalEnabled: true) + try! fillCardData(app, cardNumber: cardNumber, postalEnabled: true) app.buttons["Save"].tap() let confirmButton = app.buttons["Confirm"] XCTAssertTrue(confirmButton.waitForExistence(timeout: 60.0)) confirmButton.tap() - - dismissAlertView(alertBody: "Success: ••••4242, selected", alertTitle: "Complete", buttonToTap: "OK") + let last4 = cardNumber?.suffix(4) ?? "4242" + dismissAlertView(alertBody: "Success: ••••\(last4), selected", alertTitle: "Complete", buttonToTap: "OK") } - func removeFirstPaymentMethodInList() { + func removeFirstPaymentMethodInList(alertBodyText: String) { let removeButton1 = app.buttons["Remove"].firstMatch removeButton1.tap() - dismissAlertView(alertBody: "Remove Visa ending in 4242", alertTitle: "Remove Card", buttonToTap: "Remove") + dismissAlertView(alertBody: alertBodyText, alertTitle: "Remove Card", buttonToTap: "Remove") } func dismissAlertView(alertBody: String, alertTitle: String, buttonToTap: String) { diff --git a/Stripe/StripeiOSTests/CustomerAdapterTests.swift b/Stripe/StripeiOSTests/CustomerAdapterTests.swift index 9d472436cac..ec2f0fe7954 100644 --- a/Stripe/StripeiOSTests/CustomerAdapterTests.swift +++ b/Stripe/StripeiOSTests/CustomerAdapterTests.swift @@ -86,6 +86,51 @@ class CustomerAdapterTests: APIStubbedTestCase { } } + func stubElementsSessions( + key: CustomerEphemeralKey, + paymentMethodJSONs: [[AnyHashable: Any]], + expectedCount: Int, + apiClient: STPAPIClient + ) { + let exp = expectation(description: "listPaymentMethod") + exp.expectedFulfillmentCount = expectedCount + stub { urlRequest in + return urlRequest.url?.absoluteString.contains("/elements/sessions") ?? false + && urlRequest.url?.query?.contains("legacy_customer_ephemeral_key=\(key.ephemeralKeySecret)") ?? false + && urlRequest.httpMethod == "GET" + } response: { _ in + let paymentMethodsJSON = """ + { + "session_id": "123", + "payment_method_preference": { + "object": "payment_method_preference", + "country_code": "US", + "ordered_payment_method_types": [ + "card" + ], + }, + "legacy_customer" : { + "payment_methods": [ + ] + } + } + """ + var pmList = + try! JSONSerialization.jsonObject( + with: paymentMethodsJSON.data(using: .utf8)!, + options: [] + ) as! [AnyHashable: Any] + var legacyCustomer = pmList["legacy_customer"] as! [AnyHashable: Any] + legacyCustomer["payment_methods"] = paymentMethodJSONs + pmList["legacy_customer"] = legacyCustomer + DispatchQueue.main.async { + // Fulfill after response is sent + exp.fulfill() + } + return HTTPStubsResponse(jsonObject: pmList, statusCode: 200, headers: nil) + } + } + func testGetOrCreateKeyErrorForwardedToFetchPMs() async throws { let exp = expectation(description: "fetchPMs") let expectedError = NSError(domain: "test", code: 123, userInfo: nil) @@ -113,9 +158,8 @@ class CustomerAdapterTests: APIStubbedTestCase { let expectedPaymentMethods = [STPFixtures.paymentMethod()] let expectedPaymentMethodsJSON = [STPFixtures.paymentMethodJSON()] let apiClient = stubbedAPIClient() - // Expect 1 call per PM: cards - stubListPaymentMethods(key: exampleKey, paymentMethodType: "card", paymentMethodJSONs: expectedPaymentMethodsJSON, expectedCount: 1, apiClient: apiClient) - stubListPaymentMethods(key: exampleKey, paymentMethodType: "us_bank_account", paymentMethodJSONs: [], expectedCount: 1, apiClient: apiClient) + + stubElementsSessions(key: exampleKey, paymentMethodJSONs: expectedPaymentMethodsJSON, expectedCount: 1, apiClient: apiClient) let ekm = MockEphemeralKeyEndpoint(exampleKey) let sut = StripeCustomerAdapter(customerEphemeralKeyProvider: ekm.getEphemeralKey, apiClient: apiClient) @@ -127,15 +171,11 @@ class CustomerAdapterTests: APIStubbedTestCase { } func testFetchPM_CardAndUSBankAccount() async throws { - let expectedPaymentMethods_card = [STPFixtures.paymentMethod()] - let expectedPaymentMethods_cardJSON = [STPFixtures.paymentMethodJSON()] - - let expectedPaymentMethods_usbank = [STPFixtures.bankAccountPaymentMethod()] - let expectedPaymentMethods_usbankJSON = [STPFixtures.bankAccountPaymentMethodJSON()] - + let expectedPaymentMethods_card_usBank = [STPFixtures.paymentMethod(), STPFixtures.bankAccountPaymentMethod()] + let expectedPaymentMethods_card_usBankJSON = [STPFixtures.paymentMethodJSON(), STPFixtures.bankAccountPaymentMethodJSON()] let apiClient = stubbedAPIClient() - stubListPaymentMethods(key: exampleKey, paymentMethodType: "card", paymentMethodJSONs: expectedPaymentMethods_cardJSON, expectedCount: 1, apiClient: apiClient) - stubListPaymentMethods(key: exampleKey, paymentMethodType: "us_bank_account", paymentMethodJSONs: expectedPaymentMethods_usbankJSON, expectedCount: 1, apiClient: apiClient) + + stubElementsSessions(key: exampleKey, paymentMethodJSONs: expectedPaymentMethods_card_usBankJSON, expectedCount: 1, apiClient: apiClient) let ekm = MockEphemeralKeyEndpoint(exampleKey) let sut = StripeCustomerAdapter(customerEphemeralKeyProvider: ekm.getEphemeralKey, @@ -143,8 +183,8 @@ class CustomerAdapterTests: APIStubbedTestCase { apiClient: apiClient) let pms = try await sut.fetchPaymentMethods() XCTAssertEqual(pms.count, 2) - XCTAssertEqual(pms[0].stripeId, expectedPaymentMethods_card[0].stripeId) - XCTAssertEqual(pms[1].stripeId, expectedPaymentMethods_usbank[0].stripeId) + XCTAssertEqual(pms[0].stripeId, expectedPaymentMethods_card_usBank[0].stripeId) + XCTAssertEqual(pms[1].stripeId, expectedPaymentMethods_card_usBank[1].stripeId) await waitForExpectations(timeout: 2) } @@ -152,9 +192,8 @@ class CustomerAdapterTests: APIStubbedTestCase { let expectedPaymentMethods = [STPFixtures.paymentMethod()] let expectedPaymentMethodsJSON = [STPFixtures.paymentMethodJSON()] let apiClient = stubbedAPIClient() - // Expect 1 call per PM: cards - stubListPaymentMethods(key: exampleKey, paymentMethodType: "card", paymentMethodJSONs: expectedPaymentMethodsJSON, expectedCount: 1, apiClient: apiClient) - stubListPaymentMethods(key: exampleKey, paymentMethodType: "us_bank_account", paymentMethodJSONs: [], expectedCount: 1, apiClient: apiClient) + + stubElementsSessions(key: exampleKey, paymentMethodJSONs: expectedPaymentMethodsJSON, expectedCount: 1, apiClient: apiClient) let ekm = MockEphemeralKeyEndpoint(exampleKey) let sut = StripeCustomerAdapter(customerEphemeralKeyProvider: ekm.getEphemeralKey, apiClient: apiClient) diff --git a/StripePaymentSheet/StripePaymentSheet/Source/Internal/API Bindings/STPAPIClient+PaymentSheet.swift b/StripePaymentSheet/StripePaymentSheet/Source/Internal/API Bindings/STPAPIClient+PaymentSheet.swift index cc64baf37ff..80a9754dc9a 100644 --- a/StripePaymentSheet/StripePaymentSheet/Source/Internal/API Bindings/STPAPIClient+PaymentSheet.swift +++ b/StripePaymentSheet/StripePaymentSheet/Source/Internal/API Bindings/STPAPIClient+PaymentSheet.swift @@ -14,7 +14,8 @@ extension STPAPIClient { typealias STPIntentCompletionBlock = ((Result) -> Void) func retrievePaymentIntentWithPreferences( - withClientSecret secret: String + withClientSecret secret: String, + customerEphemeralKey: String? = nil ) async throws -> STPPaymentIntent { guard STPPaymentIntentParams.isClientSecretValid(secret) && !publishableKeyIsUserKey else { throw NSError.stp_clientSecretError() @@ -25,6 +26,9 @@ extension STPAPIClient { parameters["type"] = "payment_intent" parameters["expand"] = ["payment_method_preference.payment_intent.payment_method"] parameters["locale"] = Locale.current.toLanguageTag() + if let customerEphemeralKey { + parameters["legacy_customer_ephemeral_key"] = customerEphemeralKey + } return try await APIRequest.getWith( self, @@ -34,9 +38,14 @@ extension STPAPIClient { } func retrieveElementsSession( - withIntentConfig intentConfig: PaymentSheet.IntentConfiguration + withIntentConfig intentConfig: PaymentSheet.IntentConfiguration, + customerEphemeralKey: String? = nil ) async throws -> STPElementsSession { - let parameters = intentConfig.elementsSessionParameters(publishableKey: publishableKey) + var parameters = intentConfig.elementsSessionParameters(publishableKey: publishableKey) + if let customerEphemeralKey { + parameters["legacy_customer_ephemeral_key"] = customerEphemeralKey + } + return try await APIRequest.getWith( self, endpoint: APIEndpointIntentWithPreferences, @@ -44,7 +53,9 @@ extension STPAPIClient { ) } - func retrieveElementsSessionForCustomerSheet() async throws -> STPElementsSession { + func retrieveElementsSessionForCustomerSheet( + customerEphemeralKey: String? = nil + ) async throws -> STPElementsSession { var parameters: [String: Any] = [:] parameters["type"] = "deferred_intent" parameters["locale"] = Locale.current.toLanguageTag() @@ -52,6 +63,9 @@ extension STPAPIClient { var deferredIntent = [String: Any]() deferredIntent["mode"] = "setup" parameters["deferred_intent"] = deferredIntent + if let customerEphemeralKey { + parameters["legacy_customer_ephemeral_key"] = customerEphemeralKey + } return try await APIRequest.getWith( self, @@ -61,7 +75,8 @@ extension STPAPIClient { } func retrieveSetupIntentWithPreferences( - withClientSecret secret: String + withClientSecret secret: String, + customerEphemeralKey: String? = nil ) async throws -> STPSetupIntent { guard STPSetupIntentConfirmParams.isClientSecretValid(secret) && !publishableKeyIsUserKey else { throw NSError.stp_clientSecretError() @@ -72,6 +87,9 @@ extension STPAPIClient { parameters["type"] = "setup_intent" parameters["expand"] = ["payment_method_preference.setup_intent.payment_method"] parameters["locale"] = Locale.current.toLanguageTag() + if let customerEphemeralKey { + parameters["legacy_customer_ephemeral_key"] = customerEphemeralKey + } return try await APIRequest.getWith( self, diff --git a/StripePaymentSheet/StripePaymentSheet/Source/Internal/API Bindings/STPElementsCustomerInformation.swift b/StripePaymentSheet/StripePaymentSheet/Source/Internal/API Bindings/STPElementsCustomerInformation.swift new file mode 100644 index 00000000000..a4ed0a74dee --- /dev/null +++ b/StripePaymentSheet/StripePaymentSheet/Source/Internal/API Bindings/STPElementsCustomerInformation.swift @@ -0,0 +1,48 @@ +// +// STPElementsCustomerInformation.swift +// StripePaymentSheet +// + +import Foundation +@_spi(STP) import StripePayments + +final class STPElementsCustomerInformation: NSObject { + let paymentMethods: [STPPaymentMethod] + + let allResponseFields: [AnyHashable: Any] + + /// :nodoc: + @objc public override var description: String { + let props: [String] = [ + String(format: "%@: %p", NSStringFromClass(STPElementsCustomerInformation.self), self), + "paymentMethods = \(String(describing: paymentMethods))", + ] + + return "<\(props.joined(separator: "; "))>" + } + + private init( + allResponseFields: [AnyHashable: Any], + paymentMethods: [STPPaymentMethod] + ) { + self.allResponseFields = allResponseFields + self.paymentMethods = paymentMethods + super.init() + } +} + +// MARK: - STPAPIResponseDecodable +extension STPElementsCustomerInformation: STPAPIResponseDecodable { + public static func decodedObject(fromAPIResponse response: [AnyHashable: Any]?) -> Self? { + guard let dict = response, + let paymentMethodPrefDict = dict["legacy_customer"] as? [AnyHashable: Any], + let savedPaymentMethods = paymentMethodPrefDict["payment_methods"] as? [[AnyHashable: Any]] else { + return nil + } + let paymentMethods = savedPaymentMethods.compactMap { STPPaymentMethod.decodedObject(fromAPIResponse: $0) } + return STPElementsCustomerInformation( + allResponseFields: dict, + paymentMethods: paymentMethods + ) as? Self + } +} diff --git a/StripePaymentSheet/StripePaymentSheet/Source/Internal/API Bindings/STPElementsSession.swift b/StripePaymentSheet/StripePaymentSheet/Source/Internal/API Bindings/STPElementsSession.swift index b85de13a3b8..381d9d2e171 100644 --- a/StripePaymentSheet/StripePaymentSheet/Source/Internal/API Bindings/STPElementsSession.swift +++ b/StripePaymentSheet/StripePaymentSheet/Source/Internal/API Bindings/STPElementsSession.swift @@ -31,6 +31,12 @@ final class STPElementsSession: NSObject { /// A map describing payment method types form specs. let paymentMethodSpecs: [[AnyHashable: Any]]? + /// An object containing Customer's saved payment methods information + let elementsCustomerInformation: STPElementsCustomerInformation? + + /// An error associated with operations surrounding STPElementsCustomerInformation + let customerError: Error? + let allResponseFields: [AnyHashable: Any] /// :nodoc: @@ -45,6 +51,8 @@ final class STPElementsSession: NSObject { "countryCode = \(String(describing: countryCode))", "merchantCountryCode = \(String(describing: merchantCountryCode))", "paymentMethodSpecs = \(String(describing: paymentMethodSpecs))", + "elementsCustomerInformation = \(String(describing: elementsCustomerInformation))", + "customerError = \(String(describing: customerError))", ] return "<\(props.joined(separator: "; "))>" @@ -58,7 +66,9 @@ final class STPElementsSession: NSObject { countryCode: String?, merchantCountryCode: String?, linkSettings: LinkSettings?, - paymentMethodSpecs: [[AnyHashable: Any]]? + paymentMethodSpecs: [[AnyHashable: Any]]?, + elementsCustomerInformation: STPElementsCustomerInformation?, + customerError: Error? ) { self.allResponseFields = allResponseFields self.sessionID = sessionID @@ -68,6 +78,8 @@ final class STPElementsSession: NSObject { self.merchantCountryCode = merchantCountryCode self.linkSettings = linkSettings self.paymentMethodSpecs = paymentMethodSpecs + self.elementsCustomerInformation = elementsCustomerInformation + self.customerError = customerError super.init() } } @@ -94,7 +106,17 @@ extension STPElementsSession: STPAPIResponseDecodable { linkSettings: LinkSettings.decodedObject( fromAPIResponse: dict["link_settings"] as? [AnyHashable: Any] ), - paymentMethodSpecs: dict["payment_method_specs"] as? [[AnyHashable: Any]] + paymentMethodSpecs: dict["payment_method_specs"] as? [[AnyHashable: Any]], + elementsCustomerInformation: STPElementsCustomerInformation.decodedObject(fromAPIResponse: dict), + customerError: decodedCustomerErrorObject(fromAPIResponse: dict["customer_error"] as? [AnyHashable: Any]) ) as? Self } + + public static func decodedCustomerErrorObject(fromAPIResponse response: [AnyHashable: Any]?) -> Error? { + guard let dict = response, + let errorMessage = dict["customer_error"] as? String else { + return nil + } + return PaymentSheetError.fetchSavedPaymentMethodsViaElementsFailure(message: errorMessage) + } } diff --git a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/CustomerAdapter/CustomerAdapter.swift b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/CustomerAdapter/CustomerAdapter.swift index 3bba9addf91..c31a4caac79 100644 --- a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/CustomerAdapter/CustomerAdapter.swift +++ b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/CustomerAdapter/CustomerAdapter.swift @@ -135,22 +135,11 @@ open class StripeCustomerAdapter: CustomerAdapter { open func fetchPaymentMethods() async throws -> [STPPaymentMethod] { let customerEphemeralKey = try await customerEphemeralKey - return try await withCheckedThrowingContinuation({ continuation in - // List the Customer's saved PaymentMethods - let savedPaymentMethodTypes: [STPPaymentMethodType] = [.card, .USBankAccount] // hardcoded for now - apiClient.listPaymentMethods( - forCustomer: customerEphemeralKey.id, - using: customerEphemeralKey.ephemeralKeySecret, - types: savedPaymentMethodTypes - ) { paymentMethods, error in - guard let paymentMethods = paymentMethods, error == nil else { - let error = error ?? PaymentSheetError.unexpectedResponseFromStripeAPI // TODO: make a better default error - continuation.resume(throwing: error) - return - } - continuation.resume(with: .success(paymentMethods)) - } - }) + let elementSession = try await apiClient.retrieveElementsSessionForCustomerSheet(customerEphemeralKey: customerEphemeralKey.ephemeralKeySecret) + if let customer_error = elementSession.customerError { + throw CustomerSheetError.errorFetchingSavedPaymentMethods(customer_error) + } + return elementSession.elementsCustomerInformation?.paymentMethods ?? [] } open func attachPaymentMethod(_ paymentMethodId: String) async throws { @@ -168,15 +157,26 @@ open class StripeCustomerAdapter: CustomerAdapter { open func detachPaymentMethod(paymentMethodId: String) async throws { let customerEphemeralKey = try await customerEphemeralKey - return try await withCheckedThrowingContinuation({ continuation in - apiClient.detachPaymentMethod(paymentMethodId, fromCustomerUsing: customerEphemeralKey.ephemeralKeySecret) { error in - if let error = error { - continuation.resume(throwing: error) - return - } - continuation.resume() + + var paymentMethodIdsToDetach: [String] = [] + do { + paymentMethodIdsToDetach = try await StripeCustomerAdapter.fetchSimilarSavedPaymentMethods(apiClient: apiClient, + customerEphemeralKey: customerEphemeralKey, + paymentMethodId: paymentMethodId) + } catch { + paymentMethodIdsToDetach = [paymentMethodId] + } + + var lastError: Error? + for paymentMethodIdToDetach in paymentMethodIdsToDetach { + if let error = try await apiClient.detachPaymentMethod(paymentMethodIdToDetach, + fromCustomerUsing: customerEphemeralKey.ephemeralKeySecret) { + lastError = error } - }) + } + if let lastError { + throw lastError + } } open func setSelectedPaymentOption(paymentOption: CustomerPaymentOption?) async throws { @@ -197,6 +197,43 @@ open class StripeCustomerAdapter: CustomerAdapter { } return try await setupIntentClientSecretProvider() } + + static func fetchSimilarSavedPaymentMethods(apiClient: STPAPIClient, + customerEphemeralKey: CustomerEphemeralKey, + paymentMethodId: String) async throws -> [String] { + let fetchedPaymentMethods = try await fetchSavedPaymentMethods(apiClient: apiClient, + customerEphemeralKey: customerEphemeralKey, + savedPaymentMethodTypes: [.card]) + let paymentMethodToDelete = fetchedPaymentMethods.first { paymentMethodId == $0.stripeId } + guard let fingerprint = paymentMethodToDelete?.card?.fingerprint else { + return [paymentMethodId] + } + let paymentMethodIdsToDelete = fetchedPaymentMethods.filter { $0.card?.fingerprint == fingerprint } + .map { $0.stripeId } + guard !paymentMethodIdsToDelete.isEmpty else { + return [paymentMethodId] + } + return paymentMethodIdsToDelete + } + + static func fetchSavedPaymentMethods(apiClient: STPAPIClient, + customerEphemeralKey: CustomerEphemeralKey, + savedPaymentMethodTypes: [STPPaymentMethodType] = [.card, .USBankAccount]) async throws -> [STPPaymentMethod] { + return try await withCheckedThrowingContinuation { continuation in + apiClient.listPaymentMethods( + forCustomer: customerEphemeralKey.id, + using: customerEphemeralKey.ephemeralKeySecret, + types: savedPaymentMethodTypes + ) { paymentMethods, error in + guard let paymentMethods = paymentMethods, error == nil else { + let error = error ?? PaymentSheetError.unexpectedResponseFromStripeAPI + continuation.resume(throwing: error) + return + } + continuation.resume(returning: paymentMethods) + } + } + } } @_spi(STP) extension StripeCustomerAdapter: STPAnalyticsProtocol { diff --git a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/CustomerSheet/CustomerSavedPaymentMethodsCollectionViewController.swift b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/CustomerSheet/CustomerSavedPaymentMethodsCollectionViewController.swift index 0746981ec65..f85ff881222 100644 --- a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/CustomerSheet/CustomerSavedPaymentMethodsCollectionViewController.swift +++ b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/CustomerSheet/CustomerSavedPaymentMethodsCollectionViewController.swift @@ -213,7 +213,10 @@ class CustomerSavedPaymentMethodsCollectionViewController: UIViewController { required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - public func didAddSavedPaymentMethod(paymentMethod: STPPaymentMethod) { + func didUpdateSavedPaymentMethods(paymentMethods: [STPPaymentMethod]) { + self.savedPaymentMethods = paymentMethods + } + func didAddSavedPaymentMethod(paymentMethod: STPPaymentMethod) { let unsyncedSavedPaymentMethodsCopy = unsyncedSavedPaymentMethods self.unsyncedSavedPaymentMethods = [paymentMethod] + unsyncedSavedPaymentMethodsCopy } diff --git a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/CustomerSheet/CustomerSavedPaymentMethodsViewController.swift b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/CustomerSheet/CustomerSavedPaymentMethodsViewController.swift index 3938027dfee..3c0a7a11d49 100644 --- a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/CustomerSheet/CustomerSavedPaymentMethodsViewController.swift +++ b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/CustomerSheet/CustomerSavedPaymentMethodsViewController.swift @@ -21,7 +21,7 @@ protocol CustomerSavedPaymentMethodsViewControllerDelegate: AnyObject { class CustomerSavedPaymentMethodsViewController: UIViewController { // MARK: - Read-only Properties - let savedPaymentMethods: [STPPaymentMethod] + var savedPaymentMethods: [STPPaymentMethod] let selectedPaymentMethodOption: CustomerPaymentOption? let isApplePayEnabled: Bool let configuration: CustomerSheet.Configuration @@ -449,11 +449,21 @@ class CustomerSavedPaymentMethodsViewController: UIViewController { self.updateUI() return } + + //before we finish + let paymentMethods = try await self.customerAdapter.fetchPaymentMethods() + self.processingInFlight = false if shouldDismissSheetOnConfirm(paymentMethod: paymentMethod, setupIntent: setupIntent) { self.handleDismissSheet(sheetCloseReason: .addedUsBankAccountViaMicrodeposits) } else { - self.savedPaymentOptionsViewController.didAddSavedPaymentMethod(paymentMethod: paymentMethod) +// self.savedPaymentOptionsViewController.didAddSavedPaymentMethod(paymentMethod: paymentMethod) + + savedPaymentMethods = paymentMethods + savedPaymentOptionsViewController.didUpdateSavedPaymentMethods(paymentMethods: savedPaymentMethods) + // TODO: Ensure that fetching Payment methods will give us the card we just entered if there is overlap? + // TODO: remove unsynced payment methods + self.mode = .selectingSaved self.updateUI(animated: true) self.reinitAddPaymentMethodViewController() diff --git a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/CustomerSheet/CustomerSheet.swift b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/CustomerSheet/CustomerSheet.swift index 4d8dafb6e31..42a8d299a94 100644 --- a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/CustomerSheet/CustomerSheet.swift +++ b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/CustomerSheet/CustomerSheet.swift @@ -163,8 +163,8 @@ extension CustomerSheet { async let paymentMethodsResult = try customerAdapter.fetchPaymentMethods() async let selectedPaymentMethodResult = try self.customerAdapter.fetchSelectedPaymentOption() async let merchantSupportedPaymentMethodTypes = try self.retrieveMerchantSupportedPaymentMethodTypes() - let (paymentMethods, selectedPaymentMethod, elementSesssion) = try await (paymentMethodsResult, selectedPaymentMethodResult, merchantSupportedPaymentMethodTypes) - completion(.success((paymentMethods, selectedPaymentMethod, elementSesssion))) + let (paymentMethods, selectedPaymentMethod, supportedPaymentMethodTypes) = try await (paymentMethodsResult, selectedPaymentMethodResult, merchantSupportedPaymentMethodTypes) + completion(.success((paymentMethods, selectedPaymentMethod, supportedPaymentMethodTypes))) } catch { completion(.failure(error)) } diff --git a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/PaymentSheetError.swift b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/PaymentSheetError.swift index e539eb3c901..fd116121144 100644 --- a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/PaymentSheetError.swift +++ b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/PaymentSheetError.swift @@ -36,7 +36,10 @@ public enum PaymentSheetError: Error { // MARK: Loading errors case paymentIntentInTerminalState(status: STPPaymentIntentStatus) case setupIntentInTerminalState(status: STPSetupIntentStatus) + /// This error is used when failing to get saved payment methods from the public api /v1/payment_methods endpoint case fetchPaymentMethodsFailure + /// This error is used when failing to get saved payment methods from the private api elements/sessions + case fetchSavedPaymentMethodsViaElementsFailure(message: String) // MARK: Deferred intent errors case deferredIntentValidationFailed(message: String) @@ -99,6 +102,8 @@ extension PaymentSheetError: CustomDebugStringConvertible { return "setupIntentInTerminalState" case .fetchPaymentMethodsFailure: return "fetchPaymentMethodsFailure" + case .fetchSavedPaymentMethodsViaElementsFailure: + return "fetchSavedPaymentMethodsViaElementsFailure" case .deferredIntentValidationFailed: return "deferredIntentValidationFailed" case .linkSignUpNotRequired: @@ -161,6 +166,8 @@ extension PaymentSheetError: CustomDebugStringConvertible { return "PaymentSheet received a SetupIntent in a terminal state: \(status)" case .fetchPaymentMethodsFailure: return "Failed to retrieve PaymentMethods for the customer" + case .fetchSavedPaymentMethodsViaElementsFailure: + return "Failed to retrieve saved PaymentMethods for the customer on elements/sessions" case .linkSignUpNotRequired: return "Don't call sign up if not needed" case .noPaymentMethodTypesAvailable(intentPaymentMethods: let intentPaymentMethods): diff --git a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/PaymentSheetLoader.swift b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/PaymentSheetLoader.swift index 0f6aaa220da..ca651dbb936 100644 --- a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/PaymentSheetLoader.swift +++ b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/PaymentSheetLoader.swift @@ -35,14 +35,14 @@ final class PaymentSheetLoader { // Fetch PaymentIntent, SetupIntent, or ElementsSession async let _intent = fetchIntent(mode: mode, configuration: configuration) - // List the Customer's saved PaymentMethods - // TODO: Use v1/elements/sessions to fetch saved PMS https://jira.corp.stripe.com/browse/MOBILESDK-964 - async let savedPaymentMethods = fetchSavedPaymentMethods(configuration: configuration) - // Load misc singletons await loadMiscellaneousSingletons() let intent = try await _intent + + // List the Customer's saved PaymentMethods + async let savedPaymentMethods = getSavedPaymentMethods(intent: intent, configuration: configuration) + // Overwrite the form specs that were already loaded from disk switch intent { case .paymentIntent(let paymentIntent): @@ -141,11 +141,12 @@ final class PaymentSheetLoader { static func fetchIntent(mode: PaymentSheet.InitializationMode, configuration: PaymentSheet.Configuration) async throws -> Intent { let intent: Intent + let customerEphemeralKey = configuration.customer?.ephemeralKeySecret switch mode { case .paymentIntentClientSecret(let clientSecret): let paymentIntent: STPPaymentIntent do { - paymentIntent = try await configuration.apiClient.retrievePaymentIntentWithPreferences(withClientSecret: clientSecret) + paymentIntent = try await configuration.apiClient.retrievePaymentIntentWithPreferences(withClientSecret: clientSecret, customerEphemeralKey: customerEphemeralKey) } catch { // Fallback to regular retrieve PI when retrieve PI with preferences fails paymentIntent = try await configuration.apiClient.retrievePaymentIntent(clientSecret: clientSecret) @@ -158,7 +159,7 @@ final class PaymentSheetLoader { case .setupIntentClientSecret(let clientSecret): let setupIntent: STPSetupIntent do { - setupIntent = try await configuration.apiClient.retrieveSetupIntentWithPreferences(withClientSecret: clientSecret) + setupIntent = try await configuration.apiClient.retrieveSetupIntentWithPreferences(withClientSecret: clientSecret, customerEphemeralKey: customerEphemeralKey) } catch { // Fallback to regular retrieve SI when retrieve SI with preferences fails setupIntent = try await configuration.apiClient.retrieveSetupIntent(clientSecret: clientSecret) @@ -170,7 +171,7 @@ final class PaymentSheetLoader { intent = .setupIntent(setupIntent) case .deferredIntent(let intentConfig): - let elementsSession = try await configuration.apiClient.retrieveElementsSession(withIntentConfig: intentConfig) + let elementsSession = try await configuration.apiClient.retrieveElementsSession(withIntentConfig: intentConfig, customerEphemeralKey: customerEphemeralKey) intent = .deferredIntent(elementsSession: elementsSession, intentConfig: intentConfig) } // Ensure that there's at least 1 payment method type available for the intent and configuration. @@ -189,8 +190,43 @@ final class PaymentSheetLoader { return intent } - static func fetchSavedPaymentMethods(configuration: PaymentSheet.Configuration) async throws -> [STPPaymentMethod] { - let savedPaymentMethodTypes: [STPPaymentMethodType] = [.card, .USBankAccount] // hardcoded for now + static func getSavedPaymentMethods(intent: Intent, configuration: PaymentSheet.Configuration) async throws -> [STPPaymentMethod] { + if let saved = getPaymentMethodsFrom(intent: intent) { + return saved + } else { + // If getting payment methods from elements/sessions fails, + // fall back to fetching the saved PMs in the public API + return try await fetchSavedPaymentMethods(configuration: configuration) + } + } + + static func getPaymentMethodsFrom(intent: Intent) -> [STPPaymentMethod]? { + switch intent { + case .paymentIntent(let underlyingIntent): + return STPElementsCustomerInformation.decodedObject(fromAPIResponse: underlyingIntent.allResponseFields)?.paymentMethods + case .setupIntent(let underlyingIntent): + return STPElementsCustomerInformation.decodedObject(fromAPIResponse: underlyingIntent.allResponseFields)?.paymentMethods + case .deferredIntent(let elementsSession, _): + return elementsSession.elementsCustomerInformation?.paymentMethods + } + } + + static func fetchSimilarSavedPaymentMethods(configuration: PaymentSheet.Configuration, + paymentMethod: STPPaymentMethod) async throws -> [STPPaymentMethod] { + guard paymentMethod.type == .card else { + return [paymentMethod] + } + let fetchedPaymentMethods = try await fetchSavedPaymentMethods(configuration: configuration, + savedPaymentMethodTypes: [.card]) + let paymentMethodToDelete = fetchedPaymentMethods.first { paymentMethod.stripeId == $0.stripeId } + guard let fingerprint = paymentMethodToDelete?.card?.fingerprint else { + return [paymentMethod] + } + return fetchedPaymentMethods.filter { $0.card?.fingerprint == fingerprint } + } + + static func fetchSavedPaymentMethods(configuration: PaymentSheet.Configuration, + savedPaymentMethodTypes: [STPPaymentMethodType] = [.card, .USBankAccount]) async throws -> [STPPaymentMethod] { guard let customerID = configuration.customer?.id, let ephemeralKey = configuration.customer?.ephemeralKeySecret else { return [] } diff --git a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/ViewControllers/PaymentSheetFlowControllerViewController.swift b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/ViewControllers/PaymentSheetFlowControllerViewController.swift index fa371e61dba..d90627fc978 100644 --- a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/ViewControllers/PaymentSheetFlowControllerViewController.swift +++ b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/ViewControllers/PaymentSheetFlowControllerViewController.swift @@ -476,21 +476,35 @@ extension PaymentSheetFlowControllerViewController: SavedPaymentOptionsViewContr else { return } - configuration.apiClient.detachPaymentMethod( - paymentMethod.stripeId, fromCustomerUsing: ephemeralKey - ) { (_) in - // no-op - } - if !savedPaymentOptionsViewController.hasRemovablePaymentMethods { - savedPaymentOptionsViewController.isRemovingPaymentMethods = false - // calling updateUI() at this point causes an issue with the height of the add card vc - // if you do a subsequent presentation. Since bottom sheet height stuff is complicated, - // just update the nav bar which is all we need to do anyway - configureNavBar() + Task { + var paymentMethodIdsToDetach: [String] = [] + do { + let paymentMethods = try await PaymentSheetLoader.fetchSimilarSavedPaymentMethods(configuration: configuration, + paymentMethod: paymentMethod) + paymentMethodIdsToDetach = paymentMethods.map { $0.stripeId } + } catch { + paymentMethodIdsToDetach = [paymentMethod.stripeId] + } + for paymentMethodIdToDetach in paymentMethodIdsToDetach { + configuration.apiClient.detachPaymentMethod( + paymentMethodIdToDetach, + fromCustomerUsing: ephemeralKey + ) { (_) in + // no-op + } + } + + if !savedPaymentOptionsViewController.hasRemovablePaymentMethods { + savedPaymentOptionsViewController.isRemovingPaymentMethods = false + // calling updateUI() at this point causes an issue with the height of the add card vc + // if you do a subsequent presentation. Since bottom sheet height stuff is complicated, + // just update the nav bar which is all we need to do anyway + configureNavBar() + } + updateButton() + updateBottomNotice() } - updateButton() - updateBottomNotice() } // MARK: Helpers diff --git a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/ViewControllers/PaymentSheetViewController.swift b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/ViewControllers/PaymentSheetViewController.swift index effe0c4f878..42bf88c2628 100644 --- a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/ViewControllers/PaymentSheetViewController.swift +++ b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/ViewControllers/PaymentSheetViewController.swift @@ -561,20 +561,32 @@ extension PaymentSheetViewController: SavedPaymentOptionsViewControllerDelegate else { return } - configuration.apiClient.detachPaymentMethod( - paymentMethod.stripeId, - fromCustomerUsing: ephemeralKey - ) { (_) in - // no-op - } - if !savedPaymentOptionsViewController.hasRemovablePaymentMethods { - savedPaymentOptionsViewController.isRemovingPaymentMethods = false - // calling updateUI() at this point causes an issue with the height of the add card vc - // if you do a subsequent presentation. Since bottom sheet height stuff is complicated, - // just update the nav bar which is all we need to do anyway - configureNavBar() + Task { + var paymentMethodIdsToDetach: [String] = [] + do { + let paymentMethods = try await PaymentSheetLoader.fetchSimilarSavedPaymentMethods(configuration: configuration, + paymentMethod: paymentMethod) + paymentMethodIdsToDetach = paymentMethods.map { $0.stripeId } + } catch { + paymentMethodIdsToDetach = [paymentMethod.stripeId] + } + for paymentMethodIdToDetach in paymentMethodIdsToDetach { + configuration.apiClient.detachPaymentMethod( + paymentMethodIdToDetach, + fromCustomerUsing: ephemeralKey + ) { (_) in + // no-op + } + } + if !savedPaymentOptionsViewController.hasRemovablePaymentMethods { + savedPaymentOptionsViewController.isRemovingPaymentMethods = false + // calling updateUI() at this point causes an issue with the height of the add card vc + // if you do a subsequent presentation. Since bottom sheet height stuff is complicated, + // just update the nav bar which is all we need to do anyway + configureNavBar() + } + updateBottomNotice() } - updateBottomNotice() } // MARK: Helpers diff --git a/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/CustomerSheet/CustomerSheetTests.swift b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/CustomerSheet/CustomerSheetTests.swift index 84dfe138e74..157c6a21892 100644 --- a/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/CustomerSheet/CustomerSheetTests.swift +++ b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/CustomerSheet/CustomerSheetTests.swift @@ -18,9 +18,22 @@ class CustomerSheetTests: APIStubbedTestCase { func testLoadPaymentMethodInfo_newCustomer() throws { let stubbedAPIClient = stubbedAPIClient() - StubbedBackend.stubPaymentMethods(fileMock: .saved_payment_methods_200, pmType: "card") - StubbedBackend.stubPaymentMethods(fileMock: .saved_payment_methods_200, pmType: "us_bank_account") - StubbedBackend.stubSessions(paymentMethods: "\"card\"") + StubbedBackend.stubSessions(fileMock: .elementsSessionsLegacyCustomer_di_withNoSavedPM_200, + paymentMethods: "\"card\"", + requestCallback: { request in + guard let requestUrl = request.url else { + return false + } + return requestUrl.absoluteString.contains("legacy_customer_ephemeral_key") + }) + StubbedBackend.stubSessions(fileMock: .elementsSessionsPaymentMethod_200, + paymentMethods: "\"card\"", + requestCallback: { request in + guard let requestUrl = request.url else { + return false + } + return !requestUrl.absoluteString.contains("legacy_customer_ephemeral_key") + }) let configuration = CustomerSheet.Configuration() let customerAdapter = StripeCustomerAdapter(customerEphemeralKeyProvider: { @@ -45,9 +58,22 @@ class CustomerSheetTests: APIStubbedTestCase { func testLoadPaymentMethodInfo_singleCard() throws { let stubbedAPIClient = stubbedAPIClient() - StubbedBackend.stubPaymentMethods(fileMock: .saved_payment_methods_withCard_200, pmType: "card") - StubbedBackend.stubPaymentMethods(fileMock: .saved_payment_methods_200, pmType: "us_bank_account") - StubbedBackend.stubSessions(paymentMethods: "\"card\"") + StubbedBackend.stubSessions(fileMock: .elementsSessionsLegacyCustomer_di_withSavedCard_200, + paymentMethods: "\"card\"", + requestCallback: { request in + guard let requestUrl = request.url else { + return false + } + return requestUrl.absoluteString.contains("legacy_customer_ephemeral_key") + }) + StubbedBackend.stubSessions(fileMock: .elementsSessionsPaymentMethod_200, + paymentMethods: "\"card\"", + requestCallback: { request in + guard let requestUrl = request.url else { + return false + } + return !requestUrl.absoluteString.contains("legacy_customer_ephemeral_key") + }) let configuration = CustomerSheet.Configuration() let customerAdapter = StripeCustomerAdapter(customerEphemeralKeyProvider: { @@ -73,9 +99,22 @@ class CustomerSheetTests: APIStubbedTestCase { func testLoadPaymentMethodInfo_singleBankAccount() throws { let stubbedAPIClient = stubbedAPIClient() - StubbedBackend.stubPaymentMethods(fileMock: .saved_payment_methods_200, pmType: "card") - StubbedBackend.stubPaymentMethods(fileMock: .saved_payment_methods_withUSBank_200, pmType: "us_bank_account") - StubbedBackend.stubSessions(paymentMethods: "\"us_bank_account\"") + StubbedBackend.stubSessions(fileMock: .elementsSessionsLegacyCustomer_di_withSavedUSBank_200, + paymentMethods: "\"us_bank_account\"", + requestCallback: { request in + guard let requestUrl = request.url else { + return false + } + return requestUrl.absoluteString.contains("legacy_customer_ephemeral_key") + }) + StubbedBackend.stubSessions(fileMock: .elementsSessionsPaymentMethod_200, + paymentMethods: "\"us_bank_account\"", + requestCallback: { request in + guard let requestUrl = request.url else { + return false + } + return !requestUrl.absoluteString.contains("legacy_customer_ephemeral_key") + }) let configuration = CustomerSheet.Configuration() let customerAdapter = StripeCustomerAdapter(customerEphemeralKeyProvider: { @@ -102,9 +141,23 @@ class CustomerSheetTests: APIStubbedTestCase { func testLoadPaymentMethodInfo_cardAndBankAccount() throws { let stubbedAPIClient = stubbedAPIClient() - StubbedBackend.stubPaymentMethods(fileMock: .saved_payment_methods_withCard_200, pmType: "card") - StubbedBackend.stubPaymentMethods(fileMock: .saved_payment_methods_withUSBank_200, pmType: "us_bank_account") - StubbedBackend.stubSessions(paymentMethods: "\"card\", \"us_bank_account\"") + StubbedBackend.stubSessions(fileMock: .elementsSessionsPaymentMethod_200, + paymentMethods: "\"card\", \"us_bank_account\"", + requestCallback: { request in + guard let requestUrl = request.url else { + return false + } + return !requestUrl.absoluteString.contains("legacy_customer_ephemeral_key") + }) + + StubbedBackend.stubSessions(fileMock: .elementsSessionsLegacyCustomer_di_withSavedCardUSBank_200, + paymentMethods: "\"card\", \"us_bank_account\"", + requestCallback: { request in + guard let requestUrl = request.url else { + return false + } + return requestUrl.absoluteString.contains("legacy_customer_ephemeral_key") + }) let configuration = CustomerSheet.Configuration() let customerAdapter = StripeCustomerAdapter(customerEphemeralKeyProvider: { @@ -136,7 +189,26 @@ class CustomerSheetTests: APIStubbedTestCase { let stubbedURLSessionConfig = APIStubbedTestCase.stubbedURLSessionConfig() stubbedURLSessionConfig.timeoutIntervalForRequest = fastTimeoutIntervalForRequest let stubbedAPIClient = stubbedAPIClient(configuration: stubbedURLSessionConfig) - StubbedBackend.stubSessions(paymentMethods: "\"card\"") + StubbedBackend.stubSessions(fileMock: .elementsSessionsPaymentMethod_200, + paymentMethods: "\"card\"", + requestCallback: { request in + guard let requestUrl = request.url else { + return false + } + return !requestUrl.absoluteString.contains("legacy_customer_ephemeral_key") + }) + + StubbedBackend.stubSessions(fileMock: .elementsSessionsLegacyCustomer_di_withNoSavedPM_200, + paymentMethods: "\"card\"", + requestCallback: { request in + guard let requestUrl = request.url else { + return false + } + return requestUrl.absoluteString.contains("legacy_customer_ephemeral_key") + }, responseCallback: { _ in + sleep(timeGreaterThanTimeoutIntervalForRequest) + return "{}".data(using: .utf8)! + }) let configuration = CustomerSheet.Configuration() let customerAdapter = StripeCustomerAdapter(customerEphemeralKeyProvider: { @@ -145,14 +217,6 @@ class CustomerSheetTests: APIStubbedTestCase { return "si_789" }, apiClient: stubbedAPIClient) - stub { urlRequest in - return urlRequest.url?.absoluteString.contains("/v1/payment_methods") ?? false - } response: { _ in - sleep(timeGreaterThanTimeoutIntervalForRequest) - let data = "{}".data(using: .utf8)! - return HTTPStubsResponse(data: data, statusCode: 200, headers: nil) - } - let loadPaymentMethodInfo = expectation(description: "loadPaymentMethodInfo completion block called") let customerSheet = CustomerSheet(configuration: configuration, customer: customerAdapter) customerSheet.loadPaymentMethodInfo { result in diff --git a/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/PaymentSheetLoaderStubbedTest.swift b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/PaymentSheetLoaderStubbedTest.swift index d632298eab0..625a83433d8 100644 --- a/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/PaymentSheetLoaderStubbedTest.swift +++ b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/PaymentSheetLoaderStubbedTest.swift @@ -22,13 +22,12 @@ class PaymentSheetLoaderStubbedTest: APIStubbedTestCase { } func testReturningCustomerWithNoSavedCards() throws { - StubbedBackend.stubPaymentMethods(fileMock: .saved_payment_methods_200, pmType: "card") - StubbedBackend.stubPaymentMethods(fileMock: .saved_payment_methods_200, pmType: "us_bank_account") - StubbedBackend.stubSessions(paymentMethods: "\"card\", \"us_bank_account\"") + StubbedBackend.stubSessions(fileMock: .elementsSessionsLegacyCustomer_pi_withNoSavedPM_200, + paymentMethods: "\"card\", \"us_bank_account\"") let loaded = expectation(description: "Loaded") PaymentSheetLoader.load( - mode: .paymentIntentClientSecret("pi_12345_secret_54321"), + mode: .paymentIntentClientSecret("pi_123456_secret_54321"), configuration: self.configuration(apiClient: stubbedAPIClient()) ) { result in switch result { @@ -48,9 +47,8 @@ class PaymentSheetLoaderStubbedTest: APIStubbedTestCase { } func testReturningCustomerWithSingleSavedCard() throws { - StubbedBackend.stubPaymentMethods(fileMock: .saved_payment_methods_withCard_200, pmType: "card") - StubbedBackend.stubPaymentMethods(fileMock: .saved_payment_methods_200, pmType: "us_bank_account") - StubbedBackend.stubSessions(paymentMethods: "\"card\", \"us_bank_account\"") + StubbedBackend.stubSessions(fileMock: .elementsSessionsLegacyCustomer_pi_withSavedCard_200, + paymentMethods: "\"card\", \"us_bank_account\"") let loaded = expectation(description: "Loaded") PaymentSheetLoader.load( @@ -75,9 +73,8 @@ class PaymentSheetLoaderStubbedTest: APIStubbedTestCase { } func testReturningCustomerWithCardAndUSBankAccount_onlyCards() throws { - StubbedBackend.stubPaymentMethods(fileMock: .saved_payment_methods_withCard_200, pmType: "card") - StubbedBackend.stubPaymentMethods(fileMock: .saved_payment_methods_withUSBank_200, pmType: "us_bank_account") - StubbedBackend.stubSessions(paymentMethods: "\"card\"") + StubbedBackend.stubSessions(fileMock: .elementsSessionsLegacyCustomer_pi_withSavedCardUSBank_200, + paymentMethods: "\"card\"") let loaded = expectation(description: "Loaded") PaymentSheetLoader.load( @@ -102,9 +99,8 @@ class PaymentSheetLoaderStubbedTest: APIStubbedTestCase { } func testReturningCustomerWithCardAndUSBankAccount() throws { - StubbedBackend.stubPaymentMethods(fileMock: .saved_payment_methods_withCard_200, pmType: "card") - StubbedBackend.stubPaymentMethods(fileMock: .saved_payment_methods_withUSBank_200, pmType: "us_bank_account") - StubbedBackend.stubSessions(paymentMethods: "\"card\", \"us_bank_account\"") + StubbedBackend.stubSessions(fileMock: .elementsSessionsLegacyCustomer_pi_withSavedCardUSBank_200, + paymentMethods: "\"card\", \"us_bank_account\"") let loaded = expectation(description: "Loaded") PaymentSheetLoader.load( @@ -129,4 +125,30 @@ class PaymentSheetLoaderStubbedTest: APIStubbedTestCase { } wait(for: [loaded], timeout: 2) } + + func testReturningCustomerWithUSBankAccountOnly() throws { + StubbedBackend.stubSessions(fileMock: .elementsSessionsLegacyCustomer_pi_withSavedUSBank_200, + paymentMethods: "\"us_bank_account\"") + + let loaded = expectation(description: "Loaded") + PaymentSheetLoader.load( + mode: .paymentIntentClientSecret("pi_12345_secret_54321"), + configuration: self.configuration(apiClient: stubbedAPIClient()) + ) { result in + switch result { + case .success(let intent, let paymentMethods, _): + guard case .paymentIntent(let paymentIntent) = intent else { + XCTFail("Expecting payment intent") + return + } + XCTAssertEqual(paymentIntent.stripeId, "pi_3Kth") + XCTAssertEqual(paymentMethods.count, 1) + XCTAssertEqual(paymentMethods[0].type, .USBankAccount) + loaded.fulfill() + case .failure: + XCTFail("Failed") + } + } + wait(for: [loaded], timeout: 2) + } } diff --git a/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/Stubbed/StubbedBackend.swift b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/Stubbed/StubbedBackend.swift index 473edab1405..a5b57c052e6 100644 --- a/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/Stubbed/StubbedBackend.swift +++ b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/Stubbed/StubbedBackend.swift @@ -13,20 +13,36 @@ import StripeCoreTestUtils import XCTest class StubbedBackend { - static func stubSessions(paymentMethods: String) { + static func stubSessions(fileMock: FileMock, + paymentMethods: String, + requestCallback: ((URLRequest) -> Bool)? = nil, + responseCallback: ((Data) -> Data)? = nil) { + let wrappedResponseCallback = wrappedResponseCaller(paymentMethods: paymentMethods, + responseCallback: responseCallback) stubSessions( - fileMock: .elementsSessionsPaymentMethod_200, - responseCallback: { data in - return self.updatePaymentMethodDetail( - data: data, - variables: [ - "": paymentMethods, - "": "\"usd\"", - ] - ) - } + fileMock: fileMock, + requestCallback: requestCallback, + responseCallback: wrappedResponseCallback ) } + static func wrappedResponseCaller(paymentMethods: String, responseCallback: ((Data) -> Data)? = nil) -> ((Data) -> Data) { + let dataTransformer = { data in + return self.updatePaymentMethodDetail( + data: data, + variables: [ + "": paymentMethods, + "": "\"usd\"", + ] + ) + } + guard let responseCallbackUnwrapped = responseCallback else { + return dataTransformer + } + return { data in + let transformedData = dataTransformer(data) + return responseCallbackUnwrapped(transformedData) + } + } static func updatePaymentMethodDetail(data: Data, variables: [String: String]) -> Data { var template = String(data: data, encoding: .utf8)! @@ -36,9 +52,16 @@ class StubbedBackend { } return template.data(using: .utf8)! } - static func stubSessions(fileMock: FileMock, responseCallback: ((Data) -> Data)? = nil) { + + private static func stubSessions(fileMock: FileMock, requestCallback: ((URLRequest) -> Bool)? = nil, responseCallback: ((Data) -> Data)? = nil) { stub { urlRequest in - return urlRequest.url?.absoluteString.contains("/v1/elements/sessions") ?? false + guard urlRequest.url?.absoluteString.contains("/v1/elements/sessions") != nil else { + return false + } + if let requestCallback = requestCallback { + return requestCallback(urlRequest) + } + return true } response: { _ in let mockResponseData = try! fileMock.data() let data = responseCallback?(mockResponseData) ?? mockResponseData @@ -70,4 +93,14 @@ public class ClassForBundle {} case saved_payment_methods_withUSBank_200 = "MockFiles/saved_payment_methods_withUSBank_200" case elementsSessionsPaymentMethod_200 = "MockFiles/elements_sessions_paymentMethod_200" + case elementsSessionsLegacyCustomer_di_withSavedCardUSBank_200 = "MockFiles/elements_sessions_di_legacyCustomer_withSavedCardUSBank_200" + case elementsSessionsLegacyCustomer_di_withSavedCard_200 = "MockFiles/elements_sessions_di_legacyCustomer_withSavedCard_200" + case elementsSessionsLegacyCustomer_di_withSavedUSBank_200 = "MockFiles/elements_sessions_di_legacyCustomer_withSavedUSBank_200" + case elementsSessionsLegacyCustomer_di_withNoSavedPM_200 = "MockFiles/elements_sessions_di_legacyCustomer_withNoSavedPM_200" + + case elementsSessionsLegacyCustomer_pi_withSavedCardUSBank_200 = "MockFiles/elements_sessions_pi_legacyCustomer_withSavedCardUSBank_200" + case elementsSessionsLegacyCustomer_pi_withSavedCard_200 = "MockFiles/elements_sessions_pi_legacyCustomer_withSavedCard_200" + case elementsSessionsLegacyCustomer_pi_withSavedUSBank_200 = "MockFiles/elements_sessions_pi_legacyCustomer_withSavedUSBank_200" + case elementsSessionsLegacyCustomer_pi_withNoSavedPM_200 = "MockFiles/elements_sessions_pi_legacyCustomer_withNoSavedPM_200" + } diff --git a/StripePaymentSheet/StripePaymentSheetTests/Resources/MockFiles/elements_sessions_di_legacyCustomer_withNoSavedPM_200.json b/StripePaymentSheet/StripePaymentSheetTests/Resources/MockFiles/elements_sessions_di_legacyCustomer_withNoSavedPM_200.json new file mode 100644 index 00000000000..e3a8f5c3b50 --- /dev/null +++ b/StripePaymentSheet/StripePaymentSheetTests/Resources/MockFiles/elements_sessions_di_legacyCustomer_withNoSavedPM_200.json @@ -0,0 +1,43 @@ +{ + "account_id": "acct_1HvTI7Lu5o3P18Zp", + "apple_pay_preference": "enabled", + "business_name": "Mobile Example Account", + "flags": { + "ece_apple_pay_payment_request_passthrough": false + }, + "google_pay_preference": "enabled", + "legacy_customer": { + "default_payment_method": null, + "payment_methods": [ + ] + }, + "merchant_country": "US", + "merchant_currency": "usd", + "merchant_id": "acct_1234", + "merchant_logo_url": null, + "meta_pay_signed_container_context": null, + "order": null, + "ordered_payment_method_types_and_wallets": [ + + ], + "payment_method_preference": { + "object": "payment_method_preference", + "country_code": "US", + "ordered_payment_method_types": [ + + ], + "type": "deferred_intent" + }, + "paypal_express_config": { + "client_id": null, + "paypal_merchant_id": null + }, + "session_id": "elements_session_1WnzSkDmifQ", + "shipping_address_settings": { + "autocomplete_allowed": true + }, + "unactivated_payment_method_types": [], + "unverified_payment_methods_on_domain": [ + "apple_pay" + ] +} diff --git a/StripePaymentSheet/StripePaymentSheetTests/Resources/MockFiles/elements_sessions_di_legacyCustomer_withSavedCardUSBank_200.json b/StripePaymentSheet/StripePaymentSheetTests/Resources/MockFiles/elements_sessions_di_legacyCustomer_withSavedCardUSBank_200.json new file mode 100644 index 00000000000..17ddc3a811f --- /dev/null +++ b/StripePaymentSheet/StripePaymentSheetTests/Resources/MockFiles/elements_sessions_di_legacyCustomer_withSavedCardUSBank_200.json @@ -0,0 +1,126 @@ +{ + "account_id": "acct_1HvTI7Lu5o3P18Zp", + "apple_pay_preference": "enabled", + "business_name": "Mobile Example Account", + "flags": { + "ece_apple_pay_payment_request_passthrough": false + }, + "google_pay_preference": "enabled", + "legacy_customer": { + "default_payment_method": null, + "payment_methods": [ + { + "id": "pm_123456789", + "object": "payment_method", + "billing_details": { + "address": { + "city": null, + "country": "US", + "line1": null, + "line2": null, + "postal_code": "42424", + "state": null + }, + "email": null, + "name": null, + "phone": null + }, + "card": { + "brand": "visa", + "checks": { + "address_line1_check": null, + "address_postal_code_check": null, + "cvc_check": null + }, + "country": "US", + "exp_month": 4, + "exp_year": 2024, + "funding": "credit", + "generated_from": null, + "last4": "4242", + "networks": { + "available": [ + "visa" + ], + "preferred": null + }, + "three_d_secure_usage": { + "supported": true + }, + "wallet": null + }, + "created": 1694550739, + "customer": "cus_Ocso99loZJWSXg", + "livemode": false, + "type": "card" + }, + { + "id": "pm_654321654", + "object": "payment_method", + "billing_details": { + "address": { + "city": null, + "country": null, + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": "test2@stripe.com", + "name": "test user", + "phone": null + }, + "created": 1694550870, + "customer": "cus_Ocso99loZJWSXg", + "livemode": false, + "type": "us_bank_account", + "us_bank_account": { + "account_holder_type": "individual", + "account_type": "checking", + "bank_name": "STRIPE TEST BANK", + "financial_connections_account": "fca_123456789453354", + "fingerprint": "FF65654654654u", + "last4": "6789", + "linked_account": "fca_1NpdHwL000000000000", + "networks": { + "preferred": "ach", + "supported": [ + "ach" + ] + }, + "routing_number": "110000000", + "status_details": null + } + } + ] + }, + "merchant_country": "US", + "merchant_currency": "usd", + "merchant_id": "acct_1234", + "merchant_logo_url": null, + "meta_pay_signed_container_context": null, + "order": null, + "ordered_payment_method_types_and_wallets": [ + + ], + "payment_method_preference": { + "object": "payment_method_preference", + "country_code": "US", + "ordered_payment_method_types": [ + + ], + "type": "deferred_intent" + }, + "paypal_express_config": { + "client_id": null, + "paypal_merchant_id": null + }, + "session_id": "elements_session_1WnzSkDmifQ", + "shipping_address_settings": { + "autocomplete_allowed": true + }, + "unactivated_payment_method_types": [], + "unverified_payment_methods_on_domain": [ + "apple_pay" + ] +} diff --git a/StripePaymentSheet/StripePaymentSheetTests/Resources/MockFiles/elements_sessions_di_legacyCustomer_withSavedCard_200.json b/StripePaymentSheet/StripePaymentSheetTests/Resources/MockFiles/elements_sessions_di_legacyCustomer_withSavedCard_200.json new file mode 100644 index 00000000000..964249dd060 --- /dev/null +++ b/StripePaymentSheet/StripePaymentSheetTests/Resources/MockFiles/elements_sessions_di_legacyCustomer_withSavedCard_200.json @@ -0,0 +1,88 @@ +{ + "account_id": "acct_1HvTI7Lu5o3P18Zp", + "apple_pay_preference": "enabled", + "business_name": "Mobile Example Account", + "flags": { + "ece_apple_pay_payment_request_passthrough": false + }, + "google_pay_preference": "enabled", + "legacy_customer": { + "default_payment_method": null, + "payment_methods": [ + { + "id": "pm_123456789", + "object": "payment_method", + "billing_details": { + "address": { + "city": null, + "country": "US", + "line1": null, + "line2": null, + "postal_code": "42424", + "state": null + }, + "email": null, + "name": null, + "phone": null + }, + "card": { + "brand": "visa", + "checks": { + "address_line1_check": null, + "address_postal_code_check": null, + "cvc_check": null + }, + "country": "US", + "exp_month": 4, + "exp_year": 2024, + "funding": "credit", + "generated_from": null, + "last4": "4242", + "networks": { + "available": [ + "visa" + ], + "preferred": null + }, + "three_d_secure_usage": { + "supported": true + }, + "wallet": null + }, + "created": 1694550739, + "customer": "cus_Ocso99loZJWSXg", + "livemode": false, + "type": "card" + } + ] + }, + "merchant_country": "US", + "merchant_currency": "usd", + "merchant_id": "acct_1234", + "merchant_logo_url": null, + "meta_pay_signed_container_context": null, + "order": null, + "ordered_payment_method_types_and_wallets": [ + + ], + "payment_method_preference": { + "object": "payment_method_preference", + "country_code": "US", + "ordered_payment_method_types": [ + + ], + "type": "deferred_intent" + }, + "paypal_express_config": { + "client_id": null, + "paypal_merchant_id": null + }, + "session_id": "elements_session_1WnzSkDmifQ", + "shipping_address_settings": { + "autocomplete_allowed": true + }, + "unactivated_payment_method_types": [], + "unverified_payment_methods_on_domain": [ + "apple_pay" + ] +} diff --git a/StripePaymentSheet/StripePaymentSheetTests/Resources/MockFiles/elements_sessions_di_legacyCustomer_withSavedUSBank_200.json b/StripePaymentSheet/StripePaymentSheetTests/Resources/MockFiles/elements_sessions_di_legacyCustomer_withSavedUSBank_200.json new file mode 100644 index 00000000000..56f027a51e9 --- /dev/null +++ b/StripePaymentSheet/StripePaymentSheetTests/Resources/MockFiles/elements_sessions_di_legacyCustomer_withSavedUSBank_200.json @@ -0,0 +1,81 @@ +{ + "account_id": "acct_1HvTI7Lu5o3P18Zp", + "apple_pay_preference": "enabled", + "business_name": "Mobile Example Account", + "flags": { + "ece_apple_pay_payment_request_passthrough": false + }, + "google_pay_preference": "enabled", + "legacy_customer": { + "default_payment_method": null, + "payment_methods": [ + { + "id": "pm_654321654", + "object": "payment_method", + "billing_details": { + "address": { + "city": null, + "country": null, + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": "test2@stripe.com", + "name": "test user", + "phone": null + }, + "created": 1694550870, + "customer": "cus_Ocso99loZJWSXg", + "livemode": false, + "type": "us_bank_account", + "us_bank_account": { + "account_holder_type": "individual", + "account_type": "checking", + "bank_name": "STRIPE TEST BANK", + "financial_connections_account": "fca_123456789453354", + "fingerprint": "FF65654654654u", + "last4": "6789", + "linked_account": "fca_1NpdHwL000000000000", + "networks": { + "preferred": "ach", + "supported": [ + "ach" + ] + }, + "routing_number": "110000000", + "status_details": null + } + } + ] + }, + "merchant_country": "US", + "merchant_currency": "usd", + "merchant_id": "acct_1234", + "merchant_logo_url": null, + "meta_pay_signed_container_context": null, + "order": null, + "ordered_payment_method_types_and_wallets": [ + + ], + "payment_method_preference": { + "object": "payment_method_preference", + "country_code": "US", + "ordered_payment_method_types": [ + + ], + "type": "deferred_intent" + }, + "paypal_express_config": { + "client_id": null, + "paypal_merchant_id": null + }, + "session_id": "elements_session_1WnzSkDmifQ", + "shipping_address_settings": { + "autocomplete_allowed": true + }, + "unactivated_payment_method_types": [], + "unverified_payment_methods_on_domain": [ + "apple_pay" + ] +} diff --git a/StripePaymentSheet/StripePaymentSheetTests/Resources/MockFiles/elements_sessions_pi_legacyCustomer_withNoSavedPM_200.json b/StripePaymentSheet/StripePaymentSheetTests/Resources/MockFiles/elements_sessions_pi_legacyCustomer_withNoSavedPM_200.json new file mode 100644 index 00000000000..e0ce363d198 --- /dev/null +++ b/StripePaymentSheet/StripePaymentSheetTests/Resources/MockFiles/elements_sessions_pi_legacyCustomer_withNoSavedPM_200.json @@ -0,0 +1,88 @@ +{ + "account_id": "acct_123456", + "apple_pay_merchant_token_webhook_url": "https://pm-hooks.stripe.com/apple_pay/merchant_token/pDq7tf9uieoQWMVJixFwuOve/acct_123456/", + "apple_pay_preference": "enabled", + "business_name": "Mobile Example Account", + "experiments_data": { + "arb_id": "13539b84-39de-43a6-b694-123456789", + "experiment_assignments": { + "elements_debit_mulberry_purchase_protections": "control", + } + }, + "flags": { + "ece_apple_pay_payment_request_passthrough": false, + }, + "google_pay_preference": "enabled", + "legacy_customer": { + "default_payment_method": "src_123456789", + "payment_methods": [ + ] + }, + "merchant_country": "US", + "merchant_currency": "usd", + "merchant_id": "acct_123456789", + "merchant_logo_url": null, + "meta_pay_signed_container_context": null, + "order": null, + "ordered_payment_method_types_and_wallets": [ + + ], + "payment_method_preference": { + "object": "payment_method_preference", + "country_code": "US", + "ordered_payment_method_types": [ + + ], + "payment_intent": { + "id": "pi_3Kth", + "object": "payment_intent", + "amount": 5099, + "amount_details": { + "tip": {} + }, + "automatic_payment_methods": { + "allow_redirects": "always", + "enabled": true + }, + "canceled_at": null, + "cancellation_reason": null, + "capture_method": "automatic", + "client_secret": "pi_123456_secret_54321", + "confirmation_method": "automatic", + "created": 1694620938, + "currency": "usd", + "description": null, + "last_payment_error": null, + "livemode": false, + "next_action": null, + "payment_method": null, + "payment_method_options": { + "us_bank_account": { + "verification_method": "automatic" + } + }, + "payment_method_types": [ + + ], + "processing": null, + "receipt_email": null, + "setup_future_usage": null, + "shipping": null, + "source": null, + "status": "requires_payment_method" + }, + "type": "payment_intent" + }, + "paypal_express_config": { + "client_id": null, + "paypal_merchant_id": null + }, + "session_id": "elements_session_0123456789", + "shipping_address_settings": { + "autocomplete_allowed": true + }, + "unactivated_payment_method_types": [], + "unverified_payment_methods_on_domain": [ + "apple_pay" + ] +} diff --git a/StripePaymentSheet/StripePaymentSheetTests/Resources/MockFiles/elements_sessions_pi_legacyCustomer_withSavedCardUSBank_200.json b/StripePaymentSheet/StripePaymentSheetTests/Resources/MockFiles/elements_sessions_pi_legacyCustomer_withSavedCardUSBank_200.json new file mode 100644 index 00000000000..834ea97788c --- /dev/null +++ b/StripePaymentSheet/StripePaymentSheetTests/Resources/MockFiles/elements_sessions_pi_legacyCustomer_withSavedCardUSBank_200.json @@ -0,0 +1,172 @@ +{ + "account_id": "acct_123456", + "apple_pay_merchant_token_webhook_url": "https://pm-hooks.stripe.com/apple_pay/merchant_token/pDq7tf9uieoQWMVJixFwuOve/acct_123456/", + "apple_pay_preference": "enabled", + "business_name": "Mobile Example Account", + "experiments_data": { + "arb_id": "13539b84-39de-43a6-b694-123456789", + "experiment_assignments": { + "elements_debit_mulberry_purchase_protections": "control", + } + }, + "flags": { + "ece_apple_pay_payment_request_passthrough": false, + }, + "google_pay_preference": "enabled", + "legacy_customer": { + "default_payment_method": "src_123456789", + "payment_methods": [ + { + "id": "pm_0000000", + "object": "payment_method", + "billing_details": { + "address": { + "city": "Seattle", + "country": "US", + "line1": "123 Main St.", + "line2": null, + "postal_code": "39500", + "state": null + }, + "email": null, + "name": null, + "phone": null + }, + "card": { + "brand": "visa", + "checks": { + "address_line1_check": null, + "address_postal_code_check": null, + "cvc_check": null + }, + "country": "US", + "exp_month": 11, + "exp_year": 2025, + "funding": "credit", + "generated_from": null, + "last4": "4242", + "networks": { + "available": [ + "visa" + ], + "preferred": null + }, + "three_d_secure_usage": { + "supported": true + }, + "wallet": null + }, + "created": 1694410331, + "customer": "cus_AAAAAAAAAAA", + "livemode": false, + "type": "card" + }, + { + "id": "pm_122222222", + "object": "payment_method", + "billing_details": { + "address": { + "city": "San Francisco", + "country": "US", + "line1": "510 Townsend St.", + "line2": null, + "postal_code": "94102", + "state": "California" + }, + "email": "foo@bar.com", + "name": "Jane Doe", + "phone": "+13105551234" + }, + "created": 1690920999, + "customer": "cus_AAAAAAAAAAA", + "livemode": false, + "type": "us_bank_account", + "us_bank_account": { + "account_holder_type": "individual", + "account_type": "checking", + "bank_name": "STRIPE TEST BANK", + "financial_connections_account": "fca_1123456789", + "fingerprint": "abcdebf12345", + "last4": "1113", + "linked_account": "fca_1123456789", + "networks": { + "preferred": "ach", + "supported": [ + "ach", + "us_domestic_wire" + ] + }, + "routing_number": "110000000", + "status_details": null + } + } + ] + }, + "merchant_country": "US", + "merchant_currency": "usd", + "merchant_id": "acct_123456789", + "merchant_logo_url": null, + "meta_pay_signed_container_context": null, + "order": null, + "ordered_payment_method_types_and_wallets": [ + + ], + "payment_method_preference": { + "object": "payment_method_preference", + "country_code": "US", + "ordered_payment_method_types": [ + + ], + "payment_intent": { + "id": "pi_3Kth", + "object": "payment_intent", + "amount": 5099, + "amount_details": { + "tip": {} + }, + "automatic_payment_methods": { + "allow_redirects": "always", + "enabled": true + }, + "canceled_at": null, + "cancellation_reason": null, + "capture_method": "automatic", + "client_secret": "pi_123456_secret_54321", + "confirmation_method": "automatic", + "created": 1694620938, + "currency": "usd", + "description": null, + "last_payment_error": null, + "livemode": false, + "next_action": null, + "payment_method": null, + "payment_method_options": { + "us_bank_account": { + "verification_method": "automatic" + } + }, + "payment_method_types": [ + + ], + "processing": null, + "receipt_email": null, + "setup_future_usage": null, + "shipping": null, + "source": null, + "status": "requires_payment_method" + }, + "type": "payment_intent" + }, + "paypal_express_config": { + "client_id": null, + "paypal_merchant_id": null + }, + "session_id": "elements_session_0123456789", + "shipping_address_settings": { + "autocomplete_allowed": true + }, + "unactivated_payment_method_types": [], + "unverified_payment_methods_on_domain": [ + "apple_pay" + ] +} diff --git a/StripePaymentSheet/StripePaymentSheetTests/Resources/MockFiles/elements_sessions_pi_legacyCustomer_withSavedCard_200.json b/StripePaymentSheet/StripePaymentSheetTests/Resources/MockFiles/elements_sessions_pi_legacyCustomer_withSavedCard_200.json new file mode 100644 index 00000000000..6aa29df1a6a --- /dev/null +++ b/StripePaymentSheet/StripePaymentSheetTests/Resources/MockFiles/elements_sessions_pi_legacyCustomer_withSavedCard_200.json @@ -0,0 +1,133 @@ +{ + "account_id": "acct_123456", + "apple_pay_merchant_token_webhook_url": "https://pm-hooks.stripe.com/apple_pay/merchant_token/pDq7tf9uieoQWMVJixFwuOve/acct_123456/", + "apple_pay_preference": "enabled", + "business_name": "Mobile Example Account", + "experiments_data": { + "arb_id": "13539b84-39de-43a6-b694-123456789", + "experiment_assignments": { + "elements_debit_mulberry_purchase_protections": "control", + } + }, + "flags": { + "ece_apple_pay_payment_request_passthrough": false, + }, + "google_pay_preference": "enabled", + "legacy_customer": { + "default_payment_method": "src_123456789", + "payment_methods": [ + { + "id": "pm_0000000", + "object": "payment_method", + "billing_details": { + "address": { + "city": "Seattle", + "country": "US", + "line1": "123 Main St.", + "line2": null, + "postal_code": "39500", + "state": null + }, + "email": null, + "name": null, + "phone": null + }, + "card": { + "brand": "visa", + "checks": { + "address_line1_check": null, + "address_postal_code_check": null, + "cvc_check": null + }, + "country": "US", + "exp_month": 11, + "exp_year": 2025, + "funding": "credit", + "generated_from": null, + "last4": "4242", + "networks": { + "available": [ + "visa" + ], + "preferred": null + }, + "three_d_secure_usage": { + "supported": true + }, + "wallet": null + }, + "created": 1694410331, + "customer": "cus_AAAAAAAAAAA", + "livemode": false, + "type": "card" + } + ] + }, + "merchant_country": "US", + "merchant_currency": "usd", + "merchant_id": "acct_123456789", + "merchant_logo_url": null, + "meta_pay_signed_container_context": null, + "order": null, + "ordered_payment_method_types_and_wallets": [ + + ], + "payment_method_preference": { + "object": "payment_method_preference", + "country_code": "US", + "ordered_payment_method_types": [ + + ], + "payment_intent": { + "id": "pi_3Kth", + "object": "payment_intent", + "amount": 5099, + "amount_details": { + "tip": {} + }, + "automatic_payment_methods": { + "allow_redirects": "always", + "enabled": true + }, + "canceled_at": null, + "cancellation_reason": null, + "capture_method": "automatic", + "client_secret": "pi_123456_secret_54321", + "confirmation_method": "automatic", + "created": 1694620938, + "currency": "usd", + "description": null, + "last_payment_error": null, + "livemode": false, + "next_action": null, + "payment_method": null, + "payment_method_options": { + "us_bank_account": { + "verification_method": "automatic" + } + }, + "payment_method_types": [ + + ], + "processing": null, + "receipt_email": null, + "setup_future_usage": null, + "shipping": null, + "source": null, + "status": "requires_payment_method" + }, + "type": "payment_intent" + }, + "paypal_express_config": { + "client_id": null, + "paypal_merchant_id": null + }, + "session_id": "elements_session_0123456789", + "shipping_address_settings": { + "autocomplete_allowed": true + }, + "unactivated_payment_method_types": [], + "unverified_payment_methods_on_domain": [ + "apple_pay" + ] +} diff --git a/StripePaymentSheet/StripePaymentSheetTests/Resources/MockFiles/elements_sessions_pi_legacyCustomer_withSavedUSBank_200.json b/StripePaymentSheet/StripePaymentSheetTests/Resources/MockFiles/elements_sessions_pi_legacyCustomer_withSavedUSBank_200.json new file mode 100644 index 00000000000..350cca66f98 --- /dev/null +++ b/StripePaymentSheet/StripePaymentSheetTests/Resources/MockFiles/elements_sessions_pi_legacyCustomer_withSavedUSBank_200.json @@ -0,0 +1,127 @@ +{ + "account_id": "acct_123456", + "apple_pay_merchant_token_webhook_url": "https://pm-hooks.stripe.com/apple_pay/merchant_token/pDq7tf9uieoQWMVJixFwuOve/acct_123456/", + "apple_pay_preference": "enabled", + "business_name": "Mobile Example Account", + "experiments_data": { + "arb_id": "13539b84-39de-43a6-b694-123456789", + "experiment_assignments": { + "elements_debit_mulberry_purchase_protections": "control", + } + }, + "flags": { + "ece_apple_pay_payment_request_passthrough": false, + }, + "google_pay_preference": "enabled", + "legacy_customer": { + "default_payment_method": "src_123456789", + "payment_methods": [ + { + "id": "pm_122222222", + "object": "payment_method", + "billing_details": { + "address": { + "city": "San Francisco", + "country": "US", + "line1": "510 Townsend St.", + "line2": null, + "postal_code": "94102", + "state": "California" + }, + "email": "foo@bar.com", + "name": "Jane Doe", + "phone": "+13105551234" + }, + "created": 1690920999, + "customer": "cus_AAAAAAAAAAA", + "livemode": false, + "type": "us_bank_account", + "us_bank_account": { + "account_holder_type": "individual", + "account_type": "checking", + "bank_name": "STRIPE TEST BANK", + "financial_connections_account": "fca_1123456789", + "fingerprint": "abcdebf12345", + "last4": "1113", + "linked_account": "fca_1123456789", + "networks": { + "preferred": "ach", + "supported": [ + "ach", + "us_domestic_wire" + ] + }, + "routing_number": "110000000", + "status_details": null + } + } + ] + }, + "merchant_country": "US", + "merchant_currency": "usd", + "merchant_id": "acct_123456789", + "merchant_logo_url": null, + "meta_pay_signed_container_context": null, + "order": null, + "ordered_payment_method_types_and_wallets": [ + + ], + "payment_method_preference": { + "object": "payment_method_preference", + "country_code": "US", + "ordered_payment_method_types": [ + + ], + "payment_intent": { + "id": "pi_3Kth", + "object": "payment_intent", + "amount": 5099, + "amount_details": { + "tip": {} + }, + "automatic_payment_methods": { + "allow_redirects": "always", + "enabled": true + }, + "canceled_at": null, + "cancellation_reason": null, + "capture_method": "automatic", + "client_secret": "pi_123456_secret_54321", + "confirmation_method": "automatic", + "created": 1694620938, + "currency": "usd", + "description": null, + "last_payment_error": null, + "livemode": false, + "next_action": null, + "payment_method": null, + "payment_method_options": { + "us_bank_account": { + "verification_method": "automatic" + } + }, + "payment_method_types": [ + + ], + "processing": null, + "receipt_email": null, + "setup_future_usage": null, + "shipping": null, + "source": null, + "status": "requires_payment_method" + }, + "type": "payment_intent" + }, + "paypal_express_config": { + "client_id": null, + "paypal_merchant_id": null + }, + "session_id": "elements_session_0123456789", + "shipping_address_settings": { + "autocomplete_allowed": true + }, + "unactivated_payment_method_types": [], + "unverified_payment_methods_on_domain": [ + "apple_pay" + ] +} diff --git a/StripePayments/StripePayments/Source/API Bindings/Models/PaymentIntents/STPPaymentIntent.swift b/StripePayments/StripePayments/Source/API Bindings/Models/PaymentIntents/STPPaymentIntent.swift index 5ab30d7cb94..9d084166464 100644 --- a/StripePayments/StripePayments/Source/API Bindings/Models/PaymentIntents/STPPaymentIntent.swift +++ b/StripePayments/StripePayments/Source/API Bindings/Models/PaymentIntents/STPPaymentIntent.swift @@ -273,6 +273,8 @@ extension STPPaymentIntent: STPAPIResponseDecodable { dict["link_settings"] = response["link_settings"] dict["payment_method_specs"] = response["payment_method_specs"] dict["card_brand_choice"] = response["card_brand_choice"] + dict["legacy_customer"] = response["legacy_customer"] + dict["customer_error"] = response["customer_error"] return decodeSTPPaymentIntentObject(fromAPIResponse: dict) } else { return decodeSTPPaymentIntentObject(fromAPIResponse: response) diff --git a/StripePayments/StripePayments/Source/API Bindings/Models/SetupIntents/STPSetupIntent.swift b/StripePayments/StripePayments/Source/API Bindings/Models/SetupIntents/STPSetupIntent.swift index eb074cdf746..b2157ba0197 100644 --- a/StripePayments/StripePayments/Source/API Bindings/Models/SetupIntents/STPSetupIntent.swift +++ b/StripePayments/StripePayments/Source/API Bindings/Models/SetupIntents/STPSetupIntent.swift @@ -195,6 +195,8 @@ public class STPSetupIntent: NSObject, STPAPIResponseDecodable { dict["unactivated_payment_method_types"] = response["unactivated_payment_method_types"] dict["merchant_country"] = response["merchant_country"] dict["link_settings"] = response["link_settings"] + dict["legacy_customer"] = response["legacy_customer"] + dict["customer_error"] = response["customer_error"] return decodeSTPSetupIntentObject(fromAPIResponse: dict) } else { return decodeSTPSetupIntentObject(fromAPIResponse: response) diff --git a/StripePayments/StripePayments/Source/API Bindings/STPAPIClient+Payments.swift b/StripePayments/StripePayments/Source/API Bindings/STPAPIClient+Payments.swift index 856256128a1..0428dcd361b 100644 --- a/StripePayments/StripePayments/Source/API Bindings/STPAPIClient+Payments.swift +++ b/StripePayments/StripePayments/Source/API Bindings/STPAPIClient+Payments.swift @@ -1044,6 +1044,27 @@ extension STPAPIClient { } } + @_spi(STP) public func detachPaymentMethod( + _ paymentMethodID: String, + fromCustomerUsing ephemeralKeySecret: String + ) async throws -> Error? { + return try await withCheckedThrowingContinuation { continuation in + let endpoint = "\(APIEndpointPaymentMethods)/\(paymentMethodID)/detach" + APIRequest.post( + with: self, + endpoint: endpoint, + additionalHeaders: authorizationHeader(using: ephemeralKeySecret), + parameters: [:] + ) { _, _, error in + if let error { + continuation.resume(returning: error) + return + } + continuation.resume(returning: nil) + } + } + } + @_spi(STP) public func attachPaymentMethod( _ paymentMethodID: String, customerID: String,