Skip to content

Commit

Permalink
[BANKCON-15711] Pass billing address and email address to payment det…
Browse files Browse the repository at this point in the history
…ails API (#4148)

Android equivalent: stripe/stripe-android#9446

## Summary

This passes the billing address model and a billing email to the
`/consumers/payment_details/` API. More details:
https://docs.google.com/document/d/1IP66DkxVK6DzeGCmuw00Gt9okFhEHQgM3lqBZSA-M4E/edit?usp=sharing

## Motivation

BANKCON-15711

## Testing

Unit tests, and I've verified the billing fields are correctly passed
from MPE to the API call:

<img width="1246" alt="Screenshot 2024-10-16 at 3 12 25 PM"
src="https://github.com/user-attachments/assets/1d780fdd-bc69-45ba-9b22-0cf3f3295683">

## Changelog

N/a
  • Loading branch information
mats-stripe authored Oct 29, 2024
1 parent 8856caf commit 7abb1ad
Show file tree
Hide file tree
Showing 11 changed files with 197 additions and 19 deletions.
4 changes: 4 additions & 0 deletions StripeCore/StripeCore.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -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>"; };
Expand Down Expand Up @@ -456,6 +458,7 @@
6A05FB4A2BCF245C0001D128 /* FinancialConnectionsEvent.swift */,
493B33052CA3015600E3622F /* LinkMode.swift */,
492039922CA47A8600CE2072 /* ElementsSessionContext.swift */,
49F3828C2CC02D43001CE69A /* BillingAddress.swift */,
);
path = "Connections Bindings";
sourceTree = "<group>";
Expand Down Expand Up @@ -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 */,
Expand Down
Original file line number Diff line number Diff line change
@@ -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? = nil,
line1: String? = nil,
line2: String? = nil,
city: String? = nil,
state: String? = nil,
postalCode: String? = nil,
countryCode: String? = nil
) {
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) }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,18 +30,21 @@ import Foundation
@_spi(STP) public let prefillDetails: PrefillDetails?
@_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?,
prefillDetails: PrefillDetails?,
intentId: IntentID?,
linkMode: LinkMode?
linkMode: LinkMode?,
billingAddress: BillingAddress?
) {
self.amount = amount
self.currency = currency
self.prefillDetails = prefillDetails
self.intentId = intentId
self.linkMode = linkMode
self.billingAddress = billingAddress
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -230,7 +245,9 @@ protocol FinancialConnectionsAPI {

func paymentDetails(
consumerSessionClientSecret: String,
bankAccountId: String
bankAccountId: String,
billingAddress: BillingAddress?,
billingEmail: String?
) -> Future<FinancialConnectionsPaymentDetails>

func sharePaymentDetails(
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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>()
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,4 +52,46 @@ class FinancialConnectionsAPIClientTests: XCTestCase {

XCTAssertNil(apiClient.consumerPublishableKeyProvider(canUseConsumerKey: false))
}

func testBillingAddressEncodedAsParameters() 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")
}

func testBillingAddressEncodedAsParametersNonNilLine2() throws {
let billingAddress = BillingAddress(
name: "Bobby Tables",
line1: "123 Fake St",
line2: "",
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")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
}

Expand All @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -224,12 +224,22 @@ extension PaymentMethodFormViewController {
phoneNumber: instantDebitsFormElement?.phone ?? configuration.defaultBillingDetails.phone
)
let linkMode = elementsSession.linkSettings?.linkMode
let billingAddress: BillingAddress? = {
if configuration.billingDetailsCollectionConfiguration.address == .full {
return instantDebitsFormElement?.billingAddress
} else if configuration.billingDetailsCollectionConfiguration.name == .always {
return BillingAddress(name: instantDebitsFormElement?.name)
} else {
return nil
}
}()
return ElementsSessionContext(
amount: intent.amount,
currency: intent.currency,
prefillDetails: prefillDetails,
intentId: intentId,
linkMode: linkMode
linkMode: linkMode,
billingAddress: billingAddress
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down

0 comments on commit 7abb1ad

Please sign in to comment.