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:
2026-07-01 11:17:20 +02:00
parent 018328a877
commit 114a32e6f2
18 changed files with 879 additions and 206 deletions
+131 -15
View File
@@ -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 });