feat(subs): scannable out-of-window slip + two-step booth flow

The advisory out-of-window slip for a subscriber had two problems:

1. Faulty character codes. It rendered via the generic text printReport,
   which has no CP852 mapping for the em dash, ellipsis, or warning sign in
   the composed strings — so they printed as "?" ("PARKIM ? JASHTE ORARIT").
   Added ASCII transliterations for that typographic punctuation in the
   ESC/POS encoder (— → -, ⚠ → !, … → ..., curly quotes/bullet), so they
   degrade to a readable glyph instead of "?".

2. Not scannable. The slip printed only "Nr: SUBSESS-…" as plain text, so
   the operator had to hand-key it. Gave the notice its own render function
   (renderWindowChargeNotice) + a printWindowChargeNotice device method that
   prints the occurrence id as a Code128 AND a QR — the same scan path as a
   transient ticket, so the operator scans it straight into the booth pay
   modal, which then quotes the combined window charge. Implemented on both
   the rongta and cashino drivers.

Also fixed the booth pay modal: "Open barrier" no longer shows by default
for a subscriber. A prepaid subscriber with nothing owed sees only a small
"assist open" reveal (the audited manual open for a faulty reader / lost
card stays available, just not the default). A subscriber owing an
out-of-window charge is now two steps — take payment first, then "Open
barrier" appears — instead of an always-on open button.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
2026-06-21 13:12:05 +02:00
parent 0cbae94842
commit df5caf8d87
8 changed files with 224 additions and 64 deletions
+9 -17
View File
@@ -1,6 +1,5 @@
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,
@@ -166,26 +165,19 @@ export async function printSubscriptionCard(
*/ */
export async function printWindowChargeNotice( export async function printWindowChargeNotice(
db: Db, db: Db,
notice: { occurrenceId: string; holderName?: string | null; at: string; windowOpensMin?: number; edge: "entry" | "exit" }, notice: { occurrenceId: string; holderName?: string | null; at: string; windowOpensMin?: number | null; edge: "entry" | "exit" },
logger: FastifyBaseLogger, logger: FastifyBaseLogger,
): Promise<string> { ): Promise<string> {
const printers = loadPrinters(db); 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) => const printedBy = await printWithFailover(printers, "booth-receipt", (d: PrinterDevice) =>
d.printReport({ title: "PARKIM — JASHTË ORARIT", lines }), d.printWindowChargeNotice({
occurrenceId: notice.occurrenceId,
holderName: notice.holderName ?? null,
at: notice.at,
edge: notice.edge,
windowOpensMin: notice.windowOpensMin ?? null,
header: ticketHeader(db),
}),
); );
logger.info(`out-of-window notice printed for ${notice.occurrenceId} on ${printedBy}`); logger.info(`out-of-window notice printed for ${notice.occurrenceId} on ${printedBy}`);
return printedBy; return printedBy;
+100 -45
View File
@@ -45,6 +45,13 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
const [result, setResult] = useState<string | null>(null); const [result, setResult] = useState<string | null>(null);
const [openingShift, setOpeningShift] = useState(false); const [openingShift, setOpeningShift] = useState(false);
const [reprinting, setReprinting] = useState(false); const [reprinting, setReprinting] = useState(false);
// For a PREPAID subscriber with nothing owed, the audited manual barrier open
// (assist a faulty reader / lost card) is no longer the default action — the
// operator reveals it explicitly so the modal isn't an always-on "open" button.
const [assistRevealed, setAssistRevealed] = useState(false);
// For a subscriber WINDOW CHARGE, payment and the barrier open are two steps: pay
// first, then the modal reveals "Open barrier". This flips true once paid.
const [windowPaid, setWindowPaid] = useState(false);
const s: SessionLookup | undefined = session.data; const s: SessionLookup | undefined = session.data;
// Checkbox default comes from config the first time it loads; operator can toggle. // Checkbox default comes from config the first time it loads; operator can toggle.
@@ -87,6 +94,25 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
} }
} }
// Subscriber out-of-window charge: take the payment, but DON'T exit yet. The
// barrier open is the operator's explicit second step (so the flow reads:
// pay → then Open barrier), mirroring the two-step the operator asked for.
async function handlePaySubscriptionWindow() {
if (!s) return;
setError(null);
setPhase("paying");
try {
await paySession(identity, tender);
setWindowPaid(true);
setPhase("review");
void qc.invalidateQueries({ queryKey: ["session", identity] });
void qc.invalidateQueries({ queryKey: qk.events });
} catch (e) {
setError((e as Error).message);
setPhase("error");
}
}
async function handleOpenShift() { async function handleOpenShift() {
setOpeningShift(true); setOpeningShift(true);
setError(null); setError(null);
@@ -280,17 +306,22 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
</span> </span>
</div> </div>
{/* For a subscription with a window charge, explain why it's payable. For a {/* Subscription guidance: an unpaid window charge explains the pay-first
plain prepaid subscription, explain the assist-open is the only action. */} gate; once paid, prompt the operator to open the barrier; a prepaid
{subWindowDue ? ( subscriber sees the assist explanation only after revealing it. */}
{subWindowDue && !windowPaid ? (
<div className="rounded-term border border-term-amber/40 bg-term-amber/5 px-3 py-2 text-[12px] text-term-text"> <div className="rounded-term border border-term-amber/40 bg-term-amber/5 px-3 py-2 text-[12px] text-term-text">
{t("pay.windowChargeHint")} {t("pay.windowChargeHint")}
</div> </div>
) : isSubscription && ( ) : isSubscription && windowPaid ? (
<div className="rounded-term border border-term-green/40 bg-term-green/5 px-3 py-2 text-[12px] text-term-text">
{t("pay.windowPaidHint")}
</div>
) : isSubscription && assistRevealed ? (
<div className="rounded-term border border-term-cyan/40 bg-term-cyan/5 px-3 py-2 text-[12px] text-term-text"> <div className="rounded-term border border-term-cyan/40 bg-term-cyan/5 px-3 py-2 text-[12px] text-term-text">
{t("pay.subAssistHint")} {t("pay.subAssistHint")}
</div> </div>
)} ) : null}
{/* For an overstay, explain why a top-up is required (no free exit). */} {/* For an overstay, explain why a top-up is required (no free exit). */}
{isOverstay && ( {isOverstay && (
@@ -302,37 +333,36 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
{/* Snapshots */} {/* Snapshots */}
<SnapshotStrip identity={identity} /> <SnapshotStrip identity={identity} />
{phase !== "done" && !isSubscription && ( {/* Tender — shown for any payable case (transient, overstay, OR a
<> subscriber window charge that's still unpaid). */}
{/* Tender */} {phase !== "done" && canPay && !(subWindowDue && windowPaid) && (
{canPay && ( <div className="flex items-center gap-2">
<div className="flex items-center gap-2"> <span className="text-[11px] uppercase tracking-wider text-term-muted">{t("pay.tender")}</span>
<span className="text-[11px] uppercase tracking-wider text-term-muted">{t("pay.tender")}</span> {(["cash", "card"] as const).map((tn) => (
{(["cash", "card"] as const).map((tn) => ( <button
<button key={tn}
key={tn} type="button"
type="button" onClick={() => setTender(tn)}
onClick={() => setTender(tn)} className={tender === tn ? "btn btn-primary btn-sm" : "btn btn-sm"}
className={tender === tn ? "btn btn-primary btn-sm" : "btn btn-sm"} >
> {t(`pay.${tn}`)}
{t(`pay.${tn}`)} </button>
</button> ))}
))} </div>
</div> )}
)}
{/* Voucher checkbox (default from site config) */} {/* Voucher checkbox (transient only; a subscriber doesn't self-exit). */}
<label className="flex items-center gap-2 text-[12px]"> {phase !== "done" && !isSubscription && (
<input <label className="flex items-center gap-2 text-[12px]">
type="checkbox" <input
className="accent-term-amber" type="checkbox"
checked={voucher} className="accent-term-amber"
onChange={(e) => setPrintVoucherChecked(e.target.checked)} checked={voucher}
/> onChange={(e) => setPrintVoucherChecked(e.target.checked)}
{t("pay.printExitVoucher")} />
<span className="text-term-muted">{t("pay.selfExitHint")}</span> {t("pay.printExitVoucher")}
</label> <span className="text-term-muted">{t("pay.selfExitHint")}</span>
</> </label>
)} )}
{error && <div className="rounded-term border border-term-red px-3 py-2 text-term-red">{error}</div>} {error && <div className="rounded-term border border-term-red px-3 py-2 text-term-red">{error}</div>}
@@ -374,16 +404,41 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
{t("common.cancel")} {t("common.cancel")}
</button> </button>
{isSubscription ? ( {isSubscription ? (
// Prepaid — the only action is the audited barrier open (assist subWindowDue && !windowPaid ? (
// a faulty exit reader / missing card). Gated on an open shift. // Step 1 — a window charge is owed: take payment first. The
<button // barrier open is the explicit next step (revealed once paid).
type="button" <button
onClick={handleOpenBarrier} type="button"
disabled={!shiftReady || phase === "finishing"} onClick={handlePaySubscriptionWindow}
className="btn btn-pay btn-lg" disabled={!shiftReady || phase === "paying"}
> className="btn btn-go btn-lg"
{phase === "finishing" ? t("pay.opening") : t("booth.openBarrier")} >
</button> {phase === "paying" ? t("pay.takingPayment") : t("pay.payWindowCharge")}
</button>
) : windowPaid || assistRevealed ? (
// The audited barrier open. Shown only AFTER a window charge is
// settled, or after the operator explicitly reveals the assist —
// never as the default action for a prepaid subscriber.
<button
type="button"
onClick={handleOpenBarrier}
disabled={!shiftReady || phase === "finishing"}
className="btn btn-pay btn-lg"
>
{phase === "finishing" ? t("pay.opening") : t("booth.openBarrier")}
</button>
) : (
// Prepaid, nothing owed: no default open. A small reveal exposes
// the audited manual open for a faulty reader / lost card.
<button
type="button"
onClick={() => setAssistRevealed(true)}
disabled={!shiftReady}
className="btn btn-ghost btn-sm"
>
{t("pay.assistOpenReveal")}
</button>
)
) : ( ) : (
<button <button
type="button" type="button"
+4 -1
View File
@@ -705,8 +705,11 @@ export const en: Catalog = {
plan: "Plan", plan: "Plan",
prepaid: "PREPAID", prepaid: "PREPAID",
subAssistHint: "Prepaid subscription. Open the barrier to assist the exit (faulty reader / missing card). No payment.", subAssistHint: "Prepaid subscription. Open the barrier to assist the exit (faulty reader / missing card). No payment.",
assistOpenReveal: "Assist open (faulty reader / lost card)…",
payWindowCharge: "Take payment",
windowPaidHint: "Window charge paid. Open the barrier to let the subscriber out.",
windowCharge: "OUT-OF-WINDOW", windowCharge: "OUT-OF-WINDOW",
windowChargeHint: "This subscriber parked outside their plan's allowed hours. They owe the transient tariff for the out-of-window time — take payment to allow the exit.", windowChargeHint: "This subscriber parked outside their plan's allowed hours. They owe the transient tariff for the out-of-window time — take payment, then open the barrier.",
subBarrierOpened: "Barrier opened for the subscriber (intervention recorded).", subBarrierOpened: "Barrier opened for the subscriber (intervention recorded).",
voucherPrinted: "Exit voucher printed on {{printer}}. Customer self-exits at the exit.", voucherPrinted: "Exit voucher printed on {{printer}}. Customer self-exits at the exit.",
// payment receipt (transparency slip) // payment receipt (transparency slip)
+4 -1
View File
@@ -719,8 +719,11 @@ export const sq = {
plan: "Plani", plan: "Plani",
prepaid: "I PARAPAGUAR", prepaid: "I PARAPAGUAR",
subAssistHint: "Abonim i parapaguar. Hap barrierën për të ndihmuar daljen (lexues me defekt / kartë e munguar). S'ka pagesë.", subAssistHint: "Abonim i parapaguar. Hap barrierën për të ndihmuar daljen (lexues me defekt / kartë e munguar). S'ka pagesë.",
assistOpenReveal: "Ndihmë për hapje (lexues me defekt / kartë e humbur)…",
payWindowCharge: "Merr pagesën",
windowPaidHint: "Pagesa jashtë orarit u krye. Hap barrierën që abonenti të dalë.",
windowCharge: "JASHTË ORARIT", windowCharge: "JASHTË ORARIT",
windowChargeHint: "Ky abonent parkoi jashtë orarit të lejuar të planit. Detyrohet të paguajë tarifën kalimtare për kohën jashtë orarit — merr pagesën për të lejuar daljen.", windowChargeHint: "Ky abonent parkoi jashtë orarit të lejuar të planit. Detyrohet të paguajë tarifën kalimtare për kohën jashtë orarit — merr pagesën, pastaj hap barrierën.",
subBarrierOpened: "Barriera u hap për abonentin (ndërhyrje e regjistruar).", subBarrierOpened: "Barriera u hap për abonentin (ndërhyrje e regjistruar).",
voucherPrinted: "Bileta e daljes u printua në {{printer}}. Klienti del vetë te dalja.", voucherPrinted: "Bileta e daljes u printua në {{printer}}. Klienti del vetë te dalja.",
// payment receipt (transparency slip) // payment receipt (transparency slip)
@@ -5,6 +5,7 @@ import type {
ReceiptData, ReceiptData,
SubscriptionCardData, SubscriptionCardData,
TicketData, TicketData,
WindowChargeNoticeData,
} from "../interfaces.js"; } from "../interfaces.js";
import type { ConfigField, DeviceConfig, PrinterDriver } from "../registry.js"; import type { ConfigField, DeviceConfig, PrinterDriver } from "../registry.js";
import { hostField, portField, stubLog } from "./common.js"; import { hostField, portField, stubLog } from "./common.js";
@@ -14,6 +15,7 @@ import {
renderReport, renderReport,
renderSubscriptionCard, renderSubscriptionCard,
renderTicket, renderTicket,
renderWindowChargeNotice,
sendRaw, sendRaw,
} from "./printer-escpos.js"; } from "./printer-escpos.js";
@@ -102,6 +104,11 @@ class CashinoPrinter implements PrinterDevice {
`printed ${data.voucher ? "voucher" : "receipt"} ${data.ticketId}`, `printed ${data.voucher ? "voucher" : "receipt"} ${data.ticketId}`,
); );
} }
async printWindowChargeNotice(data: WindowChargeNoticeData): Promise<void> {
await sendRaw(this.#host, this.#port, renderWindowChargeNotice(data), this.#timeout);
stubLog(this.driverId, `printed out-of-window slip ${data.occurrenceId}`);
}
} }
const roleField: ConfigField = { const roleField: ConfigField = {
@@ -4,6 +4,7 @@ import type {
ReceiptData, ReceiptData,
SubscriptionCardData, SubscriptionCardData,
TicketData, TicketData,
WindowChargeNoticeData,
} from "../interfaces.js"; } from "../interfaces.js";
// Shared ESC/POS rendering + raw-TCP transport for 80mm thermal printers. // Shared ESC/POS rendering + raw-TCP transport for 80mm thermal printers.
@@ -68,6 +69,19 @@ const ASCII_FALLBACK: Record<string, string> = {
í: "i", í: "i",
ó: "o", ó: "o",
ú: "u", ú: "u",
// Typographic punctuation that creeps in from composed strings — CP852 has no
// em/en dash, ellipsis, curly quotes, or the warning sign, so without these they
// print as "?" (the cause of the "PARKIM ? JASHTË ORARIT" misprint). Degrade to
// the obvious ASCII equivalent rather than a literal "?".
"—": "-", // em dash U+2014
"–": "-", // en dash U+2013
"…": "...", // ellipsis U+2026
"‘": "'",
"’": "'",
"“": '"',
"”": '"',
"⚠": "!", // warning sign U+26A0 — no glyph on a thermal head; "!" reads as a flag
"•": "*",
}; };
/** Encode one line of text to CP852 bytes + a line feed. ASCII (<0x80) passes /** Encode one line of text to CP852 bytes + a line feed. ASCII (<0x80) passes
@@ -188,6 +202,21 @@ const STR = {
], ],
/** Thank-you footer. */ /** Thank-you footer. */
thanks: "Faleminderit!", thanks: "Faleminderit!",
// --- out-of-window advisory slip (subscriber) ---
/** Slip title. ASCII dash (not em dash) so no codepage surprise. */
windowTitle: "PARKIM - JASHTË ORARIT",
/** "Subscriber: <name>" line. */
windowHolder: (name: string) => `Abonent: ${name}`,
/** Entry-edge line; appends the window-open time when known. */
windowEnteredEarly: (opensHHMM?: string) =>
opensHHMM ? `Ka hyrë jashtë orarit (orari hap ${opensHHMM})` : "Ka hyrë jashtë orarit",
/** Exit-edge line. */
windowExitedLate: "Ka dalë jashtë orarit",
/** "Entry:" / "Exit:" stamp label per edge. */
windowStamp: (edge: "entry" | "exit", v: string) => (edge === "entry" ? `Hyrja: ${v}` : `Dalja: ${v}`),
/** Two short lines (each fits 80mm) telling the customer a fee is pending and
* is settled at the booth before exit. ASCII "!" flag (no glyph for ⚠). */
windowPending: ["! Detyrim do të llogaritet në dalje", " (paguhet në kabinë para se të dilni)"] as const,
} as const; } as const;
/** Format integer minor units + ISO-4217 currency as a major-unit string for the /** Format integer minor units + ISO-4217 currency as a major-unit string for the
@@ -409,6 +438,48 @@ export function renderReceipt(data: ReceiptData): Buffer {
return Buffer.concat(parts); return Buffer.concat(parts);
} }
/** Build the ESC/POS byte stream for the ADVISORY out-of-window slip. Header →
* title → a SCANNABLE Code128 + QR of the occurrence id (so the operator scans it
* straight into the booth pay modal — same path as a transient ticket) → the id in
* text (hand-key fallback) → holder + entry/exit stamp → the "pay at booth" notice.
* Carries NO amount (the booth quotes the combined charge at settlement). Albanian. */
export function renderWindowChargeNotice(data: WindowChargeNoticeData): Buffer {
const hhmm = (m?: number | null) =>
m == null ? undefined : `${String(Math.floor(m / 60)).padStart(2, "0")}:${String(m % 60).padStart(2, "0")}`;
const parts: Buffer[] = [
INIT,
SELECT_CP852,
renderHeader(data.header),
line(),
ALIGN_CENTER,
BOLD_ON,
line(STR.windowTitle),
BOLD_OFF,
line(),
// The occurrence id, scannable two ways: Code128 for a 1D laser scanner, QR for
// the booth's combo reader. Either pulls the occurrence up in the pay modal.
code128(data.occurrenceId),
line(),
qrCode(data.occurrenceId),
line(),
// The id in text, as the hand-key fallback if neither scans.
line(data.occurrenceId),
ALIGN_LEFT,
line(),
];
if (data.holderName) parts.push(line(STR.windowHolder(data.holderName)));
parts.push(line(STR.windowStamp(data.edge, stamp(data.at))));
parts.push(
line(data.edge === "entry" ? STR.windowEnteredEarly(hhmm(data.windowOpensMin)) : STR.windowExitedLate),
line(),
BOLD_ON,
...STR.windowPending.map((l) => line(l)),
BOLD_OFF,
FEED_AND_CUT,
);
return Buffer.concat(parts);
}
/** Open a TCP socket, write the bytes, and close GRACEFULLY so the printer reads the /** Open a TCP socket, write the bytes, and close GRACEFULLY so the printer reads the
* whole stream before the connection tears down. * whole stream before the connection tears down.
* *
@@ -8,6 +8,7 @@ import type {
PrintReport, PrintReport,
SubscriptionCardData, SubscriptionCardData,
TicketData, TicketData,
WindowChargeNoticeData,
} from "../interfaces.js"; } from "../interfaces.js";
import type { ConfigField, DeviceConfig, PrinterDriver } from "../registry.js"; import type { ConfigField, DeviceConfig, PrinterDriver } from "../registry.js";
import { hostField, portField, stubLog } from "./common.js"; import { hostField, portField, stubLog } from "./common.js";
@@ -17,6 +18,7 @@ import {
renderReport, renderReport,
renderSubscriptionCard, renderSubscriptionCard,
renderTicket, renderTicket,
renderWindowChargeNotice,
sendRaw, sendRaw,
} from "./printer-escpos.js"; } from "./printer-escpos.js";
@@ -186,6 +188,11 @@ class RongtaPrinter implements PrinterDevice, MonitorableDevice {
); );
} }
async printWindowChargeNotice(data: WindowChargeNoticeData): Promise<void> {
await sendRaw(this.#host, this.#port, renderWindowChargeNotice(data), this.#timeout);
stubLog(this.driverId, `printed out-of-window slip ${data.occurrenceId}`);
}
/** /**
* Live operator-actionable status, scraped from the device's own status page. * Live operator-actionable status, scraped from the device's own status page.
* The board decodes the ESC/POS status bits itself, so we trust its Yes/No * The board decodes the ESC/POS status bits itself, so we trust its Yes/No
+22
View File
@@ -247,6 +247,26 @@ export interface SubscriptionCardData {
readonly header?: TicketHeader; readonly header?: TicketHeader;
} }
/** An ADVISORY "out-of-window" slip for a subscriber who entered/exited outside
* their plan's allowed hours. NOT a payable ticket and carries NO final amount —
* the total is computed at the booth on settlement. It carries the OCCURRENCE id as
* a SCANNABLE Code128 + QR so the operator scans it straight into the booth pay
* modal (which then quotes the window charge) instead of hand-keying it — the same
* scan path as a transient ticket. See wiki/entities/subscription.md. */
export interface WindowChargeNoticeData {
/** The occurrence id (e.g. "SUBSESS-…") — the session identity the booth pay
* modal looks up. Encoded as the scannable code. */
readonly occurrenceId: string;
readonly holderName?: string | null;
/** When the scan happened (ISO-8601), printed as the human stamp. */
readonly at: string;
/** Entry (early) vs exit (late) — selects the wording. */
readonly edge: "entry" | "exit";
/** Minutes-from-midnight the allowed window opens, when known (entry slips). */
readonly windowOpensMin?: number | null;
readonly header?: TicketHeader;
}
export interface PrinterDevice extends Device { export interface PrinterDevice extends Device {
printTicket(data: TicketData): Promise<void>; printTicket(data: TicketData): Promise<void>;
/** Print a free-form text report (a shift Z-report, a receipt). `lines` are /** Print a free-form text report (a shift Z-report, a receipt). `lines` are
@@ -259,6 +279,8 @@ export interface PrinterDevice extends Device {
* voucher mode it also carries the ticket-id barcode + grace window so it * voucher mode it also carries the ticket-id barcode + grace window so it
* doubles as the self-exit voucher. See ReceiptData. */ * doubles as the self-exit voucher. See ReceiptData. */
printReceipt(data: ReceiptData): Promise<void>; printReceipt(data: ReceiptData): Promise<void>;
/** Print the advisory out-of-window slip with a scannable occurrence-id code. */
printWindowChargeNotice(data: WindowChargeNoticeData): Promise<void>;
} }
export interface PrintReport { export interface PrintReport {