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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user