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

import Foundation
public import LibSignalClient
import PassKit

public enum OneTimeBadgeLevel: Hashable {
    case boostBadge
    case giftBadge(OWSGiftBadge.Level)

    public var rawValue: UInt64 {
        switch self {
        case .boostBadge:
            return 1
        case .giftBadge(let level):
            return level.rawLevel
        }
    }
}

public enum SubscriptionBadgeIds: String, CaseIterable {
    case low = "R_LOW"
    case med = "R_MED"
    case high = "R_HIGH"

    public static func contains(_ id: String) -> Bool {
        return Self(rawValue: id) != nil
    }
}

public enum BoostBadgeIds: String {
    case boost = "BOOST"

    public static func contains(_ id: String) -> Bool {
        return Self(rawValue: id) != nil
    }
}

public enum GiftBadgeIds: String {
    case gift = "GIFT"

    public static func contains(_ id: String) -> Bool {
        return Self(rawValue: id) != nil
    }
}

public extension Notification.Name {
    static let hasExpiredGiftBadgeDidChangeNotification = NSNotification.Name("hasExpiredGiftBadgeDidChangeNotification")
}

/// Responsible for one-time and recurring-subscription actions related to
/// donation payments and their resulting profile badges.
///
/// - Note
/// Donation payments are done via external payment processors (Stripe and
/// Braintree) that consequently require custom, in-app payments management; for
/// example, subscriptions are cancelled via in-app UI.
///
/// - Important
/// Not to be confused with ``BackupSubscriptionManager``, which does many
/// similar things but designed around In-App Payments (StoreKit) and paid-tier
/// Backups.
public enum DonationSubscriptionManager {
    public static func performMigrationToStorageServiceIfNecessary() async {
        let hasMigratedToStorageService = SSKEnvironment.shared.databaseStorageRef.read { transaction in
            subscriptionKVS.getBool(hasMigratedToStorageServiceKey, defaultValue: false, transaction: transaction)
        }

        guard !hasMigratedToStorageService else { return }

        Logger.info("[Donations] Migrating to storage service")

        await SSKEnvironment.shared.databaseStorageRef.awaitableWrite { transaction in
            subscriptionKVS.setBool(true, key: hasMigratedToStorageServiceKey, transaction: transaction)

            let localProfile = SSKEnvironment.shared.profileManagerRef.localUserProfile(tx: transaction)
            let displayBadgesOnProfile = localProfile?.badges.count == localProfile?.visibleBadges.count
            setDisplayBadgesOnProfile(displayBadgesOnProfile, transaction: transaction)
        }

        SSKEnvironment.shared.storageServiceManagerRef.recordPendingLocalAccountUpdates()
    }

    // MARK: -

    private static var receiptCredentialRedemptionJobQueue: DonationReceiptCredentialRedemptionJobQueue {
        SSKEnvironment.shared.donationReceiptCredentialRedemptionJobQueue
    }

    /// - Note
    /// This collection name is reused by other subscription-related stores. For
    /// example, see ``DonationReceiptCredentialResultStore``.
    private static let subscriptionKVS = KeyValueStore(collection: "SubscriptionKeyValueStore")

    fileprivate static let subscriberIDKey = "subscriberID"
    fileprivate static let subscriberCurrencyCodeKey = "subscriberCurrencyCode"
    fileprivate static let lastSubscriptionExpirationKey = "subscriptionExpiration"
    fileprivate static let lastSubscriptionHeartbeatKey = "subscriptionHeartbeat"
    fileprivate static let userManuallyCancelledSubscriptionKey = "userManuallyCancelledSubscriptionKey"
    fileprivate static let displayBadgesOnProfileKey = "displayBadgesOnProfileKey"
    fileprivate static let knownUserSubscriptionBadgeIDsKey = "knownUserSubscriptionBadgeIDsKey"
    fileprivate static let knownUserBoostBadgeIDsKey = "knownUserBoostBadgeIDsKey"
    fileprivate static let knownUserGiftBadgeIDsKey = "knownUserGiftBageIDsKey"
    fileprivate static let mostRecentlyExpiredBadgeIDKey = "mostRecentlyExpiredBadgeIDKey"
    fileprivate static let mostRecentlyExpiredGiftBadgeIDKey = "mostRecentlyExpiredGiftBadgeIDKey"
    fileprivate static let showExpirySheetOnHomeScreenKey = "showExpirySheetOnHomeScreenKey"
    fileprivate static let mostRecentSubscriptionPaymentMethodKey = "mostRecentSubscriptionPaymentMethod"
    fileprivate static let hasMigratedToStorageServiceKey = "hasMigratedToStorageServiceKey"

    // MARK: -

    public static func currentProfileSubscriptionBadges(tx: DBReadTransaction) -> [OWSUserProfileBadgeInfo] {
        let localProfile = SSKEnvironment.shared.profileManagerRef.localUserProfile(tx: tx)
        return (localProfile?.badges ?? []).filter { SubscriptionBadgeIds.contains($0.badgeId) }
    }

