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
+33 -7
View File
@@ -46,6 +46,7 @@ export const en: Catalog = {
tariff: "Tariff",
tariffLab: "Tariff Lab",
subscriptions: "Subscriptions",
plans: "Plans",
site: "Site",
users: "Users",
roles: "Roles",
@@ -382,9 +383,15 @@ export const en: Catalog = {
cred: "cred",
plates: "{{count}} plate(s)",
noPrice: "no price",
perDay: "day",
perWeek: "week",
perMonth: "month",
monthlyPrice: "Monthly price",
pricePlaceholder: "e.g. 10000",
plan: "Plan",
planNone: "— comp / no charge —",
planNoneAvail: "No plans defined — an admin must create one first.",
quoting: "pricing…",
quotePrompt: "pick an end date",
quoteLine: "{{periods}} × {{unit}} · {{amount}} {{currency}}",
tender: "Paid by",
tenderCash: "Cash",
tenderCard: "Card",
@@ -404,11 +411,7 @@ export const en: Catalog = {
limitCarsInAtOnce: "limit cars in at once",
validFrom: "Valid from",
validTo: "Valid to",
months: "Months",
monthsHint: "months paid",
coverageHint: "until {{end}}",
totalDue: "total {{total}}",
validToOverride: "Valid to (manual)",
validToEnd: "Valid to (end)",
isoDateOptional: "ISO date (optional)",
boundPlates: "Bound plates",
commaSeparatedOptional: "comma-separated (optional)",
@@ -441,6 +444,29 @@ export const en: Catalog = {
statusSuspended: "suspended",
statusRevoked: "revoked",
},
plans: {
title: "Subscription plans",
intro: "Admin-defined plans the operator sells from — the price is looked up, never typed. Editing a plan publishes a new version; past sales keep their recorded price.",
add: "+ Add plan",
noneYet: "No plans yet. Add one so the booth can sell subscriptions.",
colName: "Name",
colPrice: "Price",
colEffective: "Effective",
inForce: "in force",
retired: "retired",
newVersion: "New version",
retire: "Retire",
newTitle: "New plan",
newVersionTitle: "Publish new version",
newVersionHint: "This publishes a NEW version of the plan — existing sales keep their original price.",
period: "Period",
pricePer: "Price per period",
namePlaceholder: "e.g. Hotel daily",
needName: "A plan name is required.",
needPrice: "Enter a price greater than zero.",
saved: "Plan saved.",
confirmRetire: "Retire the plan “{{name}}”? It will no longer be sellable (history is kept).",
},
site: {
occupancy: "Occupancy:",
noCapacitySet: "(no capacity set)",