feat: subscription plan catalog — config-defined pricing, dated spans, no typed amounts

Re-model subscription pricing from per-row, operator-typed prices into an
admin-composed, versioned PLAN CATALOG (the tariff pattern). The operator now
SELLS by picking a plan over a date span; the price is LOOKED UP, never typed —
removing the fat-finger risk on a money field — and day/week/month periods make
the hotel "guest stays 1–N days" case a daily plan over a check-in→check-out span.

- Schema/migration 0010: new `subscription_plans` (immutable, effective-dated,
  keyed by a stable planId; period day/week/month + per-period price + active
  flag). `subscriptions` gains planId/planVersionId; period enum widened. Seeds a
  "Monthly" plan from the existing site default price (no data loss).
- Pricing (pure, unit-tested in @parking/shared): periods = ceil(span / period),
  amount = periods × per-period price. Ceil = any started period is full (hotel
  practice). `resolvePlanVersion` picks the latest active version ≤ sale instant.
- Backend: new admin-only plan CRUD (`subscription:plan` permission); reworked
  sell path derives the amount from the plan; `POST /api/subscriptions/quote`
  returns a server-computed quote so the operator can't override it. The
  signed-payment sale fix is unchanged — only the amount SOURCE moved; payload
  now carries planId/planVersionId/periods. Updates never re-sell (price frozen).
- Frontend: SubscriptionManager sell form swaps the price field for a plan
  picker + start/end dates + a live quote line. New SubscriptionPlansManager
  (Setup tab) for the admin catalog. i18n (sq+en) for both.

