feat: subscription v2 — quantity pricing, plan timeframes (tariff bridge), reserved spots

Three subscriber enhancements driven by real scenarios (migration 0011, all
additive columns — backward-compatible).

1. QUANTITY. One subscription covers N cars (a family pays once for two). Sale
   amount = span price × quantity; maxConcurrent defaults to the quantity so all
   N cars can be inside. Quantity rides in the payment payload.

2. PLAN TIMEFRAMES → TARIFF BRIDGE. A plan may restrict WHEN a subscriber may
   park (e.g. weekday 20:00→08:00, weekend all-day). A scan outside the window is
   NOT refused — the out-of-window minutes are charged at the normal TRANSIENT
   tariff (the subscriber is a transient for that time):
     - early entry: arrival → window-open, DEFERRED (signed as windowOwedMinor on
       the vehicle_entry payload), collected at exit;
     - late exit: window-close → departure, and exit is GATED
       (sub.refused.unpaidWindow) until paid at the booth.
   Pure, tz-aware outOfWindowGap in @parking/shared (12 unit tests); pricing
   reuses computeFee + the active tariff version
   (apps/server/src/subscription-window.ts). The exit refusal is a host-ONLINE
   business gate — the fail-open rule still governs the offline path.

3. RESERVED SPOTS. Site toggle reserve_subscriber_spots: occupancy holds
   max(0, quantity − itsCarsInside) per active subscription, so transients see
   "full" sooner; effectiveFree = capacity − count − reserved. Subscribers are
   never gated by full.

UI: quantity field + ×N quote (SubscriptionManager); timeframes editor
(SubscriptionPlansManager); reserve checkbox (SiteSettings); booth pay modal
shows an "OUT-OF-WINDOW" charge and takes payment to clear the exit gate.

Verified on a copy of the live DB: qty 2 = 2× price; a night-plan 19:30 entry →
30min/15,000 ALL owed, stamped + paid → gate clears, chain verifies; the reserve
toggle holds a qty-2 sub's 2 spots. Build+lint 12/12; 80 shared tests pass.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
2026-06-20 18:22:50 +02:00
parent fd4608a8f1
commit 53e1e7b25c
23 changed files with 929 additions and 40 deletions
+16
View File
@@ -387,6 +387,8 @@ export const en: Catalog = {
perWeek: "week",
perMonth: "month",
plan: "Plan",
quantity: "Cars",
quantityHint: "cars covered by this subscription (price ×N)",
planNone: "— comp / no charge —",
planNoneAvail: "No plans defined — an admin must create one first.",
quoting: "pricing…",
@@ -466,6 +468,16 @@ export const en: Catalog = {
needPrice: "Enter a price greater than zero.",
saved: "Plan saved.",
confirmRetire: "Retire the plan “{{name}}”? It will no longer be sellable (history is kept).",
needWindow: "Enter valid window times (HH:MM).",
restrictTimes: "Restrict parking times (charge transient tariff outside the window)",
weekdayWindow: "Weekday",
enterAfter: "enter after",
exitBefore: "· exit before",
weekend: "Weekend",
weekendAllDay: "all day (no restriction)",
grace: "Grace",
graceHint: "minutes tolerance around the window edges",
timeframesHint: "A scan outside the allowed window is charged the normal transient tariff for the out-of-window minutes (early entry is deferred to exit; late exit is gated until paid).",
},
site: {
occupancy: "Occupancy:",
@@ -476,6 +488,8 @@ export const en: Catalog = {
capacityPlaceholder: "e.g. 120",
printExitDefault: "Print exit ticket by default",
printExitHint: "(booth far from exit → customer self-exits with a voucher)",
reserveSubs: "Reserve subscriber spots",
reserveSubsHint: "Hold a spot for each active subscriber's car(s) even when they're not parked — transients see 'full' sooner. Off: only cars inside count (handle overflow by valet).",
parkDetails: "Park details (optional — shown on tickets/receipts)",
save: "Save",
saved: "Saved.",
@@ -659,6 +673,8 @@ export const en: Catalog = {
plan: "Plan",
prepaid: "PREPAID",
subAssistHint: "Prepaid subscription. Open the barrier to assist the exit (faulty reader / missing card). No payment.",
windowCharge: "OUT-OF-WINDOW",
windowChargeHint: "This subscriber parked outside their plan's allowed hours. They owe the transient tariff for the out-of-window time — take payment to allow the exit.",
subBarrierOpened: "Barrier opened for the subscriber (intervention recorded).",
voucherPrinted: "Exit voucher printed on {{printer}}. Customer self-exits at the exit.",
// payment receipt (transparency slip)
+16
View File
@@ -398,6 +398,8 @@ export const sq = {
perWeek: "javë",
perMonth: "muaj",
plan: "Plani",
quantity: "Makina",
quantityHint: "makina të mbuluara nga ky abonim (çmimi ×N)",
planNone: "— pa pagesë / falas —",
planNoneAvail: "Asnjë plan i përcaktuar — admini duhet të krijojë një të parin.",
quoting: "duke llogaritur…",
@@ -477,6 +479,16 @@ export const sq = {
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).",
needWindow: "Shkruaj orare të vlefshme (HH:MM).",
restrictTimes: "Kufizo oraret e parkimit (tarifë kalimtare jashtë intervalit)",
weekdayWindow: "Ditë pune",
enterAfter: "hyrje pas",
exitBefore: "· dalje para",
weekend: "Fundjavë",
weekendAllDay: "gjithë ditën (pa kufizim)",
grace: "Tolerancë",
graceHint: "minuta tolerancë rreth kufijve të intervalit",
timeframesHint: "Një skanim jashtë intervalit të lejuar tarifohet me tarifën normale kalimtare për minutat jashtë intervalit (hyrja e hershme shtyhet në dalje; dalja e vonuar bllokohet derisa paguhet).",
},
site: {
occupancy: "Prania:",
@@ -487,6 +499,8 @@ export const sq = {
capacityPlaceholder: "p.sh. 120",
printExitDefault: "Printo biletën e daljes si parazgjedhje",
printExitHint: "(klienti skanon biletën në dalje)",
reserveSubs: "Rezervo vendet e abonentëve",
reserveSubsHint: "Mban një vend për makinat e çdo abonenti aktiv edhe kur nuk janë të parkuar — kalimtarët e shohin 'plot' më shpejt. Joaktiv: numërohen vetëm makinat brenda (mbingarkesa menaxhohet me parkim manual).",
parkDetails: "Të dhënat e parkimit (opsionale — shfaqen në bileta/fatura)",
save: "Ruaj",
saved: "U ruajt.",
@@ -673,6 +687,8 @@ export const sq = {
plan: "Plani",
prepaid: "I PARAPAGUAR",
subAssistHint: "Abonim i parapaguar. Hap barrierën për të ndihmuar daljen (lexues me defekt / kartë e munguar). S'ka pagesë.",
windowCharge: "JASHTË ORARIT",
windowChargeHint: "Ky abonent parkoi jashtë orarit të lejuar të planit. Detyrohet të paguajë tarifën kalimtare për kohën jashtë orarit — merr pagesën për të lejuar daljen.",
subBarrierOpened: "Barriera u hap për abonentin (ndërhyrje e regjistruar).",
voucherPrinted: "Bileta e daljes u printua në {{printer}}. Klienti del vetë te dalja.",
// payment receipt (transparency slip)