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
+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.