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

import CryptoKit
import LibSignalClient
import StoreKit

/// Responsible for In-App Purchases (IAP) that grant access to paid-tier Backups.
///
/// - Note
/// Backup payments are done via IAP using Apple as the payment processor, and
/// consequently payments management is done via Apple ID management in the iOS
/// Settings app rather than in-app UI.
///
/// - Note
/// An IAP subscription may only be started on a primary. However, that primary
/// may or may not be the same device as our current primary; that primary may
/// or may not even be an iOS device if the user migrated from Android to iOS.
///
/// - Important
/// Not to be confused with ``DonationSubscriptionManager``, which does many
/// similar things but designed around donations and profile badges.
public protocol BackupSubscriptionManager {
    typealias PurchaseResult = BackupSubscription.PurchaseResult
    typealias IAPSubscriberData = BackupSubscription.IAPSubscriberData

    // MARK: Fetch remote state

    /// Fetch the user's Backups subscription, if it exists. May downgrade the
    /// local `BackupPlan`, depending on the remote state of the subscription.
    func fetchAndMaybeDowngradeSubscription() async throws -> Subscription?

    // MARK: IAPSubscriberData

    /// Get the user's current IAP subscriber data, if present.
    func getIAPSubscriberData(tx: DBReadTransaction) -> IAPSubscriberData?

    /// Persist the given IAP subscriber data.
    ///
    /// - Important
    /// Generally, this type generates and manages the `iapSubscriberData`
    /// internally. The exception is "restoring" `iapSubscriberData` preserved
    /// in external storage and considered authoritative, such as one in Storage
    /// Service or a Backup.
    func restoreIAPSubscriberData(_ iapSubscriberData: IAPSubscriberData, tx: DBWriteTransaction)

    // MARK: Purchasing

    /// Returns the price for a Backups subscription, formatted for display.
    func subscriptionDisplayPrice() async throws -> String

    /// Attempts to purchase a Backups subscription for the first time, via
    /// StoreKit IAP.
    ///
    /// - Important
    /// If this method returns successfully, callers must subsequently call
    /// ``redeemSubscriptionIfNecessary()`` to redeem the newly-purchased IAP
    /// subscription.
    ///
    /// - Note
    /// While this should be called only for users who do not currently have a
    /// Backups subscription, StoreKit handles already-subscribed users
    /// gracefully by showing explanatory UI.
    func purchaseNewSubscription() async throws -> PurchaseResult

    // MARK: Redeeming

    /// Record, in response to an external state change, that we should attempt
    /// to redeem our Backups subscription.
    func setRedemptionAttemptIsNecessary(tx: DBWriteTransaction)

    /// Redeems a StoreKit Backups subscription with Signal servers for access
    /// to paid-tier Backup credentials, if there exists a StoreKit transaction
    /// we have not yet redeemed.
    ///
    /// - Note
    /// This method serializes callers, is safe to call repeatedly, and returns
    /// quickly if there is not a transaction we have yet to redeem.
    func redeemSubscriptionIfNecessary() async throws
}

// MARK: -

public enum BackupSubscription {

    /// Bundles data associated with a user's IAP subscription.
    public struct IAPSubscriberData {
        /// An identifier generated by an IAP provider identifying the user's
        /// subscription in the IAP system.
        public enum IAPSubscriptionId {
            /// An `originalTransactionId` from an iOS StoreKit `Transaction`.
            case originalTransactionId(UInt64)

            /// A `purchaseToken` identifying an Android Play Store subscription.
            case purchaseToken(String)
        }

        /// A client-generated ID identifying this subscriber to Signal's
        /// services. Like a `donationSubscriberId` (see: `DonationSubscriptionManager`),
        /// this value is not associated with a user's account.
        ///
        /// - Note
        /// This value may have been generated by this client, or may have been
        /// generated by a former primary device for this account and later
        /// restored onto this device (e.g., via Storage Service or a backup).
        public let subscriberId: Data

        /// See doc on `IAPSubscriptionId`.
        public let iapSubscriptionId: IAPSubscriptionId

        fileprivate func matches(storeKitTransaction: Transaction) -> Bool {
            switch iapSubscriptionId {
            case .originalTransactionId(let originalTransactionId):
                return storeKitTransaction.originalID == originalTransactionId
            case .purchaseToken:
                return false
            }
        }
    }

