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