diff --git a/apps/server/src/routes/shift.ts b/apps/server/src/routes/shift.ts index a9c5c74..dd9b3f1 100644 --- a/apps/server/src/routes/shift.ts +++ b/apps/server/src/routes/shift.ts @@ -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 { +export async function shiftRoutes(app: FastifyInstance, shift: ShiftService, db: Db): Promise { // 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 }); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 12f4ddd..1d7dd68 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -204,7 +204,7 @@ export async function buildServer(opts: BuildOptions = {}): Promise 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 { + 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 { const rows = await this.#db.select().from(devices).where(eq(devices.category, "printer")).all(); diff --git a/apps/web/src/BoothScreen.tsx b/apps/web/src/BoothScreen.tsx index 781f60b..60c1630 100644 --- a/apps/web/src/BoothScreen.tsx +++ b/apps/web/src/BoothScreen.tsx @@ -31,6 +31,8 @@ const EVENT_STYLE: Record = { 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" }, }; diff --git a/apps/web/src/ShiftControl.tsx b/apps/web/src/ShiftControl.tsx index ccf1f96..6624619 100644 --- a/apps/web/src/ShiftControl.tsx +++ b/apps/web/src/ShiftControl.tsx @@ -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(null); const [drawerMinor, setDrawerMinor] = useState(null); @@ -19,9 +20,11 @@ export function ShiftControl({ isAdmin = false }: { isAdmin?: boolean }) { const [report, setReport] = useState(null); const [err, setErr] = useState(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(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 &&

{err}

} - {/* 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 && (
- {t("shift.drawerCashAdmin")} + {t("shift.drawerVoucher")}
setMoveReason(e.target.value)} placeholder={t("shift.reasonPlaceholder")} /> - -
+ {/* Admin sign-off — the float can only move with an admin's authorization. */} +
+ setAuthName(e.target.value)} + placeholder={t("shift.authName")} + autoComplete="off" + /> + setAuthPassword(e.target.value)} + placeholder={t("shift.authPassword")} + autoComplete="off" + /> + + +
+
{t("shift.voucherHint")}
{moveMsg &&
{moveMsg}
}
)} diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index d33c4df..dfa6e3c 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -627,14 +627,25 @@ export function closeShift(): Promise { 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), }); } diff --git a/apps/web/src/lib/i18n/en.ts b/apps/web/src/lib/i18n/en.ts index f9e821e..d431092 100644 --- a/apps/web/src/lib/i18n/en.ts +++ b/apps/web/src/lib/i18n/en.ts @@ -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", diff --git a/apps/web/src/lib/i18n/sq.ts b/apps/web/src/lib/i18n/sq.ts index 2c9e5e5..c2cade1 100644 --- a/apps/web/src/lib/i18n/sq.ts +++ b/apps/web/src/lib/i18n/sq.ts @@ -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", diff --git a/apps/web/src/lib/use-live-feed.ts b/apps/web/src/lib/use-live-feed.ts index a3bcdc7..a39ad1f 100644 --- a/apps/web/src/lib/use-live-feed.ts +++ b/apps/web/src/lib/use-live-feed.ts @@ -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 }); } diff --git a/apps/web/src/router.tsx b/apps/web/src/router.tsx index 9ebada0..8097fbd 100644 --- a/apps/web/src/router.tsx +++ b/apps/web/src/router.tsx @@ -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 ; + // The drawer-voucher form is operator-RAISED (shift:create); an admin still has + // to authorize each voucher with their password server-side. + return ; }, }); diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index ec12d3a..2c90a0e 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -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; } diff --git a/wiki/concepts/shift.md b/wiki/concepts/shift.md index affef8b..d1ed1a3 100644 --- a/wiki/concepts/shift.md +++ b/wiki/concepts/shift.md @@ -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` +expectedDrawer(at) = Σ cash payments (tender=cash) 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). diff --git a/wiki/log.md b/wiki/log.md index eb25997..7bcab11 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -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).