    /// Describes the result of initiating a StoreKit purchase.
    public enum PurchaseResult {
        /// Purchase was successful. Contains the result of the purchase's
        /// redemption with Signal servers.
        ///
        /// - Note
        /// Success also covers if the user attempted to purchase this
        /// subscription, but was already subscribed.
        case success

        /// Purchase is pending external action, such as approval when "Ask to
        /// Buy" is enabled.
        case pending

        /// The user cancelled the purchase.
        case userCancelled
    }
}

// MARK: -

final class BackupSubscriptionManagerImpl: BackupSubscriptionManager {

    private enum Constants {
        /// This value corresponds to our IAP config set up in App Store
        /// Connect, and must not change!
        static let paidTierBackupsProductId = "backups.mediatier"
    }

    private let logger = PrefixedLogger(prefix: "[Backups][Sub]")

    private let appContext: AppContext
    private let backupPlanManager: BackupPlanManager
    private let backupSubscriptionIssueStore: BackupSubscriptionIssueStore
    private let backupSubscriptionRedeemer: BackupSubscriptionRedeemer
    private let dateProvider: DateProvider
    private let db: any DB
    private let networkManager: NetworkManager
    private let storageServiceManager: StorageServiceManager
    private let store: Store
    private let tsAccountManager: TSAccountManager
    private let whoAmIManager: WhoAmIManager

    init(
        appContext: AppContext,
        backupPlanManager: BackupPlanManager,
        backupSubscriptionIssueStore: BackupSubscriptionIssueStore,
        backupSubscriptionRedeemer: BackupSubscriptionRedeemer,
        dateProvider: @escaping DateProvider,
        db: any DB,
        networkManager: NetworkManager,
        storageServiceManager: StorageServiceManager,
        tsAccountManager: TSAccountManager,
        whoAmIManager: WhoAmIManager,
    ) {
        self.appContext = appContext
        self.backupPlanManager = backupPlanManager
        self.backupSubscriptionIssueStore = backupSubscriptionIssueStore
        self.backupSubscriptionRedeemer = backupSubscriptionRedeemer
        self.dateProvider = dateProvider
        self.db = db
        self.networkManager = networkManager
        self.storageServiceManager = storageServiceManager
        self.store = Store()
        self.tsAccountManager = tsAccountManager
        self.whoAmIManager = whoAmIManager

        Task { await doStartupLogging() }
        listenForTransactionUpdates()
    }

    private func doStartupLogging() async {
        let latestTransaction = await self.latestTransaction(onlyEntitling: false)
        let latestEntitlingTransaction = await self.latestTransaction(onlyEntitling: true)
        let localIAPSubscriberData: IAPSubscriberData?
        let localBackupPlan: BackupPlan
        (
            localIAPSubscriberData,
            localBackupPlan,
        ) = db.read {
            (
                store.getIAPSubscriberData(tx: $0),
                backupPlanManager.backupPlan(tx: $0),
            )
        }

        let logger = logger.suffixed(with: "BackupPlan: \(localBackupPlan)")

        if let latestEntitlingTransaction {
            if let localIAPSubscriberData, localIAPSubscriberData.matches(storeKitTransaction: latestEntitlingTransaction) {
                logger.info("Active StoreKit, matches local IAPSubscriberData.")
            } else {
                logger.info("Active StoreKit, does not match local IAPSubscriberData.")
            }
        } else if let latestTransaction {
            if let localIAPSubscriberData, localIAPSubscriberData.matches(storeKitTransaction: latestTransaction) {
                logger.info("Inactive StoreKit, matches local IAPSubscriberData.")
            } else {
                logger.info("Inactive StoreKit, does not match local IAPSubscriberData.")
            }
        } else if localIAPSubscriberData != nil {
            logger.info("No StoreKit, but local IAPSubscriberData.")
        } else {
            logger.info("No StoreKit or local IAPSubscriberData.")
        }
    }

    // MARK: -

    /// This should never throw, nor be missing.
    private func getPaidTierProduct() async throws -> Product {
        struct MissingProductError: Error {}

        do {
            guard
                let product = try await Product.products(
                    for: [Constants.paidTierBackupsProductId],
                ).first
            else {
                throw MissingProductError()
            }

            return product
        } catch is MissingProductError {
            throw OWSAssertionError(
                "Paid-tier product missing from StoreKit!",
                logger: logger,
            )
        } catch {
            throw OWSAssertionError(
                "Failed to get paid-tier product from StoreKit! \(error)",
                logger: logger,
            )
        }
    }

