From 5697137c5225c332c009d35ae5d4437b5ff6eb7e Mon Sep 17 00:00:00 2001 From: Julian Cuni Date: Thu, 18 Jun 2026 13:15:04 +0200 Subject: [PATCH] =?UTF-8?q?feat(subscription):=20rename=20permit=E2=86=92s?= =?UTF-8?q?ubscription=20+=20monthly=20pricing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/server/src/entry-flow.ts | 4 +- apps/server/src/read-dispatch.ts | 28 +- apps/server/src/routes/permits.ts | 160 ------------ apps/server/src/routes/site.ts | 22 +- apps/server/src/routes/subscriptions.ts | 191 ++++++++++++++ apps/server/src/server.ts | 30 ++- apps/server/src/snapshot.ts | 2 +- .../{permit-flow.ts => subscription-flow.ts} | 119 +++++---- apps/web/src/PermitManager.tsx | 191 -------------- apps/web/src/SubscriptionManager.tsx | 244 ++++++++++++++++++ apps/web/src/api.ts | 60 +++-- apps/web/src/lib/i18n/en.ts | 53 +++- apps/web/src/lib/i18n/sq.ts | 61 +++-- apps/web/src/router.tsx | 15 +- .../db/drizzle/0004_subscriptions_rename.sql | 25 ++ packages/db/drizzle/meta/_journal.json | 7 + packages/db/src/schema.ts | 59 +++-- wiki/concepts/anti-passback.md | 6 +- wiki/concepts/capacity-occupancy.md | 2 +- wiki/concepts/parking-session.md | 6 +- wiki/concepts/reporting-analytics.md | 2 +- wiki/concepts/shift.md | 4 +- wiki/concepts/tariff.md | 8 +- wiki/concepts/validation-sponsorship.md | 6 +- wiki/decisions/session-model.md | 4 +- wiki/entities/gee-qr-er80.md | 4 +- wiki/entities/opencv-anpr-service.md | 6 +- wiki/entities/permit.md | 149 ----------- wiki/entities/subscription.md | 184 +++++++++++++ wiki/index.md | 9 +- wiki/log.md | 20 ++ wiki/sources/parksql2017-legacy-schema.md | 2 +- 32 files changed, 1008 insertions(+), 675 deletions(-) delete mode 100644 apps/server/src/routes/permits.ts create mode 100644 apps/server/src/routes/subscriptions.ts rename apps/server/src/{permit-flow.ts => subscription-flow.ts} (57%) delete mode 100644 apps/web/src/PermitManager.tsx create mode 100644 apps/web/src/SubscriptionManager.tsx create mode 100644 packages/db/drizzle/0004_subscriptions_rename.sql delete mode 100644 wiki/entities/permit.md create mode 100644 wiki/entities/subscription.md diff --git a/apps/server/src/entry-flow.ts b/apps/server/src/entry-flow.ts index 6dc69ac..77c95fa 100644 --- a/apps/server/src/entry-flow.ts +++ b/apps/server/src/entry-flow.ts @@ -73,9 +73,9 @@ export class EntryFlow { async #runEntry(resolved: ResolvedRelay): Promise { // CAPACITY GATE (transient only). When the lot is full, refuse transient entry: - // no ticket, no vehicle_entry, no open — sign an anomaly. Permit holders are NOT + // no ticket, no vehicle_entry, no open — sign an anomaly. Subscribers are NOT // gated here (their flow ignores site-full; their own maxConcurrent applies), so - // subscribers aren't locked out. "Full" is a soft policy seam for valet over- + // they aren't locked out. "Full" is a soft policy seam for valet over- // capacity later. See wiki/concepts/capacity-occupancy.md. const occ = getOccupancy(this.#db); if (occ.full) { diff --git a/apps/server/src/read-dispatch.ts b/apps/server/src/read-dispatch.ts index 7dc647a..9beebf9 100644 --- a/apps/server/src/read-dispatch.ts +++ b/apps/server/src/read-dispatch.ts @@ -2,32 +2,32 @@ import { devices, eq, type Db } from "@parking/db"; import type { FastifyBaseLogger } from "fastify"; import type { DeviceReadEvent, ReadOutcome } from "./device-events.js"; import type { ExitFlow } from "./exit-flow.js"; -import type { PermitFlow } from "./permit-flow.js"; +import type { SubscriptionFlow } from "./subscription-flow.js"; import { relayForDevice } from "./device-resolve.js"; // Routes a credential read (ticket scan / plate / card) to the right flow. A read -// can mean a permit entry/exit OR a transient exit, so we dispatch by WHAT the +// can mean a subscription entry/exit OR a transient exit, so we dispatch by WHAT the // credential is (decision 2026-06-15): -// - matches a permit (card/QR/bound plate) → PERMIT flow, +// - matches a subscription (card/QR/bound plate) → SUBSCRIPTION flow, // - else → transient EXIT flow (open ticket session → exit, else reject+log). // // The reader is BOUND to a controller relay (config.controllerId + relay), so a read // resolves to exactly the barrier it sits at, and the direction is inherited from // that relay (see entry-exit-points.md). The resolved relay is handed to the flow so // it opens that exact barrier. An "entry" reader drives the entry side, an "exit" -// reader the exit side; "both" defers to the flow's own inference (permit: session -// state; transient: exit). +// reader the exit side; "both" defers to the flow's own inference (subscription: +// session state; transient: exit). export class ReadDispatcher { readonly #db: Db; readonly #exit: ExitFlow; - readonly #permit: PermitFlow; + readonly #subscription: SubscriptionFlow; readonly #logger: FastifyBaseLogger; - constructor(db: Db, exit: ExitFlow, permit: PermitFlow, logger: FastifyBaseLogger) { + constructor(db: Db, exit: ExitFlow, subscription: SubscriptionFlow, logger: FastifyBaseLogger) { this.#db = db; this.#exit = exit; - this.#permit = permit; + this.#subscription = subscription; this.#logger = logger; } @@ -41,13 +41,13 @@ export class ReadDispatcher { return { accepted: false, reason: "reader not bound to a barrier (no relay to open)" }; } - const permit = this.#permit.match(e); - if (permit) { - return this.#permit.run(resolved, e, permit); + const sub = this.#subscription.match(e); + if (sub) { + return this.#subscription.run(resolved, e, sub); } - // Not a permit → transient ticket exit. An ENTRY reader can't produce a transient - // exit (transient entry is the button flow, not a reader), so reject+log rather - // than treat an entry scan as an exit. + // Not a subscription → transient ticket exit. An ENTRY reader can't produce a + // transient exit (transient entry is the button flow, not a reader), so reject+log + // rather than treat an entry scan as an exit. if (resolved.direction === "entry") { return { accepted: false, direction: "entry", reason: "entry reader: no transient entry via reader" }; } diff --git a/apps/server/src/routes/permits.ts b/apps/server/src/routes/permits.ts deleted file mode 100644 index 2835bbe..0000000 --- a/apps/server/src/routes/permits.ts +++ /dev/null @@ -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 { - // 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(); - }, - ); -} diff --git a/apps/server/src/routes/site.ts b/apps/server/src/routes/site.ts index f5295ed..13de6a6 100644 --- a/apps/server/src/routes/site.ts +++ b/apps/server/src/routes/site.ts @@ -23,18 +23,23 @@ interface SiteConfigBody extends Partial> { capacity?: number | null; /** Default for the booth "print exit ticket" checkbox (booth-geography knob). */ exitVoucherDefault?: boolean; + /** Site default monthly subscription price in minor units (pre-fills the form). */ + subscriptionMonthlyPriceMinor?: number | null; } -/** Shape returned by GET/PUT: capacity + the booth flag + every metadata field. */ -type SiteConfig = { capacity: number | null; exitVoucherDefault: boolean } & Record< - TextField, - string | null ->; +/** Shape returned by GET/PUT: capacity + the booth flag + the subscription default + * + every metadata field. */ +type SiteConfig = { + capacity: number | null; + exitVoucherDefault: boolean; + subscriptionMonthlyPriceMinor: number | null; +} & Record; function toSiteConfig(row: typeof siteConfig.$inferSelect | undefined): SiteConfig { const out = { capacity: row?.capacity ?? null, exitVoucherDefault: row?.exitVoucherDefault ?? false, + subscriptionMonthlyPriceMinor: row?.subscriptionMonthlyPriceMinor ?? null, } as SiteConfig; for (const f of TEXT_FIELDS) out[f] = row?.[f] ?? null; return out; @@ -79,6 +84,13 @@ export async function siteRoutes(app: FastifyInstance, db: Db): Promise { } patch.exitVoucherDefault = body.exitVoucherDefault; } + if ("subscriptionMonthlyPriceMinor" in body) { + const p = body.subscriptionMonthlyPriceMinor; + if (p != null && (!Number.isInteger(p) || p < 0)) { + return reply.code(400).send({ error: "subscriptionMonthlyPriceMinor must be a non-negative integer or null" }); + } + patch.subscriptionMonthlyPriceMinor = p ?? null; + } for (const f of TEXT_FIELDS) { if (f in body) patch[f] = normText(body[f]); } diff --git a/apps/server/src/routes/subscriptions.ts b/apps/server/src/routes/subscriptions.ts new file mode 100644 index 0000000..2078b69 --- /dev/null +++ b/apps/server/src/routes/subscriptions.ts @@ -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 { + // 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(); + }, + ); +} diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index e637ef9..d70fd41 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -10,16 +10,17 @@ import { EntryFlow } from "./entry-flow.js"; import { EventLog } from "./event-log.js"; import { ExitFlow } from "./exit-flow.js"; import { PayStation } from "./pay-station.js"; -import { PermitFlow } from "./permit-flow.js"; +import { SubscriptionFlow } from "./subscription-flow.js"; import { ShiftService } from "./shift-service.js"; import { ReadDispatcher } from "./read-dispatch.js"; import { PrinterMonitor } from "./printer-monitor.js"; +import { DeviceMonitor } from "./device-monitor.js"; import { buildSigner, buildVerifier } from "./signer.js"; import { authRoutes } from "./routes/auth.js"; import { deviceRoutes } from "./routes/devices.js"; import { eventRoutes } from "./routes/events.js"; import { payRoutes } from "./routes/pay.js"; -import { permitRoutes } from "./routes/permits.js"; +import { subscriptionRoutes } from "./routes/subscriptions.js"; import { qrReaderRoutes } from "./routes/qr-reader.js"; import { shiftRoutes } from "./routes/shift.js"; import { siteRoutes } from "./routes/site.js"; @@ -27,6 +28,7 @@ import { snapshotRoutes } from "./routes/snapshots.js"; import { tariffRoutes } from "./routes/tariffs.js"; import { printerRoutes } from "./routes/printers.js"; import { setupRoutes } from "./routes/setup.js"; +import { deviceStatusRoutes } from "./routes/device-status.js"; import { wsRoutes } from "./routes/ws.js"; // The backend is Fastify (Node). Hardware drivers live as isolated Fastify @@ -86,6 +88,15 @@ export async function buildServer(opts: BuildOptions = {}): Promise printerMonitor.start()); app.addHook("onClose", async () => printerMonitor.stop()); + // Unified device-status monitor: polls EVERY configured device (relays/readers/ + // cameras via healthCheck, printers via rich readStatus) and feeds the booth's + // device-status footer over the WS. Read-only — never drives a relay. + // See wiki/concepts/device-status-monitoring.md. + const deviceMonitor = new DeviceMonitor(db, app.log); + await deviceStatusRoutes(app, deviceMonitor); + app.addHook("onReady", async () => deviceMonitor.start()); + app.addHook("onClose", async () => deviceMonitor.stop()); + // Append-only signed business LEDGER (ledger_events). Holds only business facts // (vehicle_entry/exit, payment, void, …) — the anti-fraud audit trail. A raw // button press is NOT a business fact: it's device telemetry, recorded UNSIGNED @@ -101,7 +112,7 @@ export async function buildServer(opts: BuildOptions = {}): Promise unsubscribeEntry()); // Read-driven flows: a credential read (ticket scan / plate / card) routes via the - // dispatcher to either the PERMIT flow (if it matches a permit) or the transient - // EXIT flow. See read-dispatch.ts, exit-flow.ts, permit-flow.ts, parking-session.md. + // dispatcher to either the SUBSCRIPTION flow (if it matches a subscription) or the + // transient EXIT flow. See read-dispatch.ts, exit-flow.ts, subscription-flow.ts, + // parking-session.md. const exitFlow = new ExitFlow(db, eventLog, app.log); - const permitFlow = new PermitFlow(db, eventLog, app.log); - const readDispatcher = new ReadDispatcher(db, exitFlow, permitFlow, app.log); + const subscriptionFlow = new SubscriptionFlow(db, eventLog, app.log); + const readDispatcher = new ReadDispatcher(db, exitFlow, subscriptionFlow, app.log); const unsubscribeRead = deviceEvents.onRead((e) => { void readDispatcher.dispatch(e); }); @@ -147,8 +159,8 @@ export async function buildServer(opts: BuildOptions = {}): Promise { - const key = `${m.permitId}:${m.carKey}`; + async run(resolved: ResolvedRelay, e: DeviceReadEvent, m: SubscriptionMatch): Promise { + const key = `${m.subscriptionId}:${m.carKey}`; if (this.#inFlight.has(key)) return { accepted: false, reason: "duplicate read in flight" }; this.#inFlight.add(key); try { return await this.#run(resolved, e, m); } catch (err) { - this.#logger.error(`permit-flow failed: ${(err as Error).message}`); + this.#logger.error(`subscription-flow failed: ${(err as Error).message}`); return { accepted: false, reason: (err as Error).message }; } finally { this.#inFlight.delete(key); } } - async #run(resolved: ResolvedRelay, e: DeviceReadEvent, m: PermitMatch): Promise { - const permit = this.#db.select().from(permits).where(eq(permits.id, m.permitId)).get(); - if (!permit) return { accepted: false, reason: "permit not found" }; + async #run(resolved: ResolvedRelay, e: DeviceReadEvent, m: SubscriptionMatch): Promise { + const sub = this.#db.select().from(subscriptions).where(eq(subscriptions.id, m.subscriptionId)).get(); + if (!sub) return { accepted: false, reason: "subscription not found" }; // Validity: active + within the coverage window. const now = new Date().toISOString(); const invalid = - permit.status !== "active" || - (permit.validFrom != null && now < permit.validFrom) || - (permit.validTo != null && now > permit.validTo); + sub.status !== "active" || + (sub.validFrom != null && now < sub.validFrom) || + (sub.validTo != null && now > sub.validTo); if (invalid) { - const reason = `permit ${permit.status}/out-of-window`; + const reason = `subscription ${sub.status}/out-of-window`; await this.#reject(m, reason); return { accepted: false, reason }; } @@ -99,7 +113,7 @@ export class PermitFlow { const carOpen = this.#carHasOpenSession(m.carKey); const inferred: FlowDirection = carOpen ? "exit" : "entry"; if (resolved.direction !== "both" && resolved.direction !== inferred) { - const reason = `permit wrong barrier — ${resolved.direction} barrier but car would ${inferred}`; + const reason = `subscription wrong barrier — ${resolved.direction} barrier but car would ${inferred}`; await this.#reject(m, reason); return { accepted: false, direction: resolved.direction === "exit" ? "exit" : "entry", reason }; } @@ -111,18 +125,19 @@ export class PermitFlow { direction: "exit", source: m.via === "plate" ? "lpr" : m.via === "qr" ? "qr" : "wiegand", identity: m.carKey, - payload: { sessionRef: m.carKey, permitId: m.permitId }, + // `permitId` is the on-chain field name (immutable) — carries the subscription id. + payload: { sessionRef: m.carKey, permitId: m.subscriptionId }, }); - await this.#open(resolved, "exit", m.carKey, "permit exit"); + await this.#open(resolved, "exit", m.carKey, "subscription exit"); this.#closeCache(m.carKey); return { accepted: true, direction: "exit" }; } // ENTRY: enforce the car-count binding (maxConcurrent), then sign + open. - if (permit.maxConcurrent != null) { - const open = this.#permitOpenCount(m.permitId); - if (open >= permit.maxConcurrent) { - const reason = `permit at capacity (${open}/${permit.maxConcurrent} cars in)`; + if (sub.maxConcurrent != null) { + const open = this.#subscriptionOpenCount(m.subscriptionId); + if (open >= sub.maxConcurrent) { + const reason = `subscription at capacity (${open}/${sub.maxConcurrent} cars in)`; await this.#reject(m, reason); return { accepted: false, direction: "entry", reason }; } @@ -133,15 +148,23 @@ export class PermitFlow { direction: "entry", source: m.via === "plate" ? "lpr" : m.via === "qr" ? "qr" : "wiegand", identity: m.carKey, - // No ticket, no fee — the permit IS the authorization. Recorded for audit. - payload: { sessionRef: m.carKey, permitId: m.permitId, permit: true }, + // No ticket, no fee — the subscription IS the authorization. Recorded for audit. + // `permitId`/`permit` are the on-chain field names (immutable). + payload: { sessionRef: m.carKey, permitId: m.subscriptionId, permit: true }, occurredAt: now, }); - await this.#open(resolved, "entry", m.carKey, "permit entry"); + await this.#open(resolved, "entry", m.carKey, "subscription entry"); try { this.#db .insert(sessions) - .values({ id: m.carKey, identity: m.carKey, source: m.via === "plate" ? "lpr" : "wiegand", permitId: m.permitId, enteredAt: now, state: "open" }) + .values({ + id: m.carKey, + identity: m.carKey, + source: m.via === "plate" ? "lpr" : "wiegand", + subscriptionId: m.subscriptionId, + enteredAt: now, + state: "open", + }) .run(); } catch (err) { this.#logger.error(`session-cache insert failed for ${m.carKey}: ${(err as Error).message}`); @@ -162,14 +185,15 @@ export class PermitFlow { return entries > exits; } - /** How many of this permit's cars are inside right now (fold over the ledger). */ - #permitOpenCount(permitId: string): number { + /** How many of this subscription's cars are inside right now (fold over the ledger). + * The on-chain field is `permitId`, so we match against that. */ + #subscriptionOpenCount(subscriptionId: string): number { const rows = this.#db .select() .from(ledgerEvents) .where(eq(ledgerEvents.type, "vehicle_entry")) .all() - .filter((r) => (r.payload as { permitId?: string } | null)?.permitId === permitId); + .filter((r) => (r.payload as { permitId?: string } | null)?.permitId === subscriptionId); let open = 0; for (const entry of rows) { if (!this.#carHasOpenSession(entry.identity ?? "")) continue; @@ -178,13 +202,14 @@ export class PermitFlow { return open; } - async #reject(m: PermitMatch, reason: string): Promise { + async #reject(m: SubscriptionMatch, reason: string): Promise { await this.#log.append({ type: "anomaly", identity: m.carKey, - payload: { reason: `permit refused — ${reason}`, permitId: m.permitId, permitRefused: true }, + // `permitId`/`permitRefused` are the on-chain field names (immutable). + payload: { reason: `subscription refused — ${reason}`, permitId: m.subscriptionId, permitRefused: true }, }); - this.#logger.warn(`permit refused (${m.carKey}): ${reason}`); + this.#logger.warn(`subscription refused (${m.carKey}): ${reason}`); } async #open(resolved: ResolvedRelay, dir: FlowDirection, carKey: string, what: string): Promise { @@ -198,7 +223,7 @@ export class PermitFlow { direction: dir, identity: carKey, logger: this.#logger, - }).catch((err) => this.#logger.error(`permit snapshot error: ${(err as Error).message}`)); + }).catch((err) => this.#logger.error(`subscription snapshot error: ${(err as Error).message}`)); } #closeCache(carKey: string): void { diff --git a/apps/web/src/PermitManager.tsx b/apps/web/src/PermitManager.tsx deleted file mode 100644 index 4f201b3..0000000 --- a/apps/web/src/PermitManager.tsx +++ /dev/null @@ -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 = { - 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(null); - const [editing, setEditing] = useState(null); - const [form, setForm] = useState(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) { - setForm((f) => ({ ...f, credentials: f.credentials.map((c, j) => (j === i ? { ...c, ...patch } : c)) })); - } - - if (!permits) return null; - - return ( -
-

{t("permits.title")}

-
    - {permits.map((p) => ( -
  • - {p.holderName ?? t("permits.unnamed")} - {t(STATUS_KEY[p.status])} - - {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 })} - - - - {p.status !== "revoked" && } - -
  • - ))} - {permits.length === 0 &&
  • {t("permits.noPermitsYet")}
  • } -
