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
+94
View File
@@ -0,0 +1,94 @@
import { desc, eq, siteConfig, subscriptionPlans, tariffVersions, tariffs, type Db } from "@parking/db";
import {
computeFee,
outOfWindowGap,
type PlanTimeframes,
type SubscriptionPlan,
type TariffStructure,
} from "@parking/shared";
// Subscription TIME-WINDOW → TARIFF BRIDGE. A plan may restrict WHEN a subscriber may be
// parked (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:
// - early ENTRY: arrival → window-open is owed (deferred; collected at exit).
// - late EXIT: window-close → departure is owed (exit is GATED until paid).
// Only plans WITH timeframes trigger any charge; a 24/7 plan never does. The gap math is
// pure + tz-aware (outOfWindowGap in @parking/shared); pricing reuses computeFee (the same
// engine transient stays use). See wiki/entities/subscription.md ("tariff bridge").
const DEFAULT_TZ = "Europe/Tirane";
/** A computed out-of-window charge: the gap, what it costs, and the tariff version used
* (recorded so it reprices identically — like every transient payment). */
export interface WindowCharge {
readonly amountMinor: number;
readonly gapStart: string;
readonly gapEnd: string;
readonly minutes: number;
readonly currency: string;
readonly tariffVersionId: string;
}
/** The plan VERSION that priced a subscription's sale (by planVersionId), or null. The
* timeframes are read from THIS version so a later plan edit can't retroactively change
* an existing subscriber's window rules. */
export function planVersionById(db: Db, planVersionId: string | null): SubscriptionPlan | null {
if (!planVersionId) return null;
const row = db.select().from(subscriptionPlans).where(eq(subscriptionPlans.id, planVersionId)).get();
return row ? (row as unknown as SubscriptionPlan) : null;
}
/** The site IANA timezone (falls back to the project default). */
export function siteTz(db: Db): string {
const cfg = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
return cfg?.timezone && cfg.timezone.length > 0 ? cfg.timezone : DEFAULT_TZ;
}
/** The active site tariff version in force at `at` (latest effectiveFrom ≤ at), or null. */
function tariffVersionAt(db: Db, at: string) {
const tariff = db.select().from(tariffs).where(eq(tariffs.scope, "site")).get();
if (!tariff) return null;
const versions = db
.select()
.from(tariffVersions)
.where(eq(tariffVersions.tariffId, tariff.id))
.orderBy(desc(tariffVersions.effectiveFrom))
.all();
return versions.find((v) => v.effectiveFrom <= at) ?? null;
}
/**
* Compute the out-of-window charge for a subscriber scan at `atISO`, or null when there
* is nothing to charge (no plan timeframes, in-window, weekend all-day, within grace, or
* no tariff configured). `edge` = "entry" (early) or "exit" (late). The gap is priced as
* a fresh transient stay of that duration (computeFee over [gapStart, gapEnd]).
*/
export function windowCharge(
db: Db,
planVersionId: string | null,
atISO: string,
edge: "entry" | "exit",
): WindowCharge | null {
const plan = planVersionById(db, planVersionId);
const timeframes = (plan?.timeframes ?? null) as PlanTimeframes | null;
if (!timeframes) return null; // 24/7 plan (or comp sub) — never a time charge.
const tz = timeframes.tz || siteTz(db);
const gap = outOfWindowGap(timeframes, tz, atISO, edge);
if (!gap) return null; // in-window / all-day / within grace.
const tv = tariffVersionAt(db, gap.start);
if (!tv) return null; // no tariff to price against — can't charge (don't trap).
const structure = tv.structure as unknown as TariffStructure;
const amountMinor = computeFee(gap.start, gap.end, structure);
if (amountMinor <= 0) return null;
return {
amountMinor,
gapStart: gap.start,
gapEnd: gap.end,
minutes: gap.minutes,
currency: tv.currency,
tariffVersionId: tv.id,
};
}