    /// A low-overhead, synchronous check for whether we *probably* have a
    /// current donation subscription. Callers who need to know precise details
    /// about our subscription should use ``SubscriptionFetcher``.
    public static func probablyHasCurrentSubscription(tx: DBReadTransaction) -> Bool {
        return !currentProfileSubscriptionBadges(tx: tx).isEmpty
    }

    // MARK: -

    /// Perform processor-agnostic steps to set up a new subscription, before
    /// payment has been authorized.
    ///
    /// - Returns: The new subscriber ID.
    public static func prepareNewSubscription(currencyCode: Currency.Code) async throws -> Data {
        Logger.info("[Donations] Setting up new subscription")
        let subscriberID = try await setupNewSubscriberID()
        Logger.info("[Donations] Caching params after setting up new subscription")

        await SSKEnvironment.shared.databaseStorageRef.awaitableWrite { transaction in
            self.setUserManuallyCancelledSubscription(false, transaction: transaction)
            self.setSubscriberID(subscriberID, transaction: transaction)
            self.setSubscriberCurrencyCode(currencyCode, transaction: transaction)
            self.setMostRecentlyExpiredBadgeID(badgeID: nil, transaction: transaction)
            self.setShowExpirySheetOnHomeScreenKey(show: false, transaction: transaction)
        }

        SSKEnvironment.shared.storageServiceManagerRef.recordPendingLocalAccountUpdates()

        return subscriberID
    }

    /// Finalize a new subscription, after payment has been authorized with the
    /// given processor.
    public static func finalizeNewSubscription(
        forSubscriberId subscriberId: Data,
        paymentType: RecurringSubscriptionPaymentType,
        subscription: DonationSubscriptionLevel,
        currencyCode: Currency.Code,
    ) async throws -> Subscription {
        Logger.info("[Donations] Setting default payment method on service")

        switch paymentType {
        case let .ideal(setupIntentId):
            try await setDefaultIDEALPaymentMethod(
                for: subscriberId,
                setupIntentId: setupIntentId,
            )
        case
            .applePay(let paymentMethodId),
            .creditOrDebitCard(let paymentMethodId),
            .paypal(let paymentMethodId),
            .sepa(let paymentMethodId):
            try await setDefaultPaymentMethod(
                for: subscriberId,
                using: paymentType.paymentProcessor,
                paymentMethodId: paymentMethodId,
            )
        }

        Logger.info("[Donations] Selecting subscription level on service")

        await SSKEnvironment.shared.databaseStorageRef.awaitableWrite { transaction in
            setMostRecentSubscriptionPaymentMethod(
                paymentMethod: paymentType.paymentMethod,
                transaction: transaction,
            )
        }

        return try await setSubscription(for: subscriberId, subscription: subscription, currencyCode: currencyCode)
    }

    /// Update the subscription level for the given subscriber ID.
    public static func updateSubscriptionLevel(
        for subscriberID: Data,
        to subscription: DonationSubscriptionLevel,
        currencyCode: Currency.Code,
    ) async throws -> Subscription {
        Logger.info("[Donations] Updating subscription level")

        return try await setSubscription(
            for: subscriberID,
            subscription: subscription,
            currencyCode: currencyCode,
        )
    }

    /// Cancel a subscription for the given subscriber ID.
    public static func cancelSubscription(for subscriberID: Data) async throws {
        let databaseStorage = SSKEnvironment.shared.databaseStorageRef
        let donationReceiptCredentialResultStore = DependenciesBridge.shared.donationReceiptCredentialResultStore
        let networkManager = SSKEnvironment.shared.networkManagerRef
        let storageServiceManager = SSKEnvironment.shared.storageServiceManagerRef

        Logger.info("[Donations] Cancelling subscription")

        let request = OWSRequestFactory.deleteSubscriberID(subscriberID)
        let response = try await networkManager.asyncRequest(request, retryPolicy: .hopefullyRecoverable)
        if response.responseStatusCode != 200, response.responseStatusCode != 404 {
            throw OWSAssertionError("Got bad response code \(response.responseStatusCode).")
        }
        Logger.info("[Donations] Deleted remote subscription.")

        await databaseStorage.awaitableWrite { transaction in
            self.setSubscriberID(nil, transaction: transaction)
            self.setSubscriberCurrencyCode(nil, transaction: transaction)
            self.setMostRecentSubscriptionPaymentMethod(paymentMethod: nil, transaction: transaction)
            self.setUserManuallyCancelledSubscription(true, transaction: transaction)

            donationReceiptCredentialResultStore.clearRedemptionSuccessForAnyRecurringSubscription(tx: transaction)
            donationReceiptCredentialResultStore.clearRequestErrorForAnyRecurringSubscription(tx: transaction)
        }

        storageServiceManager.recordPendingLocalAccountUpdates()
        Logger.info("[Donations] Deleted local subscription.")
    }

