Compare commits
93 Commits
d0b609e375
..
v0.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 21bfdce27a | |||
| d3288e29eb | |||
| baf7a4a99d | |||
| 885b410e48 | |||
| a1f3103a76 | |||
| 0fd66b261a | |||
| dfc5a07c10 | |||
| 5aabd7a791 | |||
| 0e9b9f5d82 | |||
| 642c5f4f70 | |||
| cb9f4d4979 | |||
| ea8fe22969 | |||
| 2910672b5a | |||
| 3a176c5cc8 | |||
| 19dff97c74 | |||
| 0ed43239c3 | |||
| 28bd838696 | |||
| 692dff5f89 | |||
| ba7538aeb5 | |||
| bb365b5d6e | |||
| c52a42dad2 | |||
| 22544ecf63 | |||
| ba5b4b1f4e | |||
| 51b160bfc9 | |||
| e2d5105da2 | |||
| 5287be5278 | |||
| 3a85483e6c | |||
| 6ceaadfbf2 | |||
| 7f42805e8d | |||
| cd3b534e51 | |||
| 011fe5a4c4 | |||
| 6f3f6ca596 | |||
| 5443b910c6 | |||
| a02957034d | |||
| ee61c24bb9 | |||
| 3a186d29df | |||
| 827445d514 | |||
| f9887c2a76 | |||
| 7649b897c4 | |||
| ab968eb25e | |||
| 5e1a885dcb | |||
| 6cf3492bff | |||
| ffe8c13a1c | |||
| fcea992e1e | |||
| 81bc2e357c | |||
| 7ef332999e | |||
| a2e102f3dd | |||
| 14638c2e13 | |||
| 4f902d869e | |||
| a5e54a8b93 | |||
| 0180394c45 | |||
| d905dd19b4 | |||
| c5ed3f1308 | |||
| 0b7eb28dfa | |||
| d5ff2097bd | |||
| 1de209be48 | |||
| dc2cdc0a91 | |||
| fd9885e9ec | |||
| 52a89bfa56 | |||
| d9e6c13831 | |||
| 493210bbb0 | |||
| a9f18be700 | |||
| 72ad504b8d | |||
| 5cdf8f227b | |||
| 365b648282 | |||
| c03ef2a34b | |||
| d9829eb61f | |||
| bafa3282c7 | |||
| 93f9ebea05 | |||
| c21babf293 | |||
| 44f34d68c4 | |||
| 43c1f45e29 | |||
| 35e593ab63 | |||
| b4f1418858 | |||
| 9d73561855 | |||
| 094e963e5e | |||
| 6505a4a73b | |||
| 8b65e199a3 | |||
| f486dcbbfc | |||
| c142166972 | |||
| 306d136a08 | |||
| 61b9955160 | |||
| b7e4037fbe | |||
| d2ab2e022e | |||
| 33c4ea1e91 | |||
| 114a32e6f2 | |||
| 018328a877 | |||
| 266e9b0027 | |||
| d92b8d1e6a | |||
| 61de1fe772 | |||
| cfac14e09e | |||
| 1b86750b0d | |||
| 9c6741a485 |
@@ -92,6 +92,8 @@ jobs:
|
||||
context: .
|
||||
file: apps/server/Dockerfile
|
||||
push: true
|
||||
build-args: |
|
||||
BUILD_VERSION=${{ steps.meta.outputs.branch }}-${{ steps.meta.outputs.sha }}
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/parking-server:${{ steps.meta.outputs.branch }}
|
||||
${{ env.REGISTRY }}/parking-server:${{ steps.meta.outputs.branch }}-${{ steps.meta.outputs.sha }}
|
||||
|
||||
+114
-12
@@ -1,12 +1,24 @@
|
||||
name: Release desktop
|
||||
|
||||
# Build the signed Tauri desktop installers on a version tag and publish them as
|
||||
# a Gitea Release. The Tauri auto-updater (apps/web/src/lib/desktop-updater.ts)
|
||||
# fetches these; latest.json + each installer + its .sig are what it needs.
|
||||
# a Gitea Release — TWICE: once on this (private, source) repo for our own
|
||||
# records/history, and once mirrored to mca/public_releases, which is what the
|
||||
# Tauri auto-updater (apps/web/src/lib/desktop-updater.ts) actually points at.
|
||||
#
|
||||
# WHY a separate public repo: the updater runs on offline-first field appliances
|
||||
# with no Gitea credentials, so its endpoint + installer downloads must be
|
||||
# reachable unauthenticated. Mirroring compiled installers to a public
|
||||
# releases-only repo avoids embedding any read token in the shipped app (which
|
||||
# would leak the moment a booth PC is compromised — this box's threat model
|
||||
# names the operator/booth as the primary adversary, see CLAUDE.md). Source
|
||||
# stays private; only signed installers become public, same as most desktop
|
||||
# software. mca/public_releases is shared across apps in the org, not
|
||||
# parking-specific — namespace release tags/asset names accordingly if another
|
||||
# app starts publishing there too.
|
||||
#
|
||||
# Trigger: push a tag like v0.1.0. The job builds .deb/.rpm/.AppImage, signs them
|
||||
# with the updater key (Gitea secrets), assembles latest.json, and uploads
|
||||
# everything to the Release for that tag.
|
||||
# with the updater key (Gitea secrets), assembles latest.json pointing at the
|
||||
# MIRROR repo's asset URLs, uploads to both repos, and mirrors the same assets.
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -73,30 +85,41 @@ jobs:
|
||||
|
||||
- name: Collect artifacts
|
||||
id: collect
|
||||
# Gather the installers + their .sig into a flat dist/ for upload.
|
||||
# Gather the installers + their .sig into a flat dist/ for upload, spaces
|
||||
# stripped from filenames. productName is "Parking System" (a space), so
|
||||
# Tauri's bundle output is e.g. "Parking System_0.1.0_amd64.deb" — an
|
||||
# unescaped space in a filename breaks the later curl asset-upload URL
|
||||
# ("URL rejected: Malformed input to a URL function", hit on the very
|
||||
# first v0.1.0 release) AND would land in latest.json's asset url, which
|
||||
# the updater's plain HTTP GET can't handle either. Rename on copy.
|
||||
run: |
|
||||
set -e
|
||||
BUNDLE=apps/desktop/src-tauri/target/release/bundle
|
||||
mkdir -p dist
|
||||
find "$BUNDLE" \( -name '*.AppImage' -o -name '*.deb' -o -name '*.rpm' \
|
||||
-o -name '*.AppImage.sig' -o -name '*.deb.sig' -o -name '*.rpm.sig' \) \
|
||||
-exec cp {} dist/ \;
|
||||
-print0 | while IFS= read -r -d '' f; do
|
||||
name=$(basename "$f" | tr ' ' '-')
|
||||
cp "$f" "dist/${name}"
|
||||
done
|
||||
echo "Artifacts:"; ls -la dist/
|
||||
|
||||
- name: Assemble latest.json
|
||||
# The Tauri updater fetches a manifest describing the newest version, its
|
||||
# notes, and per-target {signature, url}. We point the AppImage target at
|
||||
# this release's asset URL. Adjust the platform keys you actually ship.
|
||||
# notes, and per-target {signature, url}. The URL points at the MIRROR
|
||||
# repo (mca/public_releases) — that's the unauthenticated endpoint field
|
||||
# appliances actually reach; see the workflow header for why. Adjust the
|
||||
# platform keys you actually ship.
|
||||
env:
|
||||
SERVER_URL: ${{ github.server_url }}
|
||||
REPO: ${{ github.repository }}
|
||||
MIRROR_REPO: mca/public_releases
|
||||
TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -e
|
||||
VERSION="${TAG#v}"
|
||||
APPIMAGE=$(cd dist && ls *.AppImage | head -1)
|
||||
SIG=$(cat "dist/${APPIMAGE}.sig")
|
||||
ASSET_URL="${SERVER_URL}/${REPO}/releases/download/${TAG}/${APPIMAGE}"
|
||||
ASSET_URL="${SERVER_URL}/${MIRROR_REPO}/releases/download/desktop-latest/${APPIMAGE}"
|
||||
cat > dist/latest.json <<JSON
|
||||
{
|
||||
"version": "${VERSION}",
|
||||
@@ -129,12 +152,12 @@ jobs:
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"tag_name\":\"${TAG}\",\"name\":\"${TAG}\",\"draft\":false,\"prerelease\":false}" \
|
||||
"${API}/repos/${REPO}/releases" || true)
|
||||
REL_ID=$(printf '%s' "$REL" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2)
|
||||
REL_ID=$(printf '%s' "$REL" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2 || true)
|
||||
if [ -z "$REL_ID" ]; then
|
||||
# Release may already exist for this tag — look it up by tag.
|
||||
REL_ID=$(curl -sS -H "Authorization: token ${TOKEN}" \
|
||||
"${API}/repos/${REPO}/releases/tags/${TAG}" \
|
||||
| grep -o '"id":[0-9]*' | head -1 | cut -d: -f2)
|
||||
| grep -o '"id":[0-9]*' | head -1 | cut -d: -f2 || true)
|
||||
fi
|
||||
echo "release id: ${REL_ID}"
|
||||
for f in dist/*; do
|
||||
@@ -147,3 +170,82 @@ jobs:
|
||||
"${API}/repos/${REPO}/releases/${REL_ID}/assets?name=${name}" >/dev/null
|
||||
done
|
||||
echo "done"
|
||||
|
||||
- name: Mirror release to mca/public_releases (Gitea API)
|
||||
# This is the release the updater and any human downloader actually use —
|
||||
# public_releases has no source, only installers, so it can be public
|
||||
# without exposing this repo. RELEASES_MIRROR_TOKEN is a write:repository
|
||||
# token scoped for pushing releases into that repo (Gitea's org secrets,
|
||||
# not exposed to any deployed client).
|
||||
#
|
||||
# Publishes to TWO tags there, since public_releases is shared across
|
||||
# apps in the org and Gitea's "latest release" redirect resolves by
|
||||
# newest tag on the WHOLE repo (would break the moment another app
|
||||
# publishes something newer):
|
||||
# - desktop-<TAG> versioned, permanent — audit trail / rollback.
|
||||
# - desktop-latest moving — assets deleted + re-uploaded each release.
|
||||
# This is the fixed URL tauri.conf.json's updater endpoint points at
|
||||
# (a stable name every appliance can always resolve, regardless of
|
||||
# what else gets released in this repo meanwhile).
|
||||
env:
|
||||
TOKEN: ${{ secrets.RELEASES_MIRROR_TOKEN }}
|
||||
API: ${{ github.api_url }}
|
||||
MIRROR_REPO: mca/public_releases
|
||||
TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -e
|
||||
create_or_get_release() {
|
||||
local mirror_tag="$1" prerelease="$2"
|
||||
REL=$(curl -sS -w '\n%{http_code}' -X POST \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"tag_name\":\"${mirror_tag}\",\"name\":\"Parking System ${TAG}\",\"draft\":false,\"prerelease\":${prerelease}}" \
|
||||
"${API}/repos/${MIRROR_REPO}/releases" || true)
|
||||
echo "create response (${mirror_tag}): ${REL}"
|
||||
REL_ID=$(printf '%s' "$REL" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2 || true)
|
||||
if [ -z "$REL_ID" ]; then
|
||||
LOOKUP=$(curl -sS -w '\n%{http_code}' -H "Authorization: token ${TOKEN}" \
|
||||
"${API}/repos/${MIRROR_REPO}/releases/tags/${mirror_tag}")
|
||||
echo "tag lookup response (${mirror_tag}): ${LOOKUP}"
|
||||
REL_ID=$(printf '%s' "$LOOKUP" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2 || true)
|
||||
fi
|
||||
if [ -z "$REL_ID" ]; then
|
||||
echo "::error::could not create or find release for tag ${mirror_tag} on ${MIRROR_REPO} — see responses above"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
upload_assets() {
|
||||
local rel_id="$1"
|
||||
for f in dist/*; do
|
||||
name=$(basename "$f")
|
||||
echo "mirroring ${name} -> release ${rel_id}"
|
||||
HTTP_CODE=$(curl -sS -o /tmp/upload_resp.json -w '%{http_code}' -X POST \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary @"${f}" \
|
||||
"${API}/repos/${MIRROR_REPO}/releases/${rel_id}/assets?name=${name}")
|
||||
if [ "$HTTP_CODE" -ge 300 ]; then
|
||||
echo "::error::upload of ${name} failed (HTTP ${HTTP_CODE}): $(cat /tmp/upload_resp.json)"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# 1. Versioned, permanent.
|
||||
create_or_get_release "desktop-${TAG}" false
|
||||
echo "versioned mirror release id: ${REL_ID}"
|
||||
upload_assets "${REL_ID}"
|
||||
|
||||
# 2. Moving desktop-latest — delete existing assets first (re-upload
|
||||
# with the same name 409s otherwise), then re-upload.
|
||||
create_or_get_release "desktop-latest" false
|
||||
LATEST_REL_ID="${REL_ID}"
|
||||
echo "latest mirror release id: ${LATEST_REL_ID}"
|
||||
EXISTING=$(curl -sS -H "Authorization: token ${TOKEN}" \
|
||||
"${API}/repos/${MIRROR_REPO}/releases/${LATEST_REL_ID}/assets")
|
||||
printf '%s' "$EXISTING" | grep -o '"id":[0-9]*' | cut -d: -f2 | while read -r asset_id; do
|
||||
curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \
|
||||
"${API}/repos/${MIRROR_REPO}/releases/${LATEST_REL_ID}/assets/${asset_id}" >/dev/null
|
||||
done || true
|
||||
upload_assets "${LATEST_REL_ID}"
|
||||
echo "done"
|
||||
|
||||
+2
-1
@@ -26,4 +26,5 @@ dist/
|
||||
|
||||
# Graphify knowledge-graph output (dev tool; generated, not committed)
|
||||
graphify-out/
|
||||
parking.sqlite*.bak-*
|
||||
parking.sqlite*.bak-*
|
||||
questions.txt
|
||||
|
||||
+11
-3
@@ -35,8 +35,16 @@ pnpm --filter @parking/desktop bundle # build the SPA + bundle the desktop app
|
||||
Requires the Rust toolchain and (on Linux) WebKitGTK 4.1 + libsoup-3 dev libraries. Under WSL2 the
|
||||
window needs a display (WSLg or an X server).
|
||||
|
||||
## Auto-update
|
||||
|
||||
Signed updates are built and published by `.gitea/workflows/release.yml` on a `vX.Y.Z` tag, mirrored
|
||||
to the public `mca/public_releases` repo (this repo is private; the updater runs on offline-first
|
||||
field appliances with no Gitea credentials, so its endpoint must be reachable unauthenticated —
|
||||
see that workflow's header and `wiki/decisions/desktop-shell-tauri.md`). The updater config and
|
||||
signing pubkey live in `tauri.conf.json`; the private signing key is held outside the repo, never
|
||||
committed.
|
||||
|
||||
## Not here (deliberately)
|
||||
|
||||
Kiosk lockdown (fullscreen/no-decorations), auto-update, code signing, and launching Fastify from
|
||||
the shell are out of scope for the scaffold — on the appliance Fastify runs as its own service and
|
||||
this shell connects to it.
|
||||
Kiosk lockdown (fullscreen/no-decorations) and launching Fastify from the shell are out of scope for
|
||||
the scaffold — on the appliance Fastify runs as its own service and this shell connects to it.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Parking System",
|
||||
"version": "0.0.0",
|
||||
"version": "0.1.0",
|
||||
"identifier": "com.parking.desktop",
|
||||
"build": {
|
||||
"devUrl": "http://localhost:5173",
|
||||
@@ -41,9 +41,9 @@
|
||||
},
|
||||
"plugins": {
|
||||
"updater": {
|
||||
"//": "Stable 'latest release' path on Gitea — redirects to the newest tag's latest.json (published by .gitea/workflows/release.yml). The updater GETs this, gets the manifest (platforms.linux-x86_64.{signature,url}), and compares versions. The release is reachable to the appliance only when it's brought online (phone hotspot); offline-first means a failed check is a no-op.",
|
||||
"//": "Points at mca/public_releases, NOT this (private, source) repo — the updater runs on offline-first field appliances with no Gitea credentials, so the endpoint must be reachable unauthenticated. That repo is public and holds only compiled installers (no source), mirrored here by .gitea/workflows/release.yml. NOT the 'latest release' redirect: public_releases is shared across apps in the org, so 'latest' there could be someone else's release. This URL names our own most-recent tag directly (desktop-vX.Y.Z, bumped by the release workflow each publish) so a newer unrelated app release never shadows ours. The updater GETs this, gets the manifest (platforms.linux-x86_64.{signature,url}), and compares versions. The release is reachable to the appliance only when it's brought online (phone hotspot); offline-first means a failed check is a no-op.",
|
||||
"endpoints": [
|
||||
"https://git.infra.msai.al/mca/parking_solution/releases/latest/download/latest.json"
|
||||
"https://git.infra.msai.al/mca/public_releases/releases/download/desktop-latest/latest.json"
|
||||
],
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDgxNzg5RUQ1QkM0Q0FDRjYKUldUMnJFeTgxWjU0Z1RlNmhneDVZQlVVTVZZdGhJTkUxTGdDeGYwQSttZmNKVVp5WEdVMWlBb1YK"
|
||||
}
|
||||
|
||||
@@ -47,6 +47,11 @@ RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
|
||||
# ---- runtime: slim, non-root ----
|
||||
FROM node:22-alpine AS runtime
|
||||
WORKDIR /app
|
||||
# Set by CI to "<branch>-<short-sha>" (e.g. "stage-28bd838"), matching the same string used
|
||||
# as the Komodo Stack's TAG (komodo/resources.toml) — so the version shown in the app is the
|
||||
# same string an admin would look up there. Empty/absent on a local `docker build` (dev only).
|
||||
ARG BUILD_VERSION=""
|
||||
ENV BUILD_VERSION=$BUILD_VERSION
|
||||
ENV NODE_ENV=production
|
||||
RUN apk add --no-cache libstdc++ # better-sqlite3 native runtime
|
||||
RUN addgroup -S app && adduser -S -G app app
|
||||
|
||||
@@ -16,7 +16,7 @@ import { createRequire } from "node:module";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const bcrypt = require("bcrypt");
|
||||
const { createDb, users, eq } = require("@parking/db");
|
||||
const { createDb, users, roles, eq } = require("@parking/db");
|
||||
|
||||
const DEFAULT_USERNAME = "admin";
|
||||
|
||||
@@ -53,6 +53,14 @@ if (!password || password.length < 8) {
|
||||
}
|
||||
|
||||
const db = createDb();
|
||||
|
||||
// Self-heal the built-in `admin` ROLE row. Migration 0007 seeds it once, but the
|
||||
// training reset (reset-db.mjs --users/--all) wipes the roles table and points here
|
||||
// to re-seed — without this, the user insert dies on the role_id FOREIGN KEY (field
|
||||
// failure 2026-07-06). The admin permission SET is resolved in code (auth.ts), so
|
||||
// the row alone is all the FK needs.
|
||||
await db.insert(roles).values({ id: "admin", name: "Admin", builtin: 1 }).onConflictDoNothing();
|
||||
|
||||
const existing = await db.select().from(users).where(eq(users.username, username)).get();
|
||||
if (existing && process.env.FORCE !== "1") {
|
||||
console.error(`user "${username}" already exists (set FORCE=1 to reset the password)`);
|
||||
@@ -73,4 +81,30 @@ if (existing) {
|
||||
});
|
||||
console.log(`created admin "${username}"`);
|
||||
}
|
||||
|
||||
// Record the action into the SIGNED ledger (config_change). A console seed/reset is
|
||||
// a Linux-admin action the app can't gate — but it must stay ATTRIBUTABLE after the
|
||||
// fact (the chain is the audit record; whoever holds root can reset a password, they
|
||||
// can't do it silently). Uses the server's own compiled EventLog + signer from dist/
|
||||
// (present in the container; in a dev checkout run `pnpm build` first). Best-effort:
|
||||
// a missing build or signing key WARNS loudly but never blocks the seed — locking an
|
||||
// admin out to protect an audit line would invert the priority.
|
||||
try {
|
||||
const { EventLog } = await import("../dist/event-log.js");
|
||||
const { buildSigner } = await import("../dist/signer.js");
|
||||
const log = new EventLog(db, buildSigner());
|
||||
await log.append({
|
||||
type: "config_change",
|
||||
source: "manual",
|
||||
identity: `user:${username}`,
|
||||
payload: {
|
||||
setting: existing ? "admin.passwordReset" : "admin.seeded",
|
||||
username,
|
||||
operator: "console:seed-admin",
|
||||
},
|
||||
});
|
||||
console.log("recorded to the signed ledger (config_change)");
|
||||
} catch (err) {
|
||||
console.warn(`WARNING: NOT recorded to the signed ledger: ${err.message}`);
|
||||
}
|
||||
process.exit(0);
|
||||
|
||||
@@ -256,6 +256,22 @@ describe("AnprBridge", () => {
|
||||
expect((skips[0].detail as { plate?: string }).plate).toBe("ZZ999ZZ");
|
||||
});
|
||||
|
||||
it("analyzes AT LEAST ONE frame even if the poll window already elapsed (loaded host)", async () => {
|
||||
// Regression for a CI flake (2026-07-04): with a plain `while`, a window that lapsed
|
||||
// between deadline-set and loop-entry (slow runner; here forced with a 0ms window)
|
||||
// meant ZERO analyze attempts — the detection was silently dropped ("gave up") and no
|
||||
// skip was recorded. The do-while guarantees one frame per detection regardless of load.
|
||||
process.env.ANPR_POLL_WINDOW_MS = "0";
|
||||
const cam = seedCamera({ anpr: true });
|
||||
const vision = fakeVision({ plate: "ZZ999ZZ", confidence: 0.97 });
|
||||
const bridge = new AnprBridge(db, vision, fakeSubFlow(null), silentLogger());
|
||||
|
||||
await captureReads(() => bridge.onVehicleDetected(cam));
|
||||
expect(captureSnapshot).toHaveBeenCalledTimes(1); // the guaranteed first attempt
|
||||
const skips = db.select().from(deviceEventsTable).where(eq(deviceEventsTable.kind, "anpr-skip")).all();
|
||||
expect(skips).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("debounces: two vehicle events within the window analyze/emit at most once", async () => {
|
||||
const cam = seedCamera({ anpr: true });
|
||||
const vision = fakeVision({ plate: "AA111BB", confidence: 0.97 });
|
||||
|
||||
@@ -192,7 +192,12 @@ export class AnprBridge {
|
||||
const hardCap = Date.now() + this.#pollMaxMs;
|
||||
let attempts = 0;
|
||||
try {
|
||||
while (Date.now() < Math.min(this.#pollDeadline.get(deviceId) ?? 0, hardCap)) {
|
||||
// DO-while: a detection always analyzes AT LEAST ONE frame, however loaded the
|
||||
// host — a plain while could zero-iterate if the window elapsed between setting
|
||||
// the deadline and reaching the loop (seen as a CI flake with the tests' 5ms
|
||||
// window; on a busy booth it would silently drop a real car's detection). Exit
|
||||
// is via the breaks below (confident read, or next tick would pass the deadline).
|
||||
do {
|
||||
attempts++;
|
||||
const shot = await camera.captureSnapshot({ direction });
|
||||
const r = await this.#vision.analyze(shot.bytes, shot.contentType);
|
||||
@@ -229,7 +234,7 @@ export class AnprBridge {
|
||||
const effDeadline = Math.min(this.#pollDeadline.get(deviceId) ?? 0, hardCap);
|
||||
if (Date.now() + this.#pollMs >= effDeadline) break;
|
||||
await sleep(this.#pollMs);
|
||||
}
|
||||
} while (true);
|
||||
} finally {
|
||||
this.#polling.delete(deviceId);
|
||||
this.#pollDeadline.delete(deviceId);
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { eq, siteConfig } from "@parking/db";
|
||||
import { createTestDb } from "@parking/db/testing";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { BackupService } from "./backup-service.js";
|
||||
|
||||
// BackupService previously tracked last-success/last-error as plain in-process fields, so a
|
||||
// server restart (a fresh BackupService instance, exactly as happens on every deploy/crash/OOM
|
||||
// reboot under `restart: always`) silently reset the admin UI to "last successful backup:
|
||||
// Never" — even with valid, correctly-rotating backups already on disk (2026-08-30 field
|
||||
// incident, park-buzi). These tests exercise the fix: status is read from site_config, so a new
|
||||
// BackupService instance pointed at the same DB sees the prior instance's last-run outcome, and
|
||||
// the schedule is wall-clock-based (isDue()) rather than time-since-process-start.
|
||||
// See wiki/concepts/backup-recovery.md.
|
||||
|
||||
const KEY = "a-test-backup-key-that-is-long-enough";
|
||||
|
||||
let workDir: string;
|
||||
let target: string;
|
||||
|
||||
beforeEach(() => {
|
||||
workDir = mkdtempSync(join(tmpdir(), "pk-backup-service-test-"));
|
||||
target = join(workDir, "target");
|
||||
process.env.BACKUP_KEY = KEY;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(workDir, { recursive: true, force: true });
|
||||
delete process.env.BACKUP_KEY;
|
||||
});
|
||||
|
||||
function setTargetDir(db: ReturnType<typeof createTestDb>["db"], dir: string): void {
|
||||
const existing = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||
if (existing) {
|
||||
db.update(siteConfig).set({ backupTargetDir: dir }).where(eq(siteConfig.id, 1)).run();
|
||||
} else {
|
||||
db.insert(siteConfig).values({ id: 1, backupTargetDir: dir }).run();
|
||||
}
|
||||
}
|
||||
|
||||
describe("BackupService — persisted status survives a restart", () => {
|
||||
it("a fresh instance sees the previous instance's last success", async () => {
|
||||
const t = createTestDb();
|
||||
setTargetDir(t.db, target);
|
||||
|
||||
const first = new BackupService(t.db);
|
||||
expect(first.status().lastSuccessAt).toBeNull();
|
||||
const result = await first.run("manual");
|
||||
|
||||
// Simulate a process restart: a brand-new BackupService over the SAME db handle (in
|
||||
// production this would be a fresh process re-opening the same sqlite file).
|
||||
const second = new BackupService(t.db);
|
||||
const status = second.status();
|
||||
expect(status.lastSuccessAt).not.toBeNull();
|
||||
expect(status.lastResult).toEqual({ path: result.path, bytes: result.bytes, prunedFiles: result.prunedFiles });
|
||||
expect(status.lastError).toBeNull();
|
||||
|
||||
t.close();
|
||||
});
|
||||
|
||||
it("a fresh instance sees the previous instance's last error, and it clears on next success", async () => {
|
||||
const t = createTestDb();
|
||||
// Target dir set, but as a FILE (not a directory) — runBackup's mkdir(recursive) will
|
||||
// throw, giving us a real, deterministic failure without needing to mock anything.
|
||||
const badTarget = join(workDir, "not-a-dir");
|
||||
writeFileSync(badTarget, "x");
|
||||
setTargetDir(t.db, badTarget);
|
||||
|
||||
const first = new BackupService(t.db);
|
||||
await expect(first.run("manual")).rejects.toThrow();
|
||||
|
||||
const second = new BackupService(t.db);
|
||||
const status = second.status();
|
||||
expect(status.lastError).not.toBeNull();
|
||||
expect(status.lastErrorAt).not.toBeNull();
|
||||
expect(status.lastSuccessAt).toBeNull();
|
||||
|
||||
// Now point at a real directory and succeed — the persisted error must clear.
|
||||
setTargetDir(t.db, target);
|
||||
await second.run("manual");
|
||||
const third = new BackupService(t.db);
|
||||
const finalStatus = third.status();
|
||||
expect(finalStatus.lastSuccessAt).not.toBeNull();
|
||||
expect(finalStatus.lastError).toBeNull();
|
||||
expect(finalStatus.lastErrorAt).toBeNull();
|
||||
|
||||
t.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("BackupService — isDue() is wall-clock-based, not process-uptime-based", () => {
|
||||
it("is due immediately when no success has ever been recorded", () => {
|
||||
const t = createTestDb();
|
||||
const svc = new BackupService(t.db);
|
||||
expect(svc.isDue()).toBe(true);
|
||||
t.close();
|
||||
});
|
||||
|
||||
it("is NOT due right after a fresh instance is constructed, if a recent success is persisted", async () => {
|
||||
const t = createTestDb();
|
||||
setTargetDir(t.db, target);
|
||||
const first = new BackupService(t.db);
|
||||
await first.run("manual");
|
||||
|
||||
// The whole point of the fix: a brand-new instance (simulating a restart moments after a
|
||||
// real backup completed) must NOT think a backup is due just because ITS OWN uptime is ~0.
|
||||
const second = new BackupService(t.db);
|
||||
expect(second.isDue()).toBe(false);
|
||||
t.close();
|
||||
});
|
||||
|
||||
it("is due once the persisted last-success timestamp is old enough", async () => {
|
||||
const t = createTestDb();
|
||||
setTargetDir(t.db, target);
|
||||
const svc = new BackupService(t.db);
|
||||
await svc.run("manual");
|
||||
|
||||
const almostADayLater = new Date(Date.now() + 23 * 60 * 60 * 1000);
|
||||
expect(svc.isDue(almostADayLater)).toBe(false);
|
||||
|
||||
const overADayLater = new Date(Date.now() + 24 * 60 * 60 * 1000 + 1000);
|
||||
expect(svc.isDue(overADayLater)).toBe(true);
|
||||
t.close();
|
||||
});
|
||||
|
||||
it("runScheduled() is a no-op when not yet due, even if configured", async () => {
|
||||
const t = createTestDb();
|
||||
setTargetDir(t.db, target);
|
||||
const svc = new BackupService(t.db);
|
||||
await svc.run("manual");
|
||||
const afterFirst = svc.status().lastSuccessAt;
|
||||
|
||||
await svc.runScheduled(); // not due yet — must not run again
|
||||
expect(svc.status().lastSuccessAt).toBe(afterFirst);
|
||||
t.close();
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,12 @@ import { DEFAULT_BACKUP_RETENTION, runBackup, type BackupResult, type BackupRete
|
||||
// a key must never live in the DB it backs up. Remembers the last outcome so the route + UI can
|
||||
// show last-success / last-error, and serializes concurrent runs (manual + timer). See
|
||||
// wiki/concepts/backup-recovery.md.
|
||||
//
|
||||
// Last-success/last-error are PERSISTED to site_config (backup_last_*), not just held in
|
||||
// memory — an earlier version tracked these as plain in-process fields only, so every server
|
||||
// restart (deploy, crash, OOM, host reboot — all routine under `restart: always`) silently
|
||||
// reset the admin UI to "last successful backup: Never", even with valid, correctly-rotating
|
||||
// backups already on disk (2026-08-30 field incident, park-buzi). See wiki/concepts/backup-recovery.md.
|
||||
|
||||
/** The dedicated backup-encryption key, from env (NOT the DB). Separate from EVENT_SIGNING_KEY. */
|
||||
export function backupKeyFromEnv(): string {
|
||||
@@ -65,16 +71,33 @@ export class BackupService {
|
||||
readonly #logger?: FastifyBaseLogger;
|
||||
|
||||
#running = false;
|
||||
#lastSuccessAt: string | null = null;
|
||||
#lastResult: BackupResult | null = null;
|
||||
#lastErrorAt: string | null = null;
|
||||
#lastError: string | null = null;
|
||||
|
||||
constructor(db: Db, logger?: FastifyBaseLogger) {
|
||||
this.#db = db;
|
||||
this.#logger = logger;
|
||||
}
|
||||
|
||||
/** Fresh read of the persisted row (single source of truth — no in-memory cache to go stale
|
||||
* or reset on restart). */
|
||||
#row(): { backupLastSuccessAt: string | null; backupLastResultJson: string | null; backupLastErrorAt: string | null; backupLastError: string | null } | undefined {
|
||||
return this.#db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||
}
|
||||
|
||||
#persist(patch: {
|
||||
backupLastSuccessAt?: string | null;
|
||||
backupLastResultJson?: string | null;
|
||||
backupLastErrorAt?: string | null;
|
||||
backupLastError?: string | null;
|
||||
}): void {
|
||||
const updatedAt = new Date().toISOString();
|
||||
const existing = this.#row();
|
||||
if (existing) {
|
||||
this.#db.update(siteConfig).set({ ...patch, updatedAt }).where(eq(siteConfig.id, 1)).run();
|
||||
} else {
|
||||
this.#db.insert(siteConfig).values({ id: 1, ...patch, updatedAt }).run();
|
||||
}
|
||||
}
|
||||
|
||||
/** The admin-chosen target dir from site_config (null/empty = unset). Read fresh each call. */
|
||||
targetDir(): string | null {
|
||||
const row = this.#db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||
@@ -104,6 +127,15 @@ export class BackupService {
|
||||
|
||||
status(): BackupStatus {
|
||||
const r = this.retention();
|
||||
const row = this.#row();
|
||||
let lastResult: BackupStatus["lastResult"] = null;
|
||||
if (row?.backupLastResultJson) {
|
||||
try {
|
||||
lastResult = JSON.parse(row.backupLastResultJson) as BackupStatus["lastResult"];
|
||||
} catch {
|
||||
lastResult = null; // corrupt/foreign value in the column — don't let it crash status()
|
||||
}
|
||||
}
|
||||
return {
|
||||
configured: this.configured,
|
||||
targetDir: this.targetDir(),
|
||||
@@ -111,12 +143,10 @@ export class BackupService {
|
||||
keepDailyDays: r.keepDailyDays,
|
||||
keyPresent: this.keyPresent,
|
||||
running: this.#running,
|
||||
lastSuccessAt: this.#lastSuccessAt,
|
||||
lastResult: this.#lastResult
|
||||
? { path: this.#lastResult.path, bytes: this.#lastResult.bytes, prunedFiles: this.#lastResult.prunedFiles }
|
||||
: null,
|
||||
lastErrorAt: this.#lastErrorAt,
|
||||
lastError: this.#lastError,
|
||||
lastSuccessAt: row?.backupLastSuccessAt ?? null,
|
||||
lastResult,
|
||||
lastErrorAt: row?.backupLastErrorAt ?? null,
|
||||
lastError: row?.backupLastError ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -139,14 +169,17 @@ export class BackupService {
|
||||
try {
|
||||
this.#logger?.info(`backup: starting (${trigger}) → ${targetDir}`);
|
||||
const res = await runBackup(this.#db, { targetDir, key, retention: this.retention() }, this.#logger);
|
||||
this.#lastResult = res;
|
||||
this.#lastSuccessAt = new Date().toISOString();
|
||||
this.#lastError = null;
|
||||
this.#persist({
|
||||
backupLastSuccessAt: new Date().toISOString(),
|
||||
backupLastResultJson: JSON.stringify({ path: res.path, bytes: res.bytes, prunedFiles: res.prunedFiles }),
|
||||
backupLastErrorAt: null,
|
||||
backupLastError: null,
|
||||
});
|
||||
return res;
|
||||
} catch (err) {
|
||||
this.#lastError = (err as Error).message;
|
||||
this.#lastErrorAt = new Date().toISOString();
|
||||
this.#logger?.error(`backup: failed (${trigger}): ${this.#lastError}`);
|
||||
const message = (err as Error).message;
|
||||
this.#persist({ backupLastErrorAt: new Date().toISOString(), backupLastError: message });
|
||||
this.#logger?.error(`backup: failed (${trigger}): ${message}`);
|
||||
throw err;
|
||||
} finally {
|
||||
this.#running = false;
|
||||
@@ -156,13 +189,34 @@ export class BackupService {
|
||||
return this.#inflight;
|
||||
}
|
||||
|
||||
/** Scheduled-run wrapper: never throws (a timer must not crash the process). */
|
||||
/**
|
||||
* Scheduled-run wrapper: never throws (a timer must not crash the process). Safe to call on
|
||||
* a short, frequent poll (see server.ts) — it's a no-op unless `isDue()` says a full interval
|
||||
* has actually elapsed since the last recorded success, so frequent polling doesn't cause
|
||||
* frequent backups.
|
||||
*/
|
||||
async runScheduled(): Promise<void> {
|
||||
if (!this.configured) return; // silent no-op when backups aren't set up
|
||||
if (!this.isDue()) return;
|
||||
try {
|
||||
await this.run("scheduled");
|
||||
} catch {
|
||||
/* recorded in last-error; already logged */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wall-clock check: has enough time elapsed since the last successful backup for a new one
|
||||
* to be due? Deliberately based on the PERSISTED last-success instant, not "time since this
|
||||
* process started" — a `setInterval(..., 24h)` measured from process start silently drifts
|
||||
* (or skips a whole day) across every restart, since the countdown restarts from zero each
|
||||
* time regardless of when the last real backup happened. See wiki/concepts/backup-recovery.md.
|
||||
*/
|
||||
isDue(now: Date = new Date(), intervalMs = 24 * 60 * 60 * 1000): boolean {
|
||||
const lastSuccessAt = this.#row()?.backupLastSuccessAt;
|
||||
if (!lastSuccessAt) return true; // never recorded a success → due immediately once configured
|
||||
const last = new Date(lastSuccessAt).getTime();
|
||||
if (Number.isNaN(last)) return true;
|
||||
return now.getTime() - last >= intervalMs;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,6 +80,8 @@ function receiptFigures(
|
||||
currency?: string;
|
||||
tender?: "cash" | "card";
|
||||
graceExitMin?: number;
|
||||
grossMinor?: number;
|
||||
validationLines?: { label: string; discountMinor: number }[];
|
||||
};
|
||||
return {
|
||||
ticketId,
|
||||
@@ -89,6 +91,9 @@ function receiptFigures(
|
||||
currency: p.currency ?? "ALL",
|
||||
tender: p.tender === "card" ? "card" : "cash",
|
||||
graceExitMin: typeof p.graceExitMin === "number" ? p.graceExitMin : null,
|
||||
// Merchant validations, as settled on the signed payment (gross → lines → net).
|
||||
grossMinor: typeof p.grossMinor === "number" ? p.grossMinor : null,
|
||||
validationLines: Array.isArray(p.validationLines) ? p.validationLines : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -192,11 +192,78 @@ describe("ButtonLightController truth table", () => {
|
||||
// First write (initial off) throws — must be swallowed.
|
||||
expect(() => ctl.start()).not.toThrow();
|
||||
await flush();
|
||||
// Subsequent writes work; driving to solid still converges to ON.
|
||||
// The failure arms a backoff (1s) rather than retrying inline; desired-state
|
||||
// changes during the window just update the target the retry will assert.
|
||||
lane(true);
|
||||
radar(true);
|
||||
await flush();
|
||||
expect(ctl.confirmedOf(CONTROLLER)).toBeNull(); // still backing off
|
||||
await vi.advanceTimersByTimeAsync(1000); // retry fires; aux is healthy again
|
||||
expect(ctl.confirmedOf(CONTROLLER)).toBe(true); // converged to solid ON
|
||||
ctl.stop();
|
||||
});
|
||||
|
||||
it("an unreachable controller backs off (1s→30s), not a hot retry loop", async () => {
|
||||
let attempts = 0;
|
||||
const aux: AuxOutputDevice = {
|
||||
async setAux() {
|
||||
attempts += 1;
|
||||
throw new Error("send ENETUNREACH 10.0.10.5:60000");
|
||||
},
|
||||
};
|
||||
const errors: string[] = [];
|
||||
const logger = silentLogger();
|
||||
(logger as { error: (msg: string) => void }).error = (msg) => errors.push(msg);
|
||||
const ctl = new ButtonLightController(db, logger, () => aux);
|
||||
ctl.start(); // initial OFF write → attempt 1 fails at t=0
|
||||
await flush();
|
||||
expect(attempts).toBe(1); // the old code hot-looped here
|
||||
|
||||
// Failures at t≈0,1,3,7,15,31 (doubling, capped 30s) → 6 attempts in the first
|
||||
// minute instead of thousands.
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
expect(attempts).toBeGreaterThanOrEqual(5);
|
||||
expect(attempts).toBeLessThanOrEqual(7);
|
||||
|
||||
// Only the FIRST failure was logged so far; the next log is a ≥60s summary.
|
||||
expect(errors).toHaveLength(1);
|
||||
await vi.advanceTimersByTimeAsync(35_000); // t≈95s → the t=61s attempt logged a summary
|
||||
expect(errors.length).toBe(2);
|
||||
expect(errors[1]).toContain("still failing");
|
||||
ctl.stop();
|
||||
});
|
||||
|
||||
it("logs a single recovery line and resets the backoff after success", async () => {
|
||||
let failing = true;
|
||||
let attempts = 0;
|
||||
const aux: AuxOutputDevice = {
|
||||
async setAux() {
|
||||
attempts += 1;
|
||||
if (failing) throw new Error("send ENETUNREACH 10.0.10.5:60000");
|
||||
},
|
||||
};
|
||||
const infos: string[] = [];
|
||||
const logger = silentLogger();
|
||||
(logger as { info: (msg: string) => void }).info = (msg) => infos.push(msg);
|
||||
const ctl = new ButtonLightController(db, logger, () => aux);
|
||||
ctl.start();
|
||||
await flush();
|
||||
await vi.advanceTimersByTimeAsync(3_000); // attempts at t=0,1,3 all fail
|
||||
const failed = attempts;
|
||||
expect(failed).toBeGreaterThanOrEqual(3);
|
||||
|
||||
failing = false; // controller reachable again
|
||||
await vi.advanceTimersByTimeAsync(8_000); // next armed retry succeeds
|
||||
expect(ctl.confirmedOf(CONTROLLER)).toBe(false); // OFF asserted on the device
|
||||
expect(infos.filter((m) => m.includes("recovered"))).toHaveLength(1);
|
||||
|
||||
// Backoff reset: a fresh state change sends immediately (no lingering retryAt).
|
||||
const before = attempts;
|
||||
lane(true);
|
||||
radar(true);
|
||||
await flush();
|
||||
expect(ctl.confirmedOf(CONTROLLER)).toBe(true);
|
||||
expect(attempts).toBe(before + 1);
|
||||
ctl.stop();
|
||||
});
|
||||
|
||||
|
||||
@@ -21,6 +21,14 @@ type LightState = "off" | "solid" | "blink";
|
||||
|
||||
const DEFAULT_BLINK_MS = 500;
|
||||
|
||||
// Failed-send retry backoff: 1s doubling to 30s, reset on success. Without this an
|
||||
// unreachable controller (ENETUNREACH) became a hot loop — the failure re-pump retried
|
||||
// instantly, thousands of sends + error lines per minute (field incident 2026-07-07).
|
||||
const RETRY_BASE_MS = 1_000;
|
||||
const RETRY_MAX_MS = 30_000;
|
||||
/** After the first failure of a streak, log at most one summary line per this window. */
|
||||
const FAIL_LOG_EVERY_MS = 60_000;
|
||||
|
||||
/** Per-lamp live state for the alert rule (one per radarAlert relay). */
|
||||
interface LampState {
|
||||
/** The controller this lamp lives on (its deviceId) — for resolving the aux adapter. */
|
||||
@@ -44,6 +52,16 @@ interface LampState {
|
||||
/** True while a send is in flight for this lamp — serializes UDP so on/off can't
|
||||
* overlap or reorder (UDP is unordered; concurrent toggles left the relay stuck). */
|
||||
sending: boolean;
|
||||
/** Consecutive failed sends (0 = healthy). Drives the backoff delay + log summaries. */
|
||||
failCount: number;
|
||||
/** Epoch ms before which #pump must not send (0 = no backoff). The armed retry
|
||||
* timer re-pumps when it elapses; desired-state changes in between just update
|
||||
* `desiredOn` and are picked up by that same retry. */
|
||||
retryAt: number;
|
||||
/** The armed backoff retry, if any. */
|
||||
retryTimer: ReturnType<typeof setTimeout> | null;
|
||||
/** Epoch ms of the last failure line we actually logged (rate-limits the flood). */
|
||||
lastFailLogAt: number;
|
||||
}
|
||||
|
||||
/** Resolves a controller's live aux-output adapter. The default goes through the
|
||||
@@ -111,6 +129,10 @@ export class ButtonLightController {
|
||||
desiredOn: false,
|
||||
confirmedOn: null,
|
||||
sending: false,
|
||||
failCount: 0,
|
||||
retryAt: 0,
|
||||
retryTimer: null,
|
||||
lastFailLogAt: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -118,10 +140,7 @@ export class ButtonLightController {
|
||||
// Drop lamps whose controller no longer declares one (or was disabled/removed).
|
||||
for (const [key, lamp] of this.#lamps) {
|
||||
if (seen.has(key)) continue;
|
||||
if (lamp.blink) {
|
||||
clearInterval(lamp.blink);
|
||||
lamp.blink = null;
|
||||
}
|
||||
this.#disarm(lamp);
|
||||
this.#finalOff(lamp); // best-effort fail-OFF before forgetting it
|
||||
this.#lamps.delete(key);
|
||||
}
|
||||
@@ -207,10 +226,17 @@ export class ButtonLightController {
|
||||
* time. Because UDP is unordered, concurrent on/off sends previously raced and left
|
||||
* the relay stuck on a stale packet. Here a single in-flight send is guaranteed
|
||||
* (`sending` guard); when it resolves, if the desired state moved on we send again —
|
||||
* so the LAST desired state is always the one finally asserted on the device. */
|
||||
* so the LAST desired state is always the one finally asserted on the device.
|
||||
*
|
||||
* Failures back off (1s → 30s, reset on success) instead of retrying inline: an
|
||||
* unreachable controller rejects instantly, and an immediate re-pump was a hot loop.
|
||||
* During backoff `desiredOn` keeps tracking the truth table; the armed retry timer
|
||||
* converges to whatever it says when it fires. Only the FIRST failure of a streak is
|
||||
* logged, then one summary per minute, and an info line on recovery. */
|
||||
#pump(lamp: LampState): void {
|
||||
if (lamp.sending) return; // a send is already in flight; it'll re-check on completion
|
||||
if (lamp.confirmedOn === lamp.desiredOn) return; // already there — no redundant UDP
|
||||
if (Date.now() < lamp.retryAt) return; // backing off — the retry timer will re-pump
|
||||
const aux = this.#resolveAux(lamp.controllerId);
|
||||
if (!aux) return;
|
||||
const target = lamp.desiredOn;
|
||||
@@ -219,15 +245,42 @@ export class ButtonLightController {
|
||||
.setAux(lamp.spec.relay, target)
|
||||
.then(() => {
|
||||
lamp.confirmedOn = target;
|
||||
if (lamp.failCount > 0) {
|
||||
this.#logger.info(
|
||||
`button-light setAux recovered (${lamp.controllerId} R${lamp.spec.relay}) after ${lamp.failCount} failed attempts`,
|
||||
);
|
||||
}
|
||||
lamp.failCount = 0;
|
||||
lamp.retryAt = 0;
|
||||
lamp.lastFailLogAt = 0;
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
// Leave confirmedOn unchanged so the next pump retries this state. Never escalates.
|
||||
this.#logger.error(`button-light setAux failed (${lamp.controllerId} R${lamp.spec.relay}): ${(err as Error).message}`);
|
||||
// Leave confirmedOn unchanged so the armed retry re-asserts the (then-current)
|
||||
// desired state. Never escalates — a dead lamp is "no hint", never a fault.
|
||||
lamp.failCount += 1;
|
||||
const delay = Math.min(RETRY_BASE_MS * 2 ** (lamp.failCount - 1), RETRY_MAX_MS);
|
||||
lamp.retryAt = Date.now() + delay;
|
||||
const now = Date.now();
|
||||
if (lamp.failCount === 1 || now - lamp.lastFailLogAt >= FAIL_LOG_EVERY_MS) {
|
||||
lamp.lastFailLogAt = now;
|
||||
const streak =
|
||||
lamp.failCount > 1 ? ` — still failing (attempt ${lamp.failCount}, retrying ≤${RETRY_MAX_MS / 1000}s)` : "";
|
||||
this.#logger.error(
|
||||
`button-light setAux failed (${lamp.controllerId} R${lamp.spec.relay}): ${(err as Error).message}${streak}`,
|
||||
);
|
||||
}
|
||||
if (lamp.retryTimer) clearTimeout(lamp.retryTimer);
|
||||
lamp.retryTimer = setTimeout(() => {
|
||||
lamp.retryTimer = null;
|
||||
this.#pump(lamp);
|
||||
}, delay);
|
||||
lamp.retryTimer.unref?.();
|
||||
})
|
||||
.finally(() => {
|
||||
lamp.sending = false;
|
||||
// Desired state may have changed (or the send failed) while we were busy —
|
||||
// re-pump to converge. This is what makes the final state authoritative.
|
||||
// Desired state may have changed while we were busy — re-pump to converge (the
|
||||
// backoff gate above makes this a no-op right after a failure). This is what
|
||||
// makes the final state authoritative.
|
||||
if (lamp.confirmedOn !== lamp.desiredOn) this.#pump(lamp);
|
||||
});
|
||||
}
|
||||
@@ -261,20 +314,31 @@ export class ButtonLightController {
|
||||
this.#unsubInput = null;
|
||||
this.#unsubLane = null;
|
||||
for (const lamp of this.#lamps.values()) {
|
||||
if (lamp.blink) {
|
||||
clearInterval(lamp.blink);
|
||||
lamp.blink = null;
|
||||
}
|
||||
this.#disarm(lamp);
|
||||
// Best-effort fail-OFF on shutdown.
|
||||
this.#finalOff(lamp);
|
||||
}
|
||||
}
|
||||
|
||||
/** Stop a lamp's timers (blink + backoff retry) without touching the device. */
|
||||
#disarm(lamp: LampState): void {
|
||||
if (lamp.blink) {
|
||||
clearInterval(lamp.blink);
|
||||
lamp.blink = null;
|
||||
}
|
||||
if (lamp.retryTimer) {
|
||||
clearTimeout(lamp.retryTimer);
|
||||
lamp.retryTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Drive a lamp OFF as a one-shot (used when dropping/stopping a lamp): set desired
|
||||
* OFF and pump. The serialized worker still applies, so this can't collide with an
|
||||
* in-flight send — it converges to OFF. */
|
||||
* in-flight send — it converges to OFF. Any backoff is waived so the last-gasp OFF
|
||||
* gets one immediate try (a lamp mid-backoff may just have recovered). */
|
||||
#finalOff(lamp: LampState): void {
|
||||
lamp.desiredOn = false;
|
||||
lamp.retryAt = 0;
|
||||
this.#pump(lamp);
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,13 @@ export interface DeviceReadEvent {
|
||||
readonly deviceId: string; // devices id of the reader/scanner/camera
|
||||
readonly value: string; // the ticket id / plate / card number
|
||||
readonly kind: "ticket" | "plate" | "qr" | "card";
|
||||
/** The CONFIRMED physical channel the value arrived on, when the reader tags it
|
||||
* (the DT-008 output prefixes — see routes/qr-reader.ts). `optical` = decoded by
|
||||
* the barcode/QR engine; `rf` = read from a card/chip. Undefined = legacy reader
|
||||
* with no prefixes configured (channel unknown — flows must not assume). Lets the
|
||||
* subscription match refuse an OPTICAL decode claiming an RF credential (a printed
|
||||
* copy of a card's UID must not clone the card). */
|
||||
readonly channel?: "optical" | "rf";
|
||||
readonly at: string; // ISO-8601
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { localIsoWithOffset } from "./device-monitor.js";
|
||||
|
||||
// The camera clock-sync sends the SITE's wall-clock now with an explicit UTC offset
|
||||
// (ISAPI localTime) — the offset is what makes the instant unambiguous regardless of
|
||||
// the camera's own tz/DST config. Pin the DST both-sides behaviour for the site tz.
|
||||
|
||||
describe("localIsoWithOffset (camera clock sync payload)", () => {
|
||||
it("Tirane summer = +02:00 (CEST)", () => {
|
||||
expect(localIsoWithOffset("Europe/Tirane", new Date("2026-07-07T10:00:00Z"))).toBe(
|
||||
"2026-07-07T12:00:00+02:00",
|
||||
);
|
||||
});
|
||||
it("Tirane winter = +01:00 (CET)", () => {
|
||||
expect(localIsoWithOffset("Europe/Tirane", new Date("2026-01-15T10:00:00Z"))).toBe(
|
||||
"2026-01-15T11:00:00+01:00",
|
||||
);
|
||||
});
|
||||
it("UTC = +00:00", () => {
|
||||
expect(localIsoWithOffset("UTC", new Date("2026-07-07T10:00:00Z"))).toBe(
|
||||
"2026-07-07T10:00:00+00:00",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { FastifyBaseLogger } from "fastify";
|
||||
import { devices, type Db, type DeviceRow } from "@parking/db";
|
||||
import { isMonitorable, registry } from "@parking/devices";
|
||||
import { isClockSyncable, isMonitorable, registry, type Device } from "@parking/devices";
|
||||
import { deviceEvents, type DeviceStatusEvent } from "./device-events.js";
|
||||
import { directionOf, relaysOf } from "./device-resolve.js";
|
||||
import type { VisionClient } from "./vision-client.js";
|
||||
import { siteTz } from "./subscription-window.js";
|
||||
|
||||
/** Synthetic device id for the vision service in the status footer (it's a service,
|
||||
* not a device row, but shares the footer's traffic-light + WS plumbing). */
|
||||
@@ -23,6 +24,40 @@ const VISION_STATUS_ID = "vision-service";
|
||||
|
||||
const POLL_MS = Number(process.env.DEVICE_POLL_MS ?? 8000);
|
||||
|
||||
// Camera clock sync (Hikvision loses its clock on power cuts — reboots at the 1970
|
||||
// epoch until a human logs into its web UI). The monitor re-syncs from the HOST
|
||||
// clock (the site's offline time authority) at the offline→ready edge — exactly the
|
||||
// power-restored moment — plus a daily backstop; drift under the threshold is left
|
||||
// alone. See wiki/entities/lpr-camera.md (clock sync).
|
||||
const CLOCK_SYNC_BACKSTOP_MS = 24 * 60 * 60 * 1000;
|
||||
const CLOCK_MAX_DRIFT_SEC = 60;
|
||||
|
||||
/** The site's wall-clock now as ISO WITH utc offset (e.g. 2026-07-07T15:30:22+02:00)
|
||||
* — what ISAPI's localTime wants. Derived via Intl for the site tz (no dep). */
|
||||
export function localIsoWithOffset(tz: string, at = new Date()): string {
|
||||
const fmt = new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: tz,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hourCycle: "h23",
|
||||
});
|
||||
const p = Object.fromEntries(fmt.formatToParts(at).map((x) => [x.type, x.value]));
|
||||
const wallAsUtcMs = Date.UTC(
|
||||
Number(p.year), Number(p.month) - 1, Number(p.day),
|
||||
Number(p.hour), Number(p.minute), Number(p.second),
|
||||
);
|
||||
const offMin = Math.round((wallAsUtcMs - at.getTime()) / 60_000);
|
||||
const sign = offMin < 0 ? "-" : "+";
|
||||
const abs = Math.abs(offMin);
|
||||
const hh = String(Math.floor(abs / 60)).padStart(2, "0");
|
||||
const mm = String(abs % 60).padStart(2, "0");
|
||||
return `${p.year}-${p.month}-${p.day}T${p.hour}:${p.minute}:${p.second}${sign}${hh}:${mm}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The device's ROLE descriptor for the footer (never the vendor). Direction-style
|
||||
* tokens the client localises next to the category:
|
||||
@@ -139,6 +174,7 @@ export class DeviceMonitor {
|
||||
};
|
||||
|
||||
let next: DeviceStatusEvent;
|
||||
let device: Device | null = null;
|
||||
const driver = registry.get(row.driverId);
|
||||
if (!driver) {
|
||||
// Configured against a driver that's no longer registered — surface it,
|
||||
@@ -146,7 +182,7 @@ export class DeviceMonitor {
|
||||
next = { ...base, state: "offline", detail: "driver not registered", checkedAt: new Date().toISOString() };
|
||||
} else {
|
||||
try {
|
||||
const device = driver.create(cfg as never);
|
||||
device = driver.create(cfg as never);
|
||||
// Printers expose richer paper/cover/cutter status; everything else uses
|
||||
// the generic reachability probe. Both flatten to the same traffic-light.
|
||||
if (isMonitorable(device)) {
|
||||
@@ -163,6 +199,33 @@ export class DeviceMonitor {
|
||||
}
|
||||
}
|
||||
|
||||
// Camera clock re-sync at the power-restored edge (prev offline/unknown →
|
||||
// ready) + a daily backstop. Stamped BEFORE the async attempt so a failing
|
||||
// camera is retried at backstop cadence, never every poll.
|
||||
if (row.category === "camera" && next.state === "ready" && device && isClockSyncable(device)) {
|
||||
const prev = this.#latest.get(row.id);
|
||||
const cameBack = !prev || prev.state === "offline";
|
||||
const last = this.#clockSyncedAt.get(row.id) ?? 0;
|
||||
if (cameBack || Date.now() - last > CLOCK_SYNC_BACKSTOP_MS) {
|
||||
this.#clockSyncedAt.set(row.id, Date.now());
|
||||
const cam = device;
|
||||
void (async () => {
|
||||
try {
|
||||
const r = await cam.syncClock(localIsoWithOffset(siteTz(this.#db)), CLOCK_MAX_DRIFT_SEC);
|
||||
if (r.synced) {
|
||||
// A large jump is the 1970 power-cut signature — warn (persisted) so
|
||||
// the reboot stays visible; a small correction is routine info.
|
||||
const msg = `device-monitor: camera ${row.id} clock synced (was ${r.driftSeconds ?? "unparseable"}s off)`;
|
||||
if (r.driftSeconds == null || r.driftSeconds > 3600) this.#log.warn(msg);
|
||||
else this.#log.info(msg);
|
||||
}
|
||||
} catch (err) {
|
||||
this.#log.warn(`device-monitor: camera ${row.id} clock sync failed: ${(err as Error).message}`);
|
||||
}
|
||||
})();
|
||||
}
|
||||
}
|
||||
|
||||
this.#publish(row.id, next);
|
||||
}
|
||||
|
||||
@@ -183,6 +246,9 @@ export class DeviceMonitor {
|
||||
});
|
||||
}
|
||||
|
||||
/** Per-camera timestamp of the last clock-sync ATTEMPT (backstop pacing). */
|
||||
readonly #clockSyncedAt = new Map<string, number>();
|
||||
|
||||
/** Cache + emit a status, but only when it CHANGED (state or detail). */
|
||||
#publish(id: string, next: DeviceStatusEvent): void {
|
||||
const prev = this.#latest.get(id);
|
||||
|
||||
@@ -289,7 +289,19 @@ export function firstRelayByDirection(db: Db, direction: FlowDirection): Resolve
|
||||
(r): r is RelaySpec & { direction: Direction } =>
|
||||
r.direction === direction || r.direction === "both",
|
||||
);
|
||||
if (spec) return { controller, relay: spec.relay, direction: spec.direction };
|
||||
if (spec) {
|
||||
// Attach the presence sensor (if any) serving the SAME relay, so callers that gate on
|
||||
// presence (the operator-issued entry) see it. Without this the ResolvedRelay carried
|
||||
// no presenceInput and the presence gate read as "unavailable". Mirrors relayForButton.
|
||||
const presence = inputsOf(controller).find((i) => i.role === "presence" && i.relay === spec.relay);
|
||||
return {
|
||||
controller,
|
||||
relay: spec.relay,
|
||||
direction: spec.direction,
|
||||
presenceInput: presence?.input,
|
||||
presenceKind: presence?.kind ?? "loop",
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { deviceEvents as deviceEventsTable, ledgerEvents, sessions, type Db } from "@parking/db";
|
||||
import { createTestDb } from "@parking/db/testing";
|
||||
import { flagDuplicateEntryPlate } from "./snapshot.js";
|
||||
import { makeLog, silentLogger } from "./test-helpers.js";
|
||||
import type { EventLog } from "./event-log.js";
|
||||
|
||||
// Entry-side duplicate-plate reconciliation (2026-07-04): when ANPR recognizes a plate on
|
||||
// a fresh transient entry and that plate is already OPEN under another RECENT session,
|
||||
// the same car most likely minted a second ticket (a motion radar dropped the stationary
|
||||
// car → the button re-armed). We sign ONE entry.duplicatePlate anomaly for the operator
|
||||
// to void. Post-hoc + advisory: recognition never gates the (already-open) barrier —
|
||||
// exactly the non-blocking role the plate can play here.
|
||||
|
||||
let db: Db;
|
||||
let log: EventLog;
|
||||
|
||||
const PLATE = "AA111BB";
|
||||
const OLD = "11111111111";
|
||||
const NEW = "22222222222";
|
||||
|
||||
beforeEach(() => {
|
||||
({ db } = createTestDb());
|
||||
log = makeLog(db);
|
||||
});
|
||||
|
||||
/** Seed the prior entry's unsigned plate-read telemetry (what recognizePlate records). */
|
||||
function seedPriorRead(opts: { identity?: string; plate?: string; direction?: string; agoMs?: number } = {}) {
|
||||
db.insert(deviceEventsTable).values({
|
||||
id: randomUUID(),
|
||||
deviceId: "cam-entry",
|
||||
category: "camera",
|
||||
kind: "read",
|
||||
detail: {
|
||||
identity: opts.identity ?? OLD,
|
||||
direction: opts.direction ?? "entry",
|
||||
plate: opts.plate ?? PLATE,
|
||||
snapshotId: "snap-old",
|
||||
source: "entry-exit-snapshot",
|
||||
},
|
||||
occurredAt: new Date(Date.now() - (opts.agoMs ?? 60_000)).toISOString(),
|
||||
}).run();
|
||||
}
|
||||
|
||||
function seedSession(id: string, state: "open" | "closed") {
|
||||
db.insert(sessions).values({
|
||||
id,
|
||||
identity: id,
|
||||
source: "ticket",
|
||||
enteredAt: new Date(Date.now() - 60_000).toISOString(),
|
||||
state,
|
||||
}).run();
|
||||
}
|
||||
|
||||
const flag = () =>
|
||||
flagDuplicateEntryPlate({ db, log, identity: NEW, plate: PLATE, snapshotId: "snap-new", logger: silentLogger() });
|
||||
|
||||
const anomalies = () =>
|
||||
db.select().from(ledgerEvents).all().filter((r) => r.type === "anomaly");
|
||||
|
||||
describe("flagDuplicateEntryPlate", () => {
|
||||
it("same plate OPEN under another recent session → signs ONE entry.duplicatePlate anomaly", async () => {
|
||||
seedPriorRead();
|
||||
seedSession(OLD, "open");
|
||||
await flag();
|
||||
expect(anomalies()).toHaveLength(1);
|
||||
const a = anomalies()[0];
|
||||
expect(a.identity).toBe(NEW); // keyed to the NEW (suspect) ticket
|
||||
expect(a.payload).toMatchObject({
|
||||
reasonCode: "entry.duplicatePlate",
|
||||
duplicateEntrySuspected: true,
|
||||
plate: PLATE,
|
||||
otherIdentity: OLD,
|
||||
snapshotId: "snap-new",
|
||||
});
|
||||
});
|
||||
|
||||
it("prior session already CLOSED → no anomaly (that car drove off; a re-visit is legit)", async () => {
|
||||
seedPriorRead();
|
||||
seedSession(OLD, "closed");
|
||||
await flag();
|
||||
expect(anomalies()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("prior read outside the window → no anomaly (stale coincidence, not a double press)", async () => {
|
||||
seedPriorRead({ agoMs: 30 * 60_000 }); // beyond the 15-min default window
|
||||
seedSession(OLD, "open");
|
||||
await flag();
|
||||
expect(anomalies()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("own read (same identity) never flags itself", async () => {
|
||||
seedPriorRead({ identity: NEW });
|
||||
seedSession(NEW, "open");
|
||||
await flag();
|
||||
expect(anomalies()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("different plate / exit-side reads are ignored", async () => {
|
||||
seedPriorRead({ plate: "ZZ999ZZ" });
|
||||
seedPriorRead({ direction: "exit" });
|
||||
seedSession(OLD, "open");
|
||||
await flag();
|
||||
expect(anomalies()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
+210
-23
@@ -12,10 +12,10 @@ import {
|
||||
} from "@parking/devices";
|
||||
import { DEFAULT_VEHICLE_CATEGORY, reasonPayload } from "@parking/shared";
|
||||
import type { FastifyBaseLogger } from "fastify";
|
||||
import type { DeviceInputEvent } from "./device-events.js";
|
||||
import type { DeviceInputEvent, LaneStatusEvent } from "./device-events.js";
|
||||
import { getOccupancy } from "./occupancy.js";
|
||||
import type { EventLog } from "./event-log.js";
|
||||
import { devicesByDirection, relayForButton, relayForPresence, type ResolvedRelay } from "./device-resolve.js";
|
||||
import { devicesByDirection, firstRelayByDirection, relayForButton, relayForPresence, type ResolvedRelay } from "./device-resolve.js";
|
||||
import { snapshotAsync } from "./snapshot.js";
|
||||
import type { VisionClient } from "./vision-client.js";
|
||||
|
||||
@@ -48,9 +48,21 @@ import type { VisionClient } from "./vision-client.js";
|
||||
// input edges to track presence + "armed" per relay.
|
||||
// - COOLDOWN (fallback, no feedback): `entryCooldownSec` suppresses repeat presses on
|
||||
// the relay for N seconds after a ticket. A timer — mitigation, not a guarantee.
|
||||
// When a loop IS wired the cooldown still runs as a BACKSTOP behind it: a motion
|
||||
// radar can drop a STATIONARY car (no doppler return) and spuriously re-arm, and the
|
||||
// cooldown bounds how fast that re-armed press can mint a second ticket.
|
||||
// - CAMERA (when an entry camera is configured): a press is live only while the entry
|
||||
// lane camera confirms a vehicle — the button lamp's SOLID state (button-light.ts).
|
||||
// A radar false-positive (rain, a pedestrian) blinks the lamp but prints nothing.
|
||||
// Camera-less sites keep the radar-only gate; a faulty camera is dropped via the
|
||||
// admin bypass (wiki/concepts/entry-presence-bypass.md).
|
||||
// A suppressed press is recorded as UNSIGNED telemetry (a no-op, not a fraud anomaly).
|
||||
// See wiki/concepts/entry-double-press.md.
|
||||
|
||||
/** A presence signal the entry gate can require (or, when a device is faulty, the admin
|
||||
* can bypass): the radar/loop presence input, or the camera vehicle-detection. */
|
||||
export type PresenceSignal = "radar" | "camera";
|
||||
|
||||
/** Per-relay anti-double-press state, keyed `controllerId:relay`. */
|
||||
interface RelayGuardState {
|
||||
/** Last successful ticket time (ms epoch) — drives the cooldown check. */
|
||||
@@ -72,6 +84,10 @@ export class EntryFlow {
|
||||
readonly #guard = new Map<string, RelayGuardState>();
|
||||
/** Optional vision client — passed to snapshotAsync so ANPR runs on the entry image. */
|
||||
readonly #vision: VisionClient | null;
|
||||
/** Live entry-lane camera state (LaneStatus mirror, fed by onLaneStatus). Gates the
|
||||
* physical press when an entry camera is configured — advisory sensor, but here it
|
||||
* only ever SUPPRESSES a reprint; it never opens a barrier or traps a car. */
|
||||
#entryBusy = false;
|
||||
|
||||
constructor(db: Db, log: EventLog, logger: FastifyBaseLogger, vision: VisionClient | null = null) {
|
||||
this.#db = db;
|
||||
@@ -121,6 +137,12 @@ export class EntryFlow {
|
||||
}
|
||||
}
|
||||
|
||||
/** Track the entry lane's camera state (wired to deviceEvents.onLaneStatus in
|
||||
* server.ts). LaneStatus emits on every flip, so this mirror stays current. */
|
||||
onLaneStatus(s: LaneStatusEvent): void {
|
||||
this.#entryBusy = s.entry;
|
||||
}
|
||||
|
||||
/** Stable per-relay key for the guard map. */
|
||||
#relayKey(r: ResolvedRelay): string {
|
||||
return `${r.controller.id}:${r.relay}`;
|
||||
@@ -153,17 +175,35 @@ export class EntryFlow {
|
||||
}
|
||||
|
||||
/** Why a press should be SUPPRESSED (no ticket), or null if it may proceed.
|
||||
* PRESENCE mode is authoritative when a loop is wired; otherwise COOLDOWN; else no
|
||||
* guard (legacy). The two can coexist — presence first, cooldown as a backstop. */
|
||||
* Three layered gates: CAMERA (when an entry camera is configured), PRESENCE
|
||||
* (when a loop is wired), and COOLDOWN — no longer alternatives: the cooldown
|
||||
* runs as a backstop BEHIND presence, because a motion radar can drop a
|
||||
* stationary car and spuriously re-arm one-car-one-ticket. */
|
||||
#suppressReason(r: ResolvedRelay): string | null {
|
||||
const s = this.#guardState(r);
|
||||
const bypass = this.#presenceBypass();
|
||||
|
||||
if (typeof r.presenceInput === "number") {
|
||||
// CAMERA GATE — the lamp's blink-vs-solid rule, enforced at the press: with an entry
|
||||
// camera configured, a press is live only once the camera confirms a vehicle in the
|
||||
// entry zone (SOLID). Blink (radar-only — rain, a pedestrian, a reflection) prints
|
||||
// nothing. Only ever suppresses a ticket; never opens or traps (advisory rule kept).
|
||||
// A camera-less site skips this; a faulty camera is dropped via the admin bypass.
|
||||
if (!bypass.camera && !this.#entryBusy && this.#entryCameraConfigured()) {
|
||||
return "no camera-confirmed vehicle in the entry zone";
|
||||
}
|
||||
|
||||
// Admin bypass for a FAULTY radar/loop: skip the presence-loop check so a press prints.
|
||||
// A dead loop can't re-arm one-car-one-ticket, so the cooldown below is what stops a
|
||||
// held button minting a burst. If no cooldown is configured there's no anti-double-press
|
||||
// left — that's the admin's accepted tradeoff while bypassed. See
|
||||
// wiki/concepts/entry-presence-bypass.md.
|
||||
if (typeof r.presenceInput === "number" && !bypass.radar) {
|
||||
// Physical one-car-one-ticket: a car must be present AND we must be armed (no
|
||||
// ticket already issued for this still-present car).
|
||||
if (!s.present) return "no vehicle at the barrier (presence loop clear)";
|
||||
if (!s.armed) return "ticket already issued for the car at the barrier";
|
||||
return null;
|
||||
// Fall THROUGH to the cooldown backstop: a presence-approved press can still be the
|
||||
// SAME stationary car after a radar dropout re-armed the guard.
|
||||
}
|
||||
|
||||
if (typeof r.entryCooldownSec === "number" && r.entryCooldownSec > 0) {
|
||||
@@ -176,6 +216,13 @@ export class EntryFlow {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Is at least one enabled camera bound to the entry lane? The camera gate applies only
|
||||
* then — a site with no entry camera keeps the radar-only press gate. Read live (like
|
||||
* the bypass flags) so adding/removing a camera needs no restart. */
|
||||
#entryCameraConfigured(): boolean {
|
||||
return devicesByDirection(this.#db, "camera", "entry").length > 0;
|
||||
}
|
||||
|
||||
/** Record a suppressed (repeat/no-car) entry press as UNSIGNED telemetry — a no-op,
|
||||
* not a fraud anomaly, so the signed ledger stays clean (the operator's choice). */
|
||||
#recordSuppressedPress(e: DeviceInputEvent, r: ResolvedRelay, reason: string): void {
|
||||
@@ -229,9 +276,36 @@ export class EntryFlow {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.#issueTicket(resolved, { source: "ticket" });
|
||||
}
|
||||
|
||||
/**
|
||||
* The shared "issue a transient ticket" sequence used by BOTH the physical button
|
||||
* (#runEntry) and the operator-initiated path (issueForOperator) — ONE copy of the
|
||||
* fraud-critical ordering (print → sign vehicle_entry BEFORE open → open → snapshot →
|
||||
* cache), never a divergent second copy. `opts.source` is "ticket" (button) or "booth"
|
||||
* (operator). For an operator mint we stamp `operatorInitiated` + `operator` on the
|
||||
* signed entry AND append a companion `anomaly` (the operator-adversary path always
|
||||
* leaves a red-flag row); `overCapacity` records a full-lot override. Returns the
|
||||
* outcome so the operator route can report it. See wiki/concepts/operator-issued-entry.md.
|
||||
*/
|
||||
async #issueTicket(
|
||||
resolved: ResolvedRelay,
|
||||
opts: {
|
||||
source: "ticket" | "manual";
|
||||
operator?: string;
|
||||
overCapacity?: { count: number; capacity: number | null };
|
||||
/** Presence signals that were BYPASSED (admin dropped them due to faulty hardware).
|
||||
* Recorded on the signed entry so a ticket issued under a weakened gate is auditable. */
|
||||
presenceBypassed?: PresenceSignal[];
|
||||
},
|
||||
): Promise<{ ok: true; ticketId: string; opened: boolean } | { ok: false; reason: string }> {
|
||||
const ticketId = newTicketId();
|
||||
const issuedAt = new Date().toISOString();
|
||||
const printers = this.#loadPrinters();
|
||||
// Operator mint = ledger source "manual" (human intervention, like the barrier re-open)
|
||||
// + operatorInitiated:true in the payload. The button path is source "ticket".
|
||||
const operatorInitiated = opts.source === "manual";
|
||||
|
||||
// 1. PRINT FIRST. The ticket is the transient's session key — no ticket, no entry.
|
||||
const ticket: TicketData = { ticketId, issuedAt, header: this.#ticketHeader() };
|
||||
@@ -260,17 +334,14 @@ export class EntryFlow {
|
||||
// Capture who is held at the barrier (evidence for the operator handling the car).
|
||||
this.#fireSnapshot("entry", ticketId);
|
||||
this.#logger.warn(`entry HELD: ${reason} (barrier NOT opened)`);
|
||||
return;
|
||||
return { ok: false, reason };
|
||||
}
|
||||
|
||||
// 2. SIGN the vehicle_entry — BEFORE the relay fires (the core invariant).
|
||||
// `category` is FROZEN here (in the signed payload) so the tariff prices and
|
||||
// later reprices the same way at exit. Today every transient takes the SITE
|
||||
// default category (operator policy, site_config.default_vehicle_category;
|
||||
// falls back to the shared DEFAULT_VEHICLE_CATEGORY). Per-relay capture (a
|
||||
// "bus lane" relay, mirroring how direction is per-relay in device-resolve.ts)
|
||||
// is the future seam — source it from `resolved` then. A V1/no-category tariff
|
||||
// ignores it; only V2 category cards consult it.
|
||||
// falls back to the shared DEFAULT_VEHICLE_CATEGORY).
|
||||
const cfg = this.#db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||
const category =
|
||||
cfg?.defaultVehicleCategory && cfg.defaultVehicleCategory.length > 0
|
||||
@@ -279,44 +350,160 @@ export class EntryFlow {
|
||||
await this.#log.append({
|
||||
type: "vehicle_entry",
|
||||
direction: "entry",
|
||||
source: "ticket",
|
||||
source: opts.source,
|
||||
identity: ticketId,
|
||||
payload: { sessionRef: ticketId, ticketPrinted: true, category },
|
||||
payload: {
|
||||
sessionRef: ticketId,
|
||||
ticketPrinted: true,
|
||||
category,
|
||||
...(operatorInitiated ? { operatorInitiated: true, operator: opts.operator } : {}),
|
||||
...(opts.overCapacity ? { lotFull: true, occupancy: `${opts.overCapacity.count}/${opts.overCapacity.capacity ?? "∞"}` } : {}),
|
||||
...(opts.presenceBypassed && opts.presenceBypassed.length > 0
|
||||
? { presenceBypassed: opts.presenceBypassed }
|
||||
: {}),
|
||||
},
|
||||
occurredAt: issuedAt,
|
||||
});
|
||||
|
||||
// 2b. For an operator mint, append a companion ANOMALY — the operator-adversary path
|
||||
// always leaves a red-flag row in the tamper-evident record for reconciliation.
|
||||
if (operatorInitiated) {
|
||||
await this.#log.append({
|
||||
type: "anomaly",
|
||||
identity: ticketId,
|
||||
payload: {
|
||||
...reasonPayload("entry.operatorIssued", { operator: opts.operator ?? "?" }),
|
||||
source: "booth",
|
||||
operatorInitiated: true,
|
||||
...(opts.operator ? { operator: opts.operator } : {}),
|
||||
...(opts.overCapacity ? { lotFull: true } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 3. OPEN the resolved entry barrier (intent only; the barrier owns the close).
|
||||
const access = this.#buildAccess(resolved.controller);
|
||||
if (access) await access.pulseOpen(resolved.relay);
|
||||
else this.#logger.warn(`entry signed for ${ticketId} but the entry relay won't build`);
|
||||
let opened = false;
|
||||
if (access) {
|
||||
await access.pulseOpen(resolved.relay);
|
||||
opened = true;
|
||||
} else this.#logger.warn(`entry signed for ${ticketId} but the entry relay won't build`);
|
||||
|
||||
// 3b. SNAPSHOT — fire the entry camera(s), never awaited (evidence, not a gate;
|
||||
// a camera failure must not delay or block the already-open barrier).
|
||||
// 3b. SNAPSHOT — fire the entry camera(s), never awaited (evidence, not a gate; a
|
||||
// camera failure must not delay or block the already-open barrier). This is ALSO
|
||||
// what records the plate that plate-reconciliation reads at exit.
|
||||
this.#fireSnapshot("entry", ticketId);
|
||||
|
||||
// 4. Update the session projection cache (rebuildable from the ledger; this is
|
||||
// just a fast read-model, never the source of truth).
|
||||
// 4. Update the session projection cache (rebuildable from the ledger; a read-model).
|
||||
try {
|
||||
this.#db
|
||||
.insert(sessions)
|
||||
.values({ id: ticketId, identity: ticketId, source: "ticket", enteredAt: issuedAt, state: "open" })
|
||||
.values({ id: ticketId, identity: ticketId, source: opts.source, enteredAt: issuedAt, state: "open" })
|
||||
.run();
|
||||
} catch (err) {
|
||||
// Cache miss is non-fatal — the ledger is authoritative and the projection
|
||||
// can be rebuilt. Log it; don't fail the (already-open) entry.
|
||||
this.#logger.error(`session-cache insert failed for ${ticketId}: ${(err as Error).message}`);
|
||||
}
|
||||
return { ok: true, ticketId, opened };
|
||||
}
|
||||
|
||||
/**
|
||||
* OPERATOR-ISSUED entry (physical entry button broken). Gated exactly like the button:
|
||||
* a REAL vehicle must be present at the entry — BOTH radar/loop presence AND camera
|
||||
* confirmation. `cameraBusy` is the current LaneStatus.entry (passed by the route); loop
|
||||
* presence is this flow's own per-relay guard state. If a site has no presence loop the
|
||||
* feature is unavailable (we require both — no weaker fallback). Refuses (+ signs an
|
||||
* anomaly) when no vehicle is present, so probing the endpoint is itself recorded. Over
|
||||
* capacity is ALLOWED but flagged (a broken button mustn't trap a legit car). The mint
|
||||
* itself is flagged (source:"booth" + operatorInitiated + a companion anomaly).
|
||||
* See wiki/concepts/operator-issued-entry.md.
|
||||
*/
|
||||
async issueForOperator(operator: string, cameraBusy: boolean): Promise<
|
||||
{ ok: true; ticketId: string; opened: boolean; overCapacity: boolean } | { ok: false; reason: string }
|
||||
> {
|
||||
const resolved = firstRelayByDirection(this.#db, "entry");
|
||||
if (!resolved) return { ok: false, reason: "no entry barrier configured" };
|
||||
|
||||
// PRESENCE GATE — normally require BOTH radar/loop presence AND camera detection. An
|
||||
// admin may BYPASS a signal when its device is faulty (site_config, signed config_change);
|
||||
// the bypassed signal is dropped as a requirement and RECORDED on the issued ticket.
|
||||
const bypass = this.#presenceBypass();
|
||||
const bypassed: PresenceSignal[] = [];
|
||||
|
||||
// Radar/loop side. A configured loop is only mandatory while radar is still REQUIRED;
|
||||
// if radar is bypassed we skip the loop entirely (a dead loop is exactly why they bypass).
|
||||
const radarRequired = !bypass.radar;
|
||||
let radarPresent: boolean | null = null;
|
||||
if (radarRequired) {
|
||||
if (typeof resolved.presenceInput !== "number") {
|
||||
return { ok: false, reason: "no presence loop on the entry barrier — operator issue unavailable (or bypass radar)" };
|
||||
}
|
||||
radarPresent = this.#guardState(resolved).present;
|
||||
} else {
|
||||
bypassed.push("radar");
|
||||
}
|
||||
|
||||
// Camera side.
|
||||
const cameraRequired = !bypass.camera;
|
||||
if (!cameraRequired) bypassed.push("camera");
|
||||
|
||||
// Refuse only when a STILL-REQUIRED signal fails to confirm a vehicle.
|
||||
const radarOk = !radarRequired || radarPresent === true;
|
||||
const cameraOk = !cameraRequired || cameraBusy;
|
||||
if (!radarOk || !cameraOk) {
|
||||
await this.#log.append({
|
||||
type: "anomaly",
|
||||
identity: `ENTRY-ATTEMPT-${randomUUID().replace(/-/g, "").slice(0, 12)}`,
|
||||
payload: {
|
||||
...reasonPayload("entry.issue.noPresence", { operator }),
|
||||
source: "booth",
|
||||
operator,
|
||||
radarPresent,
|
||||
cameraBusy,
|
||||
...(bypassed.length > 0 ? { presenceBypassed: bypassed } : {}),
|
||||
},
|
||||
});
|
||||
this.#logger.warn(
|
||||
`operator entry refused by ${operator}: no vehicle present (radar=${radarPresent}, camera=${cameraBusy}, bypassed=[${bypassed.join(",")}])`,
|
||||
);
|
||||
return { ok: false, reason: "no vehicle detected at the entry" };
|
||||
}
|
||||
|
||||
const key = `operator-issue:${this.#relayKey(resolved)}`;
|
||||
if (this.#inFlight.has(key)) return { ok: false, reason: "an entry is already in progress" };
|
||||
this.#inFlight.add(key);
|
||||
try {
|
||||
const occ = getOccupancy(this.#db);
|
||||
const res = await this.#issueTicket(resolved, {
|
||||
source: "manual",
|
||||
operator,
|
||||
...(occ.full ? { overCapacity: { count: occ.count, capacity: occ.capacity ?? null } } : {}),
|
||||
...(bypassed.length > 0 ? { presenceBypassed: bypassed } : {}),
|
||||
});
|
||||
if (!res.ok) return res;
|
||||
return { ok: true, ticketId: res.ticketId, opened: res.opened, overCapacity: occ.full };
|
||||
} finally {
|
||||
this.#inFlight.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
/** Fire the entry camera(s) for an identity; never awaited (evidence, not a gate).
|
||||
* Used on both the OPEN path and the refused/held anomaly paths — a turned-away or
|
||||
* held car is exactly when the operator wants the photo. */
|
||||
#fireSnapshot(direction: "entry", identity: string): void {
|
||||
void snapshotAsync({ db: this.#db, direction, identity, logger: this.#logger, vision: this.#vision }).catch(
|
||||
// `log` lets the ANPR ride-along flag a duplicate-plate entry (a signed anomaly) —
|
||||
// still fire-and-forget; recognition never gates the open. See snapshot.ts.
|
||||
void snapshotAsync({ db: this.#db, direction, identity, logger: this.#logger, vision: this.#vision, log: this.#log }).catch(
|
||||
(err) => this.#logger.error(`entry snapshot error: ${(err as Error).message}`),
|
||||
);
|
||||
}
|
||||
|
||||
/** Current admin presence-gate bypass (site_config), read LIVE so a toggle takes effect
|
||||
* with no restart. Default: nothing bypassed (the normal both-required gate). */
|
||||
#presenceBypass(): { radar: boolean; camera: boolean } {
|
||||
const cfg = this.#db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||
return { radar: cfg?.bypassPresenceRadar ?? false, camera: cfg?.bypassPresenceCamera ?? false };
|
||||
}
|
||||
|
||||
/** Build a live access adapter from a resolved controller row, or null. */
|
||||
#buildAccess(row: DeviceRow): AccessControlDevice | null {
|
||||
const driver = registry.get(row.driverId);
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { devices, siteConfig, ledgerEvents, type Db } from "@parking/db";
|
||||
import { createTestDb } from "@parking/db/testing";
|
||||
import { EntryFlow } from "./entry-flow.js";
|
||||
import { makeLog, silentLogger } from "./test-helpers.js";
|
||||
|
||||
// The entry presence gate normally requires BOTH radar/loop presence AND camera detection.
|
||||
// An admin may BYPASS a signal when its device is faulty (site_config, set via a signed
|
||||
// endpoint). These tests pin the GATE decision in EntryFlow.issueForOperator under each
|
||||
// bypass combination: a still-required-but-absent signal refuses (+ signs an anomaly); a
|
||||
// bypassed signal is dropped and recorded. We assert the gate outcome via the refuse path
|
||||
// (deterministic, no printer needed); the allow path is proven by getting PAST the gate
|
||||
// (it then fails at printing — a different reason — which is exactly "the gate opened").
|
||||
|
||||
let db: Db;
|
||||
let flow: EntryFlow;
|
||||
|
||||
const CTL = "ctl-entry";
|
||||
const PRESENCE_INPUT = 2;
|
||||
|
||||
beforeEach(() => {
|
||||
({ db } = createTestDb());
|
||||
// A controller with an entry barrier (R1), a presence loop on input 2, and an entry button
|
||||
// on input 1 — the shape device-resolve expects (relays[] + inputs[]).
|
||||
db.insert(devices).values({
|
||||
id: CTL,
|
||||
category: "access",
|
||||
driverId: "stub-access",
|
||||
config: {
|
||||
relays: [{ relay: 1, direction: "entry" }],
|
||||
inputs: [
|
||||
{ input: 1, role: "button", relay: 1 },
|
||||
{ input: PRESENCE_INPUT, role: "presence", relay: 1, kind: "loop" },
|
||||
],
|
||||
},
|
||||
enabled: true,
|
||||
}).run();
|
||||
flow = new EntryFlow(db, makeLog(db), silentLogger());
|
||||
});
|
||||
|
||||
function setBypass(patch: { radar?: boolean; camera?: boolean }) {
|
||||
db.insert(siteConfig)
|
||||
.values({ id: 1, bypassPresenceRadar: patch.radar ?? false, bypassPresenceCamera: patch.camera ?? false })
|
||||
.onConflictDoUpdate({
|
||||
target: siteConfig.id,
|
||||
set: { bypassPresenceRadar: patch.radar ?? false, bypassPresenceCamera: patch.camera ?? false },
|
||||
})
|
||||
.run();
|
||||
}
|
||||
|
||||
/** Drive a presence loop edge so the flow's per-relay guard marks a car present/clear. */
|
||||
async function setRadarPresent(present: boolean) {
|
||||
await flow.onInput({
|
||||
driverId: "stub-access",
|
||||
deviceId: CTL,
|
||||
input: PRESENCE_INPUT,
|
||||
edge: present ? "on" : "off",
|
||||
at: new Date().toISOString(),
|
||||
source: "poll",
|
||||
});
|
||||
}
|
||||
|
||||
const anomalies = () =>
|
||||
db.select().from(ledgerEvents).all().filter((r) => r.type === "anomaly");
|
||||
|
||||
describe("entry presence-gate bypass", () => {
|
||||
it("no bypass + no vehicle → refuses and signs a noPresence anomaly", async () => {
|
||||
const res = await flow.issueForOperator("admin", /*cameraBusy*/ false);
|
||||
expect(res.ok).toBe(false);
|
||||
expect(anomalies()).toHaveLength(1);
|
||||
expect(anomalies()[0].payload).toMatchObject({ reasonCode: "entry.issue.noPresence" });
|
||||
});
|
||||
|
||||
it("camera bypassed + radar present → gate OPENS (no refuse anomaly)", async () => {
|
||||
setBypass({ camera: true });
|
||||
await setRadarPresent(true);
|
||||
const res = await flow.issueForOperator("admin", /*cameraBusy*/ false); // camera absent but bypassed
|
||||
// Gate passed: no noPresence refusal. (It then proceeds to print — no printer configured,
|
||||
// so it HOLDS with a print reason, not a presence reason. Either way the gate opened.)
|
||||
const refusals = anomalies().filter((a) => (a.payload as { reasonCode?: string }).reasonCode === "entry.issue.noPresence");
|
||||
expect(refusals).toHaveLength(0);
|
||||
if (!res.ok) expect(res.reason).not.toMatch(/no vehicle detected/);
|
||||
});
|
||||
|
||||
it("radar bypassed + camera busy → gate OPENS even with NO presence loop reading", async () => {
|
||||
setBypass({ radar: true });
|
||||
// radar NOT set present; camera busy=true → radar dropped, camera satisfies.
|
||||
const res = await flow.issueForOperator("admin", /*cameraBusy*/ true);
|
||||
const refusals = anomalies().filter((a) => (a.payload as { reasonCode?: string }).reasonCode === "entry.issue.noPresence");
|
||||
expect(refusals).toHaveLength(0);
|
||||
if (!res.ok) expect(res.reason).not.toMatch(/no vehicle detected/);
|
||||
});
|
||||
|
||||
it("camera bypassed but radar STILL required and absent → refuses (only the faulty signal is dropped)", async () => {
|
||||
setBypass({ camera: true });
|
||||
await setRadarPresent(false); // radar required (not bypassed) and clear
|
||||
const res = await flow.issueForOperator("admin", /*cameraBusy*/ true);
|
||||
expect(res.ok).toBe(false);
|
||||
const refusal = anomalies().find((a) => (a.payload as { reasonCode?: string }).reasonCode === "entry.issue.noPresence");
|
||||
expect(refusal, "the still-required radar gates the button").toBeTruthy();
|
||||
// The refusal records which signal was bypassed (audit).
|
||||
expect(refusal!.payload).toMatchObject({ presenceBypassed: ["camera"] });
|
||||
});
|
||||
|
||||
it("both bypassed → gate OPENS with no radar and no camera (press-to-print)", async () => {
|
||||
setBypass({ radar: true, camera: true });
|
||||
const res = await flow.issueForOperator("admin", /*cameraBusy*/ false);
|
||||
const refusals = anomalies().filter((a) => (a.payload as { reasonCode?: string }).reasonCode === "entry.issue.noPresence");
|
||||
expect(refusals).toHaveLength(0);
|
||||
if (!res.ok) expect(res.reason).not.toMatch(/no vehicle detected/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,213 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { devices, siteConfig, ledgerEvents, deviceEvents as deviceEventsTable, type Db } from "@parking/db";
|
||||
import { createTestDb } from "@parking/db/testing";
|
||||
import { registry, type PrinterDevice } from "@parking/devices";
|
||||
import { EntryFlow } from "./entry-flow.js";
|
||||
import { makeLog, silentLogger } from "./test-helpers.js";
|
||||
|
||||
// The PHYSICAL entry button's press gate (#suppressReason), layered (2026-07-04):
|
||||
// CAMERA — with an entry camera configured, a press is live only while the entry lane
|
||||
// camera confirms a vehicle (the button lamp's SOLID state). Blink (radar-only) prints
|
||||
// nothing. Camera-less sites skip this; the admin camera bypass drops it.
|
||||
// PRESENCE — one-car-one-ticket off the loop (unchanged).
|
||||
// COOLDOWN — now a BACKSTOP behind presence, not an alternative: a motion radar drops a
|
||||
// stationary car (no doppler return), spuriously re-arming the guard; the cooldown bounds
|
||||
// how fast that re-armed press can mint a second ticket for the same car.
|
||||
// A suppressed press is unsigned telemetry (entrySuppressed), never a ledger anomaly.
|
||||
|
||||
let db: Db;
|
||||
let flow: EntryFlow;
|
||||
|
||||
const CTL = "ctl-entry";
|
||||
const BUTTON_INPUT = 1;
|
||||
const PRESENCE_INPUT = 2;
|
||||
|
||||
// A no-op printer that always succeeds, so the happy path reaches the signed
|
||||
// vehicle_entry (the real drivers need hardware). Registered once (registry is global).
|
||||
const noopPrinter: PrinterDevice = {
|
||||
driverId: "test-printer-ok",
|
||||
connect: async () => {},
|
||||
disconnect: async () => {},
|
||||
healthCheck: async () => ({ status: "ready" as const }),
|
||||
printTicket: async () => {},
|
||||
printReport: async () => {},
|
||||
printSubscriptionCard: async () => {},
|
||||
printReceipt: async () => {},
|
||||
printWindowChargeNotice: async () => {},
|
||||
};
|
||||
if (!registry.get("test-printer-ok")) {
|
||||
registry.register({
|
||||
id: "test-printer-ok",
|
||||
category: "printer",
|
||||
label: "Test printer",
|
||||
description: "always-succeeds stub for tests",
|
||||
transports: [],
|
||||
configFields: [],
|
||||
create: () => noopPrinter,
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
({ db } = createTestDb());
|
||||
db.insert(devices).values({
|
||||
id: CTL,
|
||||
category: "access",
|
||||
driverId: "stub-access",
|
||||
config: {
|
||||
relays: [{ relay: 1, direction: "entry" }],
|
||||
inputs: [
|
||||
{ input: BUTTON_INPUT, role: "button", relay: 1 },
|
||||
{ input: PRESENCE_INPUT, role: "presence", relay: 1, kind: "radar" },
|
||||
],
|
||||
},
|
||||
enabled: true,
|
||||
}).run();
|
||||
db.insert(devices).values({
|
||||
id: "printer-entry",
|
||||
category: "printer",
|
||||
driverId: "test-printer-ok",
|
||||
config: { direction: "entry" },
|
||||
enabled: true,
|
||||
}).run();
|
||||
flow = new EntryFlow(db, makeLog(db), silentLogger());
|
||||
});
|
||||
|
||||
/** Add an entry camera row. The driver never builds (unknown id) — only its EXISTENCE
|
||||
* matters to the press gate; snapshot capture failing is the normal fire-and-forget path. */
|
||||
function addEntryCamera() {
|
||||
db.insert(devices).values({
|
||||
id: "cam-entry",
|
||||
category: "camera",
|
||||
driverId: "no-such-camera-driver",
|
||||
config: { direction: "entry" },
|
||||
enabled: true,
|
||||
}).run();
|
||||
}
|
||||
|
||||
function setCameraBypass(on: boolean) {
|
||||
db.insert(siteConfig)
|
||||
.values({ id: 1, bypassPresenceCamera: on })
|
||||
.onConflictDoUpdate({ target: siteConfig.id, set: { bypassPresenceCamera: on } })
|
||||
.run();
|
||||
}
|
||||
|
||||
async function edge(input: number, edge: "on" | "off") {
|
||||
await flow.onInput({
|
||||
driverId: "stub-access",
|
||||
deviceId: CTL,
|
||||
input,
|
||||
edge,
|
||||
at: new Date().toISOString(),
|
||||
source: "poll",
|
||||
});
|
||||
}
|
||||
|
||||
const press = () => edge(BUTTON_INPUT, "on");
|
||||
const radar = (present: boolean) => edge(PRESENCE_INPUT, present ? "on" : "off");
|
||||
|
||||
const entries = () =>
|
||||
db.select().from(ledgerEvents).all().filter((r) => r.type === "vehicle_entry");
|
||||
const suppressed = () =>
|
||||
db.select().from(deviceEventsTable).all()
|
||||
.map((r) => r.detail as { entrySuppressed?: boolean; reason?: string })
|
||||
.filter((d) => d.entrySuppressed === true);
|
||||
|
||||
describe("entry press gate — camera (blink vs solid)", () => {
|
||||
it("BLINK state (radar present, no camera confirmation) → press suppressed, nothing signed", async () => {
|
||||
addEntryCamera();
|
||||
await radar(true); // lamp would blink: radar sees something, camera does not
|
||||
await press();
|
||||
expect(entries()).toHaveLength(0);
|
||||
expect(db.select().from(ledgerEvents).all()).toHaveLength(0); // no anomaly either — telemetry only
|
||||
expect(suppressed()).toHaveLength(1);
|
||||
expect(suppressed()[0].reason).toMatch(/camera/);
|
||||
});
|
||||
|
||||
it("SOLID state (radar present + camera busy) → press prints and signs a vehicle_entry", async () => {
|
||||
addEntryCamera();
|
||||
await radar(true);
|
||||
flow.onLaneStatus({ entry: true, exit: false }); // camera confirms → SOLID
|
||||
await press();
|
||||
expect(entries()).toHaveLength(1);
|
||||
expect(suppressed()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("camera-less site → the camera gate does not apply (radar-only, as before)", async () => {
|
||||
await radar(true); // no camera row; lane state irrelevant
|
||||
await press();
|
||||
expect(entries()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("camera bypassed (faulty camera) → press prints without camera confirmation", async () => {
|
||||
addEntryCamera();
|
||||
setCameraBypass(true);
|
||||
await radar(true);
|
||||
await press();
|
||||
expect(entries()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("no car at all (radar clear too) → suppressed even with the camera bypassed", async () => {
|
||||
addEntryCamera();
|
||||
setCameraBypass(true);
|
||||
await press(); // radar never went on
|
||||
expect(entries()).toHaveLength(0);
|
||||
expect(suppressed()[0].reason).toMatch(/presence loop clear/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("entry press gate — cooldown backstop behind presence", () => {
|
||||
/** Same lane but the button carries a cooldown, making it a backstop behind the loop. */
|
||||
function setButtonCooldown(sec: number) {
|
||||
db.delete(devices).run();
|
||||
db.insert(devices).values({
|
||||
id: CTL,
|
||||
category: "access",
|
||||
driverId: "stub-access",
|
||||
config: {
|
||||
relays: [{ relay: 1, direction: "entry" }],
|
||||
inputs: [
|
||||
{ input: BUTTON_INPUT, role: "button", relay: 1, cooldownSec: sec },
|
||||
{ input: PRESENCE_INPUT, role: "presence", relay: 1, kind: "radar" },
|
||||
],
|
||||
},
|
||||
enabled: true,
|
||||
}).run();
|
||||
db.insert(devices).values({
|
||||
id: "printer-entry",
|
||||
category: "printer",
|
||||
driverId: "test-printer-ok",
|
||||
config: { direction: "entry" },
|
||||
enabled: true,
|
||||
}).run();
|
||||
}
|
||||
|
||||
it("radar dropout re-arm + quick re-press → caught by the cooldown (one ticket)", async () => {
|
||||
setButtonCooldown(60);
|
||||
await radar(true);
|
||||
await press(); // ticket 1 (no camera configured — radar-only site)
|
||||
expect(entries()).toHaveLength(1);
|
||||
// The motion radar loses the STATIONARY car and re-fires: off (re-arms!) then on.
|
||||
await radar(false);
|
||||
await radar(true);
|
||||
await press(); // presence gate says yes (present + re-armed) — the backstop must catch it
|
||||
expect(entries()).toHaveLength(1);
|
||||
expect(suppressed().some((d) => /cooldown/.test(d.reason ?? ""))).toBe(true);
|
||||
});
|
||||
|
||||
it("without a cooldown the dropout re-press mints a second ticket (the documented residual risk)", async () => {
|
||||
await radar(true);
|
||||
await press();
|
||||
await radar(false);
|
||||
await radar(true);
|
||||
await press();
|
||||
expect(entries()).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("still-present car re-pressing (no dropout) stays suppressed by one-car-one-ticket", async () => {
|
||||
await radar(true);
|
||||
await press();
|
||||
await press(); // car never left the loop → not re-armed
|
||||
expect(entries()).toHaveLength(1);
|
||||
expect(suppressed().some((d) => /already issued/.test(d.reason ?? ""))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { createTestDb } from "@parking/db/testing";
|
||||
import { ledgerEvents, eq, type Db } from "@parking/db";
|
||||
import { ledgerEvents, deviceEvents as deviceEventsTable, sessions as sessionsTable, eq, type Db } from "@parking/db";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { ExitFlow } from "./exit-flow.js";
|
||||
import { PayStation } from "./pay-station.js";
|
||||
import type { EventLog } from "./event-log.js";
|
||||
@@ -33,6 +34,19 @@ async function enter(identity: string, enteredAt: string, payload?: Record<strin
|
||||
function exitsSigned(identity: string) {
|
||||
return db.select().from(ledgerEvents).where(eq(ledgerEvents.identity, identity)).all().filter((r) => r.type === "vehicle_exit");
|
||||
}
|
||||
function anomalies(reason?: string) {
|
||||
return db.select().from(ledgerEvents).where(eq(ledgerEvents.type, "anomaly")).all()
|
||||
.filter((r) => !reason || (r.payload as { reason?: string } | null)?.reason?.includes(reason));
|
||||
}
|
||||
/** Seed the projection-cache open-session row + an ANPR plate read (device_events) so the
|
||||
* plate-reconciliation check can see this identity's plate against open sessions. */
|
||||
function seedOpenWithPlate(identity: string, plate: string, confidence: number, enteredAt: string) {
|
||||
db.insert(sessionsTable).values({ id: identity, identity, source: "ticket", enteredAt, state: "open" }).run();
|
||||
db.insert(deviceEventsTable).values({
|
||||
id: randomUUID(), deviceId: "cam-entry", category: "camera", kind: "read", occurredAt: enteredAt,
|
||||
detail: { identity, direction: "entry", plate, confidence },
|
||||
}).run();
|
||||
}
|
||||
|
||||
describe("exitForBooth — refusal gates", () => {
|
||||
it("refuses an unknown ticket (no session) and signs an anomaly", async () => {
|
||||
@@ -116,3 +130,63 @@ describe("reopenBarrier — no unpaid re-open", () => {
|
||||
expect(exitsSigned("T1")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("exitForBooth — plate-swap reconciliation (ticket-swap fraud)", () => {
|
||||
// The fraud: a paid car is let out on a fresh $0 ticket while the original lingers "inside".
|
||||
// The plate is the invariant — the exiting car's plate is already open under the old ticket.
|
||||
it("HOLDS a paid exit when the plate is already open under a DIFFERENT ticket", async () => {
|
||||
seedTariff(db, { pricePerIncrementMinor: 10000, gracePeriodExitMin: 15 });
|
||||
// Original car entered on 1234, plate AA123BB, still open (never paid/exited).
|
||||
await enter("1234", minutesAgo(120));
|
||||
seedOpenWithPlate("1234", "AA123BB", 0.99, minutesAgo(120));
|
||||
// A fresh ticket 1237 (same physical car, same plate) is paid and tries to exit.
|
||||
await enter("1237", minutesAgo(1));
|
||||
seedOpenWithPlate("1237", "AA123BB", 0.99, minutesAgo(1));
|
||||
await pay.pay("1237", "cash");
|
||||
|
||||
const r = await exit.exitForBooth("1237");
|
||||
expect(r).toMatchObject({ ok: false, status: "swap_suspected", plate: "AA123BB", otherIdentity: "1234" });
|
||||
expect(exitsSigned("1237")).toHaveLength(0); // NOT let out
|
||||
expect(anomalies("plate AA123BB is already inside").length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("RELEASES on explicit operator override + signs an attributed override anomaly", async () => {
|
||||
seedTariff(db, { pricePerIncrementMinor: 10000, gracePeriodExitMin: 15 });
|
||||
await enter("1234", minutesAgo(120));
|
||||
seedOpenWithPlate("1234", "AA123BB", 0.99, minutesAgo(120));
|
||||
await enter("1237", minutesAgo(1));
|
||||
seedOpenWithPlate("1237", "AA123BB", 0.99, minutesAgo(1));
|
||||
await pay.pay("1237", "cash");
|
||||
|
||||
const r = await exit.exitForBooth("1237", { override: true, operator: "op1" });
|
||||
expect(r.ok).toBe(true);
|
||||
expect(exitsSigned("1237")).toHaveLength(1); // released
|
||||
const ov = anomalies("released a suspected ticket-swap");
|
||||
expect(ov.length).toBe(1);
|
||||
expect((ov[0].payload as { operator?: string }).operator).toBe("op1");
|
||||
});
|
||||
|
||||
it("does NOT warn on a LOW-confidence plate read (advisory, never a gate)", async () => {
|
||||
seedTariff(db, { pricePerIncrementMinor: 10000, gracePeriodExitMin: 15 });
|
||||
await enter("1234", minutesAgo(120));
|
||||
seedOpenWithPlate("1234", "AA123BB", 0.5, minutesAgo(120)); // low conf
|
||||
await enter("1237", minutesAgo(1));
|
||||
seedOpenWithPlate("1237", "AA123BB", 0.5, minutesAgo(1)); // low conf
|
||||
await pay.pay("1237", "cash");
|
||||
|
||||
const r = await exit.exitForBooth("1237");
|
||||
expect(r.ok).toBe(true); // no warning — exits normally
|
||||
expect(exitsSigned("1237")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("does NOT warn a normal exit whose OWN plate is only open under its OWN ticket", async () => {
|
||||
seedTariff(db, { pricePerIncrementMinor: 10000, gracePeriodExitMin: 15 });
|
||||
await enter("1237", minutesAgo(90));
|
||||
seedOpenWithPlate("1237", "AA999ZZ", 0.99, minutesAgo(90));
|
||||
await pay.pay("1237", "cash");
|
||||
|
||||
const r = await exit.exitForBooth("1237");
|
||||
expect(r.ok).toBe(true); // its own plate under its own ticket is not a swap
|
||||
expect(exitsSigned("1237")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { desc, eq, ledgerEvents, sessions, tariffVersions, tariffs, type Db, type DeviceRow } from "@parking/db";
|
||||
import { registry, type AccessControlDevice } from "@parking/devices";
|
||||
import { firstRelayByDirection, type ResolvedRelay } from "./device-resolve.js";
|
||||
import { plateForIdentity, platesForIdentities } from "./plate-lookup.js";
|
||||
import { snapshotAsync } from "./snapshot.js";
|
||||
import type { VisionClient } from "./vision-client.js";
|
||||
import { computeFee, reasonPayload, renderReasonEn, type LedgerPayload, type TariffStructure } from "@parking/shared";
|
||||
@@ -47,6 +48,11 @@ interface SessionView {
|
||||
* the barrier didn't open (payment stands; operator opens manually). */
|
||||
export type BoothExitResult =
|
||||
| { ok: false; status: "invalid" | "no_session" | "closed" | "unpaid" | "grace_expired"; reason: string }
|
||||
// PLATE-SWAP suspected: the exiting car's plate is already OPEN under a DIFFERENT ticket
|
||||
// (possible ticket-swap fraud / mixed-up tickets). Not opened — the operator must review
|
||||
// and either resolve the tickets or consciously OVERRIDE (re-submit with override:true).
|
||||
// See wiki/concepts/plate-reconciliation.md.
|
||||
| { ok: false; status: "swap_suspected"; reason: string; plate: string; otherIdentity: string; otherEnteredAt: string | null }
|
||||
| { ok: true; opened: true }
|
||||
| { ok: true; opened: false; reason: string };
|
||||
|
||||
@@ -57,6 +63,11 @@ export type BoothReopenResult =
|
||||
| { ok: false; reason: string }
|
||||
| { ok: true; opened: boolean; reason?: string };
|
||||
|
||||
/** Minimum ANPR confidence for a plate to participate in swap reconciliation, both for the
|
||||
* exiting read and the matched open session's entry read. Below this, the read is advisory-
|
||||
* only and never triggers a swap warning (a fuzzy read must not block a legit car). */
|
||||
const PLATE_MATCH_MIN_CONFIDENCE = 0.85;
|
||||
|
||||
export class ExitFlow {
|
||||
readonly #db: Db;
|
||||
readonly #log: EventLog;
|
||||
@@ -88,7 +99,7 @@ export class ExitFlow {
|
||||
* (money was taken, the car is owed an exit) and an `anomaly` is appended so the
|
||||
* operator opens manually. Payment is never rolled back.
|
||||
*/
|
||||
async exitForBooth(identity: string): Promise<BoothExitResult> {
|
||||
async exitForBooth(identity: string, opts?: { override?: boolean; operator?: string }): Promise<BoothExitResult> {
|
||||
const id = identity.trim();
|
||||
if (!id) return { ok: false, status: "invalid", reason: "ticket id required" };
|
||||
|
||||
@@ -122,6 +133,40 @@ export class ExitFlow {
|
||||
return { ok: false, status: paid ? "grace_expired" : "unpaid", reason: rp.reason };
|
||||
}
|
||||
|
||||
// PLATE-SWAP CHECK — after the money/grace validation, before we sign the exit. If
|
||||
// the plate is already open under a DIFFERENT ticket, HOLD for the operator to review
|
||||
// (unless they consciously override). A denial here never traps the car — exit fails
|
||||
// open and the operator can override; the anomaly is the control either way.
|
||||
const swap = this.#reconcilePlateAtExit(id);
|
||||
if (swap) {
|
||||
if (!opts?.override) {
|
||||
// Sign the SUSPICION even if the operator walks away (tamper-evident record).
|
||||
const rp = reasonPayload("exit.plateSwapSuspected", { plate: swap.plate, otherIdentity: swap.otherIdentity });
|
||||
await this.#log.append({
|
||||
type: "anomaly",
|
||||
identity: id,
|
||||
payload: { ...rp, source: "booth", plateSwapSuspected: true, plate: swap.plate, otherIdentity: swap.otherIdentity },
|
||||
});
|
||||
this.#fireExitSnapshot(id);
|
||||
this.#logger.warn(`booth exit HELD (${id}): plate ${swap.plate} already open under ${swap.otherIdentity}`);
|
||||
return { ok: false, status: "swap_suspected", reason: rp.reason, plate: swap.plate, otherIdentity: swap.otherIdentity, otherEnteredAt: swap.otherEnteredAt };
|
||||
}
|
||||
// OVERRIDE: the operator consciously releases it. Sign the override (attributed).
|
||||
await this.#log.append({
|
||||
type: "anomaly",
|
||||
identity: id,
|
||||
payload: {
|
||||
...reasonPayload("exit.plateSwapOverride", { operator: opts.operator ?? "?", plate: swap.plate, otherIdentity: swap.otherIdentity }),
|
||||
source: "booth",
|
||||
plateSwapOverride: true,
|
||||
plate: swap.plate,
|
||||
otherIdentity: swap.otherIdentity,
|
||||
...(opts.operator ? { operator: opts.operator } : {}),
|
||||
},
|
||||
});
|
||||
this.#logger.warn(`booth exit OVERRIDE (${id}) by ${opts.operator ?? "?"}: plate-swap released (${swap.plate}, also open under ${swap.otherIdentity})`);
|
||||
}
|
||||
|
||||
// Free entry-grace path: mint the $0 payment first (ledger invariant), as the
|
||||
// reader path does.
|
||||
if (freeGrace && view.freeGrace) {
|
||||
@@ -336,6 +381,25 @@ export class ExitFlow {
|
||||
return { accepted: false, direction: "exit", reason: rp.reason };
|
||||
}
|
||||
|
||||
// PLATE-SWAP (reader path): detect + LOG, but FAIL OPEN. There's no operator at an
|
||||
// automated lane to make the override decision, and exit fails open for safety, so we
|
||||
// sign the suspicion anomaly (the control here) and still let the car out. The booth
|
||||
// path (operator-mediated) is where the hold + override lives.
|
||||
const swap = this.#reconcilePlateAtExit(e.value);
|
||||
if (swap) {
|
||||
await this.#log.append({
|
||||
type: "anomaly",
|
||||
identity: e.value,
|
||||
payload: {
|
||||
...reasonPayload("exit.plateSwapSuspected", { plate: swap.plate, otherIdentity: swap.otherIdentity }),
|
||||
plateSwapSuspected: true,
|
||||
plate: swap.plate,
|
||||
otherIdentity: swap.otherIdentity,
|
||||
},
|
||||
});
|
||||
this.#logger.warn(`reader exit: plate ${swap.plate} already open under ${swap.otherIdentity} (${e.value}) — logged, fail-open`);
|
||||
}
|
||||
|
||||
// Valid (a real payment within walk-back grace): sign + open.
|
||||
return this.#signExitAndOpen(resolved, e);
|
||||
}
|
||||
@@ -406,6 +470,43 @@ export class ExitFlow {
|
||||
this.#logger.error(`booth exit open failed (${identity}): ${detail}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* PLATE-SWAP reconciliation. The car's PLATE is the invariant a ticket-swap can't hide:
|
||||
* if this exiting ticket's plate is already OPEN under a DIFFERENT ticket, someone let a
|
||||
* paid car out on a fresh $0 ticket while the original lingers "inside" (occupancy fraud),
|
||||
* or two tickets were mixed up. We compare the EXITING plate against every open session's
|
||||
* ENTRY plate, EXACT normalized match, HIGH-CONFIDENCE reads only (a fuzzy/absent read is
|
||||
* advisory — never a gate, so it can't trap a legit car). Returns the matched open session
|
||||
* or null. See wiki/concepts/plate-reconciliation.md.
|
||||
*/
|
||||
#reconcilePlateAtExit(exitingId: string): { plate: string; otherIdentity: string; otherEnteredAt: string | null } | null {
|
||||
// The exiting car's plate: prefer its own exit read, else its entry read.
|
||||
const mine = plateForIdentity(this.#db, exitingId);
|
||||
if (!mine || !mine.plate || (mine.confidence ?? 0) < PLATE_MATCH_MIN_CONFIDENCE) return null;
|
||||
const wanted = mine.plate.trim().toUpperCase();
|
||||
|
||||
// All currently-open sessions (from the projection cache — a fast read-model; the check
|
||||
// is advisory so a slightly-stale cache is acceptable), excluding this ticket.
|
||||
const openIds = this.#db
|
||||
.select({ id: sessions.id })
|
||||
.from(sessions)
|
||||
.where(eq(sessions.state, "open"))
|
||||
.all()
|
||||
.map((r) => r.id)
|
||||
.filter((id) => id !== exitingId);
|
||||
if (openIds.length === 0) return null;
|
||||
|
||||
const plates = platesForIdentities(this.#db, openIds);
|
||||
for (const [otherId, pv] of plates) {
|
||||
if ((pv.confidence ?? 0) < PLATE_MATCH_MIN_CONFIDENCE) continue;
|
||||
if (pv.plate.trim().toUpperCase() !== wanted) continue;
|
||||
// A high-confidence exact match under a DIFFERENT open ticket → swap suspected.
|
||||
const enteredAt = this.#db.select({ enteredAt: sessions.enteredAt }).from(sessions).where(eq(sessions.id, otherId)).get()?.enteredAt ?? null;
|
||||
return { plate: wanted, otherIdentity: otherId, otherEnteredAt: enteredAt };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Fold the signed ledger into a session view for one identity (authoritative). */
|
||||
#sessionFor(identity: string): SessionView | null {
|
||||
const rows = this.#db
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { appLogs, type Db } from "@parking/db";
|
||||
import { createTestDb } from "@parking/db/testing";
|
||||
import { LogService, pinoDbStream } from "./log-service.js";
|
||||
|
||||
// pinoDbStream feeds backend warn+ lines into app_logs. Since 2026-07-04 the logger
|
||||
// emits level NAMES ("warn") instead of pino's numeric codes (40) — for human-readable
|
||||
// container logs — and the stream must accept BOTH encodings (numeric covers any
|
||||
// default-configured pino). A level the tee can't resolve falls back to info → not
|
||||
// persisted, never a crash.
|
||||
|
||||
let db: Db;
|
||||
let stream: { write: (line: string) => void };
|
||||
let teed: string[];
|
||||
|
||||
beforeEach(() => {
|
||||
({ db } = createTestDb());
|
||||
teed = [];
|
||||
stream = pinoDbStream(new LogService(db), {
|
||||
write: (line: string) => {
|
||||
teed.push(line);
|
||||
return true;
|
||||
},
|
||||
} as unknown as NodeJS.WritableStream);
|
||||
});
|
||||
|
||||
const rows = () => db.select().from(appLogs).all();
|
||||
|
||||
describe("pinoDbStream level encodings", () => {
|
||||
it("persists a LABEL-level warn line (the current logger format)", () => {
|
||||
stream.write(`{"level":"warn","time":"2026-07-04T18:14:11.453Z","msg":"label warn"}\n`);
|
||||
expect(rows()).toHaveLength(1);
|
||||
expect(rows()[0]).toMatchObject({ level: "warn", source: "backend", message: "label warn" });
|
||||
});
|
||||
|
||||
it("still persists a NUMERIC-level error line (legacy/default pino)", () => {
|
||||
stream.write(`{"level":50,"time":1783179038453,"msg":"numeric error"}\n`);
|
||||
expect(rows()[0]).toMatchObject({ level: "error", message: "numeric error" });
|
||||
});
|
||||
|
||||
it("info stays stdout-only in both encodings (teed, not persisted)", () => {
|
||||
stream.write(`{"level":"info","msg":"label info"}\n`);
|
||||
stream.write(`{"level":30,"msg":"numeric info"}\n`);
|
||||
expect(rows()).toHaveLength(0);
|
||||
expect(teed).toHaveLength(2); // stdout tee always happens
|
||||
});
|
||||
|
||||
it("an unresolvable level falls back to info (dropped), never throws", () => {
|
||||
stream.write(`{"level":"loud","msg":"weird"}\n`);
|
||||
stream.write(`not json at all\n`);
|
||||
expect(rows()).toHaveLength(0);
|
||||
expect(teed).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
// Storm coalescing: a line identical to the LAST persisted row (level+source+message+
|
||||
// path), arriving within 5 min of its previous occurrence, UPDATES that row (bumping
|
||||
// context._repeat) instead of inserting — one screaming device can't evict unrelated
|
||||
// history. The row's createdAt tracks the LATEST occurrence; the first is preserved in
|
||||
// context._firstAt.
|
||||
describe("storm coalescing", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("folds a burst of identical error lines into ONE row with a repeat counter", () => {
|
||||
for (let i = 0; i < 200; i++) {
|
||||
stream.write(`{"level":"error","msg":"button-light setAux failed (ctl R3): send ENETUNREACH"}\n`);
|
||||
}
|
||||
const all = rows();
|
||||
expect(all).toHaveLength(1);
|
||||
expect(all[0].context).toMatchObject({ _repeat: 200 });
|
||||
expect(teed).toHaveLength(200); // stdout still gets every line
|
||||
});
|
||||
|
||||
it("keeps first-occurrence time in _firstAt while createdAt tracks the latest", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-07-08T10:00:00.000Z"));
|
||||
stream.write(`{"level":"warn","msg":"same"}\n`);
|
||||
vi.setSystemTime(new Date("2026-07-08T10:02:00.000Z"));
|
||||
stream.write(`{"level":"warn","msg":"same"}\n`);
|
||||
const [row] = rows();
|
||||
expect(row.createdAt).toBe("2026-07-08T10:02:00.000Z");
|
||||
expect(row.context).toMatchObject({ _repeat: 2, _firstAt: "2026-07-08T10:00:00.000Z" });
|
||||
});
|
||||
|
||||
it("a different message (or level) breaks the run — separate rows", () => {
|
||||
stream.write(`{"level":"error","msg":"boom A"}\n`);
|
||||
stream.write(`{"level":"error","msg":"boom A"}\n`);
|
||||
stream.write(`{"level":"error","msg":"boom B"}\n`);
|
||||
stream.write(`{"level":"warn","msg":"boom B"}\n`);
|
||||
expect(rows()).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("an occurrence past the 5-minute window starts a fresh row", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-07-08T10:00:00.000Z"));
|
||||
stream.write(`{"level":"error","msg":"slow leak"}\n`);
|
||||
vi.setSystemTime(new Date("2026-07-08T10:06:00.000Z"));
|
||||
stream.write(`{"level":"error","msg":"slow leak"}\n`);
|
||||
expect(rows()).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("a CONTINUOUS storm stays one row past the window (each hit refreshes it)", () => {
|
||||
vi.useFakeTimers();
|
||||
let t = new Date("2026-07-08T10:00:00.000Z").getTime();
|
||||
for (let i = 0; i < 10; i++) {
|
||||
vi.setSystemTime(new Date(t));
|
||||
stream.write(`{"level":"error","msg":"storm"}\n`);
|
||||
t += 240_000; // 4 min apart — each inside the window of the PREVIOUS hit
|
||||
}
|
||||
const all = rows();
|
||||
expect(all).toHaveLength(1);
|
||||
expect(all[0].context).toMatchObject({ _repeat: 10 });
|
||||
});
|
||||
});
|
||||
@@ -25,6 +25,14 @@ const MAX_MESSAGE = 4_000;
|
||||
const MAX_STACK = 16_000;
|
||||
const MAX_CONTEXT_JSON = 16_000;
|
||||
|
||||
/** Storm coalescing: a line identical to the LAST persisted one (level+source+message+
|
||||
* path) within this window of its previous occurrence UPDATES that row (bumping a
|
||||
* `_repeat` counter in its context) instead of inserting a new one. A continuous storm
|
||||
* keeps refreshing the window, so it stays ONE row however long it rages — repeated
|
||||
* errors can't evict unrelated history or grind the appliance disk (field incident
|
||||
* 2026-07-07: one unreachable controller ≈ hundreds of identical rows/minute). */
|
||||
const COALESCE_WINDOW_MS = 300_000;
|
||||
|
||||
export interface LogRetention {
|
||||
/** Delete logs older than this many days. */
|
||||
readonly maxAgeDays: number;
|
||||
@@ -33,7 +41,10 @@ export interface LogRetention {
|
||||
}
|
||||
|
||||
export const DEFAULT_RETENTION: LogRetention = {
|
||||
maxAgeDays: Number(process.env.LOG_RETENTION_DAYS ?? 30),
|
||||
// 60 days (~2 months) — the operator's chosen diagnostic window (2026-07-04),
|
||||
// matched by the container-log rotation caps in docker-compose.prod.yml. The row
|
||||
// cap below still bounds a burst regardless of age.
|
||||
maxAgeDays: Number(process.env.LOG_RETENTION_DAYS ?? 60),
|
||||
maxRows: Number(process.env.LOG_RETENTION_MAX_ROWS ?? 50_000),
|
||||
};
|
||||
|
||||
@@ -59,6 +70,16 @@ export class LogService {
|
||||
readonly #retention: LogRetention;
|
||||
/** Reentrancy guard: never let persisting a log itself emit a persisted log. */
|
||||
#writing = false;
|
||||
/** The last persisted row, for storm coalescing (in-memory only; a restart just
|
||||
* starts a fresh row — best-effort, like everything in this sink). */
|
||||
#last: {
|
||||
id: string;
|
||||
key: string;
|
||||
count: number;
|
||||
firstAt: string;
|
||||
lastAtMs: number;
|
||||
baseContext: Record<string, unknown> | null;
|
||||
} | null = null;
|
||||
|
||||
constructor(db: Db, retention: LogRetention = DEFAULT_RETENTION) {
|
||||
this.#db = db;
|
||||
@@ -82,22 +103,53 @@ export class LogService {
|
||||
if (this.#writing) return;
|
||||
this.#writing = true;
|
||||
try {
|
||||
const createdAt = row.createdAt ?? new Date().toISOString();
|
||||
const message = clamp(row.message, MAX_MESSAGE) ?? "";
|
||||
const path = clamp(row.path, 512);
|
||||
const key = `${row.level}|${row.source}|${message}|${path ?? ""}`;
|
||||
const nowMs = Date.now();
|
||||
|
||||
// Storm coalescing: identical to the last persisted row, within the window →
|
||||
// bump that row instead of inserting. createdAt moves to the LATEST occurrence
|
||||
// (keeps the storm visible at the top of the newest-first viewer); the first
|
||||
// occurrence's time is preserved in context._firstAt.
|
||||
const last = this.#last;
|
||||
if (last && last.key === key && nowMs - last.lastAtMs <= COALESCE_WINDOW_MS) {
|
||||
const res = this.#db
|
||||
.update(appLogs)
|
||||
.set({
|
||||
context: { ...(last.baseContext ?? {}), _repeat: last.count + 1, _firstAt: last.firstAt },
|
||||
createdAt,
|
||||
})
|
||||
.where(eq(appLogs.id, last.id))
|
||||
.run();
|
||||
if ((res.changes ?? 0) > 0) {
|
||||
last.count += 1;
|
||||
last.lastAtMs = nowMs;
|
||||
return;
|
||||
}
|
||||
// The row was pruned out from under us — fall through to a fresh insert.
|
||||
}
|
||||
|
||||
const id = randomUUID();
|
||||
const baseContext = safeContext(row.context);
|
||||
this.#db
|
||||
.insert(appLogs)
|
||||
.values({
|
||||
id: randomUUID(),
|
||||
id,
|
||||
level: row.level,
|
||||
source: row.source,
|
||||
message: clamp(row.message, MAX_MESSAGE) ?? "",
|
||||
context: safeContext(row.context),
|
||||
message,
|
||||
context: baseContext,
|
||||
httpStatus: row.httpStatus ?? null,
|
||||
path: clamp(row.path, 512),
|
||||
path,
|
||||
stack: clamp(row.stack, MAX_STACK),
|
||||
userId: row.userId ?? null,
|
||||
userAgent: clamp(row.userAgent, 512),
|
||||
createdAt: row.createdAt ?? new Date().toISOString(),
|
||||
createdAt,
|
||||
})
|
||||
.run();
|
||||
this.#last = { id, key, count: 1, firstAt: createdAt, lastAtMs: nowMs, baseContext };
|
||||
} catch {
|
||||
// Swallow — diagnostics must never take down the path they observe. (Can't log
|
||||
// it; that's the recursion we're guarding against.)
|
||||
@@ -192,9 +244,10 @@ export class LogService {
|
||||
|
||||
/**
|
||||
* A pino-compatible write stream that forwards BACKEND warn+ lines into the LogService.
|
||||
* Pino writes one JSON object per line to this stream; we parse, map the numeric level
|
||||
* to a name, and persist. Returned as `{ write }` so it can be passed as pino's stream.
|
||||
* stdout still receives the same line (we tee), so console logging is unchanged.
|
||||
* Pino writes one JSON object per line to this stream; we parse, resolve the level
|
||||
* (name or numeric encoding), and persist. Returned as `{ write }` so it can be passed
|
||||
* as pino's stream. stdout still receives the same line (we tee), so console logging is
|
||||
* unchanged.
|
||||
*/
|
||||
export function pinoDbStream(
|
||||
service: LogService,
|
||||
@@ -218,12 +271,17 @@ export function pinoDbStream(
|
||||
}
|
||||
try {
|
||||
const obj = JSON.parse(line) as {
|
||||
level?: number;
|
||||
level?: number | string;
|
||||
msg?: string;
|
||||
err?: { stack?: string; message?: string };
|
||||
[k: string]: unknown;
|
||||
};
|
||||
const level = NUM_TO_LEVEL[obj.level ?? 30] ?? "info";
|
||||
// The logger emits level NAMES (formatters.level in server.ts, for human-
|
||||
// readable container logs); a default pino config emits numbers. Accept both.
|
||||
const level: LogLevel =
|
||||
typeof obj.level === "string" && obj.level in LOG_LEVEL_ORDER
|
||||
? (obj.level as LogLevel)
|
||||
: NUM_TO_LEVEL[typeof obj.level === "number" ? obj.level : 30] ?? "info";
|
||||
if (LOG_LEVEL_ORDER[level] < LOG_LEVEL_ORDER[BACKEND_PERSIST_MIN]) return;
|
||||
// Strip pino's noisy standard fields from the persisted context.
|
||||
const { level: _l, time: _t, pid: _p, hostname: _h, msg, ...rest } = obj;
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { desc, eq, ledgerEvents, sessions, subscriptions, tariffVersions, tariffs, type Db } from "@parking/db";
|
||||
import { priceSession, type TariffStructure, type Tender } from "@parking/shared";
|
||||
import { priceSession, type TariffStructure, type Tender, type ValidationLine } from "@parking/shared";
|
||||
import type { FastifyBaseLogger } from "fastify";
|
||||
import type { EventLog } from "./event-log.js";
|
||||
import { plateForIdentity, platesForIdentities } from "./plate-lookup.js";
|
||||
import { windowOwedBetween } from "./subscription-window.js";
|
||||
import { liveValidations } from "./validations.js";
|
||||
|
||||
// The PAY STATION: a customer pays for an open session BEFORE walking back to the
|
||||
// car (pay-on-foot — payment is decoupled from exit). Two steps:
|
||||
@@ -38,8 +39,17 @@ export interface Quote {
|
||||
* is priced as a fresh stay from there → now, with its own daily-cap ladder, NOT
|
||||
* "full stay minus paid" (which a daily cap collapses toward zero). */
|
||||
readonly periodStart: string;
|
||||
/** Amount owed now: the fee for [periodStart → now]. */
|
||||
/** Amount owed now: the fee for [periodStart → now], NET of merchant validations. */
|
||||
readonly amountMinor: number;
|
||||
/** The pre-validation fee (= amountMinor when no validations apply). */
|
||||
readonly grossMinor: number;
|
||||
/** Total the merchant validations took off (gross − net). */
|
||||
readonly discountMinor: number;
|
||||
/** Per-validation receipt/display lines (empty when none apply). */
|
||||
readonly validationLines: ValidationLine[];
|
||||
/** The validation event ids this quote applied — the payment stamps them as
|
||||
* CONSUMED so an overstay's fresh period never re-applies them. */
|
||||
readonly validationIds: string[];
|
||||
/** True when this quote prices an overstay period (grace lapsed), not the first stay. */
|
||||
readonly overstay: boolean;
|
||||
readonly currency: string;
|
||||
@@ -98,6 +108,11 @@ export interface SessionLookup {
|
||||
/** Amount owed right now (the quote). Null when no session / no active tariff. */
|
||||
readonly amountMinor: number | null;
|
||||
readonly currency: string | null;
|
||||
/** Amount actually PAID (from the latest payment event), if any. Distinct from
|
||||
* `amountMinor` (what's owed now): once a transient is settled `amountMinor` is null,
|
||||
* but the operator still wants to see the sum that was collected. */
|
||||
readonly paidMinor: number | null;
|
||||
readonly paidCurrency: string | null;
|
||||
/** True when paid AND still within the walk-back grace window. */
|
||||
readonly withinGrace: boolean;
|
||||
/** ISO time the walk-back grace expires (paidAt + graceExitMin), if paid. */
|
||||
@@ -112,6 +127,12 @@ export interface SessionLookup {
|
||||
/** Advisory licence plate recognized for this session (ANPR-on-snapshot). Null when
|
||||
* none. Display/audit only — never an access decision. */
|
||||
readonly plate: string | null;
|
||||
/** Merchant validations folded into `amountMinor` (which is NET): the pre-discount
|
||||
* fee, the total taken off, and the per-validation lines for the modal/receipt.
|
||||
* grossMinor/discountMinor are null when no quote resolved. */
|
||||
readonly grossMinor: number | null;
|
||||
readonly discountMinor: number | null;
|
||||
readonly validationLines: ValidationLine[];
|
||||
}
|
||||
|
||||
export class PayStation {
|
||||
@@ -150,19 +171,28 @@ export class PayStation {
|
||||
// Pure pricing shared with the Tariff Lab (priceSession). Only the latest payment
|
||||
// matters for grace/overstay; pass it through. Overstay → fresh period from
|
||||
// grace-expiry; within-grace → settled; unpaid → entry→now running total.
|
||||
// Merchant validations: fold the LIVE ones (applied, unvoided, not consumed by a
|
||||
// prior payment) so the quote is NET — the payment then stamps their ids as
|
||||
// consumed. See wiki/concepts/validation-discounts.md.
|
||||
const last = this.#lastPayment(identity);
|
||||
const validations = liveValidations(this.#db, identity);
|
||||
const p = priceSession(
|
||||
entry.occurredAt,
|
||||
new Date().toISOString(),
|
||||
structure,
|
||||
last ? [last] : [],
|
||||
category,
|
||||
validations,
|
||||
);
|
||||
return {
|
||||
identity,
|
||||
enteredAt: entry.occurredAt,
|
||||
periodStart: p.periodStart,
|
||||
amountMinor: p.amountMinor,
|
||||
grossMinor: p.grossMinor,
|
||||
discountMinor: p.discountMinor,
|
||||
validationLines: p.validationLines,
|
||||
validationIds: validations.map((v) => v.eventId),
|
||||
overstay: p.overstay,
|
||||
currency: tv.currency,
|
||||
tariffVersionId: tv.id,
|
||||
@@ -241,6 +271,18 @@ export class PayStation {
|
||||
// The exit flow reads graceExitMin off the payment to validate the
|
||||
// walk-back window without re-resolving the tariff.
|
||||
graceExitMin: q.graceExitMin,
|
||||
// Merchant validations: record the gross/discount split + CONSUME the applied
|
||||
// validation ids, so reporting sees the leakage and a later overstay period
|
||||
// never re-applies them. A zero-net settlement (full comp) is still a signed
|
||||
// payment — grace/voucher/exit work unchanged. See validation-discounts.md.
|
||||
...(q.validationIds.length
|
||||
? {
|
||||
grossMinor: q.grossMinor,
|
||||
discountMinor: q.discountMinor,
|
||||
validationIds: q.validationIds,
|
||||
validationLines: q.validationLines.map((l) => ({ ...l })),
|
||||
}
|
||||
: {}),
|
||||
...(overrideMinor != null ? { reason: "operator-set amount", quotedMinor: q.amountMinor } : {}),
|
||||
},
|
||||
});
|
||||
@@ -274,8 +316,10 @@ export class PayStation {
|
||||
if (!entry) {
|
||||
return {
|
||||
identity: id, found: false, open: false, enteredAt: null, exitedAt: null,
|
||||
paidAt: null, amountMinor: null, currency: null, withinGrace: false, graceExpiresAt: null,
|
||||
paidAt: null, amountMinor: null, currency: null, paidMinor: null, paidCurrency: null,
|
||||
withinGrace: false, graceExpiresAt: null,
|
||||
overstay: false, subscription: false, subscriptionId: null, subscriptionHolder: null, plate: null,
|
||||
grossMinor: null, discountMinor: null, validationLines: [],
|
||||
};
|
||||
}
|
||||
// Subscription occurrence? The entry payload carries permit:true + permitId.
|
||||
@@ -289,11 +333,17 @@ export class PayStation {
|
||||
|
||||
let paidAt: string | null = null;
|
||||
let graceExitMin: number | null = null;
|
||||
let paidMinor: number | null = null;
|
||||
let paidCurrency: string | null = null;
|
||||
for (const r of rows) {
|
||||
if (r.type === "payment") {
|
||||
paidAt = r.occurredAt;
|
||||
const p = (r.payload ?? {}) as { graceExitMin?: number };
|
||||
const p = (r.payload ?? {}) as { graceExitMin?: number; amountMinor?: number; currency?: string };
|
||||
if (typeof p.graceExitMin === "number") graceExitMin = p.graceExitMin;
|
||||
// Sum payments (overstay top-ups append a second one) so the displayed paid total
|
||||
// reflects everything collected for the session, not just the last slip.
|
||||
if (typeof p.amountMinor === "number") paidMinor = (paidMinor ?? 0) + p.amountMinor;
|
||||
if (typeof p.currency === "string") paidCurrency = p.currency;
|
||||
}
|
||||
}
|
||||
const graceExpiresAt =
|
||||
@@ -307,11 +357,17 @@ export class PayStation {
|
||||
// exit gate clears. See wiki/entities/subscription.md.
|
||||
let amountMinor: number | null = null;
|
||||
let currency: string | null = null;
|
||||
let grossMinor: number | null = null;
|
||||
let discountMinor: number | null = null;
|
||||
let validationLines: ValidationLine[] = [];
|
||||
if (open && !isSubscription) {
|
||||
try {
|
||||
const q = this.quote(id);
|
||||
amountMinor = q.amountMinor;
|
||||
currency = q.currency;
|
||||
grossMinor = q.grossMinor;
|
||||
discountMinor = q.discountMinor;
|
||||
validationLines = q.validationLines;
|
||||
} catch {
|
||||
/* no active tariff — leave null; modal shows session without a price */
|
||||
}
|
||||
@@ -328,10 +384,11 @@ export class PayStation {
|
||||
return {
|
||||
identity: id, found: true, open,
|
||||
enteredAt: entry.occurredAt, exitedAt: exitRow?.occurredAt ?? null,
|
||||
paidAt, amountMinor, currency, withinGrace, graceExpiresAt, overstay,
|
||||
paidAt, amountMinor, currency, paidMinor, paidCurrency, withinGrace, graceExpiresAt, overstay,
|
||||
subscription: isSubscription, subscriptionId,
|
||||
subscriptionHolder: this.#holderOf(subscriptionId),
|
||||
plate: plateForIdentity(this.#db, id)?.plate ?? null,
|
||||
grossMinor, discountMinor, validationLines,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { devices, deviceEvents as deviceEventsTable, ledgerEvents, subscriptionCredentials, type Db } from "@parking/db";
|
||||
import { createTestDb } from "@parking/db/testing";
|
||||
import { ReadDispatcher } from "./read-dispatch.js";
|
||||
import { ExitFlow } from "./exit-flow.js";
|
||||
import { SubscriptionFlow } from "./subscription-flow.js";
|
||||
import type { DeviceReadEvent } from "./device-events.js";
|
||||
import { makeLog, silentLogger } from "./test-helpers.js";
|
||||
|
||||
// STRUCTURAL FILTER at the dispatcher (2026-07-04): a reader value that matched
|
||||
// nothing AND can't possibly be a credential we issued (no ticket Luhn shape, no
|
||||
// SUB-/SUBSESS- prefix, not a confirmed-RF read) is refused with UNSIGNED telemetry
|
||||
// instead of reaching the exit flow and signing a noSession anomaly. Born from the
|
||||
// park-buzi phantom optical decodes: red "who is exiting?" rows for NOBODY train the
|
||||
// operator to ignore the signed feed. Anything plausibly ours STILL signs normally.
|
||||
|
||||
let db: Db;
|
||||
let dispatcher: ReadDispatcher;
|
||||
|
||||
const READER = "reader-exit";
|
||||
|
||||
beforeEach(() => {
|
||||
({ db } = createTestDb());
|
||||
db.insert(devices).values({
|
||||
id: "ctl-exit",
|
||||
category: "access",
|
||||
driverId: "stub-access",
|
||||
config: { relays: [{ relay: 1, direction: "exit" }] },
|
||||
enabled: true,
|
||||
}).run();
|
||||
db.insert(devices).values({
|
||||
id: READER,
|
||||
category: "reader",
|
||||
driverId: "dingtian-qr-reader",
|
||||
config: { serial: "H05MA5B0", direction: "exit" },
|
||||
enabled: true,
|
||||
}).run();
|
||||
const log = makeLog(db);
|
||||
dispatcher = new ReadDispatcher(db, new ExitFlow(db, log, silentLogger()), new SubscriptionFlow(db, log, silentLogger()), silentLogger());
|
||||
});
|
||||
|
||||
function read(value: string, opts: { kind?: DeviceReadEvent["kind"]; channel?: DeviceReadEvent["channel"] } = {}): DeviceReadEvent {
|
||||
return {
|
||||
driverId: "dingtian-qr-reader",
|
||||
deviceId: READER,
|
||||
value,
|
||||
kind: opts.kind ?? "qr",
|
||||
...(opts.channel ? { channel: opts.channel } : {}),
|
||||
at: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
const ledger = () => db.select().from(ledgerEvents).all();
|
||||
const unrecognized = () =>
|
||||
db.select().from(deviceEventsTable).all()
|
||||
.map((r) => r.detail as { unrecognizedRead?: boolean; value?: string })
|
||||
.filter((d) => d.unrecognizedRead === true);
|
||||
|
||||
describe("read-dispatch structural filter", () => {
|
||||
it("phantom 6-digit optical decode → refused, telemetry only, NOTHING signed", async () => {
|
||||
const out = await dispatcher.dispatch(read("999459", { channel: "optical" }));
|
||||
expect(out.accepted).toBe(false);
|
||||
expect(out.reason).toMatch(/unrecognized/);
|
||||
expect(ledger()).toHaveLength(0); // the whole point: no red row in the feed
|
||||
expect(unrecognized()).toHaveLength(1);
|
||||
expect(unrecognized()[0].value).toBe("999459");
|
||||
});
|
||||
|
||||
it("legacy untagged garbage ('C') → filtered too (works before prefixes are deployed)", async () => {
|
||||
const out = await dispatcher.dispatch(read("C"));
|
||||
expect(out.accepted).toBe(false);
|
||||
expect(ledger()).toHaveLength(0);
|
||||
expect(unrecognized()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("Luhn-valid unknown ticket → NOT filtered: the exit flow signs the noSession anomaly", async () => {
|
||||
const out = await dispatcher.dispatch(read("00000000000")); // valid shape, no session
|
||||
expect(out.accepted).toBe(false);
|
||||
expect(unrecognized()).toHaveLength(0);
|
||||
const anomalies = ledger().filter((r) => r.type === "anomaly");
|
||||
expect(anomalies.length).toBeGreaterThan(0); // a real probe stays in the signed feed
|
||||
});
|
||||
|
||||
it("unknown card on a CONFIRMED RF channel → NOT filtered (a physical card is a real event)", async () => {
|
||||
await dispatcher.dispatch(read("1A86A158", { kind: "card", channel: "rf" }));
|
||||
expect(unrecognized()).toHaveLength(0);
|
||||
expect(ledger().filter((r) => r.type === "anomaly").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("unknown SUB- code → NOT filtered (our own prefix = an interesting probe)", async () => {
|
||||
await dispatcher.dispatch(read("SUB-DOESNOTEXIST", { channel: "optical" }));
|
||||
expect(unrecognized()).toHaveLength(0);
|
||||
expect(ledger().filter((r) => r.type === "anomaly").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("an ENROLLED credential is matched BEFORE the filter (never hidden by it)", async () => {
|
||||
// A card UID that would fail every shape test — enrolled, so it must still match.
|
||||
db.insert(subscriptionCredentials).values({ id: randomUUID(), subscriptionId: "sub-1", kind: "rf", value: "999459" }).run();
|
||||
await dispatcher.dispatch(read("999459")); // legacy untagged read of it
|
||||
expect(unrecognized()).toHaveLength(0); // reached the subscription flow, not the filter
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,9 @@
|
||||
import { devices, eq, type Db } from "@parking/db";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { devices, deviceEvents as deviceEventsTable, eq, type Db } from "@parking/db";
|
||||
import type { FastifyBaseLogger } from "fastify";
|
||||
import type { DeviceReadEvent, ReadOutcome } from "./device-events.js";
|
||||
import type { ExitFlow } from "./exit-flow.js";
|
||||
import { validateTicketCode } from "./entry-flow.js";
|
||||
import type { SubscriptionFlow } from "./subscription-flow.js";
|
||||
import { relayForDevice } from "./device-resolve.js";
|
||||
|
||||
@@ -17,6 +19,22 @@ import { relayForDevice } from "./device-resolve.js";
|
||||
// it opens that exact barrier. An "entry" reader drives the entry side, an "exit"
|
||||
// reader the exit side; "both" defers to the flow's own inference (subscription:
|
||||
// session state; transient: exit).
|
||||
//
|
||||
// STRUCTURAL FILTER (2026-07-04, operator-requested). The DT-008's scan engine
|
||||
// false-decodes sunlight stripe patterns into short garbage codes (phantom reads —
|
||||
// see wiki/entities/dingtian-dt008-reader.md), and each one was reaching the exit
|
||||
// flow and signing an exit.refused.noSession anomaly: red "who is trying to exit?"
|
||||
// rows for NOBODY, training the operator to ignore the feed (alarm fatigue is the
|
||||
// adversary's friend). So a reader value that matched nothing AND cannot possibly be
|
||||
// a credential we issued is dropped to UNSIGNED telemetry (device_events, still
|
||||
// auditable) instead of the signed ledger. "Possibly ours" stays deliberately wide —
|
||||
// any of these still reaches the flows and signs the normal refusal anomaly:
|
||||
// - a Luhn-valid ticket shape (validateTicketCode — a forged/expired ticket is a
|
||||
// real probe),
|
||||
// - our issued-code prefixes (SUB- / SUBSESS-),
|
||||
// - ANY read on a CONFIRMED RF channel (a physically present card, enrolled or
|
||||
// not, is a real event — RF is never sun noise),
|
||||
// - plates (different population; never shape-filtered here).
|
||||
|
||||
export class ReadDispatcher {
|
||||
readonly #db: Db;
|
||||
@@ -45,6 +63,20 @@ export class ReadDispatcher {
|
||||
if (sub) {
|
||||
return this.#subscription.run(resolved, e, sub);
|
||||
}
|
||||
|
||||
// Matched nothing — if the value can't even BE one of ours, it's scanner noise
|
||||
// (phantom optical decode): refuse with unsigned telemetry, keep the signed feed
|
||||
// for events that involve an actual credential or an actual card.
|
||||
if ((e.kind === "qr" || e.kind === "card" || e.kind === "ticket") && !plausibleCredential(e)) {
|
||||
this.#recordUnrecognized(e);
|
||||
this.#logger.info(`read filtered (not a credential shape): '${e.value}' from ${e.deviceId}${e.channel ? ` ch=${e.channel}` : ""}`);
|
||||
return {
|
||||
accepted: false,
|
||||
direction: resolved.direction === "entry" ? "entry" : "exit",
|
||||
reason: "unrecognized code (no credential shape — telemetry only)",
|
||||
};
|
||||
}
|
||||
|
||||
// Not a subscription → transient ticket exit. An ENTRY reader can't produce a
|
||||
// transient exit (transient entry is the button flow, not a reader), so reject+log
|
||||
// rather than treat an entry scan as an exit.
|
||||
@@ -53,4 +85,39 @@ export class ReadDispatcher {
|
||||
}
|
||||
return this.#exit.handleAt(resolved, e);
|
||||
}
|
||||
|
||||
/** Unsigned telemetry for a filtered read — auditable in device_events, out of the
|
||||
* signed feed. Mirrors the entry flow's suppressed-press pattern. */
|
||||
#recordUnrecognized(e: DeviceReadEvent): void {
|
||||
try {
|
||||
this.#db
|
||||
.insert(deviceEventsTable)
|
||||
.values({
|
||||
id: randomUUID(),
|
||||
deviceId: e.deviceId,
|
||||
category: "reader",
|
||||
kind: "read",
|
||||
detail: {
|
||||
unrecognizedRead: true,
|
||||
value: e.value,
|
||||
readKind: e.kind,
|
||||
...(e.channel ? { channel: e.channel } : {}),
|
||||
reason: "no credential shape (phantom decode / garbage scan)",
|
||||
},
|
||||
occurredAt: e.at,
|
||||
})
|
||||
.run();
|
||||
} catch (err) {
|
||||
this.#logger.error(`unrecognized-read telemetry insert failed: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Could this reader value possibly be a credential WE issued (or a real card)?
|
||||
* Deliberately WIDE — only shapes that can't be anything of ours are filtered. */
|
||||
function plausibleCredential(e: DeviceReadEvent): boolean {
|
||||
if (e.channel === "rf") return true; // a physically present card — never sun noise
|
||||
if (validateTicketCode(e.value)) return true; // ticket shape (10–14 digits + Luhn)
|
||||
if (/^SUB(SESS)?-/.test(e.value)) return true; // our subscription QR / window-slip ids
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -181,3 +181,68 @@ describe("reportSummary — duration (sessions cache) + subscriptions", () => {
|
||||
expect(r.subscriptions.coveredCars).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reportSummary — occupancy, heatmap, stay histogram, look-closer counters (2026-07-05)", () => {
|
||||
it("folds prior ledger into occupancyStart and walks occupancyEnd through the series", async () => {
|
||||
// Before the range: 3 entries, 1 exit → 2 cars inside when June opens.
|
||||
await entry(at("2026-05-20T08:00:00Z"));
|
||||
await entry(at("2026-05-20T09:00:00Z"));
|
||||
await entry(at("2026-05-21T10:00:00Z"));
|
||||
await exit(at("2026-05-21T12:00:00Z"));
|
||||
// In range: +2 on the 10th, −1 on the 11th.
|
||||
await entry(at("2026-06-10T08:00:00Z"));
|
||||
await entry(at("2026-06-10T09:00:00Z"));
|
||||
await exit(at("2026-06-11T09:00:00Z"));
|
||||
|
||||
const r = reportSummary(db, { ...RANGE, bucket: "day" });
|
||||
expect(r.occupancyStart).toBe(2);
|
||||
expect(r.series.map((p) => [p.bucket, p.occupancyEnd])).toEqual([
|
||||
["2026-06-10", 4],
|
||||
["2026-06-11", 3],
|
||||
]);
|
||||
});
|
||||
|
||||
it("a voided pre-range entry does not inflate occupancyStart", async () => {
|
||||
const id = randomUUID();
|
||||
await log.append({ type: "vehicle_entry", direction: "entry", identity: id, occurredAt: at("2026-05-20T08:00:00Z") });
|
||||
await log.append({ type: "void", identity: id, occurredAt: at("2026-05-20T08:05:00Z"), payload: { reason: "misprint" } });
|
||||
const r = reportSummary(db, { ...RANGE, bucket: "day" });
|
||||
expect(r.occupancyStart).toBe(0);
|
||||
});
|
||||
|
||||
it("entriesByDowHour lands on the local weekday/hour (row 0 = Monday)", async () => {
|
||||
// 2026-06-10 is a WEDNESDAY; 08:00Z = 10:00 in Tirane (UTC+2 in June).
|
||||
await entry(at("2026-06-10T08:00:00Z"));
|
||||
const r = reportSummary(db, { ...RANGE, bucket: "day" });
|
||||
expect(r.entriesByDowHour[2]![10]).toBe(1); // Wed row, 10h column
|
||||
expect(r.entriesByDowHour.flat().reduce((a, b) => a + b, 0)).toBe(1);
|
||||
});
|
||||
|
||||
it("stay histogram buckets closed sessions; series carries the cash/card split", async () => {
|
||||
db.insert(sessions).values({ id: "h1", identity: "h1", enteredAt: at("2026-06-10T08:00:00Z"), exitedAt: at("2026-06-10T08:20:00Z"), state: "closed" }).run(); // 20m → ≤30
|
||||
db.insert(sessions).values({ id: "h2", identity: "h2", enteredAt: at("2026-06-10T08:00:00Z"), exitedAt: at("2026-06-10T09:30:00Z"), state: "closed" }).run(); // 90m → ≤120
|
||||
db.insert(sessions).values({ id: "h3", identity: "h3", enteredAt: at("2026-06-08T08:00:00Z"), exitedAt: at("2026-06-10T09:00:00Z"), state: "closed" }).run(); // 2 days → >24h tail
|
||||
await payment(at("2026-06-10T09:00:00Z"), 500, { tender: "cash" });
|
||||
await payment(at("2026-06-10T09:30:00Z"), 700, { tender: "card" });
|
||||
|
||||
const r = reportSummary(db, { ...RANGE, bucket: "day" });
|
||||
const counts = Object.fromEntries(r.stayHistogram.map((b) => [String(b.uptoMin), b.count]));
|
||||
expect(counts["30"]).toBe(1);
|
||||
expect(counts["120"]).toBe(1);
|
||||
expect(counts["null"]).toBe(1);
|
||||
const day = r.series.find((p) => p.bucket === "2026-06-10")!;
|
||||
expect(day.cashMinor).toBe(500);
|
||||
expect(day.cardMinor).toBe(700);
|
||||
});
|
||||
|
||||
it("counts voids and anomalies in range (the look-closer counters)", async () => {
|
||||
const id = randomUUID();
|
||||
await log.append({ type: "vehicle_entry", direction: "entry", identity: id, occurredAt: at("2026-06-10T08:00:00Z") });
|
||||
await log.append({ type: "void", identity: id, occurredAt: at("2026-06-10T08:05:00Z"), payload: { reason: "misprint" } });
|
||||
await log.append({ type: "anomaly", identity: "X", occurredAt: at("2026-06-10T09:00:00Z"), payload: { reason: "test" } });
|
||||
const r = reportSummary(db, { ...RANGE, bucket: "day" });
|
||||
expect(r.totals.voids).toBe(1);
|
||||
expect(r.totals.anomalies).toBe(1);
|
||||
expect(r.totals.entries).toBe(0); // the voided entry stays excluded
|
||||
});
|
||||
});
|
||||
|
||||
+105
-14
@@ -4,9 +4,11 @@ import {
|
||||
desc,
|
||||
eq,
|
||||
gte,
|
||||
lt,
|
||||
lte,
|
||||
ledgerEvents,
|
||||
sessions,
|
||||
siteConfig,
|
||||
subscriptions,
|
||||
tariffVersions,
|
||||
tariffs,
|
||||
@@ -46,8 +48,13 @@ export interface SeriesPoint {
|
||||
readonly exits: number;
|
||||
/** Net transient revenue collected in the bucket (minor units), all tenders. */
|
||||
readonly revenueMinor: number;
|
||||
/** Tender split of the bucket's revenue (cash = everything not card). */
|
||||
readonly cashMinor: number;
|
||||
readonly cardMinor: number;
|
||||
/** Payment COUNT in the bucket (transactions, not amount). */
|
||||
readonly payments: number;
|
||||
/** Cars inside at the END of the bucket (occupancyStart + running entries−exits). */
|
||||
readonly occupancyEnd: number;
|
||||
}
|
||||
|
||||
export interface ReportTotals {
|
||||
@@ -67,6 +74,10 @@ export interface ReportTotals {
|
||||
readonly totalParkedMinutes: number;
|
||||
readonly avgParkedMinutes: number;
|
||||
readonly medianParkedMinutes: number;
|
||||
/** Cancelled tickets + signed anomalies in range — the "look closer" counters
|
||||
* (the operator at the booth is the threat model's primary adversary). */
|
||||
readonly voids: number;
|
||||
readonly anomalies: number;
|
||||
}
|
||||
|
||||
export interface SubscriptionStats {
|
||||
@@ -79,6 +90,13 @@ export interface SubscriptionStats {
|
||||
readonly coveredCars: number;
|
||||
}
|
||||
|
||||
/** One bar of the stay-duration histogram: stays up to `uptoMin` minutes (null = the
|
||||
* open-ended tail). Edges chosen to mirror how tariffs are designed (see tariff.md). */
|
||||
export interface StayBucket {
|
||||
readonly uptoMin: number | null;
|
||||
readonly count: number;
|
||||
}
|
||||
|
||||
export interface ReportSummary {
|
||||
readonly from: string;
|
||||
readonly to: string;
|
||||
@@ -89,25 +107,43 @@ export interface ReportSummary {
|
||||
readonly series: SeriesPoint[];
|
||||
/** Entries by local hour-of-day (0–23), summed across the range — the peak-hour view. */
|
||||
readonly entriesByHour: number[];
|
||||
/** Entries by [day-of-week][hour-of-day] — 7×24, row 0 = Monday. The heatmap that
|
||||
* shows weekday-vs-weekend patterns (feeds tariff-window design). */
|
||||
readonly entriesByDowHour: number[][];
|
||||
/** Stay-duration histogram over closed sessions in range. */
|
||||
readonly stayHistogram: StayBucket[];
|
||||
/** Cars inside when the range OPENS (folded from the whole prior ledger). */
|
||||
readonly occupancyStart: number;
|
||||
/** Nominal capacity from site config (null = uncapped) — the reference line. */
|
||||
readonly capacity: number | null;
|
||||
readonly subscriptions: SubscriptionStats;
|
||||
}
|
||||
|
||||
/** Local wall-clock parts of an ISO instant in a given IANA tz. Reuses Intl (no dep). */
|
||||
function localParts(iso: string, tz: string): { y: number; mo: number; d: number; h: number } {
|
||||
const fmt = new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: tz,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
hourCycle: "h23",
|
||||
});
|
||||
const fmtCache = new Map<string, Intl.DateTimeFormat>();
|
||||
const DOW_INDEX: Record<string, number> = { Mon: 0, Tue: 1, Wed: 2, Thu: 3, Fri: 4, Sat: 5, Sun: 6 };
|
||||
function localParts(iso: string, tz: string): { y: number; mo: number; d: number; h: number; dow: number } {
|
||||
// Cached per tz — this runs once per ledger row in a report.
|
||||
let fmt = fmtCache.get(tz);
|
||||
if (!fmt) {
|
||||
fmt = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: tz,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
hourCycle: "h23",
|
||||
weekday: "short",
|
||||
});
|
||||
fmtCache.set(tz, fmt);
|
||||
}
|
||||
const parts = Object.fromEntries(fmt.formatToParts(new Date(iso)).map((p) => [p.type, p.value]));
|
||||
return {
|
||||
y: Number(parts.year),
|
||||
mo: Number(parts.month),
|
||||
d: Number(parts.day),
|
||||
h: Number(parts.hour),
|
||||
dow: DOW_INDEX[parts.weekday ?? ""] ?? 0, // row 0 = Monday
|
||||
};
|
||||
}
|
||||
|
||||
@@ -162,6 +198,7 @@ export function reportSummary(db: Db, q: ReportQuery): ReportSummary {
|
||||
|
||||
const seriesMap = new Map<string, SeriesPoint>();
|
||||
const entriesByHour = new Array<number>(24).fill(0);
|
||||
const entriesByDowHour = Array.from({ length: 7 }, () => new Array<number>(24).fill(0));
|
||||
const totals = {
|
||||
entries: 0,
|
||||
exits: 0,
|
||||
@@ -172,12 +209,14 @@ export function reportSummary(db: Db, q: ReportQuery): ReportSummary {
|
||||
ticketMinor: 0,
|
||||
subscriptionSalesMinor: 0,
|
||||
subscriptionWindowMinor: 0,
|
||||
voids: 0,
|
||||
anomalies: 0,
|
||||
};
|
||||
|
||||
function point(label: string): SeriesPoint {
|
||||
let p = seriesMap.get(label);
|
||||
if (!p) {
|
||||
p = { bucket: label, entries: 0, exits: 0, revenueMinor: 0, payments: 0 };
|
||||
p = { bucket: label, entries: 0, exits: 0, revenueMinor: 0, cashMinor: 0, cardMinor: 0, payments: 0, occupancyEnd: 0 };
|
||||
seriesMap.set(label, p);
|
||||
}
|
||||
return p;
|
||||
@@ -196,11 +235,16 @@ export function reportSummary(db: Db, q: ReportQuery): ReportSummary {
|
||||
if (row.identity && voided.has(row.identity)) continue; // cancelled — not a real entry
|
||||
totals.entries++;
|
||||
p.entries++;
|
||||
const h = localParts(row.occurredAt, tz).h;
|
||||
entriesByHour[h] = (entriesByHour[h] ?? 0) + 1;
|
||||
const lp = localParts(row.occurredAt, tz);
|
||||
entriesByHour[lp.h] = (entriesByHour[lp.h] ?? 0) + 1;
|
||||
entriesByDowHour[lp.dow]![lp.h] = (entriesByDowHour[lp.dow]![lp.h] ?? 0) + 1;
|
||||
} else if (row.type === "vehicle_exit") {
|
||||
totals.exits++;
|
||||
p.exits++;
|
||||
} else if (row.type === "void") {
|
||||
totals.voids++;
|
||||
} else if (row.type === "anomaly") {
|
||||
totals.anomalies++;
|
||||
} else if (row.type === "payment") {
|
||||
const pl = (row.payload ?? {}) as PaymentPayload;
|
||||
const amt = typeof pl.amountMinor === "number" ? pl.amountMinor : 0;
|
||||
@@ -209,8 +253,13 @@ export function reportSummary(db: Db, q: ReportQuery): ReportSummary {
|
||||
totals.revenueMinor += amt;
|
||||
p.payments++;
|
||||
p.revenueMinor += amt;
|
||||
if (pl.tender === "card") totals.cardMinor += amt;
|
||||
else totals.cashMinor += amt;
|
||||
if (pl.tender === "card") {
|
||||
totals.cardMinor += amt;
|
||||
p.cardMinor += amt;
|
||||
} else {
|
||||
totals.cashMinor += amt;
|
||||
p.cashMinor += amt;
|
||||
}
|
||||
// Revenue split mirrors the shift Z-report: subscription sale / window charge /
|
||||
// (the rest is) transient ticket revenue.
|
||||
if (pl.subscriptionSale === true) totals.subscriptionSalesMinor += amt;
|
||||
@@ -221,6 +270,31 @@ export function reportSummary(db: Db, q: ReportQuery): ReportSummary {
|
||||
|
||||
const series = [...seriesMap.values()].sort((a, b) => a.bucket.localeCompare(b.bucket));
|
||||
|
||||
// --- Occupancy: fold the PRIOR ledger for cars-inside at range start, then walk the
|
||||
// series. Voided pre-range entries cancel out the same way the in-range pass does.
|
||||
// Sparse buckets (no events) simply carry the previous level — the step line is exact
|
||||
// at every plotted point.
|
||||
const prior = db
|
||||
.select({ type: ledgerEvents.type, identity: ledgerEvents.identity })
|
||||
.from(ledgerEvents)
|
||||
.where(lt(ledgerEvents.occurredAt, q.from))
|
||||
.all();
|
||||
const priorVoided = new Set<string>();
|
||||
for (const r of prior) if (r.type === "void" && r.identity) priorVoided.add(r.identity);
|
||||
let occupancyStart = 0;
|
||||
for (const r of prior) {
|
||||
if (r.type === "vehicle_entry" && !(r.identity && priorVoided.has(r.identity))) occupancyStart++;
|
||||
else if (r.type === "vehicle_exit") occupancyStart--;
|
||||
}
|
||||
occupancyStart = Math.max(0, occupancyStart);
|
||||
let running = occupancyStart;
|
||||
for (const p of series) {
|
||||
running = Math.max(0, running + p.entries - p.exits);
|
||||
(p as { -readonly [K in keyof SeriesPoint]: SeriesPoint[K] }).occupancyEnd = running;
|
||||
}
|
||||
|
||||
const capacity = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get()?.capacity ?? null;
|
||||
|
||||
// No payment in range? Fall back to the site tariff's latest version currency, so a
|
||||
// zero-revenue range still labels its money column.
|
||||
if (!currency) {
|
||||
@@ -251,6 +325,19 @@ export function reportSummary(db: Db, q: ReportQuery): ReportSummary {
|
||||
durations.sort((a, b) => a - b);
|
||||
const totalParkedMinutes = durations.reduce((a, b) => a + b, 0);
|
||||
|
||||
// Stay-duration histogram. Edges mirror how rate cards are designed (30m/1h bands,
|
||||
// the 8h working day, the 24h rolling day) so the chart answers "where should the
|
||||
// ladder/up-to breakpoints sit". Last bucket is the open-ended >24h tail.
|
||||
const STAY_EDGES_MIN = [30, 60, 120, 240, 480, 1440];
|
||||
const stayHistogram: { uptoMin: number | null; count: number }[] = [
|
||||
...STAY_EDGES_MIN.map((uptoMin) => ({ uptoMin, count: 0 })),
|
||||
{ uptoMin: null, count: 0 },
|
||||
];
|
||||
for (const mins of durations) {
|
||||
const i = STAY_EDGES_MIN.findIndex((edge) => mins <= edge);
|
||||
stayHistogram[i === -1 ? STAY_EDGES_MIN.length : i]!.count++;
|
||||
}
|
||||
|
||||
// --- Subscriptions: status counts + currently-valid (window covers `to`).
|
||||
const subs = db.select().from(subscriptions).all();
|
||||
const subStats = { active: 0, suspended: 0, revoked: 0, currentlyValid: 0, coveredCars: 0 };
|
||||
@@ -283,6 +370,10 @@ export function reportSummary(db: Db, q: ReportQuery): ReportSummary {
|
||||
},
|
||||
series,
|
||||
entriesByHour,
|
||||
entriesByDowHour,
|
||||
stayHistogram,
|
||||
occupancyStart,
|
||||
capacity,
|
||||
subscriptions: subStats,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { requirePermission, roleHasPermissions } from "../auth.js";
|
||||
import { InvalidCashMovementError, type MovementStatus, type ShiftService } from "../shift-service.js";
|
||||
|
||||
// Drawer cash movements (manned mode). Redesigned 2026-07-01: an operator RECORDS a
|
||||
// receipt/disbursement FREELY (no admin sign-off at creation); an admin REVIEWS it after
|
||||
// the fact (authorize/deny — a flag that never moves cash). See wiki/concepts/shift.md.
|
||||
// - POST /api/drawer/movement : operator records a cash_in/cash_out. (drawer:create)
|
||||
// - GET /api/drawer/movements: list with review status. Operators see (shift:read)
|
||||
// only their own; reviewers see all + can filter status.
|
||||
// - POST /api/drawer/review : admin authorize/deny a movement. (drawer:review)
|
||||
// - GET /api/drawer/balance : the physical drawer balance NOW (cash (shift:read)
|
||||
// payments + vouchers over the whole chain — the
|
||||
// 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.
|
||||
|
||||
interface MovementBody {
|
||||
/** Direction is the document TYPE, not a sign: cash_in = Mandat Arkëtimi (pay-IN),
|
||||
* cash_out = Mandat Pagese (pay-OUT). */
|
||||
type: "cash_in" | "cash_out";
|
||||
/** POSITIVE minor units (magnitude). The direction comes from `type`. */
|
||||
amountMinor: number;
|
||||
reason?: string;
|
||||
currency?: string;
|
||||
}
|
||||
|
||||
interface ReviewBody {
|
||||
/** The cash_in/cash_out event id being decided on. */
|
||||
refId: string;
|
||||
decision: "authorize" | "deny";
|
||||
/** Optional admin note (e.g. why denied). */
|
||||
note?: string;
|
||||
}
|
||||
|
||||
interface MovementsQuery {
|
||||
/** Reviewers only: filter to pending/authorized/denied. Ignored for non-reviewers. */
|
||||
status?: MovementStatus;
|
||||
}
|
||||
|
||||
export async function drawerRoutes(app: FastifyInstance, shift: ShiftService): Promise<void> {
|
||||
const createGuard = requirePermission("drawer:create");
|
||||
const reviewGuard = requirePermission("drawer:review");
|
||||
const readGuard = requirePermission("shift:read");
|
||||
|
||||
// Operator RECORDS a movement — freely, no authorizer. It counts in the drawer at once.
|
||||
app.post<{ Body: MovementBody }>("/api/drawer/movement", { preHandler: createGuard }, async (req, reply) => {
|
||||
const b = req.body ?? ({} as MovementBody);
|
||||
if (b.type !== "cash_in" && b.type !== "cash_out") {
|
||||
return reply.code(400).send({ error: "type must be cash_in or cash_out" });
|
||||
}
|
||||
try {
|
||||
return await shift.recordVoucher({
|
||||
type: b.type,
|
||||
operator: req.user.username,
|
||||
amountMinor: b.amountMinor,
|
||||
reason: b.reason ?? "",
|
||||
currency: b.currency,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof InvalidCashMovementError) return reply.code(400).send({ error: err.message });
|
||||
return reply.code(500).send({ error: (err as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
// 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) => {
|
||||
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 movements = shift.movementsWithStatus({
|
||||
operator: canReview ? undefined : req.user.username,
|
||||
status,
|
||||
});
|
||||
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());
|
||||
|
||||
// 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) => {
|
||||
const b = req.body ?? ({} as ReviewBody);
|
||||
if (!b.refId || (b.decision !== "authorize" && b.decision !== "deny")) {
|
||||
return reply.code(400).send({ error: "refId and decision (authorize|deny) are required" });
|
||||
}
|
||||
try {
|
||||
return await shift.reviewMovement({
|
||||
refId: b.refId,
|
||||
decision: b.decision,
|
||||
reviewedBy: req.user.username,
|
||||
note: b.note,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof InvalidCashMovementError) return reply.code(400).send({ error: err.message });
|
||||
return reply.code(500).send({ error: (err as Error).message });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { requirePermission } from "../auth.js";
|
||||
import type { EntryFlow } from "../entry-flow.js";
|
||||
import type { LaneStatus } from "../lane-status.js";
|
||||
import type { ShiftService } from "../shift-service.js";
|
||||
import { NoShiftOpenError } from "../shift-service.js";
|
||||
|
||||
// Operator-issued entry (2026-07-01). When the physical entry button is broken, an operator
|
||||
// may issue an entry ticket — a FLAGGED mint (vehicle_entry source=manual + operatorInitiated
|
||||
// + a companion anomaly), gated EXACTLY like the physical button: a real vehicle must be
|
||||
// present (radar/loop AND camera). The presence gate is enforced HERE (server-side), so a
|
||||
// direct POST can't bypass a disabled UI button. Money-adjacent → requires an open shift.
|
||||
// See wiki/concepts/operator-issued-entry.md.
|
||||
|
||||
export async function entryRoutes(
|
||||
app: FastifyInstance,
|
||||
entryFlow: EntryFlow,
|
||||
laneStatus: LaneStatus,
|
||||
shift: ShiftService,
|
||||
): Promise<void> {
|
||||
const guard = requirePermission("session:create");
|
||||
|
||||
app.post("/api/entry/issue", { preHandler: guard }, async (req, reply) => {
|
||||
// Gate on an open shift (a minted entry belongs to an accountable operator).
|
||||
if (!shift.currentOpenShift()) {
|
||||
return reply.code(409).send({ error: new NoShiftOpenError().message });
|
||||
}
|
||||
// The camera side of the presence gate = the live entry lane-busy state; the radar/loop
|
||||
// side is checked inside the flow (its per-relay presence guard).
|
||||
const cameraBusy = laneStatus.snapshot().entry;
|
||||
const res = await entryFlow.issueForOperator(req.user.username, cameraBusy);
|
||||
if (!res.ok) return reply.code(409).send({ error: res.reason });
|
||||
return res;
|
||||
});
|
||||
}
|
||||
@@ -30,6 +30,9 @@ interface PayBody {
|
||||
}
|
||||
interface ExitBody {
|
||||
identity: string;
|
||||
/** Operator consciously releases a suspected plate-swap exit (re-submit after the
|
||||
* first call returned status "swap_suspected"). Signs an attributed override anomaly. */
|
||||
override?: boolean;
|
||||
}
|
||||
interface VoucherBody {
|
||||
identity: string;
|
||||
@@ -109,8 +112,17 @@ export async function payRoutes(
|
||||
async (req, reply) => {
|
||||
const identity = (req.body?.identity ?? "").trim();
|
||||
if (!identity) return reply.code(400).send({ error: "identity required" });
|
||||
const res = await exitFlow.exitForBooth(identity);
|
||||
if (!res.ok) return reply.code(409).send({ error: res.reason, status: res.status });
|
||||
const res = await exitFlow.exitForBooth(identity, {
|
||||
override: req.body?.override === true,
|
||||
operator: req.user?.username,
|
||||
});
|
||||
// A suspected plate-swap returns the full detail so the modal can warn + offer override.
|
||||
if (!res.ok) {
|
||||
if (res.status === "swap_suspected") {
|
||||
return reply.code(409).send({ error: res.reason, status: res.status, plate: res.plate, otherIdentity: res.otherIdentity, otherEnteredAt: res.otherEnteredAt });
|
||||
}
|
||||
return reply.code(409).send({ error: res.reason, status: res.status });
|
||||
}
|
||||
return reply.code(200).send(res);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { ledgerEvents, type Db } from "@parking/db";
|
||||
import { createTestDb } from "@parking/db/testing";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { buildServer } from "../server.js";
|
||||
import { seedUser, login } from "../test-helpers.js";
|
||||
|
||||
// PUT /api/site-config/presence-bypass toggles the entry presence-gate bypass. It's a
|
||||
// DEDICATED, SIGNED endpoint: each signal that actually changes appends a config_change to
|
||||
// the ledger (attributed), and it persists to site_config. Admin-only.
|
||||
|
||||
let db: Db;
|
||||
let close: () => void;
|
||||
let app: FastifyInstance;
|
||||
|
||||
beforeEach(async () => {
|
||||
const t = createTestDb();
|
||||
db = t.db;
|
||||
close = t.close;
|
||||
app = await buildServer({ db });
|
||||
await app.ready();
|
||||
});
|
||||
afterEach(async () => {
|
||||
await app.close();
|
||||
close();
|
||||
});
|
||||
|
||||
const configChanges = () => db.select().from(ledgerEvents).all().filter((r) => r.type === "config_change");
|
||||
|
||||
async function put(body: unknown, auth: { cookie: string; csrf: string }) {
|
||||
return app.inject({
|
||||
method: "PUT",
|
||||
url: "/api/site-config/presence-bypass",
|
||||
headers: { cookie: auth.cookie, "x-csrf-token": auth.csrf },
|
||||
payload: body as Record<string, unknown>,
|
||||
});
|
||||
}
|
||||
|
||||
describe("PUT /api/site-config/presence-bypass", () => {
|
||||
it("is admin-only: a non-site:update user is 403", async () => {
|
||||
await seedUser(db, { username: "op", password: "pw", roleId: "operator", permissions: ["shift:read"] });
|
||||
const auth = await login(app, "op", "pw");
|
||||
const res = await put({ camera: true }, auth);
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it("enabling a signal persists it AND signs an attributed config_change", async () => {
|
||||
await seedUser(db, { username: "admin", password: "pw" });
|
||||
const auth = await login(app, "admin", "pw");
|
||||
|
||||
const res = await put({ camera: true }, auth);
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json()).toMatchObject({ bypassPresenceCamera: true, bypassPresenceRadar: false });
|
||||
|
||||
const changes = configChanges();
|
||||
expect(changes).toHaveLength(1);
|
||||
expect(changes[0].source).toBe("manual");
|
||||
expect(changes[0].signature.length).toBeGreaterThan(0);
|
||||
expect(changes[0].payload).toMatchObject({
|
||||
setting: "entryPresenceBypass.camera",
|
||||
value: true,
|
||||
prev: false,
|
||||
operator: "admin",
|
||||
});
|
||||
});
|
||||
|
||||
it("a no-op toggle (already in that state) signs nothing", async () => {
|
||||
await seedUser(db, { username: "admin", password: "pw" });
|
||||
const auth = await login(app, "admin", "pw");
|
||||
await put({ camera: true }, auth); // 1st: on → 1 event
|
||||
await put({ camera: true }, auth); // 2nd: still on → no new event
|
||||
expect(configChanges()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("disabling signs the off transition too (auditable both ways)", async () => {
|
||||
await seedUser(db, { username: "admin", password: "pw" });
|
||||
const auth = await login(app, "admin", "pw");
|
||||
await put({ radar: true }, auth);
|
||||
await put({ radar: false }, auth);
|
||||
const changes = configChanges();
|
||||
expect(changes).toHaveLength(2);
|
||||
expect(changes[1].payload).toMatchObject({ setting: "entryPresenceBypass.radar", value: false, prev: true });
|
||||
});
|
||||
|
||||
it("rejects a non-boolean and an empty body", async () => {
|
||||
await seedUser(db, { username: "admin", password: "pw" });
|
||||
const auth = await login(app, "admin", "pw");
|
||||
expect((await put({ camera: "yes" }, auth)).statusCode).toBe(400);
|
||||
expect((await put({}, auth)).statusCode).toBe(400);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import Fastify from "fastify";
|
||||
import { beforeEach, afterEach, describe, expect, it } from "vitest";
|
||||
import { devices, type Db } from "@parking/db";
|
||||
import { createTestDb } from "@parking/db/testing";
|
||||
import { qrReaderRoutes, splitChannel } from "./qr-reader.js";
|
||||
import { CredentialCapture } from "../credential-capture.js";
|
||||
import type { DeviceReadEvent, ReadOutcome } from "../device-events.js";
|
||||
import type { ReadDispatcher } from "../read-dispatch.js";
|
||||
|
||||
// CHANNEL TAGGING (2026-07-04): the DT-008's "QRCode Output Prefix" / "Card Output
|
||||
// Prefix" (vendor tool) mark which engine produced a push — Q: = optical, K: = RF.
|
||||
// The route strips the prefix, tags the read's confirmed channel, and enrollment
|
||||
// capture stores the BARE value. Unprefixed reads stay the legacy untagged shape so
|
||||
// an unconfigured reader keeps working. These tests pin the route-side contract;
|
||||
// the match-side enforcement is pinned in ../subscription-channel.test.ts.
|
||||
|
||||
const SERIAL = "H05MA5B0";
|
||||
const READER_ID = "reader-exit";
|
||||
|
||||
let db: Db;
|
||||
let app: ReturnType<typeof Fastify>;
|
||||
let capture: CredentialCapture;
|
||||
let seen: DeviceReadEvent[];
|
||||
|
||||
/** Dispatcher stub: records the event the route built, always rejects. */
|
||||
const fakeDispatcher = {
|
||||
dispatch: async (e: DeviceReadEvent): Promise<ReadOutcome> => {
|
||||
seen.push(e);
|
||||
return { accepted: false, reason: "test" };
|
||||
},
|
||||
} as unknown as ReadDispatcher;
|
||||
|
||||
beforeEach(async () => {
|
||||
({ db } = createTestDb());
|
||||
db.insert(devices).values({
|
||||
id: READER_ID,
|
||||
category: "reader",
|
||||
driverId: "dingtian-qr-reader",
|
||||
config: { serial: SERIAL },
|
||||
enabled: true,
|
||||
}).run();
|
||||
seen = [];
|
||||
capture = new CredentialCapture();
|
||||
app = Fastify({ logger: false });
|
||||
await qrReaderRoutes(app as never, db, fakeDispatcher, capture);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
const scan = (cardid: string) =>
|
||||
app.inject({ method: "GET", url: `/qa/mcardsea.php?cardid=${encodeURIComponent(cardid)}&cjihao=${SERIAL}&mjihao=1&status=10` });
|
||||
|
||||
describe("splitChannel", () => {
|
||||
it("K: prefix → bare value, kind card, channel rf", () => {
|
||||
expect(splitChannel("K:86A158")).toEqual({ value: "86A158", kind: "card", channel: "rf" });
|
||||
});
|
||||
it("Q: prefix → bare value, kind qr, channel optical", () => {
|
||||
expect(splitChannel("Q:12345678901")).toEqual({ value: "12345678901", kind: "qr", channel: "optical" });
|
||||
});
|
||||
it("no prefix → value untouched, legacy untagged qr", () => {
|
||||
expect(splitChannel("86A158")).toEqual({ value: "86A158", kind: "qr" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("qr-reader route channel tagging", () => {
|
||||
it("card-prefixed push dispatches a stripped, rf-tagged read", async () => {
|
||||
const res = await scan("K:86A158");
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(seen).toHaveLength(1);
|
||||
expect(seen[0]).toMatchObject({ value: "86A158", kind: "card", channel: "rf", deviceId: READER_ID });
|
||||
});
|
||||
|
||||
it("qr-prefixed push dispatches a stripped, optical-tagged read", async () => {
|
||||
await scan("Q:00000000000");
|
||||
expect(seen[0]).toMatchObject({ value: "00000000000", kind: "qr", channel: "optical" });
|
||||
});
|
||||
|
||||
it("unprefixed push stays legacy: kind qr, no channel", async () => {
|
||||
await scan("86A158");
|
||||
expect(seen[0]).toMatchObject({ value: "86A158", kind: "qr" });
|
||||
expect(seen[0].channel).toBeUndefined();
|
||||
});
|
||||
|
||||
it("a bare prefix (empty value after strip) dispatches nothing", async () => {
|
||||
await scan("K:");
|
||||
expect(seen).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("enrollment capture stores the BARE value, not the prefixed one", async () => {
|
||||
capture.arm(READER_ID);
|
||||
const res = await scan("K:86A158");
|
||||
expect(seen).toHaveLength(0); // intercepted — never dispatched to the access flow
|
||||
const state = capture.state();
|
||||
expect(state.status).toBe("captured");
|
||||
if (state.status === "captured") expect(state.value).toBe("86A158");
|
||||
// Beeps "ok" so the operator knows the card was read.
|
||||
expect(res.json().data[0].status).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -27,6 +27,36 @@ interface ReaderQuery {
|
||||
time?: string;
|
||||
}
|
||||
|
||||
// ── CHANNEL TAGGING (2026-07-04) ────────────────────────────────────────────────
|
||||
// The DT-008 push carries one opaque `cardid` whether its OPTICAL engine decoded a
|
||||
// QR/barcode or its RF engine read a card — the server can't tell them apart. That
|
||||
// enabled a cheap clone: print a card's UID (often written on the card face) as a
|
||||
// barcode and the optical decode matches the RF credential. Fix: the vendor tool's
|
||||
// "QRCode Output Prefix" / "Card Output Prefix" are set to the markers below on every
|
||||
// reader; the route strips the prefix and tags the read's confirmed channel, and the
|
||||
// subscription match refuses a channel-mismatched credential. A read with NO prefix
|
||||
// stays the legacy untagged shape (kind "qr", channel undefined) so an unconfigured
|
||||
// reader keeps working — the enforcement only bites where prefixes are deployed.
|
||||
// ⚠️ Prefixes must MATCH the vendor tool; also FREEZE "Card Input format" (6H) — that
|
||||
// setting defines the UID shape we enroll. See wiki/entities/dingtian-dt008-reader.md.
|
||||
const QR_CHANNEL_PREFIX = process.env.READER_QR_PREFIX ?? "Q:";
|
||||
const CARD_CHANNEL_PREFIX = process.env.READER_CARD_PREFIX ?? "K:";
|
||||
|
||||
/** Split a raw pushed `cardid` into its bare value + confirmed channel (if prefixed). */
|
||||
export function splitChannel(raw: string): {
|
||||
value: string;
|
||||
kind: "qr" | "card";
|
||||
channel?: "optical" | "rf";
|
||||
} {
|
||||
if (CARD_CHANNEL_PREFIX.length > 0 && raw.startsWith(CARD_CHANNEL_PREFIX)) {
|
||||
return { value: raw.slice(CARD_CHANNEL_PREFIX.length), kind: "card", channel: "rf" };
|
||||
}
|
||||
if (QR_CHANNEL_PREFIX.length > 0 && raw.startsWith(QR_CHANNEL_PREFIX)) {
|
||||
return { value: raw.slice(QR_CHANNEL_PREFIX.length), kind: "qr", channel: "optical" };
|
||||
}
|
||||
return { value: raw, kind: "qr" }; // legacy: unprefixed reader, channel unknown
|
||||
}
|
||||
|
||||
export async function qrReaderRoutes(
|
||||
app: FastifyInstance,
|
||||
db: Db,
|
||||
@@ -55,6 +85,7 @@ export async function qrReaderRoutes(
|
||||
// See wiki/sources/qrcode-sdk.md, entities/dingtian-dt008-reader.md.
|
||||
reply.header("connection", "close");
|
||||
const cardid = (q.cardid ?? "").trim();
|
||||
const scan = splitChannel(cardid); // bare value + confirmed channel (if prefixed)
|
||||
const mjihao = q.mjihao != null ? Number(q.mjihao) : 0;
|
||||
const serial = (q.cjihao ?? "").trim();
|
||||
|
||||
@@ -65,35 +96,37 @@ export async function qrReaderRoutes(
|
||||
const deviceId = matchedRowId ?? serial;
|
||||
|
||||
let accepted = false;
|
||||
if (cardid) {
|
||||
if (scan.value) {
|
||||
// ENROLLMENT INTERCEPT: if THIS reader is armed for credential capture, grab the
|
||||
// value for the subscription form and do NOT run the access flow (we must not
|
||||
// open a barrier for a card being enrolled). Single-shot — capture auto-disarms.
|
||||
// Reads from the OTHER reader are untouched and dispatch normally below.
|
||||
if (capture.tryConsume(deviceId, cardid)) {
|
||||
app.log.info(`CAPTURE serial=${serial || "?"} device=${matchedRowId ? matchedRowId.slice(0, 8) : "?"} value=${cardid}`);
|
||||
// Captured BARE (prefix stripped) so enrolled values match future stripped reads.
|
||||
if (capture.tryConsume(deviceId, scan.value)) {
|
||||
app.log.info(`CAPTURE serial=${serial || "?"} device=${matchedRowId ? matchedRowId.slice(0, 8) : "?"} value=${scan.value}${scan.channel ? ` ch=${scan.channel}` : ""}`);
|
||||
accepted = true; // beep "ok" so the operator knows the card was read
|
||||
} else {
|
||||
const read: DeviceReadEvent = {
|
||||
driverId: "dingtian-qr-reader",
|
||||
deviceId,
|
||||
value: cardid,
|
||||
kind: "qr",
|
||||
value: scan.value,
|
||||
kind: scan.kind,
|
||||
...(scan.channel ? { channel: scan.channel } : {}),
|
||||
at: new Date().toISOString(),
|
||||
};
|
||||
try {
|
||||
const outcome = await dispatcher.dispatch(read);
|
||||
accepted = outcome.accepted;
|
||||
// Per-read diagnostic: which reader (serial) sent it, which configured device
|
||||
// it mapped to, and the verdict — so a barrier/serial mismatch is visible in
|
||||
// the logs (e.g. an entry-side scan resolving to the exit relay).
|
||||
// it mapped to, the confirmed channel (if prefixed), and the verdict — so a
|
||||
// barrier/serial mismatch or a channel anomaly is visible in the logs.
|
||||
app.log.info(
|
||||
`READ serial=${serial || "?"} → device=${matchedRowId ? matchedRowId.slice(0, 8) : "UNASSIGNED"} ` +
|
||||
`card=${cardid} verdict=${accepted ? "ACCEPT" : "REJECT"}${outcome.direction ? ` dir=${outcome.direction}` : ""}` +
|
||||
`card=${scan.value}${scan.channel ? ` ch=${scan.channel}` : ""} verdict=${accepted ? "ACCEPT" : "REJECT"}${outcome.direction ? ` dir=${outcome.direction}` : ""}` +
|
||||
`${accepted ? "" : ` reason="${outcome.reason ?? "?"}"`}`,
|
||||
);
|
||||
} catch (err) {
|
||||
app.log.error(`QR dispatch failed for ${cardid}: ${(err as Error).message}`);
|
||||
app.log.error(`QR dispatch failed for ${scan.value}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,9 +48,18 @@ export async function reportRoutes(app: FastifyInstance, db: Db): Promise<void>
|
||||
async (req, reply) => {
|
||||
const summary = reportSummary(db, parseQuery(req.query));
|
||||
const lines = [
|
||||
"bucket,entries,exits,payments,revenue",
|
||||
"bucket,entries,exits,payments,revenue,cash,card,occupancy_end",
|
||||
...summary.series.map((p) =>
|
||||
[p.bucket, p.entries, p.exits, p.payments, (p.revenueMinor / 100).toFixed(2)].join(","),
|
||||
[
|
||||
p.bucket,
|
||||
p.entries,
|
||||
p.exits,
|
||||
p.payments,
|
||||
(p.revenueMinor / 100).toFixed(2),
|
||||
(p.cashMinor / 100).toFixed(2),
|
||||
(p.cardMinor / 100).toFixed(2),
|
||||
p.occupancyEnd,
|
||||
].join(","),
|
||||
),
|
||||
];
|
||||
reply
|
||||
|
||||
@@ -56,6 +56,37 @@ describe("auth guard — no token", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/version", () => {
|
||||
it("without a session is 401", async () => {
|
||||
const res = await app.inject({ method: "GET", url: "/api/version" });
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it("a site:read user gets the BUILD_VERSION env var, null when unset", async () => {
|
||||
const { username, password } = await seedUser(db, {
|
||||
username: "viewer2", roleId: "viewer2", permissions: ["site:read"],
|
||||
});
|
||||
const { cookie } = await login(app, username, password);
|
||||
const res = await app.inject({ method: "GET", url: "/api/version", headers: { cookie } });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json()).toEqual({ buildVersion: null }); // no BUILD_VERSION set in the test env
|
||||
});
|
||||
|
||||
it("reflects a real BUILD_VERSION when the env var is set", async () => {
|
||||
process.env.BUILD_VERSION = "stage-abc1234";
|
||||
try {
|
||||
const { username, password } = await seedUser(db, {
|
||||
username: "viewer3", roleId: "viewer3", permissions: ["site:read"],
|
||||
});
|
||||
const { cookie } = await login(app, username, password);
|
||||
const res = await app.inject({ method: "GET", url: "/api/version", headers: { cookie } });
|
||||
expect(res.json()).toEqual({ buildVersion: "stage-abc1234" });
|
||||
} finally {
|
||||
delete process.env.BUILD_VERSION;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("RBAC permission gate", () => {
|
||||
it("a site:read-only user can GET occupancy but is 403 on PUT site-config", async () => {
|
||||
const { username, password } = await seedUser(db, {
|
||||
@@ -101,3 +132,21 @@ describe("CSRF double-submit on mutations", () => {
|
||||
expect(put.statusCode).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe("drawer balance (the till NOW)", () => {
|
||||
it("shift:read gets the balance; a role without it is 403; no auth 401", async () => {
|
||||
const anon = await app.inject({ method: "GET", url: "/api/drawer/balance" });
|
||||
expect(anon.statusCode).toBe(401);
|
||||
|
||||
const viewer = await seedUser(db, { username: "till", roleId: "till", permissions: ["shift:read"] });
|
||||
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 });
|
||||
|
||||
const outsider = await seedUser(db, { username: "noshift", roleId: "noshift", permissions: ["site:read"] });
|
||||
const other = await login(app, outsider.username, outsider.password);
|
||||
const denied = await app.inject({ method: "GET", url: "/api/drawer/balance", headers: { cookie: other.cookie } });
|
||||
expect(denied.statusCode).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { devices, ledgerEvents, type Db } from "@parking/db";
|
||||
import { createTestDb } from "@parking/db/testing";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { buildServer } from "../server.js";
|
||||
import { seedUser, login } from "../test-helpers.js";
|
||||
|
||||
// POST /api/setup/test-relay pulses a SAVED controller's barrier relay to prove the
|
||||
// wiring — it physically opens the barrier. Because "a physical open with no matching
|
||||
// signed command is the fraud signal" (append-only-event-chain / reconciliation), the
|
||||
// route must SIGN a barrier_open_command (reason setup.relayTest) BEFORE it fires, and it
|
||||
// must be admin-only. These tests use the `stub-access` controller (pulseOpen only logs —
|
||||
// no real hardware) so they exercise the validate → sign → pulse path safely.
|
||||
|
||||
let db: Db;
|
||||
let close: () => void;
|
||||
let app: FastifyInstance;
|
||||
|
||||
const CTL = "ctl-stub";
|
||||
|
||||
beforeEach(async () => {
|
||||
const t = createTestDb();
|
||||
db = t.db;
|
||||
close = t.close;
|
||||
db.insert(devices).values({
|
||||
id: CTL,
|
||||
category: "access",
|
||||
driverId: "stub-access",
|
||||
config: { relays: [{ relay: 1, direction: "entry" }, { relay: 2, direction: "exit" }] },
|
||||
enabled: true,
|
||||
}).run();
|
||||
app = await buildServer({ db });
|
||||
await app.ready();
|
||||
});
|
||||
afterEach(async () => {
|
||||
await app.close();
|
||||
close();
|
||||
});
|
||||
|
||||
async function pulse(
|
||||
body: unknown,
|
||||
auth?: { cookie: string; csrf: string },
|
||||
) {
|
||||
return app.inject({
|
||||
method: "POST",
|
||||
url: "/api/setup/test-relay",
|
||||
headers: auth ? { cookie: auth.cookie, "x-csrf-token": auth.csrf } : {},
|
||||
payload: body as Record<string, unknown>,
|
||||
});
|
||||
}
|
||||
|
||||
describe("POST /api/setup/test-relay", () => {
|
||||
it("is admin-only: a non-site:update user is 403", async () => {
|
||||
await seedUser(db, { username: "op", password: "pw", roleId: "operator", permissions: ["shift:read"] });
|
||||
const auth = await login(app, "op", "pw");
|
||||
const res = await pulse({ id: CTL, relay: 1 }, auth);
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it("requires CSRF on the mutation", async () => {
|
||||
await seedUser(db, { username: "admin", password: "pw" });
|
||||
const { cookie } = await login(app, "admin", "pw");
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/setup/test-relay",
|
||||
headers: { cookie }, // no x-csrf-token
|
||||
payload: { id: CTL, relay: 1 },
|
||||
});
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it("signs a barrier_open_command (reason setup.relayTest) BEFORE firing, then reports ok", async () => {
|
||||
await seedUser(db, { username: "admin", password: "pw" });
|
||||
const auth = await login(app, "admin", "pw");
|
||||
|
||||
const res = await pulse({ id: CTL, relay: 2 }, auth);
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json()).toMatchObject({ ok: true });
|
||||
|
||||
// The deliberate open is EXPLAINED in the signed ledger — not an anomaly.
|
||||
const rows = db.select().from(ledgerEvents).all();
|
||||
const testOpen = rows.find((r) => r.type === "barrier_open_command");
|
||||
expect(testOpen, "a barrier_open_command must be signed").toBeTruthy();
|
||||
expect(testOpen!.source).toBe("manual"); // deliberate human action
|
||||
expect(testOpen!.signature.length).toBeGreaterThan(0);
|
||||
const payload = testOpen!.payload as Record<string, unknown>;
|
||||
expect(payload.relayTest).toBe(true);
|
||||
expect(payload.reasonCode).toBe("setup.relayTest");
|
||||
expect(payload.relay).toBe(2);
|
||||
expect(payload.controllerId).toBe(CTL);
|
||||
expect(payload.operator).toBe("admin"); // attributed to the acting admin
|
||||
});
|
||||
|
||||
it("rejects a relay the controller does not declare (400, no ledger row)", async () => {
|
||||
await seedUser(db, { username: "admin", password: "pw" });
|
||||
const auth = await login(app, "admin", "pw");
|
||||
const res = await pulse({ id: CTL, relay: 9 }, auth);
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(db.select().from(ledgerEvents).all()).toHaveLength(0); // nothing signed
|
||||
});
|
||||
|
||||
it("404s an unknown controller id", async () => {
|
||||
await seedUser(db, { username: "admin", password: "pw" });
|
||||
const auth = await login(app, "admin", "pw");
|
||||
const res = await pulse({ id: "nope", relay: 1 }, auth);
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it("rejects a bad relay value (non-positive-integer)", async () => {
|
||||
await seedUser(db, { username: "admin", password: "pw" });
|
||||
const auth = await login(app, "admin", "pw");
|
||||
expect((await pulse({ id: CTL, relay: 0 }, auth)).statusCode).toBe(400);
|
||||
expect((await pulse({ id: CTL, relay: -1 }, auth)).statusCode).toBe(400);
|
||||
});
|
||||
});
|
||||
@@ -15,7 +15,9 @@ import {
|
||||
type DeviceCategory,
|
||||
type DeviceConfig,
|
||||
} from "@parking/devices";
|
||||
import { reasonPayload } from "@parking/shared";
|
||||
import { requirePermission } from "../auth.js";
|
||||
import type { EventLog } from "../event-log.js";
|
||||
import { backendIpCandidates, backendIpForDevice, backendPort } from "../net.js";
|
||||
import type { VisionClient } from "../vision-client.js";
|
||||
|
||||
@@ -60,6 +62,12 @@ function redactSecrets(config: Record<string, unknown>): Record<string, unknown>
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Feature-detect the barrier-pulse capability on a built device adapter (the Setup
|
||||
* relay test needs it; a stub/reader/camera won't have it). */
|
||||
function hasPulseOpen(d: unknown): d is { pulseOpen(doorId: number): Promise<void> } {
|
||||
return typeof (d as { pulseOpen?: unknown } | null)?.pulseOpen === "function";
|
||||
}
|
||||
|
||||
// Connection-identity keys: the fields that decide WHERE a probe is sent. A stored
|
||||
// secret may only be re-merged when these match the stored row — otherwise an admin
|
||||
// could point a test at an attacker host while keeping a real device id and have the
|
||||
@@ -220,6 +228,7 @@ export async function setupRoutes(
|
||||
app: FastifyInstance,
|
||||
db: Db,
|
||||
vision?: VisionClient | null,
|
||||
eventLog?: EventLog | null,
|
||||
): Promise<void> {
|
||||
registerBuiltinDrivers();
|
||||
setDeviceLogSink((line) => app.log.info(line));
|
||||
@@ -456,6 +465,89 @@ export async function setupRoutes(
|
||||
},
|
||||
);
|
||||
|
||||
// PULSE a controller's barrier relay from Setup, to test the wiring — WITHOUT any
|
||||
// vehicle/session. This physically opens the barrier, so unlike the other tests it
|
||||
// runs only against a SAVED controller (real id → clean attribution) and it SIGNS a
|
||||
// `barrier_open_command` into the ledger FIRST, with reason `setup.relayTest` + the
|
||||
// admin's identity. That is the whole point of doing it this way: a physical open with
|
||||
// no matching signed command is the fraud signal ([[append-only-event-chain]],
|
||||
// [[reconciliation]]) — a deliberate test must therefore be an EXPLAINED open, not a
|
||||
// silent one. Sign-before-fire mirrors exit-flow's manual re-open: the intervention is
|
||||
// recorded whether or not the physical pulse then succeeds. Admin-only (site:update).
|
||||
app.post<{ Body: { id: string; relay: number } }>(
|
||||
"/api/setup/test-relay",
|
||||
{ preHandler: adminGuard },
|
||||
async (req, reply) => {
|
||||
const { id, relay } = req.body;
|
||||
if (typeof id !== "string" || !id) return reply.code(400).send({ error: "missing controller id" });
|
||||
if (!Number.isInteger(relay) || relay < 1) {
|
||||
return reply.code(400).send({ error: "relay must be a 1-based channel number" });
|
||||
}
|
||||
|
||||
// A relay test fires REAL hardware, so it must target a persisted controller — no
|
||||
// firing an unsaved/redirected config (that would let a probe open an arbitrary host's
|
||||
// barrier). Load the saved row and build straight from its stored config (relayPassword
|
||||
// included — it's on the row, never in the request).
|
||||
const row = db.select().from(devices).where(eq(devices.id, id)).get();
|
||||
if (!row) return reply.code(404).send({ error: "controller not found" });
|
||||
if (row.category !== "access") {
|
||||
return reply.code(400).send({ error: `device ${id} is not a controller` });
|
||||
}
|
||||
const cfg = (row.config ?? {}) as Record<string, unknown>;
|
||||
const relays = Array.isArray(cfg.relays) ? (cfg.relays as { relay?: number }[]) : [];
|
||||
if (!relays.some((r) => r.relay === relay)) {
|
||||
return reply.code(400).send({ error: `controller ${id} has no relay ${relay}` });
|
||||
}
|
||||
|
||||
let device;
|
||||
try {
|
||||
device = registry.create(row.driverId, cfg as Record<string, string | number | boolean>);
|
||||
} catch (err) {
|
||||
return reply.code(400).send({ error: (err as Error).message });
|
||||
}
|
||||
if (!hasPulseOpen(device)) {
|
||||
return reply.code(400).send({ error: `driver ${row.driverId} cannot pulse a relay` });
|
||||
}
|
||||
|
||||
// Sign the deliberate open FIRST — recorded whether or not the physical pulse then
|
||||
// succeeds. Skip only if no ledger is wired (test/degraded), in which case we still
|
||||
// refuse rather than fire an unrecorded open.
|
||||
const operator = req.user?.username ?? "unknown";
|
||||
if (!eventLog) {
|
||||
return reply.code(503).send({ error: "ledger unavailable — refusing an unrecorded relay open" });
|
||||
}
|
||||
await eventLog.append({
|
||||
type: "barrier_open_command",
|
||||
// A deliberate human action from the admin console → "manual" (the top-level
|
||||
// IdentitySource). The relayTest marker + reason distinguish it in the payload.
|
||||
source: "manual",
|
||||
identity: `relay-test:${id}:${relay}`,
|
||||
payload: {
|
||||
...reasonPayload("setup.relayTest", { operator, relay, controller: row.driverId }),
|
||||
relayTest: true,
|
||||
controllerId: id,
|
||||
relay,
|
||||
operator,
|
||||
},
|
||||
});
|
||||
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
await device.pulseOpen(relay);
|
||||
} catch (err) {
|
||||
// The failure we're testing for (relay unreachable, wrong password). The open is
|
||||
// already signed; report the pulse failure, don't 500.
|
||||
return reply.send({
|
||||
ok: false,
|
||||
reason: "pulse-failed",
|
||||
detail: (err as Error).message,
|
||||
tookMs: Date.now() - startedAt,
|
||||
});
|
||||
}
|
||||
return reply.send({ ok: true, firedAt: new Date().toISOString(), tookMs: Date.now() - startedAt });
|
||||
},
|
||||
);
|
||||
|
||||
// Candidate backend IPs the device can push to, for a given device host. The
|
||||
// wizard pre-fills with the on-subnet one and lets the admin override (matters
|
||||
// on multi-NIC hosts). See net.ts / wiki/concepts/device-input-flow.md.
|
||||
@@ -468,6 +560,39 @@ export async function setupRoutes(
|
||||
},
|
||||
);
|
||||
|
||||
// USB printers PRESENT on the box: enumerate /dev/usb/lpN (the usblp nodes the
|
||||
// container sees via the /dev/usb bind-mount) and enrich each with the printer's
|
||||
// self-reported make/model from sysfs (ieee1284_id — readable through Docker's
|
||||
// default ro /sys). The wizard offers these as a SELECT so the admin never has to
|
||||
// shell in and `ls /dev/usb` to learn the kernel picked lp1 (field friction,
|
||||
// park-buzi 2026-07-07). Empty list = no usblp printer plugged/visible.
|
||||
app.get("/api/setup/usb-printers", { preHandler: adminGuard }, async () => {
|
||||
const { readdir, readFile } = await import("node:fs/promises");
|
||||
let names: string[] = [];
|
||||
try {
|
||||
names = (await readdir("/dev/usb")).filter((n) => /^lp\d+$/.test(n)).sort();
|
||||
} catch {
|
||||
return { printers: [] }; // no /dev/usb at all — nothing plugged (or no mount)
|
||||
}
|
||||
const printers = await Promise.all(
|
||||
names.map(async (n) => {
|
||||
// ieee1284_id: "MFG:Xprinter;CMD:ESCPOS;MDL:XP-K200L;…" — best-effort.
|
||||
let description: string | null = null;
|
||||
try {
|
||||
const id = await readFile(`/sys/class/usbmisc/${n}/device/ieee1284_id`, "utf8");
|
||||
const pick = (key: string) => id.match(new RegExp(`(?:^|;)\\s*${key}:([^;]+)`, "i"))?.[1]?.trim();
|
||||
const mfg = pick("MFG") ?? pick("MANUFACTURER");
|
||||
const mdl = pick("MDL") ?? pick("MODEL");
|
||||
description = [mfg, mdl].filter(Boolean).join(" ") || null;
|
||||
} catch {
|
||||
/* sysfs not readable / attribute absent — path alone is still useful */
|
||||
}
|
||||
return { path: `/dev/usb/${n}`, description };
|
||||
}),
|
||||
);
|
||||
return { printers };
|
||||
});
|
||||
|
||||
// Assign a device. Validates the chosen driver + config, configures the device
|
||||
// (fix preconditions + set up Digest-authenticated input push — no manual device-
|
||||
// web-UI step by the admin), then persists. Fails the save if the device can't be
|
||||
|
||||
@@ -1,27 +1,6 @@
|
||||
import bcrypt from "bcrypt";
|
||||
import { eq, users, type Db } from "@parking/db";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { requirePermission, roleHasPermissions } from "../auth.js";
|
||||
import {
|
||||
InvalidCashMovementError,
|
||||
NoOpenShiftError,
|
||||
ShiftAlreadyOpenError,
|
||||
type ShiftService,
|
||||
} from "../shift-service.js";
|
||||
|
||||
interface CashVoucherBody {
|
||||
/** Direction is the document TYPE, not a sign: cash_in = Mandat Arkëtimi (pay-IN),
|
||||
* cash_out = Mandat Pagese (pay-OUT). */
|
||||
type: "cash_in" | "cash_out";
|
||||
/** POSITIVE minor units (magnitude). The direction comes from `type`. */
|
||||
amountMinor: number;
|
||||
reason?: string;
|
||||
currency?: string;
|
||||
/** The admin who authorizes this voucher (operator-raised / admin-authorized). */
|
||||
authorizedBy: string;
|
||||
/** That admin's password — re-entered to sign off on the drawer movement. */
|
||||
authorizerPassword: string;
|
||||
}
|
||||
import { NoOpenShiftError, ShiftAlreadyOpenError, type ShiftService } from "../shift-service.js";
|
||||
|
||||
interface ShiftsQuery {
|
||||
/** Filter to one operator (admin-only; non-admins are forced to themselves). */
|
||||
@@ -35,7 +14,7 @@ interface ShiftsQuery {
|
||||
// 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.
|
||||
|
||||
export async function shiftRoutes(app: FastifyInstance, shift: ShiftService, db: Db): Promise<void> {
|
||||
export async function shiftRoutes(app: FastifyInstance, 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");
|
||||
@@ -84,52 +63,14 @@ export async function shiftRoutes(app: FastifyInstance, shift: ShiftService, db:
|
||||
const from = canSeeAll ? q.from?.trim() || undefined : undefined;
|
||||
const to = canSeeAll ? q.to?.trim() || undefined : undefined;
|
||||
const shifts = shift.listShifts({ operator, from, to });
|
||||
return { shifts, scope: canSeeAll ? "all" : "self" };
|
||||
// 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" };
|
||||
});
|
||||
|
||||
// Drawer cash VOUCHER — Mandat Arkëtimi (cash_in / pay-IN) or Mandat Pagese
|
||||
// (cash_out / pay-OUT). The direction is the document TYPE, not a signed amount.
|
||||
// OPERATOR-RAISED, ADMIN-AUTHORIZED: any holder of `shift:create` (operator-grade)
|
||||
// may RAISE the voucher, but it only commits if `authorizedBy` is a real admin
|
||||
// (`shift:cash`) who re-enters their password. This keeps the float control —
|
||||
// an operator cannot move the float alone — while letting them raise the slip.
|
||||
// See wiki/concepts/shift.md.
|
||||
app.post<{ Body: CashVoucherBody }>(
|
||||
"/api/cash-voucher",
|
||||
{ preHandler: guard },
|
||||
async (req, reply) => {
|
||||
const b = req.body ?? ({} as CashVoucherBody);
|
||||
if (b.type !== "cash_in" && b.type !== "cash_out") {
|
||||
return reply.code(400).send({ error: "type must be cash_in or cash_out" });
|
||||
}
|
||||
const authName = (b.authorizedBy ?? "").trim();
|
||||
if (!authName || !b.authorizerPassword) {
|
||||
return reply.code(400).send({ error: "authorizedBy and authorizerPassword are required" });
|
||||
}
|
||||
// Verify the authorizer: a real user, admin-grade (shift:cash), correct password.
|
||||
const authUser = await db.select().from(users).where(eq(users.username, authName)).get();
|
||||
// Always run a bcrypt compare (constant-time wrt whether the user exists).
|
||||
const hash = authUser?.passwordHash ?? "$2b$10$invalidinvalidinvalidinvalidinvalidinvalidinv";
|
||||
const passwordOk = await bcrypt.compare(b.authorizerPassword, hash);
|
||||
const isAdminGrade = authUser != null && roleHasPermissions(authUser.roleId, ["shift:cash"]);
|
||||
if (!authUser || !passwordOk || !isAdminGrade) {
|
||||
return reply.code(403).send({ error: "authorizer must be an admin with a correct password" });
|
||||
}
|
||||
try {
|
||||
return await shift.recordVoucher({
|
||||
type: b.type,
|
||||
operator: req.user.username, // who RAISED it
|
||||
authorizedBy: authUser.username, // who signed off (canonical case)
|
||||
amountMinor: b.amountMinor,
|
||||
reason: b.reason ?? "",
|
||||
currency: b.currency,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof InvalidCashMovementError) return reply.code(400).send({ error: err.message });
|
||||
return reply.code(500).send({ error: (err as Error).message });
|
||||
}
|
||||
},
|
||||
);
|
||||
// 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) => {
|
||||
try {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { eq, siteConfig, type Db } from "@parking/db";
|
||||
import { requirePermission } from "../auth.js";
|
||||
import type { EventLog } from "../event-log.js";
|
||||
import { getOccupancy } from "../occupancy.js";
|
||||
|
||||
// Site config (capacity) + live occupancy. Occupancy is a fold over the signed
|
||||
@@ -38,13 +39,15 @@ interface SiteConfigBody extends Partial<Record<TextField, string | null>> {
|
||||
}
|
||||
|
||||
/** Shape returned by GET/PUT: capacity + the booth flag + the subscription default
|
||||
* + every metadata field. */
|
||||
* + the entry presence-bypass flags + every metadata field. */
|
||||
type SiteConfig = {
|
||||
capacity: number | null;
|
||||
exitVoucherDefault: boolean;
|
||||
subscriptionMonthlyPriceMinor: number | null;
|
||||
reserveSubscriberSpots: boolean;
|
||||
anprEntryEnabled: boolean;
|
||||
bypassPresenceRadar: boolean;
|
||||
bypassPresenceCamera: boolean;
|
||||
} & Record<TextField, string | null>;
|
||||
|
||||
function toSiteConfig(row: typeof siteConfig.$inferSelect | undefined): SiteConfig {
|
||||
@@ -54,6 +57,8 @@ function toSiteConfig(row: typeof siteConfig.$inferSelect | undefined): SiteConf
|
||||
subscriptionMonthlyPriceMinor: row?.subscriptionMonthlyPriceMinor ?? null,
|
||||
reserveSubscriberSpots: row?.reserveSubscriberSpots ?? false,
|
||||
anprEntryEnabled: row?.anprEntryEnabled ?? true,
|
||||
bypassPresenceRadar: row?.bypassPresenceRadar ?? false,
|
||||
bypassPresenceCamera: row?.bypassPresenceCamera ?? false,
|
||||
} as SiteConfig;
|
||||
for (const f of TEXT_FIELDS) out[f] = row?.[f] ?? null;
|
||||
return out;
|
||||
@@ -66,13 +71,22 @@ function normText(v: unknown): string | null {
|
||||
return s === "" ? null : s;
|
||||
}
|
||||
|
||||
export async function siteRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||
export async function siteRoutes(app: FastifyInstance, db: Db, eventLog?: EventLog | null): Promise<void> {
|
||||
const readGuard = requirePermission("site:read");
|
||||
const writeGuard = requirePermission("site:update");
|
||||
|
||||
// Live occupancy: cars inside, capacity, free, full. Any signed-in role.
|
||||
app.get("/api/occupancy", { preHandler: readGuard }, async () => getOccupancy(db));
|
||||
|
||||
// Running build version ("<branch>-<short-sha>", matching the Komodo Stack's TAG in
|
||||
// komodo/resources.toml) — baked in at image build time (apps/server/Dockerfile
|
||||
// BUILD_VERSION ARG), read here from the running process env. null on a local/dev
|
||||
// build with no CI-supplied value. Purely informational (Setup nav display); not
|
||||
// site config, so it isn't stored in site_config.
|
||||
app.get("/api/version", { preHandler: readGuard }, async () => ({
|
||||
buildVersion: process.env.BUILD_VERSION?.trim() || null,
|
||||
}));
|
||||
|
||||
// Read site config (capacity + park metadata).
|
||||
app.get("/api/site-config", { preHandler: readGuard }, async () => {
|
||||
const row = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||
@@ -131,4 +145,64 @@ export async function siteRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||
const row = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||
return toSiteConfig(row);
|
||||
});
|
||||
|
||||
// Entry presence-gate BYPASS — a DEDICATED, SIGNED endpoint (not the generic PUT above),
|
||||
// because dropping a radar/camera requirement weakens an anti-fraud gate. The admin is not
|
||||
// the adversary (a faulty device blocks legit entry until support fixes it), but the change
|
||||
// must be attributed + auditable: each toggled signal appends a signed `config_change`
|
||||
// {setting, value, prev, operator}. Granular per signal. See wiki/concepts/entry-presence-bypass.md.
|
||||
app.put<{ Body: { radar?: boolean; camera?: boolean } }>(
|
||||
"/api/site-config/presence-bypass",
|
||||
{ preHandler: writeGuard },
|
||||
async (req, reply) => {
|
||||
const body = req.body ?? {};
|
||||
for (const k of ["radar", "camera"] as const) {
|
||||
if (k in body && typeof body[k] !== "boolean") {
|
||||
return reply.code(400).send({ error: `${k} must be a boolean` });
|
||||
}
|
||||
}
|
||||
if (!("radar" in body) && !("camera" in body)) {
|
||||
return reply.code(400).send({ error: "nothing to change (send radar and/or camera)" });
|
||||
}
|
||||
|
||||
const existing = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||
const prev = {
|
||||
radar: existing?.bypassPresenceRadar ?? false,
|
||||
camera: existing?.bypassPresenceCamera ?? false,
|
||||
};
|
||||
const next = {
|
||||
radar: "radar" in body ? (body.radar as boolean) : prev.radar,
|
||||
camera: "camera" in body ? (body.camera as boolean) : prev.camera,
|
||||
};
|
||||
|
||||
// Sign a config_change for each signal that ACTUALLY changed (before persisting, so the
|
||||
// audit record exists whether or not a later write hiccups). No-op toggles sign nothing.
|
||||
const operator = req.user?.username ?? "unknown";
|
||||
for (const signal of ["radar", "camera"] as const) {
|
||||
if (next[signal] !== prev[signal]) {
|
||||
await eventLog?.append({
|
||||
type: "config_change",
|
||||
source: "manual",
|
||||
identity: `presence-bypass:${signal}`,
|
||||
payload: {
|
||||
setting: `entryPresenceBypass.${signal}`,
|
||||
value: next[signal],
|
||||
prev: prev[signal],
|
||||
operator,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const updatedAt = new Date().toISOString();
|
||||
const patch = { bypassPresenceRadar: next.radar, bypassPresenceCamera: next.camera, updatedAt };
|
||||
if (existing) {
|
||||
db.update(siteConfig).set(patch).where(eq(siteConfig.id, 1)).run();
|
||||
} else {
|
||||
db.insert(siteConfig).values({ id: 1, ...patch }).run();
|
||||
}
|
||||
const row = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||
return toSiteConfig(row);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { and, desc, eq, deviceEvents, snapshots, type Db } from "@parking/db";
|
||||
import { requirePermission } from "../auth.js";
|
||||
import { cleanType } from "../snapshot.js";
|
||||
|
||||
// Read access to captured entry/exit snapshots (the BLOB-in-DB image store, see
|
||||
// packages/db schema + wiki/concepts/lane-direction.md). Snapshots are evidence
|
||||
@@ -116,7 +117,10 @@ export async function snapshotRoutes(app: FastifyInstance, db: Db): Promise<void
|
||||
async (req, reply) => {
|
||||
const row = db.select().from(snapshots).where(eq(snapshots.id, req.params.id)).get();
|
||||
if (!row) return reply.code(404).send({ error: "no such snapshot" });
|
||||
reply.header("content-type", row.contentType);
|
||||
// Normalize on the way OUT too: legacy rows stored a camera's malformed
|
||||
// `image/jpeg; charset="UTF-8"`, which browsers refuse to render. cleanType strips
|
||||
// the bogus params back to a bare `image/jpeg` so every stored image displays.
|
||||
reply.header("content-type", cleanType(row.contentType));
|
||||
reply.header("cache-control", "private, max-age=31536000, immutable");
|
||||
return reply.send(row.bytes);
|
||||
},
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { createTestDb } from "@parking/db/testing";
|
||||
import { type Db } from "@parking/db";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { buildServer } from "../server.js";
|
||||
import { seedUser, login } from "../test-helpers.js";
|
||||
|
||||
// Tariff-lab drafts: the MUTABLE experiment scratchpad next to the immutable
|
||||
// published versions. The contract under test: drafts are validated + tz-stamped on
|
||||
// save exactly like a publish (so "publish this draft" can never fail on a card that
|
||||
// saved fine), mutations need tariff:update, and publishing a draft goes through the
|
||||
// normal immutable-version path untouched.
|
||||
|
||||
let db: Db;
|
||||
let close: () => void;
|
||||
let app: FastifyInstance;
|
||||
|
||||
beforeEach(async () => {
|
||||
const t = createTestDb();
|
||||
db = t.db;
|
||||
close = t.close;
|
||||
app = await buildServer({ db });
|
||||
await app.ready();
|
||||
});
|
||||
afterEach(async () => {
|
||||
await app.close();
|
||||
close();
|
||||
});
|
||||
|
||||
const V1_STRUCTURE = {
|
||||
gracePeriodEntryMin: 5,
|
||||
incrementMin: 60,
|
||||
lostTicketMinor: 2000,
|
||||
gracePeriodExitMin: 10,
|
||||
overstay: "reprice",
|
||||
blocks: [{ uptoMin: null, priceMinorPerIncrement: 200 }],
|
||||
dailyCapMinor: null,
|
||||
};
|
||||
|
||||
// A V2 card with a night package — tz left blank on purpose: the server must stamp it.
|
||||
const V2_STRUCTURE = {
|
||||
version: 2,
|
||||
tz: "",
|
||||
gracePeriodEntryMin: 5,
|
||||
incrementMin: 60,
|
||||
lostTicketMinor: 2000,
|
||||
gracePeriodExitMin: 10,
|
||||
overstay: "reprice",
|
||||
defaultCard: { name: "default", priority: 0, blocks: [{ uptoMin: null, priceMinorPerIncrement: 200 }], dailyCapMinor: null },
|
||||
windowedCards: [{ name: "night", priority: 10, window: { fromHour: "20:00", toHour: "07:00" }, packageMinor: 40000 }],
|
||||
};
|
||||
|
||||
async function editor() {
|
||||
const { username, password } = await seedUser(db, {
|
||||
username: "editor",
|
||||
roleId: "editor",
|
||||
permissions: ["tariff:read", "tariff:update"],
|
||||
});
|
||||
return login(app, username, password);
|
||||
}
|
||||
|
||||
describe("tariff drafts", () => {
|
||||
it("requires auth", async () => {
|
||||
const res = await app.inject({ method: "GET", url: "/api/tariff/drafts" });
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it("a tariff:read-only user can list but not create", async () => {
|
||||
const { username, password } = await seedUser(db, {
|
||||
username: "viewer",
|
||||
roleId: "viewer",
|
||||
permissions: ["tariff:read"],
|
||||
});
|
||||
const { cookie, csrf } = await login(app, username, password);
|
||||
|
||||
const list = await app.inject({ method: "GET", url: "/api/tariff/drafts", headers: { cookie } });
|
||||
expect(list.statusCode).toBe(200);
|
||||
expect(list.json().drafts).toEqual([]);
|
||||
|
||||
const create = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/tariff/drafts",
|
||||
headers: { cookie, "x-csrf-token": csrf },
|
||||
payload: { name: "x", currency: "ALL", structure: V1_STRUCTURE },
|
||||
});
|
||||
expect(create.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it("create → list → update → delete roundtrip", async () => {
|
||||
const { cookie, csrf } = await editor();
|
||||
const headers = { cookie, "x-csrf-token": csrf };
|
||||
|
||||
const create = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/tariff/drafts",
|
||||
headers,
|
||||
payload: { name: "Winter proposal", currency: "all", structure: V1_STRUCTURE },
|
||||
});
|
||||
expect(create.statusCode).toBe(201);
|
||||
const draft = create.json();
|
||||
expect(draft.name).toBe("Winter proposal");
|
||||
expect(draft.currency).toBe("ALL"); // normalised to upper case
|
||||
expect(draft.createdBy).toBe("editor");
|
||||
|
||||
const list = await app.inject({ method: "GET", url: "/api/tariff/drafts", headers: { cookie } });
|
||||
expect(list.json().drafts).toHaveLength(1);
|
||||
|
||||
const update = await app.inject({
|
||||
method: "PUT",
|
||||
url: `/api/tariff/drafts/${draft.id}`,
|
||||
headers,
|
||||
payload: { name: "Winter v2", currency: "ALL", structure: V1_STRUCTURE },
|
||||
});
|
||||
expect(update.statusCode).toBe(200);
|
||||
expect(update.json().name).toBe("Winter v2");
|
||||
|
||||
const del = await app.inject({ method: "DELETE", url: `/api/tariff/drafts/${draft.id}`, headers });
|
||||
expect(del.statusCode).toBe(204);
|
||||
const after = await app.inject({ method: "GET", url: "/api/tariff/drafts", headers: { cookie } });
|
||||
expect(after.json().drafts).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects an invalid structure with problems (validated like a publish)", async () => {
|
||||
const { cookie, csrf } = await editor();
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/tariff/drafts",
|
||||
headers: { cookie, "x-csrf-token": csrf },
|
||||
payload: { name: "broken", currency: "ALL", structure: { ...V1_STRUCTURE, blocks: [] } },
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.json().problems?.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("stamps the site timezone on a V2 draft, and the draft simulates + publishes as-is", async () => {
|
||||
const { cookie, csrf } = await editor();
|
||||
const headers = { cookie, "x-csrf-token": csrf };
|
||||
|
||||
const create = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/tariff/drafts",
|
||||
headers,
|
||||
payload: { name: "Night package", currency: "ALL", structure: V2_STRUCTURE },
|
||||
});
|
||||
expect(create.statusCode).toBe(201);
|
||||
const draft = create.json();
|
||||
expect(draft.structure.tz).toBe("Europe/Tirane");
|
||||
|
||||
// The lab prices the draft by sending its stored structure inline.
|
||||
const sim = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/tariff/simulate",
|
||||
headers,
|
||||
payload: {
|
||||
enteredAt: "2026-07-03T21:00:00.000+02:00",
|
||||
asOf: "2026-07-03T23:00:00.000+02:00",
|
||||
structure: draft.structure,
|
||||
currency: draft.currency,
|
||||
},
|
||||
});
|
||||
expect(sim.statusCode).toBe(200);
|
||||
expect(sim.json().pricing.amountMinor).toBe(40000); // one night package
|
||||
|
||||
// "Publish this draft" = the normal immutable-version path with the draft's card;
|
||||
// the draft's name rides along as the version's optional label.
|
||||
const publish = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/tariff/versions",
|
||||
headers,
|
||||
payload: { currency: draft.currency, structure: draft.structure, name: draft.name },
|
||||
});
|
||||
expect(publish.statusCode).toBe(201);
|
||||
const state = await app.inject({ method: "GET", url: "/api/tariff", headers: { cookie } });
|
||||
expect(state.json().active?.name).toBe("Night package");
|
||||
expect(state.json().active?.structure?.windowedCards?.[0]?.packageMinor).toBe(40000);
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,9 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { and, desc, eq, isNull, ledgerEvents, siteConfig, tariffVersions, tariffs, type Db } from "@parking/db";
|
||||
import { and, desc, eq, isNull, ledgerEvents, siteConfig, tariffDrafts, tariffVersions, tariffs, type Db } from "@parking/db";
|
||||
import {
|
||||
computeFee,
|
||||
explainFee,
|
||||
isTariffV2,
|
||||
priceSession,
|
||||
validateTariffStructure,
|
||||
@@ -25,10 +26,19 @@ interface PublishBody {
|
||||
structure: TariffStructure;
|
||||
/** When this version takes effect (ISO-8601). Defaults to now. */
|
||||
effectiveFrom?: string;
|
||||
/** Optional human label (e.g. carried from the lab draft being published). */
|
||||
name?: string;
|
||||
}
|
||||
|
||||
const SITE_TARIFF_NAME = "Site tariff";
|
||||
|
||||
/** Body for saving a lab draft (create + update share the shape). */
|
||||
interface DraftBody {
|
||||
name: string;
|
||||
currency: string;
|
||||
structure: TariffStructure;
|
||||
}
|
||||
|
||||
/** Body for POST /api/tariff/simulate — price a hypothetical session, no ledger write.
|
||||
* Provide a structure source (one of): `tariffVersionId`, inline `structure`, or
|
||||
* neither (uses the active version). */
|
||||
@@ -80,19 +90,14 @@ export async function tariffRoutes(app: FastifyInstance, db: Db): Promise<void>
|
||||
"/api/tariff/versions",
|
||||
{ preHandler: writeGuard },
|
||||
async (req, reply) => {
|
||||
const { currency, structure, effectiveFrom } = req.body ?? ({} as PublishBody);
|
||||
const { currency, structure, effectiveFrom, name } = req.body ?? ({} as PublishBody);
|
||||
if (!currency || typeof currency !== "string" || currency.length < 3) {
|
||||
return reply.code(400).send({ error: "currency (ISO 4217) required" });
|
||||
}
|
||||
// For a windowed (V2) structure, stamp the wall-clock timezone from SITE config
|
||||
// (not the client) BEFORE validating — so the frozen tz is authoritative and the
|
||||
// validation that requires tz passes. A V1 (bare) structure is left untouched.
|
||||
let toStore: TariffStructure = structure;
|
||||
if (structure && isTariffV2(structure)) {
|
||||
const cfg = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||
const tz = cfg?.timezone && cfg.timezone.length > 0 ? cfg.timezone : DEFAULT_TZ;
|
||||
toStore = { ...structure, tz };
|
||||
}
|
||||
const toStore = stampSiteTz(structure);
|
||||
|
||||
const problems = validateTariffStructure(toStore);
|
||||
if (problems.length) {
|
||||
@@ -128,6 +133,7 @@ export async function tariffRoutes(app: FastifyInstance, db: Db): Promise<void>
|
||||
const row = {
|
||||
id,
|
||||
tariffId,
|
||||
name: typeof name === "string" && name.trim() ? name.trim() : null,
|
||||
effectiveFrom: effective,
|
||||
currency,
|
||||
structure: toStore as unknown as Record<string, unknown>,
|
||||
@@ -183,6 +189,12 @@ export async function tariffRoutes(app: FastifyInstance, db: Db): Promise<void>
|
||||
const payments = Array.isArray(b.payments) ? b.payments : [];
|
||||
const pricing = priceSession(b.enteredAt, b.asOf, structure, payments, b.category);
|
||||
|
||||
// HOW the amount is produced — the same engine walk with a trace collector
|
||||
// (Σ lines ≡ amountMinor by construction). Null when settled (nothing billed).
|
||||
const breakdown = pricing.withinGrace
|
||||
? null
|
||||
: explainFee(pricing.periodStart, b.asOf, structure, b.category);
|
||||
|
||||
// A duration curve from entry: handy to SEE where the cap flattens / windows shift.
|
||||
const SAMPLES_MIN = [30, 60, 120, 180, 360, 720, 1440, 2880, 4320];
|
||||
const enteredMs = Date.parse(b.enteredAt);
|
||||
@@ -191,7 +203,7 @@ export async function tariffRoutes(app: FastifyInstance, db: Db): Promise<void>
|
||||
amountMinor: computeFee(b.enteredAt, new Date(enteredMs + min * 60_000).toISOString(), structure!, b.category),
|
||||
}));
|
||||
|
||||
return { currency, pricing, curve, gracePeriodExitMin: structure.gracePeriodExitMin };
|
||||
return { currency, pricing, breakdown, curve, gracePeriodExitMin: structure.gracePeriodExitMin };
|
||||
});
|
||||
|
||||
// Prefill the lab from a REAL session: fold its ledger into entry + payments so the
|
||||
@@ -230,6 +242,92 @@ export async function tariffRoutes(app: FastifyInstance, db: Db): Promise<void>
|
||||
},
|
||||
);
|
||||
|
||||
// --- Lab drafts ---------------------------------------------------------------
|
||||
// The lab's scratchpad: MUTABLE experimental rate cards (see tariff_drafts in the
|
||||
// schema for why mutability is safe here — a draft prices nothing and signs
|
||||
// nothing). Saved drafts are validated + tz-stamped exactly like a publish, so the
|
||||
// simulator can always price them and "publish this draft" can never surprise the
|
||||
// admin with a card that saved fine but won't go live. Publishing a draft is just
|
||||
// POST /api/tariff/versions with the draft's structure — same guard, same
|
||||
// validation, same immutability.
|
||||
app.get("/api/tariff/drafts", { preHandler: readGuard }, async () => {
|
||||
const drafts = db.select().from(tariffDrafts).orderBy(desc(tariffDrafts.updatedAt)).all();
|
||||
return { drafts };
|
||||
});
|
||||
|
||||
app.post<{ Body: DraftBody }>("/api/tariff/drafts", { preHandler: writeGuard }, async (req, reply) => {
|
||||
const parsed = parseDraftBody(req.body);
|
||||
if ("error" in parsed) return reply.code(400).send(parsed);
|
||||
const now = new Date().toISOString();
|
||||
const row = {
|
||||
id: randomUUID(),
|
||||
name: parsed.name,
|
||||
currency: parsed.currency,
|
||||
structure: parsed.structure as unknown as Record<string, unknown>,
|
||||
createdBy: req.user?.username ?? null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
db.insert(tariffDrafts).values(row).run();
|
||||
return reply.code(201).send(row);
|
||||
});
|
||||
|
||||
app.put<{ Params: { id: string }; Body: DraftBody }>(
|
||||
"/api/tariff/drafts/:id",
|
||||
{ preHandler: writeGuard },
|
||||
async (req, reply) => {
|
||||
const existing = db.select().from(tariffDrafts).where(eq(tariffDrafts.id, req.params.id)).get();
|
||||
if (!existing) return reply.code(404).send({ error: "draft not found" });
|
||||
const parsed = parseDraftBody(req.body);
|
||||
if ("error" in parsed) return reply.code(400).send(parsed);
|
||||
const patch = {
|
||||
name: parsed.name,
|
||||
currency: parsed.currency,
|
||||
structure: parsed.structure as unknown as Record<string, unknown>,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
db.update(tariffDrafts).set(patch).where(eq(tariffDrafts.id, existing.id)).run();
|
||||
return { ...existing, ...patch };
|
||||
},
|
||||
);
|
||||
|
||||
app.delete<{ Params: { id: string } }>(
|
||||
"/api/tariff/drafts/:id",
|
||||
{ preHandler: writeGuard },
|
||||
async (req, reply) => {
|
||||
const existing = db.select().from(tariffDrafts).where(eq(tariffDrafts.id, req.params.id)).get();
|
||||
if (!existing) return reply.code(404).send({ error: "draft not found" });
|
||||
db.delete(tariffDrafts).where(eq(tariffDrafts.id, existing.id)).run();
|
||||
return reply.code(204).send();
|
||||
},
|
||||
);
|
||||
|
||||
/** Validate + normalise a draft save body; tz-stamps V2 structures like a publish. */
|
||||
function parseDraftBody(
|
||||
body: DraftBody | undefined,
|
||||
): { name: string; currency: string; structure: TariffStructure } | { error: string; problems?: string[] } {
|
||||
const b = body ?? ({} as DraftBody);
|
||||
const name = (b.name ?? "").trim();
|
||||
if (!name) return { error: "name required" };
|
||||
const currency = (b.currency ?? "").trim().toUpperCase();
|
||||
if (currency.length < 3) return { error: "currency (ISO 4217) required" };
|
||||
const structure = stampSiteTz(b.structure);
|
||||
const problems = validateTariffStructure(structure);
|
||||
if (problems.length) return { error: "invalid tariff structure", problems };
|
||||
return { name, currency, structure };
|
||||
}
|
||||
|
||||
/** Stamp a V2 structure's frozen wall-clock timezone from SITE config (never the
|
||||
* client); a V1 (bare) structure passes through untouched. */
|
||||
function stampSiteTz(structure: TariffStructure): TariffStructure {
|
||||
if (structure && isTariffV2(structure)) {
|
||||
const cfg = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||
const tz = cfg?.timezone && cfg.timezone.length > 0 ? cfg.timezone : DEFAULT_TZ;
|
||||
return { ...structure, tz };
|
||||
}
|
||||
return structure;
|
||||
}
|
||||
|
||||
/** The tariff version in force at a given instant (latest effectiveFrom ≤ when). */
|
||||
function tariffVersionIdFor(whenIso: string): string | null {
|
||||
const tariffId = ensureSiteTariff();
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { eq, ledgerEvents, users, type Db } from "@parking/db";
|
||||
import { createTestDb } from "@parking/db/testing";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { buildServer } from "../server.js";
|
||||
import { login, makeLog, minutesAgo, seedTariff, seedUser } from "../test-helpers.js";
|
||||
import type { EventLog } from "../event-log.js";
|
||||
|
||||
// Merchant validations (bar/lavazh): the merchant user scans a ticket and applies
|
||||
// their program (a SIGNED, attributed ledger event); the booth settlement quotes NET
|
||||
// and the payment CONSUMES the validation ids. These tests pin the route guards
|
||||
// (binding, caps, session state), the signed apply/void events, and the money cycle
|
||||
// through /api/pay/quote + /api/pay. See wiki/concepts/validation-discounts.md.
|
||||
|
||||
let db: Db;
|
||||
let close: () => void;
|
||||
let app: FastifyInstance;
|
||||
|
||||
beforeEach(async () => {
|
||||
const t = createTestDb();
|
||||
db = t.db;
|
||||
close = t.close;
|
||||
app = await buildServer({ db });
|
||||
await app.ready();
|
||||
});
|
||||
afterEach(async () => {
|
||||
await app.close();
|
||||
close();
|
||||
});
|
||||
|
||||
type Auth = { cookie: string; csrf: string };
|
||||
const hdrs = (a: Auth) => ({ cookie: a.cookie, "x-csrf-token": a.csrf });
|
||||
|
||||
async function seedMerchant(username = "bari"): Promise<{ auth: Auth; userId: string }> {
|
||||
await seedUser(db, { username, password: "pw123456", roleId: "validues", permissions: ["validation:create"] });
|
||||
const auth = await login(app, username, "pw123456");
|
||||
const row = db.select().from(users).where(eq(users.username, username)).get()!;
|
||||
return { auth, userId: row.id };
|
||||
}
|
||||
|
||||
async function seedAdmin(): Promise<Auth> {
|
||||
await seedUser(db, { username: "admin", password: "pw123456" });
|
||||
return login(app, "admin", "pw123456");
|
||||
}
|
||||
|
||||
/** Admin-upserts the "bar" program bound to the given user. */
|
||||
async function putProgram(auth: Auth, body: Record<string, unknown>, id = "bar") {
|
||||
return app.inject({ method: "PUT", url: `/api/validation/programs/${id}`, headers: hdrs(auth), payload: body });
|
||||
}
|
||||
|
||||
const fixedProgram = (userId: string, over: Record<string, unknown> = {}) => ({
|
||||
name: "Bar",
|
||||
mode: "fixed",
|
||||
maxAmountMinor: 100000,
|
||||
active: true,
|
||||
userIds: [userId],
|
||||
...over,
|
||||
});
|
||||
|
||||
describe("merchant validations", () => {
|
||||
let log: EventLog;
|
||||
beforeEach(() => {
|
||||
log = makeLog(db);
|
||||
});
|
||||
|
||||
const mint = (identity: string, minAgo: number, payload: Record<string, unknown> | null = null) =>
|
||||
log.append({ type: "vehicle_entry", direction: "entry", identity, occurredAt: minutesAgo(minAgo), payload });
|
||||
|
||||
it("program upsert is admin-gated and signs a config_change; a no-op save signs nothing", async () => {
|
||||
const admin = await seedAdmin();
|
||||
const { auth: merchant, userId } = await seedMerchant();
|
||||
|
||||
expect((await putProgram(merchant, fixedProgram(userId))).statusCode).toBe(403);
|
||||
|
||||
const res = await putProgram(admin, fixedProgram(userId));
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json()).toMatchObject({ id: "bar", mode: "fixed", active: true, userIds: [userId] });
|
||||
|
||||
const changes = () => db.select().from(ledgerEvents).all().filter((r) => r.type === "config_change");
|
||||
expect(changes()).toHaveLength(1);
|
||||
expect(changes()[0].payload).toMatchObject({ setting: "validationProgram.bar", operator: "admin" });
|
||||
|
||||
// Identical second save → no second config_change.
|
||||
await putProgram(admin, fixedProgram(userId));
|
||||
expect(changes()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("per-mode validation: timeCredit needs minutes, percent needs percent, fixed needs a cap", async () => {
|
||||
const admin = await seedAdmin();
|
||||
expect((await putProgram(admin, { name: "X", mode: "timeCredit", active: true })).statusCode).toBe(400);
|
||||
expect((await putProgram(admin, { name: "X", mode: "percent", active: true })).statusCode).toBe(400);
|
||||
expect((await putProgram(admin, { name: "X", mode: "fixed", active: true })).statusCode).toBe(400);
|
||||
expect((await putProgram(admin, { name: "X", mode: "timeCredit", minutes: 60, active: true })).statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it("GET /mine returns only MY bound, active programs", async () => {
|
||||
const admin = await seedAdmin();
|
||||
const { auth: merchant, userId } = await seedMerchant();
|
||||
await putProgram(admin, fixedProgram(userId));
|
||||
await putProgram(admin, { name: "Lavazh", mode: "comp", active: true, userIds: [] }, "lavazh");
|
||||
|
||||
const res = await app.inject({ method: "GET", url: "/api/validation/mine", headers: hdrs(merchant) });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const programs = res.json().programs as { id: string }[];
|
||||
expect(programs.map((p) => p.id)).toEqual(["bar"]);
|
||||
});
|
||||
|
||||
it("apply: binding, session-state, duplicate and amount guards", async () => {
|
||||
const admin = await seedAdmin();
|
||||
const { auth: merchant, userId } = await seedMerchant();
|
||||
const { auth: other } = await seedMerchant("tjetri");
|
||||
await putProgram(admin, fixedProgram(userId));
|
||||
seedTariff(db);
|
||||
await mint("T1", 120);
|
||||
|
||||
const apply = (auth: Auth, payload: Record<string, unknown>) =>
|
||||
app.inject({ method: "POST", url: "/api/validation/apply", headers: hdrs(auth), payload });
|
||||
|
||||
// Unbound merchant → 403; unknown ticket → 404; missing amount (fixed) → 400;
|
||||
// amount above the cap → 400.
|
||||
expect((await apply(other, { identity: "T1", programId: "bar", amountMinor: 5000 })).statusCode).toBe(403);
|
||||
expect((await apply(merchant, { identity: "NOPE", programId: "bar", amountMinor: 5000 })).statusCode).toBe(404);
|
||||
expect((await apply(merchant, { identity: "T1", programId: "bar" })).statusCode).toBe(400);
|
||||
expect((await apply(merchant, { identity: "T1", programId: "bar", amountMinor: 999999 })).statusCode).toBe(400);
|
||||
|
||||
// Subscriber sessions are never validated (prepaid).
|
||||
await mint("SUB1", 60, { permit: true, permitId: "s-1" });
|
||||
expect((await apply(merchant, { identity: "SUB1", programId: "bar", amountMinor: 5000 })).statusCode).toBe(409);
|
||||
|
||||
// Success → a SIGNED validation event with resolved values + the merchant username.
|
||||
const ok = await apply(merchant, { identity: "T1", programId: "bar", amountMinor: 5000 });
|
||||
expect(ok.statusCode).toBe(201);
|
||||
const ev = db.select().from(ledgerEvents).all().find((r) => r.type === "validation")!;
|
||||
expect(ev.payload).toMatchObject({
|
||||
programId: "bar",
|
||||
programLabel: "Bar",
|
||||
mode: "fixed",
|
||||
amountMinor: 5000,
|
||||
operator: "bari",
|
||||
});
|
||||
|
||||
// Same program twice on one ticket → 409.
|
||||
expect((await apply(merchant, { identity: "T1", programId: "bar", amountMinor: 1000 })).statusCode).toBe(409);
|
||||
});
|
||||
|
||||
it("the money cycle: quote nets the validation, pay records gross/discount and CONSUMES it", async () => {
|
||||
const admin = await seedAdmin();
|
||||
const { auth: merchant, userId } = await seedMerchant();
|
||||
await putProgram(admin, fixedProgram(userId));
|
||||
// 100/h flat; 2h → gross 20000.
|
||||
seedTariff(db, { pricePerIncrementMinor: 10000, incrementMin: 60 });
|
||||
await mint("T1", 119);
|
||||
|
||||
await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/validation/apply",
|
||||
headers: hdrs(merchant),
|
||||
payload: { identity: "T1", programId: "bar", amountMinor: 5000 },
|
||||
});
|
||||
|
||||
const q1 = await app.inject({ method: "GET", url: "/api/pay/quote?identity=T1", headers: hdrs(admin) });
|
||||
expect(q1.json()).toMatchObject({
|
||||
grossMinor: 20000,
|
||||
discountMinor: 5000,
|
||||
amountMinor: 15000,
|
||||
});
|
||||
expect(q1.json().validationLines).toEqual([
|
||||
{ programId: "bar", label: "Bar", mode: "fixed", discountMinor: 5000 },
|
||||
]);
|
||||
|
||||
// Pay (needs an open shift) → the payment carries the split + consumed ids.
|
||||
await app.inject({ method: "POST", url: "/api/shift/open", headers: hdrs(admin) });
|
||||
const pay = await app.inject({ method: "POST", url: "/api/pay", headers: hdrs(admin), payload: { identity: "T1", tender: "cash" } });
|
||||
expect(pay.statusCode).toBe(201);
|
||||
expect(pay.json().amountMinor).toBe(15000);
|
||||
|
||||
const payment = db.select().from(ledgerEvents).all().find((r) => r.type === "payment")!;
|
||||
expect(payment.payload).toMatchObject({ amountMinor: 15000, grossMinor: 20000, discountMinor: 5000 });
|
||||
expect((payment.payload as { validationIds?: string[] }).validationIds).toHaveLength(1);
|
||||
|
||||
// Settled: the follow-up quote owes 0 and applies nothing further.
|
||||
const q2 = await app.inject({ method: "GET", url: "/api/pay/quote?identity=T1", headers: hdrs(admin) });
|
||||
expect(q2.json().amountMinor).toBe(0);
|
||||
expect(q2.json().validationLines).toEqual([]);
|
||||
});
|
||||
|
||||
it("a full comp settles at 0 through the normal pay path (grace starts, chain verifies)", async () => {
|
||||
const admin = await seedAdmin();
|
||||
const { auth: merchant, userId } = await seedMerchant();
|
||||
await putProgram(admin, { name: "Lavazh falas", mode: "comp", active: true, userIds: [userId] }, "lavazh");
|
||||
seedTariff(db, { pricePerIncrementMinor: 10000 });
|
||||
await mint("T1", 90);
|
||||
|
||||
await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/validation/apply",
|
||||
headers: hdrs(merchant),
|
||||
payload: { identity: "T1", programId: "lavazh" },
|
||||
});
|
||||
|
||||
const q = await app.inject({ method: "GET", url: "/api/pay/quote?identity=T1", headers: hdrs(admin) });
|
||||
expect(q.json().amountMinor).toBe(0);
|
||||
expect(q.json().grossMinor).toBeGreaterThan(0);
|
||||
|
||||
await app.inject({ method: "POST", url: "/api/shift/open", headers: hdrs(admin) });
|
||||
const pay = await app.inject({ method: "POST", url: "/api/pay", headers: hdrs(admin), payload: { identity: "T1", tender: "cash" } });
|
||||
expect(pay.statusCode).toBe(201);
|
||||
expect(pay.json().amountMinor).toBe(0);
|
||||
|
||||
// The 0-net settlement still grants walk-back grace (the session reads settled).
|
||||
const view = await app.inject({ method: "GET", url: "/api/session/T1", headers: hdrs(admin) });
|
||||
expect(view.json()).toMatchObject({ withinGrace: true, amountMinor: 0 });
|
||||
});
|
||||
|
||||
it("void: own unused only; a consumed validation is locked", async () => {
|
||||
const admin = await seedAdmin();
|
||||
const { auth: merchant, userId } = await seedMerchant();
|
||||
const { auth: other, userId: otherId } = await seedMerchant("tjetri");
|
||||
await putProgram(admin, fixedProgram(userId, { userIds: [userId, otherId] }));
|
||||
seedTariff(db, { pricePerIncrementMinor: 10000 });
|
||||
await mint("T1", 90);
|
||||
|
||||
const applied = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/validation/apply",
|
||||
headers: hdrs(merchant),
|
||||
payload: { identity: "T1", programId: "bar", amountMinor: 5000 },
|
||||
});
|
||||
const eventId = applied.json().eventId as string;
|
||||
|
||||
const voidReq = (auth: Auth) =>
|
||||
app.inject({ method: "POST", url: "/api/validation/void", headers: hdrs(auth), payload: { eventId, identity: "T1" } });
|
||||
|
||||
// Someone else's validation → 403. Own → ok, and the quote returns to gross.
|
||||
expect((await voidReq(other)).statusCode).toBe(403);
|
||||
expect((await voidReq(merchant)).statusCode).toBe(200);
|
||||
const q = await app.inject({ method: "GET", url: "/api/pay/quote?identity=T1", headers: hdrs(admin) });
|
||||
expect(q.json().discountMinor).toBe(0);
|
||||
|
||||
// Re-apply (the void freed the per-session slot), consume it with a payment, then
|
||||
// a void must refuse — the settlement already happened.
|
||||
const re = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/validation/apply",
|
||||
headers: hdrs(merchant),
|
||||
payload: { identity: "T1", programId: "bar", amountMinor: 5000 },
|
||||
});
|
||||
await app.inject({ method: "POST", url: "/api/shift/open", headers: hdrs(admin) });
|
||||
await app.inject({ method: "POST", url: "/api/pay", headers: hdrs(admin), payload: { identity: "T1", tender: "cash" } });
|
||||
const locked = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/validation/void",
|
||||
headers: hdrs(merchant),
|
||||
payload: { eventId: re.json().eventId, identity: "T1" },
|
||||
});
|
||||
expect(locked.statusCode).toBe(409);
|
||||
});
|
||||
|
||||
it("maxPerDay caps applications across tickets", async () => {
|
||||
const admin = await seedAdmin();
|
||||
const { auth: merchant, userId } = await seedMerchant();
|
||||
await putProgram(admin, { name: "Lavazh", mode: "comp", maxPerDay: 1, active: true, userIds: [userId] }, "lavazh");
|
||||
seedTariff(db);
|
||||
await mint("T1", 60);
|
||||
await mint("T2", 30);
|
||||
|
||||
const apply = (identity: string) =>
|
||||
app.inject({ method: "POST", url: "/api/validation/apply", headers: hdrs(merchant), payload: { identity, programId: "lavazh" } });
|
||||
expect((await apply("T1")).statusCode).toBe(201);
|
||||
expect((await apply("T2")).statusCode).toBe(409);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,360 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import {
|
||||
and,
|
||||
eq,
|
||||
isNull,
|
||||
inArray,
|
||||
ledgerEvents,
|
||||
users,
|
||||
validationProgramUsers,
|
||||
validationPrograms,
|
||||
type Db,
|
||||
} from "@parking/db";
|
||||
import { VALIDATION_MODES, type ValidationMode } from "@parking/shared";
|
||||
import { requirePermission } from "../auth.js";
|
||||
import type { EventLog } from "../event-log.js";
|
||||
import { 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
|
||||
// stay at the booth, which settles net of these events. Program config is admin-composed
|
||||
// on /setup/site (site:read/update — no dedicated permission); applying is the merchant
|
||||
// user's `validation:create`, guarded FURTHER by the program↔user binding so a bar user
|
||||
// can never apply the lavazh program. Every apply/void is a signed, attributed ledger
|
||||
// event. See wiki/concepts/validation-discounts.md.
|
||||
// - GET /api/validation/programs : all programs + bound users. (site:read)
|
||||
// - PUT /api/validation/programs/:id : upsert config + bindings; (site:update)
|
||||
// signs a config_change.
|
||||
// - GET /api/validation/mine : my bound ACTIVE programs. (validation:create)
|
||||
// - GET /api/validation/session/:identity : minimal session view for (validation:create)
|
||||
// the merchant screen (no money data).
|
||||
// - POST /api/validation/apply : apply my program (signed). (validation:create)
|
||||
// - POST /api/validation/void : void my OWN unused apply. (validation:create)
|
||||
|
||||
/** Well-formed program ids: kebab slugs ("bar", "lavazh", a future "hotel-2"). */
|
||||
const ID_RE = /^[a-z][a-z0-9-]{1,31}$/;
|
||||
|
||||
interface ProgramBody {
|
||||
name?: string;
|
||||
mode?: ValidationMode;
|
||||
minutes?: number | null;
|
||||
percent?: number | null;
|
||||
maxAmountMinor?: number | null;
|
||||
maxPerDay?: number | null;
|
||||
active?: boolean;
|
||||
/** Full replacement set of bound user ids. */
|
||||
userIds?: string[];
|
||||
}
|
||||
|
||||
interface ApplyBody {
|
||||
identity: string;
|
||||
programId: string;
|
||||
/** fixed mode only: the discount the merchant grants (minor units, ≤ maxAmountMinor). */
|
||||
amountMinor?: number;
|
||||
}
|
||||
|
||||
interface VoidBody {
|
||||
eventId: string;
|
||||
identity: string;
|
||||
}
|
||||
|
||||
/** null when valid, else the 400 message. Checks the per-mode parameter. */
|
||||
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";
|
||||
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 === "percent" && b.percent == null) return "percent mode needs percent";
|
||||
if (b.mode === "fixed" && b.maxAmountMinor == null) return "fixed mode needs maxAmountMinor";
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function validationRoutes(app: FastifyInstance, db: Db, eventLog: EventLog): Promise<void> {
|
||||
const siteRead = requirePermission("site:read");
|
||||
const siteWrite = requirePermission("site:update");
|
||||
const applyGuard = requirePermission("validation:create");
|
||||
|
||||
const liveProgram = (id: string) =>
|
||||
db
|
||||
.select()
|
||||
.from(validationPrograms)
|
||||
.where(and(eq(validationPrograms.id, id), isNull(validationPrograms.deletedAt)))
|
||||
.get();
|
||||
|
||||
const boundUserIds = (programId: string): string[] =>
|
||||
db
|
||||
.select({ userId: validationProgramUsers.userId })
|
||||
.from(validationProgramUsers)
|
||||
.where(eq(validationProgramUsers.programId, programId))
|
||||
.all()
|
||||
.map((r) => r.userId);
|
||||
|
||||
// The setup panel's read: every live program with its bound users.
|
||||
app.get("/api/validation/programs", { preHandler: siteRead }, async () => {
|
||||
const programs = db.select().from(validationPrograms).where(isNull(validationPrograms.deletedAt)).all();
|
||||
return {
|
||||
programs: programs.map((p) => ({ ...p, userIds: boundUserIds(p.id) })),
|
||||
};
|
||||
});
|
||||
|
||||
// Upsert a program (the /setup/site checkbox + panel). Creates the well-known row on
|
||||
// first enable; replaces the binding set; signs an attributed config_change when
|
||||
// anything actually changed (the entry-presence-bypass precedent — enabling a discount
|
||||
// program is fraud-relevant config).
|
||||
app.put<{ Params: { id: string }; Body: ProgramBody }>(
|
||||
"/api/validation/programs/:id",
|
||||
{ preHandler: siteWrite },
|
||||
async (req, reply) => {
|
||||
const id = (req.params.id ?? "").trim();
|
||||
if (!ID_RE.test(id)) return reply.code(400).send({ error: "invalid program id" });
|
||||
const b = req.body ?? ({} as ProgramBody);
|
||||
const bad = validateProgram(b);
|
||||
if (bad) return reply.code(400).send({ error: bad });
|
||||
|
||||
const userIds = Array.isArray(b.userIds) ? [...new Set(b.userIds)] : [];
|
||||
if (userIds.length) {
|
||||
const found = db
|
||||
.select({ id: users.id })
|
||||
.from(users)
|
||||
.where(and(inArray(users.id, userIds), isNull(users.deletedAt)))
|
||||
.all();
|
||||
if (found.length !== userIds.length) return reply.code(400).send({ error: "unknown user in userIds" });
|
||||
}
|
||||
|
||||
const prev = liveProgram(id);
|
||||
const prevUserIds = prev ? boundUserIds(id).sort() : [];
|
||||
const next = {
|
||||
name: String(b.name).trim(),
|
||||
mode: b.mode as ValidationMode,
|
||||
minutes: b.minutes ?? null,
|
||||
percent: b.percent ?? null,
|
||||
maxAmountMinor: b.maxAmountMinor ?? null,
|
||||
maxPerDay: b.maxPerDay ?? null,
|
||||
active: b.active === true,
|
||||
};
|
||||
|
||||
if (prev) {
|
||||
db.update(validationPrograms).set(next).where(eq(validationPrograms.id, id)).run();
|
||||
} else {
|
||||
db.insert(validationPrograms).values({ id, ...next }).run();
|
||||
}
|
||||
db.delete(validationProgramUsers).where(eq(validationProgramUsers.programId, id)).run();
|
||||
for (const userId of userIds) {
|
||||
db.insert(validationProgramUsers).values({ programId: id, userId }).run();
|
||||
}
|
||||
|
||||
// Sign the change (attributed) — enabling/reshaping a discount program is
|
||||
// fraud-relevant config. Compare against the previous row + binding set so a
|
||||
// no-op save signs nothing.
|
||||
const summary = (row: typeof next, ids: string[]) => JSON.stringify({ ...row, userIds: [...ids].sort() });
|
||||
const prevSummary = prev
|
||||
? summary(
|
||||
{ name: prev.name, mode: prev.mode, minutes: prev.minutes, percent: prev.percent,
|
||||
maxAmountMinor: prev.maxAmountMinor, maxPerDay: prev.maxPerDay, active: prev.active },
|
||||
prevUserIds,
|
||||
)
|
||||
: null;
|
||||
if (prevSummary !== summary(next, userIds)) {
|
||||
await eventLog.append({
|
||||
type: "config_change",
|
||||
source: "manual",
|
||||
identity: `validation-program:${id}`,
|
||||
payload: {
|
||||
setting: `validationProgram.${id}`,
|
||||
value: { ...next, userCount: userIds.length },
|
||||
prev: prev
|
||||
? { name: prev.name, mode: prev.mode, minutes: prev.minutes, percent: prev.percent,
|
||||
maxAmountMinor: prev.maxAmountMinor, maxPerDay: prev.maxPerDay, active: prev.active }
|
||||
: null,
|
||||
operator: req.user?.username ?? "unknown",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const row = liveProgram(id);
|
||||
return { ...row, userIds: boundUserIds(id) };
|
||||
},
|
||||
);
|
||||
|
||||
// The merchant screen's program list: MY bound, active programs.
|
||||
app.get("/api/validation/mine", { preHandler: applyGuard }, async (req) => {
|
||||
const rows = db
|
||||
.select()
|
||||
.from(validationPrograms)
|
||||
.innerJoin(validationProgramUsers, eq(validationProgramUsers.programId, validationPrograms.id))
|
||||
.where(
|
||||
and(
|
||||
eq(validationProgramUsers.userId, req.user.sub),
|
||||
eq(validationPrograms.active, true),
|
||||
isNull(validationPrograms.deletedAt),
|
||||
),
|
||||
)
|
||||
.all();
|
||||
return { programs: rows.map((r) => r.validation_programs) };
|
||||
});
|
||||
|
||||
// Minimal session view for the merchant screen — deliberately NO money data (the
|
||||
// merchant validates; the booth settles): found/open/entry time + the validations
|
||||
// already on the session (so the UI can show "already validated" and offer void).
|
||||
app.get<{ Params: { identity: string } }>(
|
||||
"/api/validation/session/:identity",
|
||||
{ preHandler: applyGuard },
|
||||
async (req, reply) => {
|
||||
const identity = (req.params.identity ?? "").trim();
|
||||
if (!identity) return reply.code(400).send({ error: "identity required" });
|
||||
const rows = db
|
||||
.select({ type: ledgerEvents.type, occurredAt: ledgerEvents.occurredAt, 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 { identity, found: false, open: false, enteredAt: null, subscription: false, validations: [] };
|
||||
const entryPl = (entry.payload ?? {}) as { permit?: boolean; permitId?: string };
|
||||
const subscription = entryPl.permit === true || entryPl.permitId != null;
|
||||
const open = !rows.some((r) => r.type === "vehicle_exit" || r.type === "void");
|
||||
return {
|
||||
identity,
|
||||
found: true,
|
||||
open,
|
||||
enteredAt: entry.occurredAt,
|
||||
subscription,
|
||||
validations: sessionValidations(db, identity),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
// APPLY: the merchant's one action. Guards, in order: program live+active → the
|
||||
// user is BOUND to it → the session is an OPEN TRANSIENT → not already carrying a
|
||||
// live application of this program → per-day cap → fixed-amount bounds. Appends the
|
||||
// signed validation event with the RESOLVED values.
|
||||
app.post<{ Body: ApplyBody }>("/api/validation/apply", { preHandler: applyGuard }, async (req, reply) => {
|
||||
const identity = (req.body?.identity ?? "").trim();
|
||||
const programId = (req.body?.programId ?? "").trim();
|
||||
if (!identity || !programId) return reply.code(400).send({ error: "identity and programId required" });
|
||||
|
||||
const program = liveProgram(programId);
|
||||
if (!program || !program.active) return reply.code(404).send({ error: "program not found or inactive" });
|
||||
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" });
|
||||
}
|
||||
|
||||
// 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,
|
||||
programId,
|
||||
label: program.name,
|
||||
mode: program.mode,
|
||||
minutes: program.mode === "timeCredit" ? program.minutes : undefined,
|
||||
percent: program.mode === "percent" ? program.percent : undefined,
|
||||
amountMinor,
|
||||
});
|
||||
});
|
||||
|
||||
// VOID my own UNUSED validation (fat-fingered amount / wrong ticket). Append-only:
|
||||
// a validation event with refId, never a delete. Refused once a payment consumed it
|
||||
// (the settlement already happened — that dispute goes to the booth/admin).
|
||||
app.post<{ Body: VoidBody }>("/api/validation/void", { preHandler: applyGuard }, async (req, reply) => {
|
||||
const eventId = (req.body?.eventId ?? "").trim();
|
||||
const identity = (req.body?.identity ?? "").trim();
|
||||
if (!eventId || !identity) return reply.code(400).send({ error: "eventId and identity required" });
|
||||
const target = sessionValidations(db, identity).find((v) => v.eventId === eventId);
|
||||
if (!target) return reply.code(404).send({ error: "validation not found" });
|
||||
if (target.operator !== req.user.username) {
|
||||
return reply.code(403).send({ error: "you may only void your own validation" });
|
||||
}
|
||||
if (target.voided) return reply.code(409).send({ error: "already voided" });
|
||||
if (target.consumedBy != null) {
|
||||
return reply.code(409).send({ error: "already used in a payment — ask the booth/admin" });
|
||||
}
|
||||
await eventLog.append({
|
||||
type: "validation",
|
||||
source: "manual",
|
||||
identity,
|
||||
payload: {
|
||||
sessionRef: identity,
|
||||
refId: eventId,
|
||||
programId: target.programId,
|
||||
programLabel: target.label,
|
||||
operator: req.user.username,
|
||||
},
|
||||
});
|
||||
return { ok: true };
|
||||
});
|
||||
}
|
||||
+55
-21
@@ -42,7 +42,10 @@ import { subscriptionRoutes } from "./routes/subscriptions.js";
|
||||
import { subscriptionPlanRoutes } from "./routes/subscription-plans.js";
|
||||
import { qrReaderRoutes } from "./routes/qr-reader.js";
|
||||
import { shiftRoutes } from "./routes/shift.js";
|
||||
import { drawerRoutes } from "./routes/drawer.js";
|
||||
import { entryRoutes } from "./routes/entry.js";
|
||||
import { siteRoutes } from "./routes/site.js";
|
||||
import { validationRoutes } from "./routes/validations.js";
|
||||
import { snapshotRoutes } from "./routes/snapshots.js";
|
||||
import { tariffRoutes } from "./routes/tariffs.js";
|
||||
import { printerRoutes } from "./routes/printers.js";
|
||||
@@ -69,7 +72,14 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
||||
const logService = new LogService(db);
|
||||
const app = Fastify({
|
||||
logger: {
|
||||
// Level knob: trace|debug|info|warn|error|fatal (pino). Default info; a booth
|
||||
// being diagnosed can run LOG_LEVEL=debug without a code change.
|
||||
level: process.env.LOG_LEVEL ?? "info",
|
||||
// Container logs are read by humans (`docker logs` / Komodo), so stamp
|
||||
// ISO-8601 UTC instead of pino's epoch-ms, and level NAMES instead of the
|
||||
// numeric codes (30/40/50). pinoDbStream accepts both encodings.
|
||||
timestamp: () => `,"time":"${new Date().toISOString()}"`,
|
||||
formatters: { level: (label) => ({ level: label }) },
|
||||
stream: pinoDbStream(logService, process.stdout),
|
||||
},
|
||||
});
|
||||
@@ -113,11 +123,24 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
||||
const visionClient = new VisionClient(app.log);
|
||||
if (visionClient.enabled) app.log.info("vision client enabled");
|
||||
|
||||
// Append-only signed business LEDGER (ledger_events). Holds only business facts
|
||||
// (vehicle_entry/exit, payment, void, …) — the anti-fraud audit trail. A raw
|
||||
// button press is NOT a business fact: it's device telemetry, recorded UNSIGNED
|
||||
// in device_events. The entry flow turns an input into a signed vehicle_entry once
|
||||
// a ticket prints + the barrier is commanded. See event-streams-split.md.
|
||||
// Constructed HERE (before setupRoutes) so the Setup relay-test can sign its
|
||||
// deliberate barrier open into the ledger; the read routes are wired further down.
|
||||
// The 4th arg is a read-side fan-out fired AFTER each durable append — used to
|
||||
// push the event to live booth clients (WS). It cannot affect the sign/chain path.
|
||||
const eventLog = new EventLog(db, buildSigner(app.log), buildVerifier, (row) =>
|
||||
deviceEvents.emitLedger(row),
|
||||
);
|
||||
|
||||
// Device-agnostic setup: the admin adds controllers (with their relays + entry
|
||||
// button) and binds readers/cameras to a controller relay at first-run. There is
|
||||
// no lane — a parking lot is one pool with a flexible set of entry/exit points.
|
||||
// See wiki/concepts/first-run-setup.md, entry-exit-points.md.
|
||||
await setupRoutes(app, db, visionClient);
|
||||
await setupRoutes(app, db, visionClient, eventLog);
|
||||
|
||||
// Inbound device pushes (e.g. Dingtian Input Link URL → button events),
|
||||
// guarded by source-IP allowlist + a shared-secret path token, both read from
|
||||
@@ -156,17 +179,7 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
||||
app.addHook("onReady", async () => deviceMonitor.start());
|
||||
app.addHook("onClose", async () => deviceMonitor.stop());
|
||||
|
||||
// Append-only signed business LEDGER (ledger_events). Holds only business facts
|
||||
// (vehicle_entry/exit, payment, void, …) — the anti-fraud audit trail. A raw
|
||||
// button press is NOT a business fact: it's device telemetry, recorded UNSIGNED
|
||||
// in device_events. The entry flow (TODO) turns an input into a signed
|
||||
// vehicle_entry once a ticket prints + the barrier is commanded.
|
||||
// See wiki/decisions/event-streams-split.md.
|
||||
// The 4th arg is a read-side fan-out fired AFTER each durable append — used to
|
||||
// push the event to live booth clients (WS). It cannot affect the sign/chain path.
|
||||
const eventLog = new EventLog(db, buildSigner(app.log), buildVerifier, (row) =>
|
||||
deviceEvents.emitLedger(row),
|
||||
);
|
||||
// Read routes for the signed ledger (constructed above, before setupRoutes).
|
||||
await eventRoutes(app, db, eventLog);
|
||||
|
||||
// Admin reporting: read-only charts/totals aggregated from the signed ledger
|
||||
@@ -198,6 +211,10 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
||||
void entryFlow.onInput(e);
|
||||
});
|
||||
app.addHook("onClose", async () => unsubscribeEntry());
|
||||
// The camera press-gate: the entry flow mirrors the entry lane's camera state so a
|
||||
// physical press is live only in the lamp's SOLID state (see entry-flow.ts).
|
||||
const unsubscribeEntryLane = deviceEvents.onLaneStatus((s) => entryFlow.onLaneStatus(s));
|
||||
app.addHook("onClose", async () => unsubscribeEntryLane());
|
||||
|
||||
// Button-light indicator: drives the entry button's lamp on a spare relay from the
|
||||
// RADAR input vs. the camera lane status (blink = radar-only, solid = radar+camera,
|
||||
@@ -265,12 +282,21 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
||||
await subscriptionRoutes(app, db, credentialCapture, eventLog, shiftService);
|
||||
await subscriptionPlanRoutes(app, db);
|
||||
|
||||
// Shift open/close + drawer endpoints (shiftService constructed above).
|
||||
await shiftRoutes(app, shiftService, db);
|
||||
// Shift open/close (shiftService constructed above).
|
||||
await shiftRoutes(app, shiftService);
|
||||
// Drawer cash movements — operator records, admin reviews (routes/drawer.ts).
|
||||
await drawerRoutes(app, shiftService);
|
||||
// Operator-issued entry (broken physical button) — flagged mint, presence-gated.
|
||||
await entryRoutes(app, entryFlow, laneStatus, shiftService);
|
||||
|
||||
// Site config (capacity) + live occupancy. The FULL gate (refuse transient entry
|
||||
// at capacity) is in the entry flow. See wiki/concepts/capacity-occupancy.md.
|
||||
await siteRoutes(app, db);
|
||||
await siteRoutes(app, db, eventLog);
|
||||
|
||||
// Merchant validations (bar / lavazh): setup panel config + the merchant user's
|
||||
// scan-and-apply. The booth settlement folds the applied validations into its
|
||||
// quote (pay-station.ts). See wiki/concepts/validation-discounts.md.
|
||||
await validationRoutes(app, db, eventLog);
|
||||
|
||||
// Application logs: ingest frontend errors (POST /api/logs, any signed-in user) +
|
||||
// read the store (GET /api/logs, log:read). See wiki/concepts/app-logs.md.
|
||||
@@ -310,12 +336,20 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
||||
void runSnapPrune(); // once at startup
|
||||
app.addHook("onClose", async () => clearInterval(snapPruneTimer));
|
||||
|
||||
// Scheduled encrypted backup — daily, unref'd. A no-op (silent) until BACKUP_TARGET_DIR +
|
||||
// BACKUP_KEY are configured; tolerates an unreachable/unmounted target by recording the
|
||||
// error and trying again next run. NOT run once at startup (a just-booted appliance after a
|
||||
// power cut shouldn't immediately write to a possibly-not-yet-mounted disk; the daily cadence
|
||||
// and the manual button cover it). See wiki/concepts/backup-recovery.md.
|
||||
const backupTimer = setInterval(() => void backupService.runScheduled(), 24 * 60 * 60 * 1000);
|
||||
// Scheduled encrypted backup — checked every 15 min, unref'd; `runScheduled()` itself is a
|
||||
// no-op unless a full 24h has actually elapsed since the last PERSISTED success (isDue(), in
|
||||
// backup-service.ts), so this frequent poll does not cause frequent backups. Deliberately
|
||||
// NOT a `setInterval(..., 24h)` measured from process start: that design silently reset its
|
||||
// own countdown on every restart (deploy/crash/OOM/reboot, all routine under `restart:
|
||||
// always`), which could push a day's backup out arbitrarily far AND — before last-success was
|
||||
// persisted — made the admin UI show "Never" despite valid backups already on disk
|
||||
// (2026-08-30 field incident, park-buzi). A short poll against a persisted, wall-clock
|
||||
// timestamp is immune to both restart timing and to any single restart cadence. A no-op
|
||||
// (silent) until BACKUP_TARGET_DIR + BACKUP_KEY are configured; tolerates an
|
||||
// unreachable/unmounted target by recording the error and trying again next check. NOT run
|
||||
// once at startup (a just-booted appliance after a power cut shouldn't immediately write to a
|
||||
// possibly-not-yet-mounted disk). See wiki/concepts/backup-recovery.md.
|
||||
const backupTimer = setInterval(() => void backupService.runScheduled(), 15 * 60 * 1000);
|
||||
backupTimer.unref();
|
||||
app.addHook("onClose", async () => clearInterval(backupTimer));
|
||||
if (backupService.configured) {
|
||||
|
||||
@@ -117,27 +117,100 @@ describe("drawer carry-forward", () => {
|
||||
expect(next.openingFloatMinor).toBe(25000); // inherited
|
||||
});
|
||||
|
||||
it("cash_in / cash_out vouchers adjust the drawer", async () => {
|
||||
it("cash_in / cash_out movements adjust the drawer", async () => {
|
||||
await shift.open("alice");
|
||||
await shift.recordVoucher({ type: "cash_in", operator: "alice", authorizedBy: "admin", amountMinor: 100000, reason: "float load" });
|
||||
await shift.recordVoucher({ type: "cash_out", operator: "alice", authorizedBy: "admin", amountMinor: 30000, reason: "bank drop" });
|
||||
await shift.recordVoucher({ type: "cash_in", operator: "alice", amountMinor: 100000, reason: "float load" });
|
||||
await shift.recordVoucher({ type: "cash_out", operator: "alice", amountMinor: 30000, reason: "bank drop" });
|
||||
const r = shift.currentReport()!;
|
||||
expect(r.cashAddedMinor).toBe(100000);
|
||||
expect(r.cashRemovedMinor).toBe(30000);
|
||||
expect(r.expectedDrawerMinor).toBe(70000);
|
||||
});
|
||||
|
||||
it("rejects a non-positive voucher amount", async () => {
|
||||
it("rejects a non-positive movement amount", async () => {
|
||||
await shift.open("alice");
|
||||
await expect(
|
||||
shift.recordVoucher({ type: "cash_in", operator: "alice", authorizedBy: "admin", amountMinor: 0, reason: "x" }),
|
||||
shift.recordVoucher({ type: "cash_in", operator: "alice", amountMinor: 0, reason: "x" }),
|
||||
).rejects.toBeInstanceOf(InvalidCashMovementError);
|
||||
await expect(
|
||||
shift.recordVoucher({ type: "cash_out", operator: "alice", authorizedBy: "admin", amountMinor: -5, reason: "x" }),
|
||||
shift.recordVoucher({ type: "cash_out", operator: "alice", amountMinor: -5, reason: "x" }),
|
||||
).rejects.toBeInstanceOf(InvalidCashMovementError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("drawer review (operator records, admin reviews after)", () => {
|
||||
it("a new movement starts pending; review sets authorized/denied", async () => {
|
||||
await shift.open("alice");
|
||||
const m = await shift.recordVoucher({ type: "cash_out", operator: "alice", amountMinor: 5000, reason: "supplies" });
|
||||
// Find the movement's ledger id via the status list.
|
||||
let list = shift.movementsWithStatus({ operator: "alice" });
|
||||
expect(list).toHaveLength(1);
|
||||
expect(list[0].status).toBe("pending");
|
||||
expect(list[0].voucherNo).toBe(m.voucherNo);
|
||||
|
||||
await shift.reviewMovement({ refId: list[0].id, decision: "deny", reviewedBy: "admin", note: "not genuine" });
|
||||
list = shift.movementsWithStatus({ operator: "alice" });
|
||||
expect(list[0].status).toBe("denied");
|
||||
expect(list[0].reviewedBy).toBe("admin");
|
||||
expect(list[0].reviewNote).toBe("not genuine");
|
||||
});
|
||||
|
||||
it("DENY is a flag only — it does NOT reverse the movement or touch the drawer", async () => {
|
||||
await shift.open("alice");
|
||||
await shift.recordVoucher({ type: "cash_out", operator: "alice", amountMinor: 10000, reason: "x" });
|
||||
const before = shift.drawerBalance().balanceMinor;
|
||||
expect(before).toBe(-10000); // the disbursement counted immediately
|
||||
const id = shift.movementsWithStatus({ operator: "alice" })[0].id;
|
||||
await shift.reviewMovement({ refId: id, decision: "deny", reviewedBy: "admin" });
|
||||
// Balance UNCHANGED by the denial — the correction is settled outside the app.
|
||||
expect(shift.drawerBalance().balanceMinor).toBe(-10000);
|
||||
});
|
||||
|
||||
it("a denied movement in a CLOSED shift never leaks into the next operator's drawer", async () => {
|
||||
// The regression that motivated the redesign: op1 disburses, shift closes, op2
|
||||
// inherits; op1's disbursement is later DENIED. op2's drawer must be untouched.
|
||||
await shift.open("op1");
|
||||
await shift.recordVoucher({ type: "cash_out", operator: "op1", amountMinor: 10000, reason: "questionable" });
|
||||
const closed = await shift.close("op1");
|
||||
expect(closed.expectedDrawerMinor).toBe(-10000);
|
||||
|
||||
const next = await shift.open("op2");
|
||||
expect(next.openingFloatMinor).toBe(-10000); // op2 inherits the real till balance
|
||||
|
||||
const id = shift.movementsWithStatus({ operator: "op1" })[0].id;
|
||||
await shift.reviewMovement({ refId: id, decision: "deny", reviewedBy: "admin" });
|
||||
|
||||
// op2's drawer is STILL -10000 — the denial added no reversing cash.
|
||||
expect(shift.drawerBalance().balanceMinor).toBe(-10000);
|
||||
expect(shift.currentReport()!.openingFloatMinor).toBe(-10000);
|
||||
});
|
||||
|
||||
it("rejects reviewing a non-movement or an already-reviewed movement", async () => {
|
||||
await shift.open("alice");
|
||||
await shift.recordVoucher({ type: "cash_in", operator: "alice", amountMinor: 5000, reason: "x" });
|
||||
const id = shift.movementsWithStatus({ operator: "alice" })[0].id;
|
||||
await expect(
|
||||
shift.reviewMovement({ refId: "not-a-real-id", decision: "authorize", reviewedBy: "admin" }),
|
||||
).rejects.toBeInstanceOf(InvalidCashMovementError);
|
||||
await shift.reviewMovement({ refId: id, decision: "authorize", reviewedBy: "admin" });
|
||||
await expect(
|
||||
shift.reviewMovement({ refId: id, decision: "deny", reviewedBy: "admin" }),
|
||||
).rejects.toBeInstanceOf(InvalidCashMovementError); // already reviewed
|
||||
});
|
||||
|
||||
it("scopes movements by operator", async () => {
|
||||
await shift.open("alice");
|
||||
await shift.recordVoucher({ type: "cash_in", operator: "alice", amountMinor: 1000, reason: "a" });
|
||||
await shift.close("alice");
|
||||
await shift.open("bob");
|
||||
await shift.recordVoucher({ type: "cash_out", operator: "bob", amountMinor: 2000, reason: "b" });
|
||||
expect(shift.movementsWithStatus({ operator: "alice" })).toHaveLength(1);
|
||||
expect(shift.movementsWithStatus({ operator: "bob" })).toHaveLength(1);
|
||||
expect(shift.movementsWithStatus()).toHaveLength(2); // reviewer sees all
|
||||
expect(shift.movementsWithStatus({ status: "pending" })).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("close signs a Z-report; listShifts reads it back", () => {
|
||||
it("a closed shift appears in history with its split figures", async () => {
|
||||
await shift.open("alice");
|
||||
@@ -161,4 +234,11 @@ describe("close signs a Z-report; listShifts reads it back", () => {
|
||||
await shift.open("bob"); await shift.close("bob");
|
||||
expect(shift.listShifts({ operator: "alice" }).map((s) => s.operator)).toEqual(["alice"]);
|
||||
});
|
||||
|
||||
it("listOperators: distinct + sorted, includes the OPEN shift's operator", async () => {
|
||||
await shift.open("bob"); await shift.close("bob");
|
||||
await shift.open("bob"); await shift.close("bob"); // twice — must stay distinct
|
||||
await shift.open("alice"); // open, no z-report yet
|
||||
expect(shift.listOperators()).toEqual(["alice", "bob"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
@@ -90,6 +94,27 @@ export interface ShiftReport {
|
||||
readonly printed: boolean;
|
||||
}
|
||||
|
||||
/** A drawer movement's admin-review status, derived from its latest `cash_review`. */
|
||||
export type MovementStatus = "pending" | "authorized" | "denied";
|
||||
|
||||
/** One drawer cash movement (cash_in/cash_out) with its review status — the row shape for
|
||||
* the operator's own list and the admin review queue. `status` is derived, not stored. */
|
||||
export interface DrawerMovement {
|
||||
readonly id: string;
|
||||
readonly type: "cash_in" | "cash_out";
|
||||
/** Positive magnitude; direction is the `type`. */
|
||||
readonly amountMinor: number;
|
||||
readonly currency: string | null;
|
||||
readonly reason: string | null;
|
||||
readonly operator: string;
|
||||
readonly voucherNo: string | null;
|
||||
readonly at: string;
|
||||
readonly status: MovementStatus;
|
||||
readonly reviewedBy: string | null;
|
||||
readonly reviewNote: string | null;
|
||||
readonly reviewedAt: string | null;
|
||||
}
|
||||
|
||||
export class InvalidCashMovementError extends Error {
|
||||
constructor(msg: string) {
|
||||
super(msg);
|
||||
@@ -156,6 +181,28 @@ export class ShiftService {
|
||||
* The open shift (no z_report yet) is intentionally excluded — it's not a
|
||||
* completed accountability period. Use `currentOpenShift()` for the live one.
|
||||
*/
|
||||
/**
|
||||
* Every operator that HAS a shift (closed z_reports + the open one, if any),
|
||||
* distinct + sorted — feeds the admin filter dropdown so it can only ever ask
|
||||
* for an operator that exists (the filter is an exact username match).
|
||||
*/
|
||||
listOperators(): string[] {
|
||||
const rows = this.#db
|
||||
.select()
|
||||
.from(ledgerEvents)
|
||||
.where(eq(ledgerEvents.type, "shift_z_report"))
|
||||
.all();
|
||||
const names = new Set<string>();
|
||||
for (const r of rows) {
|
||||
const op = ((r.payload ?? {}) as { operator?: string }).operator ?? r.identity;
|
||||
if (op) names.add(op);
|
||||
}
|
||||
const open = this.currentOpenShift();
|
||||
const openOp = open ? (((open.payload ?? {}) as { operator?: string }).operator ?? open.identity) : null;
|
||||
if (openOp) names.add(openOp);
|
||||
return [...names].sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
listShifts(opts: { operator?: string; from?: string; to?: string } = {}): ShiftSummary[] {
|
||||
const rows = this.#db
|
||||
.select()
|
||||
@@ -177,6 +224,7 @@ export class ShiftService {
|
||||
subscriptionTotalMinor?: number;
|
||||
subscriptionSalesMinor?: number;
|
||||
subscriptionWindowMinor?: number;
|
||||
discountTotalMinor?: number;
|
||||
openingFloatMinor?: number;
|
||||
cashAddedMinor?: number;
|
||||
cashRemovedMinor?: number;
|
||||
@@ -207,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,
|
||||
@@ -281,24 +331,24 @@ export class ShiftService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a drawer cash VOUCHER — the direction is the event TYPE, not the sign of
|
||||
* an amount (a receipt and a disbursement are different financial documents):
|
||||
* Record a drawer cash MOVEMENT — the direction is the event TYPE, not the sign of an
|
||||
* amount (a receipt and a disbursement are different financial documents):
|
||||
* - `cash_in` (Mandat Arkëtimi): cash entered the drawer (+).
|
||||
* - `cash_out` (Mandat Pagese): cash left the drawer (−).
|
||||
* `amountMinor` is always a POSITIVE magnitude. The voucher is OPERATOR-RAISED and
|
||||
* ADMIN-AUTHORIZED: `operator` raised it, `authorizedBy` signed off (verified at the
|
||||
* route). Returns the new drawer balance + the assigned voucher number, and prints
|
||||
* a slip best-effort (the signed event is the record). See wiki/concepts/shift.md.
|
||||
* `amountMinor` is always a POSITIVE magnitude. The movement is OPERATOR-RECORDED FREELY
|
||||
* (no admin sign-off at creation — 2026-07-01); an admin REVIEWS it after the fact via
|
||||
* `reviewMovement` (authorize/deny — a flag that never moves cash). It counts in the
|
||||
* drawer immediately (the cash physically moved). Returns the new drawer balance + the
|
||||
* assigned voucher number, and prints a slip best-effort. See wiki/concepts/shift.md.
|
||||
*/
|
||||
async recordVoucher(args: {
|
||||
type: "cash_in" | "cash_out";
|
||||
operator: string;
|
||||
authorizedBy: string;
|
||||
amountMinor: number;
|
||||
reason: string;
|
||||
currency?: string;
|
||||
}): Promise<{ type: "cash_in" | "cash_out"; amountMinor: number; voucherNo: string; balanceMinor: number; printed: boolean }> {
|
||||
const { type, operator, authorizedBy, reason } = args;
|
||||
const { type, operator, reason } = args;
|
||||
if (!Number.isInteger(args.amountMinor) || args.amountMinor <= 0) {
|
||||
throw new InvalidCashMovementError("amountMinor must be a positive integer (minor units)");
|
||||
}
|
||||
@@ -308,25 +358,122 @@ export class ShiftService {
|
||||
await this.#log.append({
|
||||
type,
|
||||
source: "manual",
|
||||
identity: operator, // who RAISED the voucher (the operator at the booth)
|
||||
identity: operator, // who RECORDED the movement (the operator at the booth)
|
||||
payload: {
|
||||
amountMinor, // positive magnitude — direction is the type
|
||||
...(reason ? { reason } : {}),
|
||||
...(args.currency ? { currency: args.currency } : {}),
|
||||
operator,
|
||||
authorizedBy,
|
||||
voucherNo,
|
||||
},
|
||||
occurredAt: now,
|
||||
});
|
||||
const { balanceMinor, currency } = this.#drawerBalanceAt(now);
|
||||
const printed = await this.#printVoucher({ type, voucherNo, amountMinor, reason, operator, authorizedBy, currency, at: now });
|
||||
const printed = await this.#printVoucher({ type, voucherNo, amountMinor, reason, operator, currency, at: now });
|
||||
this.#logger.info(
|
||||
`${type} ${voucherNo} ${amountMinor} by ${operator} authz ${authorizedBy} (${reason || "no reason"}) → drawer ${balanceMinor}`,
|
||||
`${type} ${voucherNo} ${amountMinor} by ${operator} (${reason || "no reason"}) → drawer ${balanceMinor}`,
|
||||
);
|
||||
return { type, amountMinor, voucherNo, balanceMinor, printed };
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin's post-hoc REVIEW of a recorded cash_in/cash_out. Appends a signed `cash_review`
|
||||
* referencing the movement. This is a FLAG ONLY — a `deny` does NOT reverse the movement
|
||||
* and does NOT touch the drawer balance (a denial is a judgment about the operator,
|
||||
* settled outside the app). Rejects an unknown/ non-movement refId, and a movement that
|
||||
* was already decided (one decision per movement; a clean audit trail). Idempotent by
|
||||
* design: the drawer fold never reads `cash_review`. See wiki/concepts/shift.md.
|
||||
*/
|
||||
async reviewMovement(args: {
|
||||
refId: string;
|
||||
decision: "authorize" | "deny";
|
||||
reviewedBy: string;
|
||||
note?: string;
|
||||
}): Promise<{ refId: string; decision: "authorize" | "deny"; reviewedBy: string; at: string }> {
|
||||
const { refId, decision, reviewedBy } = args;
|
||||
if (decision !== "authorize" && decision !== "deny") {
|
||||
throw new InvalidCashMovementError("decision must be authorize or deny");
|
||||
}
|
||||
const movement = this.#db.select().from(ledgerEvents).where(eq(ledgerEvents.id, refId)).get();
|
||||
if (!movement || (movement.type !== "cash_in" && movement.type !== "cash_out")) {
|
||||
throw new InvalidCashMovementError("refId is not a cash movement");
|
||||
}
|
||||
// One decision per movement — reject a re-review so the audit stays unambiguous.
|
||||
const already = this.#db
|
||||
.select()
|
||||
.from(ledgerEvents)
|
||||
.where(eq(ledgerEvents.type, "cash_review"))
|
||||
.all()
|
||||
.some((r) => (r.payload as LedgerPayload | null)?.refId === refId);
|
||||
if (already) throw new InvalidCashMovementError("movement already reviewed");
|
||||
|
||||
const now = new Date().toISOString();
|
||||
await this.#log.append({
|
||||
type: "cash_review",
|
||||
source: "manual",
|
||||
identity: reviewedBy, // the admin who decided
|
||||
payload: {
|
||||
refId,
|
||||
decision,
|
||||
reviewedBy,
|
||||
...(args.note ? { note: args.note } : {}),
|
||||
},
|
||||
occurredAt: now,
|
||||
});
|
||||
this.#logger.info(`cash_review ${decision} of ${movement.type} ${refId} by ${reviewedBy}`);
|
||||
return { refId, decision, reviewedBy, at: now };
|
||||
}
|
||||
|
||||
/**
|
||||
* All drawer cash movements (cash_in/cash_out) with their review STATUS, newest first.
|
||||
* Status is derived from the latest `cash_review` referencing each movement: none →
|
||||
* `pending`, else `authorized`/`denied`. Powers the operator's own list and the admin
|
||||
* review queue. `operator` (optional) scopes to one operator's movements (an operator
|
||||
* sees only their own; a reviewer sees all). See wiki/concepts/shift.md.
|
||||
*/
|
||||
movementsWithStatus(filter?: { operator?: string; status?: MovementStatus }): DrawerMovement[] {
|
||||
const rows = this.#db.select().from(ledgerEvents).orderBy(ledgerEvents.index).all();
|
||||
// Latest review decision per movement id.
|
||||
const reviewByRef = new Map<string, { decision: "authorize" | "deny"; reviewedBy: string; note?: string; at: string }>();
|
||||
for (const r of rows) {
|
||||
if (r.type !== "cash_review") continue;
|
||||
const pl = (r.payload ?? {}) as LedgerPayload;
|
||||
if (!pl.refId || (pl.decision !== "authorize" && pl.decision !== "deny")) continue;
|
||||
reviewByRef.set(pl.refId, {
|
||||
decision: pl.decision,
|
||||
reviewedBy: pl.reviewedBy ?? "",
|
||||
...(pl.note ? { note: pl.note } : {}),
|
||||
at: r.occurredAt,
|
||||
});
|
||||
}
|
||||
const out: DrawerMovement[] = [];
|
||||
for (const r of rows) {
|
||||
if (r.type !== "cash_in" && r.type !== "cash_out") continue;
|
||||
const pl = (r.payload ?? {}) as LedgerPayload;
|
||||
const operator = (typeof pl.operator === "string" ? pl.operator : null) ?? r.identity ?? "";
|
||||
if (filter?.operator && operator !== filter.operator) continue;
|
||||
const review = reviewByRef.get(r.id);
|
||||
const status: MovementStatus = review ? (review.decision === "authorize" ? "authorized" : "denied") : "pending";
|
||||
if (filter?.status && status !== filter.status) continue;
|
||||
out.push({
|
||||
id: r.id,
|
||||
type: r.type,
|
||||
amountMinor: typeof pl.amountMinor === "number" ? Math.abs(pl.amountMinor) : 0,
|
||||
currency: pl.currency ?? null,
|
||||
reason: pl.reason ?? null,
|
||||
operator,
|
||||
voucherNo: pl.voucherNo ?? null,
|
||||
at: r.occurredAt,
|
||||
status,
|
||||
reviewedBy: review?.reviewedBy ?? null,
|
||||
reviewNote: review?.note ?? null,
|
||||
reviewedAt: review?.at ?? null,
|
||||
});
|
||||
}
|
||||
// Newest first.
|
||||
return out.sort((a, b) => (a.at < b.at ? 1 : a.at > b.at ? -1 : 0));
|
||||
}
|
||||
|
||||
/** Open a shift for the operator (explicit start). The opening float is auto-
|
||||
* inherited from the chain = the drawer balance at the start instant. */
|
||||
async open(operator: string): Promise<{ startedAt: string; openingFloatMinor: number }> {
|
||||
@@ -382,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 & {
|
||||
@@ -394,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;
|
||||
@@ -449,6 +600,7 @@ export class ShiftService {
|
||||
subscriptionTotalMinor,
|
||||
subscriptionSalesMinor,
|
||||
subscriptionWindowMinor,
|
||||
discountTotalMinor,
|
||||
openingFloatMinor,
|
||||
cashAddedMinor,
|
||||
cashRemovedMinor,
|
||||
@@ -487,6 +639,7 @@ export class ShiftService {
|
||||
subscriptionTotalMinor,
|
||||
subscriptionSalesMinor,
|
||||
subscriptionWindowMinor,
|
||||
discountTotalMinor,
|
||||
openingFloatMinor,
|
||||
cashAddedMinor,
|
||||
cashRemovedMinor,
|
||||
@@ -509,6 +662,7 @@ export class ShiftService {
|
||||
subscriptionTotalMinor,
|
||||
subscriptionSalesMinor,
|
||||
subscriptionWindowMinor,
|
||||
discountTotalMinor,
|
||||
openingFloatMinor,
|
||||
cashAddedMinor,
|
||||
cashRemovedMinor,
|
||||
@@ -550,15 +704,18 @@ export class ShiftService {
|
||||
`Bileta: ${money(r.ticketTotalMinor)} ${cur}`,
|
||||
// Abonime is the subscription TOTAL; only the out-of-window part is broken out.
|
||||
// (subscriptionSalesMinor stays in the signed payload — it's just not printed.)
|
||||
`Abonime: ${money(r.subscriptionTotalMinor)} ${cur}`,
|
||||
` jashtë orarit: ${money(r.subscriptionWindowMinor)} ${cur}`,
|
||||
`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 --",
|
||||
`Fillimi (kusur): ${money(r.openingFloatMinor)} ${cur}`,
|
||||
`Para të marra: ${money(r.cashTotalMinor)} ${cur}`,
|
||||
`Para të shtuara: ${money(r.cashAddedMinor)} ${cur}`,
|
||||
`Para të hequra: ${money(r.cashRemovedMinor)} ${cur}`,
|
||||
`Arka e pritur: ${money(r.expectedDrawerMinor)} ${cur}`,
|
||||
`Gjëndje fillestare: ${money(r.openingFloatMinor)} ${cur}`,
|
||||
`Para të grumbulluara: ${money(r.cashTotalMinor)} ${cur}`,
|
||||
`Arkëtime: ${money(r.cashAddedMinor)} ${cur}`,
|
||||
`Pagesa: ${money(r.cashRemovedMinor)} ${cur}`,
|
||||
`Gjëndje aktuale: ${money(r.expectedDrawerMinor)} ${cur}`,
|
||||
];
|
||||
try {
|
||||
await printer.printReport({ title: "RAPORT TURNI", lines });
|
||||
@@ -578,7 +735,6 @@ export class ShiftService {
|
||||
amountMinor: number;
|
||||
reason: string;
|
||||
operator: string;
|
||||
authorizedBy: string;
|
||||
currency: string | null;
|
||||
at: string;
|
||||
}): Promise<boolean> {
|
||||
@@ -597,8 +753,7 @@ export class ShiftService {
|
||||
`Shuma: ${money(v.amountMinor)} ${cur}`,
|
||||
`Arsyeja: ${v.reason || "-"}`,
|
||||
"",
|
||||
`Hapur nga: ${v.operator}`,
|
||||
`Autorizoi: ${v.authorizedBy}`,
|
||||
`Regjistroi: ${v.operator}`,
|
||||
];
|
||||
try {
|
||||
await printer.printReport({ title, lines });
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import sharp from "sharp";
|
||||
import type { CameraDevice, Snapshot } from "@parking/devices";
|
||||
import { captureSnapshotShared, encodeForStorage } from "./snapshot.js";
|
||||
import { captureSnapshotShared, cleanType, encodeForStorage } from "./snapshot.js";
|
||||
import { silentLogger } from "./test-helpers.js";
|
||||
|
||||
// captureSnapshotShared: one HTTP pull per camera per vehicle. A Hikvision unit serves
|
||||
@@ -139,3 +139,20 @@ describe("encodeForStorage", () => {
|
||||
expect(out.contentType).toBe("text/plain"); // charset stripped even on the fallback
|
||||
});
|
||||
});
|
||||
|
||||
describe("cleanType", () => {
|
||||
it("strips a camera's charset cruft so a binary JPEG renders", () => {
|
||||
// The exact malformed value some cameras (Hikvision) return, which broke the
|
||||
// snapshot strip for every legacy row until the serve route normalized it.
|
||||
expect(cleanType('image/jpeg; charset="UTF-8"')).toBe("image/jpeg");
|
||||
expect(cleanType("image/jpeg; charset=utf-8")).toBe("image/jpeg");
|
||||
});
|
||||
|
||||
it("passes a clean type through and defaults a missing one", () => {
|
||||
expect(cleanType("image/jpeg")).toBe("image/jpeg");
|
||||
expect(cleanType("image/png")).toBe("image/png");
|
||||
expect(cleanType(null)).toBe("image/jpeg");
|
||||
expect(cleanType(undefined)).toBe("image/jpeg");
|
||||
expect(cleanType("")).toBe("image/jpeg");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import sharp from "sharp";
|
||||
import { deviceEvents as deviceEventsTable, snapshots, type Db } from "@parking/db";
|
||||
import { and, eq, gte, sessions, deviceEvents as deviceEventsTable, snapshots, type Db } from "@parking/db";
|
||||
import { registry, type CameraDevice, type Snapshot } from "@parking/devices";
|
||||
import { reasonPayload } from "@parking/shared";
|
||||
import type { FastifyBaseLogger } from "fastify";
|
||||
import { devicesByDirection, type FlowDirection } from "./device-resolve.js";
|
||||
import { deviceEvents } from "./device-events.js";
|
||||
import type { EventLog } from "./event-log.js";
|
||||
import type { VisionClient } from "./vision-client.js";
|
||||
|
||||
// Camera snapshot capture, fired AFTER the barrier opens and never awaited on the
|
||||
@@ -40,9 +42,13 @@ import type { VisionClient } from "./vision-client.js";
|
||||
const SNAP_MAX_EDGE = Number(process.env.SNAPSHOT_MAX_EDGE ?? 1280);
|
||||
const SNAP_QUALITY = Number(process.env.SNAPSHOT_JPEG_QUALITY ?? 80);
|
||||
|
||||
/** Strip a camera's `; charset=...` cruft from a content type (a JPEG is binary). */
|
||||
function cleanType(ct: string): string {
|
||||
const base = ct.split(";")[0]?.trim();
|
||||
/** Strip a camera's `; charset=...` cruft from a content type (a JPEG is binary). A bare
|
||||
* `image/jpeg` renders; `image/jpeg; charset="UTF-8"` (what some cameras return, e.g.
|
||||
* Hikvision) is malformed for a binary body and browsers refuse to decode it. Applied
|
||||
* both on capture AND when serving, so legacy rows stored before this normalization
|
||||
* existed still serve a clean type. */
|
||||
export function cleanType(ct: string | null | undefined): string {
|
||||
const base = ct?.split(";")[0]?.trim();
|
||||
return base || "image/jpeg";
|
||||
}
|
||||
|
||||
@@ -75,6 +81,10 @@ interface SnapshotJob {
|
||||
/** Optional vision client — when present, ANPR runs on each captured image from an
|
||||
* `anpr`-enabled camera and records the plate against `identity`. Advisory only. */
|
||||
readonly vision?: VisionClient | null;
|
||||
/** Optional signed ledger — when present (the transient ENTRY path passes it), a
|
||||
* recognized entry plate that is already OPEN under another recent session signs an
|
||||
* `entry.duplicatePlate` anomaly (same car, second ticket). Post-hoc; never a gate. */
|
||||
readonly log?: EventLog | null;
|
||||
}
|
||||
|
||||
/** Camera config flag opting it into snapshot-triggered ANPR. */
|
||||
@@ -89,7 +99,7 @@ interface CameraConfig {
|
||||
* The caller must NOT block its open path on this.
|
||||
*/
|
||||
export function snapshotAsync(job: SnapshotJob): Promise<string[]> {
|
||||
const { db, direction, identity, logger, vision } = job;
|
||||
const { db, direction, identity, logger, vision, log } = job;
|
||||
const rows = devicesByDirection(db, "camera", direction);
|
||||
if (rows.length === 0) return Promise.resolve([]);
|
||||
|
||||
@@ -125,7 +135,7 @@ export function snapshotAsync(job: SnapshotJob): Promise<string[]> {
|
||||
// ANPR off the SAME image, tied to the SAME session — when vision is enabled
|
||||
// and this camera opts in. Fire-and-forget: never delays the open path.
|
||||
if (vision?.enabled && (row.config as CameraConfig)?.anpr === true) {
|
||||
void recognizePlate(db, vision, row.id, direction, identity, id, shot, logger);
|
||||
void recognizePlate(db, vision, row.id, direction, identity, id, shot, logger, log);
|
||||
}
|
||||
return id;
|
||||
} catch (err) {
|
||||
@@ -153,6 +163,7 @@ async function recognizePlate(
|
||||
snapshotId: string,
|
||||
shot: { bytes: Buffer; contentType: string },
|
||||
logger: FastifyBaseLogger,
|
||||
log?: EventLog | null,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const result = await vision.analyze(shot.bytes, shot.contentType);
|
||||
@@ -183,11 +194,81 @@ async function recognizePlate(
|
||||
// The session's entry/exit event already shipped without this (async) plate — tell the
|
||||
// booth so it backfills the plate badge in place (no refresh). Advisory; ledger untouched.
|
||||
deviceEvents.emitPlateRecognized({ identity, plate, direction });
|
||||
|
||||
// ENTRY-SIDE duplicate check: this plate already OPEN under another recent session is
|
||||
// most likely the SAME car that minted a second ticket (a motion radar drops a
|
||||
// stationary car → the button re-arms). Signed anomaly for the operator to void.
|
||||
if (direction === "entry" && log) {
|
||||
await flagDuplicateEntryPlate({ db, log, identity, plate, snapshotId, logger });
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn(`anpr recognize failed (${identity}): ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** How far back a recognized entry plate is compared against other OPEN sessions'
|
||||
* entry plates. Short on purpose: the duplicate-ticket scenario is the same car
|
||||
* re-pressing within minutes; a long window would flag legit re-visits. */
|
||||
function dupPlateWindowMs(): number {
|
||||
const raw = Number(process.env.ENTRY_DUP_PLATE_WINDOW_MIN ?? 15);
|
||||
return (Number.isFinite(raw) && raw > 0 ? raw : 15) * 60_000;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flag a freshly-recognized ENTRY plate that is already open under a DIFFERENT recent
|
||||
* session: sign ONE `entry.duplicatePlate` anomaly keyed to the new session, pointing at
|
||||
* the prior one. Mirrors the exit-side plateSwapSuspected pattern (advisory, post-hoc —
|
||||
* the barrier already opened; the operator voids the duplicate ticket). Exported for tests.
|
||||
*/
|
||||
export async function flagDuplicateEntryPlate(opts: {
|
||||
db: Db;
|
||||
log: EventLog;
|
||||
/** The session the plate was just recognized for (the NEW ticket). */
|
||||
identity: string;
|
||||
plate: string;
|
||||
snapshotId: string;
|
||||
logger: FastifyBaseLogger;
|
||||
}): Promise<void> {
|
||||
const { db, log, identity, plate, snapshotId, logger } = opts;
|
||||
try {
|
||||
const cutoff = new Date(Date.now() - dupPlateWindowMs()).toISOString();
|
||||
// Recent entry-plate reads (unsigned `kind:"read"` telemetry, written above) for the
|
||||
// same plate under a different identity. detail is JSON — filter in JS; read volume
|
||||
// inside the window is tiny (one row per entry).
|
||||
const reads = db
|
||||
.select()
|
||||
.from(deviceEventsTable)
|
||||
.where(and(eq(deviceEventsTable.kind, "read"), gte(deviceEventsTable.occurredAt, cutoff)))
|
||||
.all();
|
||||
const prior = reads
|
||||
.map((r) => r.detail as { identity?: string; direction?: string; plate?: string })
|
||||
.find((d) => d.direction === "entry" && d.plate === plate && d.identity && d.identity !== identity);
|
||||
if (!prior?.identity) return;
|
||||
// Only a still-OPEN prior session is a duplicate suspect (a closed one drove off).
|
||||
const open = db
|
||||
.select()
|
||||
.from(sessions)
|
||||
.where(and(eq(sessions.id, prior.identity), eq(sessions.state, "open")))
|
||||
.get();
|
||||
if (!open) return;
|
||||
await log.append({
|
||||
type: "anomaly",
|
||||
identity,
|
||||
payload: {
|
||||
...reasonPayload("entry.duplicatePlate", { plate, otherIdentity: prior.identity }),
|
||||
duplicateEntrySuspected: true,
|
||||
plate,
|
||||
otherIdentity: prior.identity,
|
||||
snapshotId,
|
||||
},
|
||||
});
|
||||
logger.warn(`duplicate entry suspected: plate ${plate} on ${identity} already open under ${prior.identity}`);
|
||||
} catch (err) {
|
||||
// Best-effort, post-hoc — never let the duplicate check surface on the open path.
|
||||
logger.error(`duplicate-plate check failed (${identity}): ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Build a live camera adapter from a resolved devices row, or null. Exported so the
|
||||
* ANPR bridge (anpr-entry.ts) reuses the identical registry-build-or-null logic. */
|
||||
export function buildCamera(row: { driverId: string; config: unknown }): CameraDevice | null {
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ledgerEvents, subscriptionCredentials, type Db } from "@parking/db";
|
||||
import { createTestDb } from "@parking/db/testing";
|
||||
import { SubscriptionFlow } from "./subscription-flow.js";
|
||||
import type { DeviceReadEvent } from "./device-events.js";
|
||||
import { makeLog, silentLogger } from "./test-helpers.js";
|
||||
|
||||
// CHANNEL AGREEMENT in SubscriptionFlow.match (2026-07-04): when the reader CONFIRMED
|
||||
// the physical channel (DT-008 output prefixes → DeviceReadEvent.channel), the
|
||||
// credential kind must agree. An OPTICAL decode claiming an RF credential is the
|
||||
// cheap clone (print the card's UID as a barcode) — refused + ONE signed anomaly.
|
||||
// Legacy untagged reads (channel undefined) match as before, so readers without
|
||||
// prefixes keep working.
|
||||
|
||||
let db: Db;
|
||||
let flow: SubscriptionFlow;
|
||||
|
||||
const SUB = "sub-1";
|
||||
const CARD_UID = "86A158";
|
||||
const QR_CODE = "SUB-TESTQR";
|
||||
|
||||
beforeEach(() => {
|
||||
({ db } = createTestDb());
|
||||
db.insert(subscriptionCredentials).values({ id: randomUUID(), subscriptionId: SUB, kind: "rf", value: CARD_UID }).run();
|
||||
db.insert(subscriptionCredentials).values({ id: randomUUID(), subscriptionId: SUB, kind: "qr", value: QR_CODE }).run();
|
||||
flow = new SubscriptionFlow(db, makeLog(db), silentLogger());
|
||||
});
|
||||
|
||||
function read(value: string, opts: { kind?: DeviceReadEvent["kind"]; channel?: DeviceReadEvent["channel"] } = {}): DeviceReadEvent {
|
||||
return {
|
||||
driverId: "dingtian-qr-reader",
|
||||
deviceId: "reader-1",
|
||||
value,
|
||||
kind: opts.kind ?? "qr",
|
||||
...(opts.channel ? { channel: opts.channel } : {}),
|
||||
at: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
const anomalies = () =>
|
||||
db.select().from(ledgerEvents).all().filter((r) => r.type === "anomaly");
|
||||
|
||||
describe("subscription match — credential channel agreement", () => {
|
||||
it("OPTICAL read of an RF card's UID → no match + signed channelMismatch anomaly (the clone)", async () => {
|
||||
const m = flow.match(read(CARD_UID, { kind: "qr", channel: "optical" }));
|
||||
expect(m).toBeNull();
|
||||
await vi.waitFor(() => expect(anomalies()).toHaveLength(1)); // append is fire-and-forget
|
||||
expect(anomalies()[0].identity).toBe(SUB);
|
||||
expect(anomalies()[0].payload).toMatchObject({
|
||||
reasonCode: "sub.refused.channelMismatch",
|
||||
channelMismatch: true,
|
||||
credentialKind: "rf",
|
||||
channel: "optical",
|
||||
value: CARD_UID,
|
||||
});
|
||||
});
|
||||
|
||||
it("RF read of the same card → matches (via card), nothing signed", () => {
|
||||
const m = flow.match(read(CARD_UID, { kind: "card", channel: "rf" }));
|
||||
expect(m).toMatchObject({ subscriptionId: SUB, via: "card" });
|
||||
expect(anomalies()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("legacy untagged read of the card → still matches (unprefixed readers keep working)", () => {
|
||||
const m = flow.match(read(CARD_UID)); // kind qr, channel undefined — today's shape
|
||||
expect(m).toMatchObject({ subscriptionId: SUB, via: "card" });
|
||||
expect(anomalies()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("OPTICAL read of a QR credential → matches (the legit path)", () => {
|
||||
const m = flow.match(read(QR_CODE, { kind: "qr", channel: "optical" }));
|
||||
expect(m).toMatchObject({ subscriptionId: SUB, via: "qr" });
|
||||
});
|
||||
|
||||
it("RF read claiming a QR credential → refused symmetrically (mis-encoded clone tag)", async () => {
|
||||
const m = flow.match(read(QR_CODE, { kind: "card", channel: "rf" }));
|
||||
expect(m).toBeNull();
|
||||
await vi.waitFor(() => expect(anomalies()).toHaveLength(1));
|
||||
expect(anomalies()[0].payload).toMatchObject({ credentialKind: "qr", channel: "rf" });
|
||||
});
|
||||
|
||||
it("unknown value → plain no-match, no anomaly (a phantom/typo is not a clone attempt)", () => {
|
||||
const m = flow.match(read("999459", { kind: "qr", channel: "optical" }));
|
||||
expect(m).toBeNull();
|
||||
expect(anomalies()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -77,6 +77,40 @@ export class SubscriptionFlow {
|
||||
.where(eq(subscriptionCredentials.value, e.value))
|
||||
.get();
|
||||
if (cred) {
|
||||
// CHANNEL AGREEMENT (clone defense, 2026-07-04). When the reader CONFIRMED the
|
||||
// physical channel (DT-008 output prefixes), the credential kind must agree: an
|
||||
// OPTICAL decode may not claim an RF credential — otherwise printing a card's
|
||||
// UID (often written on the card face) as a barcode clones the card. Symmetric
|
||||
// for an RF read claiming a QR credential (a mis-encoded clone tag). A legacy
|
||||
// untagged read (channel undefined) matches as before — enforcement only bites
|
||||
// where prefixes are deployed. The attempt itself is a fraud signal → signed
|
||||
// anomaly, then treated as no-match (the flows refuse it as unknown).
|
||||
const mismatch =
|
||||
(e.channel === "optical" && cred.kind === "rf") ||
|
||||
(e.channel === "rf" && cred.kind === "qr");
|
||||
if (mismatch) {
|
||||
this.#logger.warn(
|
||||
`credential channel mismatch: ${cred.kind} credential '${e.value}' presented via ${e.channel} (sub ${cred.subscriptionId}) — possible clone`,
|
||||
);
|
||||
void this.#log
|
||||
.append({
|
||||
type: "anomaly",
|
||||
identity: cred.subscriptionId,
|
||||
payload: {
|
||||
...reasonPayload("sub.refused.channelMismatch", {
|
||||
credentialKind: cred.kind,
|
||||
channel: e.channel === "optical" ? "optical" : "rf",
|
||||
}),
|
||||
channelMismatch: true,
|
||||
credentialKind: cred.kind,
|
||||
channel: e.channel,
|
||||
value: e.value,
|
||||
deviceId: e.deviceId,
|
||||
},
|
||||
})
|
||||
.catch((err) => this.#logger.error(`channel-mismatch anomaly append failed: ${(err as Error).message}`));
|
||||
return null;
|
||||
}
|
||||
return { subscriptionId: cred.subscriptionId, carKey: e.value, via: cred.kind === "qr" ? "qr" : "card" };
|
||||
}
|
||||
// Plate binding: a read plate that matches a subscription's bound plate is an identity.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -4,6 +4,10 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" href="data:," />
|
||||
<!-- Self-hosted primary face (offline appliance — no webfont CDN). Preload the
|
||||
two weights on every screen so first paint doesn't flash the fallback. -->
|
||||
<link rel="preload" href="/fonts/chakra-petch/chakra-petch-latin-400.woff2" as="font" type="font/woff2" crossorigin />
|
||||
<link rel="preload" href="/fonts/chakra-petch/chakra-petch-latin-600.woff2" as="font" type="font/woff2" crossorigin />
|
||||
<title>Parking System</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
Copyright 2018 The Chakra Petch Project Authors (https://github.com/m4rc1e/Chakra-Petch.git)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,10 +1,9 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { fetchActiveSessions, reopenBarrier, type ActiveSession } from "./api.js";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchActiveSessions } from "./api.js";
|
||||
import { qk } from "./lib/query.js";
|
||||
import { useShift } from "./lib/use-shift.js";
|
||||
import { formatDuration, formatRelativeDateTime } from "./lib/format.js";
|
||||
import { formatCountdown, formatDuration, formatRelativeDateTime } from "./lib/format.js";
|
||||
import { Panel } from "./ui/Panel.js";
|
||||
import { FilterBar, SegGroup, type SegOption } from "./ui/FilterBar.js";
|
||||
|
||||
@@ -12,13 +11,8 @@ import { FilterBar, SegGroup, type SegOption } from "./ui/FilterBar.js";
|
||||
// within-grace (the barrier is UNCONFIRMED, so a paid/exited car is presumed
|
||||
// possibly-present until grace runs out). Lets the operator find a stuck car —
|
||||
// damaged ticket, dead scanner, or a phantom barrier re-close — without a scan:
|
||||
// - click a row → the pay/exit modal (pay an unpaid car, settle a subscriber's
|
||||
// out-of-window charge, assist-open a prepaid subscriber, or review),
|
||||
// - "Open barrier" (PAID transient sessions only) → an audited human-intervention
|
||||
// re-pulse for a car that paid but whose barrier didn't confirm.
|
||||
// No payment → no Open barrier button (the no-unpaid-bypass rule). Subscriptions get
|
||||
// NO inline open here — their assist-open / window-charge payment is modal-only, so
|
||||
// the list can't one-click past an unpaid out-of-window charge.
|
||||
// click a row → the pay/exit modal (pay an unpaid car, settle a subscriber's
|
||||
// out-of-window charge, assist-open a prepaid subscriber, or review).
|
||||
//
|
||||
// OVERSTAY sessions (paid, grace expired, no signed exit) are no longer aged out — they
|
||||
// stay listed with a distinct badge. A new period has begun (the car re-parked or is
|
||||
@@ -30,11 +24,6 @@ type KindFilter = "transient" | "subscription";
|
||||
|
||||
export function ActiveSessions({ onPick }: { onPick: (identity: string) => void }) {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
// The audited barrier re-open is a money-path action (server-gated on an open
|
||||
// shift); disable it unless this operator's shift is open.
|
||||
const { isOpen: shiftOpen, isMine: shiftMine } = useShift();
|
||||
const shiftReady = shiftOpen && shiftMine;
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: qk.activeSessions,
|
||||
queryFn: fetchActiveSessions,
|
||||
@@ -43,14 +32,13 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void
|
||||
refetchInterval: 15_000,
|
||||
});
|
||||
|
||||
const reopen = useMutation({
|
||||
mutationFn: (identity: string) => reopenBarrier(identity),
|
||||
onSettled: () => {
|
||||
void qc.invalidateQueries({ queryKey: qk.activeSessions });
|
||||
void qc.invalidateQueries({ queryKey: qk.events });
|
||||
},
|
||||
});
|
||||
const [reopenMsg, setReopenMsg] = useState<{ id: string; text: string; ok: boolean } | null>(null);
|
||||
// A 1-second clock so the within-grace countdown badge ticks live (the query only
|
||||
// refetches every 15s; the badge needs per-second resolution).
|
||||
const [nowMs, setNowMs] = useState(() => Date.now());
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setNowMs(Date.now()), 1000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
// Filters: free-text search + transient-vs-subscriber. (No status filter — the status
|
||||
// column was dropped; an unpaid transient is normal and a subscriber is marked ★.)
|
||||
@@ -77,20 +65,6 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void
|
||||
{ value: "subscription", label: t("booth.fKindSubscription") },
|
||||
];
|
||||
|
||||
async function handleReopen(s: ActiveSession) {
|
||||
setReopenMsg(null);
|
||||
try {
|
||||
const r = await reopen.mutateAsync(s.identity);
|
||||
setReopenMsg({
|
||||
id: s.identity,
|
||||
ok: r.opened,
|
||||
text: r.opened ? t("booth.barrierOpened") : r.reason ?? t("booth.openManually"),
|
||||
});
|
||||
} catch (e) {
|
||||
setReopenMsg({ id: s.identity, ok: false, text: (e as Error).message });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Panel
|
||||
title={t("booth.activeSessions")}
|
||||
@@ -117,11 +91,11 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void
|
||||
: t("booth.noMatch")}
|
||||
</div>
|
||||
) : (
|
||||
// A real table — aligned columns (who · plate · entry · elapsed · action). No
|
||||
// status column: an unpaid transient is the normal case, and a subscriber is
|
||||
// already marked with ★ + holder name. Overstay (a top-up is owed) keeps a row
|
||||
// tint so that fraud-relevant signal isn't lost. The whole row is clickable
|
||||
// (→ pay/exit modal); the trailing cell holds the audited Open-barrier action.
|
||||
// A real table — aligned columns (who · plate · entry · elapsed). No status
|
||||
// column: an unpaid transient is the normal case, and a subscriber is already
|
||||
// marked with ★ + holder name. Overstay (a top-up is owed) keeps a row tint so
|
||||
// that fraud-relevant signal isn't lost. The whole row is clickable (→ pay/exit
|
||||
// modal).
|
||||
<table className="w-full text-[0.75rem] tabular-nums">
|
||||
<thead className="sticky top-0 bg-term-panel-2 text-[0.6875rem] uppercase tracking-wider text-term-muted">
|
||||
<tr>
|
||||
@@ -129,31 +103,43 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void
|
||||
<th className="px-2 py-1.5 text-left font-semibold">{t("booth.colPlate")}</th>
|
||||
<th className="whitespace-nowrap px-2 py-1.5 text-left font-semibold">{t("booth.colEntry")}</th>
|
||||
<th className="whitespace-nowrap px-2 py-1.5 text-left font-semibold">{t("booth.colElapsed")}</th>
|
||||
<th className="px-2 py-1.5" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map((s) => {
|
||||
const msg = reopenMsg?.id === s.identity ? reopenMsg : null;
|
||||
// Paid-and-in-grace TRANSIENT only: an audited re-pulse for a car that paid
|
||||
// but the barrier didn't confirm. NOT overstay (owes a top-up → modal) and
|
||||
// NOT a subscription (assist-open lives in the modal). An unpaid transient
|
||||
// gets no button (no-unpaid-bypass). Mirrors reopenBarrier's server guard.
|
||||
const canReopen = s.paidAt && !s.overstay && !s.subscription;
|
||||
// EXITED-WITHIN-GRACE: a paid transient whose exit is recorded but the
|
||||
// barrier didn't confirm — it lingers here until grace runs out. Mark it
|
||||
// so the operator can tell it apart from a still-inside car (clicking it
|
||||
// opens the modal's manual barrier re-open, not a pay flow).
|
||||
const closedInGrace = !s.open && s.withinGrace && !s.subscription;
|
||||
// Live grace-remaining for the badge (M:SS). Null once it lapses — the
|
||||
// next refetch (≤15s) reclassifies the row (overstay / gone); until then
|
||||
// we show a generic label so the badge doesn't flicker empty.
|
||||
const graceLeft = closedInGrace ? formatCountdown(s.graceExpiresAt, nowMs) : null;
|
||||
return (
|
||||
<tr
|
||||
key={s.identity}
|
||||
onClick={() => onPick(s.identity)}
|
||||
className={`cursor-pointer border-t border-term-border/50 hover:bg-term-panel-2 ${
|
||||
s.overstay ? "bg-term-red/5" : ""
|
||||
s.overstay ? "bg-term-red/5" : closedInGrace ? "bg-term-amber/5 text-term-muted" : ""
|
||||
}`}
|
||||
title={t("booth.openPayExit")}
|
||||
title={closedInGrace ? t("booth.openReopenBarrier") : t("booth.openPayExit")}
|
||||
>
|
||||
<td className="px-2 py-1.5 text-term-text">
|
||||
{s.subscription ? (
|
||||
<span className="text-term-cyan">★ {s.subscriptionHolder ?? t("subs.unnamed")}</span>
|
||||
) : (
|
||||
s.identity
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{s.identity}
|
||||
{closedInGrace && (
|
||||
<span
|
||||
className="rounded border border-term-amber/60 px-1 text-[0.5625rem] uppercase tracking-wider tabular-nums text-term-amber"
|
||||
title={t("booth.exitedGraceTitle")}
|
||||
>
|
||||
{graceLeft ? t("booth.exitedGraceLeft", { time: graceLeft }) : t("booth.exitedGrace")}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-2 py-1.5">
|
||||
@@ -170,28 +156,8 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void
|
||||
{formatRelativeDateTime(s.enteredAt, t)}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-2 py-1.5 text-term-muted">
|
||||
{formatDuration(s.enteredAt, new Date().toISOString())}
|
||||
</td>
|
||||
<td className="px-2 py-1.5 text-right">
|
||||
{canReopen && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={reopen.isPending || !shiftReady}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation(); // don't also open the pay/exit modal
|
||||
void handleReopen(s);
|
||||
}}
|
||||
className="btn btn-pay btn-sm"
|
||||
title={shiftReady ? t("booth.openBarrierTitle") : t("shift.gateTitle")}
|
||||
>
|
||||
{t("booth.openBarrier")}
|
||||
</button>
|
||||
)}
|
||||
{msg && (
|
||||
<span className={`ml-2 text-[0.625rem] ${msg.ok ? "text-term-green" : "text-term-red"}`}>
|
||||
{msg.text}
|
||||
</span>
|
||||
)}
|
||||
{/* Freeze the elapsed at the recorded exit for a closed-in-grace row. */}
|
||||
{formatDuration(s.enteredAt, (closedInGrace ? s.exitedAt : null) ?? new Date().toISOString())}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
|
||||
+204
-47
@@ -18,8 +18,10 @@ import {
|
||||
import { rootRoute } from "./router.js";
|
||||
import { qk } from "./lib/query.js";
|
||||
import { useShift } from "./lib/use-shift.js";
|
||||
import { formatDuration, formatMoney, formatTime, formatRelativeDateTime } from "./lib/format.js";
|
||||
import { formatDuration, formatMoney, formatRelativeDateTime } from "./lib/format.js";
|
||||
import { CARD_PAYMENTS_ENABLED } from "./lib/features.js";
|
||||
import { SnapshotStrip } from "./ui/SnapshotStrip.js";
|
||||
import { Spinner } from "./ui/Spinner.js";
|
||||
|
||||
// The booth pay/exit modal. Opened when the operator submits a ticket id. Shows the
|
||||
// session (entry, exit=now, duration, total owed) + entry/exit snapshots, takes
|
||||
@@ -59,6 +61,9 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
||||
const { user } = rootRoute.useRouteContext();
|
||||
const canVoid = can(user, "event:void");
|
||||
const [voiding, setVoiding] = useState(false); // reason prompt revealed
|
||||
// Plate-swap: set when boothExit returns swap_suspected. Holds the detail for the warning
|
||||
// panel; the operator must consciously "Override & release". See plate-reconciliation.md.
|
||||
const [swap, setSwap] = useState<{ plate: string; otherIdentity: string; otherEnteredAt: string | null } | null>(null);
|
||||
const [voidReason, setVoidReason] = useState("");
|
||||
|
||||
const s: SessionLookup | undefined = session.data;
|
||||
@@ -73,6 +78,12 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
||||
// exit. A normal within-grace paid session is NOT payable (it's settled). See
|
||||
// booth-exit-flow.md / reopenBarrier server guard.
|
||||
const isOverstay = s?.overstay === true;
|
||||
// CLOSED-WITHIN-GRACE: a paid transient whose exit was already signed but the barrier
|
||||
// didn't confirm — it lingers in the active list until grace runs out (the "phantom
|
||||
// re-close" / damaged-ticket case). `s.open` is false, so it's not payable and not the
|
||||
// normal review flow; the only action is an audited manual re-pulse of the barrier.
|
||||
// (A grace-EXPIRED closed session falls through to the plain "already closed" notice.)
|
||||
const closedWithinGrace = !!(s?.found && !s.open && s.withinGrace && !isSubscription);
|
||||
// A subscription is normally prepaid (never charged). EXCEPTION: a time-window plan can
|
||||
// owe an out-of-window TARIFF-BRIDGE charge (early entry / late exit) — lookup() returns
|
||||
// it as s.amountMinor, and exit is GATED until it's paid. So a subscription IS payable
|
||||
@@ -171,7 +182,7 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePayAndExit() {
|
||||
async function handlePayAndExit(override = false) {
|
||||
if (!s) return;
|
||||
setError(null);
|
||||
try {
|
||||
@@ -179,7 +190,8 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
||||
// session is "already paid" but a new period accrued — we still charge (canPay
|
||||
// is true). A settled within-grace session is not payable (canPay false) and is
|
||||
// skipped. The server re-quotes authoritatively (overstay → from grace-expiry).
|
||||
if (canPay) {
|
||||
// On an OVERRIDE re-submit the payment already happened; don't double-charge.
|
||||
if (canPay && !override) {
|
||||
setPhase("paying");
|
||||
await paySession(identity, tender);
|
||||
}
|
||||
@@ -190,7 +202,14 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
||||
const r = await printVoucher(identity);
|
||||
setResult(t("pay.voucherPrinted", { printer: r.printedBy }));
|
||||
} else {
|
||||
const r = await boothExit(identity);
|
||||
const r = await boothExit(identity, override);
|
||||
// PLATE-SWAP suspected → don't exit; surface the warning + offer an override.
|
||||
if (!r.ok) {
|
||||
setSwap({ plate: r.plate, otherIdentity: r.otherIdentity, otherEnteredAt: r.otherEnteredAt });
|
||||
setPhase("review");
|
||||
return;
|
||||
}
|
||||
setSwap(null);
|
||||
// No voucher → auto-print a standalone payment receipt for transparency.
|
||||
// Best-effort: a printer fault must NOT block the exit that already happened;
|
||||
// the operator can reprint from the done screen.
|
||||
@@ -263,7 +282,13 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
||||
disabled={openingShift}
|
||||
className="btn btn-go btn-sm mt-2"
|
||||
>
|
||||
{openingShift ? t("shift.opening") : t("shift.openNow")}
|
||||
{openingShift ? (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Spinner /> {t("shift.opening")}
|
||||
</span>
|
||||
) : (
|
||||
t("shift.openNow")
|
||||
)}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
@@ -278,21 +303,58 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
||||
</div>
|
||||
)}
|
||||
|
||||
{s && s.found && !s.open && (
|
||||
<div className="rounded-term border border-term-amber px-3 py-2 text-term-amber">
|
||||
{t("pay.alreadyClosed", { time: formatTime(s.exitedAt) })}
|
||||
</div>
|
||||
{s && s.found && !s.open && !closedWithinGrace && (
|
||||
// A fully-closed session (exited, grace expired): no action to take, but the
|
||||
// operator may still need to REVIEW the evidence (entry/exit snapshots + plate)
|
||||
// — e.g. a dispute about a car that just left. Show the closed notice, the
|
||||
// figures, and the snapshot strip read-only. No tender / voucher / open here.
|
||||
<>
|
||||
<div className="rounded-term border border-term-amber px-3 py-2 text-term-amber">
|
||||
{t("pay.alreadyClosed", { time: formatRelativeDateTime(s.exitedAt, t, { seconds: true }) })}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-1 tabular-nums">
|
||||
<Row label={t("pay.entry")} value={formatRelativeDateTime(s.enteredAt, t, { seconds: true })} />
|
||||
<Row label={t("pay.exit")} value={formatRelativeDateTime(s.exitedAt, t, { seconds: true })} />
|
||||
<Row
|
||||
label={t("pay.duration")}
|
||||
value={
|
||||
s.enteredAt ? formatDuration(s.enteredAt, s.exitedAt ?? new Date().toISOString()) : "—"
|
||||
}
|
||||
/>
|
||||
{alreadyPaid && s.paidMinor != null && s.paidCurrency && (
|
||||
<Row label={t("pay.paidAmount")} value={formatMoney(s.paidMinor, s.paidCurrency)} valueClass="text-term-green" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SnapshotStrip identity={identity} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{s && s.found && s.open && (
|
||||
{s && s.found && (s.open || closedWithinGrace) && (
|
||||
<>
|
||||
{/* Session figures */}
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-1 tabular-nums">
|
||||
<Row label={t("pay.entry")} value={formatRelativeDateTime(s.enteredAt, t)} />
|
||||
<Row label={t("pay.now")} value={formatTime(new Date().toISOString())} />
|
||||
<Row label={t("pay.entry")} value={formatRelativeDateTime(s.enteredAt, t, { seconds: true })} />
|
||||
{/* Closed-within-grace shows the recorded EXIT; an open session shows now. */}
|
||||
<Row
|
||||
label={closedWithinGrace ? t("pay.exit") : t("pay.now")}
|
||||
value={formatRelativeDateTime(
|
||||
closedWithinGrace ? s.exitedAt : new Date().toISOString(),
|
||||
t,
|
||||
{ seconds: true },
|
||||
)}
|
||||
/>
|
||||
<Row
|
||||
label={t("pay.duration")}
|
||||
value={s.enteredAt ? formatDuration(s.enteredAt, new Date().toISOString()) : "—"}
|
||||
value={
|
||||
s.enteredAt
|
||||
? formatDuration(
|
||||
s.enteredAt,
|
||||
(closedWithinGrace ? s.exitedAt : null) ?? new Date().toISOString(),
|
||||
)
|
||||
: "—"
|
||||
}
|
||||
/>
|
||||
<Row
|
||||
label={t("pay.statusLabel")}
|
||||
@@ -301,28 +363,65 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
||||
? t("pay.subscription")
|
||||
: isOverstay
|
||||
? t("pay.overstay")
|
||||
: alreadyPaid
|
||||
? t("pay.paid")
|
||||
: t("pay.unpaid")
|
||||
: closedWithinGrace
|
||||
? t("pay.closedWithinGrace")
|
||||
: alreadyPaid
|
||||
? t("pay.paid")
|
||||
: t("pay.unpaid")
|
||||
}
|
||||
valueClass={
|
||||
isSubscription
|
||||
? "text-term-cyan"
|
||||
: isOverstay
|
||||
? "text-term-red"
|
||||
: alreadyPaid
|
||||
? "text-term-green"
|
||||
: "text-term-amber"
|
||||
: closedWithinGrace
|
||||
? "text-term-amber"
|
||||
: alreadyPaid
|
||||
? "text-term-green"
|
||||
: "text-term-amber"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Merchant validations (bar/lavazh): the gross fee + one line per
|
||||
discount — the Total below is the NET the customer pays. The lines
|
||||
ride the quote (SessionLookup.validationLines) and reprint on the
|
||||
receipt. See wiki/concepts/validation-discounts.md. */}
|
||||
{!isSubscription &&
|
||||
(s.validationLines ?? []).length > 0 &&
|
||||
s.currency != null &&
|
||||
s.amountMinor != null && (
|
||||
<div className="rounded-term bg-term-panel-2 px-3 py-2 text-[0.75rem]">
|
||||
<div className="flex justify-between text-term-text">
|
||||
<span>{t("val.gross")}</span>
|
||||
<span className="tabular-nums">
|
||||
{formatMoney(s.grossMinor ?? s.amountMinor, s.currency)}
|
||||
</span>
|
||||
</div>
|
||||
{(s.validationLines ?? []).map((v, i) => (
|
||||
<div key={i} className="flex justify-between text-term-green">
|
||||
<span>{v.label}</span>
|
||||
<span className="tabular-nums">−{formatMoney(v.discountMinor, s.currency!)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Total — a subscription is prepaid (no amount) UNLESS it owes an
|
||||
out-of-window window charge; then show that amount. For an overstay the
|
||||
amount is the TOP-UP delta, not the whole stay. */}
|
||||
<div className="flex items-end justify-between rounded-term bg-term-panel-2 px-3 py-2">
|
||||
<span className="text-[0.6875rem] uppercase tracking-wider text-term-muted">
|
||||
{subWindowDue ? t("pay.windowCharge") : isSubscription ? t("pay.plan") : isOverstay ? t("pay.topUp") : t("pay.total")}
|
||||
{subWindowDue
|
||||
? t("pay.windowCharge")
|
||||
: isSubscription
|
||||
? t("pay.plan")
|
||||
: isOverstay
|
||||
? t("pay.topUp")
|
||||
: alreadyPaid && s.paidMinor != null
|
||||
? // Settled session — the figure is the sum collected, not a quote.
|
||||
t("pay.paidAmount")
|
||||
: t("pay.total")}
|
||||
</span>
|
||||
<span className="text-3xl font-bold text-term-cyan">
|
||||
{subWindowDue && s.amountMinor != null && s.currency
|
||||
@@ -331,9 +430,12 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
||||
? t("pay.prepaid")
|
||||
: s.amountMinor != null && s.currency
|
||||
? formatMoney(s.amountMinor, s.currency)
|
||||
: alreadyPaid
|
||||
? t("booth.badgePaid")
|
||||
: t("pay.noTariff")}
|
||||
: alreadyPaid && s.paidMinor != null && s.paidCurrency
|
||||
? // Settled (within-grace / closed): show the sum actually collected.
|
||||
formatMoney(s.paidMinor, s.paidCurrency)
|
||||
: alreadyPaid
|
||||
? t("booth.badgePaid")
|
||||
: t("pay.noTariff")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -361,12 +463,22 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Closed-within-grace: the exit is already paid + recorded; the barrier
|
||||
just didn't confirm. Explain that the only action is a manual re-pulse. */}
|
||||
{closedWithinGrace && (
|
||||
<div className="rounded-term border border-term-amber/40 bg-term-amber/5 px-3 py-2 text-[0.75rem] text-term-text">
|
||||
{t("pay.closedWithinGraceHint")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Snapshots */}
|
||||
<SnapshotStrip identity={identity} />
|
||||
|
||||
{/* Tender — shown for any payable case (transient, overstay, OR a
|
||||
subscriber window charge that's still unpaid). */}
|
||||
{phase !== "done" && canPay && !(subWindowDue && windowPaid) && (
|
||||
subscriber window charge that's still unpaid). Card is hidden until a
|
||||
P2PE POS terminal is on-site (CARD_PAYMENTS_ENABLED) — see
|
||||
lib/features.ts + wiki/concepts/card-payments.md. */}
|
||||
{phase !== "done" && canPay && !(subWindowDue && windowPaid) && CARD_PAYMENTS_ENABLED && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[0.6875rem] uppercase tracking-wider text-term-muted">{t("pay.tender")}</span>
|
||||
{(["cash", "card"] as const).map((tn) => (
|
||||
@@ -382,8 +494,9 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Voucher checkbox (transient only; a subscriber doesn't self-exit). */}
|
||||
{phase !== "done" && !isSubscription && (
|
||||
{/* Voucher checkbox (transient only; a subscriber doesn't self-exit). Not
|
||||
for a closed-within-grace session — its exit is already recorded. */}
|
||||
{phase !== "done" && !isSubscription && !closedWithinGrace && (
|
||||
<label className="flex items-center gap-2 text-[0.75rem]">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -426,6 +539,25 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* PLATE-SWAP warning: the exiting plate is already inside under another
|
||||
ticket. A prominent, deliberate hold — the operator must consciously
|
||||
override to release. See wiki/concepts/plate-reconciliation.md. */}
|
||||
{swap && (
|
||||
<div className="rounded-term border border-term-red bg-term-red/10 px-3 py-2">
|
||||
<div className="text-[0.75rem] font-semibold uppercase tracking-wider text-term-red">
|
||||
{t("pay.swapTitle")}
|
||||
</div>
|
||||
<div className="mt-1 text-[0.75rem] text-term-text">
|
||||
{t("pay.swapBody", {
|
||||
plate: swap.plate,
|
||||
other: swap.otherIdentity,
|
||||
when: swap.otherEnteredAt ? formatRelativeDateTime(swap.otherEnteredAt, t) : "—",
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-1 text-[0.6875rem] text-term-muted">{t("pay.swapHint")}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <div className="rounded-term border border-term-red px-3 py-2 text-term-red">{error}</div>}
|
||||
{result && (
|
||||
<div className="rounded-term border border-term-green px-3 py-2 text-term-green">{result}</div>
|
||||
@@ -464,7 +596,19 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</button>
|
||||
{isSubscription ? (
|
||||
{closedWithinGrace ? (
|
||||
// Paid + exited but the barrier didn't confirm — the only action is
|
||||
// an audited manual re-pulse (the server re-opens without signing a
|
||||
// second exit). No payment, no voucher; mirrors reopenBarrier's guard.
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpenBarrier}
|
||||
disabled={!shiftReady || phase === "finishing"}
|
||||
className="btn btn-pay btn-lg"
|
||||
>
|
||||
{phase === "finishing" ? t("pay.opening") : t("booth.openBarrier")}
|
||||
</button>
|
||||
) : isSubscription ? (
|
||||
subWindowDue && !windowPaid ? (
|
||||
// Step 1 — a window charge is owed: take payment first. The
|
||||
// barrier open is the explicit next step (revealed once paid).
|
||||
@@ -523,26 +667,39 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
||||
{t("pay.cancelTicket")}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePayAndExit}
|
||||
disabled={!shiftReady || phase === "paying" || phase === "finishing"}
|
||||
className="btn btn-go btn-lg"
|
||||
>
|
||||
{phase === "paying"
|
||||
? t("pay.takingPayment")
|
||||
: phase === "finishing"
|
||||
? voucher
|
||||
? t("pay.printingVoucher")
|
||||
: t("pay.opening")
|
||||
: alreadyPaid
|
||||
{swap ? (
|
||||
// Plate-swap held → the only forward action is a conscious
|
||||
// override (re-submit with override:true; payment already taken).
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handlePayAndExit(true)}
|
||||
disabled={!shiftReady || phase === "finishing"}
|
||||
className="btn btn-danger btn-lg"
|
||||
>
|
||||
{phase === "finishing" ? t("pay.opening") : t("pay.swapOverride")}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handlePayAndExit()}
|
||||
disabled={!shiftReady || phase === "paying" || phase === "finishing"}
|
||||
className="btn btn-go btn-lg"
|
||||
>
|
||||
{phase === "paying"
|
||||
? t("pay.takingPayment")
|
||||
: phase === "finishing"
|
||||
? voucher
|
||||
? t("pay.printVoucher")
|
||||
: t("pay.openBarrier")
|
||||
: voucher
|
||||
? t("pay.payAndVoucher")
|
||||
: t("pay.payAndOpen")}
|
||||
</button>
|
||||
? t("pay.printingVoucher")
|
||||
: t("pay.opening")
|
||||
: alreadyPaid
|
||||
? voucher
|
||||
? t("pay.printVoucher")
|
||||
: t("pay.openBarrier")
|
||||
: voucher
|
||||
? t("pay.payAndVoucher")
|
||||
: t("pay.payAndOpen")}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchEvents, fetchOccupancy, type LedgerEvent, type Occupancy } from "./api.js";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { can, fetchEvents, fetchOccupancy, fetchSiteConfig, issueEntryTicket, type LedgerEvent, type Occupancy } from "./api.js";
|
||||
import { rootRoute } from "./router.js";
|
||||
import { qk } from "./lib/query.js";
|
||||
import { useLiveStore } from "./lib/live-store.js";
|
||||
import { useShift } from "./lib/use-shift.js";
|
||||
@@ -116,16 +117,48 @@ function TicketInput({ onSubmit }: { onSubmit: (identity: string) => void }) {
|
||||
* - radar present + camera NOT busy → BLINK green↔red (~1 Hz): "detected, not yet confirmed"
|
||||
* - camera busy → SOLID red: a vehicle is confirmed at the lane vicinity
|
||||
* - otherwise → SOLID green: free
|
||||
* Advisory only; it gates nothing. The blink uses the `.lane-blink` keyframe (index.css),
|
||||
* whose children inherit the alternating colour via `currentColor`. */
|
||||
function BarrierLight({ label, busy, radar }: { label: string; busy: boolean; radar: boolean }) {
|
||||
* Advisory only; it gates nothing. On the ENTRY light, when the operator holds `session:create`
|
||||
* and BOTH presence conditions meet (radar present AND camera busy = a real car at the entry),
|
||||
* the light becomes a CLICKABLE issue-ticket control (broken physical button). Same presence
|
||||
* rule as the physical button; the server re-checks it. See operator-issued-entry.md. */
|
||||
function BarrierLight({
|
||||
label,
|
||||
busy,
|
||||
radar,
|
||||
onIssue,
|
||||
issuing,
|
||||
bypassRadar,
|
||||
bypassCamera,
|
||||
}: {
|
||||
label: string;
|
||||
busy: boolean;
|
||||
radar: boolean;
|
||||
/** When set (entry light + permission), clicking issues an entry ticket — enabled when
|
||||
* both presence conditions are satisfied, treating a BYPASSED signal as satisfied. */
|
||||
onIssue?: () => void;
|
||||
issuing?: boolean;
|
||||
/** Admin bypass of a faulty device: a bypassed signal counts as present (server re-checks). */
|
||||
bypassRadar?: boolean;
|
||||
bypassCamera?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
// Blink only when the radar sees something the camera hasn't confirmed.
|
||||
const blinking = radar && !busy;
|
||||
const solid = busy ? "border-term-red bg-term-red/10 text-term-red" : "border-term-green bg-term-green/10 text-term-green";
|
||||
// A bypassed signal counts as satisfied (its device is faulty). The SERVER re-checks the
|
||||
// effective gate authoritatively; this only governs button affordance.
|
||||
const radarOk = radar || !!bypassRadar;
|
||||
const cameraOk = busy || !!bypassCamera;
|
||||
const canIssue = !!onIssue && radarOk && cameraOk && !issuing;
|
||||
const clickable = !!onIssue && radarOk && cameraOk;
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center gap-2 rounded-term border px-3 py-2 ${blinking ? "lane-blink" : solid}`}
|
||||
title={label}
|
||||
className={`flex items-center gap-2 rounded-term border px-3 py-2 ${blinking ? "lane-blink" : solid} ${
|
||||
clickable ? "cursor-pointer hover:brightness-125" : ""
|
||||
}`}
|
||||
title={clickable ? t("booth.issueEntryTitle") : label}
|
||||
onClick={canIssue ? onIssue : undefined}
|
||||
role={clickable ? "button" : undefined}
|
||||
>
|
||||
{/* Barrier glyph: a post + an arm. `currentColor` follows the (possibly blinking) state. */}
|
||||
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
@@ -135,22 +168,68 @@ function BarrierLight({ label, busy, radar }: { label: string; busy: boolean; ra
|
||||
</svg>
|
||||
<div className="leading-tight">
|
||||
<div className="text-[0.625rem] uppercase tracking-wider text-term-muted">{label}</div>
|
||||
<div className="text-xs font-bold">{busy ? "●" : blinking ? "◐" : "○"}</div>
|
||||
<div className="text-xs font-bold">
|
||||
{issuing ? "…" : clickable ? t("booth.issueEntry") : busy ? "●" : blinking ? "◐" : "○"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The two lane barrier lights (entry / exit) fed by the live lane-status (camera busy/free)
|
||||
* and lane-presence (radar). */
|
||||
* and lane-presence (radar). The ENTRY light doubles as an operator issue-ticket control when
|
||||
* the physical button is broken (permission + presence gated). */
|
||||
function LaneIndicators() {
|
||||
const { t } = useTranslation();
|
||||
const lanes = useLiveStore((s) => s.lanes);
|
||||
const radar = useLiveStore((s) => s.radar);
|
||||
const { user } = rootRoute.useRouteContext();
|
||||
const { isOpen: shiftOpen, isMine } = useShift();
|
||||
const qc = useQueryClient();
|
||||
const canIssue = can(user, "session:create") && shiftOpen && isMine;
|
||||
// Presence-gate bypass flags (admin, for faulty radar/camera). Refetched on interval so a
|
||||
// toggle reaches the booth without a reload; the server still re-checks authoritatively.
|
||||
const { data: site } = useQuery({
|
||||
queryKey: qk.siteConfig,
|
||||
queryFn: fetchSiteConfig,
|
||||
staleTime: 30_000,
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
const [msg, setMsg] = useState<{ text: string; ok: boolean } | null>(null);
|
||||
|
||||
const issue = useMutation({
|
||||
mutationFn: issueEntryTicket,
|
||||
onSuccess: (r) => {
|
||||
setMsg({ ok: true, text: t("booth.issueEntryOk", { ticket: r.ticketId }) });
|
||||
void qc.invalidateQueries({ queryKey: qk.events });
|
||||
void qc.invalidateQueries({ queryKey: qk.occupancy });
|
||||
setTimeout(() => setMsg(null), 4000);
|
||||
},
|
||||
onError: (e) => {
|
||||
setMsg({ ok: false, text: (e as Error).message });
|
||||
setTimeout(() => setMsg(null), 4000);
|
||||
},
|
||||
});
|
||||
|
||||
function onIssue() {
|
||||
if (window.confirm(t("booth.issueEntryConfirm"))) issue.mutate();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<BarrierLight label={t("booth.laneEntry")} busy={lanes?.entry ?? false} radar={radar?.entry ?? false} />
|
||||
<BarrierLight
|
||||
label={t("booth.laneEntry")}
|
||||
busy={lanes?.entry ?? false}
|
||||
radar={radar?.entry ?? false}
|
||||
onIssue={canIssue ? onIssue : undefined}
|
||||
issuing={issue.isPending}
|
||||
bypassRadar={site?.bypassPresenceRadar ?? false}
|
||||
bypassCamera={site?.bypassPresenceCamera ?? false}
|
||||
/>
|
||||
<BarrierLight label={t("booth.laneExit")} busy={lanes?.exit ?? false} radar={radar?.exit ?? false} />
|
||||
{msg && (
|
||||
<span className={`text-[0.6875rem] ${msg.ok ? "text-term-green" : "text-term-red"}`}>{msg.text}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,508 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
fetchDrawerBalance,
|
||||
fetchDrawerMovements,
|
||||
fetchEvents,
|
||||
fetchShift,
|
||||
fetchShiftReport,
|
||||
fetchShifts,
|
||||
recordDrawerMovement,
|
||||
reviewDrawerMovement,
|
||||
type DrawerMovement,
|
||||
type MovementStatus,
|
||||
type ShiftSummary,
|
||||
} from "./api.js";
|
||||
import { formatClock, formatMoney, formatRelativeDateTime } from "./lib/format.js";
|
||||
import { Panel } from "./ui/Panel.js";
|
||||
import type { LedgerEvent } from "@parking/shared";
|
||||
|
||||
// The DRAWER HUB (redesigned 2026-07-05 — was only record + review). One screen
|
||||
// answers "what's in the till and why": the CURRENT drawer balance with the open
|
||||
// shift's running breakdown (float + takings + vouchers = expected), TODAY's cash
|
||||
// activity (every cash payment and voucher, live), the movement record/review flow
|
||||
// (unchanged), and the closed-shift drawer history. All figures come from the signed
|
||||
// chain — the drawer is a single site-wide till that carries across shifts. See
|
||||
// wiki/concepts/shift.md.
|
||||
|
||||
const money = (m: number, cur: string | null) => formatMoney(m, cur ?? "");
|
||||
|
||||
/** Local midnight, ISO — the "today" window for the activity feed. */
|
||||
function startOfToday(): string {
|
||||
const d = new Date();
|
||||
d.setHours(0, 0, 0, 0);
|
||||
return d.toISOString();
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: MovementStatus }) {
|
||||
const { t } = useTranslation();
|
||||
const cls =
|
||||
status === "authorized"
|
||||
? "border-term-green/60 text-term-green"
|
||||
: status === "denied"
|
||||
? "border-term-red/60 text-term-red"
|
||||
: "border-term-amber/60 text-term-amber";
|
||||
return (
|
||||
<span className={`rounded border px-1 text-[0.625rem] uppercase tracking-wider ${cls}`}>
|
||||
{t(`drawer.status.${status}`)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function DrawerManager({ canCreate, canReview }: { canCreate: boolean; canReview: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const refresh = () => {
|
||||
void qc.invalidateQueries({ queryKey: ["drawer"] });
|
||||
// A voucher moves the open shift's added/removed figures too (the X-report).
|
||||
void qc.invalidateQueries({ queryKey: ["shift"] });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col gap-3 overflow-y-auto p-3 lg:overflow-hidden">
|
||||
{/* Row 1: the till NOW + the record form. */}
|
||||
<div className="grid shrink-0 gap-3 lg:grid-cols-[1.3fr_1fr]">
|
||||
<StatePanel />
|
||||
{canCreate && <RecordPanel onDone={refresh} />}
|
||||
</div>
|
||||
|
||||
{/* Row 2: today's cash feed · the movement review queue · closed shifts. */}
|
||||
<div className="grid min-h-0 flex-1 gap-3 lg:grid-cols-3">
|
||||
<TodayPanel />
|
||||
<MovementsPanel canReview={canReview} onChanged={refresh} />
|
||||
<ShiftHistoryPanel />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- The drawer NOW ---------------------------------------------------------
|
||||
// Balance from the chain + the open shift's running X-report breakdown, so the big
|
||||
// number is always explainable: float + cash takings + in − out = expected = balance.
|
||||
|
||||
function StatePanel() {
|
||||
const { t } = useTranslation();
|
||||
const balance = useQuery({ queryKey: ["drawer", "balance"], queryFn: fetchDrawerBalance, refetchInterval: 10_000 });
|
||||
const status = useQuery({ queryKey: ["shift", "current"], queryFn: fetchShift });
|
||||
const report = useQuery({
|
||||
queryKey: ["shift", "xreport"],
|
||||
queryFn: fetchShiftReport,
|
||||
enabled: status.data?.open != null,
|
||||
refetchInterval: 10_000,
|
||||
});
|
||||
const x = status.data?.open ? report.data : null;
|
||||
const cur = balance.data?.currency ?? x?.currency ?? null;
|
||||
// The current SHIFT's own balance: what this shift changed in the till
|
||||
// (takings + vouchers), i.e. everything above the inherited opening float.
|
||||
const shiftDelta = x ? x.expectedDrawerMinor - x.openingFloatMinor : null;
|
||||
|
||||
return (
|
||||
<Panel title={t("drawer.stateTitle")}>
|
||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-3xl font-bold text-term-cyan tabular-nums">
|
||||
{balance.data ? money(balance.data.balanceMinor, cur) : "…"}
|
||||
</div>
|
||||
{shiftDelta != null && (
|
||||
<div className="mt-0.5 text-[0.8125rem] tabular-nums">
|
||||
<span className="text-term-muted">{t("drawer.thisShift")} </span>
|
||||
<span className={shiftDelta < 0 ? "font-semibold text-term-red" : "font-semibold text-term-green"}>
|
||||
{shiftDelta >= 0 ? "+" : ""}
|
||||
{money(shiftDelta, cur)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-0.5 text-[0.6875rem] text-term-muted">
|
||||
{status.data?.open
|
||||
? t("drawer.openShift", { operator: status.data.open.operator }) +
|
||||
" · " +
|
||||
formatRelativeDateTime(status.data.open.startedAt, t)
|
||||
: t("drawer.noShiftOpen")}
|
||||
</div>
|
||||
</div>
|
||||
{/* The running breakdown, only while a shift is open (it's the X-report). */}
|
||||
{x && (
|
||||
<dl className="grid grid-cols-[max-content_max-content] gap-x-4 gap-y-0.5 text-[0.75rem] tabular-nums">
|
||||
<dt className="text-term-muted">{t("shifts.openingFloat")}</dt>
|
||||
<dd className="text-right text-term-text">{money(x.openingFloatMinor, cur)}</dd>
|
||||
<dt className="text-term-muted">
|
||||
{t("shifts.cashTaken")} · {t("shifts.payments")} {x.paymentCount}
|
||||
</dt>
|
||||
<dd className="text-right text-term-green">{money(x.cashTotalMinor, cur)}</dd>
|
||||
<dt className="text-term-muted">{t("shifts.cashAdded")}</dt>
|
||||
<dd className="text-right text-term-text">{money(x.cashAddedMinor, cur)}</dd>
|
||||
<dt className="text-term-muted">{t("shifts.cashRemoved")}</dt>
|
||||
<dd className="text-right text-term-red">{money(-x.cashRemovedMinor, cur)}</dd>
|
||||
<dt className="border-t border-term-border pt-0.5 font-semibold text-term-muted">{t("shifts.expectedDrawer")}</dt>
|
||||
<dd className="border-t border-term-border pt-0.5 text-right font-semibold text-term-text">
|
||||
{money(x.expectedDrawerMinor, cur)}
|
||||
</dd>
|
||||
</dl>
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Today's cash activity ---------------------------------------------------
|
||||
// Every drawer-touching event since local midnight: cash payments (the current
|
||||
// shift's incomings, live) + vouchers. Card payments never enter the till.
|
||||
|
||||
function TodayPanel() {
|
||||
const { t } = useTranslation();
|
||||
const q = useQuery({
|
||||
queryKey: ["drawer", "today"],
|
||||
queryFn: () => fetchEvents(1000, startOfToday()),
|
||||
refetchInterval: 15_000,
|
||||
});
|
||||
|
||||
const rows = (q.data?.events ?? []).filter((e) => {
|
||||
if (e.type === "cash_in" || e.type === "cash_out") return true;
|
||||
if (e.type !== "payment") return false;
|
||||
return (e.payload as { tender?: string } | null)?.tender !== "card";
|
||||
});
|
||||
|
||||
let cashIn = 0;
|
||||
let vouchersNet = 0;
|
||||
let payments = 0;
|
||||
let cur: string | null = null;
|
||||
for (const e of rows) {
|
||||
const pl = (e.payload ?? {}) as { amountMinor?: number; currency?: string };
|
||||
const amt = pl.amountMinor ?? 0;
|
||||
if (pl.currency) cur = pl.currency;
|
||||
if (e.type === "payment") {
|
||||
cashIn += amt;
|
||||
payments++;
|
||||
} else {
|
||||
vouchersNet += e.type === "cash_in" ? Math.abs(amt) : -Math.abs(amt);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Panel
|
||||
title={t("drawer.todayTitle")}
|
||||
right={
|
||||
rows.length > 0 ? (
|
||||
<span className="text-[0.6875rem] tabular-nums text-term-muted">
|
||||
{t("drawer.todayPayments", { count: payments })} · <span className="text-term-green">{money(cashIn, cur)}</span>
|
||||
{vouchersNet !== 0 && (
|
||||
<>
|
||||
{" "}
|
||||
· <span className={vouchersNet < 0 ? "text-term-red" : "text-term-green"}>{money(vouchersNet, cur)}</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
) : null
|
||||
}
|
||||
className="min-h-0"
|
||||
>
|
||||
<div className="h-full min-h-0 overflow-y-auto pr-1">
|
||||
{q.isError ? (
|
||||
<div className="text-[0.75rem] text-term-red">{(q.error as Error).message}</div>
|
||||
) : q.isLoading ? (
|
||||
<div className="text-term-muted">{t("common.loading")}</div>
|
||||
) : rows.length === 0 ? (
|
||||
<div className="text-term-muted">{t("drawer.noActivity")}</div>
|
||||
) : (
|
||||
<table className="w-full text-[0.75rem] tabular-nums">
|
||||
<tbody>
|
||||
{rows.map((e) => (
|
||||
<TodayRow key={e.id} e={e} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
function TodayRow({ e }: { e: LedgerEvent }) {
|
||||
const { t } = useTranslation();
|
||||
const pl = (e.payload ?? {}) as { amountMinor?: number; currency?: string; voucherNo?: string; reason?: string };
|
||||
const amt = pl.amountMinor ?? 0;
|
||||
const signed = e.type === "cash_out" ? -Math.abs(amt) : Math.abs(amt);
|
||||
const time = formatClock(e.occurredAt);
|
||||
const label =
|
||||
e.type === "payment"
|
||||
? `${t("drawer.payment")}${e.identity ? ` · ${e.identity}` : ""}`
|
||||
: `${e.type === "cash_in" ? t("drawer.mandatArketimi") : t("drawer.mandatPagese")}${pl.voucherNo ? ` ${pl.voucherNo}` : ""}`;
|
||||
return (
|
||||
<tr className="border-t border-term-border/40">
|
||||
<td className="whitespace-nowrap py-1 pr-2 text-term-muted">{time}</td>
|
||||
<td className="max-w-0 truncate py-1 pr-2 text-term-text" title={pl.reason || undefined}>
|
||||
{label}
|
||||
</td>
|
||||
<td className={`whitespace-nowrap py-1 text-right ${signed < 0 ? "text-term-red" : "text-term-green"}`}>
|
||||
{money(signed, pl.currency ?? null)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Movements (record + review) — the pre-redesign feature, unchanged ------
|
||||
|
||||
function MovementsPanel({ canReview, onChanged }: { canReview: boolean; onChanged: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
// Reviewers can filter the list (the pending queue); operators always see their own, all.
|
||||
const [statusFilter, setStatusFilter] = useState<MovementStatus | "">("");
|
||||
const q = useQuery({
|
||||
queryKey: ["drawer", "movements", canReview ? statusFilter : ""],
|
||||
queryFn: () => fetchDrawerMovements(canReview && statusFilter ? statusFilter : undefined),
|
||||
});
|
||||
const movements = q.data?.movements ?? [];
|
||||
const pendingCount = movements.filter((m) => m.status === "pending").length;
|
||||
|
||||
return (
|
||||
<Panel
|
||||
title={canReview ? t("drawer.allTitle") : t("drawer.myTitle")}
|
||||
right={
|
||||
canReview && pendingCount > 0 ? (
|
||||
<span className="rounded border border-term-amber/60 px-1.5 text-[0.625rem] uppercase tracking-wider text-term-amber">
|
||||
{t("drawer.pendingCount", { count: pendingCount })}
|
||||
</span>
|
||||
) : null
|
||||
}
|
||||
className="min-h-0"
|
||||
>
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
{canReview && (
|
||||
<div className="mb-2 flex items-center gap-1.5">
|
||||
{(["", "pending", "authorized", "denied"] as const).map((s) => (
|
||||
<button
|
||||
key={s || "all"}
|
||||
type="button"
|
||||
onClick={() => setStatusFilter(s)}
|
||||
className={statusFilter === s ? "btn btn-primary btn-sm" : "btn btn-sm"}
|
||||
>
|
||||
{s === "" ? t("drawer.filterAll") : t(`drawer.status.${s}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto pr-1">
|
||||
{q.isLoading ? (
|
||||
<div className="text-term-muted">{t("common.loading")}</div>
|
||||
) : movements.length === 0 ? (
|
||||
<div className="text-term-muted">{t("drawer.empty")}</div>
|
||||
) : (
|
||||
<table className="w-full text-[0.75rem] tabular-nums">
|
||||
<thead className="sticky top-0 bg-term-panel-2 text-[0.6875rem] uppercase tracking-wider text-term-muted">
|
||||
<tr>
|
||||
<th className="px-2 py-1.5 text-left font-semibold">{t("drawer.colWhen")}</th>
|
||||
<th className="px-2 py-1.5 text-left font-semibold">{t("drawer.colType")}</th>
|
||||
<th className="px-2 py-1.5 text-right font-semibold">{t("drawer.colAmount")}</th>
|
||||
<th className="px-2 py-1.5 text-left font-semibold">{t("drawer.colReason")}</th>
|
||||
{canReview && <th className="px-2 py-1.5 text-left font-semibold">{t("drawer.colOperator")}</th>}
|
||||
<th className="px-2 py-1.5 text-left font-semibold">{t("drawer.colStatus")}</th>
|
||||
{canReview && <th className="px-2 py-1.5" />}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{movements.map((m) => (
|
||||
<MovementRow key={m.id} m={m} canReview={canReview} onReviewed={onChanged} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Closed shifts, drawer-focused -------------------------------------------
|
||||
// Scope follows /api/shifts: operators see their own, admins all.
|
||||
|
||||
function ShiftHistoryPanel() {
|
||||
const { t } = useTranslation();
|
||||
const q = useQuery({ queryKey: ["shifts", "drawer-history"], queryFn: () => fetchShifts() });
|
||||
const shifts = (q.data?.shifts ?? []).slice(0, 50);
|
||||
const showOperator = q.data?.scope === "all";
|
||||
|
||||
return (
|
||||
<Panel title={t("drawer.historyTitle")} className="min-h-0">
|
||||
<div className="h-full min-h-0 overflow-y-auto pr-1">
|
||||
{q.isLoading ? (
|
||||
<div className="text-term-muted">{t("common.loading")}</div>
|
||||
) : shifts.length === 0 ? (
|
||||
<div className="text-term-muted">{t("drawer.noShifts")}</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{shifts.map((s) => (
|
||||
<ShiftDrawerCard key={s.id} s={s} showOperator={showOperator} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
function ShiftDrawerCard({ s, showOperator }: { s: ShiftSummary; showOperator: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const cur = s.currency;
|
||||
return (
|
||||
<div className="card p-2.5 text-[0.75rem]">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-semibold text-term-text">
|
||||
{showOperator ? `${s.operator} · ` : ""}
|
||||
{formatRelativeDateTime(s.startedAt, t)}
|
||||
</span>
|
||||
<span className="font-semibold text-term-text tabular-nums" title={t("shifts.expectedDrawer")}>
|
||||
{money(s.expectedDrawerMinor, cur)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-0.5 flex flex-wrap gap-x-3 text-term-muted tabular-nums">
|
||||
<span title={t("shifts.openingFloat")}>{money(s.openingFloatMinor, cur)} →</span>
|
||||
<span className="text-term-green" title={t("shifts.cashTaken")}>
|
||||
+{money(s.cashTotalMinor, cur)}
|
||||
</span>
|
||||
{s.cashAddedMinor > 0 && (
|
||||
<span className="text-term-green" title={t("shifts.cashAdded")}>
|
||||
+{money(s.cashAddedMinor, cur)}
|
||||
</span>
|
||||
)}
|
||||
{s.cashRemovedMinor > 0 && (
|
||||
<span className="text-term-red" title={t("shifts.cashRemoved")}>
|
||||
−{money(s.cashRemovedMinor, cur)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Record form (unchanged from the pre-redesign feature) ------------------
|
||||
|
||||
function RecordPanel({ onDone }: { onDone: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const [amount, setAmount] = useState("");
|
||||
const [reason, setReason] = useState("");
|
||||
const [msg, setMsg] = useState<{ text: string; ok: boolean } | null>(null);
|
||||
const record = useMutation({
|
||||
mutationFn: (type: "cash_in" | "cash_out") =>
|
||||
recordDrawerMovement({ type, amountMinor: Math.round(Number(amount) * 100), reason: reason.trim() }),
|
||||
onSuccess: (r) => {
|
||||
setMsg({ ok: true, text: t("drawer.recorded", { no: r.voucherNo, amount: money(r.balanceMinor, null) }) });
|
||||
setAmount("");
|
||||
setReason("");
|
||||
onDone();
|
||||
},
|
||||
onError: (e) => setMsg({ ok: false, text: (e as Error).message }),
|
||||
});
|
||||
|
||||
function submit(type: "cash_in" | "cash_out") {
|
||||
setMsg(null);
|
||||
const major = Number(amount);
|
||||
if (!Number.isFinite(major) || major <= 0) {
|
||||
setMsg({ ok: false, text: t("drawer.enterPositive") });
|
||||
return;
|
||||
}
|
||||
record.mutate(type);
|
||||
}
|
||||
|
||||
return (
|
||||
<Panel title={t("drawer.recordTitle")}>
|
||||
<div className="flex flex-col gap-2 text-[0.8125rem]">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
className="input w-28"
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
placeholder={t("drawer.amount")}
|
||||
inputMode="decimal"
|
||||
/>
|
||||
<input
|
||||
className="input min-w-40 flex-1"
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
placeholder={t("drawer.reasonPlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-[0.6875rem] text-term-muted">{t("drawer.recordHint")}</div>
|
||||
{msg && (
|
||||
<div className={`text-[0.75rem] ${msg.ok ? "text-term-green" : "text-term-red"}`}>{msg.text}</div>
|
||||
)}
|
||||
<div className="flex justify-end gap-2">
|
||||
<button type="button" className="btn btn-go btn-sm" disabled={record.isPending} onClick={() => submit("cash_in")}>
|
||||
{t("drawer.mandatArketimi")}
|
||||
</button>
|
||||
<button type="button" className="btn btn-danger btn-sm" disabled={record.isPending} onClick={() => submit("cash_out")}>
|
||||
{t("drawer.mandatPagese")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
function MovementRow({ m, canReview, onReviewed }: { m: DrawerMovement; canReview: boolean; onReviewed: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const [note, setNote] = useState("");
|
||||
const [noteOpen, setNoteOpen] = useState(false);
|
||||
const review = useMutation({
|
||||
mutationFn: (decision: "authorize" | "deny") =>
|
||||
reviewDrawerMovement({ refId: m.id, decision, note: note.trim() || undefined }),
|
||||
onSuccess: onReviewed,
|
||||
});
|
||||
// Direction sign for display: cash_in is +, cash_out is −.
|
||||
const signed = m.type === "cash_in" ? m.amountMinor : -m.amountMinor;
|
||||
return (
|
||||
<tr className="border-t border-term-border/50 align-top">
|
||||
<td className="whitespace-nowrap px-2 py-1.5 text-term-muted">{formatRelativeDateTime(m.at, t)}</td>
|
||||
<td className="px-2 py-1.5">
|
||||
<span className={m.type === "cash_in" ? "text-term-green" : "text-term-red"}>
|
||||
{m.type === "cash_in" ? t("drawer.mandatArketimi") : t("drawer.mandatPagese")}
|
||||
</span>
|
||||
{m.voucherNo && <span className="ml-1 text-[0.625rem] text-term-muted">{m.voucherNo}</span>}
|
||||
</td>
|
||||
<td className={`whitespace-nowrap px-2 py-1.5 text-right ${signed < 0 ? "text-term-red" : "text-term-green"}`}>
|
||||
{money(signed, m.currency)}
|
||||
</td>
|
||||
<td className="px-2 py-1.5 text-term-text">{m.reason || "—"}</td>
|
||||
{canReview && <td className="px-2 py-1.5 text-term-muted">{m.operator}</td>}
|
||||
<td className="px-2 py-1.5">
|
||||
<StatusBadge status={m.status} />
|
||||
{m.status !== "pending" && m.reviewedBy && (
|
||||
<div className="mt-0.5 text-[0.5625rem] text-term-muted">
|
||||
{m.reviewedBy}
|
||||
{m.reviewNote ? ` · ${m.reviewNote}` : ""}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
{canReview && (
|
||||
<td className="px-2 py-1.5 text-right">
|
||||
{m.status === "pending" ? (
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<div className="flex gap-1">
|
||||
<button type="button" className="btn btn-go btn-sm" disabled={review.isPending} onClick={() => review.mutate("authorize")}>
|
||||
{t("drawer.authorize")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-danger btn-sm"
|
||||
disabled={review.isPending}
|
||||
onClick={() => (noteOpen ? review.mutate("deny") : setNoteOpen(true))}
|
||||
>
|
||||
{t("drawer.deny")}
|
||||
</button>
|
||||
</div>
|
||||
{noteOpen && (
|
||||
<input
|
||||
className="input w-44 text-[0.6875rem]"
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
placeholder={t("drawer.denyNotePlaceholder")}
|
||||
/>
|
||||
)}
|
||||
{review.isError && <span className="text-[0.625rem] text-term-red">{(review.error as Error).message}</span>}
|
||||
</div>
|
||||
) : null}
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
@@ -25,6 +25,10 @@ function LogRow({ log }: { log: AppLogRecord }) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const hasDetail = (log.context && Object.keys(log.context).length > 0) || log.stack;
|
||||
// Storm-coalesced row: the server folds repeated identical lines into one row and
|
||||
// counts them in context._repeat (first occurrence kept in _firstAt).
|
||||
const repeat = typeof log.context?._repeat === "number" ? (log.context?._repeat as number) : null;
|
||||
const firstAt = typeof log.context?._firstAt === "string" ? (log.context?._firstAt as string) : null;
|
||||
|
||||
return (
|
||||
<div className={`border-b border-term-border/50 ${log.level === "error" || log.level === "fatal" ? "bg-term-red/5" : ""}`}>
|
||||
@@ -38,7 +42,20 @@ function LogRow({ log }: { log: AppLogRecord }) {
|
||||
<span className="text-term-muted tabular-nums">{formatRelativeDateTime(log.createdAt, t)}</span>
|
||||
<span className={`font-semibold uppercase ${LEVEL_COLOR[log.level]}`}>{log.level}</span>
|
||||
<span className="text-term-muted">{t(log.source === "frontend" ? "logs.frontend" : "logs.backend")}</span>
|
||||
<span className="truncate text-term-text">{log.message}</span>
|
||||
<span className="truncate text-term-text">
|
||||
{repeat != null && repeat > 1 && (
|
||||
<span
|
||||
className="mr-1.5 rounded-term border border-term-amber/50 px-1 text-[0.625rem] font-semibold text-term-amber"
|
||||
title={t("logs.repeated", {
|
||||
count: repeat,
|
||||
firstAt: firstAt ? formatRelativeDateTime(firstAt, t) : "—",
|
||||
})}
|
||||
>
|
||||
×{repeat}
|
||||
</span>
|
||||
)}
|
||||
{log.message}
|
||||
</span>
|
||||
<span className="text-term-muted tabular-nums">{log.httpStatus ?? ""}</span>
|
||||
</button>
|
||||
{open && hasDetail && (
|
||||
|
||||
+95
-12
@@ -1,8 +1,10 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Fragment, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TFunction } from "i18next";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
@@ -12,6 +14,7 @@ import {
|
||||
LineChart,
|
||||
Pie,
|
||||
PieChart,
|
||||
ReferenceLine,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
@@ -37,6 +40,7 @@ const C = {
|
||||
border: "#2a2f38",
|
||||
text: "#f2f2ee",
|
||||
panel: "#14171c",
|
||||
panel2: "#1e222a",
|
||||
};
|
||||
|
||||
type PresetKey = "today" | "7d" | "30d" | "90d";
|
||||
@@ -140,27 +144,37 @@ function ReportBody({ data, t }: { data: ReportSummary; t: TFunction }) {
|
||||
...p,
|
||||
label: data.bucket === "hour" ? p.bucket.slice(11) + "h" : p.bucket,
|
||||
revenue: p.revenueMinor / 100,
|
||||
cash: p.cashMinor / 100,
|
||||
card: p.cardMinor / 100,
|
||||
}));
|
||||
const hours = data.entriesByHour.map((entries, h) => ({ hour: `${h}`, entries }));
|
||||
const mix = [
|
||||
{ name: t("reports.mix.ticket"), value: tot.ticketMinor, color: C.amber },
|
||||
{ name: t("reports.mix.subSales"), value: tot.subscriptionSalesMinor, color: C.cyan },
|
||||
{ name: t("reports.mix.subWindow"), value: tot.subscriptionWindowMinor, color: C.green },
|
||||
].filter((s) => s.value > 0);
|
||||
const peakOcc = Math.max(data.occupancyStart, ...data.series.map((p) => p.occupancyEnd));
|
||||
// Stay-duration bars: "≤30m … ≤24h" + the open-ended tail.
|
||||
const stay = data.stayHistogram.map((b) => ({
|
||||
label: b.uptoMin == null ? `>24${t("reports.stay.h")}` : b.uptoMin < 60 ? `≤${b.uptoMin}${t("reports.stay.m")}` : `≤${b.uptoMin / 60}${t("reports.stay.h")}`,
|
||||
count: b.count,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* KPI cards. */}
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-6">
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4 xl:grid-cols-8">
|
||||
<Kpi label={t("reports.kpi.entries")} value={String(tot.entries)} accent="green" />
|
||||
<Kpi label={t("reports.kpi.exits")} value={String(tot.exits)} accent="red" />
|
||||
<Kpi label={t("reports.kpi.revenue")} value={money(tot.revenueMinor)} accent="amber" />
|
||||
<Kpi label={t("reports.kpi.payments")} value={String(tot.payments)} accent="cyan" />
|
||||
<Kpi label={t("reports.kpi.avgStay")} value={formatMinutes(tot.avgParkedMinutes)} />
|
||||
<Kpi
|
||||
label={t("reports.kpi.subscribers")}
|
||||
value={String(data.subscriptions.currentlyValid)}
|
||||
label={t("reports.kpi.peakOcc")}
|
||||
value={data.capacity ? `${peakOcc} / ${data.capacity}` : String(peakOcc)}
|
||||
/>
|
||||
{/* The "look closer" counters — a spike here is what the signed chain is FOR. */}
|
||||
<Kpi label={t("reports.kpi.voids")} value={String(tot.voids)} accent={tot.voids > 0 ? "amber" : undefined} />
|
||||
<Kpi label={t("reports.kpi.anomalies")} value={String(tot.anomalies)} accent={tot.anomalies > 0 ? "red" : undefined} />
|
||||
</div>
|
||||
|
||||
{/* Entry / exit over time. */}
|
||||
@@ -192,8 +206,38 @@ function ReportBody({ data, t }: { data: ReportSummary; t: TFunction }) {
|
||||
</ResponsiveContainer>
|
||||
</Panel>
|
||||
|
||||
{/* Occupancy over time — THE parking curve: cars inside vs capacity. Step-shaped
|
||||
(occupancy only moves at entries/exits); the red line is the configured cap. */}
|
||||
<Panel title={t("reports.chart.occupancy")}>
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<AreaChart data={series} margin={{ top: 8, right: 12, bottom: 0, left: -8 }}>
|
||||
<CartesianGrid stroke={C.border} strokeDasharray="3 3" />
|
||||
<XAxis dataKey="label" stroke={C.muted} fontSize={11} />
|
||||
<YAxis stroke={C.muted} fontSize={11} allowDecimals={false} />
|
||||
<Tooltip contentStyle={tooltipStyle} />
|
||||
{data.capacity != null && (
|
||||
<ReferenceLine
|
||||
y={data.capacity}
|
||||
stroke={C.red}
|
||||
strokeDasharray="4 4"
|
||||
label={{ value: t("reports.capacityLine"), fill: C.red, fontSize: 11, position: "insideTopRight" }}
|
||||
/>
|
||||
)}
|
||||
<Area
|
||||
type="stepAfter"
|
||||
dataKey="occupancyEnd"
|
||||
name={t("reports.chart.occupancySeries")}
|
||||
stroke={C.cyan}
|
||||
fill={C.cyan}
|
||||
fillOpacity={0.15}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</Panel>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
{/* Revenue per bucket. */}
|
||||
{/* Revenue per bucket, stacked by tender — the drawer's cash vs the bank's card. */}
|
||||
<Panel title={t("reports.chart.revenue", { currency: cur })}>
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
<BarChart data={series} margin={{ top: 8, right: 12, bottom: 0, left: -8 }}>
|
||||
@@ -201,7 +245,9 @@ function ReportBody({ data, t }: { data: ReportSummary; t: TFunction }) {
|
||||
<XAxis dataKey="label" stroke={C.muted} fontSize={11} />
|
||||
<YAxis stroke={C.muted} fontSize={11} />
|
||||
<Tooltip contentStyle={tooltipStyle} formatter={(v) => money(Math.round(Number(v) * 100))} />
|
||||
<Bar dataKey="revenue" name={t("reports.kpi.revenue")} fill={C.amber} />
|
||||
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||
<Bar dataKey="cash" stackId="tender" name={t("reports.row.cash")} fill={C.amber} />
|
||||
<Bar dataKey="card" stackId="tender" name={t("reports.row.card")} fill={C.cyan} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</Panel>
|
||||
@@ -232,15 +278,15 @@ function ReportBody({ data, t }: { data: ReportSummary; t: TFunction }) {
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
{/* Peak hours (entries by hour-of-day). */}
|
||||
<Panel title={t("reports.chart.peakHours")}>
|
||||
{/* Stay-duration histogram — where the ladder/up-to breakpoints should sit. */}
|
||||
<Panel title={t("reports.chart.stay")}>
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
<BarChart data={hours} margin={{ top: 8, right: 12, bottom: 0, left: -8 }}>
|
||||
<BarChart data={stay} margin={{ top: 8, right: 12, bottom: 0, left: -8 }}>
|
||||
<CartesianGrid stroke={C.border} strokeDasharray="3 3" />
|
||||
<XAxis dataKey="hour" stroke={C.muted} fontSize={11} interval={1} />
|
||||
<XAxis dataKey="label" stroke={C.muted} fontSize={11} />
|
||||
<YAxis stroke={C.muted} fontSize={11} allowDecimals={false} />
|
||||
<Tooltip contentStyle={tooltipStyle} />
|
||||
<Bar dataKey="entries" name={t("reports.kpi.entries")} fill={C.cyan} />
|
||||
<Bar dataKey="count" name={t("reports.row.closed")} fill={C.green} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</Panel>
|
||||
@@ -262,6 +308,12 @@ function ReportBody({ data, t }: { data: ReportSummary; t: TFunction }) {
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
{/* Entries heatmap: hour × day-of-week. Weekday-vs-weekend patterns at a glance —
|
||||
the direct input for tariff windows (night rates, weekend cards, early bird). */}
|
||||
<Panel title={t("reports.chart.heatmap")}>
|
||||
<Heatmap matrix={data.entriesByDowHour} dows={t("reports.dowShort", { returnObjects: true }) as string[]} />
|
||||
</Panel>
|
||||
|
||||
<p className="text-[0.6875rem] text-term-muted">
|
||||
{t("reports.footnote", { tz: data.tz })}
|
||||
</p>
|
||||
@@ -269,6 +321,37 @@ function ReportBody({ data, t }: { data: ReportSummary; t: TFunction }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Hour-of-day × day-of-week entries heatmap: pure CSS grid, amber intensity scaled to
|
||||
* the busiest cell. Row 0 = Monday (server contract). Cell tooltip = exact count. */
|
||||
function Heatmap({ matrix, dows }: { matrix: number[][]; dows: string[] }) {
|
||||
const max = Math.max(1, ...matrix.flat());
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<div className="grid min-w-[560px] grid-cols-[max-content_repeat(24,1fr)] gap-px text-[0.625rem]">
|
||||
<span />
|
||||
{Array.from({ length: 24 }, (_, h) => (
|
||||
<span key={h} className="pb-0.5 text-center text-term-muted">
|
||||
{h % 3 === 0 ? h : ""}
|
||||
</span>
|
||||
))}
|
||||
{matrix.map((row, d) => (
|
||||
<Fragment key={d}>
|
||||
<span className="pr-1.5 leading-4 text-term-muted">{dows[d]}</span>
|
||||
{row.map((v, h) => (
|
||||
<span
|
||||
key={h}
|
||||
title={`${dows[d]} ${String(h).padStart(2, "0")}:00 — ${v}`}
|
||||
className="h-4 rounded-[1px]"
|
||||
style={{ background: v === 0 ? C.panel2 : C.amber, opacity: v === 0 ? 1 : 0.25 + 0.75 * (v / max) }}
|
||||
/>
|
||||
))}
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const tooltipStyle = {
|
||||
background: C.panel,
|
||||
border: `1px solid ${C.border}`,
|
||||
|
||||
@@ -6,10 +6,13 @@ import {
|
||||
discoverDevices,
|
||||
fetchBackendIps,
|
||||
fetchCatalog,
|
||||
fetchSiteConfig,
|
||||
fetchState,
|
||||
updatePresenceBypass,
|
||||
testAnpr,
|
||||
testDevice,
|
||||
testPrint,
|
||||
testRelay,
|
||||
unassignDevice,
|
||||
type AnprTestResult,
|
||||
type PrintTestResult,
|
||||
@@ -26,6 +29,7 @@ import {
|
||||
type RelayEvent,
|
||||
type RelaySpec,
|
||||
type TestResult,
|
||||
fetchUsbPrinters,
|
||||
} from "./api.js";
|
||||
import { Modal } from "./ui/Modal.js";
|
||||
|
||||
@@ -148,6 +152,8 @@ export function SetupWizard() {
|
||||
onChanged={reloadState}
|
||||
/>
|
||||
|
||||
<PresenceGatePanel />
|
||||
|
||||
{BOUND.map(({ key, titleKey, nounKey }) => (
|
||||
<CategorySection
|
||||
key={key}
|
||||
@@ -166,6 +172,63 @@ export function SetupWizard() {
|
||||
);
|
||||
}
|
||||
|
||||
/** Admin control (in the controller section) to BYPASS a presence signal when its device is
|
||||
* faulty. The entry button normally needs radar/loop AND camera; a dead device blocks legit
|
||||
* transient entry. Dropping a signal is signed (config_change) + flags every ticket issued
|
||||
* while bypassed. Persists until turned off. See wiki/concepts/entry-presence-bypass.md. */
|
||||
function PresenceGatePanel() {
|
||||
const { t } = useTranslation();
|
||||
const [radar, setRadar] = useState<boolean | null>(null);
|
||||
const [camera, setCamera] = useState<boolean | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSiteConfig()
|
||||
.then((c) => {
|
||||
setRadar(c.bypassPresenceRadar);
|
||||
setCamera(c.bypassPresenceCamera);
|
||||
})
|
||||
.catch((e) => setError((e as Error).message));
|
||||
}, []);
|
||||
|
||||
async function toggle(signal: "radar" | "camera", next: boolean) {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const c = await updatePresenceBypass({ [signal]: next });
|
||||
setRadar(c.bypassPresenceRadar);
|
||||
setCamera(c.bypassPresenceCamera);
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (radar == null || camera == null) return null;
|
||||
const active = radar || camera;
|
||||
|
||||
return (
|
||||
<div className="mb-6 rounded-term border border-term-border/60 px-4 py-3">
|
||||
<h3 className="mb-1 text-sm font-semibold text-term-text">{t("setup.presenceGateTitle")}</h3>
|
||||
<p className="hint mb-3 max-w">{t("setup.presenceGateHint")}</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="flex items-center gap-2 text-[0.8125rem]">
|
||||
<input type="checkbox" checked={radar} disabled={busy} onChange={(e) => toggle("radar", e.target.checked)} />
|
||||
{t("setup.presenceBypassRadar")}
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-[0.8125rem]">
|
||||
<input type="checkbox" checked={camera} disabled={busy} onChange={(e) => toggle("camera", e.target.checked)} />
|
||||
{t("setup.presenceBypassCamera")}
|
||||
</label>
|
||||
</div>
|
||||
{active && <p className="mt-2 text-[0.75rem] text-term-amber">⚠ {t("setup.presenceBypassActive")}</p>}
|
||||
{error && <p className="mt-2 text-[0.75rem] text-term-red">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CategorySection({
|
||||
category,
|
||||
title,
|
||||
@@ -195,8 +258,10 @@ function CategorySection({
|
||||
const [formFor, setFormFor] = useState<Assignment | "new" | null>(null);
|
||||
const [warnings, setWarnings] = useState<string[]>([]);
|
||||
|
||||
// Binding categories need a controller to point at first.
|
||||
const isBound = category !== "access";
|
||||
// Binding categories need a controller to point at first. Printers do NOT bind
|
||||
// (role + failoverRank route print jobs — see printer-routing.ts), so they are
|
||||
// addable on a controller-less box (e.g. the lab bench testing a USB printer).
|
||||
const isBound = category !== "access" && category !== "printer";
|
||||
const blockedNoController = isBound && controllers.length === 0;
|
||||
const editing = formFor && formFor !== "new" ? formFor : undefined;
|
||||
|
||||
@@ -305,6 +370,7 @@ function AssignmentRow({
|
||||
<strong className="text-term-text">{assignment.driverId}</strong>
|
||||
{host && <span className="tabular-nums text-term-muted">{host}</span>}
|
||||
<DeviceSummary assignment={assignment} controllers={controllers} />
|
||||
{assignment.category === "access" && <RelayTester assignment={assignment} />}
|
||||
{!assignment.enabled && <span className="text-term-amber">{t("setup.disabled")}</span>}
|
||||
<span className="flex-1" />
|
||||
{error && <span className="text-term-red">{error}</span>}
|
||||
@@ -318,6 +384,59 @@ function AssignmentRow({
|
||||
);
|
||||
}
|
||||
|
||||
/** Per-relay "Test" control on a SAVED controller row. Pulses a BARRIER relay to prove
|
||||
* the wiring — this physically opens the barrier, so it confirms first, and the server
|
||||
* signs the deliberate open into the ledger (reason setup.relayTest). radarAlert relays
|
||||
* are lamps, not barriers — excluded (pulsing one is meaningless/wrong). */
|
||||
function RelayTester({ assignment }: { assignment: Assignment }) {
|
||||
const { t } = useTranslation();
|
||||
const cfg = assignment.config as Record<string, unknown>;
|
||||
const relays = Array.isArray(cfg.relays) ? (cfg.relays as RelaySpec[]) : [];
|
||||
const barriers = relays.filter((r) => r.direction !== "radarAlert");
|
||||
const [busyRelay, setBusyRelay] = useState<number | null>(null);
|
||||
const [result, setResult] = useState<{ relay: number; ok: boolean; detail?: string } | null>(null);
|
||||
|
||||
if (barriers.length === 0) return null;
|
||||
|
||||
async function testRelayNow(relay: number) {
|
||||
if (!confirm(t("setup.confirmRelayTest", { relay }))) return;
|
||||
setBusyRelay(relay);
|
||||
setResult(null);
|
||||
try {
|
||||
const res = await testRelay(assignment.id, relay);
|
||||
setResult({ relay, ok: res.ok, detail: res.ok ? undefined : res.detail ?? res.reason });
|
||||
} catch (e) {
|
||||
setResult({ relay, ok: false, detail: (e as Error).message });
|
||||
} finally {
|
||||
setBusyRelay(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="flex flex-wrap items-center gap-1">
|
||||
{barriers.map((r) => (
|
||||
<button
|
||||
key={r.relay}
|
||||
type="button"
|
||||
className="btn btn-ghost btn-sm"
|
||||
onClick={() => testRelayNow(r.relay)}
|
||||
disabled={busyRelay != null}
|
||||
title={t("setup.testRelayTitle", { relay: r.relay })}
|
||||
>
|
||||
{busyRelay === r.relay ? t("setup.relayTesting") : t("setup.testRelay", { relay: r.relay })}
|
||||
</button>
|
||||
))}
|
||||
{result && (
|
||||
<span className={result.ok ? "text-term-green" : "text-term-red"}>
|
||||
{result.ok
|
||||
? t("setup.relayTestOk", { relay: result.relay })
|
||||
: t("setup.relayTestFailed", { relay: result.relay, detail: result.detail ?? "" })}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Inline summary of an assignment's direction/binding for the list. */
|
||||
function DeviceSummary({ assignment, controllers }: { assignment: Assignment; controllers: Assignment[] }) {
|
||||
const { t } = useTranslation();
|
||||
@@ -347,6 +466,16 @@ function DeviceSummary({ assignment, controllers }: { assignment: Assignment; co
|
||||
</span>
|
||||
);
|
||||
}
|
||||
// Printers don't bind to a barrier (routing is role + failoverRank) — show the
|
||||
// role instead of a bogus "unbound" warning.
|
||||
if (assignment.category === "printer") {
|
||||
const role = typeof cfg.role === "string" ? cfg.role : null;
|
||||
return role ? (
|
||||
<span className="text-term-muted">
|
||||
{t(role === "booth-receipt" ? "devices.role.booth" : "devices.role.lane")}
|
||||
</span>
|
||||
) : null;
|
||||
}
|
||||
// Bound device: show controller + relay it points at, with inherited direction.
|
||||
const controllerId = typeof cfg.controllerId === "string" ? cfg.controllerId : null;
|
||||
const relay = typeof cfg.relay === "number" ? cfg.relay : null;
|
||||
@@ -418,6 +547,28 @@ function DeviceForm({
|
||||
}
|
||||
return out;
|
||||
});
|
||||
// USB printers PRESENT on the box (/dev/usb/lpN + sysfs model) — fetched when a
|
||||
// printer form is on the USB transport, so devicePath becomes a SELECT of real
|
||||
// devices instead of a guessed path (the kernel may pick lp1 — park-buzi did).
|
||||
const [usbPrinters, setUsbPrinters] = useState<{ path: string; description: string | null }[] | null>(null);
|
||||
const usbTransport = isPrinter && String(config.transport ?? "tcp-ip") === "usb";
|
||||
useEffect(() => {
|
||||
if (!usbTransport) return;
|
||||
let alive = true;
|
||||
fetchUsbPrinters()
|
||||
.then((r) => {
|
||||
if (!alive) return;
|
||||
setUsbPrinters(r.printers);
|
||||
// Fresh form with no explicit path yet → preselect the first REAL device.
|
||||
if (r.printers.length > 0) {
|
||||
setConfig((c) => (c.devicePath == null ? { ...c, devicePath: r.printers[0]!.path } : c));
|
||||
}
|
||||
})
|
||||
.catch(() => alive && setUsbPrinters([]));
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [usbTransport]);
|
||||
// Controllers: the unified relay map. Each relay reacts to an EVENT — entry/exit/both
|
||||
// (pulse a barrier) or radarAlert (drive an alert lamp). Alert relays carry a trigger
|
||||
// input + blink cadence; barriers carry no input wiring (that lives in `inputs` below).
|
||||
@@ -570,7 +721,11 @@ function DeviceForm({
|
||||
...(i.role === "presence" && i.activeLow ? { activeLow: true } : {}),
|
||||
...(i.role === "button" && i.cooldownSec ? { cooldownSec: i.cooldownSec } : {}),
|
||||
}));
|
||||
} else if (controllerId && boundRelay !== "") {
|
||||
} else if (!isPrinter && controllerId && boundRelay !== "") {
|
||||
// Readers/cameras bind to a controller relay (which barrier a scan opens +
|
||||
// inherited direction). Printers do NOT — routing is role+failoverRank only,
|
||||
// so no binding is emitted (and a stale one saved before 2026-07-06 drops
|
||||
// off on the next edit).
|
||||
out.controllerId = controllerId;
|
||||
out.relay = boundRelay;
|
||||
}
|
||||
@@ -643,7 +798,9 @@ function DeviceForm({
|
||||
if (!selected) return;
|
||||
// Bound devices must point at a controller relay (binding is optional in the
|
||||
// model with a fallback, but the wizard guides the admin to bind explicitly).
|
||||
if (!isController && (!controllerId || boundRelay === "")) {
|
||||
// Printers are exempt: nothing consumes a printer's binding — their routing is
|
||||
// role + failoverRank (see printer-routing.ts).
|
||||
if (!isController && !isPrinter && (!controllerId || boundRelay === "")) {
|
||||
setSaveError("Pick the controller and relay this device sits at.");
|
||||
return;
|
||||
}
|
||||
@@ -756,7 +913,32 @@ function DeviceForm({
|
||||
{f.label}
|
||||
{f.required ? " *" : ""}
|
||||
</label>
|
||||
{f.type === "select" ? (
|
||||
{f.key === "devicePath" && usbPrinters != null && usbPrinters.length > 0 ? (
|
||||
// Real devices found → a select (path + self-reported model). A saved
|
||||
// path that is NOT currently present stays selectable, flagged.
|
||||
<select
|
||||
className="select"
|
||||
value={String(config.devicePath ?? (f.default as string | undefined) ?? "")}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
setConfig((c) => ({ ...c, devicePath: v }));
|
||||
resetStatus();
|
||||
}}
|
||||
>
|
||||
{(() => {
|
||||
const cur = String(config.devicePath ?? (f.default as string | undefined) ?? "");
|
||||
const missing = cur && !usbPrinters.some((u) => u.path === cur);
|
||||
return [
|
||||
...(missing ? [{ path: cur, description: t("setup.usbSavedMissing") }] : []),
|
||||
...usbPrinters,
|
||||
].map((u) => (
|
||||
<option key={u.path} value={u.path}>
|
||||
{u.description ? `${u.path} — ${u.description}` : u.path}
|
||||
</option>
|
||||
));
|
||||
})()}
|
||||
</select>
|
||||
) : f.type === "select" ? (
|
||||
<select
|
||||
className="select"
|
||||
value={String(config[f.key] ?? (f.default as string | number | undefined) ?? "")}
|
||||
@@ -811,6 +993,9 @@ function DeviceForm({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{f.key === "devicePath" && usbPrinters != null && usbPrinters.length === 0 && (
|
||||
<p className="hint mt-1">{t("setup.usbNoneFound")}</p>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
@@ -843,8 +1028,9 @@ function DeviceForm({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* BOUND device: which controller + relay it sits at. */}
|
||||
{!isController && (
|
||||
{/* BOUND device: which controller + relay it sits at. Not printers —
|
||||
nothing consumes a printer binding (role+rank routes print jobs). */}
|
||||
{!isController && !isPrinter && (
|
||||
<BindingPicker
|
||||
controllers={controllers}
|
||||
controllerId={controllerId}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
closeShift,
|
||||
fetchEvents,
|
||||
@@ -8,13 +8,14 @@ import {
|
||||
fetchShiftReport,
|
||||
fetchShifts,
|
||||
openShift,
|
||||
recordCashVoucher,
|
||||
type ShiftReport,
|
||||
type ShiftSummary,
|
||||
type SessionUser,
|
||||
} from "./api.js";
|
||||
import { formatMoney, formatDuration, formatRelativeDateTime } from "./lib/format.js";
|
||||
import { formatMoney, formatDuration, formatDateTime, formatRelativeDateTime } from "./lib/format.js";
|
||||
import { CARD_PAYMENTS_ENABLED } from "./lib/features.js";
|
||||
import { Modal } from "./ui/Modal.js";
|
||||
import { Spinner } from "./ui/Spinner.js";
|
||||
import { EventDetailModal, EventRow } from "./ui/event-detail.js";
|
||||
import type { LedgerEvent } from "@parking/shared";
|
||||
|
||||
@@ -88,7 +89,7 @@ function useCurrentShift(): { current: (ShiftSummary & { open: true }) | null; i
|
||||
};
|
||||
}
|
||||
|
||||
export function ShiftsHistory({ user, canManage = false, canVoucher = false }: { user: SessionUser | null; canManage?: boolean; canVoucher?: boolean }) {
|
||||
export function ShiftsHistory({ user, canManage = false }: { user: SessionUser | null; canManage?: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const [preset, setPreset] = useState<Preset>("week");
|
||||
const [operator, setOperator] = useState("");
|
||||
@@ -105,9 +106,17 @@ export function ShiftsHistory({ user, canManage = false, canVoucher = false }: {
|
||||
to: range?.to ? new Date(`${range.to}T23:59:59`).toISOString() : undefined,
|
||||
};
|
||||
|
||||
const q = useQuery({ queryKey: ["shifts", applied], queryFn: () => fetchShifts(applied) });
|
||||
// keepPreviousData: every filter change makes a NEW query key; without it the
|
||||
// data (and with it `scope`) goes undefined for the fetch round-trip, which
|
||||
// unmounted the admin filter controls mid-interaction and blanked the list.
|
||||
const q = useQuery({
|
||||
queryKey: ["shifts", applied],
|
||||
queryFn: () => fetchShifts(applied),
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
const isAdmin = q.data?.scope === "all";
|
||||
const closed = q.data?.shifts ?? [];
|
||||
const operators = q.data?.operators ?? [];
|
||||
|
||||
// The current/open shift sits at the TOP of the list (when present + visible to me).
|
||||
const list: (ShiftSummary & { open?: boolean })[] = current && (isMine || isAdmin) ? [current, ...closed] : closed;
|
||||
@@ -168,8 +177,15 @@ export function ShiftsHistory({ user, canManage = false, canVoucher = false }: {
|
||||
)}
|
||||
{isAdmin && (
|
||||
<div className="field">
|
||||
<span className="label">{t("shifts.operator")}</span>
|
||||
<input className="input w-44" value={operator} onChange={(e) => setOperator(e.target.value)} placeholder={t("shifts.allOperators")} />
|
||||
{/* <span className="label">{t("shifts.operator")}</span> */}
|
||||
{/* A select over operators that HAVE shifts — the server filter is an
|
||||
exact username match, so free text could only miss. */}
|
||||
<select className="input w-44" value={operator} onChange={(e) => setOperator(e.target.value)}>
|
||||
<option value="">{t("shifts.allOperators")}</option>
|
||||
{operators.map((op) => (
|
||||
<option key={op} value={op}>{op}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -198,7 +214,6 @@ export function ShiftsHistory({ user, canManage = false, canVoucher = false }: {
|
||||
isMine={isMine}
|
||||
showOperator={isAdmin}
|
||||
canManage={canManage}
|
||||
canVoucher={canVoucher}
|
||||
onChanged={refreshAll}
|
||||
/>
|
||||
) : (
|
||||
@@ -230,7 +245,13 @@ function StartShiftButton({ onDone }: { onDone: () => void }) {
|
||||
<span className="flex items-center gap-2">
|
||||
{err && <span className="text-[0.75rem] text-term-red">{err}</span>}
|
||||
<button type="button" className="btn btn-go btn-sm" onClick={start} disabled={busy}>
|
||||
{busy ? t("shift.starting") : t("shift.startShift")}
|
||||
{busy ? (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Spinner /> {t("shift.starting")}
|
||||
</span>
|
||||
) : (
|
||||
t("shift.startShift")
|
||||
)}
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
@@ -257,7 +278,7 @@ function ShiftCard({ s, showOperator, open, selected, onClick }: { s: ShiftSumma
|
||||
<div className="mt-1 flex flex-wrap gap-x-3 tabular-nums">
|
||||
<span className="text-term-muted">{t("shifts.payments")} {s.paymentCount}</span>
|
||||
<span className="text-term-green">{money(s.cashTotalMinor, cur)}</span>
|
||||
<span className="text-term-cyan">{money(s.cardTotalMinor, cur)}</span>
|
||||
{CARD_PAYMENTS_ENABLED && <span className="text-term-cyan">{money(s.cardTotalMinor, cur)}</span>}
|
||||
<span className="ml-auto font-semibold text-term-text" title={t("shifts.expectedDrawer")}>{money(s.expectedDrawerMinor, cur)}</span>
|
||||
</div>
|
||||
</button>
|
||||
@@ -270,7 +291,6 @@ function ShiftActivityLog({
|
||||
isMine,
|
||||
showOperator,
|
||||
canManage,
|
||||
canVoucher,
|
||||
onChanged,
|
||||
}: {
|
||||
shift: ShiftSummary;
|
||||
@@ -278,11 +298,10 @@ function ShiftActivityLog({
|
||||
isMine: boolean;
|
||||
showOperator: boolean;
|
||||
canManage: boolean;
|
||||
canVoucher: boolean;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [modal, setModal] = useState<null | "end" | "voucher" | "takings">(null);
|
||||
const [modal, setModal] = useState<null | "end" | "takings">(null);
|
||||
// Click an activity row → the SAME read-only event-detail modal the booth feed opens
|
||||
// (full signed payload + snapshots + chain provenance).
|
||||
const [detailEvent, setDetailEvent] = useState<LedgerEvent | null>(null);
|
||||
@@ -311,7 +330,6 @@ function ShiftActivityLog({
|
||||
{isCurrent && isMine && canManage && (
|
||||
<span className="flex flex-wrap gap-1.5">
|
||||
<button type="button" className="btn btn-sm" onClick={() => setModal("takings")}>{t("shift.viewTakings")}</button>
|
||||
{canVoucher && <button type="button" className="btn btn-sm" onClick={() => setModal("voucher")}>{t("shift.drawerVoucher")}</button>}
|
||||
<button type="button" className="btn btn-sm btn-danger" onClick={() => setModal("end")}>{t("shift.endShift")}</button>
|
||||
</span>
|
||||
)}
|
||||
@@ -325,7 +343,7 @@ function ShiftActivityLog({
|
||||
<Figure label={t("shifts.cashTaken")} value={money(shift.cashTotalMinor, cur)} />
|
||||
<Figure label={t("shifts.cashAdded")} value={money(shift.cashAddedMinor, cur)} />
|
||||
<Figure label={t("shifts.cashRemoved")} value={money(shift.cashRemovedMinor, cur)} />
|
||||
<Figure label={t("shifts.card")} value={money(shift.cardTotalMinor, cur)} />
|
||||
{CARD_PAYMENTS_ENABLED && <Figure label={t("shifts.card")} value={money(shift.cardTotalMinor, cur)} />}
|
||||
<Figure label={t("shifts.expectedDrawer")} value={money(shift.expectedDrawerMinor, cur)} bold />
|
||||
</div>
|
||||
</div>
|
||||
@@ -340,7 +358,6 @@ function ShiftActivityLog({
|
||||
|
||||
{detailEvent && <EventDetailModal e={detailEvent} onClose={() => setDetailEvent(null)} />}
|
||||
{modal === "end" && <EndShiftModal shift={shift} onClose={() => setModal(null)} onDone={onChanged} />}
|
||||
{modal === "voucher" && <VoucherModal currency={cur} onClose={() => setModal(null)} onDone={onChanged} />}
|
||||
{modal === "takings" && <TakingsModal onClose={() => setModal(null)} />}
|
||||
</div>
|
||||
);
|
||||
@@ -385,7 +402,7 @@ function EndShiftModal({ shift, onClose, onDone }: { shift: ShiftSummary; onClos
|
||||
</div>
|
||||
<div className="mt-1 grid grid-cols-2 gap-x-6 gap-y-0.5 border-t border-term-border pt-1">
|
||||
<Figure label={t("shift.cash")} value={money(report.cashTotalMinor, report.currency)} />
|
||||
<Figure label={t("shift.card")} value={money(report.cardTotalMinor, report.currency)} />
|
||||
{CARD_PAYMENTS_ENABLED && <Figure label={t("shift.card")} value={money(report.cardTotalMinor, report.currency)} />}
|
||||
<Figure label={t("shift.openingFloat")} value={money(report.openingFloatMinor, report.currency)} />
|
||||
<Figure label={t("shift.cashAdded")} value={money(report.cashAddedMinor, report.currency)} />
|
||||
<Figure label={t("shift.cashRemoved")} value={money(report.cashRemovedMinor, report.currency)} />
|
||||
@@ -411,7 +428,7 @@ function EndShiftModal({ shift, onClose, onDone }: { shift: ShiftSummary; onClos
|
||||
</div>
|
||||
<div className="mt-2 grid grid-cols-2 gap-x-6 gap-y-0.5 border-t border-term-border pt-2">
|
||||
<Figure label={t("shift.cash")} value={money(shift.cashTotalMinor, cur)} />
|
||||
<Figure label={t("shift.card")} value={money(shift.cardTotalMinor, cur)} />
|
||||
{CARD_PAYMENTS_ENABLED && <Figure label={t("shift.card")} value={money(shift.cardTotalMinor, cur)} />}
|
||||
{/* Drawer math made explicit: opening cash + cash taken = expected drawer. */}
|
||||
<Figure label={t("shift.openingFloat")} value={money(shift.openingFloatMinor, cur)} />
|
||||
<span />
|
||||
@@ -430,54 +447,6 @@ function EndShiftModal({ shift, onClose, onDone }: { shift: ShiftSummary; onClos
|
||||
);
|
||||
}
|
||||
|
||||
function VoucherModal({ currency, onClose, onDone }: { currency: string | null; onClose: () => void; onDone: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const [amount, setAmount] = useState("");
|
||||
const [reason, setReason] = useState("");
|
||||
const [authName, setAuthName] = useState("");
|
||||
const [authPassword, setAuthPassword] = useState("");
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
|
||||
async function submit(type: "cash_in" | "cash_out") {
|
||||
setMsg(null);
|
||||
const major = Number(amount);
|
||||
if (!Number.isFinite(major) || major <= 0) return setMsg(t("shift.enterPositive"));
|
||||
if (!authName.trim() || !authPassword) return setMsg(t("shift.authRequired"));
|
||||
try {
|
||||
const r = await recordCashVoucher({ type, amountMinor: Math.round(major * 100), reason: reason.trim(), authorizedBy: authName.trim(), authorizerPassword: authPassword });
|
||||
setMsg(t("shift.voucherRecorded", { no: r.voucherNo, amount: money(r.balanceMinor, currency) }));
|
||||
setAmount("");
|
||||
setReason("");
|
||||
setAuthPassword("");
|
||||
onDone();
|
||||
} catch (e) {
|
||||
setMsg((e as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal open onClose={onClose} title={t("shift.drawerVoucher")} width="max-w-md">
|
||||
<div className="flex flex-col gap-2 text-[0.8125rem]">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<input className="input w-28" value={amount} onChange={(e) => setAmount(e.target.value)} placeholder={t("shift.amount")} inputMode="decimal" />
|
||||
<input className="input min-w-36 flex-1" value={reason} onChange={(e) => setReason(e.target.value)} placeholder={t("shift.reasonPlaceholder")} />
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<input className="input w-36" value={authName} onChange={(e) => setAuthName(e.target.value)} placeholder={t("shift.authName")} autoComplete="off" />
|
||||
<input className="input w-36" type="password" value={authPassword} onChange={(e) => setAuthPassword(e.target.value)} placeholder={t("shift.authPassword")} autoComplete="off" />
|
||||
</div>
|
||||
<div className="text-[0.6875rem] text-term-muted">{t("shift.voucherHint")}</div>
|
||||
{msg && <div className="text-[0.75rem] text-term-muted">{msg}</div>}
|
||||
<div className="mt-1 flex justify-end gap-2">
|
||||
<button type="button" className="btn btn-sm" onClick={onClose}>{t("common.close")}</button>
|
||||
<button type="button" className="btn btn-go btn-sm" onClick={() => submit("cash_in")}>{t("shift.mandatArketimi")}</button>
|
||||
<button type="button" className="btn btn-danger btn-sm" onClick={() => submit("cash_out")}>{t("shift.mandatPagese")}</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function TakingsModal({ onClose }: { onClose: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const q = useQuery({ queryKey: ["shift", "xreport", "modal"], queryFn: fetchShiftReport });
|
||||
@@ -488,7 +457,7 @@ function TakingsModal({ onClose }: { onClose: () => void }) {
|
||||
<p className="text-[0.75rem] text-term-muted">{t("common.loading")}</p>
|
||||
) : (
|
||||
<div className="text-[0.8125rem] tabular-nums">
|
||||
<div className="text-term-muted">{t("shift.asOf")} {new Date(x.asOf).toLocaleString()}</div>
|
||||
<div className="text-term-muted">{t("shift.asOf")} {formatDateTime(x.asOf, t)}</div>
|
||||
<div className="mt-1 grid grid-cols-2 gap-x-6 gap-y-0.5">
|
||||
<Figure label={t("shift.payments")} value={String(x.paymentCount)} />
|
||||
<span />
|
||||
@@ -500,7 +469,7 @@ function TakingsModal({ onClose }: { onClose: () => void }) {
|
||||
</div>
|
||||
<div className="mt-1 grid grid-cols-2 gap-x-6 gap-y-0.5 border-t border-term-border pt-1">
|
||||
<Figure label={t("shift.cash")} value={money(x.cashTotalMinor, x.currency)} />
|
||||
<Figure label={t("shift.card")} value={money(x.cardTotalMinor, x.currency)} />
|
||||
{CARD_PAYMENTS_ENABLED && <Figure label={t("shift.card")} value={money(x.cardTotalMinor, x.currency)} />}
|
||||
<Figure label={t("shift.openingFloat")} value={money(x.openingFloatMinor, x.currency)} />
|
||||
<Figure label={t("shift.cashAdded")} value={money(x.cashAddedMinor, x.currency)} />
|
||||
<Figure label={t("shift.cashRemoved")} value={money(x.cashRemovedMinor, x.currency)} />
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { fetchOccupancy, fetchSiteConfig, saveSiteConfig, type Occupancy, type SiteConfig } from "./api.js";
|
||||
import {
|
||||
fetchOccupancy,
|
||||
fetchSiteConfig,
|
||||
fetchValidationPrograms,
|
||||
saveSiteConfig,
|
||||
saveValidationProgram,
|
||||
type Occupancy,
|
||||
type SiteConfig,
|
||||
type ValidationProgramView,
|
||||
} from "./api.js";
|
||||
import { STATIONS, ValidationStationsPanel, defaultProgram, type StationId } from "./ValidationSetup.js";
|
||||
|
||||
// Live occupancy + capacity + park metadata. Occupancy is shown to everyone (it's a
|
||||
// fold over the signed ledger); capacity and the metadata fields are admin-editable.
|
||||
@@ -28,12 +38,21 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
|
||||
const [reserveSubs, setReserveSubs] = useState(false);
|
||||
const [anprEntry, setAnprEntry] = useState(true);
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
// Merchant-validation programs (bar / lavazh). The checkboxes below toggle a
|
||||
// station's `active` (persisted at once — each flip signs a config_change); the
|
||||
// right-column panel edits the enabled stations. See validation-discounts.md.
|
||||
const [programs, setPrograms] = useState<ValidationProgramView[]>([]);
|
||||
|
||||
function reload() {
|
||||
fetchOccupancy().then(setOcc).catch(() => {});
|
||||
}
|
||||
useEffect(() => {
|
||||
reload();
|
||||
if (canEdit) {
|
||||
fetchValidationPrograms()
|
||||
.then((r) => setPrograms(r.programs))
|
||||
.catch(() => {});
|
||||
}
|
||||
fetchSiteConfig()
|
||||
.then((c) => {
|
||||
setCapInput(c.capacity == null ? "" : String(c.capacity));
|
||||
@@ -45,7 +64,23 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
|
||||
setMeta(m);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
}, [canEdit]);
|
||||
|
||||
/** Flip a merchant station's checkbox: persist `active` at once (a signed
|
||||
* config_change server-side), creating the well-known row with comp defaults on
|
||||
* the first enable. Config details are edited in the right-column panel. */
|
||||
async function toggleStation(id: StationId, active: boolean) {
|
||||
const existing = programs.find((p) => p.id === id);
|
||||
const body = existing
|
||||
? { ...existing, active }
|
||||
: { ...defaultProgram(id, t(id === "bar" ? "val.enableBar" : "val.enableLavazh")), active };
|
||||
try {
|
||||
const saved = await saveValidationProgram(id, body);
|
||||
setPrograms((ps) => [...ps.filter((p) => p.id !== id), saved]);
|
||||
} catch (e) {
|
||||
setMsg((e as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
setMsg(null);
|
||||
@@ -68,7 +103,8 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="card mt-6 max-w-md p-4">
|
||||
<div className="mt-6 flex flex-wrap items-start gap-6">
|
||||
<section className="card w-full max-w-md p-4">
|
||||
<div className="flex flex-wrap items-center gap-1.5 text-[0.8125rem]">
|
||||
<strong className="uppercase tracking-wider text-term-muted">{t("site.occupancy")}</strong>
|
||||
{occ == null ? (
|
||||
@@ -127,6 +163,23 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
|
||||
<span className="hint block">{t("site.anprEntryHint")}</span>
|
||||
</span>
|
||||
</label>
|
||||
<div className="border-t border-term-border pt-3 text-[0.6875rem] uppercase tracking-wider text-term-muted">
|
||||
{t("val.sectionTitle")}
|
||||
</div>
|
||||
<span className="hint -mt-2">{t("val.sectionHint")}</span>
|
||||
<div className="flex gap-6">
|
||||
{STATIONS.map((id) => (
|
||||
<label key={id} className="flex items-center gap-2 text-[0.75rem] text-term-text">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="accent-term-amber"
|
||||
checked={programs.find((p) => p.id === id)?.active ?? false}
|
||||
onChange={(e) => toggleStation(id, e.target.checked)}
|
||||
/>
|
||||
{t(id === "bar" ? "val.enableBar" : "val.enableLavazh")}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<div className="border-t border-term-border pt-3 text-[0.6875rem] uppercase tracking-wider text-term-muted">
|
||||
{t("site.parkDetails")}
|
||||
</div>
|
||||
@@ -158,5 +211,12 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
{canEdit && (
|
||||
<ValidationStationsPanel
|
||||
programs={programs}
|
||||
onSaved={(p) => setPrograms((ps) => [...ps.filter((x) => x.id !== p.id), p])}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,7 +25,9 @@ import {
|
||||
type SubscriptionPlan,
|
||||
type SubscriptionQuote,
|
||||
} from "./api.js";
|
||||
import { CARD_PAYMENTS_ENABLED } from "./lib/features.js";
|
||||
import { Modal } from "./ui/Modal.js";
|
||||
import { formatDateTime, type TFn } from "./lib/format.js";
|
||||
|
||||
// Subscription admin. Create/edit/revoke/delete subscriptions + their credentials
|
||||
// (card/QR) and bound plates. A SALE is priced by selecting an admin-defined PLAN over
|
||||
@@ -178,9 +180,9 @@ function daysLabel(days: number[] | undefined, t: (k: string) => string): string
|
||||
|
||||
/** A one-line label for a plan VERSION in the correction picker: effective date + its
|
||||
* timeframe summary (or "24/7" when the version has no window). */
|
||||
function versionLabel(v: SubscriptionPlan, t: (k: string) => string): string {
|
||||
function versionLabel(v: SubscriptionPlan, t: TFn): string {
|
||||
const eff = new Date(v.effectiveFrom);
|
||||
const date = Number.isNaN(eff.getTime()) ? v.effectiveFrom : eff.toLocaleString();
|
||||
const date = Number.isNaN(eff.getTime()) ? v.effectiveFrom : formatDateTime(v.effectiveFrom, t);
|
||||
const tf = v.timeframes;
|
||||
const rules = tf ? `${daysLabel(tf.days, t)} ${hhmm(tf.fromMin)}–${hhmm(tf.toMin)}` : t("subs.allDay");
|
||||
return `${date} · ${rules}`;
|
||||
@@ -537,7 +539,11 @@ export function SubscriptionManager({ user }: { user: SessionUser | null }) {
|
||||
)}
|
||||
{/* Tender — only relevant when selling a plan (a SALE). The sale appends a
|
||||
signed payment so the money shows in the feed/drawer/Z-report. */}
|
||||
{form.planId.trim() !== "" && editing === "new" && (
|
||||
{/* Tender picker — only meaningful when there's a choice. Card is hidden until a
|
||||
P2PE POS terminal is on-site (CARD_PAYMENTS_ENABLED); with cash-only there's
|
||||
nothing to pick, so the whole row is suppressed (form.tender stays "cash").
|
||||
See lib/features.ts + wiki/concepts/card-payments.md. */}
|
||||
{form.planId.trim() !== "" && editing === "new" && CARD_PAYMENTS_ENABLED && (
|
||||
<>
|
||||
<label className="label">{t("subs.tender")}</label>
|
||||
<span className="flex items-center gap-3">
|
||||
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
type SubscriptionPlan,
|
||||
} from "./api.js";
|
||||
import { Modal } from "./ui/Modal.js";
|
||||
import { formatDate } from "./lib/format.js";
|
||||
import { currencyOptions } from "./lib/currencies.js";
|
||||
|
||||
// Admin-only subscription PLAN catalog. Plans are admin-composed, versioned config the
|
||||
// operator sells from (so the operator never types a price). Editing a plan PUBLISHES A
|
||||
@@ -276,7 +278,7 @@ export function SubscriptionPlansManager() {
|
||||
{(p.pricePerPeriodMinor / 100).toLocaleString()} {p.currency} / {t(PERIOD_KEY[p.period])}
|
||||
</span>
|
||||
<span>{timeframesSummary(p.timeframes, t)}</span>
|
||||
<span>{t("plans.colEffective")}: {new Date(p.effectiveFrom).toLocaleDateString()}</span>
|
||||
<span>{t("plans.colEffective")}: {formatDate(p.effectiveFrom, t)}</span>
|
||||
</div>
|
||||
|
||||
{/* Used by */}
|
||||
@@ -348,7 +350,11 @@ export function SubscriptionPlansManager() {
|
||||
<label className="label">{t("plans.pricePer")}</label>
|
||||
<span className="flex items-center gap-2">
|
||||
<input className="input w-28" value={form.priceMajor} inputMode="decimal" onChange={(e) => setForm((f) => f && { ...f, priceMajor: e.target.value })} placeholder="e.g. 800" />
|
||||
<input className="input w-16" value={form.currency} onChange={(e) => setForm((f) => f && { ...f, currency: e.target.value })} />
|
||||
<select className="input w-auto" value={form.currency} onChange={(e) => setForm((f) => f && { ...f, currency: e.target.value })}>
|
||||
{currencyOptions(form.currency).map((c) => (
|
||||
<option key={c} value={c}>{c}</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="text-[0.75rem] text-term-muted">/ {t(PERIOD_KEY[form.period])}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
+88
-559
@@ -1,267 +1,28 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ApiError,
|
||||
fetchTariff,
|
||||
isTariffV2,
|
||||
publishTariffVersion,
|
||||
type TariffBlock,
|
||||
type TariffCard,
|
||||
type TariffStep,
|
||||
type TariffStructure,
|
||||
type TariffState,
|
||||
} from "./api.js";
|
||||
import { ApiError, fetchTariff, publishTariffVersion, type TariffState, type TariffVersion } from "./api.js";
|
||||
import { TariffEditorForm, emptyForm, formFromActive, formFromVersion, toStructure, type FormState } from "./TariffEditorForm.js";
|
||||
import { formatDateTime } from "./lib/format.js";
|
||||
|
||||
// Tariff composer — the admin builds + edits the rate card at runtime. Publishing
|
||||
// Tariff composer — the admin edits + publishes the LIVE rate card. Publishing
|
||||
// creates a new IMMUTABLE version (the active card); old versions are kept so past
|
||||
// sessions reprice correctly. Amounts are entered in major units (e.g. euros) for
|
||||
// usability and converted to integer minor units on submit. See wiki/concepts/tariff.md.
|
||||
|
||||
// Editable form mirror of TariffStructure, but money in major-unit strings.
|
||||
// Blocks are edited as a DURATION in hours ("this band lasts N hours") — the
|
||||
// owner thinks "first 2 hours, then next 3 hours", not in cumulative minutes.
|
||||
// The LAST block is always open-ended ("thereafter"): its hours field is unused
|
||||
// and it has no bound. On submit, per-block hours accumulate into the engine's
|
||||
// cumulative `uptoMin` (minutes), and the last block emits uptoMin: null.
|
||||
interface BlockForm {
|
||||
hours: string; // duration of THIS band, in hours (ignored for the last block)
|
||||
price: string; // major units, e.g. "2.00"
|
||||
}
|
||||
// One STEPPED ("up-to") row: "a stay up to N hours costs TOTAL". The owner enters the
|
||||
// matrix verbatim (totals, not marginal rates). See wiki/concepts/tariff.md.
|
||||
interface StepForm {
|
||||
hours: string; // inclusive upper bound of this tier, in hours (e.g. "3")
|
||||
total: string; // TOTAL major units for a stay within this tier (e.g. "5.00")
|
||||
}
|
||||
// A pricing body the form edits: a flat rate, a marginal block ladder, or a stepped
|
||||
// (up-to) total-by-duration table.
|
||||
interface PricingForm {
|
||||
mode: "ladder" | "flat" | "stepped";
|
||||
flat: string; // major units (used when mode==="flat")
|
||||
blocks: BlockForm[]; // hours-based ladder (used when mode==="ladder")
|
||||
steps: StepForm[]; // up-to tiers (used when mode==="stepped")
|
||||
dailyCap: string; // "" = no cap (ladder only)
|
||||
}
|
||||
// An optional time/category TIER (a V2 windowed card). Absent windows = unconstrained.
|
||||
interface TierForm {
|
||||
name: string;
|
||||
priority: string;
|
||||
category: string; // "" = applies to all categories
|
||||
dow: number[]; // selected days 0..6; empty = every day
|
||||
fromHour: string; // "" = all day
|
||||
toHour: string;
|
||||
dateFrom: string; // "" = unbounded
|
||||
dateTo: string;
|
||||
pricing: PricingForm;
|
||||
}
|
||||
interface FormState {
|
||||
currency: string;
|
||||
gracePeriodEntryMin: string;
|
||||
incrementMin: string;
|
||||
lostTicket: string;
|
||||
gracePeriodExitMin: string;
|
||||
// The default (always-active) card — its own flat/ladder body + daily cap.
|
||||
base: PricingForm;
|
||||
// Optional time/category tiers. Empty ⇒ a bare V1 structure is published.
|
||||
tiers: TierForm[];
|
||||
}
|
||||
|
||||
const toMinor = (major: string): number => Math.round(parseFloat(major || "0") * 100);
|
||||
const toMajor = (minor: number): string => (minor / 100).toFixed(2);
|
||||
|
||||
function emptySteps(): StepForm[] {
|
||||
return [
|
||||
{ hours: "1", total: "2.00" },
|
||||
{ hours: "3", total: "5.00" },
|
||||
];
|
||||
}
|
||||
function emptyLadder(): PricingForm {
|
||||
return {
|
||||
mode: "ladder",
|
||||
flat: "0.00",
|
||||
dailyCap: "",
|
||||
blocks: [{ hours: "1", price: "2.00" }, { hours: "", price: "1.00" }],
|
||||
steps: emptySteps(),
|
||||
};
|
||||
}
|
||||
function emptyTier(): TierForm {
|
||||
return {
|
||||
name: "",
|
||||
priority: "10",
|
||||
category: "",
|
||||
dow: [],
|
||||
fromHour: "",
|
||||
toHour: "",
|
||||
dateFrom: "",
|
||||
dateTo: "",
|
||||
pricing: { ...emptyLadder(), blocks: [{ hours: "", price: "1.00" }] },
|
||||
};
|
||||
}
|
||||
|
||||
function emptyForm(): FormState {
|
||||
return {
|
||||
currency: "EUR",
|
||||
gracePeriodEntryMin: "15",
|
||||
incrementMin: "60",
|
||||
lostTicket: "20.00",
|
||||
gracePeriodExitMin: "15",
|
||||
base: emptyLadder(),
|
||||
tiers: [],
|
||||
};
|
||||
}
|
||||
|
||||
// Convert a stored block ladder's cumulative `uptoMin` (minutes) into the per-band
|
||||
// hours the form edits. Open-ended last band has no hours. Legacy bounded tails still
|
||||
// load (shown as their own band).
|
||||
function blocksToForm(blocks: TariffBlock[]): BlockForm[] {
|
||||
let prev = 0;
|
||||
return blocks.map((b) => {
|
||||
if (b.uptoMin == null) return { hours: "", price: toMajor(b.priceMinorPerIncrement) };
|
||||
const hours = (b.uptoMin - prev) / 60;
|
||||
prev = b.uptoMin;
|
||||
return { hours: String(hours), price: toMajor(b.priceMinorPerIncrement) };
|
||||
});
|
||||
}
|
||||
|
||||
// A stored stepped table's `uptoMin` (minutes) → the per-tier hours the form edits.
|
||||
function stepsToForm(steps: TariffStep[]): StepForm[] {
|
||||
return steps.map((s) => ({ hours: String(s.uptoMin / 60), total: toMajor(s.totalMinor) }));
|
||||
}
|
||||
|
||||
// A stored card (V2) or bare-V1 body → the form's PricingForm (flat, ladder, or stepped).
|
||||
function pricingFromCard(c: {
|
||||
flatMinor?: number;
|
||||
blocks?: TariffBlock[];
|
||||
steps?: TariffStep[];
|
||||
dailyCapMinor?: number | null;
|
||||
}): PricingForm {
|
||||
if (c.steps != null && c.steps.length > 0) {
|
||||
return { mode: "stepped", flat: "0.00", dailyCap: "", blocks: emptyLadder().blocks, steps: stepsToForm(c.steps) };
|
||||
}
|
||||
if (c.flatMinor != null) {
|
||||
return { mode: "flat", flat: toMajor(c.flatMinor), dailyCap: "", blocks: emptyLadder().blocks, steps: emptySteps() };
|
||||
}
|
||||
return {
|
||||
mode: "ladder",
|
||||
flat: "0.00",
|
||||
dailyCap: c.dailyCapMinor == null ? "" : toMajor(c.dailyCapMinor),
|
||||
blocks: blocksToForm(c.blocks ?? []),
|
||||
steps: emptySteps(),
|
||||
};
|
||||
}
|
||||
|
||||
function tierFromCard(c: TariffCard): TierForm {
|
||||
const w = c.window ?? {};
|
||||
return {
|
||||
name: c.name,
|
||||
priority: String(c.priority),
|
||||
category: c.category ?? "",
|
||||
dow: w.dow ? [...w.dow] : [],
|
||||
fromHour: w.fromHour ?? "",
|
||||
toHour: w.toHour ?? "",
|
||||
dateFrom: w.dateFrom ?? "",
|
||||
dateTo: w.dateTo ?? "",
|
||||
pricing: pricingFromCard(c),
|
||||
};
|
||||
}
|
||||
|
||||
function formFromActive(s: TariffState): FormState {
|
||||
const v = s.active;
|
||||
if (!v) return emptyForm();
|
||||
const st = v.structure;
|
||||
const common = {
|
||||
currency: v.currency,
|
||||
gracePeriodEntryMin: String(st.gracePeriodEntryMin),
|
||||
incrementMin: String(st.incrementMin),
|
||||
lostTicket: toMajor(st.lostTicketMinor),
|
||||
gracePeriodExitMin: String(st.gracePeriodExitMin),
|
||||
};
|
||||
if (isTariffV2(st)) {
|
||||
return { ...common, base: pricingFromCard(st.defaultCard), tiers: (st.windowedCards ?? []).map(tierFromCard) };
|
||||
}
|
||||
// V1: the bare ladder becomes the default card body; no tiers.
|
||||
return { ...common, base: pricingFromCard(st), tiers: [] };
|
||||
}
|
||||
|
||||
// Build a tariff card's pricing body (flat XOR ladder XOR stepped) from a PricingForm.
|
||||
function pricingToCardBody(p: PricingForm): Pick<TariffCard, "flatMinor" | "blocks" | "steps" | "dailyCapMinor"> {
|
||||
if (p.mode === "flat") return { flatMinor: toMinor(p.flat) };
|
||||
if (p.mode === "stepped") {
|
||||
// Each row's `hours` IS the inclusive threshold (the matrix "up to N hours").
|
||||
const steps: TariffStep[] = p.steps.map((s) => ({
|
||||
uptoMin: Math.round(Number(s.hours || "0") * 60),
|
||||
totalMinor: toMinor(s.total),
|
||||
}));
|
||||
return { steps };
|
||||
}
|
||||
// Accumulate each band's hours into cumulative uptoMin (min); last band open-ended.
|
||||
const last = p.blocks.length - 1;
|
||||
let cum = 0;
|
||||
const blocks: TariffBlock[] = p.blocks.map((b, i) => {
|
||||
if (i === last) return { uptoMin: null, priceMinorPerIncrement: toMinor(b.price) };
|
||||
cum += Math.round(Number(b.hours || "0") * 60);
|
||||
return { uptoMin: cum, priceMinorPerIncrement: toMinor(b.price) };
|
||||
});
|
||||
return { blocks, dailyCapMinor: p.dailyCap.trim() === "" ? null : toMinor(p.dailyCap) };
|
||||
}
|
||||
|
||||
function tierToCard(tr: TierForm): TariffCard {
|
||||
const window: TariffCard["window"] = {};
|
||||
if (tr.dow.length > 0) window.dow = [...tr.dow].sort((a, b) => a - b);
|
||||
if (tr.fromHour && tr.toHour) {
|
||||
window.fromHour = tr.fromHour;
|
||||
window.toHour = tr.toHour;
|
||||
}
|
||||
if (tr.dateFrom) window.dateFrom = tr.dateFrom;
|
||||
if (tr.dateTo) window.dateTo = tr.dateTo;
|
||||
const card: TariffCard = {
|
||||
name: tr.name.trim() || "tier",
|
||||
priority: Math.round(Number(tr.priority || "0")),
|
||||
...pricingToCardBody(tr.pricing),
|
||||
};
|
||||
if (tr.category.trim()) card.category = tr.category.trim();
|
||||
if (Object.keys(window).length > 0) card.window = window;
|
||||
return card;
|
||||
}
|
||||
|
||||
function toStructure(f: FormState): TariffStructure {
|
||||
const common = {
|
||||
gracePeriodEntryMin: Math.round(Number(f.gracePeriodEntryMin)),
|
||||
incrementMin: Math.round(Number(f.incrementMin)),
|
||||
lostTicketMinor: toMinor(f.lostTicket),
|
||||
gracePeriodExitMin: Math.round(Number(f.gracePeriodExitMin)),
|
||||
overstay: "reprice" as const,
|
||||
};
|
||||
const baseBody = pricingToCardBody(f.base);
|
||||
|
||||
// NO tiers ⇒ publish a BARE V1 structure (back-compat: a site that never wants
|
||||
// tiers gets exactly today's shape; the server leaves it untouched).
|
||||
if (f.tiers.length === 0) {
|
||||
if (f.base.mode === "stepped") {
|
||||
// A stepped V1: the up-to table replaces the ladder (blocks empty, no cap).
|
||||
return { ...common, blocks: [], steps: baseBody.steps ?? [], dailyCapMinor: null };
|
||||
}
|
||||
if (f.base.mode === "flat") {
|
||||
// A flat V1: a single open-ended block at the flat rate (V1 has no flat field).
|
||||
return { ...common, blocks: [{ uptoMin: null, priceMinorPerIncrement: toMinor(f.base.flat) }], dailyCapMinor: null };
|
||||
}
|
||||
return { ...common, blocks: baseBody.blocks ?? [], dailyCapMinor: baseBody.dailyCapMinor ?? null };
|
||||
}
|
||||
|
||||
// Tiers present ⇒ V2. tz is stamped server-side from site config (left blank here).
|
||||
return {
|
||||
...common,
|
||||
version: 2,
|
||||
tz: "",
|
||||
defaultCard: { name: "default", priority: 0, ...baseBody },
|
||||
windowedCards: f.tiers.map(tierToCard),
|
||||
};
|
||||
}
|
||||
// sessions reprice correctly. A right sidebar lists the published history (named
|
||||
// since 2026-07-05); clicking a version loads it into the editor as the STARTING
|
||||
// POINT — publishing always creates a new version effective now, it never edits the
|
||||
// clicked one. The form machinery is shared with the Tariff Lab's draft modal — see
|
||||
// TariffEditorForm.tsx. To experiment without publishing, use the lab. See
|
||||
// wiki/concepts/tariff.md.
|
||||
|
||||
export function TariffComposer() {
|
||||
const { t } = useTranslation();
|
||||
const [state, setState] = useState<TariffState | null>(null);
|
||||
const [form, setForm] = useState<FormState>(emptyForm);
|
||||
// Which published version the editor was last loaded from (sidebar highlight).
|
||||
const [loadedId, setLoadedId] = useState<string | null>(null);
|
||||
// Optional label for the version about to be published. Deliberately NOT prefilled
|
||||
// from the active version — a tweaked card republished under last season's name
|
||||
// would mislabel the history.
|
||||
const [versionName, setVersionName] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [msg, setMsg] = useState<{ kind: "ok" | "err"; text: string } | null>(null);
|
||||
|
||||
@@ -270,74 +31,30 @@ export function TariffComposer() {
|
||||
.then((s) => {
|
||||
setState(s);
|
||||
setForm(formFromActive(s));
|
||||
setLoadedId(s.active?.id ?? null);
|
||||
})
|
||||
.catch((e) => setMsg({ kind: "err", text: (e as Error).message }));
|
||||
}, []);
|
||||
|
||||
function set<K extends keyof FormState>(key: K, value: FormState[K]) {
|
||||
setForm((f) => ({ ...f, [key]: value }));
|
||||
}
|
||||
|
||||
// --- pricing-body editing (used by the default card AND each tier) ---
|
||||
// `update` maps the old PricingForm to a new one; `target` selects which body:
|
||||
// the base card, or tier index N.
|
||||
function updatePricing(target: "base" | number, update: (p: PricingForm) => PricingForm) {
|
||||
setForm((f) => {
|
||||
if (target === "base") return { ...f, base: update(f.base) };
|
||||
return { ...f, tiers: f.tiers.map((tr, j) => (j === target ? { ...tr, pricing: update(tr.pricing) } : tr)) };
|
||||
});
|
||||
}
|
||||
function setBlock(target: "base" | number, i: number, patch: Partial<BlockForm>) {
|
||||
updatePricing(target, (p) => ({ ...p, blocks: p.blocks.map((b, j) => (j === i ? { ...b, ...patch } : b)) }));
|
||||
}
|
||||
// Insert a bounded band just BEFORE the open-ended tail, so the last block stays open-ended.
|
||||
function addBlock(target: "base" | number) {
|
||||
updatePricing(target, (p) => {
|
||||
const next = [...p.blocks];
|
||||
next.splice(p.blocks.length - 1, 0, { hours: "1", price: "0.00" });
|
||||
return { ...p, blocks: next };
|
||||
});
|
||||
}
|
||||
function removeBlock(target: "base" | number, i: number) {
|
||||
updatePricing(target, (p) => (i === p.blocks.length - 1 || p.blocks.length <= 1 ? p : { ...p, blocks: p.blocks.filter((_, j) => j !== i) }));
|
||||
}
|
||||
// --- stepped (up-to) editing (base card only) ---
|
||||
function setStep(i: number, patch: Partial<StepForm>) {
|
||||
updatePricing("base", (p) => ({ ...p, steps: p.steps.map((s, j) => (j === i ? { ...s, ...patch } : s)) }));
|
||||
}
|
||||
function addStep() {
|
||||
updatePricing("base", (p) => ({ ...p, steps: [...p.steps, { hours: "", total: "0.00" }] }));
|
||||
}
|
||||
function removeStep(i: number) {
|
||||
updatePricing("base", (p) => (p.steps.length <= 1 ? p : { ...p, steps: p.steps.filter((_, j) => j !== i) }));
|
||||
}
|
||||
|
||||
// --- tier editing ---
|
||||
function setTier(i: number, patch: Partial<TierForm>) {
|
||||
setForm((f) => ({ ...f, tiers: f.tiers.map((tr, j) => (j === i ? { ...tr, ...patch } : tr)) }));
|
||||
}
|
||||
function addTier() {
|
||||
setForm((f) => ({ ...f, tiers: [...f.tiers, emptyTier()] }));
|
||||
}
|
||||
function removeTier(i: number) {
|
||||
setForm((f) => ({ ...f, tiers: f.tiers.filter((_, j) => j !== i) }));
|
||||
}
|
||||
function toggleDow(i: number, d: number) {
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
tiers: f.tiers.map((tr, j) =>
|
||||
j === i ? { ...tr, dow: tr.dow.includes(d) ? tr.dow.filter((x) => x !== d) : [...tr.dow, d] } : tr,
|
||||
),
|
||||
}));
|
||||
function loadVersion(v: TariffVersion) {
|
||||
setForm(formFromVersion(v.currency, v.structure));
|
||||
setLoadedId(v.id);
|
||||
setMsg(null);
|
||||
}
|
||||
|
||||
async function publish() {
|
||||
setSaving(true);
|
||||
setMsg(null);
|
||||
try {
|
||||
await publishTariffVersion({ currency: form.currency.trim().toUpperCase(), structure: toStructure(form) });
|
||||
await publishTariffVersion({
|
||||
currency: form.currency.trim().toUpperCase(),
|
||||
structure: toStructure(form),
|
||||
...(versionName.trim() ? { name: versionName.trim() } : {}),
|
||||
});
|
||||
const fresh = await fetchTariff();
|
||||
setState(fresh);
|
||||
setLoadedId(fresh.active?.id ?? null);
|
||||
setVersionName("");
|
||||
setMsg({ kind: "ok", text: t("tariff.publishedOk") });
|
||||
} catch (e) {
|
||||
const text =
|
||||
@@ -359,264 +76,76 @@ export function TariffComposer() {
|
||||
</p>
|
||||
) : (
|
||||
<p className="mb-4 text-[0.75rem] text-term-muted">
|
||||
{state.active.name ? `${state.active.name} — ` : ""}
|
||||
{t("tariff.activeSince", {
|
||||
date: new Date(state.active.effectiveFrom).toLocaleString(),
|
||||
date: formatDateTime(state.active.effectiveFrom, t),
|
||||
count: state.versions.length,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="card card-body grid grid-cols-[max-content_1fr] items-center gap-x-4 gap-y-2">
|
||||
<label className="label">{t("tariff.currency")}</label>
|
||||
<input className="input w-24" value={form.currency} onChange={(e) => set("currency", e.target.value)} maxLength={3} />
|
||||
<label className="label">{t("tariff.freeEntryGrace")}</label>
|
||||
<input className="input w-32" value={form.gracePeriodEntryMin} onChange={(e) => set("gracePeriodEntryMin", e.target.value)} />
|
||||
<label className="label">{t("tariff.billingIncrement")}</label>
|
||||
<input className="input w-32" value={form.incrementMin} onChange={(e) => set("incrementMin", e.target.value)} />
|
||||
<label className="label">{t("tariff.lostTicketFee")}</label>
|
||||
<input className="input w-32" value={form.lostTicket} onChange={(e) => set("lostTicket", e.target.value)} />
|
||||
<label className="label">{t("tariff.exitGrace")}</label>
|
||||
<input className="input w-32" value={form.gracePeriodExitMin} onChange={(e) => set("gracePeriodExitMin", e.target.value)} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 lg:flex-row">
|
||||
<div className="min-w-0 flex-1">
|
||||
<TariffEditorForm form={form} onChange={setForm} />
|
||||
|
||||
{/* The DEFAULT card — always-active rate. Front-and-centre; a site that never
|
||||
wants tiers just edits this and publishes a bare V1 structure. */}
|
||||
<h3 className="mt-6 mb-0.5 text-h6 font-semibold uppercase tracking-wider text-term-text">{t("tariff.defaultCard")}</h3>
|
||||
<p className="hint mb-2">{t("tariff.defaultCardHint")}</p>
|
||||
<div className="card card-body">
|
||||
<PricingEditor
|
||||
t={t}
|
||||
pricing={form.base}
|
||||
allowStepped
|
||||
onMode={(mode) => updatePricing("base", (p) => ({ ...p, mode }))}
|
||||
onFlat={(flat) => updatePricing("base", (p) => ({ ...p, flat }))}
|
||||
onCap={(dailyCap) => updatePricing("base", (p) => ({ ...p, dailyCap }))}
|
||||
onBlock={(i, patch) => setBlock("base", i, patch)}
|
||||
onAddBlock={() => addBlock("base")}
|
||||
onRemoveBlock={(i) => removeBlock("base", i)}
|
||||
onStep={setStep}
|
||||
onAddStep={addStep}
|
||||
onRemoveStep={removeStep}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-6 flex flex-wrap items-center gap-3">
|
||||
<input
|
||||
className="input w-64"
|
||||
value={versionName}
|
||||
onChange={(e) => setVersionName(e.target.value)}
|
||||
placeholder={t("tariff.versionNamePh")}
|
||||
/>
|
||||
<button type="button" className="btn btn-primary btn-lg" onClick={publish} disabled={saving}>
|
||||
{saving ? t("tariff.publishing") : t("tariff.publishNewVersion")}
|
||||
</button>
|
||||
{msg && (
|
||||
<span className={msg.kind === "ok" ? "text-[0.75rem] text-term-green" : "text-[0.75rem] text-term-red"}>{msg.text}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Advanced: time & seasonal/category TIERS (opt-in). Empty ⇒ V1 is published. */}
|
||||
<details className="mt-6" open={form.tiers.length > 0}>
|
||||
<summary className="cursor-pointer text-h6 font-semibold uppercase tracking-wider text-term-text">{t("tariff.tiersAdvanced")}</summary>
|
||||
<p className="hint mt-1.5 mb-2">{t("tariff.tiersHint")}</p>
|
||||
{/* A stepped ("up-to") base rate cannot be combined with time tiers — the
|
||||
engine would ignore them. Warn up-front; publishing is also blocked server-side. */}
|
||||
{form.base.mode === "stepped" && form.tiers.length > 0 && (
|
||||
<p className="mb-3 rounded-term border border-term-red/50 bg-term-red/10 px-3 py-2 text-[0.75rem] text-term-red">
|
||||
{t("tariff.steppedTiersConflict")}
|
||||
</p>
|
||||
)}
|
||||
{form.tiers.map((tr, i) => (
|
||||
<fieldset key={i} className="card mb-3 p-4">
|
||||
<legend className="flex items-center gap-2 px-1">
|
||||
<input
|
||||
className="input w-40"
|
||||
value={tr.name}
|
||||
onChange={(e) => setTier(i, { name: e.target.value })}
|
||||
placeholder={t("tariff.tierName")}
|
||||
/>
|
||||
<button type="button" className="btn btn-danger btn-sm" onClick={() => removeTier(i)}>
|
||||
{t("tariff.remove")}
|
||||
</button>
|
||||
</legend>
|
||||
<div className="grid grid-cols-[max-content_1fr] items-center gap-x-4 gap-y-2">
|
||||
<label className="label">{t("tariff.tierPriority")}</label>
|
||||
<input className="input w-20" value={tr.priority} onChange={(e) => setTier(i, { priority: e.target.value })} />
|
||||
<label className="label">{t("tariff.tierCategory")}</label>
|
||||
<input className="input w-40" value={tr.category} onChange={(e) => setTier(i, { category: e.target.value })} placeholder={t("tariff.tierCategoryPh")} />
|
||||
<label className="label">{t("tariff.tierDays")}</label>
|
||||
<span className="flex flex-wrap gap-2">
|
||||
{[1, 2, 3, 4, 5, 6, 0].map((d) => (
|
||||
<label key={d} className="inline-flex items-center gap-1 text-[0.75rem] text-term-text">
|
||||
<input type="checkbox" className="accent-term-amber" checked={tr.dow.includes(d)} onChange={() => toggleDow(i, d)} />
|
||||
{t(`tariff.dow${d}`)}
|
||||
</label>
|
||||
))}
|
||||
</span>
|
||||
<label className="label">{t("tariff.tierHours")}</label>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<input className="input w-20" value={tr.fromHour} onChange={(e) => setTier(i, { fromHour: e.target.value })} placeholder="22:00" />
|
||||
<span className="text-term-muted">–</span>
|
||||
<input className="input w-20" value={tr.toHour} onChange={(e) => setTier(i, { toHour: e.target.value })} placeholder="06:00" />
|
||||
{tr.fromHour && tr.toHour && tr.toHour <= tr.fromHour && (
|
||||
<span className="text-[0.6875rem] text-term-muted">{t("tariff.tierOvernight")}</span>
|
||||
)}
|
||||
</span>
|
||||
<label className="label">{t("tariff.tierDates")}</label>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<input type="date" className="input w-40" value={tr.dateFrom} onChange={(e) => setTier(i, { dateFrom: e.target.value })} />
|
||||
<span className="text-term-muted">–</span>
|
||||
<input type="date" className="input w-40" value={tr.dateTo} onChange={(e) => setTier(i, { dateTo: e.target.value })} />
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3 border-t border-term-border pt-3">
|
||||
<PricingEditor
|
||||
t={t}
|
||||
pricing={tr.pricing}
|
||||
onMode={(mode) => updatePricing(i, (p) => ({ ...p, mode }))}
|
||||
onFlat={(flat) => updatePricing(i, (p) => ({ ...p, flat }))}
|
||||
onCap={(dailyCap) => updatePricing(i, (p) => ({ ...p, dailyCap }))}
|
||||
onBlock={(bi, patch) => setBlock(i, bi, patch)}
|
||||
onAddBlock={() => addBlock(i)}
|
||||
onRemoveBlock={(bi) => removeBlock(i, bi)}
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
))}
|
||||
<button type="button" className="btn btn-sm" onClick={addTier}>
|
||||
{t("tariff.addTier")}
|
||||
</button>
|
||||
</details>
|
||||
|
||||
<div className="mt-6 flex items-center gap-3">
|
||||
<button type="button" className="btn btn-primary btn-lg" onClick={publish} disabled={saving}>
|
||||
{saving ? t("tariff.publishing") : t("tariff.publishNewVersion")}
|
||||
</button>
|
||||
{msg && (
|
||||
<span className={msg.kind === "ok" ? "text-[0.75rem] text-term-green" : "text-[0.75rem] text-term-red"}>{msg.text}</span>
|
||||
{/* Published history — click a version to load it into the editor. Same list
|
||||
the lab's sidebar shows; here it seeds the next publish. */}
|
||||
{state && state.versions.length > 0 && (
|
||||
<aside className="w-full shrink-0 lg:w-72">
|
||||
<h3 className="mb-1 text-h6 font-semibold uppercase tracking-wider text-term-text">
|
||||
{t("tariff.versionsTitle")}
|
||||
</h3>
|
||||
<p className="hint mb-2">{t("tariff.versionsHint")}</p>
|
||||
<ul className="flex flex-col gap-1">
|
||||
{state.versions.map((v) => {
|
||||
const isActive = v.id === state.active?.id;
|
||||
return (
|
||||
<li key={v.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => loadVersion(v)}
|
||||
className={`w-full rounded-term border px-3 py-2 text-left text-[0.8125rem] ${
|
||||
loadedId === v.id
|
||||
? "border-term-amber bg-term-amber/10 text-term-text"
|
||||
: "border-term-border text-term-muted hover:text-term-text"
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center gap-2 font-semibold">
|
||||
{v.name ?? formatDateTime(v.effectiveFrom, t)}
|
||||
{isActive && (
|
||||
<span className="rounded border border-term-green px-1 text-[0.625rem] uppercase text-term-green">
|
||||
{t("tariff.activeBadge")}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="block text-[0.6875rem] text-term-muted">
|
||||
{v.name ? `${formatDateTime(v.effectiveFrom, t)} · ` : ""}
|
||||
{v.currency}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// A reusable pricing-body editor — flat / marginal ladder / stepped (up-to). The
|
||||
// stepped mode is offered only where `allowStepped` (the default card, not tiers).
|
||||
function PricingEditor(props: {
|
||||
t: (k: string) => string;
|
||||
pricing: PricingForm;
|
||||
allowStepped?: boolean;
|
||||
onMode: (m: "ladder" | "flat" | "stepped") => void;
|
||||
onFlat: (v: string) => void;
|
||||
onCap: (v: string) => void;
|
||||
onBlock: (i: number, patch: Partial<BlockForm>) => void;
|
||||
onAddBlock: () => void;
|
||||
onRemoveBlock: (i: number) => void;
|
||||
onStep?: (i: number, patch: Partial<StepForm>) => void;
|
||||
onAddStep?: () => void;
|
||||
onRemoveStep?: (i: number) => void;
|
||||
}) {
|
||||
const { t, pricing: p } = props;
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-3 flex gap-4 text-[0.75rem]">
|
||||
<label className="inline-flex items-center gap-1.5 text-term-text">
|
||||
<input type="radio" className="accent-term-amber" checked={p.mode === "ladder"} onChange={() => props.onMode("ladder")} />
|
||||
{t("tariff.modeLadder")}
|
||||
</label>
|
||||
<label className="inline-flex items-center gap-1.5 text-term-text">
|
||||
<input type="radio" className="accent-term-amber" checked={p.mode === "flat"} onChange={() => props.onMode("flat")} />
|
||||
{t("tariff.modeFlat")}
|
||||
</label>
|
||||
{props.allowStepped && (
|
||||
<label className="inline-flex items-center gap-1.5 text-term-text">
|
||||
<input type="radio" className="accent-term-amber" checked={p.mode === "stepped"} onChange={() => props.onMode("stepped")} />
|
||||
{t("tariff.modeStepped")}
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{p.mode === "stepped" ? (
|
||||
<>
|
||||
<p className="hint mb-2">{t("tariff.steppedHint")}</p>
|
||||
<table className="w-full border-collapse">
|
||||
<thead>
|
||||
<tr className="text-left">
|
||||
<th className="label px-2 pb-1 font-normal">{t("tariff.stepUpTo")}</th>
|
||||
<th className="label px-2 pb-1 font-normal">{t("tariff.stepTotal")}</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{p.steps.map((s, i) => (
|
||||
<tr key={i}>
|
||||
<td className="px-2 py-1">
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<input className="input w-20" value={s.hours} onChange={(e) => props.onStep?.(i, { hours: e.target.value })} placeholder={t("tariff.egHours")} />
|
||||
<span className="text-[0.6875rem] text-term-muted">{t("tariff.hoursUnit")}</span>
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-2 py-1">
|
||||
<input className="input w-28" value={s.total} onChange={(e) => props.onStep?.(i, { total: e.target.value })} />
|
||||
</td>
|
||||
<td className="px-2">
|
||||
{p.steps.length > 1 && (
|
||||
<button type="button" className="btn btn-ghost btn-sm" onClick={() => props.onRemoveStep?.(i)}>
|
||||
{t("tariff.remove")}
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="mt-3">
|
||||
<button type="button" className="btn btn-sm" onClick={props.onAddStep}>
|
||||
{t("tariff.addStep")}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : p.mode === "flat" ? (
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<span className="label">{t("tariff.pricePerIncrement")}</span>
|
||||
<input className="input w-28" value={p.flat} onChange={(e) => props.onFlat(e.target.value)} />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<table className="w-full border-collapse">
|
||||
<thead>
|
||||
<tr className="text-left">
|
||||
<th className="label px-2 pb-1 font-normal">{t("tariff.bandDuration")}</th>
|
||||
<th className="label px-2 pb-1 font-normal">{t("tariff.pricePerIncrement")}</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{p.blocks.map((b, i) => {
|
||||
const isTail = i === p.blocks.length - 1;
|
||||
return (
|
||||
<tr key={i}>
|
||||
<td className="px-2 py-1">
|
||||
{isTail ? (
|
||||
<span className="italic text-term-muted">{t("tariff.thereafter")}</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<input className="input w-20" value={b.hours} onChange={(e) => props.onBlock(i, { hours: e.target.value })} placeholder={t("tariff.egHours")} />
|
||||
<span className="text-[0.6875rem] text-term-muted">{t("tariff.hoursUnit")}</span>
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-2 py-1">
|
||||
<input className="input w-28" value={b.price} onChange={(e) => props.onBlock(i, { price: e.target.value })} />
|
||||
</td>
|
||||
<td className="px-2">
|
||||
{!isTail && (
|
||||
<button type="button" className="btn btn-ghost btn-sm" onClick={() => props.onRemoveBlock(i)}>
|
||||
{t("tariff.remove")}
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="mt-3 flex items-center gap-4">
|
||||
<button type="button" className="btn btn-sm" onClick={props.onAddBlock}>
|
||||
{t("tariff.addBlock")}
|
||||
</button>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<span className="label">{t("tariff.dailyCap")}</span>
|
||||
<input className="input w-28" value={p.dailyCap} onChange={(e) => props.onCap(e.target.value)} placeholder={t("tariff.dailyCapPh")} />
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,661 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { currencyOptions } from "./lib/currencies.js";
|
||||
import {
|
||||
isTariffV2,
|
||||
type TariffBlock,
|
||||
type TariffCard,
|
||||
type TariffStep,
|
||||
type TariffStructure,
|
||||
type TariffState,
|
||||
} from "./api.js";
|
||||
|
||||
// The tariff EDITOR FORM — the rate-card composer's form machinery (state shape,
|
||||
// structure↔form converters, and the editing UI), extracted so two hosts can share
|
||||
// it: the /setup/tariff page (edits + publishes the live card) and the Tariff Lab's
|
||||
// draft modal (edits an experimental card). The host owns the FormState and the
|
||||
// submit action; this module owns everything between. Amounts are entered in major
|
||||
// units (e.g. euros) and converted to integer minor units on submit.
|
||||
// See wiki/concepts/tariff.md.
|
||||
|
||||
// Editable form mirror of TariffStructure, but money in major-unit strings.
|
||||
// Blocks are edited as a DURATION in hours ("this band lasts N hours") — the
|
||||
// owner thinks "first 2 hours, then next 3 hours", not in cumulative minutes.
|
||||
// The LAST block is always open-ended ("thereafter"): its hours field is unused
|
||||
// and it has no bound. On submit, per-block hours accumulate into the engine's
|
||||
// cumulative `uptoMin` (minutes), and the last block emits uptoMin: null.
|
||||
export interface BlockForm {
|
||||
hours: string; // duration of THIS band, in hours (ignored for the last block)
|
||||
price: string; // major units, e.g. "2.00"
|
||||
}
|
||||
// One STEPPED ("up-to") row: "a stay up to N hours costs TOTAL". The owner enters the
|
||||
// matrix verbatim (totals, not marginal rates). See wiki/concepts/tariff.md.
|
||||
export interface StepForm {
|
||||
hours: string; // inclusive upper bound of this tier, in hours (e.g. "3")
|
||||
total: string; // TOTAL major units for a stay within this tier (e.g. "5.00")
|
||||
}
|
||||
// A pricing body the form edits: a per-increment flat rate, a marginal block ladder,
|
||||
// a stepped (up-to) total-by-duration table, or a whole-window package (tiers only).
|
||||
export interface PricingForm {
|
||||
mode: "ladder" | "flat" | "stepped" | "package";
|
||||
flat: string; // major units PER INCREMENT (used when mode==="flat")
|
||||
packageTotal: string; // major units for the WHOLE window occurrence (mode==="package")
|
||||
blocks: BlockForm[]; // hours-based ladder (used when mode==="ladder")
|
||||
steps: StepForm[]; // up-to tiers (used when mode==="stepped")
|
||||
dailyCap: string; // "" = no cap (ladder only)
|
||||
}
|
||||
// An optional time/category TIER (a V2 windowed card). Absent windows = unconstrained.
|
||||
export interface TierForm {
|
||||
name: string;
|
||||
priority: string;
|
||||
category: string; // "" = applies to all categories
|
||||
dow: number[]; // selected days 0..6; empty = every day
|
||||
fromHour: string; // "" = all day
|
||||
toHour: string;
|
||||
dateFrom: string; // "" = unbounded
|
||||
dateTo: string;
|
||||
pricing: PricingForm;
|
||||
}
|
||||
export interface FormState {
|
||||
currency: string;
|
||||
gracePeriodEntryMin: string;
|
||||
incrementMin: string;
|
||||
lostTicket: string;
|
||||
gracePeriodExitMin: string;
|
||||
// The default (always-active) card — its own flat/ladder body + daily cap.
|
||||
base: PricingForm;
|
||||
// Optional time/category tiers. Empty ⇒ a bare V1 structure is published.
|
||||
tiers: TierForm[];
|
||||
}
|
||||
|
||||
const toMinor = (major: string): number => Math.round(parseFloat(major || "0") * 100);
|
||||
const toMajor = (minor: number): string => (minor / 100).toFixed(2);
|
||||
|
||||
/** Currency-plausible EXAMPLE amounts for fresh forms/rows. The old hardcoded
|
||||
* "2.00 / 1.00" examples were euro-scaled — displayed under ALL they read as
|
||||
* 2 lekë/hour, i.e. nonsense (operator feedback 2026-07-06). Lek amounts are
|
||||
* ~100× the euro ones; USD rides with EUR. */
|
||||
function examples(currency: string): { hi: string; lo: string; stepSmall: string; stepBig: string; lost: string } {
|
||||
return currency.trim().toUpperCase() === "ALL"
|
||||
? { hi: "200.00", lo: "100.00", stepSmall: "200.00", stepBig: "500.00", lost: "2000.00" }
|
||||
: { hi: "2.00", lo: "1.00", stepSmall: "2.00", stepBig: "5.00", lost: "20.00" };
|
||||
}
|
||||
|
||||
function emptySteps(currency: string): StepForm[] {
|
||||
const ex = examples(currency);
|
||||
return [
|
||||
{ hours: "1", total: ex.stepSmall },
|
||||
{ hours: "3", total: ex.stepBig },
|
||||
];
|
||||
}
|
||||
function emptyLadder(currency: string): PricingForm {
|
||||
const ex = examples(currency);
|
||||
return {
|
||||
mode: "ladder",
|
||||
flat: "0.00",
|
||||
packageTotal: "0.00",
|
||||
dailyCap: "",
|
||||
blocks: [{ hours: "1", price: ex.hi }, { hours: "", price: ex.lo }],
|
||||
steps: emptySteps(currency),
|
||||
};
|
||||
}
|
||||
function emptyTier(currency: string): TierForm {
|
||||
return {
|
||||
name: "",
|
||||
priority: "10",
|
||||
category: "",
|
||||
dow: [],
|
||||
fromHour: "",
|
||||
toHour: "",
|
||||
dateFrom: "",
|
||||
dateTo: "",
|
||||
pricing: { ...emptyLadder(currency), blocks: [{ hours: "", price: examples(currency).lo }] },
|
||||
};
|
||||
}
|
||||
|
||||
export function emptyForm(): FormState {
|
||||
const currency = "ALL"; // the site's currency — examples scale with it
|
||||
return {
|
||||
currency,
|
||||
gracePeriodEntryMin: "15",
|
||||
incrementMin: "60",
|
||||
lostTicket: examples(currency).lost,
|
||||
gracePeriodExitMin: "15",
|
||||
base: emptyLadder(currency),
|
||||
tiers: [],
|
||||
};
|
||||
}
|
||||
|
||||
// Convert a stored block ladder's cumulative `uptoMin` (minutes) into the per-band
|
||||
// hours the form edits. Open-ended last band has no hours. Legacy bounded tails still
|
||||
// load (shown as their own band).
|
||||
function blocksToForm(blocks: TariffBlock[]): BlockForm[] {
|
||||
let prev = 0;
|
||||
return blocks.map((b) => {
|
||||
if (b.uptoMin == null) return { hours: "", price: toMajor(b.priceMinorPerIncrement) };
|
||||
const hours = (b.uptoMin - prev) / 60;
|
||||
prev = b.uptoMin;
|
||||
return { hours: String(hours), price: toMajor(b.priceMinorPerIncrement) };
|
||||
});
|
||||
}
|
||||
|
||||
// A stored stepped table's `uptoMin` (minutes) → the per-tier hours the form edits.
|
||||
function stepsToForm(steps: TariffStep[]): StepForm[] {
|
||||
return steps.map((s) => ({ hours: String(s.uptoMin / 60), total: toMajor(s.totalMinor) }));
|
||||
}
|
||||
|
||||
// A stored card (V2) or bare-V1 body → the form's PricingForm (flat, ladder, stepped,
|
||||
// or window package).
|
||||
function pricingFromCard(
|
||||
c: {
|
||||
flatMinor?: number;
|
||||
blocks?: TariffBlock[];
|
||||
steps?: TariffStep[];
|
||||
packageMinor?: number;
|
||||
dailyCapMinor?: number | null;
|
||||
},
|
||||
currency: string,
|
||||
): PricingForm {
|
||||
if (c.steps != null && c.steps.length > 0) {
|
||||
return { ...emptyLadder(currency), mode: "stepped", steps: stepsToForm(c.steps) };
|
||||
}
|
||||
if (c.packageMinor != null) {
|
||||
return { ...emptyLadder(currency), mode: "package", packageTotal: toMajor(c.packageMinor) };
|
||||
}
|
||||
if (c.flatMinor != null) {
|
||||
return { ...emptyLadder(currency), mode: "flat", flat: toMajor(c.flatMinor) };
|
||||
}
|
||||
return {
|
||||
...emptyLadder(currency),
|
||||
mode: "ladder",
|
||||
dailyCap: c.dailyCapMinor == null ? "" : toMajor(c.dailyCapMinor),
|
||||
blocks: blocksToForm(c.blocks ?? []),
|
||||
};
|
||||
}
|
||||
|
||||
function tierFromCard(c: TariffCard, currency: string): TierForm {
|
||||
const w = c.window ?? {};
|
||||
return {
|
||||
name: c.name,
|
||||
priority: String(c.priority),
|
||||
category: c.category ?? "",
|
||||
dow: w.dow ? [...w.dow] : [],
|
||||
fromHour: w.fromHour ?? "",
|
||||
toHour: w.toHour ?? "",
|
||||
dateFrom: w.dateFrom ?? "",
|
||||
dateTo: w.dateTo ?? "",
|
||||
pricing: pricingFromCard(c, currency),
|
||||
};
|
||||
}
|
||||
|
||||
/** A stored (currency, structure) pair → the editable form. Used to load the active
|
||||
* version into the composer page and a saved draft into the lab modal. */
|
||||
export function formFromVersion(currency: string, st: TariffStructure): FormState {
|
||||
const common = {
|
||||
currency,
|
||||
gracePeriodEntryMin: String(st.gracePeriodEntryMin),
|
||||
incrementMin: String(st.incrementMin),
|
||||
lostTicket: toMajor(st.lostTicketMinor),
|
||||
gracePeriodExitMin: String(st.gracePeriodExitMin),
|
||||
};
|
||||
if (isTariffV2(st)) {
|
||||
return {
|
||||
...common,
|
||||
base: pricingFromCard(st.defaultCard, currency),
|
||||
tiers: (st.windowedCards ?? []).map((c) => tierFromCard(c, currency)),
|
||||
};
|
||||
}
|
||||
// V1: the bare ladder becomes the default card body; no tiers.
|
||||
return { ...common, base: pricingFromCard(st, currency), tiers: [] };
|
||||
}
|
||||
|
||||
export function formFromActive(s: TariffState): FormState {
|
||||
return s.active ? formFromVersion(s.active.currency, s.active.structure) : emptyForm();
|
||||
}
|
||||
|
||||
// Build a tariff card's pricing body (flat XOR ladder XOR stepped XOR package) from a PricingForm.
|
||||
function pricingToCardBody(p: PricingForm): Pick<TariffCard, "flatMinor" | "blocks" | "steps" | "packageMinor" | "dailyCapMinor"> {
|
||||
if (p.mode === "flat") return { flatMinor: toMinor(p.flat) };
|
||||
if (p.mode === "package") return { packageMinor: toMinor(p.packageTotal) };
|
||||
if (p.mode === "stepped") {
|
||||
// Each row's `hours` IS the inclusive threshold (the matrix "up to N hours").
|
||||
const steps: TariffStep[] = p.steps.map((s) => ({
|
||||
uptoMin: Math.round(Number(s.hours || "0") * 60),
|
||||
totalMinor: toMinor(s.total),
|
||||
}));
|
||||
return { steps };
|
||||
}
|
||||
// Accumulate each band's hours into cumulative uptoMin (min); last band open-ended.
|
||||
const last = p.blocks.length - 1;
|
||||
let cum = 0;
|
||||
const blocks: TariffBlock[] = p.blocks.map((b, i) => {
|
||||
if (i === last) return { uptoMin: null, priceMinorPerIncrement: toMinor(b.price) };
|
||||
cum += Math.round(Number(b.hours || "0") * 60);
|
||||
return { uptoMin: cum, priceMinorPerIncrement: toMinor(b.price) };
|
||||
});
|
||||
return { blocks, dailyCapMinor: p.dailyCap.trim() === "" ? null : toMinor(p.dailyCap) };
|
||||
}
|
||||
|
||||
function tierToCard(tr: TierForm): TariffCard {
|
||||
const window: TariffCard["window"] = {};
|
||||
if (tr.dow.length > 0) window.dow = [...tr.dow].sort((a, b) => a - b);
|
||||
if (tr.fromHour && tr.toHour) {
|
||||
window.fromHour = tr.fromHour;
|
||||
window.toHour = tr.toHour;
|
||||
}
|
||||
if (tr.dateFrom) window.dateFrom = tr.dateFrom;
|
||||
if (tr.dateTo) window.dateTo = tr.dateTo;
|
||||
const card: TariffCard = {
|
||||
name: tr.name.trim() || "tier",
|
||||
priority: Math.round(Number(tr.priority || "0")),
|
||||
...pricingToCardBody(tr.pricing),
|
||||
};
|
||||
if (tr.category.trim()) card.category = tr.category.trim();
|
||||
if (Object.keys(window).length > 0) card.window = window;
|
||||
return card;
|
||||
}
|
||||
|
||||
export function toStructure(f: FormState): TariffStructure {
|
||||
const common = {
|
||||
gracePeriodEntryMin: Math.round(Number(f.gracePeriodEntryMin)),
|
||||
incrementMin: Math.round(Number(f.incrementMin)),
|
||||
lostTicketMinor: toMinor(f.lostTicket),
|
||||
gracePeriodExitMin: Math.round(Number(f.gracePeriodExitMin)),
|
||||
overstay: "reprice" as const,
|
||||
};
|
||||
const baseBody = pricingToCardBody(f.base);
|
||||
|
||||
// NO tiers ⇒ publish a BARE V1 structure (back-compat: a site that never wants
|
||||
// tiers gets exactly today's shape; the server leaves it untouched).
|
||||
if (f.tiers.length === 0) {
|
||||
if (f.base.mode === "stepped") {
|
||||
// A stepped V1: the up-to table replaces the ladder (blocks empty, no cap).
|
||||
return { ...common, blocks: [], steps: baseBody.steps ?? [], dailyCapMinor: null };
|
||||
}
|
||||
if (f.base.mode === "flat") {
|
||||
// A flat V1: a single open-ended block at the flat rate (V1 has no flat field).
|
||||
return { ...common, blocks: [{ uptoMin: null, priceMinorPerIncrement: toMinor(f.base.flat) }], dailyCapMinor: null };
|
||||
}
|
||||
return { ...common, blocks: baseBody.blocks ?? [], dailyCapMinor: baseBody.dailyCapMinor ?? null };
|
||||
}
|
||||
|
||||
// Tiers present ⇒ V2. tz is stamped server-side from site config (left blank here).
|
||||
return {
|
||||
...common,
|
||||
version: 2,
|
||||
tz: "",
|
||||
defaultCard: { name: "default", priority: 0, ...baseBody },
|
||||
windowedCards: f.tiers.map(tierToCard),
|
||||
};
|
||||
}
|
||||
|
||||
/** The full rate-card editing UI (shared settings + default card + tiers). The host
|
||||
* owns the FormState; every edit flows through `onChange` as a functional update. */
|
||||
export function TariffEditorForm({
|
||||
form,
|
||||
onChange,
|
||||
}: {
|
||||
form: FormState;
|
||||
onChange: (update: (f: FormState) => FormState) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
// The billing unit all flat/ladder prices are entered in (labels reflect it live).
|
||||
const inc = Math.max(1, Math.round(Number(form.incrementMin)) || 60);
|
||||
|
||||
function set<K extends keyof FormState>(key: K, value: FormState[K]) {
|
||||
onChange((f) => ({ ...f, [key]: value }));
|
||||
}
|
||||
|
||||
// --- pricing-body editing (used by the default card AND each tier) ---
|
||||
// `update` maps the old PricingForm to a new one; `target` selects which body:
|
||||
// the base card, or tier index N.
|
||||
function updatePricing(target: "base" | number, update: (p: PricingForm) => PricingForm) {
|
||||
onChange((f) => {
|
||||
if (target === "base") return { ...f, base: update(f.base) };
|
||||
return { ...f, tiers: f.tiers.map((tr, j) => (j === target ? { ...tr, pricing: update(tr.pricing) } : tr)) };
|
||||
});
|
||||
}
|
||||
function setBlock(target: "base" | number, i: number, patch: Partial<BlockForm>) {
|
||||
updatePricing(target, (p) => ({ ...p, blocks: p.blocks.map((b, j) => (j === i ? { ...b, ...patch } : b)) }));
|
||||
}
|
||||
// Insert a bounded band just BEFORE the open-ended tail, so the last block stays open-ended.
|
||||
function addBlock(target: "base" | number) {
|
||||
updatePricing(target, (p) => {
|
||||
const next = [...p.blocks];
|
||||
next.splice(p.blocks.length - 1, 0, { hours: "1", price: "0.00" });
|
||||
return { ...p, blocks: next };
|
||||
});
|
||||
}
|
||||
function removeBlock(target: "base" | number, i: number) {
|
||||
updatePricing(target, (p) => (i === p.blocks.length - 1 || p.blocks.length <= 1 ? p : { ...p, blocks: p.blocks.filter((_, j) => j !== i) }));
|
||||
}
|
||||
// --- stepped (up-to) editing (base card only) ---
|
||||
function setStep(i: number, patch: Partial<StepForm>) {
|
||||
updatePricing("base", (p) => ({ ...p, steps: p.steps.map((s, j) => (j === i ? { ...s, ...patch } : s)) }));
|
||||
}
|
||||
function addStep() {
|
||||
updatePricing("base", (p) => ({ ...p, steps: [...p.steps, { hours: "", total: "0.00" }] }));
|
||||
}
|
||||
function removeStep(i: number) {
|
||||
updatePricing("base", (p) => (p.steps.length <= 1 ? p : { ...p, steps: p.steps.filter((_, j) => j !== i) }));
|
||||
}
|
||||
|
||||
// --- tier editing ---
|
||||
function setTier(i: number, patch: Partial<TierForm>) {
|
||||
onChange((f) => ({ ...f, tiers: f.tiers.map((tr, j) => (j === i ? { ...tr, ...patch } : tr)) }));
|
||||
}
|
||||
function addTier() {
|
||||
onChange((f) => ({ ...f, tiers: [...f.tiers, emptyTier(f.currency)] }));
|
||||
}
|
||||
function removeTier(i: number) {
|
||||
onChange((f) => ({ ...f, tiers: f.tiers.filter((_, j) => j !== i) }));
|
||||
}
|
||||
function toggleDow(i: number, d: number) {
|
||||
onChange((f) => ({
|
||||
...f,
|
||||
tiers: f.tiers.map((tr, j) =>
|
||||
j === i ? { ...tr, dow: tr.dow.includes(d) ? tr.dow.filter((x) => x !== d) : [...tr.dow, d] } : tr,
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="card card-body grid grid-cols-[max-content_1fr] items-center gap-x-4 gap-y-2">
|
||||
<label className="label">{t("tariff.currency")}</label>
|
||||
<select className="input w-24" value={form.currency} onChange={(e) => set("currency", e.target.value)}>
|
||||
{currencyOptions(form.currency).map((c) => (
|
||||
<option key={c} value={c}>{c}</option>
|
||||
))}
|
||||
</select>
|
||||
<label className="label">{t("tariff.freeEntryGrace")}</label>
|
||||
<input className="input w-32" value={form.gracePeriodEntryMin} onChange={(e) => set("gracePeriodEntryMin", e.target.value)} />
|
||||
<label className="label">{t("tariff.billingIncrement")}</label>
|
||||
<input className="input w-32" value={form.incrementMin} onChange={(e) => set("incrementMin", e.target.value)} />
|
||||
<label className="label">{t("tariff.lostTicketFee")}</label>
|
||||
<input className="input w-32" value={form.lostTicket} onChange={(e) => set("lostTicket", e.target.value)} />
|
||||
<label className="label">{t("tariff.exitGrace")}</label>
|
||||
<input className="input w-32" value={form.gracePeriodExitMin} onChange={(e) => set("gracePeriodExitMin", e.target.value)} />
|
||||
</div>
|
||||
|
||||
{/* The increment is the UNIT every flat/ladder price is charged in. At 60 the
|
||||
form reads naturally as per-hour; any other value silently redefines every
|
||||
price below, so shout it (the 60→10 "six charges per hour" trap). */}
|
||||
{inc !== 60 && (
|
||||
<p className="mt-2 rounded-term border border-term-amber/50 bg-term-amber/10 px-3 py-2 text-[0.75rem] text-term-amber">
|
||||
{t("tariff.incrementWarning", { min: inc })}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* The DEFAULT card — always-active rate. Front-and-centre; a site that never
|
||||
wants tiers just edits this and publishes a bare V1 structure. */}
|
||||
<h3 className="mt-6 mb-0.5 text-h6 font-semibold uppercase tracking-wider text-term-text">{t("tariff.defaultCard")}</h3>
|
||||
<p className="hint mb-2">{t("tariff.defaultCardHint")}</p>
|
||||
<div className="card card-body">
|
||||
<PricingEditor
|
||||
t={t}
|
||||
pricing={form.base}
|
||||
incrementMin={inc}
|
||||
allowStepped
|
||||
onMode={(mode) => updatePricing("base", (p) => ({ ...p, mode }))}
|
||||
onFlat={(flat) => updatePricing("base", (p) => ({ ...p, flat }))}
|
||||
onCap={(dailyCap) => updatePricing("base", (p) => ({ ...p, dailyCap }))}
|
||||
onBlock={(i, patch) => setBlock("base", i, patch)}
|
||||
onAddBlock={() => addBlock("base")}
|
||||
onRemoveBlock={(i) => removeBlock("base", i)}
|
||||
onStep={setStep}
|
||||
onAddStep={addStep}
|
||||
onRemoveStep={removeStep}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Advanced: time & seasonal/category TIERS (opt-in). Empty ⇒ V1 is published. */}
|
||||
<details className="mt-6" open={form.tiers.length > 0}>
|
||||
<summary className="cursor-pointer text-h6 font-semibold uppercase tracking-wider text-term-text">{t("tariff.tiersAdvanced")}</summary>
|
||||
<p className="hint mt-1.5 mb-2">{t("tariff.tiersHint")}</p>
|
||||
{/* A stepped ("up-to") base rate cannot be combined with time tiers — the
|
||||
engine would ignore them. Warn up-front; publishing is also blocked server-side. */}
|
||||
{form.base.mode === "stepped" && form.tiers.length > 0 && (
|
||||
<p className="mb-3 rounded-term border border-term-red/50 bg-term-red/10 px-3 py-2 text-[0.75rem] text-term-red">
|
||||
{t("tariff.steppedTiersConflict")}
|
||||
</p>
|
||||
)}
|
||||
{form.tiers.map((tr, i) => (
|
||||
<fieldset key={i} className="card mb-3 p-4">
|
||||
<legend className="flex items-center gap-2 px-1">
|
||||
<input
|
||||
className="input w-40"
|
||||
value={tr.name}
|
||||
onChange={(e) => setTier(i, { name: e.target.value })}
|
||||
placeholder={t("tariff.tierName")}
|
||||
/>
|
||||
<button type="button" className="btn btn-danger btn-sm" onClick={() => removeTier(i)}>
|
||||
{t("tariff.remove")}
|
||||
</button>
|
||||
</legend>
|
||||
<div className="grid grid-cols-[max-content_1fr] items-center gap-x-4 gap-y-2">
|
||||
<label className="label">{t("tariff.tierPriority")}</label>
|
||||
<input className="input w-20" value={tr.priority} onChange={(e) => setTier(i, { priority: e.target.value })} />
|
||||
<label className="label">{t("tariff.tierCategory")}</label>
|
||||
<input className="input w-40" value={tr.category} onChange={(e) => setTier(i, { category: e.target.value })} placeholder={t("tariff.tierCategoryPh")} />
|
||||
<label className="label">{t("tariff.tierDays")}</label>
|
||||
<span className="flex flex-wrap gap-2">
|
||||
{[1, 2, 3, 4, 5, 6, 0].map((d) => (
|
||||
<label key={d} className="inline-flex items-center gap-1 text-[0.75rem] text-term-text">
|
||||
<input type="checkbox" className="accent-term-amber" checked={tr.dow.includes(d)} onChange={() => toggleDow(i, d)} />
|
||||
{t(`tariff.dow${d}`)}
|
||||
</label>
|
||||
))}
|
||||
</span>
|
||||
<label className="label">{t("tariff.tierHours")}</label>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<input className="input w-20" value={tr.fromHour} onChange={(e) => setTier(i, { fromHour: e.target.value })} placeholder="22:00" />
|
||||
<span className="text-term-muted">–</span>
|
||||
<input className="input w-20" value={tr.toHour} onChange={(e) => setTier(i, { toHour: e.target.value })} placeholder="06:00" />
|
||||
{tr.fromHour && tr.toHour && tr.toHour <= tr.fromHour && (
|
||||
<span className="text-[0.6875rem] text-term-muted">{t("tariff.tierOvernight")}</span>
|
||||
)}
|
||||
</span>
|
||||
<label className="label">{t("tariff.tierDates")}</label>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<input type="date" className="input w-40" value={tr.dateFrom} onChange={(e) => setTier(i, { dateFrom: e.target.value })} />
|
||||
<span className="text-term-muted">–</span>
|
||||
<input type="date" className="input w-40" value={tr.dateTo} onChange={(e) => setTier(i, { dateTo: e.target.value })} />
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3 border-t border-term-border pt-3">
|
||||
<PricingEditor
|
||||
t={t}
|
||||
pricing={tr.pricing}
|
||||
incrementMin={inc}
|
||||
allowPackage
|
||||
onMode={(mode) => updatePricing(i, (p) => ({ ...p, mode }))}
|
||||
onFlat={(flat) => updatePricing(i, (p) => ({ ...p, flat }))}
|
||||
onPackage={(packageTotal) => updatePricing(i, (p) => ({ ...p, packageTotal }))}
|
||||
onCap={(dailyCap) => updatePricing(i, (p) => ({ ...p, dailyCap }))}
|
||||
onBlock={(bi, patch) => setBlock(i, bi, patch)}
|
||||
onAddBlock={() => addBlock(i)}
|
||||
onRemoveBlock={(bi) => removeBlock(i, bi)}
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
))}
|
||||
<button type="button" className="btn btn-sm" onClick={addTier}>
|
||||
{t("tariff.addTier")}
|
||||
</button>
|
||||
</details>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// A reusable pricing-body editor — flat (per increment) / marginal ladder / stepped
|
||||
// (up-to) / window package. The stepped mode is offered only where `allowStepped`
|
||||
// (the default card); the package mode only where `allowPackage` (tier cards — the
|
||||
// engine needs a window to be an occurrence of).
|
||||
function PricingEditor(props: {
|
||||
t: (k: string, opts?: Record<string, unknown>) => string;
|
||||
pricing: PricingForm;
|
||||
/** Current billing increment (minutes) — every flat/ladder price is PER this unit,
|
||||
* so the price labels state it explicitly instead of a vague "per increment". */
|
||||
incrementMin: number;
|
||||
allowStepped?: boolean;
|
||||
allowPackage?: boolean;
|
||||
onMode: (m: "ladder" | "flat" | "stepped" | "package") => void;
|
||||
onFlat: (v: string) => void;
|
||||
onPackage?: (v: string) => void;
|
||||
onCap: (v: string) => void;
|
||||
onBlock: (i: number, patch: Partial<BlockForm>) => void;
|
||||
onAddBlock: () => void;
|
||||
onRemoveBlock: (i: number) => void;
|
||||
onStep?: (i: number, patch: Partial<StepForm>) => void;
|
||||
onAddStep?: () => void;
|
||||
onRemoveStep?: (i: number) => void;
|
||||
}) {
|
||||
const { t, pricing: p } = props;
|
||||
/** "= N / orë" equivalence for a per-increment price (only shown when the tick
|
||||
* isn't an hour — at 60 the price already IS the hourly price). */
|
||||
const perHour = (major: string): string | null => {
|
||||
if (props.incrementMin === 60) return null;
|
||||
const v = Number(major);
|
||||
if (!Number.isFinite(v) || v <= 0) return null;
|
||||
return t("tariff.perHourEquiv", { amount: ((v * 60) / props.incrementMin).toFixed(2) });
|
||||
};
|
||||
const unitLabel =
|
||||
props.incrementMin === 60 ? t("tariff.pricePerHour") : t("tariff.pricePerN", { min: props.incrementMin });
|
||||
const flatLabel =
|
||||
props.incrementMin === 60 ? t("tariff.modeFlat") : t("tariff.modeFlatN", { min: props.incrementMin });
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-3 flex flex-wrap gap-4 text-[0.75rem]">
|
||||
<label className="inline-flex items-center gap-1.5 text-term-text">
|
||||
<input type="radio" className="accent-term-amber" checked={p.mode === "ladder"} onChange={() => props.onMode("ladder")} />
|
||||
{t("tariff.modeLadder")}
|
||||
</label>
|
||||
<label className="inline-flex items-center gap-1.5 text-term-text">
|
||||
<input type="radio" className="accent-term-amber" checked={p.mode === "flat"} onChange={() => props.onMode("flat")} />
|
||||
{flatLabel}
|
||||
</label>
|
||||
{props.allowStepped && (
|
||||
<label className="inline-flex items-center gap-1.5 text-term-text">
|
||||
<input type="radio" className="accent-term-amber" checked={p.mode === "stepped"} onChange={() => props.onMode("stepped")} />
|
||||
{t("tariff.modeStepped")}
|
||||
</label>
|
||||
)}
|
||||
{props.allowPackage && (
|
||||
<label className="inline-flex items-center gap-1.5 text-term-text">
|
||||
<input type="radio" className="accent-term-amber" checked={p.mode === "package"} onChange={() => props.onMode("package")} />
|
||||
{t("tariff.modePackage")}
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{p.mode === "package" ? (
|
||||
<div>
|
||||
<p className="hint mb-2">{t("tariff.packageHint")}</p>
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<span className="label">{t("tariff.packageTotal")}</span>
|
||||
<input className="input w-28" value={p.packageTotal} onChange={(e) => props.onPackage?.(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
) : p.mode === "stepped" ? (
|
||||
<>
|
||||
<p className="hint mb-2">{t("tariff.steppedHint")}</p>
|
||||
<table className="w-full border-collapse">
|
||||
<thead>
|
||||
<tr className="text-left">
|
||||
<th className="label px-2 pb-1 font-normal">{t("tariff.stepUpTo")}</th>
|
||||
<th className="label px-2 pb-1 font-normal">{t("tariff.stepTotal")}</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{p.steps.map((s, i) => (
|
||||
<tr key={i}>
|
||||
<td className="px-2 py-1">
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<input className="input w-20" value={s.hours} onChange={(e) => props.onStep?.(i, { hours: e.target.value })} placeholder={t("tariff.egHours")} />
|
||||
<span className="text-[0.6875rem] text-term-muted">{t("tariff.hoursUnit")}</span>
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-2 py-1">
|
||||
<input className="input w-28" value={s.total} onChange={(e) => props.onStep?.(i, { total: e.target.value })} />
|
||||
</td>
|
||||
<td className="px-2">
|
||||
{p.steps.length > 1 && (
|
||||
<button type="button" className="btn btn-ghost btn-sm" onClick={() => props.onRemoveStep?.(i)}>
|
||||
{t("tariff.remove")}
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="mt-3">
|
||||
<button type="button" className="btn btn-sm" onClick={props.onAddStep}>
|
||||
{t("tariff.addStep")}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : p.mode === "flat" ? (
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<span className="label">{unitLabel}</span>
|
||||
<input className="input w-28" value={p.flat} onChange={(e) => props.onFlat(e.target.value)} />
|
||||
{perHour(p.flat) && <span className="text-[0.6875rem] text-term-muted">{perHour(p.flat)}</span>}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<table className="w-full border-collapse">
|
||||
<thead>
|
||||
<tr className="text-left">
|
||||
<th className="label px-2 pb-1 font-normal">{t("tariff.bandDuration")}</th>
|
||||
<th className="label px-2 pb-1 font-normal">{unitLabel}</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{p.blocks.map((b, i) => {
|
||||
const isTail = i === p.blocks.length - 1;
|
||||
return (
|
||||
<tr key={i}>
|
||||
<td className="px-2 py-1">
|
||||
{isTail ? (
|
||||
<span className="italic text-term-muted">{t("tariff.thereafter")}</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<input className="input w-20" value={b.hours} onChange={(e) => props.onBlock(i, { hours: e.target.value })} placeholder={t("tariff.egHours")} />
|
||||
<span className="text-[0.6875rem] text-term-muted">{t("tariff.hoursUnit")}</span>
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-2 py-1">
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<input className="input w-28" value={b.price} onChange={(e) => props.onBlock(i, { price: e.target.value })} />
|
||||
{perHour(b.price) && <span className="text-[0.6875rem] text-term-muted">{perHour(b.price)}</span>}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-2">
|
||||
{!isTail && (
|
||||
<button type="button" className="btn btn-ghost btn-sm" onClick={() => props.onRemoveBlock(i)}>
|
||||
{t("tariff.remove")}
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="mt-3 flex items-center gap-4">
|
||||
<button type="button" className="btn btn-sm" onClick={props.onAddBlock}>
|
||||
{t("tariff.addBlock")}
|
||||
</button>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<span className="label">{t("tariff.dailyCap")}</span>
|
||||
<input className="input w-28" value={p.dailyCap} onChange={(e) => props.onCap(e.target.value)} placeholder={t("tariff.dailyCapPh")} />
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+416
-166
@@ -1,21 +1,32 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ApiError,
|
||||
createTariffDraft,
|
||||
deleteTariffDraft,
|
||||
fetchTariff,
|
||||
loadSimSession,
|
||||
fetchTariffDrafts,
|
||||
publishTariffVersion,
|
||||
simulateTariff,
|
||||
updateTariffDraft,
|
||||
type SimulateResult,
|
||||
type SimPayment,
|
||||
type TariffDraft,
|
||||
type TariffState,
|
||||
} from "./api.js";
|
||||
import { formatMoney, formatDuration } from "./lib/format.js";
|
||||
import { TariffEditorForm, emptyForm, formFromActive, formFromVersion, toStructure, type FormState } from "./TariffEditorForm.js";
|
||||
import { Modal } from "./ui/Modal.js";
|
||||
import { formatClock, formatDateTime, formatMoney, formatDuration } from "./lib/format.js";
|
||||
import type { FeeBreakdown } from "@parking/shared";
|
||||
import type { TFunction } from "i18next";
|
||||
|
||||
// The TARIFF LAB — a pure session-pricing simulator. Test rates "in time" (overnight
|
||||
// windows, daily caps, overstay) in seconds instead of waiting hours, against ANY
|
||||
// published tariff version, with no real ledger writes. Build a hypothetical session
|
||||
// (entry, optional payment, "now") OR load a real ticket and re-evaluate it at any
|
||||
// instant. Prices via the SAME `priceSession` the booth uses (server), so the lab and
|
||||
// the live booth can never diverge. See wiki/concepts/tariff.md, booth-exit-flow.md.
|
||||
// The TARIFF LAB — a sandbox for composing + pricing EXPERIMENTAL rate cards. Drafts
|
||||
// live in their own mutable table (tariff_drafts), so experimenting never churns the
|
||||
// immutable published versions or risks a half-baked card going live: the admin
|
||||
// composes a draft in the modal (the same form the composer page uses), simulates
|
||||
// hypothetical stays against it (entry + exit, nothing else), and only when satisfied
|
||||
// PUBLISHES it through the normal immutable-version path. Pricing uses the SAME
|
||||
// `priceSession` the booth uses (server-side), so the lab and the live booth can
|
||||
// never diverge. No ledger writes. See wiki/concepts/tariff.md.
|
||||
|
||||
/** <input type="datetime-local"> wants "YYYY-MM-DDTHH:mm" in LOCAL time. */
|
||||
function toLocalInput(iso: string): string {
|
||||
@@ -33,50 +44,76 @@ function nowLocal(): string {
|
||||
return toLocalInput(new Date().toISOString());
|
||||
}
|
||||
|
||||
/** What the simulation runs against: the live card, a historical published
|
||||
* version, or one lab draft. */
|
||||
type Selection = { kind: "active" } | { kind: "version"; id: string } | { kind: "draft"; id: string };
|
||||
|
||||
/** Modal state: a draft being composed (id null = not yet saved). */
|
||||
interface DraftEdit {
|
||||
id: string | null;
|
||||
name: string;
|
||||
form: FormState;
|
||||
}
|
||||
|
||||
export function TariffLab() {
|
||||
const { t } = useTranslation();
|
||||
const [state, setState] = useState<TariffState | null>(null);
|
||||
const [drafts, setDrafts] = useState<TariffDraft[]>([]);
|
||||
const [selected, setSelected] = useState<Selection>({ kind: "active" });
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
|
||||
// Inputs (datetime-local strings, local wall-clock).
|
||||
// The hypothetical stay: entry + exit, nothing else.
|
||||
const [entered, setEntered] = useState<string>(() => {
|
||||
const d = new Date();
|
||||
d.setHours(d.getHours() - 3); // default: a 3h-ago entry
|
||||
return toLocalInput(d.toISOString());
|
||||
});
|
||||
const [asOf, setAsOf] = useState<string>(nowLocal);
|
||||
const [category, setCategory] = useState("");
|
||||
const [versionId, setVersionId] = useState<string>(""); // "" = active
|
||||
// Optional single hypothetical payment (the latest grants the walk-back grace).
|
||||
const [paid, setPaid] = useState(false);
|
||||
const [paidAt, setPaidAt] = useState<string>(nowLocal);
|
||||
const [graceMin, setGraceMin] = useState<string>("5");
|
||||
// Load-a-real-ticket.
|
||||
const [ticket, setTicket] = useState("");
|
||||
const [loadMsg, setLoadMsg] = useState<string | null>(null);
|
||||
const [exit, setExit] = useState<string>(nowLocal);
|
||||
|
||||
const [result, setResult] = useState<SimulateResult | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
// The draft-composer modal.
|
||||
const [edit, setEdit] = useState<DraftEdit | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [editErr, setEditErr] = useState<string | null>(null);
|
||||
|
||||
async function refresh() {
|
||||
const [s, d] = await Promise.all([fetchTariff(), fetchTariffDrafts()]);
|
||||
setState(s);
|
||||
setDrafts(d.drafts);
|
||||
return d.drafts;
|
||||
}
|
||||
useEffect(() => {
|
||||
fetchTariff()
|
||||
.then(setState)
|
||||
.catch((e) => setErr((e as Error).message));
|
||||
refresh().catch((e) => setErr((e as Error).message));
|
||||
}, []);
|
||||
|
||||
const selectedDraft = selected.kind === "draft" ? drafts.find((d) => d.id === selected.id) ?? null : null;
|
||||
const selectedVersion =
|
||||
selected.kind === "version" ? state?.versions.find((v) => v.id === selected.id) ?? null : null;
|
||||
|
||||
function select(sel: Selection) {
|
||||
setSelected(sel);
|
||||
setResult(null); // a stale price against another card would mislead
|
||||
setErr(null);
|
||||
setNotice(null);
|
||||
}
|
||||
|
||||
async function run() {
|
||||
setErr(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
const payments: SimPayment[] = paid
|
||||
? [{ paidAt: fromLocalInput(paidAt), graceExitMin: graceMin.trim() === "" ? null : Number(graceMin) }]
|
||||
: [];
|
||||
const r = await simulateTariff({
|
||||
enteredAt: fromLocalInput(entered),
|
||||
asOf: fromLocalInput(asOf),
|
||||
payments,
|
||||
category: category.trim() || undefined,
|
||||
tariffVersionId: versionId || undefined,
|
||||
asOf: fromLocalInput(exit),
|
||||
// A draft carries its own structure+currency; a historical version is
|
||||
// referenced by id; otherwise the ACTIVE version.
|
||||
...(selectedDraft
|
||||
? { structure: selectedDraft.structure, currency: selectedDraft.currency }
|
||||
: selectedVersion
|
||||
? { tariffVersionId: selectedVersion.id }
|
||||
: {}),
|
||||
});
|
||||
setResult(r);
|
||||
} catch (e) {
|
||||
@@ -87,162 +124,296 @@ export function TariffLab() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTicket() {
|
||||
setLoadMsg(null);
|
||||
// --- draft actions ---
|
||||
function newDraft() {
|
||||
// Start from the live card when there is one — the admin usually experiments
|
||||
// with a variation of today's prices, not from a blank slate.
|
||||
const form = state?.active ? formFromActive(state) : emptyForm();
|
||||
setEditErr(null);
|
||||
setEdit({ id: null, name: "", form });
|
||||
}
|
||||
function editDraft(d: TariffDraft) {
|
||||
setEditErr(null);
|
||||
setEdit({ id: d.id, name: d.name, form: formFromVersion(d.currency, d.structure) });
|
||||
}
|
||||
|
||||
async function saveDraft() {
|
||||
if (!edit) return;
|
||||
setSaving(true);
|
||||
setEditErr(null);
|
||||
try {
|
||||
const body = {
|
||||
name: edit.name.trim(),
|
||||
currency: edit.form.currency.trim().toUpperCase(),
|
||||
structure: toStructure(edit.form),
|
||||
};
|
||||
const saved = edit.id ? await updateTariffDraft(edit.id, body) : await createTariffDraft(body);
|
||||
await refresh();
|
||||
setEdit(null);
|
||||
select({ kind: "draft", id: saved.id });
|
||||
} catch (e) {
|
||||
const text =
|
||||
e instanceof ApiError && e.problems?.length ? `${e.message}: ${e.problems.join("; ")}` : (e as Error).message;
|
||||
setEditErr(text);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function removeDraft(d: TariffDraft) {
|
||||
if (!confirm(t("lab.confirmDelete", { name: d.name }))) return;
|
||||
setErr(null);
|
||||
try {
|
||||
const s = await loadSimSession(ticket.trim());
|
||||
setEntered(toLocalInput(s.enteredAt));
|
||||
setAsOf(s.exitedAt ? toLocalInput(s.exitedAt) : nowLocal());
|
||||
setCategory(s.category ?? "");
|
||||
setVersionId(s.tariffVersionId ?? "");
|
||||
const last = s.payments.at(-1);
|
||||
if (last) {
|
||||
setPaid(true);
|
||||
setPaidAt(toLocalInput(last.paidAt));
|
||||
setGraceMin(last.graceExitMin != null ? String(last.graceExitMin) : "");
|
||||
} else {
|
||||
setPaid(false);
|
||||
}
|
||||
setLoadMsg(t("lab.loaded", { id: s.identity }));
|
||||
await deleteTariffDraft(d.id);
|
||||
await refresh();
|
||||
select({ kind: "active" });
|
||||
} catch (e) {
|
||||
setErr((e as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
const currency = result?.currency ?? state?.active?.currency ?? "ALL";
|
||||
async function publishDraft(d: TariffDraft) {
|
||||
if (!confirm(t("lab.confirmPublish", { name: d.name }))) return;
|
||||
setErr(null);
|
||||
setNotice(null);
|
||||
try {
|
||||
// The draft's name rides along onto the immutable version.
|
||||
await publishTariffVersion({ currency: d.currency, structure: d.structure, name: d.name });
|
||||
await refresh();
|
||||
setNotice(t("tariff.publishedOk"));
|
||||
} catch (e) {
|
||||
const text =
|
||||
e instanceof ApiError && e.problems?.length ? `${e.message}: ${e.problems.join("; ")}` : (e as Error).message;
|
||||
setErr(text);
|
||||
}
|
||||
}
|
||||
|
||||
const currency = result?.currency ?? selectedDraft?.currency ?? selectedVersion?.currency ?? state?.active?.currency ?? "ALL";
|
||||
|
||||
return (
|
||||
<section className="px-4 py-6">
|
||||
<h2 className="mb-1 text-h4 font-semibold text-term-text">{t("lab.title")}</h2>
|
||||
<p className="hint mb-4">{t("lab.intro")}</p>
|
||||
|
||||
{/* Load a real ticket */}
|
||||
<div className="card card-body mb-4 flex flex-wrap items-end gap-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="label">{t("lab.loadTicket")}</label>
|
||||
<input
|
||||
className="input w-56"
|
||||
value={ticket}
|
||||
onChange={(e) => setTicket(e.target.value)}
|
||||
placeholder={t("lab.loadTicketPh")}
|
||||
/>
|
||||
</div>
|
||||
<button type="button" className="btn btn-sm" onClick={loadTicket} disabled={!ticket.trim()}>
|
||||
{t("lab.load")}
|
||||
</button>
|
||||
{loadMsg && <span className="text-[0.75rem] text-term-green">{loadMsg}</span>}
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 lg:flex-row">
|
||||
{/* Main: the hypothetical stay + result, priced against the selection. */}
|
||||
<div className="min-w-0 flex-1">
|
||||
{/* What we're pricing against + draft actions. */}
|
||||
<div className="mb-3 flex flex-wrap items-center gap-2">
|
||||
<span className="rounded bg-term-panel-2 px-2 py-1 text-[0.75rem] text-term-cyan">
|
||||
{selectedDraft
|
||||
? selectedDraft.name
|
||||
: selectedVersion
|
||||
? selectedVersion.name ?? formatDateTime(selectedVersion.effectiveFrom, t)
|
||||
: t("lab.activeTariff")}
|
||||
</span>
|
||||
{selectedDraft && (
|
||||
<>
|
||||
<button type="button" className="btn btn-sm" onClick={() => editDraft(selectedDraft)}>
|
||||
{t("lab.edit")}
|
||||
</button>
|
||||
<button type="button" className="btn btn-sm" onClick={() => publishDraft(selectedDraft)}>
|
||||
{t("lab.publish")}
|
||||
</button>
|
||||
<button type="button" className="btn btn-danger btn-sm" onClick={() => removeDraft(selectedDraft)}>
|
||||
{t("lab.delete")}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{notice && <span className="text-[0.75rem] text-term-green">{notice}</span>}
|
||||
</div>
|
||||
|
||||
{/* Hypothetical session inputs */}
|
||||
<div className="card card-body grid grid-cols-[max-content_1fr] items-center gap-x-4 gap-y-2">
|
||||
<label className="label">{t("lab.tariffVersion")}</label>
|
||||
<select className="input w-full max-w-md" value={versionId} onChange={(e) => setVersionId(e.target.value)}>
|
||||
<option value="">{t("lab.activeVersion")}</option>
|
||||
{state?.versions.map((v) => (
|
||||
<option key={v.id} value={v.id}>
|
||||
{new Date(v.effectiveFrom).toLocaleString()} · {v.currency} · {v.id.slice(0, 8)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="card card-body grid grid-cols-[max-content_1fr] items-center gap-x-4 gap-y-2">
|
||||
<label className="label">{t("lab.entered")}</label>
|
||||
<input type="datetime-local" className="input w-64" value={entered} onChange={(e) => setEntered(e.target.value)} />
|
||||
|
||||
<label className="label">{t("lab.entered")}</label>
|
||||
<input type="datetime-local" className="input w-64" value={entered} onChange={(e) => setEntered(e.target.value)} />
|
||||
<label className="label">{t("lab.exit")}</label>
|
||||
<span className="flex items-center gap-2">
|
||||
<input type="datetime-local" className="input w-64" value={exit} onChange={(e) => setExit(e.target.value)} />
|
||||
<button type="button" className="btn btn-sm" onClick={() => setExit(nowLocal())}>
|
||||
{t("lab.now")}
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<label className="label">{t("lab.asOf")}</label>
|
||||
<span className="flex items-center gap-2">
|
||||
<input type="datetime-local" className="input w-64" value={asOf} onChange={(e) => setAsOf(e.target.value)} />
|
||||
<button type="button" className="btn btn-sm" onClick={() => setAsOf(nowLocal())}>
|
||||
{t("lab.now")}
|
||||
</button>
|
||||
</span>
|
||||
<div className="mt-4 flex items-center gap-3">
|
||||
<button type="button" className="btn btn-primary btn-lg" onClick={run} disabled={busy}>
|
||||
{busy ? t("lab.pricing") : t("lab.price")}
|
||||
</button>
|
||||
{err && <span className="text-[0.75rem] text-term-red">{err}</span>}
|
||||
</div>
|
||||
|
||||
<label className="label">{t("lab.category")}</label>
|
||||
<input
|
||||
className="input w-40"
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value)}
|
||||
placeholder={t("lab.categoryPh")}
|
||||
/>
|
||||
{result && (
|
||||
<div className="mt-6 grid gap-4 md:grid-cols-2">
|
||||
{/* Outcome */}
|
||||
<div className="card card-body">
|
||||
<h3 className="mb-2 text-h6 font-semibold uppercase tracking-wider text-term-text">{t("lab.outcome")}</h3>
|
||||
<dl className="grid grid-cols-[max-content_1fr] gap-x-4 gap-y-1 text-[0.8125rem]">
|
||||
<dt className="text-term-muted">{t("lab.amountDue")}</dt>
|
||||
<dd className="text-2xl font-bold text-term-cyan">{formatMoney(result.pricing.amountMinor, currency)}</dd>
|
||||
<dt className="text-term-muted">{t("lab.billedPeriod")}</dt>
|
||||
<dd className="text-term-text">
|
||||
{formatDuration(result.pricing.periodStart, fromLocalInput(exit))}
|
||||
{result.pricing.overstay && (
|
||||
<span className="ml-2 rounded bg-term-red/15 px-1.5 py-0.5 text-[0.625rem] uppercase text-term-red">
|
||||
{t("lab.overstay")}
|
||||
</span>
|
||||
)}
|
||||
{result.pricing.withinGrace && (
|
||||
<span className="ml-2 rounded bg-term-green/15 px-1.5 py-0.5 text-[0.625rem] uppercase text-term-green">
|
||||
{t("lab.settled")}
|
||||
</span>
|
||||
)}
|
||||
</dd>
|
||||
<dt className="text-term-muted">{t("lab.periodStart")}</dt>
|
||||
<dd className="text-term-text">{formatDateTime(result.pricing.periodStart, t)}</dd>
|
||||
{result.pricing.graceExpiresAt && (
|
||||
<>
|
||||
<dt className="text-term-muted">{t("lab.graceExpires")}</dt>
|
||||
<dd className="text-term-text">{formatDateTime(result.pricing.graceExpiresAt, t)}</dd>
|
||||
</>
|
||||
)}
|
||||
</dl>
|
||||
{/* HOW the sum is produced — line items from the SAME engine walk
|
||||
(their sum is the amount by construction). */}
|
||||
{result.breakdown && (
|
||||
<BreakdownTable b={result.breakdown} periodStart={result.pricing.periodStart} currency={currency} t={t} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<label className="label">{t("lab.payment")}</label>
|
||||
<span className="flex flex-wrap items-center gap-2">
|
||||
<label className="inline-flex items-center gap-1 text-[0.75rem] text-term-text">
|
||||
<input type="checkbox" className="accent-term-amber" checked={paid} onChange={(e) => setPaid(e.target.checked)} />
|
||||
{t("lab.paid")}
|
||||
</label>
|
||||
{paid && (
|
||||
<>
|
||||
<input
|
||||
type="datetime-local"
|
||||
className="input w-64"
|
||||
value={paidAt}
|
||||
onChange={(e) => setPaidAt(e.target.value)}
|
||||
/>
|
||||
<span className="text-term-muted">{t("lab.graceMin")}</span>
|
||||
<input className="input w-20" value={graceMin} onChange={(e) => setGraceMin(e.target.value)} />
|
||||
</>
|
||||
{/* Duration curve from entry — see where the cap flattens / windows shift. */}
|
||||
<div className="card card-body">
|
||||
<h3 className="mb-2 text-h6 font-semibold uppercase tracking-wider text-term-text">{t("lab.curve")}</h3>
|
||||
<p className="hint mb-2">{t("lab.curveHint")}</p>
|
||||
<table className="w-full text-[0.75rem] tabular-nums">
|
||||
<tbody>
|
||||
{result.curve.map((c) => (
|
||||
<tr key={c.minutes} className="border-b border-term-border/40">
|
||||
<td className="py-0.5 text-term-muted">{labelMin(c.minutes)}</td>
|
||||
<td className="py-0.5 text-right text-term-text">{formatMoney(c.amountMinor, currency)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center gap-3">
|
||||
<button type="button" className="btn btn-primary btn-lg" onClick={run} disabled={busy}>
|
||||
{busy ? t("lab.pricing") : t("lab.price")}
|
||||
</button>
|
||||
{err && <span className="text-[0.75rem] text-term-red">{err}</span>}
|
||||
</div>
|
||||
|
||||
{result && (
|
||||
<div className="mt-6 grid gap-4 md:grid-cols-2">
|
||||
{/* Outcome */}
|
||||
<div className="card card-body">
|
||||
<h3 className="mb-2 text-h6 font-semibold uppercase tracking-wider text-term-text">{t("lab.outcome")}</h3>
|
||||
<dl className="grid grid-cols-[max-content_1fr] gap-x-4 gap-y-1 text-[0.8125rem]">
|
||||
<dt className="text-term-muted">{t("lab.amountDue")}</dt>
|
||||
<dd className="text-2xl font-bold text-term-cyan">{formatMoney(result.pricing.amountMinor, currency)}</dd>
|
||||
<dt className="text-term-muted">{t("lab.billedPeriod")}</dt>
|
||||
<dd className="text-term-text">
|
||||
{formatDuration(result.pricing.periodStart, fromLocalInput(asOf))}
|
||||
{result.pricing.overstay && (
|
||||
<span className="ml-2 rounded bg-term-red/15 px-1.5 py-0.5 text-[0.625rem] uppercase text-term-red">
|
||||
{t("lab.overstay")}
|
||||
</span>
|
||||
)}
|
||||
{result.pricing.withinGrace && (
|
||||
<span className="ml-2 rounded bg-term-green/15 px-1.5 py-0.5 text-[0.625rem] uppercase text-term-green">
|
||||
{t("lab.settled")}
|
||||
</span>
|
||||
)}
|
||||
</dd>
|
||||
<dt className="text-term-muted">{t("lab.periodStart")}</dt>
|
||||
<dd className="text-term-text">{new Date(result.pricing.periodStart).toLocaleString()}</dd>
|
||||
{result.pricing.graceExpiresAt && (
|
||||
<>
|
||||
<dt className="text-term-muted">{t("lab.graceExpires")}</dt>
|
||||
<dd className="text-term-text">{new Date(result.pricing.graceExpiresAt).toLocaleString()}</dd>
|
||||
</>
|
||||
)}
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{/* Duration curve from entry — see where the cap flattens / windows shift. */}
|
||||
<div className="card card-body">
|
||||
<h3 className="mb-2 text-h6 font-semibold uppercase tracking-wider text-term-text">{t("lab.curve")}</h3>
|
||||
<p className="hint mb-2">{t("lab.curveHint")}</p>
|
||||
<table className="w-full text-[0.75rem] tabular-nums">
|
||||
<tbody>
|
||||
{result.curve.map((c) => (
|
||||
<tr key={c.minutes} className="border-b border-term-border/40">
|
||||
<td className="py-0.5 text-term-muted">{labelMin(c.minutes)}</td>
|
||||
<td className="py-0.5 text-right text-term-text">{formatMoney(c.amountMinor, currency)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sidebar: lab drafts + the full published history; click any to price
|
||||
against it. */}
|
||||
<aside className="w-full shrink-0 lg:w-72">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h3 className="text-h6 font-semibold uppercase tracking-wider text-term-text">{t("lab.drafts")}</h3>
|
||||
<button type="button" className="btn btn-sm" onClick={newDraft}>
|
||||
{t("lab.newDraft")}
|
||||
</button>
|
||||
</div>
|
||||
<ul className="flex flex-col gap-1">
|
||||
{drafts.map((d) => (
|
||||
<li key={d.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => select({ kind: "draft", id: d.id })}
|
||||
className={`w-full rounded-term border px-3 py-2 text-left text-[0.8125rem] ${
|
||||
selected.kind === "draft" && selected.id === d.id
|
||||
? "border-term-amber bg-term-amber/10 text-term-text"
|
||||
: "border-term-border text-term-muted hover:text-term-text"
|
||||
}`}
|
||||
>
|
||||
<span className="block font-semibold">{d.name}</span>
|
||||
<span className="block text-[0.6875rem] text-term-muted">
|
||||
{d.currency} · {formatDateTime(d.updatedAt, t)}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
{drafts.length === 0 && <li className="hint px-1 py-2">{t("lab.noDrafts")}</li>}
|
||||
</ul>
|
||||
|
||||
{/* Published versions: the active card first, then the immutable history
|
||||
(older versions still price past sessions — see wiki/concepts/tariff.md). */}
|
||||
<h3 className="mb-2 mt-5 text-h6 font-semibold uppercase tracking-wider text-term-text">
|
||||
{t("lab.published")}
|
||||
</h3>
|
||||
<ul className="flex flex-col gap-1">
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => select({ kind: "active" })}
|
||||
className={`w-full rounded-term border px-3 py-2 text-left text-[0.8125rem] ${
|
||||
selected.kind === "active"
|
||||
? "border-term-amber bg-term-amber/10 text-term-text"
|
||||
: "border-term-border text-term-muted hover:text-term-text"
|
||||
}`}
|
||||
>
|
||||
<span className="block font-semibold">
|
||||
{t("lab.activeTariff")}
|
||||
{state?.active?.name ? ` — ${state.active.name}` : ""}
|
||||
</span>
|
||||
<span className="block text-[0.6875rem] text-term-muted">
|
||||
{state?.active ? formatDateTime(state.active.effectiveFrom, t) : t("tariff.noRateCard")}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
{state?.versions
|
||||
.filter((v) => v.id !== state.active?.id)
|
||||
.map((v) => (
|
||||
<li key={v.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => select({ kind: "version", id: v.id })}
|
||||
className={`w-full rounded-term border px-3 py-2 text-left text-[0.8125rem] ${
|
||||
selected.kind === "version" && selected.id === v.id
|
||||
? "border-term-amber bg-term-amber/10 text-term-text"
|
||||
: "border-term-border text-term-muted hover:text-term-text"
|
||||
}`}
|
||||
>
|
||||
<span className="block font-semibold">
|
||||
{v.name ?? formatDateTime(v.effectiveFrom, t)}
|
||||
</span>
|
||||
<span className="block text-[0.6875rem] text-term-muted">
|
||||
{v.name ? `${formatDateTime(v.effectiveFrom, t)} · ` : ""}
|
||||
{v.currency}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
{/* The draft composer — the SAME form the /setup/tariff page uses, in a modal. */}
|
||||
<Modal
|
||||
open={edit != null}
|
||||
onClose={() => setEdit(null)}
|
||||
title={edit?.id ? t("lab.editDraftTitle") : t("lab.newDraftTitle")}
|
||||
width="max-w-3xl"
|
||||
>
|
||||
{edit && (
|
||||
<div>
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<label className="label">{t("lab.draftName")}</label>
|
||||
<input
|
||||
className="input w-72"
|
||||
value={edit.name}
|
||||
onChange={(e) => setEdit((d) => (d ? { ...d, name: e.target.value } : d))}
|
||||
placeholder={t("lab.draftNamePh")}
|
||||
/>
|
||||
</div>
|
||||
<TariffEditorForm
|
||||
form={edit.form}
|
||||
onChange={(update) => setEdit((d) => (d ? { ...d, form: update(d.form) } : d))}
|
||||
/>
|
||||
<div className="mt-6 flex items-center gap-3">
|
||||
<button type="button" className="btn btn-primary" onClick={saveDraft} disabled={saving || !edit.name.trim()}>
|
||||
{saving ? t("lab.savingDraft") : t("lab.saveDraft")}
|
||||
</button>
|
||||
{editErr && <span className="text-[0.75rem] text-term-red">{editErr}</span>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -252,3 +423,82 @@ function labelMin(min: number): string {
|
||||
if (min < 1440) return `${min / 60}h`;
|
||||
return `${min / 1440}d`;
|
||||
}
|
||||
|
||||
/** The fee's line items — every row states its time window / rule and its amount, so
|
||||
* the operator can retrace the exact sum (caps show as negative adjustments). */
|
||||
function BreakdownTable({
|
||||
b,
|
||||
periodStart,
|
||||
currency,
|
||||
t,
|
||||
}: {
|
||||
b: FeeBreakdown;
|
||||
periodStart: string;
|
||||
currency: string;
|
||||
t: TFunction;
|
||||
}) {
|
||||
const startMs = Date.parse(periodStart);
|
||||
const multiDay = b.billedMinutes > 1440;
|
||||
const at = (min: number) => {
|
||||
const iso = new Date(startMs + min * 60_000).toISOString();
|
||||
return multiDay ? formatDateTime(iso, t) : formatClock(iso);
|
||||
};
|
||||
const money = (m: number) => formatMoney(m, currency);
|
||||
const hours = (min: number) => (min % 60 === 0 ? `${min / 60}` : (min / 60).toFixed(1));
|
||||
|
||||
return (
|
||||
<div className="mt-3 border-t border-term-border pt-2">
|
||||
<div className="mb-1 text-[0.6875rem] uppercase tracking-wider text-term-muted">{t("lab.bd.title")}</div>
|
||||
{b.billedMinutes > 0 && (
|
||||
<p className="hint mb-1.5">
|
||||
{t("lab.bd.rounding", { raw: b.rawMinutes, billed: b.billedMinutes, inc: b.incrementMin })}
|
||||
</p>
|
||||
)}
|
||||
<table className="w-full text-[0.75rem] tabular-nums">
|
||||
<tbody>
|
||||
{b.items.map((it, i) => {
|
||||
let label: string;
|
||||
let amount: number;
|
||||
let cls = "text-term-text";
|
||||
switch (it.kind) {
|
||||
case "grace":
|
||||
label = t("lab.bd.grace", { min: it.minutes });
|
||||
amount = 0;
|
||||
cls = "text-term-green";
|
||||
break;
|
||||
case "band":
|
||||
label = `${at(it.fromMin)}–${at(it.toMin)} · ${it.increments} × ${money(it.unitMinor)}${it.card ? ` · ${it.card}` : ""}`;
|
||||
amount = it.amountMinor;
|
||||
break;
|
||||
case "package":
|
||||
label = `${at(it.fromMin)} · ${it.card} — ${t("lab.bd.package")}`;
|
||||
amount = it.amountMinor;
|
||||
break;
|
||||
case "step":
|
||||
label = it.repeated
|
||||
? t("lab.bd.stepRepeated", { day: it.day })
|
||||
: t("lab.bd.step", { day: it.day, hours: hours(it.uptoMin) });
|
||||
amount = it.amountMinor;
|
||||
break;
|
||||
case "cap":
|
||||
label = t("lab.bd.cap", { day: it.day, cap: money(it.capMinor) });
|
||||
amount = it.amountMinor;
|
||||
cls = "text-term-red";
|
||||
break;
|
||||
}
|
||||
return (
|
||||
<tr key={i} className="border-b border-term-border/40">
|
||||
<td className="py-0.5 pr-2 text-term-muted">{label}</td>
|
||||
<td className={`whitespace-nowrap py-0.5 text-right ${cls}`}>{money(amount)}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
<tr>
|
||||
<td className="py-1 pr-2 font-semibold text-term-text">{t("lab.bd.total")}</td>
|
||||
<td className="whitespace-nowrap py-1 text-right font-semibold text-term-cyan">{money(b.totalMinor)}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
applyValidation,
|
||||
fetchMyValidationPrograms,
|
||||
fetchValidationSession,
|
||||
voidValidation,
|
||||
type SessionUser,
|
||||
type ValidationProgramView,
|
||||
type ValidationSessionView,
|
||||
} from "./api.js";
|
||||
import { formatDuration, formatMoney, formatRelativeDateTime } from "./lib/format.js";
|
||||
|
||||
// The MERCHANT screen (/validate): the bar/lavazh user's ENTIRE surface. Scan or key
|
||||
// the customer's ticket → see the session (deliberately NO money data — the booth
|
||||
// settles) → apply the bound program → done. Mobile-friendly: a phone/tablet on the
|
||||
// site LAN, or a booth-style USB HID scanner (it types digits + Enter into the
|
||||
// focused input). A mistake can be voided while UNUSED (append-only, signed).
|
||||
// Gated by validation:create + the server-side program↔user binding.
|
||||
// See wiki/concepts/validation-discounts.md.
|
||||
|
||||
type Program = Omit<ValidationProgramView, "userIds">;
|
||||
|
||||
/** Human line for what a program grants (the params live on the program row). */
|
||||
function programSummary(p: Program, t: (k: string, o?: Record<string, unknown>) => string): string {
|
||||
if (p.mode === "comp") return t("val.modeComp");
|
||||
if (p.mode === "timeCredit") return `${t("val.modeTimeCredit")}: ${p.minutes ?? 0} min`;
|
||||
if (p.mode === "percent") return `${t("val.modePercent")}: ${p.percent ?? 0}%`;
|
||||
return t("val.modeFixed");
|
||||
}
|
||||
|
||||
export function ValidateScreen({ user }: { user: SessionUser }) {
|
||||
const { t } = useTranslation();
|
||||
const [programs, setPrograms] = useState<Program[] | null>(null);
|
||||
const [programId, setProgramId] = useState<string | null>(null);
|
||||
const [ticket, setTicket] = useState("");
|
||||
const [view, setView] = useState<ValidationSessionView | null>(null);
|
||||
const [amount, setAmount] = useState("");
|
||||
const [msg, setMsg] = useState<{ kind: "ok" | "err"; text: string } | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchMyValidationPrograms()
|
||||
.then((r) => {
|
||||
setPrograms(r.programs);
|
||||
if (r.programs.length === 1) setProgramId(r.programs[0]!.id);
|
||||
})
|
||||
.catch(() => setPrograms([]));
|
||||
inputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
const program = programs?.find((p) => p.id === programId) ?? null;
|
||||
|
||||
async function lookup(id?: string) {
|
||||
const identity = (id ?? ticket).trim();
|
||||
if (!identity) return;
|
||||
setMsg(null);
|
||||
try {
|
||||
setView(await fetchValidationSession(identity));
|
||||
} catch (e) {
|
||||
setMsg({ kind: "err", text: (e as Error).message });
|
||||
}
|
||||
}
|
||||
|
||||
async function apply() {
|
||||
if (!view || !program) return;
|
||||
setBusy(true);
|
||||
setMsg(null);
|
||||
try {
|
||||
const body: { identity: string; programId: string; amountMinor?: number } = {
|
||||
identity: view.identity,
|
||||
programId: program.id,
|
||||
};
|
||||
if (program.mode === "fixed") {
|
||||
const n = Number(amount);
|
||||
body.amountMinor = Number.isFinite(n) ? Math.round(n * 100) : 0;
|
||||
}
|
||||
await applyValidation(body);
|
||||
setMsg({ kind: "ok", text: t("val.applied") });
|
||||
setAmount("");
|
||||
await lookup(view.identity);
|
||||
} catch (e) {
|
||||
setMsg({ kind: "err", text: (e as Error).message });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function voidOne(eventId: string) {
|
||||
if (!view) return;
|
||||
if (!window.confirm(t("val.confirmVoid"))) return;
|
||||
setMsg(null);
|
||||
try {
|
||||
await voidValidation({ eventId, identity: view.identity });
|
||||
await lookup(view.identity);
|
||||
} catch (e) {
|
||||
setMsg({ kind: "err", text: (e as Error).message });
|
||||
}
|
||||
}
|
||||
|
||||
// The session's blocking condition, if any (not found / closed / subscriber).
|
||||
const blocked =
|
||||
view == null
|
||||
? null
|
||||
: !view.found
|
||||
? t("val.notFound")
|
||||
: view.subscription
|
||||
? t("val.subscription")
|
||||
: !view.open
|
||||
? t("val.closed")
|
||||
: null;
|
||||
|
||||
const alreadyApplied =
|
||||
view != null &&
|
||||
program != null &&
|
||||
view.validations.some((v) => v.programId === program.id && !v.voided && v.consumedBy == null);
|
||||
|
||||
const fixedAmountOk =
|
||||
program?.mode !== "fixed" ||
|
||||
(Number(amount) > 0 &&
|
||||
(program.maxAmountMinor == null || Math.round(Number(amount) * 100) <= program.maxAmountMinor));
|
||||
|
||||
return (
|
||||
<div className="mx-auto mt-6 w-full max-w-md">
|
||||
<section className="card p-4">
|
||||
<div className="text-[0.6875rem] uppercase tracking-wider text-term-muted">{t("val.title")}</div>
|
||||
|
||||
{programs != null && programs.length === 0 && (
|
||||
<p className="mt-3 text-[0.8125rem] text-term-red">{t("val.noPrograms")}</p>
|
||||
)}
|
||||
|
||||
{programs != null && programs.length > 1 && (
|
||||
<div className="mt-3 flex gap-1">
|
||||
{programs.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
className={`btn btn-sm ${p.id === programId ? "btn-primary" : "btn-ghost"}`}
|
||||
onClick={() => setProgramId(p.id)}
|
||||
>
|
||||
{p.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{program && <p className="mt-1 text-[0.75rem] text-term-muted">{program.name} — {programSummary(program, t)}</p>}
|
||||
|
||||
<form
|
||||
className="mt-3 flex gap-2"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void lookup();
|
||||
}}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="input flex-1 tabular-nums"
|
||||
inputMode="numeric"
|
||||
value={ticket}
|
||||
onChange={(e) => setTicket(e.target.value)}
|
||||
placeholder={t("val.scanPrompt")}
|
||||
/>
|
||||
<button type="submit" className="btn btn-primary btn-sm">{t("val.lookup")}</button>
|
||||
</form>
|
||||
|
||||
{msg && (
|
||||
<p className={`mt-2 text-[0.8125rem] ${msg.kind === "ok" ? "text-term-green" : "text-term-red"}`}>
|
||||
{msg.text}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{view && (
|
||||
<div className="mt-3 border-t border-term-border pt-3">
|
||||
{blocked ? (
|
||||
<p className="text-[0.8125rem] text-term-red">{blocked}</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-baseline justify-between text-[0.8125rem]">
|
||||
<span className="font-semibold tabular-nums text-term-text">{view.identity}</span>
|
||||
<span className="text-term-muted">
|
||||
{t("val.entry")} {formatRelativeDateTime(view.enteredAt, t)}
|
||||
{view.enteredAt && <> · {formatDuration(view.enteredAt, new Date().toISOString())}</>}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{program && !alreadyApplied && (
|
||||
<div className="mt-3 grid gap-2">
|
||||
{program.mode === "fixed" && (
|
||||
<div className="field">
|
||||
<span className="label">
|
||||
{t("val.amountLabel")}
|
||||
{program.maxAmountMinor != null && (
|
||||
<span className="hint ml-2">
|
||||
{t("val.amountHint", { max: formatMoney(program.maxAmountMinor, "") })}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<input
|
||||
className="input w-40 tabular-nums"
|
||||
inputMode="decimal"
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
placeholder="300"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={busy || !fixedAmountOk}
|
||||
onClick={apply}
|
||||
>
|
||||
{t("val.apply")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{view.validations.length > 0 && (
|
||||
<div className="mt-3">
|
||||
<div className="label">{t("val.existing")}</div>
|
||||
<ul className="mt-1 grid gap-1">
|
||||
{view.validations.map((v) => (
|
||||
<li key={v.eventId} className="flex items-center gap-2 text-[0.75rem] text-term-text">
|
||||
<span>{v.label}</span>
|
||||
{v.amountMinor != null && <span className="tabular-nums">−{formatMoney(v.amountMinor, "")}</span>}
|
||||
{v.minutes != null && <span>{v.minutes} min</span>}
|
||||
{v.percent != null && <span>{v.percent}%</span>}
|
||||
{v.voided ? (
|
||||
<span className="text-term-muted">({t("val.voided")})</span>
|
||||
) : v.consumedBy != null ? (
|
||||
<span className="text-term-muted">({t("val.used")})</span>
|
||||
) : (
|
||||
v.operator === user.username && (
|
||||
<button type="button" className="btn btn-ghost btn-sm ml-auto" onClick={() => voidOne(v.eventId)}>
|
||||
{t("val.void")}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
fetchUsers,
|
||||
saveValidationProgram,
|
||||
type ManagedUser,
|
||||
type ValidationMode,
|
||||
type ValidationProgramView,
|
||||
} from "./api.js";
|
||||
|
||||
// The /setup/site RIGHT panel: per-station merchant-validation config (Bar / Lavazh).
|
||||
// The checkboxes on the left card toggle a station's `active`; this panel edits the
|
||||
// enabled stations' programs — one panel, tabs when both are on. Storage is generic
|
||||
// (validation_programs rows keyed "bar"/"lavazh"); the UI is deliberately these two
|
||||
// fixed stations. Amounts are entered in MAJOR units and stored in integer minor
|
||||
// units (the tariff-composer convention). See wiki/concepts/validation-discounts.md.
|
||||
|
||||
/** The two well-known stations the checkboxes toggle. */
|
||||
export const STATIONS = ["bar", "lavazh"] as const;
|
||||
export type StationId = (typeof STATIONS)[number];
|
||||
|
||||
/** A blank program draft for a station enabled for the first time. */
|
||||
export function defaultProgram(id: StationId, label: string): Omit<ValidationProgramView, "id"> {
|
||||
return {
|
||||
name: label,
|
||||
mode: "comp",
|
||||
minutes: null,
|
||||
percent: null,
|
||||
maxAmountMinor: null,
|
||||
maxPerDay: null,
|
||||
active: true,
|
||||
userIds: [],
|
||||
};
|
||||
}
|
||||
|
||||
const toMinor = (s: string): number | null => {
|
||||
const v = s.trim();
|
||||
if (v === "") return null;
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) && n > 0 ? Math.round(n * 100) : null;
|
||||
};
|
||||
const fromMinor = (m: number | null): string => (m == null ? "" : String(m / 100));
|
||||
const toInt = (s: string): number | null => {
|
||||
const v = s.trim();
|
||||
if (v === "") return null;
|
||||
const n = Number(v);
|
||||
return Number.isInteger(n) && n > 0 ? n : null;
|
||||
};
|
||||
|
||||
function StationForm({
|
||||
program,
|
||||
onSaved,
|
||||
}: {
|
||||
program: ValidationProgramView;
|
||||
onSaved: (p: ValidationProgramView) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [name, setName] = useState(program.name);
|
||||
const [mode, setMode] = useState<ValidationMode>(program.mode);
|
||||
const [minutes, setMinutes] = useState(program.minutes == null ? "" : String(program.minutes));
|
||||
const [percent, setPercent] = useState(program.percent == null ? "" : String(program.percent));
|
||||
const [maxAmount, setMaxAmount] = useState(fromMinor(program.maxAmountMinor));
|
||||
const [maxPerDay, setMaxPerDay] = useState(program.maxPerDay == null ? "" : String(program.maxPerDay));
|
||||
const [userIds, setUserIds] = useState<Set<string>>(new Set(program.userIds));
|
||||
const [users, setUsers] = useState<ManagedUser[] | null>(null);
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
|
||||
// Reset the form when the tab switches to another station.
|
||||
useEffect(() => {
|
||||
setName(program.name);
|
||||
setMode(program.mode);
|
||||
setMinutes(program.minutes == null ? "" : String(program.minutes));
|
||||
setPercent(program.percent == null ? "" : String(program.percent));
|
||||
setMaxAmount(fromMinor(program.maxAmountMinor));
|
||||
setMaxPerDay(program.maxPerDay == null ? "" : String(program.maxPerDay));
|
||||
setUserIds(new Set(program.userIds));
|
||||
setMsg(null);
|
||||
}, [program.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers()
|
||||
.then((r) => setUsers(r.users))
|
||||
.catch(() => setUsers([]));
|
||||
}, []);
|
||||
|
||||
const valid = useMemo(() => {
|
||||
if (!name.trim()) return false;
|
||||
if (mode === "timeCredit") return toInt(minutes) != null;
|
||||
if (mode === "percent") {
|
||||
const p = toInt(percent);
|
||||
return p != null && p <= 100;
|
||||
}
|
||||
if (mode === "fixed") return toMinor(maxAmount) != null;
|
||||
return true;
|
||||
}, [name, mode, minutes, percent, maxAmount]);
|
||||
|
||||
async function save() {
|
||||
setMsg(null);
|
||||
try {
|
||||
const saved = await saveValidationProgram(program.id, {
|
||||
name: name.trim(),
|
||||
mode,
|
||||
minutes: mode === "timeCredit" ? toInt(minutes) : null,
|
||||
percent: mode === "percent" ? toInt(percent) : null,
|
||||
maxAmountMinor: mode === "fixed" ? toMinor(maxAmount) : null,
|
||||
maxPerDay: toInt(maxPerDay),
|
||||
active: program.active,
|
||||
userIds: [...userIds],
|
||||
});
|
||||
onSaved(saved);
|
||||
setMsg(t("val.saved"));
|
||||
} catch (e) {
|
||||
setMsg((e as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
const toggleUser = (id: string) =>
|
||||
setUserIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.has(id) ? next.delete(id) : next.add(id);
|
||||
return next;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="mt-3 grid gap-3">
|
||||
<div className="field">
|
||||
<span className="label">{t("val.labelName")}</span>
|
||||
<input className="input" value={name} onChange={(e) => setName(e.target.value)} placeholder={t("val.labelNamePh")} />
|
||||
</div>
|
||||
<div className="field">
|
||||
<span className="label">{t("val.mode")}</span>
|
||||
<select className="input w-fit" value={mode} onChange={(e) => setMode(e.target.value as ValidationMode)}>
|
||||
<option value="comp">{t("val.modeComp")}</option>
|
||||
<option value="timeCredit">{t("val.modeTimeCredit")}</option>
|
||||
<option value="fixed">{t("val.modeFixed")}</option>
|
||||
<option value="percent">{t("val.modePercent")}</option>
|
||||
</select>
|
||||
</div>
|
||||
{mode === "timeCredit" && (
|
||||
<div className="field">
|
||||
<span className="label">{t("val.minutes")}</span>
|
||||
<input className="input w-32" value={minutes} onChange={(e) => setMinutes(e.target.value)} placeholder="60" />
|
||||
</div>
|
||||
)}
|
||||
{mode === "percent" && (
|
||||
<div className="field">
|
||||
<span className="label">{t("val.percent")}</span>
|
||||
<input className="input w-32" value={percent} onChange={(e) => setPercent(e.target.value)} placeholder="100" />
|
||||
</div>
|
||||
)}
|
||||
{mode === "fixed" && (
|
||||
<div className="field">
|
||||
<span className="label">{t("val.maxAmount")}</span>
|
||||
<input className="input w-32" value={maxAmount} onChange={(e) => setMaxAmount(e.target.value)} placeholder="1000" />
|
||||
</div>
|
||||
)}
|
||||
<div className="field">
|
||||
<span className="label">{t("val.maxPerDay")}</span>
|
||||
<input className="input w-32" value={maxPerDay} onChange={(e) => setMaxPerDay(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="label">{t("val.users")}</div>
|
||||
<span className="hint block">{t("val.usersHint")}</span>
|
||||
<div className="mt-1 grid gap-1">
|
||||
{users == null ? (
|
||||
<span className="text-term-muted">…</span>
|
||||
) : users.length === 0 ? (
|
||||
<span className="text-[0.75rem] text-term-muted">{t("val.noUsers")}</span>
|
||||
) : (
|
||||
users.map((u) => (
|
||||
<label key={u.id} className="flex items-center gap-2 text-[0.75rem] text-term-text">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="accent-term-amber"
|
||||
checked={userIds.has(u.id)}
|
||||
onChange={() => toggleUser(u.id)}
|
||||
/>
|
||||
{u.username}
|
||||
{u.fullName && <span className="text-term-muted">({u.fullName})</span>}
|
||||
</label>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button type="button" className="btn btn-primary btn-sm" disabled={!valid} onClick={save}>
|
||||
{t("site.save")}
|
||||
</button>
|
||||
{msg && <span className="text-[0.75rem] text-term-muted">{msg}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The right-column panel: tabs across the ENABLED stations, one form each. */
|
||||
export function ValidationStationsPanel({
|
||||
programs,
|
||||
onSaved,
|
||||
}: {
|
||||
programs: ValidationProgramView[];
|
||||
onSaved: (p: ValidationProgramView) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const enabled = STATIONS.map((id) => programs.find((p) => p.id === id)).filter(
|
||||
(p): p is ValidationProgramView => p != null && p.active,
|
||||
);
|
||||
const [tab, setTab] = useState<string | null>(null);
|
||||
const current = enabled.find((p) => p.id === tab) ?? enabled[0];
|
||||
if (!current) return null;
|
||||
|
||||
return (
|
||||
<section className="card w-full max-w-md p-4">
|
||||
<div className="text-[0.6875rem] uppercase tracking-wider text-term-muted">{t("val.sectionTitle")}</div>
|
||||
{enabled.length > 1 && (
|
||||
<div className="mt-2 flex gap-1">
|
||||
{enabled.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
className={`btn btn-sm ${p.id === current.id ? "btn-primary" : "btn-ghost"}`}
|
||||
onClick={() => setTab(p.id)}
|
||||
>
|
||||
{t(p.id === "bar" ? "val.enableBar" : "val.enableLavazh")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<StationForm program={current} onSaved={onSaved} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
+286
-28
@@ -7,7 +7,7 @@
|
||||
|
||||
import { logFailedRequest } from "./lib/logger.js";
|
||||
import { apiUrl } from "./lib/origin.js";
|
||||
import type { AppLogRecord } from "@parking/shared";
|
||||
import type { AppLogRecord, ValidationLine, ValidationMode } from "@parking/shared";
|
||||
|
||||
const CSRF_COOKIE = "parking_csrf";
|
||||
const CSRF_HEADER = "X-CSRF-Token";
|
||||
@@ -30,7 +30,7 @@ export async function apiFetch<T>(path: string, init: RequestInit = {}): Promise
|
||||
}
|
||||
const res = await fetch(apiUrl(path), { ...init, headers, credentials: "include" });
|
||||
if (!res.ok) {
|
||||
const msg = (await res.json().catch(() => ({}))) as { error?: string; problems?: string[] };
|
||||
const msg = (await res.json().catch(() => ({}))) as { error?: string; problems?: string[]; [k: string]: unknown };
|
||||
const error = msg.error ?? `${path}: ${res.status}`;
|
||||
// Ship the failed request to the backend log store (best-effort, loop-safe — the
|
||||
// logger itself never logs the /api/logs call). 401s are normal pre-login churn,
|
||||
@@ -38,7 +38,7 @@ export async function apiFetch<T>(path: string, init: RequestInit = {}): Promise
|
||||
if (res.status !== 401) {
|
||||
logFailedRequest({ path, method, status: res.status, error });
|
||||
}
|
||||
throw new ApiError(error, res.status, msg.problems);
|
||||
throw new ApiError(error, res.status, msg.problems, msg);
|
||||
}
|
||||
if (res.status === 204) return undefined as T;
|
||||
return res.json() as Promise<T>;
|
||||
@@ -50,6 +50,9 @@ export class ApiError extends Error {
|
||||
readonly status: number,
|
||||
/** Field-level problems from a validation error (e.g. tariff publish), if any. */
|
||||
readonly problems?: string[],
|
||||
/** The full parsed error body, for callers that need extra fields (e.g. a booth
|
||||
* exit's plate-swap detail: { status, plate, otherIdentity, otherEnteredAt }). */
|
||||
readonly body?: Record<string, unknown>,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
@@ -250,6 +253,15 @@ export async function fetchBackupStatus(): Promise<BackupStatus> {
|
||||
return apiFetch("/api/backup/status");
|
||||
}
|
||||
|
||||
export interface VersionInfo {
|
||||
/** "<branch>-<short-sha>" baked in at image build time; null on a local/dev build. */
|
||||
buildVersion: string | null;
|
||||
}
|
||||
|
||||
export async function fetchVersion(): Promise<VersionInfo> {
|
||||
return apiFetch("/api/version");
|
||||
}
|
||||
|
||||
export interface BackupConfigPatch {
|
||||
/** "" clears the target. Omit a field to leave it unchanged; null resets retention to default. */
|
||||
targetDir?: string | null;
|
||||
@@ -457,6 +469,21 @@ export function testPrint(
|
||||
});
|
||||
}
|
||||
|
||||
export type RelayTestResult =
|
||||
| { ok: true; firedAt: string; tookMs: number }
|
||||
| { ok: false; reason: string; detail?: string; tookMs?: number };
|
||||
|
||||
/** Pulse a SAVED controller's barrier relay to test the wiring — physically opens the
|
||||
* barrier. The server signs a `barrier_open_command` (reason setup.relayTest) before
|
||||
* firing, so the open is explained, not a reconciliation anomaly. Saved controller only
|
||||
* (needs a persisted id for attribution). */
|
||||
export function testRelay(id: string, relay: number): Promise<RelayTestResult> {
|
||||
return apiFetch<RelayTestResult>("/api/setup/test-relay", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ id, relay }),
|
||||
});
|
||||
}
|
||||
|
||||
// --- Admin reports -------------------------------------------------------
|
||||
export type ReportBucket = "hour" | "day" | "month";
|
||||
|
||||
@@ -465,7 +492,11 @@ export interface ReportSeriesPoint {
|
||||
entries: number;
|
||||
exits: number;
|
||||
revenueMinor: number;
|
||||
cashMinor: number;
|
||||
cardMinor: number;
|
||||
payments: number;
|
||||
/** Cars inside at the END of the bucket. */
|
||||
occupancyEnd: number;
|
||||
}
|
||||
|
||||
export interface ReportTotals {
|
||||
@@ -482,6 +513,8 @@ export interface ReportTotals {
|
||||
totalParkedMinutes: number;
|
||||
avgParkedMinutes: number;
|
||||
medianParkedMinutes: number;
|
||||
voids: number;
|
||||
anomalies: number;
|
||||
}
|
||||
|
||||
export interface ReportSubscriptionStats {
|
||||
@@ -501,6 +534,12 @@ export interface ReportSummary {
|
||||
totals: ReportTotals;
|
||||
series: ReportSeriesPoint[];
|
||||
entriesByHour: number[];
|
||||
/** 7×24, row 0 = Monday — entries heatmap (weekday-vs-weekend patterns). */
|
||||
entriesByDowHour: number[][];
|
||||
/** Stay-duration histogram; last bucket has uptoMin null (>24h tail). */
|
||||
stayHistogram: { uptoMin: number | null; count: number }[];
|
||||
occupancyStart: number;
|
||||
capacity: number | null;
|
||||
subscriptions: ReportSubscriptionStats;
|
||||
}
|
||||
|
||||
@@ -574,6 +613,11 @@ export interface AssignBody {
|
||||
backendIp?: string;
|
||||
}
|
||||
|
||||
/** USB printers currently visible on the appliance (/dev/usb/lpN + sysfs model). */
|
||||
export function fetchUsbPrinters(): Promise<{ printers: { path: string; description: string | null }[] }> {
|
||||
return apiFetch("/api/setup/usb-printers");
|
||||
}
|
||||
|
||||
/** Save + configure the device (preconditions, push setup), then persist. */
|
||||
export function assignDevice(body: AssignBody): Promise<AssignResult> {
|
||||
return apiFetch("/api/setup/assign", { method: "POST", body: JSON.stringify(body) });
|
||||
@@ -651,10 +695,14 @@ export interface TariffCard {
|
||||
priority: number;
|
||||
category?: string;
|
||||
window?: TariffWindow;
|
||||
/** Flat price PER INCREMENT (an hourly flat rate) — not a whole-stay price. */
|
||||
flatMinor?: number;
|
||||
blocks?: TariffBlock[];
|
||||
/** STEPPED ("up-to") table (defaultCard only); mutually exclusive with flat/blocks. */
|
||||
steps?: TariffStep[];
|
||||
/** WINDOW PACKAGE (windowed cards only): ONE total per contiguous window occurrence
|
||||
* ("any presence in the window = this price"). Mirrors @parking/shared. */
|
||||
packageMinor?: number;
|
||||
dailyCapMinor?: number | null;
|
||||
}
|
||||
/** One row of a STEPPED ("up-to") tariff: a TOTAL price for a stay up to and including
|
||||
@@ -684,6 +732,8 @@ export function isTariffV2(t: TariffStructure): t is TariffStructureV2 {
|
||||
export interface TariffVersion {
|
||||
id: string;
|
||||
tariffId: string;
|
||||
/** Optional human label, stamped at publish (e.g. carried from a lab draft). */
|
||||
name?: string | null;
|
||||
effectiveFrom: string;
|
||||
currency: string;
|
||||
structure: TariffStructure;
|
||||
@@ -705,6 +755,7 @@ export function publishTariffVersion(body: {
|
||||
currency: string;
|
||||
structure: TariffStructure;
|
||||
effectiveFrom?: string;
|
||||
name?: string;
|
||||
}): Promise<TariffVersion> {
|
||||
return apiFetch("/api/tariff/versions", { method: "POST", body: JSON.stringify(body) });
|
||||
}
|
||||
@@ -725,6 +776,9 @@ export interface SimSessionPricing {
|
||||
export interface SimulateResult {
|
||||
currency: string | null;
|
||||
pricing: SimSessionPricing;
|
||||
/** Line items explaining pricing.amountMinor (same engine walk, Σ ≡ amount);
|
||||
* null when the session is settled (within walk-back grace). */
|
||||
breakdown: import("@parking/shared").FeeBreakdown | null;
|
||||
curve: { minutes: number; amountMinor: number }[];
|
||||
gracePeriodExitMin: number;
|
||||
}
|
||||
@@ -743,18 +797,40 @@ export function simulateTariff(body: SimulateBody): Promise<SimulateResult> {
|
||||
return apiFetch("/api/tariff/simulate", { method: "POST", body: JSON.stringify(body) });
|
||||
}
|
||||
|
||||
export interface SimSessionLoad {
|
||||
identity: string;
|
||||
enteredAt: string;
|
||||
exitedAt: string | null;
|
||||
payments: SimPayment[];
|
||||
category: string | null;
|
||||
tariffVersionId: string | null;
|
||||
// --- Tariff Lab drafts ------------------------------------------------------
|
||||
// Mutable experimental rate cards — the lab composes + simulates these, and
|
||||
// publishing one goes through the normal immutable-version path above.
|
||||
|
||||
export interface TariffDraft {
|
||||
id: string;
|
||||
name: string;
|
||||
currency: string;
|
||||
structure: TariffStructure;
|
||||
createdBy: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** Prefill the lab from a real ledger session. */
|
||||
export function loadSimSession(identity: string): Promise<SimSessionLoad> {
|
||||
return apiFetch(`/api/tariff/simulate/session/${encodeURIComponent(identity)}`);
|
||||
export function fetchTariffDrafts(): Promise<{ drafts: TariffDraft[] }> {
|
||||
return apiFetch("/api/tariff/drafts");
|
||||
}
|
||||
|
||||
export interface TariffDraftBody {
|
||||
name: string;
|
||||
currency: string;
|
||||
structure: TariffStructure;
|
||||
}
|
||||
|
||||
export function createTariffDraft(body: TariffDraftBody): Promise<TariffDraft> {
|
||||
return apiFetch("/api/tariff/drafts", { method: "POST", body: JSON.stringify(body) });
|
||||
}
|
||||
|
||||
export function updateTariffDraft(id: string, body: TariffDraftBody): Promise<TariffDraft> {
|
||||
return apiFetch(`/api/tariff/drafts/${encodeURIComponent(id)}`, { method: "PUT", body: JSON.stringify(body) });
|
||||
}
|
||||
|
||||
export function deleteTariffDraft(id: string): Promise<void> {
|
||||
return apiFetch(`/api/tariff/drafts/${encodeURIComponent(id)}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
// --- Subscriptions --------------------------------------------------------
|
||||
@@ -1019,15 +1095,38 @@ export async function fetchShiftReport(): Promise<XReport | null> {
|
||||
return (await apiFetch<XReport | undefined>("/api/shift/report")) ?? null;
|
||||
}
|
||||
|
||||
/** A drawer cash voucher: Mandat Arkëtimi (cash_in / pay-IN) or Mandat Pagese
|
||||
* (cash_out / pay-OUT). Direction is the TYPE, amountMinor a positive magnitude.
|
||||
* Operator-raised, admin-authorized (authorizedBy + their password). */
|
||||
export function recordCashVoucher(args: {
|
||||
// --- Drawer cash movements (operator records, admin reviews) ---------------------
|
||||
// Redesigned 2026-07-01: an operator RECORDS a receipt/disbursement freely; an admin
|
||||
// REVIEWS it after the fact (authorize/deny — a flag, never a cash reversal). See
|
||||
// wiki/concepts/shift.md.
|
||||
|
||||
export type MovementStatus = "pending" | "authorized" | "denied";
|
||||
|
||||
/** A drawer movement with its admin-review status. */
|
||||
export interface DrawerMovement {
|
||||
id: string;
|
||||
type: "cash_in" | "cash_out";
|
||||
/** Positive magnitude; direction is the type. */
|
||||
amountMinor: number;
|
||||
currency: string | null;
|
||||
reason: string | null;
|
||||
operator: string;
|
||||
voucherNo: string | null;
|
||||
at: string;
|
||||
status: MovementStatus;
|
||||
reviewedBy: string | null;
|
||||
reviewNote: string | null;
|
||||
reviewedAt: string | null;
|
||||
}
|
||||
|
||||
/** Operator RECORDS a drawer movement — cash_in (Mandat Arkëtimi / pay-IN) or cash_out
|
||||
* (Mandat Pagese / pay-OUT). Direction is the TYPE; amountMinor a positive magnitude.
|
||||
* No admin sign-off at creation — it's reviewed afterward. */
|
||||
export function recordDrawerMovement(args: {
|
||||
type: "cash_in" | "cash_out";
|
||||
amountMinor: number;
|
||||
reason: string;
|
||||
authorizedBy: string;
|
||||
authorizerPassword: string;
|
||||
currency?: string;
|
||||
}): Promise<{
|
||||
type: "cash_in" | "cash_out";
|
||||
amountMinor: number;
|
||||
@@ -1035,10 +1134,32 @@ export function recordCashVoucher(args: {
|
||||
balanceMinor: number;
|
||||
printed: boolean;
|
||||
}> {
|
||||
return apiFetch("/api/cash-voucher", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(args),
|
||||
});
|
||||
return apiFetch("/api/drawer/movement", { method: "POST", body: JSON.stringify(args) });
|
||||
}
|
||||
|
||||
/** List drawer movements + review status. Operators get their OWN; a reviewer gets all
|
||||
* and may filter by status (the pending review queue). */
|
||||
export function fetchDrawerMovements(status?: MovementStatus): Promise<{
|
||||
movements: DrawerMovement[];
|
||||
scope: "all" | "self";
|
||||
}> {
|
||||
const qs = status ? `?status=${encodeURIComponent(status)}` : "";
|
||||
return apiFetch(`/api/drawer/movements${qs}`);
|
||||
}
|
||||
|
||||
/** The physical drawer balance NOW (cash payments + vouchers over the whole chain —
|
||||
* the amount that carries across shifts). */
|
||||
export function fetchDrawerBalance(): Promise<{ balanceMinor: number; currency: string | null }> {
|
||||
return apiFetch("/api/drawer/balance");
|
||||
}
|
||||
|
||||
/** Admin AUTHORIZES or DENIES a recorded movement (a flag — never a cash reversal). */
|
||||
export function reviewDrawerMovement(args: {
|
||||
refId: string;
|
||||
decision: "authorize" | "deny";
|
||||
note?: string;
|
||||
}): Promise<{ refId: string; decision: "authorize" | "deny"; reviewedBy: string; at: string }> {
|
||||
return apiFetch("/api/drawer/review", { method: "POST", body: JSON.stringify(args) });
|
||||
}
|
||||
|
||||
/** A completed shift (reconstructed from its signed Z-report). */
|
||||
@@ -1065,6 +1186,8 @@ export interface ShiftSummary extends ShiftSourceSplit {
|
||||
export function fetchShifts(params: { operator?: string; from?: string; to?: string } = {}): Promise<{
|
||||
shifts: ShiftSummary[];
|
||||
scope: "all" | "self";
|
||||
/** Admin scope only: every operator that has a shift — feeds the filter dropdown. */
|
||||
operators?: string[];
|
||||
}> {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.operator) qs.set("operator", params.operator);
|
||||
@@ -1094,6 +1217,11 @@ export interface SiteConfig {
|
||||
reserveSubscriberSpots: boolean;
|
||||
/** Master switch for the ANPR subscriber-entry bridge (auto-open on a plate read). */
|
||||
anprEntryEnabled: boolean;
|
||||
/** Entry presence-gate bypass: drop radar/loop as an entry-button requirement (faulty
|
||||
* device). Set only via the dedicated signed endpoint, not saveSiteConfig. */
|
||||
bypassPresenceRadar: boolean;
|
||||
/** Entry presence-gate bypass: drop camera detection as an entry-button requirement. */
|
||||
bypassPresenceCamera: boolean;
|
||||
parkName: string | null;
|
||||
operatorName: string | null;
|
||||
/** NIUS — Albanian tax/identification number. */
|
||||
@@ -1168,6 +1296,9 @@ export interface SessionLookup {
|
||||
paidAt: string | null;
|
||||
amountMinor: number | null;
|
||||
currency: string | null;
|
||||
/** Amount actually PAID (sum of payment events), independent of what's owed now. */
|
||||
paidMinor: number | null;
|
||||
paidCurrency: string | null;
|
||||
withinGrace: boolean;
|
||||
graceExpiresAt: string | null;
|
||||
/** OVERSTAY: paid transient, walk-back grace expired, no exit — a new period began;
|
||||
@@ -1179,6 +1310,11 @@ export interface SessionLookup {
|
||||
subscriptionHolder: string | null;
|
||||
/** Advisory licence plate recognized for this session (ANPR). Null when none. */
|
||||
plate: string | null;
|
||||
/** Merchant validations folded into `amountMinor` (which is NET): pre-discount fee,
|
||||
* total taken off, and the per-validation lines. See validation-discounts.md. */
|
||||
grossMinor: number | null;
|
||||
discountMinor: number | null;
|
||||
validationLines: ValidationLine[];
|
||||
}
|
||||
|
||||
/** Look up a ticket/session for the booth modal (entry/exit, paid, amount owed). */
|
||||
@@ -1242,12 +1378,43 @@ export function voidTicket(identity: string, reason: string): Promise<{ ok: bool
|
||||
}
|
||||
|
||||
/** Booth-driven exit result. `opened:false` = exit recorded but barrier didn't
|
||||
* open (payment stands; operator opens manually). */
|
||||
export type BoothExitResult = { ok: true; opened: boolean; reason?: string };
|
||||
* open (payment stands; operator opens manually). `swapSuspected` = the exiting car's
|
||||
* plate is already inside under a DIFFERENT ticket (possible ticket-swap); the operator
|
||||
* must review and re-call with override:true to release. See plate-reconciliation.md. */
|
||||
export type BoothExitResult =
|
||||
| { ok: true; opened: boolean; reason?: string }
|
||||
| { ok: false; swapSuspected: true; reason: string; plate: string; otherIdentity: string; otherEnteredAt: string | null };
|
||||
|
||||
/** Validate + open the barrier for a session from the booth (when near the exit). */
|
||||
export function boothExit(identity: string): Promise<BoothExitResult> {
|
||||
return apiFetch("/api/exit", { method: "POST", body: JSON.stringify({ identity }) });
|
||||
/** Validate + open the barrier for a session from the booth (when near the exit).
|
||||
* Pass override:true to consciously release a suspected plate-swap exit. */
|
||||
export async function boothExit(identity: string, override = false): Promise<BoothExitResult> {
|
||||
try {
|
||||
return await apiFetch<{ ok: true; opened: boolean; reason?: string }>("/api/exit", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ identity, ...(override ? { override: true } : {}) }),
|
||||
});
|
||||
} catch (e) {
|
||||
// A suspected plate-swap comes back 409 with status:"swap_suspected" + detail — surface
|
||||
// it as a structured result (not a thrown error) so the modal can warn + offer override.
|
||||
if (e instanceof ApiError && e.body?.status === "swap_suspected") {
|
||||
const b = e.body;
|
||||
return {
|
||||
ok: false,
|
||||
swapSuspected: true,
|
||||
reason: String(b.error ?? ""),
|
||||
plate: String(b.plate ?? ""),
|
||||
otherIdentity: String(b.otherIdentity ?? ""),
|
||||
otherEnteredAt: (b.otherEnteredAt as string | null) ?? null,
|
||||
};
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/** Operator issues an entry ticket when the physical button is broken. A FLAGGED mint,
|
||||
* server-gated on real vehicle presence (radar + camera). Returns the new ticket id. */
|
||||
export function issueEntryTicket(): Promise<{ ok: true; ticketId: string; opened: boolean; overCapacity: boolean }> {
|
||||
return apiFetch("/api/entry/issue", { method: "POST", body: JSON.stringify({}) });
|
||||
}
|
||||
|
||||
/** Print an exit voucher (paid ticket id reprinted as a barcode) + payment detail,
|
||||
@@ -1313,6 +1480,97 @@ export function fetchSiteConfig(): Promise<SiteConfig> {
|
||||
export function saveSiteConfig(patch: Partial<SiteConfig>): Promise<SiteConfig> {
|
||||
return apiFetch("/api/site-config", { method: "PUT", body: JSON.stringify(patch) });
|
||||
}
|
||||
|
||||
/** Toggle the entry presence-gate bypass (radar/camera). Dedicated signed endpoint —
|
||||
* each changed signal appends a config_change to the ledger. See entry-presence-bypass. */
|
||||
export function updatePresenceBypass(patch: { radar?: boolean; camera?: boolean }): Promise<SiteConfig> {
|
||||
return apiFetch("/api/site-config/presence-bypass", { method: "PUT", body: JSON.stringify(patch) });
|
||||
}
|
||||
export function setCapacity(capacity: number | null): Promise<SiteConfig> {
|
||||
return saveSiteConfig({ capacity });
|
||||
}
|
||||
|
||||
// --- Merchant validations (bar / lavazh) -----------------------------------
|
||||
// The merchant is VALIDATION-ONLY: they scan the ticket on their device and apply
|
||||
// their program; the booth settles NET of the applied validations and prints the
|
||||
// detailed receipt. Program config lives on /setup/site. See validation-discounts.md.
|
||||
|
||||
export type { ValidationLine, ValidationMode } from "@parking/shared";
|
||||
|
||||
/** An admin-composed program (mirrors the server row + its bound users). */
|
||||
export interface ValidationProgramView {
|
||||
id: string;
|
||||
name: string;
|
||||
mode: ValidationMode;
|
||||
minutes: number | null;
|
||||
percent: number | null;
|
||||
maxAmountMinor: number | null;
|
||||
maxPerDay: number | null;
|
||||
active: boolean;
|
||||
userIds: string[];
|
||||
}
|
||||
|
||||
/** A validation applied to a session, with its lifecycle state. */
|
||||
export interface AppliedValidationView {
|
||||
eventId: string;
|
||||
occurredAt: string;
|
||||
programId: string;
|
||||
label: string;
|
||||
mode: ValidationMode;
|
||||
minutes?: number;
|
||||
amountMinor?: number;
|
||||
percent?: number;
|
||||
operator: string | null;
|
||||
voided: boolean;
|
||||
consumedBy: string | null;
|
||||
}
|
||||
|
||||
/** The merchant screen's minimal session view — deliberately no money data. */
|
||||
export interface ValidationSessionView {
|
||||
identity: string;
|
||||
found: boolean;
|
||||
open: boolean;
|
||||
enteredAt: string | null;
|
||||
subscription: boolean;
|
||||
validations: AppliedValidationView[];
|
||||
}
|
||||
|
||||
/** All programs + bound users (the /setup/site panel). site:read. */
|
||||
export function fetchValidationPrograms(): Promise<{ programs: ValidationProgramView[] }> {
|
||||
return apiFetch("/api/validation/programs");
|
||||
}
|
||||
|
||||
/** Upsert a program's config + binding set (site:update; signs a config_change). */
|
||||
export function saveValidationProgram(
|
||||
id: string,
|
||||
body: Omit<ValidationProgramView, "id">,
|
||||
): Promise<ValidationProgramView> {
|
||||
return apiFetch(`/api/validation/programs/${encodeURIComponent(id)}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
/** MY bound, active programs (the merchant screen). validation:create. */
|
||||
export function fetchMyValidationPrograms(): Promise<{ programs: Omit<ValidationProgramView, "userIds">[] }> {
|
||||
return apiFetch("/api/validation/mine");
|
||||
}
|
||||
|
||||
/** Merchant lookup of a scanned ticket (no money data). validation:create. */
|
||||
export function fetchValidationSession(identity: string): Promise<ValidationSessionView> {
|
||||
return apiFetch(`/api/validation/session/${encodeURIComponent(identity)}`);
|
||||
}
|
||||
|
||||
/** Apply my program to a ticket (signed, attributed). `amountMinor` only for fixed mode. */
|
||||
export function applyValidation(body: {
|
||||
identity: string;
|
||||
programId: string;
|
||||
amountMinor?: number;
|
||||
}): Promise<{ ok: true; eventId: string; label: string }> {
|
||||
return apiFetch("/api/validation/apply", { method: "POST", body: JSON.stringify(body) });
|
||||
}
|
||||
|
||||
/** Void my own UNUSED validation (append-only correction). */
|
||||
export function voidValidation(body: { eventId: string; identity: string }): Promise<{ ok: true }> {
|
||||
return apiFetch("/api/validation/void", { method: "POST", body: JSON.stringify(body) });
|
||||
}
|
||||
|
||||
+45
-10
@@ -13,9 +13,42 @@
|
||||
exposed as utilities (night-*, ink-*, paper-*, flag/amber/green/blue, the
|
||||
spacing/type/shadow scales) for new work.
|
||||
|
||||
Offline appliance: NO webfont @import (no network at runtime). Goldplay (the
|
||||
TRM display face) is not self-hosted yet — display/heading text falls back to
|
||||
a clean sans stack; wire local Goldplay @font-face here if it's wanted. */
|
||||
Offline appliance: NO webfont @import (no network at runtime). The primary
|
||||
face is Chakra Petch, SELF-HOSTED from public/fonts/chakra-petch (SIL OFL,
|
||||
license alongside the files) — latin subset only (covers en + sq ë/ç), the
|
||||
weights the UI actually uses (400/600/700 + 400 italic). Not a true
|
||||
monospace: it stays FIRST in --font-mono for the look, with the real mono
|
||||
stack behind it as fallback; .num/.tabular still request tabular figures. */
|
||||
|
||||
@font-face {
|
||||
font-family: "Chakra Petch";
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url("/fonts/chakra-petch/chakra-petch-latin-400.woff2") format("woff2");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Chakra Petch";
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url("/fonts/chakra-petch/chakra-petch-latin-600.woff2") format("woff2");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Chakra Petch";
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url("/fonts/chakra-petch/chakra-petch-latin-700.woff2") format("woff2");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Chakra Petch";
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url("/fonts/chakra-petch/chakra-petch-latin-400-italic.woff2") format("woff2");
|
||||
}
|
||||
|
||||
@theme {
|
||||
/* ============================================================
|
||||
TERMINAL ACCENTS — aligned onto TRM's exact values.
|
||||
@@ -92,13 +125,15 @@
|
||||
--color-viz-8: #5a5a53;
|
||||
|
||||
/* ---------- TYPE — families ---------- */
|
||||
/* Mono is the booth's primary face (data-dense, tabular). Display/UI fall
|
||||
back to a clean sans (Goldplay not self-hosted — see header note). */
|
||||
--font-mono: "JetBrains Mono", "IBM Plex Mono", ui-monospace, "SFMono-Regular",
|
||||
"Menlo", "Consolas", monospace;
|
||||
--font-display: "Goldplay", "Helvetica Neue", Arial, sans-serif;
|
||||
--font-ui: "Goldplay", "Helvetica Neue", Arial, sans-serif;
|
||||
--font-body: "Inter", "Helvetica Neue", Arial, sans-serif;
|
||||
/* Chakra Petch (self-hosted, see @font-face above) is the booth's primary
|
||||
face everywhere — it leads every stack so headings, body, and the
|
||||
`font-mono` chrome all render with it; the stacks behind it are the
|
||||
pre-2026-07-05 fallbacks for glyphs outside the latin subset. */
|
||||
--font-mono: "Chakra Petch", "JetBrains Mono", "IBM Plex Mono", ui-monospace,
|
||||
"SFMono-Regular", "Menlo", "Consolas", monospace;
|
||||
--font-display: "Chakra Petch", "Helvetica Neue", Arial, sans-serif;
|
||||
--font-ui: "Chakra Petch", "Helvetica Neue", Arial, sans-serif;
|
||||
--font-body: "Chakra Petch", "Inter", "Helvetica Neue", Arial, sans-serif;
|
||||
|
||||
/* ---------- TYPE — scale (TRM, optimised for data density) ---------- */
|
||||
--text-overline: 11px;
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
// The currencies the booth can price in (ISO 4217). Money is always stored as
|
||||
// integer minor units + one of these codes; the UI offers a closed select rather
|
||||
// than free text so a typo can never publish an unknown currency.
|
||||
export const CURRENCIES = ["ALL", "EUR", "USD"] as const;
|
||||
|
||||
/** The select options: the known set, plus the current value when it's some
|
||||
* historical code outside it (so an old record still displays + round-trips). */
|
||||
export function currencyOptions(current: string): string[] {
|
||||
const cur = current.trim().toUpperCase();
|
||||
return cur && !CURRENCIES.includes(cur as (typeof CURRENCIES)[number]) ? [...CURRENCIES, cur] : [...CURRENCIES];
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// Client-side feature flags. Small, hand-flipped switches for capabilities the app
|
||||
// SUPPORTS in code but that aren't provisioned on-site yet — so the UI doesn't offer an
|
||||
// action the site can't fulfil.
|
||||
|
||||
/**
|
||||
* CARD payments. The app models a `card` tender end-to-end (server, shift accounting,
|
||||
* reports), but a card sale needs a bank-certified **P2PE POS terminal** on-site, and we
|
||||
* have NONE yet (2026-07-01). Until one is procured + configured, the booth/subscription
|
||||
* tender pickers show CASH only — offering "Card" would let an operator record a card
|
||||
* payment that never actually cleared a terminal, corrupting the till reconciliation.
|
||||
*
|
||||
* Flip to `true` (and add the POS device config) once a terminal is on-site. Nothing about
|
||||
* the `Tender` type or historical `card` events changes — this only gates the UI *offer*.
|
||||
* See wiki/concepts/card-payments.md (future POS device requirements).
|
||||
*/
|
||||
export const CARD_PAYMENTS_ENABLED = false;
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatMoney, formatDuration, formatTime, formatRelativeDateTime, type TFn } from "./format.js";
|
||||
import { formatMoney, formatDuration, formatRelativeDateTime, type TFn } from "./format.js";
|
||||
|
||||
// The booth's display formatters. Money is integer MINOR units (never a float, matching
|
||||
// the ledger/tariff model); duration is whole minutes; relative dates drive the session/
|
||||
@@ -35,16 +35,6 @@ describe("formatDuration", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatTime", () => {
|
||||
it("returns an em dash for null/invalid", () => {
|
||||
expect(formatTime(null)).toBe("—");
|
||||
expect(formatTime("not-a-date")).toBe("—");
|
||||
});
|
||||
it("renders HH:MM:SS local time", () => {
|
||||
expect(formatTime("2026-06-21T10:48:25.000Z")).toMatch(/^\d{2}:\d{2}:\d{2}$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatRelativeDateTime", () => {
|
||||
// A tiny fake t(): today/yesterday words + the month-name array.
|
||||
const months = ["Jan","Shkurt","Mars","Prill","Maj","Qershor","Korrik","Gusht","Sht","Tet","Nën","Dhj"];
|
||||
@@ -61,6 +51,12 @@ describe("formatRelativeDateTime", () => {
|
||||
expect(formatRelativeDateTime(now.toISOString(), t)).toMatch(/^Sot \d{2}:\d{2}$/);
|
||||
});
|
||||
|
||||
it("appends :ss with the seconds option (entry/exit rows read alike)", () => {
|
||||
const now = new Date();
|
||||
now.setHours(19, 25, 44, 0);
|
||||
expect(formatRelativeDateTime(now.toISOString(), t, { seconds: true })).toMatch(/^Sot \d{2}:\d{2}:44$/);
|
||||
});
|
||||
|
||||
it("labels yesterday with the localized word", () => {
|
||||
const y = new Date();
|
||||
y.setDate(y.getDate() - 1);
|
||||
|
||||
+65
-18
@@ -22,6 +22,22 @@ export function formatDuration(fromIso: string, toIso: string): string {
|
||||
return h > 0 ? `${h}h ${m}m` : `${m}m`;
|
||||
}
|
||||
|
||||
/** Remaining time until `untilIso`, as a live countdown: "M:SS" (or "H:MM:SS" past an
|
||||
* hour). Returns null once expired (or for a bad/empty input) so callers can drop the
|
||||
* badge. Pass `nowMs` (a ticking clock) to make it update each second. */
|
||||
export function formatCountdown(untilIso: string | null, nowMs: number = Date.now()): string | null {
|
||||
if (!untilIso) return null;
|
||||
const ms = Date.parse(untilIso) - nowMs;
|
||||
if (!Number.isFinite(ms) || ms <= 0) return null;
|
||||
const total = Math.ceil(ms / 1000);
|
||||
const h = Math.floor(total / 3600);
|
||||
const m = Math.floor((total % 3600) / 60);
|
||||
const s = total % 60;
|
||||
const ss = String(s).padStart(2, "0");
|
||||
if (h > 0) return `${h}:${String(m).padStart(2, "0")}:${ss}`;
|
||||
return `${m}:${ss}`;
|
||||
}
|
||||
|
||||
/** Human duration from whole minutes, e.g. 134 → "2h 14m", 47 → "47m", 0 → "0m". */
|
||||
export function formatMinutes(mins: number): string {
|
||||
if (!Number.isFinite(mins) || mins < 0) return "—";
|
||||
@@ -30,13 +46,6 @@ export function formatMinutes(mins: number): string {
|
||||
return h > 0 ? `${h}h ${m % 60}m` : `${m}m`;
|
||||
}
|
||||
|
||||
/** Local time-of-day HH:MM:SS from an ISO string. */
|
||||
export function formatTime(iso: string | null): string {
|
||||
if (!iso) return "—";
|
||||
const d = new Date(iso);
|
||||
return Number.isNaN(d.getTime()) ? "—" : d.toTimeString().slice(0, 8);
|
||||
}
|
||||
|
||||
/** Calendar-day difference (local) between two dates: 0 = same day, 1 = d is one day
|
||||
* before ref, etc. Compares date parts only (ignores time-of-day). */
|
||||
function dayDiff(d: Date, ref: Date): number {
|
||||
@@ -45,10 +54,11 @@ function dayDiff(d: Date, ref: Date): number {
|
||||
return Math.round((b.getTime() - a.getTime()) / 86_400_000);
|
||||
}
|
||||
|
||||
/** HH:MM (local, 24h) for the relative-day labels. */
|
||||
function hhmm(d: Date): string {
|
||||
/** HH:MM (local, 24h) for the relative-day labels; ":ss" appended when `seconds`. */
|
||||
function hhmm(d: Date, seconds = false): string {
|
||||
const p = (n: number) => String(n).padStart(2, "0");
|
||||
return `${p(d.getHours())}:${p(d.getMinutes())}`;
|
||||
const base = `${p(d.getHours())}:${p(d.getMinutes())}`;
|
||||
return seconds ? `${base}:${p(d.getSeconds())}` : base;
|
||||
}
|
||||
|
||||
/** Minimal shape of i18next's `t` that we rely on: a string lookup, plus the
|
||||
@@ -69,6 +79,42 @@ function monthName(d: Date, t: TFn): string {
|
||||
return String(d.getMonth() + 1);
|
||||
}
|
||||
|
||||
/** Short month ("Qer", "Korr") from the catalog — the UI-wide date standard
|
||||
* (2026-07-06): every visible date reads "25 Qer" / "7 Korr 2025", never the
|
||||
* browser-locale "7/6/2026". Falls back to the full name, then the number. */
|
||||
function monthShort(d: Date, t: TFn): string {
|
||||
const months = t("common.monthsShort", { returnObjects: true });
|
||||
if (Array.isArray(months) && typeof months[d.getMonth()] === "string") {
|
||||
return months[d.getMonth()] as string;
|
||||
}
|
||||
return monthName(d, t);
|
||||
}
|
||||
|
||||
/** "HH:mm" (local, 24h) — the unified time-of-day everywhere ("—" for bad input). */
|
||||
export function formatClock(iso: string | null): string {
|
||||
if (!iso) return "—";
|
||||
const d = new Date(iso);
|
||||
return Number.isNaN(d.getTime()) ? "—" : hhmm(d);
|
||||
}
|
||||
|
||||
/** "25 Qer" (current year) / "25 Qer 2025" (other years) — the unified DATE. */
|
||||
export function formatDate(iso: string | null, t: TFn): string {
|
||||
if (!iso) return "—";
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return "—";
|
||||
const base = `${d.getDate()} ${monthShort(d, t)}`;
|
||||
return d.getFullYear() === new Date().getFullYear() ? base : `${base} ${d.getFullYear()}`;
|
||||
}
|
||||
|
||||
/** "25 Qer 14:30" (+ ":ss" when `seconds`) — the unified absolute DATE+TIME. Use
|
||||
* formatRelativeDateTime instead where "Sot/Dje" reads better (feeds, history). */
|
||||
export function formatDateTime(iso: string | null, t: TFn, opts?: { seconds?: boolean }): string {
|
||||
if (!iso) return "—";
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return "—";
|
||||
return `${formatDate(iso, t)} ${hhmm(d, opts?.seconds)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Human, day-relative date+time for sessions/logs/history. An event from earlier
|
||||
* today reads "Sot 10:48", yesterday "Dje 17:33", and anything older a localized
|
||||
@@ -77,17 +123,18 @@ function monthName(d: Date, t: TFn): string {
|
||||
*
|
||||
* `t` supplies the today/yesterday words AND the month names (the appliance browser
|
||||
* may lack Albanian Intl data, so month names come from the catalog, not Intl).
|
||||
*
|
||||
* `seconds` appends ":ss" — use it where a timestamp sits next to another that shows
|
||||
* seconds (e.g. the booth pay modal's entry vs. exit rows), so the two read alike.
|
||||
*/
|
||||
export function formatRelativeDateTime(iso: string | null, t: TFn): string {
|
||||
export function formatRelativeDateTime(iso: string | null, t: TFn, opts?: { seconds?: boolean }): string {
|
||||
if (!iso) return "—";
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return "—";
|
||||
const time = hhmm(d, opts?.seconds);
|
||||
const diff = dayDiff(d, new Date());
|
||||
if (diff === 0) return `${t("common.today")} ${hhmm(d)}`;
|
||||
if (diff === 1) return `${t("common.yesterday")} ${hhmm(d)}`;
|
||||
// Older (or future): "17 Qershor 10:48", with the year only if it differs.
|
||||
const sameYear = d.getFullYear() === new Date().getFullYear();
|
||||
const month = monthName(d, t);
|
||||
const date = sameYear ? `${d.getDate()} ${month}` : `${d.getDate()} ${month} ${d.getFullYear()}`;
|
||||
return `${date} ${hhmm(d)}`;
|
||||
if (diff === 0) return `${t("common.today")} ${time}`;
|
||||
if (diff === 1) return `${t("common.yesterday")} ${time}`;
|
||||
// Older (or future): "17 Qer 10:48" — the short-month standard, year only if it differs.
|
||||
return `${formatDate(iso, t)} ${time}`;
|
||||
}
|
||||
|
||||
+196
-23
@@ -33,6 +33,7 @@ export const en: Catalog = {
|
||||
"November",
|
||||
"December",
|
||||
],
|
||||
monthsShort: ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"],
|
||||
},
|
||||
auth: {
|
||||
title: "Parking System",
|
||||
@@ -58,11 +59,52 @@ export const en: Catalog = {
|
||||
users: "Users",
|
||||
roles: "Roles",
|
||||
shifts: "Shifts",
|
||||
drawer: "Drawer",
|
||||
reports: "Reports",
|
||||
recycleBin: "Recycle bin",
|
||||
logs: "Logs",
|
||||
backup: "Backup",
|
||||
profile: "Profile",
|
||||
validate: "Validations",
|
||||
},
|
||||
drawer: {
|
||||
stateTitle: "Drawer now",
|
||||
openShift: "Open shift: {{operator}}",
|
||||
noShiftOpen: "No shift open — the drawer carries the last shift's closing balance.",
|
||||
thisShift: "This shift:",
|
||||
todayTitle: "Today's cash activity",
|
||||
todayPayments: "{{count}} payments",
|
||||
payment: "Payment",
|
||||
noActivity: "No cash activity today.",
|
||||
historyTitle: "Closed shifts",
|
||||
noShifts: "No closed shifts yet.",
|
||||
recordTitle: "Record a cash movement",
|
||||
amount: "amount",
|
||||
reasonPlaceholder: "reason (e.g. supplier payment, bank drop)",
|
||||
recordHint: "Recorded to the drawer immediately. An admin reviews it afterward.",
|
||||
mandatArketimi: "Receipt (in) +",
|
||||
mandatPagese: "Disbursement (out) −",
|
||||
enterPositive: "Enter a positive amount.",
|
||||
recorded: "{{no}} recorded. Drawer now {{amount}}.",
|
||||
myTitle: "My cash movements",
|
||||
allTitle: "Cash movements",
|
||||
pendingCount: "{{count}} pending",
|
||||
filterAll: "All",
|
||||
empty: "No cash movements yet.",
|
||||
colWhen: "When",
|
||||
colType: "Type",
|
||||
colAmount: "Amount",
|
||||
colReason: "Reason",
|
||||
colOperator: "Operator",
|
||||
colStatus: "Status",
|
||||
status: {
|
||||
pending: "pending",
|
||||
authorized: "authorized",
|
||||
denied: "denied",
|
||||
},
|
||||
authorize: "Authorize",
|
||||
deny: "Deny",
|
||||
denyNotePlaceholder: "reason for denial (optional)",
|
||||
},
|
||||
profile: {
|
||||
title: "My profile",
|
||||
@@ -160,6 +202,14 @@ export const en: Catalog = {
|
||||
fEvtVoid: "Void",
|
||||
fEvtAnomaly: "Anomaly",
|
||||
openPayExit: "Open pay / exit",
|
||||
openReopenBarrier: "Open — paid, awaiting barrier",
|
||||
issueEntry: "Issue ticket",
|
||||
issueEntryTitle: "Issue an entry ticket & open the barrier (physical button broken)",
|
||||
issueEntryConfirm: "A vehicle is at the entry. Issue an entry ticket and open the barrier?",
|
||||
issueEntryOk: "Entry ticket {{ticket}} issued.",
|
||||
exitedGrace: "exited · grace",
|
||||
exitedGraceLeft: "exited · {{time}}",
|
||||
exitedGraceTitle: "Paid and exited — barrier not confirmed; waiting out the grace period.",
|
||||
openBarrier: "Open barrier",
|
||||
openBarrierTitle: "Human-intervention barrier open (audited)",
|
||||
barrierOpened: "barrier opened",
|
||||
@@ -179,6 +229,10 @@ export const en: Catalog = {
|
||||
evtCashMovement: "CASH",
|
||||
evtCashIn: "PAY-IN",
|
||||
evtCashOut: "PAY-OUT",
|
||||
evtCashReview: "REVIEW",
|
||||
evtConfigChange: "CONFIG",
|
||||
evtValidation: "VALIDATION",
|
||||
decision: { authorize: "authorized", deny: "denied" },
|
||||
evtAnomaly: "ANOMALY",
|
||||
evtRefused: "REFUSED",
|
||||
// live-feed event detail line + classification badges (computed from payload)
|
||||
@@ -220,6 +274,10 @@ export const en: Catalog = {
|
||||
edPlate: "Plate",
|
||||
edCategory: "Category",
|
||||
edOperator: "Operator",
|
||||
edDecision: "Review decision",
|
||||
edReviewedBy: "Reviewed by",
|
||||
edReviewNote: "Note",
|
||||
edReviewRef: "Movement ref",
|
||||
edTariffVersion: "Tariff version",
|
||||
edRawPayload: "Raw signed payload",
|
||||
edOccurrence: "Occurrence id",
|
||||
@@ -235,6 +293,9 @@ export const en: Catalog = {
|
||||
reason: {
|
||||
"entry.refused.full": "Entry refused — lot full ({{count}}/{{capacity}})",
|
||||
"entry.held.noTicket": "Entry held — ticket not printed: {{detail}}",
|
||||
"entry.operatorIssued": "Entry ticket issued by operator {{operator}} (physical button broken)",
|
||||
"entry.issue.noPresence": "Operator entry refused — no vehicle detected at the entry",
|
||||
"entry.duplicatePlate": "Possible duplicate entry — plate {{plate}} is already inside under ticket {{otherIdentity}}",
|
||||
"exit.refused.closed": "Exit refused — session already closed",
|
||||
"exit.refused.noSession": "Exit refused — unknown ticket",
|
||||
"exit.refused.unpaid": "Exit refused — not paid (take payment first)",
|
||||
@@ -244,12 +305,16 @@ export const en: Catalog = {
|
||||
"exit.open.failed": "Exit recorded, but the barrier did not open — open manually",
|
||||
"exit.freeGrace": "Free entry-grace (no charge)",
|
||||
"exit.manualOpen": "Manual barrier open (human intervention)",
|
||||
"exit.plateSwapSuspected": "Possible ticket swap — plate {{plate}} is already inside under ticket {{otherIdentity}}",
|
||||
"exit.plateSwapOverride": "Operator {{operator}} released a suspected ticket-swap exit (plate {{plate}}, also open under {{otherIdentity}})",
|
||||
"sub.refused.notFound": "Subscription refused — not found",
|
||||
"sub.refused.outOfWindow": "Subscription refused — {{status}}/out-of-window",
|
||||
"sub.refused.noSession": "Subscription exit with no open session (already out / never entered)",
|
||||
"sub.refused.atCapacity": "Subscription refused — at capacity ({{inUse}}/{{max}} cars in)",
|
||||
"sub.refused.unpaidWindow": "Exit refused — out-of-window charge unpaid ({{amount}} {{currency}}); pay at the booth",
|
||||
"sub.refused.channelMismatch": "Credential refused — a {{credentialKind}} credential arrived via the {{channel}} channel (possible cloned credential)",
|
||||
"void.ticketCancelled": "Ticket cancelled — {{reason}}",
|
||||
"setup.relayTest": "Relay test — admin {{operator}} pulsed relay {{relay}} on controller {{controller}} from Setup",
|
||||
},
|
||||
tariff: {
|
||||
title: "Tariff",
|
||||
@@ -267,25 +332,39 @@ export const en: Catalog = {
|
||||
bandDuration: "Band duration",
|
||||
hoursUnit: "hours",
|
||||
egHours: "e.g. 2",
|
||||
pricePerIncrement: "Price / increment",
|
||||
pricePerIncrement: "Price / increment (per hour)",
|
||||
pricePerHour: "Price / hour",
|
||||
pricePerN: "Price / {{min}} min",
|
||||
modeFlatN: "Flat price / {{min}} min",
|
||||
perHourEquiv: "= {{amount}} / hour",
|
||||
incrementWarning:
|
||||
"Careful: the billing increment is {{min}} min — every price below is charged per started {{min}} minutes, NOT per hour.",
|
||||
thereafter: "thereafter (open-ended)",
|
||||
remove: "Remove",
|
||||
addBlock: "+ Add block",
|
||||
publishNewVersion: "Publish new version",
|
||||
publishing: "Publishing…",
|
||||
versionNamePh: "Version name (optional), e.g. Summer 2026",
|
||||
versionsTitle: "Published versions",
|
||||
versionsHint: "Click one to load it into the editor. Publishing always creates a new version — past versions never change.",
|
||||
activeBadge: "active",
|
||||
publishedOk: "New tariff version published — it's now the active rate.",
|
||||
defaultCard: "Base rate (always active)",
|
||||
defaultCardHint: "The base rate applied when no time/seasonal tier matches. This alone is enough for most car parks.",
|
||||
modeLadder: "Hourly ladder",
|
||||
modeFlat: "Flat price",
|
||||
modeFlat: "Flat price / hour",
|
||||
modeStepped: "By duration (up-to)",
|
||||
modePackage: "Window package (one total)",
|
||||
packageHint:
|
||||
"ONE total for any presence inside this tier's window — leaving earlier costs the same. Touching the window on two different nights charges the package twice (once per night). Hours outside the window are priced by the base rate.",
|
||||
packageTotal: "Package total",
|
||||
steppedHint:
|
||||
"Set the TOTAL price for a stay up to a given time (e.g. up to 3h = 500). The first row whose limit ≥ the duration wins (the limit is inclusive). The last row's total repeats as a per-day price for longer stays.",
|
||||
stepUpTo: "Up to",
|
||||
stepTotal: "Total price",
|
||||
addStep: "+ Add row",
|
||||
steppedTiersConflict:
|
||||
"⚠ Time/seasonal tiers do NOT apply when the base rate is 'By duration (up-to)' — the engine ignores them entirely. Remove the tiers, or switch the base rate to 'Hourly ladder' or 'Flat price'. Publishing is blocked until this is fixed.",
|
||||
"⚠ Time/seasonal tiers do NOT apply when the base rate is 'By duration (up-to)' — the engine ignores them entirely. Remove the tiers, or switch the base rate to 'Hourly ladder' or 'Flat price / hour'. Publishing is blocked until this is fixed.",
|
||||
tiersAdvanced: "Advanced: time & seasonal tiers",
|
||||
tiersHint: "Optional. Add tiers that apply only at certain hours/days/dates or for a category (e.g. happy hour, night rate, weekend, bus). With no tiers, just the base rate is published.",
|
||||
tierName: "Name",
|
||||
@@ -342,6 +421,8 @@ export const en: Catalog = {
|
||||
scan: "Scan for controllers",
|
||||
scanning: "Scanning…",
|
||||
noControllersFound: "No controllers found on the LAN.",
|
||||
usbNoneFound: "No USB printer found (/dev/usb/lpN) — check cable/power; the path can be typed manually.",
|
||||
usbSavedMissing: "saved — not present now",
|
||||
use: "Use",
|
||||
test: "Test connection",
|
||||
testing: "Testing…",
|
||||
@@ -422,6 +503,20 @@ export const en: Catalog = {
|
||||
"Sends a test slip to the printer now. ‘Connected’ only opens the link — this confirms the printer actually feeds paper.",
|
||||
printOk: "✓ Test slip sent ({{ms}} ms). Check the printer.",
|
||||
"printFail.print-failed": "The printer rejected the job (out of paper, cover open, or the link dropped).",
|
||||
// Relay wiring test — physically opens the barrier (the open is signed into the ledger).
|
||||
testRelay: "Test R{{relay}}",
|
||||
relayTesting: "Opening…",
|
||||
testRelayTitle: "Pulse relay {{relay}} — opens the barrier",
|
||||
confirmRelayTest: "Pulse relay {{relay}} now? This physically opens the barrier and is recorded in the ledger as a test.",
|
||||
relayTestOk: "✓ R{{relay}} pulsed — barrier opened",
|
||||
relayTestFailed: "✗ R{{relay}} failed: {{detail}}",
|
||||
// Entry presence-gate bypass (faulty radar/camera) — admin drops a signal as a requirement.
|
||||
presenceGateTitle: "Entry presence gate",
|
||||
presenceGateHint:
|
||||
"The entry button normally needs both a radar/loop and a camera detection to confirm a real vehicle. If a device is faulty, bypass it so transients can enter until support fixes it. Each change is signed to the ledger, and tickets issued while bypassed are flagged.",
|
||||
presenceBypassRadar: "Bypass radar / loop (faulty presence sensor)",
|
||||
presenceBypassCamera: "Bypass camera (faulty vehicle detection)",
|
||||
presenceBypassActive: "Presence bypass active — the entry gate is weakened. Turn off once the device is repaired.",
|
||||
// Reveal/hide toggle for a secret field (e.g. the device web password).
|
||||
revealSecret: "Show password",
|
||||
hideSecret: "Hide password",
|
||||
@@ -447,21 +542,26 @@ export const en: Catalog = {
|
||||
lab: {
|
||||
title: "Tariff Lab",
|
||||
intro:
|
||||
"Test rates in time (day/night windows, daily caps, overstay) in seconds, with no waiting. Pricing uses the same logic as the booth; nothing is written to the ledger.",
|
||||
loadTicket: "Load from a real ticket",
|
||||
loadTicketPh: "Ticket number / identity",
|
||||
load: "Load",
|
||||
loaded: "Loaded session {{id}}",
|
||||
tariffVersion: "Tariff version",
|
||||
activeVersion: "Active version (current)",
|
||||
"Compose experimental rate cards and price hypothetical stays against them — nothing goes live until you publish. Pricing uses the same logic as the booth; nothing is written to the ledger.",
|
||||
drafts: "Lab tariffs",
|
||||
newDraft: "New draft",
|
||||
activeTariff: "Active tariff",
|
||||
published: "Published versions",
|
||||
noDrafts: "No lab tariffs yet — create a draft to experiment.",
|
||||
edit: "Edit",
|
||||
publish: "Publish",
|
||||
delete: "Delete",
|
||||
draftName: "Name",
|
||||
draftNamePh: "e.g. Winter proposal",
|
||||
saveDraft: "Save draft",
|
||||
savingDraft: "Saving…",
|
||||
newDraftTitle: "New lab tariff",
|
||||
editDraftTitle: "Edit lab tariff",
|
||||
confirmPublish: 'Publish "{{name}}" as the new live rate card? It takes effect immediately.',
|
||||
confirmDelete: 'Delete lab tariff "{{name}}"?',
|
||||
entered: "Entered",
|
||||
asOf: "As of (now/exit)",
|
||||
exit: "Exit",
|
||||
now: "Now",
|
||||
category: "Category",
|
||||
categoryPh: "e.g. bus (blank = car)",
|
||||
payment: "Payment",
|
||||
paid: "paid",
|
||||
graceMin: "grace (min)",
|
||||
price: "Compute price",
|
||||
pricing: "Pricing…",
|
||||
outcome: "Outcome",
|
||||
@@ -473,6 +573,16 @@ export const en: Catalog = {
|
||||
graceExpires: "Grace expires",
|
||||
curve: "Duration curve",
|
||||
curveHint: "Fee from entry at several durations — see where the daily cap flattens or windows shift.",
|
||||
bd: {
|
||||
title: "How the amount is produced",
|
||||
rounding: "{{raw}} min parked → {{billed}} min billed ({{inc}}-min increments)",
|
||||
grace: "Free — within the entry grace ({{min}} min)",
|
||||
package: "window package",
|
||||
step: "Day {{day}}: stay up to {{hours}}h — total",
|
||||
stepRepeated: "Day {{day}}: beyond the top tier — full-day total",
|
||||
cap: "Daily cap {{cap}} applied (day {{day}})",
|
||||
total: "Total",
|
||||
},
|
||||
},
|
||||
subs: {
|
||||
title: "Subscriptions",
|
||||
@@ -627,6 +737,51 @@ export const en: Catalog = {
|
||||
fieldPhone: "Phone",
|
||||
fieldEmail: "Email",
|
||||
},
|
||||
// Merchant validations (bar / lavazh) — the /setup/site panel, the merchant's
|
||||
// /validate screen, and the booth-modal discount lines. See validation-discounts.md.
|
||||
val: {
|
||||
// /setup/site
|
||||
sectionTitle: "Merchant validations",
|
||||
sectionHint: "An in-park merchant (bar / car-wash) scans the customer's ticket and grants a parking discount — payment and the receipt always stay at the booth.",
|
||||
enableBar: "Bar",
|
||||
enableLavazh: "Car wash",
|
||||
labelName: "Receipt label",
|
||||
labelNamePh: "e.g. Car wash — first hour free",
|
||||
mode: "Discount type",
|
||||
modeComp: "Parking fully free",
|
||||
modeTimeCredit: "First minutes free",
|
||||
modeFixed: "Amount off (typed at scan)",
|
||||
modePercent: "Percent off",
|
||||
minutes: "Free minutes",
|
||||
percent: "Percent (%)",
|
||||
maxAmount: "Cap per validation",
|
||||
maxPerDay: "Max validations per day (blank = unlimited)",
|
||||
users: "Validating users",
|
||||
usersHint: "Only the selected users (whose role grants validation:create) can apply this program from their device.",
|
||||
noUsers: "No users in the system — create one under Users.",
|
||||
saved: "Saved.",
|
||||
// /validate (the merchant screen)
|
||||
title: "Ticket validation",
|
||||
scanPrompt: "Scan or type the ticket number",
|
||||
lookup: "Look up",
|
||||
entry: "Entry:",
|
||||
notFound: "No ticket found with this number.",
|
||||
closed: "The ticket is closed (exited or voided).",
|
||||
subscription: "This is a subscriber entry — not validatable.",
|
||||
amountLabel: "Discount amount",
|
||||
amountHint: "max {{max}}",
|
||||
apply: "Apply validation",
|
||||
applied: "Validation applied.",
|
||||
existing: "Validations on this ticket",
|
||||
voided: "voided",
|
||||
used: "used in a payment",
|
||||
void: "Void",
|
||||
confirmVoid: "Void this validation?",
|
||||
noPrograms: "You have no validation program bound to you — contact the administrator.",
|
||||
// booth pay modal / receipts
|
||||
gross: "Fee",
|
||||
discount: "Discount",
|
||||
},
|
||||
users: {
|
||||
title: "Users",
|
||||
add: "+ Add user",
|
||||
@@ -705,9 +860,9 @@ export const en: Catalog = {
|
||||
srcSubWindow: "out-of-window",
|
||||
drawerSection: "— Drawer —",
|
||||
openingFloat: "Opening cash:",
|
||||
cashTaken: "Cash taken:",
|
||||
cashAdded: "Cash added:",
|
||||
cashRemoved: "Cash removed:",
|
||||
cashTaken: "Daily takings:",
|
||||
cashAdded: "Receipts:",
|
||||
cashRemoved: "Disbursements:",
|
||||
expectedDrawer: "Expected drawer:",
|
||||
printedToReceipt: "Printed to booth receipt.",
|
||||
recordedNoPrinter: "Recorded (no printer to print to).",
|
||||
@@ -756,9 +911,9 @@ export const en: Catalog = {
|
||||
current: "current",
|
||||
drawerSection: "Drawer",
|
||||
openingFloat: "Opening cash",
|
||||
cashTaken: "Cash taken",
|
||||
cashAdded: "Cash added",
|
||||
cashRemoved: "Cash removed",
|
||||
cashTaken: "Daily takings",
|
||||
cashAdded: "Receipts",
|
||||
cashRemoved: "Disbursements",
|
||||
loadFailed: "Failed to load shifts.",
|
||||
},
|
||||
reports: {
|
||||
@@ -777,14 +932,23 @@ export const en: Catalog = {
|
||||
payments: "Payments",
|
||||
avgStay: "Avg stay",
|
||||
subscribers: "Subscribers",
|
||||
peakOcc: "Peak occupancy",
|
||||
voids: "Voided tickets",
|
||||
anomalies: "Anomalies",
|
||||
},
|
||||
chart: {
|
||||
flow: "Entries & exits over time",
|
||||
occupancy: "Occupancy — cars inside",
|
||||
occupancySeries: "Cars inside",
|
||||
revenue: "Revenue ({{currency}})",
|
||||
mix: "Revenue mix",
|
||||
peakHours: "Entries by hour of day",
|
||||
stay: "Stay duration (closed sessions)",
|
||||
heatmap: "Entries heatmap — hour × day",
|
||||
breakdown: "Breakdown",
|
||||
},
|
||||
capacityLine: "capacity",
|
||||
stay: { m: "m", h: "h" },
|
||||
dowShort: ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"],
|
||||
mix: { ticket: "Transient", subSales: "Subscriptions", subWindow: "Out-of-window" },
|
||||
row: {
|
||||
cash: "Cash",
|
||||
@@ -830,6 +994,7 @@ export const en: Catalog = {
|
||||
status: "Status",
|
||||
path: "Path",
|
||||
empty: "No logs.",
|
||||
repeated: "Repeated {{count}} times (first at {{firstAt}})",
|
||||
},
|
||||
backup: {
|
||||
title: "Backup",
|
||||
@@ -874,14 +1039,18 @@ export const en: Catalog = {
|
||||
ticket: "Ticket",
|
||||
entry: "Entry",
|
||||
now: "Now",
|
||||
exit: "Exit",
|
||||
duration: "Duration",
|
||||
statusLabel: "Status",
|
||||
paid: "PAID",
|
||||
unpaid: "UNPAID",
|
||||
overstay: "OVERSTAY",
|
||||
overstayHint: "Earlier session paid. The customer failed to exit during the grace period. Payment for the new period is required. The total below is the new period's fee.",
|
||||
closedWithinGrace: "EXITED · GRACE",
|
||||
closedWithinGraceHint: "Paid and exit recorded — the barrier didn't confirm yet. The car stays listed until the grace period ends. Open the barrier manually if it's still waiting.",
|
||||
topUp: "New period due",
|
||||
total: "Total",
|
||||
paidAmount: "Paid",
|
||||
noTariff: "no tariff",
|
||||
tender: "Tender",
|
||||
cash: "Cash",
|
||||
@@ -900,6 +1069,10 @@ export const en: Catalog = {
|
||||
lookingUp: "looking up…",
|
||||
paidBarrierOpened: "Paid — barrier opened. Car may exit.",
|
||||
paidExitRecorded: "Paid and exit recorded, but the barrier did not open: {{reason}}.",
|
||||
swapTitle: "Possible ticket swap",
|
||||
swapBody: "Plate {{plate}} is already inside under ticket {{other}} (entered {{when}}). This car may be exiting on a different ticket than it entered on.",
|
||||
swapHint: "Verify the vehicle before releasing. Overriding is recorded against you.",
|
||||
swapOverride: "Override & release",
|
||||
subscription: "SUBSCRIPTION",
|
||||
plan: "Plan",
|
||||
prepaid: "PREPAID",
|
||||
|
||||
+203
-29
@@ -35,6 +35,8 @@ export const sq = {
|
||||
"Nëntor",
|
||||
"Dhjetor",
|
||||
],
|
||||
// Short month names — the UI-wide date standard ("25 Qer", "7 Korr").
|
||||
monthsShort: ["Jan", "Shk", "Mar", "Pri", "Maj", "Qer", "Korr", "Gush", "Sht", "Tet", "Nën", "Dhj"],
|
||||
},
|
||||
auth: {
|
||||
title: "Sistemi i Parkimit",
|
||||
@@ -60,11 +62,52 @@ export const sq = {
|
||||
users: "Përdoruesit",
|
||||
roles: "Rolet",
|
||||
shifts: "Turnet",
|
||||
drawer: "Arka",
|
||||
reports: "Raportet",
|
||||
recycleBin: "Koshi",
|
||||
logs: "Loget",
|
||||
backup: "Kopje rezervë",
|
||||
profile: "Profili",
|
||||
validate: "Validime",
|
||||
},
|
||||
drawer: {
|
||||
stateTitle: "Arka tani",
|
||||
openShift: "Turn i hapur: {{operator}}",
|
||||
noShiftOpen: "Asnjë turn i hapur — arka mban gjendjen e mbylljes së turnit të fundit.",
|
||||
thisShift: "Ky turn:",
|
||||
todayTitle: "Aktiviteti i arkës sot",
|
||||
todayPayments: "{{count}} pagesa",
|
||||
payment: "Pagesë",
|
||||
noActivity: "Pa lëvizje arke sot.",
|
||||
historyTitle: "Turne të mbyllura",
|
||||
noShifts: "Ende pa turne të mbyllura.",
|
||||
recordTitle: "Regjistro një lëvizje arke",
|
||||
amount: "shuma",
|
||||
reasonPlaceholder: "arsyeja (p.sh. pagesë furnitori, depozitë banke)",
|
||||
recordHint: "Regjistrohet menjëherë në arkë. Një admin e shqyrton më pas.",
|
||||
mandatArketimi: "Arkëtim (hyrje) +",
|
||||
mandatPagese: "Pagesë (dalje) −",
|
||||
enterPositive: "Fut një shumë pozitive.",
|
||||
recorded: "{{no}} u regjistrua. Arka tani {{amount}}.",
|
||||
myTitle: "Lëvizjet e mia të arkës",
|
||||
allTitle: "Lëvizjet e arkës",
|
||||
pendingCount: "{{count}} në pritje",
|
||||
filterAll: "Të gjitha",
|
||||
empty: "Asnjë lëvizje arke ende.",
|
||||
colWhen: "Kur",
|
||||
colType: "Lloji",
|
||||
colAmount: "Shuma",
|
||||
colReason: "Arsyeja",
|
||||
colOperator: "Operatori",
|
||||
colStatus: "Statusi",
|
||||
status: {
|
||||
pending: "në pritje",
|
||||
authorized: "autorizuar",
|
||||
denied: "refuzuar",
|
||||
},
|
||||
authorize: "Autorizo",
|
||||
deny: "Refuzo",
|
||||
denyNotePlaceholder: "arsyeja e refuzimit (opsionale)",
|
||||
},
|
||||
profile: {
|
||||
title: "Profili im",
|
||||
@@ -162,10 +205,18 @@ export const sq = {
|
||||
fEvtVoid: "Anulim",
|
||||
fEvtAnomaly: "Anomali",
|
||||
openPayExit: "Hap pagesën / daljen",
|
||||
openReopenBarrier: "Hap — paguar, pret barrierën",
|
||||
issueEntry: "Lësho biletë",
|
||||
issueEntryTitle: "Lësho një biletë hyrjeje & hap barrierën (butoni fizik i prishur)",
|
||||
issueEntryConfirm: "Një automjet është te hyrja. Të lëshohet një biletë hyrjeje dhe të hapet barriera?",
|
||||
issueEntryOk: "Bileta e hyrjes {{ticket}} u lëshua.",
|
||||
exitedGrace: "doli · në afat",
|
||||
exitedGraceLeft: "doli · {{time}}",
|
||||
exitedGraceTitle: "Paguar dhe dalur — barriera nuk u konfirmua; po pret afatin kohor.",
|
||||
openBarrier: "Hap barrierën",
|
||||
openBarrierTitle: "Hap barrierën manualisht",
|
||||
barrierOpened: "barriera u hap",
|
||||
openManually: "hape me dorë",
|
||||
openManually: "hape manualisht",
|
||||
// session row badges
|
||||
badgeExiting: "duke dalë",
|
||||
badgePaid: "paguar",
|
||||
@@ -183,6 +234,10 @@ export const sq = {
|
||||
evtCashMovement: "ARKË",
|
||||
evtCashIn: "ARKËTIM",
|
||||
evtCashOut: "PAGESË",
|
||||
evtCashReview: "SHQYRTIM",
|
||||
evtConfigChange: "KONFIG",
|
||||
evtValidation: "VALIDIM",
|
||||
decision: { authorize: "autorizuar", deny: "refuzuar" },
|
||||
evtAnomaly: "ANOMALI",
|
||||
evtRefused: "REFUZUAR",
|
||||
// rreshti i detajeve të eventit live + etiketat e klasifikimit (nga payload)
|
||||
@@ -224,6 +279,10 @@ export const sq = {
|
||||
edPlate: "Targa",
|
||||
edCategory: "Kategoria",
|
||||
edOperator: "Operatori",
|
||||
edDecision: "Vendimi i shqyrtimit",
|
||||
edReviewedBy: "Shqyrtuar nga",
|
||||
edReviewNote: "Shënim",
|
||||
edReviewRef: "Ref. lëvizjes",
|
||||
edTariffVersion: "Versioni i tarifës",
|
||||
edRawPayload: "Të dhënat e papërpunuara të nënshkruara",
|
||||
edOccurrence: "ID e hyrjes",
|
||||
@@ -238,21 +297,28 @@ export const sq = {
|
||||
reason: {
|
||||
"entry.refused.full": "Hyrja u refuzua — parkimi plot ({{count}}/{{capacity}})",
|
||||
"entry.held.noTicket": "Hyrja u mbajt — bileta nuk u printua: {{detail}}",
|
||||
"entry.operatorIssued": "Biletë hyrjeje e lëshuar nga operatori {{operator}} (butoni fizik i prishur)",
|
||||
"entry.issue.noPresence": "Hyrja nga operatori u refuzua — asnjë automjet te hyrja",
|
||||
"entry.duplicatePlate": "Hyrje e dyfishtë e mundshme — targa {{plate}} është tashmë brenda me biletën {{otherIdentity}}",
|
||||
"exit.refused.closed": "Dalja u refuzua — sesioni është mbyllur tashmë",
|
||||
"exit.refused.noSession": "Dalja u refuzua — biletë e panjohur",
|
||||
"exit.refused.unpaid": "Dalja u refuzua — e papaguar (bëj pagesën në fillim)",
|
||||
"exit.refused.graceExpired": "Dalja u refuzua — afati i daljes skadoi (kërkohet pagesë shtesë)",
|
||||
"exit.open.noBarrier": "Dalja u regjistrua, por nuk ka barrierë daljeje të konfiguruar — hape me dorë",
|
||||
"exit.open.unavailable": "Dalja u regjistrua, por barriera është e padisponueshme — hape me dorë",
|
||||
"exit.open.failed": "Dalja u regjistrua, por barriera nuk u hap — hape me dorë",
|
||||
"exit.open.noBarrier": "Dalja u regjistrua, por nuk ka barrierë daljeje të konfiguruar — hape manualisht",
|
||||
"exit.open.unavailable": "Dalja u regjistrua, por barriera është e padisponueshme — hape manualisht",
|
||||
"exit.open.failed": "Dalja u regjistrua, por barriera nuk u hap — hape manualisht",
|
||||
"exit.freeGrace": "Periudhë pa pagesë në hyrje (pa tarifë)",
|
||||
"exit.manualOpen": "Hapje manuale e barrierës (ndërhyrje njerëzore)",
|
||||
"exit.plateSwapSuspected": "Mundësi ndërrimi biletash — targa {{plate}} është tashmë brenda me biletën {{otherIdentity}}",
|
||||
"exit.plateSwapOverride": "Operatori {{operator}} lëshoi një dalje me dyshim ndërrimi biletash (targa {{plate}}, edhe e hapur me {{otherIdentity}})",
|
||||
"sub.refused.notFound": "Abonimi u refuzua — nuk u gjet",
|
||||
"sub.refused.outOfWindow": "Abonimi u refuzua — {{status}}/jashtë afatit",
|
||||
"sub.refused.noSession": "Dalje me abonim pa sesion të hapur (tashmë jashtë / nuk ka hyrë kurrë)",
|
||||
"sub.refused.atCapacity": "Abonimi u refuzua — në kapacitet ({{inUse}}/{{max}} makina brenda)",
|
||||
"sub.refused.unpaidWindow": "Dalja u refuzua — detyrim jashtë orarit i papaguar ({{amount}} {{currency}}); paguaje në kabinë",
|
||||
"sub.refused.channelMismatch": "Kredenciali u refuzua — kredencial {{credentialKind}} i paraqitur në kanalin {{channel}} (kredencial i mundshëm i klonuar)",
|
||||
"void.ticketCancelled": "Bileta u anulua — {{reason}}",
|
||||
"setup.relayTest": "Test releje — admini {{operator}} aktivizoi relenë {{relay}} te kontrolluesi {{controller}} nga Konfigurimi",
|
||||
},
|
||||
tariff: {
|
||||
title: "Tarifa",
|
||||
@@ -270,25 +336,39 @@ export const sq = {
|
||||
bandDuration: "Kohëzgjatja e brezit",
|
||||
hoursUnit: "orë",
|
||||
egHours: "p.sh. 2",
|
||||
pricePerIncrement: "Çmimi / interval",
|
||||
pricePerIncrement: "Çmimi / interval (min)",
|
||||
pricePerHour: "Çmimi / orë",
|
||||
pricePerN: "Çmimi / {{min}} min",
|
||||
modeFlatN: "Çmim fiks / {{min}} min",
|
||||
perHourEquiv: "= {{amount}} / orë",
|
||||
incrementWarning:
|
||||
"Kujdes: intervali i faturimit është {{min}} min — çdo çmim më poshtë faturohet për çdo {{min}} minuta të filluara, JO për orë.",
|
||||
thereafter: "më pas (i hapur)",
|
||||
remove: "Hiq",
|
||||
addBlock: "+ Shto bllok",
|
||||
publishNewVersion: "Publiko version të ri",
|
||||
publishing: "Duke publikuar…",
|
||||
versionNamePh: "Emri i versionit (opsional), p.sh. Vera 2026",
|
||||
versionsTitle: "Versione të publikuara",
|
||||
versionsHint: "Kliko një për ta ngarkuar në editor. Publikimi krijon gjithmonë version të ri — versionet e kaluara nuk ndryshojnë kurrë.",
|
||||
activeBadge: "aktive",
|
||||
publishedOk: "U publikua versioni i ri i tarifës — tani është tarifa aktive.",
|
||||
defaultCard: "Tarifa bazë (gjithmonë aktive)",
|
||||
defaultCardHint: "Çmimi bazë i zbatuar kur asnjë nivel kohor/sezonal nuk vlen. Kjo e vetme është mjaftueshëm për shumicën e parkimeve.",
|
||||
modeLadder: "Shkallë orësh",
|
||||
modeFlat: "Çmim fiks",
|
||||
modeFlat: "Çmim fiks / orë",
|
||||
modeStepped: "Sipas kohëzgjatjes (deri-në)",
|
||||
modePackage: "Paketë dritareje (një total)",
|
||||
packageHint:
|
||||
"NJË çmim total për çdo prani brenda dritares së këtij niveli — largimi më herët kushton njësoj. Prekja e dritares në dy net të ndryshme e faturon paketën dy herë (një herë për natë). Orët jashtë dritares vlerësohen me tarifën bazë.",
|
||||
packageTotal: "Çmimi i paketës",
|
||||
steppedHint:
|
||||
"Vendos çmimin TOTAL për një qëndrim deri në një kohë të caktuar (p.sh. deri 3 orë = 500). Fiton rreshti i parë me kufi ≥ kohëzgjatjes (kufiri përfshihet). Totali i rreshtit të fundit përsëritet si çmim ditor për qëndrime më të gjata.",
|
||||
stepUpTo: "Deri në",
|
||||
stepTotal: "Çmimi total",
|
||||
addStep: "+ Shto rresht",
|
||||
steppedTiersConflict:
|
||||
"⚠ Nivelet kohore/sezonale NUK zbatohen kur tarifa bazë është 'Sipas kohëzgjatjes (deri-në)' — motori i shpërfill plotësisht. Hiqi nivelet, ose ndrysho tarifën bazë në 'Shkallë orësh' a 'Çmim fiks'. Publikimi bllokohet derisa kjo të rregullohet.",
|
||||
"⚠ Nivelet kohore/sezonale NUK zbatohen kur tarifa bazë është 'Sipas kohëzgjatjes (deri-në)' — motori i shpërfill plotësisht. Hiqi nivelet, ose ndrysho tarifën bazë në 'Shkallë orësh' a 'Çmim fiks / orë'. Publikimi bllokohet derisa kjo të rregullohet.",
|
||||
tiersAdvanced: "Të avancuara: nivele kohore & sezonale",
|
||||
tiersHint: "Opsionale. Shto nivele tarifore që vlejnë vetëm në orë/ditë/data ose kategori të caktuara (p.sh. orë e lirë, tarifë nate, fundjavë, autobus). Pa nivele, publikohet vetëm tarifa bazë.",
|
||||
tierName: "Emri",
|
||||
@@ -350,6 +430,8 @@ export const sq = {
|
||||
scan: "Skano për kontroller",
|
||||
scanning: "Duke skanuar…",
|
||||
noControllersFound: "Asnjë kontroller në LAN.",
|
||||
usbNoneFound: "Nuk u gjet asnjë printer USB (/dev/usb/lpN) — kontrollo kabllon/ushqimin; rruga mund të shkruhet me dorë.",
|
||||
usbSavedMissing: "i ruajtur — jo i pranishëm tani",
|
||||
use: "Përdor",
|
||||
test: "Testo lidhjen",
|
||||
testing: "Duke testuar…",
|
||||
@@ -432,6 +514,20 @@ export const sq = {
|
||||
"Dërgon një fletë prove te printeri tani. ‘I lidhur’ vetëm hap lidhjen — kjo konfirmon se printeri vërtet nxjerr letër.",
|
||||
printOk: "✓ Fleta e provës u dërgua ({{ms}} ms). Kontrollo printerin.",
|
||||
"printFail.print-failed": "Printeri nuk pranoi punën (pa letër, kapaku hapur, ose lidhja ra).",
|
||||
// Provë e releut — hap fizikisht barrierën (hapja regjistrohet në ledger).
|
||||
testRelay: "Provo R{{relay}}",
|
||||
relayTesting: "Duke hapur…",
|
||||
testRelayTitle: "Puls releu {{relay}} — hap barrierën",
|
||||
confirmRelayTest: "Ky veprim hap fizikisht barrierën dhe regjistrohet në ledger si provë.",
|
||||
relayTestOk: "✓ R{{relay}} u pulsua — barriera u hap",
|
||||
relayTestFailed: "✗ R{{relay}} dështoi: {{detail}}",
|
||||
// Anashkalimi i portës së pranisë (radar/kamera me defekt) — admini heq një sinjal si kusht.
|
||||
presenceGateTitle: "Porta e pranisë në hyrje",
|
||||
presenceGateHint:
|
||||
"Butoni i hyrjes normalisht kërkon edhe radarin edhe një event nga kamera për të konfirmuar një automjet në hyrje. Nëse një pajisje ka defekt, anashkaloje që kalimtarët të mund të hyjnë derisa ta rregullohet/ndërrohet. Çdo ndryshim regjistrohet në ledger, dhe biletat e lëshuara gjatë anashkalimit shënohen.",
|
||||
presenceBypassRadar: "Anashkalo radarin (radari me defekt)",
|
||||
presenceBypassCamera: "Anashkalo kamerën (kamera me defekt)",
|
||||
presenceBypassActive: "Anashkalimi i pranisë aktiv — siguria e hyrjes është dobësuar.",
|
||||
// Reveal/hide toggle for a secret field (e.g. the device web password).
|
||||
revealSecret: "Shfaq fjalëkalimin",
|
||||
hideSecret: "Fshih fjalëkalimin",
|
||||
@@ -459,21 +555,26 @@ export const sq = {
|
||||
lab: {
|
||||
title: "Lab Tarife",
|
||||
intro:
|
||||
"Testo tarifat në kohë (dritare ditë/natë, kufi ditor, qëndrim tej afatit) në sekonda, pa pritur orë. Çmimi llogaritet me të njëjtën logjikë si kabina; nuk shkruhet asgjë në ledger.",
|
||||
loadTicket: "Ngarko nga një biletë reale",
|
||||
loadTicketPh: "Numri i biletës / identiteti",
|
||||
load: "Ngarko",
|
||||
loaded: "U ngarkua sesioni {{id}}",
|
||||
tariffVersion: "Versioni i tarifës",
|
||||
activeVersion: "Versioni aktiv (i tanishëm)",
|
||||
"Kompozo tarifa eksperimentale dhe llogarit qëndrime hipotetike kundrejt tyre — asgjë nuk hyn në fuqi pa u publikuar. Çmimi llogaritet me të njëjtën logjikë si kabina; nuk shkruhet asgjë në ledger.",
|
||||
drafts: "Tarifa laboratori",
|
||||
newDraft: "Draft i ri",
|
||||
activeTariff: "Tarifa aktive",
|
||||
published: "Versione të publikuara",
|
||||
noDrafts: "Ende pa tarifa laboratori — krijo një draft për të eksperimentuar.",
|
||||
edit: "Ndrysho",
|
||||
publish: "Publiko",
|
||||
delete: "Fshi",
|
||||
draftName: "Emri",
|
||||
draftNamePh: "p.sh. Propozimi i dimrit",
|
||||
saveDraft: "Ruaj draftin",
|
||||
savingDraft: "Duke ruajtur…",
|
||||
newDraftTitle: "Tarifë e re laboratori",
|
||||
editDraftTitle: "Ndrysho tarifën e laboratorit",
|
||||
confirmPublish: 'Të publikohet "{{name}}" si karta e re aktive e çmimeve? Hyn në fuqi menjëherë.',
|
||||
confirmDelete: 'Të fshihet tarifa e laboratorit "{{name}}"?',
|
||||
entered: "Hyrja",
|
||||
asOf: "Deri më (tani/dalja)",
|
||||
exit: "Dalja",
|
||||
now: "Tani",
|
||||
category: "Kategoria",
|
||||
categoryPh: "p.sh. bus (bosh = makinë)",
|
||||
payment: "Pagesa",
|
||||
paid: "u pagua",
|
||||
graceMin: "afati (min)",
|
||||
price: "Llogarit çmimin",
|
||||
pricing: "Duke llogaritur…",
|
||||
outcome: "Rezultati",
|
||||
@@ -485,6 +586,16 @@ export const sq = {
|
||||
graceExpires: "Afati skadon",
|
||||
curve: "Kurba sipas kohëzgjatjes",
|
||||
curveHint: "Tarifa nga hyrja për disa kohëzgjatje — shih ku rrafshohet kufiri ditor ose ndryshojnë dritaret.",
|
||||
bd: {
|
||||
title: "Si prodhohet shuma",
|
||||
rounding: "{{raw}} min qëndrim → {{billed}} min të faturuara (njësi {{inc}} min)",
|
||||
grace: "Falas — brenda minutave të hirit ({{min}} min)",
|
||||
package: "paketë dritareje",
|
||||
step: "Dita {{day}}: qëndrim deri në {{hours}}h — total",
|
||||
stepRepeated: "Dita {{day}}: mbi shkallën më të lartë — totali ditor",
|
||||
cap: "U zbatua kufiri ditor {{cap}} (dita {{day}})",
|
||||
total: "Totali",
|
||||
},
|
||||
},
|
||||
subs: {
|
||||
title: "Abonimet",
|
||||
@@ -639,6 +750,51 @@ export const sq = {
|
||||
fieldPhone: "Telefoni",
|
||||
fieldEmail: "Email",
|
||||
},
|
||||
// Merchant validations (bar / lavazh) — the /setup/site panel, the merchant's
|
||||
// /validate screen, and the booth-modal discount lines. See validation-discounts.md.
|
||||
val: {
|
||||
// /setup/site
|
||||
sectionTitle: "Validime tregtare",
|
||||
sectionHint: "Shërbime të tjera brenda parkut (bar / lavazh) skanojnë biletën e hyrjes dhe bëjnë zbritje — pagesa dhe fatura bëhen në kabinë.",
|
||||
enableBar: "Bar",
|
||||
enableLavazh: "Lavazh",
|
||||
labelName: "Etiketa në faturë",
|
||||
labelNamePh: "p.sh. Lavazh — 1 orë falas",
|
||||
mode: "Lloji i zbritjes",
|
||||
modeComp: "Parkimi falas plotësisht",
|
||||
modeTimeCredit: "Minutat e para falas",
|
||||
modeFixed: "Zbritje shume (shkruhet në skanim)",
|
||||
modePercent: "Zbritje në përqindje",
|
||||
minutes: "Minuta falas",
|
||||
percent: "Përqindja (%)",
|
||||
maxAmount: "Tavani i zbritjes për validim",
|
||||
maxPerDay: "Maks. validime në ditë (bosh = pa kufi)",
|
||||
users: "Përdoruesit që validojnë",
|
||||
usersHint: "Vetëm përdoruesit e zgjedhur (me lejen validation:create në rolin e tyre) mund të aplikojnë këtë program nga pajisja e tyre.",
|
||||
noUsers: "Asnjë përdorues në sistem — krijojeni te Përdoruesit.",
|
||||
saved: "U ruajt.",
|
||||
// /validate (the merchant screen)
|
||||
title: "Validim biletash",
|
||||
scanPrompt: "Skanoni ose shkruani numrin e biletës",
|
||||
lookup: "Kërko",
|
||||
entry: "Hyrja:",
|
||||
notFound: "Nuk u gjet biletë me këtë numër.",
|
||||
closed: "Bileta është e mbyllur (ka dalë ose është anuluar).",
|
||||
subscription: "Kjo është hyrje abonenti — nuk validohet.",
|
||||
amountLabel: "Shuma e zbritjes",
|
||||
amountHint: "maks. {{max}}",
|
||||
apply: "Apliko validimin",
|
||||
applied: "Validimi u aplikua.",
|
||||
existing: "Validime në këtë biletë",
|
||||
voided: "anuluar",
|
||||
used: "përdorur në pagesë",
|
||||
void: "Anulo",
|
||||
confirmVoid: "Të anulohet ky validim?",
|
||||
noPrograms: "Nuk keni asnjë program validimi të lidhur me ju — kontaktoni administratorin.",
|
||||
// booth pay modal / receipts
|
||||
gross: "Tarifa",
|
||||
discount: "Zbritje",
|
||||
},
|
||||
users: {
|
||||
title: "Përdoruesit",
|
||||
add: "+ Shto përdorues",
|
||||
@@ -718,9 +874,9 @@ export const sq = {
|
||||
srcSubWindow: "jashtë orarit",
|
||||
drawerSection: "— Arka —",
|
||||
openingFloat: "Arka fillestare:",
|
||||
cashTaken: "Para të marra:",
|
||||
cashAdded: "Para të shtuara:",
|
||||
cashRemoved: "Para të hequra:",
|
||||
cashTaken: "Xhiro ditore:",
|
||||
cashAdded: "Arkëtime:",
|
||||
cashRemoved: "Pagesa:",
|
||||
expectedDrawer: "Gjëndje Arke:",
|
||||
printedToReceipt: "Printuar te printeri i kabinës.",
|
||||
recordedNoPrinter: "Regjistruar (pa printer për të printuar).",
|
||||
@@ -771,9 +927,9 @@ export const sq = {
|
||||
// Expanded drawer detail.
|
||||
drawerSection: "Arka",
|
||||
openingFloat: "Arka fillestare",
|
||||
cashTaken: "Para të marra",
|
||||
cashAdded: "Para të shtuara",
|
||||
cashRemoved: "Para të hequra",
|
||||
cashTaken: "Xhiro ditore",
|
||||
cashAdded: "Arkëtime",
|
||||
cashRemoved: "Pagesa",
|
||||
loadFailed: "Ngarkimi i turneve dështoi.",
|
||||
},
|
||||
reports: {
|
||||
@@ -792,14 +948,23 @@ export const sq = {
|
||||
payments: "Pagesa",
|
||||
avgStay: "Qëndrim mes.",
|
||||
subscribers: "Abonentë",
|
||||
peakOcc: "Zënia maksimale",
|
||||
voids: "Bileta të anuluara",
|
||||
anomalies: "Anomali",
|
||||
},
|
||||
chart: {
|
||||
flow: "Hyrjet & daljet me kalimin e kohës",
|
||||
occupancy: "Zënia — makina brenda",
|
||||
occupancySeries: "Makina brenda",
|
||||
revenue: "Të ardhurat ({{currency}})",
|
||||
mix: "Përbërja e të ardhurave",
|
||||
peakHours: "Hyrjet sipas orës së ditës",
|
||||
stay: "Kohëzgjatja e qëndrimit (sesione të mbyllura)",
|
||||
heatmap: "Harta e hyrjeve — orë × ditë",
|
||||
breakdown: "Ndarja",
|
||||
},
|
||||
capacityLine: "kapaciteti",
|
||||
stay: { m: "m", h: "o" },
|
||||
dowShort: ["Hën", "Mar", "Mër", "Enj", "Pre", "Sht", "Die"],
|
||||
mix: { ticket: "Tranzit", subSales: "Abonime", subWindow: "Jashtë orarit" },
|
||||
row: {
|
||||
cash: "Para në dorë",
|
||||
@@ -845,6 +1010,7 @@ export const sq = {
|
||||
status: "Statusi",
|
||||
path: "Rruga",
|
||||
empty: "Asnjë regjistër.",
|
||||
repeated: "Përsëritur {{count}} herë (hera e parë {{firstAt}})",
|
||||
},
|
||||
backup: {
|
||||
title: "Kopje rezervë",
|
||||
@@ -890,20 +1056,24 @@ export const sq = {
|
||||
ticket: "Bileta",
|
||||
entry: "Hyrja",
|
||||
now: "Tani",
|
||||
exit: "Dalja",
|
||||
duration: "Kohëzgjatja",
|
||||
statusLabel: "Statusi",
|
||||
paid: "PAGUAR",
|
||||
unpaid: "PAPAGUAR",
|
||||
overstay: "TEJ AFATIT",
|
||||
overstayHint: "Sesion i mëparshëm i paguar. Klienti nuk doli brënda afatit kohor. Kërkohet pagesë për periudhën e re. Totali më poshtë është tarifa e periudhës së re.",
|
||||
closedWithinGrace: "Paguar",
|
||||
closedWithinGraceHint: "Pagesa dhe dalja u regjistruan — barriera nuk u konfirmua ende. Makina mbetet në listë derisa të mbarojë afati. Hapni barrierën manualisht nëse pret ende.",
|
||||
topUp: "Periudha e re për pagesë",
|
||||
total: "Totali",
|
||||
paidAmount: "Paguar",
|
||||
noTariff: "pa tarifë",
|
||||
tender: "Mënyra",
|
||||
cash: "Para",
|
||||
card: "Kartë",
|
||||
printExitVoucher: "Printo biletë dalje",
|
||||
selfExitHint: "(klienti del vetë te dalja)",
|
||||
selfExitHint: "(klienti del duke skanuar biletën)",
|
||||
payAndOpen: "Paguaj + hap barrierën",
|
||||
payAndVoucher: "Paguaj + printo biletën",
|
||||
openBarrier: "Hap barrierën",
|
||||
@@ -916,6 +1086,10 @@ export const sq = {
|
||||
lookingUp: "Duke kërkuar…",
|
||||
paidBarrierOpened: "Paguar — barriera u hap. Automjeti mund të dalë.",
|
||||
paidExitRecorded: "Paguar dhe dalja u regjistrua, por barriera nuk u hap: {{reason}}.",
|
||||
swapTitle: "Mundësi ndërrimi biletash",
|
||||
swapBody: "Targa {{plate}} është tashmë brenda me biletën {{other}} (hyri {{when}}). Ky automjet mund të jetë duke dalë me një biletë tjetër nga ajo me të cilën hyri.",
|
||||
swapHint: "Verifiko automjetin para se ta lëshosh. Anashkalimi regjistrohet në emrin tënd.",
|
||||
swapOverride: "Anashkalo & lësho",
|
||||
subscription: "ABONIM",
|
||||
plan: "Plani",
|
||||
prepaid: "I PARAPAGUAR",
|
||||
@@ -926,7 +1100,7 @@ export const sq = {
|
||||
windowCharge: "JASHTË ORARIT",
|
||||
windowChargeHint: "Ky abonent parkoi jashtë orarit të lejuar të planit. Detyrohet të paguajë tarifën kalimtare për kohën jashtë orarit — merr pagesën, pastaj hap barrierën.",
|
||||
subBarrierOpened: "Barriera u hap për abonentin (ndërhyrje e regjistruar).",
|
||||
voucherPrinted: "Bileta e daljes u printua në {{printer}}. Klienti del vetë te dalja.",
|
||||
voucherPrinted: "Bileta e daljes u printua në {{printer}}. Klienti del duke skanuar biletën.",
|
||||
// payment receipt (transparency slip)
|
||||
receiptPrintFailed: "(fatura nuk u printua — provoni \"Riprinto faturën\".)",
|
||||
receiptReprinted: "Fatura u riprintua në {{printer}}.",
|
||||
|
||||
@@ -23,7 +23,14 @@ type WsMessage =
|
||||
| { kind: "plate-recognized"; plate: { identity: string; plate: string; direction: "entry" | "exit" } };
|
||||
|
||||
|
||||
export function useLiveFeed(): void {
|
||||
/**
|
||||
* @param enabled Gate on the WATCHER permission (`report:read` — mirrors the server's
|
||||
* WS guard in routes/ws.ts). A user whose role lacks it (e.g. a merchant validator
|
||||
* with only `validation:create`) must not attempt the socket at all: the server
|
||||
* 403s the upgrade and the capped-backoff reconnect would otherwise hammer it
|
||||
* forever, filling the server log with a 403 every few seconds.
|
||||
*/
|
||||
export function useLiveFeed(enabled: boolean = true): void {
|
||||
const qc = useQueryClient();
|
||||
const { setStatus, setOccupancy, pushEvent, setDevices, upsertDevice, setLanes, setRadar, patchPlate } =
|
||||
useLiveStore();
|
||||
@@ -34,6 +41,10 @@ export function useLiveFeed(): void {
|
||||
const closedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
setStatus("closed");
|
||||
return;
|
||||
}
|
||||
closedRef.current = false;
|
||||
|
||||
const connect = () => {
|
||||
@@ -117,7 +128,8 @@ export function useLiveFeed(): void {
|
||||
sockRef.current?.close();
|
||||
sockRef.current = null;
|
||||
};
|
||||
// qc / store setters are stable; run once on mount.
|
||||
// qc / store setters are stable; re-run only if the permission gate flips
|
||||
// (login as a different role without a full reload).
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
}, [enabled]);
|
||||
}
|
||||
|
||||
+138
-34
@@ -14,6 +14,7 @@ import {
|
||||
can,
|
||||
closeShift,
|
||||
fetchShiftReport,
|
||||
fetchVersion,
|
||||
logout,
|
||||
openShift,
|
||||
setLanguagePref,
|
||||
@@ -25,6 +26,7 @@ import {
|
||||
} from "./api.js";
|
||||
import { qk, queryClient } from "./lib/query.js";
|
||||
import { Modal } from "./ui/Modal.js";
|
||||
import { Spinner } from "./ui/Spinner.js";
|
||||
import { setLanguage } from "./lib/i18n/index.js";
|
||||
import { applyTheme, applyFontScale } from "./lib/theme.js";
|
||||
import { useLiveFeed } from "./lib/use-live-feed.js";
|
||||
@@ -41,8 +43,11 @@ import { SiteSettings } from "./SiteSettings.js";
|
||||
import { UsersManager } from "./UsersManager.js";
|
||||
import { RolesManager } from "./RolesManager.js";
|
||||
import { ShiftsHistory } from "./ShiftsHistory.js";
|
||||
import { DrawerManager } from "./DrawerManager.js";
|
||||
import { CARD_PAYMENTS_ENABLED } from "./lib/features.js";
|
||||
import { LogsViewer } from "./LogsViewer.js";
|
||||
import { BackupSettings } from "./BackupSettings.js";
|
||||
import { ValidateScreen } from "./ValidateScreen.js";
|
||||
import { RecycleBin } from "./RecycleBin.js";
|
||||
import { Profile } from "./Profile.js";
|
||||
// Reports pulls in Recharts (~heavy) — lazy-loaded so it stays OUT of the booth's
|
||||
@@ -90,6 +95,17 @@ function SetupTab({ to, label, exact = false }: { to: string; label: string; exa
|
||||
);
|
||||
}
|
||||
|
||||
/** The running deploy's "<branch>-<short-sha>" (matches the Komodo Stack's TAG in
|
||||
* komodo/resources.toml), gated the same as the "Park" tab (site:read) since it's the
|
||||
* same kind of read-only app metadata. Renders nothing if the value isn't known (e.g. a
|
||||
* local/dev build with no CI-supplied BUILD_VERSION) rather than showing an empty badge. */
|
||||
function VersionBadge() {
|
||||
const q = useQuery({ queryKey: ["version"], queryFn: fetchVersion, staleTime: Infinity });
|
||||
const version = q.data?.buildVersion;
|
||||
if (!version) return null;
|
||||
return <span className="ml-auto shrink-0 pl-3 text-[0.7rem] text-term-muted">{version}</span>;
|
||||
}
|
||||
|
||||
/** Setup layout — the config hub. Renders a permission-gated tab bar and the active
|
||||
* tab's screen via <Outlet>. Each tab is a child route (its own URL + guard), so
|
||||
* deep links and the back button work and a denied tab redirects to the booth. */
|
||||
@@ -108,6 +124,7 @@ function SetupLayout() {
|
||||
{show("recyclebin:read") && <SetupTab to="/setup/recycle-bin" label={t("nav.recycleBin")} />}
|
||||
{show("log:read") && <SetupTab to="/setup/logs" label={t("nav.logs")} />}
|
||||
{show("backup:read") && <SetupTab to="/setup/backup" label={t("nav.backup")} />}
|
||||
{show("site:read") && <VersionBadge />}
|
||||
</nav>
|
||||
<Outlet />
|
||||
</div>
|
||||
@@ -115,9 +132,10 @@ function SetupLayout() {
|
||||
}
|
||||
|
||||
/** Subscriptions layout — a standalone top-level section (its own header nav entry),
|
||||
* with tabs for the subscriber catalog, the plan catalog, and the tariff lab. Each
|
||||
* tab is a gated child route; an operator with only subscription:read sees just the
|
||||
* first tab. */
|
||||
* with tabs for the subscriber catalog and the plan catalog. Each tab is a gated
|
||||
* child route; an operator with only subscription:read sees just the first tab.
|
||||
* (The tariff lab moved to /setup/tariff/lab, 2026-07-05 — it tests the tariff, so
|
||||
* it lives with the tariff.) */
|
||||
function SubscriptionsLayout() {
|
||||
const { user } = rootRoute.useRouteContext();
|
||||
const { t } = useTranslation();
|
||||
@@ -127,7 +145,21 @@ function SubscriptionsLayout() {
|
||||
<nav className="mb-4 flex flex-wrap items-center gap-1 border-b border-term-border">
|
||||
{show("subscription:read") && <SetupTab to="/subscriptions" label={t("nav.subscriptions")} exact />}
|
||||
{show("subscription:plan") && <SetupTab to="/subscriptions/plans" label={t("nav.plans")} />}
|
||||
{show("tariff:read") && <SetupTab to="/subscriptions/tariff-lab" label={t("nav.tariffLab")} />}
|
||||
</nav>
|
||||
<Outlet />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Tariff layout — the rate-card hub under Setup: the composer (index) and the
|
||||
* pricing LAB as sub-tabs. One tariff:read gate on the parent covers both. */
|
||||
function TariffLayout() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div>
|
||||
<nav className="mb-2 flex flex-wrap items-center gap-1 border-b border-term-border">
|
||||
<SetupTab to="/setup/tariff" label={t("nav.tariff")} exact />
|
||||
<SetupTab to="/setup/tariff/lab" label={t("nav.tariffLab")} />
|
||||
</nav>
|
||||
<Outlet />
|
||||
</div>
|
||||
@@ -318,9 +350,15 @@ function ShiftButton() {
|
||||
disabled={busy || blockedByOther}
|
||||
title={blockedByOther ? t("shift.headerHeldBy", { operator: heldBy ?? "?" }) : undefined}
|
||||
onClick={onClick}
|
||||
className={`rounded-term border px-2 py-0.5 text-[0.6875rem] font-semibold uppercase tracking-wider ${tone}`}
|
||||
className={`rounded-term border px-2 py-0.5 text-[0.6875rem] font-semibold uppercase tracking-wider disabled:opacity-60 ${tone}`}
|
||||
>
|
||||
{busy ? t("shift.opening") : label}
|
||||
{busy ? (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Spinner /> {isMine ? t("shift.ending") : t("shift.opening")}
|
||||
</span>
|
||||
) : (
|
||||
label
|
||||
)}
|
||||
</button>
|
||||
{!isOpen && (
|
||||
<span className="text-[0.625rem] uppercase tracking-wider text-term-amber">{t("shift.headerNoShift")}</span>
|
||||
@@ -379,7 +417,7 @@ function CloseShiftConfirm({
|
||||
</div>
|
||||
<div className="mt-2 grid grid-cols-2 gap-x-6 gap-y-0.5 border-t border-term-border pt-2">
|
||||
<ConfirmFigure label={t("shift.cash")} value={fmt(x.cashTotalMinor)} />
|
||||
<ConfirmFigure label={t("shift.card")} value={fmt(x.cardTotalMinor)} />
|
||||
{CARD_PAYMENTS_ENABLED && <ConfirmFigure label={t("shift.card")} value={fmt(x.cardTotalMinor)} />}
|
||||
{/* Drawer math made explicit: opening float + cash taken = expected drawer. */}
|
||||
<ConfirmFigure label={t("shift.openingFloat")} value={fmt(x.openingFloatMinor)} />
|
||||
<span />
|
||||
@@ -392,7 +430,13 @@ function CloseShiftConfirm({
|
||||
{t("subs.cancel")}
|
||||
</button>
|
||||
<button type="button" className="btn btn-sm btn-danger" onClick={onConfirm} disabled={busy || !x}>
|
||||
{busy ? t("shift.ending") : t("shift.endShift")}
|
||||
{busy ? (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Spinner /> {t("shift.ending")}
|
||||
</span>
|
||||
) : (
|
||||
t("shift.endShift")
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -417,19 +461,33 @@ function ConfirmFigure({ label, value, bold, sub }: { label: string; value: stri
|
||||
function RootLayout() {
|
||||
const { user, setUser } = rootRoute.useRouteContext();
|
||||
const { t } = useTranslation();
|
||||
// One app-wide WebSocket for the live feed (booth + any live widget).
|
||||
useLiveFeed();
|
||||
// Nav is gated by PERMISSION, not role — a tab shows iff the user's role grants
|
||||
// the permission its screen needs (the route guards enforce the same server-side).
|
||||
const show = (perm: Permission) => can(user, perm);
|
||||
// One app-wide WebSocket for the live feed (booth + any live widget) — but ONLY
|
||||
// for roles the server would accept (routes/ws.ts gates on report:read). A
|
||||
// merchant validator must not even attempt it: the 403'd upgrade would reconnect
|
||||
// on backoff forever and spam the server log. Same rule for the widgets that feed
|
||||
// off it (StatusDot) or make their own gated calls (ShiftButton → shift:read,
|
||||
// DeviceFooter → device:read).
|
||||
const canWatch = show("report:read");
|
||||
useLiveFeed(canWatch);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col bg-term-bg text-term-text">
|
||||
<header className="flex items-center gap-4 border-b border-term-border bg-term-panel px-4 py-2">
|
||||
<span className="text-sm font-bold uppercase tracking-widest text-term-amber">▮ Parking</span>
|
||||
<nav className="flex items-center gap-1">
|
||||
<NavLink to="/booth" label={t("nav.booth")} />
|
||||
<NavLink to="/shifts" label={t("nav.shifts")} />
|
||||
{show("session:read") && <NavLink to="/booth" label={t("nav.booth")} />}
|
||||
{show("shift:read") && <NavLink to="/shifts" label={t("nav.shifts")} />}
|
||||
{/* The merchant's (bar/lavazh) scan-and-validate screen. Their typical role
|
||||
grants ONLY validation:create, so this is often their whole nav. */}
|
||||
{show("validation:create") && <NavLink to="/validate" label={t("nav.validate")} />}
|
||||
{/* Drawer — record cash movements (operator) / review them (admin). Shown if the
|
||||
user can do either. See wiki/concepts/shift.md. */}
|
||||
{(show("drawer:create") || show("drawer:review")) && (
|
||||
<NavLink to="/drawer" label={t("nav.drawer")} />
|
||||
)}
|
||||
{/* Subscriptions — a standalone section (Abonimet / Planet / Lab tarife).
|
||||
Shown if the user can reach ANY of its tabs. */}
|
||||
{(show("subscription:read") || show("subscription:plan") || show("tariff:read")) && (
|
||||
@@ -450,11 +508,11 @@ function RootLayout() {
|
||||
show("shift:read")) && <NavLink to="/setup" label={t("nav.setup")} />}
|
||||
</nav>
|
||||
<div className="ml-auto flex items-center gap-3">
|
||||
{user && <ShiftButton />}
|
||||
{user && show("shift:read") && <ShiftButton />}
|
||||
{user && <LanguageToggle user={user} setUser={setUser} />}
|
||||
{user && <ThemeToggle user={user} setUser={setUser} />}
|
||||
{user && <FontScaleToggle user={user} setUser={setUser} />}
|
||||
<StatusDot />
|
||||
{canWatch && <StatusDot />}
|
||||
{user && (
|
||||
<Link
|
||||
to="/profile"
|
||||
@@ -479,8 +537,10 @@ function RootLayout() {
|
||||
<main className="min-h-0 flex-1 overflow-auto p-3">
|
||||
<Outlet />
|
||||
</main>
|
||||
{/* Fixed device-status footer — relays, readers, cameras, printers. */}
|
||||
{user && <DeviceFooter />}
|
||||
{/* Fixed device-status footer — relays, readers, cameras, printers. Its REST
|
||||
seed needs device:read (and its live updates ride the report:read WS), so
|
||||
it's hidden for roles without device visibility (e.g. merchant validators). */}
|
||||
{user && show("device:read") && <DeviceFooter />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -488,7 +548,12 @@ function RootLayout() {
|
||||
const indexRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/",
|
||||
beforeLoad: () => {
|
||||
beforeLoad: ({ context }) => {
|
||||
// A merchant-only user (validation:create without the booth's session:read)
|
||||
// lands on their scan-and-validate screen; everyone else on the booth.
|
||||
if (can(context.user, "validation:create") && !can(context.user, "session:read")) {
|
||||
throw redirect({ to: "/validate" });
|
||||
}
|
||||
throw redirect({ to: "/booth" });
|
||||
},
|
||||
});
|
||||
@@ -499,6 +564,20 @@ const boothRoute = createRoute({
|
||||
component: BoothScreen,
|
||||
});
|
||||
|
||||
// The merchant (bar/lavazh) scan-and-validate screen — usually the ONLY page a
|
||||
// merchant user's role can reach. The server enforces the program↔user binding on
|
||||
// apply; this gate is defence in depth. See wiki/concepts/validation-discounts.md.
|
||||
const validateRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/validate",
|
||||
beforeLoad: ({ context }) => requirePerm("validation:create")(context),
|
||||
component: function ValidateRoute() {
|
||||
const { user } = rootRoute.useRouteContext();
|
||||
if (!user) return null;
|
||||
return <ValidateScreen user={user} />;
|
||||
},
|
||||
});
|
||||
|
||||
// Back-compat redirects for paths that moved. Most config screens live under /setup;
|
||||
// Subscriptions/Plans/Tariff-Lab were promoted OUT of /setup into the standalone
|
||||
// /subscriptions section (2026-06-21) — redirect the old /setup/* paths too so existing
|
||||
@@ -512,7 +591,10 @@ const legacyRedirects = (
|
||||
["/shift", "/shifts"],
|
||||
["/setup/subscriptions", "/subscriptions"],
|
||||
["/setup/plans", "/subscriptions/plans"],
|
||||
["/setup/tariff-lab", "/subscriptions/tariff-lab"],
|
||||
// The tariff lab bounced twice: /setup/tariff-lab → /subscriptions/tariff-lab
|
||||
// (2026-06-21) → /setup/tariff/lab (2026-07-05, back with the tariff it tests).
|
||||
["/setup/tariff-lab", "/setup/tariff/lab"],
|
||||
["/subscriptions/tariff-lab", "/setup/tariff/lab"],
|
||||
["/setup/shifts", "/shifts"],
|
||||
["/setup/reports", "/reports"],
|
||||
] as const
|
||||
@@ -548,14 +630,30 @@ const shiftRoute = createRoute({
|
||||
component: function ShiftRoute() {
|
||||
const { user } = rootRoute.useRouteContext();
|
||||
// The shift hub: list (current/open shift on top + history) + per-shift activity log.
|
||||
// The CURRENT shift's pane carries the actions (open/close, drawer voucher, takings),
|
||||
// each opening a modal. `canManage` = shift:create (start/end + raise vouchers); a
|
||||
// voucher additionally needs an admin's password sign-off server-side.
|
||||
// The CURRENT shift's pane carries the actions (open/close, takings), each opening a
|
||||
// modal. `canManage` = shift:create (start/end). Drawer cash movements moved to /drawer
|
||||
// (2026-07-01).
|
||||
return <ShiftsHistory user={user} canManage={can(user, "shift:create")} />;
|
||||
},
|
||||
});
|
||||
|
||||
const drawerRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/drawer",
|
||||
// Reachable by anyone who can record OR review; the component shows the right view per
|
||||
// permission. Guard on the broader of the two (create) so a review-only admin still gets
|
||||
// in — the redirect only fires if the user has NEITHER, which the nav already hides.
|
||||
beforeLoad: ({ context }) => {
|
||||
if (!can(context.user, "drawer:create") && !can(context.user, "drawer:review")) {
|
||||
throw redirect({ to: "/booth" });
|
||||
}
|
||||
},
|
||||
component: function DrawerRoute() {
|
||||
const { user } = rootRoute.useRouteContext();
|
||||
return (
|
||||
<ShiftsHistory
|
||||
user={user}
|
||||
canManage={can(user, "shift:create")}
|
||||
canVoucher={can(user, "shift:create")}
|
||||
<DrawerManager
|
||||
canCreate={can(user, "drawer:create")}
|
||||
canReview={can(user, "drawer:review")}
|
||||
/>
|
||||
);
|
||||
},
|
||||
@@ -610,8 +708,20 @@ const tariffRoute = createRoute({
|
||||
getParentRoute: () => setupRoute,
|
||||
path: "tariff",
|
||||
beforeLoad: ({ context }) => requirePerm("tariff:read")(context),
|
||||
component: TariffLayout,
|
||||
});
|
||||
const tariffComposerRoute = createRoute({
|
||||
getParentRoute: () => tariffRoute,
|
||||
path: "/",
|
||||
component: () => <TariffComposer />,
|
||||
});
|
||||
// The tariff LAB — lives with the tariff it tests (moved from /subscriptions,
|
||||
// 2026-07-05). The parent's tariff:read gate covers it.
|
||||
const tariffLabRoute = createRoute({
|
||||
getParentRoute: () => tariffRoute,
|
||||
path: "lab",
|
||||
component: () => <TariffLab />,
|
||||
});
|
||||
|
||||
// --- /subscriptions — a standalone top-level section with its own tabs. The catalog
|
||||
// (index), the plan catalog, and the tariff lab live here, not under /setup. ---
|
||||
@@ -628,7 +738,6 @@ const subscriptionsIndexRoute = createRoute({
|
||||
beforeLoad: ({ context }) => {
|
||||
if (can(context.user, "subscription:read")) return;
|
||||
if (can(context.user, "subscription:plan")) throw redirect({ to: "/subscriptions/plans" });
|
||||
if (can(context.user, "tariff:read")) throw redirect({ to: "/subscriptions/tariff-lab" });
|
||||
throw redirect({ to: "/booth" });
|
||||
},
|
||||
component: function SubscriptionsRoute() {
|
||||
@@ -642,12 +751,6 @@ const subscriptionPlansRoute = createRoute({
|
||||
beforeLoad: ({ context }) => requirePerm("subscription:plan")(context),
|
||||
component: () => <SubscriptionPlansManager />,
|
||||
});
|
||||
const tariffLabRoute = createRoute({
|
||||
getParentRoute: () => subscriptionsRoute,
|
||||
path: "tariff-lab",
|
||||
beforeLoad: ({ context }) => requirePerm("tariff:read")(context),
|
||||
component: () => <TariffLab />,
|
||||
});
|
||||
const siteRoute = createRoute({
|
||||
getParentRoute: () => setupRoute,
|
||||
path: "site",
|
||||
@@ -720,18 +823,19 @@ const profileRoute = createRoute({
|
||||
const routeTree = rootRoute.addChildren([
|
||||
indexRoute,
|
||||
boothRoute,
|
||||
validateRoute,
|
||||
...legacyRedirects,
|
||||
profileRoute,
|
||||
shiftRoute,
|
||||
drawerRoute,
|
||||
reportsRoute,
|
||||
subscriptionsRoute.addChildren([
|
||||
subscriptionsIndexRoute,
|
||||
subscriptionPlansRoute,
|
||||
tariffLabRoute,
|
||||
]),
|
||||
setupRoute.addChildren([
|
||||
setupDevicesRoute,
|
||||
tariffRoute,
|
||||
tariffRoute.addChildren([tariffComposerRoute, tariffLabRoute]),
|
||||
siteRoute,
|
||||
usersRoute,
|
||||
rolesRoute,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchDeviceStatus, type DeviceStatus } from "../api.js";
|
||||
import { formatClock } from "../lib/format.js";
|
||||
import { qk } from "../lib/query.js";
|
||||
import { useLiveStore } from "../lib/live-store.js";
|
||||
|
||||
@@ -184,7 +185,7 @@ export function DeviceFooter() {
|
||||
</div>
|
||||
{d.detail && <div className="mt-0.5 break-words text-[0.6875rem] text-term-muted">{d.detail}</div>}
|
||||
<div className="mt-0.5 text-[0.625rem] tabular-nums text-term-muted/70">
|
||||
{t("devices.checkedAt", { time: new Date(d.checkedAt).toLocaleTimeString() })}
|
||||
{t("devices.checkedAt", { time: formatClock(d.checkedAt) })}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { formatDateTime } from "../lib/format.js";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchSnapshots, snapshotImageUrl, type PlateRead } from "../api.js";
|
||||
|
||||
@@ -52,7 +53,7 @@ export function SnapshotStrip({ identity }: { identity: string }) {
|
||||
key={`${p.plate}-${p.direction}-${i}`}
|
||||
className="inline-flex items-center gap-1.5 rounded-term border border-term-cyan/40 bg-term-cyan/10 px-2 py-0.5 text-[0.6875rem]"
|
||||
title={`${dirLabel(p.direction)}${p.region ? ` · ${p.region}` : ""}${
|
||||
p.at ? ` · ${new Date(p.at).toLocaleString()}` : ""
|
||||
p.at ? ` · ${formatDateTime(p.at, t)}` : ""
|
||||
}`}
|
||||
>
|
||||
<span className="text-[0.5625rem] uppercase tracking-wider text-term-muted">{t("pay.plate")}</span>
|
||||
@@ -72,7 +73,7 @@ export function SnapshotStrip({ identity }: { identity: string }) {
|
||||
type="button"
|
||||
onClick={() => setZoom(s.id)}
|
||||
className="group flex flex-col items-center gap-1 rounded-term border border-term-border bg-term-panel-2 p-1 hover:border-term-amber"
|
||||
title={`${dirLabel(s.direction)} · ${new Date(s.capturedAt).toLocaleString()}`}
|
||||
title={`${dirLabel(s.direction)} · ${formatDateTime(s.capturedAt, t)}`}
|
||||
>
|
||||
<img
|
||||
src={snapshotImageUrl(s.id)}
|
||||
@@ -97,7 +98,7 @@ export function SnapshotStrip({ identity }: { identity: string }) {
|
||||
<div
|
||||
key={`fail-${f.direction ?? "both"}-${i}`}
|
||||
className="flex h-[6.75rem] w-28 flex-col items-center justify-center gap-1 rounded-term border border-dashed border-term-amber/60 bg-term-amber/5 p-1 text-center"
|
||||
title={`${dirLabel(f.direction)} · ${f.error}${f.occurredAt ? ` · ${new Date(f.occurredAt).toLocaleString()}` : ""}`}
|
||||
title={`${dirLabel(f.direction)} · ${f.error}${f.occurredAt ? ` · ${formatDateTime(f.occurredAt, t)}` : ""}`}
|
||||
>
|
||||
<span className="text-lg leading-none text-term-amber">⚠</span>
|
||||
<span className="text-[0.5625rem] uppercase tracking-wider text-term-amber">{dirLabel(f.direction)}</span>
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
// Inline busy indicator for buttons whose action can take a moment (opening a
|
||||
// shift signs an event over the whole chain; printing waits on hardware). A label
|
||||
// swap alone ("Opening…") proved too subtle on the booth — operators re-clicked or
|
||||
// assumed the click was lost, so busy buttons pair the text with this spinner.
|
||||
// Inherits the button's text colour via border-current.
|
||||
export function Spinner() {
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
className="inline-block h-3 w-3 animate-spin rounded-full border-2 border-current border-t-transparent align-[-1px]"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { type ReactNode } from "react";
|
||||
import { type LedgerEvent } from "../api.js";
|
||||
import { formatMoney } from "../lib/format.js";
|
||||
import { formatMoney, formatDateTime } from "../lib/format.js";
|
||||
import { renderReason } from "../lib/reason.js";
|
||||
import { Modal } from "./Modal.js";
|
||||
import { SnapshotStrip } from "./SnapshotStrip.js";
|
||||
@@ -23,6 +23,9 @@ export const EVENT_STYLE: Record<string, { labelKey: string; color: string }> =
|
||||
cash_movement: { labelKey: "booth.evtCashMovement", color: "text-term-cyan" },
|
||||
cash_in: { labelKey: "booth.evtCashIn", color: "text-term-green" },
|
||||
cash_out: { labelKey: "booth.evtCashOut", color: "text-term-amber" },
|
||||
cash_review: { labelKey: "booth.evtCashReview", color: "text-term-cyan" },
|
||||
config_change: { labelKey: "booth.evtConfigChange", color: "text-term-amber" },
|
||||
validation: { labelKey: "booth.evtValidation", color: "text-term-green" },
|
||||
anomaly: { labelKey: "booth.evtAnomaly", color: "text-term-red" },
|
||||
};
|
||||
|
||||
@@ -187,6 +190,12 @@ export function EventDetailModal({ e, onClose }: { e: LedgerEvent; onClose: () =
|
||||
const category = typeof p?.category === "string" ? p.category : null;
|
||||
const operator = typeof p?.operator === "string" ? p.operator : null;
|
||||
const tariffVersionId = typeof p?.tariffVersionId === "string" ? p.tariffVersionId : null;
|
||||
// cash_review fields: the admin's decision on a drawer movement (+ who / note / the
|
||||
// reviewed movement id). A flag only — it never moves cash. See wiki/concepts/shift.md.
|
||||
const decision = p?.decision === "authorize" || p?.decision === "deny" ? p.decision : null;
|
||||
const reviewedBy = typeof p?.reviewedBy === "string" ? p.reviewedBy : null;
|
||||
const reviewNote = typeof p?.note === "string" ? p.note : null;
|
||||
const refId = typeof p?.refId === "string" ? p.refId : null;
|
||||
|
||||
return (
|
||||
<Modal open onClose={onClose} title={t("booth.eventDetail")} width="max-w-2xl">
|
||||
@@ -218,7 +227,7 @@ export function EventDetailModal({ e, onClose }: { e: LedgerEvent; onClose: () =
|
||||
|
||||
{/* Humanized fields — labelled rows, not raw JSON. Only what applies renders. */}
|
||||
<div>
|
||||
<DetailRow label={t("booth.edTime")}>{new Date(e.occurredAt).toLocaleString()}</DetailRow>
|
||||
<DetailRow label={t("booth.edTime")}>{formatDateTime(e.occurredAt, t, { seconds: true })}</DetailRow>
|
||||
<DetailRow label={t("booth.edIndex")}>#{e.index}</DetailRow>
|
||||
{e.direction && <DetailRow label={t("booth.edDirection")}>{e.direction}</DetailRow>}
|
||||
{e.source && <DetailRow label={t("booth.edSource")}>{e.source}</DetailRow>}
|
||||
@@ -244,6 +253,21 @@ export function EventDetailModal({ e, onClose }: { e: LedgerEvent; onClose: () =
|
||||
{category && <DetailRow label={t("booth.edCategory")}>{category}</DetailRow>}
|
||||
{plate && <DetailRow label={t("booth.edPlate")}>{plate}</DetailRow>}
|
||||
{operator && <DetailRow label={t("booth.edOperator")}>{operator}</DetailRow>}
|
||||
{/* cash_review: the admin's decision + who + why (for a denial). */}
|
||||
{decision && (
|
||||
<DetailRow label={t("booth.edDecision")}>
|
||||
<span className={decision === "authorize" ? "text-term-green" : "text-term-red"}>
|
||||
{t(`booth.decision.${decision}`)}
|
||||
</span>
|
||||
</DetailRow>
|
||||
)}
|
||||
{reviewedBy && <DetailRow label={t("booth.edReviewedBy")}>{reviewedBy}</DetailRow>}
|
||||
{reviewNote && <DetailRow label={t("booth.edReviewNote")}>{reviewNote}</DetailRow>}
|
||||
{refId && (
|
||||
<DetailRow label={t("booth.edReviewRef")}>
|
||||
<code className="text-[0.6875rem] text-term-muted">{refId}</code>
|
||||
</DetailRow>
|
||||
)}
|
||||
{sessionRef && sessionRef !== e.identity && (
|
||||
<DetailRow label={t("booth.edSession")}>{sessionRef}</DetailRow>
|
||||
)}
|
||||
|
||||
+13
-5
@@ -26,11 +26,12 @@ services:
|
||||
- caddy-config:/config
|
||||
depends_on:
|
||||
- server
|
||||
# ≈2 months (see the server note): Caddy logs errors only — 10 MB × 5 is plenty.
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
max-file: "5"
|
||||
|
||||
server:
|
||||
restart: always
|
||||
@@ -73,11 +74,17 @@ services:
|
||||
- /dev/usb:/dev/usb
|
||||
device_cgroup_rules:
|
||||
- "c 180:* rmw"
|
||||
# LOG ROTATION (2026-07-04). Docker's json-file driver rotates by SIZE, not time —
|
||||
# these caps are sized to hold ≈2 MONTHS at observed booth rates (the operator's
|
||||
# chosen diagnostic window; revisit if `docker logs` shows less than ~60 days of
|
||||
# history). Server gets the most (request + device chatter): 20 MB × 30 = 600 MB
|
||||
# ceiling. NB: `docker logs` only reaches back as far as these files. The queryable
|
||||
# warn+ store (app_logs, /setup/logs) has its own matching 60-day retention.
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
max-size: "20m"
|
||||
max-file: "30"
|
||||
|
||||
vision:
|
||||
restart: always
|
||||
@@ -89,11 +96,12 @@ services:
|
||||
VISION_RECOGNIZER: fast_alpr
|
||||
ports:
|
||||
- "127.0.0.1:8089:8089"
|
||||
# ≈2 months (see the server note): vision logs less — 20 MB × 10 = 200 MB ceiling.
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
max-size: "20m"
|
||||
max-file: "10"
|
||||
|
||||
volumes:
|
||||
caddy-data:
|
||||
|
||||
@@ -68,9 +68,10 @@ WS_ALLOWED_ORIGINS=
|
||||
# # (multi-NIC hosts; usually auto-detected fine)
|
||||
# WEB_DIST_DIR= # where the built SPA lives (the image sets it)
|
||||
# --- logging ---
|
||||
# LOG_LEVEL=info # debug|info|warn|error
|
||||
# LOG_RETENTION_DAYS=30 # app_logs auto-purge age
|
||||
# LOG_RETENTION_MAX_ROWS=50000 # app_logs row cap
|
||||
# LOG_LEVEL=info # trace|debug|info|warn|error|fatal (pino)
|
||||
# LOG_RETENTION_DAYS=60 # app_logs auto-purge age (~2 months, matches the
|
||||
# # container-log rotation caps in compose.prod)
|
||||
# LOG_RETENTION_MAX_ROWS=50000 # app_logs row cap (burst guard regardless of age)
|
||||
# RECYCLE_BIN_RETENTION_DAYS=30 # soft-deleted items auto-purge age (0 = keep forever)
|
||||
# --- snapshots (camera evidence; stored re-encoded, then pruned under disk pressure) ---
|
||||
# SNAPSHOT_MAX_EDGE=1280 # downscale long edge (px) before storing
|
||||
@@ -84,7 +85,11 @@ WS_ALLOWED_ORIGINS=
|
||||
# DEVICE_POLL_MS=8000 # device health poll interval
|
||||
# PRINTER_POLL_MS=5000 # printer status poll interval
|
||||
# LANE_BUSY_TTL_MS=30000 # how long a lane stays "busy" after a vehicle push
|
||||
# CAPTURE_TTL_MS=30000 # snapshot evidence cache TTL
|
||||
# CAPTURE_TTL_MS=30000 # credential-enrollment arm window (card capture form)
|
||||
# --- readers (DT-008 channel tagging — must MATCH the vendor tool's output prefixes;
|
||||
# see wiki/entities/dingtian-dt008-reader.md) ---
|
||||
# READER_QR_PREFIX=Q: # optical-decode marker the reader prepends
|
||||
# READER_CARD_PREFIX=K: # RF-read marker the reader prepends
|
||||
# --- ANPR / vision (server side) ---
|
||||
# VISION_URL is compose-set (above). These are the knobs you may tweak per booth:
|
||||
# VISION_TIMEOUT_MS=1500 # per /analyze call timeout
|
||||
@@ -98,6 +103,8 @@ WS_ALLOWED_ORIGINS=
|
||||
# # barrier means the car waits >8s before reading clean.
|
||||
# ANPR_POLL_MAX_MS=30000 # hard ceiling on one loop from start (so a continuously
|
||||
# # busy lane can't slide the window forever)
|
||||
# ENTRY_DUP_PLATE_WINDOW_MIN=15 # entry ANPR duplicate-plate lookback (same plate already
|
||||
# # OPEN under another session → signed anomaly)
|
||||
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
# VISION CONTAINER env (the Python ANPR service — its OWN process, prefix VISION_)
|
||||
|
||||
+31
-1
@@ -49,7 +49,7 @@ REGISTRY=git.infra.msai.al/mca/parking_solution
|
||||
# Staging booth: pinned immutable stage-<sha>. After each promotion (merge dev → stage, CI builds
|
||||
# :stage-<sha>), bump this to the new sha and re-sync/deploy from Core. The moving `:stage` tag
|
||||
# exists as the pointer; we deploy the sha, not the mover.
|
||||
TAG=stage-39c778f
|
||||
TAG=stage-28bd838
|
||||
COOKIE_SECURE=0
|
||||
VISION_ENABLED=1
|
||||
WS_ALLOWED_ORIGINS=
|
||||
@@ -57,3 +57,33 @@ JWT_SECRET=[[park_buzi_jwt_secret]]
|
||||
EVENT_SIGNING_KEY=[[park_buzi_event_signing_key]]
|
||||
BACKUP_KEY=[[park_buzi_backup_key]]
|
||||
"""
|
||||
|
||||
##############################################################################
|
||||
|
||||
[[stack]]
|
||||
name = "park-2"
|
||||
[stack.config]
|
||||
server = "park-2"
|
||||
git_provider = "git.infra.msai.al"
|
||||
git_account = "komodo"
|
||||
repo = "mca/parking_solution"
|
||||
branch = "stage"
|
||||
file_paths = [
|
||||
"docker-compose.yml",
|
||||
"docker-compose.prod.yml"
|
||||
]
|
||||
registry_provider = "git.infra.msai.al"
|
||||
registry_account = "komodo"
|
||||
environment = """
|
||||
REGISTRY=git.infra.msai.al/mca/parking_solution
|
||||
# Staging booth: pinned immutable stage-<sha>. After each promotion (merge dev → stage, CI builds
|
||||
# :stage-<sha>), bump this to the new sha and re-sync/deploy from Core. The moving `:stage` tag
|
||||
# exists as the pointer; we deploy the sha, not the mover.
|
||||
TAG=stage-28bd838
|
||||
COOKIE_SECURE=0
|
||||
VISION_ENABLED=1
|
||||
WS_ALLOWED_ORIGINS=
|
||||
JWT_SECRET=[[park_2_jwt_secret]]
|
||||
EVENT_SIGNING_KEY=[[park_2_event_signing_key]]
|
||||
BACKUP_KEY=[[park_2_backup_key]]
|
||||
"""
|
||||
|
||||
+2
-1
@@ -12,7 +12,8 @@
|
||||
"lint": "turbo run lint",
|
||||
"typecheck": "turbo run typecheck",
|
||||
"test": "turbo run test",
|
||||
"seed:admin": "pnpm --filter @parking/server seed-admin"
|
||||
"seed:admin": "pnpm --filter @parking/server seed-admin",
|
||||
"db:reset": "pnpm --filter @parking/db db:reset"
|
||||
},
|
||||
"devDependencies": {
|
||||
"turbo": "2.9.18",
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
-- Drawer redesign (2026-07-01): operators RECORD cash movements freely; admins REVIEW them
|
||||
-- after the fact (authorize/deny — a flag, not a reversal). New `drawer` resource with two
|
||||
-- permissions in @parking/shared: drawer:create + drawer:review.
|
||||
--
|
||||
-- The built-in `admin` role gets ALL permissions in code (auth.ts ADMIN_PERMS = new
|
||||
-- Set(PERMISSIONS)), so it needs NO seed row here. This grants the default `operator` role
|
||||
-- the ability to record movements (drawer:create) — matching the prior behaviour where an
|
||||
-- operator could raise a voucher. An admin can revoke it per-role in the Roles UI (it's just
|
||||
-- data). drawer:review is admin-only, so it is NOT granted to operator.
|
||||
--
|
||||
-- Idempotent: role_permissions has a UNIQUE(role_id, permission) index, so re-running is a
|
||||
-- no-op via OR IGNORE. See wiki/concepts/shift.md.
|
||||
INSERT OR IGNORE INTO `role_permissions` (`role_id`, `permission`) VALUES
|
||||
('operator','drawer:create');
|
||||
@@ -0,0 +1,9 @@
|
||||
-- Operator-issued entry (2026-07-01): when the physical entry button is broken, an operator
|
||||
-- may ISSUE an entry ticket (a flagged mint, gated on real vehicle presence — radar + camera).
|
||||
-- New permission `session:create` in @parking/shared. The built-in `admin` role gets ALL
|
||||
-- permissions in code (auth.ts ADMIN_PERMS), so no seed row is needed for it. This grants the
|
||||
-- default `operator` role the ability to issue — an admin can revoke it per-role in the Roles
|
||||
-- UI (it's data). Idempotent via the UNIQUE(role_id, permission) index.
|
||||
-- See wiki/concepts/operator-issued-entry.md.
|
||||
INSERT OR IGNORE INTO `role_permissions` (`role_id`, `permission`) VALUES
|
||||
('operator','session:create');
|
||||
@@ -0,0 +1,12 @@
|
||||
-- Entry presence-gate bypass (2026-07-02). The operator-issued entry button (and the
|
||||
-- physical entry button) require a REAL vehicle at the barrier: radar/loop presence AND a
|
||||
-- camera vehicle-detection. When a device is FAULTY (dead radar, dead camera), that gate
|
||||
-- blocks legitimate transient entry. These flags let the ADMIN drop a specific signal as a
|
||||
-- requirement until support fixes the hardware. Granular on purpose: a faulty camera drops
|
||||
-- only the camera check (radar still gates); a faulty radar drops only radar. Both null/0 =
|
||||
-- the normal both-required gate. Enabling/disabling is ALSO signed into the ledger
|
||||
-- (config_change) — the admin is not the adversary, but weakening an anti-fraud gate stays
|
||||
-- attributed + auditable. See wiki/concepts/entry-presence-bypass.md.
|
||||
ALTER TABLE `site_config` ADD `bypass_presence_radar` integer DEFAULT 0 NOT NULL;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `site_config` ADD `bypass_presence_camera` integer DEFAULT 0 NOT NULL;
|
||||
@@ -0,0 +1,14 @@
|
||||
-- Tariff-lab drafts (2026-07-05). A mutable scratchpad for the lab: the admin composes
|
||||
-- experimental rate cards here, simulates them against hypothetical stays, and only
|
||||
-- PUBLISHES (normal immutable tariff_versions path) when satisfied. Deliberately mutable —
|
||||
-- a draft prices nothing and signs nothing; experimenting through real publishes would
|
||||
-- churn permanent versions and risk a wrong card going live. See wiki/concepts/tariff.md.
|
||||
CREATE TABLE `tariff_drafts` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`currency` text NOT NULL,
|
||||
`structure` text NOT NULL,
|
||||
`created_by` text,
|
||||
`created_at` text DEFAULT (current_timestamp) NOT NULL,
|
||||
`updated_at` text DEFAULT (current_timestamp) NOT NULL
|
||||
);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user