Lanework Pro is a subscription — the tier seam, StoreKit 2, and Settings

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
This commit is contained in:
2026-07-30 18:14:58 -04:00
parent 2c6b8fe63a
commit 3b19883593
15 changed files with 1459 additions and 15 deletions
+130
View File
@@ -0,0 +1,130 @@
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
}
}