- - {editing == null ? ( - - ) : ( -
-

{editing === "new" ? t("permits.newPermit") : t("permits.editPermit")}

-
- - setForm((f) => ({ ...f, holderName: e.target.value }))} /> - - setForm((f) => ({ ...f, contact: e.target.value }))} /> - - - - {form.carBound && ( - setForm((f) => ({ ...f, maxConcurrent: e.target.value }))} style={{ width: 50 }} /> - )} - - - setForm((f) => ({ ...f, validFrom: e.target.value }))} placeholder={t("permits.isoDateOptional")} /> - - setForm((f) => ({ ...f, validTo: e.target.value }))} placeholder={t("permits.isoDateOptional")} /> - - setForm((f) => ({ ...f, platesText: e.target.value }))} placeholder={t("permits.commaSeparatedOptional")} /> -
- -

{t("permits.credentialsCardQr")}

- {form.credentials.map((c, i) => ( -
- - setCred(i, { value: e.target.value })} placeholder={t("permits.credentialValue")} style={{ flex: 1 }} /> - -
- ))} - -

- {t("permits.needCredentialOrPlate")} -

- -
- - -
-
- )} - {msg &&

{msg.text}

} -
- ); -} diff --git a/apps/web/src/SubscriptionManager.tsx b/apps/web/src/SubscriptionManager.tsx new file mode 100644 index 0000000..7b70421 --- /dev/null +++ b/apps/web/src/SubscriptionManager.tsx @@ -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 = { + 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(null); + const [defaultPriceMajor, setDefaultPriceMajor] = useState(""); + const [editing, setEditing] = useState(null); + const [form, setForm] = useState(() => 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) { + setForm((f) => ({ ...f, credentials: f.credentials.map((c, j) => (j === i ? { ...c, ...patch } : c)) })); + } + + if (!subs) return null; + + return ( +
+

{t("subs.title")}

+
    + {subs.map((s) => ( +
  • + {s.holderName ?? t("subs.unnamed")} + {t(STATUS_KEY[s.status])} + {priceLabel(s, t)} + + {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 })} + + + + {s.status !== "revoked" && } + +
  • + ))} + {subs.length === 0 &&
  • {t("subs.noneYet")}
  • } +
