From 9a0d88b75e1dcdc9360941605a974106f70e9cfe Mon Sep 17 00:00:00 2001
From: Mat Schmid <mats@stripe.com>
Date: Mon, 21 Oct 2024 13:45:27 -0400
Subject: [PATCH] Pass billing address and email address to payment details API

---
 .../StripeCore.xcodeproj/project.pbxproj      |  4 ++
 .../Connections Bindings/BillingAddress.swift | 58 +++++++++++++++++++
 .../ElementsSessionContext.swift              |  5 +-
 .../FinancialConnectionsAPIClient.swift       | 41 ++++++++++++-
 .../Source/Native/NativeFlowController.swift  |  8 ++-
 .../Source/Native/NativeFlowDataManager.swift | 12 +++-
 .../EmptyFinancialConnectionsAPIClient.swift  |  7 ++-
 .../FinancialConnectionsAPIClientTests.swift  | 21 +++++++
 .../InstantDebitsPaymentMethodElement.swift   | 24 ++++++--
 .../PaymentMethodFormViewController.swift     |  4 +-
 .../PaymentSheetFormFactoryTest.swift         |  3 +-
 11 files changed, 168 insertions(+), 19 deletions(-)
 create mode 100644 StripeCore/StripeCore/Source/Connections Bindings/BillingAddress.swift

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 = "<group>"; };
 		49538DBF8457D96707A2DA56 /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/Localizable.strings; sourceTree = "<group>"; };
 		49ECDA402CA340E100F647F0 /* AsyncTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncTests.swift; sourceTree = "<group>"; };
+		49F3828C2CC02D43001CE69A /* BillingAddress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BillingAddress.swift; sourceTree = "<group>"; };
 		4A8030BF88608CA86E295F18 /* Enums+CustomStringConvertible.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Enums+CustomStringConvertible.swift"; sourceTree = "<group>"; };
 		4C51E3FA5EE3587BB7BBC634 /* STPError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = STPError.swift; sourceTree = "<group>"; };
 		4EC3BCEEECB3E1485B18F0C4 /* FinancialConnectionsSDKInterface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FinancialConnectionsSDKInterface.swift; sourceTree = "<group>"; };
@@ -456,6 +458,7 @@
 				6A05FB4A2BCF245C0001D128 /* FinancialConnectionsEvent.swift */,
 				493B33052CA3015600E3622F /* LinkMode.swift */,
 				492039922CA47A8600CE2072 /* ElementsSessionContext.swift */,
+				49F3828C2CC02D43001CE69A /* BillingAddress.swift */,
 			);
 			path = "Connections Bindings";
 			sourceTree = "<group>";
@@ -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<FinancialConnectionsPaymentDetails>
 
     func sharePaymentDetails(
@@ -967,9 +984,11 @@ extension FinancialConnectionsAPIClient: FinancialConnectionsAPI {
 
     func paymentDetails(
         consumerSessionClientSecret: String,
-        bankAccountId: String
+        bankAccountId: String,
+        billingAddress: BillingAddress?,
+        billingEmail: String?
     ) -> Future<FinancialConnectionsPaymentDetails> {
-        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<FinancialConnectionsPaymentDetails>()
+                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<PaymentMethodIDProvider> 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<FinancialConnectionsPaymentDetails>
     func createPaymentMethod(
         consumerSessionClientSecret: String,
@@ -140,11 +142,15 @@ class NativeFlowAPIDataManager: NativeFlowDataManager {
 
     func createPaymentDetails(
         consumerSessionClientSecret: String,
-        bankAccountId: String
+        bankAccountId: String,
+        billingAddress: BillingAddress?,
+        billingEmail: String?
     ) -> Future<FinancialConnectionsPaymentDetails> {
         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<StripeFinancialConnections.AttachLinkConsumerToLinkAccountSessionResponse>()
     }
 
-    func paymentDetails(consumerSessionClientSecret: String, bankAccountId: String) -> StripeCore.Future<StripeFinancialConnections.FinancialConnectionsPaymentDetails> {
+    func paymentDetails(
+        consumerSessionClientSecret: String,
+        bankAccountId: String,
+        billingAddress: BillingAddress?,
+        billingEmail: String?
+    ) -> StripeCore.Future<StripeFinancialConnections.FinancialConnectionsPaymentDetails> {
         Promise<StripeFinancialConnections.FinancialConnectionsPaymentDetails>()
     }
 
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)
     }