Promote to staging (park-buzi): snapshot content-type fix, Active Sessions/modal rework, DB reset CLI, drawer redesign (operator records / admin reviews), card tender disabled (no POS), operator-issued entry + exit plate-swap reconciliation. Migrations 0018 (drawer permissions) + 0019 (session:create) run at container boot. TAG in komodo/resources.toml still points at the OLD image — re-pin to the new stage-<sha> CI produces from this merge before deploying. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
+114
-17
@@ -15,7 +15,7 @@ import type { FastifyBaseLogger } from "fastify";
|
||||
import type { DeviceInputEvent } from "./device-events.js";
|
||||
import { getOccupancy } from "./occupancy.js";
|
||||
import type { EventLog } from "./event-log.js";
|
||||
import { devicesByDirection, relayForButton, relayForPresence, type ResolvedRelay } from "./device-resolve.js";
|
||||
import { devicesByDirection, firstRelayByDirection, relayForButton, relayForPresence, type ResolvedRelay } from "./device-resolve.js";
|
||||
import { snapshotAsync } from "./snapshot.js";
|
||||
import type { VisionClient } from "./vision-client.js";
|
||||
|
||||
@@ -229,9 +229,29 @@ export class EntryFlow {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.#issueTicket(resolved, { source: "ticket" });
|
||||
}
|
||||
|
||||
/**
|
||||
* The shared "issue a transient ticket" sequence used by BOTH the physical button
|
||||
* (#runEntry) and the operator-initiated path (issueForOperator) — ONE copy of the
|
||||
* fraud-critical ordering (print → sign vehicle_entry BEFORE open → open → snapshot →
|
||||
* cache), never a divergent second copy. `opts.source` is "ticket" (button) or "booth"
|
||||
* (operator). For an operator mint we stamp `operatorInitiated` + `operator` on the
|
||||
* signed entry AND append a companion `anomaly` (the operator-adversary path always
|
||||
* leaves a red-flag row); `overCapacity` records a full-lot override. Returns the
|
||||
* outcome so the operator route can report it. See wiki/concepts/operator-issued-entry.md.
|
||||
*/
|
||||
async #issueTicket(
|
||||
resolved: ResolvedRelay,
|
||||
opts: { source: "ticket" | "manual"; operator?: string; overCapacity?: { count: number; capacity: number | null } },
|
||||
): Promise<{ ok: true; ticketId: string; opened: boolean } | { ok: false; reason: string }> {
|
||||
const ticketId = newTicketId();
|
||||
const issuedAt = new Date().toISOString();
|
||||
const printers = this.#loadPrinters();
|
||||
// Operator mint = ledger source "manual" (human intervention, like the barrier re-open)
|
||||
// + operatorInitiated:true in the payload. The button path is source "ticket".
|
||||
const operatorInitiated = opts.source === "manual";
|
||||
|
||||
// 1. PRINT FIRST. The ticket is the transient's session key — no ticket, no entry.
|
||||
const ticket: TicketData = { ticketId, issuedAt, header: this.#ticketHeader() };
|
||||
@@ -260,17 +280,14 @@ export class EntryFlow {
|
||||
// Capture who is held at the barrier (evidence for the operator handling the car).
|
||||
this.#fireSnapshot("entry", ticketId);
|
||||
this.#logger.warn(`entry HELD: ${reason} (barrier NOT opened)`);
|
||||
return;
|
||||
return { ok: false, reason };
|
||||
}
|
||||
|
||||
// 2. SIGN the vehicle_entry — BEFORE the relay fires (the core invariant).
|
||||
// `category` is FROZEN here (in the signed payload) so the tariff prices and
|
||||
// later reprices the same way at exit. Today every transient takes the SITE
|
||||
// default category (operator policy, site_config.default_vehicle_category;
|
||||
// falls back to the shared DEFAULT_VEHICLE_CATEGORY). Per-relay capture (a
|
||||
// "bus lane" relay, mirroring how direction is per-relay in device-resolve.ts)
|
||||
// is the future seam — source it from `resolved` then. A V1/no-category tariff
|
||||
// ignores it; only V2 category cards consult it.
|
||||
// falls back to the shared DEFAULT_VEHICLE_CATEGORY).
|
||||
const cfg = this.#db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||
const category =
|
||||
cfg?.defaultVehicleCategory && cfg.defaultVehicleCategory.length > 0
|
||||
@@ -279,33 +296,113 @@ export class EntryFlow {
|
||||
await this.#log.append({
|
||||
type: "vehicle_entry",
|
||||
direction: "entry",
|
||||
source: "ticket",
|
||||
source: opts.source,
|
||||
identity: ticketId,
|
||||
payload: { sessionRef: ticketId, ticketPrinted: true, category },
|
||||
payload: {
|
||||
sessionRef: ticketId,
|
||||
ticketPrinted: true,
|
||||
category,
|
||||
...(operatorInitiated ? { operatorInitiated: true, operator: opts.operator } : {}),
|
||||
...(opts.overCapacity ? { lotFull: true, occupancy: `${opts.overCapacity.count}/${opts.overCapacity.capacity ?? "∞"}` } : {}),
|
||||
},
|
||||
occurredAt: issuedAt,
|
||||
});
|
||||
|
||||
// 2b. For an operator mint, append a companion ANOMALY — the operator-adversary path
|
||||
// always leaves a red-flag row in the tamper-evident record for reconciliation.
|
||||
if (operatorInitiated) {
|
||||
await this.#log.append({
|
||||
type: "anomaly",
|
||||
identity: ticketId,
|
||||
payload: {
|
||||
...reasonPayload("entry.operatorIssued", { operator: opts.operator ?? "?" }),
|
||||
source: "booth",
|
||||
operatorInitiated: true,
|
||||
...(opts.operator ? { operator: opts.operator } : {}),
|
||||
...(opts.overCapacity ? { lotFull: true } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 3. OPEN the resolved entry barrier (intent only; the barrier owns the close).
|
||||
const access = this.#buildAccess(resolved.controller);
|
||||
if (access) await access.pulseOpen(resolved.relay);
|
||||
else this.#logger.warn(`entry signed for ${ticketId} but the entry relay won't build`);
|
||||
let opened = false;
|
||||
if (access) {
|
||||
await access.pulseOpen(resolved.relay);
|
||||
opened = true;
|
||||
} else this.#logger.warn(`entry signed for ${ticketId} but the entry relay won't build`);
|
||||
|
||||
// 3b. SNAPSHOT — fire the entry camera(s), never awaited (evidence, not a gate;
|
||||
// a camera failure must not delay or block the already-open barrier).
|
||||
// 3b. SNAPSHOT — fire the entry camera(s), never awaited (evidence, not a gate; a
|
||||
// camera failure must not delay or block the already-open barrier). This is ALSO
|
||||
// what records the plate that plate-reconciliation reads at exit.
|
||||
this.#fireSnapshot("entry", ticketId);
|
||||
|
||||
// 4. Update the session projection cache (rebuildable from the ledger; this is
|
||||
// just a fast read-model, never the source of truth).
|
||||
// 4. Update the session projection cache (rebuildable from the ledger; a read-model).
|
||||
try {
|
||||
this.#db
|
||||
.insert(sessions)
|
||||
.values({ id: ticketId, identity: ticketId, source: "ticket", enteredAt: issuedAt, state: "open" })
|
||||
.values({ id: ticketId, identity: ticketId, source: opts.source, enteredAt: issuedAt, state: "open" })
|
||||
.run();
|
||||
} catch (err) {
|
||||
// Cache miss is non-fatal — the ledger is authoritative and the projection
|
||||
// can be rebuilt. Log it; don't fail the (already-open) entry.
|
||||
this.#logger.error(`session-cache insert failed for ${ticketId}: ${(err as Error).message}`);
|
||||
}
|
||||
return { ok: true, ticketId, opened };
|
||||
}
|
||||
|
||||
/**
|
||||
* OPERATOR-ISSUED entry (physical entry button broken). Gated exactly like the button:
|
||||
* a REAL vehicle must be present at the entry — BOTH radar/loop presence AND camera
|
||||
* confirmation. `cameraBusy` is the current LaneStatus.entry (passed by the route); loop
|
||||
* presence is this flow's own per-relay guard state. If a site has no presence loop the
|
||||
* feature is unavailable (we require both — no weaker fallback). Refuses (+ signs an
|
||||
* anomaly) when no vehicle is present, so probing the endpoint is itself recorded. Over
|
||||
* capacity is ALLOWED but flagged (a broken button mustn't trap a legit car). The mint
|
||||
* itself is flagged (source:"booth" + operatorInitiated + a companion anomaly).
|
||||
* See wiki/concepts/operator-issued-entry.md.
|
||||
*/
|
||||
async issueForOperator(operator: string, cameraBusy: boolean): Promise<
|
||||
{ ok: true; ticketId: string; opened: boolean; overCapacity: boolean } | { ok: false; reason: string }
|
||||
> {
|
||||
const resolved = firstRelayByDirection(this.#db, "entry");
|
||||
if (!resolved) return { ok: false, reason: "no entry barrier configured" };
|
||||
|
||||
// PRESENCE GATE — require BOTH a presence loop (configured + currently occupied) AND
|
||||
// the camera confirming a vehicle. No loop configured → feature unavailable here.
|
||||
if (typeof resolved.presenceInput !== "number") {
|
||||
return { ok: false, reason: "no presence loop on the entry barrier — operator issue unavailable" };
|
||||
}
|
||||
const present = this.#guardState(resolved).present;
|
||||
if (!present || !cameraBusy) {
|
||||
await this.#log.append({
|
||||
type: "anomaly",
|
||||
identity: `ENTRY-ATTEMPT-${randomUUID().replace(/-/g, "").slice(0, 12)}`,
|
||||
payload: {
|
||||
...reasonPayload("entry.issue.noPresence", { operator }),
|
||||
source: "booth",
|
||||
operator,
|
||||
radarPresent: present,
|
||||
cameraBusy,
|
||||
},
|
||||
});
|
||||
this.#logger.warn(`operator entry refused by ${operator}: no vehicle present (radar=${present}, camera=${cameraBusy})`);
|
||||
return { ok: false, reason: "no vehicle detected at the entry" };
|
||||
}
|
||||
|
||||
const key = `operator-issue:${this.#relayKey(resolved)}`;
|
||||
if (this.#inFlight.has(key)) return { ok: false, reason: "an entry is already in progress" };
|
||||
this.#inFlight.add(key);
|
||||
try {
|
||||
const occ = getOccupancy(this.#db);
|
||||
const res = await this.#issueTicket(resolved, {
|
||||
source: "manual",
|
||||
operator,
|
||||
...(occ.full ? { overCapacity: { count: occ.count, capacity: occ.capacity ?? null } } : {}),
|
||||
});
|
||||
if (!res.ok) return res;
|
||||
return { ok: true, ticketId: res.ticketId, opened: res.opened, overCapacity: occ.full };
|
||||
} finally {
|
||||
this.#inFlight.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
/** Fire the entry camera(s) for an identity; never awaited (evidence, not a gate).
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { createTestDb } from "@parking/db/testing";
|
||||
import { ledgerEvents, eq, type Db } from "@parking/db";
|
||||
import { ledgerEvents, deviceEvents as deviceEventsTable, sessions as sessionsTable, eq, type Db } from "@parking/db";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { ExitFlow } from "./exit-flow.js";
|
||||
import { PayStation } from "./pay-station.js";
|
||||
import type { EventLog } from "./event-log.js";
|
||||
@@ -33,6 +34,19 @@ async function enter(identity: string, enteredAt: string, payload?: Record<strin
|
||||
function exitsSigned(identity: string) {
|
||||
return db.select().from(ledgerEvents).where(eq(ledgerEvents.identity, identity)).all().filter((r) => r.type === "vehicle_exit");
|
||||
}
|
||||
function anomalies(reason?: string) {
|
||||
return db.select().from(ledgerEvents).where(eq(ledgerEvents.type, "anomaly")).all()
|
||||
.filter((r) => !reason || (r.payload as { reason?: string } | null)?.reason?.includes(reason));
|
||||
}
|
||||
/** Seed the projection-cache open-session row + an ANPR plate read (device_events) so the
|
||||
* plate-reconciliation check can see this identity's plate against open sessions. */
|
||||
function seedOpenWithPlate(identity: string, plate: string, confidence: number, enteredAt: string) {
|
||||
db.insert(sessionsTable).values({ id: identity, identity, source: "ticket", enteredAt, state: "open" }).run();
|
||||
db.insert(deviceEventsTable).values({
|
||||
id: randomUUID(), deviceId: "cam-entry", category: "camera", kind: "read", occurredAt: enteredAt,
|
||||
detail: { identity, direction: "entry", plate, confidence },
|
||||
}).run();
|
||||
}
|
||||
|
||||
describe("exitForBooth — refusal gates", () => {
|
||||
it("refuses an unknown ticket (no session) and signs an anomaly", async () => {
|
||||
@@ -116,3 +130,63 @@ describe("reopenBarrier — no unpaid re-open", () => {
|
||||
expect(exitsSigned("T1")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("exitForBooth — plate-swap reconciliation (ticket-swap fraud)", () => {
|
||||
// The fraud: a paid car is let out on a fresh $0 ticket while the original lingers "inside".
|
||||
// The plate is the invariant — the exiting car's plate is already open under the old ticket.
|
||||
it("HOLDS a paid exit when the plate is already open under a DIFFERENT ticket", async () => {
|
||||
seedTariff(db, { pricePerIncrementMinor: 10000, gracePeriodExitMin: 15 });
|
||||
// Original car entered on 1234, plate AA123BB, still open (never paid/exited).
|
||||
await enter("1234", minutesAgo(120));
|
||||
seedOpenWithPlate("1234", "AA123BB", 0.99, minutesAgo(120));
|
||||
// A fresh ticket 1237 (same physical car, same plate) is paid and tries to exit.
|
||||
await enter("1237", minutesAgo(1));
|
||||
seedOpenWithPlate("1237", "AA123BB", 0.99, minutesAgo(1));
|
||||
await pay.pay("1237", "cash");
|
||||
|
||||
const r = await exit.exitForBooth("1237");
|
||||
expect(r).toMatchObject({ ok: false, status: "swap_suspected", plate: "AA123BB", otherIdentity: "1234" });
|
||||
expect(exitsSigned("1237")).toHaveLength(0); // NOT let out
|
||||
expect(anomalies("plate AA123BB is already inside").length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("RELEASES on explicit operator override + signs an attributed override anomaly", async () => {
|
||||
seedTariff(db, { pricePerIncrementMinor: 10000, gracePeriodExitMin: 15 });
|
||||
await enter("1234", minutesAgo(120));
|
||||
seedOpenWithPlate("1234", "AA123BB", 0.99, minutesAgo(120));
|
||||
await enter("1237", minutesAgo(1));
|
||||
seedOpenWithPlate("1237", "AA123BB", 0.99, minutesAgo(1));
|
||||
await pay.pay("1237", "cash");
|
||||
|
||||
const r = await exit.exitForBooth("1237", { override: true, operator: "op1" });
|
||||
expect(r.ok).toBe(true);
|
||||
expect(exitsSigned("1237")).toHaveLength(1); // released
|
||||
const ov = anomalies("released a suspected ticket-swap");
|
||||
expect(ov.length).toBe(1);
|
||||
expect((ov[0].payload as { operator?: string }).operator).toBe("op1");
|
||||
});
|
||||
|
||||
it("does NOT warn on a LOW-confidence plate read (advisory, never a gate)", async () => {
|
||||
seedTariff(db, { pricePerIncrementMinor: 10000, gracePeriodExitMin: 15 });
|
||||
await enter("1234", minutesAgo(120));
|
||||
seedOpenWithPlate("1234", "AA123BB", 0.5, minutesAgo(120)); // low conf
|
||||
await enter("1237", minutesAgo(1));
|
||||
seedOpenWithPlate("1237", "AA123BB", 0.5, minutesAgo(1)); // low conf
|
||||
await pay.pay("1237", "cash");
|
||||
|
||||
const r = await exit.exitForBooth("1237");
|
||||
expect(r.ok).toBe(true); // no warning — exits normally
|
||||
expect(exitsSigned("1237")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("does NOT warn a normal exit whose OWN plate is only open under its OWN ticket", async () => {
|
||||
seedTariff(db, { pricePerIncrementMinor: 10000, gracePeriodExitMin: 15 });
|
||||
await enter("1237", minutesAgo(90));
|
||||
seedOpenWithPlate("1237", "AA999ZZ", 0.99, minutesAgo(90));
|
||||
await pay.pay("1237", "cash");
|
||||
|
||||
const r = await exit.exitForBooth("1237");
|
||||
expect(r.ok).toBe(true); // its own plate under its own ticket is not a swap
|
||||
expect(exitsSigned("1237")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { desc, eq, ledgerEvents, sessions, tariffVersions, tariffs, type Db, type DeviceRow } from "@parking/db";
|
||||
import { registry, type AccessControlDevice } from "@parking/devices";
|
||||
import { firstRelayByDirection, type ResolvedRelay } from "./device-resolve.js";
|
||||
import { plateForIdentity, platesForIdentities } from "./plate-lookup.js";
|
||||
import { snapshotAsync } from "./snapshot.js";
|
||||
import type { VisionClient } from "./vision-client.js";
|
||||
import { computeFee, reasonPayload, renderReasonEn, type LedgerPayload, type TariffStructure } from "@parking/shared";
|
||||
@@ -47,6 +48,11 @@ interface SessionView {
|
||||
* the barrier didn't open (payment stands; operator opens manually). */
|
||||
export type BoothExitResult =
|
||||
| { ok: false; status: "invalid" | "no_session" | "closed" | "unpaid" | "grace_expired"; reason: string }
|
||||
// PLATE-SWAP suspected: the exiting car's plate is already OPEN under a DIFFERENT ticket
|
||||
// (possible ticket-swap fraud / mixed-up tickets). Not opened — the operator must review
|
||||
// and either resolve the tickets or consciously OVERRIDE (re-submit with override:true).
|
||||
// See wiki/concepts/plate-reconciliation.md.
|
||||
| { ok: false; status: "swap_suspected"; reason: string; plate: string; otherIdentity: string; otherEnteredAt: string | null }
|
||||
| { ok: true; opened: true }
|
||||
| { ok: true; opened: false; reason: string };
|
||||
|
||||
@@ -57,6 +63,11 @@ export type BoothReopenResult =
|
||||
| { ok: false; reason: string }
|
||||
| { ok: true; opened: boolean; reason?: string };
|
||||
|
||||
/** Minimum ANPR confidence for a plate to participate in swap reconciliation, both for the
|
||||
* exiting read and the matched open session's entry read. Below this, the read is advisory-
|
||||
* only and never triggers a swap warning (a fuzzy read must not block a legit car). */
|
||||
const PLATE_MATCH_MIN_CONFIDENCE = 0.85;
|
||||
|
||||
export class ExitFlow {
|
||||
readonly #db: Db;
|
||||
readonly #log: EventLog;
|
||||
@@ -88,7 +99,7 @@ export class ExitFlow {
|
||||
* (money was taken, the car is owed an exit) and an `anomaly` is appended so the
|
||||
* operator opens manually. Payment is never rolled back.
|
||||
*/
|
||||
async exitForBooth(identity: string): Promise<BoothExitResult> {
|
||||
async exitForBooth(identity: string, opts?: { override?: boolean; operator?: string }): Promise<BoothExitResult> {
|
||||
const id = identity.trim();
|
||||
if (!id) return { ok: false, status: "invalid", reason: "ticket id required" };
|
||||
|
||||
@@ -122,6 +133,40 @@ export class ExitFlow {
|
||||
return { ok: false, status: paid ? "grace_expired" : "unpaid", reason: rp.reason };
|
||||
}
|
||||
|
||||
// PLATE-SWAP CHECK — after the money/grace validation, before we sign the exit. If
|
||||
// the plate is already open under a DIFFERENT ticket, HOLD for the operator to review
|
||||
// (unless they consciously override). A denial here never traps the car — exit fails
|
||||
// open and the operator can override; the anomaly is the control either way.
|
||||
const swap = this.#reconcilePlateAtExit(id);
|
||||
if (swap) {
|
||||
if (!opts?.override) {
|
||||
// Sign the SUSPICION even if the operator walks away (tamper-evident record).
|
||||
const rp = reasonPayload("exit.plateSwapSuspected", { plate: swap.plate, otherIdentity: swap.otherIdentity });
|
||||
await this.#log.append({
|
||||
type: "anomaly",
|
||||
identity: id,
|
||||
payload: { ...rp, source: "booth", plateSwapSuspected: true, plate: swap.plate, otherIdentity: swap.otherIdentity },
|
||||
});
|
||||
this.#fireExitSnapshot(id);
|
||||
this.#logger.warn(`booth exit HELD (${id}): plate ${swap.plate} already open under ${swap.otherIdentity}`);
|
||||
return { ok: false, status: "swap_suspected", reason: rp.reason, plate: swap.plate, otherIdentity: swap.otherIdentity, otherEnteredAt: swap.otherEnteredAt };
|
||||
}
|
||||
// OVERRIDE: the operator consciously releases it. Sign the override (attributed).
|
||||
await this.#log.append({
|
||||
type: "anomaly",
|
||||
identity: id,
|
||||
payload: {
|
||||
...reasonPayload("exit.plateSwapOverride", { operator: opts.operator ?? "?", plate: swap.plate, otherIdentity: swap.otherIdentity }),
|
||||
source: "booth",
|
||||
plateSwapOverride: true,
|
||||
plate: swap.plate,
|
||||
otherIdentity: swap.otherIdentity,
|
||||
...(opts.operator ? { operator: opts.operator } : {}),
|
||||
},
|
||||
});
|
||||
this.#logger.warn(`booth exit OVERRIDE (${id}) by ${opts.operator ?? "?"}: plate-swap released (${swap.plate}, also open under ${swap.otherIdentity})`);
|
||||
}
|
||||
|
||||
// Free entry-grace path: mint the $0 payment first (ledger invariant), as the
|
||||
// reader path does.
|
||||
if (freeGrace && view.freeGrace) {
|
||||
@@ -336,6 +381,25 @@ export class ExitFlow {
|
||||
return { accepted: false, direction: "exit", reason: rp.reason };
|
||||
}
|
||||
|
||||
// PLATE-SWAP (reader path): detect + LOG, but FAIL OPEN. There's no operator at an
|
||||
// automated lane to make the override decision, and exit fails open for safety, so we
|
||||
// sign the suspicion anomaly (the control here) and still let the car out. The booth
|
||||
// path (operator-mediated) is where the hold + override lives.
|
||||
const swap = this.#reconcilePlateAtExit(e.value);
|
||||
if (swap) {
|
||||
await this.#log.append({
|
||||
type: "anomaly",
|
||||
identity: e.value,
|
||||
payload: {
|
||||
...reasonPayload("exit.plateSwapSuspected", { plate: swap.plate, otherIdentity: swap.otherIdentity }),
|
||||
plateSwapSuspected: true,
|
||||
plate: swap.plate,
|
||||
otherIdentity: swap.otherIdentity,
|
||||
},
|
||||
});
|
||||
this.#logger.warn(`reader exit: plate ${swap.plate} already open under ${swap.otherIdentity} (${e.value}) — logged, fail-open`);
|
||||
}
|
||||
|
||||
// Valid (a real payment within walk-back grace): sign + open.
|
||||
return this.#signExitAndOpen(resolved, e);
|
||||
}
|
||||
@@ -406,6 +470,43 @@ export class ExitFlow {
|
||||
this.#logger.error(`booth exit open failed (${identity}): ${detail}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* PLATE-SWAP reconciliation. The car's PLATE is the invariant a ticket-swap can't hide:
|
||||
* if this exiting ticket's plate is already OPEN under a DIFFERENT ticket, someone let a
|
||||
* paid car out on a fresh $0 ticket while the original lingers "inside" (occupancy fraud),
|
||||
* or two tickets were mixed up. We compare the EXITING plate against every open session's
|
||||
* ENTRY plate, EXACT normalized match, HIGH-CONFIDENCE reads only (a fuzzy/absent read is
|
||||
* advisory — never a gate, so it can't trap a legit car). Returns the matched open session
|
||||
* or null. See wiki/concepts/plate-reconciliation.md.
|
||||
*/
|
||||
#reconcilePlateAtExit(exitingId: string): { plate: string; otherIdentity: string; otherEnteredAt: string | null } | null {
|
||||
// The exiting car's plate: prefer its own exit read, else its entry read.
|
||||
const mine = plateForIdentity(this.#db, exitingId);
|
||||
if (!mine || !mine.plate || (mine.confidence ?? 0) < PLATE_MATCH_MIN_CONFIDENCE) return null;
|
||||
const wanted = mine.plate.trim().toUpperCase();
|
||||
|
||||
// All currently-open sessions (from the projection cache — a fast read-model; the check
|
||||
// is advisory so a slightly-stale cache is acceptable), excluding this ticket.
|
||||
const openIds = this.#db
|
||||
.select({ id: sessions.id })
|
||||
.from(sessions)
|
||||
.where(eq(sessions.state, "open"))
|
||||
.all()
|
||||
.map((r) => r.id)
|
||||
.filter((id) => id !== exitingId);
|
||||
if (openIds.length === 0) return null;
|
||||
|
||||
const plates = platesForIdentities(this.#db, openIds);
|
||||
for (const [otherId, pv] of plates) {
|
||||
if ((pv.confidence ?? 0) < PLATE_MATCH_MIN_CONFIDENCE) continue;
|
||||
if (pv.plate.trim().toUpperCase() !== wanted) continue;
|
||||
// A high-confidence exact match under a DIFFERENT open ticket → swap suspected.
|
||||
const enteredAt = this.#db.select({ enteredAt: sessions.enteredAt }).from(sessions).where(eq(sessions.id, otherId)).get()?.enteredAt ?? null;
|
||||
return { plate: wanted, otherIdentity: otherId, otherEnteredAt: enteredAt };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Fold the signed ledger into a session view for one identity (authoritative). */
|
||||
#sessionFor(identity: string): SessionView | null {
|
||||
const rows = this.#db
|
||||
|
||||
@@ -98,6 +98,11 @@ export interface SessionLookup {
|
||||
/** Amount owed right now (the quote). Null when no session / no active tariff. */
|
||||
readonly amountMinor: number | null;
|
||||
readonly currency: string | null;
|
||||
/** Amount actually PAID (from the latest payment event), if any. Distinct from
|
||||
* `amountMinor` (what's owed now): once a transient is settled `amountMinor` is null,
|
||||
* but the operator still wants to see the sum that was collected. */
|
||||
readonly paidMinor: number | null;
|
||||
readonly paidCurrency: string | null;
|
||||
/** True when paid AND still within the walk-back grace window. */
|
||||
readonly withinGrace: boolean;
|
||||
/** ISO time the walk-back grace expires (paidAt + graceExitMin), if paid. */
|
||||
@@ -274,7 +279,8 @@ export class PayStation {
|
||||
if (!entry) {
|
||||
return {
|
||||
identity: id, found: false, open: false, enteredAt: null, exitedAt: null,
|
||||
paidAt: null, amountMinor: null, currency: null, withinGrace: false, graceExpiresAt: null,
|
||||
paidAt: null, amountMinor: null, currency: null, paidMinor: null, paidCurrency: null,
|
||||
withinGrace: false, graceExpiresAt: null,
|
||||
overstay: false, subscription: false, subscriptionId: null, subscriptionHolder: null, plate: null,
|
||||
};
|
||||
}
|
||||
@@ -289,11 +295,17 @@ export class PayStation {
|
||||
|
||||
let paidAt: string | null = null;
|
||||
let graceExitMin: number | null = null;
|
||||
let paidMinor: number | null = null;
|
||||
let paidCurrency: string | null = null;
|
||||
for (const r of rows) {
|
||||
if (r.type === "payment") {
|
||||
paidAt = r.occurredAt;
|
||||
const p = (r.payload ?? {}) as { graceExitMin?: number };
|
||||
const p = (r.payload ?? {}) as { graceExitMin?: number; amountMinor?: number; currency?: string };
|
||||
if (typeof p.graceExitMin === "number") graceExitMin = p.graceExitMin;
|
||||
// Sum payments (overstay top-ups append a second one) so the displayed paid total
|
||||
// reflects everything collected for the session, not just the last slip.
|
||||
if (typeof p.amountMinor === "number") paidMinor = (paidMinor ?? 0) + p.amountMinor;
|
||||
if (typeof p.currency === "string") paidCurrency = p.currency;
|
||||
}
|
||||
}
|
||||
const graceExpiresAt =
|
||||
@@ -328,7 +340,7 @@ export class PayStation {
|
||||
return {
|
||||
identity: id, found: true, open,
|
||||
enteredAt: entry.occurredAt, exitedAt: exitRow?.occurredAt ?? null,
|
||||
paidAt, amountMinor, currency, withinGrace, graceExpiresAt, overstay,
|
||||
paidAt, amountMinor, currency, paidMinor, paidCurrency, withinGrace, graceExpiresAt, overstay,
|
||||
subscription: isSubscription, subscriptionId,
|
||||
subscriptionHolder: this.#holderOf(subscriptionId),
|
||||
plate: plateForIdentity(this.#db, id)?.plate ?? null,
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { requirePermission, roleHasPermissions } from "../auth.js";
|
||||
import { InvalidCashMovementError, type MovementStatus, type ShiftService } from "../shift-service.js";
|
||||
|
||||
// Drawer cash movements (manned mode). Redesigned 2026-07-01: an operator RECORDS a
|
||||
// receipt/disbursement FREELY (no admin sign-off at creation); an admin REVIEWS it after
|
||||
// the fact (authorize/deny — a flag that never moves cash). See wiki/concepts/shift.md.
|
||||
// - POST /api/drawer/movement : operator records a cash_in/cash_out. (drawer:create)
|
||||
// - GET /api/drawer/movements: list with review status. Operators see (shift:read)
|
||||
// only their own; reviewers see all + can filter status.
|
||||
// - POST /api/drawer/review : admin authorize/deny a movement. (drawer:review)
|
||||
// The drawer BALANCE math is unchanged — a movement counts immediately; a denial is a
|
||||
// judgment about the operator settled outside the app, never a cash reversal.
|
||||
|
||||
interface MovementBody {
|
||||
/** Direction is the document TYPE, not a sign: cash_in = Mandat Arkëtimi (pay-IN),
|
||||
* cash_out = Mandat Pagese (pay-OUT). */
|
||||
type: "cash_in" | "cash_out";
|
||||
/** POSITIVE minor units (magnitude). The direction comes from `type`. */
|
||||
amountMinor: number;
|
||||
reason?: string;
|
||||
currency?: string;
|
||||
}
|
||||
|
||||
interface ReviewBody {
|
||||
/** The cash_in/cash_out event id being decided on. */
|
||||
refId: string;
|
||||
decision: "authorize" | "deny";
|
||||
/** Optional admin note (e.g. why denied). */
|
||||
note?: string;
|
||||
}
|
||||
|
||||
interface MovementsQuery {
|
||||
/** Reviewers only: filter to pending/authorized/denied. Ignored for non-reviewers. */
|
||||
status?: MovementStatus;
|
||||
}
|
||||
|
||||
export async function drawerRoutes(app: FastifyInstance, shift: ShiftService): Promise<void> {
|
||||
const createGuard = requirePermission("drawer:create");
|
||||
const reviewGuard = requirePermission("drawer:review");
|
||||
const readGuard = requirePermission("shift:read");
|
||||
|
||||
// Operator RECORDS a movement — freely, no authorizer. It counts in the drawer at once.
|
||||
app.post<{ Body: MovementBody }>("/api/drawer/movement", { preHandler: createGuard }, async (req, reply) => {
|
||||
const b = req.body ?? ({} as MovementBody);
|
||||
if (b.type !== "cash_in" && b.type !== "cash_out") {
|
||||
return reply.code(400).send({ error: "type must be cash_in or cash_out" });
|
||||
}
|
||||
try {
|
||||
return await shift.recordVoucher({
|
||||
type: b.type,
|
||||
operator: req.user.username,
|
||||
amountMinor: b.amountMinor,
|
||||
reason: b.reason ?? "",
|
||||
currency: b.currency,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof InvalidCashMovementError) return reply.code(400).send({ error: err.message });
|
||||
return reply.code(500).send({ error: (err as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
// List movements + review status. Operators are hard-scoped to their OWN movements; a
|
||||
// reviewer sees ALL and may filter by status (the pending review queue).
|
||||
app.get<{ Querystring: MovementsQuery }>("/api/drawer/movements", { preHandler: readGuard }, async (req) => {
|
||||
const canReview = roleHasPermissions(req.user.roleId, ["drawer:review"]);
|
||||
const q = req.query ?? {};
|
||||
const status = canReview && ["pending", "authorized", "denied"].includes(q.status ?? "") ? q.status : undefined;
|
||||
const movements = shift.movementsWithStatus({
|
||||
operator: canReview ? undefined : req.user.username,
|
||||
status,
|
||||
});
|
||||
return { movements, scope: canReview ? "all" : "self" };
|
||||
});
|
||||
|
||||
// Admin AUTHORIZES or DENIES a recorded movement. A flag only — no cash reversal.
|
||||
app.post<{ Body: ReviewBody }>("/api/drawer/review", { preHandler: reviewGuard }, async (req, reply) => {
|
||||
const b = req.body ?? ({} as ReviewBody);
|
||||
if (!b.refId || (b.decision !== "authorize" && b.decision !== "deny")) {
|
||||
return reply.code(400).send({ error: "refId and decision (authorize|deny) are required" });
|
||||
}
|
||||
try {
|
||||
return await shift.reviewMovement({
|
||||
refId: b.refId,
|
||||
decision: b.decision,
|
||||
reviewedBy: req.user.username,
|
||||
note: b.note,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof InvalidCashMovementError) return reply.code(400).send({ error: err.message });
|
||||
return reply.code(500).send({ error: (err as Error).message });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { requirePermission } from "../auth.js";
|
||||
import type { EntryFlow } from "../entry-flow.js";
|
||||
import type { LaneStatus } from "../lane-status.js";
|
||||
import type { ShiftService } from "../shift-service.js";
|
||||
import { NoShiftOpenError } from "../shift-service.js";
|
||||
|
||||
// Operator-issued entry (2026-07-01). When the physical entry button is broken, an operator
|
||||
// may issue an entry ticket — a FLAGGED mint (vehicle_entry source=manual + operatorInitiated
|
||||
// + a companion anomaly), gated EXACTLY like the physical button: a real vehicle must be
|
||||
// present (radar/loop AND camera). The presence gate is enforced HERE (server-side), so a
|
||||
// direct POST can't bypass a disabled UI button. Money-adjacent → requires an open shift.
|
||||
// See wiki/concepts/operator-issued-entry.md.
|
||||
|
||||
export async function entryRoutes(
|
||||
app: FastifyInstance,
|
||||
entryFlow: EntryFlow,
|
||||
laneStatus: LaneStatus,
|
||||
shift: ShiftService,
|
||||
): Promise<void> {
|
||||
const guard = requirePermission("session:create");
|
||||
|
||||
app.post("/api/entry/issue", { preHandler: guard }, async (req, reply) => {
|
||||
// Gate on an open shift (a minted entry belongs to an accountable operator).
|
||||
if (!shift.currentOpenShift()) {
|
||||
return reply.code(409).send({ error: new NoShiftOpenError().message });
|
||||
}
|
||||
// The camera side of the presence gate = the live entry lane-busy state; the radar/loop
|
||||
// side is checked inside the flow (its per-relay presence guard).
|
||||
const cameraBusy = laneStatus.snapshot().entry;
|
||||
const res = await entryFlow.issueForOperator(req.user.username, cameraBusy);
|
||||
if (!res.ok) return reply.code(409).send({ error: res.reason });
|
||||
return res;
|
||||
});
|
||||
}
|
||||
@@ -30,6 +30,9 @@ interface PayBody {
|
||||
}
|
||||
interface ExitBody {
|
||||
identity: string;
|
||||
/** Operator consciously releases a suspected plate-swap exit (re-submit after the
|
||||
* first call returned status "swap_suspected"). Signs an attributed override anomaly. */
|
||||
override?: boolean;
|
||||
}
|
||||
interface VoucherBody {
|
||||
identity: string;
|
||||
@@ -109,8 +112,17 @@ export async function payRoutes(
|
||||
async (req, reply) => {
|
||||
const identity = (req.body?.identity ?? "").trim();
|
||||
if (!identity) return reply.code(400).send({ error: "identity required" });
|
||||
const res = await exitFlow.exitForBooth(identity);
|
||||
if (!res.ok) return reply.code(409).send({ error: res.reason, status: res.status });
|
||||
const res = await exitFlow.exitForBooth(identity, {
|
||||
override: req.body?.override === true,
|
||||
operator: req.user?.username,
|
||||
});
|
||||
// A suspected plate-swap returns the full detail so the modal can warn + offer override.
|
||||
if (!res.ok) {
|
||||
if (res.status === "swap_suspected") {
|
||||
return reply.code(409).send({ error: res.reason, status: res.status, plate: res.plate, otherIdentity: res.otherIdentity, otherEnteredAt: res.otherEnteredAt });
|
||||
}
|
||||
return reply.code(409).send({ error: res.reason, status: res.status });
|
||||
}
|
||||
return reply.code(200).send(res);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,27 +1,6 @@
|
||||
import bcrypt from "bcrypt";
|
||||
import { eq, users, type Db } from "@parking/db";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { requirePermission, roleHasPermissions } from "../auth.js";
|
||||
import {
|
||||
InvalidCashMovementError,
|
||||
NoOpenShiftError,
|
||||
ShiftAlreadyOpenError,
|
||||
type ShiftService,
|
||||
} from "../shift-service.js";
|
||||
|
||||
interface CashVoucherBody {
|
||||
/** Direction is the document TYPE, not a sign: cash_in = Mandat Arkëtimi (pay-IN),
|
||||
* cash_out = Mandat Pagese (pay-OUT). */
|
||||
type: "cash_in" | "cash_out";
|
||||
/** POSITIVE minor units (magnitude). The direction comes from `type`. */
|
||||
amountMinor: number;
|
||||
reason?: string;
|
||||
currency?: string;
|
||||
/** The admin who authorizes this voucher (operator-raised / admin-authorized). */
|
||||
authorizedBy: string;
|
||||
/** That admin's password — re-entered to sign off on the drawer movement. */
|
||||
authorizerPassword: string;
|
||||
}
|
||||
import { NoOpenShiftError, ShiftAlreadyOpenError, type ShiftService } from "../shift-service.js";
|
||||
|
||||
interface ShiftsQuery {
|
||||
/** Filter to one operator (admin-only; non-admins are forced to themselves). */
|
||||
@@ -35,7 +14,7 @@ interface ShiftsQuery {
|
||||
// opened/closed explicitly (not time-based — see wiki/concepts/shift.md and
|
||||
// local-jwt-auth.md "until logout"). End Shift signs a shift_z_report + prints it.
|
||||
|
||||
export async function shiftRoutes(app: FastifyInstance, shift: ShiftService, db: Db): Promise<void> {
|
||||
export async function shiftRoutes(app: FastifyInstance, shift: ShiftService): Promise<void> {
|
||||
// Reading the shift state vs. opening/closing one's own shift.
|
||||
const readGuard = requirePermission("shift:read");
|
||||
const guard = requirePermission("shift:create");
|
||||
@@ -87,49 +66,8 @@ export async function shiftRoutes(app: FastifyInstance, shift: ShiftService, db:
|
||||
return { shifts, scope: canSeeAll ? "all" : "self" };
|
||||
});
|
||||
|
||||
// Drawer cash VOUCHER — Mandat Arkëtimi (cash_in / pay-IN) or Mandat Pagese
|
||||
// (cash_out / pay-OUT). The direction is the document TYPE, not a signed amount.
|
||||
// OPERATOR-RAISED, ADMIN-AUTHORIZED: any holder of `shift:create` (operator-grade)
|
||||
// may RAISE the voucher, but it only commits if `authorizedBy` is a real admin
|
||||
// (`shift:cash`) who re-enters their password. This keeps the float control —
|
||||
// an operator cannot move the float alone — while letting them raise the slip.
|
||||
// See wiki/concepts/shift.md.
|
||||
app.post<{ Body: CashVoucherBody }>(
|
||||
"/api/cash-voucher",
|
||||
{ preHandler: guard },
|
||||
async (req, reply) => {
|
||||
const b = req.body ?? ({} as CashVoucherBody);
|
||||
if (b.type !== "cash_in" && b.type !== "cash_out") {
|
||||
return reply.code(400).send({ error: "type must be cash_in or cash_out" });
|
||||
}
|
||||
const authName = (b.authorizedBy ?? "").trim();
|
||||
if (!authName || !b.authorizerPassword) {
|
||||
return reply.code(400).send({ error: "authorizedBy and authorizerPassword are required" });
|
||||
}
|
||||
// Verify the authorizer: a real user, admin-grade (shift:cash), correct password.
|
||||
const authUser = await db.select().from(users).where(eq(users.username, authName)).get();
|
||||
// Always run a bcrypt compare (constant-time wrt whether the user exists).
|
||||
const hash = authUser?.passwordHash ?? "$2b$10$invalidinvalidinvalidinvalidinvalidinvalidinv";
|
||||
const passwordOk = await bcrypt.compare(b.authorizerPassword, hash);
|
||||
const isAdminGrade = authUser != null && roleHasPermissions(authUser.roleId, ["shift:cash"]);
|
||||
if (!authUser || !passwordOk || !isAdminGrade) {
|
||||
return reply.code(403).send({ error: "authorizer must be an admin with a correct password" });
|
||||
}
|
||||
try {
|
||||
return await shift.recordVoucher({
|
||||
type: b.type,
|
||||
operator: req.user.username, // who RAISED it
|
||||
authorizedBy: authUser.username, // who signed off (canonical case)
|
||||
amountMinor: b.amountMinor,
|
||||
reason: b.reason ?? "",
|
||||
currency: b.currency,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof InvalidCashMovementError) return reply.code(400).send({ error: err.message });
|
||||
return reply.code(500).send({ error: (err as Error).message });
|
||||
}
|
||||
},
|
||||
);
|
||||
// NB: drawer cash movements (record/review) moved to routes/drawer.ts (2026-07-01) — the
|
||||
// feature is no longer part of the shift route. See wiki/concepts/shift.md.
|
||||
|
||||
app.post("/api/shift/open", { preHandler: guard }, async (req, reply) => {
|
||||
try {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { and, desc, eq, deviceEvents, snapshots, type Db } from "@parking/db";
|
||||
import { requirePermission } from "../auth.js";
|
||||
import { cleanType } from "../snapshot.js";
|
||||
|
||||
// Read access to captured entry/exit snapshots (the BLOB-in-DB image store, see
|
||||
// packages/db schema + wiki/concepts/lane-direction.md). Snapshots are evidence
|
||||
@@ -116,7 +117,10 @@ export async function snapshotRoutes(app: FastifyInstance, db: Db): Promise<void
|
||||
async (req, reply) => {
|
||||
const row = db.select().from(snapshots).where(eq(snapshots.id, req.params.id)).get();
|
||||
if (!row) return reply.code(404).send({ error: "no such snapshot" });
|
||||
reply.header("content-type", row.contentType);
|
||||
// Normalize on the way OUT too: legacy rows stored a camera's malformed
|
||||
// `image/jpeg; charset="UTF-8"`, which browsers refuse to render. cleanType strips
|
||||
// the bogus params back to a bare `image/jpeg` so every stored image displays.
|
||||
reply.header("content-type", cleanType(row.contentType));
|
||||
reply.header("cache-control", "private, max-age=31536000, immutable");
|
||||
return reply.send(row.bytes);
|
||||
},
|
||||
|
||||
@@ -42,6 +42,8 @@ import { subscriptionRoutes } from "./routes/subscriptions.js";
|
||||
import { subscriptionPlanRoutes } from "./routes/subscription-plans.js";
|
||||
import { qrReaderRoutes } from "./routes/qr-reader.js";
|
||||
import { shiftRoutes } from "./routes/shift.js";
|
||||
import { drawerRoutes } from "./routes/drawer.js";
|
||||
import { entryRoutes } from "./routes/entry.js";
|
||||
import { siteRoutes } from "./routes/site.js";
|
||||
import { snapshotRoutes } from "./routes/snapshots.js";
|
||||
import { tariffRoutes } from "./routes/tariffs.js";
|
||||
@@ -265,8 +267,12 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
||||
await subscriptionRoutes(app, db, credentialCapture, eventLog, shiftService);
|
||||
await subscriptionPlanRoutes(app, db);
|
||||
|
||||
// Shift open/close + drawer endpoints (shiftService constructed above).
|
||||
await shiftRoutes(app, shiftService, db);
|
||||
// Shift open/close (shiftService constructed above).
|
||||
await shiftRoutes(app, shiftService);
|
||||
// Drawer cash movements — operator records, admin reviews (routes/drawer.ts).
|
||||
await drawerRoutes(app, shiftService);
|
||||
// Operator-issued entry (broken physical button) — flagged mint, presence-gated.
|
||||
await entryRoutes(app, entryFlow, laneStatus, shiftService);
|
||||
|
||||
// Site config (capacity) + live occupancy. The FULL gate (refuse transient entry
|
||||
// at capacity) is in the entry flow. See wiki/concepts/capacity-occupancy.md.
|
||||
|
||||
@@ -117,27 +117,100 @@ describe("drawer carry-forward", () => {
|
||||
expect(next.openingFloatMinor).toBe(25000); // inherited
|
||||
});
|
||||
|
||||
it("cash_in / cash_out vouchers adjust the drawer", async () => {
|
||||
it("cash_in / cash_out movements 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" });
|
||||
await shift.recordVoucher({ type: "cash_in", operator: "alice", amountMinor: 100000, reason: "float load" });
|
||||
await shift.recordVoucher({ type: "cash_out", operator: "alice", 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 () => {
|
||||
it("rejects a non-positive movement amount", async () => {
|
||||
await shift.open("alice");
|
||||
await expect(
|
||||
shift.recordVoucher({ type: "cash_in", operator: "alice", authorizedBy: "admin", amountMinor: 0, reason: "x" }),
|
||||
shift.recordVoucher({ type: "cash_in", operator: "alice", amountMinor: 0, reason: "x" }),
|
||||
).rejects.toBeInstanceOf(InvalidCashMovementError);
|
||||
await expect(
|
||||
shift.recordVoucher({ type: "cash_out", operator: "alice", authorizedBy: "admin", amountMinor: -5, reason: "x" }),
|
||||
shift.recordVoucher({ type: "cash_out", operator: "alice", amountMinor: -5, reason: "x" }),
|
||||
).rejects.toBeInstanceOf(InvalidCashMovementError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("drawer review (operator records, admin reviews after)", () => {
|
||||
it("a new movement starts pending; review sets authorized/denied", async () => {
|
||||
await shift.open("alice");
|
||||
const m = await shift.recordVoucher({ type: "cash_out", operator: "alice", amountMinor: 5000, reason: "supplies" });
|
||||
// Find the movement's ledger id via the status list.
|
||||
let list = shift.movementsWithStatus({ operator: "alice" });
|
||||
expect(list).toHaveLength(1);
|
||||
expect(list[0].status).toBe("pending");
|
||||
expect(list[0].voucherNo).toBe(m.voucherNo);
|
||||
|
||||
await shift.reviewMovement({ refId: list[0].id, decision: "deny", reviewedBy: "admin", note: "not genuine" });
|
||||
list = shift.movementsWithStatus({ operator: "alice" });
|
||||
expect(list[0].status).toBe("denied");
|
||||
expect(list[0].reviewedBy).toBe("admin");
|
||||
expect(list[0].reviewNote).toBe("not genuine");
|
||||
});
|
||||
|
||||
it("DENY is a flag only — it does NOT reverse the movement or touch the drawer", async () => {
|
||||
await shift.open("alice");
|
||||
await shift.recordVoucher({ type: "cash_out", operator: "alice", amountMinor: 10000, reason: "x" });
|
||||
const before = shift.drawerBalance().balanceMinor;
|
||||
expect(before).toBe(-10000); // the disbursement counted immediately
|
||||
const id = shift.movementsWithStatus({ operator: "alice" })[0].id;
|
||||
await shift.reviewMovement({ refId: id, decision: "deny", reviewedBy: "admin" });
|
||||
// Balance UNCHANGED by the denial — the correction is settled outside the app.
|
||||
expect(shift.drawerBalance().balanceMinor).toBe(-10000);
|
||||
});
|
||||
|
||||
it("a denied movement in a CLOSED shift never leaks into the next operator's drawer", async () => {
|
||||
// The regression that motivated the redesign: op1 disburses, shift closes, op2
|
||||
// inherits; op1's disbursement is later DENIED. op2's drawer must be untouched.
|
||||
await shift.open("op1");
|
||||
await shift.recordVoucher({ type: "cash_out", operator: "op1", amountMinor: 10000, reason: "questionable" });
|
||||
const closed = await shift.close("op1");
|
||||
expect(closed.expectedDrawerMinor).toBe(-10000);
|
||||
|
||||
const next = await shift.open("op2");
|
||||
expect(next.openingFloatMinor).toBe(-10000); // op2 inherits the real till balance
|
||||
|
||||
const id = shift.movementsWithStatus({ operator: "op1" })[0].id;
|
||||
await shift.reviewMovement({ refId: id, decision: "deny", reviewedBy: "admin" });
|
||||
|
||||
// op2's drawer is STILL -10000 — the denial added no reversing cash.
|
||||
expect(shift.drawerBalance().balanceMinor).toBe(-10000);
|
||||
expect(shift.currentReport()!.openingFloatMinor).toBe(-10000);
|
||||
});
|
||||
|
||||
it("rejects reviewing a non-movement or an already-reviewed movement", async () => {
|
||||
await shift.open("alice");
|
||||
await shift.recordVoucher({ type: "cash_in", operator: "alice", amountMinor: 5000, reason: "x" });
|
||||
const id = shift.movementsWithStatus({ operator: "alice" })[0].id;
|
||||
await expect(
|
||||
shift.reviewMovement({ refId: "not-a-real-id", decision: "authorize", reviewedBy: "admin" }),
|
||||
).rejects.toBeInstanceOf(InvalidCashMovementError);
|
||||
await shift.reviewMovement({ refId: id, decision: "authorize", reviewedBy: "admin" });
|
||||
await expect(
|
||||
shift.reviewMovement({ refId: id, decision: "deny", reviewedBy: "admin" }),
|
||||
).rejects.toBeInstanceOf(InvalidCashMovementError); // already reviewed
|
||||
});
|
||||
|
||||
it("scopes movements by operator", async () => {
|
||||
await shift.open("alice");
|
||||
await shift.recordVoucher({ type: "cash_in", operator: "alice", amountMinor: 1000, reason: "a" });
|
||||
await shift.close("alice");
|
||||
await shift.open("bob");
|
||||
await shift.recordVoucher({ type: "cash_out", operator: "bob", amountMinor: 2000, reason: "b" });
|
||||
expect(shift.movementsWithStatus({ operator: "alice" })).toHaveLength(1);
|
||||
expect(shift.movementsWithStatus({ operator: "bob" })).toHaveLength(1);
|
||||
expect(shift.movementsWithStatus()).toHaveLength(2); // reviewer sees all
|
||||
expect(shift.movementsWithStatus({ status: "pending" })).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
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");
|
||||
|
||||
@@ -90,6 +90,27 @@ export interface ShiftReport {
|
||||
readonly printed: boolean;
|
||||
}
|
||||
|
||||
/** A drawer movement's admin-review status, derived from its latest `cash_review`. */
|
||||
export type MovementStatus = "pending" | "authorized" | "denied";
|
||||
|
||||
/** One drawer cash movement (cash_in/cash_out) with its review status — the row shape for
|
||||
* the operator's own list and the admin review queue. `status` is derived, not stored. */
|
||||
export interface DrawerMovement {
|
||||
readonly id: string;
|
||||
readonly type: "cash_in" | "cash_out";
|
||||
/** Positive magnitude; direction is the `type`. */
|
||||
readonly amountMinor: number;
|
||||
readonly currency: string | null;
|
||||
readonly reason: string | null;
|
||||
readonly operator: string;
|
||||
readonly voucherNo: string | null;
|
||||
readonly at: string;
|
||||
readonly status: MovementStatus;
|
||||
readonly reviewedBy: string | null;
|
||||
readonly reviewNote: string | null;
|
||||
readonly reviewedAt: string | null;
|
||||
}
|
||||
|
||||
export class InvalidCashMovementError extends Error {
|
||||
constructor(msg: string) {
|
||||
super(msg);
|
||||
@@ -281,24 +302,24 @@ export class ShiftService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a drawer cash VOUCHER — the direction is the event TYPE, not the sign of
|
||||
* an amount (a receipt and a disbursement are different financial documents):
|
||||
* Record a drawer cash MOVEMENT — the direction is the event TYPE, not the sign of an
|
||||
* amount (a receipt and a disbursement are different financial documents):
|
||||
* - `cash_in` (Mandat Arkëtimi): cash entered the drawer (+).
|
||||
* - `cash_out` (Mandat Pagese): cash left the drawer (−).
|
||||
* `amountMinor` is always a POSITIVE magnitude. The voucher is OPERATOR-RAISED and
|
||||
* ADMIN-AUTHORIZED: `operator` raised it, `authorizedBy` signed off (verified at the
|
||||
* route). Returns the new drawer balance + the assigned voucher number, and prints
|
||||
* a slip best-effort (the signed event is the record). See wiki/concepts/shift.md.
|
||||
* `amountMinor` is always a POSITIVE magnitude. The movement is OPERATOR-RECORDED FREELY
|
||||
* (no admin sign-off at creation — 2026-07-01); an admin REVIEWS it after the fact via
|
||||
* `reviewMovement` (authorize/deny — a flag that never moves cash). It counts in the
|
||||
* drawer immediately (the cash physically moved). Returns the new drawer balance + the
|
||||
* assigned voucher number, and prints a slip best-effort. See wiki/concepts/shift.md.
|
||||
*/
|
||||
async recordVoucher(args: {
|
||||
type: "cash_in" | "cash_out";
|
||||
operator: string;
|
||||
authorizedBy: string;
|
||||
amountMinor: number;
|
||||
reason: string;
|
||||
currency?: string;
|
||||
}): Promise<{ type: "cash_in" | "cash_out"; amountMinor: number; voucherNo: string; balanceMinor: number; printed: boolean }> {
|
||||
const { type, operator, authorizedBy, reason } = args;
|
||||
const { type, operator, reason } = args;
|
||||
if (!Number.isInteger(args.amountMinor) || args.amountMinor <= 0) {
|
||||
throw new InvalidCashMovementError("amountMinor must be a positive integer (minor units)");
|
||||
}
|
||||
@@ -308,25 +329,122 @@ export class ShiftService {
|
||||
await this.#log.append({
|
||||
type,
|
||||
source: "manual",
|
||||
identity: operator, // who RAISED the voucher (the operator at the booth)
|
||||
identity: operator, // who RECORDED the movement (the operator at the booth)
|
||||
payload: {
|
||||
amountMinor, // positive magnitude — direction is the type
|
||||
...(reason ? { reason } : {}),
|
||||
...(args.currency ? { currency: args.currency } : {}),
|
||||
operator,
|
||||
authorizedBy,
|
||||
voucherNo,
|
||||
},
|
||||
occurredAt: now,
|
||||
});
|
||||
const { balanceMinor, currency } = this.#drawerBalanceAt(now);
|
||||
const printed = await this.#printVoucher({ type, voucherNo, amountMinor, reason, operator, authorizedBy, currency, at: now });
|
||||
const printed = await this.#printVoucher({ type, voucherNo, amountMinor, reason, operator, currency, at: now });
|
||||
this.#logger.info(
|
||||
`${type} ${voucherNo} ${amountMinor} by ${operator} authz ${authorizedBy} (${reason || "no reason"}) → drawer ${balanceMinor}`,
|
||||
`${type} ${voucherNo} ${amountMinor} by ${operator} (${reason || "no reason"}) → drawer ${balanceMinor}`,
|
||||
);
|
||||
return { type, amountMinor, voucherNo, balanceMinor, printed };
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin's post-hoc REVIEW of a recorded cash_in/cash_out. Appends a signed `cash_review`
|
||||
* referencing the movement. This is a FLAG ONLY — a `deny` does NOT reverse the movement
|
||||
* and does NOT touch the drawer balance (a denial is a judgment about the operator,
|
||||
* settled outside the app). Rejects an unknown/ non-movement refId, and a movement that
|
||||
* was already decided (one decision per movement; a clean audit trail). Idempotent by
|
||||
* design: the drawer fold never reads `cash_review`. See wiki/concepts/shift.md.
|
||||
*/
|
||||
async reviewMovement(args: {
|
||||
refId: string;
|
||||
decision: "authorize" | "deny";
|
||||
reviewedBy: string;
|
||||
note?: string;
|
||||
}): Promise<{ refId: string; decision: "authorize" | "deny"; reviewedBy: string; at: string }> {
|
||||
const { refId, decision, reviewedBy } = args;
|
||||
if (decision !== "authorize" && decision !== "deny") {
|
||||
throw new InvalidCashMovementError("decision must be authorize or deny");
|
||||
}
|
||||
const movement = this.#db.select().from(ledgerEvents).where(eq(ledgerEvents.id, refId)).get();
|
||||
if (!movement || (movement.type !== "cash_in" && movement.type !== "cash_out")) {
|
||||
throw new InvalidCashMovementError("refId is not a cash movement");
|
||||
}
|
||||
// One decision per movement — reject a re-review so the audit stays unambiguous.
|
||||
const already = this.#db
|
||||
.select()
|
||||
.from(ledgerEvents)
|
||||
.where(eq(ledgerEvents.type, "cash_review"))
|
||||
.all()
|
||||
.some((r) => (r.payload as LedgerPayload | null)?.refId === refId);
|
||||
if (already) throw new InvalidCashMovementError("movement already reviewed");
|
||||
|
||||
const now = new Date().toISOString();
|
||||
await this.#log.append({
|
||||
type: "cash_review",
|
||||
source: "manual",
|
||||
identity: reviewedBy, // the admin who decided
|
||||
payload: {
|
||||
refId,
|
||||
decision,
|
||||
reviewedBy,
|
||||
...(args.note ? { note: args.note } : {}),
|
||||
},
|
||||
occurredAt: now,
|
||||
});
|
||||
this.#logger.info(`cash_review ${decision} of ${movement.type} ${refId} by ${reviewedBy}`);
|
||||
return { refId, decision, reviewedBy, at: now };
|
||||
}
|
||||
|
||||
/**
|
||||
* All drawer cash movements (cash_in/cash_out) with their review STATUS, newest first.
|
||||
* Status is derived from the latest `cash_review` referencing each movement: none →
|
||||
* `pending`, else `authorized`/`denied`. Powers the operator's own list and the admin
|
||||
* review queue. `operator` (optional) scopes to one operator's movements (an operator
|
||||
* sees only their own; a reviewer sees all). See wiki/concepts/shift.md.
|
||||
*/
|
||||
movementsWithStatus(filter?: { operator?: string; status?: MovementStatus }): DrawerMovement[] {
|
||||
const rows = this.#db.select().from(ledgerEvents).orderBy(ledgerEvents.index).all();
|
||||
// Latest review decision per movement id.
|
||||
const reviewByRef = new Map<string, { decision: "authorize" | "deny"; reviewedBy: string; note?: string; at: string }>();
|
||||
for (const r of rows) {
|
||||
if (r.type !== "cash_review") continue;
|
||||
const pl = (r.payload ?? {}) as LedgerPayload;
|
||||
if (!pl.refId || (pl.decision !== "authorize" && pl.decision !== "deny")) continue;
|
||||
reviewByRef.set(pl.refId, {
|
||||
decision: pl.decision,
|
||||
reviewedBy: pl.reviewedBy ?? "",
|
||||
...(pl.note ? { note: pl.note } : {}),
|
||||
at: r.occurredAt,
|
||||
});
|
||||
}
|
||||
const out: DrawerMovement[] = [];
|
||||
for (const r of rows) {
|
||||
if (r.type !== "cash_in" && r.type !== "cash_out") continue;
|
||||
const pl = (r.payload ?? {}) as LedgerPayload;
|
||||
const operator = (typeof pl.operator === "string" ? pl.operator : null) ?? r.identity ?? "";
|
||||
if (filter?.operator && operator !== filter.operator) continue;
|
||||
const review = reviewByRef.get(r.id);
|
||||
const status: MovementStatus = review ? (review.decision === "authorize" ? "authorized" : "denied") : "pending";
|
||||
if (filter?.status && status !== filter.status) continue;
|
||||
out.push({
|
||||
id: r.id,
|
||||
type: r.type,
|
||||
amountMinor: typeof pl.amountMinor === "number" ? Math.abs(pl.amountMinor) : 0,
|
||||
currency: pl.currency ?? null,
|
||||
reason: pl.reason ?? null,
|
||||
operator,
|
||||
voucherNo: pl.voucherNo ?? null,
|
||||
at: r.occurredAt,
|
||||
status,
|
||||
reviewedBy: review?.reviewedBy ?? null,
|
||||
reviewNote: review?.note ?? null,
|
||||
reviewedAt: review?.at ?? null,
|
||||
});
|
||||
}
|
||||
// Newest first.
|
||||
return out.sort((a, b) => (a.at < b.at ? 1 : a.at > b.at ? -1 : 0));
|
||||
}
|
||||
|
||||
/** Open a shift for the operator (explicit start). The opening float is auto-
|
||||
* inherited from the chain = the drawer balance at the start instant. */
|
||||
async open(operator: string): Promise<{ startedAt: string; openingFloatMinor: number }> {
|
||||
@@ -578,7 +696,6 @@ export class ShiftService {
|
||||
amountMinor: number;
|
||||
reason: string;
|
||||
operator: string;
|
||||
authorizedBy: string;
|
||||
currency: string | null;
|
||||
at: string;
|
||||
}): Promise<boolean> {
|
||||
@@ -597,8 +714,7 @@ export class ShiftService {
|
||||
`Shuma: ${money(v.amountMinor)} ${cur}`,
|
||||
`Arsyeja: ${v.reason || "-"}`,
|
||||
"",
|
||||
`Hapur nga: ${v.operator}`,
|
||||
`Autorizoi: ${v.authorizedBy}`,
|
||||
`Regjistroi: ${v.operator}`,
|
||||
];
|
||||
try {
|
||||
await printer.printReport({ title, lines });
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import sharp from "sharp";
|
||||
import type { CameraDevice, Snapshot } from "@parking/devices";
|
||||
import { captureSnapshotShared, encodeForStorage } from "./snapshot.js";
|
||||
import { captureSnapshotShared, cleanType, encodeForStorage } from "./snapshot.js";
|
||||
import { silentLogger } from "./test-helpers.js";
|
||||
|
||||
// captureSnapshotShared: one HTTP pull per camera per vehicle. A Hikvision unit serves
|
||||
@@ -139,3 +139,20 @@ describe("encodeForStorage", () => {
|
||||
expect(out.contentType).toBe("text/plain"); // charset stripped even on the fallback
|
||||
});
|
||||
});
|
||||
|
||||
describe("cleanType", () => {
|
||||
it("strips a camera's charset cruft so a binary JPEG renders", () => {
|
||||
// The exact malformed value some cameras (Hikvision) return, which broke the
|
||||
// snapshot strip for every legacy row until the serve route normalized it.
|
||||
expect(cleanType('image/jpeg; charset="UTF-8"')).toBe("image/jpeg");
|
||||
expect(cleanType("image/jpeg; charset=utf-8")).toBe("image/jpeg");
|
||||
});
|
||||
|
||||
it("passes a clean type through and defaults a missing one", () => {
|
||||
expect(cleanType("image/jpeg")).toBe("image/jpeg");
|
||||
expect(cleanType("image/png")).toBe("image/png");
|
||||
expect(cleanType(null)).toBe("image/jpeg");
|
||||
expect(cleanType(undefined)).toBe("image/jpeg");
|
||||
expect(cleanType("")).toBe("image/jpeg");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -40,9 +40,13 @@ import type { VisionClient } from "./vision-client.js";
|
||||
const SNAP_MAX_EDGE = Number(process.env.SNAPSHOT_MAX_EDGE ?? 1280);
|
||||
const SNAP_QUALITY = Number(process.env.SNAPSHOT_JPEG_QUALITY ?? 80);
|
||||
|
||||
/** Strip a camera's `; charset=...` cruft from a content type (a JPEG is binary). */
|
||||
function cleanType(ct: string): string {
|
||||
const base = ct.split(";")[0]?.trim();
|
||||
/** Strip a camera's `; charset=...` cruft from a content type (a JPEG is binary). A bare
|
||||
* `image/jpeg` renders; `image/jpeg; charset="UTF-8"` (what some cameras return, e.g.
|
||||
* Hikvision) is malformed for a binary body and browsers refuse to decode it. Applied
|
||||
* both on capture AND when serving, so legacy rows stored before this normalization
|
||||
* existed still serve a clean type. */
|
||||
export function cleanType(ct: string | null | undefined): string {
|
||||
const base = ct?.split(";")[0]?.trim();
|
||||
return base || "image/jpeg";
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { fetchActiveSessions, reopenBarrier, type ActiveSession } from "./api.js";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchActiveSessions } from "./api.js";
|
||||
import { qk } from "./lib/query.js";
|
||||
import { useShift } from "./lib/use-shift.js";
|
||||
import { formatDuration, formatRelativeDateTime } from "./lib/format.js";
|
||||
import { formatCountdown, formatDuration, formatRelativeDateTime } from "./lib/format.js";
|
||||
import { Panel } from "./ui/Panel.js";
|
||||
import { FilterBar, SegGroup, type SegOption } from "./ui/FilterBar.js";
|
||||
|
||||
@@ -12,13 +11,8 @@ import { FilterBar, SegGroup, type SegOption } from "./ui/FilterBar.js";
|
||||
// within-grace (the barrier is UNCONFIRMED, so a paid/exited car is presumed
|
||||
// possibly-present until grace runs out). Lets the operator find a stuck car —
|
||||
// damaged ticket, dead scanner, or a phantom barrier re-close — without a scan:
|
||||
// - click a row → the pay/exit modal (pay an unpaid car, settle a subscriber's
|
||||
// out-of-window charge, assist-open a prepaid subscriber, or review),
|
||||
// - "Open barrier" (PAID transient sessions only) → an audited human-intervention
|
||||
// re-pulse for a car that paid but whose barrier didn't confirm.
|
||||
// No payment → no Open barrier button (the no-unpaid-bypass rule). Subscriptions get
|
||||
// NO inline open here — their assist-open / window-charge payment is modal-only, so
|
||||
// the list can't one-click past an unpaid out-of-window charge.
|
||||
// click a row → the pay/exit modal (pay an unpaid car, settle a subscriber's
|
||||
// out-of-window charge, assist-open a prepaid subscriber, or review).
|
||||
//
|
||||
// OVERSTAY sessions (paid, grace expired, no signed exit) are no longer aged out — they
|
||||
// stay listed with a distinct badge. A new period has begun (the car re-parked or is
|
||||
@@ -30,11 +24,6 @@ type KindFilter = "transient" | "subscription";
|
||||
|
||||
export function ActiveSessions({ onPick }: { onPick: (identity: string) => void }) {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
// The audited barrier re-open is a money-path action (server-gated on an open
|
||||
// shift); disable it unless this operator's shift is open.
|
||||
const { isOpen: shiftOpen, isMine: shiftMine } = useShift();
|
||||
const shiftReady = shiftOpen && shiftMine;
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: qk.activeSessions,
|
||||
queryFn: fetchActiveSessions,
|
||||
@@ -43,14 +32,13 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void
|
||||
refetchInterval: 15_000,
|
||||
});
|
||||
|
||||
const reopen = useMutation({
|
||||
mutationFn: (identity: string) => reopenBarrier(identity),
|
||||
onSettled: () => {
|
||||
void qc.invalidateQueries({ queryKey: qk.activeSessions });
|
||||
void qc.invalidateQueries({ queryKey: qk.events });
|
||||
},
|
||||
});
|
||||
const [reopenMsg, setReopenMsg] = useState<{ id: string; text: string; ok: boolean } | null>(null);
|
||||
// A 1-second clock so the within-grace countdown badge ticks live (the query only
|
||||
// refetches every 15s; the badge needs per-second resolution).
|
||||
const [nowMs, setNowMs] = useState(() => Date.now());
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setNowMs(Date.now()), 1000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
// Filters: free-text search + transient-vs-subscriber. (No status filter — the status
|
||||
// column was dropped; an unpaid transient is normal and a subscriber is marked ★.)
|
||||
@@ -77,20 +65,6 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void
|
||||
{ value: "subscription", label: t("booth.fKindSubscription") },
|
||||
];
|
||||
|
||||
async function handleReopen(s: ActiveSession) {
|
||||
setReopenMsg(null);
|
||||
try {
|
||||
const r = await reopen.mutateAsync(s.identity);
|
||||
setReopenMsg({
|
||||
id: s.identity,
|
||||
ok: r.opened,
|
||||
text: r.opened ? t("booth.barrierOpened") : r.reason ?? t("booth.openManually"),
|
||||
});
|
||||
} catch (e) {
|
||||
setReopenMsg({ id: s.identity, ok: false, text: (e as Error).message });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Panel
|
||||
title={t("booth.activeSessions")}
|
||||
@@ -117,11 +91,11 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void
|
||||
: t("booth.noMatch")}
|
||||
</div>
|
||||
) : (
|
||||
// A real table — aligned columns (who · plate · entry · elapsed · action). No
|
||||
// status column: an unpaid transient is the normal case, and a subscriber is
|
||||
// already marked with ★ + holder name. Overstay (a top-up is owed) keeps a row
|
||||
// tint so that fraud-relevant signal isn't lost. The whole row is clickable
|
||||
// (→ pay/exit modal); the trailing cell holds the audited Open-barrier action.
|
||||
// A real table — aligned columns (who · plate · entry · elapsed). No status
|
||||
// column: an unpaid transient is the normal case, and a subscriber is already
|
||||
// marked with ★ + holder name. Overstay (a top-up is owed) keeps a row tint so
|
||||
// that fraud-relevant signal isn't lost. The whole row is clickable (→ pay/exit
|
||||
// modal).
|
||||
<table className="w-full text-[0.75rem] tabular-nums">
|
||||
<thead className="sticky top-0 bg-term-panel-2 text-[0.6875rem] uppercase tracking-wider text-term-muted">
|
||||
<tr>
|
||||
@@ -129,31 +103,43 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void
|
||||
<th className="px-2 py-1.5 text-left font-semibold">{t("booth.colPlate")}</th>
|
||||
<th className="whitespace-nowrap px-2 py-1.5 text-left font-semibold">{t("booth.colEntry")}</th>
|
||||
<th className="whitespace-nowrap px-2 py-1.5 text-left font-semibold">{t("booth.colElapsed")}</th>
|
||||
<th className="px-2 py-1.5" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map((s) => {
|
||||
const msg = reopenMsg?.id === s.identity ? reopenMsg : null;
|
||||
// Paid-and-in-grace TRANSIENT only: an audited re-pulse for a car that paid
|
||||
// but the barrier didn't confirm. NOT overstay (owes a top-up → modal) and
|
||||
// NOT a subscription (assist-open lives in the modal). An unpaid transient
|
||||
// gets no button (no-unpaid-bypass). Mirrors reopenBarrier's server guard.
|
||||
const canReopen = s.paidAt && !s.overstay && !s.subscription;
|
||||
// EXITED-WITHIN-GRACE: a paid transient whose exit is recorded but the
|
||||
// barrier didn't confirm — it lingers here until grace runs out. Mark it
|
||||
// so the operator can tell it apart from a still-inside car (clicking it
|
||||
// opens the modal's manual barrier re-open, not a pay flow).
|
||||
const closedInGrace = !s.open && s.withinGrace && !s.subscription;
|
||||
// Live grace-remaining for the badge (M:SS). Null once it lapses — the
|
||||
// next refetch (≤15s) reclassifies the row (overstay / gone); until then
|
||||
// we show a generic label so the badge doesn't flicker empty.
|
||||
const graceLeft = closedInGrace ? formatCountdown(s.graceExpiresAt, nowMs) : null;
|
||||
return (
|
||||
<tr
|
||||
key={s.identity}
|
||||
onClick={() => onPick(s.identity)}
|
||||
className={`cursor-pointer border-t border-term-border/50 hover:bg-term-panel-2 ${
|
||||
s.overstay ? "bg-term-red/5" : ""
|
||||
s.overstay ? "bg-term-red/5" : closedInGrace ? "bg-term-amber/5 text-term-muted" : ""
|
||||
}`}
|
||||
title={t("booth.openPayExit")}
|
||||
title={closedInGrace ? t("booth.openReopenBarrier") : t("booth.openPayExit")}
|
||||
>
|
||||
<td className="px-2 py-1.5 text-term-text">
|
||||
{s.subscription ? (
|
||||
<span className="text-term-cyan">★ {s.subscriptionHolder ?? t("subs.unnamed")}</span>
|
||||
) : (
|
||||
s.identity
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{s.identity}
|
||||
{closedInGrace && (
|
||||
<span
|
||||
className="rounded border border-term-amber/60 px-1 text-[0.5625rem] uppercase tracking-wider tabular-nums text-term-amber"
|
||||
title={t("booth.exitedGraceTitle")}
|
||||
>
|
||||
{graceLeft ? t("booth.exitedGraceLeft", { time: graceLeft }) : t("booth.exitedGrace")}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-2 py-1.5">
|
||||
@@ -170,28 +156,8 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void
|
||||
{formatRelativeDateTime(s.enteredAt, t)}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-2 py-1.5 text-term-muted">
|
||||
{formatDuration(s.enteredAt, new Date().toISOString())}
|
||||
</td>
|
||||
<td className="px-2 py-1.5 text-right">
|
||||
{canReopen && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={reopen.isPending || !shiftReady}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation(); // don't also open the pay/exit modal
|
||||
void handleReopen(s);
|
||||
}}
|
||||
className="btn btn-pay btn-sm"
|
||||
title={shiftReady ? t("booth.openBarrierTitle") : t("shift.gateTitle")}
|
||||
>
|
||||
{t("booth.openBarrier")}
|
||||
</button>
|
||||
)}
|
||||
{msg && (
|
||||
<span className={`ml-2 text-[0.625rem] ${msg.ok ? "text-term-green" : "text-term-red"}`}>
|
||||
{msg.text}
|
||||
</span>
|
||||
)}
|
||||
{/* Freeze the elapsed at the recorded exit for a closed-in-grace row. */}
|
||||
{formatDuration(s.enteredAt, (closedInGrace ? s.exitedAt : null) ?? new Date().toISOString())}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
|
||||
+166
-44
@@ -19,6 +19,7 @@ import { rootRoute } from "./router.js";
|
||||
import { qk } from "./lib/query.js";
|
||||
import { useShift } from "./lib/use-shift.js";
|
||||
import { formatDuration, formatMoney, formatTime, formatRelativeDateTime } from "./lib/format.js";
|
||||
import { CARD_PAYMENTS_ENABLED } from "./lib/features.js";
|
||||
import { SnapshotStrip } from "./ui/SnapshotStrip.js";
|
||||
|
||||
// The booth pay/exit modal. Opened when the operator submits a ticket id. Shows the
|
||||
@@ -59,6 +60,9 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
||||
const { user } = rootRoute.useRouteContext();
|
||||
const canVoid = can(user, "event:void");
|
||||
const [voiding, setVoiding] = useState(false); // reason prompt revealed
|
||||
// Plate-swap: set when boothExit returns swap_suspected. Holds the detail for the warning
|
||||
// panel; the operator must consciously "Override & release". See plate-reconciliation.md.
|
||||
const [swap, setSwap] = useState<{ plate: string; otherIdentity: string; otherEnteredAt: string | null } | null>(null);
|
||||
const [voidReason, setVoidReason] = useState("");
|
||||
|
||||
const s: SessionLookup | undefined = session.data;
|
||||
@@ -73,6 +77,12 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
||||
// exit. A normal within-grace paid session is NOT payable (it's settled). See
|
||||
// booth-exit-flow.md / reopenBarrier server guard.
|
||||
const isOverstay = s?.overstay === true;
|
||||
// CLOSED-WITHIN-GRACE: a paid transient whose exit was already signed but the barrier
|
||||
// didn't confirm — it lingers in the active list until grace runs out (the "phantom
|
||||
// re-close" / damaged-ticket case). `s.open` is false, so it's not payable and not the
|
||||
// normal review flow; the only action is an audited manual re-pulse of the barrier.
|
||||
// (A grace-EXPIRED closed session falls through to the plain "already closed" notice.)
|
||||
const closedWithinGrace = !!(s?.found && !s.open && s.withinGrace && !isSubscription);
|
||||
// A subscription is normally prepaid (never charged). EXCEPTION: a time-window plan can
|
||||
// owe an out-of-window TARIFF-BRIDGE charge (early entry / late exit) — lookup() returns
|
||||
// it as s.amountMinor, and exit is GATED until it's paid. So a subscription IS payable
|
||||
@@ -171,7 +181,7 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePayAndExit() {
|
||||
async function handlePayAndExit(override = false) {
|
||||
if (!s) return;
|
||||
setError(null);
|
||||
try {
|
||||
@@ -179,7 +189,8 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
||||
// session is "already paid" but a new period accrued — we still charge (canPay
|
||||
// is true). A settled within-grace session is not payable (canPay false) and is
|
||||
// skipped. The server re-quotes authoritatively (overstay → from grace-expiry).
|
||||
if (canPay) {
|
||||
// On an OVERRIDE re-submit the payment already happened; don't double-charge.
|
||||
if (canPay && !override) {
|
||||
setPhase("paying");
|
||||
await paySession(identity, tender);
|
||||
}
|
||||
@@ -190,7 +201,14 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
||||
const r = await printVoucher(identity);
|
||||
setResult(t("pay.voucherPrinted", { printer: r.printedBy }));
|
||||
} else {
|
||||
const r = await boothExit(identity);
|
||||
const r = await boothExit(identity, override);
|
||||
// PLATE-SWAP suspected → don't exit; surface the warning + offer an override.
|
||||
if (!r.ok) {
|
||||
setSwap({ plate: r.plate, otherIdentity: r.otherIdentity, otherEnteredAt: r.otherEnteredAt });
|
||||
setPhase("review");
|
||||
return;
|
||||
}
|
||||
setSwap(null);
|
||||
// No voucher → auto-print a standalone payment receipt for transparency.
|
||||
// Best-effort: a printer fault must NOT block the exit that already happened;
|
||||
// the operator can reprint from the done screen.
|
||||
@@ -278,21 +296,54 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
||||
</div>
|
||||
)}
|
||||
|
||||
{s && s.found && !s.open && (
|
||||
<div className="rounded-term border border-term-amber px-3 py-2 text-term-amber">
|
||||
{t("pay.alreadyClosed", { time: formatTime(s.exitedAt) })}
|
||||
</div>
|
||||
{s && s.found && !s.open && !closedWithinGrace && (
|
||||
// A fully-closed session (exited, grace expired): no action to take, but the
|
||||
// operator may still need to REVIEW the evidence (entry/exit snapshots + plate)
|
||||
// — e.g. a dispute about a car that just left. Show the closed notice, the
|
||||
// figures, and the snapshot strip read-only. No tender / voucher / open here.
|
||||
<>
|
||||
<div className="rounded-term border border-term-amber px-3 py-2 text-term-amber">
|
||||
{t("pay.alreadyClosed", { time: formatTime(s.exitedAt) })}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-1 tabular-nums">
|
||||
<Row label={t("pay.entry")} value={formatRelativeDateTime(s.enteredAt, t)} />
|
||||
<Row label={t("pay.exit")} value={formatTime(s.exitedAt)} />
|
||||
<Row
|
||||
label={t("pay.duration")}
|
||||
value={
|
||||
s.enteredAt ? formatDuration(s.enteredAt, s.exitedAt ?? new Date().toISOString()) : "—"
|
||||
}
|
||||
/>
|
||||
{alreadyPaid && s.paidMinor != null && s.paidCurrency && (
|
||||
<Row label={t("pay.paidAmount")} value={formatMoney(s.paidMinor, s.paidCurrency)} valueClass="text-term-green" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SnapshotStrip identity={identity} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{s && s.found && s.open && (
|
||||
{s && s.found && (s.open || closedWithinGrace) && (
|
||||
<>
|
||||
{/* Session figures */}
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-1 tabular-nums">
|
||||
<Row label={t("pay.entry")} value={formatRelativeDateTime(s.enteredAt, t)} />
|
||||
<Row label={t("pay.now")} value={formatTime(new Date().toISOString())} />
|
||||
{/* Closed-within-grace shows the recorded EXIT; an open session shows now. */}
|
||||
<Row
|
||||
label={closedWithinGrace ? t("pay.exit") : t("pay.now")}
|
||||
value={closedWithinGrace ? formatTime(s.exitedAt) : formatTime(new Date().toISOString())}
|
||||
/>
|
||||
<Row
|
||||
label={t("pay.duration")}
|
||||
value={s.enteredAt ? formatDuration(s.enteredAt, new Date().toISOString()) : "—"}
|
||||
value={
|
||||
s.enteredAt
|
||||
? formatDuration(
|
||||
s.enteredAt,
|
||||
(closedWithinGrace ? s.exitedAt : null) ?? new Date().toISOString(),
|
||||
)
|
||||
: "—"
|
||||
}
|
||||
/>
|
||||
<Row
|
||||
label={t("pay.statusLabel")}
|
||||
@@ -301,18 +352,22 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
||||
? t("pay.subscription")
|
||||
: isOverstay
|
||||
? t("pay.overstay")
|
||||
: alreadyPaid
|
||||
? t("pay.paid")
|
||||
: t("pay.unpaid")
|
||||
: closedWithinGrace
|
||||
? t("pay.closedWithinGrace")
|
||||
: alreadyPaid
|
||||
? t("pay.paid")
|
||||
: t("pay.unpaid")
|
||||
}
|
||||
valueClass={
|
||||
isSubscription
|
||||
? "text-term-cyan"
|
||||
: isOverstay
|
||||
? "text-term-red"
|
||||
: alreadyPaid
|
||||
? "text-term-green"
|
||||
: "text-term-amber"
|
||||
: closedWithinGrace
|
||||
? "text-term-amber"
|
||||
: alreadyPaid
|
||||
? "text-term-green"
|
||||
: "text-term-amber"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
@@ -322,7 +377,16 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
||||
amount is the TOP-UP delta, not the whole stay. */}
|
||||
<div className="flex items-end justify-between rounded-term bg-term-panel-2 px-3 py-2">
|
||||
<span className="text-[0.6875rem] uppercase tracking-wider text-term-muted">
|
||||
{subWindowDue ? t("pay.windowCharge") : isSubscription ? t("pay.plan") : isOverstay ? t("pay.topUp") : t("pay.total")}
|
||||
{subWindowDue
|
||||
? t("pay.windowCharge")
|
||||
: isSubscription
|
||||
? t("pay.plan")
|
||||
: isOverstay
|
||||
? t("pay.topUp")
|
||||
: alreadyPaid && s.paidMinor != null
|
||||
? // Settled session — the figure is the sum collected, not a quote.
|
||||
t("pay.paidAmount")
|
||||
: t("pay.total")}
|
||||
</span>
|
||||
<span className="text-3xl font-bold text-term-cyan">
|
||||
{subWindowDue && s.amountMinor != null && s.currency
|
||||
@@ -331,9 +395,12 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
||||
? t("pay.prepaid")
|
||||
: s.amountMinor != null && s.currency
|
||||
? formatMoney(s.amountMinor, s.currency)
|
||||
: alreadyPaid
|
||||
? t("booth.badgePaid")
|
||||
: t("pay.noTariff")}
|
||||
: alreadyPaid && s.paidMinor != null && s.paidCurrency
|
||||
? // Settled (within-grace / closed): show the sum actually collected.
|
||||
formatMoney(s.paidMinor, s.paidCurrency)
|
||||
: alreadyPaid
|
||||
? t("booth.badgePaid")
|
||||
: t("pay.noTariff")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -361,12 +428,22 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Closed-within-grace: the exit is already paid + recorded; the barrier
|
||||
just didn't confirm. Explain that the only action is a manual re-pulse. */}
|
||||
{closedWithinGrace && (
|
||||
<div className="rounded-term border border-term-amber/40 bg-term-amber/5 px-3 py-2 text-[0.75rem] text-term-text">
|
||||
{t("pay.closedWithinGraceHint")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Snapshots */}
|
||||
<SnapshotStrip identity={identity} />
|
||||
|
||||
{/* Tender — shown for any payable case (transient, overstay, OR a
|
||||
subscriber window charge that's still unpaid). */}
|
||||
{phase !== "done" && canPay && !(subWindowDue && windowPaid) && (
|
||||
subscriber window charge that's still unpaid). Card is hidden until a
|
||||
P2PE POS terminal is on-site (CARD_PAYMENTS_ENABLED) — see
|
||||
lib/features.ts + wiki/concepts/card-payments.md. */}
|
||||
{phase !== "done" && canPay && !(subWindowDue && windowPaid) && CARD_PAYMENTS_ENABLED && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[0.6875rem] uppercase tracking-wider text-term-muted">{t("pay.tender")}</span>
|
||||
{(["cash", "card"] as const).map((tn) => (
|
||||
@@ -382,8 +459,9 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Voucher checkbox (transient only; a subscriber doesn't self-exit). */}
|
||||
{phase !== "done" && !isSubscription && (
|
||||
{/* Voucher checkbox (transient only; a subscriber doesn't self-exit). Not
|
||||
for a closed-within-grace session — its exit is already recorded. */}
|
||||
{phase !== "done" && !isSubscription && !closedWithinGrace && (
|
||||
<label className="flex items-center gap-2 text-[0.75rem]">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -426,6 +504,25 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* PLATE-SWAP warning: the exiting plate is already inside under another
|
||||
ticket. A prominent, deliberate hold — the operator must consciously
|
||||
override to release. See wiki/concepts/plate-reconciliation.md. */}
|
||||
{swap && (
|
||||
<div className="rounded-term border border-term-red bg-term-red/10 px-3 py-2">
|
||||
<div className="text-[0.75rem] font-semibold uppercase tracking-wider text-term-red">
|
||||
{t("pay.swapTitle")}
|
||||
</div>
|
||||
<div className="mt-1 text-[0.75rem] text-term-text">
|
||||
{t("pay.swapBody", {
|
||||
plate: swap.plate,
|
||||
other: swap.otherIdentity,
|
||||
when: swap.otherEnteredAt ? formatRelativeDateTime(swap.otherEnteredAt, t) : "—",
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-1 text-[0.6875rem] text-term-muted">{t("pay.swapHint")}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <div className="rounded-term border border-term-red px-3 py-2 text-term-red">{error}</div>}
|
||||
{result && (
|
||||
<div className="rounded-term border border-term-green px-3 py-2 text-term-green">{result}</div>
|
||||
@@ -464,7 +561,19 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</button>
|
||||
{isSubscription ? (
|
||||
{closedWithinGrace ? (
|
||||
// Paid + exited but the barrier didn't confirm — the only action is
|
||||
// an audited manual re-pulse (the server re-opens without signing a
|
||||
// second exit). No payment, no voucher; mirrors reopenBarrier's guard.
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpenBarrier}
|
||||
disabled={!shiftReady || phase === "finishing"}
|
||||
className="btn btn-pay btn-lg"
|
||||
>
|
||||
{phase === "finishing" ? t("pay.opening") : t("booth.openBarrier")}
|
||||
</button>
|
||||
) : isSubscription ? (
|
||||
subWindowDue && !windowPaid ? (
|
||||
// Step 1 — a window charge is owed: take payment first. The
|
||||
// barrier open is the explicit next step (revealed once paid).
|
||||
@@ -523,26 +632,39 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
||||
{t("pay.cancelTicket")}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePayAndExit}
|
||||
disabled={!shiftReady || phase === "paying" || phase === "finishing"}
|
||||
className="btn btn-go btn-lg"
|
||||
>
|
||||
{phase === "paying"
|
||||
? t("pay.takingPayment")
|
||||
: phase === "finishing"
|
||||
? voucher
|
||||
? t("pay.printingVoucher")
|
||||
: t("pay.opening")
|
||||
: alreadyPaid
|
||||
{swap ? (
|
||||
// Plate-swap held → the only forward action is a conscious
|
||||
// override (re-submit with override:true; payment already taken).
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handlePayAndExit(true)}
|
||||
disabled={!shiftReady || phase === "finishing"}
|
||||
className="btn btn-danger btn-lg"
|
||||
>
|
||||
{phase === "finishing" ? t("pay.opening") : t("pay.swapOverride")}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handlePayAndExit()}
|
||||
disabled={!shiftReady || phase === "paying" || phase === "finishing"}
|
||||
className="btn btn-go btn-lg"
|
||||
>
|
||||
{phase === "paying"
|
||||
? t("pay.takingPayment")
|
||||
: phase === "finishing"
|
||||
? voucher
|
||||
? t("pay.printVoucher")
|
||||
: t("pay.openBarrier")
|
||||
: voucher
|
||||
? t("pay.payAndVoucher")
|
||||
: t("pay.payAndOpen")}
|
||||
</button>
|
||||
? t("pay.printingVoucher")
|
||||
: t("pay.opening")
|
||||
: alreadyPaid
|
||||
? voucher
|
||||
? t("pay.printVoucher")
|
||||
: t("pay.openBarrier")
|
||||
: voucher
|
||||
? t("pay.payAndVoucher")
|
||||
: t("pay.payAndOpen")}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchEvents, fetchOccupancy, type LedgerEvent, type Occupancy } from "./api.js";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { can, fetchEvents, fetchOccupancy, issueEntryTicket, type LedgerEvent, type Occupancy } from "./api.js";
|
||||
import { rootRoute } from "./router.js";
|
||||
import { qk } from "./lib/query.js";
|
||||
import { useLiveStore } from "./lib/live-store.js";
|
||||
import { useShift } from "./lib/use-shift.js";
|
||||
@@ -116,16 +117,40 @@ function TicketInput({ onSubmit }: { onSubmit: (identity: string) => void }) {
|
||||
* - radar present + camera NOT busy → BLINK green↔red (~1 Hz): "detected, not yet confirmed"
|
||||
* - camera busy → SOLID red: a vehicle is confirmed at the lane vicinity
|
||||
* - otherwise → SOLID green: free
|
||||
* Advisory only; it gates nothing. The blink uses the `.lane-blink` keyframe (index.css),
|
||||
* whose children inherit the alternating colour via `currentColor`. */
|
||||
function BarrierLight({ label, busy, radar }: { label: string; busy: boolean; radar: boolean }) {
|
||||
* Advisory only; it gates nothing. On the ENTRY light, when the operator holds `session:create`
|
||||
* and BOTH presence conditions meet (radar present AND camera busy = a real car at the entry),
|
||||
* the light becomes a CLICKABLE issue-ticket control (broken physical button). Same presence
|
||||
* rule as the physical button; the server re-checks it. See operator-issued-entry.md. */
|
||||
function BarrierLight({
|
||||
label,
|
||||
busy,
|
||||
radar,
|
||||
onIssue,
|
||||
issuing,
|
||||
}: {
|
||||
label: string;
|
||||
busy: boolean;
|
||||
radar: boolean;
|
||||
/** When set (entry light + permission), clicking issues an entry ticket — only enabled
|
||||
* when both presence conditions meet (radar && busy). */
|
||||
onIssue?: () => void;
|
||||
issuing?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
// Blink only when the radar sees something the camera hasn't confirmed.
|
||||
const blinking = radar && !busy;
|
||||
const solid = busy ? "border-term-red bg-term-red/10 text-term-red" : "border-term-green bg-term-green/10 text-term-green";
|
||||
// The issue control is active only with a REAL car present (radar AND camera).
|
||||
const canIssue = !!onIssue && radar && busy && !issuing;
|
||||
const clickable = !!onIssue && radar && busy;
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center gap-2 rounded-term border px-3 py-2 ${blinking ? "lane-blink" : solid}`}
|
||||
title={label}
|
||||
className={`flex items-center gap-2 rounded-term border px-3 py-2 ${blinking ? "lane-blink" : solid} ${
|
||||
clickable ? "cursor-pointer hover:brightness-125" : ""
|
||||
}`}
|
||||
title={clickable ? t("booth.issueEntryTitle") : label}
|
||||
onClick={canIssue ? onIssue : undefined}
|
||||
role={clickable ? "button" : undefined}
|
||||
>
|
||||
{/* Barrier glyph: a post + an arm. `currentColor` follows the (possibly blinking) state. */}
|
||||
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
@@ -135,22 +160,58 @@ function BarrierLight({ label, busy, radar }: { label: string; busy: boolean; ra
|
||||
</svg>
|
||||
<div className="leading-tight">
|
||||
<div className="text-[0.625rem] uppercase tracking-wider text-term-muted">{label}</div>
|
||||
<div className="text-xs font-bold">{busy ? "●" : blinking ? "◐" : "○"}</div>
|
||||
<div className="text-xs font-bold">
|
||||
{issuing ? "…" : clickable ? t("booth.issueEntry") : busy ? "●" : blinking ? "◐" : "○"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The two lane barrier lights (entry / exit) fed by the live lane-status (camera busy/free)
|
||||
* and lane-presence (radar). */
|
||||
* and lane-presence (radar). The ENTRY light doubles as an operator issue-ticket control when
|
||||
* the physical button is broken (permission + presence gated). */
|
||||
function LaneIndicators() {
|
||||
const { t } = useTranslation();
|
||||
const lanes = useLiveStore((s) => s.lanes);
|
||||
const radar = useLiveStore((s) => s.radar);
|
||||
const { user } = rootRoute.useRouteContext();
|
||||
const { isOpen: shiftOpen, isMine } = useShift();
|
||||
const qc = useQueryClient();
|
||||
const canIssue = can(user, "session:create") && shiftOpen && isMine;
|
||||
const [msg, setMsg] = useState<{ text: string; ok: boolean } | null>(null);
|
||||
|
||||
const issue = useMutation({
|
||||
mutationFn: issueEntryTicket,
|
||||
onSuccess: (r) => {
|
||||
setMsg({ ok: true, text: t("booth.issueEntryOk", { ticket: r.ticketId }) });
|
||||
void qc.invalidateQueries({ queryKey: qk.events });
|
||||
void qc.invalidateQueries({ queryKey: qk.occupancy });
|
||||
setTimeout(() => setMsg(null), 4000);
|
||||
},
|
||||
onError: (e) => {
|
||||
setMsg({ ok: false, text: (e as Error).message });
|
||||
setTimeout(() => setMsg(null), 4000);
|
||||
},
|
||||
});
|
||||
|
||||
function onIssue() {
|
||||
if (window.confirm(t("booth.issueEntryConfirm"))) issue.mutate();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<BarrierLight label={t("booth.laneEntry")} busy={lanes?.entry ?? false} radar={radar?.entry ?? false} />
|
||||
<BarrierLight
|
||||
label={t("booth.laneEntry")}
|
||||
busy={lanes?.entry ?? false}
|
||||
radar={radar?.entry ?? false}
|
||||
onIssue={canIssue ? onIssue : undefined}
|
||||
issuing={issue.isPending}
|
||||
/>
|
||||
<BarrierLight label={t("booth.laneExit")} busy={lanes?.exit ?? false} radar={radar?.exit ?? false} />
|
||||
{msg && (
|
||||
<span className={`text-[0.6875rem] ${msg.ok ? "text-term-green" : "text-term-red"}`}>{msg.text}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
fetchDrawerMovements,
|
||||
recordDrawerMovement,
|
||||
reviewDrawerMovement,
|
||||
type DrawerMovement,
|
||||
type MovementStatus,
|
||||
} from "./api.js";
|
||||
import { formatMoney, formatRelativeDateTime } from "./lib/format.js";
|
||||
import { Panel } from "./ui/Panel.js";
|
||||
|
||||
// Drawer cash movements. Operators RECORD receipts (Mandat Arkëtimi / cash_in) and
|
||||
// disbursements (Mandat Pagese / cash_out) freely; admins REVIEW them after the fact
|
||||
// (authorize/deny — a flag, never a cash reversal). A denial is a judgment about the
|
||||
// operator, settled outside the app: the drawer balance is untouched. See
|
||||
// wiki/concepts/shift.md.
|
||||
|
||||
const money = (m: number, cur: string | null) => formatMoney(m, cur ?? "");
|
||||
|
||||
function StatusBadge({ status }: { status: MovementStatus }) {
|
||||
const { t } = useTranslation();
|
||||
const cls =
|
||||
status === "authorized"
|
||||
? "border-term-green/60 text-term-green"
|
||||
: status === "denied"
|
||||
? "border-term-red/60 text-term-red"
|
||||
: "border-term-amber/60 text-term-amber";
|
||||
return (
|
||||
<span className={`rounded border px-1 text-[0.625rem] uppercase tracking-wider ${cls}`}>
|
||||
{t(`drawer.status.${status}`)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function DrawerManager({ canCreate, canReview }: { canCreate: boolean; canReview: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
// Reviewers can filter the list (the pending queue); operators always see their own, all.
|
||||
const [statusFilter, setStatusFilter] = useState<MovementStatus | "">("");
|
||||
const q = useQuery({
|
||||
queryKey: ["drawer", "movements", canReview ? statusFilter : ""],
|
||||
queryFn: () => fetchDrawerMovements(canReview && statusFilter ? statusFilter : undefined),
|
||||
});
|
||||
const movements = q.data?.movements ?? [];
|
||||
const pendingCount = movements.filter((m) => m.status === "pending").length;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-3 p-3">
|
||||
{canCreate && <RecordPanel onDone={() => void qc.invalidateQueries({ queryKey: ["drawer"] })} />}
|
||||
|
||||
<Panel
|
||||
title={canReview ? t("drawer.allTitle") : t("drawer.myTitle")}
|
||||
right={
|
||||
canReview && pendingCount > 0 ? (
|
||||
<span className="rounded border border-term-amber/60 px-1.5 text-[0.625rem] uppercase tracking-wider text-term-amber">
|
||||
{t("drawer.pendingCount", { count: pendingCount })}
|
||||
</span>
|
||||
) : null
|
||||
}
|
||||
className="min-h-0 flex-1"
|
||||
>
|
||||
<div className="flex h-full flex-col">
|
||||
{canReview && (
|
||||
<div className="mb-2 flex items-center gap-1.5">
|
||||
{(["", "pending", "authorized", "denied"] as const).map((s) => (
|
||||
<button
|
||||
key={s || "all"}
|
||||
type="button"
|
||||
onClick={() => setStatusFilter(s)}
|
||||
className={statusFilter === s ? "btn btn-primary btn-sm" : "btn btn-sm"}
|
||||
>
|
||||
{s === "" ? t("drawer.filterAll") : t(`drawer.status.${s}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto pr-1">
|
||||
{q.isLoading ? (
|
||||
<div className="text-term-muted">{t("common.loading")}</div>
|
||||
) : movements.length === 0 ? (
|
||||
<div className="text-term-muted">{t("drawer.empty")}</div>
|
||||
) : (
|
||||
<table className="w-full text-[0.75rem] tabular-nums">
|
||||
<thead className="sticky top-0 bg-term-panel-2 text-[0.6875rem] uppercase tracking-wider text-term-muted">
|
||||
<tr>
|
||||
<th className="px-2 py-1.5 text-left font-semibold">{t("drawer.colWhen")}</th>
|
||||
<th className="px-2 py-1.5 text-left font-semibold">{t("drawer.colType")}</th>
|
||||
<th className="px-2 py-1.5 text-right font-semibold">{t("drawer.colAmount")}</th>
|
||||
<th className="px-2 py-1.5 text-left font-semibold">{t("drawer.colReason")}</th>
|
||||
{canReview && <th className="px-2 py-1.5 text-left font-semibold">{t("drawer.colOperator")}</th>}
|
||||
<th className="px-2 py-1.5 text-left font-semibold">{t("drawer.colStatus")}</th>
|
||||
{canReview && <th className="px-2 py-1.5" />}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{movements.map((m) => (
|
||||
<MovementRow key={m.id} m={m} canReview={canReview} onReviewed={() => void qc.invalidateQueries({ queryKey: ["drawer"] })} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RecordPanel({ onDone }: { onDone: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const [amount, setAmount] = useState("");
|
||||
const [reason, setReason] = useState("");
|
||||
const [msg, setMsg] = useState<{ text: string; ok: boolean } | null>(null);
|
||||
const record = useMutation({
|
||||
mutationFn: (type: "cash_in" | "cash_out") =>
|
||||
recordDrawerMovement({ type, amountMinor: Math.round(Number(amount) * 100), reason: reason.trim() }),
|
||||
onSuccess: (r) => {
|
||||
setMsg({ ok: true, text: t("drawer.recorded", { no: r.voucherNo, amount: money(r.balanceMinor, null) }) });
|
||||
setAmount("");
|
||||
setReason("");
|
||||
onDone();
|
||||
},
|
||||
onError: (e) => setMsg({ ok: false, text: (e as Error).message }),
|
||||
});
|
||||
|
||||
function submit(type: "cash_in" | "cash_out") {
|
||||
setMsg(null);
|
||||
const major = Number(amount);
|
||||
if (!Number.isFinite(major) || major <= 0) {
|
||||
setMsg({ ok: false, text: t("drawer.enterPositive") });
|
||||
return;
|
||||
}
|
||||
record.mutate(type);
|
||||
}
|
||||
|
||||
return (
|
||||
<Panel title={t("drawer.recordTitle")}>
|
||||
<div className="flex flex-col gap-2 text-[0.8125rem]">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
className="input w-28"
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
placeholder={t("drawer.amount")}
|
||||
inputMode="decimal"
|
||||
/>
|
||||
<input
|
||||
className="input min-w-40 flex-1"
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
placeholder={t("drawer.reasonPlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-[0.6875rem] text-term-muted">{t("drawer.recordHint")}</div>
|
||||
{msg && (
|
||||
<div className={`text-[0.75rem] ${msg.ok ? "text-term-green" : "text-term-red"}`}>{msg.text}</div>
|
||||
)}
|
||||
<div className="flex justify-end gap-2">
|
||||
<button type="button" className="btn btn-go btn-sm" disabled={record.isPending} onClick={() => submit("cash_in")}>
|
||||
{t("drawer.mandatArketimi")}
|
||||
</button>
|
||||
<button type="button" className="btn btn-danger btn-sm" disabled={record.isPending} onClick={() => submit("cash_out")}>
|
||||
{t("drawer.mandatPagese")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
function MovementRow({ m, canReview, onReviewed }: { m: DrawerMovement; canReview: boolean; onReviewed: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const [note, setNote] = useState("");
|
||||
const [noteOpen, setNoteOpen] = useState(false);
|
||||
const review = useMutation({
|
||||
mutationFn: (decision: "authorize" | "deny") =>
|
||||
reviewDrawerMovement({ refId: m.id, decision, note: note.trim() || undefined }),
|
||||
onSuccess: onReviewed,
|
||||
});
|
||||
// Direction sign for display: cash_in is +, cash_out is −.
|
||||
const signed = m.type === "cash_in" ? m.amountMinor : -m.amountMinor;
|
||||
return (
|
||||
<tr className="border-t border-term-border/50 align-top">
|
||||
<td className="whitespace-nowrap px-2 py-1.5 text-term-muted">{formatRelativeDateTime(m.at, t)}</td>
|
||||
<td className="px-2 py-1.5">
|
||||
<span className={m.type === "cash_in" ? "text-term-green" : "text-term-red"}>
|
||||
{m.type === "cash_in" ? t("drawer.mandatArketimi") : t("drawer.mandatPagese")}
|
||||
</span>
|
||||
{m.voucherNo && <span className="ml-1 text-[0.625rem] text-term-muted">{m.voucherNo}</span>}
|
||||
</td>
|
||||
<td className={`whitespace-nowrap px-2 py-1.5 text-right ${signed < 0 ? "text-term-red" : "text-term-green"}`}>
|
||||
{money(signed, m.currency)}
|
||||
</td>
|
||||
<td className="px-2 py-1.5 text-term-text">{m.reason || "—"}</td>
|
||||
{canReview && <td className="px-2 py-1.5 text-term-muted">{m.operator}</td>}
|
||||
<td className="px-2 py-1.5">
|
||||
<StatusBadge status={m.status} />
|
||||
{m.status !== "pending" && m.reviewedBy && (
|
||||
<div className="mt-0.5 text-[0.5625rem] text-term-muted">
|
||||
{m.reviewedBy}
|
||||
{m.reviewNote ? ` · ${m.reviewNote}` : ""}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
{canReview && (
|
||||
<td className="px-2 py-1.5 text-right">
|
||||
{m.status === "pending" ? (
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<div className="flex gap-1">
|
||||
<button type="button" className="btn btn-go btn-sm" disabled={review.isPending} onClick={() => review.mutate("authorize")}>
|
||||
{t("drawer.authorize")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-danger btn-sm"
|
||||
disabled={review.isPending}
|
||||
onClick={() => (noteOpen ? review.mutate("deny") : setNoteOpen(true))}
|
||||
>
|
||||
{t("drawer.deny")}
|
||||
</button>
|
||||
</div>
|
||||
{noteOpen && (
|
||||
<input
|
||||
className="input w-44 text-[0.6875rem]"
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
placeholder={t("drawer.denyNotePlaceholder")}
|
||||
/>
|
||||
)}
|
||||
{review.isError && <span className="text-[0.625rem] text-term-red">{(review.error as Error).message}</span>}
|
||||
</div>
|
||||
) : null}
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
@@ -8,12 +8,12 @@ import {
|
||||
fetchShiftReport,
|
||||
fetchShifts,
|
||||
openShift,
|
||||
recordCashVoucher,
|
||||
type ShiftReport,
|
||||
type ShiftSummary,
|
||||
type SessionUser,
|
||||
} from "./api.js";
|
||||
import { formatMoney, formatDuration, formatRelativeDateTime } from "./lib/format.js";
|
||||
import { CARD_PAYMENTS_ENABLED } from "./lib/features.js";
|
||||
import { Modal } from "./ui/Modal.js";
|
||||
import { EventDetailModal, EventRow } from "./ui/event-detail.js";
|
||||
import type { LedgerEvent } from "@parking/shared";
|
||||
@@ -88,7 +88,7 @@ function useCurrentShift(): { current: (ShiftSummary & { open: true }) | null; i
|
||||
};
|
||||
}
|
||||
|
||||
export function ShiftsHistory({ user, canManage = false, canVoucher = false }: { user: SessionUser | null; canManage?: boolean; canVoucher?: boolean }) {
|
||||
export function ShiftsHistory({ user, canManage = false }: { user: SessionUser | null; canManage?: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const [preset, setPreset] = useState<Preset>("week");
|
||||
const [operator, setOperator] = useState("");
|
||||
@@ -198,7 +198,6 @@ export function ShiftsHistory({ user, canManage = false, canVoucher = false }: {
|
||||
isMine={isMine}
|
||||
showOperator={isAdmin}
|
||||
canManage={canManage}
|
||||
canVoucher={canVoucher}
|
||||
onChanged={refreshAll}
|
||||
/>
|
||||
) : (
|
||||
@@ -257,7 +256,7 @@ function ShiftCard({ s, showOperator, open, selected, onClick }: { s: ShiftSumma
|
||||
<div className="mt-1 flex flex-wrap gap-x-3 tabular-nums">
|
||||
<span className="text-term-muted">{t("shifts.payments")} {s.paymentCount}</span>
|
||||
<span className="text-term-green">{money(s.cashTotalMinor, cur)}</span>
|
||||
<span className="text-term-cyan">{money(s.cardTotalMinor, cur)}</span>
|
||||
{CARD_PAYMENTS_ENABLED && <span className="text-term-cyan">{money(s.cardTotalMinor, cur)}</span>}
|
||||
<span className="ml-auto font-semibold text-term-text" title={t("shifts.expectedDrawer")}>{money(s.expectedDrawerMinor, cur)}</span>
|
||||
</div>
|
||||
</button>
|
||||
@@ -270,7 +269,6 @@ function ShiftActivityLog({
|
||||
isMine,
|
||||
showOperator,
|
||||
canManage,
|
||||
canVoucher,
|
||||
onChanged,
|
||||
}: {
|
||||
shift: ShiftSummary;
|
||||
@@ -278,11 +276,10 @@ function ShiftActivityLog({
|
||||
isMine: boolean;
|
||||
showOperator: boolean;
|
||||
canManage: boolean;
|
||||
canVoucher: boolean;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [modal, setModal] = useState<null | "end" | "voucher" | "takings">(null);
|
||||
const [modal, setModal] = useState<null | "end" | "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);
|
||||
@@ -311,7 +308,6 @@ function ShiftActivityLog({
|
||||
{isCurrent && isMine && canManage && (
|
||||
<span className="flex flex-wrap gap-1.5">
|
||||
<button type="button" className="btn btn-sm" onClick={() => setModal("takings")}>{t("shift.viewTakings")}</button>
|
||||
{canVoucher && <button type="button" className="btn btn-sm" onClick={() => setModal("voucher")}>{t("shift.drawerVoucher")}</button>}
|
||||
<button type="button" className="btn btn-sm btn-danger" onClick={() => setModal("end")}>{t("shift.endShift")}</button>
|
||||
</span>
|
||||
)}
|
||||
@@ -325,7 +321,7 @@ function ShiftActivityLog({
|
||||
<Figure label={t("shifts.cashTaken")} value={money(shift.cashTotalMinor, cur)} />
|
||||
<Figure label={t("shifts.cashAdded")} value={money(shift.cashAddedMinor, cur)} />
|
||||
<Figure label={t("shifts.cashRemoved")} value={money(shift.cashRemovedMinor, cur)} />
|
||||
<Figure label={t("shifts.card")} value={money(shift.cardTotalMinor, cur)} />
|
||||
{CARD_PAYMENTS_ENABLED && <Figure label={t("shifts.card")} value={money(shift.cardTotalMinor, cur)} />}
|
||||
<Figure label={t("shifts.expectedDrawer")} value={money(shift.expectedDrawerMinor, cur)} bold />
|
||||
</div>
|
||||
</div>
|
||||
@@ -340,7 +336,6 @@ function ShiftActivityLog({
|
||||
|
||||
{detailEvent && <EventDetailModal e={detailEvent} onClose={() => setDetailEvent(null)} />}
|
||||
{modal === "end" && <EndShiftModal shift={shift} onClose={() => setModal(null)} onDone={onChanged} />}
|
||||
{modal === "voucher" && <VoucherModal currency={cur} onClose={() => setModal(null)} onDone={onChanged} />}
|
||||
{modal === "takings" && <TakingsModal onClose={() => setModal(null)} />}
|
||||
</div>
|
||||
);
|
||||
@@ -385,7 +380,7 @@ function EndShiftModal({ shift, onClose, onDone }: { shift: ShiftSummary; onClos
|
||||
</div>
|
||||
<div className="mt-1 grid grid-cols-2 gap-x-6 gap-y-0.5 border-t border-term-border pt-1">
|
||||
<Figure label={t("shift.cash")} value={money(report.cashTotalMinor, report.currency)} />
|
||||
<Figure label={t("shift.card")} value={money(report.cardTotalMinor, report.currency)} />
|
||||
{CARD_PAYMENTS_ENABLED && <Figure label={t("shift.card")} value={money(report.cardTotalMinor, report.currency)} />}
|
||||
<Figure label={t("shift.openingFloat")} value={money(report.openingFloatMinor, report.currency)} />
|
||||
<Figure label={t("shift.cashAdded")} value={money(report.cashAddedMinor, report.currency)} />
|
||||
<Figure label={t("shift.cashRemoved")} value={money(report.cashRemovedMinor, report.currency)} />
|
||||
@@ -411,7 +406,7 @@ function EndShiftModal({ shift, onClose, onDone }: { shift: ShiftSummary; onClos
|
||||
</div>
|
||||
<div className="mt-2 grid grid-cols-2 gap-x-6 gap-y-0.5 border-t border-term-border pt-2">
|
||||
<Figure label={t("shift.cash")} value={money(shift.cashTotalMinor, cur)} />
|
||||
<Figure label={t("shift.card")} value={money(shift.cardTotalMinor, cur)} />
|
||||
{CARD_PAYMENTS_ENABLED && <Figure label={t("shift.card")} value={money(shift.cardTotalMinor, cur)} />}
|
||||
{/* Drawer math made explicit: opening cash + cash taken = expected drawer. */}
|
||||
<Figure label={t("shift.openingFloat")} value={money(shift.openingFloatMinor, cur)} />
|
||||
<span />
|
||||
@@ -430,54 +425,6 @@ function EndShiftModal({ shift, onClose, onDone }: { shift: ShiftSummary; onClos
|
||||
);
|
||||
}
|
||||
|
||||
function VoucherModal({ currency, onClose, onDone }: { currency: string | null; onClose: () => void; onDone: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const [amount, setAmount] = useState("");
|
||||
const [reason, setReason] = useState("");
|
||||
const [authName, setAuthName] = useState("");
|
||||
const [authPassword, setAuthPassword] = useState("");
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
|
||||
async function submit(type: "cash_in" | "cash_out") {
|
||||
setMsg(null);
|
||||
const major = Number(amount);
|
||||
if (!Number.isFinite(major) || major <= 0) return setMsg(t("shift.enterPositive"));
|
||||
if (!authName.trim() || !authPassword) return setMsg(t("shift.authRequired"));
|
||||
try {
|
||||
const r = await recordCashVoucher({ type, amountMinor: Math.round(major * 100), reason: reason.trim(), authorizedBy: authName.trim(), authorizerPassword: authPassword });
|
||||
setMsg(t("shift.voucherRecorded", { no: r.voucherNo, amount: money(r.balanceMinor, currency) }));
|
||||
setAmount("");
|
||||
setReason("");
|
||||
setAuthPassword("");
|
||||
onDone();
|
||||
} catch (e) {
|
||||
setMsg((e as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal open onClose={onClose} title={t("shift.drawerVoucher")} width="max-w-md">
|
||||
<div className="flex flex-col gap-2 text-[0.8125rem]">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<input className="input w-28" value={amount} onChange={(e) => setAmount(e.target.value)} placeholder={t("shift.amount")} inputMode="decimal" />
|
||||
<input className="input min-w-36 flex-1" value={reason} onChange={(e) => setReason(e.target.value)} placeholder={t("shift.reasonPlaceholder")} />
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<input className="input w-36" value={authName} onChange={(e) => setAuthName(e.target.value)} placeholder={t("shift.authName")} autoComplete="off" />
|
||||
<input className="input w-36" type="password" value={authPassword} onChange={(e) => setAuthPassword(e.target.value)} placeholder={t("shift.authPassword")} autoComplete="off" />
|
||||
</div>
|
||||
<div className="text-[0.6875rem] text-term-muted">{t("shift.voucherHint")}</div>
|
||||
{msg && <div className="text-[0.75rem] text-term-muted">{msg}</div>}
|
||||
<div className="mt-1 flex justify-end gap-2">
|
||||
<button type="button" className="btn btn-sm" onClick={onClose}>{t("common.close")}</button>
|
||||
<button type="button" className="btn btn-go btn-sm" onClick={() => submit("cash_in")}>{t("shift.mandatArketimi")}</button>
|
||||
<button type="button" className="btn btn-danger btn-sm" onClick={() => submit("cash_out")}>{t("shift.mandatPagese")}</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function TakingsModal({ onClose }: { onClose: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const q = useQuery({ queryKey: ["shift", "xreport", "modal"], queryFn: fetchShiftReport });
|
||||
@@ -500,7 +447,7 @@ function TakingsModal({ onClose }: { onClose: () => void }) {
|
||||
</div>
|
||||
<div className="mt-1 grid grid-cols-2 gap-x-6 gap-y-0.5 border-t border-term-border pt-1">
|
||||
<Figure label={t("shift.cash")} value={money(x.cashTotalMinor, x.currency)} />
|
||||
<Figure label={t("shift.card")} value={money(x.cardTotalMinor, x.currency)} />
|
||||
{CARD_PAYMENTS_ENABLED && <Figure label={t("shift.card")} value={money(x.cardTotalMinor, x.currency)} />}
|
||||
<Figure label={t("shift.openingFloat")} value={money(x.openingFloatMinor, x.currency)} />
|
||||
<Figure label={t("shift.cashAdded")} value={money(x.cashAddedMinor, x.currency)} />
|
||||
<Figure label={t("shift.cashRemoved")} value={money(x.cashRemovedMinor, x.currency)} />
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
type SubscriptionPlan,
|
||||
type SubscriptionQuote,
|
||||
} from "./api.js";
|
||||
import { CARD_PAYMENTS_ENABLED } from "./lib/features.js";
|
||||
import { Modal } from "./ui/Modal.js";
|
||||
|
||||
// Subscription admin. Create/edit/revoke/delete subscriptions + their credentials
|
||||
@@ -537,7 +538,11 @@ export function SubscriptionManager({ user }: { user: SessionUser | null }) {
|
||||
)}
|
||||
{/* Tender — only relevant when selling a plan (a SALE). The sale appends a
|
||||
signed payment so the money shows in the feed/drawer/Z-report. */}
|
||||
{form.planId.trim() !== "" && editing === "new" && (
|
||||
{/* Tender picker — only meaningful when there's a choice. Card is hidden until a
|
||||
P2PE POS terminal is on-site (CARD_PAYMENTS_ENABLED); with cash-only there's
|
||||
nothing to pick, so the whole row is suppressed (form.tender stays "cash").
|
||||
See lib/features.ts + wiki/concepts/card-payments.md. */}
|
||||
{form.planId.trim() !== "" && editing === "new" && CARD_PAYMENTS_ENABLED && (
|
||||
<>
|
||||
<label className="label">{t("subs.tender")}</label>
|
||||
<span className="flex items-center gap-3">
|
||||
|
||||
+93
-17
@@ -30,7 +30,7 @@ export async function apiFetch<T>(path: string, init: RequestInit = {}): Promise
|
||||
}
|
||||
const res = await fetch(apiUrl(path), { ...init, headers, credentials: "include" });
|
||||
if (!res.ok) {
|
||||
const msg = (await res.json().catch(() => ({}))) as { error?: string; problems?: string[] };
|
||||
const msg = (await res.json().catch(() => ({}))) as { error?: string; problems?: string[]; [k: string]: unknown };
|
||||
const error = msg.error ?? `${path}: ${res.status}`;
|
||||
// Ship the failed request to the backend log store (best-effort, loop-safe — the
|
||||
// logger itself never logs the /api/logs call). 401s are normal pre-login churn,
|
||||
@@ -38,7 +38,7 @@ export async function apiFetch<T>(path: string, init: RequestInit = {}): Promise
|
||||
if (res.status !== 401) {
|
||||
logFailedRequest({ path, method, status: res.status, error });
|
||||
}
|
||||
throw new ApiError(error, res.status, msg.problems);
|
||||
throw new ApiError(error, res.status, msg.problems, msg);
|
||||
}
|
||||
if (res.status === 204) return undefined as T;
|
||||
return res.json() as Promise<T>;
|
||||
@@ -50,6 +50,9 @@ export class ApiError extends Error {
|
||||
readonly status: number,
|
||||
/** Field-level problems from a validation error (e.g. tariff publish), if any. */
|
||||
readonly problems?: string[],
|
||||
/** The full parsed error body, for callers that need extra fields (e.g. a booth
|
||||
* exit's plate-swap detail: { status, plate, otherIdentity, otherEnteredAt }). */
|
||||
readonly body?: Record<string, unknown>,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
@@ -1019,15 +1022,38 @@ export async function fetchShiftReport(): Promise<XReport | null> {
|
||||
return (await apiFetch<XReport | undefined>("/api/shift/report")) ?? null;
|
||||
}
|
||||
|
||||
/** A drawer cash voucher: Mandat Arkëtimi (cash_in / pay-IN) or Mandat Pagese
|
||||
* (cash_out / pay-OUT). Direction is the TYPE, amountMinor a positive magnitude.
|
||||
* Operator-raised, admin-authorized (authorizedBy + their password). */
|
||||
export function recordCashVoucher(args: {
|
||||
// --- Drawer cash movements (operator records, admin reviews) ---------------------
|
||||
// Redesigned 2026-07-01: an operator RECORDS a receipt/disbursement freely; an admin
|
||||
// REVIEWS it after the fact (authorize/deny — a flag, never a cash reversal). See
|
||||
// wiki/concepts/shift.md.
|
||||
|
||||
export type MovementStatus = "pending" | "authorized" | "denied";
|
||||
|
||||
/** A drawer movement with its admin-review status. */
|
||||
export interface DrawerMovement {
|
||||
id: string;
|
||||
type: "cash_in" | "cash_out";
|
||||
/** Positive magnitude; direction is the type. */
|
||||
amountMinor: number;
|
||||
currency: string | null;
|
||||
reason: string | null;
|
||||
operator: string;
|
||||
voucherNo: string | null;
|
||||
at: string;
|
||||
status: MovementStatus;
|
||||
reviewedBy: string | null;
|
||||
reviewNote: string | null;
|
||||
reviewedAt: string | null;
|
||||
}
|
||||
|
||||
/** Operator RECORDS a drawer movement — cash_in (Mandat Arkëtimi / pay-IN) or cash_out
|
||||
* (Mandat Pagese / pay-OUT). Direction is the TYPE; amountMinor a positive magnitude.
|
||||
* No admin sign-off at creation — it's reviewed afterward. */
|
||||
export function recordDrawerMovement(args: {
|
||||
type: "cash_in" | "cash_out";
|
||||
amountMinor: number;
|
||||
reason: string;
|
||||
authorizedBy: string;
|
||||
authorizerPassword: string;
|
||||
currency?: string;
|
||||
}): Promise<{
|
||||
type: "cash_in" | "cash_out";
|
||||
amountMinor: number;
|
||||
@@ -1035,10 +1061,26 @@ export function recordCashVoucher(args: {
|
||||
balanceMinor: number;
|
||||
printed: boolean;
|
||||
}> {
|
||||
return apiFetch("/api/cash-voucher", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(args),
|
||||
});
|
||||
return apiFetch("/api/drawer/movement", { method: "POST", body: JSON.stringify(args) });
|
||||
}
|
||||
|
||||
/** List drawer movements + review status. Operators get their OWN; a reviewer gets all
|
||||
* and may filter by status (the pending review queue). */
|
||||
export function fetchDrawerMovements(status?: MovementStatus): Promise<{
|
||||
movements: DrawerMovement[];
|
||||
scope: "all" | "self";
|
||||
}> {
|
||||
const qs = status ? `?status=${encodeURIComponent(status)}` : "";
|
||||
return apiFetch(`/api/drawer/movements${qs}`);
|
||||
}
|
||||
|
||||
/** Admin AUTHORIZES or DENIES a recorded movement (a flag — never a cash reversal). */
|
||||
export function reviewDrawerMovement(args: {
|
||||
refId: string;
|
||||
decision: "authorize" | "deny";
|
||||
note?: string;
|
||||
}): Promise<{ refId: string; decision: "authorize" | "deny"; reviewedBy: string; at: string }> {
|
||||
return apiFetch("/api/drawer/review", { method: "POST", body: JSON.stringify(args) });
|
||||
}
|
||||
|
||||
/** A completed shift (reconstructed from its signed Z-report). */
|
||||
@@ -1168,6 +1210,9 @@ export interface SessionLookup {
|
||||
paidAt: string | null;
|
||||
amountMinor: number | null;
|
||||
currency: string | null;
|
||||
/** Amount actually PAID (sum of payment events), independent of what's owed now. */
|
||||
paidMinor: number | null;
|
||||
paidCurrency: string | null;
|
||||
withinGrace: boolean;
|
||||
graceExpiresAt: string | null;
|
||||
/** OVERSTAY: paid transient, walk-back grace expired, no exit — a new period began;
|
||||
@@ -1242,12 +1287,43 @@ export function voidTicket(identity: string, reason: string): Promise<{ ok: bool
|
||||
}
|
||||
|
||||
/** Booth-driven exit result. `opened:false` = exit recorded but barrier didn't
|
||||
* open (payment stands; operator opens manually). */
|
||||
export type BoothExitResult = { ok: true; opened: boolean; reason?: string };
|
||||
* open (payment stands; operator opens manually). `swapSuspected` = the exiting car's
|
||||
* plate is already inside under a DIFFERENT ticket (possible ticket-swap); the operator
|
||||
* must review and re-call with override:true to release. See plate-reconciliation.md. */
|
||||
export type BoothExitResult =
|
||||
| { ok: true; opened: boolean; reason?: string }
|
||||
| { ok: false; swapSuspected: true; reason: string; plate: string; otherIdentity: string; otherEnteredAt: string | null };
|
||||
|
||||
/** Validate + open the barrier for a session from the booth (when near the exit). */
|
||||
export function boothExit(identity: string): Promise<BoothExitResult> {
|
||||
return apiFetch("/api/exit", { method: "POST", body: JSON.stringify({ identity }) });
|
||||
/** Validate + open the barrier for a session from the booth (when near the exit).
|
||||
* Pass override:true to consciously release a suspected plate-swap exit. */
|
||||
export async function boothExit(identity: string, override = false): Promise<BoothExitResult> {
|
||||
try {
|
||||
return await apiFetch<{ ok: true; opened: boolean; reason?: string }>("/api/exit", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ identity, ...(override ? { override: true } : {}) }),
|
||||
});
|
||||
} catch (e) {
|
||||
// A suspected plate-swap comes back 409 with status:"swap_suspected" + detail — surface
|
||||
// it as a structured result (not a thrown error) so the modal can warn + offer override.
|
||||
if (e instanceof ApiError && e.body?.status === "swap_suspected") {
|
||||
const b = e.body;
|
||||
return {
|
||||
ok: false,
|
||||
swapSuspected: true,
|
||||
reason: String(b.error ?? ""),
|
||||
plate: String(b.plate ?? ""),
|
||||
otherIdentity: String(b.otherIdentity ?? ""),
|
||||
otherEnteredAt: (b.otherEnteredAt as string | null) ?? null,
|
||||
};
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/** Operator issues an entry ticket when the physical button is broken. A FLAGGED mint,
|
||||
* server-gated on real vehicle presence (radar + camera). Returns the new ticket id. */
|
||||
export function issueEntryTicket(): Promise<{ ok: true; ticketId: string; opened: boolean; overCapacity: boolean }> {
|
||||
return apiFetch("/api/entry/issue", { method: "POST", body: JSON.stringify({}) });
|
||||
}
|
||||
|
||||
/** Print an exit voucher (paid ticket id reprinted as a barcode) + payment detail,
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
// Client-side feature flags. Small, hand-flipped switches for capabilities the app
|
||||
// SUPPORTS in code but that aren't provisioned on-site yet — so the UI doesn't offer an
|
||||
// action the site can't fulfil.
|
||||
|
||||
/**
|
||||
* CARD payments. The app models a `card` tender end-to-end (server, shift accounting,
|
||||
* reports), but a card sale needs a bank-certified **P2PE POS terminal** on-site, and we
|
||||
* have NONE yet (2026-07-01). Until one is procured + configured, the booth/subscription
|
||||
* tender pickers show CASH only — offering "Card" would let an operator record a card
|
||||
* payment that never actually cleared a terminal, corrupting the till reconciliation.
|
||||
*
|
||||
* Flip to `true` (and add the POS device config) once a terminal is on-site. Nothing about
|
||||
* the `Tender` type or historical `card` events changes — this only gates the UI *offer*.
|
||||
* See wiki/concepts/card-payments.md (future POS device requirements).
|
||||
*/
|
||||
export const CARD_PAYMENTS_ENABLED = false;
|
||||
@@ -22,6 +22,22 @@ export function formatDuration(fromIso: string, toIso: string): string {
|
||||
return h > 0 ? `${h}h ${m}m` : `${m}m`;
|
||||
}
|
||||
|
||||
/** Remaining time until `untilIso`, as a live countdown: "M:SS" (or "H:MM:SS" past an
|
||||
* hour). Returns null once expired (or for a bad/empty input) so callers can drop the
|
||||
* badge. Pass `nowMs` (a ticking clock) to make it update each second. */
|
||||
export function formatCountdown(untilIso: string | null, nowMs: number = Date.now()): string | null {
|
||||
if (!untilIso) return null;
|
||||
const ms = Date.parse(untilIso) - nowMs;
|
||||
if (!Number.isFinite(ms) || ms <= 0) return null;
|
||||
const total = Math.ceil(ms / 1000);
|
||||
const h = Math.floor(total / 3600);
|
||||
const m = Math.floor((total % 3600) / 60);
|
||||
const s = total % 60;
|
||||
const ss = String(s).padStart(2, "0");
|
||||
if (h > 0) return `${h}:${String(m).padStart(2, "0")}:${ss}`;
|
||||
return `${m}:${ss}`;
|
||||
}
|
||||
|
||||
/** 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 "—";
|
||||
|
||||
@@ -58,12 +58,42 @@ export const en: Catalog = {
|
||||
users: "Users",
|
||||
roles: "Roles",
|
||||
shifts: "Shifts",
|
||||
drawer: "Drawer",
|
||||
reports: "Reports",
|
||||
recycleBin: "Recycle bin",
|
||||
logs: "Logs",
|
||||
backup: "Backup",
|
||||
profile: "Profile",
|
||||
},
|
||||
drawer: {
|
||||
recordTitle: "Record a cash movement",
|
||||
amount: "amount",
|
||||
reasonPlaceholder: "reason (e.g. supplier payment, bank drop)",
|
||||
recordHint: "Recorded to the drawer immediately. An admin reviews it afterward.",
|
||||
mandatArketimi: "Receipt (in) +",
|
||||
mandatPagese: "Disbursement (out) −",
|
||||
enterPositive: "Enter a positive amount.",
|
||||
recorded: "{{no}} recorded. Drawer now {{amount}}.",
|
||||
myTitle: "My cash movements",
|
||||
allTitle: "Cash movements",
|
||||
pendingCount: "{{count}} pending",
|
||||
filterAll: "All",
|
||||
empty: "No cash movements yet.",
|
||||
colWhen: "When",
|
||||
colType: "Type",
|
||||
colAmount: "Amount",
|
||||
colReason: "Reason",
|
||||
colOperator: "Operator",
|
||||
colStatus: "Status",
|
||||
status: {
|
||||
pending: "pending",
|
||||
authorized: "authorized",
|
||||
denied: "denied",
|
||||
},
|
||||
authorize: "Authorize",
|
||||
deny: "Deny",
|
||||
denyNotePlaceholder: "reason for denial (optional)",
|
||||
},
|
||||
profile: {
|
||||
title: "My profile",
|
||||
accountSection: "Account",
|
||||
@@ -160,6 +190,14 @@ export const en: Catalog = {
|
||||
fEvtVoid: "Void",
|
||||
fEvtAnomaly: "Anomaly",
|
||||
openPayExit: "Open pay / exit",
|
||||
openReopenBarrier: "Open — paid, awaiting barrier",
|
||||
issueEntry: "Issue ticket",
|
||||
issueEntryTitle: "Issue an entry ticket & open the barrier (physical button broken)",
|
||||
issueEntryConfirm: "A vehicle is at the entry. Issue an entry ticket and open the barrier?",
|
||||
issueEntryOk: "Entry ticket {{ticket}} issued.",
|
||||
exitedGrace: "exited · grace",
|
||||
exitedGraceLeft: "exited · {{time}}",
|
||||
exitedGraceTitle: "Paid and exited — barrier not confirmed; waiting out the grace period.",
|
||||
openBarrier: "Open barrier",
|
||||
openBarrierTitle: "Human-intervention barrier open (audited)",
|
||||
barrierOpened: "barrier opened",
|
||||
@@ -179,6 +217,8 @@ export const en: Catalog = {
|
||||
evtCashMovement: "CASH",
|
||||
evtCashIn: "PAY-IN",
|
||||
evtCashOut: "PAY-OUT",
|
||||
evtCashReview: "REVIEW",
|
||||
decision: { authorize: "authorized", deny: "denied" },
|
||||
evtAnomaly: "ANOMALY",
|
||||
evtRefused: "REFUSED",
|
||||
// live-feed event detail line + classification badges (computed from payload)
|
||||
@@ -220,6 +260,10 @@ export const en: Catalog = {
|
||||
edPlate: "Plate",
|
||||
edCategory: "Category",
|
||||
edOperator: "Operator",
|
||||
edDecision: "Review decision",
|
||||
edReviewedBy: "Reviewed by",
|
||||
edReviewNote: "Note",
|
||||
edReviewRef: "Movement ref",
|
||||
edTariffVersion: "Tariff version",
|
||||
edRawPayload: "Raw signed payload",
|
||||
edOccurrence: "Occurrence id",
|
||||
@@ -235,6 +279,8 @@ export const en: Catalog = {
|
||||
reason: {
|
||||
"entry.refused.full": "Entry refused — lot full ({{count}}/{{capacity}})",
|
||||
"entry.held.noTicket": "Entry held — ticket not printed: {{detail}}",
|
||||
"entry.operatorIssued": "Entry ticket issued by operator {{operator}} (physical button broken)",
|
||||
"entry.issue.noPresence": "Operator entry refused — no vehicle detected at the entry",
|
||||
"exit.refused.closed": "Exit refused — session already closed",
|
||||
"exit.refused.noSession": "Exit refused — unknown ticket",
|
||||
"exit.refused.unpaid": "Exit refused — not paid (take payment first)",
|
||||
@@ -244,6 +290,8 @@ export const en: Catalog = {
|
||||
"exit.open.failed": "Exit recorded, but the barrier did not open — open manually",
|
||||
"exit.freeGrace": "Free entry-grace (no charge)",
|
||||
"exit.manualOpen": "Manual barrier open (human intervention)",
|
||||
"exit.plateSwapSuspected": "Possible ticket swap — plate {{plate}} is already inside under ticket {{otherIdentity}}",
|
||||
"exit.plateSwapOverride": "Operator {{operator}} released a suspected ticket-swap exit (plate {{plate}}, also open under {{otherIdentity}})",
|
||||
"sub.refused.notFound": "Subscription refused — not found",
|
||||
"sub.refused.outOfWindow": "Subscription refused — {{status}}/out-of-window",
|
||||
"sub.refused.noSession": "Subscription exit with no open session (already out / never entered)",
|
||||
@@ -705,9 +753,9 @@ export const en: Catalog = {
|
||||
srcSubWindow: "out-of-window",
|
||||
drawerSection: "— Drawer —",
|
||||
openingFloat: "Opening cash:",
|
||||
cashTaken: "Cash taken:",
|
||||
cashAdded: "Cash added:",
|
||||
cashRemoved: "Cash removed:",
|
||||
cashTaken: "Daily takings:",
|
||||
cashAdded: "Receipts:",
|
||||
cashRemoved: "Disbursements:",
|
||||
expectedDrawer: "Expected drawer:",
|
||||
printedToReceipt: "Printed to booth receipt.",
|
||||
recordedNoPrinter: "Recorded (no printer to print to).",
|
||||
@@ -756,9 +804,9 @@ export const en: Catalog = {
|
||||
current: "current",
|
||||
drawerSection: "Drawer",
|
||||
openingFloat: "Opening cash",
|
||||
cashTaken: "Cash taken",
|
||||
cashAdded: "Cash added",
|
||||
cashRemoved: "Cash removed",
|
||||
cashTaken: "Daily takings",
|
||||
cashAdded: "Receipts",
|
||||
cashRemoved: "Disbursements",
|
||||
loadFailed: "Failed to load shifts.",
|
||||
},
|
||||
reports: {
|
||||
@@ -874,14 +922,18 @@ export const en: Catalog = {
|
||||
ticket: "Ticket",
|
||||
entry: "Entry",
|
||||
now: "Now",
|
||||
exit: "Exit",
|
||||
duration: "Duration",
|
||||
statusLabel: "Status",
|
||||
paid: "PAID",
|
||||
unpaid: "UNPAID",
|
||||
overstay: "OVERSTAY",
|
||||
overstayHint: "Earlier session paid. The customer failed to exit during the grace period. Payment for the new period is required. The total below is the new period's fee.",
|
||||
closedWithinGrace: "EXITED · GRACE",
|
||||
closedWithinGraceHint: "Paid and exit recorded — the barrier didn't confirm yet. The car stays listed until the grace period ends. Open the barrier manually if it's still waiting.",
|
||||
topUp: "New period due",
|
||||
total: "Total",
|
||||
paidAmount: "Paid",
|
||||
noTariff: "no tariff",
|
||||
tender: "Tender",
|
||||
cash: "Cash",
|
||||
@@ -900,6 +952,10 @@ export const en: Catalog = {
|
||||
lookingUp: "looking up…",
|
||||
paidBarrierOpened: "Paid — barrier opened. Car may exit.",
|
||||
paidExitRecorded: "Paid and exit recorded, but the barrier did not open: {{reason}}.",
|
||||
swapTitle: "Possible ticket swap",
|
||||
swapBody: "Plate {{plate}} is already inside under ticket {{other}} (entered {{when}}). This car may be exiting on a different ticket than it entered on.",
|
||||
swapHint: "Verify the vehicle before releasing. Overriding is recorded against you.",
|
||||
swapOverride: "Override & release",
|
||||
subscription: "SUBSCRIPTION",
|
||||
plan: "Plan",
|
||||
prepaid: "PREPAID",
|
||||
|
||||
+68
-12
@@ -60,12 +60,42 @@ export const sq = {
|
||||
users: "Përdoruesit",
|
||||
roles: "Rolet",
|
||||
shifts: "Turnet",
|
||||
drawer: "Arka",
|
||||
reports: "Raportet",
|
||||
recycleBin: "Koshi",
|
||||
logs: "Loget",
|
||||
backup: "Kopje rezervë",
|
||||
profile: "Profili",
|
||||
},
|
||||
drawer: {
|
||||
recordTitle: "Regjistro një lëvizje arke",
|
||||
amount: "shuma",
|
||||
reasonPlaceholder: "arsyeja (p.sh. pagesë furnitori, depozitë banke)",
|
||||
recordHint: "Regjistrohet menjëherë në arkë. Një admin e shqyrton më pas.",
|
||||
mandatArketimi: "Arkëtim (hyrje) +",
|
||||
mandatPagese: "Pagesë (dalje) −",
|
||||
enterPositive: "Fut një shumë pozitive.",
|
||||
recorded: "{{no}} u regjistrua. Arka tani {{amount}}.",
|
||||
myTitle: "Lëvizjet e mia të arkës",
|
||||
allTitle: "Lëvizjet e arkës",
|
||||
pendingCount: "{{count}} në pritje",
|
||||
filterAll: "Të gjitha",
|
||||
empty: "Asnjë lëvizje arke ende.",
|
||||
colWhen: "Kur",
|
||||
colType: "Lloji",
|
||||
colAmount: "Shuma",
|
||||
colReason: "Arsyeja",
|
||||
colOperator: "Operatori",
|
||||
colStatus: "Statusi",
|
||||
status: {
|
||||
pending: "në pritje",
|
||||
authorized: "autorizuar",
|
||||
denied: "refuzuar",
|
||||
},
|
||||
authorize: "Autorizo",
|
||||
deny: "Refuzo",
|
||||
denyNotePlaceholder: "arsyeja e refuzimit (opsionale)",
|
||||
},
|
||||
profile: {
|
||||
title: "Profili im",
|
||||
accountSection: "Llogaria",
|
||||
@@ -162,10 +192,18 @@ export const sq = {
|
||||
fEvtVoid: "Anulim",
|
||||
fEvtAnomaly: "Anomali",
|
||||
openPayExit: "Hap pagesën / daljen",
|
||||
openReopenBarrier: "Hap — paguar, pret barrierën",
|
||||
issueEntry: "Lësho biletë",
|
||||
issueEntryTitle: "Lësho një biletë hyrjeje & hap barrierën (butoni fizik i prishur)",
|
||||
issueEntryConfirm: "Një automjet është te hyrja. Të lëshohet një biletë hyrjeje dhe të hapet barriera?",
|
||||
issueEntryOk: "Bileta e hyrjes {{ticket}} u lëshua.",
|
||||
exitedGrace: "doli · në afat",
|
||||
exitedGraceLeft: "doli · {{time}}",
|
||||
exitedGraceTitle: "Paguar dhe dalur — barriera nuk u konfirmua; po pret afatin kohor.",
|
||||
openBarrier: "Hap barrierën",
|
||||
openBarrierTitle: "Hap barrierën manualisht",
|
||||
barrierOpened: "barriera u hap",
|
||||
openManually: "hape me dorë",
|
||||
openManually: "hape manualisht",
|
||||
// session row badges
|
||||
badgeExiting: "duke dalë",
|
||||
badgePaid: "paguar",
|
||||
@@ -183,6 +221,8 @@ export const sq = {
|
||||
evtCashMovement: "ARKË",
|
||||
evtCashIn: "ARKËTIM",
|
||||
evtCashOut: "PAGESË",
|
||||
evtCashReview: "SHQYRTIM",
|
||||
decision: { authorize: "autorizuar", deny: "refuzuar" },
|
||||
evtAnomaly: "ANOMALI",
|
||||
evtRefused: "REFUZUAR",
|
||||
// rreshti i detajeve të eventit live + etiketat e klasifikimit (nga payload)
|
||||
@@ -224,6 +264,10 @@ export const sq = {
|
||||
edPlate: "Targa",
|
||||
edCategory: "Kategoria",
|
||||
edOperator: "Operatori",
|
||||
edDecision: "Vendimi i shqyrtimit",
|
||||
edReviewedBy: "Shqyrtuar nga",
|
||||
edReviewNote: "Shënim",
|
||||
edReviewRef: "Ref. lëvizjes",
|
||||
edTariffVersion: "Versioni i tarifës",
|
||||
edRawPayload: "Të dhënat e papërpunuara të nënshkruara",
|
||||
edOccurrence: "ID e hyrjes",
|
||||
@@ -238,15 +282,19 @@ export const sq = {
|
||||
reason: {
|
||||
"entry.refused.full": "Hyrja u refuzua — parkimi plot ({{count}}/{{capacity}})",
|
||||
"entry.held.noTicket": "Hyrja u mbajt — bileta nuk u printua: {{detail}}",
|
||||
"entry.operatorIssued": "Biletë hyrjeje e lëshuar nga operatori {{operator}} (butoni fizik i prishur)",
|
||||
"entry.issue.noPresence": "Hyrja nga operatori u refuzua — asnjë automjet te hyrja",
|
||||
"exit.refused.closed": "Dalja u refuzua — sesioni është mbyllur tashmë",
|
||||
"exit.refused.noSession": "Dalja u refuzua — biletë e panjohur",
|
||||
"exit.refused.unpaid": "Dalja u refuzua — e papaguar (bëj pagesën në fillim)",
|
||||
"exit.refused.graceExpired": "Dalja u refuzua — afati i daljes skadoi (kërkohet pagesë shtesë)",
|
||||
"exit.open.noBarrier": "Dalja u regjistrua, por nuk ka barrierë daljeje të konfiguruar — hape me dorë",
|
||||
"exit.open.unavailable": "Dalja u regjistrua, por barriera është e padisponueshme — hape me dorë",
|
||||
"exit.open.failed": "Dalja u regjistrua, por barriera nuk u hap — hape me dorë",
|
||||
"exit.open.noBarrier": "Dalja u regjistrua, por nuk ka barrierë daljeje të konfiguruar — hape manualisht",
|
||||
"exit.open.unavailable": "Dalja u regjistrua, por barriera është e padisponueshme — hape manualisht",
|
||||
"exit.open.failed": "Dalja u regjistrua, por barriera nuk u hap — hape manualisht",
|
||||
"exit.freeGrace": "Periudhë pa pagesë në hyrje (pa tarifë)",
|
||||
"exit.manualOpen": "Hapje manuale e barrierës (ndërhyrje njerëzore)",
|
||||
"exit.plateSwapSuspected": "Mundësi ndërrimi biletash — targa {{plate}} është tashmë brenda me biletën {{otherIdentity}}",
|
||||
"exit.plateSwapOverride": "Operatori {{operator}} lëshoi një dalje me dyshim ndërrimi biletash (targa {{plate}}, edhe e hapur me {{otherIdentity}})",
|
||||
"sub.refused.notFound": "Abonimi u refuzua — nuk u gjet",
|
||||
"sub.refused.outOfWindow": "Abonimi u refuzua — {{status}}/jashtë afatit",
|
||||
"sub.refused.noSession": "Dalje me abonim pa sesion të hapur (tashmë jashtë / nuk ka hyrë kurrë)",
|
||||
@@ -718,9 +766,9 @@ export const sq = {
|
||||
srcSubWindow: "jashtë orarit",
|
||||
drawerSection: "— Arka —",
|
||||
openingFloat: "Arka fillestare:",
|
||||
cashTaken: "Para të marra:",
|
||||
cashAdded: "Para të shtuara:",
|
||||
cashRemoved: "Para të hequra:",
|
||||
cashTaken: "Xhiro ditore:",
|
||||
cashAdded: "Arkëtime:",
|
||||
cashRemoved: "Pagesa:",
|
||||
expectedDrawer: "Gjëndje Arke:",
|
||||
printedToReceipt: "Printuar te printeri i kabinës.",
|
||||
recordedNoPrinter: "Regjistruar (pa printer për të printuar).",
|
||||
@@ -771,9 +819,9 @@ export const sq = {
|
||||
// Expanded drawer detail.
|
||||
drawerSection: "Arka",
|
||||
openingFloat: "Arka fillestare",
|
||||
cashTaken: "Para të marra",
|
||||
cashAdded: "Para të shtuara",
|
||||
cashRemoved: "Para të hequra",
|
||||
cashTaken: "Xhiro ditore",
|
||||
cashAdded: "Arkëtime",
|
||||
cashRemoved: "Pagesa",
|
||||
loadFailed: "Ngarkimi i turneve dështoi.",
|
||||
},
|
||||
reports: {
|
||||
@@ -890,20 +938,24 @@ export const sq = {
|
||||
ticket: "Bileta",
|
||||
entry: "Hyrja",
|
||||
now: "Tani",
|
||||
exit: "Dalja",
|
||||
duration: "Kohëzgjatja",
|
||||
statusLabel: "Statusi",
|
||||
paid: "PAGUAR",
|
||||
unpaid: "PAPAGUAR",
|
||||
overstay: "TEJ AFATIT",
|
||||
overstayHint: "Sesion i mëparshëm i paguar. Klienti nuk doli brënda afatit kohor. Kërkohet pagesë për periudhën e re. Totali më poshtë është tarifa e periudhës së re.",
|
||||
closedWithinGrace: "Paguar",
|
||||
closedWithinGraceHint: "Pagesa dhe dalja u regjistruan — barriera nuk u konfirmua ende. Makina mbetet në listë derisa të mbarojë afati. Hapni barrierën manualisht nëse pret ende.",
|
||||
topUp: "Periudha e re për pagesë",
|
||||
total: "Totali",
|
||||
paidAmount: "Paguar",
|
||||
noTariff: "pa tarifë",
|
||||
tender: "Mënyra",
|
||||
cash: "Para",
|
||||
card: "Kartë",
|
||||
printExitVoucher: "Printo biletë dalje",
|
||||
selfExitHint: "(klienti del vetë te dalja)",
|
||||
selfExitHint: "(klienti del duke skanuar biletën)",
|
||||
payAndOpen: "Paguaj + hap barrierën",
|
||||
payAndVoucher: "Paguaj + printo biletën",
|
||||
openBarrier: "Hap barrierën",
|
||||
@@ -916,6 +968,10 @@ export const sq = {
|
||||
lookingUp: "Duke kërkuar…",
|
||||
paidBarrierOpened: "Paguar — barriera u hap. Automjeti mund të dalë.",
|
||||
paidExitRecorded: "Paguar dhe dalja u regjistrua, por barriera nuk u hap: {{reason}}.",
|
||||
swapTitle: "Mundësi ndërrimi biletash",
|
||||
swapBody: "Targa {{plate}} është tashmë brenda me biletën {{other}} (hyri {{when}}). Ky automjet mund të jetë duke dalë me një biletë tjetër nga ajo me të cilën hyri.",
|
||||
swapHint: "Verifiko automjetin para se ta lëshosh. Anashkalimi regjistrohet në emrin tënd.",
|
||||
swapOverride: "Anashkalo & lësho",
|
||||
subscription: "ABONIM",
|
||||
plan: "Plani",
|
||||
prepaid: "I PARAPAGUAR",
|
||||
@@ -926,7 +982,7 @@ export const sq = {
|
||||
windowCharge: "JASHTË ORARIT",
|
||||
windowChargeHint: "Ky abonent parkoi jashtë orarit të lejuar të planit. Detyrohet të paguajë tarifën kalimtare për kohën jashtë orarit — merr pagesën, pastaj hap barrierën.",
|
||||
subBarrierOpened: "Barriera u hap për abonentin (ndërhyrje e regjistruar).",
|
||||
voucherPrinted: "Bileta e daljes u printua në {{printer}}. Klienti del vetë te dalja.",
|
||||
voucherPrinted: "Bileta e daljes u printua në {{printer}}. Klienti del duke skanuar biletën.",
|
||||
// payment receipt (transparency slip)
|
||||
receiptPrintFailed: "(fatura nuk u printua — provoni \"Riprinto faturën\".)",
|
||||
receiptReprinted: "Fatura u riprintua në {{printer}}.",
|
||||
|
||||
+32
-8
@@ -41,6 +41,8 @@ import { SiteSettings } from "./SiteSettings.js";
|
||||
import { UsersManager } from "./UsersManager.js";
|
||||
import { RolesManager } from "./RolesManager.js";
|
||||
import { ShiftsHistory } from "./ShiftsHistory.js";
|
||||
import { DrawerManager } from "./DrawerManager.js";
|
||||
import { CARD_PAYMENTS_ENABLED } from "./lib/features.js";
|
||||
import { LogsViewer } from "./LogsViewer.js";
|
||||
import { BackupSettings } from "./BackupSettings.js";
|
||||
import { RecycleBin } from "./RecycleBin.js";
|
||||
@@ -379,7 +381,7 @@ function CloseShiftConfirm({
|
||||
</div>
|
||||
<div className="mt-2 grid grid-cols-2 gap-x-6 gap-y-0.5 border-t border-term-border pt-2">
|
||||
<ConfirmFigure label={t("shift.cash")} value={fmt(x.cashTotalMinor)} />
|
||||
<ConfirmFigure label={t("shift.card")} value={fmt(x.cardTotalMinor)} />
|
||||
{CARD_PAYMENTS_ENABLED && <ConfirmFigure label={t("shift.card")} value={fmt(x.cardTotalMinor)} />}
|
||||
{/* Drawer math made explicit: opening float + cash taken = expected drawer. */}
|
||||
<ConfirmFigure label={t("shift.openingFloat")} value={fmt(x.openingFloatMinor)} />
|
||||
<span />
|
||||
@@ -430,6 +432,11 @@ function RootLayout() {
|
||||
<nav className="flex items-center gap-1">
|
||||
<NavLink to="/booth" label={t("nav.booth")} />
|
||||
<NavLink to="/shifts" label={t("nav.shifts")} />
|
||||
{/* Drawer — record cash movements (operator) / review them (admin). Shown if the
|
||||
user can do either. See wiki/concepts/shift.md. */}
|
||||
{(show("drawer:create") || show("drawer:review")) && (
|
||||
<NavLink to="/drawer" label={t("nav.drawer")} />
|
||||
)}
|
||||
{/* Subscriptions — a standalone section (Abonimet / Planet / Lab tarife).
|
||||
Shown if the user can reach ANY of its tabs. */}
|
||||
{(show("subscription:read") || show("subscription:plan") || show("tariff:read")) && (
|
||||
@@ -548,14 +555,30 @@ const shiftRoute = createRoute({
|
||||
component: function ShiftRoute() {
|
||||
const { user } = rootRoute.useRouteContext();
|
||||
// The shift hub: list (current/open shift on top + history) + per-shift activity log.
|
||||
// The CURRENT shift's pane carries the actions (open/close, drawer voucher, takings),
|
||||
// each opening a modal. `canManage` = shift:create (start/end + raise vouchers); a
|
||||
// voucher additionally needs an admin's password sign-off server-side.
|
||||
// The CURRENT shift's pane carries the actions (open/close, takings), each opening a
|
||||
// modal. `canManage` = shift:create (start/end). Drawer cash movements moved to /drawer
|
||||
// (2026-07-01).
|
||||
return <ShiftsHistory user={user} canManage={can(user, "shift:create")} />;
|
||||
},
|
||||
});
|
||||
|
||||
const drawerRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/drawer",
|
||||
// Reachable by anyone who can record OR review; the component shows the right view per
|
||||
// permission. Guard on the broader of the two (create) so a review-only admin still gets
|
||||
// in — the redirect only fires if the user has NEITHER, which the nav already hides.
|
||||
beforeLoad: ({ context }) => {
|
||||
if (!can(context.user, "drawer:create") && !can(context.user, "drawer:review")) {
|
||||
throw redirect({ to: "/booth" });
|
||||
}
|
||||
},
|
||||
component: function DrawerRoute() {
|
||||
const { user } = rootRoute.useRouteContext();
|
||||
return (
|
||||
<ShiftsHistory
|
||||
user={user}
|
||||
canManage={can(user, "shift:create")}
|
||||
canVoucher={can(user, "shift:create")}
|
||||
<DrawerManager
|
||||
canCreate={can(user, "drawer:create")}
|
||||
canReview={can(user, "drawer:review")}
|
||||
/>
|
||||
);
|
||||
},
|
||||
@@ -723,6 +746,7 @@ const routeTree = rootRoute.addChildren([
|
||||
...legacyRedirects,
|
||||
profileRoute,
|
||||
shiftRoute,
|
||||
drawerRoute,
|
||||
reportsRoute,
|
||||
subscriptionsRoute.addChildren([
|
||||
subscriptionsIndexRoute,
|
||||
|
||||
@@ -23,6 +23,7 @@ export const EVENT_STYLE: Record<string, { labelKey: string; color: string }> =
|
||||
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" },
|
||||
cash_review: { labelKey: "booth.evtCashReview", color: "text-term-cyan" },
|
||||
anomaly: { labelKey: "booth.evtAnomaly", color: "text-term-red" },
|
||||
};
|
||||
|
||||
@@ -187,6 +188,12 @@ export function EventDetailModal({ e, onClose }: { e: LedgerEvent; onClose: () =
|
||||
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;
|
||||
// cash_review fields: the admin's decision on a drawer movement (+ who / note / the
|
||||
// reviewed movement id). A flag only — it never moves cash. See wiki/concepts/shift.md.
|
||||
const decision = p?.decision === "authorize" || p?.decision === "deny" ? p.decision : null;
|
||||
const reviewedBy = typeof p?.reviewedBy === "string" ? p.reviewedBy : null;
|
||||
const reviewNote = typeof p?.note === "string" ? p.note : null;
|
||||
const refId = typeof p?.refId === "string" ? p.refId : null;
|
||||
|
||||
return (
|
||||
<Modal open onClose={onClose} title={t("booth.eventDetail")} width="max-w-2xl">
|
||||
@@ -244,6 +251,21 @@ export function EventDetailModal({ e, onClose }: { e: LedgerEvent; onClose: () =
|
||||
{category && <DetailRow label={t("booth.edCategory")}>{category}</DetailRow>}
|
||||
{plate && <DetailRow label={t("booth.edPlate")}>{plate}</DetailRow>}
|
||||
{operator && <DetailRow label={t("booth.edOperator")}>{operator}</DetailRow>}
|
||||
{/* cash_review: the admin's decision + who + why (for a denial). */}
|
||||
{decision && (
|
||||
<DetailRow label={t("booth.edDecision")}>
|
||||
<span className={decision === "authorize" ? "text-term-green" : "text-term-red"}>
|
||||
{t(`booth.decision.${decision}`)}
|
||||
</span>
|
||||
</DetailRow>
|
||||
)}
|
||||
{reviewedBy && <DetailRow label={t("booth.edReviewedBy")}>{reviewedBy}</DetailRow>}
|
||||
{reviewNote && <DetailRow label={t("booth.edReviewNote")}>{reviewNote}</DetailRow>}
|
||||
{refId && (
|
||||
<DetailRow label={t("booth.edReviewRef")}>
|
||||
<code className="text-[0.6875rem] text-term-muted">{refId}</code>
|
||||
</DetailRow>
|
||||
)}
|
||||
{sessionRef && sessionRef !== e.identity && (
|
||||
<DetailRow label={t("booth.edSession")}>{sessionRef}</DetailRow>
|
||||
)}
|
||||
|
||||
+2
-1
@@ -12,7 +12,8 @@
|
||||
"lint": "turbo run lint",
|
||||
"typecheck": "turbo run typecheck",
|
||||
"test": "turbo run test",
|
||||
"seed:admin": "pnpm --filter @parking/server seed-admin"
|
||||
"seed:admin": "pnpm --filter @parking/server seed-admin",
|
||||
"db:reset": "pnpm --filter @parking/db db:reset"
|
||||
},
|
||||
"devDependencies": {
|
||||
"turbo": "2.9.18",
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
-- Drawer redesign (2026-07-01): operators RECORD cash movements freely; admins REVIEW them
|
||||
-- after the fact (authorize/deny — a flag, not a reversal). New `drawer` resource with two
|
||||
-- permissions in @parking/shared: drawer:create + drawer:review.
|
||||
--
|
||||
-- The built-in `admin` role gets ALL permissions in code (auth.ts ADMIN_PERMS = new
|
||||
-- Set(PERMISSIONS)), so it needs NO seed row here. This grants the default `operator` role
|
||||
-- the ability to record movements (drawer:create) — matching the prior behaviour where an
|
||||
-- operator could raise a voucher. An admin can revoke it per-role in the Roles UI (it's just
|
||||
-- data). drawer:review is admin-only, so it is NOT granted to operator.
|
||||
--
|
||||
-- Idempotent: role_permissions has a UNIQUE(role_id, permission) index, so re-running is a
|
||||
-- no-op via OR IGNORE. See wiki/concepts/shift.md.
|
||||
INSERT OR IGNORE INTO `role_permissions` (`role_id`, `permission`) VALUES
|
||||
('operator','drawer:create');
|
||||
@@ -0,0 +1,9 @@
|
||||
-- Operator-issued entry (2026-07-01): when the physical entry button is broken, an operator
|
||||
-- may ISSUE an entry ticket (a flagged mint, gated on real vehicle presence — radar + camera).
|
||||
-- New permission `session:create` in @parking/shared. The built-in `admin` role gets ALL
|
||||
-- permissions in code (auth.ts ADMIN_PERMS), so no seed row is needed for it. This grants the
|
||||
-- default `operator` role the ability to issue — an admin can revoke it per-role in the Roles
|
||||
-- UI (it's data). Idempotent via the UNIQUE(role_id, permission) index.
|
||||
-- See wiki/concepts/operator-issued-entry.md.
|
||||
INSERT OR IGNORE INTO `role_permissions` (`role_id`, `permission`) VALUES
|
||||
('operator','session:create');
|
||||
@@ -127,6 +127,20 @@
|
||||
"when": 1781886000000,
|
||||
"tag": "0017_backup_retention",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 18,
|
||||
"version": "6",
|
||||
"when": 1781886100000,
|
||||
"tag": "0018_drawer_permissions",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 19,
|
||||
"version": "6",
|
||||
"when": 1781886200000,
|
||||
"tag": "0019_operator_session_create",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -26,7 +26,8 @@
|
||||
"lint": "tsc --noEmit",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "DATABASE_URL=\"${DATABASE_URL:-../../apps/server/parking.sqlite}\" drizzle-kit migrate",
|
||||
"db:migrate:runtime": "node scripts/migrate-runtime.mjs"
|
||||
"db:migrate:runtime": "node scripts/migrate-runtime.mjs",
|
||||
"db:reset": "DATABASE_URL=\"${DATABASE_URL:-../../apps/server/parking.sqlite}\" node scripts/reset-db.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@parking/shared": "workspace:*",
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
#!/usr/bin/env node
|
||||
// DESTRUCTIVE training/demo reset of the SQLite DB at DATABASE_URL. Deletes rows from
|
||||
// whole CATEGORIES of tables so a site can be re-used to TRAIN operators/admins without
|
||||
// leaving demo data behind. This is intentionally a CLI script (no UI button) so it
|
||||
// cannot be triggered casually — and it is double-gated so it never runs on a real booth.
|
||||
//
|
||||
// ⚠ This TRUNCATES the append-only, hash-chained, SIGNED ledger (`ledger_events`).
|
||||
// That is the anti-fraud record. A partial delete would break the chain, so a
|
||||
// financial reset wipes the whole ledger back to empty (re-seeding starts a NEW
|
||||
// chain under the same EVENT_SIGNING_KEY — the key is NOT touched here). Only ever
|
||||
// do this on a TRAINING/DEMO box. See wiki/concepts/append-only-event-chain.md.
|
||||
//
|
||||
// Flags (combinable; at least one required):
|
||||
// --all every category below (a blank-slate box)
|
||||
// --financial transactional history: ledger (entry/exit/payment/void/shift/cash/
|
||||
// anomaly), device telemetry, snapshots, subscription INSTANCES +
|
||||
// their credentials/plates, blocklist. KEEPS users, devices, config,
|
||||
// tariffs, subscription PLANS.
|
||||
// --config site_config, devices, setup_state (re-runs first-run setup),
|
||||
// tariffs + tariff_versions, subscription_plans.
|
||||
// --users users, roles, role_permissions, auth sessions. (After this or --all,
|
||||
// re-seed an admin: apps/server/scripts/seed-admin.mjs.)
|
||||
//
|
||||
// Safety gates (BOTH required):
|
||||
// 1. env RESET_ALLOWED=1 — a real booth never sets this.
|
||||
// 2. type the DB filename — interactive confirmation (skip with --yes ONLY in CI).
|
||||
//
|
||||
// Usage:
|
||||
// RESET_ALLOWED=1 DATABASE_URL=apps/server/parking.sqlite \
|
||||
// node packages/db/scripts/reset-db.mjs --financial
|
||||
import { createInterface } from "node:readline";
|
||||
import { basename, resolve } from "node:path";
|
||||
import { existsSync } from "node:fs";
|
||||
import Database from "better-sqlite3";
|
||||
|
||||
// --- Category → tables (child tables BEFORE parents; we also disable FKs for the txn). ---
|
||||
const CATEGORIES = {
|
||||
financial: [
|
||||
"ledger_events",
|
||||
"device_events",
|
||||
"snapshots",
|
||||
"subscription_plates",
|
||||
"subscription_credentials",
|
||||
"subscriptions",
|
||||
"blocklist",
|
||||
],
|
||||
config: ["site_config", "devices", "setup_state", "tariff_versions", "tariffs", "subscription_plans"],
|
||||
users: ["sessions", "role_permissions", "users", "roles"],
|
||||
};
|
||||
|
||||
function parseArgs(argv) {
|
||||
const flags = new Set(argv.filter((a) => a.startsWith("--")).map((a) => a.slice(2)));
|
||||
const wantAll = flags.has("all");
|
||||
const cats = wantAll ? Object.keys(CATEGORIES) : Object.keys(CATEGORIES).filter((c) => flags.has(c));
|
||||
return { cats, autoYes: flags.has("yes"), wantAll };
|
||||
}
|
||||
|
||||
function die(msg) {
|
||||
console.error(`[reset] ${msg}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
async function confirm(promptText, expected) {
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
const answer = await new Promise((res) => rl.question(promptText, res));
|
||||
rl.close();
|
||||
return answer.trim() === expected;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const url = process.env.DATABASE_URL;
|
||||
if (!url) die("DATABASE_URL is required");
|
||||
const dbPath = resolve(url);
|
||||
if (!existsSync(dbPath)) die(`no database at ${dbPath}`);
|
||||
|
||||
const { cats, autoYes, wantAll } = parseArgs(process.argv.slice(2));
|
||||
if (cats.length === 0) {
|
||||
die("nothing to do — pass --all, --financial, --config, and/or --users");
|
||||
}
|
||||
|
||||
// GATE 1: env opt-in. A production booth never sets this.
|
||||
if (process.env.RESET_ALLOWED !== "1") {
|
||||
die(
|
||||
`refusing to reset ${dbPath}\n` +
|
||||
` set RESET_ALLOWED=1 to enable (real booths never set this).`,
|
||||
);
|
||||
}
|
||||
|
||||
// Resolve the ordered, de-duplicated table list for the chosen categories.
|
||||
const tables = [];
|
||||
for (const c of cats) for (const t of CATEGORIES[c]) if (!tables.includes(t)) tables.push(t);
|
||||
|
||||
console.error(`\n⚠ DESTRUCTIVE RESET`);
|
||||
console.error(` db : ${dbPath}`);
|
||||
console.error(` categories: ${cats.join(", ")}${wantAll ? " (= everything)" : ""}`);
|
||||
console.error(` tables : ${tables.join(", ")}`);
|
||||
if (cats.includes("financial")) {
|
||||
console.error(` NOTE: this TRUNCATES the signed append-only ledger. Training/demo only.`);
|
||||
}
|
||||
console.error("");
|
||||
|
||||
// GATE 2: typed confirmation of the DB filename (skippable only with --yes, for CI).
|
||||
if (!autoYes) {
|
||||
const fname = basename(dbPath);
|
||||
const ok = await confirm(`Type the db filename to confirm (${fname}): `, fname);
|
||||
if (!ok) die("confirmation did not match — aborted, nothing changed.");
|
||||
}
|
||||
|
||||
const sqlite = new Database(dbPath);
|
||||
try {
|
||||
// FKs OFF for the wipe so we can delete in any order without ordering hazards;
|
||||
// a single transaction makes it all-or-nothing.
|
||||
sqlite.pragma("foreign_keys = OFF");
|
||||
const wipe = sqlite.transaction(() => {
|
||||
const counts = {};
|
||||
for (const t of tables) {
|
||||
const before = sqlite.prepare(`SELECT COUNT(*) AS n FROM "${t}"`).get().n;
|
||||
sqlite.prepare(`DELETE FROM "${t}"`).run();
|
||||
counts[t] = before;
|
||||
}
|
||||
return counts;
|
||||
});
|
||||
const counts = wipe();
|
||||
sqlite.pragma("foreign_keys = ON");
|
||||
// Reclaim space + reset the WAL so the file shrinks (demo boxes get re-used a lot).
|
||||
sqlite.exec("VACUUM");
|
||||
|
||||
console.error(`[reset] done. Rows deleted:`);
|
||||
for (const t of tables) console.error(` ${String(counts[t]).padStart(7)} ${t}`);
|
||||
if (cats.includes("users") || wantAll) {
|
||||
console.error(
|
||||
`\n[reset] users were cleared — re-seed an admin:\n` +
|
||||
` ADMIN_USER=admin ADMIN_PASS='…' node apps/server/scripts/seed-admin.mjs`,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
sqlite.close();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((e) => die(e.message));
|
||||
@@ -21,7 +21,8 @@ export const RESOURCES = [
|
||||
"subscription", // the subscription registry
|
||||
"site", // site_config + device setup/assign
|
||||
"device", // device status / printers / snapshots / catalog
|
||||
"shift", // open/close own shift; move the drawer float
|
||||
"shift", // open/close own shift
|
||||
"drawer", // record cash receipts/disbursements (operator); review them (admin)
|
||||
"payment", // take payment, quote, voucher/receipt, exit, reopen
|
||||
"session", // active sessions, lookup
|
||||
"event", // the signed ledger feed + void
|
||||
@@ -33,9 +34,11 @@ export const RESOURCES = [
|
||||
export type Resource = (typeof RESOURCES)[number];
|
||||
|
||||
/** CRUD plus domain verbs where CRUD doesn't fit: `void` (append a void event, NOT a
|
||||
* delete), `cash` (move the drawer float — admin-grade shift action), and `plan`
|
||||
* (compose the subscription plan catalog — admin-grade; selling stays `create`). */
|
||||
export type Action = "create" | "read" | "update" | "delete" | "void" | "cash" | "plan";
|
||||
* delete), `cash` (admin-grade shift scope — see all operators' shifts), `plan` (compose
|
||||
* the subscription plan catalog — admin-grade; selling stays `create`), and `review`
|
||||
* (admin authorizes/denies a drawer movement an operator recorded — a flag, not a
|
||||
* reversal; see wiki/concepts/shift.md). */
|
||||
export type Action = "create" | "read" | "update" | "delete" | "void" | "cash" | "plan" | "review";
|
||||
|
||||
/** A single permission, e.g. "tariff:update". The route guard checks one of these. */
|
||||
export type Permission = `${Resource}:${Action}`;
|
||||
@@ -53,8 +56,16 @@ export const PERMISSIONS: readonly Permission[] = [
|
||||
"site:read", "site:update",
|
||||
"device:read",
|
||||
"shift:read", "shift:create", "shift:cash",
|
||||
// Drawer cash movements: create (operator RECORDS a receipt/disbursement — freely, no
|
||||
// admin sign-off at creation; admin-revocable per role) and review (admin AUTHORIZES or
|
||||
// DENIES a recorded movement after the fact — a flag, never a cash reversal). A denial is
|
||||
// a judgment about the operator, settled outside the app. See wiki/concepts/shift.md.
|
||||
"drawer:create", "drawer:review",
|
||||
"payment:read", "payment:create",
|
||||
"session:read",
|
||||
// session:create = the operator ISSUES an entry ticket when the physical entry button
|
||||
// is broken (a flagged mint, gated on real vehicle presence). Admin-revocable per role.
|
||||
// See wiki/concepts/operator-issued-entry.md.
|
||||
"session:read", "session:create",
|
||||
"event:read", "event:void",
|
||||
"report:read",
|
||||
"log:read",
|
||||
@@ -241,11 +252,19 @@ export type LedgerEventType =
|
||||
// financial documents — the direction is the TYPE, not the sign of an amount):
|
||||
// cash_in = Mandat Arkëtimi (receipt / pay-IN): cash enters the drawer.
|
||||
// cash_out = Mandat Pagese (disbursement / pay-OUT): cash leaves the drawer.
|
||||
// Payload: { amountMinor (POSITIVE magnitude), reason, currency, operator (raised
|
||||
// by), authorizedBy (admin who signed off), voucherNo }. Operator-raised /
|
||||
// admin-authorized. Folds into the drawer balance. See wiki/concepts/shift.md.
|
||||
// Payload: { amountMinor (POSITIVE magnitude), reason, currency, operator (raised by),
|
||||
// voucherNo }. OPERATOR-RECORDED (freely; no admin sign-off at creation — 2026-07-01).
|
||||
// Folds into the drawer balance. Reviewed after the fact via cash_review (below).
|
||||
// See wiki/concepts/shift.md.
|
||||
| "cash_in"
|
||||
| "cash_out"
|
||||
// Admin's post-hoc REVIEW of a recorded cash_in/cash_out. Payload: { refId (the
|
||||
// reviewed movement's event id), decision: "authorize"|"deny", reviewedBy, note?,
|
||||
// currency? }. A FLAG only — it NEVER moves cash: a denial is a judgment about the
|
||||
// operator (settled outside the app), so it does NOT reverse the movement and does NOT
|
||||
// touch the drawer balance. Append-only, signed, so the decision is itself auditable.
|
||||
// See wiki/concepts/shift.md.
|
||||
| "cash_review"
|
||||
| "anomaly";
|
||||
|
||||
/** How money was tendered (for payment events + the shift Z-report). */
|
||||
@@ -292,12 +311,23 @@ export interface LedgerPayload {
|
||||
/** vehicle_entry: the vehicle/customer category, frozen at entry so V2 category
|
||||
* pricing reprices identically at exit. Absent on legacy entries (= default). */
|
||||
readonly category?: string;
|
||||
/** cash_in / cash_out voucher: the admin who AUTHORIZED the drawer movement (the
|
||||
* operator in `operator` raised it). Operator-raised / admin-authorized. */
|
||||
readonly authorizedBy?: string;
|
||||
/** cash_in / cash_out voucher: a human-facing voucher number printed on the slip
|
||||
* (Mandat Nr.). Sequential per type; signed for reproducibility. */
|
||||
readonly voucherNo?: string;
|
||||
/** LEGACY cash_in / cash_out (pre-2026-07-01): the admin who AUTHORIZED the movement
|
||||
* at creation. The current flow records movements freely and reviews them AFTER via a
|
||||
* cash_review event, so new movements do NOT carry this. Kept so historical events
|
||||
* still verify + display. See wiki/concepts/shift.md. */
|
||||
readonly authorizedBy?: string;
|
||||
/** cash_review: the id of the cash_in/cash_out event this review decides on. */
|
||||
readonly refId?: string;
|
||||
/** cash_review: the admin's decision on the referenced movement. A FLAG only —
|
||||
* neither value moves cash or touches the drawer balance. */
|
||||
readonly decision?: "authorize" | "deny";
|
||||
/** cash_review: the admin (username) who made the decision. */
|
||||
readonly reviewedBy?: string;
|
||||
/** cash_review: optional free-text admin note (e.g. why a movement was denied). */
|
||||
readonly note?: string;
|
||||
/** subscription tariff-bridge: this occurrence opened OUTSIDE the plan's allowed window,
|
||||
* so the minutes actually parked out-of-window are charged at the transient tariff and
|
||||
* collected (gated) at exit. The AMOUNT is NOT fixed at entry — it depends on how long
|
||||
@@ -333,6 +363,10 @@ export const REASON_CODES = [
|
||||
// entry
|
||||
"entry.refused.full",
|
||||
"entry.held.noTicket",
|
||||
// operator-issued entry (physical button broken) — a flagged mint, gated on real
|
||||
// vehicle presence (radar + camera). See wiki/concepts/operator-issued-entry.md.
|
||||
"entry.operatorIssued",
|
||||
"entry.issue.noPresence",
|
||||
// exit refusals
|
||||
"exit.refused.closed",
|
||||
"exit.refused.noSession",
|
||||
@@ -346,6 +380,11 @@ export const REASON_CODES = [
|
||||
"exit.freeGrace",
|
||||
// manual / human-intervention barrier open
|
||||
"exit.manualOpen",
|
||||
// plate reconciliation: the exiting car's plate is already OPEN under a DIFFERENT
|
||||
// ticket (possible ticket-swap fraud). Suspected = flagged; Override = operator
|
||||
// consciously released it. See wiki/concepts/plate-reconciliation.md.
|
||||
"exit.plateSwapSuspected",
|
||||
"exit.plateSwapOverride",
|
||||
// subscriptions
|
||||
"sub.refused.notFound",
|
||||
"sub.refused.outOfWindow",
|
||||
@@ -370,6 +409,8 @@ export type ReasonCode = (typeof REASON_CODES)[number];
|
||||
export const REASON_EN: Record<ReasonCode, string> = {
|
||||
"entry.refused.full": "entry refused — lot full ({count}/{capacity})",
|
||||
"entry.held.noTicket": "entry held — ticket not printed: {detail}",
|
||||
"entry.operatorIssued": "entry ticket issued by operator {operator} (physical button)",
|
||||
"entry.issue.noPresence": "operator entry refused — no vehicle detected at the entry",
|
||||
"exit.refused.closed": "exit refused — session already closed",
|
||||
"exit.refused.noSession": "exit refused — no open session for ticket",
|
||||
"exit.refused.unpaid": "exit refused — not paid (take payment first)",
|
||||
@@ -379,6 +420,8 @@ export const REASON_EN: Record<ReasonCode, string> = {
|
||||
"exit.open.failed": "exit recorded, but the barrier did not open — open manually",
|
||||
"exit.freeGrace": "free entry-grace (no charge)",
|
||||
"exit.manualOpen": "manual barrier open (human intervention)",
|
||||
"exit.plateSwapSuspected": "possible ticket swap — plate {plate} is already inside under ticket {otherIdentity}",
|
||||
"exit.plateSwapOverride": "operator {operator} released a suspected ticket-swap exit (plate {plate}, also open under {otherIdentity})",
|
||||
"sub.refused.notFound": "subscription refused — not found",
|
||||
"sub.refused.outOfWindow": "subscription refused — {status}/out-of-window",
|
||||
"sub.refused.noSession": "subscription exit with no open session (already out / never entered)",
|
||||
|
||||
@@ -99,6 +99,38 @@ All three supported in the first cut; the manual button and the periodic timer s
|
||||
- **SFTP** — push to an SFTP endpoint, useful for an offsite copy. **FTP is excluded** (plaintext
|
||||
credentials + data); SFTP is the safe equivalent.
|
||||
|
||||
### The target must be a bind-mounted host path — NOT a casually-plugged USB (2026-06-29)
|
||||
|
||||
The server runs **inside the `parking-server` container**, so it can only `stat()`/write paths that
|
||||
are **bind-mounted into that container**. A USB stick the operator plugs in lands at a desktop
|
||||
auto-mount path on the *host* (`/run/media/<user>/<UUID>`), which **does not exist inside the
|
||||
container** — so the in-UI **Test target** correctly reports *"location does not exist."* This bit on
|
||||
the first booth deploy (2026-06-29): `BACKUP_KEY` was finally injected, then the target test failed
|
||||
because the USB path wasn't visible to the process.
|
||||
|
||||
**So a backup destination is provisioned by the ADMIN at the host level, not chosen ad hoc by the
|
||||
operator.** The procedure:
|
||||
|
||||
1. Attach the disk (external HDD/SSD/USB) and mount it at a **stable host path** (e.g. `/mnt/backup`)
|
||||
via **`/etc/fstab` by UUID** — *not* the desktop automounter, whose UUID-named path changes per
|
||||
drive and vanishes on unplug.
|
||||
2. **Bind-mount that host path into the container** in the prod compose (e.g.
|
||||
`/mnt/backup:/mnt/backup` on the `server` service — same pattern as the `/dev/usb` printer
|
||||
passthrough in [[container-deployment]]).
|
||||
3. In the UI (Setup → Backup), set the **target directory to the in-container path** (`/mnt/backup`)
|
||||
and **Test target** — now writable.
|
||||
|
||||
> **This is partly a feature, not just a limitation** ([[threat-model]]): because the destination is
|
||||
> a host-provisioned bind-mount, the **booth operator cannot redirect backups to a removable stick
|
||||
> they walk off with** — real destinations are an admin/host decision, on the trusted side of the
|
||||
> [[trust-boundary]]. A network share (SMB/NFS) is the same shape: mount on the host, bind-mount in.
|
||||
>
|
||||
> **Limitation acknowledged:** the backup target is therefore **not operator-flexible** — you cannot
|
||||
> just plug in a USB and back up from the UI. Adding a new destination = a host `fstab` + compose
|
||||
> bind-mount change + redeploy. For the appliance model (single-purpose, admin-provisioned) this is
|
||||
> the right trade; a future "back up to a freshly-plugged removable drive" flow would need host-level
|
||||
> automount detection wired to the container, which is **deferred / not built**.
|
||||
|
||||
## Retention at the destination
|
||||
|
||||
**Keep last N + thinned dailies** (e.g. last 7 daily / last 4 weekly) — bounded disk use, and it
|
||||
@@ -155,6 +187,18 @@ timer + the manual route**. What landed:
|
||||
- **Komodo wiring.** `BACKUP_KEY` is a **per-booth Komodo secret** (`[[park_buzi_backup_key]]` in
|
||||
`komodo/resources.toml`; documented in `komodo/.env.komodo.example`), escrowed offsite alongside
|
||||
`EVENT_SIGNING_KEY`. It is the *only* backup env var — target + retention are in the DB.
|
||||
|
||||
> **Gotcha — compose `environment:` is an ALLOWLIST (cost a full booth-deploy session, 2026-06-29).**
|
||||
> Wiring `BACKUP_KEY` as a Komodo secret + Stack-env line is **necessary but not sufficient**:
|
||||
> `docker-compose.yml`'s `server.environment:` block only forwards the variables it *names*. The key
|
||||
> was wired everywhere (secret store, Stack env, `.env.example`, schema) but **never added to that
|
||||
> compose block**, so the container came up *without* it — `docker inspect ...Config.Env` showed
|
||||
> `JWT_SECRET`/`EVENT_SIGNING_KEY` present and `BACKUP_KEY` **absent (not empty)**, while the Backup
|
||||
> screen correctly reported "BACKUP_KEY missing". Diagnosis was muddied by chasing Komodo (secret
|
||||
> name, re-sync, destroy/redeploy, env-only-change-doesn't-recreate) before checking the compose
|
||||
> allowlist. **Lesson: a new server env var needs a line in `docker-compose.yml` `server.environment:`
|
||||
> too — that's the only place env reaches the container.** Fixed: `BACKUP_KEY: ${BACKUP_KEY:-}` next to
|
||||
> `EVENT_SIGNING_KEY`. Quick check on a booth: `docker inspect <server> --format '{{range .Config.Env}}{{println .}}{{end}}' | grep -i backup`.
|
||||
- **`server.ts`** — an **unref'd daily timer** (`backupService.runScheduled`), a **no-op until
|
||||
configured**, and **deliberately NOT run at startup** (a just-power-cut booth shouldn't write to a
|
||||
possibly-unmounted disk; the daily cadence + the manual button cover it).
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
type: concept
|
||||
tags: [parking, domain, booth, exit, payment, threat-model]
|
||||
sources: []
|
||||
updated: 2026-06-18
|
||||
updated: 2026-06-30
|
||||
status: open
|
||||
---
|
||||
|
||||
@@ -162,6 +162,22 @@ server-side in `reopenBarrier`: allow only when `subscription` OR (`paidAt != nu
|
||||
paidAt + graceExitMin`). A future reason-required *force exit* for genuine disputes (car already gone)
|
||||
would be a separately-audited path — see Open.
|
||||
|
||||
> **Open-barrier moved INTO the modal — the inline row button is gone (2026-06-30).** The audited
|
||||
> re-pulse was previously an inline button on the paid-in-grace Active Sessions *row*. It was removed:
|
||||
> clicking any row now opens the modal, which carries the Open-barrier action. Why: a paid-and-exited
|
||||
> session is `open=false`, so clicking its row used to dead-end on *"This session is already closed"* —
|
||||
> useless for the very case (paid, barrier didn't confirm) where the operator needs to re-pulse. The
|
||||
> modal now recognizes a **closed-within-grace** transient (`found && !open && withinGrace`) and renders
|
||||
> the session view + **Open barrier** instead of the dead-end notice. The server guard is unchanged
|
||||
> (`reopenBarrier` already handled the closed-but-in-grace case — the T-397815c0 fix above). The
|
||||
> Active Sessions list distinguishes these rows with a **live grace-remaining countdown** badge
|
||||
> (`exited · M:SS`, ticking each second off `graceExpiresAt`) instead of a static "exited" label.
|
||||
> Settled amounts now show the **actual sum paid** (new `SessionLookup.paidMinor`, summed across
|
||||
> payments) rather than a flat "PAID" badge. And a **fully-closed (grace-expired) session** is no longer
|
||||
> a pure dead-end: its modal shows a read-only **review view** — figures + paid amount + the entry/exit
|
||||
> [[entry-exit-points#camera-snapshots-evidence-not-a-gate|snapshot strip]] — so an operator can review
|
||||
> evidence for a car that just left (disputes/audits), with no pay/exit/open controls.
|
||||
|
||||
### Subscription occurrences in the booth (built 2026-06-18)
|
||||
|
||||
A subscriber's car shows in Active Sessions as a **subscription** session (badge "abonim"; labelled by
|
||||
|
||||
@@ -32,6 +32,9 @@ editable and drifts; the chain is the truth). Spaces-free = `capacity − occupa
|
||||
diverge from physical reality. The count is the *system's* occupancy; periodic ground-truth (a
|
||||
loop count, or the [[opencv-anpr-service|vision]] count) reconciles it — surfaced as an anomaly,
|
||||
not silently corrected.
|
||||
- **A DELIBERATE drift attack — the ticket swap:** a paid car let out on a fresh $0 ticket leaves its
|
||||
original ticket "inside" forever, inflating occupancy by phantom cars. Defended by
|
||||
[[plate-reconciliation]] (the exiting plate is already open under the original ticket → flag/hold).
|
||||
|
||||
## Reserved subscriber spots (admin toggle, built 2026-06-20)
|
||||
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
---
|
||||
type: concept
|
||||
tags: [parking, payment, pci, p2pe, pos, threat-model]
|
||||
sources: [parking-system-architecture]
|
||||
updated: 2026-07-01
|
||||
status: open
|
||||
---
|
||||
|
||||
# Card payments (P2PE POS) — disabled until a terminal is on-site
|
||||
|
||||
The app **models** a `card` tender end-to-end, but as of **2026-07-01 there is no card processor /
|
||||
POS terminal on any site**, so the card option is **disabled in the UI**. This page records why,
|
||||
what a future POS needs, and how to re-enable — so the gap isn't rediscovered as "why can't I take a
|
||||
card?".
|
||||
|
||||
## Current state — CASH ONLY (2026-07-01)
|
||||
|
||||
- The booth pay/exit modal and the subscription-sale form show **cash only**. The tender picker is
|
||||
**suppressed entirely** when there's nothing to choose (payment silently defaults to `cash`).
|
||||
- Nothing about the data model changed: `Tender = "cash" | "card"` still exists ([[technology-stack|
|
||||
shared]] `packages/shared`), the server/`payment` events, [[shift]] accounting, and
|
||||
[[reporting-analytics|reports]] all still understand `card` — this is **only a UI gate**, so any
|
||||
historical `card` events (or a future re-enable) stay coherent.
|
||||
- Flag: **`apps/web/src/lib/features.ts` → `CARD_PAYMENTS_ENABLED = false`**. Both tender pickers
|
||||
(`BoothPayModal.tsx`, `SubscriptionManager.tsx`) render card only when it is `true`.
|
||||
|
||||
**Why disable rather than leave it?** Offering "Card" with no terminal lets an operator record a card
|
||||
payment that **never actually cleared** — money that isn't in the drawer and didn't hit the bank —
|
||||
which silently corrupts the [[shift|till reconciliation]] and the [[reconciliation|financial audit]].
|
||||
On a system whose adversary is the [[threat-model|booth operator]], a tender the site can't fulfil is
|
||||
a fraud/error surface, not a convenience. Cash-only is the honest state until hardware exists.
|
||||
|
||||
## The constraint a POS must satisfy — PCI scope stays OUT of the app
|
||||
|
||||
This is a **standing architectural rule** ([[bom]], [[open-questions]] #3, [[parking-session]]):
|
||||
card capture goes through a **standalone, bank-certified P2PE (point-to-point encryption) terminal** —
|
||||
the application **must never see card data (PAN, track, CVV)**. The app only records that a payment's
|
||||
`tender` was `card`; the terminal does the capture, encryption, and settlement against the acquiring
|
||||
bank. This keeps the whole appliance **out of PCI-DSS scope**, which is a hard requirement (a booth PC
|
||||
in PCI scope is a non-starter).
|
||||
|
||||
The terminal model is **dictated by the acquiring bank** (not our choice) — verify local
|
||||
availability (Albania/EU) when the bank is chosen. See [[bom]] "Payment".
|
||||
|
||||
## Future POS device — what has to be configured (open)
|
||||
|
||||
When a terminal is procured, this is the outline (details TBD — flag on [[open-questions]] #3):
|
||||
|
||||
1. **Hardware**: a bank-certified standalone P2PE terminal beside the booth PC + the cash drawer.
|
||||
2. **Integration boundary**: decide how the app learns a card sale succeeded WITHOUT touching card
|
||||
data — options range from *manual* (operator runs the card on the terminal, then confirms in the
|
||||
app → a `card` `payment` event) to a *terminal-integration* (the app requests an amount, the
|
||||
terminal returns an approved/declined result over a local link). The manual path keeps PCI scope
|
||||
trivially out; an integration must preserve the same boundary (no PAN ever reaches the app).
|
||||
3. **Device model**: if integrated, the terminal becomes a [[device-registry|device adapter]] behind
|
||||
an interface (like reader/printer/relay) — a `payment-terminal` capability — so a hardware swap is
|
||||
a new adapter, nothing else. A *manual* terminal needs no adapter (it's off-system; the app just
|
||||
records the tender).
|
||||
4. **Reconciliation**: card takings must reconcile against the **terminal's/bank's** settlement
|
||||
report, separately from the cash drawer (card money never enters the drawer). [[shift]] Z-reports
|
||||
already split cash vs card totals — wire the card side to the terminal batch.
|
||||
|
||||
## Re-enabling
|
||||
|
||||
1. Provision + configure the terminal (per above).
|
||||
2. Flip `CARD_PAYMENTS_ENABLED = true` in `apps/web/src/lib/features.ts`. The tender pickers reappear.
|
||||
3. If integrated, add the `payment-terminal` adapter + wire the approved-result → `card` `payment`
|
||||
event. If manual, no code beyond the flag.
|
||||
4. Update this page (→ `status: settled`) and [[open-questions]] #3.
|
||||
|
||||
## Relates
|
||||
|
||||
- [[bom]] — the payment subsystem line (certified P2PE terminal + cash drawer, PCI-out-of-scope).
|
||||
- [[open-questions]] #3 — payment subsystem (manned booth P2PE vs unmanned pay station).
|
||||
- [[parking-session]] / [[shift]] — where `tender` is recorded and reconciled.
|
||||
- [[threat-model]] — why a tender the site can't fulfil is a fraud surface.
|
||||
@@ -2,7 +2,7 @@
|
||||
type: concept
|
||||
tags: [parking, architecture, devices, setup]
|
||||
sources: []
|
||||
updated: 2026-06-16
|
||||
updated: 2026-06-30
|
||||
---
|
||||
|
||||
# Entry / Exit Points (pool-of-spaces model)
|
||||
@@ -114,6 +114,21 @@ re-encode is **storage-only** — ANPR recognition runs on the **original full-r
|
||||
(downscaling hurts OCR). Fail-soft: a re-encode error stores the original, never drops the snapshot
|
||||
(`snapshot.ts` `encodeForStorage`).
|
||||
|
||||
> **Content-type bug — every legacy snapshot rendered blank (fixed 2026-06-30).** Symptom: *no*
|
||||
> snapshot showed in the booth modal. Root cause: some cameras (Hikvision) return
|
||||
> `Content-Type: image/jpeg; charset="UTF-8"` — a charset param on a binary body is **malformed**, and
|
||||
> browsers refuse to decode an `<img>` declared that way. Old capture code persisted that raw header
|
||||
> into `snapshots.content_type` (100 of 101 rows in the dev DB), and the serve route
|
||||
> (`GET /api/snapshots/:id`) re-emitted it **verbatim** → broken render for every legacy row. The
|
||||
> capture path was *already* hardened (`encodeForStorage` re-encodes to a clean `image/jpeg`; its
|
||||
> fail-soft branch calls `cleanType` to strip `; charset=…`), so NEW rows were fine — but the serve
|
||||
> route trusted the stored value. Fix: the route now also runs `cleanType(row.contentType)` on the way
|
||||
> out (a bare `image/jpeg`), which un-breaks all legacy rows with **no data migration**. Verified: a
|
||||
> previously-unrenderable 2560×1440 row now decodes in-browser. Lesson: **normalize a camera-supplied
|
||||
> content-type both on capture AND on serve** — a stored value from an untrusted device is itself input.
|
||||
> The stored `content_type` column could be backfilled to `image/jpeg` for cleanliness, but serving
|
||||
> normalizes so it isn't required.
|
||||
|
||||
**Retention (2026-06-28, resolves the old open question) — DISK-PRESSURE safety valve.** Snapshots
|
||||
are unsigned/advisory, so they prune freely. The day-to-day shrink is the re-encode above; pruning is
|
||||
a backstop that only fires under real disk pressure. A **daily** check (`snapshot-retention.ts`
|
||||
@@ -142,4 +157,5 @@ as "⚠ camera unreachable" tiles (see [[booth-console]]).
|
||||
|
||||
[[entry-exit-readers]] · [[device-events]] · [[parking-session]] · [[anti-passback]] ·
|
||||
[[append-only-event-chain]] · [[barrier-not-a-door]] · [[opencv-anpr-service]] ·
|
||||
[[dingtian-relay]] · [[first-run-setup]]
|
||||
[[dingtian-relay]] · [[first-run-setup]] · [[operator-issued-entry]] (mint when the button
|
||||
is broken) · [[plate-reconciliation]] (the entry snapshot's plate defends the exit)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
type: reference
|
||||
tags: [parking, dev-environment, workflow]
|
||||
sources: []
|
||||
updated: 2026-06-15
|
||||
updated: 2026-06-30
|
||||
---
|
||||
|
||||
# Local Dev Workflow
|
||||
@@ -54,3 +54,44 @@ Production uses an **nginx** reverse proxy (`deploy/nginx.conf`) for the same sa
|
||||
`ADMIN_USER=.. ADMIN_PASS=.. pnpm seed:admin`. Reset a password: add `FORCE=1`.
|
||||
- Hardware test scripts (UHPPOTE): `apps/server/scripts/uhppote-listen.mjs` (live events),
|
||||
`uhppote-relay.mjs` (guarded door-open). See [[uhppote-controller]].
|
||||
|
||||
## Database reset — training / demo only (2026-06-30)
|
||||
|
||||
A site is sometimes run live to **train** operators/admins on the real app; afterwards the demo data
|
||||
must go without leaving an obvious self-serve button (an operator must not be able to wipe history).
|
||||
So the reset is a **CLI script**, not UI: `packages/db/scripts/reset-db.mjs`, run via `pnpm db:reset`.
|
||||
|
||||
```bash
|
||||
RESET_ALLOWED=1 pnpm db:reset --financial # default DB = apps/server/parking.sqlite
|
||||
RESET_ALLOWED=1 DATABASE_URL=/path node packages/db/scripts/reset-db.mjs --all
|
||||
```
|
||||
|
||||
**Category flags** (combinable; ≥1 required) — grounded in which tables hold what:
|
||||
|
||||
| Flag | Wipes | Keeps |
|
||||
| --- | --- | --- |
|
||||
| `--financial` | `ledger_events` (entry/exit/payment/void/shift/cash/anomaly), `device_events`, `snapshots`, subscription **instances** + credentials/plates, `blocklist` | users, devices, config, tariffs, subscription **plans** |
|
||||
| `--config` | `site_config`, `devices`, `setup_state` (→ re-runs first-run setup), tariffs + versions, subscription plans | everything else |
|
||||
| `--users` | `users`, `roles`, `role_permissions`, auth `sessions` | everything else |
|
||||
| `--all` | every table (blank slate) | — |
|
||||
|
||||
> **⚠ `--financial`/`--all` TRUNCATE the append-only, signed [[append-only-event-chain|ledger]].**
|
||||
> That is the anti-fraud record; a *partial* delete would break the hash chain, so a financial reset
|
||||
> wipes the whole ledger back to empty (re-seeding starts a NEW chain under the **same**
|
||||
> `EVENT_SIGNING_KEY` — the key is **not** touched). This is the opposite of how the ledger is meant to
|
||||
> behave, hence the gates below. It is a **training/demo** tool; never point it at a live booth.
|
||||
|
||||
**Two safety gates ([[threat-model|operator-as-adversary]]):**
|
||||
1. **`RESET_ALLOWED=1`** env must be set — a real booth never sets it, so the command is inert in
|
||||
production even if typed.
|
||||
2. **Typed confirmation** of the DB filename (interactive). `--yes` skips it for CI/scripted training
|
||||
setup only.
|
||||
|
||||
Runs as a single transaction (all-or-nothing) + `VACUUM` to shrink the re-used demo DB. After
|
||||
`--users`/`--all` (users cleared), re-seed an admin: `pnpm seed:admin`. The `EVENT_SIGNING_KEY` and
|
||||
`BACKUP_KEY` are intentionally left alone (see [[backup-recovery]] on key custody).
|
||||
|
||||
> **On the BOOTH there is no `pnpm`** — only Docker containers. `pnpm db:reset` is the *dev* form;
|
||||
> on an appliance, run the same script via `docker exec` into the `server` container
|
||||
> (`node node_modules/@parking/db/scripts/reset-db.mjs …`, `DATABASE_URL=/data/parking.sqlite`).
|
||||
> Full booth procedure: [[appliance-provisioning]] §7d.
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
---
|
||||
type: concept
|
||||
tags: [parking, booth, entry, threat-model, anpr, presence]
|
||||
sources: []
|
||||
updated: 2026-07-01
|
||||
status: settled
|
||||
---
|
||||
|
||||
# Operator-issued entry (broken entry button)
|
||||
|
||||
When the physical entry button is broken, an operator can **issue an entry ticket** from the booth so
|
||||
a real car isn't blocked out of the lot. This hands the [[threat-model|operator (the adversary)]] a way
|
||||
to mint entries — so it is **flagged, presence-gated, and paired with an exit defense**
|
||||
([[plate-reconciliation]]). Built 2026-07-01. Companion to [[entry-exit-points]] (the entry flow it
|
||||
reuses) and [[capacity-occupancy]].
|
||||
|
||||
## Why give the operator this at all
|
||||
An operator *could* mint tickets to defraud — but a broken entry button otherwise **blocks the whole
|
||||
lot**, which is worse and more common. So the feature exists, and the fraud it enables is defended
|
||||
downstream (see the "ticket-swap" scenario in [[plate-reconciliation]]) rather than by withholding the
|
||||
capability.
|
||||
|
||||
## The three controls that make it safe
|
||||
|
||||
### 1. PRESENCE-GATED — a real car must be there (radar AND camera)
|
||||
The operator button obeys the **same rule as the physical button**: it is only active when **BOTH**
|
||||
presence conditions meet —
|
||||
- **radar/loop present** (a presence input is shorted at the entry barrier), AND
|
||||
- **camera confirms** a vehicle in the zone (the entry lane is "busy").
|
||||
|
||||
This ties every mint to a **real vehicle physically at the entry** — the operator can't pad occupancy
|
||||
with phantom tickets, and (crucially) it guarantees the entry snapshot captures a **plate**, which is
|
||||
what [[plate-reconciliation]] reads at exit. **No presence loop configured → the feature is
|
||||
unavailable** at that site (we require both; no weaker camera-only fallback).
|
||||
|
||||
**Enforced on BOTH sides.** The UI only enables the entry [[booth-console|BarrierLight]] as a clickable
|
||||
issue-control when `radar.entry && lanes.entry` (both true) and the operator holds `session:create`.
|
||||
The **server re-checks** current presence (`LaneStatus.snapshot().entry === true` AND the entry relay's
|
||||
guard `present === true`) and **refuses** otherwise — so a direct `POST /api/entry/issue` by the
|
||||
operator-adversary can't bypass a disabled button. A refused (no-presence) attempt signs an
|
||||
`anomaly` (`entry.issue.noPresence`) so probing the endpoint is itself in the tamper-evident record.
|
||||
|
||||
### 2. FLAGGED — every operator mint leaves a red-flag row
|
||||
The issued entry is a **real** `vehicle_entry` (so occupancy/tariff/exit all work), but:
|
||||
- `source: "manual"` + `operatorInitiated: true` + `operator` on the signed payload, AND
|
||||
- a **companion `anomaly`** (`entry.operatorIssued`) — mirroring the [[booth-exit-flow|barrier
|
||||
re-open]]: the operator-adversary path always leaves an explicit anomaly for [[reconciliation]].
|
||||
|
||||
### 3. Capacity OVERRIDE is allowed but recorded
|
||||
Unlike the physical button (which refuses transient entry when the lot is [[capacity-occupancy|full]]),
|
||||
the operator **can** issue over capacity — a broken button mustn't trap a legit car when the count is
|
||||
near/at the cap (and the count may itself be inflated by the very fraud this defends). But an over-cap
|
||||
mint stamps `lotFull: true` + the occupancy on the events, so the override is visible.
|
||||
|
||||
## Wiring
|
||||
- **Permission:** `session:create` (new; migration 0019 grants it to the default `operator` role;
|
||||
admin-revocable per role, so an admin can turn off an operator's ability to mint). Admin has it in code.
|
||||
- **Route:** `POST /api/entry/issue` — `session:create` + an **open shift** (a minted entry belongs to
|
||||
an accountable operator, like the money path).
|
||||
- **Server:** `EntryFlow.issueForOperator(operator, cameraBusy)`. The fraud-critical
|
||||
print → sign(vehicle_entry) → pulseOpen → snapshot → cache sequence is a **single shared
|
||||
`#issueTicket`** used by both the physical button and this path (no divergent copy).
|
||||
- **UI:** the entry `BarrierLight` becomes clickable (confirm → issue) only when presence + permission +
|
||||
shift are satisfied; the exit light stays a pure indicator.
|
||||
|
||||
## Relates
|
||||
- [[plate-reconciliation]] — the exit-side defense against the ticket-swap this capability enables.
|
||||
- [[entry-exit-points]] — the entry flow + snapshot/ANPR path reused here.
|
||||
- [[capacity-occupancy]] — why occupancy integrity matters (the swap fraud drifts it upward).
|
||||
- [[threat-model]] — the operator-adversary framing all three controls serve.
|
||||
@@ -148,6 +148,7 @@ follow this page and [[tariff]]; the decision is recorded in [[session-model]].
|
||||
`graceExitMin`). An operator `overrideMinor` covers lost-ticket/dispute (recorded as the charged
|
||||
amount + the quoted amount). Pay-on-foot: payment is decoupled from the exit lane. PCI scope stays
|
||||
out of the app — `tender` only records cash/card; card capture is the standalone P2PE terminal.
|
||||
(Card is currently **disabled in the UI** — no POS on-site yet; cash-only. See [[card-payments]].)
|
||||
- **The full transient loop now passes end to end** (verified): entry → quote → pay → exit opens,
|
||||
session closed, `verifyChain` ok.
|
||||
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
---
|
||||
type: concept
|
||||
tags: [parking, anpr, exit, threat-model, reconciliation, fraud]
|
||||
sources: []
|
||||
updated: 2026-07-01
|
||||
status: settled
|
||||
---
|
||||
|
||||
# Plate reconciliation at exit (ticket-swap defense)
|
||||
|
||||
Uses the ANPR **plate as an invariant** to catch a **ticket-swap fraud**: the car's plate is the same
|
||||
regardless of which ticket it holds, so if a car tries to exit on a ticket whose plate is **already
|
||||
inside under a different ticket**, something is wrong. Built 2026-07-01 alongside
|
||||
[[operator-issued-entry]] (the capability that makes the fraud easy). The [[threat-model|adversary is
|
||||
the operator]], but the same swap happens innocently (two people mix up tickets).
|
||||
|
||||
## The fraud (worked scenario)
|
||||
A lot with 1000 spots:
|
||||
1. Real car enters on ticket **1234** → ANPR records plate **AA123BB** at entry.
|
||||
2. Car comes to exit owing 10,000 ALL. Operator scans 1234, **pockets the cash, does NOT record the
|
||||
payment**.
|
||||
3. Operator **mints a fresh ticket 1237** (age ≈ 0 → owes ~0) and lets the car out on 1237.
|
||||
4. **1234 lingers "inside" forever** — a phantom car. Repeat → +100, +200 phantom cars; occupancy
|
||||
becomes meaningless and the operator skims cash while the books look internally consistent (a ticket
|
||||
was "paid" — 1237 for 0; a ticket is "inside" — 1234).
|
||||
|
||||
The plate is what the swap can't hide: entry-1234 = AA123BB, and the car exiting on 1237 **is** AA123BB.
|
||||
|
||||
## The check
|
||||
`ExitFlow.#reconcilePlateAtExit(exitingId)`:
|
||||
1. Resolve the **exiting** ticket's plate (its own exit read, else its entry read).
|
||||
2. Enumerate all **currently-open** sessions (projection cache) and their **entry** plates
|
||||
(`platesForIdentities`).
|
||||
3. If the exiting plate **exactly** matches an open session under a **DIFFERENT** identity → **swap
|
||||
suspected**, returning `{ plate, otherIdentity, otherEnteredAt }`.
|
||||
|
||||
**EXACT, HIGH-CONFIDENCE only.** Both the exiting read AND the matched session's entry read must be
|
||||
≥ `PLATE_MATCH_MIN_CONFIDENCE` (0.85), normalized exact string match. No fuzzy/edit-distance matching.
|
||||
Rationale: ANPR is **advisory and misses** (G3H snapshot 503s, camera-side push failures, no-plate
|
||||
reads — see the ANPR memory notes). A fuzzy/low-confidence read must **never** be the reason a car is
|
||||
held — so a shaky read simply doesn't trigger the warning (fails toward not-annoying).
|
||||
|
||||
## What happens on a suspected swap
|
||||
|
||||
### Booth path (operator-mediated) — FLAG LOUDLY + require an override
|
||||
Exit fails-OPEN for safety and a plate is **never the sole gate**, so we do **not** silently hard-block
|
||||
(that would trap a legit car on a bad read). Instead:
|
||||
- `exitForBooth` returns status **`swap_suspected`** with the detail; the barrier does **not** open.
|
||||
- A **`anomaly` (`exit.plateSwapSuspected`)** is signed immediately — so even if the operator walks
|
||||
away, the suspicion is in the tamper-evident record.
|
||||
- The pay/exit modal shows a **prominent red warning** ("Plate AA123BB is already inside under ticket
|
||||
1234, entered 3h ago") with an explicit **"Override & release"** action.
|
||||
- On override, `exitForBooth(id, { override, operator })` proceeds AND signs an attributed
|
||||
**`anomaly` (`exit.plateSwapOverride`)** — the override is itself a signed, named decision.
|
||||
|
||||
### Reader path (automated, no operator) — LOG-ONLY, fail-open
|
||||
At an unmanned exit lane there's no one to make the override decision, and exit fails-open, so the
|
||||
reader path **signs the `exit.plateSwapSuspected` anomaly and still lets the car out**. The anomaly is
|
||||
the control there (a manager reconciles it later). This is a smaller surface — the fraud scenario is
|
||||
booth-mediated.
|
||||
|
||||
## Why this is the right shape
|
||||
- **Occupancy stops drifting.** A swap can no longer silently strand ticket 1234 "inside" — the exit
|
||||
attempt on 1237 surfaces it. Directly serves [[capacity-occupancy]] integrity.
|
||||
- **The signed anomaly is the audit signal** a manager reconciles ([[reconciliation]]) — consistent
|
||||
with "the fraud control lives in the signed chain + human review, not a real-time hard gate".
|
||||
- **Advisory-not-a-gate is preserved both ways:** a plate never *opens* a barrier by itself, and now a
|
||||
plate never *traps* a car by itself either (flag + override, never a silent hard block).
|
||||
|
||||
## Relates
|
||||
- [[operator-issued-entry]] — the capability whose fraud this defends.
|
||||
- [[capacity-occupancy]] — occupancy integrity the swap attacks.
|
||||
- [[reconciliation]] — where the signed anomalies are ultimately settled.
|
||||
- [[entry-exit-points]] — the ANPR-on-snapshot path that records the plates compared here.
|
||||
- [[threat-model]] — operator-as-adversary.
|
||||
+52
-15
@@ -2,7 +2,7 @@
|
||||
type: concept
|
||||
tags: [parking, domain, business, shifts, anti-fraud]
|
||||
sources: []
|
||||
updated: 2026-06-20
|
||||
updated: 2026-07-01
|
||||
status: open
|
||||
---
|
||||
|
||||
@@ -152,21 +152,20 @@ lives in the **event type**, not the sign of an amount:
|
||||
a **positive magnitude**. Voucher no. `AR-NNNN`.
|
||||
- **`cash_out`** (*Mandat Pagese* — a **disbursement / pay-OUT**): cash leaves the drawer.
|
||||
`amountMinor` positive; the fold subtracts it. Voucher no. `PA-NNNN`.
|
||||
- Payload: `{ amountMinor (positive), reason, currency, operator (who raised), authorizedBy (admin who
|
||||
signed off), voucherNo }`. Each prints a **slip** (Albanian, like every operator-facing paper).
|
||||
- **Authorization changed: operator-RAISED, admin-AUTHORIZED.** Previously admin-only. Now any holder
|
||||
of `shift:create` (operator-grade) may *raise* a voucher, but the route only commits it if
|
||||
`authorizedBy` is a real **admin** (`shift:cash`) who **re-enters their password**. This keeps the
|
||||
float control — an operator can't move the float alone — while letting them do the paperwork at the
|
||||
booth. (`POST /api/cash-voucher`, guarded `shift:create` + server-side authorizer password+grade check.)
|
||||
- Payload: `{ amountMinor (positive), reason, currency, operator (who recorded), voucherNo }`. Each
|
||||
prints a **slip** (Albanian, like every operator-facing paper).
|
||||
- **Authorization model (redesigned 2026-07-01): operator RECORDS freely → admin REVIEWS after.** See
|
||||
"Drawer review" below. (Superseded the 2026-06-20 *operator-raised / admin-authorized-at-creation*
|
||||
scheme, where the operator typed an admin's password inline — that blocked the operator until an
|
||||
admin stood at the booth, and it lived on `/shifts`.)
|
||||
- **Legacy `cash_movement` stays valid.** The type is retained; historical signed events on the live
|
||||
chain still verify and still fold into the drawer (signed-± as before). Only *new* movements use the
|
||||
voucher pair. The append-only chain is never rewritten.
|
||||
- The existing `payment` events already add cash to the drawer (cash tender only; card never touches
|
||||
the drawer).
|
||||
|
||||
**The math — drawer is a fold over the chain BY TIME, not by operator** (a drawer voucher is the
|
||||
admin's authorization, not the shift operator's takings, so it can't key off `identity`):
|
||||
**The math — drawer is a fold over the chain BY TIME, not by operator** (whoever holds the drawer at a
|
||||
given instant is accountable for its running balance, regardless of who recorded each movement):
|
||||
|
||||
```
|
||||
expectedDrawer(at) = Σ cash payments (tender=cash) up to `at`
|
||||
@@ -175,6 +174,9 @@ expectedDrawer(at) = Σ cash payments (tender=cash) up to `at`
|
||||
+ Σ cash_movement amounts (legacy, signed) up to `at`
|
||||
```
|
||||
|
||||
**`cash_review` is NOT in this fold.** A review decision never moves cash — so it's excluded from the
|
||||
drawer math by construction (see "Drawer review").
|
||||
|
||||
A shift's **opening float = expectedDrawer(shiftStart)** — i.e. everything that happened to the drawer
|
||||
before this shift's start mark. It is **auto-inherited from the chain** (no operator entry). The
|
||||
first shift ever opens at **0**; the admin's load makes it 5000.
|
||||
@@ -201,6 +203,40 @@ Card payments are excluded from the drawer (they settle to the bank, not the til
|
||||
is **expected**, not counted — the optional blind-count enhancement below would record the *variance*
|
||||
against it.
|
||||
|
||||
## Drawer review — operator records freely, admin reviews after (2026-07-01)
|
||||
|
||||
The drawer feature was reworked from **synchronous admin-authorization-at-creation** (an admin had to
|
||||
type their password at the booth for every receipt/disbursement) to **operator-records → admin-reviews-
|
||||
after**. This removes the friction while keeping accountability.
|
||||
|
||||
- **Record (`drawer:create`).** An operator RECORDS a `cash_in`/`cash_out` freely — no admin sign-off
|
||||
at creation. It **counts in the drawer immediately** (the cash physically moved). The permission is
|
||||
**per-role and admin-revocable** in the Roles UI: an admin can turn off an operator's ability to
|
||||
record at all. `POST /api/drawer/movement`.
|
||||
- **Review (`drawer:review`, admin-grade).** Each movement is `pending` until an admin **authorizes**
|
||||
or **denies** it. The decision is a new **signed `cash_review`** event `{ refId, decision, reviewedBy,
|
||||
note? }` — append-only, so the decision itself is auditable. `GET /api/drawer/movements` (operators
|
||||
see only their own; reviewers see all + a status filter = the pending queue) and
|
||||
`POST /api/drawer/review`. One decision per movement (re-review rejected).
|
||||
- **A denial is a FLAG, not a reversal — this is the load-bearing design choice.** Denying a movement
|
||||
does **NOT** append a reversing cash event and does **NOT** touch the drawer balance. It's a judgment
|
||||
about the operator ("this disbursement wasn't genuine"); crediting/debiting them is the **admin's/
|
||||
accountant's job, outside this system**. We deliberately do **not** build accounting here — just a
|
||||
simple running balance.
|
||||
|
||||
> **Why deny ≠ reversal (the cross-shift argument).** The drawer folds BY TIME across shifts. If a
|
||||
> denial appended a reversal, it would land in whatever shift is open **when the admin clicks** — which
|
||||
> can be a **later** operator's shift, after the reviewed shift already closed and Z-reported. That
|
||||
> would make operator 2 accountable for correcting operator 1's mistake. By making review a pure flag,
|
||||
> the correction never enters the ledger, so it **cannot leak into the next operator's drawer**. The
|
||||
> next operator simply inherits the real physical balance (which they count at shift open) and carries
|
||||
> on. This is verified by a regression test (`shift-service.test.ts`: op1 disburses → closes → op2
|
||||
> inherits → admin denies → op2's drawer unchanged).
|
||||
|
||||
- **Home.** The feature moved OFF `/shifts` to its own top-level **`/drawer`** route (operator: record
|
||||
+ own movements; admin: the review queue + all movements). `/shifts` is now just open/close +
|
||||
Z-report. Server: `routes/drawer.ts` (lifted out of `routes/shift.ts`); UI: `DrawerManager.tsx`.
|
||||
|
||||
## Where the fraud control actually lives
|
||||
|
||||
Deliberately **not** in a shift-close ceremony. Because every payment is a **signed event in the
|
||||
@@ -217,11 +253,12 @@ reconciles the signed Z-report against the actual drawer and the bank/POS batch
|
||||
|
||||
## Open
|
||||
|
||||
- **Drawer carry-over (decided 2026-06-18, built; vouchers re-modelled 2026-06-20):** opening float
|
||||
auto-inherits the prior shift's expected drawer; drawer movements are now the **`cash_in` /
|
||||
`cash_out` voucher pair** (Mandat Arkëtimi / Mandat Pagese — direction is the type, operator-raised
|
||||
& admin-authorized), superseding the signed-± `cash_movement` (kept for history). Z-report reports
|
||||
the full drawer picture. See the Drawer balance section above.
|
||||
- **Drawer carry-over (decided 2026-06-18, built; vouchers re-modelled 2026-06-20; review reworked
|
||||
2026-07-01):** opening float auto-inherits the prior shift's expected drawer; drawer movements are the
|
||||
**`cash_in` / `cash_out` voucher pair** (Mandat Arkëtimi / Mandat Pagese — direction is the type),
|
||||
superseding the signed-± `cash_movement` (kept for history). As of 2026-07-01 an operator RECORDS them
|
||||
freely and an admin REVIEWS after (signed `cash_review`, a flag not a reversal) — see "Drawer review".
|
||||
Z-report reports the full drawer picture. See the Drawer balance section above.
|
||||
- **Shift ↔ session boundary:** a vehicle may enter under one shift and pay under another — the
|
||||
Z-report sums by **payment time** (when cash/card was taken), which is the operator who handled
|
||||
the money. Confirm that's the intended accountability (vs. by entry).
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
type: reference
|
||||
tags: [parking, deployment, appliance, hardening, runbook, offline-first]
|
||||
sources: []
|
||||
updated: 2026-06-27
|
||||
updated: 2026-06-30
|
||||
status: settled
|
||||
---
|
||||
|
||||
@@ -103,9 +103,67 @@ sudo reboot
|
||||
- Still prompts = PCR mismatch; type the passphrase (NOT locked out), then retry with
|
||||
`--tpm2-pcrs=0`. The `password` slot + `crypttab.bak` make this fully reversible.
|
||||
|
||||
> **Re-seal runbook:** a BIOS update / Secure Boot change alters PCR 7 → the TPM refuses → boot
|
||||
> falls back to the passphrase prompt (not a brick). After such a change, re-run step 4's
|
||||
> `systemd-cryptenroll --wipe-slot=tpm2 --tpm2-device=auto --tpm2-pcrs=7 /dev/sda3` to re-bind.
|
||||
> **Re-seal runbook:** a BIOS update / Secure Boot change / **UEFI dbx (revocation list) update**
|
||||
> alters PCR 7 → the TPM refuses → boot falls back to the passphrase prompt (not a brick). After
|
||||
> such a change, re-run step 4's
|
||||
> `systemd-cryptenroll --wipe-slot=tpm2 --tpm2-device=auto --tpm2-pcrs=7 /dev/sda3` to re-bind, then
|
||||
> reboot to confirm unattended unlock returned.
|
||||
|
||||
### 4a. Firmware / UEFI dbx updates break PCR 7 — and are an OPERATOR threat (VERIFIED 2026-06-30)
|
||||
|
||||
The PCR-7 re-seal hazard above is **not** a rare event — the most common trigger is a **UEFI `dbx`
|
||||
(Secure Boot revocation database) update**, and it bit the real `park-buzi` booth on 2026-06-28:
|
||||
|
||||
- **What `dbx` is:** the Secure Boot blocklist of known-vulnerable bootloader/shim hashes
|
||||
(vendor = Microsoft). It is delivered by **`fwupd`/LVFS — a channel SEPARATE from APT** (the GNOME
|
||||
"Firmware Updater", which on Ubuntu is the **`firmware-updater` snap**, surfaces it). `apt list
|
||||
--upgradable` being clean does NOT mean a firmware/dbx update isn't pending.
|
||||
- **The GRUB panic (root cause):** applying a *new* dbx against a *stale* GRUB/shim revokes the
|
||||
installed bootloader → Secure Boot refuses to load it → **unbootable / GRUB "panic"**. The fix is
|
||||
ordering: `apt full-upgrade` (current `grub-efi`/`shim-signed`) FIRST, *then* dbx. A fresh reinstall
|
||||
ships a current GRUB, so reinstalling recovers it.
|
||||
- **It moves PCR 7:** even with a current GRUB, applying dbx changes the Secure-Boot-policy
|
||||
measurement → the TPM (slot 1) refuses to release the key → next boot **drops to the slot-0
|
||||
passphrase prompt**. Recover with the re-seal runbook above. VERIFIED: on `park-buzi` the dbx
|
||||
update went through, the box rebooted to a passphrase prompt, the slot-0 passphrase unlocked it,
|
||||
and `systemd-cryptenroll --wipe-slot=tpm2 … --tpm2-pcrs=7` restored silent auto-unlock.
|
||||
|
||||
**Threat-model consequence ([[threat-model]]: the operator is the adversary).** A firmware/dbx update
|
||||
on a TPM-sealed booth → the booth won't boot unattended and needs the slot-0 passphrase. So the
|
||||
operator must be unable to *trigger* a firmware update, and must never hold the passphrase. Lock it
|
||||
down (DONE on `park-buzi` 2026-06-30):
|
||||
|
||||
```bash
|
||||
# 1. Kill the firmware-update DAEMON (the GUI "Update" button then fails with no daemon):
|
||||
sudo systemctl mask fwupd.service fwupd-refresh.timer
|
||||
systemctl is-enabled fwupd.service fwupd-refresh.timer # → masked / masked (persists across reboot)
|
||||
|
||||
# 2. Remove the operator-facing GUI so the screen is never even presented (Ubuntu = a snap):
|
||||
sudo snap remove firmware-updater
|
||||
snap list | grep -i firmware # → no output (re-check: seeded snaps can re-install)
|
||||
```
|
||||
|
||||
Plus: the **BIOS admin password** (§1) must gate *entering setup / changing settings* (a
|
||||
supervisor/admin password, not just a boot password) so the operator can't disable Secure Boot or
|
||||
change boot order — either of which also breaks the seal. And the **slot-0 passphrase stays
|
||||
off-machine / escrowed** (same custody as `EVENT_SIGNING_KEY` / `BACKUP_KEY`); it is an admin-only
|
||||
recovery secret, used on-site during a maintenance window, never known to operators.
|
||||
|
||||
> **Net:** firmware/dbx updates become an **admin-only, on-site, deliberate** action. The booth is
|
||||
> unattended-bootable only while the firmware/Secure-Boot state is frozen — that is the security
|
||||
> property, not a bug. Legitimate firmware maintenance now costs: physical presence + the slot-0
|
||||
> passphrase + a PCR-7 re-enroll.
|
||||
|
||||
> **⚠ Gotcha — `cryptsetup … --test-passphrase` SILENTLY passes via the TPM.** Before any
|
||||
> firmware/dbx change, you must *prove a typed passphrase still unlocks the disk* (the TPM-independent
|
||||
> safety net). But `sudo cryptsetup open --test-passphrase /dev/sda3` with a TPM2 token enrolled will
|
||||
> succeed **without prompting** — the TPM auto-answers (it unlocks the tpm2 *slot*, e.g. slot 1), a
|
||||
> FALSE positive that proves nothing about a human-typeable key. Force a real test with
|
||||
> `--disable-external-tokens` (→ `No usable token is available.` then it prompts; success on slot 0 =
|
||||
> the passphrase genuinely works):
|
||||
> ```bash
|
||||
> sudo cryptsetup open --test-passphrase /dev/sda3 --disable-external-tokens --verbose
|
||||
> ```
|
||||
|
||||
## 5. GRUB password — EDIT-ONLY (VERIFIED 2026-06-23)
|
||||
|
||||
@@ -293,6 +351,42 @@ ENV=prod ./booth.sh up
|
||||
`booth.sh` runs from wherever it sits next to the compose files (the booth deploys them flat, e.g.
|
||||
`/opt/parking_systems/`). See [[container-deployment]].
|
||||
|
||||
### 7d. Reset the DB for TRAINING/DEMO — `docker exec`, not `pnpm` (2026-06-30)
|
||||
|
||||
A site is sometimes run live to **train** operators/admins on the real app; afterwards the demo data
|
||||
must go without leaving an obvious self-serve button (the [[threat-model|operator must not be able to
|
||||
wipe history]]). The reset is a **CLI script** (`packages/db/scripts/reset-db.mjs`), and on the booth
|
||||
there is **no `pnpm`** — only the running containers. So run it the same way as the seed-admin step in
|
||||
§7b: **`docker exec` into the `server` container**, where the script ships inside the deploy bundle at
|
||||
`node_modules/@parking/db/scripts/reset-db.mjs` (the same place the boot migrator lives — see the
|
||||
entrypoint). `DATABASE_URL` in-container is **`/data/parking.sqlite`** (the `parking-data` volume).
|
||||
|
||||
```bash
|
||||
# On the booth (or via Komodo's terminal on the server container). Category flags:
|
||||
# --financial ledger (entry/exit/payment/void/shift/cash/anomaly) + device_events + snapshots +
|
||||
# subscription INSTANCES/credentials/plates + blocklist. KEEPS users/devices/config/
|
||||
# tariffs/subscription PLANS.
|
||||
# --config site_config, devices, setup_state (re-runs first-run setup), tariffs + versions, plans.
|
||||
# --users users, roles, role_permissions, auth sessions. --all every table.
|
||||
docker exec -it \
|
||||
-e RESET_ALLOWED=1 \
|
||||
-e DATABASE_URL=/data/parking.sqlite \
|
||||
park-buzi-server-1 \
|
||||
node node_modules/@parking/db/scripts/reset-db.mjs --financial
|
||||
```
|
||||
|
||||
> **⚠ `--financial`/`--all` TRUNCATE the append-only, signed [[append-only-event-chain|ledger]]** —
|
||||
> the anti-fraud record. A *partial* delete would break the hash chain, so a financial reset wipes the
|
||||
> whole ledger back to empty (re-seeding starts a NEW chain under the **same** `EVENT_SIGNING_KEY`/
|
||||
> `BACKUP_KEY` — the keys are **not** touched). This is the opposite of how the ledger is meant to
|
||||
> behave, hence the two gates: it refuses unless **`RESET_ALLOWED=1`** is set (a real booth never sets
|
||||
> it) **and** you type the DB filename to confirm (`parking.sqlite`; `--yes` skips that for scripted
|
||||
> setup only). It is a **training/demo** tool — never run on a production booth's data.
|
||||
|
||||
After `--users`/`--all` (users cleared), re-seed the first admin exactly as in §7b
|
||||
(`docker exec … node scripts/seed-admin.mjs`) so someone can log back in. For dev (where `pnpm` exists)
|
||||
the same script is `pnpm db:reset --financial` — see [[local-dev-workflow]].
|
||||
|
||||
### Healthy startup + web-access
|
||||
|
||||
Healthy logs: vision `Initialized LicensePlateDetector …` with NO "Downloading" (baked weights),
|
||||
@@ -317,6 +411,16 @@ works; the desktop app is a separate workstream.
|
||||
6. GRUB password MUST be **edit-only** (`--unrestricted` on entries) or it prompts on EVERY boot →
|
||||
breaks unattended reboot. Verify `grep -c unrestricted /boot/grub/grub.cfg` ≥1 before rebooting.
|
||||
|
||||
### Firmware / dbx gotchas (2026-06-30, §4a)
|
||||
|
||||
12. **UEFI dbx ships via `fwupd`/LVFS, NOT APT.** `apt list --upgradable` clean ≠ no firmware update
|
||||
pending. A new dbx vs a stale GRUB → revoked bootloader → **unbootable / GRUB panic** (`apt
|
||||
full-upgrade` first, then dbx). And dbx **moves PCR 7** → breaks TPM auto-unlock → passphrase
|
||||
prompt → re-seal (§4 runbook). Mask `fwupd` + remove the `firmware-updater` snap so the operator
|
||||
can't trigger it.
|
||||
13. `cryptsetup … --test-passphrase` **silently passes via the TPM token** (false safety signal). Use
|
||||
`--disable-external-tokens` to actually force a typed-passphrase test before any firmware change.
|
||||
|
||||
### Komodo deploy gotchas (2026-06-27)
|
||||
|
||||
7. Periphery `core_address` is **Core's reverse-proxy URL** (`https://komodo.infra.msai.al`), NOT
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
---
|
||||
type: decision
|
||||
tags: [parking, hardening, threat-model, luks, tpm, secure-boot, grub, firmware, offline-first]
|
||||
sources: [parking-system-architecture]
|
||||
updated: 2026-06-30
|
||||
status: settled
|
||||
---
|
||||
|
||||
# Disk & OS hardening (booth appliance)
|
||||
|
||||
The host-level defences that raise the cost of **offline, physical tamper** of a booth PC: full-disk
|
||||
encryption, TPM-sealed auto-unlock, Secure Boot, a GRUB edit-lock, an unprivileged operator account,
|
||||
and locking firmware updates away from the operator. This page is the **rationale (the *why*)**; the
|
||||
step-by-step verified commands live in the [[appliance-provisioning]] runbook (§3–5c, §4a). Settled
|
||||
across the first real provisioning (2026-06-23) and the firmware-update episode (2026-06-30).
|
||||
|
||||
> ⚠ **This is the secondary control, not the main event.** The load-bearing anti-fraud mechanism is
|
||||
> [[reconciliation]] over the [[append-only-event-chain|signed event chain]]. Disk/OS hardening
|
||||
> defends the [[threat-model|outsider-with-the-box]] and raises the cost of offline tamper — it does
|
||||
> **not** replace reconciliation, and it cannot stop a *legitimate, logged-in* operator from
|
||||
> committing fraud through the app (that's what the signed ledger + reconciliation are for).
|
||||
|
||||
## What it defends against
|
||||
|
||||
The appliance sits on-site, physically reachable by the [[threat-model|booth operator (the primary
|
||||
adversary)]] and by an outsider who can open the case. Without host hardening, either can:
|
||||
|
||||
- **Pull the SSD** and read/alter the SQLite ledger offline → FDE (LUKS) defeats this.
|
||||
- **Boot a live USB** to mount and edit the disk → Secure Boot + TPM-sealing (PCR 7) defeats booting
|
||||
a tampered/unsigned kernel; FDE keeps the data unreadable.
|
||||
- **Edit the GRUB cmdline** (`init=/bin/bash`) for a no-login root shell on the *decrypted* disk →
|
||||
the GRUB edit-lock defeats this (the TPM seal does NOT — see below).
|
||||
- **Escalate from the operator login** (sudo, `docker`/`lxd` groups) → the unprivileged-operator
|
||||
model defeats this.
|
||||
|
||||
## The five controls and why each is shaped the way it is
|
||||
|
||||
| Control | Choice | Why this shape (the load-bearing nuance) |
|
||||
| --- | --- | --- |
|
||||
| **FDE** | LUKS, **passphrase** at install (not the installer's "hardware-backed" option) | The 26.04 installer's automated FDE profiler fails on this firmware (`PCR_UNUSABLE`/`dbt`). Passphrase LUKS + a *manual* TPM seal sidesteps it and lets us pick PCRs. The passphrase slot is the **permanent recovery key**. |
|
||||
| **TPM auto-unlock** | `systemd-cryptenroll`, **PCR 7 only** | Unattended reboot is a hard requirement (no operator types a passphrase). PCR 7 = Secure-Boot policy: catches the attack that matters (disabling Secure Boot) **without** churning on kernel/GRUB updates (PCRs 4/8/9 → would drop to passphrase every boot). **Keep BOTH slots** — slot 0 password (recovery), slot 1 tpm2 (auto-unlock); the TPM is never the only key. |
|
||||
| **Secure Boot** | Enabled, **Deployed Mode**, stock MS keys | Ubuntu's signed shim needs stock `db`. Reaching the installer with Secure Boot ON is itself proof the MS third-party UEFI CA is trusted. |
|
||||
| **GRUB edit-lock** | password, **edit-only** (`--unrestricted`) | Closes the `init=/bin/bash` root-shell hole. **The PCR-7 TPM seal does NOT cover this** — editing the cmdline doesn't change PCR 7, so the TPM still releases the key and the attacker lands on the decrypted disk. Edit-only so the box still boots **unattended** (password required only to *edit* entries). |
|
||||
| **Operator account** | unprivileged, auto-login; separate **admin**+sudo | The operator is the adversary; their OS identity must not be able to escalate. Strip `sudo`, and the latent-escalation groups `lxd`/`docker` (both root-equivalent) + `lpadmin`. Admin is a distinct, no-auto-login identity. |
|
||||
|
||||
See [[tpm]] for the TPM-2.0 analysis (why PCR-only sealing, bus-sniff limits, TPM-vs-[[atecc608|ATECC608]]).
|
||||
|
||||
## Firmware / UEFI dbx updates — a hardening surface AND an operator threat
|
||||
|
||||
Settled 2026-06-30 after a real incident on `park-buzi`. This is the non-obvious one, because it
|
||||
turns a routine "security update" into a booth-availability risk:
|
||||
|
||||
- **UEFI `dbx`** (the Secure Boot revocation database) and BIOS firmware are delivered by
|
||||
**`fwupd`/LVFS — a channel SEPARATE from APT** (Ubuntu's GNOME "Firmware Updater" = the
|
||||
`firmware-updater` snap). A clean `apt list --upgradable` does NOT mean no firmware update is pending.
|
||||
- **It can brick boot:** a new dbx against a *stale* GRUB/shim **revokes the installed bootloader** →
|
||||
Secure Boot refuses it → unbootable / GRUB panic. Correct order: `apt full-upgrade` (current
|
||||
`grub-efi`/`shim-signed`) **first**, then dbx.
|
||||
- **It breaks auto-unlock:** even with a current GRUB, applying dbx **moves PCR 7** → the TPM refuses
|
||||
the LUKS key → next boot falls back to the slot-0 passphrase prompt (not a brick). Recover with the
|
||||
PCR-7 re-seal runbook ([[appliance-provisioning]] §4/§4a).
|
||||
- **Threat-model consequence:** a firmware/dbx update makes the booth need a passphrase to boot
|
||||
unattended — so the **operator must be unable to trigger one, and must never hold the passphrase.**
|
||||
Lock it down: `systemctl mask fwupd.service fwupd-refresh.timer`, `snap remove firmware-updater`,
|
||||
a **BIOS admin password** that gates *entering setup* (so the operator can't disable Secure Boot /
|
||||
change boot order), and the **slot-0 passphrase escrowed off-machine** (same custody as
|
||||
`EVENT_SIGNING_KEY` / `BACKUP_KEY`). Firmware maintenance becomes **admin-only, on-site, deliberate**.
|
||||
|
||||
> The booth is unattended-bootable **only while the firmware / Secure-Boot state is frozen** — that is
|
||||
> the security property, not a bug. The cost is that legitimate firmware maintenance now needs physical
|
||||
> presence + the slot-0 passphrase + a PCR-7 re-enroll.
|
||||
|
||||
> **⚠ Verification trap:** `cryptsetup … --test-passphrase` **silently passes via the TPM token** (a
|
||||
> false safety signal). Before any firmware change, prove a *typed* passphrase still unlocks the disk
|
||||
> with `--disable-external-tokens` — see [[appliance-provisioning]] §4a.
|
||||
|
||||
## Where the commands live
|
||||
|
||||
This page is the rationale. The **verified, run-on-real-hardware commands** are in
|
||||
[[appliance-provisioning]]: §1 BIOS, §2 Secure-Boot live-USB check, §3 encrypted install (the `dbt`
|
||||
workaround), §4 TPM seal (PCR 7) + re-seal runbook, **§4a firmware/dbx lockdown**, §5 GRUB edit-lock,
|
||||
§5c admin-vs-operator accounts. Komodo Periphery is folded into the same hardened surface as a
|
||||
root-capable remote agent — see [[fleet-deployment-komodo]] (bind to the NetBird interface only).
|
||||
|
||||
## Relates
|
||||
|
||||
- [[appliance-provisioning]] — the runbook (commands); this page is its *why*.
|
||||
- [[tpm]] — TPM 2.0 analysis (sealing, PCR choice, limits, vs ATECC608).
|
||||
- [[threat-model]] — the operator-adversary framing this hardening serves.
|
||||
- [[reconciliation]] / [[append-only-event-chain]] — the **primary** anti-fraud control this
|
||||
complements, never replaces.
|
||||
- [[fleet-deployment-komodo]] — Periphery as part of the trusted computing base.
|
||||
@@ -19,7 +19,8 @@ procurement. (See [[parking-system-architecture]] §10.)
|
||||
[[fail-state-safety]].
|
||||
3. **Payment subsystem.** Manned booth (P2PE terminal + cash drawer) vs unmanned pay station;
|
||||
confirm **PCI scope is kept out of the application** via a standalone certified terminal
|
||||
(see [[bom]]).
|
||||
(see [[bom]]). **No POS on any site yet (2026-07-01)** → card tender is **disabled in the UI**
|
||||
(cash-only); the future-POS requirements + re-enable path are in [[card-payments]].
|
||||
4. **Reconciliation channel.** Even if "offline," establish *some* periodic path (USB, hotspot,
|
||||
manager visit) to reconcile the signed log against an external authority — the real anti-fraud
|
||||
control. See [[reconciliation]].
|
||||
|
||||
+6
-3
@@ -55,7 +55,7 @@ Counts: 4 sources · 19 entities · 46 concepts · 7 decision records.
|
||||
## Concepts — integrity & anti-fraud
|
||||
- [[append-only-event-chain]] — append-only + hash chain + ATECC608 signing = unforgeable log.
|
||||
- [[reconciliation]] — the real anti-fraud control; what remote sync actually is.
|
||||
- [[disk-os-hardening]] — LUKS/GRUB/Secure Boot; worthwhile but not the main event.
|
||||
- [[disk-os-hardening]] — the *why* of host hardening: LUKS FDE + TPM-sealed auto-unlock (PCR 7) + Secure Boot + GRUB edit-lock + unprivileged operator + firmware/dbx lockdown; secondary control (reconciliation is the main event). Commands → [[appliance-provisioning]].
|
||||
- [[backup-recovery]] — admin-driven encrypted full-DB backup (local/SMB/SFTP) + DR; signing key escrowed & decoupled from TPM so the ledger survives total hardware loss; restore is admin-only.
|
||||
|
||||
## Concepts — device architecture & safety
|
||||
@@ -88,7 +88,10 @@ Counts: 4 sources · 19 entities · 46 concepts · 7 decision records.
|
||||
- [[tariff]] — fee model; pure, data-driven, offline; pay-on-foot adds a walk-back grace window.
|
||||
- [[tariff-time-tiers]] — BUILT (V2 tariff): happy-hour/off-peak/weekend/seasonal + vehicle category + flat rate via wall-clock windowed cards; tz frozen per version.
|
||||
- [[booth-exit-flow]] — manned booth: pay → voucher (self-exit later) or immediate exit; active sessions; audited barrier re-open.
|
||||
- [[shift]] — manned-only accountability period; explicit Start/End; End → signed + printed Z-report; drawer float carries across shifts.
|
||||
- [[operator-issued-entry]] — operator mints an entry ticket when the physical button is broken; presence-gated (radar AND camera, both sides), flagged (source=manual + operatorInitiated + anomaly), capacity-override allowed; needs `session:create` (2026-07-01).
|
||||
- [[plate-reconciliation]] — ANPR plate-as-invariant catches the ticket-swap fraud (paid car let out on a fresh $0 ticket, original lingers "inside"); exact/high-conf match vs open sessions; booth = flag + operator override, reader = log-only fail-open (2026-07-01).
|
||||
- [[shift]] — manned-only accountability period; explicit Start/End; End → signed + printed Z-report; drawer float carries across shifts. Drawer cash movements (operator records, admin reviews via signed cash_review — a flag, not a reversal) live at the /drawer route (2026-07-01).
|
||||
- [[card-payments]] — card tender DISABLED (no P2PE POS on-site yet, 2026-07-01); cash-only UI gate (`CARD_PAYMENTS_ENABLED`); future POS keeps PCI scope out of the app; how to re-enable.
|
||||
- [[capacity-occupancy]] — live count = open sessions; refuse entry + FULL sign when full (soft policy); exit never blocked.
|
||||
- [[site-metadata]] — optional park identity (name, operator, VAT, address, contact) in site_config; feeds the ticket header.
|
||||
- [[valet-overcapacity]] — "full" is soft: operator may valet-accept over capacity (keys handed over, custody). Manned, deferred.
|
||||
@@ -111,7 +114,7 @@ Counts: 4 sources · 19 entities · 46 concepts · 7 decision records.
|
||||
- [[i18n]] — Albanian default + English; per-user server-stored language preference (users.language), loaded on login; tickets stay Albanian.
|
||||
|
||||
## Dev environment (reference)
|
||||
- [[local-dev-workflow]] — running the stack locally; setup, the dev-hang gotchas, seed:admin.
|
||||
- [[local-dev-workflow]] — running the stack locally; setup, the dev-hang gotchas, seed:admin, the gated `db:reset` training/demo tool.
|
||||
- [[wsl-dev-networking]] — WSL2 NAT blocks device broadcast; use mirrored mode + the gotchas after.
|
||||
|
||||
## Decisions
|
||||
|
||||
+163
@@ -1976,3 +1976,166 @@ TAG=stage-84f00db; komodo/README.md promotion section + per-booth secret list no
|
||||
fleet-deployment-komodo open-item resolved + new 'Promotion tiers' table; container-deployment tag list +
|
||||
:stage. Per-booth secrets (jwt/event_signing/backup) must pre-exist in Core for park-buzi; migrations run at
|
||||
boot so a promotion auto-migrates the staging ledger (where a bad migration is caught before prod).
|
||||
|
||||
## [2026-06-29] fix+doc | First park-buzi backup deploy: BACKUP_KEY allowlist + container mount constraint
|
||||
First real-world staging deploy surfaced two backup gotchas, both now in [[backup-recovery]]:
|
||||
(1) BACKUP_KEY was wired as a Komodo secret + Stack-env line but NEVER added to docker-compose.yml's
|
||||
server `environment:` ALLOWLIST — so the container came up without it (docker inspect: JWT/SIGN present,
|
||||
BACKUP_KEY absent-not-empty; Backup screen "BACKUP_KEY missing"). A whole session was lost chasing Komodo
|
||||
(secret name, re-sync, destroy/redeploy, env-only-change-doesn't-force-recreate) before checking the
|
||||
compose allowlist. Fix: `BACKUP_KEY: ${BACKUP_KEY:-}` next to EVENT_SIGNING_KEY (commit on dev 8f32d90,
|
||||
promoted dev→stage merge d0b609e → built stage-d0b609e). Lesson recorded: a new server env var ALSO needs a
|
||||
line in the compose environment block.
|
||||
(2) The backup target must be a HOST path BIND-MOUNTED into the container — a casually-plugged USB at
|
||||
/run/media/<user>/<UUID> is invisible inside the container, so Test target rightly says "does not exist".
|
||||
Provisioning = fstab-by-UUID a stable host path (e.g. /mnt/backup) + bind-mount it in prod compose + set
|
||||
the in-container path as the UI target. Acknowledged limitation: backups are NOT operator-flexible (no
|
||||
plug-a-USB-and-go); adding a destination is an admin host+compose change. Partly a feature vs the
|
||||
operator-adversary threat model (operator can't redirect backups to a removable stick). USB-automount-to-
|
||||
container flow deferred/not built.
|
||||
|
||||
## [2026-06-30] note | UEFI dbx / firmware update vs TPM-sealed LUKS — GRUB panic + PCR-7 re-seal + operator lockdown (park-buzi)
|
||||
|
||||
Real-world on park-buzi. The GNOME "Firmware Updater" (Ubuntu = the `firmware-updater` snap) surfaced a
|
||||
pending UEFI dbx (Secure Boot revocation DB) update, vendor Microsoft, delivered by fwupd/LVFS — a channel
|
||||
SEPARATE from APT (apt list --upgradable was clean except 2 cups packages). 2026-06-28 a dbx update against a
|
||||
stale GRUB revoked the bootloader → GRUB panic / unbootable → user reinstalled Ubuntu 26.04 LTS (resolute) to
|
||||
recover (fresh install ships a current GRUB). Correct order is `apt full-upgrade` (current grub-efi/shim-signed)
|
||||
FIRST, then dbx.
|
||||
|
||||
Even with a current GRUB, applying dbx moves PCR 7 (Secure-Boot-policy measurement) → the TPM (slot 1) refuses
|
||||
to release the LUKS key → next boot drops to the slot-0 passphrase prompt. VERIFIED end-to-end: proved the
|
||||
slot-0 typed passphrase first, applied dbx, rebooted to a passphrase prompt, unlocked with slot 0, re-enrolled
|
||||
`systemd-cryptenroll --wipe-slot=tpm2 --tpm2-device=auto --tpm2-pcrs=7 /dev/sda3` → silent auto-unlock restored.
|
||||
|
||||
Gotcha: `cryptsetup open --test-passphrase /dev/sda3` SILENTLY passes via the TPM token (auto-unlocks the tpm2
|
||||
slot without prompting) — a false safety signal. Force a real typed-passphrase test with
|
||||
`--disable-external-tokens` (→ "No usable token is available." then prompts; success on slot 0 proves it).
|
||||
|
||||
Threat-model lockdown (operator is the adversary): a firmware/dbx update makes the booth need the passphrase to
|
||||
boot unattended, so operators must not be able to trigger one and must never hold the passphrase. Applied on
|
||||
park-buzi: `systemctl mask fwupd.service fwupd-refresh.timer` (→ masked/masked, persists), `snap remove
|
||||
firmware-updater` (remove the GUI; re-check, seeded snaps can re-install), BIOS admin password gates setup
|
||||
entry, slot-0 passphrase stays escrowed off-machine. Firmware updates are now admin-only/on-site/deliberate.
|
||||
Recorded in appliance-provisioning.md §4 re-seal runbook + new §4a + gotchas #12/#13.
|
||||
|
||||
## [2026-06-30] note | Created disk-os-hardening.md (resolved a long-standing orphan)
|
||||
|
||||
`[[disk-os-hardening]]` was referenced from ~18 pages (overview, threat-model, tpm, fleet-deployment,
|
||||
appliance-provisioning, backup-recovery, index, …) but never written — a dangling wikilink. Wrote it as
|
||||
the *rationale* page (the why): the five host controls (LUKS FDE, TPM-sealed PCR-7 auto-unlock, Secure
|
||||
Boot Deployed, GRUB edit-lock, unprivileged-operator) + the firmware/dbx lockdown (§4a cross-ref), each
|
||||
with its load-bearing nuance, plus the standing caveat that this is the SECONDARY control —
|
||||
reconciliation over the signed chain is the main anti-fraud event. Commands stay in appliance-provisioning
|
||||
(the how); this page points there. Updated the index.md line accordingly.
|
||||
|
||||
## [2026-06-30] fix | Snapshot content-type bug — every legacy image rendered blank
|
||||
|
||||
Symptom: no snapshot showed in the booth modal. Root cause: Hikvision-style cameras return
|
||||
`Content-Type: image/jpeg; charset="UTF-8"` (a charset param on a binary body = malformed; browsers
|
||||
refuse to decode an <img> declared that way). Old capture code persisted that raw header into
|
||||
snapshots.content_type (100 of 101 dev-DB rows); the serve route GET /api/snapshots/:id re-emitted it
|
||||
verbatim → broken render for every legacy row. Capture was already hardened (encodeForStorage →
|
||||
clean image/jpeg, fail-soft cleanType), but the serve route trusted the stored value. Fix: route now
|
||||
runs cleanType(row.contentType) on the way OUT too → bare image/jpeg, un-breaks all legacy rows with
|
||||
NO data migration. Verified via Playwright: a previously-unrenderable 2560×1440 row now decodes
|
||||
in-browser; clean + malformed rows both load. Exported cleanType from snapshot.ts + unit tests.
|
||||
Recorded in entry-exit-points.md. Lesson: normalize a device-supplied content-type on capture AND on
|
||||
serve (a stored value from an untrusted camera is itself input).
|
||||
|
||||
## [2026-06-30] feat | Booth Active-Sessions + pay/exit modal rework
|
||||
|
||||
(1) The inline "Open barrier" button on paid-in-grace Active-Session ROWS was removed; the audited
|
||||
re-pulse now lives only in the modal. Reason: a paid+exited session is open=false, so clicking its
|
||||
row dead-ended on "already closed" — useless for the exact case (paid, barrier unconfirmed) that
|
||||
needs a re-pulse. The modal now recognizes closed-within-grace (found && !open && withinGrace) and
|
||||
shows the session view + Open barrier. Server reopenBarrier guard unchanged (already handled the
|
||||
closed-in-grace case — the T-397815c0 fix). (2) Active-Sessions rows show a LIVE grace-remaining
|
||||
countdown badge (exited · M:SS, 1s tick off graceExpiresAt) instead of a static label. (3) Settled
|
||||
sessions show the ACTUAL sum paid (new SessionLookup.paidMinor, summed across payment events) not a
|
||||
flat "PAID". (4) A fully-closed (grace-expired) session's modal is no longer a dead-end: it shows a
|
||||
read-only review view (figures + paid amount + entry/exit snapshot strip) for dispute/audit review,
|
||||
with no pay/exit/open controls. i18n sq+en parity kept; web build/tests green. Recorded in
|
||||
booth-exit-flow.md.
|
||||
|
||||
## [2026-06-30] feat | DB reset CLI for training/demo (packages/db/scripts/reset-db.mjs)
|
||||
|
||||
A site is sometimes run live to train operators/admins; afterwards the demo data must go WITHOUT an
|
||||
obvious self-serve button (operator must not wipe history). So: a CLI script `pnpm db:reset`, not UI.
|
||||
Category flags grounded in the table map — --financial (ledger/telemetry/snapshots/subscription
|
||||
instances/blocklist; keeps users/devices/config/tariffs/plans), --config, --users, --all. Because
|
||||
shifts/cash/payments all live as event types INSIDE the hash-chained ledger_events, "financial" =
|
||||
truncate the whole signed ledger back to empty (re-seed starts a new chain under the SAME
|
||||
EVENT_SIGNING_KEY — key untouched). Two safety gates (decided with user): RESET_ALLOWED=1 env (real
|
||||
booths never set it) + typed DB-filename confirmation (--yes skips for CI). Single txn + VACUUM;
|
||||
re-seed admin after --users/--all. On the BOOTH there is no pnpm — only containers — so it runs via
|
||||
`docker exec` into the server container (node node_modules/@parking/db/scripts/reset-db.mjs,
|
||||
DATABASE_URL=/data/parking.sqlite); the script ships in the deploy bundle next to the boot migrator
|
||||
(@parking/db has no `files` allowlist → whole pkg copied). Verified on throwaway dev-DB copies (both
|
||||
gates refuse correctly; each flag wipes/keeps the right tables; real dev DB never touched). Recorded
|
||||
in local-dev-workflow.md + appliance-provisioning.md §7d.
|
||||
|
||||
## [2026-07-01] feat | Card tender DISABLED until a P2PE POS is on-site (cash-only)
|
||||
|
||||
No card processor / POS terminal on any site yet, so offering "Card" would let an operator record a
|
||||
card payment that never cleared → corrupts till reconciliation ([[threat-model]] surface). Disabled
|
||||
the card option in the UI: new apps/web/src/lib/features.ts → CARD_PAYMENTS_ENABLED=false gates both
|
||||
tender pickers (BoothPayModal.tsx, SubscriptionManager.tsx); with card off there's nothing to choose,
|
||||
so the tender row is suppressed entirely and payment silently defaults to cash. UI-only gate — the
|
||||
Tender="cash"|"card" type, payment events, shift accounting, and reports still understand card (so
|
||||
historical card events + a future re-enable stay coherent). Verified via Playwright: an unpaid-ticket
|
||||
modal shows Total + "Pay + open barrier" with NO tender/cash/card row. Re-enable = flip the flag once
|
||||
a bank-certified P2PE terminal is provisioned (PCI scope stays out of the app — the terminal captures
|
||||
card data, not the app). New page concepts/card-payments.md documents current state + future-POS
|
||||
device requirements + re-enable path; linked from index, parking-session, open-questions #3.
|
||||
|
||||
## [2026-07-01] feat | Drawer redesign — operator records freely, admin reviews after; moved to /drawer
|
||||
|
||||
Reworked drawer cash movements from synchronous admin-authorization-at-creation (operator typed an
|
||||
admin's password inline at the booth for every receipt/disbursement) to operator-records → admin-
|
||||
reviews-after. An operator with drawer:create RECORDS a cash_in/cash_out freely; it counts in the
|
||||
drawer immediately. An admin with drawer:review AUTHORIZES/DENIES it after via a new signed cash_review
|
||||
event { refId, decision, reviewedBy, note? }. THE LOAD-BEARING CHOICE (settled with user): a denial is
|
||||
a FLAG, not a reversal — it never appends reversing cash and never touches the drawer balance (the
|
||||
correction is the admin's/accountant's job outside the app; we are NOT building accounting). This kills
|
||||
the cross-shift-leak problem the user raised: a denial that lands after the reviewed shift closed can't
|
||||
pollute the next operator's inherited drawer, because it moves no cash. New `drawer` resource +
|
||||
drawer:create (per-role revocable) / drawer:review permissions; migration 0018 grants operator
|
||||
drawer:create. Feature moved OFF the polluted /shifts route to a top-level /drawer (operator: record +
|
||||
own; admin: review queue + all). New routes/drawer.ts (lifted from routes/shift.ts, retired the
|
||||
authorizer-password gate; kept shift:cash for its other job = admin-sees-all-shifts scope),
|
||||
DrawerManager.tsx, drawer.* i18n (sq+en). Verified: full monorepo build/lint/test green (225 server
|
||||
tests incl. the op1-denied → op2-drawer-unchanged regression); Playwright end-to-end on /drawer
|
||||
(record disbursement → pending → authorize → status flips, ledger shows cash_out + cash_review with no
|
||||
authorizedBy). Recorded in shift.md "Drawer review".
|
||||
|
||||
## [2026-07-01] feat | Operator-issued entry + exit plate-swap reconciliation (one anti-fraud design)
|
||||
|
||||
Two halves of one design. (A) When the physical entry button is broken, an operator can ISSUE an entry
|
||||
ticket so a real car isn't blocked out of the lot — but this hands the operator-adversary a mint, so
|
||||
it's (1) PRESENCE-GATED exactly like the physical button (radar/loop present AND camera busy = a real
|
||||
car; enforced BOTH sides, server re-checks so a direct POST can't bypass a disabled button; no presence
|
||||
loop → feature unavailable; a no-presence attempt signs an entry.issue.noPresence anomaly), (2) FLAGGED
|
||||
(vehicle_entry source=manual + operatorInitiated + operator, PLUS a companion entry.operatorIssued
|
||||
anomaly), (3) capacity-OVERRIDE allowed but stamped lotFull (a broken button mustn't trap a legit car).
|
||||
New session:create permission (migration 0019 → operator role; admin-revocable), POST /api/entry/issue
|
||||
(open-shift gated), EntryFlow.issueForOperator; the fraud-critical print→sign→open→snapshot sequence
|
||||
factored into one shared #issueTicket (button + operator). UI: the entry BarrierLight becomes a
|
||||
clickable issue-control when presence+permission+shift meet (confirm → issue).
|
||||
|
||||
(B) Plate-swap fraud (user's scenario): operator scans exiting ticket 1234 (owes 10000), pockets cash
|
||||
WITHOUT recording payment, mints fresh 1237 (owes ~0), lets the car out on 1237 → 1234 lingers "inside"
|
||||
forever, occupancy drifts up by phantom cars. Defense = ANPR plate as invariant: the car's plate is the
|
||||
same either way. ExitFlow.#reconcilePlateAtExit compares the exiting plate against all OPEN sessions'
|
||||
entry plates — EXACT, HIGH-CONFIDENCE only (≥0.85; a fuzzy/low read never gates, ANPR is advisory). On
|
||||
a match under a DIFFERENT ticket: BOOTH path returns swap_suspected + signs exit.plateSwapSuspected
|
||||
anomaly + the pay/exit modal shows a red warning with "Override & release" (override signs an attributed
|
||||
exit.plateSwapOverride) — flag+override, never a silent hard block (exit fails-open, plate never the
|
||||
sole gate). READER path (no operator) = log-only anomaly + fail-open (user's call). Extended
|
||||
BoothExitResult + /api/exit (override param), boothExit client returns a structured swap result.
|
||||
|
||||
Verified: full monorepo build/lint/test green (229 server tests incl. 4 new: hold-on-swap,
|
||||
override-releases-with-attribution, low-confidence-no-warning, own-plate-no-warning). New wiki pages
|
||||
operator-issued-entry.md + plate-reconciliation.md; cross-linked from entry-exit-points,
|
||||
capacity-occupancy, index. Preserves "a plate never OPENS a barrier alone — and now never TRAPS a car
|
||||
alone either."
|
||||
|
||||
Reference in New Issue
Block a user