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

import Foundation
import SignalServiceKit

extension DonateViewController {
    /// State for the donate screen.
    ///
    /// There's only one currency picker in the UI but we store the selected
    /// currency twice. This is because supported currency codes may differ
    /// between one-time and monthly donations. For example, EUR may be
    /// supported for monthly donations but not one-time ones. If EUR is
    /// selected in monthly mode and you switch, we need to change the selected
    /// currency.
    struct State: Equatable {

        // MARK: Typealiases

        typealias PaymentMethodsConfiguration = DonationSubscriptionConfiguration.PaymentMethodsConfiguration
        typealias OneTimeConfiguration = DonationSubscriptionConfiguration.BoostConfiguration
        typealias MonthlyConfiguration = DonationSubscriptionConfiguration.SubscriptionConfiguration

        // MARK: - One-time state

        struct OneTimeState: Equatable {
            enum SelectedAmount: Equatable {
                case nothingSelected(currencyCode: Currency.Code)
                case selectedPreset(amount: FiatMoney)
                case choseCustomAmount(amount: FiatMoney)
            }

            enum OneTimePaymentRequest: Equatable {
                case alreadyHasPaymentProcessing(paymentMethod: DonationPaymentMethod)
                case awaitingIDEALAuthorization
                case noAmountSelected
                case amountIsTooSmall(minimumAmount: FiatMoney)
                case canContinue(amount: FiatMoney, supportedPaymentMethods: Set<DonationPaymentMethod>)
            }

            let selectedAmount: SelectedAmount
            let profileBadge: ProfileBadge

            /// The maximum amount a user is allowed to donate via SEPA.
            let maximumAmountViaSepa: FiatMoney

            fileprivate let presets: [Currency.Code: DonationUtilities.Preset]
            fileprivate let minimumAmountsByCurrency: [Currency.Code: FiatMoney]
            fileprivate let paymentMethodConfiguration: PaymentMethodsConfiguration
            fileprivate let receiptCredentialRequestError: DonationReceiptCredentialRequestError?
            fileprivate let pendingIDEALOneTimeDonation: PendingOneTimeIDEALDonation?

            fileprivate let localNumber: String?

            var amount: FiatMoney? {
                switch selectedAmount {
                case .nothingSelected:
                    return nil
                case let .selectedPreset(amount: amount), let .choseCustomAmount(amount):
                    return amount
                }
            }

            var selectedCurrencyCode: Currency.Code {
                switch selectedAmount {
                case let .nothingSelected(currencyCode):
                    return currencyCode
                case let .selectedPreset(amount: amount), let .choseCustomAmount(amount):
                    return amount.currencyCode
                }
            }

            var selectedPreset: DonationUtilities.Preset? {
                presets[selectedCurrencyCode]
            }

            /// The set of supported currency codes. Excludes currencies for
            /// which there are no supported payment methods.
            fileprivate var supportedCurrencyCodes: Set<Currency.Code> {
                Set(presets.keys).supported(
                    forDonationMode: .oneTime,
                    withConfig: paymentMethodConfiguration,
                    localNumber: localNumber,
                )
            }

            var paymentRequest: OneTimePaymentRequest {
                if pendingIDEALOneTimeDonation != nil {
                    return .awaitingIDEALAuthorization
                }

                if
                    let receiptCredentialRequestError,
                    case .paymentStillProcessing = receiptCredentialRequestError.errorCode
                {
                    guard let paymentMethod = receiptCredentialRequestError.paymentMethod else {
                        owsFailBeta("Missing payment method for processing one-time payment. This should be impossible!")
                        return .alreadyHasPaymentProcessing(paymentMethod: .applePay)
                    }

                    return .alreadyHasPaymentProcessing(paymentMethod: paymentMethod)
                }

                guard let amount else {
                    return .noAmountSelected
                }

                let minimumAmount: FiatMoney
                if let minimum = minimumAmountsByCurrency[amount.currencyCode] {
                    minimumAmount = minimum
                } else {
                    // Since this is just a sanity check, don't prevent donation here.
                    // It is likely to fail on its own while processing the payment.
                    Logger.warn("[Donations] Unexpectedly missing minimum boost amount for currency \(amount.currencyCode)!")
                    minimumAmount = .init(currencyCode: amount.currencyCode, value: 0)
                }

                if DonationUtilities.isBoostAmountTooSmall(amount, minimumAmount: minimumAmount) {
                    return .amountIsTooSmall(minimumAmount: minimumAmount)
                }

                return .canContinue(
                    amount: amount,
                    supportedPaymentMethods: supportedPaymentMethods(forCurrencyCode: amount.currencyCode),
                )
            }

