From df5caf8d875be0550971c6680305162678fc80ec Mon Sep 17 00:00:00 2001 From: Julian Cuni Date: Sun, 21 Jun 2026 13:12:05 +0200 Subject: [PATCH] feat(subs): scannable out-of-window slip + two-step booth flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/server/src/booth-print.ts | 26 ++-- apps/web/src/BoothPayModal.tsx | 145 ++++++++++++------ apps/web/src/lib/i18n/en.ts | 5 +- apps/web/src/lib/i18n/sq.ts | 5 +- .../devices/src/drivers/printer-cashino.ts | 7 + .../devices/src/drivers/printer-escpos.ts | 71 +++++++++ .../devices/src/drivers/printer-rongta.ts | 7 + packages/devices/src/interfaces.ts | 22 +++ 8 files changed, 224 insertions(+), 64 deletions(-) diff --git a/apps/server/src/booth-print.ts b/apps/server/src/booth-print.ts index 327335c..7c56b31 100644 --- a/apps/server/src/booth-print.ts +++ b/apps/server/src/booth-print.ts @@ -1,6 +1,5 @@ import { eq, ledgerEvents, siteConfig, type Db } from "@parking/db"; import { - formatStampSq, printWithFailover, registry, type PrinterDevice, @@ -166,26 +165,19 @@ export async function printSubscriptionCard( */ export async function printWindowChargeNotice( 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, ): 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 }), + 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}`); return printedBy; diff --git a/apps/web/src/BoothPayModal.tsx b/apps/web/src/BoothPayModal.tsx index 670ac76..0c3d1de 100644 --- a/apps/web/src/BoothPayModal.tsx +++ b/apps/web/src/BoothPayModal.tsx @@ -45,6 +45,13 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose const [result, setResult] = useState(null); const [openingShift, setOpeningShift] = 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; // 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() { setOpeningShift(true); setError(null); @@ -280,17 +306,22 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose - {/* For a subscription with a window charge, explain why it's payable. For a - plain prepaid subscription, explain the assist-open is the only action. */} - {subWindowDue ? ( + {/* Subscription guidance: an unpaid window charge explains the pay-first + gate; once paid, prompt the operator to open the barrier; a prepaid + subscriber sees the assist explanation only after revealing it. */} + {subWindowDue && !windowPaid ? (
{t("pay.windowChargeHint")}
- ) : isSubscription && ( + ) : isSubscription && windowPaid ? ( +
+ {t("pay.windowPaidHint")} +
+ ) : isSubscription && assistRevealed ? (
{t("pay.subAssistHint")}
- )} + ) : null} {/* For an overstay, explain why a top-up is required (no free exit). */} {isOverstay && ( @@ -302,37 +333,36 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose {/* Snapshots */} - {phase !== "done" && !isSubscription && ( - <> - {/* Tender */} - {canPay && ( -
- {t("pay.tender")} - {(["cash", "card"] as const).map((tn) => ( - - ))} -
- )} + {/* Tender — shown for any payable case (transient, overstay, OR a + subscriber window charge that's still unpaid). */} + {phase !== "done" && canPay && !(subWindowDue && windowPaid) && ( +
+ {t("pay.tender")} + {(["cash", "card"] as const).map((tn) => ( + + ))} +
+ )} - {/* Voucher checkbox (default from site config) */} - - + {/* Voucher checkbox (transient only; a subscriber doesn't self-exit). */} + {phase !== "done" && !isSubscription && ( + )} {error &&
{error}
} @@ -374,16 +404,41 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose {t("common.cancel")} {isSubscription ? ( - // Prepaid — the only action is the audited barrier open (assist - // a faulty exit reader / missing card). Gated on an open shift. - + subWindowDue && !windowPaid ? ( + // Step 1 — a window charge is owed: take payment first. The + // barrier open is the explicit next step (revealed once paid). + + ) : 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. + + ) : ( + // Prepaid, nothing owed: no default open. A small reveal exposes + // the audited manual open for a faulty reader / lost card. + + ) ) : (