feat(drawer): operator records cash movements, admin reviews after (own /drawer route)
Rework drawer cash movements from synchronous admin-authorization-at-creation
(operator typed an admin's password inline for every receipt/disbursement) to
operator-records-freely -> admin-reviews-after.
- New `drawer` resource: drawer:create (operator records; admin-revocable per
role) + drawer:review (admin authorizes/denies). Migration 0018 grants the
default operator role drawer:create; admin gets all in code.
- New signed `cash_review` ledger event { refId, decision, reviewedBy, note? }.
A DENIAL is a FLAG, not a reversal: it never appends reversing cash and never
touches the drawer balance (the correction is settled outside the app). This
is what keeps a late review from leaking into the next operator's inherited
drawer — a denial that lands after the reviewed shift closed moves no cash.
Regression test: op1 disburses -> closes -> op2 inherits -> admin denies ->
op2 drawer unchanged.
- Move the feature OFF the polluted /shifts route to a top-level /drawer
(operator: record + own; admin: review queue + all). routes/drawer.ts lifted
from routes/shift.ts (retired the authorizer-password gate; kept shift:cash
for its other job = admin-sees-all-shifts). New DrawerManager.tsx.
Display fixes bundled:
- Render cash_review in the event-detail modal (decision / reviewed-by / note /
movement ref) — previously showed nothing.
- Relabel the shift drawer figures for clarity: Daily takings / Receipts /
Disbursements (was Cash payments / Cash added / Cash removed).
- Hide the Card figure everywhere when CARD_PAYMENTS_ENABLED is false (no POS
on-site), matching the card-tender gate.
shared/db/server/web all typecheck; 225 server tests pass (incl. the drawer
review + cross-shift-leak regression); web build + i18n parity green. Verified
end-to-end via Playwright. Recorded in wiki/concepts/shift.md.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
-- Drawer redesign (2026-07-01): operators RECORD cash movements freely; admins REVIEW them
|
||||
-- after the fact (authorize/deny — a flag, not a reversal). New `drawer` resource with two
|
||||
-- permissions in @parking/shared: drawer:create + drawer:review.
|
||||
--
|
||||
-- The built-in `admin` role gets ALL permissions in code (auth.ts ADMIN_PERMS = new
|
||||
-- Set(PERMISSIONS)), so it needs NO seed row here. This grants the default `operator` role
|
||||
-- the ability to record movements (drawer:create) — matching the prior behaviour where an
|
||||
-- operator could raise a voucher. An admin can revoke it per-role in the Roles UI (it's just
|
||||
-- data). drawer:review is admin-only, so it is NOT granted to operator.
|
||||
--
|
||||
-- Idempotent: role_permissions has a UNIQUE(role_id, permission) index, so re-running is a
|
||||
-- no-op via OR IGNORE. See wiki/concepts/shift.md.
|
||||
INSERT OR IGNORE INTO `role_permissions` (`role_id`, `permission`) VALUES
|
||||
('operator','drawer:create');
|
||||
@@ -127,6 +127,13 @@
|
||||
"when": 1781886000000,
|
||||
"tag": "0017_backup_retention",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 18,
|
||||
"version": "6",
|
||||
"when": 1781886100000,
|
||||
"tag": "0018_drawer_permissions",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -21,7 +21,8 @@ export const RESOURCES = [
|
||||
"subscription", // the subscription registry
|
||||
"site", // site_config + device setup/assign
|
||||
"device", // device status / printers / snapshots / catalog
|
||||
"shift", // open/close own shift; move the drawer float
|
||||
"shift", // open/close own shift
|
||||
"drawer", // record cash receipts/disbursements (operator); review them (admin)
|
||||
"payment", // take payment, quote, voucher/receipt, exit, reopen
|
||||
"session", // active sessions, lookup
|
||||
"event", // the signed ledger feed + void
|
||||
@@ -33,9 +34,11 @@ export const RESOURCES = [
|
||||
export type Resource = (typeof RESOURCES)[number];
|
||||
|
||||
/** CRUD plus domain verbs where CRUD doesn't fit: `void` (append a void event, NOT a
|
||||
* delete), `cash` (move the drawer float — admin-grade shift action), and `plan`
|
||||
* (compose the subscription plan catalog — admin-grade; selling stays `create`). */
|
||||
export type Action = "create" | "read" | "update" | "delete" | "void" | "cash" | "plan";
|
||||
* delete), `cash` (admin-grade shift scope — see all operators' shifts), `plan` (compose
|
||||
* the subscription plan catalog — admin-grade; selling stays `create`), and `review`
|
||||
* (admin authorizes/denies a drawer movement an operator recorded — a flag, not a
|
||||
* reversal; see wiki/concepts/shift.md). */
|
||||
export type Action = "create" | "read" | "update" | "delete" | "void" | "cash" | "plan" | "review";
|
||||
|
||||
/** A single permission, e.g. "tariff:update". The route guard checks one of these. */
|
||||
export type Permission = `${Resource}:${Action}`;
|
||||
@@ -53,6 +56,11 @@ export const PERMISSIONS: readonly Permission[] = [
|
||||
"site:read", "site:update",
|
||||
"device:read",
|
||||
"shift:read", "shift:create", "shift:cash",
|
||||
// Drawer cash movements: create (operator RECORDS a receipt/disbursement — freely, no
|
||||
// admin sign-off at creation; admin-revocable per role) and review (admin AUTHORIZES or
|
||||
// DENIES a recorded movement after the fact — a flag, never a cash reversal). A denial is
|
||||
// a judgment about the operator, settled outside the app. See wiki/concepts/shift.md.
|
||||
"drawer:create", "drawer:review",
|
||||
"payment:read", "payment:create",
|
||||
"session:read",
|
||||
"event:read", "event:void",
|
||||
@@ -241,11 +249,19 @@ export type LedgerEventType =
|
||||
// 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.
|
||||
// Payload: { amountMinor (POSITIVE magnitude), reason, currency, operator (raised by),
|
||||
// voucherNo }. OPERATOR-RECORDED (freely; no admin sign-off at creation — 2026-07-01).
|
||||
// Folds into the drawer balance. Reviewed after the fact via cash_review (below).
|
||||
// See wiki/concepts/shift.md.
|
||||
| "cash_in"
|
||||
| "cash_out"
|
||||
// Admin's post-hoc REVIEW of a recorded cash_in/cash_out. Payload: { refId (the
|
||||
// reviewed movement's event id), decision: "authorize"|"deny", reviewedBy, note?,
|
||||
// currency? }. A FLAG only — it NEVER moves cash: a denial is a judgment about the
|
||||
// operator (settled outside the app), so it does NOT reverse the movement and does NOT
|
||||
// touch the drawer balance. Append-only, signed, so the decision is itself auditable.
|
||||
// See wiki/concepts/shift.md.
|
||||
| "cash_review"
|
||||
| "anomaly";
|
||||
|
||||
/** How money was tendered (for payment events + the shift Z-report). */
|
||||
@@ -292,12 +308,23 @@ 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;
|
||||
/** LEGACY cash_in / cash_out (pre-2026-07-01): the admin who AUTHORIZED the movement
|
||||
* at creation. The current flow records movements freely and reviews them AFTER via a
|
||||
* cash_review event, so new movements do NOT carry this. Kept so historical events
|
||||
* still verify + display. See wiki/concepts/shift.md. */
|
||||
readonly authorizedBy?: string;
|
||||
/** cash_review: the id of the cash_in/cash_out event this review decides on. */
|
||||
readonly refId?: string;
|
||||
/** cash_review: the admin's decision on the referenced movement. A FLAG only —
|
||||
* neither value moves cash or touches the drawer balance. */
|
||||
readonly decision?: "authorize" | "deny";
|
||||
/** cash_review: the admin (username) who made the decision. */
|
||||
readonly reviewedBy?: string;
|
||||
/** cash_review: optional free-text admin note (e.g. why a movement was denied). */
|
||||
readonly note?: string;
|
||||
/** subscription tariff-bridge: this occurrence opened OUTSIDE the plan's allowed window,
|
||||
* so the minutes actually parked out-of-window are charged at the transient tariff and
|
||||
* collected (gated) at exit. The AMOUNT is NOT fixed at entry — it depends on how long
|
||||
|
||||
Reference in New Issue
Block a user