c5ed3f1308
/drawer was record + review only: no current balance, no sight of the open shift's incomings, no daily activity, no shift history. Rebuilt as a hub: - Drawer now: the till's running balance (new GET /api/drawer/balance, shift:read — exposes the service's existing drawerBalance(); the drawer is one site-wide till, same exposure the X-report already had) with the open shift's X-report breakdown alongside (float + takings + vouchers = expected = balance) and a "This shift: ±X" figure (expected − opening float — the shift's own contribution vs what it inherited). - Today's cash activity: every cash payment + voucher since local midnight from the signed chain, live, with day totals (card never enters the till). - Record + movements/review: the 2026-07-01 flow, unchanged. - Closed shifts: drawer-focused history via the scope-aware /api/shifts (float → takings ± vouchers → expected per shift). Also: every shift open/close button (header, /shifts, pay modal, end- shift confirm) now shows an animated spinner + dims while busy — the old label-swap-only feedback read as a dead click when a shift open ran slow. The slowness itself (drawer/shift reads fold the WHOLE chain, O(chain)) is recorded as an open item in wiki/concepts/shift.md with the fix sketch: fold from the last z-report's signed expectedDrawerMinor forward. No new ledger surface — one read-only endpoint; RBAC test added. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
102 lines
4.7 KiB
TypeScript
102 lines
4.7 KiB
TypeScript
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<void> {
|
|
const createGuard = requirePermission("drawer:create");
|
|
const reviewGuard = requirePermission("drawer:review");
|
|
const readGuard = requirePermission("shift:read");
|
|
|
|
// Operator RECORDS a movement — freely, no authorizer. It counts in the drawer at once.
|
|
app.post<{ Body: MovementBody }>("/api/drawer/movement", { preHandler: createGuard }, async (req, reply) => {
|
|
const b = req.body ?? ({} as MovementBody);
|
|
if (b.type !== "cash_in" && b.type !== "cash_out") {
|
|
return reply.code(400).send({ error: "type must be cash_in or cash_out" });
|
|
}
|
|
try {
|
|
return await shift.recordVoucher({
|
|
type: b.type,
|
|
operator: req.user.username,
|
|
amountMinor: b.amountMinor,
|
|
reason: b.reason ?? "",
|
|
currency: b.currency,
|
|
});
|
|
} catch (err) {
|
|
if (err instanceof InvalidCashMovementError) return reply.code(400).send({ error: err.message });
|
|
return reply.code(500).send({ error: (err as Error).message });
|
|
}
|
|
});
|
|
|
|
// List movements + review status. Operators are hard-scoped to their OWN movements; a
|
|
// reviewer sees ALL and may filter by status (the pending review queue).
|
|
app.get<{ Querystring: MovementsQuery }>("/api/drawer/movements", { preHandler: readGuard }, async (req) => {
|
|
const canReview = roleHasPermissions(req.user.roleId, ["drawer:review"]);
|
|
const q = req.query ?? {};
|
|
const status = canReview && ["pending", "authorized", "denied"].includes(q.status ?? "") ? q.status : undefined;
|
|
const movements = shift.movementsWithStatus({
|
|
operator: canReview ? undefined : req.user.username,
|
|
status,
|
|
});
|
|
return { movements, scope: canReview ? "all" : "self" };
|
|
});
|
|
|
|
// 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 });
|
|
}
|
|
});
|
|
}
|