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
+67 -1
View File
@@ -587,7 +587,7 @@ struct BoardSessionHistoryTests {
defer { tearDown() }
let bound = FakeHistoryProvider()
model.makeHistoryProvider = { _ in bound }
model.makeHistoryProvider = { _, _ in bound }
let ref = try openBoard(model, at: fixture.root)
let session = try #require(model.session(for: ref))
@@ -596,6 +596,72 @@ struct BoardSessionHistoryTests {
session.undoManager.undo()
#expect(bound.undoCount == 1, "the window's manager reaches whatever the root bound")
}
@Test("The tier reaches the composition root, and the session records what it composed under")
func theTierIsAComposedFact() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (model, tearDown) = try makeModel()
defer { tearDown() }
// 12-editions.md The entitlement: the tier is read at composition, once, and handed to the
// root that binds the provider. Both tiers bind the native stack until pro-m1 what this
// pins is that the *argument arrives*, so the milestone that switches on it is a closure body.
var seen: [Tier] = []
model.currentTier = { .pro }
model.makeHistoryProvider = { _, tier in
seen.append(tier)
return NativeHistoryProvider()
}
let ref = try openBoard(model, at: fixture.root)
let session = try #require(model.session(for: ref))
#expect(seen == [.pro])
#expect(session.tier == .pro, "the session carries the fact it composed under")
}
@Test("A tier change never reaches a session that is already open")
func aLapseNeverRebindsAnOpenSession() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (model, tearDown) = try makeModel()
defer { tearDown() }
model.currentTier = { .pro }
let ref = try openBoard(model, at: fixture.root)
#expect(try #require(model.session(for: ref)).tier == .pro)
// The subscription lapses mid-session the one thing 12 The entitlement says must not
// disturb a board that is already on screen: "an open board finishes with the provider it
// composed; the next open composes the native stack over inert `.git`".
model.currentTier = { .free }
#expect(try #require(model.session(for: ref)).tier == .pro)
}
@Test("The purchase flow's reopen ends every session, because that is what recomposing means")
func reopeningEndsTheSessions() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (model, tearDown) = try makeModel()
defer { tearDown() }
let ref = try openBoard(model, at: fixture.root)
#expect(model.hasOpenBoards)
// 12 The entitlement: "Subscribe takes effect at each board's next open ... The purchase
// flow offers to reopen open boards." There is no rebinding-in-place to test for, and that
// is the finding: reopening *is* ending the session and composing a new one, so what this
// pins is the ending. The reopen half needs SwiftUI's window actions, which this host has
// none of the URLs simply buffer until an opener exists (`AppModel.openBoard`), which is
// the same path a cold-launch Finder open already takes.
await model.reopenOpenBoards()
#expect(model.session(for: ref) == nil)
#expect(model.hasOpenBoards == false)
#expect(model.storeRegistry.openBoardCount == 0, "the store and its watcher went with the session")
}
}
// MARK: - The command surface
+223
View File
@@ -0,0 +1,223 @@
import Foundation
import Testing
@testable import Kanban
/// **The tier decision** (12-editions.md The entitlement).
///
/// Every test here goes through `Tier.resolve(from:now:)` with facts and a date the test wrote down
/// no StoreKit, no App Store account, no clock. That is the point of the seam: the semantics 12
/// rules are semantics about *cached facts and a date*, and everything else in the Pro stack
/// (`ProEntitlement`, `ProStorefront`) exists to produce those facts honestly. Pinning the decision
/// here is what makes the adapter thin enough to read.
///
/// The five states 12 names are the five suites' worth of cases below, in its own order:
/// active, lapsed, offline-grace, never-online, and free with free and never-online deliberately
/// arriving at the same input, because 12 says nothing may distinguish them.
@Suite("Tier ▸ the entitlement decision")
struct TierDecisionTests {
/// A fixed instant, so nothing here depends on when the suite runs.
static let now = Date(timeIntervalSinceReferenceDate: 800_000_000)
private static func resolve(_ facts: SubscriptionFacts) -> Tier {
Tier.resolve(from: facts, now: now)
}
// MARK: - Free
@Test("No cached transaction reads as the free tier")
func freeTier() {
#expect(Self.resolve(.none) == .free)
}
@Test("An auto-renew flag with no expiry behind it is still the free tier")
func autoRenewWithoutAnExpiryIsNotAnEntitlement() {
// The offline-grace hold is about an expiry that *passed*, never about the flag on its own.
// A fact set that carries a renew flag and no period is not a subscription in any state.
#expect(Self.resolve(SubscriptionFacts(expiration: nil, willAutoRenew: true)) == .free)
}
// MARK: - Active
@Test("An expiry in the future is Pro")
func activeSubscription() {
let facts = SubscriptionFacts(expiration: Self.now.addingTimeInterval(60 * 60 * 24 * 20), willAutoRenew: true)
#expect(Self.resolve(facts) == .pro)
}
@Test("An active subscription the user has already cancelled is Pro until its expiry")
func cancelledButStillInsideThePaidPeriod() {
// "A cancellation (auto-renew off) lapses **at expiry**" not when it is made. The paid
// period is paid for.
let facts = SubscriptionFacts(expiration: Self.now.addingTimeInterval(60 * 60 * 24 * 3), willAutoRenew: false)
#expect(Self.resolve(facts) == .pro)
}
@Test("Offline changes nothing about an active subscription")
func offlineWithAnActiveSubscription() {
// 12: "Offline with an active subscription is indistinguishable from online." Pinned by the
// absence of a reachability parameter as much as by this assertion there is nothing to
// pass in that could make this answer differently.
let facts = SubscriptionFacts(expiration: Self.now.addingTimeInterval(60 * 60 * 24 * 400), willAutoRenew: true)
#expect(Self.resolve(facts) == .pro)
}
// MARK: - Lapsed
@Test("A cancellation whose expiry has passed is the free tier")
func lapsedSubscription() {
// 12: "A cancellation (auto-renew off) lapses at expiry, offline or not."
let facts = SubscriptionFacts(expiration: Self.now.addingTimeInterval(-60), willAutoRenew: false)
#expect(Self.resolve(facts) == .free)
}
@Test("A long-lapsed cancellation is the same free tier as never having subscribed")
func lapsedAndUnsubscribedAreOneState() {
// "Unsubscribed and lapsed are **one state** the inert posture, nothing lost, histories
// frozen not forfeited." Nothing downstream may tell these two apart, and the decision is
// where that starts: one answer, from two histories.
let lapsed = SubscriptionFacts(expiration: Self.now.addingTimeInterval(-60 * 60 * 24 * 365), willAutoRenew: false)
#expect(Self.resolve(lapsed) == Self.resolve(.none))
}
// MARK: - Offline grace
@Test("An expiry that passed with auto-renew still on holds the entitlement")
func offlineGraceHoldsForThePayingUser() {
// 12: "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."
let facts = SubscriptionFacts(expiration: Self.now.addingTimeInterval(-60 * 60), willAutoRenew: true)
#expect(Self.resolve(facts) == .pro)
}
@Test("The offline-grace hold does not decay with time")
func theHoldHasNoTimeout() {
// Deliberately no expiry-of-the-expiry: the hold ends when StoreKit *answers*
// (`ProEntitlement.adopt`), not when a timer this function knows nothing about runs out.
// 12 weighs the cost and takes it: "a wrong hold gives away days of local commits Apple's
// own billing grace makes the same trade."
let facts = SubscriptionFacts(expiration: Self.now.addingTimeInterval(-60 * 60 * 24 * 90), willAutoRenew: true)
#expect(Self.resolve(facts) == .pro)
}
@Test("Auto-renew is the only thing separating a hold from a lapse")
func theFlagIsTheWholeDifference() {
let expired = Self.now.addingTimeInterval(-60 * 60 * 24)
#expect(Self.resolve(SubscriptionFacts(expiration: expired, willAutoRenew: true)) == .pro)
#expect(Self.resolve(SubscriptionFacts(expiration: expired, willAutoRenew: false)) == .free)
}
// MARK: - Never online
@Test("A fresh install that has never been online reads as the free tier")
func neverOnline() {
// 12: "A fresh install that has never been online has no cached transactions and reads as
// the free tier until the first refresh honest and self-correcting." The input is
// identical to `freeTier` above and that is the finding, not a duplicate: a device that has
// not asked yet and an account that never subscribed are the same state, and the app has no
// vocabulary for telling them apart.
#expect(Self.resolve(.none) == .free)
}
@Test("The first refresh is what corrects a never-online install")
func theFirstRefreshCorrectsIt() {
// The self-correction, as the decision sees it: nothing about the free answer is sticky
// the same function over the facts a refresh produces answers Pro immediately.
#expect(Self.resolve(.none) == .free)
let refreshed = SubscriptionFacts(expiration: Self.now.addingTimeInterval(60 * 60 * 24 * 30), willAutoRenew: true)
#expect(Self.resolve(refreshed) == .pro)
}
// MARK: - The boundary
@Test("An expiry falling exactly on now is past")
func theExpiryBoundaryIsExclusive() {
// A StoreKit expiration date is the instant the period ends, not the last instant it covers.
// With auto-renew off that means the lapse takes effect at the boundary rather than a tick
// after it; with auto-renew on the grace hold catches it, which is the paying user's side.
#expect(Self.resolve(SubscriptionFacts(expiration: Self.now, willAutoRenew: false)) == .free)
#expect(Self.resolve(SubscriptionFacts(expiration: Self.now, willAutoRenew: true)) == .pro)
}
@Test("The decision is a pure function of its two arguments")
func theSameInputsAlwaysAnswerTheSame() {
let facts = SubscriptionFacts(expiration: Self.now.addingTimeInterval(-1), willAutoRenew: true)
let answers = (0..<5).map { _ in Tier.resolve(from: facts, now: Self.now) }
#expect(Set(answers).count == 1)
// And a *different* date genuinely moves it the clock is an argument, not decoration.
let before = Tier.resolve(from: facts, now: Self.now.addingTimeInterval(-60))
#expect(before == .pro, "still inside the paid period")
}
}
// MARK: - The cached facts
/// The entitlement's cache, exercised through its injected `UserDefaults` the same injection every
/// other app-side store in this project takes (`AppStateHome`), and here it also keeps a suite from
/// editing the developer's own subscription state.
///
/// StoreKit itself is deliberately absent: `ProEntitlement.start()` and
/// `refreshFromLocalTransactions()` are the only members that touch it, neither is called here, and
/// what is left is exactly the seam worth pinning that the facts round-trip, that the tier follows
/// them, and that adopting `.none` ends a hold.
@MainActor
@Suite("Tier ▸ the entitlement's cache")
struct ProEntitlementCacheTests {
/// A defaults domain of this suite's own. Removed at the end of each test so the cases cannot
/// see each other's writes.
private static func makeDefaults() -> (UserDefaults, () -> Void) {
let name = "dev.rzen.indie.Kanban.tier-tests.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: name)!
return (defaults, { UserDefaults.standard.removePersistentDomain(forName: name) })
}
@Test("A fresh domain has no facts, and the tier is free")
func emptyCache() {
let (defaults, tearDown) = Self.makeDefaults()
defer { tearDown() }
let entitlement = ProEntitlement(defaults: defaults)
#expect(entitlement.facts == .none)
#expect(entitlement.tier == .free)
}
@Test("Adopted facts survive into the next launch")
func factsRoundTrip() {
let (defaults, tearDown) = Self.makeDefaults()
defer { tearDown() }
let facts = SubscriptionFacts(expiration: Date().addingTimeInterval(60 * 60 * 24 * 30), willAutoRenew: true)
ProEntitlement(defaults: defaults).adopt(facts)
// A second object over the same domain is what a relaunch is.
let relaunched = ProEntitlement(defaults: defaults)
#expect(relaunched.facts == facts)
#expect(relaunched.tier == .pro)
}
@Test("Adopting nothing ends the entitlement — StoreKit's definitive answer")
func adoptingNoneEndsAHold() {
let (defaults, tearDown) = Self.makeDefaults()
defer { tearDown() }
let entitlement = ProEntitlement(defaults: defaults)
// An offline-grace hold: the expiry passed, the last we heard it was renewing.
entitlement.adopt(SubscriptionFacts(expiration: Date().addingTimeInterval(-60), willAutoRenew: true))
#expect(entitlement.tier == .pro)
// and then StoreKit answers (a revocation, or a group status reading expired).
entitlement.adopt(.none)
#expect(entitlement.tier == .free)
#expect(ProEntitlement(defaults: defaults).tier == .free, "and the answer is cached, not just held")
}
@Test("A cache that will not decode is treated as no cache")
func aCorruptCacheIsEmpty() {
let (defaults, tearDown) = Self.makeDefaults()
defer { tearDown() }
defaults.set(Data("not json".utf8), forKey: AppPreferences.subscriptionFactsKey)
#expect(ProEntitlement(defaults: defaults).tier == .free)
}
}