Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
signalapp
GitHub Repository: signalapp/Signal-iOS
Path: blob/main/Signal/src/ViewControllers/Donations/DonationPaymentDetailsViewController+FormState.swift
1 views
//
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//

import Foundation
import SignalServiceKit

extension DonationPaymentDetailsViewController {
    enum InvalidFormField: Hashable {
        // Credit card
        case cardNumber
        case expirationDate
        case cvv
        // SEPA
        case iban(SEPABankAccounts.IBANInvalidity)
    }

    enum FormState: Equatable {
        enum ValidForm: Equatable {

            case card(Stripe.PaymentMethod.CreditOrDebitCard)
            case sepa(mandate: Stripe.PaymentMethod.Mandate, account: Stripe.PaymentMethod.SEPA)
            case ideal(Stripe.PaymentMethod.IDEAL)

            var stripePaymentMethod: Stripe.PaymentMethod {
                switch self {
                case let .card(card):
                    return .creditOrDebitCard(creditOrDebitCard: card)
                case let .sepa(mandate: mandate, account: sepaAccount):
                    return .bankTransferSEPA(mandate: mandate, account: sepaAccount)
                case let .ideal(type):
                    return .bankTransferIDEAL(type)
                }
            }

            var donationPaymentMethod: DonationPaymentMethod {
                switch self {
                case .card: return .creditOrDebitCard
                case .sepa: return .sepa
                case .ideal: return .ideal
                }
            }
        }

        /// At least one of the form's fields are invalid.
        case invalid(invalidFields: Set<InvalidFormField>)

        /// The form is potentially valid, but not ready to submit yet.
        case potentiallyValid

        /// The form is fully valid and ready to submit.
        case fullyValid(ValidForm)
    }

    // MARK: Card

    static func formState(
        cardNumber rawNumber: String,
        isCardNumberFieldFocused: Bool,
        expirationDate rawExpirationDate: String,
        cvv rawCvv: String,
    ) -> FormState {
        var invalidFields = Set<InvalidFormField>()
        var hasPotentiallyValidFields = false

        let numberForValidation = rawNumber.removeCharacters(characterSet: .whitespaces)
        let numberValidity = CreditAndDebitCards.validity(
            ofNumber: numberForValidation,
            isNumberFieldFocused: isCardNumberFieldFocused,
        )
        switch numberValidity {
        case .invalid: invalidFields.insert(.cardNumber)
        case .potentiallyValid: hasPotentiallyValidFields = true
        case .fullyValid: break
        }

        let expirationMonth: String
        let expirationTwoDigitYear: String
        let expirationValidity: CreditAndDebitCards.Validity
        let expirationDate = rawExpirationDate.removeCharacters(characterSet: .whitespaces)
        let expirationComponents = expirationDate.components(separatedBy: "/")
        let calendar = Calendar(identifier: .iso8601)
        let currentMonth = calendar.component(.month, from: Date())
        let currentYear = calendar.component(.year, from: Date())
        switch expirationComponents.count {
        case 1:
            if let parsedMonth = parseAsExpirationMonth(slashlessString: expirationDate) {
                expirationMonth = parsedMonth
                expirationTwoDigitYear = String(expirationDate.suffix(from: expirationMonth.endIndex))
                expirationValidity = CreditAndDebitCards.validity(
                    ofExpirationMonth: expirationMonth,
                    andYear: expirationTwoDigitYear,
                    currentMonth: currentMonth,
                    currentYear: currentYear,
                )
            } else {
                expirationMonth = ""
                expirationTwoDigitYear = ""
                expirationValidity = .invalid(())
            }
        case 2:
            expirationMonth = expirationComponents[0]
            expirationTwoDigitYear = expirationComponents[1]
            expirationValidity = CreditAndDebitCards.validity(
                ofExpirationMonth: expirationMonth,
                andYear: expirationTwoDigitYear,
                currentMonth: currentMonth,
                currentYear: currentYear,
            )
        default:
            expirationMonth = ""
            expirationTwoDigitYear = ""
            expirationValidity = .invalid(())
        }
        switch expirationValidity {
        case .invalid: invalidFields.insert(.expirationDate)
        case .potentiallyValid: hasPotentiallyValidFields = true
        case .fullyValid: break
        }

        let cvv = rawCvv.trimmingCharacters(in: .whitespaces)
        let cvvValidity = CreditAndDebitCards.validity(
            ofCvv: cvv,
            cardType: CreditAndDebitCards.cardType(ofNumber: numberForValidation),
        )
        switch cvvValidity {
        case .invalid: invalidFields.insert(.cvv)
        case .potentiallyValid: hasPotentiallyValidFields = true
        case .fullyValid: break
        }

        guard invalidFields.isEmpty else {
            return .invalid(invalidFields: invalidFields)
        }

        if hasPotentiallyValidFields {
            return .potentiallyValid
        }

        return .fullyValid(.card(Stripe.PaymentMethod.CreditOrDebitCard(
            cardNumber: numberForValidation,
            expirationMonth: {
                guard let result = UInt8(String(expirationMonth)) else {
                    owsFail("Couldn't convert exp. month to int, even though it should be valid")
                }
                return result
            }(),
            expirationTwoDigitYear: {
                guard let result = UInt8(String(expirationTwoDigitYear)) else {
                    owsFail("Couldn't convert exp. year to int, even though it should be valid")
                }
                return result
            }(),
            cvv: cvv,
        )))
    }

