feat(booth): overstay sessions, top-up pricing, and session/feed filters

Rework paid-but-grace-expired sessions and add booth filters.

Overstay (was "stuck"):
- Stop silently aging out a paid transient whose walk-back grace lapsed with no
  signed exit. Keep it listed with an OVERSTAY badge — a new parking period began
  (re-parked) or the car is faulty/abandoned; it is not a system fault.
- No free exit: reopenBarrier refuses server-side once a transient's payment grace
  has expired (allow only subscription OR paid-and-within-grace); the UI hides the
  Open-barrier button on overstay rows and routes to the pay/exit modal. Closes a
  hole where a stale payment authorized a free multi-day exit (operator-as-adversary).
- Price the overstay as a NEW period from grace-expiry -> now with its own daily-cap
  ladder, NOT "full stay minus paid" (which a daily cap collapsed to 0 — ticket
  1245791632490 owed ALL 0; now owes its real overstay). quote() gains periodStart +
  overstay; SessionLookup/ActiveSession gain `overstay`. handlePayAndExit charges
  whenever the session is payable (was: only if !alreadyPaid, skipping the overstay).

Filters (new ui/FilterBar): Active Sessions — search + status
(unpaid/paid/exiting/overstay) + transient-vs-subscriber. Live feed — search +
event (entry/exit/pay/void/anomaly) + direction + source (booth=manual vs reader).
All client-side over already-fetched data; matched/total count shown.

