5 Commits

Author SHA1 Message Date
julian 28bd838696 docs(wiki): merchant validations settled + as-built; scan input decided (camera paths postponed)
Build desktop / desktop (push) Successful in 5m5s
CI / check (push) Successful in 47s
Build & push images / images (push) Successful in 2m59s
validation-discounts: driving cases → the settled validation-only model (all
money/paper at the booth) → setup UX/storage/RBAC → full as-built record.
DECIDED: merchant stations scan with a USB/HID barcode scanner on the
web/desktop app (hand-keying + Luhn as fallback); POSTPONED with analysis:
web getUserMedia scanning (secure-context TLS prerequisite on the LAN +
Code128-via-camera weakness → QR-on-ticket first) and a Tauri v2 Android
merchant app (native ML Kit scanning; Android build/sideload overhead +
configurable-server-URL prerequisite). Also: wsl-dev-networking gains the
mirrored-mode gotcha where a Windows-side listener makes a port EADDRINUSE
inside WSL while invisible to ss — Vite auto-increments and tauri dev's fixed
devUrl waits on the wrong port.

Claude-Session: https://claude.ai/code/session_01YYkpEsLmoQPaize5ec3oUm
2026-07-13 19:50:09 +02:00
julian 692dff5f89 feat(validations): merchant (bar/lavazh) ticket validations end-to-end
In-park merchants discharge customers' parking: a merchant user scans the
ticket on their device (/validate; validation:create + program↔user binding)
and applies their program — comp / first-N-minutes free / amount-off (capped,
typed at scan) / percent. All money stays at the booth: the quote folds live
validations in a canonical order (timeCredit → percent → fixed → comp, net
floors at 0, Σ lines ≡ gross − net), the payment records gross/discount and
CONSUMES the validation ids (an overstay's fresh period never re-applies
them), the receipt prints the gross → lines → net story, and the Z/X-report
carries discountTotalMinor leakage. Every apply/void is a signed, attributed
ledger event (refId = append-only void); program config is /setup/site master
data (Bar/Lavazh checkboxes + right-column panel, tabs when both) whose saves
sign config_change. Migration 0024 + reset-db drift-guard entries; 8 route
integration tests + priceSession fold suite.

See wiki/concepts/validation-discounts.md for the full design record.

