import Foundation // MARK: - Tier /// Which tier a board session composes under (12-editions.md ▸ The tiers). /// /// **Two cases, and there will never be a third here.** Lanework Teams is deferred and will "never /// share an app group or any cross-app state with Lanework" (12 ▸ The tiers, ruled 2026-07-30) — /// whatever it becomes, it is a different app, not a third case of this enum. /// /// Nothing about this type is a *feature flag*. It is the answer to one question — free or Pro — /// asked once per board session at composition (`AppModel.beginSession`), recorded on the session, /// and never asked again for that board. What consumes it is the provider seam /// (12 ▸ The provider seam); see `AppModel.makeHistoryProvider`. public enum Tier: String, Sendable, Equatable, Codable, CaseIterable { /// Lanework. Boards are plain folders, mode `none` everywhere, undo is macOS-native /// (13-native-undo.md), and any `.git` the app meets is inert (12 ▸ The free tier and `.git`). /// /// **This is also the lapsed tier.** "Unsubscribed and lapsed are one state — the inert posture, /// nothing lost, histories frozen not forfeited" (12 ▸ The entitlement). There is deliberately no /// `.lapsed` case: a case nothing may act on differently is a distinction the design forbids from /// existing at all. case free /// Lanework Pro — an active auto-renewable subscription. Binds the git history provider when /// pro-m1 builds it (06-history-undo.md, 07-sync-collab.md). case pro } // MARK: - SubscriptionFacts /// **What the app knows locally about the subscription** — the whole input to the tier decision, /// beside a date. /// /// ### Why a cached fact struct rather than a live StoreKit read /// /// 12-editions.md ▸ The entitlement makes two demands that pull in the same direction. Pro state is /// "a local read, never a network call ... the open path gains no network dependency"; and offline /// grace "resolves toward the paying user" — "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." /// /// The second demand is the reason this type exists as *stored* state rather than as a view onto /// `Transaction.currentEntitlements`. StoreKit computes entitlement validity locally, so a /// subscription whose expiry has passed drops out of `currentEntitlements` **whether or not the /// device has been able to ask the App Store about it** — an offline device and a genuinely lapsed /// subscription look identical from that property alone. Holding the last answer ourselves is what /// lets the two be told apart in the only direction the design cares about: a *cancellation* (auto /// renew off) lapses at its expiry with no network needed, while a *renewal we simply have not heard /// about yet* keeps the user paid-up until StoreKit says otherwise (`ProEntitlement.adopt`). /// /// ### Never-subscribed and never-online are one shape, on purpose /// /// `expiration == nil` means "no cached transaction" and covers both the user who has never /// subscribed and the fresh install that "has no cached transactions and reads as the free tier /// until the first refresh — honest and self-correcting" (12). Nothing distinguishes them because /// nothing may: they are the same tier, reached by the same route, correcting themselves the same /// way. /// /// `Codable` because these facts are cached across launches in `UserDefaults` /// (`AppPreferences.subscriptionFactsKey`) — that cache *is* the "local read" the open path performs. public struct SubscriptionFacts: Codable, Sendable, Equatable { /// When the current subscription period ends, as StoreKit last reported it. /// /// `nil` is the no-cached-transaction state — see the type's note. A non-`nil` value is never /// evidence on its own that the subscription is *live*: an expiry in the past is either a lapse /// or an offline hold, and `willAutoRenew` is what decides which. public var expiration: Date? /// Whether the subscription was set to renew, at the last moment StoreKit told us anything. /// /// This is the whole of the offline-grace rule. Auto-renew **on** with a passed expiry is a /// renewal the device has not heard about — hold. Auto-renew **off** with a passed expiry is a /// cancellation that has run out — lapse, "offline or not" (12 ▸ The entitlement). public var willAutoRenew: Bool public init(expiration: Date?, willAutoRenew: Bool) { self.expiration = expiration self.willAutoRenew = willAutoRenew } /// No cached transaction: never subscribed, never online, or an entitlement StoreKit has /// definitively withdrawn (a refund, a revocation). All three read as the free tier, and that is /// the point — see the type's note. public static let none = SubscriptionFacts(expiration: nil, willAutoRenew: false) } // MARK: - The decision public extension Tier { /// **The tier decision, as a pure function of cached facts and a date.** /// /// Every semantic here is 12-editions.md ▸ The entitlement's, in its own order: /// /// 1. **No cached transaction → free.** "A fresh install that has never been online has no /// cached transactions and reads as the free tier until the first refresh." The /// never-subscribed user takes the identical branch, which is what makes unsubscribed and /// lapsed one state. /// 2. **Expiry in the future → Pro.** "Offline with an active subscription is indistinguishable /// from online" — there is no reachability term in this function because there is no /// reachability term in the rule. /// 3. **Expiry passed, auto-renew on → Pro.** The offline-grace hold: "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." The *answering* is `ProEntitlement`'s job /// — this function's job is only to resolve toward the paying user until it happens. /// 4. **Expiry passed, auto-renew off → free.** "A cancellation (auto-renew off) lapses at /// expiry, offline or not." /// /// The design weighs both wrong-for-a-window directions and accepts them: "a wrong lapse pauses /// auto-commits into one catch-up commit; a wrong hold gives away days of local commits — Apple's /// own billing grace makes the same trade." /// /// `nonisolated` and `static` because it is exactly as pure as that reads: no stored state, no /// clock of its own, no StoreKit. `now` is a parameter rather than a `Date()` inside for the /// reason `AppModel.shouldRestoreAtLaunch` takes its two `Bool`s — a decision worth this much /// prose is worth being provable without a machine in a particular state. /// /// The expiry comparison is strict (`>`), so an expiry falling exactly on `now` is *past*: a /// StoreKit expiration date is the instant the period ends, not the last instant it covers, and /// resolving the boundary the other way would extend every subscription by a tick for no reason. /// At that boundary rule 3 is usually what answers anyway, which is the paying user's direction. static func resolve(from facts: SubscriptionFacts, now: Date) -> Tier { guard let expiration = facts.expiration else { return .free } if expiration > now { return .pro } return facts.willAutoRenew ? .pro : .free } }