feat(drawer): operator records cash movements, admin reviews after (own /drawer route)
Rework drawer cash movements from synchronous admin-authorization-at-creation
(operator typed an admin's password inline for every receipt/disbursement) to
operator-records-freely -> admin-reviews-after.
- New `drawer` resource: drawer:create (operator records; admin-revocable per
role) + drawer:review (admin authorizes/denies). Migration 0018 grants the
default operator role drawer:create; admin gets all in code.
- New signed `cash_review` ledger event { refId, decision, reviewedBy, note? }.
A DENIAL is a FLAG, not a reversal: it never appends reversing cash and never
touches the drawer balance (the correction is settled outside the app). This
is what keeps a late review from leaking into the next operator's inherited
drawer — a denial that lands after the reviewed shift closed moves no cash.
Regression test: op1 disburses -> closes -> op2 inherits -> admin denies ->
op2 drawer unchanged.
- Move the feature OFF the polluted /shifts route to a top-level /drawer
(operator: record + own; admin: review queue + all). routes/drawer.ts lifted
from routes/shift.ts (retired the authorizer-password gate; kept shift:cash
for its other job = admin-sees-all-shifts). New DrawerManager.tsx.
Display fixes bundled:
- Render cash_review in the event-detail modal (decision / reviewed-by / note /
movement ref) — previously showed nothing.
- Relabel the shift drawer figures for clarity: Daily takings / Receipts /
Disbursements (was Cash payments / Cash added / Cash removed).
- Hide the Card figure everywhere when CARD_PAYMENTS_ENABLED is false (no POS
on-site), matching the card-tender gate.
shared/db/server/web all typecheck; 225 server tests pass (incl. the drawer
review + cross-shift-leak regression); web build + i18n parity green. Verified
end-to-end via Playwright. Recorded in wiki/concepts/shift.md.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
@@ -0,0 +1,94 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { requirePermission, roleHasPermissions } from "../auth.js";
|
||||
import { InvalidCashMovementError, type MovementStatus, type ShiftService } from "../shift-service.js";
|
||||
|
||||
// Drawer cash movements (manned mode). Redesigned 2026-07-01: an operator RECORDS a
|
||||
// receipt/disbursement FREELY (no admin sign-off at creation); an admin REVIEWS it after
|
||||
// the fact (authorize/deny — a flag that never moves cash). See wiki/concepts/shift.md.
|
||||
// - POST /api/drawer/movement : operator records a cash_in/cash_out. (drawer:create)
|
||||
// - GET /api/drawer/movements: list with review status. Operators see (shift:read)
|
||||
// only their own; reviewers see all + can filter status.
|
||||
// - POST /api/drawer/review : admin authorize/deny a movement. (drawer:review)
|
||||
// The drawer BALANCE math is unchanged — a movement counts immediately; a denial is a
|
||||
// judgment about the operator settled outside the app, never a cash reversal.
|
||||
|
||||
interface MovementBody {
|
||||
/** Direction is the document TYPE, not a sign: cash_in = Mandat Arkëtimi (pay-IN),
|
||||
* cash_out = Mandat Pagese (pay-OUT). */
|
||||
type: "cash_in" | "cash_out";
|
||||
/** POSITIVE minor units (magnitude). The direction comes from `type`. */
|
||||
amountMinor: number;
|
||||
reason?: string;
|
||||
currency?: string;
|
||||
}
|
||||
|
||||
interface ReviewBody {
|
||||
/** The cash_in/cash_out event id being decided on. */
|
||||
refId: string;
|
||||
decision: "authorize" | "deny";
|
||||
/** Optional admin note (e.g. why denied). */
|
||||
note?: string;
|
||||
}
|
||||
|
||||
interface MovementsQuery {
|
||||
/** Reviewers only: filter to pending/authorized/denied. Ignored for non-reviewers. */
|
||||
status?: MovementStatus;
|
||||
}
|
||||
|
||||
export async function drawerRoutes(app: FastifyInstance, shift: ShiftService): Promise<void> {
|
||||
const createGuard = requirePermission("drawer:create");
|
||||
const reviewGuard = requirePermission("drawer:review");
|
||||
const readGuard = requirePermission("shift:read");
|
||||
|
||||
// Operator RECORDS a movement — freely, no authorizer. It counts in the drawer at once.
|
||||
app.post<{ Body: MovementBody }>("/api/drawer/movement", { preHandler: createGuard }, async (req, reply) => {
|
||||
const b = req.body ?? ({} as MovementBody);
|
||||
if (b.type !== "cash_in" && b.type !== "cash_out") {
|
||||
return reply.code(400).send({ error: "type must be cash_in or cash_out" });
|
||||
}
|
||||
try {
|
||||
return await shift.recordVoucher({
|
||||
type: b.type,
|
||||
operator: req.user.username,
|
||||
amountMinor: b.amountMinor,
|
||||
reason: b.reason ?? "",
|
||||
currency: b.currency,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof InvalidCashMovementError) return reply.code(400).send({ error: err.message });
|
||||
return reply.code(500).send({ error: (err as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
// List movements + review status. Operators are hard-scoped to their OWN movements; a
|
||||
// reviewer sees ALL and may filter by status (the pending review queue).
|
||||
app.get<{ Querystring: MovementsQuery }>("/api/drawer/movements", { preHandler: readGuard }, async (req) => {
|
||||
const canReview = roleHasPermissions(req.user.roleId, ["drawer:review"]);
|
||||
const q = req.query ?? {};
|
||||
const status = canReview && ["pending", "authorized", "denied"].includes(q.status ?? "") ? q.status : undefined;
|
||||
const movements = shift.movementsWithStatus({
|
||||
operator: canReview ? undefined : req.user.username,
|
||||
status,
|
||||
});
|
||||
return { movements, scope: canReview ? "all" : "self" };
|
||||
});
|
||||
|
||||
// Admin AUTHORIZES or DENIES a recorded movement. A flag only — no cash reversal.
|
||||
app.post<{ Body: ReviewBody }>("/api/drawer/review", { preHandler: reviewGuard }, async (req, reply) => {
|
||||
const b = req.body ?? ({} as ReviewBody);
|
||||
if (!b.refId || (b.decision !== "authorize" && b.decision !== "deny")) {
|
||||
return reply.code(400).send({ error: "refId and decision (authorize|deny) are required" });
|
||||
}
|
||||
try {
|
||||
return await shift.reviewMovement({
|
||||
refId: b.refId,
|
||||
decision: b.decision,
|
||||
reviewedBy: req.user.username,
|
||||
note: b.note,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof InvalidCashMovementError) return reply.code(400).send({ error: err.message });
|
||||
return reply.code(500).send({ error: (err as Error).message });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,27 +1,6 @@
|
||||
import bcrypt from "bcrypt";
|
||||
import { eq, users, type Db } from "@parking/db";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { requirePermission, roleHasPermissions } from "../auth.js";
|
||||
import {
|
||||
InvalidCashMovementError,
|
||||
NoOpenShiftError,
|
||||
ShiftAlreadyOpenError,
|
||||
type ShiftService,
|
||||
} from "../shift-service.js";
|
||||
|
||||
interface CashVoucherBody {
|
||||
/** Direction is the document TYPE, not a sign: cash_in = Mandat Arkëtimi (pay-IN),
|
||||
* cash_out = Mandat Pagese (pay-OUT). */
|
||||
type: "cash_in" | "cash_out";
|
||||
/** POSITIVE minor units (magnitude). The direction comes from `type`. */
|
||||
amountMinor: number;
|
||||
reason?: string;
|
||||
currency?: string;
|
||||
/** The admin who authorizes this voucher (operator-raised / admin-authorized). */
|
||||
authorizedBy: string;
|
||||
/** That admin's password — re-entered to sign off on the drawer movement. */
|
||||
authorizerPassword: string;
|
||||
}
|
||||
import { NoOpenShiftError, ShiftAlreadyOpenError, type ShiftService } from "../shift-service.js";
|
||||
|
||||
interface ShiftsQuery {
|
||||
/** Filter to one operator (admin-only; non-admins are forced to themselves). */
|
||||
@@ -35,7 +14,7 @@ interface ShiftsQuery {
|
||||
// opened/closed explicitly (not time-based — see wiki/concepts/shift.md and
|
||||
// local-jwt-auth.md "until logout"). End Shift signs a shift_z_report + prints it.
|
||||
|
||||
export async function shiftRoutes(app: FastifyInstance, shift: ShiftService, db: Db): Promise<void> {
|
||||
export async function shiftRoutes(app: FastifyInstance, shift: ShiftService): Promise<void> {
|
||||
// Reading the shift state vs. opening/closing one's own shift.
|
||||
const readGuard = requirePermission("shift:read");
|
||||
const guard = requirePermission("shift:create");
|
||||
@@ -87,49 +66,8 @@ export async function shiftRoutes(app: FastifyInstance, shift: ShiftService, db:
|
||||
return { shifts, scope: canSeeAll ? "all" : "self" };
|
||||
});
|
||||
|
||||
// Drawer cash VOUCHER — Mandat Arkëtimi (cash_in / pay-IN) or Mandat Pagese
|
||||
// (cash_out / pay-OUT). The direction is the document TYPE, not a signed amount.
|
||||
// OPERATOR-RAISED, ADMIN-AUTHORIZED: any holder of `shift:create` (operator-grade)
|
||||
// may RAISE the voucher, but it only commits if `authorizedBy` is a real admin
|
||||
// (`shift:cash`) who re-enters their password. This keeps the float control —
|
||||
// an operator cannot move the float alone — while letting them raise the slip.
|
||||
// See wiki/concepts/shift.md.
|
||||
app.post<{ Body: CashVoucherBody }>(
|
||||
"/api/cash-voucher",
|
||||
{ preHandler: guard },
|
||||
async (req, reply) => {
|
||||
const b = req.body ?? ({} as CashVoucherBody);
|
||||
if (b.type !== "cash_in" && b.type !== "cash_out") {
|
||||
return reply.code(400).send({ error: "type must be cash_in or cash_out" });
|
||||
}
|
||||
const authName = (b.authorizedBy ?? "").trim();
|
||||
if (!authName || !b.authorizerPassword) {
|
||||
return reply.code(400).send({ error: "authorizedBy and authorizerPassword are required" });
|
||||
}
|
||||
// Verify the authorizer: a real user, admin-grade (shift:cash), correct password.
|
||||
const authUser = await db.select().from(users).where(eq(users.username, authName)).get();
|
||||
// Always run a bcrypt compare (constant-time wrt whether the user exists).
|
||||
const hash = authUser?.passwordHash ?? "$2b$10$invalidinvalidinvalidinvalidinvalidinvalidinv";
|
||||
const passwordOk = await bcrypt.compare(b.authorizerPassword, hash);
|
||||
const isAdminGrade = authUser != null && roleHasPermissions(authUser.roleId, ["shift:cash"]);
|
||||
if (!authUser || !passwordOk || !isAdminGrade) {
|
||||
return reply.code(403).send({ error: "authorizer must be an admin with a correct password" });
|
||||
}
|
||||
try {
|
||||
return await shift.recordVoucher({
|
||||
type: b.type,
|
||||
operator: req.user.username, // who RAISED it
|
||||
authorizedBy: authUser.username, // who signed off (canonical case)
|
||||
amountMinor: b.amountMinor,
|
||||
reason: b.reason ?? "",
|
||||
currency: b.currency,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof InvalidCashMovementError) return reply.code(400).send({ error: err.message });
|
||||
return reply.code(500).send({ error: (err as Error).message });
|
||||
}
|
||||
},
|
||||
);
|
||||
// NB: drawer cash movements (record/review) moved to routes/drawer.ts (2026-07-01) — the
|
||||
// feature is no longer part of the shift route. See wiki/concepts/shift.md.
|
||||
|
||||
app.post("/api/shift/open", { preHandler: guard }, async (req, reply) => {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user