diff --git a/apps/server/src/entry-flow.ts b/apps/server/src/entry-flow.ts index a818c60..c64a877 100644 --- a/apps/server/src/entry-flow.ts +++ b/apps/server/src/entry-flow.ts @@ -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). diff --git a/apps/server/src/exit-flow.test.ts b/apps/server/src/exit-flow.test.ts index bc01b5e..3ca93c0 100644 --- a/apps/server/src/exit-flow.test.ts +++ b/apps/server/src/exit-flow.test.ts @@ -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 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); + }); +}); diff --git a/apps/server/src/exit-flow.ts b/apps/server/src/exit-flow.ts index 24f6bd3..ee0f5fa 100644 --- a/apps/server/src/exit-flow.ts +++ b/apps/server/src/exit-flow.ts @@ -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 { + async exitForBooth(identity: string, opts?: { override?: boolean; operator?: string }): Promise { 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 diff --git a/apps/server/src/pay-station.ts b/apps/server/src/pay-station.ts index e8d87a3..901b046 100644 --- a/apps/server/src/pay-station.ts +++ b/apps/server/src/pay-station.ts @@ -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, diff --git a/apps/server/src/routes/drawer.ts b/apps/server/src/routes/drawer.ts new file mode 100644 index 0000000..e420eeb --- /dev/null +++ b/apps/server/src/routes/drawer.ts @@ -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 { + 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 }); + } + }); +} diff --git a/apps/server/src/routes/entry.ts b/apps/server/src/routes/entry.ts new file mode 100644 index 0000000..da40d77 --- /dev/null +++ b/apps/server/src/routes/entry.ts @@ -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 { + 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; + }); +} diff --git a/apps/server/src/routes/pay.ts b/apps/server/src/routes/pay.ts index c7adc85..2489b02 100644 --- a/apps/server/src/routes/pay.ts +++ b/apps/server/src/routes/pay.ts @@ -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); }, ); diff --git a/apps/server/src/routes/shift.ts b/apps/server/src/routes/shift.ts index 1e6f5fc..9b0c428 100644 --- a/apps/server/src/routes/shift.ts +++ b/apps/server/src/routes/shift.ts @@ -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 { +export async function shiftRoutes(app: FastifyInstance, shift: ShiftService): Promise { // 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 { diff --git a/apps/server/src/routes/snapshots.ts b/apps/server/src/routes/snapshots.ts index ec413cc..b545a3d 100644 --- a/apps/server/src/routes/snapshots.ts +++ b/apps/server/src/routes/snapshots.ts @@ -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 { 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); }, diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 385ddbb..44a7b89 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -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 { 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"); diff --git a/apps/server/src/shift-service.ts b/apps/server/src/shift-service.ts index 83df760..dde17f3 100644 --- a/apps/server/src/shift-service.ts +++ b/apps/server/src/shift-service.ts @@ -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(); + 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 { @@ -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 }); diff --git a/apps/server/src/snapshot.test.ts b/apps/server/src/snapshot.test.ts index 81e5630..d7a4f65 100644 --- a/apps/server/src/snapshot.test.ts +++ b/apps/server/src/snapshot.test.ts @@ -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"); + }); +}); diff --git a/apps/server/src/snapshot.ts b/apps/server/src/snapshot.ts index a708ed3..bd07572 100644 --- a/apps/server/src/snapshot.ts +++ b/apps/server/src/snapshot.ts @@ -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"; } diff --git a/apps/web/src/ActiveSessions.tsx b/apps/web/src/ActiveSessions.tsx index 8621c49..55999b5 100644 --- a/apps/web/src/ActiveSessions.tsx +++ b/apps/web/src/ActiveSessions.tsx @@ -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 ( void : t("booth.noMatch")} ) : ( - // 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). @@ -129,31 +103,43 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void - {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 ( 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")} > - ); diff --git a/apps/web/src/BoothPayModal.tsx b/apps/web/src/BoothPayModal.tsx index 34a658b..310d44c 100644 --- a/apps/web/src/BoothPayModal.tsx +++ b/apps/web/src/BoothPayModal.tsx @@ -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 )} - {s && s.found && !s.open && ( -
- {t("pay.alreadyClosed", { time: formatTime(s.exitedAt) })} -
+ {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. + <> +
+ {t("pay.alreadyClosed", { time: formatTime(s.exitedAt) })} +
+ +
+ + + + {alreadyPaid && s.paidMinor != null && s.paidCurrency && ( + + )} +
+ + + )} - {s && s.found && s.open && ( + {s && s.found && (s.open || closedWithinGrace) && ( <> {/* Session figures */}
- + {/* Closed-within-grace shows the recorded EXIT; an open session shows now. */} +
@@ -322,7 +377,16 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose amount is the TOP-UP delta, not the whole stay. */}
- {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")} {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")}
@@ -361,12 +428,22 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose )} + {/* 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 && ( +
+ {t("pay.closedWithinGraceHint")} +
+ )} + {/* Snapshots */} {/* 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 && (
{t("pay.tender")} {(["cash", "card"] as const).map((tn) => ( @@ -382,8 +459,9 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
)} - {/* 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 && (
{t("booth.colPlate")} {t("booth.colEntry")} {t("booth.colElapsed")}
{s.subscription ? ( ★ {s.subscriptionHolder ?? t("subs.unnamed")} ) : ( - s.identity + + {s.identity} + {closedInGrace && ( + + {graceLeft ? t("booth.exitedGraceLeft", { time: graceLeft }) : t("booth.exitedGrace")} + + )} + )} @@ -170,28 +156,8 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void {formatRelativeDateTime(s.enteredAt, t)} - {formatDuration(s.enteredAt, new Date().toISOString())} - - {canReopen && ( - - )} - {msg && ( - - {msg.text} - - )} + {/* Freeze the elapsed at the recorded exit for a closed-in-grace row. */} + {formatDuration(s.enteredAt, (closedInGrace ? s.exitedAt : null) ?? new Date().toISOString())}
+ + + + + + + {canReview && } + + {canReview && + + + {movements.map((m) => ( + void qc.invalidateQueries({ queryKey: ["drawer"] })} /> + ))} + +
{t("drawer.colWhen")}{t("drawer.colType")}{t("drawer.colAmount")}{t("drawer.colReason")}{t("drawer.colOperator")}{t("drawer.colStatus")}} +
+ )} + + +
+ + ); +} + +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 ( + +
+
+ setAmount(e.target.value)} + placeholder={t("drawer.amount")} + inputMode="decimal" + /> + setReason(e.target.value)} + placeholder={t("drawer.reasonPlaceholder")} + /> +
+
{t("drawer.recordHint")}
+ {msg && ( +
{msg.text}
+ )} +
+ + +
+
+
+ ); +} + +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 ( + + {formatRelativeDateTime(m.at, t)} + + + {m.type === "cash_in" ? t("drawer.mandatArketimi") : t("drawer.mandatPagese")} + + {m.voucherNo && {m.voucherNo}} + + + {money(signed, m.currency)} + + {m.reason || "—"} + {canReview && {m.operator}} + + + {m.status !== "pending" && m.reviewedBy && ( +
+ {m.reviewedBy} + {m.reviewNote ? ` · ${m.reviewNote}` : ""} +
+ )} + + {canReview && ( + + {m.status === "pending" ? ( +
+
+ + +
+ {noteOpen && ( + setNote(e.target.value)} + placeholder={t("drawer.denyNotePlaceholder")} + /> + )} + {review.isError && {(review.error as Error).message}} +
+ ) : null} + + )} + + ); +} diff --git a/apps/web/src/ShiftsHistory.tsx b/apps/web/src/ShiftsHistory.tsx index 4fff06b..81828c3 100644 --- a/apps/web/src/ShiftsHistory.tsx +++ b/apps/web/src/ShiftsHistory.tsx @@ -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("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
{t("shifts.payments")} {s.paymentCount} {money(s.cashTotalMinor, cur)} - {money(s.cardTotalMinor, cur)} + {CARD_PAYMENTS_ENABLED && {money(s.cardTotalMinor, cur)}} {money(s.expectedDrawerMinor, cur)}
@@ -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); + const [modal, setModal] = useState(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(null); @@ -311,7 +308,6 @@ function ShiftActivityLog({ {isCurrent && isMine && canManage && ( - {canVoucher && } )} @@ -325,7 +321,7 @@ function ShiftActivityLog({
-
+ {CARD_PAYMENTS_ENABLED &&
}
@@ -340,7 +336,6 @@ function ShiftActivityLog({ {detailEvent && setDetailEvent(null)} />} {modal === "end" && setModal(null)} onDone={onChanged} />} - {modal === "voucher" && setModal(null)} onDone={onChanged} />} {modal === "takings" && setModal(null)} />} ); @@ -385,7 +380,7 @@ function EndShiftModal({ shift, onClose, onDone }: { shift: ShiftSummary; onClos
-
+ {CARD_PAYMENTS_ENABLED &&
}
@@ -411,7 +406,7 @@ function EndShiftModal({ shift, onClose, onDone }: { shift: ShiftSummary; onClos
-
+ {CARD_PAYMENTS_ENABLED &&
} {/* Drawer math made explicit: opening cash + cash taken = expected drawer. */}
@@ -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(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 ( - -
-
- setAmount(e.target.value)} placeholder={t("shift.amount")} inputMode="decimal" /> - setReason(e.target.value)} placeholder={t("shift.reasonPlaceholder")} /> -
-
- setAuthName(e.target.value)} placeholder={t("shift.authName")} autoComplete="off" /> - setAuthPassword(e.target.value)} placeholder={t("shift.authPassword")} autoComplete="off" /> -
-
{t("shift.voucherHint")}
- {msg &&
{msg}
} -
- - - -
-
-
- ); -} - 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 }) {
-
+ {CARD_PAYMENTS_ENABLED &&
}
diff --git a/apps/web/src/SubscriptionManager.tsx b/apps/web/src/SubscriptionManager.tsx index ac170a2..d7cde0e 100644 --- a/apps/web/src/SubscriptionManager.tsx +++ b/apps/web/src/SubscriptionManager.tsx @@ -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 && ( <> diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index a7e30be..6a57731 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -30,7 +30,7 @@ export async function apiFetch(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(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; @@ -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, ) { super(message); } @@ -1019,15 +1022,38 @@ export async function fetchShiftReport(): Promise { return (await apiFetch("/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 { - 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 { + 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, diff --git a/apps/web/src/lib/features.ts b/apps/web/src/lib/features.ts new file mode 100644 index 0000000..a913827 --- /dev/null +++ b/apps/web/src/lib/features.ts @@ -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; diff --git a/apps/web/src/lib/format.ts b/apps/web/src/lib/format.ts index 18fedf6..5170c23 100644 --- a/apps/web/src/lib/format.ts +++ b/apps/web/src/lib/format.ts @@ -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 "—"; diff --git a/apps/web/src/lib/i18n/en.ts b/apps/web/src/lib/i18n/en.ts index d3e2e02..61ae09d 100644 --- a/apps/web/src/lib/i18n/en.ts +++ b/apps/web/src/lib/i18n/en.ts @@ -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", diff --git a/apps/web/src/lib/i18n/sq.ts b/apps/web/src/lib/i18n/sq.ts index 2b78256..a571331 100644 --- a/apps/web/src/lib/i18n/sq.ts +++ b/apps/web/src/lib/i18n/sq.ts @@ -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}}.", diff --git a/apps/web/src/router.tsx b/apps/web/src/router.tsx index fc8a6e3..384d1b0 100644 --- a/apps/web/src/router.tsx +++ b/apps/web/src/router.tsx @@ -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({
- + {CARD_PAYMENTS_ENABLED && } {/* Drawer math made explicit: opening float + cash taken = expected drawer. */} @@ -430,6 +432,11 @@ function RootLayout() {