    /// Returns the latest `Transaction` for the the StoreKit "paid tier"
    /// subscription, or `nil` if this IAP account has never subscribed.
    ///
    /// - Parameter onlyEntitling
    /// If `true`, returns the latest `Transaction` if it currently entitles us
    /// to the subscription.
    private func latestTransaction(onlyEntitling: Bool) async -> Transaction? {
        let transactionResult: VerificationResult<Transaction>? = if onlyEntitling {
            await Transaction.currentEntitlement(for: Constants.paidTierBackupsProductId)
        } else {
            await Transaction.latest(for: Constants.paidTierBackupsProductId)
        }

        guard let transactionResult else {
            return nil
        }

        guard let transaction = try? transactionResult.payloadValue else {
            owsFailDebug(
                "Transaction was unverified! onlyEntitling: \(onlyEntitling)",
                logger: logger,
            )
            return nil
        }

        return transaction
    }

    /// `Transaction.updates` is how the app is informed by StoreKit about
    /// transactions other than ones we completed inline via `.purchase()`. This
    /// covers scenarios like renewals and "Ask to Buy" where a transaction may
    /// occur asynchronously; we'll learn about those transactions here.
    ///
    /// If we learn about a transaction here that entitles us to a subscription
    /// we'll attempt a redemption. We don't need to be more precise than that,
    /// since we already regularly check if we need to perform a redemption and
    /// track relevant state on our own.
    private func listenForTransactionUpdates() {
        Task.detached { [weak self] in
            for await transactionResult in Transaction.updates {
                /// Guard on `self` in here, since we're in an async stream.
                guard let self else { return }

                guard let transaction = try? transactionResult.payloadValue else {
                    owsFailDebug(
                        "Transaction from update was unverified!",
                        logger: logger,
                    )
                    continue
                }

                if
                    let latestEntitlingTransaction = await latestTransaction(onlyEntitling: true),
                    latestEntitlingTransaction.id == transaction.id
                {
                    logger.info("Transaction update is for latest entitling transaction; attempting subscription redemption.")

                    do {
                        /// This transaction entitles us to a subscription, so
                        /// let's attempt to do so. Because we know we have a
                        /// novel transaction, we know redemption is necessary.
                        await db.awaitableWrite { tx in
                            self.setRedemptionAttemptIsNecessary(tx: tx)
                        }
                        try await redeemSubscriptionIfNecessary()
                    } catch {
                        owsFailDebug(
                            "Failed to redeem subscription: \(error)",
                            logger: logger,
                        )
                    }
                } else {
                    logger.info("Transaction update is not for latest entitling subscription.")
                }

                /// All transactions should be finished eventually, so let's
                /// make sure we do so.
                await transaction.finish()
            }
        }
    }

    // MARK: -

    func getIAPSubscriberData(tx: DBReadTransaction) -> IAPSubscriberData? {
        store.getIAPSubscriberData(tx: tx)
    }

    func restoreIAPSubscriberData(_ iapSubscriberData: IAPSubscriberData, tx: DBWriteTransaction) {
        store.setIAPSubscriberData(iapSubscriberData, tx: tx)
    }

    // MARK: -

    func fetchAndMaybeDowngradeSubscription() async throws -> Subscription? {
        guard let subscriberID = db.read(block: { store.getIAPSubscriberData(tx: $0)?.subscriberId }) else {
            return nil
        }

        return try await _fetchAndMaybeDowngradeSubscription(
            subscriberID: subscriberID,
            subscriptionFetcher: SubscriptionFetcher(networkManager: networkManager),
        )
    }

    private func _fetchAndMaybeDowngradeSubscription(
        subscriberID: Data,
        subscriptionFetcher: SubscriptionFetcher,
    ) async throws -> Subscription? {
        let subscription = try await subscriptionFetcher.fetch(subscriberID: subscriberID)
        let backupEntitlement = try await whoAmIManager.makeWhoAmIRequest().entitlements.backup

        await db.awaitableWrite { tx in
            warnSubscriptionFailedToRenewIfNecessary(
                fetchedSubscription: subscription,
                tx: tx,
            )

            downgradeBackupPlanIfNecessary(
                fetchedSubscription: subscription,
                backupEntitlement: backupEntitlement,
                tx: tx,
            )
        }

        return subscription
    }

