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 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; // --- 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; } 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. */ 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; 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, 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 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. */ #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_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 { // cash_movement amount is signed (+ load, − removal). balanceMinor += amt; } if (pl.currency) currency = pl.currency; } 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)"); } const now = new Date().toISOString(); await this.#log.append({ type: "cash_movement", source: "manual", identity: operator, // who moved the cash (admin) payload: { amountMinor, ...(reason ? { reason } : {}), ...(currency ? { currency } : {}), operator, }, occurredAt: now, }); const { balanceMinor } = this.#drawerBalanceAt(now); this.#logger.info( `cash_movement ${amountMinor >= 0 ? "+" : ""}${amountMinor} by ${operator} (${reason || "no reason"}) → drawer ${balanceMinor}`, ); return { amountMinor, balanceMinor }; } /** 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 }; } /** Close the operator's open shift: sum payments in the window, sign + print the Z-report. */ async close(operator: string): Promise { const open = this.openShiftFor(operator); if (!open) throw new NoOpenShiftError(operator); const startedAt = open.occurredAt; const endedAt = new Date().toISOString(); // All payments taken in [startedAt, endedAt], 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 <= endedAt); let cashTotalMinor = 0; let cardTotalMinor = 0; let currency: string | null = null; for (const p of payments) { const pl = (p.payload ?? {}) as LedgerPayload; const amt = typeof pl.amountMinor === "number" ? pl.amountMinor : 0; if (pl.tender === "card") cardTotalMinor += amt; else cashTotalMinor += amt; if (pl.currency) currency = pl.currency; } // --- 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; // Cash movements within the shift window, split into added (+) and removed (−). const movements = this.#db .select() .from(ledgerEvents) .where(eq(ledgerEvents.type, "cash_movement")) .all() .filter((r) => 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 (pl.currency) currency = pl.currency; } // Expected drawer at close = opening + cash taken + added − removed. This is the // figure the NEXT shift inherits as its opening float. const expectedDrawerMinor = openingFloatMinor + cashTotalMinor + cashAddedMinor - cashRemovedMinor; const report: Omit = { operator, startedAt, endedAt, cashTotalMinor, cardTotalMinor, currency, paymentCount: payments.length, openingFloatMinor, cashAddedMinor, cashRemovedMinor, expectedDrawerMinor, }; await this.#log.append({ type: "shift_z_report", source: "manual", identity: operator, payload: { operator, startedAt, endedAt, cashTotalMinor, cardTotalMinor, currency: currency ?? undefined, paymentCount: payments.length, openingFloatMinor, cashAddedMinor, cashRemovedMinor, expectedDrawerMinor, }, }); const printed = await this.#printZReport(report); this.#logger.info( `shift closed for ${operator}: cash ${cashTotalMinor} card ${cardTotalMinor} (${payments.length} 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): Promise { 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}`, "", "-- Arka --", `Fillimi (kusur): ${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; } } /** 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(); 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; } } }