[MC] SEPA support (#265)

* SEPA saved PMs

* SEPA Debit

* Remove old-way code

* Handle SEPA errors

* Wait longer for test?

* PR feedback

* Attempt to simplify IBAN validation logic

* Update localization

* PR feedback
This commit is contained in:
Yuki 2021-07-02 14:37:21 -07:00 committed by GitHub
parent 88e06d8e85
commit 14596a3967
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 578 additions and 97 deletions

View File

@ -266,9 +266,51 @@ class PaymentSheetUITest: XCTestCase {
let successText = app.staticTexts["Payment status view"]
XCTAssertTrue(successText.waitForExistence(timeout: 10.0))
XCTAssertNotNil(successText.label.range(of: "Your order is confirmed!"))
}
// iDEAL has some text fields and a dropdown, and
func testIdealPaymentMethod() throws {
app.staticTexts["PaymentSheet (test playground)"].tap()
app.buttons["new"].tap() // new customer
app.buttons["off"].tap() // disable Apple Pay
app.buttons["EUR"].tap() // EUR currency
app.buttons["Reload PaymentSheet"].tap()
let checkout = app.buttons["Checkout (Complete)"]
expectation(
for: NSPredicate(format: "enabled == true"),
evaluatedWith: checkout,
handler: nil
)
waitForExpectations(timeout: 60.0, handler: nil)
checkout.tap()
let payButton = app.buttons["Pay €9.73"]
// Select iDEAL
app.cells["iDEAL"].tap()
XCTAssertFalse(payButton.isEnabled)
let name = app.textFields["Name"]
name.tap()
name.typeText("John Doe")
let email = app.textFields["Email"]
email.tap()
email.typeText("stripe@stripe.com")
let bank = app.textFields["iDEAL Bank"]
bank.tap()
app.pickerWheels.firstMatch.adjust(toPickerWheelValue: "ASN Bank")
app.toolbars.buttons["Done"].tap()
// Attempt payment
payButton.tap()
// Close the webview, no need to see the successful pay
let webviewCloseButton = app.otherElements["TopBrowserBar"].buttons["Close"]
XCTAssertTrue(webviewCloseButton.waitForExistence(timeout: 10.0))
webviewCloseButton.tap()
}
}
// There seems to be an issue with our SwiftUI buttons - XCTest fails to scroll to the button's position.

View File