    /// Warn the user if their subscription has failed to renew.
    private func warnSubscriptionFailedToRenewIfNecessary(
        fetchedSubscription subscription: Subscription?,
        tx: DBWriteTransaction,
    ) {
        guard let subscription else { return }

        switch subscription.status {
        case .active, .canceled:
            break
        case .unrecognized:
            owsFailDebug("Unexpected subscription status for IAP subscription! \(subscription.status)")
        case .pastDue:
            // The .pastDue status is returned if we're in the IAP "billing
            // retry", period, which indicates something has gone wrong with a
            // subscription renewal.
            backupSubscriptionIssueStore.setShouldWarnIAPSubscriptionFailedToRenew(
                endOfCurrentPeriod: subscription.endOfCurrentPeriod,
                tx: tx,
            )
        }
    }

    /// While we store locally a `BackupPlan`, the ultimate source of truth as
    /// to the state our our Backup subscription/plan is remote. Any time we
    /// fetch that remote state could be the moment we learn that something
    /// has changed, such that we should "downgrade" our local `BackupPlan`.
    ///
    /// For example, something may have changed with our subscription, or our
    /// Backup entitlement may have expired.
    ///
    /// - Note
    /// Upgrading requires redeeming a subscription that has renewed, which can
    /// only happen while the app is running (rather than externally while the
    /// app wasn't running). Consequently, the redemption code sets `BackupPlan`
    /// for the upgrade case.
    private func downgradeBackupPlanIfNecessary(
        fetchedSubscription subscription: Subscription?,
        backupEntitlement: WhoAmIRequestFactory.Responses.WhoAmI.Entitlements.BackupEntitlement?,
        tx: DBWriteTransaction,
    ) {
        let currentBackupPlan = backupPlanManager.backupPlan(tx: tx)

        enum Downgrade {
            case toFreeTier
            case toPaidExpiringSoon(optimizeLocalStorage: Bool)
        }
        let downgrade: Downgrade? = {
            /// The value of optimizeLocalStorage, if the current BackupPlan
            /// is .paid. nil otherwise.
            let paidTierOptimizeLocalStorage: Bool?
            switch currentBackupPlan {
            case .paidAsTester:
                // Handled by `BackupTestFlightEntitlementManager`.
                return nil
            case .disabled, .disabling, .free:
                // Nothing to downgrade.
                return nil
            case .paid(let optimizeLocalStorage):
                paidTierOptimizeLocalStorage = optimizeLocalStorage
            case .paidExpiringSoon:
                paidTierOptimizeLocalStorage = nil
            }

            guard
                let backupEntitlement,
                Date(timeIntervalSince1970: backupEntitlement.expirationSeconds) > dateProvider()
            else {
                // Our entitlement has expired, so we must downgrade to the
                // free tier. (Paid-tier operations will no longer work!)
                //
                // This likely means the subscription failed to renew, and
                // the "grace period" during which the entitlement persists
                // after the subscription period ends has now elapsed
                // without the user fixing the renewal issue.
                logger.warn("Backup entitlement missing or expired: downgrading to free tier.")
                return .toFreeTier
            }

            let subscriptionCancelAtEndOfPeriod: Bool
            switch subscription?.status {
            case nil, .canceled:
                // This means the subscription is "expired", which happens in
                // two ways:
                // - The user manually canceled, and their last-subscribed
                //   period has now elapsed.
                // - A renewal failed, and Apple's given up trying to get
                //   the user to resolve the issue. (Note that Apple will
                //   try for 60d, so our Backup entitlement will have
                //   generally have expired before this happens.)
                //
                // "Expiration" is the trigger for Chat Service to wipe its
                // knowledge of the subscriber ID, hence we can infer that a
                // missing subscription expired. If that wiping hasn't happened
                // yet, Chat Service will return the `.canceled` status.
                //
                // If the subscription has expired, downgrade to free.
                logger.warn("IAP subscription missing or expired: downgrading to free tier.")
                return .toFreeTier
            case .active, .pastDue, .unrecognized:
                subscriptionCancelAtEndOfPeriod = subscription!.cancelAtEndOfPeriod
            }

            // At this point we have a non-expired subscription, and we have a
            // Backup entitlement, so things are generally good.

            if
                let paidTierOptimizeLocalStorage,
                subscriptionCancelAtEndOfPeriod
            {
                // We're on the paid tier, but our subscription won't renew.
                logger.warn("IAP subscription not renewing: downgrading to expiring soon.")
                return .toPaidExpiringSoon(optimizeLocalStorage: paidTierOptimizeLocalStorage)
            }

            return nil
        }()

        if let downgrade {
            let downgradedBackupPlan: BackupPlan = switch downgrade {
            case .toFreeTier: .free
            case .toPaidExpiringSoon(let optimizeLocalStorage): .paidExpiringSoon(optimizeLocalStorage: optimizeLocalStorage)
            }

            backupPlanManager.setBackupPlan(downgradedBackupPlan, tx: tx)

            switch downgrade {
            case .toFreeTier:
                // Subscription issues no longer relevant!
                backupSubscriptionIssueStore.setStopWarningIAPSubscriptionAlreadyRedeemed(tx: tx)
                backupSubscriptionIssueStore.setStopWarningIAPSubscriptionNotFoundLocally(tx: tx)

                // Warn that it expired, though.
                backupSubscriptionIssueStore.setShouldWarnIAPSubscriptionExpired(true, tx: tx)
            case .toPaidExpiringSoon:
                break
            }
        }
    }

