diff --git a/apps/server/src/pay-station.ts b/apps/server/src/pay-station.ts new file mode 100644 index 0000000..6b399c8 --- /dev/null +++ b/apps/server/src/pay-station.ts @@ -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; + } +} diff --git a/apps/server/src/routes/pay.ts b/apps/server/src/routes/pay.ts new file mode 100644 index 0000000..37f25d4 --- /dev/null +++ b/apps/server/src/routes/pay.ts @@ -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 { + // 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 }); +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 6206731..fcde7e6 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -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", diff --git a/wiki/concepts/parking-session.md b/wiki/concepts/parking-session.md index 2c7b199..99b48e8 100644 --- a/wiki/concepts/parking-session.md +++ b/wiki/concepts/parking-session.md @@ -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 diff --git a/wiki/concepts/tariff.md b/wiki/concepts/tariff.md index df06041..b48084e 100644 --- a/wiki/concepts/tariff.md +++ b/wiki/concepts/tariff.md @@ -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 diff --git a/wiki/log.md b/wiki/log.md index 3bd5888..7c16108 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -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).