Advertisement

← poker-tutor

Sources/PokerStore/SubscriptionStore.swift

13.0 KB Download file
import Foundation
import PokerSession
import Observation
#if os(Android)
import SkipFuse
#endif

#if canImport(StoreKit)
import StoreKit

/// Apple's half of the subscription.
///
/// Everything here is about talking to the App Store: what the subscription costs in the buyer's
/// own currency, buying it, restoring it on a second device, and noticing when it renews, lapses,
/// or is refunded. What it deliberately does NOT contain is any rule about who may play — that
/// lives in `FreeTrial` and `TrialTally`, is ours, and is testable without a network or an Apple
/// account. This class answers exactly one question for that code: `isSubscribed`.
///
/// The split matters because StoreKit cannot be exercised by the test suite. Anything decided in
/// here is decided somewhere no test can reach, so as little as possible is decided in here.
@Observable
@MainActor
public final class SubscriptionStore {

    /// The one thing sold — this product's, from `AppVariant`. ⚠️ Permanent once a build carrying
    /// it has been uploaded: a product ID cannot be reused or renamed, and the string must match
    /// App Store Connect exactly.
    public static var yearlyProductID: String { AppVariant.current.storeProductID }

    /// What the store is doing, for the paywall to show.
    public enum Status: Equatable, Sendable {
        case idle
        /// Fetching the price, or a purchase is in flight.
        case working
        /// Bought, but somebody else has to approve it — Ask to Buy on a child's account, or a
        /// bank asking for confirmation. Access is NOT granted yet. The transaction listener picks
        /// it up if and when it is approved, which may be days later.
        case waitingForApproval
        /// Something went wrong, in words a player can read.
        case failed(String)
    }

    /// Whether Apple says there is a live subscription. This is the only output the rest of the app
    /// reads. It starts false and stays false until Apple has actually been asked — see
    /// `hasCheckedWithApple` for why that distinction matters.
    public private(set) var isSubscribed = false

    /// Whether Apple has answered at all yet.
    ///
    /// Before the first answer, `isSubscribed` is false because nothing is known, not because
    /// somebody is unsubscribed. A paywall shown in that window would be shown to a paying
    /// customer, which is the worst thing this file can do. The app waits for this.
    public private(set) var hasCheckedWithApple = false

    /// The price, in the buyer's own currency and formatting — "$9.99", "£8.99". Nil until the
    /// product has been fetched. Never build this string by hand: the number, the symbol and where
    /// the symbol goes are all Apple's to decide, and they differ by storefront.
    public private(set) var displayPrice: String?

    public private(set) var status: Status = .idle

    /// Called on the main actor after Apple's answer changes. The app uses it to take a paywall
    /// down the moment a purchase lands, wherever it landed.
    @ObservationIgnored public var didChange: (@MainActor () -> Void)?

    @ObservationIgnored private let productID: String
    @ObservationIgnored private var product: Product?
    @ObservationIgnored private var listener: Task<Void, Never>?

    public init(productID: String = SubscriptionStore.yearlyProductID) {
        self.productID = productID
    }

    /// A store that never talks to Apple, with the answer already given.
    ///
    /// For the suite and for previews. Everything the app decides about who may play depends on
    /// `isSubscribed` and `hasCheckedWithApple`, and both are otherwise only reachable by making a
    /// real purchase — which would leave the gate itself, the part that can lock a paying customer
    /// out, as the one piece with no test behind it.
    public static func stub(subscribed: Bool, price: String? = nil) -> SubscriptionStore {
        let store = SubscriptionStore()
        store.isSubscribed = subscribed
        store.hasCheckedWithApple = true
        store.displayPrice = price
        return store
    }

    deinit { listener?.cancel() }

    // MARK: - Starting up

    /// Fetch the price, ask Apple what the buyer already owns, and start listening for changes.
    ///
    /// Safe to call more than once. Call it at launch, before any paywall could be shown.
    public func start() async {
        if listener == nil { listener = makeListener() }
        await refreshEntitlement()
        await loadProduct()
    }

    /// Watches for transactions that arrive without the app asking: a renewal, a refund, a
    /// purchase made on another device, or an Ask to Buy that a parent finally approved.
    ///
    /// Without this the app would only notice a lapsed subscription on the next cold launch, and
    /// would keep a refunded customer playing for free.
    private func makeListener() -> Task<Void, Never> {
        Task { [weak self] in
            for await update in Transaction.updates {
                guard let self else { return }
                if case .verified(let transaction) = update {
                    await transaction.finish()
                }
                await self.refreshEntitlement()
            }
        }
    }

    /// Ask Apple for the price. Failure here is not fatal and is not the buyer's fault — it is
    /// usually no network, occasionally a product that has not finished propagating after being
    /// created in App Store Connect.
    private func loadProduct() async {
        do {
            let products = try await Product.products(for: [productID])
            guard let found = products.first else {
                // The ID is wrong, or the product is not approved yet. Worth being loud about in
                // development; the paywall just shows no price.
                status = .failed(Copy.priceUnavailable)
                return
            }
            product = found
            displayPrice = found.displayPrice
            if case .failed = status { status = .idle }
        } catch {
            status = .failed(Copy.priceUnavailable)
        }
    }

    // MARK: - What Apple says the buyer owns

