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:
@@ -1,5 +1,6 @@
|
|||||||
import { eq, ledgerEvents, siteConfig, type Db } from "@parking/db";
|
import { eq, ledgerEvents, siteConfig, type Db } from "@parking/db";
|
||||||
import {
|
import {
|
||||||
|
formatStampSq,
|
||||||
printWithFailover,
|
printWithFailover,
|
||||||
registry,
|
registry,
|
||||||
type PrinterDevice,
|
type PrinterDevice,
|
||||||
@@ -154,3 +155,38 @@ export async function printSubscriptionCard(
|
|||||||
logger.info(`subscription card ${card.code} printed on ${printedBy}`);
|
logger.info(`subscription card ${card.code} printed on ${printedBy}`);
|
||||||
return 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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,13 +10,14 @@ import {
|
|||||||
type DeviceRow,
|
type DeviceRow,
|
||||||
} from "@parking/db";
|
} from "@parking/db";
|
||||||
import { registry, type AccessControlDevice } from "@parking/devices";
|
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 type { FastifyBaseLogger } from "fastify";
|
||||||
|
import { printWindowChargeNotice } from "./booth-print.js";
|
||||||
import type { DeviceReadEvent, ReadOutcome } from "./device-events.js";
|
import type { DeviceReadEvent, ReadOutcome } from "./device-events.js";
|
||||||
import type { EventLog } from "./event-log.js";
|
import type { EventLog } from "./event-log.js";
|
||||||
import { type FlowDirection, type ResolvedRelay } from "./device-resolve.js";
|
import { type FlowDirection, type ResolvedRelay } from "./device-resolve.js";
|
||||||
import { snapshotAsync } from "./snapshot.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";
|
import type { VisionClient } from "./vision-client.js";
|
||||||
|
|
||||||
// SUBSCRIPTION flow: a subscriber identified by card/QR/plate enters/exits without
|
// SUBSCRIPTION flow: a subscriber identified by card/QR/plate enters/exits without
|
||||||
@@ -245,6 +246,18 @@ export class SubscriptionFlow {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.#logger.error(`session-cache insert failed for ${occurrenceId}: ${(err as Error).message}`);
|
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" };
|
return { accepted: true, direction: "entry" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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).
|
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
|
**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`).
|
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
|
- **The owed amount is ONE computation over the whole stay** (`windowOwedBetween` →
|
||||||
`vehicle_entry` payload** (`windowOwedMinor` + the priced gap + `windowTariffVersionId`) — the
|
`minutesOutsideWindow(timeframes, tz, entry, now)`): the minutes within `[entry, now]` that fall
|
||||||
on-chain source of truth, read back at exit.
|
outside the allowed window — covering **early entry AND late exit together**, bounded by the stay,
|
||||||
- **Late exit is GATED:** at exit, `totalOwed = carried entry charge + a fresh late-exit charge −
|
off-days free. Priced once as a transient duration (so increments + the daily cap apply). This
|
||||||
payments`. If `> 0`, the exit is **REFUSED** with a new signed reason `sub.refused.unpaidWindow`;
|
replaced an earlier buggy "entry-gap + exit-gap" sum whose exit gap reached back to a *previous*
|
||||||
the subscriber settles at the booth (a signed `payment` keyed to the occurrence — folds into the
|
day's close, charging a phantom ~12h to a car that had just entered early (the 4,100 ALL bug,
|
||||||
shift/drawer/Z-report like any taking) and re-scans. The booth pay modal surfaces the amount as an
|
fixed 2026-06-20). Both the exit gate and the booth quote call this one function, so they agree.
|
||||||
"OUT-OF-WINDOW" charge (`PayStation.lookup`/`pay` handle the subscription-window case).
|
- **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**,
|
> ⚠ **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
|
> 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*
|
> *choosing* to refuse an unpaid car. The standing **fail-open** rule governs the *can't-decide*
|
||||||
|
|||||||
Reference in New Issue
Block a user