@ -512,6 +512,8 @@
B64519D92514410C006BF25E /* STPPaymentMethodBancontact.swift in Sources */ = {isa = PBXBuildFile; fileRef = 317ABD7825117C9700CC59EF /* STPPaymentMethodBancontact.swift */; };
B64519DD25144184006BF25E /* STPPaymentMethodBancontactParams.swift in Sources */ = {isa = PBXBuildFile; fileRef = 317ABDD425117C9D00CC59EF /* STPPaymentMethodBancontactParams.swift */; };
B64519E125144254006BF25E /* STPPaymentMethodBillingDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = 317ABD6125117C9600CC59EF /* STPPaymentMethodBillingDetails.swift */; };
B646C2522682731000F4EAE5 /* TextFieldElement+IBAN.swift in Sources */ = {isa = PBXBuildFile; fileRef = B646C2512682731000F4EAE5 /* TextFieldElement+IBAN.swift */; };
B646C25526827F1200F4EAE5 /* TextFieldElement+IBANTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = B646C25326827EE500F4EAE5 /* TextFieldElement+IBANTest.swift */; };
B64763B922FE1AF700C01BC0 /* STPSetupIntentLastSetupErrorTest.m in Sources */ = {isa = PBXBuildFile; fileRef = B64763B822FE1AF700C01BC0 /* STPSetupIntentLastSetupErrorTest.m */; };
B648F38F25E45A780009FB36 /* PaymentOption+Images.swift in Sources */ = {isa = PBXBuildFile; fileRef = B648F38E25E45A770009FB36 /* PaymentOption+Images.swift */; };
B64A1B82266AE97B0020A13C /* SectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B64A1B81266AE97B0020A13C /* SectionView.swift */; };
@ -531,12 +533,14 @@
B66B39B6223045EF006D1CAD /* CardPaymentMethod.json in Resources */ = {isa = PBXBuildFile; fileRef = B66B39B5223045EF006D1CAD /* CardPaymentMethod.json */; };
B66D5024222F5A27004A9210 /* STPPaymentMethodThreeDSecureUsageTest.m in Sources */ = {isa = PBXBuildFile; fileRef = B66D5023222F5A27004A9210 /* STPPaymentMethodThreeDSecureUsageTest.m */; };
B66D5027222F8605004A9210 /* STPPaymentMethodCardChecksTest.m in Sources */ = {isa = PBXBuildFile; fileRef = B66D5026222F8605004A9210 /* STPPaymentMethodCardChecksTest.m */; };
B66F0C98267070530097C2E8 /* TextFieldElement+Factory.swift in Sources */ = {isa = PBXBuildFile; fileRef = B66F0C97267070530097C2E8 /* TextFieldElement+Factory.swift */; };
B66F0C98267070530097C2E8 /* TextFieldElement+AddressFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = B66F0C97267070530097C2E8 /* TextFieldElement+AddressFactory.swift */; };
B66F0C9B267071790097C2E8 /* Element.swift in Sources */ = {isa = PBXBuildFile; fileRef = B66F0C9A267071790097C2E8 /* Element.swift */; };
B66F0C9D2670723D0097C2E8 /* ElementValidation.swift in Sources */ = {isa = PBXBuildFile; fileRef = B66F0C9C2670723D0097C2E8 /* ElementValidation.swift */; };
B66F0C9F267142E40097C2E8 /* TextFieldElementConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = B66F0C9E267142E40097C2E8 /* TextFieldElementConfiguration.swift */; };
B66F0CA1267143200097C2E8 /* TextFieldValidationError.swift in Sources */ = {isa = PBXBuildFile; fileRef = B66F0CA0267143200097C2E8 /* TextFieldValidationError.swift */; };
B66F0CA426717B8C0097C2E8 /* FormElement+Factory.swift in Sources */ = {isa = PBXBuildFile; fileRef = B66F0CA326717B8C0097C2E8 /* FormElement+Factory.swift */; };
B66FA0B2267D6F03008D7F1D /* icon-pm-sepa_dark@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = B66FA0B0267D6F03008D7F1D /* icon-pm-sepa_dark@3x.png */; };
B66FA0B3267D6F03008D7F1D /* icon-pm-sepa@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = B66FA0B1267D6F03008D7F1D /* icon-pm-sepa@3x.png */; };
B66FA0BA267EA6C1008D7F1D /* icon-pm-sofort_dark.png in Resources */ = {isa = PBXBuildFile; fileRef = B66FA0B8267EA6C1008D7F1D /* icon-pm-sofort_dark.png */; };
B66FA0BB267EA6C1008D7F1D /* icon-pm-sofort.png in Resources */ = {isa = PBXBuildFile; fileRef = B66FA0B9267EA6C1008D7F1D /* icon-pm-sofort.png */; };
B66FA0BE267EE204008D7F1D /* FormElementTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = B66FA0BC267EE1DB008D7F1D /* FormElementTest.swift */; };
@ -1329,6 +1333,8 @@
B63E42782231F8FE007B5B95 /* STPPaymentMethodParamsTest.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = STPPaymentMethodParamsTest.m; sourceTree = "<group>"; };
B640DB1922C69C01003C8810 /* STPSetupIntentFunctionalTest.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = STPSetupIntentFunctionalTest.m; sourceTree = "<group>"; };
B643470323173E5000754F11 /* WeChatPaySource.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = WeChatPaySource.json; sourceTree = "<group>"; };
B646C2512682731000F4EAE5 /* TextFieldElement+IBAN.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "TextFieldElement+IBAN.swift"; sourceTree = "<group>"; };
B646C25326827EE500F4EAE5 /* TextFieldElement+IBANTest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "TextFieldElement+IBANTest.swift"; sourceTree = "<group>"; };
B64763B822FE1AF700C01BC0 /* STPSetupIntentLastSetupErrorTest.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = STPSetupIntentLastSetupErrorTest.m; sourceTree = "<group>"; };
B648F38E25E45A770009FB36 /* PaymentOption+Images.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PaymentOption+Images.swift"; sourceTree = "<group>"; };
B64A1B81266AE97B0020A13C /* SectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SectionView.swift; sourceTree = "<group>"; };
@ -1347,12 +1353,14 @@
B66B39B5223045EF006D1CAD /* CardPaymentMethod.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = CardPaymentMethod.json; sourceTree = "<group>"; };
B66D5023222F5A27004A9210 /* STPPaymentMethodThreeDSecureUsageTest.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = STPPaymentMethodThreeDSecureUsageTest.m; sourceTree = "<group>"; };
B66D5026222F8605004A9210 /* STPPaymentMethodCardChecksTest.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = STPPaymentMethodCardChecksTest.m; sourceTree = "<group>"; };
B66F0C97267070530097C2E8 /* TextFieldElement+Factory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TextFieldElement+Factory.swift"; sourceTree = "<group>"; };
B66F0C97267070530097C2E8 /* TextFieldElement+AddressFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TextFieldElement+AddressFactory.swift"; sourceTree = "<group>"; };
B66F0C9A267071790097C2E8 /* Element.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Element.swift; sourceTree = "<group>"; };
B66F0C9C2670723D0097C2E8 /* ElementValidation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ElementValidation.swift; sourceTree = "<group>"; };
B66F0C9E267142E40097C2E8 /* TextFieldElementConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextFieldElementConfiguration.swift; sourceTree = "<group>"; };
B66F0CA0267143200097C2E8 /* TextFieldValidationError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextFieldValidationError.swift; sourceTree = "<group>"; };
B66F0CA326717B8C0097C2E8 /* FormElement+Factory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FormElement+Factory.swift"; sourceTree = "<group>"; };
B66FA0B0267D6F03008D7F1D /* icon-pm-sepa_dark@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "icon-pm-sepa_dark@3x.png"; sourceTree = "<group>"; };
B66FA0B1267D6F03008D7F1D /* icon-pm-sepa@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "icon-pm-sepa@3x.png"; sourceTree = "<group>"; };
B66FA0B8267EA6C1008D7F1D /* icon-pm-sofort_dark.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "icon-pm-sofort_dark.png"; sourceTree = "<group>"; };
B66FA0B9267EA6C1008D7F1D /* icon-pm-sofort.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "icon-pm-sofort.png"; sourceTree = "<group>"; };
B66FA0BC267EE1DB008D7F1D /* FormElementTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FormElementTest.swift; sourceTree = "<group>"; };
@ -1707,13 +1715,15 @@
3128856D25ED9B1000D54C33 /* PaymentMethods */ = {
isa = PBXGroup;
children = (
B633B4B226784B1C0072CA65 /* icon-pm-bancontact_dark@3x.png */,
B633B4B126784B1C0072CA65 /* icon-pm-bancontact@3x.png */,
3128856E25ED9B1000D54C33 /* icon-pm-card@3x.png */,
B6E6C0E026556E3500445507 /* icon-pm-ideal_dark@3x.png */,
3128856F25ED9B1000D54C33 /* icon-pm-ideal@3x.png */,
B66FA0B9267EA6C1008D7F1D /* icon-pm-sofort.png */,
B66FA0B8267EA6C1008D7F1D /* icon-pm-sofort_dark.png */,
B633B4B126784B1C0072CA65 /* icon-pm-bancontact@3x.png */,
B633B4B226784B1C0072CA65 /* icon-pm-bancontact_dark@3x.png */,
B66FA0B0267D6F03008D7F1D /* icon-pm-sepa_dark@3x.png */,
B66FA0B1267D6F03008D7F1D /* icon-pm-sepa@3x.png */,
);
path = PaymentMethods;
sourceTree = "<group>";
@ -2354,7 +2364,8 @@
isa = PBXGroup;
children = (
B6C3C2BE266ADCBE0089BBF5 /* TextFieldElement.swift */,
B66F0C97267070530097C2E8 /* TextFieldElement+Factory.swift */,
B646C2512682731000F4EAE5 /* TextFieldElement+IBAN.swift */,
B66F0C97267070530097C2E8 /* TextFieldElement+AddressFactory.swift */,
B66F0C9E267142E40097C2E8 /* TextFieldElementConfiguration.swift */,
B66F0CA0267143200097C2E8 /* TextFieldValidationError.swift */,
B67495DD26697CFB00BBB155 /* TextFieldView.swift */,
@ -2765,6 +2776,7 @@
04A4C3931C4F276100B3B290 /* STPUIVCStripeParentViewControllerTests.m */,
3111C3F8252BC79D00207E32 /* StripeErrorTest.swift */,
B6ABC21426780CB000E5EC29 /* TextFieldElement+FactoryTest.swift */,
B646C25326827EE500F4EAE5 /* TextFieldElement+IBANTest.swift */,
3111C3C7252BC79500207E32 /* UIImage+StripeTests.swift */,
F1122A7D1DFB84E000A8B1AF /* UINavigationBar+StripeTest.m */,
E61BEEAB265F6B5B0002FA4F /* URLEncoderTest.swift */,
@ -3037,6 +3049,7 @@
3180E10C2592BB1800CE3D7E /* stp_bank_fpx_affin_bank@3x.png in Resources */,
3180E0B62592B98900CE3D7E /* stp_card_unknown@3x.png in Resources */,
3180E1122592BB1800CE3D7E /* stp_bank_fpx_maybank2e@3x.png in Resources */,
B66FA0B3267D6F03008D7F1D /* icon-pm-sepa@3x.png in Resources */,
3180E1002592BB1800CE3D7E /* stp_bank_fpx_ocbc@3x.png in Resources */,
3128858D25ED9B1000D54C33 /* card_unknown_icon@3x.png in Resources */,
3180E0E92592BB1100CE3D7E /* banksa@3x.png in Resources */,
@ -3071,6 +3084,7 @@
3180E0E02592BB1100CE3D7E /* anz@3x.png in Resources */,
3128858725ED9B1000D54C33 /* card_jcb@3x.png in Resources */,
3180E0B82592B98900CE3D7E /* stp_card_amex_template@3x.png in Resources */,
B66FA0B2267D6F03008D7F1D /* icon-pm-sepa_dark@3x.png in Resources */,
3180E10E2592BB1800CE3D7E /* stp_bank_fpx_kfh@3x.png in Resources */,
3180E0E62592BB1100CE3D7E /* boq@3x.png in Resources */,
F35E2DB2267ABA6700BE074B /* clearpay_mark@3x.png in Resources */,
@ -3197,6 +3211,7 @@
36E283F8254A35210028C186 /* STPCardCVCInputTextFieldValidatorTests.swift in Sources */,
36D7A91E253111E7009F2978 /* STPFloatingPlaceholderTextFieldSnapshotTests.swift in Sources */,
B66FA0BE267EE204008D7F1D /* FormElementTest.swift in Sources */,
B646C25526827F1200F4EAE5 /* TextFieldElement+IBANTest.swift in Sources */,
B63E42792231F8FE007B5B95 /* STPPaymentMethodParamsTest.m in Sources */,
3111C5DF252CC5C700207E32 /* STPPaymentMethodTest.swift in Sources */,
3111C5F7252D1BE600207E32 /* STPSetupIntentConfirmParamsTest.swift in Sources */,
@ -3523,7 +3538,7 @@
368E1F5B254CB29D00150A2D /* STPPostalCodeInputTextField.swift in Sources */,
B68A9E3A257EE77100E904B5 /* PaymentSheetError.swift in Sources */,
31D4D67A2512E7B200809066 /* UINavigationBar+Stripe_Theme.swift in Sources */,
B66F0C98267070530097C2E8 /* TextFieldElement+Factory.swift in Sources */,
B66F0C98267070530097C2E8 /* TextFieldElement+AddressFactory.swift in Sources */,
3111C3422526C71A00207E32 /* STPEphemeralKeyManager.swift in Sources */,
3111BE8925130A3600288D28 /* STPAUBECSDebitFormView.swift in Sources */,
31D4D65F2512B1EC00809066 /* STPBSBNumberValidator.swift in Sources */,
@ -3638,6 +3653,7 @@
B6D9CEAB2514809B00AAD424 /* STPPaymentMethodCardNetworks.swift in Sources */,
B67243502524F689002E1AAF /* STPSourceParams.swift in Sources */,
B609B0C5255C8583002AC0A7 /* UIFont+Stripe.swift in Sources */,
B646C2522682731000F4EAE5 /* TextFieldElement+IBAN.swift in Sources */,
3176C227251A6ABF00300ADE /* STPThreeDSButtonCustomization.swift in Sources */,
36E295BF25229A6800CF5C06 /* STPPaymentIntent.swift in Sources */,
3176C22F251A6AFD00300ADE /* STPThreeDSFooterCustomization.swift in Sources */,

View File

@ -164,6 +164,8 @@ class AddPaymentMethodViewController: UIViewController {
return FormElement.makeAlipay()
case .sofort:
return FormElement.makeSofort(merchantDisplayName: merchantDisplayName)
case .SEPADebit:
return FormElement.makeSepa(merchantDisplayName: merchantDisplayName)
default:
fatalError()
}

View File

@ -88,6 +88,7 @@ class DropdownTextField: UITextField {
target: self,
action: #selector(didTapDone)
)
doneButton.accessibilityLabel = UIButton.doneButtonTitle
toolbar.setItems([doneButton], animated: false)
toolbar.sizeToFit()
toolbar.setContentHuggingPriority(.defaultLow, for: .horizontal)

View File

@ -36,6 +36,14 @@ protocol Element: AnyObject {
var view: UIView { get }
}
// MARK: Element default implementation
extension Element {
var validationState: ElementValidationState {
return .valid
}
}
// MARK: - ElementDelegate
/**

View File

@ -100,4 +100,25 @@ extension FormElement {
return params
}
}
static func makeSepa(merchantDisplayName: String) -> FormElement {
let iban = TextFieldElement.makeIBAN()
let name = TextFieldElement.Address.makeName()
let email = TextFieldElement.Address.makeEmail()
let mandate = StaticElement(view: SepaMandateView(merchantDisplayName: merchantDisplayName))
return FormElement(elements: [
SectionElement(elements: [name]),
SectionElement(elements: [email]),
SectionElement(elements: [iban]),
CheckboxElement(didToggle: { selected in
email.isOptional = !selected
mandate.isHidden = !selected
}),
mandate,
]) { params in
params.paymentMethodParams.type = .SEPADebit
params.paymentMethodParams.sepaDebit = STPPaymentMethodSEPADebitParams()
return params
}
}
}

View File

@ -23,6 +23,7 @@ enum Image: String, CaseIterable {
case pm_type_ideal = "icon-pm-ideal"
case pm_type_bancontact = "icon-pm-bancontact"
case pm_type_sofort = "icon-pm-sofort"
case pm_type_sepa = "icon-pm-sepa"
// Icons/symbols
case icon_checkmark = "icon_checkmark"

View File

@ -10,6 +10,7 @@ import Foundation
extension CharacterSet {
static let stp_asciiDigit = CharacterSet(charactersIn: "0123456789")
static let stp_asciiLetters = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
static let stp_invertedAsciiDigit = stp_asciiDigit.inverted
static let stp_postalCode = CharacterSet(
charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789- ")

View File

@ -87,4 +87,18 @@ extension NSError {
"There was an unexpected error -- try again in a few seconds",
"Unexpected error, such as a 500 from Stripe or a JSON parse error")
}
static var stp_invalidOwnerName: String {
return STPLocalizedString(
"Your name is invalid.",
"Error when customer's name is invalid"
)
}
static var stp_invalidBankAccountIban: String {
return STPLocalizedString(
"The IBAN you entered is invalid.",
"An error message displayed when the customer's iban is invalid."
)
}
}

View File

@ -15,7 +15,7 @@ protocol PaymentMethodTypeCollectionViewDelegate: AnyObject {
// MARK: - Constants
private let cellSize: CGSize = CGSize(width: 100, height: 52)
private let paymentMethodLogoSize: CGSize = CGSize(width: 16, height: 12)
private let paymentMethodLogoSize: CGSize = CGSize(width: UIView.noIntrinsicMetric, height: 12)
/// A carousel of Payment Method types e.g. [Card, Alipay, SEPA Debit]
class PaymentMethodTypeCollectionView: UICollectionView {
@ -127,6 +127,9 @@ extension PaymentMethodTypeCollectionView {
top: 15, left: 24, bottom: 15, right: 24)
return shadowRoundedRectangle
}()
lazy var paymentMethodLogoWidthConstraint: NSLayoutConstraint = {
paymentMethodLogo.widthAnchor.constraint(equalToConstant: 0)
}()
// MARK: - UICollectionViewCell
@ -147,10 +150,9 @@ extension PaymentMethodTypeCollectionView {
equalTo: shadowRoundedRectangle.topAnchor, constant: 12),
paymentMethodLogo.leftAnchor.constraint(
equalTo: shadowRoundedRectangle.leftAnchor, constant: 12),
paymentMethodLogo.widthAnchor.constraint(
equalToConstant: paymentMethodLogoSize.width),
paymentMethodLogo.heightAnchor.constraint(
equalToConstant: paymentMethodLogoSize.height),
paymentMethodLogoWidthConstraint,
label.topAnchor.constraint(equalTo: paymentMethodLogo.bottomAnchor, constant: 4),
label.bottomAnchor.constraint(
@ -206,7 +208,10 @@ extension PaymentMethodTypeCollectionView {
// MARK: - Private Methods
private func update() {
label.text = paymentMethodType.displayName
paymentMethodLogo.image = paymentMethodType.makeImage()
let image = paymentMethodType.makeImage()
paymentMethodLogo.image = image
paymentMethodLogoWidthConstraint.constant = paymentMethodLogoSize.height / image.size.height * image.size.width
setNeedsLayout()
if isSelected {
// Set shadow

View File

@ -96,6 +96,8 @@ extension STPPaymentMethodType {
return .pm_type_bancontact
case .sofort:
return .pm_type_sofort
case .SEPADebit:
return .pm_type_sepa
default:
return nil
}

View File

@ -197,14 +197,17 @@ extension PaymentSheet {
}
// List the Customer's saved PaymentMethods
let savedPaymentMethodTypes: [STPPaymentMethodType] = [.card, .SEPADebit] // hardcoded for now
if let customerID = customerID, let ephemeralKey = ephemeralKey {
apiClient.listPaymentMethods(forCustomer: customerID, using: ephemeralKey) {
paymentMethods, error in
apiClient.listPaymentMethods(
forCustomer: customerID,
using: ephemeralKey,
types: savedPaymentMethodTypes
) { paymentMethods, error in
guard let paymentMethods = paymentMethods, error == nil else {
let error =
error
?? PaymentSheetError.unknown(
debugDescription: "Failed to retrieve PaymentMethods for the customer")
let error = error ?? PaymentSheetError.unknown(
debugDescription: "Failed to retrieve PaymentMethods for the customer"
)
paymentMethodsPromise.reject(with: error)
return
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 933 B

View File

@ -173,6 +173,9 @@
/* Payment Method type brand name. */
"GrabPay" = "GrabPay";
/* IBAN placeholder */
"IBAN" = "IBAN";
/* Source type brand name */
"iDEAL" = "iDEAL";
@ -321,6 +324,15 @@
/* Error string displayed to user when they enter in an invalid BSB number. */
"The BSB you entered is invalid." = "The BSB you entered is invalid.";
/* An error message. */
"The IBAN you entered is incomplete." = "The IBAN you entered is incomplete.";
/* An error message. */
"The IBAN you entered is invalid, \"%@\" is not a supported country code." = "The IBAN you entered is invalid, \"%@\" is not a supported country code.";
/* An error message displayed when the customer's iban is invalid. */
"The IBAN you entered is invalid." = "The IBAN you entered is invalid.";
/* Error when there is a problem processing the credit card */
"There was an error processing your card -- try again in a few seconds" = "There was an error processing your card -- try again in a few seconds";
@ -400,6 +412,12 @@
/* Error message when email is invalid */
"Your email is invalid." = "Your email is invalid.";
/* An error message. */
"Your IBAN should start with a two-letter country code." = "Your IBAN should start with a two-letter country code.";
/* Error when customer's name is invalid */
"Your name is invalid." = "Your name is invalid.";
/* Error message for when postal code in form is incomplete */
"Your postal code is incomplete." = "Your postal code is incomplete.";

View File

@ -982,28 +982,53 @@ extension STPAPIClient {
using ephemeralKey: STPEphemeralKey, completion: @escaping STPPaymentMethodsCompletionBlock
) {
listPaymentMethods(
forCustomer: ephemeralKey.customerID ?? "", using: ephemeralKey.secret,
completion: completion)
forCustomer: ephemeralKey.customerID ?? "",
using: ephemeralKey.secret,
completion: completion
)
}
func listPaymentMethods(
forCustomer customerID: String, using ephemeralKeySecret: String,
forCustomer customerID: String,
using ephemeralKeySecret: String,
types: [STPPaymentMethodType] = [.card],
completion: @escaping STPPaymentMethodsCompletionBlock
) {
let header = authorizationHeader(using: ephemeralKeySecret)
// Unfortunately, this API only supports fetching saved pms for one type at a time
var shared_allPaymentMethods = [STPPaymentMethod]()
var shared_lastError: Error? = nil
let group = DispatchGroup()
for type in types {
group.enter()
let params = [
"customer": customerID,
"type": STPPaymentMethod.string(from: .card),
"type": STPPaymentMethod.string(from: type)
]
APIRequest<STPPaymentMethodListDeserializer>.getWith(
self,
endpoint: APIEndpointPaymentMethods,
additionalHeaders: authorizationHeader(using: ephemeralKeySecret),
additionalHeaders: header,
parameters: params as [String: Any]
) { deserializer, _, error in
completion(deserializer?.paymentMethods, error)
DispatchQueue.global(qos: .userInteractive).async(flags: .barrier) {
// .barrier ensures we're the only thing writing to shared_ vars
if let error = error {
shared_lastError = error
}
if let paymentMethods = deserializer?.paymentMethods {
shared_allPaymentMethods.append(contentsOf: paymentMethods)
}
group.leave()
}
}
}
group.notify(queue: DispatchQueue.main) {
completion(shared_allPaymentMethods, shared_lastError)
}
}
}
// MARK: - ThreeDS2

