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
+24 -3
View File
@@ -33,6 +33,7 @@ interface FormState {
holderName: string;
contact: string;
planId: string; // selected plan (sells/prices it); "" = comp (no charge)
quantity: string; // cars covered by this one subscription (price ×N)
tender: "cash" | "card"; // how the sale fee is collected (cash → drawer, card → bank)
carBound: boolean; // false = unbound (maxConcurrent null)
maxConcurrent: string;
@@ -52,6 +53,7 @@ function emptyForm(): FormState {
holderName: "",
contact: "",
planId: "",
quantity: "1",
tender: "cash",
carBound: true,
maxConcurrent: "1",
@@ -66,6 +68,7 @@ function formFrom(s: Subscription): FormState {
holderName: s.holderName ?? "",
contact: s.contact ?? "",
planId: s.planId ?? "", // edit doesn't re-sell; plan shown read-only
quantity: String(s.quantity ?? 1),
tender: "cash", // edit doesn't re-collect money; tender only matters on a new sale
carBound: s.maxConcurrent != null,
maxConcurrent: s.maxConcurrent != null ? String(s.maxConcurrent) : "1",
@@ -97,6 +100,7 @@ function toInput(f: FormState, isNew: boolean): SubscriptionInput {
// A SALE: send the chosen plan; price is looked up server-side. On edit we never
// re-sell, so no planId is sent (price/plan stay frozen).
planId: planSelected ? f.planId.trim() : null,
quantity: Math.max(1, Math.round(Number(f.quantity) || 1)),
tender: f.tender,
maxConcurrent: f.carBound ? Math.max(1, Math.round(Number(f.maxConcurrent) || 1)) : null,
validFrom: dateToISO(f.validFrom),
@@ -165,10 +169,11 @@ export function SubscriptionManager() {
setQuote(null);
return;
}
const quantity = Math.max(1, Math.round(Number(form.quantity) || 1));
let cancelled = false;
setQuoting(true);
const h = setTimeout(() => {
quoteSubscription({ planId: form.planId.trim(), validFrom: from, validTo: to })
quoteSubscription({ planId: form.planId.trim(), validFrom: from, validTo: to, quantity })
.then((q) => !cancelled && setQuote(q))
.catch(() => !cancelled && setQuote(null))
.finally(() => !cancelled && setQuoting(false));
@@ -177,7 +182,7 @@ export function SubscriptionManager() {
cancelled = true;
clearTimeout(h);
};
}, [editing, form.planId, form.validFrom, form.validTo]);
}, [editing, form.planId, form.validFrom, form.validTo, form.quantity]);
function startNew() {
setForm(emptyForm());
@@ -378,6 +383,22 @@ export function SubscriptionManager() {
<span className="text-[13px] text-term-text">{form.planId || t("subs.noPrice")}</span>
</>
)}
{/* Quantity — cars covered by this ONE subscription (a family pays once for
N cars). Price ×N; maxConcurrent below pre-fills to it. */}
{form.planId.trim() !== "" && editing === "new" && (
<>
<label className="label">{t("subs.quantity")}</label>
<span className="flex flex-wrap items-center gap-2">
<input
className="input w-16"
value={form.quantity}
inputMode="numeric"
onChange={(e) => setForm((f) => ({ ...f, quantity: e.target.value, maxConcurrent: e.target.value }))}
/>
<span className="text-[12px] text-term-muted">{t("subs.quantityHint")}</span>
</span>
</>
)}
{/* Tender — only relevant when selling a plan (a SALE). The sale appends a
signed payment so the money shows in the feed/drawer/Z-report. */}
{form.planId.trim() !== "" && editing === "new" && (
@@ -434,7 +455,7 @@ export function SubscriptionManager() {
unit: t(PERIOD_KEY[quote.period]),
amount: (quote.amountMinor / 100).toLocaleString(),
currency: quote.currency,
})
}) + (quote.quantity && quote.quantity > 1 ? ` (×${quote.quantity})` : "")
: t("subs.quotePrompt")}
</span>
)}