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