+ + {editing == null ? ( + + ) : ( +
+

{editing === "new" ? t("subs.new") : t("subs.editTitle")}

+
+ + setForm((f) => ({ ...f, holderName: e.target.value }))} /> + + setForm((f) => ({ ...f, contact: e.target.value }))} /> + + + setForm((f) => ({ ...f, priceMajor: e.target.value }))} + inputMode="decimal" + placeholder={t("subs.pricePlaceholder")} + style={{ width: 110 }} + /> + setForm((f) => ({ ...f, currency: e.target.value }))} style={{ width: 60 }} /> + / {t("subs.perMonth")} + + + + + {form.carBound && ( + setForm((f) => ({ ...f, maxConcurrent: e.target.value }))} style={{ width: 50 }} /> + )} + + + setForm((f) => ({ ...f, validFrom: e.target.value }))} placeholder={t("subs.isoDateOptional")} /> + + setForm((f) => ({ ...f, validTo: e.target.value }))} placeholder={t("subs.isoDateOptional")} /> + + setForm((f) => ({ ...f, platesText: e.target.value }))} placeholder={t("subs.commaSeparatedOptional")} /> +
+ +

{t("subs.credentialsCardQr")}

+ {form.credentials.map((c, i) => ( +
+ + setCred(i, { value: e.target.value })} placeholder={t("subs.credentialValue")} style={{ flex: 1 }} /> + +
+ ))} + +

