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 { FastifyBaseLogger } from "fastify";
|
||||||
import type { EventLog } from "./event-log.js";
|
import type { EventLog } from "./event-log.js";
|
||||||
import { plateForIdentity, platesForIdentities } from "./plate-lookup.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
|
// 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:
|
// 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
|
* 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
|
* now: the transient cost of the minutes parked OUTSIDE the plan's window over the WHOLE
|
||||||
* charge (window-close→now) − whatever they've already paid against the occurrence.
|
* stay `[entry, now]` (one computation — covers early entry AND late exit without
|
||||||
* null when the plan has no timeframes / nothing is owed. Mirrors SubscriptionFlow's
|
* double-counting), minus whatever they've already paid against the occurrence. null
|
||||||
* exit-gate computation so the booth quote and the gate agree.
|
* 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 {
|
#subscriptionWindowDue(occurrenceId: string, subscriptionId: string | null): { dueMinor: number; currency: string | null } | null {
|
||||||
if (!subscriptionId) return 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 rows = this.#db.select().from(ledgerEvents).where(eq(ledgerEvents.identity, occurrenceId)).all();
|
||||||
const entryRow = rows.find((r) => r.type === "vehicle_entry");
|
const entryRow = rows.find((r) => r.type === "vehicle_entry");
|
||||||
const ep = (entryRow?.payload ?? {}) as { windowOwedMinor?: number; windowCurrency?: string };
|
if (!entryRow) return null;
|
||||||
const entryOwed = typeof ep.windowOwedMinor === "number" ? ep.windowOwedMinor : 0;
|
|
||||||
|
|
||||||
const exitCh = windowCharge(this.#db, sub.planVersionId, new Date().toISOString(), "exit");
|
const owed = windowOwedBetween(this.#db, sub.planVersionId, entryRow.occurredAt, new Date().toISOString());
|
||||||
const exitOwed = exitCh?.amountMinor ?? 0;
|
if (!owed) return null;
|
||||||
|
|
||||||
let paid = 0;
|
let paid = 0;
|
||||||
for (const r of rows) {
|
for (const r of rows) {
|
||||||
@@ -478,9 +478,7 @@ export class PayStation {
|
|||||||
if (typeof pl.amountMinor === "number") paid += pl.amountMinor;
|
if (typeof pl.amountMinor === "number") paid += pl.amountMinor;
|
||||||
}
|
}
|
||||||
|
|
||||||
const dueMinor = entryOwed + exitOwed - paid;
|
return { dueMinor: owed.amountMinor - paid, currency: owed.currency };
|
||||||
const currency = ep.windowCurrency ?? exitCh?.currency ?? null;
|
|
||||||
return { dueMinor, currency };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Is this identity an OPEN subscription occurrence that owes a window charge? Returns
|
/** 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 rows = this.#db.select().from(ledgerEvents).where(eq(ledgerEvents.identity, identity)).all();
|
||||||
const entry = rows.find((r) => r.type === "vehicle_entry");
|
const entry = rows.find((r) => r.type === "vehicle_entry");
|
||||||
if (!entry) return null;
|
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 (ep.permit !== true && ep.permitId == null) return null; // transient
|
||||||
if (rows.some((r) => r.type === "vehicle_exit")) return null; // already out
|
if (rows.some((r) => r.type === "vehicle_exit")) return null; // already out
|
||||||
const due = this.#subscriptionWindowDue(identity, ep.permitId ?? null);
|
const due = this.#subscriptionWindowDue(identity, ep.permitId ?? null);
|
||||||
if (!due || due.dueMinor <= 0) return null;
|
if (!due || due.dueMinor <= 0) return null;
|
||||||
// The late-exit charge resolves its own tariff version; for the entry-only case we
|
// Tariff version for the payment payload = the one that priced the stay (resolved at
|
||||||
// stamped windowTariffVersionId on entry — pass whichever applies for reproducibility.
|
// entry inside windowOwedBetween).
|
||||||
const exitCh = windowCharge(this.#db, this.#planVersionOf(ep.permitId ?? null), new Date().toISOString(), "exit");
|
const owed = windowOwedBetween(this.#db, this.#planVersionOf(ep.permitId ?? null), entry.occurredAt, new Date().toISOString());
|
||||||
return { dueMinor: due.dueMinor, currency: due.currency, tariffVersionId: exitCh?.tariffVersionId ?? ep.windowTariffVersionId ?? null };
|
return { dueMinor: due.dueMinor, currency: due.currency, tariffVersionId: owed?.tariffVersionId ?? null };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The planVersionId of a subscription (for resolving its timeframes), or null. */
|
/** The planVersionId of a subscription (for resolving its timeframes), or null. */
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import type { DeviceReadEvent, ReadOutcome } from "./device-events.js";
|
|||||||
import type { EventLog } from "./event-log.js";
|
import type { EventLog } from "./event-log.js";
|
||||||
import { type FlowDirection, type ResolvedRelay } from "./device-resolve.js";
|
import { type FlowDirection, type ResolvedRelay } from "./device-resolve.js";
|
||||||
import { snapshotAsync } from "./snapshot.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";
|
import type { VisionClient } from "./vision-client.js";
|
||||||
|
|
||||||
// SUBSCRIPTION flow: a subscriber identified by card/QR/plate enters/exits without
|
// 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
|
* Total out-of-window charge owed for an occurrence right now: the transient cost of the
|
||||||
* charge (signed on the `vehicle_entry` payload as `windowOwedMinor`) + a fresh
|
* minutes parked OUTSIDE the plan's window over the WHOLE stay `[entry, now]` — ONE
|
||||||
* LATE-EXIT charge (window-close→now). Pure read; the entry portion is on-chain truth,
|
* computation covering early entry AND late exit (not entry-gap + exit-gap, which
|
||||||
* the exit portion is recomputed each scan (it grows until they leave). Returns the sum
|
* double-counts and lets the exit gap reach a previous day's close). A plan without
|
||||||
* and the currency. A plan without timeframes yields 0.
|
* timeframes yields 0. Single source of truth shared with the booth quote.
|
||||||
*/
|
*/
|
||||||
#windowOwed(
|
#windowOwed(
|
||||||
occurrenceId: string,
|
occurrenceId: string,
|
||||||
_subscriptionId: string,
|
_subscriptionId: string,
|
||||||
planVersionId: string | null,
|
planVersionId: string | null,
|
||||||
): { totalMinor: number; currency: string | null } {
|
): { totalMinor: number; currency: string | null } {
|
||||||
// Carried early-entry charge from the signed entry payload.
|
|
||||||
const entryRow = this.#db
|
const entryRow = this.#db
|
||||||
.select()
|
.select()
|
||||||
.from(ledgerEvents)
|
.from(ledgerEvents)
|
||||||
.where(eq(ledgerEvents.identity, occurrenceId))
|
.where(eq(ledgerEvents.identity, occurrenceId))
|
||||||
.all()
|
.all()
|
||||||
.find((r) => r.type === "vehicle_entry");
|
.find((r) => r.type === "vehicle_entry");
|
||||||
const ep = (entryRow?.payload ?? {}) as { windowOwedMinor?: number; windowCurrency?: string };
|
if (!entryRow) return { totalMinor: 0, currency: null };
|
||||||
const entryOwed = typeof ep.windowOwedMinor === "number" ? ep.windowOwedMinor : 0;
|
const owed = windowOwedBetween(this.#db, planVersionId, entryRow.occurredAt, new Date().toISOString());
|
||||||
|
return { totalMinor: owed?.amountMinor ?? 0, currency: owed?.currency ?? null };
|
||||||
// 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 };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Sum of signed `payment` events keyed to this occurrence (what the subscriber has
|
/** Sum of signed `payment` events keyed to this occurrence (what the subscriber has
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { desc, eq, siteConfig, subscriptionPlans, tariffVersions, tariffs, type Db } from "@parking/db";
|
import { desc, eq, siteConfig, subscriptionPlans, tariffVersions, tariffs, type Db } from "@parking/db";
|
||||||
import {
|
import {
|
||||||
computeFee,
|
computeFee,
|
||||||
|
minutesOutsideWindow,
|
||||||
outOfWindowGap,
|
outOfWindowGap,
|
||||||
type PlanTimeframes,
|
type PlanTimeframes,
|
||||||
type SubscriptionPlan,
|
type SubscriptionPlan,
|
||||||
@@ -92,3 +93,37 @@ export function windowCharge(
|
|||||||
tariffVersionId: tv.id,
|
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 };
|
||||||
|
}
|
||||||
|
|||||||
@@ -1142,6 +1142,47 @@ export function outOfWindowGap(
|
|||||||
return { start, end: atISO, minutes: mins };
|
return { start, end: atISO, minutes: mins };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Total minutes WITHIN the stay span `[fromISO, toISO)` that fall OUTSIDE the plan's
|
||||||
|
* allowed window — the correct charge basis for a subscriber's out-of-window parking
|
||||||
|
* (early entry AND/OR late exit, in one number, bounded by the actual stay). On days the
|
||||||
|
* window doesn't apply (not in `days`) the whole day is allowed (0 outside minutes). The
|
||||||
|
* window edges are widened by `graceMin`. Pure + tz-aware. Returns 0 for an unrestricted
|
||||||
|
* plan / empty span. (Sampled per minute; capped so a pathological span can't spin.)
|
||||||
|
*/
|
||||||
|
export function minutesOutsideWindow(
|
||||||
|
timeframes: PlanTimeframes | null | undefined,
|
||||||
|
tz: string,
|
||||||
|
fromISO: string,
|
||||||
|
toISO: string,
|
||||||
|
): number {
|
||||||
|
if (!timeframes) return 0;
|
||||||
|
if (typeof timeframes.fromMin !== "number" || typeof timeframes.toMin !== "number") return 0;
|
||||||
|
const fromMs = Date.parse(fromISO);
|
||||||
|
const toMs = Date.parse(toISO);
|
||||||
|
if (!Number.isFinite(fromMs) || !Number.isFinite(toMs) || toMs <= fromMs) return 0;
|
||||||
|
|
||||||
|
const zone = timeframes.tz || tz;
|
||||||
|
const grace = Math.max(0, timeframes.graceMin ?? 0);
|
||||||
|
const days = timeframes.days && timeframes.days.length > 0 ? new Set(timeframes.days) : null;
|
||||||
|
// Widen the allowed window by grace on both edges (so a few minutes either side is free).
|
||||||
|
const from = (timeframes.fromMin - grace + 1440) % 1440;
|
||||||
|
const to = (timeframes.toMin + grace) % 1440;
|
||||||
|
|
||||||
|
// Iterate minute-by-minute over the stay; count minutes outside the allowed window.
|
||||||
|
const totalMin = Math.ceil((toMs - fromMs) / 60_000);
|
||||||
|
const cap = 60 * 24 * 400; // ~400 days of minutes — a hard safety bound
|
||||||
|
let outside = 0;
|
||||||
|
for (let i = 0; i < totalMin && i < cap; i += 1) {
|
||||||
|
const wall = localBreakdown(fromMs + i * 60_000, zone);
|
||||||
|
// A day the window doesn't apply to ⇒ fully allowed (this minute is free).
|
||||||
|
if (days && !days.has(wall.dow)) continue;
|
||||||
|
const m = wall.hour * 60 + wall.minute;
|
||||||
|
if (!inWindow(m, from, to)) outside += 1;
|
||||||
|
}
|
||||||
|
return outside;
|
||||||
|
}
|
||||||
|
|
||||||
/** "HH:MM" → minutes-of-day (0-1439). Invalid → NaN (validation rejects those). */
|
/** "HH:MM" → minutes-of-day (0-1439). Invalid → NaN (validation rejects those). */
|
||||||
function hourToMin(hhmm: string): number {
|
function hourToMin(hhmm: string): number {
|
||||||
const m = /^(\d{2}):(\d{2})$/.exec(hhmm);
|
const m = /^(\d{2}):(\d{2})$/.exec(hhmm);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { outOfWindowGap, type PlanTimeframes } from "./index.js";
|
import { minutesOutsideWindow, outOfWindowGap, type PlanTimeframes } from "./index.js";
|
||||||
|
|
||||||
// The "tariff bridge" gap for a subscriber scan outside their allowed window. UTC tz
|
// The "tariff bridge" gap for a subscriber scan outside their allowed window. UTC tz
|
||||||
// keeps the wall-clock arithmetic obvious in the tests. See wiki/entities/subscription.md.
|
// keeps the wall-clock arithmetic obvious in the tests. See wiki/entities/subscription.md.
|
||||||
@@ -86,3 +86,28 @@ describe("outOfWindowGap — unrestricted", () => {
|
|||||||
expect(outOfWindowGap(null, "UTC", monday("09:00"), "entry")).toBeNull();
|
expect(outOfWindowGap(null, "UTC", monday("09:00"), "entry")).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("minutesOutsideWindow — the charge basis (regression: no phantom span)", () => {
|
||||||
|
// entered 30 min before the 20:00 window; STILL inside, only a few minutes elapsed.
|
||||||
|
it("early entry, barely elapsed → only the elapsed pre-window minutes (NOT back to a prior close)", () => {
|
||||||
|
// 19:30 entry, now 19:33 → 3 minutes outside (the bug charged ~12h here).
|
||||||
|
expect(minutesOutsideWindow(night, "UTC", monday("19:30"), monday("19:33"))).toBe(3);
|
||||||
|
});
|
||||||
|
it("early entry until the window opens = the full pre-window gap, then 0 inside", () => {
|
||||||
|
// 19:30 → 22:00: 30 min outside (19:30→20:00), the rest in-window.
|
||||||
|
expect(minutesOutsideWindow(night, "UTC", monday("19:30"), monday("22:00"))).toBe(30);
|
||||||
|
});
|
||||||
|
it("in-window the whole stay → 0", () => {
|
||||||
|
expect(minutesOutsideWindow(night, "UTC", monday("22:00"), monday("23:30"))).toBe(90 - 90); // fully inside
|
||||||
|
});
|
||||||
|
it("late exit after close adds the post-close minutes", () => {
|
||||||
|
// enter 22:00 (in window), exit 08:30 → 30 min outside (08:00→08:30).
|
||||||
|
expect(minutesOutsideWindow(night, "UTC", monday("22:00"), `2026-06-23T08:30:00.000Z`)).toBe(30);
|
||||||
|
});
|
||||||
|
it("a day the window doesn't apply contributes 0 (weekend free)", () => {
|
||||||
|
expect(minutesOutsideWindow(night, "UTC", saturday("09:00"), saturday("18:00"))).toBe(0);
|
||||||
|
});
|
||||||
|
it("unrestricted plan → 0", () => {
|
||||||
|
expect(minutesOutsideWindow(null, "UTC", monday("09:00"), monday("23:00"))).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user