Signal-iOS/Signal/util/PaymentDetailsValidity.swift
2025-03-11 16:17:17 -07:00

486 lines
15 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// 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
/// (1219). For example, let's say I've entered "4242424242424" (13
/// digits), which is Luhn-invalid. If I'm still typing, it's potentially
/// validI 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
fileprivate 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,
]
}