            fileprivate func selectCurrencyCode(_ newValue: Currency.Code) -> OneTimeState {
                guard presets.keys.contains(newValue) else {
                    Logger.warn("[Donations] \(newValue) is not a supported one-time currency code. This may indicate a bug")
                    return self
                }
                return OneTimeState(
                    selectedAmount: .nothingSelected(currencyCode: newValue),
                    profileBadge: profileBadge,
                    maximumAmountViaSepa: maximumAmountViaSepa,
                    presets: presets,
                    minimumAmountsByCurrency: minimumAmountsByCurrency,
                    paymentMethodConfiguration: paymentMethodConfiguration,
                    receiptCredentialRequestError: receiptCredentialRequestError,
                    pendingIDEALOneTimeDonation: pendingIDEALOneTimeDonation,
                    localNumber: localNumber,
                )
            }

            fileprivate func selectOneTimeAmount(_ newValue: SelectedAmount) -> OneTimeState {
                let currencyCodeToCheck: Currency.Code
                switch newValue {
                case let .nothingSelected(currencyCode):
                    currencyCodeToCheck = currencyCode
                case let .selectedPreset(amount):
                    guard
                        let preset = presets[amount.currencyCode],
                        preset.amounts.contains(amount)
                    else {
                        owsFail("[Donations] Selected a one-time preset amount but preset amount was not found")
                    }
                    currencyCodeToCheck = amount.currencyCode
                case let .choseCustomAmount(amount):
                    currencyCodeToCheck = amount.currencyCode
                }

                guard presets.keys.contains(currencyCodeToCheck) else {
                    owsFail("[Donations] Selected a non-supported currency")
                }

                return OneTimeState(
                    selectedAmount: newValue,
                    profileBadge: profileBadge,
                    maximumAmountViaSepa: maximumAmountViaSepa,
                    presets: presets,
                    minimumAmountsByCurrency: minimumAmountsByCurrency,
                    paymentMethodConfiguration: paymentMethodConfiguration,
                    receiptCredentialRequestError: receiptCredentialRequestError,
                    pendingIDEALOneTimeDonation: pendingIDEALOneTimeDonation,
                    localNumber: localNumber,
                )
            }

            private func supportedPaymentMethods(
                forCurrencyCode currencyCode: Currency.Code,
            ) -> Set<DonationPaymentMethod> {
                DonationUtilities.supportedDonationPaymentMethods(
                    forDonationMode: .oneTime,
                    usingCurrency: currencyCode,
                    withConfiguration: paymentMethodConfiguration,
                    localNumber: localNumber,
                )
            }
        }

        // MARK: - Monthly state

        struct MonthlyState: Equatable {
            struct MonthlyPaymentRequest: Equatable {
                let amount: FiatMoney
                let profileBadge: ProfileBadge
                let supportedPaymentMethods: Set<DonationPaymentMethod>
            }

            let subscriptionLevels: [DonationSubscriptionLevel]
            let selectedCurrencyCode: Currency.Code
            let selectedSubscriptionLevel: DonationSubscriptionLevel?
            let currentSubscription: Subscription?
            let subscriberID: Data?
            let previousMonthlySubscriptionPaymentMethod: DonationPaymentMethod?
            let pendingIDEALSubscription: PendingMonthlyIDEALDonation?

