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
+17
View File
@@ -55,6 +55,7 @@ export interface ShiftSummary {
readonly subscriptionTotalMinor: number;
readonly subscriptionSalesMinor: number;
readonly subscriptionWindowMinor: number;
readonly discountTotalMinor: number;
readonly openingFloatMinor: number;
readonly cashAddedMinor: number;
readonly cashRemovedMinor: number;
@@ -78,6 +79,9 @@ export interface ShiftReport {
readonly subscriptionSalesMinor: number;
/** Subscriber OUT-OF-WINDOW transient-tariff charges only. */
readonly subscriptionWindowMinor: number;
/** Merchant-validation DISCOUNT total given away in the window (leakage — the
* cash/card figures above are already NET of it). See validation-discounts.md. */
readonly discountTotalMinor: number;
// --- Drawer (physical cash till; carries across shifts) ---
/** Cash in the drawer at shift start = prior shift's expected closing drawer. */
readonly openingFloatMinor: number;
@@ -220,6 +224,7 @@ export class ShiftService {
subscriptionTotalMinor?: number;
subscriptionSalesMinor?: number;
subscriptionWindowMinor?: number;
discountTotalMinor?: number;
openingFloatMinor?: number;
cashAddedMinor?: number;
cashRemovedMinor?: number;
@@ -250,6 +255,8 @@ export class ShiftService {
ticketTotalMinor:
pl.ticketTotalMinor ??
(pl.cashTotalMinor ?? 0) + (pl.cardTotalMinor ?? 0) - (pl.subscriptionTotalMinor ?? 0),
// Merchant-validation leakage (added 2026-07-13). Old reports lack it → 0.
discountTotalMinor: pl.discountTotalMinor ?? 0,
openingFloatMinor: pl.openingFloatMinor ?? 0,
cashAddedMinor: pl.cashAddedMinor ?? 0,
cashRemovedMinor: pl.cashRemovedMinor ?? 0,
@@ -522,6 +529,9 @@ export class ShiftService {
// the subscription sale path).
let subscriptionSalesMinor = 0;
let subscriptionWindowMinor = 0;
// Merchant-validation leakage: Σ discountMinor across the window's payments. The
// tender totals are already NET; this is the "given away" figure beside them.
let discountTotalMinor = 0;
let currency: string | null = null;
for (const p of payments) {
const pl = (p.payload ?? {}) as LedgerPayload & {
@@ -534,6 +544,7 @@ export class ShiftService {
if (pl.subscriptionSale === true) subscriptionSalesMinor += amt;
else if (pl.subscriptionWindowCharge === true) subscriptionWindowMinor += amt;
// (else → transient ticket; derived below as total − subscription)
if (typeof pl.discountMinor === "number") discountTotalMinor += pl.discountMinor;
if (pl.currency) currency = pl.currency;
}
const subscriptionTotalMinor = subscriptionSalesMinor + subscriptionWindowMinor;
@@ -589,6 +600,7 @@ export class ShiftService {
subscriptionTotalMinor,
subscriptionSalesMinor,
subscriptionWindowMinor,
discountTotalMinor,
openingFloatMinor,
cashAddedMinor,
cashRemovedMinor,
@@ -627,6 +639,7 @@ export class ShiftService {
subscriptionTotalMinor,
subscriptionSalesMinor,
subscriptionWindowMinor,
discountTotalMinor,
openingFloatMinor,
cashAddedMinor,
cashRemovedMinor,
@@ -649,6 +662,7 @@ export class ShiftService {
subscriptionTotalMinor,
subscriptionSalesMinor,
subscriptionWindowMinor,
discountTotalMinor,
openingFloatMinor,
cashAddedMinor,
cashRemovedMinor,
@@ -692,6 +706,9 @@ export class ShiftService {
// (subscriptionSalesMinor stays in the signed payload — it's just not printed.)
`Abonime: ${money(r.subscriptionTotalMinor)} ${cur}`,
`Jashtë orarit: ${money(r.subscriptionWindowMinor)} ${cur}`,
// Merchant-validation leakage — printed only when the shift actually gave any
// (older slips stay byte-identical). The takings above are already NET of it.
...(r.discountTotalMinor > 0 ? [`Zbritje (validime): ${money(r.discountTotalMinor)} ${cur}`] : []),
"",
"-- Arka --",
`Gjëndje fillestare: ${money(r.openingFloatMinor)} ${cur}`,