Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
signalapp
GitHub Repository: signalapp/Signal-iOS
Path: blob/main/SignalServiceKit/Subscriptions/Donations/DonationUtilities.swift
1 views
//
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//

import Foundation
public import PassKit

public class DonationUtilities {
    public static var sendGiftBadgeJobQueue: SendGiftBadgeJobQueue { SSKEnvironment.shared.smJobQueuesRef.sendGiftBadgeJobQueue }

    /// Returns a set of donation payment methods available to the local user,
    /// for donating in a specific currency.
    public static func supportedDonationPaymentMethods(
        forDonationMode donationMode: DonationMode,
        usingCurrency currencyCode: Currency.Code,
        withConfiguration configuration: DonationSubscriptionConfiguration.PaymentMethodsConfiguration,
        localNumber: String?,
    ) -> Set<DonationPaymentMethod> {
        let generallySupportedMethods = supportedDonationPaymentMethods(
            forDonationMode: donationMode,
            localNumber: localNumber,
        )

        let currencySupportedMethods = configuration
            .supportedPaymentMethodsByCurrency[currencyCode, default: []]

        return generallySupportedMethods.intersection(currencySupportedMethods)
    }

    /// Returns a set of the donation payment methods available to the local
    /// user for the given donation mode, without considering what currency
    /// they will be donating in.
    public static func supportedDonationPaymentMethods(
        forDonationMode donationMode: DonationMode,
        localNumber: String?,
    ) -> Set<DonationPaymentMethod> {
        guard let localNumber else { return [] }

        let isApplePayAvailable: Bool = {
            if
                PKPaymentAuthorizationController.canMakePayments(),
                !RemoteConfig.current.applePayDisabledRegions.contains(e164: localNumber)
            {
                switch donationMode {
                case .oneTime:
                    return RemoteConfig.current.canDonateOneTimeWithApplePay
                case .gift:
                    return RemoteConfig.current.canDonateGiftWithApplePay
                case .monthly:
                    return RemoteConfig.current.canDonateMonthlyWithApplePay
                }
            }

            return false
        }()

        let isPaypalAvailable = {
            if
                !RemoteConfig.current.paypalDisabledRegions.contains(e164: localNumber)
            {
                switch donationMode {
                case .oneTime:
                    return RemoteConfig.current.canDonateOneTimeWithPaypal
                case .gift:
                    return RemoteConfig.current.canDonateGiftWithPayPal
                case .monthly:
                    return RemoteConfig.current.canDonateMonthlyWithPaypal
                }
            }

            return false
        }()

        let isCardAvailable = {
            if
                !RemoteConfig.current.creditAndDebitCardDisabledRegions.contains(e164: localNumber)
            {
                switch donationMode {
                case .oneTime:
                    return RemoteConfig.current.canDonateOneTimeWithCreditOrDebitCard
                case .gift:
                    return RemoteConfig.current.canDonateGiftWithCreditOrDebitCard
                case .monthly:
                    return RemoteConfig.current.canDonateMonthlyWithCreditOrDebitCard
                }
            }

            return false
        }()

        let isSEPAAvailable = {
            if !TSConstants.isUsingProductionService {
                return true
            }

            guard RemoteConfig.current.sepaEnabledRegions.contains(e164: localNumber) else {
                return false
            }

            switch donationMode {
            case .oneTime, .monthly:
                return true
            case .gift:
                return false
            }
        }()

        let isIDEALAvailable = {
            if !TSConstants.isUsingProductionService {
                return true
            }

            guard RemoteConfig.current.idealEnabledRegions.contains(e164: localNumber) else {
                return false
            }

            switch donationMode {
            case .oneTime, .monthly:
                return true
            case .gift:
                return false
            }
        }()

        var result = Set<DonationPaymentMethod>()

        if isApplePayAvailable {
            result.insert(.applePay)
        }

        if isPaypalAvailable {
            result.insert(.paypal)
        }

        if isCardAvailable {
            result.insert(.creditOrDebitCard)
        }

        if isSEPAAvailable {
            result.insert(.sepa)
        }

        if isIDEALAvailable {
            result.insert(.ideal)
        }

        return result
    }

    /// Can the user donate in the given donation mode?
    public static func canDonate(
        inMode donationMode: DonationMode,
        tsAccountManager: TSAccountManager,
    ) -> Bool {
        guard let registeredState = try? tsAccountManager.registeredStateWithMaybeSneakyTransaction() else {
            // Don't allow donations if unregistered.
            return false
        }

        return !supportedDonationPaymentMethods(
            forDonationMode: donationMode,
            localNumber: registeredState.localIdentifiers.phoneNumber,
        ).isEmpty
    }

    /// Can the user donate in any donation mode?
    public static func canDonateInAnyWay(
        tsAccountManager: TSAccountManager,
    ) -> Bool {
        DonationMode.allCases.contains { mode in
            canDonate(
                inMode: mode,
                tsAccountManager: tsAccountManager,
            )
        }
    }

