c5ed3f1308
/drawer was record + review only: no current balance, no sight of the open shift's incomings, no daily activity, no shift history. Rebuilt as a hub: - Drawer now: the till's running balance (new GET /api/drawer/balance, shift:read — exposes the service's existing drawerBalance(); the drawer is one site-wide till, same exposure the X-report already had) with the open shift's X-report breakdown alongside (float + takings + vouchers = expected = balance) and a "This shift: ±X" figure (expected − opening float — the shift's own contribution vs what it inherited). - Today's cash activity: every cash payment + voucher since local midnight from the signed chain, live, with day totals (card never enters the till). - Record + movements/review: the 2026-07-01 flow, unchanged. - Closed shifts: drawer-focused history via the scope-aware /api/shifts (float → takings ± vouchers → expected per shift). Also: every shift open/close button (header, /shifts, pay modal, end- shift confirm) now shows an animated spinner + dims while busy — the old label-swap-only feedback read as a dead click when a shift open ran slow. The slowness itself (drawer/shift reads fold the WHOLE chain, O(chain)) is recorded as an open item in wiki/concepts/shift.md with the fix sketch: fold from the last z-report's signed expectedDrawerMinor forward. No new ledger surface — one read-only endpoint; RBAC test added. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
765 lines
31 KiB
TypeScript
765 lines
31 KiB
TypeScript
import { eq, devices, ledgerEvents, type Db } from "@parking/db";
|
||
import { registry, formatStampSq as zStamp, type PrinterDevice } from "@parking/devices";
|
||
import type { LedgerPayload } from "@parking/shared";
|
||
import type { FastifyBaseLogger } from "fastify";
|
||
import type { EventLog } from "./event-log.js";
|
||
|
||
// Shift service (manned mode only). A shift is an operator's accountability period,
|
||
// delimited by EXPLICIT marks — not a clock. Represented entirely as signed ledger
|
||
// events (no mutable table): `shift_open` … `shift_z_report`. At close, sum the
|
||
// `payment` events taken during the shift by tender and print a Z-report.
|
||
// See wiki/concepts/shift.md.
|
||
|
||
export class ShiftAlreadyOpenError extends Error {
|
||
/** The operator who currently holds the open shift (may be someone else). */
|
||
readonly heldBy: string;
|
||
constructor(operator: string, heldBy: string) {
|
||
super(
|
||
heldBy === operator
|
||
? `operator ${operator} already has an open shift`
|
||
: `another operator (${heldBy}) has an open shift; only one shift may be open at a time`,
|
||
);
|
||
this.name = "ShiftAlreadyOpenError";
|
||
this.heldBy = heldBy;
|
||
}
|
||
}
|
||
export class NoOpenShiftError extends Error {
|
||
constructor(operator: string) {
|
||
super(`operator ${operator} has no open shift`);
|
||
this.name = "NoOpenShiftError";
|
||
}
|
||
}
|
||
/** Thrown by the booth money path when NO shift is open site-wide — an operator
|
||
* must open a shift before any payment/exit can be attributed to a shift. */
|
||
export class NoShiftOpenError extends Error {
|
||
constructor() {
|
||
super("no shift is open — open a shift before processing tickets");
|
||
this.name = "NoShiftOpenError";
|
||
}
|
||
}
|
||
|
||
/** A COMPLETED shift, reconstructed from its signed `shift_z_report` (which carries
|
||
* all the figures in its payload). This is the unit of the shift-history feature.
|
||
* `id` is the z_report's ledger id (stable, for the UI list key / future deep-link). */
|
||
export interface ShiftSummary {
|
||
readonly id: string;
|
||
readonly index: number;
|
||
readonly operator: string;
|
||
readonly startedAt: string;
|
||
readonly endedAt: string;
|
||
readonly cashTotalMinor: number;
|
||
readonly cardTotalMinor: number;
|
||
readonly currency: string | null;
|
||
readonly paymentCount: number;
|
||
readonly ticketTotalMinor: number;
|
||
readonly subscriptionTotalMinor: number;
|
||
readonly subscriptionSalesMinor: number;
|
||
readonly subscriptionWindowMinor: number;
|
||
readonly openingFloatMinor: number;
|
||
readonly cashAddedMinor: number;
|
||
readonly cashRemovedMinor: number;
|
||
readonly expectedDrawerMinor: number;
|
||
}
|
||
|
||
export interface ShiftReport {
|
||
readonly operator: string;
|
||
readonly startedAt: string;
|
||
readonly endedAt: string;
|
||
readonly cashTotalMinor: number;
|
||
readonly cardTotalMinor: number;
|
||
readonly currency: string | null;
|
||
readonly paymentCount: number;
|
||
// --- Takings split by SOURCE (cash+card combined; the drawer cash/card stay above) ---
|
||
/** Transient TICKET money (the default — any payment not flagged subscription). */
|
||
readonly ticketTotalMinor: number;
|
||
/** All SUBSCRIBER money = monthly sales + out-of-window charges. */
|
||
readonly subscriptionTotalMinor: number;
|
||
/** Subscription SALES only (the prepaid monthly/period fee). */
|
||
readonly subscriptionSalesMinor: number;
|
||
/** Subscriber OUT-OF-WINDOW transient-tariff charges only. */
|
||
readonly subscriptionWindowMinor: number;
|
||
// --- Drawer (physical cash till; carries across shifts) ---
|
||
/** Cash in the drawer at shift start = prior shift's expected closing drawer. */
|
||
readonly openingFloatMinor: number;
|
||
/** Admin cash LOADED into the drawer during the shift (sum of + movements). */
|
||
readonly cashAddedMinor: number;
|
||
/** Admin cash REMOVED from the drawer during the shift (sum of − movements, as +). */
|
||
readonly cashRemovedMinor: number;
|
||
/** Expected drawer at close = opening + cashTaken + added − removed. Carries forward. */
|
||
readonly expectedDrawerMinor: number;
|
||
readonly printed: boolean;
|
||
}
|
||
|
||
/** A drawer movement's admin-review status, derived from its latest `cash_review`. */
|
||
export type MovementStatus = "pending" | "authorized" | "denied";
|
||
|
||
/** One drawer cash movement (cash_in/cash_out) with its review status — the row shape for
|
||
* the operator's own list and the admin review queue. `status` is derived, not stored. */
|
||
export interface DrawerMovement {
|
||
readonly id: string;
|
||
readonly type: "cash_in" | "cash_out";
|
||
/** Positive magnitude; direction is the `type`. */
|
||
readonly amountMinor: number;
|
||
readonly currency: string | null;
|
||
readonly reason: string | null;
|
||
readonly operator: string;
|
||
readonly voucherNo: string | null;
|
||
readonly at: string;
|
||
readonly status: MovementStatus;
|
||
readonly reviewedBy: string | null;
|
||
readonly reviewNote: string | null;
|
||
readonly reviewedAt: string | null;
|
||
}
|
||
|
||
export class InvalidCashMovementError extends Error {
|
||
constructor(msg: string) {
|
||
super(msg);
|
||
this.name = "InvalidCashMovementError";
|
||
}
|
||
}
|
||
|
||
export class ShiftService {
|
||
readonly #db: Db;
|
||
readonly #log: EventLog;
|
||
readonly #logger: FastifyBaseLogger;
|
||
|
||
constructor(db: Db, log: EventLog, logger: FastifyBaseLogger) {
|
||
this.#db = db;
|
||
this.#log = log;
|
||
this.#logger = logger;
|
||
}
|
||
|
||
/** Current physical drawer balance (cash payments + cash_movements, by time). For
|
||
* the UI to show "inherited / in the drawer now". */
|
||
drawerBalance(): { balanceMinor: number; currency: string | null } {
|
||
return this.#drawerBalanceAt(new Date().toISOString());
|
||
}
|
||
|
||
/** Is there an open shift for this operator? Returns the open `shift_open` row or null. */
|
||
openShiftFor(operator: string) {
|
||
// Scan shift events for this operator; the shift is open if the most recent
|
||
// shift event for them is a `shift_open` (not yet closed by a z_report).
|
||
const rows = this.#db
|
||
.select()
|
||
.from(ledgerEvents)
|
||
.where(eq(ledgerEvents.identity, operator))
|
||
.orderBy(ledgerEvents.index)
|
||
.all()
|
||
.filter((r) => r.type === "shift_open" || r.type === "shift_z_report");
|
||
const last = rows[rows.length - 1];
|
||
return last && last.type === "shift_open" ? last : null;
|
||
}
|
||
|
||
/**
|
||
* The SINGLE site-wide open shift, or null. A shift is a site-wide accountability
|
||
* period: at most ONE may be open at a time (so booth takings are unambiguously
|
||
* attributed to one operator). It's open iff the most recent shift event on the
|
||
* whole chain is a `shift_open` (the matching `shift_z_report` hasn't been
|
||
* appended yet). Returns that row so callers can read its operator/startedAt.
|
||
*/
|
||
currentOpenShift() {
|
||
const rows = this.#db
|
||
.select()
|
||
.from(ledgerEvents)
|
||
.orderBy(ledgerEvents.index)
|
||
.all()
|
||
.filter((r) => r.type === "shift_open" || r.type === "shift_z_report");
|
||
const last = rows[rows.length - 1];
|
||
return last && last.type === "shift_open" ? last : null;
|
||
}
|
||
|
||
/**
|
||
* COMPLETED shift history, newest first. Each closed shift is one signed
|
||
* `shift_z_report` whose payload already holds every figure, so this is a simple
|
||
* read of those rows (no re-summing). Optional filters:
|
||
* - operator: only this operator's shifts (the `identity` on the z_report).
|
||
* - from/to: ISO timestamps; keep shifts whose START falls in [from, to].
|
||
* The open shift (no z_report yet) is intentionally excluded — it's not a
|
||
* completed accountability period. Use `currentOpenShift()` for the live one.
|
||
*/
|
||
/**
|
||
* Every operator that HAS a shift (closed z_reports + the open one, if any),
|
||
* distinct + sorted — feeds the admin filter dropdown so it can only ever ask
|
||
* for an operator that exists (the filter is an exact username match).
|
||
*/
|
||
listOperators(): string[] {
|
||
const rows = this.#db
|
||
.select()
|
||
.from(ledgerEvents)
|
||
.where(eq(ledgerEvents.type, "shift_z_report"))
|
||
.all();
|
||
const names = new Set<string>();
|
||
for (const r of rows) {
|
||
const op = ((r.payload ?? {}) as { operator?: string }).operator ?? r.identity;
|
||
if (op) names.add(op);
|
||
}
|
||
const open = this.currentOpenShift();
|
||
const openOp = open ? (((open.payload ?? {}) as { operator?: string }).operator ?? open.identity) : null;
|
||
if (openOp) names.add(openOp);
|
||
return [...names].sort((a, b) => a.localeCompare(b));
|
||
}
|
||
|
||
listShifts(opts: { operator?: string; from?: string; to?: string } = {}): ShiftSummary[] {
|
||
const rows = this.#db
|
||
.select()
|
||
.from(ledgerEvents)
|
||
.where(eq(ledgerEvents.type, "shift_z_report"))
|
||
.orderBy(ledgerEvents.index)
|
||
.all();
|
||
|
||
const out: ShiftSummary[] = [];
|
||
for (const r of rows) {
|
||
const pl = (r.payload ?? {}) as LedgerPayload & {
|
||
operator?: string;
|
||
startedAt?: string;
|
||
endedAt?: string;
|
||
cashTotalMinor?: number;
|
||
cardTotalMinor?: number;
|
||
paymentCount?: number;
|
||
ticketTotalMinor?: number;
|
||
subscriptionTotalMinor?: number;
|
||
subscriptionSalesMinor?: number;
|
||
subscriptionWindowMinor?: number;
|
||
openingFloatMinor?: number;
|
||
cashAddedMinor?: number;
|
||
cashRemovedMinor?: number;
|
||
expectedDrawerMinor?: number;
|
||
};
|
||
const operator = pl.operator ?? r.identity ?? "?";
|
||
const startedAt = pl.startedAt ?? r.occurredAt;
|
||
if (opts.operator && operator !== opts.operator) continue;
|
||
if (opts.from && startedAt < opts.from) continue;
|
||
if (opts.to && startedAt > opts.to) continue;
|
||
out.push({
|
||
id: r.id,
|
||
index: r.index,
|
||
operator,
|
||
startedAt,
|
||
endedAt: pl.endedAt ?? r.occurredAt,
|
||
cashTotalMinor: pl.cashTotalMinor ?? 0,
|
||
cardTotalMinor: pl.cardTotalMinor ?? 0,
|
||
currency: pl.currency ?? null,
|
||
paymentCount: pl.paymentCount ?? 0,
|
||
// Split-by-source fields (added 2026-06-21). Old reports lack them → default the
|
||
// subscription buckets to 0 and let ticket absorb the whole take, so the buckets
|
||
// still reconcile to cash+card for a pre-split shift.
|
||
subscriptionSalesMinor: pl.subscriptionSalesMinor ?? 0,
|
||
subscriptionWindowMinor: pl.subscriptionWindowMinor ?? 0,
|
||
subscriptionTotalMinor:
|
||
pl.subscriptionTotalMinor ?? (pl.subscriptionSalesMinor ?? 0) + (pl.subscriptionWindowMinor ?? 0),
|
||
ticketTotalMinor:
|
||
pl.ticketTotalMinor ??
|
||
(pl.cashTotalMinor ?? 0) + (pl.cardTotalMinor ?? 0) - (pl.subscriptionTotalMinor ?? 0),
|
||
openingFloatMinor: pl.openingFloatMinor ?? 0,
|
||
cashAddedMinor: pl.cashAddedMinor ?? 0,
|
||
cashRemovedMinor: pl.cashRemovedMinor ?? 0,
|
||
expectedDrawerMinor: pl.expectedDrawerMinor ?? 0,
|
||
});
|
||
}
|
||
// Newest first for the history list.
|
||
return out.reverse();
|
||
}
|
||
|
||
/** Require an open shift for the booth money path; returns it or throws. */
|
||
requireOpenShift() {
|
||
const open = this.currentOpenShift();
|
||
if (!open) throw new NoShiftOpenError();
|
||
return open;
|
||
}
|
||
|
||
/**
|
||
* The physical drawer balance at `at`: a fold over the SIGNED chain BY TIME (not
|
||
* 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
|
||
.select()
|
||
.from(ledgerEvents)
|
||
.orderBy(ledgerEvents.index)
|
||
.all()
|
||
.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) {
|
||
const pl = (r.payload ?? {}) as LedgerPayload;
|
||
const amt = typeof pl.amountMinor === "number" ? pl.amountMinor : 0;
|
||
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 {
|
||
// legacy cash_movement amount is signed (+ load, − removal).
|
||
balanceMinor += amt;
|
||
}
|
||
if (pl.currency) currency = pl.currency;
|
||
}
|
||
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 a drawer cash MOVEMENT — 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 movement is OPERATOR-RECORDED FREELY
|
||
* (no admin sign-off at creation — 2026-07-01); an admin REVIEWS it after the fact via
|
||
* `reviewMovement` (authorize/deny — a flag that never moves cash). It counts in the
|
||
* drawer immediately (the cash physically moved). Returns the new drawer balance + the
|
||
* assigned voucher number, and prints a slip best-effort. See wiki/concepts/shift.md.
|
||
*/
|
||
async recordVoucher(args: {
|
||
type: "cash_in" | "cash_out";
|
||
operator: string;
|
||
amountMinor: number;
|
||
reason: string;
|
||
currency?: string;
|
||
}): Promise<{ type: "cash_in" | "cash_out"; amountMinor: number; voucherNo: string; balanceMinor: number; printed: boolean }> {
|
||
const { type, operator, 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,
|
||
source: "manual",
|
||
identity: operator, // who RECORDED the movement (the operator at the booth)
|
||
payload: {
|
||
amountMinor, // positive magnitude — direction is the type
|
||
...(reason ? { reason } : {}),
|
||
...(args.currency ? { currency: args.currency } : {}),
|
||
operator,
|
||
voucherNo,
|
||
},
|
||
occurredAt: now,
|
||
});
|
||
const { balanceMinor, currency } = this.#drawerBalanceAt(now);
|
||
const printed = await this.#printVoucher({ type, voucherNo, amountMinor, reason, operator, currency, at: now });
|
||
this.#logger.info(
|
||
`${type} ${voucherNo} ${amountMinor} by ${operator} (${reason || "no reason"}) → drawer ${balanceMinor}`,
|
||
);
|
||
return { type, amountMinor, voucherNo, balanceMinor, printed };
|
||
}
|
||
|
||
/**
|
||
* Admin's post-hoc REVIEW of a recorded cash_in/cash_out. Appends a signed `cash_review`
|
||
* referencing the movement. This is a FLAG ONLY — a `deny` does NOT reverse the movement
|
||
* and does NOT touch the drawer balance (a denial is a judgment about the operator,
|
||
* settled outside the app). Rejects an unknown/ non-movement refId, and a movement that
|
||
* was already decided (one decision per movement; a clean audit trail). Idempotent by
|
||
* design: the drawer fold never reads `cash_review`. See wiki/concepts/shift.md.
|
||
*/
|
||
async reviewMovement(args: {
|
||
refId: string;
|
||
decision: "authorize" | "deny";
|
||
reviewedBy: string;
|
||
note?: string;
|
||
}): Promise<{ refId: string; decision: "authorize" | "deny"; reviewedBy: string; at: string }> {
|
||
const { refId, decision, reviewedBy } = args;
|
||
if (decision !== "authorize" && decision !== "deny") {
|
||
throw new InvalidCashMovementError("decision must be authorize or deny");
|
||
}
|
||
const movement = this.#db.select().from(ledgerEvents).where(eq(ledgerEvents.id, refId)).get();
|
||
if (!movement || (movement.type !== "cash_in" && movement.type !== "cash_out")) {
|
||
throw new InvalidCashMovementError("refId is not a cash movement");
|
||
}
|
||
// One decision per movement — reject a re-review so the audit stays unambiguous.
|
||
const already = this.#db
|
||
.select()
|
||
.from(ledgerEvents)
|
||
.where(eq(ledgerEvents.type, "cash_review"))
|
||
.all()
|
||
.some((r) => (r.payload as LedgerPayload | null)?.refId === refId);
|
||
if (already) throw new InvalidCashMovementError("movement already reviewed");
|
||
|
||
const now = new Date().toISOString();
|
||
await this.#log.append({
|
||
type: "cash_review",
|
||
source: "manual",
|
||
identity: reviewedBy, // the admin who decided
|
||
payload: {
|
||
refId,
|
||
decision,
|
||
reviewedBy,
|
||
...(args.note ? { note: args.note } : {}),
|
||
},
|
||
occurredAt: now,
|
||
});
|
||
this.#logger.info(`cash_review ${decision} of ${movement.type} ${refId} by ${reviewedBy}`);
|
||
return { refId, decision, reviewedBy, at: now };
|
||
}
|
||
|
||
/**
|
||
* All drawer cash movements (cash_in/cash_out) with their review STATUS, newest first.
|
||
* Status is derived from the latest `cash_review` referencing each movement: none →
|
||
* `pending`, else `authorized`/`denied`. Powers the operator's own list and the admin
|
||
* review queue. `operator` (optional) scopes to one operator's movements (an operator
|
||
* sees only their own; a reviewer sees all). See wiki/concepts/shift.md.
|
||
*/
|
||
movementsWithStatus(filter?: { operator?: string; status?: MovementStatus }): DrawerMovement[] {
|
||
const rows = this.#db.select().from(ledgerEvents).orderBy(ledgerEvents.index).all();
|
||
// Latest review decision per movement id.
|
||
const reviewByRef = new Map<string, { decision: "authorize" | "deny"; reviewedBy: string; note?: string; at: string }>();
|
||
for (const r of rows) {
|
||
if (r.type !== "cash_review") continue;
|
||
const pl = (r.payload ?? {}) as LedgerPayload;
|
||
if (!pl.refId || (pl.decision !== "authorize" && pl.decision !== "deny")) continue;
|
||
reviewByRef.set(pl.refId, {
|
||
decision: pl.decision,
|
||
reviewedBy: pl.reviewedBy ?? "",
|
||
...(pl.note ? { note: pl.note } : {}),
|
||
at: r.occurredAt,
|
||
});
|
||
}
|
||
const out: DrawerMovement[] = [];
|
||
for (const r of rows) {
|
||
if (r.type !== "cash_in" && r.type !== "cash_out") continue;
|
||
const pl = (r.payload ?? {}) as LedgerPayload;
|
||
const operator = (typeof pl.operator === "string" ? pl.operator : null) ?? r.identity ?? "";
|
||
if (filter?.operator && operator !== filter.operator) continue;
|
||
const review = reviewByRef.get(r.id);
|
||
const status: MovementStatus = review ? (review.decision === "authorize" ? "authorized" : "denied") : "pending";
|
||
if (filter?.status && status !== filter.status) continue;
|
||
out.push({
|
||
id: r.id,
|
||
type: r.type,
|
||
amountMinor: typeof pl.amountMinor === "number" ? Math.abs(pl.amountMinor) : 0,
|
||
currency: pl.currency ?? null,
|
||
reason: pl.reason ?? null,
|
||
operator,
|
||
voucherNo: pl.voucherNo ?? null,
|
||
at: r.occurredAt,
|
||
status,
|
||
reviewedBy: review?.reviewedBy ?? null,
|
||
reviewNote: review?.note ?? null,
|
||
reviewedAt: review?.at ?? null,
|
||
});
|
||
}
|
||
// Newest first.
|
||
return out.sort((a, b) => (a.at < b.at ? 1 : a.at > b.at ? -1 : 0));
|
||
}
|
||
|
||
/** Open a shift for the operator (explicit start). The opening float is auto-
|
||
* inherited from the chain = the drawer balance at the start instant. */
|
||
async open(operator: string): Promise<{ startedAt: string; openingFloatMinor: number }> {
|
||
// Site-wide single-open invariant: refuse if ANY shift is open — whether this
|
||
// operator's own (double-open) or another operator's (handover not done). Only
|
||
// one accountability period at a time.
|
||
const current = this.currentOpenShift();
|
||
if (current) throw new ShiftAlreadyOpenError(operator, current.identity ?? operator);
|
||
const startedAt = new Date().toISOString();
|
||
const { balanceMinor: openingFloatMinor } = this.#drawerBalanceAt(startedAt);
|
||
await this.#log.append({
|
||
type: "shift_open",
|
||
source: "manual",
|
||
identity: operator, // the shift's operator; `identity` keys the shift to them
|
||
// Record the inherited opening float on the shift_open so it's reproducible
|
||
// and the next operator's handover figure is fixed in the chain.
|
||
payload: { operator, openingFloatMinor },
|
||
occurredAt: startedAt,
|
||
});
|
||
this.#logger.info(`shift opened for ${operator} (opening float ${openingFloatMinor})`);
|
||
return { startedAt, openingFloatMinor };
|
||
}
|
||
|
||
/**
|
||
* Project the drawer/takings figures for a shift's window `[startedAt, asOf]`.
|
||
* Pure read over the signed chain — appends NOTHING — so it backs BOTH the
|
||
* mid-shift X-report (asOf = now, shift still open) and the Z-report at close
|
||
* (asOf = endedAt). The figures are identical projections; only the persistence
|
||
* differs (X = read-only, Z = signed + carried forward).
|
||
*/
|
||
#summariseWindow(
|
||
open: typeof ledgerEvents.$inferSelect,
|
||
asOf: string,
|
||
): Omit<ShiftReport, "printed"> {
|
||
const operator = open.identity ?? "?";
|
||
const startedAt = open.occurredAt;
|
||
|
||
// All payments taken in [startedAt, asOf], summed by tender. Payment time =
|
||
// the operator who handled the money (decision: sum by payment time).
|
||
const payments = this.#db
|
||
.select()
|
||
.from(ledgerEvents)
|
||
.where(eq(ledgerEvents.type, "payment"))
|
||
.all()
|
||
.filter((r) => r.occurredAt >= startedAt && r.occurredAt <= asOf);
|
||
|
||
let cashTotalMinor = 0;
|
||
let cardTotalMinor = 0;
|
||
// Split by SOURCE: subscription SALES (the prepaid fee), subscriber OUT-OF-WINDOW
|
||
// charges, and everything else = transient TICKET money. Both subscriber kinds roll
|
||
// up into subscriptionTotal; the rest is ticketTotal. The flags ride the signed
|
||
// payment payload (subscriptionSale / subscriptionWindowCharge — see pay-station +
|
||
// the subscription sale path).
|
||
let subscriptionSalesMinor = 0;
|
||
let subscriptionWindowMinor = 0;
|
||
let currency: string | null = null;
|
||
for (const p of payments) {
|
||
const pl = (p.payload ?? {}) as LedgerPayload & {
|
||
subscriptionSale?: boolean;
|
||
subscriptionWindowCharge?: boolean;
|
||
};
|
||
const amt = typeof pl.amountMinor === "number" ? pl.amountMinor : 0;
|
||
if (pl.tender === "card") cardTotalMinor += amt;
|
||
else cashTotalMinor += amt;
|
||
if (pl.subscriptionSale === true) subscriptionSalesMinor += amt;
|
||
else if (pl.subscriptionWindowCharge === true) subscriptionWindowMinor += amt;
|
||
// (else → transient ticket; derived below as total − subscription)
|
||
if (pl.currency) currency = pl.currency;
|
||
}
|
||
const subscriptionTotalMinor = subscriptionSalesMinor + subscriptionWindowMinor;
|
||
const ticketTotalMinor = cashTotalMinor + cardTotalMinor - subscriptionTotalMinor;
|
||
|
||
// --- Drawer figures ---
|
||
// Opening float was fixed on shift_open (inherited from the chain at start);
|
||
// fall back to a fresh fold if an older shift_open lacks it.
|
||
const openPl = (open.payload ?? {}) as LedgerPayload & { openingFloatMinor?: number };
|
||
const openingFloatMinor =
|
||
typeof openPl.openingFloatMinor === "number"
|
||
? openPl.openingFloatMinor
|
||
: this.#drawerBalanceAt(startedAt).balanceMinor;
|
||
|
||
// Drawer movements within the 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)
|
||
.all()
|
||
.filter(
|
||
(r) =>
|
||
(r.type === "cash_in" || r.type === "cash_out" || r.type === "cash_movement") &&
|
||
r.occurredAt >= startedAt &&
|
||
r.occurredAt <= asOf,
|
||
);
|
||
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 (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;
|
||
}
|
||
|
||
// Expected drawer = opening + cash taken + added − removed. At close this is the
|
||
// figure the NEXT shift inherits as its opening float.
|
||
const expectedDrawerMinor = openingFloatMinor + cashTotalMinor + cashAddedMinor - cashRemovedMinor;
|
||
|
||
return {
|
||
operator,
|
||
startedAt,
|
||
endedAt: asOf,
|
||
cashTotalMinor,
|
||
cardTotalMinor,
|
||
currency,
|
||
paymentCount: payments.length,
|
||
ticketTotalMinor,
|
||
subscriptionTotalMinor,
|
||
subscriptionSalesMinor,
|
||
subscriptionWindowMinor,
|
||
openingFloatMinor,
|
||
cashAddedMinor,
|
||
cashRemovedMinor,
|
||
expectedDrawerMinor,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Mid-shift X-report: a READ-ONLY "so far" snapshot of the open shift's takings +
|
||
* drawer, computed as of now. Appends nothing (it's not an accountability mark —
|
||
* the Z-report at close is). Returns null when no shift is open. The same
|
||
* projection the Z-report prints, so the operator sees exactly what their close
|
||
* will show. See wiki/concepts/shift.md.
|
||
*/
|
||
currentReport(): (Omit<ShiftReport, "printed"> & { asOf: string }) | null {
|
||
const open = this.currentOpenShift();
|
||
if (!open) return null;
|
||
const asOf = new Date().toISOString();
|
||
return { ...this.#summariseWindow(open, asOf), asOf };
|
||
}
|
||
|
||
/** Close the operator's open shift: sum payments in the window, sign + print the Z-report. */
|
||
async close(operator: string): Promise<ShiftReport> {
|
||
const open = this.openShiftFor(operator);
|
||
if (!open) throw new NoOpenShiftError(operator);
|
||
const endedAt = new Date().toISOString();
|
||
|
||
const report = this.#summariseWindow(open, endedAt);
|
||
const {
|
||
startedAt,
|
||
cashTotalMinor,
|
||
cardTotalMinor,
|
||
currency,
|
||
paymentCount,
|
||
ticketTotalMinor,
|
||
subscriptionTotalMinor,
|
||
subscriptionSalesMinor,
|
||
subscriptionWindowMinor,
|
||
openingFloatMinor,
|
||
cashAddedMinor,
|
||
cashRemovedMinor,
|
||
expectedDrawerMinor,
|
||
} = report;
|
||
|
||
await this.#log.append({
|
||
type: "shift_z_report",
|
||
source: "manual",
|
||
identity: operator,
|
||
payload: {
|
||
operator,
|
||
startedAt,
|
||
endedAt,
|
||
cashTotalMinor,
|
||
cardTotalMinor,
|
||
currency: currency ?? undefined,
|
||
paymentCount,
|
||
ticketTotalMinor,
|
||
subscriptionTotalMinor,
|
||
subscriptionSalesMinor,
|
||
subscriptionWindowMinor,
|
||
openingFloatMinor,
|
||
cashAddedMinor,
|
||
cashRemovedMinor,
|
||
expectedDrawerMinor,
|
||
},
|
||
});
|
||
|
||
const printed = await this.#printZReport(report);
|
||
|
||
this.#logger.info(
|
||
`shift closed for ${operator}: cash ${cashTotalMinor} card ${cardTotalMinor} (${paymentCount} payments); ` +
|
||
`drawer open ${openingFloatMinor} +${cashAddedMinor} −${cashRemovedMinor} → expected ${expectedDrawerMinor}`,
|
||
);
|
||
return { ...report, printed };
|
||
}
|
||
|
||
/** Print the Z-report on a booth-receipt printer (best-effort; the signed event
|
||
* is the record — a failed print doesn't undo the close). */
|
||
async #printZReport(r: Omit<ShiftReport, "printed">): Promise<boolean> {
|
||
const printer = await this.#boothPrinter();
|
||
if (!printer) {
|
||
this.#logger.warn(`no booth-receipt printer — Z-report for ${r.operator} not printed (event is recorded)`);
|
||
return false;
|
||
}
|
||
const cur = r.currency ?? "";
|
||
const money = (m: number) => (m / 100).toFixed(2);
|
||
// Customer/operator-facing print is Albanian (see i18n.md — printed slips are not
|
||
// governed by the UI language), with human dates "19 Qershor 2026 10:48:25".
|
||
const lines = [
|
||
`Operatori: ${r.operator}`,
|
||
`Nga: ${zStamp(r.startedAt)}`,
|
||
`Deri: ${zStamp(r.endedAt)}`,
|
||
"",
|
||
`Pagesa: ${r.paymentCount}`,
|
||
`Para në dorë: ${money(r.cashTotalMinor)} ${cur}`,
|
||
`Kartë: ${money(r.cardTotalMinor)} ${cur}`,
|
||
"",
|
||
"-- Arkëtime sipas burimit --",
|
||
`Bileta: ${money(r.ticketTotalMinor)} ${cur}`,
|
||
// Abonime is the subscription TOTAL; only the out-of-window part is broken out.
|
||
// (subscriptionSalesMinor stays in the signed payload — it's just not printed.)
|
||
`Abonime: ${money(r.subscriptionTotalMinor)} ${cur}`,
|
||
` jashtë orarit: ${money(r.subscriptionWindowMinor)} ${cur}`,
|
||
"",
|
||
"-- Arka --",
|
||
`Fillimi: ${money(r.openingFloatMinor)} ${cur}`,
|
||
`Para të marra: ${money(r.cashTotalMinor)} ${cur}`,
|
||
`Para të shtuara: ${money(r.cashAddedMinor)} ${cur}`,
|
||
`Para të hequra: ${money(r.cashRemovedMinor)} ${cur}`,
|
||
`Arka e pritur: ${money(r.expectedDrawerMinor)} ${cur}`,
|
||
];
|
||
try {
|
||
await printer.printReport({ title: "RAPORT TURNI", lines });
|
||
return true;
|
||
} catch (err) {
|
||
this.#logger.warn(`Z-report print failed for ${r.operator}: ${(err as Error).message} (event recorded)`);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/** 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;
|
||
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 || "-"}`,
|
||
"",
|
||
`Regjistroi: ${v.operator}`,
|
||
];
|
||
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();
|
||
const enabled = rows.filter((r) => r.enabled);
|
||
const booth = enabled.find((r) => (r.config as { role?: string }).role === "booth-receipt") ?? enabled[0];
|
||
if (!booth) return null;
|
||
const driver = registry.get(booth.driverId);
|
||
if (!driver) return null;
|
||
try {
|
||
return driver.create(booth.config as never) as PrinterDevice;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
}
|