View File

@ -26,10 +26,11 @@ import Foundation
@objc(STPEphemeralKeyDecodingError) case ephemeralKeyDecodingError = 1000
}
// MARK: userInfo keys
// MARK: - STPError
/// Top-level class for Stripe error constants.
public class STPError: NSObject {
// MARK: userInfo keys
/// All Stripe iOS errors will be under this domain.
@objc public static let stripeDomain = "com.stripe.lib"
@ -52,55 +53,6 @@ public class STPError: NSObject {
/// the value for this key contains the decline code.
/// - seealso: https://stripe.com/docs/declines/codes
@objc public static let stripeDeclineCodeKey = "com.stripe.lib:DeclineCodeKey"
/// The card number is not a valid credit card number.
@objc public static let invalidNumber = STPCardErrorCode.invalidNumber.rawValue
/// The card has an invalid expiration month.
@objc public static let invalidExpMonth = STPCardErrorCode.invalidExpMonth.rawValue
/// The card has an invalid expiration year.
@objc public static let invalidExpYear = STPCardErrorCode.invalidExpYear.rawValue
/// The card has an invalid CVC.
@objc public static let invalidCVC = STPCardErrorCode.invalidCVC.rawValue
/// The card number is incorrect.
@objc public static let incorrectNumber = STPCardErrorCode.incorrectNumber.rawValue
/// The card is expired.
@objc public static let expiredCard = STPCardErrorCode.expiredCard.rawValue
/// The card was declined.
@objc public static let cardDeclined = STPCardErrorCode.cardDeclined.rawValue
/// An error occured while processing this card.
@objc public static let processingError = STPCardErrorCode.processingError.rawValue
/// The card has an incorrect CVC.
@objc public static let incorrectCVC = STPCardErrorCode.incorrectCVC.rawValue
/// The postal code is incorrect.
@objc public static let incorrectZip = STPCardErrorCode.incorrectZip.rawValue
}
// MARK: STPCardErrorCodeKeys
/// Possible string values you may receive when there was an error tokenizing
/// a card. These values will come back in the error `userInfo` dictionary
/// under the `STPCardErrorCodeKey` key.
public enum STPCardErrorCode: String {
/// The card number is not a valid credit card number.
case invalidNumber = "com.stripe.lib:InvalidNumber"
/// The card has an invalid expiration month.
case invalidExpMonth = "com.stripe.lib:InvalidExpiryMonth"
/// The card has an invalid expiration year.
case invalidExpYear = "com.stripe.lib:InvalidExpiryYear"
/// The card has an invalid CVC.
case invalidCVC = "com.stripe.lib:InvalidCVC"
/// The card number is incorrect.
case incorrectNumber = "com.stripe.lib:IncorrectNumber"
/// The card is expired.
case expiredCard = "com.stripe.lib:ExpiredCard"
/// The card was declined.
case cardDeclined = "com.stripe.lib:CardDeclined"
/// The card has an incorrect CVC.
case incorrectCVC = "com.stripe.lib:IncorrectCVC"
/// An error occured while processing this card.
case processingError = "com.stripe.lib:ProcessingError"
/// The postal code is incorrect.
case incorrectZip = "com.stripe.lib:IncorrectZip"
}
/// NSError extensions for creating error objects from Stripe API responses.
@ -185,6 +137,12 @@ public enum STPCardErrorCode: String {
"incorrect_zip": [
"code": STPCardErrorCode.incorrectZip.rawValue
],
"invalid_owner_name": [
"message": self.stp_invalidOwnerName,
],
"invalid_bank_account_iban": [
"message": self.stp_invalidBankAccountIban,
],
]
let codeMapEntry = codeMap[stripeErrorCode ?? ""]
let cardErrorCode = codeMapEntry?["code"]
@ -223,3 +181,54 @@ public enum STPCardErrorCode: String {
stp_error(fromStripeResponse: jsonDictionary, httpResponse: nil)
}
}
// MARK: STPCardErrorCodeKeys -
/// Possible string values you may receive when there was an error tokenizing
/// a card. These values will come back in the error `userInfo` dictionary
/// under the `STPCardErrorCodeKey` key.
public enum STPCardErrorCode: String {
/// The card number is not a valid credit card number.
case invalidNumber = "com.stripe.lib:InvalidNumber"
/// The card has an invalid expiration month.
case invalidExpMonth = "com.stripe.lib:InvalidExpiryMonth"
/// The card has an invalid expiration year.
case invalidExpYear = "com.stripe.lib:InvalidExpiryYear"
/// The card has an invalid CVC.
case invalidCVC = "com.stripe.lib:InvalidCVC"
/// The card number is incorrect.
case incorrectNumber = "com.stripe.lib:IncorrectNumber"
/// The card is expired.
case expiredCard = "com.stripe.lib:ExpiredCard"
/// The card was declined.
case cardDeclined = "com.stripe.lib:CardDeclined"
/// The card has an incorrect CVC.
case incorrectCVC = "com.stripe.lib:IncorrectCVC"
/// An error occured while processing this card.
case processingError = "com.stripe.lib:ProcessingError"
/// The postal code is incorrect.
case incorrectZip = "com.stripe.lib:IncorrectZip"
}
@objc extension STPError {
/// The card number is not a valid credit card number.
@objc public static let invalidNumber = STPCardErrorCode.invalidNumber.rawValue
/// The card has an invalid expiration month.
@objc public static let invalidExpMonth = STPCardErrorCode.invalidExpMonth.rawValue
/// The card has an invalid expiration year.
@objc public static let invalidExpYear = STPCardErrorCode.invalidExpYear.rawValue
/// The card has an invalid CVC.
@objc public static let invalidCVC = STPCardErrorCode.invalidCVC.rawValue
/// The card number is incorrect.
@objc public static let incorrectNumber = STPCardErrorCode.incorrectNumber.rawValue
/// The card is expired.
@objc public static let expiredCard = STPCardErrorCode.expiredCard.rawValue
/// The card was declined.
@objc public static let cardDeclined = STPCardErrorCode.cardDeclined.rawValue
/// An error occured while processing this card.
@objc public static let processingError = STPCardErrorCode.processingError.rawValue
/// The card has an incorrect CVC.
@objc public static let incorrectCVC = STPCardErrorCode.incorrectCVC.rawValue
/// The postal code is incorrect.
@objc public static let incorrectZip = STPCardErrorCode.incorrectZip.rawValue
}