    // MARK: - Purchase new subscription

    func subscriptionDisplayPrice() async throws -> String {
        owsPrecondition(!BuildFlags.Backups.avoidStoreKitForTesters)

        return try await getPaidTierProduct().displayPrice
    }

    func purchaseNewSubscription() async throws -> PurchaseResult {
        owsPrecondition(!BuildFlags.Backups.avoidStoreKitForTesters)

        switch try await getPaidTierProduct().purchase() {
        case .success(let purchaseResult):
            switch purchaseResult {
            case .verified:
                // We've successfully purchased, which means a redemption
                // attempt is necessary.
                await db.awaitableWrite { tx in
                    setRedemptionAttemptIsNecessary(tx: tx)
                }
                return .success
            case .unverified:
                throw OWSAssertionError(
                    "Unverified successful purchase result!",
                    logger: logger,
                )
            }
        case .userCancelled:
            logger.info("User cancelled subscription purchase.")
            return .userCancelled
        case .pending:
            logger.warn("Subscription purchase is pending; expect redemption if it is approved.")
            return .pending
        @unknown default:
            throw OWSAssertionError(
                "Unknown purchase result!",
                logger: logger,
            )
        }
    }

    // MARK: -

    /// We generally only attempt redemptions 1x/3d, but on occasion we know
    /// that a redemption is necessary and we should bypass that debounce.
    func setRedemptionAttemptIsNecessary(tx: DBWriteTransaction) {
        store.wipeLastRedemptionNecessaryCheck(tx: tx)
    }

    // MARK: - Redeem subscription

    /// - Note
    /// `_redeemSubscriptionIfNecessary()` uses persisted state, so latter
    /// callers may be able to short-circuit based on state persisted by an
    /// earlier caller.
    private let redemptionTaskQueue = ConcurrentTaskQueue(concurrentLimit: 1)

    func redeemSubscriptionIfNecessary() async throws {
        return try await redemptionTaskQueue.run {
            try await self._redeemSubscriptionIfNecessary()
        }
    }

