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 {
|
||||
|
||||
@@ -42,6 +42,7 @@ 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 { siteRoutes } from "./routes/site.js";
|
||||
import { snapshotRoutes } from "./routes/snapshots.js";
|
||||
import { tariffRoutes } from "./routes/tariffs.js";
|
||||
@@ -265,8 +266,10 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
||||
await subscriptionRoutes(app, db, credentialCapture, eventLog, shiftService);
|
||||
await subscriptionPlanRoutes(app, db);
|
||||
|
||||
// Shift open/close + drawer endpoints (shiftService constructed above).
|
||||
await shiftRoutes(app, shiftService, db);
|
||||
// Shift open/close (shiftService constructed above).
|
||||
await shiftRoutes(app, shiftService);
|
||||
// Drawer cash movements — operator records, admin reviews (routes/drawer.ts).
|
||||
await drawerRoutes(app, shiftService);
|
||||
|
||||
// Site config (capacity) + live occupancy. The FULL gate (refuse transient entry
|
||||
// at capacity) is in the entry flow. See wiki/concepts/capacity-occupancy.md.
|
||||
|
||||
@@ -117,27 +117,100 @@ describe("drawer carry-forward", () => {
|
||||
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");
|
||||
|
||||
@@ -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<string, { decision: "authorize" | "deny"; reviewedBy: string; note?: string; at: string }>();
|
||||
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<boolean> {
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user