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:
@@ -202,6 +202,9 @@ const STR = {
|
||||
tenderCard: "Kartë",
|
||||
/** "Paid:" amount label (precedes the large total). */
|
||||
amountLabel: "PAGUAR",
|
||||
/** Merchant-validation lines: the pre-discount fee + one line per discount. */
|
||||
gross: (v: string) => `Tarifa: ${v}`,
|
||||
discount: (label: string, v: string) => `${label}: -${v}`,
|
||||
/** Walk-back grace emphasis (voucher mode) — two short lines that each fit the
|
||||
* 80mm width, so neither wraps mid-word. */
|
||||
graceLines: (min: number): readonly string[] => [
|
||||
@@ -413,6 +416,16 @@ export function renderReceipt(data: ReceiptData): Buffer {
|
||||
line(
|
||||
STR.tender(data.tender === "card" ? STR.tenderCard : STR.tenderCash),
|
||||
),
|
||||
// Merchant validations: gross fee + one line per discount, so the customer sees
|
||||
// the full gross → discounts → net story (the big amount below is the NET).
|
||||
...(data.validationLines?.length
|
||||
? [
|
||||
line(STR.gross(money(data.grossMinor ?? data.amountMinor, data.currency))),
|
||||
...data.validationLines.map((v) =>
|
||||
line(STR.discount(v.label, money(v.discountMinor, data.currency))),
|
||||
),
|
||||
]
|
||||
: []),
|
||||
line(),
|
||||
// The amount, large and centred.
|
||||
ALIGN_CENTER,
|
||||
|
||||
@@ -275,6 +275,11 @@ export interface ReceiptData {
|
||||
readonly voucher: boolean;
|
||||
/** Minutes the customer has to reach the exit after paying (voucher mode only). */
|
||||
readonly graceExitMin?: number | null;
|
||||
/** Merchant validations (bar/lavazh): the PRE-discount fee and the per-validation
|
||||
* lines. When present, `amountMinor` is the NET actually paid and the receipt
|
||||
* shows the full gross → discounts → net story. See validation-discounts.md. */
|
||||
readonly grossMinor?: number | null;
|
||||
readonly validationLines?: readonly { label: string; discountMinor: number }[];
|
||||
readonly header?: TicketHeader;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user