    /// Ask Apple what is currently owned and set `isSubscribed` from the answer.
    ///
    /// `currentEntitlements` already leaves out subscriptions that have expired, so what arrives
    /// here is live. Two things are still checked by hand: the transaction has to be *verified* —
    /// an unverified one is a forgery and must never open the table — and it must not have been
    /// revoked, which is what a refund looks like.
    public func refreshEntitlement() async {
        var owned = false
        for await result in Transaction.currentEntitlements {
            guard case .verified(let transaction) = result else { continue }
            guard transaction.productID == productID else { continue }
            guard transaction.revocationDate == nil else { continue }
            owned = true
        }
        isSubscribed = owned
        hasCheckedWithApple = true
        didChange?()
    }

    // MARK: - Buying

    /// Buy the subscription.
    ///
    /// Every branch of `purchase()` is handled on purpose. The two that are easy to get wrong are
    /// `.pending`, which must NOT grant access, and an unverified success, which must not either.
    public func purchase() async {
        guard let product else {
            // No price was ever fetched, so there is nothing to buy. Try once more — this is
            // usually somebody who opened the paywall while offline and has since reconnected.
            await loadProduct()
            guard product != nil else {
                status = .failed(Copy.priceUnavailable)
                return
            }
            await purchase()
            return
        }

        status = .working
        do {
            switch try await product.purchase() {
            case .success(let verification):
                guard case .verified(let transaction) = verification else {
                    // Signed by something that is not Apple. No access, and say nothing about how
                    // the check works.
                    status = .failed(Copy.purchaseFailed)
                    return
                }
                await transaction.finish()
                await refreshEntitlement()
                status = .idle

            case .pending:
                // Ask to Buy, or a bank wanting confirmation. Nothing is owed and nothing is
                // granted; the listener will pick it up if it is approved.
                status = .waitingForApproval

            case .userCancelled:
                // Not an error. Somebody changed their mind, and telling them off for it would be
                // strange.
                status = .idle

            @unknown default:
                status = .idle
            }
        } catch {
            status = .failed(Copy.purchaseFailed)
        }
    }

    /// Restore a subscription bought on another device, or before a reinstall.
    ///
    /// Apple requires this button to exist on any screen that sells a subscription, and rejects
    /// apps without it. `AppStore.sync()` prompts for a password, so it must only ever run because
    /// somebody tapped something — never on its own at launch.
    public func restore() async {
        status = .working
        do {
            try await AppStore.sync()
            await refreshEntitlement()
            status = isSubscribed ? .idle : .failed(Copy.nothingToRestore)
        } catch {
            // A cancelled password prompt lands here too, which is why this says nothing was found
            // rather than accusing anybody of an error.
            await refreshEntitlement()
            status = isSubscribed ? .idle : .failed(Copy.nothingToRestore)
        }
    }

    /// Clear a message the player has read.
    public func dismissMessage() {
        if case .failed = status { status = .idle }
        if case .waitingForApproval = status { status = .idle }
    }
}

/// Everything this file can say to a player.
///
/// Kept together and in plain English on purpose: the app's copy rules ban jargon, and "the
/// transaction could not be verified" is jargon wearing a suit. None of these blame the player for
/// something that is almost always a network or an account.
enum Copy {
    static let priceUnavailable =
        "The price could not be loaded just now. Check your connection and try again."
    static let purchaseFailed =
        "That did not go through, and you have not been charged. Please try again."
    static let nothingToRestore =
        "No subscription was found on this Apple Account."
}

#else

/// The store on a device with no App Store — Android, for now.
///
/// The same shape as the Apple one above, so nothing that sits on top of it has to know which
/// device it is on. It never says anybody is subscribed, because it has nobody to ask: Google Play
/// billing is the piece still to be built, and until it exists this class answers "not subscribed"
/// and explains why when somebody tries to buy. `hasCheckedWithApple` keeps its name so the gate
/// above it reads the same on every device; here it means "checked, and there is nothing to check".
@Observable
@MainActor
public final class SubscriptionStore {

    public static var yearlyProductID: String { AppVariant.current.storeProductID }

    public enum Status: Equatable, Sendable {
        case idle
        case working
        case waitingForApproval
        case failed(String)
    }

    public private(set) var isSubscribed = false
    public private(set) var hasCheckedWithApple = false
    public private(set) var displayPrice: String?
    public private(set) var status: Status = .idle

    /// Called on the main actor after Apple's answer changes. The app uses it to take a paywall
    /// down the moment a purchase lands, wherever it landed.
    @ObservationIgnored public var didChange: (@MainActor () -> Void)?

    public init(productID: String = SubscriptionStore.yearlyProductID) {}

    public static func stub(subscribed: Bool, price: String? = nil) -> SubscriptionStore {
        let store = SubscriptionStore()
        store.isSubscribed = subscribed
        store.hasCheckedWithApple = true
        store.displayPrice = price
        return store
    }

    public func start() async { hasCheckedWithApple = true }

    public func refreshEntitlement() async { hasCheckedWithApple = true }

    public func purchase() async { status = .failed(Copy.notOnThisDevice) }

    public func restore() async { status = .failed(Copy.notOnThisDevice) }

    public func dismissMessage() {
        if case .failed = status { status = .idle }
        if case .waitingForApproval = status { status = .idle }
    }
}

enum Copy {
    static let notOnThisDevice =
        "Subscriptions are not available on this device yet."
}

#endif