Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f2734641b2 | |||
| de858e91f4 | |||
| 294ca85ded | |||
| eafbc3ddbb | |||
| 36f30d39ff | |||
| 488dcb5e4e | |||
| c64457020f | |||
| ff04ec10be | |||
| e0e218fa61 | |||
| 21bd0f6227 | |||
| 53e1e7b25c | |||
| fd4608a8f1 | |||
| 052da8c3a7 | |||
| cb68cbafdb | |||
| 2835f78635 | |||
| a20400c2c5 | |||
| cdb55a8652 | |||
| b0c9ba0f8c | |||
| 9a1feeeb20 | |||
| cc507f490f | |||
| 3d02134711 | |||
| a4712774ab | |||
| 918f76fbef |
@@ -1,5 +1,6 @@
|
|||||||
import { eq, ledgerEvents, siteConfig, type Db } from "@parking/db";
|
import { eq, ledgerEvents, siteConfig, type Db } from "@parking/db";
|
||||||
import {
|
import {
|
||||||
|
formatStampSq,
|
||||||
printWithFailover,
|
printWithFailover,
|
||||||
registry,
|
registry,
|
||||||
type PrinterDevice,
|
type PrinterDevice,
|
||||||
@@ -154,3 +155,38 @@ export async function printSubscriptionCard(
|
|||||||
logger.info(`subscription card ${card.code} printed on ${printedBy}`);
|
logger.info(`subscription card ${card.code} printed on ${printedBy}`);
|
||||||
return printedBy;
|
return printedBy;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Print an ADVISORY "out-of-window" slip when a subscriber enters (or exits) outside
|
||||||
|
* their plan's allowed hours. It is NOT a payable ticket and carries NO final amount —
|
||||||
|
* the total is computed at the booth on settlement (early-entry AND any late-exit time
|
||||||
|
* combined). It just gives the subscriber paper proof that a fee is pending against this
|
||||||
|
* occurrence. Albanian (like every customer-facing slip — see i18n.md). Best-effort:
|
||||||
|
* the caller swallows failures so a missing printer never blocks the barrier.
|
||||||
|
*/
|
||||||
|
export async function printWindowChargeNotice(
|
||||||
|
db: Db,
|
||||||
|
notice: { occurrenceId: string; holderName?: string | null; at: string; windowOpensMin?: number; edge: "entry" | "exit" },
|
||||||
|
logger: FastifyBaseLogger,
|
||||||
|
): Promise<string> {
|
||||||
|
const printers = loadPrinters(db);
|
||||||
|
const hhmm = (m?: number) =>
|
||||||
|
m == null ? "" : `${String(Math.floor(m / 60)).padStart(2, "0")}:${String(m % 60).padStart(2, "0")}`;
|
||||||
|
const lines = [
|
||||||
|
`Abonent: ${notice.holderName || "-"}`,
|
||||||
|
`${notice.edge === "entry" ? "Hyrje" : "Dalje"}: ${formatStampSq(notice.at)}`,
|
||||||
|
notice.edge === "entry"
|
||||||
|
? `Ka hyrë jashtë orarit${notice.windowOpensMin != null ? ` (orari hap ${hhmm(notice.windowOpensMin)})` : ""}`
|
||||||
|
: "Ka dalë jashtë orarit",
|
||||||
|
"",
|
||||||
|
"⚠ Detyrim do të llogaritet në dalje",
|
||||||
|
" (paguhet në kabinë para se të dilni)",
|
||||||
|
"",
|
||||||
|
`Nr: ${notice.occurrenceId}`,
|
||||||
|
];
|
||||||
|
const printedBy = await printWithFailover(printers, "booth-receipt", (d: PrinterDevice) =>
|
||||||
|
d.printReport({ title: "PARKIM — JASHTË ORARIT", lines }),
|
||||||
|
);
|
||||||
|
logger.info(`out-of-window notice printed for ${notice.occurrenceId} on ${printedBy}`);
|
||||||
|
return printedBy;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { eq, subscriptions, type Db } from "@parking/db";
|
import { eq, subscriptions, type Db } from "@parking/db";
|
||||||
import type { LedgerEvent } from "@parking/shared";
|
import type { LedgerEvent } from "@parking/shared";
|
||||||
|
import { plateForIdentity, platesForIdentities } from "./plate-lookup.js";
|
||||||
|
|
||||||
// READ-TIME event enrichment. The signed ledger stays minimal and stable; some fields
|
// READ-TIME event enrichment. The signed ledger stays minimal and stable; some fields
|
||||||
// are nice to SHOW but must not be signed (they can change, or depend on other tables).
|
// are nice to SHOW but must not be signed (they can change, or depend on other tables).
|
||||||
@@ -45,12 +46,43 @@ export function clearHolderCache(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Attach read-time display fields to a raw ledger row before it goes to a client.
|
* Attach read-time display fields to a raw ledger row before it goes to a client:
|
||||||
* Currently: `subscriberLabel` for subscription occurrences. Idempotent and cheap;
|
* - `subscriberLabel` for a subscription occurrence (payload.permitId → holder name);
|
||||||
* non-subscription events pass through unchanged (no `subscriberLabel`).
|
* - `plate` for an entry/exit event whose session has an advisory ANPR read.
|
||||||
|
* Idempotent and cheap; events without either pass through unchanged. Used by the WS
|
||||||
|
* feed (per event). For the bulk feed page prefer `enrichEvents` (one plate scan).
|
||||||
*/
|
*/
|
||||||
export function enrichEvent<T extends LedgerEvent>(db: Db, event: T): T {
|
export function enrichEvent<T extends LedgerEvent>(db: Db, event: T): T {
|
||||||
|
let out: T = event;
|
||||||
const permitId = event.payload && typeof event.payload.permitId === "string" ? event.payload.permitId : null;
|
const permitId = event.payload && typeof event.payload.permitId === "string" ? event.payload.permitId : null;
|
||||||
if (!permitId) return event;
|
if (permitId) out = { ...out, subscriberLabel: holderName(db, permitId) ?? SUBSCRIBER_FALLBACK };
|
||||||
return { ...event, subscriberLabel: holderName(db, permitId) ?? SUBSCRIBER_FALLBACK };
|
if ((event.type === "vehicle_entry" || event.type === "vehicle_exit") && event.identity) {
|
||||||
|
const p = plateForIdentity(db, event.identity);
|
||||||
|
if (p) out = { ...out, plate: p.plate };
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bulk variant for the feed page: enriches a list of events with subscriber labels AND
|
||||||
|
* plates using a SINGLE device_events scan for all the plates (instead of one per row).
|
||||||
|
* Order preserved.
|
||||||
|
*/
|
||||||
|
export function enrichEvents<T extends LedgerEvent>(db: Db, events: T[]): T[] {
|
||||||
|
// Collect identities of entry/exit events to resolve their plates in one scan.
|
||||||
|
const wanted = new Set<string>();
|
||||||
|
for (const e of events) {
|
||||||
|
if ((e.type === "vehicle_entry" || e.type === "vehicle_exit") && e.identity) wanted.add(e.identity);
|
||||||
|
}
|
||||||
|
const plates = wanted.size ? platesForIdentities(db, wanted) : new Map();
|
||||||
|
return events.map((e) => {
|
||||||
|
let out: T = e;
|
||||||
|
const permitId = e.payload && typeof e.payload.permitId === "string" ? e.payload.permitId : null;
|
||||||
|
if (permitId) out = { ...out, subscriberLabel: holderName(db, permitId) ?? SUBSCRIBER_FALLBACK };
|
||||||
|
if ((e.type === "vehicle_entry" || e.type === "vehicle_exit") && e.identity) {
|
||||||
|
const p = plates.get(e.identity);
|
||||||
|
if (p) out = { ...out, plate: p.plate };
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -195,12 +195,22 @@ export class ExitFlow {
|
|||||||
|
|
||||||
const view = this.#sessionFor(id);
|
const view = this.#sessionFor(id);
|
||||||
if (!view) return { ok: false, reason: "no session for ticket" };
|
if (!view) return { ok: false, reason: "no session for ticket" };
|
||||||
// Authorization to re-open: a PAID transient (paid, or paid-then-exited within
|
// Authorization to re-open: a SUBSCRIPTION occurrence (prepaid — exactly the case
|
||||||
// grace) OR a SUBSCRIPTION occurrence (prepaid — exactly the case the operator must
|
// the operator must assist when the exit reader / card fails) OR a transient whose
|
||||||
// assist when the exit reader / card fails). An unpaid TRANSIENT takes the pay/exit
|
// payment is STILL WITHIN the walk-back grace window. A stale payment does NOT
|
||||||
// flow instead — enforced here, not just in the UI (the no-unpaid-bypass rule).
|
// authorize a free open: a car that paid once and then sat inside past grace owes a
|
||||||
if (view.paidAt == null && !view.subscription) {
|
// top-up for the extra time — letting it out on the old payment is the overstay-fraud
|
||||||
return { ok: false, reason: "session not paid — no barrier open without payment" };
|
// path. So we mirror the exit flow's grace check here (not just in the UI): an
|
||||||
|
// unpaid OR grace-expired transient takes the pay/exit (top-up) flow instead.
|
||||||
|
// The no-unpaid-bypass + no-free-overstay-exit rules, enforced server-side.
|
||||||
|
const paid = view.paidAt != null;
|
||||||
|
const withinGrace =
|
||||||
|
paid && view.graceExitMin != null && Date.now() - Date.parse(view.paidAt!) <= view.graceExitMin * 60_000;
|
||||||
|
if (!view.subscription && (!paid || !withinGrace)) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
reason: paid ? "walk-back grace expired — take a top-up payment first" : "session not paid — no barrier open without payment",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const key = `reopen:${id}`;
|
const key = `reopen:${id}`;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { eq, ledgerEvents, siteConfig, type Db } from "@parking/db";
|
import { eq, ledgerEvents, siteConfig, subscriptions, type Db } from "@parking/db";
|
||||||
|
|
||||||
// Occupancy = a FOLD over the signed ledger: the count of vehicle_entry events
|
// Occupancy = a FOLD over the signed ledger: the count of vehicle_entry events
|
||||||
// with no matching vehicle_exit. Never a hand-maintained counter (which is
|
// with no matching vehicle_exit. Never a hand-maintained counter (which is
|
||||||
@@ -7,11 +7,18 @@ import { eq, ledgerEvents, siteConfig, type Db } from "@parking/db";
|
|||||||
export interface Occupancy {
|
export interface Occupancy {
|
||||||
/** Cars currently inside (open sessions). */
|
/** Cars currently inside (open sessions). */
|
||||||
readonly count: number;
|
readonly count: number;
|
||||||
|
/** Spots HELD for active subscribers who are NOT currently parked (when the
|
||||||
|
* reserve-subscriber-spots toggle is on; 0 otherwise). Each active subscription holds
|
||||||
|
* `quantity` spots minus however many of its cars are already inside. */
|
||||||
|
readonly reserved: number;
|
||||||
/** Admin-set nominal capacity, or null = no limit. */
|
/** Admin-set nominal capacity, or null = no limit. */
|
||||||
readonly capacity: number | null;
|
readonly capacity: number | null;
|
||||||
/** capacity − count, or null when uncapped. Can read 0 (or below) when full. */
|
/** capacity − count, or null when uncapped. Can read 0 (or below) when full. */
|
||||||
readonly free: number | null;
|
readonly free: number | null;
|
||||||
/** True when count ≥ capacity (always false when uncapped). */
|
/** Effective free for a TRANSIENT car = capacity − count − reserved (null uncapped). */
|
||||||
|
readonly effectiveFree: number | null;
|
||||||
|
/** True when a TRANSIENT entry should be refused: count + reserved ≥ capacity
|
||||||
|
* (always false when uncapped). Subscribers are never gated by this. */
|
||||||
readonly full: boolean;
|
readonly full: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,13 +44,66 @@ export function siteCapacity(db: Db): number | null {
|
|||||||
return row?.capacity ?? null;
|
return row?.capacity ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spots to RESERVE for active subscribers who aren't currently parked. Off (0) unless
|
||||||
|
* `site_config.reserve_subscriber_spots` is set. For each ACTIVE subscription (status
|
||||||
|
* active AND now ∈ [validFrom, validTo]), hold `quantity` spots minus the cars of that
|
||||||
|
* subscription already inside (so we never double-count a parked subscriber). This is
|
||||||
|
* what makes a transient see "full" sooner while the subscriber's spot is held.
|
||||||
|
*/
|
||||||
|
export function reservedSubscriberSpots(db: Db): number {
|
||||||
|
const cfg = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||||
|
if (!cfg?.reserveSubscriberSpots) return 0;
|
||||||
|
|
||||||
|
// Cars currently inside per subscription (occurrence entries by permitId, net of exits).
|
||||||
|
const rows = db.select().from(ledgerEvents).orderBy(ledgerEvents.index).all();
|
||||||
|
const insidePerSub = new Map<string, number>();
|
||||||
|
const net = new Map<string, number>(); // occurrence identity → entries−exits
|
||||||
|
const subOf = new Map<string, string>(); // occurrence identity → subscription id
|
||||||
|
for (const r of rows) {
|
||||||
|
const id = r.identity;
|
||||||
|
if (!id) continue;
|
||||||
|
if (r.type === "vehicle_entry") {
|
||||||
|
const pl = (r.payload ?? {}) as { permitId?: string };
|
||||||
|
if (pl.permitId == null) continue; // transient
|
||||||
|
net.set(id, (net.get(id) ?? 0) + 1);
|
||||||
|
subOf.set(id, pl.permitId);
|
||||||
|
} else if (r.type === "vehicle_exit") {
|
||||||
|
if (net.has(id)) net.set(id, (net.get(id) ?? 0) - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [id, n] of net) if (n > 0) {
|
||||||
|
const sub = subOf.get(id)!;
|
||||||
|
insidePerSub.set(sub, (insidePerSub.get(sub) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const subs = db.select().from(subscriptions).all();
|
||||||
|
let reserved = 0;
|
||||||
|
for (const s of subs) {
|
||||||
|
const active =
|
||||||
|
s.status === "active" &&
|
||||||
|
(s.validFrom == null || now >= s.validFrom) &&
|
||||||
|
(s.validTo == null || now <= s.validTo);
|
||||||
|
if (!active) continue;
|
||||||
|
const qty = s.quantity ?? 1;
|
||||||
|
const inside = insidePerSub.get(s.id) ?? 0;
|
||||||
|
reserved += Math.max(0, qty - inside); // hold only the not-yet-parked portion
|
||||||
|
}
|
||||||
|
return reserved;
|
||||||
|
}
|
||||||
|
|
||||||
export function getOccupancy(db: Db): Occupancy {
|
export function getOccupancy(db: Db): Occupancy {
|
||||||
const count = occupancyCount(db);
|
const count = occupancyCount(db);
|
||||||
const capacity = siteCapacity(db);
|
const capacity = siteCapacity(db);
|
||||||
|
const reserved = reservedSubscriberSpots(db);
|
||||||
return {
|
return {
|
||||||
count,
|
count,
|
||||||
|
reserved,
|
||||||
capacity,
|
capacity,
|
||||||
free: capacity == null ? null : capacity - count,
|
free: capacity == null ? null : capacity - count,
|
||||||
full: capacity != null && count >= capacity,
|
effectiveFree: capacity == null ? null : capacity - count - reserved,
|
||||||
|
// A transient is refused once physical cars + held subscriber spots reach capacity.
|
||||||
|
full: capacity != null && count + reserved >= capacity,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+193
-23
@@ -1,7 +1,9 @@
|
|||||||
import { desc, eq, ledgerEvents, sessions, subscriptions, tariffVersions, tariffs, type Db } from "@parking/db";
|
import { desc, eq, ledgerEvents, sessions, subscriptions, tariffVersions, tariffs, type Db } from "@parking/db";
|
||||||
import { computeFee, type TariffStructure, type Tender } from "@parking/shared";
|
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 { 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:
|
||||||
@@ -28,8 +30,18 @@ export class NoTariffError extends Error {
|
|||||||
|
|
||||||
export interface Quote {
|
export interface Quote {
|
||||||
readonly identity: string;
|
readonly identity: string;
|
||||||
|
/** Vehicle entry time (the session's original entry; for display/audit). */
|
||||||
readonly enteredAt: string;
|
readonly enteredAt: string;
|
||||||
|
/** Start of the period being billed RIGHT NOW. For a first payment this is the
|
||||||
|
* entry. For an OVERSTAY (a paid session whose walk-back grace lapsed — the car
|
||||||
|
* re-parked / a new period began) it is the moment that grace expired: the overstay
|
||||||
|
* is priced as a fresh stay from there → now, with its own daily-cap ladder, NOT
|
||||||
|
* "full stay minus paid" (which a daily cap collapses toward zero). */
|
||||||
|
readonly periodStart: string;
|
||||||
|
/** Amount owed now: the fee for [periodStart → now]. */
|
||||||
readonly amountMinor: number;
|
readonly amountMinor: number;
|
||||||
|
/** True when this quote prices an overstay period (grace lapsed), not the first stay. */
|
||||||
|
readonly overstay: boolean;
|
||||||
readonly currency: string;
|
readonly currency: string;
|
||||||
readonly tariffVersionId: string;
|
readonly tariffVersionId: string;
|
||||||
readonly graceExitMin: number;
|
readonly graceExitMin: number;
|
||||||
@@ -53,6 +65,13 @@ export interface ActiveSession {
|
|||||||
readonly currency: string | null;
|
readonly currency: string | null;
|
||||||
readonly withinGrace: boolean;
|
readonly withinGrace: boolean;
|
||||||
readonly graceExpiresAt: string | null;
|
readonly graceExpiresAt: string | null;
|
||||||
|
/** OVERSTAY = a paid transient whose walk-back grace lapsed with NO signed vehicle_exit.
|
||||||
|
* The car either re-parked (a new period began) or is faulty/abandoned — not a system
|
||||||
|
* fault, and not "stuck". It lingers in occupancy and owes a fresh period (priced from
|
||||||
|
* grace-expiry, see `quote`). We keep it listed and BADGE it OVERSTAY so the operator
|
||||||
|
* reconciles via a top-up, instead of silently aging it out. No free barrier open.
|
||||||
|
* See wiki/concepts/booth-exit-flow.md. */
|
||||||
|
readonly overstay: boolean;
|
||||||
/** True for a SUBSCRIPTION occurrence (prepaid — never charged). The booth shows it
|
/** True for a SUBSCRIPTION occurrence (prepaid — never charged). The booth shows it
|
||||||
* with snapshots + an always-available "open barrier" (assist a faulty exit reader /
|
* with snapshots + an always-available "open barrier" (assist a faulty exit reader /
|
||||||
* missing card), and never a pay flow. See wiki/entities/subscription.md. */
|
* missing card), and never a pay flow. See wiki/entities/subscription.md. */
|
||||||
@@ -61,6 +80,9 @@ export interface ActiveSession {
|
|||||||
readonly subscriptionId: string | null;
|
readonly subscriptionId: string | null;
|
||||||
/** The subscriber's holder name (for a friendly label instead of the raw key). */
|
/** The subscriber's holder name (for a friendly label instead of the raw key). */
|
||||||
readonly subscriptionHolder: string | null;
|
readonly subscriptionHolder: string | null;
|
||||||
|
/** Advisory licence plate recognized for this session (ANPR-on-snapshot), shown for
|
||||||
|
* at-a-glance identification. Null when no plate was read. Never an access decision. */
|
||||||
|
readonly plate: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Booth session view: everything the pay/exit modal needs in one read. */
|
/** Booth session view: everything the pay/exit modal needs in one read. */
|
||||||
@@ -80,10 +102,16 @@ export interface SessionLookup {
|
|||||||
readonly withinGrace: boolean;
|
readonly withinGrace: boolean;
|
||||||
/** ISO time the walk-back grace expires (paidAt + graceExitMin), if paid. */
|
/** ISO time the walk-back grace expires (paidAt + graceExitMin), if paid. */
|
||||||
readonly graceExpiresAt: string | null;
|
readonly graceExpiresAt: string | null;
|
||||||
|
/** OVERSTAY = paid transient, walk-back grace expired, no signed exit. A new period
|
||||||
|
* began; `amountMinor` is the fresh fee from grace-expiry — it cannot exit for free. */
|
||||||
|
readonly overstay: boolean;
|
||||||
/** True for a SUBSCRIPTION occurrence (prepaid — never charged; barrier-open only). */
|
/** True for a SUBSCRIPTION occurrence (prepaid — never charged; barrier-open only). */
|
||||||
readonly subscription: boolean;
|
readonly subscription: boolean;
|
||||||
readonly subscriptionId: string | null;
|
readonly subscriptionId: string | null;
|
||||||
readonly subscriptionHolder: string | null;
|
readonly subscriptionHolder: string | null;
|
||||||
|
/** Advisory licence plate recognized for this session (ANPR-on-snapshot). Null when
|
||||||
|
* none. Display/audit only — never an access decision. */
|
||||||
|
readonly plate: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class PayStation {
|
export class PayStation {
|
||||||
@@ -97,11 +125,19 @@ export class PayStation {
|
|||||||
this.#logger = logger;
|
this.#logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Price an open session against the tariff in force at its entry. No side effect. */
|
/** Price an open session. Normally the period is entry→now. But for an OVERSTAY — a
|
||||||
|
* paid session whose walk-back grace has lapsed (the car re-parked, or a new period
|
||||||
|
* began) — the customer is billed for a FRESH period from grace-expiry→now, with its
|
||||||
|
* own daily-cap ladder. This is NOT "full stay minus paid": with a daily cap the
|
||||||
|
* whole-stay gross plateaus while prior payments keep pace, so the delta collapses to
|
||||||
|
* 0 and a multi-day overstay would exit free (ticket 1245791632490). A new period
|
||||||
|
* reflects the reality and re-accrues the fee. No side effect. */
|
||||||
quote(identity: string): Quote {
|
quote(identity: string): Quote {
|
||||||
const entry = this.#openEntry(identity);
|
const entry = this.#openEntry(identity);
|
||||||
if (!entry) throw new NoOpenSessionError(identity);
|
if (!entry) throw new NoOpenSessionError(identity);
|
||||||
|
|
||||||
|
// The tariff in force is keyed to ENTRY (the version frozen for this session), even
|
||||||
|
// for an overstay period — the customer keeps the rate card they entered under.
|
||||||
const tv = this.#tariffVersionFor(entry.occurredAt);
|
const tv = this.#tariffVersionFor(entry.occurredAt);
|
||||||
if (!tv) throw new NoTariffError();
|
if (!tv) throw new NoTariffError();
|
||||||
const structure = tv.structure as unknown as TariffStructure;
|
const structure = tv.structure as unknown as TariffStructure;
|
||||||
@@ -110,29 +146,85 @@ export class PayStation {
|
|||||||
// both read it from there, so a V2 category tariff yields the same amount at the
|
// both read it from there, so a V2 category tariff yields the same amount at the
|
||||||
// booth and at exit. Absent (legacy/V1) ⇒ undefined ⇒ category-agnostic pricing.
|
// booth and at exit. Absent (legacy/V1) ⇒ undefined ⇒ category-agnostic pricing.
|
||||||
const category = (entry.payload as { category?: string } | null)?.category;
|
const category = (entry.payload as { category?: string } | null)?.category;
|
||||||
const amountMinor = computeFee(entry.occurredAt, new Date().toISOString(), structure, category);
|
|
||||||
|
// Pure pricing shared with the Tariff Lab (priceSession). Only the latest payment
|
||||||
|
// matters for grace/overstay; pass it through. Overstay → fresh period from
|
||||||
|
// grace-expiry; within-grace → settled; unpaid → entry→now running total.
|
||||||
|
const last = this.#lastPayment(identity);
|
||||||
|
const p = priceSession(
|
||||||
|
entry.occurredAt,
|
||||||
|
new Date().toISOString(),
|
||||||
|
structure,
|
||||||
|
last ? [last] : [],
|
||||||
|
category,
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
identity,
|
identity,
|
||||||
enteredAt: entry.occurredAt,
|
enteredAt: entry.occurredAt,
|
||||||
amountMinor,
|
periodStart: p.periodStart,
|
||||||
|
amountMinor: p.amountMinor,
|
||||||
|
overstay: p.overstay,
|
||||||
currency: tv.currency,
|
currency: tv.currency,
|
||||||
tariffVersionId: tv.id,
|
tariffVersionId: tv.id,
|
||||||
graceExitMin: structure.gracePeriodExitMin,
|
graceExitMin: structure.gracePeriodExitMin,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The latest signed `payment` for this session (time + the grace window it granted),
|
||||||
|
* or null if never paid. Folds the append-only ledger. */
|
||||||
|
#lastPayment(identity: string): { paidAt: string; graceExitMin: number | null } | null {
|
||||||
|
const rows = this.#db
|
||||||
|
.select({ type: ledgerEvents.type, occurredAt: ledgerEvents.occurredAt, payload: ledgerEvents.payload })
|
||||||
|
.from(ledgerEvents)
|
||||||
|
.where(eq(ledgerEvents.identity, identity))
|
||||||
|
.orderBy(ledgerEvents.index)
|
||||||
|
.all();
|
||||||
|
let last: { paidAt: string; graceExitMin: number | null } | null = null;
|
||||||
|
for (const r of rows) {
|
||||||
|
if (r.type !== "payment") continue;
|
||||||
|
const g = (r.payload as { graceExitMin?: number } | null)?.graceExitMin;
|
||||||
|
last = { paidAt: r.occurredAt, graceExitMin: typeof g === "number" ? g : null };
|
||||||
|
}
|
||||||
|
return last;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Take payment for a session and append the signed `payment` event. Re-quotes at
|
* Take payment for a session and append the signed `payment` event. Re-quotes at
|
||||||
* the moment of payment (the customer pays for time parked SO FAR). For an
|
* the moment of payment (the customer pays for time parked SO FAR). For an OVERSTAY
|
||||||
* overstay top-up the same call re-prices entry→now and the exit flow's
|
* (grace lapsed) the quote prices a fresh period from grace-expiry→now (see `quote`),
|
||||||
* grace-window restarts from this payment. `overrideMinor` lets the operator set
|
* and this payment writes a new `graceExitMin` so the walk-back window restarts.
|
||||||
* an arbitrary amount (lost ticket / dispute) — recorded as the charged amount.
|
* `overrideMinor` lets the operator set an arbitrary amount (lost ticket / dispute) —
|
||||||
|
* recorded as the charged amount.
|
||||||
*/
|
*/
|
||||||
async pay(
|
async pay(
|
||||||
identity: string,
|
identity: string,
|
||||||
tender: Tender,
|
tender: Tender,
|
||||||
overrideMinor?: number,
|
overrideMinor?: number,
|
||||||
): Promise<{ amountMinor: number; currency: string }> {
|
): Promise<{ amountMinor: number; currency: string }> {
|
||||||
|
// A SUBSCRIPTION occurrence settles its out-of-window tariff-bridge charge here
|
||||||
|
// (not a transient quote — the subscription itself is prepaid). The payment is keyed
|
||||||
|
// to the occurrence so the exit gate (#windowOwed − payments) clears.
|
||||||
|
const subWindow = this.#payableSubscriptionWindow(identity);
|
||||||
|
if (subWindow) {
|
||||||
|
const amountMinor = overrideMinor ?? subWindow.dueMinor;
|
||||||
|
await this.#log.append({
|
||||||
|
type: "payment",
|
||||||
|
source: "manual",
|
||||||
|
identity,
|
||||||
|
payload: {
|
||||||
|
sessionRef: identity,
|
||||||
|
amountMinor,
|
||||||
|
currency: subWindow.currency ?? undefined,
|
||||||
|
tender,
|
||||||
|
...(subWindow.tariffVersionId ? { tariffVersionId: subWindow.tariffVersionId } : {}),
|
||||||
|
subscriptionWindowCharge: true,
|
||||||
|
...(overrideMinor != null ? { reason: "operator-set amount", quotedMinor: subWindow.dueMinor } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.#logger.info(`subscription window-charge payment ${amountMinor} ${subWindow.currency ?? ""} (${tender}) for ${identity}`);
|
||||||
|
return { amountMinor, currency: subWindow.currency ?? "" };
|
||||||
|
}
|
||||||
|
|
||||||
const q = this.quote(identity);
|
const q = this.quote(identity);
|
||||||
const amountMinor = overrideMinor ?? q.amountMinor;
|
const amountMinor = overrideMinor ?? q.amountMinor;
|
||||||
|
|
||||||
@@ -183,7 +275,7 @@ export class PayStation {
|
|||||||
return {
|
return {
|
||||||
identity: id, found: false, open: false, enteredAt: null, exitedAt: null,
|
identity: id, found: false, open: false, enteredAt: null, exitedAt: null,
|
||||||
paidAt: null, amountMinor: null, currency: null, withinGrace: false, graceExpiresAt: null,
|
paidAt: null, amountMinor: null, currency: null, withinGrace: false, graceExpiresAt: null,
|
||||||
subscription: false, subscriptionId: null, subscriptionHolder: null,
|
overstay: false, subscription: false, subscriptionId: null, subscriptionHolder: null, plate: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
// Subscription occurrence? The entry payload carries permit:true + permitId.
|
// Subscription occurrence? The entry payload carries permit:true + permitId.
|
||||||
@@ -206,8 +298,11 @@ export class PayStation {
|
|||||||
paidAt && graceExitMin != null ? new Date(Date.parse(paidAt) + graceExitMin * 60_000).toISOString() : null;
|
paidAt && graceExitMin != null ? new Date(Date.parse(paidAt) + graceExitMin * 60_000).toISOString() : null;
|
||||||
const withinGrace = graceExpiresAt != null && Date.now() <= Date.parse(graceExpiresAt);
|
const withinGrace = graceExpiresAt != null && Date.now() <= Date.parse(graceExpiresAt);
|
||||||
|
|
||||||
// Amount owed now (best-effort; null if no tariff resolves). Only meaningful while
|
// Amount owed now (best-effort; null if no tariff resolves). For a TRANSIENT session
|
||||||
// open AND transient — a subscription is prepaid, never quoted/charged.
|
// it's the running tariff. For a SUBSCRIPTION it's normally null (prepaid) — EXCEPT a
|
||||||
|
// time-window plan can owe an out-of-window TARIFF-BRIDGE charge (early-entry carried
|
||||||
|
// on the entry payload + a live late-exit charge), which the booth must take so the
|
||||||
|
// exit gate clears. See wiki/entities/subscription.md.
|
||||||
let amountMinor: number | null = null;
|
let amountMinor: number | null = null;
|
||||||
let currency: string | null = null;
|
let currency: string | null = null;
|
||||||
if (open && !isSubscription) {
|
if (open && !isSubscription) {
|
||||||
@@ -218,14 +313,23 @@ export class PayStation {
|
|||||||
} catch {
|
} catch {
|
||||||
/* no active tariff — leave null; modal shows session without a price */
|
/* no active tariff — leave null; modal shows session without a price */
|
||||||
}
|
}
|
||||||
|
} else if (open && isSubscription) {
|
||||||
|
const w = this.#subscriptionWindowDue(id, subscriptionId);
|
||||||
|
if (w && w.dueMinor > 0) {
|
||||||
|
amountMinor = w.dueMinor;
|
||||||
|
currency = w.currency;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const overstay = open && !isSubscription && paidAt != null && graceExpiresAt != null && !withinGrace;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
identity: id, found: true, open,
|
identity: id, found: true, open,
|
||||||
enteredAt: entry.occurredAt, exitedAt: exitRow?.occurredAt ?? null,
|
enteredAt: entry.occurredAt, exitedAt: exitRow?.occurredAt ?? null,
|
||||||
paidAt, amountMinor, currency, withinGrace, graceExpiresAt,
|
paidAt, amountMinor, currency, withinGrace, graceExpiresAt, overstay,
|
||||||
subscription: isSubscription, subscriptionId,
|
subscription: isSubscription, subscriptionId,
|
||||||
subscriptionHolder: this.#holderOf(subscriptionId),
|
subscriptionHolder: this.#holderOf(subscriptionId),
|
||||||
|
plate: plateForIdentity(this.#db, id)?.plate ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -275,6 +379,10 @@ export class PayStation {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resolve advisory plates for all candidate identities in ONE device_events scan
|
||||||
|
// (cheaper than one lookup per row).
|
||||||
|
const plates = platesForIdentities(this.#db, byId.keys());
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const out: ActiveSession[] = [];
|
const out: ActiveSession[] = [];
|
||||||
for (const [identity, a] of byId) {
|
for (const [identity, a] of byId) {
|
||||||
@@ -287,26 +395,29 @@ export class PayStation {
|
|||||||
const withinGrace = graceExpiresAt != null && now <= Date.parse(graceExpiresAt);
|
const withinGrace = graceExpiresAt != null && now <= Date.parse(graceExpiresAt);
|
||||||
const paid = a.paidAt != null;
|
const paid = a.paidAt != null;
|
||||||
|
|
||||||
|
const isSubscription = a.subscriptionId !== undefined;
|
||||||
|
|
||||||
// ACTIVE membership:
|
// ACTIVE membership:
|
||||||
// - exited + within grace → still shown (barrier unconfirmed, may be present);
|
// - exited + within grace → still shown (barrier unconfirmed, may be present);
|
||||||
// - exited + past grace → presumed gone, omitted;
|
// - exited + past grace → presumed gone, omitted;
|
||||||
// - open + UNPAID → always shown (a car owing money never ages out —
|
// - open + UNPAID → always shown (a car owing money never ages out —
|
||||||
// it's genuinely still inside until it pays, however long that takes);
|
// it's genuinely still inside until it pays, however long that takes);
|
||||||
// - open + PAID + past grace → AGE-OUT (omit). A paid car whose walk-back grace
|
// - open + PAID + past grace → OVERSTAY. A paid transient whose walk-back grace
|
||||||
// lapsed has left; if no vehicle_exit was ever signed (e.g. it left via a
|
// lapsed with no signed vehicle_exit: the car re-parked (a new period) or is
|
||||||
// manual barrier re-open before that path closed the session, or a historical
|
// faulty/abandoned — not a system fault, not "stuck". It lingers in occupancy
|
||||||
// session like T-397815c0) it would otherwise linger forever. The signed log
|
// and owes a fresh period (priced from grace-expiry, see `quote`). We used to
|
||||||
// is unchanged — this is purely a display filter. See booth-exit-flow.md.
|
// age these out (a silent display filter); now we KEEP them and flag `overstay`
|
||||||
|
// so the operator reconciles via a top-up. The signed log is untouched, and the
|
||||||
|
// barrier never opens for free on these. See booth-exit-flow.md.
|
||||||
if (!open && !withinGrace) continue;
|
if (!open && !withinGrace) continue;
|
||||||
if (open && paid && graceExpiresAt != null && !withinGrace) continue;
|
const overstay =
|
||||||
|
open && paid && !isSubscription && graceExpiresAt != null && !withinGrace;
|
||||||
|
|
||||||
const isSubscription = a.subscriptionId !== undefined;
|
// Amount owed now: an open + unpaid TRANSIENT (first stay) OR an OVERSTAY (the new
|
||||||
|
// period's top-up). A subscription is prepaid — never quote/charge it.
|
||||||
// Amount owed now: only meaningful for an open + unpaid TRANSIENT session. A
|
|
||||||
// subscription is prepaid — never quote/charge it.
|
|
||||||
let amountMinor: number | null = null;
|
let amountMinor: number | null = null;
|
||||||
let currency: string | null = null;
|
let currency: string | null = null;
|
||||||
if (open && a.paidAt == null && !isSubscription) {
|
if (open && !isSubscription && (a.paidAt == null || overstay)) {
|
||||||
try {
|
try {
|
||||||
const q = this.quote(identity);
|
const q = this.quote(identity);
|
||||||
amountMinor = q.amountMinor;
|
amountMinor = q.amountMinor;
|
||||||
@@ -327,9 +438,11 @@ export class PayStation {
|
|||||||
currency,
|
currency,
|
||||||
withinGrace,
|
withinGrace,
|
||||||
graceExpiresAt,
|
graceExpiresAt,
|
||||||
|
overstay,
|
||||||
subscription: isSubscription,
|
subscription: isSubscription,
|
||||||
subscriptionId: a.subscriptionId ?? null,
|
subscriptionId: a.subscriptionId ?? null,
|
||||||
subscriptionHolder: this.#holderOf(a.subscriptionId ?? null),
|
subscriptionHolder: this.#holderOf(a.subscriptionId ?? null),
|
||||||
|
plate: plates.get(identity)?.plate ?? null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -338,6 +451,63 @@ export class PayStation {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The out-of-window TARIFF-BRIDGE amount a subscriber owes on an OPEN occurrence right
|
||||||
|
* 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;
|
||||||
|
const sub = this.#db.select().from(subscriptions).where(eq(subscriptions.id, subscriptionId)).get();
|
||||||
|
if (!sub) return null;
|
||||||
|
|
||||||
|
const rows = this.#db.select().from(ledgerEvents).where(eq(ledgerEvents.identity, occurrenceId)).all();
|
||||||
|
const entryRow = rows.find((r) => r.type === "vehicle_entry");
|
||||||
|
if (!entryRow) return null;
|
||||||
|
|
||||||
|
const owed = windowOwedBetween(this.#db, sub.planVersionId, entryRow.occurredAt, new Date().toISOString());
|
||||||
|
if (!owed) return null;
|
||||||
|
|
||||||
|
let paid = 0;
|
||||||
|
for (const r of rows) {
|
||||||
|
if (r.type !== "payment") continue;
|
||||||
|
const pl = (r.payload ?? {}) as { amountMinor?: number };
|
||||||
|
if (typeof pl.amountMinor === "number") paid += pl.amountMinor;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { dueMinor: owed.amountMinor - paid, currency: owed.currency };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Is this identity an OPEN subscription occurrence that owes a window charge? Returns
|
||||||
|
* the due amount + currency + the tariff version that priced the late-exit charge (for
|
||||||
|
* the payment payload), or null when it's transient / nothing owed. */
|
||||||
|
#payableSubscriptionWindow(
|
||||||
|
identity: string,
|
||||||
|
): { dueMinor: number; currency: string | null; tariffVersionId: string | null } | null {
|
||||||
|
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 };
|
||||||
|
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;
|
||||||
|
// 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. */
|
||||||
|
#planVersionOf(subscriptionId: string | null): string | null {
|
||||||
|
if (!subscriptionId) return null;
|
||||||
|
const row = this.#db.select().from(subscriptions).where(eq(subscriptions.id, subscriptionId)).get();
|
||||||
|
return row?.planVersionId ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
/** The subscriber's holder name for a subscription id (for a friendly UI label),
|
/** The subscriber's holder name for a subscription id (for a friendly UI label),
|
||||||
* or null. Best-effort: a deleted subscription just yields null. */
|
* or null. Best-effort: a deleted subscription just yields null. */
|
||||||
#holderOf(subscriptionId: string | null): string | null {
|
#holderOf(subscriptionId: string | null): string | null {
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { and, desc, deviceEvents, eq, type Db } from "@parking/db";
|
||||||
|
|
||||||
|
// READ-TIME plate resolution. A recognized licence plate is ADVISORY evidence — it
|
||||||
|
// lives in the unsigned, prunable `device_events` (kind="read") stream written by the
|
||||||
|
// ANPR-on-snapshot path (snapshot.ts → recognizePlate), keyed to the session `identity`.
|
||||||
|
// It is deliberately NOT on the signed ledger (a fuzzy camera read must never become a
|
||||||
|
// signed fact). To SHOW it next to a feed event or an active session we resolve it here,
|
||||||
|
// at serialize time, the same way subscriber names are resolved (see event-enrich.ts).
|
||||||
|
//
|
||||||
|
// Preference: an ENTRY read over an exit read (the plate as it arrived identifies the
|
||||||
|
// session); within a direction, the newest read wins. Returns the plate text only —
|
||||||
|
// confidence/region detail stays on the snapshot review panel, not the at-a-glance feed.
|
||||||
|
|
||||||
|
/** The best advisory plate observed for a session, for display. */
|
||||||
|
export interface PlateView {
|
||||||
|
readonly plate: string;
|
||||||
|
readonly confidence: number | null;
|
||||||
|
readonly direction: "entry" | "exit" | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReadDetail {
|
||||||
|
identity?: string;
|
||||||
|
plate?: string;
|
||||||
|
confidence?: number;
|
||||||
|
direction?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Best plate for one identity, or null. Prefers an entry read, then the newest read. */
|
||||||
|
export function plateForIdentity(db: Db, identity: string): PlateView | null {
|
||||||
|
const rows = db
|
||||||
|
.select({ detail: deviceEvents.detail })
|
||||||
|
.from(deviceEvents)
|
||||||
|
.where(and(eq(deviceEvents.category, "camera"), eq(deviceEvents.kind, "read")))
|
||||||
|
.orderBy(desc(deviceEvents.occurredAt))
|
||||||
|
.all();
|
||||||
|
return pickBest(rows.map((r) => (r.detail ?? {}) as ReadDetail), identity);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve plates for MANY identities in one device_events scan (used by the active-
|
||||||
|
* sessions list and the feed page, which each carry tens–hundreds of rows). */
|
||||||
|
export function platesForIdentities(db: Db, identities: Iterable<string>): Map<string, PlateView> {
|
||||||
|
const want = new Set(identities);
|
||||||
|
const out = new Map<string, PlateView>();
|
||||||
|
if (want.size === 0) return out;
|
||||||
|
// Newest first so the first acceptable read per (identity,direction) is the freshest.
|
||||||
|
const rows = db
|
||||||
|
.select({ detail: deviceEvents.detail })
|
||||||
|
.from(deviceEvents)
|
||||||
|
.where(and(eq(deviceEvents.category, "camera"), eq(deviceEvents.kind, "read")))
|
||||||
|
.orderBy(desc(deviceEvents.occurredAt))
|
||||||
|
.all();
|
||||||
|
const byId = new Map<string, ReadDetail[]>();
|
||||||
|
for (const r of rows) {
|
||||||
|
const d = (r.detail ?? {}) as ReadDetail;
|
||||||
|
if (!d.identity || !d.plate || !want.has(d.identity)) continue;
|
||||||
|
let list = byId.get(d.identity);
|
||||||
|
if (!list) byId.set(d.identity, (list = []));
|
||||||
|
list.push(d);
|
||||||
|
}
|
||||||
|
for (const [id, reads] of byId) {
|
||||||
|
const best = pickBest(reads, id);
|
||||||
|
if (best) out.set(id, best);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pick the best read for `identity` from a NEWEST-FIRST list: an entry read beats an
|
||||||
|
* exit read; otherwise the first (newest) acceptable read wins. */
|
||||||
|
function pickBest(reads: ReadDetail[], identity: string): PlateView | null {
|
||||||
|
let fallback: ReadDetail | null = null;
|
||||||
|
for (const d of reads) {
|
||||||
|
if (d.identity !== identity || !d.plate) continue;
|
||||||
|
if (d.direction === "entry") return toView(d);
|
||||||
|
if (!fallback) fallback = d;
|
||||||
|
}
|
||||||
|
return fallback ? toView(fallback) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toView(d: ReadDetail): PlateView {
|
||||||
|
return {
|
||||||
|
plate: d.plate!.trim().toUpperCase(),
|
||||||
|
confidence: typeof d.confidence === "number" ? d.confidence : null,
|
||||||
|
direction: d.direction === "entry" || d.direction === "exit" ? d.direction : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import type { FastifyInstance } from "fastify";
|
|||||||
import { desc, gte, ledgerEvents, type Db } from "@parking/db";
|
import { desc, gte, ledgerEvents, type Db } from "@parking/db";
|
||||||
import type { LedgerEvent } from "@parking/shared";
|
import type { LedgerEvent } from "@parking/shared";
|
||||||
import { requirePermission } from "../auth.js";
|
import { requirePermission } from "../auth.js";
|
||||||
import { enrichEvent } from "../event-enrich.js";
|
import { enrichEvents } from "../event-enrich.js";
|
||||||
import type { EventLog } from "../event-log.js";
|
import type { EventLog } from "../event-log.js";
|
||||||
|
|
||||||
// Read access to the append-only signed event log. NO write/update/delete routes
|
// Read access to the append-only signed event log. NO write/update/delete routes
|
||||||
@@ -35,9 +35,10 @@ export async function eventRoutes(
|
|||||||
.orderBy(desc(ledgerEvents.index))
|
.orderBy(desc(ledgerEvents.index))
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
.all();
|
.all();
|
||||||
// Attach read-time display fields (e.g. subscriber name) without touching the
|
// Attach read-time display fields (subscriber name, advisory plate) without
|
||||||
// signed record. The cast bridges the Drizzle row to the shared LedgerEvent.
|
// touching the signed record. One plate scan for the whole page (enrichEvents).
|
||||||
const events = rows.map((r) => enrichEvent(db, r as unknown as LedgerEvent));
|
// The cast bridges the Drizzle row to the shared LedgerEvent.
|
||||||
|
const events = enrichEvents(db, rows as unknown as LedgerEvent[]);
|
||||||
return { events };
|
return { events };
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import bcrypt from "bcrypt";
|
||||||
|
import { eq, users, type Db } from "@parking/db";
|
||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
import { requirePermission, roleHasPermissions } from "../auth.js";
|
import { requirePermission, roleHasPermissions } from "../auth.js";
|
||||||
import {
|
import {
|
||||||
@@ -7,11 +9,18 @@ import {
|
|||||||
type ShiftService,
|
type ShiftService,
|
||||||
} from "../shift-service.js";
|
} from "../shift-service.js";
|
||||||
|
|
||||||
interface CashMovementBody {
|
interface CashVoucherBody {
|
||||||
/** Signed minor units: positive = load INTO drawer, negative = remove FROM drawer. */
|
/** Direction is the document TYPE, not a sign: cash_in = Mandat Arkëtimi (pay-IN),
|
||||||
|
* cash_out = Mandat Pagese (pay-OUT). */
|
||||||
|
type: "cash_in" | "cash_out";
|
||||||
|
/** POSITIVE minor units (magnitude). The direction comes from `type`. */
|
||||||
amountMinor: number;
|
amountMinor: number;
|
||||||
reason?: string;
|
reason?: string;
|
||||||
currency?: string;
|
currency?: string;
|
||||||
|
/** The admin who authorizes this voucher (operator-raised / admin-authorized). */
|
||||||
|
authorizedBy: string;
|
||||||
|
/** That admin's password — re-entered to sign off on the drawer movement. */
|
||||||
|
authorizerPassword: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ShiftsQuery {
|
interface ShiftsQuery {
|
||||||
@@ -26,7 +35,7 @@ interface ShiftsQuery {
|
|||||||
// opened/closed explicitly (not time-based — see wiki/concepts/shift.md and
|
// opened/closed explicitly (not time-based — see wiki/concepts/shift.md and
|
||||||
// local-jwt-auth.md "until logout"). End Shift signs a shift_z_report + prints it.
|
// local-jwt-auth.md "until logout"). End Shift signs a shift_z_report + prints it.
|
||||||
|
|
||||||
export async function shiftRoutes(app: FastifyInstance, shift: ShiftService): Promise<void> {
|
export async function shiftRoutes(app: FastifyInstance, shift: ShiftService, db: Db): Promise<void> {
|
||||||
// Reading the shift state vs. opening/closing one's own shift.
|
// Reading the shift state vs. opening/closing one's own shift.
|
||||||
const readGuard = requirePermission("shift:read");
|
const readGuard = requirePermission("shift:read");
|
||||||
const guard = requirePermission("shift:create");
|
const guard = requirePermission("shift:create");
|
||||||
@@ -51,6 +60,16 @@ export async function shiftRoutes(app: FastifyInstance, shift: ShiftService): Pr
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Mid-shift X-report: a READ-ONLY "so far" snapshot of the OPEN shift's takings +
|
||||||
|
// drawer (opening float, cash/card taken, pay-ins/outs, expected drawer), computed
|
||||||
|
// as of now. Appends nothing — it's not an accountability mark, just a projection
|
||||||
|
// (the Z-report at close is the signed record). 204 when no shift is open.
|
||||||
|
app.get("/api/shift/report", { preHandler: readGuard }, async (_req, reply) => {
|
||||||
|
const report = shift.currentReport();
|
||||||
|
if (!report) return reply.code(204).send();
|
||||||
|
return report;
|
||||||
|
});
|
||||||
|
|
||||||
// Completed shift history. SCOPED by permission:
|
// Completed shift history. SCOPED by permission:
|
||||||
// - `shift:read` (operators) → own shifts only; operator/from/to params ignored.
|
// - `shift:read` (operators) → own shifts only; operator/from/to params ignored.
|
||||||
// - `shift:cash` (admin-grade) → all operators, optionally filtered by
|
// - `shift:cash` (admin-grade) → all operators, optionally filtered by
|
||||||
@@ -68,16 +87,43 @@ export async function shiftRoutes(app: FastifyInstance, shift: ShiftService): Pr
|
|||||||
return { shifts, scope: canSeeAll ? "all" : "self" };
|
return { shifts, scope: canSeeAll ? "all" : "self" };
|
||||||
});
|
});
|
||||||
|
|
||||||
// Admin loads/removes physical drawer cash (the float). Signed cash_movement
|
// Drawer cash VOUCHER — Mandat Arkëtimi (cash_in / pay-IN) or Mandat Pagese
|
||||||
// event. ADMIN ONLY — an operator takes payments but cannot move the float.
|
// (cash_out / pay-OUT). The direction is the document TYPE, not a signed amount.
|
||||||
// amountMinor is signed: + load IN, − remove OUT. See wiki/concepts/shift.md.
|
// OPERATOR-RAISED, ADMIN-AUTHORIZED: any holder of `shift:create` (operator-grade)
|
||||||
app.post<{ Body: CashMovementBody }>(
|
// may RAISE the voucher, but it only commits if `authorizedBy` is a real admin
|
||||||
"/api/cash-movement",
|
// (`shift:cash`) who re-enters their password. This keeps the float control —
|
||||||
{ preHandler: requirePermission("shift:cash") },
|
// an operator cannot move the float alone — while letting them raise the slip.
|
||||||
|
// See wiki/concepts/shift.md.
|
||||||
|
app.post<{ Body: CashVoucherBody }>(
|
||||||
|
"/api/cash-voucher",
|
||||||
|
{ preHandler: guard },
|
||||||
async (req, reply) => {
|
async (req, reply) => {
|
||||||
const { amountMinor, reason, currency } = req.body ?? ({} as CashMovementBody);
|
const b = req.body ?? ({} as CashVoucherBody);
|
||||||
|
if (b.type !== "cash_in" && b.type !== "cash_out") {
|
||||||
|
return reply.code(400).send({ error: "type must be cash_in or cash_out" });
|
||||||
|
}
|
||||||
|
const authName = (b.authorizedBy ?? "").trim();
|
||||||
|
if (!authName || !b.authorizerPassword) {
|
||||||
|
return reply.code(400).send({ error: "authorizedBy and authorizerPassword are required" });
|
||||||
|
}
|
||||||
|
// Verify the authorizer: a real user, admin-grade (shift:cash), correct password.
|
||||||
|
const authUser = await db.select().from(users).where(eq(users.username, authName)).get();
|
||||||
|
// Always run a bcrypt compare (constant-time wrt whether the user exists).
|
||||||
|
const hash = authUser?.passwordHash ?? "$2b$10$invalidinvalidinvalidinvalidinvalidinvalidinv";
|
||||||
|
const passwordOk = await bcrypt.compare(b.authorizerPassword, hash);
|
||||||
|
const isAdminGrade = authUser != null && roleHasPermissions(authUser.roleId, ["shift:cash"]);
|
||||||
|
if (!authUser || !passwordOk || !isAdminGrade) {
|
||||||
|
return reply.code(403).send({ error: "authorizer must be an admin with a correct password" });
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
return await shift.recordCashMovement(req.user.username, amountMinor, reason ?? "", currency);
|
return await shift.recordVoucher({
|
||||||
|
type: b.type,
|
||||||
|
operator: req.user.username, // who RAISED it
|
||||||
|
authorizedBy: authUser.username, // who signed off (canonical case)
|
||||||
|
amountMinor: b.amountMinor,
|
||||||
|
reason: b.reason ?? "",
|
||||||
|
currency: b.currency,
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof InvalidCashMovementError) return reply.code(400).send({ error: err.message });
|
if (err instanceof InvalidCashMovementError) return reply.code(400).send({ error: err.message });
|
||||||
return reply.code(500).send({ error: (err as Error).message });
|
return reply.code(500).send({ error: (err as Error).message });
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ interface SiteConfigBody extends Partial<Record<TextField, string | null>> {
|
|||||||
exitVoucherDefault?: boolean;
|
exitVoucherDefault?: boolean;
|
||||||
/** Site default monthly subscription price in minor units (pre-fills the form). */
|
/** Site default monthly subscription price in minor units (pre-fills the form). */
|
||||||
subscriptionMonthlyPriceMinor?: number | null;
|
subscriptionMonthlyPriceMinor?: number | null;
|
||||||
|
/** Reserve a spot in occupancy for each active subscriber's car(s), even when not
|
||||||
|
* parked — so transients see "full" sooner and the subscriber's spot is held. */
|
||||||
|
reserveSubscriberSpots?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Shape returned by GET/PUT: capacity + the booth flag + the subscription default
|
/** Shape returned by GET/PUT: capacity + the booth flag + the subscription default
|
||||||
@@ -37,6 +40,7 @@ type SiteConfig = {
|
|||||||
capacity: number | null;
|
capacity: number | null;
|
||||||
exitVoucherDefault: boolean;
|
exitVoucherDefault: boolean;
|
||||||
subscriptionMonthlyPriceMinor: number | null;
|
subscriptionMonthlyPriceMinor: number | null;
|
||||||
|
reserveSubscriberSpots: boolean;
|
||||||
} & Record<TextField, string | null>;
|
} & Record<TextField, string | null>;
|
||||||
|
|
||||||
function toSiteConfig(row: typeof siteConfig.$inferSelect | undefined): SiteConfig {
|
function toSiteConfig(row: typeof siteConfig.$inferSelect | undefined): SiteConfig {
|
||||||
@@ -44,6 +48,7 @@ function toSiteConfig(row: typeof siteConfig.$inferSelect | undefined): SiteConf
|
|||||||
capacity: row?.capacity ?? null,
|
capacity: row?.capacity ?? null,
|
||||||
exitVoucherDefault: row?.exitVoucherDefault ?? false,
|
exitVoucherDefault: row?.exitVoucherDefault ?? false,
|
||||||
subscriptionMonthlyPriceMinor: row?.subscriptionMonthlyPriceMinor ?? null,
|
subscriptionMonthlyPriceMinor: row?.subscriptionMonthlyPriceMinor ?? null,
|
||||||
|
reserveSubscriberSpots: row?.reserveSubscriberSpots ?? false,
|
||||||
} as SiteConfig;
|
} as SiteConfig;
|
||||||
for (const f of TEXT_FIELDS) out[f] = row?.[f] ?? null;
|
for (const f of TEXT_FIELDS) out[f] = row?.[f] ?? null;
|
||||||
return out;
|
return out;
|
||||||
@@ -95,6 +100,12 @@ export async function siteRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
|||||||
}
|
}
|
||||||
patch.subscriptionMonthlyPriceMinor = p ?? null;
|
patch.subscriptionMonthlyPriceMinor = p ?? null;
|
||||||
}
|
}
|
||||||
|
if ("reserveSubscriberSpots" in body) {
|
||||||
|
if (typeof body.reserveSubscriberSpots !== "boolean") {
|
||||||
|
return reply.code(400).send({ error: "reserveSubscriberSpots must be a boolean" });
|
||||||
|
}
|
||||||
|
patch.reserveSubscriberSpots = body.reserveSubscriberSpots;
|
||||||
|
}
|
||||||
for (const f of TEXT_FIELDS) {
|
for (const f of TEXT_FIELDS) {
|
||||||
if (f in body) patch[f] = normText(body[f]);
|
if (f in body) patch[f] = normText(body[f]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import { desc, eq, subscriptionPlans, subscriptions, type Db } from "@parking/db";
|
||||||
|
import { SUBSCRIPTION_PERIODS, type PlanTimeframes, type SubscriptionPeriod } from "@parking/shared";
|
||||||
|
import { requirePermission } from "../auth.js";
|
||||||
|
import { siteTz } from "../subscription-window.js";
|
||||||
|
|
||||||
|
// Subscription PLAN catalog — admin-composed, versioned config the operator SELLS
|
||||||
|
// from (so they never type a price). Mirrors the tariff composer: plans are
|
||||||
|
// EFFECTIVE-DATED IMMUTABLE VERSIONS keyed by a stable `planId`; editing a plan
|
||||||
|
// PUBLISHES A NEW VERSION (new row, new effectiveFrom), never mutates an old one, so
|
||||||
|
// a past sale reprices identically against its recorded planVersionId. Retire =
|
||||||
|
// active=0 (soft, keeps history). Admin-only (`subscription:plan`); selling stays
|
||||||
|
// operator-grade (`subscription:create`). See wiki/entities/subscription.md.
|
||||||
|
|
||||||
|
interface PlanBody {
|
||||||
|
/** Stable identity across versions (e.g. "hotel-daily"). New on create; reused to
|
||||||
|
* publish a new version of an existing plan. Slugified server-side. */
|
||||||
|
planId?: string;
|
||||||
|
name?: string;
|
||||||
|
period?: SubscriptionPeriod;
|
||||||
|
pricePerPeriodMinor?: number;
|
||||||
|
currency?: string;
|
||||||
|
/** When this version takes effect (ISO-8601). Defaults to now. */
|
||||||
|
effectiveFrom?: string;
|
||||||
|
/** Allowed-time windows (tariff bridge); null/omitted = 24/7. */
|
||||||
|
timeframes?: PlanTimeframes | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Validate the optional timeframes blob (minutes-of-day 0–1439, days 0–6, sane grace). */
|
||||||
|
function validTimeframes(tf: PlanTimeframes | null | undefined): string | null {
|
||||||
|
if (tf == null) return null;
|
||||||
|
const okMin = (v: unknown) => Number.isInteger(v) && (v as number) >= 0 && (v as number) <= 1439;
|
||||||
|
if (!okMin(tf.fromMin) || !okMin(tf.toMin)) return "window times must be minutes-of-day (0–1439)";
|
||||||
|
if (tf.days != null && (!Array.isArray(tf.days) || tf.days.some((d) => !Number.isInteger(d) || d < 0 || d > 6))) {
|
||||||
|
return "days must be integers 0–6 (0=Sun..6=Sat)";
|
||||||
|
}
|
||||||
|
if (tf.graceMin != null && (!Number.isInteger(tf.graceMin) || tf.graceMin < 0)) return "graceMin must be ≥ 0";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lowercase, hyphenate, strip junk — a stable slug for the plan identity. */
|
||||||
|
function slugify(s: string): string {
|
||||||
|
return s
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/^-+|-+$/g, "")
|
||||||
|
.slice(0, 48);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function subscriptionPlanRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||||
|
const readGuard = requirePermission("subscription:read");
|
||||||
|
const planGuard = requirePermission("subscription:plan");
|
||||||
|
|
||||||
|
function validate(b: PlanBody): string[] {
|
||||||
|
const errs: string[] = [];
|
||||||
|
if (!b.name?.trim()) errs.push("name is required");
|
||||||
|
if (!b.period || !SUBSCRIPTION_PERIODS.includes(b.period)) {
|
||||||
|
errs.push(`period must be one of: ${SUBSCRIPTION_PERIODS.join(", ")}`);
|
||||||
|
}
|
||||||
|
if (!Number.isInteger(b.pricePerPeriodMinor) || (b.pricePerPeriodMinor ?? 0) <= 0) {
|
||||||
|
errs.push("pricePerPeriodMinor must be a positive integer (minor units)");
|
||||||
|
}
|
||||||
|
if (!b.currency?.trim()) errs.push("currency is required");
|
||||||
|
if (b.effectiveFrom != null && Number.isNaN(Date.parse(b.effectiveFrom))) {
|
||||||
|
errs.push("effectiveFrom must be a valid ISO-8601 timestamp");
|
||||||
|
}
|
||||||
|
const tfErr = validTimeframes(b.timeframes);
|
||||||
|
if (tfErr) errs.push(tfErr);
|
||||||
|
return errs;
|
||||||
|
}
|
||||||
|
|
||||||
|
// List plans. ?all=1 → every version (history); default → the CURRENT sellable plan
|
||||||
|
// per planId (latest active version with effectiveFrom ≤ now). Operators selling
|
||||||
|
// need the current list; the admin catalog screen asks for ?all=1.
|
||||||
|
app.get<{ Querystring: { all?: string } }>("/api/subscription-plans", { preHandler: readGuard }, async (req) => {
|
||||||
|
const rows = db.select().from(subscriptionPlans).orderBy(desc(subscriptionPlans.effectiveFrom)).all();
|
||||||
|
if (req.query?.all) return { plans: rows };
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
// Newest-effective active version wins per planId.
|
||||||
|
const current = new Map<string, (typeof rows)[number]>();
|
||||||
|
for (const r of rows) {
|
||||||
|
if (!r.active || r.effectiveFrom > now) continue;
|
||||||
|
if (!current.has(r.planId)) current.set(r.planId, r); // rows are newest-first
|
||||||
|
}
|
||||||
|
return { plans: [...current.values()] };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Publish a plan version (create a plan, or a new version of an existing planId).
|
||||||
|
app.post<{ Body: PlanBody }>("/api/subscription-plans", { preHandler: planGuard }, async (req, reply) => {
|
||||||
|
const b = req.body ?? ({} as PlanBody);
|
||||||
|
const problems = validate(b);
|
||||||
|
if (problems.length) return reply.code(400).send({ error: "invalid plan", problems });
|
||||||
|
|
||||||
|
const planId = (b.planId?.trim() ? slugify(b.planId) : slugify(b.name!)) || randomUUID();
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const effectiveFrom = b.effectiveFrom?.trim() || now;
|
||||||
|
// Backdating would retroactively reprice — refuse (mirrors tariff publish).
|
||||||
|
if (Date.parse(effectiveFrom) < Date.parse(now) - 60_000) {
|
||||||
|
return reply.code(400).send({
|
||||||
|
error: "effectiveFrom cannot be in the past — backdating a plan would retroactively reprice sales",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Stamp the site tz into the timeframes so the windows evaluate in the site's
|
||||||
|
// wall-clock, FROZEN in this version (mirrors how tariff V2 freezes its tz).
|
||||||
|
const timeframes =
|
||||||
|
b.timeframes != null ? { ...b.timeframes, tz: b.timeframes.tz || siteTz(db) } : null;
|
||||||
|
|
||||||
|
const row = {
|
||||||
|
id: randomUUID(),
|
||||||
|
planId,
|
||||||
|
name: b.name!.trim(),
|
||||||
|
period: b.period!,
|
||||||
|
pricePerPeriodMinor: b.pricePerPeriodMinor!,
|
||||||
|
currency: b.currency!.trim(),
|
||||||
|
effectiveFrom,
|
||||||
|
timeframes,
|
||||||
|
active: true,
|
||||||
|
createdBy: req.user?.username ?? null,
|
||||||
|
};
|
||||||
|
db.insert(subscriptionPlans).values(row as typeof subscriptionPlans.$inferInsert).run();
|
||||||
|
return reply.code(201).send(row);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Retire a plan (soft): mark every version of this planId inactive so it's no longer
|
||||||
|
// sellable. History (and past sales' planVersionId) is preserved. Reactivate to revive.
|
||||||
|
app.post<{ Params: { planId: string } }>(
|
||||||
|
"/api/subscription-plans/:planId/retire",
|
||||||
|
{ preHandler: planGuard },
|
||||||
|
async (req) => {
|
||||||
|
db.update(subscriptionPlans)
|
||||||
|
.set({ active: false })
|
||||||
|
.where(eq(subscriptionPlans.planId, req.params.planId))
|
||||||
|
.run();
|
||||||
|
return { planId: req.params.planId, retired: true };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// REACTIVATE a retired plan: mark its versions active again so it's sellable. The
|
||||||
|
// latest-effective version becomes "in force" again. (The inverse of retire.)
|
||||||
|
app.post<{ Params: { planId: string } }>(
|
||||||
|
"/api/subscription-plans/:planId/reactivate",
|
||||||
|
{ preHandler: planGuard },
|
||||||
|
async (req) => {
|
||||||
|
db.update(subscriptionPlans)
|
||||||
|
.set({ active: true })
|
||||||
|
.where(eq(subscriptionPlans.planId, req.params.planId))
|
||||||
|
.run();
|
||||||
|
return { planId: req.params.planId, reactivated: true };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// DELETE a plan entirely — allowed ONLY when NO subscription references it (any
|
||||||
|
// version). A referenced plan version MUST survive: a subscription's planVersionId is
|
||||||
|
// needed to reprice/audit that sale, so deleting it would dangle. 409 with the count
|
||||||
|
// when in use (the admin should retire instead). Removes all versions of the planId.
|
||||||
|
app.delete<{ Params: { planId: string } }>(
|
||||||
|
"/api/subscription-plans/:planId",
|
||||||
|
{ preHandler: planGuard },
|
||||||
|
async (req, reply) => {
|
||||||
|
const refs = db.select().from(subscriptions).where(eq(subscriptions.planId, req.params.planId)).all();
|
||||||
|
if (refs.length > 0) {
|
||||||
|
return reply.code(409).send({
|
||||||
|
error: "plan is in use and cannot be deleted",
|
||||||
|
code: "plan_in_use",
|
||||||
|
subscribers: refs.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
db.delete(subscriptionPlans).where(eq(subscriptionPlans.planId, req.params.planId)).run();
|
||||||
|
return { planId: req.params.planId, deleted: true };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,11 +2,15 @@ import { randomBytes, randomUUID } from "node:crypto";
|
|||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
import { eq, devices, subscriptionCredentials, subscriptionPlates, subscriptions, type Db } from "@parking/db";
|
import { eq, devices, subscriptionCredentials, subscriptionPlates, subscriptions, type Db } from "@parking/db";
|
||||||
import { NoPrinterAvailableError } from "@parking/devices";
|
import { NoPrinterAvailableError } from "@parking/devices";
|
||||||
|
import type { SubscriptionPlan, SubscriptionQuote, Tender } from "@parking/shared";
|
||||||
import { requirePermission } from "../auth.js";
|
import { requirePermission } from "../auth.js";
|
||||||
import { invalidateHolder } from "../event-enrich.js";
|
import { invalidateHolder } from "../event-enrich.js";
|
||||||
import { printSubscriptionCard } from "../booth-print.js";
|
import { printSubscriptionCard } from "../booth-print.js";
|
||||||
import type { CredentialCapture } from "../credential-capture.js";
|
import type { CredentialCapture } from "../credential-capture.js";
|
||||||
|
import type { EventLog } from "../event-log.js";
|
||||||
|
import type { ShiftService } from "../shift-service.js";
|
||||||
import { directionOf } from "../device-resolve.js";
|
import { directionOf } from "../device-resolve.js";
|
||||||
|
import { priceSubscriptionSpan, resolvePlanVersion } from "../subscription-pricing.js";
|
||||||
|
|
||||||
// Subscription admin CRUD. A subscription is mutable master data — admins
|
// Subscription admin CRUD. A subscription is mutable master data — admins
|
||||||
// grant/edit/revoke — but every USE of it is a signed ledger event, so the audit
|
// grant/edit/revoke — but every USE of it is a signed ledger event, so the audit
|
||||||
@@ -14,9 +18,15 @@ import { directionOf } from "../device-resolve.js";
|
|||||||
// aggregate: the row + its credentials (card/QR) + its bound plates. The API treats
|
// aggregate: the row + its credentials (card/QR) + its bound plates. The API treats
|
||||||
// them as one unit (create/update replace the child sets; delete removes all).
|
// them as one unit (create/update replace the child sets; delete removes all).
|
||||||
//
|
//
|
||||||
// Pricing: priceMinor + period ("monthly") + currency record the recurring plan
|
// Pricing & THE SALE. priceMinor + period ("monthly") + currency record the recurring
|
||||||
// (e.g. 10,000 ALL / month). Collecting the fee into the ledger/shift is deferred —
|
// plan (e.g. 10,000 ALL / month). When a subscription is SOLD (created with a price),
|
||||||
// here we just store the agreed price and the coverage window.
|
// the operator collects real money — so we append a SIGNED `payment` ledger event for
|
||||||
|
// the amount actually taken (priceMinor × months for a multi-month prepay), with the
|
||||||
|
// tender the operator chose. That is the ONLY accountability mechanism: without it the
|
||||||
|
// sale leaves no trace in the live feed, the drawer, or the shift Z-report, and the
|
||||||
|
// operator could pocket the cash untraceably (the exact booth-operator-as-adversary
|
||||||
|
// gap this system exists to close). The `subscriptions` row is mutable master data and
|
||||||
|
// is NOT the financial record; the signed payment event is. See wiki/concepts/shift.md.
|
||||||
|
|
||||||
interface Credential {
|
interface Credential {
|
||||||
kind: "rf" | "qr";
|
kind: "rf" | "qr";
|
||||||
@@ -27,22 +37,34 @@ interface Credential {
|
|||||||
interface SubscriptionBody {
|
interface SubscriptionBody {
|
||||||
holderName?: string;
|
holderName?: string;
|
||||||
contact?: string;
|
contact?: string;
|
||||||
/** Recurring price in minor units (e.g. 1000000 = 10,000.00). null = no price set. */
|
/** PRICED SALE: the plan the operator selected. The price is LOOKED UP from the
|
||||||
priceMinor?: number | null;
|
* plan version (periods × per-period price) — the operator never types an amount.
|
||||||
period?: "monthly";
|
* Omit for a free/comp subscription (no plan, no charge). */
|
||||||
/** ISO-4217 currency of priceMinor (e.g. "ALL"). */
|
planId?: string | null;
|
||||||
currency?: string | null;
|
/** Coverage window. For a priced sale: `validFrom` defaults to now, `validTo` is
|
||||||
/** Car-count binding: cars inside at once. Default 1; null = unbound. */
|
* REQUIRED (the span priced against the plan). For a comp sub, both optional. */
|
||||||
maxConcurrent?: number | null;
|
|
||||||
validFrom?: string | null;
|
validFrom?: string | null;
|
||||||
validTo?: string | null;
|
validTo?: string | null;
|
||||||
/** Months paid for. When set (with validFrom), validTo = validFrom + months — the
|
/** How many cars this subscription covers (a family pays once for N cars). Sale =
|
||||||
* multi-month case (e.g. 3 months). Takes precedence over an explicit validTo. */
|
* plan span price × quantity; maxConcurrent defaults to it. ≥ 1, default 1. */
|
||||||
months?: number | null;
|
quantity?: number | null;
|
||||||
|
/** Car-count binding: cars inside at once. Default = quantity; null = unbound. */
|
||||||
|
maxConcurrent?: number | null;
|
||||||
status?: "active" | "suspended" | "revoked";
|
status?: "active" | "suspended" | "revoked";
|
||||||
credentials?: Credential[];
|
credentials?: Credential[];
|
||||||
/** Plate binding (optional): bound plates that also serve as identity. */
|
/** Plate binding (optional): bound plates that also serve as identity. */
|
||||||
plates?: string[];
|
plates?: string[];
|
||||||
|
/** How the sale fee was tendered (cash → drawer, card → bank). Used at CREATE when a
|
||||||
|
* plan is sold; ignored on update (master-data edit, no money moves). Default "cash". */
|
||||||
|
tender?: Tender;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Body for POST /api/subscriptions/quote — price a span against a plan, no write. */
|
||||||
|
interface QuoteBody {
|
||||||
|
planId?: string;
|
||||||
|
validFrom?: string;
|
||||||
|
validTo?: string;
|
||||||
|
quantity?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Mint an unguessable QR credential value. Namespaced + crypto-random; the reader
|
/** Mint an unguessable QR credential value. Namespaced + crypto-random; the reader
|
||||||
@@ -56,21 +78,12 @@ function newQrCode(): string {
|
|||||||
return `SUB-${out}`;
|
return `SUB-${out}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Add whole months to an ISO datetime, clamping day overflow (e.g. Jan 31 +1mo →
|
|
||||||
* Feb 28/29). Returns ISO. */
|
|
||||||
function addMonths(iso: string, months: number): string {
|
|
||||||
const d = new Date(iso);
|
|
||||||
const day = d.getUTCDate();
|
|
||||||
d.setUTCMonth(d.getUTCMonth() + months);
|
|
||||||
// If the month rolled past (e.g. day 31 → next month had fewer days), clamp back.
|
|
||||||
if (d.getUTCDate() < day) d.setUTCDate(0);
|
|
||||||
return d.toISOString();
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function subscriptionRoutes(
|
export async function subscriptionRoutes(
|
||||||
app: FastifyInstance,
|
app: FastifyInstance,
|
||||||
db: Db,
|
db: Db,
|
||||||
capture: CredentialCapture,
|
capture: CredentialCapture,
|
||||||
|
eventLog: EventLog,
|
||||||
|
shift: ShiftService,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// Reading/looking up subscriptions vs. managing them. Revoke folds into update.
|
// Reading/looking up subscriptions vs. managing them. Revoke folds into update.
|
||||||
const readGuard = requirePermission("subscription:read");
|
const readGuard = requirePermission("subscription:read");
|
||||||
@@ -86,28 +99,35 @@ export async function subscriptionRoutes(
|
|||||||
errs.push("maxConcurrent must be a positive integer, or null for unbound");
|
errs.push("maxConcurrent must be a positive integer, or null for unbound");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (b.priceMinor != null) {
|
// PRICED SALE: a plan is selected → the span must be valid and price > 0. The
|
||||||
if (!Number.isInteger(b.priceMinor) || b.priceMinor < 0) {
|
// amount is derived from the plan (operator never types it), so there's no
|
||||||
errs.push("priceMinor must be a non-negative integer (minor units), or null");
|
// priceMinor to validate.
|
||||||
}
|
if (b.planId != null && b.planId.trim()) {
|
||||||
if (!b.currency?.trim()) {
|
const from = b.validFrom?.trim() || new Date().toISOString();
|
||||||
errs.push("currency is required when a price is set");
|
const to = b.validTo?.trim();
|
||||||
|
if (!to) {
|
||||||
|
errs.push("validTo (end date) is required when selling a plan");
|
||||||
|
} else if (Number.isNaN(Date.parse(to)) || Number.isNaN(Date.parse(from))) {
|
||||||
|
errs.push("validFrom/validTo must be valid ISO-8601 dates");
|
||||||
|
} else if (Date.parse(to) <= Date.parse(from)) {
|
||||||
|
errs.push("validTo must be after validFrom");
|
||||||
|
} else {
|
||||||
|
// Resolve the plan version at the SALE instant (now) — the customer buys today's
|
||||||
|
// published plan/price. (validFrom is the coverage start, which may be midnight
|
||||||
|
// today and predate a plan published this afternoon.)
|
||||||
|
const plan = resolvePlanVersion(db, b.planId.trim(), new Date().toISOString());
|
||||||
|
if (!plan) errs.push("no active plan found for the selected planId");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (b.period != null && b.period !== "monthly") {
|
if (b.quantity != null && (!Number.isInteger(b.quantity) || b.quantity < 1)) {
|
||||||
errs.push("period must be 'monthly' (the only period supported today)");
|
errs.push("quantity must be a positive integer (cars covered)");
|
||||||
}
|
|
||||||
if (b.months != null) {
|
|
||||||
if (!Number.isInteger(b.months) || b.months < 1) {
|
|
||||||
errs.push("months must be a positive integer");
|
|
||||||
}
|
|
||||||
if (!b.validFrom?.trim()) {
|
|
||||||
errs.push("validFrom is required when months is set (validTo = validFrom + months)");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (b.status && !["active", "suspended", "revoked"].includes(b.status)) {
|
if (b.status && !["active", "suspended", "revoked"].includes(b.status)) {
|
||||||
errs.push("status must be active|suspended|revoked");
|
errs.push("status must be active|suspended|revoked");
|
||||||
}
|
}
|
||||||
|
if (b.tender != null && b.tender !== "cash" && b.tender !== "card") {
|
||||||
|
errs.push("tender must be cash|card");
|
||||||
|
}
|
||||||
for (const c of b.credentials ?? []) {
|
for (const c of b.credentials ?? []) {
|
||||||
if (c.kind !== "rf" && c.kind !== "qr") {
|
if (c.kind !== "rf" && c.kind !== "qr") {
|
||||||
errs.push("each credential needs kind (rf|qr)");
|
errs.push("each credential needs kind (rf|qr)");
|
||||||
@@ -171,13 +191,34 @@ export async function subscriptionRoutes(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Resolve the coverage end: months (validFrom + months) wins over an explicit validTo. */
|
/** Resolve the coverage end: an explicit validTo (the span end the operator picked).
|
||||||
|
* Falls back to the existing value on an update that doesn't touch it. */
|
||||||
function resolveValidTo(b: SubscriptionBody, fallback: string | null): string | null {
|
function resolveValidTo(b: SubscriptionBody, fallback: string | null): string | null {
|
||||||
if (b.months != null && b.validFrom?.trim()) return addMonths(b.validFrom.trim(), b.months);
|
|
||||||
if (b.validTo !== undefined) return b.validTo ?? null;
|
if (b.validTo !== undefined) return b.validTo ?? null;
|
||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Resolve + price a priced sale: returns the plan version, the effective span, the
|
||||||
|
* quantity (cars covered), and the server-computed quote with the amount already
|
||||||
|
* MULTIPLIED by quantity (a family paying once for N cars). Returns null for a comp
|
||||||
|
* sub (no planId). validate() guards the happy path. */
|
||||||
|
function priceSale(
|
||||||
|
b: SubscriptionBody,
|
||||||
|
): { plan: SubscriptionPlan; validFrom: string; validTo: string; quantity: number; quote: SubscriptionQuote } | null {
|
||||||
|
if (!b.planId?.trim() || !b.validTo?.trim()) return null;
|
||||||
|
const validFrom = b.validFrom?.trim() || new Date().toISOString();
|
||||||
|
const validTo = b.validTo.trim();
|
||||||
|
// Plan version is resolved at the SALE instant (now), not validFrom (which is the
|
||||||
|
// coverage start and may predate a plan published later today).
|
||||||
|
const plan = resolvePlanVersion(db, b.planId.trim(), new Date().toISOString());
|
||||||
|
if (!plan) return null;
|
||||||
|
const quantity = b.quantity != null && b.quantity > 0 ? Math.round(b.quantity) : 1;
|
||||||
|
const base = priceSubscriptionSpan(plan, validFrom, validTo);
|
||||||
|
// Price ×N: the whole sale covers N cars on one subscription.
|
||||||
|
const quote: SubscriptionQuote = { ...base, amountMinor: base.amountMinor * quantity };
|
||||||
|
return { plan, validFrom, validTo, quantity, quote };
|
||||||
|
}
|
||||||
|
|
||||||
// List all subscriptions (with their credentials + plates).
|
// List all subscriptions (with their credentials + plates).
|
||||||
app.get("/api/subscriptions", { preHandler: readGuard }, async () => {
|
app.get("/api/subscriptions", { preHandler: readGuard }, async () => {
|
||||||
const rows = db.select().from(subscriptions).all();
|
const rows = db.select().from(subscriptions).all();
|
||||||
@@ -226,34 +267,135 @@ export async function subscriptionRoutes(
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Create a subscription.
|
// Create a subscription.
|
||||||
|
// Price a span against a plan WITHOUT writing anything — the live quote the sell form
|
||||||
|
// shows ("3 nights · 2,400 ALL"). Server-computed so the operator can't fudge it.
|
||||||
|
app.post<{ Body: QuoteBody }>("/api/subscriptions/quote", { preHandler: readGuard }, async (req, reply) => {
|
||||||
|
const b = req.body ?? {};
|
||||||
|
if (!b.planId?.trim()) return reply.code(400).send({ error: "planId is required" });
|
||||||
|
const validFrom = b.validFrom?.trim() || new Date().toISOString();
|
||||||
|
const validTo = b.validTo?.trim();
|
||||||
|
if (!validTo) return reply.code(400).send({ error: "validTo is required" });
|
||||||
|
if (Number.isNaN(Date.parse(validFrom)) || Number.isNaN(Date.parse(validTo))) {
|
||||||
|
return reply.code(400).send({ error: "validFrom/validTo must be valid ISO-8601 dates" });
|
||||||
|
}
|
||||||
|
if (Date.parse(validTo) <= Date.parse(validFrom)) {
|
||||||
|
return reply.code(400).send({ error: "validTo must be after validFrom" });
|
||||||
|
}
|
||||||
|
// Resolve at the sale instant (now), not validFrom — see priceSale.
|
||||||
|
const plan = resolvePlanVersion(db, b.planId.trim(), new Date().toISOString());
|
||||||
|
if (!plan) return reply.code(404).send({ error: "no active plan for that planId" });
|
||||||
|
const quantity = b.quantity != null && b.quantity > 0 ? Math.round(b.quantity) : 1;
|
||||||
|
const base = priceSubscriptionSpan(plan, validFrom, validTo);
|
||||||
|
// Echo the ×quantity total so the form previews the family's combined price.
|
||||||
|
return { ...base, amountMinor: base.amountMinor * quantity, quantity, plan };
|
||||||
|
});
|
||||||
|
|
||||||
app.post<{ Body: SubscriptionBody }>("/api/subscriptions", { preHandler: createGuard }, async (req, reply) => {
|
app.post<{ Body: SubscriptionBody }>("/api/subscriptions", { preHandler: createGuard }, async (req, reply) => {
|
||||||
const b = req.body ?? {};
|
const b = req.body ?? {};
|
||||||
const problems = validate(b);
|
const problems = validate(b);
|
||||||
if (problems.length) return reply.code(400).send({ error: "invalid subscription", problems });
|
if (problems.length) return reply.code(400).send({ error: "invalid subscription", problems });
|
||||||
const id = randomUUID();
|
const id = randomUUID();
|
||||||
|
// Price is LOOKED UP from the chosen plan (periods × per-period price) — never typed
|
||||||
|
// by the operator. A comp sub (no plan) carries no price. Persist the plan + version
|
||||||
|
// so the sale reprices identically later.
|
||||||
|
const priced = priceSale(b);
|
||||||
db.insert(subscriptions)
|
db.insert(subscriptions)
|
||||||
.values({
|
.values({
|
||||||
id,
|
id,
|
||||||
holderName: b.holderName ?? null,
|
holderName: b.holderName ?? null,
|
||||||
contact: b.contact ?? null,
|
contact: b.contact ?? null,
|
||||||
priceMinor: b.priceMinor ?? null,
|
priceMinor: priced ? priced.quote.amountMinor : null,
|
||||||
period: b.period ?? "monthly",
|
period: priced ? priced.plan.period : "month",
|
||||||
currency: b.priceMinor != null ? (b.currency ?? null) : null,
|
currency: priced ? priced.quote.currency : null,
|
||||||
maxConcurrent: b.maxConcurrent === undefined ? 1 : b.maxConcurrent,
|
planId: priced ? priced.plan.planId : null,
|
||||||
validFrom: b.validFrom ?? null,
|
planVersionId: priced ? priced.plan.id : null,
|
||||||
validTo: resolveValidTo(b, null),
|
quantity: priced ? priced.quantity : (b.quantity != null && b.quantity > 0 ? Math.round(b.quantity) : 1),
|
||||||
|
// maxConcurrent defaults to the quantity (the family's N cars can all be inside),
|
||||||
|
// unless the operator set it explicitly (null = unbound).
|
||||||
|
maxConcurrent:
|
||||||
|
b.maxConcurrent !== undefined
|
||||||
|
? b.maxConcurrent
|
||||||
|
: priced
|
||||||
|
? priced.quantity
|
||||||
|
: (b.quantity != null && b.quantity > 0 ? Math.round(b.quantity) : 1),
|
||||||
|
validFrom: priced ? priced.validFrom : (b.validFrom ?? null),
|
||||||
|
validTo: priced ? priced.validTo : resolveValidTo(b, null),
|
||||||
status: b.status ?? "active",
|
status: b.status ?? "active",
|
||||||
})
|
})
|
||||||
.run();
|
.run();
|
||||||
writeChildren(id, b);
|
writeChildren(id, b);
|
||||||
const sub = loadAggregate(id);
|
const sub = loadAggregate(id);
|
||||||
|
// THE SALE: a priced subscription means the operator collected money. Append a
|
||||||
|
// SIGNED `payment` event so the takings show up in the live feed, the drawer, and
|
||||||
|
// the shift Z-report — never an untraceable cash grab. Best-effort wrt the response,
|
||||||
|
// but the append is the whole point, so a failure is logged loudly.
|
||||||
|
const sale = await recordSale(id, priced, b.tender, req.user?.username ?? "?");
|
||||||
// Auto-print the QR card so the operator can hand it to the customer. Best-effort:
|
// Auto-print the QR card so the operator can hand it to the customer. Best-effort:
|
||||||
// a print failure NEVER fails the create (the subscription + its code are saved);
|
// a print failure NEVER fails the create (the subscription + its code are saved);
|
||||||
// the response carries { printed, printError } so the UI can warn + offer reprint.
|
// the response carries { printed, printError } so the UI can warn + offer reprint.
|
||||||
const printResult = await tryPrintCard(sub);
|
const printResult = await tryPrintCard(sub);
|
||||||
return reply.code(201).send({ ...sub, ...printResult });
|
return reply.code(201).send({ ...sub, ...sale, ...printResult });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append the SIGNED `payment` ledger event for a subscription sale, so the money is
|
||||||
|
* accounted for exactly like a parking payment (live feed + drawer + Z-report). The
|
||||||
|
* amount comes from the PLAN quote (periods × per-period price) — never an
|
||||||
|
* operator-typed number. No plan → no sale → nothing appended (free/comp). The event
|
||||||
|
* carries `subscriptionSale: true` + the subscription id + the plan version so the
|
||||||
|
* feed/audit can label it and the price is reproducible. We do NOT hard-require an
|
||||||
|
* open shift (a subscription can be sold outside the booth money path), but the
|
||||||
|
* operator IS recorded and the payment folds into whichever shift window contains its
|
||||||
|
* timestamp — so it can never be silently pocketed. Returns { sale } or {}.
|
||||||
|
*/
|
||||||
|
async function recordSale(
|
||||||
|
id: string,
|
||||||
|
priced: ReturnType<typeof priceSale>,
|
||||||
|
tenderIn: Tender | undefined,
|
||||||
|
operator: string,
|
||||||
|
): Promise<{ sale?: { amountMinor: number; currency: string | null; tender: Tender; periods: number; inShift: boolean } }> {
|
||||||
|
if (!priced || priced.quote.amountMinor <= 0) return {}; // free/comp — nothing collected
|
||||||
|
const { plan, quote } = priced;
|
||||||
|
const amountMinor = quote.amountMinor;
|
||||||
|
const tender: Tender = tenderIn ?? "cash";
|
||||||
|
const currency = quote.currency;
|
||||||
|
const inShift = shift.currentOpenShift() != null;
|
||||||
|
try {
|
||||||
|
await eventLog.append({
|
||||||
|
type: "payment",
|
||||||
|
source: "manual",
|
||||||
|
// Key the payment to the subscription so the feed can resolve the holder label
|
||||||
|
// and the audit can trace WHICH subscription was sold.
|
||||||
|
identity: id,
|
||||||
|
payload: {
|
||||||
|
sessionRef: id,
|
||||||
|
amountMinor,
|
||||||
|
currency,
|
||||||
|
tender,
|
||||||
|
operator,
|
||||||
|
// Flags this `payment` as a subscription SALE (not a parking payment) so the
|
||||||
|
// live feed / activity log can label it distinctly. plan + periods for audit
|
||||||
|
// and reproducible repricing.
|
||||||
|
subscriptionSale: true,
|
||||||
|
permitId: id,
|
||||||
|
planId: plan.planId,
|
||||||
|
planVersionId: plan.id,
|
||||||
|
periods: quote.periods,
|
||||||
|
...(priced.quantity > 1 ? { quantity: priced.quantity } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
app.log.info(
|
||||||
|
`subscription sale ${amountMinor} ${currency} (${tender}, ${quote.periods}×${plan.period}×${priced.quantity}car) for ${id} by ${operator}` +
|
||||||
|
(inShift ? "" : " [no open shift]"),
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
// A failed append is serious — the money would be untraceable. Surface it.
|
||||||
|
app.log.error(`subscription-sale payment append FAILED for ${id}: ${(err as Error).message}`);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
return { sale: { amountMinor, currency, tender, periods: quote.periods, inShift } };
|
||||||
|
}
|
||||||
|
|
||||||
/** The first QR credential's code for a subscription aggregate, or null. */
|
/** The first QR credential's code for a subscription aggregate, or null. */
|
||||||
function qrCodeOf(sub: ReturnType<typeof loadAggregate>): string | null {
|
function qrCodeOf(sub: ReturnType<typeof loadAggregate>): string | null {
|
||||||
const cred = sub?.credentials.find((c) => c.kind === "qr");
|
const cred = sub?.credentials.find((c) => c.kind === "qr");
|
||||||
@@ -291,20 +433,16 @@ export async function subscriptionRoutes(
|
|||||||
const b = req.body ?? {};
|
const b = req.body ?? {};
|
||||||
const problems = validate(b);
|
const problems = validate(b);
|
||||||
if (problems.length) return reply.code(400).send({ error: "invalid subscription", problems });
|
if (problems.length) return reply.code(400).send({ error: "invalid subscription", problems });
|
||||||
|
// An update is a MASTER-DATA edit — it never re-sells or re-prices. The price,
|
||||||
|
// plan, version and currency are FROZEN as the original sale recorded them (a new
|
||||||
|
// price means a new sale = a new subscription). Editable here: holder/contact,
|
||||||
|
// car-count, the validity window, status, and credentials/plates.
|
||||||
db.update(subscriptions)
|
db.update(subscriptions)
|
||||||
.set({
|
.set({
|
||||||
holderName: b.holderName ?? null,
|
holderName: b.holderName ?? null,
|
||||||
contact: b.contact ?? null,
|
contact: b.contact ?? null,
|
||||||
priceMinor: b.priceMinor === undefined ? existing.priceMinor : b.priceMinor,
|
|
||||||
period: b.period ?? existing.period,
|
|
||||||
currency:
|
|
||||||
b.priceMinor === undefined
|
|
||||||
? existing.currency
|
|
||||||
: b.priceMinor != null
|
|
||||||
? (b.currency ?? null)
|
|
||||||
: null,
|
|
||||||
maxConcurrent: b.maxConcurrent === undefined ? existing.maxConcurrent : b.maxConcurrent,
|
maxConcurrent: b.maxConcurrent === undefined ? existing.maxConcurrent : b.maxConcurrent,
|
||||||
validFrom: b.validFrom ?? null,
|
validFrom: b.validFrom === undefined ? existing.validFrom : (b.validFrom ?? null),
|
||||||
validTo: resolveValidTo(b, existing.validTo),
|
validTo: resolveValidTo(b, existing.validTo),
|
||||||
status: b.status ?? existing.status,
|
status: b.status ?? existing.status,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
import { desc, eq, siteConfig, tariffVersions, tariffs, type Db } from "@parking/db";
|
import { desc, eq, ledgerEvents, siteConfig, tariffVersions, tariffs, type Db } from "@parking/db";
|
||||||
import { isTariffV2, validateTariffStructure, type TariffStructure } from "@parking/shared";
|
import {
|
||||||
|
computeFee,
|
||||||
|
isTariffV2,
|
||||||
|
priceSession,
|
||||||
|
validateTariffStructure,
|
||||||
|
type SessionPayment,
|
||||||
|
type TariffStructure,
|
||||||
|
} from "@parking/shared";
|
||||||
import { requirePermission } from "../auth.js";
|
import { requirePermission } from "../auth.js";
|
||||||
|
|
||||||
/** Default site timezone for wall-clock tariff windows when none is configured. */
|
/** Default site timezone for wall-clock tariff windows when none is configured. */
|
||||||
@@ -22,6 +29,19 @@ interface PublishBody {
|
|||||||
|
|
||||||
const SITE_TARIFF_NAME = "Site tariff";
|
const SITE_TARIFF_NAME = "Site tariff";
|
||||||
|
|
||||||
|
/** Body for POST /api/tariff/simulate — price a hypothetical session, no ledger write.
|
||||||
|
* Provide a structure source (one of): `tariffVersionId`, inline `structure`, or
|
||||||
|
* neither (uses the active version). */
|
||||||
|
interface SimulateBody {
|
||||||
|
enteredAt: string; // ISO-8601
|
||||||
|
asOf: string; // ISO-8601 (the "now"/exit instant being simulated)
|
||||||
|
payments?: SessionPayment[]; // hypothetical payment history (latest grants grace)
|
||||||
|
category?: string;
|
||||||
|
tariffVersionId?: string;
|
||||||
|
structure?: TariffStructure;
|
||||||
|
currency?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export async function tariffRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
export async function tariffRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||||
// Reading the rate card (pay station / operator UI needs it).
|
// Reading the rate card (pay station / operator UI needs it).
|
||||||
const readGuard = requirePermission("tariff:read");
|
const readGuard = requirePermission("tariff:read");
|
||||||
@@ -114,4 +134,110 @@ export async function tariffRoutes(app: FastifyInstance, db: Db): Promise<void>
|
|||||||
return reply.code(201).send(row);
|
return reply.code(201).send(row);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// --- Tariff Lab (simulator) -------------------------------------------------
|
||||||
|
// Price a HYPOTHETICAL session at arbitrary times against any tariff version —
|
||||||
|
// pure, no ledger writes. Lets an admin test rates "in time" (overnight windows,
|
||||||
|
// daily caps, overstay) in seconds instead of waiting hours. Also used to quote a
|
||||||
|
// customer dispute on-site. tariff:read (admins always have it). See tariff.md.
|
||||||
|
app.post<{ Body: SimulateBody }>("/api/tariff/simulate", { preHandler: readGuard }, async (req, reply) => {
|
||||||
|
const b = req.body ?? ({} as SimulateBody);
|
||||||
|
if (!b.enteredAt || !b.asOf) {
|
||||||
|
return reply.code(400).send({ error: "enteredAt and asOf (ISO-8601) required" });
|
||||||
|
}
|
||||||
|
if (!(Date.parse(b.enteredAt) <= Date.parse(b.asOf))) {
|
||||||
|
return reply.code(400).send({ error: "asOf must be at or after enteredAt" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the structure: an explicit version id, or the active version, or an
|
||||||
|
// inline structure (preview unpublished edits). A version carries its currency.
|
||||||
|
let structure: TariffStructure | undefined = b.structure;
|
||||||
|
let currency = b.currency ?? null;
|
||||||
|
if (b.tariffVersionId) {
|
||||||
|
const v = db.select().from(tariffVersions).where(eq(tariffVersions.id, b.tariffVersionId)).get();
|
||||||
|
if (!v) return reply.code(404).send({ error: "tariff version not found" });
|
||||||
|
structure = v.structure as unknown as TariffStructure;
|
||||||
|
currency = v.currency;
|
||||||
|
} else if (!structure) {
|
||||||
|
const tariffId = ensureSiteTariff();
|
||||||
|
const nowIso = new Date().toISOString();
|
||||||
|
const active =
|
||||||
|
db
|
||||||
|
.select()
|
||||||
|
.from(tariffVersions)
|
||||||
|
.where(eq(tariffVersions.tariffId, tariffId))
|
||||||
|
.orderBy(desc(tariffVersions.effectiveFrom))
|
||||||
|
.all()
|
||||||
|
.find((v) => v.effectiveFrom <= nowIso) ?? null;
|
||||||
|
if (!active) return reply.code(404).send({ error: "no active tariff to simulate against" });
|
||||||
|
structure = active.structure as unknown as TariffStructure;
|
||||||
|
currency = active.currency;
|
||||||
|
}
|
||||||
|
|
||||||
|
const problems = validateTariffStructure(structure);
|
||||||
|
if (problems.length) return reply.code(400).send({ error: "invalid tariff structure", problems });
|
||||||
|
|
||||||
|
const payments = Array.isArray(b.payments) ? b.payments : [];
|
||||||
|
const pricing = priceSession(b.enteredAt, b.asOf, structure, payments, b.category);
|
||||||
|
|
||||||
|
// A duration curve from entry: handy to SEE where the cap flattens / windows shift.
|
||||||
|
const SAMPLES_MIN = [30, 60, 120, 180, 360, 720, 1440, 2880, 4320];
|
||||||
|
const enteredMs = Date.parse(b.enteredAt);
|
||||||
|
const curve = SAMPLES_MIN.map((min) => ({
|
||||||
|
minutes: min,
|
||||||
|
amountMinor: computeFee(b.enteredAt, new Date(enteredMs + min * 60_000).toISOString(), structure!, b.category),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { currency, pricing, curve, gracePeriodExitMin: structure.gracePeriodExitMin };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Prefill the lab from a REAL session: fold its ledger into entry + payments so the
|
||||||
|
// admin can re-evaluate an actual ticket (e.g. an overstay) at any chosen `asOf`.
|
||||||
|
app.get<{ Params: { identity: string } }>(
|
||||||
|
"/api/tariff/simulate/session/:identity",
|
||||||
|
{ preHandler: readGuard },
|
||||||
|
async (req, reply) => {
|
||||||
|
const id = (req.params.identity ?? "").trim();
|
||||||
|
if (!id) return reply.code(400).send({ error: "identity required" });
|
||||||
|
const rows = db
|
||||||
|
.select()
|
||||||
|
.from(ledgerEvents)
|
||||||
|
.where(eq(ledgerEvents.identity, id))
|
||||||
|
.orderBy(ledgerEvents.index)
|
||||||
|
.all();
|
||||||
|
const entry = rows.find((r) => r.type === "vehicle_entry");
|
||||||
|
if (!entry) return reply.code(404).send({ error: "no session for identity" });
|
||||||
|
const payments: { paidAt: string; graceExitMin: number | null }[] = [];
|
||||||
|
for (const r of rows) {
|
||||||
|
if (r.type !== "payment") continue;
|
||||||
|
const g = (r.payload as { graceExitMin?: number } | null)?.graceExitMin;
|
||||||
|
payments.push({ paidAt: r.occurredAt, graceExitMin: typeof g === "number" ? g : null });
|
||||||
|
}
|
||||||
|
const exit = rows.find((r) => r.type === "vehicle_exit");
|
||||||
|
const category = (entry.payload as { category?: string } | null)?.category ?? null;
|
||||||
|
return {
|
||||||
|
identity: id,
|
||||||
|
enteredAt: entry.occurredAt,
|
||||||
|
exitedAt: exit?.occurredAt ?? null,
|
||||||
|
payments,
|
||||||
|
category,
|
||||||
|
// The version frozen at entry — the rate card this session actually keeps.
|
||||||
|
tariffVersionId: tariffVersionIdFor(entry.occurredAt),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/** The tariff version in force at a given instant (latest effectiveFrom ≤ when). */
|
||||||
|
function tariffVersionIdFor(whenIso: string): string | null {
|
||||||
|
const tariffId = ensureSiteTariff();
|
||||||
|
const v =
|
||||||
|
db
|
||||||
|
.select()
|
||||||
|
.from(tariffVersions)
|
||||||
|
.where(eq(tariffVersions.tariffId, tariffId))
|
||||||
|
.orderBy(desc(tariffVersions.effectiveFrom))
|
||||||
|
.all()
|
||||||
|
.find((row) => row.effectiveFrom <= whenIso) ?? null;
|
||||||
|
return v?.id ?? null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import { deviceRoutes } from "./routes/devices.js";
|
|||||||
import { eventRoutes } from "./routes/events.js";
|
import { eventRoutes } from "./routes/events.js";
|
||||||
import { payRoutes } from "./routes/pay.js";
|
import { payRoutes } from "./routes/pay.js";
|
||||||
import { subscriptionRoutes } from "./routes/subscriptions.js";
|
import { subscriptionRoutes } from "./routes/subscriptions.js";
|
||||||
|
import { subscriptionPlanRoutes } from "./routes/subscription-plans.js";
|
||||||
import { qrReaderRoutes } from "./routes/qr-reader.js";
|
import { qrReaderRoutes } from "./routes/qr-reader.js";
|
||||||
import { shiftRoutes } from "./routes/shift.js";
|
import { shiftRoutes } from "./routes/shift.js";
|
||||||
import { siteRoutes } from "./routes/site.js";
|
import { siteRoutes } from "./routes/site.js";
|
||||||
@@ -201,10 +202,11 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
|||||||
|
|
||||||
// Subscription admin CRUD + credential capture (arm/poll/cancel). See
|
// Subscription admin CRUD + credential capture (arm/poll/cancel). See
|
||||||
// wiki/entities/subscription.md.
|
// wiki/entities/subscription.md.
|
||||||
await subscriptionRoutes(app, db, credentialCapture);
|
await subscriptionRoutes(app, db, credentialCapture, eventLog, shiftService);
|
||||||
|
await subscriptionPlanRoutes(app, db);
|
||||||
|
|
||||||
// Shift open/close + drawer endpoints (shiftService constructed above).
|
// Shift open/close + drawer endpoints (shiftService constructed above).
|
||||||
await shiftRoutes(app, shiftService);
|
await shiftRoutes(app, shiftService, db);
|
||||||
|
|
||||||
// Site config (capacity) + live occupancy. The FULL gate (refuse transient entry
|
// Site config (capacity) + live occupancy. The FULL gate (refuse transient entry
|
||||||
// at capacity) is in the entry flow. See wiki/concepts/capacity-occupancy.md.
|
// at capacity) is in the entry flow. See wiki/concepts/capacity-occupancy.md.
|
||||||
|
|||||||
@@ -199,9 +199,14 @@ export class ShiftService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The physical drawer balance at `at`: a fold over the SIGNED chain BY TIME (not
|
* The physical drawer balance at `at`: a fold over the SIGNED chain BY TIME (not
|
||||||
* by operator — a cash_movement is the admin's, not the shift operator's). Cash
|
* by operator — a drawer voucher is the admin's, not the shift operator's). Cash
|
||||||
* payments add to the drawer; card payments never touch it; cash_movement amounts
|
* payments add to the drawer; card payments never touch it. Drawer movements adjust
|
||||||
* (signed: + load, − removal) adjust it. This is what carries across shifts.
|
* it via three event types kept side-by-side:
|
||||||
|
* - `cash_in` (Mandat Arkëtimi): + amountMinor (positive magnitude)
|
||||||
|
* - `cash_out` (Mandat Pagese): − amountMinor (positive magnitude)
|
||||||
|
* - `cash_movement` (legacy, pre-2026-06-20): a SIGNED amountMinor (+ load / −
|
||||||
|
* removal) — historical chain events that still fold in unchanged.
|
||||||
|
* This is what carries across shifts.
|
||||||
*/
|
*/
|
||||||
#drawerBalanceAt(at: string): { balanceMinor: number; currency: string | null } {
|
#drawerBalanceAt(at: string): { balanceMinor: number; currency: string | null } {
|
||||||
const rows = this.#db
|
const rows = this.#db
|
||||||
@@ -209,7 +214,14 @@ export class ShiftService {
|
|||||||
.from(ledgerEvents)
|
.from(ledgerEvents)
|
||||||
.orderBy(ledgerEvents.index)
|
.orderBy(ledgerEvents.index)
|
||||||
.all()
|
.all()
|
||||||
.filter((r) => r.occurredAt <= at && (r.type === "payment" || r.type === "cash_movement"));
|
.filter(
|
||||||
|
(r) =>
|
||||||
|
r.occurredAt <= at &&
|
||||||
|
(r.type === "payment" ||
|
||||||
|
r.type === "cash_in" ||
|
||||||
|
r.type === "cash_out" ||
|
||||||
|
r.type === "cash_movement"),
|
||||||
|
);
|
||||||
let balanceMinor = 0;
|
let balanceMinor = 0;
|
||||||
let currency: string | null = null;
|
let currency: string | null = null;
|
||||||
for (const r of rows) {
|
for (const r of rows) {
|
||||||
@@ -218,8 +230,12 @@ export class ShiftService {
|
|||||||
if (r.type === "payment") {
|
if (r.type === "payment") {
|
||||||
// Only CASH enters the till; card settles to the bank.
|
// Only CASH enters the till; card settles to the bank.
|
||||||
if (pl.tender !== "card") balanceMinor += amt;
|
if (pl.tender !== "card") balanceMinor += amt;
|
||||||
|
} else if (r.type === "cash_in") {
|
||||||
|
balanceMinor += Math.abs(amt); // receipt — direction is the type
|
||||||
|
} else if (r.type === "cash_out") {
|
||||||
|
balanceMinor -= Math.abs(amt); // disbursement — direction is the type
|
||||||
} else {
|
} else {
|
||||||
// cash_movement amount is signed (+ load, − removal).
|
// legacy cash_movement amount is signed (+ load, − removal).
|
||||||
balanceMinor += amt;
|
balanceMinor += amt;
|
||||||
}
|
}
|
||||||
if (pl.currency) currency = pl.currency;
|
if (pl.currency) currency = pl.currency;
|
||||||
@@ -227,38 +243,61 @@ export class ShiftService {
|
|||||||
return { balanceMinor, currency };
|
return { balanceMinor, currency };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Next voucher number for a drawer-voucher type, e.g. `AR-0007` (cash_in) /
|
||||||
|
* `PA-0007` (cash_out). Sequential per type = count of existing events + 1. The
|
||||||
|
* number is human-facing (printed on the slip); the signed chain is the real
|
||||||
|
* record, so a small race only risks a duplicate label, never a lost voucher. */
|
||||||
|
#nextVoucherNo(type: "cash_in" | "cash_out"): string {
|
||||||
|
const prefix = type === "cash_in" ? "AR" : "PA";
|
||||||
|
const count = this.#db.select().from(ledgerEvents).where(eq(ledgerEvents.type, type)).all().length;
|
||||||
|
return `${prefix}-${String(count + 1).padStart(4, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Record an admin cash movement (load/remove drawer float). `amountMinor` is
|
* Record a drawer cash VOUCHER — the direction is the event TYPE, not the sign of
|
||||||
* signed: positive = cash loaded IN, negative = cash taken OUT. Signed +
|
* an amount (a receipt and a disbursement are different financial documents):
|
||||||
* attributed. Admin-only is enforced at the route. Returns the new drawer balance.
|
* - `cash_in` (Mandat Arkëtimi): cash entered the drawer (+).
|
||||||
|
* - `cash_out` (Mandat Pagese): cash left the drawer (−).
|
||||||
|
* `amountMinor` is always a POSITIVE magnitude. The voucher is OPERATOR-RAISED and
|
||||||
|
* ADMIN-AUTHORIZED: `operator` raised it, `authorizedBy` signed off (verified at the
|
||||||
|
* route). Returns the new drawer balance + the assigned voucher number, and prints
|
||||||
|
* a slip best-effort (the signed event is the record). See wiki/concepts/shift.md.
|
||||||
*/
|
*/
|
||||||
async recordCashMovement(
|
async recordVoucher(args: {
|
||||||
operator: string,
|
type: "cash_in" | "cash_out";
|
||||||
amountMinor: number,
|
operator: string;
|
||||||
reason: string,
|
authorizedBy: string;
|
||||||
currency?: string,
|
amountMinor: number;
|
||||||
): Promise<{ amountMinor: number; balanceMinor: number }> {
|
reason: string;
|
||||||
if (!Number.isInteger(amountMinor) || amountMinor === 0) {
|
currency?: string;
|
||||||
throw new InvalidCashMovementError("amountMinor must be a non-zero integer (minor units)");
|
}): Promise<{ type: "cash_in" | "cash_out"; amountMinor: number; voucherNo: string; balanceMinor: number; printed: boolean }> {
|
||||||
|
const { type, operator, authorizedBy, reason } = args;
|
||||||
|
if (!Number.isInteger(args.amountMinor) || args.amountMinor <= 0) {
|
||||||
|
throw new InvalidCashMovementError("amountMinor must be a positive integer (minor units)");
|
||||||
}
|
}
|
||||||
|
const amountMinor = args.amountMinor;
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
|
const voucherNo = this.#nextVoucherNo(type);
|
||||||
await this.#log.append({
|
await this.#log.append({
|
||||||
type: "cash_movement",
|
type,
|
||||||
source: "manual",
|
source: "manual",
|
||||||
identity: operator, // who moved the cash (admin)
|
identity: operator, // who RAISED the voucher (the operator at the booth)
|
||||||
payload: {
|
payload: {
|
||||||
amountMinor,
|
amountMinor, // positive magnitude — direction is the type
|
||||||
...(reason ? { reason } : {}),
|
...(reason ? { reason } : {}),
|
||||||
...(currency ? { currency } : {}),
|
...(args.currency ? { currency: args.currency } : {}),
|
||||||
operator,
|
operator,
|
||||||
|
authorizedBy,
|
||||||
|
voucherNo,
|
||||||
},
|
},
|
||||||
occurredAt: now,
|
occurredAt: now,
|
||||||
});
|
});
|
||||||
const { balanceMinor } = this.#drawerBalanceAt(now);
|
const { balanceMinor, currency } = this.#drawerBalanceAt(now);
|
||||||
|
const printed = await this.#printVoucher({ type, voucherNo, amountMinor, reason, operator, authorizedBy, currency, at: now });
|
||||||
this.#logger.info(
|
this.#logger.info(
|
||||||
`cash_movement ${amountMinor >= 0 ? "+" : ""}${amountMinor} by ${operator} (${reason || "no reason"}) → drawer ${balanceMinor}`,
|
`${type} ${voucherNo} ${amountMinor} by ${operator} authz ${authorizedBy} (${reason || "no reason"}) → drawer ${balanceMinor}`,
|
||||||
);
|
);
|
||||||
return { amountMinor, balanceMinor };
|
return { type, amountMinor, voucherNo, balanceMinor, printed };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Open a shift for the operator (explicit start). The opening float is auto-
|
/** Open a shift for the operator (explicit start). The opening float is auto-
|
||||||
@@ -284,21 +323,28 @@ export class ShiftService {
|
|||||||
return { startedAt, openingFloatMinor };
|
return { startedAt, openingFloatMinor };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Close the operator's open shift: sum payments in the window, sign + print the Z-report. */
|
/**
|
||||||
async close(operator: string): Promise<ShiftReport> {
|
* Project the drawer/takings figures for a shift's window `[startedAt, asOf]`.
|
||||||
const open = this.openShiftFor(operator);
|
* Pure read over the signed chain — appends NOTHING — so it backs BOTH the
|
||||||
if (!open) throw new NoOpenShiftError(operator);
|
* mid-shift X-report (asOf = now, shift still open) and the Z-report at close
|
||||||
|
* (asOf = endedAt). The figures are identical projections; only the persistence
|
||||||
|
* differs (X = read-only, Z = signed + carried forward).
|
||||||
|
*/
|
||||||
|
#summariseWindow(
|
||||||
|
open: typeof ledgerEvents.$inferSelect,
|
||||||
|
asOf: string,
|
||||||
|
): Omit<ShiftReport, "printed"> {
|
||||||
|
const operator = open.identity ?? "?";
|
||||||
const startedAt = open.occurredAt;
|
const startedAt = open.occurredAt;
|
||||||
const endedAt = new Date().toISOString();
|
|
||||||
|
|
||||||
// All payments taken in [startedAt, endedAt], summed by tender. Payment time =
|
// All payments taken in [startedAt, asOf], summed by tender. Payment time =
|
||||||
// the operator who handled the money (decision: sum by payment time).
|
// the operator who handled the money (decision: sum by payment time).
|
||||||
const payments = this.#db
|
const payments = this.#db
|
||||||
.select()
|
.select()
|
||||||
.from(ledgerEvents)
|
.from(ledgerEvents)
|
||||||
.where(eq(ledgerEvents.type, "payment"))
|
.where(eq(ledgerEvents.type, "payment"))
|
||||||
.all()
|
.all()
|
||||||
.filter((r) => r.occurredAt >= startedAt && r.occurredAt <= endedAt);
|
.filter((r) => r.occurredAt >= startedAt && r.occurredAt <= asOf);
|
||||||
|
|
||||||
let cashTotalMinor = 0;
|
let cashTotalMinor = 0;
|
||||||
let cardTotalMinor = 0;
|
let cardTotalMinor = 0;
|
||||||
@@ -320,31 +366,39 @@ export class ShiftService {
|
|||||||
? openPl.openingFloatMinor
|
? openPl.openingFloatMinor
|
||||||
: this.#drawerBalanceAt(startedAt).balanceMinor;
|
: this.#drawerBalanceAt(startedAt).balanceMinor;
|
||||||
|
|
||||||
// Cash movements within the shift window, split into added (+) and removed (−).
|
// Drawer movements within the window, split into added (+) and removed (−).
|
||||||
|
// Three side-by-side types: cash_in (+), cash_out (−), and the legacy signed-±
|
||||||
|
// cash_movement. All carry a POSITIVE magnitude except legacy, which is signed.
|
||||||
const movements = this.#db
|
const movements = this.#db
|
||||||
.select()
|
.select()
|
||||||
.from(ledgerEvents)
|
.from(ledgerEvents)
|
||||||
.where(eq(ledgerEvents.type, "cash_movement"))
|
|
||||||
.all()
|
.all()
|
||||||
.filter((r) => r.occurredAt >= startedAt && r.occurredAt <= endedAt);
|
.filter(
|
||||||
|
(r) =>
|
||||||
|
(r.type === "cash_in" || r.type === "cash_out" || r.type === "cash_movement") &&
|
||||||
|
r.occurredAt >= startedAt &&
|
||||||
|
r.occurredAt <= asOf,
|
||||||
|
);
|
||||||
let cashAddedMinor = 0;
|
let cashAddedMinor = 0;
|
||||||
let cashRemovedMinor = 0;
|
let cashRemovedMinor = 0;
|
||||||
for (const m of movements) {
|
for (const m of movements) {
|
||||||
const pl = (m.payload ?? {}) as LedgerPayload;
|
const pl = (m.payload ?? {}) as LedgerPayload;
|
||||||
const amt = typeof pl.amountMinor === "number" ? pl.amountMinor : 0;
|
const amt = typeof pl.amountMinor === "number" ? pl.amountMinor : 0;
|
||||||
if (amt >= 0) cashAddedMinor += amt;
|
if (m.type === "cash_in") cashAddedMinor += Math.abs(amt);
|
||||||
else cashRemovedMinor += -amt; // store as a positive magnitude
|
else if (m.type === "cash_out") cashRemovedMinor += Math.abs(amt);
|
||||||
|
else if (amt >= 0) cashAddedMinor += amt; // legacy + load
|
||||||
|
else cashRemovedMinor += -amt; // legacy − removal, store as positive magnitude
|
||||||
if (pl.currency) currency = pl.currency;
|
if (pl.currency) currency = pl.currency;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Expected drawer at close = opening + cash taken + added − removed. This is the
|
// Expected drawer = opening + cash taken + added − removed. At close this is the
|
||||||
// figure the NEXT shift inherits as its opening float.
|
// figure the NEXT shift inherits as its opening float.
|
||||||
const expectedDrawerMinor = openingFloatMinor + cashTotalMinor + cashAddedMinor - cashRemovedMinor;
|
const expectedDrawerMinor = openingFloatMinor + cashTotalMinor + cashAddedMinor - cashRemovedMinor;
|
||||||
|
|
||||||
const report: Omit<ShiftReport, "printed"> = {
|
return {
|
||||||
operator,
|
operator,
|
||||||
startedAt,
|
startedAt,
|
||||||
endedAt,
|
endedAt: asOf,
|
||||||
cashTotalMinor,
|
cashTotalMinor,
|
||||||
cardTotalMinor,
|
cardTotalMinor,
|
||||||
currency,
|
currency,
|
||||||
@@ -354,6 +408,40 @@ export class ShiftService {
|
|||||||
cashRemovedMinor,
|
cashRemovedMinor,
|
||||||
expectedDrawerMinor,
|
expectedDrawerMinor,
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mid-shift X-report: a READ-ONLY "so far" snapshot of the open shift's takings +
|
||||||
|
* drawer, computed as of now. Appends nothing (it's not an accountability mark —
|
||||||
|
* the Z-report at close is). Returns null when no shift is open. The same
|
||||||
|
* projection the Z-report prints, so the operator sees exactly what their close
|
||||||
|
* will show. See wiki/concepts/shift.md.
|
||||||
|
*/
|
||||||
|
currentReport(): (Omit<ShiftReport, "printed"> & { asOf: string }) | null {
|
||||||
|
const open = this.currentOpenShift();
|
||||||
|
if (!open) return null;
|
||||||
|
const asOf = new Date().toISOString();
|
||||||
|
return { ...this.#summariseWindow(open, asOf), asOf };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Close the operator's open shift: sum payments in the window, sign + print the Z-report. */
|
||||||
|
async close(operator: string): Promise<ShiftReport> {
|
||||||
|
const open = this.openShiftFor(operator);
|
||||||
|
if (!open) throw new NoOpenShiftError(operator);
|
||||||
|
const endedAt = new Date().toISOString();
|
||||||
|
|
||||||
|
const report = this.#summariseWindow(open, endedAt);
|
||||||
|
const {
|
||||||
|
startedAt,
|
||||||
|
cashTotalMinor,
|
||||||
|
cardTotalMinor,
|
||||||
|
currency,
|
||||||
|
paymentCount,
|
||||||
|
openingFloatMinor,
|
||||||
|
cashAddedMinor,
|
||||||
|
cashRemovedMinor,
|
||||||
|
expectedDrawerMinor,
|
||||||
|
} = report;
|
||||||
|
|
||||||
await this.#log.append({
|
await this.#log.append({
|
||||||
type: "shift_z_report",
|
type: "shift_z_report",
|
||||||
@@ -366,7 +454,7 @@ export class ShiftService {
|
|||||||
cashTotalMinor,
|
cashTotalMinor,
|
||||||
cardTotalMinor,
|
cardTotalMinor,
|
||||||
currency: currency ?? undefined,
|
currency: currency ?? undefined,
|
||||||
paymentCount: payments.length,
|
paymentCount,
|
||||||
openingFloatMinor,
|
openingFloatMinor,
|
||||||
cashAddedMinor,
|
cashAddedMinor,
|
||||||
cashRemovedMinor,
|
cashRemovedMinor,
|
||||||
@@ -377,7 +465,7 @@ export class ShiftService {
|
|||||||
const printed = await this.#printZReport(report);
|
const printed = await this.#printZReport(report);
|
||||||
|
|
||||||
this.#logger.info(
|
this.#logger.info(
|
||||||
`shift closed for ${operator}: cash ${cashTotalMinor} card ${cardTotalMinor} (${payments.length} payments); ` +
|
`shift closed for ${operator}: cash ${cashTotalMinor} card ${cardTotalMinor} (${paymentCount} payments); ` +
|
||||||
`drawer open ${openingFloatMinor} +${cashAddedMinor} −${cashRemovedMinor} → expected ${expectedDrawerMinor}`,
|
`drawer open ${openingFloatMinor} +${cashAddedMinor} −${cashRemovedMinor} → expected ${expectedDrawerMinor}`,
|
||||||
);
|
);
|
||||||
return { ...report, printed };
|
return { ...report, printed };
|
||||||
@@ -420,6 +508,46 @@ export class ShiftService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Print a drawer-voucher slip (Mandat Arkëtimi / Mandat Pagese). Best-effort —
|
||||||
|
* the signed event is the record; a failed print doesn't undo the voucher.
|
||||||
|
* Albanian, like every customer/operator-facing slip (see i18n.md). */
|
||||||
|
async #printVoucher(v: {
|
||||||
|
type: "cash_in" | "cash_out";
|
||||||
|
voucherNo: string;
|
||||||
|
amountMinor: number;
|
||||||
|
reason: string;
|
||||||
|
operator: string;
|
||||||
|
authorizedBy: string;
|
||||||
|
currency: string | null;
|
||||||
|
at: string;
|
||||||
|
}): Promise<boolean> {
|
||||||
|
const printer = await this.#boothPrinter();
|
||||||
|
if (!printer) {
|
||||||
|
this.#logger.warn(`no booth-receipt printer — ${v.type} ${v.voucherNo} not printed (event recorded)`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const cur = v.currency ?? "";
|
||||||
|
const money = (m: number) => (m / 100).toFixed(2);
|
||||||
|
const title = v.type === "cash_in" ? "MANDAT ARKËTIMI" : "MANDAT PAGESE";
|
||||||
|
const lines = [
|
||||||
|
`Mandat Nr.: ${v.voucherNo}`,
|
||||||
|
`Data: ${zStamp(v.at)}`,
|
||||||
|
"",
|
||||||
|
`Shuma: ${money(v.amountMinor)} ${cur}`,
|
||||||
|
`Arsyeja: ${v.reason || "-"}`,
|
||||||
|
"",
|
||||||
|
`Hapur nga: ${v.operator}`,
|
||||||
|
`Autorizoi: ${v.authorizedBy}`,
|
||||||
|
];
|
||||||
|
try {
|
||||||
|
await printer.printReport({ title, lines });
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
this.#logger.warn(`${v.type} ${v.voucherNo} print failed: ${(err as Error).message} (event recorded)`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** First enabled booth-receipt printer, or any enabled printer. */
|
/** First enabled booth-receipt printer, or any enabled printer. */
|
||||||
async #boothPrinter(): Promise<PrinterDevice | null> {
|
async #boothPrinter(): Promise<PrinterDevice | null> {
|
||||||
const rows = await this.#db.select().from(devices).where(eq(devices.category, "printer")).all();
|
const rows = await this.#db.select().from(devices).where(eq(devices.category, "printer")).all();
|
||||||
|
|||||||
@@ -10,12 +10,14 @@ import {
|
|||||||
type DeviceRow,
|
type DeviceRow,
|
||||||
} from "@parking/db";
|
} from "@parking/db";
|
||||||
import { registry, type AccessControlDevice } from "@parking/devices";
|
import { registry, type AccessControlDevice } from "@parking/devices";
|
||||||
import { reasonPayload, type ReasonCode } from "@parking/shared";
|
import { reasonPayload, type PlanTimeframes, type ReasonCode } from "@parking/shared";
|
||||||
import type { FastifyBaseLogger } from "fastify";
|
import type { FastifyBaseLogger } from "fastify";
|
||||||
|
import { printWindowChargeNotice } from "./booth-print.js";
|
||||||
import type { DeviceReadEvent, ReadOutcome } from "./device-events.js";
|
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 { planVersionById, 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
|
||||||
@@ -147,6 +149,23 @@ export class SubscriptionFlow {
|
|||||||
return { accepted: false, direction: "exit", reason };
|
return { accepted: false, direction: "exit", reason };
|
||||||
}
|
}
|
||||||
const occurrenceId = oldest.identity;
|
const occurrenceId = oldest.identity;
|
||||||
|
|
||||||
|
// TARIFF BRIDGE — exit gate. Total owed = carried early-entry charge (signed on the
|
||||||
|
// entry payload) + a late-exit charge (window-close→now) computed fresh. If the
|
||||||
|
// subscriber owes money and hasn't paid it, REFUSE the exit (like the transient
|
||||||
|
// unpaid/overstay gate) — they settle at the booth (a signed `payment` keyed to the
|
||||||
|
// occurrence), then re-scan. This is a host-ONLINE business gate; the offline path
|
||||||
|
// still fails open. See wiki/entities/subscription.md ("tariff bridge").
|
||||||
|
const owed = this.#windowOwed(occurrenceId, m.subscriptionId, sub.planVersionId);
|
||||||
|
const paid = this.#windowPaidMinor(occurrenceId);
|
||||||
|
if (owed.totalMinor - paid > 0) {
|
||||||
|
const reason = await this.#reject(m, "exit", "sub.refused.unpaidWindow", {
|
||||||
|
amount: ((owed.totalMinor - paid) / 100).toFixed(2),
|
||||||
|
currency: owed.currency ?? "",
|
||||||
|
});
|
||||||
|
return { accepted: false, direction: "exit", reason };
|
||||||
|
}
|
||||||
|
|
||||||
await this.#log.append({
|
await this.#log.append({
|
||||||
type: "vehicle_exit",
|
type: "vehicle_exit",
|
||||||
direction: "exit",
|
direction: "exit",
|
||||||
@@ -170,6 +189,14 @@ 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
|
||||||
|
// the window opens, the subscriber owes the transient tariff for arrival→window-open.
|
||||||
|
// We DEFER it (open now, collect at exit): stamp the owed amount on the SIGNED entry
|
||||||
|
// payload (the source of truth — `windowOwedMinor`), so the exit gate reads it back
|
||||||
|
// from the chain. Plans without timeframes return null → nothing owed. See
|
||||||
|
// wiki/entities/subscription.md.
|
||||||
|
const entryCharge = 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.
|
||||||
const occurrenceId = `SUBSESS-${randomUUID().replace(/-/g, "").slice(0, 12)}`;
|
const occurrenceId = `SUBSESS-${randomUUID().replace(/-/g, "").slice(0, 12)}`;
|
||||||
@@ -179,10 +206,30 @@ export class SubscriptionFlow {
|
|||||||
source,
|
source,
|
||||||
identity: occurrenceId,
|
identity: occurrenceId,
|
||||||
// No ticket, no fee — the subscription IS the authorization. Recorded for audit.
|
// No ticket, no fee — the subscription IS the authorization. Recorded for audit.
|
||||||
// `permitId`/`permit` are the on-chain field names (immutable).
|
// `permitId`/`permit` are the on-chain field names (immutable). A deferred early-
|
||||||
payload: { sessionRef: occurrenceId, permitId: m.subscriptionId, permit: true, via: m.via },
|
// entry charge is signed here (windowOwedMinor + the priced gap) so it's owed at exit.
|
||||||
|
payload: {
|
||||||
|
sessionRef: occurrenceId,
|
||||||
|
permitId: m.subscriptionId,
|
||||||
|
permit: true,
|
||||||
|
via: m.via,
|
||||||
|
...(entryCharge
|
||||||
|
? {
|
||||||
|
windowOwedMinor: entryCharge.amountMinor,
|
||||||
|
windowCurrency: entryCharge.currency,
|
||||||
|
windowTariffVersionId: entryCharge.tariffVersionId,
|
||||||
|
windowGapStart: entryCharge.gapStart,
|
||||||
|
windowGapEnd: entryCharge.gapEnd,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
occurredAt: now,
|
occurredAt: now,
|
||||||
});
|
});
|
||||||
|
if (entryCharge) {
|
||||||
|
this.#logger.info(
|
||||||
|
`subscription early-entry charge ${entryCharge.amountMinor} ${entryCharge.currency} (${entryCharge.minutes}min) deferred on ${occurrenceId}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
await this.#open(resolved, "entry", occurrenceId, "subscription entry");
|
await this.#open(resolved, "entry", occurrenceId, "subscription entry");
|
||||||
try {
|
try {
|
||||||
this.#db
|
this.#db
|
||||||
@@ -199,9 +246,57 @@ 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
|
||||||
|
// proof a fee is pending (the final amount is computed at the booth on settlement,
|
||||||
|
// combining early-entry + any late-exit time). AFTER the open + cache, and fully
|
||||||
|
// swallowed — a missing/failed printer must NEVER block or delay the barrier.
|
||||||
|
if (entryCharge) {
|
||||||
|
const tf = (planVersionById(this.#db, sub.planVersionId)?.timeframes ?? null) as PlanTimeframes | null;
|
||||||
|
void printWindowChargeNotice(
|
||||||
|
this.#db,
|
||||||
|
{ occurrenceId, holderName: sub.holderName, at: now, windowOpensMin: tf?.fromMin, edge: "entry" },
|
||||||
|
this.#logger,
|
||||||
|
).catch((err) => this.#logger.warn(`out-of-window slip print failed for ${occurrenceId}: ${(err as Error).message}`));
|
||||||
|
}
|
||||||
return { accepted: true, direction: "entry" };
|
return { accepted: true, direction: "entry" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 } {
|
||||||
|
const entryRow = this.#db
|
||||||
|
.select()
|
||||||
|
.from(ledgerEvents)
|
||||||
|
.where(eq(ledgerEvents.identity, occurrenceId))
|
||||||
|
.all()
|
||||||
|
.find((r) => r.type === "vehicle_entry");
|
||||||
|
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
|
||||||
|
* already paid toward their window charge). Folds the append-only ledger. */
|
||||||
|
#windowPaidMinor(occurrenceId: string): number {
|
||||||
|
const rows = this.#db.select().from(ledgerEvents).where(eq(ledgerEvents.identity, occurrenceId)).all();
|
||||||
|
let paid = 0;
|
||||||
|
for (const r of rows) {
|
||||||
|
if (r.type !== "payment") continue;
|
||||||
|
const pl = (r.payload ?? {}) as { amountMinor?: number };
|
||||||
|
if (typeof pl.amountMinor === "number") paid += pl.amountMinor;
|
||||||
|
}
|
||||||
|
return paid;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The OPEN occurrences of a subscription right now, **oldest first** (FIFO) — a
|
* The OPEN occurrences of a subscription right now, **oldest first** (FIFO) — a
|
||||||
* fold over the signed ledger. An occurrence is a `vehicle_entry` (whose
|
* fold over the signed ledger. An occurrence is a `vehicle_entry` (whose
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { and, eq, lte, desc, subscriptionPlans, type Db } from "@parking/db";
|
||||||
|
import type { SubscriptionPlan } from "@parking/shared";
|
||||||
|
|
||||||
|
// Subscription-plan pricing. The PURE span math (periodsBetween / priceSubscriptionSpan
|
||||||
|
// / addMonths) lives in @parking/shared so it's unit-tested alongside the tariff fee
|
||||||
|
// function; here we add the DB-backed plan-version resolver. A plan is admin-composed,
|
||||||
|
// versioned config (like a tariff) — the operator SELLS from it and never types a
|
||||||
|
// price. See wiki/entities/subscription.md.
|
||||||
|
export { periodsBetween, priceSubscriptionSpan, addMonths } from "@parking/shared";
|
||||||
|
|
||||||
|
/** Resolve the plan VERSION in force for `planId` at `asOf`: the latest active row
|
||||||
|
* with effectiveFrom ≤ asOf (the tariff-resolve pattern). null when none applies. */
|
||||||
|
export function resolvePlanVersion(db: Db, planId: string, asOf: string): SubscriptionPlan | null {
|
||||||
|
const row = db
|
||||||
|
.select()
|
||||||
|
.from(subscriptionPlans)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(subscriptionPlans.planId, planId),
|
||||||
|
eq(subscriptionPlans.active, true),
|
||||||
|
lte(subscriptionPlans.effectiveFrom, asOf),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.orderBy(desc(subscriptionPlans.effectiveFrom))
|
||||||
|
.limit(1)
|
||||||
|
.get();
|
||||||
|
return row ? (row as SubscriptionPlan) : null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { desc, eq, siteConfig, subscriptionPlans, tariffVersions, tariffs, type Db } from "@parking/db";
|
||||||
|
import {
|
||||||
|
computeFee,
|
||||||
|
minutesOutsideWindow,
|
||||||
|
outOfWindowGap,
|
||||||
|
type PlanTimeframes,
|
||||||
|
type SubscriptionPlan,
|
||||||
|
type TariffStructure,
|
||||||
|
} from "@parking/shared";
|
||||||
|
|
||||||
|
// Subscription TIME-WINDOW → TARIFF BRIDGE. A plan may restrict WHEN a subscriber may be
|
||||||
|
// parked (e.g. weekday 20:00→08:00, weekend all-day). A scan OUTSIDE the window is NOT
|
||||||
|
// refused — the out-of-window minutes are charged at the normal TRANSIENT tariff:
|
||||||
|
// - early ENTRY: arrival → window-open is owed (deferred; collected at exit).
|
||||||
|
// - late EXIT: window-close → departure is owed (exit is GATED until paid).
|
||||||
|
// Only plans WITH timeframes trigger any charge; a 24/7 plan never does. The gap math is
|
||||||
|
// pure + tz-aware (outOfWindowGap in @parking/shared); pricing reuses computeFee (the same
|
||||||
|
// engine transient stays use). See wiki/entities/subscription.md ("tariff bridge").
|
||||||
|
|
||||||
|
const DEFAULT_TZ = "Europe/Tirane";
|
||||||
|
|
||||||
|
/** A computed out-of-window charge: the gap, what it costs, and the tariff version used
|
||||||
|
* (recorded so it reprices identically — like every transient payment). */
|
||||||
|
export interface WindowCharge {
|
||||||
|
readonly amountMinor: number;
|
||||||
|
readonly gapStart: string;
|
||||||
|
readonly gapEnd: string;
|
||||||
|
readonly minutes: number;
|
||||||
|
readonly currency: string;
|
||||||
|
readonly tariffVersionId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The plan VERSION that priced a subscription's sale (by planVersionId), or null. The
|
||||||
|
* timeframes are read from THIS version so a later plan edit can't retroactively change
|
||||||
|
* an existing subscriber's window rules. */
|
||||||
|
export function planVersionById(db: Db, planVersionId: string | null): SubscriptionPlan | null {
|
||||||
|
if (!planVersionId) return null;
|
||||||
|
const row = db.select().from(subscriptionPlans).where(eq(subscriptionPlans.id, planVersionId)).get();
|
||||||
|
return row ? (row as unknown as SubscriptionPlan) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The site IANA timezone (falls back to the project default). */
|
||||||
|
export function siteTz(db: Db): string {
|
||||||
|
const cfg = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||||
|
return cfg?.timezone && cfg.timezone.length > 0 ? cfg.timezone : DEFAULT_TZ;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The active site tariff version in force at `at` (latest effectiveFrom ≤ at), or null. */
|
||||||
|
function tariffVersionAt(db: Db, at: string) {
|
||||||
|
const tariff = db.select().from(tariffs).where(eq(tariffs.scope, "site")).get();
|
||||||
|
if (!tariff) return null;
|
||||||
|
const versions = db
|
||||||
|
.select()
|
||||||
|
.from(tariffVersions)
|
||||||
|
.where(eq(tariffVersions.tariffId, tariff.id))
|
||||||
|
.orderBy(desc(tariffVersions.effectiveFrom))
|
||||||
|
.all();
|
||||||
|
return versions.find((v) => v.effectiveFrom <= at) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute the out-of-window charge for a subscriber scan at `atISO`, or null when there
|
||||||
|
* is nothing to charge (no plan timeframes, in-window, weekend all-day, within grace, or
|
||||||
|
* no tariff configured). `edge` = "entry" (early) or "exit" (late). The gap is priced as
|
||||||
|
* a fresh transient stay of that duration (computeFee over [gapStart, gapEnd]).
|
||||||
|
*/
|
||||||
|
export function windowCharge(
|
||||||
|
db: Db,
|
||||||
|
planVersionId: string | null,
|
||||||
|
atISO: string,
|
||||||
|
edge: "entry" | "exit",
|
||||||
|
): WindowCharge | null {
|
||||||
|
const plan = planVersionById(db, planVersionId);
|
||||||
|
const timeframes = (plan?.timeframes ?? null) as PlanTimeframes | null;
|
||||||
|
if (!timeframes) return null; // 24/7 plan (or comp sub) — never a time charge.
|
||||||
|
|
||||||
|
const tz = timeframes.tz || siteTz(db);
|
||||||
|
const gap = outOfWindowGap(timeframes, tz, atISO, edge);
|
||||||
|
if (!gap) return null; // in-window / all-day / within grace.
|
||||||
|
|
||||||
|
const tv = tariffVersionAt(db, gap.start);
|
||||||
|
if (!tv) return null; // no tariff to price against — can't charge (don't trap).
|
||||||
|
const structure = tv.structure as unknown as TariffStructure;
|
||||||
|
const amountMinor = computeFee(gap.start, gap.end, structure);
|
||||||
|
if (amountMinor <= 0) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
amountMinor,
|
||||||
|
gapStart: gap.start,
|
||||||
|
gapEnd: gap.end,
|
||||||
|
minutes: gap.minutes,
|
||||||
|
currency: tv.currency,
|
||||||
|
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 };
|
||||||
|
}
|
||||||
+128
-50
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { fetchActiveSessions, reopenBarrier, type ActiveSession } from "./api.js";
|
import { fetchActiveSessions, reopenBarrier, type ActiveSession } from "./api.js";
|
||||||
@@ -6,6 +6,7 @@ import { qk } from "./lib/query.js";
|
|||||||
import { useShift } from "./lib/use-shift.js";
|
import { useShift } from "./lib/use-shift.js";
|
||||||
import { formatDuration, formatRelativeDateTime } from "./lib/format.js";
|
import { formatDuration, formatRelativeDateTime } from "./lib/format.js";
|
||||||
import { Panel } from "./ui/Panel.js";
|
import { Panel } from "./ui/Panel.js";
|
||||||
|
import { FilterBar, SegGroup, type SegOption } from "./ui/FilterBar.js";
|
||||||
|
|
||||||
// Active Sessions panel. A session is "active" while still inside OR exited-but-
|
// Active Sessions panel. A session is "active" while still inside OR exited-but-
|
||||||
// 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
|
||||||
@@ -14,10 +15,28 @@ import { Panel } from "./ui/Panel.js";
|
|||||||
// - click a row → the pay/exit modal (pay an unpaid car, or review),
|
// - click a row → the pay/exit modal (pay an unpaid car, or review),
|
||||||
// - "Open barrier" (PAID sessions only) → an audited human-intervention re-pulse.
|
// - "Open barrier" (PAID sessions only) → an audited human-intervention re-pulse.
|
||||||
// No payment → no Open barrier button (the no-unpaid-bypass rule).
|
// No payment → no Open barrier button (the no-unpaid-bypass rule).
|
||||||
|
//
|
||||||
|
// 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
|
||||||
|
// faulty/abandoned); occupancy lingers and the car owes a fresh top-up. The operator
|
||||||
|
// reconciles via the pay/exit modal — never a free barrier open.
|
||||||
// See wiki/concepts/booth-exit-flow.md.
|
// See wiki/concepts/booth-exit-flow.md.
|
||||||
|
|
||||||
function statusBadge(s: ActiveSession): { key: string; cls: string } {
|
type StatusFilter = "unpaid" | "paid" | "exiting" | "overstay";
|
||||||
|
type KindFilter = "transient" | "subscription";
|
||||||
|
|
||||||
|
function statusOf(s: ActiveSession): StatusFilter | "subscription" {
|
||||||
|
if (s.subscription) return "subscription";
|
||||||
|
if (s.overstay) return "overstay";
|
||||||
|
if (!s.open && s.withinGrace) return "exiting";
|
||||||
|
if (s.paidAt) return "paid";
|
||||||
|
return "unpaid";
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusBadge(s: ActiveSession): { key: string; titleKey?: string; cls: string } {
|
||||||
if (s.subscription) return { key: "booth.badgeSubscription", cls: "text-term-cyan" };
|
if (s.subscription) return { key: "booth.badgeSubscription", cls: "text-term-cyan" };
|
||||||
|
if (s.overstay)
|
||||||
|
return { key: "booth.badgeOverstay", titleKey: "booth.badgeOverstayTitle", cls: "text-term-red" };
|
||||||
if (!s.open && s.withinGrace) return { key: "booth.badgeExiting", cls: "text-term-cyan" };
|
if (!s.open && s.withinGrace) return { key: "booth.badgeExiting", cls: "text-term-cyan" };
|
||||||
if (s.paidAt) return { key: "booth.badgePaid", cls: "text-term-green" };
|
if (s.paidAt) return { key: "booth.badgePaid", cls: "text-term-green" };
|
||||||
return { key: "booth.badgeUnpaid", cls: "text-term-amber" };
|
return { key: "booth.badgeUnpaid", cls: "text-term-amber" };
|
||||||
@@ -47,7 +66,36 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void
|
|||||||
});
|
});
|
||||||
const [reopenMsg, setReopenMsg] = useState<{ id: string; text: string; ok: boolean } | null>(null);
|
const [reopenMsg, setReopenMsg] = useState<{ id: string; text: string; ok: boolean } | null>(null);
|
||||||
|
|
||||||
const sessions = data?.sessions ?? [];
|
// Filters: free-text search, status, and transient-vs-subscriber.
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [status, setStatus] = useState<StatusFilter | "">("");
|
||||||
|
const [kind, setKind] = useState<KindFilter | "">("");
|
||||||
|
|
||||||
|
const sessions = useMemo(() => data?.sessions ?? [], [data]);
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const q = search.trim().toLowerCase();
|
||||||
|
return sessions.filter((s) => {
|
||||||
|
if (kind === "transient" && s.subscription) return false;
|
||||||
|
if (kind === "subscription" && !s.subscription) return false;
|
||||||
|
if (status && statusOf(s) !== status) return false;
|
||||||
|
if (q) {
|
||||||
|
const hay = `${s.identity} ${s.subscriptionHolder ?? ""}`.toLowerCase();
|
||||||
|
if (!hay.includes(q)) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [sessions, search, status, kind]);
|
||||||
|
|
||||||
|
const statusOpts: SegOption<StatusFilter>[] = [
|
||||||
|
{ value: "unpaid", label: t("booth.fStatusUnpaid") },
|
||||||
|
{ value: "paid", label: t("booth.fStatusPaid") },
|
||||||
|
{ value: "exiting", label: t("booth.fStatusExiting") },
|
||||||
|
{ value: "overstay", label: t("booth.fStatusOverstay") },
|
||||||
|
];
|
||||||
|
const kindOpts: SegOption<KindFilter>[] = [
|
||||||
|
{ value: "transient", label: t("booth.fKindTransient") },
|
||||||
|
{ value: "subscription", label: t("booth.fKindSubscription") },
|
||||||
|
];
|
||||||
|
|
||||||
async function handleReopen(s: ActiveSession) {
|
async function handleReopen(s: ActiveSession) {
|
||||||
setReopenMsg(null);
|
setReopenMsg(null);
|
||||||
@@ -68,62 +116,92 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void
|
|||||||
title={t("booth.activeSessions")}
|
title={t("booth.activeSessions")}
|
||||||
right={
|
right={
|
||||||
<span className="text-[10px] uppercase tracking-wider text-term-muted">
|
<span className="text-[10px] uppercase tracking-wider text-term-muted">
|
||||||
{sessions.length} {t("booth.insideCount")}
|
{filtered.length}
|
||||||
|
{filtered.length !== sessions.length ? `/${sessions.length}` : ""} {t("booth.insideCount")}
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
className="min-h-0"
|
className="min-h-0 flex-1"
|
||||||
>
|
>
|
||||||
<div className="h-full overflow-y-auto pr-1">
|
<div className="flex h-full flex-col">
|
||||||
{sessions.length === 0 ? (
|
<FilterBar search={search} onSearch={setSearch} searchPlaceholder={t("booth.filterSearchSessions")}>
|
||||||
<div className="text-term-muted">{isLoading ? t("common.loading") : t("booth.noActiveSessions")}</div>
|
<SegGroup value={status} options={statusOpts} onChange={setStatus} allLabel={t("booth.filterAll")} />
|
||||||
) : (
|
<SegGroup value={kind} options={kindOpts} onChange={setKind} allLabel={t("booth.filterAll")} />
|
||||||
sessions.map((s) => {
|
</FilterBar>
|
||||||
const badge = statusBadge(s);
|
|
||||||
const msg = reopenMsg?.id === s.identity ? reopenMsg : null;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={s.identity}
|
|
||||||
className="flex items-center gap-3 border-b border-term-border/50 py-1.5 text-[12px] tabular-nums"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onPick(s.identity)}
|
|
||||||
className="flex flex-1 items-center gap-3 text-left hover:text-term-amber"
|
|
||||||
title={t("booth.openPayExit")}
|
|
||||||
>
|
|
||||||
<span className="text-term-text">
|
|
||||||
{s.subscription ? `★ ${s.subscriptionHolder ?? t("subs.unnamed")}` : s.identity}
|
|
||||||
</span>
|
|
||||||
<span className="text-term-muted">{formatRelativeDateTime(s.enteredAt, t)}</span>
|
|
||||||
<span className="text-term-muted">{formatDuration(s.enteredAt, new Date().toISOString())}</span>
|
|
||||||
<span className={`ml-auto w-16 text-right font-semibold uppercase ${badge.cls}`}>{t(badge.key)}</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Open barrier — PAID transient OR a SUBSCRIPTION (prepaid). An
|
<div className="min-h-0 flex-1 overflow-y-auto pr-1">
|
||||||
unpaid transient has no button (no-unpaid-bypass). */}
|
{filtered.length === 0 ? (
|
||||||
{s.paidAt || s.subscription ? (
|
<div className="text-term-muted">
|
||||||
|
{isLoading
|
||||||
|
? t("common.loading")
|
||||||
|
: sessions.length === 0
|
||||||
|
? t("booth.noActiveSessions")
|
||||||
|
: t("booth.noMatch")}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filtered.map((s) => {
|
||||||
|
const badge = statusBadge(s);
|
||||||
|
const msg = reopenMsg?.id === s.identity ? reopenMsg : null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={s.identity}
|
||||||
|
className="flex items-center gap-3 border-b border-term-border/50 py-1.5 text-[12px] tabular-nums"
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={reopen.isPending || !shiftReady}
|
onClick={() => onPick(s.identity)}
|
||||||
onClick={() => handleReopen(s)}
|
className="flex flex-1 items-center gap-3 text-left hover:text-term-amber"
|
||||||
className="btn btn-pay btn-sm shrink-0"
|
title={t("booth.openPayExit")}
|
||||||
title={shiftReady ? t("booth.openBarrierTitle") : t("shift.gateTitle")}
|
|
||||||
>
|
>
|
||||||
{t("booth.openBarrier")}
|
<span className="text-term-text">
|
||||||
|
{s.subscription ? `★ ${s.subscriptionHolder ?? t("subs.unnamed")}` : s.identity}
|
||||||
|
</span>
|
||||||
|
{s.plate && (
|
||||||
|
<span
|
||||||
|
className="rounded border border-term-border px-1 font-semibold tracking-wide text-term-amber"
|
||||||
|
title={t("booth.plateTitle")}
|
||||||
|
>
|
||||||
|
{s.plate}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-term-muted">{formatRelativeDateTime(s.enteredAt, t)}</span>
|
||||||
|
<span className="text-term-muted">{formatDuration(s.enteredAt, new Date().toISOString())}</span>
|
||||||
|
<span
|
||||||
|
className={`ml-auto w-16 text-right font-semibold uppercase ${badge.cls}`}
|
||||||
|
title={badge.titleKey ? t(badge.titleKey) : undefined}
|
||||||
|
>
|
||||||
|
{t(badge.key)}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
) : (
|
|
||||||
<span className="w-[88px] shrink-0" />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{msg && (
|
{/* Open barrier — PAID-and-still-in-grace transient OR a SUBSCRIPTION
|
||||||
<span className={`shrink-0 text-[10px] ${msg.ok ? "text-term-green" : "text-term-red"}`}>
|
(prepaid). NOT an OVERSTAY session: its grace has expired, so the car
|
||||||
{msg.text}
|
owes a top-up — the row routes to the pay/exit modal instead (no
|
||||||
</span>
|
free overstay exit). An unpaid transient also has no button
|
||||||
)}
|
(no-unpaid-bypass). Mirrors reopenBarrier's server-side guard. */}
|
||||||
</div>
|
{(s.paidAt && !s.overstay) || s.subscription ? (
|
||||||
);
|
<button
|
||||||
})
|
type="button"
|
||||||
)}
|
disabled={reopen.isPending || !shiftReady}
|
||||||
|
onClick={() => handleReopen(s)}
|
||||||
|
className="btn btn-pay btn-sm shrink-0"
|
||||||
|
title={shiftReady ? t("booth.openBarrierTitle") : t("shift.gateTitle")}
|
||||||
|
>
|
||||||
|
{t("booth.openBarrier")}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="w-[88px] shrink-0" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{msg && (
|
||||||
|
<span className={`shrink-0 text-[10px] ${msg.ok ? "text-term-green" : "text-term-red"}`}>
|
||||||
|
{msg.text}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -52,9 +52,24 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
|||||||
|
|
||||||
const alreadyPaid = s?.paidAt != null;
|
const alreadyPaid = s?.paidAt != null;
|
||||||
const isSubscription = s?.subscription === true;
|
const isSubscription = s?.subscription === true;
|
||||||
// A subscription is prepaid: never charged. The only booth action is an audited
|
// OVERSTAY = paid but walk-back grace expired with no exit → a NEW period began; owes
|
||||||
// barrier open to ASSIST (faulty exit reader / lost card). Transient pay path is off.
|
// a fresh TOP-UP. Treat it as payable even though it's "already paid": the car must
|
||||||
const canPay = shiftReady && s?.found && s.open && !alreadyPaid && !isSubscription;
|
// settle the new period's fee (s.amountMinor, priced from grace-expiry) before any
|
||||||
|
// exit. A normal within-grace paid session is NOT payable (it's settled). See
|
||||||
|
// booth-exit-flow.md / reopenBarrier server guard.
|
||||||
|
const isOverstay = s?.overstay === true;
|
||||||
|
// A subscription is normally prepaid (never charged). EXCEPTION: a time-window plan can
|
||||||
|
// owe an out-of-window TARIFF-BRIDGE charge (early entry / late exit) — lookup() returns
|
||||||
|
// it as s.amountMinor, and exit is GATED until it's paid. So a subscription IS payable
|
||||||
|
// when it has an amount due. Otherwise the only action is an audited assist-open.
|
||||||
|
const subWindowDue = !!(isSubscription && (s?.amountMinor ?? 0) > 0);
|
||||||
|
// Allow pay for an unpaid transient, an overstay top-up, or a subscriber window charge.
|
||||||
|
const canPay = !!(
|
||||||
|
shiftReady &&
|
||||||
|
s?.found &&
|
||||||
|
s.open &&
|
||||||
|
((!alreadyPaid && !isSubscription) || isOverstay || subWindowDue)
|
||||||
|
);
|
||||||
|
|
||||||
async function handleOpenBarrier() {
|
async function handleOpenBarrier() {
|
||||||
if (!s) return;
|
if (!s) return;
|
||||||
@@ -103,8 +118,11 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
|||||||
if (!s) return;
|
if (!s) return;
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
// 1. Take payment (unless already paid — e.g. paid earlier at a kiosk).
|
// 1. Take payment. For a first stay this is the only charge; for an OVERSTAY the
|
||||||
if (!alreadyPaid) {
|
// session is "already paid" but a new period accrued — we still charge (canPay
|
||||||
|
// is true). A settled within-grace session is not payable (canPay false) and is
|
||||||
|
// skipped. The server re-quotes authoritatively (overstay → from grace-expiry).
|
||||||
|
if (canPay) {
|
||||||
setPhase("paying");
|
setPhase("paying");
|
||||||
await paySession(identity, tender);
|
await paySession(identity, tender);
|
||||||
}
|
}
|
||||||
@@ -221,34 +239,66 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
|||||||
/>
|
/>
|
||||||
<Row
|
<Row
|
||||||
label={t("pay.statusLabel")}
|
label={t("pay.statusLabel")}
|
||||||
value={isSubscription ? t("pay.subscription") : alreadyPaid ? t("pay.paid") : t("pay.unpaid")}
|
value={
|
||||||
valueClass={isSubscription ? "text-term-cyan" : alreadyPaid ? "text-term-green" : "text-term-amber"}
|
isSubscription
|
||||||
|
? t("pay.subscription")
|
||||||
|
: isOverstay
|
||||||
|
? t("pay.overstay")
|
||||||
|
: alreadyPaid
|
||||||
|
? t("pay.paid")
|
||||||
|
: t("pay.unpaid")
|
||||||
|
}
|
||||||
|
valueClass={
|
||||||
|
isSubscription
|
||||||
|
? "text-term-cyan"
|
||||||
|
: isOverstay
|
||||||
|
? "text-term-red"
|
||||||
|
: alreadyPaid
|
||||||
|
? "text-term-green"
|
||||||
|
: "text-term-amber"
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Total — a subscription is prepaid (no amount); show a badge. */}
|
{/* Total — a subscription is prepaid (no amount) UNLESS it owes an
|
||||||
|
out-of-window window charge; then show that amount. For an overstay the
|
||||||
|
amount is the TOP-UP delta, not the whole stay. */}
|
||||||
<div className="flex items-end justify-between rounded-term bg-term-panel-2 px-3 py-2">
|
<div className="flex items-end justify-between rounded-term bg-term-panel-2 px-3 py-2">
|
||||||
<span className="text-[11px] uppercase tracking-wider text-term-muted">
|
<span className="text-[11px] uppercase tracking-wider text-term-muted">
|
||||||
{isSubscription ? t("pay.plan") : t("pay.total")}
|
{subWindowDue ? t("pay.windowCharge") : isSubscription ? t("pay.plan") : isOverstay ? t("pay.topUp") : t("pay.total")}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-3xl font-bold text-term-cyan">
|
<span className="text-3xl font-bold text-term-cyan">
|
||||||
{isSubscription
|
{subWindowDue && s.amountMinor != null && s.currency
|
||||||
? t("pay.prepaid")
|
? formatMoney(s.amountMinor, s.currency)
|
||||||
: s.amountMinor != null && s.currency
|
: isSubscription
|
||||||
? formatMoney(s.amountMinor, s.currency)
|
? t("pay.prepaid")
|
||||||
: alreadyPaid
|
: s.amountMinor != null && s.currency
|
||||||
? t("booth.badgePaid")
|
? formatMoney(s.amountMinor, s.currency)
|
||||||
: t("pay.noTariff")}
|
: alreadyPaid
|
||||||
|
? t("booth.badgePaid")
|
||||||
|
: t("pay.noTariff")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* For a subscription, explain the only available action. */}
|
{/* For a subscription with a window charge, explain why it's payable. For a
|
||||||
{isSubscription && (
|
plain prepaid subscription, explain the assist-open is the only action. */}
|
||||||
|
{subWindowDue ? (
|
||||||
|
<div className="rounded-term border border-term-amber/40 bg-term-amber/5 px-3 py-2 text-[12px] text-term-text">
|
||||||
|
{t("pay.windowChargeHint")}
|
||||||
|
</div>
|
||||||
|
) : isSubscription && (
|
||||||
<div className="rounded-term border border-term-cyan/40 bg-term-cyan/5 px-3 py-2 text-[12px] text-term-text">
|
<div className="rounded-term border border-term-cyan/40 bg-term-cyan/5 px-3 py-2 text-[12px] text-term-text">
|
||||||
{t("pay.subAssistHint")}
|
{t("pay.subAssistHint")}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* For an overstay, explain why a top-up is required (no free exit). */}
|
||||||
|
{isOverstay && (
|
||||||
|
<div className="rounded-term border border-term-red/40 bg-term-red/5 px-3 py-2 text-[12px] text-term-text">
|
||||||
|
{t("pay.overstayHint")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Snapshots */}
|
{/* Snapshots */}
|
||||||
<SnapshotStrip identity={identity} />
|
<SnapshotStrip identity={identity} />
|
||||||
|
|
||||||
|
|||||||
+111
-12
@@ -12,6 +12,7 @@ import { BoothPayModal } from "./BoothPayModal.js";
|
|||||||
import { ActiveSessions } from "./ActiveSessions.js";
|
import { ActiveSessions } from "./ActiveSessions.js";
|
||||||
import { Modal } from "./ui/Modal.js";
|
import { Modal } from "./ui/Modal.js";
|
||||||
import { SnapshotStrip } from "./ui/SnapshotStrip.js";
|
import { SnapshotStrip } from "./ui/SnapshotStrip.js";
|
||||||
|
import { FilterBar, SegGroup, type SegOption } from "./ui/FilterBar.js";
|
||||||
import { renderReason } from "./lib/reason.js";
|
import { renderReason } from "./lib/reason.js";
|
||||||
|
|
||||||
// The live operator booth view — the real-time heart of the console. Occupancy
|
// The live operator booth view — the real-time heart of the console. Occupancy
|
||||||
@@ -30,9 +31,32 @@ const EVENT_STYLE: Record<string, { labelKey: string; color: string }> = {
|
|||||||
shift_open: { labelKey: "booth.evtShiftOpen", color: "text-term-amber" },
|
shift_open: { labelKey: "booth.evtShiftOpen", color: "text-term-amber" },
|
||||||
shift_z_report: { labelKey: "booth.evtShiftZ", color: "text-term-amber" },
|
shift_z_report: { labelKey: "booth.evtShiftZ", color: "text-term-amber" },
|
||||||
cash_movement: { labelKey: "booth.evtCashMovement", color: "text-term-cyan" },
|
cash_movement: { labelKey: "booth.evtCashMovement", color: "text-term-cyan" },
|
||||||
|
cash_in: { labelKey: "booth.evtCashIn", color: "text-term-green" },
|
||||||
|
cash_out: { labelKey: "booth.evtCashOut", color: "text-term-amber" },
|
||||||
anomaly: { labelKey: "booth.evtAnomaly", color: "text-term-red" },
|
anomaly: { labelKey: "booth.evtAnomaly", color: "text-term-red" },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Live-feed filter category for an event type. Several ledger types collapse into a
|
||||||
|
// few operator-meaningful buckets; the rest (barrier/shift/cash) fall outside the
|
||||||
|
// filter and only show under "all".
|
||||||
|
type FeedCat = "entry" | "exit" | "pay" | "void" | "anomaly";
|
||||||
|
function feedCat(type: string): FeedCat | null {
|
||||||
|
switch (type) {
|
||||||
|
case "vehicle_entry":
|
||||||
|
return "entry";
|
||||||
|
case "vehicle_exit":
|
||||||
|
return "exit";
|
||||||
|
case "payment":
|
||||||
|
return "pay";
|
||||||
|
case "void":
|
||||||
|
return "void";
|
||||||
|
case "anomaly":
|
||||||
|
return "anomaly";
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function hhmmss(iso: string): string {
|
function hhmmss(iso: string): string {
|
||||||
// Local time-of-day, terminal style. Defensive against a bad timestamp.
|
// Local time-of-day, terminal style. Defensive against a bad timestamp.
|
||||||
const d = new Date(iso);
|
const d = new Date(iso);
|
||||||
@@ -87,7 +111,11 @@ function eventBadges(p: LedgerEvent["payload"]): string[] {
|
|||||||
if (p.exitOpenFailed) keys.push("booth.badgeBarrierFailed");
|
if (p.exitOpenFailed) keys.push("booth.badgeBarrierFailed");
|
||||||
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.source === "manual") keys.push("booth.badgeManualOpen");
|
if (p.subscriptionSale) keys.push("booth.badgeSubSale");
|
||||||
|
// Subscriber entered/exited outside their plan's allowed window → owes a deferred
|
||||||
|
// transient charge, collected (gated) at exit. Flag it so the operator KNOWS now.
|
||||||
|
if (typeof p.windowOwedMinor === "number" && p.windowOwedMinor > 0) keys.push("booth.badgeWindowCharge");
|
||||||
|
if (p.source === "manual" && !p.subscriptionSale) keys.push("booth.badgeManualOpen");
|
||||||
return keys;
|
return keys;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,7 +171,17 @@ function EventRow({ e, onOpen }: { e: LedgerEvent; onOpen: (e: LedgerEvent) => v
|
|||||||
>
|
>
|
||||||
<span className="text-term-muted">{hhmmss(e.occurredAt)}</span>
|
<span className="text-term-muted">{hhmmss(e.occurredAt)}</span>
|
||||||
<span className={`shrink-0 font-semibold ${style?.color ?? "text-term-text"}`}>{label}</span>
|
<span className={`shrink-0 font-semibold ${style?.color ?? "text-term-text"}`}>{label}</span>
|
||||||
<span className="truncate text-term-text">{displayIdentity(e)}</span>
|
<span className="flex min-w-0 items-center gap-2">
|
||||||
|
<span className="truncate text-term-text">{displayIdentity(e)}</span>
|
||||||
|
{e.plate && (
|
||||||
|
<span
|
||||||
|
className="shrink-0 rounded border border-term-border px-1 text-[11px] font-semibold tracking-wide text-term-amber"
|
||||||
|
title={t("booth.plateTitle")}
|
||||||
|
>
|
||||||
|
{e.plate}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
<span className="text-term-muted">#{e.index}</span>
|
<span className="text-term-muted">#{e.index}</span>
|
||||||
{showDetail && (
|
{showDetail && (
|
||||||
<div className="col-start-3 col-end-5 flex flex-wrap items-center gap-x-2 gap-y-1">
|
<div className="col-start-3 col-end-5 flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||||
@@ -372,6 +410,12 @@ export function BoothScreen() {
|
|||||||
// The ledger event open in the read-only detail modal (null = closed).
|
// The ledger event open in the read-only detail modal (null = closed).
|
||||||
const [detailEvent, setDetailEvent] = useState<LedgerEvent | null>(null);
|
const [detailEvent, setDetailEvent] = useState<LedgerEvent | null>(null);
|
||||||
|
|
||||||
|
// Live-feed filters: free-text search, event category, and direction/source.
|
||||||
|
const [feedSearch, setFeedSearch] = useState("");
|
||||||
|
const [feedType, setFeedType] = useState<FeedCat | "">("");
|
||||||
|
const [feedDir, setFeedDir] = useState<"entry" | "exit" | "">("");
|
||||||
|
const [feedSrc, setFeedSrc] = useState<"booth" | "reader" | "">("");
|
||||||
|
|
||||||
// Live overlays from the WS store.
|
// Live overlays from the WS store.
|
||||||
const liveOcc = useLiveStore((s) => s.occupancy);
|
const liveOcc = useLiveStore((s) => s.occupancy);
|
||||||
const liveFeed = useLiveStore((s) => s.feed);
|
const liveFeed = useLiveStore((s) => s.feed);
|
||||||
@@ -385,11 +429,45 @@ export function BoothScreen() {
|
|||||||
const seen = new Set(liveFeed.map((e) => e.id));
|
const seen = new Set(liveFeed.map((e) => e.id));
|
||||||
const history = (eventsQuery.data?.events ?? []).filter((e) => !seen.has(e.id));
|
const history = (eventsQuery.data?.events ?? []).filter((e) => !seen.has(e.id));
|
||||||
const merged = [...liveFeed, ...history].slice(0, 200);
|
const merged = [...liveFeed, ...history].slice(0, 200);
|
||||||
const events =
|
const scoped =
|
||||||
shiftOpen && shiftStart
|
shiftOpen && shiftStart
|
||||||
? merged.filter((e) => e.occurredAt >= shiftStart)
|
? merged.filter((e) => e.occurredAt >= shiftStart)
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
|
// Apply the live-feed filters. Source maps to booth (operator-initiated `manual`)
|
||||||
|
// vs reader (device-initiated: wiegand/lpr/qr/ticket). Search spans identity,
|
||||||
|
// subscriber label, and any advisory plate on the payload.
|
||||||
|
const fq = feedSearch.trim().toLowerCase();
|
||||||
|
const events = scoped.filter((e) => {
|
||||||
|
if (feedType && feedCat(e.type) !== feedType) return false;
|
||||||
|
if (feedDir && e.direction !== feedDir) return false;
|
||||||
|
if (feedSrc) {
|
||||||
|
const isBooth = e.source === "manual";
|
||||||
|
if (feedSrc === "booth" ? !isBooth : isBooth) return false;
|
||||||
|
}
|
||||||
|
if (fq) {
|
||||||
|
const hay = `${e.identity ?? ""} ${e.subscriberLabel ?? ""} ${e.payload?.plate ?? ""}`.toLowerCase();
|
||||||
|
if (!hay.includes(fq)) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const feedTypeOpts: SegOption<FeedCat>[] = [
|
||||||
|
{ value: "entry", label: t("booth.fEvtEntry") },
|
||||||
|
{ value: "exit", label: t("booth.fEvtExit") },
|
||||||
|
{ value: "pay", label: t("booth.fEvtPay") },
|
||||||
|
{ value: "void", label: t("booth.fEvtVoid") },
|
||||||
|
{ value: "anomaly", label: t("booth.fEvtAnomaly") },
|
||||||
|
];
|
||||||
|
const feedDirOpts: SegOption<"entry" | "exit">[] = [
|
||||||
|
{ value: "entry", label: t("booth.fDirEntry") },
|
||||||
|
{ value: "exit", label: t("booth.fDirExit") },
|
||||||
|
];
|
||||||
|
const feedSrcOpts: SegOption<"booth" | "reader">[] = [
|
||||||
|
{ value: "booth", label: t("booth.fSrcBooth") },
|
||||||
|
{ value: "reader", label: t("booth.fSrcReader") },
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid h-full grid-cols-1 gap-3 lg:grid-cols-[minmax(320px,1fr)_2fr] lg:grid-rows-[auto_1fr]">
|
<div className="grid h-full grid-cols-1 gap-3 lg:grid-cols-[minmax(320px,1fr)_2fr] lg:grid-rows-[auto_1fr]">
|
||||||
{/* Ticket input spans both columns at the top — the operator's primary action. */}
|
{/* Ticket input spans both columns at the top — the operator's primary action. */}
|
||||||
@@ -408,7 +486,7 @@ export function BoothScreen() {
|
|||||||
<div className="text-term-muted">{occQuery.isError ? t("booth.occUnavailable") : t("common.loading")}</div>
|
<div className="text-term-muted">{occQuery.isError ? t("booth.occUnavailable") : t("common.loading")}</div>
|
||||||
)}
|
)}
|
||||||
</Panel>
|
</Panel>
|
||||||
<div className="min-h-0 flex-1">
|
<div className="flex min-h-0 flex-1 flex-col">
|
||||||
<ActiveSessions onPick={setActiveTicket} />
|
<ActiveSessions onPick={setActiveTicket} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -417,19 +495,40 @@ export function BoothScreen() {
|
|||||||
title={t("booth.liveFeed")}
|
title={t("booth.liveFeed")}
|
||||||
right={
|
right={
|
||||||
<span className="text-[10px] uppercase tracking-wider text-term-muted">
|
<span className="text-[10px] uppercase tracking-wider text-term-muted">
|
||||||
{events.length} {t("booth.events")}
|
{events.length}
|
||||||
|
{events.length !== scoped.length ? `/${scoped.length}` : ""} {t("booth.events")}
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
className="min-h-0"
|
className="min-h-0"
|
||||||
>
|
>
|
||||||
<div className="h-full overflow-y-auto pr-1">
|
<div className="flex h-full flex-col">
|
||||||
{!shiftOpen ? (
|
{shiftOpen && (
|
||||||
<div className="text-term-amber">{t("shift.gateTitle")}</div>
|
<FilterBar search={feedSearch} onSearch={setFeedSearch} searchPlaceholder={t("booth.filterSearchFeed")}>
|
||||||
) : events.length === 0 ? (
|
<SegGroup
|
||||||
<div className="text-term-muted">{eventsQuery.isLoading ? t("common.loading") : t("booth.noEventsYet")}</div>
|
value={feedType}
|
||||||
) : (
|
options={feedTypeOpts}
|
||||||
events.map((e) => <EventRow key={e.id} e={e} onOpen={setDetailEvent} />)
|
onChange={setFeedType}
|
||||||
|
allLabel={t("booth.filterAll")}
|
||||||
|
/>
|
||||||
|
<SegGroup value={feedDir} options={feedDirOpts} onChange={setFeedDir} allLabel={t("booth.filterAll")} />
|
||||||
|
<SegGroup value={feedSrc} options={feedSrcOpts} onChange={setFeedSrc} allLabel={t("booth.filterAll")} />
|
||||||
|
</FilterBar>
|
||||||
)}
|
)}
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto pr-1">
|
||||||
|
{!shiftOpen ? (
|
||||||
|
<div className="text-term-amber">{t("shift.gateTitle")}</div>
|
||||||
|
) : events.length === 0 ? (
|
||||||
|
<div className="text-term-muted">
|
||||||
|
{eventsQuery.isLoading
|
||||||
|
? t("common.loading")
|
||||||
|
: scoped.length === 0
|
||||||
|
? t("booth.noEventsYet")
|
||||||
|
: t("booth.noMatch")}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
events.map((e) => <EventRow key={e.id} e={e} onOpen={setDetailEvent} />)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
|
|||||||
@@ -1,27 +1,39 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { closeShift, fetchShift, openShift, recordCashMovement, type ShiftReport } from "./api.js";
|
import {
|
||||||
|
closeShift,
|
||||||
|
fetchShift,
|
||||||
|
fetchShiftReport,
|
||||||
|
openShift,
|
||||||
|
recordCashVoucher,
|
||||||
|
type ShiftReport,
|
||||||
|
type XReport,
|
||||||
|
} from "./api.js";
|
||||||
|
|
||||||
// Manned-mode shift control. Start/End are explicit (not time-based — see
|
// Manned-mode shift control. Start/End are explicit (not time-based — see
|
||||||
// wiki/concepts/shift.md). End Shift signs + prints a Z-report and shows the
|
// wiki/concepts/shift.md). End Shift signs + prints a Z-report and shows the
|
||||||
// totals + the DRAWER picture (opening float carried from the prior shift, cash
|
// totals + the DRAWER picture (opening float carried from the prior shift, cash
|
||||||
// taken/added/removed, expected drawer). Admins can load/remove drawer cash.
|
// taken/added/removed, expected drawer). Operators RAISE a drawer cash voucher
|
||||||
|
// (Mandat Arkëtimi / Mandat Pagese); an admin AUTHORIZES it with their password.
|
||||||
// Available to cashier/operator/admin (readonly has no shift).
|
// Available to cashier/operator/admin (readonly has no shift).
|
||||||
|
|
||||||
const money = (m: number, cur: string | null) => `${(m / 100).toFixed(2)} ${cur ?? ""}`.trim();
|
const money = (m: number, cur: string | null) => `${(m / 100).toFixed(2)} ${cur ?? ""}`.trim();
|
||||||
|
|
||||||
export function ShiftControl({ isAdmin = false }: { isAdmin?: boolean }) {
|
export function ShiftControl({ canVoucher = false }: { canVoucher?: boolean }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [startedAt, setStartedAt] = useState<string | null>(null);
|
const [startedAt, setStartedAt] = useState<string | null>(null);
|
||||||
const [drawerMinor, setDrawerMinor] = useState<number | null>(null);
|
const [drawerMinor, setDrawerMinor] = useState<number | null>(null);
|
||||||
const [currency, setCurrency] = useState<string | null>(null);
|
const [currency, setCurrency] = useState<string | null>(null);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [report, setReport] = useState<ShiftReport | null>(null);
|
const [report, setReport] = useState<ShiftReport | null>(null);
|
||||||
|
const [xReport, setXReport] = useState<XReport | null>(null);
|
||||||
const [err, setErr] = useState<string | null>(null);
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
|
||||||
// Cash-movement form (admin only).
|
// Drawer-voucher form. Operator raises; an admin authorizes (name + password).
|
||||||
const [moveAmount, setMoveAmount] = useState("");
|
const [moveAmount, setMoveAmount] = useState("");
|
||||||
const [moveReason, setMoveReason] = useState("");
|
const [moveReason, setMoveReason] = useState("");
|
||||||
|
const [authName, setAuthName] = useState("");
|
||||||
|
const [authPassword, setAuthPassword] = useState("");
|
||||||
const [moveMsg, setMoveMsg] = useState<string | null>(null);
|
const [moveMsg, setMoveMsg] = useState<string | null>(null);
|
||||||
|
|
||||||
function refresh() {
|
function refresh() {
|
||||||
@@ -41,6 +53,7 @@ export function ShiftControl({ isAdmin = false }: { isAdmin?: boolean }) {
|
|||||||
setBusy(true);
|
setBusy(true);
|
||||||
setErr(null);
|
setErr(null);
|
||||||
setReport(null);
|
setReport(null);
|
||||||
|
setXReport(null);
|
||||||
try {
|
try {
|
||||||
const { startedAt } = await openShift();
|
const { startedAt } = await openShift();
|
||||||
setStartedAt(startedAt);
|
setStartedAt(startedAt);
|
||||||
@@ -54,6 +67,7 @@ export function ShiftControl({ isAdmin = false }: { isAdmin?: boolean }) {
|
|||||||
async function end() {
|
async function end() {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setErr(null);
|
setErr(null);
|
||||||
|
setXReport(null);
|
||||||
try {
|
try {
|
||||||
const z = await closeShift();
|
const z = await closeShift();
|
||||||
setReport(z);
|
setReport(z);
|
||||||
@@ -65,19 +79,42 @@ export function ShiftControl({ isAdmin = false }: { isAdmin?: boolean }) {
|
|||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Mid-shift X-report: read-only "takings so far" (appends nothing). Re-fetched on
|
||||||
|
// each click so it's always current.
|
||||||
|
async function viewReport() {
|
||||||
|
setErr(null);
|
||||||
|
try {
|
||||||
|
setXReport(await fetchShiftReport());
|
||||||
|
} catch (e) {
|
||||||
|
setErr((e as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function move(sign: 1 | -1) {
|
async function voucher(type: "cash_in" | "cash_out") {
|
||||||
setMoveMsg(null);
|
setMoveMsg(null);
|
||||||
const major = Number(moveAmount);
|
const major = Number(moveAmount);
|
||||||
if (!Number.isFinite(major) || major <= 0) {
|
if (!Number.isFinite(major) || major <= 0) {
|
||||||
setMoveMsg(t("shift.enterPositive"));
|
setMoveMsg(t("shift.enterPositive"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!authName.trim() || !authPassword) {
|
||||||
|
setMoveMsg(t("shift.authRequired"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const r = await recordCashMovement(sign * Math.round(major * 100), moveReason.trim());
|
const r = await recordCashVoucher({
|
||||||
|
type,
|
||||||
|
amountMinor: Math.round(major * 100),
|
||||||
|
reason: moveReason.trim(),
|
||||||
|
authorizedBy: authName.trim(),
|
||||||
|
authorizerPassword: authPassword,
|
||||||
|
});
|
||||||
setMoveAmount("");
|
setMoveAmount("");
|
||||||
setMoveReason("");
|
setMoveReason("");
|
||||||
setMoveMsg(t("shift.drawerNow", { amount: money(r.balanceMinor, currency) }));
|
setAuthPassword("");
|
||||||
|
setMoveMsg(
|
||||||
|
t("shift.voucherRecorded", { no: r.voucherNo, amount: money(r.balanceMinor, currency) }),
|
||||||
|
);
|
||||||
refresh();
|
refresh();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setMoveMsg((e as Error).message);
|
setMoveMsg((e as Error).message);
|
||||||
@@ -92,6 +129,9 @@ export function ShiftControl({ isAdmin = false }: { isAdmin?: boolean }) {
|
|||||||
<>
|
<>
|
||||||
<span className="font-semibold text-term-green">{t("shift.open")}</span>
|
<span className="font-semibold text-term-green">{t("shift.open")}</span>
|
||||||
<span className="text-term-muted">{t("shift.since")} {new Date(startedAt).toLocaleString()}</span>
|
<span className="text-term-muted">{t("shift.since")} {new Date(startedAt).toLocaleString()}</span>
|
||||||
|
<button type="button" className="btn btn-sm" onClick={viewReport} disabled={busy}>
|
||||||
|
{t("shift.viewTakings")}
|
||||||
|
</button>
|
||||||
<button type="button" className="btn btn-sm btn-danger" onClick={end} disabled={busy}>
|
<button type="button" className="btn btn-sm btn-danger" onClick={end} disabled={busy}>
|
||||||
{busy ? t("shift.ending") : t("shift.endShift")}
|
{busy ? t("shift.ending") : t("shift.endShift")}
|
||||||
</button>
|
</button>
|
||||||
@@ -115,11 +155,12 @@ export function ShiftControl({ isAdmin = false }: { isAdmin?: boolean }) {
|
|||||||
|
|
||||||
{err && <p className="mt-2 text-[12px] text-term-red">{err}</p>}
|
{err && <p className="mt-2 text-[12px] text-term-red">{err}</p>}
|
||||||
|
|
||||||
{/* Admin: load / remove physical drawer cash (signed cash_movement). */}
|
{/* Drawer cash voucher: operator RAISES, an admin AUTHORIZES (name + password).
|
||||||
{isAdmin && (
|
cash_in = Mandat Arkëtimi (pay-IN), cash_out = Mandat Pagese (pay-OUT). */}
|
||||||
|
{canVoucher && (
|
||||||
<div className="mt-4 border-t border-term-border pt-3">
|
<div className="mt-4 border-t border-term-border pt-3">
|
||||||
<div className="mb-1.5 text-[11px] uppercase tracking-wider text-term-muted">
|
<div className="mb-1.5 text-[11px] uppercase tracking-wider text-term-muted">
|
||||||
{t("shift.drawerCashAdmin")}
|
{t("shift.drawerVoucher")}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<input
|
<input
|
||||||
@@ -135,13 +176,58 @@ export function ShiftControl({ isAdmin = false }: { isAdmin?: boolean }) {
|
|||||||
onChange={(e) => setMoveReason(e.target.value)}
|
onChange={(e) => setMoveReason(e.target.value)}
|
||||||
placeholder={t("shift.reasonPlaceholder")}
|
placeholder={t("shift.reasonPlaceholder")}
|
||||||
/>
|
/>
|
||||||
<button type="button" className="btn btn-go btn-sm" onClick={() => move(1)}>{t("shift.load")}</button>
|
|
||||||
<button type="button" className="btn btn-danger btn-sm" onClick={() => move(-1)}>{t("shift.remove")}</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
{/* Admin sign-off — the float can only move with an admin's authorization. */}
|
||||||
|
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||||
|
<input
|
||||||
|
className="input w-36"
|
||||||
|
value={authName}
|
||||||
|
onChange={(e) => setAuthName(e.target.value)}
|
||||||
|
placeholder={t("shift.authName")}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className="input w-36"
|
||||||
|
type="password"
|
||||||
|
value={authPassword}
|
||||||
|
onChange={(e) => setAuthPassword(e.target.value)}
|
||||||
|
placeholder={t("shift.authPassword")}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
<button type="button" className="btn btn-go btn-sm" onClick={() => voucher("cash_in")}>
|
||||||
|
{t("shift.mandatArketimi")}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-danger btn-sm" onClick={() => voucher("cash_out")}>
|
||||||
|
{t("shift.mandatPagese")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-[11px] text-term-muted">{t("shift.voucherHint")}</div>
|
||||||
{moveMsg && <div className="mt-1.5 text-[12px] text-term-muted">{moveMsg}</div>}
|
{moveMsg && <div className="mt-1.5 text-[12px] text-term-muted">{moveMsg}</div>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Mid-shift X-report — read-only "takings so far" (no event appended). */}
|
||||||
|
{xReport && (
|
||||||
|
<div className="mt-4 rounded-term border border-term-cyan/40 bg-term-bg p-3 text-[12px] tabular-nums">
|
||||||
|
<div className="font-semibold text-term-cyan">{t("shift.xReport")} — {xReport.operator}</div>
|
||||||
|
<div className="text-term-muted">
|
||||||
|
{t("shift.asOf")} {new Date(xReport.asOf).toLocaleString()}
|
||||||
|
</div>
|
||||||
|
<div className="text-term-text">{t("shift.payments")} {xReport.paymentCount}</div>
|
||||||
|
<div className="text-term-text">{t("shift.cash")} {money(xReport.cashTotalMinor, xReport.currency)}</div>
|
||||||
|
<div className="text-term-text">{t("shift.card")} {money(xReport.cardTotalMinor, xReport.currency)}</div>
|
||||||
|
<div className="mt-2 text-[11px] uppercase tracking-wider text-term-muted">{t("shift.drawerSection")}</div>
|
||||||
|
<div className="text-term-text">{t("shift.openingFloat")} {money(xReport.openingFloatMinor, xReport.currency)}</div>
|
||||||
|
<div className="text-term-text">{t("shift.cashTaken")} {money(xReport.cashTotalMinor, xReport.currency)}</div>
|
||||||
|
<div className="text-term-text">{t("shift.cashAdded")} {money(xReport.cashAddedMinor, xReport.currency)}</div>
|
||||||
|
<div className="text-term-text">{t("shift.cashRemoved")} {money(xReport.cashRemovedMinor, xReport.currency)}</div>
|
||||||
|
<div className="font-semibold text-term-text">
|
||||||
|
{t("shift.expectedDrawer")} {money(xReport.expectedDrawerMinor, xReport.currency)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-[11px] text-term-muted">{t("shift.xReportHint")}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{report && (
|
{report && (
|
||||||
<div className="mt-4 rounded-term border border-term-border bg-term-bg p-3 text-[12px] tabular-nums">
|
<div className="mt-4 rounded-term border border-term-border bg-term-bg p-3 text-[12px] tabular-nums">
|
||||||
<div className="font-semibold text-term-text">{t("shift.zReport")} — {report.operator}</div>
|
<div className="font-semibold text-term-text">{t("shift.zReport")} — {report.operator}</div>
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
|
|||||||
const [capInput, setCapInput] = useState("");
|
const [capInput, setCapInput] = useState("");
|
||||||
const [meta, setMeta] = useState<Record<string, string>>({});
|
const [meta, setMeta] = useState<Record<string, string>>({});
|
||||||
const [exitVoucherDefault, setExitVoucherDefault] = useState(false);
|
const [exitVoucherDefault, setExitVoucherDefault] = useState(false);
|
||||||
|
const [reserveSubs, setReserveSubs] = useState(false);
|
||||||
const [msg, setMsg] = useState<string | null>(null);
|
const [msg, setMsg] = useState<string | null>(null);
|
||||||
|
|
||||||
function reload() {
|
function reload() {
|
||||||
@@ -36,6 +37,7 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
|
|||||||
.then((c) => {
|
.then((c) => {
|
||||||
setCapInput(c.capacity == null ? "" : String(c.capacity));
|
setCapInput(c.capacity == null ? "" : String(c.capacity));
|
||||||
setExitVoucherDefault(c.exitVoucherDefault);
|
setExitVoucherDefault(c.exitVoucherDefault);
|
||||||
|
setReserveSubs(c.reserveSubscriberSpots);
|
||||||
const m: Record<string, string> = {};
|
const m: Record<string, string> = {};
|
||||||
for (const { key } of META_FIELDS) m[key] = c[key] == null ? "" : String(c[key]);
|
for (const { key } of META_FIELDS) m[key] = c[key] == null ? "" : String(c[key]);
|
||||||
setMeta(m);
|
setMeta(m);
|
||||||
@@ -49,6 +51,7 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
|
|||||||
const patch: Partial<SiteConfig> = {
|
const patch: Partial<SiteConfig> = {
|
||||||
capacity: raw === "" ? null : Math.round(Number(raw)),
|
capacity: raw === "" ? null : Math.round(Number(raw)),
|
||||||
exitVoucherDefault,
|
exitVoucherDefault,
|
||||||
|
reserveSubscriberSpots: reserveSubs,
|
||||||
};
|
};
|
||||||
// Send each metadata field; "" → null is applied server-side.
|
// Send each metadata field; "" → null is applied server-side.
|
||||||
for (const { key } of META_FIELDS) (patch as Record<string, string | null>)[key] = meta[key] ?? "";
|
for (const { key } of META_FIELDS) (patch as Record<string, string | null>)[key] = meta[key] ?? "";
|
||||||
@@ -97,6 +100,18 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
|
|||||||
{t("site.printExitDefault")}
|
{t("site.printExitDefault")}
|
||||||
<span className="hint">{t("site.printExitHint")}</span>
|
<span className="hint">{t("site.printExitHint")}</span>
|
||||||
</label>
|
</label>
|
||||||
|
<label className="flex items-start gap-2 text-[12px] text-term-text">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="mt-0.5 accent-term-amber"
|
||||||
|
checked={reserveSubs}
|
||||||
|
onChange={(e) => setReserveSubs(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
{t("site.reserveSubs")}
|
||||||
|
<span className="hint block">{t("site.reserveSubsHint")}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
<div className="border-t border-term-border pt-3 text-[11px] uppercase tracking-wider text-term-muted">
|
<div className="border-t border-term-border pt-3 text-[11px] uppercase tracking-wider text-term-muted">
|
||||||
{t("site.parkDetails")}
|
{t("site.parkDetails")}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,36 +7,40 @@ import {
|
|||||||
createSubscription,
|
createSubscription,
|
||||||
deleteSubscription,
|
deleteSubscription,
|
||||||
fetchReaders,
|
fetchReaders,
|
||||||
fetchSiteConfig,
|
fetchSubscriptionPlans,
|
||||||
fetchSubscriptions,
|
fetchSubscriptions,
|
||||||
pollCapture,
|
pollCapture,
|
||||||
printSubscription,
|
printSubscription,
|
||||||
|
quoteSubscription,
|
||||||
revokeSubscription,
|
revokeSubscription,
|
||||||
updateSubscription,
|
updateSubscription,
|
||||||
type ReaderInfo,
|
type ReaderInfo,
|
||||||
type Subscription,
|
type Subscription,
|
||||||
type SubscriptionCredential,
|
type SubscriptionCredential,
|
||||||
type SubscriptionInput,
|
type SubscriptionInput,
|
||||||
|
type SubscriptionPeriod,
|
||||||
|
type SubscriptionPlan,
|
||||||
|
type SubscriptionQuote,
|
||||||
} from "./api.js";
|
} from "./api.js";
|
||||||
import { Modal } from "./ui/Modal.js";
|
import { Modal } from "./ui/Modal.js";
|
||||||
|
|
||||||
// Subscription admin. Create/edit/revoke/delete subscriptions + their credentials
|
// Subscription admin. Create/edit/revoke/delete subscriptions + their credentials
|
||||||
// (card/QR) and bound plates, and the recurring monthly price (e.g. 10,000 ALL). A
|
// (card/QR) and bound plates. A SALE is priced by selecting an admin-defined PLAN over
|
||||||
// subscription is mutable master data; every USE of it is a signed ledger event
|
// a date span — the operator never types a price (the amount is looked up: ceil(periods)
|
||||||
// elsewhere. See wiki/entities/subscription.md.
|
// × per-period price). A subscription is mutable master data; every USE of it is a
|
||||||
|
// signed ledger event elsewhere. See wiki/entities/subscription.md.
|
||||||
const DEFAULT_CURRENCY = "ALL";
|
|
||||||
|
|
||||||
interface FormState {
|
interface FormState {
|
||||||
holderName: string;
|
holderName: string;
|
||||||
contact: string;
|
contact: string;
|
||||||
priceMajor: string; // major units as typed (e.g. "10000"); "" = no price
|
planId: string; // selected plan (sells/prices it); "" = comp (no charge)
|
||||||
currency: string;
|
quantity: string; // cars covered by this one subscription (price ×N)
|
||||||
|
count: string; // HOW MANY of the plan's period (e.g. 3 months) — drives the end date
|
||||||
|
tender: "cash" | "card"; // how the sale fee is collected (cash → drawer, card → bank)
|
||||||
carBound: boolean; // false = unbound (maxConcurrent null)
|
carBound: boolean; // false = unbound (maxConcurrent null)
|
||||||
maxConcurrent: string;
|
maxConcurrent: string;
|
||||||
validFrom: string;
|
validFrom: string; // span start (date)
|
||||||
months: string; // months paid for; "" = none (use explicit validTo / open-ended)
|
validTo: string; // span end (date) — auto-filled from count, or set directly (hotel)
|
||||||
validTo: string;
|
|
||||||
credentials: SubscriptionCredential[];
|
credentials: SubscriptionCredential[];
|
||||||
platesText: string; // comma/space separated
|
platesText: string; // comma/space separated
|
||||||
}
|
}
|
||||||
@@ -46,16 +50,17 @@ function todayISODate(): string {
|
|||||||
return new Date().toISOString().slice(0, 10);
|
return new Date().toISOString().slice(0, 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
function emptyForm(defaultPriceMajor = "", currency = DEFAULT_CURRENCY): FormState {
|
function emptyForm(): FormState {
|
||||||
return {
|
return {
|
||||||
holderName: "",
|
holderName: "",
|
||||||
contact: "",
|
contact: "",
|
||||||
priceMajor: defaultPriceMajor,
|
planId: "",
|
||||||
currency,
|
quantity: "1",
|
||||||
|
count: "1",
|
||||||
|
tender: "cash",
|
||||||
carBound: true,
|
carBound: true,
|
||||||
maxConcurrent: "1",
|
maxConcurrent: "1",
|
||||||
validFrom: todayISODate(),
|
validFrom: todayISODate(),
|
||||||
months: "1",
|
|
||||||
validTo: "",
|
validTo: "",
|
||||||
credentials: [{ kind: "qr", value: "" }],
|
credentials: [{ kind: "qr", value: "" }],
|
||||||
platesText: "",
|
platesText: "",
|
||||||
@@ -65,49 +70,61 @@ function formFrom(s: Subscription): FormState {
|
|||||||
return {
|
return {
|
||||||
holderName: s.holderName ?? "",
|
holderName: s.holderName ?? "",
|
||||||
contact: s.contact ?? "",
|
contact: s.contact ?? "",
|
||||||
priceMajor: s.priceMinor != null ? String(s.priceMinor / 100) : "",
|
planId: s.planId ?? "", // edit doesn't re-sell; plan shown read-only
|
||||||
currency: s.currency ?? DEFAULT_CURRENCY,
|
quantity: String(s.quantity ?? 1),
|
||||||
|
count: "1",
|
||||||
|
tender: "cash", // edit doesn't re-collect money; tender only matters on a new sale
|
||||||
carBound: s.maxConcurrent != null,
|
carBound: s.maxConcurrent != null,
|
||||||
maxConcurrent: s.maxConcurrent != null ? String(s.maxConcurrent) : "1",
|
maxConcurrent: s.maxConcurrent != null ? String(s.maxConcurrent) : "1",
|
||||||
validFrom: s.validFrom ?? "",
|
validFrom: (s.validFrom ?? "").slice(0, 10),
|
||||||
months: "", // on edit, default to leaving the window as-is (explicit validTo below)
|
validTo: (s.validTo ?? "").slice(0, 10),
|
||||||
validTo: s.validTo ?? "",
|
|
||||||
credentials: s.credentials.length ? s.credentials : [{ kind: "qr", value: "" }],
|
credentials: s.credentials.length ? s.credentials : [{ kind: "qr", value: "" }],
|
||||||
platesText: s.plates.join(", "),
|
platesText: s.plates.join(", "),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Add whole months to a yyyy-mm-dd (clamps day overflow), → yyyy-mm-dd. Mirrors the
|
|
||||||
* server's addMonths so the form can preview the coverage end. */
|
|
||||||
function addMonthsDate(date: string, months: number): string | null {
|
|
||||||
const d = new Date(`${date}T00:00:00Z`);
|
|
||||||
if (Number.isNaN(d.getTime())) return null;
|
|
||||||
const day = d.getUTCDate();
|
|
||||||
d.setUTCMonth(d.getUTCMonth() + months);
|
|
||||||
if (d.getUTCDate() < day) d.setUTCDate(0);
|
|
||||||
return d.toISOString().slice(0, 10);
|
|
||||||
}
|
|
||||||
const STATUS_KEY: Record<Subscription["status"], string> = {
|
const STATUS_KEY: Record<Subscription["status"], string> = {
|
||||||
active: "subs.statusActive",
|
active: "subs.statusActive",
|
||||||
suspended: "subs.statusSuspended",
|
suspended: "subs.statusSuspended",
|
||||||
revoked: "subs.statusRevoked",
|
revoked: "subs.statusRevoked",
|
||||||
};
|
};
|
||||||
|
|
||||||
function toInput(f: FormState): SubscriptionInput {
|
/** A yyyy-mm-dd date → an ISO instant (UTC midnight) for the span endpoints. */
|
||||||
const major = Number(f.priceMajor);
|
function dateToISO(d: string): string | null {
|
||||||
const priceSet = f.priceMajor.trim() !== "" && Number.isFinite(major) && major >= 0;
|
if (!d.trim()) return null;
|
||||||
const monthsNum = f.months.trim() === "" ? null : Math.max(1, Math.round(Number(f.months) || 0));
|
const t = Date.parse(`${d}T00:00:00Z`);
|
||||||
|
return Number.isNaN(t) ? null : new Date(t).toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Add `count` of the plan's period to a yyyy-mm-dd start → yyyy-mm-dd end. Mirrors the
|
||||||
|
* server's whole-month clamp (Jan 31 +1mo → Feb 28) so the previewed end date matches
|
||||||
|
* what the sale will store. day/week are exact multiples of 24h. */
|
||||||
|
function addPeriods(startDate: string, period: SubscriptionPeriod, count: number): string | null {
|
||||||
|
const d = new Date(`${startDate}T00:00:00Z`);
|
||||||
|
if (Number.isNaN(d.getTime()) || count < 1) return null;
|
||||||
|
if (period === "day") d.setUTCDate(d.getUTCDate() + count);
|
||||||
|
else if (period === "week") d.setUTCDate(d.getUTCDate() + count * 7);
|
||||||
|
else {
|
||||||
|
const day = d.getUTCDate();
|
||||||
|
d.setUTCMonth(d.getUTCMonth() + count);
|
||||||
|
if (d.getUTCDate() < day) d.setUTCDate(0); // clamp month-overflow
|
||||||
|
}
|
||||||
|
return d.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toInput(f: FormState, isNew: boolean): SubscriptionInput {
|
||||||
|
const planSelected = isNew && f.planId.trim() !== "";
|
||||||
return {
|
return {
|
||||||
holderName: f.holderName.trim() || null,
|
holderName: f.holderName.trim() || null,
|
||||||
contact: f.contact.trim() || null,
|
contact: f.contact.trim() || null,
|
||||||
priceMinor: priceSet ? Math.round(major * 100) : null,
|
// A SALE: send the chosen plan; price is looked up server-side. On edit we never
|
||||||
period: "monthly",
|
// re-sell, so no planId is sent (price/plan stay frozen).
|
||||||
currency: priceSet ? f.currency.trim() || DEFAULT_CURRENCY : null,
|
planId: planSelected ? f.planId.trim() : null,
|
||||||
|
quantity: Math.max(1, Math.round(Number(f.quantity) || 1)),
|
||||||
|
tender: f.tender,
|
||||||
maxConcurrent: f.carBound ? Math.max(1, Math.round(Number(f.maxConcurrent) || 1)) : null,
|
maxConcurrent: f.carBound ? Math.max(1, Math.round(Number(f.maxConcurrent) || 1)) : null,
|
||||||
validFrom: f.validFrom.trim() || null,
|
validFrom: dateToISO(f.validFrom),
|
||||||
// months (with validFrom) drives validTo server-side; else send the explicit end.
|
validTo: dateToISO(f.validTo),
|
||||||
months: monthsNum && f.validFrom.trim() ? monthsNum : null,
|
|
||||||
validTo: f.validTo.trim() || null,
|
|
||||||
// A QR credential with a blank value is sent as { kind:'qr' } (no value) so the
|
// A QR credential with a blank value is sent as { kind:'qr' } (no value) so the
|
||||||
// server auto-generates the code. RF (and pre-existing QR) keep their value.
|
// server auto-generates the code. RF (and pre-existing QR) keep their value.
|
||||||
credentials: f.credentials
|
credentials: f.credentials
|
||||||
@@ -117,15 +134,23 @@ function toInput(f: FormState): SubscriptionInput {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PERIOD_KEY: Record<SubscriptionPlan["period"], string> = {
|
||||||
|
day: "subs.perDay",
|
||||||
|
week: "subs.perWeek",
|
||||||
|
month: "subs.perMonth",
|
||||||
|
};
|
||||||
|
|
||||||
function priceLabel(s: Subscription, t: (k: string) => string): string {
|
function priceLabel(s: Subscription, t: (k: string) => string): string {
|
||||||
if (s.priceMinor == null) return t("subs.noPrice");
|
if (s.priceMinor == null) return t("subs.noPrice");
|
||||||
return `${(s.priceMinor / 100).toLocaleString()} ${s.currency ?? ""} / ${t("subs.perMonth")}`.trim();
|
return `${(s.priceMinor / 100).toLocaleString()} ${s.currency ?? ""}`.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SubscriptionManager() {
|
export function SubscriptionManager() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [subs, setSubs] = useState<Subscription[] | null>(null);
|
const [subs, setSubs] = useState<Subscription[] | null>(null);
|
||||||
const [defaultPriceMajor, setDefaultPriceMajor] = useState("");
|
const [plans, setPlans] = useState<SubscriptionPlan[]>([]);
|
||||||
|
const [quote, setQuote] = useState<SubscriptionQuote | null>(null);
|
||||||
|
const [quoting, setQuoting] = useState(false);
|
||||||
const [editing, setEditing] = useState<string | "new" | null>(null);
|
const [editing, setEditing] = useState<string | "new" | null>(null);
|
||||||
const [form, setForm] = useState<FormState>(() => emptyForm());
|
const [form, setForm] = useState<FormState>(() => emptyForm());
|
||||||
const [msg, setMsg] = useState<{ kind: "ok" | "err"; text: string } | null>(null);
|
const [msg, setMsg] = useState<{ kind: "ok" | "err"; text: string } | null>(null);
|
||||||
@@ -142,18 +167,61 @@ export function SubscriptionManager() {
|
|||||||
}
|
}
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
reload();
|
reload();
|
||||||
// Pull the site default monthly price to pre-fill new subscriptions.
|
// Load the sellable plan catalog (the operator picks one instead of typing a price).
|
||||||
fetchSiteConfig()
|
fetchSubscriptionPlans()
|
||||||
.then((c) => {
|
.then((r) => setPlans(r.plans))
|
||||||
if (c.subscriptionMonthlyPriceMinor != null) setDefaultPriceMajor(String(c.subscriptionMonthlyPriceMinor / 100));
|
|
||||||
})
|
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
/* non-fatal — the form just won't pre-fill */
|
/* non-fatal — the form will show "no plans" */
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// The currently-selected plan (for its period, to drive the count → end-date math).
|
||||||
|
const selectedPlan = plans.find((p) => p.planId === form.planId.trim()) ?? null;
|
||||||
|
|
||||||
|
// COUNT → END DATE. When the operator types "how many periods" (e.g. 3 months), derive
|
||||||
|
// validTo = validFrom + count × plan period. Keeps the common "renew for N" case to a
|
||||||
|
// single number while the end-date field stays directly editable (the hotel case).
|
||||||
|
useEffect(() => {
|
||||||
|
if (editing !== "new" || !selectedPlan || !form.validFrom.trim()) return;
|
||||||
|
const n = Math.round(Number(form.count));
|
||||||
|
if (!Number.isFinite(n) || n < 1) return;
|
||||||
|
const end = addPeriods(form.validFrom, selectedPlan.period, n);
|
||||||
|
if (end && end !== form.validTo) setForm((f) => ({ ...f, validTo: end }));
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [editing, form.planId, form.validFrom, form.count]);
|
||||||
|
|
||||||
|
// Live server-computed quote for the sell form: ceil(periods) × per-period price.
|
||||||
|
// Debounced; re-runs when the plan or the span changes. The operator can't override
|
||||||
|
// the amount — it's whatever the server returns.
|
||||||
|
useEffect(() => {
|
||||||
|
if (editing !== "new" || !form.planId.trim() || !form.validTo.trim() || !form.validFrom.trim()) {
|
||||||
|
setQuote(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const from = dateToISO(form.validFrom);
|
||||||
|
const to = dateToISO(form.validTo);
|
||||||
|
if (!from || !to || Date.parse(to) <= Date.parse(from)) {
|
||||||
|
setQuote(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const quantity = Math.max(1, Math.round(Number(form.quantity) || 1));
|
||||||
|
let cancelled = false;
|
||||||
|
setQuoting(true);
|
||||||
|
const h = setTimeout(() => {
|
||||||
|
quoteSubscription({ planId: form.planId.trim(), validFrom: from, validTo: to, quantity })
|
||||||
|
.then((q) => !cancelled && setQuote(q))
|
||||||
|
.catch(() => !cancelled && setQuote(null))
|
||||||
|
.finally(() => !cancelled && setQuoting(false));
|
||||||
|
}, 200);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
clearTimeout(h);
|
||||||
|
};
|
||||||
|
}, [editing, form.planId, form.validFrom, form.validTo, form.quantity]);
|
||||||
|
|
||||||
function startNew() {
|
function startNew() {
|
||||||
setForm(emptyForm(defaultPriceMajor));
|
setForm(emptyForm());
|
||||||
|
setQuote(null);
|
||||||
setEditing("new");
|
setEditing("new");
|
||||||
setMsg(null);
|
setMsg(null);
|
||||||
}
|
}
|
||||||
@@ -167,21 +235,33 @@ export function SubscriptionManager() {
|
|||||||
setMsg(null);
|
setMsg(null);
|
||||||
try {
|
try {
|
||||||
if (editing === "new") {
|
if (editing === "new") {
|
||||||
const created = await createSubscription(toInput(form));
|
const created = await createSubscription(toInput(form, true));
|
||||||
setEditing(null);
|
setEditing(null);
|
||||||
reload();
|
reload();
|
||||||
|
// The recorded SALE (signed payment) — confirm the amount taken so the operator
|
||||||
|
// sees it was logged, and warn if no shift was open (the takings still recorded,
|
||||||
|
// but won't fall inside a shift Z-report until/unless one covers the time).
|
||||||
|
const sale = created.sale
|
||||||
|
? " " +
|
||||||
|
t("subs.saleRecorded", {
|
||||||
|
amount: (created.sale.amountMinor / 100).toLocaleString(),
|
||||||
|
currency: created.sale.currency ?? "",
|
||||||
|
tender: t(created.sale.tender === "card" ? "subs.tenderCard" : "subs.tenderCash"),
|
||||||
|
}) +
|
||||||
|
(created.sale.inShift ? "" : " " + t("subs.saleNoShift"))
|
||||||
|
: "";
|
||||||
// Reflect the auto-print outcome: printed OK, or saved-but-print-failed (the
|
// Reflect the auto-print outcome: printed OK, or saved-but-print-failed (the
|
||||||
// operator can use "Print code" to retry).
|
// operator can use "Print code" to retry).
|
||||||
if (created.printed) {
|
if (created.printError) {
|
||||||
setMsg({ kind: "ok", text: t("subs.savedPrinted") });
|
setMsg({ kind: "err", text: t("subs.savedPrintFailed", { error: created.printError }) + sale });
|
||||||
} else if (created.printError) {
|
} else if (created.printed) {
|
||||||
setMsg({ kind: "err", text: t("subs.savedPrintFailed", { error: created.printError }) });
|
setMsg({ kind: "ok", text: t("subs.savedPrinted") + sale });
|
||||||
} else {
|
} else {
|
||||||
setMsg({ kind: "ok", text: t("subs.saved") });
|
setMsg({ kind: "ok", text: t("subs.saved") + sale });
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (editing) await updateSubscription(editing, toInput(form));
|
if (editing) await updateSubscription(editing, toInput(form, false));
|
||||||
setEditing(null);
|
setEditing(null);
|
||||||
reload();
|
reload();
|
||||||
setMsg({ kind: "ok", text: t("subs.saved") });
|
setMsg({ kind: "ok", text: t("subs.saved") });
|
||||||
@@ -270,19 +350,6 @@ export function SubscriptionManager() {
|
|||||||
// Stop polling if the form closes or the component unmounts.
|
// Stop polling if the form closes or the component unmounts.
|
||||||
useEffect(() => clearPoll, []);
|
useEffect(() => clearPoll, []);
|
||||||
|
|
||||||
// Live coverage preview: when months + validFrom are set, show the end date and
|
|
||||||
// (if priced) the N×monthly total the operator should collect.
|
|
||||||
const monthsN = form.months.trim() === "" ? 0 : Math.max(0, Math.round(Number(form.months) || 0));
|
|
||||||
const coverageEnd = monthsN >= 1 && form.validFrom.trim() ? addMonthsDate(form.validFrom.trim(), monthsN) : null;
|
|
||||||
const priceMajorN = form.priceMajor.trim() === "" ? null : Number(form.priceMajor);
|
|
||||||
const totalDue =
|
|
||||||
coverageEnd && priceMajorN != null && Number.isFinite(priceMajorN)
|
|
||||||
? `${(priceMajorN * monthsN).toLocaleString()} ${form.currency.trim() || DEFAULT_CURRENCY}`
|
|
||||||
: null;
|
|
||||||
const coverageHint = coverageEnd
|
|
||||||
? t("subs.coverageHint", { end: coverageEnd }) + (totalDue ? ` · ${t("subs.totalDue", { total: totalDue })}` : "")
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if (!subs) return null;
|
if (!subs) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -324,18 +391,79 @@ export function SubscriptionManager() {
|
|||||||
<input className="input" value={form.holderName} onChange={(e) => setForm((f) => ({ ...f, holderName: e.target.value }))} />
|
<input className="input" value={form.holderName} onChange={(e) => setForm((f) => ({ ...f, holderName: e.target.value }))} />
|
||||||
<label className="label">{t("subs.contact")}</label>
|
<label className="label">{t("subs.contact")}</label>
|
||||||
<input className="input" value={form.contact} onChange={(e) => setForm((f) => ({ ...f, contact: e.target.value }))} />
|
<input className="input" value={form.contact} onChange={(e) => setForm((f) => ({ ...f, contact: e.target.value }))} />
|
||||||
<label className="label">{t("subs.monthlyPrice")}</label>
|
{/* PLAN — the operator selects an admin-defined plan; the price is looked up
|
||||||
<span className="flex items-center gap-2">
|
(never typed). On edit the plan/price is frozen, shown read-only. */}
|
||||||
<input
|
{editing === "new" ? (
|
||||||
className="input w-28"
|
<>
|
||||||
value={form.priceMajor}
|
<label className="label">{t("subs.plan")}</label>
|
||||||
onChange={(e) => setForm((f) => ({ ...f, priceMajor: e.target.value }))}
|
<span className="flex flex-wrap items-center gap-2">
|
||||||
inputMode="decimal"
|
<select
|
||||||
placeholder={t("subs.pricePlaceholder")}
|
className="select input w-auto"
|
||||||
/>
|
value={form.planId}
|
||||||
<input className="input w-16" value={form.currency} onChange={(e) => setForm((f) => ({ ...f, currency: e.target.value }))} />
|
onChange={(e) => setForm((f) => ({ ...f, planId: e.target.value }))}
|
||||||
<span className="text-[12px] text-term-muted">/ {t("subs.perMonth")}</span>
|
>
|
||||||
</span>
|
<option value="">{t("subs.planNone")}</option>
|
||||||
|
{plans.map((p) => (
|
||||||
|
<option key={p.planId} value={p.planId}>
|
||||||
|
{p.name} — {(p.pricePerPeriodMinor / 100).toLocaleString()} {p.currency} / {t(PERIOD_KEY[p.period])}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{plans.length === 0 && <span className="text-[12px] text-term-amber">{t("subs.planNoneAvail")}</span>}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<label className="label">{t("subs.plan")}</label>
|
||||||
|
<span className="text-[13px] text-term-text">{form.planId || t("subs.noPrice")}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{/* Quantity — cars covered by this ONE subscription (a family pays once for
|
||||||
|
N cars). Price ×N; maxConcurrent below pre-fills to it. */}
|
||||||
|
{form.planId.trim() !== "" && editing === "new" && (
|
||||||
|
<>
|
||||||
|
<label className="label">{t("subs.quantity")}</label>
|
||||||
|
<span className="flex flex-wrap items-center gap-2">
|
||||||
|
<input
|
||||||
|
className="input w-16"
|
||||||
|
value={form.quantity}
|
||||||
|
inputMode="numeric"
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, quantity: e.target.value, maxConcurrent: e.target.value }))}
|
||||||
|
/>
|
||||||
|
<span className="text-[12px] text-term-muted">{t("subs.quantityHint")}</span>
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{/* Tender — only relevant when selling a plan (a SALE). The sale appends a
|
||||||
|
signed payment so the money shows in the feed/drawer/Z-report. */}
|
||||||
|
{form.planId.trim() !== "" && editing === "new" && (
|
||||||
|
<>
|
||||||
|
<label className="label">{t("subs.tender")}</label>
|
||||||
|
<span className="flex items-center gap-3">
|
||||||
|
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-text">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="tender"
|
||||||
|
className="accent-term-amber"
|
||||||
|
checked={form.tender === "cash"}
|
||||||
|
onChange={() => setForm((f) => ({ ...f, tender: "cash" }))}
|
||||||
|
/>
|
||||||
|
{t("subs.tenderCash")}
|
||||||
|
</label>
|
||||||
|
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-text">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="tender"
|
||||||
|
className="accent-term-amber"
|
||||||
|
checked={form.tender === "card"}
|
||||||
|
onChange={() => setForm((f) => ({ ...f, tender: "card" }))}
|
||||||
|
/>
|
||||||
|
{t("subs.tenderCard")}
|
||||||
|
</label>
|
||||||
|
<span className="text-[12px] text-term-muted">{t("subs.tenderHint")}</span>
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<label className="label">{t("subs.carLimit")}</label>
|
<label className="label">{t("subs.carLimit")}</label>
|
||||||
<span className="flex items-center gap-3">
|
<span className="flex items-center gap-3">
|
||||||
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-text">
|
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-text">
|
||||||
@@ -347,21 +475,44 @@ export function SubscriptionManager() {
|
|||||||
</span>
|
</span>
|
||||||
<label className="label">{t("subs.validFrom")}</label>
|
<label className="label">{t("subs.validFrom")}</label>
|
||||||
<input type="date" className="input w-44" value={form.validFrom} onChange={(e) => setForm((f) => ({ ...f, validFrom: e.target.value }))} />
|
<input type="date" className="input w-44" value={form.validFrom} onChange={(e) => setForm((f) => ({ ...f, validFrom: e.target.value }))} />
|
||||||
<label className="label">{t("subs.months")}</label>
|
{/* HOW MANY periods (e.g. 3 months) — the common "renew for N" case. Drives the
|
||||||
|
end date below; for an irregular span the operator can edit the end directly. */}
|
||||||
|
{editing === "new" && selectedPlan && (
|
||||||
|
<>
|
||||||
|
<label className="label">{t("subs.count")}</label>
|
||||||
|
<span className="flex flex-wrap items-center gap-2">
|
||||||
|
<input
|
||||||
|
className="input w-16"
|
||||||
|
value={form.count}
|
||||||
|
inputMode="numeric"
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, count: e.target.value }))}
|
||||||
|
/>
|
||||||
|
<span className="text-[12px] text-term-muted">
|
||||||
|
× {t(PERIOD_KEY[selectedPlan.period])}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<label className="label">{t("subs.validToEnd")}</label>
|
||||||
<span className="flex flex-wrap items-center gap-2">
|
<span className="flex flex-wrap items-center gap-2">
|
||||||
<input
|
<input type="date" className="input w-44" value={form.validTo} onChange={(e) => setForm((f) => ({ ...f, validTo: e.target.value }))} />
|
||||||
className="input w-16"
|
{/* Live SERVER quote: ceil(periods) × per-period price. The operator can't
|
||||||
value={form.months}
|
override it — this is exactly what will be charged + signed. */}
|
||||||
onChange={(e) => setForm((f) => ({ ...f, months: e.target.value }))}
|
{editing === "new" && form.planId.trim() !== "" && (
|
||||||
inputMode="numeric"
|
<span className="text-[12px] text-term-cyan">
|
||||||
placeholder="1"
|
{quoting
|
||||||
/>
|
? t("subs.quoting")
|
||||||
<span className="text-[12px] text-term-muted">{t("subs.monthsHint")}</span>
|
: quote
|
||||||
{/* Live preview of the coverage end + the N×price total. */}
|
? t("subs.quoteLine", {
|
||||||
{coverageHint && <span className="text-[12px] text-term-cyan">{coverageHint}</span>}
|
periods: quote.periods,
|
||||||
|
unit: t(PERIOD_KEY[quote.period]),
|
||||||
|
amount: (quote.amountMinor / 100).toLocaleString(),
|
||||||
|
currency: quote.currency,
|
||||||
|
}) + (quote.quantity && quote.quantity > 1 ? ` (×${quote.quantity})` : "")
|
||||||
|
: t("subs.quotePrompt")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
<label className="label">{t("subs.validToOverride")}</label>
|
|
||||||
<input type="date" className="input w-44" value={form.validTo} onChange={(e) => setForm((f) => ({ ...f, validTo: e.target.value }))} />
|
|
||||||
<label className="label">{t("subs.boundPlates")}</label>
|
<label className="label">{t("subs.boundPlates")}</label>
|
||||||
<input className="input" value={form.platesText} onChange={(e) => setForm((f) => ({ ...f, platesText: e.target.value }))} placeholder={t("subs.commaSeparatedOptional")} />
|
<input className="input" value={form.platesText} onChange={(e) => setForm((f) => ({ ...f, platesText: e.target.value }))} placeholder={t("subs.commaSeparatedOptional")} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,415 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
ApiError,
|
||||||
|
createSubscriptionPlan,
|
||||||
|
deleteSubscriptionPlan,
|
||||||
|
fetchSubscriptionPlans,
|
||||||
|
fetchSubscriptions,
|
||||||
|
reactivateSubscriptionPlan,
|
||||||
|
retireSubscriptionPlan,
|
||||||
|
type PlanTimeframes,
|
||||||
|
type Subscription,
|
||||||
|
type SubscriptionPeriod,
|
||||||
|
type SubscriptionPlan,
|
||||||
|
} from "./api.js";
|
||||||
|
import { Modal } from "./ui/Modal.js";
|
||||||
|
|
||||||
|
// Admin-only subscription PLAN catalog. Plans are admin-composed, versioned config the
|
||||||
|
// operator sells from (so the operator never types a price). Editing a plan PUBLISHES A
|
||||||
|
// NEW VERSION (new effectiveFrom) — past sales keep their recorded version. Retire is
|
||||||
|
// soft (active=0). Mirrors the tariff composer. See wiki/entities/subscription.md.
|
||||||
|
|
||||||
|
const DEFAULT_CURRENCY = "ALL";
|
||||||
|
const PERIODS: SubscriptionPeriod[] = ["day", "week", "month"];
|
||||||
|
const PERIOD_KEY: Record<SubscriptionPeriod, string> = {
|
||||||
|
day: "subs.perDay",
|
||||||
|
week: "subs.perWeek",
|
||||||
|
month: "subs.perMonth",
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_KEY: Record<Subscription["status"], string> = {
|
||||||
|
active: "subs.statusActive",
|
||||||
|
suspended: "subs.statusSuspended",
|
||||||
|
revoked: "subs.statusRevoked",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** minutes-of-day → "HH:MM" for the timeframes summary. */
|
||||||
|
function fmtMin(min: number): string {
|
||||||
|
return `${String(Math.floor(min / 60)).padStart(2, "0")}:${String(min % 60).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A compact human summary of a plan's timeframes, e.g. "Mon–Fri 21:00–08:00" or "24/7".
|
||||||
|
* Uses the shared tariff.dow labels for day names. */
|
||||||
|
function timeframesSummary(tf: PlanTimeframes | null | undefined, t: (k: string) => string): string {
|
||||||
|
if (!tf) return t("plans.allHours"); // 24/7
|
||||||
|
const days = tf.days && tf.days.length > 0 ? tf.days : [0, 1, 2, 3, 4, 5, 6];
|
||||||
|
// Render selected days Monday-first; collapse to a range label only when contiguous
|
||||||
|
// Mon–Fri / Sat–Sun for the common cases, else list them.
|
||||||
|
const set = new Set(days);
|
||||||
|
const isWeekdays = [1, 2, 3, 4, 5].every((d) => set.has(d)) && ![0, 6].some((d) => set.has(d));
|
||||||
|
const dayLabel = isWeekdays
|
||||||
|
? `${t("tariff.dow1")}–${t("tariff.dow5")}`
|
||||||
|
: [1, 2, 3, 4, 5, 6, 0].filter((d) => set.has(d)).map((d) => t(`tariff.dow${d}`)).join(",");
|
||||||
|
return `${dayLabel} ${fmtMin(tf.fromMin)}–${fmtMin(tf.toMin)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Day-of-week picker, Monday-first (mirrors the tariff composer). Labels come from the
|
||||||
|
// shared tariff.dow0..6 i18n keys (Hën..Die / Mon..Sun).
|
||||||
|
const DOW_ORDER = [1, 2, 3, 4, 5, 6, 0];
|
||||||
|
|
||||||
|
interface PlanForm {
|
||||||
|
planId: string; // blank on a brand-new plan; set when publishing a new version
|
||||||
|
name: string;
|
||||||
|
period: SubscriptionPeriod;
|
||||||
|
priceMajor: string;
|
||||||
|
currency: string;
|
||||||
|
// Timeframes (tariff bridge). Off → 24/7. On → an allowed window (enter-after /
|
||||||
|
// exit-before as HH:MM) on the SELECTED days (0=Sun..6=Sat); on unselected days the
|
||||||
|
// subscriber parks free. Plus grace minutes.
|
||||||
|
restrictTimes: boolean;
|
||||||
|
days: number[]; // days the window applies to; empty = every day
|
||||||
|
winFrom: string; // window opens (HH:MM) — when the subscriber may enter
|
||||||
|
winTo: string; // window closes (HH:MM) — by when they should exit
|
||||||
|
graceMin: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyForm(): PlanForm {
|
||||||
|
return {
|
||||||
|
planId: "",
|
||||||
|
name: "",
|
||||||
|
period: "month",
|
||||||
|
priceMajor: "",
|
||||||
|
currency: DEFAULT_CURRENCY,
|
||||||
|
restrictTimes: false,
|
||||||
|
days: [1, 2, 3, 4, 5], // default Mon–Fri (the common "night plan, free weekends")
|
||||||
|
winFrom: "20:00",
|
||||||
|
winTo: "08:00",
|
||||||
|
graceMin: "0",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "HH:MM" → minutes-of-day, or null if blank/invalid. */
|
||||||
|
function hhmmToMin(s: string): number | null {
|
||||||
|
const m = /^(\d{1,2}):(\d{2})$/.exec(s.trim());
|
||||||
|
if (!m) return null;
|
||||||
|
const min = Number(m[1]) * 60 + Number(m[2]);
|
||||||
|
return min >= 0 && min <= 1439 ? min : null;
|
||||||
|
}
|
||||||
|
/** minutes-of-day → "HH:MM". */
|
||||||
|
function minToHHMM(min: number): string {
|
||||||
|
return `${String(Math.floor(min / 60)).padStart(2, "0")}:${String(min % 60).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SubscriptionPlansManager() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [plans, setPlans] = useState<SubscriptionPlan[] | null>(null);
|
||||||
|
const [subs, setSubs] = useState<Subscription[]>([]);
|
||||||
|
const [expanded, setExpanded] = useState<string | null>(null); // planId whose subscribers are shown
|
||||||
|
const [form, setForm] = useState<PlanForm | null>(null);
|
||||||
|
const [msg, setMsg] = useState<{ kind: "ok" | "err"; text: string } | null>(null);
|
||||||
|
|
||||||
|
function reload() {
|
||||||
|
// ?all=1 → every version (history), so the admin sees superseded prices too.
|
||||||
|
fetchSubscriptionPlans(true)
|
||||||
|
.then((r) => setPlans(r.plans))
|
||||||
|
.catch((e) => setMsg({ kind: "err", text: (e as Error).message }));
|
||||||
|
// Subscriptions carry planId — group them to show "who depends on this plan".
|
||||||
|
fetchSubscriptions()
|
||||||
|
.then((r) => setSubs(r.subscriptions))
|
||||||
|
.catch(() => {
|
||||||
|
/* non-fatal — counts just won't show */
|
||||||
|
});
|
||||||
|
}
|
||||||
|
useEffect(reload, []);
|
||||||
|
|
||||||
|
// Subscribers per planId (active first), for the count badge + expandable list.
|
||||||
|
function subscribersOf(planId: string): Subscription[] {
|
||||||
|
return subs.filter((s) => s.planId === planId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (!form) return;
|
||||||
|
setMsg(null);
|
||||||
|
const major = Number(form.priceMajor);
|
||||||
|
if (!form.name.trim()) return setMsg({ kind: "err", text: t("plans.needName") });
|
||||||
|
if (!Number.isFinite(major) || major <= 0) return setMsg({ kind: "err", text: t("plans.needPrice") });
|
||||||
|
// Build the timeframes blob from the form (null = 24/7). The window [winFrom, winTo)
|
||||||
|
// (wraps midnight for a night plan) applies on the SELECTED days; unselected days are
|
||||||
|
// unrestricted. Empty days = every day. The server stamps the site tz.
|
||||||
|
let timeframes = null as Parameters<typeof createSubscriptionPlan>[0]["timeframes"];
|
||||||
|
if (form.restrictTimes) {
|
||||||
|
const from = hhmmToMin(form.winFrom);
|
||||||
|
const to = hhmmToMin(form.winTo);
|
||||||
|
if (from == null || to == null) return setMsg({ kind: "err", text: t("plans.needWindow") });
|
||||||
|
if (form.days.length === 0) return setMsg({ kind: "err", text: t("plans.needDays") });
|
||||||
|
timeframes = {
|
||||||
|
days: [...form.days].sort((a, b) => a - b),
|
||||||
|
fromMin: from,
|
||||||
|
toMin: to,
|
||||||
|
graceMin: Math.max(0, Math.round(Number(form.graceMin) || 0)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await createSubscriptionPlan({
|
||||||
|
planId: form.planId.trim() || undefined,
|
||||||
|
name: form.name.trim(),
|
||||||
|
period: form.period,
|
||||||
|
pricePerPeriodMinor: Math.round(major * 100),
|
||||||
|
currency: form.currency.trim() || DEFAULT_CURRENCY,
|
||||||
|
timeframes,
|
||||||
|
});
|
||||||
|
setForm(null);
|
||||||
|
reload();
|
||||||
|
setMsg({ kind: "ok", text: t("plans.saved") });
|
||||||
|
} catch (e) {
|
||||||
|
const problems = e instanceof ApiError ? (e as ApiError & { problems?: string[] }).problems : undefined;
|
||||||
|
setMsg({ kind: "err", text: problems?.length ? `${(e as Error).message}: ${problems.join("; ")}` : (e as Error).message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function retire(p: SubscriptionPlan) {
|
||||||
|
if (!confirm(t("plans.confirmRetire", { name: p.name }))) return;
|
||||||
|
await retireSubscriptionPlan(p.planId).catch((e) => setMsg({ kind: "err", text: (e as Error).message }));
|
||||||
|
reload();
|
||||||
|
}
|
||||||
|
async function reactivate(p: SubscriptionPlan) {
|
||||||
|
setMsg(null);
|
||||||
|
try {
|
||||||
|
await reactivateSubscriptionPlan(p.planId);
|
||||||
|
setMsg({ kind: "ok", text: t("plans.reactivated", { name: p.name }) });
|
||||||
|
reload();
|
||||||
|
} catch (e) {
|
||||||
|
setMsg({ kind: "err", text: (e as Error).message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function del(p: SubscriptionPlan) {
|
||||||
|
if (!confirm(t("plans.confirmDelete", { name: p.name }))) return;
|
||||||
|
setMsg(null);
|
||||||
|
try {
|
||||||
|
await deleteSubscriptionPlan(p.planId);
|
||||||
|
setMsg({ kind: "ok", text: t("plans.deleted", { name: p.name }) });
|
||||||
|
reload();
|
||||||
|
} catch (e) {
|
||||||
|
// 409 → plan is in use; explain why it can't be deleted (retire instead).
|
||||||
|
const inUse = e instanceof ApiError && e.status === 409;
|
||||||
|
setMsg({ kind: "err", text: inUse ? t("plans.deleteInUse") : (e as Error).message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Publish a new version of an existing plan (pre-fills its identity + last values). */
|
||||||
|
function newVersionOf(p: SubscriptionPlan) {
|
||||||
|
const tf = p.timeframes ?? null;
|
||||||
|
setForm({
|
||||||
|
planId: p.planId,
|
||||||
|
name: p.name,
|
||||||
|
period: p.period,
|
||||||
|
priceMajor: String(p.pricePerPeriodMinor / 100),
|
||||||
|
currency: p.currency,
|
||||||
|
restrictTimes: tf != null,
|
||||||
|
days: tf?.days && tf.days.length > 0 ? [...tf.days] : [1, 2, 3, 4, 5],
|
||||||
|
winFrom: tf?.fromMin != null ? minToHHMM(tf.fromMin) : "20:00",
|
||||||
|
winTo: tf?.toMin != null ? minToHHMM(tf.toMin) : "08:00",
|
||||||
|
graceMin: String(tf?.graceMin ?? 0),
|
||||||
|
});
|
||||||
|
setMsg(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!plans) return null;
|
||||||
|
|
||||||
|
// GROUP versions by planId; the newest version (plans come newest-first) represents the
|
||||||
|
// plan in the list. A planId is "in force" when its versions are active; "retired"
|
||||||
|
// otherwise. One card per planId — avoids the cramped multi-version table.
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const groups: { planId: string; head: SubscriptionPlan; active: boolean; versions: number }[] = [];
|
||||||
|
const seen = new Map<string, number>();
|
||||||
|
for (const p of plans) {
|
||||||
|
const idx = seen.get(p.planId);
|
||||||
|
if (idx == null) {
|
||||||
|
seen.set(p.planId, groups.length);
|
||||||
|
groups.push({ planId: p.planId, head: p, active: p.active && p.effectiveFrom <= now, versions: 1 });
|
||||||
|
} else {
|
||||||
|
groups[idx]!.versions += 1;
|
||||||
|
if (p.active && p.effectiveFrom <= now) groups[idx]!.active = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="mx-auto max-w-2xl px-4 py-6">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<h3 className="text-[13px] font-semibold uppercase tracking-wider text-term-muted">{t("plans.title")}</h3>
|
||||||
|
<button type="button" className="btn btn-go btn-sm" onClick={() => setForm(emptyForm())}>
|
||||||
|
{t("plans.add")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="mb-3 text-[12px] text-term-muted">{t("plans.intro")}</p>
|
||||||
|
|
||||||
|
{msg && (
|
||||||
|
<div className={`mb-3 text-[12px] ${msg.kind === "ok" ? "text-term-green" : "text-term-red"}`}>{msg.text}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{groups.length === 0 ? (
|
||||||
|
<p className="text-[13px] text-term-muted">{t("plans.noneYet")}</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{groups.map(({ planId, head: p, active, versions }) => {
|
||||||
|
const users = subscribersOf(planId);
|
||||||
|
const activeUsers = users.filter((s) => s.status === "active");
|
||||||
|
const isOpen = expanded === planId;
|
||||||
|
const canDelete = users.length === 0; // no sale references it → safe to delete
|
||||||
|
return (
|
||||||
|
<div key={planId} className={`card p-3 ${active ? "" : "opacity-70"}`}>
|
||||||
|
{/* Header: name + status badge */}
|
||||||
|
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||||
|
<span className="font-semibold text-term-text">{p.name}</span>
|
||||||
|
{active ? (
|
||||||
|
<span className="rounded border border-term-green px-1 text-[10px] text-term-green">{t("plans.inForce")}</span>
|
||||||
|
) : (
|
||||||
|
<span className="rounded border border-term-border px-1 text-[10px] text-term-muted">{t("plans.retired")}</span>
|
||||||
|
)}
|
||||||
|
{versions > 1 && <span className="text-[10px] text-term-muted">{t("plans.versionCount", { count: versions })}</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Details: price · hours · effective */}
|
||||||
|
<div className="mt-1 flex flex-wrap gap-x-4 gap-y-0.5 text-[12px] text-term-muted">
|
||||||
|
<span className="tabular-nums text-term-text">
|
||||||
|
{(p.pricePerPeriodMinor / 100).toLocaleString()} {p.currency} / {t(PERIOD_KEY[p.period])}
|
||||||
|
</span>
|
||||||
|
<span>{timeframesSummary(p.timeframes, t)}</span>
|
||||||
|
<span>{t("plans.colEffective")}: {new Date(p.effectiveFrom).toLocaleDateString()}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Used by */}
|
||||||
|
<div className="mt-1 text-[12px]">
|
||||||
|
{users.length > 0 ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-term-cyan hover:underline tabular-nums"
|
||||||
|
onClick={() => setExpanded(isOpen ? null : planId)}
|
||||||
|
title={t("plans.usedByTitle")}
|
||||||
|
>
|
||||||
|
{t("plans.colUsedBy")}: {t("plans.usedByCount", { active: activeUsers.length, total: users.length })} {isOpen ? "▾" : "▸"}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="text-term-muted">{t("plans.colUsedBy")}: {t("plans.usedByNone")}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{isOpen && users.length > 0 && (
|
||||||
|
<ul className="mt-1 flex flex-wrap gap-x-4 gap-y-1 rounded-term bg-term-bg px-3 py-2 text-[12px]">
|
||||||
|
{users.map((s) => (
|
||||||
|
<li key={s.id} className={s.status === "active" ? "text-term-text" : "text-term-muted"}>
|
||||||
|
{s.holderName || t("subs.unnamed")}
|
||||||
|
{s.quantity > 1 && <span className="text-term-muted"> ×{s.quantity}</span>}
|
||||||
|
{s.status !== "active" && <span className="ml-1 text-[10px]">({t(STATUS_KEY[s.status])})</span>}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions — own row, never overlapping */}
|
||||||
|
<div className="mt-2 flex flex-wrap gap-1.5 border-t border-term-border pt-2">
|
||||||
|
{active ? (
|
||||||
|
<>
|
||||||
|
<button type="button" className="btn btn-sm" onClick={() => newVersionOf(p)}>{t("plans.newVersion")}</button>
|
||||||
|
<button type="button" className="btn btn-sm btn-danger" onClick={() => retire(p)}>{t("plans.retire")}</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button type="button" className="btn btn-go btn-sm" onClick={() => reactivate(p)}>{t("plans.reactivate")}</button>
|
||||||
|
)}
|
||||||
|
{canDelete && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm btn-danger"
|
||||||
|
onClick={() => del(p)}
|
||||||
|
title={t("plans.deleteTitle")}
|
||||||
|
>
|
||||||
|
{t("plans.delete")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Modal open={form != null} onClose={() => setForm(null)} title={form?.planId ? t("plans.newVersionTitle") : t("plans.newTitle")} width="max-w-lg">
|
||||||
|
{form && (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-[max-content_1fr] items-center gap-x-4 gap-y-2">
|
||||||
|
<label className="label">{t("plans.colName")}</label>
|
||||||
|
<input className="input" value={form.name} onChange={(e) => setForm((f) => f && { ...f, name: e.target.value })} placeholder={t("plans.namePlaceholder")} />
|
||||||
|
<label className="label">{t("plans.period")}</label>
|
||||||
|
<select className="select input w-auto" value={form.period} onChange={(e) => setForm((f) => f && { ...f, period: e.target.value as SubscriptionPeriod })}>
|
||||||
|
{PERIODS.map((p) => (
|
||||||
|
<option key={p} value={p}>{t(PERIOD_KEY[p])}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<label className="label">{t("plans.pricePer")}</label>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<input className="input w-28" value={form.priceMajor} inputMode="decimal" onChange={(e) => setForm((f) => f && { ...f, priceMajor: e.target.value })} placeholder="e.g. 800" />
|
||||||
|
<input className="input w-16" value={form.currency} onChange={(e) => setForm((f) => f && { ...f, currency: e.target.value })} />
|
||||||
|
<span className="text-[12px] text-term-muted">/ {t(PERIOD_KEY[form.period])}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Timeframes (tariff bridge): restrict WHEN a subscriber may park. Outside the
|
||||||
|
window they're charged the transient tariff for the gap. Off = 24/7. */}
|
||||||
|
<div className="mt-3 border-t border-term-border pt-3">
|
||||||
|
<label className="flex items-center gap-2 text-[12px] text-term-text">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="accent-term-amber"
|
||||||
|
checked={form.restrictTimes}
|
||||||
|
onChange={(e) => setForm((f) => f && { ...f, restrictTimes: e.target.checked })}
|
||||||
|
/>
|
||||||
|
{t("plans.restrictTimes")}
|
||||||
|
</label>
|
||||||
|
{form.restrictTimes && (
|
||||||
|
<div className="mt-2 grid grid-cols-[max-content_1fr] items-center gap-x-4 gap-y-2">
|
||||||
|
<label className="label">{t("plans.days")}</label>
|
||||||
|
<span className="flex flex-wrap gap-2">
|
||||||
|
{DOW_ORDER.map((d) => (
|
||||||
|
<label key={d} className="inline-flex items-center gap-1 text-[12px] text-term-text">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="accent-term-amber"
|
||||||
|
checked={form.days.includes(d)}
|
||||||
|
onChange={() =>
|
||||||
|
setForm((f) =>
|
||||||
|
f && { ...f, days: f.days.includes(d) ? f.days.filter((x) => x !== d) : [...f.days, d] },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{t(`tariff.dow${d}`)}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
<label className="label">{t("plans.window")}</label>
|
||||||
|
<span className="flex flex-wrap items-center gap-2 text-[12px] text-term-muted">
|
||||||
|
{t("plans.enterAfter")}
|
||||||
|
<input type="time" className="input w-28" value={form.winFrom} onChange={(e) => setForm((f) => f && { ...f, winFrom: e.target.value })} />
|
||||||
|
{t("plans.exitBefore")}
|
||||||
|
<input type="time" className="input w-28" value={form.winTo} onChange={(e) => setForm((f) => f && { ...f, winTo: e.target.value })} />
|
||||||
|
</span>
|
||||||
|
<label className="label">{t("plans.grace")}</label>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<input className="input w-16" value={form.graceMin} inputMode="numeric" onChange={(e) => setForm((f) => f && { ...f, graceMin: e.target.value })} />
|
||||||
|
<span className="text-[12px] text-term-muted">{t("plans.graceHint")}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="mt-1.5 text-[11px] text-term-muted">{t("plans.timeframesHint")}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{form.planId && <p className="mt-2 text-[11px] text-term-amber">{t("plans.newVersionHint")}</p>}
|
||||||
|
<div className="mt-4 flex justify-end gap-2">
|
||||||
|
<button type="button" className="btn btn-sm" onClick={() => setForm(null)}>{t("subs.cancel")}</button>
|
||||||
|
<button type="button" className="btn btn-go btn-sm" onClick={save}>{t("subs.save")}</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
+143
-14
@@ -7,6 +7,7 @@ import {
|
|||||||
publishTariffVersion,
|
publishTariffVersion,
|
||||||
type TariffBlock,
|
type TariffBlock,
|
||||||
type TariffCard,
|
type TariffCard,
|
||||||
|
type TariffStep,
|
||||||
type TariffStructure,
|
type TariffStructure,
|
||||||
type TariffState,
|
type TariffState,
|
||||||
} from "./api.js";
|
} from "./api.js";
|
||||||
@@ -26,11 +27,19 @@ interface BlockForm {
|
|||||||
hours: string; // duration of THIS band, in hours (ignored for the last block)
|
hours: string; // duration of THIS band, in hours (ignored for the last block)
|
||||||
price: string; // major units, e.g. "2.00"
|
price: string; // major units, e.g. "2.00"
|
||||||
}
|
}
|
||||||
// A pricing body the form edits: either a flat rate or a block ladder.
|
// One STEPPED ("up-to") row: "a stay up to N hours costs TOTAL". The owner enters the
|
||||||
|
// matrix verbatim (totals, not marginal rates). See wiki/concepts/tariff.md.
|
||||||
|
interface StepForm {
|
||||||
|
hours: string; // inclusive upper bound of this tier, in hours (e.g. "3")
|
||||||
|
total: string; // TOTAL major units for a stay within this tier (e.g. "5.00")
|
||||||
|
}
|
||||||
|
// A pricing body the form edits: a flat rate, a marginal block ladder, or a stepped
|
||||||
|
// (up-to) total-by-duration table.
|
||||||
interface PricingForm {
|
interface PricingForm {
|
||||||
mode: "ladder" | "flat";
|
mode: "ladder" | "flat" | "stepped";
|
||||||
flat: string; // major units (used when mode==="flat")
|
flat: string; // major units (used when mode==="flat")
|
||||||
blocks: BlockForm[]; // hours-based ladder (used when mode==="ladder")
|
blocks: BlockForm[]; // hours-based ladder (used when mode==="ladder")
|
||||||
|
steps: StepForm[]; // up-to tiers (used when mode==="stepped")
|
||||||
dailyCap: string; // "" = no cap (ladder only)
|
dailyCap: string; // "" = no cap (ladder only)
|
||||||
}
|
}
|
||||||
// An optional time/category TIER (a V2 windowed card). Absent windows = unconstrained.
|
// An optional time/category TIER (a V2 windowed card). Absent windows = unconstrained.
|
||||||
@@ -60,11 +69,33 @@ interface FormState {
|
|||||||
const toMinor = (major: string): number => Math.round(parseFloat(major || "0") * 100);
|
const toMinor = (major: string): number => Math.round(parseFloat(major || "0") * 100);
|
||||||
const toMajor = (minor: number): string => (minor / 100).toFixed(2);
|
const toMajor = (minor: number): string => (minor / 100).toFixed(2);
|
||||||
|
|
||||||
|
function emptySteps(): StepForm[] {
|
||||||
|
return [
|
||||||
|
{ hours: "1", total: "2.00" },
|
||||||
|
{ hours: "3", total: "5.00" },
|
||||||
|
];
|
||||||
|
}
|
||||||
function emptyLadder(): PricingForm {
|
function emptyLadder(): PricingForm {
|
||||||
return { mode: "ladder", flat: "0.00", dailyCap: "", blocks: [{ hours: "1", price: "2.00" }, { hours: "", price: "1.00" }] };
|
return {
|
||||||
|
mode: "ladder",
|
||||||
|
flat: "0.00",
|
||||||
|
dailyCap: "",
|
||||||
|
blocks: [{ hours: "1", price: "2.00" }, { hours: "", price: "1.00" }],
|
||||||
|
steps: emptySteps(),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
function emptyTier(): TierForm {
|
function emptyTier(): TierForm {
|
||||||
return { name: "", priority: "10", category: "", dow: [], fromHour: "", toHour: "", dateFrom: "", dateTo: "", pricing: { ...emptyLadder(), blocks: [{ hours: "", price: "1.00" }] } };
|
return {
|
||||||
|
name: "",
|
||||||
|
priority: "10",
|
||||||
|
category: "",
|
||||||
|
dow: [],
|
||||||
|
fromHour: "",
|
||||||
|
toHour: "",
|
||||||
|
dateFrom: "",
|
||||||
|
dateTo: "",
|
||||||
|
pricing: { ...emptyLadder(), blocks: [{ hours: "", price: "1.00" }] },
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function emptyForm(): FormState {
|
function emptyForm(): FormState {
|
||||||
@@ -92,16 +123,30 @@ function blocksToForm(blocks: TariffBlock[]): BlockForm[] {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// A stored card (V2) or bare-V1 body → the form's PricingForm (flat or ladder).
|
// A stored stepped table's `uptoMin` (minutes) → the per-tier hours the form edits.
|
||||||
function pricingFromCard(c: { flatMinor?: number; blocks?: TariffBlock[]; dailyCapMinor?: number | null }): PricingForm {
|
function stepsToForm(steps: TariffStep[]): StepForm[] {
|
||||||
|
return steps.map((s) => ({ hours: String(s.uptoMin / 60), total: toMajor(s.totalMinor) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// A stored card (V2) or bare-V1 body → the form's PricingForm (flat, ladder, or stepped).
|
||||||
|
function pricingFromCard(c: {
|
||||||
|
flatMinor?: number;
|
||||||
|
blocks?: TariffBlock[];
|
||||||
|
steps?: TariffStep[];
|
||||||
|
dailyCapMinor?: number | null;
|
||||||
|
}): PricingForm {
|
||||||
|
if (c.steps != null && c.steps.length > 0) {
|
||||||
|
return { mode: "stepped", flat: "0.00", dailyCap: "", blocks: emptyLadder().blocks, steps: stepsToForm(c.steps) };
|
||||||
|
}
|
||||||
if (c.flatMinor != null) {
|
if (c.flatMinor != null) {
|
||||||
return { mode: "flat", flat: toMajor(c.flatMinor), dailyCap: "", blocks: emptyLadder().blocks };
|
return { mode: "flat", flat: toMajor(c.flatMinor), dailyCap: "", blocks: emptyLadder().blocks, steps: emptySteps() };
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
mode: "ladder",
|
mode: "ladder",
|
||||||
flat: "0.00",
|
flat: "0.00",
|
||||||
dailyCap: c.dailyCapMinor == null ? "" : toMajor(c.dailyCapMinor),
|
dailyCap: c.dailyCapMinor == null ? "" : toMajor(c.dailyCapMinor),
|
||||||
blocks: blocksToForm(c.blocks ?? []),
|
blocks: blocksToForm(c.blocks ?? []),
|
||||||
|
steps: emptySteps(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,9 +183,17 @@ function formFromActive(s: TariffState): FormState {
|
|||||||
return { ...common, base: pricingFromCard(st), tiers: [] };
|
return { ...common, base: pricingFromCard(st), tiers: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build a tariff card's pricing body (flat XOR ladder) from a PricingForm.
|
// Build a tariff card's pricing body (flat XOR ladder XOR stepped) from a PricingForm.
|
||||||
function pricingToCardBody(p: PricingForm): Pick<TariffCard, "flatMinor" | "blocks" | "dailyCapMinor"> {
|
function pricingToCardBody(p: PricingForm): Pick<TariffCard, "flatMinor" | "blocks" | "steps" | "dailyCapMinor"> {
|
||||||
if (p.mode === "flat") return { flatMinor: toMinor(p.flat) };
|
if (p.mode === "flat") return { flatMinor: toMinor(p.flat) };
|
||||||
|
if (p.mode === "stepped") {
|
||||||
|
// Each row's `hours` IS the inclusive threshold (the matrix "up to N hours").
|
||||||
|
const steps: TariffStep[] = p.steps.map((s) => ({
|
||||||
|
uptoMin: Math.round(Number(s.hours || "0") * 60),
|
||||||
|
totalMinor: toMinor(s.total),
|
||||||
|
}));
|
||||||
|
return { steps };
|
||||||
|
}
|
||||||
// Accumulate each band's hours into cumulative uptoMin (min); last band open-ended.
|
// Accumulate each band's hours into cumulative uptoMin (min); last band open-ended.
|
||||||
const last = p.blocks.length - 1;
|
const last = p.blocks.length - 1;
|
||||||
let cum = 0;
|
let cum = 0;
|
||||||
@@ -184,6 +237,10 @@ function toStructure(f: FormState): TariffStructure {
|
|||||||
// NO tiers ⇒ publish a BARE V1 structure (back-compat: a site that never wants
|
// NO tiers ⇒ publish a BARE V1 structure (back-compat: a site that never wants
|
||||||
// tiers gets exactly today's shape; the server leaves it untouched).
|
// tiers gets exactly today's shape; the server leaves it untouched).
|
||||||
if (f.tiers.length === 0) {
|
if (f.tiers.length === 0) {
|
||||||
|
if (f.base.mode === "stepped") {
|
||||||
|
// A stepped V1: the up-to table replaces the ladder (blocks empty, no cap).
|
||||||
|
return { ...common, blocks: [], steps: baseBody.steps ?? [], dailyCapMinor: null };
|
||||||
|
}
|
||||||
if (f.base.mode === "flat") {
|
if (f.base.mode === "flat") {
|
||||||
// A flat V1: a single open-ended block at the flat rate (V1 has no flat field).
|
// A flat V1: a single open-ended block at the flat rate (V1 has no flat field).
|
||||||
return { ...common, blocks: [{ uptoMin: null, priceMinorPerIncrement: toMinor(f.base.flat) }], dailyCapMinor: null };
|
return { ...common, blocks: [{ uptoMin: null, priceMinorPerIncrement: toMinor(f.base.flat) }], dailyCapMinor: null };
|
||||||
@@ -244,6 +301,16 @@ export function TariffComposer() {
|
|||||||
function removeBlock(target: "base" | number, i: number) {
|
function removeBlock(target: "base" | number, i: number) {
|
||||||
updatePricing(target, (p) => (i === p.blocks.length - 1 || p.blocks.length <= 1 ? p : { ...p, blocks: p.blocks.filter((_, j) => j !== i) }));
|
updatePricing(target, (p) => (i === p.blocks.length - 1 || p.blocks.length <= 1 ? p : { ...p, blocks: p.blocks.filter((_, j) => j !== i) }));
|
||||||
}
|
}
|
||||||
|
// --- stepped (up-to) editing (base card only) ---
|
||||||
|
function setStep(i: number, patch: Partial<StepForm>) {
|
||||||
|
updatePricing("base", (p) => ({ ...p, steps: p.steps.map((s, j) => (j === i ? { ...s, ...patch } : s)) }));
|
||||||
|
}
|
||||||
|
function addStep() {
|
||||||
|
updatePricing("base", (p) => ({ ...p, steps: [...p.steps, { hours: "", total: "0.00" }] }));
|
||||||
|
}
|
||||||
|
function removeStep(i: number) {
|
||||||
|
updatePricing("base", (p) => (p.steps.length <= 1 ? p : { ...p, steps: p.steps.filter((_, j) => j !== i) }));
|
||||||
|
}
|
||||||
|
|
||||||
// --- tier editing ---
|
// --- tier editing ---
|
||||||
function setTier(i: number, patch: Partial<TierForm>) {
|
function setTier(i: number, patch: Partial<TierForm>) {
|
||||||
@@ -274,8 +341,8 @@ export function TariffComposer() {
|
|||||||
setMsg({ kind: "ok", text: t("tariff.publishedOk") });
|
setMsg({ kind: "ok", text: t("tariff.publishedOk") });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const text =
|
const text =
|
||||||
e instanceof ApiError && (e as ApiError & { problems?: string[] }).problems
|
e instanceof ApiError && e.problems?.length
|
||||||
? `${e.message}: ${((e as ApiError & { problems?: string[] }).problems ?? []).join("; ")}`
|
? `${e.message}: ${e.problems.join("; ")}`
|
||||||
: (e as Error).message;
|
: (e as Error).message;
|
||||||
setMsg({ kind: "err", text });
|
setMsg({ kind: "err", text });
|
||||||
} finally {
|
} finally {
|
||||||
@@ -320,12 +387,16 @@ export function TariffComposer() {
|
|||||||
<PricingEditor
|
<PricingEditor
|
||||||
t={t}
|
t={t}
|
||||||
pricing={form.base}
|
pricing={form.base}
|
||||||
|
allowStepped
|
||||||
onMode={(mode) => updatePricing("base", (p) => ({ ...p, mode }))}
|
onMode={(mode) => updatePricing("base", (p) => ({ ...p, mode }))}
|
||||||
onFlat={(flat) => updatePricing("base", (p) => ({ ...p, flat }))}
|
onFlat={(flat) => updatePricing("base", (p) => ({ ...p, flat }))}
|
||||||
onCap={(dailyCap) => updatePricing("base", (p) => ({ ...p, dailyCap }))}
|
onCap={(dailyCap) => updatePricing("base", (p) => ({ ...p, dailyCap }))}
|
||||||
onBlock={(i, patch) => setBlock("base", i, patch)}
|
onBlock={(i, patch) => setBlock("base", i, patch)}
|
||||||
onAddBlock={() => addBlock("base")}
|
onAddBlock={() => addBlock("base")}
|
||||||
onRemoveBlock={(i) => removeBlock("base", i)}
|
onRemoveBlock={(i) => removeBlock("base", i)}
|
||||||
|
onStep={setStep}
|
||||||
|
onAddStep={addStep}
|
||||||
|
onRemoveStep={removeStep}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -333,6 +404,13 @@ export function TariffComposer() {
|
|||||||
<details className="mt-6" open={form.tiers.length > 0}>
|
<details className="mt-6" open={form.tiers.length > 0}>
|
||||||
<summary className="cursor-pointer text-h6 font-semibold uppercase tracking-wider text-term-text">{t("tariff.tiersAdvanced")}</summary>
|
<summary className="cursor-pointer text-h6 font-semibold uppercase tracking-wider text-term-text">{t("tariff.tiersAdvanced")}</summary>
|
||||||
<p className="hint mt-1.5 mb-2">{t("tariff.tiersHint")}</p>
|
<p className="hint mt-1.5 mb-2">{t("tariff.tiersHint")}</p>
|
||||||
|
{/* A stepped ("up-to") base rate cannot be combined with time tiers — the
|
||||||
|
engine would ignore them. Warn up-front; publishing is also blocked server-side. */}
|
||||||
|
{form.base.mode === "stepped" && form.tiers.length > 0 && (
|
||||||
|
<p className="mb-3 rounded-term border border-term-red/50 bg-term-red/10 px-3 py-2 text-[12px] text-term-red">
|
||||||
|
{t("tariff.steppedTiersConflict")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
{form.tiers.map((tr, i) => (
|
{form.tiers.map((tr, i) => (
|
||||||
<fieldset key={i} className="card mb-3 p-4">
|
<fieldset key={i} className="card mb-3 p-4">
|
||||||
<legend className="flex items-center gap-2 px-1">
|
<legend className="flex items-center gap-2 px-1">
|
||||||
@@ -407,16 +485,21 @@ export function TariffComposer() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// A reusable flat/ladder pricing-body editor — used by the default card and each tier.
|
// A reusable pricing-body editor — flat / marginal ladder / stepped (up-to). The
|
||||||
|
// stepped mode is offered only where `allowStepped` (the default card, not tiers).
|
||||||
function PricingEditor(props: {
|
function PricingEditor(props: {
|
||||||
t: (k: string) => string;
|
t: (k: string) => string;
|
||||||
pricing: PricingForm;
|
pricing: PricingForm;
|
||||||
onMode: (m: "ladder" | "flat") => void;
|
allowStepped?: boolean;
|
||||||
|
onMode: (m: "ladder" | "flat" | "stepped") => void;
|
||||||
onFlat: (v: string) => void;
|
onFlat: (v: string) => void;
|
||||||
onCap: (v: string) => void;
|
onCap: (v: string) => void;
|
||||||
onBlock: (i: number, patch: Partial<BlockForm>) => void;
|
onBlock: (i: number, patch: Partial<BlockForm>) => void;
|
||||||
onAddBlock: () => void;
|
onAddBlock: () => void;
|
||||||
onRemoveBlock: (i: number) => void;
|
onRemoveBlock: (i: number) => void;
|
||||||
|
onStep?: (i: number, patch: Partial<StepForm>) => void;
|
||||||
|
onAddStep?: () => void;
|
||||||
|
onRemoveStep?: (i: number) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t, pricing: p } = props;
|
const { t, pricing: p } = props;
|
||||||
return (
|
return (
|
||||||
@@ -430,9 +513,55 @@ function PricingEditor(props: {
|
|||||||
<input type="radio" className="accent-term-amber" checked={p.mode === "flat"} onChange={() => props.onMode("flat")} />
|
<input type="radio" className="accent-term-amber" checked={p.mode === "flat"} onChange={() => props.onMode("flat")} />
|
||||||
{t("tariff.modeFlat")}
|
{t("tariff.modeFlat")}
|
||||||
</label>
|
</label>
|
||||||
|
{props.allowStepped && (
|
||||||
|
<label className="inline-flex items-center gap-1.5 text-term-text">
|
||||||
|
<input type="radio" className="accent-term-amber" checked={p.mode === "stepped"} onChange={() => props.onMode("stepped")} />
|
||||||
|
{t("tariff.modeStepped")}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{p.mode === "flat" ? (
|
{p.mode === "stepped" ? (
|
||||||
|
<>
|
||||||
|
<p className="hint mb-2">{t("tariff.steppedHint")}</p>
|
||||||
|
<table className="w-full border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-left">
|
||||||
|
<th className="label px-2 pb-1 font-normal">{t("tariff.stepUpTo")}</th>
|
||||||
|
<th className="label px-2 pb-1 font-normal">{t("tariff.stepTotal")}</th>
|
||||||
|
<th />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{p.steps.map((s, i) => (
|
||||||
|
<tr key={i}>
|
||||||
|
<td className="px-2 py-1">
|
||||||
|
<span className="inline-flex items-center gap-2">
|
||||||
|
<input className="input w-20" value={s.hours} onChange={(e) => props.onStep?.(i, { hours: e.target.value })} placeholder={t("tariff.egHours")} />
|
||||||
|
<span className="text-[11px] text-term-muted">{t("tariff.hoursUnit")}</span>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-1">
|
||||||
|
<input className="input w-28" value={s.total} onChange={(e) => props.onStep?.(i, { total: e.target.value })} />
|
||||||
|
</td>
|
||||||
|
<td className="px-2">
|
||||||
|
{p.steps.length > 1 && (
|
||||||
|
<button type="button" className="btn btn-ghost btn-sm" onClick={() => props.onRemoveStep?.(i)}>
|
||||||
|
{t("tariff.remove")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div className="mt-3">
|
||||||
|
<button type="button" className="btn btn-sm" onClick={props.onAddStep}>
|
||||||
|
{t("tariff.addStep")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : p.mode === "flat" ? (
|
||||||
<div className="inline-flex items-center gap-2">
|
<div className="inline-flex items-center gap-2">
|
||||||
<span className="label">{t("tariff.pricePerIncrement")}</span>
|
<span className="label">{t("tariff.pricePerIncrement")}</span>
|
||||||
<input className="input w-28" value={p.flat} onChange={(e) => props.onFlat(e.target.value)} />
|
<input className="input w-28" value={p.flat} onChange={(e) => props.onFlat(e.target.value)} />
|
||||||
|
|||||||
@@ -0,0 +1,254 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
fetchTariff,
|
||||||
|
loadSimSession,
|
||||||
|
simulateTariff,
|
||||||
|
type SimulateResult,
|
||||||
|
type SimPayment,
|
||||||
|
type TariffState,
|
||||||
|
} from "./api.js";
|
||||||
|
import { formatMoney, formatDuration } from "./lib/format.js";
|
||||||
|
|
||||||
|
// The TARIFF LAB — a pure session-pricing simulator. Test rates "in time" (overnight
|
||||||
|
// windows, daily caps, overstay) in seconds instead of waiting hours, against ANY
|
||||||
|
// published tariff version, with no real ledger writes. Build a hypothetical session
|
||||||
|
// (entry, optional payment, "now") OR load a real ticket and re-evaluate it at any
|
||||||
|
// instant. Prices via the SAME `priceSession` the booth uses (server), so the lab and
|
||||||
|
// the live booth can never diverge. See wiki/concepts/tariff.md, booth-exit-flow.md.
|
||||||
|
|
||||||
|
/** <input type="datetime-local"> wants "YYYY-MM-DDTHH:mm" in LOCAL time. */
|
||||||
|
function toLocalInput(iso: string): string {
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (Number.isNaN(d.getTime())) return "";
|
||||||
|
const pad = (n: number) => String(n).padStart(2, "0");
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||||
|
}
|
||||||
|
/** A local datetime-local value → ISO-8601 (treats the value as local wall-clock). */
|
||||||
|
function fromLocalInput(v: string): string {
|
||||||
|
const d = new Date(v);
|
||||||
|
return Number.isNaN(d.getTime()) ? "" : d.toISOString();
|
||||||
|
}
|
||||||
|
function nowLocal(): string {
|
||||||
|
return toLocalInput(new Date().toISOString());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TariffLab() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [state, setState] = useState<TariffState | null>(null);
|
||||||
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Inputs (datetime-local strings, local wall-clock).
|
||||||
|
const [entered, setEntered] = useState<string>(() => {
|
||||||
|
const d = new Date();
|
||||||
|
d.setHours(d.getHours() - 3); // default: a 3h-ago entry
|
||||||
|
return toLocalInput(d.toISOString());
|
||||||
|
});
|
||||||
|
const [asOf, setAsOf] = useState<string>(nowLocal);
|
||||||
|
const [category, setCategory] = useState("");
|
||||||
|
const [versionId, setVersionId] = useState<string>(""); // "" = active
|
||||||
|
// Optional single hypothetical payment (the latest grants the walk-back grace).
|
||||||
|
const [paid, setPaid] = useState(false);
|
||||||
|
const [paidAt, setPaidAt] = useState<string>(nowLocal);
|
||||||
|
const [graceMin, setGraceMin] = useState<string>("5");
|
||||||
|
// Load-a-real-ticket.
|
||||||
|
const [ticket, setTicket] = useState("");
|
||||||
|
const [loadMsg, setLoadMsg] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [result, setResult] = useState<SimulateResult | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchTariff()
|
||||||
|
.then(setState)
|
||||||
|
.catch((e) => setErr((e as Error).message));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
setErr(null);
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const payments: SimPayment[] = paid
|
||||||
|
? [{ paidAt: fromLocalInput(paidAt), graceExitMin: graceMin.trim() === "" ? null : Number(graceMin) }]
|
||||||
|
: [];
|
||||||
|
const r = await simulateTariff({
|
||||||
|
enteredAt: fromLocalInput(entered),
|
||||||
|
asOf: fromLocalInput(asOf),
|
||||||
|
payments,
|
||||||
|
category: category.trim() || undefined,
|
||||||
|
tariffVersionId: versionId || undefined,
|
||||||
|
});
|
||||||
|
setResult(r);
|
||||||
|
} catch (e) {
|
||||||
|
setErr((e as Error).message);
|
||||||
|
setResult(null);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTicket() {
|
||||||
|
setLoadMsg(null);
|
||||||
|
setErr(null);
|
||||||
|
try {
|
||||||
|
const s = await loadSimSession(ticket.trim());
|
||||||
|
setEntered(toLocalInput(s.enteredAt));
|
||||||
|
setAsOf(s.exitedAt ? toLocalInput(s.exitedAt) : nowLocal());
|
||||||
|
setCategory(s.category ?? "");
|
||||||
|
setVersionId(s.tariffVersionId ?? "");
|
||||||
|
const last = s.payments.at(-1);
|
||||||
|
if (last) {
|
||||||
|
setPaid(true);
|
||||||
|
setPaidAt(toLocalInput(last.paidAt));
|
||||||
|
setGraceMin(last.graceExitMin != null ? String(last.graceExitMin) : "");
|
||||||
|
} else {
|
||||||
|
setPaid(false);
|
||||||
|
}
|
||||||
|
setLoadMsg(t("lab.loaded", { id: s.identity }));
|
||||||
|
} catch (e) {
|
||||||
|
setErr((e as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const currency = result?.currency ?? state?.active?.currency ?? "ALL";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="mx-auto max-w-3xl px-4 py-6">
|
||||||
|
<h2 className="mb-1 text-h4 font-semibold text-term-text">{t("lab.title")}</h2>
|
||||||
|
<p className="hint mb-4">{t("lab.intro")}</p>
|
||||||
|
|
||||||
|
{/* Load a real ticket */}
|
||||||
|
<div className="card card-body mb-4 flex flex-wrap items-end gap-2">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label className="label">{t("lab.loadTicket")}</label>
|
||||||
|
<input
|
||||||
|
className="input w-56"
|
||||||
|
value={ticket}
|
||||||
|
onChange={(e) => setTicket(e.target.value)}
|
||||||
|
placeholder={t("lab.loadTicketPh")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button type="button" className="btn btn-sm" onClick={loadTicket} disabled={!ticket.trim()}>
|
||||||
|
{t("lab.load")}
|
||||||
|
</button>
|
||||||
|
{loadMsg && <span className="text-[12px] text-term-green">{loadMsg}</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Hypothetical session inputs */}
|
||||||
|
<div className="card card-body grid grid-cols-[max-content_1fr] items-center gap-x-4 gap-y-2">
|
||||||
|
<label className="label">{t("lab.tariffVersion")}</label>
|
||||||
|
<select className="input w-full max-w-md" value={versionId} onChange={(e) => setVersionId(e.target.value)}>
|
||||||
|
<option value="">{t("lab.activeVersion")}</option>
|
||||||
|
{state?.versions.map((v) => (
|
||||||
|
<option key={v.id} value={v.id}>
|
||||||
|
{new Date(v.effectiveFrom).toLocaleString()} · {v.currency} · {v.id.slice(0, 8)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<label className="label">{t("lab.entered")}</label>
|
||||||
|
<input type="datetime-local" className="input w-64" value={entered} onChange={(e) => setEntered(e.target.value)} />
|
||||||
|
|
||||||
|
<label className="label">{t("lab.asOf")}</label>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<input type="datetime-local" className="input w-64" value={asOf} onChange={(e) => setAsOf(e.target.value)} />
|
||||||
|
<button type="button" className="btn btn-sm" onClick={() => setAsOf(nowLocal())}>
|
||||||
|
{t("lab.now")}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<label className="label">{t("lab.category")}</label>
|
||||||
|
<input
|
||||||
|
className="input w-40"
|
||||||
|
value={category}
|
||||||
|
onChange={(e) => setCategory(e.target.value)}
|
||||||
|
placeholder={t("lab.categoryPh")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<label className="label">{t("lab.payment")}</label>
|
||||||
|
<span className="flex flex-wrap items-center gap-2">
|
||||||
|
<label className="inline-flex items-center gap-1 text-[12px] text-term-text">
|
||||||
|
<input type="checkbox" className="accent-term-amber" checked={paid} onChange={(e) => setPaid(e.target.checked)} />
|
||||||
|
{t("lab.paid")}
|
||||||
|
</label>
|
||||||
|
{paid && (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
className="input w-64"
|
||||||
|
value={paidAt}
|
||||||
|
onChange={(e) => setPaidAt(e.target.value)}
|
||||||
|
/>
|
||||||
|
<span className="text-term-muted">{t("lab.graceMin")}</span>
|
||||||
|
<input className="input w-20" value={graceMin} onChange={(e) => setGraceMin(e.target.value)} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 flex items-center gap-3">
|
||||||
|
<button type="button" className="btn btn-primary btn-lg" onClick={run} disabled={busy}>
|
||||||
|
{busy ? t("lab.pricing") : t("lab.price")}
|
||||||
|
</button>
|
||||||
|
{err && <span className="text-[12px] text-term-red">{err}</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{result && (
|
||||||
|
<div className="mt-6 grid gap-4 md:grid-cols-2">
|
||||||
|
{/* Outcome */}
|
||||||
|
<div className="card card-body">
|
||||||
|
<h3 className="mb-2 text-h6 font-semibold uppercase tracking-wider text-term-text">{t("lab.outcome")}</h3>
|
||||||
|
<dl className="grid grid-cols-[max-content_1fr] gap-x-4 gap-y-1 text-[13px]">
|
||||||
|
<dt className="text-term-muted">{t("lab.amountDue")}</dt>
|
||||||
|
<dd className="text-2xl font-bold text-term-cyan">{formatMoney(result.pricing.amountMinor, currency)}</dd>
|
||||||
|
<dt className="text-term-muted">{t("lab.billedPeriod")}</dt>
|
||||||
|
<dd className="text-term-text">
|
||||||
|
{formatDuration(result.pricing.periodStart, fromLocalInput(asOf))}
|
||||||
|
{result.pricing.overstay && (
|
||||||
|
<span className="ml-2 rounded bg-term-red/15 px-1.5 py-0.5 text-[10px] uppercase text-term-red">
|
||||||
|
{t("lab.overstay")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{result.pricing.withinGrace && (
|
||||||
|
<span className="ml-2 rounded bg-term-green/15 px-1.5 py-0.5 text-[10px] uppercase text-term-green">
|
||||||
|
{t("lab.settled")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</dd>
|
||||||
|
<dt className="text-term-muted">{t("lab.periodStart")}</dt>
|
||||||
|
<dd className="text-term-text">{new Date(result.pricing.periodStart).toLocaleString()}</dd>
|
||||||
|
{result.pricing.graceExpiresAt && (
|
||||||
|
<>
|
||||||
|
<dt className="text-term-muted">{t("lab.graceExpires")}</dt>
|
||||||
|
<dd className="text-term-text">{new Date(result.pricing.graceExpiresAt).toLocaleString()}</dd>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Duration curve from entry — see where the cap flattens / windows shift. */}
|
||||||
|
<div className="card card-body">
|
||||||
|
<h3 className="mb-2 text-h6 font-semibold uppercase tracking-wider text-term-text">{t("lab.curve")}</h3>
|
||||||
|
<p className="hint mb-2">{t("lab.curveHint")}</p>
|
||||||
|
<table className="w-full text-[12px] tabular-nums">
|
||||||
|
<tbody>
|
||||||
|
{result.curve.map((c) => (
|
||||||
|
<tr key={c.minutes} className="border-b border-term-border/40">
|
||||||
|
<td className="py-0.5 text-term-muted">{labelMin(c.minutes)}</td>
|
||||||
|
<td className="py-0.5 text-right text-term-text">{formatMoney(c.amountMinor, currency)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function labelMin(min: number): string {
|
||||||
|
if (min < 60) return `${min}m`;
|
||||||
|
if (min < 1440) return `${min / 60}h`;
|
||||||
|
return `${min / 1440}d`;
|
||||||
|
}
|
||||||
+217
-18
@@ -29,7 +29,7 @@ export async function apiFetch<T>(path: string, init: RequestInit = {}): Promise
|
|||||||
}
|
}
|
||||||
const res = await fetch(path, { ...init, headers, credentials: "include" });
|
const res = await fetch(path, { ...init, headers, credentials: "include" });
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const msg = (await res.json().catch(() => ({}))) as { error?: string };
|
const msg = (await res.json().catch(() => ({}))) as { error?: string; problems?: string[] };
|
||||||
const error = msg.error ?? `${path}: ${res.status}`;
|
const error = msg.error ?? `${path}: ${res.status}`;
|
||||||
// Ship the failed request to the backend log store (best-effort, loop-safe — the
|
// Ship the failed request to the backend log store (best-effort, loop-safe — the
|
||||||
// logger itself never logs the /api/logs call). 401s are normal pre-login churn,
|
// logger itself never logs the /api/logs call). 401s are normal pre-login churn,
|
||||||
@@ -37,7 +37,7 @@ export async function apiFetch<T>(path: string, init: RequestInit = {}): Promise
|
|||||||
if (res.status !== 401) {
|
if (res.status !== 401) {
|
||||||
logFailedRequest({ path, method, status: res.status, error });
|
logFailedRequest({ path, method, status: res.status, error });
|
||||||
}
|
}
|
||||||
throw new ApiError(error, res.status);
|
throw new ApiError(error, res.status, msg.problems);
|
||||||
}
|
}
|
||||||
if (res.status === 204) return undefined as T;
|
if (res.status === 204) return undefined as T;
|
||||||
return res.json() as Promise<T>;
|
return res.json() as Promise<T>;
|
||||||
@@ -47,6 +47,8 @@ export class ApiError extends Error {
|
|||||||
constructor(
|
constructor(
|
||||||
message: string,
|
message: string,
|
||||||
readonly status: number,
|
readonly status: number,
|
||||||
|
/** Field-level problems from a validation error (e.g. tariff publish), if any. */
|
||||||
|
readonly problems?: string[],
|
||||||
) {
|
) {
|
||||||
super(message);
|
super(message);
|
||||||
}
|
}
|
||||||
@@ -359,6 +361,8 @@ export interface TariffStructureV1 {
|
|||||||
gracePeriodEntryMin: number;
|
gracePeriodEntryMin: number;
|
||||||
incrementMin: number;
|
incrementMin: number;
|
||||||
blocks: TariffBlock[];
|
blocks: TariffBlock[];
|
||||||
|
/** STEPPED ("up-to") total-by-duration table; when non-empty it replaces `blocks`. */
|
||||||
|
steps?: TariffStep[];
|
||||||
dailyCapMinor: number | null;
|
dailyCapMinor: number | null;
|
||||||
lostTicketMinor: number;
|
lostTicketMinor: number;
|
||||||
gracePeriodExitMin: number;
|
gracePeriodExitMin: number;
|
||||||
@@ -378,8 +382,17 @@ export interface TariffCard {
|
|||||||
window?: TariffWindow;
|
window?: TariffWindow;
|
||||||
flatMinor?: number;
|
flatMinor?: number;
|
||||||
blocks?: TariffBlock[];
|
blocks?: TariffBlock[];
|
||||||
|
/** STEPPED ("up-to") table (defaultCard only); mutually exclusive with flat/blocks. */
|
||||||
|
steps?: TariffStep[];
|
||||||
dailyCapMinor?: number | null;
|
dailyCapMinor?: number | null;
|
||||||
}
|
}
|
||||||
|
/** One row of a STEPPED ("up-to") tariff: a TOTAL price for a stay up to and including
|
||||||
|
* `uptoMin` minutes (cumulative, not marginal). Mirrors @parking/shared TariffStep. */
|
||||||
|
export interface TariffStep {
|
||||||
|
uptoMin: number;
|
||||||
|
totalMinor: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TariffStructureV2 {
|
export interface TariffStructureV2 {
|
||||||
version: 2;
|
version: 2;
|
||||||
tz: string;
|
tz: string;
|
||||||
@@ -425,20 +438,101 @@ export function publishTariffVersion(body: {
|
|||||||
return apiFetch("/api/tariff/versions", { method: "POST", body: JSON.stringify(body) });
|
return apiFetch("/api/tariff/versions", { method: "POST", body: JSON.stringify(body) });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Tariff Lab (simulator) -----------------------------------------------
|
||||||
|
|
||||||
|
export interface SimPayment {
|
||||||
|
paidAt: string;
|
||||||
|
graceExitMin: number | null;
|
||||||
|
}
|
||||||
|
export interface SimSessionPricing {
|
||||||
|
periodStart: string;
|
||||||
|
amountMinor: number;
|
||||||
|
overstay: boolean;
|
||||||
|
withinGrace: boolean;
|
||||||
|
graceExpiresAt: string | null;
|
||||||
|
}
|
||||||
|
export interface SimulateResult {
|
||||||
|
currency: string | null;
|
||||||
|
pricing: SimSessionPricing;
|
||||||
|
curve: { minutes: number; amountMinor: number }[];
|
||||||
|
gracePeriodExitMin: number;
|
||||||
|
}
|
||||||
|
export interface SimulateBody {
|
||||||
|
enteredAt: string;
|
||||||
|
asOf: string;
|
||||||
|
payments?: SimPayment[];
|
||||||
|
category?: string;
|
||||||
|
tariffVersionId?: string;
|
||||||
|
structure?: TariffStructure;
|
||||||
|
currency?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Price a hypothetical session — pure, no ledger write. See Tariff Lab. */
|
||||||
|
export function simulateTariff(body: SimulateBody): Promise<SimulateResult> {
|
||||||
|
return apiFetch("/api/tariff/simulate", { method: "POST", body: JSON.stringify(body) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SimSessionLoad {
|
||||||
|
identity: string;
|
||||||
|
enteredAt: string;
|
||||||
|
exitedAt: string | null;
|
||||||
|
payments: SimPayment[];
|
||||||
|
category: string | null;
|
||||||
|
tariffVersionId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Prefill the lab from a real ledger session. */
|
||||||
|
export function loadSimSession(identity: string): Promise<SimSessionLoad> {
|
||||||
|
return apiFetch(`/api/tariff/simulate/session/${encodeURIComponent(identity)}`);
|
||||||
|
}
|
||||||
|
|
||||||
// --- Subscriptions --------------------------------------------------------
|
// --- Subscriptions --------------------------------------------------------
|
||||||
|
|
||||||
export interface SubscriptionCredential {
|
export interface SubscriptionCredential {
|
||||||
kind: "rf" | "qr";
|
kind: "rf" | "qr";
|
||||||
value: string;
|
value: string;
|
||||||
}
|
}
|
||||||
|
export type SubscriptionPeriod = "day" | "week" | "month";
|
||||||
|
|
||||||
|
/** A subscriber's allowed parking window (minutes-from-local-midnight) on selected days.
|
||||||
|
* A scan outside the window is charged the transient tariff for the gap. days: 0=Sun..6=Sat
|
||||||
|
* (empty = every day); the window [fromMin,toMin) wraps past midnight when toMin ≤ fromMin. */
|
||||||
|
export interface PlanTimeframes {
|
||||||
|
days?: number[];
|
||||||
|
fromMin: number;
|
||||||
|
toMin: number;
|
||||||
|
graceMin?: number;
|
||||||
|
tz?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A subscription PLAN version — admin-composed, versioned config the operator sells
|
||||||
|
* from (so they never type a price). */
|
||||||
|
export interface SubscriptionPlan {
|
||||||
|
id: string;
|
||||||
|
planId: string;
|
||||||
|
name: string;
|
||||||
|
period: SubscriptionPeriod;
|
||||||
|
pricePerPeriodMinor: number;
|
||||||
|
currency: string;
|
||||||
|
effectiveFrom: string;
|
||||||
|
active: boolean;
|
||||||
|
/** Allowed-time windows (tariff bridge); null/absent = 24/7, no time charge. */
|
||||||
|
timeframes?: PlanTimeframes | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Subscription {
|
export interface Subscription {
|
||||||
id: string;
|
id: string;
|
||||||
holderName: string | null;
|
holderName: string | null;
|
||||||
contact: string | null;
|
contact: string | null;
|
||||||
/** Recurring price in minor units (e.g. 1000000 = 10,000.00). null = not set. */
|
/** Price billed for the window in minor units — DERIVED from the plan. null = comp. */
|
||||||
priceMinor: number | null;
|
priceMinor: number | null;
|
||||||
period: "monthly";
|
period: SubscriptionPeriod;
|
||||||
currency: string | null;
|
currency: string | null;
|
||||||
|
/** Which plan + immutable version priced this sale (null for legacy/comp). */
|
||||||
|
planId: string | null;
|
||||||
|
planVersionId: string | null;
|
||||||
|
/** Cars covered by this one subscription (price was ×N). Default 1. */
|
||||||
|
quantity: number;
|
||||||
maxConcurrent: number | null;
|
maxConcurrent: number | null;
|
||||||
validFrom: string | null;
|
validFrom: string | null;
|
||||||
validTo: string | null;
|
validTo: string | null;
|
||||||
@@ -455,29 +549,87 @@ export interface SubscriptionCredentialInput {
|
|||||||
export type SubscriptionInput = {
|
export type SubscriptionInput = {
|
||||||
holderName: string | null;
|
holderName: string | null;
|
||||||
contact: string | null;
|
contact: string | null;
|
||||||
priceMinor: number | null;
|
/** PRICED SALE: the plan selected. Price is looked up server-side (never typed).
|
||||||
period: "monthly";
|
* Omit for a comp subscription. */
|
||||||
currency: string | null;
|
planId?: string | null;
|
||||||
maxConcurrent: number | null;
|
/** Coverage window. Priced sale: validFrom defaults to now, validTo required. */
|
||||||
validFrom: string | null;
|
validFrom: string | null;
|
||||||
validTo: string | null;
|
validTo: string | null;
|
||||||
/** Months paid for: when set (with validFrom), validTo = validFrom + months. */
|
/** Cars covered (price ×N). Default 1. */
|
||||||
months?: number | null;
|
quantity?: number;
|
||||||
|
maxConcurrent: number | null;
|
||||||
status?: Subscription["status"];
|
status?: Subscription["status"];
|
||||||
|
/** How the sale fee was tendered (cash → drawer, card → bank). Used at CREATE when a
|
||||||
|
* plan is sold (the sale appends a signed payment); ignored on update. */
|
||||||
|
tender?: "cash" | "card";
|
||||||
credentials: SubscriptionCredentialInput[];
|
credentials: SubscriptionCredentialInput[];
|
||||||
plates: string[];
|
plates: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
/** The create response = the saved subscription + the auto-print outcome. */
|
/** A server-computed quote: periods (ceil) × per-period price × quantity for a span. */
|
||||||
|
export interface SubscriptionQuote {
|
||||||
|
periods: number;
|
||||||
|
amountMinor: number;
|
||||||
|
currency: string;
|
||||||
|
period: SubscriptionPeriod;
|
||||||
|
quantity?: number;
|
||||||
|
plan: SubscriptionPlan;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The create response = the saved subscription + the auto-print outcome, plus the
|
||||||
|
* recorded SALE (the signed payment) when a price was collected. */
|
||||||
export type SubscriptionCreated = Subscription & {
|
export type SubscriptionCreated = Subscription & {
|
||||||
printed: boolean;
|
printed: boolean;
|
||||||
printedBy?: string;
|
printedBy?: string;
|
||||||
printError?: string;
|
printError?: string;
|
||||||
|
/** Present when a priced subscription was sold: the signed payment just appended. */
|
||||||
|
sale?: {
|
||||||
|
amountMinor: number;
|
||||||
|
currency: string | null;
|
||||||
|
tender: "cash" | "card";
|
||||||
|
periods: number;
|
||||||
|
inShift: boolean;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export function fetchSubscriptions(): Promise<{ subscriptions: Subscription[] }> {
|
export function fetchSubscriptions(): Promise<{ subscriptions: Subscription[] }> {
|
||||||
return apiFetch("/api/subscriptions");
|
return apiFetch("/api/subscriptions");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Subscription plan catalog (admin-composed; operator sells from it) ------
|
||||||
|
export function fetchSubscriptionPlans(all = false): Promise<{ plans: SubscriptionPlan[] }> {
|
||||||
|
return apiFetch(`/api/subscription-plans${all ? "?all=1" : ""}`);
|
||||||
|
}
|
||||||
|
export function createSubscriptionPlan(body: {
|
||||||
|
planId?: string;
|
||||||
|
name: string;
|
||||||
|
period: SubscriptionPeriod;
|
||||||
|
pricePerPeriodMinor: number;
|
||||||
|
currency: string;
|
||||||
|
effectiveFrom?: string;
|
||||||
|
timeframes?: PlanTimeframes | null;
|
||||||
|
}): Promise<SubscriptionPlan> {
|
||||||
|
return apiFetch("/api/subscription-plans", { method: "POST", body: JSON.stringify(body) });
|
||||||
|
}
|
||||||
|
export function retireSubscriptionPlan(planId: string): Promise<{ planId: string; retired: boolean }> {
|
||||||
|
return apiFetch(`/api/subscription-plans/${encodeURIComponent(planId)}/retire`, { method: "POST" });
|
||||||
|
}
|
||||||
|
export function reactivateSubscriptionPlan(planId: string): Promise<{ planId: string; reactivated: boolean }> {
|
||||||
|
return apiFetch(`/api/subscription-plans/${encodeURIComponent(planId)}/reactivate`, { method: "POST" });
|
||||||
|
}
|
||||||
|
/** Delete a plan (all versions). Rejects (409 plan_in_use) if any subscription uses it. */
|
||||||
|
export function deleteSubscriptionPlan(planId: string): Promise<{ planId: string; deleted: boolean }> {
|
||||||
|
return apiFetch(`/api/subscription-plans/${encodeURIComponent(planId)}`, { method: "DELETE" });
|
||||||
|
}
|
||||||
|
/** Live quote for the sell form (server-computed; the operator can't override it). */
|
||||||
|
export function quoteSubscription(body: {
|
||||||
|
planId: string;
|
||||||
|
validFrom: string | null;
|
||||||
|
validTo: string;
|
||||||
|
quantity?: number;
|
||||||
|
}): Promise<SubscriptionQuote> {
|
||||||
|
return apiFetch("/api/subscriptions/quote", { method: "POST", body: JSON.stringify(body) });
|
||||||
|
}
|
||||||
export function createSubscription(body: SubscriptionInput): Promise<SubscriptionCreated> {
|
export function createSubscription(body: SubscriptionInput): Promise<SubscriptionCreated> {
|
||||||
return apiFetch("/api/subscriptions", { method: "POST", body: JSON.stringify(body) });
|
return apiFetch("/api/subscriptions", { method: "POST", body: JSON.stringify(body) });
|
||||||
}
|
}
|
||||||
@@ -560,14 +712,48 @@ export function closeShift(): Promise<ShiftReport> {
|
|||||||
return apiFetch("/api/shift/close", { method: "POST" });
|
return apiFetch("/api/shift/close", { method: "POST" });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Admin loads/removes physical drawer cash. amountMinor signed: + load, − remove. */
|
/** Mid-shift X-report: the open shift's takings + drawer "so far" (read-only — no
|
||||||
export function recordCashMovement(
|
* event is appended). Same figures the Z-report will print at close. `asOf` is the
|
||||||
amountMinor: number,
|
* snapshot instant. */
|
||||||
reason: string,
|
export interface XReport {
|
||||||
): Promise<{ amountMinor: number; balanceMinor: number }> {
|
operator: string;
|
||||||
return apiFetch("/api/cash-movement", {
|
startedAt: string;
|
||||||
|
endedAt: string; // = asOf
|
||||||
|
asOf: string;
|
||||||
|
cashTotalMinor: number;
|
||||||
|
cardTotalMinor: number;
|
||||||
|
currency: string | null;
|
||||||
|
paymentCount: number;
|
||||||
|
openingFloatMinor: number;
|
||||||
|
cashAddedMinor: number;
|
||||||
|
cashRemovedMinor: number;
|
||||||
|
expectedDrawerMinor: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetch the mid-shift X-report; resolves to null when no shift is open (204). */
|
||||||
|
export async function fetchShiftReport(): Promise<XReport | null> {
|
||||||
|
return (await apiFetch<XReport | undefined>("/api/shift/report")) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A drawer cash voucher: Mandat Arkëtimi (cash_in / pay-IN) or Mandat Pagese
|
||||||
|
* (cash_out / pay-OUT). Direction is the TYPE, amountMinor a positive magnitude.
|
||||||
|
* Operator-raised, admin-authorized (authorizedBy + their password). */
|
||||||
|
export function recordCashVoucher(args: {
|
||||||
|
type: "cash_in" | "cash_out";
|
||||||
|
amountMinor: number;
|
||||||
|
reason: string;
|
||||||
|
authorizedBy: string;
|
||||||
|
authorizerPassword: string;
|
||||||
|
}): Promise<{
|
||||||
|
type: "cash_in" | "cash_out";
|
||||||
|
amountMinor: number;
|
||||||
|
voucherNo: string;
|
||||||
|
balanceMinor: number;
|
||||||
|
printed: boolean;
|
||||||
|
}> {
|
||||||
|
return apiFetch("/api/cash-voucher", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ amountMinor, reason }),
|
body: JSON.stringify(args),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -620,6 +806,8 @@ export interface SiteConfig {
|
|||||||
exitVoucherDefault: boolean;
|
exitVoucherDefault: boolean;
|
||||||
/** Site default monthly subscription price (minor units); pre-fills the form. */
|
/** Site default monthly subscription price (minor units); pre-fills the form. */
|
||||||
subscriptionMonthlyPriceMinor: number | null;
|
subscriptionMonthlyPriceMinor: number | null;
|
||||||
|
/** Reserve a spot for each active subscriber's car(s) in the occupancy/full gate. */
|
||||||
|
reserveSubscriberSpots: boolean;
|
||||||
parkName: string | null;
|
parkName: string | null;
|
||||||
operatorName: string | null;
|
operatorName: string | null;
|
||||||
/** NIUS — Albanian tax/identification number. */
|
/** NIUS — Albanian tax/identification number. */
|
||||||
@@ -694,10 +882,15 @@ export interface SessionLookup {
|
|||||||
currency: string | null;
|
currency: string | null;
|
||||||
withinGrace: boolean;
|
withinGrace: boolean;
|
||||||
graceExpiresAt: string | null;
|
graceExpiresAt: string | null;
|
||||||
|
/** OVERSTAY: paid transient, walk-back grace expired, no exit — a new period began;
|
||||||
|
* owes a fresh top-up (amountMinor); cannot exit for free. */
|
||||||
|
overstay: boolean;
|
||||||
/** A subscription occurrence (prepaid — no pay flow; barrier-open assist only). */
|
/** A subscription occurrence (prepaid — no pay flow; barrier-open assist only). */
|
||||||
subscription: boolean;
|
subscription: boolean;
|
||||||
subscriptionId: string | null;
|
subscriptionId: string | null;
|
||||||
subscriptionHolder: string | null;
|
subscriptionHolder: string | null;
|
||||||
|
/** Advisory licence plate recognized for this session (ANPR). Null when none. */
|
||||||
|
plate: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Look up a ticket/session for the booth modal (entry/exit, paid, amount owed). */
|
/** Look up a ticket/session for the booth modal (entry/exit, paid, amount owed). */
|
||||||
@@ -717,10 +910,16 @@ export interface ActiveSession {
|
|||||||
currency: string | null;
|
currency: string | null;
|
||||||
withinGrace: boolean;
|
withinGrace: boolean;
|
||||||
graceExpiresAt: string | null;
|
graceExpiresAt: string | null;
|
||||||
|
/** OVERSTAY: paid transient whose walk-back grace lapsed with no signed exit — a new
|
||||||
|
* period began (re-parked) or the car is faulty/abandoned. Owes a fresh top-up;
|
||||||
|
* flagged so the operator reconciles, never a free exit. */
|
||||||
|
overstay: boolean;
|
||||||
/** A subscription occurrence (prepaid — no pay flow; barrier-open assist only). */
|
/** A subscription occurrence (prepaid — no pay flow; barrier-open assist only). */
|
||||||
subscription: boolean;
|
subscription: boolean;
|
||||||
subscriptionId: string | null;
|
subscriptionId: string | null;
|
||||||
subscriptionHolder: string | null;
|
subscriptionHolder: string | null;
|
||||||
|
/** Advisory licence plate recognized for this session (ANPR). Null when none. */
|
||||||
|
plate: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Active sessions: still-inside OR exited-but-within-grace (barrier unconfirmed). */
|
/** Active sessions: still-inside OR exited-but-within-grace (barrier unconfirmed). */
|
||||||
|
|||||||
+157
-10
@@ -44,7 +44,9 @@ export const en: Catalog = {
|
|||||||
setup: "Setup",
|
setup: "Setup",
|
||||||
devices: "Devices",
|
devices: "Devices",
|
||||||
tariff: "Tariff",
|
tariff: "Tariff",
|
||||||
|
tariffLab: "Tariff Lab",
|
||||||
subscriptions: "Subscriptions",
|
subscriptions: "Subscriptions",
|
||||||
|
plans: "Plans",
|
||||||
site: "Site",
|
site: "Site",
|
||||||
users: "Users",
|
users: "Users",
|
||||||
roles: "Roles",
|
roles: "Roles",
|
||||||
@@ -59,11 +61,11 @@ export const en: Catalog = {
|
|||||||
devices: {
|
devices: {
|
||||||
footerTitle: "Devices",
|
footerTitle: "Devices",
|
||||||
none: "No devices configured.",
|
none: "No devices configured.",
|
||||||
catAccess: "Barrier",
|
catAccess: "Relay",
|
||||||
catReader: "Reader",
|
catReader: "Reader",
|
||||||
catCamera: "Camera",
|
catCamera: "Camera",
|
||||||
catPrinter: "Printer",
|
catPrinter: "Printer",
|
||||||
catVision: "Vision",
|
catVision: "ANPR",
|
||||||
// Role/direction suffixes for the chip label (e.g. "Reader entry").
|
// Role/direction suffixes for the chip label (e.g. "Reader entry").
|
||||||
role: {
|
role: {
|
||||||
entry: "entry",
|
entry: "entry",
|
||||||
@@ -101,6 +103,30 @@ export const en: Catalog = {
|
|||||||
activeSessions: "Active sessions",
|
activeSessions: "Active sessions",
|
||||||
insideCount: "inside",
|
insideCount: "inside",
|
||||||
noActiveSessions: "No active sessions.",
|
noActiveSessions: "No active sessions.",
|
||||||
|
noMatch: "No sessions match the filter.",
|
||||||
|
badgeOverstay: "overstay",
|
||||||
|
badgeOverstayTitle:
|
||||||
|
"Paid session. The customer failed to exit during the grace period. A new period began.",
|
||||||
|
plateTitle: "Licence plate recognized by the camera (advisory — not an access decision).",
|
||||||
|
// filters
|
||||||
|
filterSearchSessions: "Search ticket / subscriber / plate…",
|
||||||
|
filterSearchFeed: "Search event / identity / plate…",
|
||||||
|
filterAll: "All",
|
||||||
|
fStatusUnpaid: "Unpaid",
|
||||||
|
fStatusPaid: "Paid",
|
||||||
|
fStatusExiting: "Exiting",
|
||||||
|
fStatusOverstay: "Overstay",
|
||||||
|
fKindTransient: "Transient",
|
||||||
|
fKindSubscription: "Subscribers",
|
||||||
|
fDirEntry: "Entry",
|
||||||
|
fDirExit: "Exit",
|
||||||
|
fSrcBooth: "Booth",
|
||||||
|
fSrcReader: "Reader",
|
||||||
|
fEvtEntry: "Entry",
|
||||||
|
fEvtExit: "Exit",
|
||||||
|
fEvtPay: "Pay",
|
||||||
|
fEvtVoid: "Void",
|
||||||
|
fEvtAnomaly: "Anomaly",
|
||||||
openPayExit: "Open pay / exit",
|
openPayExit: "Open pay / exit",
|
||||||
openBarrier: "Open barrier",
|
openBarrier: "Open barrier",
|
||||||
openBarrierTitle: "Human-intervention barrier open (audited)",
|
openBarrierTitle: "Human-intervention barrier open (audited)",
|
||||||
@@ -119,6 +145,8 @@ export const en: Catalog = {
|
|||||||
evtShiftOpen: "SHIFT+",
|
evtShiftOpen: "SHIFT+",
|
||||||
evtShiftZ: "SHIFT Z",
|
evtShiftZ: "SHIFT Z",
|
||||||
evtCashMovement: "CASH",
|
evtCashMovement: "CASH",
|
||||||
|
evtCashIn: "PAY-IN",
|
||||||
|
evtCashOut: "PAY-OUT",
|
||||||
evtAnomaly: "ANOMALY",
|
evtAnomaly: "ANOMALY",
|
||||||
// live-feed event detail line + classification badges (computed from payload)
|
// live-feed event detail line + classification badges (computed from payload)
|
||||||
evtNoReason: "no reason recorded",
|
evtNoReason: "no reason recorded",
|
||||||
@@ -128,6 +156,8 @@ export const en: Catalog = {
|
|||||||
badgeBarrierFailed: "barrier did not open",
|
badgeBarrierFailed: "barrier did not open",
|
||||||
badgeManualOpen: "manual open",
|
badgeManualOpen: "manual open",
|
||||||
badgeSubRefused: "subscription refused",
|
badgeSubRefused: "subscription refused",
|
||||||
|
badgeSubSale: "subscription sale",
|
||||||
|
badgeWindowCharge: "out-of-window — owes fee",
|
||||||
badgeNoTicket: "ticket not printed",
|
badgeNoTicket: "ticket not printed",
|
||||||
feedSourceBooth: "booth",
|
feedSourceBooth: "booth",
|
||||||
feedSourceReader: "reader",
|
feedSourceReader: "reader",
|
||||||
@@ -185,6 +215,7 @@ export const en: Catalog = {
|
|||||||
"sub.refused.outOfWindow": "Subscription refused — {{status}}/out-of-window",
|
"sub.refused.outOfWindow": "Subscription refused — {{status}}/out-of-window",
|
||||||
"sub.refused.noSession": "Subscription exit with no open session (already out / never entered)",
|
"sub.refused.noSession": "Subscription exit with no open session (already out / never entered)",
|
||||||
"sub.refused.atCapacity": "Subscription refused — at capacity ({{inUse}}/{{max}} cars in)",
|
"sub.refused.atCapacity": "Subscription refused — at capacity ({{inUse}}/{{max}} cars in)",
|
||||||
|
"sub.refused.unpaidWindow": "Exit refused — out-of-window charge unpaid ({{amount}} {{currency}}); pay at the booth",
|
||||||
},
|
},
|
||||||
tariff: {
|
tariff: {
|
||||||
title: "Tariff",
|
title: "Tariff",
|
||||||
@@ -213,6 +244,14 @@ export const en: Catalog = {
|
|||||||
defaultCardHint: "The base rate applied when no time/seasonal tier matches. This alone is enough for most car parks.",
|
defaultCardHint: "The base rate applied when no time/seasonal tier matches. This alone is enough for most car parks.",
|
||||||
modeLadder: "Hourly ladder",
|
modeLadder: "Hourly ladder",
|
||||||
modeFlat: "Flat price",
|
modeFlat: "Flat price",
|
||||||
|
modeStepped: "By duration (up-to)",
|
||||||
|
steppedHint:
|
||||||
|
"Set the TOTAL price for a stay up to a given time (e.g. up to 3h = 500). The first row whose limit ≥ the duration wins (the limit is inclusive). The last row's total repeats as a per-day price for longer stays.",
|
||||||
|
stepUpTo: "Up to",
|
||||||
|
stepTotal: "Total price",
|
||||||
|
addStep: "+ Add row",
|
||||||
|
steppedTiersConflict:
|
||||||
|
"⚠ Time/seasonal tiers do NOT apply when the base rate is 'By duration (up-to)' — the engine ignores them entirely. Remove the tiers, or switch the base rate to 'Hourly ladder' or 'Flat price'. Publishing is blocked until this is fixed.",
|
||||||
tiersAdvanced: "Advanced: time & seasonal tiers",
|
tiersAdvanced: "Advanced: time & seasonal tiers",
|
||||||
tiersHint: "Optional. Add tiers that apply only at certain hours/days/dates or for a category (e.g. happy hour, night rate, weekend, bus). With no tiers, just the base rate is published.",
|
tiersHint: "Optional. Add tiers that apply only at certain hours/days/dates or for a category (e.g. happy hour, night rate, weekend, bus). With no tiers, just the base rate is published.",
|
||||||
tierName: "Name",
|
tierName: "Name",
|
||||||
@@ -307,6 +346,36 @@ export const en: Catalog = {
|
|||||||
relayLabel: "Relay {{relay}} ({{direction}})",
|
relayLabel: "Relay {{relay}} ({{direction}})",
|
||||||
noRelaysConfigured: "This controller has no relays configured.",
|
noRelaysConfigured: "This controller has no relays configured.",
|
||||||
},
|
},
|
||||||
|
lab: {
|
||||||
|
title: "Tariff Lab",
|
||||||
|
intro:
|
||||||
|
"Test rates in time (day/night windows, daily caps, overstay) in seconds, with no waiting. Pricing uses the same logic as the booth; nothing is written to the ledger.",
|
||||||
|
loadTicket: "Load from a real ticket",
|
||||||
|
loadTicketPh: "Ticket number / identity",
|
||||||
|
load: "Load",
|
||||||
|
loaded: "Loaded session {{id}}",
|
||||||
|
tariffVersion: "Tariff version",
|
||||||
|
activeVersion: "Active version (current)",
|
||||||
|
entered: "Entered",
|
||||||
|
asOf: "As of (now/exit)",
|
||||||
|
now: "Now",
|
||||||
|
category: "Category",
|
||||||
|
categoryPh: "e.g. bus (blank = car)",
|
||||||
|
payment: "Payment",
|
||||||
|
paid: "paid",
|
||||||
|
graceMin: "grace (min)",
|
||||||
|
price: "Compute price",
|
||||||
|
pricing: "Pricing…",
|
||||||
|
outcome: "Outcome",
|
||||||
|
amountDue: "Amount due",
|
||||||
|
billedPeriod: "Billed period",
|
||||||
|
overstay: "overstay",
|
||||||
|
settled: "settled",
|
||||||
|
periodStart: "Period start",
|
||||||
|
graceExpires: "Grace expires",
|
||||||
|
curve: "Duration curve",
|
||||||
|
curveHint: "Fee from entry at several durations — see where the daily cap flattens or windows shift.",
|
||||||
|
},
|
||||||
subs: {
|
subs: {
|
||||||
title: "Subscriptions",
|
title: "Subscriptions",
|
||||||
unnamed: "(unnamed)",
|
unnamed: "(unnamed)",
|
||||||
@@ -316,9 +385,24 @@ export const en: Catalog = {
|
|||||||
cred: "cred",
|
cred: "cred",
|
||||||
plates: "{{count}} plate(s)",
|
plates: "{{count}} plate(s)",
|
||||||
noPrice: "no price",
|
noPrice: "no price",
|
||||||
|
perDay: "day",
|
||||||
|
perWeek: "week",
|
||||||
perMonth: "month",
|
perMonth: "month",
|
||||||
monthlyPrice: "Monthly price",
|
plan: "Plan",
|
||||||
pricePlaceholder: "e.g. 10000",
|
quantity: "Cars",
|
||||||
|
quantityHint: "cars covered by this subscription (price ×N)",
|
||||||
|
count: "How many",
|
||||||
|
planNone: "— comp / no charge —",
|
||||||
|
planNoneAvail: "No plans defined — an admin must create one first.",
|
||||||
|
quoting: "pricing…",
|
||||||
|
quotePrompt: "pick an end date",
|
||||||
|
quoteLine: "{{periods}} × {{unit}} · {{amount}} {{currency}}",
|
||||||
|
tender: "Paid by",
|
||||||
|
tenderCash: "Cash",
|
||||||
|
tenderCard: "Card",
|
||||||
|
tenderHint: "Recorded as a signed payment (feed, drawer, Z-report).",
|
||||||
|
saleRecorded: "Sale recorded: {{amount}} {{currency}} ({{tender}}).",
|
||||||
|
saleNoShift: "⚠ No shift was open — open one so the takings land in a Z-report.",
|
||||||
edit: "Edit",
|
edit: "Edit",
|
||||||
revoke: "Revoke",
|
revoke: "Revoke",
|
||||||
delete: "Delete",
|
delete: "Delete",
|
||||||
@@ -332,11 +416,7 @@ export const en: Catalog = {
|
|||||||
limitCarsInAtOnce: "limit cars in at once",
|
limitCarsInAtOnce: "limit cars in at once",
|
||||||
validFrom: "Valid from",
|
validFrom: "Valid from",
|
||||||
validTo: "Valid to",
|
validTo: "Valid to",
|
||||||
months: "Months",
|
validToEnd: "Valid to (end)",
|
||||||
monthsHint: "months paid",
|
|
||||||
coverageHint: "until {{end}}",
|
|
||||||
totalDue: "total {{total}}",
|
|
||||||
validToOverride: "Valid to (manual)",
|
|
||||||
isoDateOptional: "ISO date (optional)",
|
isoDateOptional: "ISO date (optional)",
|
||||||
boundPlates: "Bound plates",
|
boundPlates: "Bound plates",
|
||||||
commaSeparatedOptional: "comma-separated (optional)",
|
commaSeparatedOptional: "comma-separated (optional)",
|
||||||
@@ -369,6 +449,54 @@ export const en: Catalog = {
|
|||||||
statusSuspended: "suspended",
|
statusSuspended: "suspended",
|
||||||
statusRevoked: "revoked",
|
statusRevoked: "revoked",
|
||||||
},
|
},
|
||||||
|
plans: {
|
||||||
|
title: "Subscription plans",
|
||||||
|
intro: "Admin-defined plans the operator sells from — the price is looked up, never typed. Editing a plan publishes a new version; past sales keep their recorded price.",
|
||||||
|
add: "+ Add plan",
|
||||||
|
noneYet: "No plans yet. Add one so the booth can sell subscriptions.",
|
||||||
|
colName: "Name",
|
||||||
|
colPrice: "Price",
|
||||||
|
colHours: "Hours",
|
||||||
|
colUsedBy: "Used by",
|
||||||
|
colEffective: "Effective",
|
||||||
|
allHours: "24/7",
|
||||||
|
usedByCount: "{{active}} active / {{total}}",
|
||||||
|
usedByNone: "none",
|
||||||
|
usedByTitle: "Show the subscriptions on this plan",
|
||||||
|
subscribers: "Subscribers",
|
||||||
|
inForce: "in force",
|
||||||
|
retired: "retired",
|
||||||
|
versionCount: "{{count}} versions",
|
||||||
|
newVersion: "New version",
|
||||||
|
retire: "Retire",
|
||||||
|
reactivate: "Reactivate",
|
||||||
|
delete: "Delete",
|
||||||
|
deleteTitle: "Delete this plan permanently (only when no subscription uses it)",
|
||||||
|
reactivated: "“{{name}}” is sellable again.",
|
||||||
|
confirmDelete: "Delete the plan “{{name}}” permanently? This can't be undone.",
|
||||||
|
deleted: "Plan “{{name}}” deleted.",
|
||||||
|
deleteInUse: "Can't delete — subscriptions still use this plan. Retire it instead.",
|
||||||
|
newTitle: "New plan",
|
||||||
|
newVersionTitle: "Publish new version",
|
||||||
|
newVersionHint: "This publishes a NEW version of the plan — existing sales keep their original price.",
|
||||||
|
period: "Period",
|
||||||
|
pricePer: "Price per period",
|
||||||
|
namePlaceholder: "e.g. Hotel daily",
|
||||||
|
needName: "A plan name is required.",
|
||||||
|
needPrice: "Enter a price greater than zero.",
|
||||||
|
saved: "Plan saved.",
|
||||||
|
confirmRetire: "Retire the plan “{{name}}”? It will no longer be sellable (history is kept).",
|
||||||
|
needWindow: "Enter valid window times (HH:MM).",
|
||||||
|
needDays: "Select at least one day for the window.",
|
||||||
|
restrictTimes: "Restrict parking times (charge transient tariff outside the window)",
|
||||||
|
days: "Days",
|
||||||
|
window: "Window",
|
||||||
|
enterAfter: "enter after",
|
||||||
|
exitBefore: "· exit before",
|
||||||
|
grace: "Grace",
|
||||||
|
graceHint: "minutes tolerance around the window edges",
|
||||||
|
timeframesHint: "A scan outside the allowed window is charged the normal transient tariff for the out-of-window minutes (early entry is deferred to exit; late exit is gated until paid).",
|
||||||
|
},
|
||||||
site: {
|
site: {
|
||||||
occupancy: "Occupancy:",
|
occupancy: "Occupancy:",
|
||||||
noCapacitySet: "(no capacity set)",
|
noCapacitySet: "(no capacity set)",
|
||||||
@@ -378,6 +506,8 @@ export const en: Catalog = {
|
|||||||
capacityPlaceholder: "e.g. 120",
|
capacityPlaceholder: "e.g. 120",
|
||||||
printExitDefault: "Print exit ticket by default",
|
printExitDefault: "Print exit ticket by default",
|
||||||
printExitHint: "(booth far from exit → customer self-exits with a voucher)",
|
printExitHint: "(booth far from exit → customer self-exits with a voucher)",
|
||||||
|
reserveSubs: "Reserve subscriber spots",
|
||||||
|
reserveSubsHint: "Hold a spot for each active subscriber's car(s) even when they're not parked — transients see 'full' sooner. Off: only cars inside count (handle overflow by valet).",
|
||||||
parkDetails: "Park details (optional — shown on tickets/receipts)",
|
parkDetails: "Park details (optional — shown on tickets/receipts)",
|
||||||
save: "Save",
|
save: "Save",
|
||||||
saved: "Saved.",
|
saved: "Saved.",
|
||||||
@@ -441,13 +571,25 @@ export const en: Catalog = {
|
|||||||
drawer: "Drawer:",
|
drawer: "Drawer:",
|
||||||
openingFloatInherited: "(opening float inherited from the prior shift)",
|
openingFloatInherited: "(opening float inherited from the prior shift)",
|
||||||
drawerCashAdmin: "Drawer cash (admin) — load or remove the float",
|
drawerCashAdmin: "Drawer cash (admin) — load or remove the float",
|
||||||
|
drawerVoucher: "Drawer voucher — operator raises, an admin authorizes",
|
||||||
amount: "amount",
|
amount: "amount",
|
||||||
reasonPlaceholder: "reason (e.g. opening float)",
|
reasonPlaceholder: "reason (e.g. opening float)",
|
||||||
load: "Load +",
|
load: "Load +",
|
||||||
remove: "Remove −",
|
remove: "Remove −",
|
||||||
|
authName: "admin username",
|
||||||
|
authPassword: "admin password",
|
||||||
|
authRequired: "An admin must authorize: enter their username and password.",
|
||||||
|
mandatArketimi: "Receipt (in) +",
|
||||||
|
mandatPagese: "Disbursement (out) −",
|
||||||
|
voucherHint: "A receipt (Mandat Arkëtimi) adds cash; a disbursement (Mandat Pagese) removes it. The float only moves with an admin's sign-off.",
|
||||||
|
voucherRecorded: "Voucher {{no}} recorded. Drawer now {{amount}}.",
|
||||||
enterPositive: "Enter a positive amount.",
|
enterPositive: "Enter a positive amount.",
|
||||||
drawerNow: "Drawer now {{amount}}.",
|
drawerNow: "Drawer now {{amount}}.",
|
||||||
zReport: "Z-REPORT",
|
zReport: "SHIFT CLOSE",
|
||||||
|
viewTakings: "Takings so far",
|
||||||
|
xReport: "TAKINGS SO FAR",
|
||||||
|
asOf: "as of",
|
||||||
|
xReportHint: "View only — nothing is recorded. These figures are signed when the shift is closed.",
|
||||||
payments: "Payments:",
|
payments: "Payments:",
|
||||||
cash: "Cash:",
|
cash: "Cash:",
|
||||||
card: "Card:",
|
card: "Card:",
|
||||||
@@ -523,6 +665,9 @@ export const en: Catalog = {
|
|||||||
statusLabel: "Status",
|
statusLabel: "Status",
|
||||||
paid: "PAID",
|
paid: "PAID",
|
||||||
unpaid: "UNPAID",
|
unpaid: "UNPAID",
|
||||||
|
overstay: "OVERSTAY",
|
||||||
|
overstayHint: "Earlier session paid. The customer failed to exit during the grace period. Payment for the new period is required. The total below is the new period's fee.",
|
||||||
|
topUp: "New period due",
|
||||||
total: "Total",
|
total: "Total",
|
||||||
noTariff: "no tariff",
|
noTariff: "no tariff",
|
||||||
tender: "Tender",
|
tender: "Tender",
|
||||||
@@ -546,6 +691,8 @@ export const en: Catalog = {
|
|||||||
plan: "Plan",
|
plan: "Plan",
|
||||||
prepaid: "PREPAID",
|
prepaid: "PREPAID",
|
||||||
subAssistHint: "Prepaid subscription. Open the barrier to assist the exit (faulty reader / missing card). No payment.",
|
subAssistHint: "Prepaid subscription. Open the barrier to assist the exit (faulty reader / missing card). No payment.",
|
||||||
|
windowCharge: "OUT-OF-WINDOW",
|
||||||
|
windowChargeHint: "This subscriber parked outside their plan's allowed hours. They owe the transient tariff for the out-of-window time — take payment to allow the exit.",
|
||||||
subBarrierOpened: "Barrier opened for the subscriber (intervention recorded).",
|
subBarrierOpened: "Barrier opened for the subscriber (intervention recorded).",
|
||||||
voucherPrinted: "Exit voucher printed on {{printer}}. Customer self-exits at the exit.",
|
voucherPrinted: "Exit voucher printed on {{printer}}. Customer self-exits at the exit.",
|
||||||
// payment receipt (transparency slip)
|
// payment receipt (transparency slip)
|
||||||
|
|||||||
+162
-15
@@ -46,12 +46,14 @@ export const sq = {
|
|||||||
setup: "Konfigurimi",
|
setup: "Konfigurimi",
|
||||||
devices: "Pajisjet",
|
devices: "Pajisjet",
|
||||||
tariff: "Tarifa",
|
tariff: "Tarifa",
|
||||||
|
tariffLab: "Lab Tarife",
|
||||||
subscriptions: "Abonimet",
|
subscriptions: "Abonimet",
|
||||||
|
plans: "Planet",
|
||||||
site: "Park",
|
site: "Park",
|
||||||
users: "Përdoruesit",
|
users: "Përdoruesit",
|
||||||
roles: "Rolet",
|
roles: "Rolet",
|
||||||
shifts: "Turnet",
|
shifts: "Turnet",
|
||||||
logs: "Regjistrat",
|
logs: "Loget",
|
||||||
},
|
},
|
||||||
status: {
|
status: {
|
||||||
live: "LIVE",
|
live: "LIVE",
|
||||||
@@ -61,11 +63,11 @@ export const sq = {
|
|||||||
devices: {
|
devices: {
|
||||||
footerTitle: "Pajisjet",
|
footerTitle: "Pajisjet",
|
||||||
none: "Asnjë pajisje e konfiguruar.",
|
none: "Asnjë pajisje e konfiguruar.",
|
||||||
catAccess: "Barriera",
|
catAccess: "Rele",
|
||||||
catReader: "Lexuesi",
|
catReader: "Lexuesi",
|
||||||
catCamera: "Kamera",
|
catCamera: "Kamera",
|
||||||
catPrinter: "Printer",
|
catPrinter: "Printer",
|
||||||
catVision: "Vizioni",
|
catVision: "ANPR",
|
||||||
// Role/direction suffixes for the chip label (e.g. "Lexuesi hyrje").
|
// Role/direction suffixes for the chip label (e.g. "Lexuesi hyrje").
|
||||||
role: {
|
role: {
|
||||||
entry: "hyrje",
|
entry: "hyrje",
|
||||||
@@ -103,6 +105,30 @@ export const sq = {
|
|||||||
activeSessions: "Sesionet aktive",
|
activeSessions: "Sesionet aktive",
|
||||||
insideCount: "brenda",
|
insideCount: "brenda",
|
||||||
noActiveSessions: "Asnjë sesion aktiv.",
|
noActiveSessions: "Asnjë sesion aktiv.",
|
||||||
|
noMatch: "Asnjë rezultat për filtrin.",
|
||||||
|
badgeOverstay: "tej afatit",
|
||||||
|
badgeOverstayTitle:
|
||||||
|
"Sesion i paguar. Klienti nuk doli brënda afatit kohor. Ka filluar një periudhë e re tarifimi.",
|
||||||
|
plateTitle: "Targa e njohur nga kamera (orientuese — nuk është vendim aksesi).",
|
||||||
|
// filtra
|
||||||
|
filterSearchSessions: "Kërko biletë / abonent / targë…",
|
||||||
|
filterSearchFeed: "Kërko event / identitet / targë…",
|
||||||
|
filterAll: "Të gjitha",
|
||||||
|
fStatusUnpaid: "Papaguar",
|
||||||
|
fStatusPaid: "Paguar",
|
||||||
|
fStatusExiting: "Duke dalë",
|
||||||
|
fStatusOverstay: "Tej afatit",
|
||||||
|
fKindTransient: "Kalimtarë",
|
||||||
|
fKindSubscription: "Abonentë",
|
||||||
|
fDirEntry: "Hyrje",
|
||||||
|
fDirExit: "Dalje",
|
||||||
|
fSrcBooth: "Kabinë",
|
||||||
|
fSrcReader: "Lexues",
|
||||||
|
fEvtEntry: "Hyrje",
|
||||||
|
fEvtExit: "Dalje",
|
||||||
|
fEvtPay: "Pagesë",
|
||||||
|
fEvtVoid: "Anulim",
|
||||||
|
fEvtAnomaly: "Anomali",
|
||||||
openPayExit: "Hap pagesën / daljen",
|
openPayExit: "Hap pagesën / daljen",
|
||||||
openBarrier: "Hap barrierën",
|
openBarrier: "Hap barrierën",
|
||||||
openBarrierTitle: "Hap barrierën manualisht",
|
openBarrierTitle: "Hap barrierën manualisht",
|
||||||
@@ -123,6 +149,8 @@ export const sq = {
|
|||||||
evtShiftOpen: "TURN+",
|
evtShiftOpen: "TURN+",
|
||||||
evtShiftZ: "TURN Z",
|
evtShiftZ: "TURN Z",
|
||||||
evtCashMovement: "ARKË",
|
evtCashMovement: "ARKË",
|
||||||
|
evtCashIn: "ARKËTIM",
|
||||||
|
evtCashOut: "PAGESË",
|
||||||
evtAnomaly: "ANOMALI",
|
evtAnomaly: "ANOMALI",
|
||||||
// rreshti i detajeve të eventit live + etiketat e klasifikimit (nga payload)
|
// rreshti i detajeve të eventit live + etiketat e klasifikimit (nga payload)
|
||||||
evtNoReason: "pa arsye të regjistruar",
|
evtNoReason: "pa arsye të regjistruar",
|
||||||
@@ -132,6 +160,8 @@ export const sq = {
|
|||||||
badgeBarrierFailed: "barriera nuk u hap",
|
badgeBarrierFailed: "barriera nuk u hap",
|
||||||
badgeManualOpen: "hapje manuale",
|
badgeManualOpen: "hapje manuale",
|
||||||
badgeSubRefused: "abonimi u refuzua",
|
badgeSubRefused: "abonimi u refuzua",
|
||||||
|
badgeSubSale: "shitje abonimi",
|
||||||
|
badgeWindowCharge: "jashtë orarit — detyrim",
|
||||||
badgeNoTicket: "bileta nuk u printua",
|
badgeNoTicket: "bileta nuk u printua",
|
||||||
feedSourceBooth: "kabinë",
|
feedSourceBooth: "kabinë",
|
||||||
feedSourceReader: "lexues",
|
feedSourceReader: "lexues",
|
||||||
@@ -188,6 +218,7 @@ export const sq = {
|
|||||||
"sub.refused.outOfWindow": "Abonimi u refuzua — {{status}}/jashtë afatit",
|
"sub.refused.outOfWindow": "Abonimi u refuzua — {{status}}/jashtë afatit",
|
||||||
"sub.refused.noSession": "Dalje me abonim pa sesion të hapur (tashmë jashtë / nuk ka hyrë kurrë)",
|
"sub.refused.noSession": "Dalje me abonim pa sesion të hapur (tashmë jashtë / nuk ka hyrë kurrë)",
|
||||||
"sub.refused.atCapacity": "Abonimi u refuzua — në kapacitet ({{inUse}}/{{max}} makina brenda)",
|
"sub.refused.atCapacity": "Abonimi u refuzua — në kapacitet ({{inUse}}/{{max}} makina brenda)",
|
||||||
|
"sub.refused.unpaidWindow": "Dalja u refuzua — detyrim jashtë orarit i papaguar ({{amount}} {{currency}}); paguaje në kabinë",
|
||||||
},
|
},
|
||||||
tariff: {
|
tariff: {
|
||||||
title: "Tarifa",
|
title: "Tarifa",
|
||||||
@@ -216,6 +247,14 @@ export const sq = {
|
|||||||
defaultCardHint: "Çmimi bazë i zbatuar kur asnjë nivel kohor/sezonal nuk vlen. Kjo e vetme është mjaftueshëm për shumicën e parkimeve.",
|
defaultCardHint: "Çmimi bazë i zbatuar kur asnjë nivel kohor/sezonal nuk vlen. Kjo e vetme është mjaftueshëm për shumicën e parkimeve.",
|
||||||
modeLadder: "Shkallë orësh",
|
modeLadder: "Shkallë orësh",
|
||||||
modeFlat: "Çmim fiks",
|
modeFlat: "Çmim fiks",
|
||||||
|
modeStepped: "Sipas kohëzgjatjes (deri-në)",
|
||||||
|
steppedHint:
|
||||||
|
"Vendos çmimin TOTAL për një qëndrim deri në një kohë të caktuar (p.sh. deri 3 orë = 500). Fiton rreshti i parë me kufi ≥ kohëzgjatjes (kufiri përfshihet). Totali i rreshtit të fundit përsëritet si çmim ditor për qëndrime më të gjata.",
|
||||||
|
stepUpTo: "Deri në",
|
||||||
|
stepTotal: "Çmimi total",
|
||||||
|
addStep: "+ Shto rresht",
|
||||||
|
steppedTiersConflict:
|
||||||
|
"⚠ Nivelet kohore/sezonale NUK zbatohen kur tarifa bazë është 'Sipas kohëzgjatjes (deri-në)' — motori i shpërfill plotësisht. Hiqi nivelet, ose ndrysho tarifën bazë në 'Shkallë orësh' a 'Çmim fiks'. Publikimi bllokohet derisa kjo të rregullohet.",
|
||||||
tiersAdvanced: "Të avancuara: nivele kohore & sezonale",
|
tiersAdvanced: "Të avancuara: nivele kohore & sezonale",
|
||||||
tiersHint: "Opsionale. Shto nivele tarifore që vlejnë vetëm në orë/ditë/data ose kategori të caktuara (p.sh. orë e lirë, tarifë nate, fundjavë, autobus). Pa nivele, publikohet vetëm tarifa bazë.",
|
tiersHint: "Opsionale. Shto nivele tarifore që vlejnë vetëm në orë/ditë/data ose kategori të caktuara (p.sh. orë e lirë, tarifë nate, fundjavë, autobus). Pa nivele, publikohet vetëm tarifa bazë.",
|
||||||
tierName: "Emri",
|
tierName: "Emri",
|
||||||
@@ -318,6 +357,36 @@ export const sq = {
|
|||||||
relayLabel: "Rele {{relay}} ({{direction}})",
|
relayLabel: "Rele {{relay}} ({{direction}})",
|
||||||
noRelaysConfigured: "Ky kontrollues nuk ka rele të konfiguruar.",
|
noRelaysConfigured: "Ky kontrollues nuk ka rele të konfiguruar.",
|
||||||
},
|
},
|
||||||
|
lab: {
|
||||||
|
title: "Lab Tarife",
|
||||||
|
intro:
|
||||||
|
"Testo tarifat në kohë (dritare ditë/natë, kufi ditor, qëndrim tej afatit) në sekonda, pa pritur orë. Çmimi llogaritet me të njëjtën logjikë si kabina; nuk shkruhet asgjë në ledger.",
|
||||||
|
loadTicket: "Ngarko nga një biletë reale",
|
||||||
|
loadTicketPh: "Numri i biletës / identiteti",
|
||||||
|
load: "Ngarko",
|
||||||
|
loaded: "U ngarkua sesioni {{id}}",
|
||||||
|
tariffVersion: "Versioni i tarifës",
|
||||||
|
activeVersion: "Versioni aktiv (i tanishëm)",
|
||||||
|
entered: "Hyrja",
|
||||||
|
asOf: "Deri më (tani/dalja)",
|
||||||
|
now: "Tani",
|
||||||
|
category: "Kategoria",
|
||||||
|
categoryPh: "p.sh. bus (bosh = makinë)",
|
||||||
|
payment: "Pagesa",
|
||||||
|
paid: "u pagua",
|
||||||
|
graceMin: "afati (min)",
|
||||||
|
price: "Llogarit çmimin",
|
||||||
|
pricing: "Duke llogaritur…",
|
||||||
|
outcome: "Rezultati",
|
||||||
|
amountDue: "Shuma për pagesë",
|
||||||
|
billedPeriod: "Periudha e faturuar",
|
||||||
|
overstay: "tej afatit",
|
||||||
|
settled: "i shlyer",
|
||||||
|
periodStart: "Fillimi i periudhës",
|
||||||
|
graceExpires: "Afati skadon",
|
||||||
|
curve: "Kurba sipas kohëzgjatjes",
|
||||||
|
curveHint: "Tarifa nga hyrja për disa kohëzgjatje — shih ku rrafshohet kufiri ditor ose ndryshojnë dritaret.",
|
||||||
|
},
|
||||||
subs: {
|
subs: {
|
||||||
title: "Abonimet",
|
title: "Abonimet",
|
||||||
unnamed: "(pa emër)",
|
unnamed: "(pa emër)",
|
||||||
@@ -327,9 +396,24 @@ export const sq = {
|
|||||||
cred: "kredencial",
|
cred: "kredencial",
|
||||||
plates: "{{count}} targë(a)",
|
plates: "{{count}} targë(a)",
|
||||||
noPrice: "pa çmim",
|
noPrice: "pa çmim",
|
||||||
|
perDay: "ditë",
|
||||||
|
perWeek: "javë",
|
||||||
perMonth: "muaj",
|
perMonth: "muaj",
|
||||||
monthlyPrice: "Çmimi mujor",
|
plan: "Plani",
|
||||||
pricePlaceholder: "p.sh. 10000",
|
quantity: "Makina",
|
||||||
|
quantityHint: "makina të mbuluara nga ky abonim (çmimi ×N)",
|
||||||
|
count: "Sa",
|
||||||
|
planNone: "— pa pagesë / falas —",
|
||||||
|
planNoneAvail: "Asnjë plan i përcaktuar — admini duhet të krijojë një të parin.",
|
||||||
|
quoting: "duke llogaritur…",
|
||||||
|
quotePrompt: "zgjidh datën e mbarimit",
|
||||||
|
quoteLine: "{{periods}} × {{unit}} · {{amount}} {{currency}}",
|
||||||
|
tender: "Paguar me",
|
||||||
|
tenderCash: "Para në dorë",
|
||||||
|
tenderCard: "Kartë",
|
||||||
|
tenderHint: "Regjistrohet si pagesë e nënshkruar (aktiviteti, arka, raporti i turnit).",
|
||||||
|
saleRecorded: "Shitja u regjistrua: {{amount}} {{currency}} ({{tender}}).",
|
||||||
|
saleNoShift: "⚠ Asnjë turn i hapur — hapni një që arkëtimi të hyjë në një raport turni.",
|
||||||
edit: "Ndrysho",
|
edit: "Ndrysho",
|
||||||
revoke: "Anulo",
|
revoke: "Anulo",
|
||||||
delete: "Fshij",
|
delete: "Fshij",
|
||||||
@@ -343,11 +427,7 @@ export const sq = {
|
|||||||
limitCarsInAtOnce: "kufizo makinat brenda njëkohësisht",
|
limitCarsInAtOnce: "kufizo makinat brenda njëkohësisht",
|
||||||
validFrom: "Vlen nga",
|
validFrom: "Vlen nga",
|
||||||
validTo: "Vlen deri",
|
validTo: "Vlen deri",
|
||||||
months: "Muaj",
|
validToEnd: "Vlen deri (mbarimi)",
|
||||||
monthsHint: "muaj të paguar",
|
|
||||||
coverageHint: "deri më {{end}}",
|
|
||||||
totalDue: "gjithsej {{total}}",
|
|
||||||
validToOverride: "Vlen deri (manual)",
|
|
||||||
isoDateOptional: "Datë ISO (opsionale)",
|
isoDateOptional: "Datë ISO (opsionale)",
|
||||||
boundPlates: "Targat e lidhura",
|
boundPlates: "Targat e lidhura",
|
||||||
commaSeparatedOptional: "të ndara me presje (opsionale)",
|
commaSeparatedOptional: "të ndara me presje (opsionale)",
|
||||||
@@ -380,6 +460,54 @@ export const sq = {
|
|||||||
statusSuspended: "pezulluar",
|
statusSuspended: "pezulluar",
|
||||||
statusRevoked: "anuluar",
|
statusRevoked: "anuluar",
|
||||||
},
|
},
|
||||||
|
plans: {
|
||||||
|
title: "Planet e abonimit",
|
||||||
|
intro: "Planet i përcakton admini; operatori vetëm shet prej tyre — çmimi merret automatikisht, nuk shkruhet. Ndryshimi i një plani publikon një version të ri; shitjet e mëparshme ruajnë çmimin e tyre.",
|
||||||
|
add: "+ Shto plan",
|
||||||
|
noneYet: "Asnjë plan ende. Shto një që kabina të shesë abonime.",
|
||||||
|
colName: "Emri",
|
||||||
|
colPrice: "Çmimi",
|
||||||
|
colHours: "Orari",
|
||||||
|
colUsedBy: "Përdorur nga",
|
||||||
|
colEffective: "Vlen nga",
|
||||||
|
allHours: "24/7",
|
||||||
|
usedByCount: "{{active}} aktive / {{total}}",
|
||||||
|
usedByNone: "asnjë",
|
||||||
|
usedByTitle: "Shfaq abonimet në këtë plan",
|
||||||
|
subscribers: "Abonentët",
|
||||||
|
inForce: "në fuqi",
|
||||||
|
retired: "i tërhequr",
|
||||||
|
versionCount: "{{count}} versione",
|
||||||
|
newVersion: "Version i ri",
|
||||||
|
retire: "Tërhiq",
|
||||||
|
reactivate: "Riaktivizo",
|
||||||
|
delete: "Fshij",
|
||||||
|
deleteTitle: "Fshij këtë plan përgjithmonë (vetëm kur asnjë abonim nuk e përdor)",
|
||||||
|
reactivated: "“{{name}}” është përsëri i shitshëm.",
|
||||||
|
confirmDelete: "Të fshihet plani “{{name}}” përgjithmonë? Kjo s'mund të kthehet.",
|
||||||
|
deleted: "Plani “{{name}}” u fshi.",
|
||||||
|
deleteInUse: "S'mund të fshihet — abonime ende e përdorin këtë plan. Tërhiqe në vend të kësaj.",
|
||||||
|
newTitle: "Plan i ri",
|
||||||
|
newVersionTitle: "Publiko version të ri",
|
||||||
|
newVersionHint: "Kjo publikon një version TË RI të planit — shitjet ekzistuese ruajnë çmimin origjinal.",
|
||||||
|
period: "Periudha",
|
||||||
|
pricePer: "Çmimi për periudhë",
|
||||||
|
namePlaceholder: "p.sh. Hotel ditor",
|
||||||
|
needName: "Emri i planit është i detyrueshëm.",
|
||||||
|
needPrice: "Shkruaj një çmim më të madh se zero.",
|
||||||
|
saved: "Plani u ruajt.",
|
||||||
|
confirmRetire: "Të tërhiqet plani “{{name}}”? Nuk do të jetë më i shitshëm (historiku ruhet).",
|
||||||
|
needWindow: "Shkruaj orare të vlefshme (HH:MM).",
|
||||||
|
needDays: "Zgjidh të paktën një ditë për intervalin.",
|
||||||
|
restrictTimes: "Kufizo oraret e parkimit (tarifë kalimtare jashtë intervalit)",
|
||||||
|
days: "Ditët",
|
||||||
|
window: "Intervali",
|
||||||
|
enterAfter: "hyrje pas",
|
||||||
|
exitBefore: "· dalje para",
|
||||||
|
grace: "Tolerancë",
|
||||||
|
graceHint: "minuta tolerancë rreth kufijve të intervalit",
|
||||||
|
timeframesHint: "Një skanim jashtë intervalit të lejuar tarifohet me tarifën normale kalimtare për minutat jashtë intervalit (hyrja e hershme shtyhet në dalje; dalja e vonuar bllokohet derisa paguhet).",
|
||||||
|
},
|
||||||
site: {
|
site: {
|
||||||
occupancy: "Prania:",
|
occupancy: "Prania:",
|
||||||
noCapacitySet: "(pa kapacitet të caktuar)",
|
noCapacitySet: "(pa kapacitet të caktuar)",
|
||||||
@@ -388,7 +516,9 @@ export const sq = {
|
|||||||
capacityLabel: "Kapaciteti (bosh = pa kufi):",
|
capacityLabel: "Kapaciteti (bosh = pa kufi):",
|
||||||
capacityPlaceholder: "p.sh. 120",
|
capacityPlaceholder: "p.sh. 120",
|
||||||
printExitDefault: "Printo biletën e daljes si parazgjedhje",
|
printExitDefault: "Printo biletën e daljes si parazgjedhje",
|
||||||
printExitHint: "(kabina larg daljes → klienti del vetë me biletë)",
|
printExitHint: "(klienti skanon biletën në dalje)",
|
||||||
|
reserveSubs: "Rezervo vendet e abonentëve",
|
||||||
|
reserveSubsHint: "Mban një vend për makinat e çdo abonenti aktiv edhe kur nuk janë të parkuar — kalimtarët e shohin 'plot' më shpejt. Joaktiv: numërohen vetëm makinat brenda (mbingarkesa menaxhohet me parkim manual).",
|
||||||
parkDetails: "Të dhënat e parkimit (opsionale — shfaqen në bileta/fatura)",
|
parkDetails: "Të dhënat e parkimit (opsionale — shfaqen në bileta/fatura)",
|
||||||
save: "Ruaj",
|
save: "Ruaj",
|
||||||
saved: "U ruajt.",
|
saved: "U ruajt.",
|
||||||
@@ -453,13 +583,25 @@ export const sq = {
|
|||||||
drawer: "Arka:",
|
drawer: "Arka:",
|
||||||
openingFloatInherited: "(bilanci fillestar i trashëguar nga turni i mëparshëm)",
|
openingFloatInherited: "(bilanci fillestar i trashëguar nga turni i mëparshëm)",
|
||||||
drawerCashAdmin: "Para në arkë (admin) — shto ose hiq bilancin",
|
drawerCashAdmin: "Para në arkë (admin) — shto ose hiq bilancin",
|
||||||
|
drawerVoucher: "Mandat arke — operatori e hap, admini e autorizon",
|
||||||
amount: "shuma",
|
amount: "shuma",
|
||||||
reasonPlaceholder: "arsyeja (p.sh. bilanci fillestar)",
|
reasonPlaceholder: "arsyeja (p.sh. bilanci fillestar)",
|
||||||
load: "Shto +",
|
load: "Shto +",
|
||||||
remove: "Hiq −",
|
remove: "Hiq −",
|
||||||
|
authName: "përdoruesi i adminit",
|
||||||
|
authPassword: "fjalëkalimi i adminit",
|
||||||
|
authRequired: "Një admin duhet ta autorizojë: shkruaj përdoruesin dhe fjalëkalimin e tij.",
|
||||||
|
mandatArketimi: "Arkëtim (hyrje) +",
|
||||||
|
mandatPagese: "Pagesë (dalje) −",
|
||||||
|
voucherHint: "Mandat Arkëtimi shton para; Mandat Pagese heq para. Arka lëviz vetëm me autorizimin e një admini.",
|
||||||
|
voucherRecorded: "Mandati {{no}} u regjistrua. Arka tani {{amount}}.",
|
||||||
enterPositive: "Shkruaj një shumë pozitive.",
|
enterPositive: "Shkruaj një shumë pozitive.",
|
||||||
drawerNow: "Arka tani {{amount}}.",
|
drawerNow: "Arka tani {{amount}}.",
|
||||||
zReport: "RAPORT Z",
|
zReport: "MBYLLJA E TURNIT",
|
||||||
|
viewTakings: "Arkëtimet deri tani",
|
||||||
|
xReport: "ARKËTIMET DERI TANI",
|
||||||
|
asOf: "deri më",
|
||||||
|
xReportHint: "Vetëm për shikim — asgjë nuk regjistrohet. Këto shifra nënshkruhen kur mbyllet turni.",
|
||||||
payments: "Pagesa:",
|
payments: "Pagesa:",
|
||||||
cash: "Para:",
|
cash: "Para:",
|
||||||
card: "Kartë:",
|
card: "Kartë:",
|
||||||
@@ -468,7 +610,7 @@ export const sq = {
|
|||||||
cashTaken: "Para të marra:",
|
cashTaken: "Para të marra:",
|
||||||
cashAdded: "Para të shtuara:",
|
cashAdded: "Para të shtuara:",
|
||||||
cashRemoved: "Para të hequra:",
|
cashRemoved: "Para të hequra:",
|
||||||
expectedDrawer: "Arka e pritshme:",
|
expectedDrawer: "Gjëndje Arke:",
|
||||||
printedToReceipt: "Printuar te printeri i kabinës.",
|
printedToReceipt: "Printuar te printeri i kabinës.",
|
||||||
recordedNoPrinter: "Regjistruar (pa printer për të printuar).",
|
recordedNoPrinter: "Regjistruar (pa printer për të printuar).",
|
||||||
// Header shift control + the booth shift gate.
|
// Header shift control + the booth shift gate.
|
||||||
@@ -496,7 +638,7 @@ export const sq = {
|
|||||||
payments: "Pagesa",
|
payments: "Pagesa",
|
||||||
cash: "Para",
|
cash: "Para",
|
||||||
card: "Kartë",
|
card: "Kartë",
|
||||||
expectedDrawer: "Arka e pritshme",
|
expectedDrawer: "Gjëndje arke",
|
||||||
// Filter (admin only).
|
// Filter (admin only).
|
||||||
filterFrom: "Nga",
|
filterFrom: "Nga",
|
||||||
filterTo: "Deri",
|
filterTo: "Deri",
|
||||||
@@ -512,7 +654,7 @@ export const sq = {
|
|||||||
loadFailed: "Ngarkimi i turneve dështoi.",
|
loadFailed: "Ngarkimi i turneve dështoi.",
|
||||||
},
|
},
|
||||||
logs: {
|
logs: {
|
||||||
title: "Regjistrat e sistemit",
|
title: "Loget e sistemit",
|
||||||
refresh: "Rifresko",
|
refresh: "Rifresko",
|
||||||
level: "Niveli",
|
level: "Niveli",
|
||||||
source: "Burimi",
|
source: "Burimi",
|
||||||
@@ -537,6 +679,9 @@ export const sq = {
|
|||||||
statusLabel: "Statusi",
|
statusLabel: "Statusi",
|
||||||
paid: "PAGUAR",
|
paid: "PAGUAR",
|
||||||
unpaid: "PAPAGUAR",
|
unpaid: "PAPAGUAR",
|
||||||
|
overstay: "TEJ AFATIT",
|
||||||
|
overstayHint: "Sesion i mëparshëm i paguar. Klienti nuk doli brënda afatit kohor. Kërkohet pagesë për periudhën e re. Totali më poshtë është tarifa e periudhës së re.",
|
||||||
|
topUp: "Periudha e re për pagesë",
|
||||||
total: "Totali",
|
total: "Totali",
|
||||||
noTariff: "pa tarifë",
|
noTariff: "pa tarifë",
|
||||||
tender: "Mënyra",
|
tender: "Mënyra",
|
||||||
@@ -560,6 +705,8 @@ export const sq = {
|
|||||||
plan: "Plani",
|
plan: "Plani",
|
||||||
prepaid: "I PARAPAGUAR",
|
prepaid: "I PARAPAGUAR",
|
||||||
subAssistHint: "Abonim i parapaguar. Hap barrierën për të ndihmuar daljen (lexues me defekt / kartë e munguar). S'ka pagesë.",
|
subAssistHint: "Abonim i parapaguar. Hap barrierën për të ndihmuar daljen (lexues me defekt / kartë e munguar). S'ka pagesë.",
|
||||||
|
windowCharge: "JASHTË ORARIT",
|
||||||
|
windowChargeHint: "Ky abonent parkoi jashtë orarit të lejuar të planit. Detyrohet të paguajë tarifën kalimtare për kohën jashtë orarit — merr pagesën për të lejuar daljen.",
|
||||||
subBarrierOpened: "Barriera u hap për abonentin (ndërhyrje e regjistruar).",
|
subBarrierOpened: "Barriera u hap për abonentin (ndërhyrje e regjistruar).",
|
||||||
voucherPrinted: "Bileta e daljes u printua në {{printer}}. Klienti del vetë te dalja.",
|
voucherPrinted: "Bileta e daljes u printua në {{printer}}. Klienti del vetë te dalja.",
|
||||||
// payment receipt (transparency slip)
|
// payment receipt (transparency slip)
|
||||||
|
|||||||
@@ -74,7 +74,9 @@ export function useLiveFeed(): void {
|
|||||||
if (
|
if (
|
||||||
msg.event.type === "shift_open" ||
|
msg.event.type === "shift_open" ||
|
||||||
msg.event.type === "shift_z_report" ||
|
msg.event.type === "shift_z_report" ||
|
||||||
msg.event.type === "cash_movement"
|
msg.event.type === "cash_movement" ||
|
||||||
|
msg.event.type === "cash_in" ||
|
||||||
|
msg.event.type === "cash_out"
|
||||||
) {
|
) {
|
||||||
void qc.invalidateQueries({ queryKey: qk.shift });
|
void qc.invalidateQueries({ queryKey: qk.shift });
|
||||||
}
|
}
|
||||||
|
|||||||
+21
-2
@@ -21,7 +21,9 @@ import { StatusDot } from "./ui/StatusDot.js";
|
|||||||
import { BoothScreen } from "./BoothScreen.js";
|
import { BoothScreen } from "./BoothScreen.js";
|
||||||
import { SetupWizard } from "./SetupWizard.js";
|
import { SetupWizard } from "./SetupWizard.js";
|
||||||
import { TariffComposer } from "./TariffComposer.js";
|
import { TariffComposer } from "./TariffComposer.js";
|
||||||
|
import { TariffLab } from "./TariffLab.js";
|
||||||
import { SubscriptionManager } from "./SubscriptionManager.js";
|
import { SubscriptionManager } from "./SubscriptionManager.js";
|
||||||
|
import { SubscriptionPlansManager } from "./SubscriptionPlansManager.js";
|
||||||
import { ShiftControl } from "./ShiftControl.js";
|
import { ShiftControl } from "./ShiftControl.js";
|
||||||
import { SiteSettings } from "./SiteSettings.js";
|
import { SiteSettings } from "./SiteSettings.js";
|
||||||
import { UsersManager } from "./UsersManager.js";
|
import { UsersManager } from "./UsersManager.js";
|
||||||
@@ -80,7 +82,9 @@ function SetupLayout() {
|
|||||||
<nav className="mb-4 flex flex-wrap items-center gap-1 border-b border-term-border">
|
<nav className="mb-4 flex flex-wrap items-center gap-1 border-b border-term-border">
|
||||||
{show("site:update") && <SetupTab to="/setup" label={t("nav.devices")} exact />}
|
{show("site:update") && <SetupTab to="/setup" label={t("nav.devices")} exact />}
|
||||||
{show("tariff:read") && <SetupTab to="/setup/tariff" label={t("nav.tariff")} />}
|
{show("tariff:read") && <SetupTab to="/setup/tariff" label={t("nav.tariff")} />}
|
||||||
|
{show("tariff:read") && <SetupTab to="/setup/tariff-lab" label={t("nav.tariffLab")} />}
|
||||||
{show("subscription:read") && <SetupTab to="/setup/subscriptions" label={t("nav.subscriptions")} />}
|
{show("subscription:read") && <SetupTab to="/setup/subscriptions" label={t("nav.subscriptions")} />}
|
||||||
|
{show("subscription:plan") && <SetupTab to="/setup/plans" label={t("nav.plans")} />}
|
||||||
{show("site:read") && <SetupTab to="/setup/site" label={t("nav.site")} />}
|
{show("site:read") && <SetupTab to="/setup/site" label={t("nav.site")} />}
|
||||||
{show("user:read") && <SetupTab to="/setup/users" label={t("nav.users")} />}
|
{show("user:read") && <SetupTab to="/setup/users" label={t("nav.users")} />}
|
||||||
{show("role:read") && <SetupTab to="/setup/roles" label={t("nav.roles")} />}
|
{show("role:read") && <SetupTab to="/setup/roles" label={t("nav.roles")} />}
|
||||||
@@ -340,8 +344,9 @@ const shiftRoute = createRoute({
|
|||||||
path: "/shift",
|
path: "/shift",
|
||||||
component: function ShiftRoute() {
|
component: function ShiftRoute() {
|
||||||
const { user } = rootRoute.useRouteContext();
|
const { user } = rootRoute.useRouteContext();
|
||||||
// "Admin" actions on the shift screen (drawer cash) need shift:cash.
|
// The drawer-voucher form is operator-RAISED (shift:create); an admin still has
|
||||||
return <ShiftControl isAdmin={can(user, "shift:cash")} />;
|
// to authorize each voucher with their password server-side.
|
||||||
|
return <ShiftControl canVoucher={can(user, "shift:create")} />;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -395,12 +400,24 @@ const tariffRoute = createRoute({
|
|||||||
beforeLoad: ({ context }) => requirePerm("tariff:read")(context),
|
beforeLoad: ({ context }) => requirePerm("tariff:read")(context),
|
||||||
component: () => <TariffComposer />,
|
component: () => <TariffComposer />,
|
||||||
});
|
});
|
||||||
|
const tariffLabRoute = createRoute({
|
||||||
|
getParentRoute: () => setupRoute,
|
||||||
|
path: "tariff-lab",
|
||||||
|
beforeLoad: ({ context }) => requirePerm("tariff:read")(context),
|
||||||
|
component: () => <TariffLab />,
|
||||||
|
});
|
||||||
const subscriptionsRoute = createRoute({
|
const subscriptionsRoute = createRoute({
|
||||||
getParentRoute: () => setupRoute,
|
getParentRoute: () => setupRoute,
|
||||||
path: "subscriptions",
|
path: "subscriptions",
|
||||||
beforeLoad: ({ context }) => requirePerm("subscription:read")(context),
|
beforeLoad: ({ context }) => requirePerm("subscription:read")(context),
|
||||||
component: () => <SubscriptionManager />,
|
component: () => <SubscriptionManager />,
|
||||||
});
|
});
|
||||||
|
const subscriptionPlansRoute = createRoute({
|
||||||
|
getParentRoute: () => setupRoute,
|
||||||
|
path: "plans",
|
||||||
|
beforeLoad: ({ context }) => requirePerm("subscription:plan")(context),
|
||||||
|
component: () => <SubscriptionPlansManager />,
|
||||||
|
});
|
||||||
const siteRoute = createRoute({
|
const siteRoute = createRoute({
|
||||||
getParentRoute: () => setupRoute,
|
getParentRoute: () => setupRoute,
|
||||||
path: "site",
|
path: "site",
|
||||||
@@ -456,7 +473,9 @@ const routeTree = rootRoute.addChildren([
|
|||||||
setupRoute.addChildren([
|
setupRoute.addChildren([
|
||||||
setupDevicesRoute,
|
setupDevicesRoute,
|
||||||
tariffRoute,
|
tariffRoute,
|
||||||
|
tariffLabRoute,
|
||||||
subscriptionsRoute,
|
subscriptionsRoute,
|
||||||
|
subscriptionPlansRoute,
|
||||||
siteRoute,
|
siteRoute,
|
||||||
usersRoute,
|
usersRoute,
|
||||||
rolesRoute,
|
rolesRoute,
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
// A compact filter toolbar shared by the session list and the live feed: a search
|
||||||
|
// box plus one or more segmented toggle groups. Purely presentational — each tab
|
||||||
|
// owns its own filter state and predicates; this just lays the controls out in the
|
||||||
|
// terminal theme. Kept tiny on purpose (the booth screen is dense).
|
||||||
|
|
||||||
|
export interface SegOption<V extends string> {
|
||||||
|
readonly value: V;
|
||||||
|
readonly label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A segmented single-select (e.g. status / direction). `value` "" = "all". */
|
||||||
|
export function SegGroup<V extends string>({
|
||||||
|
value,
|
||||||
|
options,
|
||||||
|
onChange,
|
||||||
|
allLabel,
|
||||||
|
}: {
|
||||||
|
value: V | "";
|
||||||
|
options: readonly SegOption<V>[];
|
||||||
|
onChange: (v: V | "") => void;
|
||||||
|
allLabel: string;
|
||||||
|
}) {
|
||||||
|
const seg = (v: V | "", label: string) => (
|
||||||
|
<button
|
||||||
|
key={v || "all"}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange(v)}
|
||||||
|
className={`px-2 py-0.5 text-[10px] uppercase tracking-wider transition-colors ${
|
||||||
|
value === v ? "bg-term-border text-term-text" : "text-term-muted hover:text-term-text"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div className="flex shrink-0 overflow-hidden rounded border border-term-border/60">
|
||||||
|
{seg("", allLabel)}
|
||||||
|
{options.map((o) => seg(o.value, o.label))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FilterBar({
|
||||||
|
search,
|
||||||
|
onSearch,
|
||||||
|
searchPlaceholder,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
search: string;
|
||||||
|
onSearch: (v: string) => void;
|
||||||
|
searchPlaceholder: string;
|
||||||
|
/** Segmented groups (one or more <SegGroup/>). */
|
||||||
|
children?: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="mb-2 flex flex-wrap items-center gap-2 border-b border-term-border/50 pb-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => onSearch(e.target.value)}
|
||||||
|
placeholder={searchPlaceholder}
|
||||||
|
className="min-w-[8rem] flex-1 rounded border border-term-border/60 bg-transparent px-2 py-0.5 text-[12px] text-term-text placeholder:text-term-muted focus:border-term-amber focus:outline-none"
|
||||||
|
/>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,6 +8,9 @@ export default defineConfig({
|
|||||||
schema: "./src/schema.ts",
|
schema: "./src/schema.ts",
|
||||||
out: "./drizzle",
|
out: "./drizzle",
|
||||||
dbCredentials: {
|
dbCredentials: {
|
||||||
url: process.env.DATABASE_URL ?? "./parking.sqlite",
|
// Default to the live appliance DB (apps/server), NOT a stray ./parking.sqlite in
|
||||||
|
// this package. The `db:migrate` script also sets DATABASE_URL to this. Override with
|
||||||
|
// DATABASE_URL to target another DB.
|
||||||
|
url: process.env.DATABASE_URL ?? "../../apps/server/parking.sqlite",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
CREATE TABLE `subscription_plans` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`plan_id` text NOT NULL,
|
||||||
|
`name` text NOT NULL,
|
||||||
|
`period` text NOT NULL,
|
||||||
|
`price_per_period_minor` integer NOT NULL,
|
||||||
|
`currency` text NOT NULL,
|
||||||
|
`effective_from` text NOT NULL,
|
||||||
|
`active` integer DEFAULT true NOT NULL,
|
||||||
|
`created_by` text,
|
||||||
|
`created_at` text DEFAULT (current_timestamp) NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX `subscription_plans_plan_id_idx` ON `subscription_plans` (`plan_id`);--> statement-breakpoint
|
||||||
|
-- A subscription now records WHICH plan + WHICH immutable plan version priced it, so
|
||||||
|
-- the sale reprices identically later (like a payment's tariff_version_id). Null for
|
||||||
|
-- legacy/comp rows. SQLite ALTER ADD COLUMN is in-place and safe for existing rows.
|
||||||
|
ALTER TABLE `subscriptions` ADD `plan_id` text;--> statement-breakpoint
|
||||||
|
ALTER TABLE `subscriptions` ADD `plan_version_id` text;--> statement-breakpoint
|
||||||
|
-- Seed a "Monthly" plan from the existing site default price so sites that already set
|
||||||
|
-- one keep their monthly plan with no data loss. period='month'; effective at epoch so
|
||||||
|
-- it always resolves. Skipped when no site default is set (no priced plan to seed).
|
||||||
|
INSERT INTO `subscription_plans`
|
||||||
|
(`id`, `plan_id`, `name`, `period`, `price_per_period_minor`, `currency`, `effective_from`, `active`)
|
||||||
|
SELECT
|
||||||
|
'plan-monthly-seed', 'monthly', 'Monthly', 'month',
|
||||||
|
`subscription_monthly_price_minor`,
|
||||||
|
'ALL',
|
||||||
|
'1970-01-01T00:00:00.000Z', 1
|
||||||
|
FROM `site_config`
|
||||||
|
WHERE `subscription_monthly_price_minor` IS NOT NULL
|
||||||
|
LIMIT 1;--> statement-breakpoint
|
||||||
|
-- Grant the new subscription:plan permission to the built-in admin role (admin is
|
||||||
|
-- runtime-special-cased to ALL permissions, but the Roles UI lists the grid from these
|
||||||
|
-- rows — keep it in sync). INSERT OR IGNORE: harmless if the row already exists.
|
||||||
|
INSERT OR IGNORE INTO `role_permissions` (`role_id`, `permission`) VALUES ('admin','subscription:plan');
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
-- Subscription plans v2: per-plan allowed-time windows (tariff bridge), per-subscription
|
||||||
|
-- car quantity, and a site toggle to reserve subscriber spots in the occupancy count.
|
||||||
|
-- All additive ALTER ADD COLUMN — backward-compatible (existing rows take the defaults:
|
||||||
|
-- timeframes null = 24/7, quantity 1, reserve off). SQLite ADD COLUMN is in-place.
|
||||||
|
ALTER TABLE `subscription_plans` ADD `timeframes` text;--> statement-breakpoint
|
||||||
|
ALTER TABLE `subscriptions` ADD `quantity` integer DEFAULT 1 NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE `site_config` ADD `reserve_subscriber_spots` integer DEFAULT 0 NOT NULL;
|
||||||
@@ -71,6 +71,20 @@
|
|||||||
"when": 1781885200000,
|
"when": 1781885200000,
|
||||||
"tag": "0009_app_logs",
|
"tag": "0009_app_logs",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 10,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1781885300000,
|
||||||
|
"tag": "0010_subscription_plans",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 11,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1781885400000,
|
||||||
|
"tag": "0011_subscription_plan_v2",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"lint": "tsc --noEmit",
|
"lint": "tsc --noEmit",
|
||||||
"db:generate": "drizzle-kit generate",
|
"db:generate": "drizzle-kit generate",
|
||||||
"db:migrate": "drizzle-kit migrate"
|
"db:migrate": "DATABASE_URL=\"${DATABASE_URL:-../../apps/server/parking.sqlite}\" drizzle-kit migrate"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@parking/shared": "workspace:*",
|
"@parking/shared": "workspace:*",
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import * as schema from "./schema.js";
|
|||||||
export * from "./schema.js";
|
export * from "./schema.js";
|
||||||
// Re-export the query helpers consumers need, so they don't depend on
|
// Re-export the query helpers consumers need, so they don't depend on
|
||||||
// drizzle-orm directly (it's an implementation detail of this package).
|
// drizzle-orm directly (it's an implementation detail of this package).
|
||||||
export { eq, and, desc, gte, sql } from "drizzle-orm";
|
export { eq, and, desc, gte, lte, sql } from "drizzle-orm";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Open the local SQLite database in WAL mode. WAL allows many concurrent readers
|
* Open the local SQLite database in WAL mode. WAL allows many concurrent readers
|
||||||
|
|||||||
@@ -218,6 +218,14 @@ export const siteConfig = sqliteTable("site_config", {
|
|||||||
* own price and may differ. null = no site default set. See
|
* own price and may differ. null = no site default set. See
|
||||||
* wiki/entities/subscription.md. */
|
* wiki/entities/subscription.md. */
|
||||||
subscriptionMonthlyPriceMinor: integer("subscription_monthly_price_minor"),
|
subscriptionMonthlyPriceMinor: integer("subscription_monthly_price_minor"),
|
||||||
|
/** When ON, the occupancy/full gate RESERVES a spot for each active subscriber's car
|
||||||
|
* (by quantity) even when they're not parked — so transients see "full" sooner and
|
||||||
|
* the subscriber's spot is held. When OFF (default), only cars physically inside
|
||||||
|
* count (the operator handles overflow by valet/key-juggling). Stored 0/1.
|
||||||
|
* See wiki/concepts/capacity-occupancy.md. */
|
||||||
|
reserveSubscriberSpots: integer("reserve_subscriber_spots", { mode: "boolean" })
|
||||||
|
.notNull()
|
||||||
|
.default(false),
|
||||||
/** IANA timezone the site operates in (e.g. "Europe/Tirane"). Used to evaluate a
|
/** IANA timezone the site operates in (e.g. "Europe/Tirane"). Used to evaluate a
|
||||||
* tariff's wall-clock pricing windows (happy hour / night / seasonal). COPIED into
|
* tariff's wall-clock pricing windows (happy hour / night / seasonal). COPIED into
|
||||||
* each published tariff version's structure.tz so the windows are frozen/immutable
|
* each published tariff version's structure.tz so the windows are frozen/immutable
|
||||||
@@ -279,18 +287,58 @@ export const tariffVersions = sqliteTable("tariff_versions", {
|
|||||||
// NB: signed ledger events still carry `permitId` in their payload — immutable
|
// NB: signed ledger events still carry `permitId` in their payload — immutable
|
||||||
// history, intentionally NOT renamed. These tables are the mutable master data,
|
// history, intentionally NOT renamed. These tables are the mutable master data,
|
||||||
// renamed permit→subscription in migration 0004.
|
// renamed permit→subscription in migration 0004.
|
||||||
|
// A subscription PLAN — admin-composed, versioned config the operator SELLS from
|
||||||
|
// (instead of typing a price). Mirrors tariffVersions: immutable rows, latest with
|
||||||
|
// effectiveFrom ≤ saleDate wins, retire via active=0 (never delete = keep history).
|
||||||
|
// A plan prices a span as ceil(periods) × pricePerPeriodMinor; period ∈ day/week/month
|
||||||
|
// (so a hotel's 1–N day stay is a daily plan over a date span). See
|
||||||
|
// wiki/entities/subscription.md.
|
||||||
|
export const subscriptionPlans = sqliteTable("subscription_plans", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
// Stable plan identity across versions (e.g. "hotel-daily"); a new price = a new row.
|
||||||
|
planId: text("plan_id").notNull(),
|
||||||
|
name: text("name").notNull(),
|
||||||
|
period: text("period", { enum: ["day", "week", "month"] }).notNull(),
|
||||||
|
pricePerPeriodMinor: integer("price_per_period_minor").notNull(),
|
||||||
|
currency: text("currency").notNull(),
|
||||||
|
// Latest version with effectiveFrom ≤ the sale instant prices the sale.
|
||||||
|
effectiveFrom: text("effective_from").notNull(),
|
||||||
|
// Composed allowed-time windows (PlanTimeframes in @parking/shared); null = 24/7, no
|
||||||
|
// restriction. When set, a scan OUTSIDE the window is charged the transient tariff for
|
||||||
|
// the out-of-window minutes (a "night plan" subscriber arriving early owes that gap).
|
||||||
|
// Evaluated in the site timezone. See wiki/entities/subscription.md (tariff bridge).
|
||||||
|
timeframes: text("timeframes", { mode: "json" }).$type<Record<string, unknown>>(),
|
||||||
|
// Soft-retire (0) without deleting history; active=1 plans are sellable.
|
||||||
|
active: integer("active", { mode: "boolean" }).notNull().default(true),
|
||||||
|
createdBy: text("created_by"),
|
||||||
|
createdAt: text("created_at")
|
||||||
|
.notNull()
|
||||||
|
.default(sql`(current_timestamp)`),
|
||||||
|
});
|
||||||
|
|
||||||
export const subscriptions = sqliteTable("subscriptions", {
|
export const subscriptions = sqliteTable("subscriptions", {
|
||||||
id: text("id").primaryKey(),
|
id: text("id").primaryKey(),
|
||||||
holderName: text("holder_name"),
|
holderName: text("holder_name"),
|
||||||
contact: text("contact"),
|
contact: text("contact"),
|
||||||
// Recurring price for the plan, in minor units (e.g. 1000000 = 10,000.00 ALL).
|
// Price actually billed for the coverage window, in minor units (e.g. 1000000 =
|
||||||
// null = no price set (comp/legacy). The `period` says what it recurs over.
|
// 10,000.00 ALL). Now DERIVED from the chosen plan (periods × per-period price) — the
|
||||||
|
// operator never types it. null = no price set (comp/legacy). `period` is display.
|
||||||
priceMinor: integer("price_minor"),
|
priceMinor: integer("price_minor"),
|
||||||
period: text("period", { enum: ["monthly"] }).notNull().default("monthly"),
|
// Display period of the sale. Widened day/week/month 2026-06-20 (was "monthly"-only);
|
||||||
|
// a legacy "monthly" value reads as "month". Source of truth is the plan version.
|
||||||
|
period: text("period", { enum: ["day", "week", "month"] }).notNull().default("month"),
|
||||||
// ISO-4217 currency of priceMinor (e.g. "ALL"). null when no price set.
|
// ISO-4217 currency of priceMinor (e.g. "ALL"). null when no price set.
|
||||||
currency: text("currency"),
|
currency: text("currency"),
|
||||||
|
// Which plan + which immutable version priced this sale (null for legacy/comp rows).
|
||||||
|
// Persisted so the sale reprices identically later — same reason payments carry
|
||||||
|
// tariffVersionId.
|
||||||
|
planId: text("plan_id"),
|
||||||
|
planVersionId: text("plan_version_id"),
|
||||||
|
// How many cars this ONE subscription covers (e.g. a family pays once for 2 cars).
|
||||||
|
// Sale amount = plan span price × quantity; maxConcurrent defaults to it. Default 1.
|
||||||
|
quantity: integer("quantity").notNull().default(1),
|
||||||
// Car-count binding: how many of the subscription's cars may be inside at once.
|
// Car-count binding: how many of the subscription's cars may be inside at once.
|
||||||
// null = unbound. Default 1.
|
// null = unbound. Defaults to `quantity` at sale.
|
||||||
maxConcurrent: integer("max_concurrent").default(1),
|
maxConcurrent: integer("max_concurrent").default(1),
|
||||||
validFrom: text("valid_from"),
|
validFrom: text("valid_from"),
|
||||||
validTo: text("valid_to"),
|
validTo: text("valid_to"),
|
||||||
@@ -401,6 +449,7 @@ export type SiteConfigRow = typeof siteConfig.$inferSelect;
|
|||||||
export type TariffRow = typeof tariffs.$inferSelect;
|
export type TariffRow = typeof tariffs.$inferSelect;
|
||||||
export type TariffVersionRow = typeof tariffVersions.$inferSelect;
|
export type TariffVersionRow = typeof tariffVersions.$inferSelect;
|
||||||
export type SubscriptionRow = typeof subscriptions.$inferSelect;
|
export type SubscriptionRow = typeof subscriptions.$inferSelect;
|
||||||
|
export type SubscriptionPlanRow = typeof subscriptionPlans.$inferSelect;
|
||||||
export type SubscriptionCredentialRow = typeof subscriptionCredentials.$inferSelect;
|
export type SubscriptionCredentialRow = typeof subscriptionCredentials.$inferSelect;
|
||||||
export type SubscriptionPlateRow = typeof subscriptionPlates.$inferSelect;
|
export type SubscriptionPlateRow = typeof subscriptionPlates.$inferSelect;
|
||||||
export type BlocklistRow = typeof blocklist.$inferSelect;
|
export type BlocklistRow = typeof blocklist.$inferSelect;
|
||||||
|
|||||||
+425
-11
@@ -30,9 +30,10 @@ export const RESOURCES = [
|
|||||||
] as const;
|
] as const;
|
||||||
export type Resource = (typeof RESOURCES)[number];
|
export type Resource = (typeof RESOURCES)[number];
|
||||||
|
|
||||||
/** CRUD plus two domain verbs where CRUD doesn't fit: `void` (append a void event,
|
/** CRUD plus domain verbs where CRUD doesn't fit: `void` (append a void event, NOT a
|
||||||
* NOT a delete) and `cash` (move the drawer float — an admin-grade shift action). */
|
* delete), `cash` (move the drawer float — admin-grade shift action), and `plan`
|
||||||
export type Action = "create" | "read" | "update" | "delete" | "void" | "cash";
|
* (compose the subscription plan catalog — admin-grade; selling stays `create`). */
|
||||||
|
export type Action = "create" | "read" | "update" | "delete" | "void" | "cash" | "plan";
|
||||||
|
|
||||||
/** A single permission, e.g. "tariff:update". The route guard checks one of these. */
|
/** A single permission, e.g. "tariff:update". The route guard checks one of these. */
|
||||||
export type Permission = `${Resource}:${Action}`;
|
export type Permission = `${Resource}:${Action}`;
|
||||||
@@ -45,6 +46,8 @@ export const PERMISSIONS: readonly Permission[] = [
|
|||||||
"role:create", "role:read", "role:update", "role:delete",
|
"role:create", "role:read", "role:update", "role:delete",
|
||||||
"tariff:read", "tariff:update",
|
"tariff:read", "tariff:update",
|
||||||
"subscription:read", "subscription:create", "subscription:update", "subscription:delete",
|
"subscription:read", "subscription:create", "subscription:update", "subscription:delete",
|
||||||
|
"subscription:plan", // compose the plan catalog (admin-grade); selling = subscription:create
|
||||||
|
|
||||||
"site:read", "site:update",
|
"site:read", "site:update",
|
||||||
"device:read",
|
"device:read",
|
||||||
"shift:read", "shift:create", "shift:cash",
|
"shift:read", "shift:create", "shift:cash",
|
||||||
@@ -59,6 +62,105 @@ export const PERMISSIONS: readonly Permission[] = [
|
|||||||
* permissions. At least one user must always hold it (no-lockout invariant). */
|
* permissions. At least one user must always hold it (no-lockout invariant). */
|
||||||
export const ADMIN_ROLE_ID = "admin";
|
export const ADMIN_ROLE_ID = "admin";
|
||||||
|
|
||||||
|
/** A subscription plan's billing period. A span is priced as ceil(periods) × the
|
||||||
|
* plan's per-period price — so a hotel's 1–N day stay is a `"day"` plan over a date
|
||||||
|
* span. See wiki/entities/subscription.md. */
|
||||||
|
export type SubscriptionPeriod = "day" | "week" | "month";
|
||||||
|
export const SUBSCRIPTION_PERIODS: readonly SubscriptionPeriod[] = ["day", "week", "month"];
|
||||||
|
|
||||||
|
/** Composed allowed-time windows on a [[subscription]] plan. A scan OUTSIDE the window
|
||||||
|
* is charged the transient tariff for the out-of-window minutes (the "tariff bridge").
|
||||||
|
* null/absent timeframes on a plan = 24/7, no charge ever. Evaluated in the site tz.
|
||||||
|
*
|
||||||
|
* The window applies ONLY on the selected `days` (0=Sun..6=Sat, mirroring the V2 tariff
|
||||||
|
* day-of-week picker). On a NON-selected day the subscriber may park all day (no charge)
|
||||||
|
* — so a "night plan" is days [Mon..Fri] with a 20:00→08:00 window, leaving the weekend
|
||||||
|
* unrestricted. The window is [fromMin, toMin) minutes-of-local-midnight; `toMin ≤ fromMin`
|
||||||
|
* WRAPS past midnight (a night window 20:00→08:00 = 1200..480). */
|
||||||
|
export interface PlanTimeframes {
|
||||||
|
/** Days the window applies to (0=Sun..6=Sat). Empty/absent ⇒ every day. */
|
||||||
|
readonly days?: number[];
|
||||||
|
readonly fromMin: number; // window opens (minutes-of-day, local)
|
||||||
|
readonly toMin: number; // window closes (minutes-of-day, local)
|
||||||
|
/** Tolerance (minutes) around the window edges before a charge applies. */
|
||||||
|
readonly graceMin?: number;
|
||||||
|
/** IANA tz the windows are wall-clock evaluated in (the site tz, captured at sale). */
|
||||||
|
readonly tz?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One immutable VERSION of a subscription plan (admin-composed catalog; latest with
|
||||||
|
* effectiveFrom ≤ sale instant prices a sale — the tariff-version pattern). The
|
||||||
|
* operator SELLS from this catalog; they never type a price. */
|
||||||
|
export interface SubscriptionPlan {
|
||||||
|
readonly id: string; // this version's id (persisted on the sale = planVersionId)
|
||||||
|
readonly planId: string; // stable identity across versions (e.g. "hotel-daily")
|
||||||
|
readonly name: string;
|
||||||
|
readonly period: SubscriptionPeriod;
|
||||||
|
readonly pricePerPeriodMinor: number;
|
||||||
|
readonly currency: string;
|
||||||
|
readonly effectiveFrom: string;
|
||||||
|
readonly active: boolean;
|
||||||
|
/** Allowed-time windows (tariff bridge). null/absent = 24/7, no time charge. */
|
||||||
|
readonly timeframes?: PlanTimeframes | null;
|
||||||
|
readonly createdBy?: string | null;
|
||||||
|
readonly createdAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The result of pricing a date span against a plan version: how many (ceil) periods
|
||||||
|
* it spans and the total to collect. Server-computed and shown to the operator as a
|
||||||
|
* read-only quote — they can't override the amount. */
|
||||||
|
export interface SubscriptionQuote {
|
||||||
|
readonly periods: number;
|
||||||
|
readonly amountMinor: number;
|
||||||
|
readonly currency: string;
|
||||||
|
readonly period: SubscriptionPeriod;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Add whole months to an ISO datetime, clamping day overflow (e.g. Jan 31 +1mo →
|
||||||
|
* Feb 28/29). Returns ISO. Shared by subscription pricing + the coverage window. */
|
||||||
|
export function addMonths(iso: string, months: number): string {
|
||||||
|
const d = new Date(iso);
|
||||||
|
const day = d.getUTCDate();
|
||||||
|
d.setUTCMonth(d.getUTCMonth() + months);
|
||||||
|
// If the month rolled past (day 31 → a shorter month), clamp back to month-end.
|
||||||
|
if (d.getUTCDate() < day) d.setUTCDate(0);
|
||||||
|
return d.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
const SUB_DAY_MS = 24 * 60 * 60 * 1000;
|
||||||
|
const SUB_WEEK_MS = 7 * SUB_DAY_MS;
|
||||||
|
|
||||||
|
/** How many whole periods (ceil) cover [from, to] — any STARTED period is a full one
|
||||||
|
* (a guest checking out mid-day still owes that day). ≥ 1 for any positive span; 0
|
||||||
|
* for a non-positive/invalid span. Months walk whole-month steps so Jan-31 overflow
|
||||||
|
* clamps consistently. Pure + deterministic. See wiki/entities/subscription.md. */
|
||||||
|
export function periodsBetween(period: SubscriptionPeriod, fromISO: string, toISO: string): number {
|
||||||
|
const from = new Date(fromISO).getTime();
|
||||||
|
const to = new Date(toISO).getTime();
|
||||||
|
if (!Number.isFinite(from) || !Number.isFinite(to) || to <= from) return 0;
|
||||||
|
if (period === "day") return Math.ceil((to - from) / SUB_DAY_MS);
|
||||||
|
if (period === "week") return Math.ceil((to - from) / SUB_WEEK_MS);
|
||||||
|
// month: smallest N whose (from + N months) ≥ to.
|
||||||
|
let n = 0;
|
||||||
|
while (n < 1200 && new Date(addMonths(fromISO, n)).getTime() < to) n += 1;
|
||||||
|
return Math.max(1, n);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Price a date span against a plan version: ceil(periods) × per-period price. */
|
||||||
|
export function priceSubscriptionSpan(
|
||||||
|
plan: Pick<SubscriptionPlan, "period" | "pricePerPeriodMinor" | "currency">,
|
||||||
|
fromISO: string,
|
||||||
|
toISO: string,
|
||||||
|
): SubscriptionQuote {
|
||||||
|
const periods = periodsBetween(plan.period, fromISO, toISO);
|
||||||
|
return {
|
||||||
|
periods,
|
||||||
|
amountMinor: periods * plan.pricePerPeriodMinor,
|
||||||
|
currency: plan.currency,
|
||||||
|
period: plan.period,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** Transitional alias. Roles are now DB rows keyed by a string id; `Role` is kept
|
/** Transitional alias. Roles are now DB rows keyed by a string id; `Role` is kept
|
||||||
* as `string` so any not-yet-migrated reference still compiles. */
|
* as `string` so any not-yet-migrated reference still compiles. */
|
||||||
export type Role = string;
|
export type Role = string;
|
||||||
@@ -97,6 +199,11 @@ export interface LedgerEvent {
|
|||||||
* name here so the UI shows "Aqif Kopertoni" instead of "SUBSESS-08cd1c52…".
|
* name here so the UI shows "Aqif Kopertoni" instead of "SUBSESS-08cd1c52…".
|
||||||
* Absent on non-subscription events and on legacy serializers. */
|
* Absent on non-subscription events and on legacy serializers. */
|
||||||
readonly subscriberLabel?: string | null;
|
readonly subscriberLabel?: string | null;
|
||||||
|
/** READ-TIME ENRICHMENT — not signed, not stored. A licence plate ADVISORILY
|
||||||
|
* recognized for this session (ANPR-on-snapshot, device_events kind="read"), shown
|
||||||
|
* next to entry/exit events. Uppercased, no confidence/region (those live on the
|
||||||
|
* snapshot review panel). Absent when no plate was read or for non-entry/exit events. */
|
||||||
|
readonly plate?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Business/accountability events that live in the SIGNED, hash-chained ledger. */
|
/** Business/accountability events that live in the SIGNED, hash-chained ledger. */
|
||||||
@@ -116,7 +223,18 @@ export type LedgerEventType =
|
|||||||
// Admin loads/removes physical drawer cash (the float). Signed payload:
|
// Admin loads/removes physical drawer cash (the float). Signed payload:
|
||||||
// { amountMinor (signed: + load, − removal), reason, currency, operator }.
|
// { amountMinor (signed: + load, − removal), reason, currency, operator }.
|
||||||
// Folds into the drawer balance carried across shifts. See wiki/concepts/shift.md.
|
// Folds into the drawer balance carried across shifts. See wiki/concepts/shift.md.
|
||||||
|
// SUPERSEDED 2026-06-20 by the directional voucher pair below — kept as a type so
|
||||||
|
// historical events on the live chain still verify and still fold into the drawer.
|
||||||
| "cash_movement"
|
| "cash_movement"
|
||||||
|
// Drawer cash vouchers (replace the signed-± cash_movement with two distinct
|
||||||
|
// financial documents — the direction is the TYPE, not the sign of an amount):
|
||||||
|
// cash_in = Mandat Arkëtimi (receipt / pay-IN): cash enters the drawer.
|
||||||
|
// cash_out = Mandat Pagese (disbursement / pay-OUT): cash leaves the drawer.
|
||||||
|
// Payload: { amountMinor (POSITIVE magnitude), reason, currency, operator (raised
|
||||||
|
// by), authorizedBy (admin who signed off), voucherNo }. Operator-raised /
|
||||||
|
// admin-authorized. Folds into the drawer balance. See wiki/concepts/shift.md.
|
||||||
|
| "cash_in"
|
||||||
|
| "cash_out"
|
||||||
| "anomaly";
|
| "anomaly";
|
||||||
|
|
||||||
/** How money was tendered (for payment events + the shift Z-report). */
|
/** How money was tendered (for payment events + the shift Z-report). */
|
||||||
@@ -163,6 +281,21 @@ export interface LedgerPayload {
|
|||||||
/** vehicle_entry: the vehicle/customer category, frozen at entry so V2 category
|
/** vehicle_entry: the vehicle/customer category, frozen at entry so V2 category
|
||||||
* pricing reprices identically at exit. Absent on legacy entries (= default). */
|
* pricing reprices identically at exit. Absent on legacy entries (= default). */
|
||||||
readonly category?: string;
|
readonly category?: string;
|
||||||
|
/** cash_in / cash_out voucher: the admin who AUTHORIZED the drawer movement (the
|
||||||
|
* operator in `operator` raised it). Operator-raised / admin-authorized. */
|
||||||
|
readonly authorizedBy?: string;
|
||||||
|
/** cash_in / cash_out voucher: a human-facing voucher number printed on the slip
|
||||||
|
* (Mandat Nr.). Sequential per type; signed for reproducibility. */
|
||||||
|
readonly voucherNo?: string;
|
||||||
|
/** subscription tariff-bridge: an early-entry / late-exit transient charge OWED for
|
||||||
|
* parking outside the plan's allowed window, stamped on the vehicle_entry and collected
|
||||||
|
* (gated) at exit. The priced gap + tariff version travel alongside for reproducibility.
|
||||||
|
* See wiki/entities/subscription.md ("tariff bridge"). */
|
||||||
|
readonly windowOwedMinor?: number;
|
||||||
|
readonly windowCurrency?: string;
|
||||||
|
readonly windowTariffVersionId?: string;
|
||||||
|
readonly windowGapStart?: string;
|
||||||
|
readonly windowGapEnd?: string;
|
||||||
/** Free-form for forward-compat without a schema change. */
|
/** Free-form for forward-compat without a schema change. */
|
||||||
readonly [k: string]: unknown;
|
readonly [k: string]: unknown;
|
||||||
}
|
}
|
||||||
@@ -200,6 +333,9 @@ export const REASON_CODES = [
|
|||||||
"sub.refused.outOfWindow",
|
"sub.refused.outOfWindow",
|
||||||
"sub.refused.noSession",
|
"sub.refused.noSession",
|
||||||
"sub.refused.atCapacity",
|
"sub.refused.atCapacity",
|
||||||
|
// a subscriber owes an out-of-window (early-entry / late-exit) transient charge and
|
||||||
|
// hasn't paid it — exit is gated until they settle (the tariff-bridge gate).
|
||||||
|
"sub.refused.unpaidWindow",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type ReasonCode = (typeof REASON_CODES)[number];
|
export type ReasonCode = (typeof REASON_CODES)[number];
|
||||||
@@ -227,6 +363,7 @@ export const REASON_EN: Record<ReasonCode, string> = {
|
|||||||
"sub.refused.outOfWindow": "subscription refused — {status}/out-of-window",
|
"sub.refused.outOfWindow": "subscription refused — {status}/out-of-window",
|
||||||
"sub.refused.noSession": "subscription exit with no open session (already out / never entered)",
|
"sub.refused.noSession": "subscription exit with no open session (already out / never entered)",
|
||||||
"sub.refused.atCapacity": "subscription refused — at capacity ({inUse}/{max} cars in)",
|
"sub.refused.atCapacity": "subscription refused — at capacity ({inUse}/{max} cars in)",
|
||||||
|
"sub.refused.unpaidWindow": "exit refused — out-of-window charge unpaid ({amount} {currency} owed); pay at the booth",
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -336,7 +473,12 @@ export interface TariffStructureV1 {
|
|||||||
readonly incrementMin: number;
|
readonly incrementMin: number;
|
||||||
/** Consumed in order as duration accrues; last block may be open-ended. */
|
/** Consumed in order as duration accrues; last block may be open-ended. */
|
||||||
readonly blocks: readonly TariffBlock[];
|
readonly blocks: readonly TariffBlock[];
|
||||||
/** Cap per rolling 24h (null = no cap). */
|
/** STEPPED ("up-to") pricing — a total-by-duration table. When present (non-empty) it
|
||||||
|
* REPLACES `blocks`: the day's fee is the smallest tier whose `uptoMin ≥ elapsed`, and
|
||||||
|
* the top tier's total becomes the per-day price beyond it. Mutually exclusive with the
|
||||||
|
* marginal `blocks` ladder. Absent/empty ⇒ the ladder is used (back-compat). */
|
||||||
|
readonly steps?: readonly TariffStep[];
|
||||||
|
/** Cap per rolling 24h (null = no cap). Ignored for `steps` (the top tier IS the cap). */
|
||||||
readonly dailyCapMinor: number | null;
|
readonly dailyCapMinor: number | null;
|
||||||
/** Flat charge when there's no entry id (admin may override at the moment). */
|
/** Flat charge when there's no entry id (admin may override at the moment). */
|
||||||
readonly lostTicketMinor: number;
|
readonly lostTicketMinor: number;
|
||||||
@@ -352,6 +494,18 @@ export interface TariffBlock {
|
|||||||
readonly priceMinorPerIncrement: number;
|
readonly priceMinorPerIncrement: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One row of a STEPPED ("up-to") tariff: a TOTAL price for a stay UP TO AND INCLUDING
|
||||||
|
* `uptoMin` minutes. Unlike a {@link TariffBlock} (a marginal per-increment rate), this
|
||||||
|
* is the cumulative total — the owner enters the price table directly (e.g. "0–3h →
|
||||||
|
* 500"). The smallest `uptoMin ≥ duration` wins; the largest row's total acts as the
|
||||||
|
* per-day price for stays beyond it (daily-cap repeat). See wiki/concepts/tariff.md. */
|
||||||
|
export interface TariffStep {
|
||||||
|
/** Inclusive upper bound of this tier in minutes (e.g. 180 = "up to 3 hours"). */
|
||||||
|
readonly uptoMin: number;
|
||||||
|
/** TOTAL charge for a stay within this tier (minor units), not a marginal rate. */
|
||||||
|
readonly totalMinor: number;
|
||||||
|
}
|
||||||
|
|
||||||
/** A wall-clock activation window for a V2 card. All parts are AND-ed; an absent
|
/** A wall-clock activation window for a V2 card. All parts are AND-ed; an absent
|
||||||
* part is unconstrained. Evaluated in the version's frozen tz. */
|
* part is unconstrained. Evaluated in the version's frozen tz. */
|
||||||
export interface TariffWindow {
|
export interface TariffWindow {
|
||||||
@@ -377,10 +531,13 @@ export interface TariffCard {
|
|||||||
readonly category?: string;
|
readonly category?: string;
|
||||||
/** Wall-clock activation window. Absent only on the defaultCard (always active). */
|
/** Wall-clock activation window. Absent only on the defaultCard (always active). */
|
||||||
readonly window?: TariffWindow;
|
readonly window?: TariffWindow;
|
||||||
/** Flat price per billing increment (mutually exclusive with `blocks`). */
|
/** Flat price per billing increment (mutually exclusive with `blocks`/`steps`). */
|
||||||
readonly flatMinor?: number;
|
readonly flatMinor?: number;
|
||||||
/** Stepped ladder (mutually exclusive with `flatMinor`); last block open-ended. */
|
/** Marginal block ladder (mutually exclusive with `flatMinor`/`steps`); last open-ended. */
|
||||||
readonly blocks?: readonly TariffBlock[];
|
readonly blocks?: readonly TariffBlock[];
|
||||||
|
/** STEPPED ("up-to") total-by-duration table (mutually exclusive with `flatMinor`/
|
||||||
|
* `blocks`). The top tier's total is this card's per-day price. */
|
||||||
|
readonly steps?: readonly TariffStep[];
|
||||||
/** Cap per rolling 24h for THIS card's ladder. Only the defaultCard's cap governs
|
/** Cap per rolling 24h for THIS card's ladder. Only the defaultCard's cap governs
|
||||||
* a mixed day (see computeFeeV2). null = no cap. */
|
* a mixed day (see computeFeeV2). null = no cap. */
|
||||||
readonly dailyCapMinor?: number | null;
|
readonly dailyCapMinor?: number | null;
|
||||||
@@ -442,10 +599,103 @@ export function computeFee(
|
|||||||
: computeFeeV1(enteredAt, asOf, tariff);
|
: computeFeeV1(enteredAt, asOf, tariff);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A signed payment as far as session pricing cares: when it happened and the
|
||||||
|
* walk-back grace it granted. (The booth folds these from the ledger; the lab
|
||||||
|
* supplies a hypothetical one.) */
|
||||||
|
export interface SessionPayment {
|
||||||
|
readonly paidAt: string; // ISO-8601
|
||||||
|
readonly graceExitMin: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The full pricing outcome for a session at a moment in time — what the booth's
|
||||||
|
* `quote()` and the exit flow compute, made PURE so it can be tested or previewed
|
||||||
|
* without a real ledger. See wiki/concepts/booth-exit-flow.md (overstay pricing). */
|
||||||
|
export interface SessionPricing {
|
||||||
|
/** The window actually billed now: entry→asOf normally, or grace-expiry→asOf for an
|
||||||
|
* overstay (a paid session whose walk-back grace lapsed — a new period began). */
|
||||||
|
readonly periodStart: string;
|
||||||
|
/** Fee for [periodStart, asOf]. */
|
||||||
|
readonly amountMinor: number;
|
||||||
|
/** True when the latest payment's grace has lapsed (overstay = new period). */
|
||||||
|
readonly overstay: boolean;
|
||||||
|
/** True when paid AND still inside the walk-back window (a settled, exitable stay). */
|
||||||
|
readonly withinGrace: boolean;
|
||||||
|
/** ISO time the walk-back grace expires (lastPaid + graceExitMin), if paid. */
|
||||||
|
readonly graceExpiresAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Price a session PURELY from its times + tariff structure — the single source of
|
||||||
|
* truth shared by the live booth (`PayStation.quote`) and the Tariff Lab simulator,
|
||||||
|
* so the two can never diverge.
|
||||||
|
*
|
||||||
|
* - Not yet paid → bill entry→asOf (the running total).
|
||||||
|
* - Paid, still within walk-back grace → settled (amount 0; the car may exit).
|
||||||
|
* - Paid, grace lapsed → OVERSTAY: bill a fresh period from grace-expiry→asOf with its
|
||||||
|
* own daily-cap ladder (NOT "full stay minus paid", which a daily cap collapses to 0).
|
||||||
|
*
|
||||||
|
* `payments` is the session's payment history (only the LATEST matters for grace);
|
||||||
|
* pass [] for an unpaid session. The tariff version is the one frozen at entry — the
|
||||||
|
* customer keeps their rate card even across an overstay. See booth-exit-flow.md.
|
||||||
|
*/
|
||||||
|
export function priceSession(
|
||||||
|
enteredAt: string,
|
||||||
|
asOf: string,
|
||||||
|
tariff: TariffStructure,
|
||||||
|
payments: readonly SessionPayment[] = [],
|
||||||
|
category?: string,
|
||||||
|
): SessionPricing {
|
||||||
|
const last = payments.length ? payments[payments.length - 1] : null;
|
||||||
|
const graceExpiryMs =
|
||||||
|
last && last.graceExitMin != null ? Date.parse(last.paidAt) + last.graceExitMin * 60_000 : null;
|
||||||
|
const asOfMs = Date.parse(asOf);
|
||||||
|
const overstay = graceExpiryMs != null && asOfMs > graceExpiryMs;
|
||||||
|
const withinGrace = graceExpiryMs != null && asOfMs <= graceExpiryMs;
|
||||||
|
const periodStart = overstay ? new Date(graceExpiryMs!).toISOString() : enteredAt;
|
||||||
|
// A settled (paid + within grace) session owes nothing more; otherwise bill the period.
|
||||||
|
const amountMinor = withinGrace ? 0 : computeFee(periodStart, asOf, tariff, category);
|
||||||
|
return {
|
||||||
|
periodStart,
|
||||||
|
amountMinor,
|
||||||
|
overstay,
|
||||||
|
withinGrace,
|
||||||
|
graceExpiresAt: graceExpiryMs != null ? new Date(graceExpiryMs).toISOString() : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when a structure/card uses STEPPED ("up-to") pricing (a non-empty `steps`
|
||||||
|
* table), as opposed to the marginal `blocks` ladder or a flat rate. */
|
||||||
|
export function hasSteps(s: { steps?: readonly TariffStep[] }): boolean {
|
||||||
|
return Array.isArray(s.steps) && s.steps.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Total fee for ELAPSED minutes under a STEPPED tariff, per the rolling-24h-day rule.
|
||||||
|
* Pure + integer. The smallest tier whose `uptoMin ≥` the day's minutes wins (≤ /
|
||||||
|
* inclusive boundary). Stays beyond the largest threshold charge that top total per
|
||||||
|
* FULL day (a daily-cap repeat) and price the remainder on the next day's ladder.
|
||||||
|
* `steps` need not be sorted; we sort defensively. See wiki/concepts/tariff.md.
|
||||||
|
*/
|
||||||
|
function steppedFee(minutes: number, steps: readonly TariffStep[]): number {
|
||||||
|
if (minutes <= 0 || steps.length === 0) return 0;
|
||||||
|
const sorted = [...steps].sort((a, b) => a.uptoMin - b.uptoMin);
|
||||||
|
const top = sorted[sorted.length - 1]!;
|
||||||
|
const DAY = 24 * 60;
|
||||||
|
let total = 0;
|
||||||
|
for (let dayStart = 0; dayStart < minutes; dayStart += DAY) {
|
||||||
|
const dayMin = Math.min(DAY, minutes - dayStart); // minutes within this rolling day
|
||||||
|
// Beyond the largest tier → the whole day is the top total (per-day cap repeat).
|
||||||
|
const tier = sorted.find((s) => dayMin <= s.uptoMin) ?? top;
|
||||||
|
total += tier.totalMinor;
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
/** The original (V1) fee algorithm — a single block ladder, no wall-clock. Kept
|
/** The original (V1) fee algorithm — a single block ladder, no wall-clock. Kept
|
||||||
* VERBATIM so bare/legacy structures (incl. the live production version) price
|
* VERBATIM so bare/legacy structures (incl. the live production version) price
|
||||||
* identically. Do not "unify" this into the V2 path: a rounding divergence would
|
* identically. Do not "unify" this into the V2 path: a rounding divergence would
|
||||||
* corrupt repricing of already-signed sessions. */
|
* corrupt repricing of already-signed sessions. A `steps` table (when present)
|
||||||
|
* REPLACES the ladder via {@link steppedFee}. */
|
||||||
function computeFeeV1(enteredAt: string, asOf: string, tariff: TariffStructureV1): number {
|
function computeFeeV1(enteredAt: string, asOf: string, tariff: TariffStructureV1): number {
|
||||||
const ms = Date.parse(asOf) - Date.parse(enteredAt);
|
const ms = Date.parse(asOf) - Date.parse(enteredAt);
|
||||||
if (!Number.isFinite(ms) || ms <= 0) return 0;
|
if (!Number.isFinite(ms) || ms <= 0) return 0;
|
||||||
@@ -456,6 +706,9 @@ function computeFeeV1(enteredAt: string, asOf: string, tariff: TariffStructureV1
|
|||||||
const inc = Math.max(1, tariff.incrementMin);
|
const inc = Math.max(1, tariff.incrementMin);
|
||||||
const minutes = Math.ceil(rawMinutes / inc) * inc; // round UP to the increment
|
const minutes = Math.ceil(rawMinutes / inc) * inc; // round UP to the increment
|
||||||
|
|
||||||
|
// STEPPED pricing: a total-by-duration table replaces the marginal ladder.
|
||||||
|
if (hasSteps(tariff)) return steppedFee(minutes, tariff.steps!);
|
||||||
|
|
||||||
const DAY = 24 * 60;
|
const DAY = 24 * 60;
|
||||||
let total = 0;
|
let total = 0;
|
||||||
for (let segStart = 0; segStart < minutes; segStart += DAY) {
|
for (let segStart = 0; segStart < minutes; segStart += DAY) {
|
||||||
@@ -508,6 +761,14 @@ function computeFeeV2(
|
|||||||
const dayCap = tariff.defaultCard.dailyCapMinor ?? null;
|
const dayCap = tariff.defaultCard.dailyCapMinor ?? null;
|
||||||
|
|
||||||
const DAY = 24 * 60;
|
const DAY = 24 * 60;
|
||||||
|
|
||||||
|
// STEPPED default card: a whole-stay "total by duration" model that does NOT compose
|
||||||
|
// with per-increment windowed cards (a total isn't a per-increment rate). So when the
|
||||||
|
// defaultCard is stepped we price the WHOLE stay by the stepped day rule and ignore
|
||||||
|
// windowed cards (they have nothing to override at the increment level). This is the
|
||||||
|
// only sound place for steps in V2. See wiki/concepts/tariff.md.
|
||||||
|
if (hasSteps(tariff.defaultCard)) return steppedFee(minutes, tariff.defaultCard.steps!);
|
||||||
|
|
||||||
let total = 0;
|
let total = 0;
|
||||||
for (let segStart = 0; segStart < minutes; segStart += DAY) {
|
for (let segStart = 0; segStart < minutes; segStart += DAY) {
|
||||||
const segEnd = Math.min(segStart + DAY, minutes);
|
const segEnd = Math.min(segStart + DAY, minutes);
|
||||||
@@ -579,6 +840,26 @@ function validateBlocks(blocks: unknown, prefix: string, errs: string[]): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Validate a STEPPED ("up-to") table: ≥1 row, strictly-ascending positive `uptoMin`,
|
||||||
|
* non-negative integer totals. Totals need NOT be monotonic (an owner may price a
|
||||||
|
* longer stay cheaper if they wish), but each tier must be a clean total. `prefix`
|
||||||
|
* labels errors (e.g. "steps" or "defaultCard.steps"). */
|
||||||
|
function validateSteps(steps: unknown, prefix: string, errs: string[]): void {
|
||||||
|
if (!Array.isArray(steps) || steps.length === 0) {
|
||||||
|
errs.push(`${prefix} must be a non-empty array`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let prevBound = 0;
|
||||||
|
steps.forEach((s: Partial<TariffStep>, i: number) => {
|
||||||
|
nonNegInt(s?.totalMinor, `${prefix}[${i}].totalMinor`, errs);
|
||||||
|
if (typeof s?.uptoMin !== "number" || !Number.isInteger(s.uptoMin) || s.uptoMin <= prevBound) {
|
||||||
|
errs.push(`${prefix}[${i}].uptoMin must be an integer greater than the previous tier's bound (${prevBound})`);
|
||||||
|
} else {
|
||||||
|
prevBound = s.uptoMin;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function validateTariffV1(t: Partial<TariffStructureV1>): string[] {
|
function validateTariffV1(t: Partial<TariffStructureV1>): string[] {
|
||||||
const errs: string[] = [];
|
const errs: string[] = [];
|
||||||
nonNegInt(t.gracePeriodEntryMin, "gracePeriodEntryMin", errs);
|
nonNegInt(t.gracePeriodEntryMin, "gracePeriodEntryMin", errs);
|
||||||
@@ -587,9 +868,17 @@ function validateTariffV1(t: Partial<TariffStructureV1>): string[] {
|
|||||||
if (typeof t.incrementMin !== "number" || !Number.isInteger(t.incrementMin) || t.incrementMin < 1) {
|
if (typeof t.incrementMin !== "number" || !Number.isInteger(t.incrementMin) || t.incrementMin < 1) {
|
||||||
errs.push("incrementMin must be a positive integer");
|
errs.push("incrementMin must be a positive integer");
|
||||||
}
|
}
|
||||||
if (t.dailyCapMinor != null) nonNegInt(t.dailyCapMinor, "dailyCapMinor", errs);
|
|
||||||
if (t.overstay !== "reprice") errs.push('overstay must be "reprice"');
|
if (t.overstay !== "reprice") errs.push('overstay must be "reprice"');
|
||||||
validateBlocks(t.blocks, "blocks", errs);
|
// STEPPED mode (a non-empty steps table) REPLACES the block ladder: validate steps
|
||||||
|
// and forbid a daily cap (the top tier IS the per-day price). Otherwise validate the
|
||||||
|
// ladder. A bare V1 with neither is invalid (validateBlocks reports the empty array).
|
||||||
|
if (hasSteps(t as { steps?: readonly TariffStep[] })) {
|
||||||
|
validateSteps(t.steps, "steps", errs);
|
||||||
|
if (t.dailyCapMinor != null) errs.push("dailyCapMinor does not apply to stepped pricing (the top tier is the per-day price)");
|
||||||
|
} else {
|
||||||
|
if (t.dailyCapMinor != null) nonNegInt(t.dailyCapMinor, "dailyCapMinor", errs);
|
||||||
|
validateBlocks(t.blocks, "blocks", errs);
|
||||||
|
}
|
||||||
return errs;
|
return errs;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -607,11 +896,19 @@ function validateCard(c: Partial<TariffCard> | undefined, label: string, isDefau
|
|||||||
|
|
||||||
const hasFlat = c.flatMinor != null;
|
const hasFlat = c.flatMinor != null;
|
||||||
const hasBlocks = c.blocks != null;
|
const hasBlocks = c.blocks != null;
|
||||||
if (hasFlat === hasBlocks) {
|
const hasStepTable = c.steps != null;
|
||||||
errs.push(`${label} must set exactly one of flatMinor or blocks`);
|
const modes = [hasFlat, hasBlocks, hasStepTable].filter(Boolean).length;
|
||||||
|
if (modes !== 1) {
|
||||||
|
errs.push(`${label} must set exactly one of flatMinor, blocks, or steps`);
|
||||||
} else if (hasFlat) {
|
} else if (hasFlat) {
|
||||||
nonNegInt(c.flatMinor, `${label}.flatMinor`, errs);
|
nonNegInt(c.flatMinor, `${label}.flatMinor`, errs);
|
||||||
if (c.dailyCapMinor != null) errs.push(`${label}: dailyCapMinor applies to a block ladder, not a flat rate`);
|
if (c.dailyCapMinor != null) errs.push(`${label}: dailyCapMinor applies to a block ladder, not a flat rate`);
|
||||||
|
} else if (hasStepTable) {
|
||||||
|
// Stepped pricing is only sound on the DEFAULT card (a whole-stay total can't be
|
||||||
|
// sliced per-increment by a windowed card). Forbid it on a windowed card + the cap.
|
||||||
|
if (!isDefault) errs.push(`${label}: stepped (steps) pricing is only allowed on the defaultCard`);
|
||||||
|
validateSteps(c.steps, `${label}.steps`, errs);
|
||||||
|
if (c.dailyCapMinor != null) errs.push(`${label}: dailyCapMinor does not apply to stepped pricing (the top tier is the per-day price)`);
|
||||||
} else {
|
} else {
|
||||||
validateBlocks(c.blocks, `${label}.blocks`, errs);
|
validateBlocks(c.blocks, `${label}.blocks`, errs);
|
||||||
if (c.dailyCapMinor != null) nonNegInt(c.dailyCapMinor, `${label}.dailyCapMinor`, errs);
|
if (c.dailyCapMinor != null) nonNegInt(c.dailyCapMinor, `${label}.dailyCapMinor`, errs);
|
||||||
@@ -673,6 +970,17 @@ function validateTariffV2(t: Partial<TariffStructureV2>): string[] {
|
|||||||
cards.forEach((c, i) => validateCard(c, `windowedCards[${i}]`, false, errs));
|
cards.forEach((c, i) => validateCard(c, `windowedCards[${i}]`, false, errs));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A STEPPED base (an "up-to" total-by-duration table) prices the WHOLE stay as one
|
||||||
|
// number — it cannot be sliced per-increment, so windowed (time/seasonal) tiers have
|
||||||
|
// nothing to override and the engine ignores them entirely. Forbid the combination
|
||||||
|
// rather than let an operator publish tiers that silently never fire. (Switch the base
|
||||||
|
// to an hourly ladder / flat rate to use tiers, or remove the tiers.)
|
||||||
|
if (t.defaultCard != null && hasSteps(t.defaultCard) && cards.length > 0) {
|
||||||
|
errs.push(
|
||||||
|
"time/seasonal tiers do not apply to an up-to-duration (stepped) base rate — remove the tiers, or switch the base rate to an hourly ladder or flat price",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Precedence determinism: reject two cards (same category bucket) that tie on
|
// Precedence determinism: reject two cards (same category bucket) that tie on
|
||||||
// (specificity, priority) with overlapping windows — the operator must break the
|
// (specificity, priority) with overlapping windows — the operator must break the
|
||||||
// tie with priority rather than relying silently on the name tiebreak.
|
// tie with priority rather than relying silently on the name tiebreak.
|
||||||
@@ -769,6 +1077,112 @@ export function localBreakdown(instantMs: number, tz: string): WallClock {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Subscription plan timeframes — the "tariff bridge" gap (pure, tz-aware) -------
|
||||||
|
|
||||||
|
/** Minute-of-day is inside the window [fromMin, toMin)? A window with toMin ≤ fromMin
|
||||||
|
* WRAPS past midnight (night window 20:00→08:00 ⇒ in = m ≥ 1200 OR m < 480). */
|
||||||
|
function inWindow(m: number, fromMin: number, toMin: number): boolean {
|
||||||
|
return toMin <= fromMin ? m >= fromMin || m < toMin : m >= fromMin && m < toMin;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The out-of-window GAP for a subscriber scan, or null when the scan is in-window (or the
|
||||||
|
* scan falls on a day the window does NOT apply to). This is the portion charged at the
|
||||||
|
* transient tariff (the "tariff bridge"):
|
||||||
|
* - edge "entry" (early arrival): gap = [scan, next window-OPEN] — they pay transient
|
||||||
|
* from arrival until their window starts (a 09:00 arrival to a 20:00 night window
|
||||||
|
* owes 09:00→20:00, capped by the tariff's daily cap).
|
||||||
|
* - edge "exit" (late departure): gap = [last window-CLOSE, scan] — they pay transient
|
||||||
|
* from when their window ended until they actually leave (08:00→08:45).
|
||||||
|
* The window applies only on `timeframes.days` (0=Sun..6=Sat; empty ⇒ every day); on a
|
||||||
|
* day NOT in the set the subscriber parks free. Grace widens the allowed window by
|
||||||
|
* `graceMin` on the relevant edge. Pure + tz-aware (wall-clock in `timeframes.tz` or the
|
||||||
|
* passed `tz`); minutes-of-day arithmetic anchored on the scan's own local day keeps it
|
||||||
|
* DST-robust for the short gaps involved.
|
||||||
|
*/
|
||||||
|
export function outOfWindowGap(
|
||||||
|
timeframes: PlanTimeframes | null | undefined,
|
||||||
|
tz: string,
|
||||||
|
atISO: string,
|
||||||
|
edge: "entry" | "exit",
|
||||||
|
): { start: string; end: string; minutes: number } | null {
|
||||||
|
if (!timeframes) return null;
|
||||||
|
if (typeof timeframes.fromMin !== "number" || typeof timeframes.toMin !== "number") return null;
|
||||||
|
const atMs = Date.parse(atISO);
|
||||||
|
if (Number.isNaN(atMs)) return null;
|
||||||
|
const zone = timeframes.tz || tz;
|
||||||
|
const wall = localBreakdown(atMs, zone);
|
||||||
|
|
||||||
|
// The window applies only on the selected days; empty/absent = every day. On a day the
|
||||||
|
// window doesn't cover, the subscriber may park all day (no charge).
|
||||||
|
const days = timeframes.days;
|
||||||
|
if (days && days.length > 0 && !days.includes(wall.dow)) return null;
|
||||||
|
|
||||||
|
const grace = Math.max(0, timeframes.graceMin ?? 0);
|
||||||
|
const nowMin = wall.hour * 60 + wall.minute;
|
||||||
|
|
||||||
|
if (inWindow(nowMin, timeframes.fromMin, timeframes.toMin)) return null; // already allowed
|
||||||
|
|
||||||
|
// Minutes (always ≥ 0) until the window OPENS, measured forward from the scan.
|
||||||
|
const minsUntil = (target: number) => ((target - nowMin) % 1440 + 1440) % 1440;
|
||||||
|
// Minutes (always ≥ 0) since the window CLOSED, measured backward from the scan.
|
||||||
|
const minsSince = (target: number) => ((nowMin - target) % 1440 + 1440) % 1440;
|
||||||
|
|
||||||
|
if (edge === "entry") {
|
||||||
|
// Early: charge from the scan until the window opens (minus grace tolerance).
|
||||||
|
const mins = minsUntil(timeframes.fromMin) - grace;
|
||||||
|
if (mins <= 0) return null; // within grace of opening
|
||||||
|
const end = new Date(atMs + mins * 60_000).toISOString();
|
||||||
|
return { start: atISO, end, minutes: mins };
|
||||||
|
}
|
||||||
|
// Late exit: charge from when the window closed (plus grace) until the scan.
|
||||||
|
const mins = minsSince(timeframes.toMin) - grace;
|
||||||
|
if (mins <= 0) return null; // within grace of closing
|
||||||
|
const start = new Date(atMs - mins * 60_000).toISOString();
|
||||||
|
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);
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { addMonths, periodsBetween, priceSubscriptionSpan } from "./index.js";
|
||||||
|
|
||||||
|
// Pricing a subscription SPAN against a plan. Period counting is CEIL — any started
|
||||||
|
// period is a full one (hotel/parking practice). See wiki/entities/subscription.md.
|
||||||
|
|
||||||
|
const iso = (s: string) => new Date(s).toISOString();
|
||||||
|
|
||||||
|
describe("periodsBetween — day", () => {
|
||||||
|
it("exact multiples are not rounded up", () => {
|
||||||
|
expect(periodsBetween("day", iso("2026-06-20T10:00:00Z"), iso("2026-06-23T10:00:00Z"))).toBe(3);
|
||||||
|
});
|
||||||
|
it("any started day rounds up (hotel check-out mid-day)", () => {
|
||||||
|
// Mon 14:00 → Wed 10:00 = 1.83 days → 2.
|
||||||
|
expect(periodsBetween("day", iso("2026-06-22T14:00:00Z"), iso("2026-06-24T10:00:00Z"))).toBe(2);
|
||||||
|
});
|
||||||
|
it("a sliver over zero is 1 day", () => {
|
||||||
|
expect(periodsBetween("day", iso("2026-06-20T00:00:00Z"), iso("2026-06-20T00:01:00Z"))).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("periodsBetween — week", () => {
|
||||||
|
it("exactly two weeks", () => {
|
||||||
|
expect(periodsBetween("week", iso("2026-06-01T00:00:00Z"), iso("2026-06-15T00:00:00Z"))).toBe(2);
|
||||||
|
});
|
||||||
|
it("eight days rounds up to 2 weeks", () => {
|
||||||
|
expect(periodsBetween("week", iso("2026-06-01T00:00:00Z"), iso("2026-06-09T00:00:00Z"))).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("periodsBetween — month (whole-month walk, day-overflow clamp)", () => {
|
||||||
|
it("exactly one month", () => {
|
||||||
|
expect(periodsBetween("month", iso("2026-01-15T00:00:00Z"), iso("2026-02-15T00:00:00Z"))).toBe(1);
|
||||||
|
});
|
||||||
|
it("just over one month rounds up to 2", () => {
|
||||||
|
expect(periodsBetween("month", iso("2026-01-15T00:00:00Z"), iso("2026-02-16T00:00:00Z"))).toBe(2);
|
||||||
|
});
|
||||||
|
it("Jan 31 + clamp: Jan 31 → Feb 28 is one month", () => {
|
||||||
|
// addMonths clamps Jan 31 +1mo to Feb 28; a span to exactly Feb 28 = 1 month.
|
||||||
|
expect(addMonths(iso("2026-01-31T00:00:00Z"), 1)).toBe(iso("2026-02-28T00:00:00Z"));
|
||||||
|
expect(periodsBetween("month", iso("2026-01-31T00:00:00Z"), iso("2026-02-28T00:00:00Z"))).toBe(1);
|
||||||
|
});
|
||||||
|
it("three months", () => {
|
||||||
|
expect(periodsBetween("month", iso("2026-01-10T00:00:00Z"), iso("2026-04-10T00:00:00Z"))).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("periodsBetween — degenerate spans", () => {
|
||||||
|
it("equal/inverted spans price to 0 periods", () => {
|
||||||
|
expect(periodsBetween("day", iso("2026-06-20T00:00:00Z"), iso("2026-06-20T00:00:00Z"))).toBe(0);
|
||||||
|
expect(periodsBetween("day", iso("2026-06-21T00:00:00Z"), iso("2026-06-20T00:00:00Z"))).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("priceSubscriptionSpan", () => {
|
||||||
|
const hotelDaily = { period: "day" as const, pricePerPeriodMinor: 80000, currency: "ALL" };
|
||||||
|
it("hotel: 3 clean days × 800 ALL = 2,400 ALL", () => {
|
||||||
|
const q = priceSubscriptionSpan(hotelDaily, iso("2026-06-20T12:00:00Z"), iso("2026-06-23T12:00:00Z"));
|
||||||
|
expect(q).toEqual({ periods: 3, amountMinor: 240000, currency: "ALL", period: "day" });
|
||||||
|
});
|
||||||
|
it("hotel: part-day rounds up the charge", () => {
|
||||||
|
const q = priceSubscriptionSpan(hotelDaily, iso("2026-06-22T14:00:00Z"), iso("2026-06-24T10:00:00Z"));
|
||||||
|
expect(q.periods).toBe(2);
|
||||||
|
expect(q.amountMinor).toBe(160000);
|
||||||
|
});
|
||||||
|
it("monthly plan: 10,000 ALL/mo over 2 months", () => {
|
||||||
|
const monthly = { period: "month" as const, pricePerPeriodMinor: 1000000, currency: "ALL" };
|
||||||
|
const q = priceSubscriptionSpan(monthly, iso("2026-01-01T00:00:00Z"), iso("2026-03-01T00:00:00Z"));
|
||||||
|
expect(q).toEqual({ periods: 2, amountMinor: 2000000, currency: "ALL", period: "month" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
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.
|
||||||
|
|
||||||
|
// Night plan: window 20:00→08:00 (wraps midnight) on weekdays (Mon..Fri). Days not in the
|
||||||
|
// set (Sat/Sun) are unrestricted — no charge.
|
||||||
|
const night: PlanTimeframes = {
|
||||||
|
days: [1, 2, 3, 4, 5], // Mon..Fri
|
||||||
|
fromMin: 20 * 60,
|
||||||
|
toMin: 8 * 60, // 1200 → 480
|
||||||
|
graceMin: 0,
|
||||||
|
tz: "UTC",
|
||||||
|
};
|
||||||
|
|
||||||
|
// 2026-06-22 is a Monday; 2026-06-20 is a Saturday.
|
||||||
|
const monday = (hhmm: string) => `2026-06-22T${hhmm}:00.000Z`;
|
||||||
|
const saturday = (hhmm: string) => `2026-06-20T${hhmm}:00.000Z`;
|
||||||
|
|
||||||
|
describe("outOfWindowGap — entry edge (early arrival)", () => {
|
||||||
|
it("19:30 arrival to a 20:00 window owes 30 min", () => {
|
||||||
|
const g = outOfWindowGap(night, "UTC", monday("19:30"), "entry");
|
||||||
|
expect(g).not.toBeNull();
|
||||||
|
expect(g!.minutes).toBe(30);
|
||||||
|
expect(g!.start).toBe(monday("19:30"));
|
||||||
|
expect(g!.end).toBe(monday("20:00"));
|
||||||
|
});
|
||||||
|
it("09:00 daytime arrival owes the whole gap to 20:00 (11h)", () => {
|
||||||
|
const g = outOfWindowGap(night, "UTC", monday("09:00"), "entry");
|
||||||
|
expect(g!.minutes).toBe(11 * 60);
|
||||||
|
expect(g!.end).toBe(monday("20:00"));
|
||||||
|
});
|
||||||
|
it("in-window arrival (22:00) owes nothing", () => {
|
||||||
|
expect(outOfWindowGap(night, "UTC", monday("22:00"), "entry")).toBeNull();
|
||||||
|
});
|
||||||
|
it("after-midnight in-window arrival (02:00) owes nothing", () => {
|
||||||
|
expect(outOfWindowGap(night, "UTC", monday("02:00"), "entry")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("outOfWindowGap — exit edge (late departure)", () => {
|
||||||
|
it("08:45 exit after an 08:00 window close owes 45 min", () => {
|
||||||
|
const g = outOfWindowGap(night, "UTC", monday("08:45"), "exit");
|
||||||
|
expect(g!.minutes).toBe(45);
|
||||||
|
expect(g!.start).toBe(monday("08:00"));
|
||||||
|
expect(g!.end).toBe(monday("08:45"));
|
||||||
|
});
|
||||||
|
it("in-window exit (07:00) owes nothing", () => {
|
||||||
|
expect(outOfWindowGap(night, "UTC", monday("07:00"), "exit")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("outOfWindowGap — days the window doesn't apply", () => {
|
||||||
|
it("a Saturday scan is free (window only Mon..Fri)", () => {
|
||||||
|
expect(outOfWindowGap(night, "UTC", saturday("09:00"), "entry")).toBeNull();
|
||||||
|
expect(outOfWindowGap(night, "UTC", saturday("23:30"), "exit")).toBeNull();
|
||||||
|
});
|
||||||
|
it("a window with no days (every day) DOES apply on Saturday", () => {
|
||||||
|
const everyDay: PlanTimeframes = { fromMin: 1200, toMin: 480, tz: "UTC" };
|
||||||
|
expect(outOfWindowGap(everyDay, "UTC", saturday("09:00"), "entry")).not.toBeNull();
|
||||||
|
});
|
||||||
|
it("an arbitrary day set (e.g. only Saturday) applies just then", () => {
|
||||||
|
const satOnly: PlanTimeframes = { days: [6], fromMin: 1200, toMin: 480, tz: "UTC" };
|
||||||
|
expect(outOfWindowGap(satOnly, "UTC", saturday("09:00"), "entry")).not.toBeNull();
|
||||||
|
expect(outOfWindowGap(satOnly, "UTC", monday("09:00"), "entry")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("outOfWindowGap — grace tolerance", () => {
|
||||||
|
const withGrace: PlanTimeframes = { ...night, graceMin: 15 };
|
||||||
|
it("19:50 entry (10 min before open) is within a 15-min grace → no charge", () => {
|
||||||
|
expect(outOfWindowGap(withGrace, "UTC", monday("19:50"), "entry")).toBeNull();
|
||||||
|
});
|
||||||
|
it("19:30 entry (30 min before) still charged, minus 15 grace = 15 min", () => {
|
||||||
|
const g = outOfWindowGap(withGrace, "UTC", monday("19:30"), "entry");
|
||||||
|
expect(g!.minutes).toBe(15);
|
||||||
|
});
|
||||||
|
it("08:10 exit within a 15-min grace of the 08:00 close → no charge", () => {
|
||||||
|
expect(outOfWindowGap(withGrace, "UTC", monday("08:10"), "exit")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("outOfWindowGap — unrestricted", () => {
|
||||||
|
it("null timeframes → never a charge", () => {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import {
|
import {
|
||||||
computeFee,
|
computeFee,
|
||||||
|
priceSession,
|
||||||
validateTariffStructure,
|
validateTariffStructure,
|
||||||
type TariffStructureV1,
|
type TariffStructureV1,
|
||||||
type TariffStructureV2,
|
type TariffStructureV2,
|
||||||
@@ -222,12 +223,25 @@ describe("validate V2", () => {
|
|||||||
});
|
});
|
||||||
it("rejects a card with both flat and blocks", () => {
|
it("rejects a card with both flat and blocks", () => {
|
||||||
const errs = validateTariffStructure({ ...base, defaultCard: { name: "d", priority: 0, flatMinor: 100, blocks: ladder(100) } });
|
const errs = validateTariffStructure({ ...base, defaultCard: { name: "d", priority: 0, flatMinor: 100, blocks: ladder(100) } });
|
||||||
expect(errs).toContain("defaultCard must set exactly one of flatMinor or blocks");
|
expect(errs).toContain("defaultCard must set exactly one of flatMinor, blocks, or steps");
|
||||||
});
|
});
|
||||||
it("rejects defaultCard with a window", () => {
|
it("rejects defaultCard with a window", () => {
|
||||||
const errs = validateTariffStructure({ ...base, defaultCard: { ...okDefault, window: { dow: [1] } } });
|
const errs = validateTariffStructure({ ...base, defaultCard: { ...okDefault, window: { dow: [1] } } });
|
||||||
expect(errs).toContain("defaultCard must not have a window (it is the always-active fallback)");
|
expect(errs).toContain("defaultCard must not have a window (it is the always-active fallback)");
|
||||||
});
|
});
|
||||||
|
it("rejects a STEPPED base card combined with windowed tiers (they would be ignored)", () => {
|
||||||
|
const steppedDefault: TariffCard = { name: "d", priority: 0, steps: [{ uptoMin: 60, totalMinor: 200 }] };
|
||||||
|
const errs = validateTariffStructure({
|
||||||
|
...base,
|
||||||
|
defaultCard: steppedDefault,
|
||||||
|
windowedCards: [{ name: "night", priority: 1, window: { dow: [1] }, blocks: ladder(5000) }],
|
||||||
|
});
|
||||||
|
expect(errs.some((e) => /up-to-duration \(stepped\) base/.test(e))).toBe(true);
|
||||||
|
});
|
||||||
|
it("accepts a STEPPED base card with NO tiers", () => {
|
||||||
|
const steppedDefault: TariffCard = { name: "d", priority: 0, steps: [{ uptoMin: 60, totalMinor: 200 }] };
|
||||||
|
expect(validateTariffStructure({ ...base, defaultCard: steppedDefault })).toEqual([]);
|
||||||
|
});
|
||||||
it("rejects a bad hour format", () => {
|
it("rejects a bad hour format", () => {
|
||||||
const errs = validateTariffStructure({ ...base, defaultCard: okDefault, windowedCards: [{ name: "w", priority: 1, window: { fromHour: "25:00", toHour: "26:00" }, blocks: ladder(5000) }] });
|
const errs = validateTariffStructure({ ...base, defaultCard: okDefault, windowedCards: [{ name: "w", priority: 1, window: { fromHour: "25:00", toHour: "26:00" }, blocks: ladder(5000) }] });
|
||||||
expect(errs.some((e) => e.includes("fromHour"))).toBe(true);
|
expect(errs.some((e) => e.includes("fromHour"))).toBe(true);
|
||||||
@@ -255,3 +269,117 @@ describe("validate V2", () => {
|
|||||||
expect(errs).toEqual([]);
|
expect(errs).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// (h) priceSession — the grace/overstay wrapper shared by the booth + Tariff Lab.
|
||||||
|
// liveV1: 5-min entry grace, 60-min increment, blocks 20000(1h)/10000(to 3h),
|
||||||
|
// daily cap 100000, exit grace 5 min.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe("priceSession grace + overstay", () => {
|
||||||
|
const paidAt = (min: number) => at(min);
|
||||||
|
|
||||||
|
it("unpaid → bills entry→asOf (running total)", () => {
|
||||||
|
const r = priceSession(entered, at(120), liveV1, []);
|
||||||
|
expect(r.overstay).toBe(false);
|
||||||
|
expect(r.withinGrace).toBe(false);
|
||||||
|
expect(r.periodStart).toBe(entered);
|
||||||
|
expect(r.amountMinor).toBe(30000); // 2h: 20000 + 10000
|
||||||
|
});
|
||||||
|
|
||||||
|
it("paid and still within walk-back grace → settled (owes 0)", () => {
|
||||||
|
// Paid at 120 min with a 5-min grace; asOf 123 min is inside the window.
|
||||||
|
const r = priceSession(entered, at(123), liveV1, [{ paidAt: paidAt(120), graceExitMin: 5 }]);
|
||||||
|
expect(r.withinGrace).toBe(true);
|
||||||
|
expect(r.overstay).toBe(false);
|
||||||
|
expect(r.amountMinor).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("paid but grace expired → overstay priced as a NEW period from grace-expiry", () => {
|
||||||
|
// Paid at 120 min, grace 5 → expires at 125 min. asOf 245 min ⇒ a 2h new period.
|
||||||
|
const r = priceSession(entered, at(245), liveV1, [{ paidAt: paidAt(120), graceExitMin: 5 }]);
|
||||||
|
expect(r.overstay).toBe(true);
|
||||||
|
expect(r.withinGrace).toBe(false);
|
||||||
|
expect(r.periodStart).toBe(at(125));
|
||||||
|
// The new period is its own ladder from 0: 2h ⇒ 20000 + 10000 = 30000.
|
||||||
|
expect(r.amountMinor).toBe(30000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("overstay does NOT collapse to 0 under a daily cap (regression: ticket 1245791632490)", () => {
|
||||||
|
// A ~2-day overstay: with 'full stay minus paid' the cap made this 0. The
|
||||||
|
// new-period model re-accrues — strictly positive.
|
||||||
|
const r = priceSession(entered, at(120 + 5 + 2880), liveV1, [{ paidAt: paidAt(120), graceExitMin: 5 }]);
|
||||||
|
expect(r.overstay).toBe(true);
|
||||||
|
expect(r.amountMinor).toBe(200000); // 2 capped days
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// (i) STEPPED ("up-to") pricing — the owner's total-by-duration matrix.
|
||||||
|
// 0-1h=200, 0-3h=500, 0-6h=800, 0-9h=900, 0-12h=1000. Beyond 12h, the top total
|
||||||
|
// (1000) repeats as a per-day price. Boundary is <= (inclusive).
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const stepped: TariffStructureV1 = {
|
||||||
|
gracePeriodEntryMin: 5,
|
||||||
|
incrementMin: 60,
|
||||||
|
blocks: [], // ignored when steps present
|
||||||
|
steps: [
|
||||||
|
{ uptoMin: 60, totalMinor: 200 },
|
||||||
|
{ uptoMin: 180, totalMinor: 500 },
|
||||||
|
{ uptoMin: 360, totalMinor: 800 },
|
||||||
|
{ uptoMin: 540, totalMinor: 900 },
|
||||||
|
{ uptoMin: 720, totalMinor: 1000 },
|
||||||
|
],
|
||||||
|
dailyCapMinor: null,
|
||||||
|
lostTicketMinor: 100000,
|
||||||
|
gracePeriodExitMin: 5,
|
||||||
|
overstay: "reprice",
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("stepped (up-to) pricing — owner matrix", () => {
|
||||||
|
const cases: Record<string, number> = {
|
||||||
|
"3": 0, // within entry grace → free
|
||||||
|
"30": 200, // ≤ 1h
|
||||||
|
"60": 200, // exactly 1h (inclusive)
|
||||||
|
"61": 500, // into the 3h tier
|
||||||
|
"180": 500, // exactly 3h
|
||||||
|
"181": 800, // into the 6h tier
|
||||||
|
"360": 800, // exactly 6h
|
||||||
|
"540": 900, // exactly 9h
|
||||||
|
"720": 1000, // exactly 12h
|
||||||
|
};
|
||||||
|
for (const [min, want] of Object.entries(cases)) {
|
||||||
|
it(`${min} min → ${want}`, () => {
|
||||||
|
expect(computeFee(entered, at(Number(min)), stepped)).toBe(want);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it("beyond the top tier the day's total is the top tier (daily-cap behaviour)", () => {
|
||||||
|
// 13h is past the 12h top tier but still within ONE rolling day → top total 1000
|
||||||
|
// (the top tier is that day's ceiling; it does NOT restart a new tier cycle).
|
||||||
|
expect(computeFee(entered, at(13 * 60), stepped)).toBe(1000);
|
||||||
|
// exactly 24h = one full day at the top total
|
||||||
|
expect(computeFee(entered, at(24 * 60), stepped)).toBe(1000);
|
||||||
|
// 25h = day1 ceiling (1000) + 1h into day2 (200) = 1200
|
||||||
|
expect(computeFee(entered, at(25 * 60), stepped)).toBe(1200);
|
||||||
|
// 26h = 1000 + (2h → ≤180min tier = 500) = 1500
|
||||||
|
expect(computeFee(entered, at(26 * 60), stepped)).toBe(1500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("priceSession routes overstay through the stepped engine too", () => {
|
||||||
|
// paid at 120, grace 5 → expires 125; asOf = 125 + 180 (3h new period) → 500
|
||||||
|
const r = priceSession(entered, at(125 + 180), stepped, [{ paidAt: at(120), graceExitMin: 5 }]);
|
||||||
|
expect(r.overstay).toBe(true);
|
||||||
|
expect(r.amountMinor).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("validates: a stepped V1 is valid; non-ascending uptoMin is rejected", () => {
|
||||||
|
expect(validateTariffStructure(stepped)).toEqual([]);
|
||||||
|
const bad = { ...stepped, steps: [{ uptoMin: 180, totalMinor: 500 }, { uptoMin: 60, totalMinor: 200 }] };
|
||||||
|
expect(validateTariffStructure(bad).length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a daily cap combined with steps", () => {
|
||||||
|
const capped = { ...stepped, dailyCapMinor: 100000 };
|
||||||
|
expect(validateTariffStructure(capped).some((e) => /dailyCap/i.test(e))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -84,12 +84,54 @@ phantom obstacle: an animal, a person, a cardboard box or bag in the wind). Thes
|
|||||||
present* until grace expires. **Payment and a successful voucher scan do NOT remove it from the
|
present* until grace expires. **Payment and a successful voucher scan do NOT remove it from the
|
||||||
list** — only grace expiry does.
|
list** — only grace expiry does.
|
||||||
|
|
||||||
A session drops off the list once it is **past grace** and EITHER exited OR **paid** (presumed truly
|
A session drops off the list once it is **exited AND past grace** (presumed truly gone). One more
|
||||||
gone). The **paid age-out** is important: a paid session whose walk-back grace lapsed has left, so it
|
state is kept and **flagged**, not dropped:
|
||||||
is omitted **even if no `vehicle_exit` was ever signed**. Without this, a paid car that left via a
|
|
||||||
manual barrier re-open (which historically signed no exit — see below) would linger **forever**
|
- **OVERSTAY — open + paid + past grace, no signed `vehicle_exit`.** A paid transient whose walk-back
|
||||||
(ticket T-397815c0, 2026-06-18). The signed log is untouched — this is purely the list's display
|
grace lapsed. This is **not a system fault and not "stuck"**: the customer paid, then the car stayed
|
||||||
filter (`PayStation.activeSessions()`).
|
beyond the paid window — they **re-parked (a new period began)**, or the car is **faulty/abandoned**.
|
||||||
|
It is **kept in the list with a distinct red `overstay` badge** (not aged out) so the operator
|
||||||
|
reconciles it. This replaces the earlier silent **paid age-out** (revised 2026-06-20): aging these
|
||||||
|
out hid a real problem — the session lingers in **occupancy** (the ledger fold counts it inside, so
|
||||||
|
occupancy and the active-list count diverge), and on a re-scan the exit flow refuses
|
||||||
|
(`exit.refused.graceExpired`). The signed log is untouched — `overstay` is a derived display flag
|
||||||
|
(`PayStation.activeSessions()` and `lookup()`), as the age-out was. The occupancy/active-list gap is
|
||||||
|
now explained by these named rows rather than an unbounded counter.
|
||||||
|
|
||||||
|
> **Naming history (2026-06-20).** First shipped as `stuck` / *i ngecur*. Renamed to `overstay` /
|
||||||
|
> *tej afatit* the same day: "stuck" wrongly implied a system fault trapping the customer, when in
|
||||||
|
> fact a **new parking period has begun**. The label now states the fact (stayed beyond the paid
|
||||||
|
> window), not a presumed cause.
|
||||||
|
|
||||||
|
**No free exit on an overstay (security fix, 2026-06-20).** An overstay is **NOT offered the
|
||||||
|
"Open barrier" action** — its row routes to the pay/exit modal for the **new-period payment**, and
|
||||||
|
`reopenBarrier` **refuses server-side** when a transient's payment grace has expired ("walk-back
|
||||||
|
grace expired — take a top-up payment first"). A stale payment no longer authorizes a free open.
|
||||||
|
*This corrects a hole introduced earlier the same day:* the first cut kept the Open-barrier button
|
||||||
|
on these rows (it gated on `paidAt != null`), which would have let an operator wave out a multi-day
|
||||||
|
overstay for free — exactly the [[threat-model|operator-as-adversary]] path. Subscriptions are never
|
||||||
|
`overstay` (prepaid; no `paidAt`/grace) and keep their assist Open-barrier.
|
||||||
|
|
||||||
|
> **Why flag, not auto-close.** The chosen fix (user, 2026-06-20) keeps the ledger append-only and
|
||||||
|
> the operator in the loop: surfacing the session beats silently synthesizing an exit (which would
|
||||||
|
> mutate occupancy with a weaker audit story) or silently hiding it (which lets occupancy drift
|
||||||
|
> upward until the lot falsely reads "full").
|
||||||
|
|
||||||
|
#### Overstay pricing — a NEW period from grace-expiry (2026-06-20)
|
||||||
|
|
||||||
|
When an overstay is settled, `quote()` prices a **fresh period anchored at grace-expiry**
|
||||||
|
(`paidAt + graceExitMin`) → now, with its **own daily-cap ladder** — NOT the whole stay, and NOT
|
||||||
|
"full stay minus paid". The latter was tried first and was **wrong under a daily cap**: the
|
||||||
|
whole-stay gross plateaus at the cap while prior payments keep pace, so `gross − paid` collapses to
|
||||||
|
**0** and a multi-day overstay would exit **free** (real case: ticket `1245791632490` — entered
|
||||||
|
2026-06-17, paid 330000 with a 100000/day cap, `gross = 330000`, delta = **0 ALL**). Pricing the
|
||||||
|
overstay as a **new session** reflects reality (the car re-parked) and re-accrues the fee
|
||||||
|
(verified: the same ticket owes 20000 ALL for its first half-hour of overstay, not 0). The tariff
|
||||||
|
version stays the one frozen at **entry** (the customer keeps their rate card). The booth modal shows
|
||||||
|
this as a **"New period due"** total with an OVERSTAY status; taking the payment writes a fresh
|
||||||
|
`graceExitMin`, restarting the walk-back window so the car can exit normally. A within-grace paid
|
||||||
|
session is not an overstay (`amountMinor = 0`, non-payable). `Quote` now carries `periodStart` (entry,
|
||||||
|
or grace-expiry for an overstay) and an `overstay` flag.
|
||||||
|
|
||||||
### The one operator action — "Open barrier" (audited re-pulse)
|
### The one operator action — "Open barrier" (audited re-pulse)
|
||||||
|
|
||||||
@@ -106,16 +148,19 @@ For an active session, the operator can open the barrier as a **human interventi
|
|||||||
> *only* way a car left (its walk-back grace had expired, so a normal exit was refused), the session
|
> *only* way a car left (its walk-back grace had expired, so a normal exit was refused), the session
|
||||||
> kept **no exit event** and lingered as "open" forever (ticket T-397815c0). Fix: sign the exit only
|
> kept **no exit event** and lingered as "open" forever (ticket T-397815c0). Fix: sign the exit only
|
||||||
> when the session is **still open**, preserving the no-double-count guarantee for the already-exited
|
> when the session is **still open**, preserving the no-double-count guarantee for the already-exited
|
||||||
> case. The [[#a-session-is-active|paid age-out]] above is the belt-and-braces safety net for any
|
> case. The [[#a-session-is-active|overstay flag]] above is the belt-and-braces visibility net for any
|
||||||
> paid session that still slips through.
|
> paid session that still slips through — it surfaces the orphan for operator reconcile instead of
|
||||||
|
> hiding it.
|
||||||
|
|
||||||
**Guard — paid OR subscription, else no button.** The "Open barrier" action is shown/active for a
|
**Guard — paid-and-in-grace OR subscription, else no button.** The "Open barrier" action is
|
||||||
session that **has a payment** (paid, or paid-and-exited-in-grace) **OR is a [[subscription]]
|
shown/active for a session that has a payment **still within the walk-back grace window** (paid, or
|
||||||
occurrence** (prepaid — the operator must be able to assist a subscriber when the exit reader / card
|
paid-and-exited-in-grace) **OR is a [[subscription]] occurrence** (prepaid — the operator must be
|
||||||
fails). An **unpaid TRANSIENT** open session has **no barrier-open affordance** — the row routes to
|
able to assist a subscriber when the exit reader / card fails). It is **NOT** offered for an **unpaid
|
||||||
the [[#operator-flow|pay/exit modal]] instead. The no-unpaid-bypass rule is enforced structurally
|
TRANSIENT** (no-unpaid-bypass) **nor for an `overstay`** session (grace expired → owes a
|
||||||
(server-side in `reopenBarrier`: `paidAt != null || subscription`). A future reason-required *force
|
top-up). Both route to the [[#operator-flow|pay/exit modal]] instead. Enforced structurally
|
||||||
exit* for genuine disputes would be a separately-audited path — see Open.
|
server-side in `reopenBarrier`: allow only when `subscription` OR (`paidAt != null` AND `now ≤
|
||||||
|
paidAt + graceExitMin`). A future reason-required *force exit* for genuine disputes (car already gone)
|
||||||
|
would be a separately-audited path — see Open.
|
||||||
|
|
||||||
### Subscription occurrences in the booth (built 2026-06-18)
|
### Subscription occurrences in the booth (built 2026-06-18)
|
||||||
|
|
||||||
@@ -131,9 +176,30 @@ This single mechanism covers both edge cases: a **damaged ticket / dead scanner*
|
|||||||
session in the list → pay/exit modal, or if already paid → Open barrier, no scan needed), and a
|
session in the list → pay/exit modal, or if already paid → Open barrier, no scan needed), and a
|
||||||
**phantom-obstacle re-close** (the just-exited car is still in the list within grace → Open barrier).
|
**phantom-obstacle re-close** (the just-exited car is still in the list within grace → Open barrier).
|
||||||
|
|
||||||
|
### Booth filters (built 2026-06-20)
|
||||||
|
|
||||||
|
Both booth lists carry a shared, client-side `FilterBar` (search box + segmented toggles; the active
|
||||||
|
filter shows a `matched/total` count). No new API — filtering is over data already fetched.
|
||||||
|
|
||||||
|
- **Active Sessions**: free-text (ticket id / subscriber holder), a **status** segment
|
||||||
|
(unpaid / paid / exiting / **overstay**), and a **transient vs subscriber** segment.
|
||||||
|
- **Live feed**: free-text (identity / subscriber label / advisory plate), an **event** segment
|
||||||
|
(entry / exit / pay / void / anomaly), a **direction** segment (entry / exit), and a **source**
|
||||||
|
segment — **booth** (operator-initiated, `source: manual`) vs **reader** (device-initiated:
|
||||||
|
wiegand/lpr/qr/ticket). Filters are scoped within the current shift window, as the feed already is.
|
||||||
|
|
||||||
## ⚠ Open question — walk-back grace renews on every payment (voucher overstay)
|
## ⚠ Open question — walk-back grace renews on every payment (voucher overstay)
|
||||||
|
|
||||||
**Found 2026-06-17. Not yet fixed.** Scenario: customer pays at the booth, takes an exit voucher,
|
> **Update 2026-06-20 — pricing half resolved; grace-renewal half still open.** The overstay work
|
||||||
|
> (see [[#overstay-pricing-a-new-period-from-grace-expiry-2026-06-20|Overstay pricing]] above) changed
|
||||||
|
> the money model: an overstay is now priced as a **NEW period from grace-expiry**, *not*
|
||||||
|
> reprice-from-entry. The note below described the older reprice-from-entry behaviour; the **leak-is-
|
||||||
|
> time, not money** analysis still holds for the grace-window side, which is **still unfixed** —
|
||||||
|
> candidate fix #1 below remains the recommendation. (Note: under new-period pricing the "pay a tiny
|
||||||
|
> delta → fresh full window" loop now also re-accrues a fresh fee each cycle, narrowing but not
|
||||||
|
> closing the time leak.)
|
||||||
|
|
||||||
|
**Found 2026-06-17.** Scenario: customer pays at the booth, takes an exit voucher,
|
||||||
then dawdles past the walk-back grace before reaching the exit.
|
then dawdles past the walk-back grace before reaching the exit.
|
||||||
|
|
||||||
What the code does today (`exit-flow.ts`, `pay-station.ts`):
|
What the code does today (`exit-flow.ts`, `pay-station.ts`):
|
||||||
|
|||||||
@@ -33,6 +33,23 @@ editable and drifts; the chain is the truth). Spaces-free = `capacity − occupa
|
|||||||
loop count, or the [[opencv-anpr-service|vision]] count) reconciles it — surfaced as an anomaly,
|
loop count, or the [[opencv-anpr-service|vision]] count) reconciles it — surfaced as an anomaly,
|
||||||
not silently corrected.
|
not silently corrected.
|
||||||
|
|
||||||
|
## Reserved subscriber spots (admin toggle, built 2026-06-20)
|
||||||
|
|
||||||
|
By default occupancy counts only cars **physically inside** — a subscriber who isn't parked frees
|
||||||
|
their spot to transients, and the operator handles any overflow by valet/key-juggling. A site can
|
||||||
|
instead **hold a spot for every active subscriber**, so the lot reads "full" to transients sooner and
|
||||||
|
the subscriber's place is guaranteed:
|
||||||
|
|
||||||
|
- `site_config.reserve_subscriber_spots` (bool, default off). When ON,
|
||||||
|
`reservedSubscriberSpots(db)` sums, over every **active** subscription (status active AND
|
||||||
|
`now ∈ [validFrom, validTo]`), `max(0, quantity − itsCarsCurrentlyInside)` — i.e. it reserves only
|
||||||
|
the **not-yet-parked** portion of each subscription's [[subscription|quantity]] (a parked
|
||||||
|
subscriber already occupies a real spot; counting them twice would over-reserve).
|
||||||
|
- `getOccupancy` gains `reserved` + `effectiveFree = capacity − count − reserved`. The transient FULL
|
||||||
|
gate becomes **`count + reserved ≥ capacity`**. Subscribers are still **never** gated by full
|
||||||
|
(their flow ignores it) — reservation only tightens the *transient* gate.
|
||||||
|
- OFF = the prior behaviour exactly (`reserved = 0`).
|
||||||
|
|
||||||
## "Full" is a soft, operator-configurable policy
|
## "Full" is a soft, operator-configurable policy
|
||||||
|
|
||||||
Refusing at capacity is the **default**, not an absolute. An operator may opt into
|
Refusing at capacity is the **default**, not an absolute. An operator may opt into
|
||||||
|
|||||||
+49
-19
@@ -2,7 +2,7 @@
|
|||||||
type: concept
|
type: concept
|
||||||
tags: [parking, domain, business, shifts, anti-fraud]
|
tags: [parking, domain, business, shifts, anti-fraud]
|
||||||
sources: []
|
sources: []
|
||||||
updated: 2026-06-19
|
updated: 2026-06-20
|
||||||
status: open
|
status: open
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -64,8 +64,11 @@ login ————————————————————————
|
|||||||
|
|
||||||
1. Determine the shift's payment set: the signed `payment` events ([[parking-session]],
|
1. Determine the shift's payment set: the signed `payment` events ([[parking-session]],
|
||||||
[[append-only-event-chain]]) between this shift's start mark and now. This includes a
|
[[append-only-event-chain]]) between this shift's start mark and now. This includes a
|
||||||
**[[subscription]] fee** an operator collects during the shift (sold/renewed at the booth → a
|
**[[subscription]] sale fee** an operator collects during the shift (selling/renewing at the booth
|
||||||
signed `payment`, deferred build) — it folds into this set like any transient taking.
|
appends a signed `payment` with `subscriptionSale: true`, amount `priceMinor × months` — **built
|
||||||
|
2026-06-20**) — it folds into this set like any transient taking, no special-casing. *(Before that
|
||||||
|
date subscription sales appended nothing, so the cash was off the Z-report entirely — a real
|
||||||
|
[[threat-model]] hole; see [[subscription]] "Collecting the fee".)*
|
||||||
2. Sum by **tender**: `cashTotal`, and `cardTotal` from the POS/terminal **if a POS is configured**
|
2. Sum by **tender**: `cashTotal`, and `cardTotal` from the POS/terminal **if a POS is configured**
|
||||||
(the card line is omitted when there's no terminal).
|
(the card line is omitted when there's no terminal).
|
||||||
3. Append a signed **`shift_z_report`** event (type already in `packages/shared`): `{ operator,
|
3. Append a signed **`shift_z_report`** event (type already in `packages/shared`): `{ operator,
|
||||||
@@ -118,20 +121,37 @@ The Z-report's payment totals answer "how much did this shift *take*?" — but a
|
|||||||
**physical cash drawer** that carries across shifts. The drawer is tracked as a running balance over
|
**physical cash drawer** that carries across shifts. The drawer is tracked as a running balance over
|
||||||
the signed chain, so each shift knows what it **inherited** and what it should **hand over**.
|
the signed chain, so each shift knows what it **inherited** and what it should **hand over**.
|
||||||
|
|
||||||
**The events:**
|
**The events (drawer vouchers — re-modelled 2026-06-20):** the original design used one signed
|
||||||
- A new signed **`cash_movement`** event: the admin loads or removes drawer cash, `{ amountMinor
|
**`cash_movement`** event with a *signed* `amountMinor` (+ load / − removal). That conflated two
|
||||||
(signed: + load, − removal), reason, operator }`. **Admin-only** (an operator takes payments but
|
distinct financial documents into a `±`. In accounting a pay-in and a pay-out are different vouchers
|
||||||
cannot move the float in/out). The opening-day load (+5000 ALL) and a mid-shift withdrawal (−5000)
|
(in Albanian: **Mandat Arkëtimi** = receipt, **Mandat Pagese** = disbursement), so the direction now
|
||||||
are both `cash_movement` events.
|
lives in the **event type**, not the sign of an amount:
|
||||||
|
|
||||||
|
- **`cash_in`** (*Mandat Arkëtimi* — a **receipt / pay-IN**): cash enters the drawer. `amountMinor` is
|
||||||
|
a **positive magnitude**. Voucher no. `AR-NNNN`.
|
||||||
|
- **`cash_out`** (*Mandat Pagese* — a **disbursement / pay-OUT**): cash leaves the drawer.
|
||||||
|
`amountMinor` positive; the fold subtracts it. Voucher no. `PA-NNNN`.
|
||||||
|
- Payload: `{ amountMinor (positive), reason, currency, operator (who raised), authorizedBy (admin who
|
||||||
|
signed off), voucherNo }`. Each prints a **slip** (Albanian, like every operator-facing paper).
|
||||||
|
- **Authorization changed: operator-RAISED, admin-AUTHORIZED.** Previously admin-only. Now any holder
|
||||||
|
of `shift:create` (operator-grade) may *raise* a voucher, but the route only commits it if
|
||||||
|
`authorizedBy` is a real **admin** (`shift:cash`) who **re-enters their password**. This keeps the
|
||||||
|
float control — an operator can't move the float alone — while letting them do the paperwork at the
|
||||||
|
booth. (`POST /api/cash-voucher`, guarded `shift:create` + server-side authorizer password+grade check.)
|
||||||
|
- **Legacy `cash_movement` stays valid.** The type is retained; historical signed events on the live
|
||||||
|
chain still verify and still fold into the drawer (signed-± as before). Only *new* movements use the
|
||||||
|
voucher pair. The append-only chain is never rewritten.
|
||||||
- The existing `payment` events already add cash to the drawer (cash tender only; card never touches
|
- The existing `payment` events already add cash to the drawer (cash tender only; card never touches
|
||||||
the drawer).
|
the drawer).
|
||||||
|
|
||||||
**The math — drawer is a fold over the chain BY TIME, not by operator** (a `cash_movement` is the
|
**The math — drawer is a fold over the chain BY TIME, not by operator** (a drawer voucher is the
|
||||||
admin's, not the shift operator's, so it can't key off `identity`):
|
admin's authorization, not the shift operator's takings, so it can't key off `identity`):
|
||||||
|
|
||||||
```
|
```
|
||||||
expectedDrawer(at) = Σ cash payments (tender=cash) up to `at`
|
expectedDrawer(at) = Σ cash payments (tender=cash) up to `at`
|
||||||
+ Σ cash_movement amounts up to `at`
|
+ Σ cash_in amounts (positive) up to `at`
|
||||||
|
− Σ cash_out amounts (positive) up to `at`
|
||||||
|
+ Σ cash_movement amounts (legacy, signed) up to `at`
|
||||||
```
|
```
|
||||||
|
|
||||||
A shift's **opening float = expectedDrawer(shiftStart)** — i.e. everything that happened to the drawer
|
A shift's **opening float = expectedDrawer(shiftStart)** — i.e. everything that happened to the drawer
|
||||||
@@ -147,11 +167,11 @@ That `expectedDrawer` is exactly the **next** shift's opening float — the carr
|
|||||||
|
|
||||||
| Step | Event | Drawer |
|
| Step | Event | Drawer |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| Opening day | admin `cash_movement` +5000 | 5000 |
|
| Opening day | `cash_in` (Mandat Arkëtimi) +5000 | 5000 |
|
||||||
| Shift 1 takes 6500 cash | payments | 11500 |
|
| Shift 1 takes 6500 cash | payments | 11500 |
|
||||||
| Shift 1 closes | Z: open 5000, took 6500, expected **11500** | 11500 |
|
| Shift 1 closes | Z: open 5000, took 6500, expected **11500** | 11500 |
|
||||||
| Shift 2 opens | opening float = **11500** (inherited) | 11500 |
|
| Shift 2 opens | opening float = **11500** (inherited) | 11500 |
|
||||||
| admin `cash_movement` −5000 | withdrawal | 6500 |
|
| `cash_out` (Mandat Pagese) 5000 | withdrawal | 6500 |
|
||||||
| Shift 2 takes 4500 cash | payments | 11000 |
|
| Shift 2 takes 4500 cash | payments | 11000 |
|
||||||
| Shift 2 closes | Z: open 11500, took 4500, removed 5000, expected **11000** | 11000 |
|
| Shift 2 closes | Z: open 11500, took 4500, removed 5000, expected **11000** | 11000 |
|
||||||
| Shift 3 opens | opening float = **11000** | … |
|
| Shift 3 opens | opening float = **11000** | … |
|
||||||
@@ -176,13 +196,23 @@ reconciles the signed Z-report against the actual drawer and the bank/POS batch
|
|||||||
|
|
||||||
## Open
|
## Open
|
||||||
|
|
||||||
- **Drawer carry-over (decided 2026-06-18, building):** opening float auto-inherits the prior shift's
|
- **Drawer carry-over (decided 2026-06-18, built; vouchers re-modelled 2026-06-20):** opening float
|
||||||
expected drawer; admin-only `cash_movement` events; Z-report reports the full drawer picture. See
|
auto-inherits the prior shift's expected drawer; drawer movements are now the **`cash_in` /
|
||||||
the Drawer balance section above.
|
`cash_out` voucher pair** (Mandat Arkëtimi / Mandat Pagese — direction is the type, operator-raised
|
||||||
|
& admin-authorized), superseding the signed-± `cash_movement` (kept for history). Z-report reports
|
||||||
|
the full drawer picture. See the Drawer balance section above.
|
||||||
- **Shift ↔ session boundary:** a vehicle may enter under one shift and pay under another — the
|
- **Shift ↔ session boundary:** a vehicle may enter under one shift and pay under another — the
|
||||||
Z-report sums by **payment time** (when cash/card was taken), which is the operator who handled
|
Z-report sums by **payment time** (when cash/card was taken), which is the operator who handled
|
||||||
the money. Confirm that's the intended accountability (vs. by entry).
|
the money. Confirm that's the intended accountability (vs. by entry).
|
||||||
- **Mid-shift report / X-report** (read-only "so far" total without closing) — add if booths want
|
- **Mid-shift report / X-report — BUILT 2026-06-20.** On demand during the shift, the operator sees
|
||||||
it; the sum is the same projection.
|
the **opening float inherited**, **cash/card collected so far**, the **pay-ins/pay-outs**, and the
|
||||||
|
**current expected drawer balance** — without closing. `GET /api/shift/report` (`shift:read`, 204
|
||||||
|
when no shift is open) returns the SAME drawer projection the Z-report computes, factored into a
|
||||||
|
shared `ShiftService.#summariseWindow(open, asOf)` so X (asOf = now, read-only) and Z (asOf =
|
||||||
|
endedAt, signed) can never drift. **It appends NOTHING** — it's not an accountability mark (the
|
||||||
|
Z-report at close is the signed record). UI: a "Takings so far" button on the shift control opens a
|
||||||
|
cyan X-report panel; the header still shows the live drawer *total* for the at-a-glance number.
|
||||||
|
Verified against a copy of the live DB: matches `drawerBalance()`, drawer identity holds, 0 events
|
||||||
|
appended, chain still verifies.
|
||||||
- **Multiple lanes/booths** — whether a shift is per-operator, per-booth, or per-site
|
- **Multiple lanes/booths** — whether a shift is per-operator, per-booth, or per-site
|
||||||
(relates to [[open-questions]] #1 lane topology).
|
(relates to [[open-questions]] #1 lane topology).
|
||||||
|
|||||||
+96
-1
@@ -14,6 +14,18 @@ not code** — the park owner builds and constantly edits the rate card at runti
|
|||||||
reprice. The computation is **pure and offline** ([[offline-first]]: no network, no clock authority
|
reprice. The computation is **pure and offline** ([[offline-first]]: no network, no clock authority
|
||||||
beyond the host).
|
beyond the host).
|
||||||
|
|
||||||
|
> **Shared pattern (2026-06-20):** [[subscription]] pricing now uses this same model — a
|
||||||
|
> **versioned, effective-dated, admin-composed catalog** (`subscription_plans`), resolved by "latest
|
||||||
|
> active version with `effectiveFrom ≤ sale`", with the sale persisting its `planVersionId` for
|
||||||
|
> reproducible repricing. The operator selects a plan + span; the price is looked up, never typed.
|
||||||
|
> Tariffs price *transient* stays by duration; plans price *subscription* spans by ceil(periods).
|
||||||
|
|
||||||
|
> **The tariff also prices SUBSCRIBERS now (2026-06-20).** A [[subscription]] plan with time windows
|
||||||
|
> charges the **transient tariff** for any out-of-window parking (early entry / late exit) — the
|
||||||
|
> subscriber temporarily *becomes* a transient for those minutes. `computeFee` is reused unchanged;
|
||||||
|
> the gap is a normal `[start, end]` priced against the active version (recorded `tariffVersionId` for
|
||||||
|
> reproducibility). See [[subscription]] "the tariff bridge".
|
||||||
|
|
||||||
> Decisions (2026-06-15): (1) tariffs are **effective-dated, immutable versions** — editing
|
> Decisions (2026-06-15): (1) tariffs are **effective-dated, immutable versions** — editing
|
||||||
> publishes a new version, never mutates an old one; (2) **one active tariff per site** (versioned
|
> publishes a new version, never mutates an old one; (2) **one active tariff per site** (versioned
|
||||||
> over time), modelled with an id/scope so multiple rate cards can be added later without migration;
|
> over time), modelled with an id/scope so multiple rate cards can be added later without migration;
|
||||||
@@ -63,6 +75,62 @@ code. All amounts are **integer minor units** in the tariff's currency.
|
|||||||
> the owner must compose + publish one before the lot can charge (until then: free, or gated —
|
> the owner must compose + publish one before the lot can charge (until then: free, or gated —
|
||||||
> operator policy, see Open).
|
> operator policy, see Open).
|
||||||
|
|
||||||
|
### Three pricing modes (per card / per V1 structure)
|
||||||
|
|
||||||
|
A card's body is **one of three mutually-exclusive shapes** — `flatMinor`, `blocks`, or `steps`:
|
||||||
|
|
||||||
|
1. **Hourly ladder (`blocks`)** — the model above: a **marginal per-increment** rate that the engine
|
||||||
|
*sums* across increments. "Each next **increment** costs X." Daily-cap and multi-day reset apply.
|
||||||
|
2. **Flat (`flatMinor`)** — one rate per increment (a one-block ladder).
|
||||||
|
|
||||||
|
> **⚠ `priceMinorPerIncrement` is PER BILLING INCREMENT, not per hour.** The effective hourly rate is
|
||||||
|
> `price × (60 / incrementMin)`. So with `incrementMin: 30`, a block priced `100` charges **100 every
|
||||||
|
> half-hour = 200/hour** → a 3h stay costs `100 × 6 = 600`, not 300. The example below uses
|
||||||
|
> `incrementMin: 60`, where per-increment happens to equal per-hour — which hides the distinction.
|
||||||
|
> This has caused repeated "the Lab is wrong" confusion (2026-06-20); the engine was correct each
|
||||||
|
> time, the *rate was per 30-min increment*. To bill 100/hour at a 30-min increment, set the price to
|
||||||
|
> `50`; or set `incrementMin: 60`. The composer column is labelled "Price / increment" and the
|
||||||
|
> billing increment is a separate top-level field — see Open (a per-hour preview is a candidate UX
|
||||||
|
> fix).
|
||||||
|
3. **Stepped / "up-to" (`steps`)** — *added 2026-06-20.* A **total-by-duration** table the owner
|
||||||
|
enters verbatim — the opposite of marginal: each row is the **cumulative TOTAL** for a stay within
|
||||||
|
that tier. Needed because owners think in totals, and many real cards (flat-day, airport) are
|
||||||
|
stated this way and **cannot** be expressed as a marginal ladder.
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
"steps": [ // each row: total price for a stay UP TO uptoMin (inclusive)
|
||||||
|
{ "uptoMin": 60, "totalMinor": 200 }, // 0–1h → 200
|
||||||
|
{ "uptoMin": 180, "totalMinor": 500 }, // 0–3h → 500
|
||||||
|
{ "uptoMin": 360, "totalMinor": 800 }, // 0–6h → 800
|
||||||
|
{ "uptoMin": 540, "totalMinor": 900 }, // 0–9h → 900
|
||||||
|
{ "uptoMin": 720, "totalMinor": 1000 } // 0–12h → 1000
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Stepped semantics** (decided with the user, 2026-06-20):
|
||||||
|
- The **smallest tier whose `uptoMin ≥ duration`** wins; the boundary is **inclusive** (`≤`) — a stay
|
||||||
|
of exactly 3h00m costs the 3h tier (500), not the next.
|
||||||
|
- Beyond the **largest threshold**, that tier's total is the **per-day price** (a daily-cap repeat):
|
||||||
|
a 13h stay within one rolling day = 1000 (the top total is the day's ceiling), and a 25h stay =
|
||||||
|
1000 (day 1) + the stepped ladder for the remaining 1h on day 2 = 1200.
|
||||||
|
- A `steps` table **replaces** the `blocks` ladder and **forbids `dailyCapMinor`** (the top tier IS
|
||||||
|
the per-day cap). In V2 it is allowed **only on the `defaultCard`** — a whole-stay total can't be
|
||||||
|
sliced per-increment by a windowed card, so windowed/stepped don't compose.
|
||||||
|
- **A stepped base + time/seasonal tiers is REJECTED** (`validateTariffV2`, 2026-06-20). The engine
|
||||||
|
short-circuits to `steppedFee` on a stepped default card and never consults windowed cards, so any
|
||||||
|
tiers would **silently never fire**. Rather than publish dead tiers, validation refuses the combo
|
||||||
|
("time/seasonal tiers do not apply to an up-to-duration (stepped) base rate — remove the tiers, or
|
||||||
|
switch the base rate to an hourly ladder or flat price"); the composer also shows an inline red
|
||||||
|
warning the moment both are present. (Discovered live: an active version had a stepped base AND
|
||||||
|
weekday-night + weekend tiers; the tiers priced nothing — every 3h stay was the stepped 600
|
||||||
|
regardless of time. The `problems[]` array now surfaces through `ApiError` to the publish message.)
|
||||||
|
- Validation: ≥1 row, strictly-ascending positive `uptoMin`, non-negative integer totals (totals
|
||||||
|
need not be monotonic — an owner *may* price a longer stay cheaper).
|
||||||
|
|
||||||
|
The owner authors this in the composer ("By duration (up-to)" mode) as an *up-to N hours / total*
|
||||||
|
table; the [[#tariff-lab-simulator-as-built-2026-06-20|Tariff Lab]] previews the curve. Verified
|
||||||
|
end-to-end: the matrix above publishes and prices exactly (30m→200, 3h→500, 6h→800, 12h→1000, 2d→2000).
|
||||||
|
|
||||||
**Lost ticket** is not just the flat `lostTicketMinor`: the admin may **override with an arbitrary
|
**Lost ticket** is not just the flat `lostTicketMinor`: the admin may **override with an arbitrary
|
||||||
amount** at the moment (operator judgement — establish entry time from [[opencv-anpr-service|plate]]
|
amount** at the moment (operator judgement — establish entry time from [[opencv-anpr-service|plate]]
|
||||||
capture/CCTV and charge real duration, or apply a set penalty). The configured flat fee is the
|
capture/CCTV and charge real duration, or apply a set penalty). The configured flat fee is the
|
||||||
@@ -101,7 +169,12 @@ because the chain + reconciliation depend on the result being reproducible.
|
|||||||
cap" model made complete — the same engine, no new axis; the only gap was the unstated tail.
|
cap" model made complete — the same engine, no new axis; the only gap was the unstated tail.
|
||||||
|
|
||||||
**As-built:** `computeFee(enteredAt, asOf, structure)` in `packages/shared` (pure). Unit-tested
|
**As-built:** `computeFee(enteredAt, asOf, structure)` in `packages/shared` (pure). Unit-tested
|
||||||
across grace, block steps, daily cap, and multi-day reset.
|
across grace, block steps, daily cap, and multi-day reset. A higher-level **`priceSession(enteredAt,
|
||||||
|
asOf, structure, payments[], category?)`** (also pure, shared) wraps `computeFee` with the
|
||||||
|
grace/overstay logic — unpaid → entry→now; paid+within-grace → settled (0); paid+grace-expired →
|
||||||
|
**overstay**, a fresh period from grace-expiry→now (see [[booth-exit-flow]]). The booth's
|
||||||
|
`PayStation.quote()` and the [[#tariff-lab-simulator-as-built-2026-06-20|Tariff Lab]] both call it, so
|
||||||
|
live pricing and the simulator can never diverge.
|
||||||
|
|
||||||
### Composer (as-built 2026-06-15)
|
### Composer (as-built 2026-06-15)
|
||||||
|
|
||||||
@@ -124,6 +197,25 @@ The admin authors the rate card at runtime — no hand-seeding:
|
|||||||
- Ships **blank** — until a version is published, `GET /api/tariff` returns `active: null` and the
|
- Ships **blank** — until a version is published, `GET /api/tariff` returns `active: null` and the
|
||||||
pay station returns `409 no active tariff`. Verified end to end (publish → pay station prices).
|
pay station returns `409 no active tariff`. Verified end to end (publish → pay station prices).
|
||||||
|
|
||||||
|
### Tariff Lab (simulator, as-built 2026-06-20)
|
||||||
|
|
||||||
|
The tariff engine is a **pure function of time**, but you could previously only *exercise* it by
|
||||||
|
waiting (the only clock the booth reads is the real wall-clock). The **Tariff Lab** closes that gap:
|
||||||
|
price a session at **any** instant against **any** tariff version in seconds.
|
||||||
|
|
||||||
|
- **API** (`apps/server/src/routes/tariffs.ts`, `tariff:read` — admins always have it; available
|
||||||
|
on-site too, useful to quote a customer dispute): `POST /api/tariff/simulate` prices a hypothetical
|
||||||
|
session — body `{enteredAt, asOf, payments[], category?, tariffVersionId? | structure?}` — and
|
||||||
|
returns the full `priceSession` outcome plus a **duration curve** (fee from entry at 30m…3d, so you
|
||||||
|
SEE where the daily cap flattens or a window shifts). `GET /api/tariff/simulate/session/:identity`
|
||||||
|
prefills from a **real ledger session** (entry + payments + the version frozen at entry). Both are
|
||||||
|
**read-only — no ledger writes.**
|
||||||
|
- **UI** (`apps/web/src/TariffLab.tsx`, Setup → "Tariff Lab"): pick a version (active or any
|
||||||
|
historical), set entry / "as of" times, an optional payment (with its grace), and a category; or
|
||||||
|
"Load" a real ticket to re-evaluate it at any moment. Shows amount due, billed period, overstay/
|
||||||
|
settled state, and the curve. Prices via the same `priceSession` the booth uses (verified: a real
|
||||||
|
overstay ticket reads identically in the lab and the booth). See [[booth-exit-flow]] (overstay).
|
||||||
|
|
||||||
## The pay-on-foot consequence
|
## The pay-on-foot consequence
|
||||||
|
|
||||||
Because payment is decoupled from exit ([[parking-session]] lifecycle), the tariff has **two
|
Because payment is decoupled from exit ([[parking-session]] lifecycle), the tariff has **two
|
||||||
@@ -238,5 +330,8 @@ Grounded in [[parksql2017-legacy-schema|the legacy schema]] + external research:
|
|||||||
- The **actual rate cards** are owner-authored at runtime — nothing to confirm at build time; the
|
- The **actual rate cards** are owner-authored at runtime — nothing to confirm at build time; the
|
||||||
composer UI + validation (sane blocks, non-negative, ordered `uptoMin`) is the work.
|
composer UI + validation (sane blocks, non-negative, ordered `uptoMin`) is the work.
|
||||||
- **Blank-tariff policy** — free vs. gated until a rate card is published (operator policy).
|
- **Blank-tariff policy** — free vs. gated until a rate card is published (operator policy).
|
||||||
|
- **Per-hour preview in the composer** (UX, candidate) — "Price / increment" is repeatedly misread as
|
||||||
|
per-hour (see the ⚠ note above). Showing the computed effective per-hour rate beside each ladder
|
||||||
|
price (`price × 60/incrementMin`), or a small live fee preview, would prevent it. No engine change.
|
||||||
- **In-progress version-boundary** — entry-version (decided) vs. pro-rate (revisit if needed).
|
- **In-progress version-boundary** — entry-version (decided) vs. pro-rate (revisit if needed).
|
||||||
- **FX** — exchange-rate system, offline rate source, base currency ([[open-questions]]).
|
- **FX** — exchange-rate system, offline rate source, base currency ([[open-questions]]).
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
type: concept
|
type: concept
|
||||||
tags: [parking, security, foundational]
|
tags: [parking, security, foundational]
|
||||||
sources: [parking-system-architecture]
|
sources: [parking-system-architecture]
|
||||||
updated: 2026-06-14
|
updated: 2026-06-20
|
||||||
---
|
---
|
||||||
|
|
||||||
# Threat Model
|
# Threat Model
|
||||||
@@ -36,6 +36,17 @@ The same reframing recurs at the device layer: the [[uhppote-controller]]'s real
|
|||||||
unauthenticated commands ([[uhppote-udp-protocol]]), addressed by detection
|
unauthenticated commands ([[uhppote-udp-protocol]]), addressed by detection
|
||||||
([[event-log-ingestion]]) or prevention ([[esp32-custom-controller]]).
|
([[event-log-ingestion]]) or prevention ([[esp32-custom-controller]]).
|
||||||
|
|
||||||
|
> **Worked example — "store the price" ≠ "account for the sale" (found + fixed 2026-06-20).** Every
|
||||||
|
> money-taking action must append a signed `payment` event, or it is invisible to
|
||||||
|
> [[reconciliation]]. A concrete miss: selling a [[subscription]] wrote only the mutable
|
||||||
|
> `subscriptions` master row (the agreed *price*) and **appended nothing to the ledger**, so the cash
|
||||||
|
> the operator collected showed up in the feed/drawer/Z-report **nowhere** — a clean off-book channel
|
||||||
|
> (three real sales, 27,000 ALL, untraceable). The fix is the textbook control: append a signed
|
||||||
|
> `payment` (`subscriptionSale: true`) at sale time so it folds into the [[shift]] like any taking.
|
||||||
|
> **The lesson generalises:** whenever a feature records *an amount* in a mutable table, ask "where is
|
||||||
|
> the signed event that says money changed hands?" — a price in master data is not an accountable
|
||||||
|
> transaction. See [[subscription]] "Collecting the fee".
|
||||||
|
|
||||||
> **Direction shift:** the system is heading toward **fully unmanned operation** — no operator, no
|
> **Direction shift:** the system is heading toward **fully unmanned operation** — no operator, no
|
||||||
> booth ([[autonomous-direction]]). That removes the booth-operator as the *primary* adversary, but
|
> booth ([[autonomous-direction]]). That removes the booth-operator as the *primary* adversary, but
|
||||||
> swaps in **unattended-machine threats** (tailgating, plate spoofing, physical tampering, forced
|
> swaps in **unattended-machine threats** (tailgating, plate spoofing, physical tampering, forced
|
||||||
|
|||||||
+157
-35
@@ -2,7 +2,8 @@
|
|||||||
type: entity
|
type: entity
|
||||||
tags: [parking, domain, business, subscriptions, identity, pricing]
|
tags: [parking, domain, business, subscriptions, identity, pricing]
|
||||||
sources: []
|
sources: []
|
||||||
updated: 2026-06-18
|
updated: 2026-06-20
|
||||||
|
aliases: [subscription-plan]
|
||||||
status: open
|
status: open
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -19,19 +20,48 @@ Transient is built first; subscriptions layer on top.
|
|||||||
> hash-chained history, so renaming it would break verification of past events. So: *code & data =
|
> hash-chained history, so renaming it would break verification of past events. So: *code & data =
|
||||||
> "subscription"; the on-chain field name stays `permitId`.* See the schema note in `schema.ts`.
|
> "subscription"; the on-chain field name stays `permitId`.* See the schema note in `schema.ts`.
|
||||||
|
|
||||||
## Pricing — recurring monthly plan (built 2026-06-18)
|
## Pricing — config-defined PLAN catalog (re-modelled 2026-06-20)
|
||||||
|
|
||||||
Each subscription records its **own price**, so an individual and a company fleet can differ:
|
A subscription is **a priced product like a [[tariff]]**, not a hand-typed number. The operator
|
||||||
|
**SELECTS an admin-defined plan over a date span**; the price is **looked up** (never typed). This
|
||||||
|
fixed two flaws in the original per-row model: (1) the operator keyed the price by hand — a
|
||||||
|
fat-finger (a dropped/extra zero) on a money field; (2) only `"monthly"` was expressible, so a
|
||||||
|
**hotel** buying parking for a guest staying **1–N days** couldn't be priced.
|
||||||
|
|
||||||
- `priceMinor` — the recurring price in **minor units** (integer; e.g. `1000000` = 10,000.00).
|
**The plan catalog** (`subscription_plans`, mirrors `tariff_versions` — immutable, effective-dated,
|
||||||
`null` = no price set (a comp / legacy subscription).
|
admin-only):
|
||||||
- `period` — the billing period. **`"monthly"` only** today (the enum is widened later if a site
|
|
||||||
ever needs weekly/annual).
|
|
||||||
- `currency` — ISO-4217 of `priceMinor` (e.g. `"ALL"`); required when a price is set.
|
|
||||||
|
|
||||||
A **site default monthly price** lives in `site_config.subscription_monthly_price_minor` — it
|
- `planId` — stable identity across versions (e.g. `"hotel-daily"`); a price change = a NEW row.
|
||||||
merely **pre-fills** the new-subscription form; each subscription still stores its own value and may
|
- `name`, `period` (**`"day" | "week" | "month"`**), `pricePerPeriodMinor`, `currency`.
|
||||||
override.
|
- `effectiveFrom` — the latest active version with `effectiveFrom ≤ sale instant` prices a sale (the
|
||||||
|
tariff-resolve rule). `active` — soft-retire (0) without deleting history.
|
||||||
|
|
||||||
|
**Pricing a span** (`priceSubscriptionSpan`, pure + unit-tested in `@parking/shared`):
|
||||||
|
|
||||||
|
```
|
||||||
|
periods = ceil( (validTo − validFrom) / one plan period ) // any STARTED period is full
|
||||||
|
amountMinor = periods × pricePerPeriodMinor
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ceil** matches hotel/parking practice — a guest checking out mid-day still owes that day (Mon
|
||||||
|
14:00 → Wed 10:00 on a daily plan = **2** days). The hotel case is just a `"day"` plan over a
|
||||||
|
check-in→check-out span. **`POST /api/subscriptions/quote`** returns this server-computed quote so the
|
||||||
|
sell form shows "3 × day · 2,400 ALL" live — **the operator can't override the amount**.
|
||||||
|
|
||||||
|
**Authority (admin-only):** composing the catalog needs the new **`subscription:plan`** permission
|
||||||
|
(admin-grade); **selling** stays `subscription:create` (operator-grade). The operator picks; only an
|
||||||
|
admin defines/edits prices. Editing a plan **publishes a new version** (new `effectiveFrom`), never
|
||||||
|
mutates an old one — past sales keep their recorded `planVersionId` and reprice identically.
|
||||||
|
|
||||||
|
**On the subscription row:** `priceMinor`/`currency`/`period` are now **derived from the plan** at
|
||||||
|
sale, plus `planId` + `planVersionId` (which version priced it — reproducible, like a payment's
|
||||||
|
`tariffVersionId`). An **update never re-sells** (price/plan frozen); a new price = a new sale.
|
||||||
|
|
||||||
|
> **Superseded — per-row typed price (built 2026-06-18).** Originally each subscription stored its own
|
||||||
|
> `priceMinor` + `period:"monthly"`, typed by the operator and pre-filled from
|
||||||
|
> `site_config.subscription_monthly_price_minor`. That column is **kept only to seed a "Monthly" plan**
|
||||||
|
> in migration `0010`; the sell path no longer reads it. The signed-`payment` sale fix (below) is
|
||||||
|
> unchanged — only the *amount source* moved from "typed × months" to "plan quote".
|
||||||
|
|
||||||
### Multi-month: pay N months → extend `validTo` (built 2026-06-18)
|
### Multi-month: pay N months → extend `validTo` (built 2026-06-18)
|
||||||
|
|
||||||
@@ -39,7 +69,8 @@ A customer paying for **more than one month** is handled by the **coverage windo
|
|||||||
records. The form takes a **`months`** count; with `validFrom` set, the server computes **`validTo =
|
records. The form takes a **`months`** count; with `validFrom` set, the server computes **`validTo =
|
||||||
validFrom + N months`** (whole-month add, with day-overflow clamp — e.g. Jan 31 + 3mo → Apr 30). One
|
validFrom + N months`** (whole-month add, with day-overflow clamp — e.g. Jan 31 + 3mo → Apr 30). One
|
||||||
subscription row, one window. The amount the operator should collect is **N × the monthly price**
|
subscription row, one window. The amount the operator should collect is **N × the monthly price**
|
||||||
(the form previews `end date · total`); collection into the ledger is still deferred (below).
|
(the form previews `end date · total`), and that **full N-month amount is now collected as one signed
|
||||||
|
`payment` at sale time** (see "Collecting the fee" below — built 2026-06-20).
|
||||||
|
|
||||||
- `months` is **input-only** — it's not stored; the stored truth is `validFrom`/`validTo`. Renewing
|
- `months` is **input-only** — it's not stored; the stored truth is `validFrom`/`validTo`. Renewing
|
||||||
for more months is just editing the window (set a new `months` or an explicit `validTo`).
|
for more months is just editing the window (set a new `months` or an explicit `validTo`).
|
||||||
@@ -47,32 +78,115 @@ subscription row, one window. The amount the operator should collect is **N × t
|
|||||||
`now` ∈ [validFrom, validTo]** — so a 3-month window simply stays valid for three months.
|
`now` ∈ [validFrom, validTo]** — so a 3-month window simply stays valid for three months.
|
||||||
- An explicit **`validTo` override** is still accepted (manual end date) when `months` isn't used.
|
- An explicit **`validTo` override** is still accepted (manual end date) when `months` isn't used.
|
||||||
|
|
||||||
### Collecting the fee is a SHIFT transaction (decided 2026-06-18, deferred build)
|
### v2 — quantity, plan timeframes (tariff bridge), reserved spots (built 2026-06-20)
|
||||||
|
|
||||||
|
Three enhancements driven by real scenarios (migration `0011`):
|
||||||
|
|
||||||
|
**Quantity (`subscriptions.quantity`, default 1).** One subscription can cover **N cars** — a family
|
||||||
|
where the husband pays once for two cars. The sale amount is `priceSubscriptionSpan(...) × quantity`;
|
||||||
|
`maxConcurrent` defaults to the quantity (so both cars can be inside). The payment payload carries
|
||||||
|
`quantity`. Credentials/plates for all N cars live on the one subscription.
|
||||||
|
|
||||||
|
**Plan timeframes → the TARIFF BRIDGE (`subscription_plans.timeframes`).** A plan may restrict WHEN a
|
||||||
|
subscriber may park (e.g. weekday allowed 20:00→08:00, weekend all-day). Instead of **refusing**
|
||||||
|
out-of-window scans, the system **charges the out-of-window minutes at the normal transient
|
||||||
|
[[tariff]]** — the subscriber becomes a transient customer for the time outside their window:
|
||||||
|
|
||||||
|
- `PlanTimeframes` = `{ days[], fromMin, toMin, graceMin?, tz }`. The allowed window
|
||||||
|
`[fromMin, toMin)` (minutes-of-local-midnight; `toMin ≤ fromMin` wraps past midnight for a night
|
||||||
|
window) applies ONLY on the selected **`days`** (0=Sun..6=Sat — the **same per-day-of-week picker as
|
||||||
|
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.
|
||||||
|
*(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`)
|
||||||
|
returns the `[start, end]` portion outside the window. **Early entry**: gap = arrival → next
|
||||||
|
window-open (a 09:00 arrival to a 20:00 window owes 09:00→20:00, capped by the tariff's daily cap).
|
||||||
|
**Late exit**: gap = window-close → departure. The gap is priced with `computeFee` (the same engine
|
||||||
|
transient stays use) at the active tariff version (`apps/server/src/subscription-window.ts`).
|
||||||
|
- **The owed amount is ONE computation over the whole stay** (`windowOwedBetween` →
|
||||||
|
`minutesOutsideWindow(timeframes, tz, entry, now)`): the minutes within `[entry, now]` that fall
|
||||||
|
outside the allowed window — covering **early entry AND late exit together**, bounded by the stay,
|
||||||
|
off-days free. Priced once as a transient duration (so increments + the daily cap apply). This
|
||||||
|
replaced an earlier buggy "entry-gap + exit-gap" sum whose exit gap reached back to a *previous*
|
||||||
|
day's close, charging a phantom ~12h to a car that had just entered early (the 4,100 ALL bug,
|
||||||
|
fixed 2026-06-20). Both the exit gate and the booth quote call this one function, so they agree.
|
||||||
|
- **Early entry is DEFERRED:** the barrier opens now; an advisory `windowOwedMinor` + priced gap are
|
||||||
|
signed onto the `vehicle_entry` for the feed badge, and a **best-effort advisory slip prints**
|
||||||
|
("PARKIM — JASHTË ORARIT": entered out-of-window, *fee computed at exit*, occurrence no.) so the
|
||||||
|
subscriber has paper proof. 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
|
||||||
|
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
|
||||||
|
any taking) and re-scans. The booth pay modal surfaces it as an "OUT-OF-WINDOW" charge
|
||||||
|
(`PayStation.lookup`/`pay`). So an early-entry-then-late-exit subscriber pays **both** portions in a
|
||||||
|
single amount, computed when they reach the booth.
|
||||||
|
> ⚠ **Exit gate vs. "never trap a vehicle."** This refusal is a **host-ONLINE business gate**,
|
||||||
|
> identical in kind to the existing transient `exit.refused.unpaid`/overstay gate — a working host
|
||||||
|
> *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.
|
||||||
|
|
||||||
|
**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
|
||||||
|
"full"; reservation only tightens the *transient* gate.
|
||||||
|
|
||||||
|
### Collecting the fee is a SHIFT transaction — BUILT 2026-06-20
|
||||||
|
|
||||||
Selling/renewing a subscription is a **financial transaction a common operator makes during their
|
Selling/renewing a subscription is a **financial transaction a common operator makes during their
|
||||||
[[shift]]** — the subscriber pays the monthly fee at the booth like any other customer. So it is
|
[[shift]]** — the subscriber pays the monthly fee at the booth like any other customer. So it is
|
||||||
**not** an admin-only master-data edit; the money must land in **that operator's shift**: their
|
**not** an admin-only master-data edit; the money lands in **that operator's shift**: their drawer
|
||||||
drawer (if cash) and their [[shift|Z-report]].
|
(if cash) and their [[shift|Z-report]].
|
||||||
|
|
||||||
The clean way (the model already supports it): collection writes a signed **`payment`** ledger event
|
> ⚠ **Why this got built — an off-book accountability hole ([[threat-model]] core path).** Until
|
||||||
— same shape the transient pay-station uses (`{ amountMinor, currency, tender }`) — at collection
|
> 2026-06-20, creating a priced subscription wrote **only** the mutable `subscriptions` master row
|
||||||
time, tagged with `{ subscriptionId }` so it's identifiable as subscription revenue.
|
> and **appended nothing to the signed ledger**. The operator collected real cash (e.g. 10,000 ALL),
|
||||||
|
> and it appeared in the live feed: **no**; the drawer: **no**; the Z-report: **no**; left any signed
|
||||||
|
> trace: **no**. The `subscriptions` row records the *plan price*, not that *money changed hands* —
|
||||||
|
> and it's a table the operator could even edit. So a booth operator could sell subscriptions and
|
||||||
|
> pocket the money untraceably — exactly the **operator-as-adversary** path the
|
||||||
|
> [[append-only-event-chain|signed append-only ledger]] exists to close. Found live: three priced
|
||||||
|
> subscriptions on the appliance (27,000 ALL sold) had **zero** payment events. This is the canonical
|
||||||
|
> reason "store the price" is not the same as "account for the sale."
|
||||||
|
|
||||||
- It folds into the shift automatically: the Z-report sums `payment` events in `[start, end]` **by
|
**As built** (chosen of the two options below): a subscription **sold with a price** appends a signed
|
||||||
payment time**, and the drawer fold adds **cash** tenders (card settles to the bank) — no new
|
**`payment`** ledger event — the same shape the transient pay-station uses — at create time:
|
||||||
summing logic needed. The fee lands in **whichever shift was open when it was taken**, attributed
|
|
||||||
to that operator. (See [[shift]] "drawer balance".)
|
- **Amount = the full sale, from the PLAN quote.** `ceil(periods) × pricePerPeriodMinor` for the
|
||||||
- **Admin** still edits the subscription master data (price, window, credentials); the **operator**
|
selected plan over the span (e.g. 3 nights × 800 = 2,400 ALL) — looked up, never typed (re-modelled
|
||||||
takes the money. Two different acts.
|
2026-06-20; was `priceMinor × months`). The payload also carries `planId`/`planVersionId`/`periods`
|
||||||
|
for audit + reproducible repricing. The ledger matches what's actually in the drawer.
|
||||||
|
- **Tender is operator-chosen** (cash/card) on the create form, defaulting to cash. Cash enters the
|
||||||
|
drawer; card settles to the bank — identical to the parking pay path.
|
||||||
|
- **Folds into the shift automatically** — no new summing logic. The Z-report sums `payment` events in
|
||||||
|
`[start, end]` by payment time; the drawer fold adds **cash** tenders. The fee lands in **whichever
|
||||||
|
shift was open when taken**, attributed to that operator (recorded `operator` on the payload).
|
||||||
|
- **Identifiable as subscription revenue.** The payload carries **`subscriptionSale: true`** + the
|
||||||
|
subscription id (as both `identity` and `permitId`, so [[booth-console|the feed]] resolves the
|
||||||
|
holder name and badges it **"subscription sale"**) + `months` for audit.
|
||||||
|
- **Free/comp = no event.** A subscription with no price appends nothing (nothing was collected).
|
||||||
- A subscription's own [[parking-session|entry/exit]] events stay **free** (no per-stay `payment`) —
|
- A subscription's own [[parking-session|entry/exit]] events stay **free** (no per-stay `payment`) —
|
||||||
only the *plan fee* is a payment, decoupled from any individual stay.
|
only the *plan fee* is a payment, decoupled from any individual stay.
|
||||||
|
- **Admin** still edits subscription master data; the act of **selling** writes the money event.
|
||||||
|
|
||||||
> **Deferred build.** Today we only *record* the agreed price + coverage window
|
**Decision on event type (resolved):** modelled as a **plain `payment` + `subscriptionSale` flag**,
|
||||||
> (`validFrom`/`validTo`); no collection event is written yet, so subscription revenue does not flow
|
not a distinct `subscription_payment` type. Reusing `payment` means the existing shift/drawer/Z-report
|
||||||
> into the drawer/Z-report or [[reconciliation]]. Open detail when built: whether to model it as a
|
folds count it with **zero** new summing surface; the flag is enough for the feed/reports to label it.
|
||||||
> plain `payment` (simplest, folds today) or a distinct `subscription_payment` type (clearer in
|
|
||||||
> reports, but the shift/drawer fold would need to count it too). Leaning **plain `payment` +
|
**Not hard-gated on an open shift** (deliberate — differs from the booth pay path). A subscription can
|
||||||
> `subscriptionId` tag**. (Decision 2026-06-18: store price now, collect-in-shift later.)
|
be sold outside the booth money flow, so `recordSale` does **not** refuse when no shift is open; it
|
||||||
|
still appends the signed payment (operator recorded) and the UI **warns** "no shift was open — open
|
||||||
|
one so the takings land in a Z-report." The payment folds into any shift whose window later covers its
|
||||||
|
timestamp. *(If a site wants subscription sales to be impossible without an open shift, add the
|
||||||
|
`requireOpenShift` gate the `/api/pay` path uses — flagged, not done.)*
|
||||||
|
|
||||||
|
> **Historical gap is not back-fillable.** The append-only ledger means the three pre-2026-06-20
|
||||||
|
> off-book sales can't be retroactively turned into dated payment events (forging back-dated signed
|
||||||
|
> events is exactly what the chain forbids). Reconcile them via an operator `cash_movement` (drawer
|
||||||
|
> adjustment with a reason) or a note on the next Z-report — not by inserting fake history.
|
||||||
|
|
||||||
|
Verified 2026-06-20 against a copy of the live DB with the real signing modules: the sale appends a
|
||||||
|
signed `payment` (30,000 ALL, 3-month, `subscriptionSale`), the **hash-chain still verifies**, and a
|
||||||
|
shift window covering it picks the amount up in cash takings.
|
||||||
|
|
||||||
## Credentials (how a subscription is presented) — confirmed 2026-06-15
|
## Credentials (how a subscription is presented) — confirmed 2026-06-15
|
||||||
|
|
||||||
@@ -210,7 +324,9 @@ Tables (mutable master data; every *use* still produces a signed `vehicle_entry`
|
|||||||
| Table / field | Notes |
|
| Table / field | Notes |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `subscriptions.id`, `holderName`, `contact` | the subscriber |
|
| `subscriptions.id`, `holderName`, `contact` | the subscriber |
|
||||||
| `subscriptions.priceMinor` / `period` / `currency` | recurring plan (monthly); null price = unset |
|
| `subscriptions.priceMinor` / `period` / `currency` | **derived from the plan** at sale; null = comp |
|
||||||
|
| `subscriptions.planId` / `planVersionId` | which plan + immutable version priced the sale (null = comp/legacy) |
|
||||||
|
| `subscription_plans[]` | admin-composed plan catalog: `{ planId, name, period(day/week/month), pricePerPeriodMinor, currency, effectiveFrom, active }` — immutable versions |
|
||||||
| `subscriptions.maxConcurrent` | car-count binding; **default 1**, raise for fleets, `null` = unbound |
|
| `subscriptions.maxConcurrent` | car-count binding; **default 1**, raise for fleets, `null` = unbound |
|
||||||
| `subscriptions.validFrom` / `validTo` / `status` | coverage window; active / suspended / revoked |
|
| `subscriptions.validFrom` / `validTo` / `status` | coverage window; active / suspended / revoked |
|
||||||
| `subscription_credentials[]` | `{ kind: 'rf' \| 'qr', value }` |
|
| `subscription_credentials[]` | `{ kind: 'rf' \| 'qr', value }` |
|
||||||
@@ -254,15 +370,21 @@ subscription** (card/QR credential, or a bound plate) — otherwise to the trans
|
|||||||
`null`; `priceMinor` non-negative int (currency required when set); at least one credential or one
|
`null`; `priceMinor` non-negative int (currency required when set); at least one credential or one
|
||||||
bound plate.
|
bound plate.
|
||||||
- **Pricing** stored on each subscription (`priceMinor`/`period`/`currency`), pre-filled from
|
- **Pricing** stored on each subscription (`priceMinor`/`period`/`currency`), pre-filled from
|
||||||
`site_config.subscription_monthly_price_minor`; **fee collection into the ledger is deferred**
|
`site_config.subscription_monthly_price_minor`. **Selling a priced subscription now appends a signed
|
||||||
(see Pricing above).
|
`payment`** (`subscriptionSale: true`, amount = `priceMinor × months`, operator-chosen tender) so it
|
||||||
|
flows into the drawer/Z-report — built 2026-06-20 (see "Collecting the fee" above). The create
|
||||||
|
response returns the recorded `{ sale }`; `subscriptionRoutes(...)` now takes the `EventLog` +
|
||||||
|
`ShiftService`.
|
||||||
|
|
||||||
## Open questions
|
## Open questions
|
||||||
|
|
||||||
1. **Reader hardware** — confirm the RF reader and QR/optical reader models (procurement; [[bom]],
|
1. **Reader hardware** — confirm the RF reader and QR/optical reader models (procurement; [[bom]],
|
||||||
[[open-questions]]).
|
[[open-questions]]).
|
||||||
2. **Lapsed-mid-stay & revoked** policy (fall back to transient [[tariff]] vs. refuse) — confirm.
|
2. **Lapsed-mid-stay & revoked** policy (fall back to transient [[tariff]] vs. refuse) — confirm.
|
||||||
3. **Subscription-fee collection** — a **shift transaction** (operator takes the monthly fee at the
|
3. ~~**Subscription-fee collection**~~ — **RESOLVED + BUILT 2026-06-20.** Selling a priced
|
||||||
booth → signed `payment` → folds into their drawer/Z-report). Deferred build; see Pricing.
|
subscription appends a signed `payment` (`subscriptionSale` flag, `priceMinor × months`,
|
||||||
|
operator-chosen tender) that folds into the drawer/Z-report. Remaining sub-question: should a sale
|
||||||
|
be **hard-blocked without an open shift** (it isn't today — it warns instead)? See "Collecting the
|
||||||
|
fee".
|
||||||
4. **Time-of-day access windows** (overnight subscribers) — design + build; boundary-case policy
|
4. **Time-of-day access windows** (overnight subscribers) — design + build; boundary-case policy
|
||||||
above (see the design note).
|
above (see the design note).
|
||||||
|
|||||||
+225
@@ -948,3 +948,228 @@ Reworked the ANPR TRIGGER per the real design goal: when a transient pushes the
|
|||||||
## [2026-06-19] feat | Surface recognized plate in the booth UI (SnapshotStrip)
|
## [2026-06-19] feat | Surface recognized plate in the booth UI (SnapshotStrip)
|
||||||
|
|
||||||
Made the ANPR plate VIEWABLE (it was saved but had no UI). Extended GET /api/snapshots/by-identity/:identity to also query device_events kind:"read" for that identity and return plates[] (plate, confidence, region, direction, snapshotId, at) alongside the existing snapshots + failures. The SnapshotStrip now renders each recognized plate as a cyan "Plate: AA558EE 100%" chip above the images (deduped by plate+direction; title shows region + time) — so it appears in BOTH the booth event-detail modal and the pay modal, beside the evidence photo, no separate screen. session:read gated (same as snapshots). i18n pay.plate sq+en. VERIFIED: by-identity returns plates[] for a seeded read (status 200, {plate:AA558EE, confidence:0.999, region:Albania, direction:entry, snapshotId}). Build+lint green. Updated [[opencv-anpr-service]].
|
Made the ANPR plate VIEWABLE (it was saved but had no UI). Extended GET /api/snapshots/by-identity/:identity to also query device_events kind:"read" for that identity and return plates[] (plate, confidence, region, direction, snapshotId, at) alongside the existing snapshots + failures. The SnapshotStrip now renders each recognized plate as a cyan "Plate: AA558EE 100%" chip above the images (deduped by plate+direction; title shows region + time) — so it appears in BOTH the booth event-detail modal and the pay modal, beside the evidence photo, no separate screen. session:read gated (same as snapshots). i18n pay.plate sq+en. VERIFIED: by-identity returns plates[] for a seeded read (status 200, {plate:AA558EE, confidence:0.999, region:Albania, direction:entry, snapshotId}). Build+lint green. Updated [[opencv-anpr-service]].
|
||||||
|
|
||||||
|
## [2026-06-20] feat | Flag stuck sessions + booth filters (session list & live feed)
|
||||||
|
|
||||||
|
Replaced the silent paid age-out in PayStation.activeSessions() with a derived `stuck` flag: an
|
||||||
|
open + paid + past-grace session with no signed vehicle_exit is no longer dropped — it stays listed
|
||||||
|
with a red "stuck" badge so the operator can reconcile (top-up exit / void). Root cause surfaced via
|
||||||
|
the live ledger: 4 such orphans (5717802544704, 1245791632490, 7985713986045, 9340902468934) each
|
||||||
|
entry=1/exit=0/pay=1, grace=5min lapsed; they linger in the occupancy fold (so occupancy diverges
|
||||||
|
from the active-list count) and a re-scan re-quotes the tariff from entry (paid customer charged
|
||||||
|
again). Signed log untouched; subscriptions never stuck (no paidAt). This removed the earlier
|
||||||
|
unbounded "presumed-left (N)" counter (occupancy − sessions), which had been growing.
|
||||||
|
|
||||||
|
Added a shared client-side FilterBar (ui/FilterBar.tsx: search + SegGroup toggles, matched/total
|
||||||
|
count). Active Sessions: search (ticket/holder) + status (unpaid/paid/exiting/stuck) + transient-vs-
|
||||||
|
subscriber. Live feed: search (identity/subscriber/plate) + event (entry/exit/pay/void/anomaly) +
|
||||||
|
direction (entry/exit) + source (booth=manual vs reader=device). No new API. Exit grace re-scan
|
||||||
|
logic UNCHANGED (still refuse + send to booth — user choice). Build+lint green across the monorepo.
|
||||||
|
Updated [[booth-exit-flow]].
|
||||||
|
|
||||||
|
## [2026-06-20] fix | No free exit on overstay (stuck session) + top-up pricing
|
||||||
|
|
||||||
|
SECURITY FIX correcting same-day stuck-flag work. A stuck session (paid + walk-back grace expired +
|
||||||
|
no signed exit) is AMBIGUOUS — the car may have left OR be overstaying inside. The first cut kept the
|
||||||
|
"Open barrier" button on these rows (gated on paidAt != null), which would let an operator wave out a
|
||||||
|
2-day overstay for free — the operator-as-adversary path. Fix: reopenBarrier now refuses a transient
|
||||||
|
whose payment grace has expired (allow only subscription OR paid-and-within-grace), enforced
|
||||||
|
server-side (exit-flow.ts), mirrored in the UI (no button on s.stuck → routes to pay/exit modal).
|
||||||
|
Verified against the live ledger: ticket 5717802544704 (entered 73.9h ago, paid 200000, grace 5min)
|
||||||
|
computes stuck=true and reopenBarrier REFUSES it.
|
||||||
|
|
||||||
|
Top-up pricing — "full stay minus paid" (user choice): quote() now returns grossMinor (whole stay
|
||||||
|
entry→now) and paidMinor (fold of prior signed payment amounts), with amountMinor = max(0, gross −
|
||||||
|
paid) — the delta only, never the full stay twice. lookup()/SessionLookup gained `stuck`; the pay
|
||||||
|
modal shows OVERSTAY status + "Top-up due" + a hint, and canPay now allows payment for a stuck
|
||||||
|
session. Taking the top-up restarts grace so the car exits normally. i18n pay.overstay/overstayHint/
|
||||||
|
topUp in sq+en. Partially resolves the grace-overstay Open question (the amount); whether to bill the
|
||||||
|
overstay delta-from-grace vs full-minus-paid left open. Build+lint green. Updated [[booth-exit-flow]].
|
||||||
|
|
||||||
|
## [2026-06-20] fix | Rename stuck→overstay + price overstay as a NEW period (fixes ALL 0)
|
||||||
|
|
||||||
|
Two user-driven corrections to the same-day overstay work. (1) NAMING: "stuck"/"i ngecur" wrongly
|
||||||
|
implied a system fault trapping the customer — but a paid-then-grace-expired car means a NEW parking
|
||||||
|
period began (re-parked) or the car is faulty/abandoned. Renamed the flag + badge + filter +
|
||||||
|
SessionLookup/ActiveSession field to `overstay` / "tej afatit" across server + web + i18n.
|
||||||
|
|
||||||
|
(2) PRICING BUG: "full stay minus paid" collapsed to 0 under a daily cap — ticket 1245791632490
|
||||||
|
(entered 06-17, paid 330000, cap 100000/day) had gross=330000, so delta=0 → "Diferenca për pagesë
|
||||||
|
ALL 0", a free multi-day exit. Fix (user choice): quote() now prices an overstay as a NEW period
|
||||||
|
anchored at grace-expiry (paidAt+graceExitMin)→now with its own daily-cap ladder, NOT entry→now. The
|
||||||
|
tariff version stays the one frozen at entry. Quote gained periodStart + overstay; removed
|
||||||
|
grossMinor/paidMinor. Verified: 1245791632490 now owes 20000 ALL (first half-hour of overstay), not 0.
|
||||||
|
pay modal: "New period due"/OVERSTAY; handlePayAndExit now charges when canPay (was: only if
|
||||||
|
!alreadyPaid — would have skipped the overstay charge). i18n pay.overstay/overstayHint/topUp +
|
||||||
|
booth.badgeOverstay*/fStatusOverstay rewritten in sq+en. Build+lint green. Updated [[booth-exit-flow]]
|
||||||
|
(overstay section + naming history + partial-resolution note on the grace-renewal open question).
|
||||||
|
|
||||||
|
## [2026-06-20] feat | Tariff Lab — pure session-pricing simulator (test rates in time)
|
||||||
|
|
||||||
|
The tariff engine is pure but could only be EXERCISED by waiting (booth reads real
|
||||||
|
wall-clock). Added a simulator. Extracted priceSession(enteredAt, asOf, structure,
|
||||||
|
payments[], category?) into @parking/shared — the grace/overstay wrapper over
|
||||||
|
computeFee (unpaid→entry→now; within-grace→settled 0; grace-expired→overstay new
|
||||||
|
period from grace-expiry). PayStation.quote() now calls it, so booth + lab can't
|
||||||
|
diverge. New routes (tariffs.ts, tariff:read, no ledger writes): POST
|
||||||
|
/api/tariff/simulate (price a hypothetical session vs active/any version/inline
|
||||||
|
structure; returns priceSession outcome + a 30m..3d duration curve) and GET
|
||||||
|
/api/tariff/simulate/session/:identity (prefill from a real ledger session). UI
|
||||||
|
apps/web/src/TariffLab.tsx at Setup→"Tariff Lab": version picker, entry/asOf, optional
|
||||||
|
payment+grace, category, load-a-ticket; shows amount due, billed period, overstay/
|
||||||
|
settled, curve. i18n lab.* + nav.tariffLab (sq+en). 4 new priceSession unit tests
|
||||||
|
incl. the ticket-1245791632490 overstay-not-zero regression (40 tests pass). Verified
|
||||||
|
live via the real UI: a 3h stay → ALL 3,000, curve shows the daily cap flattening at
|
||||||
|
6h and multi-day stepping; ticket-load returned a real session. Build+lint green.
|
||||||
|
Updated [[tariff]] + [[booth-exit-flow]].
|
||||||
|
|
||||||
|
## [2026-06-20] feat | Stepped ("up-to") tariff mode — total-by-duration pricing
|
||||||
|
|
||||||
|
The owner needed a total-by-duration matrix (0-1h=200, 0-3h=500, 0-6h=800, 0-9h=900,
|
||||||
|
0-12h=1000) the marginal hourly ladder CANNOT express (ladder sums per-increment
|
||||||
|
rates; this is cumulative totals at thresholds). Added STEPPED pricing as a third
|
||||||
|
mode alongside ladder + flat. New TariffStep{uptoMin,totalMinor} + steps[] on V1
|
||||||
|
structures and V2 cards (mutually exclusive w/ blocks/flatMinor). Engine steppedFee():
|
||||||
|
smallest tier with uptoMin>=duration wins (INCLUSIVE <=), top tier repeats as per-day
|
||||||
|
cap; wired into computeFeeV1 + computeFeeV2 (V2 defaultCard only — a whole-stay total
|
||||||
|
can't be sliced by a windowed card). Validation: ascending uptoMin, non-neg totals, no
|
||||||
|
dailyCap-with-steps, steps-only-on-default. priceSession/quote/booth/Lab all price it
|
||||||
|
via the shared core (no extra wiring). Composer UI: "By duration (up-to)" radio +
|
||||||
|
up-to/total table (base card only); i18n modeStepped/steppedHint/stepUpTo/stepTotal/
|
||||||
|
addStep (sq+en). 8 new unit tests incl. the exact owner matrix + multi-day + overstay
|
||||||
|
+ validation (53 pass). VERIFIED end-to-end via the real UI: authored the matrix in the
|
||||||
|
composer, published, Tariff Lab priced it exactly (3h->500, 6h->800, 12h->1000,
|
||||||
|
2d->2000). Build+lint green. Updated [[tariff]].
|
||||||
|
|
||||||
|
## [2026-06-20] fix | Reject stepped base + time tiers (silently-ignored tiers)
|
||||||
|
|
||||||
|
Found live: the active tariff had a STEPPED ("up-to") base card AND two windowed tiers
|
||||||
|
(weekday-night "Nata gjate javes", weekend "Fundjava"). computeFeeV2 short-circuits to
|
||||||
|
steppedFee on a stepped default card, so the tiers NEVER fired — a 3h stay was 600 ALL
|
||||||
|
at every hour/day. The composer happily let this contradictory combo be built + published.
|
||||||
|
Fix: validateTariffV2 now rejects a stepped defaultCard combined with windowedCards
|
||||||
|
(clear message: switch base to ladder/flat or remove tiers); the composer shows an inline
|
||||||
|
red warning when base.mode==="stepped" && tiers>0. Also: ApiError now carries the
|
||||||
|
server's problems[] so the publish error shows the SPECIFIC reason (was generic "invalid
|
||||||
|
tariff structure"). 2 new validation tests (55 pass). Verified live: warning renders +
|
||||||
|
publish blocked with the full message. Build+lint green. Updated [[tariff]].
|
||||||
|
|
||||||
|
## [2026-06-20] query | "Tariff Lab wrong: weekend 3h shows 600, expected 300"
|
||||||
|
|
||||||
|
NOT a bug — the engine was correct. The active tariff's billing increment is 30 min,
|
||||||
|
and `priceMinorPerIncrement` is PER INCREMENT, not per hour. The Fundjava (weekend) tier
|
||||||
|
DID apply (traced: every increment of the Saturday stay selected the Fundjava card), but
|
||||||
|
it bills 100 per 30-min increment = 200/hour, so 3h = 6 increments x 100 = 600. To get
|
||||||
|
300, set the price to 50/increment OR the increment to 60 min. This per-increment-vs-per-
|
||||||
|
hour confusion has recurred; documented it as a ⚠ callout in [[tariff]] and filed a
|
||||||
|
per-hour-preview composer UX idea under Open. No code change.
|
||||||
|
|
||||||
|
## [2026-06-20] fix | Subscription sale was off the books — append a signed payment
|
||||||
|
|
||||||
|
Operator-reported [[threat-model]] hole: creating a priced [[subscription]] wrote ONLY the
|
||||||
|
mutable `subscriptions` master row and appended NOTHING to the signed ledger. The cash the
|
||||||
|
operator collected (e.g. 10,000 ALL) showed in the live feed / drawer / Z-report nowhere —
|
||||||
|
a clean off-book channel. Confirmed live on the appliance: three priced subscriptions
|
||||||
|
(27,000 ALL sold) had ZERO payment events. This is the canonical booth-operator-as-adversary
|
||||||
|
path the [[append-only-event-chain]] exists to close; the "collect-in-shift later" deferral
|
||||||
|
(decided 2026-06-18) had left it open.
|
||||||
|
|
||||||
|
Fix: selling a priced subscription now appends a signed `payment` event (the long-planned
|
||||||
|
plain-`payment`-not-new-type choice, resolved) at create time — amount = `priceMinor x
|
||||||
|
months` (full multi-month prepay), operator-chosen tender (cash->drawer / card->bank),
|
||||||
|
payload `{ subscriptionSale: true, permitId, operator, months }`. Folds into the [[shift]]
|
||||||
|
Z-report/drawer with no new summing logic; the live feed badges it "subscription sale" and
|
||||||
|
resolves the holder name. NOT hard-gated on an open shift (a sale can happen outside the
|
||||||
|
booth money path) — it warns instead; flagged as a remaining sub-question. The 3 historical
|
||||||
|
off-book sales are NOT back-fillable (append-only forbids forging dated events) —
|
||||||
|
reconcile via `cash_movement` / a Z-report note.
|
||||||
|
|
||||||
|
Verified against a COPY of the live DB with the real signing modules: signed payment
|
||||||
|
appended (30,000 ALL, 3-month), hash-chain still verifies, lands in shift cash totals.
|
||||||
|
Build + lint 12/12. Updated [[subscription]] (Collecting the fee → BUILT; data model;
|
||||||
|
open-question #3 resolved), [[shift]] (sale folds in), [[threat-model]] (worked example:
|
||||||
|
"store the price ≠ account for the sale").
|
||||||
|
|
||||||
|
## [2026-06-20] feat | Show recognized plate in live feed + active sessions
|
||||||
|
|
||||||
|
The advisory ANPR plate (device_events kind="read", keyed by session identity — unsigned,
|
||||||
|
prunable, NEVER an access decision) is now surfaced next to entry/exit events in the live
|
||||||
|
feed and on active-session rows. Resolved at serialize time (new `plate-lookup.ts`, prefers
|
||||||
|
an entry read; one device_events scan for the whole page), like subscriber-name enrichment —
|
||||||
|
the signed ledger is untouched. Added `plate?` to the shared `LedgerEvent` + `ActiveSession`/
|
||||||
|
`SessionLookup`; a small amber badge in the UI. Caveat: a `vehicle_entry` is signed + pushed
|
||||||
|
over WS BEFORE the async ANPR read lands, so a fresh feed row may show no plate until reload;
|
||||||
|
always present on active sessions. Build + lint 12/12.
|
||||||
|
|
||||||
|
## [2026-06-20] feat | Drawer cash re-modelled as directional vouchers (Mandat Arkëtimi / Pagese)
|
||||||
|
|
||||||
|
Replaced the single signed-± `cash_movement` (one event, +load/−removal in the sign of an
|
||||||
|
amount) with two distinct financial documents — the direction is now the event TYPE:
|
||||||
|
`cash_in` = **Mandat Arkëtimi** (receipt / pay-IN, +) and `cash_out` = **Mandat Pagese**
|
||||||
|
(disbursement / pay-OUT, −). Each carries a positive magnitude, a voucher number (`AR-NNNN`
|
||||||
|
/ `PA-NNNN`), reason, the operator who raised it and the admin who authorized it, and prints
|
||||||
|
an Albanian slip. **Authorization changed**: was admin-only; now **operator-RAISED,
|
||||||
|
admin-AUTHORIZED** — any `shift:create` holder raises the voucher but `POST /api/cash-voucher`
|
||||||
|
only commits if `authorizedBy` is a real admin (`shift:cash`) re-entering their password.
|
||||||
|
Legacy `cash_movement` events are KEPT (still verify, still fold into the drawer signed-±) —
|
||||||
|
the append-only chain is never rewritten. Drawer fold + Z-report window updated to sum all
|
||||||
|
three types. Verified against a COPY of the live DB with the real signing modules: cash_in
|
||||||
|
3000 + cash_out 5000 → drawer −2000, hash-chain still verifies OK. Build + lint 12/12.
|
||||||
|
Updated [[shift]] (Drawer balance section, math, worked example, open items). Prompted by the
|
||||||
|
operator-balance question; the live mid-shift **X-report** breakdown is logged as REQUESTED,
|
||||||
|
not yet built (see [[shift]] Open).
|
||||||
|
|
||||||
|
## [2026-06-20] feat | Mid-shift X-report (read-only takings-so-far)
|
||||||
|
|
||||||
|
The operator can now see, on demand during an open shift, the opening float inherited,
|
||||||
|
cash/card collected so far, pay-ins/pay-outs, and the current expected drawer balance —
|
||||||
|
without closing. `GET /api/shift/report` (shift:read; 204 when no shift open) returns the
|
||||||
|
SAME projection the Z-report prints, factored into a shared `ShiftService.#summariseWindow
|
||||||
|
(open, asOf)` so X (asOf=now, read-only) and Z (asOf=endedAt, signed) can't drift. Appends
|
||||||
|
NOTHING — it's a snapshot, not an accountability mark (the Z at close is the signed record).
|
||||||
|
UI: a "Takings so far" button on the shift control reveals a cyan X-report panel; the header
|
||||||
|
keeps the live drawer total. Verified on a copy of the live DB: matches drawerBalance(),
|
||||||
|
drawer identity holds (expected = opening + cash + added − removed), 0 events appended, chain
|
||||||
|
verifies. Build + lint 12/12. Resolves the X-report item flagged the same day in [[shift]].
|
||||||
|
|
||||||
|
## [2026-06-20] feat | Subscription plan catalog — config-defined, dated spans, no typed prices
|
||||||
|
|
||||||
|
Re-modelled subscription pricing from per-row operator-typed `priceMinor` + monthly-only `period`
|
||||||
|
into an admin-composed, versioned PLAN CATALOG (the tariff pattern). Plans (`subscription_plans`,
|
||||||
|
migration 0010) are immutable effective-dated versions keyed by a stable planId, with period ∈
|
||||||
|
day/week/month + per-period price. The operator SELLS by selecting a plan over a date span (start
|
||||||
|
defaults to today, end required); the price is LOOKED UP — periods = ceil(span / period), amount =
|
||||||
|
periods × per-period price (ceil = any started period is full; hotel/parking practice). The hotel
|
||||||
|
1–N day case is a daily plan over a check-in→check-out span. `POST /api/subscriptions/quote` gives a
|
||||||
|
live server-computed quote so the operator can't override the amount. New `subscription:plan`
|
||||||
|
permission (admin-only) composes the catalog; selling stays operator-grade `subscription:create`.
|
||||||
|
The signed-`payment` sale fix is unchanged — only the amount SOURCE moved to the plan quote; payload
|
||||||
|
now carries planId/planVersionId/periods. Pure span math lives + is unit-tested in @parking/shared
|
||||||
|
(68 tests incl. ceil/Jan-31 clamp). New SubscriptionPlansManager screen (Setup tab) + reworked
|
||||||
|
SubscriptionManager sell form (plan picker + dates + quote, no price field). Verified on a copy of
|
||||||
|
the live DB: 0010 applies (existing subs intact, monthly plan seeds from site default), a 3-night
|
||||||
|
hotel sale prices to 2,400 ALL, appends ONE signed payment with planVersionId, chain verifies.
|
||||||
|
Build + lint 12/12. Updated [[subscription]] (plan catalog supersedes typed price; data model) +
|
||||||
|
[[tariff]] (shared versioned-config pattern). The site default price column is kept only to seed the
|
||||||
|
first plan.
|
||||||
|
|
||||||
|
## [2026-06-20] feat | Subscription v2 — quantity, plan timeframes (tariff bridge), reserved spots
|
||||||
|
|
||||||
|
Three subscriber enhancements (migration 0011, additive columns):
|
||||||
|
(1) QUANTITY — one subscription covers N cars (a family pays once for 2); sale = span price × quantity,
|
||||||
|
maxConcurrent defaults to it.
|
||||||
|
(2) PLAN TIMEFRAMES → TARIFF BRIDGE — a plan may restrict when a subscriber may park (weekday
|
||||||
|
20:00→08:00, weekend all-day). Outside the window they're charged the TRANSIENT tariff for the gap
|
||||||
|
(not refused): early entry = arrival→window-open (deferred, signed as windowOwedMinor on the
|
||||||
|
vehicle_entry); late exit = window-close→departure, and exit is GATED (sub.refused.unpaidWindow) until
|
||||||
|
paid at the booth. Pure tz-aware outOfWindowGap in @parking/shared (12 unit tests); pricing reuses
|
||||||
|
computeFee + the active tariff version (apps/server/src/subscription-window.ts). The exit refusal is a
|
||||||
|
host-ONLINE business gate — fail-open still governs the offline path (flagged in the wiki).
|
||||||
|
(3) RESERVED SPOTS — site toggle reserve_subscriber_spots: occupancy holds max(0, quantity−inside) per
|
||||||
|
active sub, so transients see "full" sooner; effectiveFree = capacity − count − reserved. Subscribers
|
||||||
|
never gated by full.
|
||||||
|
UI: quantity field + ×N quote (SubscriptionManager); timeframes editor (SubscriptionPlansManager);
|
||||||
|
reserve checkbox (SiteSettings); booth pay modal shows an "OUT-OF-WINDOW" charge + takes payment.
|
||||||
|
Verified on a copy of the live DB: qty 2 = 2× price; night-plan 19:30 entry → 30min/15,000 ALL owed,
|
||||||
|
stamped + paid → gate clears, chain verifies; reserve toggle holds a qty-2 sub's 2 spots. Build+lint
|
||||||
|
12/12; 80 shared tests. Updated [[subscription]], [[capacity-occupancy]], [[tariff]].
|
||||||
|
|||||||
Reference in New Issue
Block a user