+ {t("subs.needCredentialOrPlate")} +

+ +
+ + +
+
+ )} + {msg &&

{msg.text}

} +
+ ); +} diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index c8fce72..cf77c8a 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -277,41 +277,45 @@ export function publishTariffVersion(body: { return apiFetch("/api/tariff/versions", { method: "POST", body: JSON.stringify(body) }); } -// --- Permits -------------------------------------------------------------- +// --- Subscriptions -------------------------------------------------------- -export interface PermitCredential { +export interface SubscriptionCredential { kind: "rf" | "qr"; value: string; } -export interface Permit { +export interface Subscription { id: string; holderName: 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; validFrom: string | null; validTo: string | null; status: "active" | "suspended" | "revoked"; - credentials: PermitCredential[]; + credentials: SubscriptionCredential[]; plates: string[]; } -export type PermitInput = Omit & { - status?: Permit["status"]; +export type SubscriptionInput = Omit & { + status?: Subscription["status"]; }; -export function fetchPermits(): Promise<{ permits: Permit[] }> { - return apiFetch("/api/permits"); +export function fetchSubscriptions(): Promise<{ subscriptions: Subscription[] }> { + return apiFetch("/api/subscriptions"); } -export function createPermit(body: PermitInput): Promise { - return apiFetch("/api/permits", { method: "POST", body: JSON.stringify(body) }); +export function createSubscription(body: SubscriptionInput): Promise { + return apiFetch("/api/subscriptions", { method: "POST", body: JSON.stringify(body) }); } -export function updatePermit(id: string, body: PermitInput): Promise { - return apiFetch(`/api/permits/${id}`, { method: "PUT", body: JSON.stringify(body) }); +export function updateSubscription(id: string, body: SubscriptionInput): Promise { + return apiFetch(`/api/subscriptions/${id}`, { method: "PUT", body: JSON.stringify(body) }); } -export function revokePermit(id: string): Promise { - return apiFetch(`/api/permits/${id}/revoke`, { method: "POST" }); +export function revokeSubscription(id: string): Promise { + return apiFetch(`/api/subscriptions/${id}/revoke`, { method: "POST" }); } -export function deletePermit(id: string): Promise { - return apiFetch(`/api/permits/${id}`, { method: "DELETE" }); +export function deleteSubscription(id: string): Promise { + return apiFetch(`/api/subscriptions/${id}`, { method: "DELETE" }); } // --- Shifts --------------------------------------------------------------- @@ -378,6 +382,8 @@ export interface SiteConfig { capacity: number | null; /** Default for the booth "print exit ticket" checkbox (booth-geography knob). */ exitVoucherDefault: boolean; + /** Site default monthly subscription price (minor units); pre-fills the form. */ + subscriptionMonthlyPriceMinor: number | null; parkName: string | null; operatorName: string | null; /** NIUS — Albanian tax/identification number. */ @@ -391,6 +397,28 @@ export function fetchOccupancy(): Promise { 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) -------------------- /** A persisted ledger row. Re-exported from shared so UI code has one source of diff --git a/apps/web/src/lib/i18n/en.ts b/apps/web/src/lib/i18n/en.ts index 696efb1..0f69137 100644 --- a/apps/web/src/lib/i18n/en.ts +++ b/apps/web/src/lib/i18n/en.ts @@ -24,7 +24,7 @@ export const en: Catalog = { shift: "Shift", setup: "Setup", tariff: "Tariff", - permits: "Permits", + subscriptions: "Subscriptions", site: "Site", }, status: { @@ -32,6 +32,33 @@ export const en: Catalog = { connecting: "CONNECTING", 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: { processTicket: "Process ticket", scanPlaceholder: "Scan or type ticket number…", @@ -92,21 +119,25 @@ export const en: Catalog = { publishing: "Publishing…", publishedOk: "New tariff version published — it's now the active rate card.", }, - permits: { - title: "Permits", + subs: { + title: "Subscriptions", unnamed: "(unnamed)", unbound: "unbound", car_one: "{{count}} car", car_other: "{{count}} cars", cred: "cred", plates: "{{count}} plate(s)", + noPrice: "no price", + perMonth: "month", + monthlyPrice: "Monthly price", + pricePlaceholder: "e.g. 10000", edit: "Edit", revoke: "Revoke", delete: "Delete", - noPermitsYet: "No permits yet.", - addPermit: "+ Add permit", - newPermit: "New permit", - editPermit: "Edit permit", + noneYet: "No subscriptions yet.", + add: "+ Add subscription", + new: "New subscription", + editTitle: "Edit subscription", holderName: "Holder name", contact: "Contact", carLimit: "Car limit", @@ -121,12 +152,12 @@ export const en: Catalog = { qr: "QR", credentialValue: "credential value", 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", cancel: "Cancel", - permitSaved: "Permit saved.", - confirmRevoke: "Revoke permit for {{name}}? It will be refused at the barrier.", - confirmDelete: "Delete permit for {{name}}? (Past events are kept.)", + saved: "Subscription saved.", + confirmRevoke: "Revoke subscription for {{name}}? It will be refused at the barrier.", + confirmDelete: "Delete subscription for {{name}}? (Past events are kept.)", statusActive: "active", statusSuspended: "suspended", statusRevoked: "revoked", diff --git a/apps/web/src/lib/i18n/sq.ts b/apps/web/src/lib/i18n/sq.ts index a0020b6..af58ff4 100644 --- a/apps/web/src/lib/i18n/sq.ts +++ b/apps/web/src/lib/i18n/sq.ts @@ -24,7 +24,7 @@ export const sq = { shift: "Turni", setup: "Konfigurimi", tariff: "Tarifa", - permits: "Lejet", + subscriptions: "Abonimet", site: "Vendi", }, status: { @@ -32,16 +32,43 @@ export const sq = { connecting: "DUKE U LIDHUR", 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: { processTicket: "Proceso biletën", scanPlaceholder: "Skano ose shkruaj numrin e biletës…", open: "Hap", - occupancy: "Zënia", + occupancy: "Prania", occUnavailable: "zënia e padisponueshme", inside: "brenda", of: "nga", uncapped: "pa kufi", - free: "lirë", + free: "Vende të lira", lotFull: "● parkimi plot", liveFeed: "Aktiviteti live", events: "ngjarje", @@ -94,21 +121,25 @@ export const sq = { publishing: "Duke publikuar…", publishedOk: "U publikua versioni i ri i tarifës — tani është karta tarifore aktive.", }, - permits: { - title: "Lejet", + subs: { + title: "Abonimet", unnamed: "(pa emër)", unbound: "pa kufizim", car_one: "{{count}} makinë", car_other: "{{count}} makina", cred: "kredencial", plates: "{{count}} targë(a)", + noPrice: "pa çmim", + perMonth: "muaj", + monthlyPrice: "Çmimi mujor", + pricePlaceholder: "p.sh. 10000", edit: "Ndrysho", revoke: "Anulo", delete: "Fshij", - noPermitsYet: "Asnjë leje ende.", - addPermit: "+ Shto leje", - newPermit: "Leje e re", - editPermit: "Ndrysho lejen", + noneYet: "Asnjë abonim ende.", + add: "+ Shto abonim", + new: "Abonim i ri", + editTitle: "Ndrysho abonimin", holderName: "Emri i mbajtësit", contact: "Kontakti", carLimit: "Kufiri i makinave", @@ -123,18 +154,18 @@ export const sq = { qr: "QR", credentialValue: "vlera e kredencialit", 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", cancel: "Anulo", - permitSaved: "Leja u ruajt.", - confirmRevoke: "Të anulohet leja për {{name}}? Do të refuzohet te barriera.", - confirmDelete: "Të fshihet leja për {{name}}? (Ngjarjet e kaluara ruhen.)", - statusActive: "aktive", + saved: "Abonimi u ruajt.", + confirmRevoke: "Të anulohet abonimi për {{name}}? Do të refuzohet te barriera.", + confirmDelete: "Të fshihet abonimi për {{name}}? (Ngjarjet e kaluara ruhen.)", + statusActive: "aktiv", statusSuspended: "pezulluar", statusRevoked: "anuluar", }, site: { - occupancy: "Zënia:", + occupancy: "Prania:", noCapacitySet: "(pa kapacitet të caktuar)", free: "lirë", full: "PLOT", diff --git a/apps/web/src/router.tsx b/apps/web/src/router.tsx index 8027b64..7b7795f 100644 --- a/apps/web/src/router.tsx +++ b/apps/web/src/router.tsx @@ -15,11 +15,12 @@ import { qk, queryClient } from "./lib/query.js"; import { setLanguage } from "./lib/i18n/index.js"; import { useLiveFeed } from "./lib/use-live-feed.js"; import { useShift } from "./lib/use-shift.js"; +import { DeviceFooter } from "./ui/DeviceFooter.js"; import { StatusDot } from "./ui/StatusDot.js"; import { BoothScreen } from "./BoothScreen.js"; import { SetupWizard } from "./SetupWizard.js"; import { TariffComposer } from "./TariffComposer.js"; -import { PermitManager } from "./PermitManager.js"; +import { SubscriptionManager } from "./SubscriptionManager.js"; import { ShiftControl } from "./ShiftControl.js"; import { SiteSettings } from "./SiteSettings.js"; @@ -164,7 +165,7 @@ function RootLayout() { {isAdmin && } {isAdmin && } - {isAdmin && } + {isAdmin && } {isAdmin && }
@@ -189,6 +190,8 @@ function RootLayout() {
+ {/* Fixed device-status footer — relays, readers, cameras, printers. */} + {user && }
); } @@ -233,11 +236,11 @@ const tariffRoute = createRoute({ beforeLoad: ({ context }) => adminOnly(context), component: () => , }); -const permitsRoute = createRoute({ +const subscriptionsRoute = createRoute({ getParentRoute: () => rootRoute, - path: "/permits", + path: "/subscriptions", beforeLoad: ({ context }) => adminOnly(context), - component: () => , + component: () => , }); const siteRoute = createRoute({ getParentRoute: () => rootRoute, @@ -252,7 +255,7 @@ const routeTree = rootRoute.addChildren([ shiftRoute, setupRoute, tariffRoute, - permitsRoute, + subscriptionsRoute, siteRoute, ]); diff --git a/packages/db/drizzle/0004_subscriptions_rename.sql b/packages/db/drizzle/0004_subscriptions_rename.sql new file mode 100644 index 0000000..53a71d5 --- /dev/null +++ b/packages/db/drizzle/0004_subscriptions_rename.sql @@ -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; diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index 97cd9da..183c392 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -29,6 +29,13 @@ "when": 1781774228086, "tag": "0003_early_hawkeye", "breakpoints": true + }, + { + "idx": 4, + "version": "6", + "when": 1781800000000, + "tag": "0004_subscriptions_rename", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 3403689..879a3f6 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -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. // • `device_events` — UNSIGNED operational telemetry (relay/printer/camera/reader/input). // 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: // editing publishes a new immutable tariff_version. See wiki/concepts/tariff.md. // - `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(), // The camera `devices` instance that captured it (raw provenance). 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"), contentType: text("content_type").notNull(), bytes: blob("bytes").notNull().$type(), @@ -166,6 +166,11 @@ export const siteConfig = sqliteTable("site_config", { exitVoucherDefault: integer("exit_voucher_default", { mode: "boolean" }) .notNull() .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") .notNull() .default(sql`(current_timestamp)`), @@ -203,16 +208,28 @@ export const tariffVersions = sqliteTable("tariff_versions", { .default(sql`(current_timestamp)`), }); -// --- Permits (subscriptions) --------------------------------------------- -// Mutable master data; every USE produces a signed vehicle_entry/exit ledger event. -// Two optional, independent bindings: car-count (maxConcurrent, default 1, null = -// unbound) and plate (plates rows, default none = any car). Identity = card/QR OR a -// matching plate. Credentials and cars are child rows. See wiki/entities/permit.md. -export const permits = sqliteTable("permits", { +// --- Subscriptions -------------------------------------------------------- +// A subscriber: a known holder who parks on a recurring plan (e.g. 10,000 ALL / +// month) instead of paying per stay. Mutable master data; every USE still produces a +// signed vehicle_entry/exit ledger event. Two optional, independent bindings: +// car-count (maxConcurrent, default 1, null = unbound) and plate (plates rows, +// 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(), holderName: text("holder_name"), 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. maxConcurrent: integer("max_concurrent").default(1), validFrom: text("valid_from"), @@ -225,19 +242,19 @@ export const permits = sqliteTable("permits", { .default(sql`(current_timestamp)`), }); -// A permit's credentials (RF tag/chip/card, or QR). Either opens the lane. -export const permitCredentials = sqliteTable("permit_credentials", { +// A subscription's credentials (RF tag/chip/card, or QR). Either opens the barrier. +export const subscriptionCredentials = sqliteTable("subscription_credentials", { id: text("id").primaryKey(), - permitId: text("permit_id").notNull(), + subscriptionId: text("subscription_id").notNull(), kind: text("kind", { enum: ["rf", "qr"] }).notNull(), value: text("value").notNull(), }); -// Plate binding (optional). When a permit has plate rows, a matching plate read is -// itself an accepted identity (card/QR OR plate). Empty = not plate-bound (any car). -export const permitPlates = sqliteTable("permit_plates", { +// Plate binding (optional). When a subscription has plate rows, a matching plate read +// is itself an accepted identity (card/QR OR plate). Empty = not plate-bound (any car). +export const subscriptionPlates = sqliteTable("subscription_plates", { id: text("id").primaryKey(), - permitId: text("permit_id").notNull(), + subscriptionId: text("subscription_id").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: text("identity"), source: text("source"), - // null while transient; set when matched to a permit. - permitId: text("permit_id"), + // null while transient; set when matched to a subscription. + subscriptionId: text("subscription_id"), enteredAt: text("entered_at").notNull(), // null until exit; presence = CLOSED. exitedAt: text("exited_at"), @@ -288,8 +305,8 @@ export type SetupStateRow = typeof setupState.$inferSelect; export type SiteConfigRow = typeof siteConfig.$inferSelect; export type TariffRow = typeof tariffs.$inferSelect; export type TariffVersionRow = typeof tariffVersions.$inferSelect; -export type PermitRow = typeof permits.$inferSelect; -export type PermitCredentialRow = typeof permitCredentials.$inferSelect; -export type PermitPlateRow = typeof permitPlates.$inferSelect; +export type SubscriptionRow = typeof subscriptions.$inferSelect; +export type SubscriptionCredentialRow = typeof subscriptionCredentials.$inferSelect; +export type SubscriptionPlateRow = typeof subscriptionPlates.$inferSelect; export type BlocklistRow = typeof blocklist.$inferSelect; export type SessionRow = typeof sessions.$inferSelect; diff --git a/wiki/concepts/anti-passback.md b/wiki/concepts/anti-passback.md index 8be12e1..1a3858e 100644 --- a/wiki/concepts/anti-passback.md +++ b/wiki/concepts/anti-passback.md @@ -14,7 +14,7 @@ session projection. ## 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: ``` @@ -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 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 - **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). -- **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 twice is still a violation. So enforce per-identity, *under* the permit's concurrency allowance. diff --git a/wiki/concepts/capacity-occupancy.md b/wiki/concepts/capacity-occupancy.md index 50325f5..d4161da 100644 --- a/wiki/concepts/capacity-occupancy.md +++ b/wiki/concepts/capacity-occupancy.md @@ -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 `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. ## Full → refuse entry + FULL sign diff --git a/wiki/concepts/parking-session.md b/wiki/concepts/parking-session.md index 9b12f68..3a7ff15 100644 --- a/wiki/concepts/parking-session.md +++ b/wiki/concepts/parking-session.md @@ -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]]) 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). ## Lifecycle (pay-on-foot / pay station model) @@ -71,7 +71,7 @@ States, as derived from events: | **CLOSED** | a matching `vehicle_exit` event exists | | **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) @@ -106,7 +106,7 @@ follow this page and [[tariff]]; the decision is recorded in [[session-model]]. fails. See [[device-input-flow]]. - **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**. - 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) → fold the signed ledger for that identity → validate **open + PAID + within `gracePeriodExitMin`** → signed `vehicle_exit` → `pulseOpen`. Unpaid / expired / unknown → signed `anomaly`, barrier diff --git a/wiki/concepts/reporting-analytics.md b/wiki/concepts/reporting-analytics.md index 5e13700..59ff9ea 100644 --- a/wiki/concepts/reporting-analytics.md +++ b/wiki/concepts/reporting-analytics.md @@ -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: `payment` events + [[validation-discounts|discount]] events + `shift_z_report` ([[shift]]). - **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`. - **Anomalies** — out-of-band opens, never-exited sessions, occupancy drift, over-validation — the `anomaly` events + reconciliation findings ([[reconciliation]]). diff --git a/wiki/concepts/shift.md b/wiki/concepts/shift.md index f91c1d5..63f37d1 100644 --- a/wiki/concepts/shift.md +++ b/wiki/concepts/shift.md @@ -63,7 +63,9 @@ login ———————————————————————— ## What End Shift does 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** (the card line is omitted when there's no terminal). 3. Append a signed **`shift_z_report`** event (type already in `packages/shared`): `{ operator, diff --git a/wiki/concepts/tariff.md b/wiki/concepts/tariff.md index 51baac1..6d33bf0 100644 --- a/wiki/concepts/tariff.md +++ b/wiki/concepts/tariff.md @@ -10,7 +10,7 @@ status: open 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 -[[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 beyond the host). @@ -144,9 +144,9 @@ production. ## 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 -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 @@ -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. - **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 - 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. ## Open diff --git a/wiki/concepts/validation-sponsorship.md b/wiki/concepts/validation-sponsorship.md index 53ad41d..be4614d 100644 --- a/wiki/concepts/validation-sponsorship.md +++ b/wiki/concepts/validation-sponsorship.md @@ -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) -| | [[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** | | 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 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 - [[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.** ## 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. ## 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. - 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 diff --git a/wiki/decisions/session-model.md b/wiki/decisions/session-model.md index 90c0b6c..f1fe681 100644 --- a/wiki/decisions/session-model.md +++ b/wiki/decisions/session-model.md @@ -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 authoritative. 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]]). 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 @@ -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]]). - 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 (payment = an independent signed event referencing a session) until procurement settles. - Reconciliation of sessions/payments against an external authority remains [[open-questions]] #4 diff --git a/wiki/entities/gee-qr-er80.md b/wiki/entities/gee-qr-er80.md index dd92198..955a257 100644 --- a/wiki/entities/gee-qr-er80.md +++ b/wiki/entities/gee-qr-er80.md @@ -11,7 +11,7 @@ status: open 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]] 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/`.) ## What it is (and isn't) @@ -39,7 +39,7 @@ GET /qa/mcardsea.php?cardid=&mjihao=&cjihao=&status=<2 chars>& 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 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.) > **This explains the "no beep":** feedback comes from the server's JSON reply, not locally. A diff --git a/wiki/entities/opencv-anpr-service.md b/wiki/entities/opencv-anpr-service.md index 2634496..1cfb5f1 100644 --- a/wiki/entities/opencv-anpr-service.md +++ b/wiki/entities/opencv-anpr-service.md @@ -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 `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 `{ 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 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 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 - **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. - 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 diff --git a/wiki/entities/permit.md b/wiki/entities/permit.md deleted file mode 100644 index 5e9b108..0000000 --- a/wiki/entities/permit.md +++ /dev/null @@ -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. diff --git a/wiki/entities/subscription.md b/wiki/entities/subscription.md new file mode 100644 index 0000000..7f4507f --- /dev/null +++ b/wiki/entities/subscription.md @@ -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). diff --git a/wiki/index.md b/wiki/index.md index e453558..cfa9970 100644 --- a/wiki/index.md +++ b/wiki/index.md @@ -1,13 +1,13 @@ --- type: overview tags: [parking, index] -updated: 2026-06-14 +updated: 2026-06-18 --- # Index 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]] — 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. - [[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. +- [[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. - [[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. - [[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. -- [[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). - [[blocklist]] — barred plates/cards refused at entry (never at exit); signed, attributed. ## 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. ## Dev environment (reference) diff --git a/wiki/log.md b/wiki/log.md index e0e91a7..cc70d46 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -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 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=`. 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). diff --git a/wiki/sources/parksql2017-legacy-schema.md b/wiki/sources/parksql2017-legacy-schema.md index 2bf2548..bbc85ec 100644 --- a/wiki/sources/parksql2017-legacy-schema.md +++ b/wiki/sources/parksql2017-legacy-schema.md @@ -74,7 +74,7 @@ clear anti-patterns, e.g. money as `float`). 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]]. 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 - **Money as `float`** everywhere (`Charge`, `Price`, `LostPenalty`) — drifts across a revenue