    /// Generate and register an ID for a new subscriber.
    ///
    /// - Returns the new subscriber ID.
    private static func setupNewSubscriberID() async throws -> Data {
        Logger.info("[Donations] Setting up new subscriber ID")

        let newSubscriberID = Randomness.generateRandomBytes(UInt(32))
        let request = OWSRequestFactory.setSubscriberID(newSubscriberID)

        let response = try await SSKEnvironment.shared.networkManagerRef
            .asyncRequest(request, retryPolicy: .hopefullyRecoverable)

        let statusCode = response.responseStatusCode
        if statusCode != 200 {
            throw OWSAssertionError("Got bad response code \(statusCode).")
        }

        return newSubscriberID
    }

    private static func setDefaultPaymentMethod(
        for subscriberId: Data,
        using processor: DonationPaymentProcessor,
        paymentMethodId: String,
    ) async throws {
        let request = OWSRequestFactory.subscriptionSetDefaultPaymentMethod(
            subscriberId: subscriberId,
            processor: processor.rawValue,
            paymentMethodId: paymentMethodId,
        )
        let response = try await SSKEnvironment.shared.networkManagerRef
            .asyncRequest(request, retryPolicy: .hopefullyRecoverable)
        let statusCode = response.responseStatusCode
        if statusCode != 200 {
            throw OWSAssertionError("Got bad response code \(statusCode).")
        }
    }

    private static func setDefaultIDEALPaymentMethod(
        for subscriberId: Data,
        setupIntentId: String,
    ) async throws {
        let request = OWSRequestFactory.subscriptionSetDefaultIDEALPaymentMethod(
            subscriberId: subscriberId,
            setupIntentId: setupIntentId,
        )

        let response = try await SSKEnvironment.shared.networkManagerRef
            .asyncRequest(request, retryPolicy: .hopefullyRecoverable)
        let statusCode = response.responseStatusCode
        if statusCode != 200 {
            throw OWSAssertionError("Got bad response code \(statusCode).")
        }
    }

    /// Set the current subscription to the given level and currency.
    ///
    /// - Returns
    /// The updated subscription.
    private static func setSubscription(
        for subscriberID: Data,
        subscription: DonationSubscriptionLevel,
        currencyCode: Currency.Code,
    ) async throws -> Subscription {
        let databaseStorage = SSKEnvironment.shared.databaseStorageRef
        let networkManager = SSKEnvironment.shared.networkManagerRef
        let storageServiceManager = SSKEnvironment.shared.storageServiceManagerRef

        let key = Randomness.generateRandomBytes(UInt(32)).asBase64Url
        let request = OWSRequestFactory.subscriptionSetSubscriptionLevelRequest(
            subscriberID: subscriberID,
            level: subscription.level,
            currency: currencyCode,
            idempotencyKey: key,
        )
        let response = try await networkManager.asyncRequest(request, retryPolicy: .hopefullyRecoverable)
        let statusCode = response.responseStatusCode
        if statusCode != 200 {
            throw OWSAssertionError("Got bad response code \(statusCode).")
        }

        guard
            let subscription = try await SubscriptionFetcher(
                networkManager: networkManager,
                retryPolicy: .hopefullyRecoverable,
            )
            .fetch(subscriberID: subscriberID)
        else {
            throw OWSAssertionError("Failed to fetch valid subscription object after setSubscription")
        }

        await databaseStorage.awaitableWrite { transaction in
            setSubscriberCurrencyCode(currencyCode, transaction: transaction)
        }

        storageServiceManager.recordPendingLocalAccountUpdates()

        return subscription
    }

    // MARK: -

    public static func requestAndRedeemReceipt(
        subscriberId: Data,
        subscriptionLevel: UInt,
        priorSubscriptionLevel: UInt?,
        paymentProcessor: DonationPaymentProcessor,
        paymentMethod: DonationPaymentMethod?,
        isNewSubscription: Bool,
    ) async throws {
        let db = DependenciesBridge.shared.db

        let (
            receiptCredentialRequestContext,
            receiptCredentialRequest,
        ) = ReceiptCredentialManager.generateReceiptRequest()

        let redemptionJobRecord = await db.awaitableWrite { tx in
            return receiptCredentialRedemptionJobQueue.saveSubscriptionRedemptionJob(
                paymentProcessor: paymentProcessor,
                paymentMethod: paymentMethod,
                receiptCredentialRequestContext: receiptCredentialRequestContext,
                receiptCredentialRequest: receiptCredentialRequest,
                subscriberID: subscriberId,
                targetSubscriptionLevel: subscriptionLevel,
                priorSubscriptionLevel: priorSubscriptionLevel,
                isNewSubscription: isNewSubscription,
                tx: tx,
            )
        }

        try await receiptCredentialRedemptionJobQueue.runRedemptionJob(
            jobRecord: redemptionJobRecord,
        )
    }

    public static func requestAndRedeemReceipt(
        boostPaymentIntentId: String,
        amount: FiatMoney,
        paymentProcessor: DonationPaymentProcessor,
        paymentMethod: DonationPaymentMethod,
    ) async throws {
        let db = DependenciesBridge.shared.db

        let (
            receiptCredentialRequestContext,
            receiptCredentialRequest,
        ) = ReceiptCredentialManager.generateReceiptRequest()

        let redemptionJobRecord = await db.awaitableWrite { tx in
            return receiptCredentialRedemptionJobQueue.saveBoostRedemptionJob(
                amount: amount,
                paymentProcessor: paymentProcessor,
                paymentMethod: paymentMethod,
                receiptCredentialRequestContext: receiptCredentialRequestContext,
                receiptCredentialRequest: receiptCredentialRequest,
                boostPaymentIntentID: boostPaymentIntentId,
                tx: tx,
            )
        }

        try await receiptCredentialRedemptionJobQueue.runRedemptionJob(
            jobRecord: redemptionJobRecord,
        )
    }

