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:
@@ -29,6 +29,9 @@ interface SiteConfigBody extends Partial<Record<TextField, string | null>> {
|
||||
exitVoucherDefault?: boolean;
|
||||
/** Site default monthly subscription price in minor units (pre-fills the form). */
|
||||
subscriptionMonthlyPriceMinor?: number | null;
|
||||
/** Reserve a spot in occupancy for each active subscriber's car(s), even when not
|
||||
* parked — so transients see "full" sooner and the subscriber's spot is held. */
|
||||
reserveSubscriberSpots?: boolean;
|
||||
}
|
||||
|
||||
/** Shape returned by GET/PUT: capacity + the booth flag + the subscription default
|
||||
@@ -37,6 +40,7 @@ type SiteConfig = {
|
||||
capacity: number | null;
|
||||
exitVoucherDefault: boolean;
|
||||
subscriptionMonthlyPriceMinor: number | null;
|
||||
reserveSubscriberSpots: boolean;
|
||||
} & Record<TextField, string | null>;
|
||||
|
||||
function toSiteConfig(row: typeof siteConfig.$inferSelect | undefined): SiteConfig {
|
||||
@@ -44,6 +48,7 @@ function toSiteConfig(row: typeof siteConfig.$inferSelect | undefined): SiteConf
|
||||
capacity: row?.capacity ?? null,
|
||||
exitVoucherDefault: row?.exitVoucherDefault ?? false,
|
||||
subscriptionMonthlyPriceMinor: row?.subscriptionMonthlyPriceMinor ?? null,
|
||||
reserveSubscriberSpots: row?.reserveSubscriberSpots ?? false,
|
||||
} as SiteConfig;
|
||||
for (const f of TEXT_FIELDS) out[f] = row?.[f] ?? null;
|
||||
return out;
|
||||
@@ -95,6 +100,12 @@ export async function siteRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||
}
|
||||
patch.subscriptionMonthlyPriceMinor = p ?? null;
|
||||
}
|
||||
if ("reserveSubscriberSpots" in body) {
|
||||
if (typeof body.reserveSubscriberSpots !== "boolean") {
|
||||
return reply.code(400).send({ error: "reserveSubscriberSpots must be a boolean" });
|
||||
}
|
||||
patch.reserveSubscriberSpots = body.reserveSubscriberSpots;
|
||||
}
|
||||
for (const f of TEXT_FIELDS) {
|
||||
if (f in body) patch[f] = normText(body[f]);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { desc, eq, subscriptionPlans, type Db } from "@parking/db";
|
||||
import { SUBSCRIPTION_PERIODS, type SubscriptionPeriod } from "@parking/shared";
|
||||
import { SUBSCRIPTION_PERIODS, type PlanTimeframes, type SubscriptionPeriod } from "@parking/shared";
|
||||
import { requirePermission } from "../auth.js";
|
||||
import { siteTz } from "../subscription-window.js";
|
||||
|
||||
// Subscription PLAN catalog — admin-composed, versioned config the operator SELLS
|
||||
// from (so they never type a price). Mirrors the tariff composer: plans are
|
||||
@@ -22,6 +23,20 @@ interface PlanBody {
|
||||
currency?: string;
|
||||
/** When this version takes effect (ISO-8601). Defaults to now. */
|
||||
effectiveFrom?: string;
|
||||
/** Allowed-time windows (tariff bridge); null/omitted = 24/7. */
|
||||
timeframes?: PlanTimeframes | null;
|
||||
}
|
||||
|
||||
/** Validate the optional timeframes blob (minutes-of-day 0–1439, sane grace). */
|
||||
function validTimeframes(tf: PlanTimeframes | null | undefined): string | null {
|
||||
if (tf == null) return null;
|
||||
const okMin = (v: unknown) => v == null || (Number.isInteger(v) && (v as number) >= 0 && (v as number) <= 1439);
|
||||
for (const dt of [tf.weekday, tf.weekend]) {
|
||||
if (!dt) continue;
|
||||
if (!dt.allDay && (!okMin(dt.fromMin) || !okMin(dt.toMin))) return "window times must be minutes-of-day (0–1439)";
|
||||
}
|
||||
if (tf.graceMin != null && (!Number.isInteger(tf.graceMin) || tf.graceMin < 0)) return "graceMin must be ≥ 0";
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Lowercase, hyphenate, strip junk — a stable slug for the plan identity. */
|
||||
@@ -51,6 +66,8 @@ export async function subscriptionPlanRoutes(app: FastifyInstance, db: Db): Prom
|
||||
if (b.effectiveFrom != null && Number.isNaN(Date.parse(b.effectiveFrom))) {
|
||||
errs.push("effectiveFrom must be a valid ISO-8601 timestamp");
|
||||
}
|
||||
const tfErr = validTimeframes(b.timeframes);
|
||||
if (tfErr) errs.push(tfErr);
|
||||
return errs;
|
||||
}
|
||||
|
||||
@@ -85,6 +102,11 @@ export async function subscriptionPlanRoutes(app: FastifyInstance, db: Db): Prom
|
||||
error: "effectiveFrom cannot be in the past — backdating a plan would retroactively reprice sales",
|
||||
});
|
||||
}
|
||||
// Stamp the site tz into the timeframes so the windows evaluate in the site's
|
||||
// wall-clock, FROZEN in this version (mirrors how tariff V2 freezes its tz).
|
||||
const timeframes =
|
||||
b.timeframes != null ? { ...b.timeframes, tz: b.timeframes.tz || siteTz(db) } : null;
|
||||
|
||||
const row = {
|
||||
id: randomUUID(),
|
||||
planId,
|
||||
@@ -93,10 +115,11 @@ export async function subscriptionPlanRoutes(app: FastifyInstance, db: Db): Prom
|
||||
pricePerPeriodMinor: b.pricePerPeriodMinor!,
|
||||
currency: b.currency!.trim(),
|
||||
effectiveFrom,
|
||||
timeframes,
|
||||
active: true,
|
||||
createdBy: req.user?.username ?? null,
|
||||
};
|
||||
db.insert(subscriptionPlans).values(row).run();
|
||||
db.insert(subscriptionPlans).values(row as typeof subscriptionPlans.$inferInsert).run();
|
||||
return reply.code(201).send(row);
|
||||
});
|
||||
|
||||
|
||||
@@ -45,7 +45,10 @@ interface SubscriptionBody {
|
||||
* REQUIRED (the span priced against the plan). For a comp sub, both optional. */
|
||||
validFrom?: string | null;
|
||||
validTo?: string | null;
|
||||
/** Car-count binding: cars inside at once. Default 1; null = unbound. */
|
||||
/** How many cars this subscription covers (a family pays once for N cars). Sale =
|
||||
* plan span price × quantity; maxConcurrent defaults to it. ≥ 1, default 1. */
|
||||
quantity?: number | null;
|
||||
/** Car-count binding: cars inside at once. Default = quantity; null = unbound. */
|
||||
maxConcurrent?: number | null;
|
||||
status?: "active" | "suspended" | "revoked";
|
||||
credentials?: Credential[];
|
||||
@@ -61,6 +64,7 @@ interface QuoteBody {
|
||||
planId?: string;
|
||||
validFrom?: string;
|
||||
validTo?: string;
|
||||
quantity?: number;
|
||||
}
|
||||
|
||||
/** Mint an unguessable QR credential value. Namespaced + crypto-random; the reader
|
||||
@@ -112,6 +116,9 @@ export async function subscriptionRoutes(
|
||||
if (!plan) errs.push("no active plan found for the selected planId");
|
||||
}
|
||||
}
|
||||
if (b.quantity != null && (!Number.isInteger(b.quantity) || b.quantity < 1)) {
|
||||
errs.push("quantity must be a positive integer (cars covered)");
|
||||
}
|
||||
if (b.status && !["active", "suspended", "revoked"].includes(b.status)) {
|
||||
errs.push("status must be active|suspended|revoked");
|
||||
}
|
||||
@@ -188,16 +195,23 @@ export async function subscriptionRoutes(
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/** Resolve + price a priced sale: returns the plan version, the effective span, and
|
||||
* the server-computed quote. Returns null for a comp sub (no planId). Throws on a
|
||||
* planId that no longer resolves (validate() guards the happy path). */
|
||||
function priceSale(b: SubscriptionBody): { plan: SubscriptionPlan; validFrom: string; validTo: string; quote: SubscriptionQuote } | null {
|
||||
/** Resolve + price a priced sale: returns the plan version, the effective span, the
|
||||
* quantity (cars covered), and the server-computed quote with the amount already
|
||||
* MULTIPLIED by quantity (a family paying once for N cars). Returns null for a comp
|
||||
* sub (no planId). validate() guards the happy path. */
|
||||
function priceSale(
|
||||
b: SubscriptionBody,
|
||||
): { plan: SubscriptionPlan; validFrom: string; validTo: string; quantity: number; quote: SubscriptionQuote } | null {
|
||||
if (!b.planId?.trim() || !b.validTo?.trim()) return null;
|
||||
const validFrom = b.validFrom?.trim() || new Date().toISOString();
|
||||
const validTo = b.validTo.trim();
|
||||
const plan = resolvePlanVersion(db, b.planId.trim(), validFrom);
|
||||
if (!plan) return null;
|
||||
return { plan, validFrom, validTo, quote: priceSubscriptionSpan(plan, validFrom, validTo) };
|
||||
const quantity = b.quantity != null && b.quantity > 0 ? Math.round(b.quantity) : 1;
|
||||
const base = priceSubscriptionSpan(plan, validFrom, validTo);
|
||||
// Price ×N: the whole sale covers N cars on one subscription.
|
||||
const quote: SubscriptionQuote = { ...base, amountMinor: base.amountMinor * quantity };
|
||||
return { plan, validFrom, validTo, quantity, quote };
|
||||
}
|
||||
|
||||
// List all subscriptions (with their credentials + plates).
|
||||
@@ -264,7 +278,10 @@ export async function subscriptionRoutes(
|
||||
}
|
||||
const plan = resolvePlanVersion(db, b.planId.trim(), validFrom);
|
||||
if (!plan) return reply.code(404).send({ error: "no active plan for that planId" });
|
||||
return { ...priceSubscriptionSpan(plan, validFrom, validTo), plan };
|
||||
const quantity = b.quantity != null && b.quantity > 0 ? Math.round(b.quantity) : 1;
|
||||
const base = priceSubscriptionSpan(plan, validFrom, validTo);
|
||||
// Echo the ×quantity total so the form previews the family's combined price.
|
||||
return { ...base, amountMinor: base.amountMinor * quantity, quantity, plan };
|
||||
});
|
||||
|
||||
app.post<{ Body: SubscriptionBody }>("/api/subscriptions", { preHandler: createGuard }, async (req, reply) => {
|
||||
@@ -286,7 +303,15 @@ export async function subscriptionRoutes(
|
||||
currency: priced ? priced.quote.currency : null,
|
||||
planId: priced ? priced.plan.planId : null,
|
||||
planVersionId: priced ? priced.plan.id : null,
|
||||
maxConcurrent: b.maxConcurrent === undefined ? 1 : b.maxConcurrent,
|
||||
quantity: priced ? priced.quantity : (b.quantity != null && b.quantity > 0 ? Math.round(b.quantity) : 1),
|
||||
// maxConcurrent defaults to the quantity (the family's N cars can all be inside),
|
||||
// unless the operator set it explicitly (null = unbound).
|
||||
maxConcurrent:
|
||||
b.maxConcurrent !== undefined
|
||||
? b.maxConcurrent
|
||||
: priced
|
||||
? priced.quantity
|
||||
: (b.quantity != null && b.quantity > 0 ? Math.round(b.quantity) : 1),
|
||||
validFrom: priced ? priced.validFrom : (b.validFrom ?? null),
|
||||
validTo: priced ? priced.validTo : resolveValidTo(b, null),
|
||||
status: b.status ?? "active",
|
||||
@@ -350,10 +375,11 @@ export async function subscriptionRoutes(
|
||||
planId: plan.planId,
|
||||
planVersionId: plan.id,
|
||||
periods: quote.periods,
|
||||
...(priced.quantity > 1 ? { quantity: priced.quantity } : {}),
|
||||
},
|
||||
});
|
||||
app.log.info(
|
||||
`subscription sale ${amountMinor} ${currency} (${tender}, ${quote.periods}×${plan.period}) for ${id} by ${operator}` +
|
||||
`subscription sale ${amountMinor} ${currency} (${tender}, ${quote.periods}×${plan.period}×${priced.quantity}car) for ${id} by ${operator}` +
|
||||
(inShift ? "" : " [no open shift]"),
|
||||
);
|
||||
} catch (err) {
|
||||
|
||||
Reference in New Issue
Block a user