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
+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>