// // Copyright 2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only // import Foundation import PassKit import SignalCoreKit public class DonationUtilities: Dependencies { public static var sendGiftBadgeJobQueue: SendGiftBadgeJobQueue { smJobQueues.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: SubscriptionManagerImpl.DonationConfiguration.PaymentMethodsConfiguration, localNumber: String? ) -> Set { let generallySupportedMethods = supportedDonationPaymentMethods( forDonationMode: donationMode, localNumber: localNumber ) let currencySupportedMethods = configuration.supportedPaymentMethods( forCurrencyCode: currencyCode ) 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 { guard let localNumber else { return [] } let isApplePayAvailable: Bool = { if PKPaymentAuthorizationController.canMakePayments(), !RemoteConfig.applePayDisabledRegions.contains(e164: localNumber) { switch donationMode { case .oneTime: return RemoteConfig.canDonateOneTimeWithApplePay case .gift: return RemoteConfig.canDonateGiftWithApplePay case .monthly: return RemoteConfig.canDonateMonthlyWithApplePay } } return false }() let isPaypalAvailable = { if !RemoteConfig.paypalDisabledRegions.contains(e164: localNumber) { switch donationMode { case .oneTime: return RemoteConfig.canDonateOneTimeWithPaypal case .gift: return RemoteConfig.canDonateGiftWithPayPal case .monthly: return RemoteConfig.canDonateMonthlyWithPaypal } } return false }() let isCardAvailable = { if !RemoteConfig.creditAndDebitCardDisabledRegions.contains(e164: localNumber) { switch donationMode { case .oneTime: return RemoteConfig.canDonateOneTimeWithCreditOrDebitCard case .gift: return RemoteConfig.canDonateGiftWithCreditOrDebitCard case .monthly: return RemoteConfig.canDonateMonthlyWithCreditOrDebitCard } } return false }() let isSEPAAvailable = { if !TSConstants.isUsingProductionService { return true } guard RemoteConfig.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.idealEnabledRegions.contains(e164: localNumber) else { return false } switch donationMode { case .oneTime, .monthly: return true case .gift: return false } }() var result = Set() 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, localNumber: String? ) -> Bool { !supportedDonationPaymentMethods( forDonationMode: donationMode, localNumber: localNumber ).isEmpty } /// Can the user donate in any donation mode? public static func canDonateInAnyWay(localNumber: String?) -> Bool { DonationMode.allCases.contains { mode in canDonate(inMode: mode, localNumber: localNumber) } } public static var supportedNetworks: [PKPaymentNetwork] { return [ .visa, .masterCard, .amex, .discover, .maestro ] } public enum Symbol: Equatable { case before(String) case after(String) case currencyCode private static let symbols: [Currency.Code: Symbol] = [ "USD": .before("$"), "AUD": .before("A$"), "BRL": .before("R$"), "GBP": .before("£"), "CAD": .before("CA$"), "CNY": .before("CN¥"), "EUR": .before("€"), "HKD": .before("HK$"), "INR": .before("₹"), "JPY": .before("¥"), "KRW": .before("₩"), "PLN": .after("zł"), "SEK": .after("kr") ] public static func `for`(currencyCode: Currency.Code) -> Symbol { return symbols[currencyCode, default: .currencyCode] } } 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 } } private static let currencyFormatter: NumberFormatter = { let currencyFormatter = NumberFormatter() currencyFormatter.numberStyle = .decimal return currencyFormatter }() public static func format(money: FiatMoney, includeSymbol: Bool = true) -> String { let value = money.value let currencyCode = money.currencyCode let isZeroDecimalCurrency = zeroDecimalCurrencyCodes.contains(currencyCode) let decimalPlaces: Int if isZeroDecimalCurrency { decimalPlaces = 0 } else if value.isInteger { decimalPlaces = 0 } else { decimalPlaces = 2 } currencyFormatter.minimumFractionDigits = decimalPlaces currencyFormatter.maximumFractionDigits = decimalPlaces let nsValue = value as NSDecimalNumber let valueString = currencyFormatter.string(from: nsValue) ?? nsValue.stringValue guard includeSymbol else { return valueString } switch Symbol.for(currencyCode: currencyCode) { case .before(let symbol): return symbol + valueString case .after(let symbol): return valueString + symbol case .currencyCode: return currencyCode + " " + valueString } } /// 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? { for currency in preferred { if let currency = 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 { if #available(iOS 15, *) { let recurringSummaryItem = PKRecurringPaymentSummaryItem( label: donationToSignal(), amount: nsValue ) recurringSummaryItem.intervalUnit = .month recurringSummaryItem.intervalCount = 1 // once per month paymentSummaryItem = recurringSummaryItem } else { paymentSummaryItem = PKPaymentSummaryItem( label: monthlyDonationToSignal(), amount: nsValue ) } } 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 = [ "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 } }