fix(subs): out-of-window charge was a phantom 12h span (4,100 ALL bug)

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
This commit is contained in:
2026-06-20 20:37:57 +02:00
parent eafbc3ddbb
commit 294ca85ded
5 changed files with 126 additions and 34 deletions
+15 -17
View File
@@ -3,7 +3,7 @@ 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 { windowCharge } from "./subscription-window.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:
@@ -453,10 +453,11 @@ export class PayStation {
/**
* The out-of-window TARIFF-BRIDGE amount a subscriber owes on an OPEN occurrence right
* now: carried early-entry charge (signed on the entry payload) + a fresh late-exit
* charge (window-close→now) − whatever they've already paid against the occurrence.
* null when the plan has no timeframes / nothing is owed. Mirrors SubscriptionFlow's
* exit-gate computation so the booth quote and the gate agree.
* 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;
@@ -465,11 +466,10 @@ export class PayStation {
const rows = this.#db.select().from(ledgerEvents).where(eq(ledgerEvents.identity, occurrenceId)).all();
const entryRow = rows.find((r) => r.type === "vehicle_entry");
const ep = (entryRow?.payload ?? {}) as { windowOwedMinor?: number; windowCurrency?: string };
const entryOwed = typeof ep.windowOwedMinor === "number" ? ep.windowOwedMinor : 0;
if (!entryRow) return null;
const exitCh = windowCharge(this.#db, sub.planVersionId, new Date().toISOString(), "exit");
const exitOwed = exitCh?.amountMinor ?? 0;
const owed = windowOwedBetween(this.#db, sub.planVersionId, entryRow.occurredAt, new Date().toISOString());
if (!owed) return null;
let paid = 0;
for (const r of rows) {
@@ -478,9 +478,7 @@ export class PayStation {
if (typeof pl.amountMinor === "number") paid += pl.amountMinor;
}
const dueMinor = entryOwed + exitOwed - paid;
const currency = ep.windowCurrency ?? exitCh?.currency ?? null;
return { dueMinor, currency };
return { dueMinor: owed.amountMinor - paid, currency: owed.currency };
}
/** Is this identity an OPEN subscription occurrence that owes a window charge? Returns
@@ -492,15 +490,15 @@ export class PayStation {
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; windowTariffVersionId?: string };
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;
// The late-exit charge resolves its own tariff version; for the entry-only case we
// stamped windowTariffVersionId on entry — pass whichever applies for reproducibility.
const exitCh = windowCharge(this.#db, this.#planVersionOf(ep.permitId ?? null), new Date().toISOString(), "exit");
return { dueMinor: due.dueMinor, currency: due.currency, tariffVersionId: exitCh?.tariffVersionId ?? ep.windowTariffVersionId ?? 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. */
+9 -16
View File
@@ -16,7 +16,7 @@ import type { DeviceReadEvent, ReadOutcome } from "./device-events.js";
import type { EventLog } from "./event-log.js";
import { type FlowDirection, type ResolvedRelay } from "./device-resolve.js";
import { snapshotAsync } from "./snapshot.js";
import { windowCharge } from "./subscription-window.js";
import { windowCharge, windowOwedBetween } from "./subscription-window.js";
import type { VisionClient } from "./vision-client.js";
// SUBSCRIPTION flow: a subscriber identified by card/QR/plate enters/exits without
@@ -249,33 +249,26 @@ export class SubscriptionFlow {
}
/**
* Total out-of-window charge owed for an occurrence right now: the carried EARLY-ENTRY
* charge (signed on the `vehicle_entry` payload as `windowOwedMinor`) + a fresh
* LATE-EXIT charge (window-close→now). Pure read; the entry portion is on-chain truth,
* the exit portion is recomputed each scan (it grows until they leave). Returns the sum
* and the currency. A plan without timeframes yields 0.
* Total out-of-window charge owed for an occurrence right now: the transient cost of the
* minutes parked OUTSIDE the plan's window over the WHOLE stay `[entry, now]` — ONE
* computation covering early entry AND late exit (not entry-gap + exit-gap, which
* double-counts and lets the exit gap reach a previous day's close). A plan without
* timeframes yields 0. Single source of truth shared with the booth quote.
*/
#windowOwed(
occurrenceId: string,
_subscriptionId: string,
planVersionId: string | null,
): { totalMinor: number; currency: string | null } {
// Carried early-entry charge from the signed entry payload.
const entryRow = this.#db
.select()
.from(ledgerEvents)
.where(eq(ledgerEvents.identity, occurrenceId))
.all()
.find((r) => r.type === "vehicle_entry");
const ep = (entryRow?.payload ?? {}) as { windowOwedMinor?: number; windowCurrency?: string };
const entryOwed = typeof ep.windowOwedMinor === "number" ? ep.windowOwedMinor : 0;
// Fresh late-exit charge (window-close → now), priced transiently.
const exitCh = windowCharge(this.#db, planVersionId, new Date().toISOString(), "exit");
const exitOwed = exitCh?.amountMinor ?? 0;
const currency = ep.windowCurrency ?? exitCh?.currency ?? null;
return { totalMinor: entryOwed + exitOwed, currency };
if (!entryRow) return { totalMinor: 0, currency: null };
const owed = windowOwedBetween(this.#db, planVersionId, entryRow.occurredAt, new Date().toISOString());
return { totalMinor: owed?.amountMinor ?? 0, currency: owed?.currency ?? null };
}
/** Sum of signed `payment` events keyed to this occurrence (what the subscriber has
+35
View File
@@ -1,6 +1,7 @@
import { desc, eq, siteConfig, subscriptionPlans, tariffVersions, tariffs, type Db } from "@parking/db";
import {
computeFee,
minutesOutsideWindow,
outOfWindowGap,
type PlanTimeframes,
type SubscriptionPlan,
@@ -92,3 +93,37 @@ export function windowCharge(
tariffVersionId: tv.id,
};
}
/**
* The TOTAL out-of-window charge a subscriber owes for an OPEN occurrence, computed over
* the whole stay `[enteredAt, nowISO)` in ONE shot (not entry-gap + exit-gap, which
* double-counts and lets the exit gap reach back to a previous day's close). Sums the
* minutes parked outside the plan's allowed window and prices them as a single transient
* stay of that duration — so the tariff's increments + daily cap apply correctly. Returns
* null when the plan has no timeframes / nothing is owed / no tariff to price against.
*/
export function windowOwedBetween(
db: Db,
planVersionId: string | null,
enteredAtISO: string,
nowISO: string,
): { amountMinor: number; minutes: number; currency: string; tariffVersionId: string } | null {
const plan = planVersionById(db, planVersionId);
const timeframes = (plan?.timeframes ?? null) as PlanTimeframes | null;
if (!timeframes) return null;
const tz = timeframes.tz || siteTz(db);
const minutes = minutesOutsideWindow(timeframes, tz, enteredAtISO, nowISO);
if (minutes <= 0) return null;
// Price the out-of-window duration as a transient stay (entry→entry+minutes), against
// the tariff in force at entry — reproducible, and the daily cap applies.
const tv = tariffVersionAt(db, enteredAtISO);
if (!tv) return null;
const structure = tv.structure as unknown as TariffStructure;
const end = new Date(Date.parse(enteredAtISO) + minutes * 60_000).toISOString();
const amountMinor = computeFee(enteredAtISO, end, structure);
if (amountMinor <= 0) return null;
return { amountMinor, minutes, currency: tv.currency, tariffVersionId: tv.id };
}