feat(subs): print an advisory "out-of-window" slip at early entry

A subscriber entering outside their plan's window owes a deferred charge, but
nothing printed — they had no paper proof a fee was pending. Print a best-effort
ADVISORY slip at entry ("PARKIM — JASHTË ORARIT"): holder, entry time, "entered
out-of-window (window opens HH:MM)", and the key line "⚠ fee computed at exit"
+ the occurrence number. It is NOT a payable ticket and carries NO amount — the
total is computed at the booth on settlement, combining early-entry AND any
late-exit time into one number (windowOwedBetween over the whole stay).

Best-effort like the Z-report / subscription card: printed AFTER the barrier
opens and fully swallowed, so a missing/failed printer never blocks entry. New
printWindowChargeNotice (booth-print.ts) via the generic printReport; wired into
the subscription entry flow when an out-of-window entry charge applies.

(The "both charges at the booth" requirement was already satisfied by the
windowOwedBetween fix — verified: early-entry + late-exit minutes combine in one
calc at lookup/exit. This commit only adds the entry paper trail.) Build+lint 12/12.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
2026-06-20 20:47:45 +02:00
parent de858e91f4
commit f2734641b2
3 changed files with 69 additions and 10 deletions
+36
View File
@@ -1,5 +1,6 @@
import { eq, ledgerEvents, siteConfig, type Db } from "@parking/db";
import {
formatStampSq,
printWithFailover,
registry,
type PrinterDevice,
@@ -154,3 +155,38 @@ export async function printSubscriptionCard(
logger.info(`subscription card ${card.code} printed on ${printedBy}`);
return printedBy;
}
/**
* Print an ADVISORY "out-of-window" slip when a subscriber enters (or exits) outside
* their plan's allowed hours. It is NOT a payable ticket and carries NO final amount —
* the total is computed at the booth on settlement (early-entry AND any late-exit time
* combined). It just gives the subscriber paper proof that a fee is pending against this
* occurrence. Albanian (like every customer-facing slip — see i18n.md). Best-effort:
* the caller swallows failures so a missing printer never blocks the barrier.
*/
export async function printWindowChargeNotice(
db: Db,
notice: { occurrenceId: string; holderName?: string | null; at: string; windowOpensMin?: number; edge: "entry" | "exit" },
logger: FastifyBaseLogger,
): Promise<string> {
const printers = loadPrinters(db);
const hhmm = (m?: number) =>
m == null ? "" : `${String(Math.floor(m / 60)).padStart(2, "0")}:${String(m % 60).padStart(2, "0")}`;
const lines = [
`Abonent: ${notice.holderName || "-"}`,
`${notice.edge === "entry" ? "Hyrje" : "Dalje"}: ${formatStampSq(notice.at)}`,
notice.edge === "entry"
? `Ka hyrë jashtë orarit${notice.windowOpensMin != null ? ` (orari hap ${hhmm(notice.windowOpensMin)})` : ""}`
: "Ka dalë jashtë orarit",
"",
"⚠ Detyrim do të llogaritet në dalje",
" (paguhet në kabinë para se të dilni)",
"",
`Nr: ${notice.occurrenceId}`,
];
const printedBy = await printWithFailover(printers, "booth-receipt", (d: PrinterDevice) =>
d.printReport({ title: "PARKIM — JASHTË ORARIT", lines }),
);
logger.info(`out-of-window notice printed for ${notice.occurrenceId} on ${printedBy}`);
return printedBy;
}
+15 -2
View File
@@ -10,13 +10,14 @@ import {
type DeviceRow,
} from "@parking/db";
import { registry, type AccessControlDevice } from "@parking/devices";
import { reasonPayload, type ReasonCode } from "@parking/shared";
import { reasonPayload, type PlanTimeframes, type ReasonCode } from "@parking/shared";
import type { FastifyBaseLogger } from "fastify";
import { printWindowChargeNotice } from "./booth-print.js";
import type { DeviceReadEvent, ReadOutcome } from "./device-events.js";
import type { EventLog } from "./event-log.js";
import { type FlowDirection, type ResolvedRelay } from "./device-resolve.js";
import { snapshotAsync } from "./snapshot.js";
import { windowCharge, windowOwedBetween } from "./subscription-window.js";
import { planVersionById, windowCharge, windowOwedBetween } from "./subscription-window.js";
import type { VisionClient } from "./vision-client.js";
// SUBSCRIPTION flow: a subscriber identified by card/QR/plate enters/exits without
@@ -245,6 +246,18 @@ export class SubscriptionFlow {
} catch (err) {
this.#logger.error(`session-cache insert failed for ${occurrenceId}: ${(err as Error).message}`);
}
// BEST-EFFORT: print an advisory "out-of-window" slip so the subscriber has paper
// proof a fee is pending (the final amount is computed at the booth on settlement,
// combining early-entry + any late-exit time). AFTER the open + cache, and fully
// swallowed — a missing/failed printer must NEVER block or delay the barrier.
if (entryCharge) {
const tf = (planVersionById(this.#db, sub.planVersionId)?.timeframes ?? null) as PlanTimeframes | null;
void printWindowChargeNotice(
this.#db,
{ occurrenceId, holderName: sub.holderName, at: now, windowOpensMin: tf?.fromMin, edge: "entry" },
this.#logger,
).catch((err) => this.#logger.warn(`out-of-window slip print failed for ${occurrenceId}: ${(err as Error).message}`));
}
return { accepted: true, direction: "entry" };
}