import Foundation import Observation import StoreKit import os /// **Lanework Pro's entitlement** — the thin StoreKit 2 adapter over the pure decision in /// `Tier.resolve(from:now:)` (12-editions.md ▸ The entitlement). /// /// ### The open path never waits on this, and never reaches the network through it /// /// "Pro state is read from StoreKit's signed on-device transaction store at board-session /// composition — the open path gains no network dependency" (12). That is enforced by shape, not by /// care: what a composing board session reads is `tier`, a synchronous computed property over /// `facts`, which is an in-memory value restored from `UserDefaults` at init. No `await`, no /// `AsyncSequence`, no `AppStore.sync()`, no product load. The two methods that *do* touch StoreKit /// — `start()` and `refreshFromLocalTransactions()` — are called from the app's launch and from the /// Settings Pro section, and both only ever *write* `facts` for a later composition to read. /// /// Even those two are local reads. `Transaction.currentEntitlements` is StoreKit's own signed /// on-device transaction store; it answers offline, which is the whole reason 12 could rule the open /// path network-free while still gating on a subscription. The genuinely networked calls — loading /// products for their localized price, `AppStore.sync()` — live in `ProStorefront`, which only the /// Settings section ever builds. /// /// ### A lapse never rebinds an open session /// /// "A lapse never interrupts an open session: an open board finishes with the provider it composed; /// the next open composes the native stack over inert `.git`" (12). This type is `@Observable` /// because the Settings section renders from it — and *nothing else* observes it. `AppModel` reads /// it through `currentTier`, once, inside `beginSession`; the answer is then a `let` on /// `AppModel.BoardSession`. There is deliberately no path from a `facts` change back into an open /// session: no observer registration, no notification, no delegate. Adding one would be the bug, not /// the feature. /// /// ### Why the facts are cached rather than re-derived /// /// See `SubscriptionFacts` — in short, StoreKit ages an expired subscription out of /// `currentEntitlements` locally, so an offline device and a real lapse are indistinguishable from /// that property alone, and 12's offline grace has to tell them apart. The cache is what "the last /// known state" means, and `adopt(_:)` is what "until StoreKit actually refreshes and answers" means. @MainActor @Observable public final class ProEntitlement { // MARK: The facts /// What StoreKit last told the app, cached across launches. /// /// `private(set)`: every write goes through `adopt(_:)`, which is also the write-through to /// `UserDefaults`. A second way to set this would be a second way for the cache and the memory to /// disagree. public private(set) var facts: SubscriptionFacts /// **The composition-time read.** Synchronous, local, and the only member the board-open path /// ever touches — see the type's note. /// /// `Date()` rather than an injected clock: the *decision* takes its date as a parameter and is /// tested that way (`Tier.resolve`), so the one place a real clock has to enter is here, at the /// edge, where there is nothing left to get wrong. public var tier: Tier { Tier.resolve(from: facts, now: Date()) } // MARK: Storage @ObservationIgnored private let defaults: UserDefaults /// The `Transaction.updates` listener, held so it can be cancelled and so `start()` is idempotent. @ObservationIgnored private var updatesTask: Task? private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "entitlement") /// The app builds one of these over `UserDefaults.standard`; a test passes its own, for the /// reason every other injected store in this app takes its home as a parameter (`AppStateHome`) /// — a suite that wrote the real domain would be editing the developer's own subscription state. /// /// **The initializer reads the cache and nothing else.** It starts no task and touches no /// StoreKit API, so constructing an `AppModel` — which a unit-test host does on every launch — /// costs one `UserDefaults` read. public init(defaults: UserDefaults = .standard) { self.defaults = defaults facts = Self.cachedFacts(in: defaults) } deinit { updatesTask?.cancel() } // MARK: - Adopting an answer /// Records what StoreKit said, in memory and in the cache. /// /// **This is the "until StoreKit actually refreshes and answers" of 12's offline-grace rule**, in /// both directions. Handed live facts it moves the expiry forward, so a renewal the device just /// learned about ends any hold that was standing. Handed `.none` it ends the entitlement outright, /// which is what a *definitive* withdrawal looks like: a refund, a revocation, or a subscription /// group whose status reads expired (`ProStorefront.reconcile`). What it is never called with is /// "`currentEntitlements` came back empty" — see `refreshFromLocalTransactions()` for why that is /// silence rather than an answer. /// /// Public because the Settings section's storefront is the other half of this: the networked /// reads live over there (12: the open path stays local), and this is where their conclusions /// land. public func adopt(_ newFacts: SubscriptionFacts) { guard newFacts != facts else { return } facts = newFacts Self.cache(newFacts, in: defaults) Self.logger.debug("entitlement facts adopted; tier is now \(self.tier.rawValue, privacy: .public)") } // MARK: - StoreKit /// Begins listening for transaction changes, and takes one local reading. /// /// Called once, from the app's launch (`AppDelegate.applicationDidFinishLaunching`) — never from /// `AppModel.init`, so that a unit-test host constructing a model does not acquire a StoreKit /// listener as a side effect. /// /// `Transaction.updates` is StoreKit's out-of-band channel: a renewal that landed while the app /// was closed, a purchase made on another device, a refund the App Store processed. It is *not* /// polling and it is not a network call this app makes — it is a delivery. Handling it here is /// what keeps the cached facts from needing anybody to visit Settings. /// /// Idempotent: a second call is a no-op, because a second listener would finish every transaction /// twice. public func start() { guard updatesTask == nil else { return } updatesTask = Task { [weak self] in for await result in Transaction.updates { guard let self else { return } await handle(result) } } Task { [weak self] in await self?.refreshFromLocalTransactions() } } /// Re-reads StoreKit's **local** signed transaction store and adopts what it finds. /// /// ### An empty result is silence, not an answer /// /// The one subtle line in this file. `Transaction.currentEntitlements` yields only entitlements /// StoreKit currently considers valid, and that validity is computed on-device from the last /// signed transaction it holds — so a subscription drops out of it the moment its cached expiry /// passes, **whether or not the device has been able to ask the App Store whether it renewed**. /// Treating an empty result as "the subscription is over" would therefore lapse every offline /// user at their renewal date, which is precisely the outcome 12 ▸ The entitlement rules out: /// "an on-disk expiry passing while offline, with the last known state active and auto-renew on, /// holds the entitlement until StoreKit actually refreshes and answers." /// /// So an empty result **leaves the cache alone** and the pure decision does the rest: an expiry /// still in the future keeps the user Pro (rule 2), a passed expiry with auto-renew on holds /// (rule 3), and a passed expiry with auto-renew off — a cancellation, which StoreKit told us /// about *before* it ran out — lapses on its own (rule 4). The definitive endings arrive through /// `adopt(.none)`: a revocation seen here, or the subscription-group status the Settings section /// reads when the user goes looking. /// /// The honest residual is 12's own accepted trade: a user who cancels while offline, quits, and /// never opens Settings again holds Pro until StoreKit delivers an update. "A wrong hold gives /// away days of local commits — Apple's own billing grace makes the same trade." public func refreshFromLocalTransactions() async { var newest: SubscriptionFacts? for await result in Transaction.currentEntitlements { guard case let .verified(transaction) = result else { // An unverified transaction is not evidence of anything. Skipped rather than treated // as a lapse, for the empty-result reason above. continue } guard ProProducts.all.contains(transaction.productID) else { continue } if transaction.revocationDate != nil { // A refund or a family-sharing withdrawal — StoreKit answering, definitively. adopt(.none) return } let candidate = await Self.facts(of: transaction) if newest == nil || Self.expiry(of: candidate) > Self.expiry(of: newest!) { newest = candidate } } guard let newest else { Self.logger.debug("no current Pro entitlement in the local store; the cached facts stand") return } adopt(newest) } /// One current entitlement, reduced to the two facts the decision needs. /// /// `willAutoRenew` comes from the subscription group's renewal info, which is the only place /// StoreKit publishes it. When it cannot be read — an unverified renewal info, a status call that /// throws — the answer defaults to `true`, which is the paying user's direction and the same one /// 12's grace rule already chose everywhere else it had to pick. /// /// A current entitlement with **no** expiration date is given `.distantFuture` rather than `nil`, /// because `nil` means "no cached transaction" to `SubscriptionFacts` and this is the opposite of /// that: a live entitlement that does not expire. Not a state an auto-renewable subscription /// reaches today; spelled out so it cannot become one silently. private static func facts(of transaction: Transaction) async -> SubscriptionFacts { var willAutoRenew = true if let status = try? await transaction.subscriptionStatus, case let .verified(renewalInfo) = status.renewalInfo { willAutoRenew = renewalInfo.willAutoRenew } return SubscriptionFacts( expiration: transaction.expirationDate ?? .distantFuture, willAutoRenew: willAutoRenew ) } /// A transaction delivered out of band. Finished, then folded into the facts by a fresh local /// read — the same read every other path uses, so there is one reduction rule and not two. /// /// Finishing is not optional bookkeeping: an unfinished transaction is redelivered forever, and /// for an auto-renewable subscription there is no content to deliver first — the entitlement /// *is* the delivery. private func handle(_ result: VerificationResult) async { guard case let .verified(transaction) = result else { return } guard ProProducts.all.contains(transaction.productID) else { return } await transaction.finish() await refreshFromLocalTransactions() } private static func expiry(of facts: SubscriptionFacts) -> Date { facts.expiration ?? .distantPast } // MARK: - The cache /// Read at init, written by `adopt(_:)`. JSON in a scalar default, beside the app's other /// scalars (`AppPreferences`) rather than in `AppStateHome` — this is two fields, not a file /// store, and `AppStateHome`'s own note draws that line. /// /// A cache that will not decode is treated as no cache at all: the tier reads free and the next /// refresh rebuilds it. Losing it costs a user with an active subscription nothing (StoreKit's /// own store still holds the transaction) and costs a user mid-grace their hold, which is the /// safe direction to fail in. private static func cachedFacts(in defaults: UserDefaults) -> SubscriptionFacts { guard let data = defaults.data(forKey: AppPreferences.subscriptionFactsKey), let decoded = try? JSONDecoder().decode(SubscriptionFacts.self, from: data) else { return .none } return decoded } private static func cache(_ facts: SubscriptionFacts, in defaults: UserDefaults) { guard let data = try? JSONEncoder().encode(facts) else { logger.error("subscription facts could not be encoded; the cache is unchanged") return } defaults.set(data, forKey: AppPreferences.subscriptionFactsKey) } }