Skip to content

Commit 80bdf29

Browse files
porter-stripecodex
andauthored
Replace bank debit FormSpecs with explicit forms (#6830)
## Summary - Builds the EPS, Przelewy24, AU BECS Debit, and FPX forms without relying on remotely loaded form specs. This keeps their billing fields, bank selectors, account fields, and submitted parameters consistent while moving them onto explicit form construction. - One final PR will come to remove FormSpecs JSON and infra. ## Motivation Form spec removal ## Testing - New unit test - `ci_scripts/run_tests.rb --test StripePaymentSheetTests/PaymentSheetFormFactoryTest/testBankDebitFormsDoNotRequireFormSpecs` ## Changelog N/A --------- Co-authored-by: codex <noreply@openai.com>
1 parent 4230345 commit 80bdf29

5 files changed

Lines changed: 303 additions & 25 deletions

File tree

Stripe/StripeiOSTests/STPFPXBankBrandTest.swift

Lines changed: 3 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -7,32 +7,11 @@
77
// Copyright © 2019 Stripe, Inc. All rights reserved.
88
//
99

10+
@_spi(STP) import StripePayments
11+
1012
class STPFPXBankBrandTest: XCTestCase {
1113
func testStringFromBrand() {
12-
let brands: [STPFPXBankBrand] = [
13-
.affinBank,
14-
.allianceBank,
15-
.ambank,
16-
.bankIslam,
17-
.bankMuamalat,
18-
.bankRakyat,
19-
.BSN,
20-
.CIMB,
21-
.hongLeongBank,
22-
.HSBC,
23-
.KFH,
24-
.maybank2E,
25-
.maybank2U,
26-
.ocbc,
27-
.publicBank,
28-
.CIMB,
29-
.RHB,
30-
.standardChartered,
31-
.UOB,
32-
.unknown,
33-
]
34-
35-
for brand in brands {
14+
for brand in STPFPXBankBrand.allCases {
3615
let brandName = STPFPXBank.stringFrom(brand)
3716
let brandID = STPFPXBank.identifierFrom(brand)
3817
let reverseTransformedBrand = STPFPXBank.brandFrom(brandID)
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
//
2+
// PaymentSheetFormFactory+BankDebits.swift
3+
// StripePaymentSheet
4+
//
5+
// Created by Nick Porter on 8/3/26.
6+
//
7+
// Copyright © 2026 Stripe, Inc. All rights reserved.
8+
//
9+
10+
@_spi(STP) import StripeCore
11+
@_spi(STP) import StripePayments
12+
@_spi(STP) import StripeUICore
13+
14+
extension PaymentSheetFormFactory {
15+
func makeEPS() -> PaymentMethodElement {
16+
let name = configuration.billingDetailsCollectionConfiguration.name != .never
17+
? makeName(apiPath: "billing_details[name]") : nil
18+
let email = configuration.billingDetailsCollectionConfiguration.email == .always ? makeEmail() : nil
19+
let phone = configuration.billingDetailsCollectionConfiguration.phone == .always ? makePhone() : nil
20+
let bank = makeBankDropdown(
21+
label: STPLocalizedString("EPS Bank", "Label title for EPS Bank"),
22+
apiPath: "eps[bank]",
23+
banks: BankDropdown.eps
24+
)
25+
let address = makeBillingAddressSectionIfNecessary(requiredByPaymentMethod: false)
26+
as? PaymentMethodElementWrapper<AddressSectionElement>
27+
connectBillingDetailsFields(addressElement: address, phoneElement: phone)
28+
let elements: [Element?] = [name, email, phone, bank, address]
29+
30+
return makeDefaultsApplierWrapper(
31+
for: FormElement(
32+
autoSectioningElements: elements.compactMap { $0 },
33+
theme: theme
34+
)
35+
)
36+
}
37+
38+
func makePrzelewy24() -> PaymentMethodElement {
39+
let name = configuration.billingDetailsCollectionConfiguration.name != .never
40+
? makeName(apiPath: "billing_details[name]") : nil
41+
let email = configuration.billingDetailsCollectionConfiguration.email != .never
42+
? makeEmail(apiPath: "billing_details[email]") : nil
43+
let phone = configuration.billingDetailsCollectionConfiguration.phone == .always ? makePhone() : nil
44+
let bank = makeBankDropdown(
45+
label: STPLocalizedString("Przelewy24 Bank", "Label title for Przelewy24 Bank"),
46+
apiPath: "p24[bank]",
47+
banks: BankDropdown.przelewy24
48+
)
49+
let address = makeBillingAddressSectionIfNecessary(requiredByPaymentMethod: false)
50+
as? PaymentMethodElementWrapper<AddressSectionElement>
51+
connectBillingDetailsFields(addressElement: address, phoneElement: phone)
52+
let elements: [Element?] = [name, email, phone, bank, address]
53+
54+
return makeDefaultsApplierWrapper(
55+
for: FormElement(
56+
autoSectioningElements: elements.compactMap { $0 },
57+
theme: theme
58+
)
59+
)
60+
}
61+
62+
func makeAUBECSDebit() -> PaymentMethodElement {
63+
let name = configuration.billingDetailsCollectionConfiguration.name != .never
64+
? makeName(label: String.Localized.nameOnAccount, apiPath: "billing_details[name]") : nil
65+
let email = configuration.billingDetailsCollectionConfiguration.email != .never
66+
? makeEmail(apiPath: "billing_details[email]") : nil
67+
let phone = configuration.billingDetailsCollectionConfiguration.phone == .always ? makePhone() : nil
68+
let bsb = makeBSB(apiPath: "au_becs_debit[bsb_number]")
69+
let accountNumber = makeAUBECSAccountNumber(apiPath: "au_becs_debit[account_number]")
70+
let address = makeBillingAddressSectionIfNecessary(requiredByPaymentMethod: false)
71+
as? PaymentMethodElementWrapper<AddressSectionElement>
72+
connectBillingDetailsFields(addressElement: address, phoneElement: phone)
73+
let elements: [Element?] = [
74+
name,
75+
email,
76+
phone,
77+
bsb,
78+
accountNumber,
79+
address,
80+
makeAUBECSMandate(),
81+
]
82+
83+
return makeDefaultsApplierWrapper(
84+
for: FormElement(
85+
autoSectioningElements: elements.compactMap { $0 },
86+
theme: theme
87+
)
88+
)
89+
}
90+
91+
func makeFPX() -> PaymentMethodElement {
92+
let bank = makeBankDropdown(
93+
label: STPLocalizedString("FPX Bank", "Select a bank dropdown for FPX"),
94+
apiPath: "fpx[bank]",
95+
banks: BankDropdown.fpx
96+
)
97+
let address = makeBillingAddressSectionIfNecessary(requiredByPaymentMethod: false)
98+
as? PaymentMethodElementWrapper<AddressSectionElement>
99+
let name = configuration.billingDetailsCollectionConfiguration.name == .always ? makeName() : nil
100+
let email = configuration.billingDetailsCollectionConfiguration.email == .always ? makeEmail() : nil
101+
let phone = configuration.billingDetailsCollectionConfiguration.phone == .always ? makePhone() : nil
102+
connectBillingDetailsFields(addressElement: address, phoneElement: phone)
103+
let elements: [Element?] = [bank, address, name, email, phone]
104+
105+
return makeDefaultsApplierWrapper(
106+
for: FormElement(
107+
autoSectioningElements: elements.compactMap { $0 },
108+
theme: theme
109+
)
110+
)
111+
}
112+
113+
private func makeBankDropdown(
114+
label: String,
115+
apiPath: String,
116+
banks: [(name: String, value: String)]
117+
) -> PaymentMethodElementWrapper<DropdownFieldElement> {
118+
let items = banks.map {
119+
DropdownFieldElement.DropdownItem(
120+
pickerDisplayName: $0.name,
121+
labelDisplayName: $0.name,
122+
accessibilityValue: $0.name,
123+
rawData: $0.value
124+
)
125+
}
126+
let defaultIndex = items.firstIndex {
127+
$0.rawData == getPreviousCustomerInput(for: apiPath)
128+
} ?? 0
129+
let dropdown = DropdownFieldElement(
130+
items: items,
131+
defaultIndex: defaultIndex,
132+
label: label,
133+
theme: theme
134+
)
135+
return PaymentMethodElementWrapper(dropdown) { dropdown, params in
136+
params.paymentMethodParams.additionalAPIParameters[apiPath] = dropdown.selectedItem.rawData
137+
return params
138+
}
139+
}
140+
}
141+
142+
private enum BankDropdown {
143+
static let eps: [(name: String, value: String)] = [
144+
("Ärzte- und Apothekerbank", "arzte_und_apotheker_bank"),
145+
("Austrian Anadi Bank AG", "austrian_anadi_bank_ag"),
146+
("Bank Austria", "bank_austria"),
147+
("bank99 AG", "brull_kallmus_bank_ag"),
148+
("Bankhaus Carl Spängler & Co.AG", "bankhaus_carl_spangler"),
149+
("Bankhaus Schelhammer & Schattera AG", "bankhaus_schelhammer_und_schattera_ag"),
150+
("BAWAG P.S.K. AG", "bawag_psk_ag"),
151+
("BKS Bank AG", "bks_bank_ag"),
152+
("BTV VIER LÄNDER BANK", "btv_vier_lander_bank"),
153+
("Capital Bank Grawe Gruppe AG", "capital_bank_grawe_gruppe_ag"),
154+
("Dolomitenbank", "dolomitenbank"),
155+
("Easybank AG", "easybank_ag"),
156+
("Erste Bank und Sparkassen", "erste_bank_und_sparkassen"),
157+
("Hypo Alpe-Adria-Bank International AG", "hypo_alpeadriabank_international_ag"),
158+
("HYPO NOE LB für Niederösterreich u. Wien", "hypo_noe_lb_fur_niederosterreich_u_wien"),
159+
("HYPO Oberösterreich,Salzburg,Steiermark", "hypo_oberosterreich_salzburg_steiermark"),
160+
("Hypo Tirol Bank AG", "hypo_tirol_bank_ag"),
161+
("Hypo Vorarlberg Bank AG", "hypo_vorarlberg_bank_ag"),
162+
("HYPO-BANK BURGENLAND Aktiengesellschaft", "hypo_bank_burgenland_aktiengesellschaft"),
163+
("Marchfelder Bank", "marchfelder_bank"),
164+
("Oberbank AG", "oberbank_ag"),
165+
("Raiffeisen Bankengruppe Österreich", "raiffeisen_bankengruppe_osterreich"),
166+
("Schoellerbank AG", "schoellerbank_ag"),
167+
("Sparda-Bank Wien", "sparda_bank_wien"),
168+
("Volksbank Gruppe", "volksbank_gruppe"),
169+
("Volkskreditbank AG", "volkskreditbank_ag"),
170+
("VR-Bank Braunau", "vr_bank_braunau"),
171+
]
172+
173+
static let przelewy24: [(name: String, value: String)] = [
174+
("Alior Bank", "alior_bank"),
175+
("Bank Millenium", "bank_millennium"),
176+
("Bank Nowy BFG S.A.", "bank_nowy_bfg_sa"),
177+
("Bank PEKAO S.A", "bank_pekao_sa"),
178+
("Bank spółdzielczy", "banki_spbdzielcze"),
179+
("BLIK", "blik"),
180+
("BNP Paribas", "bnp_paribas"),
181+
("BOZ", "boz"),
182+
("CitiHandlowy", "citi_handlowy"),
183+
("Credit Agricole", "credit_agricole"),
184+
("e-Transfer Pocztowy24", "etransfer_pocztowy24"),
185+
("Getin Bank", "getin_bank"),
186+
("IdeaBank", "ideabank"),
187+
("ING", "ing"),
188+
("inteligo", "inteligo"),
189+
("mBank", "mbank_mtransfer"),
190+
("Nest Przelew", "nest_przelew"),
191+
("Noble Pay", "noble_pay"),
192+
("Płać z iPKO (PKO BP)", "pbac_z_ipko"),
193+
("Plus Bank", "plus_bank"),
194+
("Santander", "santander_przelew24"),
195+
("Toyota Bank", "toyota_bank"),
196+
("VeloBank", "velobank"),
197+
("Volkswagen Bank", "volkswagen_bank"),
198+
]
199+
200+
static let fpx: [(name: String, value: String)] = STPFPXBankBrand.allCases
201+
.compactMap { brand in
202+
guard brand != .unknown,
203+
let name = STPFPXBank.stringFrom(brand),
204+
let value = STPFPXBank.identifierFrom(brand)
205+
else {
206+
return nil
207+
}
208+
return (name, value)
209+
}
210+
.sorted { $0.value < $1.value }
211+
}

StripePaymentSheet/StripePaymentSheet/Source/PaymentSheet/PaymentSheetFormFactory/PaymentSheetFormFactory.swift

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -291,7 +291,15 @@ class PaymentSheetFormFactory {
291291
return makeContactInformationAndBillingAddressForm(
292292
additionalElements: makeSetupMandateElements(for: paymentMethod)
293293
)
294-
case .FPX, .AUBECSDebit, .przelewy24, .EPS, .netBanking, .weChatPay,
294+
case .EPS:
295+
return makeEPS()
296+
case .przelewy24:
297+
return makePrzelewy24()
298+
case .AUBECSDebit:
299+
return makeAUBECSDebit()
300+
case .FPX:
301+
return makeFPX()
302+
case .netBanking, .weChatPay,
295303
.link, .cardPresent, .unknown:
296304
return makeFormSpecBasedForm(for: paymentMethod)
297305
@unknown default:

StripePaymentSheet/StripePaymentSheetTests/PaymentSheet/PaymentSheetFormFactoryTest.swift

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,14 @@ private final class MockFormSpecProvider: FormSpecProvider {
3737
}
3838
}
3939

40+
private struct BankFormExpectation {
41+
let paymentMethod: STPPaymentMethodType
42+
let apiPath: String
43+
let itemCount: Int
44+
let firstValue: String
45+
let lastValue: String
46+
}
47+
4048
@MainActor
4149
class PaymentSheetFormFactoryTest: XCTestCase {
4250
private func extractBNPLHeaderView(from subtitle: SubtitleElement) -> BNPLFormHeaderView? {
@@ -2023,6 +2031,76 @@ class PaymentSheetFormFactoryTest: XCTestCase {
20232031
}
20242032
}
20252033

2034+
func testBankDebitFormsDoNotRequireFormSpecs() {
2035+
// Given no loaded form specs and billing detail collection disabled
2036+
let originalFormSpecProvider = FormSpecProvider.shared
2037+
defer { FormSpecProvider.shared = originalFormSpecProvider }
2038+
FormSpecProvider.shared = FormSpecProvider()
2039+
var configuration = PaymentSheet.Configuration()
2040+
configuration.billingDetailsCollectionConfiguration.name = .never
2041+
configuration.billingDetailsCollectionConfiguration.email = .never
2042+
configuration.billingDetailsCollectionConfiguration.phone = .never
2043+
configuration.billingDetailsCollectionConfiguration.address = .never
2044+
let bankForms: [BankFormExpectation] = [
2045+
.init(
2046+
paymentMethod: .EPS,
2047+
apiPath: "eps[bank]",
2048+
itemCount: 27,
2049+
firstValue: "arzte_und_apotheker_bank",
2050+
lastValue: "vr_bank_braunau"
2051+
),
2052+
.init(
2053+
paymentMethod: .przelewy24,
2054+
apiPath: "p24[bank]",
2055+
itemCount: 24,
2056+
firstValue: "alior_bank",
2057+
lastValue: "volkswagen_bank"
2058+
),
2059+
.init(
2060+
paymentMethod: .FPX,
2061+
apiPath: "fpx[bank]",
2062+
itemCount: 18,
2063+
firstValue: "affin_bank",
2064+
lastValue: "uob"
2065+
),
2066+
]
2067+
2068+
for bankForm in bankForms {
2069+
// When building a bank-selector form
2070+
let form = PaymentSheetFormFactory(
2071+
intent: ._testPaymentIntent(paymentMethodTypes: [bankForm.paymentMethod]),
2072+
elementsSession: ._testValue(paymentMethodTypes: [bankForm.paymentMethod.identifier]),
2073+
configuration: .paymentElement(configuration),
2074+
paymentMethod: .stripe(bankForm.paymentMethod)
2075+
).make()
2076+
2077+
// Then the complete selector is built without consulting FormSpecProvider
2078+
let dropdown = form.getAllUnwrappedSubElements()
2079+
.compactMap { $0 as? DropdownFieldElement }
2080+
.first
2081+
XCTAssertEqual(dropdown?.items.count, bankForm.itemCount)
2082+
XCTAssertEqual(dropdown?.items.first?.rawData, bankForm.firstValue)
2083+
XCTAssertEqual(dropdown?.items.last?.rawData, bankForm.lastValue)
2084+
let params = form.updateParams(params: .init(type: .stripe(bankForm.paymentMethod)))
2085+
XCTAssertEqual(
2086+
params?.paymentMethodParams.additionalAPIParameters[bankForm.apiPath] as? String,
2087+
bankForm.firstValue
2088+
)
2089+
}
2090+
2091+
// And AU BECS keeps its two account fields without a form spec
2092+
let auBecsForm = PaymentSheetFormFactory(
2093+
intent: ._testPaymentIntent(paymentMethodTypes: [.AUBECSDebit]),
2094+
elementsSession: ._testValue(paymentMethodTypes: [STPPaymentMethodType.AUBECSDebit.identifier]),
2095+
configuration: .paymentElement(configuration),
2096+
paymentMethod: .stripe(.AUBECSDebit)
2097+
).make()
2098+
XCTAssertEqual(
2099+
auBecsForm.getAllUnwrappedSubElements().compactMap { $0 as? TextFieldElement }.count,
2100+
2
2101+
)
2102+
}
2103+
20262104
func testLinkPMModeCardFormContainsMandateText() {
20272105
let expectation = expectation(description: "Load specs")
20282106
AddressSpecProvider.shared.loadAddressSpecs {

StripePayments/StripePayments/Source/API Bindings/Models/STPFPXBankBrand.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ import Foundation
5050
case unknown
5151
}
5252

53+
@_spi(STP) extension STPFPXBankBrand: CaseIterable {}
54+
5355
/// Convenience methods for using FPX bank brands.
5456
public class STPFPXBank: NSObject {
5557
/// Returns a string representation for the provided bank brand;

0 commit comments

Comments
 (0)