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:
2026-06-18 13:15:04 +02:00
parent ca8c7f2fa2
commit 5697137c52
32 changed files with 1008 additions and 675 deletions
+2 -2
View File
@@ -73,9 +73,9 @@ export class EntryFlow {
async #runEntry(resolved: ResolvedRelay): Promise<void> { async #runEntry(resolved: ResolvedRelay): Promise<void> {
// CAPACITY GATE (transient only). When the lot is full, refuse transient entry: // 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 // 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. // capacity later. See wiki/concepts/capacity-occupancy.md.
const occ = getOccupancy(this.#db); const occ = getOccupancy(this.#db);
if (occ.full) { if (occ.full) {
+14 -14
View File
@@ -2,32 +2,32 @@ import { devices, eq, type Db } from "@parking/db";
import type { FastifyBaseLogger } from "fastify"; import type { FastifyBaseLogger } from "fastify";
import type { DeviceReadEvent, ReadOutcome } from "./device-events.js"; import type { DeviceReadEvent, ReadOutcome } from "./device-events.js";
import type { ExitFlow } from "./exit-flow.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"; import { relayForDevice } from "./device-resolve.js";
// Routes a credential read (ticket scan / plate / card) to the right flow. A read // 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): // 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). // - 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 // 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 // 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 // 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" // 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 // reader the exit side; "both" defers to the flow's own inference (subscription:
// state; transient: exit). // session state; transient: exit).
export class ReadDispatcher { export class ReadDispatcher {
readonly #db: Db; readonly #db: Db;
readonly #exit: ExitFlow; readonly #exit: ExitFlow;
readonly #permit: PermitFlow; readonly #subscription: SubscriptionFlow;
readonly #logger: FastifyBaseLogger; 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.#db = db;
this.#exit = exit; this.#exit = exit;
this.#permit = permit; this.#subscription = subscription;
this.#logger = logger; this.#logger = logger;
} }
@@ -41,13 +41,13 @@ export class ReadDispatcher {
return { accepted: false, reason: "reader not bound to a barrier (no relay to open)" }; return { accepted: false, reason: "reader not bound to a barrier (no relay to open)" };
} }
const permit = this.#permit.match(e); const sub = this.#subscription.match(e);
if (permit) { if (sub) {
return this.#permit.run(resolved, e, permit); return this.#subscription.run(resolved, e, sub);
} }
// Not a permit → transient ticket exit. An ENTRY reader can't produce a transient // Not a subscription → transient ticket exit. An ENTRY reader can't produce a
// exit (transient entry is the button flow, not a reader), so reject+log rather // transient exit (transient entry is the button flow, not a reader), so reject+log
// than treat an entry scan as an exit. // rather than treat an entry scan as an exit.
if (resolved.direction === "entry") { if (resolved.direction === "entry") {
return { accepted: false, direction: "entry", reason: "entry reader: no transient entry via reader" }; return { accepted: false, direction: "entry", reason: "entry reader: no transient entry via reader" };
} }
-160
View File
@@ -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();
},
);
}
+17 -5
View File
@@ -23,18 +23,23 @@ interface SiteConfigBody extends Partial<Record<TextField, string | null>> {
capacity?: number | null; capacity?: number | null;
/** Default for the booth "print exit ticket" checkbox (booth-geography knob). */ /** Default for the booth "print exit ticket" checkbox (booth-geography knob). */
exitVoucherDefault?: boolean; 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. */ /** Shape returned by GET/PUT: capacity + the booth flag + the subscription default
type SiteConfig = { capacity: number | null; exitVoucherDefault: boolean } & Record< * + every metadata field. */
TextField, type SiteConfig = {
string | null capacity: number | null;
>; exitVoucherDefault: boolean;
subscriptionMonthlyPriceMinor: number | null;
} & Record<TextField, string | null>;
function toSiteConfig(row: typeof siteConfig.$inferSelect | undefined): SiteConfig { function toSiteConfig(row: typeof siteConfig.$inferSelect | undefined): SiteConfig {
const out = { const out = {
capacity: row?.capacity ?? null, capacity: row?.capacity ?? null,
exitVoucherDefault: row?.exitVoucherDefault ?? false, exitVoucherDefault: row?.exitVoucherDefault ?? false,
subscriptionMonthlyPriceMinor: row?.subscriptionMonthlyPriceMinor ?? null,
} as SiteConfig; } as SiteConfig;
for (const f of TEXT_FIELDS) out[f] = row?.[f] ?? null; for (const f of TEXT_FIELDS) out[f] = row?.[f] ?? null;
return out; return out;
@@ -79,6 +84,13 @@ export async function siteRoutes(app: FastifyInstance, db: Db): Promise<void> {
} }
patch.exitVoucherDefault = body.exitVoucherDefault; 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) { for (const f of TEXT_FIELDS) {
if (f in body) patch[f] = normText(body[f]); if (f in body) patch[f] = normText(body[f]);
} }
+191
View File
@@ -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();
},
);
}
+21 -9
View File
@@ -10,16 +10,17 @@ import { EntryFlow } from "./entry-flow.js";
import { EventLog } from "./event-log.js"; import { EventLog } from "./event-log.js";
import { ExitFlow } from "./exit-flow.js"; import { ExitFlow } from "./exit-flow.js";
import { PayStation } from "./pay-station.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 { ShiftService } from "./shift-service.js";
import { ReadDispatcher } from "./read-dispatch.js"; import { ReadDispatcher } from "./read-dispatch.js";
import { PrinterMonitor } from "./printer-monitor.js"; import { PrinterMonitor } from "./printer-monitor.js";
import { DeviceMonitor } from "./device-monitor.js";
import { buildSigner, buildVerifier } from "./signer.js"; import { buildSigner, buildVerifier } from "./signer.js";
import { authRoutes } from "./routes/auth.js"; import { authRoutes } from "./routes/auth.js";
import { deviceRoutes } from "./routes/devices.js"; import { deviceRoutes } from "./routes/devices.js";
import { eventRoutes } from "./routes/events.js"; import { eventRoutes } from "./routes/events.js";
import { payRoutes } from "./routes/pay.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 { qrReaderRoutes } from "./routes/qr-reader.js";
import { shiftRoutes } from "./routes/shift.js"; import { shiftRoutes } from "./routes/shift.js";
import { siteRoutes } from "./routes/site.js"; import { siteRoutes } from "./routes/site.js";
@@ -27,6 +28,7 @@ import { snapshotRoutes } from "./routes/snapshots.js";
import { tariffRoutes } from "./routes/tariffs.js"; import { tariffRoutes } from "./routes/tariffs.js";
import { printerRoutes } from "./routes/printers.js"; import { printerRoutes } from "./routes/printers.js";
import { setupRoutes } from "./routes/setup.js"; import { setupRoutes } from "./routes/setup.js";
import { deviceStatusRoutes } from "./routes/device-status.js";
import { wsRoutes } from "./routes/ws.js"; import { wsRoutes } from "./routes/ws.js";
// The backend is Fastify (Node). Hardware drivers live as isolated Fastify // 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("onReady", async () => printerMonitor.start());
app.addHook("onClose", async () => printerMonitor.stop()); 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 // Append-only signed business LEDGER (ledger_events). Holds only business facts
// (vehicle_entry/exit, payment, void, …) — the anti-fraud audit trail. A raw // (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 // 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 // Live booth feed: server-pushed ledger + occupancy + printer-status over a
// single authenticated WebSocket (/api/ws). See routes/ws.ts. // 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. // Entry/exit camera snapshots (BLOB-in-DB), read-only. See snapshot.ts.
await snapshotRoutes(app, db); await snapshotRoutes(app, db);
@@ -117,11 +128,12 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
app.addHook("onClose", async () => unsubscribeEntry()); app.addHook("onClose", async () => unsubscribeEntry());
// Read-driven flows: a credential read (ticket scan / plate / card) routes via the // 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 // dispatcher to either the SUBSCRIPTION flow (if it matches a subscription) or the
// EXIT flow. See read-dispatch.ts, exit-flow.ts, permit-flow.ts, parking-session.md. // 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 exitFlow = new ExitFlow(db, eventLog, app.log);
const permitFlow = new PermitFlow(db, eventLog, app.log); const subscriptionFlow = new SubscriptionFlow(db, eventLog, app.log);
const readDispatcher = new ReadDispatcher(db, exitFlow, permitFlow, app.log); const readDispatcher = new ReadDispatcher(db, exitFlow, subscriptionFlow, app.log);
const unsubscribeRead = deviceEvents.onRead((e) => { const unsubscribeRead = deviceEvents.onRead((e) => {
void readDispatcher.dispatch(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. // the pay station prices against. See wiki/concepts/tariff.md.
await tariffRoutes(app, db); await tariffRoutes(app, db);
// Permit (subscription) admin CRUD. See wiki/entities/permit.md. // Subscription admin CRUD. See wiki/entities/subscription.md.
await permitRoutes(app, db); await subscriptionRoutes(app, db);
// Shift open/close + drawer endpoints (shiftService constructed above). // Shift open/close + drawer endpoints (shiftService constructed above).
await shiftRoutes(app, shiftService); await shiftRoutes(app, shiftService);
+1 -1
View File
@@ -19,7 +19,7 @@ import { devicesByDirection, type FlowDirection } from "./device-resolve.js";
interface SnapshotJob { interface SnapshotJob {
readonly db: Db; readonly db: Db;
readonly direction: FlowDirection; 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 identity: string;
readonly logger: FastifyBaseLogger; 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 { registry, type AccessControlDevice } from "@parking/devices";
import type { FastifyBaseLogger } from "fastify"; import type { FastifyBaseLogger } from "fastify";
import type { DeviceReadEvent, ReadOutcome } from "./device-events.js"; 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 { type FlowDirection, type ResolvedRelay } from "./device-resolve.js";
import { snapshotAsync } from "./snapshot.js"; import { snapshotAsync } from "./snapshot.js";
// PERMIT flow: a subscriber identified by card/QR/plate enters/exits without paying. // SUBSCRIPTION flow: a subscriber identified by card/QR/plate enters/exits without
// Reached from the read dispatcher when a read matches a permit (not an open ticket). // paying per stay (they're on a recurring plan). Reached from the read dispatcher
// See wiki/entities/permit.md. // when a read matches a subscription (not an open ticket). See
// wiki/entities/subscription.md.
// //
// Two optional, independent bindings: // Two optional, independent bindings:
// - car-count: `maxConcurrent` (default 1, null = unbound) — how many of the // - 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 // - 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 // 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 // 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). // 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 { export interface SubscriptionMatch {
readonly permitId: string; readonly subscriptionId: string;
/** The specific credential/plate value read — the per-car session key. */ /** The specific credential/plate value read — the per-car session key. */
readonly carKey: string; readonly carKey: string;
readonly via: "card" | "qr" | "plate"; readonly via: "card" | "qr" | "plate";
} }
export class PermitFlow { export class SubscriptionFlow {
readonly #db: Db; readonly #db: Db;
readonly #log: EventLog; readonly #log: EventLog;
readonly #logger: FastifyBaseLogger; readonly #logger: FastifyBaseLogger;
@@ -40,53 +54,53 @@ export class PermitFlow {
this.#logger = logger; this.#logger = logger;
} }
/** Resolve a read to a permit (by card/QR credential, or by a bound plate), or null. */ /** Resolve a read to a subscription (by card/QR credential, or a bound plate), or null. */
match(e: DeviceReadEvent): PermitMatch | null { match(e: DeviceReadEvent): SubscriptionMatch | null {
// Card / QR / generic credential value. // Card / QR / generic credential value.
const cred = this.#db const cred = this.#db
.select() .select()
.from(permitCredentials) .from(subscriptionCredentials)
.where(eq(permitCredentials.value, e.value)) .where(eq(subscriptionCredentials.value, e.value))
.get(); .get();
if (cred) { 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") { if (e.kind === "plate") {
const plate = this.#db.select().from(permitPlates).where(eq(permitPlates.plate, e.value)).get(); const plate = this.#db.select().from(subscriptionPlates).where(eq(subscriptionPlates.plate, e.value)).get();
if (plate) return { permitId: plate.permitId, carKey: e.value, via: "plate" }; if (plate) return { subscriptionId: plate.subscriptionId, carKey: e.value, via: "plate" };
} }
return null; 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. */ * reader's bound relay; its direction constrains, "both" defers to session state. */
async run(resolved: ResolvedRelay, e: DeviceReadEvent, m: PermitMatch): Promise<ReadOutcome> { async run(resolved: ResolvedRelay, e: DeviceReadEvent, m: SubscriptionMatch): Promise<ReadOutcome> {
const key = `${m.permitId}:${m.carKey}`; const key = `${m.subscriptionId}:${m.carKey}`;
if (this.#inFlight.has(key)) return { accepted: false, reason: "duplicate read in flight" }; if (this.#inFlight.has(key)) return { accepted: false, reason: "duplicate read in flight" };
this.#inFlight.add(key); this.#inFlight.add(key);
try { try {
return await this.#run(resolved, e, m); return await this.#run(resolved, e, m);
} catch (err) { } 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 }; return { accepted: false, reason: (err as Error).message };
} finally { } finally {
this.#inFlight.delete(key); this.#inFlight.delete(key);
} }
} }
async #run(resolved: ResolvedRelay, e: DeviceReadEvent, m: PermitMatch): Promise<ReadOutcome> { async #run(resolved: ResolvedRelay, e: DeviceReadEvent, m: SubscriptionMatch): Promise<ReadOutcome> {
const permit = this.#db.select().from(permits).where(eq(permits.id, m.permitId)).get(); const sub = this.#db.select().from(subscriptions).where(eq(subscriptions.id, m.subscriptionId)).get();
if (!permit) return { accepted: false, reason: "permit not found" }; if (!sub) return { accepted: false, reason: "subscription not found" };
// Validity: active + within the coverage window. // Validity: active + within the coverage window.
const now = new Date().toISOString(); const now = new Date().toISOString();
const invalid = const invalid =
permit.status !== "active" || sub.status !== "active" ||
(permit.validFrom != null && now < permit.validFrom) || (sub.validFrom != null && now < sub.validFrom) ||
(permit.validTo != null && now > permit.validTo); (sub.validTo != null && now > sub.validTo);
if (invalid) { if (invalid) {
const reason = `permit ${permit.status}/out-of-window`; const reason = `subscription ${sub.status}/out-of-window`;
await this.#reject(m, reason); await this.#reject(m, reason);
return { accepted: false, reason }; return { accepted: false, reason };
} }
@@ -99,7 +113,7 @@ export class PermitFlow {
const carOpen = this.#carHasOpenSession(m.carKey); const carOpen = this.#carHasOpenSession(m.carKey);
const inferred: FlowDirection = carOpen ? "exit" : "entry"; const inferred: FlowDirection = carOpen ? "exit" : "entry";
if (resolved.direction !== "both" && resolved.direction !== inferred) { 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); await this.#reject(m, reason);
return { accepted: false, direction: resolved.direction === "exit" ? "exit" : "entry", reason }; return { accepted: false, direction: resolved.direction === "exit" ? "exit" : "entry", reason };
} }
@@ -111,18 +125,19 @@ export class PermitFlow {
direction: "exit", direction: "exit",
source: m.via === "plate" ? "lpr" : m.via === "qr" ? "qr" : "wiegand", source: m.via === "plate" ? "lpr" : m.via === "qr" ? "qr" : "wiegand",
identity: m.carKey, 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); this.#closeCache(m.carKey);
return { accepted: true, direction: "exit" }; return { accepted: true, direction: "exit" };
} }
// ENTRY: enforce the car-count binding (maxConcurrent), then sign + open. // ENTRY: enforce the car-count binding (maxConcurrent), then sign + open.
if (permit.maxConcurrent != null) { if (sub.maxConcurrent != null) {
const open = this.#permitOpenCount(m.permitId); const open = this.#subscriptionOpenCount(m.subscriptionId);
if (open >= permit.maxConcurrent) { if (open >= sub.maxConcurrent) {
const reason = `permit at capacity (${open}/${permit.maxConcurrent} cars in)`; const reason = `subscription at capacity (${open}/${sub.maxConcurrent} cars in)`;
await this.#reject(m, reason); await this.#reject(m, reason);
return { accepted: false, direction: "entry", reason }; return { accepted: false, direction: "entry", reason };
} }
@@ -133,15 +148,23 @@ export class PermitFlow {
direction: "entry", direction: "entry",
source: m.via === "plate" ? "lpr" : m.via === "qr" ? "qr" : "wiegand", source: m.via === "plate" ? "lpr" : m.via === "qr" ? "qr" : "wiegand",
identity: m.carKey, identity: m.carKey,
// No ticket, no fee — the permit IS the authorization. Recorded for audit. // No ticket, no fee — the subscription IS the authorization. Recorded for audit.
payload: { sessionRef: m.carKey, permitId: m.permitId, permit: true }, // `permitId`/`permit` are the on-chain field names (immutable).
payload: { sessionRef: m.carKey, permitId: m.subscriptionId, permit: true },
occurredAt: now, occurredAt: now,
}); });
await this.#open(resolved, "entry", m.carKey, "permit entry"); await this.#open(resolved, "entry", m.carKey, "subscription entry");
try { try {
this.#db this.#db
.insert(sessions) .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(); .run();
} catch (err) { } catch (err) {
this.#logger.error(`session-cache insert failed for ${m.carKey}: ${(err as Error).message}`); this.#logger.error(`session-cache insert failed for ${m.carKey}: ${(err as Error).message}`);
@@ -162,14 +185,15 @@ export class PermitFlow {
return entries > exits; return entries > exits;
} }
/** How many of this permit's cars are inside right now (fold over the ledger). */ /** How many of this subscription's cars are inside right now (fold over the ledger).
#permitOpenCount(permitId: string): number { * The on-chain field is `permitId`, so we match against that. */
#subscriptionOpenCount(subscriptionId: string): number {
const rows = this.#db const rows = this.#db
.select() .select()
.from(ledgerEvents) .from(ledgerEvents)
.where(eq(ledgerEvents.type, "vehicle_entry")) .where(eq(ledgerEvents.type, "vehicle_entry"))
.all() .all()
.filter((r) => (r.payload as { permitId?: string } | null)?.permitId === permitId); .filter((r) => (r.payload as { permitId?: string } | null)?.permitId === subscriptionId);
let open = 0; let open = 0;
for (const entry of rows) { for (const entry of rows) {
if (!this.#carHasOpenSession(entry.identity ?? "")) continue; if (!this.#carHasOpenSession(entry.identity ?? "")) continue;
@@ -178,13 +202,14 @@ export class PermitFlow {
return open; return open;
} }
async #reject(m: PermitMatch, reason: string): Promise<void> { async #reject(m: SubscriptionMatch, reason: string): Promise<void> {
await this.#log.append({ await this.#log.append({
type: "anomaly", type: "anomaly",
identity: m.carKey, 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> { async #open(resolved: ResolvedRelay, dir: FlowDirection, carKey: string, what: string): Promise<void> {
@@ -198,7 +223,7 @@ export class PermitFlow {
direction: dir, direction: dir,
identity: carKey, identity: carKey,
logger: this.#logger, 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 { #closeCache(carKey: string): void {
-191
View File
@@ -1,191 +0,0 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import {
ApiError,
createPermit,
deletePermit,
fetchPermits,
revokePermit,
updatePermit,
type Permit,
type PermitCredential,
type PermitInput,
} from "./api.js";
// Permit (subscription) admin. Create/edit/revoke/delete permits + their
// credentials (card/QR) and bound plates. A permit is mutable master data; every
// USE of it is a signed ledger event elsewhere. See wiki/entities/permit.md.
interface FormState {
holderName: string;
contact: string;
carBound: boolean; // false = unbound (maxConcurrent null)
maxConcurrent: string;
validFrom: string;
validTo: string;
credentials: PermitCredential[];
platesText: string; // comma/space separated
}
function emptyForm(): FormState {
return { holderName: "", contact: "", carBound: true, maxConcurrent: "1", validFrom: "", validTo: "", credentials: [{ kind: "rf", value: "" }], platesText: "" };
}
function formFrom(p: Permit): FormState {
return {
holderName: p.holderName ?? "",
contact: p.contact ?? "",
carBound: p.maxConcurrent != null,
maxConcurrent: p.maxConcurrent != null ? String(p.maxConcurrent) : "1",
validFrom: p.validFrom ?? "",
validTo: p.validTo ?? "",
credentials: p.credentials.length ? p.credentials : [{ kind: "rf", value: "" }],
platesText: p.plates.join(", "),
};
}
const STATUS_KEY: Record<Permit["status"], string> = {
active: "permits.statusActive",
suspended: "permits.statusSuspended",
revoked: "permits.statusRevoked",
};
function toInput(f: FormState): PermitInput {
return {
holderName: f.holderName.trim() || null,
contact: f.contact.trim() || null,
maxConcurrent: f.carBound ? Math.max(1, Math.round(Number(f.maxConcurrent) || 1)) : null,
validFrom: f.validFrom.trim() || null,
validTo: f.validTo.trim() || null,
credentials: f.credentials.filter((c) => c.value.trim()).map((c) => ({ kind: c.kind, value: c.value.trim() })),
plates: f.platesText.split(/[,\s]+/).map((s) => s.trim()).filter(Boolean),
};
}
export function PermitManager() {
const { t } = useTranslation();
const [permits, setPermits] = useState<Permit[] | null>(null);
const [editing, setEditing] = useState<string | "new" | null>(null);
const [form, setForm] = useState<FormState>(emptyForm);
const [msg, setMsg] = useState<{ kind: "ok" | "err"; text: string } | null>(null);
function reload() {
fetchPermits()
.then((r) => setPermits(r.permits))
.catch((e) => setMsg({ kind: "err", text: (e as Error).message }));
}
useEffect(reload, []);
function startNew() {
setForm(emptyForm());
setEditing("new");
setMsg(null);
}
function startEdit(p: Permit) {
setForm(formFrom(p));
setEditing(p.id);
setMsg(null);
}
async function save() {
setMsg(null);
try {
if (editing === "new") await createPermit(toInput(form));
else if (editing) await updatePermit(editing, toInput(form));
setEditing(null);
reload();
setMsg({ kind: "ok", text: t("permits.permitSaved") });
} catch (e) {
const problems = e instanceof ApiError ? (e as ApiError & { problems?: string[] }).problems : undefined;
setMsg({ kind: "err", text: problems?.length ? `${(e as Error).message}: ${problems.join("; ")}` : (e as Error).message });
}
}
async function doRevoke(p: Permit) {
if (!confirm(t("permits.confirmRevoke", { name: p.holderName ?? p.id }))) return;
await revokePermit(p.id).catch((e) => setMsg({ kind: "err", text: (e as Error).message }));
reload();
}
async function doDelete(p: Permit) {
if (!confirm(t("permits.confirmDelete", { name: p.holderName ?? p.id }))) return;
await deletePermit(p.id).catch((e) => setMsg({ kind: "err", text: (e as Error).message }));
reload();
}
function setCred(i: number, patch: Partial<PermitCredential>) {
setForm((f) => ({ ...f, credentials: f.credentials.map((c, j) => (j === i ? { ...c, ...patch } : c)) }));
}
if (!permits) return null;
return (
<section style={{ marginTop: "2rem" }}>
<h2>{t("permits.title")}</h2>
<ul style={{ listStyle: "none", padding: 0 }}>
{permits.map((p) => (
<li key={p.id} style={{ display: "flex", gap: "0.5rem", alignItems: "center", padding: "0.4rem 0", borderBottom: "1px solid #eee" }}>
<strong>{p.holderName ?? t("permits.unnamed")}</strong>
<span style={{ color: p.status === "active" ? "#16a34a" : "#b45309" }}>{t(STATUS_KEY[p.status])}</span>
<span style={{ color: "#666" }}>
{p.maxConcurrent == null ? t("permits.unbound") : t("permits.car", { count: p.maxConcurrent })} ·{" "}
{p.credentials.length} {t("permits.cred")} · {t("permits.plates", { count: p.plates.length })}
</span>
<span style={{ flex: 1 }} />
<button type="button" onClick={() => startEdit(p)}>{t("permits.edit")}</button>
{p.status !== "revoked" && <button type="button" onClick={() => doRevoke(p)}>{t("permits.revoke")}</button>}
<button type="button" onClick={() => doDelete(p)}>{t("permits.delete")}</button>
</li>
))}
{permits.length === 0 && <li style={{ color: "#777" }}>{t("permits.noPermitsYet")}</li>}
</ul>
{editing == null ? (
<button type="button" onClick={startNew}>{t("permits.addPermit")}</button>
) : (
<div style={{ border: "1px solid #ddd", padding: "1rem", marginTop: "0.5rem", maxWidth: 460 }}>
<h3 style={{ marginTop: 0 }}>{editing === "new" ? t("permits.newPermit") : t("permits.editPermit")}</h3>
<div style={{ display: "grid", gridTemplateColumns: "max-content 1fr", gap: "0.4rem 0.75rem", alignItems: "center" }}>
<label>{t("permits.holderName")}</label>
<input value={form.holderName} onChange={(e) => setForm((f) => ({ ...f, holderName: e.target.value }))} />
<label>{t("permits.contact")}</label>
<input value={form.contact} onChange={(e) => setForm((f) => ({ ...f, contact: e.target.value }))} />
<label>{t("permits.carLimit")}</label>
<span>
<label style={{ marginRight: "0.5rem" }}>
<input type="checkbox" checked={form.carBound} onChange={(e) => setForm((f) => ({ ...f, carBound: e.target.checked }))} /> {t("permits.limitCarsInAtOnce")}
</label>
{form.carBound && (
<input value={form.maxConcurrent} onChange={(e) => setForm((f) => ({ ...f, maxConcurrent: e.target.value }))} style={{ width: 50 }} />
)}
</span>
<label>{t("permits.validFrom")}</label>
<input value={form.validFrom} onChange={(e) => setForm((f) => ({ ...f, validFrom: e.target.value }))} placeholder={t("permits.isoDateOptional")} />
<label>{t("permits.validTo")}</label>
<input value={form.validTo} onChange={(e) => setForm((f) => ({ ...f, validTo: e.target.value }))} placeholder={t("permits.isoDateOptional")} />
<label>{t("permits.boundPlates")}</label>
<input value={form.platesText} onChange={(e) => setForm((f) => ({ ...f, platesText: e.target.value }))} placeholder={t("permits.commaSeparatedOptional")} />
</div>
<h4 style={{ marginBottom: "0.25rem" }}>{t("permits.credentialsCardQr")}</h4>
{form.credentials.map((c, i) => (
<div key={i} style={{ display: "flex", gap: "0.4rem", marginBottom: "0.3rem" }}>
<select value={c.kind} onChange={(e) => setCred(i, { kind: e.target.value as "rf" | "qr" })}>
<option value="rf">{t("permits.rfCardTag")}</option>
<option value="qr">{t("permits.qr")}</option>
</select>
<input value={c.value} onChange={(e) => setCred(i, { value: e.target.value })} placeholder={t("permits.credentialValue")} style={{ flex: 1 }} />
<button type="button" onClick={() => setForm((f) => ({ ...f, credentials: f.credentials.filter((_, j) => j !== i) }))}>×</button>
</div>
))}
<button type="button" onClick={() => setForm((f) => ({ ...f, credentials: [...f.credentials, { kind: "rf", value: "" }] }))}>{t("permits.addCredential")}</button>
<p style={{ color: "#777", fontSize: "0.85em", margin: "0.5rem 0 0" }}>
{t("permits.needCredentialOrPlate")}
</p>
<div style={{ marginTop: "1rem", display: "flex", gap: "0.5rem" }}>
<button type="button" onClick={save}>{t("permits.save")}</button>
<button type="button" onClick={() => setEditing(null)}>{t("permits.cancel")}</button>
</div>
</div>
)}
{msg && <p style={{ color: msg.kind === "ok" ? "#16a34a" : "crimson" }}>{msg.text}</p>}
</section>
);
}
+244
View File
@@ -0,0 +1,244 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import {
ApiError,
createSubscription,
deleteSubscription,
fetchSiteConfig,
fetchSubscriptions,
revokeSubscription,
updateSubscription,
type Subscription,
type SubscriptionCredential,
type SubscriptionInput,
} from "./api.js";
// Subscription admin. Create/edit/revoke/delete subscriptions + their credentials
// (card/QR) and bound plates, and the recurring monthly price (e.g. 10,000 ALL). A
// subscription is mutable master data; every USE of it is a signed ledger event
// elsewhere. See wiki/entities/subscription.md.
const DEFAULT_CURRENCY = "ALL";
interface FormState {
holderName: string;
contact: string;
priceMajor: string; // major units as typed (e.g. "10000"); "" = no price
currency: string;
carBound: boolean; // false = unbound (maxConcurrent null)
maxConcurrent: string;
validFrom: string;
validTo: string;
credentials: SubscriptionCredential[];
platesText: string; // comma/space separated
}
function emptyForm(defaultPriceMajor = "", currency = DEFAULT_CURRENCY): FormState {
return {
holderName: "",
contact: "",
priceMajor: defaultPriceMajor,
currency,
carBound: true,
maxConcurrent: "1",
validFrom: "",
validTo: "",
credentials: [{ kind: "rf", value: "" }],
platesText: "",
};
}
function formFrom(s: Subscription): FormState {
return {
holderName: s.holderName ?? "",
contact: s.contact ?? "",
priceMajor: s.priceMinor != null ? String(s.priceMinor / 100) : "",
currency: s.currency ?? DEFAULT_CURRENCY,
carBound: s.maxConcurrent != null,
maxConcurrent: s.maxConcurrent != null ? String(s.maxConcurrent) : "1",
validFrom: s.validFrom ?? "",
validTo: s.validTo ?? "",
credentials: s.credentials.length ? s.credentials : [{ kind: "rf", value: "" }],
platesText: s.plates.join(", "),
};
}
const STATUS_KEY: Record<Subscription["status"], string> = {
active: "subs.statusActive",
suspended: "subs.statusSuspended",
revoked: "subs.statusRevoked",
};
function toInput(f: FormState): SubscriptionInput {
const major = Number(f.priceMajor);
const priceSet = f.priceMajor.trim() !== "" && Number.isFinite(major) && major >= 0;
return {
holderName: f.holderName.trim() || null,
contact: f.contact.trim() || null,
priceMinor: priceSet ? Math.round(major * 100) : null,
period: "monthly",
currency: priceSet ? f.currency.trim() || DEFAULT_CURRENCY : null,
maxConcurrent: f.carBound ? Math.max(1, Math.round(Number(f.maxConcurrent) || 1)) : null,
validFrom: f.validFrom.trim() || null,
validTo: f.validTo.trim() || null,
credentials: f.credentials.filter((c) => c.value.trim()).map((c) => ({ kind: c.kind, value: c.value.trim() })),
plates: f.platesText.split(/[,\s]+/).map((s) => s.trim()).filter(Boolean),
};
}
function priceLabel(s: Subscription, t: (k: string) => string): string {
if (s.priceMinor == null) return t("subs.noPrice");
return `${(s.priceMinor / 100).toLocaleString()} ${s.currency ?? ""} / ${t("subs.perMonth")}`.trim();
}
export function SubscriptionManager() {
const { t } = useTranslation();
const [subs, setSubs] = useState<Subscription[] | null>(null);
const [defaultPriceMajor, setDefaultPriceMajor] = useState("");
const [editing, setEditing] = useState<string | "new" | null>(null);
const [form, setForm] = useState<FormState>(() => emptyForm());
const [msg, setMsg] = useState<{ kind: "ok" | "err"; text: string } | null>(null);
function reload() {
fetchSubscriptions()
.then((r) => setSubs(r.subscriptions))
.catch((e) => setMsg({ kind: "err", text: (e as Error).message }));
}
useEffect(() => {
reload();
// Pull the site default monthly price to pre-fill new subscriptions.
fetchSiteConfig()
.then((c) => {
if (c.subscriptionMonthlyPriceMinor != null) setDefaultPriceMajor(String(c.subscriptionMonthlyPriceMinor / 100));
})
.catch(() => {
/* non-fatal — the form just won't pre-fill */
});
}, []);
function startNew() {
setForm(emptyForm(defaultPriceMajor));
setEditing("new");
setMsg(null);
}
function startEdit(s: Subscription) {
setForm(formFrom(s));
setEditing(s.id);
setMsg(null);
}
async function save() {
setMsg(null);
try {
if (editing === "new") await createSubscription(toInput(form));
else if (editing) await updateSubscription(editing, toInput(form));
setEditing(null);
reload();
setMsg({ kind: "ok", text: t("subs.saved") });
} catch (e) {
const problems = e instanceof ApiError ? (e as ApiError & { problems?: string[] }).problems : undefined;
setMsg({ kind: "err", text: problems?.length ? `${(e as Error).message}: ${problems.join("; ")}` : (e as Error).message });
}
}
async function doRevoke(s: Subscription) {
if (!confirm(t("subs.confirmRevoke", { name: s.holderName ?? s.id }))) return;
await revokeSubscription(s.id).catch((e) => setMsg({ kind: "err", text: (e as Error).message }));
reload();
}
async function doDelete(s: Subscription) {
if (!confirm(t("subs.confirmDelete", { name: s.holderName ?? s.id }))) return;
await deleteSubscription(s.id).catch((e) => setMsg({ kind: "err", text: (e as Error).message }));
reload();
}
function setCred(i: number, patch: Partial<SubscriptionCredential>) {
setForm((f) => ({ ...f, credentials: f.credentials.map((c, j) => (j === i ? { ...c, ...patch } : c)) }));
}
if (!subs) return null;
return (
<section style={{ marginTop: "2rem" }}>
<h2>{t("subs.title")}</h2>
<ul style={{ listStyle: "none", padding: 0 }}>
{subs.map((s) => (
<li key={s.id} style={{ display: "flex", gap: "0.5rem", alignItems: "center", padding: "0.4rem 0", borderBottom: "1px solid #eee" }}>
<strong>{s.holderName ?? t("subs.unnamed")}</strong>
<span style={{ color: s.status === "active" ? "#16a34a" : "#b45309" }}>{t(STATUS_KEY[s.status])}</span>
<span style={{ color: "#0a7", fontVariantNumeric: "tabular-nums" }}>{priceLabel(s, t)}</span>
<span style={{ color: "#666" }}>
{s.maxConcurrent == null ? t("subs.unbound") : t("subs.car", { count: s.maxConcurrent })} ·{" "}
{s.credentials.length} {t("subs.cred")} · {t("subs.plates", { count: s.plates.length })}
</span>
<span style={{ flex: 1 }} />
<button type="button" onClick={() => startEdit(s)}>{t("subs.edit")}</button>
{s.status !== "revoked" && <button type="button" onClick={() => doRevoke(s)}>{t("subs.revoke")}</button>}
<button type="button" onClick={() => doDelete(s)}>{t("subs.delete")}</button>
</li>
))}
{subs.length === 0 && <li style={{ color: "#777" }}>{t("subs.noneYet")}</li>}
</ul>
{editing == null ? (
<button type="button" onClick={startNew}>{t("subs.add")}</button>
) : (
<div style={{ border: "1px solid #ddd", padding: "1rem", marginTop: "0.5rem", maxWidth: 460 }}>
<h3 style={{ marginTop: 0 }}>{editing === "new" ? t("subs.new") : t("subs.editTitle")}</h3>
<div style={{ display: "grid", gridTemplateColumns: "max-content 1fr", gap: "0.4rem 0.75rem", alignItems: "center" }}>
<label>{t("subs.holderName")}</label>
<input value={form.holderName} onChange={(e) => setForm((f) => ({ ...f, holderName: e.target.value }))} />
<label>{t("subs.contact")}</label>
<input value={form.contact} onChange={(e) => setForm((f) => ({ ...f, contact: e.target.value }))} />
<label>{t("subs.monthlyPrice")}</label>
<span style={{ display: "flex", gap: "0.4rem", alignItems: "center" }}>
<input
value={form.priceMajor}
onChange={(e) => setForm((f) => ({ ...f, priceMajor: e.target.value }))}
inputMode="decimal"
placeholder={t("subs.pricePlaceholder")}
style={{ width: 110 }}
/>
<input value={form.currency} onChange={(e) => setForm((f) => ({ ...f, currency: e.target.value }))} style={{ width: 60 }} />
<span style={{ color: "#888" }}>/ {t("subs.perMonth")}</span>
</span>
<label>{t("subs.carLimit")}</label>
<span>
<label style={{ marginRight: "0.5rem" }}>
<input type="checkbox" checked={form.carBound} onChange={(e) => setForm((f) => ({ ...f, carBound: e.target.checked }))} /> {t("subs.limitCarsInAtOnce")}
</label>
{form.carBound && (
<input value={form.maxConcurrent} onChange={(e) => setForm((f) => ({ ...f, maxConcurrent: e.target.value }))} style={{ width: 50 }} />
)}
</span>
<label>{t("subs.validFrom")}</label>
<input value={form.validFrom} onChange={(e) => setForm((f) => ({ ...f, validFrom: e.target.value }))} placeholder={t("subs.isoDateOptional")} />
<label>{t("subs.validTo")}</label>
<input value={form.validTo} onChange={(e) => setForm((f) => ({ ...f, validTo: e.target.value }))} placeholder={t("subs.isoDateOptional")} />
<label>{t("subs.boundPlates")}</label>
<input value={form.platesText} onChange={(e) => setForm((f) => ({ ...f, platesText: e.target.value }))} placeholder={t("subs.commaSeparatedOptional")} />
</div>
<h4 style={{ marginBottom: "0.25rem" }}>{t("subs.credentialsCardQr")}</h4>
{form.credentials.map((c, i) => (
<div key={i} style={{ display: "flex", gap: "0.4rem", marginBottom: "0.3rem" }}>
<select value={c.kind} onChange={(e) => setCred(i, { kind: e.target.value as "rf" | "qr" })}>
<option value="rf">{t("subs.rfCardTag")}</option>
<option value="qr">{t("subs.qr")}</option>
</select>
<input value={c.value} onChange={(e) => setCred(i, { value: e.target.value })} placeholder={t("subs.credentialValue")} style={{ flex: 1 }} />
<button type="button" onClick={() => setForm((f) => ({ ...f, credentials: f.credentials.filter((_, j) => j !== i) }))}>×</button>
</div>
))}
<button type="button" onClick={() => setForm((f) => ({ ...f, credentials: [...f.credentials, { kind: "rf", value: "" }] }))}>{t("subs.addCredential")}</button>
<p style={{ color: "#777", fontSize: "0.85em", margin: "0.5rem 0 0" }}>
{t("subs.needCredentialOrPlate")}
</p>
<div style={{ marginTop: "1rem", display: "flex", gap: "0.5rem" }}>
<button type="button" onClick={save}>{t("subs.save")}</button>
<button type="button" onClick={() => setEditing(null)}>{t("subs.cancel")}</button>
</div>
</div>
)}
{msg && <p style={{ color: msg.kind === "ok" ? "#16a34a" : "crimson" }}>{msg.text}</p>}
</section>
);
}
+44 -16
View File
@@ -277,41 +277,45 @@ export function publishTariffVersion(body: {
return apiFetch("/api/tariff/versions", { method: "POST", body: JSON.stringify(body) }); return apiFetch("/api/tariff/versions", { method: "POST", body: JSON.stringify(body) });
} }
// --- Permits -------------------------------------------------------------- // --- Subscriptions --------------------------------------------------------
export interface PermitCredential { export interface SubscriptionCredential {
kind: "rf" | "qr"; kind: "rf" | "qr";
value: string; value: string;
} }
export interface Permit { export interface Subscription {
id: string; id: string;
holderName: string | null; holderName: string | null;
contact: string | null; contact: string | null;
/** Recurring price in minor units (e.g. 1000000 = 10,000.00). null = not set. */
priceMinor: number | null;
period: "monthly";
currency: string | null;
maxConcurrent: number | null; maxConcurrent: number | null;
validFrom: string | null; validFrom: string | null;
validTo: string | null; validTo: string | null;
status: "active" | "suspended" | "revoked"; status: "active" | "suspended" | "revoked";
credentials: PermitCredential[]; credentials: SubscriptionCredential[];
plates: string[]; plates: string[];
} }
export type PermitInput = Omit<Permit, "id" | "status"> & { export type SubscriptionInput = Omit<Subscription, "id" | "status"> & {
status?: Permit["status"]; status?: Subscription["status"];
}; };
export function fetchPermits(): Promise<{ permits: Permit[] }> { export function fetchSubscriptions(): Promise<{ subscriptions: Subscription[] }> {
return apiFetch("/api/permits"); return apiFetch("/api/subscriptions");
} }
export function createPermit(body: PermitInput): Promise<Permit> { export function createSubscription(body: SubscriptionInput): Promise<Subscription> {
return apiFetch("/api/permits", { method: "POST", body: JSON.stringify(body) }); return apiFetch("/api/subscriptions", { method: "POST", body: JSON.stringify(body) });
} }
export function updatePermit(id: string, body: PermitInput): Promise<Permit> { export function updateSubscription(id: string, body: SubscriptionInput): Promise<Subscription> {
return apiFetch(`/api/permits/${id}`, { method: "PUT", body: JSON.stringify(body) }); return apiFetch(`/api/subscriptions/${id}`, { method: "PUT", body: JSON.stringify(body) });
} }
export function revokePermit(id: string): Promise<Permit> { export function revokeSubscription(id: string): Promise<Subscription> {
return apiFetch(`/api/permits/${id}/revoke`, { method: "POST" }); return apiFetch(`/api/subscriptions/${id}/revoke`, { method: "POST" });
} }
export function deletePermit(id: string): Promise<void> { export function deleteSubscription(id: string): Promise<void> {
return apiFetch(`/api/permits/${id}`, { method: "DELETE" }); return apiFetch(`/api/subscriptions/${id}`, { method: "DELETE" });
} }
// --- Shifts --------------------------------------------------------------- // --- Shifts ---------------------------------------------------------------
@@ -378,6 +382,8 @@ export interface SiteConfig {
capacity: number | null; capacity: number | null;
/** Default for the booth "print exit ticket" checkbox (booth-geography knob). */ /** Default for the booth "print exit ticket" checkbox (booth-geography knob). */
exitVoucherDefault: boolean; exitVoucherDefault: boolean;
/** Site default monthly subscription price (minor units); pre-fills the form. */
subscriptionMonthlyPriceMinor: number | null;
parkName: string | null; parkName: string | null;
operatorName: string | null; operatorName: string | null;
/** NIUS — Albanian tax/identification number. */ /** NIUS — Albanian tax/identification number. */
@@ -391,6 +397,28 @@ export function fetchOccupancy(): Promise<Occupancy> {
return apiFetch("/api/occupancy"); return apiFetch("/api/occupancy");
} }
// --- Device status (the booth footer) -------------------------------------
/** Live status of one configured device — mirrors the server's DeviceStatusEvent.
* Every enabled device is polled (printers via rich readStatus, the rest via
* healthCheck) and flattened to one traffic-light. Pushed over the WS; the REST
* snapshot below is the initial load / fallback. */
export interface DeviceStatus {
deviceId: string;
driverId: string;
category: "access" | "reader" | "camera" | "printer";
/** Role/direction token for the footer label (NOT the vendor) — the client
* localises it next to the category, e.g. "Lexuesi hyrje", "Printer kabina". */
roleKind: "entry" | "exit" | "both" | "mixed" | "lane" | "booth" | null;
state: "ready" | "degraded" | "offline";
detail?: string;
checkedAt: string;
}
export function fetchDeviceStatus(): Promise<{ devices: DeviceStatus[] }> {
return apiFetch("/api/devices/status");
}
// --- Ledger events (the signed audit trail; read-only) -------------------- // --- Ledger events (the signed audit trail; read-only) --------------------
/** A persisted ledger row. Re-exported from shared so UI code has one source of /** A persisted ledger row. Re-exported from shared so UI code has one source of
+42 -11
View File
@@ -24,7 +24,7 @@ export const en: Catalog = {
shift: "Shift", shift: "Shift",
setup: "Setup", setup: "Setup",
tariff: "Tariff", tariff: "Tariff",
permits: "Permits", subscriptions: "Subscriptions",
site: "Site", site: "Site",
}, },
status: { status: {
@@ -32,6 +32,33 @@ export const en: Catalog = {
connecting: "CONNECTING", connecting: "CONNECTING",
offline: "OFFLINE", offline: "OFFLINE",
}, },
devices: {
footerTitle: "Devices",
none: "No devices configured.",
catAccess: "Barrier",
catReader: "Reader",
catCamera: "Camera",
catPrinter: "Printer",
// Role/direction suffixes for the chip label (e.g. "Reader entry").
role: {
entry: "entry",
exit: "exit",
both: "entry/exit",
mixed: "mixed",
lane: "lane",
booth: "booth",
},
state: {
ready: "ready",
degraded: "degraded",
offline: "offline",
},
allOk: "all ready",
issuesCount: "{{count}} with issues",
issuesTitle: "Device issues",
clickForIssues: "Click for details",
checkedAt: "checked {{time}}",
},
booth: { booth: {
processTicket: "Process ticket", processTicket: "Process ticket",
scanPlaceholder: "Scan or type ticket number…", scanPlaceholder: "Scan or type ticket number…",
@@ -92,21 +119,25 @@ export const en: Catalog = {
publishing: "Publishing…", publishing: "Publishing…",
publishedOk: "New tariff version published — it's now the active rate card.", publishedOk: "New tariff version published — it's now the active rate card.",
}, },
permits: { subs: {
title: "Permits", title: "Subscriptions",
unnamed: "(unnamed)", unnamed: "(unnamed)",
unbound: "unbound", unbound: "unbound",
car_one: "{{count}} car", car_one: "{{count}} car",
car_other: "{{count}} cars", car_other: "{{count}} cars",
cred: "cred", cred: "cred",
plates: "{{count}} plate(s)", plates: "{{count}} plate(s)",
noPrice: "no price",
perMonth: "month",
monthlyPrice: "Monthly price",
pricePlaceholder: "e.g. 10000",
edit: "Edit", edit: "Edit",
revoke: "Revoke", revoke: "Revoke",
delete: "Delete", delete: "Delete",
noPermitsYet: "No permits yet.", noneYet: "No subscriptions yet.",
addPermit: "+ Add permit", add: "+ Add subscription",
newPermit: "New permit", new: "New subscription",
editPermit: "Edit permit", editTitle: "Edit subscription",
holderName: "Holder name", holderName: "Holder name",
contact: "Contact", contact: "Contact",
carLimit: "Car limit", carLimit: "Car limit",
@@ -121,12 +152,12 @@ export const en: Catalog = {
qr: "QR", qr: "QR",
credentialValue: "credential value", credentialValue: "credential value",
addCredential: "+ credential", addCredential: "+ credential",
needCredentialOrPlate: "A permit needs at least one credential OR one bound plate.", needCredentialOrPlate: "A subscription needs at least one credential OR one bound plate.",
save: "Save", save: "Save",
cancel: "Cancel", cancel: "Cancel",
permitSaved: "Permit saved.", saved: "Subscription saved.",
confirmRevoke: "Revoke permit for {{name}}? It will be refused at the barrier.", confirmRevoke: "Revoke subscription for {{name}}? It will be refused at the barrier.",
confirmDelete: "Delete permit for {{name}}? (Past events are kept.)", confirmDelete: "Delete subscription for {{name}}? (Past events are kept.)",
statusActive: "active", statusActive: "active",
statusSuspended: "suspended", statusSuspended: "suspended",
statusRevoked: "revoked", statusRevoked: "revoked",
+46 -15
View File
@@ -24,7 +24,7 @@ export const sq = {
shift: "Turni", shift: "Turni",
setup: "Konfigurimi", setup: "Konfigurimi",
tariff: "Tarifa", tariff: "Tarifa",
permits: "Lejet", subscriptions: "Abonimet",
site: "Vendi", site: "Vendi",
}, },
status: { status: {
@@ -32,16 +32,43 @@ export const sq = {
connecting: "DUKE U LIDHUR", connecting: "DUKE U LIDHUR",
offline: "JASHTË LINJE", offline: "JASHTË LINJE",
}, },
devices: {
footerTitle: "Pajisjet",
none: "Asnjë pajisje e konfiguruar.",
catAccess: "Barriera",
catReader: "Lexuesi",
catCamera: "Kamera",
catPrinter: "Printer",
// Role/direction suffixes for the chip label (e.g. "Lexuesi hyrje").
role: {
entry: "hyrje",
exit: "dalje",
both: "hyrje/dalje",
mixed: "i përzier",
lane: "korsia",
booth: "kabina",
},
state: {
ready: "gati",
degraded: "i dëmtuar",
offline: "jashtë linje",
},
allOk: "të gjitha gati",
issuesCount: "{{count}} me probleme",
issuesTitle: "Problemet e pajisjeve",
clickForIssues: "Kliko për detajet",
checkedAt: "kontrolluar {{time}}",
},
booth: { booth: {
processTicket: "Proceso biletën", processTicket: "Proceso biletën",
scanPlaceholder: "Skano ose shkruaj numrin e biletës…", scanPlaceholder: "Skano ose shkruaj numrin e biletës…",
open: "Hap", open: "Hap",
occupancy: "Zënia", occupancy: "Prania",
occUnavailable: "zënia e padisponueshme", occUnavailable: "zënia e padisponueshme",
inside: "brenda", inside: "brenda",
of: "nga", of: "nga",
uncapped: "pa kufi", uncapped: "pa kufi",
free: "lirë", free: "Vende të lira",
lotFull: "● parkimi plot", lotFull: "● parkimi plot",
liveFeed: "Aktiviteti live", liveFeed: "Aktiviteti live",
events: "ngjarje", events: "ngjarje",
@@ -94,21 +121,25 @@ export const sq = {
publishing: "Duke publikuar…", publishing: "Duke publikuar…",
publishedOk: "U publikua versioni i ri i tarifës — tani është karta tarifore aktive.", publishedOk: "U publikua versioni i ri i tarifës — tani është karta tarifore aktive.",
}, },
permits: { subs: {
title: "Lejet", title: "Abonimet",
unnamed: "(pa emër)", unnamed: "(pa emër)",
unbound: "pa kufizim", unbound: "pa kufizim",
car_one: "{{count}} makinë", car_one: "{{count}} makinë",
car_other: "{{count}} makina", car_other: "{{count}} makina",
cred: "kredencial", cred: "kredencial",
plates: "{{count}} targë(a)", plates: "{{count}} targë(a)",
noPrice: "pa çmim",
perMonth: "muaj",
monthlyPrice: "Çmimi mujor",
pricePlaceholder: "p.sh. 10000",
edit: "Ndrysho", edit: "Ndrysho",
revoke: "Anulo", revoke: "Anulo",
delete: "Fshij", delete: "Fshij",
noPermitsYet: "Asnjë leje ende.", noneYet: "Asnjë abonim ende.",
addPermit: "+ Shto leje", add: "+ Shto abonim",
newPermit: "Leje e re", new: "Abonim i ri",
editPermit: "Ndrysho lejen", editTitle: "Ndrysho abonimin",
holderName: "Emri i mbajtësit", holderName: "Emri i mbajtësit",
contact: "Kontakti", contact: "Kontakti",
carLimit: "Kufiri i makinave", carLimit: "Kufiri i makinave",
@@ -123,18 +154,18 @@ export const sq = {
qr: "QR", qr: "QR",
credentialValue: "vlera e kredencialit", credentialValue: "vlera e kredencialit",
addCredential: "+ kredencial", addCredential: "+ kredencial",
needCredentialOrPlate: "Një leje kërkon të paktën një kredencial OSE një targë të lidhur.", needCredentialOrPlate: "Një abonim kërkon të paktën një kredencial OSE një targë të lidhur.",
save: "Ruaj", save: "Ruaj",
cancel: "Anulo", cancel: "Anulo",
permitSaved: "Leja u ruajt.", saved: "Abonimi u ruajt.",
confirmRevoke: "Të anulohet leja për {{name}}? Do të refuzohet te barriera.", confirmRevoke: "Të anulohet abonimi për {{name}}? Do të refuzohet te barriera.",
confirmDelete: "Të fshihet leja për {{name}}? (Ngjarjet e kaluara ruhen.)", confirmDelete: "Të fshihet abonimi për {{name}}? (Ngjarjet e kaluara ruhen.)",
statusActive: "aktive", statusActive: "aktiv",
statusSuspended: "pezulluar", statusSuspended: "pezulluar",
statusRevoked: "anuluar", statusRevoked: "anuluar",
}, },
site: { site: {
occupancy: "Zënia:", occupancy: "Prania:",
noCapacitySet: "(pa kapacitet të caktuar)", noCapacitySet: "(pa kapacitet të caktuar)",
free: "lirë", free: "lirë",
full: "PLOT", full: "PLOT",
+9 -6
View File
@@ -15,11 +15,12 @@ import { qk, queryClient } from "./lib/query.js";
import { setLanguage } from "./lib/i18n/index.js"; import { setLanguage } from "./lib/i18n/index.js";
import { useLiveFeed } from "./lib/use-live-feed.js"; import { useLiveFeed } from "./lib/use-live-feed.js";
import { useShift } from "./lib/use-shift.js"; import { useShift } from "./lib/use-shift.js";
import { DeviceFooter } from "./ui/DeviceFooter.js";
import { StatusDot } from "./ui/StatusDot.js"; import { StatusDot } from "./ui/StatusDot.js";
import { BoothScreen } from "./BoothScreen.js"; import { BoothScreen } from "./BoothScreen.js";
import { SetupWizard } from "./SetupWizard.js"; import { SetupWizard } from "./SetupWizard.js";
import { TariffComposer } from "./TariffComposer.js"; import { TariffComposer } from "./TariffComposer.js";
import { PermitManager } from "./PermitManager.js"; import { SubscriptionManager } from "./SubscriptionManager.js";
import { ShiftControl } from "./ShiftControl.js"; import { ShiftControl } from "./ShiftControl.js";
import { SiteSettings } from "./SiteSettings.js"; import { SiteSettings } from "./SiteSettings.js";
@@ -164,7 +165,7 @@ function RootLayout() {
<NavLink to="/shift" label={t("nav.shift")} /> <NavLink to="/shift" label={t("nav.shift")} />
{isAdmin && <NavLink to="/setup" label={t("nav.setup")} />} {isAdmin && <NavLink to="/setup" label={t("nav.setup")} />}
{isAdmin && <NavLink to="/tariff" label={t("nav.tariff")} />} {isAdmin && <NavLink to="/tariff" label={t("nav.tariff")} />}
{isAdmin && <NavLink to="/permits" label={t("nav.permits")} />} {isAdmin && <NavLink to="/subscriptions" label={t("nav.subscriptions")} />}
{isAdmin && <NavLink to="/site" label={t("nav.site")} />} {isAdmin && <NavLink to="/site" label={t("nav.site")} />}
</nav> </nav>
<div className="ml-auto flex items-center gap-3"> <div className="ml-auto flex items-center gap-3">
@@ -189,6 +190,8 @@ function RootLayout() {
<main className="min-h-0 flex-1 overflow-auto p-3"> <main className="min-h-0 flex-1 overflow-auto p-3">
<Outlet /> <Outlet />
</main> </main>
{/* Fixed device-status footer — relays, readers, cameras, printers. */}
{user && <DeviceFooter />}
</div> </div>
); );
} }
@@ -233,11 +236,11 @@ const tariffRoute = createRoute({
beforeLoad: ({ context }) => adminOnly(context), beforeLoad: ({ context }) => adminOnly(context),
component: () => <TariffComposer />, component: () => <TariffComposer />,
}); });
const permitsRoute = createRoute({ const subscriptionsRoute = createRoute({
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
path: "/permits", path: "/subscriptions",
beforeLoad: ({ context }) => adminOnly(context), beforeLoad: ({ context }) => adminOnly(context),
component: () => <PermitManager />, component: () => <SubscriptionManager />,
}); });
const siteRoute = createRoute({ const siteRoute = createRoute({
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
@@ -252,7 +255,7 @@ const routeTree = rootRoute.addChildren([
shiftRoute, shiftRoute,
setupRoute, setupRoute,
tariffRoute, tariffRoute,
permitsRoute, subscriptionsRoute,
siteRoute, siteRoute,
]); ]);
@@ -0,0 +1,25 @@
-- Rename permit → subscription (master data only). The signed ledger keeps its
-- immutable `permitId` payload — NOT touched here. Data-preserving ALTER RENAMEs
-- (SQLite 3.25+) rather than drop/recreate, so existing subscriptions survive.
-- Adds per-subscription pricing (price_minor + period + currency) and a site-wide
-- default monthly price. See wiki/entities/subscription.md.
ALTER TABLE `permits` RENAME TO `subscriptions`;
--> statement-breakpoint
ALTER TABLE `permit_credentials` RENAME TO `subscription_credentials`;
--> statement-breakpoint
ALTER TABLE `subscription_credentials` RENAME COLUMN `permit_id` TO `subscription_id`;
--> statement-breakpoint
ALTER TABLE `permit_plates` RENAME TO `subscription_plates`;
--> statement-breakpoint
ALTER TABLE `subscription_plates` RENAME COLUMN `permit_id` TO `subscription_id`;
--> statement-breakpoint
ALTER TABLE `subscriptions` ADD `price_minor` integer;
--> statement-breakpoint
ALTER TABLE `subscriptions` ADD `period` text DEFAULT 'monthly' NOT NULL;
--> statement-breakpoint
ALTER TABLE `subscriptions` ADD `currency` text;
--> statement-breakpoint
ALTER TABLE `sessions` RENAME COLUMN `permit_id` TO `subscription_id`;
--> statement-breakpoint
ALTER TABLE `site_config` ADD `subscription_monthly_price_minor` integer;
+7
View File
@@ -29,6 +29,13 @@
"when": 1781774228086, "when": 1781774228086,
"tag": "0003_early_hawkeye", "tag": "0003_early_hawkeye",
"breakpoints": true "breakpoints": true
},
{
"idx": 4,
"version": "6",
"when": 1781800000000,
"tag": "0004_subscriptions_rename",
"breakpoints": true
} }
] ]
} }
+38 -21
View File
@@ -9,7 +9,7 @@ import { blob, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
// tariffs and occupancy are PROJECTIONS over it. See append-only-event-chain.md. // tariffs and occupancy are PROJECTIONS over it. See append-only-event-chain.md.
// • `device_events` — UNSIGNED operational telemetry (relay/printer/camera/reader/input). // • `device_events` — UNSIGNED operational telemetry (relay/printer/camera/reader/input).
// High-volume, prunable, never reconciled. See wiki/concepts/device-events.md. // High-volume, prunable, never reconciled. See wiki/concepts/device-events.md.
// - Business master data (tariffs/permits/blocklist) IS mutable, but its USE is fixed in a // - Business master data (tariffs/subscriptions/blocklist) IS mutable, but its USE is fixed in a
// signed ledger event, so the audit trail stays append-only. Tariffs are versioned: // signed ledger event, so the audit trail stays append-only. Tariffs are versioned:
// editing publishes a new immutable tariff_version. See wiki/concepts/tariff.md. // editing publishes a new immutable tariff_version. See wiki/concepts/tariff.md.
// - `users` holds bcrypt hashes + a role; auth is fully local (offline-first). // - `users` holds bcrypt hashes + a role; auth is fully local (offline-first).
@@ -93,7 +93,7 @@ export const snapshots = sqliteTable("snapshots", {
direction: text("direction", { enum: ["entry", "exit"] }).notNull(), direction: text("direction", { enum: ["entry", "exit"] }).notNull(),
// The camera `devices` instance that captured it (raw provenance). // The camera `devices` instance that captured it (raw provenance).
deviceId: text("device_id"), deviceId: text("device_id"),
// The session/credential ref (ticket id, plate, permit) — links to the ledger event. // The session/credential ref (ticket id, plate, subscription) — links to the ledger event.
identity: text("identity"), identity: text("identity"),
contentType: text("content_type").notNull(), contentType: text("content_type").notNull(),
bytes: blob("bytes").notNull().$type<Buffer>(), bytes: blob("bytes").notNull().$type<Buffer>(),
@@ -166,6 +166,11 @@ export const siteConfig = sqliteTable("site_config", {
exitVoucherDefault: integer("exit_voucher_default", { mode: "boolean" }) exitVoucherDefault: integer("exit_voucher_default", { mode: "boolean" })
.notNull() .notNull()
.default(false), .default(false),
/** Default monthly subscription price in minor units (e.g. 1000000 = 10,000.00).
* A starting value the subscription form pre-fills; each subscription stores its
* own price and may differ. null = no site default set. See
* wiki/entities/subscription.md. */
subscriptionMonthlyPriceMinor: integer("subscription_monthly_price_minor"),
updatedAt: text("updated_at") updatedAt: text("updated_at")
.notNull() .notNull()
.default(sql`(current_timestamp)`), .default(sql`(current_timestamp)`),
@@ -203,16 +208,28 @@ export const tariffVersions = sqliteTable("tariff_versions", {
.default(sql`(current_timestamp)`), .default(sql`(current_timestamp)`),
}); });
// --- Permits (subscriptions) --------------------------------------------- // --- Subscriptions --------------------------------------------------------
// Mutable master data; every USE produces a signed vehicle_entry/exit ledger event. // A subscriber: a known holder who parks on a recurring plan (e.g. 10,000 ALL /
// Two optional, independent bindings: car-count (maxConcurrent, default 1, null = // month) instead of paying per stay. Mutable master data; every USE still produces a
// unbound) and plate (plates rows, default none = any car). Identity = card/QR OR a // signed vehicle_entry/exit ledger event. Two optional, independent bindings:
// matching plate. Credentials and cars are child rows. See wiki/entities/permit.md. // car-count (maxConcurrent, default 1, null = unbound) and plate (plates rows,
export const permits = sqliteTable("permits", { // default none = any car). Identity = card/QR OR a matching plate (LPR/ANPR future).
// Pricing: priceMinor + period + currency record the plan; collecting the fee into
// the ledger/shift is deferred. See wiki/entities/subscription.md.
// NB: signed ledger events still carry `permitId` in their payload — immutable
// history, intentionally NOT renamed. These tables are the mutable master data,
// renamed permit→subscription in migration 0004.
export const subscriptions = sqliteTable("subscriptions", {
id: text("id").primaryKey(), id: text("id").primaryKey(),
holderName: text("holder_name"), holderName: text("holder_name"),
contact: text("contact"), contact: text("contact"),
// Car-count binding: how many of the permit's cars may be inside at once. // Recurring price for the plan, in minor units (e.g. 1000000 = 10,000.00 ALL).
// null = no price set (comp/legacy). The `period` says what it recurs over.
priceMinor: integer("price_minor"),
period: text("period", { enum: ["monthly"] }).notNull().default("monthly"),
// ISO-4217 currency of priceMinor (e.g. "ALL"). null when no price set.
currency: text("currency"),
// Car-count binding: how many of the subscription's cars may be inside at once.
// null = unbound. Default 1. // null = unbound. Default 1.
maxConcurrent: integer("max_concurrent").default(1), maxConcurrent: integer("max_concurrent").default(1),
validFrom: text("valid_from"), validFrom: text("valid_from"),
@@ -225,19 +242,19 @@ export const permits = sqliteTable("permits", {
.default(sql`(current_timestamp)`), .default(sql`(current_timestamp)`),
}); });
// A permit's credentials (RF tag/chip/card, or QR). Either opens the lane. // A subscription's credentials (RF tag/chip/card, or QR). Either opens the barrier.
export const permitCredentials = sqliteTable("permit_credentials", { export const subscriptionCredentials = sqliteTable("subscription_credentials", {
id: text("id").primaryKey(), id: text("id").primaryKey(),
permitId: text("permit_id").notNull(), subscriptionId: text("subscription_id").notNull(),
kind: text("kind", { enum: ["rf", "qr"] }).notNull(), kind: text("kind", { enum: ["rf", "qr"] }).notNull(),
value: text("value").notNull(), value: text("value").notNull(),
}); });
// Plate binding (optional). When a permit has plate rows, a matching plate read is // Plate binding (optional). When a subscription has plate rows, a matching plate read
// itself an accepted identity (card/QR OR plate). Empty = not plate-bound (any car). // is itself an accepted identity (card/QR OR plate). Empty = not plate-bound (any car).
export const permitPlates = sqliteTable("permit_plates", { export const subscriptionPlates = sqliteTable("subscription_plates", {
id: text("id").primaryKey(), id: text("id").primaryKey(),
permitId: text("permit_id").notNull(), subscriptionId: text("subscription_id").notNull(),
plate: text("plate").notNull(), plate: text("plate").notNull(),
}); });
@@ -266,8 +283,8 @@ export const sessions = sqliteTable("sessions", {
// Identity that opened the session, and how it was read. // Identity that opened the session, and how it was read.
identity: text("identity"), identity: text("identity"),
source: text("source"), source: text("source"),
// null while transient; set when matched to a permit. // null while transient; set when matched to a subscription.
permitId: text("permit_id"), subscriptionId: text("subscription_id"),
enteredAt: text("entered_at").notNull(), enteredAt: text("entered_at").notNull(),
// null until exit; presence = CLOSED. // null until exit; presence = CLOSED.
exitedAt: text("exited_at"), exitedAt: text("exited_at"),
@@ -288,8 +305,8 @@ export type SetupStateRow = typeof setupState.$inferSelect;
export type SiteConfigRow = typeof siteConfig.$inferSelect; export type SiteConfigRow = typeof siteConfig.$inferSelect;
export type TariffRow = typeof tariffs.$inferSelect; export type TariffRow = typeof tariffs.$inferSelect;
export type TariffVersionRow = typeof tariffVersions.$inferSelect; export type TariffVersionRow = typeof tariffVersions.$inferSelect;
export type PermitRow = typeof permits.$inferSelect; export type SubscriptionRow = typeof subscriptions.$inferSelect;
export type PermitCredentialRow = typeof permitCredentials.$inferSelect; export type SubscriptionCredentialRow = typeof subscriptionCredentials.$inferSelect;
export type PermitPlateRow = typeof permitPlates.$inferSelect; export type SubscriptionPlateRow = typeof subscriptionPlates.$inferSelect;
export type BlocklistRow = typeof blocklist.$inferSelect; export type BlocklistRow = typeof blocklist.$inferSelect;
export type SessionRow = typeof sessions.$inferSelect; export type SessionRow = typeof sessions.$inferSelect;
+3 -3
View File
@@ -14,7 +14,7 @@ session projection.
## The rule ## The rule
An identity (ticket id, [[permit]] credential, or plate) **must not enter while it already has an An identity (ticket id, [[subscription]] credential, or plate) **must not enter while it already has an
OPEN [[parking-session|session]].** At entry: OPEN [[parking-session|session]].** At entry:
``` ```
@@ -25,13 +25,13 @@ identify vehicle → is there already an OPEN session for this id?
This is a **fold over the signed [[append-only-event-chain]]** ("does an entry for this id exist This is a **fold over the signed [[append-only-event-chain]]** ("does an entry for this id exist
with no matching exit?") — not a mutable in/out flag that could be edited. Same projection that with no matching exit?") — not a mutable in/out flag that could be edited. Same projection that
powers [[capacity-occupancy]] and [[permit]] `maxConcurrent`. powers [[capacity-occupancy]] and [[subscription]] `maxConcurrent`.
## Interaction with the limits already designed ## Interaction with the limits already designed
- **Transient ticket** — a single ticket id is inherently one session; a second entry on the same - **Transient ticket** — a single ticket id is inherently one session; a second entry on the same
id is always a violation (or a re-print/duplication attempt). id is always a violation (or a re-print/duplication attempt).
- **Permit** — passback is the *per-car* case of the permit's `maxConcurrent` ([[permit]]): a - **Permit** — passback is the *per-car* case of the permit's `maxConcurrent` ([[subscription]]): a
multi-car permit legitimately has several open sessions, but **the same car/credential** entering multi-car permit legitimately has several open sessions, but **the same car/credential** entering
twice is still a violation. So enforce per-identity, *under* the permit's concurrency allowance. twice is still a violation. So enforce per-identity, *under* the permit's concurrency allowance.
+1 -1
View File
@@ -18,7 +18,7 @@ editable and drifts; the chain is the truth). Spaces-free = `capacity − occupa
- **`capacity`** is admin-set per site (and per **zone/level** if the lot has sections — model a - **`capacity`** is admin-set per site (and per **zone/level** if the lot has sections — model a
`zone` on capacity + on the entry so multi-level is a later addition, not a rewrite). `zone` on capacity + on the entry so multi-level is a later addition, not a rewrite).
- Permit concurrency (`maxConcurrent`, see [[permit]]) is the same kind of fold, scoped to one - Permit concurrency (`maxConcurrent`, see [[subscription]]) is the same kind of fold, scoped to one
permit's open sessions. permit's open sessions.
## Full → refuse entry + FULL sign ## Full → refuse entry + FULL sign
+3 -3
View File
@@ -43,7 +43,7 @@ A session needs a key that survives from entry to exit. Two populations, two key
- **Transient:** a **ticket id** (printed, ideally on pre-numbered stock — see [[reconciliation]]) - **Transient:** a **ticket id** (printed, ideally on pre-numbered stock — see [[reconciliation]])
or a **plate** read by [[lpr-camera|LPR]]. This id is carried in the event's `identity` field. or a **plate** read by [[lpr-camera|LPR]]. This id is carried in the event's `identity` field.
- **Permit holder:** a **credential** (card / plate / QR) matched to a [[permit]] record. A valid - **Permit holder:** a **credential** (card / plate / QR) matched to a [[subscription]] record. A valid
permit means the session owes nothing — the PAY step is skipped (see below). permit means the session owes nothing — the PAY step is skipped (see below).
## Lifecycle (pay-on-foot / pay station model) ## Lifecycle (pay-on-foot / pay station model)
@@ -71,7 +71,7 @@ States, as derived from events:
| **CLOSED** | a matching `vehicle_exit` event exists | | **CLOSED** | a matching `vehicle_exit` event exists |
| **VOIDED** | a `void` event references the session (lost ticket written off, error correction) | | **VOIDED** | a `void` event references the session (lost ticket written off, error correction) |
Permit sessions skip PAID: a valid [[permit]] at exit is itself the authorization to close. Permit sessions skip PAID: a valid [[subscription]] at exit is itself the authorization to close.
## Edge cases the model must name (not yet designed in full) ## Edge cases the model must name (not yet designed in full)
@@ -106,7 +106,7 @@ follow this page and [[tariff]]; the decision is recorded in [[session-model]].
fails. See [[device-input-flow]]. fails. See [[device-input-flow]].
- **Read dispatch** (`apps/server/src/read-dispatch.ts`): a credential read routes to the - **Read dispatch** (`apps/server/src/read-dispatch.ts`): a credential read routes to the
**permit flow** if it matches a permit (card/QR/bound plate), else to the transient **exit flow**. **permit flow** if it matches a permit (card/QR/bound plate), else to the transient **exit flow**.
Lane resolved once (`readerLaneWithAccess`). See [[permit]] as-built. Lane resolved once (`readerLaneWithAccess`). See [[subscription]] as-built.
- **Exit flow** (`apps/server/src/exit-flow.ts`): a credential **read** (the `read` bus channel) → - **Exit flow** (`apps/server/src/exit-flow.ts`): a credential **read** (the `read` bus channel) →
fold the signed ledger for that identity → validate **open + PAID + within `gracePeriodExitMin`** fold the signed ledger for that identity → validate **open + PAID + within `gracePeriodExitMin`**
→ signed `vehicle_exit` → `pulseOpen`. Unpaid / expired / unknown → signed `anomaly`, barrier → signed `vehicle_exit` → `pulseOpen`. Unpaid / expired / unknown → signed `anomaly`, barrier
+1 -1
View File
@@ -17,7 +17,7 @@ derived and rebuildable, never a separate ledger.
- **Revenue** — by day/week/shift, by tender (cash vs. card), gross vs. discounts vs. net. Source: - **Revenue** — by day/week/shift, by tender (cash vs. card), gross vs. discounts vs. net. Source:
`payment` events + [[validation-discounts|discount]] events + `shift_z_report` ([[shift]]). `payment` events + [[validation-discounts|discount]] events + `shift_z_report` ([[shift]]).
- **Occupancy** — current ([[capacity-occupancy]]) and historical curve; peak times; turnover. - **Occupancy** — current ([[capacity-occupancy]]) and historical curve; peak times; turnover.
- **Stay analytics** — average/median duration, distribution; transient vs. [[permit]] split. - **Stay analytics** — average/median duration, distribution; transient vs. [[subscription]] split.
- **Permit usage** — active permits, utilisation, concurrency vs. `maxConcurrent`. - **Permit usage** — active permits, utilisation, concurrency vs. `maxConcurrent`.
- **Anomalies** — out-of-band opens, never-exited sessions, occupancy drift, over-validation — - **Anomalies** — out-of-band opens, never-exited sessions, occupancy drift, over-validation —
the `anomaly` events + reconciliation findings ([[reconciliation]]). the `anomaly` events + reconciliation findings ([[reconciliation]]).
+3 -1
View File
@@ -63,7 +63,9 @@ login ————————————————————————
## What End Shift does ## What End Shift does
1. Determine the shift's payment set: the signed `payment` events ([[parking-session]], 1. Determine the shift's payment set: the signed `payment` events ([[parking-session]],
[[append-only-event-chain]]) between this shift's start mark and now. [[append-only-event-chain]]) between this shift's start mark and now. This includes a
**[[subscription]] fee** an operator collects during the shift (sold/renewed at the booth → a
signed `payment`, deferred build) — it folds into this set like any transient taking.
2. Sum by **tender**: `cashTotal`, and `cardTotal` from the POS/terminal **if a POS is configured** 2. Sum by **tender**: `cashTotal`, and `cardTotal` from the POS/terminal **if a POS is configured**
(the card line is omitted when there's no terminal). (the card line is omitted when there's no terminal).
3. Append a signed **`shift_z_report`** event (type already in `packages/shared`): `{ operator, 3. Append a signed **`shift_z_report`** event (type already in `packages/shared`): `{ operator,
+4 -4
View File
@@ -10,7 +10,7 @@ status: open
How a [[parking-session]]'s fee is computed from its duration. A tariff is **admin-composed data, How a [[parking-session]]'s fee is computed from its duration. A tariff is **admin-composed data,
not code** — the park owner builds and constantly edits the rate card at runtime (like a not code** — the park owner builds and constantly edits the rate card at runtime (like a
[[permit]]), in a selectable currency, with **no numbers hard-coded anywhere** and no code change to [[subscription]]), in a selectable currency, with **no numbers hard-coded anywhere** and no code change to
reprice. The computation is **pure and offline** ([[offline-first]]: no network, no clock authority reprice. The computation is **pure and offline** ([[offline-first]]: no network, no clock authority
beyond the host). beyond the host).
@@ -144,9 +144,9 @@ production.
## Permit holders ## Permit holders
A valid [[permit]] bypasses tariff computation entirely for the covered period (subscription A valid [[subscription]] bypasses tariff computation entirely for the covered period (subscription
already paid out-of-band). A permit that has lapsed mid-stay falls back to the transient tariff for already paid out-of-band). A permit that has lapsed mid-stay falls back to the transient tariff for
the uncovered time — an edge case to design with [[permit]]. the uncovered time — an edge case to design with [[subscription]].
## Versioning — edits publish immutable, effective-dated versions ## Versioning — edits publish immutable, effective-dated versions
@@ -198,7 +198,7 @@ Two operator asks extend this engine; both have design pages (not yet built), gr
slicing a stay at window boundaries while keeping the block ladder + daily cap continuous. slicing a stay at window boundaries while keeping the block ladder + daily cap continuous.
- **Validation & sponsorship** (merchant comps, coupons, **postpaid B2B** "enter/exit free, bill the - **Validation & sponsorship** (merchant comps, coupons, **postpaid B2B** "enter/exit free, bill the
business monthly") — see [[validation-sponsorship]]. A validation is a **typed modifier applied as a business monthly") — see [[validation-sponsorship]]. A validation is a **typed modifier applied as a
signed event** on a transient session, distinct from a [[permit]]; postpaid sponsors accrue a signed event** on a transient session, distinct from a [[subscription]]; postpaid sponsors accrue a
monthly-invoiced liability derivable from the chain. monthly-invoiced liability derivable from the chain.
## Open ## Open
+3 -3
View File
@@ -19,7 +19,7 @@ postpaid agreement whose customers enter and exit freely, billed to the business
## Why this is NOT a permit (the key distinction) ## Why this is NOT a permit (the key distinction)
| | [[permit]] | Validation / sponsorship | | | [[subscription]] | Validation / sponsorship |
| --- | --- | --- | | --- | --- | --- |
| Subject | Known in advance; carries a credential (card/QR/plate) | Anonymous walk-in; identified only by the **ticket they were issued** | | Subject | Known in advance; carries a credential (card/QR/plate) | Anonymous walk-in; identified only by the **ticket they were issued** |
| When applied | At entry (credential opens the lane) | **After entry**, against an existing session — at a pay station, by a code, or by a sponsor rule | | When applied | At entry (credential opens the lane) | **After entry**, against an existing session — at a pay station, by a code, or by a sponsor rule |
@@ -74,7 +74,7 @@ validations id, session_id, sponsor_id?, type, amount_minor|minutes,
- A **postpaid** sponsor: each full-comp validation appends a row and accrues `amount` to the - A **postpaid** sponsor: each full-comp validation appends a row and accrues `amount` to the
sponsor; monthly invoice = sum over the period; exit is free at the lane. sponsor; monthly invoice = sum over the period; exit is free at the lane.
- **Free entry/exit "freely"**: either the sponsor issues credentials (then it's closer to a - **Free entry/exit "freely"**: either the sponsor issues credentials (then it's closer to a
[[permit]] — pick that path), or customers take a normal ticket and a sponsor rule / merchant code [[subscription]] — pick that path), or customers take a normal ticket and a sponsor rule / merchant code
comps it at exit. The agreement wording decides which; **both are expressible.** comps it at exit. The agreement wording decides which; **both are expressible.**
## Reconciliation & settlement ## Reconciliation & settlement
@@ -83,7 +83,7 @@ validations id, session_id, sponsor_id?, type, amount_minor|minutes,
Statement lines trace to signed validation events → disputes resolvable against the chain. Statement lines trace to signed validation events → disputes resolvable against the chain.
## Open ## Open
- **"Enter/exit freely" mechanism**: sponsor-issued credentials ([[permit]]-like) vs. ticket + - **"Enter/exit freely" mechanism**: sponsor-issued credentials ([[subscription]]-like) vs. ticket +
comp-at-exit. Likely offer both; confirm the operator's actual deal shape. comp-at-exit. Likely offer both; confirm the operator's actual deal shape.
- Prepaid coupon format: printed codes (legacy) vs. QR vs. merchant web-validation portal. - Prepaid coupon format: printed codes (legacy) vs. QR vs. merchant web-validation portal.
- Who may apply a validation, and the **per-operator cap** (a comp is a fraud vector — bound it and - Who may apply a validation, and the **per-operator cap** (a comp is a fraud vector — bound it and
+2 -2
View File
@@ -19,7 +19,7 @@ The starting decision for the **business layer**, taken 2026-06-15 as the projec
events. A cache table is allowed for query speed but is always rebuildable and never events. A cache table is allowed for query speed but is always rebuildable and never
authoritative. authoritative.
2. **Transient-first, mixed site.** Model the casual pay-for-duration session + [[tariff]] first; 2. **Transient-first, mixed site.** Model the casual pay-for-duration session + [[tariff]] first;
layer [[permit]] holders on top as a second identity source that short-circuits payment layer [[subscription]] holders on top as a second identity source that short-circuits payment
([[entry-exit-readers]]). ([[entry-exit-readers]]).
3. **Pay-on-foot / pay station.** Payment is **decoupled from exit**: the customer pays at a 3. **Pay-on-foot / pay station.** Payment is **decoupled from exit**: the customer pays at a
central station; the exit lane only validates the session is paid and within the walk-back central station; the exit lane only validates the session is paid and within the walk-back
@@ -48,7 +48,7 @@ pay-station and exit-validation flows. Schema (`packages/db`) + shared types fol
- Rate card, currency, grace windows, caps — operator/procurement input ([[tariff]]). - Rate card, currency, grace windows, caps — operator/procurement input ([[tariff]]).
- Tariff versioning (effective-dated) for historical repricing. - Tariff versioning (effective-dated) for historical repricing.
- [[permit]] data model + lapsed-mid-stay handling. - [[subscription]] data model + lapsed-mid-stay handling.
- Wire payment capture to a concrete pay-station terminal ([[open-questions]] #3) — kept abstract - Wire payment capture to a concrete pay-station terminal ([[open-questions]] #3) — kept abstract
(payment = an independent signed event referencing a session) until procurement settles. (payment = an independent signed event referencing a session) until procurement settles.
- Reconciliation of sessions/payments against an external authority remains [[open-questions]] #4 - Reconciliation of sessions/payments against an external authority remains [[open-questions]] #4
+2 -2
View File
@@ -11,7 +11,7 @@ status: open
The project's **QR-code reader** (GEE NFC LIMITED). A static optical scanner for **QR / The project's **QR-code reader** (GEE NFC LIMITED). A static optical scanner for **QR /
DataMatrix / 1D barcode**, optional ID/IC card. This is the **[[ticket-encoding|QR ticket]] DataMatrix / 1D barcode**, optional ID/IC card. This is the **[[ticket-encoding|QR ticket]]
scanner** the design called for — read at the pay station and exit lane — and a path for **QR scanner** the design called for — read at the pay station and exit lane — and a path for **QR
[[permit]]** credentials. On hand: variant **`-Q-W`** (QR scanner; Wiegand/RS-232/RS-485). [[subscription]]** credentials. On hand: variant **`-Q-W`** (QR scanner; Wiegand/RS-232/RS-485).
(See [[gee-qr-er80|datasheet summary]] / `raw/`.) (See [[gee-qr-er80|datasheet summary]] / `raw/`.)
## What it is (and isn't) ## What it is (and isn't)
@@ -39,7 +39,7 @@ GET /qa/mcardsea.php?cardid=<QR>&mjihao=<devId>&cjihao=<devSN>&status=<2 chars>&
This is **host-in-the-loop and SYNCHRONOUS**: the GET *is* the access query and **our reply is the This is **host-in-the-loop and SYNCHRONOUS**: the GET *is* the access query and **our reply is the
decision** — it drives the reader's beep + output. So unlike a fire-and-forget reader, the endpoint decision** — it drives the reader's beep + output. So unlike a fire-and-forget reader, the endpoint
must decide (valid/invalid, direction from `status`) and reply, then also emit a `DeviceReadEvent` must decide (valid/invalid, direction from `status`) and reply, then also emit a `DeviceReadEvent`
on the `read` bus for the entry/exit/permit flows ([[parking-session]], [[permit]]) to open the on the `read` bus for the entry/exit/permit flows ([[parking-session]], [[subscription]]) to open the
barrier. ([[device-input-flow]] is the analogous push pattern; this one also returns a verdict.) barrier. ([[device-input-flow]] is the analogous push pattern; this one also returns a verdict.)
> **This explains the "no beep":** feedback comes from the server's JSON reply, not locally. A > **This explains the "no beep":** feedback comes from the server's JSON reply, not locally. A
+3 -3
View File
@@ -17,12 +17,12 @@ recognition **host-side on ordinary IP-camera snapshots**, replacing the dedicat
1. **Identity (ANPR).** snapshot → `{ plate, confidence, bbox }`. Feeds the existing 1. **Identity (ANPR).** snapshot → `{ plate, confidence, bbox }`. Feeds the existing
`IdentitySource = "lpr"` ([[parking-session]]): the plate is a session/identity key and the way `IdentitySource = "lpr"` ([[parking-session]]): the plate is a session/identity key and the way
a plate-bound [[permit]] is matched. a plate-bound [[subscription]] is matched.
2. **Verification (anti-fraud witness).** snapshot → vehicle attributes — at minimum 2. **Verification (anti-fraud witness).** snapshot → vehicle attributes — at minimum
`{ make?, model?, colour, bodyType }`, ideally a compact **visual fingerprint** (an embedding). `{ make?, model?, colour, bodyType }`, ideally a compact **visual fingerprint** (an embedding).
This is the answer to **plate-spoofing**: *a fraudster prints a registered/paid plate and drives This is the answer to **plate-spoofing**: *a fraudster prints a registered/paid plate and drives
in with a different car.* Plate-reading alone can't catch that; comparing the **vehicle** seen at in with a different car.* Plate-reading alone can't catch that; comparing the **vehicle** seen at
entry vs. exit (and vs. the [[permit]]'s known car) can. A plate that entered on a red hatchback entry vs. exit (and vs. the [[subscription]]'s known car) can. A plate that entered on a red hatchback
but exits on a black SUV is a **reconciliation anomaly** — exactly the independent-witness role but exits on a black SUV is a **reconciliation anomaly** — exactly the independent-witness role
the [[append-only-event-chain]] flags as the unbuilt gap. See [[reconciliation]]. the [[append-only-event-chain]] flags as the unbuilt gap. See [[reconciliation]].
@@ -64,7 +64,7 @@ guarantee is preserved. Recorded as an explicit exception in [[standing-decision
## Anti-fraud / threat-model fit ## Anti-fraud / threat-model fit
- **Plate spoofing** (the motivating case): vehicle-attribute / fingerprint mismatch entry↔exit or - **Plate spoofing** (the motivating case): vehicle-attribute / fingerprint mismatch entry↔exit or
vs. a [[permit]]'s registered car → anomaly. Doesn't *block* on its own (recognition is vs. a [[subscription]]'s registered car → anomaly. Doesn't *block* on its own (recognition is
probabilistic) — it **flags for [[reconciliation]]** and is captured in the signed record. probabilistic) — it **flags for [[reconciliation]]** and is captured in the signed record.
- The recognition result and the source image both attach to the signed [[append-only-event-chain]] - The recognition result and the source image both attach to the signed [[append-only-event-chain]]
entry, so the *evidence* is tamper-evident even though recognition itself is host-side and entry, so the *evidence* is tamper-evident even though recognition itself is host-side and
-149
View File
@@ -1,149 +0,0 @@
---
type: entity
tags: [parking, domain, business, subscriptions, identity]
sources: []
updated: 2026-06-15
status: open
---
# Permit (Subscription)
A **subscription**: a known holder authorized to enter/exit without paying per-stay, for a covered
period. The second of the "two populations" ([[entry-exit-readers]]); a valid permit
**short-circuits the payment step** of a [[parking-session]] ([[session-model]]). Transient is
built first; permits layer on top.
## Credentials (how a permit is presented) — confirmed with operator 2026-06-15
A permit is recognized by a credential read at the lane. Two kinds, mapping to the two identity
paths:
- **RF tag / chip / card.** An RFID/proximity credential. Read **host-side** (reader → host →
`pulseOpen`): autonomy isn't required (resolved below), and the [[dingtian-relay]] has no onboard
card list anyway, so there's no need to route RF into a controller. A Wiegand-out reader is still
fine and keeps a future autonomous path open ([[entry-exit-readers]]), but isn't required.
- **QR code.** Read by the **optical reader** — inherently **host-side** ([[entry-exit-readers]]:
pure optical/network readers are invisible to a controller). Host decodes the QR → looks up the
permit → decides.
Both feed the host as a reader event whose `source` is `wiegand` / `qr` (the `IdentitySource`
already in the model) and whose value is the credential id.
## Two optional, independent bindings — confirmed 2026-06-15
A permit has **two constraints the admin may or may not apply**, orthogonally. Either, both, or
neither — the four combinations are all valid.
### 1. Car-count binding (default: 1)
- **Optional.** By default a permit is bound to **1 car at a time**. The admin may raise the limit
(a household, a company fleet) or **unbind it entirely** (no cap on how many cars use it).
- The limit is on **cars inside at once** (`maxConcurrent`), enforced over the
[[parking-session]] projection: at entry, count the permit's currently-open sessions; if
`< maxConcurrent` (or unbound) allow, else reject (allowance full). This is exactly why
sessions-as-projection matters — "how many of this permit's cars are inside right now" is a fold
over open entry/exit events, **not a counter someone can edit**.
### 2. Plate binding (default: off)
- **Optional.** By default a permit is **not** plate-bound — any car may use it (identity is the
card/QR). The admin may bind it to a set of specific licence plates.
- When **bound**, an allowed plate is an **accepted identity in its own right** — a valid
**card/QR OR a matching plate** opens the lane (either, not a second factor):
```
entry: read card/QR → find permit → car-count ok → open
OR LPR plate ∈ permit's bound plates → find permit → car-count ok → open
```
- **Accepted tradeoff:** card-OR-plate is the most convenient but does **not** prevent
card-sharing (a lent card still opens). Fine for a trusted permit population; the signed
[[append-only-event-chain]] records exactly which credential/plate entered, so abuse is visible
to [[reconciliation]] after the fact.
- **Plate-spoofing defence:** a printed copy of a registered plate on a *different* car is caught
not here but by the [[opencv-anpr-service]]'s **vehicle-attribute verification** — the seen car
must reconcile with the permit's known car, not just the plate string.
> The two are independent: a plate-bound permit may have no car cap; a car-capped permit may accept
> any plate. The binding fields are simply absent/null when a constraint isn't applied.
## Data model (first cut — to firm up with [[session-model]])
A `permits` table (and supporting rows). Unlike the event log, reference/master data like permits
**is** mutable (an admin grants/revokes/renews) — but every *use* of a permit still produces a
signed `vehicle_entry`/`vehicle_exit` event in the [[append-only-event-chain]], so the audit trail
stays append-only even though the permit record itself is editable.
| Field | Notes |
| --- | --- |
| `id`, `holderName`/contact | the subscriber |
| `credentials[]` | one or more: `{ kind: 'rf' \| 'qr', value }` |
| `maxConcurrent` | car-count binding; **default 1**, raise for fleets, or `null` = unbound |
| `plates[]` | plate binding; **default empty/false** = any car; when set, these plates are accepted identities |
| `validFrom`, `validTo` | coverage window |
| `status` | active / suspended / revoked |
> Both bindings are nullable/empty by default — a bare permit is "1 car at a time, any plate,
> identified by its card/QR".
## Interaction with the session model
- **Entry:** credential read → permit lookup → valid (active, in window, plate allowed **if
plate-bound**, concurrent cars `< maxConcurrent` **if car-bound**) → signed `vehicle_entry`
(source = `wiegand`/`qr`/`lpr`), open barrier. No ticket, no fee. (A bare permit applies neither
extra check — just active + in window.)
- **Exit:** credential/plate read → matching open permit session → signed `vehicle_exit`, open. No
payment required.
- **Lapsed mid-stay:** permit expires while a car is parked → the uncovered time falls back to the
transient [[tariff]] (edge case to design).
- **Revoked:** a revoked permit fails the entry check → treated as transient (take a ticket) or
refused, per policy (OPEN).
## As-built (2026-06-15)
`apps/server/src/permit-flow.ts`, reached via the **read dispatcher**
(`read-dispatch.ts`): a credential read routes to the permit flow if it **matches a permit**
(card/QR credential, or a bound plate) — otherwise to the transient exit flow. So one read handler
serves both populations ([[entry-exit-readers]]), disambiguated by *what the credential is*.
- **Direction is inferred from session state for that car** — the read credential value is the
per-car session key. No open session for that car → **ENTRY** (check `maxConcurrent`, sign
`vehicle_entry`, open); an open session → **EXIT** (sign `vehicle_exit`, open, close). A fleet
permit thus has one session per car concurrently, and anti-passback falls out (a re-read of an
inside car is its exit, never a second entry).
- **`maxConcurrent`** is enforced as a **fold over the signed ledger** — count the permit's
`vehicle_entry` events whose car has no later exit; reject at the limit (`null` = unbound).
- **Validity** (active + within `validFrom`/`validTo`) and **plate-OR-card identity** as designed.
No ticket, no fee — the permit is the authorization; every use is still a signed ledger event
carrying `permitId`.
- Refusals (revoked / out-of-window / at-capacity) are signed `anomaly` events; the barrier stays
closed. Verified end to end (entry, inferred exit, fleet cap, plate-bound, revoked, dispatch).
**Admin CRUD** (`apps/server/src/routes/permits.ts` + `apps/web/src/PermitManager.tsx`): a permit is
an **aggregate** (the row + its credentials + bound plates); create/update treat it as one unit
(child sets are replaced on update). `GET /api/permits` (any signed-in role — for lookup),
`POST/PUT/DELETE /api/permits[/:id]` + `POST /api/permits/:id/revoke` (**admin only**). Validation:
`maxConcurrent` is a positive int or `null` (unbound); a permit must have **at least one credential
or one bound plate** (else nothing identifies it). Revoke is the soft, common case (keeps history,
barred at the barrier); DELETE hard-removes — past ledger events that reference the permit are
untouched (the audit trail is append-only and independent). Verified via inject (validation, child
replacement, RBAC, revoke/delete).
## Resolved (2026-06-15)
- **Two optional bindings, independent:** car-count (`maxConcurrent`, **default 1**, raisable or
unbound) and plate-binding (`plates[]`, **default off** = any car). Either, both, or neither.
- **Plate vs. credential:** when plate-bound, **card/QR OR matching plate** — either is accepted
identity (not a second factor); card-sharing not prevented by design, caught by
[[reconciliation]] after.
- **Autonomy:** **host-in-the-loop for everything** — no onboard card list needed, so the
[[dingtian-relay]] stays sufficient (no new controller). Permit entry **fails closed** if the
host is down ([[fail-state-safety]]). One code path for transient + permit.
## Open questions
1. **Reader hardware** — confirm the RF reader and the QR/optical reader models (procurement;
relates to [[bom]] and [[open-questions]]). RF need not be Wiegand now that autonomy isn't
required, but a Wiegand-out reader keeps options open.
2. **Lapsed-mid-stay & revoked** policy (fall back to transient [[tariff]] vs. refuse) — confirm
with operator.
+184
View File
@@ -0,0 +1,184 @@
---
type: entity
tags: [parking, domain, business, subscriptions, identity, pricing]
sources: []
updated: 2026-06-18
status: open
---
# Subscription
A **subscriber**: a known holder who parks on a **recurring plan** (e.g. **10,000 ALL / month**)
instead of paying per stay. The second of the "two populations" ([[entry-exit-readers]]); a valid
subscription **short-circuits the payment step** of a [[parking-session]] ([[session-model]]).
Transient is built first; subscriptions layer on top.
> **Renamed 2026-06-18 (was "Permit").** The operator term is **subscription / abonim**, not
> "permit / lejet". The master-data **tables/routes/UI/types were renamed** permit→subscription
> (migration `0004`). The **signed ledger keeps its `permitId` payload field** — that is immutable,
> hash-chained history, so renaming it would break verification of past events. So: *code & data =
> "subscription"; the on-chain field name stays `permitId`.* See the schema note in `schema.ts`.
## Pricing — recurring monthly plan (built 2026-06-18)
Each subscription records its **own price**, so an individual and a company fleet can differ:
- `priceMinor` — the recurring price in **minor units** (integer; e.g. `1000000` = 10,000.00).
`null` = no price set (a comp / legacy subscription).
- `period` — the billing period. **`"monthly"` only** today (the enum is widened later if a site
ever needs weekly/annual).
- `currency` — ISO-4217 of `priceMinor` (e.g. `"ALL"`); required when a price is set.
A **site default monthly price** lives in `site_config.subscription_monthly_price_minor` — it
merely **pre-fills** the new-subscription form; each subscription still stores its own value and may
override.
### Collecting the fee is a SHIFT transaction (decided 2026-06-18, deferred build)
Selling/renewing a subscription is a **financial transaction a common operator makes during their
[[shift]]** — the subscriber pays the monthly fee at the booth like any other customer. So it is
**not** an admin-only master-data edit; the money must land in **that operator's shift**: their
drawer (if cash) and their [[shift|Z-report]].
The clean way (the model already supports it): collection writes a signed **`payment`** ledger event
— same shape the transient pay-station uses (`{ amountMinor, currency, tender }`) — at collection
time, tagged with `{ subscriptionId }` so it's identifiable as subscription revenue.
- It folds into the shift automatically: the Z-report sums `payment` events in `[start, end]` **by
payment time**, and the drawer fold adds **cash** tenders (card settles to the bank) — no new
summing logic needed. The fee lands in **whichever shift was open when it was taken**, attributed
to that operator. (See [[shift]] "drawer balance".)
- **Admin** still edits the subscription master data (price, window, credentials); the **operator**
takes the money. Two different acts.
- A subscription's own [[parking-session|entry/exit]] events stay **free** (no per-stay `payment`) —
only the *plan fee* is a payment, decoupled from any individual stay.
> **Deferred build.** Today we only *record* the agreed price + coverage window
> (`validFrom`/`validTo`); no collection event is written yet, so subscription revenue does not flow
> into the drawer/Z-report or [[reconciliation]]. Open detail when built: whether to model it as a
> plain `payment` (simplest, folds today) or a distinct `subscription_payment` type (clearer in
> reports, but the shift/drawer fold would need to count it too). Leaning **plain `payment` +
> `subscriptionId` tag**. (Decision 2026-06-18: store price now, collect-in-shift later.)
## Credentials (how a subscription is presented) — confirmed 2026-06-15
Recognized by a credential read at the barrier. Two kinds, mapping to the two identity paths, and
**either can be combined with LPR/ANPR plate identity** (the plate binding below):
- **RF tag / chip / card.** An RFID/proximity credential, read **host-side** (reader → host →
`pulseOpen`). A Wiegand-out reader keeps a future autonomous path open ([[entry-exit-readers]]) but
isn't required (the [[dingtian-relay]] has no onboard card list).
- **QR code.** Read by the optical reader — inherently **host-side** ([[entry-exit-readers]]). Host
decodes the QR → looks up the subscription → decides. A subscription's QR can be **printed**.
- **Plate (LPR/ANPR) — NOT YET IMPLEMENTED.** When plate-bound (below), a matching plate read is an
accepted identity too. The vision/ANPR service that produces plate reads is future work
([[opencv-anpr-service]] / [[lpr-camera]]); until it exists, plate binding has no live source.
Both feed the host as a reader event whose `source` is `wiegand` / `qr` (the `IdentitySource`
already in the model) and whose value is the credential id.
## Two optional, independent bindings — confirmed 2026-06-15
A subscription has **two constraints the admin may or may not apply**, orthogonally. Either, both, or
neither.
### 1. Car-count binding (default: 1)
- **Optional.** By default bound to **1 car at a time**. The admin may raise the limit (a household, a
company fleet) or **unbind it entirely** (no cap).
- The limit is on **cars inside at once** (`maxConcurrent`), enforced over the [[parking-session]]
projection: at entry, count the subscription's currently-open sessions; if `< maxConcurrent` (or
unbound) allow, else reject. A fold over the signed ledger, **not a counter someone can edit**.
### 2. Plate binding (default: off)
- **Optional.** By default not plate-bound — any car may use it (identity is the card/QR). The admin
may bind it to a set of specific plates; a matching plate then **is an accepted identity**
(card/QR **OR** plate, not a second factor).
- **Accepted tradeoff:** card-OR-plate doesn't prevent card-sharing; the signed
[[append-only-event-chain]] records exactly which credential/plate entered, so abuse is visible to
[[reconciliation]]. Plate-spoofing (a printed plate on a different car) is caught by the
[[opencv-anpr-service]]'s vehicle-attribute verification, not here.
## Time-of-day access windows — DESIGN NOTE, NOT YET IMPLEMENTED (2026-06-18)
A subscription may be valid **only during certain hours of the day**, behaving as a normal transient
customer outside them. The motivating case: an **overnight subscriber** allowed in on their
subscription **19:00 → 07:00**, but charged the normal [[tariff]] if they park during the day.
Intended behaviour (to design + build later):
- The subscription carries one or more **recurring daily time windows** (e.g. `[{ from: "19:00",
to: "07:00", days: [...] }]`). Windows may **wrap past midnight** (19:00→07:00 spans two calendar
days) — the check must handle the wrap.
- **At ENTRY**, evaluate the window against the host clock ([[clock-integrity]]):
- **inside the window** → subscription entry (no ticket, no fee), exactly as today;
- **outside the window** → the car is treated as a **normal transient**: it takes a ticket and
pays the [[tariff]] on the way out. The subscription is simply *not used* for this stay.
- **The boundary cases need a decision** (flagged, not resolved):
- *Enters inside the window, exits outside it* (parks past 07:00): is the whole stay free
(entry-time decides), or is the over-window time charged transient (like
[[tariff|lapsed-mid-stay]])? Leaning **entry-time decides** for simplicity, but confirm.
- *Day-of-week scope* (weekdays vs. weekends), holidays.
- Interaction with `maxConcurrent` and plate binding (orthogonal — should still apply).
- **Data:** a child table (e.g. `subscription_windows`) or a JSON column on `subscriptions`; TBD with
the implementation. Legacy precedent exists — the ParkSQL2017 schema had
`MembershipPlansTime` / `ActiveDays` ([[parksql2017-legacy-schema]] §"time-/day-restricted
memberships"), confirming this is a real market need.
> **Explicitly postponed.** For now this is documentation only — no schema, no enforcement. A
> subscription is valid whenever it is active and within `validFrom`/`validTo`, all day.
## Data model (as-built 2026-06-18)
Tables (mutable master data; every *use* still produces a signed `vehicle_entry`/`vehicle_exit`):
| Table / field | Notes |
| --- | --- |
| `subscriptions.id`, `holderName`, `contact` | the subscriber |
| `subscriptions.priceMinor` / `period` / `currency` | recurring plan (monthly); null price = unset |
| `subscriptions.maxConcurrent` | car-count binding; **default 1**, raise for fleets, `null` = unbound |
| `subscriptions.validFrom` / `validTo` / `status` | coverage window; active / suspended / revoked |
| `subscription_credentials[]` | `{ kind: 'rf' \| 'qr', value }` |
| `subscription_plates[]` | bound plates (accepted identities when set) |
## Interaction with the session model
- **Entry:** credential read → subscription lookup → valid (active, in window, plate allowed **if
plate-bound**, concurrent cars `< maxConcurrent` **if car-bound**) → signed `vehicle_entry`
(`source = wiegand/qr/lpr`), open barrier. No ticket, no fee.
- **Exit:** credential/plate read → matching open subscription session → signed `vehicle_exit`, open.
- **Lapsed mid-stay:** subscription expires while parked → uncovered time falls back to the transient
[[tariff]] (edge case to design — and the same question the time-window boundary raises above).
- **Revoked:** a revoked subscription fails the entry check → treated as transient or refused (OPEN).
## As-built (2026-06-15, renamed + priced 2026-06-18)
`apps/server/src/subscription-flow.ts` (was `permit-flow.ts`), reached via the **read dispatcher**
(`read-dispatch.ts`): a credential read routes to the subscription flow if it **matches a
subscription** (card/QR credential, or a bound plate) — otherwise to the transient exit flow.
- **Direction inferred from session state for that car** — no open session → ENTRY (check
`maxConcurrent`, sign `vehicle_entry`, open); an open session → EXIT (sign `vehicle_exit`, open,
close). A fleet has one session per car; anti-passback falls out.
- **`maxConcurrent`** enforced as a fold over the signed ledger (the on-chain `permitId` payload is
the match key). Refusals (revoked / out-of-window / at-capacity) are signed `anomaly` events.
- **Admin CRUD** (`apps/server/src/routes/subscriptions.ts` + `apps/web/src/SubscriptionManager.tsx`):
a subscription is an **aggregate** (row + credentials + bound plates + price). `GET
/api/subscriptions` (any signed-in role — for lookup), `POST/PUT/DELETE /api/subscriptions[/:id]` +
`POST /api/subscriptions/:id/revoke` (**admin only**). Validation: `maxConcurrent` positive int or
`null`; `priceMinor` non-negative int (currency required when set); at least one credential or one
bound plate.
- **Pricing** stored on each subscription (`priceMinor`/`period`/`currency`), pre-filled from
`site_config.subscription_monthly_price_minor`; **fee collection into the ledger is deferred**
(see Pricing above).
## Open questions
1. **Reader hardware** — confirm the RF reader and QR/optical reader models (procurement; [[bom]],
[[open-questions]]).
2. **Lapsed-mid-stay & revoked** policy (fall back to transient [[tariff]] vs. refuse) — confirm.
3. **Subscription-fee collection** — a **shift transaction** (operator takes the monthly fee at the
booth → signed `payment` → folds into their drawer/Z-report). Deferred build; see Pricing.
4. **Time-of-day access windows** (overnight subscribers) — design + build; boundary-case policy
above (see the design note).
+5 -4
View File
@@ -1,13 +1,13 @@
--- ---
type: overview type: overview
tags: [parking, index] tags: [parking, index]
updated: 2026-06-14 updated: 2026-06-18
--- ---
# Index # Index
Content catalog for the wiki. Start at [[overview]]. Maintained on every ingest. Content catalog for the wiki. Start at [[overview]]. Maintained on every ingest.
Counts: 4 sources · 19 entities · 41 concepts · 5 decision records. Counts: 4 sources · 19 entities · 42 concepts · 5 decision records.
## Overview & navigation ## Overview & navigation
- [[overview]] — the top-level synthesis and entry point. - [[overview]] — the top-level synthesis and entry point.
@@ -64,6 +64,7 @@ Counts: 4 sources · 19 entities · 41 concepts · 5 decision records.
- [[barrier-not-a-door]] — never timed-close a barrier; safety lives in barrier firmware. - [[barrier-not-a-door]] — never timed-close a barrier; safety lives in barrier firmware.
- [[printer-roles-failover]] — ≥2 printers by role; entry ticket falls back outside→booth. - [[printer-roles-failover]] — ≥2 printers by role; entry ticket falls back outside→booth.
- [[printer-status-monitoring]] — live poll of paper/cover/cutter/offline via the device's status page; SSE to the booth UI. - [[printer-status-monitoring]] — live poll of paper/cover/cutter/offline via the device's status page; SSE to the booth UI.
- [[device-status-monitoring]] — unified live status across ALL device categories (healthCheck + printer readStatus) → the booth footer over /api/ws.
- [[trust-boundary]] — the core fork: network vs. device; auditable vs. unforgeable. - [[trust-boundary]] — the core fork: network vs. device; auditable vs. unforgeable.
- [[fail-state-safety]] — entry fails closed, exit fails open; manual override; watchdog. - [[fail-state-safety]] — entry fails closed, exit fails open; manual override; watchdog.
@@ -92,12 +93,12 @@ Counts: 4 sources · 19 entities · 41 concepts · 5 decision records.
- [[ticket-encoding]] — transient ticket id as QR; printed at entry, scanned at pay station + exit; plate-as-ticket alt. - [[ticket-encoding]] — transient ticket id as QR; printed at entry, scanned at pay station + exit; plate-as-ticket alt.
- [[anti-passback]] — block/flag one id entering twice without an exit; fold over open sessions. - [[anti-passback]] — block/flag one id entering twice without an exit; fold over open sessions.
- [[device-events]] — unsigned hardware telemetry (relay/printer/camera/reader/input); separate from the signed ledger. - [[device-events]] — unsigned hardware telemetry (relay/printer/camera/reader/input); separate from the signed ledger.
- [[permit]] — subscription; RF/QR or plate identity, registered-cars + max-concurrent, host-in-loop; short-circuits payment. - [[subscription]] — recurring plan (e.g. 10,000 ALL/month); RF/QR or plate identity, car-count + max-concurrent, host-in-loop; short-circuits payment. (Renamed from "permit"; time-of-day windows noted, deferred.)
- [[opencv-anpr-service]] — host-side vision microservice: ANPR (plate identity) + vehicle verification (anti-plate-spoofing witness). - [[opencv-anpr-service]] — host-side vision microservice: ANPR (plate identity) + vehicle verification (anti-plate-spoofing witness).
- [[blocklist]] — barred plates/cards refused at entry (never at exit); signed, attributed. - [[blocklist]] — barred plates/cards refused at entry (never at exit); signed, attributed.
## Concepts — frontend / operator UI ## Concepts — frontend / operator UI
- [[booth-console]] — operator-UI architecture: TanStack Query/Router + Zustand + Tailwind terminal theme; one /api/ws live feed (anti-CSWSH). - [[booth-console]] — operator-UI architecture: TanStack Query/Router + Zustand + Tailwind terminal theme; one /api/ws live feed (anti-CSWSH); shift control + device-status footer.
- [[i18n]] — Albanian default + English; per-user server-stored language preference (users.language), loaded on login; tickets stay Albanian. - [[i18n]] — Albanian default + English; per-user server-stored language preference (users.language), loaded on login; tickets stay Albanian.
## Dev environment (reference) ## Dev environment (reference)
+20
View File
@@ -800,3 +800,23 @@ Audited wiki vs. the session: three major builds (live WebSocket, frontend found
## [2026-06-18] ingest | Shift gating — site-wide single-open, booth money-path gate, per-shift logs ## [2026-06-18] ingest | Shift gating — site-wide single-open, booth money-path gate, per-shift logs
Built the shift-enforcement model. A shift is now **site-wide single-open** (was per-operator): `ShiftService.currentOpenShift()` reads the most recent shift event on the whole chain; `open()` refuses if ANY shift is open and throws `ShiftAlreadyOpenError{heldBy}`. Login stays decoupled from shifts (operator can log in off-shift to review). The booth money path is **gated**: `/api/pay`, `/api/exit`, `/api/voucher`, `/api/barrier/reopen` get a `requireShift` preHandler → 409 `{code:"no_shift"}`; read-only lookups stay open so the modal can display + prompt. `GET /api/shift/current` now returns the site-wide `{open:{startedAt,operator},isMine}`. Logs are **per-shift** via `GET /api/events?since=<shiftStart>`. UI: header shift button (open / close-mine / disabled-when-other), pay-modal gate banner with one-click open, gated Active-Sessions re-open, shift-scoped live feed; shared `useShift()` Query invalidated by the WS on shift/cash events. Updated [[shift]] (new "Site-wide single-open + booth gate" section; superseded the per-operator as-built note) and [[booth-console]] (header control + gate). Verified the invariant + chain integrity on a fresh migrated DB (11/11 assertions). Builds clean across db/server/web. Built the shift-enforcement model. A shift is now **site-wide single-open** (was per-operator): `ShiftService.currentOpenShift()` reads the most recent shift event on the whole chain; `open()` refuses if ANY shift is open and throws `ShiftAlreadyOpenError{heldBy}`. Login stays decoupled from shifts (operator can log in off-shift to review). The booth money path is **gated**: `/api/pay`, `/api/exit`, `/api/voucher`, `/api/barrier/reopen` get a `requireShift` preHandler → 409 `{code:"no_shift"}`; read-only lookups stay open so the modal can display + prompt. `GET /api/shift/current` now returns the site-wide `{open:{startedAt,operator},isMine}`. Logs are **per-shift** via `GET /api/events?since=<shiftStart>`. UI: header shift button (open / close-mine / disabled-when-other), pay-modal gate banner with one-click open, gated Active-Sessions re-open, shift-scoped live feed; shared `useShift()` Query invalidated by the WS on shift/cash events. Updated [[shift]] (new "Site-wide single-open + booth gate" section; superseded the per-operator as-built note) and [[booth-console]] (header control + gate). Verified the invariant + chain integrity on a fresh migrated DB (11/11 assertions). Builds clean across db/server/web.
## [2026-06-18] ingest | Device-status footer — unified monitor across all categories
Generalised printer-only status monitoring to a booth-wide DEVICE-STATUS FOOTER covering relays/readers/cameras/printers. New `DeviceMonitor` (`apps/server/src/device-monitor.ts`) polls every enabled device each tick (default 8s): printers via rich `readStatus()`, all others via the generic `healthCheck()` reachability probe, flattened to one traffic-light (ready/degraded/offline)+detail, deduped (emits on change only), fail-toward-offline (a throw/timeout → offline, never false-healthy). New `device-status` bus event + `GET /api/devices/status` snapshot; live updates ride the existing `/api/ws` (`hello` now carries the initial device set; `device-status` frame per change). Web: live-store `devices` map (setDevices/upsertDevice), WS handler wired, new `DeviceFooter` chip-per-device with an "all ready / N offline" roll-up, mounted in the app shell; `devices` i18n namespace (sq/en). The PrinterMonitor + its SSE stream stay as the printer-specific authority (both run — see the note in [[device-status-monitoring]]). Filed [[device-status-monitoring]] (resolves the code link), cross-linked [[printer-status-monitoring]] + [[booth-console]], indexed (concepts 41→42). Verified on a fresh DB (relay+reader → ready via healthCheck; unreachable printer → offline with detail, no throw; emit-once-then-silent) — 9/9; server+web build clean.
## [2026-06-18] refine | Device footer — role-only labels + click-to-see-issues
Two refinements to the device-status footer. (1) Chips label by ROLE, not vendor: the server sends a structured `roleKind` token per device (reader/camera → direction inherited from the bound relay via `directionOf()`; access → entry/exit/both, or "mixed" across relays; printer → lane/booth) and the client localises category+role → "Lexuesi hyrje", "Printer kabina", "Kamera dalje". Dropped `label`/`role`/driverId from the chip. (2) Fault detail no longer pollutes the footer: chips are compact (dot + label); a degraded/offline chip (or the "N with issues" roll-up) is clickable and opens a small issues panel above the footer listing only the problem devices with state/detail/checked-time (outside-click/Esc to close; no new dependency). i18n `devices.role.*` + issues keys (sq/en). Verified roleKind resolution on a fresh DB (access→mixed, reader(exit)→exit, camera(entry)→entry, printers→lane/booth) 7/7; server+web build clean. Updated [[device-status-monitoring]].
## [2026-06-18] fix | Stuck active session — paid ticket that never got a vehicle_exit (T-397815c0)
Investigated a paid ticket stuck forever in the Active Sessions tab. Root cause (confirmed from the live ledger): the car left via a **manual barrier re-open**, which by design signed an `anomaly` but **never a `vehicle_exit`** — so `activeSessions()` saw it as permanently `open` (the grace-expiry eviction only applied to *exited* sessions). The normal exit that would have signed the exit was refused because walk-back grace (5 min) had expired ~17h earlier. Two fixes: (1) `ExitFlow.reopenBarrier` now signs a `vehicle_exit` (`source:manual`) **when the session is still open**, closing it — while still NOT double-signing an already-exited session (phantom re-close). (2) `PayStation.activeSessions()` ages out a **paid** open session past grace even with no exit (unpaid open sessions never age out — a car owing money stays). Plus a one-off corrective: appended a signed `vehicle_exit` (index 68, `correction:true`) for T-397815c0 through EventLog (chain verified `{ok:true}`), clearing it from the list. Verified both fixes on a fresh DB (9/9; chain intact). Updated [[booth-exit-flow]] (active-session definition + the re-open rule, was "NEVER a vehicle_exit").
## [2026-06-18] ingest | Permit → Subscription rename + monthly pricing (timeframes deferred)
Renamed the "permit" feature to "subscription" (operator term: abonim) and added recurring monthly pricing. FULL rename of mutable master data: tables permits→subscriptions, permit_credentials→subscription_credentials, permit_plates→subscription_plates, sessions.permit_id→subscription_id (data-preserving ALTER RENAMEs, migration 0004); server permit-flow.ts→subscription-flow.ts (SubscriptionFlow), routes/permits.ts→routes/subscriptions.ts (/api/subscriptions), web PermitManager→SubscriptionManager, api types, i18n (sq "Abonimet"/en "Subscriptions"). The signed ledger `permitId` payload field is INTENTIONALLY kept (immutable hash-chained history — renaming would break verification of past events); code/data are "subscription", the on-chain field stays `permitId`. Pricing: per-subscription priceMinor + period("monthly") + currency, with a site default (site_config.subscription_monthly_price_minor) pre-filling the form; collecting the fee into the ledger/shift is DEFERRED (wiki note only). Time-of-day access windows (e.g. overnight subscriber 19:00–07:00, transient outside) documented as a design note in [[subscription]] — NOT implemented; legacy precedent in [[parksql2017-legacy-schema]] (MembershipPlansTime). Renamed [[entities/permit|permit]]→[[subscription]] and swept all [[permit]] wikilinks across the wiki (log.md historical entries left as-was). Verified end-to-end on a fresh migrated DB (schema+pricing, card entry/exit, maxConcurrent cap, on-chain permitId carries the sub id, chain verify) 6/6; migration also applied cleanly to a copy of the live DB (18 sessions preserved). Full monorepo builds clean.
## [2026-06-18] note | Subscription-fee collection is a SHIFT transaction
Clarified (user): collecting/renewing a subscription's monthly fee is a financial transaction a common operator makes DURING their shift — it must reflect in THAT shift's drawer + Z-report, not be an admin-only edit. Updated [[subscription]] (Pricing → "Collecting the fee is a SHIFT transaction"): model it as a signed `payment` event (same `{amountMinor,currency,tender}` shape) tagged `{subscriptionId}` at collection time, so it folds into the open shift automatically (Z-report sums payments by time; drawer adds cash tenders) with no new summing logic. Admin edits the master data; operator takes the money. Subscription entry/exit stay free — only the plan fee is a payment. Still DEFERRED build; cross-linked from [[shift]] ("What End Shift does"). Open: plain `payment`+tag vs. a distinct `subscription_payment` type (leaning plain).
+1 -1
View File
@@ -74,7 +74,7 @@ clear anti-patterns, e.g. money as `float`).
1. **Time-of-day + date windows on the rate card** (`ValidFromHour`/`ValidToHour`, 1. **Time-of-day + date windows on the rate card** (`ValidFromHour`/`ValidToHour`,
`ValidFrom`/`ValidTo`) — the shipped way to do **happy hour / seasonal**. See [[tariff-time-tiers]]. `ValidFrom`/`ValidTo`) — the shipped way to do **happy hour / seasonal**. See [[tariff-time-tiers]].
2. **Vehicle/customer category as a pricing axis** (`BA_TicketCategory`). See [[tariff-time-tiers]]. 2. **Vehicle/customer category as a pricing axis** (`BA_TicketCategory`). See [[tariff-time-tiers]].
3. **Time-/day-restricted memberships** (`MembershipPlansTime`, `ActiveDays`) — a [[permit]] gap. 3. **Time-/day-restricted memberships** (`MembershipPlansTime`, `ActiveDays`) — a [[subscription]] gap.
## Anti-patterns to NOT copy ## Anti-patterns to NOT copy
- **Money as `float`** everywhere (`Charge`, `Price`, `LostPenalty`) — drifts across a revenue - **Money as `float`** everywhere (`Charge`, `Price`, `LostPenalty`) — drifts across a revenue