From f2734641b2e4b121e32e3ae60fcf6fe08cb0a78c Mon Sep 17 00:00:00 2001 From: Julian Cuni Date: Sat, 20 Jun 2026 20:47:45 +0200 Subject: [PATCH] feat(subs): print an advisory "out-of-window" slip at early entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/server/src/booth-print.ts | 36 ++++++++++++++++++++++++++++ apps/server/src/subscription-flow.ts | 17 +++++++++++-- wiki/entities/subscription.md | 26 +++++++++++++------- 3 files changed, 69 insertions(+), 10 deletions(-) diff --git a/apps/server/src/booth-print.ts b/apps/server/src/booth-print.ts index ede1355..327335c 100644 --- a/apps/server/src/booth-print.ts +++ b/apps/server/src/booth-print.ts @@ -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 { + 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; +} diff --git a/apps/server/src/subscription-flow.ts b/apps/server/src/subscription-flow.ts index b877dec..ff5666d 100644 --- a/apps/server/src/subscription-flow.ts +++ b/apps/server/src/subscription-flow.ts @@ -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" }; } diff --git a/wiki/entities/subscription.md b/wiki/entities/subscription.md index 560c81a..bf13e91 100644 --- a/wiki/entities/subscription.md +++ b/wiki/entities/subscription.md @@ -103,14 +103,24 @@ out-of-window scans, the system **charges the out-of-window minutes at the norma window-open (a 09:00 arrival to a 20:00 window owes 09:00→20:00, capped by the tariff's daily cap). **Late exit**: gap = window-close → departure. The gap is priced with `computeFee` (the same engine transient stays use) at the active tariff version (`apps/server/src/subscription-window.ts`). -- **Early entry is DEFERRED:** the barrier opens now; the owed amount is **signed onto the - `vehicle_entry` payload** (`windowOwedMinor` + the priced gap + `windowTariffVersionId`) — the - on-chain source of truth, read back at exit. -- **Late exit is GATED:** at exit, `totalOwed = carried entry charge + a fresh late-exit charge − - payments`. If `> 0`, the exit is **REFUSED** with a new signed reason `sub.refused.unpaidWindow`; - the subscriber settles at the booth (a signed `payment` keyed to the occurrence — folds into the - shift/drawer/Z-report like any taking) and re-scans. The booth pay modal surfaces the amount as an - "OUT-OF-WINDOW" charge (`PayStation.lookup`/`pay` handle the subscription-window case). +- **The owed amount is ONE computation over the whole stay** (`windowOwedBetween` → + `minutesOutsideWindow(timeframes, tz, entry, now)`): the minutes within `[entry, now]` that fall + outside the allowed window — covering **early entry AND late exit together**, bounded by the stay, + off-days free. Priced once as a transient duration (so increments + the daily cap apply). This + replaced an earlier buggy "entry-gap + exit-gap" sum whose exit gap reached back to a *previous* + day's close, charging a phantom ~12h to a car that had just entered early (the 4,100 ALL bug, + fixed 2026-06-20). Both the exit gate and the booth quote call this one function, so they agree. +- **Early entry is DEFERRED:** the barrier opens now; an advisory `windowOwedMinor` + priced gap are + signed onto the `vehicle_entry` for the feed badge, and a **best-effort advisory slip prints** + ("PARKIM — JASHTË ORARIT": entered out-of-window, *fee computed at exit*, occurrence no.) so the + subscriber has paper proof. A missing/failed printer NEVER blocks the barrier (`printWindowChargeNotice`, + fully swallowed, after the open). +- **Late exit is GATED:** at exit, `owed = windowOwedBetween(entry, now) − payments`. If `> 0`, the + exit is **REFUSED** with the signed reason `sub.refused.unpaidWindow`; the subscriber settles at + the booth (a signed `payment` keyed to the occurrence — folds into the shift/drawer/Z-report like + any taking) and re-scans. The booth pay modal surfaces it as an "OUT-OF-WINDOW" charge + (`PayStation.lookup`/`pay`). So an early-entry-then-late-exit subscriber pays **both** portions in a + single amount, computed when they reach the booth. > ⚠ **Exit gate vs. "never trap a vehicle."** This refusal is a **host-ONLINE business gate**, > identical in kind to the existing transient `exit.refused.unpaid`/overstay gate — a working host > *choosing* to refuse an unpaid car. The standing **fail-open** rule governs the *can't-decide*