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