Verified on a copy of the live DB: 0010 applies (existing subs intact), a
3-night hotel sale prices to 2,400 ALL, appends one signed payment with
planVersionId, chain verifies. Build+lint 12/12; 68 shared tests pass.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
2026-06-20 17:13:42 +02:00
parent 052da8c3a7
commit fd4608a8f1
19 changed files with 1022 additions and 223 deletions
+6
View File
@@ -14,6 +14,12 @@ not code** — the park owner builds and constantly edits the rate card at runti
reprice. The computation is **pure and offline** ([[offline-first]]: no network, no clock authority
beyond the host).
> **Shared pattern (2026-06-20):** [[subscription]] pricing now uses this same model — a
> **versioned, effective-dated, admin-composed catalog** (`subscription_plans`), resolved by "latest
> active version with `effectiveFrom ≤ sale`", with the sale persisting its `planVersionId` for
> reproducible repricing. The operator selects a plan + span; the price is looked up, never typed.
> Tariffs price *transient* stays by duration; plans price *subscription* spans by ceil(periods).
> Decisions (2026-06-15): (1) tariffs are **effective-dated, immutable versions** — editing
> publishes a new version, never mutates an old one; (2) **one active tariff per site** (versioned
> over time), modelled with an id/scope so multiple rate cards can be added later without migration;
+47 -13
View File
@@ -3,6 +3,7 @@ type: entity
tags: [parking, domain, business, subscriptions, identity, pricing]
sources: []
updated: 2026-06-20
aliases: [subscription-plan]
status: open
---
@@ -19,19 +20,48 @@ Transient is built first; subscriptions layer on top.
> hash-chained history, so renaming it would break verification of past events. So: *code & data =
> "subscription"; the on-chain field name stays `permitId`.* See the schema note in `schema.ts`.
## Pricing — recurring monthly plan (built 2026-06-18)
## Pricing — config-defined PLAN catalog (re-modelled 2026-06-20)
Each subscription records its **own price**, so an individual and a company fleet can differ:
A subscription is **a priced product like a [[tariff]]**, not a hand-typed number. The operator
**SELECTS an admin-defined plan over a date span**; the price is **looked up** (never typed). This
fixed two flaws in the original per-row model: (1) the operator keyed the price by hand — a
fat-finger (a dropped/extra zero) on a money field; (2) only `"monthly"` was expressible, so a
**hotel** buying parking for a guest staying **1–N days** couldn't be priced.
- `priceMinor` — the recurring price in **minor units** (integer; e.g. `1000000` = 10,000.00).
`null` = no price set (a comp / legacy subscription).
- `period` — the billing period. **`"monthly"` only** today (the enum is widened later if a site
ever needs weekly/annual).
- `currency` — ISO-4217 of `priceMinor` (e.g. `"ALL"`); required when a price is set.
**The plan catalog** (`subscription_plans`, mirrors `tariff_versions` — immutable, effective-dated,
admin-only):
A **site default monthly price** lives in `site_config.subscription_monthly_price_minor` — it
merely **pre-fills** the new-subscription form; each subscription still stores its own value and may
override.
- `planId` — stable identity across versions (e.g. `"hotel-daily"`); a price change = a NEW row.
- `name`, `period` (**`"day" | "week" | "month"`**), `pricePerPeriodMinor`, `currency`.
- `effectiveFrom` — the latest active version with `effectiveFrom ≤ sale instant` prices a sale (the
tariff-resolve rule). `active` — soft-retire (0) without deleting history.
**Pricing a span** (`priceSubscriptionSpan`, pure + unit-tested in `@parking/shared`):
```
periods = ceil( (validTo − validFrom) / one plan period ) // any STARTED period is full
amountMinor = periods × pricePerPeriodMinor
```
**Ceil** matches hotel/parking practice — a guest checking out mid-day still owes that day (Mon
14:00 → Wed 10:00 on a daily plan = **2** days). The hotel case is just a `"day"` plan over a
check-in→check-out span. **`POST /api/subscriptions/quote`** returns this server-computed quote so the
sell form shows "3 × day · 2,400 ALL" live — **the operator can't override the amount**.
**Authority (admin-only):** composing the catalog needs the new **`subscription:plan`** permission
(admin-grade); **selling** stays `subscription:create` (operator-grade). The operator picks; only an
admin defines/edits prices. Editing a plan **publishes a new version** (new `effectiveFrom`), never
mutates an old one — past sales keep their recorded `planVersionId` and reprice identically.
**On the subscription row:** `priceMinor`/`currency`/`period` are now **derived from the plan** at
sale, plus `planId` + `planVersionId` (which version priced it — reproducible, like a payment's
`tariffVersionId`). An **update never re-sells** (price/plan frozen); a new price = a new sale.
> **Superseded — per-row typed price (built 2026-06-18).** Originally each subscription stored its own
> `priceMinor` + `period:"monthly"`, typed by the operator and pre-filled from
> `site_config.subscription_monthly_price_minor`. That column is **kept only to seed a "Monthly" plan**
> in migration `0010`; the sell path no longer reads it. The signed-`payment` sale fix (below) is
> unchanged — only the *amount source* moved from "typed × months" to "plan quote".
### Multi-month: pay N months → extend `validTo` (built 2026-06-18)
@@ -69,8 +99,10 @@ Selling/renewing a subscription is a **financial transaction a common operator m
**As built** (chosen of the two options below): a subscription **sold with a price** appends a signed
**`payment`** ledger event — the same shape the transient pay-station uses — at create time:
- **Amount = the full sale.** `priceMinor × months` (a 3-month prepay records all 30,000 today, not
one month), so the ledger matches what's actually in the drawer.
- **Amount = the full sale, from the PLAN quote.** `ceil(periods) × pricePerPeriodMinor` for the
selected plan over the span (e.g. 3 nights × 800 = 2,400 ALL) — looked up, never typed (re-modelled
2026-06-20; was `priceMinor × months`). The payload also carries `planId`/`planVersionId`/`periods`
for audit + reproducible repricing. The ledger matches what's actually in the drawer.
- **Tender is operator-chosen** (cash/card) on the create form, defaulting to cash. Cash enters the
drawer; card settles to the bank — identical to the parking pay path.
- **Folds into the shift automatically** — no new summing logic. The Z-report sums `payment` events in
@@ -240,7 +272,9 @@ Tables (mutable master data; every *use* still produces a signed `vehicle_entry`
| Table / field | Notes |
| --- | --- |
| `subscriptions.id`, `holderName`, `contact` | the subscriber |
| `subscriptions.priceMinor` / `period` / `currency` | recurring plan (monthly); null price = unset |
| `subscriptions.priceMinor` / `period` / `currency` | **derived from the plan** at sale; null = comp |
| `subscriptions.planId` / `planVersionId` | which plan + immutable version priced the sale (null = comp/legacy) |
| `subscription_plans[]` | admin-composed plan catalog: `{ planId, name, period(day/week/month), pricePerPeriodMinor, currency, effectiveFrom, active }` — immutable versions |
| `subscriptions.maxConcurrent` | car-count binding; **default 1**, raise for fleets, `null` = unbound |
| `subscriptions.validFrom` / `validTo` / `status` | coverage window; active / suspended / revoked |
| `subscription_credentials[]` | `{ kind: 'rf' \| 'qr', value }` |
+21
View File
@@ -1131,3 +1131,24 @@ UI: a "Takings so far" button on the shift control reveals a cyan X-report panel
keeps the live drawer total. Verified on a copy of the live DB: matches drawerBalance(),
drawer identity holds (expected = opening + cash + added − removed), 0 events appended, chain
verifies. Build + lint 12/12. Resolves the X-report item flagged the same day in [[shift]].
## [2026-06-20] feat | Subscription plan catalog — config-defined, dated spans, no typed prices
Re-modelled subscription pricing from per-row operator-typed `priceMinor` + monthly-only `period`
into an admin-composed, versioned PLAN CATALOG (the tariff pattern). Plans (`subscription_plans`,
migration 0010) are immutable effective-dated versions keyed by a stable planId, with period ∈
day/week/month + per-period price. The operator SELLS by selecting a plan over a date span (start
defaults to today, end required); the price is LOOKED UP — periods = ceil(span / period), amount =
periods × per-period price (ceil = any started period is full; hotel/parking practice). The hotel
1–N day case is a daily plan over a check-in→check-out span. `POST /api/subscriptions/quote` gives a
live server-computed quote so the operator can't override the amount. New `subscription:plan`
permission (admin-only) composes the catalog; selling stays operator-grade `subscription:create`.
The signed-`payment` sale fix is unchanged — only the amount SOURCE moved to the plan quote; payload
now carries planId/planVersionId/periods. Pure span math lives + is unit-tested in @parking/shared
(68 tests incl. ceil/Jan-31 clamp). New SubscriptionPlansManager screen (Setup tab) + reworked
SubscriptionManager sell form (plan picker + dates + quote, no price field). Verified on a copy of
the live DB: 0010 applies (existing subs intact, monthly plan seeds from site default), a 3-night
hotel sale prices to 2,400 ALL, appends ONE signed payment with planVersionId, chain verifies.
Build + lint 12/12. Updated [[subscription]] (plan catalog supersedes typed price; data model) +
[[tariff]] (shared versioned-config pattern). The site default price column is kept only to seed the
first plan.