feat(subscription): RFID enrollment, any-credential exit, prepaid booth handling

Rounds out subscriptions across enrollment, the barrier flow, and the booth.

- RFID credentials enabled with a "Read card" enrollment flow: the operator
  arms ONE chosen reader (CredentialCapture, single-shot + ~30s TTL); that
  reader's next read is captured into the form and NOT dispatched to the access
  flow — the OTHER reader keeps serving live entry/exit. Routes:
  /api/subscriptions/readers + /capture/{arm,cancel} + poll.
- Enter with one credential, exit with another: sessions are keyed by a
  per-occurrence id (SUBSESS-<short>), not the credential value, with
  permitId in the payload. Direction is decided by the barrier the reader sits
  at (entry-lane→entry, exit-lane→exit; "both" infers); a fleet (maxConcurrent>1)
  admits several cars and exits any with any credential, FIFO (oldest first).
- Booth treats a subscription occurrence as PREPAID: never quoted/charged; the
  pay/exit modal shows a subscription mode (snapshots + a single audited
  Open-barrier action) to assist a faulty exit reader / missing card;
  reopenBarrier authorizes paidAt!=null OR subscription. Active Sessions badges
  "abonim" and labels by holder name (not the raw key).
- Plus a per-read diagnostic log in the QR-reader route (serial → device →
  verdict/dir), which surfaced the earlier duplicate-reader-IP misroute.

