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
+2
View File
@@ -31,6 +31,8 @@ const EVENT_STYLE: Record<string, { labelKey: string; color: string }> = {
shift_open: { labelKey: "booth.evtShiftOpen", color: "text-term-amber" },
shift_z_report: { labelKey: "booth.evtShiftZ", color: "text-term-amber" },
cash_movement: { labelKey: "booth.evtCashMovement", color: "text-term-cyan" },
cash_in: { labelKey: "booth.evtCashIn", color: "text-term-green" },
cash_out: { labelKey: "booth.evtCashOut", color: "text-term-amber" },
anomaly: { labelKey: "booth.evtAnomaly", color: "text-term-red" },
};
+52 -12
View File
@@ -1,16 +1,17 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { closeShift, fetchShift, openShift, recordCashMovement, type ShiftReport } from "./api.js";
import { closeShift, fetchShift, openShift, recordCashVoucher, type ShiftReport } from "./api.js";
// Manned-mode shift control. Start/End are explicit (not time-based — see
// wiki/concepts/shift.md). End Shift signs + prints a Z-report and shows the
// totals + the DRAWER picture (opening float carried from the prior shift, cash
// taken/added/removed, expected drawer). Admins can load/remove drawer cash.
// taken/added/removed, expected drawer). Operators RAISE a drawer cash voucher
// (Mandat Arkëtimi / Mandat Pagese); an admin AUTHORIZES it with their password.
// Available to cashier/operator/admin (readonly has no shift).
const money = (m: number, cur: string | null) => `${(m / 100).toFixed(2)} ${cur ?? ""}`.trim();
export function ShiftControl({ isAdmin = false }: { isAdmin?: boolean }) {
export function ShiftControl({ canVoucher = false }: { canVoucher?: boolean }) {
const { t } = useTranslation();
const [startedAt, setStartedAt] = useState<string | null>(null);
const [drawerMinor, setDrawerMinor] = useState<number | null>(null);
@@ -19,9 +20,11 @@ export function ShiftControl({ isAdmin = false }: { isAdmin?: boolean }) {
const [report, setReport] = useState<ShiftReport | null>(null);
const [err, setErr] = useState<string | null>(null);
// Cash-movement form (admin only).
// Drawer-voucher form. Operator raises; an admin authorizes (name + password).
const [moveAmount, setMoveAmount] = useState("");
const [moveReason, setMoveReason] = useState("");
const [authName, setAuthName] = useState("");
const [authPassword, setAuthPassword] = useState("");
const [moveMsg, setMoveMsg] = useState<string | null>(null);
function refresh() {
@@ -66,18 +69,31 @@ export function ShiftControl({ isAdmin = false }: { isAdmin?: boolean }) {
}
}
async function move(sign: 1 | -1) {
async function voucher(type: "cash_in" | "cash_out") {
setMoveMsg(null);
const major = Number(moveAmount);
if (!Number.isFinite(major) || major <= 0) {
setMoveMsg(t("shift.enterPositive"));
return;
}
if (!authName.trim() || !authPassword) {
setMoveMsg(t("shift.authRequired"));
return;
}
try {
const r = await recordCashMovement(sign * Math.round(major * 100), moveReason.trim());
const r = await recordCashVoucher({
type,
amountMinor: Math.round(major * 100),
reason: moveReason.trim(),
authorizedBy: authName.trim(),
authorizerPassword: authPassword,
});
setMoveAmount("");
setMoveReason("");
setMoveMsg(t("shift.drawerNow", { amount: money(r.balanceMinor, currency) }));
setAuthPassword("");
setMoveMsg(
t("shift.voucherRecorded", { no: r.voucherNo, amount: money(r.balanceMinor, currency) }),
);
refresh();
} catch (e) {
setMoveMsg((e as Error).message);
@@ -115,11 +131,12 @@ export function ShiftControl({ isAdmin = false }: { isAdmin?: boolean }) {
{err && <p className="mt-2 text-[12px] text-term-red">{err}</p>}
{/* Admin: load / remove physical drawer cash (signed cash_movement). */}
{isAdmin && (
{/* Drawer cash voucher: operator RAISES, an admin AUTHORIZES (name + password).
cash_in = Mandat Arkëtimi (pay-IN), cash_out = Mandat Pagese (pay-OUT). */}
{canVoucher && (
<div className="mt-4 border-t border-term-border pt-3">
<div className="mb-1.5 text-[11px] uppercase tracking-wider text-term-muted">
{t("shift.drawerCashAdmin")}
{t("shift.drawerVoucher")}
</div>
<div className="flex flex-wrap items-center gap-2">
<input
@@ -135,9 +152,32 @@ export function ShiftControl({ isAdmin = false }: { isAdmin?: boolean }) {
onChange={(e) => setMoveReason(e.target.value)}
placeholder={t("shift.reasonPlaceholder")}
/>
<button type="button" className="btn btn-go btn-sm" onClick={() => move(1)}>{t("shift.load")}</button>
<button type="button" className="btn btn-danger btn-sm" onClick={() => move(-1)}>{t("shift.remove")}</button>
</div>
{/* Admin sign-off — the float can only move with an admin's authorization. */}
<div className="mt-2 flex flex-wrap items-center gap-2">
<input
className="input w-36"
value={authName}
onChange={(e) => setAuthName(e.target.value)}
placeholder={t("shift.authName")}
autoComplete="off"
/>
<input
className="input w-36"
type="password"
value={authPassword}
onChange={(e) => setAuthPassword(e.target.value)}
placeholder={t("shift.authPassword")}
autoComplete="off"
/>
<button type="button" className="btn btn-go btn-sm" onClick={() => voucher("cash_in")}>
{t("shift.mandatArketimi")}
</button>
<button type="button" className="btn btn-danger btn-sm" onClick={() => voucher("cash_out")}>
{t("shift.mandatPagese")}
</button>
</div>
<div className="mt-1 text-[11px] text-term-muted">{t("shift.voucherHint")}</div>
{moveMsg && <div className="mt-1.5 text-[12px] text-term-muted">{moveMsg}</div>}
</div>
)}
+18 -7
View File
@@ -627,14 +627,25 @@ export function closeShift(): Promise<ShiftReport> {
return apiFetch("/api/shift/close", { method: "POST" });
}
/** Admin loads/removes physical drawer cash. amountMinor signed: + load, − remove. */
export function recordCashMovement(
amountMinor: number,
reason: string,
): Promise<{ amountMinor: number; balanceMinor: number }> {
return apiFetch("/api/cash-movement", {
/** A drawer cash voucher: Mandat Arkëtimi (cash_in / pay-IN) or Mandat Pagese
* (cash_out / pay-OUT). Direction is the TYPE, amountMinor a positive magnitude.
* Operator-raised, admin-authorized (authorizedBy + their password). */
export function recordCashVoucher(args: {
type: "cash_in" | "cash_out";
amountMinor: number;
reason: string;
authorizedBy: string;
authorizerPassword: string;
}): Promise<{
type: "cash_in" | "cash_out";
amountMinor: number;
voucherNo: string;
balanceMinor: number;
printed: boolean;
}> {
return apiFetch("/api/cash-voucher", {
method: "POST",
body: JSON.stringify({ amountMinor, reason }),
body: JSON.stringify(args),
});
}
+10
View File
@@ -144,6 +144,8 @@ export const en: Catalog = {
evtShiftOpen: "SHIFT+",
evtShiftZ: "SHIFT Z",
evtCashMovement: "CASH",
evtCashIn: "PAY-IN",
evtCashOut: "PAY-OUT",
evtAnomaly: "ANOMALY",
// live-feed event detail line + classification badges (computed from payload)
evtNoReason: "no reason recorded",
@@ -511,10 +513,18 @@ export const en: Catalog = {
drawer: "Drawer:",
openingFloatInherited: "(opening float inherited from the prior shift)",
drawerCashAdmin: "Drawer cash (admin) — load or remove the float",
drawerVoucher: "Drawer voucher — operator raises, an admin authorizes",
amount: "amount",
reasonPlaceholder: "reason (e.g. opening float)",
load: "Load +",
remove: "Remove −",
authName: "admin username",
authPassword: "admin password",
authRequired: "An admin must authorize: enter their username and password.",
mandatArketimi: "Receipt (in) +",
mandatPagese: "Disbursement (out) −",
voucherHint: "A receipt (Mandat Arkëtimi) adds cash; a disbursement (Mandat Pagese) removes it. The float only moves with an admin's sign-off.",
voucherRecorded: "Voucher {{no}} recorded. Drawer now {{amount}}.",
enterPositive: "Enter a positive amount.",
drawerNow: "Drawer now {{amount}}.",
zReport: "Z-REPORT",
+10
View File
@@ -148,6 +148,8 @@ export const sq = {
evtShiftOpen: "TURN+",
evtShiftZ: "TURN Z",
evtCashMovement: "ARKË",
evtCashIn: "ARKËTIM",
evtCashOut: "PAGESË",
evtAnomaly: "ANOMALI",
// rreshti i detajeve të eventit live + etiketat e klasifikimit (nga payload)
evtNoReason: "pa arsye të regjistruar",
@@ -523,10 +525,18 @@ export const sq = {
drawer: "Arka:",
openingFloatInherited: "(bilanci fillestar i trashëguar nga turni i mëparshëm)",
drawerCashAdmin: "Para në arkë (admin) — shto ose hiq bilancin",
drawerVoucher: "Mandat arke — operatori e hap, admini e autorizon",
amount: "shuma",
reasonPlaceholder: "arsyeja (p.sh. bilanci fillestar)",
load: "Shto +",
remove: "Hiq −",
authName: "përdoruesi i adminit",
authPassword: "fjalëkalimi i adminit",
authRequired: "Një admin duhet ta autorizojë: shkruaj përdoruesin dhe fjalëkalimin e tij.",
mandatArketimi: "Arkëtim (hyrje) +",
mandatPagese: "Pagesë (dalje) −",
voucherHint: "Mandat Arkëtimi shton para; Mandat Pagese heq para. Arka lëviz vetëm me autorizimin e një admini.",
voucherRecorded: "Mandati {{no}} u regjistrua. Arka tani {{amount}}.",
enterPositive: "Shkruaj një shumë pozitive.",
drawerNow: "Arka tani {{amount}}.",
zReport: "RAPORT Z",
+3 -1
View File
@@ -74,7 +74,9 @@ export function useLiveFeed(): void {
if (
msg.event.type === "shift_open" ||
msg.event.type === "shift_z_report" ||
msg.event.type === "cash_movement"
msg.event.type === "cash_movement" ||
msg.event.type === "cash_in" ||
msg.event.type === "cash_out"
) {
void qc.invalidateQueries({ queryKey: qk.shift });
}
+3 -2
View File
@@ -342,8 +342,9 @@ const shiftRoute = createRoute({
path: "/shift",
component: function ShiftRoute() {
const { user } = rootRoute.useRouteContext();
// "Admin" actions on the shift screen (drawer cash) need shift:cash.
return <ShiftControl isAdmin={can(user, "shift:cash")} />;
// The drawer-voucher form is operator-RAISED (shift:create); an admin still has
// to authorize each voucher with their password server-side.
return <ShiftControl canVoucher={can(user, "shift:create")} />;
},
});