    private func _redeemSubscriptionIfNecessary() async throws {
        guard appContext.isMainApp else {
            throw OWSAssertionError("Shouldn't be redeeming subscripions outside the main app process!")
        }

        if
            let preexistingRedemptionContext = db.read(block: {
                return BackupSubscriptionRedemptionContext.fetch(tx: $0)
            })
        {
            // We have a persisted redemption context, which means a previous
            // redemption was interrupted. Finish it, then try again.
            //
            // It's very likely that once we've finished the interrupted one
            // the recursive call will no-op.
            try await backupSubscriptionRedeemer.redeem(context: preexistingRedemptionContext)
            try await _redeemSubscriptionIfNecessary()
        }

        /// Wait on any in-progress restores, since there's a chance we're
        /// restoring subscriber data.
        do {
            try await storageServiceManager.waitForPendingRestores()
        } catch let error as CancellationError {
            throw error
        } catch {
            // ignore other errors; we want to proceed if we couldn't restore
        }

        let (
            isRegisteredPrimaryDevice,
            persistedIAPSubscriberData,
        ) = db.read { tx in
            return (
                tsAccountManager.registrationState(tx: tx).isRegisteredPrimaryDevice,
                store.getIAPSubscriberData(tx: tx),
            )
        }

        guard isRegisteredPrimaryDevice else {
            return
        }

        let localEntitlingTransaction = await latestTransaction(onlyEntitling: true)

        let localIAPSubscriberData: IAPSubscriberData
        var registerNewSubscriberIdIfSubscriptionMissing = false
        if
            let localEntitlingTransaction,
            let persistedIAPSubscriberData
        {
            if persistedIAPSubscriberData.matches(storeKitTransaction: localEntitlingTransaction) {
                localIAPSubscriberData = persistedIAPSubscriberData

                /// We have an active local subscription that matches our persisted
                /// identifiers. Happy path!
                ///
                /// However, we may need to register a new subscriber ID.
                ///
                /// If you start a subscription with StoreKit, cancel it (and
                /// let it expire), then resubscribe, StoreKit uses the same
                /// `originalTransactionId` for the previous and current
                /// iterations of the subscription.
                ///
                /// That's an issue because Signal's servers wipe the
                /// `subscriberID -> originalTransactionId` eventually for
                /// expired StoreKit subscriptions, thereby rendering that
                /// `subscriberID` useless; we'll fail to find a `Subscription`
                /// for that `subscriberID` even though our subscription is
                /// active again.
                ///
                /// So, if we later find the `Subscription` is missing for this
                /// `subscriberId`, register a new one.
                registerNewSubscriberIdIfSubscriptionMissing = true
            } else {
                /// We have an active local subscription, but it doesn't match
                /// our persisted identifers. That must mean we initiated a
                /// subscription on another device (either with a different App
                /// Store account, or even on an Android) and restored it here,
                /// and also have subscribed with our local App Store account.
                ///
                /// As a rule we prefer to rely on the local subscription, so
                /// we'll "claim" it by generating and registering identifiers
                /// for the local subscription!
                localIAPSubscriberData = try await registerNewSubscriberId(
                    originalTransactionId: localEntitlingTransaction.originalID,
                )
            }
        } else if let localEntitlingTransaction {
            /// We have a local subscription, but don't yet have any persisted
            /// identifiers. (This might be the first time we're subscribing!)
            /// Generate and register them now!
            localIAPSubscriberData = try await registerNewSubscriberId(
                originalTransactionId: localEntitlingTransaction.originalID,
            )
        } else if let persistedIAPSubscriberData {
            /// We don't have an active subscription locally, but we do have
            /// identifiers for one. Those identifiers may be for a subscription
            /// started by the current IAP account but since expired, or they
            /// may be for a subscription started by another IAP account (e.g.,
            /// a different Apple ID on this or another device, or an Android
            /// from which we restored).
            ///
            /// We'll go ahead and continue to redeem these identifiers if
            /// possible, but because they don't match the local IAP account
            /// we'll persist a warning below.
            localIAPSubscriberData = persistedIAPSubscriberData
        } else {
            /// We don't have an active local subscription, nor do we have
            /// subscription IDs for some other subscription. Nothing to do!
            return
        }

        await reconcileIAPNotFoundLocallyWarnings(localIAPSubscriberData: localIAPSubscriberData)

        let subscriptionRedemptionNecessaryChecker = SubscriptionRedemptionNecessityChecker<
            BackupSubscriptionRedemptionContext,
        >(
            checkerStore: store,
            dateProvider: dateProvider,
            db: db,
            logger: logger,
            networkManager: networkManager,
            tsAccountManager: tsAccountManager,
        )

        try await subscriptionRedemptionNecessaryChecker.redeemSubscriptionIfNecessary(
            fetchSubscriptionBlock: { db, subscriptionFetcher -> (subscriberID: Data, subscription: Subscription)? in
                if
                    let subscriberID = db.read(block: { store.getIAPSubscriberData(tx: $0)?.subscriberId }),
                    let subscription = try await _fetchAndMaybeDowngradeSubscription(
                        subscriberID: subscriberID,
                        subscriptionFetcher: subscriptionFetcher,
                    )
                {
                    return (subscriberID, subscription)
                }

                if
                    let localEntitlingTransaction,
                    registerNewSubscriberIdIfSubscriptionMissing
                {
                    // See comments above on registerNewSubscriberIdIfSubscriptionMissing.
                    logger.info("Registering new subscriber ID for active local IAP, remote subscription was missing!")

                    let newSubscriberId = try await registerNewSubscriberId(
                        originalTransactionId: localEntitlingTransaction.originalID,
                    ).subscriberId

                    if
                        let subscription = try await _fetchAndMaybeDowngradeSubscription(
                            subscriberID: newSubscriberId,
                            subscriptionFetcher: subscriptionFetcher,
                        )
                    {
                        return (newSubscriberId, subscription)
                    } else {
                        owsFailDebug("Subscription missing, but we just registered a new subscriber ID!")
                    }
                }

                return nil
            },
            parseEntitlementExpirationBlock: { accountEntitlements, _ in
                return accountEntitlements.backup?.expirationSeconds
            },
            saveRedemptionJobBlock: { subscriberId, subscription, tx -> BackupSubscriptionRedemptionContext in
                let redemptionContext = BackupSubscriptionRedemptionContext(
                    subscriberId: subscriberId,
                    subscriptionEndOfCurrentPeriod: subscription.endOfCurrentPeriod,
                )
                redemptionContext.upsert(tx: tx)
                return redemptionContext
            },
            startRedemptionJobBlock: { redemptionContext async throws in
                // Note that this step, if successful, will set BackupPlan.
                try await backupSubscriptionRedeemer.redeem(context: redemptionContext)
            },
        )
    }

