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:
@@ -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