Files
parking_solution/apps/server/src/pay-station.ts
T
julian 692dff5f89 feat(validations): merchant (bar/lavazh) ticket validations end-to-end
In-park merchants discharge customers' parking: a merchant user scans the
ticket on their device (/validate; validation:create + program↔user binding)
and applies their program — comp / first-N-minutes free / amount-off (capped,
typed at scan) / percent. All money stays at the booth: the quote folds live
validations in a canonical order (timeCredit → percent → fixed → comp, net
floors at 0, Σ lines ≡ gross − net), the payment records gross/discount and
CONSUMES the validation ids (an overstay's fresh period never re-applies
them), the receipt prints the gross → lines → net story, and the Z/X-report
carries discountTotalMinor leakage. Every apply/void is a signed, attributed
ledger event (refId = append-only void); program config is /setup/site master
data (Bar/Lavazh checkboxes + right-column panel, tabs when both) whose saves
sign config_change. Migration 0024 + reset-db drift-guard entries; 8 route
integration tests + priceSession fold suite.

See wiki/concepts/validation-discounts.md for the full design record.

Claude-Session: https://claude.ai/code/session_01YYkpEsLmoQPaize5ec3oUm
2026-07-13 19:49:58 +02:00

611 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { desc, eq, ledgerEvents, sessions, subscriptions, tariffVersions, tariffs, type Db } from "@parking/db";
import { priceSession, type TariffStructure, type Tender, type ValidationLine } from "@parking/shared";
import type { FastifyBaseLogger } from "fastify";
import type { EventLog } from "./event-log.js";
import { plateForIdentity, platesForIdentities } from "./plate-lookup.js";
import { windowOwedBetween } from "./subscription-window.js";
import { liveValidations } from "./validations.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;
/** Vehicle entry time (the session's original entry; for display/audit). */
readonly enteredAt: string;
/** Start of the period being billed RIGHT NOW. For a first payment this is the
* entry. For an OVERSTAY (a paid session whose walk-back grace lapsed — the car
* re-parked / a new period began) it is the moment that grace expired: the overstay
* is priced as a fresh stay from there → now, with its own daily-cap ladder, NOT
* "full stay minus paid" (which a daily cap collapses toward zero). */
readonly periodStart: string;
/** Amount owed now: the fee for [periodStart → now], NET of merchant validations. */
readonly amountMinor: number;
/** The pre-validation fee (= amountMinor when no validations apply). */
readonly grossMinor: number;
/** Total the merchant validations took off (gross − net). */
readonly discountMinor: number;
/** Per-validation receipt/display lines (empty when none apply). */
readonly validationLines: ValidationLine[];
/** The validation event ids this quote applied — the payment stamps them as
* CONSUMED so an overstay's fresh period never re-applies them. */
readonly validationIds: string[];
/** True when this quote prices an overstay period (grace lapsed), not the first stay. */
readonly overstay: boolean;
readonly currency: string;
readonly tariffVersionId: string;
readonly graceExitMin: number;
}
/** One row in the booth Active Sessions list. A session is "active" while it is
* still open OR exited-but-within-grace — because the barrier is UNCONFIRMED, a
* paid/exited car is presumed possibly-still-present until grace expires. The
* "Open barrier" action is offered only when `paidAt != null` (no payment, no
* button — the no-unpaid-bypass rule). See wiki/concepts/booth-exit-flow.md. */
export interface ActiveSession {
readonly identity: string;
readonly source: string | null;
readonly enteredAt: string;
/** null while still inside; set once a vehicle_exit is signed (may still be present). */
readonly exitedAt: string | null;
readonly open: boolean;
readonly paidAt: string | null;
/** Amount owed now (open + unpaid only; null otherwise / no tariff). */
readonly amountMinor: number | null;
readonly currency: string | null;
readonly withinGrace: boolean;
readonly graceExpiresAt: string | null;
/** OVERSTAY = a paid transient whose walk-back grace lapsed with NO signed vehicle_exit.
* The car either re-parked (a new period began) or is faulty/abandoned — not a system
* fault, and not "stuck". It lingers in occupancy and owes a fresh period (priced from
* grace-expiry, see `quote`). We keep it listed and BADGE it OVERSTAY so the operator
* reconciles via a top-up, instead of silently aging it out. No free barrier open.
* See wiki/concepts/booth-exit-flow.md. */
readonly overstay: boolean;
/** True for a SUBSCRIPTION occurrence (prepaid — never charged). The booth shows it
* with snapshots + an always-available "open barrier" (assist a faulty exit reader /
* missing card), and never a pay flow. See wiki/entities/subscription.md. */
readonly subscription: boolean;
/** The subscription id (on-chain `permitId`), when `subscription` is true. */
readonly subscriptionId: string | null;
/** The subscriber's holder name (for a friendly label instead of the raw key). */
readonly subscriptionHolder: string | null;
/** Advisory licence plate recognized for this session (ANPR-on-snapshot), shown for
* at-a-glance identification. Null when no plate was read. Never an access decision. */
readonly plate: string | null;
}
/** Booth session view: everything the pay/exit modal needs in one read. */
export interface SessionLookup {
readonly identity: string;
readonly found: boolean;
/** Open = entered, no exit yet. */
readonly open: boolean;
readonly enteredAt: string | null;
readonly exitedAt: string | null;
/** Latest payment time, if paid. */
readonly paidAt: string | null;
/** Amount owed right now (the quote). Null when no session / no active tariff. */
readonly amountMinor: number | null;
readonly currency: string | null;
/** Amount actually PAID (from the latest payment event), if any. Distinct from
* `amountMinor` (what's owed now): once a transient is settled `amountMinor` is null,
* but the operator still wants to see the sum that was collected. */
readonly paidMinor: number | null;
readonly paidCurrency: string | null;
/** True when paid AND still within the walk-back grace window. */
readonly withinGrace: boolean;
/** ISO time the walk-back grace expires (paidAt + graceExitMin), if paid. */
readonly graceExpiresAt: string | null;
/** OVERSTAY = paid transient, walk-back grace expired, no signed exit. A new period
* began; `amountMinor` is the fresh fee from grace-expiry — it cannot exit for free. */
readonly overstay: boolean;
/** True for a SUBSCRIPTION occurrence (prepaid — never charged; barrier-open only). */
readonly subscription: boolean;
readonly subscriptionId: string | null;
readonly subscriptionHolder: string | null;
/** Advisory licence plate recognized for this session (ANPR-on-snapshot). Null when
* none. Display/audit only — never an access decision. */
readonly plate: string | null;
/** Merchant validations folded into `amountMinor` (which is NET): the pre-discount
* fee, the total taken off, and the per-validation lines for the modal/receipt.
* grossMinor/discountMinor are null when no quote resolved. */
readonly grossMinor: number | null;
readonly discountMinor: number | null;
readonly validationLines: ValidationLine[];
}
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. Normally the period is entry→now. But for an OVERSTAY — a
* paid session whose walk-back grace has lapsed (the car re-parked, or a new period
* began) — the customer is billed for a FRESH period from grace-expiry→now, with its
* own daily-cap ladder. This is NOT "full stay minus paid": with a daily cap the
* whole-stay gross plateaus while prior payments keep pace, so the delta collapses to
* 0 and a multi-day overstay would exit free (ticket 1245791632490). A new period
* reflects the reality and re-accrues the fee. No side effect. */
quote(identity: string): Quote {
const entry = this.#openEntry(identity);
if (!entry) throw new NoOpenSessionError(identity);
// The tariff in force is keyed to ENTRY (the version frozen for this session), even
// for an overstay period — the customer keeps the rate card they entered under.
const tv = this.#tariffVersionFor(entry.occurredAt);
if (!tv) throw new NoTariffError();
const structure = tv.structure as unknown as TariffStructure;
// Category was frozen in the signed vehicle_entry payload — pricing AND repricing
// both read it from there, so a V2 category tariff yields the same amount at the
// booth and at exit. Absent (legacy/V1) ⇒ undefined ⇒ category-agnostic pricing.
const category = (entry.payload as { category?: string } | null)?.category;
// Pure pricing shared with the Tariff Lab (priceSession). Only the latest payment
// matters for grace/overstay; pass it through. Overstay → fresh period from
// grace-expiry; within-grace → settled; unpaid → entry→now running total.
// Merchant validations: fold the LIVE ones (applied, unvoided, not consumed by a
// prior payment) so the quote is NET — the payment then stamps their ids as
// consumed. See wiki/concepts/validation-discounts.md.
const last = this.#lastPayment(identity);
const validations = liveValidations(this.#db, identity);
const p = priceSession(
entry.occurredAt,
new Date().toISOString(),
structure,
last ? [last] : [],
category,
validations,
);
return {
identity,
enteredAt: entry.occurredAt,
periodStart: p.periodStart,
amountMinor: p.amountMinor,
grossMinor: p.grossMinor,
discountMinor: p.discountMinor,
validationLines: p.validationLines,
validationIds: validations.map((v) => v.eventId),
overstay: p.overstay,
currency: tv.currency,
tariffVersionId: tv.id,
graceExitMin: structure.gracePeriodExitMin,
};
}
/** The latest signed `payment` for this session (time + the grace window it granted),
* or null if never paid. Folds the append-only ledger. */
#lastPayment(identity: string): { paidAt: string; graceExitMin: number | null } | null {
const rows = this.#db
.select({ type: ledgerEvents.type, occurredAt: ledgerEvents.occurredAt, payload: ledgerEvents.payload })
.from(ledgerEvents)
.where(eq(ledgerEvents.identity, identity))
.orderBy(ledgerEvents.index)
.all();
let last: { paidAt: string; graceExitMin: number | null } | null = null;
for (const r of rows) {
if (r.type !== "payment") continue;
const g = (r.payload as { graceExitMin?: number } | null)?.graceExitMin;
last = { paidAt: r.occurredAt, graceExitMin: typeof g === "number" ? g : null };
}
return last;
}
/**
* 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
* (grace lapsed) the quote prices a fresh period from grace-expiry→now (see `quote`),
* and this payment writes a new `graceExitMin` so the walk-back window restarts.
* `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 }> {
// A SUBSCRIPTION occurrence settles its out-of-window tariff-bridge charge here
// (not a transient quote — the subscription itself is prepaid). The payment is keyed
// to the occurrence so the exit gate (#windowOwed − payments) clears.
const subWindow = this.#payableSubscriptionWindow(identity);
if (subWindow) {
const amountMinor = overrideMinor ?? subWindow.dueMinor;
await this.#log.append({
type: "payment",
source: "manual",
identity,
payload: {
sessionRef: identity,
amountMinor,
currency: subWindow.currency ?? undefined,
tender,
...(subWindow.tariffVersionId ? { tariffVersionId: subWindow.tariffVersionId } : {}),
subscriptionWindowCharge: true,
...(overrideMinor != null ? { reason: "operator-set amount", quotedMinor: subWindow.dueMinor } : {}),
},
});
this.#logger.info(`subscription window-charge payment ${amountMinor} ${subWindow.currency ?? ""} (${tender}) for ${identity}`);
return { amountMinor, currency: subWindow.currency ?? "" };
}
const q = this.quote(identity);
const amountMinor = overrideMinor ?? q.amountMinor;
await this.#log.append({
type: "payment",
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,
// Merchant validations: record the gross/discount split + CONSUME the applied
// validation ids, so reporting sees the leakage and a later overstay period
// never re-applies them. A zero-net settlement (full comp) is still a signed
// payment — grace/voucher/exit work unchanged. See validation-discounts.md.
...(q.validationIds.length
? {
grossMinor: q.grossMinor,
discountMinor: q.discountMinor,
validationIds: q.validationIds,
validationLines: q.validationLines.map((l) => ({ ...l })),
}
: {}),
...(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 };
}
/**
* One-read session view for the booth pay/exit modal: entry/exit times, paid
* state, amount owed now, and walk-back-grace status. Read-only — folds the
* signed ledger (authoritative). A quote failure (no tariff) leaves amount null
* rather than throwing, so the modal can still show the session.
*/
lookup(identity: string): SessionLookup {
const id = identity.trim();
const rows = this.#db
.select()
.from(ledgerEvents)
.where(eq(ledgerEvents.identity, id))
.orderBy(ledgerEvents.index)
.all();
const entry = rows.find((r) => r.type === "vehicle_entry");
if (!entry) {
return {
identity: id, found: false, open: false, enteredAt: null, exitedAt: null,
paidAt: null, amountMinor: null, currency: null, paidMinor: null, paidCurrency: null,
withinGrace: false, graceExpiresAt: null,
overstay: false, subscription: false, subscriptionId: null, subscriptionHolder: null, plate: null,
grossMinor: null, discountMinor: null, validationLines: [],
};
}
// Subscription occurrence? The entry payload carries permit:true + permitId.
const entryPl = (entry.payload ?? {}) as { permit?: boolean; permitId?: string };
const isSubscription = entryPl.permit === true || entryPl.permitId != null;
const subscriptionId = isSubscription ? (entryPl.permitId ?? null) : null;
// A `void` (cancelled ticket) closes the session like an exit — a voided ticket is no
// longer open and can't be paid/exited. See void-flow.ts.
const exitRow = rows.find((r) => r.type === "vehicle_exit" || r.type === "void");
const open = !exitRow;
let paidAt: string | null = null;
let graceExitMin: number | null = null;
let paidMinor: number | null = null;
let paidCurrency: string | null = null;
for (const r of rows) {
if (r.type === "payment") {
paidAt = r.occurredAt;
const p = (r.payload ?? {}) as { graceExitMin?: number; amountMinor?: number; currency?: string };
if (typeof p.graceExitMin === "number") graceExitMin = p.graceExitMin;
// Sum payments (overstay top-ups append a second one) so the displayed paid total
// reflects everything collected for the session, not just the last slip.
if (typeof p.amountMinor === "number") paidMinor = (paidMinor ?? 0) + p.amountMinor;
if (typeof p.currency === "string") paidCurrency = p.currency;
}
}
const graceExpiresAt =
paidAt && graceExitMin != null ? new Date(Date.parse(paidAt) + graceExitMin * 60_000).toISOString() : null;
const withinGrace = graceExpiresAt != null && Date.now() <= Date.parse(graceExpiresAt);
// Amount owed now (best-effort; null if no tariff resolves). For a TRANSIENT session
// it's the running tariff. For a SUBSCRIPTION it's normally null (prepaid) — EXCEPT a
// time-window plan can owe an out-of-window TARIFF-BRIDGE charge (early-entry carried
// on the entry payload + a live late-exit charge), which the booth must take so the
// exit gate clears. See wiki/entities/subscription.md.
let amountMinor: number | null = null;
let currency: string | null = null;
let grossMinor: number | null = null;
let discountMinor: number | null = null;
let validationLines: ValidationLine[] = [];
if (open && !isSubscription) {
try {
const q = this.quote(id);
amountMinor = q.amountMinor;
currency = q.currency;
grossMinor = q.grossMinor;
discountMinor = q.discountMinor;
validationLines = q.validationLines;
} catch {
/* no active tariff — leave null; modal shows session without a price */
}
} else if (open && isSubscription) {
const w = this.#subscriptionWindowDue(id, subscriptionId);
if (w && w.dueMinor > 0) {
amountMinor = w.dueMinor;
currency = w.currency;
}
}
const overstay = open && !isSubscription && paidAt != null && graceExpiresAt != null && !withinGrace;
return {
identity: id, found: true, open,
enteredAt: entry.occurredAt, exitedAt: exitRow?.occurredAt ?? null,
paidAt, amountMinor, currency, paidMinor, paidCurrency, withinGrace, graceExpiresAt, overstay,
subscription: isSubscription, subscriptionId,
subscriptionHolder: this.#holderOf(subscriptionId),
plate: plateForIdentity(this.#db, id)?.plate ?? null,
grossMinor, discountMinor, validationLines,
};
}
/**
* All ACTIVE sessions for the booth list: still-open, OR exited-but-within-grace
* (the barrier is unconfirmed, so a paid/exited car is presumed possibly-present
* until grace expires). One ledger scan, grouped by identity (cheaper than N
* lookups). Sorted by entry time, newest first. Folds the SIGNED ledger
* (authoritative — not the sessions projection cache, which can drift).
* See wiki/concepts/booth-exit-flow.md.
*/
activeSessions(): ActiveSession[] {
const rows = this.#db.select().from(ledgerEvents).orderBy(ledgerEvents.index).all();
// Group the relevant events per identity in one pass.
type Acc = {
enteredAt?: string;
source: string | null;
exitedAt?: string;
paidAt?: string;
graceExitMin?: number;
subscriptionId?: string | null;
};
const byId = new Map<string, Acc>();
for (const r of rows) {
const id = r.identity;
if (!id) continue;
if (r.type === "vehicle_entry") {
const a = byId.get(id) ?? { source: r.source ?? null };
a.enteredAt = r.occurredAt;
a.source = r.source ?? a.source;
// Subscription occurrence? The entry payload carries permit:true + permitId
// (the on-chain field). Mark it so the booth never tries to charge it.
const pl = (r.payload ?? {}) as { permit?: boolean; permitId?: string };
if (pl.permit === true || pl.permitId) a.subscriptionId = pl.permitId ?? null;
byId.set(id, a);
} else if (r.type === "vehicle_exit" || r.type === "void") {
// A `void` closes the session like an exit — drop it from the active list.
const a = byId.get(id);
if (a) a.exitedAt = r.occurredAt;
} else if (r.type === "payment") {
const a = byId.get(id);
if (a) {
a.paidAt = r.occurredAt;
const p = (r.payload ?? {}) as { graceExitMin?: number };
if (typeof p.graceExitMin === "number") a.graceExitMin = p.graceExitMin;
}
}
}
// Resolve advisory plates for all candidate identities in ONE device_events scan
// (cheaper than one lookup per row).
const plates = platesForIdentities(this.#db, byId.keys());
const now = Date.now();
const out: ActiveSession[] = [];
for (const [identity, a] of byId) {
if (!a.enteredAt) continue; // no entry → not a real session
const open = a.exitedAt == null;
const graceExpiresAt =
a.paidAt && a.graceExitMin != null
? new Date(Date.parse(a.paidAt) + a.graceExitMin * 60_000).toISOString()
: null;
const withinGrace = graceExpiresAt != null && now <= Date.parse(graceExpiresAt);
const paid = a.paidAt != null;
const isSubscription = a.subscriptionId !== undefined;
// ACTIVE membership:
// - exited + within grace → still shown (barrier unconfirmed, may be present);
// - exited + past grace → presumed gone, omitted;
// - open + UNPAID → always shown (a car owing money never ages out —
// it's genuinely still inside until it pays, however long that takes);
// - open + PAID + past grace → OVERSTAY. A paid transient whose walk-back grace
// lapsed with no signed vehicle_exit: the car re-parked (a new period) or is
// faulty/abandoned — not a system fault, not "stuck". It lingers in occupancy
// and owes a fresh period (priced from grace-expiry, see `quote`). We used to
// age these out (a silent display filter); now we KEEP them and flag `overstay`
// so the operator reconciles via a top-up. The signed log is untouched, and the
// barrier never opens for free on these. See booth-exit-flow.md.
if (!open && !withinGrace) continue;
const overstay =
open && paid && !isSubscription && graceExpiresAt != null && !withinGrace;
// Amount owed now: an open + unpaid TRANSIENT (first stay) OR an OVERSTAY (the new
// period's top-up). A subscription is prepaid — never quote/charge it.
let amountMinor: number | null = null;
let currency: string | null = null;
if (open && !isSubscription && (a.paidAt == null || overstay)) {
try {
const q = this.quote(identity);
amountMinor = q.amountMinor;
currency = q.currency;
} catch {
/* no active tariff — leave null */
}
}
out.push({
identity,
source: a.source,
enteredAt: a.enteredAt,
exitedAt: a.exitedAt ?? null,
open,
paidAt: a.paidAt ?? null,
amountMinor,
currency,
withinGrace,
graceExpiresAt,
overstay,
subscription: isSubscription,
subscriptionId: a.subscriptionId ?? null,
subscriptionHolder: this.#holderOf(a.subscriptionId ?? null),
plate: plates.get(identity)?.plate ?? null,
});
}
// Newest entry first.
out.sort((x, y) => Date.parse(y.enteredAt) - Date.parse(x.enteredAt));
return out;
}
/**
* The out-of-window TARIFF-BRIDGE amount a subscriber owes on an OPEN occurrence right
* now: the transient cost of the minutes parked OUTSIDE the plan's window over the WHOLE
* stay `[entry, now]` (one computation — covers early entry AND late exit without
* double-counting), minus whatever they've already paid against the occurrence. null
* when the plan has no timeframes / nothing is owed. Single source of truth shared with
* the exit gate so the booth quote and the gate agree.
*/
#subscriptionWindowDue(occurrenceId: string, subscriptionId: string | null): { dueMinor: number; currency: string | null } | null {
if (!subscriptionId) return null;
const sub = this.#db.select().from(subscriptions).where(eq(subscriptions.id, subscriptionId)).get();
if (!sub) return null;
const rows = this.#db.select().from(ledgerEvents).where(eq(ledgerEvents.identity, occurrenceId)).all();
const entryRow = rows.find((r) => r.type === "vehicle_entry");
if (!entryRow) return null;
const owed = windowOwedBetween(this.#db, sub.planVersionId, entryRow.occurredAt, new Date().toISOString());
if (!owed) return null;
let paid = 0;
for (const r of rows) {
if (r.type !== "payment") continue;
const pl = (r.payload ?? {}) as { amountMinor?: number };
if (typeof pl.amountMinor === "number") paid += pl.amountMinor;
}
return { dueMinor: owed.amountMinor - paid, currency: owed.currency };
}
/** Is this identity an OPEN subscription occurrence that owes a window charge? Returns
* the due amount + currency + the tariff version that priced the late-exit charge (for
* the payment payload), or null when it's transient / nothing owed. */
#payableSubscriptionWindow(
identity: string,
): { dueMinor: number; currency: string | null; tariffVersionId: string | null } | null {
const rows = this.#db.select().from(ledgerEvents).where(eq(ledgerEvents.identity, identity)).all();
const entry = rows.find((r) => r.type === "vehicle_entry");
if (!entry) return null;
const ep = (entry.payload ?? {}) as { permit?: boolean; permitId?: string };
if (ep.permit !== true && ep.permitId == null) return null; // transient
if (rows.some((r) => r.type === "vehicle_exit")) return null; // already out
const due = this.#subscriptionWindowDue(identity, ep.permitId ?? null);
if (!due || due.dueMinor <= 0) return null;
// Tariff version for the payment payload = the one that priced the stay (resolved at
// entry inside windowOwedBetween).
const owed = windowOwedBetween(this.#db, this.#planVersionOf(ep.permitId ?? null), entry.occurredAt, new Date().toISOString());
return { dueMinor: due.dueMinor, currency: due.currency, tariffVersionId: owed?.tariffVersionId ?? null };
}
/** The planVersionId of a subscription (for resolving its timeframes), or null. */
#planVersionOf(subscriptionId: string | null): string | null {
if (!subscriptionId) return null;
const row = this.#db.select().from(subscriptions).where(eq(subscriptions.id, subscriptionId)).get();
return row?.planVersionId ?? null;
}
/** The subscriber's holder name for a subscription id (for a friendly UI label),
* or null. Best-effort: a deleted subscription just yields null. */
#holderOf(subscriptionId: string | null): string | null {
if (!subscriptionId) return null;
try {
const row = this.#db.select().from(subscriptions).where(eq(subscriptions.id, subscriptionId)).get();
return row?.holderName ?? null;
} catch {
return null;
}
}
/** 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;
}
}