From 692dff5f897c56b4d703bcb3268ffbbd6895d987 Mon Sep 17 00:00:00 2001 From: Julian Cuni Date: Mon, 13 Jul 2026 19:49:58 +0200 Subject: [PATCH] feat(validations): merchant (bar/lavazh) ticket validations end-to-end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/server/src/booth-print.ts | 5 + apps/server/src/pay-station.ts | 49 ++- apps/server/src/routes/validations.test.ts | 272 +++++++++++++ apps/server/src/routes/validations.ts | 360 ++++++++++++++++++ apps/server/src/server.ts | 6 + apps/server/src/shift-service.ts | 17 + apps/server/src/validations.ts | 88 +++++ apps/web/src/BoothPayModal.tsx | 24 ++ apps/web/src/SiteSettings.tsx | 66 +++- apps/web/src/ValidateScreen.tsx | 252 ++++++++++++ apps/web/src/ValidationSetup.tsx | 231 +++++++++++ apps/web/src/api.ts | 92 ++++- apps/web/src/lib/i18n/en.ts | 47 +++ apps/web/src/lib/i18n/sq.ts | 47 +++ apps/web/src/router.tsx | 30 +- apps/web/src/ui/event-detail.tsx | 1 + .../db/drizzle/0024_validation_programs.sql | 31 ++ packages/db/drizzle/meta/_journal.json | 7 + packages/db/scripts/reset-db.mjs | 5 + packages/db/src/schema.ts | 53 +++ .../devices/src/drivers/printer-escpos.ts | 13 + packages/devices/src/interfaces.ts | 5 + packages/shared/src/index.ts | 151 +++++++- packages/shared/src/tariff.test.ts | 101 +++++ 24 files changed, 1939 insertions(+), 14 deletions(-) create mode 100644 apps/server/src/routes/validations.test.ts create mode 100644 apps/server/src/routes/validations.ts create mode 100644 apps/server/src/validations.ts create mode 100644 apps/web/src/ValidateScreen.tsx create mode 100644 apps/web/src/ValidationSetup.tsx create mode 100644 packages/db/drizzle/0024_validation_programs.sql diff --git a/apps/server/src/booth-print.ts b/apps/server/src/booth-print.ts index 7c56b31..ecb0532 100644 --- a/apps/server/src/booth-print.ts +++ b/apps/server/src/booth-print.ts @@ -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, }; } diff --git a/apps/server/src/pay-station.ts b/apps/server/src/pay-station.ts index 901b046..15a59d4 100644 --- a/apps/server/src/pay-station.ts +++ b/apps/server/src/pay-station.ts @@ -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, }; } diff --git a/apps/server/src/routes/validations.test.ts b/apps/server/src/routes/validations.test.ts new file mode 100644 index 0000000..550c6de --- /dev/null +++ b/apps/server/src/routes/validations.test.ts @@ -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 { + 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, id = "bar") { + return app.inject({ method: "PUT", url: `/api/validation/programs/${id}`, headers: hdrs(auth), payload: body }); +} + +const fixedProgram = (userId: string, over: Record = {}) => ({ + 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 | 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) => + 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); + }); +}); diff --git a/apps/server/src/routes/validations.ts b/apps/server/src/routes/validations.ts new file mode 100644 index 0000000..3afe60f --- /dev/null +++ b/apps/server/src/routes/validations.ts @@ -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 { + 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 }; + }); +} diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 0993bf8..66184b3 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -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 0 ? [`Zbritje (validime): ${money(r.discountTotalMinor)} ${cur}`] : []), "", "-- Arka --", `Gjëndje fillestare: ${money(r.openingFloatMinor)} ${cur}`, diff --git a/apps/server/src/validations.ts b/apps/server/src/validations.ts new file mode 100644 index 0000000..bd45206 --- /dev/null +++ b/apps/server/src/validations.ts @@ -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(); + const consumedBy = new Map(); + 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); +} diff --git a/apps/web/src/BoothPayModal.tsx b/apps/web/src/BoothPayModal.tsx index 4a27a95..a01aa96 100644 --- a/apps/web/src/BoothPayModal.tsx +++ b/apps/web/src/BoothPayModal.tsx @@ -383,6 +383,30 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose /> + {/* 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 && ( +
+
+ {t("val.gross")} + + {formatMoney(s.grossMinor ?? s.amountMinor, s.currency)} + +
+ {(s.validationLines ?? []).map((v, i) => ( +
+ {v.label} + −{formatMoney(v.discountMinor, s.currency!)} +
+ ))} +
+ )} + {/* 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. */} diff --git a/apps/web/src/SiteSettings.tsx b/apps/web/src/SiteSettings.tsx index 48c319b..7e60440 100644 --- a/apps/web/src/SiteSettings.tsx +++ b/apps/web/src/SiteSettings.tsx @@ -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(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([]); 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 ( -
+
+
{t("site.occupancy")} {occ == null ? ( @@ -127,6 +163,23 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) { {t("site.anprEntryHint")} +
+ {t("val.sectionTitle")} +
+ {t("val.sectionHint")} +
+ {STATIONS.map((id) => ( + + ))} +
{t("site.parkDetails")}
@@ -158,5 +211,12 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
)}
+ {canEdit && ( + setPrograms((ps) => [...ps.filter((x) => x.id !== p.id), p])} + /> + )} +
); } diff --git a/apps/web/src/ValidateScreen.tsx b/apps/web/src/ValidateScreen.tsx new file mode 100644 index 0000000..36c3282 --- /dev/null +++ b/apps/web/src/ValidateScreen.tsx @@ -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; + +/** Human line for what a program grants (the params live on the program row). */ +function programSummary(p: Program, t: (k: string, o?: Record) => 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(null); + const [programId, setProgramId] = useState(null); + const [ticket, setTicket] = useState(""); + const [view, setView] = useState(null); + const [amount, setAmount] = useState(""); + const [msg, setMsg] = useState<{ kind: "ok" | "err"; text: string } | null>(null); + const [busy, setBusy] = useState(false); + const inputRef = useRef(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 ( +
+
+
{t("val.title")}
+ + {programs != null && programs.length === 0 && ( +

{t("val.noPrograms")}

+ )} + + {programs != null && programs.length > 1 && ( +
+ {programs.map((p) => ( + + ))} +
+ )} + {program &&

{program.name} — {programSummary(program, t)}

} + +
{ + e.preventDefault(); + void lookup(); + }} + > + setTicket(e.target.value)} + placeholder={t("val.scanPrompt")} + /> + +
+ + {msg && ( +

+ {msg.text} +

+ )} + + {view && ( +
+ {blocked ? ( +

{blocked}

+ ) : ( + <> +
+ {view.identity} + + {t("val.entry")} {formatRelativeDateTime(view.enteredAt, t)} + {view.enteredAt && <> · {formatDuration(view.enteredAt, new Date().toISOString())}} + +
+ + {program && !alreadyApplied && ( +
+ {program.mode === "fixed" && ( +
+ + {t("val.amountLabel")} + {program.maxAmountMinor != null && ( + + {t("val.amountHint", { max: formatMoney(program.maxAmountMinor, "") })} + + )} + + setAmount(e.target.value)} + placeholder="300" + /> +
+ )} + +
+ )} + + {view.validations.length > 0 && ( +
+
{t("val.existing")}
+
    + {view.validations.map((v) => ( +
  • + {v.label} + {v.amountMinor != null && −{formatMoney(v.amountMinor, "")}} + {v.minutes != null && {v.minutes} min} + {v.percent != null && {v.percent}%} + {v.voided ? ( + ({t("val.voided")}) + ) : v.consumedBy != null ? ( + ({t("val.used")}) + ) : ( + v.operator === user.username && ( + + ) + )} +
  • + ))} +
+
+ )} + + )} +
+ )} +
+
+ ); +} diff --git a/apps/web/src/ValidationSetup.tsx b/apps/web/src/ValidationSetup.tsx new file mode 100644 index 0000000..2dd19a1 --- /dev/null +++ b/apps/web/src/ValidationSetup.tsx @@ -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 { + 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(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>(new Set(program.userIds)); + const [users, setUsers] = useState(null); + const [msg, setMsg] = useState(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 ( +
+
+ {t("val.labelName")} + setName(e.target.value)} placeholder={t("val.labelNamePh")} /> +
+
+ {t("val.mode")} + +
+ {mode === "timeCredit" && ( +
+ {t("val.minutes")} + setMinutes(e.target.value)} placeholder="60" /> +
+ )} + {mode === "percent" && ( +
+ {t("val.percent")} + setPercent(e.target.value)} placeholder="100" /> +
+ )} + {mode === "fixed" && ( +
+ {t("val.maxAmount")} + setMaxAmount(e.target.value)} placeholder="1000" /> +
+ )} +
+ {t("val.maxPerDay")} + setMaxPerDay(e.target.value)} /> +
+
+
{t("val.users")}
+ {t("val.usersHint")} +
+ {users == null ? ( + … + ) : users.length === 0 ? ( + {t("val.noUsers")} + ) : ( + users.map((u) => ( + + )) + )} +
+
+
+ + {msg && {msg}} +
+
+ ); +} + +/** 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(null); + const current = enabled.find((p) => p.id === tab) ?? enabled[0]; + if (!current) return null; + + return ( +
+
{t("val.sectionTitle")}
+ {enabled.length > 1 && ( +
+ {enabled.map((p) => ( + + ))} +
+ )} + +
+ ); +} diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index 8a03149..3257473 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -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 { 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, +): Promise { + 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[] }> { + return apiFetch("/api/validation/mine"); +} + +/** Merchant lookup of a scanned ticket (no money data). validation:create. */ +export function fetchValidationSession(identity: string): Promise { + 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) }); +} diff --git a/apps/web/src/lib/i18n/en.ts b/apps/web/src/lib/i18n/en.ts index 35bda90..8f935c9 100644 --- a/apps/web/src/lib/i18n/en.ts +++ b/apps/web/src/lib/i18n/en.ts @@ -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", diff --git a/apps/web/src/lib/i18n/sq.ts b/apps/web/src/lib/i18n/sq.ts index 7b8166b..350855d 100644 --- a/apps/web/src/lib/i18n/sq.ts +++ b/apps/web/src/lib/i18n/sq.ts @@ -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", diff --git a/apps/web/src/router.tsx b/apps/web/src/router.tsx index bbba508..08cbabe 100644 --- a/apps/web/src/router.tsx +++ b/apps/web/src/router.tsx @@ -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() {
▮ Parking