Files
parking_solution/apps/server/src/shift-service.ts
T
julian a9ccf9e20c feat(carwash): Car Wash v1 + per-till shifts + site-level pay-at + till access by module permission
Car Wash — the pilot venue module (wiki/decisions/venue-modules.md):
- Master data (categories × services price matrix) at /setup/carwash; the desk at /wash
  (ticket lookup → order; open queue oldest-first: Done / Paid cash / Paid card / Void;
  Finished list). Orders freeze names + price; their life is signed (carwash_order,
  carwash_payment). Migration 0027.
- Where money is taken is a SITE setting (carwash_config.pay_at, migration 0028, signed
  config_change on a flip) — no per-order radio; a stale client is refused (409).
- Core seams: PayStation charge providers (a booth-paid wash rides the parking payment as
  chargeLines) + applyValidation() shared with the merchant route. A bay-paid, done wash
  signs the $0 parking payment so the exit reader releases the car.
- "Parking discount" modes for the wash: free while the wash runs (+ tolerance) and wash
  price off the fee (floored at 0), resolved at done and anchored at the order's intake
  (the entry-anchored version comped a 74-day stay); typed-amount and percent hidden for
  the wash. Long durations render y/d/h/m.

Tills — a shift belongs to a till, not the site (wiki/concepts/shift.md §Tills):
- TillId booth|carwash; every money event names its till (absent = booth, so the chain
  re-folds identically). ShiftService is per till: single-open, folds, X/Z-reports,
  vouchers, carry-forward. A bay payment needs the carwash shift.
- Working a till needs that till's module permission (manifest tillPermission; 403
  till_forbidden); /api/shift/tills lists only the role's tills.
- Web: ShiftButton per till (header = booth, wash desk = carwash); shift hub lists every
  open shift with till badges + filter; drawer hub switches tills.

Modules: landing per module (index route resolves booth → module landing → shifts →
profile); guards bounce to "/", /booth needs session:read.

Tests: carwash e2e suite (settings, intake, booth/bay paths, modes, void, gate, pay-at
policy, till permissions), 6 per-till shift tests; suite green (1 pre-existing flaky
backup test under the parallel run).

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-05 13:23:09 +02:00

