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:
2026-07-13 19:49:58 +02:00
parent ba7538aeb5
commit 692dff5f89
24 changed files with 1939 additions and 14 deletions
+91 -1
View File
@@ -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) });
}