    public static func redeemReceiptCredentialPresentation(
        receiptCredentialPresentation: ReceiptCredentialPresentation,
    ) async throws {
        let expiresAtForLogging: String = {
            guard let result = try? receiptCredentialPresentation.getReceiptExpirationTime() else { return "UNKNOWN" }
            return String(result)
        }()
        Logger.info("[Donations] Redeeming receipt credential presentation. Expires at \(expiresAtForLogging)")

        let databaseStorage = SSKEnvironment.shared.databaseStorageRef
        let receiptCredentialPresentationData = receiptCredentialPresentation.serialize()
        let request = OWSRequestFactory.subscriptionRedeemReceiptCredential(
            receiptCredentialPresentation: receiptCredentialPresentationData,
            displayBadgesOnProfile: databaseStorage.read(block: displayBadgesOnProfile(transaction:)),
        )
        let response = try await SSKEnvironment.shared.networkManagerRef.asyncRequest(request)
        let statusCode = response.responseStatusCode
        if statusCode != 200 {
            throw OWSAssertionError("[Donations] Receipt credential presentation request failed with status code \(statusCode)")
        }
        _ = try await SSKEnvironment.shared.profileManagerImplRef.fetchLocalUsersProfile(authedAccount: .implicit())
    }

    // MARK: Heartbeat

    public static func redeemSubscriptionIfNecessary() async throws {
        struct CheckerStore: SubscriptionRedemptionNecessityCheckerStore {
            let donationSubscriptionManager: DonationSubscriptionManager.Type

            func subscriberId(tx: DBReadTransaction) -> Data? {
                return donationSubscriptionManager.getSubscriberID(transaction: tx)
            }

            func getLastRedemptionNecessaryCheck(tx: DBReadTransaction) -> Date? {
                return donationSubscriptionManager.subscriptionKVS.getDate(donationSubscriptionManager.lastSubscriptionHeartbeatKey, transaction: tx)
            }

            func setLastRedemptionNecessaryCheck(_ now: Date, tx: DBWriteTransaction) {
                donationSubscriptionManager.subscriptionKVS.setDate(now, key: donationSubscriptionManager.lastSubscriptionHeartbeatKey, transaction: tx)
            }
        }

        let logger = PrefixedLogger(prefix: "[Donations]")

        let subscriptionRedemptionNecessityChecker = SubscriptionRedemptionNecessityChecker<
            DonationReceiptCredentialRedemptionJobRecord,
        >(
            checkerStore: CheckerStore(donationSubscriptionManager: self),
            dateProvider: { Date() },
            db: DependenciesBridge.shared.db,
            logger: logger,
            networkManager: SSKEnvironment.shared.networkManagerRef,
            tsAccountManager: DependenciesBridge.shared.tsAccountManager,
        )

        _ = try await subscriptionRedemptionNecessityChecker.redeemSubscriptionIfNecessary(
            fetchSubscriptionBlock: { db, subscriptionFetcher -> (subscriberID: Data, subscription: Subscription)? in
                if
                    let subscriberID = db.read(block: { getSubscriberID(transaction: $0) }),
                    let subscription = try await subscriptionFetcher.fetch(subscriberID: subscriberID)
                {
                    return (subscriberID, subscription)
                }

                return nil
            },
            parseEntitlementExpirationBlock: { accountEntitlements, subscription -> TimeInterval? in
                // TODO: If the entitlement contains something we can correlate
                // with the subscription, like the "subscription level" int
                // value, then we can more simply extract the entitlement that
                // matches the given subscription.

                // Grab only the subscription badge entitlements...
                let subscriptionBadgeEntitlements = accountEntitlements.badges.filter { entitlement in
                    return SubscriptionBadgeIds.contains(entitlement.badgeId)
                }

                // ...and return the last-expiring one. We can infer that's the
                // "current" one.
                return subscriptionBadgeEntitlements.map(\.expirationSeconds).max()
            },
            saveRedemptionJobBlock: { subscriberId, subscription, tx -> DonationReceiptCredentialRedemptionJobRecord? in
                if
                    receiptCredentialRedemptionJobQueue.subscriptionJobExists(
                        subscriberID: subscriberId,
                        tx: tx,
                    )
                {
                    // A redemption job is already enqueued for this subscription!
                    // This can happen if a previously-enqueued job hasn't
                    // finished but the NecessityChecker decided it should run,
                    // maybe because it's been >3d.
                    //
                    // That's not implausible, for example for SEPA donations in
                    // which a payment could be "processing" for days (and an
                    // enqueued redemption job stalled during that time).
                    //
                    // Since the jobs persist state (such as "redemption success
                    // or error"), avoid enqueuing multiple that might step on
                    // each other.
                    logger.warn("Not enqueuing new subscription redemption job: one already exists for this subscriber ID!")
                    return nil
                }

                guard let donationPaymentProcessor = subscription.donationPaymentProcessor else {
                    throw OWSAssertionError(
                        "Unexpectedly missing donation payment processor while redeeming donation subscription!",
                        logger: logger,
                    )
                }

                let (
                    receiptCredentialRequestContext,
                    receiptCredentialRequest,
                ) = ReceiptCredentialManager.generateReceiptRequest()

                return receiptCredentialRedemptionJobQueue.saveSubscriptionRedemptionJob(
                    paymentProcessor: donationPaymentProcessor,
                    paymentMethod: subscription.donationPaymentMethod,
                    receiptCredentialRequestContext: receiptCredentialRequestContext,
                    receiptCredentialRequest: receiptCredentialRequest,
                    subscriberID: subscriberId,
                    targetSubscriptionLevel: subscription.level,
                    priorSubscriptionLevel: nil,
                    isNewSubscription: false,
                    tx: tx,
                )
            },
            startRedemptionJobBlock: { jobRecord async throws in
                try await receiptCredentialRedemptionJobQueue.runRedemptionJob(jobRecord: jobRecord)
            },
        )
    }
}

