379 lines
12 KiB
Swift
379 lines
12 KiB
Swift
//
|
|
// 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: DonationSubscriptionManager.DonationConfiguration.PaymentMethodsConfiguration,
|
|
localNumber: String?
|
|
) -> Set<DonationPaymentMethod> {
|
|
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<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,
|
|
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>
|
|
) -> 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 {
|
|
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
|
|
}
|
|
}
|