    /// Generate a new subscriber ID, and register it with the server to be
    /// associated with the given StoreKit "original transaction ID" for a
    /// subscription. Persists and returns the new subscriber ID.
    private func registerNewSubscriberId(
        originalTransactionId: UInt64,
    ) async throws -> IAPSubscriberData {
        logger.info("Generating and registering new Backups subscriber ID!")

        let newSubscriberId: Data = Randomness.generateRandomBytes(32)

        /// First, we tell the server (unauthenticated) that a new subscriber ID
        /// exists. At this point, it won't be associated with anything.
        let registerSubscriberIdResponse = try await networkManager.asyncRequest(
            .registerSubscriberId(subscriberId: newSubscriberId),
        )

        guard registerSubscriberIdResponse.responseStatusCode == 200 else {
            throw registerSubscriberIdResponse.asError()
        }

        /// Next, we tell the server (unauthenticated) to associate the
        /// subscriber ID with the "original transaction ID" of an IAP.
        ///
        /// Importantly, this request is safe to make repeatedly, with any
        /// combination of `subscriberId` and `originalTransactionId`.
        let associateIdsResponse = try await networkManager.asyncRequest(
            .associateSubscriberId(
                newSubscriberId,
                withOriginalTransactionId: originalTransactionId,
            ),
        )

        guard associateIdsResponse.responseStatusCode == 200 else {
            throw associateIdsResponse.asError()
        }

        let newSubscriberData = IAPSubscriberData(
            subscriberId: newSubscriberId,
            iapSubscriptionId: .originalTransactionId(originalTransactionId),
        )

        /// Our subscription is now set up on the service, and we should record
        /// it locally!
        await db.awaitableWrite { tx in
            store.setIAPSubscriberData(newSubscriberData, tx: tx)
        }

        /// We store the subscriber data in Storage Service, so let's kick off
        /// that backup now.
        storageServiceManager.recordPendingLocalAccountUpdates()

        return newSubscriberData
    }

    /// We warn the user if their `IAPSubscriberData` doesn't correspond to the
    /// local-device IAP account. This method manages setting or clearing that
    /// warning as appropriate.
    private func reconcileIAPNotFoundLocallyWarnings(
        localIAPSubscriberData: IAPSubscriberData,
    ) async {
        let (
            isWarningIAPNotFoundLocally,
            backupPlan,
        ): (Bool, BackupPlan) = db.read { tx in
            return (
                backupSubscriptionIssueStore.shouldShowIAPSubscriptionNotFoundLocallyWarning(tx: tx),
                backupPlanManager.backupPlan(tx: tx),
            )
        }

        if
            let latestTransaction = await latestTransaction(onlyEntitling: false),
            localIAPSubscriberData.matches(storeKitTransaction: latestTransaction)
        {
            // Our local IAPSubscriberData came from a subscription by the local
            // IAP account: clear any "not found locally" warnings.
            if isWarningIAPNotFoundLocally {
                await db.awaitableWrite { tx in
                    backupSubscriptionIssueStore.setStopWarningIAPSubscriptionNotFoundLocally(tx: tx)
                }
            }
            return
        }

        // Our local IAPSubscriberData doesn't match a subscription from the
        // local IAP account. We may want to save a warning.

        if isWarningIAPNotFoundLocally {
            // Already warning!
            return
        }

        switch backupPlan {
        case .free, .paidAsTester:
            // We never discard IAPSubscriberData, even when we downgrade. If
            // we're on the free or TestFlight plans, we don't need to warn.
            return
        case .disabling, .disabled, .paid, .paidExpiringSoon:
            break
        }

        await db.awaitableWrite { tx in
            backupSubscriptionIssueStore.setShouldWarnIAPSubscriptionNotFoundLocally(tx: tx)
        }
    }