// MARK: - State management

extension DonationSubscriptionManager {

    public static func getSubscriberID(transaction: DBReadTransaction) -> Data? {
        guard
            let subscriberID = subscriptionKVS.getObject(
                subscriberIDKey,
                ofClass: NSData.self,
                transaction: transaction,
            ) as Data?
        else {
            return nil
        }
        return subscriberID
    }

    public static func setSubscriberID(_ subscriberID: Data?, transaction: DBWriteTransaction) {
        subscriptionKVS.setObject(
            subscriberID as NSData?,
            key: subscriberIDKey,
            transaction: transaction,
        )
    }

    public static func getSubscriberCurrencyCode(transaction: DBReadTransaction) -> String? {
        guard
            let subscriberCurrencyCode = subscriptionKVS.getString(
                subscriberCurrencyCodeKey,
                transaction: transaction,
            )
        else {
            return nil
        }
        return subscriberCurrencyCode
    }

    public static func setSubscriberCurrencyCode(
        _ currencyCode: Currency.Code?,
        transaction: DBWriteTransaction,
    ) {
        subscriptionKVS.setString(
            currencyCode,
            key: subscriberCurrencyCodeKey,
            transaction: transaction,
        )
    }

    public static func userManuallyCancelledSubscription(transaction: DBReadTransaction) -> Bool {
        return subscriptionKVS.getBool(userManuallyCancelledSubscriptionKey, transaction: transaction) ?? false
    }

    public static func setUserManuallyCancelledSubscription(_ value: Bool, updateStorageService: Bool = false, transaction: DBWriteTransaction) {
        guard value != userManuallyCancelledSubscription(transaction: transaction) else { return }
        subscriptionKVS.setBool(value, key: userManuallyCancelledSubscriptionKey, transaction: transaction)
        if updateStorageService {
            SSKEnvironment.shared.storageServiceManagerRef.recordPendingLocalAccountUpdates()
        }
    }

    // MARK: -

    public static func displayBadgesOnProfile(transaction: DBReadTransaction) -> Bool {
        return subscriptionKVS.getBool(displayBadgesOnProfileKey, transaction: transaction) ?? false
    }

    public static func setDisplayBadgesOnProfile(_ value: Bool, updateStorageService: Bool = false, transaction: DBWriteTransaction) {
        guard value != displayBadgesOnProfile(transaction: transaction) else { return }
        subscriptionKVS.setBool(value, key: displayBadgesOnProfileKey, transaction: transaction)
        if updateStorageService {
            SSKEnvironment.shared.storageServiceManagerRef.recordPendingLocalAccountUpdates()
        }
    }

    // MARK: -

    fileprivate static func setKnownUserSubscriptionBadgeIDs(badgeIDs: [String], transaction: DBWriteTransaction) {
        subscriptionKVS.setStringArray(badgeIDs, key: knownUserSubscriptionBadgeIDsKey, transaction: transaction)
    }

    fileprivate static func knownUserSubscriptionBadgeIDs(transaction: DBReadTransaction) -> [String] {
        return subscriptionKVS.getStringArray(knownUserSubscriptionBadgeIDsKey, transaction: transaction) ?? []
    }

    fileprivate static func setKnownUserBoostBadgeIDs(badgeIDs: [String], transaction: DBWriteTransaction) {
        subscriptionKVS.setStringArray(badgeIDs, key: knownUserBoostBadgeIDsKey, transaction: transaction)
    }

    fileprivate static func knownUserBoostBadgeIDs(transaction: DBReadTransaction) -> [String] {
        return subscriptionKVS.getStringArray(knownUserBoostBadgeIDsKey, transaction: transaction) ?? []
    }

    fileprivate static func setKnownUserGiftBadgeIDs(badgeIDs: [String], transaction: DBWriteTransaction) {
        subscriptionKVS.setStringArray(badgeIDs, key: knownUserGiftBadgeIDsKey, transaction: transaction)
    }

