eb47016ae3
CI / check (push) Failing after 31s
Three changes: 1. Confirm-before-close. The header shift button closed the shift directly — a stray click would sign the irreversible Z-report. It now opens a confirm modal showing the live X-report (takings split by source + expected drawer) with Cancel / End-shift. Opening a shift stays immediate (no such risk). 2. Split takings by SOURCE. The report separates Tickets (transient) from Subscriptions (monthly sales + a subscriber's out-of-window charge), so the operator sees subscriber money apart from ticket money. Buckets are derived from the signed payment payload flags (subscriptionSale / subscriptionWindowCharge) and always reconcile to cash + card (a payment with neither flag is a ticket). Computed in #summariseWindow, carried on the signed shift_z_report payload, and shown in the X-report, the close modal, the shift history detail, and the printed Z-report. Reports predating the fields default subscription to 0 (ticket absorbs the whole take), so old shifts still reconcile. 3. Fix dark-theme native <select> popups rendering WHITE on WebKitGTK (the Tauri Linux WebView): set color-scheme dark/light on <html> per theme + explicit <option> colours, so the OS-drawn dropdown list follows the theme. Verified the split on a read-only DB copy: tickets 0, subscriptions 10,200 (10,000 sale + 200 out-of-window), reconciles to cash+card. build+lint 14/14, i18n parity (sq+en). Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
626 lines
25 KiB
TypeScript
626 lines
25 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;
|
||
}
|
||
|
||
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;
|
||
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 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 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,
|
||
source: "manual",
|
||
identity: operator, // who RAISED the voucher (the operator at the booth)
|
||
payload: {
|
||
amountMinor, // positive magnitude — direction is the type
|
||
...(reason ? { reason } : {}),
|
||
...(args.currency ? { currency: args.currency } : {}),
|
||
operator,
|
||
authorizedBy,
|
||
voucherNo,
|
||
},
|
||
occurredAt: now,
|
||
});
|
||
const { balanceMinor, currency } = this.#drawerBalanceAt(now);
|
||
const printed = await this.#printVoucher({ type, voucherNo, amountMinor, reason, operator, authorizedBy, currency, at: now });
|
||
this.#logger.info(
|
||
`${type} ${voucherNo} ${amountMinor} by ${operator} authz ${authorizedBy} (${reason || "no reason"}) → drawer ${balanceMinor}`,
|
||
);
|
||
return { type, amountMinor, voucherNo, balanceMinor, printed };
|
||
}
|
||
|
||
/** 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: ${money(r.subscriptionTotalMinor)} ${cur}`,
|
||
` shitje: ${money(r.subscriptionSalesMinor)} ${cur}`,
|
||
` jashtë orarit: ${money(r.subscriptionWindowMinor)} ${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;
|
||
}
|
||
}
|
||
|
||
/** 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<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 || "-"}`,
|
||
"",
|
||
`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<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;
|
||
}
|
||
}
|
||
}
|