            fileprivate let paymentMethodConfiguration: PaymentMethodsConfiguration
            fileprivate let receiptCredentialRequestError: DonationReceiptCredentialRequestError?
            fileprivate let localNumber: String?

            /// Get the currency codes supported by all subscription levels.
            ///
            /// Subscription levels usually come from the server, which means a server
            /// bug could have *some*, but not all, levels support a currency. For
            /// example, only one of them could support EUR. This would be a bug, but we
            /// protect against this by requiring the currency to be supported by *all*
            /// levels, not just one.
            fileprivate static func supportedCurrencyCodes(subscriptionLevels: [DonationSubscriptionLevel]) -> Set<Currency.Code> {
                guard let firstSubscriptionLevel = subscriptionLevels.first else { return [] }
                var result = Set<Currency.Code>(firstSubscriptionLevel.amounts.keys)
                for subscriptionLevel in subscriptionLevels {
                    result.formIntersection(subscriptionLevel.amounts.keys)
                }
                return result
            }

            fileprivate var supportedCurrencyCodes: Set<Currency.Code> {
                Self.supportedCurrencyCodes(subscriptionLevels: subscriptionLevels).supported(
                    forDonationMode: .monthly,
                    withConfig: paymentMethodConfiguration,
                    localNumber: localNumber,
                )
            }

            fileprivate var selectedProfileBadge: ProfileBadge? {
                selectedSubscriptionLevel?.badge
            }

            var currentSubscriptionLevel: DonationSubscriptionLevel? {
                if let currentSubscription {
                    return DonationViewsUtil.subscriptionLevelForSubscription(
                        subscriptionLevels: subscriptionLevels,
                        subscription: currentSubscription,
                    )
                } else {
                    return nil
                }
            }

            var paymentMethodIfPaymentProcessing: DonationPaymentMethod? {
                guard let subscription = currentSubscription else { return nil }

                let subscriptionProcessing = subscription.isPaymentProcessing
                let receiptCredentialRequestErrorProcessing = receiptCredentialRequestError?.errorCode == .paymentStillProcessing

                if subscriptionProcessing || receiptCredentialRequestErrorProcessing {
                    guard
                        let donationPaymentMethod = subscription.donationPaymentMethod
                        ?? receiptCredentialRequestError?.paymentMethod
                    else {
                        owsFailDebug("Missing payment method for processing subscription payment!")
                        return .applePay
                    }

                    return donationPaymentMethod
                }

                return nil
            }

            var paymentRequest: MonthlyPaymentRequest? {
                guard
                    let selectedSubscriptionLevel,
                    let amount = selectedSubscriptionLevel.amounts[selectedCurrencyCode]
                else {
                    return nil
                }

                return MonthlyPaymentRequest(
                    amount: amount,
                    profileBadge: selectedSubscriptionLevel.badge,
                    supportedPaymentMethods: supportedPaymentMethods(forCurrencyCode: selectedCurrencyCode),
                )
            }

            fileprivate func selectCurrencyCode(_ newValue: Currency.Code) -> MonthlyState {
                let isCurrencySupported = subscriptionLevels.allSatisfy { subscriptionLevel in
                    subscriptionLevel.amounts.keys.contains(newValue)
                }
                guard isCurrencySupported else {
                    Logger.warn("[Donations] \(newValue) is not a supported monthly currency code. This may indicate a bug")
                    return self
                }
                return MonthlyState(
                    subscriptionLevels: subscriptionLevels,
                    selectedCurrencyCode: newValue,
                    selectedSubscriptionLevel: selectedSubscriptionLevel,
                    currentSubscription: currentSubscription,
                    subscriberID: subscriberID,
                    previousMonthlySubscriptionPaymentMethod: previousMonthlySubscriptionPaymentMethod,
                    pendingIDEALSubscription: pendingIDEALSubscription,
                    paymentMethodConfiguration: paymentMethodConfiguration,
                    receiptCredentialRequestError: receiptCredentialRequestError,
                    localNumber: localNumber,
                )
            }

