diff --git a/StripeCore/StripeCore.xcodeproj/project.pbxproj b/StripeCore/StripeCore.xcodeproj/project.pbxproj index 54acec977d2..8e377225964 100644 --- a/StripeCore/StripeCore.xcodeproj/project.pbxproj +++ b/StripeCore/StripeCore.xcodeproj/project.pbxproj @@ -40,6 +40,7 @@ 492039932CA47A8600CE2072 /* ElementsSessionContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 492039922CA47A8600CE2072 /* ElementsSessionContext.swift */; }; 493B33062CA3015600E3622F /* LinkMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 493B33052CA3015600E3622F /* LinkMode.swift */; }; 49ECDA412CA340E100F647F0 /* AsyncTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49ECDA402CA340E100F647F0 /* AsyncTests.swift */; }; + 49F3828D2CC02D43001CE69A /* BillingAddress.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49F3828C2CC02D43001CE69A /* BillingAddress.swift */; }; 4B2FAC57E03D8654A177C408 /* Dictionary+Stripe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7727AEEFD2FC880BADDA1872 /* Dictionary+Stripe.swift */; }; 53D46A03B77577EE21F4B166 /* StripeCodableTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FCE36551600C3E53BEAF8F0 /* StripeCodableTest.swift */; }; 552DA7969984C443617DBC3E /* STPMultipartFormDataPart.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C72BA9C44FF60A0E7BEF76 /* STPMultipartFormDataPart.swift */; }; @@ -235,6 +236,7 @@ 49424775D3233411D9C2473B /* StripeCodable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StripeCodable.swift; sourceTree = ""; }; 49538DBF8457D96707A2DA56 /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/Localizable.strings; sourceTree = ""; }; 49ECDA402CA340E100F647F0 /* AsyncTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncTests.swift; sourceTree = ""; }; + 49F3828C2CC02D43001CE69A /* BillingAddress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BillingAddress.swift; sourceTree = ""; }; 4A8030BF88608CA86E295F18 /* Enums+CustomStringConvertible.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Enums+CustomStringConvertible.swift"; sourceTree = ""; }; 4C51E3FA5EE3587BB7BBC634 /* STPError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPError.swift; sourceTree = ""; }; 4EC3BCEEECB3E1485B18F0C4 /* FinancialConnectionsSDKInterface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FinancialConnectionsSDKInterface.swift; sourceTree = ""; }; @@ -456,6 +458,7 @@ 6A05FB4A2BCF245C0001D128 /* FinancialConnectionsEvent.swift */, 493B33052CA3015600E3622F /* LinkMode.swift */, 492039922CA47A8600CE2072 /* ElementsSessionContext.swift */, + 49F3828C2CC02D43001CE69A /* BillingAddress.swift */, ); path = "Connections Bindings"; sourceTree = ""; @@ -1026,6 +1029,7 @@ A62AEDF871AC89489FE19A13 /* ServerErrorMapper.swift in Sources */, B6DBB2BF2BA8C4E400783D15 /* STPAnalyticsClient+Error.swift in Sources */, 6A05FB452BCF24100001D128 /* FinancialConnectionsSDKResult.swift in Sources */, + 49F3828D2CC02D43001CE69A /* BillingAddress.swift in Sources */, 62FD088E003BE06F5413FB4F /* StripeCoreBundleLocator.swift in Sources */, 17CE96B50813CF626293CBF9 /* URLEncoder.swift in Sources */, 0709F5D265CC641E6DE1011D /* URLSession+Retry.swift in Sources */, diff --git a/StripeCore/StripeCore/Source/Connections Bindings/BillingAddress.swift b/StripeCore/StripeCore/Source/Connections Bindings/BillingAddress.swift new file mode 100644 index 00000000000..67db6948c4c --- /dev/null +++ b/StripeCore/StripeCore/Source/Connections Bindings/BillingAddress.swift @@ -0,0 +1,58 @@ +// +// BillingAddress.swift +// StripeCore +// +// Created by Mat Schmid on 2024-10-16. +// + +import Foundation + +@_spi(STP) public struct BillingAddress: Encodable { + let name: String? + let line1: String? + let line2: String? + let city: String? + let state: String? + let postalCode: String? + let countryCode: String? + + @_spi(STP) public init( + name: String?, + line1: String?, + line2: String?, + city: String?, + state: String?, + postalCode: String?, + countryCode: String? + ) { + self.name = name + self.line1 = line1 + self.line2 = line2 + self.city = city + self.state = state + self.postalCode = postalCode + self.countryCode = countryCode + } + + enum CodingKeys: String, CodingKey { + case name + case line1 = "line_1" + case line2 = "line_2" + case city = "locality" + case state = "administrative_area" + case postalCode = "postal_code" + case countryCode = "country_code" + } + + // Custom encoder to only encode non-nil & non-empty properties. + @_spi(STP) public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + if let name, !name.isEmpty { try container.encode(name, forKey: .name) } + if let line1, !line1.isEmpty { try container.encode(line1, forKey: .line1) } + if let line2, !line2.isEmpty { try container.encode(line2, forKey: .line2) } + if let city, !city.isEmpty { try container.encode(city, forKey: .city) } + if let state, !state.isEmpty { try container.encode(state, forKey: .state) } + if let postalCode, !postalCode.isEmpty { try container.encode(postalCode, forKey: .postalCode) } + if let countryCode, !countryCode.isEmpty { try container.encode(countryCode, forKey: .countryCode) } + } +} diff --git a/StripeCore/StripeCore/Source/Connections Bindings/ElementsSessionContext.swift b/StripeCore/StripeCore/Source/Connections Bindings/ElementsSessionContext.swift index adc5781dd4c..c95767529c4 100644 --- a/StripeCore/StripeCore/Source/Connections Bindings/ElementsSessionContext.swift +++ b/StripeCore/StripeCore/Source/Connections Bindings/ElementsSessionContext.swift @@ -18,16 +18,19 @@ import Foundation @_spi(STP) public let currency: String? @_spi(STP) public let intentId: IntentID? @_spi(STP) public let linkMode: LinkMode? + @_spi(STP) public let billingAddress: BillingAddress? @_spi(STP) public init( amount: Int?, currency: String?, intentId: IntentID?, - linkMode: LinkMode? + linkMode: LinkMode?, + billingAddress: BillingAddress? ) { self.amount = amount self.currency = currency self.intentId = intentId self.linkMode = linkMode + self.billingAddress = billingAddress } } diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/API Bindings/FinancialConnectionsAPIClient.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/API Bindings/FinancialConnectionsAPIClient.swift index c47bcbd1dd7..f56f5fb2699 100644 --- a/StripeFinancialConnections/StripeFinancialConnections/Source/API Bindings/FinancialConnectionsAPIClient.swift +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/API Bindings/FinancialConnectionsAPIClient.swift @@ -9,6 +9,10 @@ import Foundation @_spi(STP) import StripeCore final class FinancialConnectionsAPIClient { + private enum EncodingError: Error { + case cannotCastToDictionary + } + let backingAPIClient: STPAPIClient var isLinkWithStripe: Bool = false @@ -74,6 +78,17 @@ final class FinancialConnectionsAPIClient { } return promise } + + static func encodeAsParameters(_ value: any Encodable) throws -> [String: Any] { + let jsonData = try JSONEncoder().encode(value) + let jsonObject = try JSONSerialization.jsonObject(with: jsonData) + + if let dictionary = jsonObject as? [String: Any] { + return dictionary + } else { + throw EncodingError.cannotCastToDictionary + } + } } protocol FinancialConnectionsAPI { @@ -230,7 +245,9 @@ protocol FinancialConnectionsAPI { func paymentDetails( consumerSessionClientSecret: String, - bankAccountId: String + bankAccountId: String, + billingAddress: BillingAddress?, + billingEmail: String? ) -> Future func sharePaymentDetails( @@ -967,9 +984,11 @@ extension FinancialConnectionsAPIClient: FinancialConnectionsAPI { func paymentDetails( consumerSessionClientSecret: String, - bankAccountId: String + bankAccountId: String, + billingAddress: BillingAddress?, + billingEmail: String? ) -> Future { - let parameters: [String: Any] = [ + var parameters: [String: Any] = [ "request_surface": requestSurface, "credentials": [ "consumer_session_client_secret": consumerSessionClientSecret @@ -979,6 +998,22 @@ extension FinancialConnectionsAPIClient: FinancialConnectionsAPI { ], "type": "bank_account", ] + + if let billingAddress { + do { + let encodedBillingAddress = try Self.encodeAsParameters(billingAddress) + parameters["billing_address"] = encodedBillingAddress + } catch let error { + let promise = Promise() + promise.reject(with: error) + return promise + } + } + + if let billingEmail, !billingEmail.isEmpty { + parameters["billing_email_address"] = billingEmail + } + return post( resource: APIEndpointPaymentDetails, parameters: parameters, diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Native/NativeFlowController.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/NativeFlowController.swift index c334a399ff8..a2d0fd5f9e0 100644 --- a/StripeFinancialConnections/StripeFinancialConnections/Source/Native/NativeFlowController.swift +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/NativeFlowController.swift @@ -508,10 +508,14 @@ extension NativeFlowController { // Bank account details extraction for the linked bank var bankAccountDetails: BankAccountDetails? - let linkMode = dataManager.elementsSessionContext?.linkMode + let elementsSessionContext = dataManager.elementsSessionContext + let linkMode = elementsSessionContext?.linkMode + let email = dataManager.consumerSession?.emailAddress dataManager.createPaymentDetails( consumerSessionClientSecret: consumerSession.clientSecret, - bankAccountId: bankAccountId + bankAccountId: bankAccountId, + billingAddress: elementsSessionContext?.billingAddress, + billingEmail: email ) .chained { [weak self] paymentDetails -> Future in guard let self else { diff --git a/StripeFinancialConnections/StripeFinancialConnections/Source/Native/NativeFlowDataManager.swift b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/NativeFlowDataManager.swift index 380ecb8ff16..15070a0951b 100644 --- a/StripeFinancialConnections/StripeFinancialConnections/Source/Native/NativeFlowDataManager.swift +++ b/StripeFinancialConnections/StripeFinancialConnections/Source/Native/NativeFlowDataManager.swift @@ -38,7 +38,9 @@ protocol NativeFlowDataManager: AnyObject { func createPaymentDetails( consumerSessionClientSecret: String, - bankAccountId: String + bankAccountId: String, + billingAddress: BillingAddress?, + billingEmail: String? ) -> Future func createPaymentMethod( consumerSessionClientSecret: String, @@ -140,11 +142,15 @@ class NativeFlowAPIDataManager: NativeFlowDataManager { func createPaymentDetails( consumerSessionClientSecret: String, - bankAccountId: String + bankAccountId: String, + billingAddress: BillingAddress?, + billingEmail: String? ) -> Future { apiClient.paymentDetails( consumerSessionClientSecret: consumerSessionClientSecret, - bankAccountId: bankAccountId + bankAccountId: bankAccountId, + billingAddress: billingAddress, + billingEmail: billingEmail ) } diff --git a/StripeFinancialConnections/StripeFinancialConnectionsTests/EmptyFinancialConnectionsAPIClient.swift b/StripeFinancialConnections/StripeFinancialConnectionsTests/EmptyFinancialConnectionsAPIClient.swift index 6ee9283cc04..77c15b82045 100644 --- a/StripeFinancialConnections/StripeFinancialConnectionsTests/EmptyFinancialConnectionsAPIClient.swift +++ b/StripeFinancialConnections/StripeFinancialConnectionsTests/EmptyFinancialConnectionsAPIClient.swift @@ -215,7 +215,12 @@ class EmptyFinancialConnectionsAPIClient: FinancialConnectionsAPI { return Promise() } - func paymentDetails(consumerSessionClientSecret: String, bankAccountId: String) -> StripeCore.Future { + func paymentDetails( + consumerSessionClientSecret: String, + bankAccountId: String, + billingAddress: BillingAddress?, + billingEmail: String? + ) -> StripeCore.Future { Promise() } diff --git a/StripeFinancialConnections/StripeFinancialConnectionsTests/FinancialConnectionsAPIClientTests.swift b/StripeFinancialConnections/StripeFinancialConnectionsTests/FinancialConnectionsAPIClientTests.swift index 4807d42b519..7d08c33b0db 100644 --- a/StripeFinancialConnections/StripeFinancialConnectionsTests/FinancialConnectionsAPIClientTests.swift +++ b/StripeFinancialConnections/StripeFinancialConnectionsTests/FinancialConnectionsAPIClientTests.swift @@ -52,4 +52,25 @@ class FinancialConnectionsAPIClientTests: XCTestCase { XCTAssertNil(apiClient.consumerPublishableKeyProvider(canUseConsumerKey: false)) } + + func testEncodedAsParameters() throws { + let billingAddress = BillingAddress( + name: "Bobby Tables", + line1: "123 Fake St", + line2: nil, + city: "Utopia", + state: "CA", + postalCode: "90210", + countryCode: "US" + ) + let encodedBillingAddress = try FinancialConnectionsAPIClient.encodeAsParameters(billingAddress) + + XCTAssertEqual(encodedBillingAddress["name"] as? String, "Bobby Tables") + XCTAssertEqual(encodedBillingAddress["line_1"] as? String, "123 Fake St") + XCTAssertNil(encodedBillingAddress["line_2"]) + XCTAssertEqual(encodedBillingAddress["locality"] as? String, "Utopia") + XCTAssertEqual(encodedBillingAddress["administrative_area"] as? String, "CA") + XCTAssertEqual(encodedBillingAddress["postal_code"] as? String, "90210") + XCTAssertEqual(encodedBillingAddress["country_code"] as? String, "US") + } } diff --git a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/USBankAccount/InstantDebitsPaymentMethodElement.swift b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/USBankAccount/InstantDebitsPaymentMethodElement.swift index ffb2ed1b8a9..2aee8a36538 100644 --- a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/USBankAccount/InstantDebitsPaymentMethodElement.swift +++ b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/USBankAccount/InstantDebitsPaymentMethodElement.swift @@ -97,12 +97,12 @@ final class InstantDebitsPaymentMethodElement: ContainerElement { var address: PaymentSheet.Address { PaymentSheet.Address( - city: addressElement?.city?.text, - country: addressElement?.selectedCountryCode, - line1: addressElement?.line1?.text, - line2: addressElement?.line2?.text, - postalCode: addressElement?.postalCode?.text, - state: addressElement?.state?.rawData + city: addressElement?.city?.text ?? defaultAddress?.city, + country: addressElement?.selectedCountryCode ?? defaultAddress?.country, + line1: addressElement?.line1?.text ?? defaultAddress?.line1, + line2: addressElement?.line2?.text ?? defaultAddress?.line2, + postalCode: addressElement?.postalCode?.text ?? defaultAddress?.postalCode, + state: addressElement?.state?.rawData ?? defaultAddress?.state ) } @@ -111,6 +111,18 @@ final class InstantDebitsPaymentMethodElement: ContainerElement { return configuration.defaultBillingDetails.address } + var billingAddress: BillingAddress { + BillingAddress( + name: name, + line1: address.line1, + line2: address.line2, + city: address.city, + state: address.state, + postalCode: address.postalCode, + countryCode: address.country + ) + } + var enableCTA: Bool { let nameValid: Bool = { // If the name field isn't shown, we treat the name as valid. diff --git a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/ViewControllers/PaymentMethodFormViewController.swift b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/ViewControllers/PaymentMethodFormViewController.swift index 3e35fd95981..1361d3f02c7 100644 --- a/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/ViewControllers/PaymentMethodFormViewController.swift +++ b/StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/ViewControllers/PaymentMethodFormViewController.swift @@ -220,11 +220,13 @@ extension PaymentMethodFormViewController { }() let linkMode = elementsSession.linkSettings?.linkMode + let billingAddress = instantDebitsFormElement?.billingAddress return ElementsSessionContext( amount: intent.amount, currency: intent.currency, intentId: intentId, - linkMode: linkMode + linkMode: linkMode, + billingAddress: billingAddress ) } diff --git a/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/PaymentSheetFormFactoryTest.swift b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/PaymentSheetFormFactoryTest.swift index 976e2b02a49..04b9d04b19f 100644 --- a/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/PaymentSheetFormFactoryTest.swift +++ b/StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/PaymentSheetFormFactoryTest.swift @@ -1765,8 +1765,7 @@ class PaymentSheetFormFactoryTest: XCTestCase { XCTAssertEqual(instantDebitsSection.defaultEmail, "foo@bar.com") XCTAssertEqual(instantDebitsSection.phone, "+12345678900") XCTAssertEqual(instantDebitsSection.defaultPhone, "+12345678900") - // Unlike the other fields, the `address` will not fallback to the default. - XCTAssertEqual(instantDebitsSection.address, PaymentSheet.Address()) + XCTAssertEqual(instantDebitsSection.address, defaultAddress) XCTAssertEqual(instantDebitsSection.defaultAddress, defaultAddress) }