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
+34 -8
View File
@@ -48,6 +48,7 @@ export const sq = {
tariff: "Tarifa",
tariffLab: "Lab Tarife",
subscriptions: "Abonimet",
plans: "Planet",
site: "Park",
users: "Përdoruesit",
roles: "Rolet",
@@ -393,9 +394,15 @@ export const sq = {
cred: "kredencial",
plates: "{{count}} targë(a)",
noPrice: "pa çmim",
perDay: "ditë",
perWeek: "javë",
perMonth: "muaj",
monthlyPrice: "Çmimi mujor",
pricePlaceholder: "p.sh. 10000",
plan: "Plani",
planNone: "— pa pagesë / falas —",
planNoneAvail: "Asnjë plan i përcaktuar — admini duhet të krijojë një të parin.",
quoting: "duke llogaritur…",
quotePrompt: "zgjidh datën e mbarimit",
quoteLine: "{{periods}} × {{unit}} · {{amount}} {{currency}}",
tender: "Paguar me",
tenderCash: "Para në dorë",
tenderCard: "Kartë",
@@ -415,11 +422,7 @@ export const sq = {
limitCarsInAtOnce: "kufizo makinat brenda njëkohësisht",
validFrom: "Vlen nga",
validTo: "Vlen deri",
months: "Muaj",
monthsHint: "muaj të paguar",
coverageHint: "deri më {{end}}",
totalDue: "gjithsej {{total}}",
validToOverride: "Vlen deri (manual)",
validToEnd: "Vlen deri (mbarimi)",
isoDateOptional: "Datë ISO (opsionale)",
boundPlates: "Targat e lidhura",
commaSeparatedOptional: "të ndara me presje (opsionale)",
@@ -452,6 +455,29 @@ export const sq = {
statusSuspended: "pezulluar",
statusRevoked: "anuluar",
},
plans: {
title: "Planet e abonimit",
intro: "Planet i përcakton admini; operatori vetëm shet prej tyre — çmimi merret automatikisht, nuk shkruhet. Ndryshimi i një plani publikon një version të ri; shitjet e mëparshme ruajnë çmimin e tyre.",
add: "+ Shto plan",
noneYet: "Asnjë plan ende. Shto një që kabina të shesë abonime.",
colName: "Emri",
colPrice: "Çmimi",
colEffective: "Vlen nga",
inForce: "në fuqi",
retired: "i tërhequr",
newVersion: "Version i ri",
retire: "Tërhiq",
newTitle: "Plan i ri",
newVersionTitle: "Publiko version të ri",
newVersionHint: "Kjo publikon një version TË RI të planit — shitjet ekzistuese ruajnë çmimin origjinal.",
period: "Periudha",
pricePer: "Çmimi për periudhë",
namePlaceholder: "p.sh. Hotel ditor",
needName: "Emri i planit është i detyrueshëm.",
needPrice: "Shkruaj një çmim më të madh se zero.",
saved: "Plani u ruajt.",
confirmRetire: "Të tërhiqet plani “{{name}}”? Nuk do të jetë më i shitshëm (historiku ruhet).",
},
site: {
occupancy: "Prania:",
noCapacitySet: "(pa kapacitet të caktuar)",
@@ -460,7 +486,7 @@ export const sq = {
capacityLabel: "Kapaciteti (bosh = pa kufi):",
capacityPlaceholder: "p.sh. 120",
printExitDefault: "Printo biletën e daljes si parazgjedhje",
printExitHint: "(kabina larg daljes → klienti del vetë me biletë)",
printExitHint: "(klienti skanon biletën në dalje)",
parkDetails: "Të dhënat e parkimit (opsionale — shfaqen në bileta/fatura)",
save: "Ruaj",
saved: "U ruajt.",