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:
@@ -1,4 +1,4 @@
|
||||
import { eq, ledgerEvents, siteConfig, type Db } from "@parking/db";
|
||||
import { eq, ledgerEvents, siteConfig, subscriptions, type Db } from "@parking/db";
|
||||
|
||||
// Occupancy = a FOLD over the signed ledger: the count of vehicle_entry events
|
||||
// with no matching vehicle_exit. Never a hand-maintained counter (which is
|
||||
@@ -7,11 +7,18 @@ import { eq, ledgerEvents, siteConfig, type Db } from "@parking/db";
|
||||
export interface Occupancy {
|
||||
/** Cars currently inside (open sessions). */
|
||||
readonly count: number;
|
||||
/** Spots HELD for active subscribers who are NOT currently parked (when the
|
||||
* reserve-subscriber-spots toggle is on; 0 otherwise). Each active subscription holds
|
||||
* `quantity` spots minus however many of its cars are already inside. */
|
||||
readonly reserved: number;
|
||||
/** Admin-set nominal capacity, or null = no limit. */
|
||||
readonly capacity: number | null;
|
||||
/** capacity − count, or null when uncapped. Can read 0 (or below) when full. */
|
||||
readonly free: number | null;
|
||||
/** True when count ≥ capacity (always false when uncapped). */
|
||||
/** Effective free for a TRANSIENT car = capacity − count − reserved (null uncapped). */
|
||||
readonly effectiveFree: number | null;
|
||||
/** True when a TRANSIENT entry should be refused: count + reserved ≥ capacity
|
||||
* (always false when uncapped). Subscribers are never gated by this. */
|
||||
readonly full: boolean;
|
||||
}
|
||||
|
||||
@@ -37,13 +44,66 @@ export function siteCapacity(db: Db): number | null {
|
||||
return row?.capacity ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Spots to RESERVE for active subscribers who aren't currently parked. Off (0) unless
|
||||
* `site_config.reserve_subscriber_spots` is set. For each ACTIVE subscription (status
|
||||
* active AND now ∈ [validFrom, validTo]), hold `quantity` spots minus the cars of that
|
||||
* subscription already inside (so we never double-count a parked subscriber). This is
|
||||
* what makes a transient see "full" sooner while the subscriber's spot is held.
|
||||
*/
|
||||
export function reservedSubscriberSpots(db: Db): number {
|
||||
const cfg = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||
if (!cfg?.reserveSubscriberSpots) return 0;
|
||||
|
||||
// Cars currently inside per subscription (occurrence entries by permitId, net of exits).
|
||||
const rows = db.select().from(ledgerEvents).orderBy(ledgerEvents.index).all();
|
||||
const insidePerSub = new Map<string, number>();
|
||||
const net = new Map<string, number>(); // occurrence identity → entries−exits
|
||||
const subOf = new Map<string, string>(); // occurrence identity → subscription id
|
||||
for (const r of rows) {
|
||||
const id = r.identity;
|
||||
if (!id) continue;
|
||||
if (r.type === "vehicle_entry") {
|
||||
const pl = (r.payload ?? {}) as { permitId?: string };
|
||||
if (pl.permitId == null) continue; // transient
|
||||
net.set(id, (net.get(id) ?? 0) + 1);
|
||||
subOf.set(id, pl.permitId);
|
||||
} else if (r.type === "vehicle_exit") {
|
||||
if (net.has(id)) net.set(id, (net.get(id) ?? 0) - 1);
|
||||
}
|
||||
}
|
||||
for (const [id, n] of net) if (n > 0) {
|
||||
const sub = subOf.get(id)!;
|
||||
insidePerSub.set(sub, (insidePerSub.get(sub) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const subs = db.select().from(subscriptions).all();
|
||||
let reserved = 0;
|
||||
for (const s of subs) {
|
||||
const active =
|
||||
s.status === "active" &&
|
||||
(s.validFrom == null || now >= s.validFrom) &&
|
||||
(s.validTo == null || now <= s.validTo);
|
||||
if (!active) continue;
|
||||
const qty = s.quantity ?? 1;
|
||||
const inside = insidePerSub.get(s.id) ?? 0;
|
||||
reserved += Math.max(0, qty - inside); // hold only the not-yet-parked portion
|
||||
}
|
||||
return reserved;
|
||||
}
|
||||
|
||||
export function getOccupancy(db: Db): Occupancy {
|
||||
const count = occupancyCount(db);
|
||||
const capacity = siteCapacity(db);
|
||||
const reserved = reservedSubscriberSpots(db);
|
||||
return {
|
||||
count,
|
||||
reserved,
|
||||
capacity,
|
||||
free: capacity == null ? null : capacity - count,
|
||||
full: capacity != null && count >= capacity,
|
||||
effectiveFree: capacity == null ? null : capacity - count - reserved,
|
||||
// A transient is refused once physical cars + held subscriber spots reach capacity.
|
||||
full: capacity != null && count + reserved >= capacity,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user