    // MARK: - Persistence

    private struct Store: SubscriptionRedemptionNecessityCheckerStore {
        private enum Keys {
            /// - SeeAlso ``BackupSubscription/IAPSubscriberData/subscriberId``
            static let subscriberId = "subscriberId"

            /// - SeeAlso ``BackupSubscription/IAPSubscriberData/subscriptionId``
            static let originalTransactionId = "originalTransactionId"

            /// - SeeAlso ``BackupSubscription/IAPSubscriberData/subscriptionId``
            static let purchaseToken = "purchaseToken"

            /// The last time we checked if redemption is necessary.
            ///
            /// Used by `SubscriptionRedemptionNecessityCheckerStore`.
            static let lastRedemptionNecessaryCheck = "lastRedemptionNecessaryCheck"
        }

        private let kvStore: KeyValueStore

        init() {
            self.kvStore = KeyValueStore(collection: "BackupSubscriptionManager")
        }

        // MARK: -

        func getIAPSubscriberData(tx: DBReadTransaction) -> IAPSubscriberData? {
            guard let subscriberId = kvStore.getData(Keys.subscriberId, transaction: tx) else {
                return nil
            }

            if let originalTransactionId = kvStore.getUInt64(Keys.originalTransactionId, transaction: tx) {
                return IAPSubscriberData(
                    subscriberId: subscriberId,
                    iapSubscriptionId: .originalTransactionId(originalTransactionId),
                )
            } else if let purchaseToken = kvStore.getString(Keys.purchaseToken, transaction: tx) {
                return IAPSubscriberData(
                    subscriberId: subscriberId,
                    iapSubscriptionId: .purchaseToken(purchaseToken),
                )
            }

            owsFailDebug("Had subscriber ID, but missing IAP subscription ID!")
            return nil
        }

        func setIAPSubscriberData(_ iapSubscriberData: IAPSubscriberData, tx: DBWriteTransaction) {
            kvStore.setData(iapSubscriberData.subscriberId, key: Keys.subscriberId, transaction: tx)

            switch iapSubscriberData.iapSubscriptionId {
            case .originalTransactionId(let originalTransactionId):
                kvStore.removeValue(forKey: Keys.purchaseToken, transaction: tx)
                kvStore.setUInt64(originalTransactionId, key: Keys.originalTransactionId, transaction: tx)
            case .purchaseToken(let purchaseToken):
                kvStore.removeValue(forKey: Keys.originalTransactionId, transaction: tx)
                kvStore.setString(purchaseToken, key: Keys.purchaseToken, transaction: tx)
            }
        }

        // MARK: - SubscriptionRedemptionNecessityCheckerStore

        func getLastRedemptionNecessaryCheck(tx: DBReadTransaction) -> Date? {
            return kvStore.getDate(Keys.lastRedemptionNecessaryCheck, transaction: tx)
        }

        func setLastRedemptionNecessaryCheck(_ now: Date, tx: DBWriteTransaction) {
            kvStore.setDate(now, key: Keys.lastRedemptionNecessaryCheck, transaction: tx)
        }

        func wipeLastRedemptionNecessaryCheck(tx: DBWriteTransaction) {
            kvStore.removeValue(forKey: Keys.lastRedemptionNecessaryCheck, transaction: tx)
        }
    }
}

// MARK: -

private extension TSRequest {
    static func registerSubscriberId(subscriberId: Data) -> TSRequest {
        return OWSRequestFactory.setSubscriberID(subscriberId)
    }

    static func associateSubscriberId(
        _ subscriberId: Data,
        withOriginalTransactionId originalTransactionId: UInt64,
    ) -> TSRequest {
        var request = TSRequest(
            url: URL(string: "v1/subscription/\(subscriberId.asBase64Url)/appstore/\(originalTransactionId)")!,
            method: "POST",
            parameters: nil,
        )
        request.auth = .anonymous
        request.applyRedactionStrategy(.redactURL())
        return request
    }
}