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:
@@ -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 });
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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)");
|
||||
/** 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 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 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();
|
||||
|
||||
@@ -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" },
|
||||
};
|
||||
|
||||
|
||||
@@ -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
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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")} />;
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -121,7 +121,18 @@ export type LedgerEventType =
|
||||
// Admin loads/removes physical drawer cash (the float). Signed payload:
|
||||
// { amountMinor (signed: + load, − removal), reason, currency, operator }.
|
||||
// Folds into the drawer balance carried across shifts. See wiki/concepts/shift.md.
|
||||
// SUPERSEDED 2026-06-20 by the directional voucher pair below — kept as a type so
|
||||
// historical events on the live chain still verify and still fold into the drawer.
|
||||
| "cash_movement"
|
||||
// Drawer cash vouchers (replace the signed-± cash_movement with two distinct
|
||||
// financial documents — the direction is the TYPE, not the sign of an amount):
|
||||
// cash_in = Mandat Arkëtimi (receipt / pay-IN): cash enters the drawer.
|
||||
// cash_out = Mandat Pagese (disbursement / pay-OUT): cash leaves the drawer.
|
||||
// Payload: { amountMinor (POSITIVE magnitude), reason, currency, operator (raised
|
||||
// by), authorizedBy (admin who signed off), voucherNo }. Operator-raised /
|
||||
// admin-authorized. Folds into the drawer balance. See wiki/concepts/shift.md.
|
||||
| "cash_in"
|
||||
| "cash_out"
|
||||
| "anomaly";
|
||||
|
||||
/** How money was tendered (for payment events + the shift Z-report). */
|
||||
@@ -168,6 +179,12 @@ export interface LedgerPayload {
|
||||
/** vehicle_entry: the vehicle/customer category, frozen at entry so V2 category
|
||||
* pricing reprices identically at exit. Absent on legacy entries (= default). */
|
||||
readonly category?: string;
|
||||
/** cash_in / cash_out voucher: the admin who AUTHORIZED the drawer movement (the
|
||||
* operator in `operator` raised it). Operator-raised / admin-authorized. */
|
||||
readonly authorizedBy?: string;
|
||||
/** cash_in / cash_out voucher: a human-facing voucher number printed on the slip
|
||||
* (Mandat Nr.). Sequential per type; signed for reproducibility. */
|
||||
readonly voucherNo?: string;
|
||||
/** Free-form for forward-compat without a schema change. */
|
||||
readonly [k: string]: unknown;
|
||||
}
|
||||
|
||||
+38
-15
@@ -121,20 +121,37 @@ The Z-report's payment totals answer "how much did this shift *take*?" — but a
|
||||
**physical cash drawer** that carries across shifts. The drawer is tracked as a running balance over
|
||||
the signed chain, so each shift knows what it **inherited** and what it should **hand over**.
|
||||
|
||||
**The events:**
|
||||
- A new signed **`cash_movement`** event: the admin loads or removes drawer cash, `{ amountMinor
|
||||
(signed: + load, − removal), reason, operator }`. **Admin-only** (an operator takes payments but
|
||||
cannot move the float in/out). The opening-day load (+5000 ALL) and a mid-shift withdrawal (−5000)
|
||||
are both `cash_movement` events.
|
||||
**The events (drawer vouchers — re-modelled 2026-06-20):** the original design used one signed
|
||||
**`cash_movement`** event with a *signed* `amountMinor` (+ load / − removal). That conflated two
|
||||
distinct financial documents into a `±`. In accounting a pay-in and a pay-out are different vouchers
|
||||
(in Albanian: **Mandat Arkëtimi** = receipt, **Mandat Pagese** = disbursement), so the direction now
|
||||
lives in the **event type**, not the sign of an amount:
|
||||
|
||||
- **`cash_in`** (*Mandat Arkëtimi* — a **receipt / pay-IN**): cash enters the drawer. `amountMinor` is
|
||||
a **positive magnitude**. Voucher no. `AR-NNNN`.
|
||||
- **`cash_out`** (*Mandat Pagese* — a **disbursement / pay-OUT**): cash leaves the drawer.
|
||||
`amountMinor` positive; the fold subtracts it. Voucher no. `PA-NNNN`.
|
||||
- Payload: `{ amountMinor (positive), reason, currency, operator (who raised), authorizedBy (admin who
|
||||
signed off), voucherNo }`. Each prints a **slip** (Albanian, like every operator-facing paper).
|
||||
- **Authorization changed: operator-RAISED, admin-AUTHORIZED.** Previously admin-only. Now any holder
|
||||
of `shift:create` (operator-grade) may *raise* a voucher, but the route only commits it if
|
||||
`authorizedBy` is a real **admin** (`shift:cash`) who **re-enters their password**. This keeps the
|
||||
float control — an operator can't move the float alone — while letting them do the paperwork at the
|
||||
booth. (`POST /api/cash-voucher`, guarded `shift:create` + server-side authorizer password+grade check.)
|
||||
- **Legacy `cash_movement` stays valid.** The type is retained; historical signed events on the live
|
||||
chain still verify and still fold into the drawer (signed-± as before). Only *new* movements use the
|
||||
voucher pair. The append-only chain is never rewritten.
|
||||
- The existing `payment` events already add cash to the drawer (cash tender only; card never touches
|
||||
the drawer).
|
||||
|
||||
**The math — drawer is a fold over the chain BY TIME, not by operator** (a `cash_movement` is the
|
||||
admin's, not the shift operator's, so it can't key off `identity`):
|
||||
**The math — drawer is a fold over the chain BY TIME, not by operator** (a drawer voucher is the
|
||||
admin's authorization, not the shift operator's takings, so it can't key off `identity`):
|
||||
|
||||
```
|
||||
expectedDrawer(at) = Σ cash payments (tender=cash) up to `at`
|
||||
+ Σ cash_movement amounts up to `at`
|
||||
+ Σ cash_in amounts (positive) up to `at`
|
||||
− Σ cash_out amounts (positive) up to `at`
|
||||
+ Σ cash_movement amounts (legacy, signed) up to `at`
|
||||
```
|
||||
|
||||
A shift's **opening float = expectedDrawer(shiftStart)** — i.e. everything that happened to the drawer
|
||||
@@ -150,11 +167,11 @@ That `expectedDrawer` is exactly the **next** shift's opening float — the carr
|
||||
|
||||
| Step | Event | Drawer |
|
||||
| --- | --- | --- |
|
||||
| Opening day | admin `cash_movement` +5000 | 5000 |
|
||||
| Opening day | `cash_in` (Mandat Arkëtimi) +5000 | 5000 |
|
||||
| Shift 1 takes 6500 cash | payments | 11500 |
|
||||
| Shift 1 closes | Z: open 5000, took 6500, expected **11500** | 11500 |
|
||||
| Shift 2 opens | opening float = **11500** (inherited) | 11500 |
|
||||
| admin `cash_movement` −5000 | withdrawal | 6500 |
|
||||
| `cash_out` (Mandat Pagese) 5000 | withdrawal | 6500 |
|
||||
| Shift 2 takes 4500 cash | payments | 11000 |
|
||||
| Shift 2 closes | Z: open 11500, took 4500, removed 5000, expected **11000** | 11000 |
|
||||
| Shift 3 opens | opening float = **11000** | … |
|
||||
@@ -179,13 +196,19 @@ reconciles the signed Z-report against the actual drawer and the bank/POS batch
|
||||
|
||||
## Open
|
||||
|
||||
- **Drawer carry-over (decided 2026-06-18, building):** opening float auto-inherits the prior shift's
|
||||
expected drawer; admin-only `cash_movement` events; Z-report reports the full drawer picture. See
|
||||
the Drawer balance section above.
|
||||
- **Drawer carry-over (decided 2026-06-18, built; vouchers re-modelled 2026-06-20):** opening float
|
||||
auto-inherits the prior shift's expected drawer; drawer movements are now the **`cash_in` /
|
||||
`cash_out` voucher pair** (Mandat Arkëtimi / Mandat Pagese — direction is the type, operator-raised
|
||||
& admin-authorized), superseding the signed-± `cash_movement` (kept for history). Z-report reports
|
||||
the full drawer picture. See the Drawer balance section above.
|
||||
- **Shift ↔ session boundary:** a vehicle may enter under one shift and pay under another — the
|
||||
Z-report sums by **payment time** (when cash/card was taken), which is the operator who handled
|
||||
the money. Confirm that's the intended accountability (vs. by entry).
|
||||
- **Mid-shift report / X-report** (read-only "so far" total without closing) — add if booths want
|
||||
it; the sum is the same projection.
|
||||
- **Mid-shift report / X-report — REQUESTED 2026-06-20, not yet built.** The operator wants to see,
|
||||
on demand during the shift, the **opening float inherited**, **cash collected so far**, the
|
||||
pay-ins/pay-outs, and the **current expected drawer balance** — without closing. It's the same
|
||||
drawer projection the Z-report computes, just read-only and mid-shift. (The header already shows the
|
||||
live drawer *total*; this is the full breakdown.) Deferred behind the voucher re-model done the same
|
||||
day; build next if wanted.
|
||||
- **Multiple lanes/booths** — whether a shift is per-operator, per-booth, or per-site
|
||||
(relates to [[open-questions]] #1 lane topology).
|
||||
|
||||
+18
@@ -1100,3 +1100,21 @@ the signed ledger is untouched. Added `plate?` to the shared `LedgerEvent` + `Ac
|
||||
`SessionLookup`; a small amber badge in the UI. Caveat: a `vehicle_entry` is signed + pushed
|
||||
over WS BEFORE the async ANPR read lands, so a fresh feed row may show no plate until reload;
|
||||
always present on active sessions. Build + lint 12/12.
|
||||
|
||||
## [2026-06-20] feat | Drawer cash re-modelled as directional vouchers (Mandat Arkëtimi / Pagese)
|
||||
|
||||
Replaced the single signed-± `cash_movement` (one event, +load/−removal in the sign of an
|
||||
amount) with two distinct financial documents — the direction is now the event TYPE:
|
||||
`cash_in` = **Mandat Arkëtimi** (receipt / pay-IN, +) and `cash_out` = **Mandat Pagese**
|
||||
(disbursement / pay-OUT, −). Each carries a positive magnitude, a voucher number (`AR-NNNN`
|
||||
/ `PA-NNNN`), reason, the operator who raised it and the admin who authorized it, and prints
|
||||
an Albanian slip. **Authorization changed**: was admin-only; now **operator-RAISED,
|
||||
admin-AUTHORIZED** — any `shift:create` holder raises the voucher but `POST /api/cash-voucher`
|
||||
only commits if `authorizedBy` is a real admin (`shift:cash`) re-entering their password.
|
||||
Legacy `cash_movement` events are KEPT (still verify, still fold into the drawer signed-±) —
|
||||
the append-only chain is never rewritten. Drawer fold + Z-report window updated to 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 still verifies OK. Build + lint 12/12.
|
||||
Updated [[shift]] (Drawer balance section, math, worked example, open items). Prompted by the
|
||||
operator-balance question; the live mid-shift **X-report** breakdown is logged as REQUESTED,
|
||||
not yet built (see [[shift]] Open).
|
||||
|
||||
Reference in New Issue
Block a user