Path: blob/main/SignalServiceKit/Subscriptions/SubscriptionConfigManager.swift
1 views
//
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
public class SubscriptionConfigManager {
private struct SubscriptionConfig {
let donation: DonationSubscriptionConfiguration
let backup: BackupSubscriptionConfiguration
}
private enum StoreKeys {
static let lastFetchedResponseBody = "lastFetchedResponseBody"
static let lastFetchDate = "lastFetchDate"
}
private let dateProvider: DateProvider
private let db: DB
private let kvStore: NewKeyValueStore
private let networkManager: NetworkManager
init(
dateProvider: @escaping DateProvider,
db: DB,
networkManager: NetworkManager,
) {
self.dateProvider = dateProvider
self.db = db
self.kvStore = NewKeyValueStore(collection: "SubscriptionConfiguration")
self.networkManager = networkManager
}
public func refresh() async throws {
_ = try await _refresh()
}
private func _refresh() async throws -> SubscriptionConfig {
var request = TSRequest(
url: URL(string: "v1/subscription/configuration")!,
method: "GET",
parameters: nil,
)
request.auth = .anonymous
let response: HTTPResponse = try await Retry.performWithBackoff(
maxAttempts: 3,
isRetryable: { $0.isNetworkFailureOrTimeout || $0.is5xxServiceResponse },
block: { try await networkManager.asyncRequest(request) },
)
guard let responseBodyData = response.responseBodyData else {
throw OWSAssertionError("Missing response body!")
}
let donationConfig: DonationSubscriptionConfiguration = try .from(responseBodyData: responseBodyData)
let backupConfig: BackupSubscriptionConfiguration = try .from(responseBodyData: responseBodyData)
await db.awaitableWrite { tx in
kvStore.writeValue(dateProvider(), forKey: StoreKeys.lastFetchDate, tx: tx)
kvStore.writeValue(responseBodyData, forKey: StoreKeys.lastFetchedResponseBody, tx: tx)
}
return SubscriptionConfig(
donation: donationConfig,
backup: backupConfig,
)
}
// MARK: Donations
/// Returns a `DonationSubscriptionConfiguration` either fetched live from
/// the service or cached on disk from a recent fetch.
public func donationConfiguration() async throws -> DonationSubscriptionConfiguration {
if
let cachedResponseBody = db.read(block: { _cachedResponseBody(tx: $0) }),
let donationConfig: DonationSubscriptionConfiguration = try? .from(responseBodyData: cachedResponseBody)
{
return donationConfig
}
return try await _refresh().donation
}
// MARK: Backups
/// Returns a `BackupSubscriptionConfiguration` either fetched live from
/// the service or cached on disk from a recent fetch.
public func backupConfiguration() async throws -> BackupSubscriptionConfiguration {
if
let cachedResponseBody = db.read(block: { _cachedResponseBody(tx: $0) }),
let backupConfig: BackupSubscriptionConfiguration = try? .from(responseBodyData: cachedResponseBody)
{
return backupConfig
}
return try await _refresh().backup
}
/// Returns a recently-fetched-and-cached `BackupSubscriptionConfiguration`
/// if available, and default values otherwise.
///
/// Useful for callers who need a synchronous, non-optional value. Callers
/// may also call ``backupConfiguration()`` once out of the critical
/// synchronous region, preferring that returned value if different.
public func backupConfigurationOrDefault(tx: DBReadTransaction) -> BackupSubscriptionConfiguration {
// It's always better to fall back on our last-fetched value than the
// defaults, so check the cache ignoring TTL.
if
let cachedResponseBody = _cachedResponseBody(ttl: nil, tx: tx),
let backupConfig: BackupSubscriptionConfiguration = try? .from(responseBodyData: cachedResponseBody)
{
return backupConfig
}
return BackupSubscriptionConfiguration(
storageAllowanceBytes: 100_000_000_000,
freeTierMediaDays: 45,
)
}
/// The cached result of a previous configuration fetch.
/// - Parameter ttl
/// An optional "max age" of the cached value, after which it is ignored.
private func _cachedResponseBody(
ttl: TimeInterval? = .week,
tx: DBReadTransaction,
) -> Data? {
if
let ttl,
let lastFetchDate = kvStore.fetchValue(Date.self, forKey: StoreKeys.lastFetchDate, tx: tx),
dateProvider().timeIntervalSince(lastFetchDate) > ttl
{
return nil
}
return kvStore.fetchValue(Data.self, forKey: StoreKeys.lastFetchedResponseBody, tx: tx)
}
}
// MARK: -
/// Represents Backup subscription configuration fetched from the service.
public struct BackupSubscriptionConfiguration: Equatable {
public let storageAllowanceBytes: UInt64
public let freeTierMediaDays: UInt64
public init(storageAllowanceBytes: UInt64, freeTierMediaDays: UInt64) {
self.storageAllowanceBytes = storageAllowanceBytes
self.freeTierMediaDays = freeTierMediaDays
}
static func from(responseBodyData: Data) throws -> BackupSubscriptionConfiguration {
struct TopLevelObject: Decodable {
struct BackupObject: Decodable {
struct BackupLevelObject: Decodable {
let storageAllowanceBytes: Int64
}
let freeTierMediaDays: Int64
let levels: [String: BackupLevelObject]
}
let backup: BackupObject
}
let topLevelObject = try JSONDecoder().decode(TopLevelObject.self, from: responseBodyData)
let backupObject = topLevelObject.backup
guard let backupLevelObject = backupObject.levels["201"] else {
throw OWSAssertionError("Missing Backup config for level 201!")
}
guard let storageAllowanceBytes = UInt64(exactly: backupLevelObject.storageAllowanceBytes) else {
throw OWSAssertionError("storageAllowanceBytes was not a valid UInt64!")
}
guard let freeTierMediaDays = UInt64(exactly: backupObject.freeTierMediaDays) else {
throw OWSAssertionError("freeTierMediaDays was not a valid UInt64!")
}
return BackupSubscriptionConfiguration(
storageAllowanceBytes: storageAllowanceBytes,
freeTierMediaDays: freeTierMediaDays,
)
}
}
// MARK: -
/// Represents donation configuration information fetched from the service,
/// such as preset donation levels and badge information.
public struct DonationSubscriptionConfiguration {
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 {
public let supportedPaymentMethodsByCurrency: [Currency.Code: Set<DonationPaymentMethod>]
}
public let boost: BoostConfiguration
public let gift: GiftConfiguration
public let subscription: SubscriptionConfiguration
public let paymentMethods: PaymentMethodsConfiguration
private init(
boost: BoostConfiguration,
gift: GiftConfiguration,
subscription: SubscriptionConfiguration,
paymentMethods: PaymentMethodsConfiguration,
) {
self.boost = boost
self.gift = gift
self.subscription = subscription
self.paymentMethods = paymentMethods
}
// MARK: -
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)
}
static func from(responseBodyData: Data) throws -> Self {
guard
let responseBodyDict = try? JSONSerialization
.jsonObject(with: responseBodyData) as? [String: Any]
else {
throw OWSAssertionError("Failed to get dictionary from body data!")
}
return try .from(responseBodyDict: responseBodyDict)
}
/// Parse a service configuration from a response body.
static func from(responseBodyDict: [String: Any]) throws -> Self {
let parser = ParamParser(responseBodyDict)
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 Self(
boost: boostConfig,
gift: giftConfig,
subscription: subscriptionConfig,
paymentMethods: paymentMethodsConfig,
)
}
// MARK: - Parse levels
private struct BadgedLevel {
let value: UInt
let badge: ProfileBadge
}
private 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.
private 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(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 static func parseSepaBoostMaximum(
fromParser parser: ParamParser,
) throws -> FiatMoney {
let sepaMaxEurosInt: Int = try parser.required(key: "sepaMaximumEuros")
return FiatMoney(currencyCode: "EUR", value: Decimal(sepaMaxEurosInt))
}
// MARK: - Parse presets
private struct BoostPresets {
let minimum: FiatMoney
let presets: [FiatMoney]
}
private struct GiftPreset {
let preset: FiatMoney
}
private struct SubscriptionPresets {
let presetsByLevel: [UInt: FiatMoney]
}
private struct Presets {
let boost: BoostPresets
let gift: GiftPreset
let subscription: SubscriptionPresets
let supportedPaymentMethods: Set<DonationPaymentMethod>
}
private 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)>,
/// ...
/// }
/// }
/// ```
private 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(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
}
}