Files
parking_solution/apps/web/src/SubscriptionManager.tsx
T
julian b8ddda86e7 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
2026-06-18 16:26:48 +02:00

435 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
} from "./api.js";
// Subscription admin. Create/edit/revoke/delete subscriptions + their credentials
// (card/QR) and bound plates, and the recurring monthly price (e.g. 10,000 ALL). A
// subscription is mutable master data; every USE of it is a signed ledger event
// elsewhere. See wiki/entities/subscription.md.
const DEFAULT_CURRENCY = "ALL";
interface FormState {
holderName: string;
contact: string;
priceMajor: string; // major units as typed (e.g. "10000"); "" = no price
currency: string;
carBound: boolean; // false = unbound (maxConcurrent null)
maxConcurrent: string;
validFrom: string;
months: string; // months paid for; "" = none (use explicit validTo / open-ended)
validTo: string;
credentials: SubscriptionCredential[];
platesText: string; // comma/space separated
}
/** Today (UTC date, yyyy-mm-dd) for a sensible default validFrom on new subs. */
function todayISODate(): string {
return new Date().toISOString().slice(0, 10);
}
function emptyForm(defaultPriceMajor = "", currency = DEFAULT_CURRENCY): FormState {
return {
holderName: "",
contact: "",
priceMajor: defaultPriceMajor,
currency,
carBound: true,
maxConcurrent: "1",
validFrom: todayISODate(),
months: "1",
validTo: "",
credentials: [{ kind: "qr", value: "" }],
platesText: "",
};
}
function formFrom(s: Subscription): FormState {
return {
holderName: s.holderName ?? "",
contact: s.contact ?? "",
priceMajor: s.priceMinor != null ? String(s.priceMinor / 100) : "",
currency: s.currency ?? DEFAULT_CURRENCY,
carBound: s.maxConcurrent != null,
maxConcurrent: s.maxConcurrent != null ? String(s.maxConcurrent) : "1",
validFrom: s.validFrom ?? "",
months: "", // on edit, default to leaving the window as-is (explicit validTo below)
validTo: s.validTo ?? "",
credentials: s.credentials.length ? s.credentials : [{ kind: "qr", value: "" }],
platesText: s.plates.join(", "),
};
}
/** Add whole months to a yyyy-mm-dd (clamps day overflow), → yyyy-mm-dd. Mirrors the
* server's addMonths so the form can preview the coverage end. */
function addMonthsDate(date: string, months: number): string | null {
const d = new Date(`${date}T00:00:00Z`);
if (Number.isNaN(d.getTime())) return null;
const day = d.getUTCDate();
d.setUTCMonth(d.getUTCMonth() + months);
if (d.getUTCDate() < day) d.setUTCDate(0);
return d.toISOString().slice(0, 10);
}
const STATUS_KEY: Record<Subscription["status"], string> = {
active: "subs.statusActive",
suspended: "subs.statusSuspended",
revoked: "subs.statusRevoked",
};
function toInput(f: FormState): SubscriptionInput {
const major = Number(f.priceMajor);
const priceSet = f.priceMajor.trim() !== "" && Number.isFinite(major) && major >= 0;
const monthsNum = f.months.trim() === "" ? null : Math.max(1, Math.round(Number(f.months) || 0));
return {
holderName: f.holderName.trim() || null,
contact: f.contact.trim() || null,
priceMinor: priceSet ? Math.round(major * 100) : null,
period: "monthly",
currency: priceSet ? f.currency.trim() || DEFAULT_CURRENCY : null,
maxConcurrent: f.carBound ? Math.max(1, Math.round(Number(f.maxConcurrent) || 1)) : null,
validFrom: f.validFrom.trim() || null,
// months (with validFrom) drives validTo server-side; else send the explicit end.
months: monthsNum && f.validFrom.trim() ? monthsNum : null,
validTo: f.validTo.trim() || null,
// A QR credential with a blank value is sent as { kind:'qr' } (no value) so the
// server auto-generates the code. RF (and pre-existing QR) keep their value.
credentials: f.credentials
.filter((c) => c.kind === "qr" || c.value.trim())
.map((c) => (c.value.trim() ? { kind: c.kind, value: c.value.trim() } : { kind: c.kind })),
plates: f.platesText.split(/[,\s]+/).map((s) => s.trim()).filter(Boolean),
};
}
function priceLabel(s: Subscription, t: (k: string) => string): string {
if (s.priceMinor == null) return t("subs.noPrice");
return `${(s.priceMinor / 100).toLocaleString()} ${s.currency ?? ""} / ${t("subs.perMonth")}`.trim();
}
export function SubscriptionManager() {
const { t } = useTranslation();
const [subs, setSubs] = useState<Subscription[] | null>(null);
const [defaultPriceMajor, setDefaultPriceMajor] = useState("");
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()
.then((r) => setSubs(r.subscriptions))
.catch((e) => setMsg({ kind: "err", text: (e as Error).message }));
}
useEffect(() => {
reload();
// Pull the site default monthly price to pre-fill new subscriptions.
fetchSiteConfig()
.then((c) => {
if (c.subscriptionMonthlyPriceMinor != null) setDefaultPriceMajor(String(c.subscriptionMonthlyPriceMinor / 100));
})
.catch(() => {
/* non-fatal — the form just won't pre-fill */
});
}, []);
function startNew() {
setForm(emptyForm(defaultPriceMajor));
setEditing("new");
setMsg(null);
}
function startEdit(s: Subscription) {
setForm(formFrom(s));
setEditing(s.id);
setMsg(null);
}
async function save() {
setMsg(null);
try {
if (editing === "new") {
const created = await createSubscription(toInput(form));
setEditing(null);
reload();
// Reflect the auto-print outcome: printed OK, or saved-but-print-failed (the
// operator can use "Print code" to retry).
if (created.printed) {
setMsg({ kind: "ok", text: t("subs.savedPrinted") });
} else if (created.printError) {
setMsg({ kind: "err", text: t("subs.savedPrintFailed", { error: created.printError }) });
} else {
setMsg({ kind: "ok", text: t("subs.saved") });
}
return;
}
if (editing) await updateSubscription(editing, toInput(form));
setEditing(null);
reload();
setMsg({ kind: "ok", text: t("subs.saved") });
} catch (e) {
const problems = e instanceof ApiError ? (e as ApiError & { problems?: string[] }).problems : undefined;
setMsg({ kind: "err", text: problems?.length ? `${(e as Error).message}: ${problems.join("; ")}` : (e as Error).message });
}
}
async function doPrint(s: Subscription) {
setMsg(null);
try {
const r = await printSubscription(s.id);
setMsg({ kind: "ok", text: t("subs.printedOn", { printer: r.printedBy }) });
} catch (e) {
setMsg({ kind: "err", text: (e as Error).message });
}
}
async function doRevoke(s: Subscription) {
if (!confirm(t("subs.confirmRevoke", { name: s.holderName ?? s.id }))) return;
await revokeSubscription(s.id).catch((e) => setMsg({ kind: "err", text: (e as Error).message }));
reload();
}
async function doDelete(s: Subscription) {
if (!confirm(t("subs.confirmDelete", { name: s.holderName ?? s.id }))) return;
await deleteSubscription(s.id).catch((e) => setMsg({ kind: "err", text: (e as Error).message }));
reload();
}
function setCred(i: number, patch: Partial<SubscriptionCredential>) {
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));
const coverageEnd = monthsN >= 1 && form.validFrom.trim() ? addMonthsDate(form.validFrom.trim(), monthsN) : null;
const priceMajorN = form.priceMajor.trim() === "" ? null : Number(form.priceMajor);
const totalDue =
coverageEnd && priceMajorN != null && Number.isFinite(priceMajorN)
? `${(priceMajorN * monthsN).toLocaleString()} ${form.currency.trim() || DEFAULT_CURRENCY}`
: null;
const coverageHint = coverageEnd
? t("subs.coverageHint", { end: coverageEnd }) + (totalDue ? ` · ${t("subs.totalDue", { total: totalDue })}` : "")
: null;
if (!subs) return null;
return (
<section style={{ marginTop: "2rem" }}>
<h2>{t("subs.title")}</h2>
<ul style={{ listStyle: "none", padding: 0 }}>
{subs.map((s) => (
<li key={s.id} style={{ display: "flex", gap: "0.5rem", alignItems: "center", padding: "0.4rem 0", borderBottom: "1px solid #eee" }}>
<strong>{s.holderName ?? t("subs.unnamed")}</strong>
<span style={{ color: s.status === "active" ? "#16a34a" : "#b45309" }}>{t(STATUS_KEY[s.status])}</span>
<span style={{ color: "#0a7", fontVariantNumeric: "tabular-nums" }}>{priceLabel(s, t)}</span>
<span style={{ color: "#666" }}>
{s.maxConcurrent == null ? t("subs.unbound") : t("subs.car", { count: s.maxConcurrent })} ·{" "}
{s.credentials.length} {t("subs.cred")} · {t("subs.plates", { count: s.plates.length })}
</span>
<span style={{ flex: 1 }} />
{/* Print code — only when the subscription has a QR credential to encode. */}
{s.credentials.some((c) => c.kind === "qr") && (
<button type="button" onClick={() => doPrint(s)}>{t("subs.printCode")}</button>
)}
<button type="button" onClick={() => startEdit(s)}>{t("subs.edit")}</button>
{s.status !== "revoked" && <button type="button" onClick={() => doRevoke(s)}>{t("subs.revoke")}</button>}
<button type="button" onClick={() => doDelete(s)}>{t("subs.delete")}</button>
</li>
))}
{subs.length === 0 && <li style={{ color: "#777" }}>{t("subs.noneYet")}</li>}
</ul>
{editing == null ? (
<button type="button" onClick={startNew}>{t("subs.add")}</button>
) : (
<div style={{ border: "1px solid #ddd", padding: "1rem", marginTop: "0.5rem", maxWidth: 460 }}>
<h3 style={{ marginTop: 0 }}>{editing === "new" ? t("subs.new") : t("subs.editTitle")}</h3>
<div style={{ display: "grid", gridTemplateColumns: "max-content 1fr", gap: "0.4rem 0.75rem", alignItems: "center" }}>
<label>{t("subs.holderName")}</label>
<input value={form.holderName} onChange={(e) => setForm((f) => ({ ...f, holderName: e.target.value }))} />
<label>{t("subs.contact")}</label>
<input value={form.contact} onChange={(e) => setForm((f) => ({ ...f, contact: e.target.value }))} />
<label>{t("subs.monthlyPrice")}</label>
<span style={{ display: "flex", gap: "0.4rem", alignItems: "center" }}>
<input
value={form.priceMajor}
onChange={(e) => setForm((f) => ({ ...f, priceMajor: e.target.value }))}
inputMode="decimal"
placeholder={t("subs.pricePlaceholder")}
style={{ width: 110 }}
/>
<input value={form.currency} onChange={(e) => setForm((f) => ({ ...f, currency: e.target.value }))} style={{ width: 60 }} />
<span style={{ color: "#888" }}>/ {t("subs.perMonth")}</span>
</span>
<label>{t("subs.carLimit")}</label>
<span>
<label style={{ marginRight: "0.5rem" }}>
<input type="checkbox" checked={form.carBound} onChange={(e) => setForm((f) => ({ ...f, carBound: e.target.checked }))} /> {t("subs.limitCarsInAtOnce")}
</label>
{form.carBound && (
<input value={form.maxConcurrent} onChange={(e) => setForm((f) => ({ ...f, maxConcurrent: e.target.value }))} style={{ width: 50 }} />
)}
</span>
<label>{t("subs.validFrom")}</label>
<input type="date" value={form.validFrom} onChange={(e) => setForm((f) => ({ ...f, validFrom: e.target.value }))} />
<label>{t("subs.months")}</label>
<span style={{ display: "flex", gap: "0.4rem", alignItems: "center", flexWrap: "wrap" }}>
<input
value={form.months}
onChange={(e) => setForm((f) => ({ ...f, months: e.target.value }))}
inputMode="numeric"
placeholder="1"
style={{ width: 50 }}
/>
<span style={{ color: "#888" }}>{t("subs.monthsHint")}</span>
{/* Live preview of the coverage end + the N×price total. */}
{coverageHint && <span style={{ color: "#0a7" }}>{coverageHint}</span>}
</span>
<label>{t("subs.validToOverride")}</label>
<input type="date" value={form.validTo} onChange={(e) => setForm((f) => ({ ...f, validTo: e.target.value }))} />
<label>{t("subs.boundPlates")}</label>
<input value={form.platesText} onChange={(e) => setForm((f) => ({ ...f, platesText: e.target.value }))} placeholder={t("subs.commaSeparatedOptional")} />
</div>
<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: 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">{t("subs.rfCardTag")}</option>
</select>
{c.kind === "qr" ? (
// QR codes are server-generated. Blank → "will be generated"; an
// existing code is shown read-only (it can be printed; never typed).
c.value.trim() ? (
<input value={c.value} readOnly style={{ flex: 1, fontFamily: "ui-monospace, monospace", background: "#f6f6f6" }} />
) : (
<span style={{ flex: 1, color: "#888", fontStyle: "italic", alignSelf: "center" }}>{t("subs.qrAutoGen")}</span>
)
) : (
// 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>
<div style={{ marginTop: "1rem", display: "flex", gap: "0.5rem" }}>
<button type="button" onClick={save}>{t("subs.save")}</button>
<button type="button" onClick={() => setEditing(null)}>{t("subs.cancel")}</button>
</div>
</div>
)}
{msg && <p style={{ color: msg.kind === "ok" ? "#16a34a" : "crimson" }}>{msg.text}</p>}
</section>
);
}