            fileprivate func selectSubscriptionLevel(_ newValue: DonationSubscriptionLevel) -> MonthlyState {
                owsPrecondition(subscriptionLevels.contains(newValue), "Subscription level not found")
                return MonthlyState(
                    subscriptionLevels: subscriptionLevels,
                    selectedCurrencyCode: selectedCurrencyCode,
                    selectedSubscriptionLevel: newValue,
                    currentSubscription: currentSubscription,
                    subscriberID: subscriberID,
                    previousMonthlySubscriptionPaymentMethod: previousMonthlySubscriptionPaymentMethod,
                    pendingIDEALSubscription: pendingIDEALSubscription,
                    paymentMethodConfiguration: paymentMethodConfiguration,
                    receiptCredentialRequestError: receiptCredentialRequestError,
                    localNumber: localNumber,
                )
            }

            private func supportedPaymentMethods(
                forCurrencyCode currencyCode: Currency.Code,
            ) -> Set<DonationPaymentMethod> {
                DonationUtilities.supportedDonationPaymentMethods(
                    forDonationMode: .monthly,
                    usingCurrency: currencyCode,
                    withConfiguration: paymentMethodConfiguration,
                    localNumber: localNumber,
                )
            }
        }

        // MARK: - Load state

        enum LoadState: Equatable {
            case initializing
            case loading
            case loadFailed
            case loaded(oneTime: OneTimeState, monthly: MonthlyState)

            var debugDescription: String {
                switch self {
                case .initializing: return "initializing"
                case .loading: return "loading"
                case .loadFailed: return "loadFailed"
                case .loaded: return "loaded"
                }
            }
        }

        private var loadedState: (OneTimeState, MonthlyState)? {
            switch loadState {
            case let .loaded(oneTime, monthly): return (oneTime, monthly)
            default: return nil
            }
        }

        private var loadedStateOrDie: (OneTimeState, MonthlyState) {
            guard let result = loadedState else {
                owsFail("[Donations] Expected the state to be loaded")
            }
            return result
        }

        // MARK: - Initialization

        let donateMode: DonateMode
        let loadState: LoadState

        init(donateMode: DonateMode) {
            self.donateMode = donateMode
            self.loadState = .initializing
        }

        private init(donateMode: DonateMode, loadState: LoadState) {
            self.donateMode = donateMode
            self.loadState = loadState
        }

        // MARK: - Getters

        var oneTime: OneTimeState? {
            switch loadState {
            case let .loaded(oneTime, _): return oneTime
            default: return nil
            }
        }

        var monthly: MonthlyState? {
            switch loadState {
            case let .loaded(_, monthly): return monthly
            default: return nil
            }
        }

        var selectedCurrencyCode: Currency.Code? {
            switch donateMode {
            case .oneTime: return oneTime?.selectedCurrencyCode
            case .monthly: return monthly?.selectedCurrencyCode
            }
        }

        /// Get the supported currency codes for the loaded donation mode. All
        /// supported currencies should have at least one allowed payment
        /// method.
        ///
        /// If not loaded, returns an empty set.
        var supportedCurrencyCodes: Set<Currency.Code> {
            switch loadState {
            case .initializing, .loading, .loadFailed:
                return []
            case let .loaded(oneTime, monthly):
                switch donateMode {
                case .oneTime: return oneTime.supportedCurrencyCodes
                case .monthly: return monthly.supportedCurrencyCodes
                }
            }
        }

        var selectedProfileBadge: ProfileBadge? {
            switch donateMode {
            case .oneTime: return oneTime?.profileBadge
            case .monthly: return monthly?.selectedProfileBadge
            }
        }

        /// Get the donation mode, but return `nil` if it's not loaded.
        var loadedDonateMode: DonateMode? {
            switch loadState {
            case .initializing, .loading, .loadFailed:
                return nil
            case .loaded:
                return donateMode
            }
        }

