Compare commits
15 Commits
eb47016ae3
...
3527f48d76
| Author | SHA1 | Date | |
|---|---|---|---|
| 3527f48d76 | |||
| 5a5f5c554b | |||
| 742653aefb | |||
| 66c1291578 | |||
| 7629d5d7b1 | |||
| 2fb947e908 | |||
| cae900afd2 | |||
| 7e912e193b | |||
| 352c643009 | |||
| 5e9be16f65 | |||
| 0985b86fa7 | |||
| 3ed785c33e | |||
| 35c10a7310 | |||
| 2a9e6846a1 | |||
| 051b440627 |
@@ -20,7 +20,13 @@ EVENT_SIGNING_KEY=
|
|||||||
# HOST=0.0.0.0 # interface to bind. 127.0.0.1 = loopback only.
|
# HOST=0.0.0.0 # interface to bind. 127.0.0.1 = loopback only.
|
||||||
# LOG_LEVEL=info
|
# LOG_LEVEL=info
|
||||||
# DATABASE_URL=./parking.sqlite
|
# DATABASE_URL=./parking.sqlite
|
||||||
# NODE_ENV=production # set in prod: makes auth cookies Secure (HTTPS-only)
|
#
|
||||||
|
# Auth-cookie Secure flag. FAIL-SAFE: cookies are Secure (HTTPS-only) BY DEFAULT —
|
||||||
|
# you only ever opt OUT, never in. Set COOKIE_SECURE=0 for a plain-HTTP deployment
|
||||||
|
# (e.g. the LAN appliance serving the SPA same-origin over http, where a Secure
|
||||||
|
# cookie would never be sent and would lock operators out). Local dev over
|
||||||
|
# http://localhost MUST set this (the dev .env does). Leave unset in any TLS deploy.
|
||||||
|
# COOKIE_SECURE=0
|
||||||
|
|
||||||
# First admin (seed once): pnpm --filter @parking/server seed-admin
|
# First admin (seed once): pnpm --filter @parking/server seed-admin
|
||||||
# ADMIN_USER=admin
|
# ADMIN_USER=admin
|
||||||
|
|||||||
@@ -9,7 +9,8 @@
|
|||||||
"start": "node --env-file-if-exists=.env dist/index.js",
|
"start": "node --env-file-if-exists=.env dist/index.js",
|
||||||
"seed-admin": "node --env-file-if-exists=.env scripts/seed-admin.mjs",
|
"seed-admin": "node --env-file-if-exists=.env scripts/seed-admin.mjs",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"lint": "tsc --noEmit"
|
"lint": "tsc --noEmit",
|
||||||
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cookie": "^11.0.2",
|
"@fastify/cookie": "^11.0.2",
|
||||||
@@ -28,6 +29,7 @@
|
|||||||
"@types/bcrypt": "6.0.0",
|
"@types/bcrypt": "6.0.0",
|
||||||
"@types/node": "25.9.3",
|
"@types/node": "25.9.3",
|
||||||
"tsx": "4.22.4",
|
"tsx": "4.22.4",
|
||||||
"typescript": "6.0.3"
|
"typescript": "6.0.3",
|
||||||
|
"vitest": "^4.1.9"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { secureCookies } from "./auth.js";
|
||||||
|
|
||||||
|
// The auth/CSRF cookies' Secure flag must be FAIL-SAFE: Secure by default, dropped only
|
||||||
|
// on a deliberate opt-out. The old behaviour (Secure iff NODE_ENV==="production") leaked
|
||||||
|
// cookies over plain HTTP on an appliance that forgot to set NODE_ENV — this pins the
|
||||||
|
// corrected matrix.
|
||||||
|
|
||||||
|
let savedCookieSecure: string | undefined;
|
||||||
|
let savedNodeEnv: string | undefined;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
savedCookieSecure = process.env.COOKIE_SECURE;
|
||||||
|
savedNodeEnv = process.env.NODE_ENV;
|
||||||
|
delete process.env.COOKIE_SECURE;
|
||||||
|
delete process.env.NODE_ENV;
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
restore("COOKIE_SECURE", savedCookieSecure);
|
||||||
|
restore("NODE_ENV", savedNodeEnv);
|
||||||
|
});
|
||||||
|
function restore(key: string, val: string | undefined) {
|
||||||
|
if (val === undefined) delete process.env[key];
|
||||||
|
else process.env[key] = val;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("secureCookies — fail-safe Secure flag", () => {
|
||||||
|
it("defaults to Secure when nothing is set (the appliance-forgot-NODE_ENV case)", () => {
|
||||||
|
expect(secureCookies()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stays Secure in production", () => {
|
||||||
|
process.env.NODE_ENV = "production";
|
||||||
|
expect(secureCookies()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops Secure only for an explicit local-dev NODE_ENV", () => {
|
||||||
|
process.env.NODE_ENV = "development";
|
||||||
|
expect(secureCookies()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("COOKIE_SECURE override wins: falsey values opt OUT", () => {
|
||||||
|
for (const v of ["0", "false", "no", "off", "FALSE", " Off "]) {
|
||||||
|
process.env.COOKIE_SECURE = v;
|
||||||
|
expect(secureCookies(), `COOKIE_SECURE=${JSON.stringify(v)}`).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("COOKIE_SECURE override wins: any other value opts IN (even in dev)", () => {
|
||||||
|
process.env.NODE_ENV = "development";
|
||||||
|
for (const v of ["1", "true", "yes", "on", ""]) {
|
||||||
|
process.env.COOKIE_SECURE = v;
|
||||||
|
expect(secureCookies(), `COOKIE_SECURE=${JSON.stringify(v)}`).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
+22
-3
@@ -50,9 +50,28 @@ export function requireJwtSecret(): string {
|
|||||||
return secret;
|
return secret;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Cookies are secure in production; relaxed for local http dev. */
|
/**
|
||||||
function secureCookies(): boolean {
|
* Whether to set the `Secure` flag on the auth/CSRF cookies. FAIL-SAFE: default is
|
||||||
return process.env.NODE_ENV === "production";
|
* `true` (Secure) — a misconfigured/forgotten env can only ever make cookies MORE
|
||||||
|
* restrictive, never silently drop the flag.
|
||||||
|
*
|
||||||
|
* The previous gate keyed off `NODE_ENV === "production"`, which meant an appliance
|
||||||
|
* deployed without that var leaked cookies over plain HTTP. Now `Secure` is the
|
||||||
|
* default and is dropped ONLY for an explicit, deliberate opt-out — `COOKIE_SECURE`
|
||||||
|
* set to a falsey value (`0/false/no/off`), or the legacy `NODE_ENV !== production`
|
||||||
|
* signal kept as a fallback so existing dev setups still work over http://localhost.
|
||||||
|
*
|
||||||
|
* The parking appliance often serves the SPA same-origin over the LAN with no TLS;
|
||||||
|
* THAT box sets `COOKIE_SECURE=0` on purpose (a Secure cookie would never be sent
|
||||||
|
* over its http origin and would lock operators out). Everything else stays secure.
|
||||||
|
*/
|
||||||
|
export function secureCookies(): boolean {
|
||||||
|
const override = process.env.COOKIE_SECURE;
|
||||||
|
if (override !== undefined) {
|
||||||
|
return !/^(0|false|no|off)$/i.test(override.trim());
|
||||||
|
}
|
||||||
|
// No explicit override: secure unless this is an obvious local-dev run.
|
||||||
|
return process.env.NODE_ENV !== "development";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function newCsrfToken(): string {
|
export function newCsrfToken(): string {
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { validateTicketCode } from "./entry-flow.js";
|
||||||
|
|
||||||
|
// validateTicketCode is the manual-entry typo guard: an all-digit code whose last digit
|
||||||
|
// is the Luhn check of the rest. The booth uses it to reject a mistyped ticket up front
|
||||||
|
// (instead of a confusing "session not found"). The capacity-gate / print-hold / sign-
|
||||||
|
// before-open paths of EntryFlow need device fakes and are exercised in the device +
|
||||||
|
// route phases; here we pin the pure, exported checksum contract.
|
||||||
|
|
||||||
|
describe("validateTicketCode (Luhn)", () => {
|
||||||
|
it("accepts a well-formed 11-digit id", () => {
|
||||||
|
// 10-digit body + its Luhn check digit. 0000000000 → check digit 0.
|
||||||
|
expect(validateTicketCode("00000000000")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a single-digit typo", () => {
|
||||||
|
expect(validateTicketCode("00000000000")).toBe(true);
|
||||||
|
expect(validateTicketCode("00000000010")).toBe(false); // flipped a digit, checksum now wrong
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects non-digit and out-of-length strings", () => {
|
||||||
|
expect(validateTicketCode("abc")).toBe(false);
|
||||||
|
expect(validateTicketCode("123")).toBe(false); // too short
|
||||||
|
expect(validateTicketCode("123456789012345")).toBe(false); // too long
|
||||||
|
expect(validateTicketCode("")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round-trips a generated body+check (Luhn is self-consistent)", () => {
|
||||||
|
// Construct a valid code: pick a body, compute its check the same way the issuer does.
|
||||||
|
const body = "4992739871";
|
||||||
|
// brute the check digit 0..9 — exactly one makes a valid code.
|
||||||
|
const valid = Array.from({ length: 10 }, (_, d) => body + d).filter(validateTicketCode);
|
||||||
|
expect(valid).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts a legacy 13-digit id shape", () => {
|
||||||
|
// 12-digit body 000000000000 → check 0; the validator is length-agnostic in 10..14.
|
||||||
|
expect(validateTicketCode("0000000000000")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { createTestDb } from "@parking/db/testing";
|
||||||
|
import { ledgerEvents, eq, type Db } from "@parking/db";
|
||||||
|
import { EventLog, canonicalize, hashEvent } from "./event-log.js";
|
||||||
|
import { SoftwareSigner, buildVerifier } from "./signer.js";
|
||||||
|
|
||||||
|
// The append-only, hash-chained, signed event log is THE anti-fraud primitive
|
||||||
|
// (threat model: the operator at the booth). These tests pin every integrity rule:
|
||||||
|
// monotonic index, prevHash linkage, payload-in-signature, and that verifyChain()
|
||||||
|
// catches each class of tamper (content edit, reorder, deletion gap, forged sig,
|
||||||
|
// missing key). No live DB is touched — a fresh in-memory SQLite per test.
|
||||||
|
|
||||||
|
const SECRET = "test-event-signing-key-0123456789";
|
||||||
|
|
||||||
|
let db: Db;
|
||||||
|
let close: () => void;
|
||||||
|
let log: EventLog;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
const t = createTestDb();
|
||||||
|
db = t.db;
|
||||||
|
close = t.close;
|
||||||
|
log = new EventLog(db, new SoftwareSigner(SECRET), buildVerifier);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => close());
|
||||||
|
|
||||||
|
describe("EventLog.append — chain construction", () => {
|
||||||
|
it("assigns a monotonic index starting at 1", async () => {
|
||||||
|
const a = await log.append({ type: "vehicle_entry", identity: "T1" });
|
||||||
|
const b = await log.append({ type: "vehicle_exit", identity: "T1" });
|
||||||
|
expect(a.index).toBe(1);
|
||||||
|
expect(b.index).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("genesis event has a null prevHash; the next chains to it", async () => {
|
||||||
|
const a = await log.append({ type: "vehicle_entry", identity: "T1" });
|
||||||
|
const b = await log.append({ type: "vehicle_exit", identity: "T1" });
|
||||||
|
expect(a.prevHash).toBeNull();
|
||||||
|
expect(b.prevHash).toBe(hashEvent(canonicalize(a)));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("signs each row under the active keyId", async () => {
|
||||||
|
const row = await log.append({ type: "payment", identity: "T1", payload: { amountMinor: 100 } });
|
||||||
|
expect(row.keyId).toBe("sw-hmac-v2");
|
||||||
|
expect(new SoftwareSigner(SECRET).verify(canonicalize(row), row.signature)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serializes concurrent appends without index collisions", async () => {
|
||||||
|
const rows = await Promise.all(
|
||||||
|
Array.from({ length: 25 }, (_, i) => log.append({ type: "vehicle_entry", identity: `T${i}` })),
|
||||||
|
);
|
||||||
|
const indices = rows.map((r) => r.index).sort((a, b) => a - b);
|
||||||
|
expect(indices).toEqual(Array.from({ length: 25 }, (_, i) => i + 1));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("EventLog.verifyChain — integrity", () => {
|
||||||
|
async function seed() {
|
||||||
|
await log.append({ type: "vehicle_entry", identity: "T1", direction: "entry" });
|
||||||
|
await log.append({ type: "payment", identity: "T1", payload: { amountMinor: 200, tariffVersionId: "tv1" } });
|
||||||
|
await log.append({ type: "vehicle_exit", identity: "T1", direction: "exit" });
|
||||||
|
}
|
||||||
|
|
||||||
|
it("accepts an untampered chain", async () => {
|
||||||
|
await seed();
|
||||||
|
expect(log.verifyChain()).toEqual({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts an empty chain", () => {
|
||||||
|
expect(log.verifyChain()).toEqual({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects a tampered payload (the money amount)", async () => {
|
||||||
|
await seed();
|
||||||
|
// Rewrite the payment amount directly in the DB — exactly the booth-operator
|
||||||
|
// fraud the signed payload defends against.
|
||||||
|
db.update(ledgerEvents).set({ payload: { amountMinor: 1, tariffVersionId: "tv1" } }).where(eq(ledgerEvents.index, 2)).run();
|
||||||
|
const r = log.verifyChain();
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
if (!r.ok) {
|
||||||
|
expect(r.index).toBe(2);
|
||||||
|
expect(r.reason).toMatch(/signature invalid/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects a deleted row as an index gap", async () => {
|
||||||
|
await seed();
|
||||||
|
db.delete(ledgerEvents).where(eq(ledgerEvents.index, 2)).run();
|
||||||
|
const r = log.verifyChain();
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
if (!r.ok) expect(r.reason).toMatch(/index gap/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects a broken prevHash link (reordering / re-chaining)", async () => {
|
||||||
|
await seed();
|
||||||
|
db.update(ledgerEvents).set({ prevHash: "0".repeat(64) }).where(eq(ledgerEvents.index, 3)).run();
|
||||||
|
const r = log.verifyChain();
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
if (!r.ok) {
|
||||||
|
expect(r.index).toBe(3);
|
||||||
|
expect(r.reason).toMatch(/prevHash/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects an event signed under a key that is no longer configured", async () => {
|
||||||
|
await seed();
|
||||||
|
// Re-sign row 2 under an unknown keyId — buildVerifier can't resolve it.
|
||||||
|
db.update(ledgerEvents).set({ keyId: "atecc608-slot9" }).where(eq(ledgerEvents.index, 2)).run();
|
||||||
|
const r = log.verifyChain();
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
if (!r.ok) expect(r.reason).toMatch(/no signer for keyId/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("canonicalize — byte-stability", () => {
|
||||||
|
it("is independent of payload key order (sorted recursively)", () => {
|
||||||
|
const base = { index: 1, type: "payment", direction: null, source: null, identity: "T1", occurredAt: "2026-06-21T10:00:00.000Z", prevHash: null };
|
||||||
|
const a = canonicalize({ ...base, payload: { amountMinor: 100, tariffVersionId: "tv1" } });
|
||||||
|
const b = canonicalize({ ...base, payload: { tariffVersionId: "tv1", amountMinor: 100 } });
|
||||||
|
expect(a).toBe(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("changes when any signed field changes", () => {
|
||||||
|
const base = { index: 1, type: "payment" as const, direction: null, source: null, identity: "T1", payload: { amountMinor: 100 }, occurredAt: "2026-06-21T10:00:00.000Z", prevHash: null };
|
||||||
|
expect(canonicalize(base)).not.toBe(canonicalize({ ...base, payload: { amountMinor: 101 } }));
|
||||||
|
expect(canonicalize(base)).not.toBe(canonicalize({ ...base, identity: "T2" }));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { createTestDb } from "@parking/db/testing";
|
||||||
|
import { ledgerEvents, eq, type Db } from "@parking/db";
|
||||||
|
import { ExitFlow } from "./exit-flow.js";
|
||||||
|
import { PayStation } from "./pay-station.js";
|
||||||
|
import type { EventLog } from "./event-log.js";
|
||||||
|
import { makeLog, silentLogger, seedTariff, minutesAgo } from "./test-helpers.js";
|
||||||
|
|
||||||
|
// The exit flow is the anti-fraud GATE: no car leaves without a covering payment within
|
||||||
|
// the walk-back grace (the no-unpaid-bypass + no-free-overstay rules), and the booth has
|
||||||
|
// no bypass. With no relay configured a clean exit returns { opened:false } — we assert
|
||||||
|
// the DECISION (refuse vs. sign the exit), not the hardware open.
|
||||||
|
|
||||||
|
let db: Db;
|
||||||
|
let close: () => void;
|
||||||
|
let log: EventLog;
|
||||||
|
let exit: ExitFlow;
|
||||||
|
let pay: PayStation;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
const t = createTestDb();
|
||||||
|
db = t.db;
|
||||||
|
close = t.close;
|
||||||
|
log = makeLog(db);
|
||||||
|
exit = new ExitFlow(db, log, silentLogger());
|
||||||
|
pay = new PayStation(db, log, silentLogger());
|
||||||
|
});
|
||||||
|
afterEach(() => close());
|
||||||
|
|
||||||
|
async function enter(identity: string, enteredAt: string, payload?: Record<string, unknown>) {
|
||||||
|
await log.append({ type: "vehicle_entry", direction: "entry", identity, occurredAt: enteredAt, payload: payload ?? null });
|
||||||
|
}
|
||||||
|
function exitsSigned(identity: string) {
|
||||||
|
return db.select().from(ledgerEvents).where(eq(ledgerEvents.identity, identity)).all().filter((r) => r.type === "vehicle_exit");
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("exitForBooth — refusal gates", () => {
|
||||||
|
it("refuses an unknown ticket (no session) and signs an anomaly", async () => {
|
||||||
|
const r = await exit.exitForBooth("ghost");
|
||||||
|
expect(r).toMatchObject({ ok: false, status: "no_session" });
|
||||||
|
const anomalies = db.select().from(ledgerEvents).where(eq(ledgerEvents.type, "anomaly")).all();
|
||||||
|
expect(anomalies).toHaveLength(1);
|
||||||
|
expect(exitsSigned("ghost")).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses an UNPAID open session — no exit signed (no-unpaid-bypass)", async () => {
|
||||||
|
seedTariff(db, { pricePerIncrementMinor: 10000 });
|
||||||
|
await enter("T1", minutesAgo(90));
|
||||||
|
const r = await exit.exitForBooth("T1");
|
||||||
|
expect(r).toMatchObject({ ok: false, status: "unpaid" });
|
||||||
|
expect(exitsSigned("T1")).toHaveLength(0); // the car did NOT leave
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a paid session whose walk-back grace has EXPIRED (no free overstay)", async () => {
|
||||||
|
seedTariff(db, { pricePerIncrementMinor: 10000, gracePeriodExitMin: 15 });
|
||||||
|
await enter("T1", minutesAgo(200));
|
||||||
|
// A payment made 60 min ago → its 15-min walk-back grace lapsed long ago.
|
||||||
|
await log.append({
|
||||||
|
type: "payment", source: "manual", identity: "T1", occurredAt: minutesAgo(60),
|
||||||
|
payload: { sessionRef: "T1", amountMinor: 10000, currency: "ALL", tender: "cash", graceExitMin: 15 },
|
||||||
|
});
|
||||||
|
const r = await exit.exitForBooth("T1");
|
||||||
|
expect(r).toMatchObject({ ok: false, status: "grace_expired" });
|
||||||
|
expect(exitsSigned("T1")).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("exitForBooth — valid exit signs the vehicle_exit", () => {
|
||||||
|
it("a paid session within grace signs an exit (opened:false — no relay in tests)", async () => {
|
||||||
|
seedTariff(db, { pricePerIncrementMinor: 10000, gracePeriodExitMin: 15 });
|
||||||
|
await enter("T1", minutesAgo(90));
|
||||||
|
await pay.pay("T1", "cash"); // fresh payment → within grace
|
||||||
|
const r = await exit.exitForBooth("T1");
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
if (r.ok) expect(r.opened).toBe(false); // signed, but no barrier resolves in tests
|
||||||
|
expect(exitsSigned("T1")).toHaveLength(1); // the exit IS on the chain
|
||||||
|
expect(log.verifyChain()).toEqual({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// NB: a subscriber's normal exit runs through SubscriptionFlow (the reader/credential
|
||||||
|
// path), not exitForBooth — the booth's transient exit has no subscription bypass and
|
||||||
|
// applies the same paid/grace gate to any identity it's handed. Asserting that here so
|
||||||
|
// the boundary is explicit: handing a bare occurrence to exitForBooth is refused, and a
|
||||||
|
// subscriber leaves via reopenBarrier (assist) or the subscription reader flow instead.
|
||||||
|
it("does NOT give the booth transient-exit path a subscription bypass", async () => {
|
||||||
|
await enter("SUBSESS-1", minutesAgo(30), { permit: true, permitId: "sub-1" });
|
||||||
|
const r = await exit.exitForBooth("SUBSESS-1");
|
||||||
|
expect(r).toMatchObject({ ok: false, status: "unpaid" });
|
||||||
|
expect(exitsSigned("SUBSESS-1")).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets a prepaid subscriber out via the assist (reopenBarrier) path", async () => {
|
||||||
|
await enter("SUBSESS-1", minutesAgo(30), { permit: true, permitId: "sub-1" });
|
||||||
|
const r = await exit.reopenBarrier("SUBSESS-1", "op1");
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
expect(exitsSigned("SUBSESS-1")).toHaveLength(1); // assist closes the open occurrence
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("reopenBarrier — no unpaid re-open", () => {
|
||||||
|
it("refuses to re-open an unpaid transient session", async () => {
|
||||||
|
seedTariff(db, { pricePerIncrementMinor: 10000 });
|
||||||
|
await enter("T1", minutesAgo(90));
|
||||||
|
const r = await exit.reopenBarrier("T1", "op1");
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
expect(exitsSigned("T1")).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-opening a paid OPEN session also closes it (signs the exit)", async () => {
|
||||||
|
seedTariff(db, { pricePerIncrementMinor: 10000, gracePeriodExitMin: 15 });
|
||||||
|
await enter("T1", minutesAgo(90));
|
||||||
|
await pay.pay("T1", "cash");
|
||||||
|
const r = await exit.reopenBarrier("T1", "op1");
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
// The open session is closed by the human-intervention exit so it leaves the list.
|
||||||
|
expect(exitsSigned("T1")).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { createTestDb } from "@parking/db/testing";
|
||||||
|
import { ledgerEvents, siteConfig, subscriptions, type Db } from "@parking/db";
|
||||||
|
import { getOccupancy, occupancyCount, reservedSubscriberSpots } from "./occupancy.js";
|
||||||
|
|
||||||
|
// Occupancy is a FOLD over the signed ledger, never a stored counter. These tests
|
||||||
|
// pin: the entries-minus-exits count, the capacity/full gate, and the reserved-
|
||||||
|
// subscriber-spots model (its trickiest invariant — never double-count a parked
|
||||||
|
// subscriber, and never gate the subscriber's own entry).
|
||||||
|
|
||||||
|
let db: Db;
|
||||||
|
let close: () => void;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
const t = createTestDb();
|
||||||
|
db = t.db;
|
||||||
|
close = t.close;
|
||||||
|
});
|
||||||
|
afterEach(() => close());
|
||||||
|
|
||||||
|
// Insert a ledger row directly (these fns read raw rows; signing is event-log's job).
|
||||||
|
let idx = 0;
|
||||||
|
function entry(identity: string, payload?: Record<string, unknown>) {
|
||||||
|
idx += 1;
|
||||||
|
db.insert(ledgerEvents).values({
|
||||||
|
id: `e${idx}`, index: idx, type: "vehicle_entry", direction: "entry",
|
||||||
|
identity, payload: payload ?? null, occurredAt: new Date().toISOString(),
|
||||||
|
signature: "x", keyId: "test",
|
||||||
|
}).run();
|
||||||
|
}
|
||||||
|
function exit(identity: string) {
|
||||||
|
idx += 1;
|
||||||
|
db.insert(ledgerEvents).values({
|
||||||
|
id: `e${idx}`, index: idx, type: "vehicle_exit", direction: "exit",
|
||||||
|
identity, payload: null, occurredAt: new Date().toISOString(),
|
||||||
|
signature: "x", keyId: "test",
|
||||||
|
}).run();
|
||||||
|
}
|
||||||
|
function setSite(v: Partial<typeof siteConfig.$inferInsert>) {
|
||||||
|
db.insert(siteConfig).values({ id: 1, ...v }).onConflictDoUpdate({ target: siteConfig.id, set: v }).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("occupancyCount", () => {
|
||||||
|
beforeEach(() => { idx = 0; });
|
||||||
|
|
||||||
|
it("is 0 with no events", () => {
|
||||||
|
expect(occupancyCount(db)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("counts open sessions (entries minus matching exits)", () => {
|
||||||
|
entry("A"); entry("B"); entry("C");
|
||||||
|
exit("B");
|
||||||
|
expect(occupancyCount(db)).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a re-entry after exit counts again", () => {
|
||||||
|
entry("A"); exit("A"); entry("A");
|
||||||
|
expect(occupancyCount(db)).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getOccupancy — capacity + full gate", () => {
|
||||||
|
beforeEach(() => { idx = 0; });
|
||||||
|
|
||||||
|
it("uncapped: never full, free/effectiveFree null", () => {
|
||||||
|
setSite({ capacity: null });
|
||||||
|
entry("A");
|
||||||
|
const o = getOccupancy(db);
|
||||||
|
expect(o.full).toBe(false);
|
||||||
|
expect(o.free).toBeNull();
|
||||||
|
expect(o.effectiveFree).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("capped: full when count reaches capacity", () => {
|
||||||
|
setSite({ capacity: 2 });
|
||||||
|
entry("A");
|
||||||
|
expect(getOccupancy(db).full).toBe(false);
|
||||||
|
entry("B");
|
||||||
|
const o = getOccupancy(db);
|
||||||
|
expect(o.full).toBe(true);
|
||||||
|
expect(o.free).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("reservedSubscriberSpots", () => {
|
||||||
|
beforeEach(() => { idx = 0; });
|
||||||
|
|
||||||
|
function addSub(id: string, opts: Partial<typeof subscriptions.$inferInsert> = {}) {
|
||||||
|
db.insert(subscriptions).values({ id, status: "active", quantity: 1, period: "month", ...opts }).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
it("is 0 when the toggle is off (default)", () => {
|
||||||
|
setSite({ capacity: 10, reserveSubscriberSpots: false });
|
||||||
|
addSub("s1", { quantity: 2 });
|
||||||
|
expect(reservedSubscriberSpots(db)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("holds quantity spots for an active, not-parked subscription", () => {
|
||||||
|
setSite({ capacity: 10, reserveSubscriberSpots: true });
|
||||||
|
addSub("s1", { quantity: 2 });
|
||||||
|
expect(reservedSubscriberSpots(db)).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT double-count a subscriber already parked (holds only the rest)", () => {
|
||||||
|
setSite({ capacity: 10, reserveSubscriberSpots: true });
|
||||||
|
addSub("s1", { quantity: 2 });
|
||||||
|
// One of the family's two cars is inside (occurrence entry carries permitId = sub id).
|
||||||
|
entry("SUBSESS-1", { permitId: "s1" });
|
||||||
|
expect(reservedSubscriberSpots(db)).toBe(1); // 2 quantity − 1 inside
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores suspended/revoked and out-of-window subscriptions", () => {
|
||||||
|
setSite({ capacity: 10, reserveSubscriberSpots: true });
|
||||||
|
addSub("active", { quantity: 1 });
|
||||||
|
addSub("suspended", { quantity: 5, status: "suspended" });
|
||||||
|
addSub("expired", { quantity: 5, validTo: "2000-01-01T00:00:00.000Z" });
|
||||||
|
expect(reservedSubscriberSpots(db)).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getOccupancy — reserved tightens the transient gate", () => {
|
||||||
|
beforeEach(() => { idx = 0; });
|
||||||
|
|
||||||
|
it("transient sees full once count + reserved ≥ capacity", () => {
|
||||||
|
setSite({ capacity: 3, reserveSubscriberSpots: true });
|
||||||
|
db.insert(subscriptions).values({ id: "s1", status: "active", quantity: 2, period: "month" }).run();
|
||||||
|
entry("A"); // 1 inside + 2 reserved = 3 ≥ capacity 3
|
||||||
|
const o = getOccupancy(db);
|
||||||
|
expect(o.reserved).toBe(2);
|
||||||
|
expect(o.effectiveFree).toBe(0);
|
||||||
|
expect(o.full).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { createTestDb } from "@parking/db/testing";
|
||||||
|
import { ledgerEvents, eq, type Db } from "@parking/db";
|
||||||
|
import { PayStation, NoOpenSessionError, NoTariffError } from "./pay-station.js";
|
||||||
|
import type { EventLog } from "./event-log.js";
|
||||||
|
import { makeLog, silentLogger, seedTariff, minutesAgo } from "./test-helpers.js";
|
||||||
|
|
||||||
|
// The pay station prices an open session against the tariff frozen at entry and writes
|
||||||
|
// a SIGNED payment event (never a mutable "paid" flag). These tests pin the quote math,
|
||||||
|
// the signed-payment side effect, the no-session / no-tariff errors, and the lookup
|
||||||
|
// view the booth modal reads.
|
||||||
|
|
||||||
|
let db: Db;
|
||||||
|
let close: () => void;
|
||||||
|
let log: EventLog;
|
||||||
|
let pay: PayStation;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
const t = createTestDb();
|
||||||
|
db = t.db;
|
||||||
|
close = t.close;
|
||||||
|
log = makeLog(db);
|
||||||
|
pay = new PayStation(db, log, silentLogger());
|
||||||
|
});
|
||||||
|
afterEach(() => close());
|
||||||
|
|
||||||
|
async function enter(identity: string, enteredAt: string, payload?: Record<string, unknown>) {
|
||||||
|
await log.append({ type: "vehicle_entry", direction: "entry", identity, occurredAt: enteredAt, payload: payload ?? null });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("PayStation.quote", () => {
|
||||||
|
it("throws NoOpenSessionError for an unknown ticket", () => {
|
||||||
|
seedTariff(db);
|
||||||
|
expect(() => pay.quote("nope")).toThrow(NoOpenSessionError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws NoTariffError when no site tariff is configured", async () => {
|
||||||
|
await enter("T1", minutesAgo(120));
|
||||||
|
expect(() => pay.quote("T1")).toThrow(NoTariffError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prices a stay against the frozen tariff (90min → 2 increments at 100/h = 200)", async () => {
|
||||||
|
// 90 min rounds UP to a 2nd 60-min increment; well clear of the boundary so a few
|
||||||
|
// ms of test runtime can't tip it into a 3rd increment.
|
||||||
|
seedTariff(db, { pricePerIncrementMinor: 10000, incrementMin: 60 });
|
||||||
|
await enter("T1", minutesAgo(90));
|
||||||
|
const q = pay.quote("T1");
|
||||||
|
expect(q.amountMinor).toBe(20000);
|
||||||
|
expect(q.currency).toBe("ALL");
|
||||||
|
expect(q.overstay).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prices 0 within the entry grace (quick in-and-out)", async () => {
|
||||||
|
seedTariff(db, { gracePeriodEntryMin: 10 });
|
||||||
|
await enter("T1", minutesAgo(5));
|
||||||
|
expect(pay.quote("T1").amountMinor).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PayStation.pay — signed payment side effect", () => {
|
||||||
|
it("appends a signed payment event carrying amount, currency, tender, grace", async () => {
|
||||||
|
const { currency } = seedTariff(db, { pricePerIncrementMinor: 10000, gracePeriodExitMin: 15 });
|
||||||
|
await enter("T1", minutesAgo(90));
|
||||||
|
|
||||||
|
const res = await pay.pay("T1", "cash");
|
||||||
|
expect(res.amountMinor).toBe(20000);
|
||||||
|
expect(res.currency).toBe(currency);
|
||||||
|
|
||||||
|
const payments = db.select().from(ledgerEvents).where(eq(ledgerEvents.type, "payment")).all();
|
||||||
|
expect(payments).toHaveLength(1);
|
||||||
|
const pl = payments[0].payload as Record<string, unknown>;
|
||||||
|
expect(pl.amountMinor).toBe(20000);
|
||||||
|
expect(pl.tender).toBe("cash");
|
||||||
|
expect(pl.graceExitMin).toBe(15);
|
||||||
|
// It must be a real signed chain event.
|
||||||
|
expect(log.verifyChain()).toEqual({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("honours an operator override amount (lost ticket / dispute)", async () => {
|
||||||
|
seedTariff(db);
|
||||||
|
await enter("T1", minutesAgo(120));
|
||||||
|
const res = await pay.pay("T1", "card", 99900);
|
||||||
|
expect(res.amountMinor).toBe(99900);
|
||||||
|
const pl = db.select().from(ledgerEvents).where(eq(ledgerEvents.type, "payment")).all()[0].payload as Record<string, unknown>;
|
||||||
|
expect(pl.amountMinor).toBe(99900);
|
||||||
|
expect(pl.reason).toBe("operator-set amount");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PayStation.lookup — booth modal view", () => {
|
||||||
|
it("reports not-found for an unknown ticket", () => {
|
||||||
|
const v = pay.lookup("ghost");
|
||||||
|
expect(v.found).toBe(false);
|
||||||
|
expect(v.open).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows an open unpaid transient with the amount owed", async () => {
|
||||||
|
seedTariff(db, { pricePerIncrementMinor: 10000 });
|
||||||
|
await enter("T1", minutesAgo(90));
|
||||||
|
const v = pay.lookup("T1");
|
||||||
|
expect(v.found).toBe(true);
|
||||||
|
expect(v.open).toBe(true);
|
||||||
|
expect(v.paidAt).toBeNull();
|
||||||
|
expect(v.amountMinor).toBe(20000);
|
||||||
|
expect(v.subscription).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("after payment shows paid + within grace, amount cleared", async () => {
|
||||||
|
seedTariff(db, { pricePerIncrementMinor: 10000, gracePeriodExitMin: 15 });
|
||||||
|
await enter("T1", minutesAgo(120));
|
||||||
|
await pay.pay("T1", "cash");
|
||||||
|
const v = pay.lookup("T1");
|
||||||
|
expect(v.paidAt).not.toBeNull();
|
||||||
|
expect(v.withinGrace).toBe(true);
|
||||||
|
expect(v.overstay).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flags a subscription occurrence (prepaid — never a transient charge)", async () => {
|
||||||
|
seedTariff(db);
|
||||||
|
await enter("SUBSESS-1", minutesAgo(120), { permit: true, permitId: "sub-1" });
|
||||||
|
const v = pay.lookup("SUBSESS-1");
|
||||||
|
expect(v.subscription).toBe(true);
|
||||||
|
expect(v.subscriptionId).toBe("sub-1");
|
||||||
|
expect(v.amountMinor).toBeNull(); // no timeframes → nothing owed
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PayStation.activeSessions", () => {
|
||||||
|
it("lists open sessions newest-first and omits exited-past-grace", async () => {
|
||||||
|
seedTariff(db, { pricePerIncrementMinor: 10000 });
|
||||||
|
await enter("OLD", minutesAgo(200));
|
||||||
|
await enter("NEW", minutesAgo(30));
|
||||||
|
const list = pay.activeSessions();
|
||||||
|
expect(list.map((s) => s.identity)).toEqual(["NEW", "OLD"]);
|
||||||
|
expect(list.every((s) => s.open)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { sessions, siteConfig, subscriptions, type Db } from "@parking/db";
|
||||||
|
import { createTestDb } from "@parking/db/testing";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { makeLog } from "./test-helpers.js";
|
||||||
|
import { reportSummary } from "./reports.js";
|
||||||
|
import type { EventLog } from "./event-log.js";
|
||||||
|
|
||||||
|
// Reports aggregation — LEDGER-FIRST. These pin that the numbers an admin sees are
|
||||||
|
// summed straight from the signed ledger (entry/exit counts + payment money, split the
|
||||||
|
// same way the shift Z-report splits it), bucketed in the SITE TIMEZONE, with duration
|
||||||
|
// stats from the closed-sessions cache and subscription counts as of the range end.
|
||||||
|
|
||||||
|
let db: Db;
|
||||||
|
let log: EventLog;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
({ db } = createTestDb());
|
||||||
|
log = makeLog(db);
|
||||||
|
// Fix the site timezone so bucket labels are deterministic regardless of the test host.
|
||||||
|
db.insert(siteConfig).values({ id: 1, timezone: "Europe/Tirane" }).run();
|
||||||
|
});
|
||||||
|
|
||||||
|
/** ISO at a UTC instant, for deterministic bucket assertions. */
|
||||||
|
function at(iso: string): string {
|
||||||
|
return new Date(iso).toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function entry(occurredAt: string): Promise<void> {
|
||||||
|
await log.append({ type: "vehicle_entry", direction: "entry", identity: randomUUID(), occurredAt });
|
||||||
|
}
|
||||||
|
async function exit(occurredAt: string): Promise<void> {
|
||||||
|
await log.append({ type: "vehicle_exit", direction: "exit", identity: randomUUID(), occurredAt });
|
||||||
|
}
|
||||||
|
async function payment(
|
||||||
|
occurredAt: string,
|
||||||
|
amountMinor: number,
|
||||||
|
opts: { tender?: "cash" | "card"; subscriptionSale?: boolean; subscriptionWindowCharge?: boolean } = {},
|
||||||
|
): Promise<void> {
|
||||||
|
await log.append({
|
||||||
|
type: "payment",
|
||||||
|
occurredAt,
|
||||||
|
payload: {
|
||||||
|
amountMinor,
|
||||||
|
currency: "ALL",
|
||||||
|
tender: opts.tender ?? "cash",
|
||||||
|
...(opts.subscriptionSale ? { subscriptionSale: true } : {}),
|
||||||
|
...(opts.subscriptionWindowCharge ? { subscriptionWindowCharge: true } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const RANGE = { from: at("2026-06-01T00:00:00Z"), to: at("2026-06-30T23:59:59Z") };
|
||||||
|
|
||||||
|
describe("reportSummary — ledger-first totals", () => {
|
||||||
|
it("counts entries and exits from the signed ledger", async () => {
|
||||||
|
await entry(at("2026-06-10T08:00:00Z"));
|
||||||
|
await entry(at("2026-06-10T09:00:00Z"));
|
||||||
|
await exit(at("2026-06-10T18:00:00Z"));
|
||||||
|
|
||||||
|
const r = reportSummary(db, { ...RANGE, bucket: "day" });
|
||||||
|
expect(r.totals.entries).toBe(2);
|
||||||
|
expect(r.totals.exits).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("excludes events outside [from, to)", async () => {
|
||||||
|
await entry(at("2026-05-31T23:00:00Z")); // before
|
||||||
|
await entry(at("2026-06-15T10:00:00Z")); // inside
|
||||||
|
const r = reportSummary(db, { ...RANGE, bucket: "day" });
|
||||||
|
expect(r.totals.entries).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sums payment money and splits cash vs card", async () => {
|
||||||
|
await payment(at("2026-06-12T10:00:00Z"), 20000, { tender: "cash" });
|
||||||
|
await payment(at("2026-06-12T11:00:00Z"), 5000, { tender: "card" });
|
||||||
|
const r = reportSummary(db, { ...RANGE, bucket: "day" });
|
||||||
|
expect(r.totals.payments).toBe(2);
|
||||||
|
expect(r.totals.revenueMinor).toBe(25000);
|
||||||
|
expect(r.totals.cashMinor).toBe(20000);
|
||||||
|
expect(r.totals.cardMinor).toBe(5000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("splits revenue into ticket / subscription-sale / out-of-window, mirroring the Z-report", async () => {
|
||||||
|
await payment(at("2026-06-12T10:00:00Z"), 10000); // transient ticket
|
||||||
|
await payment(at("2026-06-12T10:05:00Z"), 30000, { subscriptionSale: true });
|
||||||
|
await payment(at("2026-06-12T10:06:00Z"), 1500, { subscriptionWindowCharge: true });
|
||||||
|
const r = reportSummary(db, { ...RANGE, bucket: "day" });
|
||||||
|
expect(r.totals.ticketMinor).toBe(10000);
|
||||||
|
expect(r.totals.subscriptionSalesMinor).toBe(30000);
|
||||||
|
expect(r.totals.subscriptionWindowMinor).toBe(1500);
|
||||||
|
// The three add up to the gross revenue.
|
||||||
|
expect(r.totals.revenueMinor).toBe(41500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("picks up the currency from a payment in range", async () => {
|
||||||
|
await payment(at("2026-06-12T10:00:00Z"), 10000);
|
||||||
|
const r = reportSummary(db, { ...RANGE, bucket: "day" });
|
||||||
|
expect(r.currency).toBe("ALL");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("reportSummary — time bucketing (site timezone)", () => {
|
||||||
|
it("buckets by local day; a 23:30 UTC event lands on the NEXT local day in Tirane (UTC+2/3)", async () => {
|
||||||
|
// 2026-06-15T23:30Z is 2026-06-16 01:30 local (summer, UTC+2) → the 16th bucket.
|
||||||
|
await entry(at("2026-06-15T23:30:00Z"));
|
||||||
|
const r = reportSummary(db, { ...RANGE, bucket: "day" });
|
||||||
|
const point = r.series.find((p) => p.entries > 0);
|
||||||
|
expect(point?.bucket).toBe("2026-06-16");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("series points are sorted and carry per-bucket entries/exits/revenue", async () => {
|
||||||
|
await entry(at("2026-06-10T08:00:00Z"));
|
||||||
|
await payment(at("2026-06-10T09:00:00Z"), 7000);
|
||||||
|
await entry(at("2026-06-12T08:00:00Z"));
|
||||||
|
const r = reportSummary(db, { ...RANGE, bucket: "day" });
|
||||||
|
const labels = r.series.map((p) => p.bucket);
|
||||||
|
expect(labels).toEqual([...labels].sort());
|
||||||
|
const d10 = r.series.find((p) => p.bucket === "2026-06-10");
|
||||||
|
expect(d10?.entries).toBe(1);
|
||||||
|
expect(d10?.revenueMinor).toBe(7000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("entriesByHour is a 24-slot local-hour histogram", async () => {
|
||||||
|
// 06:00Z = 08:00 local (summer) → hour slot 8.
|
||||||
|
await entry(at("2026-06-10T06:00:00Z"));
|
||||||
|
await entry(at("2026-06-11T06:00:00Z"));
|
||||||
|
const r = reportSummary(db, { ...RANGE, bucket: "day" });
|
||||||
|
expect(r.entriesByHour).toHaveLength(24);
|
||||||
|
expect(r.entriesByHour[8]).toBe(2);
|
||||||
|
expect(r.entriesByHour.reduce((a, b) => a + b, 0)).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("reportSummary — duration (sessions cache) + subscriptions", () => {
|
||||||
|
it("computes parked-minute stats from closed sessions whose exit fell in range", async () => {
|
||||||
|
// 60-min and 120-min stays → avg 90, median 90.
|
||||||
|
db.insert(sessions).values({
|
||||||
|
id: "s1",
|
||||||
|
identity: "t1",
|
||||||
|
enteredAt: at("2026-06-10T08:00:00Z"),
|
||||||
|
exitedAt: at("2026-06-10T09:00:00Z"),
|
||||||
|
state: "closed",
|
||||||
|
}).run();
|
||||||
|
db.insert(sessions).values({
|
||||||
|
id: "s2",
|
||||||
|
identity: "t2",
|
||||||
|
enteredAt: at("2026-06-10T08:00:00Z"),
|
||||||
|
exitedAt: at("2026-06-10T10:00:00Z"),
|
||||||
|
state: "closed",
|
||||||
|
}).run();
|
||||||
|
// An OPEN session (no exit) must not count.
|
||||||
|
db.insert(sessions).values({ id: "s3", identity: "t3", enteredAt: at("2026-06-10T08:00:00Z"), state: "open" }).run();
|
||||||
|
|
||||||
|
const r = reportSummary(db, { ...RANGE, bucket: "day" });
|
||||||
|
expect(r.totals.closedSessions).toBe(2);
|
||||||
|
expect(r.totals.totalParkedMinutes).toBe(180);
|
||||||
|
expect(r.totals.avgParkedMinutes).toBe(90);
|
||||||
|
expect(r.totals.medianParkedMinutes).toBe(90);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("counts subscriptions by status and currently-valid coverage as of `to`", async () => {
|
||||||
|
const base = { holderName: "x", period: "month" as const, createdAt: at("2026-06-01T00:00:00Z") };
|
||||||
|
// active + valid window covering `to`, quantity 2.
|
||||||
|
db.insert(subscriptions).values({
|
||||||
|
id: "a", status: "active", quantity: 2,
|
||||||
|
validFrom: at("2026-06-01T00:00:00Z"), validTo: at("2026-07-01T00:00:00Z"), ...base,
|
||||||
|
}).run();
|
||||||
|
// active but EXPIRED before `to` → not currently valid.
|
||||||
|
db.insert(subscriptions).values({
|
||||||
|
id: "b", status: "active", quantity: 1,
|
||||||
|
validFrom: at("2026-05-01T00:00:00Z"), validTo: at("2026-06-05T00:00:00Z"), ...base,
|
||||||
|
}).run();
|
||||||
|
// suspended.
|
||||||
|
db.insert(subscriptions).values({ id: "c", status: "suspended", quantity: 1, ...base }).run();
|
||||||
|
|
||||||
|
const r = reportSummary(db, { ...RANGE, bucket: "day" });
|
||||||
|
expect(r.subscriptions.active).toBe(2);
|
||||||
|
expect(r.subscriptions.suspended).toBe(1);
|
||||||
|
expect(r.subscriptions.revoked).toBe(0);
|
||||||
|
expect(r.subscriptions.currentlyValid).toBe(1);
|
||||||
|
expect(r.subscriptions.coveredCars).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
import {
|
||||||
|
and,
|
||||||
|
asc,
|
||||||
|
desc,
|
||||||
|
eq,
|
||||||
|
gte,
|
||||||
|
lte,
|
||||||
|
ledgerEvents,
|
||||||
|
sessions,
|
||||||
|
subscriptions,
|
||||||
|
tariffVersions,
|
||||||
|
tariffs,
|
||||||
|
type Db,
|
||||||
|
} from "@parking/db";
|
||||||
|
import { siteTz } from "./subscription-window.js";
|
||||||
|
|
||||||
|
// Admin reporting — LEDGER-FIRST aggregation (decision 2026-06-22). The numbers an
|
||||||
|
// admin sees on the Reports page are summed from the SIGNED, hash-chained
|
||||||
|
// ledger_events (vehicle_entry/exit + payment), the same source the shift Z-report
|
||||||
|
// reconciles against — so a chart total always ties out to the drawer. Only the
|
||||||
|
// duration/occupancy view leans on the derived `sessions` cache, where the ledger is
|
||||||
|
// awkward (you'd have to pair every entry with its exit by hand); that's flagged as a
|
||||||
|
// cache, not the financial truth. See wiki/concepts/reports.md, event-streams-split.md.
|
||||||
|
//
|
||||||
|
// All bucketing is in the SITE TIMEZONE (siteConfig.timezone) — a "day" is a local
|
||||||
|
// calendar day, not a UTC one, so a 01:00-local payment lands on the right date and the
|
||||||
|
// peak-hour chart reads in wall-clock. Pure date math on the stored ISO strings; no
|
||||||
|
// floats (money is integer minor units throughout).
|
||||||
|
|
||||||
|
export type Bucket = "hour" | "day" | "month";
|
||||||
|
|
||||||
|
export interface ReportQuery {
|
||||||
|
/** Inclusive lower bound (ISO instant). */
|
||||||
|
readonly from: string;
|
||||||
|
/** Exclusive upper bound (ISO instant). */
|
||||||
|
readonly to: string;
|
||||||
|
/** Time grain for the series. Default "day". */
|
||||||
|
readonly bucket: Bucket;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One point in a time series, keyed by its local-time bucket label (e.g. "2026-06-22"
|
||||||
|
* for a day, "2026-06-22 14" for an hour). */
|
||||||
|
export interface SeriesPoint {
|
||||||
|
readonly bucket: string;
|
||||||
|
readonly entries: number;
|
||||||
|
readonly exits: number;
|
||||||
|
/** Net transient revenue collected in the bucket (minor units), all tenders. */
|
||||||
|
readonly revenueMinor: number;
|
||||||
|
/** Payment COUNT in the bucket (transactions, not amount). */
|
||||||
|
readonly payments: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReportTotals {
|
||||||
|
readonly entries: number;
|
||||||
|
readonly exits: number;
|
||||||
|
readonly payments: number;
|
||||||
|
readonly revenueMinor: number;
|
||||||
|
readonly cashMinor: number;
|
||||||
|
readonly cardMinor: number;
|
||||||
|
/** Revenue split by what was sold. ticket = transient parking; subscriptionSales =
|
||||||
|
* new/renewed subscriptions; subscriptionWindow = out-of-window tariff-bridge charges. */
|
||||||
|
readonly ticketMinor: number;
|
||||||
|
readonly subscriptionSalesMinor: number;
|
||||||
|
readonly subscriptionWindowMinor: number;
|
||||||
|
/** Closed transient sessions in range + their parked-minutes stats (from the cache). */
|
||||||
|
readonly closedSessions: number;
|
||||||
|
readonly totalParkedMinutes: number;
|
||||||
|
readonly avgParkedMinutes: number;
|
||||||
|
readonly medianParkedMinutes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubscriptionStats {
|
||||||
|
readonly active: number;
|
||||||
|
readonly suspended: number;
|
||||||
|
readonly revoked: number;
|
||||||
|
/** Active subscriptions whose window covers `to` (the report's "now"). */
|
||||||
|
readonly currentlyValid: number;
|
||||||
|
/** Cars covered by currently-valid subscriptions (Σ quantity). */
|
||||||
|
readonly coveredCars: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReportSummary {
|
||||||
|
readonly from: string;
|
||||||
|
readonly to: string;
|
||||||
|
readonly bucket: Bucket;
|
||||||
|
readonly tz: string;
|
||||||
|
readonly currency: string | null;
|
||||||
|
readonly totals: ReportTotals;
|
||||||
|
readonly series: SeriesPoint[];
|
||||||
|
/** Entries by local hour-of-day (0–23), summed across the range — the peak-hour view. */
|
||||||
|
readonly entriesByHour: number[];
|
||||||
|
readonly subscriptions: SubscriptionStats;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Local wall-clock parts of an ISO instant in a given IANA tz. Reuses Intl (no dep). */
|
||||||
|
function localParts(iso: string, tz: string): { y: number; mo: number; d: number; h: number } {
|
||||||
|
const fmt = new Intl.DateTimeFormat("en-CA", {
|
||||||
|
timeZone: tz,
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
hourCycle: "h23",
|
||||||
|
});
|
||||||
|
const parts = Object.fromEntries(fmt.formatToParts(new Date(iso)).map((p) => [p.type, p.value]));
|
||||||
|
return {
|
||||||
|
y: Number(parts.year),
|
||||||
|
mo: Number(parts.month),
|
||||||
|
d: Number(parts.day),
|
||||||
|
h: Number(parts.hour),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bucket label for an instant at the chosen grain, in local time. Sorts lexically. */
|
||||||
|
function bucketLabel(iso: string, tz: string, bucket: Bucket): string {
|
||||||
|
const p = localParts(iso, tz);
|
||||||
|
const mo = String(p.mo).padStart(2, "0");
|
||||||
|
const d = String(p.d).padStart(2, "0");
|
||||||
|
const h = String(p.h).padStart(2, "0");
|
||||||
|
if (bucket === "month") return `${p.y}-${mo}`;
|
||||||
|
if (bucket === "hour") return `${p.y}-${mo}-${d} ${h}`;
|
||||||
|
return `${p.y}-${mo}-${d}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PaymentPayload {
|
||||||
|
amountMinor?: number;
|
||||||
|
currency?: string;
|
||||||
|
tender?: "cash" | "card";
|
||||||
|
subscriptionSale?: boolean;
|
||||||
|
subscriptionWindowCharge?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function median(sorted: number[]): number {
|
||||||
|
if (sorted.length === 0) return 0;
|
||||||
|
const mid = Math.floor(sorted.length / 2);
|
||||||
|
const hi = sorted[mid] ?? 0;
|
||||||
|
if (sorted.length % 2) return hi;
|
||||||
|
const lo = sorted[mid - 1] ?? 0;
|
||||||
|
return Math.round((lo + hi) / 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the admin report summary for [from, to) at the chosen grain. Entry/exit counts
|
||||||
|
* and money are summed from the signed ledger; duration stats from the closed sessions
|
||||||
|
* in range; subscription counts from the subscriptions table as of `to`.
|
||||||
|
*/
|
||||||
|
export function reportSummary(db: Db, q: ReportQuery): ReportSummary {
|
||||||
|
const tz = siteTz(db);
|
||||||
|
|
||||||
|
// --- Ledger: entry/exit/payment in range, oldest-first so the series builds in order.
|
||||||
|
const rows = db
|
||||||
|
.select()
|
||||||
|
.from(ledgerEvents)
|
||||||
|
.where(and(gte(ledgerEvents.occurredAt, q.from), lte(ledgerEvents.occurredAt, q.to)))
|
||||||
|
.orderBy(asc(ledgerEvents.index))
|
||||||
|
.all();
|
||||||
|
|
||||||
|
// Currency for display: money everywhere is { minorUnits, currency }; payments carry
|
||||||
|
// the currency they were taken in, so take it from a payment in range (then fall back
|
||||||
|
// to the active tariff version). Reports never mix currencies (single-currency site).
|
||||||
|
let currency: string | null = null;
|
||||||
|
|
||||||
|
const seriesMap = new Map<string, SeriesPoint>();
|
||||||
|
const entriesByHour = new Array<number>(24).fill(0);
|
||||||
|
const totals = {
|
||||||
|
entries: 0,
|
||||||
|
exits: 0,
|
||||||
|
payments: 0,
|
||||||
|
revenueMinor: 0,
|
||||||
|
cashMinor: 0,
|
||||||
|
cardMinor: 0,
|
||||||
|
ticketMinor: 0,
|
||||||
|
subscriptionSalesMinor: 0,
|
||||||
|
subscriptionWindowMinor: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
function point(label: string): SeriesPoint {
|
||||||
|
let p = seriesMap.get(label);
|
||||||
|
if (!p) {
|
||||||
|
p = { bucket: label, entries: 0, exits: 0, revenueMinor: 0, payments: 0 };
|
||||||
|
seriesMap.set(label, p);
|
||||||
|
}
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const label = bucketLabel(row.occurredAt, tz, q.bucket);
|
||||||
|
const p = point(label) as { -readonly [K in keyof SeriesPoint]: SeriesPoint[K] };
|
||||||
|
if (row.type === "vehicle_entry") {
|
||||||
|
totals.entries++;
|
||||||
|
p.entries++;
|
||||||
|
const h = localParts(row.occurredAt, tz).h;
|
||||||
|
entriesByHour[h] = (entriesByHour[h] ?? 0) + 1;
|
||||||
|
} else if (row.type === "vehicle_exit") {
|
||||||
|
totals.exits++;
|
||||||
|
p.exits++;
|
||||||
|
} else if (row.type === "payment") {
|
||||||
|
const pl = (row.payload ?? {}) as PaymentPayload;
|
||||||
|
const amt = typeof pl.amountMinor === "number" ? pl.amountMinor : 0;
|
||||||
|
if (!currency && typeof pl.currency === "string") currency = pl.currency;
|
||||||
|
totals.payments++;
|
||||||
|
totals.revenueMinor += amt;
|
||||||
|
p.payments++;
|
||||||
|
p.revenueMinor += amt;
|
||||||
|
if (pl.tender === "card") totals.cardMinor += amt;
|
||||||
|
else totals.cashMinor += amt;
|
||||||
|
// Revenue split mirrors the shift Z-report: subscription sale / window charge /
|
||||||
|
// (the rest is) transient ticket revenue.
|
||||||
|
if (pl.subscriptionSale === true) totals.subscriptionSalesMinor += amt;
|
||||||
|
else if (pl.subscriptionWindowCharge === true) totals.subscriptionWindowMinor += amt;
|
||||||
|
else totals.ticketMinor += amt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const series = [...seriesMap.values()].sort((a, b) => a.bucket.localeCompare(b.bucket));
|
||||||
|
|
||||||
|
// No payment in range? Fall back to the site tariff's latest version currency, so a
|
||||||
|
// zero-revenue range still labels its money column.
|
||||||
|
if (!currency) {
|
||||||
|
const tariff = db.select().from(tariffs).where(eq(tariffs.scope, "site")).get();
|
||||||
|
if (tariff) {
|
||||||
|
const tv = db
|
||||||
|
.select()
|
||||||
|
.from(tariffVersions)
|
||||||
|
.where(eq(tariffVersions.tariffId, tariff.id))
|
||||||
|
.orderBy(desc(tariffVersions.effectiveFrom))
|
||||||
|
.get();
|
||||||
|
currency = tv?.currency ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Duration: closed transient sessions whose EXIT fell in range (the cache; flagged).
|
||||||
|
const closed = db
|
||||||
|
.select()
|
||||||
|
.from(sessions)
|
||||||
|
.where(and(gte(sessions.exitedAt, q.from), lte(sessions.exitedAt, q.to)))
|
||||||
|
.all();
|
||||||
|
const durations: number[] = [];
|
||||||
|
for (const s of closed) {
|
||||||
|
if (!s.enteredAt || !s.exitedAt) continue;
|
||||||
|
const mins = Math.max(0, Math.round((Date.parse(s.exitedAt) - Date.parse(s.enteredAt)) / 60000));
|
||||||
|
durations.push(mins);
|
||||||
|
}
|
||||||
|
durations.sort((a, b) => a - b);
|
||||||
|
const totalParkedMinutes = durations.reduce((a, b) => a + b, 0);
|
||||||
|
|
||||||
|
// --- Subscriptions: status counts + currently-valid (window covers `to`).
|
||||||
|
const subs = db.select().from(subscriptions).all();
|
||||||
|
const subStats = { active: 0, suspended: 0, revoked: 0, currentlyValid: 0, coveredCars: 0 };
|
||||||
|
for (const s of subs) {
|
||||||
|
if (s.status === "active") subStats.active++;
|
||||||
|
else if (s.status === "suspended") subStats.suspended++;
|
||||||
|
else if (s.status === "revoked") subStats.revoked++;
|
||||||
|
const validNow =
|
||||||
|
s.status === "active" &&
|
||||||
|
(!s.validFrom || s.validFrom <= q.to) &&
|
||||||
|
(!s.validTo || s.validTo >= q.to);
|
||||||
|
if (validNow) {
|
||||||
|
subStats.currentlyValid++;
|
||||||
|
subStats.coveredCars += s.quantity ?? 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
from: q.from,
|
||||||
|
to: q.to,
|
||||||
|
bucket: q.bucket,
|
||||||
|
tz,
|
||||||
|
currency,
|
||||||
|
totals: {
|
||||||
|
...totals,
|
||||||
|
closedSessions: durations.length,
|
||||||
|
totalParkedMinutes,
|
||||||
|
avgParkedMinutes: durations.length ? Math.round(totalParkedMinutes / durations.length) : 0,
|
||||||
|
medianParkedMinutes: median(durations),
|
||||||
|
},
|
||||||
|
series,
|
||||||
|
entriesByHour,
|
||||||
|
subscriptions: subStats,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import type { Db } from "@parking/db";
|
||||||
|
import { requirePermission } from "../auth.js";
|
||||||
|
import { reportSummary, type Bucket } from "../reports.js";
|
||||||
|
|
||||||
|
// Admin reporting API. Read-only aggregation over the signed ledger (+ the sessions
|
||||||
|
// cache for durations); no writes, no new event types. Gated on `report:read` — the
|
||||||
|
// same permission the events feed/occupancy use. See reports.ts, wiki/concepts/reports.md.
|
||||||
|
|
||||||
|
const BUCKETS: Bucket[] = ["hour", "day", "month"];
|
||||||
|
|
||||||
|
/** Clamp a query into a valid [from, to) + bucket. Defaults: last 30 days, daily. */
|
||||||
|
function parseQuery(q: { from?: string; to?: string; bucket?: string }): {
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
bucket: Bucket;
|
||||||
|
} {
|
||||||
|
const now = Date.now();
|
||||||
|
const to = isFiniteIso(q.to) ? q.to! : new Date(now).toISOString();
|
||||||
|
const from = isFiniteIso(q.from) ? q.from! : new Date(now - 30 * 86_400_000).toISOString();
|
||||||
|
const bucket = BUCKETS.includes(q.bucket as Bucket) ? (q.bucket as Bucket) : "day";
|
||||||
|
// Guard the inversion (from after to) — swap rather than return an empty report.
|
||||||
|
return from <= to ? { from, to, bucket } : { from: to, to: from, bucket };
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFiniteIso(s: string | undefined): boolean {
|
||||||
|
return !!s && Number.isFinite(Date.parse(s));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function reportRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||||
|
const guard = requirePermission("report:read");
|
||||||
|
|
||||||
|
// The whole dashboard in one call: totals, the time series, peak-hour histogram, and
|
||||||
|
// subscription stats — aggregated server-side so the SPA just renders. Bucketed in the
|
||||||
|
// site timezone. See reports.ts.
|
||||||
|
app.get<{ Querystring: { from?: string; to?: string; bucket?: string } }>(
|
||||||
|
"/api/reports/summary",
|
||||||
|
{ preHandler: guard },
|
||||||
|
async (req) => reportSummary(db, parseQuery(req.query)),
|
||||||
|
);
|
||||||
|
|
||||||
|
// The same series as CSV (one row per bucket) for spreadsheet / accountant export.
|
||||||
|
// Amounts are in MAJOR units with 2 decimals here (a CSV is for humans/Excel), unlike
|
||||||
|
// the JSON which stays in minor units. text/csv with a download filename.
|
||||||
|
app.get<{ Querystring: { from?: string; to?: string; bucket?: string } }>(
|
||||||
|
"/api/reports/summary.csv",
|
||||||
|
{ preHandler: guard },
|
||||||
|
async (req, reply) => {
|
||||||
|
const summary = reportSummary(db, parseQuery(req.query));
|
||||||
|
const lines = [
|
||||||
|
"bucket,entries,exits,payments,revenue",
|
||||||
|
...summary.series.map((p) =>
|
||||||
|
[p.bucket, p.entries, p.exits, p.payments, (p.revenueMinor / 100).toFixed(2)].join(","),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
reply
|
||||||
|
.header("content-type", "text/csv; charset=utf-8")
|
||||||
|
.header(
|
||||||
|
"content-disposition",
|
||||||
|
`attachment; filename="parking-report-${summary.from.slice(0, 10)}_${summary.to.slice(0, 10)}.csv"`,
|
||||||
|
)
|
||||||
|
.send(lines.join("\n") + "\n");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { createTestDb } from "@parking/db/testing";
|
||||||
|
import { type Db } from "@parking/db";
|
||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import { buildServer } from "../server.js";
|
||||||
|
import { seedUser, login } from "../test-helpers.js";
|
||||||
|
|
||||||
|
// HTTP integration: boot the REAL Fastify app over a fresh in-memory DB (no listen —
|
||||||
|
// app.inject drives it) and exercise the auth + RBAC guards end to end. The point is the
|
||||||
|
// security seam: no token → 401, wrong permission → 403, CSRF required on mutations, and
|
||||||
|
// a correctly-scoped user passes. (vitest.config sets JWT_SECRET/EVENT_SIGNING_KEY.)
|
||||||
|
|
||||||
|
let db: Db;
|
||||||
|
let close: () => void;
|
||||||
|
let app: FastifyInstance;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const t = createTestDb();
|
||||||
|
db = t.db;
|
||||||
|
close = t.close;
|
||||||
|
app = await buildServer({ db });
|
||||||
|
await app.ready();
|
||||||
|
});
|
||||||
|
afterEach(async () => {
|
||||||
|
await app.close();
|
||||||
|
close();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("health + login", () => {
|
||||||
|
it("GET /health is open", async () => {
|
||||||
|
const res = await app.inject({ method: "GET", url: "/health" });
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.json()).toEqual({ status: "ok" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("login with bad credentials is rejected", async () => {
|
||||||
|
await seedUser(db, { username: "alice", password: "right-password" });
|
||||||
|
const res = await app.inject({ method: "POST", url: "/api/auth/login", payload: { username: "alice", password: "wrong" } });
|
||||||
|
expect(res.statusCode).toBeGreaterThanOrEqual(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("login with good credentials sets auth + csrf cookies", async () => {
|
||||||
|
await seedUser(db, { username: "alice", password: "right-password" });
|
||||||
|
const res = await app.inject({ method: "POST", url: "/api/auth/login", payload: { username: "alice", password: "right-password" } });
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const names = res.cookies.map((c) => c.name);
|
||||||
|
expect(names).toContain("parking_token");
|
||||||
|
expect(names).toContain("parking_csrf");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("auth guard — no token", () => {
|
||||||
|
it("GET /api/occupancy without a session is 401", async () => {
|
||||||
|
const res = await app.inject({ method: "GET", url: "/api/occupancy" });
|
||||||
|
expect(res.statusCode).toBe(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("RBAC permission gate", () => {
|
||||||
|
it("a site:read-only user can GET occupancy but is 403 on PUT site-config", async () => {
|
||||||
|
const { username, password } = await seedUser(db, {
|
||||||
|
username: "viewer", roleId: "viewer", permissions: ["site:read"],
|
||||||
|
});
|
||||||
|
const { cookie, csrf } = await login(app, username, password);
|
||||||
|
|
||||||
|
// GET allowed (site:read).
|
||||||
|
const get = await app.inject({ method: "GET", url: "/api/occupancy", headers: { cookie } });
|
||||||
|
expect(get.statusCode).toBe(200);
|
||||||
|
|
||||||
|
// PUT requires site:update — which this role lacks → 403 (with valid CSRF, so the
|
||||||
|
// 403 is the PERMISSION check, not CSRF).
|
||||||
|
const put = await app.inject({
|
||||||
|
method: "PUT", url: "/api/site-config",
|
||||||
|
headers: { cookie, "x-csrf-token": csrf },
|
||||||
|
payload: { capacity: 50 },
|
||||||
|
});
|
||||||
|
expect(put.statusCode).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an admin user passes the same PUT", async () => {
|
||||||
|
const { username, password } = await seedUser(db, { username: "boss", roleId: "admin" });
|
||||||
|
const { cookie, csrf } = await login(app, username, password);
|
||||||
|
const put = await app.inject({
|
||||||
|
method: "PUT", url: "/api/site-config",
|
||||||
|
headers: { cookie, "x-csrf-token": csrf },
|
||||||
|
payload: { capacity: 50 },
|
||||||
|
});
|
||||||
|
expect(put.statusCode).toBeLessThan(300);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("CSRF double-submit on mutations", () => {
|
||||||
|
it("a mutation with the auth cookie but NO csrf header is 403", async () => {
|
||||||
|
const { username, password } = await seedUser(db, { username: "boss", roleId: "admin" });
|
||||||
|
const { cookie } = await login(app, username, password);
|
||||||
|
const put = await app.inject({
|
||||||
|
method: "PUT", url: "/api/site-config",
|
||||||
|
headers: { cookie }, // csrf header deliberately omitted
|
||||||
|
payload: { capacity: 50 },
|
||||||
|
});
|
||||||
|
expect(put.statusCode).toBe(403);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,16 +4,19 @@ import { eq, devices, setupState, type Db } from "@parking/db";
|
|||||||
import {
|
import {
|
||||||
hasPreconditions,
|
hasPreconditions,
|
||||||
hasPushConfig,
|
hasPushConfig,
|
||||||
|
isCamera,
|
||||||
isDiscoverable,
|
isDiscoverable,
|
||||||
isHardenable,
|
isHardenable,
|
||||||
registerBuiltinDrivers,
|
registerBuiltinDrivers,
|
||||||
registry,
|
registry,
|
||||||
setDeviceLogSink,
|
setDeviceLogSink,
|
||||||
|
type CameraDevice,
|
||||||
type DeviceCategory,
|
type DeviceCategory,
|
||||||
type DeviceConfig,
|
type DeviceConfig,
|
||||||
} from "@parking/devices";
|
} from "@parking/devices";
|
||||||
import { requirePermission } from "../auth.js";
|
import { requirePermission } from "../auth.js";
|
||||||
import { backendIpCandidates, backendIpForDevice, backendPort } from "../net.js";
|
import { backendIpCandidates, backendIpForDevice, backendPort } from "../net.js";
|
||||||
|
import type { VisionClient } from "../vision-client.js";
|
||||||
|
|
||||||
// First-run setup API. The admin reads the driver catalog and assigns devices
|
// First-run setup API. The admin reads the driver catalog and assigns devices
|
||||||
// per lane. See wiki/concepts/first-run-setup.md.
|
// per lane. See wiki/concepts/first-run-setup.md.
|
||||||
@@ -172,7 +175,11 @@ async function configureDevice(
|
|||||||
return { config: fullConfig, warnings: hardenWarnings };
|
return { config: fullConfig, warnings: hardenWarnings };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function setupRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
export async function setupRoutes(
|
||||||
|
app: FastifyInstance,
|
||||||
|
db: Db,
|
||||||
|
vision?: VisionClient | null,
|
||||||
|
): Promise<void> {
|
||||||
registerBuiltinDrivers();
|
registerBuiltinDrivers();
|
||||||
setDeviceLogSink((line) => app.log.info(line));
|
setDeviceLogSink((line) => app.log.info(line));
|
||||||
|
|
||||||
@@ -261,6 +268,72 @@ export async function setupRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Test ANPR end-to-end on a camera config WITHOUT saving: capture a live snapshot
|
||||||
|
// off the camera and run it through the vision (ANPR) service, reporting whether a
|
||||||
|
// plate was extracted, the read, and how long it took. Lets the admin verify the
|
||||||
|
// camera→vision pipeline before committing the camera's `anpr` opt-in. Advisory +
|
||||||
|
// fail-soft, exactly like the runtime path (snapshot.ts): a vision failure is a
|
||||||
|
// reported "no plate", never a 500. See wiki/entities/opencv-anpr-service.md.
|
||||||
|
app.post<{ Body: TestBody }>(
|
||||||
|
"/api/setup/test-anpr",
|
||||||
|
{ preHandler: adminGuard },
|
||||||
|
async (req, reply) => {
|
||||||
|
const { driverId, config } = req.body;
|
||||||
|
const driver = registry.get(driverId);
|
||||||
|
if (!driver) return reply.code(400).send({ error: `unknown driver: ${driverId}` });
|
||||||
|
if (driver.category !== "camera") {
|
||||||
|
return reply.code(400).send({ error: `driver ${driverId} is not a camera` });
|
||||||
|
}
|
||||||
|
if (!vision?.enabled) {
|
||||||
|
// The vision service is off (VISION_ENABLED unset) — there's nothing to test
|
||||||
|
// against. Report it cleanly so the UI can say "enable vision first".
|
||||||
|
return reply.send({ ok: false, reason: "vision-disabled" });
|
||||||
|
}
|
||||||
|
|
||||||
|
let device;
|
||||||
|
try {
|
||||||
|
device = registry.create(driverId, config);
|
||||||
|
} catch (err) {
|
||||||
|
return reply.code(400).send({ error: (err as Error).message });
|
||||||
|
}
|
||||||
|
if (!isCamera(device)) {
|
||||||
|
return reply.code(400).send({ error: `driver ${driverId} cannot capture snapshots` });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1) Grab a frame off the camera. A camera/network failure here is the failure
|
||||||
|
// we're testing for — report it, don't 500.
|
||||||
|
const startedAt = Date.now();
|
||||||
|
let shot: Awaited<ReturnType<CameraDevice["captureSnapshot"]>>;
|
||||||
|
try {
|
||||||
|
shot = await device.captureSnapshot({ direction: "entry" });
|
||||||
|
} catch (err) {
|
||||||
|
return reply.send({
|
||||||
|
ok: false,
|
||||||
|
reason: "snapshot-failed",
|
||||||
|
detail: (err as Error).message,
|
||||||
|
tookMs: Date.now() - startedAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Run the same advisory analyze the runtime path uses. `analyze` is fail-soft
|
||||||
|
// (null on any error/timeout) and applies the confidence floor.
|
||||||
|
const result = await vision.analyze(shot.bytes, shot.contentType);
|
||||||
|
const tookMs = Date.now() - startedAt;
|
||||||
|
if (!result || !result.plate) {
|
||||||
|
return reply.send({ ok: false, reason: "no-plate", tookMs });
|
||||||
|
}
|
||||||
|
return reply.send({
|
||||||
|
ok: true,
|
||||||
|
plate: result.plate.text.trim().toUpperCase(),
|
||||||
|
confidence: result.plate.confidence,
|
||||||
|
region: result.plate.region ?? null,
|
||||||
|
lowConfidence: result.lowConfidence,
|
||||||
|
modelVersion: result.modelVersion,
|
||||||
|
tookMs,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Candidate backend IPs the device can push to, for a given device host. The
|
// Candidate backend IPs the device can push to, for a given device host. The
|
||||||
// wizard pre-fills with the on-subnet one and lets the admin override (matters
|
// wizard pre-fills with the on-subnet one and lets the admin override (matters
|
||||||
// on multi-NIC hosts). See net.ts / wiki/concepts/device-input-flow.md.
|
// on multi-NIC hosts). See net.ts / wiki/concepts/device-input-flow.md.
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { userRoutes } from "./routes/users.js";
|
|||||||
import { roleRoutes } from "./routes/roles.js";
|
import { roleRoutes } from "./routes/roles.js";
|
||||||
import { deviceRoutes } from "./routes/devices.js";
|
import { deviceRoutes } from "./routes/devices.js";
|
||||||
import { eventRoutes } from "./routes/events.js";
|
import { eventRoutes } from "./routes/events.js";
|
||||||
|
import { reportRoutes } from "./routes/reports.js";
|
||||||
import { payRoutes } from "./routes/pay.js";
|
import { payRoutes } from "./routes/pay.js";
|
||||||
import { subscriptionRoutes } from "./routes/subscriptions.js";
|
import { subscriptionRoutes } from "./routes/subscriptions.js";
|
||||||
import { subscriptionPlanRoutes } from "./routes/subscription-plans.js";
|
import { subscriptionPlanRoutes } from "./routes/subscription-plans.js";
|
||||||
@@ -93,11 +94,18 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
|||||||
await userRoutes(app, db);
|
await userRoutes(app, db);
|
||||||
await roleRoutes(app, db);
|
await roleRoutes(app, db);
|
||||||
|
|
||||||
|
// Vision (ANPR) client — built early so the device monitor can include the vision
|
||||||
|
// service's health in the footer, AND so the setup wizard's "Test ANPR" can run a
|
||||||
|
// snapshot→analyze probe on an ANPR-enabled camera. Opt-in (VISION_ENABLED) +
|
||||||
|
// fail-soft; advisory only. See wiki/entities/opencv-anpr-service.md.
|
||||||
|
const visionClient = new VisionClient(app.log);
|
||||||
|
if (visionClient.enabled) app.log.info("vision client enabled");
|
||||||
|
|
||||||
// Device-agnostic setup: the admin adds controllers (with their relays + entry
|
// Device-agnostic setup: the admin adds controllers (with their relays + entry
|
||||||
// button) and binds readers/cameras to a controller relay at first-run. There is
|
// button) and binds readers/cameras to a controller relay at first-run. There is
|
||||||
// no lane — a parking lot is one pool with a flexible set of entry/exit points.
|
// no lane — a parking lot is one pool with a flexible set of entry/exit points.
|
||||||
// See wiki/concepts/first-run-setup.md, entry-exit-points.md.
|
// See wiki/concepts/first-run-setup.md, entry-exit-points.md.
|
||||||
await setupRoutes(app, db);
|
await setupRoutes(app, db, visionClient);
|
||||||
|
|
||||||
// Inbound device pushes (e.g. Dingtian Input Link URL → button events),
|
// Inbound device pushes (e.g. Dingtian Input Link URL → button events),
|
||||||
// guarded by source-IP allowlist + a shared-secret path token, both read from
|
// guarded by source-IP allowlist + a shared-secret path token, both read from
|
||||||
@@ -112,12 +120,6 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
|||||||
app.addHook("onReady", async () => printerMonitor.start());
|
app.addHook("onReady", async () => printerMonitor.start());
|
||||||
app.addHook("onClose", async () => printerMonitor.stop());
|
app.addHook("onClose", async () => printerMonitor.stop());
|
||||||
|
|
||||||
// Vision (ANPR) client — built early so the device monitor can include the vision
|
|
||||||
// service's health in the footer. Opt-in (VISION_ENABLED) + fail-soft; advisory only.
|
|
||||||
// See wiki/entities/opencv-anpr-service.md.
|
|
||||||
const visionClient = new VisionClient(app.log);
|
|
||||||
if (visionClient.enabled) app.log.info("vision client enabled");
|
|
||||||
|
|
||||||
// Unified device-status monitor: polls EVERY configured device (relays/readers/
|
// Unified device-status monitor: polls EVERY configured device (relays/readers/
|
||||||
// cameras via healthCheck, printers via rich readStatus) PLUS the vision service's
|
// cameras via healthCheck, printers via rich readStatus) PLUS the vision service's
|
||||||
// /health, and feeds the booth's device-status footer over the WS. Read-only.
|
// /health, and feeds the booth's device-status footer over the WS. Read-only.
|
||||||
@@ -140,6 +142,10 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
|||||||
);
|
);
|
||||||
await eventRoutes(app, db, eventLog);
|
await eventRoutes(app, db, eventLog);
|
||||||
|
|
||||||
|
// Admin reporting: read-only charts/totals aggregated from the signed ledger
|
||||||
|
// (+ sessions cache for durations). Gated on report:read. See routes/reports.ts.
|
||||||
|
await reportRoutes(app, db);
|
||||||
|
|
||||||
// Live booth feed: server-pushed ledger + occupancy + printer-status over a
|
// Live booth feed: server-pushed ledger + occupancy + printer-status over a
|
||||||
// single authenticated WebSocket (/api/ws). See routes/ws.ts.
|
// single authenticated WebSocket (/api/ws). See routes/ws.ts.
|
||||||
await wsRoutes(app, db, deviceMonitor);
|
await wsRoutes(app, db, deviceMonitor);
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { createTestDb } from "@parking/db/testing";
|
||||||
|
import { type Db } from "@parking/db";
|
||||||
|
import {
|
||||||
|
ShiftService,
|
||||||
|
ShiftAlreadyOpenError,
|
||||||
|
NoOpenShiftError,
|
||||||
|
NoShiftOpenError,
|
||||||
|
InvalidCashMovementError,
|
||||||
|
} from "./shift-service.js";
|
||||||
|
import type { EventLog } from "./event-log.js";
|
||||||
|
import { makeLog, silentLogger } from "./test-helpers.js";
|
||||||
|
|
||||||
|
// The shift is an operator's accountability period — signed shift_open … shift_z_report,
|
||||||
|
// no mutable table. These tests pin: the site-wide single-open invariant, the takings
|
||||||
|
// SPLIT by source (subscription sales vs out-of-window charges vs transient tickets — the
|
||||||
|
// 2026-06-21 work), the drawer carry-forward, and that close signs a Z-report with the
|
||||||
|
// right figures.
|
||||||
|
|
||||||
|
let db: Db;
|
||||||
|
let close: () => void;
|
||||||
|
let log: EventLog;
|
||||||
|
let shift: ShiftService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
const t = createTestDb();
|
||||||
|
db = t.db;
|
||||||
|
close = t.close;
|
||||||
|
log = makeLog(db);
|
||||||
|
shift = new ShiftService(db, log, silentLogger());
|
||||||
|
});
|
||||||
|
afterEach(() => close());
|
||||||
|
|
||||||
|
/** Append a signed payment with source-split flags, as the booth/pay paths do. */
|
||||||
|
async function payment(
|
||||||
|
amountMinor: number,
|
||||||
|
opts: { tender?: "cash" | "card"; subscriptionSale?: boolean; subscriptionWindowCharge?: boolean } = {},
|
||||||
|
) {
|
||||||
|
await log.append({
|
||||||
|
type: "payment", source: "manual", identity: "T",
|
||||||
|
payload: {
|
||||||
|
sessionRef: "T", amountMinor, currency: "ALL", tender: opts.tender ?? "cash",
|
||||||
|
...(opts.subscriptionSale ? { subscriptionSale: true } : {}),
|
||||||
|
...(opts.subscriptionWindowCharge ? { subscriptionWindowCharge: true } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("single-open invariant", () => {
|
||||||
|
it("opens a shift and reports it as the current open one", async () => {
|
||||||
|
await shift.open("alice");
|
||||||
|
const cur = shift.currentOpenShift();
|
||||||
|
expect(cur?.identity).toBe("alice");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a second open while one is already open (even another operator)", async () => {
|
||||||
|
await shift.open("alice");
|
||||||
|
await expect(shift.open("alice")).rejects.toBeInstanceOf(ShiftAlreadyOpenError);
|
||||||
|
await expect(shift.open("bob")).rejects.toBeInstanceOf(ShiftAlreadyOpenError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows a new shift after the prior one closes", async () => {
|
||||||
|
await shift.open("alice");
|
||||||
|
await shift.close("alice");
|
||||||
|
await expect(shift.open("bob")).resolves.toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("close without an open shift throws", async () => {
|
||||||
|
await expect(shift.close("alice")).rejects.toBeInstanceOf(NoOpenShiftError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requireOpenShift throws when none is open", () => {
|
||||||
|
expect(() => shift.requireOpenShift()).toThrow(NoShiftOpenError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("takings split by source", () => {
|
||||||
|
it("separates subscription sales, out-of-window charges, and transient tickets", async () => {
|
||||||
|
await shift.open("alice");
|
||||||
|
await payment(50000, { subscriptionSale: true }); // monthly fee
|
||||||
|
await payment(20000, { subscriptionWindowCharge: true }); // out-of-window
|
||||||
|
await payment(10000); // transient ticket
|
||||||
|
await payment(30000, { tender: "card" }); // transient ticket, card
|
||||||
|
|
||||||
|
const r = shift.currentReport()!;
|
||||||
|
expect(r.subscriptionSalesMinor).toBe(50000);
|
||||||
|
expect(r.subscriptionWindowMinor).toBe(20000);
|
||||||
|
expect(r.subscriptionTotalMinor).toBe(70000);
|
||||||
|
expect(r.ticketTotalMinor).toBe(40000); // 10000 cash + 30000 card
|
||||||
|
// The split must reconcile to the cash+card grand total.
|
||||||
|
expect(r.cashTotalMinor + r.cardTotalMinor).toBe(
|
||||||
|
r.ticketTotalMinor + r.subscriptionTotalMinor,
|
||||||
|
);
|
||||||
|
expect(r.cashTotalMinor).toBe(80000); // 50000 + 20000 + 10000
|
||||||
|
expect(r.cardTotalMinor).toBe(30000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("drawer carry-forward", () => {
|
||||||
|
it("cash payments enter the drawer; card does not", async () => {
|
||||||
|
await shift.open("alice");
|
||||||
|
await payment(10000, { tender: "cash" });
|
||||||
|
await payment(50000, { tender: "card" });
|
||||||
|
const r = shift.currentReport()!;
|
||||||
|
expect(r.cashTotalMinor).toBe(10000);
|
||||||
|
// Expected drawer = opening(0) + cash(10000) + added(0) − removed(0).
|
||||||
|
expect(r.expectedDrawerMinor).toBe(10000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a closed shift's expected drawer becomes the next shift's opening float", async () => {
|
||||||
|
await shift.open("alice");
|
||||||
|
await payment(25000, { tender: "cash" });
|
||||||
|
const closed = await shift.close("alice");
|
||||||
|
expect(closed.expectedDrawerMinor).toBe(25000);
|
||||||
|
|
||||||
|
const next = await shift.open("bob");
|
||||||
|
expect(next.openingFloatMinor).toBe(25000); // inherited
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cash_in / cash_out vouchers adjust the drawer", async () => {
|
||||||
|
await shift.open("alice");
|
||||||
|
await shift.recordVoucher({ type: "cash_in", operator: "alice", authorizedBy: "admin", amountMinor: 100000, reason: "float load" });
|
||||||
|
await shift.recordVoucher({ type: "cash_out", operator: "alice", authorizedBy: "admin", amountMinor: 30000, reason: "bank drop" });
|
||||||
|
const r = shift.currentReport()!;
|
||||||
|
expect(r.cashAddedMinor).toBe(100000);
|
||||||
|
expect(r.cashRemovedMinor).toBe(30000);
|
||||||
|
expect(r.expectedDrawerMinor).toBe(70000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a non-positive voucher amount", async () => {
|
||||||
|
await shift.open("alice");
|
||||||
|
await expect(
|
||||||
|
shift.recordVoucher({ type: "cash_in", operator: "alice", authorizedBy: "admin", amountMinor: 0, reason: "x" }),
|
||||||
|
).rejects.toBeInstanceOf(InvalidCashMovementError);
|
||||||
|
await expect(
|
||||||
|
shift.recordVoucher({ type: "cash_out", operator: "alice", authorizedBy: "admin", amountMinor: -5, reason: "x" }),
|
||||||
|
).rejects.toBeInstanceOf(InvalidCashMovementError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("close signs a Z-report; listShifts reads it back", () => {
|
||||||
|
it("a closed shift appears in history with its split figures", async () => {
|
||||||
|
await shift.open("alice");
|
||||||
|
await payment(50000, { subscriptionSale: true });
|
||||||
|
await payment(10000); // ticket
|
||||||
|
await shift.close("alice");
|
||||||
|
|
||||||
|
const history = shift.listShifts();
|
||||||
|
expect(history).toHaveLength(1);
|
||||||
|
const s = history[0];
|
||||||
|
expect(s.operator).toBe("alice");
|
||||||
|
expect(s.subscriptionSalesMinor).toBe(50000);
|
||||||
|
expect(s.ticketTotalMinor).toBe(10000);
|
||||||
|
expect(s.cashTotalMinor).toBe(60000);
|
||||||
|
// The Z-report is a signed chain event.
|
||||||
|
expect(log.verifyChain()).toEqual({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters history by operator", async () => {
|
||||||
|
await shift.open("alice"); await shift.close("alice");
|
||||||
|
await shift.open("bob"); await shift.close("bob");
|
||||||
|
expect(shift.listShifts({ operator: "alice" }).map((s) => s.operator)).toEqual(["alice"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { SoftwareSigner, buildSigner, buildVerifier } from "./signer.js";
|
||||||
|
|
||||||
|
// The signer is half of the anti-fraud chain (the other half is event-log's hashing).
|
||||||
|
// These tests pin: a sign/verify round-trip, rejection of any tamper, constant-time
|
||||||
|
// length handling, and the keyId rotation contract that lets one chain span keys.
|
||||||
|
|
||||||
|
describe("SoftwareSigner", () => {
|
||||||
|
it("verifies its own signature (round-trip)", () => {
|
||||||
|
const s = new SoftwareSigner("a-test-secret-key");
|
||||||
|
const sig = s.sign("hello world");
|
||||||
|
expect(s.verify("hello world", sig)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a signature over different content (tamper-evidence)", () => {
|
||||||
|
const s = new SoftwareSigner("a-test-secret-key");
|
||||||
|
const sig = s.sign("amount=100");
|
||||||
|
// Flip the signed content — the whole point of signing the payload.
|
||||||
|
expect(s.verify("amount=9999", sig)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a signature made under a different key (forgery)", () => {
|
||||||
|
const real = new SoftwareSigner("the-real-host-key");
|
||||||
|
const forger = new SoftwareSigner("an-attacker-guess");
|
||||||
|
const forged = forger.sign("amount=100");
|
||||||
|
expect(real.verify("amount=100", forged)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a malformed / wrong-length signature without throwing", () => {
|
||||||
|
const s = new SoftwareSigner("a-test-secret-key");
|
||||||
|
// timingSafeEqual throws on length mismatch; verify() must guard it.
|
||||||
|
expect(() => s.verify("x", "deadbeef")).not.toThrow();
|
||||||
|
expect(s.verify("x", "deadbeef")).toBe(false);
|
||||||
|
expect(s.verify("x", "")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is deterministic — same key + payload yields the same signature", () => {
|
||||||
|
const a = new SoftwareSigner("k").sign("p");
|
||||||
|
const b = new SoftwareSigner("k").sign("p");
|
||||||
|
expect(a).toBe(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults to the v2 keyId", () => {
|
||||||
|
expect(new SoftwareSigner("k").keyId).toBe("sw-hmac-v2");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildSigner", () => {
|
||||||
|
// vitest.config.ts sets EVENT_SIGNING_KEY + JWT_SECRET for the whole run.
|
||||||
|
it("prefers EVENT_SIGNING_KEY (keyId sw-hmac-v2)", () => {
|
||||||
|
const s = buildSigner();
|
||||||
|
expect(s.keyId).toBe("sw-hmac-v2");
|
||||||
|
const sig = s.sign("x");
|
||||||
|
expect(s.verify("x", sig)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildVerifier (key rotation)", () => {
|
||||||
|
it("returns a working verifier for the configured v2 key", () => {
|
||||||
|
const v = buildVerifier("sw-hmac-v2");
|
||||||
|
expect(v).toBeDefined();
|
||||||
|
const signer = new SoftwareSigner(process.env.EVENT_SIGNING_KEY!, "sw-hmac-v2");
|
||||||
|
expect(v!.verify("x", signer.sign("x"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves the jwtfallback key when present", () => {
|
||||||
|
const v = buildVerifier("sw-hmac-jwtfallback");
|
||||||
|
expect(v).toBeDefined();
|
||||||
|
const signer = new SoftwareSigner(process.env.JWT_SECRET!, "sw-hmac-jwtfallback");
|
||||||
|
expect(v!.verify("x", signer.sign("x"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined for an unknown keyId (key gone, not a false tamper)", () => {
|
||||||
|
expect(buildVerifier("atecc608-slot0")).toBeUndefined();
|
||||||
|
expect(buildVerifier("nonsense")).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import bcrypt from "bcrypt";
|
||||||
|
import { roles, rolePermissions, tariffs, tariffVersions, users, type Db } from "@parking/db";
|
||||||
|
import type { Permission, TariffStructure } from "@parking/shared";
|
||||||
|
import type { FastifyBaseLogger, FastifyInstance } from "fastify";
|
||||||
|
import { EventLog } from "./event-log.js";
|
||||||
|
import { SoftwareSigner, buildVerifier } from "./signer.js";
|
||||||
|
|
||||||
|
// Shared scaffolding for server tests (NOT a *.test file, so it is not collected as a
|
||||||
|
// suite and stays out of shipped dist via the tsconfig test-exclude). Builds the real
|
||||||
|
// EventLog over a fresh test DB, a silent logger, and a minimal active tariff so the
|
||||||
|
// pay/exit flows have something to price against.
|
||||||
|
|
||||||
|
const SECRET = "test-event-signing-key-0123456789";
|
||||||
|
|
||||||
|
/** Real EventLog (real signer + per-keyId verifier) over a test DB. */
|
||||||
|
export function makeLog(db: Db): EventLog {
|
||||||
|
return new EventLog(db, new SoftwareSigner(SECRET), buildVerifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A logger that swallows everything — flows log liberally; tests don't care. */
|
||||||
|
export function silentLogger(): FastifyBaseLogger {
|
||||||
|
const noop = () => {};
|
||||||
|
const l: Record<string, unknown> = {
|
||||||
|
info: noop, warn: noop, error: noop, debug: noop, fatal: noop, trace: noop,
|
||||||
|
silent: noop, level: "silent",
|
||||||
|
};
|
||||||
|
l.child = () => l;
|
||||||
|
return l as unknown as FastifyBaseLogger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A simple flat-rate V1 tariff: free under the entry grace, then a fixed price per
|
||||||
|
* increment, with a walk-back exit grace. Returns the tariffVersionId + currency. */
|
||||||
|
export function seedTariff(
|
||||||
|
db: Db,
|
||||||
|
opts: { pricePerIncrementMinor?: number; incrementMin?: number; gracePeriodEntryMin?: number; gracePeriodExitMin?: number; currency?: string; effectiveFrom?: string } = {},
|
||||||
|
): { tariffVersionId: string; currency: string } {
|
||||||
|
const tariffId = randomUUID();
|
||||||
|
const versionId = randomUUID();
|
||||||
|
const currency = opts.currency ?? "ALL";
|
||||||
|
const structure: TariffStructure = {
|
||||||
|
gracePeriodEntryMin: opts.gracePeriodEntryMin ?? 10,
|
||||||
|
incrementMin: opts.incrementMin ?? 60,
|
||||||
|
blocks: [{ uptoMin: null, priceMinorPerIncrement: opts.pricePerIncrementMinor ?? 10000 }],
|
||||||
|
dailyCapMinor: null,
|
||||||
|
lostTicketMinor: 50000,
|
||||||
|
gracePeriodExitMin: opts.gracePeriodExitMin ?? 15,
|
||||||
|
overstay: "reprice",
|
||||||
|
};
|
||||||
|
db.insert(tariffs).values({ id: tariffId, scope: "site", name: "Test" }).run();
|
||||||
|
db.insert(tariffVersions).values({
|
||||||
|
id: versionId,
|
||||||
|
tariffId,
|
||||||
|
effectiveFrom: opts.effectiveFrom ?? "2000-01-01T00:00:00.000Z",
|
||||||
|
currency,
|
||||||
|
structure: structure as unknown as Record<string, unknown>,
|
||||||
|
}).run();
|
||||||
|
return { tariffVersionId: versionId, currency };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ISO string `minutes` ago from now (for entries that should already owe a fee). */
|
||||||
|
export function minutesAgo(minutes: number): string {
|
||||||
|
return new Date(Date.now() - minutes * 60_000).toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- HTTP integration scaffolding (route tests via app.inject) -----------------
|
||||||
|
|
||||||
|
/** Seed a user with a role. `admin` role grants every permission (ADMIN_PERMS);
|
||||||
|
* any other role gets exactly the `permissions` listed. Returns the credentials. */
|
||||||
|
export async function seedUser(
|
||||||
|
db: Db,
|
||||||
|
opts: { username?: string; password?: string; roleId?: string; permissions?: Permission[] } = {},
|
||||||
|
): Promise<{ username: string; password: string; roleId: string }> {
|
||||||
|
const username = opts.username ?? "tester";
|
||||||
|
const password = opts.password ?? "test-password-123";
|
||||||
|
const roleId = opts.roleId ?? "admin";
|
||||||
|
if (roleId !== "admin") {
|
||||||
|
db.insert(roles).values({ id: roleId, name: roleId, builtin: 0 }).onConflictDoNothing().run();
|
||||||
|
for (const p of opts.permissions ?? []) {
|
||||||
|
db.insert(rolePermissions).values({ roleId, permission: p }).onConflictDoNothing().run();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// The admin role row must exist for the FK; ADMIN_PERMS is resolved in code.
|
||||||
|
db.insert(roles).values({ id: "admin", name: "admin", builtin: 1 }).onConflictDoNothing().run();
|
||||||
|
}
|
||||||
|
db.insert(users).values({
|
||||||
|
id: randomUUID(),
|
||||||
|
username,
|
||||||
|
passwordHash: await bcrypt.hash(password, 10),
|
||||||
|
roleId,
|
||||||
|
}).run();
|
||||||
|
return { username, password, roleId };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Log in via the real auth route and return the cookie header + CSRF token to
|
||||||
|
* replay on subsequent requests (mutations need both the cookie and the header). */
|
||||||
|
export async function login(
|
||||||
|
app: FastifyInstance,
|
||||||
|
username: string,
|
||||||
|
password: string,
|
||||||
|
): Promise<{ cookie: string; csrf: string }> {
|
||||||
|
const res = await app.inject({ method: "POST", url: "/api/auth/login", payload: { username, password } });
|
||||||
|
if (res.statusCode !== 200) throw new Error(`login failed: ${res.statusCode} ${res.body}`);
|
||||||
|
const setCookies = res.cookies;
|
||||||
|
const cookie = setCookies.map((c) => `${c.name}=${c.value}`).join("; ");
|
||||||
|
const csrf = setCookies.find((c) => c.name === "parking_csrf")?.value ?? "";
|
||||||
|
return { cookie, csrf };
|
||||||
|
}
|
||||||
@@ -9,5 +9,6 @@
|
|||||||
{ "path": "../../packages/db" },
|
{ "path": "../../packages/db" },
|
||||||
{ "path": "../../packages/devices" }
|
{ "path": "../../packages/devices" }
|
||||||
],
|
],
|
||||||
"include": ["src/**/*"]
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["src/**/*.test.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
|
// Server tests live next to the code under test (src/**/*.test.ts). They run against
|
||||||
|
// a fresh in-memory SQLite from @parking/db/testing — never the live parking.sqlite.
|
||||||
|
// A test signing key is set here so the SoftwareSigner/buildSigner path works without
|
||||||
|
// a real .env (the value is irrelevant — tests assert self-consistency, not secrecy).
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
include: ["src/**/*.test.ts"],
|
||||||
|
env: {
|
||||||
|
EVENT_SIGNING_KEY: "test-event-signing-key-0123456789",
|
||||||
|
JWT_SECRET: "test-jwt-secret-0123456789abcdef",
|
||||||
|
// Silence the Fastify request logger — route tests assert 401/403 responses,
|
||||||
|
// whose error logs would otherwise flood the test output.
|
||||||
|
LOG_LEVEL: "silent",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"""Shared test fixtures.
|
||||||
|
|
||||||
|
The stub-mode smoke tests must be deterministic regardless of the developer's local
|
||||||
|
apps/vision/.env (which may set VISION_RECOGNIZER=fast_alpr for real-model work). An OS
|
||||||
|
environment variable takes precedence over the .env file in pydantic-settings, so we
|
||||||
|
force stub mode for the whole test session before the app's lifespan builds the
|
||||||
|
recognizer. Tests that exercise the real recognizer set their own override explicitly.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _force_stub_recognizer() -> None:
|
||||||
|
"""Pin the recognizer to the model-free stub for every test (overrides .env)."""
|
||||||
|
prev = os.environ.get("VISION_RECOGNIZER")
|
||||||
|
os.environ["VISION_RECOGNIZER"] = "stub"
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
if prev is None:
|
||||||
|
os.environ.pop("VISION_RECOGNIZER", None)
|
||||||
|
else:
|
||||||
|
os.environ["VISION_RECOGNIZER"] = prev
|
||||||
@@ -8,7 +8,8 @@
|
|||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"lint": "tsc --noEmit"
|
"lint": "tsc --noEmit",
|
||||||
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@parking/shared": "workspace:*",
|
"@parking/shared": "workspace:*",
|
||||||
@@ -23,6 +24,7 @@
|
|||||||
"react": "19.2.7",
|
"react": "19.2.7",
|
||||||
"react-dom": "19.2.7",
|
"react-dom": "19.2.7",
|
||||||
"react-i18next": "^17.0.8",
|
"react-i18next": "^17.0.8",
|
||||||
|
"recharts": "^3.2.1",
|
||||||
"zustand": "^5.0.14"
|
"zustand": "^5.0.14"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -30,9 +32,12 @@
|
|||||||
"@tanstack/react-router-devtools": "^1.167.0",
|
"@tanstack/react-router-devtools": "^1.167.0",
|
||||||
"@types/react": "19.2.17",
|
"@types/react": "19.2.17",
|
||||||
"@types/react-dom": "19.2.3",
|
"@types/react-dom": "19.2.3",
|
||||||
|
"@testing-library/react": "^16.1.0",
|
||||||
"@vitejs/plugin-react": "6.0.2",
|
"@vitejs/plugin-react": "6.0.2",
|
||||||
|
"jsdom": "^25.0.1",
|
||||||
"tailwindcss": "^4.3.1",
|
"tailwindcss": "^4.3.1",
|
||||||
"typescript": "6.0.3",
|
"typescript": "6.0.3",
|
||||||
"vite": "8.0.16"
|
"vite": "8.0.16",
|
||||||
|
"vitest": "^4.1.9"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,17 @@
|
|||||||
import { useRef, useState, type ReactNode } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { fetchEvents, fetchOccupancy, type LedgerEvent, type Occupancy } from "./api.js";
|
import { fetchEvents, fetchOccupancy, type LedgerEvent, type Occupancy } from "./api.js";
|
||||||
import { formatMoney } from "./lib/format.js";
|
|
||||||
import { qk } from "./lib/query.js";
|
import { qk } from "./lib/query.js";
|
||||||
import { useLiveStore } from "./lib/live-store.js";
|
import { useLiveStore } from "./lib/live-store.js";
|
||||||
import { useShift } from "./lib/use-shift.js";
|
import { useShift } from "./lib/use-shift.js";
|
||||||
|
import { useScanner } from "./lib/use-scanner.js";
|
||||||
import { Panel } from "./ui/Panel.js";
|
import { Panel } from "./ui/Panel.js";
|
||||||
import { StatusDot } from "./ui/StatusDot.js";
|
import { StatusDot } from "./ui/StatusDot.js";
|
||||||
import { BoothPayModal } from "./BoothPayModal.js";
|
import { BoothPayModal } from "./BoothPayModal.js";
|
||||||
import { ActiveSessions } from "./ActiveSessions.js";
|
import { ActiveSessions } from "./ActiveSessions.js";
|
||||||
import { Modal } from "./ui/Modal.js";
|
|
||||||
import { SnapshotStrip } from "./ui/SnapshotStrip.js";
|
|
||||||
import { FilterBar, SegGroup, type SegOption } from "./ui/FilterBar.js";
|
import { FilterBar, SegGroup, type SegOption } from "./ui/FilterBar.js";
|
||||||
import { renderReason } from "./lib/reason.js";
|
import { EventDetailModal, EventRow } from "./ui/event-detail.js";
|
||||||
|
|
||||||
// The live operator booth view — the real-time heart of the console. Occupancy
|
// The live operator booth view — the real-time heart of the console. Occupancy
|
||||||
// gauge + a streaming entry/exit/payment ticker. Query owns the initial load and
|
// gauge + a streaming entry/exit/payment ticker. Query owns the initial load and
|
||||||
@@ -21,21 +19,6 @@ import { renderReason } from "./lib/reason.js";
|
|||||||
// the screen reacts the instant a car enters or exits. Dense, dark, glanceable.
|
// the screen reacts the instant a car enters or exits. Dense, dark, glanceable.
|
||||||
|
|
||||||
/** Per-event-type display: i18n label key + accent colour for the ticker. */
|
/** Per-event-type display: i18n label key + accent colour for the ticker. */
|
||||||
const EVENT_STYLE: Record<string, { labelKey: string; color: string }> = {
|
|
||||||
vehicle_entry: { labelKey: "booth.evtEntry", color: "text-term-green" },
|
|
||||||
vehicle_exit: { labelKey: "booth.evtExit", color: "text-term-red" },
|
|
||||||
payment: { labelKey: "booth.evtPay", color: "text-term-cyan" },
|
|
||||||
void: { labelKey: "booth.evtVoid", color: "text-term-amber" },
|
|
||||||
barrier_open_command: { labelKey: "booth.evtOpenCmd", color: "text-term-muted" },
|
|
||||||
barrier_open_observed: { labelKey: "booth.evtOpenObserved", color: "text-term-muted" },
|
|
||||||
shift_open: { labelKey: "booth.evtShiftOpen", color: "text-term-amber" },
|
|
||||||
shift_z_report: { labelKey: "booth.evtShiftZ", color: "text-term-amber" },
|
|
||||||
cash_movement: { labelKey: "booth.evtCashMovement", color: "text-term-cyan" },
|
|
||||||
cash_in: { labelKey: "booth.evtCashIn", color: "text-term-green" },
|
|
||||||
cash_out: { labelKey: "booth.evtCashOut", color: "text-term-amber" },
|
|
||||||
anomaly: { labelKey: "booth.evtAnomaly", color: "text-term-red" },
|
|
||||||
};
|
|
||||||
|
|
||||||
// Live-feed filter category for an event type. Several ledger types collapse into a
|
// Live-feed filter category for an event type. Several ledger types collapse into a
|
||||||
// few operator-meaningful buckets; the rest (barrier/shift/cash) fall outside the
|
// few operator-meaningful buckets; the rest (barrier/shift/cash) fall outside the
|
||||||
// filter and only show under "all".
|
// filter and only show under "all".
|
||||||
@@ -57,12 +40,6 @@ function feedCat(type: string): FeedCat | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function hhmmss(iso: string): string {
|
|
||||||
// Local time-of-day, terminal style. Defensive against a bad timestamp.
|
|
||||||
const d = new Date(iso);
|
|
||||||
return Number.isNaN(d.getTime()) ? "--:--:--" : d.toTimeString().slice(0, 8);
|
|
||||||
}
|
|
||||||
|
|
||||||
function OccupancyGauge({ occ }: { occ: Occupancy }) {
|
function OccupancyGauge({ occ }: { occ: Occupancy }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const pct = occ.capacity ? Math.min(100, Math.round((occ.count / occ.capacity) * 100)) : null;
|
const pct = occ.capacity ? Math.min(100, Math.round((occ.count / occ.capacity) * 100)) : null;
|
||||||
@@ -98,263 +75,6 @@ function OccupancyGauge({ occ }: { occ: Occupancy }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Translated classification badges derived from a payload's boolean flags. Unlike
|
|
||||||
* `reason` (an immutable English sentence baked into the signed ledger, shown
|
|
||||||
* verbatim), these are computed client-side so they CAN be localized. They give a
|
|
||||||
* glanceable "what kind of anomaly" tag without parsing the free-text reason. */
|
|
||||||
function eventBadges(p: LedgerEvent["payload"]): string[] {
|
|
||||||
if (!p) return [];
|
|
||||||
const keys: string[] = [];
|
|
||||||
if (p.entryRefused) keys.push("booth.badgeEntryRefused");
|
|
||||||
if (p.exitRefused) keys.push("booth.badgeExitRefused");
|
|
||||||
if (p.full) keys.push("booth.badgeLotFull");
|
|
||||||
if (p.exitOpenFailed) keys.push("booth.badgeBarrierFailed");
|
|
||||||
if (p.permitRefused) keys.push("booth.badgeSubRefused");
|
|
||||||
if (p.ticketPrinted === false) keys.push("booth.badgeNoTicket");
|
|
||||||
if (p.subscriptionSale) keys.push("booth.badgeSubSale");
|
|
||||||
// Subscriber entered outside their plan's allowed window → will owe a transient charge
|
|
||||||
// for the minutes actually parked out-of-window, priced + collected (gated) at exit.
|
|
||||||
// Flag it so the operator KNOWS now. (`windowOwedMinor` is the old fixed-amount stamp,
|
|
||||||
// kept so historic events still badge.)
|
|
||||||
if (p.outOfWindow === true || (typeof p.windowOwedMinor === "number" && p.windowOwedMinor > 0))
|
|
||||||
keys.push("booth.badgeWindowCharge");
|
|
||||||
if (p.source === "manual" && !p.subscriptionSale) keys.push("booth.badgeManualOpen");
|
|
||||||
return keys;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The i18n key for a subscriber's access medium (`via`), or null. Lets the activity
|
|
||||||
* log show HOW a subscriber entered/left — QR code, RFID card/chip, or plate. */
|
|
||||||
function viaKey(p: LedgerEvent["payload"]): string | null {
|
|
||||||
if (!p) return null;
|
|
||||||
if (p.via === "qr") return "booth.viaQr";
|
|
||||||
if (p.via === "card") return "booth.viaCard";
|
|
||||||
if (p.via === "plate") return "booth.viaPlate";
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A short money summary for payment events (e.g. "350.00 ALL"). */
|
|
||||||
function paymentSummary(p: LedgerEvent["payload"]): string | null {
|
|
||||||
if (!p || typeof p.amountMinor !== "number" || !p.currency) return null;
|
|
||||||
return formatMoney(p.amountMinor, p.currency);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** What to SHOW for an event's actor. A subscription occurrence has an opaque
|
|
||||||
* `SUBSESS-…` identity; the server resolves the holder's name into `subscriberLabel`,
|
|
||||||
* so we show that (e.g. "Aqif Kopertoni") instead. Otherwise the identity itself. */
|
|
||||||
function displayIdentity(e: LedgerEvent): string {
|
|
||||||
return e.subscriberLabel ?? e.identity ?? "—";
|
|
||||||
}
|
|
||||||
|
|
||||||
function EventRow({ e, onOpen }: { e: LedgerEvent; onOpen: (e: LedgerEvent) => void }) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const style = EVENT_STYLE[e.type];
|
|
||||||
const label = style ? t(style.labelKey) : e.type.toUpperCase();
|
|
||||||
const isAnomaly = e.type === "anomaly";
|
|
||||||
const p = e.payload;
|
|
||||||
// Localize the reason from the signed reasonCode (falls back to the English text on
|
|
||||||
// legacy events). Anomalies ALWAYS get a detail line so a red flag is never silent.
|
|
||||||
const reason = renderReason(p, t);
|
|
||||||
const amount = paymentSummary(p);
|
|
||||||
const badges = eventBadges(p);
|
|
||||||
const via = viaKey(p);
|
|
||||||
const detail = reason ?? amount ?? (isAnomaly ? t("booth.evtNoReason") : null);
|
|
||||||
const showDetail = detail != null || badges.length > 0 || via != null;
|
|
||||||
|
|
||||||
// The whole row is a button → opens the event-detail modal (full payload + the
|
|
||||||
// session's entry/exit snapshots). A grid keeps the time/label/identity/index
|
|
||||||
// columns aligned across rows; the detail line lives in its own row, indented to
|
|
||||||
// start under the identity column so it never collides with the ticket code.
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onOpen(e)}
|
|
||||||
className={`grid w-full grid-cols-[auto_5rem_1fr_auto] items-center gap-x-3 gap-y-0.5 border-b border-term-border/50 px-1 py-1 text-left text-[12px] tabular-nums hover:bg-term-panel-2 ${
|
|
||||||
isAnomaly ? "bg-term-red/5" : ""
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span className="text-term-muted">{hhmmss(e.occurredAt)}</span>
|
|
||||||
<span className={`shrink-0 font-semibold ${style?.color ?? "text-term-text"}`}>{label}</span>
|
|
||||||
<span className="flex min-w-0 items-center gap-2">
|
|
||||||
<span className="truncate text-term-text">{displayIdentity(e)}</span>
|
|
||||||
{e.plate && (
|
|
||||||
<span
|
|
||||||
className="shrink-0 rounded border border-term-border px-1 text-[11px] font-semibold tracking-wide text-term-amber"
|
|
||||||
title={t("booth.plateTitle")}
|
|
||||||
>
|
|
||||||
{e.plate}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
<span className="text-term-muted">#{e.index}</span>
|
|
||||||
{showDetail && (
|
|
||||||
<div className="col-start-3 col-end-5 flex flex-wrap items-center gap-x-2 gap-y-1">
|
|
||||||
{badges.map((k) => (
|
|
||||||
<span
|
|
||||||
key={k}
|
|
||||||
className="rounded-sm bg-term-red/15 px-1.5 py-px text-[10px] font-semibold uppercase tracking-wide text-term-red"
|
|
||||||
>
|
|
||||||
{t(k)}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
{via && (
|
|
||||||
<span className="rounded-sm bg-term-cyan/15 px-1.5 py-px text-[10px] font-semibold uppercase tracking-wide text-term-cyan">
|
|
||||||
{t(via)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{detail && (
|
|
||||||
<span className={`text-[11px] ${isAnomaly ? "text-term-red/90" : "text-term-muted"}`}>{detail}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One label/value line in the event-detail modal. */
|
|
||||||
function DetailRow({ label, children }: { label: string; children: ReactNode }) {
|
|
||||||
return (
|
|
||||||
<div className="grid grid-cols-[8rem_1fr] gap-3 border-b border-term-border/40 py-1.5 text-[12px]">
|
|
||||||
<span className="text-[11px] uppercase tracking-wider text-term-muted">{label}</span>
|
|
||||||
<span className="min-w-0 break-words text-term-text">{children}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Full read-only detail for one ledger event: business fields + the human-readable
|
|
||||||
* reason + the session's entry/exit snapshots, then the signed-chain provenance
|
|
||||||
* (signature/prev-hash/key) for an audit trail. Read-only — the ledger is immutable;
|
|
||||||
* this only DISPLAYS the signed record. */
|
|
||||||
function EventDetailModal({ e, onClose }: { e: LedgerEvent; onClose: () => void }) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const style = EVENT_STYLE[e.type];
|
|
||||||
const label = style ? t(style.labelKey) : e.type.toUpperCase();
|
|
||||||
const p = e.payload;
|
|
||||||
const reason = renderReason(p, t);
|
|
||||||
const amount = paymentSummary(p);
|
|
||||||
const badges = eventBadges(p);
|
|
||||||
const isAnomaly = e.type === "anomaly";
|
|
||||||
|
|
||||||
// Pretty money for any minor-unit amount in the payload.
|
|
||||||
const money =
|
|
||||||
p && typeof p.amountMinor === "number" && typeof p.currency === "string"
|
|
||||||
? formatMoney(p.amountMinor, p.currency)
|
|
||||||
: null;
|
|
||||||
// Pull out the business fields worth a labelled row. Everything else (and the raw
|
|
||||||
// bytes) lives behind the audit disclosure — the operator sees a clean summary.
|
|
||||||
const sessionRef = typeof p?.sessionRef === "string" ? p.sessionRef : null;
|
|
||||||
const plate = typeof p?.plate === "string" ? p.plate : null;
|
|
||||||
const category = typeof p?.category === "string" ? p.category : null;
|
|
||||||
const operator = typeof p?.operator === "string" ? p.operator : null;
|
|
||||||
const tariffVersionId = typeof p?.tariffVersionId === "string" ? p.tariffVersionId : null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal open onClose={onClose} title={t("booth.eventDetail")} width="max-w-2xl">
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
{/* Headline: the type + localized reason, prominent for anomalies. */}
|
|
||||||
<div className={`rounded-term border p-3 ${isAnomaly ? "border-term-red/50 bg-term-red/5" : "border-term-border bg-term-panel-2"}`}>
|
|
||||||
<div className={`text-sm font-bold uppercase tracking-widest ${style?.color ?? "text-term-text"}`}>{label}</div>
|
|
||||||
{(reason || money) && (
|
|
||||||
<div className={`mt-1 text-[13px] ${isAnomaly ? "text-term-red/90" : "text-term-text"}`}>
|
|
||||||
{reason ?? money}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{!reason && !money && isAnomaly && (
|
|
||||||
<div className="mt-1 text-[13px] text-term-red/90">{t("booth.evtNoReason")}</div>
|
|
||||||
)}
|
|
||||||
{badges.length > 0 && (
|
|
||||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
|
||||||
{badges.map((k) => (
|
|
||||||
<span
|
|
||||||
key={k}
|
|
||||||
className="rounded-sm bg-term-red/15 px-1.5 py-px text-[10px] font-semibold uppercase tracking-wide text-term-red"
|
|
||||||
>
|
|
||||||
{t(k)}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Humanized fields — labelled rows, not raw JSON. Only what applies renders. */}
|
|
||||||
<div>
|
|
||||||
<DetailRow label={t("booth.edTime")}>{new Date(e.occurredAt).toLocaleString()}</DetailRow>
|
|
||||||
<DetailRow label={t("booth.edIndex")}>#{e.index}</DetailRow>
|
|
||||||
{e.direction && <DetailRow label={t("booth.edDirection")}>{e.direction}</DetailRow>}
|
|
||||||
{e.source && <DetailRow label={t("booth.edSource")}>{e.source}</DetailRow>}
|
|
||||||
<DetailRow label={t("booth.edIdentity")}>{displayIdentity(e)}</DetailRow>
|
|
||||||
{/* When we showed a subscriber NAME above, also expose the raw occurrence id
|
|
||||||
(the SUBSESS-… session key) for traceability against the ledger. */}
|
|
||||||
{e.subscriberLabel && e.identity && (
|
|
||||||
<DetailRow label={t("booth.edOccurrence")}>
|
|
||||||
<code className="text-[11px] text-term-muted">{e.identity}</code>
|
|
||||||
</DetailRow>
|
|
||||||
)}
|
|
||||||
{money && (
|
|
||||||
<DetailRow label={t("booth.edAmount")}>
|
|
||||||
<span className="text-term-cyan">{money}</span>
|
|
||||||
</DetailRow>
|
|
||||||
)}
|
|
||||||
{typeof p?.tender === "string" && <DetailRow label={t("booth.edTender")}>{p.tender}</DetailRow>}
|
|
||||||
{viaKey(p) && (
|
|
||||||
<DetailRow label={t("booth.edVia")}>
|
|
||||||
<span className="text-term-cyan">{t(viaKey(p)!)}</span>
|
|
||||||
</DetailRow>
|
|
||||||
)}
|
|
||||||
{category && <DetailRow label={t("booth.edCategory")}>{category}</DetailRow>}
|
|
||||||
{plate && <DetailRow label={t("booth.edPlate")}>{plate}</DetailRow>}
|
|
||||||
{operator && <DetailRow label={t("booth.edOperator")}>{operator}</DetailRow>}
|
|
||||||
{sessionRef && sessionRef !== e.identity && (
|
|
||||||
<DetailRow label={t("booth.edSession")}>{sessionRef}</DetailRow>
|
|
||||||
)}
|
|
||||||
{tariffVersionId && (
|
|
||||||
<DetailRow label={t("booth.edTariffVersion")}>
|
|
||||||
<code className="text-[11px] text-term-muted">{tariffVersionId}</code>
|
|
||||||
</DetailRow>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* The entry/exit evidence images for this session's identity. */}
|
|
||||||
{e.identity && (
|
|
||||||
<div>
|
|
||||||
<div className="mb-1.5 text-[11px] uppercase tracking-wider text-term-muted">{t("booth.edSnapshots")}</div>
|
|
||||||
<SnapshotStrip identity={e.identity} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Audit data — collapsed by default. The signed-chain provenance (signature,
|
|
||||||
key, prev-hash) and the raw payload are an auditor's concern, not the
|
|
||||||
operator's; tucking them behind a disclosure keeps the common view clean
|
|
||||||
while preserving the tamper-evidence trail on demand. */}
|
|
||||||
<details className="rounded-term border border-term-border bg-term-panel-2">
|
|
||||||
<summary className="cursor-pointer select-none px-3 py-2 text-[11px] uppercase tracking-wider text-term-muted hover:text-term-text">
|
|
||||||
{t("booth.edAuditData")}
|
|
||||||
</summary>
|
|
||||||
<div className="border-t border-term-border px-3 pb-3 pt-1">
|
|
||||||
<DetailRow label={t("booth.edSignature")}>
|
|
||||||
<code className="break-all text-[11px] text-term-muted">{e.signature}</code>
|
|
||||||
</DetailRow>
|
|
||||||
<DetailRow label={t("booth.edKeyId")}>
|
|
||||||
<code className="text-[11px] text-term-muted">{e.keyId}</code>
|
|
||||||
</DetailRow>
|
|
||||||
<DetailRow label={t("booth.edPrevHash")}>
|
|
||||||
<code className="break-all text-[11px] text-term-muted">{e.prevHash ?? "—"}</code>
|
|
||||||
</DetailRow>
|
|
||||||
<div className="mb-1.5 mt-3 text-[11px] uppercase tracking-wider text-term-muted">
|
|
||||||
{t("booth.edRawPayload")}
|
|
||||||
</div>
|
|
||||||
{p && Object.keys(p).length > 0 ? (
|
|
||||||
<pre className="overflow-x-auto rounded-term border border-term-border bg-term-bg p-2 text-[11px] text-term-text">
|
|
||||||
{JSON.stringify(p, null, 2)}
|
|
||||||
</pre>
|
|
||||||
) : (
|
|
||||||
<div className="text-[12px] text-term-muted">{t("booth.edNoPayload")}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Ticket entry: an HID barcode scanner types the id and presses Enter; a manual
|
/** Ticket entry: an HID barcode scanner types the id and presses Enter; a manual
|
||||||
* operator types it. Either way, submit opens the pay/exit modal for that id. The
|
* operator types it. Either way, submit opens the pay/exit modal for that id. The
|
||||||
@@ -413,6 +133,11 @@ export function BoothScreen() {
|
|||||||
// The ledger event open in the read-only detail modal (null = closed).
|
// The ledger event open in the read-only detail modal (null = closed).
|
||||||
const [detailEvent, setDetailEvent] = useState<LedgerEvent | null>(null);
|
const [detailEvent, setDetailEvent] = useState<LedgerEvent | null>(null);
|
||||||
|
|
||||||
|
// A hardware scan opens the pay/exit modal regardless of focus (the operator needn't
|
||||||
|
// click the ticket field first). Paused while a modal is already up — a scan must not
|
||||||
|
// abandon an in-progress payment (the operator finishes/closes, then scans the next).
|
||||||
|
useScanner({ onScan: setActiveTicket, paused: activeTicket != null || detailEvent != null });
|
||||||
|
|
||||||
// Live-feed filters: free-text search, event category, and direction/source.
|
// Live-feed filters: free-text search, event category, and direction/source.
|
||||||
const [feedSearch, setFeedSearch] = useState("");
|
const [feedSearch, setFeedSearch] = useState("");
|
||||||
const [feedType, setFeedType] = useState<FeedCat | "">("");
|
const [feedType, setFeedType] = useState<FeedCat | "">("");
|
||||||
|
|||||||
@@ -0,0 +1,319 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import type { TFunction } from "i18next";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
Bar,
|
||||||
|
BarChart,
|
||||||
|
CartesianGrid,
|
||||||
|
Cell,
|
||||||
|
Legend,
|
||||||
|
Line,
|
||||||
|
LineChart,
|
||||||
|
Pie,
|
||||||
|
PieChart,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Tooltip,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
} from "recharts";
|
||||||
|
import { fetchReport, reportCsvUrl, type ReportBucket, type ReportSummary } from "./api.js";
|
||||||
|
import { qk } from "./lib/query.js";
|
||||||
|
import { formatMinutes, formatMoney } from "./lib/format.js";
|
||||||
|
|
||||||
|
// Admin Reports — the at-a-glance dashboard over the signed ledger. All numbers come
|
||||||
|
// from the server already aggregated (ledger-first; see apps/server/src/reports.ts), so
|
||||||
|
// this file is pure presentation: date-range presets, KPI cards, and a handful of
|
||||||
|
// Recharts views (entry/exit, revenue cash/card, peak hours, revenue mix, subscriptions).
|
||||||
|
// Themed to the terminal palette. Gated by report:read at the route + server.
|
||||||
|
|
||||||
|
// Terminal palette (mirrors index.css --color-term-*). Recharts wants literal colors.
|
||||||
|
const C = {
|
||||||
|
green: "#2e8c4a", // entry / ok
|
||||||
|
red: "#e8412b", // exit / fault
|
||||||
|
amber: "#f2a516", // accent / cash
|
||||||
|
cyan: "#2563c8", // payment / card
|
||||||
|
muted: "#8a8a82",
|
||||||
|
border: "#2a2f38",
|
||||||
|
text: "#f2f2ee",
|
||||||
|
panel: "#14171c",
|
||||||
|
};
|
||||||
|
|
||||||
|
type PresetKey = "today" | "7d" | "30d" | "90d";
|
||||||
|
|
||||||
|
/** [from, to) ISO bounds + a sensible default bucket for a preset, computed in the
|
||||||
|
* browser's local time (the appliance IS the site, so local == site time). */
|
||||||
|
function presetRange(key: PresetKey): { from: string; to: string; bucket: ReportBucket } {
|
||||||
|
const now = new Date();
|
||||||
|
const to = now.toISOString();
|
||||||
|
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||||
|
if (key === "today") return { from: startOfToday.toISOString(), to, bucket: "hour" };
|
||||||
|
const days = key === "7d" ? 7 : key === "30d" ? 30 : 90;
|
||||||
|
const from = new Date(now.getTime() - days * 86_400_000).toISOString();
|
||||||
|
return { from, to, bucket: days <= 30 ? "day" : "month" };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Reports() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [preset, setPreset] = useState<PresetKey>("30d");
|
||||||
|
const [bucketOverride, setBucketOverride] = useState<ReportBucket | null>(null);
|
||||||
|
|
||||||
|
const range = useMemo(() => presetRange(preset), [preset]);
|
||||||
|
const bucket = bucketOverride ?? range.bucket;
|
||||||
|
|
||||||
|
const { data, isLoading, isError, error } = useQuery({
|
||||||
|
queryKey: qk.report(range.from, range.to, bucket),
|
||||||
|
queryFn: () => fetchReport(range.from, range.to, bucket),
|
||||||
|
});
|
||||||
|
|
||||||
|
const presets: { key: PresetKey; label: string }[] = [
|
||||||
|
{ key: "today", label: t("reports.preset.today") },
|
||||||
|
{ key: "7d", label: t("reports.preset.7d") },
|
||||||
|
{ key: "30d", label: t("reports.preset.30d") },
|
||||||
|
{ key: "90d", label: t("reports.preset.90d") },
|
||||||
|
];
|
||||||
|
const buckets: ReportBucket[] = ["hour", "day", "month"];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-6xl">
|
||||||
|
<div className="mb-3 flex flex-wrap items-center gap-2">
|
||||||
|
<h1 className="mr-2 text-base font-bold uppercase tracking-widest text-term-amber">
|
||||||
|
{t("reports.title")}
|
||||||
|
</h1>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{presets.map((p) => (
|
||||||
|
<button
|
||||||
|
key={p.key}
|
||||||
|
type="button"
|
||||||
|
className={`btn btn-sm ${preset === p.key ? "btn-primary" : "btn-ghost"}`}
|
||||||
|
onClick={() => {
|
||||||
|
setPreset(p.key);
|
||||||
|
setBucketOverride(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{p.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="ml-2 flex items-center gap-1 text-[12px] text-term-muted">
|
||||||
|
<span>{t("reports.groupBy")}</span>
|
||||||
|
<select
|
||||||
|
className="select input-sm w-auto"
|
||||||
|
value={bucket}
|
||||||
|
onChange={(e) => setBucketOverride(e.target.value as ReportBucket)}
|
||||||
|
>
|
||||||
|
{buckets.map((b) => (
|
||||||
|
<option key={b} value={b}>
|
||||||
|
{t(`reports.bucket.${b}`)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
className="btn btn-sm btn-ghost ml-auto"
|
||||||
|
href={reportCsvUrl(range.from, range.to, bucket)}
|
||||||
|
download
|
||||||
|
>
|
||||||
|
{t("reports.exportCsv")}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading && <p className="text-term-muted">{t("common.loading")}</p>}
|
||||||
|
{isError && (
|
||||||
|
<p className="text-term-red">
|
||||||
|
{t("reports.loadFailed", { error: (error as Error)?.message ?? "?" })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{data && <ReportBody data={data} t={t} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReportBody({ data, t }: { data: ReportSummary; t: TFunction }) {
|
||||||
|
const cur = data.currency ?? "ALL";
|
||||||
|
const money = (m: number) => formatMoney(m, cur);
|
||||||
|
const tot = data.totals;
|
||||||
|
|
||||||
|
// Recharts series: label + the metrics. Keep the server's lexically-sortable bucket
|
||||||
|
// labels; trim the date prefix off hour labels for a tighter axis.
|
||||||
|
const series = data.series.map((p) => ({
|
||||||
|
...p,
|
||||||
|
label: data.bucket === "hour" ? p.bucket.slice(11) + "h" : p.bucket,
|
||||||
|
revenue: p.revenueMinor / 100,
|
||||||
|
}));
|
||||||
|
const hours = data.entriesByHour.map((entries, h) => ({ hour: `${h}`, entries }));
|
||||||
|
const mix = [
|
||||||
|
{ name: t("reports.mix.ticket"), value: tot.ticketMinor, color: C.amber },
|
||||||
|
{ name: t("reports.mix.subSales"), value: tot.subscriptionSalesMinor, color: C.cyan },
|
||||||
|
{ name: t("reports.mix.subWindow"), value: tot.subscriptionWindowMinor, color: C.green },
|
||||||
|
].filter((s) => s.value > 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* KPI cards. */}
|
||||||
|
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-6">
|
||||||
|
<Kpi label={t("reports.kpi.entries")} value={String(tot.entries)} accent="green" />
|
||||||
|
<Kpi label={t("reports.kpi.exits")} value={String(tot.exits)} accent="red" />
|
||||||
|
<Kpi label={t("reports.kpi.revenue")} value={money(tot.revenueMinor)} accent="amber" />
|
||||||
|
<Kpi label={t("reports.kpi.payments")} value={String(tot.payments)} accent="cyan" />
|
||||||
|
<Kpi label={t("reports.kpi.avgStay")} value={formatMinutes(tot.avgParkedMinutes)} />
|
||||||
|
<Kpi
|
||||||
|
label={t("reports.kpi.subscribers")}
|
||||||
|
value={String(data.subscriptions.currentlyValid)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Entry / exit over time. */}
|
||||||
|
<Panel title={t("reports.chart.flow")}>
|
||||||
|
<ResponsiveContainer width="100%" height={260}>
|
||||||
|
<LineChart data={series} margin={{ top: 8, right: 12, bottom: 0, left: -8 }}>
|
||||||
|
<CartesianGrid stroke={C.border} strokeDasharray="3 3" />
|
||||||
|
<XAxis dataKey="label" stroke={C.muted} fontSize={11} />
|
||||||
|
<YAxis stroke={C.muted} fontSize={11} allowDecimals={false} />
|
||||||
|
<Tooltip contentStyle={tooltipStyle} />
|
||||||
|
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="entries"
|
||||||
|
name={t("reports.kpi.entries")}
|
||||||
|
stroke={C.green}
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={false}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="exits"
|
||||||
|
name={t("reports.kpi.exits")}
|
||||||
|
stroke={C.red}
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={false}
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
|
{/* Revenue per bucket. */}
|
||||||
|
<Panel title={t("reports.chart.revenue", { currency: cur })}>
|
||||||
|
<ResponsiveContainer width="100%" height={240}>
|
||||||
|
<BarChart data={series} margin={{ top: 8, right: 12, bottom: 0, left: -8 }}>
|
||||||
|
<CartesianGrid stroke={C.border} strokeDasharray="3 3" />
|
||||||
|
<XAxis dataKey="label" stroke={C.muted} fontSize={11} />
|
||||||
|
<YAxis stroke={C.muted} fontSize={11} />
|
||||||
|
<Tooltip contentStyle={tooltipStyle} formatter={(v) => money(Math.round(Number(v) * 100))} />
|
||||||
|
<Bar dataKey="revenue" name={t("reports.kpi.revenue")} fill={C.amber} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
{/* Revenue mix (ticket vs subscription vs window). */}
|
||||||
|
<Panel title={t("reports.chart.mix")}>
|
||||||
|
{mix.length === 0 ? (
|
||||||
|
<Empty t={t} />
|
||||||
|
) : (
|
||||||
|
<ResponsiveContainer width="100%" height={240}>
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={mix}
|
||||||
|
dataKey="value"
|
||||||
|
nameKey="name"
|
||||||
|
innerRadius={48}
|
||||||
|
outerRadius={80}
|
||||||
|
paddingAngle={2}
|
||||||
|
>
|
||||||
|
{mix.map((s) => (
|
||||||
|
<Cell key={s.name} fill={s.color} stroke={C.panel} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip contentStyle={tooltipStyle} formatter={(v) => money(Number(v))} />
|
||||||
|
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
{/* Peak hours (entries by hour-of-day). */}
|
||||||
|
<Panel title={t("reports.chart.peakHours")}>
|
||||||
|
<ResponsiveContainer width="100%" height={240}>
|
||||||
|
<BarChart data={hours} margin={{ top: 8, right: 12, bottom: 0, left: -8 }}>
|
||||||
|
<CartesianGrid stroke={C.border} strokeDasharray="3 3" />
|
||||||
|
<XAxis dataKey="hour" stroke={C.muted} fontSize={11} interval={1} />
|
||||||
|
<YAxis stroke={C.muted} fontSize={11} allowDecimals={false} />
|
||||||
|
<Tooltip contentStyle={tooltipStyle} />
|
||||||
|
<Bar dataKey="entries" name={t("reports.kpi.entries")} fill={C.cyan} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
{/* Cash / card + duration + subscription breakdown (numbers). */}
|
||||||
|
<Panel title={t("reports.chart.breakdown")}>
|
||||||
|
<dl className="grid grid-cols-2 gap-x-6 gap-y-1.5 text-[13px]">
|
||||||
|
<Row label={t("reports.row.cash")} value={money(tot.cashMinor)} />
|
||||||
|
<Row label={t("reports.row.card")} value={money(tot.cardMinor)} />
|
||||||
|
<Row label={t("reports.mix.ticket")} value={money(tot.ticketMinor)} />
|
||||||
|
<Row label={t("reports.mix.subSales")} value={money(tot.subscriptionSalesMinor)} />
|
||||||
|
<Row label={t("reports.mix.subWindow")} value={money(tot.subscriptionWindowMinor)} />
|
||||||
|
<Row label={t("reports.row.closed")} value={String(tot.closedSessions)} />
|
||||||
|
<Row label={t("reports.kpi.avgStay")} value={formatMinutes(tot.avgParkedMinutes)} />
|
||||||
|
<Row label={t("reports.row.medianStay")} value={formatMinutes(tot.medianParkedMinutes)} />
|
||||||
|
<Row label={t("reports.row.subActive")} value={String(data.subscriptions.active)} />
|
||||||
|
<Row label={t("reports.row.subCars")} value={String(data.subscriptions.coveredCars)} />
|
||||||
|
</dl>
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-[11px] text-term-muted">
|
||||||
|
{t("reports.footnote", { tz: data.tz })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tooltipStyle = {
|
||||||
|
background: C.panel,
|
||||||
|
border: `1px solid ${C.border}`,
|
||||||
|
borderRadius: 6,
|
||||||
|
color: C.text,
|
||||||
|
fontSize: 12,
|
||||||
|
};
|
||||||
|
|
||||||
|
function Kpi({ label, value, accent }: { label: string; value: string; accent?: "green" | "red" | "amber" | "cyan" }) {
|
||||||
|
const color =
|
||||||
|
accent === "green"
|
||||||
|
? "text-term-green"
|
||||||
|
: accent === "red"
|
||||||
|
? "text-term-red"
|
||||||
|
: accent === "amber"
|
||||||
|
? "text-term-amber"
|
||||||
|
: accent === "cyan"
|
||||||
|
? "text-term-cyan"
|
||||||
|
: "text-term-text";
|
||||||
|
return (
|
||||||
|
<div className="rounded-term border border-term-border bg-term-panel p-2.5">
|
||||||
|
<div className="text-[11px] uppercase tracking-wider text-term-muted">{label}</div>
|
||||||
|
<div className={`mt-0.5 text-lg font-bold tabular-nums ${color}`}>{value}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Panel({ title, children }: { title: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-term border border-term-border bg-term-panel p-3">
|
||||||
|
<h2 className="mb-2 text-[11px] uppercase tracking-wider text-term-muted">{title}</h2>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Row({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<dt className="text-term-muted">{label}</dt>
|
||||||
|
<dd className="text-right tabular-nums text-term-text">{value}</dd>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Empty({ t }: { t: TFunction }) {
|
||||||
|
return <p className="py-12 text-center text-[12px] text-term-muted">{t("reports.noData")}</p>;
|
||||||
|
}
|
||||||
@@ -7,8 +7,10 @@ import {
|
|||||||
fetchBackendIps,
|
fetchBackendIps,
|
||||||
fetchCatalog,
|
fetchCatalog,
|
||||||
fetchState,
|
fetchState,
|
||||||
|
testAnpr,
|
||||||
testDevice,
|
testDevice,
|
||||||
unassignDevice,
|
unassignDevice,
|
||||||
|
type AnprTestResult,
|
||||||
type Assignment,
|
type Assignment,
|
||||||
type BackendIpCandidate,
|
type BackendIpCandidate,
|
||||||
type Catalog,
|
type Catalog,
|
||||||
@@ -352,6 +354,10 @@ function DeviceForm({
|
|||||||
const [tested, setTested] = useState<TestResult | null>(null);
|
const [tested, setTested] = useState<TestResult | null>(null);
|
||||||
const [testing, setTesting] = useState(false);
|
const [testing, setTesting] = useState(false);
|
||||||
const [testError, setTestError] = useState<string | null>(null);
|
const [testError, setTestError] = useState<string | null>(null);
|
||||||
|
// ANPR probe (camera + anpr on): snapshot → vision analyze, reported below.
|
||||||
|
const [anprResult, setAnprResult] = useState<AnprTestResult | null>(null);
|
||||||
|
const [anprTesting, setAnprTesting] = useState(false);
|
||||||
|
const [anprError, setAnprError] = useState<string | null>(null);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [saveError, setSaveError] = useState<string | null>(null);
|
const [saveError, setSaveError] = useState<string | null>(null);
|
||||||
const [found, setFound] = useState<DiscoveredDevice[] | null>(null);
|
const [found, setFound] = useState<DiscoveredDevice[] | null>(null);
|
||||||
@@ -442,6 +448,8 @@ function DeviceForm({
|
|||||||
setTested(null);
|
setTested(null);
|
||||||
setTestError(null);
|
setTestError(null);
|
||||||
setSaveError(null);
|
setSaveError(null);
|
||||||
|
setAnprResult(null);
|
||||||
|
setAnprError(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function test() {
|
async function test() {
|
||||||
@@ -458,6 +466,23 @@ function DeviceForm({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// End-to-end ANPR probe: capture a frame off this camera and run the vision service
|
||||||
|
// on it, reporting plate + time (or the failure stage). Only meaningful for an
|
||||||
|
// ANPR-enabled camera; never blocks save.
|
||||||
|
async function testAnprNow() {
|
||||||
|
if (!selected) return;
|
||||||
|
setAnprTesting(true);
|
||||||
|
setAnprError(null);
|
||||||
|
setAnprResult(null);
|
||||||
|
try {
|
||||||
|
setAnprResult(await testAnpr(selected.id, mergedScalarConfig()));
|
||||||
|
} catch (e) {
|
||||||
|
setAnprError((e as Error).message);
|
||||||
|
} finally {
|
||||||
|
setAnprTesting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
if (!selected) return;
|
if (!selected) return;
|
||||||
// Bound devices must point at a controller relay (binding is optional in the
|
// Bound devices must point at a controller relay (binding is optional in the
|
||||||
@@ -641,6 +666,40 @@ function DeviceForm({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* CAMERA + ANPR on: a bottom-of-modal end-to-end probe — capture a frame and
|
||||||
|
run the vision service on it, reporting the plate read + how long it took. */}
|
||||||
|
{isCamera && anpr && (
|
||||||
|
<div className="mt-3 rounded-term border border-term-border bg-term-bg p-2">
|
||||||
|
<button type="button" className="btn btn-sm" onClick={testAnprNow} disabled={anprTesting}>
|
||||||
|
{anprTesting ? t("setup.anprTesting") : t("setup.testAnpr")}
|
||||||
|
</button>
|
||||||
|
<p className="hint mt-1">{t("setup.testAnprHint")}</p>
|
||||||
|
|
||||||
|
{anprError && <p className="mt-2 text-[12px] text-term-red">{t("setup.testFailed", { error: anprError })}</p>}
|
||||||
|
{anprResult &&
|
||||||
|
(anprResult.ok ? (
|
||||||
|
<div className="mt-2 text-[12px] text-term-green">
|
||||||
|
{t("setup.anprOk", {
|
||||||
|
plate: anprResult.plate,
|
||||||
|
confidence: Math.round(anprResult.confidence * 100),
|
||||||
|
ms: anprResult.tookMs,
|
||||||
|
})}
|
||||||
|
{anprResult.lowConfidence && (
|
||||||
|
<span className="ml-1 text-term-amber">{t("setup.anprLowConfidence")}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mt-2 text-[12px] text-term-amber">
|
||||||
|
⚠ {t(`setup.anprFail.${anprResult.reason}`, { defaultValue: anprResult.reason })}
|
||||||
|
{anprResult.detail && <span className="text-term-muted"> — {anprResult.detail}</span>}
|
||||||
|
{anprResult.tookMs != null && (
|
||||||
|
<span className="text-term-muted"> ({t("setup.anprTookMs", { ms: anprResult.tookMs })})</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{backendIps && backendIps.length > 0 && (
|
{backendIps && backendIps.length > 0 && (
|
||||||
<div className="mt-3">
|
<div className="mt-3">
|
||||||
<div className="field max-w-md">
|
<div className="field max-w-md">
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
} from "./api.js";
|
} from "./api.js";
|
||||||
import { formatMoney, formatDuration, formatRelativeDateTime } from "./lib/format.js";
|
import { formatMoney, formatDuration, formatRelativeDateTime } from "./lib/format.js";
|
||||||
import { Modal } from "./ui/Modal.js";
|
import { Modal } from "./ui/Modal.js";
|
||||||
|
import { EventDetailModal, EventRow } from "./ui/event-detail.js";
|
||||||
import type { LedgerEvent } from "@parking/shared";
|
import type { LedgerEvent } from "@parking/shared";
|
||||||
|
|
||||||
// Shift hub — a two-pane master/detail. LEFT: the open/CURRENT shift (when any) plus
|
// Shift hub — a two-pane master/detail. LEFT: the open/CURRENT shift (when any) plus
|
||||||
@@ -28,22 +29,6 @@ function money(minor: number, currency: string | null): string {
|
|||||||
return currency ? formatMoney(minor, currency) : (minor / 100).toFixed(2);
|
return currency ? formatMoney(minor, currency) : (minor / 100).toFixed(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Event styling for the activity log (mirrors the booth live feed).
|
|
||||||
const EVENT_STYLE: Record<string, { labelKey: string; color: string }> = {
|
|
||||||
vehicle_entry: { labelKey: "booth.evtEntry", color: "text-term-green" },
|
|
||||||
vehicle_exit: { labelKey: "booth.evtExit", color: "text-term-red" },
|
|
||||||
payment: { labelKey: "booth.evtPay", color: "text-term-cyan" },
|
|
||||||
void: { labelKey: "booth.evtVoid", color: "text-term-amber" },
|
|
||||||
barrier_open_command: { labelKey: "booth.evtOpenCmd", color: "text-term-muted" },
|
|
||||||
barrier_open_observed: { labelKey: "booth.evtOpenObserved", color: "text-term-muted" },
|
|
||||||
shift_open: { labelKey: "booth.evtShiftOpen", color: "text-term-amber" },
|
|
||||||
shift_z_report: { labelKey: "booth.evtShiftZ", color: "text-term-amber" },
|
|
||||||
cash_movement: { labelKey: "booth.evtCashMovement", color: "text-term-cyan" },
|
|
||||||
cash_in: { labelKey: "booth.evtCashIn", color: "text-term-green" },
|
|
||||||
cash_out: { labelKey: "booth.evtCashOut", color: "text-term-amber" },
|
|
||||||
anomaly: { labelKey: "booth.evtAnomaly", color: "text-term-red" },
|
|
||||||
};
|
|
||||||
|
|
||||||
type Preset = "yesterday" | "week" | "month" | "custom" | "all";
|
type Preset = "yesterday" | "week" | "month" | "custom" | "all";
|
||||||
|
|
||||||
/** A preset → an inclusive [from, to] date window (yyyy-mm-dd) over the shift START. */
|
/** A preset → an inclusive [from, to] date window (yyyy-mm-dd) over the shift START. */
|
||||||
@@ -142,9 +127,12 @@ export function ShiftsHistory({ user, canManage = false, canVoucher = false }: {
|
|||||||
|
|
||||||
const PRESETS: Preset[] = ["yesterday", "week", "month", "all", "custom"];
|
const PRESETS: Preset[] = ["yesterday", "week", "month", "all", "custom"];
|
||||||
|
|
||||||
|
// Fill the viewport like the booth: a fixed title + filters, then a two-pane area
|
||||||
|
// that takes the remaining height — the shift LIST and the activity LOG each scroll
|
||||||
|
// on their own rather than the whole page growing.
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="flex h-full min-h-0 flex-col">
|
||||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
<div className="mb-3 flex shrink-0 flex-wrap items-center justify-between gap-2">
|
||||||
<h1 className="text-sm font-bold uppercase tracking-widest text-term-amber">
|
<h1 className="text-sm font-bold uppercase tracking-widest text-term-amber">
|
||||||
{isAdmin ? t("shifts.title") : t("shifts.myTitle")}
|
{isAdmin ? t("shifts.title") : t("shifts.myTitle")}
|
||||||
</h1>
|
</h1>
|
||||||
@@ -155,7 +143,7 @@ export function ShiftsHistory({ user, canManage = false, canVoucher = false }: {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filters: timeframe presets (everyone) + operator (admin only). */}
|
{/* Filters: timeframe presets (everyone) + operator (admin only). */}
|
||||||
<div className="card mb-3 flex flex-wrap items-end gap-3 p-3">
|
<div className="card mb-3 flex shrink-0 flex-wrap items-end gap-3 p-3">
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<span className="label">{t("shifts.timeframe")}</span>
|
<span className="label">{t("shifts.timeframe")}</span>
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
@@ -187,12 +175,13 @@ export function ShiftsHistory({ user, canManage = false, canVoucher = false }: {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{q.isError && (
|
{q.isError && (
|
||||||
<div className="mb-2 rounded-term border border-term-red px-3 py-2 text-[12px] text-term-red">{t("shifts.loadFailed")}</div>
|
<div className="mb-2 shrink-0 rounded-term border border-term-red px-3 py-2 text-[12px] text-term-red">{t("shifts.loadFailed")}</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Two-pane: shift list (left) + selected shift's activity log (right). */}
|
{/* Two-pane: shift list (left) + selected shift's activity log (right). Both
|
||||||
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr)_minmax(0,1.6fr)]">
|
panes scroll independently and fill the remaining height (like the booth). */}
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="grid min-h-0 flex-1 gap-3 md:grid-cols-[minmax(0,1fr)_minmax(0,1.6fr)]">
|
||||||
|
<div className="flex min-h-0 flex-col gap-1.5 overflow-y-auto pr-1">
|
||||||
{!q.isLoading && list.length === 0 && (
|
{!q.isLoading && list.length === 0 && (
|
||||||
<p className="rounded-term border border-term-border px-3 py-3 text-[12px] text-term-muted">{t("shifts.none")}</p>
|
<p className="rounded-term border border-term-border px-3 py-3 text-[12px] text-term-muted">{t("shifts.none")}</p>
|
||||||
)}
|
)}
|
||||||
@@ -201,7 +190,7 @@ export function ShiftsHistory({ user, canManage = false, canVoucher = false }: {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-term border border-term-border">
|
<div className="min-h-0 overflow-hidden rounded-term border border-term-border">
|
||||||
{selected ? (
|
{selected ? (
|
||||||
<ShiftActivityLog
|
<ShiftActivityLog
|
||||||
shift={selected}
|
shift={selected}
|
||||||
@@ -294,6 +283,9 @@ function ShiftActivityLog({
|
|||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [modal, setModal] = useState<null | "end" | "voucher" | "takings">(null);
|
const [modal, setModal] = useState<null | "end" | "voucher" | "takings">(null);
|
||||||
|
// Click an activity row → the SAME read-only event-detail modal the booth feed opens
|
||||||
|
// (full signed payload + snapshots + chain provenance).
|
||||||
|
const [detailEvent, setDetailEvent] = useState<LedgerEvent | null>(null);
|
||||||
|
|
||||||
// The current shift's log runs entry→now (no upper bound); a closed shift is bounded.
|
// The current shift's log runs entry→now (no upper bound); a closed shift is bounded.
|
||||||
const q = useQuery({
|
const q = useQuery({
|
||||||
@@ -304,9 +296,10 @@ function ShiftActivityLog({
|
|||||||
const events = q.data?.events ?? [];
|
const events = q.data?.events ?? [];
|
||||||
const cur = shift.currency;
|
const cur = shift.currency;
|
||||||
|
|
||||||
|
// Fill the pane: a fixed header + a scrollable activity list (matches the booth feed).
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="flex h-full min-h-0 flex-col">
|
||||||
<div className="border-b border-term-border bg-term-panel-2 px-3 py-2">
|
<div className="shrink-0 border-b border-term-border bg-term-panel-2 px-3 py-2">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2 text-[12px]">
|
<div className="flex flex-wrap items-center justify-between gap-2 text-[12px]">
|
||||||
<span className="flex items-center gap-2 font-semibold text-term-text">
|
<span className="flex items-center gap-2 font-semibold text-term-text">
|
||||||
{isCurrent && <span className="rounded border border-term-green px-1 text-[10px] text-term-green">{t("shifts.current")}</span>}
|
{isCurrent && <span className="rounded border border-term-green px-1 text-[10px] text-term-green">{t("shifts.current")}</span>}
|
||||||
@@ -337,14 +330,15 @@ function ShiftActivityLog({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="max-h-[62vh] overflow-y-auto">
|
<div className="min-h-0 flex-1 overflow-y-auto px-1">
|
||||||
{q.isLoading && <p className="px-3 py-3 text-[12px] text-term-muted">{t("common.loading")}</p>}
|
{q.isLoading && <p className="px-3 py-3 text-[12px] text-term-muted">{t("common.loading")}</p>}
|
||||||
{!q.isLoading && events.length === 0 && <p className="px-3 py-3 text-[12px] text-term-muted">{t("shifts.noActivity")}</p>}
|
{!q.isLoading && events.length === 0 && <p className="px-3 py-3 text-[12px] text-term-muted">{t("shifts.noActivity")}</p>}
|
||||||
{events.map((e) => (
|
{events.map((e) => (
|
||||||
<ActivityRow key={e.id} e={e} />
|
<EventRow key={e.id} e={e} onOpen={setDetailEvent} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{detailEvent && <EventDetailModal e={detailEvent} onClose={() => setDetailEvent(null)} />}
|
||||||
{modal === "end" && <EndShiftModal shift={shift} onClose={() => setModal(null)} onDone={onChanged} />}
|
{modal === "end" && <EndShiftModal shift={shift} onClose={() => setModal(null)} onDone={onChanged} />}
|
||||||
{modal === "voucher" && <VoucherModal currency={cur} onClose={() => setModal(null)} onDone={onChanged} />}
|
{modal === "voucher" && <VoucherModal currency={cur} onClose={() => setModal(null)} onDone={onChanged} />}
|
||||||
{modal === "takings" && <TakingsModal onClose={() => setModal(null)} />}
|
{modal === "takings" && <TakingsModal onClose={() => setModal(null)} />}
|
||||||
@@ -516,23 +510,6 @@ function TakingsModal({ onClose }: { onClose: () => void }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ActivityRow({ e }: { e: LedgerEvent }) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const style = EVENT_STYLE[e.type] ?? { labelKey: "", color: "text-term-text" };
|
|
||||||
const time = new Date(e.occurredAt).toLocaleTimeString();
|
|
||||||
const p = e.payload ?? {};
|
|
||||||
const amount = typeof p.amountMinor === "number" && p.amountMinor !== 0 ? money(p.amountMinor, (p.currency as string) ?? null) : null;
|
|
||||||
const actor = (e.subscriberLabel as string | undefined) ?? e.identity ?? (p.sessionRef as string | undefined) ?? "";
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-2 border-t border-term-border/60 px-3 py-1.5 text-[12px] first:border-t-0">
|
|
||||||
<span className="w-16 shrink-0 tabular-nums text-term-muted">{time}</span>
|
|
||||||
<span className={`w-20 shrink-0 font-semibold uppercase ${style.color}`}>{style.labelKey ? t(style.labelKey) : e.type}</span>
|
|
||||||
<span className="min-w-0 flex-1 truncate text-term-text" title={actor}>{actor}</span>
|
|
||||||
{amount && <span className="shrink-0 tabular-nums text-term-cyan">{amount}</span>}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Figure({ label, value, bold, sub }: { label: string; value: string; bold?: boolean; sub?: boolean }) {
|
function Figure({ label, value, bold, sub }: { label: string; value: string; bold?: boolean; sub?: boolean }) {
|
||||||
return (
|
return (
|
||||||
<div className={`flex justify-between gap-2 ${sub ? "pl-3" : ""}`}>
|
<div className={`flex justify-between gap-2 ${sub ? "pl-3" : ""}`}>
|
||||||
|
|||||||
@@ -280,6 +280,94 @@ export function testDevice(driverId: string, config: DeviceConfig): Promise<Test
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Result of an end-to-end ANPR probe on a camera: snapshot → vision analyze. */
|
||||||
|
export type AnprTestResult =
|
||||||
|
| {
|
||||||
|
ok: true;
|
||||||
|
plate: string;
|
||||||
|
confidence: number;
|
||||||
|
region: string | null;
|
||||||
|
lowConfidence: boolean;
|
||||||
|
modelVersion: string;
|
||||||
|
tookMs: number;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
ok: false;
|
||||||
|
/** vision-disabled | snapshot-failed | no-plate */
|
||||||
|
reason: string;
|
||||||
|
detail?: string;
|
||||||
|
tookMs?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Take a live snapshot off the camera and run ANPR on it — without saving. Reports
|
||||||
|
* whether a plate was extracted, the read, and how long it took. */
|
||||||
|
export function testAnpr(driverId: string, config: DeviceConfig): Promise<AnprTestResult> {
|
||||||
|
return apiFetch<AnprTestResult>("/api/setup/test-anpr", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ driverId, config }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Admin reports -------------------------------------------------------
|
||||||
|
export type ReportBucket = "hour" | "day" | "month";
|
||||||
|
|
||||||
|
export interface ReportSeriesPoint {
|
||||||
|
bucket: string;
|
||||||
|
entries: number;
|
||||||
|
exits: number;
|
||||||
|
revenueMinor: number;
|
||||||
|
payments: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReportTotals {
|
||||||
|
entries: number;
|
||||||
|
exits: number;
|
||||||
|
payments: number;
|
||||||
|
revenueMinor: number;
|
||||||
|
cashMinor: number;
|
||||||
|
cardMinor: number;
|
||||||
|
ticketMinor: number;
|
||||||
|
subscriptionSalesMinor: number;
|
||||||
|
subscriptionWindowMinor: number;
|
||||||
|
closedSessions: number;
|
||||||
|
totalParkedMinutes: number;
|
||||||
|
avgParkedMinutes: number;
|
||||||
|
medianParkedMinutes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReportSubscriptionStats {
|
||||||
|
active: number;
|
||||||
|
suspended: number;
|
||||||
|
revoked: number;
|
||||||
|
currentlyValid: number;
|
||||||
|
coveredCars: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReportSummary {
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
bucket: ReportBucket;
|
||||||
|
tz: string;
|
||||||
|
currency: string | null;
|
||||||
|
totals: ReportTotals;
|
||||||
|
series: ReportSeriesPoint[];
|
||||||
|
entriesByHour: number[];
|
||||||
|
subscriptions: ReportSubscriptionStats;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The whole admin dashboard (totals + series + peak-hours + subscriptions) for a range. */
|
||||||
|
export function fetchReport(from: string, to: string, bucket: ReportBucket): Promise<ReportSummary> {
|
||||||
|
const qs = new URLSearchParams({ from, to, bucket }).toString();
|
||||||
|
return apiFetch<ReportSummary>(`/api/reports/summary?${qs}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** URL for the CSV export of the per-bucket series (opened/downloaded directly; the
|
||||||
|
* auth cookie rides along same-origin). */
|
||||||
|
export function reportCsvUrl(from: string, to: string, bucket: ReportBucket): string {
|
||||||
|
const qs = new URLSearchParams({ from, to, bucket }).toString();
|
||||||
|
return apiUrl(`/api/reports/summary.csv?${qs}`);
|
||||||
|
}
|
||||||
|
|
||||||
export interface BackendIpCandidate {
|
export interface BackendIpCandidate {
|
||||||
ip: string;
|
ip: string;
|
||||||
iface: string;
|
iface: string;
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { formatMoney, formatDuration, formatTime, formatRelativeDateTime, type TFn } from "./format.js";
|
||||||
|
|
||||||
|
// The booth's display formatters. Money is integer MINOR units (never a float, matching
|
||||||
|
// the ledger/tariff model); duration is whole minutes; relative dates drive the session/
|
||||||
|
// log/history rows. These are the numbers an operator reads off the screen.
|
||||||
|
|
||||||
|
describe("formatMoney", () => {
|
||||||
|
it("renders minor units as a major-unit currency string", () => {
|
||||||
|
// 20000 minor = 200.00; the exact glyph/locale varies, but the number must show.
|
||||||
|
expect(formatMoney(20000, "ALL")).toContain("200");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to '<n> <code>' for a malformed currency code", () => {
|
||||||
|
// Intl requires a 3-letter ISO code; a malformed one throws RangeError → fallback.
|
||||||
|
// (Note: an unknown-but-well-formed code like "ZZZ" does NOT throw — Intl renders it.)
|
||||||
|
expect(formatMoney(12345, "X")).toBe("123.45 X");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatDuration", () => {
|
||||||
|
const base = "2026-06-21T10:00:00.000Z";
|
||||||
|
it("shows minutes under an hour", () => {
|
||||||
|
expect(formatDuration(base, "2026-06-21T10:47:00.000Z")).toBe("47m");
|
||||||
|
});
|
||||||
|
it("shows hours and minutes past an hour", () => {
|
||||||
|
expect(formatDuration(base, "2026-06-21T12:14:00.000Z")).toBe("2h 14m");
|
||||||
|
});
|
||||||
|
it("renders 0m for a sub-minute span", () => {
|
||||||
|
expect(formatDuration(base, "2026-06-21T10:00:30.000Z")).toBe("0m");
|
||||||
|
});
|
||||||
|
it("returns an em dash for a negative or invalid span", () => {
|
||||||
|
expect(formatDuration("2026-06-21T10:00:00.000Z", "2026-06-21T09:00:00.000Z")).toBe("—");
|
||||||
|
expect(formatDuration("bad", "also-bad")).toBe("—");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatTime", () => {
|
||||||
|
it("returns an em dash for null/invalid", () => {
|
||||||
|
expect(formatTime(null)).toBe("—");
|
||||||
|
expect(formatTime("not-a-date")).toBe("—");
|
||||||
|
});
|
||||||
|
it("renders HH:MM:SS local time", () => {
|
||||||
|
expect(formatTime("2026-06-21T10:48:25.000Z")).toMatch(/^\d{2}:\d{2}:\d{2}$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatRelativeDateTime", () => {
|
||||||
|
// A tiny fake t(): today/yesterday words + the month-name array.
|
||||||
|
const months = ["Jan","Shkurt","Mars","Prill","Maj","Qershor","Korrik","Gusht","Sht","Tet","Nën","Dhj"];
|
||||||
|
const t = ((key: string, opts?: { returnObjects: true }) => {
|
||||||
|
if (key === "common.today") return "Sot";
|
||||||
|
if (key === "common.yesterday") return "Dje";
|
||||||
|
if (key === "common.months" && opts?.returnObjects) return months;
|
||||||
|
return key;
|
||||||
|
}) as TFn;
|
||||||
|
|
||||||
|
it("labels today with the localized word + HH:MM", () => {
|
||||||
|
const now = new Date();
|
||||||
|
now.setHours(10, 48, 0, 0);
|
||||||
|
expect(formatRelativeDateTime(now.toISOString(), t)).toMatch(/^Sot \d{2}:\d{2}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("labels yesterday with the localized word", () => {
|
||||||
|
const y = new Date();
|
||||||
|
y.setDate(y.getDate() - 1);
|
||||||
|
y.setHours(17, 33, 0, 0);
|
||||||
|
expect(formatRelativeDateTime(y.toISOString(), t)).toMatch(/^Dje \d{2}:\d{2}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the catalog month name for an older date (no Intl dependence)", () => {
|
||||||
|
// A fixed older date in the same year as 'now' would risk year drift; use an
|
||||||
|
// explicit past date and just assert a catalog month name appears.
|
||||||
|
const out = formatRelativeDateTime("2020-03-05T08:15:00.000Z", t);
|
||||||
|
expect(out).toContain("Mars");
|
||||||
|
expect(out).toContain("2020"); // different year → year shown
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns an em dash for null/invalid", () => {
|
||||||
|
expect(formatRelativeDateTime(null, t)).toBe("—");
|
||||||
|
expect(formatRelativeDateTime("nope", t)).toBe("—");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -22,6 +22,14 @@ export function formatDuration(fromIso: string, toIso: string): string {
|
|||||||
return h > 0 ? `${h}h ${m}m` : `${m}m`;
|
return h > 0 ? `${h}h ${m}m` : `${m}m`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Human duration from whole minutes, e.g. 134 → "2h 14m", 47 → "47m", 0 → "0m". */
|
||||||
|
export function formatMinutes(mins: number): string {
|
||||||
|
if (!Number.isFinite(mins) || mins < 0) return "—";
|
||||||
|
const m = Math.round(mins);
|
||||||
|
const h = Math.floor(m / 60);
|
||||||
|
return h > 0 ? `${h}h ${m % 60}m` : `${m}m`;
|
||||||
|
}
|
||||||
|
|
||||||
/** Local time-of-day HH:MM:SS from an ISO string. */
|
/** Local time-of-day HH:MM:SS from an ISO string. */
|
||||||
export function formatTime(iso: string | null): string {
|
export function formatTime(iso: string | null): string {
|
||||||
if (!iso) return "—";
|
if (!iso) return "—";
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ export const en: Catalog = {
|
|||||||
users: "Users",
|
users: "Users",
|
||||||
roles: "Roles",
|
roles: "Roles",
|
||||||
shifts: "Shifts",
|
shifts: "Shifts",
|
||||||
|
reports: "Reports",
|
||||||
logs: "Logs",
|
logs: "Logs",
|
||||||
},
|
},
|
||||||
status: {
|
status: {
|
||||||
@@ -344,6 +345,16 @@ export const en: Catalog = {
|
|||||||
anpr: "Plate recognition (ANPR)",
|
anpr: "Plate recognition (ANPR)",
|
||||||
anprHint:
|
anprHint:
|
||||||
"Enable to scan plates on this camera: the vision service reads the plate from a snapshot and feeds it as a read (advisory only — it never opens a barrier on its own). Requires the vision service running.",
|
"Enable to scan plates on this camera: the vision service reads the plate from a snapshot and feeds it as a read (advisory only — it never opens a barrier on its own). Requires the vision service running.",
|
||||||
|
testAnpr: "Test ANPR",
|
||||||
|
anprTesting: "Testing ANPR…",
|
||||||
|
testAnprHint:
|
||||||
|
"Takes a live snapshot from this camera and tries to read a plate, reporting the result and the time it took. Point a plate at the camera first.",
|
||||||
|
anprOk: "✓ Read plate {{plate}} — {{confidence}}% confidence, {{ms}} ms",
|
||||||
|
anprLowConfidence: "(low confidence — advisory only)",
|
||||||
|
anprTookMs: "{{ms}} ms",
|
||||||
|
"anprFail.vision-disabled": "Vision service is disabled — enable it (VISION_ENABLED) to test ANPR.",
|
||||||
|
"anprFail.snapshot-failed": "Couldn't take a snapshot from the camera (offline or unreachable).",
|
||||||
|
"anprFail.no-plate": "No plate found in the snapshot.",
|
||||||
whichBarrier: "Which barrier does this device serve?",
|
whichBarrier: "Which barrier does this device serve?",
|
||||||
controller: "Controller",
|
controller: "Controller",
|
||||||
choose: "Choose…",
|
choose: "Choose…",
|
||||||
@@ -667,6 +678,40 @@ export const en: Catalog = {
|
|||||||
cashRemoved: "Cash removed",
|
cashRemoved: "Cash removed",
|
||||||
loadFailed: "Failed to load shifts.",
|
loadFailed: "Failed to load shifts.",
|
||||||
},
|
},
|
||||||
|
reports: {
|
||||||
|
title: "Reports",
|
||||||
|
groupBy: "Group by",
|
||||||
|
exportCsv: "Export CSV",
|
||||||
|
loadFailed: "Couldn't load the report: {{error}}",
|
||||||
|
noData: "No data in this range.",
|
||||||
|
footnote: "Counts and money are summed from the signed event log. Times shown in {{tz}}.",
|
||||||
|
preset: { today: "Today", "7d": "7 days", "30d": "30 days", "90d": "90 days" },
|
||||||
|
bucket: { hour: "Hour", day: "Day", month: "Month" },
|
||||||
|
kpi: {
|
||||||
|
entries: "Entries",
|
||||||
|
exits: "Exits",
|
||||||
|
revenue: "Revenue",
|
||||||
|
payments: "Payments",
|
||||||
|
avgStay: "Avg stay",
|
||||||
|
subscribers: "Subscribers",
|
||||||
|
},
|
||||||
|
chart: {
|
||||||
|
flow: "Entries & exits over time",
|
||||||
|
revenue: "Revenue ({{currency}})",
|
||||||
|
mix: "Revenue mix",
|
||||||
|
peakHours: "Entries by hour of day",
|
||||||
|
breakdown: "Breakdown",
|
||||||
|
},
|
||||||
|
mix: { ticket: "Transient", subSales: "Subscriptions", subWindow: "Out-of-window" },
|
||||||
|
row: {
|
||||||
|
cash: "Cash",
|
||||||
|
card: "Card",
|
||||||
|
closed: "Closed sessions",
|
||||||
|
medianStay: "Median stay",
|
||||||
|
subActive: "Active subscriptions",
|
||||||
|
subCars: "Cars covered",
|
||||||
|
},
|
||||||
|
},
|
||||||
logs: {
|
logs: {
|
||||||
title: "System logs",
|
title: "System logs",
|
||||||
refresh: "Refresh",
|
refresh: "Refresh",
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ export const sq = {
|
|||||||
users: "Përdoruesit",
|
users: "Përdoruesit",
|
||||||
roles: "Rolet",
|
roles: "Rolet",
|
||||||
shifts: "Turnet",
|
shifts: "Turnet",
|
||||||
|
reports: "Raportet",
|
||||||
logs: "Loget",
|
logs: "Loget",
|
||||||
},
|
},
|
||||||
status: {
|
status: {
|
||||||
@@ -354,6 +355,16 @@ export const sq = {
|
|||||||
anpr: "Njohja e targave (ANPR)",
|
anpr: "Njohja e targave (ANPR)",
|
||||||
anprHint:
|
anprHint:
|
||||||
"Aktivizo që ky aparat të skanojë targat: shërbimi i vizionit lexon targën nga pamja dhe e dërgon si lexim (vetëm këshillues — nuk hap vetë barrierën). Kërkon shërbimin e vizionit aktiv.",
|
"Aktivizo që ky aparat të skanojë targat: shërbimi i vizionit lexon targën nga pamja dhe e dërgon si lexim (vetëm këshillues — nuk hap vetë barrierën). Kërkon shërbimin e vizionit aktiv.",
|
||||||
|
testAnpr: "Testo ANPR",
|
||||||
|
anprTesting: "Duke testuar ANPR…",
|
||||||
|
testAnprHint:
|
||||||
|
"Merr një pamje të drejtpërdrejtë nga kjo kamerë dhe përpiqet të lexojë një targë, duke raportuar rezultatin dhe kohën e nevojshme. Vendos një targë para kamerës më parë.",
|
||||||
|
anprOk: "✓ Targa u lexua {{plate}} — {{confidence}}% besueshmëri, {{ms}} ms",
|
||||||
|
anprLowConfidence: "(besueshmëri e ulët — vetëm këshillues)",
|
||||||
|
anprTookMs: "{{ms}} ms",
|
||||||
|
"anprFail.vision-disabled": "Shërbimi i vizionit është çaktivizuar — aktivizoje (VISION_ENABLED) për ta testuar ANPR.",
|
||||||
|
"anprFail.snapshot-failed": "Nuk u mor dot pamje nga kamera (jashtë linje ose e paarritshme).",
|
||||||
|
"anprFail.no-plate": "Nuk u gjet asnjë targë në pamje.",
|
||||||
// Binding picker.
|
// Binding picker.
|
||||||
whichBarrier: "Cilën barrierë shërben kjo pajisje?",
|
whichBarrier: "Cilën barrierë shërben kjo pajisje?",
|
||||||
controller: "Kontrolluesi",
|
controller: "Kontrolluesi",
|
||||||
@@ -669,7 +680,7 @@ export const sq = {
|
|||||||
preset_week: "Javën e fundit",
|
preset_week: "Javën e fundit",
|
||||||
preset_month: "Muajin e fundit",
|
preset_month: "Muajin e fundit",
|
||||||
preset_all: "Të gjitha",
|
preset_all: "Të gjitha",
|
||||||
preset_custom: "E zgjedhur",
|
preset_custom: "Zgjidh periudhë",
|
||||||
selectAShift: "Zgjidh një turn për të parë aktivitetin e tij.",
|
selectAShift: "Zgjidh një turn për të parë aktivitetin e tij.",
|
||||||
noActivity: "Asnjë aktivitet në këtë turn.",
|
noActivity: "Asnjë aktivitet në këtë turn.",
|
||||||
current: "aktual",
|
current: "aktual",
|
||||||
@@ -681,6 +692,40 @@ export const sq = {
|
|||||||
cashRemoved: "Para të hequra",
|
cashRemoved: "Para të hequra",
|
||||||
loadFailed: "Ngarkimi i turneve dështoi.",
|
loadFailed: "Ngarkimi i turneve dështoi.",
|
||||||
},
|
},
|
||||||
|
reports: {
|
||||||
|
title: "Raportet",
|
||||||
|
groupBy: "Grupo sipas",
|
||||||
|
exportCsv: "Eksporto CSV",
|
||||||
|
loadFailed: "Raporti nuk u ngarkua dot: {{error}}",
|
||||||
|
noData: "Nuk ka të dhëna në këtë interval.",
|
||||||
|
footnote: "Numërimet dhe paratë mblidhen nga regjistri i nënshkruar. Oraret në {{tz}}.",
|
||||||
|
preset: { today: "Sot", "7d": "7 ditë", "30d": "30 ditë", "90d": "90 ditë" },
|
||||||
|
bucket: { hour: "Orë", day: "Ditë", month: "Muaj" },
|
||||||
|
kpi: {
|
||||||
|
entries: "Hyrje",
|
||||||
|
exits: "Dalje",
|
||||||
|
revenue: "Të ardhura",
|
||||||
|
payments: "Pagesa",
|
||||||
|
avgStay: "Qëndrim mes.",
|
||||||
|
subscribers: "Abonentë",
|
||||||
|
},
|
||||||
|
chart: {
|
||||||
|
flow: "Hyrjet & daljet me kalimin e kohës",
|
||||||
|
revenue: "Të ardhurat ({{currency}})",
|
||||||
|
mix: "Përbërja e të ardhurave",
|
||||||
|
peakHours: "Hyrjet sipas orës së ditës",
|
||||||
|
breakdown: "Ndarja",
|
||||||
|
},
|
||||||
|
mix: { ticket: "Tranzit", subSales: "Abonime", subWindow: "Jashtë orarit" },
|
||||||
|
row: {
|
||||||
|
cash: "Para në dorë",
|
||||||
|
card: "Kartë",
|
||||||
|
closed: "Sesione të mbyllura",
|
||||||
|
medianStay: "Qëndrim mesatar (median)",
|
||||||
|
subActive: "Abonime aktive",
|
||||||
|
subCars: "Makina të mbuluara",
|
||||||
|
},
|
||||||
|
},
|
||||||
logs: {
|
logs: {
|
||||||
title: "Loget e sistemit",
|
title: "Loget e sistemit",
|
||||||
refresh: "Rifresko",
|
refresh: "Rifresko",
|
||||||
|
|||||||
@@ -27,4 +27,6 @@ export const qk = {
|
|||||||
siteConfig: ["site-config"] as const,
|
siteConfig: ["site-config"] as const,
|
||||||
shift: ["shift"] as const,
|
shift: ["shift"] as const,
|
||||||
deviceStatus: ["device-status"] as const,
|
deviceStatus: ["device-status"] as const,
|
||||||
|
report: (from: string, to: string, bucket: string) =>
|
||||||
|
["report", from, to, bucket] as const,
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { renderHook } from "@testing-library/react";
|
||||||
|
import { useScanner } from "./use-scanner.js";
|
||||||
|
|
||||||
|
// The global hardware-scanner hook: a fast keystroke burst ended by Enter fires onScan,
|
||||||
|
// regardless of focus, WITHOUT hijacking human typing or editable fields, and pauses
|
||||||
|
// while a modal is open. This pins the 2026-06-21 focus-independent scan behaviour
|
||||||
|
// (otherwise only verifiable in Playwright).
|
||||||
|
|
||||||
|
afterEach(() => vi.restoreAllMocks());
|
||||||
|
|
||||||
|
/** Dispatch a keydown on the document with a controllable timeStamp (the hook measures
|
||||||
|
* inter-key gaps off e.timeStamp). jsdom sets timeStamp to 0, so we override it. */
|
||||||
|
function key(char: string, timeStamp: number, target: EventTarget = document.body) {
|
||||||
|
const e = new KeyboardEvent("keydown", { key: char, bubbles: true, cancelable: true });
|
||||||
|
Object.defineProperty(e, "timeStamp", { value: timeStamp });
|
||||||
|
Object.defineProperty(e, "target", { value: target });
|
||||||
|
document.dispatchEvent(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Type a code as a fast burst (5ms apart) ending in Enter, from a start time. */
|
||||||
|
function scan(code: string, start = 1000, gap = 5) {
|
||||||
|
let t = start;
|
||||||
|
for (const ch of code) { key(ch, t); t += gap; }
|
||||||
|
key("Enter", t);
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("useScanner", () => {
|
||||||
|
it("fires onScan with the code on a fast burst + Enter (focus on body)", () => {
|
||||||
|
const onScan = vi.fn();
|
||||||
|
renderHook(() => useScanner({ onScan }));
|
||||||
|
scan("12345678901");
|
||||||
|
expect(onScan).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onScan).toHaveBeenCalledWith("12345678901");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores slow, human-paced typing (gap > 50ms resets the buffer)", () => {
|
||||||
|
const onScan = vi.fn();
|
||||||
|
renderHook(() => useScanner({ onScan }));
|
||||||
|
// 120ms between keys — a person, not a scanner. Each gap resets the buffer, so by
|
||||||
|
// Enter only the last char remains (< MIN_LENGTH) → no scan.
|
||||||
|
scan("123", 1000, 120);
|
||||||
|
expect(onScan).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not fire while paused (a modal is open)", () => {
|
||||||
|
const onScan = vi.fn();
|
||||||
|
renderHook(() => useScanner({ onScan, paused: true }));
|
||||||
|
scan("12345678901");
|
||||||
|
expect(onScan).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores keystrokes into an editable field (manual typing unaffected)", () => {
|
||||||
|
const onScan = vi.fn();
|
||||||
|
renderHook(() => useScanner({ onScan }));
|
||||||
|
const input = document.createElement("input");
|
||||||
|
document.body.appendChild(input);
|
||||||
|
scanInto("12345678901", input);
|
||||||
|
expect(onScan).not.toHaveBeenCalled();
|
||||||
|
input.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores a lone Enter / too-short burst", () => {
|
||||||
|
const onScan = vi.fn();
|
||||||
|
renderHook(() => useScanner({ onScan }));
|
||||||
|
key("Enter", 1000);
|
||||||
|
expect(onScan).not.toHaveBeenCalled();
|
||||||
|
scan("ab"); // length 2 < MIN_LENGTH 3
|
||||||
|
expect(onScan).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Burst with the event target set to an editable element. */
|
||||||
|
function scanInto(code: string, target: EventTarget, start = 1000, gap = 5) {
|
||||||
|
let t = start;
|
||||||
|
for (const ch of code) { key(ch, t, target); t += gap; }
|
||||||
|
key("Enter", t, target);
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
// Global hardware-scanner capture (HID "keyboard wedge"). A barcode/QR scanner types
|
||||||
|
// the code as a fast keystroke burst followed by Enter — like a keyboard, but far
|
||||||
|
// faster than a human. This hook listens at the DOCUMENT level so a scan fires the
|
||||||
|
// callback no matter what's focused (or if nothing is), unlike a single <input> that
|
||||||
|
// only catches scans while it holds focus. See wiki/concepts/booth-console.md.
|
||||||
|
//
|
||||||
|
// It does NOT hijack manual typing: keystrokes into an <input>/<textarea>/editable
|
||||||
|
// element are left to that field (the booth's ticket input still works by hand). The
|
||||||
|
// burst heuristic — chars arriving faster than a human could type, ended by Enter —
|
||||||
|
// is what distinguishes a scan from a person pressing keys with nothing focused.
|
||||||
|
|
||||||
|
/** Tuning. A scanner emits keystrokes ~1–20ms apart; a human is ≥80–100ms. */
|
||||||
|
const MAX_INTERKEY_MS = 50; // a gap longer than this resets the buffer (not one scan)
|
||||||
|
const MIN_LENGTH = 3; // ignore stray single Enter presses / very short bursts
|
||||||
|
|
||||||
|
interface ScannerOptions {
|
||||||
|
/** Called with the scanned code (trimmed) when a burst completes with Enter. */
|
||||||
|
onScan: (code: string) => void;
|
||||||
|
/** When true, scans are ignored (e.g. a modal is already open — don't interrupt an
|
||||||
|
* in-progress payment). The listener stays attached; it just no-ops. */
|
||||||
|
paused?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Capture hardware-scanner input globally. The callback fires on the Enter that ends a
|
||||||
|
* fast keystroke burst, regardless of focus. Editable-field keystrokes are ignored so
|
||||||
|
* manual typing is unaffected. */
|
||||||
|
export function useScanner({ onScan, paused = false }: ScannerOptions): void {
|
||||||
|
// Keep the latest callback + paused flag in refs so the effect's listener never goes
|
||||||
|
// stale and we don't re-attach on every render.
|
||||||
|
const onScanRef = useRef(onScan);
|
||||||
|
const pausedRef = useRef(paused);
|
||||||
|
onScanRef.current = onScan;
|
||||||
|
pausedRef.current = paused;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let buffer = "";
|
||||||
|
let lastTime = 0;
|
||||||
|
|
||||||
|
function isEditableTarget(el: EventTarget | null): boolean {
|
||||||
|
if (!(el instanceof HTMLElement)) return false;
|
||||||
|
const tag = el.tagName;
|
||||||
|
return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || el.isContentEditable;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeyDown(e: KeyboardEvent) {
|
||||||
|
// Let the focused field (and its form) handle its own keystrokes — the manual
|
||||||
|
// ticket input submits via its form's onSubmit; we only cover the un-focused case.
|
||||||
|
if (isEditableTarget(e.target)) return;
|
||||||
|
|
||||||
|
const now = e.timeStamp || performance.now();
|
||||||
|
const gap = now - lastTime;
|
||||||
|
lastTime = now;
|
||||||
|
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
const code = buffer.trim();
|
||||||
|
buffer = "";
|
||||||
|
// Only a fast-burst code of reasonable length counts as a scan; a lone Enter or
|
||||||
|
// a slowly-assembled string (a person mashing keys) is ignored.
|
||||||
|
if (code.length >= MIN_LENGTH && !pausedRef.current) {
|
||||||
|
e.preventDefault();
|
||||||
|
onScanRef.current(code);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A gap too long means a new (human-paced) sequence — start the buffer over.
|
||||||
|
if (gap > MAX_INTERKEY_MS) buffer = "";
|
||||||
|
|
||||||
|
// Accumulate printable single characters (scanner codes: digits + SUB-/SUBSESS-…).
|
||||||
|
if (e.key.length === 1) buffer += e.key;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("keydown", onKeyDown);
|
||||||
|
return () => document.removeEventListener("keydown", onKeyDown);
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
+98
-42
@@ -6,7 +6,7 @@ import {
|
|||||||
Outlet,
|
Outlet,
|
||||||
redirect,
|
redirect,
|
||||||
} from "@tanstack/react-router";
|
} from "@tanstack/react-router";
|
||||||
import { useState } from "react";
|
import { lazy, Suspense, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import type { Lang, Permission, SessionUser, Theme } from "./api.js";
|
import type { Lang, Permission, SessionUser, Theme } from "./api.js";
|
||||||
@@ -30,6 +30,9 @@ import { UsersManager } from "./UsersManager.js";
|
|||||||
import { RolesManager } from "./RolesManager.js";
|
import { RolesManager } from "./RolesManager.js";
|
||||||
import { ShiftsHistory } from "./ShiftsHistory.js";
|
import { ShiftsHistory } from "./ShiftsHistory.js";
|
||||||
import { LogsViewer } from "./LogsViewer.js";
|
import { LogsViewer } from "./LogsViewer.js";
|
||||||
|
// Reports pulls in Recharts (~heavy) — lazy-loaded so it stays OUT of the booth's
|
||||||
|
// initial bundle and only downloads when an admin opens /setup/reports.
|
||||||
|
const Reports = lazy(() => import("./Reports.js").then((m) => ({ default: m.Reports })));
|
||||||
|
|
||||||
// Code-based TanStack Router (no file-based codegen — the app is small enough that
|
// Code-based TanStack Router (no file-based codegen — the app is small enough that
|
||||||
// an explicit tree is clearer). The router context carries the signed-in user and
|
// an explicit tree is clearer). The router context carries the signed-in user and
|
||||||
@@ -82,13 +85,9 @@ function SetupLayout() {
|
|||||||
<nav className="mb-4 flex flex-wrap items-center gap-1 border-b border-term-border">
|
<nav className="mb-4 flex flex-wrap items-center gap-1 border-b border-term-border">
|
||||||
{show("site:update") && <SetupTab to="/setup" label={t("nav.devices")} exact />}
|
{show("site:update") && <SetupTab to="/setup" label={t("nav.devices")} exact />}
|
||||||
{show("tariff:read") && <SetupTab to="/setup/tariff" label={t("nav.tariff")} />}
|
{show("tariff:read") && <SetupTab to="/setup/tariff" label={t("nav.tariff")} />}
|
||||||
{show("tariff:read") && <SetupTab to="/setup/tariff-lab" label={t("nav.tariffLab")} />}
|
|
||||||
{show("subscription:read") && <SetupTab to="/setup/subscriptions" label={t("nav.subscriptions")} />}
|
|
||||||
{show("subscription:plan") && <SetupTab to="/setup/plans" label={t("nav.plans")} />}
|
|
||||||
{show("site:read") && <SetupTab to="/setup/site" label={t("nav.site")} />}
|
{show("site:read") && <SetupTab to="/setup/site" label={t("nav.site")} />}
|
||||||
{show("user:read") && <SetupTab to="/setup/users" label={t("nav.users")} />}
|
{show("user:read") && <SetupTab to="/setup/users" label={t("nav.users")} />}
|
||||||
{show("role:read") && <SetupTab to="/setup/roles" label={t("nav.roles")} />}
|
{show("role:read") && <SetupTab to="/setup/roles" label={t("nav.roles")} />}
|
||||||
{show("shift:read") && <SetupTab to="/setup/shifts" label={t("nav.shifts")} />}
|
|
||||||
{show("log:read") && <SetupTab to="/setup/logs" label={t("nav.logs")} />}
|
{show("log:read") && <SetupTab to="/setup/logs" label={t("nav.logs")} />}
|
||||||
</nav>
|
</nav>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
@@ -96,6 +95,26 @@ function SetupLayout() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Subscriptions layout — a standalone top-level section (its own header nav entry),
|
||||||
|
* with tabs for the subscriber catalog, the plan catalog, and the tariff lab. Each
|
||||||
|
* tab is a gated child route; an operator with only subscription:read sees just the
|
||||||
|
* first tab. */
|
||||||
|
function SubscriptionsLayout() {
|
||||||
|
const { user } = rootRoute.useRouteContext();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const show = (perm: Permission) => can(user, perm);
|
||||||
|
return (
|
||||||
|
<div className="">
|
||||||
|
<nav className="mb-4 flex flex-wrap items-center gap-1 border-b border-term-border">
|
||||||
|
{show("subscription:read") && <SetupTab to="/subscriptions" label={t("nav.subscriptions")} exact />}
|
||||||
|
{show("subscription:plan") && <SetupTab to="/subscriptions/plans" label={t("nav.plans")} />}
|
||||||
|
{show("tariff:read") && <SetupTab to="/subscriptions/tariff-lab" label={t("nav.tariffLab")} />}
|
||||||
|
</nav>
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** SQ/EN toggle. Persists the choice to the user's profile (restored on next login)
|
/** SQ/EN toggle. Persists the choice to the user's profile (restored on next login)
|
||||||
* and applies it immediately. Updates the router-context user so App re-syncs. */
|
* and applies it immediately. Updates the router-context user so App re-syncs. */
|
||||||
function LanguageToggle({
|
function LanguageToggle({
|
||||||
@@ -352,13 +371,19 @@ function RootLayout() {
|
|||||||
<span className="text-sm font-bold uppercase tracking-widest text-term-amber">▮ Parking</span>
|
<span className="text-sm font-bold uppercase tracking-widest text-term-amber">▮ Parking</span>
|
||||||
<nav className="flex items-center gap-1">
|
<nav className="flex items-center gap-1">
|
||||||
<NavLink to="/booth" label={t("nav.booth")} />
|
<NavLink to="/booth" label={t("nav.booth")} />
|
||||||
<NavLink to="/shift" label={t("nav.shift")} />
|
<NavLink to="/shifts" label={t("nav.shifts")} />
|
||||||
{/* One Setup entry — its tabs hold devices/tariff/subscriptions/site/users/
|
{/* Subscriptions — a standalone section (Abonimet / Planet / Lab tarife).
|
||||||
roles/shifts. Shown if the user can reach ANY of those screens (an
|
Shown if the user can reach ANY of its tabs. */}
|
||||||
operator with only shift:read still gets in, landing on Shifts). */}
|
{(show("subscription:read") || show("subscription:plan") || show("tariff:read")) && (
|
||||||
|
<NavLink to="/subscriptions" label={t("nav.subscriptions")} />
|
||||||
|
)}
|
||||||
|
{/* Reports — a standalone admin section (own header entry, route /reports). */}
|
||||||
|
{show("report:read") && <NavLink to="/reports" label={t("nav.reports")} />}
|
||||||
|
{/* One Setup entry — its tabs hold devices/tariff/site/users/roles/logs.
|
||||||
|
Shown if the user can reach ANY of those screens (an operator with only
|
||||||
|
shift:read still gets in, landing on Shifts). */}
|
||||||
{(show("site:update") ||
|
{(show("site:update") ||
|
||||||
show("tariff:read") ||
|
show("tariff:read") ||
|
||||||
show("subscription:read") ||
|
|
||||||
show("site:read") ||
|
show("site:read") ||
|
||||||
show("user:read") ||
|
show("user:read") ||
|
||||||
show("role:read") ||
|
show("role:read") ||
|
||||||
@@ -407,15 +432,22 @@ const boothRoute = createRoute({
|
|||||||
component: BoothScreen,
|
component: BoothScreen,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Back-compat: the config screens used to be top-level routes. They now live under
|
// Back-compat redirects for paths that moved. Most config screens live under /setup;
|
||||||
// /setup as tabs — redirect the old paths so existing bookmarks/links don't 404.
|
// Subscriptions/Plans/Tariff-Lab were promoted OUT of /setup into the standalone
|
||||||
|
// /subscriptions section (2026-06-21) — redirect the old /setup/* paths too so existing
|
||||||
|
// bookmarks/links don't 404. (No "/subscriptions" entry: that's now a REAL route.)
|
||||||
const legacyRedirects = (
|
const legacyRedirects = (
|
||||||
[
|
[
|
||||||
["/tariff", "/setup/tariff"],
|
["/tariff", "/setup/tariff"],
|
||||||
["/subscriptions", "/setup/subscriptions"],
|
|
||||||
["/site", "/setup/site"],
|
["/site", "/setup/site"],
|
||||||
["/users", "/setup/users"],
|
["/users", "/setup/users"],
|
||||||
["/roles", "/setup/roles"],
|
["/roles", "/setup/roles"],
|
||||||
|
["/shift", "/shifts"],
|
||||||
|
["/setup/subscriptions", "/subscriptions"],
|
||||||
|
["/setup/plans", "/subscriptions/plans"],
|
||||||
|
["/setup/tariff-lab", "/subscriptions/tariff-lab"],
|
||||||
|
["/setup/shifts", "/shifts"],
|
||||||
|
["/setup/reports", "/reports"],
|
||||||
] as const
|
] as const
|
||||||
).map(([from, to]) =>
|
).map(([from, to]) =>
|
||||||
createRoute({
|
createRoute({
|
||||||
@@ -427,9 +459,25 @@ const legacyRedirects = (
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Admin reports/charts — a top-level section (own header nav entry), NOT a Setup tab.
|
||||||
|
// Gated by report:read. Lazy component (Recharts) in a Suspense so it stays out of the
|
||||||
|
// booth's initial bundle.
|
||||||
|
const reportsRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: "/reports",
|
||||||
|
beforeLoad: ({ context }) => requirePerm("report:read")(context),
|
||||||
|
component: function ReportsRoute() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<div className="p-3 text-term-muted">…</div>}>
|
||||||
|
<Reports />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const shiftRoute = createRoute({
|
const shiftRoute = createRoute({
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
path: "/shift",
|
path: "/shifts",
|
||||||
component: function ShiftRoute() {
|
component: function ShiftRoute() {
|
||||||
const { user } = rootRoute.useRouteContext();
|
const { user } = rootRoute.useRouteContext();
|
||||||
// The shift hub: list (current/open shift on top + history) + per-shift activity log.
|
// The shift hub: list (current/open shift on top + history) + per-shift activity log.
|
||||||
@@ -457,15 +505,15 @@ function requirePerm(perm: Permission) {
|
|||||||
|
|
||||||
// The Setup tabs in display order, each with the permission its screen needs. Used
|
// The Setup tabs in display order, each with the permission its screen needs. Used
|
||||||
// to land a user on the FIRST tab they may see when they open /setup without
|
// to land a user on the FIRST tab they may see when they open /setup without
|
||||||
// `site:update` (e.g. an operator who only has shift:read → goes to /setup/shifts).
|
// `site:update` (e.g. an operator who only has shift:read → goes to the standalone
|
||||||
|
// /shifts hub, which is no longer a Setup tab).
|
||||||
const SETUP_TABS: { to: string; perm: Permission }[] = [
|
const SETUP_TABS: { to: string; perm: Permission }[] = [
|
||||||
{ to: "/setup", perm: "site:update" },
|
{ to: "/setup", perm: "site:update" },
|
||||||
{ to: "/setup/tariff", perm: "tariff:read" },
|
{ to: "/setup/tariff", perm: "tariff:read" },
|
||||||
{ to: "/setup/subscriptions", perm: "subscription:read" },
|
|
||||||
{ to: "/setup/site", perm: "site:read" },
|
{ to: "/setup/site", perm: "site:read" },
|
||||||
{ to: "/setup/users", perm: "user:read" },
|
{ to: "/setup/users", perm: "user:read" },
|
||||||
{ to: "/setup/roles", perm: "role:read" },
|
{ to: "/setup/roles", perm: "role:read" },
|
||||||
{ to: "/setup/shifts", perm: "shift:read" },
|
{ to: "/shifts", perm: "shift:read" },
|
||||||
{ to: "/setup/logs", perm: "log:read" },
|
{ to: "/setup/logs", perm: "log:read" },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -496,27 +544,42 @@ const tariffRoute = createRoute({
|
|||||||
beforeLoad: ({ context }) => requirePerm("tariff:read")(context),
|
beforeLoad: ({ context }) => requirePerm("tariff:read")(context),
|
||||||
component: () => <TariffComposer />,
|
component: () => <TariffComposer />,
|
||||||
});
|
});
|
||||||
const tariffLabRoute = createRoute({
|
|
||||||
getParentRoute: () => setupRoute,
|
// --- /subscriptions — a standalone top-level section with its own tabs. The catalog
|
||||||
path: "tariff-lab",
|
// (index), the plan catalog, and the tariff lab live here, not under /setup. ---
|
||||||
beforeLoad: ({ context }) => requirePerm("tariff:read")(context),
|
|
||||||
component: () => <TariffLab />,
|
|
||||||
});
|
|
||||||
const subscriptionsRoute = createRoute({
|
const subscriptionsRoute = createRoute({
|
||||||
getParentRoute: () => setupRoute,
|
getParentRoute: () => rootRoute,
|
||||||
path: "subscriptions",
|
path: "/subscriptions",
|
||||||
beforeLoad: ({ context }) => requirePerm("subscription:read")(context),
|
component: SubscriptionsLayout,
|
||||||
|
});
|
||||||
|
// Index tab = the subscriber catalog at /subscriptions exactly. A user lacking
|
||||||
|
// subscription:read is redirected to the first sub-tab they CAN see (or the booth).
|
||||||
|
const subscriptionsIndexRoute = createRoute({
|
||||||
|
getParentRoute: () => subscriptionsRoute,
|
||||||
|
path: "/",
|
||||||
|
beforeLoad: ({ context }) => {
|
||||||
|
if (can(context.user, "subscription:read")) return;
|
||||||
|
if (can(context.user, "subscription:plan")) throw redirect({ to: "/subscriptions/plans" });
|
||||||
|
if (can(context.user, "tariff:read")) throw redirect({ to: "/subscriptions/tariff-lab" });
|
||||||
|
throw redirect({ to: "/booth" });
|
||||||
|
},
|
||||||
component: function SubscriptionsRoute() {
|
component: function SubscriptionsRoute() {
|
||||||
const { user } = rootRoute.useRouteContext();
|
const { user } = rootRoute.useRouteContext();
|
||||||
return <SubscriptionManager user={user} />;
|
return <SubscriptionManager user={user} />;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const subscriptionPlansRoute = createRoute({
|
const subscriptionPlansRoute = createRoute({
|
||||||
getParentRoute: () => setupRoute,
|
getParentRoute: () => subscriptionsRoute,
|
||||||
path: "plans",
|
path: "plans",
|
||||||
beforeLoad: ({ context }) => requirePerm("subscription:plan")(context),
|
beforeLoad: ({ context }) => requirePerm("subscription:plan")(context),
|
||||||
component: () => <SubscriptionPlansManager />,
|
component: () => <SubscriptionPlansManager />,
|
||||||
});
|
});
|
||||||
|
const tariffLabRoute = createRoute({
|
||||||
|
getParentRoute: () => subscriptionsRoute,
|
||||||
|
path: "tariff-lab",
|
||||||
|
beforeLoad: ({ context }) => requirePerm("tariff:read")(context),
|
||||||
|
component: () => <TariffLab />,
|
||||||
|
});
|
||||||
const siteRoute = createRoute({
|
const siteRoute = createRoute({
|
||||||
getParentRoute: () => setupRoute,
|
getParentRoute: () => setupRoute,
|
||||||
path: "site",
|
path: "site",
|
||||||
@@ -544,17 +607,8 @@ const rolesRoute = createRoute({
|
|||||||
return <RolesManager user={user} />;
|
return <RolesManager user={user} />;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
// Shift history. Gated by shift:read (operators have it) — the SERVER scopes the
|
// (Shift history lives at the standalone /shifts route — see shiftRoute. It was
|
||||||
// data: operators see only their own; shift:cash holders see all + can filter.
|
// removed as a Setup tab; /setup/shifts and the old /shift both redirect there.)
|
||||||
const shiftsHistoryRoute = createRoute({
|
|
||||||
getParentRoute: () => setupRoute,
|
|
||||||
path: "shifts",
|
|
||||||
beforeLoad: ({ context }) => requirePerm("shift:read")(context),
|
|
||||||
component: function ShiftsHistoryRoute() {
|
|
||||||
const { user } = rootRoute.useRouteContext();
|
|
||||||
return <ShiftsHistory user={user} />;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Diagnostic logs. Gated by log:read (an admin/diagnostic permission).
|
// Diagnostic logs. Gated by log:read (an admin/diagnostic permission).
|
||||||
const logsRoute = createRoute({
|
const logsRoute = createRoute({
|
||||||
@@ -569,16 +623,18 @@ const routeTree = rootRoute.addChildren([
|
|||||||
boothRoute,
|
boothRoute,
|
||||||
...legacyRedirects,
|
...legacyRedirects,
|
||||||
shiftRoute,
|
shiftRoute,
|
||||||
|
reportsRoute,
|
||||||
|
subscriptionsRoute.addChildren([
|
||||||
|
subscriptionsIndexRoute,
|
||||||
|
subscriptionPlansRoute,
|
||||||
|
tariffLabRoute,
|
||||||
|
]),
|
||||||
setupRoute.addChildren([
|
setupRoute.addChildren([
|
||||||
setupDevicesRoute,
|
setupDevicesRoute,
|
||||||
tariffRoute,
|
tariffRoute,
|
||||||
tariffLabRoute,
|
|
||||||
subscriptionsRoute,
|
|
||||||
subscriptionPlansRoute,
|
|
||||||
siteRoute,
|
siteRoute,
|
||||||
usersRoute,
|
usersRoute,
|
||||||
rolesRoute,
|
rolesRoute,
|
||||||
shiftsHistoryRoute,
|
|
||||||
logsRoute,
|
logsRoute,
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -0,0 +1,282 @@
|
|||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { type ReactNode } from "react";
|
||||||
|
import { type LedgerEvent } from "../api.js";
|
||||||
|
import { formatMoney } from "../lib/format.js";
|
||||||
|
import { renderReason } from "../lib/reason.js";
|
||||||
|
import { Modal } from "./Modal.js";
|
||||||
|
import { SnapshotStrip } from "./SnapshotStrip.js";
|
||||||
|
|
||||||
|
// Shared ledger-event presentation: the colour/label map, the clickable feed ROW, and
|
||||||
|
// the read-only DETAIL modal (full signed payload + snapshots + chain provenance). Used
|
||||||
|
// by the booth live feed AND the shift activity log so both render — and open — events
|
||||||
|
// identically. See wiki/concepts/append-only-event-chain.md.
|
||||||
|
|
||||||
|
export const EVENT_STYLE: Record<string, { labelKey: string; color: string }> = {
|
||||||
|
vehicle_entry: { labelKey: "booth.evtEntry", color: "text-term-green" },
|
||||||
|
vehicle_exit: { labelKey: "booth.evtExit", color: "text-term-red" },
|
||||||
|
payment: { labelKey: "booth.evtPay", color: "text-term-cyan" },
|
||||||
|
void: { labelKey: "booth.evtVoid", color: "text-term-amber" },
|
||||||
|
barrier_open_command: { labelKey: "booth.evtOpenCmd", color: "text-term-muted" },
|
||||||
|
barrier_open_observed: { labelKey: "booth.evtOpenObserved", color: "text-term-muted" },
|
||||||
|
shift_open: { labelKey: "booth.evtShiftOpen", color: "text-term-amber" },
|
||||||
|
shift_z_report: { labelKey: "booth.evtShiftZ", color: "text-term-amber" },
|
||||||
|
cash_movement: { labelKey: "booth.evtCashMovement", color: "text-term-cyan" },
|
||||||
|
cash_in: { labelKey: "booth.evtCashIn", color: "text-term-green" },
|
||||||
|
cash_out: { labelKey: "booth.evtCashOut", color: "text-term-amber" },
|
||||||
|
anomaly: { labelKey: "booth.evtAnomaly", color: "text-term-red" },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Local time-of-day, terminal style. Defensive against a bad timestamp. */
|
||||||
|
function hhmmss(iso: string): string {
|
||||||
|
const d = new Date(iso);
|
||||||
|
return Number.isNaN(d.getTime()) ? "--:--:--" : d.toTimeString().slice(0, 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Red-flag classification badges computed from the signed payload. */
|
||||||
|
export function eventBadges(p: LedgerEvent["payload"]): string[] {
|
||||||
|
if (!p) return [];
|
||||||
|
const keys: string[] = [];
|
||||||
|
if (p.entryRefused) keys.push("booth.badgeEntryRefused");
|
||||||
|
if (p.exitRefused) keys.push("booth.badgeExitRefused");
|
||||||
|
if (p.full) keys.push("booth.badgeLotFull");
|
||||||
|
if (p.exitOpenFailed) keys.push("booth.badgeBarrierFailed");
|
||||||
|
if (p.permitRefused) keys.push("booth.badgeSubRefused");
|
||||||
|
if (p.ticketPrinted === false) keys.push("booth.badgeNoTicket");
|
||||||
|
if (p.subscriptionSale) keys.push("booth.badgeSubSale");
|
||||||
|
// Subscriber entered outside their plan's allowed window → will owe a transient charge
|
||||||
|
// for the minutes actually parked out-of-window, priced + collected (gated) at exit.
|
||||||
|
// (`windowOwedMinor` is the old fixed-amount stamp, kept so historic events still badge.)
|
||||||
|
if (p.outOfWindow === true || (typeof p.windowOwedMinor === "number" && p.windowOwedMinor > 0))
|
||||||
|
keys.push("booth.badgeWindowCharge");
|
||||||
|
if (p.source === "manual" && !p.subscriptionSale) keys.push("booth.badgeManualOpen");
|
||||||
|
return keys;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The i18n key for a subscriber's access medium (`via`), or null. */
|
||||||
|
export function viaKey(p: LedgerEvent["payload"]): string | null {
|
||||||
|
if (!p) return null;
|
||||||
|
if (p.via === "qr") return "booth.viaQr";
|
||||||
|
if (p.via === "card") return "booth.viaCard";
|
||||||
|
if (p.via === "plate") return "booth.viaPlate";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A short money summary for payment events (e.g. "350.00 ALL"). */
|
||||||
|
export function paymentSummary(p: LedgerEvent["payload"]): string | null {
|
||||||
|
if (!p || typeof p.amountMinor !== "number" || !p.currency) return null;
|
||||||
|
return formatMoney(p.amountMinor, p.currency);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What to SHOW for an event's actor. A subscription occurrence has an opaque
|
||||||
|
* `SUBSESS-…` identity; the server resolves the holder's name into `subscriberLabel`,
|
||||||
|
* so we show that (e.g. "Aqif Kopertoni") instead. Otherwise the identity itself. */
|
||||||
|
export function displayIdentity(e: LedgerEvent): string {
|
||||||
|
return e.subscriberLabel ?? e.identity ?? "—";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One clickable live-feed / activity row → opens the event-detail modal. A grid keeps
|
||||||
|
* the time/label/identity/index columns aligned across rows; the detail line lives in
|
||||||
|
* its own row, indented under the identity column. */
|
||||||
|
export function EventRow({ e, onOpen }: { e: LedgerEvent; onOpen: (e: LedgerEvent) => void }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const style = EVENT_STYLE[e.type];
|
||||||
|
const label = style ? t(style.labelKey) : e.type.toUpperCase();
|
||||||
|
const isAnomaly = e.type === "anomaly";
|
||||||
|
const p = e.payload;
|
||||||
|
const reason = renderReason(p, t);
|
||||||
|
const amount = paymentSummary(p);
|
||||||
|
const badges = eventBadges(p);
|
||||||
|
const via = viaKey(p);
|
||||||
|
const detail = reason ?? amount ?? (isAnomaly ? t("booth.evtNoReason") : null);
|
||||||
|
const showDetail = detail != null || badges.length > 0 || via != null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onOpen(e)}
|
||||||
|
className={`grid w-full grid-cols-[auto_5rem_1fr_auto] items-center gap-x-3 gap-y-0.5 border-b border-term-border/50 px-1 py-1 text-left text-[12px] tabular-nums hover:bg-term-panel-2 ${
|
||||||
|
isAnomaly ? "bg-term-red/5" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="text-term-muted">{hhmmss(e.occurredAt)}</span>
|
||||||
|
<span className={`shrink-0 font-semibold ${style?.color ?? "text-term-text"}`}>{label}</span>
|
||||||
|
<span className="flex min-w-0 items-center gap-2">
|
||||||
|
<span className="truncate text-term-text">{displayIdentity(e)}</span>
|
||||||
|
{e.plate && (
|
||||||
|
<span
|
||||||
|
className="shrink-0 rounded border border-term-border px-1 text-[11px] font-semibold tracking-wide text-term-amber"
|
||||||
|
title={t("booth.plateTitle")}
|
||||||
|
>
|
||||||
|
{e.plate}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="text-term-muted">#{e.index}</span>
|
||||||
|
{showDetail && (
|
||||||
|
<div className="col-start-3 col-end-5 flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||||
|
{badges.map((k) => (
|
||||||
|
<span
|
||||||
|
key={k}
|
||||||
|
className="rounded-sm bg-term-red/15 px-1.5 py-px text-[10px] font-semibold uppercase tracking-wide text-term-red"
|
||||||
|
>
|
||||||
|
{t(k)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{via && (
|
||||||
|
<span className="rounded-sm bg-term-cyan/15 px-1.5 py-px text-[10px] font-semibold uppercase tracking-wide text-term-cyan">
|
||||||
|
{t(via)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{detail && (
|
||||||
|
<span className={`text-[11px] ${isAnomaly ? "text-term-red/90" : "text-term-muted"}`}>{detail}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One label/value line in the event-detail modal. */
|
||||||
|
function DetailRow({ label, children }: { label: string; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-[8rem_1fr] gap-3 border-b border-term-border/40 py-1.5 text-[12px]">
|
||||||
|
<span className="text-[11px] uppercase tracking-wider text-term-muted">{label}</span>
|
||||||
|
<span className="min-w-0 break-words text-term-text">{children}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Full read-only detail for one ledger event: business fields + the human-readable
|
||||||
|
* reason + the session's entry/exit snapshots, then the signed-chain provenance
|
||||||
|
* (signature/prev-hash/key) for an audit trail. Read-only — the ledger is immutable;
|
||||||
|
* this only DISPLAYS the signed record. */
|
||||||
|
export function EventDetailModal({ e, onClose }: { e: LedgerEvent; onClose: () => void }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const style = EVENT_STYLE[e.type];
|
||||||
|
const label = style ? t(style.labelKey) : e.type.toUpperCase();
|
||||||
|
const p = e.payload;
|
||||||
|
const reason = renderReason(p, t);
|
||||||
|
const badges = eventBadges(p);
|
||||||
|
const isAnomaly = e.type === "anomaly";
|
||||||
|
|
||||||
|
// Pretty money for any minor-unit amount in the payload.
|
||||||
|
const money =
|
||||||
|
p && typeof p.amountMinor === "number" && typeof p.currency === "string"
|
||||||
|
? formatMoney(p.amountMinor, p.currency)
|
||||||
|
: null;
|
||||||
|
// Pull out the business fields worth a labelled row. Everything else (and the raw
|
||||||
|
// bytes) lives behind the audit disclosure — the operator sees a clean summary.
|
||||||
|
const sessionRef = typeof p?.sessionRef === "string" ? p.sessionRef : null;
|
||||||
|
const plate = typeof p?.plate === "string" ? p.plate : null;
|
||||||
|
const category = typeof p?.category === "string" ? p.category : null;
|
||||||
|
const operator = typeof p?.operator === "string" ? p.operator : null;
|
||||||
|
const tariffVersionId = typeof p?.tariffVersionId === "string" ? p.tariffVersionId : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open onClose={onClose} title={t("booth.eventDetail")} width="max-w-2xl">
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{/* Headline: the type + localized reason, prominent for anomalies. */}
|
||||||
|
<div className={`rounded-term border p-3 ${isAnomaly ? "border-term-red/50 bg-term-red/5" : "border-term-border bg-term-panel-2"}`}>
|
||||||
|
<div className={`text-sm font-bold uppercase tracking-widest ${style?.color ?? "text-term-text"}`}>{label}</div>
|
||||||
|
{(reason || money) && (
|
||||||
|
<div className={`mt-1 text-[13px] ${isAnomaly ? "text-term-red/90" : "text-term-text"}`}>
|
||||||
|
{reason ?? money}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!reason && !money && isAnomaly && (
|
||||||
|
<div className="mt-1 text-[13px] text-term-red/90">{t("booth.evtNoReason")}</div>
|
||||||
|
)}
|
||||||
|
{badges.length > 0 && (
|
||||||
|
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||||
|
{badges.map((k) => (
|
||||||
|
<span
|
||||||
|
key={k}
|
||||||
|
className="rounded-sm bg-term-red/15 px-1.5 py-px text-[10px] font-semibold uppercase tracking-wide text-term-red"
|
||||||
|
>
|
||||||
|
{t(k)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Humanized fields — labelled rows, not raw JSON. Only what applies renders. */}
|
||||||
|
<div>
|
||||||
|
<DetailRow label={t("booth.edTime")}>{new Date(e.occurredAt).toLocaleString()}</DetailRow>
|
||||||
|
<DetailRow label={t("booth.edIndex")}>#{e.index}</DetailRow>
|
||||||
|
{e.direction && <DetailRow label={t("booth.edDirection")}>{e.direction}</DetailRow>}
|
||||||
|
{e.source && <DetailRow label={t("booth.edSource")}>{e.source}</DetailRow>}
|
||||||
|
<DetailRow label={t("booth.edIdentity")}>{displayIdentity(e)}</DetailRow>
|
||||||
|
{/* When we showed a subscriber NAME above, also expose the raw occurrence id
|
||||||
|
(the SUBSESS-… session key) for traceability against the ledger. */}
|
||||||
|
{e.subscriberLabel && e.identity && (
|
||||||
|
<DetailRow label={t("booth.edOccurrence")}>
|
||||||
|
<code className="text-[11px] text-term-muted">{e.identity}</code>
|
||||||
|
</DetailRow>
|
||||||
|
)}
|
||||||
|
{money && (
|
||||||
|
<DetailRow label={t("booth.edAmount")}>
|
||||||
|
<span className="text-term-cyan">{money}</span>
|
||||||
|
</DetailRow>
|
||||||
|
)}
|
||||||
|
{typeof p?.tender === "string" && <DetailRow label={t("booth.edTender")}>{p.tender}</DetailRow>}
|
||||||
|
{viaKey(p) && (
|
||||||
|
<DetailRow label={t("booth.edVia")}>
|
||||||
|
<span className="text-term-cyan">{t(viaKey(p)!)}</span>
|
||||||
|
</DetailRow>
|
||||||
|
)}
|
||||||
|
{category && <DetailRow label={t("booth.edCategory")}>{category}</DetailRow>}
|
||||||
|
{plate && <DetailRow label={t("booth.edPlate")}>{plate}</DetailRow>}
|
||||||
|
{operator && <DetailRow label={t("booth.edOperator")}>{operator}</DetailRow>}
|
||||||
|
{sessionRef && sessionRef !== e.identity && (
|
||||||
|
<DetailRow label={t("booth.edSession")}>{sessionRef}</DetailRow>
|
||||||
|
)}
|
||||||
|
{tariffVersionId && (
|
||||||
|
<DetailRow label={t("booth.edTariffVersion")}>
|
||||||
|
<code className="text-[11px] text-term-muted">{tariffVersionId}</code>
|
||||||
|
</DetailRow>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* The entry/exit evidence images for this session's identity. */}
|
||||||
|
{e.identity && (
|
||||||
|
<div>
|
||||||
|
<div className="mb-1.5 text-[11px] uppercase tracking-wider text-term-muted">{t("booth.edSnapshots")}</div>
|
||||||
|
<SnapshotStrip identity={e.identity} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Audit data — collapsed by default. The signed-chain provenance (signature,
|
||||||
|
key, prev-hash) and the raw payload are an auditor's concern, not the
|
||||||
|
operator's; tucking them behind a disclosure keeps the common view clean
|
||||||
|
while preserving the tamper-evidence trail on demand. */}
|
||||||
|
<details className="rounded-term border border-term-border bg-term-panel-2">
|
||||||
|
<summary className="cursor-pointer select-none px-3 py-2 text-[11px] uppercase tracking-wider text-term-muted hover:text-term-text">
|
||||||
|
{t("booth.edAuditData")}
|
||||||
|
</summary>
|
||||||
|
<div className="border-t border-term-border px-3 pb-3 pt-1">
|
||||||
|
<DetailRow label={t("booth.edSignature")}>
|
||||||
|
<code className="break-all text-[11px] text-term-muted">{e.signature}</code>
|
||||||
|
</DetailRow>
|
||||||
|
<DetailRow label={t("booth.edKeyId")}>
|
||||||
|
<code className="text-[11px] text-term-muted">{e.keyId}</code>
|
||||||
|
</DetailRow>
|
||||||
|
<DetailRow label={t("booth.edPrevHash")}>
|
||||||
|
<code className="break-all text-[11px] text-term-muted">{e.prevHash ?? "—"}</code>
|
||||||
|
</DetailRow>
|
||||||
|
<div className="mb-1.5 mt-3 text-[11px] uppercase tracking-wider text-term-muted">
|
||||||
|
{t("booth.edRawPayload")}
|
||||||
|
</div>
|
||||||
|
{p && Object.keys(p).length > 0 ? (
|
||||||
|
<pre className="overflow-x-auto rounded-term border border-term-border bg-term-bg p-2 text-[11px] text-term-text">
|
||||||
|
{JSON.stringify(p, null, 2)}
|
||||||
|
</pre>
|
||||||
|
) : (
|
||||||
|
<div className="text-[12px] text-term-muted">{t("booth.edNoPayload")}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
|
// Web unit tests: pure formatters (no DOM) + the global hardware-scanner hook (needs a
|
||||||
|
// document, so jsdom). Kept minimal — the booth/live-feed/modal flows are still verified
|
||||||
|
// manually (Playwright); this pins the testable pure logic + the focus-independent scan.
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
test: {
|
||||||
|
environment: "jsdom",
|
||||||
|
include: ["src/**/*.test.{ts,tsx}"],
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -11,6 +11,10 @@
|
|||||||
"./schema": {
|
"./schema": {
|
||||||
"types": "./dist/schema.d.ts",
|
"types": "./dist/schema.d.ts",
|
||||||
"default": "./dist/schema.js"
|
"default": "./dist/schema.js"
|
||||||
|
},
|
||||||
|
"./testing": {
|
||||||
|
"types": "./dist/testing.d.ts",
|
||||||
|
"default": "./dist/testing.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"main": "./dist/index.js",
|
"main": "./dist/index.js",
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import * as schema from "./schema.js";
|
|||||||
export * from "./schema.js";
|
export * from "./schema.js";
|
||||||
// Re-export the query helpers consumers need, so they don't depend on
|
// Re-export the query helpers consumers need, so they don't depend on
|
||||||
// drizzle-orm directly (it's an implementation detail of this package).
|
// drizzle-orm directly (it's an implementation detail of this package).
|
||||||
export { eq, and, desc, gte, lte, sql } from "drizzle-orm";
|
export { eq, and, asc, desc, gte, lte, sql } from "drizzle-orm";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Open the local SQLite database in WAL mode. WAL allows many concurrent readers
|
* Open the local SQLite database in WAL mode. WAL allows many concurrent readers
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { dirname, resolve } from "node:path";
|
||||||
|
import Database from "better-sqlite3";
|
||||||
|
import { drizzle } from "drizzle-orm/better-sqlite3";
|
||||||
|
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
||||||
|
import * as schema from "./schema.js";
|
||||||
|
import type { Db } from "./index.js";
|
||||||
|
|
||||||
|
// Test-only helper: a fresh, fully-migrated SQLite database with NO live-DB risk.
|
||||||
|
// Every server/integration test spins one of these so suites are isolated and
|
||||||
|
// deterministic — never the real parking.sqlite. Not exported from the package
|
||||||
|
// root (`@parking/db`); import it from `@parking/db/testing` in test code only.
|
||||||
|
|
||||||
|
// The migrations live next to this package's compiled output. From dist/testing.js
|
||||||
|
// that's ../drizzle; resolve it off import.meta.url so it works regardless of the
|
||||||
|
// caller's cwd (tests run from apps/server, packages/devices, etc.).
|
||||||
|
const MIGRATIONS_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "../drizzle");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open an in-memory SQLite (or a temp file if `url` is given), apply every Drizzle
|
||||||
|
* migration in order, and return a typed Drizzle handle plus the raw better-sqlite3
|
||||||
|
* connection (so a test can assert raw rows or `.close()` it). The schema matches
|
||||||
|
* production exactly because it's the SAME migration set, not a hand-rolled DDL.
|
||||||
|
*/
|
||||||
|
export function createTestDb(url = ":memory:"): { db: Db; sqlite: Database.Database; close: () => void } {
|
||||||
|
const sqlite = new Database(url);
|
||||||
|
sqlite.pragma("journal_mode = WAL");
|
||||||
|
sqlite.pragma("foreign_keys = ON");
|
||||||
|
const db = drizzle(sqlite, { schema }) as Db;
|
||||||
|
migrate(db, { migrationsFolder: MIGRATIONS_DIR });
|
||||||
|
return { db, sqlite, close: () => sqlite.close() };
|
||||||
|
}
|
||||||
@@ -15,13 +15,15 @@
|
|||||||
"build": "tsc -b",
|
"build": "tsc -b",
|
||||||
"dev": "tsc -b --watch",
|
"dev": "tsc -b --watch",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"lint": "tsc --noEmit"
|
"lint": "tsc --noEmit",
|
||||||
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@parking/shared": "workspace:*"
|
"@parking/shared": "workspace:*"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "25.9.3",
|
"@types/node": "25.9.3",
|
||||||
"typescript": "6.0.3"
|
"typescript": "6.0.3",
|
||||||
|
"vitest": "^4.1.9"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
renderTicket,
|
||||||
|
renderReceipt,
|
||||||
|
renderWindowChargeNotice,
|
||||||
|
renderSubscriptionCard,
|
||||||
|
stamp,
|
||||||
|
} from "./printer-escpos.js";
|
||||||
|
|
||||||
|
// The ESC/POS renderers are pure (data → Buffer). These tests pin the byte-level
|
||||||
|
// invariants that caused real misprints: the CP852 codepage select, the Albanian/
|
||||||
|
// punctuation character mapping (no stray "?"), and the Code128 module width — a
|
||||||
|
// ~20-char id at width 3 overflows the 80mm head and the firmware silently aborts the
|
||||||
|
// barcode, so the out-of-window slip MUST use width 2.
|
||||||
|
|
||||||
|
// Command-byte markers (see printer-escpos.ts).
|
||||||
|
const SELECT_CP852 = Buffer.from([0x1b, 0x74, 0x12]); // ESC t 18
|
||||||
|
const CODE128_PREFIX = [0x1d, 0x6b, 0x49]; // GS k 73 (function B, Code128)
|
||||||
|
const GS_W = (w: number) => [0x1d, 0x77, w]; // GS w n — module width
|
||||||
|
const QR_PRINT = [0x1d, 0x28, 0x6b, 0x03, 0x00, 0x31, 0x51, 0x30]; // fn 181
|
||||||
|
|
||||||
|
function indexOfSeq(buf: Buffer, seq: number[]): number {
|
||||||
|
return buf.indexOf(Buffer.from(seq));
|
||||||
|
}
|
||||||
|
function hasSeq(buf: Buffer, seq: number[]): boolean {
|
||||||
|
return indexOfSeq(buf, seq) >= 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("renderTicket", () => {
|
||||||
|
const out = renderTicket({ ticketId: "12345678901", issuedAt: "2026-06-21T10:00:00.000Z" });
|
||||||
|
|
||||||
|
it("selects the CP852 codepage in the preamble", () => {
|
||||||
|
expect(out.includes(SELECT_CP852)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits a Code128 barcode of the ticket id", () => {
|
||||||
|
expect(hasSeq(out, CODE128_PREFIX)).toBe(true);
|
||||||
|
// The id appears as both barcode payload (prefixed {B) and large text.
|
||||||
|
expect(out.includes(Buffer.from("12345678901", "ascii"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses module width 3 for a short (11-char) ticket id", () => {
|
||||||
|
expect(hasSeq(out, GS_W(3))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("renderWindowChargeNotice — the scannable out-of-window slip", () => {
|
||||||
|
const out = renderWindowChargeNotice({
|
||||||
|
occurrenceId: "SUBSESS-abcdef0123456789",
|
||||||
|
holderName: "Taras Bulba",
|
||||||
|
at: "2026-06-21T13:21:00.000Z",
|
||||||
|
edge: "entry",
|
||||||
|
windowOpensMin: 20 * 60, // 20:00
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses module width 2 so the ~20-char occurrence id fits the 80mm head", () => {
|
||||||
|
// This is the fix for the silent no-print: width 3 would overflow ~576 dots.
|
||||||
|
expect(hasSeq(out, GS_W(2))).toBe(true);
|
||||||
|
expect(hasSeq(out, GS_W(3))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits BOTH a Code128 and a QR of the occurrence id (scan two ways)", () => {
|
||||||
|
expect(hasSeq(out, CODE128_PREFIX)).toBe(true);
|
||||||
|
expect(hasSeq(out, QR_PRINT)).toBe(true);
|
||||||
|
expect(out.includes(Buffer.from("SUBSESS-abcdef0123456789", "ascii"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not emit a literal '?' for the warning sign or em dash (CP852 fallback)", () => {
|
||||||
|
// The title is "PARKIM - JASHTË ORARIT" (ASCII dash) and the pending notice uses
|
||||||
|
// "!" not ⚠. The Ë must map to its CP852 byte 0xD3, never 0x3f.
|
||||||
|
expect(out.includes(0xd3)).toBe(true); // Ë → CP852 0xD3
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("CP852 character mapping (the misprint fixes)", () => {
|
||||||
|
it("maps ë to its CP852 byte, not '?'", () => {
|
||||||
|
// A receipt's "Kohëzgjatja" / "Mënyra" lines carry ë.
|
||||||
|
const out = renderReceipt({
|
||||||
|
ticketId: "12345678901",
|
||||||
|
header: { parkName: "Parking Ë" },
|
||||||
|
enteredAt: "2026-06-21T08:00:00.000Z",
|
||||||
|
paidAt: "2026-06-21T10:00:00.000Z",
|
||||||
|
amountMinor: 20000,
|
||||||
|
currency: "ALL",
|
||||||
|
tender: "cash",
|
||||||
|
voucher: false,
|
||||||
|
} as Parameters<typeof renderReceipt>[0]);
|
||||||
|
expect(out.includes(0x89)).toBe(true); // ë → CP852 0x89
|
||||||
|
});
|
||||||
|
|
||||||
|
it("transliterates an em dash to ASCII '-' (no '?') in the validity line", () => {
|
||||||
|
// No validFrom/validTo → the card uses an em dash placeholder "—" which must
|
||||||
|
// degrade to '-'. Count of '?' (0x3f) stays 0 across the buffer.
|
||||||
|
const out = renderSubscriptionCard({
|
||||||
|
code: "SUB-1",
|
||||||
|
header: { parkName: "P" },
|
||||||
|
holderName: "Test",
|
||||||
|
validFrom: null,
|
||||||
|
validTo: null,
|
||||||
|
} as Parameters<typeof renderSubscriptionCard>[0]);
|
||||||
|
// The em dash is replaced by '-' (0x2d); there must be no '?' fallback byte.
|
||||||
|
expect(out.includes(0x3f)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("stamp (Albanian date format)", () => {
|
||||||
|
it("formats an ISO time as '<day> <Month> <year> HH:MM:SS'", () => {
|
||||||
|
// Local-time dependent, so assert the structure + the Albanian month name.
|
||||||
|
const s = stamp("2026-06-21T10:48:25.000Z");
|
||||||
|
expect(s).toMatch(/Qershor 2026 \d{2}:\d{2}:\d{2}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes through an invalid date unchanged", () => {
|
||||||
|
expect(stamp("not-a-date")).toBe("not-a-date");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -173,6 +173,10 @@ export interface CameraDevice extends Device {
|
|||||||
captureSnapshot(ctx: SnapshotContext): Promise<Snapshot>;
|
captureSnapshot(ctx: SnapshotContext): Promise<Snapshot>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isCamera(device: Device): device is Device & CameraDevice {
|
||||||
|
return typeof (device as Partial<CameraDevice>).captureSnapshot === "function";
|
||||||
|
}
|
||||||
|
|
||||||
export interface SnapshotContext {
|
export interface SnapshotContext {
|
||||||
readonly direction: "entry" | "exit";
|
readonly direction: "entry" | "exit";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
orderForRole,
|
||||||
|
printWithFailover,
|
||||||
|
NoPrinterAvailableError,
|
||||||
|
type PrinterInstance,
|
||||||
|
} from "./printer-routing.js";
|
||||||
|
import type { PrinterDevice } from "./interfaces.js";
|
||||||
|
|
||||||
|
// Printer routing is pure selection over (config, health): which printer prints a job,
|
||||||
|
// best-first, with failover. The key business rules: the booth printer is a FALLBACK for
|
||||||
|
// entry tickets but a receipt NEVER prints on the outside dispenser; rank then id break
|
||||||
|
// ties deterministically; printWithFailover walks the order and surfaces all failures.
|
||||||
|
|
||||||
|
function inst(id: string, role: PrinterInstance["role"], failoverRank = 0, device?: PrinterDevice): PrinterInstance {
|
||||||
|
return { id, role, failoverRank, device: device ?? ({} as PrinterDevice) };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("orderForRole", () => {
|
||||||
|
it("entry-dispenser job: dispensers first, booth-receipt as fallback", () => {
|
||||||
|
const printers = [inst("booth", "booth-receipt"), inst("disp", "entry-dispenser")];
|
||||||
|
expect(orderForRole(printers, "entry-dispenser").map((p) => p.id)).toEqual(["disp", "booth"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("booth-receipt job: NEVER falls back to the outside dispenser", () => {
|
||||||
|
const printers = [inst("disp", "entry-dispenser"), inst("booth", "booth-receipt")];
|
||||||
|
expect(orderForRole(printers, "booth-receipt").map((p) => p.id)).toEqual(["booth"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("breaks ties by failoverRank (higher first), then id", () => {
|
||||||
|
const printers = [
|
||||||
|
inst("b", "entry-dispenser", 1),
|
||||||
|
inst("a", "entry-dispenser", 1),
|
||||||
|
inst("c", "entry-dispenser", 5),
|
||||||
|
];
|
||||||
|
expect(orderForRole(printers, "entry-dispenser").map((p) => p.id)).toEqual(["c", "a", "b"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("excludes printers of no relevant role", () => {
|
||||||
|
const printers = [inst("booth", "booth-receipt")];
|
||||||
|
expect(orderForRole(printers, "booth-receipt").map((p) => p.id)).toEqual(["booth"]);
|
||||||
|
// For a receipt job, an entry dispenser is excluded entirely.
|
||||||
|
expect(orderForRole([inst("disp", "entry-dispenser")], "booth-receipt")).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("printWithFailover", () => {
|
||||||
|
function device(behavior: "ok" | "fail"): PrinterDevice {
|
||||||
|
return {
|
||||||
|
printTicket: vi.fn(behavior === "ok" ? async () => {} : async () => { throw new Error("offline"); }),
|
||||||
|
} as unknown as PrinterDevice;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("prints on the first healthy candidate and returns its id", async () => {
|
||||||
|
const printers = [inst("disp", "entry-dispenser", 0, device("ok")), inst("booth", "booth-receipt", 0, device("ok"))];
|
||||||
|
const job = vi.fn(async (d: PrinterDevice) => d.printTicket({} as never));
|
||||||
|
const used = await printWithFailover(printers, "entry-dispenser", job);
|
||||||
|
expect(used).toBe("disp");
|
||||||
|
expect(job).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails over to the booth printer when the dispenser throws", async () => {
|
||||||
|
const printers = [inst("disp", "entry-dispenser", 0, device("fail")), inst("booth", "booth-receipt", 0, device("ok"))];
|
||||||
|
const used = await printWithFailover(printers, "entry-dispenser", (d) => d.printTicket({} as never));
|
||||||
|
expect(used).toBe("booth");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws NoPrinterAvailableError listing every failed attempt", async () => {
|
||||||
|
const printers = [inst("disp", "entry-dispenser", 0, device("fail")), inst("booth", "booth-receipt", 0, device("fail"))];
|
||||||
|
await expect(printWithFailover(printers, "entry-dispenser", (d) => d.printTicket({} as never)))
|
||||||
|
.rejects.toBeInstanceOf(NoPrinterAvailableError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws when no printer is configured for the role", async () => {
|
||||||
|
await expect(printWithFailover([], "entry-dispenser", async () => {}))
|
||||||
|
.rejects.toBeInstanceOf(NoPrinterAvailableError);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,5 +7,6 @@
|
|||||||
"types": ["node"]
|
"types": ["node"]
|
||||||
},
|
},
|
||||||
"references": [{ "path": "../shared" }],
|
"references": [{ "path": "../shared" }],
|
||||||
"include": ["src/**/*"]
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["src/**/*.test.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
|
// Device tests are pure byte-stream assertions over the ESC/POS renderers + the
|
||||||
|
// printer-routing logic — no sockets, no hardware. Run from src (not dist).
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
include: ["src/**/*.test.ts"],
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -5,5 +5,6 @@
|
|||||||
"outDir": "./dist",
|
"outDir": "./dist",
|
||||||
"composite": true
|
"composite": true
|
||||||
},
|
},
|
||||||
"include": ["src/**/*"]
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["src/**/*.test.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+901
-4
File diff suppressed because it is too large
Load Diff
@@ -68,6 +68,14 @@ a right column with the **live event ticker**. Submitting/clicking a ticket open
|
|||||||
modal** (entry/duration/total, tender, voucher checkbox, entry/exit snapshots). All live-refreshed via
|
modal** (entry/duration/total, tender, voucher checkbox, entry/exit snapshots). All live-refreshed via
|
||||||
the WS.
|
the WS.
|
||||||
|
|
||||||
|
> **Focus-independent scan capture (2026-06-21).** A scan now opens the pay/exit modal **regardless of
|
||||||
|
> focus** — the operator needn't click the ticket field first. A document-level listener (`useScanner`,
|
||||||
|
> `apps/web/src/lib/use-scanner.ts`) detects the HID scanner's fast keystroke BURST ended by Enter (a
|
||||||
|
> gap > 50ms resets the buffer, so human-paced typing with nothing focused never triggers it) and fires
|
||||||
|
> the same `setActiveTicket`. It ignores keystrokes into an `<input>/<textarea>/select/contenteditable`
|
||||||
|
> so the manual ticket field still works by hand. It is **paused while a modal is already open** — a
|
||||||
|
> scan must not abandon an in-progress payment; the operator finishes/closes, then scans the next car.
|
||||||
|
|
||||||
### Explainable activity log (2026-06-19)
|
### Explainable activity log (2026-06-19)
|
||||||
The ticker used to flag an **anomaly** with no explanation — a red row with just an id, "nobody knows
|
The ticker used to flag an **anomaly** with no explanation — a red row with just an id, "nobody knows
|
||||||
what happened." Now every event is **self-describing and clickable**:
|
what happened." Now every event is **self-describing and clickable**:
|
||||||
@@ -140,8 +148,15 @@ screen, so the operator always sees the barrier relay's reachability and the pri
|
|||||||
and served 404s in this environment).
|
and served 404s in this environment).
|
||||||
|
|
||||||
## Open
|
## Open
|
||||||
- **No automated frontend tests** — the booth/live-feed/modal logic is verified manually
|
- **Automated test coverage landed 2026-06-21** (was: "no automated tests anywhere"). `pnpm test`
|
||||||
(Playwright + curl + DB inspection), not by a suite. The standing test-harness gap (see
|
now runs across all six packages (was shared + vision only): a fresh-SQLite harness
|
||||||
[[reconciliation]]-adjacent notes) now spans front and back.
|
(`@parking/db/testing` → `createTestDb()`, real migrations, never the live DB) backs server-core
|
||||||
|
suites for the anti-fraud heart — event-log hash-chain + tamper detection, signer, occupancy +
|
||||||
|
reserved-spots, pay-station, the exit GATE, and the shift takings-split; `@parking/devices` pins the
|
||||||
|
ESC/POS byte stream (CP852 fallbacks + the Code128 width contract) and printer routing; an HTTP
|
||||||
|
integration suite boots the real Fastify app (`app.inject`) to exercise the auth/RBAC/CSRF guards;
|
||||||
|
and `@parking/web` covers the booth formatters + the focus-independent `useScanner` hook. **Still
|
||||||
|
manual (Playwright):** the live-feed/modal *rendering* and full booth UI flows — the front-end unit
|
||||||
|
layer covers pure logic + the scanner hook, not component rendering (no jsdom component suite yet).
|
||||||
- The pre-existing admin screens (Setup/Tariff/Permits/Site/Shift) still carry their **old inline
|
- The pre-existing admin screens (Setup/Tariff/Permits/Site/Shift) still carry their **old inline
|
||||||
styles** — reachable and functional, not yet on the terminal component system.
|
styles** — reachable and functional, not yet on the terminal component system.
|
||||||
|
|||||||
@@ -28,3 +28,23 @@ Physical-access attacks on Windows are trivial (boot media + password-reset tool
|
|||||||
With LUKS in place, **SQLCipher becomes optional** defence-in-depth rather than the critical
|
With LUKS in place, **SQLCipher becomes optional** defence-in-depth rather than the critical
|
||||||
layer. (The custom controller adds its own: ESP32 flash encryption + secure boot — see
|
layer. (The custom controller adds its own: ESP32 flash encryption + secure boot — see
|
||||||
[[esp32-custom-controller]].)
|
[[esp32-custom-controller]].)
|
||||||
|
|
||||||
|
## Deploy-time server configuration (runbook)
|
||||||
|
|
||||||
|
Env in `apps/server/.env` on the appliance (see `apps/server/.env.example`). The security-load-bearing ones:
|
||||||
|
|
||||||
|
- **`JWT_SECRET`** — ≥32 random chars; the server refuses to boot without a strong one (no
|
||||||
|
insecure default). `openssl rand -hex 32`. See [[local-jwt-auth]].
|
||||||
|
- **`EVENT_SIGNING_KEY`** — dedicated HMAC key for the signed ledger; ≥16 chars. Falls back to
|
||||||
|
`JWT_SECRET` with a warning if unset — set a dedicated one before production.
|
||||||
|
- **`COOKIE_SECURE=0`** — **REQUIRED on the plain-HTTP LAN appliance.** Auth/CSRF cookies are
|
||||||
|
`Secure` by **default** (fail-safe). The appliance serves the SPA same-origin over **plain
|
||||||
|
http** on the booth LAN, where a `Secure` cookie is **never sent** — so without this opt-out
|
||||||
|
**operators cannot log in**. Set it deliberately. (A TLS/reverse-proxied deploy leaves it
|
||||||
|
UNSET so cookies stay `Secure`.) This replaced the old `NODE_ENV=production` gate, which
|
||||||
|
silently dropped `Secure` if the var was forgotten. See [[local-jwt-auth]].
|
||||||
|
|
||||||
|
> The plain-http booth LAN is acceptable because it's an **isolated, single-purpose network**
|
||||||
|
> (the only browser is the booth's own; access controllers sit on a separate VLAN — see
|
||||||
|
> [[network-isolation]], [[trust-boundary]]). `Secure`-off is a network-scoped decision, not a
|
||||||
|
> blanket weakening; the JWT stays HttpOnly + SameSite=Strict and CSRF double-submit still applies.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
type: concept
|
type: concept
|
||||||
tags: [parking, domain, business, reporting]
|
tags: [parking, domain, business, reporting]
|
||||||
sources: []
|
sources: []
|
||||||
updated: 2026-06-15
|
updated: 2026-06-22
|
||||||
status: open
|
status: open
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -12,6 +12,37 @@ Turning the signed event log into the numbers an owner runs the business on. All
|
|||||||
**projections over the [[append-only-event-chain]]** — the chain is the single source, reports are
|
**projections over the [[append-only-event-chain]]** — the chain is the single source, reports are
|
||||||
derived and rebuildable, never a separate ledger.
|
derived and rebuildable, never a separate ledger.
|
||||||
|
|
||||||
|
## Built — admin Reports dashboard v1 (2026-06-22)
|
||||||
|
|
||||||
|
A first cut shipped: an admin **Reports** screen — a **top-level section** at **`/reports`** with its
|
||||||
|
own header nav entry (not nested under Setup), gated on `report:read` — an on-demand **dashboard**
|
||||||
|
(not a live feed). Server aggregates everything in **one call**
|
||||||
|
(`GET /api/reports/summary?from&to&bucket`) so the SPA only renders; `…/summary.csv` exports the
|
||||||
|
per-bucket series. Code: `apps/server/src/reports.ts` (+ `routes/reports.ts`), `apps/web/src/Reports.tsx`.
|
||||||
|
|
||||||
|
- **Ledger-first** (decision 2026-06-22). Entry/exit **counts** and all **money** are summed
|
||||||
|
straight from the signed `ledger_events` — the SAME source the `shift_z_report` reconciles, so a
|
||||||
|
chart total always ties out to the drawer. The revenue **split** (transient ticket /
|
||||||
|
subscription sale / out-of-window window-charge) mirrors the Z-report's split exactly
|
||||||
|
(`subscriptionSale` / `subscriptionWindowCharge` payload flags). Duration/occupancy stats are the
|
||||||
|
one exception: read from the derived `sessions` cache (pairing each entry with its exit on the
|
||||||
|
chain by hand is awkward) — flagged as a cache, not the financial truth.
|
||||||
|
- **Site-timezone bucketing.** A "day"/"hour" bucket is **local wall-clock** in `siteConfig.timezone`
|
||||||
|
(reuses `siteTz()`), so a 23:30Z entry lands on the right local date and the peak-hour histogram
|
||||||
|
reads in wall-clock. Bucket grain: hour / day / month, with date-range presets (today / 7d / 30d / 90d).
|
||||||
|
- **Views:** KPI cards (entries, exits, revenue, payments, avg stay, current subscribers); entry/exit
|
||||||
|
line; revenue bar (per bucket) + cash/card split; revenue-mix pie; **peak-hours** histogram
|
||||||
|
(entries by local hour-of-day); a numeric breakdown (cash/card, the 3-way revenue split, closed
|
||||||
|
sessions, avg/median stay, active subs + cars covered); subscription status counts + currently-valid
|
||||||
|
coverage as of the range end. Charts via **Recharts** (MIT), **lazy-loaded** into its own bundle
|
||||||
|
chunk so the booth never downloads it. Tested: `reports.test.ts` (10) pin the sums, the tz bucketing,
|
||||||
|
the money split, duration stats, and subscription counts.
|
||||||
|
|
||||||
|
**Not yet** (deferred from the list below): anomalies/voids reporting, per-operator takings, the
|
||||||
|
plate/entry search (next section), PDF export, and a live dashboard. The `report:read` permission
|
||||||
|
already existed for "events feed, occupancy, future reports" — this is its first real consumer
|
||||||
|
beyond the feed.
|
||||||
|
|
||||||
## Reports (driven by the events already designed)
|
## Reports (driven by the events already designed)
|
||||||
|
|
||||||
- **Revenue** — by day/week/shift, by tender (cash vs. card), gross vs. discounts vs. net. Source:
|
- **Revenue** — by day/week/shift, by tender (cash vs. card), gross vs. discounts vs. net. Source:
|
||||||
|
|||||||
@@ -51,8 +51,12 @@ Authentication and authorization, kept **fully local** — a direct consequence
|
|||||||
|
|
||||||
The SPA never sees the JWT. Login (`POST /api/auth/login`) verifies bcrypt and sets two cookies:
|
The SPA never sees the JWT. Login (`POST /api/auth/login`) verifies bcrypt and sets two cookies:
|
||||||
|
|
||||||
- **`parking_token`** — the JWT, **HttpOnly + SameSite=Strict** (+ `Secure` when
|
- **`parking_token`** — the JWT, **HttpOnly + SameSite=Strict**, and **`Secure` by default**
|
||||||
`NODE_ENV=production`). JS can't read it; `@fastify/jwt` reads it from the cookie, not the
|
(fail-safe — a forgotten env can only make cookies more restrictive, never drop the flag).
|
||||||
|
`Secure` is dropped ONLY for a deliberate opt-out: `COOKIE_SECURE=0` (the plain-HTTP LAN
|
||||||
|
appliance — see [[disk-os-hardening]] deploy checklist) or `NODE_ENV=development`. (Was keyed
|
||||||
|
off `NODE_ENV=production`, which silently leaked cookies on an appliance that forgot to set it
|
||||||
|
— corrected 2026-06-21.) JS can't read it; `@fastify/jwt` reads it from the cookie, not the
|
||||||
`Authorization` header.
|
`Authorization` header.
|
||||||
- **`parking_csrf`** — a random token, **readable** by JS. The JWT also carries a matching `csrf`
|
- **`parking_csrf`** — a random token, **readable** by JS. The JWT also carries a matching `csrf`
|
||||||
claim. On every mutation the SPA echoes the cookie in the **`X-CSRF-Token`** header; the guard
|
claim. On every mutation the SPA echoes the cookie in the **`X-CSRF-Token`** header; the guard
|
||||||
|
|||||||
+86
@@ -1268,3 +1268,89 @@ before signing the irreversible Z-report. Opening stays immediate. (3) Fixed dar
|
|||||||
<select> popups rendering WHITE on WebKitGTK (Tauri Linux) via color-scheme + explicit option colours.
|
<select> popups rendering WHITE on WebKitGTK (Tauri Linux) via color-scheme + explicit option colours.
|
||||||
Verified the split on a read-only DB copy (tickets 0, subs 10,200 = 10,000 sale + 200 out-of-window,
|
Verified the split on a read-only DB copy (tickets 0, subs 10,200 = 10,000 sale + 200 out-of-window,
|
||||||
reconciles). build+lint 14/14, i18n parity (sq+en). See [[shift]] "Takings split by source".
|
reconciles). build+lint 14/14, i18n parity (sq+en). See [[shift]] "Takings split by source".
|
||||||
|
|
||||||
|
## [2026-06-21] feat | Promote Subscriptions to a top-level section with its own tabs
|
||||||
|
Moved Subscriptions out of /setup into a standalone /subscriptions section with a header nav entry
|
||||||
|
(Kabina · Turni · Abonimet · Konfigurimi) and its own tab bar: Abonimet (/subscriptions), Planet
|
||||||
|
(/subscriptions/plans), Lab Tarife (/subscriptions/tariff-lab). Removed those three tabs from the Setup
|
||||||
|
layout (Setup now: Pajisjet · Tarifa · Park · Përdoruesit · Rolet · Turnet · Loget). Tabs are
|
||||||
|
permission-gated (subscription:read / subscription:plan / tariff:read), so an operator with only
|
||||||
|
subscription:read sees just Abonimet; the index redirects to the first allowed sub-tab otherwise.
|
||||||
|
Legacy /setup/subscriptions, /setup/plans, /setup/tariff-lab redirect to the new paths; the old
|
||||||
|
/subscriptions→/setup redirect was removed (it's a real route now). Verified at runtime via Playwright
|
||||||
|
(header order, the 3 sub-tabs, Setup no longer shows them, /setup/subscriptions redirects). The Tariff
|
||||||
|
COMPOSER stays in Setup; only the Tariff LAB simulator moved. build+lint 14/14.
|
||||||
|
|
||||||
|
## [2026-06-21] fix | Header "Turni"→"Turnet" (plural); drop the duplicate Setup shifts tab
|
||||||
|
The header shift link was nav.shift (singular: Turni/Shift) but points at the /shift HISTORY hub →
|
||||||
|
relabelled to nav.shifts (plural: Turnet/Shifts). Removed the duplicate "Turnet" tab from Setup (the
|
||||||
|
/setup/shifts route + tab rendered the SAME ShiftsHistory as the standalone /shift). /setup/shifts now
|
||||||
|
redirects to /shift; the operator-landing fallback (shift:read user opening /setup) points at /shift.
|
||||||
|
nav.shift key left in the catalogs (now orphaned, harmless). Verified at runtime. build+lint 14/14.
|
||||||
|
|
||||||
|
## [2026-06-21] feat | /shift→/shifts; clickable activity log (shared event-detail); booth-style full-height layout
|
||||||
|
Three changes to the shift hub. (1) Renamed the route /shift→/shifts (matches the plural Turnet label
|
||||||
|
+ the section); /shift and /setup/shifts both redirect there. (2) The activity-log rows are now
|
||||||
|
CLICKABLE and open the SAME read-only event-detail modal the booth live feed uses (full signed payload
|
||||||
|
+ snapshots + chain provenance). Extracted EVENT_STYLE + the row + the modal + their helpers from
|
||||||
|
BoothScreen into a shared apps/web/src/ui/event-detail.tsx, imported by both — so the booth feed and the
|
||||||
|
shift log render/behave identically and can't drift. (3) Reworked the /shifts layout to fill the
|
||||||
|
viewport like /booth: a fixed title+filters, then a two-pane area (shift list | activity log) where each
|
||||||
|
pane scrolls independently (min-h-0/flex-1 + overflow-y-auto) instead of the whole page growing.
|
||||||
|
Verified at runtime (Playwright): /shift redirects, rows open the detail modal, layout fills height,
|
||||||
|
booth still works (0 console errors). build+lint 14/14.
|
||||||
|
|
||||||
|
## [2026-06-21] feat | Booth: focus-independent hardware-scan capture
|
||||||
|
A scan now opens the /booth pay/exit modal no matter what's focused (or if nothing is) — the operator
|
||||||
|
needn't click the ticket field first. New useScanner hook (apps/web/src/lib/use-scanner.ts): a
|
||||||
|
document-level keydown listener that detects the HID scanner's fast keystroke burst ended by Enter (gap
|
||||||
|
> 50ms resets the buffer, so human-paced typing never triggers it; min length 3) and fires
|
||||||
|
setActiveTicket. Ignores keystrokes into editable fields so the manual ticket input is unaffected;
|
||||||
|
paused while a modal is open so a scan can't abandon an in-progress payment. Verified at runtime
|
||||||
|
(Playwright): scan with focus on BODY opens the modal; second scan while open is ignored; slow typing
|
||||||
|
doesn't trigger; manual form submit still works. build+lint 14/14. See [[booth-console]].
|
||||||
|
|
||||||
|
## [2026-06-21] test | Automated test coverage across every service (was shared + vision only)
|
||||||
|
Added a fresh-SQLite test harness and suites for all six packages; `pnpm test` (turbo `test` task) now
|
||||||
|
covers them all (previously only @parking/shared + @parking/vision had test scripts). New
|
||||||
|
`@parking/db/testing` exports `createTestDb()` — an in-memory SQLite with the real Drizzle migrations
|
||||||
|
applied, so server tests run against the production schema with NO live-DB risk. Coverage: **server**
|
||||||
|
(anti-fraud core) — event-log hash-chain linkage + `verifyChain` catching every tamper class (edited
|
||||||
|
payload, deleted row/index gap, broken prevHash, unknown keyId), signer round-trip/forgery/rotation,
|
||||||
|
occupancy fold + reserved-spots (no double-count of a parked subscriber), pay-station quote/sign/lookup,
|
||||||
|
the exit GATE (refuse unknown/unpaid/grace-expired; no booth subscription bypass; assist path), and the
|
||||||
|
shift takings-SPLIT by source (subscription sales vs out-of-window vs transient tickets) + drawer
|
||||||
|
carry-forward + Z-report; plus an HTTP integration suite booting the real Fastify app via `app.inject`
|
||||||
|
for the auth/RBAC/CSRF guards. **devices** — ESC/POS byte stream (CP852 ë/Ë mapping + em-dash/⚠ ASCII
|
||||||
|
fallbacks, no stray "?"; the Code128 module-width contract: width 2 for the ~20-char out-of-window id so
|
||||||
|
it fits the 80mm head) + printer-routing failover. **web** — booth formatters + the focus-independent
|
||||||
|
`useScanner` hook (jsdom). **vision** — fixed 2 pre-existing stub-mode test failures via a conftest
|
||||||
|
autouse fixture that pins `VISION_RECOGNIZER=stub` (the dev `.env` had set `fast_alpr`, which broke the
|
||||||
|
model-free smoke tests). Also stopped `*.test.ts` leaking into shipped `dist/` (server + shared
|
||||||
|
tsconfig excludes). Totals: shared 87, server 75, devices 18, web 17, vision 7 = 204 tests; build/lint
|
||||||
|
14/14. See [[booth-console]].
|
||||||
|
|
||||||
|
## [2026-06-21] fix | Auth cookies Secure-by-default; COOKIE_SECURE=0 in the appliance deploy runbook
|
||||||
|
secureCookies() keyed off NODE_ENV==="production", so an appliance deployed without that var
|
||||||
|
silently dropped the Secure flag on the auth/CSRF cookies (the code-review's one Medium finding).
|
||||||
|
Flipped to FAIL-SAFE: Secure by DEFAULT, dropped only on a deliberate COOKIE_SECURE=0/false/no/off
|
||||||
|
(or NODE_ENV=development as a dev fallback). The plain-http LAN appliance sets COOKIE_SECURE=0 ON
|
||||||
|
PURPOSE (a Secure cookie is never sent over its http origin → operators couldn't log in); a TLS
|
||||||
|
deploy leaves it unset. Added a "Deploy-time server configuration (runbook)" section to
|
||||||
|
[[disk-os-hardening]] documenting COOKIE_SECURE=0 (+ JWT_SECRET / EVENT_SIGNING_KEY) and corrected
|
||||||
|
the stale "Secure when NODE_ENV=production" line on [[local-jwt-auth]]. auth.test.ts (5) pins the
|
||||||
|
matrix; server 80/80.
|
||||||
|
|
||||||
|
## [2026-06-22] feat | Admin Reports dashboard v1 (ledger-first charts) + camera "Test ANPR"
|
||||||
|
Built the admin Reports screen (`/setup/reports`, gated `report:read`): one server call
|
||||||
|
(`GET /api/reports/summary?from&to&bucket`, + `.csv` export) aggregates entry/exit counts and all
|
||||||
|
money straight from the signed `ledger_events` (LEDGER-FIRST decision) — the same source the
|
||||||
|
`shift_z_report` reconciles, so totals tie out to the drawer; the 3-way revenue split (ticket /
|
||||||
|
subscription sale / out-of-window) mirrors the Z-report. Duration/occupancy stats come from the
|
||||||
|
`sessions` cache (flagged). All bucketing is in the SITE timezone (`siteTz()`). Views: KPI cards,
|
||||||
|
entry/exit line, revenue bar + cash/card split, revenue-mix pie, peak-hours histogram, numeric
|
||||||
|
breakdown, subscription stats. Charts via Recharts (MIT), lazy-loaded into its own chunk (111KB gz)
|
||||||
|
so the booth bundle is untouched. reports.test.ts (10) pins the sums/tz/split/duration/subs; server
|
||||||
|
90/90, build+lint 14/14. Also (earlier same session): a camera "Test ANPR" probe in first-run setup
|
||||||
|
(`POST /api/setup/test-anpr`) — snapshot→vision analyze, fail-soft, shown only when a camera's ANPR
|
||||||
|
opt-in is checked. See [[reporting-analytics]], [[opencv-anpr-service]].
|
||||||
|
|||||||
Reference in New Issue
Block a user