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:
@@ -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`);
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"],
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user