Files
parking_solution/apps/server/src/pay-station.ts
T
julian 294ca85ded fix(subs): out-of-window charge was a phantom 12h span (4,100 ALL bug)
The tariff-bridge owed amount summed TWO charges — the early-entry gap +
a "late-exit" gap — and the exit gap (outOfWindowGap edge:"exit") always
measured back to the PREVIOUS window close, even for a subscriber still BEFORE
their window. So a car that entered ~30 min early showed ~12h owed (4,100 ALL)
the moment it was looked up, instead of ~100 ALL.

Replace the two-gap sum with a single correct primitive,
minutesOutsideWindow(timeframes, tz, from, to): the minutes within the actual
stay [entry, now] that fall outside the allowed window (covering early entry AND
late exit, bounded by the stay, weekend/off-days free). windowOwedBetween prices
those minutes once as a transient stay (so increments + daily cap apply) against
the tariff in force at entry. Both the exit gate (subscription-flow) and the
booth quote (pay-station) now use this one source of truth — they can't disagree.

Verified on the live occurrence: was 4,100 ALL, now 100 ALL (9 min outside →
one increment). 87 shared tests (6 new regression cases incl. the phantom span).
Build+lint 12/12.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-20 20:37:57 +02:00

551 lines
24 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 { desc, eq, ledgerEvents, sessions, subscriptions, tariffVersions, tariffs, type Db } from "@parking/db";
import { priceSession, type TariffStructure, type Tender } from "@parking/shared";
import type { FastifyBaseLogger } from "fastify";
import type { EventLog } from "./event-log.js";
import { plateForIdentity, platesForIdentities } from "./plate-lookup.js";
import { windowOwedBetween } from "./subscription-window.js";
// The PAY STATION: a customer pays for an open session BEFORE walking back to the
// car (pay-on-foot — payment is decoupled from exit). Two steps:
// 1. quote(identity) → look up the open session, price it against the tariff in
// force at entry, return the amount due (no side effect).
// 2. pay(identity, tender) → re-price, append a SIGNED `payment` event carrying
// the amount, currency, tender, tariffVersionId, and graceExitMin (so the exit
// flow can validate paid + within walk-back grace). Payment is a signed ledger
// event, never a mutable "paid" flag — an operator can't forge or delete it.
// See wiki/concepts/tariff.md, parking-session.md.
export class NoOpenSessionError extends Error {
constructor(identity: string) {
super(`no open session for ${identity}`);
this.name = "NoOpenSessionError";
}
}
export class NoTariffError extends Error {
constructor() {
super("no active tariff configured");
this.name = "NoTariffError";
}
}
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;
}
/** One row in the booth Active Sessions list. A session is "active" while it is
* still open OR exited-but-within-grace — because the barrier is UNCONFIRMED, a
* paid/exited car is presumed possibly-still-present until grace expires. The
* "Open barrier" action is offered only when `paidAt != null` (no payment, no
* button — the no-unpaid-bypass rule). See wiki/concepts/booth-exit-flow.md. */
export interface ActiveSession {
readonly identity: string;
readonly source: string | null;
readonly enteredAt: string;
/** null while still inside; set once a vehicle_exit is signed (may still be present). */
readonly exitedAt: string | null;
readonly open: boolean;
readonly paidAt: string | null;
/** Amount owed now (open + unpaid only; null otherwise / no tariff). */
readonly amountMinor: number | null;
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. */
readonly subscription: boolean;
/** The subscription id (on-chain `permitId`), when `subscription` is true. */
readonly subscriptionId: string | null;
/** The subscriber's holder name (for a friendly label instead of the raw key). */
readonly subscriptionHolder: string | null;
/** Advisory licence plate recognized for this session (ANPR-on-snapshot), shown for
* at-a-glance identification. Null when no plate was read. Never an access decision. */
readonly plate: string | null;
}
/** Booth session view: everything the pay/exit modal needs in one read. */
export interface SessionLookup {
readonly identity: string;
readonly found: boolean;
/** Open = entered, no exit yet. */
readonly open: boolean;
readonly enteredAt: string | null;
readonly exitedAt: string | null;
/** Latest payment time, if paid. */
readonly paidAt: string | null;
/** Amount owed right now (the quote). Null when no session / no active tariff. */
readonly amountMinor: number | null;
readonly currency: string | null;
/** True when paid AND still within the walk-back grace window. */
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;
readonly subscriptionHolder: string | null;
/** Advisory licence plate recognized for this session (ANPR-on-snapshot). Null when
* none. Display/audit only — never an access decision. */
readonly plate: string | null;
}
export class PayStation {
readonly #db: Db;
readonly #log: EventLog;
readonly #logger: FastifyBaseLogger;
constructor(db: Db, log: EventLog, logger: FastifyBaseLogger) {
this.#db = db;
this.#log = log;
this.#logger = logger;
}
/** 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);
// 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;
// Category was frozen in the signed vehicle_entry payload — pricing AND repricing
// 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;
// Pure pricing shared with the Tariff Lab (priceSession). Only the latest payment
// matters for grace/overstay; pass it through. Overstay → fresh period from
// grace-expiry; within-grace → settled; unpaid → entry→now running total.
const last = this.#lastPayment(identity);
const p = priceSession(
entry.occurredAt,
new Date().toISOString(),
structure,
last ? [last] : [],
category,
);
return {
identity,
enteredAt: entry.occurredAt,
periodStart: p.periodStart,
amountMinor: p.amountMinor,
overstay: p.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
* (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,
tender: Tender,
overrideMinor?: number,
): Promise<{ amountMinor: number; currency: string }> {
// A SUBSCRIPTION occurrence settles its out-of-window tariff-bridge charge here
// (not a transient quote — the subscription itself is prepaid). The payment is keyed
// to the occurrence so the exit gate (#windowOwed − payments) clears.
const subWindow = this.#payableSubscriptionWindow(identity);
if (subWindow) {
const amountMinor = overrideMinor ?? subWindow.dueMinor;
await this.#log.append({
type: "payment",
source: "manual",
identity,
payload: {
sessionRef: identity,
amountMinor,
currency: subWindow.currency ?? undefined,
tender,
...(subWindow.tariffVersionId ? { tariffVersionId: subWindow.tariffVersionId } : {}),
subscriptionWindowCharge: true,
...(overrideMinor != null ? { reason: "operator-set amount", quotedMinor: subWindow.dueMinor } : {}),
},
});
this.#logger.info(`subscription window-charge payment ${amountMinor} ${subWindow.currency ?? ""} (${tender}) for ${identity}`);
return { amountMinor, currency: subWindow.currency ?? "" };
}
const q = this.quote(identity);
const amountMinor = overrideMinor ?? q.amountMinor;
await this.#log.append({
type: "payment",
source: "manual",
identity,
payload: {
sessionRef: identity,
amountMinor,
currency: q.currency,
tender,
tariffVersionId: q.tariffVersionId,
// The exit flow reads graceExitMin off the payment to validate the
// walk-back window without re-resolving the tariff.
graceExitMin: q.graceExitMin,
...(overrideMinor != null ? { reason: "operator-set amount", quotedMinor: q.amountMinor } : {}),
},
});
// Update the projection cache (rebuildable; not the source of truth).
try {
this.#db.update(sessions).set({ state: "paid" }).where(eq(sessions.id, identity)).run();
} catch (err) {
this.#logger.error(`session-cache mark-paid failed for ${identity}: ${(err as Error).message}`);
}
this.#logger.info(`payment ${amountMinor} ${q.currency} (${tender}) for ${identity}`);
return { amountMinor, currency: q.currency };
}
/**
* One-read session view for the booth pay/exit modal: entry/exit times, paid
* state, amount owed now, and walk-back-grace status. Read-only — folds the
* signed ledger (authoritative). A quote failure (no tariff) leaves amount null
* rather than throwing, so the modal can still show the session.
*/
lookup(identity: string): SessionLookup {
const id = identity.trim();
const rows = this.#db
.select()
.from(ledgerEvents)
.where(eq(ledgerEvents.identity, id))
.orderBy(ledgerEvents.index)
.all();
const entry = rows.find((r) => r.type === "vehicle_entry");
if (!entry) {
return {
identity: id, found: false, open: false, enteredAt: null, exitedAt: null,
paidAt: null, amountMinor: null, currency: null, withinGrace: false, graceExpiresAt: null,
overstay: false, subscription: false, subscriptionId: null, subscriptionHolder: null, plate: null,
};
}
// Subscription occurrence? The entry payload carries permit:true + permitId.
const entryPl = (entry.payload ?? {}) as { permit?: boolean; permitId?: string };
const isSubscription = entryPl.permit === true || entryPl.permitId != null;
const subscriptionId = isSubscription ? (entryPl.permitId ?? null) : null;
const exitRow = rows.find((r) => r.type === "vehicle_exit");
const open = !exitRow;
let paidAt: string | null = null;
let graceExitMin: number | null = null;
for (const r of rows) {
if (r.type === "payment") {
paidAt = r.occurredAt;
const p = (r.payload ?? {}) as { graceExitMin?: number };
if (typeof p.graceExitMin === "number") graceExitMin = p.graceExitMin;
}
}
const graceExpiresAt =
paidAt && graceExitMin != null ? new Date(Date.parse(paidAt) + graceExitMin * 60_000).toISOString() : null;
const withinGrace = graceExpiresAt != null && Date.now() <= Date.parse(graceExpiresAt);
// Amount owed now (best-effort; null if no tariff resolves). For a TRANSIENT session
// it's the running tariff. For a SUBSCRIPTION it's normally null (prepaid) — EXCEPT a
// time-window plan can owe an out-of-window TARIFF-BRIDGE charge (early-entry carried
// on the entry payload + a live late-exit charge), which the booth must take so the
// exit gate clears. See wiki/entities/subscription.md.
let amountMinor: number | null = null;
let currency: string | null = null;
if (open && !isSubscription) {
try {
const q = this.quote(id);
amountMinor = q.amountMinor;
currency = q.currency;
} catch {
/* no active tariff — leave null; modal shows session without a price */
}
} else if (open && isSubscription) {
const w = this.#subscriptionWindowDue(id, subscriptionId);
if (w && w.dueMinor > 0) {
amountMinor = w.dueMinor;
currency = w.currency;
}
}
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, overstay,
subscription: isSubscription, subscriptionId,
subscriptionHolder: this.#holderOf(subscriptionId),
plate: plateForIdentity(this.#db, id)?.plate ?? null,
};
}
/**
* All ACTIVE sessions for the booth list: still-open, OR exited-but-within-grace
* (the barrier is unconfirmed, so a paid/exited car is presumed possibly-present
* until grace expires). One ledger scan, grouped by identity (cheaper than N
* lookups). Sorted by entry time, newest first. Folds the SIGNED ledger
* (authoritative — not the sessions projection cache, which can drift).
* See wiki/concepts/booth-exit-flow.md.
*/
activeSessions(): ActiveSession[] {
const rows = this.#db.select().from(ledgerEvents).orderBy(ledgerEvents.index).all();
// Group the relevant events per identity in one pass.
type Acc = {
enteredAt?: string;
source: string | null;
exitedAt?: string;
paidAt?: string;
graceExitMin?: number;
subscriptionId?: string | null;
};
const byId = new Map<string, Acc>();
for (const r of rows) {
const id = r.identity;
if (!id) continue;
if (r.type === "vehicle_entry") {
const a = byId.get(id) ?? { source: r.source ?? null };
a.enteredAt = r.occurredAt;
a.source = r.source ?? a.source;
// Subscription occurrence? The entry payload carries permit:true + permitId
// (the on-chain field). Mark it so the booth never tries to charge it.
const pl = (r.payload ?? {}) as { permit?: boolean; permitId?: string };
if (pl.permit === true || pl.permitId) a.subscriptionId = pl.permitId ?? null;
byId.set(id, a);
} else if (r.type === "vehicle_exit") {
const a = byId.get(id);
if (a) a.exitedAt = r.occurredAt;
} else if (r.type === "payment") {
const a = byId.get(id);
if (a) {
a.paidAt = r.occurredAt;
const p = (r.payload ?? {}) as { graceExitMin?: number };
if (typeof p.graceExitMin === "number") a.graceExitMin = p.graceExitMin;
}
}
}
// Resolve advisory plates for all candidate identities in ONE device_events scan
// (cheaper than one lookup per row).
const plates = platesForIdentities(this.#db, byId.keys());
const now = Date.now();
const out: ActiveSession[] = [];
for (const [identity, a] of byId) {
if (!a.enteredAt) continue; // no entry → not a real session
const open = a.exitedAt == null;
const graceExpiresAt =
a.paidAt && a.graceExitMin != null
? new Date(Date.parse(a.paidAt) + a.graceExitMin * 60_000).toISOString()
: null;
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 → 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;
const overstay =
open && paid && !isSubscription && graceExpiresAt != null && !withinGrace;
// 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 && !isSubscription && (a.paidAt == null || overstay)) {
try {
const q = this.quote(identity);
amountMinor = q.amountMinor;
currency = q.currency;
} catch {
/* no active tariff — leave null */
}
}
out.push({
identity,
source: a.source,
enteredAt: a.enteredAt,
exitedAt: a.exitedAt ?? null,
open,
paidAt: a.paidAt ?? null,
amountMinor,
currency,
withinGrace,
graceExpiresAt,
overstay,
subscription: isSubscription,
subscriptionId: a.subscriptionId ?? null,
subscriptionHolder: this.#holderOf(a.subscriptionId ?? null),
plate: plates.get(identity)?.plate ?? null,
});
}
// Newest entry first.
out.sort((x, y) => Date.parse(y.enteredAt) - Date.parse(x.enteredAt));
return out;
}
/**
* The out-of-window TARIFF-BRIDGE amount a subscriber owes on an OPEN occurrence right
* now: the transient cost of the minutes parked OUTSIDE the plan's window over the WHOLE
* stay `[entry, now]` (one computation — covers early entry AND late exit without
* double-counting), minus whatever they've already paid against the occurrence. null
* when the plan has no timeframes / nothing is owed. Single source of truth shared with
* the exit gate so the booth quote and the gate agree.
*/
#subscriptionWindowDue(occurrenceId: string, subscriptionId: string | null): { dueMinor: number; currency: string | null } | null {
if (!subscriptionId) return null;
const sub = this.#db.select().from(subscriptions).where(eq(subscriptions.id, subscriptionId)).get();
if (!sub) return null;
const rows = this.#db.select().from(ledgerEvents).where(eq(ledgerEvents.identity, occurrenceId)).all();
const entryRow = rows.find((r) => r.type === "vehicle_entry");
if (!entryRow) return null;
const owed = windowOwedBetween(this.#db, sub.planVersionId, entryRow.occurredAt, new Date().toISOString());
if (!owed) return null;
let paid = 0;
for (const r of rows) {
if (r.type !== "payment") continue;
const pl = (r.payload ?? {}) as { amountMinor?: number };
if (typeof pl.amountMinor === "number") paid += pl.amountMinor;
}
return { dueMinor: owed.amountMinor - paid, currency: owed.currency };
}
/** Is this identity an OPEN subscription occurrence that owes a window charge? Returns
* the due amount + currency + the tariff version that priced the late-exit charge (for
* the payment payload), or null when it's transient / nothing owed. */
#payableSubscriptionWindow(
identity: string,
): { dueMinor: number; currency: string | null; tariffVersionId: string | null } | null {
const rows = this.#db.select().from(ledgerEvents).where(eq(ledgerEvents.identity, identity)).all();
const entry = rows.find((r) => r.type === "vehicle_entry");
if (!entry) return null;
const ep = (entry.payload ?? {}) as { permit?: boolean; permitId?: string };
if (ep.permit !== true && ep.permitId == null) return null; // transient
if (rows.some((r) => r.type === "vehicle_exit")) return null; // already out
const due = this.#subscriptionWindowDue(identity, ep.permitId ?? null);
if (!due || due.dueMinor <= 0) return null;
// Tariff version for the payment payload = the one that priced the stay (resolved at
// entry inside windowOwedBetween).
const owed = windowOwedBetween(this.#db, this.#planVersionOf(ep.permitId ?? null), entry.occurredAt, new Date().toISOString());
return { dueMinor: due.dueMinor, currency: due.currency, tariffVersionId: owed?.tariffVersionId ?? null };
}
/** The planVersionId of a subscription (for resolving its timeframes), or null. */
#planVersionOf(subscriptionId: string | null): string | null {
if (!subscriptionId) return null;
const row = this.#db.select().from(subscriptions).where(eq(subscriptions.id, subscriptionId)).get();
return row?.planVersionId ?? null;
}
/** The subscriber's holder name for a subscription id (for a friendly UI label),
* or null. Best-effort: a deleted subscription just yields null. */
#holderOf(subscriptionId: string | null): string | null {
if (!subscriptionId) return null;
try {
const row = this.#db.select().from(subscriptions).where(eq(subscriptions.id, subscriptionId)).get();
return row?.holderName ?? null;
} catch {
return null;
}
}
/** The vehicle_entry of an OPEN session for this identity (no later exit), or null. */
#openEntry(identity: string) {
const rows = this.#db
.select()
.from(ledgerEvents)
.where(eq(ledgerEvents.identity, identity))
.orderBy(ledgerEvents.index)
.all();
const entry = rows.find((r) => r.type === "vehicle_entry");
if (!entry) return null;
if (rows.some((r) => r.type === "vehicle_exit")) return null; // already closed
return entry;
}
/** The tariff version in force at `at` — latest effectiveFrom ≤ at, for the
* (single, for now) active site tariff. */
#tariffVersionFor(at: string) {
const tariff = this.#db.select().from(tariffs).where(eq(tariffs.scope, "site")).get();
if (!tariff) return null;
const versions = this.#db
.select()
.from(tariffVersions)
.where(eq(tariffVersions.tariffId, tariff.id))
.orderBy(desc(tariffVersions.effectiveFrom))
.all();
return versions.find((v) => v.effectiveFrom <= at) ?? null;
}
}