Path: blob/main/Signal/util/PaymentDetailsValidity.swift
1 views
//
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import SignalServiceKit
// MARK: - Payment method field validity
/// The validity of a particular field.
enum PaymentMethodFieldValidity<Invalidity> {
/// The data could be submitted if the user added some more data, but
/// not yet. For example, "42" is a potentially valid card number.
case potentiallyValid
/// The data can be submitted with no modifications. Implies potential
/// validity. For example, "4242424242424242" is a fully valid card
/// number. The user should be allowed to submit fully valid data.
case fullyValid
/// The data cannot be submitted without deleting something. For
/// example, "42XX" is an invalid card number, and the user needs to
/// delete something to make it okay again. An error should be shown.
case invalid(Invalidity)
}
// MARK: - Credit and debit cards
extension PaymentMethodFieldValidity where Invalidity == Void {
fileprivate func combine(with other: Self) -> Self {
switch (self, other) {
case (.invalid, _), (_, .invalid):
return .invalid(())
case (.potentiallyValid, _), (_, .potentiallyValid):
return .potentiallyValid
default:
return .fullyValid
}
}
}
#if TESTABLE_BUILD
extension PaymentMethodFieldValidity: Equatable where Invalidity: Equatable {
static func ==(lhs: PaymentMethodFieldValidity<Invalidity>, rhs: PaymentMethodFieldValidity<Invalidity>) -> Bool where Invalidity: Equatable {
switch (lhs, rhs) {
case (.potentiallyValid, .potentiallyValid):
return true
case (.fullyValid, .fullyValid):
return true
case let (.invalid(invalidLHS), .invalid(invalidRHS)):
return invalidLHS == invalidRHS
default:
return false
}
}
}
#endif
enum CreditAndDebitCards {
typealias Validity = PaymentMethodFieldValidity<Void>
/// The type of the credit card as useful for Signal's purposes.
enum CardType {
case americanExpress
case unionPay
case other
var cvvCount: Int {
switch self {
case .americanExpress: return 4
case .unionPay, .other: return 3
}
}
}
/// Determine the card type from a card number.
///
/// Only returns a few types that are useful for our purposes. Not meant
/// for general use.
///
/// - Parameter ofNumber: The card number entered by the user. May be
/// incomplete or invalid.
/// - Returns: The determined card type. Again, only returns types that are
/// useful for Signal's purposes.
static func cardType(ofNumber number: String) -> CardType {
if number.starts(with: "34") || number.starts(with: "37") {
return .americanExpress
} else if number.starts(with: "62") || number.starts(with: "81") {
return .unionPay
} else {
return .other
}
}
// MARK: Card number
/// Determine the validity of a card number.
///
/// Card numbers are fully valid when all of these conditions are met:
///
/// - All characters are digits
/// - They are between 12 digits and 19 digits (inclusive) in length
/// - At least one of the following is true:
/// - It is a UnionPay card
/// - The card passes a Luhn check
///
/// Card numbers are potentially valid when all of these conditions are met:
/// - They are not fully valid (see above)
/// - All characters are digits
/// - They are 19 or fewer digits in length
/// - At least one of the following is true:
/// - They are fewer than 12 digits in length
/// - The user has focused the text input
///
/// If a card number is neither kind of valid, then it is invalid.
///
/// We need to know the focus state because it helps us determine whether
/// the user is still typing while they're in the valid length range
/// (12–19). For example, let's say I've entered "4242424242424" (13
/// digits), which is Luhn-invalid. If I'm still typing, it's potentially
/// valid—I might type another "2" and finish off my card number, making it
/// Luhn-valid. If I'm done typing, it's invalid, because it's Luhn-invalid.
/// We don't want to show errors while you're typing.
///
/// - Parameter ofNumber: The card number as entered by the user. Should
/// only contain digits.
/// - Parameter isNumberFieldFocused: Whether the user has focused the
/// number field.
/// - Returns: The validity of the card number.
static func validity(ofNumber number: String, isNumberFieldFocused: Bool) -> Validity {
guard number.count <= 19, number.isAsciiDigitsOnly else {
return .invalid(())
}
if number.count < 12 {
return .potentiallyValid
}
let isValid: Bool
switch cardType(ofNumber: number) {
case .unionPay:
isValid = true
case .americanExpress, .other:
isValid = number.isLuhnValid
}
if isValid {
return .fullyValid
}
if isNumberFieldFocused {
return .potentiallyValid
}
return .invalid(())
}
// MARK: Expiration
/// Determine the validity of an expiration date.
///
/// Expiration dates are fully valid if:
///
/// - There are 1 or 2 digits for the month, and parsing that as an integer
/// is between 1 and 12
/// - The 2-digit year is in the next 20 years
/// - If the year is the current year, the month is greater than or equal to
/// the current month
///
/// Expiration dates are partially valid if you're still typing.
///
/// - Parameter ofExpirationMonth: The expiration month as entered by the
/// user. Should only contain digits.
/// - Parameter andYear: The expiration year as entered by the user. Should
/// only contain digits.
/// - Returns: The validity of the expiration date.
static func validity(
ofExpirationMonth month: String,
andYear year: String,
currentMonth: Int,
currentYear: Int,
) -> Validity {
let monthValidity = validity(ofExpirationMonth: month)
let yearValidity = validity(ofExpirationYear: year)
switch monthValidity.combine(with: yearValidity) {
case .invalid: return .invalid(())
case .potentiallyValid: return .potentiallyValid
default: break
}
guard
let monthInt = Int(month),
let yearTwoDigits = Int(year)
else {
return .invalid(())
}
let century = currentYear / 100 * 100
var yearInt = century + yearTwoDigits
if yearInt < currentYear {
yearInt += 100
}
if yearInt == currentYear {
return monthInt < currentMonth ? .invalid(()) : .fullyValid
}
if yearInt > currentYear + 20 {
return .invalid(())
}
return .fullyValid
}
private static func validity(ofExpirationMonth monthString: String) -> Validity {
guard monthString.count <= 2, monthString.isAsciiDigitsOnly, monthString != "00" else {
return .invalid(())
}
if monthString.isEmpty || monthString == "0" {
return .potentiallyValid
}
guard let monthInt = UInt8(monthString), monthInt >= 1, monthInt <= 12 else {
return .invalid(())
}
return .fullyValid
}
private static func validity(ofExpirationYear yearString: String) -> Validity {
guard yearString.count <= 2, yearString.isAsciiDigitsOnly else {
return .invalid(())
}
if yearString.count < 2 {
return .potentiallyValid
}
return .fullyValid
}
// MARK: CVV
/// Determine the validity of a card verification code.
///
/// CVVs are usually 3 digits long, but are 4 digits for American Express
/// cards.
///
/// - Parameter ofCvv: The card verification code as entered by the user.
/// Should only contain digits.
/// - Parameter cardType: The card type as determined elsewhere.
/// - Returns: The validity of the CVV.
static func validity(ofCvv cvv: String, cardType: CardType) -> Validity {
let validLength = cardType.cvvCount
guard cvv.count <= validLength, cvv.isAsciiDigitsOnly else {
return .invalid(())
}
if cvv.count < validLength {
return .potentiallyValid
}
return .fullyValid
}
}
// MARK: Luhn Validation
private extension String {
var isLuhnValid: Bool {
var checksum = 0
var shouldDouble = false
for character in reversed() {
guard var digit = Int(String(character)) else {
owsFail("Unexpected non-digit character")
}
if shouldDouble {
digit *= 2
}
shouldDouble = !shouldDouble
if digit >= 10 {
digit -= 9
}
checksum += digit
}
return (checksum % 10) == 0
}
}
// MARK: - SEPA bank accounts
enum SEPABankAccounts {
// MARK: IBAN
typealias IBANValidity = PaymentMethodFieldValidity<Self.IBANInvalidity>
enum IBANInvalidity: Hashable {
case tooShort
case tooLong
case invalidCountry
case invalidCharacters
case invalidCheck
}
static func validity(of iban: String, isFieldFocused: Bool) -> IBANValidity {
// Check for invalid characters
guard iban.isAsciiAlphanumericsOnly else {
return .invalid(.invalidCharacters)
}
// Don't show an error message if the user hasn't input anything yet
if iban.isEmpty {
return .potentiallyValid
}
func potentiallyInvalid(
_ invalidity: IBANInvalidity,
isPotentiallyValid: Bool,
) -> IBANValidity {
if isPotentiallyValid {
return .potentiallyValid
}
return .invalid(invalidity)
}
// Check the country
guard iban.count >= 2 else {
return potentiallyInvalid(.tooShort, isPotentiallyValid: isFieldFocused)
}
let countryCode = String(iban.prefix(2))
guard let expectedLength = expectedIBANLengthByCountryCode[countryCode] else {
return .invalid(.invalidCountry)
}
// Check length
if iban.count < expectedLength {
return potentiallyInvalid(.tooShort, isPotentiallyValid: isFieldFocused)
}
if iban.count > expectedLength {
// Too long can be displayed immediately
return .invalid(.tooLong)
}
// Validation check
guard doesIBANPassValidationCheck(iban) else {
return .invalid(.invalidCheck)
}
// Everything passed
return .fullyValid
}
/// Checks if an IBAN string might be valid.
///
/// Input should be alaphanumerics only with no whitespace.
/// Any unexpected characters will cause a `false` return.
///
/// The following methed is used:
///
/// 1. Move the four initial characters to the end of the string
/// 1. Replace each letter in the string with two digits, thereby expanding the string, where A = 10, B = 11, ..., Z = 35
/// 1. Interpret the string as a decimal integer and compute the remainder of that number on division by 97
///
/// See [Validating the IBAN][0] on Wikipedia.
///
/// [0]:https://en.wikipedia.org/wiki/International_Bank_Account_Number#Validating_the_IBAN
///
/// - Parameter iban: A string containing an international bank account number.
/// - Returns: `true` if the IBAN might be valid.
/// `false` if it does not pass a validation check.
static func doesIBANPassValidationCheck(_ iban: String) -> Bool {
let rearrangedIBAN = iban.dropFirst(4) + iban.prefix(4)
let numericIBAN = rearrangedIBAN.uppercased().compactMap { character in
// Base 36 means A = 10, B = 11, ..., Z = 36, exactly how IBAN expects
Int(String(character), radix: 36)
}
guard numericIBAN.count == iban.count else {
// Invalid characters couldn't be converted to numbers
return false
}
// The numeric representation is too large to fit into a UInt64 (it would
// need at least a UInt219), so perform the mod piecewise.
let mod97 = numericIBAN.reduce(0) { previousMod, number in
// The base-36 numbers can only be one or two digits. Offset them
// appropriately so the new number can be effectively concatenated
// to the end of the previous mod
let offsetFactor = number < 10 ? 10 : 100
return (previousMod * offsetFactor + number) % 97
}
return mod97 == 1
}
// MARK: Supported countries
/// The expected length of an IBAN by a country's two-character ISO country code.
///
/// Expected IBAN lengths from [Wikipedia][1].
///
/// [1]:https://en.wikipedia.org/wiki/International_Bank_Account_Number#IBAN_formats_by_country
static let expectedIBANLengthByCountryCode: [String: Int] = [
"AL": 28,
"AD": 24,
"AT": 20,
"AZ": 28,
"BH": 22,
"BY": 28,
"BE": 16,
"BA": 20,
"BR": 29,
"BG": 22,
"CR": 22,
"HR": 21,
"CY": 28,
"CZ": 24,
"DK": 18,
"DO": 28,
"TL": 23,
"EG": 29,
"SV": 28,
"EE": 20,
"FO": 18,
"FI": 18,
"FR": 27,
"GE": 22,
"DE": 22,
"GI": 23,
"GR": 27,
"GL": 18,
"GT": 28,
"HU": 28,
"IS": 26,
"IQ": 23,
"IE": 22,
"IL": 23,
"IT": 27,
"JO": 30,
"KZ": 20,
"XK": 20,
"KW": 30,
"LV": 21,
"LB": 28,
"LY": 25,
"LI": 21,
"LT": 20,
"LU": 20,
"MT": 31,
"MR": 27,
"MU": 30,
"MC": 27,
"MD": 24,
"ME": 22,
"NL": 18,
"MK": 19,
"NO": 15,
"PK": 24,
"PS": 29,
"PL": 28,
"PT": 25,
"QA": 29,
"RO": 24,
"RU": 33,
"LC": 32,
"SM": 27,
"ST": 25,
"SA": 24,
"RS": 22,
"SC": 31,
"SK": 24,
"SI": 19,
"ES": 24,
"SD": 18,
"SE": 24,
"CH": 21,
"TN": 24,
"TR": 26,
"UA": 29,
"AE": 23,
"GB": 22,
"VA": 22,
"VG": 24,
]
}