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
@@ -0,0 +1,82 @@
import { describe, it, expect } from "vitest";
import { outOfWindowGap, type PlanTimeframes } from "./index.js";
// The "tariff bridge" gap for a subscriber scan outside their allowed window. UTC tz
// keeps the wall-clock arithmetic obvious in the tests. See wiki/entities/subscription.md.
// Night plan: weekday allowed 20:00→08:00 (wraps midnight); weekend all-day.
const night: PlanTimeframes = {
weekday: { fromMin: 20 * 60, toMin: 8 * 60 }, // 1200 → 480
weekend: { allDay: true },
graceMin: 0,
tz: "UTC",
};
// A weekday + a weekend (2026-06-22 is a Monday; 2026-06-20 is a Saturday).
const monday = (hhmm: string) => `2026-06-22T${hhmm}:00.000Z`;
const saturday = (hhmm: string) => `2026-06-20T${hhmm}:00.000Z`;
describe("outOfWindowGap — entry edge (early arrival)", () => {
it("19:30 arrival to a 20:00 window owes 30 min", () => {
const g = outOfWindowGap(night, "UTC", monday("19:30"), "entry");
expect(g).not.toBeNull();
expect(g!.minutes).toBe(30);
expect(g!.start).toBe(monday("19:30"));
expect(g!.end).toBe(monday("20:00"));
});
it("09:00 daytime arrival owes the whole gap to 20:00 (11h)", () => {
const g = outOfWindowGap(night, "UTC", monday("09:00"), "entry");
expect(g!.minutes).toBe(11 * 60);
expect(g!.end).toBe(monday("20:00"));
});
it("in-window arrival (22:00) owes nothing", () => {
expect(outOfWindowGap(night, "UTC", monday("22:00"), "entry")).toBeNull();
});
it("after-midnight in-window arrival (02:00) owes nothing", () => {
expect(outOfWindowGap(night, "UTC", monday("02:00"), "entry")).toBeNull();
});
});
describe("outOfWindowGap — exit edge (late departure)", () => {
it("08:45 exit after an 08:00 window close owes 45 min", () => {
const g = outOfWindowGap(night, "UTC", monday("08:45"), "exit");
expect(g!.minutes).toBe(45);
expect(g!.start).toBe(monday("08:00"));
expect(g!.end).toBe(monday("08:45"));
});
it("in-window exit (07:00) owes nothing", () => {
expect(outOfWindowGap(night, "UTC", monday("07:00"), "exit")).toBeNull();
});
});
describe("outOfWindowGap — weekend all-day", () => {
it("any Saturday scan is free (entry + exit)", () => {
expect(outOfWindowGap(night, "UTC", saturday("09:00"), "entry")).toBeNull();
expect(outOfWindowGap(night, "UTC", saturday("23:30"), "exit")).toBeNull();
});
});
describe("outOfWindowGap — grace tolerance", () => {
const withGrace: PlanTimeframes = { ...night, graceMin: 15 };
it("19:50 entry (10 min before open) is within a 15-min grace → no charge", () => {
expect(outOfWindowGap(withGrace, "UTC", monday("19:50"), "entry")).toBeNull();
});
it("19:30 entry (30 min before) still charged, minus 15 grace = 15 min", () => {
const g = outOfWindowGap(withGrace, "UTC", monday("19:30"), "entry");
expect(g!.minutes).toBe(15);
});
it("08:10 exit within a 15-min grace of the 08:00 close → no charge", () => {
expect(outOfWindowGap(withGrace, "UTC", monday("08:10"), "exit")).toBeNull();
});
});
describe("outOfWindowGap — unrestricted", () => {
it("null timeframes → never a charge", () => {
expect(outOfWindowGap(null, "UTC", monday("09:00"), "entry")).toBeNull();
});
it("a day-type with no window → no charge", () => {
const weekdayOnly: PlanTimeframes = { weekday: { fromMin: 1200, toMin: 480 }, tz: "UTC" };
// weekend absent ⇒ unrestricted on Saturday.
expect(outOfWindowGap(weekdayOnly, "UTC", saturday("09:00"), "entry")).toBeNull();
});
});