294ca85ded
The tariff-bridge owed amount summed TWO charges — the early-entry gap + a "late-exit" gap — and the exit gap (outOfWindowGap edge:"exit") always measured back to the PREVIOUS window close, even for a subscriber still BEFORE their window. So a car that entered ~30 min early showed ~12h owed (4,100 ALL) the moment it was looked up, instead of ~100 ALL. Replace the two-gap sum with a single correct primitive, minutesOutsideWindow(timeframes, tz, from, to): the minutes within the actual stay [entry, now] that fall outside the allowed window (covering early entry AND late exit, bounded by the stay, weekend/off-days free). windowOwedBetween prices those minutes once as a transient stay (so increments + daily cap apply) against the tariff in force at entry. Both the exit gate (subscription-flow) and the booth quote (pay-station) now use this one source of truth — they can't disagree. Verified on the live occurrence: was 4,100 ALL, now 100 ALL (9 min outside → one increment). 87 shared tests (6 new regression cases incl. the phantom span). Build+lint 12/12. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
551 lines
24 KiB
TypeScript
551 lines
24 KiB
TypeScript
import { desc, eq, ledgerEvents, sessions, subscriptions, tariffVersions, tariffs, type Db } from "@parking/db";
|
||
import { priceSession, type TariffStructure, type Tender } 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";
|
||
|
||
// 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]. */
|
||
readonly amountMinor: number;
|
||
/** 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;
|
||
/** 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;
|
||
}
|
||
|
||
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.
|
||
const last = this.#lastPayment(identity);
|
||
const p = priceSession(
|
||
entry.occurredAt,
|
||
new Date().toISOString(),
|
||
structure,
|
||
last ? [last] : [],
|
||
category,
|
||
);
|
||
return {
|
||
identity,
|
||
enteredAt: entry.occurredAt,
|
||
periodStart: p.periodStart,
|
||
amountMinor: p.amountMinor,
|
||
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,
|
||
...(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, withinGrace: false, graceExpiresAt: null,
|
||
overstay: false, subscription: false, subscriptionId: null, subscriptionHolder: null, plate: null,
|
||
};
|
||
}
|
||
// 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;
|
||
const exitRow = rows.find((r) => r.type === "vehicle_exit");
|
||
const open = !exitRow;
|
||
|
||
let paidAt: string | null = null;
|
||
let graceExitMin: number | null = null;
|
||
for (const r of rows) {
|
||
if (r.type === "payment") {
|
||
paidAt = r.occurredAt;
|
||
const p = (r.payload ?? {}) as { graceExitMin?: number };
|
||
if (typeof p.graceExitMin === "number") graceExitMin = p.graceExitMin;
|
||
}
|
||
}
|
||
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;
|
||
if (open && !isSubscription) {
|
||
try {
|
||
const q = this.quote(id);
|
||
amountMinor = q.amountMinor;
|
||
currency = q.currency;
|
||
} 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, withinGrace, graceExpiresAt, overstay,
|
||
subscription: isSubscription, subscriptionId,
|
||
subscriptionHolder: this.#holderOf(subscriptionId),
|
||
plate: plateForIdentity(this.#db, id)?.plate ?? null,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 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") {
|
||
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;
|
||
}
|
||
}
|