import type { FastifyInstance } from "fastify"; import type { Db } from "@parking/db"; import type { TillId } from "@parking/shared"; import { requireAuth, requirePermission, roleHasPermissions } from "../auth.js"; import { parseTill, requireTill, tillsReadableBy } from "../modules.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 on a till. // Guard = the till's `cash` (booth drawer:create, // wash carwash:cash). // - GET /api/drawer/movements: list with review status, over the tills the role may // read (own movements); reviewers (drawer:review) see all // tills + all operators and can filter status. // - POST /api/drawer/review : admin authorize/deny a movement. (drawer:review) // - GET /api/drawer/balance : a till's physical balance NOW (guard = the till's read). // 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. // TILLS: a movement names the drawer it moved in/out of (`till`, default booth); each // desk's cash is guarded by that desk's own permissions (venue-modules.md §"Permissions // matrix"). 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; /** Which drawer (default: the booth). */ till?: 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; /** Filter to one till; absent = every till the role may read (reviewers: every till). */ till?: string; } export async function drawerRoutes(app: FastifyInstance, db: Db, shift: ShiftService): Promise { const reviewGuard = requirePermission("drawer:review"); // Operator RECORDS a movement — freely, no authorizer. It counts in the drawer at once. app.post<{ Body: MovementBody }>("/api/drawer/movement", { preHandler: requireTill(db, "cash", "body") }, 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, till: req.till!, }); } 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 on // the tills they may read; a reviewer sees ALL and may filter by status (the pending // review queue). app.get<{ Querystring: MovementsQuery }>("/api/drawer/movements", { preHandler: requireAuth }, async (req, reply) => { const canReview = roleHasPermissions(req.user.roleId, ["drawer:review"]); const readable = tillsReadableBy(db, req.user.roleId); if (!canReview && readable.length === 0) return reply.code(403).send({ error: "forbidden" }); const q = req.query ?? {}; const status = canReview && ["pending", "authorized", "denied"].includes(q.status ?? "") ? q.status : undefined; let tills: TillId[] | undefined = canReview ? undefined : readable; if (q.till?.trim()) { const parsed = parseTill(db, q.till.trim()); if (!parsed) return reply.code(400).send({ error: "unknown till", code: "bad_till" }); if (!canReview && !readable.includes(parsed)) { return reply.code(403).send({ error: `your role cannot see the ${parsed} till`, code: "till_forbidden", till: parsed }); } tills = [parsed]; } const operator = canReview ? undefined : req.user.username; const movements = tills ? tills.flatMap((till) => shift.movementsWithStatus({ operator, status, till })).sort((a, b) => (a.at < b.at ? 1 : a.at > b.at ? -1 : 0)) : shift.movementsWithStatus({ operator, status }); return { movements, scope: canReview ? "all" : "self" }; }); // A till's physical drawer balance now. Same visibility as the open shift's X-report // (the till's read guard) — a drawer is a shared till, not per-operator data. app.get("/api/drawer/balance", { preHandler: requireTill(db, "read", "query") }, async (req) => ({ till: req.till!, ...shift.drawerBalance(req.till!), })); // 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 }); } }); }