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:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user