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; 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[] = [ export const ROLES: readonly Role[] = [
"admin", "admin",
"operator", "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 - **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 the pay station); "exit fails open" ([[fail-state-safety]]) is about the *system* being unable
to decide (host/power loss), not an unpaid car. to decide (host/power loss), not an unpaid car.
- **Currently every transient exit rejects** — no `payment` events exist until the pay station is - **Pay station** (`apps/server/src/pay-station.ts`, routes `GET /api/pay/quote` + `POST /api/pay`):
built; the validation is the correct end-state, just not passable yet. 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 > **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 > 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 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. 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 ## The pay-on-foot consequence
Because payment is decoupled from exit ([[parking-session]] lifecycle), the tariff has **two 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); - 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 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]]. [[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).