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
+26 -1
View File
@@ -494,6 +494,20 @@ export interface SubscriptionCredential {
}
export type SubscriptionPeriod = "day" | "week" | "month";
/** A subscriber's allowed parking window for a day-type (minutes-from-local-midnight).
* A scan outside the window is charged the transient tariff for the gap. */
export interface DayWindow {
allDay?: boolean;
fromMin?: number;
toMin?: number;
}
export interface PlanTimeframes {
weekday?: DayWindow;
weekend?: DayWindow;
graceMin?: number;
tz?: string;
}
/** A subscription PLAN version — admin-composed, versioned config the operator sells
* from (so they never type a price). */
export interface SubscriptionPlan {
@@ -505,6 +519,8 @@ export interface SubscriptionPlan {
currency: string;
effectiveFrom: string;
active: boolean;
/** Allowed-time windows (tariff bridge); null/absent = 24/7, no time charge. */
timeframes?: PlanTimeframes | null;
}
export interface Subscription {
@@ -518,6 +534,8 @@ export interface Subscription {
/** Which plan + immutable version priced this sale (null for legacy/comp). */
planId: string | null;
planVersionId: string | null;
/** Cars covered by this one subscription (price was ×N). Default 1. */
quantity: number;
maxConcurrent: number | null;
validFrom: string | null;
validTo: string | null;
@@ -540,6 +558,8 @@ export type SubscriptionInput = {
/** Coverage window. Priced sale: validFrom defaults to now, validTo required. */
validFrom: string | null;
validTo: string | null;
/** Cars covered (price ×N). Default 1. */
quantity?: number;
maxConcurrent: number | null;
status?: Subscription["status"];
/** How the sale fee was tendered (cash → drawer, card → bank). Used at CREATE when a
@@ -549,12 +569,13 @@ export type SubscriptionInput = {
plates: string[];
};
/** A server-computed quote: periods (ceil) × per-period price for a span. */
/** A server-computed quote: periods (ceil) × per-period price × quantity for a span. */
export interface SubscriptionQuote {
periods: number;
amountMinor: number;
currency: string;
period: SubscriptionPeriod;
quantity?: number;
plan: SubscriptionPlan;
}
@@ -589,6 +610,7 @@ export function createSubscriptionPlan(body: {
pricePerPeriodMinor: number;
currency: string;
effectiveFrom?: string;
timeframes?: PlanTimeframes | null;
}): Promise<SubscriptionPlan> {
return apiFetch("/api/subscription-plans", { method: "POST", body: JSON.stringify(body) });
}
@@ -600,6 +622,7 @@ export function quoteSubscription(body: {
planId: string;
validFrom: string | null;
validTo: string;
quantity?: number;
}): Promise<SubscriptionQuote> {
return apiFetch("/api/subscriptions/quote", { method: "POST", body: JSON.stringify(body) });
}
@@ -779,6 +802,8 @@ export interface SiteConfig {
exitVoucherDefault: boolean;
/** Site default monthly subscription price (minor units); pre-fills the form. */
subscriptionMonthlyPriceMinor: number | null;
/** Reserve a spot for each active subscriber's car(s) in the occupancy/full gate. */
reserveSubscriberSpots: boolean;
parkName: string | null;
operatorName: string | null;
/** NIUS — Albanian tax/identification number. */