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