Claude-Session: https://claude.ai/code/session_01YYkpEsLmoQPaize5ec3oUm
2026-07-13 19:49:58 +02:00
julian ba7538aeb5 docs(wiki): capture cloud-service SaaS requirements (postponed)
Multi-tenant SaaS layered on the offline model: link-up monitoring of the
signed ledger, device status, financials; one admin → many sites; per-site
secret custody; recurring fee. Records the four tensions, the confirmed
secrets boundary (sync creds + device-password escrow + app identity, NOT
the signing key), and the two in-discussion corrections that stand (NetBird
already solves booth isolation; remote barrier-open is pulseOpen-and-signed,
driven by the unmanned future). status: open, postponed.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-13 14:51:01 +02:00
julian bb365b5d6e fix(booth-pay): entry/exit timestamps read alike (Sot 19:25:44)
The pay modal rendered entry via formatRelativeDateTime (relative day, no
seconds → "Sot 19:25") and exit/now via the legacy formatTime (raw
HH:MM:SS, no day → "19:25:44") — inconsistent on both day context and
seconds. Added a { seconds } option to formatRelativeDateTime and routed
all four call sites (entry, exit, live now, alreadyClosed toast) through
it, so every row reads "Sot 19:25:44". Removed formatTime — the last raw
toTimeString() helper and the source of the mismatch; BoothPayModal was
its only caller.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-11 10:38:12 +02:00
julian c52a42dad2 fix(resources): update TAG to stage-22544ec for deployment consistency
Build & push images / images (push) Successful in 2m52s
CI / check (push) Successful in 44s
2026-07-10 08:50:51 +02:00
32 changed files with 2372 additions and 51 deletions
+5
View File
@@ -80,6 +80,8 @@ function receiptFigures(
currency?: string; currency?: string;
tender?: "cash" | "card"; tender?: "cash" | "card";
graceExitMin?: number; graceExitMin?: number;
grossMinor?: number;
validationLines?: { label: string; discountMinor: number }[];
}; };
return { return {
ticketId, ticketId,
@@ -89,6 +91,9 @@ function receiptFigures(
currency: p.currency ?? "ALL", currency: p.currency ?? "ALL",
tender: p.tender === "card" ? "card" : "cash", tender: p.tender === "card" ? "card" : "cash",
graceExitMin: typeof p.graceExitMin === "number" ? p.graceExitMin : null, graceExitMin: typeof p.graceExitMin === "number" ? p.graceExitMin : null,
// Merchant validations, as settled on the signed payment (gross → lines → net).
grossMinor: typeof p.grossMinor === "number" ? p.grossMinor : null,
validationLines: Array.isArray(p.validationLines) ? p.validationLines : undefined,
}; };
} }
+47 -2
View File
@@ -1,9 +1,10 @@
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 { priceSession, type TariffStructure, type Tender } from "@parking/shared"; import { priceSession, type TariffStructure, type Tender, type ValidationLine } from "@parking/shared";
import type { FastifyBaseLogger } from "fastify"; import type { FastifyBaseLogger } from "fastify";
import type { EventLog } from "./event-log.js"; import type { EventLog } from "./event-log.js";
import { plateForIdentity, platesForIdentities } from "./plate-lookup.js"; import { plateForIdentity, platesForIdentities } from "./plate-lookup.js";
import { windowOwedBetween } from "./subscription-window.js"; import { windowOwedBetween } from "./subscription-window.js";
import { liveValidations } from "./validations.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:
@@ -38,8 +39,17 @@ export interface Quote {
* is priced as a fresh stay from there → now, with its own daily-cap ladder, NOT * 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). */ * "full stay minus paid" (which a daily cap collapses toward zero). */
readonly periodStart: string; readonly periodStart: string;
/** Amount owed now: the fee for [periodStart → now]. */ /** Amount owed now: the fee for [periodStart → now], NET of merchant validations. */
readonly amountMinor: number; readonly amountMinor: number;
/** The pre-validation fee (= amountMinor when no validations apply). */
readonly grossMinor: number;
/** Total the merchant validations took off (gross − net). */
readonly discountMinor: number;
/** Per-validation receipt/display lines (empty when none apply). */
readonly validationLines: ValidationLine[];
/** The validation event ids this quote applied — the payment stamps them as
* CONSUMED so an overstay's fresh period never re-applies them. */
readonly validationIds: string[];
/** True when this quote prices an overstay period (grace lapsed), not the first stay. */ /** True when this quote prices an overstay period (grace lapsed), not the first stay. */
readonly overstay: boolean; readonly overstay: boolean;
readonly currency: string; readonly currency: string;
@@ -117,6 +127,12 @@ export interface SessionLookup {
/** Advisory licence plate recognized for this session (ANPR-on-snapshot). Null when /** Advisory licence plate recognized for this session (ANPR-on-snapshot). Null when
* none. Display/audit only — never an access decision. */ * none. Display/audit only — never an access decision. */
readonly plate: string | null; readonly plate: string | null;
/** Merchant validations folded into `amountMinor` (which is NET): the pre-discount
* fee, the total taken off, and the per-validation lines for the modal/receipt.
* grossMinor/discountMinor are null when no quote resolved. */
readonly grossMinor: number | null;
readonly discountMinor: number | null;
readonly validationLines: ValidationLine[];
} }
export class PayStation { export class PayStation {
@@ -155,19 +171,28 @@ export class PayStation {
// Pure pricing shared with the Tariff Lab (priceSession). Only the latest payment // Pure pricing shared with the Tariff Lab (priceSession). Only the latest payment
// matters for grace/overstay; pass it through. Overstay → fresh period from // matters for grace/overstay; pass it through. Overstay → fresh period from
// grace-expiry; within-grace → settled; unpaid → entry→now running total. // grace-expiry; within-grace → settled; unpaid → entry→now running total.
// Merchant validations: fold the LIVE ones (applied, unvoided, not consumed by a
// prior payment) so the quote is NET — the payment then stamps their ids as
// consumed. See wiki/concepts/validation-discounts.md.
const last = this.#lastPayment(identity); const last = this.#lastPayment(identity);
const validations = liveValidations(this.#db, identity);
const p = priceSession( const p = priceSession(
entry.occurredAt, entry.occurredAt,
new Date().toISOString(), new Date().toISOString(),
structure, structure,
last ? [last] : [], last ? [last] : [],
category, category,
validations,
); );
return { return {
identity, identity,
enteredAt: entry.occurredAt, enteredAt: entry.occurredAt,
periodStart: p.periodStart, periodStart: p.periodStart,
amountMinor: p.amountMinor, amountMinor: p.amountMinor,
grossMinor: p.grossMinor,
discountMinor: p.discountMinor,
validationLines: p.validationLines,
validationIds: validations.map((v) => v.eventId),
overstay: p.overstay, overstay: p.overstay,
currency: tv.currency, currency: tv.currency,
tariffVersionId: tv.id, tariffVersionId: tv.id,
@@ -246,6 +271,18 @@ export class PayStation {
// The exit flow reads graceExitMin off the payment to validate the // The exit flow reads graceExitMin off the payment to validate the
// walk-back window without re-resolving the tariff. // walk-back window without re-resolving the tariff.
graceExitMin: q.graceExitMin, graceExitMin: q.graceExitMin,
// Merchant validations: record the gross/discount split + CONSUME the applied
// validation ids, so reporting sees the leakage and a later overstay period
// never re-applies them. A zero-net settlement (full comp) is still a signed
// payment — grace/voucher/exit work unchanged. See validation-discounts.md.
...(q.validationIds.length
? {
grossMinor: q.grossMinor,
discountMinor: q.discountMinor,
validationIds: q.validationIds,
validationLines: q.validationLines.map((l) => ({ ...l })),
}
: {}),
...(overrideMinor != null ? { reason: "operator-set amount", quotedMinor: q.amountMinor } : {}), ...(overrideMinor != null ? { reason: "operator-set amount", quotedMinor: q.amountMinor } : {}),
}, },
}); });
@@ -282,6 +319,7 @@ export class PayStation {
paidAt: null, amountMinor: null, currency: null, paidMinor: null, paidCurrency: null, paidAt: null, amountMinor: null, currency: null, paidMinor: null, paidCurrency: null,
withinGrace: false, graceExpiresAt: null, withinGrace: false, graceExpiresAt: null,
overstay: false, subscription: false, subscriptionId: null, subscriptionHolder: null, plate: null, overstay: false, subscription: false, subscriptionId: null, subscriptionHolder: null, plate: null,
grossMinor: null, discountMinor: null, validationLines: [],
}; };
} }
// Subscription occurrence? The entry payload carries permit:true + permitId. // Subscription occurrence? The entry payload carries permit:true + permitId.
@@ -319,11 +357,17 @@ export class PayStation {
// exit gate clears. See wiki/entities/subscription.md. // 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;
let grossMinor: number | null = null;
let discountMinor: number | null = null;
let validationLines: ValidationLine[] = [];
if (open && !isSubscription) { if (open && !isSubscription) {
try { try {
const q = this.quote(id); const q = this.quote(id);
amountMinor = q.amountMinor; amountMinor = q.amountMinor;
currency = q.currency; currency = q.currency;
grossMinor = q.grossMinor;
discountMinor = q.discountMinor;
validationLines = q.validationLines;
} catch { } catch {
/* no active tariff — leave null; modal shows session without a price */ /* no active tariff — leave null; modal shows session without a price */
} }
@@ -344,6 +388,7 @@ export class PayStation {
subscription: isSubscription, subscriptionId, subscription: isSubscription, subscriptionId,
subscriptionHolder: this.#holderOf(subscriptionId), subscriptionHolder: this.#holderOf(subscriptionId),
plate: plateForIdentity(this.#db, id)?.plate ?? null, plate: plateForIdentity(this.#db, id)?.plate ?? null,
grossMinor, discountMinor, validationLines,
}; };
} }
+272
View File
@@ -0,0 +1,272 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { eq, ledgerEvents, users, type Db } from "@parking/db";
import { createTestDb } from "@parking/db/testing";
import type { FastifyInstance } from "fastify";
import { buildServer } from "../server.js";
import { login, makeLog, minutesAgo, seedTariff, seedUser } from "../test-helpers.js";
import type { EventLog } from "../event-log.js";
// Merchant validations (bar/lavazh): the merchant user scans a ticket and applies
// their program (a SIGNED, attributed ledger event); the booth settlement quotes NET
// and the payment CONSUMES the validation ids. These tests pin the route guards
// (binding, caps, session state), the signed apply/void events, and the money cycle
// through /api/pay/quote + /api/pay. See wiki/concepts/validation-discounts.md.
let db: Db;
let close: () => void;
let app: FastifyInstance;
beforeEach(async () => {
const t = createTestDb();
db = t.db;
close = t.close;
app = await buildServer({ db });
await app.ready();
});
afterEach(async () => {
await app.close();
close();
});
type Auth = { cookie: string; csrf: string };
const hdrs = (a: Auth) => ({ cookie: a.cookie, "x-csrf-token": a.csrf });
async function seedMerchant(username = "bari"): Promise<{ auth: Auth; userId: string }> {
await seedUser(db, { username, password: "pw123456", roleId: "validues", permissions: ["validation:create"] });
const auth = await login(app, username, "pw123456");
const row = db.select().from(users).where(eq(users.username, username)).get()!;
return { auth, userId: row.id };
}
async function seedAdmin(): Promise<Auth> {
await seedUser(db, { username: "admin", password: "pw123456" });
return login(app, "admin", "pw123456");
}
/** Admin-upserts the "bar" program bound to the given user. */
async function putProgram(auth: Auth, body: Record<string, unknown>, id = "bar") {
return app.inject({ method: "PUT", url: `/api/validation/programs/${id}`, headers: hdrs(auth), payload: body });
}
const fixedProgram = (userId: string, over: Record<string, unknown> = {}) => ({
name: "Bar",
mode: "fixed",
maxAmountMinor: 100000,
active: true,
userIds: [userId],
...over,
});
describe("merchant validations", () => {
let log: EventLog;
beforeEach(() => {
log = makeLog(db);
});
const mint = (identity: string, minAgo: number, payload: Record<string, unknown> | null = null) =>
log.append({ type: "vehicle_entry", direction: "entry", identity, occurredAt: minutesAgo(minAgo), payload });
it("program upsert is admin-gated and signs a config_change; a no-op save signs nothing", async () => {
const admin = await seedAdmin();
const { auth: merchant, userId } = await seedMerchant();
expect((await putProgram(merchant, fixedProgram(userId))).statusCode).toBe(403);
const res = await putProgram(admin, fixedProgram(userId));
expect(res.statusCode).toBe(200);
expect(res.json()).toMatchObject({ id: "bar", mode: "fixed", active: true, userIds: [userId] });
const changes = () => db.select().from(ledgerEvents).all().filter((r) => r.type === "config_change");
expect(changes()).toHaveLength(1);
expect(changes()[0].payload).toMatchObject({ setting: "validationProgram.bar", operator: "admin" });
// Identical second save → no second config_change.
await putProgram(admin, fixedProgram(userId));
expect(changes()).toHaveLength(1);
});
it("per-mode validation: timeCredit needs minutes, percent needs percent, fixed needs a cap", async () => {
const admin = await seedAdmin();
expect((await putProgram(admin, { name: "X", mode: "timeCredit", active: true })).statusCode).toBe(400);
expect((await putProgram(admin, { name: "X", mode: "percent", active: true })).statusCode).toBe(400);
expect((await putProgram(admin, { name: "X", mode: "fixed", active: true })).statusCode).toBe(400);
expect((await putProgram(admin, { name: "X", mode: "timeCredit", minutes: 60, active: true })).statusCode).toBe(200);
});
it("GET /mine returns only MY bound, active programs", async () => {
const admin = await seedAdmin();
const { auth: merchant, userId } = await seedMerchant();
await putProgram(admin, fixedProgram(userId));
await putProgram(admin, { name: "Lavazh", mode: "comp", active: true, userIds: [] }, "lavazh");
const res = await app.inject({ method: "GET", url: "/api/validation/mine", headers: hdrs(merchant) });
expect(res.statusCode).toBe(200);
const programs = res.json().programs as { id: string }[];
expect(programs.map((p) => p.id)).toEqual(["bar"]);
});
it("apply: binding, session-state, duplicate and amount guards", async () => {
const admin = await seedAdmin();
const { auth: merchant, userId } = await seedMerchant();
const { auth: other } = await seedMerchant("tjetri");
await putProgram(admin, fixedProgram(userId));
seedTariff(db);
await mint("T1", 120);
const apply = (auth: Auth, payload: Record<string, unknown>) =>
app.inject({ method: "POST", url: "/api/validation/apply", headers: hdrs(auth), payload });
// Unbound merchant → 403; unknown ticket → 404; missing amount (fixed) → 400;
// amount above the cap → 400.
expect((await apply(other, { identity: "T1", programId: "bar", amountMinor: 5000 })).statusCode).toBe(403);
expect((await apply(merchant, { identity: "NOPE", programId: "bar", amountMinor: 5000 })).statusCode).toBe(404);
expect((await apply(merchant, { identity: "T1", programId: "bar" })).statusCode).toBe(400);
expect((await apply(merchant, { identity: "T1", programId: "bar", amountMinor: 999999 })).statusCode).toBe(400);
// Subscriber sessions are never validated (prepaid).
await mint("SUB1", 60, { permit: true, permitId: "s-1" });
expect((await apply(merchant, { identity: "SUB1", programId: "bar", amountMinor: 5000 })).statusCode).toBe(409);
// Success → a SIGNED validation event with resolved values + the merchant username.
const ok = await apply(merchant, { identity: "T1", programId: "bar", amountMinor: 5000 });
expect(ok.statusCode).toBe(201);
const ev = db.select().from(ledgerEvents).all().find((r) => r.type === "validation")!;
expect(ev.payload).toMatchObject({
programId: "bar",
programLabel: "Bar",
mode: "fixed",
amountMinor: 5000,
operator: "bari",
});
// Same program twice on one ticket → 409.
expect((await apply(merchant, { identity: "T1", programId: "bar", amountMinor: 1000 })).statusCode).toBe(409);
});
it("the money cycle: quote nets the validation, pay records gross/discount and CONSUMES it", async () => {
const admin = await seedAdmin();
const { auth: merchant, userId } = await seedMerchant();
await putProgram(admin, fixedProgram(userId));
// 100/h flat; 2h → gross 20000.
seedTariff(db, { pricePerIncrementMinor: 10000, incrementMin: 60 });
await mint("T1", 119);
await app.inject({
method: "POST",
url: "/api/validation/apply",
headers: hdrs(merchant),
payload: { identity: "T1", programId: "bar", amountMinor: 5000 },
});
const q1 = await app.inject({ method: "GET", url: "/api/pay/quote?identity=T1", headers: hdrs(admin) });
expect(q1.json()).toMatchObject({
grossMinor: 20000,
discountMinor: 5000,
amountMinor: 15000,
});
expect(q1.json().validationLines).toEqual([
{ programId: "bar", label: "Bar", mode: "fixed", discountMinor: 5000 },
]);
// Pay (needs an open shift) → the payment carries the split + consumed ids.
await app.inject({ method: "POST", url: "/api/shift/open", headers: hdrs(admin) });
const pay = await app.inject({ method: "POST", url: "/api/pay", headers: hdrs(admin), payload: { identity: "T1", tender: "cash" } });
expect(pay.statusCode).toBe(201);
expect(pay.json().amountMinor).toBe(15000);
const payment = db.select().from(ledgerEvents).all().find((r) => r.type === "payment")!;
expect(payment.payload).toMatchObject({ amountMinor: 15000, grossMinor: 20000, discountMinor: 5000 });
expect((payment.payload as { validationIds?: string[] }).validationIds).toHaveLength(1);
// Settled: the follow-up quote owes 0 and applies nothing further.
const q2 = await app.inject({ method: "GET", url: "/api/pay/quote?identity=T1", headers: hdrs(admin) });
expect(q2.json().amountMinor).toBe(0);
expect(q2.json().validationLines).toEqual([]);
});
it("a full comp settles at 0 through the normal pay path (grace starts, chain verifies)", async () => {
const admin = await seedAdmin();
const { auth: merchant, userId } = await seedMerchant();
await putProgram(admin, { name: "Lavazh falas", mode: "comp", active: true, userIds: [userId] }, "lavazh");
seedTariff(db, { pricePerIncrementMinor: 10000 });
await mint("T1", 90);
await app.inject({
method: "POST",
url: "/api/validation/apply",
headers: hdrs(merchant),
payload: { identity: "T1", programId: "lavazh" },
});
const q = await app.inject({ method: "GET", url: "/api/pay/quote?identity=T1", headers: hdrs(admin) });
expect(q.json().amountMinor).toBe(0);
expect(q.json().grossMinor).toBeGreaterThan(0);
await app.inject({ method: "POST", url: "/api/shift/open", headers: hdrs(admin) });
const pay = await app.inject({ method: "POST", url: "/api/pay", headers: hdrs(admin), payload: { identity: "T1", tender: "cash" } });
expect(pay.statusCode).toBe(201);
expect(pay.json().amountMinor).toBe(0);
// The 0-net settlement still grants walk-back grace (the session reads settled).
const view = await app.inject({ method: "GET", url: "/api/session/T1", headers: hdrs(admin) });
expect(view.json()).toMatchObject({ withinGrace: true, amountMinor: 0 });
});
it("void: own unused only; a consumed validation is locked", async () => {
const admin = await seedAdmin();
const { auth: merchant, userId } = await seedMerchant();
const { auth: other, userId: otherId } = await seedMerchant("tjetri");
await putProgram(admin, fixedProgram(userId, { userIds: [userId, otherId] }));
seedTariff(db, { pricePerIncrementMinor: 10000 });
await mint("T1", 90);
const applied = await app.inject({
method: "POST",
url: "/api/validation/apply",
headers: hdrs(merchant),
payload: { identity: "T1", programId: "bar", amountMinor: 5000 },
});
const eventId = applied.json().eventId as string;
const voidReq = (auth: Auth) =>
app.inject({ method: "POST", url: "/api/validation/void", headers: hdrs(auth), payload: { eventId, identity: "T1" } });
// Someone else's validation → 403. Own → ok, and the quote returns to gross.
expect((await voidReq(other)).statusCode).toBe(403);
expect((await voidReq(merchant)).statusCode).toBe(200);
const q = await app.inject({ method: "GET", url: "/api/pay/quote?identity=T1", headers: hdrs(admin) });
expect(q.json().discountMinor).toBe(0);
// Re-apply (the void freed the per-session slot), consume it with a payment, then
// a void must refuse — the settlement already happened.
const re = await app.inject({
method: "POST",
url: "/api/validation/apply",
headers: hdrs(merchant),
payload: { identity: "T1", programId: "bar", amountMinor: 5000 },
});
await app.inject({ method: "POST", url: "/api/shift/open", headers: hdrs(admin) });
await app.inject({ method: "POST", url: "/api/pay", headers: hdrs(admin), payload: { identity: "T1", tender: "cash" } });
const locked = await app.inject({
method: "POST",
url: "/api/validation/void",
headers: hdrs(merchant),
payload: { eventId: re.json().eventId, identity: "T1" },
});
expect(locked.statusCode).toBe(409);
});
it("maxPerDay caps applications across tickets", async () => {
const admin = await seedAdmin();
const { auth: merchant, userId } = await seedMerchant();
await putProgram(admin, { name: "Lavazh", mode: "comp", maxPerDay: 1, active: true, userIds: [userId] }, "lavazh");
seedTariff(db);
await mint("T1", 60);
await mint("T2", 30);
const apply = (identity: string) =>
app.inject({ method: "POST", url: "/api/validation/apply", headers: hdrs(merchant), payload: { identity, programId: "lavazh" } });
expect((await apply("T1")).statusCode).toBe(201);
expect((await apply("T2")).statusCode).toBe(409);
});
});
+360
View File
@@ -0,0 +1,360 @@
import type { FastifyInstance } from "fastify";
import {
and,
eq,
isNull,
inArray,
ledgerEvents,
users,
validationProgramUsers,
validationPrograms,
type Db,
} from "@parking/db";
import { VALIDATION_MODES, type ValidationMode } from "@parking/shared";
import { requirePermission } from "../auth.js";
import type { EventLog } from "../event-log.js";
import { liveValidations, sessionValidations } from "../validations.js";
// Merchant validations (bar / lavazh). The merchant is VALIDATION-ONLY: they scan the
// customer's ticket on their own device and apply their program — all money and paper
// stay at the booth, which settles net of these events. Program config is admin-composed
// on /setup/site (site:read/update — no dedicated permission); applying is the merchant
// user's `validation:create`, guarded FURTHER by the program↔user binding so a bar user
// can never apply the lavazh program. Every apply/void is a signed, attributed ledger
// event. See wiki/concepts/validation-discounts.md.
// - GET /api/validation/programs : all programs + bound users. (site:read)
// - PUT /api/validation/programs/:id : upsert config + bindings; (site:update)
// signs a config_change.
// - GET /api/validation/mine : my bound ACTIVE programs. (validation:create)
// - GET /api/validation/session/:identity : minimal session view for (validation:create)
// the merchant screen (no money data).
// - POST /api/validation/apply : apply my program (signed). (validation:create)
// - POST /api/validation/void : void my OWN unused apply. (validation:create)
/** Well-formed program ids: kebab slugs ("bar", "lavazh", a future "hotel-2"). */
const ID_RE = /^[a-z][a-z0-9-]{1,31}$/;
interface ProgramBody {
name?: string;
mode?: ValidationMode;
minutes?: number | null;
percent?: number | null;
maxAmountMinor?: number | null;
maxPerDay?: number | null;
active?: boolean;
/** Full replacement set of bound user ids. */
userIds?: string[];
}
interface ApplyBody {
identity: string;
programId: string;
/** fixed mode only: the discount the merchant grants (minor units, ≤ maxAmountMinor). */
amountMinor?: number;
}
interface VoidBody {
eventId: string;
identity: string;
}
/** null when valid, else the 400 message. Checks the per-mode parameter. */
function validateProgram(b: ProgramBody): string | null {
if (!b.name || !String(b.name).trim()) return "name is required";
if (!VALIDATION_MODES.includes(b.mode as ValidationMode)) return "mode must be comp|timeCredit|fixed|percent";
const intOrNull = (v: unknown) => v == null || (Number.isInteger(v) && (v as number) > 0);
if (!intOrNull(b.minutes)) return "minutes must be a positive integer";
if (!intOrNull(b.maxAmountMinor)) return "maxAmountMinor must be a positive integer";
if (!intOrNull(b.maxPerDay)) return "maxPerDay must be a positive integer";
if (b.percent != null && (!Number.isInteger(b.percent) || b.percent < 1 || b.percent > 100))
return "percent must be 1..100";
if (b.mode === "timeCredit" && b.minutes == null) return "timeCredit needs minutes";
if (b.mode === "percent" && b.percent == null) return "percent mode needs percent";
if (b.mode === "fixed" && b.maxAmountMinor == null) return "fixed mode needs maxAmountMinor";
return null;
}
export async function validationRoutes(app: FastifyInstance, db: Db, eventLog: EventLog): Promise<void> {
const siteRead = requirePermission("site:read");
const siteWrite = requirePermission("site:update");
const applyGuard = requirePermission("validation:create");
const liveProgram = (id: string) =>
db
.select()
.from(validationPrograms)
.where(and(eq(validationPrograms.id, id), isNull(validationPrograms.deletedAt)))
.get();
const boundUserIds = (programId: string): string[] =>
db
.select({ userId: validationProgramUsers.userId })
.from(validationProgramUsers)
.where(eq(validationProgramUsers.programId, programId))
.all()
.map((r) => r.userId);
// The setup panel's read: every live program with its bound users.
app.get("/api/validation/programs", { preHandler: siteRead }, async () => {
const programs = db.select().from(validationPrograms).where(isNull(validationPrograms.deletedAt)).all();
return {
programs: programs.map((p) => ({ ...p, userIds: boundUserIds(p.id) })),
};
});
// Upsert a program (the /setup/site checkbox + panel). Creates the well-known row on
// first enable; replaces the binding set; signs an attributed config_change when
// anything actually changed (the entry-presence-bypass precedent — enabling a discount
// program is fraud-relevant config).
app.put<{ Params: { id: string }; Body: ProgramBody }>(
"/api/validation/programs/:id",
{ preHandler: siteWrite },
async (req, reply) => {
const id = (req.params.id ?? "").trim();
if (!ID_RE.test(id)) return reply.code(400).send({ error: "invalid program id" });
const b = req.body ?? ({} as ProgramBody);
const bad = validateProgram(b);
if (bad) return reply.code(400).send({ error: bad });
const userIds = Array.isArray(b.userIds) ? [...new Set(b.userIds)] : [];
if (userIds.length) {
const found = db
.select({ id: users.id })
.from(users)
.where(and(inArray(users.id, userIds), isNull(users.deletedAt)))
.all();
if (found.length !== userIds.length) return reply.code(400).send({ error: "unknown user in userIds" });
}
const prev = liveProgram(id);
const prevUserIds = prev ? boundUserIds(id).sort() : [];
const next = {
name: String(b.name).trim(),
mode: b.mode as ValidationMode,
minutes: b.minutes ?? null,
percent: b.percent ?? null,
maxAmountMinor: b.maxAmountMinor ?? null,
maxPerDay: b.maxPerDay ?? null,
active: b.active === true,
};
if (prev) {
db.update(validationPrograms).set(next).where(eq(validationPrograms.id, id)).run();
} else {
db.insert(validationPrograms).values({ id, ...next }).run();
}
db.delete(validationProgramUsers).where(eq(validationProgramUsers.programId, id)).run();
for (const userId of userIds) {
db.insert(validationProgramUsers).values({ programId: id, userId }).run();
}
// Sign the change (attributed) — enabling/reshaping a discount program is
// fraud-relevant config. Compare against the previous row + binding set so a
// no-op save signs nothing.
const summary = (row: typeof next, ids: string[]) => JSON.stringify({ ...row, userIds: [...ids].sort() });
const prevSummary = prev
? summary(
{ name: prev.name, mode: prev.mode, minutes: prev.minutes, percent: prev.percent,
maxAmountMinor: prev.maxAmountMinor, maxPerDay: prev.maxPerDay, active: prev.active },
prevUserIds,
)
: null;
if (prevSummary !== summary(next, userIds)) {
await eventLog.append({
type: "config_change",
source: "manual",
identity: `validation-program:${id}`,
payload: {
setting: `validationProgram.${id}`,
value: { ...next, userCount: userIds.length },
prev: prev
? { name: prev.name, mode: prev.mode, minutes: prev.minutes, percent: prev.percent,
maxAmountMinor: prev.maxAmountMinor, maxPerDay: prev.maxPerDay, active: prev.active }
: null,
operator: req.user?.username ?? "unknown",
},
});
}
const row = liveProgram(id);
return { ...row, userIds: boundUserIds(id) };
},
);
// The merchant screen's program list: MY bound, active programs.
app.get("/api/validation/mine", { preHandler: applyGuard }, async (req) => {
const rows = db
.select()
.from(validationPrograms)
.innerJoin(validationProgramUsers, eq(validationProgramUsers.programId, validationPrograms.id))
.where(
and(
eq(validationProgramUsers.userId, req.user.sub),
eq(validationPrograms.active, true),
isNull(validationPrograms.deletedAt),
),
)
.all();
return { programs: rows.map((r) => r.validation_programs) };
});
// Minimal session view for the merchant screen — deliberately NO money data (the
// merchant validates; the booth settles): found/open/entry time + the validations
// already on the session (so the UI can show "already validated" and offer void).
app.get<{ Params: { identity: string } }>(
"/api/validation/session/:identity",
{ preHandler: applyGuard },
async (req, reply) => {
const identity = (req.params.identity ?? "").trim();
if (!identity) return reply.code(400).send({ error: "identity required" });
const rows = db
.select({ type: ledgerEvents.type, occurredAt: ledgerEvents.occurredAt, payload: ledgerEvents.payload })
.from(ledgerEvents)
.where(eq(ledgerEvents.identity, identity))
.orderBy(ledgerEvents.index)
.all();
const entry = rows.find((r) => r.type === "vehicle_entry");
if (!entry) return { identity, found: false, open: false, enteredAt: null, subscription: false, validations: [] };
const entryPl = (entry.payload ?? {}) as { permit?: boolean; permitId?: string };
const subscription = entryPl.permit === true || entryPl.permitId != null;
const open = !rows.some((r) => r.type === "vehicle_exit" || r.type === "void");
return {
identity,
found: true,
open,
enteredAt: entry.occurredAt,
subscription,
validations: sessionValidations(db, identity),
};
},
);
// APPLY: the merchant's one action. Guards, in order: program live+active → the
// user is BOUND to it → the session is an OPEN TRANSIENT → not already carrying a
// live application of this program → per-day cap → fixed-amount bounds. Appends the
// signed validation event with the RESOLVED values.
app.post<{ Body: ApplyBody }>("/api/validation/apply", { preHandler: applyGuard }, async (req, reply) => {
const identity = (req.body?.identity ?? "").trim();
const programId = (req.body?.programId ?? "").trim();
if (!identity || !programId) return reply.code(400).send({ error: "identity and programId required" });
const program = liveProgram(programId);
if (!program || !program.active) return reply.code(404).send({ error: "program not found or inactive" });
if (!boundUserIds(programId).includes(req.user.sub)) {
return reply.code(403).send({ error: "you are not bound to this program" });
}
// Session state — an open transient (subscriptions are prepaid; nothing to discount).
const rows = db
.select({ type: ledgerEvents.type, payload: ledgerEvents.payload })
.from(ledgerEvents)
.where(eq(ledgerEvents.identity, identity))
.orderBy(ledgerEvents.index)
.all();
const entry = rows.find((r) => r.type === "vehicle_entry");
if (!entry) return reply.code(404).send({ error: "no session for ticket" });
const entryPl = (entry.payload ?? {}) as { permit?: boolean; permitId?: string };
if (entryPl.permit === true || entryPl.permitId != null) {
return reply.code(409).send({ error: "subscription sessions cannot be validated" });
}
if (rows.some((r) => r.type === "vehicle_exit" || r.type === "void")) {
return reply.code(409).send({ error: "session is closed" });
}
if (liveValidations(db, identity).some((v) => v.programId === programId)) {
return reply.code(409).send({ error: "this program is already applied to the ticket" });
}
// Per-day cap: unvoided applications of this program since LOCAL midnight (the
// appliance runs in site time).
if (program.maxPerDay != null) {
const midnight = new Date();
midnight.setHours(0, 0, 0, 0);
const todays = db
.select({ id: ledgerEvents.id, occurredAt: ledgerEvents.occurredAt, payload: ledgerEvents.payload, type: ledgerEvents.type })
.from(ledgerEvents)
.where(eq(ledgerEvents.type, "validation"))
.all()
.filter((r) => Date.parse(r.occurredAt) >= midnight.getTime());
const voidedIds = new Set(
todays.map((r) => (r.payload as { refId?: string } | null)?.refId).filter(Boolean) as string[],
);
const count = todays.filter((r) => {
const p = (r.payload ?? {}) as { programId?: string; refId?: string };
return p.programId === programId && !p.refId && !voidedIds.has(r.id);
}).length;
if (count >= program.maxPerDay) {
return reply.code(409).send({ error: "daily cap reached for this program" });
}
}
// Resolve the values off the program row (frozen into the signed event).
let amountMinor: number | undefined;
if (program.mode === "fixed") {
const a = req.body?.amountMinor;
if (a == null || !Number.isInteger(a) || a <= 0) {
return reply.code(400).send({ error: "amountMinor (positive integer) required for this program" });
}
if (program.maxAmountMinor != null && a > program.maxAmountMinor) {
return reply.code(400).send({ error: `amount exceeds the program cap (${program.maxAmountMinor})` });
}
amountMinor = a;
}
const ev = await eventLog.append({
type: "validation",
source: "manual",
identity,
payload: {
sessionRef: identity,
programId,
programLabel: program.name,
mode: program.mode,
...(program.mode === "timeCredit" && program.minutes != null ? { minutes: program.minutes } : {}),
...(program.mode === "percent" && program.percent != null ? { percent: program.percent } : {}),
...(amountMinor != null ? { amountMinor } : {}),
operator: req.user.username,
},
});
return reply.code(201).send({
ok: true,
eventId: ev.id,
programId,
label: program.name,
mode: program.mode,
minutes: program.mode === "timeCredit" ? program.minutes : undefined,
percent: program.mode === "percent" ? program.percent : undefined,
amountMinor,
});
});
// VOID my own UNUSED validation (fat-fingered amount / wrong ticket). Append-only:
// a validation event with refId, never a delete. Refused once a payment consumed it
// (the settlement already happened — that dispute goes to the booth/admin).
app.post<{ Body: VoidBody }>("/api/validation/void", { preHandler: applyGuard }, async (req, reply) => {
const eventId = (req.body?.eventId ?? "").trim();
const identity = (req.body?.identity ?? "").trim();
if (!eventId || !identity) return reply.code(400).send({ error: "eventId and identity required" });
const target = sessionValidations(db, identity).find((v) => v.eventId === eventId);
if (!target) return reply.code(404).send({ error: "validation not found" });
if (target.operator !== req.user.username) {
return reply.code(403).send({ error: "you may only void your own validation" });
}
if (target.voided) return reply.code(409).send({ error: "already voided" });
if (target.consumedBy != null) {
return reply.code(409).send({ error: "already used in a payment — ask the booth/admin" });
}
await eventLog.append({
type: "validation",
source: "manual",
identity,
payload: {
sessionRef: identity,
refId: eventId,
programId: target.programId,
programLabel: target.label,
operator: req.user.username,
},
});
return { ok: true };
});
}
+6
View File
@@ -45,6 +45,7 @@ import { shiftRoutes } from "./routes/shift.js";
import { drawerRoutes } from "./routes/drawer.js"; import { drawerRoutes } from "./routes/drawer.js";
import { entryRoutes } from "./routes/entry.js"; import { entryRoutes } from "./routes/entry.js";
import { siteRoutes } from "./routes/site.js"; import { siteRoutes } from "./routes/site.js";
import { validationRoutes } from "./routes/validations.js";
import { snapshotRoutes } from "./routes/snapshots.js"; import { snapshotRoutes } from "./routes/snapshots.js";
import { tariffRoutes } from "./routes/tariffs.js"; import { tariffRoutes } from "./routes/tariffs.js";
import { printerRoutes } from "./routes/printers.js"; import { printerRoutes } from "./routes/printers.js";
@@ -292,6 +293,11 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
// 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.
await siteRoutes(app, db, eventLog); await siteRoutes(app, db, eventLog);
// Merchant validations (bar / lavazh): setup panel config + the merchant user's
// scan-and-apply. The booth settlement folds the applied validations into its
// quote (pay-station.ts). See wiki/concepts/validation-discounts.md.
await validationRoutes(app, db, eventLog);
// Application logs: ingest frontend errors (POST /api/logs, any signed-in user) + // Application logs: ingest frontend errors (POST /api/logs, any signed-in user) +
// read the store (GET /api/logs, log:read). See wiki/concepts/app-logs.md. // read the store (GET /api/logs, log:read). See wiki/concepts/app-logs.md.
await logRoutes(app, logService); await logRoutes(app, logService);
+17
View File
@@ -55,6 +55,7 @@ export interface ShiftSummary {
readonly subscriptionTotalMinor: number; readonly subscriptionTotalMinor: number;
readonly subscriptionSalesMinor: number; readonly subscriptionSalesMinor: number;
readonly subscriptionWindowMinor: number; readonly subscriptionWindowMinor: number;
readonly discountTotalMinor: number;
readonly openingFloatMinor: number; readonly openingFloatMinor: number;
readonly cashAddedMinor: number; readonly cashAddedMinor: number;
readonly cashRemovedMinor: number; readonly cashRemovedMinor: number;
@@ -78,6 +79,9 @@ export interface ShiftReport {
readonly subscriptionSalesMinor: number; readonly subscriptionSalesMinor: number;
/** Subscriber OUT-OF-WINDOW transient-tariff charges only. */ /** Subscriber OUT-OF-WINDOW transient-tariff charges only. */
readonly subscriptionWindowMinor: number; readonly subscriptionWindowMinor: number;
/** Merchant-validation DISCOUNT total given away in the window (leakage — the
* cash/card figures above are already NET of it). See validation-discounts.md. */
readonly discountTotalMinor: number;
// --- Drawer (physical cash till; carries across shifts) --- // --- Drawer (physical cash till; carries across shifts) ---
/** Cash in the drawer at shift start = prior shift's expected closing drawer. */ /** Cash in the drawer at shift start = prior shift's expected closing drawer. */
readonly openingFloatMinor: number; readonly openingFloatMinor: number;
@@ -220,6 +224,7 @@ export class ShiftService {
subscriptionTotalMinor?: number; subscriptionTotalMinor?: number;
subscriptionSalesMinor?: number; subscriptionSalesMinor?: number;
subscriptionWindowMinor?: number; subscriptionWindowMinor?: number;
discountTotalMinor?: number;
openingFloatMinor?: number; openingFloatMinor?: number;
cashAddedMinor?: number; cashAddedMinor?: number;
cashRemovedMinor?: number; cashRemovedMinor?: number;
@@ -250,6 +255,8 @@ export class ShiftService {
ticketTotalMinor: ticketTotalMinor:
pl.ticketTotalMinor ?? pl.ticketTotalMinor ??
(pl.cashTotalMinor ?? 0) + (pl.cardTotalMinor ?? 0) - (pl.subscriptionTotalMinor ?? 0), (pl.cashTotalMinor ?? 0) + (pl.cardTotalMinor ?? 0) - (pl.subscriptionTotalMinor ?? 0),
// Merchant-validation leakage (added 2026-07-13). Old reports lack it → 0.
discountTotalMinor: pl.discountTotalMinor ?? 0,
openingFloatMinor: pl.openingFloatMinor ?? 0, openingFloatMinor: pl.openingFloatMinor ?? 0,
cashAddedMinor: pl.cashAddedMinor ?? 0, cashAddedMinor: pl.cashAddedMinor ?? 0,
cashRemovedMinor: pl.cashRemovedMinor ?? 0, cashRemovedMinor: pl.cashRemovedMinor ?? 0,
@@ -522,6 +529,9 @@ export class ShiftService {
// the subscription sale path). // the subscription sale path).
let subscriptionSalesMinor = 0; let subscriptionSalesMinor = 0;
let subscriptionWindowMinor = 0; let subscriptionWindowMinor = 0;
// Merchant-validation leakage: Σ discountMinor across the window's payments. The
// tender totals are already NET; this is the "given away" figure beside them.
let discountTotalMinor = 0;
let currency: string | null = null; let currency: string | null = null;
for (const p of payments) { for (const p of payments) {
const pl = (p.payload ?? {}) as LedgerPayload & { const pl = (p.payload ?? {}) as LedgerPayload & {
@@ -534,6 +544,7 @@ export class ShiftService {
if (pl.subscriptionSale === true) subscriptionSalesMinor += amt; if (pl.subscriptionSale === true) subscriptionSalesMinor += amt;
else if (pl.subscriptionWindowCharge === true) subscriptionWindowMinor += amt; else if (pl.subscriptionWindowCharge === true) subscriptionWindowMinor += amt;
// (else → transient ticket; derived below as total − subscription) // (else → transient ticket; derived below as total − subscription)
if (typeof pl.discountMinor === "number") discountTotalMinor += pl.discountMinor;
if (pl.currency) currency = pl.currency; if (pl.currency) currency = pl.currency;
} }
const subscriptionTotalMinor = subscriptionSalesMinor + subscriptionWindowMinor; const subscriptionTotalMinor = subscriptionSalesMinor + subscriptionWindowMinor;
@@ -589,6 +600,7 @@ export class ShiftService {
subscriptionTotalMinor, subscriptionTotalMinor,
subscriptionSalesMinor, subscriptionSalesMinor,
subscriptionWindowMinor, subscriptionWindowMinor,
discountTotalMinor,
openingFloatMinor, openingFloatMinor,
cashAddedMinor, cashAddedMinor,
cashRemovedMinor, cashRemovedMinor,
@@ -627,6 +639,7 @@ export class ShiftService {
subscriptionTotalMinor, subscriptionTotalMinor,
subscriptionSalesMinor, subscriptionSalesMinor,
subscriptionWindowMinor, subscriptionWindowMinor,
discountTotalMinor,
openingFloatMinor, openingFloatMinor,
cashAddedMinor, cashAddedMinor,
cashRemovedMinor, cashRemovedMinor,
@@ -649,6 +662,7 @@ export class ShiftService {
subscriptionTotalMinor, subscriptionTotalMinor,
subscriptionSalesMinor, subscriptionSalesMinor,
subscriptionWindowMinor, subscriptionWindowMinor,
discountTotalMinor,
openingFloatMinor, openingFloatMinor,
cashAddedMinor, cashAddedMinor,
cashRemovedMinor, cashRemovedMinor,
@@ -692,6 +706,9 @@ export class ShiftService {
// (subscriptionSalesMinor stays in the signed payload — it's just not printed.) // (subscriptionSalesMinor stays in the signed payload — it's just not printed.)
`Abonime: ${money(r.subscriptionTotalMinor)} ${cur}`, `Abonime: ${money(r.subscriptionTotalMinor)} ${cur}`,
`Jashtë orarit: ${money(r.subscriptionWindowMinor)} ${cur}`, `Jashtë orarit: ${money(r.subscriptionWindowMinor)} ${cur}`,
// Merchant-validation leakage — printed only when the shift actually gave any
// (older slips stay byte-identical). The takings above are already NET of it.
...(r.discountTotalMinor > 0 ? [`Zbritje (validime): ${money(r.discountTotalMinor)} ${cur}`] : []),
"", "",
"-- Arka --", "-- Arka --",
`Gjëndje fillestare: ${money(r.openingFloatMinor)} ${cur}`, `Gjëndje fillestare: ${money(r.openingFloatMinor)} ${cur}`,
+88
View File
@@ -0,0 +1,88 @@
import { eq, ledgerEvents, type Db } from "@parking/db";
import type { SessionValidation, ValidationMode } from "@parking/shared";
// Merchant-validation ledger folds. A validation is a SIGNED, appended event on the
// session (never a mutable flag): payload carries the RESOLVED values (programId,
// label, mode, minutes/amountMinor/percent) + the merchant username. A validation
// event with `refId` set VOIDS the referenced one; a payment's `validationIds` marks
// which validations it CONSUMED (so an overstay's fresh period never re-applies
// them). See wiki/concepts/validation-discounts.md.
/** A validation event folded with its lifecycle state. */
export interface AppliedValidation extends SessionValidation {
readonly eventId: string;
readonly occurredAt: string;
/** The merchant username who applied it. */
readonly operator: string | null;
/** Voided by a later validation event referencing it. */
readonly voided: boolean;
/** The payment event id that consumed it, if settled. */
readonly consumedBy: string | null;
}
/** All validations ever applied to a session (newest last), with voided/consumed
* state folded from the chain. One identity-scoped ledger scan. */
export function sessionValidations(db: Db, identity: string): AppliedValidation[] {
const rows = db
.select({
id: ledgerEvents.id,
type: ledgerEvents.type,
occurredAt: ledgerEvents.occurredAt,
payload: ledgerEvents.payload,
})
.from(ledgerEvents)
.where(eq(ledgerEvents.identity, identity))
.orderBy(ledgerEvents.index)
.all();
const voided = new Set<string>();
const consumedBy = new Map<string, string>();
const applies: AppliedValidation[] = [];
for (const r of rows) {
const p = (r.payload ?? {}) as {
refId?: string;
programId?: string;
programLabel?: string;
mode?: ValidationMode;
minutes?: number;
amountMinor?: number;
percent?: number;
operator?: string;
validationIds?: string[];
};
if (r.type === "validation") {
if (p.refId) {
voided.add(p.refId);
} else if (p.programId && p.mode) {
applies.push({
eventId: r.id,
occurredAt: r.occurredAt,
programId: p.programId,
label: p.programLabel ?? p.programId,
mode: p.mode,
...(typeof p.minutes === "number" ? { minutes: p.minutes } : {}),
...(typeof p.amountMinor === "number" ? { amountMinor: p.amountMinor } : {}),
...(typeof p.percent === "number" ? { percent: p.percent } : {}),
operator: p.operator ?? null,
voided: false,
consumedBy: null,
});
}
} else if (r.type === "payment" && Array.isArray(p.validationIds)) {
for (const vid of p.validationIds) consumedBy.set(vid, r.id);
}
}
return applies.map((a) => ({
...a,
voided: voided.has(a.eventId),
consumedBy: consumedBy.get(a.eventId) ?? null,
}));
}
/** The LIVE validations for pricing: applied, not voided, not consumed by a prior
* payment. This is exactly what `priceSession(..., validations)` expects. */
export function liveValidations(db: Db, identity: string): AppliedValidation[] {
return sessionValidations(db, identity).filter((v) => !v.voided && v.consumedBy == null);
}
+34 -6
View File
@@ -18,7 +18,7 @@ import {
import { rootRoute } from "./router.js"; import { rootRoute } from "./router.js";
import { qk } from "./lib/query.js"; import { qk } from "./lib/query.js";
import { useShift } from "./lib/use-shift.js"; import { useShift } from "./lib/use-shift.js";
import { formatDuration, formatMoney, formatTime, formatRelativeDateTime } from "./lib/format.js"; import { formatDuration, formatMoney, formatRelativeDateTime } from "./lib/format.js";
import { CARD_PAYMENTS_ENABLED } from "./lib/features.js"; import { CARD_PAYMENTS_ENABLED } from "./lib/features.js";
import { SnapshotStrip } from "./ui/SnapshotStrip.js"; import { SnapshotStrip } from "./ui/SnapshotStrip.js";
import { Spinner } from "./ui/Spinner.js"; import { Spinner } from "./ui/Spinner.js";
@@ -310,12 +310,12 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
// figures, and the snapshot strip read-only. No tender / voucher / open here. // figures, and the snapshot strip read-only. No tender / voucher / open here.
<> <>
<div className="rounded-term border border-term-amber px-3 py-2 text-term-amber"> <div className="rounded-term border border-term-amber px-3 py-2 text-term-amber">
{t("pay.alreadyClosed", { time: formatTime(s.exitedAt) })} {t("pay.alreadyClosed", { time: formatRelativeDateTime(s.exitedAt, t, { seconds: true }) })}
</div> </div>
<div className="grid grid-cols-2 gap-x-6 gap-y-1 tabular-nums"> <div className="grid grid-cols-2 gap-x-6 gap-y-1 tabular-nums">
<Row label={t("pay.entry")} value={formatRelativeDateTime(s.enteredAt, t)} /> <Row label={t("pay.entry")} value={formatRelativeDateTime(s.enteredAt, t, { seconds: true })} />
<Row label={t("pay.exit")} value={formatTime(s.exitedAt)} /> <Row label={t("pay.exit")} value={formatRelativeDateTime(s.exitedAt, t, { seconds: true })} />
<Row <Row
label={t("pay.duration")} label={t("pay.duration")}
value={ value={
@@ -335,11 +335,15 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
<> <>
{/* Session figures */} {/* Session figures */}
<div className="grid grid-cols-2 gap-x-6 gap-y-1 tabular-nums"> <div className="grid grid-cols-2 gap-x-6 gap-y-1 tabular-nums">
<Row label={t("pay.entry")} value={formatRelativeDateTime(s.enteredAt, t)} /> <Row label={t("pay.entry")} value={formatRelativeDateTime(s.enteredAt, t, { seconds: true })} />
{/* Closed-within-grace shows the recorded EXIT; an open session shows now. */} {/* Closed-within-grace shows the recorded EXIT; an open session shows now. */}
<Row <Row
label={closedWithinGrace ? t("pay.exit") : t("pay.now")} label={closedWithinGrace ? t("pay.exit") : t("pay.now")}
value={closedWithinGrace ? formatTime(s.exitedAt) : formatTime(new Date().toISOString())} value={formatRelativeDateTime(
closedWithinGrace ? s.exitedAt : new Date().toISOString(),
t,
{ seconds: true },
)}
/> />
<Row <Row
label={t("pay.duration")} label={t("pay.duration")}
@@ -379,6 +383,30 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
/> />
</div> </div>
{/* Merchant validations (bar/lavazh): the gross fee + one line per
discount — the Total below is the NET the customer pays. The lines
ride the quote (SessionLookup.validationLines) and reprint on the
receipt. See wiki/concepts/validation-discounts.md. */}
{!isSubscription &&
(s.validationLines ?? []).length > 0 &&
s.currency != null &&
s.amountMinor != null && (
<div className="rounded-term bg-term-panel-2 px-3 py-2 text-[0.75rem]">
<div className="flex justify-between text-term-text">
<span>{t("val.gross")}</span>
<span className="tabular-nums">
{formatMoney(s.grossMinor ?? s.amountMinor, s.currency)}
</span>
</div>
{(s.validationLines ?? []).map((v, i) => (
<div key={i} className="flex justify-between text-term-green">
<span>{v.label}</span>
<span className="tabular-nums">−{formatMoney(v.discountMinor, s.currency!)}</span>
</div>
))}
</div>
)}
{/* Total — a subscription is prepaid (no amount) UNLESS it owes an {/* Total — a subscription is prepaid (no amount) UNLESS it owes an
out-of-window window charge; then show that amount. For an overstay the out-of-window window charge; then show that amount. For an overstay the
amount is the TOP-UP delta, not the whole stay. */} amount is the TOP-UP delta, not the whole stay. */}
+63 -3
View File
@@ -1,6 +1,16 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { fetchOccupancy, fetchSiteConfig, saveSiteConfig, type Occupancy, type SiteConfig } from "./api.js"; import {
fetchOccupancy,
fetchSiteConfig,
fetchValidationPrograms,
saveSiteConfig,
saveValidationProgram,
type Occupancy,
type SiteConfig,
type ValidationProgramView,
} from "./api.js";
import { STATIONS, ValidationStationsPanel, defaultProgram, type StationId } from "./ValidationSetup.js";
// Live occupancy + capacity + park metadata. Occupancy is shown to everyone (it's a // Live occupancy + capacity + park metadata. Occupancy is shown to everyone (it's a
// fold over the signed ledger); capacity and the metadata fields are admin-editable. // fold over the signed ledger); capacity and the metadata fields are admin-editable.
@@ -28,12 +38,21 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
const [reserveSubs, setReserveSubs] = useState(false); const [reserveSubs, setReserveSubs] = useState(false);
const [anprEntry, setAnprEntry] = useState(true); const [anprEntry, setAnprEntry] = useState(true);
const [msg, setMsg] = useState<string | null>(null); const [msg, setMsg] = useState<string | null>(null);
// Merchant-validation programs (bar / lavazh). The checkboxes below toggle a
// station's `active` (persisted at once — each flip signs a config_change); the
// right-column panel edits the enabled stations. See validation-discounts.md.
const [programs, setPrograms] = useState<ValidationProgramView[]>([]);
function reload() { function reload() {
fetchOccupancy().then(setOcc).catch(() => {}); fetchOccupancy().then(setOcc).catch(() => {});
} }
useEffect(() => { useEffect(() => {
reload(); reload();
if (canEdit) {
fetchValidationPrograms()
.then((r) => setPrograms(r.programs))
.catch(() => {});
}
fetchSiteConfig() fetchSiteConfig()
.then((c) => { .then((c) => {
setCapInput(c.capacity == null ? "" : String(c.capacity)); setCapInput(c.capacity == null ? "" : String(c.capacity));
@@ -45,7 +64,23 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
setMeta(m); setMeta(m);
}) })
.catch(() => {}); .catch(() => {});
}, []); }, [canEdit]);
/** Flip a merchant station's checkbox: persist `active` at once (a signed
* config_change server-side), creating the well-known row with comp defaults on
* the first enable. Config details are edited in the right-column panel. */
async function toggleStation(id: StationId, active: boolean) {
const existing = programs.find((p) => p.id === id);
const body = existing
? { ...existing, active }
: { ...defaultProgram(id, t(id === "bar" ? "val.enableBar" : "val.enableLavazh")), active };
try {
const saved = await saveValidationProgram(id, body);
setPrograms((ps) => [...ps.filter((p) => p.id !== id), saved]);
} catch (e) {
setMsg((e as Error).message);
}
}
async function save() { async function save() {
setMsg(null); setMsg(null);
@@ -68,7 +103,8 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
} }
return ( return (
<section className="card mt-6 max-w-md p-4"> <div className="mt-6 flex flex-wrap items-start gap-6">
<section className="card w-full max-w-md p-4">
<div className="flex flex-wrap items-center gap-1.5 text-[0.8125rem]"> <div className="flex flex-wrap items-center gap-1.5 text-[0.8125rem]">
<strong className="uppercase tracking-wider text-term-muted">{t("site.occupancy")}</strong> <strong className="uppercase tracking-wider text-term-muted">{t("site.occupancy")}</strong>
{occ == null ? ( {occ == null ? (
@@ -127,6 +163,23 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
<span className="hint block">{t("site.anprEntryHint")}</span> <span className="hint block">{t("site.anprEntryHint")}</span>
</span> </span>
</label> </label>
<div className="border-t border-term-border pt-3 text-[0.6875rem] uppercase tracking-wider text-term-muted">
{t("val.sectionTitle")}
</div>
<span className="hint -mt-2">{t("val.sectionHint")}</span>
<div className="flex gap-6">
{STATIONS.map((id) => (
<label key={id} className="flex items-center gap-2 text-[0.75rem] text-term-text">
<input
type="checkbox"
className="accent-term-amber"
checked={programs.find((p) => p.id === id)?.active ?? false}
onChange={(e) => toggleStation(id, e.target.checked)}
/>
{t(id === "bar" ? "val.enableBar" : "val.enableLavazh")}
</label>
))}
</div>
<div className="border-t border-term-border pt-3 text-[0.6875rem] uppercase tracking-wider text-term-muted"> <div className="border-t border-term-border pt-3 text-[0.6875rem] uppercase tracking-wider text-term-muted">
{t("site.parkDetails")} {t("site.parkDetails")}
</div> </div>
@@ -158,5 +211,12 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
</div> </div>
)} )}
</section> </section>
{canEdit && (
<ValidationStationsPanel
programs={programs}
onSaved={(p) => setPrograms((ps) => [...ps.filter((x) => x.id !== p.id), p])}
/>
)}
</div>
); );
} }
+252
View File
@@ -0,0 +1,252 @@
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
applyValidation,
fetchMyValidationPrograms,
fetchValidationSession,
voidValidation,
type SessionUser,
type ValidationProgramView,
type ValidationSessionView,
} from "./api.js";
import { formatDuration, formatMoney, formatRelativeDateTime } from "./lib/format.js";
// The MERCHANT screen (/validate): the bar/lavazh user's ENTIRE surface. Scan or key
// the customer's ticket → see the session (deliberately NO money data — the booth
// settles) → apply the bound program → done. Mobile-friendly: a phone/tablet on the
// site LAN, or a booth-style USB HID scanner (it types digits + Enter into the
// focused input). A mistake can be voided while UNUSED (append-only, signed).
// Gated by validation:create + the server-side program↔user binding.
// See wiki/concepts/validation-discounts.md.
type Program = Omit<ValidationProgramView, "userIds">;
/** Human line for what a program grants (the params live on the program row). */
function programSummary(p: Program, t: (k: string, o?: Record<string, unknown>) => string): string {
if (p.mode === "comp") return t("val.modeComp");
if (p.mode === "timeCredit") return `${t("val.modeTimeCredit")}: ${p.minutes ?? 0} min`;
if (p.mode === "percent") return `${t("val.modePercent")}: ${p.percent ?? 0}%`;
return t("val.modeFixed");
}
export function ValidateScreen({ user }: { user: SessionUser }) {
const { t } = useTranslation();
const [programs, setPrograms] = useState<Program[] | null>(null);
const [programId, setProgramId] = useState<string | null>(null);
const [ticket, setTicket] = useState("");
const [view, setView] = useState<ValidationSessionView | null>(null);
const [amount, setAmount] = useState("");
const [msg, setMsg] = useState<{ kind: "ok" | "err"; text: string } | null>(null);
const [busy, setBusy] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
fetchMyValidationPrograms()
.then((r) => {
setPrograms(r.programs);
if (r.programs.length === 1) setProgramId(r.programs[0]!.id);
})
.catch(() => setPrograms([]));
inputRef.current?.focus();
}, []);
const program = programs?.find((p) => p.id === programId) ?? null;
async function lookup(id?: string) {
const identity = (id ?? ticket).trim();
if (!identity) return;
setMsg(null);
try {
setView(await fetchValidationSession(identity));
} catch (e) {
setMsg({ kind: "err", text: (e as Error).message });
}
}
async function apply() {
if (!view || !program) return;
setBusy(true);
setMsg(null);
try {
const body: { identity: string; programId: string; amountMinor?: number } = {
identity: view.identity,
programId: program.id,
};
if (program.mode === "fixed") {
const n = Number(amount);
body.amountMinor = Number.isFinite(n) ? Math.round(n * 100) : 0;
}
await applyValidation(body);
setMsg({ kind: "ok", text: t("val.applied") });
setAmount("");
await lookup(view.identity);
} catch (e) {
setMsg({ kind: "err", text: (e as Error).message });
} finally {
setBusy(false);
}
}
async function voidOne(eventId: string) {
if (!view) return;
if (!window.confirm(t("val.confirmVoid"))) return;
setMsg(null);
try {
await voidValidation({ eventId, identity: view.identity });
await lookup(view.identity);
} catch (e) {
setMsg({ kind: "err", text: (e as Error).message });
}
}
// The session's blocking condition, if any (not found / closed / subscriber).
const blocked =
view == null
? null
: !view.found
? t("val.notFound")
: view.subscription
? t("val.subscription")
: !view.open
? t("val.closed")
: null;
const alreadyApplied =
view != null &&
program != null &&
view.validations.some((v) => v.programId === program.id && !v.voided && v.consumedBy == null);
const fixedAmountOk =
program?.mode !== "fixed" ||
(Number(amount) > 0 &&
(program.maxAmountMinor == null || Math.round(Number(amount) * 100) <= program.maxAmountMinor));
return (
<div className="mx-auto mt-6 w-full max-w-md">
<section className="card p-4">
<div className="text-[0.6875rem] uppercase tracking-wider text-term-muted">{t("val.title")}</div>
{programs != null && programs.length === 0 && (
<p className="mt-3 text-[0.8125rem] text-term-red">{t("val.noPrograms")}</p>
)}
{programs != null && programs.length > 1 && (
<div className="mt-3 flex gap-1">
{programs.map((p) => (
<button
key={p.id}
type="button"
className={`btn btn-sm ${p.id === programId ? "btn-primary" : "btn-ghost"}`}
onClick={() => setProgramId(p.id)}
>
{p.name}
</button>
))}
</div>
)}
{program && <p className="mt-1 text-[0.75rem] text-term-muted">{program.name} — {programSummary(program, t)}</p>}
<form
className="mt-3 flex gap-2"
onSubmit={(e) => {
e.preventDefault();
void lookup();
}}
>
<input
ref={inputRef}
className="input flex-1 tabular-nums"
inputMode="numeric"
value={ticket}
onChange={(e) => setTicket(e.target.value)}
placeholder={t("val.scanPrompt")}
/>
<button type="submit" className="btn btn-primary btn-sm">{t("val.lookup")}</button>
</form>
{msg && (
<p className={`mt-2 text-[0.8125rem] ${msg.kind === "ok" ? "text-term-green" : "text-term-red"}`}>
{msg.text}
</p>
)}
{view && (
<div className="mt-3 border-t border-term-border pt-3">
{blocked ? (
<p className="text-[0.8125rem] text-term-red">{blocked}</p>
) : (
<>
<div className="flex items-baseline justify-between text-[0.8125rem]">
<span className="font-semibold tabular-nums text-term-text">{view.identity}</span>
<span className="text-term-muted">
{t("val.entry")} {formatRelativeDateTime(view.enteredAt, t)}
{view.enteredAt && <> · {formatDuration(view.enteredAt, new Date().toISOString())}</>}
</span>
</div>
{program && !alreadyApplied && (
<div className="mt-3 grid gap-2">
{program.mode === "fixed" && (
<div className="field">
<span className="label">
{t("val.amountLabel")}
{program.maxAmountMinor != null && (
<span className="hint ml-2">
{t("val.amountHint", { max: formatMoney(program.maxAmountMinor, "") })}
</span>
)}
</span>
<input
className="input w-40 tabular-nums"
inputMode="decimal"
value={amount}
onChange={(e) => setAmount(e.target.value)}
placeholder="300"
/>
</div>
)}
<button
type="button"
className="btn btn-primary"
disabled={busy || !fixedAmountOk}
onClick={apply}
>
{t("val.apply")}
</button>
</div>
)}
{view.validations.length > 0 && (
<div className="mt-3">
<div className="label">{t("val.existing")}</div>
<ul className="mt-1 grid gap-1">
{view.validations.map((v) => (
<li key={v.eventId} className="flex items-center gap-2 text-[0.75rem] text-term-text">
<span>{v.label}</span>
{v.amountMinor != null && <span className="tabular-nums">−{formatMoney(v.amountMinor, "")}</span>}
{v.minutes != null && <span>{v.minutes} min</span>}
{v.percent != null && <span>{v.percent}%</span>}
{v.voided ? (
<span className="text-term-muted">({t("val.voided")})</span>
) : v.consumedBy != null ? (
<span className="text-term-muted">({t("val.used")})</span>
) : (
v.operator === user.username && (
<button type="button" className="btn btn-ghost btn-sm ml-auto" onClick={() => voidOne(v.eventId)}>
{t("val.void")}
</button>
)
)}
</li>
))}
</ul>
</div>
)}
</>
)}
</div>
)}
</section>
</div>
);
}
+231
View File
@@ -0,0 +1,231 @@
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import {
fetchUsers,
saveValidationProgram,
type ManagedUser,
type ValidationMode,
type ValidationProgramView,
} from "./api.js";
// The /setup/site RIGHT panel: per-station merchant-validation config (Bar / Lavazh).
// The checkboxes on the left card toggle a station's `active`; this panel edits the
// enabled stations' programs — one panel, tabs when both are on. Storage is generic
// (validation_programs rows keyed "bar"/"lavazh"); the UI is deliberately these two
// fixed stations. Amounts are entered in MAJOR units and stored in integer minor
// units (the tariff-composer convention). See wiki/concepts/validation-discounts.md.
/** The two well-known stations the checkboxes toggle. */
export const STATIONS = ["bar", "lavazh"] as const;
export type StationId = (typeof STATIONS)[number];
/** A blank program draft for a station enabled for the first time. */
export function defaultProgram(id: StationId, label: string): Omit<ValidationProgramView, "id"> {
return {
name: label,
mode: "comp",
minutes: null,
percent: null,
maxAmountMinor: null,
maxPerDay: null,
active: true,
userIds: [],
};
}
const toMinor = (s: string): number | null => {
const v = s.trim();
if (v === "") return null;
const n = Number(v);
return Number.isFinite(n) && n > 0 ? Math.round(n * 100) : null;
};
const fromMinor = (m: number | null): string => (m == null ? "" : String(m / 100));
const toInt = (s: string): number | null => {
const v = s.trim();
if (v === "") return null;
const n = Number(v);
return Number.isInteger(n) && n > 0 ? n : null;
};
function StationForm({
program,
onSaved,
}: {
program: ValidationProgramView;
onSaved: (p: ValidationProgramView) => void;
}) {
const { t } = useTranslation();
const [name, setName] = useState(program.name);
const [mode, setMode] = useState<ValidationMode>(program.mode);
const [minutes, setMinutes] = useState(program.minutes == null ? "" : String(program.minutes));
const [percent, setPercent] = useState(program.percent == null ? "" : String(program.percent));
const [maxAmount, setMaxAmount] = useState(fromMinor(program.maxAmountMinor));
const [maxPerDay, setMaxPerDay] = useState(program.maxPerDay == null ? "" : String(program.maxPerDay));
const [userIds, setUserIds] = useState<Set<string>>(new Set(program.userIds));
const [users, setUsers] = useState<ManagedUser[] | null>(null);
const [msg, setMsg] = useState<string | null>(null);
// Reset the form when the tab switches to another station.
useEffect(() => {
setName(program.name);
setMode(program.mode);
setMinutes(program.minutes == null ? "" : String(program.minutes));
setPercent(program.percent == null ? "" : String(program.percent));
setMaxAmount(fromMinor(program.maxAmountMinor));
setMaxPerDay(program.maxPerDay == null ? "" : String(program.maxPerDay));
setUserIds(new Set(program.userIds));
setMsg(null);
}, [program.id]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
fetchUsers()
.then((r) => setUsers(r.users))
.catch(() => setUsers([]));
}, []);
const valid = useMemo(() => {
if (!name.trim()) return false;
if (mode === "timeCredit") return toInt(minutes) != null;
if (mode === "percent") {
const p = toInt(percent);
return p != null && p <= 100;
}
if (mode === "fixed") return toMinor(maxAmount) != null;
return true;
}, [name, mode, minutes, percent, maxAmount]);
async function save() {
setMsg(null);
try {
const saved = await saveValidationProgram(program.id, {
name: name.trim(),
mode,
minutes: mode === "timeCredit" ? toInt(minutes) : null,
percent: mode === "percent" ? toInt(percent) : null,
maxAmountMinor: mode === "fixed" ? toMinor(maxAmount) : null,
maxPerDay: toInt(maxPerDay),
active: program.active,
userIds: [...userIds],
});
onSaved(saved);
setMsg(t("val.saved"));
} catch (e) {
setMsg((e as Error).message);
}
}
const toggleUser = (id: string) =>
setUserIds((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
return (
<div className="mt-3 grid gap-3">
<div className="field">
<span className="label">{t("val.labelName")}</span>
<input className="input" value={name} onChange={(e) => setName(e.target.value)} placeholder={t("val.labelNamePh")} />
</div>
<div className="field">
<span className="label">{t("val.mode")}</span>
<select className="input w-fit" value={mode} onChange={(e) => setMode(e.target.value as ValidationMode)}>
<option value="comp">{t("val.modeComp")}</option>
<option value="timeCredit">{t("val.modeTimeCredit")}</option>
<option value="fixed">{t("val.modeFixed")}</option>
<option value="percent">{t("val.modePercent")}</option>
</select>
</div>
{mode === "timeCredit" && (
<div className="field">
<span className="label">{t("val.minutes")}</span>
<input className="input w-32" value={minutes} onChange={(e) => setMinutes(e.target.value)} placeholder="60" />
</div>
)}
{mode === "percent" && (
<div className="field">
<span className="label">{t("val.percent")}</span>
<input className="input w-32" value={percent} onChange={(e) => setPercent(e.target.value)} placeholder="100" />
</div>
)}
{mode === "fixed" && (
<div className="field">
<span className="label">{t("val.maxAmount")}</span>
<input className="input w-32" value={maxAmount} onChange={(e) => setMaxAmount(e.target.value)} placeholder="1000" />
</div>
)}
<div className="field">
<span className="label">{t("val.maxPerDay")}</span>
<input className="input w-32" value={maxPerDay} onChange={(e) => setMaxPerDay(e.target.value)} />
</div>
<div>
<div className="label">{t("val.users")}</div>
<span className="hint block">{t("val.usersHint")}</span>
<div className="mt-1 grid gap-1">
{users == null ? (
<span className="text-term-muted">…</span>
) : users.length === 0 ? (
<span className="text-[0.75rem] text-term-muted">{t("val.noUsers")}</span>
) : (
users.map((u) => (
<label key={u.id} className="flex items-center gap-2 text-[0.75rem] text-term-text">
<input
type="checkbox"
className="accent-term-amber"
checked={userIds.has(u.id)}
onChange={() => toggleUser(u.id)}
/>
{u.username}
{u.fullName && <span className="text-term-muted">({u.fullName})</span>}
</label>
))
)}
</div>
</div>
<div className="flex items-center gap-3">
<button type="button" className="btn btn-primary btn-sm" disabled={!valid} onClick={save}>
{t("site.save")}
</button>
{msg && <span className="text-[0.75rem] text-term-muted">{msg}</span>}
</div>
</div>
);
}
/** The right-column panel: tabs across the ENABLED stations, one form each. */
export function ValidationStationsPanel({
programs,
onSaved,
}: {
programs: ValidationProgramView[];
onSaved: (p: ValidationProgramView) => void;
}) {
const { t } = useTranslation();
const enabled = STATIONS.map((id) => programs.find((p) => p.id === id)).filter(
(p): p is ValidationProgramView => p != null && p.active,
);
const [tab, setTab] = useState<string | null>(null);
const current = enabled.find((p) => p.id === tab) ?? enabled[0];
if (!current) return null;
return (
<section className="card w-full max-w-md p-4">
<div className="text-[0.6875rem] uppercase tracking-wider text-term-muted">{t("val.sectionTitle")}</div>
{enabled.length > 1 && (
<div className="mt-2 flex gap-1">
{enabled.map((p) => (
<button
key={p.id}
type="button"
className={`btn btn-sm ${p.id === current.id ? "btn-primary" : "btn-ghost"}`}
onClick={() => setTab(p.id)}
>
{t(p.id === "bar" ? "val.enableBar" : "val.enableLavazh")}
</button>
))}
</div>
)}
<StationForm program={current} onSaved={onSaved} />
</section>
);
}
+91 -1
View File
@@ -7,7 +7,7 @@
import { logFailedRequest } from "./lib/logger.js"; import { logFailedRequest } from "./lib/logger.js";
import { apiUrl } from "./lib/origin.js"; import { apiUrl } from "./lib/origin.js";
import type { AppLogRecord } from "@parking/shared"; import type { AppLogRecord, ValidationLine, ValidationMode } from "@parking/shared";
const CSRF_COOKIE = "parking_csrf"; const CSRF_COOKIE = "parking_csrf";
const CSRF_HEADER = "X-CSRF-Token"; const CSRF_HEADER = "X-CSRF-Token";
@@ -1301,6 +1301,11 @@ export interface SessionLookup {
subscriptionHolder: string | null; subscriptionHolder: string | null;
/** Advisory licence plate recognized for this session (ANPR). Null when none. */ /** Advisory licence plate recognized for this session (ANPR). Null when none. */
plate: string | null; plate: string | null;
/** Merchant validations folded into `amountMinor` (which is NET): pre-discount fee,
* total taken off, and the per-validation lines. See validation-discounts.md. */
grossMinor: number | null;
discountMinor: number | null;
validationLines: ValidationLine[];
} }
/** 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). */
@@ -1475,3 +1480,88 @@ export function updatePresenceBypass(patch: { radar?: boolean; camera?: boolean
export function setCapacity(capacity: number | null): Promise<SiteConfig> { export function setCapacity(capacity: number | null): Promise<SiteConfig> {
return saveSiteConfig({ capacity }); return saveSiteConfig({ capacity });
} }
// --- Merchant validations (bar / lavazh) -----------------------------------
// The merchant is VALIDATION-ONLY: they scan the ticket on their device and apply
// their program; the booth settles NET of the applied validations and prints the
// detailed receipt. Program config lives on /setup/site. See validation-discounts.md.
export type { ValidationLine, ValidationMode } from "@parking/shared";
/** An admin-composed program (mirrors the server row + its bound users). */
export interface ValidationProgramView {
id: string;
name: string;
mode: ValidationMode;
minutes: number | null;
percent: number | null;
maxAmountMinor: number | null;
maxPerDay: number | null;
active: boolean;
userIds: string[];
}
/** A validation applied to a session, with its lifecycle state. */
export interface AppliedValidationView {
eventId: string;
occurredAt: string;
programId: string;
label: string;
mode: ValidationMode;
minutes?: number;
amountMinor?: number;
percent?: number;
operator: string | null;
voided: boolean;
consumedBy: string | null;
}
/** The merchant screen's minimal session view — deliberately no money data. */
export interface ValidationSessionView {
identity: string;
found: boolean;
open: boolean;
enteredAt: string | null;
subscription: boolean;
validations: AppliedValidationView[];
}
/** All programs + bound users (the /setup/site panel). site:read. */
export function fetchValidationPrograms(): Promise<{ programs: ValidationProgramView[] }> {
return apiFetch("/api/validation/programs");
}
/** Upsert a program's config + binding set (site:update; signs a config_change). */
export function saveValidationProgram(
id: string,
body: Omit<ValidationProgramView, "id">,
): Promise<ValidationProgramView> {
return apiFetch(`/api/validation/programs/${encodeURIComponent(id)}`, {
method: "PUT",
body: JSON.stringify(body),
});
}
/** MY bound, active programs (the merchant screen). validation:create. */
export function fetchMyValidationPrograms(): Promise<{ programs: Omit<ValidationProgramView, "userIds">[] }> {
return apiFetch("/api/validation/mine");
}
/** Merchant lookup of a scanned ticket (no money data). validation:create. */
export function fetchValidationSession(identity: string): Promise<ValidationSessionView> {
return apiFetch(`/api/validation/session/${encodeURIComponent(identity)}`);
}
/** Apply my program to a ticket (signed, attributed). `amountMinor` only for fixed mode. */
export function applyValidation(body: {
identity: string;
programId: string;
amountMinor?: number;
}): Promise<{ ok: true; eventId: string; label: string }> {
return apiFetch("/api/validation/apply", { method: "POST", body: JSON.stringify(body) });
}
/** Void my own UNUSED validation (append-only correction). */
export function voidValidation(body: { eventId: string; identity: string }): Promise<{ ok: true }> {
return apiFetch("/api/validation/void", { method: "POST", body: JSON.stringify(body) });
}
+7 -11
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { formatMoney, formatDuration, formatTime, formatRelativeDateTime, type TFn } from "./format.js"; import { formatMoney, formatDuration, formatRelativeDateTime, type TFn } from "./format.js";
// The booth's display formatters. Money is integer MINOR units (never a float, matching // The booth's display formatters. Money is integer MINOR units (never a float, matching
// the ledger/tariff model); duration is whole minutes; relative dates drive the session/ // the ledger/tariff model); duration is whole minutes; relative dates drive the session/
@@ -35,16 +35,6 @@ describe("formatDuration", () => {
}); });
}); });
describe("formatTime", () => {
it("returns an em dash for null/invalid", () => {
expect(formatTime(null)).toBe("—");
expect(formatTime("not-a-date")).toBe("—");
});
it("renders HH:MM:SS local time", () => {
expect(formatTime("2026-06-21T10:48:25.000Z")).toMatch(/^\d{2}:\d{2}:\d{2}$/);
});
});
describe("formatRelativeDateTime", () => { describe("formatRelativeDateTime", () => {
// A tiny fake t(): today/yesterday words + the month-name array. // A tiny fake t(): today/yesterday words + the month-name array.
const months = ["Jan","Shkurt","Mars","Prill","Maj","Qershor","Korrik","Gusht","Sht","Tet","Nën","Dhj"]; const months = ["Jan","Shkurt","Mars","Prill","Maj","Qershor","Korrik","Gusht","Sht","Tet","Nën","Dhj"];
@@ -61,6 +51,12 @@ describe("formatRelativeDateTime", () => {
expect(formatRelativeDateTime(now.toISOString(), t)).toMatch(/^Sot \d{2}:\d{2}$/); expect(formatRelativeDateTime(now.toISOString(), t)).toMatch(/^Sot \d{2}:\d{2}$/);
}); });
it("appends :ss with the seconds option (entry/exit rows read alike)", () => {
const now = new Date();
now.setHours(19, 25, 44, 0);
expect(formatRelativeDateTime(now.toISOString(), t, { seconds: true })).toMatch(/^Sot \d{2}:\d{2}:44$/);
});
it("labels yesterday with the localized word", () => { it("labels yesterday with the localized word", () => {
const y = new Date(); const y = new Date();
y.setDate(y.getDate() - 1); y.setDate(y.getDate() - 1);
+13 -16
View File
@@ -46,13 +46,6 @@ export function formatMinutes(mins: number): string {
return h > 0 ? `${h}h ${m % 60}m` : `${m}m`; return h > 0 ? `${h}h ${m % 60}m` : `${m}m`;
} }
/** Local time-of-day HH:MM:SS from an ISO string. */
export function formatTime(iso: string | null): string {
if (!iso) return "—";
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? "—" : d.toTimeString().slice(0, 8);
}
/** Calendar-day difference (local) between two dates: 0 = same day, 1 = d is one day /** Calendar-day difference (local) between two dates: 0 = same day, 1 = d is one day
* before ref, etc. Compares date parts only (ignores time-of-day). */ * before ref, etc. Compares date parts only (ignores time-of-day). */
function dayDiff(d: Date, ref: Date): number { function dayDiff(d: Date, ref: Date): number {
@@ -61,10 +54,11 @@ function dayDiff(d: Date, ref: Date): number {
return Math.round((b.getTime() - a.getTime()) / 86_400_000); return Math.round((b.getTime() - a.getTime()) / 86_400_000);
} }
/** HH:MM (local, 24h) for the relative-day labels. */ /** HH:MM (local, 24h) for the relative-day labels; ":ss" appended when `seconds`. */
function hhmm(d: Date): string { function hhmm(d: Date, seconds = false): string {
const p = (n: number) => String(n).padStart(2, "0"); const p = (n: number) => String(n).padStart(2, "0");
return `${p(d.getHours())}:${p(d.getMinutes())}`; const base = `${p(d.getHours())}:${p(d.getMinutes())}`;
return seconds ? `${base}:${p(d.getSeconds())}` : base;
} }
/** Minimal shape of i18next's `t` that we rely on: a string lookup, plus the /** Minimal shape of i18next's `t` that we rely on: a string lookup, plus the
@@ -118,8 +112,7 @@ export function formatDateTime(iso: string | null, t: TFn, opts?: { seconds?: bo
if (!iso) return "—"; if (!iso) return "—";
const d = new Date(iso); const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "—"; if (Number.isNaN(d.getTime())) return "—";
const sec = opts?.seconds ? `:${String(d.getSeconds()).padStart(2, "0")}` : ""; return `${formatDate(iso, t)} ${hhmm(d, opts?.seconds)}`;
return `${formatDate(iso, t)} ${hhmm(d)}${sec}`;
} }
/** /**
@@ -130,14 +123,18 @@ export function formatDateTime(iso: string | null, t: TFn, opts?: { seconds?: bo
* *
* `t` supplies the today/yesterday words AND the month names (the appliance browser * `t` supplies the today/yesterday words AND the month names (the appliance browser
* may lack Albanian Intl data, so month names come from the catalog, not Intl). * may lack Albanian Intl data, so month names come from the catalog, not Intl).
*
* `seconds` appends ":ss" — use it where a timestamp sits next to another that shows
* seconds (e.g. the booth pay modal's entry vs. exit rows), so the two read alike.
*/ */
export function formatRelativeDateTime(iso: string | null, t: TFn): string { export function formatRelativeDateTime(iso: string | null, t: TFn, opts?: { seconds?: boolean }): string {
if (!iso) return "—"; if (!iso) return "—";
const d = new Date(iso); const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "—"; if (Number.isNaN(d.getTime())) return "—";
const time = hhmm(d, opts?.seconds);
const diff = dayDiff(d, new Date()); const diff = dayDiff(d, new Date());
if (diff === 0) return `${t("common.today")} ${hhmm(d)}`; if (diff === 0) return `${t("common.today")} ${time}`;
if (diff === 1) return `${t("common.yesterday")} ${hhmm(d)}`; if (diff === 1) return `${t("common.yesterday")} ${time}`;
// Older (or future): "17 Qer 10:48" — the short-month standard, year only if it differs. // Older (or future): "17 Qer 10:48" — the short-month standard, year only if it differs.
return `${formatDate(iso, t)} ${hhmm(d)}`; return `${formatDate(iso, t)} ${time}`;
} }
+47
View File
@@ -65,6 +65,7 @@ export const en: Catalog = {
logs: "Logs", logs: "Logs",
backup: "Backup", backup: "Backup",
profile: "Profile", profile: "Profile",
validate: "Validations",
}, },
drawer: { drawer: {
stateTitle: "Drawer now", stateTitle: "Drawer now",
@@ -230,6 +231,7 @@ export const en: Catalog = {
evtCashOut: "PAY-OUT", evtCashOut: "PAY-OUT",
evtCashReview: "REVIEW", evtCashReview: "REVIEW",
evtConfigChange: "CONFIG", evtConfigChange: "CONFIG",
evtValidation: "VALIDATION",
decision: { authorize: "authorized", deny: "denied" }, decision: { authorize: "authorized", deny: "denied" },
evtAnomaly: "ANOMALY", evtAnomaly: "ANOMALY",
evtRefused: "REFUSED", evtRefused: "REFUSED",
@@ -735,6 +737,51 @@ export const en: Catalog = {
fieldPhone: "Phone", fieldPhone: "Phone",
fieldEmail: "Email", fieldEmail: "Email",
}, },
// Merchant validations (bar / lavazh) — the /setup/site panel, the merchant's
// /validate screen, and the booth-modal discount lines. See validation-discounts.md.
val: {
// /setup/site
sectionTitle: "Merchant validations",
sectionHint: "An in-park merchant (bar / car-wash) scans the customer's ticket and grants a parking discount — payment and the receipt always stay at the booth.",
enableBar: "Bar",
enableLavazh: "Car wash",
labelName: "Receipt label",
labelNamePh: "e.g. Car wash — first hour free",
mode: "Discount type",
modeComp: "Parking fully free",
modeTimeCredit: "First minutes free",
modeFixed: "Amount off (typed at scan)",
modePercent: "Percent off",
minutes: "Free minutes",
percent: "Percent (%)",
maxAmount: "Cap per validation",
maxPerDay: "Max validations per day (blank = unlimited)",
users: "Validating users",
usersHint: "Only the selected users (whose role grants validation:create) can apply this program from their device.",
noUsers: "No users in the system — create one under Users.",
saved: "Saved.",
// /validate (the merchant screen)
title: "Ticket validation",
scanPrompt: "Scan or type the ticket number",
lookup: "Look up",
entry: "Entry:",
notFound: "No ticket found with this number.",
closed: "The ticket is closed (exited or voided).",
subscription: "This is a subscriber entry — not validatable.",
amountLabel: "Discount amount",
amountHint: "max {{max}}",
apply: "Apply validation",
applied: "Validation applied.",
existing: "Validations on this ticket",
voided: "voided",
used: "used in a payment",
void: "Void",
confirmVoid: "Void this validation?",
noPrograms: "You have no validation program bound to you — contact the administrator.",
// booth pay modal / receipts
gross: "Fee",
discount: "Discount",
},
users: { users: {
title: "Users", title: "Users",
add: "+ Add user", add: "+ Add user",
+47
View File
@@ -68,6 +68,7 @@ export const sq = {
logs: "Loget", logs: "Loget",
backup: "Kopje rezervë", backup: "Kopje rezervë",
profile: "Profili", profile: "Profili",
validate: "Validime",
}, },
drawer: { drawer: {
stateTitle: "Arka tani", stateTitle: "Arka tani",
@@ -235,6 +236,7 @@ export const sq = {
evtCashOut: "PAGESË", evtCashOut: "PAGESË",
evtCashReview: "SHQYRTIM", evtCashReview: "SHQYRTIM",
evtConfigChange: "KONFIG", evtConfigChange: "KONFIG",
evtValidation: "VALIDIM",
decision: { authorize: "autorizuar", deny: "refuzuar" }, decision: { authorize: "autorizuar", deny: "refuzuar" },
evtAnomaly: "ANOMALI", evtAnomaly: "ANOMALI",
evtRefused: "REFUZUAR", evtRefused: "REFUZUAR",
@@ -748,6 +750,51 @@ export const sq = {
fieldPhone: "Telefoni", fieldPhone: "Telefoni",
fieldEmail: "Email", fieldEmail: "Email",
}, },
// Merchant validations (bar / lavazh) — the /setup/site panel, the merchant's
// /validate screen, and the booth-modal discount lines. See validation-discounts.md.
val: {
// /setup/site
sectionTitle: "Validime tregtare",
sectionHint: "Shërbime të tjera brenda parkut (bar / lavazh) skanojnë biletën e hyrjes dhe bëjnë zbritje — pagesa dhe fatura bëhen në kabinë.",
enableBar: "Bar",
enableLavazh: "Lavazh",
labelName: "Etiketa në faturë",
labelNamePh: "p.sh. Lavazh — 1 orë falas",
mode: "Lloji i zbritjes",
modeComp: "Parkimi falas plotësisht",
modeTimeCredit: "Minutat e para falas",
modeFixed: "Zbritje shume (shkruhet në skanim)",
modePercent: "Zbritje në përqindje",
minutes: "Minuta falas",
percent: "Përqindja (%)",
maxAmount: "Tavani i zbritjes për validim",
maxPerDay: "Maks. validime në ditë (bosh = pa kufi)",
users: "Përdoruesit që validojnë",
usersHint: "Vetëm përdoruesit e zgjedhur (me lejen validation:create në rolin e tyre) mund të aplikojnë këtë program nga pajisja e tyre.",
noUsers: "Asnjë përdorues në sistem — krijojeni te Përdoruesit.",
saved: "U ruajt.",
// /validate (the merchant screen)
title: "Validim biletash",
scanPrompt: "Skanoni ose shkruani numrin e biletës",
lookup: "Kërko",
entry: "Hyrja:",
notFound: "Nuk u gjet biletë me këtë numër.",
closed: "Bileta është e mbyllur (ka dalë ose është anuluar).",
subscription: "Kjo është hyrje abonenti — nuk validohet.",
amountLabel: "Shuma e zbritjes",
amountHint: "maks. {{max}}",
apply: "Apliko validimin",
applied: "Validimi u aplikua.",
existing: "Validime në këtë biletë",
voided: "anuluar",
used: "përdorur në pagesë",
void: "Anulo",
confirmVoid: "Të anulohet ky validim?",
noPrograms: "Nuk keni asnjë program validimi të lidhur me ju — kontaktoni administratorin.",
// booth pay modal / receipts
gross: "Tarifa",
discount: "Zbritje",
},
users: { users: {
title: "Përdoruesit", title: "Përdoruesit",
add: "+ Shto përdorues", add: "+ Shto përdorues",
+27 -3
View File
@@ -46,6 +46,7 @@ import { DrawerManager } from "./DrawerManager.js";
import { CARD_PAYMENTS_ENABLED } from "./lib/features.js"; import { CARD_PAYMENTS_ENABLED } from "./lib/features.js";
import { LogsViewer } from "./LogsViewer.js"; import { LogsViewer } from "./LogsViewer.js";
import { BackupSettings } from "./BackupSettings.js"; import { BackupSettings } from "./BackupSettings.js";
import { ValidateScreen } from "./ValidateScreen.js";
import { RecycleBin } from "./RecycleBin.js"; import { RecycleBin } from "./RecycleBin.js";
import { Profile } from "./Profile.js"; import { Profile } from "./Profile.js";
// Reports pulls in Recharts (~heavy) — lazy-loaded so it stays OUT of the booth's // Reports pulls in Recharts (~heavy) — lazy-loaded so it stays OUT of the booth's
@@ -458,8 +459,11 @@ function RootLayout() {
<header className="flex items-center gap-4 border-b border-term-border bg-term-panel px-4 py-2"> <header className="flex items-center gap-4 border-b border-term-border bg-term-panel px-4 py-2">
<span className="text-sm font-bold uppercase tracking-widest text-term-amber">▮ Parking</span> <span className="text-sm font-bold uppercase tracking-widest text-term-amber">▮ Parking</span>
<nav className="flex items-center gap-1"> <nav className="flex items-center gap-1">
<NavLink to="/booth" label={t("nav.booth")} /> {show("session:read") && <NavLink to="/booth" label={t("nav.booth")} />}
<NavLink to="/shifts" label={t("nav.shifts")} /> {show("shift:read") && <NavLink to="/shifts" label={t("nav.shifts")} />}
{/* The merchant's (bar/lavazh) scan-and-validate screen. Their typical role
grants ONLY validation:create, so this is often their whole nav. */}
{show("validation:create") && <NavLink to="/validate" label={t("nav.validate")} />}
{/* Drawer — record cash movements (operator) / review them (admin). Shown if the {/* Drawer — record cash movements (operator) / review them (admin). Shown if the
user can do either. See wiki/concepts/shift.md. */} user can do either. See wiki/concepts/shift.md. */}
{(show("drawer:create") || show("drawer:review")) && ( {(show("drawer:create") || show("drawer:review")) && (
@@ -523,7 +527,12 @@ function RootLayout() {
const indexRoute = createRoute({ const indexRoute = createRoute({
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
path: "/", path: "/",
beforeLoad: () => { beforeLoad: ({ context }) => {
// A merchant-only user (validation:create without the booth's session:read)
// lands on their scan-and-validate screen; everyone else on the booth.
if (can(context.user, "validation:create") && !can(context.user, "session:read")) {
throw redirect({ to: "/validate" });
}
throw redirect({ to: "/booth" }); throw redirect({ to: "/booth" });
}, },
}); });
@@ -534,6 +543,20 @@ const boothRoute = createRoute({
component: BoothScreen, component: BoothScreen,
}); });
// The merchant (bar/lavazh) scan-and-validate screen — usually the ONLY page a
// merchant user's role can reach. The server enforces the program↔user binding on
// apply; this gate is defence in depth. See wiki/concepts/validation-discounts.md.
const validateRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/validate",
beforeLoad: ({ context }) => requirePerm("validation:create")(context),
component: function ValidateRoute() {
const { user } = rootRoute.useRouteContext();
if (!user) return null;
return <ValidateScreen user={user} />;
},
});
// Back-compat redirects for paths that moved. Most config screens live under /setup; // Back-compat redirects for paths that moved. Most config screens live under /setup;
// Subscriptions/Plans/Tariff-Lab were promoted OUT of /setup into the standalone // Subscriptions/Plans/Tariff-Lab were promoted OUT of /setup into the standalone
// /subscriptions section (2026-06-21) — redirect the old /setup/* paths too so existing // /subscriptions section (2026-06-21) — redirect the old /setup/* paths too so existing
@@ -779,6 +802,7 @@ const profileRoute = createRoute({
const routeTree = rootRoute.addChildren([ const routeTree = rootRoute.addChildren([
indexRoute, indexRoute,
boothRoute, boothRoute,
validateRoute,
...legacyRedirects, ...legacyRedirects,
profileRoute, profileRoute,
shiftRoute, shiftRoute,
+1
View File
@@ -25,6 +25,7 @@ export const EVENT_STYLE: Record<string, { labelKey: string; color: string }> =
cash_out: { labelKey: "booth.evtCashOut", color: "text-term-amber" }, cash_out: { labelKey: "booth.evtCashOut", color: "text-term-amber" },
cash_review: { labelKey: "booth.evtCashReview", color: "text-term-cyan" }, cash_review: { labelKey: "booth.evtCashReview", color: "text-term-cyan" },
config_change: { labelKey: "booth.evtConfigChange", color: "text-term-amber" }, config_change: { labelKey: "booth.evtConfigChange", color: "text-term-amber" },
validation: { labelKey: "booth.evtValidation", color: "text-term-green" },
anomaly: { labelKey: "booth.evtAnomaly", color: "text-term-red" }, anomaly: { labelKey: "booth.evtAnomaly", color: "text-term-red" },
}; };
+1 -1
View File
@@ -85,7 +85,7 @@ REGISTRY=git.infra.msai.al/mca/parking_solution
# Staging booth: pinned immutable stage-<sha>. After each promotion (merge dev → stage, CI builds # Staging booth: pinned immutable stage-<sha>. After each promotion (merge dev → stage, CI builds
# :stage-<sha>), bump this to the new sha and re-sync/deploy from Core. The moving `:stage` tag # :stage-<sha>), bump this to the new sha and re-sync/deploy from Core. The moving `:stage` tag
# exists as the pointer; we deploy the sha, not the mover. # exists as the pointer; we deploy the sha, not the mover.
TAG=stage-6ceaadf TAG=stage-22544ec
COOKIE_SECURE=0 COOKIE_SECURE=0
VISION_ENABLED=1 VISION_ENABLED=1
WS_ALLOWED_ORIGINS= WS_ALLOWED_ORIGINS=
@@ -0,0 +1,31 @@
-- Merchant validation programs (2026-07-13). In-park merchants (bar / lavazh) validate a
-- customer's ticket so the BOOTH settlement discounts the fee — the merchant only
-- validates, all money and paper stay at the booth. The /setup/site checkboxes toggle the
-- WELL-KNOWN rows ("bar", "lavazh"); a future merchant is a new row, not a migration.
-- Config is plainly MUTABLE (no versioning): the applied validation is a signed ledger
-- event carrying the RESOLVED values, so reproducibility never depends on these rows.
-- See wiki/concepts/validation-discounts.md.
CREATE TABLE `validation_programs` (
`id` text PRIMARY KEY NOT NULL,
`name` text NOT NULL,
`mode` text DEFAULT 'comp' NOT NULL,
`minutes` integer,
`percent` integer,
`max_amount_minor` integer,
`max_per_day` integer,
`active` integer DEFAULT 0 NOT NULL,
`created_at` text DEFAULT (current_timestamp) NOT NULL,
`deleted_at` text,
`deleted_by` text
);
--> statement-breakpoint
-- WHICH users may apply a program: the apply guard is `validation:create` AND a binding
-- row here — a bar user can never apply the lavazh program.
CREATE TABLE `validation_program_users` (
`program_id` text NOT NULL,
`user_id` text NOT NULL,
FOREIGN KEY (`program_id`) REFERENCES `validation_programs`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE UNIQUE INDEX `validation_program_users_program_id_user_id_unique` ON `validation_program_users` (`program_id`,`user_id`);
+7
View File
@@ -169,6 +169,13 @@
"when": 1781886600000, "when": 1781886600000,
"tag": "0023_driver_id_escpos", "tag": "0023_driver_id_escpos",
"breakpoints": true "breakpoints": true
},
{
"idx": 24,
"version": "6",
"when": 1783948800000,
"tag": "0024_validation_programs",
"breakpoints": true
} }
] ]
} }
+5
View File
@@ -60,6 +60,11 @@ const CATEGORIES = {
"tariff_versions", "tariff_versions",
"tariffs", "tariffs",
"subscription_plans", "subscription_plans",
// Merchant validation programs (bar/lavazh) + their user bindings (child first).
// A --users reset without --config may orphan a binding row; harmless — a binding
// whose user is gone grants nothing.
"validation_program_users",
"validation_programs",
], ],
users: ["sessions", "role_permissions", "users", "roles"], users: ["sessions", "role_permissions", "users", "roles"],
diagnostics: ["app_logs"], diagnostics: ["app_logs"],
+53
View File
@@ -458,6 +458,57 @@ export const subscriptionPlates = sqliteTable("subscription_plates", {
plate: text("plate").notNull(), plate: text("plate").notNull(),
}); });
// --- Merchant validation programs (bar / lavazh) --------------------------
// Admin-composed master data for in-park merchant discounts: the /setup/site
// checkboxes toggle the WELL-KNOWN rows ("bar", "lavazh") — a future merchant is a
// new row, not a migration. Config is plainly MUTABLE (no versioning): the applied
// validation is a signed ledger event carrying the RESOLVED values, so historical
// reproducibility never depends on this row. Enabling/saving signs a config_change.
// See wiki/concepts/validation-discounts.md.
export const validationPrograms = sqliteTable("validation_programs", {
// Well-known slug ("bar" | "lavazh"); generic text so future merchants are rows.
id: text("id").primaryKey(),
// Receipt label printed on the booth settlement line (e.g. "Lavazh — 1 orë falas").
name: text("name").notNull(),
// How the program discounts — see @parking/shared ValidationMode.
mode: text("mode", { enum: ["comp", "timeCredit", "fixed", "percent"] })
.notNull()
.default("comp"),
// timeCredit: the free minutes.
minutes: integer("minutes"),
// percent: 1..100 off the fee.
percent: integer("percent"),
// fixed: cap on the amount the merchant may type at scan time (minor units).
maxAmountMinor: integer("max_amount_minor"),
// Anti-abuse cap: max applications per local day (null = unlimited).
maxPerDay: integer("max_per_day"),
// The /setup/site checkbox. Inactive = merchants can't apply it (row + history kept).
active: integer("active", { mode: "boolean" }).notNull().default(false),
createdAt: text("created_at")
.notNull()
.default(sql`(current_timestamp)`),
// Soft delete (recycle bin) — see roles.deletedAt.
deletedAt: text("deleted_at"),
deletedBy: text("deleted_by"),
});
// The program↔user binding: WHICH users may apply a program (the guard is
// `validation:create` AND a binding row — a bar user can never apply lavazh).
export const validationProgramUsers = sqliteTable(
"validation_program_users",
{
programId: text("program_id")
.notNull()
.references(() => validationPrograms.id),
userId: text("user_id")
.notNull()
.references(() => users.id),
},
(t) => ({
uniq: unique().on(t.programId, t.userId),
}),
);
// --- Blocklist (banlist) ------------------------------------------------- // --- Blocklist (banlist) -------------------------------------------------
// Plates/cards refused at ENTRY (never at exit — never trap a vehicle). A hit appends // Plates/cards refused at ENTRY (never at exit — never trap a vehicle). A hit appends
// a signed anomaly/refused-entry ledger event. See wiki/entities/blocklist.md. // a signed anomaly/refused-entry ledger event. See wiki/entities/blocklist.md.
@@ -546,5 +597,7 @@ 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;
export type ValidationProgramRow = typeof validationPrograms.$inferSelect;
export type ValidationProgramUserRow = typeof validationProgramUsers.$inferSelect;
export type SessionRow = typeof sessions.$inferSelect; export type SessionRow = typeof sessions.$inferSelect;
export type AppLogRow = typeof appLogs.$inferSelect; export type AppLogRow = typeof appLogs.$inferSelect;
@@ -202,6 +202,9 @@ const STR = {
tenderCard: "Kartë", tenderCard: "Kartë",
/** "Paid:" amount label (precedes the large total). */ /** "Paid:" amount label (precedes the large total). */
amountLabel: "PAGUAR", amountLabel: "PAGUAR",
/** Merchant-validation lines: the pre-discount fee + one line per discount. */
gross: (v: string) => `Tarifa: ${v}`,
discount: (label: string, v: string) => `${label}: -${v}`,
/** Walk-back grace emphasis (voucher mode) — two short lines that each fit the /** Walk-back grace emphasis (voucher mode) — two short lines that each fit the
* 80mm width, so neither wraps mid-word. */ * 80mm width, so neither wraps mid-word. */
graceLines: (min: number): readonly string[] => [ graceLines: (min: number): readonly string[] => [
@@ -413,6 +416,16 @@ export function renderReceipt(data: ReceiptData): Buffer {
line( line(
STR.tender(data.tender === "card" ? STR.tenderCard : STR.tenderCash), STR.tender(data.tender === "card" ? STR.tenderCard : STR.tenderCash),
), ),
// Merchant validations: gross fee + one line per discount, so the customer sees
// the full gross → discounts → net story (the big amount below is the NET).
...(data.validationLines?.length
? [
line(STR.gross(money(data.grossMinor ?? data.amountMinor, data.currency))),
...data.validationLines.map((v) =>
line(STR.discount(v.label, money(v.discountMinor, data.currency))),
),
]
: []),
line(), line(),
// The amount, large and centred. // The amount, large and centred.
ALIGN_CENTER, ALIGN_CENTER,
+5
View File
@@ -275,6 +275,11 @@ export interface ReceiptData {
readonly voucher: boolean; readonly voucher: boolean;
/** Minutes the customer has to reach the exit after paying (voucher mode only). */ /** Minutes the customer has to reach the exit after paying (voucher mode only). */
readonly graceExitMin?: number | null; readonly graceExitMin?: number | null;
/** Merchant validations (bar/lavazh): the PRE-discount fee and the per-validation
* lines. When present, `amountMinor` is the NET actually paid and the receipt
* shows the full gross → discounts → net story. See validation-discounts.md. */
readonly grossMinor?: number | null;
readonly validationLines?: readonly { label: string; discountMinor: number }[];
readonly header?: TicketHeader; readonly header?: TicketHeader;
} }
+146 -5
View File
@@ -20,6 +20,7 @@ export const RESOURCES = [
"tariff", // read / publish a new version "tariff", // read / publish a new version
"subscription", // the subscription registry "subscription", // the subscription registry
"site", // site_config + device setup/assign "site", // site_config + device setup/assign
"validation", // merchant validations: apply a discount to a session (bar/lavazh)
"device", // device status / printers / snapshots / catalog "device", // device status / printers / snapshots / catalog
"shift", // open/close own shift "shift", // open/close own shift
"drawer", // record cash receipts/disbursements (operator); review them (admin) "drawer", // record cash receipts/disbursements (operator); review them (admin)
@@ -54,6 +55,13 @@ export const PERMISSIONS: readonly Permission[] = [
"subscription:plan", // compose the plan catalog (admin-grade); selling = subscription:create "subscription:plan", // compose the plan catalog (admin-grade); selling = subscription:create
"site:read", "site:update", "site:read", "site:update",
// Merchant validations (bar/lavazh): create = APPLY a validation to a session (the
// merchant user's one permission — guarded further by the program↔user binding, so a
// bar user can never apply the lavazh program) + void their OWN unused validation;
// read = see applied validations (reports/history). Program COMPOSITION needs no new
// permission — it lives on /setup/site behind site:update. See
// wiki/concepts/validation-discounts.md.
"validation:create", "validation:read",
"device:read", "device:read",
"shift:read", "shift:create", "shift:cash", "shift:read", "shift:create", "shift:cash",
// Drawer cash movements: create (operator RECORDS a receipt/disbursement — freely, no // Drawer cash movements: create (operator RECORDS a receipt/disbursement — freely, no
@@ -272,6 +280,14 @@ export type LedgerEventType =
// the admin is NOT the adversary, but weakening an anti-fraud gate must still be // the admin is NOT the adversary, but weakening an anti-fraud gate must still be
// attributed + auditable). See wiki/concepts/entry-presence-bypass.md. // attributed + auditable). See wiki/concepts/entry-presence-bypass.md.
| "config_change" | "config_change"
// A merchant validation applied to (or voided from) a transient session: the bar/
// lavazh user scanned the customer's ticket, so the booth settlement discounts the
// fee. Payload carries the RESOLVED values (programId, label, mode, minutes/
// amountMinor/percent) — reproducible even if the program config later changes —
// plus `operator` (the merchant username). A payload with `refId` set is a VOID of
// the referenced validation event (append-only correction, mirrors cash_review).
// See wiki/concepts/validation-discounts.md.
| "validation"
| "anomaly"; | "anomaly";
/** How money was tendered (for payment events + the shift Z-report). */ /** How money was tendered (for payment events + the shift Z-report). */
@@ -291,9 +307,25 @@ export interface LedgerPayload {
readonly tender?: Tender; readonly tender?: Tender;
/** payment: which tariff_version priced it (reproducible repricing). */ /** payment: which tariff_version priced it (reproducible repricing). */
readonly tariffVersionId?: string; readonly tariffVersionId?: string;
/** payment: gross/discount/net split when a validation applied. */ /** payment: gross/discount/net split when a validation applied. `amountMinor` is the
* NET collected; grossMinor the pre-discount fee; discountMinor what validations took
* off. `validationIds` = the validation event ids this payment CONSUMED (so an
* overstay's fresh period never re-applies them). */
readonly grossMinor?: number; readonly grossMinor?: number;
readonly discountMinor?: number; readonly discountMinor?: number;
readonly validationIds?: string[];
/** payment: the per-validation receipt lines as settled (label + amount taken off) —
* stamped so the printed receipt reproduces without re-deriving the fold. */
readonly validationLines?: { programId: string; label: string; mode: string; discountMinor: number }[];
/** validation: which program (bar/lavazh) + its receipt label, frozen at apply time. */
readonly programId?: string;
readonly programLabel?: string;
/** validation: resolved values by mode — timeCredit's free minutes / percent off.
* A fixed amount rides the shared `amountMinor`. */
readonly minutes?: number;
readonly percent?: number;
/** validation / cash vouchers: the username of the user who recorded it. */
readonly operator?: string;
/** FX-ready, deferred: rate applied (null/absent now). See open-questions #8. */ /** FX-ready, deferred: rate applied (null/absent now). See open-questions #8. */
readonly fxRate?: number | null; readonly fxRate?: number | null;
/** void / anomaly / override: a human-readable English sentence, signed as the /** void / anomaly / override: a human-readable English sentence, signed as the
@@ -326,7 +358,8 @@ export interface LedgerPayload {
* cash_review event, so new movements do NOT carry this. Kept so historical events * cash_review event, so new movements do NOT carry this. Kept so historical events
* still verify + display. See wiki/concepts/shift.md. */ * still verify + display. See wiki/concepts/shift.md. */
readonly authorizedBy?: string; readonly authorizedBy?: string;
/** cash_review: the id of the cash_in/cash_out event this review decides on. */ /** cash_review: the id of the cash_in/cash_out event this review decides on.
* validation: set = this event VOIDS the referenced validation event. */
readonly refId?: string; readonly refId?: string;
/** cash_review: the admin's decision on the referenced movement. A FLAG only — /** cash_review: the admin's decision on the referenced movement. A FLAG only —
* neither value moves cash or touches the drawer balance. */ * neither value moves cash or touches the drawer balance. */
@@ -702,6 +735,58 @@ export interface SessionPayment {
readonly graceExitMin: number | null; readonly graceExitMin: number | null;
} }
// --- Merchant validations (bar / lavazh discounts) ---------------------------
// An in-park merchant validates a customer's ticket so the BOOTH settlement charges
// less or nothing. The program is admin-composed MUTABLE master data (no versioning:
// the applied validation is a signed ledger event carrying the RESOLVED values, so
// reproducibility never depends on the row). All money stays at the booth — the
// merchant only validates. See wiki/concepts/validation-discounts.md.
/** How a program discounts: full comp / first-N-minutes free / a fixed amount (typed
* by the merchant at scan time, capped) / a percentage off. */
export type ValidationMode = "comp" | "timeCredit" | "fixed" | "percent";
export const VALIDATION_MODES: readonly ValidationMode[] = ["comp", "timeCredit", "fixed", "percent"];
/** An admin-composed validation program (one per merchant station; `bar` and `lavazh`
* are the well-known ids the /setup/site checkboxes toggle). */
export interface ValidationProgram {
readonly id: string; // well-known slug ("bar" | "lavazh"); generic for future merchants
/** Receipt label, e.g. "Lavazh — 1 orë falas". Printed on the booth receipt line. */
readonly name: string;
readonly mode: ValidationMode;
/** timeCredit: the free minutes. */
readonly minutes: number | null;
/** percent: 1..100 off the fee. */
readonly percent: number | null;
/** fixed: cap on the amount the merchant may type at scan time (minor units). */
readonly maxAmountMinor: number | null;
/** Cap: max applications of this program per local day (null = unlimited). */
readonly maxPerDay: number | null;
readonly active: boolean;
}
/** An APPLIED validation as pricing cares about it — the RESOLVED values folded off
* the signed validation event (never the mutable program row). */
export interface SessionValidation {
/** The validation event id (payments record which ids they consumed). */
readonly eventId?: string;
readonly programId: string;
readonly label: string;
readonly mode: ValidationMode;
readonly minutes?: number; // timeCredit
readonly amountMinor?: number; // fixed
readonly percent?: number; // percent
}
/** One receipt/display line: what a validation actually saved on this settlement. */
export interface ValidationLine {
readonly programId: string;
readonly label: string;
readonly mode: ValidationMode;
/** The (positive) amount this line took off the fee. */
readonly discountMinor: number;
}
/** The full pricing outcome for a session at a moment in time — what the booth's /** 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 * `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). */ * without a real ledger. See wiki/concepts/booth-exit-flow.md (overstay pricing). */
@@ -709,8 +794,14 @@ export interface SessionPricing {
/** The window actually billed now: entry→asOf normally, or grace-expiry→asOf for an /** 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). */ * overstay (a paid session whose walk-back grace lapsed — a new period began). */
readonly periodStart: string; readonly periodStart: string;
/** Fee for [periodStart, asOf]. */ /** Amount DUE for [periodStart, asOf] — NET of any merchant validations. */
readonly amountMinor: number; readonly amountMinor: number;
/** The pre-validation fee for the same period (= amountMinor when no validations). */
readonly grossMinor: number;
/** Total the validations took off (grossMinor − amountMinor). */
readonly discountMinor: number;
/** Per-validation receipt lines, in the canonical application order. */
readonly validationLines: ValidationLine[];
/** True when the latest payment's grace has lapsed (overstay = new period). */ /** True when the latest payment's grace has lapsed (overstay = new period). */
readonly overstay: boolean; readonly overstay: boolean;
/** True when paid AND still inside the walk-back window (a settled, exitable stay). */ /** True when paid AND still inside the walk-back window (a settled, exitable stay). */
@@ -732,6 +823,15 @@ export interface SessionPricing {
* `payments` is the session's payment history (only the LATEST matters for grace); * `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 * 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. * customer keeps their rate card even across an overstay. See booth-exit-flow.md.
*
* `validations` are the UNCONSUMED merchant validations on the session (the caller
* filters out ids already recorded on a prior payment's `validationIds`, so an
* overstay's fresh period never re-applies them). Canonical application order —
* deterministic regardless of scan order: timeCredit (shifts the billed period's
* start forward, so "first hour free" is literal and windowed/stepped cards price
* the remainder correctly) → percent (of the remaining fee) → fixed amounts
* (clamped to the remainder) → comp (zeroes whatever is left). Net never goes
* below 0. See wiki/concepts/validation-discounts.md.
*/ */
export function priceSession( export function priceSession(
enteredAt: string, enteredAt: string,
@@ -739,6 +839,7 @@ export function priceSession(
tariff: TariffStructure, tariff: TariffStructure,
payments: readonly SessionPayment[] = [], payments: readonly SessionPayment[] = [],
category?: string, category?: string,
validations: readonly SessionValidation[] = [],
): SessionPricing { ): SessionPricing {
const last = payments.length ? payments[payments.length - 1] : null; const last = payments.length ? payments[payments.length - 1] : null;
const graceExpiryMs = const graceExpiryMs =
@@ -748,10 +849,50 @@ export function priceSession(
const withinGrace = graceExpiryMs != null && asOfMs <= graceExpiryMs; const withinGrace = graceExpiryMs != null && asOfMs <= graceExpiryMs;
const periodStart = overstay ? new Date(graceExpiryMs!).toISOString() : enteredAt; const periodStart = overstay ? new Date(graceExpiryMs!).toISOString() : enteredAt;
// A settled (paid + within grace) session owes nothing more; otherwise bill the period. // A settled (paid + within grace) session owes nothing more; otherwise bill the period.
const amountMinor = withinGrace ? 0 : computeFee(periodStart, asOf, tariff, category); const grossMinor = withinGrace ? 0 : computeFee(periodStart, asOf, tariff, category);
// Fold the validations (nothing to discount on a settled session or a zero fee is
// still folded so the receipt can show "Lavazh — falas" even when gross is 0-adjacent).
const lines: ValidationLine[] = [];
let net = grossMinor;
if (!withinGrace && validations.length) {
const byMode = (m: ValidationMode) => validations.filter((v) => v.mode === m);
// 1. Time credits: bill as if the period started later (clamped at asOf). The
// marginal saving of each credit is its line amount.
let startMs = Date.parse(periodStart);
for (const v of byMode("timeCredit")) {
const minutes = v.minutes ?? 0;
const shiftedMs = Math.min(startMs + minutes * 60_000, asOfMs);
const newFee = computeFee(new Date(shiftedMs).toISOString(), asOf, tariff, category);
lines.push({ programId: v.programId, label: v.label, mode: v.mode, discountMinor: net - newFee });
startMs = shiftedMs;
net = newFee;
}
// 2. Percent of the remaining fee (floor — integer minor units).
for (const v of byMode("percent")) {
const off = Math.floor((net * Math.min(Math.max(v.percent ?? 0, 0), 100)) / 100);
lines.push({ programId: v.programId, label: v.label, mode: v.mode, discountMinor: off });
net -= off;
}
// 3. Fixed amounts, clamped to the remainder so Σ lines ≡ gross − net.
for (const v of byMode("fixed")) {
const off = Math.min(Math.max(v.amountMinor ?? 0, 0), net);
lines.push({ programId: v.programId, label: v.label, mode: v.mode, discountMinor: off });
net -= off;
}
// 4. Comp: zero whatever is left.
for (const v of byMode("comp")) {
lines.push({ programId: v.programId, label: v.label, mode: v.mode, discountMinor: net });
net = 0;
}
}
return { return {
periodStart, periodStart,
amountMinor, amountMinor: net,
grossMinor,
discountMinor: grossMinor - net,
validationLines: lines,
overstay, overstay,
withinGrace, withinGrace,
graceExpiresAt: graceExpiryMs != null ? new Date(graceExpiryMs).toISOString() : null, graceExpiresAt: graceExpiryMs != null ? new Date(graceExpiryMs).toISOString() : null,
+101
View File
@@ -532,3 +532,104 @@ describe("explainFee — the breakdown IS the fee (2026-07-06)", () => {
]); ]);
}); });
}); });
// ---------------------------------------------------------------------------
// (i) Merchant validations — the priceSession discount fold (2026-07-13).
// liveV1: 5-min entry grace, 60-min increment, blocks 20000(1h)/10000(to 3h),
// daily cap 100000, exit grace 5 min. See wiki/concepts/validation-discounts.md.
// ---------------------------------------------------------------------------
describe("priceSession merchant validations", () => {
const val = (
mode: "comp" | "timeCredit" | "fixed" | "percent",
over: Partial<import("./index.js").SessionValidation> = {},
): import("./index.js").SessionValidation => ({
programId: "bar",
label: "Bar",
mode,
...over,
});
it("no validations → gross == net, no lines (back-compat)", () => {
const r = priceSession(entered, at(120), liveV1, []);
expect(r.grossMinor).toBe(30000);
expect(r.amountMinor).toBe(30000);
expect(r.discountMinor).toBe(0);
expect(r.validationLines).toEqual([]);
});
it("comp zeroes the fee and the line carries the whole gross", () => {
const r = priceSession(entered, at(120), liveV1, [], undefined, [val("comp")]);
expect(r.grossMinor).toBe(30000);
expect(r.amountMinor).toBe(0);
expect(r.discountMinor).toBe(30000);
expect(r.validationLines).toEqual([{ programId: "bar", label: "Bar", mode: "comp", discountMinor: 30000 }]);
});
it("fixed subtracts, floors at 0, and clamps the line to the remainder", () => {
// 2h → 30000 gross; 300-off style: fixed 20000 → net 10000.
const r = priceSession(entered, at(120), liveV1, [], undefined, [val("fixed", { amountMinor: 20000 })]);
expect(r.amountMinor).toBe(10000);
expect(r.discountMinor).toBe(20000);
// Bigger than the fee → net 0, line clamped to the gross (Σ lines ≡ gross − net).
const r2 = priceSession(entered, at(120), liveV1, [], undefined, [val("fixed", { amountMinor: 99999 })]);
expect(r2.amountMinor).toBe(0);
expect(r2.validationLines[0]!.discountMinor).toBe(30000);
});
it("timeCredit prices as if entered later — 'first hour free' is literal", () => {
// 2h stay, 60 free minutes → bill the remaining 1h at the FIRST block (20000),
// exactly what a 1h stay costs.
const r = priceSession(entered, at(120), liveV1, [], undefined, [val("timeCredit", { minutes: 60 })]);
expect(r.grossMinor).toBe(30000);
expect(r.amountMinor).toBe(computeFee(at(60), at(120), liveV1));
expect(r.amountMinor).toBe(20000);
expect(r.validationLines[0]!.discountMinor).toBe(10000);
});
it("timeCredit covering the whole stay → net 0", () => {
const r = priceSession(entered, at(50), liveV1, [], undefined, [val("timeCredit", { minutes: 120 })]);
expect(r.amountMinor).toBe(0);
expect(r.discountMinor).toBe(r.grossMinor);
});
it("percent takes a floor'd share of the remaining fee", () => {
const r = priceSession(entered, at(120), liveV1, [], undefined, [val("percent", { percent: 50 })]);
expect(r.amountMinor).toBe(15000);
expect(r.discountMinor).toBe(15000);
});
it("stacking is canonical-order (timeCredit → percent → fixed → comp) and Σ lines ≡ gross − net", () => {
// Scan order deliberately reversed; the fold must still do time first.
const r = priceSession(entered, at(120), liveV1, [], undefined, [
val("fixed", { amountMinor: 5000, programId: "bar" }),
val("timeCredit", { minutes: 60, programId: "lavazh", label: "Lavazh" }),
]);
// gross 30000 → time credit leaves 20000 → fixed 5000 → net 15000.
expect(r.grossMinor).toBe(30000);
expect(r.amountMinor).toBe(15000);
const sum = r.validationLines.reduce((a, l) => a + l.discountMinor, 0);
expect(sum).toBe(r.discountMinor);
expect(r.validationLines.map((l) => l.mode)).toEqual(["timeCredit", "fixed"]);
});
it("a settled (paid + within grace) session ignores validations", () => {
const r = priceSession(entered, at(123), liveV1, [{ paidAt: at(120), graceExitMin: 5 }], undefined, [
val("comp"),
]);
expect(r.withinGrace).toBe(true);
expect(r.amountMinor).toBe(0);
expect(r.validationLines).toEqual([]);
});
it("an overstay period applies (unconsumed) validations to the FRESH period", () => {
// Paid at 120, grace 5 → overstay period starts at 125. A 60-min credit eats the
// overstay's first hour: net = fee(185→245 from period start) = the 1h price… i.e.
// fee of (245−125−60)=60 min from the ladder start.
const r = priceSession(entered, at(245), liveV1, [{ paidAt: at(120), graceExitMin: 5 }], undefined, [
val("timeCredit", { minutes: 60 }),
]);
expect(r.overstay).toBe(true);
expect(r.grossMinor).toBe(computeFee(at(125), at(245), liveV1));
expect(r.amountMinor).toBe(computeFee(at(185), at(245), liveV1));
});
});
+128 -1
View File
@@ -2,7 +2,7 @@
type: concept type: concept
tags: [parking, domain, business, pricing, revenue] tags: [parking, domain, business, pricing, revenue]
sources: [] sources: []
updated: 2026-06-15 updated: 2026-07-13
status: open status: open
--- ---
@@ -11,6 +11,133 @@ status: open
A merchant (shop, hotel, clinic) **validates** a customer's parking so they pay less or nothing — A merchant (shop, hotel, clinic) **validates** a customer's parking so they pay less or nothing —
a common revenue/retention feature that modifies what a [[parking-session]] owes. a common revenue/retention feature that modifies what a [[parking-session]] owes.
## Driving cases (owner requirements, 2026-07-13)
The feature moved from "industry gap" to **asked-for**: the park may contain an in-park
**car-wash (al. "lavazh")** and/or a **bar**, and the owner wants their customers discharged
(fully or partly) for the parking stay:
- **Car-wash**: parking free entirely, **or** free for an owner-set duration (30 min / 1 h / 2 h …)
after which the stay prices like any transient → `comp` or `time-credit`.
- **Bar**: subtract the bar consumption from the parking fee (consumed 300 ALL, park fee 500 ALL →
pay 200 ALL) → `fixed` with a **per-use variable amount**; or parking free for bar customers → `comp`.
- These must be **admin-composable at runtime like tariffs/subscription plans** — the owner
defines the programs and their parameters; nothing hard-coded.
**Refined the same day (settled): the merchant is a VALIDATION-ONLY system user; ALL money and
paper stay at the booth.** Ownership is immaterial and the [[validation-sponsorship]]
sponsor/settlement layer is **not needed** for this. The model:
- A **merchant user** (the "bar user", "lavazh user") logs into the system on their own device and
**scans the customer's ticket** there — the scan-and-apply *is* the validation, a signed event
attributed to that user (accountability sits with the merchant, not the booth operator). That is
the merchant's ENTIRE surface: no payment collection, no printer, no shift.
- **Every car still checks in at the booth to settle** — even a fully-comped one. The booth quote
applies the session's validation events (`gross − discounts`, floor 0); the operator collects the
**net** (possibly 0 — a zero-amount settlement is still a signed `payment` event so grace/exit
work unchanged) and **prints the detailed receipt there** (gross fee, each validation line, net
paid).
- Exit is the unchanged [[booth-exit-flow]] (immediate exit or voucher self-exit at the reader).
This DISSOLVES the two consequences flagged by the earlier merchant-collects variant (rejected
2026-07-13, same conversation): the [[shift]] site-wide single-open invariant and single till stay
as built (Z/X-reports just gain gross/discount/net lines so cash reconciles to net), and the exit
reader needs no live due=0 branch (the booth settlement covers the zero-due case; time-credit is
priced at booth check-in, inside the normal walk-back-grace flow).
## Settled design (2026-07-13) — setup UX, storage, RBAC
- **Setup lives on `/setup/site`** (gated by the page's existing `site:update`): the left card
gains **Bar** and **Lavazh** checkboxes; the empty right column renders the enabled station's
config panel (tabs when both). Panel per station: **mode** (comp / time-credit N-min / fixed
amount-typed-at-scan with a max cap / percent), **caps** (max per validation, max per day,
one-per-session default), **receipt label**, **bound users**.
- **Fixed UI, generic storage**: a `validation_programs` table (+ user binding) where Bar and
Lavazh are two **well-known rows** created on first enable — a third merchant later is a data
row, not a migration (honours the "composable like tariffs" requirement). Config is plainly
**mutable, no versioning**: the applied validation is a signed ledger event carrying the
RESOLVED values (minutes/amountMinor + programId), so reproducibility never depends on the row.
Enabling/saving signs a `config_change` ([[entry-presence-bypass]] precedent).
- **RBAC**: new `validation` resource in the code-defined grid — `validation:create` (apply; the
merchant's only permission) + `validation:read` (reports/history). Guard = permission **AND**
station binding (data), so a bar user can never apply the lavazh program. Merchant users land on
a new **`/validate`** screen (scan → session → apply); the permission-driven nav shows them
nothing else. Program composition needs no new permission (`site:update`).
- **Mistake handling**: a merchant may **void their own validation while unused** (before it
entered a payment) — a signed void event, never a delete. Booth/admin can void via the normal
event-void path.
- Open (non-blocking): per-customer mode choice (v1 = one mode per station); merchant scan
hardware — lean: also print a **QR** of the ticket id so any phone camera works
([[ticket-encoding]]).
## As-built (2026-07-13)
- **Shared (`@parking/shared`)**: `validation` resource (`validation:create`/`read`) in the
permission grid; `ValidationMode`/`ValidationProgram`/`SessionValidation`/`ValidationLine`;
`priceSession(…, validations[])` folds the discounts in a **canonical order** — timeCredit
(shifts the billed period's start forward, so grace/steps/windowed cards price the remainder
correctly) → percent (of the remainder) → fixed (clamped) → comp — net floors at 0 and
**Σ lines ≡ gross − net** by construction. Unit-tested (incl. overstay + settled cases).
- **Ledger**: new `validation` event type — payload carries the **resolved** values
(`programId`, `programLabel`, `mode`, `minutes`/`amountMinor`/`percent`) + `operator` (the
merchant username); `refId` set = a VOID of the referenced validation (append-only, mirrors
`cash_review`). The settling `payment` records `grossMinor`/`discountMinor`/`validationIds`
(**consumption** — an overstay's fresh period never re-applies them) + `validationLines`
(receipt reproducibility).
- **DB**: `validation_programs` + `validation_program_users` (migration `0024`; both in
reset-db's `config` category). Mutable master data, soft-deletable.
- **Server**: `routes/validations.ts` — programs GET/PUT (`site:read`/`site:update`, signed
`config_change` on real change only), `/mine`, `/session/:identity` (deliberately no money
data), `/apply` (guards in order: program live+active → user **bound** → open **transient** →
no live duplicate of the program → `maxPerDay` → fixed-amount bounds), `/void` (own +
unconsumed only). `PayStation.quote/lookup/pay` fold `liveValidations` (applied − voided −
consumed); `activeSessions` amounts are net automatically. Receipt (`renderReceipt`) prints
gross (`Tarifa`) + one line per discount; the big amount is the NET. Z/X-report gained
`discountTotalMinor` (leakage; takings stay net) — printed as `Zbritje (validime)` only when
non-zero, so old slips stay byte-identical.
- **Web**: `/setup/site` is two-column — Bar/Lavazh checkboxes on the left card (a flip persists
`active` at once = signed config change), `ValidationSetup.tsx` panel on the right (tabs when
both; mode/params/caps/receipt-label/bound-users). `/validate` (`ValidateScreen.tsx`) is the
merchant's whole surface (scan/key → apply → void own unused), mobile-friendly, autofocused
input works with HID scanners; merchant-only users (no `session:read`) land there on login and
the permission-gated nav shows them nothing else. Booth pay modal shows gross → lines → net;
the zero-net comp settles through the normal pay path (grace starts, voucher/exit unchanged).
Feed label `VALIDIM`/`VALIDATION`. RolesManager picks the new resource up generically.
- **Verified**: 8 route-level integration tests (guards, signed events, money cycle, void locks,
per-day cap) + the shared fold suite; whole-workspace build/typecheck/test green; migration
applied to the dev DB.
- **Remaining polish (not blocking)**: show `discountTotalMinor` in the X-report/close-modal/
shift-history UI (it's already in the signed payload + printed Z); a validations/leakage
**report** (per program/user/day) under [[reporting-analytics]].
## Merchant scan input — DECIDED 2026-07-13: barcode scanner on the web/desktop app; camera paths POSTPONED
**v1 (in force):** the merchant scans with a **USB/HID barcode scanner** into the `/validate`
screen on the web (or desktop) app — the scanner types the 11-digit id + Enter into the
autofocused input, exactly like the booth. Hand-keying is the zero-hardware fallback; the
[[ticket-encoding|Luhn check digit]] catches typos. The park site is expected to equip the
bar/lavazh station accordingly — no phone-camera path for now.
**Postponed (evaluated 2026-07-13, both viable, deliberately deferred):**
1. **Web camera scanning** — `BarcodeDetector` (Chromium/Android native) + the `barcode-detector`
polyfill on **zxing-wasm** (Apache/MIT — license-clean, bundles offline). Two prerequisites
killed it for now: (a) `getUserMedia` needs a **secure context** — a merchant phone on
`http://<booth-ip>` gets NO camera, so the appliance needs a TLS story (realistically a
self-signed CA minted on the booth + one-time cert install per device — fold into the
[[booth-deploy-networking|reverse-proxy]] plan); (b) Code128 via phone camera on thermal
paper decodes poorly — would want the **QR-of-ticket-id** addition first (the ESC/POS driver
already has `qrCode()`; `renderTicket` is a one-line change — still a good idea whenever any
camera path revives).
2. **Tauri Android merchant app** (a SECOND small Tauri target, e.g. `apps/validator` — NOT an
extension of [[desktop-shell-tauri|apps/desktop]], which is a booth kiosk hardwired to
localhost:3000): Tauri v2 mobile + the official `barcode-scanner` plugin (ML Kit — reads
Code128 well natively, and the tauri:// origin is secure so the TLS problem vanishes).
Costs that drove the postponement: Android SDK/NDK + Rust-target build infra (+CI), APK
sideload distribution/updates to merchant devices, effectively Android-only (iOS needs a
paid signing account), and it needs the configurable-server-URL work the desktop shell also
wants. Revisit if the owner issues dedicated Android tablets to merchants.
## Model: a discount is a signed event, applied at fee time ## Model: a discount is a signed event, applied at fee time
A validation is **not** an edit to the session or a mutable "discount applied" flag — same reason A validation is **not** an edit to the session or a mutable "discount applied" flag — same reason
+11
View File
@@ -55,6 +55,17 @@ Mirrored networking is necessary but **not sufficient** — these still bit us:
- **`localhost` → IPv6 first.** `localhost` resolves to `::1`, but the backend binds IPv4 - **`localhost` → IPv6 first.** `localhost` resolves to `::1`, but the backend binds IPv4
(`127.0.0.1`). Node's Vite proxy can stall on the v6 attempt before falling back — point the (`127.0.0.1`). Node's Vite proxy can stall on the v6 attempt before falling back — point the
proxy at `127.0.0.1` explicitly. (See [[local-dev-workflow]].) proxy at `127.0.0.1` explicitly. (See [[local-dev-workflow]].)
- **Windows-side listeners collide with WSL binds — INVISIBLY (2026-07-13).** Under mirrored
mode, a process listening on the WINDOWS side makes the same port `EADDRINUSE` inside WSL,
but it never appears in Linux `ss`/`lsof` — the port looks free yet won't bind. Bit us as
"tauri dev: Could not connect to http://localhost:5173 after 180s": a DIFFERENT React app's
dev server running on the Windows side held `::1:5173`, so the WSL Vite silently
auto-incremented to 5174 while Tauri's `devUrl` is the FIXED string `http://localhost:5173`
in `tauri.conf.json` (it cannot follow the auto-increment). Diagnose from WSL with
`powershell.exe -NoProfile -Command "Get-NetTCPConnection -LocalPort 5173 -State Listen"`
(then `Get-Process -Id <OwningProcess>`); kill with `taskkill.exe /PID <pid> /F`. Guard:
`strictPort: true` in the web `vite.config` so the mismatch fails in a second with a clear
error instead of a 3-minute hang on the wrong port.
## Multi-subnet source-address trap (the "ARP works but ping/TCP dies" bug) ## Multi-subnet source-address trap (the "ARP works but ping/TCP dies" bug)
+181
View File
@@ -0,0 +1,181 @@
---
type: decision
tags: [parking, cloud, saas, multi-tenant, monitoring, netbird, threat-model, offline-first]
sources: []
updated: 2026-07-13
status: open
---
# Cloud service — multi-tenant SaaS for fleet monitoring & control
> **Status: postponed (2026-07-13).** Captured as context, not a commitment. This records an
> early requirements/architecture discussion so it isn't re-derived from scratch later. No app
> code, no schema. Two of the initial requirements were **corrected in-discussion** (see
> "Corrections" below) — read those before treating any first-pass answer as settled.
## The idea
The offline backup model we ship today is the right **tradeoff for offline sites** and stays.
On top of it, the user wants an **online, multi-tenant SaaS** — the "**cloud service**" — that
subscribing park sites connect to for **real-time (link-up) monitoring**: the signed ledger,
device status, financial reports, and whatever else a site reports. One **admin owns more than
one site** (a portfolio). The cloud also **custodies per-site secrets**. Business model: recurring
per-site monthly/yearly fee — a revenue line the offline appliance alone can't produce.
This is the customer-facing evolution of the off-site control plane that [[fleet-deployment-komodo]]
already stood up (**Komodo Core**, **NetBird** mesh, **Gitea** registry). Much of the transport and
Tier-0 reasoning there carries over directly; this page is about turning that internal ops plane into
a **multi-tenant product**.
## The four hard tensions (what makes a naïve SaaS wrong here)
The booth's two governing forces ([[offline-first]], [[threat-model]]) plus the signed ledger
([[append-only-event-chain]]) make the "obvious" SaaS shape wrong. Four tensions dominate:
1. **Offline-first vs. real-time monitoring.** The cloud must **never be in the critical path** of
entry/exit/payment/barrier ([[offline-first]]). It is a **read-mostly mirror + control-plane**, fed
by the booth when the link is up, tolerant of hours/days offline, and unable to block booth
operation by being down. "Real-time" = *near*-real-time when up, **gracefully stale** when not —
and the UI must show staleness **honestly** (last-seen everywhere), never paint a dark site green.
2. **The signed ledger must stay *verifiable* in the cloud, not merely displayed.** If subscribers
see "their ledger" in the cloud, the cloud copy must be **re-verified server-side** — re-check the
hash chain + signatures on ingest, flag gaps/breaks/forks loudly. The [[threat-model|operator-as-
adversary]] extends upward: an operator may want the cloud *not* to see certain events, so the sync
must be **gap-evident** (sequence continuity). This is both the anti-tamper mechanism **and** a
headline feature — *"we can prove your revenue record wasn't altered, even by your own night
shift."* See [[reconciliation]] (this is reconciliation, productised).
3. **Secrets for every site — the scariest requirement.** A central secret store for hundreds of
sites is a single juicy target. The custody boundary must be deliberate — see "Secrets boundary".
4. **Multi-tenancy under operator-as-adversary — now at two levels.** One admin, many sites ⇒ a new
**portfolio-owner** role *above* the existing per-site roles ([[local-jwt-auth]] admin/operator/
cashier/readonly). Row-level tenant isolation must be **airtight** — a bug now leaks *another
company's* revenue, not just an intra-site escalation. Every row carries `tenant_id` + `site_id`,
non-optional in the query path (not a filter someone can forget). **Cloud identity is separate from
booth-local auth** — the booth keeps its offline JWT/bcrypt login untouched; a site never
authenticates its *users* against the cloud (that would break [[offline-first]]).
## Secrets boundary (settled-in-principle 2026-07-13)
User confirmed the cloud custodies **three** classes — and **not** the crown jewel:
| Class | Cloud custodies? | Notes |
| --- | --- | --- |
| Sync/connection creds + ledger **public** (verify) key | ✅ yes | Per-site uplink credential + the public half to *verify* signatures. Smallest blast radius. |
| **Device/controller passwords** (Dingtian `relay_pw`, camera creds, push tokens) | ✅ yes, as **escrow** | Solves the real pain: lost `relay_pw` after a DB reset ([[dingtian-relay]]). See escrow rules below. |
| App/admin identity (portfolio login) | ✅ yes | Cloud-side identity for portfolio admins. Separate from booth-local auth. |
| Ledger **signing** key / [[atecc608\|ATECC608]] private key, LUKS/TPM material | ❌ **never** | Centralising the signer **kills the anti-fraud model** ([[append-only-event-chain]], [[hardware-signer-options]]). The user did **not** pick this. |
**How device-password escrow must work (so it earns its keep instead of becoming the breach):**
- **Envelope encryption, per-tenant DEK**, DEKs wrapped by a KMS master key; a DB dump is ciphertext,
every decrypt is KMS-audited.
- The cloud is an **escrow, not an operational credential store**. Its job is "**give the booth back
its `relay_pw`** after a wipe," *not* "the cloud logs into the Dingtian." Decryption happens **at the
booth** (booth fetches its own wrapped blob, unwraps locally); ideally the cloud never holds
plaintext device secrets in memory. This keeps the [[dingtian-http-api-unauthenticated|unauthenticated-
CGI]] exposure host-local.
- **The booth threat model applies upward:** writes to escrow are append/version ops the operator
can't silently rewrite; reads are logged where the operator can't scrub them.
- Sellable as: *"your device credentials survive any wipe, encrypted so even we can't read them in
bulk."*
## Corrections made in-discussion (2026-07-13) — read these
The first pass argued *against* the user's two boldest choices ("cloud reaches into the booth";
implicitly, "no remote barrier open"). **The user corrected both, and the corrections stand.**
### Correction 1 — NetBird already solves the isolation objection
Initial worry: a cloud tunnel *into* the booth is a new inbound attack surface on every site. **But
park-buzi is already monitored remotely over a NetBird private mesh** (WireGuard) — the same
mesh [[fleet-deployment-komodo]] uses. The booth **dials out** to join the overlay; **nothing is
exposed** on the booth PC. So "cloud reaches booth" is the booth-dialed reverse-channel pattern
**already in production**, not a new hole. The objection is **withdrawn.** What it *shifts* rather than
removes:
- Trust moves to the **overlay's identity/ACL layer**: "cloud can reach the booth" now means "any
peer the mesh authorizes can reach the booth host." **Mesh ACLs must enforce the same tenant
isolation as the app layer** — site A's admin never gets a route to site B's booth. Multi-tenant
isolation in a different hat.
- **The access-controller VLAN still holds:** the mesh terminates at the **host**, not the controller
segment. A cloud peer talks to the booth API; the **booth** talks to the Dingtian/UHPPOTE
([[network-isolation]], [[access-direction-is-per-relay]]). The cloud never gets an L3 route to the
UDP relay.
- **NetBird's control plane joins the trust base** (self-hosted = another service to harden; their
SaaS = a third party who can authorize peers). A conscious call, not an architecture change.
### Correction 2 — remote barrier-open is *compatible* with barrier-not-a-door, and the unmanned future *requires* it
Initial worry: the cloud must never open a barrier. **The user's driver is the [[autonomous-direction|
unmanned-site]] future** — no operator on-site; if the exit reader or payment dies, *someone* must open
the barrier remotely rather than trap people ("we can't take hostages because a stupid device is not
responsive"). This is **right**, and it does **not** violate [[barrier-not-a-door]]:
- That rule was **never** "no remote open." It forbids driving the barrier as a **timed auto-close**
("open for N ms"); physical safety (loop-detector, anti-crush reversal) lives in the **barrier
firmware**. A remote human pressing "open" is an **intent expression** — exactly `pulseOpen`. It's
the [[fail-state-safety|exit-fails-open]] value, triggered by a remote human instead of a power-loss.
- Constrain the **how**, not the whether (this is the command where [[threat-model|operator-as-
adversary]] bites hardest — a remote "let this car out free" is the classic fraud):
- **Every remote open is a first-class signed ledger event** ([[append-only-event-chain]]): appended,
hash-chained, signed, with **actor** (which cloud identity), **reason code**, and **site/relay**.
Control power and audit come as a **pair** — the same discipline [[setup-relay-test]] and
[[booth-exit-flow|audited re-open]] already apply locally.
- A **distinct, high-privilege capability**, not bundled into "monitoring" — a readonly portfolio
viewer can't open barriers.
- **The booth stays the enforcer:** cloud sends *intent*; the booth validates (for-me? authorized
peer? signed?) and issues `pulseOpen` to its own relay. Cloud never touches the relay.
- **Cloud can't be the *sole* egress path.** A fully unattended site needs a **local fail-open on
host-loss** + physical override too — offline-first means the cloud is a *convenience* remote-open
path, not the *only* one, or you've recreated "device down = hostages" one layer up.
> **Emergent tenet:** an unattended site is a **higher** safety bar than an attended one, not a lower
> one. Every local failure mode (barrier stuck, payment dead, network down) needs an answer that
> **doesn't require the cloud**; the cloud makes resolution *nicer*, not *possible*. Fold into
> [[autonomous-direction]] and [[fail-state-safety]] when this is picked up.
## What looks straightforward (agreed quickly)
- **Transport:** the existing **NetBird overlay** (booth-dialed, nothing exposed) — not a bespoke
channel. Reuses [[fleet-deployment-komodo]].
- **Sync:** **booth-push, verify-on-ingest** — booth streams ledger + device telemetry
([[device-events]]) + snapshot metadata + financial data outbound; cloud **re-verifies the chain +
signatures** and flags gaps.
- **Staleness first-class in the UI:** every site tile shows last-seen; a dark site is visibly stale.
- **DB:** almost certainly **PostgreSQL** — already the named deferred sync target ([[drizzle-orm]],
[[technology-stack]]); the Drizzle schemas are meant to port to it.
## The genuinely open questions (postponed — pick up here)
1. **What does "real-time" mean to the buyer?** Live-ish (seconds, streaming uplink → **heavier
booth**) vs. every-few-minutes rollups (cheap, still sells "monitoring"). This gap is **most of the
engineering cost** and drives how heavy the booth-side uplink must be.
2. **Financial reports computed where?** Cloud **re-derives** revenue from the verified ledger →
independently trustworthy (*"we don't take the booth's word for it"*) but the cloud must implement
the [[tariff]] pricing logic. Vs. booth sends **pre-computed rollups** (cheaper, but trusts the
booth's math). Lean: **cloud re-derives** — the whole point of [[threat-model|operator-adversary]]
is not to trust the site's self-report ([[reporting-analytics]] is already "projections over the
signed log").
3. **Hosting + licensing.** The booth stack is deliberately all-MIT/Apache/BSD ([[technology-stack]]);
a SaaS the user **hosts** has more freedom (like the [[fleet-deployment-komodo|Komodo GPL]] /
[[vision-service|AGPL]] self-host exceptions) — but anything that ever ships **on-premise** re-binds
the constraint.
4. **Custodianship is leverage *and* liability.** Holding other companies' financial records + device
secrets is what makes the subscription **sticky** — and what pulls in **backups, retention policy,
breach disclosure, data-residency**. A deliberate "yes, we want to be the custodian" call, with the
obligations that implies. (Cloud/Core is a **Tier-0 asset** for the whole fleet — the same bar
[[fleet-deployment-komodo]] already sets for Core.)
## Relates
- [[fleet-deployment-komodo]] — the off-site control plane (Komodo Core + NetBird) this productises;
Core-as-Tier-0 reasoning carries over.
- [[autonomous-direction]] — the unmanned future that *drives* remote barrier-open (Correction 2).
- [[reconciliation]] — the cloud *is* reconciliation, productised (verify-on-ingest, gap-evidence).
- [[append-only-event-chain]] / [[hardware-signer-options]] — why the **signing** key stays on the
booth even as everything else centralises.
- [[threat-model]] / [[offline-first]] — the two forces every tension above traces back to.
- [[network-isolation]] / [[access-direction-is-per-relay]] — why the mesh terminates at the host.
+3 -2
View File
@@ -7,7 +7,7 @@ updated: 2026-07-02
# Index # Index
Content catalog for the wiki. Start at [[overview]]. Maintained on every ingest. Content catalog for the wiki. Start at [[overview]]. Maintained on every ingest.
Counts: 4 sources · 19 entities · 47 concepts · 7 decision records. Counts: 4 sources · 19 entities · 47 concepts · 8 decision records.
## Overview & navigation ## Overview & navigation
- [[overview]] — the top-level synthesis and entry point. - [[overview]] — the top-level synthesis and entry point.
@@ -99,7 +99,7 @@ Counts: 4 sources · 19 entities · 47 concepts · 7 decision records.
- [[capacity-occupancy]] — live count = open sessions; refuse entry + FULL sign when full (soft policy); exit never blocked. - [[capacity-occupancy]] — live count = open sessions; refuse entry + FULL sign when full (soft policy); exit never blocked.
- [[site-metadata]] — optional park identity (name, operator, VAT, address, contact) in site_config; feeds the ticket header. - [[site-metadata]] — optional park identity (name, operator, VAT, address, contact) in site_config; feeds the ticket header.
- [[valet-overcapacity]] — "full" is soft: operator may valet-accept over capacity (keys handed over, custody). Manned, deferred. - [[valet-overcapacity]] — "full" is soft: operator may valet-accept over capacity (keys handed over, custody). Manned, deferred.
- [[validation-discounts]] — merchant validates a ticket → signed discount event applied at fee time. - [[validation-discounts]] — BUILT (2026-07-13): in-park merchant (bar/lavazh) users scan-and-validate on their device (signed event, program↔user binding); booth settles NET + prints gross/discount/net; comp/time-credit/fixed/percent, caps, /setup/site panel, /validate screen.
- [[validation-sponsorship]] — design: sponsor accounts + postpaid B2B (customers park free, business billed monthly); not a permit. - [[validation-sponsorship]] — design: sponsor accounts + postpaid B2B (customers park free, business billed monthly); not a permit.
- [[reporting-analytics]] — revenue/occupancy/stay reports + plate-search, all projections over the signed log. - [[reporting-analytics]] — revenue/occupancy/stay reports + plate-search, all projections over the signed log.
- [[clock-integrity]] — fees depend on the host clock; detect/flag backdating on an offline box. - [[clock-integrity]] — fees depend on the host clock; detect/flag backdating on an offline box.
@@ -127,6 +127,7 @@ Counts: 4 sources · 19 entities · 47 concepts · 7 decision records.
- [[open-questions]] — 9 open items (procurement + JWT key + FX + pay-station money corners); ESP32 device auth deferred. - [[open-questions]] — 9 open items (procurement + JWT key + FX + pay-station money corners); ESP32 device auth deferred.
- [[access-controller-button-flow]] — ✅ RESOLVED: Dingtian decoupled inputs enable ticket-first entry (was a UHPPOTE/ZKTeco blocker). - [[access-controller-button-flow]] — ✅ RESOLVED: Dingtian decoupled inputs enable ticket-first entry (was a UHPPOTE/ZKTeco blocker).
- [[autonomous-direction]] — roadmap: toward fully unmanned (no booth); reshapes threat model + fail-state. - [[autonomous-direction]] — roadmap: toward fully unmanned (no booth); reshapes threat model + fail-state.
- [[cloud-service-saas]] — 📌 POSTPONED: multi-tenant SaaS for fleet monitoring/control; productises the NetBird/Komodo control plane. Four tensions (offline-first vs real-time, verifiable-ledger-in-cloud, secrets custody, two-level tenancy); signing key stays on the booth; NetBird already solves isolation; remote barrier-open is `pulseOpen`+signed (the unmanned driver).
- [[dingtian-vs-mqtt]] — transport choice: direct HTTP/UDP now, MQTT parked until multi-lane scale. - [[dingtian-vs-mqtt]] — transport choice: direct HTTP/UDP now, MQTT parked until multi-lane scale.
- [[session-model]] — business layer start: session = projection; transient-first; pay-on-foot. New event types. - [[session-model]] — business layer start: session = projection; transient-first; pay-on-foot. New event types.
- [[vision-service]] — build a host-side ANPR + vehicle-verification service; replaces edge-LPR; scoped AGPL exception. - [[vision-service]] — build a host-side ANPR + vehicle-verification service; replaces edge-LPR; scoped AGPL exception.
+79
View File
@@ -2540,3 +2540,82 @@ to no reset-db category, silently surviving even `--all`. Added `--diagnostics`
tariff_drafts under `--config`, and a drift guard that refuses to run when any table is tariff_drafts under `--config`, and a drift guard that refuses to run when any table is
uncategorized ([[local-dev-workflow]], [[appliance-provisioning]] §7d). 8 new tests uncategorized ([[local-dev-workflow]], [[appliance-provisioning]] §7d). 8 new tests
(3 button-light backoff, 5 coalescing); guard + both new wipes verified on a scratch DB. (3 button-light backoff, 5 coalescing); guard + both new wipes verified on a scratch DB.
## [2026-07-13] decision | Cloud service — multi-tenant SaaS (postponed, context captured)
From a design conversation, not a source. The user floated an online, multi-tenant SaaS (the
"cloud service") on TOP of the offline backup model (which stays, as the offline-site tradeoff):
subscribing park sites get real-time (link-up) monitoring of the signed ledger, device status,
and financial reports; one admin owns many sites; the cloud custodies per-site secrets; recurring
per-site fee = a revenue line. Recorded as [[cloud-service-saas]] (status: open, POSTPONED per the
user) so it isn't re-derived later. It productises the off-site control plane already stood up in
[[fleet-deployment-komodo]] (Komodo Core + NetBird). Captured: the four hard tensions (offline-first
vs real-time; the ledger must be VERIFIABLE not just displayed in the cloud; central secret custody;
two-level tenancy under operator-as-adversary), the secrets boundary the user confirmed (sync creds
+ device-password ESCROW + app identity — but NOT the signing/ATECC608 key, which stays on the
booth), and TWO in-discussion corrections that stand: (1) NetBird already solves the "cloud reaches
booth" isolation objection — park-buzi is monitored that way today, booth-dialed, nothing exposed;
(2) remote barrier-open is COMPATIBLE with [[barrier-not-a-door]] (it's `pulseOpen`/intent, never
timed-close) and is DRIVEN by the [[autonomous-direction]] unmanned future — gated as a distinct
privilege + a signed ledger event with actor+reason, with the booth as enforcer and a local
fail-open that can't depend on the cloud. Four open questions parked (real-time definition, where
reports are computed, hosting/licensing, custodianship-as-liability). Cross-linked; index count
7→8 decisions.
## [2026-07-13] update | Owner requirement — in-park merchant validations (car-wash "lavazh", bar)
The [[validation-discounts]] feature is now asked-for, not just an industry-survey gap: the park
may host an in-park car-wash and/or bar whose customers the owner wants discharged for the stay —
full comp, free-first-N-minutes (`time-credit`), or consumption-offset (`fixed`, variable amount:
300 ALL consumed vs 500 ALL fee → pay 200). Must be admin-composable at runtime like
tariffs/subscription plans. Driving-cases section added to [[validation-discounts]]. Open: merchant
ownership (owner-run → pure discount; tenant → [[validation-sponsorship]] settlement), who applies
(operator vs merchant code/portal), stacking rules, caps.
## [2026-07-13] update | Merchant validations refined — merchant STATIONS (users), not sponsors
Second pass on the [[validation-discounts]] requirement: ownership immaterial, sponsor layer
dropped. Merchant = a system user on their own device who scans the ticket to validate (signed,
attributed); admin checkbox per station = may collect parking payments (then shift + till +
Z-report apply to them like the booth); paid/zero-due tickets self-exit at the reader.
Consequences: per-station shifts/drawers (breaks the site-wide single-open invariant), exit-reader
live due=0 branch. Details on [[validation-discounts]].
## [2026-07-13] update | Merchant validations settled — validation-only merchants, all money at the booth
Third pass, settled: the merchant-collects-payments variant is REJECTED. Merchant users only scan
+ validate (signed, attributed); every car checks in at the booth to settle (net may be 0 — still
a signed payment) and gets the detailed gross/discount/net receipt there. Per-station
shifts/drawers and the exit-reader due=0 branch are no longer needed — shift/drawer/exit flows
stay as built; Z/X-reports gain discount lines. Build surface: validation_programs master data,
signed validation event, priceSession validations[] extension, merchant scan page, booth
quote/receipt/Z-report lines. Details on [[validation-discounts]].
## [2026-07-13] decision | Merchant validations — design SETTLED, build started
Setup UX on /setup/site (Bar/Lavazh checkboxes → right-column config panel, tabs when both);
fixed UI over generic storage (validation_programs + user binding, well-known bar/lavazh rows,
mutable config — the signed validation event carries resolved values); RBAC = new `validation`
resource (create/read), guard = permission AND station binding; merchant-only users land on
/validate; merchants may void their own unused validation. See [[validation-discounts]].
## [2026-07-13] update | Merchant validations BUILT end-to-end (bar / lavazh)
Shipped the settled design: `validation` permission + ledger event (resolved values, refId-void),
priceSession validations[] canonical fold (timeCredit→percent→fixed→comp, Σ lines ≡ gross−net),
validation_programs(+users) tables (migration 0024, reset-db config category), routes/validations.ts
(programs PUT signs config_change; apply guards: binding → open transient → no dup → maxPerDay →
amount cap; void own-unused-only), PayStation quote/pay/lookup net folding + payment consumption
(grossMinor/discountMinor/validationIds/validationLines), receipt gross+discount lines, Z/X-report
discountTotalMinor ("Zbritje (validime)", printed only when >0), /setup/site two-column Bar/Lavazh
checkboxes + config panel (tabs), /validate merchant screen (merchant-only users land there),
booth-modal gross→lines→net, feed label VALIDIM. 8 new route integration tests + shared fold suite;
workspace build/typecheck/test green. As-built + remaining polish on [[validation-discounts]].
## [2026-07-13] decision | Merchant scan input: HID barcode scanner on web/desktop; camera paths postponed
The bar/lavazh stations use a USB/HID scanner (or hand-keying + Luhn) into /validate on the
web/desktop app. Two evaluated camera alternatives deliberately POSTPONED: web getUserMedia
scanning (blocked on secure-context TLS for LAN phones + weak Code128-via-camera — would want
QR-on-ticket first) and a Tauri v2 Android merchant app (native ML Kit scanning via the official
barcode-scanner plugin; deferred over Android build/distribution overhead + the
configurable-server-URL prerequisite). Full analysis on [[validation-discounts]].