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,88 @@
|
||||
import { eq, ledgerEvents, type Db } from "@parking/db";
|
||||
import type { SessionValidation, ValidationMode } from "@parking/shared";
|
||||
|
||||
// Merchant-validation ledger folds. A validation is a SIGNED, appended event on the
|
||||
// session (never a mutable flag): payload carries the RESOLVED values (programId,
|
||||
// label, mode, minutes/amountMinor/percent) + the merchant username. A validation
|
||||
// event with `refId` set VOIDS the referenced one; a payment's `validationIds` marks
|
||||
// which validations it CONSUMED (so an overstay's fresh period never re-applies
|
||||
// them). See wiki/concepts/validation-discounts.md.
|
||||
|
||||
/** A validation event folded with its lifecycle state. */
|
||||
export interface AppliedValidation extends SessionValidation {
|
||||
readonly eventId: string;
|
||||
readonly occurredAt: string;
|
||||
/** The merchant username who applied it. */
|
||||
readonly operator: string | null;
|
||||
/** Voided by a later validation event referencing it. */
|
||||
readonly voided: boolean;
|
||||
/** The payment event id that consumed it, if settled. */
|
||||
readonly consumedBy: string | null;
|
||||
}
|
||||
|
||||
/** All validations ever applied to a session (newest last), with voided/consumed
|
||||
* state folded from the chain. One identity-scoped ledger scan. */
|
||||
export function sessionValidations(db: Db, identity: string): AppliedValidation[] {
|
||||
const rows = db
|
||||
.select({
|
||||
id: ledgerEvents.id,
|
||||
type: ledgerEvents.type,
|
||||
occurredAt: ledgerEvents.occurredAt,
|
||||
payload: ledgerEvents.payload,
|
||||
})
|
||||
.from(ledgerEvents)
|
||||
.where(eq(ledgerEvents.identity, identity))
|
||||
.orderBy(ledgerEvents.index)
|
||||
.all();
|
||||
|
||||
const voided = new Set<string>();
|
||||
const consumedBy = new Map<string, string>();
|
||||
const applies: AppliedValidation[] = [];
|
||||
|
||||
for (const r of rows) {
|
||||
const p = (r.payload ?? {}) as {
|
||||
refId?: string;
|
||||
programId?: string;
|
||||
programLabel?: string;
|
||||
mode?: ValidationMode;
|
||||
minutes?: number;
|
||||
amountMinor?: number;
|
||||
percent?: number;
|
||||
operator?: string;
|
||||
validationIds?: string[];
|
||||
};
|
||||
if (r.type === "validation") {
|
||||
if (p.refId) {
|
||||
voided.add(p.refId);
|
||||
} else if (p.programId && p.mode) {
|
||||
applies.push({
|
||||
eventId: r.id,
|
||||
occurredAt: r.occurredAt,
|
||||
programId: p.programId,
|
||||
label: p.programLabel ?? p.programId,
|
||||
mode: p.mode,
|
||||
...(typeof p.minutes === "number" ? { minutes: p.minutes } : {}),
|
||||
...(typeof p.amountMinor === "number" ? { amountMinor: p.amountMinor } : {}),
|
||||
...(typeof p.percent === "number" ? { percent: p.percent } : {}),
|
||||
operator: p.operator ?? null,
|
||||
voided: false,
|
||||
consumedBy: null,
|
||||
});
|
||||
}
|
||||
} else if (r.type === "payment" && Array.isArray(p.validationIds)) {
|
||||
for (const vid of p.validationIds) consumedBy.set(vid, r.id);
|
||||
}
|
||||
}
|
||||
|
||||
return applies.map((a) => ({
|
||||
...a,
|
||||
voided: voided.has(a.eventId),
|
||||
consumedBy: consumedBy.get(a.eventId) ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
/** The LIVE validations for pricing: applied, not voided, not consumed by a prior
|
||||
* payment. This is exactly what `priceSession(..., validations)` expects. */
|
||||
export function liveValidations(db: Db, identity: string): AppliedValidation[] {
|
||||
return sessionValidations(db, identity).filter((v) => !v.voided && v.consumedBy == null);
|
||||
}
|
||||
Reference in New Issue
Block a user