i18n parity (sq+en). Wiki: booth-exit-flow updated (overstay model, naming history,
no-free-exit security fix, new-period pricing; open question on grace-renewal noted).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
2026-06-20 11:48:54 +02:00
parent 918f76fbef
commit a4712774ab
11 changed files with 620 additions and 110 deletions
+83 -19
View File
@@ -28,8 +28,18 @@ export class NoTariffError extends Error {
export interface Quote {
readonly identity: string;
/** Vehicle entry time (the session's original entry; for display/audit). */
readonly enteredAt: string;
/** Start of the period being billed RIGHT NOW. For a first payment this is the
* entry. For an OVERSTAY (a paid session whose walk-back grace lapsed — the car
* re-parked / a new period began) it is the moment that grace expired: the overstay
* is priced as a fresh stay from there → now, with its own daily-cap ladder, NOT
* "full stay minus paid" (which a daily cap collapses toward zero). */
readonly periodStart: string;
/** Amount owed now: the fee for [periodStart → now]. */
readonly amountMinor: number;
/** True when this quote prices an overstay period (grace lapsed), not the first stay. */
readonly overstay: boolean;
readonly currency: string;
readonly tariffVersionId: string;
readonly graceExitMin: number;
@@ -53,6 +63,13 @@ export interface ActiveSession {
readonly currency: string | null;
readonly withinGrace: boolean;
readonly graceExpiresAt: string | null;
/** OVERSTAY = a paid transient whose walk-back grace lapsed with NO signed vehicle_exit.
* The car either re-parked (a new period began) or is faulty/abandoned — not a system
* fault, and not "stuck". It lingers in occupancy and owes a fresh period (priced from
* grace-expiry, see `quote`). We keep it listed and BADGE it OVERSTAY so the operator
* reconciles via a top-up, instead of silently aging it out. No free barrier open.
* See wiki/concepts/booth-exit-flow.md. */
readonly overstay: boolean;
/** True for a SUBSCRIPTION occurrence (prepaid — never charged). The booth shows it
* with snapshots + an always-available "open barrier" (assist a faulty exit reader /
* missing card), and never a pay flow. See wiki/entities/subscription.md. */
@@ -80,6 +97,9 @@ export interface SessionLookup {
readonly withinGrace: boolean;
/** ISO time the walk-back grace expires (paidAt + graceExitMin), if paid. */
readonly graceExpiresAt: string | null;
/** OVERSTAY = paid transient, walk-back grace expired, no signed exit. A new period
* began; `amountMinor` is the fresh fee from grace-expiry — it cannot exit for free. */
readonly overstay: boolean;
/** True for a SUBSCRIPTION occurrence (prepaid — never charged; barrier-open only). */
readonly subscription: boolean;
readonly subscriptionId: string | null;
@@ -97,11 +117,28 @@ export class PayStation {
this.#logger = logger;
}
/** Price an open session against the tariff in force at its entry. No side effect. */
/** Price an open session. Normally the period is entry→now. But for an OVERSTAY — a
* paid session whose walk-back grace has lapsed (the car re-parked, or a new period
* began) — the customer is billed for a FRESH period from grace-expiry→now, with its
* own daily-cap ladder. This is NOT "full stay minus paid": with a daily cap the
* whole-stay gross plateaus while prior payments keep pace, so the delta collapses to
* 0 and a multi-day overstay would exit free (ticket 1245791632490). A new period
* reflects the reality and re-accrues the fee. No side effect. */
quote(identity: string): Quote {
const entry = this.#openEntry(identity);
if (!entry) throw new NoOpenSessionError(identity);
// If the latest payment's walk-back grace has expired, this is an overstay: anchor
// the new billing period at grace-expiry (paidAt + graceExitMin). Otherwise price
// from entry (first payment, or a still-within-grace re-quote of the same stay).
const last = this.#lastPayment(identity);
const graceExpiryMs =
last && last.graceExitMin != null ? Date.parse(last.paidAt) + last.graceExitMin * 60_000 : null;
const overstay = graceExpiryMs != null && Date.now() > graceExpiryMs;
const periodStart = overstay ? new Date(graceExpiryMs!).toISOString() : entry.occurredAt;
// The tariff in force is keyed to ENTRY (the version frozen for this session), even
// for an overstay period — the customer keeps the rate card they entered under.
const tv = this.#tariffVersionFor(entry.occurredAt);
if (!tv) throw new NoTariffError();
const structure = tv.structure as unknown as TariffStructure;
@@ -110,23 +147,44 @@ export class PayStation {
// both read it from there, so a V2 category tariff yields the same amount at the
// booth and at exit. Absent (legacy/V1) ⇒ undefined ⇒ category-agnostic pricing.
const category = (entry.payload as { category?: string } | null)?.category;
const amountMinor = computeFee(entry.occurredAt, new Date().toISOString(), structure, category);
const amountMinor = computeFee(periodStart, new Date().toISOString(), structure, category);
return {
identity,
enteredAt: entry.occurredAt,
periodStart,
amountMinor,
overstay,
currency: tv.currency,
tariffVersionId: tv.id,
graceExitMin: structure.gracePeriodExitMin,
};
}
/** The latest signed `payment` for this session (time + the grace window it granted),
* or null if never paid. Folds the append-only ledger. */
#lastPayment(identity: string): { paidAt: string; graceExitMin: number | null } | null {
const rows = this.#db
.select({ type: ledgerEvents.type, occurredAt: ledgerEvents.occurredAt, payload: ledgerEvents.payload })
.from(ledgerEvents)
.where(eq(ledgerEvents.identity, identity))
.orderBy(ledgerEvents.index)
.all();
let last: { paidAt: string; graceExitMin: number | null } | null = null;
for (const r of rows) {
if (r.type !== "payment") continue;
const g = (r.payload as { graceExitMin?: number } | null)?.graceExitMin;
last = { paidAt: r.occurredAt, graceExitMin: typeof g === "number" ? g : null };
}
return last;
}
/**
* Take payment for a session and append the signed `payment` event. Re-quotes at
* the moment of payment (the customer pays for time parked SO FAR). For an
* overstay top-up the same call re-prices entry→now and the exit flow's
* grace-window restarts from this payment. `overrideMinor` lets the operator set
* an arbitrary amount (lost ticket / dispute) — recorded as the charged amount.
* the moment of payment (the customer pays for time parked SO FAR). For an OVERSTAY
* (grace lapsed) the quote prices a fresh period from grace-expiry→now (see `quote`),
* and this payment writes a new `graceExitMin` so the walk-back window restarts.
* `overrideMinor` lets the operator set an arbitrary amount (lost ticket / dispute) —
* recorded as the charged amount.
*/
async pay(
identity: string,
@@ -183,7 +241,7 @@ export class PayStation {
return {
identity: id, found: false, open: false, enteredAt: null, exitedAt: null,
paidAt: null, amountMinor: null, currency: null, withinGrace: false, graceExpiresAt: null,
subscription: false, subscriptionId: null, subscriptionHolder: null,
overstay: false, subscription: false, subscriptionId: null, subscriptionHolder: null,
};
}
// Subscription occurrence? The entry payload carries permit:true + permitId.
@@ -220,10 +278,12 @@ export class PayStation {
}
}
const overstay = open && !isSubscription && paidAt != null && graceExpiresAt != null && !withinGrace;
return {
identity: id, found: true, open,
enteredAt: entry.occurredAt, exitedAt: exitRow?.occurredAt ?? null,
paidAt, amountMinor, currency, withinGrace, graceExpiresAt,
paidAt, amountMinor, currency, withinGrace, graceExpiresAt, overstay,
subscription: isSubscription, subscriptionId,
subscriptionHolder: this.#holderOf(subscriptionId),
};
@@ -287,26 +347,29 @@ export class PayStation {
const withinGrace = graceExpiresAt != null && now <= Date.parse(graceExpiresAt);
const paid = a.paidAt != null;
const isSubscription = a.subscriptionId !== undefined;
// ACTIVE membership:
// - exited + within grace → still shown (barrier unconfirmed, may be present);
// - exited + past grace → presumed gone, omitted;
// - open + UNPAID → always shown (a car owing money never ages out —
// it's genuinely still inside until it pays, however long that takes);
// - open + PAID + past grace → AGE-OUT (omit). A paid car whose walk-back grace
// lapsed has left; if no vehicle_exit was ever signed (e.g. it left via a
// manual barrier re-open before that path closed the session, or a historical
// session like T-397815c0) it would otherwise linger forever. The signed log
// is unchanged — this is purely a display filter. See booth-exit-flow.md.
// - open + PAID + past grace → OVERSTAY. A paid transient whose walk-back grace
// lapsed with no signed vehicle_exit: the car re-parked (a new period) or is
// faulty/abandoned — not a system fault, not "stuck". It lingers in occupancy
// and owes a fresh period (priced from grace-expiry, see `quote`). We used to
// age these out (a silent display filter); now we KEEP them and flag `overstay`
// so the operator reconciles via a top-up. The signed log is untouched, and the
// barrier never opens for free on these. See booth-exit-flow.md.
if (!open && !withinGrace) continue;
if (open && paid && graceExpiresAt != null && !withinGrace) continue;
const overstay =
open && paid && !isSubscription && graceExpiresAt != null && !withinGrace;
const isSubscription = a.subscriptionId !== undefined;
// Amount owed now: only meaningful for an open + unpaid TRANSIENT session. A
// subscription is prepaid — never quote/charge it.
// Amount owed now: an open + unpaid TRANSIENT (first stay) OR an OVERSTAY (the new
// period's top-up). A subscription is prepaid — never quote/charge it.
let amountMinor: number | null = null;
let currency: string | null = null;
if (open && a.paidAt == null && !isSubscription) {
if (open && !isSubscription && (a.paidAt == null || overstay)) {
try {
const q = this.quote(identity);
amountMinor = q.amountMinor;
@@ -327,6 +390,7 @@ export class PayStation {
currency,
withinGrace,
graceExpiresAt,
overstay,
subscription: isSubscription,
subscriptionId: a.subscriptionId ?? null,
subscriptionHolder: this.#holderOf(a.subscriptionId ?? null),