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
This commit is contained in:
@@ -80,6 +80,8 @@ function receiptFigures(
|
||||
currency?: string;
|
||||
tender?: "cash" | "card";
|
||||
graceExitMin?: number;
|
||||
grossMinor?: number;
|
||||
validationLines?: { label: string; discountMinor: number }[];
|
||||
};
|
||||
return {
|
||||
ticketId,
|
||||
@@ -89,6 +91,9 @@ function receiptFigures(
|
||||
currency: p.currency ?? "ALL",
|
||||
tender: p.tender === "card" ? "card" : "cash",
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
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 { EventLog } from "./event-log.js";
|
||||
import { plateForIdentity, platesForIdentities } from "./plate-lookup.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
|
||||
// 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
|
||||
* "full stay minus paid" (which a daily cap collapses toward zero). */
|
||||
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;
|
||||
/** 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. */
|
||||
readonly overstay: boolean;
|
||||
readonly currency: string;
|
||||
@@ -117,6 +127,12 @@ export interface SessionLookup {
|
||||
/** Advisory licence plate recognized for this session (ANPR-on-snapshot). Null when
|
||||
* none. Display/audit only — never an access decision. */
|
||||
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 {
|
||||
@@ -155,19 +171,28 @@ export class PayStation {
|
||||
// Pure pricing shared with the Tariff Lab (priceSession). Only the latest payment
|
||||
// matters for grace/overstay; pass it through. Overstay → fresh period from
|
||||
// grace-expiry; within-grace → settled; unpaid → entry→now running total.
|
||||
// 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 validations = liveValidations(this.#db, identity);
|
||||
const p = priceSession(
|
||||
entry.occurredAt,
|
||||
new Date().toISOString(),
|
||||
structure,
|
||||
last ? [last] : [],
|
||||
category,
|
||||
validations,
|
||||
);
|
||||
return {
|
||||
identity,
|
||||
enteredAt: entry.occurredAt,
|
||||
periodStart: p.periodStart,
|
||||
amountMinor: p.amountMinor,
|
||||
grossMinor: p.grossMinor,
|
||||
discountMinor: p.discountMinor,
|
||||
validationLines: p.validationLines,
|
||||
validationIds: validations.map((v) => v.eventId),
|
||||
overstay: p.overstay,
|
||||
currency: tv.currency,
|
||||
tariffVersionId: tv.id,
|
||||
@@ -246,6 +271,18 @@ export class PayStation {
|
||||
// The exit flow reads graceExitMin off the payment to validate the
|
||||
// walk-back window without re-resolving the tariff.
|
||||
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 } : {}),
|
||||
},
|
||||
});
|
||||
@@ -282,6 +319,7 @@ export class PayStation {
|
||||
paidAt: null, amountMinor: null, currency: null, paidMinor: null, paidCurrency: null,
|
||||
withinGrace: false, graceExpiresAt: 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.
|
||||
@@ -319,11 +357,17 @@ export class PayStation {
|
||||
// exit gate clears. See wiki/entities/subscription.md.
|
||||
let amountMinor: number | null = null;
|
||||
let currency: string | null = null;
|
||||
let grossMinor: number | null = null;
|
||||
let discountMinor: number | null = null;
|
||||
let validationLines: ValidationLine[] = [];
|
||||
if (open && !isSubscription) {
|
||||
try {
|
||||
const q = this.quote(id);
|
||||
amountMinor = q.amountMinor;
|
||||
currency = q.currency;
|
||||
grossMinor = q.grossMinor;
|
||||
discountMinor = q.discountMinor;
|
||||
validationLines = q.validationLines;
|
||||
} catch {
|
||||
/* no active tariff — leave null; modal shows session without a price */
|
||||
}
|
||||
@@ -344,6 +388,7 @@ export class PayStation {
|
||||
subscription: isSubscription, subscriptionId,
|
||||
subscriptionHolder: this.#holderOf(subscriptionId),
|
||||
plate: plateForIdentity(this.#db, id)?.plate ?? null,
|
||||
grossMinor, discountMinor, validationLines,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
@@ -45,6 +45,7 @@ import { shiftRoutes } from "./routes/shift.js";
|
||||
import { drawerRoutes } from "./routes/drawer.js";
|
||||
import { entryRoutes } from "./routes/entry.js";
|
||||
import { siteRoutes } from "./routes/site.js";
|
||||
import { validationRoutes } from "./routes/validations.js";
|
||||
import { snapshotRoutes } from "./routes/snapshots.js";
|
||||
import { tariffRoutes } from "./routes/tariffs.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.
|
||||
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) +
|
||||
// read the store (GET /api/logs, log:read). See wiki/concepts/app-logs.md.
|
||||
await logRoutes(app, logService);
|
||||
|
||||
@@ -55,6 +55,7 @@ export interface ShiftSummary {
|
||||
readonly subscriptionTotalMinor: number;
|
||||
readonly subscriptionSalesMinor: number;
|
||||
readonly subscriptionWindowMinor: number;
|
||||
readonly discountTotalMinor: number;
|
||||
readonly openingFloatMinor: number;
|
||||
readonly cashAddedMinor: number;
|
||||
readonly cashRemovedMinor: number;
|
||||
@@ -78,6 +79,9 @@ export interface ShiftReport {
|
||||
readonly subscriptionSalesMinor: number;
|
||||
/** Subscriber OUT-OF-WINDOW transient-tariff charges only. */
|
||||
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) ---
|
||||
/** Cash in the drawer at shift start = prior shift's expected closing drawer. */
|
||||
readonly openingFloatMinor: number;
|
||||
@@ -220,6 +224,7 @@ export class ShiftService {
|
||||
subscriptionTotalMinor?: number;
|
||||
subscriptionSalesMinor?: number;
|
||||
subscriptionWindowMinor?: number;
|
||||
discountTotalMinor?: number;
|
||||
openingFloatMinor?: number;
|
||||
cashAddedMinor?: number;
|
||||
cashRemovedMinor?: number;
|
||||
@@ -250,6 +255,8 @@ export class ShiftService {
|
||||
ticketTotalMinor:
|
||||
pl.ticketTotalMinor ??
|
||||
(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,
|
||||
cashAddedMinor: pl.cashAddedMinor ?? 0,
|
||||
cashRemovedMinor: pl.cashRemovedMinor ?? 0,
|
||||
@@ -522,6 +529,9 @@ export class ShiftService {
|
||||
// the subscription sale path).
|
||||
let subscriptionSalesMinor = 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;
|
||||
for (const p of payments) {
|
||||
const pl = (p.payload ?? {}) as LedgerPayload & {
|
||||
@@ -534,6 +544,7 @@ export class ShiftService {
|
||||
if (pl.subscriptionSale === true) subscriptionSalesMinor += amt;
|
||||
else if (pl.subscriptionWindowCharge === true) subscriptionWindowMinor += amt;
|
||||
// (else → transient ticket; derived below as total − subscription)
|
||||
if (typeof pl.discountMinor === "number") discountTotalMinor += pl.discountMinor;
|
||||
if (pl.currency) currency = pl.currency;
|
||||
}
|
||||
const subscriptionTotalMinor = subscriptionSalesMinor + subscriptionWindowMinor;
|
||||
@@ -589,6 +600,7 @@ export class ShiftService {
|
||||
subscriptionTotalMinor,
|
||||
subscriptionSalesMinor,
|
||||
subscriptionWindowMinor,
|
||||
discountTotalMinor,
|
||||
openingFloatMinor,
|
||||
cashAddedMinor,
|
||||
cashRemovedMinor,
|
||||
@@ -627,6 +639,7 @@ export class ShiftService {
|
||||
subscriptionTotalMinor,
|
||||
subscriptionSalesMinor,
|
||||
subscriptionWindowMinor,
|
||||
discountTotalMinor,
|
||||
openingFloatMinor,
|
||||
cashAddedMinor,
|
||||
cashRemovedMinor,
|
||||
@@ -649,6 +662,7 @@ export class ShiftService {
|
||||
subscriptionTotalMinor,
|
||||
subscriptionSalesMinor,
|
||||
subscriptionWindowMinor,
|
||||
discountTotalMinor,
|
||||
openingFloatMinor,
|
||||
cashAddedMinor,
|
||||
cashRemovedMinor,
|
||||
@@ -692,6 +706,9 @@ export class ShiftService {
|
||||
// (subscriptionSalesMinor stays in the signed payload — it's just not printed.)
|
||||
`Abonime: ${money(r.subscriptionTotalMinor)} ${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 --",
|
||||
`Gjëndje fillestare: ${money(r.openingFloatMinor)} ${cur}`,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -383,6 +383,30 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
||||
/>
|
||||
</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
|
||||
out-of-window window charge; then show that amount. For an overstay the
|
||||
amount is the TOP-UP delta, not the whole stay. */}
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import { useEffect, useState } from "react";
|
||||
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
|
||||
// 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 [anprEntry, setAnprEntry] = useState(true);
|
||||
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() {
|
||||
fetchOccupancy().then(setOcc).catch(() => {});
|
||||
}
|
||||
useEffect(() => {
|
||||
reload();
|
||||
if (canEdit) {
|
||||
fetchValidationPrograms()
|
||||
.then((r) => setPrograms(r.programs))
|
||||
.catch(() => {});
|
||||
}
|
||||
fetchSiteConfig()
|
||||
.then((c) => {
|
||||
setCapInput(c.capacity == null ? "" : String(c.capacity));
|
||||
@@ -45,7 +64,23 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
|
||||
setMeta(m);
|
||||
})
|
||||
.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() {
|
||||
setMsg(null);
|
||||
@@ -68,7 +103,8 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
|
||||
}
|
||||
|
||||
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]">
|
||||
<strong className="uppercase tracking-wider text-term-muted">{t("site.occupancy")}</strong>
|
||||
{occ == null ? (
|
||||
@@ -127,6 +163,23 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
|
||||
<span className="hint block">{t("site.anprEntryHint")}</span>
|
||||
</span>
|
||||
</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">
|
||||
{t("site.parkDetails")}
|
||||
</div>
|
||||
@@ -158,5 +211,12 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
{canEdit && (
|
||||
<ValidationStationsPanel
|
||||
programs={programs}
|
||||
onSaved={(p) => setPrograms((ps) => [...ps.filter((x) => x.id !== p.id), p])}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -7,7 +7,7 @@
|
||||
|
||||
import { logFailedRequest } from "./lib/logger.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_HEADER = "X-CSRF-Token";
|
||||
@@ -1301,6 +1301,11 @@ export interface SessionLookup {
|
||||
subscriptionHolder: string | null;
|
||||
/** Advisory licence plate recognized for this session (ANPR). Null when none. */
|
||||
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). */
|
||||
@@ -1475,3 +1480,88 @@ export function updatePresenceBypass(patch: { radar?: boolean; camera?: boolean
|
||||
export function setCapacity(capacity: number | null): Promise<SiteConfig> {
|
||||
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) });
|
||||
}
|
||||
|
||||
@@ -65,6 +65,7 @@ export const en: Catalog = {
|
||||
logs: "Logs",
|
||||
backup: "Backup",
|
||||
profile: "Profile",
|
||||
validate: "Validations",
|
||||
},
|
||||
drawer: {
|
||||
stateTitle: "Drawer now",
|
||||
@@ -230,6 +231,7 @@ export const en: Catalog = {
|
||||
evtCashOut: "PAY-OUT",
|
||||
evtCashReview: "REVIEW",
|
||||
evtConfigChange: "CONFIG",
|
||||
evtValidation: "VALIDATION",
|
||||
decision: { authorize: "authorized", deny: "denied" },
|
||||
evtAnomaly: "ANOMALY",
|
||||
evtRefused: "REFUSED",
|
||||
@@ -735,6 +737,51 @@ export const en: Catalog = {
|
||||
fieldPhone: "Phone",
|
||||
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: {
|
||||
title: "Users",
|
||||
add: "+ Add user",
|
||||
|
||||
@@ -68,6 +68,7 @@ export const sq = {
|
||||
logs: "Loget",
|
||||
backup: "Kopje rezervë",
|
||||
profile: "Profili",
|
||||
validate: "Validime",
|
||||
},
|
||||
drawer: {
|
||||
stateTitle: "Arka tani",
|
||||
@@ -235,6 +236,7 @@ export const sq = {
|
||||
evtCashOut: "PAGESË",
|
||||
evtCashReview: "SHQYRTIM",
|
||||
evtConfigChange: "KONFIG",
|
||||
evtValidation: "VALIDIM",
|
||||
decision: { authorize: "autorizuar", deny: "refuzuar" },
|
||||
evtAnomaly: "ANOMALI",
|
||||
evtRefused: "REFUZUAR",
|
||||
@@ -748,6 +750,51 @@ export const sq = {
|
||||
fieldPhone: "Telefoni",
|
||||
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: {
|
||||
title: "Përdoruesit",
|
||||
add: "+ Shto përdorues",
|
||||
|
||||
+27
-3
@@ -46,6 +46,7 @@ import { DrawerManager } from "./DrawerManager.js";
|
||||
import { CARD_PAYMENTS_ENABLED } from "./lib/features.js";
|
||||
import { LogsViewer } from "./LogsViewer.js";
|
||||
import { BackupSettings } from "./BackupSettings.js";
|
||||
import { ValidateScreen } from "./ValidateScreen.js";
|
||||
import { RecycleBin } from "./RecycleBin.js";
|
||||
import { Profile } from "./Profile.js";
|
||||
// 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">
|
||||
<span className="text-sm font-bold uppercase tracking-widest text-term-amber">▮ Parking</span>
|
||||
<nav className="flex items-center gap-1">
|
||||
<NavLink to="/booth" label={t("nav.booth")} />
|
||||
<NavLink to="/shifts" label={t("nav.shifts")} />
|
||||
{show("session:read") && <NavLink to="/booth" label={t("nav.booth")} />}
|
||||
{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
|
||||
user can do either. See wiki/concepts/shift.md. */}
|
||||
{(show("drawer:create") || show("drawer:review")) && (
|
||||
@@ -523,7 +527,12 @@ function RootLayout() {
|
||||
const indexRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
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" });
|
||||
},
|
||||
});
|
||||
@@ -534,6 +543,20 @@ const boothRoute = createRoute({
|
||||
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;
|
||||
// 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
|
||||
@@ -779,6 +802,7 @@ const profileRoute = createRoute({
|
||||
const routeTree = rootRoute.addChildren([
|
||||
indexRoute,
|
||||
boothRoute,
|
||||
validateRoute,
|
||||
...legacyRedirects,
|
||||
profileRoute,
|
||||
shiftRoute,
|
||||
|
||||
@@ -25,6 +25,7 @@ export const EVENT_STYLE: Record<string, { labelKey: string; color: string }> =
|
||||
cash_out: { labelKey: "booth.evtCashOut", color: "text-term-amber" },
|
||||
cash_review: { labelKey: "booth.evtCashReview", color: "text-term-cyan" },
|
||||
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" },
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user