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,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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user