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:
+26
-1
@@ -494,6 +494,20 @@ export interface SubscriptionCredential {
|
||||
}
|
||||
export type SubscriptionPeriod = "day" | "week" | "month";
|
||||
|
||||
/** A subscriber's allowed parking window for a day-type (minutes-from-local-midnight).
|
||||
* A scan outside the window is charged the transient tariff for the gap. */
|
||||
export interface DayWindow {
|
||||
allDay?: boolean;
|
||||
fromMin?: number;
|
||||
toMin?: number;
|
||||
}
|
||||
export interface PlanTimeframes {
|
||||
weekday?: DayWindow;
|
||||
weekend?: DayWindow;
|
||||
graceMin?: number;
|
||||
tz?: string;
|
||||
}
|
||||
|
||||
/** A subscription PLAN version — admin-composed, versioned config the operator sells
|
||||
* from (so they never type a price). */
|
||||
export interface SubscriptionPlan {
|
||||
@@ -505,6 +519,8 @@ export interface SubscriptionPlan {
|
||||
currency: string;
|
||||
effectiveFrom: string;
|
||||
active: boolean;
|
||||
/** Allowed-time windows (tariff bridge); null/absent = 24/7, no time charge. */
|
||||
timeframes?: PlanTimeframes | null;
|
||||
}
|
||||
|
||||
export interface Subscription {
|
||||
@@ -518,6 +534,8 @@ export interface Subscription {
|
||||
/** Which plan + immutable version priced this sale (null for legacy/comp). */
|
||||
planId: string | null;
|
||||
planVersionId: string | null;
|
||||
/** Cars covered by this one subscription (price was ×N). Default 1. */
|
||||
quantity: number;
|
||||
maxConcurrent: number | null;
|
||||
validFrom: string | null;
|
||||
validTo: string | null;
|
||||
@@ -540,6 +558,8 @@ export type SubscriptionInput = {
|
||||
/** Coverage window. Priced sale: validFrom defaults to now, validTo required. */
|
||||
validFrom: string | null;
|
||||
validTo: string | null;
|
||||
/** Cars covered (price ×N). Default 1. */
|
||||
quantity?: number;
|
||||
maxConcurrent: number | null;
|
||||
status?: Subscription["status"];
|
||||
/** How the sale fee was tendered (cash → drawer, card → bank). Used at CREATE when a
|
||||
@@ -549,12 +569,13 @@ export type SubscriptionInput = {
|
||||
plates: string[];
|
||||
};
|
||||
|
||||
/** A server-computed quote: periods (ceil) × per-period price for a span. */
|
||||
/** A server-computed quote: periods (ceil) × per-period price × quantity for a span. */
|
||||
export interface SubscriptionQuote {
|
||||
periods: number;
|
||||
amountMinor: number;
|
||||
currency: string;
|
||||
period: SubscriptionPeriod;
|
||||
quantity?: number;
|
||||
plan: SubscriptionPlan;
|
||||
}
|
||||
|
||||
@@ -589,6 +610,7 @@ export function createSubscriptionPlan(body: {
|
||||
pricePerPeriodMinor: number;
|
||||
currency: string;
|
||||
effectiveFrom?: string;
|
||||
timeframes?: PlanTimeframes | null;
|
||||
}): Promise<SubscriptionPlan> {
|
||||
return apiFetch("/api/subscription-plans", { method: "POST", body: JSON.stringify(body) });
|
||||
}
|
||||
@@ -600,6 +622,7 @@ export function quoteSubscription(body: {
|
||||
planId: string;
|
||||
validFrom: string | null;
|
||||
validTo: string;
|
||||
quantity?: number;
|
||||
}): Promise<SubscriptionQuote> {
|
||||
return apiFetch("/api/subscriptions/quote", { method: "POST", body: JSON.stringify(body) });
|
||||
}
|
||||
@@ -779,6 +802,8 @@ export interface SiteConfig {
|
||||
exitVoucherDefault: boolean;
|
||||
/** Site default monthly subscription price (minor units); pre-fills the form. */
|
||||
subscriptionMonthlyPriceMinor: number | null;
|
||||
/** Reserve a spot for each active subscriber's car(s) in the occupancy/full gate. */
|
||||
reserveSubscriberSpots: boolean;
|
||||
parkName: string | null;
|
||||
operatorName: string | null;
|
||||
/** NIUS — Albanian tax/identification number. */
|
||||
|
||||
Reference in New Issue
Block a user