Verified via buildServer+inject + reader-scan/TCP-capture simulations
(enrollment isolation, cross-credential + FIFO fleet, prepaid-not-charged,
subscription reopen, unpaid-transient guard). Updated wiki (subscription,
booth-exit-flow). No migration.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
2026-06-18 16:26:48 +02:00
parent bba988c4e8
commit b8ddda86e7
16 changed files with 663 additions and 143 deletions
+7 -3
View File
@@ -17,6 +17,7 @@ import { Panel } from "./ui/Panel.js";
// See wiki/concepts/booth-exit-flow.md.
function statusBadge(s: ActiveSession): { key: string; cls: string } {
if (s.subscription) return { key: "booth.badgeSubscription", cls: "text-term-cyan" };
if (!s.open && s.withinGrace) return { key: "booth.badgeExiting", cls: "text-term-cyan" };
if (s.paidAt) return { key: "booth.badgePaid", cls: "text-term-green" };
return { key: "booth.badgeUnpaid", cls: "text-term-amber" };
@@ -90,7 +91,9 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void
className="flex flex-1 items-center gap-3 text-left hover:text-term-amber"
title={t("booth.openPayExit")}
>
<span className="text-term-text">{s.identity}</span>
<span className="text-term-text">
{s.subscription ? `★ ${s.subscriptionHolder ?? t("subs.unnamed")}` : s.identity}
</span>
<span className="text-term-muted">
{t("booth.inAt")} {formatTime(s.enteredAt)}
</span>
@@ -98,8 +101,9 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void
<span className={`ml-auto w-16 text-right font-semibold uppercase ${badge.cls}`}>{t(badge.key)}</span>
</button>
{/* Open barrier — PAID sessions only (no payment, no button). */}
{s.paidAt ? (
{/* Open barrier — PAID transient OR a SUBSCRIPTION (prepaid). An
unpaid transient has no button (no-unpaid-bypass). */}
{s.paidAt || s.subscription ? (
<button
type="button"
disabled={reopen.isPending || !shiftReady}
+77 -31
View File
@@ -9,6 +9,7 @@ import {
openShift,
paySession,
printVoucher,
reopenBarrier,
type SessionLookup,
} from "./api.js";
import { qk } from "./lib/query.js";
@@ -48,7 +49,26 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
const voucher = printVoucherChecked ?? config.data?.exitVoucherDefault ?? false;
const alreadyPaid = s?.paidAt != null;
const canPay = shiftReady && s?.found && s.open && !alreadyPaid;
const isSubscription = s?.subscription === true;
// A subscription is prepaid: never charged. The only booth action is an audited
// barrier open to ASSIST (faulty exit reader / lost card). Transient pay path is off.
const canPay = shiftReady && s?.found && s.open && !alreadyPaid && !isSubscription;
async function handleOpenBarrier() {
if (!s) return;
setError(null);
setPhase("finishing");
try {
const r = await reopenBarrier(identity);
setResult(r.opened ? t("pay.subBarrierOpened") : t("pay.paidExitRecorded", { reason: r.reason ?? t("booth.openManually") }));
void qc.invalidateQueries({ queryKey: qk.events });
void qc.invalidateQueries({ queryKey: qk.activeSessions });
setPhase("done");
} catch (e) {
setError((e as Error).message);
setPhase("error");
}
}
async function handleOpenShift() {
setOpeningShift(true);
@@ -106,7 +126,9 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
>
<div className="flex items-center justify-between border-b border-term-border bg-term-panel-2 px-4 py-2">
<Dialog.Title className="m-0 text-[12px] font-semibold uppercase tracking-wider text-term-amber">
{t("pay.ticket")} {identity}
{isSubscription
? `${t("pay.subscription")} · ${s?.subscriptionHolder ?? t("subs.unnamed")}`
: `${t("pay.ticket")} ${identity}`}
</Dialog.Title>
<Dialog.Close className="text-term-muted hover:text-term-text" aria-label={t("common.close")}>
✕
@@ -173,27 +195,38 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
/>
<Row
label={t("pay.statusLabel")}
value={alreadyPaid ? t("pay.paid") : t("pay.unpaid")}
valueClass={alreadyPaid ? "text-term-green" : "text-term-amber"}
value={isSubscription ? t("pay.subscription") : alreadyPaid ? t("pay.paid") : t("pay.unpaid")}
valueClass={isSubscription ? "text-term-cyan" : alreadyPaid ? "text-term-green" : "text-term-amber"}
/>
</div>
{/* Total */}
{/* Total — a subscription is prepaid (no amount); show a badge. */}
<div className="flex items-end justify-between rounded-term bg-term-panel-2 px-3 py-2">
<span className="text-[11px] uppercase tracking-wider text-term-muted">{t("pay.total")}</span>
<span className="text-[11px] uppercase tracking-wider text-term-muted">
{isSubscription ? t("pay.plan") : t("pay.total")}
</span>
<span className="text-3xl font-bold text-term-cyan">
{s.amountMinor != null && s.currency
? formatMoney(s.amountMinor, s.currency)
: alreadyPaid
? t("booth.badgePaid")
: t("pay.noTariff")}
{isSubscription
? t("pay.prepaid")
: s.amountMinor != null && s.currency
? formatMoney(s.amountMinor, s.currency)
: alreadyPaid
? t("booth.badgePaid")
: t("pay.noTariff")}
</span>
</div>
{/* For a subscription, explain the only available action. */}
{isSubscription && (
<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")}
</div>
)}
{/* Snapshots */}
<SnapshotStrip identity={identity} />
{phase !== "done" && (
{phase !== "done" && !isSubscription && (
<>
{/* Tender */}
{canPay && (
@@ -253,26 +286,39 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
>
{t("common.cancel")}
</button>
<button
type="button"
onClick={handlePayAndExit}
disabled={!shiftReady || phase === "paying" || phase === "finishing"}
className="rounded-term border border-term-green bg-term-green/10 px-4 py-1.5 text-[12px] font-semibold uppercase tracking-wider text-term-green disabled:opacity-50"
>
{phase === "paying"
? t("pay.takingPayment")
: phase === "finishing"
? voucher
? t("pay.printingVoucher")
: t("pay.opening")
: alreadyPaid
{isSubscription ? (
// Prepaid — the only action is the audited barrier open (assist
// a faulty exit reader / missing card). Gated on an open shift.
<button
type="button"
onClick={handleOpenBarrier}
disabled={!shiftReady || phase === "finishing"}
className="rounded-term border border-term-cyan bg-term-cyan/10 px-4 py-1.5 text-[12px] font-semibold uppercase tracking-wider text-term-cyan disabled:opacity-50"
>
{phase === "finishing" ? t("pay.opening") : t("booth.openBarrier")}
</button>
) : (
<button
type="button"
onClick={handlePayAndExit}
disabled={!shiftReady || phase === "paying" || phase === "finishing"}
className="rounded-term border border-term-green bg-term-green/10 px-4 py-1.5 text-[12px] font-semibold uppercase tracking-wider text-term-green disabled:opacity-50"
>
{phase === "paying"
? t("pay.takingPayment")
: phase === "finishing"
? voucher
? t("pay.printVoucher")
: t("pay.openBarrier")
: voucher
? t("pay.payAndVoucher")
: t("pay.payAndOpen")}
</button>
? t("pay.printingVoucher")
: t("pay.opening")
: alreadyPaid
? voucher
? t("pay.printVoucher")
: t("pay.openBarrier")
: voucher
? t("pay.payAndVoucher")
: t("pay.payAndOpen")}
</button>
)}
</>
)}
</div>
+103 -6
View File
@@ -1,14 +1,19 @@
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
ApiError,
armCapture,
cancelCapture,
createSubscription,
deleteSubscription,
fetchReaders,
fetchSiteConfig,
fetchSubscriptions,
pollCapture,
printSubscription,
revokeSubscription,
updateSubscription,
type ReaderInfo,
type Subscription,
type SubscriptionCredential,
type SubscriptionInput,
@@ -123,6 +128,11 @@ export function SubscriptionManager() {
const [editing, setEditing] = useState<string | "new" | null>(null);
const [form, setForm] = useState<FormState>(() => emptyForm());
const [msg, setMsg] = useState<{ kind: "ok" | "err"; text: string } | null>(null);
// Credential capture ("Read card"): which credential index is being captured, the
// reader picker list, and a live status line. null = no capture in progress.
const [capture, setCapture] = useState<{ credIndex: number; phase: "pick" | "waiting"; status?: string } | null>(null);
const [readers, setReaders] = useState<ReaderInfo[]>([]);
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
function reload() {
fetchSubscriptions()
@@ -203,6 +213,62 @@ export function SubscriptionManager() {
setForm((f) => ({ ...f, credentials: f.credentials.map((c, j) => (j === i ? { ...c, ...patch } : c)) }));
}
function clearPoll() {
if (pollRef.current) {
clearInterval(pollRef.current);
pollRef.current = null;
}
}
// Stop a capture in progress (cancel on the server + clear local state).
function stopCapture() {
clearPoll();
void cancelCapture().catch(() => {});
setCapture(null);
}
// "Read card" on credential i → load readers + show the picker.
async function startCapture(i: number) {
setMsg(null);
try {
const r = await fetchReaders();
setReaders(r.readers);
setCapture({ credIndex: i, phase: "pick" });
} catch (e) {
setMsg({ kind: "err", text: (e as Error).message });
}
}
// Operator picked a reader → arm it and poll until captured / expired.
async function pickReader(deviceId: string) {
const cap = capture;
if (!cap) return;
try {
await armCapture(deviceId);
setCapture({ ...cap, phase: "waiting", status: t("subs.captureWaiting") });
clearPoll();
pollRef.current = setInterval(async () => {
try {
const st = await pollCapture();
if (st.status === "captured") {
clearPoll();
setCred(cap.credIndex, { value: st.value });
void cancelCapture().catch(() => {}); // clear the server-side result
setCapture(null);
setMsg({ kind: "ok", text: t("subs.captured", { value: st.value }) });
} else if (st.status === "expired" || st.status === "idle") {
clearPoll();
setCapture({ ...cap, phase: "waiting", status: t("subs.captureTimeout") });
}
} catch {
/* transient poll error — keep polling */
}
}, 700);
} catch (e) {
setMsg({ kind: "err", text: (e as Error).message });
setCapture(null);
}
}
// Stop polling if the form closes or the component unmounts.
useEffect(() => clearPoll, []);
// Live coverage preview: when months + validFrom are set, show the end date and
// (if priced) the N×monthly total the operator should collect.
const monthsN = form.months.trim() === "" ? 0 : Math.max(0, Math.round(Number(form.months) || 0));
@@ -299,12 +365,11 @@ export function SubscriptionManager() {
<h4 style={{ marginBottom: "0.25rem" }}>{t("subs.credentials")}</h4>
{form.credentials.map((c, i) => (
<div key={i} style={{ display: "flex", gap: "0.4rem", marginBottom: "0.3rem" }}>
{/* Operator chooses the credential type. Only QR is live today; RFID
is shown disabled ("soon") so the choice is visible — the backend
already accepts both, so re-enabling RFID is just dropping `disabled`. */}
{/* Operator chooses the credential type: QR (auto-generated) or RFID
(read off a card via "Read card"). */}
<select value={c.kind} onChange={(e) => setCred(i, { kind: e.target.value as "rf" | "qr" })}>
<option value="qr">{t("subs.qr")}</option>
<option value="rf" disabled>{t("subs.rfCardTagSoon")}</option>
<option value="rf">{t("subs.rfCardTag")}</option>
</select>
{c.kind === "qr" ? (
// QR codes are server-generated. Blank → "will be generated"; an
@@ -315,12 +380,44 @@ export function SubscriptionManager() {
<span style={{ flex: 1, color: "#888", fontStyle: "italic", alignSelf: "center" }}>{t("subs.qrAutoGen")}</span>
)
) : (
<input value={c.value} onChange={(e) => setCred(i, { value: e.target.value })} placeholder={t("subs.credentialValue")} style={{ flex: 1 }} />
// RFID: the value is read off a physical card (or typed). "Read card"
// arms a chosen reader and fills the captured value.
<input value={c.value} onChange={(e) => setCred(i, { value: e.target.value })} placeholder={t("subs.rfPlaceholder")} style={{ flex: 1, fontFamily: "ui-monospace, monospace" }} />
)}
{c.kind === "rf" && (
<button type="button" onClick={() => startCapture(i)} disabled={capture != null}>{t("subs.readCard")}</button>
)}
<button type="button" onClick={() => setForm((f) => ({ ...f, credentials: f.credentials.filter((_, j) => j !== i) }))}>×</button>
</div>
))}
<button type="button" onClick={() => setForm((f) => ({ ...f, credentials: [...f.credentials, { kind: "qr", value: "" }] }))}>{t("subs.addCredential")}</button>
{/* Capture panel: pick a reader, present the card; the captured value fills
the credential. The OTHER reader keeps serving the live flow. */}
{capture && (
<div style={{ marginTop: "0.5rem", padding: "0.6rem 0.75rem", border: "1px solid #0a7", borderRadius: 6, background: "#f0fbf6" }}>
{capture.phase === "pick" ? (
<>
<div style={{ marginBottom: "0.35rem" }}>{t("subs.captureChooseReader")}</div>
<div style={{ display: "flex", gap: "0.4rem", flexWrap: "wrap" }}>
{readers.length === 0 && <span style={{ color: "#a00" }}>{t("subs.captureNoReaders")}</span>}
{readers.map((r) => (
<button key={r.id} type="button" onClick={() => pickReader(r.id)}>
{t(`devices.role.${r.direction}`)} ({r.driverId})
</button>
))}
<button type="button" onClick={stopCapture}>{t("subs.cancel")}</button>
</div>
</>
) : (
<div style={{ display: "flex", gap: "0.6rem", alignItems: "center" }}>
<span>{capture.status ?? t("subs.captureWaiting")}</span>
<button type="button" onClick={stopCapture}>{t("subs.cancel")}</button>
</div>
)}
</div>
)}
<p style={{ color: "#777", fontSize: "0.85em", margin: "0.5rem 0 0" }}>
{t("subs.needCredentialOrPlate")}
</p>
+34
View File
@@ -337,6 +337,32 @@ export function createSubscription(body: SubscriptionInput): Promise<Subscriptio
export function printSubscription(id: string): Promise<{ ok: boolean; printedBy: string }> {
return apiFetch(`/api/subscriptions/${id}/print`, { method: "POST" });
}
// --- Credential capture ("enroll a card" on a chosen reader) ---------------
export interface ReaderInfo {
id: string;
driverId: string;
direction: "entry" | "exit" | "both";
}
export type CaptureState =
| { status: "idle" }
| { status: "armed"; deviceId: string; armedAt: number; expiresAt: number }
| { status: "captured"; deviceId: string; value: string; capturedAt: number }
| { status: "expired"; deviceId: string };
export function fetchReaders(): Promise<{ readers: ReaderInfo[] }> {
return apiFetch("/api/subscriptions/readers");
}
export function armCapture(deviceId: string): Promise<{ expiresAt: number }> {
return apiFetch("/api/subscriptions/capture/arm", { method: "POST", body: JSON.stringify({ deviceId }) });
}
export function pollCapture(): Promise<CaptureState> {
return apiFetch("/api/subscriptions/capture");
}
export function cancelCapture(): Promise<{ ok: boolean }> {
return apiFetch("/api/subscriptions/capture/cancel", { method: "POST" });
}
export function updateSubscription(id: string, body: SubscriptionInput): Promise<Subscription> {
return apiFetch(`/api/subscriptions/${id}`, { method: "PUT", body: JSON.stringify(body) });
}
@@ -481,6 +507,10 @@ export interface SessionLookup {
currency: string | null;
withinGrace: boolean;
graceExpiresAt: string | null;
/** A subscription occurrence (prepaid — no pay flow; barrier-open assist only). */
subscription: boolean;
subscriptionId: string | null;
subscriptionHolder: string | null;
}
/** Look up a ticket/session for the booth modal (entry/exit, paid, amount owed). */
@@ -500,6 +530,10 @@ export interface ActiveSession {
currency: string | null;
withinGrace: boolean;
graceExpiresAt: string | null;
/** A subscription occurrence (prepaid — no pay flow; barrier-open assist only). */
subscription: boolean;
subscriptionId: string | null;
subscriptionHolder: string | null;
}
/** Active sessions: still-inside OR exited-but-within-grace (barrier unconfirmed). */
+13
View File
@@ -85,6 +85,7 @@ export const en: Catalog = {
badgeExiting: "exiting",
badgePaid: "paid",
badgeUnpaid: "unpaid",
badgeSubscription: "subscription",
evtEntry: "ENTRY",
evtExit: "EXIT",
evtPay: "PAY",
@@ -156,6 +157,13 @@ export const en: Catalog = {
credentialsCardQr: "Credentials (card / QR)",
rfCardTag: "RF card/tag",
rfCardTagSoon: "RF card/tag (soon)",
rfPlaceholder: "card number (or read the card)",
readCard: "Read card",
captureChooseReader: "Choose a reader, then present the card:",
captureNoReaders: "No readers configured.",
captureWaiting: "Present the card to the reader…",
captureTimeout: "Timed out with no card read. Try again.",
captured: "Card read: {{value}}",
qr: "QR",
qrAutoGen: "QR code is auto-generated on save",
credentialValue: "credential value",
@@ -268,6 +276,11 @@ export const en: Catalog = {
lookingUp: "looking up…",
paidBarrierOpened: "Paid — barrier opened. Car may exit.",
paidExitRecorded: "Paid and exit recorded, but the barrier did not open: {{reason}}.",
subscription: "SUBSCRIPTION",
plan: "Plan",
prepaid: "PREPAID",
subAssistHint: "Prepaid subscription. Open the barrier to assist the exit (faulty reader / missing card). No payment.",
subBarrierOpened: "Barrier opened for the subscriber (intervention recorded).",
voucherPrinted: "Exit voucher printed on {{printer}}. Customer self-exits at the exit.",
noSnapshots: "no snapshots",
loadingSnapshots: "loading snapshots…",
+14 -1
View File
@@ -79,13 +79,14 @@ export const sq = {
inAt: "në",
openPayExit: "Hap pagesën / daljen",
openBarrier: "Hap barrierën",
openBarrierTitle: "Hapje barriere me ndërhyrje njerëzore (e regjistruar)",
openBarrierTitle: "Hap barrierën manualisht",
barrierOpened: "barriera u hap",
openManually: "hape me dorë",
// session row badges
badgeExiting: "duke dalë",
badgePaid: "paguar",
badgeUnpaid: "papaguar",
badgeSubscription: "abonim",
// event types (live feed labels)
evtEntry: "HYRJE",
evtExit: "DALJE",
@@ -158,6 +159,13 @@ export const sq = {
credentialsCardQr: "Kredencialet (kartë / QR)",
rfCardTag: "Kartë/etiketë RF",
rfCardTagSoon: "Kartë/etiketë RF (së shpejti)",
rfPlaceholder: "numri i kartës (ose lexo kartën)",
readCard: "Lexo kartën",
captureChooseReader: "Zgjidh lexuesin, pastaj afro kartën:",
captureNoReaders: "Asnjë lexues i konfiguruar.",
captureWaiting: "Afro kartën te lexuesi…",
captureTimeout: "Skadoi pa lexuar kartë. Provo sërish.",
captured: "Karta u lexua: {{value}}",
qr: "QR",
qrAutoGen: "kodi QR gjenerohet automatikisht në ruajtje",
credentialValue: "vlera e kredencialit",
@@ -270,6 +278,11 @@ export const sq = {
lookingUp: "Duke kërkuar…",
paidBarrierOpened: "Paguar — barriera u hap. Automjeti mund të dalë.",
paidExitRecorded: "Paguar dhe dalja u regjistrua, por barriera nuk u hap: {{reason}}.",
subscription: "ABONIM",
plan: "Plani",
prepaid: "I PARAPAGUAR",
subAssistHint: "Abonim i parapaguar. Hap barrierën për të ndihmuar daljen (lexues me defekt / kartë e munguar). S'ka pagesë.",
subBarrierOpened: "Barriera u hap për abonentin (ndërhyrje e regjistruar).",
voucherPrinted: "Bileta e daljes u printua në {{printer}}. Klienti del vetë te dalja.",
// snapshots
noSnapshots: "asnjë foto",