        var debugDescription: String {
            "\(donateMode.debugDescription), \(loadState.debugDescription)"
        }

        // MARK: - Setters

        func loading() -> State {
            State(donateMode: donateMode, loadState: .loading)
        }

        func loadFailed() -> State {
            State(donateMode: donateMode, loadState: .loadFailed)
        }

        func loaded(
            oneTimeConfig: OneTimeConfiguration,
            monthlyConfig: MonthlyConfiguration,
            paymentMethodsConfig: PaymentMethodsConfiguration,
            currentMonthlySubscription: Subscription?,
            subscriberID: Data?,
            previousMonthlySubscriptionCurrencyCode: Currency.Code?,
            previousMonthlySubscriptionPaymentMethod: DonationPaymentMethod?,
            oneTimeBoostReceiptCredentialRequestError: DonationReceiptCredentialRequestError?,
            recurringSubscriptionReceiptCredentialRequestError: DonationReceiptCredentialRequestError?,
            pendingIDEALOneTimeDonation: PendingOneTimeIDEALDonation?,
            pendingIDEALSubscription: PendingMonthlyIDEALDonation?,
            locale: Locale,
            localNumber: String?,
        ) -> State {
            let localeCurrency = locale.currencyCode?.uppercased()

            let oneTime: OneTimeState? = { () -> OneTimeState? in
                let oneTimeSupportedCurrencies = Set(oneTimeConfig.presetAmounts.keys)
                    .supported(
                        forDonationMode: .oneTime,
                        withConfig: paymentMethodsConfig,
                        localNumber: localNumber,
                    )

                guard
                    let oneTimeDefaultCurrency = DonationUtilities.chooseDefaultCurrency(
                        preferred: [localeCurrency, "USD", oneTimeSupportedCurrencies.first],
                        supported: oneTimeSupportedCurrencies,
                    )
                else {
                    // This indicates a bug, either in the iOS app or the server.
                    owsFailDebug("[Donations] Successfully loaded one-time donations, but a preferred currency could not be found")
                    return nil
                }

                return OneTimeState(
                    selectedAmount: OneTimeState.SelectedAmount.nothingSelected(currencyCode: oneTimeDefaultCurrency),
                    profileBadge: oneTimeConfig.badge,
                    maximumAmountViaSepa: oneTimeConfig.maximumAmountViaSepa,
                    presets: oneTimeConfig.presetAmounts,
                    minimumAmountsByCurrency: oneTimeConfig.minimumAmountsByCurrency,
                    paymentMethodConfiguration: paymentMethodsConfig,
                    receiptCredentialRequestError: oneTimeBoostReceiptCredentialRequestError,
                    pendingIDEALOneTimeDonation: pendingIDEALOneTimeDonation,
                    localNumber: localNumber,
                )
            }()

            let monthly: MonthlyState? = {
                let supportedMonthlyCurrencies = MonthlyState.supportedCurrencyCodes(
                    subscriptionLevels: monthlyConfig.levels,
                ).supported(
                    forDonationMode: .monthly,
                    withConfig: paymentMethodsConfig,
                    localNumber: localNumber,
                )

                guard
                    let monthlyDefaultCurrency = DonationUtilities.chooseDefaultCurrency(
                        preferred: [
                            previousMonthlySubscriptionCurrencyCode,
                            currentMonthlySubscription?.amount.currencyCode,
                            localeCurrency,
                            "USD",
                            supportedMonthlyCurrencies.first,
                        ],
                        supported: supportedMonthlyCurrencies,
                    )
                else {
                    // This indicates a bug, either in the iOS app or the server.
                    owsFailDebug("[Donations] Successfully loaded monthly donations, but a preferred currency could not be found")
                    return nil
                }

                let selectedMonthlySubscriptionLevel: DonationSubscriptionLevel?
                if let current = currentMonthlySubscription {
                    selectedMonthlySubscriptionLevel = (
                        monthlyConfig.levels.first(where: { current.level == $0.level }) ??
                            monthlyConfig.levels.first,
                    )
                } else {
                    selectedMonthlySubscriptionLevel = monthlyConfig.levels.first
                }

                return MonthlyState(
                    subscriptionLevels: monthlyConfig.levels,
                    selectedCurrencyCode: monthlyDefaultCurrency,
                    selectedSubscriptionLevel: selectedMonthlySubscriptionLevel,
                    currentSubscription: currentMonthlySubscription,
                    subscriberID: subscriberID,
                    previousMonthlySubscriptionPaymentMethod: previousMonthlySubscriptionPaymentMethod,
                    pendingIDEALSubscription: pendingIDEALSubscription,
                    paymentMethodConfiguration: paymentMethodsConfig,
                    receiptCredentialRequestError: recurringSubscriptionReceiptCredentialRequestError,
                    localNumber: localNumber,
                )
            }()

            guard let oneTime, let monthly else {
                return State(donateMode: donateMode, loadState: .loadFailed)
            }

            return State(
                donateMode: donateMode,
                loadState: .loaded(oneTime: oneTime, monthly: monthly),
            )
        }

