The subscription machinery leaves the code — Kanban/Tier excised, StoreKit wiring unwound

The 2026-08-08 one-version ruling (12-editions.md ▸ PIVOT 2026-08-08) carried out: Kanban/Tier/
deleted wholesale (Tier, ProEntitlement, ProProducts, ProStorefront, the never-rendered
ProSettingsSection) with TierTests and Configuration.storekit, whose project.yml resource entry
and scheme storeKitConfiguration go with it. AppModel loses the entitlement, the currentTier
seam, BoardSession.tier, and the purchase flow's reopenOpenBoards (its only caller was the
storefront); AppDelegate's launch keeps only the appearance application. The three tests
pinning the recorded tier and the reopen are deleted with their subject. The network-client
entitlement stays — the sync capability to come needs it regardless — and the HistoryProviding
seam stands untouched. 2,686 unit tests green (2,707 minus the 21 that tested what left).

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
This commit is contained in:
2026-08-08 13:18:54 -04:00
parent 871083e5ca
commit e1f89d9cf9
15 changed files with 48 additions and 1465 deletions
+3 -6
View File
@@ -2,12 +2,9 @@ import IndieAbout
/// **The About box's configuration.**
///
/// **Dormant since the 2026-08-07 pivot** (12-editions.md PIVOT 2026-08-07 git leaves the
/// paywall): this box used to carry a second copyright line naming Lanework Pro, one of the three
/// places 12's pre-pivot "Tier naming" section named the subscription. Git left the paywall, the
/// base/Pro split is being re-decided, and until it's ruled, no surface in the app names or sells
/// Pro so the line comes out, and the box goes back to a plain, one-line copyright shown to
/// everyone.
/// **One box, one copyright line, shown to everyone** (12-editions.md PIVOT 2026-08-08 one
/// version, everything free): this box once carried a second copyright line naming a paid edition,
/// back when the app had editions to name. It has none, so the line is gone and the box is plain.
enum AboutBox {
/// The About window's content: version/build/date from the stamped Info.plist
+5 -14
View File
@@ -16,24 +16,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "app-delegate")
/// Starts the Pro entitlement's transaction listener (12-editions.md The entitlement).
/// Applies the stored appearance override to `NSApp` (03-board-ui.md Toolbar).
///
/// **Here rather than in `AppModel.init`**, and the distinction matters: a unit-test host *is*
/// this app, so `KanbanApp.init()` and therefore `AppModel.init` run on every test launch
/// (`AppStateHome.isUnitTestHost`). Building the entitlement there costs one `UserDefaults` read;
/// acquiring a StoreKit listener there would give every test run a live `Transaction.updates`
/// subscription for no reason. Launch is the honest home for a listener, and this is the app's.
///
/// It starts nothing the board-open path waits on: the listener writes cached facts that a
/// *later* composition may read, and never reaches into a session that is already open
/// (`ProEntitlement`).
///
/// **The appearance override applies here too, for the same reason.** `AppearanceStore.init`
/// only reads; this is the one call that hands its answer to `NSApp` the global side effect
/// `KanbanApp.init` must not carry, since a unit-test host runs that `init` on every launch
/// (`AppearanceStore.applyCurrent`).
/// (`AppStateHome.isUnitTestHost`). `AppearanceStore.init` only reads a default; this is the one
/// call that hands its answer to `NSApp` the global side effect `KanbanApp.init` must not
/// carry (`AppearanceStore.applyCurrent`). Launch is the honest home for it, and this is the
/// app's.
func applicationDidFinishLaunching(_ notification: Notification) {
appModel?.entitlement.start()
appModel?.appearance.applyCurrent()
}
+9 -138
View File
@@ -146,16 +146,6 @@ public enum AppPreferences {
public static var appearance: AppAppearance? {
UserDefaults.standard.string(forKey: appearanceKey).flatMap(AppAppearance.init(rawValue:))
}
/// The cached subscription facts behind the tier decision (12-editions.md The entitlement)
/// JSON-encoded `SubscriptionFacts`, read and written by `ProEntitlement`.
///
/// A scalar default rather than a file in `AppStateHome` because it is two fields, which is the
/// line that type's own note draws. **Not a secret and not a receipt**: the signed transaction
/// store is StoreKit's and stays StoreKit's; this is a *cache of the last answer* whose worst
/// case if edited by hand is one wrong tier until the next refresh corrects it, which is the
/// same self-correction a fresh install already relies on.
public static let subscriptionFactsKey = "subscriptionFacts"
}
// MARK: - Launch failures
@@ -323,36 +313,6 @@ public final class AppModel {
/// reads the pasteboard once and collects every staged tree it no longer names.
public let clipboard: ClipboardStore
// MARK: The entitlement
/// **Lanework Pro's entitlement** (12-editions.md The entitlement) the cached, local answer
/// to "is this a subscriber?", owned here for the registries' reason: it is app-scoped, and a
/// test holds its own over its own defaults rather than colliding with the app's.
///
/// Nothing on the board-open path awaits anything through this object. See `ProEntitlement` for
/// why that is a property of its shape rather than a rule somebody has to remember.
public let entitlement: ProEntitlement
/// **The tier a board session composes under**, as an injectable seam.
///
/// Defaulted to the real entitlement's local read and separated from it so a test binds a tier
/// without needing a StoreKit transaction, an App Store account, or a second `AppModel`
/// initializer. `@MainActor` on the closure type because the entitlement it reads is main-actor
/// state, and `@ObservationIgnored` because nothing renders from it the tier reaches the UI,
/// where it reaches it at all, through `entitlement`.
///
/// **Read once per session, at composition, and never again** (12 The entitlement: "a lapse
/// never interrupts an open session"). `beginSession` is the only caller.
///
/// **Dormant since PIVOT 2026-08-07** (12-editions.md git left the paywall, and the 2026-08-08
/// ruling then excised git entirely): the answer is still read and still recorded on the session
/// (`BoardSession.tier`), and **nothing consults it any more** `makeHistoryProvider` lost the
/// axis, and the stack it used to gate has gone. The seam is kept unchanged, mechanics and all,
/// because the entitlement is correct for whatever the next base/Pro split turns out to gate;
/// what it does not do is decide undo.
@ObservationIgnored
public var currentTier: @MainActor () -> Tier = { .free }
// MARK: The provider seam
/// **The composition root for `HistoryProviding`** (12-editions.md The provider seam): what a
@@ -361,10 +321,11 @@ public final class AppModel {
/// **Every board gets the native stack, and the seam has one answer**
/// (13-native-undo.md's header; `strategy/01-git-excision.md`, ruled 2026-08-08 the app-managed
/// git substrate is excised, so `Kanban/History/` is the only one there is). Nothing about a board
/// decides this any more: not its tier (12 PIVOT 2026-08-07 took the last row the tier decided),
/// not whether it sits inside somebody's repository, not what is on disk beside it. A board
/// nobody has done anything special to and a board living in a user's git repo bind the same
/// stack, which was already true before this ruling and is now true by construction.
/// decides this any more: not what the user paid (12-editions.md PIVOT 2026-08-08 one
/// version, everything free, no edition axis left to consult), not whether it sits inside
/// somebody's repository, not what is on disk beside it. A board nobody has done anything
/// special to and a board living in a user's git repo bind the same stack, which was already
/// true before this ruling and is now true by construction.
///
/// ### Why it is still a seam
///
@@ -421,30 +382,12 @@ public final class AppModel {
/// (`BoardStore.registerStep`). The command surface disables through `undoManager`, which
/// answers the empty way over an absent substrate.
///
/// A `var` rather than a `let` beside `tier`, and now for no event at all: the one sanctioned
/// mid-session substrate swap was add-git's commanded mode flip, which went with the git
/// stack. It stays a `var` because a second provider is a live possibility
/// (`strategy/01-git-excision.md` Reversibility) and because nothing is bought by tightening
/// it; a tier lapse could never touch it `tier` has no setter, and since PIVOT 2026-08-07 it
/// has no say in this either.
/// A `var` rather than a `let`, and now for no event at all: the one sanctioned mid-session
/// substrate swap was add-git's commanded mode flip, which went with the git stack. It stays
/// a `var` because a second provider is a live possibility (`strategy/01-git-excision.md`
/// Reversibility) and because nothing is bought by tightening it.
public var history: (any HistoryProviding)?
/// **The tier this board composed under** (12-editions.md The entitlement) recorded,
/// and **dormant since PIVOT 2026-08-07**.
///
/// A `let`, on a value type, set once by `beginSession`. That is the entire mechanism behind
/// "a lapse never interrupts an open session": there is no setter, no observation, and
/// nothing anywhere that re-evaluates a live session's tier, so a subscription ending
/// mid-session is a fact about the *next* open and about nothing already on screen.
///
/// **Nothing reads it.** Git left the paywall (12 PIVOT 2026-08-07) and then left the app
/// (`strategy/01-git-excision.md`, ruled 2026-08-08), so no surface anywhere is decided by
/// this. It stays recorded because the entitlement's machinery stays built and correct for
/// whatever the re-decided base/Pro split gates, and because the fact a board opened under is
/// a composition-time answer the way the provider binding is: whatever asks next must get what
/// this board opened with, never what the entitlement says at render time.
public let tier: Tier
/// The same stack, wearing the face AppKit needs (`BoardUndoManager`): what this board's
/// windows hand back from `windowWillReturnUndoManager`, so the Edit menu's Undo/Redo rows
/// and the toolbar's pair resolve to *this* board through the ordinary responder chain.
@@ -670,14 +613,6 @@ public final class AppModel {
zoom = BoardZoomStore(defaults: preferences)
appearance = AppearanceStore(defaults: preferences)
clipboard = ClipboardStore(stagingRoot: clipboardStagingRoot)
// Reads the cached facts and nothing else no StoreKit API is touched until
// `ProEntitlement.start()`, which the app's launch calls and a test host never does.
let entitlement = ProEntitlement(defaults: preferences)
self.entitlement = entitlement
// Bound after the stored properties are in place, so the closure captures the object rather
// than a half-built `self`. This is the app's default wiring; a test that wants a tier
// assigns over it.
currentTier = { entitlement.tier }
// Read once here rather than lazily, so File Open Recent is populated from the app's first
// menu pass a launch that restores boards never shows welcome, and a submenu that filled
// in only after the first close would look broken. It costs one bookmark-resolution sweep at
@@ -836,16 +771,6 @@ public final class AppModel {
/// drop 02 forbids it forbids a failure that was never surfaced disappearing, not one the user
/// has since fixed.
func beginSession(ref: BoardWindowRef, store: BoardStore, recordID: UUID, access: ScopedAccess?) {
// **The entitlement read** (12-editions.md The entitlement): "Pro state is read from
// StoreKit's signed on-device transaction store at board-session composition the open path
// gains no network dependency." Synchronous, over facts already in memory. It is the *only*
// time this board asks: the answer becomes `BoardSession.tier` and nothing re-derives it.
//
// **Recorded, and consulted by nothing below** (12 PIVOT 2026-08-07 git left the paywall;
// `strategy/01-git-excision.md`, ruled 2026-08-08 git left the app). This line used to sit
// on the same line as the git state it gated; both the gate and the state are gone, and what
// is left is a dormant fact kept for the base/Pro split still to be ruled.
let tier = currentTier()
// The board's stack is born here, with the session that owns it, and dies in `tearDown`
// below the whole of 13-native-undo.md's session-only persistence: "the stack lives with
// the board session and dies at close/quit ... standard macOS behavior".
@@ -861,7 +786,6 @@ public final class AppModel {
store: store,
recordID: recordID,
history: history,
tier: tier,
// The lock's enablement half (13-native-undo.md Rules): Undo and Redo disable with the
// other mutating commands while the board refuses writes, and the stack survives to
// resume when it clears. Weak, so the adapter is never the reason a closed board's store
@@ -1131,59 +1055,6 @@ public final class AppModel {
return session.cardRefs.contains { cardSessions[$0]?.holdsUnsavedContent == true }
}
/// **The purchase flow's reopen offer, carried out** (12-editions.md The entitlement:
/// "Subscribe takes effect at each board's next open ... The purchase flow offers to reopen open
/// boards so the upgrade feels immediate").
///
/// ### Close and open, through the ordinary paths
///
/// There is no reopen-in-place mechanism here and deliberately so: the provider binding is a
/// composition-time fact, so "apply the new tier to this board" *means* end its session and
/// compose a new one. Doing that through `closeBoard` and `openBoard` the same two calls W and
/// welcome make is what keeps every guarantee those paths carry: the close flush runs in its
/// fixed order (card windows, pending work, registry stamp, teardown), the reopen resolves and
/// re-scopes the board's URL exactly as a fresh open does, and the registry records both.
///
/// ### Why the window is dismissed rather than reused
///
/// A board window's identity is its root path (`BoardWindowRef`), so reopening the same board
/// hands `openWindow(value:)` a ref it already has a window for which *focuses* that window
/// instead of building a new one, and the window it would focus is one whose host has already run
/// its one-shot load. Dismissing first is what makes the reopen an open. The dismissals are all
/// issued before any reopen, then given a run-loop turn to land: SwiftUI processes a window's
/// teardown asynchronously, and asking for a value's window in the same turn it was dismissed is
/// the one way this sequence can produce a focused corpse.
///
/// ### Declining costs nothing, today least of all
///
/// A board that says Not Now keeps the substrate it composed with, and since every board composes
/// the same one (`makeHistoryProvider`), that costs undo nothing at all. The offer exists because
/// "subscribe takes effect at each board's next open" (12 The entitlement) needs one, not
/// because anything breaks without it.
public func reopenOpenBoards() async {
// Sorted for `flushAllBoardsForQuit`'s reason: a reproducible order rather than a `Set`'s.
let refs = sessions.keys.sorted { $0.path < $1.path }
guard !refs.isEmpty else { return }
// The store's `rootURL` rather than the ref's path: a board renamed while open keeps the
// path it was opened with, and reopening it there would open nothing (`BoardStore.rootURL`).
var roots: [URL] = []
for ref in refs {
guard let session = sessions[ref] else { continue }
roots.append(session.store.rootURL)
await closeBoard(ref: ref, cause: .userClose)
windowDismisser?(value: ref)
}
// One run-loop turn for the dismissals see the note above. `Task.sleep` rather than
// `Task.yield` because the main run loop, not the cooperative pool, is what has to advance.
try? await Task.sleep(for: .milliseconds(150))
for root in roots {
openBoard(at: root)
}
}
/// Quit: the same sequence, once per open board, **sequentially**.
///
/// Sequential rather than concurrent so each board's ordering is the one 02 fixes rather than
+8 -8
View File
@@ -379,15 +379,15 @@ private struct RecentBoardRow: View {
/// The app's preferences (, 11-command-nexus.md).
///
/// One section today: "Restore open boards at launch", whose preference gates only whether the
/// registry's open-now flags are *consulted* at launch the flags themselves are maintained either
/// way, which is what keeps crash recovery working for a user who has restoration turned off and then
/// turns it back on.
/// One section, and one section is all there is: "Restore open boards at launch", whose preference
/// gates only whether the registry's open-now flags are *consulted* at launch the flags themselves
/// are maintained either way, which is what keeps crash recovery working for a user who has
/// restoration turned off and then turns it back on.
///
/// **The Pro section is dormant, not deleted** (12-editions.md PIVOT 2026-08-07 git leaves the
/// paywall): `ProSettingsSection` still exists and still compiles, but this scene no longer renders
/// it the base/Pro split is being re-decided, and until it's ruled, no surface in the app names or
/// sells Lanework Pro.
/// **There is no purchase section, and nothing to build one out of** (12-editions.md PIVOT
/// 2026-08-08 one version, everything free): the subscription machinery this pane once hosted is
/// excised, not dormant. A paid tier returns as a fresh design pass, with the iPhone companion and
/// sync, and will bring its own surface.
struct SettingsView: View {
@AppStorage(AppPreferences.restoreOpenBoardsAtLaunchKey)
+2 -2
View File
@@ -181,8 +181,8 @@ struct KanbanApp: App {
// The About window (11-command-nexus.md files About under the app menu's standard
// furniture): icon, version/build/date from the Info.plist that `update_build_info.sh`
// stamped at build time never a hardcoded string with the version line opening the
// bundled changelog. `AboutBox` names no tier (12-editions.md PIVOT 2026-08-07) one box,
// every tier, identical.
// bundled changelog. `AboutBox` names no edition (12-editions.md PIVOT 2026-08-08)
// there is one version of this app, so there is one box.
IndieAboutCommand(configuration: AboutBox.configuration)
// The File group, in 11-command-nexus.md's own row order: New Card, New Lane, New Board,
-259
View File
@@ -1,259 +0,0 @@
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<Void, Never>?
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<Transaction>) 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)
}
}
-49
View File
@@ -1,49 +0,0 @@
import Foundation
/// **The App Store Connect configuration this app mirrors in code** the subscription's product
/// identifier and the group it belongs to.
///
/// ### These strings are configuration, not invention
///
/// Nothing here is created at runtime. A subscription group and its products are made **by hand** in
/// App Store Connect, against the record `dev.rzen.indie.Kanban` (12-editions.md Distribution;
/// RELEASE.md Lanework Pro), and StoreKit will only ever return products whose identifiers match
/// what that account actually declares. This file is the local half of that agreement: change a
/// string here without changing it there and the Settings Pro section quietly reports that it cannot
/// reach the App Store, which is exactly what a mismatched identifier looks like from inside the app.
/// `Configuration.storekit` at the repo root is the third copy the Xcode-local one and it exists
/// so the purchase flow can be exercised on a machine with no App Store Connect access at all.
///
/// ### The identifier's shape
///
/// `dev.rzen.indie.kanban.pro.monthly` follows the family's reverse-DNS style, the same one the three
/// pasteboard types already use (`dev.rzen.indie.kanban.cards`, `lanes`, `clipboard`
/// `PasteboardTypes.swift`): the lowercase `kanban` codename segment, then what the thing *is*. The
/// bundle id keeps its capital `K` because that is what the 1.x App Store record ships under; the
/// identifiers the app coins for itself do not, and consistency inside that set is what matters.
///
/// **One product to start.** A monthly subscription is the whole storefront until there is evidence
/// for a second an annual tier, a family plan and an introductory offer are all additions to the
/// same group and none of them changes a line of the entitlement, which reads *whatever* transaction
/// the group produced (`ProEntitlement`). `all` is the seam that keeps that true: everything that
/// matches a transaction against this app's products asks this set, never the single constant.
public enum ProProducts {
/// Lanework Pro, billed monthly the one auto-renewable subscription product.
public static let monthly = "dev.rzen.indie.kanban.pro.monthly"
/// The subscription group's **reference name** in App Store Connect: "Lanework Pro".
///
/// Deliberately *not* the group's numeric id. That number is minted by App Store Connect when the
/// group is created and cannot be known until then, so hardcoding one would be a value invented
/// at the wrong end. Where StoreKit needs the numeric id reading a group's subscription status
/// it is read off a loaded `Product` (`product.subscription?.subscriptionGroupID`), which is
/// the account's own answer rather than this file's guess.
public static let subscriptionGroupName = "Lanework Pro"
/// Every product identifier this app's entitlement recognises.
///
/// The set, rather than the constant, is what `ProEntitlement` matches transactions against and
/// what `ProStorefront` loads so adding an annual product is one line here and nothing else.
public static let all: Set<String> = [monthly]
}
-183
View File
@@ -1,183 +0,0 @@
import SwiftUI
/// **The Settings Pro section** subscribe, manage, restore (12-editions.md Distribution).
///
/// **Dormant since the 2026-08-07 pivot** (12 PIVOT 2026-08-07 git leaves the paywall): git
/// integration left the paywall and the base/Pro feature split is being re-decided, so this view is
/// no longer instantiated from `SettingsView` it is unrendered pending the new split. The type,
/// its logic, and its tests stay exactly as they were: the entitlement's mechanics (local read,
/// composition-time binding, offline grace, the recorded session tier) are unchanged and correct
/// for whatever the next split gates, and this section is the seam that will surface it again once
/// that split is ruled. Nothing below this comment was touched for the pivot.
///
/// ### Calm, because the rule says calm
///
/// "The free tier presents as a complete app, not a demo ... Nothing on the welcome screen, nothing
/// in banners." This section is where the subscription actually lives, so it is allowed to describe
/// itself and that is all it does: what Pro adds, in the same sentence the About box uses, then the
/// price and the buttons. No badge, no comparison table, no countdown, no second sentence selling the
/// first. A user who never subscribes should be able to read this pane and feel they are looking at a
/// feature they do not need, not an ad they have to dismiss.
///
/// ### Three states, all of them sentences
///
/// - **Subscribed** says so, with whatever renewal or expiry date StoreKit exposes, and offers
/// Manage. Restore is not shown: there is nothing to restore onto an account that is already
/// entitled here.
/// - **Not subscribed, product loaded** the localized price and Subscribe, with Restore Purchases
/// beside it for the account that owns a subscription this Mac has never seen.
/// - **Not subscribed, App Store unreachable** one quiet line and Try Again, never an indefinite
/// spinner (`ProStorefront`'s own note). Restore stays available: it is the button whose whole job
/// is to reach the App Store, so hiding it in the state where the App Store could not be reached
/// would remove the one thing worth pressing.
///
/// ### The reopen offer
///
/// "Subscribe takes effect at each board's next open ... The purchase flow offers to reopen open
/// boards so the upgrade feels immediate" (12 The entitlement). The offer is raised **once**, from
/// here, on the one outcome that means an active subscription just landed and declining costs a
/// gitless board nothing at all: the provider follows the board, so its undo is the same native
/// stack before and after (`AppModel.makeHistoryProvider`). What a Not Now defers is the git trail
/// on the boards that have a repository.
struct ProSettingsSection: View {
@Environment(AppModel.self) private var appModel
/// Built on appearance rather than at init, because the entitlement it wraps comes from the
/// environment and dropped when the pane goes, which is what keeps the networked half of Pro
/// out of every other part of the app (`ProStorefront`'s note).
@State private var storefront: ProStorefront?
/// The reopen offer, raised at most once per activation.
@State private var isOfferingReopen = false
/// How many boards the offer was raised over. Captured when the offer is made, like the trash
/// confirmations capture their counts (`TrashConfirmations`): the user is being asked about the
/// boards that were open when they subscribed, and a count recomputed at render time could
/// disagree with the sentence they are reading.
@State private var openBoardCount = 0
var body: some View {
Section {
content
} header: {
Text("Lanework Pro")
} footer: {
Text("Lanework Pro adds git-backed board history and sync. It takes effect the next time each board opens.")
}
.task {
let storefront = storefront ?? ProStorefront(entitlement: appModel.entitlement)
self.storefront = storefront
await storefront.load()
}
.alert("Reopen your open boards?", isPresented: $isOfferingReopen) {
Button("Reopen") {
Task { await appModel.reopenOpenBoards() }
}
Button("Not Now", role: .cancel) {}
} message: {
Text(reopenMessage)
}
}
// MARK: Content
@ViewBuilder
private var content: some View {
if appModel.entitlement.tier == .pro {
subscribedRows
} else {
unsubscribedRows
}
}
/// The subscribed state. One status line and one button the App Store owns everything else
/// about a subscription (price changes, cancellation, billing), and duplicating any of it here
/// would be a second, staler answer.
@ViewBuilder
private var subscribedRows: some View {
// One `Text`, deliberately, rather than a status word beside a date: VoiceOver reads it as
// the one sentence it is, and nothing about the state is carried by layout
// (10-accessibility.md nothing is ever said by position or colour alone).
Text(subscriptionStatusLine)
Button("Manage Subscription…") {
storefront?.openManageSubscriptions()
}
.accessibilityHint("Opens your subscriptions in the App Store.")
}
@ViewBuilder
private var unsubscribedRows: some View {
switch storefront?.availability ?? .idle {
case .idle, .loading:
// A determinate, one-line placeholder rather than a progress spinner: the load is short,
// and a spinner that resolves into a price reads as a slower price.
Text("Checking the App Store…")
.foregroundStyle(.secondary)
case let .ready(product):
HStack {
Text(product.displayPrice)
Spacer()
Button("Subscribe") {
Task { await activate { await storefront?.subscribe() } }
}
.disabled(storefront?.isBusy ?? false)
.accessibilityLabel("Subscribe to Lanework Pro, \(product.displayPrice)")
}
case .unreachable:
Text("Can't reach the App Store right now.")
.foregroundStyle(.secondary)
Button("Try Again") {
Task { await storefront?.load() }
}
.disabled(storefront?.isBusy ?? false)
}
Button("Restore Purchases") {
Task { await activate { await storefront?.restore() } }
}
.disabled(storefront?.isBusy ?? false)
.accessibilityHint("Checks this Apple Account for a Lanework Pro subscription.")
if let message = storefront?.message {
Text(message)
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
// MARK: Wording
/// What a subscriber's status line says, built from exactly what StoreKit cached and nothing
/// more. An unknown expiry says the plain fact and stops, rather than inventing a date.
private var subscriptionStatusLine: String {
let facts = appModel.entitlement.facts
guard let expiration = facts.expiration, expiration < .distantFuture else {
return "Subscribed."
}
let date = expiration.formatted(date: .abbreviated, time: .omitted)
return facts.willAutoRenew ? "Subscribed — renews \(date)." : "Subscribed — ends \(date)."
}
private var reopenMessage: String {
let boards = openBoardCount == 1 ? "the board you have open" : "the \(openBoardCount) boards you have open"
return "Pro takes effect at each board's next open. Lanework can close and reopen \(boards) now."
}
// MARK: Actions
/// Runs a purchase or a restore and raises the reopen offer on the one outcome that earns it.
///
/// The count is read here, before the alert exists, for the reason `TrashConfirmations` captures
/// its phrasing at request time. And the offer is skipped outright when nothing is open, because
/// an offer to reopen no boards is a dialog with nothing behind it.
private func activate(_ work: () async -> ProStorefront.Outcome?) async {
guard await work() == .activated else { return }
let count = appModel.storeRegistry.openBoardCount
guard count > 0 else { return }
openBoardCount = count
isOfferingReopen = true
}
}
-273
View File
@@ -1,273 +0,0 @@
import AppKit
import Foundation
import Observation
import StoreKit
import os
/// **The networked half of Lanework Pro** loading the product for its localized price, buying it,
/// restoring it, and pointing at the system's manage-subscription surface (12-editions.md
/// Distribution: "purchased and managed in a Pro section of Settings (,) subscribe, manage,
/// restore purchases").
///
/// ### Why it is a separate type from `ProEntitlement`
///
/// Because the network is. 12 The entitlement makes the *entitlement* a local read so the
/// board-open path gains no network dependency, and the cleanest way to keep a promise like that is
/// to put everything that could break it somewhere the open path cannot reach. Nothing constructs a
/// `ProStorefront` except the Settings Pro section, which builds one when the pane appears and drops
/// it when the pane goes; `AppModel` has no reference to it and no way to acquire one. The split is
/// the enforcement.
///
/// What crosses back the other way is narrow and one-directional: this type hands `ProEntitlement`
/// the conclusions StoreKit reached (`adopt(_:)`), which a *later* board composition may read. It
/// never reaches into an open session see `ProEntitlement`'s note on why a lapse cannot rebind one.
///
/// ### Offline is a sentence, not a spinner
///
/// A product load that fails leaves `availability` at `.unreachable`, which the section renders as
/// one quiet line with a Try Again button. There is deliberately no retry loop and no indefinite
/// progress view: the App Store being unreachable is an ordinary, temporary, user-legible condition,
/// and a subscriber's *entitlement* is unaffected by it the cached facts already answered that
/// question before this type existed.
@MainActor
@Observable
public final class ProStorefront {
// MARK: Availability
/// Whether the subscription product can be shown, and at what price.
public enum Availability: Equatable {
/// Nothing has been asked for yet the state the pane is built in.
case idle
/// A product load is in flight.
case loading
/// Loaded. The `Product` carries its own localized `displayPrice`, which is the only place
/// a price may come from: a price written into the app would be wrong in most of the world
/// and out of date in the rest.
case ready(Product)
/// The App Store could not be reached, or answered with no such product. **One case for
/// both**, because they are one sentence to the user and neither is actionable beyond
/// trying again a missing product id is a configuration mistake that shows up in
/// development, never in a shipped build.
case unreachable
}
/// What just happened, for the section to react to. Distinct from `availability`, which is about
/// the *product*; this is about the last thing the user asked for.
public enum Outcome: Equatable {
/// A purchase or restore left an active subscription in the cache the one outcome that
/// raises the reopen offer (12: "the purchase flow offers to reopen open boards").
case activated
/// Ask to Buy, or a payment the App Store has not settled. Nothing to do but wait; the
/// entitlement will arrive through `Transaction.updates` when it does.
case pending
/// The user backed out of the App Store's sheet. Not an error and not worth a word.
case cancelled
/// A restore that reached the App Store and found nothing to restore.
case nothingToRestore
/// Anything else, carrying the sentence to show.
case failed(String)
}
public private(set) var availability: Availability = .idle
/// Whether a purchase or a restore is in flight what the buttons disable on.
public private(set) var isBusy = false
/// The last outcome's sentence, or `nil`. Rendered as one quiet line under the buttons.
public private(set) var message: String?
@ObservationIgnored
private let entitlement: ProEntitlement
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "storefront")
public init(entitlement: ProEntitlement) {
self.entitlement = entitlement
}
// MARK: - Loading
/// Loads the subscription product and reconciles the cached facts against what the App Store
/// says.
///
/// **The reconciliation is the point, as much as the price is.** This is the moment
/// `ProEntitlement`'s offline-grace hold can end honestly: the app has demonstrably reached the
/// App Store (the product came back), so the subscription group's status is StoreKit *answering*
/// rather than StoreKit computing locally from a stale transaction which is exactly what 12
/// The entitlement makes the hold wait for.
///
/// Run from the section's `.task`, so opening Settings is what triggers it. That is also the one
/// place a user who has been offline for a while goes looking when they wonder about their
/// subscription, which makes it the right door for this to be behind.
public func load() async {
availability = .loading
do {
let products = try await Product.products(for: ProProducts.all)
guard let product = products.first(where: { $0.id == ProProducts.monthly }) else {
Self.logger.error("the App Store returned no product for \(ProProducts.monthly, privacy: .public)")
availability = .unreachable
return
}
availability = .ready(product)
await reconcile(with: product)
} catch {
Self.logger.error("product load failed: \(error.localizedDescription, privacy: .public)")
availability = .unreachable
}
}
/// Folds the subscription group's status into the cached facts.
///
/// Three outcomes, and the middle one is the whole reason this method exists:
///
/// - **A live status** (subscribed, in grace, in billing retry) re-read the local transactions,
/// which moves the cached expiry forward to whatever the renewal actually is.
/// - **Every status expired or revoked** `adopt(.none)`. StoreKit has answered, so any hold
/// standing on "we haven't heard" is over.
/// - **No statuses at all** also `adopt(.none)`, and for the same reason rather than a weaker
/// one: the account demonstrably reached the App Store, and the App Store knows of no
/// subscription in this group. For a user who never subscribed this is a no-op on facts that
/// are already empty.
///
/// A status read that throws changes nothing. That is silence again, not an answer the same
/// posture `ProEntitlement.refreshFromLocalTransactions()` takes toward an empty local store.
private func reconcile(with product: Product) async {
guard let subscription = product.subscription else { return }
guard let statuses = try? await subscription.status else {
Self.logger.debug("subscription status unavailable; the cached facts stand")
return
}
let isLive = statuses.contains { status in
switch status.state {
case .subscribed, .inGracePeriod, .inBillingRetryPeriod: true
default: false
}
}
if isLive {
await entitlement.refreshFromLocalTransactions()
} else {
entitlement.adopt(.none)
}
}
// MARK: - Buying
/// The Subscribe button. Returns what happened, so the section can raise the reopen offer on
/// exactly one outcome.
///
/// The transaction is **finished** on the way through. An auto-renewable subscription has no
/// content to deliver the entitlement is the delivery so an unfinished one is simply a
/// transaction StoreKit redelivers forever.
@discardableResult
public func subscribe() async -> Outcome {
guard case let .ready(product) = availability, !isBusy else { return .cancelled }
isBusy = true
message = nil
defer { isBusy = false }
do {
switch try await product.purchase() {
case let .success(verification):
guard case let .verified(transaction) = verification else {
return report(.failed("This purchase couldn't be verified."))
}
await transaction.finish()
await entitlement.refreshFromLocalTransactions()
return report(entitlement.tier == .pro ? .activated : .pending)
case .pending:
return report(.pending)
case .userCancelled:
return report(.cancelled)
@unknown default:
return report(.failed("The App Store returned an unexpected answer."))
}
} catch {
Self.logger.error("purchase failed: \(error.localizedDescription, privacy: .public)")
return report(.failed(error.localizedDescription))
}
}
/// Restore Purchases `AppStore.sync()`, then the same reconciliation the load runs.
///
/// This is the app's **only** deliberate App Store refresh, and it is behind a button the user
/// pressed, which is where 12 puts the network. It exists for the account that owns a
/// subscription this device's transaction store has never seen: a new Mac, a reinstall, a signed
/// out-and-in Apple Account.
@discardableResult
public func restore() async -> Outcome {
guard !isBusy else { return .cancelled }
isBusy = true
message = nil
defer { isBusy = false }
do {
try await AppStore.sync()
} catch {
// A cancelled authentication sheet arrives here too, and is not a failure worth a
// sentence the user closed a dialog.
Self.logger.error("App Store sync failed: \(error.localizedDescription, privacy: .public)")
return report(.failed(error.localizedDescription))
}
await entitlement.refreshFromLocalTransactions()
if case let .ready(product) = availability {
await reconcile(with: product)
}
return report(entitlement.tier == .pro ? .activated : .nothingToRestore)
}
// MARK: - Managing
/// Opens the system's subscription-management surface.
///
/// **The Mac App Store's account page, not a StoreKit sheet.** StoreKit 2's
/// `AppStore.showManageSubscriptions(in:)` takes a `UIWindowScene` and has no macOS counterpart;
/// on the Mac the surface is the App Store app's Account Subscriptions, and the
/// `macappstore:` URL is how an app asks for it. The `https:` form is the fallback for a machine
/// where that scheme is unhandled, and lands on the same page in a browser.
public func openManageSubscriptions() {
let candidates = [
"macappstore://apps.apple.com/account/subscriptions",
"https://apps.apple.com/account/subscriptions"
]
for candidate in candidates {
guard let url = URL(string: candidate) else { continue }
if NSWorkspace.shared.open(url) { return }
}
Self.logger.error("no handler for the App Store subscriptions page")
}
// MARK: - Messages
/// Records an outcome's sentence and hands the outcome straight back, so every return site is
/// one line.
@discardableResult
private func report(_ outcome: Outcome) -> Outcome {
message = Self.sentence(for: outcome)
return outcome
}
/// The one place outcome wording lives. Calm and factual 12 Tier naming keeps this whole
/// section free of upsell, and that applies to its failure lines as much as to its heading.
static func sentence(for outcome: Outcome) -> String? {
switch outcome {
case .activated: nil
case .pending: "This subscription is waiting for approval."
case .cancelled: nil
case .nothingToRestore: "No subscription was found for this Apple Account."
case let .failed(message): message
}
}
}
-133
View File
@@ -1,133 +0,0 @@
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. It is what puts git on the table; which
/// substrate a given board then binds is the *board's* answer, not this case's (re-ruled
/// 2026-07-31 `AppModel.makeHistoryProvider`): the git provider on a git board
/// (06-history-undo.md, 07-sync-collab.md), and the same native stack the free tier uses on every
/// board without app-managed git, repo-nested ones included.
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
}
}