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) // - GET /api/drawer/balance : the physical drawer balance NOW (cash (shift:read) // payments + vouchers over the whole chain — the // amount that carries across shifts). // 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" }; }); // The physical drawer balance now. Same visibility as the open shift's X-report // (shift:read) — the drawer is a single site-wide till, not per-operator data. app.get("/api/drawer/balance", { preHandler: readGuard }, async () => shift.drawerBalance()); // 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 }); } }); }