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 discountTotalMinor: 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; /** Merchant-validation DISCOUNT total given away in the window (leakage — the * cash/card figures above are already NET of it). See validation-discounts.md. */ readonly discountTotalMinor: 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(); 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; discountTotalMinor?: 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), // Merchant-validation leakage (added 2026-07-13). Old reports lack it → 0. discountTotalMinor: pl.discountTotalMinor ?? 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(); 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 { 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; // Merchant-validation leakage: Σ discountMinor across the window's payments. The // tender totals are already NET; this is the "given away" figure beside them. let discountTotalMinor = 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 (typeof pl.discountMinor === "number") discountTotalMinor += pl.discountMinor; 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, discountTotalMinor, 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 & { 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 { 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, discountTotalMinor, 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, discountTotalMinor, 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): 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}`, "", "-- 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}`, // Merchant-validation leakage — printed only when the shift actually gave any // (older slips stay byte-identical). The takings above are already NET of it. ...(r.discountTotalMinor > 0 ? [`Zbritje (validime): ${money(r.discountTotalMinor)} ${cur}`] : []), "", "-- Arka --", `Gjëndje fillestare: ${money(r.openingFloatMinor)} ${cur}`, `Para të grumbulluara: ${money(r.cashTotalMinor)} ${cur}`, `Arkëtime: ${money(r.cashAddedMinor)} ${cur}`, `Pagesa: ${money(r.cashRemovedMinor)} ${cur}`, `Gjëndje aktuale: ${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 { 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 { 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; } } }