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
@@ -0,0 +1,31 @@
-- Merchant validation programs (2026-07-13). In-park merchants (bar / lavazh) validate a
-- customer's ticket so the BOOTH settlement discounts the fee — the merchant only
-- validates, all money and paper stay at the booth. The /setup/site checkboxes toggle the
-- WELL-KNOWN rows ("bar", "lavazh"); a future merchant is a new row, not a migration.
-- Config is plainly MUTABLE (no versioning): the applied validation is a signed ledger
-- event carrying the RESOLVED values, so reproducibility never depends on these rows.
-- See wiki/concepts/validation-discounts.md.
CREATE TABLE `validation_programs` (
`id` text PRIMARY KEY NOT NULL,
`name` text NOT NULL,
`mode` text DEFAULT 'comp' NOT NULL,
`minutes` integer,
`percent` integer,
`max_amount_minor` integer,
`max_per_day` integer,
`active` integer DEFAULT 0 NOT NULL,
`created_at` text DEFAULT (current_timestamp) NOT NULL,
`deleted_at` text,
`deleted_by` text
);
--> statement-breakpoint
-- WHICH users may apply a program: the apply guard is `validation:create` AND a binding
-- row here — a bar user can never apply the lavazh program.
CREATE TABLE `validation_program_users` (
`program_id` text NOT NULL,
`user_id` text NOT NULL,
FOREIGN KEY (`program_id`) REFERENCES `validation_programs`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE UNIQUE INDEX `validation_program_users_program_id_user_id_unique` ON `validation_program_users` (`program_id`,`user_id`);