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:
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user