diff --git a/apps/server/src/pay-station.ts b/apps/server/src/pay-station.ts index b76962d..bd9e02a 100644 --- a/apps/server/src/pay-station.ts +++ b/apps/server/src/pay-station.ts @@ -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. */ diff --git a/apps/server/src/subscription-flow.ts b/apps/server/src/subscription-flow.ts index 6fc2181..b877dec 100644 --- a/apps/server/src/subscription-flow.ts +++ b/apps/server/src/subscription-flow.ts @@ -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 diff --git a/apps/server/src/subscription-window.ts b/apps/server/src/subscription-window.ts index 09959b4..a5cff31 100644 --- a/apps/server/src/subscription-window.ts +++ b/apps/server/src/subscription-window.ts @@ -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 }; +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 84529da..0f0feca 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1142,6 +1142,47 @@ export function outOfWindowGap( 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). */ function hourToMin(hhmm: string): number { const m = /^(\d{2}):(\d{2})$/.exec(hhmm); diff --git a/packages/shared/src/subscription-window.test.ts b/packages/shared/src/subscription-window.test.ts index c065f03..2096a70 100644 --- a/packages/shared/src/subscription-window.test.ts +++ b/packages/shared/src/subscription-window.test.ts @@ -1,5 +1,5 @@ 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 // 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(); }); }); + +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); + }); +});