Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
signalapp
GitHub Repository: signalapp/Signal-iOS
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,
    ]
}