    public static var supportedNetworks: [PKPaymentNetwork] {
        return [
            .visa,
            .masterCard,
            .amex,
            .discover,
            .maestro,
        ]
    }

    public struct Preset: Equatable {
        public let currencyCode: Currency.Code
        public let amounts: [FiatMoney]

        public init(currencyCode: Currency.Code, amounts: [FiatMoney]) {
            self.currencyCode = currencyCode
            self.amounts = amounts
        }
    }

    /// Given a list of currencies in preference order and a collection of
    /// supported ones, pick a default currency.
    ///
    /// For example, we might want to use EUR with a USD fallback if EUR is
    /// unsupported.
    ///
    /// ```
    /// DonationUtilities.chooseDefaultCurrency(
    ///     preferred: ["EUR", "USD"],
    ///     supported: ["USD"]
    /// )
    /// // => "USD"
    /// ```
    ///
    /// - Parameter preferred: A list of currencies in preference order.
    ///   As a convenience, can contain `nil`, which is ignored.
    /// - Parameter supported: A collection of supported currencies.
    /// - Returns: The first supported currency code, or `nil` if none are found.
    public static func chooseDefaultCurrency(
        preferred: [Currency.Code?],
        supported: any Collection<Currency.Code>,
    ) -> Currency.Code? {
        for currency in preferred {
            if let currency, supported.contains(currency) {
                return currency
            }
        }
        return nil
    }

    private static func donationToSignal() -> String {
        OWSLocalizedString(
            "DONATION_VIEW_DONATION_TO_SIGNAL",
            comment: "Text describing to the user that they're going to pay a donation to Signal",
        )
    }

    private static func monthlyDonationToSignal() -> String {
        OWSLocalizedString(
            "DONATION_VIEW_MONTHLY_DONATION_TO_SIGNAL",
            comment: "Text describing to the user that they're going to pay a monthly donation to Signal",
        )
    }

    public static func newPaymentRequest(for amount: FiatMoney, isRecurring: Bool) -> PKPaymentRequest {
        let nsValue = amount.value as NSDecimalNumber
        let currencyCode = amount.currencyCode

        let paymentSummaryItem: PKPaymentSummaryItem
        if isRecurring {
            let recurringSummaryItem = PKRecurringPaymentSummaryItem(
                label: donationToSignal(),
                amount: nsValue,
            )
            recurringSummaryItem.intervalUnit = .month
            recurringSummaryItem.intervalCount = 1 // once per month
            paymentSummaryItem = recurringSummaryItem
        } else {
            paymentSummaryItem = PKPaymentSummaryItem(label: donationToSignal(), amount: nsValue)
        }

        let request = PKPaymentRequest()
        request.paymentSummaryItems = [paymentSummaryItem]
        request.merchantIdentifier = "merchant." + Bundle.main.merchantId
        request.merchantCapabilities = .capability3DS
        request.countryCode = "US"
        request.currencyCode = currencyCode
        request.supportedNetworks = DonationUtilities.supportedNetworks
        return request
    }
}

// MARK: - Money amounts

/// The values in this extension are drawn largely from Stripe's documentation,
/// which means they may not be exactly correct for PayPal transactions.
/// However: 1) they are probably "good enough"; and 2) they should be replaced
/// with Signal-server values fetched from a configuration endpoint like
/// `/v1/subscription/configuration` eventually, anyway.
public extension DonationUtilities {
    /// A list of currencies known not to use decimal values
    static let zeroDecimalCurrencyCodes: Set<Currency.Code> = [
        "BIF",
        "CLP",
        "DJF",
        "GNF",
        "JPY",
        "KMF",
        "KRW",
        "MGA",
        "PYG",
        "RWF",
        "UGX",
        "VND",
        "VUV",
        "XAF",
        "XOF",
        "XPF",
    ]

    /// Is an amount of money too small, given a minimum?
    static func isBoostAmountTooSmall(_ amount: FiatMoney, minimumAmount: FiatMoney) -> Bool {
        (amount.value <= 0) || (integralAmount(for: amount) < integralAmount(for: minimumAmount))
    }

    static func isBoostAmountTooLarge(_ amount: FiatMoney, maximumAmount: FiatMoney) -> Bool {
        return integralAmount(for: amount) > integralAmount(for: maximumAmount)
    }

    /// Convert the given money amount to an integer that can be passed to
    /// service APIs. Applies rounding and scaling as appropriate for the
    /// currency.
    static func integralAmount(for amount: FiatMoney) -> UInt {
        let scaled: Decimal
        if Self.zeroDecimalCurrencyCodes.contains(amount.currencyCode.uppercased()) {
            scaled = amount.value
        } else {
            scaled = amount.value * 100
        }

        let rounded = scaled.rounded()

        guard rounded >= 0 else { return 0 }
        guard rounded <= Decimal(UInt.max) else { return UInt.max }

        return (rounded as NSDecimalNumber).uintValue
    }
}