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:
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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).
|
||||||
|
|||||||
Reference in New Issue
Block a user