    fileprivate static func knownUserGiftBadgeIDs(transaction: DBReadTransaction) -> [String] {
        return subscriptionKVS.getStringArray(knownUserGiftBadgeIDsKey, transaction: transaction) ?? []
    }

    fileprivate static func setMostRecentlyExpiredBadgeID(badgeID: String?, transaction: DBWriteTransaction) {
        guard let badgeID else {
            subscriptionKVS.removeValue(forKey: mostRecentlyExpiredBadgeIDKey, transaction: transaction)
            return
        }

        subscriptionKVS.setString(badgeID, key: mostRecentlyExpiredBadgeIDKey, transaction: transaction)

    }

    public static func mostRecentlyExpiredBadgeID(transaction: DBReadTransaction) -> String? {
        subscriptionKVS.getString(mostRecentlyExpiredBadgeIDKey, transaction: transaction)
    }

    public static func clearMostRecentlyExpiredBadgeIDWithSneakyTransaction() {
        SSKEnvironment.shared.databaseStorageRef.write { transaction in
            self.setMostRecentlyExpiredBadgeID(badgeID: nil, transaction: transaction)
        }
    }

    fileprivate static func setMostRecentlyExpiredGiftBadgeID(badgeID: String?, transaction: DBWriteTransaction) {
        if let badgeID {
            subscriptionKVS.setString(badgeID, key: mostRecentlyExpiredGiftBadgeIDKey, transaction: transaction)
        } else {
            subscriptionKVS.removeValue(forKey: mostRecentlyExpiredGiftBadgeIDKey, transaction: transaction)
        }
        transaction.addSyncCompletion {
            NotificationCenter.default.postOnMainThread(name: .hasExpiredGiftBadgeDidChangeNotification, object: nil)
        }
    }

    public static func mostRecentlyExpiredGiftBadgeID(transaction: DBReadTransaction) -> String? {
        subscriptionKVS.getString(mostRecentlyExpiredGiftBadgeIDKey, transaction: transaction)
    }

    public static func clearMostRecentlyExpiredGiftBadgeIDWithSneakyTransaction() {
        SSKEnvironment.shared.databaseStorageRef.write { transaction in
            self.setMostRecentlyExpiredGiftBadgeID(badgeID: nil, transaction: transaction)
        }
    }

    public static func setShowExpirySheetOnHomeScreenKey(show: Bool, transaction: DBWriteTransaction) {
        subscriptionKVS.setBool(show, key: showExpirySheetOnHomeScreenKey, transaction: transaction)
    }

    public static func showExpirySheetOnHomeScreenKey(transaction: DBReadTransaction) -> Bool {
        return subscriptionKVS.getBool(showExpirySheetOnHomeScreenKey, transaction: transaction) ?? false
    }

    public static func setMostRecentSubscriptionPaymentMethod(
        paymentMethod: DonationPaymentMethod?,
        transaction: DBWriteTransaction,
    ) {
        subscriptionKVS.setString(paymentMethod?.rawValue, key: mostRecentSubscriptionPaymentMethodKey, transaction: transaction)
    }

    public static func getMostRecentSubscriptionPaymentMethod(transaction: DBReadTransaction) -> DonationPaymentMethod? {
        guard let paymentMethodString = subscriptionKVS.getString(mostRecentSubscriptionPaymentMethodKey, transaction: transaction) else {
            return nil
        }

        guard let paymentMethod = DonationPaymentMethod(rawValue: paymentMethodString) else {
            owsFailBeta("Unexpected payment method string: \(paymentMethodString)")
            return nil
        }

        return paymentMethod
    }
}

// MARK: -

extension DonationSubscriptionManager {

    private static let cachedBadges = AtomicValue<[OneTimeBadgeLevel: CachedBadge]>([:], lock: .init())

    public static func getCachedBadge(level: OneTimeBadgeLevel) -> CachedBadge {
        return self.cachedBadges.update {
            if let cachedBadge = $0[level] {
                return cachedBadge
            }
            let cachedBadge = CachedBadge(level: level)
            $0[level] = cachedBadge
            return cachedBadge
        }
    }

    public static func getBoostBadge() async throws -> ProfileBadge {
        let profileBadge = try await getOneTimeBadge(level: .boostBadge)
        guard let profileBadge else {
            owsFail("No badge for this level was found")
        }
        return profileBadge
    }

    public static func getOneTimeBadge(level: OneTimeBadgeLevel) async throws -> ProfileBadge? {
        let donationConfiguration = try await fetchDonationConfiguration()
        switch level {
        case .boostBadge:
            return donationConfiguration.boost.badge
        case .giftBadge(let level):
            guard donationConfiguration.gift.level == level.rawLevel else {
                Logger.warn("Requested gift badge with level \(level), which did not match known gift badge with level \(donationConfiguration.gift.level)")
                return nil
            }

            return donationConfiguration.gift.badge
        }
    }

