feat(carwash): Car Wash v1 + per-till shifts + site-level pay-at + till access by module permission
Car Wash — the pilot venue module (wiki/decisions/venue-modules.md): - Master data (categories × services price matrix) at /setup/carwash; the desk at /wash (ticket lookup → order; open queue oldest-first: Done / Paid cash / Paid card / Void; Finished list). Orders freeze names + price; their life is signed (carwash_order, carwash_payment). Migration 0027. - Where money is taken is a SITE setting (carwash_config.pay_at, migration 0028, signed config_change on a flip) — no per-order radio; a stale client is refused (409). - Core seams: PayStation charge providers (a booth-paid wash rides the parking payment as chargeLines) + applyValidation() shared with the merchant route. A bay-paid, done wash signs the $0 parking payment so the exit reader releases the car. - "Parking discount" modes for the wash: free while the wash runs (+ tolerance) and wash price off the fee (floored at 0), resolved at done and anchored at the order's intake (the entry-anchored version comped a 74-day stay); typed-amount and percent hidden for the wash. Long durations render y/d/h/m. Tills — a shift belongs to a till, not the site (wiki/concepts/shift.md §Tills): - TillId booth|carwash; every money event names its till (absent = booth, so the chain re-folds identically). ShiftService is per till: single-open, folds, X/Z-reports, vouchers, carry-forward. A bay payment needs the carwash shift. - Working a till needs that till's module permission (manifest tillPermission; 403 till_forbidden); /api/shift/tills lists only the role's tills. - Web: ShiftButton per till (header = booth, wash desk = carwash); shift hub lists every open shift with till badges + filter; drawer hub switches tills. Modules: landing per module (index route resolves booth → module landing → shifts → profile); guards bounce to "/", /booth needs session:read. Tests: carwash e2e suite (settings, intake, booth/bay paths, modes, void, gate, pay-at policy, till permissions), 6 per-till shift tests; suite green (1 pre-existing flaky backup test under the parallel run). Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import type { Db } from "@parking/db";
|
||||
import { requirePermission, roleHasPermissions } from "../auth.js";
|
||||
import { accessibleTillsFor, parseTill } from "../modules.js";
|
||||
import { InvalidCashMovementError, type MovementStatus, type ShiftService } from "../shift-service.js";
|
||||
|
||||
// Drawer cash movements (manned mode). Redesigned 2026-07-01: an operator RECORDS a
|
||||
@@ -14,6 +16,8 @@ import { InvalidCashMovementError, type MovementStatus, type ShiftService } from
|
||||
// amount that carries across shifts).
|
||||
// The drawer BALANCE math is unchanged — a movement counts immediately; a denial is a
|
||||
// judgment about the operator settled outside the app, never a cash reversal.
|
||||
// TILLS: a movement names the drawer it moved in/out of (`till`, default booth); the
|
||||
// balance and the list take a `till` filter. See wiki/concepts/shift.md "Tills".
|
||||
|
||||
interface MovementBody {
|
||||
/** Direction is the document TYPE, not a sign: cash_in = Mandat Arkëtimi (pay-IN),
|
||||
@@ -23,6 +27,8 @@ interface MovementBody {
|
||||
amountMinor: number;
|
||||
reason?: string;
|
||||
currency?: string;
|
||||
/** Which drawer (default: the booth). */
|
||||
till?: string;
|
||||
}
|
||||
|
||||
interface ReviewBody {
|
||||
@@ -36,9 +42,11 @@ interface ReviewBody {
|
||||
interface MovementsQuery {
|
||||
/** Reviewers only: filter to pending/authorized/denied. Ignored for non-reviewers. */
|
||||
status?: MovementStatus;
|
||||
/** Filter to one till; absent = every till. */
|
||||
till?: string;
|
||||
}
|
||||
|
||||
export async function drawerRoutes(app: FastifyInstance, shift: ShiftService): Promise<void> {
|
||||
export async function drawerRoutes(app: FastifyInstance, db: Db, shift: ShiftService): Promise<void> {
|
||||
const createGuard = requirePermission("drawer:create");
|
||||
const reviewGuard = requirePermission("drawer:review");
|
||||
const readGuard = requirePermission("shift:read");
|
||||
@@ -49,6 +57,12 @@ export async function drawerRoutes(app: FastifyInstance, shift: ShiftService): P
|
||||
if (b.type !== "cash_in" && b.type !== "cash_out") {
|
||||
return reply.code(400).send({ error: "type must be cash_in or cash_out" });
|
||||
}
|
||||
const till = parseTill(db, b.till);
|
||||
if (!till) return reply.code(400).send({ error: "unknown till", code: "bad_till" });
|
||||
// Moving a till's cash needs that till's module permission (see routes/shift.ts).
|
||||
if (!accessibleTillsFor(db, req.user.roleId).includes(till)) {
|
||||
return reply.code(403).send({ error: `your role cannot work the ${till} till`, code: "till_forbidden", till });
|
||||
}
|
||||
try {
|
||||
return await shift.recordVoucher({
|
||||
type: b.type,
|
||||
@@ -56,6 +70,7 @@ export async function drawerRoutes(app: FastifyInstance, shift: ShiftService): P
|
||||
amountMinor: b.amountMinor,
|
||||
reason: b.reason ?? "",
|
||||
currency: b.currency,
|
||||
till,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof InvalidCashMovementError) return reply.code(400).send({ error: err.message });
|
||||
@@ -65,20 +80,27 @@ export async function drawerRoutes(app: FastifyInstance, shift: ShiftService): P
|
||||
|
||||
// List movements + review status. Operators are hard-scoped to their OWN movements; a
|
||||
// reviewer sees ALL and may filter by status (the pending review queue).
|
||||
app.get<{ Querystring: MovementsQuery }>("/api/drawer/movements", { preHandler: readGuard }, async (req) => {
|
||||
app.get<{ Querystring: MovementsQuery }>("/api/drawer/movements", { preHandler: readGuard }, async (req, reply) => {
|
||||
const canReview = roleHasPermissions(req.user.roleId, ["drawer:review"]);
|
||||
const q = req.query ?? {};
|
||||
const status = canReview && ["pending", "authorized", "denied"].includes(q.status ?? "") ? q.status : undefined;
|
||||
const till = q.till?.trim() ? parseTill(db, q.till.trim()) : undefined;
|
||||
if (till === null) return reply.code(400).send({ error: "unknown till", code: "bad_till" });
|
||||
const movements = shift.movementsWithStatus({
|
||||
operator: canReview ? undefined : req.user.username,
|
||||
status,
|
||||
till,
|
||||
});
|
||||
return { movements, scope: canReview ? "all" : "self" };
|
||||
});
|
||||
|
||||
// The physical drawer balance now. Same visibility as the open shift's X-report
|
||||
// (shift:read) — the drawer is a single site-wide till, not per-operator data.
|
||||
app.get("/api/drawer/balance", { preHandler: readGuard }, async () => shift.drawerBalance());
|
||||
// A till's physical drawer balance now. Same visibility as the open shift's X-report
|
||||
// (shift:read) — a drawer is a shared till, not per-operator data.
|
||||
app.get<{ Querystring: { till?: string } }>("/api/drawer/balance", { preHandler: readGuard }, async (req, reply) => {
|
||||
const till = parseTill(db, req.query?.till);
|
||||
if (!till) return reply.code(400).send({ error: "unknown till", code: "bad_till" });
|
||||
return { till, ...shift.drawerBalance(till) };
|
||||
});
|
||||
|
||||
// Admin AUTHORIZES or DENIES a recorded movement. A flag only — no cash reversal.
|
||||
app.post<{ Body: ReviewBody }>("/api/drawer/review", { preHandler: reviewGuard }, async (req, reply) => {
|
||||
|
||||
@@ -142,7 +142,7 @@ describe("drawer balance (the till NOW)", () => {
|
||||
const { cookie } = await login(app, viewer.username, viewer.password);
|
||||
const ok = await app.inject({ method: "GET", url: "/api/drawer/balance", headers: { cookie } });
|
||||
expect(ok.statusCode).toBe(200);
|
||||
expect(ok.json()).toEqual({ balanceMinor: 0, currency: null });
|
||||
expect(ok.json()).toEqual({ till: "booth", balanceMinor: 0, currency: null });
|
||||
|
||||
const outsider = await seedUser(db, { username: "noshift", roleId: "noshift", permissions: ["site:read"] });
|
||||
const other = await login(app, outsider.username, outsider.password);
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import type { FastifyInstance, FastifyReply } from "fastify";
|
||||
import type { Db } from "@parking/db";
|
||||
import type { TillId } from "@parking/shared";
|
||||
import { requirePermission, roleHasPermissions } from "../auth.js";
|
||||
import { accessibleTillsFor, effectiveTillsFor, parseTill } from "../modules.js";
|
||||
import { NoOpenShiftError, ShiftAlreadyOpenError, type ShiftService } from "../shift-service.js";
|
||||
|
||||
interface ShiftsQuery {
|
||||
@@ -8,43 +11,85 @@ interface ShiftsQuery {
|
||||
/** ISO window over shift START time. */
|
||||
from?: string;
|
||||
to?: string;
|
||||
/** Filter to one till; absent = every till. */
|
||||
till?: string;
|
||||
}
|
||||
|
||||
interface TillQuery {
|
||||
/** Which till (default: the booth). */
|
||||
till?: string;
|
||||
}
|
||||
interface TillBody {
|
||||
till?: string;
|
||||
}
|
||||
|
||||
// Shift endpoints (manned mode). The operator is the logged-in user; a shift is
|
||||
// opened/closed explicitly (not time-based — see wiki/concepts/shift.md and
|
||||
// local-jwt-auth.md "until logout"). End Shift signs a shift_z_report + prints it.
|
||||
//
|
||||
// TILLS: every endpoint takes a `till` (query on GET, body on POST; default booth).
|
||||
// A till is addressable only when the module that declares it is effective here
|
||||
// (400 otherwise) — the wash desk's shift control passes till=carwash. WORKING a till
|
||||
// (open/close, its state) additionally needs the role to hold that till's module
|
||||
// permission (booth: session:read; carwash: carwash:read) — 403 `till_forbidden` — so a
|
||||
// wash operator's role can never open the booth's shift, nor a booth operator the
|
||||
// wash's. History (`/api/shifts`) stays scoped by shift:read/cash, not by till.
|
||||
|
||||
export async function shiftRoutes(app: FastifyInstance, shift: ShiftService): Promise<void> {
|
||||
export async function shiftRoutes(app: FastifyInstance, db: Db, shift: ShiftService): Promise<void> {
|
||||
// Reading the shift state vs. opening/closing one's own shift.
|
||||
const readGuard = requirePermission("shift:read");
|
||||
const guard = requirePermission("shift:create");
|
||||
|
||||
// The SITE-WIDE shift state (at most one shift open at a time). The UI uses this
|
||||
// to render the header control: no shift → "Open"; my shift → "Close" (enabled);
|
||||
// someone else's shift → disabled. Also returns the live drawer balance.
|
||||
// - open: the open shift { startedAt, operator } or null (site-wide)
|
||||
// - isMine: true iff the open shift belongs to the requesting operator
|
||||
// - operator: the requesting user (for the UI's own identity)
|
||||
app.get("/api/shift/current", { preHandler: readGuard }, async (req) => {
|
||||
const me = req.user.username;
|
||||
const open = shift.currentOpenShift();
|
||||
const badTill = (reply: FastifyReply) => reply.code(400).send({ error: "unknown till", code: "bad_till" });
|
||||
const forbidden = (reply: FastifyReply, till: TillId) =>
|
||||
reply.code(403).send({ error: `your role cannot work the ${till} till`, code: "till_forbidden", till });
|
||||
const mayWork = (roleId: string, till: TillId) => accessibleTillsFor(db, roleId).includes(till);
|
||||
|
||||
const statusOf = (till: TillId, me: string) => {
|
||||
const open = shift.currentOpenShift(till);
|
||||
const heldBy = open?.identity ?? null;
|
||||
const drawer = shift.drawerBalance();
|
||||
const drawer = shift.drawerBalance(till);
|
||||
return {
|
||||
operator: me,
|
||||
till,
|
||||
open: open ? { startedAt: open.occurredAt, operator: heldBy } : null,
|
||||
isMine: open != null && heldBy === me,
|
||||
drawerMinor: drawer.balanceMinor,
|
||||
currency: drawer.currency,
|
||||
};
|
||||
};
|
||||
|
||||
// The shift state of ONE till (at most one shift open per till). The UI uses this
|
||||
// to render a till's control: no shift → "Open"; my shift → "Close" (enabled);
|
||||
// someone else's shift → disabled. Also returns the live drawer balance.
|
||||
// - till: which till this describes
|
||||
// - open: the open shift { startedAt, operator } or null
|
||||
// - isMine: true iff the open shift belongs to the requesting operator
|
||||
// - operator: the requesting user (for the UI's own identity)
|
||||
// - tills: every till THIS ROLE may work (the booth + effective modules' tills it
|
||||
// holds the permission for) — what the UI offers controls for
|
||||
app.get<{ Querystring: TillQuery }>("/api/shift/current", { preHandler: readGuard }, async (req, reply) => {
|
||||
const till = parseTill(db, req.query?.till);
|
||||
if (!till) return badTill(reply);
|
||||
if (!mayWork(req.user.roleId, till)) return forbidden(reply, till);
|
||||
return { operator: req.user.username, tills: accessibleTillsFor(db, req.user.roleId), ...statusOf(till, req.user.username) };
|
||||
});
|
||||
|
||||
// The state of every till this role may work, in one read — the shift hub lists
|
||||
// each open shift and offers "start" for the idle ones.
|
||||
app.get("/api/shift/tills", { preHandler: readGuard }, async (req) => {
|
||||
const me = req.user.username;
|
||||
return { operator: me, tills: accessibleTillsFor(db, req.user.roleId).map((t) => statusOf(t, me)) };
|
||||
});
|
||||
|
||||
// Mid-shift X-report: a READ-ONLY "so far" snapshot of the OPEN shift's takings +
|
||||
// drawer (opening float, cash/card taken, pay-ins/outs, expected drawer), computed
|
||||
// as of now. Appends nothing — it's not an accountability mark, just a projection
|
||||
// (the Z-report at close is the signed record). 204 when no shift is open.
|
||||
app.get("/api/shift/report", { preHandler: readGuard }, async (_req, reply) => {
|
||||
const report = shift.currentReport();
|
||||
app.get<{ Querystring: TillQuery }>("/api/shift/report", { preHandler: readGuard }, async (req, reply) => {
|
||||
const till = parseTill(db, req.query?.till);
|
||||
if (!till) return badTill(reply);
|
||||
if (!mayWork(req.user.roleId, till)) return forbidden(reply, till);
|
||||
const report = shift.currentReport(till);
|
||||
if (!report) return reply.code(204).send();
|
||||
return report;
|
||||
});
|
||||
@@ -54,36 +99,49 @@ export async function shiftRoutes(app: FastifyInstance, shift: ShiftService): Pr
|
||||
// - `shift:cash` (admin-grade) → all operators, optionally filtered by
|
||||
// `operator` and a `from`/`to` time window over each shift's START.
|
||||
// This keeps one operator from reading another's takings while letting admins
|
||||
// reconcile across the site. The data is the signed shift_z_report chain.
|
||||
app.get<{ Querystring: ShiftsQuery }>("/api/shifts", { preHandler: readGuard }, async (req) => {
|
||||
// reconcile across the site. The data is the signed shift_z_report chain. Both
|
||||
// scopes may filter by `till`.
|
||||
app.get<{ Querystring: ShiftsQuery }>("/api/shifts", { preHandler: readGuard }, async (req, reply) => {
|
||||
const canSeeAll = roleHasPermissions(req.user.roleId, ["shift:cash"]);
|
||||
const q = req.query ?? {};
|
||||
// Non-admins are hard-scoped to themselves regardless of any operator param.
|
||||
const operator = canSeeAll ? (q.operator?.trim() || undefined) : req.user.username;
|
||||
const from = canSeeAll ? q.from?.trim() || undefined : undefined;
|
||||
const to = canSeeAll ? q.to?.trim() || undefined : undefined;
|
||||
const shifts = shift.listShifts({ operator, from, to });
|
||||
let till: TillId | undefined;
|
||||
if (q.till?.trim()) {
|
||||
const parsed = parseTill(db, q.till.trim());
|
||||
if (!parsed) return badTill(reply);
|
||||
till = parsed;
|
||||
}
|
||||
const shifts = shift.listShifts({ operator, from, to, till });
|
||||
// Admins also get the distinct operator list (unfiltered) for the filter
|
||||
// dropdown — operators don't see other names, so it's scope-gated.
|
||||
if (canSeeAll) return { shifts, scope: "all", operators: shift.listOperators() };
|
||||
return { shifts, scope: "self" };
|
||||
if (canSeeAll) return { shifts, scope: "all", operators: shift.listOperators(), tills: effectiveTillsFor(db) };
|
||||
return { shifts, scope: "self", tills: effectiveTillsFor(db) };
|
||||
});
|
||||
|
||||
// NB: drawer cash movements (record/review) moved to routes/drawer.ts (2026-07-01) — the
|
||||
// feature is no longer part of the shift route. See wiki/concepts/shift.md.
|
||||
|
||||
app.post("/api/shift/open", { preHandler: guard }, async (req, reply) => {
|
||||
app.post<{ Body: TillBody }>("/api/shift/open", { preHandler: guard }, async (req, reply) => {
|
||||
const till = parseTill(db, req.body?.till);
|
||||
if (!till) return badTill(reply);
|
||||
if (!mayWork(req.user.roleId, till)) return forbidden(reply, till);
|
||||
try {
|
||||
return await shift.open(req.user.username);
|
||||
return await shift.open(req.user.username, till);
|
||||
} catch (err) {
|
||||
if (err instanceof ShiftAlreadyOpenError) return reply.code(409).send({ error: err.message });
|
||||
return reply.code(500).send({ error: (err as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/api/shift/close", { preHandler: guard }, async (req, reply) => {
|
||||
app.post<{ Body: TillBody }>("/api/shift/close", { preHandler: guard }, async (req, reply) => {
|
||||
const till = parseTill(db, req.body?.till);
|
||||
if (!till) return badTill(reply);
|
||||
if (!mayWork(req.user.roleId, till)) return forbidden(reply, till);
|
||||
try {
|
||||
return await shift.close(req.user.username);
|
||||
return await shift.close(req.user.username, till);
|
||||
} catch (err) {
|
||||
if (err instanceof NoOpenShiftError) return reply.code(409).send({ error: err.message });
|
||||
return reply.code(500).send({ error: (err as Error).message });
|
||||
|
||||
@@ -2,7 +2,7 @@ import { randomBytes, randomUUID } from "node:crypto";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { and, eq, isNull, devices, subscriptionCredentials, subscriptionPlans, subscriptionPlates, subscriptions, type Db } from "@parking/db";
|
||||
import { NoPrinterAvailableError } from "@parking/devices";
|
||||
import type { SubscriptionPlan, SubscriptionQuote, Tender } from "@parking/shared";
|
||||
import { BOOTH_TILL, type SubscriptionPlan, type SubscriptionQuote, type Tender } from "@parking/shared";
|
||||
import { requirePermission, roleHasPermissions } from "../auth.js";
|
||||
import { softDelete } from "../recycle-bin.js";
|
||||
import { invalidateHolder } from "../event-enrich.js";
|
||||
@@ -381,6 +381,7 @@ export async function subscriptionRoutes(
|
||||
amountMinor,
|
||||
currency,
|
||||
tender,
|
||||
till: BOOTH_TILL,
|
||||
operator,
|
||||
// Flags this `payment` as a subscription SALE (not a parking payment) so the
|
||||
// live feed / activity log can label it distinctly. plan + periods for audit
|
||||
|
||||
@@ -10,11 +10,11 @@ import {
|
||||
validationPrograms,
|
||||
type Db,
|
||||
} from "@parking/db";
|
||||
import { VALIDATION_MODES, type ValidationMode } from "@parking/shared";
|
||||
import { MERCHANT_VALIDATION_MODES, VALIDATION_MODES, type ValidationMode } from "@parking/shared";
|
||||
import { requirePermission } from "../auth.js";
|
||||
import { requireModule } from "../modules.js";
|
||||
import type { EventLog } from "../event-log.js";
|
||||
import { liveValidations, sessionValidations } from "../validations.js";
|
||||
import { applyValidation, liveValidations, sessionValidations } from "../validations.js";
|
||||
|
||||
// Merchant validations (bar / lavazh). The merchant is VALIDATION-ONLY: they scan the
|
||||
// customer's ticket on their own device and apply their program — all money and paper
|
||||
@@ -64,12 +64,18 @@ function validateProgram(b: ProgramBody): string | null {
|
||||
if (!b.name || !String(b.name).trim()) return "name is required";
|
||||
if (!VALIDATION_MODES.includes(b.mode as ValidationMode)) return "mode must be comp|timeCredit|fixed|percent";
|
||||
const intOrNull = (v: unknown) => v == null || (Number.isInteger(v) && (v as number) > 0);
|
||||
if (!intOrNull(b.minutes)) return "minutes must be a positive integer";
|
||||
// doneTolerance's minutes is a TOLERANCE — zero is a legitimate "free until done, not a
|
||||
// minute more"; every other minutes use is a positive credit.
|
||||
const minutesOk = b.mode === "doneTolerance"
|
||||
? b.minutes == null || (Number.isInteger(b.minutes) && (b.minutes as number) >= 0)
|
||||
: intOrNull(b.minutes);
|
||||
if (!minutesOk) return b.mode === "doneTolerance" ? "minutes must be a non-negative integer" : "minutes must be a positive integer";
|
||||
if (!intOrNull(b.maxAmountMinor)) return "maxAmountMinor must be a positive integer";
|
||||
if (!intOrNull(b.maxPerDay)) return "maxPerDay must be a positive integer";
|
||||
if (b.percent != null && (!Number.isInteger(b.percent) || b.percent < 1 || b.percent > 100))
|
||||
return "percent must be 1..100";
|
||||
if (b.mode === "timeCredit" && b.minutes == null) return "timeCredit needs minutes";
|
||||
if (b.mode === "doneTolerance" && b.minutes == null) return "doneTolerance needs minutes (the tolerance; 0 allowed)";
|
||||
if (b.mode === "percent" && b.percent == null) return "percent mode needs percent";
|
||||
if (b.mode === "fixed" && b.maxAmountMinor == null) return "fixed mode needs maxAmountMinor";
|
||||
return null;
|
||||
@@ -247,88 +253,21 @@ export async function validationRoutes(app: FastifyInstance, db: Db, eventLog: E
|
||||
if (!boundUserIds(programId).includes(req.user.sub)) {
|
||||
return reply.code(403).send({ error: "you are not bound to this program" });
|
||||
}
|
||||
|
||||
// Session state — an open transient (subscriptions are prepaid; nothing to discount).
|
||||
const rows = db
|
||||
.select({ type: ledgerEvents.type, payload: ledgerEvents.payload })
|
||||
.from(ledgerEvents)
|
||||
.where(eq(ledgerEvents.identity, identity))
|
||||
.orderBy(ledgerEvents.index)
|
||||
.all();
|
||||
const entry = rows.find((r) => r.type === "vehicle_entry");
|
||||
if (!entry) return reply.code(404).send({ error: "no session for ticket" });
|
||||
const entryPl = (entry.payload ?? {}) as { permit?: boolean; permitId?: string };
|
||||
if (entryPl.permit === true || entryPl.permitId != null) {
|
||||
return reply.code(409).send({ error: "subscription sessions cannot be validated" });
|
||||
}
|
||||
if (rows.some((r) => r.type === "vehicle_exit" || r.type === "void")) {
|
||||
return reply.code(409).send({ error: "session is closed" });
|
||||
}
|
||||
if (liveValidations(db, identity).some((v) => v.programId === programId)) {
|
||||
return reply.code(409).send({ error: "this program is already applied to the ticket" });
|
||||
if (!MERCHANT_VALIDATION_MODES.includes(program.mode)) {
|
||||
return reply.code(400).send({ error: "this program's discount is resolved by a car wash order, not at scan" });
|
||||
}
|
||||
|
||||
// Per-day cap: unvoided applications of this program since LOCAL midnight (the
|
||||
// appliance runs in site time).
|
||||
if (program.maxPerDay != null) {
|
||||
const midnight = new Date();
|
||||
midnight.setHours(0, 0, 0, 0);
|
||||
const todays = db
|
||||
.select({ id: ledgerEvents.id, occurredAt: ledgerEvents.occurredAt, payload: ledgerEvents.payload, type: ledgerEvents.type })
|
||||
.from(ledgerEvents)
|
||||
.where(eq(ledgerEvents.type, "validation"))
|
||||
.all()
|
||||
.filter((r) => Date.parse(r.occurredAt) >= midnight.getTime());
|
||||
const voidedIds = new Set(
|
||||
todays.map((r) => (r.payload as { refId?: string } | null)?.refId).filter(Boolean) as string[],
|
||||
);
|
||||
const count = todays.filter((r) => {
|
||||
const p = (r.payload ?? {}) as { programId?: string; refId?: string };
|
||||
return p.programId === programId && !p.refId && !voidedIds.has(r.id);
|
||||
}).length;
|
||||
if (count >= program.maxPerDay) {
|
||||
return reply.code(409).send({ error: "daily cap reached for this program" });
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve the values off the program row (frozen into the signed event).
|
||||
let amountMinor: number | undefined;
|
||||
if (program.mode === "fixed") {
|
||||
const a = req.body?.amountMinor;
|
||||
if (a == null || !Number.isInteger(a) || a <= 0) {
|
||||
return reply.code(400).send({ error: "amountMinor (positive integer) required for this program" });
|
||||
}
|
||||
if (program.maxAmountMinor != null && a > program.maxAmountMinor) {
|
||||
return reply.code(400).send({ error: `amount exceeds the program cap (${program.maxAmountMinor})` });
|
||||
}
|
||||
amountMinor = a;
|
||||
}
|
||||
|
||||
const ev = await eventLog.append({
|
||||
type: "validation",
|
||||
source: "manual",
|
||||
identity,
|
||||
payload: {
|
||||
sessionRef: identity,
|
||||
programId,
|
||||
programLabel: program.name,
|
||||
mode: program.mode,
|
||||
...(program.mode === "timeCredit" && program.minutes != null ? { minutes: program.minutes } : {}),
|
||||
...(program.mode === "percent" && program.percent != null ? { percent: program.percent } : {}),
|
||||
...(amountMinor != null ? { amountMinor } : {}),
|
||||
operator: req.user.username,
|
||||
},
|
||||
});
|
||||
return reply.code(201).send({
|
||||
ok: true,
|
||||
eventId: ev.id,
|
||||
// The decision chain + the signed append live in ../validations.ts (applyValidation)
|
||||
// — shared with the Car Wash module, which applies its own sponsorship program with
|
||||
// no user binding. Only the binding check above is merchant-specific.
|
||||
const result = await applyValidation(db, eventLog, {
|
||||
programId,
|
||||
label: program.name,
|
||||
mode: program.mode,
|
||||
minutes: program.mode === "timeCredit" ? program.minutes : undefined,
|
||||
percent: program.mode === "percent" ? program.percent : undefined,
|
||||
amountMinor,
|
||||
identity,
|
||||
actor: req.user.username,
|
||||
amountMinor: req.body?.amountMinor,
|
||||
});
|
||||
if (!result.ok) return reply.code(result.status).send({ error: result.error });
|
||||
return reply.code(201).send(result);
|
||||
});
|
||||
|
||||
// VOID my own UNUSED validation (fat-fingered amount / wrong ticket). Append-only:
|
||||
|
||||
Reference in New Issue
Block a user