837 lines
36 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { eq, devices, ledgerEvents, type Db, inArray } from "@parking/db";
import { registry, formatStampSq as zStamp, type PrinterDevice } from "@parking/devices";
import { BOOTH_TILL, tillOf, type LedgerPayload, type TillId } 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.
//
// TILLS (2026-09-05): a shift is opened ON A TILL — the booth, or a money-taking
// module's own desk (Car Wash → "carwash"). One shift may be open PER TILL, each with
// its own operator, opening float, expected drawer and Z-report. Every money event
// names its till (`payload.till`; absent = booth, which is what every pre-till event
// is), and every fold in this file filters by it. Every public method takes the till,
// defaulting to the booth so the parking paths read as they always did.
// 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, till: TillId = BOOTH_TILL) {
super(
heldBy === operator
? `operator ${operator} already has an open ${till} shift`
: `another operator (${heldBy}) has an open ${till} shift; only one shift may be open per till`,
);
this.name = "ShiftAlreadyOpenError";
this.heldBy = heldBy;
}
}
export class NoOpenShiftError extends Error {
constructor(operator: string, till: TillId = BOOTH_TILL) {
super(`operator ${operator} has no open ${till} shift`);
this.name = "NoOpenShiftError";
}
}
/** Thrown by a money path when NO shift is open on its till — an operator must open
* a shift there before any payment/exit can be attributed to one. */
export class NoShiftOpenError extends Error {
readonly till: TillId;
constructor(till: TillId = BOOTH_TILL) {
super(
till === BOOTH_TILL
? "no shift is open — open a shift before processing tickets"
: `no ${till} shift is open — open one before taking money there`,
);
this.name = "NoShiftOpenError";
this.till = till;
}
}
/** 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;
/** The till this shift reconciled (booth for every pre-till report). */
readonly till: TillId;
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 till: TillId;
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";
/** Which drawer the cash moved in/out of. */
readonly till: TillId;
/** 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";
}
}
/** Printed (Albanian) name of a till on Z-reports and voucher slips. */
const TILL_PRINT_LABEL: Record<TillId, string> = { booth: "Kabina", carwash: "Lavazhi" };
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 of a till (cash payments + cash_movements, by
* time). For the UI to show "inherited / in the drawer now". */
drawerBalance(till: TillId = BOOTH_TILL): { balanceMinor: number; currency: string | null } {
return this.#drawerBalanceAt(new Date().toISOString(), till);
}
/** The shift-boundary events (shift_open / shift_z_report) of ONE till, chain order. */
#shiftEvents(till: TillId) {
return this.#db
.select()
.from(ledgerEvents)
.where(inArray(ledgerEvents.type, ["shift_open", "shift_z_report"]))
.orderBy(ledgerEvents.index)
.all()
.filter((r) => tillOf(r.payload as LedgerPayload | null) === till);
}
/** Is there an open shift for this operator on this till? Returns the open
* `shift_open` row or null. */
openShiftFor(operator: string, till: TillId = BOOTH_TILL) {
// The shift is open if the operator's most recent shift event on the till is a
// `shift_open` (not yet closed by a z_report).
const rows = this.#shiftEvents(till).filter((r) => r.identity === operator);
const last = rows[rows.length - 1];
return last && last.type === "shift_open" ? last : null;
}
/**
* The SINGLE open shift of a till, or null. A shift is the till's accountability
* period: at most ONE may be open per till at a time (so its takings are
* unambiguously attributed to one operator). It's open iff the till's most recent
* shift event 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(till: TillId = BOOTH_TILL) {
const rows = this.#shiftEvents(till);
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(till?: TillId): string[] {
const rows = this.#db
.select()
.from(ledgerEvents)
.where(inArray(ledgerEvents.type, ["shift_z_report", "shift_open"]))
.all()
.filter((r) => till == null || tillOf(r.payload as LedgerPayload | null) === till);
// Every operator with a closed report, plus the holder of each open shift (an
// open shift is the last shift_open on its till — but any shift_open's operator
// has or had a shift, which is all the dropdown needs).
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);
}
return [...names].sort((a, b) => a.localeCompare(b));
}
listShifts(opts: { operator?: string; from?: string; to?: string; till?: TillId } = {}): 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;
const till = tillOf(pl);
if (opts.till && till !== opts.till) continue;
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,
till,
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 on a till for its money path; returns it or throws. */
requireOpenShift(till: TillId = BOOTH_TILL) {
const open = this.currentOpenShift(till);
if (!open) throw new NoShiftOpenError(till);
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. ONE till: every money event is filtered by
* `tillOf(payload)` (absent = booth).
*/
#drawerBalanceAt(at: string, till: TillId): { balanceMinor: number; currency: string | null } {
const rows = this.#db
.select()
.from(ledgerEvents)
.orderBy(ledgerEvents.index)
.all()
.filter(
(r) =>
r.occurredAt <= at &&
(r.type === "payment" ||
// Car Wash module: money taken at the bay (cash adds to the drawer, card
// never does — same tender rule as a parking payment).
r.type === "carwash_payment" ||
r.type === "cash_in" ||
r.type === "cash_out" ||
r.type === "cash_movement") &&
tillOf(r.payload as LedgerPayload | null) === till,
);
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" || r.type === "carwash_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;
/** Which drawer the cash moved in/out of (default: the booth). */
till?: TillId;
}): Promise<{ type: "cash_in" | "cash_out"; till: TillId; amountMinor: number; voucherNo: string; balanceMinor: number; printed: boolean }> {
const { type, operator, reason } = args;
const till = args.till ?? BOOTH_TILL;
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,
till,
},
occurredAt: now,
});
const { balanceMinor, currency } = this.#drawerBalanceAt(now, till);
const printed = await this.#printVoucher({ type, voucherNo, amountMinor, reason, operator, currency, at: now, till });
this.#logger.info(
`${type} ${voucherNo} ${amountMinor} by ${operator} on ${till} (${reason || "no reason"}) → drawer ${balanceMinor}`,
);
return { type, till, 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; till?: TillId }): 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 till = tillOf(pl);
if (filter?.till && till !== filter.till) 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,
till,
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 on a till (explicit start). The opening float is
* auto-inherited from the chain = that till's drawer balance at the start instant. */
async open(operator: string, till: TillId = BOOTH_TILL): Promise<{ startedAt: string; till: TillId; openingFloatMinor: number }> {
// Single-open-per-till invariant: refuse if a shift is open ON THIS TILL — whether
// this operator's own (double-open) or another operator's (handover not done).
// One accountability period per drawer at a time. (Another till's shift is
// independent: the booth and the wash desk run side by side.)
const current = this.currentOpenShift(till);
if (current) throw new ShiftAlreadyOpenError(operator, current.identity ?? operator, till);
const startedAt = new Date().toISOString();
const { balanceMinor: openingFloatMinor } = this.#drawerBalanceAt(startedAt, till);
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, till },
occurredAt: startedAt,
});
this.#logger.info(`${till} shift opened for ${operator} (opening float ${openingFloatMinor})`);
return { startedAt, till, 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;
const till = tillOf(open.payload as LedgerPayload | null);
// All payments taken ON THIS TILL 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)
// Parking payments + Car Wash bay payments (a wash paid at the BOOTH is inside the
// parking payment's amount already, as chargeLines). Both fold into the cash/card
// tender totals so the expected drawer is right; a separate wash bucket on the
// Z-report is a follow-up (venue-modules.md).
.where(inArray(ledgerEvents.type, ["payment", "carwash_payment"]))
.all()
.filter(
(r) => r.occurredAt >= startedAt && r.occurredAt <= asOf && tillOf(r.payload as LedgerPayload | null) === till,
);
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, till).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 &&
tillOf(r.payload as LedgerPayload | null) === till,
);
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 {
till,
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(till: TillId = BOOTH_TILL): (Omit<ShiftReport, "printed"> & { asOf: string }) | null {
const open = this.currentOpenShift(till);
if (!open) return null;
const asOf = new Date().toISOString();
return { ...this.#summariseWindow(open, asOf), asOf };
}
/** Close the operator's open shift on a till: sum its payments in the window, sign +
* print the Z-report. */
async close(operator: string, till: TillId = BOOTH_TILL): Promise<ShiftReport> {
const open = this.openShiftFor(operator, till);
if (!open) throw new NoOpenShiftError(operator, till);
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,
till,
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(
`${till} 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 = [
// Which drawer this report reconciles — only printed off the booth, so booth
// slips stay byte-identical to before tills existed.
...(r.till !== BOOTH_TILL ? [`Arka: ${TILL_PRINT_LABEL[r.till]}`] : []),
`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;
till: TillId;
}): 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}`,
...(v.till !== BOOTH_TILL ? [`Arka: ${TILL_PRINT_LABEL[v.till]}`] : []),
`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;
}
}
}