        /// Change the donation mode.
        func selectDonateMode(_ newValue: DonateMode) -> State {
            State(donateMode: newValue, loadState: loadState)
        }

        /// Attempt to change the selected currency.
        ///
        /// Most of the time, this will change the currency code for one-time
        /// and monthly states. However, it's possible for the server to support
        /// different currencies for different modes. For example, EUR might be
        /// supported for one-time donations but not monthly ones. Therefore,
        /// this method will only change to a supported currency.
        ///
        /// If the state is not loaded, there will be a fatal error.
        func selectCurrencyCode(_ newValue: Currency.Code) -> State {
            let (oneTime, monthly) = loadedStateOrDie
            return State(
                donateMode: donateMode,
                loadState: .loaded(
                    oneTime: oneTime.selectCurrencyCode(newValue),
                    monthly: monthly.selectCurrencyCode(newValue),
                ),
            )
        }

        /// Change the selected one-time amount.
        ///
        /// The following conditions must be met:
        ///
        /// - The state must be loaded
        /// - The currency code must be supported
        /// - If selecting a preset amount, the amount must be listed
        ///
        /// If any of these conditions are not met, there will be a fatal error.
        func selectOneTimeAmount(_ newSelectedAmount: OneTimeState.SelectedAmount) -> State {
            let (oneTime, monthly) = loadedStateOrDie
            return State(
                donateMode: donateMode,
                loadState: .loaded(
                    oneTime: oneTime.selectOneTimeAmount(newSelectedAmount),
                    monthly: monthly,
                ),
            )
        }

        /// Change the selected subscription level.
        ///
        /// The following conditions must be met:
        ///
        /// - The state must be loaded
        /// - The selected level must be in the list
        ///
        /// If any of these conditions are not met, there will be a fatal error.
        func selectSubscriptionLevel(_ newSubscriptionLevel: DonationSubscriptionLevel) -> State {
            let (oneTime, monthly) = loadedStateOrDie
            return State(
                donateMode: donateMode,
                loadState: .loaded(
                    oneTime: oneTime,
                    monthly: monthly.selectSubscriptionLevel(newSubscriptionLevel),
                ),
            )
        }
    }
}

private extension Set where Element == Currency.Code {
    func supported(
        forDonationMode donationMode: DonationMode,
        withConfig paymentMethodConfig: DonateViewController.State.PaymentMethodsConfiguration,
        localNumber: String?,
    ) -> Self {
        filter { currencyCode in
            !DonationUtilities.supportedDonationPaymentMethods(
                forDonationMode: donationMode,
                usingCurrency: currencyCode,
                withConfiguration: paymentMethodConfig,
                localNumber: localNumber,
            ).isEmpty
        }
    }
}