feat: re-model drawer cash as directional vouchers (Mandat Arkëtimi / Pagese)

Replace the single signed-± cash_movement with two distinct financial
documents — the direction is the event TYPE, not the sign of an amount:

  cash_in  = Mandat Arkëtimi (receipt / pay-IN,  +)  voucher AR-NNNN
  cash_out = Mandat Pagese  (disbursement / pay-OUT, −)  voucher PA-NNNN

Each carries a positive magnitude, voucher number, reason, the operator who
raised it and the admin who authorized it, and prints an Albanian slip.

Authorization changes from admin-only to operator-RAISED / admin-AUTHORIZED:
any shift:create holder raises the voucher, but POST /api/cash-voucher only
commits when authorizedBy is a real admin (shift:cash) re-entering their
password (verified server-side). Keeps the float control while letting the
operator do the booth paperwork.

Legacy cash_movement events are kept — they still verify and still fold into
the drawer (signed-±); the append-only chain is never rewritten. The drawer
fold and the Z-report window now sum all three types.

Verified against a copy of the live DB with the real signing modules:
cash_in 3000 + cash_out 5000 → drawer −2000, hash-chain verifies OK.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
2026-06-20 16:18:26 +02:00
parent a20400c2c5
commit 2835f78635
13 changed files with 335 additions and 78 deletions
+47 -11
View File
@@ -1,3 +1,5 @@
import bcrypt from "bcrypt";
import { eq, users, type Db } from "@parking/db";
import type { FastifyInstance } from "fastify";
import { requirePermission, roleHasPermissions } from "../auth.js";
import {
@@ -7,11 +9,18 @@ import {
type ShiftService,
} from "../shift-service.js";
interface CashMovementBody {
/** Signed minor units: positive = load INTO drawer, negative = remove FROM drawer. */
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;
}
interface ShiftsQuery {
@@ -26,7 +35,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): Promise<void> {
export async function shiftRoutes(app: FastifyInstance, shift: ShiftService, db: Db): Promise<void> {
// Reading the shift state vs. opening/closing one's own shift.
const readGuard = requirePermission("shift:read");
const guard = requirePermission("shift:create");
@@ -68,16 +77,43 @@ export async function shiftRoutes(app: FastifyInstance, shift: ShiftService): Pr
return { shifts, scope: canSeeAll ? "all" : "self" };
});
// Admin loads/removes physical drawer cash (the float). Signed cash_movement
// event. ADMIN ONLY — an operator takes payments but cannot move the float.
// amountMinor is signed: + load IN, − remove OUT. See wiki/concepts/shift.md.
app.post<{ Body: CashMovementBody }>(
"/api/cash-movement",
{ preHandler: requirePermission("shift:cash") },
// 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 { amountMinor, reason, currency } = req.body ?? ({} as CashMovementBody);
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.recordCashMovement(req.user.username, amountMinor, reason ?? "", currency);
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 });
+1 -1
View File
@@ -204,7 +204,7 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
await subscriptionRoutes(app, db, credentialCapture, eventLog, shiftService);
// Shift open/close + drawer endpoints (shiftService constructed above).
await shiftRoutes(app, shiftService);
await shiftRoutes(app, shiftService, db);
// Site config (capacity) + live occupancy. The FULL gate (refuse transient entry
// at capacity) is in the entry flow. See wiki/concepts/capacity-occupancy.md.
+115 -28
View File
@@ -199,9 +199,14 @@ export class ShiftService {
/**
* The physical drawer balance at `at`: a fold over the SIGNED chain BY TIME (not
* by operator — a cash_movement is the admin's, not the shift operator's). Cash
* payments add to the drawer; card payments never touch it; cash_movement amounts
* (signed: + load, − removal) adjust it. This is what carries across shifts.
* by operator — a drawer voucher is the admin's, not the shift operator's). Cash
* payments add to the drawer; card payments never touch it. Drawer movements adjust
* it via three event types kept side-by-side:
* - `cash_in` (Mandat Arkëtimi): + amountMinor (positive magnitude)
* - `cash_out` (Mandat Pagese): − amountMinor (positive magnitude)
* - `cash_movement` (legacy, pre-2026-06-20): a SIGNED amountMinor (+ load / −
* removal) — historical chain events that still fold in unchanged.
* This is what carries across shifts.
*/
#drawerBalanceAt(at: string): { balanceMinor: number; currency: string | null } {
const rows = this.#db
@@ -209,7 +214,14 @@ export class ShiftService {
.from(ledgerEvents)
.orderBy(ledgerEvents.index)
.all()
.filter((r) => r.occurredAt <= at && (r.type === "payment" || r.type === "cash_movement"));
.filter(
(r) =>
r.occurredAt <= at &&
(r.type === "payment" ||
r.type === "cash_in" ||
r.type === "cash_out" ||
r.type === "cash_movement"),
);
let balanceMinor = 0;
let currency: string | null = null;
for (const r of rows) {
@@ -218,8 +230,12 @@ export class ShiftService {
if (r.type === "payment") {
// Only CASH enters the till; card settles to the bank.
if (pl.tender !== "card") balanceMinor += amt;
} else if (r.type === "cash_in") {
balanceMinor += Math.abs(amt); // receipt — direction is the type
} else if (r.type === "cash_out") {
balanceMinor -= Math.abs(amt); // disbursement — direction is the type
} else {
// cash_movement amount is signed (+ load, − removal).
// legacy cash_movement amount is signed (+ load, − removal).
balanceMinor += amt;
}
if (pl.currency) currency = pl.currency;
@@ -227,38 +243,61 @@ export class ShiftService {
return { balanceMinor, currency };
}
/** Next voucher number for a drawer-voucher type, e.g. `AR-0007` (cash_in) /
* `PA-0007` (cash_out). Sequential per type = count of existing events + 1. The
* number is human-facing (printed on the slip); the signed chain is the real
* record, so a small race only risks a duplicate label, never a lost voucher. */
#nextVoucherNo(type: "cash_in" | "cash_out"): string {
const prefix = type === "cash_in" ? "AR" : "PA";
const count = this.#db.select().from(ledgerEvents).where(eq(ledgerEvents.type, type)).all().length;
return `${prefix}-${String(count + 1).padStart(4, "0")}`;
}
/**
* Record an admin cash movement (load/remove drawer float). `amountMinor` is
* signed: positive = cash loaded IN, negative = cash taken OUT. Signed +
* attributed. Admin-only is enforced at the route. Returns the new drawer balance.
* 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):
* - `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.
*/
async recordCashMovement(
operator: string,
amountMinor: number,
reason: string,
currency?: string,
): Promise<{ amountMinor: number; balanceMinor: number }> {
if (!Number.isInteger(amountMinor) || amountMinor === 0) {
throw new InvalidCashMovementError("amountMinor must be a non-zero integer (minor units)");
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;
if (!Number.isInteger(args.amountMinor) || args.amountMinor <= 0) {
throw new InvalidCashMovementError("amountMinor must be a positive integer (minor units)");
}
const amountMinor = args.amountMinor;
const now = new Date().toISOString();
const voucherNo = this.#nextVoucherNo(type);
await this.#log.append({
type: "cash_movement",
type,
source: "manual",
identity: operator, // who moved the cash (admin)
identity: operator, // who RAISED the voucher (the operator at the booth)
payload: {
amountMinor,
amountMinor, // positive magnitude — direction is the type
...(reason ? { reason } : {}),
...(currency ? { currency } : {}),
...(args.currency ? { currency: args.currency } : {}),
operator,
authorizedBy,
voucherNo,
},
occurredAt: now,
});
const { balanceMinor } = this.#drawerBalanceAt(now);
const { balanceMinor, currency } = this.#drawerBalanceAt(now);
const printed = await this.#printVoucher({ type, voucherNo, amountMinor, reason, operator, authorizedBy, currency, at: now });
this.#logger.info(
`cash_movement ${amountMinor >= 0 ? "+" : ""}${amountMinor} by ${operator} (${reason || "no reason"}) → drawer ${balanceMinor}`,
`${type} ${voucherNo} ${amountMinor} by ${operator} authz ${authorizedBy} (${reason || "no reason"}) → drawer ${balanceMinor}`,
);
return { amountMinor, balanceMinor };
return { type, amountMinor, voucherNo, balanceMinor, printed };
}
/** Open a shift for the operator (explicit start). The opening float is auto-
@@ -320,20 +359,28 @@ export class ShiftService {
? openPl.openingFloatMinor
: this.#drawerBalanceAt(startedAt).balanceMinor;
// Cash movements within the shift window, split into added (+) and removed (−).
// Drawer movements within the shift window, split into added (+) and removed (−).
// Three side-by-side types: cash_in (+), cash_out (−), and the legacy signed-±
// cash_movement. All carry a POSITIVE magnitude except legacy, which is signed.
const movements = this.#db
.select()
.from(ledgerEvents)
.where(eq(ledgerEvents.type, "cash_movement"))
.all()
.filter((r) => r.occurredAt >= startedAt && r.occurredAt <= endedAt);
.filter(
(r) =>
(r.type === "cash_in" || r.type === "cash_out" || r.type === "cash_movement") &&
r.occurredAt >= startedAt &&
r.occurredAt <= endedAt,
);
let cashAddedMinor = 0;
let cashRemovedMinor = 0;
for (const m of movements) {
const pl = (m.payload ?? {}) as LedgerPayload;
const amt = typeof pl.amountMinor === "number" ? pl.amountMinor : 0;
if (amt >= 0) cashAddedMinor += amt;
else cashRemovedMinor += -amt; // store as a positive magnitude
if (m.type === "cash_in") cashAddedMinor += Math.abs(amt);
else if (m.type === "cash_out") cashRemovedMinor += Math.abs(amt);
else if (amt >= 0) cashAddedMinor += amt; // legacy + load
else cashRemovedMinor += -amt; // legacy − removal, store as positive magnitude
if (pl.currency) currency = pl.currency;
}
@@ -420,6 +467,46 @@ export class ShiftService {
}
}
/** Print a drawer-voucher slip (Mandat Arkëtimi / Mandat Pagese). Best-effort —
* the signed event is the record; a failed print doesn't undo the voucher.
* Albanian, like every customer/operator-facing slip (see i18n.md). */
async #printVoucher(v: {
type: "cash_in" | "cash_out";
voucherNo: string;
amountMinor: number;
reason: string;
operator: string;
authorizedBy: string;
currency: string | null;
at: string;
}): Promise<boolean> {
const printer = await this.#boothPrinter();
if (!printer) {
this.#logger.warn(`no booth-receipt printer — ${v.type} ${v.voucherNo} not printed (event recorded)`);
return false;
}
const cur = v.currency ?? "";
const money = (m: number) => (m / 100).toFixed(2);
const title = v.type === "cash_in" ? "MANDAT ARKËTIMI" : "MANDAT PAGESE";
const lines = [
`Mandat Nr.: ${v.voucherNo}`,
`Data: ${zStamp(v.at)}`,
"",
`Shuma: ${money(v.amountMinor)} ${cur}`,
`Arsyeja: ${v.reason || "-"}`,
"",
`Hapur nga: ${v.operator}`,
`Autorizoi: ${v.authorizedBy}`,
];
try {
await printer.printReport({ title, lines });
return true;
} catch (err) {
this.#logger.warn(`${v.type} ${v.voucherNo} print failed: ${(err as Error).message} (event recorded)`);
return false;
}
}
/** First enabled booth-receipt printer, or any enabled printer. */
async #boothPrinter(): Promise<PrinterDevice | null> {
const rows = await this.#db.select().from(devices).where(eq(devices.category, "printer")).all();