diff --git a/StripePaymentSheet/StripePaymentSheet.xcodeproj/project.pbxproj b/StripePaymentSheet/StripePaymentSheet.xcodeproj/project.pbxproj index 2eac0fa38f3..ca60ca10ceb 100644 --- a/StripePaymentSheet/StripePaymentSheet.xcodeproj/project.pbxproj +++ b/StripePaymentSheet/StripePaymentSheet.xcodeproj/project.pbxproj @@ -52,6 +52,7 @@ 2E4C37C73AD202C8A3DD2E4E /* LoadingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51FF291A25EA43D4D100983B /* LoadingViewController.swift */; }; 2EC9C94DD8D62E4F4EFC8AB8 /* IntentStatusPollerTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 990304EF35A0EE37DCE20D5B /* IntentStatusPollerTest.swift */; }; 311AC53D6C76953E9B70148A /* ConsumerSession+PublishableKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2D61B52BFA201D25E8F6428 /* ConsumerSession+PublishableKey.swift */; }; + 31316BD72CEC00FC0000016F /* PaymentSheet+APIMockTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31316BD62CEC00EF0000016F /* PaymentSheet+APIMockTest.swift */; }; 313D00C82CD9972F00A8E6B0 /* PayWithNativeLinkController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 313D00C72CD9972F00A8E6B0 /* PayWithNativeLinkController.swift */; }; 313F5F832B0BE5FD00BD98A9 /* Docs.docc in Sources */ = {isa = PBXBuildFile; fileRef = 313F5F822B0BE5FD00BD98A9 /* Docs.docc */; }; 3147CEBB2CC07E960067B5E4 /* LinkUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3147CEBA2CC07E960067B5E4 /* LinkUtils.swift */; }; @@ -461,6 +462,7 @@ 2DF75FD35820E7556EC34D15 /* hu */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = hu; path = hu.lproj/Localizable.strings; sourceTree = ""; }; 2E2B99961C09E31383C9FCE9 /* mt */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = mt; path = mt.lproj/Localizable.strings; sourceTree = ""; }; 2E42F31D392C0AED757D6239 /* StripePaymentSheet.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = StripePaymentSheet.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 31316BD62CEC00EF0000016F /* PaymentSheet+APIMockTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PaymentSheet+APIMockTest.swift"; sourceTree = ""; }; 313D00C72CD9972F00A8E6B0 /* PayWithNativeLinkController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PayWithNativeLinkController.swift; sourceTree = ""; }; 313F5F822B0BE5FD00BD98A9 /* Docs.docc */ = {isa = PBXFileReference; lastKnownFileType = folder.documentationcatalog; path = Docs.docc; sourceTree = ""; }; 3147CEBA2CC07E960067B5E4 /* LinkUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkUtils.swift; sourceTree = ""; }; @@ -1641,6 +1643,7 @@ 5BA7BFC43DB3EFD38A460EB9 /* PaymentMethodMessagingViewSnapshotTests.swift */, 619AF0842BF56C5E00D1C981 /* PaymentMethodRowButtonSnapshotTests.swift */, 8A0B7F6E25D93C0C0ACE3B3D /* PaymentSheet+APITest.swift */, + 31316BD62CEC00EF0000016F /* PaymentSheet+APIMockTest.swift */, 82C21D5722BDEB8BAA71F69F /* PaymentSheet+DashboardConfirmParamsTest.swift */, AA8F7F2824DFC78268ED6459 /* PaymentSheet+DeferredAPITest.swift */, 135B7354260E0E7CADCF3426 /* PaymentSheetAddressTests.swift */, @@ -1951,6 +1954,7 @@ B65FE7092BED33EA009A73FC /* VerticalPaymentMethodListViewControllerSnapshotTest.swift in Sources */, 37F750E1C99D6257E845A66E /* BacsDDMandateViewSnapshotTests.swift in Sources */, 694A3B36AC19FC1F87EF0CB1 /* CustomerSheetPaymentMethodAvailabilityTests.swift in Sources */, + 31316BD72CEC00FC0000016F /* PaymentSheet+APIMockTest.swift in Sources */, 9F750611C4E8EAABE9F0B460 /* CustomerSheetTests.swift in Sources */, 4FF15C6F7AF22F55B2F2BEA8 /* CustomerSheetSnapshotTests.swift in Sources */, 31699A832BE183D40048677F /* DownloadManagerTest.swift in Sources */, diff --git a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/PaymentSheet+API.swift b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/PaymentSheet+API.swift index abb1691525a..76ebaefb0b9 100644 --- a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/PaymentSheet+API.swift +++ b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/PaymentSheet+API.swift @@ -489,8 +489,21 @@ extension PaymentSheet { case .withPaymentMethod(let paymentMethod): confirmWithPaymentMethod(paymentMethod, nil, false) case .withPaymentDetails(let linkAccount, let paymentDetails): - // shouldSave is false, as we don't show a save checkbox in the Link VC - confirmWithPaymentDetails(linkAccount, paymentDetails, paymentDetails.cvc, false) + let shouldSave = false // always false, as we don't show a save-to-merchant checkbox in Link VC + + if elementsSession.linkPassthroughModeEnabled { + linkAccount.sharePaymentDetails(id: paymentDetails.stripeID, cvc: paymentDetails.cvc) { result in + switch result { + case .success(let paymentDetailsShareResponse): + confirmWithPaymentMethod(paymentDetailsShareResponse.paymentMethod, linkAccount, shouldSave) + case .failure(let error): + STPAnalyticsClient.sharedClient.logLinkSharePaymentDetailsFailure(error: error) + paymentHandlerCompletion(.failed, error as NSError) + } + } + } else { + confirmWithPaymentDetails(linkAccount, paymentDetails, paymentDetails.cvc, shouldSave) + } case .withPaymentMethodParams(let linkAccount, let paymentMethodParams): // shouldSave is false, as we don't show a save checkbox in the Link VC createPaymentDetailsAndConfirm(linkAccount, paymentMethodParams, false) diff --git a/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/Link/LinkURLGeneratorTests.swift b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/Link/LinkURLGeneratorTests.swift index 82f795f57a6..1fd29cd4bb9 100644 --- a/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/Link/LinkURLGeneratorTests.swift +++ b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/Link/LinkURLGeneratorTests.swift @@ -133,6 +133,17 @@ extension STPElementsSession { ] return STPElementsSession.decodedObject(fromAPIResponse: apiResponse)! } + + static var linkPassthroughElementsSession: STPElementsSession { + let apiResponse: [String: Any] = ["payment_method_preference": ["ordered_payment_method_types": ["123"], + "country_code": "US", ] as [String: Any], + "session_id": "123", + "apple_pay_preference": "enabled", + "link_settings": ["link_funding_sources": ["card"], + "link_passthrough_mode_enabled": true] + ] + return STPElementsSession.decodedObject(fromAPIResponse: apiResponse)! + } } // Just for the purposes of this test. No need to add the (tiny) overhead of Equatable to the published binary diff --git a/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/PaymentSheet+APIMockTest.swift b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/PaymentSheet+APIMockTest.swift new file mode 100644 index 00000000000..84dac7efb8b --- /dev/null +++ b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/PaymentSheet+APIMockTest.swift @@ -0,0 +1,284 @@ +// +// PaymentSheet+APIMockTest.swift +// StripePaymentSheet +// + +import StripeCoreTestUtils +import XCTest + +@testable@_spi(STP) import StripeCore +@testable@_spi(STP) import StripePayments +@testable@_spi(STP) import StripePaymentSheet +@testable@_spi(STP) import StripePaymentsTestUtils +@testable@_spi(STP) import StripeUICore + +import OHHTTPStubs +import OHHTTPStubsSwift + +final class PaymentSheetAPIMockTest: APIStubbedTestCase { + enum MockJson { + static let cardPaymentMethod = STPTestUtils.jsonNamed("CardPaymentMethod")! + static let paymentIntent = STPTestUtils.jsonNamed("PaymentIntent")! + static let setupIntent = STPTestUtils.jsonNamed("SetupIntent")! + } + + enum MockParams { + static let paymentIntentClientSecret = "pi_xxx_secret_xxx" + static let setupIntentClientSecret = "seti_xxx_secret_xxx" + static let publicKey = "pk_xxx" + + static func configuration(pk: String) -> PaymentSheet.Configuration { + var config = PaymentSheet.Configuration() + config.apiClient = STPAPIClient(publishableKey: pk) + config.allowsDelayedPaymentMethods = true + config.shippingDetails = { + return .init( + address: .init( + country: "US", + line1: "Line 1" + ), + name: "Jane Doe", + phone: "5551234567" + ) + } + config.savePaymentMethodOptInBehavior = .requiresOptOut + return config + } + + static func configurationWithCustomer(pk: String) -> PaymentSheet.Configuration { + var configuration = self.configuration(pk: pk) + configuration.customer = .init(id: "id", ephemeralKeySecret: "ek") + return configuration + } + + static let paymentMethodCardParams: STPPaymentMethodCardParams = { + let cardParams = STPPaymentMethodCardParams() + cardParams.number = "4242424242424242" + cardParams.cvc = "123" + cardParams.expYear = 32 + cardParams.expMonth = 12 + return cardParams + }() + + static var intentConfirmParams: IntentConfirmParams { + .init( + params: .init( + card: paymentMethodCardParams, + billingDetails: .init(), + metadata: nil + ), + type: .stripe(.card) + ) + } + + static var linkPaymentOption: PaymentSheet.PaymentOption { + let exampleBillingEmail = "test@example.com" + return .link(option: .withPaymentDetails( + account: .init( + email: exampleBillingEmail, + session: .init( + clientSecret: "cs_xxx", + emailAddress: exampleBillingEmail, + redactedPhoneNumber: "+1-555-xxx-xxxx", + verificationSessions: [.init(type: .sms, state: .verified)], + supportedPaymentDetailsTypes: [.card] + ), + publishableKey: "pk_xxx_for_link_account_xxx" + ), + paymentDetails: .init( + stripeID: "pd1", + details: .card(card: + .init(expiryYear: 2055, + expiryMonth: 12, + brand: "visa", + last4: "1234", + checks: nil) + ), + billingAddress: nil, + billingEmailAddress: exampleBillingEmail, + isDefault: true) + ) + ) + } + + static let cardPaymentMethod = STPPaymentMethod.decodedObject(fromAPIResponse: MockJson.cardPaymentMethod)! + + static let paymentIntent = STPPaymentIntent.decodedObject(fromAPIResponse: MockJson.paymentIntent)! + + static let setupIntent = STPSetupIntent.decodedObject(fromAPIResponse: MockJson.setupIntent)! + + static func deferredPaymentIntentConfiguration(clientSecret: String) -> PaymentSheet.IntentConfiguration { + .init(mode: .payment(amount: 123, currency: "USD"), paymentMethodTypes: ["card"]) { _, _, c in c(.success(clientSecret)) } + } + + static func deferredSetupIntentConfiguration(clientSecret: String) -> PaymentSheet.IntentConfiguration { + .init(mode: .setup(currency: "USD", setupFutureUsage: .offSession), confirmHandler: { _, _, c in c(.success(clientSecret)) }) + } + } + + override func setUp() { + super.setUp() + + stub { urlRequest in + urlRequest.url?.absoluteString.contains("payment_methods") ?? false + } response: { _ in + return HTTPStubsResponse(jsonObject: MockJson.cardPaymentMethod, statusCode: 200, headers: nil) + } + + stub { urlRequest in + guard let pathComponents = urlRequest.url?.pathComponents else { return false } + return pathComponents[2] == "payment_intents" && pathComponents.last != "confirm" + } response: { request in + var json = MockJson.paymentIntent + + // Mock that the PI requires confirmation if it's being fetched for a deferred PI + if request.httpMethod == "GET" { + json["status"] = "requires_confirmation" + json["capture_method"] = "automatic" + } + + return HTTPStubsResponse(jsonObject: json, statusCode: 200, headers: nil) + } + + stub { urlRequest in + guard let pathComponents = urlRequest.url?.pathComponents else { return false } + return pathComponents[2] == "setup_intents" && pathComponents.last != "confirm" + } response: { request in + var json = MockJson.setupIntent + // Mock that the PI requires confirmation if it's being fetched for a deferred PI + if request.httpMethod == "GET" { + json["status"] = "requires_confirmation" + } + + return HTTPStubsResponse(jsonObject: json, statusCode: 200, headers: nil) + } + } + + func testPassthroughModeCallsSharePaymentDetails() { + stubConfirmPaymentExpecting(isPaymentIntent: true, paymentMethodId: MockParams.cardPaymentMethod.stripeId) + stubLinkShareExpecting(consumerSessionClientSecret: "cs_xxx", paymentMethodID: "pd1") + stubLinkLogout(consumerSessionClientSecret: "cs_xxx") + + let configuration = MockParams.configurationWithCustomer(pk: MockParams.publicKey) + let exp = expectation(description: "confirm completed") + let paymentHandler = STPPaymentHandler(apiClient: configuration.apiClient) + let elementsSession = STPElementsSession.linkPassthroughElementsSession + PaymentSheet.confirm( + configuration: configuration, + authenticationContext: self, + intent: .deferredIntent(intentConfig: MockParams.deferredPaymentIntentConfiguration(clientSecret: MockParams.paymentIntentClientSecret)), + elementsSession: elementsSession, + paymentOption: .link(option: .withPaymentDetails(account: .init(email: "test@example.com", session: .init(clientSecret: "cs_xxx", emailAddress: "test@example.com", redactedPhoneNumber: "+1-555-xxx-xxxx", verificationSessions: [.init(type: .sms, state: .verified)], supportedPaymentDetailsTypes: [.card]), publishableKey: MockParams.publicKey), paymentDetails: .init(stripeID: "pd1", details: .card(card: .init(expiryYear: 2055, expiryMonth: 12, brand: "visa", last4: "1234", checks: nil)), billingAddress: nil, billingEmailAddress: nil, isDefault: true))), + paymentHandler: paymentHandler, + analyticsHelper: ._testValue(), + completion: { _, _ in + exp.fulfill() + } + ) + + waitForExpectations(timeout: 10) + } +} + +extension PaymentSheetAPIMockTest: STPAuthenticationContext { + func authenticationPresentingViewController() -> UIViewController { + return UIViewController() + } +} + +// MARK: - Helpers + +private extension PaymentSheetAPIMockTest { + func stubConfirmPaymentExpecting( + isPaymentIntent: Bool, + paymentMethodId: String, + setupFutureUsage: String? = nil, + line: UInt = #line + ) { + let exp = expectation(description: "confirm payment requested") + + stub { urlRequest in + guard let pathComponents = urlRequest.url?.pathComponents else { return false } + return pathComponents.last == "confirm" + } response: { [self] request in + let params = bodyParams(from: request, line: line) + + assertParam(params, named: "payment_method_data[type]", is: nil, line: line) + assertParam(params, named: "payment_method_data[card][number]", is: nil, line: line) + assertParam(params, named: "payment_method", is: paymentMethodId, line: line) + + // Payment Method Options + assertParam(params, named: "payment_method_options[card][setup_future_usage]", is: setupFutureUsage, line: line) + + defer { exp.fulfill() } + var json = isPaymentIntent ? MockJson.paymentIntent : MockJson.setupIntent + json["status"] = "succeeded" + + return HTTPStubsResponse(jsonObject: json, statusCode: 200, headers: nil) + } + } + + func stubLinkShareExpecting( + consumerSessionClientSecret: String, + paymentMethodID: String, + line: UInt = #line + ) { + let exp = expectation(description: "share payment method requested") + + stub { urlRequest in + guard let pathComponents = urlRequest.url?.pathComponents else { return false } + return pathComponents.last == "share" + } response: { [self] request in + let params = bodyParams(from: request, line: line) + + assertParam(params, named: "credentials[consumer_session_client_secret]", is: consumerSessionClientSecret, line: line) + assertParam(params, named: "request_surface", is: "ios_payment_element", line: line) + assertParam(params, named: "expand[0]", is: "payment_method", line: line) + assertParam(params, named: "id", is: paymentMethodID, line: line) + + defer { exp.fulfill() } + let json = ["payment_method": MockJson.cardPaymentMethod] + + return HTTPStubsResponse(jsonObject: json, statusCode: 200, headers: nil) + } + } + + func stubLinkLogout( + consumerSessionClientSecret: String, + line: UInt = #line + ) { + let exp = expectation(description: "Link logout") + + stub { urlRequest in + guard let pathComponents = urlRequest.url?.pathComponents else { return false } + return pathComponents.last == "log_out" + } response: { [self] request in + let params = bodyParams(from: request, line: line) + + assertParam(params, named: "credentials[consumer_session_client_secret]", is: consumerSessionClientSecret, line: line) + assertParam(params, named: "request_surface", is: "ios_payment_element", line: line) + + defer { exp.fulfill() } + + return HTTPStubsResponse(jsonObject: [], statusCode: 200, headers: nil) + } + } + + func assertParam(_ params: [String: String], named name: String, is value: String?, line: UInt) { + XCTAssertEqual(params[name], value, name, line: line) + } + + func bodyParams(from request: URLRequest, line: UInt) -> [String: String] { + guard let httpBody = request.httpBodyOrBodyStream, + let query = String(decoding: httpBody, as: UTF8.self).addingPercentEncoding(withAllowedCharacters: .urlPathAllowed), + let components = URLComponents(string: "http://someurl.com?\(query)") else { + XCTFail("Request body empty", line: line) + return [:] + } + + return components.queryItems?.reduce(into: [:], { partialResult, item in + guard item.value != "" else { return } + partialResult[item.name] = item.value?.removingPercentEncoding + }) ?? [:] + } +}