TM-SGNL-iOS/SignalServiceKit/Subscriptions/Donations/DonationSubscriptionManager+DonationConfiguration.swift
TeleMessage developers dde0620daf initial commit
2025-05-03 12:28:28 -07:00

502 lines
18 KiB
Swift

//
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
extension DonationSubscriptionManager {
/// Represents donation configuration information fetched from the service,
/// such as preset donation levels and badge information.
public struct DonationConfiguration {
public struct BoostConfiguration {
public let level: UInt
public let badge: ProfileBadge
public let presetAmounts: [Currency.Code: DonationUtilities.Preset]
public let minimumAmountsByCurrency: [Currency.Code: FiatMoney]
/// The maximum donation amount allowed for SEPA debit transfers.
public let maximumAmountViaSepa: FiatMoney
}
public struct GiftConfiguration {
public let level: UInt
public let badge: ProfileBadge
public let presetAmount: [Currency.Code: FiatMoney]
}
public struct SubscriptionConfiguration {
public let levels: [DonationSubscriptionLevel]
}
public struct PaymentMethodsConfiguration: Equatable {
private let supportedPaymentMethodsByCurrency: [Currency.Code: Set<DonationPaymentMethod>]
init(supportedPaymentMethodsByCurrency: [Currency.Code: Set<DonationPaymentMethod>]) {
self.supportedPaymentMethodsByCurrency = supportedPaymentMethodsByCurrency
}
public func supportedPaymentMethods(
forCurrencyCode code: Currency.Code
) -> Set<DonationPaymentMethod> {
supportedPaymentMethodsByCurrency[code] ?? []
}
}
public let boost: BoostConfiguration
public let gift: GiftConfiguration
public let subscription: SubscriptionConfiguration
public let paymentMethods: PaymentMethodsConfiguration
fileprivate init(
boost: BoostConfiguration,
gift: GiftConfiguration,
subscription: SubscriptionConfiguration,
paymentMethods: PaymentMethodsConfiguration
) {
self.boost = boost
self.gift = gift
self.subscription = subscription
self.paymentMethods = paymentMethods
}
}
/// Fetch donation configuration from the service.
public static func fetchDonationConfiguration() -> Promise<DonationConfiguration> {
let request = OWSRequestFactory.donationConfiguration()
return firstly {
SSKEnvironment.shared.networkManagerRef.makePromise(request: request)
}.map(on: DispatchQueue.sharedUserInitiated) { response -> DonationConfiguration in
try DonationConfiguration.from(configurationServiceResponse: response.responseBodyJson)
}
}
}
extension DonationSubscriptionManager.DonationConfiguration {
enum ParseError: Error, Equatable {
/// Missing a preset amount for a donation level.
case missingAmountForLevel(_ level: UInt)
/// Invalid level for a badge.
case invalidBadgeLevel(levelString: String)
/// Missing the boost badge.
case missingBoostBadge
/// Missing the gift badge.
case missingGiftBadge
/// Invalid currency code.
case invalidCurrencyCode(_ code: String)
/// Invalid level for a one-time preset amount.
case invalidOneTimeAmountLevel(levelString: String)
/// Missing boost badge preset amounts.
case missingBoostPresetAmounts
/// Missing gift badge preset amount.
case missingGiftPresetAmount
/// Invalid level for a subscription preset amount.
case invalidSubscriptionAmountLevel(levelString: String)
/// Invalid payment method string.
case invalidPaymentMethodString(string: String)
}
/// Parse a service configuration from a response body.
static func from(configurationServiceResponse responseBody: Any?) throws -> DonationSubscriptionManager.DonationConfiguration {
guard let parser = ParamParser(responseObject: responseBody) else {
throw OWSAssertionError("Missing or invalid response!")
}
let levels: BadgedLevels = try parseLevels(fromParser: parser)
let presetsByCurrency: PresetsByCurrency = try parsePresets(fromParser: parser, forLevels: levels)
let sepaBoostMaximum = try parseSepaBoostMaximum(fromParser: parser)
let boostConfig: BoostConfiguration = {
let minimumAmountsByCurrency: [Currency.Code: FiatMoney] = presetsByCurrency.mapValues { $0.boost.minimum }
let presetAmounts: [Currency.Code: DonationUtilities.Preset] = presetsByCurrency.reduce(
into: [:], { partialResult, kv in
let (code, presets) = kv
partialResult[code] = DonationUtilities.Preset(
currencyCode: code,
amounts: presets.boost.presets
)
}
)
return BoostConfiguration(
level: levels.boost.value,
badge: levels.boost.badge,
presetAmounts: presetAmounts,
minimumAmountsByCurrency: minimumAmountsByCurrency,
maximumAmountViaSepa: sepaBoostMaximum
)
}()
let giftConfig: GiftConfiguration = {
let presetAmounts: [Currency.Code: FiatMoney] = presetsByCurrency.mapValues {
$0.gift.preset
}
return GiftConfiguration(
level: levels.gift.value,
badge: levels.gift.badge,
presetAmount: presetAmounts
)
}()
let subscriptionConfig: SubscriptionConfiguration = try {
/// Query for the preset donation amounts for the given badged
/// level. Throws if amounts are missing for this level.
func makeSubscriptionLevel(fromBadgedLevel level: BadgedLevel) throws -> DonationSubscriptionLevel {
let presetsByCurrencyForLevel: [Currency.Code: FiatMoney] = try presetsByCurrency.mapValues { presets in
guard let amountForLevel = presets.subscription.presetsByLevel[level.value] else {
throw ParseError.missingAmountForLevel(level.value)
}
return amountForLevel
}
return DonationSubscriptionLevel(
level: level.value,
badge: level.badge,
amounts: presetsByCurrencyForLevel
)
}
let subscriptionLevels: [DonationSubscriptionLevel] = try levels.subscription
.map(makeSubscriptionLevel)
.sorted()
return SubscriptionConfiguration(levels: subscriptionLevels)
}()
let paymentMethodsConfig: PaymentMethodsConfiguration = {
let supportedPaymentMethodsByCurrency: [Currency.Code: Set<DonationPaymentMethod>] = presetsByCurrency
.mapValues { presets in
presets.supportedPaymentMethods
}
return PaymentMethodsConfiguration(supportedPaymentMethodsByCurrency: supportedPaymentMethodsByCurrency)
}()
return DonationSubscriptionManager.DonationConfiguration(
boost: boostConfig,
gift: giftConfig,
subscription: subscriptionConfig,
paymentMethods: paymentMethodsConfig
)
}
}
// MARK: - Parse levels
private extension DonationSubscriptionManager.DonationConfiguration {
struct BadgedLevel {
let value: UInt
let badge: ProfileBadge
}
struct BadgedLevels {
let boost: BadgedLevel
let gift: BadgedLevel
let subscription: [BadgedLevel]
}
/// Parse well-known donation levels from the given parser.
///
/// The levels are returned by the service in the following format:
///
/// ```json
/// {
/// "levels": {
/// "<level (int as string)>": {
/// "name": "<name (string)",
/// "badge": <badge (json)>
/// },
/// ...
/// }
/// }
/// ```
///
/// Boost and gift one-time donations have well-known levels and are
/// expected. Any other levels are interpreted as subscription levels.
static func parseLevels(fromParser parser: ParamParser) throws -> BadgedLevels {
let levelsJson: [String: [String: Any]] = try parser.required(key: "levels")
var badgesByLevel: [UInt: BadgedLevel] = try levelsJson.reduce(into: [:]) { partialResult, kv in
let (levelString, json) = kv
guard let level = UInt(levelString) else {
throw ParseError.invalidBadgeLevel(levelString: levelString)
}
let levelParser = ParamParser(dictionary: json)
partialResult[level] = BadgedLevel(
value: level,
badge: try ProfileBadge(jsonDictionary: try levelParser.required(key: "badge"))
)
}
let boostLevel = OneTimeBadgeLevel.boostBadge.rawValue.asNSNumber.uintValue
guard let boostBadge = badgesByLevel.removeValue(forKey: boostLevel) else {
throw ParseError.missingBoostBadge
}
let giftLevel = OWSGiftBadge.Level.signalGift.rawLevel.asNSNumber.uintValue
guard let giftBadge = badgesByLevel.removeValue(forKey: giftLevel) else {
throw ParseError.missingGiftBadge
}
// Remaining levels are assumed to be subscriptions
let subscriptionLevels = badgesByLevel
return BadgedLevels(
boost: boostBadge,
gift: giftBadge,
subscription: Array(subscriptionLevels.values)
)
}
}
// MARK: - SEPA maximum boost
private extension DonationSubscriptionManager.DonationConfiguration {
static func parseSepaBoostMaximum(
fromParser parser: ParamParser
) throws -> FiatMoney {
let sepaMaxEurosInt: Int = try parser.required(key: "sepaMaximumEuros")
return FiatMoney(currencyCode: .euro, value: Decimal(sepaMaxEurosInt))
}
}
private extension Currency.Code {
static let euro: Currency.Code = "EUR"
}
// MARK: - Parse presets
private extension DonationSubscriptionManager.DonationConfiguration {
struct BoostPresets {
let minimum: FiatMoney
let presets: [FiatMoney]
}
struct GiftPreset {
let preset: FiatMoney
}
struct SubscriptionPresets {
let presetsByLevel: [UInt: FiatMoney]
}
struct Presets {
let boost: BoostPresets
let gift: GiftPreset
let subscription: SubscriptionPresets
let supportedPaymentMethods: Set<DonationPaymentMethod>
}
typealias PresetsByCurrency = [Currency.Code: Presets]
/// Parse amounts, grouped by currency, from the given parser.
///
/// The amounts are returned by the service in the following format:
///
/// ```json
/// {
/// "currencies": {
/// "<currency (string)>": <amounts (json)>,
/// ...
/// }
/// }
/// ```
static func parsePresets(
fromParser parser: ParamParser,
forLevels levels: BadgedLevels
) throws -> PresetsByCurrency {
let amountsByCurrency: [String: [String: Any]] = try parser.required(key: "currencies")
return try amountsByCurrency.reduce(into: [:]) { partialResult, kv in
let (currencyCode, json) = kv
guard !currencyCode.isEmpty else {
throw ParseError.invalidCurrencyCode(currencyCode)
}
partialResult[currencyCode.uppercased()] = try parsePresets(
fromJson: json,
forCurrency: currencyCode.uppercased(),
withLevels: levels
)
}
}
private static func parsePresets(
fromJson json: [String: Any],
forCurrency code: Currency.Code,
withLevels levels: BadgedLevels
) throws -> Presets {
let parser = ParamParser(dictionary: json)
let (boostPresets, giftPreset) = try parseOneTimePresets(
fromParser: parser,
forCurrency: code,
withLevels: levels
)
let subscriptionPresets = try parseSubscriptionPresets(
fromParser: parser,
forCurrency: code
)
let supportedPaymentMethods = try parseSupportedPaymentMethods(
fromParser: parser
)
return Presets(
boost: boostPresets,
gift: giftPreset,
subscription: subscriptionPresets,
supportedPaymentMethods: supportedPaymentMethods
)
}
/// Parse one-time donation amounts from the given parser.
///
/// The one-time amounts are returned by the service in the following
/// format:
///
/// ```json
/// {
/// "minimum": <preset value (int)>,
/// "oneTime": {
/// "<level (int as string)>": [<preset value (int)>, ...],
/// ...
/// }
/// }
/// ```
///
/// Boost and gift donations (at the time of writing, the two possible
/// one-time donation types) each have a well-known level, which we
/// query from the parsed JSON above to parse a boost and gift configuration.
private static func parseOneTimePresets(
fromParser parser: ParamParser,
forCurrency code: Currency.Code,
withLevels levels: BadgedLevels
) throws -> (BoostPresets, GiftPreset) {
/// Create a ``FiatMoney`` from a parsed JSON integer value.
func makeMoney(fromIntValue amount: Int) -> FiatMoney {
FiatMoney(currencyCode: code, value: Decimal(amount))
}
let oneTimeAmountsFromService: [String: [Int]] = try parser.required(key: "oneTime")
let oneTimeAmounts: [UInt: [FiatMoney]] = try oneTimeAmountsFromService
.reduce(into: [:]) { partialResult, kv in
let (levelString, amounts): (String, [Int]) = kv
guard let level = UInt(levelString) else {
throw ParseError.invalidOneTimeAmountLevel(levelString: levelString)
}
partialResult[level] = amounts.map(makeMoney)
}
guard
let boostPresetAmounts = oneTimeAmounts[levels.boost.value],
!boostPresetAmounts.isEmpty
else {
throw ParseError.missingBoostPresetAmounts
}
guard
let giftPresetAmounts = oneTimeAmounts[levels.gift.value],
let giftPresetAmount = giftPresetAmounts.first
else {
throw ParseError.missingGiftPresetAmount
}
return (
BoostPresets(
minimum: makeMoney(fromIntValue: try parser.required(key: "minimum")),
presets: boostPresetAmounts
),
GiftPreset(
preset: giftPresetAmount
)
)
}
/// Parse subscription donation levels and their associated amounts from the
/// given parser.
///
/// The subscription amounts are returned by the service in the following
/// format:
///
/// ```json
/// {
/// "subscription": {
/// "<level (int as string)>": <preset value (int)>,
/// ...
/// }
/// }
/// ```
///
/// Each subscription level is assigned a single preset value.
private static func parseSubscriptionPresets(
fromParser parser: ParamParser,
forCurrency code: Currency.Code
) throws -> SubscriptionPresets {
/// Create a ``FiatMoney`` from a parsed JSON integer value.
func makeMoney(fromIntValue amount: Int) -> FiatMoney {
FiatMoney(currencyCode: code, value: Decimal(amount))
}
let subscriptionAmountsFromService: [String: Int] = try parser.required(key: "subscription")
let subscriptionAmounts: [UInt: FiatMoney] = try subscriptionAmountsFromService
.reduce(into: [:]) { partialResult, kv in
let (levelString, amount) = kv
guard let level = UInt(levelString) else {
throw ParseError.invalidSubscriptionAmountLevel(levelString: levelString)
}
partialResult[level] = makeMoney(fromIntValue: amount)
}
return SubscriptionPresets(
presetsByLevel: subscriptionAmounts
)
}
/// Parse supported payment methods from the given parser.
///
/// The payment methods are returned by the service in the following
/// format:
///
/// ```json
/// {
/// "supportedPaymentMethods": [<payment method (string)>, ...]
/// }
/// ```
///
/// Known payment methods include "CARD", which corresponds to Apple Pay
/// and credit cards, and "PAYPAL", which corresponds to PayPal.
private static func parseSupportedPaymentMethods(
fromParser parser: ParamParser
) throws -> Set<DonationPaymentMethod> {
let paymentMethodStrings: [String] = try parser.required(key: "supportedPaymentMethods")
var result: Set<DonationPaymentMethod> = []
for methodString in paymentMethodStrings {
switch methodString {
case "CARD":
result.formUnion([.applePay, .creditOrDebitCard])
case "PAYPAL":
result.formUnion([.paypal])
case "SEPA_DEBIT":
result.formUnion([.sepa])
case "IDEAL":
result.formUnion([.ideal])
default:
Logger.warn("Unrecognized payment string: \(methodString)")
}
}
return result
}
}