    private static func parseAsExpirationMonth(slashlessString str: String) -> String? {
        switch str.count {
        case 0, 1:
            // The empty string should be untouched.
            // One-digits should be assumed to be months. Examples: 1, 9
            return str
        case 2:
            // If a valid month, assume that. Examples: 01, 09, 12.
            // Otherwise, assume later digits are years. Examples: 13, 98
            return str.isValidMonth ? str : String(str.prefix(1))
        case 3:
            // This is the tricky case.
            //
            // Some are unambiguously 1-digit months. Examples: 135 → 1/35, 987 → 9/87
            //
            // Some are unambigiously 2-digit months. Examples: 012 → 01/2
            //
            // Some are ambiguous. What should happen for 123?
            //
            // - If we choose what the user intended, we're good. For example,
            //   if the user types 123 and meant 1/23.
            // - If we choose 1/23 and the user meant 12/34, the field will
            //   briefly appear invalid as they type, but will resolve after
            //   they type another digit.
            // - If we choose 12/3 and the user meant 1/23, the field will be
            //   potentially valid and the user will not be able to submit.
            //
            // We choose the second option (123 → 12/34) because the brief
            // invalid state is okay, especially because we will format the
            // input which should make this case unlikely.
            //
            // Alternatively, we could change validation based on whether the
            // expiration date field is focused.
            return String(str.prefix(str.first == "0" ? 2 : 1))
        case 4:
            return String(str.prefix(2))
        default:
            return nil
        }
    }

    // MARK: SEPA

    static func formState(
        mandate: Stripe.PaymentMethod.Mandate,
        iban: String,
        isIBANFieldFocused: Bool,
        name: String,
        email: String,
        isEmailFieldFocused: Bool,
    ) -> FormState {
        var invalidFields = Set<InvalidFormField>()
        var hasPotentiallyValidFields = false

        let ibanValidity = SEPABankAccounts.validity(
            of: iban.removeCharacters(characterSet: .whitespaces),
            isFieldFocused: isIBANFieldFocused,
        )
        switch ibanValidity {
        case .potentiallyValid:
            hasPotentiallyValidFields = true
        case .fullyValid:
            break
        case .invalid(let invalidity):
            invalidFields.insert(.iban(invalidity))
        }

        if name.count <= 2 {
            hasPotentiallyValidFields = true
        }
        if email.isEmpty {
            hasPotentiallyValidFields = true
        }

        if !invalidFields.isEmpty {
            return .invalid(invalidFields: invalidFields)
        }

        if hasPotentiallyValidFields {
            return .potentiallyValid
        }

        // All `Mandate` instances represent an acceptance, so we don't
        // actually need to check anything specific on it

        return .fullyValid(.sepa(
            mandate: mandate,
            account: .init(
                name: name,
                iban: iban,
                email: email,
            ),
        ))
    }

    static func formState(
        IDEALPaymentType: IDEALPaymentType,
        name: String,
        email: String,
        isEmailFieldFocused: Bool,
    ) -> FormState {
        if name.count <= 2 {
            return .potentiallyValid
        }

        if
            case .recurring = IDEALPaymentType,
            email.isEmpty
        {
            return .potentiallyValid
        }

        switch IDEALPaymentType {
        case let .recurring(mandate):
            return .fullyValid(.ideal(
                .recurring(
                    mandate: mandate,
                    name: name,
                    email: email,
                ),
            ))
        case .oneTime:
            return .fullyValid(.ideal(
                .oneTime(
                    name: name,
                ),
            ))
        }
    }
}

private extension String {
    /// Is this 2-character string a valid month?
    ///
    /// Not meant for general use.
    var isValidMonth: Bool {
        guard let asInt = UInt8(self) else { return false }
        return asInt >= 1 && asInt <= 12
    }
}