Phase 3 of the one-app pivot (DESIGN 12 ▸ The entitlement / Distribution, ruled 2026-07-30; card c3a3ddd5). New Kanban/Tier/: Tier (.free/.pro — deliberately no .lapsed case; unsubscribed and lapsed are one state) and the pure decision Tier.resolve(from:now:) over SubscriptionFacts (expiration + willAutoRenew), unit-tested through all five named states: free, active, lapsed, offline-grace, never-online. The facts are a persisted cache (standard defaults), not a live view: StoreKit ages an expired subscription out of currentEntitlements locally, so an offline device and a real lapse are indistinguishable from that property alone — the cache holds the last answer, empty entitlements read as silence, and holds end only on a definitive answer (revocation, or the subscription-group status read Settings performs). That is 12's offline-grace trade, resolved toward the paying user. ProEntitlement is the local adapter (currentEntitlements + Transaction.updates, started from launch, never from a test host); ProStorefront holds everything networked (product load, purchase, AppStore.sync) and only the Settings section ever constructs one — the split is the enforcement of "never network on the open path". beginSession reads the tier once at composition; BoardSession.tier is a let with no path back in, so a lapse never rebinds an open session. makeHistoryProvider now takes the tier; both tiers bind the native stack until pro-m1 builds the git provider — the seam's consumer is named, not invented early. Settings gains the Pro section (subscribe with localized price, manage, restore; a quiet unreachable line, no indefinite spinner) — the third of the exactly-three Pro mentions; the About line gains its "…in Settings" pointer now that there is a Settings to point at. A successful purchase or restore offers once to reopen open boards (close + reopen through the ordinary paths). Configuration.storekit wired into the scheme's run action for ASC-free exercise; RELEASE.md gains the pro-m1 store-side steps and the rule that the product must not be configured before then. 1901 tests in 319 suites green. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
131 lines
7.5 KiB
Swift
131 lines
7.5 KiB
Swift
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
|
|
}
|
|
}
|