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:
@@ -195,12 +195,22 @@ export class ExitFlow {
|
||||
|
||||
const view = this.#sessionFor(id);
|
||||
if (!view) return { ok: false, reason: "no session for ticket" };
|
||||
// Authorization to re-open: a PAID transient (paid, or paid-then-exited within
|
||||
// grace) OR a SUBSCRIPTION occurrence (prepaid — exactly the case the operator must
|
||||
// assist when the exit reader / card fails). An unpaid TRANSIENT takes the pay/exit
|
||||
// flow instead — enforced here, not just in the UI (the no-unpaid-bypass rule).
|
||||
if (view.paidAt == null && !view.subscription) {
|
||||
return { ok: false, reason: "session not paid — no barrier open without payment" };
|
||||
// Authorization to re-open: a SUBSCRIPTION occurrence (prepaid — exactly the case
|
||||
// the operator must assist when the exit reader / card fails) OR a transient whose
|
||||
// payment is STILL WITHIN the walk-back grace window. A stale payment does NOT
|
||||
// authorize a free open: a car that paid once and then sat inside past grace owes a
|
||||
// top-up for the extra time — letting it out on the old payment is the overstay-fraud
|
||||
// path. So we mirror the exit flow's grace check here (not just in the UI): an
|
||||
// unpaid OR grace-expired transient takes the pay/exit (top-up) flow instead.
|
||||
// The no-unpaid-bypass + no-free-overstay-exit rules, enforced server-side.
|
||||
const paid = view.paidAt != null;
|
||||
const withinGrace =
|
||||
paid && view.graceExitMin != null && Date.now() - Date.parse(view.paidAt!) <= view.graceExitMin * 60_000;
|
||||
if (!view.subscription && (!paid || !withinGrace)) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: paid ? "walk-back grace expired — take a top-up payment first" : "session not paid — no barrier open without payment",
|
||||
};
|
||||
}
|
||||
|
||||
const key = `reopen:${id}`;
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user