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:
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user