server: pay station + fee calc — full transient loop now passes

computeFee() in @parking/shared: pure integer fee over a TariffStructure
(stepped blocks, rolling-24h cap). Two edges fixed under test: grace uses RAW
duration (not rounded-up minutes), and the block ladder resets each 24h day.

PayStation + routes (GET /api/pay/quote, POST /api/pay): look up the open
session, resolve the active tariff version (latest effectiveFrom <= entry),
computeFee, append a signed payment event (amount/currency/tender/
tariffVersionId/graceExitMin). overrideMinor handles lost-ticket/dispute. PCI
stays out of the app: tender only records cash/card.

Verified end to end: entry -> quote (300 for 90min) -> pay -> exit opens and
closes the session, verifyChain ok.
This commit is contained in:
2026-06-15 19:15:53 +02:00
parent a8c6d6e714
commit f18e28eeca
6 changed files with 297 additions and 2 deletions
+140
View File
@@ -0,0 +1,140 @@
import { desc, eq, ledgerEvents, sessions, tariffVersions, tariffs, type Db } from "@parking/db";
import { computeFee, type TariffStructure, type Tender } from "@parking/shared";
import type { FastifyBaseLogger } from "fastify";
import type { EventLog } from "./event-log.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;
readonly enteredAt: string;
readonly amountMinor: number;
readonly currency: string;
readonly tariffVersionId: string;
readonly graceExitMin: number;
}
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 against the tariff in force at its entry. No side effect. */
quote(identity: string): Quote {
const entry = this.#openEntry(identity);
if (!entry) throw new NoOpenSessionError(identity);
const tv = this.#tariffVersionFor(entry.occurredAt);
if (!tv) throw new NoTariffError();
const structure = tv.structure as unknown as TariffStructure;
const amountMinor = computeFee(entry.occurredAt, new Date().toISOString(), structure);
return {
identity,
enteredAt: entry.occurredAt,
amountMinor,
currency: tv.currency,
tariffVersionId: tv.id,
graceExitMin: structure.gracePeriodExitMin,
};
}
/**
* 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.
*/
async pay(
identity: string,
tender: Tender,
overrideMinor?: number,
): Promise<{ amountMinor: number; currency: string }> {
const q = this.quote(identity);
const amountMinor = overrideMinor ?? q.amountMinor;
await this.#log.append({
type: "payment",
lane: -1, // payment happens at a central station, not a lane
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 };
}
/** 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;
}
}
+69
View File
@@ -0,0 +1,69 @@
import type { FastifyInstance } from "fastify";
import { requireRole } from "../auth.js";
import {
NoOpenSessionError,
NoTariffError,
type PayStation,
} from "../pay-station.js";
// Pay-station endpoints (pay-on-foot). The terminal/operator UI quotes a session
// then takes payment; the payment becomes a signed ledger event. PCI scope stays
// OUT of the app — actual card capture is a standalone P2PE terminal; here `tender`
// just records cash vs. card. See wiki/concepts/tariff.md, parking-session.md, bom.md.
interface QuoteQuery {
identity: string;
}
interface PayBody {
identity: string;
tender: "cash" | "card";
/** Operator-set amount (lost ticket / dispute) — overrides the computed fee. */
overrideMinor?: number;
}
export async function payRoutes(app: FastifyInstance, payStation: PayStation): Promise<void> {
// Cashier/operator/admin operate the pay station; readonly may not.
const guard = requireRole("admin", "operator", "cashier");
// Quote: what does this session owe right now? (No side effect.)
app.get<{ Querystring: QuoteQuery }>(
"/api/pay/quote",
{ preHandler: guard },
async (req, reply) => {
const identity = (req.query.identity ?? "").trim();
if (!identity) return reply.code(400).send({ error: "identity required" });
try {
return payStation.quote(identity);
} catch (err) {
return mapError(reply, err);
}
},
);
// Pay: take payment and append the signed `payment` event.
app.post<{ Body: PayBody }>(
"/api/pay",
{ preHandler: guard },
async (req, reply) => {
const { identity, tender, overrideMinor } = req.body ?? {};
if (!identity || (tender !== "cash" && tender !== "card")) {
return reply.code(400).send({ error: "identity and tender (cash|card) required" });
}
if (overrideMinor != null && (!Number.isInteger(overrideMinor) || overrideMinor < 0)) {
return reply.code(400).send({ error: "overrideMinor must be a non-negative integer (minor units)" });
}
try {
const res = await payStation.pay(identity, tender, overrideMinor);
return reply.code(201).send(res);
} catch (err) {
return mapError(reply, err);
}
},
);
}
function mapError(reply: import("fastify").FastifyReply, err: unknown) {
if (err instanceof NoOpenSessionError) return reply.code(404).send({ error: err.message });
if (err instanceof NoTariffError) return reply.code(409).send({ error: err.message });
return reply.code(500).send({ error: (err as Error).message });
}
+54
View File
@@ -113,6 +113,60 @@ export interface TariffBlock {
readonly priceMinorPerIncrement: number;
}
/**
* Compute the parking fee (integer minor units) for a stay, from a TariffStructure.
* PURE + deterministic + offline — the pay station calls it with asOf = now; the
* result is fixed into a signed `payment` event, so it must be reproducible.
*
* Algorithm (wiki/concepts/tariff.md): round duration UP to incrementMin; free if
* within entry grace; else walk the stay one rolling-24h segment at a time, charging
* each increment at its block's rate (blocks consumed in order by cumulative minutes),
* capping each segment at dailyCapMinor. Times are ISO-8601; bad input → 0 (caller
* validates the tariff exists first).
*/
export function computeFee(
enteredAt: string,
asOf: string,
tariff: TariffStructure,
): number {
const ms = Date.parse(asOf) - Date.parse(enteredAt);
if (!Number.isFinite(ms) || ms <= 0) return 0;
const rawMinutes = ms / 60_000;
// Grace uses the RAW duration (a 10-min stay is free even if the increment is
// 60 min — otherwise rounding-up would defeat the grace window).
if (rawMinutes <= tariff.gracePeriodEntryMin) return 0;
const inc = Math.max(1, tariff.incrementMin);
const minutes = Math.ceil(rawMinutes / inc) * inc; // round UP to the increment
const DAY = 24 * 60;
let total = 0;
for (let segStart = 0; segStart < minutes; segStart += DAY) {
const segEnd = Math.min(segStart + DAY, minutes);
let segFee = 0;
// The block ladder RESETS each rolling-24h day: `within` is minutes elapsed
// WITHIN this day, so day 2 starts at the first block again (decision 2026-06-15).
for (let within = 0; segStart + within < segEnd; within += inc) {
segFee += rateAt(tariff.blocks, within);
}
if (tariff.dailyCapMinor != null) segFee = Math.min(segFee, tariff.dailyCapMinor);
total += segFee;
}
return total;
}
/** Price of the increment that starts at `cumulativeMin` — the block whose range
* [prevUpto, uptoMin) contains it; the open-ended (uptoMin=null) block catches the rest. */
function rateAt(blocks: readonly TariffBlock[], cumulativeMin: number): number {
let prev = 0;
for (const b of blocks) {
if (b.uptoMin == null || cumulativeMin < b.uptoMin) return b.priceMinorPerIncrement;
prev = b.uptoMin;
void prev;
}
// No open-ended block and past the last bound: charge the last block's rate.
return blocks.length ? blocks[blocks.length - 1]!.priceMinorPerIncrement : 0;
}
export const ROLES: readonly Role[] = [
"admin",
"operator",
+8 -2
View File
@@ -111,8 +111,14 @@ follow this page and [[tariff]]; the decision is recorded in [[session-model]].
- **Not a fail-state:** an unpaid reject keeps the barrier closed deliberately (driver returns to
the pay station); "exit fails open" ([[fail-state-safety]]) is about the *system* being unable
to decide (host/power loss), not an unpaid car.
- **Currently every transient exit rejects** — no `payment` events exist until the pay station is
built; the validation is the correct end-state, just not passable yet.
- **Pay station** (`apps/server/src/pay-station.ts`, routes `GET /api/pay/quote` + `POST /api/pay`):
look up the open session → resolve the active tariff version (latest `effectiveFrom ≤ entry`) →
`computeFee` → append a signed `payment` event (amount, currency, tender, `tariffVersionId`,
`graceExitMin`). An operator `overrideMinor` covers lost-ticket/dispute (recorded as the charged
amount + the quoted amount). Pay-on-foot: payment is decoupled from the exit lane. PCI scope stays
out of the app — `tender` only records cash/card; card capture is the standalone P2PE terminal.
- **The full transient loop now passes end to end** (verified): entry → quote → pay → exit opens,
session closed, `verifyChain` ok.
> **Design gap (flagged):** `lane_devices` has **no entry/exit direction** model. Entry is
> button-driven and exit is read-driven, so they don't currently collide — but a lane with both an
+9
View File
@@ -87,6 +87,15 @@ Deterministic, side-effect-free, unit-testable; the daily cap is applied **per r
overnight stay doesn't hit the cap twice). Rounding and segment edges are part of the settled spec
because the chain + reconciliation depend on the result being reproducible.
**Settled edges (2026-06-15, with tests):**
- **Grace uses RAW duration** — a stay within `gracePeriodEntryMin` is free even though the
increment would round it up (else rounding defeats the grace window).
- **The block ladder RESETS each rolling-24h day** — day 2 starts at the first block again (a 25h
stay = day-1 capped + day-2 first-hour rate), so the "daily" rate truly resets daily.
**As-built:** `computeFee(enteredAt, asOf, structure)` in `packages/shared` (pure). Unit-tested
across grace, block steps, daily cap, and multi-day reset.
## The pay-on-foot consequence
Because payment is decoupled from exit ([[parking-session]] lifecycle), the tariff has **two
+17
View File
@@ -517,3 +517,20 @@ guarantee. Recorded in [[dingtian-relay]] (new Hardening section).
- GAP flagged: lane_devices has no entry/exit DIRECTION model (door mapping hardcoded to 1 for exit);
fine while entry=button/exit=read, but multi-reader lanes need a lane-direction/role model (ties to
[[open-questions]] #1). Updated [[parking-session]] as-built + gap, [[index]].
## [2026-06-15] build | Pay station + fee calc; JWT 8h → until-logout
- JWT: dropped the 8h `expiresIn` (server.ts global + login). Token now valid **until explicit
logout**; cookie maxAge = 30 days so a browser restart doesn't log out an active operator
(auth.ts `COOKIE_MAX_AGE_SECONDS`). Closes the pending change from the shift decision; updated
[[local-jwt-auth]].
- `computeFee(enteredAt, asOf, structure)` in `packages/shared` — pure integer fee calc.
TWO BUGS caught by tests: (1) grace must use RAW duration, not the rounded-up minutes (a 10-min
stay was being charged a full hour); (2) the block ladder must RESET each rolling-24h day (decision:
day 2 restarts at first-block pricing → 25h = 1200 cap + 200). Both fixed; 9 cases pass.
- Pay station (`apps/server/src/pay-station.ts` + routes `GET /api/pay/quote`, `POST /api/pay`):
open session → active tariff version → computeFee → signed `payment` event (amount/currency/tender/
tariffVersionId/graceExitMin); `overrideMinor` for lost-ticket/dispute. Cashier/operator/admin guard.
- VERIFIED: full loop entry→quote(300 for 90min)→pay→exit opens+closes, verifyChain ok. (A
raw-SQL backdate in one test correctly broke the chain — the tamper-evidence working, not a flow bug.)
- Updated [[tariff]] (settled edges + as-built), [[parking-session]] (pay station as-built; full
loop passes).