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
+259
View File
@@ -0,0 +1,259 @@
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)
}
}