View File

@ -336,6 +336,8 @@ extension STPPaymentMethod {
switch type {
case .card:
return "••••\(card?.last4 ?? "")"
case .SEPADebit:
return "••••\(sepaDebit?.last4 ?? "")"
default:
return label
}

View File

@ -1,5 +1,5 @@
//
// TextFieldElement+Factory.swift
// TextFieldElement+AddressFactory.swift
// StripeiOS
//
// Created by Yuki Tokuhiro on 6/8/21.
@ -97,6 +97,10 @@ extension TextFieldElement {
params.paymentMethodParams.billingDetails = billingDetails
return params
}
func makeKeyboardProperties(for text: String) -> TextFieldElement.ViewModel.KeyboardProperties {
return .init(type: .emailAddress, autocapitalization: .none)
}
}
static func makeEmail() -> TextFieldElement {

View File

@ -0,0 +1,186 @@
//
// TextFieldElement+IBAN.swift
// StripeiOS
//
// Created by Yuki Tokuhiro on 5/23/21.
// Copyright © 2021 Stripe, Inc. All rights reserved.
//
import Foundation
import UIKit
extension TextFieldElement {
static func makeIBAN() -> TextFieldElement {
return TextFieldElement(configuration: IBANConfiguration())
}
// MARK: - IBANError
enum IBANError: TextFieldValidationError, Equatable {
case incomplete
case shouldStartWithCountryCode
case invalidCountryCode(countryCode: String)
/// A catch-all for things like incorrect length, invalid characters, bad checksum.
case invalidFormat
var localizedDescription: String {
switch self {
case .incomplete:
return STPLocalizedString("The IBAN you entered is incomplete.", "An error message.")
case .shouldStartWithCountryCode:
return STPLocalizedString("Your IBAN should start with a two-letter country code.", "An error message.")
case .invalidCountryCode(let countryCode):
let localized = STPLocalizedString("The IBAN you entered is invalid, \"%@\" is not a supported country code.", "An error message.")
return String(format: localized, countryCode)
case .invalidFormat:
return NSError.stp_invalidBankAccountIban
}
}
func shouldDisplay(isUserEditing: Bool) -> Bool {
switch self {
case .incomplete, .invalidFormat:
return !isUserEditing
case .shouldStartWithCountryCode, .invalidCountryCode:
return true
}
}
}
// MARK: IBANConfiguration
/**
A text field configuration for an IBAN, or International Bank Account Number, as defined in ISO 13616-1.
- Seealso: https://en.wikipedia.org/wiki/International_Bank_Account_Number
*/
struct IBANConfiguration: TextFieldElementConfiguration {
let placeholder: String = STPLocalizedString("IBAN", "IBAN placeholder")
let maxLength: Int = 34
/// Ensure it's at least the minimum size assumed by the algorith. Note: ideally, this length depends on the country.
let minLength: Int = 8
let disallowedCharacters: CharacterSet = CharacterSet.stp_asciiLetters
.union(CharacterSet.stp_asciiDigit)
.inverted
func updateParams(for text: String, params: IntentConfirmParams) -> IntentConfirmParams? {
let sepa = params.paymentMethodParams.sepaDebit ?? STPPaymentMethodSEPADebitParams()
sepa.iban = text
params.paymentMethodParams.sepaDebit = sepa
return params
}
func makeDisplayText(for text: String) -> NSAttributedString {
let firstTwoCapitalized = text.prefix(2).uppercased() + text.dropFirst(2)
let attributed = NSMutableAttributedString(string: firstTwoCapitalized, attributes: [.kern: 0])
// Put a space between every 4th character
for i in stride(from: 3, to: attributed.length, by: 4) {
attributed.addAttribute(.kern, value: 5, range: NSRange(location: i, length: 1))
}
return attributed
}
/**
The IBAN structure is defined in ISO 13616-1 and consists of a two-letter ISO 3166-1 country code,
followed by two check digits and up to thirty alphanumeric characters for a BBAN (Basic Bank Account Number)
which has a fixed length per country and, included within it, a bank identifier with a fixed position and a fixed length per country.
The check digits are calculated based on the scheme defined in ISO/IEC 7064 (MOD97-10).
We perform the algorithm as described in https://en.wikipedia.org/wiki/International_Bank_Account_Number#Validating_the_IBAN
*/
func validate(text: String, isOptional: Bool) -> ElementValidationState {
let iBAN = text.uppercased()
guard !iBAN.isEmpty else {
return isOptional ? .valid : .invalid(Error.empty)
}
// Validate starts with a two-letter country code
let countryValidationResult = Self.validateCountryCode(iBAN)
guard case .valid = countryValidationResult else {
return countryValidationResult
}
// Validate that the total IBAN length is correct
guard iBAN.count > minLength else {
return .invalid(IBANError.incomplete)
}
// Validate it's up to 34 alphanumeric characters long
guard
iBAN.count <= maxLength,
iBAN.allSatisfy({ $0.isASCII && ($0.isLetter || $0.isNumber)}) else {
return .invalid(IBANError.invalidFormat)
}
// Move the four initial characters to the end of the string
// e.g. "GB1234" -> "34GB12"
let reorderedIBAN = iBAN.dropFirst(4) + iBAN.prefix(4)
// Replace each letter in the string with two digits, thereby expanding the string, where A = 10, B = 11, ..., Z = 35
// e.g., "GB82" -> "161182"
let oneBigNumber = Self.transformToASCIIDigits(String(reorderedIBAN))
// Interpret the string as a decimal integer and compute the remainder of that number on division by 97
// If the IBAN is valid, the remainder equals 1.
// e.g., "00001011" -> Int(1011)
guard Self.mod97(oneBigNumber) == 1 else {
return .invalid(IBANError.invalidFormat)
}
return .valid
}
// MARK: - Helper methods
/// Validates that the iBAN begins with a two-letter country code
static func validateCountryCode(_ iBAN: String) -> ElementValidationState {
let countryCode = String(iBAN.prefix(2))
guard countryCode.allSatisfy({ $0.isASCII && $0.isLetter }) else {
// The user put in numbers or something weird; let them know the iban should start with a country code
return .invalid(IBANError.shouldStartWithCountryCode)
}
guard countryCode.count == 2 else {
return .invalid(IBANError.incomplete)
}
// Validate that the country code exists
guard NSLocale.isoCountryCodes.contains(countryCode) else {
return .invalid(IBANError.invalidCountryCode(countryCode: countryCode))
}
return .valid
}
/// Interprets `bigNumber` as a decimal integer and compute the remainder of that number on division by 97
/// - Note: Does not handle empty strings
static func mod97(_ bigNumber: String) -> Int? {
return bigNumber.reduce(0) { (previousMod, char) in
guard let previousMod = previousMod,
let value = Int(String(char)) else {
return nil
}
let factor = value < 10 ? 10 : 100
return (factor * previousMod + value) % 97
}
}
/// Replaces each letter in the string with two digits, thereby expanding the string, where A = 10, B = 11, ..., Z = 35
/// e.g., "GB82" -> "161182"
/// - Note: Assumes the string is alphanumeric
static func transformToASCIIDigits(_ string: String) -> String {
return string.reduce("") { result, character in
if character.isLetter {
guard let asciiValue = character.asciiValue else {
return ""
}
let digit = Int(asciiValue) - asciiValueOfA + 10
return result + String(digit)
} else if character.isNumber {
return result + String(character)
} else {
return ""
}
}
}
}
}
fileprivate let asciiValueOfA: Int = Int(Character("A").asciiValue!)

View File

@ -41,10 +41,10 @@ final class TextFieldElement {
}
var placeholder: String
var text: String = ""
var attributedText: NSAttributedString = NSAttributedString()
var text: String
var attributedText: NSAttributedString
var keyboardProperties: KeyboardProperties
var validationState: ElementValidationState = .valid
var validationState: ElementValidationState
var isOptional: Bool
}
@ -85,7 +85,6 @@ extension TextFieldElement: Element {
var validationState: ElementValidationState {
return configuration.validate(text: text, isOptional: isOptional)
}
}
// MARK: - TextFieldViewDelegate
@ -93,7 +92,10 @@ extension TextFieldElement: Element {
extension TextFieldElement: TextFieldViewDelegate {
func didUpdate(view: TextFieldView) {
// Update our state
text = view.text.stp_stringByRemovingCharacters(from: configuration.disallowedCharacters)
text = String(
view.text.stp_stringByRemovingCharacters(from: configuration.disallowedCharacters)
.prefix(configuration.maxLength)
)
isEditing = view.isEditing
// Glue: Update the view and our delegate

View File

@ -16,6 +16,8 @@ import UIKit
*/
protocol TextFieldElementConfiguration {
var placeholder: String { get }
var disallowedCharacters: CharacterSet { get }
var maxLength: Int { get }
/**
Validate the text.
@ -38,8 +40,6 @@ protocol TextFieldElementConfiguration {
- Returns: The passed in `params` object mutated according to the text field's text. You can assume the text is valid.
*/
func updateParams(for text: String, params: IntentConfirmParams) -> IntentConfirmParams?
var disallowedCharacters: CharacterSet { get }
}
// MARK: - Default implementation
@ -56,4 +56,8 @@ extension TextFieldElementConfiguration {
var disallowedCharacters: CharacterSet {
return .newlines
}
var maxLength: Int {
return Int.max // i.e., there is no maximum length
}
}

View File

@ -116,10 +116,8 @@ class TextFieldView: UIView {
}
}()
if textField.attributedText?.string != viewModel.attributedText.string {
// Updating the text unnecessarily causes it to jump
textField.attributedText = viewModel.attributedText
}
textField.font = Constants.textFieldFont
textField.textColor = {
switch (isUserInteractionEnabled, viewModel.validationState) {
case (false, _):

View File

@ -46,6 +46,7 @@ enum PaymentSheetUI {
label.font = .preferredFont(forTextStyle: .footnote)
label.textColor = .systemRed
label.numberOfLines = 0
label.setContentHuggingPriority(.required, for: .vertical)
return label
}
@ -57,6 +58,16 @@ enum PaymentSheetUI {
header.accessibilityTraits = [.header]
return header
}
static func makeInputLabel() -> UILabel {
let label = UILabel()
let fontMetrics = UIFontMetrics(forTextStyle: .body)
let font = UIFont.systemFont(ofSize: 13, weight: .semibold)
label.font = fontMetrics.scaledFont(for: font)
label.textColor = CompatibleColor.secondaryLabel
label.accessibilityTraits = [.header]
return label
}
}
extension PKPaymentButtonStyle {

View File

@ -1,5 +1,5 @@
//
// TextFieldElement+FactoryTest.swift
// TextFieldElement+AddressTest.swift
// StripeiOS Tests
//
// Created by Yuki Tokuhiro on 6/14/21.
@ -16,7 +16,7 @@ extension TextFieldElementConfiguration {
}
}
class TextFieldElementFactoryTest: XCTestCase {
class TextFieldElementAddressTest: XCTestCase {
// MARK: - Name
func testNameConfigurationValidation() {

View File

@ -0,0 +1,106 @@
//
// TextFieldElement+IBANTest.swift
// StripeiOS Tests
//
// Created by Yuki Tokuhiro on 5/23/21.
// Copyright © 2021 Stripe, Inc. All rights reserved.
//
import XCTest
@testable import Stripe
class TextFieldElementIBANTest: XCTestCase {
typealias IBANError = TextFieldElement.IBANError
typealias Error = TextFieldElement.Error
func testValidation() throws {
let testcases: [String: ElementValidationState] = [
"": .invalid(Error.empty),
"G": .invalid(IBANError.incomplete),
"GB": .invalid(IBANError.incomplete),
"GB1": .invalid(IBANError.incomplete),
"GB12": .invalid(IBANError.incomplete),
"1": .invalid(IBANError.shouldStartWithCountryCode),
"12": .invalid(IBANError.shouldStartWithCountryCode),
"Z1": .invalid(IBANError.shouldStartWithCountryCode),
"🤦🏻🇺🇸": .invalid(IBANError.shouldStartWithCountryCode),
"ZZ": .invalid(IBANError.invalidCountryCode(countryCode: "ZZ")),
"GB82WEST12345698765432🇺🇸": .invalid(IBANError.invalidFormat),
"GB94BARC20201530093459": .invalid(IBANError.invalidFormat), // https://www.iban.com/testibans
"GB33BUKB20201555555555": .valid,
"GB94BARC10201530093459": .valid,
"SK6902000000001933504555": .valid,
"BG09STSA93000021741508": .valid,
"FR1420041010050500013M02606": .valid,
"AT611904300234573201": .valid,
"AT861904300235473202": .valid,
]
let config = TextFieldElement.IBANConfiguration()
for (text, expected) in testcases {
let actual = config.validate(text: text, isOptional: false)
XCTAssertTrue(
actual == expected,
"Input \"\(text)\": expected \(expected) but got \(actual)"
)
}
}
func testValidateCountryCode() {
let testcases: [String: ElementValidationState] = [
"": .invalid(IBANError.incomplete),
"A": .invalid(IBANError.incomplete),
"D": .invalid(IBANError.incomplete),
"ū": .invalid(IBANError.shouldStartWithCountryCode),
"1": .invalid(IBANError.shouldStartWithCountryCode),
".": .invalid(IBANError.shouldStartWithCountryCode),
"AT": .valid,
"DE": .valid,
]
for (test, expected) in testcases {
let actual = TextFieldElement.IBANConfiguration.validateCountryCode(test)
XCTAssertTrue(actual == expected)
}
}
func testTransformToASCIIDigits() {
let testcases: [String: String] = [
"": "",
"1234": "1234",
"GB82": "161182",
"AAAA": "10101010",
"ZZZZ": "35353535",
]
for (test, expected) in testcases {
let actual = TextFieldElement.IBANConfiguration.transformToASCIIDigits(test)
XCTAssertTrue(actual == expected)
}
}
func testMod97() {
let testcases: [String: Int?] = [
"0": 0,
"97": 0,
"96": 96,
"00001": 1,
"13985713857180375018375081735081735": 15,
"13985713857180375018375081735081720": 0,
]
for (test, expected) in testcases {
let actual = TextFieldElement.IBANConfiguration.mod97(test)
XCTAssertTrue(actual == expected)
}
for _ in 0...100 {
let test = Int.random(in: 0...Int.max)
let actual = TextFieldElement.IBANConfiguration.mod97(String(test))
XCTAssertTrue(actual == test % 97)
}
}
}