692dff5f89
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
89 lines
3.2 KiB
TypeScript
89 lines
3.2 KiB
TypeScript
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);
|
|
}
|