feat(subscription): rename permit→subscription + monthly pricing
The "permit/lejet" feature is really a subscription. Full rename of the mutable master data, plus a recurring monthly price. - DB (migration 0004, data-preserving ALTER RENAME): permits→subscriptions, permit_credentials/_plates→subscription_*, sessions.permit_id→subscription_id. - Pricing: per-subscription priceMinor + period(monthly) + currency, with a site default (site_config.subscription_monthly_price_minor) pre-filling the form. - Server: subscription-flow.ts (SubscriptionFlow), routes/subscriptions.ts (/api/subscriptions). Web: SubscriptionManager, route, i18n (sq Abonimet/en). - The signed ledger `permitId` payload is intentionally kept — immutable hash-chained history; renaming it would break verification of past events. Deferred (wiki notes): fee collection into the ledger/shift (a shift-attributed payment), LPR/ANPR plate source, time-of-day access windows (overnight subscriber). Also carries the device-footer UI surface (api DeviceStatus, router mount, i18n devices) due to shared-file overlap with the preceding footer commit. Verified end-to-end on a fresh DB and migration on a live-DB copy (sessions preserved). Live DB migrated. Full monorepo builds clean. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
@@ -73,9 +73,9 @@ export class EntryFlow {
|
||||
|
||||
async #runEntry(resolved: ResolvedRelay): Promise<void> {
|
||||
// CAPACITY GATE (transient only). When the lot is full, refuse transient entry:
|
||||
// no ticket, no vehicle_entry, no open — sign an anomaly. Permit holders are NOT
|
||||
// no ticket, no vehicle_entry, no open — sign an anomaly. Subscribers are NOT
|
||||
// gated here (their flow ignores site-full; their own maxConcurrent applies), so
|
||||
// subscribers aren't locked out. "Full" is a soft policy seam for valet over-
|
||||
// they aren't locked out. "Full" is a soft policy seam for valet over-
|
||||
// capacity later. See wiki/concepts/capacity-occupancy.md.
|
||||
const occ = getOccupancy(this.#db);
|
||||
if (occ.full) {
|
||||
|
||||
@@ -2,32 +2,32 @@ import { devices, eq, type Db } from "@parking/db";
|
||||
import type { FastifyBaseLogger } from "fastify";
|
||||
import type { DeviceReadEvent, ReadOutcome } from "./device-events.js";
|
||||
import type { ExitFlow } from "./exit-flow.js";
|
||||
import type { PermitFlow } from "./permit-flow.js";
|
||||
import type { SubscriptionFlow } from "./subscription-flow.js";
|
||||
import { relayForDevice } from "./device-resolve.js";
|
||||
|
||||
// Routes a credential read (ticket scan / plate / card) to the right flow. A read
|
||||
// can mean a permit entry/exit OR a transient exit, so we dispatch by WHAT the
|
||||
// can mean a subscription entry/exit OR a transient exit, so we dispatch by WHAT the
|
||||
// credential is (decision 2026-06-15):
|
||||
// - matches a permit (card/QR/bound plate) → PERMIT flow,
|
||||
// - matches a subscription (card/QR/bound plate) → SUBSCRIPTION flow,
|
||||
// - else → transient EXIT flow (open ticket session → exit, else reject+log).
|
||||
//
|
||||
// The reader is BOUND to a controller relay (config.controllerId + relay), so a read
|
||||
// resolves to exactly the barrier it sits at, and the direction is inherited from
|
||||
// that relay (see entry-exit-points.md). The resolved relay is handed to the flow so
|
||||
// it opens that exact barrier. An "entry" reader drives the entry side, an "exit"
|
||||
// reader the exit side; "both" defers to the flow's own inference (permit: session
|
||||
// state; transient: exit).
|
||||
// reader the exit side; "both" defers to the flow's own inference (subscription:
|
||||
// session state; transient: exit).
|
||||
|
||||
export class ReadDispatcher {
|
||||
readonly #db: Db;
|
||||
readonly #exit: ExitFlow;
|
||||
readonly #permit: PermitFlow;
|
||||
readonly #subscription: SubscriptionFlow;
|
||||
readonly #logger: FastifyBaseLogger;
|
||||
|
||||
constructor(db: Db, exit: ExitFlow, permit: PermitFlow, logger: FastifyBaseLogger) {
|
||||
constructor(db: Db, exit: ExitFlow, subscription: SubscriptionFlow, logger: FastifyBaseLogger) {
|
||||
this.#db = db;
|
||||
this.#exit = exit;
|
||||
this.#permit = permit;
|
||||
this.#subscription = subscription;
|
||||
this.#logger = logger;
|
||||
}
|
||||
|
||||
@@ -41,13 +41,13 @@ export class ReadDispatcher {
|
||||
return { accepted: false, reason: "reader not bound to a barrier (no relay to open)" };
|
||||
}
|
||||
|
||||
const permit = this.#permit.match(e);
|
||||
if (permit) {
|
||||
return this.#permit.run(resolved, e, permit);
|
||||
const sub = this.#subscription.match(e);
|
||||
if (sub) {
|
||||
return this.#subscription.run(resolved, e, sub);
|
||||
}
|
||||
// Not a permit → transient ticket exit. An ENTRY reader can't produce a transient
|
||||
// exit (transient entry is the button flow, not a reader), so reject+log rather
|
||||
// than treat an entry scan as an exit.
|
||||
// Not a subscription → transient ticket exit. An ENTRY reader can't produce a
|
||||
// transient exit (transient entry is the button flow, not a reader), so reject+log
|
||||
// rather than treat an entry scan as an exit.
|
||||
if (resolved.direction === "entry") {
|
||||
return { accepted: false, direction: "entry", reason: "entry reader: no transient entry via reader" };
|
||||
}
|
||||
|
||||
@@ -1,160 +0,0 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { eq, permitCredentials, permitPlates, permits, type Db } from "@parking/db";
|
||||
import { requireRole } from "../auth.js";
|
||||
|
||||
// Permit (subscription) admin CRUD. A permit is mutable master data — admins
|
||||
// grant/edit/revoke — but every USE of it is a signed ledger event, so the audit
|
||||
// trail stays append-only (see wiki/entities/permit.md). A permit is an aggregate:
|
||||
// the permit row + its credentials (card/QR) + its bound plates. The API treats them
|
||||
// as one unit (create/update replace the child sets; delete removes all).
|
||||
|
||||
interface Credential {
|
||||
kind: "rf" | "qr";
|
||||
value: string;
|
||||
}
|
||||
interface PermitBody {
|
||||
holderName?: string;
|
||||
contact?: string;
|
||||
/** Car-count binding: cars inside at once. Default 1; null = unbound. */
|
||||
maxConcurrent?: number | null;
|
||||
validFrom?: string | null;
|
||||
validTo?: string | null;
|
||||
status?: "active" | "suspended" | "revoked";
|
||||
credentials?: Credential[];
|
||||
/** Plate binding (optional): bound plates that also serve as identity. */
|
||||
plates?: string[];
|
||||
}
|
||||
|
||||
export async function permitRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||
// Admin manages permits; operator/cashier/readonly may LIST (to look one up).
|
||||
const readGuard = requireRole("admin", "operator", "cashier", "readonly");
|
||||
const writeGuard = requireRole("admin");
|
||||
|
||||
// Validate the body; returns problems (empty = ok). Shared by create + update.
|
||||
function validate(b: PermitBody): string[] {
|
||||
const errs: string[] = [];
|
||||
if (b.maxConcurrent != null) {
|
||||
if (!Number.isInteger(b.maxConcurrent) || b.maxConcurrent < 1) {
|
||||
errs.push("maxConcurrent must be a positive integer, or null for unbound");
|
||||
}
|
||||
}
|
||||
if (b.status && !["active", "suspended", "revoked"].includes(b.status)) {
|
||||
errs.push("status must be active|suspended|revoked");
|
||||
}
|
||||
for (const c of b.credentials ?? []) {
|
||||
if ((c.kind !== "rf" && c.kind !== "qr") || !c.value?.trim()) {
|
||||
errs.push("each credential needs kind (rf|qr) and a non-empty value");
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ((b.credentials?.length ?? 0) === 0 && (b.plates?.length ?? 0) === 0) {
|
||||
errs.push("a permit needs at least one credential or one bound plate (else nothing identifies it)");
|
||||
}
|
||||
return errs;
|
||||
}
|
||||
|
||||
function loadAggregate(id: string) {
|
||||
const permit = db.select().from(permits).where(eq(permits.id, id)).get();
|
||||
if (!permit) return null;
|
||||
const credentials = db.select().from(permitCredentials).where(eq(permitCredentials.permitId, id)).all();
|
||||
const plates = db.select().from(permitPlates).where(eq(permitPlates.permitId, id)).all();
|
||||
return {
|
||||
...permit,
|
||||
credentials: credentials.map((c) => ({ kind: c.kind, value: c.value })),
|
||||
plates: plates.map((p) => p.plate),
|
||||
};
|
||||
}
|
||||
|
||||
// Replace a permit's child rows (credentials + plates) from the body.
|
||||
function writeChildren(id: string, b: PermitBody) {
|
||||
db.delete(permitCredentials).where(eq(permitCredentials.permitId, id)).run();
|
||||
db.delete(permitPlates).where(eq(permitPlates.permitId, id)).run();
|
||||
for (const c of b.credentials ?? []) {
|
||||
db.insert(permitCredentials).values({ id: randomUUID(), permitId: id, kind: c.kind, value: c.value.trim() }).run();
|
||||
}
|
||||
for (const p of b.plates ?? []) {
|
||||
if (p.trim()) db.insert(permitPlates).values({ id: randomUUID(), permitId: id, plate: p.trim() }).run();
|
||||
}
|
||||
}
|
||||
|
||||
// List all permits (with their credentials + plates).
|
||||
app.get("/api/permits", { preHandler: readGuard }, async () => {
|
||||
const rows = db.select().from(permits).all();
|
||||
return { permits: rows.map((r) => loadAggregate(r.id)) };
|
||||
});
|
||||
|
||||
// Create a permit.
|
||||
app.post<{ Body: PermitBody }>("/api/permits", { preHandler: writeGuard }, async (req, reply) => {
|
||||
const b = req.body ?? {};
|
||||
const problems = validate(b);
|
||||
if (problems.length) return reply.code(400).send({ error: "invalid permit", problems });
|
||||
const id = randomUUID();
|
||||
db.insert(permits)
|
||||
.values({
|
||||
id,
|
||||
holderName: b.holderName ?? null,
|
||||
contact: b.contact ?? null,
|
||||
maxConcurrent: b.maxConcurrent === undefined ? 1 : b.maxConcurrent,
|
||||
validFrom: b.validFrom ?? null,
|
||||
validTo: b.validTo ?? null,
|
||||
status: b.status ?? "active",
|
||||
})
|
||||
.run();
|
||||
writeChildren(id, b);
|
||||
return reply.code(201).send(loadAggregate(id));
|
||||
});
|
||||
|
||||
// Update a permit (replaces fields + child sets).
|
||||
app.put<{ Params: { id: string }; Body: PermitBody }>(
|
||||
"/api/permits/:id",
|
||||
{ preHandler: writeGuard },
|
||||
async (req, reply) => {
|
||||
const existing = db.select().from(permits).where(eq(permits.id, req.params.id)).get();
|
||||
if (!existing) return reply.code(404).send({ error: "permit not found" });
|
||||
const b = req.body ?? {};
|
||||
const problems = validate(b);
|
||||
if (problems.length) return reply.code(400).send({ error: "invalid permit", problems });
|
||||
db.update(permits)
|
||||
.set({
|
||||
holderName: b.holderName ?? null,
|
||||
contact: b.contact ?? null,
|
||||
maxConcurrent: b.maxConcurrent === undefined ? existing.maxConcurrent : b.maxConcurrent,
|
||||
validFrom: b.validFrom ?? null,
|
||||
validTo: b.validTo ?? null,
|
||||
status: b.status ?? existing.status,
|
||||
})
|
||||
.where(eq(permits.id, req.params.id))
|
||||
.run();
|
||||
writeChildren(req.params.id, b);
|
||||
return loadAggregate(req.params.id);
|
||||
},
|
||||
);
|
||||
|
||||
// Revoke (soft): the common case — keeps the permit + its history, just bars it.
|
||||
// A revoked permit fails the entry check (see permit-flow.ts). Use DELETE only to
|
||||
// fully remove a permit created in error.
|
||||
app.post<{ Params: { id: string } }>(
|
||||
"/api/permits/:id/revoke",
|
||||
{ preHandler: writeGuard },
|
||||
async (req, reply) => {
|
||||
const r = db.update(permits).set({ status: "revoked" }).where(eq(permits.id, req.params.id)).run();
|
||||
if (r.changes === 0) return reply.code(404).send({ error: "permit not found" });
|
||||
return loadAggregate(req.params.id);
|
||||
},
|
||||
);
|
||||
|
||||
// Hard delete a permit + its child rows. (Past ledger events that reference it
|
||||
// are untouched — the audit trail is append-only and independent of this row.)
|
||||
app.delete<{ Params: { id: string } }>(
|
||||
"/api/permits/:id",
|
||||
{ preHandler: writeGuard },
|
||||
async (req, reply) => {
|
||||
const r = db.delete(permits).where(eq(permits.id, req.params.id)).run();
|
||||
if (r.changes === 0) return reply.code(404).send({ error: "permit not found" });
|
||||
db.delete(permitCredentials).where(eq(permitCredentials.permitId, req.params.id)).run();
|
||||
db.delete(permitPlates).where(eq(permitPlates.permitId, req.params.id)).run();
|
||||
return reply.code(204).send();
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -23,18 +23,23 @@ interface SiteConfigBody extends Partial<Record<TextField, string | null>> {
|
||||
capacity?: number | null;
|
||||
/** Default for the booth "print exit ticket" checkbox (booth-geography knob). */
|
||||
exitVoucherDefault?: boolean;
|
||||
/** Site default monthly subscription price in minor units (pre-fills the form). */
|
||||
subscriptionMonthlyPriceMinor?: number | null;
|
||||
}
|
||||
|
||||
/** Shape returned by GET/PUT: capacity + the booth flag + every metadata field. */
|
||||
type SiteConfig = { capacity: number | null; exitVoucherDefault: boolean } & Record<
|
||||
TextField,
|
||||
string | null
|
||||
>;
|
||||
/** Shape returned by GET/PUT: capacity + the booth flag + the subscription default
|
||||
* + every metadata field. */
|
||||
type SiteConfig = {
|
||||
capacity: number | null;
|
||||
exitVoucherDefault: boolean;
|
||||
subscriptionMonthlyPriceMinor: number | null;
|
||||
} & Record<TextField, string | null>;
|
||||
|
||||
function toSiteConfig(row: typeof siteConfig.$inferSelect | undefined): SiteConfig {
|
||||
const out = {
|
||||
capacity: row?.capacity ?? null,
|
||||
exitVoucherDefault: row?.exitVoucherDefault ?? false,
|
||||
subscriptionMonthlyPriceMinor: row?.subscriptionMonthlyPriceMinor ?? null,
|
||||
} as SiteConfig;
|
||||
for (const f of TEXT_FIELDS) out[f] = row?.[f] ?? null;
|
||||
return out;
|
||||
@@ -79,6 +84,13 @@ export async function siteRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||
}
|
||||
patch.exitVoucherDefault = body.exitVoucherDefault;
|
||||
}
|
||||
if ("subscriptionMonthlyPriceMinor" in body) {
|
||||
const p = body.subscriptionMonthlyPriceMinor;
|
||||
if (p != null && (!Number.isInteger(p) || p < 0)) {
|
||||
return reply.code(400).send({ error: "subscriptionMonthlyPriceMinor must be a non-negative integer or null" });
|
||||
}
|
||||
patch.subscriptionMonthlyPriceMinor = p ?? null;
|
||||
}
|
||||
for (const f of TEXT_FIELDS) {
|
||||
if (f in body) patch[f] = normText(body[f]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { eq, subscriptionCredentials, subscriptionPlates, subscriptions, type Db } from "@parking/db";
|
||||
import { requireRole } from "../auth.js";
|
||||
|
||||
// Subscription admin CRUD. A subscription is mutable master data — admins
|
||||
// grant/edit/revoke — but every USE of it is a signed ledger event, so the audit
|
||||
// trail stays append-only (see wiki/entities/subscription.md). A subscription is an
|
||||
// aggregate: the row + its credentials (card/QR) + its bound plates. The API treats
|
||||
// them as one unit (create/update replace the child sets; delete removes all).
|
||||
//
|
||||
// Pricing: priceMinor + period ("monthly") + currency record the recurring plan
|
||||
// (e.g. 10,000 ALL / month). Collecting the fee into the ledger/shift is deferred —
|
||||
// here we just store the agreed price and the coverage window.
|
||||
|
||||
interface Credential {
|
||||
kind: "rf" | "qr";
|
||||
value: string;
|
||||
}
|
||||
interface SubscriptionBody {
|
||||
holderName?: string;
|
||||
contact?: string;
|
||||
/** Recurring price in minor units (e.g. 1000000 = 10,000.00). null = no price set. */
|
||||
priceMinor?: number | null;
|
||||
period?: "monthly";
|
||||
/** ISO-4217 currency of priceMinor (e.g. "ALL"). */
|
||||
currency?: string | null;
|
||||
/** Car-count binding: cars inside at once. Default 1; null = unbound. */
|
||||
maxConcurrent?: number | null;
|
||||
validFrom?: string | null;
|
||||
validTo?: string | null;
|
||||
status?: "active" | "suspended" | "revoked";
|
||||
credentials?: Credential[];
|
||||
/** Plate binding (optional): bound plates that also serve as identity. */
|
||||
plates?: string[];
|
||||
}
|
||||
|
||||
export async function subscriptionRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||
// Admin manages subscriptions; operator/cashier/readonly may LIST (to look one up).
|
||||
const readGuard = requireRole("admin", "operator", "cashier", "readonly");
|
||||
const writeGuard = requireRole("admin");
|
||||
|
||||
// Validate the body; returns problems (empty = ok). Shared by create + update.
|
||||
function validate(b: SubscriptionBody): string[] {
|
||||
const errs: string[] = [];
|
||||
if (b.maxConcurrent != null) {
|
||||
if (!Number.isInteger(b.maxConcurrent) || b.maxConcurrent < 1) {
|
||||
errs.push("maxConcurrent must be a positive integer, or null for unbound");
|
||||
}
|
||||
}
|
||||
if (b.priceMinor != null) {
|
||||
if (!Number.isInteger(b.priceMinor) || b.priceMinor < 0) {
|
||||
errs.push("priceMinor must be a non-negative integer (minor units), or null");
|
||||
}
|
||||
if (!b.currency?.trim()) {
|
||||
errs.push("currency is required when a price is set");
|
||||
}
|
||||
}
|
||||
if (b.period != null && b.period !== "monthly") {
|
||||
errs.push("period must be 'monthly' (the only period supported today)");
|
||||
}
|
||||
if (b.status && !["active", "suspended", "revoked"].includes(b.status)) {
|
||||
errs.push("status must be active|suspended|revoked");
|
||||
}
|
||||
for (const c of b.credentials ?? []) {
|
||||
if ((c.kind !== "rf" && c.kind !== "qr") || !c.value?.trim()) {
|
||||
errs.push("each credential needs kind (rf|qr) and a non-empty value");
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ((b.credentials?.length ?? 0) === 0 && (b.plates?.length ?? 0) === 0) {
|
||||
errs.push("a subscription needs at least one credential or one bound plate (else nothing identifies it)");
|
||||
}
|
||||
return errs;
|
||||
}
|
||||
|
||||
function loadAggregate(id: string) {
|
||||
const sub = db.select().from(subscriptions).where(eq(subscriptions.id, id)).get();
|
||||
if (!sub) return null;
|
||||
const credentials = db.select().from(subscriptionCredentials).where(eq(subscriptionCredentials.subscriptionId, id)).all();
|
||||
const plates = db.select().from(subscriptionPlates).where(eq(subscriptionPlates.subscriptionId, id)).all();
|
||||
return {
|
||||
...sub,
|
||||
credentials: credentials.map((c) => ({ kind: c.kind, value: c.value })),
|
||||
plates: plates.map((p) => p.plate),
|
||||
};
|
||||
}
|
||||
|
||||
// Replace a subscription's child rows (credentials + plates) from the body.
|
||||
function writeChildren(id: string, b: SubscriptionBody) {
|
||||
db.delete(subscriptionCredentials).where(eq(subscriptionCredentials.subscriptionId, id)).run();
|
||||
db.delete(subscriptionPlates).where(eq(subscriptionPlates.subscriptionId, id)).run();
|
||||
for (const c of b.credentials ?? []) {
|
||||
db.insert(subscriptionCredentials).values({ id: randomUUID(), subscriptionId: id, kind: c.kind, value: c.value.trim() }).run();
|
||||
}
|
||||
for (const p of b.plates ?? []) {
|
||||
if (p.trim()) db.insert(subscriptionPlates).values({ id: randomUUID(), subscriptionId: id, plate: p.trim() }).run();
|
||||
}
|
||||
}
|
||||
|
||||
// List all subscriptions (with their credentials + plates).
|
||||
app.get("/api/subscriptions", { preHandler: readGuard }, async () => {
|
||||
const rows = db.select().from(subscriptions).all();
|
||||
return { subscriptions: rows.map((r) => loadAggregate(r.id)) };
|
||||
});
|
||||
|
||||
// Create a subscription.
|
||||
app.post<{ Body: SubscriptionBody }>("/api/subscriptions", { preHandler: writeGuard }, async (req, reply) => {
|
||||
const b = req.body ?? {};
|
||||
const problems = validate(b);
|
||||
if (problems.length) return reply.code(400).send({ error: "invalid subscription", problems });
|
||||
const id = randomUUID();
|
||||
db.insert(subscriptions)
|
||||
.values({
|
||||
id,
|
||||
holderName: b.holderName ?? null,
|
||||
contact: b.contact ?? null,
|
||||
priceMinor: b.priceMinor ?? null,
|
||||
period: b.period ?? "monthly",
|
||||
currency: b.priceMinor != null ? (b.currency ?? null) : null,
|
||||
maxConcurrent: b.maxConcurrent === undefined ? 1 : b.maxConcurrent,
|
||||
validFrom: b.validFrom ?? null,
|
||||
validTo: b.validTo ?? null,
|
||||
status: b.status ?? "active",
|
||||
})
|
||||
.run();
|
||||
writeChildren(id, b);
|
||||
return reply.code(201).send(loadAggregate(id));
|
||||
});
|
||||
|
||||
// Update a subscription (replaces fields + child sets).
|
||||
app.put<{ Params: { id: string }; Body: SubscriptionBody }>(
|
||||
"/api/subscriptions/:id",
|
||||
{ preHandler: writeGuard },
|
||||
async (req, reply) => {
|
||||
const existing = db.select().from(subscriptions).where(eq(subscriptions.id, req.params.id)).get();
|
||||
if (!existing) return reply.code(404).send({ error: "subscription not found" });
|
||||
const b = req.body ?? {};
|
||||
const problems = validate(b);
|
||||
if (problems.length) return reply.code(400).send({ error: "invalid subscription", problems });
|
||||
db.update(subscriptions)
|
||||
.set({
|
||||
holderName: b.holderName ?? null,
|
||||
contact: b.contact ?? null,
|
||||
priceMinor: b.priceMinor === undefined ? existing.priceMinor : b.priceMinor,
|
||||
period: b.period ?? existing.period,
|
||||
currency:
|
||||
b.priceMinor === undefined
|
||||
? existing.currency
|
||||
: b.priceMinor != null
|
||||
? (b.currency ?? null)
|
||||
: null,
|
||||
maxConcurrent: b.maxConcurrent === undefined ? existing.maxConcurrent : b.maxConcurrent,
|
||||
validFrom: b.validFrom ?? null,
|
||||
validTo: b.validTo ?? null,
|
||||
status: b.status ?? existing.status,
|
||||
})
|
||||
.where(eq(subscriptions.id, req.params.id))
|
||||
.run();
|
||||
writeChildren(req.params.id, b);
|
||||
return loadAggregate(req.params.id);
|
||||
},
|
||||
);
|
||||
|
||||
// Revoke (soft): the common case — keeps the subscription + its history, just bars
|
||||
// it. A revoked subscription fails the entry check (see subscription-flow.ts). Use
|
||||
// DELETE only to fully remove one created in error.
|
||||
app.post<{ Params: { id: string } }>(
|
||||
"/api/subscriptions/:id/revoke",
|
||||
{ preHandler: writeGuard },
|
||||
async (req, reply) => {
|
||||
const r = db.update(subscriptions).set({ status: "revoked" }).where(eq(subscriptions.id, req.params.id)).run();
|
||||
if (r.changes === 0) return reply.code(404).send({ error: "subscription not found" });
|
||||
return loadAggregate(req.params.id);
|
||||
},
|
||||
);
|
||||
|
||||
// Hard delete a subscription + its child rows. (Past ledger events that reference it
|
||||
// are untouched — the audit trail is append-only and independent of this row.)
|
||||
app.delete<{ Params: { id: string } }>(
|
||||
"/api/subscriptions/:id",
|
||||
{ preHandler: writeGuard },
|
||||
async (req, reply) => {
|
||||
const r = db.delete(subscriptions).where(eq(subscriptions.id, req.params.id)).run();
|
||||
if (r.changes === 0) return reply.code(404).send({ error: "subscription not found" });
|
||||
db.delete(subscriptionCredentials).where(eq(subscriptionCredentials.subscriptionId, req.params.id)).run();
|
||||
db.delete(subscriptionPlates).where(eq(subscriptionPlates.subscriptionId, req.params.id)).run();
|
||||
return reply.code(204).send();
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -10,16 +10,17 @@ import { EntryFlow } from "./entry-flow.js";
|
||||
import { EventLog } from "./event-log.js";
|
||||
import { ExitFlow } from "./exit-flow.js";
|
||||
import { PayStation } from "./pay-station.js";
|
||||
import { PermitFlow } from "./permit-flow.js";
|
||||
import { SubscriptionFlow } from "./subscription-flow.js";
|
||||
import { ShiftService } from "./shift-service.js";
|
||||
import { ReadDispatcher } from "./read-dispatch.js";
|
||||
import { PrinterMonitor } from "./printer-monitor.js";
|
||||
import { DeviceMonitor } from "./device-monitor.js";
|
||||
import { buildSigner, buildVerifier } from "./signer.js";
|
||||
import { authRoutes } from "./routes/auth.js";
|
||||
import { deviceRoutes } from "./routes/devices.js";
|
||||
import { eventRoutes } from "./routes/events.js";
|
||||
import { payRoutes } from "./routes/pay.js";
|
||||
import { permitRoutes } from "./routes/permits.js";
|
||||
import { subscriptionRoutes } from "./routes/subscriptions.js";
|
||||
import { qrReaderRoutes } from "./routes/qr-reader.js";
|
||||
import { shiftRoutes } from "./routes/shift.js";
|
||||
import { siteRoutes } from "./routes/site.js";
|
||||
@@ -27,6 +28,7 @@ import { snapshotRoutes } from "./routes/snapshots.js";
|
||||
import { tariffRoutes } from "./routes/tariffs.js";
|
||||
import { printerRoutes } from "./routes/printers.js";
|
||||
import { setupRoutes } from "./routes/setup.js";
|
||||
import { deviceStatusRoutes } from "./routes/device-status.js";
|
||||
import { wsRoutes } from "./routes/ws.js";
|
||||
|
||||
// The backend is Fastify (Node). Hardware drivers live as isolated Fastify
|
||||
@@ -86,6 +88,15 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
||||
app.addHook("onReady", async () => printerMonitor.start());
|
||||
app.addHook("onClose", async () => printerMonitor.stop());
|
||||
|
||||
// Unified device-status monitor: polls EVERY configured device (relays/readers/
|
||||
// cameras via healthCheck, printers via rich readStatus) and feeds the booth's
|
||||
// device-status footer over the WS. Read-only — never drives a relay.
|
||||
// See wiki/concepts/device-status-monitoring.md.
|
||||
const deviceMonitor = new DeviceMonitor(db, app.log);
|
||||
await deviceStatusRoutes(app, deviceMonitor);
|
||||
app.addHook("onReady", async () => deviceMonitor.start());
|
||||
app.addHook("onClose", async () => deviceMonitor.stop());
|
||||
|
||||
// Append-only signed business LEDGER (ledger_events). Holds only business facts
|
||||
// (vehicle_entry/exit, payment, void, …) — the anti-fraud audit trail. A raw
|
||||
// button press is NOT a business fact: it's device telemetry, recorded UNSIGNED
|
||||
@@ -101,7 +112,7 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
||||
|
||||
// Live booth feed: server-pushed ledger + occupancy + printer-status over a
|
||||
// single authenticated WebSocket (/api/ws). See routes/ws.ts.
|
||||
await wsRoutes(app, db);
|
||||
await wsRoutes(app, db, deviceMonitor);
|
||||
|
||||
// Entry/exit camera snapshots (BLOB-in-DB), read-only. See snapshot.ts.
|
||||
await snapshotRoutes(app, db);
|
||||
@@ -117,11 +128,12 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
||||
app.addHook("onClose", async () => unsubscribeEntry());
|
||||
|
||||
// Read-driven flows: a credential read (ticket scan / plate / card) routes via the
|
||||
// dispatcher to either the PERMIT flow (if it matches a permit) or the transient
|
||||
// EXIT flow. See read-dispatch.ts, exit-flow.ts, permit-flow.ts, parking-session.md.
|
||||
// dispatcher to either the SUBSCRIPTION flow (if it matches a subscription) or the
|
||||
// transient EXIT flow. See read-dispatch.ts, exit-flow.ts, subscription-flow.ts,
|
||||
// parking-session.md.
|
||||
const exitFlow = new ExitFlow(db, eventLog, app.log);
|
||||
const permitFlow = new PermitFlow(db, eventLog, app.log);
|
||||
const readDispatcher = new ReadDispatcher(db, exitFlow, permitFlow, app.log);
|
||||
const subscriptionFlow = new SubscriptionFlow(db, eventLog, app.log);
|
||||
const readDispatcher = new ReadDispatcher(db, exitFlow, subscriptionFlow, app.log);
|
||||
const unsubscribeRead = deviceEvents.onRead((e) => {
|
||||
void readDispatcher.dispatch(e);
|
||||
});
|
||||
@@ -147,8 +159,8 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
||||
// the pay station prices against. See wiki/concepts/tariff.md.
|
||||
await tariffRoutes(app, db);
|
||||
|
||||
// Permit (subscription) admin CRUD. See wiki/entities/permit.md.
|
||||
await permitRoutes(app, db);
|
||||
// Subscription admin CRUD. See wiki/entities/subscription.md.
|
||||
await subscriptionRoutes(app, db);
|
||||
|
||||
// Shift open/close + drawer endpoints (shiftService constructed above).
|
||||
await shiftRoutes(app, shiftService);
|
||||
|
||||
@@ -19,7 +19,7 @@ import { devicesByDirection, type FlowDirection } from "./device-resolve.js";
|
||||
interface SnapshotJob {
|
||||
readonly db: Db;
|
||||
readonly direction: FlowDirection;
|
||||
/** Session/credential ref (ticket id, plate, permit car key) — links to the ledger. */
|
||||
/** Session/credential ref (ticket id, plate, subscription car key) — links to the ledger. */
|
||||
readonly identity: string;
|
||||
readonly logger: FastifyBaseLogger;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
import { eq, ledgerEvents, permitCredentials, permitPlates, permits, sessions, type Db, type DeviceRow } from "@parking/db";
|
||||
import {
|
||||
eq,
|
||||
ledgerEvents,
|
||||
sessions,
|
||||
subscriptionCredentials,
|
||||
subscriptionPlates,
|
||||
subscriptions,
|
||||
type Db,
|
||||
type DeviceRow,
|
||||
} from "@parking/db";
|
||||
import { registry, type AccessControlDevice } from "@parking/devices";
|
||||
import type { FastifyBaseLogger } from "fastify";
|
||||
import type { DeviceReadEvent, ReadOutcome } from "./device-events.js";
|
||||
@@ -6,29 +15,34 @@ import type { EventLog } from "./event-log.js";
|
||||
import { type FlowDirection, type ResolvedRelay } from "./device-resolve.js";
|
||||
import { snapshotAsync } from "./snapshot.js";
|
||||
|
||||
// PERMIT flow: a subscriber identified by card/QR/plate enters/exits without paying.
|
||||
// Reached from the read dispatcher when a read matches a permit (not an open ticket).
|
||||
// See wiki/entities/permit.md.
|
||||
// SUBSCRIPTION flow: a subscriber identified by card/QR/plate enters/exits without
|
||||
// paying per stay (they're on a recurring plan). Reached from the read dispatcher
|
||||
// when a read matches a subscription (not an open ticket). See
|
||||
// wiki/entities/subscription.md.
|
||||
//
|
||||
// Two optional, independent bindings:
|
||||
// - car-count: `maxConcurrent` (default 1, null = unbound) — how many of the
|
||||
// permit's cars may be inside at once; enforced over the session projection.
|
||||
// subscription's cars may be inside at once; enforced over the session projection.
|
||||
// - plate: optional `plates[]` — when set, a matching plate is an accepted identity
|
||||
// too (card/QR OR plate). When unset, any car may use the permit's card/QR.
|
||||
// too (card/QR OR plate). When unset, any car may use the subscription's card/QR.
|
||||
//
|
||||
// Direction is inferred from session state for THAT car (the read credential value
|
||||
// is the per-car session key): no open session → ENTRY; open session → EXIT. So a
|
||||
// fleet permit can have several cars in at once, each its own session, and
|
||||
// fleet subscription can have several cars in at once, each its own session, and
|
||||
// anti-passback falls out (a second "entry" on a car already in becomes its exit).
|
||||
//
|
||||
// NB: the SIGNED ledger payload still carries `permitId` (immutable history — see the
|
||||
// schema note). The mutable master data / code is "subscription"; the on-chain field
|
||||
// name is intentionally left as-is so historical events keep verifying.
|
||||
|
||||
export interface PermitMatch {
|
||||
readonly permitId: string;
|
||||
export interface SubscriptionMatch {
|
||||
readonly subscriptionId: string;
|
||||
/** The specific credential/plate value read — the per-car session key. */
|
||||
readonly carKey: string;
|
||||
readonly via: "card" | "qr" | "plate";
|
||||
}
|
||||
|
||||
export class PermitFlow {
|
||||
export class SubscriptionFlow {
|
||||
readonly #db: Db;
|
||||
readonly #log: EventLog;
|
||||
readonly #logger: FastifyBaseLogger;
|
||||
@@ -40,53 +54,53 @@ export class PermitFlow {
|
||||
this.#logger = logger;
|
||||
}
|
||||
|
||||
/** Resolve a read to a permit (by card/QR credential, or by a bound plate), or null. */
|
||||
match(e: DeviceReadEvent): PermitMatch | null {
|
||||
/** Resolve a read to a subscription (by card/QR credential, or a bound plate), or null. */
|
||||
match(e: DeviceReadEvent): SubscriptionMatch | null {
|
||||
// Card / QR / generic credential value.
|
||||
const cred = this.#db
|
||||
.select()
|
||||
.from(permitCredentials)
|
||||
.where(eq(permitCredentials.value, e.value))
|
||||
.from(subscriptionCredentials)
|
||||
.where(eq(subscriptionCredentials.value, e.value))
|
||||
.get();
|
||||
if (cred) {
|
||||
return { permitId: cred.permitId, carKey: e.value, via: cred.kind === "qr" ? "qr" : "card" };
|
||||
return { subscriptionId: cred.subscriptionId, carKey: e.value, via: cred.kind === "qr" ? "qr" : "card" };
|
||||
}
|
||||
// Plate binding: a read plate that matches a permit's bound plate is an identity.
|
||||
// Plate binding: a read plate that matches a subscription's bound plate is an identity.
|
||||
if (e.kind === "plate") {
|
||||
const plate = this.#db.select().from(permitPlates).where(eq(permitPlates.plate, e.value)).get();
|
||||
if (plate) return { permitId: plate.permitId, carKey: e.value, via: "plate" };
|
||||
const plate = this.#db.select().from(subscriptionPlates).where(eq(subscriptionPlates.plate, e.value)).get();
|
||||
if (plate) return { subscriptionId: plate.subscriptionId, carKey: e.value, via: "plate" };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Run the permit entry/exit for a matched read at a barrier. `resolved` is the
|
||||
/** Run the subscription entry/exit for a matched read at a barrier. `resolved` is the
|
||||
* reader's bound relay; its direction constrains, "both" defers to session state. */
|
||||
async run(resolved: ResolvedRelay, e: DeviceReadEvent, m: PermitMatch): Promise<ReadOutcome> {
|
||||
const key = `${m.permitId}:${m.carKey}`;
|
||||
async run(resolved: ResolvedRelay, e: DeviceReadEvent, m: SubscriptionMatch): Promise<ReadOutcome> {
|
||||
const key = `${m.subscriptionId}:${m.carKey}`;
|
||||
if (this.#inFlight.has(key)) return { accepted: false, reason: "duplicate read in flight" };
|
||||
this.#inFlight.add(key);
|
||||
try {
|
||||
return await this.#run(resolved, e, m);
|
||||
} catch (err) {
|
||||
this.#logger.error(`permit-flow failed: ${(err as Error).message}`);
|
||||
this.#logger.error(`subscription-flow failed: ${(err as Error).message}`);
|
||||
return { accepted: false, reason: (err as Error).message };
|
||||
} finally {
|
||||
this.#inFlight.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
async #run(resolved: ResolvedRelay, e: DeviceReadEvent, m: PermitMatch): Promise<ReadOutcome> {
|
||||
const permit = this.#db.select().from(permits).where(eq(permits.id, m.permitId)).get();
|
||||
if (!permit) return { accepted: false, reason: "permit not found" };
|
||||
async #run(resolved: ResolvedRelay, e: DeviceReadEvent, m: SubscriptionMatch): Promise<ReadOutcome> {
|
||||
const sub = this.#db.select().from(subscriptions).where(eq(subscriptions.id, m.subscriptionId)).get();
|
||||
if (!sub) return { accepted: false, reason: "subscription not found" };
|
||||
|
||||
// Validity: active + within the coverage window.
|
||||
const now = new Date().toISOString();
|
||||
const invalid =
|
||||
permit.status !== "active" ||
|
||||
(permit.validFrom != null && now < permit.validFrom) ||
|
||||
(permit.validTo != null && now > permit.validTo);
|
||||
sub.status !== "active" ||
|
||||
(sub.validFrom != null && now < sub.validFrom) ||
|
||||
(sub.validTo != null && now > sub.validTo);
|
||||
if (invalid) {
|
||||
const reason = `permit ${permit.status}/out-of-window`;
|
||||
const reason = `subscription ${sub.status}/out-of-window`;
|
||||
await this.#reject(m, reason);
|
||||
return { accepted: false, reason };
|
||||
}
|
||||
@@ -99,7 +113,7 @@ export class PermitFlow {
|
||||
const carOpen = this.#carHasOpenSession(m.carKey);
|
||||
const inferred: FlowDirection = carOpen ? "exit" : "entry";
|
||||
if (resolved.direction !== "both" && resolved.direction !== inferred) {
|
||||
const reason = `permit wrong barrier — ${resolved.direction} barrier but car would ${inferred}`;
|
||||
const reason = `subscription wrong barrier — ${resolved.direction} barrier but car would ${inferred}`;
|
||||
await this.#reject(m, reason);
|
||||
return { accepted: false, direction: resolved.direction === "exit" ? "exit" : "entry", reason };
|
||||
}
|
||||
@@ -111,18 +125,19 @@ export class PermitFlow {
|
||||
direction: "exit",
|
||||
source: m.via === "plate" ? "lpr" : m.via === "qr" ? "qr" : "wiegand",
|
||||
identity: m.carKey,
|
||||
payload: { sessionRef: m.carKey, permitId: m.permitId },
|
||||
// `permitId` is the on-chain field name (immutable) — carries the subscription id.
|
||||
payload: { sessionRef: m.carKey, permitId: m.subscriptionId },
|
||||
});
|
||||
await this.#open(resolved, "exit", m.carKey, "permit exit");
|
||||
await this.#open(resolved, "exit", m.carKey, "subscription exit");
|
||||
this.#closeCache(m.carKey);
|
||||
return { accepted: true, direction: "exit" };
|
||||
}
|
||||
|
||||
// ENTRY: enforce the car-count binding (maxConcurrent), then sign + open.
|
||||
if (permit.maxConcurrent != null) {
|
||||
const open = this.#permitOpenCount(m.permitId);
|
||||
if (open >= permit.maxConcurrent) {
|
||||
const reason = `permit at capacity (${open}/${permit.maxConcurrent} cars in)`;
|
||||
if (sub.maxConcurrent != null) {
|
||||
const open = this.#subscriptionOpenCount(m.subscriptionId);
|
||||
if (open >= sub.maxConcurrent) {
|
||||
const reason = `subscription at capacity (${open}/${sub.maxConcurrent} cars in)`;
|
||||
await this.#reject(m, reason);
|
||||
return { accepted: false, direction: "entry", reason };
|
||||
}
|
||||
@@ -133,15 +148,23 @@ export class PermitFlow {
|
||||
direction: "entry",
|
||||
source: m.via === "plate" ? "lpr" : m.via === "qr" ? "qr" : "wiegand",
|
||||
identity: m.carKey,
|
||||
// No ticket, no fee — the permit IS the authorization. Recorded for audit.
|
||||
payload: { sessionRef: m.carKey, permitId: m.permitId, permit: true },
|
||||
// No ticket, no fee — the subscription IS the authorization. Recorded for audit.
|
||||
// `permitId`/`permit` are the on-chain field names (immutable).
|
||||
payload: { sessionRef: m.carKey, permitId: m.subscriptionId, permit: true },
|
||||
occurredAt: now,
|
||||
});
|
||||
await this.#open(resolved, "entry", m.carKey, "permit entry");
|
||||
await this.#open(resolved, "entry", m.carKey, "subscription entry");
|
||||
try {
|
||||
this.#db
|
||||
.insert(sessions)
|
||||
.values({ id: m.carKey, identity: m.carKey, source: m.via === "plate" ? "lpr" : "wiegand", permitId: m.permitId, enteredAt: now, state: "open" })
|
||||
.values({
|
||||
id: m.carKey,
|
||||
identity: m.carKey,
|
||||
source: m.via === "plate" ? "lpr" : "wiegand",
|
||||
subscriptionId: m.subscriptionId,
|
||||
enteredAt: now,
|
||||
state: "open",
|
||||
})
|
||||
.run();
|
||||
} catch (err) {
|
||||
this.#logger.error(`session-cache insert failed for ${m.carKey}: ${(err as Error).message}`);
|
||||
@@ -162,14 +185,15 @@ export class PermitFlow {
|
||||
return entries > exits;
|
||||
}
|
||||
|
||||
/** How many of this permit's cars are inside right now (fold over the ledger). */
|
||||
#permitOpenCount(permitId: string): number {
|
||||
/** How many of this subscription's cars are inside right now (fold over the ledger).
|
||||
* The on-chain field is `permitId`, so we match against that. */
|
||||
#subscriptionOpenCount(subscriptionId: string): number {
|
||||
const rows = this.#db
|
||||
.select()
|
||||
.from(ledgerEvents)
|
||||
.where(eq(ledgerEvents.type, "vehicle_entry"))
|
||||
.all()
|
||||
.filter((r) => (r.payload as { permitId?: string } | null)?.permitId === permitId);
|
||||
.filter((r) => (r.payload as { permitId?: string } | null)?.permitId === subscriptionId);
|
||||
let open = 0;
|
||||
for (const entry of rows) {
|
||||
if (!this.#carHasOpenSession(entry.identity ?? "")) continue;
|
||||
@@ -178,13 +202,14 @@ export class PermitFlow {
|
||||
return open;
|
||||
}
|
||||
|
||||
async #reject(m: PermitMatch, reason: string): Promise<void> {
|
||||
async #reject(m: SubscriptionMatch, reason: string): Promise<void> {
|
||||
await this.#log.append({
|
||||
type: "anomaly",
|
||||
identity: m.carKey,
|
||||
payload: { reason: `permit refused — ${reason}`, permitId: m.permitId, permitRefused: true },
|
||||
// `permitId`/`permitRefused` are the on-chain field names (immutable).
|
||||
payload: { reason: `subscription refused — ${reason}`, permitId: m.subscriptionId, permitRefused: true },
|
||||
});
|
||||
this.#logger.warn(`permit refused (${m.carKey}): ${reason}`);
|
||||
this.#logger.warn(`subscription refused (${m.carKey}): ${reason}`);
|
||||
}
|
||||
|
||||
async #open(resolved: ResolvedRelay, dir: FlowDirection, carKey: string, what: string): Promise<void> {
|
||||
@@ -198,7 +223,7 @@ export class PermitFlow {
|
||||
direction: dir,
|
||||
identity: carKey,
|
||||
logger: this.#logger,
|
||||
}).catch((err) => this.#logger.error(`permit snapshot error: ${(err as Error).message}`));
|
||||
}).catch((err) => this.#logger.error(`subscription snapshot error: ${(err as Error).message}`));
|
||||
}
|
||||
|
||||
#closeCache(carKey: string): void {
|
||||
Reference in New Issue
Block a user