[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:
parent
88e06d8e85
commit
14596a3967
|
@ -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.
|
||||
|
|
|
@ -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 */,
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -36,6 +36,14 @@ protocol Element: AnyObject {
|
|||
var view: UIView { get }
|
||||
}
|
||||
|
||||
// MARK: Element default implementation
|
||||
|
||||
extension Element {
|
||||
var validationState: ElementValidationState {
|
||||
return .valid
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ElementDelegate
|
||||
|
||||
/**
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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- ")
|
||||
|
|
|
@ -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."
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
||||
|
@ -141,16 +144,15 @@ extension PaymentMethodTypeCollectionView {
|
|||
isAccessibilityElement = true
|
||||
contentView.addSubview(shadowRoundedRectangle)
|
||||
shadowRoundedRectangle.frame = bounds
|
||||
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
paymentMethodLogo.topAnchor.constraint(
|
||||
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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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 |
|
@ -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.";
|
||||
|
||||
|
|
|
@ -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 params = [
|
||||
"customer": customerID,
|
||||
"type": STPPaymentMethod.string(from: .card),
|
||||
]
|
||||
APIRequest<STPPaymentMethodListDeserializer>.getWith(
|
||||
self,
|
||||
endpoint: APIEndpointPaymentMethods,
|
||||
additionalHeaders: authorizationHeader(using: ephemeralKeySecret),
|
||||
parameters: params as [String: Any]
|
||||
) { deserializer, _, error in
|
||||
completion(deserializer?.paymentMethods, error)
|
||||
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: type)
|
||||
]
|
||||
APIRequest<STPPaymentMethodListDeserializer>.getWith(
|
||||
self,
|
||||
endpoint: APIEndpointPaymentMethods,
|
||||
additionalHeaders: header,
|
||||
parameters: params as [String: Any]
|
||||
) { deserializer, _, error in
|
||||
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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -336,6 +336,8 @@ extension STPPaymentMethod {
|
|||
switch type {
|
||||
case .card:
|
||||
return "••••\(card?.last4 ?? "")"
|
||||
case .SEPADebit:
|
||||
return "••••\(sepaDebit?.last4 ?? "")"
|
||||
default:
|
||||
return label
|
||||
}
|
||||
|
|
|
@ -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 {
|
|
@ -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!)
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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.attributedText = viewModel.attributedText
|
||||
textField.font = Constants.textFieldFont
|
||||
textField.textColor = {
|
||||
switch (isUserInteractionEnabled, viewModel.validationState) {
|
||||
case (false, _):
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue