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