f18e28eeca
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.
141 lines
5.1 KiB
TypeScript
141 lines
5.1 KiB
TypeScript
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;
|
|
}
|
|
}
|