    public static func getSubscriptionBadge(subscriptionLevel levelRawValue: UInt) async throws -> ProfileBadge {
        let donationConfiguration = try await fetchDonationConfiguration()
        guard
            let matchingLevel = donationConfiguration.subscription.levels.first(where: {
                $0.level == levelRawValue
            })
        else {
            throw OWSAssertionError("Missing requested subscription level!")
        }

        return matchingLevel.badge
    }

    public static func fetchDonationConfiguration() async throws -> DonationSubscriptionConfiguration {
        let subscriptionConfigManager = DependenciesBridge.shared.subscriptionConfigManager
        return try await subscriptionConfigManager.donationConfiguration()
    }
}

// MARK: -

extension DonationSubscriptionManager {
    public static func reconcileBadgeStates(
        currentLocalUserProfile: OWSUserProfile,
        transaction: DBWriteTransaction,
    ) {
        let currentBadges = currentLocalUserProfile.badges

        let currentSubscriberBadgeIDs = currentBadges.compactMap { (badge: OWSUserProfileBadgeInfo) -> String? in
            guard SubscriptionBadgeIds.contains(badge.badgeId) else { return nil }
            return badge.badgeId
        }

        let currentBoostBadgeIDs = currentBadges.compactMap { (badge: OWSUserProfileBadgeInfo) -> String? in
            guard BoostBadgeIds.contains(badge.badgeId) else { return nil }
            return badge.badgeId
        }

        let currentGiftBadgeIDs = currentBadges.compactMap { (badge: OWSUserProfileBadgeInfo) -> String? in
            guard GiftBadgeIds.contains(badge.badgeId) else { return nil }
            return badge.badgeId
        }

        // Read existing values
        let persistedSubscriberBadgeIDs = Self.knownUserSubscriptionBadgeIDs(transaction: transaction)
        let persistedBoostBadgeIDs = Self.knownUserBoostBadgeIDs(transaction: transaction)
        let persistedGiftBadgeIDs = Self.knownUserGiftBadgeIDs(transaction: transaction)
        let oldExpiredGiftBadgeID = Self.mostRecentlyExpiredGiftBadgeID(transaction: transaction)
        var expiringBadgeId = Self.mostRecentlyExpiredBadgeID(transaction: transaction)
        var userManuallyCancelled = Self.userManuallyCancelledSubscription(transaction: transaction)
        var showExpiryOnHomeScreen = Self.showExpirySheetOnHomeScreenKey(transaction: transaction)
        var displayBadgesOnProfile = Self.displayBadgesOnProfile(transaction: transaction)

        let isCurrentlyDisplayingBadgesOnProfile = currentBadges.allSatisfy { badge in
            badge.isVisible ?? {
                owsFailDebug("Local user badges should always have a non-nil visibility flag")
                return true
            }()
        }
        if displayBadgesOnProfile != isCurrentlyDisplayingBadgesOnProfile {
            displayBadgesOnProfile = isCurrentlyDisplayingBadgesOnProfile
            Logger.info("Updating displayBadgesOnProfile to reflect state on profile \(displayBadgesOnProfile)")
        }

        let newSubscriberBadgeIds = Set(currentSubscriberBadgeIDs).subtracting(persistedSubscriberBadgeIDs)
        if !newSubscriberBadgeIds.isEmpty {
            Logger.info("Learned of \(newSubscriberBadgeIds.count) new subscriber badge ids: \(newSubscriberBadgeIds)")
        }

        let expiredSubscriberBadgeIds = Set(persistedSubscriberBadgeIDs).subtracting(currentSubscriberBadgeIDs)
        if !expiredSubscriberBadgeIds.isEmpty {
            Logger.info("Learned of \(expiredSubscriberBadgeIds.count) newly expired subscriber badge ids: \(expiredSubscriberBadgeIds)")
        }

        let newBoostBadgeIds = Set(currentBoostBadgeIDs).subtracting(persistedBoostBadgeIDs)
        if !newBoostBadgeIds.isEmpty {
            Logger.info("Learned of \(newBoostBadgeIds.count) new boost badge ids: \(newBoostBadgeIds)")
        }

        let expiredBoostBadgeIds = Set(persistedBoostBadgeIDs).subtracting(currentBoostBadgeIDs)
        if !expiredBoostBadgeIds.isEmpty {
            Logger.info("Learned of \(expiredBoostBadgeIds.count) newly expired boost badge ids: \(expiredBoostBadgeIds)")
        }

        let newGiftBadgeIds = Set(currentGiftBadgeIDs).subtracting(persistedGiftBadgeIDs)
        if !newGiftBadgeIds.isEmpty {
            Logger.info("Learned of \(newGiftBadgeIds.count) new gift badge ids: \(newGiftBadgeIds)")
        }

        let expiredGiftBadgeIds = Set(persistedGiftBadgeIDs).subtracting(currentGiftBadgeIDs)
        if !expiredGiftBadgeIds.isEmpty {
            Logger.info("Learned of \(expiredGiftBadgeIds.count) newly expired gift badge ids: \(expiredGiftBadgeIds)")
        }

        var newExpiringBadgeId: String?
        if let persistedBadgeId = persistedSubscriberBadgeIDs.first, currentSubscriberBadgeIDs.isEmpty {
            if !userManuallyCancelled {
                Logger.info("Last subscription badge id expired \(persistedBadgeId)")
                newExpiringBadgeId = persistedBadgeId
            } else {
                Logger.info("Last subscription badge id expired \(persistedBadgeId), but ignoring because subscription was manually cancelled")
            }
        }

        if let persistedBadgeId = persistedBoostBadgeIDs.first, currentBoostBadgeIDs.isEmpty {
            if expiringBadgeId == nil || BoostBadgeIds.contains(expiringBadgeId!), newExpiringBadgeId == nil {
                Logger.info("Last boost badge id expired \(persistedBadgeId)")
                newExpiringBadgeId = persistedBadgeId
            } else {
                Logger.info("Last boost badge id expired \(persistedBadgeId), but ignoring because subscription badge also expired")
            }
        }

        if let newExpiringBadgeId, newExpiringBadgeId != expiringBadgeId {
            Logger.info("Recording new expired badge id to show on home screen \(newExpiringBadgeId)")
            expiringBadgeId = newExpiringBadgeId
            showExpiryOnHomeScreen = true
        } else if let oldExpiringBadgeId = expiringBadgeId {
            if SubscriptionBadgeIds.contains(oldExpiringBadgeId), !newSubscriberBadgeIds.isEmpty {
                Logger.info("Clearing expired subscription badge id \(oldExpiringBadgeId), new subscription badge found.")
                expiringBadgeId = nil
                showExpiryOnHomeScreen = false
            } else if BoostBadgeIds.contains(oldExpiringBadgeId), !newBoostBadgeIds.isEmpty {
                Logger.info("Clearing expired boost badge id \(oldExpiringBadgeId), new boost badge found.")
                expiringBadgeId = nil
                showExpiryOnHomeScreen = false
            }
        }

        if userManuallyCancelled, !newSubscriberBadgeIds.isEmpty {
            Logger.info("Clearing manual subscription cancellation, new subscription badge found.")
            userManuallyCancelled = false
        }

        let newExpiredGiftBadgeID: String?
        if currentGiftBadgeIDs.isEmpty {
            // If you don't have any remaining gift badges, show (a) the badge that
            // *just* expired, (b) a gift that expired during a previous call to
            // reconcile badge states, or (c) nothing. Most users will fall into (c).
            newExpiredGiftBadgeID = expiredGiftBadgeIds.first ?? oldExpiredGiftBadgeID ?? nil
        } else {
            // If you have a gift badge, don't show any expiration about gift badges.
            // Perhaps you redeemed another gift before we displayed the sheet.
            newExpiredGiftBadgeID = nil
        }

        Logger.info("""
        Reconciled badge state:
            Subscriber Badge Ids: \(currentSubscriberBadgeIDs)
            Boost Badge Ids: \(currentBoostBadgeIDs)
            Gift Badge Ids: \(currentGiftBadgeIDs)
            Most Recently Expired Badge Id: \(expiringBadgeId ?? "nil")
            Expired Gift Badge Id: \(newExpiredGiftBadgeID ?? "nil")
            Show Expiry On Home Screen: \(showExpiryOnHomeScreen)
            User Manually Cancelled Subscription: \(userManuallyCancelled)
            Display Badges On Profile: \(displayBadgesOnProfile)
        """)

        // Persist new values
        Self.setKnownUserSubscriptionBadgeIDs(badgeIDs: currentSubscriberBadgeIDs, transaction: transaction)
        Self.setKnownUserBoostBadgeIDs(badgeIDs: currentBoostBadgeIDs, transaction: transaction)
        Self.setKnownUserGiftBadgeIDs(badgeIDs: currentGiftBadgeIDs, transaction: transaction)
        Self.setMostRecentlyExpiredGiftBadgeID(badgeID: newExpiredGiftBadgeID, transaction: transaction)
        Self.setMostRecentlyExpiredBadgeID(badgeID: expiringBadgeId, transaction: transaction)
        Self.setShowExpirySheetOnHomeScreenKey(show: showExpiryOnHomeScreen, transaction: transaction)
        Self.setUserManuallyCancelledSubscription(userManuallyCancelled, transaction: transaction)
        Self.setDisplayBadgesOnProfile(displayBadgesOnProfile, transaction: transaction)
    }
}

// MARK: -

extension DonationSubscriptionManager {

    public enum RecurringSubscriptionPaymentType {
        case applePay(paymentMethodId: String)
        case creditOrDebitCard(paymentMethodId: String)
        case paypal(paymentMethodId: String)
        case sepa(paymentMethodId: String)
        case ideal(setupIntentId: String)

        public var paymentProcessor: DonationPaymentProcessor {
            switch self {
            case .applePay, .ideal, .sepa, .creditOrDebitCard:
                return .stripe
            case .paypal:
                return .braintree
            }
        }

        public var paymentMethod: DonationPaymentMethod {
            switch self {
            case .applePay: return .applePay
            case .creditOrDebitCard: return .creditOrDebitCard
            case .paypal: return .paypal
            case .sepa: return .sepa
            case .ideal: return .ideal
            }
        }
    }
}