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`);
+7
View File
@@ -169,6 +169,13 @@
"when": 1781886600000,
"tag": "0023_driver_id_escpos",
"breakpoints": true
},
{
"idx": 24,
"version": "6",
"when": 1783948800000,
"tag": "0024_validation_programs",
"breakpoints": true
}
]
}
+5
View File
@@ -60,6 +60,11 @@ const CATEGORIES = {
"tariff_versions",
"tariffs",
"subscription_plans",
// Merchant validation programs (bar/lavazh) + their user bindings (child first).
// A --users reset without --config may orphan a binding row; harmless — a binding
// whose user is gone grants nothing.
"validation_program_users",
"validation_programs",
],
users: ["sessions", "role_permissions", "users", "roles"],
diagnostics: ["app_logs"],
+53
View File
@@ -458,6 +458,57 @@ export const subscriptionPlates = sqliteTable("subscription_plates", {
plate: text("plate").notNull(),
});
// --- Merchant validation programs (bar / lavazh) --------------------------
// Admin-composed master data for in-park merchant discounts: the /setup/site
// checkboxes toggle the WELL-KNOWN rows ("bar", "lavazh") — a future merchant is a
// new row, not a migration. Config is plainly MUTABLE (no versioning): the applied
// validation is a signed ledger event carrying the RESOLVED values, so historical
// reproducibility never depends on this row. Enabling/saving signs a config_change.
// See wiki/concepts/validation-discounts.md.
export const validationPrograms = sqliteTable("validation_programs", {
// Well-known slug ("bar" | "lavazh"); generic text so future merchants are rows.
id: text("id").primaryKey(),
// Receipt label printed on the booth settlement line (e.g. "Lavazh — 1 orë falas").
name: text("name").notNull(),
// How the program discounts — see @parking/shared ValidationMode.
mode: text("mode", { enum: ["comp", "timeCredit", "fixed", "percent"] })
.notNull()
.default("comp"),
// timeCredit: the free minutes.
minutes: integer("minutes"),
// percent: 1..100 off the fee.
percent: integer("percent"),
// fixed: cap on the amount the merchant may type at scan time (minor units).
maxAmountMinor: integer("max_amount_minor"),
// Anti-abuse cap: max applications per local day (null = unlimited).
maxPerDay: integer("max_per_day"),
// The /setup/site checkbox. Inactive = merchants can't apply it (row + history kept).
active: integer("active", { mode: "boolean" }).notNull().default(false),
createdAt: text("created_at")
.notNull()
.default(sql`(current_timestamp)`),
// Soft delete (recycle bin) — see roles.deletedAt.
deletedAt: text("deleted_at"),
deletedBy: text("deleted_by"),
});
// The program↔user binding: WHICH users may apply a program (the guard is
// `validation:create` AND a binding row — a bar user can never apply lavazh).
export const validationProgramUsers = sqliteTable(
"validation_program_users",
{
programId: text("program_id")
.notNull()
.references(() => validationPrograms.id),
userId: text("user_id")
.notNull()
.references(() => users.id),
},
(t) => ({
uniq: unique().on(t.programId, t.userId),
}),
);
// --- Blocklist (banlist) -------------------------------------------------
// Plates/cards refused at ENTRY (never at exit — never trap a vehicle). A hit appends
// a signed anomaly/refused-entry ledger event. See wiki/entities/blocklist.md.
@@ -546,5 +597,7 @@ export type SubscriptionPlanRow = typeof subscriptionPlans.$inferSelect;
export type SubscriptionCredentialRow = typeof subscriptionCredentials.$inferSelect;
export type SubscriptionPlateRow = typeof subscriptionPlates.$inferSelect;
export type BlocklistRow = typeof blocklist.$inferSelect;
export type ValidationProgramRow = typeof validationPrograms.$inferSelect;
export type ValidationProgramUserRow = typeof validationProgramUsers.$inferSelect;
export type SessionRow = typeof sessions.$inferSelect;
export type AppLogRow = typeof appLogs.$inferSelect;