fix(subs): price out-of-window charge from minutes actually parked, not a fixed entry stamp
An out-of-window subscriber entry stamped a FIXED windowOwedMinor = the whole gap to window-open (e.g. 800 ALL for a 13:21 arrival to a 20:00 window) and deferred it to exit. That over-charged anyone who left before the window opened — a 1-hour visit was billed as 6.5 hours. The amount isn't knowable at entry: a subscriber may enter early, leave after an hour, come and go several times before the window opens, and linger past window-close. They should pay only for the minutes actually parked outside the window (capped at the window edges) — exactly what minutesOutsideWindow already computes. So the entry now stamps a MARKER only (outOfWindow: true + windowTariffVersionId for reproducible pricing), no fixed amount. The exit gate and booth quote price it live via windowOwedBetween(entry → settle-time), which already caps at the window edges (early entry stops accruing at window-open; the in-window portion of a crossing stay is free; the late-exit tail keeps accruing until payment). Both already called that one function, so they agree. - subscription-flow: entry stamps outOfWindow marker; the advisory slip is now a scannable out-of-window TICKET (Code128 + QR of the occurrence id). - shared LedgerPayload: add outOfWindow; mark windowOwedMinor/windowGap*/ windowCurrency deprecated read-only (historic signed events still type-check). - BoothScreen: window-charge badge keys on outOfWindow (or the old stamp). - ActiveSessions: drop the always-on "Open barrier" for subscribers — the assist-open / window-charge payment live in the pay modal, so the list can't one-click past an unpaid out-of-window charge. Verified the live model on a DB copy: 13:21→14:30 = 200 ALL; 19:55(in grace)→ 23:00 = 0; 19:00→21:30 (crosses into window) = 100 ALL. Existing signed occurrences left untouched (immutable). build+lint 14/14, shared 87/87. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
@@ -189,13 +189,16 @@ export class SubscriptionFlow {
|
|||||||
return { accepted: false, direction: "entry", reason };
|
return { accepted: false, direction: "entry", reason };
|
||||||
}
|
}
|
||||||
|
|
||||||
// TARIFF BRIDGE — early entry. If the plan has time windows and this scan is before
|
// TARIFF BRIDGE — out-of-window entry. If the plan has time windows and this scan is
|
||||||
// the window opens, the subscriber owes the transient tariff for arrival→window-open.
|
// OUTSIDE the allowed window, the subscriber will owe the transient tariff for the time
|
||||||
// We DEFER it (open now, collect at exit): stamp the owed amount on the SIGNED entry
|
// they actually park out-of-window. The AMOUNT is NOT knowable now — it depends on when
|
||||||
// payload (the source of truth — `windowOwedMinor`), so the exit gate reads it back
|
// they leave (a subscriber who enters early and leaves before the window opens owes only
|
||||||
// from the chain. Plans without timeframes return null → nothing owed. See
|
// their parked minutes, NOT the whole gap-to-window-open). So we stamp only a MARKER
|
||||||
// wiki/entities/subscription.md.
|
// (`outOfWindow`) + the tariff version, and price it live at settlement from
|
||||||
const entryCharge = windowCharge(this.#db, sub.planVersionId, now, "entry");
|
// minutesOutsideWindow(entry → pay-time), which caps at the window edges. Open now
|
||||||
|
// (never trap); the charge is gated at exit. Plans without timeframes → null → no
|
||||||
|
// marker. See wiki/entities/subscription.md ("tariff bridge").
|
||||||
|
const outOfWindow = windowCharge(this.#db, sub.planVersionId, now, "entry");
|
||||||
|
|
||||||
// A short, unique occurrence id. The subscription id is NOT embedded — it rides in
|
// A short, unique occurrence id. The subscription id is NOT embedded — it rides in
|
||||||
// the payload's `permitId` (which every fold matches on), so the key stays compact.
|
// the payload's `permitId` (which every fold matches on), so the key stays compact.
|
||||||
@@ -205,29 +208,27 @@ export class SubscriptionFlow {
|
|||||||
direction: "entry",
|
direction: "entry",
|
||||||
source,
|
source,
|
||||||
identity: occurrenceId,
|
identity: occurrenceId,
|
||||||
// No ticket, no fee — the subscription IS the authorization. Recorded for audit.
|
// The subscription IS the authorization (no fee for in-window use). `permitId`/`permit`
|
||||||
// `permitId`/`permit` are the on-chain field names (immutable). A deferred early-
|
// are the on-chain field names (immutable). An out-of-window entry is MARKED here
|
||||||
// entry charge is signed here (windowOwedMinor + the priced gap) so it's owed at exit.
|
// (`outOfWindow` + the tariff version for reproducible pricing) so the booth/exit gate
|
||||||
|
// know to charge the parked-out-of-window minutes — priced live, not a fixed amount.
|
||||||
payload: {
|
payload: {
|
||||||
sessionRef: occurrenceId,
|
sessionRef: occurrenceId,
|
||||||
permitId: m.subscriptionId,
|
permitId: m.subscriptionId,
|
||||||
permit: true,
|
permit: true,
|
||||||
via: m.via,
|
via: m.via,
|
||||||
...(entryCharge
|
...(outOfWindow
|
||||||
? {
|
? {
|
||||||
windowOwedMinor: entryCharge.amountMinor,
|
outOfWindow: true,
|
||||||
windowCurrency: entryCharge.currency,
|
windowTariffVersionId: outOfWindow.tariffVersionId,
|
||||||
windowTariffVersionId: entryCharge.tariffVersionId,
|
|
||||||
windowGapStart: entryCharge.gapStart,
|
|
||||||
windowGapEnd: entryCharge.gapEnd,
|
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
},
|
},
|
||||||
occurredAt: now,
|
occurredAt: now,
|
||||||
});
|
});
|
||||||
if (entryCharge) {
|
if (outOfWindow) {
|
||||||
this.#logger.info(
|
this.#logger.info(
|
||||||
`subscription early-entry charge ${entryCharge.amountMinor} ${entryCharge.currency} (${entryCharge.minutes}min) deferred on ${occurrenceId}`,
|
`subscription out-of-window entry marked on ${occurrenceId} (charge priced from parked minutes at exit)`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
await this.#open(resolved, "entry", occurrenceId, "subscription entry");
|
await this.#open(resolved, "entry", occurrenceId, "subscription entry");
|
||||||
@@ -246,11 +247,12 @@ export class SubscriptionFlow {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.#logger.error(`session-cache insert failed for ${occurrenceId}: ${(err as Error).message}`);
|
this.#logger.error(`session-cache insert failed for ${occurrenceId}: ${(err as Error).message}`);
|
||||||
}
|
}
|
||||||
// BEST-EFFORT: print an advisory "out-of-window" slip so the subscriber has paper
|
// BEST-EFFORT: print the out-of-window TICKET so the subscriber has the paper the
|
||||||
// proof a fee is pending (the final amount is computed at the booth on settlement,
|
// operator scans to settle at the booth. It carries the occurrence id as a scannable
|
||||||
// combining early-entry + any late-exit time). AFTER the open + cache, and fully
|
// code; the amount is computed at settlement from the minutes actually parked
|
||||||
|
// out-of-window (capped at the window edges). AFTER the open + cache, and fully
|
||||||
// swallowed — a missing/failed printer must NEVER block or delay the barrier.
|
// swallowed — a missing/failed printer must NEVER block or delay the barrier.
|
||||||
if (entryCharge) {
|
if (outOfWindow) {
|
||||||
const tf = (planVersionById(this.#db, sub.planVersionId)?.timeframes ?? null) as PlanTimeframes | null;
|
const tf = (planVersionById(this.#db, sub.planVersionId)?.timeframes ?? null) as PlanTimeframes | null;
|
||||||
void printWindowChargeNotice(
|
void printWindowChargeNotice(
|
||||||
this.#db,
|
this.#db,
|
||||||
|
|||||||
@@ -12,9 +12,13 @@ import { FilterBar, SegGroup, type SegOption } from "./ui/FilterBar.js";
|
|||||||
// within-grace (the barrier is UNCONFIRMED, so a paid/exited car is presumed
|
// within-grace (the barrier is UNCONFIRMED, so a paid/exited car is presumed
|
||||||
// possibly-present until grace runs out). Lets the operator find a stuck car —
|
// possibly-present until grace runs out). Lets the operator find a stuck car —
|
||||||
// damaged ticket, dead scanner, or a phantom barrier re-close — without a scan:
|
// damaged ticket, dead scanner, or a phantom barrier re-close — without a scan:
|
||||||
// - click a row → the pay/exit modal (pay an unpaid car, or review),
|
// - click a row → the pay/exit modal (pay an unpaid car, settle a subscriber's
|
||||||
// - "Open barrier" (PAID sessions only) → an audited human-intervention re-pulse.
|
// out-of-window charge, assist-open a prepaid subscriber, or review),
|
||||||
// No payment → no Open barrier button (the no-unpaid-bypass rule).
|
// - "Open barrier" (PAID transient sessions only) → an audited human-intervention
|
||||||
|
// re-pulse for a car that paid but whose barrier didn't confirm.
|
||||||
|
// No payment → no Open barrier button (the no-unpaid-bypass rule). Subscriptions get
|
||||||
|
// NO inline open here — their assist-open / window-charge payment is modal-only, so
|
||||||
|
// the list can't one-click past an unpaid out-of-window charge.
|
||||||
//
|
//
|
||||||
// OVERSTAY sessions (paid, grace expired, no signed exit) are no longer aged out — they
|
// OVERSTAY sessions (paid, grace expired, no signed exit) are no longer aged out — they
|
||||||
// stay listed with a distinct badge. A new period has begun (the car re-parked or is
|
// stay listed with a distinct badge. A new period has begun (the car re-parked or is
|
||||||
@@ -173,12 +177,14 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Open barrier — PAID-and-still-in-grace transient OR a SUBSCRIPTION
|
{/* Open barrier — PAID-and-still-in-grace TRANSIENT only: an audited
|
||||||
(prepaid). NOT an OVERSTAY session: its grace has expired, so the car
|
re-pulse for a car that paid but the barrier didn't confirm. NOT an
|
||||||
owes a top-up — the row routes to the pay/exit modal instead (no
|
OVERSTAY (grace expired → owes a top-up; routes to the pay/exit modal)
|
||||||
free overstay exit). An unpaid transient also has no button
|
and NOT a SUBSCRIPTION (the assist-open, and any out-of-window payment,
|
||||||
(no-unpaid-bypass). Mirrors reopenBarrier's server-side guard. */}
|
live in the pay/exit modal — the list must not offer a one-click open,
|
||||||
{(s.paidAt && !s.overstay) || s.subscription ? (
|
which would bypass an unpaid window charge). An unpaid transient has no
|
||||||
|
button either (no-unpaid-bypass). Mirrors reopenBarrier's server guard. */}
|
||||||
|
{s.paidAt && !s.overstay && !s.subscription ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={reopen.isPending || !shiftReady}
|
disabled={reopen.isPending || !shiftReady}
|
||||||
|
|||||||
@@ -112,9 +112,12 @@ function eventBadges(p: LedgerEvent["payload"]): string[] {
|
|||||||
if (p.permitRefused) keys.push("booth.badgeSubRefused");
|
if (p.permitRefused) keys.push("booth.badgeSubRefused");
|
||||||
if (p.ticketPrinted === false) keys.push("booth.badgeNoTicket");
|
if (p.ticketPrinted === false) keys.push("booth.badgeNoTicket");
|
||||||
if (p.subscriptionSale) keys.push("booth.badgeSubSale");
|
if (p.subscriptionSale) keys.push("booth.badgeSubSale");
|
||||||
// Subscriber entered/exited outside their plan's allowed window → owes a deferred
|
// Subscriber entered outside their plan's allowed window → will owe a transient charge
|
||||||
// transient charge, collected (gated) at exit. Flag it so the operator KNOWS now.
|
// for the minutes actually parked out-of-window, priced + collected (gated) at exit.
|
||||||
if (typeof p.windowOwedMinor === "number" && p.windowOwedMinor > 0) keys.push("booth.badgeWindowCharge");
|
// Flag it so the operator KNOWS now. (`windowOwedMinor` is the old fixed-amount stamp,
|
||||||
|
// kept so historic events still badge.)
|
||||||
|
if (p.outOfWindow === true || (typeof p.windowOwedMinor === "number" && p.windowOwedMinor > 0))
|
||||||
|
keys.push("booth.badgeWindowCharge");
|
||||||
if (p.source === "manual" && !p.subscriptionSale) keys.push("booth.badgeManualOpen");
|
if (p.source === "manual" && !p.subscriptionSale) keys.push("booth.badgeManualOpen");
|
||||||
return keys;
|
return keys;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -287,13 +287,20 @@ export interface LedgerPayload {
|
|||||||
/** cash_in / cash_out voucher: a human-facing voucher number printed on the slip
|
/** cash_in / cash_out voucher: a human-facing voucher number printed on the slip
|
||||||
* (Mandat Nr.). Sequential per type; signed for reproducibility. */
|
* (Mandat Nr.). Sequential per type; signed for reproducibility. */
|
||||||
readonly voucherNo?: string;
|
readonly voucherNo?: string;
|
||||||
/** subscription tariff-bridge: an early-entry / late-exit transient charge OWED for
|
/** subscription tariff-bridge: this occurrence opened OUTSIDE the plan's allowed window,
|
||||||
* parking outside the plan's allowed window, stamped on the vehicle_entry and collected
|
* so the minutes actually parked out-of-window are charged at the transient tariff and
|
||||||
* (gated) at exit. The priced gap + tariff version travel alongside for reproducibility.
|
* collected (gated) at exit. The AMOUNT is NOT fixed at entry — it depends on how long
|
||||||
* See wiki/entities/subscription.md ("tariff bridge"). */
|
* they actually park out-of-window (capped at the window edges), so it's priced live at
|
||||||
|
* settlement from minutesOutsideWindow(entry → pay-time). Only the marker + the tariff
|
||||||
|
* version (for reproducible pricing) are stamped. See wiki/entities/subscription.md
|
||||||
|
* ("tariff bridge"). */
|
||||||
|
readonly outOfWindow?: boolean;
|
||||||
|
readonly windowTariffVersionId?: string;
|
||||||
|
/** DEPRECATED stamp — a FIXED full-gap amount written by an earlier model. No longer
|
||||||
|
* produced (it over-charged a subscriber who left before the window opened); retained
|
||||||
|
* here only so historic signed events still type-check. Never read for pricing. */
|
||||||
readonly windowOwedMinor?: number;
|
readonly windowOwedMinor?: number;
|
||||||
readonly windowCurrency?: string;
|
readonly windowCurrency?: string;
|
||||||
readonly windowTariffVersionId?: string;
|
|
||||||
readonly windowGapStart?: string;
|
readonly windowGapStart?: string;
|
||||||
readonly windowGapEnd?: string;
|
readonly windowGapEnd?: string;
|
||||||
/** Free-form for forward-compat without a schema change. */
|
/** Free-form for forward-compat without a schema change. */
|
||||||
|
|||||||
@@ -98,23 +98,33 @@ out-of-window scans, the system **charges the out-of-window minutes at the norma
|
|||||||
the V2 [[tariff]]**, Hën–Die; empty = every day). On a day NOT in the set the subscriber parks free.
|
the V2 [[tariff]]**, Hën–Die; empty = every day). On a day NOT in the set the subscriber parks free.
|
||||||
`tz` is frozen in the plan version (like a V2 tariff's tz). null timeframes = 24/7, no charge ever.
|
`tz` is frozen in the plan version (like a V2 tariff's tz). null timeframes = 24/7, no charge ever.
|
||||||
*(A "night plan, free weekends" is just `days:[Mon..Fri], 20:00→08:00`.)*
|
*(A "night plan, free weekends" is just `days:[Mon..Fri], 20:00→08:00`.)*
|
||||||
- `outOfWindowGap(timeframes, tz, at, edge)` (pure, tz-aware, unit-tested in `@parking/shared`)
|
- **An out-of-window subscriber is a transient ONLY for the minutes actually parked outside the
|
||||||
returns the `[start, end]` portion outside the window. **Early entry**: gap = arrival → next
|
window — the amount is NOT knowable at entry.** A night-plan subscriber (window opens 20:00) who
|
||||||
window-open (a 09:00 arrival to a 20:00 window owes 09:00→20:00, capped by the tariff's daily cap).
|
arrives at 13:21 and leaves at 14:30 parked **~1 hour** out-of-window and owes **one hour's transient
|
||||||
**Late exit**: gap = window-close → departure. The gap is priced with `computeFee` (the same engine
|
fee** — NOT the whole 13:21→20:00 gap. They may come and go several times before the window opens;
|
||||||
transient stays use) at the active tariff version (`apps/server/src/subscription-window.ts`).
|
each parked interval is its own short transient charge. So nothing fixed can be billed at entry.
|
||||||
- **The owed amount is ONE computation over the whole stay** (`windowOwedBetween` →
|
- **The owed amount is ONE live computation over the whole stay** (`windowOwedBetween` →
|
||||||
`minutesOutsideWindow(timeframes, tz, entry, now)`): the minutes within `[entry, now]` that fall
|
`minutesOutsideWindow(timeframes, tz, entry, settle-time)`): the minutes within `[entry, settle]`
|
||||||
outside the allowed window — covering **early entry AND late exit together**, bounded by the stay,
|
that fall outside the allowed window, **capped at the window edges** — covering early entry AND late
|
||||||
off-days free. Priced once as a transient duration (so increments + the daily cap apply). This
|
exit together, off-days free. Priced once as a transient duration with `computeFee` (so increments +
|
||||||
replaced an earlier buggy "entry-gap + exit-gap" sum whose exit gap reached back to a *previous*
|
the daily cap apply) at the active tariff version (`apps/server/src/subscription-window.ts`). Both
|
||||||
day's close, charging a phantom ~12h to a car that had just entered early (the 4,100 ALL bug,
|
the exit gate and the booth quote call this one function against the **current time**, so they agree
|
||||||
fixed 2026-06-20). Both the exit gate and the booth quote call this one function, so they agree.
|
and the amount reflects exactly the out-of-window minutes parked. `settle-time` is the exit-scan at
|
||||||
- **Early entry is DEFERRED:** the barrier opens now; an advisory `windowOwedMinor` + priced gap are
|
the gate, and the pay-time at the booth; the **late-exit tail keeps accruing until payment** (it
|
||||||
signed onto the `vehicle_entry` for the feed badge, and a **best-effort advisory slip prints**
|
doesn't stop at the refused scan), so a subscriber who lingers past window-close pays for that time.
|
||||||
("PARKIM — JASHTË ORARIT": entered out-of-window, *fee computed at exit*, occurrence no.) so the
|
- **Capping is automatic:** once a subscriber crosses INTO the window (e.g. parked 19:00→21:30 with
|
||||||
subscriber has paper proof. A missing/failed printer NEVER blocks the barrier (`printWindowChargeNotice`,
|
a 20:00 open), only the 19:00→20:00 portion is charged; the in-window time is free. An early
|
||||||
fully swallowed, after the open).
|
arrival who is still parked when the window opens stops accruing at window-open.
|
||||||
|
- This corrected the earlier model that **stamped a FIXED `windowOwedMinor` = full gap-to-window-open
|
||||||
|
at entry** (e.g. 800 ALL for 13:21→20:00) and deferred it — which over-charged anyone who left
|
||||||
|
before the window opened. The fixed stamp is gone; see [[#tariff-bridge-history]].
|
||||||
|
- **Out-of-window entry opens the barrier and prints a window-bounded TICKET.** The `vehicle_entry`
|
||||||
|
carries only a **marker** (`outOfWindow: true` + `windowTariffVersionId` for reproducible pricing),
|
||||||
|
NO fixed amount. A **best-effort ticket slip prints** ("PARKIM — JASHTË ORARIT": entered
|
||||||
|
out-of-window, *fee computed at exit*, occurrence no.) carrying the occurrence id as a **scannable
|
||||||
|
Code128 + QR** — the operator scans it straight into the booth pay modal at settlement, the same
|
||||||
|
scan path as a transient ticket. A missing/failed printer NEVER blocks the barrier
|
||||||
|
(`printWindowChargeNotice`, fully swallowed, after the open).
|
||||||
- **Late exit is GATED:** at exit, `owed = windowOwedBetween(entry, now) − payments`. If `> 0`, the
|
- **Late exit is GATED:** at exit, `owed = windowOwedBetween(entry, now) − payments`. If `> 0`, the
|
||||||
exit is **REFUSED** with the signed reason `sub.refused.unpaidWindow`; the subscriber settles at
|
exit is **REFUSED** with the signed reason `sub.refused.unpaidWindow`; the subscriber settles at
|
||||||
the booth (a signed `payment` keyed to the occurrence — folds into the shift/drawer/Z-report like
|
the booth (a signed `payment` keyed to the occurrence — folds into the shift/drawer/Z-report like
|
||||||
@@ -126,6 +136,19 @@ out-of-window scans, the system **charges the out-of-window minutes at the norma
|
|||||||
> *choosing* to refuse an unpaid car. The standing **fail-open** rule governs the *can't-decide*
|
> *choosing* to refuse an unpaid car. The standing **fail-open** rule governs the *can't-decide*
|
||||||
> (power/host/network loss) path, which still opens. The two are not in conflict; don't conflate them.
|
> (power/host/network loss) path, which still opens. The two are not in conflict; don't conflate them.
|
||||||
|
|
||||||
|
##### tariff-bridge history
|
||||||
|
The out-of-window charge has had two superseded models, both over-charging:
|
||||||
|
1. **entry-gap + exit-gap sum** whose exit gap reached back to a *previous* day's close → a phantom
|
||||||
|
~12h on a car that had just entered early (the 4,100 ALL bug, fixed 2026-06-20 by switching to the
|
||||||
|
single `windowOwedBetween(entry, now)` computation).
|
||||||
|
2. **a FIXED `windowOwedMinor` stamped at entry** = the whole gap-to-window-open (e.g. 800 ALL for a
|
||||||
|
13:21 arrival to a 20:00 window), deferred and billed at exit → over-charged anyone who left before
|
||||||
|
the window opened (a 1-hour visit billed as 6.5 hours). Fixed 2026-06-21: the entry stamp is now a
|
||||||
|
**marker only** (`outOfWindow` + `windowTariffVersionId`); the amount is priced live from the
|
||||||
|
minutes **actually** parked out-of-window, capped at the window edges. `windowOwedMinor` and the
|
||||||
|
`windowGap*`/`windowCurrency` fields remain in the `LedgerPayload` type as **deprecated, read-only**
|
||||||
|
so historic signed events still type-check; they are never produced or read for pricing.
|
||||||
|
|
||||||
**Reserved subscriber spots** — see [[capacity-occupancy]] (an admin toggle that holds a spot per
|
**Reserved subscriber spots** — see [[capacity-occupancy]] (an admin toggle that holds a spot per
|
||||||
active subscriber's car in the [[occupancy]] full-gate). The subscriber flow itself is never gated by
|
active subscriber's car in the [[occupancy]] full-gate). The subscriber flow itself is never gated by
|
||||||
"full"; reservation only tightens the *transient* gate.
|
"full"; reservation only tightens the *transient* gate.
|
||||||
|
|||||||
+16
@@ -1229,3 +1229,19 @@ keypair generated: pubkey embedded in tauri.conf.json; private key + password ke
|
|||||||
.deb/.rpm/.AppImage + .sig updater signatures; turbo run build lint 14/14 green; no key material in
|
.deb/.rpm/.AppImage + .sig updater signatures; turbo run build lint 14/14 green; no key material in
|
||||||
the repo. Updated As-built in [[desktop-shell-tauri]]. Deferred: real update URL, OS installer
|
the repo. Updated As-built in [[desktop-shell-tauri]]. Deferred: real update URL, OS installer
|
||||||
signing, Windows kiosk-browser fallback.
|
signing, Windows kiosk-browser fallback.
|
||||||
|
|
||||||
|
## [2026-06-21] fix | Subscription out-of-window charge — marker-not-fixed-amount; scannable ticket; booth flow
|
||||||
|
Corrected the [[subscription]] tariff-bridge charging model after operator feedback. The entry path
|
||||||
|
stamped a FIXED `windowOwedMinor` = the whole gap-to-window-open (e.g. 800 ALL for a 13:21 arrival to
|
||||||
|
a 20:00 window) and deferred it — over-charging anyone who left before the window opened (a 1-hour
|
||||||
|
visit billed as 6.5h). Now the `vehicle_entry` carries only a MARKER (`outOfWindow` +
|
||||||
|
`windowTariffVersionId`); the amount is priced LIVE from `minutesOutsideWindow(entry → settle-time)`,
|
||||||
|
which caps at the window edges, so one hour parked = one hour's transient fee, in-window time free, and
|
||||||
|
the late-exit tail keeps accruing until payment. The advisory slip is now a scannable Code128 + QR
|
||||||
|
TICKET of the occurrence id (operator scans it into the booth pay modal). Also: removed the always-on
|
||||||
|
"Open barrier" from the active-sessions list AND modal for subscribers — a prepaid sub shows only a
|
||||||
|
small "assist open" reveal; an out-of-window sub is pay-first-then-open. Fixed the ESC/POS encoder so
|
||||||
|
typographic chars (— ⚠ … ' ") transliterate to ASCII instead of "?". `windowOwedMinor`/`windowGap*`
|
||||||
|
kept as deprecated read-only in `LedgerPayload` for historic events. Verified live model on a DB copy
|
||||||
|
(13:21→14:30 = 200 ALL; 19:55-grace→23:00 = 0; 19:00→21:30-cross = 100 ALL). build+lint 14/14, shared
|
||||||
|
87/87. Existing signed occurrences left untouched (immutable). See [[subscription]] tariff-bridge-history.
|
||||||
|
|||||||
Reference in New Issue
Block a user