Compare commits
62 Commits
d5ff2097bd
...
v0.1.1
| Author | SHA1 | Date | |
|---|---|---|---|
| faa3265e49 | |||
| 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 | |||
| 493210bbb0 | |||
| 72ad504b8d | |||
| 365b648282 | |||
| d9829eb61f | |||
| 93f9ebea05 | |||
| 44f34d68c4 | |||
| 9d73561855 |
@@ -92,6 +92,8 @@ jobs:
|
|||||||
context: .
|
context: .
|
||||||
file: apps/server/Dockerfile
|
file: apps/server/Dockerfile
|
||||||
push: true
|
push: true
|
||||||
|
build-args: |
|
||||||
|
BUILD_VERSION=${{ steps.meta.outputs.branch }}-${{ steps.meta.outputs.sha }}
|
||||||
tags: |
|
tags: |
|
||||||
${{ env.REGISTRY }}/parking-server:${{ steps.meta.outputs.branch }}
|
${{ env.REGISTRY }}/parking-server:${{ steps.meta.outputs.branch }}
|
||||||
${{ env.REGISTRY }}/parking-server:${{ steps.meta.outputs.branch }}-${{ steps.meta.outputs.sha }}
|
${{ env.REGISTRY }}/parking-server:${{ steps.meta.outputs.branch }}-${{ steps.meta.outputs.sha }}
|
||||||
|
|||||||
+114
-12
@@ -1,12 +1,24 @@
|
|||||||
name: Release desktop
|
name: Release desktop
|
||||||
|
|
||||||
# Build the signed Tauri desktop installers on a version tag and publish them as
|
# 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)
|
# a Gitea Release — TWICE: once on this (private, source) repo for our own
|
||||||
# fetches these; latest.json + each installer + its .sig are what it needs.
|
# 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
|
# 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
|
# with the updater key (Gitea secrets), assembles latest.json pointing at the
|
||||||
# everything to the Release for that tag.
|
# MIRROR repo's asset URLs, uploads to both repos, and mirrors the same assets.
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -73,30 +85,41 @@ jobs:
|
|||||||
|
|
||||||
- name: Collect artifacts
|
- name: Collect artifacts
|
||||||
id: collect
|
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: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
BUNDLE=apps/desktop/src-tauri/target/release/bundle
|
BUNDLE=apps/desktop/src-tauri/target/release/bundle
|
||||||
mkdir -p dist
|
mkdir -p dist
|
||||||
find "$BUNDLE" \( -name '*.AppImage' -o -name '*.deb' -o -name '*.rpm' \
|
find "$BUNDLE" \( -name '*.AppImage' -o -name '*.deb' -o -name '*.rpm' \
|
||||||
-o -name '*.AppImage.sig' -o -name '*.deb.sig' -o -name '*.rpm.sig' \) \
|
-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/
|
echo "Artifacts:"; ls -la dist/
|
||||||
|
|
||||||
- name: Assemble latest.json
|
- name: Assemble latest.json
|
||||||
# The Tauri updater fetches a manifest describing the newest version, its
|
# The Tauri updater fetches a manifest describing the newest version, its
|
||||||
# notes, and per-target {signature, url}. We point the AppImage target at
|
# notes, and per-target {signature, url}. The URL points at the MIRROR
|
||||||
# this release's asset URL. Adjust the platform keys you actually ship.
|
# 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:
|
env:
|
||||||
SERVER_URL: ${{ github.server_url }}
|
SERVER_URL: ${{ github.server_url }}
|
||||||
REPO: ${{ github.repository }}
|
MIRROR_REPO: mca/public_releases
|
||||||
TAG: ${{ github.ref_name }}
|
TAG: ${{ github.ref_name }}
|
||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
VERSION="${TAG#v}"
|
VERSION="${TAG#v}"
|
||||||
APPIMAGE=$(cd dist && ls *.AppImage | head -1)
|
APPIMAGE=$(cd dist && ls *.AppImage | head -1)
|
||||||
SIG=$(cat "dist/${APPIMAGE}.sig")
|
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
|
cat > dist/latest.json <<JSON
|
||||||
{
|
{
|
||||||
"version": "${VERSION}",
|
"version": "${VERSION}",
|
||||||
@@ -129,12 +152,12 @@ jobs:
|
|||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d "{\"tag_name\":\"${TAG}\",\"name\":\"${TAG}\",\"draft\":false,\"prerelease\":false}" \
|
-d "{\"tag_name\":\"${TAG}\",\"name\":\"${TAG}\",\"draft\":false,\"prerelease\":false}" \
|
||||||
"${API}/repos/${REPO}/releases" || true)
|
"${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
|
if [ -z "$REL_ID" ]; then
|
||||||
# Release may already exist for this tag — look it up by tag.
|
# Release may already exist for this tag — look it up by tag.
|
||||||
REL_ID=$(curl -sS -H "Authorization: token ${TOKEN}" \
|
REL_ID=$(curl -sS -H "Authorization: token ${TOKEN}" \
|
||||||
"${API}/repos/${REPO}/releases/tags/${TAG}" \
|
"${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
|
fi
|
||||||
echo "release id: ${REL_ID}"
|
echo "release id: ${REL_ID}"
|
||||||
for f in dist/*; do
|
for f in dist/*; do
|
||||||
@@ -147,3 +170,82 @@ jobs:
|
|||||||
"${API}/repos/${REPO}/releases/${REL_ID}/assets?name=${name}" >/dev/null
|
"${API}/repos/${REPO}/releases/${REL_ID}/assets?name=${name}" >/dev/null
|
||||||
done
|
done
|
||||||
echo "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"
|
||||||
|
|||||||
@@ -27,3 +27,4 @@ dist/
|
|||||||
# Graphify knowledge-graph output (dev tool; generated, not committed)
|
# Graphify knowledge-graph output (dev tool; generated, not committed)
|
||||||
graphify-out/
|
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
|
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).
|
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)
|
## Not here (deliberately)
|
||||||
|
|
||||||
Kiosk lockdown (fullscreen/no-decorations), auto-update, code signing, and launching Fastify from
|
Kiosk lockdown (fullscreen/no-decorations) and launching Fastify from the shell are out of scope for
|
||||||
the shell are out of scope for the scaffold — on the appliance Fastify runs as its own service and
|
the scaffold — on the appliance Fastify runs as its own service and this shell connects to it.
|
||||||
this shell connects to it.
|
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "Parking System",
|
"productName": "Parking System",
|
||||||
"version": "0.0.0",
|
"version": "0.1.0",
|
||||||
"identifier": "com.parking.desktop",
|
"identifier": "com.parking.desktop",
|
||||||
"build": {
|
"build": {
|
||||||
"devUrl": "http://localhost:5173",
|
"devUrl": "http://localhost:5173",
|
||||||
"frontendDist": "../../web/dist",
|
"frontendDist": "../../web/dist",
|
||||||
"beforeDevCommand": "pnpm --filter @parking/web dev",
|
"beforeDevCommand": "pnpm --filter @parking/web dev",
|
||||||
"beforeBuildCommand": "pnpm --filter @parking/web build"
|
"beforeBuildCommand": "VITE_API_BASE=http://127.0.0.1:3000 pnpm --filter @parking/web build"
|
||||||
},
|
},
|
||||||
"app": {
|
"app": {
|
||||||
"windows": [
|
"windows": [
|
||||||
@@ -41,9 +41,9 @@
|
|||||||
},
|
},
|
||||||
"plugins": {
|
"plugins": {
|
||||||
"updater": {
|
"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": [
|
"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"
|
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDgxNzg5RUQ1QkM0Q0FDRjYKUldUMnJFeTgxWjU0Z1RlNmhneDVZQlVVTVZZdGhJTkUxTGdDeGYwQSttZmNKVVp5WEdVMWlBb1YK"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,11 @@ RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
|
|||||||
# ---- runtime: slim, non-root ----
|
# ---- runtime: slim, non-root ----
|
||||||
FROM node:22-alpine AS runtime
|
FROM node:22-alpine AS runtime
|
||||||
WORKDIR /app
|
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
|
ENV NODE_ENV=production
|
||||||
RUN apk add --no-cache libstdc++ # better-sqlite3 native runtime
|
RUN apk add --no-cache libstdc++ # better-sqlite3 native runtime
|
||||||
RUN addgroup -S app && adduser -S -G app app
|
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 require = createRequire(import.meta.url);
|
||||||
const bcrypt = require("bcrypt");
|
const bcrypt = require("bcrypt");
|
||||||
const { createDb, users, eq } = require("@parking/db");
|
const { createDb, users, roles, eq } = require("@parking/db");
|
||||||
|
|
||||||
const DEFAULT_USERNAME = "admin";
|
const DEFAULT_USERNAME = "admin";
|
||||||
|
|
||||||
@@ -53,6 +53,14 @@ if (!password || password.length < 8) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const db = createDb();
|
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();
|
const existing = await db.select().from(users).where(eq(users.username, username)).get();
|
||||||
if (existing && process.env.FORCE !== "1") {
|
if (existing && process.env.FORCE !== "1") {
|
||||||
console.error(`user "${username}" already exists (set FORCE=1 to reset the password)`);
|
console.error(`user "${username}" already exists (set FORCE=1 to reset the password)`);
|
||||||
@@ -73,4 +81,30 @@ if (existing) {
|
|||||||
});
|
});
|
||||||
console.log(`created admin "${username}"`);
|
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);
|
process.exit(0);
|
||||||
|
|||||||
@@ -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
|
// 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
|
// show last-success / last-error, and serializes concurrent runs (manual + timer). See
|
||||||
// wiki/concepts/backup-recovery.md.
|
// 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. */
|
/** The dedicated backup-encryption key, from env (NOT the DB). Separate from EVENT_SIGNING_KEY. */
|
||||||
export function backupKeyFromEnv(): string {
|
export function backupKeyFromEnv(): string {
|
||||||
@@ -65,16 +71,33 @@ export class BackupService {
|
|||||||
readonly #logger?: FastifyBaseLogger;
|
readonly #logger?: FastifyBaseLogger;
|
||||||
|
|
||||||
#running = false;
|
#running = false;
|
||||||
#lastSuccessAt: string | null = null;
|
|
||||||
#lastResult: BackupResult | null = null;
|
|
||||||
#lastErrorAt: string | null = null;
|
|
||||||
#lastError: string | null = null;
|
|
||||||
|
|
||||||
constructor(db: Db, logger?: FastifyBaseLogger) {
|
constructor(db: Db, logger?: FastifyBaseLogger) {
|
||||||
this.#db = db;
|
this.#db = db;
|
||||||
this.#logger = logger;
|
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. */
|
/** The admin-chosen target dir from site_config (null/empty = unset). Read fresh each call. */
|
||||||
targetDir(): string | null {
|
targetDir(): string | null {
|
||||||
const row = this.#db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
const row = this.#db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||||
@@ -104,6 +127,15 @@ export class BackupService {
|
|||||||
|
|
||||||
status(): BackupStatus {
|
status(): BackupStatus {
|
||||||
const r = this.retention();
|
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 {
|
return {
|
||||||
configured: this.configured,
|
configured: this.configured,
|
||||||
targetDir: this.targetDir(),
|
targetDir: this.targetDir(),
|
||||||
@@ -111,12 +143,10 @@ export class BackupService {
|
|||||||
keepDailyDays: r.keepDailyDays,
|
keepDailyDays: r.keepDailyDays,
|
||||||
keyPresent: this.keyPresent,
|
keyPresent: this.keyPresent,
|
||||||
running: this.#running,
|
running: this.#running,
|
||||||
lastSuccessAt: this.#lastSuccessAt,
|
lastSuccessAt: row?.backupLastSuccessAt ?? null,
|
||||||
lastResult: this.#lastResult
|
lastResult,
|
||||||
? { path: this.#lastResult.path, bytes: this.#lastResult.bytes, prunedFiles: this.#lastResult.prunedFiles }
|
lastErrorAt: row?.backupLastErrorAt ?? null,
|
||||||
: null,
|
lastError: row?.backupLastError ?? null,
|
||||||
lastErrorAt: this.#lastErrorAt,
|
|
||||||
lastError: this.#lastError,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,14 +169,17 @@ export class BackupService {
|
|||||||
try {
|
try {
|
||||||
this.#logger?.info(`backup: starting (${trigger}) → ${targetDir}`);
|
this.#logger?.info(`backup: starting (${trigger}) → ${targetDir}`);
|
||||||
const res = await runBackup(this.#db, { targetDir, key, retention: this.retention() }, this.#logger);
|
const res = await runBackup(this.#db, { targetDir, key, retention: this.retention() }, this.#logger);
|
||||||
this.#lastResult = res;
|
this.#persist({
|
||||||
this.#lastSuccessAt = new Date().toISOString();
|
backupLastSuccessAt: new Date().toISOString(),
|
||||||
this.#lastError = null;
|
backupLastResultJson: JSON.stringify({ path: res.path, bytes: res.bytes, prunedFiles: res.prunedFiles }),
|
||||||
|
backupLastErrorAt: null,
|
||||||
|
backupLastError: null,
|
||||||
|
});
|
||||||
return res;
|
return res;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.#lastError = (err as Error).message;
|
const message = (err as Error).message;
|
||||||
this.#lastErrorAt = new Date().toISOString();
|
this.#persist({ backupLastErrorAt: new Date().toISOString(), backupLastError: message });
|
||||||
this.#logger?.error(`backup: failed (${trigger}): ${this.#lastError}`);
|
this.#logger?.error(`backup: failed (${trigger}): ${message}`);
|
||||||
throw err;
|
throw err;
|
||||||
} finally {
|
} finally {
|
||||||
this.#running = false;
|
this.#running = false;
|
||||||
@@ -156,13 +189,34 @@ export class BackupService {
|
|||||||
return this.#inflight;
|
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> {
|
async runScheduled(): Promise<void> {
|
||||||
if (!this.configured) return; // silent no-op when backups aren't set up
|
if (!this.configured) return; // silent no-op when backups aren't set up
|
||||||
|
if (!this.isDue()) return;
|
||||||
try {
|
try {
|
||||||
await this.run("scheduled");
|
await this.run("scheduled");
|
||||||
} catch {
|
} catch {
|
||||||
/* recorded in last-error; already logged */
|
/* 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;
|
currency?: string;
|
||||||
tender?: "cash" | "card";
|
tender?: "cash" | "card";
|
||||||
graceExitMin?: number;
|
graceExitMin?: number;
|
||||||
|
grossMinor?: number;
|
||||||
|
validationLines?: { label: string; discountMinor: number }[];
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
ticketId,
|
ticketId,
|
||||||
@@ -89,6 +91,9 @@ function receiptFigures(
|
|||||||
currency: p.currency ?? "ALL",
|
currency: p.currency ?? "ALL",
|
||||||
tender: p.tender === "card" ? "card" : "cash",
|
tender: p.tender === "card" ? "card" : "cash",
|
||||||
graceExitMin: typeof p.graceExitMin === "number" ? p.graceExitMin : null,
|
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.
|
// First write (initial off) throws — must be swallowed.
|
||||||
expect(() => ctl.start()).not.toThrow();
|
expect(() => ctl.start()).not.toThrow();
|
||||||
await flush();
|
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);
|
lane(true);
|
||||||
radar(true);
|
radar(true);
|
||||||
await flush();
|
await flush();
|
||||||
expect(ctl.confirmedOf(CONTROLLER)).toBe(true);
|
expect(ctl.confirmedOf(CONTROLLER)).toBe(true);
|
||||||
|
expect(attempts).toBe(before + 1);
|
||||||
ctl.stop();
|
ctl.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,14 @@ type LightState = "off" | "solid" | "blink";
|
|||||||
|
|
||||||
const DEFAULT_BLINK_MS = 500;
|
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). */
|
/** Per-lamp live state for the alert rule (one per radarAlert relay). */
|
||||||
interface LampState {
|
interface LampState {
|
||||||
/** The controller this lamp lives on (its deviceId) — for resolving the aux adapter. */
|
/** 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
|
/** 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). */
|
* overlap or reorder (UDP is unordered; concurrent toggles left the relay stuck). */
|
||||||
sending: boolean;
|
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
|
/** Resolves a controller's live aux-output adapter. The default goes through the
|
||||||
@@ -111,6 +129,10 @@ export class ButtonLightController {
|
|||||||
desiredOn: false,
|
desiredOn: false,
|
||||||
confirmedOn: null,
|
confirmedOn: null,
|
||||||
sending: false,
|
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).
|
// Drop lamps whose controller no longer declares one (or was disabled/removed).
|
||||||
for (const [key, lamp] of this.#lamps) {
|
for (const [key, lamp] of this.#lamps) {
|
||||||
if (seen.has(key)) continue;
|
if (seen.has(key)) continue;
|
||||||
if (lamp.blink) {
|
this.#disarm(lamp);
|
||||||
clearInterval(lamp.blink);
|
|
||||||
lamp.blink = null;
|
|
||||||
}
|
|
||||||
this.#finalOff(lamp); // best-effort fail-OFF before forgetting it
|
this.#finalOff(lamp); // best-effort fail-OFF before forgetting it
|
||||||
this.#lamps.delete(key);
|
this.#lamps.delete(key);
|
||||||
}
|
}
|
||||||
@@ -207,10 +226,17 @@ export class ButtonLightController {
|
|||||||
* time. Because UDP is unordered, concurrent on/off sends previously raced and left
|
* 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
|
* 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 —
|
* (`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 {
|
#pump(lamp: LampState): void {
|
||||||
if (lamp.sending) return; // a send is already in flight; it'll re-check on completion
|
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 (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);
|
const aux = this.#resolveAux(lamp.controllerId);
|
||||||
if (!aux) return;
|
if (!aux) return;
|
||||||
const target = lamp.desiredOn;
|
const target = lamp.desiredOn;
|
||||||
@@ -219,15 +245,42 @@ export class ButtonLightController {
|
|||||||
.setAux(lamp.spec.relay, target)
|
.setAux(lamp.spec.relay, target)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
lamp.confirmedOn = target;
|
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) => {
|
.catch((err: unknown) => {
|
||||||
// Leave confirmedOn unchanged so the next pump retries this state. Never escalates.
|
// Leave confirmedOn unchanged so the armed retry re-asserts the (then-current)
|
||||||
this.#logger.error(`button-light setAux failed (${lamp.controllerId} R${lamp.spec.relay}): ${(err as Error).message}`);
|
// 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(() => {
|
.finally(() => {
|
||||||
lamp.sending = false;
|
lamp.sending = false;
|
||||||
// Desired state may have changed (or the send failed) while we were busy —
|
// Desired state may have changed while we were busy — re-pump to converge (the
|
||||||
// re-pump to converge. This is what makes the final state authoritative.
|
// 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);
|
if (lamp.confirmedOn !== lamp.desiredOn) this.#pump(lamp);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -261,20 +314,31 @@ export class ButtonLightController {
|
|||||||
this.#unsubInput = null;
|
this.#unsubInput = null;
|
||||||
this.#unsubLane = null;
|
this.#unsubLane = null;
|
||||||
for (const lamp of this.#lamps.values()) {
|
for (const lamp of this.#lamps.values()) {
|
||||||
if (lamp.blink) {
|
this.#disarm(lamp);
|
||||||
clearInterval(lamp.blink);
|
|
||||||
lamp.blink = null;
|
|
||||||
}
|
|
||||||
// Best-effort fail-OFF on shutdown.
|
// Best-effort fail-OFF on shutdown.
|
||||||
this.#finalOff(lamp);
|
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
|
/** 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
|
* 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 {
|
#finalOff(lamp: LampState): void {
|
||||||
lamp.desiredOn = false;
|
lamp.desiredOn = false;
|
||||||
|
lamp.retryAt = 0;
|
||||||
this.#pump(lamp);
|
this.#pump(lamp);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 type { FastifyBaseLogger } from "fastify";
|
||||||
import { devices, type Db, type DeviceRow } from "@parking/db";
|
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 { deviceEvents, type DeviceStatusEvent } from "./device-events.js";
|
||||||
import { directionOf, relaysOf } from "./device-resolve.js";
|
import { directionOf, relaysOf } from "./device-resolve.js";
|
||||||
import type { VisionClient } from "./vision-client.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,
|
/** 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). */
|
* 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);
|
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
|
* The device's ROLE descriptor for the footer (never the vendor). Direction-style
|
||||||
* tokens the client localises next to the category:
|
* tokens the client localises next to the category:
|
||||||
@@ -139,6 +174,7 @@ export class DeviceMonitor {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let next: DeviceStatusEvent;
|
let next: DeviceStatusEvent;
|
||||||
|
let device: Device | null = null;
|
||||||
const driver = registry.get(row.driverId);
|
const driver = registry.get(row.driverId);
|
||||||
if (!driver) {
|
if (!driver) {
|
||||||
// Configured against a driver that's no longer registered — surface it,
|
// 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() };
|
next = { ...base, state: "offline", detail: "driver not registered", checkedAt: new Date().toISOString() };
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
const device = driver.create(cfg as never);
|
device = driver.create(cfg as never);
|
||||||
// Printers expose richer paper/cover/cutter status; everything else uses
|
// Printers expose richer paper/cover/cutter status; everything else uses
|
||||||
// the generic reachability probe. Both flatten to the same traffic-light.
|
// the generic reachability probe. Both flatten to the same traffic-light.
|
||||||
if (isMonitorable(device)) {
|
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);
|
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). */
|
/** Cache + emit a status, but only when it CHANGED (state or detail). */
|
||||||
#publish(id: string, next: DeviceStatusEvent): void {
|
#publish(id: string, next: DeviceStatusEvent): void {
|
||||||
const prev = this.#latest.get(id);
|
const prev = this.#latest.get(id);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { beforeEach, describe, expect, it } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { appLogs, type Db } from "@parking/db";
|
import { appLogs, type Db } from "@parking/db";
|
||||||
import { createTestDb } from "@parking/db/testing";
|
import { createTestDb } from "@parking/db/testing";
|
||||||
import { LogService, pinoDbStream } from "./log-service.js";
|
import { LogService, pinoDbStream } from "./log-service.js";
|
||||||
@@ -52,3 +52,65 @@ describe("pinoDbStream level encodings", () => {
|
|||||||
expect(teed).toHaveLength(2);
|
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_STACK = 16_000;
|
||||||
const MAX_CONTEXT_JSON = 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 {
|
export interface LogRetention {
|
||||||
/** Delete logs older than this many days. */
|
/** Delete logs older than this many days. */
|
||||||
readonly maxAgeDays: number;
|
readonly maxAgeDays: number;
|
||||||
@@ -62,6 +70,16 @@ export class LogService {
|
|||||||
readonly #retention: LogRetention;
|
readonly #retention: LogRetention;
|
||||||
/** Reentrancy guard: never let persisting a log itself emit a persisted log. */
|
/** Reentrancy guard: never let persisting a log itself emit a persisted log. */
|
||||||
#writing = false;
|
#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) {
|
constructor(db: Db, retention: LogRetention = DEFAULT_RETENTION) {
|
||||||
this.#db = db;
|
this.#db = db;
|
||||||
@@ -85,22 +103,53 @@ export class LogService {
|
|||||||
if (this.#writing) return;
|
if (this.#writing) return;
|
||||||
this.#writing = true;
|
this.#writing = true;
|
||||||
try {
|
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
|
this.#db
|
||||||
.insert(appLogs)
|
.insert(appLogs)
|
||||||
.values({
|
.values({
|
||||||
id: randomUUID(),
|
id,
|
||||||
level: row.level,
|
level: row.level,
|
||||||
source: row.source,
|
source: row.source,
|
||||||
message: clamp(row.message, MAX_MESSAGE) ?? "",
|
message,
|
||||||
context: safeContext(row.context),
|
context: baseContext,
|
||||||
httpStatus: row.httpStatus ?? null,
|
httpStatus: row.httpStatus ?? null,
|
||||||
path: clamp(row.path, 512),
|
path,
|
||||||
stack: clamp(row.stack, MAX_STACK),
|
stack: clamp(row.stack, MAX_STACK),
|
||||||
userId: row.userId ?? null,
|
userId: row.userId ?? null,
|
||||||
userAgent: clamp(row.userAgent, 512),
|
userAgent: clamp(row.userAgent, 512),
|
||||||
createdAt: row.createdAt ?? new Date().toISOString(),
|
createdAt,
|
||||||
})
|
})
|
||||||
.run();
|
.run();
|
||||||
|
this.#last = { id, key, count: 1, firstAt: createdAt, lastAtMs: nowMs, baseContext };
|
||||||
} catch {
|
} catch {
|
||||||
// Swallow — diagnostics must never take down the path they observe. (Can't log
|
// Swallow — diagnostics must never take down the path they observe. (Can't log
|
||||||
// it; that's the recursion we're guarding against.)
|
// it; that's the recursion we're guarding against.)
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { desc, eq, ledgerEvents, sessions, subscriptions, tariffVersions, tariffs, type Db } from "@parking/db";
|
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 { FastifyBaseLogger } from "fastify";
|
||||||
import type { EventLog } from "./event-log.js";
|
import type { EventLog } from "./event-log.js";
|
||||||
import { plateForIdentity, platesForIdentities } from "./plate-lookup.js";
|
import { plateForIdentity, platesForIdentities } from "./plate-lookup.js";
|
||||||
import { windowOwedBetween } from "./subscription-window.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
|
// 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:
|
// 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
|
* 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). */
|
* "full stay minus paid" (which a daily cap collapses toward zero). */
|
||||||
readonly periodStart: string;
|
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;
|
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. */
|
/** True when this quote prices an overstay period (grace lapsed), not the first stay. */
|
||||||
readonly overstay: boolean;
|
readonly overstay: boolean;
|
||||||
readonly currency: string;
|
readonly currency: string;
|
||||||
@@ -117,6 +127,12 @@ export interface SessionLookup {
|
|||||||
/** Advisory licence plate recognized for this session (ANPR-on-snapshot). Null when
|
/** Advisory licence plate recognized for this session (ANPR-on-snapshot). Null when
|
||||||
* none. Display/audit only — never an access decision. */
|
* none. Display/audit only — never an access decision. */
|
||||||
readonly plate: string | null;
|
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 {
|
export class PayStation {
|
||||||
@@ -155,19 +171,28 @@ export class PayStation {
|
|||||||
// Pure pricing shared with the Tariff Lab (priceSession). Only the latest payment
|
// Pure pricing shared with the Tariff Lab (priceSession). Only the latest payment
|
||||||
// matters for grace/overstay; pass it through. Overstay → fresh period from
|
// matters for grace/overstay; pass it through. Overstay → fresh period from
|
||||||
// grace-expiry; within-grace → settled; unpaid → entry→now running total.
|
// 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 last = this.#lastPayment(identity);
|
||||||
|
const validations = liveValidations(this.#db, identity);
|
||||||
const p = priceSession(
|
const p = priceSession(
|
||||||
entry.occurredAt,
|
entry.occurredAt,
|
||||||
new Date().toISOString(),
|
new Date().toISOString(),
|
||||||
structure,
|
structure,
|
||||||
last ? [last] : [],
|
last ? [last] : [],
|
||||||
category,
|
category,
|
||||||
|
validations,
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
identity,
|
identity,
|
||||||
enteredAt: entry.occurredAt,
|
enteredAt: entry.occurredAt,
|
||||||
periodStart: p.periodStart,
|
periodStart: p.periodStart,
|
||||||
amountMinor: p.amountMinor,
|
amountMinor: p.amountMinor,
|
||||||
|
grossMinor: p.grossMinor,
|
||||||
|
discountMinor: p.discountMinor,
|
||||||
|
validationLines: p.validationLines,
|
||||||
|
validationIds: validations.map((v) => v.eventId),
|
||||||
overstay: p.overstay,
|
overstay: p.overstay,
|
||||||
currency: tv.currency,
|
currency: tv.currency,
|
||||||
tariffVersionId: tv.id,
|
tariffVersionId: tv.id,
|
||||||
@@ -246,6 +271,18 @@ export class PayStation {
|
|||||||
// The exit flow reads graceExitMin off the payment to validate the
|
// The exit flow reads graceExitMin off the payment to validate the
|
||||||
// walk-back window without re-resolving the tariff.
|
// walk-back window without re-resolving the tariff.
|
||||||
graceExitMin: q.graceExitMin,
|
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 } : {}),
|
...(overrideMinor != null ? { reason: "operator-set amount", quotedMinor: q.amountMinor } : {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -282,6 +319,7 @@ export class PayStation {
|
|||||||
paidAt: null, amountMinor: null, currency: null, paidMinor: null, paidCurrency: null,
|
paidAt: null, amountMinor: null, currency: null, paidMinor: null, paidCurrency: null,
|
||||||
withinGrace: false, graceExpiresAt: null,
|
withinGrace: false, graceExpiresAt: null,
|
||||||
overstay: false, subscription: false, subscriptionId: null, subscriptionHolder: null, plate: 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.
|
// Subscription occurrence? The entry payload carries permit:true + permitId.
|
||||||
@@ -319,11 +357,17 @@ export class PayStation {
|
|||||||
// exit gate clears. See wiki/entities/subscription.md.
|
// exit gate clears. See wiki/entities/subscription.md.
|
||||||
let amountMinor: number | null = null;
|
let amountMinor: number | null = null;
|
||||||
let currency: string | null = null;
|
let currency: string | null = null;
|
||||||
|
let grossMinor: number | null = null;
|
||||||
|
let discountMinor: number | null = null;
|
||||||
|
let validationLines: ValidationLine[] = [];
|
||||||
if (open && !isSubscription) {
|
if (open && !isSubscription) {
|
||||||
try {
|
try {
|
||||||
const q = this.quote(id);
|
const q = this.quote(id);
|
||||||
amountMinor = q.amountMinor;
|
amountMinor = q.amountMinor;
|
||||||
currency = q.currency;
|
currency = q.currency;
|
||||||
|
grossMinor = q.grossMinor;
|
||||||
|
discountMinor = q.discountMinor;
|
||||||
|
validationLines = q.validationLines;
|
||||||
} catch {
|
} catch {
|
||||||
/* no active tariff — leave null; modal shows session without a price */
|
/* no active tariff — leave null; modal shows session without a price */
|
||||||
}
|
}
|
||||||
@@ -344,6 +388,7 @@ export class PayStation {
|
|||||||
subscription: isSubscription, subscriptionId,
|
subscription: isSubscription, subscriptionId,
|
||||||
subscriptionHolder: this.#holderOf(subscriptionId),
|
subscriptionHolder: this.#holderOf(subscriptionId),
|
||||||
plate: plateForIdentity(this.#db, id)?.plate ?? null,
|
plate: plateForIdentity(this.#db, id)?.plate ?? null,
|
||||||
|
grossMinor, discountMinor, validationLines,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -181,3 +181,68 @@ describe("reportSummary — duration (sessions cache) + subscriptions", () => {
|
|||||||
expect(r.subscriptions.coveredCars).toBe(2);
|
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
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ import {
|
|||||||
desc,
|
desc,
|
||||||
eq,
|
eq,
|
||||||
gte,
|
gte,
|
||||||
|
lt,
|
||||||
lte,
|
lte,
|
||||||
ledgerEvents,
|
ledgerEvents,
|
||||||
sessions,
|
sessions,
|
||||||
|
siteConfig,
|
||||||
subscriptions,
|
subscriptions,
|
||||||
tariffVersions,
|
tariffVersions,
|
||||||
tariffs,
|
tariffs,
|
||||||
@@ -46,8 +48,13 @@ export interface SeriesPoint {
|
|||||||
readonly exits: number;
|
readonly exits: number;
|
||||||
/** Net transient revenue collected in the bucket (minor units), all tenders. */
|
/** Net transient revenue collected in the bucket (minor units), all tenders. */
|
||||||
readonly revenueMinor: number;
|
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). */
|
/** Payment COUNT in the bucket (transactions, not amount). */
|
||||||
readonly payments: number;
|
readonly payments: number;
|
||||||
|
/** Cars inside at the END of the bucket (occupancyStart + running entries−exits). */
|
||||||
|
readonly occupancyEnd: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReportTotals {
|
export interface ReportTotals {
|
||||||
@@ -67,6 +74,10 @@ export interface ReportTotals {
|
|||||||
readonly totalParkedMinutes: number;
|
readonly totalParkedMinutes: number;
|
||||||
readonly avgParkedMinutes: number;
|
readonly avgParkedMinutes: number;
|
||||||
readonly medianParkedMinutes: 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 {
|
export interface SubscriptionStats {
|
||||||
@@ -79,6 +90,13 @@ export interface SubscriptionStats {
|
|||||||
readonly coveredCars: number;
|
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 {
|
export interface ReportSummary {
|
||||||
readonly from: string;
|
readonly from: string;
|
||||||
readonly to: string;
|
readonly to: string;
|
||||||
@@ -89,25 +107,43 @@ export interface ReportSummary {
|
|||||||
readonly series: SeriesPoint[];
|
readonly series: SeriesPoint[];
|
||||||
/** Entries by local hour-of-day (0–23), summed across the range — the peak-hour view. */
|
/** Entries by local hour-of-day (0–23), summed across the range — the peak-hour view. */
|
||||||
readonly entriesByHour: number[];
|
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;
|
readonly subscriptions: SubscriptionStats;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Local wall-clock parts of an ISO instant in a given IANA tz. Reuses Intl (no dep). */
|
/** 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 fmtCache = new Map<string, Intl.DateTimeFormat>();
|
||||||
const fmt = new Intl.DateTimeFormat("en-CA", {
|
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,
|
timeZone: tz,
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
month: "2-digit",
|
month: "2-digit",
|
||||||
day: "2-digit",
|
day: "2-digit",
|
||||||
hour: "2-digit",
|
hour: "2-digit",
|
||||||
hourCycle: "h23",
|
hourCycle: "h23",
|
||||||
|
weekday: "short",
|
||||||
});
|
});
|
||||||
|
fmtCache.set(tz, fmt);
|
||||||
|
}
|
||||||
const parts = Object.fromEntries(fmt.formatToParts(new Date(iso)).map((p) => [p.type, p.value]));
|
const parts = Object.fromEntries(fmt.formatToParts(new Date(iso)).map((p) => [p.type, p.value]));
|
||||||
return {
|
return {
|
||||||
y: Number(parts.year),
|
y: Number(parts.year),
|
||||||
mo: Number(parts.month),
|
mo: Number(parts.month),
|
||||||
d: Number(parts.day),
|
d: Number(parts.day),
|
||||||
h: Number(parts.hour),
|
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 seriesMap = new Map<string, SeriesPoint>();
|
||||||
const entriesByHour = new Array<number>(24).fill(0);
|
const entriesByHour = new Array<number>(24).fill(0);
|
||||||
|
const entriesByDowHour = Array.from({ length: 7 }, () => new Array<number>(24).fill(0));
|
||||||
const totals = {
|
const totals = {
|
||||||
entries: 0,
|
entries: 0,
|
||||||
exits: 0,
|
exits: 0,
|
||||||
@@ -172,12 +209,14 @@ export function reportSummary(db: Db, q: ReportQuery): ReportSummary {
|
|||||||
ticketMinor: 0,
|
ticketMinor: 0,
|
||||||
subscriptionSalesMinor: 0,
|
subscriptionSalesMinor: 0,
|
||||||
subscriptionWindowMinor: 0,
|
subscriptionWindowMinor: 0,
|
||||||
|
voids: 0,
|
||||||
|
anomalies: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
function point(label: string): SeriesPoint {
|
function point(label: string): SeriesPoint {
|
||||||
let p = seriesMap.get(label);
|
let p = seriesMap.get(label);
|
||||||
if (!p) {
|
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);
|
seriesMap.set(label, p);
|
||||||
}
|
}
|
||||||
return 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
|
if (row.identity && voided.has(row.identity)) continue; // cancelled — not a real entry
|
||||||
totals.entries++;
|
totals.entries++;
|
||||||
p.entries++;
|
p.entries++;
|
||||||
const h = localParts(row.occurredAt, tz).h;
|
const lp = localParts(row.occurredAt, tz);
|
||||||
entriesByHour[h] = (entriesByHour[h] ?? 0) + 1;
|
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") {
|
} else if (row.type === "vehicle_exit") {
|
||||||
totals.exits++;
|
totals.exits++;
|
||||||
p.exits++;
|
p.exits++;
|
||||||
|
} else if (row.type === "void") {
|
||||||
|
totals.voids++;
|
||||||
|
} else if (row.type === "anomaly") {
|
||||||
|
totals.anomalies++;
|
||||||
} else if (row.type === "payment") {
|
} else if (row.type === "payment") {
|
||||||
const pl = (row.payload ?? {}) as PaymentPayload;
|
const pl = (row.payload ?? {}) as PaymentPayload;
|
||||||
const amt = typeof pl.amountMinor === "number" ? pl.amountMinor : 0;
|
const amt = typeof pl.amountMinor === "number" ? pl.amountMinor : 0;
|
||||||
@@ -209,8 +253,13 @@ export function reportSummary(db: Db, q: ReportQuery): ReportSummary {
|
|||||||
totals.revenueMinor += amt;
|
totals.revenueMinor += amt;
|
||||||
p.payments++;
|
p.payments++;
|
||||||
p.revenueMinor += amt;
|
p.revenueMinor += amt;
|
||||||
if (pl.tender === "card") totals.cardMinor += amt;
|
if (pl.tender === "card") {
|
||||||
else totals.cashMinor += amt;
|
totals.cardMinor += amt;
|
||||||
|
p.cardMinor += amt;
|
||||||
|
} else {
|
||||||
|
totals.cashMinor += amt;
|
||||||
|
p.cashMinor += amt;
|
||||||
|
}
|
||||||
// Revenue split mirrors the shift Z-report: subscription sale / window charge /
|
// Revenue split mirrors the shift Z-report: subscription sale / window charge /
|
||||||
// (the rest is) transient ticket revenue.
|
// (the rest is) transient ticket revenue.
|
||||||
if (pl.subscriptionSale === true) totals.subscriptionSalesMinor += amt;
|
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));
|
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
|
// No payment in range? Fall back to the site tariff's latest version currency, so a
|
||||||
// zero-revenue range still labels its money column.
|
// zero-revenue range still labels its money column.
|
||||||
if (!currency) {
|
if (!currency) {
|
||||||
@@ -251,6 +325,19 @@ export function reportSummary(db: Db, q: ReportQuery): ReportSummary {
|
|||||||
durations.sort((a, b) => a - b);
|
durations.sort((a, b) => a - b);
|
||||||
const totalParkedMinutes = durations.reduce((a, b) => a + b, 0);
|
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`).
|
// --- Subscriptions: status counts + currently-valid (window covers `to`).
|
||||||
const subs = db.select().from(subscriptions).all();
|
const subs = db.select().from(subscriptions).all();
|
||||||
const subStats = { active: 0, suspended: 0, revoked: 0, currentlyValid: 0, coveredCars: 0 };
|
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,
|
series,
|
||||||
entriesByHour,
|
entriesByHour,
|
||||||
|
entriesByDowHour,
|
||||||
|
stayHistogram,
|
||||||
|
occupancyStart,
|
||||||
|
capacity,
|
||||||
subscriptions: subStats,
|
subscriptions: subStats,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ import { InvalidCashMovementError, type MovementStatus, type ShiftService } from
|
|||||||
// - GET /api/drawer/movements: list with review status. Operators see (shift:read)
|
// - GET /api/drawer/movements: list with review status. Operators see (shift:read)
|
||||||
// only their own; reviewers see all + can filter status.
|
// only their own; reviewers see all + can filter status.
|
||||||
// - POST /api/drawer/review : admin authorize/deny a movement. (drawer:review)
|
// - 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
|
// 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.
|
// judgment about the operator settled outside the app, never a cash reversal.
|
||||||
|
|
||||||
@@ -73,6 +76,10 @@ export async function drawerRoutes(app: FastifyInstance, shift: ShiftService): P
|
|||||||
return { movements, scope: canReview ? "all" : "self" };
|
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.
|
// 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) => {
|
app.post<{ Body: ReviewBody }>("/api/drawer/review", { preHandler: reviewGuard }, async (req, reply) => {
|
||||||
const b = req.body ?? ({} as ReviewBody);
|
const b = req.body ?? ({} as ReviewBody);
|
||||||
|
|||||||
@@ -48,9 +48,18 @@ export async function reportRoutes(app: FastifyInstance, db: Db): Promise<void>
|
|||||||
async (req, reply) => {
|
async (req, reply) => {
|
||||||
const summary = reportSummary(db, parseQuery(req.query));
|
const summary = reportSummary(db, parseQuery(req.query));
|
||||||
const lines = [
|
const lines = [
|
||||||
"bucket,entries,exits,payments,revenue",
|
"bucket,entries,exits,payments,revenue,cash,card,occupancy_end",
|
||||||
...summary.series.map((p) =>
|
...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
|
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", () => {
|
describe("RBAC permission gate", () => {
|
||||||
it("a site:read-only user can GET occupancy but is 403 on PUT site-config", async () => {
|
it("a site:read-only user can GET occupancy but is 403 on PUT site-config", async () => {
|
||||||
const { username, password } = await seedUser(db, {
|
const { username, password } = await seedUser(db, {
|
||||||
@@ -101,3 +132,21 @@ describe("CSRF double-submit on mutations", () => {
|
|||||||
expect(put.statusCode).toBe(403);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -560,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
|
// Assign a device. Validates the chosen driver + config, configures the device
|
||||||
// (fix preconditions + set up Digest-authenticated input push — no manual 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
|
// web-UI step by the admin), then persists. Fails the save if the device can't be
|
||||||
|
|||||||
@@ -78,6 +78,15 @@ export async function siteRoutes(app: FastifyInstance, db: Db, eventLog?: EventL
|
|||||||
// Live occupancy: cars inside, capacity, free, full. Any signed-in role.
|
// Live occupancy: cars inside, capacity, free, full. Any signed-in role.
|
||||||
app.get("/api/occupancy", { preHandler: readGuard }, async () => getOccupancy(db));
|
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).
|
// Read site config (capacity + park metadata).
|
||||||
app.get("/api/site-config", { preHandler: readGuard }, async () => {
|
app.get("/api/site-config", { preHandler: readGuard }, async () => {
|
||||||
const row = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
const row = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { FastifyInstance } from "fastify";
|
|||||||
import { and, desc, eq, isNull, ledgerEvents, siteConfig, tariffDrafts, tariffVersions, tariffs, type Db } from "@parking/db";
|
import { and, desc, eq, isNull, ledgerEvents, siteConfig, tariffDrafts, tariffVersions, tariffs, type Db } from "@parking/db";
|
||||||
import {
|
import {
|
||||||
computeFee,
|
computeFee,
|
||||||
|
explainFee,
|
||||||
isTariffV2,
|
isTariffV2,
|
||||||
priceSession,
|
priceSession,
|
||||||
validateTariffStructure,
|
validateTariffStructure,
|
||||||
@@ -188,6 +189,12 @@ export async function tariffRoutes(app: FastifyInstance, db: Db): Promise<void>
|
|||||||
const payments = Array.isArray(b.payments) ? b.payments : [];
|
const payments = Array.isArray(b.payments) ? b.payments : [];
|
||||||
const pricing = priceSession(b.enteredAt, b.asOf, structure, payments, b.category);
|
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.
|
// 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 SAMPLES_MIN = [30, 60, 120, 180, 360, 720, 1440, 2880, 4320];
|
||||||
const enteredMs = Date.parse(b.enteredAt);
|
const enteredMs = Date.parse(b.enteredAt);
|
||||||
@@ -196,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),
|
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
|
// Prefill the lab from a REAL session: fold its ledger into entry + payments so the
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -45,6 +45,7 @@ import { shiftRoutes } from "./routes/shift.js";
|
|||||||
import { drawerRoutes } from "./routes/drawer.js";
|
import { drawerRoutes } from "./routes/drawer.js";
|
||||||
import { entryRoutes } from "./routes/entry.js";
|
import { entryRoutes } from "./routes/entry.js";
|
||||||
import { siteRoutes } from "./routes/site.js";
|
import { siteRoutes } from "./routes/site.js";
|
||||||
|
import { validationRoutes } from "./routes/validations.js";
|
||||||
import { snapshotRoutes } from "./routes/snapshots.js";
|
import { snapshotRoutes } from "./routes/snapshots.js";
|
||||||
import { tariffRoutes } from "./routes/tariffs.js";
|
import { tariffRoutes } from "./routes/tariffs.js";
|
||||||
import { printerRoutes } from "./routes/printers.js";
|
import { printerRoutes } from "./routes/printers.js";
|
||||||
@@ -292,6 +293,11 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
|||||||
// at capacity) is in the entry flow. See wiki/concepts/capacity-occupancy.md.
|
// at capacity) is in the entry flow. See wiki/concepts/capacity-occupancy.md.
|
||||||
await siteRoutes(app, db, eventLog);
|
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) +
|
// 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.
|
// read the store (GET /api/logs, log:read). See wiki/concepts/app-logs.md.
|
||||||
await logRoutes(app, logService);
|
await logRoutes(app, logService);
|
||||||
@@ -330,12 +336,20 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
|||||||
void runSnapPrune(); // once at startup
|
void runSnapPrune(); // once at startup
|
||||||
app.addHook("onClose", async () => clearInterval(snapPruneTimer));
|
app.addHook("onClose", async () => clearInterval(snapPruneTimer));
|
||||||
|
|
||||||
// Scheduled encrypted backup — daily, unref'd. A no-op (silent) until BACKUP_TARGET_DIR +
|
// Scheduled encrypted backup — checked every 15 min, unref'd; `runScheduled()` itself is a
|
||||||
// BACKUP_KEY are configured; tolerates an unreachable/unmounted target by recording the
|
// no-op unless a full 24h has actually elapsed since the last PERSISTED success (isDue(), in
|
||||||
// error and trying again next run. NOT run once at startup (a just-booted appliance after a
|
// backup-service.ts), so this frequent poll does not cause frequent backups. Deliberately
|
||||||
// power cut shouldn't immediately write to a possibly-not-yet-mounted disk; the daily cadence
|
// NOT a `setInterval(..., 24h)` measured from process start: that design silently reset its
|
||||||
// and the manual button cover it). See wiki/concepts/backup-recovery.md.
|
// own countdown on every restart (deploy/crash/OOM/reboot, all routine under `restart:
|
||||||
const backupTimer = setInterval(() => void backupService.runScheduled(), 24 * 60 * 60 * 1000);
|
// 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();
|
backupTimer.unref();
|
||||||
app.addHook("onClose", async () => clearInterval(backupTimer));
|
app.addHook("onClose", async () => clearInterval(backupTimer));
|
||||||
if (backupService.configured) {
|
if (backupService.configured) {
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ export interface ShiftSummary {
|
|||||||
readonly subscriptionTotalMinor: number;
|
readonly subscriptionTotalMinor: number;
|
||||||
readonly subscriptionSalesMinor: number;
|
readonly subscriptionSalesMinor: number;
|
||||||
readonly subscriptionWindowMinor: number;
|
readonly subscriptionWindowMinor: number;
|
||||||
|
readonly discountTotalMinor: number;
|
||||||
readonly openingFloatMinor: number;
|
readonly openingFloatMinor: number;
|
||||||
readonly cashAddedMinor: number;
|
readonly cashAddedMinor: number;
|
||||||
readonly cashRemovedMinor: number;
|
readonly cashRemovedMinor: number;
|
||||||
@@ -78,6 +79,9 @@ export interface ShiftReport {
|
|||||||
readonly subscriptionSalesMinor: number;
|
readonly subscriptionSalesMinor: number;
|
||||||
/** Subscriber OUT-OF-WINDOW transient-tariff charges only. */
|
/** Subscriber OUT-OF-WINDOW transient-tariff charges only. */
|
||||||
readonly subscriptionWindowMinor: number;
|
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) ---
|
// --- Drawer (physical cash till; carries across shifts) ---
|
||||||
/** Cash in the drawer at shift start = prior shift's expected closing drawer. */
|
/** Cash in the drawer at shift start = prior shift's expected closing drawer. */
|
||||||
readonly openingFloatMinor: number;
|
readonly openingFloatMinor: number;
|
||||||
@@ -220,6 +224,7 @@ export class ShiftService {
|
|||||||
subscriptionTotalMinor?: number;
|
subscriptionTotalMinor?: number;
|
||||||
subscriptionSalesMinor?: number;
|
subscriptionSalesMinor?: number;
|
||||||
subscriptionWindowMinor?: number;
|
subscriptionWindowMinor?: number;
|
||||||
|
discountTotalMinor?: number;
|
||||||
openingFloatMinor?: number;
|
openingFloatMinor?: number;
|
||||||
cashAddedMinor?: number;
|
cashAddedMinor?: number;
|
||||||
cashRemovedMinor?: number;
|
cashRemovedMinor?: number;
|
||||||
@@ -250,6 +255,8 @@ export class ShiftService {
|
|||||||
ticketTotalMinor:
|
ticketTotalMinor:
|
||||||
pl.ticketTotalMinor ??
|
pl.ticketTotalMinor ??
|
||||||
(pl.cashTotalMinor ?? 0) + (pl.cardTotalMinor ?? 0) - (pl.subscriptionTotalMinor ?? 0),
|
(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,
|
openingFloatMinor: pl.openingFloatMinor ?? 0,
|
||||||
cashAddedMinor: pl.cashAddedMinor ?? 0,
|
cashAddedMinor: pl.cashAddedMinor ?? 0,
|
||||||
cashRemovedMinor: pl.cashRemovedMinor ?? 0,
|
cashRemovedMinor: pl.cashRemovedMinor ?? 0,
|
||||||
@@ -522,6 +529,9 @@ export class ShiftService {
|
|||||||
// the subscription sale path).
|
// the subscription sale path).
|
||||||
let subscriptionSalesMinor = 0;
|
let subscriptionSalesMinor = 0;
|
||||||
let subscriptionWindowMinor = 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;
|
let currency: string | null = null;
|
||||||
for (const p of payments) {
|
for (const p of payments) {
|
||||||
const pl = (p.payload ?? {}) as LedgerPayload & {
|
const pl = (p.payload ?? {}) as LedgerPayload & {
|
||||||
@@ -534,6 +544,7 @@ export class ShiftService {
|
|||||||
if (pl.subscriptionSale === true) subscriptionSalesMinor += amt;
|
if (pl.subscriptionSale === true) subscriptionSalesMinor += amt;
|
||||||
else if (pl.subscriptionWindowCharge === true) subscriptionWindowMinor += amt;
|
else if (pl.subscriptionWindowCharge === true) subscriptionWindowMinor += amt;
|
||||||
// (else → transient ticket; derived below as total − subscription)
|
// (else → transient ticket; derived below as total − subscription)
|
||||||
|
if (typeof pl.discountMinor === "number") discountTotalMinor += pl.discountMinor;
|
||||||
if (pl.currency) currency = pl.currency;
|
if (pl.currency) currency = pl.currency;
|
||||||
}
|
}
|
||||||
const subscriptionTotalMinor = subscriptionSalesMinor + subscriptionWindowMinor;
|
const subscriptionTotalMinor = subscriptionSalesMinor + subscriptionWindowMinor;
|
||||||
@@ -589,6 +600,7 @@ export class ShiftService {
|
|||||||
subscriptionTotalMinor,
|
subscriptionTotalMinor,
|
||||||
subscriptionSalesMinor,
|
subscriptionSalesMinor,
|
||||||
subscriptionWindowMinor,
|
subscriptionWindowMinor,
|
||||||
|
discountTotalMinor,
|
||||||
openingFloatMinor,
|
openingFloatMinor,
|
||||||
cashAddedMinor,
|
cashAddedMinor,
|
||||||
cashRemovedMinor,
|
cashRemovedMinor,
|
||||||
@@ -627,6 +639,7 @@ export class ShiftService {
|
|||||||
subscriptionTotalMinor,
|
subscriptionTotalMinor,
|
||||||
subscriptionSalesMinor,
|
subscriptionSalesMinor,
|
||||||
subscriptionWindowMinor,
|
subscriptionWindowMinor,
|
||||||
|
discountTotalMinor,
|
||||||
openingFloatMinor,
|
openingFloatMinor,
|
||||||
cashAddedMinor,
|
cashAddedMinor,
|
||||||
cashRemovedMinor,
|
cashRemovedMinor,
|
||||||
@@ -649,6 +662,7 @@ export class ShiftService {
|
|||||||
subscriptionTotalMinor,
|
subscriptionTotalMinor,
|
||||||
subscriptionSalesMinor,
|
subscriptionSalesMinor,
|
||||||
subscriptionWindowMinor,
|
subscriptionWindowMinor,
|
||||||
|
discountTotalMinor,
|
||||||
openingFloatMinor,
|
openingFloatMinor,
|
||||||
cashAddedMinor,
|
cashAddedMinor,
|
||||||
cashRemovedMinor,
|
cashRemovedMinor,
|
||||||
@@ -691,14 +705,17 @@ export class ShiftService {
|
|||||||
// Abonime is the subscription TOTAL; only the out-of-window part is broken out.
|
// 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.)
|
// (subscriptionSalesMinor stays in the signed payload — it's just not printed.)
|
||||||
`Abonime: ${money(r.subscriptionTotalMinor)} ${cur}`,
|
`Abonime: ${money(r.subscriptionTotalMinor)} ${cur}`,
|
||||||
` jashtë orarit: ${money(r.subscriptionWindowMinor)} ${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 --",
|
"-- Arka --",
|
||||||
`Fillimi (kusur): ${money(r.openingFloatMinor)} ${cur}`,
|
`Gjëndje fillestare: ${money(r.openingFloatMinor)} ${cur}`,
|
||||||
`Para të marra: ${money(r.cashTotalMinor)} ${cur}`,
|
`Para të grumbulluara: ${money(r.cashTotalMinor)} ${cur}`,
|
||||||
`Para të shtuara: ${money(r.cashAddedMinor)} ${cur}`,
|
`Arkëtime: ${money(r.cashAddedMinor)} ${cur}`,
|
||||||
`Para të hequra: ${money(r.cashRemovedMinor)} ${cur}`,
|
`Pagesa: ${money(r.cashRemovedMinor)} ${cur}`,
|
||||||
`Arka e pritur: ${money(r.expectedDrawerMinor)} ${cur}`,
|
`Gjëndje aktuale: ${money(r.expectedDrawerMinor)} ${cur}`,
|
||||||
];
|
];
|
||||||
try {
|
try {
|
||||||
await printer.printReport({ title: "RAPORT TURNI", lines });
|
await printer.printReport({ title: "RAPORT TURNI", lines });
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -18,9 +18,10 @@ import {
|
|||||||
import { rootRoute } from "./router.js";
|
import { rootRoute } from "./router.js";
|
||||||
import { qk } from "./lib/query.js";
|
import { qk } from "./lib/query.js";
|
||||||
import { useShift } from "./lib/use-shift.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 { CARD_PAYMENTS_ENABLED } from "./lib/features.js";
|
||||||
import { SnapshotStrip } from "./ui/SnapshotStrip.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
|
// 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
|
// session (entry, exit=now, duration, total owed) + entry/exit snapshots, takes
|
||||||
@@ -281,7 +282,13 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
|||||||
disabled={openingShift}
|
disabled={openingShift}
|
||||||
className="btn btn-go btn-sm mt-2"
|
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>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -303,12 +310,12 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
|||||||
// figures, and the snapshot strip read-only. No tender / voucher / open here.
|
// 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">
|
<div className="rounded-term border border-term-amber px-3 py-2 text-term-amber">
|
||||||
{t("pay.alreadyClosed", { time: formatTime(s.exitedAt) })}
|
{t("pay.alreadyClosed", { time: formatRelativeDateTime(s.exitedAt, t, { seconds: true }) })}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-x-6 gap-y-1 tabular-nums">
|
<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.entry")} value={formatRelativeDateTime(s.enteredAt, t, { seconds: true })} />
|
||||||
<Row label={t("pay.exit")} value={formatTime(s.exitedAt)} />
|
<Row label={t("pay.exit")} value={formatRelativeDateTime(s.exitedAt, t, { seconds: true })} />
|
||||||
<Row
|
<Row
|
||||||
label={t("pay.duration")}
|
label={t("pay.duration")}
|
||||||
value={
|
value={
|
||||||
@@ -328,11 +335,15 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
|||||||
<>
|
<>
|
||||||
{/* Session figures */}
|
{/* Session figures */}
|
||||||
<div className="grid grid-cols-2 gap-x-6 gap-y-1 tabular-nums">
|
<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.entry")} value={formatRelativeDateTime(s.enteredAt, t, { seconds: true })} />
|
||||||
{/* Closed-within-grace shows the recorded EXIT; an open session shows now. */}
|
{/* Closed-within-grace shows the recorded EXIT; an open session shows now. */}
|
||||||
<Row
|
<Row
|
||||||
label={closedWithinGrace ? t("pay.exit") : t("pay.now")}
|
label={closedWithinGrace ? t("pay.exit") : t("pay.now")}
|
||||||
value={closedWithinGrace ? formatTime(s.exitedAt) : formatTime(new Date().toISOString())}
|
value={formatRelativeDateTime(
|
||||||
|
closedWithinGrace ? s.exitedAt : new Date().toISOString(),
|
||||||
|
t,
|
||||||
|
{ seconds: true },
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
<Row
|
<Row
|
||||||
label={t("pay.duration")}
|
label={t("pay.duration")}
|
||||||
@@ -372,6 +383,30 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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
|
{/* Total — a subscription is prepaid (no amount) UNLESS it owes an
|
||||||
out-of-window window charge; then show that amount. For an overstay the
|
out-of-window window charge; then show that amount. For an overstay the
|
||||||
amount is the TOP-UP delta, not the whole stay. */}
|
amount is the TOP-UP delta, not the whole stay. */}
|
||||||
|
|||||||
+279
-11
@@ -2,23 +2,39 @@ import { useState } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
|
fetchDrawerBalance,
|
||||||
fetchDrawerMovements,
|
fetchDrawerMovements,
|
||||||
|
fetchEvents,
|
||||||
|
fetchShift,
|
||||||
|
fetchShiftReport,
|
||||||
|
fetchShifts,
|
||||||
recordDrawerMovement,
|
recordDrawerMovement,
|
||||||
reviewDrawerMovement,
|
reviewDrawerMovement,
|
||||||
type DrawerMovement,
|
type DrawerMovement,
|
||||||
type MovementStatus,
|
type MovementStatus,
|
||||||
|
type ShiftSummary,
|
||||||
} from "./api.js";
|
} from "./api.js";
|
||||||
import { formatMoney, formatRelativeDateTime } from "./lib/format.js";
|
import { formatClock, formatMoney, formatRelativeDateTime } from "./lib/format.js";
|
||||||
import { Panel } from "./ui/Panel.js";
|
import { Panel } from "./ui/Panel.js";
|
||||||
|
import type { LedgerEvent } from "@parking/shared";
|
||||||
|
|
||||||
// Drawer cash movements. Operators RECORD receipts (Mandat Arkëtimi / cash_in) and
|
// The DRAWER HUB (redesigned 2026-07-05 — was only record + review). One screen
|
||||||
// disbursements (Mandat Pagese / cash_out) freely; admins REVIEW them after the fact
|
// answers "what's in the till and why": the CURRENT drawer balance with the open
|
||||||
// (authorize/deny — a flag, never a cash reversal). A denial is a judgment about the
|
// shift's running breakdown (float + takings + vouchers = expected), TODAY's cash
|
||||||
// operator, settled outside the app: the drawer balance is untouched. See
|
// 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.
|
// wiki/concepts/shift.md.
|
||||||
|
|
||||||
const money = (m: number, cur: string | null) => formatMoney(m, cur ?? "");
|
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 }) {
|
function StatusBadge({ status }: { status: MovementStatus }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const cls =
|
const cls =
|
||||||
@@ -37,6 +53,198 @@ function StatusBadge({ status }: { status: MovementStatus }) {
|
|||||||
export function DrawerManager({ canCreate, canReview }: { canCreate: boolean; canReview: boolean }) {
|
export function DrawerManager({ canCreate, canReview }: { canCreate: boolean; canReview: boolean }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const qc = useQueryClient();
|
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.
|
// Reviewers can filter the list (the pending queue); operators always see their own, all.
|
||||||
const [statusFilter, setStatusFilter] = useState<MovementStatus | "">("");
|
const [statusFilter, setStatusFilter] = useState<MovementStatus | "">("");
|
||||||
const q = useQuery({
|
const q = useQuery({
|
||||||
@@ -47,9 +255,6 @@ export function DrawerManager({ canCreate, canReview }: { canCreate: boolean; ca
|
|||||||
const pendingCount = movements.filter((m) => m.status === "pending").length;
|
const pendingCount = movements.filter((m) => m.status === "pending").length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col gap-3 p-3">
|
|
||||||
{canCreate && <RecordPanel onDone={() => void qc.invalidateQueries({ queryKey: ["drawer"] })} />}
|
|
||||||
|
|
||||||
<Panel
|
<Panel
|
||||||
title={canReview ? t("drawer.allTitle") : t("drawer.myTitle")}
|
title={canReview ? t("drawer.allTitle") : t("drawer.myTitle")}
|
||||||
right={
|
right={
|
||||||
@@ -59,9 +264,9 @@ export function DrawerManager({ canCreate, canReview }: { canCreate: boolean; ca
|
|||||||
</span>
|
</span>
|
||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
className="min-h-0 flex-1"
|
className="min-h-0"
|
||||||
>
|
>
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full min-h-0 flex-col">
|
||||||
{canReview && (
|
{canReview && (
|
||||||
<div className="mb-2 flex items-center gap-1.5">
|
<div className="mb-2 flex items-center gap-1.5">
|
||||||
{(["", "pending", "authorized", "denied"] as const).map((s) => (
|
{(["", "pending", "authorized", "denied"] as const).map((s) => (
|
||||||
@@ -97,7 +302,7 @@ export function DrawerManager({ canCreate, canReview }: { canCreate: boolean; ca
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{movements.map((m) => (
|
{movements.map((m) => (
|
||||||
<MovementRow key={m.id} m={m} canReview={canReview} onReviewed={() => void qc.invalidateQueries({ queryKey: ["drawer"] })} />
|
<MovementRow key={m.id} m={m} canReview={canReview} onReviewed={onChanged} />
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -105,10 +310,73 @@ export function DrawerManager({ canCreate, canReview }: { canCreate: boolean; ca
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Panel>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Record form (unchanged from the pre-redesign feature) ------------------
|
||||||
|
|
||||||
function RecordPanel({ onDone }: { onDone: () => void }) {
|
function RecordPanel({ onDone }: { onDone: () => void }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [amount, setAmount] = useState("");
|
const [amount, setAmount] = useState("");
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ function LogRow({ log }: { log: AppLogRecord }) {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const hasDetail = (log.context && Object.keys(log.context).length > 0) || log.stack;
|
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 (
|
return (
|
||||||
<div className={`border-b border-term-border/50 ${log.level === "error" || log.level === "fatal" ? "bg-term-red/5" : ""}`}>
|
<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="text-term-muted tabular-nums">{formatRelativeDateTime(log.createdAt, t)}</span>
|
||||||
<span className={`font-semibold uppercase ${LEVEL_COLOR[log.level]}`}>{log.level}</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="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>
|
<span className="text-term-muted tabular-nums">{log.httpStatus ?? ""}</span>
|
||||||
</button>
|
</button>
|
||||||
{open && hasDetail && (
|
{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 { useTranslation } from "react-i18next";
|
||||||
import type { TFunction } from "i18next";
|
import type { TFunction } from "i18next";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
|
Area,
|
||||||
|
AreaChart,
|
||||||
Bar,
|
Bar,
|
||||||
BarChart,
|
BarChart,
|
||||||
CartesianGrid,
|
CartesianGrid,
|
||||||
@@ -12,6 +14,7 @@ import {
|
|||||||
LineChart,
|
LineChart,
|
||||||
Pie,
|
Pie,
|
||||||
PieChart,
|
PieChart,
|
||||||
|
ReferenceLine,
|
||||||
ResponsiveContainer,
|
ResponsiveContainer,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
XAxis,
|
XAxis,
|
||||||
@@ -37,6 +40,7 @@ const C = {
|
|||||||
border: "#2a2f38",
|
border: "#2a2f38",
|
||||||
text: "#f2f2ee",
|
text: "#f2f2ee",
|
||||||
panel: "#14171c",
|
panel: "#14171c",
|
||||||
|
panel2: "#1e222a",
|
||||||
};
|
};
|
||||||
|
|
||||||
type PresetKey = "today" | "7d" | "30d" | "90d";
|
type PresetKey = "today" | "7d" | "30d" | "90d";
|
||||||
@@ -140,27 +144,37 @@ function ReportBody({ data, t }: { data: ReportSummary; t: TFunction }) {
|
|||||||
...p,
|
...p,
|
||||||
label: data.bucket === "hour" ? p.bucket.slice(11) + "h" : p.bucket,
|
label: data.bucket === "hour" ? p.bucket.slice(11) + "h" : p.bucket,
|
||||||
revenue: p.revenueMinor / 100,
|
revenue: p.revenueMinor / 100,
|
||||||
|
cash: p.cashMinor / 100,
|
||||||
|
card: p.cardMinor / 100,
|
||||||
}));
|
}));
|
||||||
const hours = data.entriesByHour.map((entries, h) => ({ hour: `${h}`, entries }));
|
|
||||||
const mix = [
|
const mix = [
|
||||||
{ name: t("reports.mix.ticket"), value: tot.ticketMinor, color: C.amber },
|
{ 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.subSales"), value: tot.subscriptionSalesMinor, color: C.cyan },
|
||||||
{ name: t("reports.mix.subWindow"), value: tot.subscriptionWindowMinor, color: C.green },
|
{ name: t("reports.mix.subWindow"), value: tot.subscriptionWindowMinor, color: C.green },
|
||||||
].filter((s) => s.value > 0);
|
].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 (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* KPI cards. */}
|
{/* 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.entries")} value={String(tot.entries)} accent="green" />
|
||||||
<Kpi label={t("reports.kpi.exits")} value={String(tot.exits)} accent="red" />
|
<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.revenue")} value={money(tot.revenueMinor)} accent="amber" />
|
||||||
<Kpi label={t("reports.kpi.payments")} value={String(tot.payments)} accent="cyan" />
|
<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.avgStay")} value={formatMinutes(tot.avgParkedMinutes)} />
|
||||||
<Kpi
|
<Kpi
|
||||||
label={t("reports.kpi.subscribers")}
|
label={t("reports.kpi.peakOcc")}
|
||||||
value={String(data.subscriptions.currentlyValid)}
|
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>
|
</div>
|
||||||
|
|
||||||
{/* Entry / exit over time. */}
|
{/* Entry / exit over time. */}
|
||||||
@@ -192,8 +206,38 @@ function ReportBody({ data, t }: { data: ReportSummary; t: TFunction }) {
|
|||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</Panel>
|
</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">
|
<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 })}>
|
<Panel title={t("reports.chart.revenue", { currency: cur })}>
|
||||||
<ResponsiveContainer width="100%" height={240}>
|
<ResponsiveContainer width="100%" height={240}>
|
||||||
<BarChart data={series} margin={{ top: 8, right: 12, bottom: 0, left: -8 }}>
|
<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} />
|
<XAxis dataKey="label" stroke={C.muted} fontSize={11} />
|
||||||
<YAxis stroke={C.muted} fontSize={11} />
|
<YAxis stroke={C.muted} fontSize={11} />
|
||||||
<Tooltip contentStyle={tooltipStyle} formatter={(v) => money(Math.round(Number(v) * 100))} />
|
<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>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</Panel>
|
</Panel>
|
||||||
@@ -232,15 +278,15 @@ function ReportBody({ data, t }: { data: ReportSummary; t: TFunction }) {
|
|||||||
)}
|
)}
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
{/* Peak hours (entries by hour-of-day). */}
|
{/* Stay-duration histogram — where the ladder/up-to breakpoints should sit. */}
|
||||||
<Panel title={t("reports.chart.peakHours")}>
|
<Panel title={t("reports.chart.stay")}>
|
||||||
<ResponsiveContainer width="100%" height={240}>
|
<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" />
|
<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} />
|
<YAxis stroke={C.muted} fontSize={11} allowDecimals={false} />
|
||||||
<Tooltip contentStyle={tooltipStyle} />
|
<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>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</Panel>
|
</Panel>
|
||||||
@@ -262,6 +308,12 @@ function ReportBody({ data, t }: { data: ReportSummary; t: TFunction }) {
|
|||||||
</Panel>
|
</Panel>
|
||||||
</div>
|
</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">
|
<p className="text-[0.6875rem] text-term-muted">
|
||||||
{t("reports.footnote", { tz: data.tz })}
|
{t("reports.footnote", { tz: data.tz })}
|
||||||
</p>
|
</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 = {
|
const tooltipStyle = {
|
||||||
background: C.panel,
|
background: C.panel,
|
||||||
border: `1px solid ${C.border}`,
|
border: `1px solid ${C.border}`,
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
type RelayEvent,
|
type RelayEvent,
|
||||||
type RelaySpec,
|
type RelaySpec,
|
||||||
type TestResult,
|
type TestResult,
|
||||||
|
fetchUsbPrinters,
|
||||||
} from "./api.js";
|
} from "./api.js";
|
||||||
import { Modal } from "./ui/Modal.js";
|
import { Modal } from "./ui/Modal.js";
|
||||||
|
|
||||||
@@ -257,8 +258,10 @@ function CategorySection({
|
|||||||
const [formFor, setFormFor] = useState<Assignment | "new" | null>(null);
|
const [formFor, setFormFor] = useState<Assignment | "new" | null>(null);
|
||||||
const [warnings, setWarnings] = useState<string[]>([]);
|
const [warnings, setWarnings] = useState<string[]>([]);
|
||||||
|
|
||||||
// Binding categories need a controller to point at first.
|
// Binding categories need a controller to point at first. Printers do NOT bind
|
||||||
const isBound = category !== "access";
|
// (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 blockedNoController = isBound && controllers.length === 0;
|
||||||
const editing = formFor && formFor !== "new" ? formFor : undefined;
|
const editing = formFor && formFor !== "new" ? formFor : undefined;
|
||||||
|
|
||||||
@@ -463,6 +466,16 @@ function DeviceSummary({ assignment, controllers }: { assignment: Assignment; co
|
|||||||
</span>
|
</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.
|
// Bound device: show controller + relay it points at, with inherited direction.
|
||||||
const controllerId = typeof cfg.controllerId === "string" ? cfg.controllerId : null;
|
const controllerId = typeof cfg.controllerId === "string" ? cfg.controllerId : null;
|
||||||
const relay = typeof cfg.relay === "number" ? cfg.relay : null;
|
const relay = typeof cfg.relay === "number" ? cfg.relay : null;
|
||||||
@@ -534,6 +547,28 @@ function DeviceForm({
|
|||||||
}
|
}
|
||||||
return out;
|
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
|
// 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
|
// (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).
|
// input + blink cadence; barriers carry no input wiring (that lives in `inputs` below).
|
||||||
@@ -686,7 +721,11 @@ function DeviceForm({
|
|||||||
...(i.role === "presence" && i.activeLow ? { activeLow: true } : {}),
|
...(i.role === "presence" && i.activeLow ? { activeLow: true } : {}),
|
||||||
...(i.role === "button" && i.cooldownSec ? { cooldownSec: i.cooldownSec } : {}),
|
...(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.controllerId = controllerId;
|
||||||
out.relay = boundRelay;
|
out.relay = boundRelay;
|
||||||
}
|
}
|
||||||
@@ -759,7 +798,9 @@ function DeviceForm({
|
|||||||
if (!selected) return;
|
if (!selected) return;
|
||||||
// Bound devices must point at a controller relay (binding is optional in the
|
// 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).
|
// 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.");
|
setSaveError("Pick the controller and relay this device sits at.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -872,7 +913,32 @@ function DeviceForm({
|
|||||||
{f.label}
|
{f.label}
|
||||||
{f.required ? " *" : ""}
|
{f.required ? " *" : ""}
|
||||||
</label>
|
</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
|
<select
|
||||||
className="select"
|
className="select"
|
||||||
value={String(config[f.key] ?? (f.default as string | number | undefined) ?? "")}
|
value={String(config[f.key] ?? (f.default as string | number | undefined) ?? "")}
|
||||||
@@ -927,6 +993,9 @@ function DeviceForm({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{f.key === "devicePath" && usbPrinters != null && usbPrinters.length === 0 && (
|
||||||
|
<p className="hint mt-1">{t("setup.usbNoneFound")}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
@@ -959,8 +1028,9 @@ function DeviceForm({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* BOUND device: which controller + relay it sits at. */}
|
{/* BOUND device: which controller + relay it sits at. Not printers —
|
||||||
{!isController && (
|
nothing consumes a printer binding (role+rank routes print jobs). */}
|
||||||
|
{!isController && !isPrinter && (
|
||||||
<BindingPicker
|
<BindingPicker
|
||||||
controllers={controllers}
|
controllers={controllers}
|
||||||
controllerId={controllerId}
|
controllerId={controllerId}
|
||||||
|
|||||||
@@ -12,9 +12,10 @@ import {
|
|||||||
type ShiftSummary,
|
type ShiftSummary,
|
||||||
type SessionUser,
|
type SessionUser,
|
||||||
} from "./api.js";
|
} 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 { CARD_PAYMENTS_ENABLED } from "./lib/features.js";
|
||||||
import { Modal } from "./ui/Modal.js";
|
import { Modal } from "./ui/Modal.js";
|
||||||
|
import { Spinner } from "./ui/Spinner.js";
|
||||||
import { EventDetailModal, EventRow } from "./ui/event-detail.js";
|
import { EventDetailModal, EventRow } from "./ui/event-detail.js";
|
||||||
import type { LedgerEvent } from "@parking/shared";
|
import type { LedgerEvent } from "@parking/shared";
|
||||||
|
|
||||||
@@ -176,7 +177,7 @@ export function ShiftsHistory({ user, canManage = false }: { user: SessionUser |
|
|||||||
)}
|
)}
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<span className="label">{t("shifts.operator")}</span>
|
{/* <span className="label">{t("shifts.operator")}</span> */}
|
||||||
{/* A select over operators that HAVE shifts — the server filter is an
|
{/* A select over operators that HAVE shifts — the server filter is an
|
||||||
exact username match, so free text could only miss. */}
|
exact username match, so free text could only miss. */}
|
||||||
<select className="input w-44" value={operator} onChange={(e) => setOperator(e.target.value)}>
|
<select className="input w-44" value={operator} onChange={(e) => setOperator(e.target.value)}>
|
||||||
@@ -244,7 +245,13 @@ function StartShiftButton({ onDone }: { onDone: () => void }) {
|
|||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
{err && <span className="text-[0.75rem] text-term-red">{err}</span>}
|
{err && <span className="text-[0.75rem] text-term-red">{err}</span>}
|
||||||
<button type="button" className="btn btn-go btn-sm" onClick={start} disabled={busy}>
|
<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>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
@@ -450,7 +457,7 @@ function TakingsModal({ onClose }: { onClose: () => void }) {
|
|||||||
<p className="text-[0.75rem] text-term-muted">{t("common.loading")}</p>
|
<p className="text-[0.75rem] text-term-muted">{t("common.loading")}</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-[0.8125rem] tabular-nums">
|
<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">
|
<div className="mt-1 grid grid-cols-2 gap-x-6 gap-y-0.5">
|
||||||
<Figure label={t("shift.payments")} value={String(x.paymentCount)} />
|
<Figure label={t("shift.payments")} value={String(x.paymentCount)} />
|
||||||
<span />
|
<span />
|
||||||
|
|||||||
@@ -1,6 +1,16 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
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
|
// 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.
|
// 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 [reserveSubs, setReserveSubs] = useState(false);
|
||||||
const [anprEntry, setAnprEntry] = useState(true);
|
const [anprEntry, setAnprEntry] = useState(true);
|
||||||
const [msg, setMsg] = useState<string | null>(null);
|
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() {
|
function reload() {
|
||||||
fetchOccupancy().then(setOcc).catch(() => {});
|
fetchOccupancy().then(setOcc).catch(() => {});
|
||||||
}
|
}
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
reload();
|
reload();
|
||||||
|
if (canEdit) {
|
||||||
|
fetchValidationPrograms()
|
||||||
|
.then((r) => setPrograms(r.programs))
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
fetchSiteConfig()
|
fetchSiteConfig()
|
||||||
.then((c) => {
|
.then((c) => {
|
||||||
setCapInput(c.capacity == null ? "" : String(c.capacity));
|
setCapInput(c.capacity == null ? "" : String(c.capacity));
|
||||||
@@ -45,7 +64,23 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
|
|||||||
setMeta(m);
|
setMeta(m);
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.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() {
|
async function save() {
|
||||||
setMsg(null);
|
setMsg(null);
|
||||||
@@ -68,7 +103,8 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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]">
|
<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>
|
<strong className="uppercase tracking-wider text-term-muted">{t("site.occupancy")}</strong>
|
||||||
{occ == null ? (
|
{occ == null ? (
|
||||||
@@ -127,6 +163,23 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
|
|||||||
<span className="hint block">{t("site.anprEntryHint")}</span>
|
<span className="hint block">{t("site.anprEntryHint")}</span>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</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">
|
<div className="border-t border-term-border pt-3 text-[0.6875rem] uppercase tracking-wider text-term-muted">
|
||||||
{t("site.parkDetails")}
|
{t("site.parkDetails")}
|
||||||
</div>
|
</div>
|
||||||
@@ -158,5 +211,12 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
{canEdit && (
|
||||||
|
<ValidationStationsPanel
|
||||||
|
programs={programs}
|
||||||
|
onSaved={(p) => setPrograms((ps) => [...ps.filter((x) => x.id !== p.id), p])}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
} from "./api.js";
|
} from "./api.js";
|
||||||
import { CARD_PAYMENTS_ENABLED } from "./lib/features.js";
|
import { CARD_PAYMENTS_ENABLED } from "./lib/features.js";
|
||||||
import { Modal } from "./ui/Modal.js";
|
import { Modal } from "./ui/Modal.js";
|
||||||
|
import { formatDateTime, type TFn } from "./lib/format.js";
|
||||||
|
|
||||||
// Subscription admin. Create/edit/revoke/delete subscriptions + their credentials
|
// 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
|
// (card/QR) and bound plates. A SALE is priced by selecting an admin-defined PLAN over
|
||||||
@@ -179,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
|
/** 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). */
|
* 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 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 tf = v.timeframes;
|
||||||
const rules = tf ? `${daysLabel(tf.days, t)} ${hhmm(tf.fromMin)}–${hhmm(tf.toMin)}` : t("subs.allDay");
|
const rules = tf ? `${daysLabel(tf.days, t)} ${hhmm(tf.fromMin)}–${hhmm(tf.toMin)}` : t("subs.allDay");
|
||||||
return `${date} · ${rules}`;
|
return `${date} · ${rules}`;
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
type SubscriptionPlan,
|
type SubscriptionPlan,
|
||||||
} from "./api.js";
|
} from "./api.js";
|
||||||
import { Modal } from "./ui/Modal.js";
|
import { Modal } from "./ui/Modal.js";
|
||||||
|
import { formatDate } from "./lib/format.js";
|
||||||
import { currencyOptions } from "./lib/currencies.js";
|
import { currencyOptions } from "./lib/currencies.js";
|
||||||
|
|
||||||
// Admin-only subscription PLAN catalog. Plans are admin-composed, versioned config the
|
// Admin-only subscription PLAN catalog. Plans are admin-composed, versioned config the
|
||||||
@@ -277,7 +278,7 @@ export function SubscriptionPlansManager() {
|
|||||||
{(p.pricePerPeriodMinor / 100).toLocaleString()} {p.currency} / {t(PERIOD_KEY[p.period])}
|
{(p.pricePerPeriodMinor / 100).toLocaleString()} {p.currency} / {t(PERIOD_KEY[p.period])}
|
||||||
</span>
|
</span>
|
||||||
<span>{timeframesSummary(p.timeframes, t)}</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>
|
</div>
|
||||||
|
|
||||||
{/* Used by */}
|
{/* Used by */}
|
||||||
|
|||||||
@@ -1,19 +1,24 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ApiError, fetchTariff, publishTariffVersion, type TariffState } from "./api.js";
|
import { ApiError, fetchTariff, publishTariffVersion, type TariffState, type TariffVersion } from "./api.js";
|
||||||
import { TariffEditorForm, emptyForm, formFromActive, toStructure, type FormState } from "./TariffEditorForm.js";
|
import { TariffEditorForm, emptyForm, formFromActive, formFromVersion, toStructure, type FormState } from "./TariffEditorForm.js";
|
||||||
|
import { formatDateTime } from "./lib/format.js";
|
||||||
|
|
||||||
// Tariff composer — the admin edits + publishes the LIVE rate card. 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
|
// creates a new IMMUTABLE version (the active card); old versions are kept so past
|
||||||
// sessions reprice correctly. The form machinery is shared with the Tariff Lab's
|
// sessions reprice correctly. A right sidebar lists the published history (named
|
||||||
// draft modal — see TariffEditorForm.tsx. To experiment without publishing, use the
|
// since 2026-07-05); clicking a version loads it into the editor as the STARTING
|
||||||
// lab (a draft only becomes real through this same publish path). See
|
// 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.
|
// wiki/concepts/tariff.md.
|
||||||
|
|
||||||
export function TariffComposer() {
|
export function TariffComposer() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [state, setState] = useState<TariffState | null>(null);
|
const [state, setState] = useState<TariffState | null>(null);
|
||||||
const [form, setForm] = useState<FormState>(emptyForm);
|
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
|
// 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
|
// from the active version — a tweaked card republished under last season's name
|
||||||
// would mislabel the history.
|
// would mislabel the history.
|
||||||
@@ -26,10 +31,17 @@ export function TariffComposer() {
|
|||||||
.then((s) => {
|
.then((s) => {
|
||||||
setState(s);
|
setState(s);
|
||||||
setForm(formFromActive(s));
|
setForm(formFromActive(s));
|
||||||
|
setLoadedId(s.active?.id ?? null);
|
||||||
})
|
})
|
||||||
.catch((e) => setMsg({ kind: "err", text: (e as Error).message }));
|
.catch((e) => setMsg({ kind: "err", text: (e as Error).message }));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
function loadVersion(v: TariffVersion) {
|
||||||
|
setForm(formFromVersion(v.currency, v.structure));
|
||||||
|
setLoadedId(v.id);
|
||||||
|
setMsg(null);
|
||||||
|
}
|
||||||
|
|
||||||
async function publish() {
|
async function publish() {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setMsg(null);
|
setMsg(null);
|
||||||
@@ -41,6 +53,7 @@ export function TariffComposer() {
|
|||||||
});
|
});
|
||||||
const fresh = await fetchTariff();
|
const fresh = await fetchTariff();
|
||||||
setState(fresh);
|
setState(fresh);
|
||||||
|
setLoadedId(fresh.active?.id ?? null);
|
||||||
setVersionName("");
|
setVersionName("");
|
||||||
setMsg({ kind: "ok", text: t("tariff.publishedOk") });
|
setMsg({ kind: "ok", text: t("tariff.publishedOk") });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -65,12 +78,14 @@ export function TariffComposer() {
|
|||||||
<p className="mb-4 text-[0.75rem] text-term-muted">
|
<p className="mb-4 text-[0.75rem] text-term-muted">
|
||||||
{state.active.name ? `${state.active.name} — ` : ""}
|
{state.active.name ? `${state.active.name} — ` : ""}
|
||||||
{t("tariff.activeSince", {
|
{t("tariff.activeSince", {
|
||||||
date: new Date(state.active.effectiveFrom).toLocaleString(),
|
date: formatDateTime(state.active.effectiveFrom, t),
|
||||||
count: state.versions.length,
|
count: state.versions.length,
|
||||||
})}
|
})}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4 lg:flex-row">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
<TariffEditorForm form={form} onChange={setForm} />
|
<TariffEditorForm form={form} onChange={setForm} />
|
||||||
|
|
||||||
<div className="mt-6 flex flex-wrap items-center gap-3">
|
<div className="mt-6 flex flex-wrap items-center gap-3">
|
||||||
@@ -87,6 +102,50 @@ export function TariffComposer() {
|
|||||||
<span className={msg.kind === "ok" ? "text-[0.75rem] text-term-green" : "text-[0.75rem] text-term-red"}>{msg.text}</span>
|
<span className={msg.kind === "ok" ? "text-[0.75rem] text-term-green" : "text-[0.75rem] text-term-red"}>{msg.text}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 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>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,23 +70,35 @@ export interface FormState {
|
|||||||
const toMinor = (major: string): number => Math.round(parseFloat(major || "0") * 100);
|
const toMinor = (major: string): number => Math.round(parseFloat(major || "0") * 100);
|
||||||
const toMajor = (minor: number): string => (minor / 100).toFixed(2);
|
const toMajor = (minor: number): string => (minor / 100).toFixed(2);
|
||||||
|
|
||||||
function emptySteps(): StepForm[] {
|
/** 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 [
|
return [
|
||||||
{ hours: "1", total: "2.00" },
|
{ hours: "1", total: ex.stepSmall },
|
||||||
{ hours: "3", total: "5.00" },
|
{ hours: "3", total: ex.stepBig },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
function emptyLadder(): PricingForm {
|
function emptyLadder(currency: string): PricingForm {
|
||||||
|
const ex = examples(currency);
|
||||||
return {
|
return {
|
||||||
mode: "ladder",
|
mode: "ladder",
|
||||||
flat: "0.00",
|
flat: "0.00",
|
||||||
packageTotal: "0.00",
|
packageTotal: "0.00",
|
||||||
dailyCap: "",
|
dailyCap: "",
|
||||||
blocks: [{ hours: "1", price: "2.00" }, { hours: "", price: "1.00" }],
|
blocks: [{ hours: "1", price: ex.hi }, { hours: "", price: ex.lo }],
|
||||||
steps: emptySteps(),
|
steps: emptySteps(currency),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
function emptyTier(): TierForm {
|
function emptyTier(currency: string): TierForm {
|
||||||
return {
|
return {
|
||||||
name: "",
|
name: "",
|
||||||
priority: "10",
|
priority: "10",
|
||||||
@@ -96,18 +108,19 @@ function emptyTier(): TierForm {
|
|||||||
toHour: "",
|
toHour: "",
|
||||||
dateFrom: "",
|
dateFrom: "",
|
||||||
dateTo: "",
|
dateTo: "",
|
||||||
pricing: { ...emptyLadder(), blocks: [{ hours: "", price: "1.00" }] },
|
pricing: { ...emptyLadder(currency), blocks: [{ hours: "", price: examples(currency).lo }] },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function emptyForm(): FormState {
|
export function emptyForm(): FormState {
|
||||||
|
const currency = "ALL"; // the site's currency — examples scale with it
|
||||||
return {
|
return {
|
||||||
currency: "ALL",
|
currency,
|
||||||
gracePeriodEntryMin: "15",
|
gracePeriodEntryMin: "15",
|
||||||
incrementMin: "60",
|
incrementMin: "60",
|
||||||
lostTicket: "20.00",
|
lostTicket: examples(currency).lost,
|
||||||
gracePeriodExitMin: "15",
|
gracePeriodExitMin: "15",
|
||||||
base: emptyLadder(),
|
base: emptyLadder(currency),
|
||||||
tiers: [],
|
tiers: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -132,31 +145,34 @@ function stepsToForm(steps: TariffStep[]): StepForm[] {
|
|||||||
|
|
||||||
// A stored card (V2) or bare-V1 body → the form's PricingForm (flat, ladder, stepped,
|
// A stored card (V2) or bare-V1 body → the form's PricingForm (flat, ladder, stepped,
|
||||||
// or window package).
|
// or window package).
|
||||||
function pricingFromCard(c: {
|
function pricingFromCard(
|
||||||
|
c: {
|
||||||
flatMinor?: number;
|
flatMinor?: number;
|
||||||
blocks?: TariffBlock[];
|
blocks?: TariffBlock[];
|
||||||
steps?: TariffStep[];
|
steps?: TariffStep[];
|
||||||
packageMinor?: number;
|
packageMinor?: number;
|
||||||
dailyCapMinor?: number | null;
|
dailyCapMinor?: number | null;
|
||||||
}): PricingForm {
|
},
|
||||||
|
currency: string,
|
||||||
|
): PricingForm {
|
||||||
if (c.steps != null && c.steps.length > 0) {
|
if (c.steps != null && c.steps.length > 0) {
|
||||||
return { ...emptyLadder(), mode: "stepped", steps: stepsToForm(c.steps) };
|
return { ...emptyLadder(currency), mode: "stepped", steps: stepsToForm(c.steps) };
|
||||||
}
|
}
|
||||||
if (c.packageMinor != null) {
|
if (c.packageMinor != null) {
|
||||||
return { ...emptyLadder(), mode: "package", packageTotal: toMajor(c.packageMinor) };
|
return { ...emptyLadder(currency), mode: "package", packageTotal: toMajor(c.packageMinor) };
|
||||||
}
|
}
|
||||||
if (c.flatMinor != null) {
|
if (c.flatMinor != null) {
|
||||||
return { ...emptyLadder(), mode: "flat", flat: toMajor(c.flatMinor) };
|
return { ...emptyLadder(currency), mode: "flat", flat: toMajor(c.flatMinor) };
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
...emptyLadder(),
|
...emptyLadder(currency),
|
||||||
mode: "ladder",
|
mode: "ladder",
|
||||||
dailyCap: c.dailyCapMinor == null ? "" : toMajor(c.dailyCapMinor),
|
dailyCap: c.dailyCapMinor == null ? "" : toMajor(c.dailyCapMinor),
|
||||||
blocks: blocksToForm(c.blocks ?? []),
|
blocks: blocksToForm(c.blocks ?? []),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function tierFromCard(c: TariffCard): TierForm {
|
function tierFromCard(c: TariffCard, currency: string): TierForm {
|
||||||
const w = c.window ?? {};
|
const w = c.window ?? {};
|
||||||
return {
|
return {
|
||||||
name: c.name,
|
name: c.name,
|
||||||
@@ -167,7 +183,7 @@ function tierFromCard(c: TariffCard): TierForm {
|
|||||||
toHour: w.toHour ?? "",
|
toHour: w.toHour ?? "",
|
||||||
dateFrom: w.dateFrom ?? "",
|
dateFrom: w.dateFrom ?? "",
|
||||||
dateTo: w.dateTo ?? "",
|
dateTo: w.dateTo ?? "",
|
||||||
pricing: pricingFromCard(c),
|
pricing: pricingFromCard(c, currency),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,10 +198,14 @@ export function formFromVersion(currency: string, st: TariffStructure): FormStat
|
|||||||
gracePeriodExitMin: String(st.gracePeriodExitMin),
|
gracePeriodExitMin: String(st.gracePeriodExitMin),
|
||||||
};
|
};
|
||||||
if (isTariffV2(st)) {
|
if (isTariffV2(st)) {
|
||||||
return { ...common, base: pricingFromCard(st.defaultCard), tiers: (st.windowedCards ?? []).map(tierFromCard) };
|
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.
|
// V1: the bare ladder becomes the default card body; no tiers.
|
||||||
return { ...common, base: pricingFromCard(st), tiers: [] };
|
return { ...common, base: pricingFromCard(st, currency), tiers: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formFromActive(s: TariffState): FormState {
|
export function formFromActive(s: TariffState): FormState {
|
||||||
@@ -278,6 +298,8 @@ export function TariffEditorForm({
|
|||||||
onChange: (update: (f: FormState) => FormState) => void;
|
onChange: (update: (f: FormState) => FormState) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
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]) {
|
function set<K extends keyof FormState>(key: K, value: FormState[K]) {
|
||||||
onChange((f) => ({ ...f, [key]: value }));
|
onChange((f) => ({ ...f, [key]: value }));
|
||||||
@@ -322,7 +344,7 @@ export function TariffEditorForm({
|
|||||||
onChange((f) => ({ ...f, tiers: f.tiers.map((tr, j) => (j === i ? { ...tr, ...patch } : tr)) }));
|
onChange((f) => ({ ...f, tiers: f.tiers.map((tr, j) => (j === i ? { ...tr, ...patch } : tr)) }));
|
||||||
}
|
}
|
||||||
function addTier() {
|
function addTier() {
|
||||||
onChange((f) => ({ ...f, tiers: [...f.tiers, emptyTier()] }));
|
onChange((f) => ({ ...f, tiers: [...f.tiers, emptyTier(f.currency)] }));
|
||||||
}
|
}
|
||||||
function removeTier(i: number) {
|
function removeTier(i: number) {
|
||||||
onChange((f) => ({ ...f, tiers: f.tiers.filter((_, j) => j !== i) }));
|
onChange((f) => ({ ...f, tiers: f.tiers.filter((_, j) => j !== i) }));
|
||||||
@@ -355,6 +377,15 @@ export function TariffEditorForm({
|
|||||||
<input className="input w-32" value={form.gracePeriodExitMin} onChange={(e) => set("gracePeriodExitMin", e.target.value)} />
|
<input className="input w-32" value={form.gracePeriodExitMin} onChange={(e) => set("gracePeriodExitMin", e.target.value)} />
|
||||||
</div>
|
</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
|
{/* The DEFAULT card — always-active rate. Front-and-centre; a site that never
|
||||||
wants tiers just edits this and publishes a bare V1 structure. */}
|
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>
|
<h3 className="mt-6 mb-0.5 text-h6 font-semibold uppercase tracking-wider text-term-text">{t("tariff.defaultCard")}</h3>
|
||||||
@@ -363,6 +394,7 @@ export function TariffEditorForm({
|
|||||||
<PricingEditor
|
<PricingEditor
|
||||||
t={t}
|
t={t}
|
||||||
pricing={form.base}
|
pricing={form.base}
|
||||||
|
incrementMin={inc}
|
||||||
allowStepped
|
allowStepped
|
||||||
onMode={(mode) => updatePricing("base", (p) => ({ ...p, mode }))}
|
onMode={(mode) => updatePricing("base", (p) => ({ ...p, mode }))}
|
||||||
onFlat={(flat) => updatePricing("base", (p) => ({ ...p, flat }))}
|
onFlat={(flat) => updatePricing("base", (p) => ({ ...p, flat }))}
|
||||||
@@ -434,6 +466,7 @@ export function TariffEditorForm({
|
|||||||
<PricingEditor
|
<PricingEditor
|
||||||
t={t}
|
t={t}
|
||||||
pricing={tr.pricing}
|
pricing={tr.pricing}
|
||||||
|
incrementMin={inc}
|
||||||
allowPackage
|
allowPackage
|
||||||
onMode={(mode) => updatePricing(i, (p) => ({ ...p, mode }))}
|
onMode={(mode) => updatePricing(i, (p) => ({ ...p, mode }))}
|
||||||
onFlat={(flat) => updatePricing(i, (p) => ({ ...p, flat }))}
|
onFlat={(flat) => updatePricing(i, (p) => ({ ...p, flat }))}
|
||||||
@@ -459,8 +492,11 @@ export function TariffEditorForm({
|
|||||||
// (the default card); the package mode only where `allowPackage` (tier cards — the
|
// (the default card); the package mode only where `allowPackage` (tier cards — the
|
||||||
// engine needs a window to be an occurrence of).
|
// engine needs a window to be an occurrence of).
|
||||||
function PricingEditor(props: {
|
function PricingEditor(props: {
|
||||||
t: (k: string) => string;
|
t: (k: string, opts?: Record<string, unknown>) => string;
|
||||||
pricing: PricingForm;
|
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;
|
allowStepped?: boolean;
|
||||||
allowPackage?: boolean;
|
allowPackage?: boolean;
|
||||||
onMode: (m: "ladder" | "flat" | "stepped" | "package") => void;
|
onMode: (m: "ladder" | "flat" | "stepped" | "package") => void;
|
||||||
@@ -475,6 +511,18 @@ function PricingEditor(props: {
|
|||||||
onRemoveStep?: (i: number) => void;
|
onRemoveStep?: (i: number) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t, pricing: p } = props;
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-3 flex flex-wrap gap-4 text-[0.75rem]">
|
<div className="mb-3 flex flex-wrap gap-4 text-[0.75rem]">
|
||||||
@@ -484,7 +532,7 @@ function PricingEditor(props: {
|
|||||||
</label>
|
</label>
|
||||||
<label className="inline-flex items-center gap-1.5 text-term-text">
|
<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")} />
|
<input type="radio" className="accent-term-amber" checked={p.mode === "flat"} onChange={() => props.onMode("flat")} />
|
||||||
{t("tariff.modeFlat")}
|
{flatLabel}
|
||||||
</label>
|
</label>
|
||||||
{props.allowStepped && (
|
{props.allowStepped && (
|
||||||
<label className="inline-flex items-center gap-1.5 text-term-text">
|
<label className="inline-flex items-center gap-1.5 text-term-text">
|
||||||
@@ -550,8 +598,9 @@ function PricingEditor(props: {
|
|||||||
</>
|
</>
|
||||||
) : p.mode === "flat" ? (
|
) : p.mode === "flat" ? (
|
||||||
<div className="inline-flex items-center gap-2">
|
<div className="inline-flex items-center gap-2">
|
||||||
<span className="label">{t("tariff.pricePerIncrement")}</span>
|
<span className="label">{unitLabel}</span>
|
||||||
<input className="input w-28" value={p.flat} onChange={(e) => props.onFlat(e.target.value)} />
|
<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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -559,7 +608,7 @@ function PricingEditor(props: {
|
|||||||
<thead>
|
<thead>
|
||||||
<tr className="text-left">
|
<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.bandDuration")}</th>
|
||||||
<th className="label px-2 pb-1 font-normal">{t("tariff.pricePerIncrement")}</th>
|
<th className="label px-2 pb-1 font-normal">{unitLabel}</th>
|
||||||
<th />
|
<th />
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -579,7 +628,10 @@ function PricingEditor(props: {
|
|||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-2 py-1">
|
<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 })} />
|
<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>
|
||||||
<td className="px-2">
|
<td className="px-2">
|
||||||
{!isTail && (
|
{!isTail && (
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ import {
|
|||||||
} from "./api.js";
|
} from "./api.js";
|
||||||
import { TariffEditorForm, emptyForm, formFromActive, formFromVersion, toStructure, type FormState } from "./TariffEditorForm.js";
|
import { TariffEditorForm, emptyForm, formFromActive, formFromVersion, toStructure, type FormState } from "./TariffEditorForm.js";
|
||||||
import { Modal } from "./ui/Modal.js";
|
import { Modal } from "./ui/Modal.js";
|
||||||
import { formatMoney, formatDuration } from "./lib/format.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 sandbox for composing + pricing EXPERIMENTAL rate cards. Drafts
|
// 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
|
// live in their own mutable table (tariff_drafts), so experimenting never churns the
|
||||||
@@ -202,7 +204,7 @@ export function TariffLab() {
|
|||||||
{selectedDraft
|
{selectedDraft
|
||||||
? selectedDraft.name
|
? selectedDraft.name
|
||||||
: selectedVersion
|
: selectedVersion
|
||||||
? selectedVersion.name ?? new Date(selectedVersion.effectiveFrom).toLocaleString()
|
? selectedVersion.name ?? formatDateTime(selectedVersion.effectiveFrom, t)
|
||||||
: t("lab.activeTariff")}
|
: t("lab.activeTariff")}
|
||||||
</span>
|
</span>
|
||||||
{selectedDraft && (
|
{selectedDraft && (
|
||||||
@@ -264,14 +266,19 @@ export function TariffLab() {
|
|||||||
)}
|
)}
|
||||||
</dd>
|
</dd>
|
||||||
<dt className="text-term-muted">{t("lab.periodStart")}</dt>
|
<dt className="text-term-muted">{t("lab.periodStart")}</dt>
|
||||||
<dd className="text-term-text">{new Date(result.pricing.periodStart).toLocaleString()}</dd>
|
<dd className="text-term-text">{formatDateTime(result.pricing.periodStart, t)}</dd>
|
||||||
{result.pricing.graceExpiresAt && (
|
{result.pricing.graceExpiresAt && (
|
||||||
<>
|
<>
|
||||||
<dt className="text-term-muted">{t("lab.graceExpires")}</dt>
|
<dt className="text-term-muted">{t("lab.graceExpires")}</dt>
|
||||||
<dd className="text-term-text">{new Date(result.pricing.graceExpiresAt).toLocaleString()}</dd>
|
<dd className="text-term-text">{formatDateTime(result.pricing.graceExpiresAt, t)}</dd>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</dl>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Duration curve from entry — see where the cap flattens / windows shift. */}
|
{/* Duration curve from entry — see where the cap flattens / windows shift. */}
|
||||||
@@ -316,7 +323,7 @@ export function TariffLab() {
|
|||||||
>
|
>
|
||||||
<span className="block font-semibold">{d.name}</span>
|
<span className="block font-semibold">{d.name}</span>
|
||||||
<span className="block text-[0.6875rem] text-term-muted">
|
<span className="block text-[0.6875rem] text-term-muted">
|
||||||
{d.currency} · {new Date(d.updatedAt).toLocaleString()}
|
{d.currency} · {formatDateTime(d.updatedAt, t)}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
@@ -345,7 +352,7 @@ export function TariffLab() {
|
|||||||
{state?.active?.name ? ` — ${state.active.name}` : ""}
|
{state?.active?.name ? ` — ${state.active.name}` : ""}
|
||||||
</span>
|
</span>
|
||||||
<span className="block text-[0.6875rem] text-term-muted">
|
<span className="block text-[0.6875rem] text-term-muted">
|
||||||
{state?.active ? new Date(state.active.effectiveFrom).toLocaleString() : t("tariff.noRateCard")}
|
{state?.active ? formatDateTime(state.active.effectiveFrom, t) : t("tariff.noRateCard")}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
@@ -363,10 +370,10 @@ export function TariffLab() {
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="block font-semibold">
|
<span className="block font-semibold">
|
||||||
{v.name ?? new Date(v.effectiveFrom).toLocaleString()}
|
{v.name ?? formatDateTime(v.effectiveFrom, t)}
|
||||||
</span>
|
</span>
|
||||||
<span className="block text-[0.6875rem] text-term-muted">
|
<span className="block text-[0.6875rem] text-term-muted">
|
||||||
{v.name ? `${new Date(v.effectiveFrom).toLocaleString()} · ` : ""}
|
{v.name ? `${formatDateTime(v.effectiveFrom, t)} · ` : ""}
|
||||||
{v.currency}
|
{v.currency}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -416,3 +423,82 @@ function labelMin(min: number): string {
|
|||||||
if (min < 1440) return `${min / 60}h`;
|
if (min < 1440) return `${min / 60}h`;
|
||||||
return `${min / 1440}d`;
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+126
-1
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
import { logFailedRequest } from "./lib/logger.js";
|
import { logFailedRequest } from "./lib/logger.js";
|
||||||
import { apiUrl } from "./lib/origin.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_COOKIE = "parking_csrf";
|
||||||
const CSRF_HEADER = "X-CSRF-Token";
|
const CSRF_HEADER = "X-CSRF-Token";
|
||||||
@@ -253,6 +253,15 @@ export async function fetchBackupStatus(): Promise<BackupStatus> {
|
|||||||
return apiFetch("/api/backup/status");
|
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 {
|
export interface BackupConfigPatch {
|
||||||
/** "" clears the target. Omit a field to leave it unchanged; null resets retention to default. */
|
/** "" clears the target. Omit a field to leave it unchanged; null resets retention to default. */
|
||||||
targetDir?: string | null;
|
targetDir?: string | null;
|
||||||
@@ -483,7 +492,11 @@ export interface ReportSeriesPoint {
|
|||||||
entries: number;
|
entries: number;
|
||||||
exits: number;
|
exits: number;
|
||||||
revenueMinor: number;
|
revenueMinor: number;
|
||||||
|
cashMinor: number;
|
||||||
|
cardMinor: number;
|
||||||
payments: number;
|
payments: number;
|
||||||
|
/** Cars inside at the END of the bucket. */
|
||||||
|
occupancyEnd: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReportTotals {
|
export interface ReportTotals {
|
||||||
@@ -500,6 +513,8 @@ export interface ReportTotals {
|
|||||||
totalParkedMinutes: number;
|
totalParkedMinutes: number;
|
||||||
avgParkedMinutes: number;
|
avgParkedMinutes: number;
|
||||||
medianParkedMinutes: number;
|
medianParkedMinutes: number;
|
||||||
|
voids: number;
|
||||||
|
anomalies: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReportSubscriptionStats {
|
export interface ReportSubscriptionStats {
|
||||||
@@ -519,6 +534,12 @@ export interface ReportSummary {
|
|||||||
totals: ReportTotals;
|
totals: ReportTotals;
|
||||||
series: ReportSeriesPoint[];
|
series: ReportSeriesPoint[];
|
||||||
entriesByHour: number[];
|
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;
|
subscriptions: ReportSubscriptionStats;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -592,6 +613,11 @@ export interface AssignBody {
|
|||||||
backendIp?: string;
|
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. */
|
/** Save + configure the device (preconditions, push setup), then persist. */
|
||||||
export function assignDevice(body: AssignBody): Promise<AssignResult> {
|
export function assignDevice(body: AssignBody): Promise<AssignResult> {
|
||||||
return apiFetch("/api/setup/assign", { method: "POST", body: JSON.stringify(body) });
|
return apiFetch("/api/setup/assign", { method: "POST", body: JSON.stringify(body) });
|
||||||
@@ -750,6 +776,9 @@ export interface SimSessionPricing {
|
|||||||
export interface SimulateResult {
|
export interface SimulateResult {
|
||||||
currency: string | null;
|
currency: string | null;
|
||||||
pricing: SimSessionPricing;
|
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 }[];
|
curve: { minutes: number; amountMinor: number }[];
|
||||||
gracePeriodExitMin: number;
|
gracePeriodExitMin: number;
|
||||||
}
|
}
|
||||||
@@ -1118,6 +1147,12 @@ export function fetchDrawerMovements(status?: MovementStatus): Promise<{
|
|||||||
return apiFetch(`/api/drawer/movements${qs}`);
|
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). */
|
/** Admin AUTHORIZES or DENIES a recorded movement (a flag — never a cash reversal). */
|
||||||
export function reviewDrawerMovement(args: {
|
export function reviewDrawerMovement(args: {
|
||||||
refId: string;
|
refId: string;
|
||||||
@@ -1275,6 +1310,11 @@ export interface SessionLookup {
|
|||||||
subscriptionHolder: string | null;
|
subscriptionHolder: string | null;
|
||||||
/** Advisory licence plate recognized for this session (ANPR). Null when none. */
|
/** Advisory licence plate recognized for this session (ANPR). Null when none. */
|
||||||
plate: string | null;
|
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). */
|
/** Look up a ticket/session for the booth modal (entry/exit, paid, amount owed). */
|
||||||
@@ -1449,3 +1489,88 @@ export function updatePresenceBypass(patch: { radar?: boolean; camera?: boolean
|
|||||||
export function setCapacity(capacity: number | null): Promise<SiteConfig> {
|
export function setCapacity(capacity: number | null): Promise<SiteConfig> {
|
||||||
return saveSiteConfig({ capacity });
|
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) });
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
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 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/
|
// 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", () => {
|
describe("formatRelativeDateTime", () => {
|
||||||
// A tiny fake t(): today/yesterday words + the month-name array.
|
// 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"];
|
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}$/);
|
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", () => {
|
it("labels yesterday with the localized word", () => {
|
||||||
const y = new Date();
|
const y = new Date();
|
||||||
y.setDate(y.getDate() - 1);
|
y.setDate(y.getDate() - 1);
|
||||||
|
|||||||
+49
-18
@@ -46,13 +46,6 @@ export function formatMinutes(mins: number): string {
|
|||||||
return h > 0 ? `${h}h ${m % 60}m` : `${m}m`;
|
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
|
/** 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). */
|
* before ref, etc. Compares date parts only (ignores time-of-day). */
|
||||||
function dayDiff(d: Date, ref: Date): number {
|
function dayDiff(d: Date, ref: Date): number {
|
||||||
@@ -61,10 +54,11 @@ function dayDiff(d: Date, ref: Date): number {
|
|||||||
return Math.round((b.getTime() - a.getTime()) / 86_400_000);
|
return Math.round((b.getTime() - a.getTime()) / 86_400_000);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** HH:MM (local, 24h) for the relative-day labels. */
|
/** HH:MM (local, 24h) for the relative-day labels; ":ss" appended when `seconds`. */
|
||||||
function hhmm(d: Date): string {
|
function hhmm(d: Date, seconds = false): string {
|
||||||
const p = (n: number) => String(n).padStart(2, "0");
|
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
|
/** Minimal shape of i18next's `t` that we rely on: a string lookup, plus the
|
||||||
@@ -85,6 +79,42 @@ function monthName(d: Date, t: TFn): string {
|
|||||||
return String(d.getMonth() + 1);
|
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
|
* 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
|
* today reads "Sot 10:48", yesterday "Dje 17:33", and anything older a localized
|
||||||
@@ -93,17 +123,18 @@ function monthName(d: Date, t: TFn): string {
|
|||||||
*
|
*
|
||||||
* `t` supplies the today/yesterday words AND the month names (the appliance browser
|
* `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).
|
* 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 "—";
|
if (!iso) return "—";
|
||||||
const d = new Date(iso);
|
const d = new Date(iso);
|
||||||
if (Number.isNaN(d.getTime())) return "—";
|
if (Number.isNaN(d.getTime())) return "—";
|
||||||
|
const time = hhmm(d, opts?.seconds);
|
||||||
const diff = dayDiff(d, new Date());
|
const diff = dayDiff(d, new Date());
|
||||||
if (diff === 0) return `${t("common.today")} ${hhmm(d)}`;
|
if (diff === 0) return `${t("common.today")} ${time}`;
|
||||||
if (diff === 1) return `${t("common.yesterday")} ${hhmm(d)}`;
|
if (diff === 1) return `${t("common.yesterday")} ${time}`;
|
||||||
// Older (or future): "17 Qershor 10:48", with the year only if it differs.
|
// Older (or future): "17 Qer 10:48" — the short-month standard, year only if it differs.
|
||||||
const sameYear = d.getFullYear() === new Date().getFullYear();
|
return `${formatDate(iso, t)} ${time}`;
|
||||||
const month = monthName(d, t);
|
|
||||||
const date = sameYear ? `${d.getDate()} ${month}` : `${d.getDate()} ${month} ${d.getFullYear()}`;
|
|
||||||
return `${date} ${hhmm(d)}`;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export const en: Catalog = {
|
|||||||
"November",
|
"November",
|
||||||
"December",
|
"December",
|
||||||
],
|
],
|
||||||
|
monthsShort: ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"],
|
||||||
},
|
},
|
||||||
auth: {
|
auth: {
|
||||||
title: "Parking System",
|
title: "Parking System",
|
||||||
@@ -64,8 +65,19 @@ export const en: Catalog = {
|
|||||||
logs: "Logs",
|
logs: "Logs",
|
||||||
backup: "Backup",
|
backup: "Backup",
|
||||||
profile: "Profile",
|
profile: "Profile",
|
||||||
|
validate: "Validations",
|
||||||
},
|
},
|
||||||
drawer: {
|
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",
|
recordTitle: "Record a cash movement",
|
||||||
amount: "amount",
|
amount: "amount",
|
||||||
reasonPlaceholder: "reason (e.g. supplier payment, bank drop)",
|
reasonPlaceholder: "reason (e.g. supplier payment, bank drop)",
|
||||||
@@ -219,6 +231,7 @@ export const en: Catalog = {
|
|||||||
evtCashOut: "PAY-OUT",
|
evtCashOut: "PAY-OUT",
|
||||||
evtCashReview: "REVIEW",
|
evtCashReview: "REVIEW",
|
||||||
evtConfigChange: "CONFIG",
|
evtConfigChange: "CONFIG",
|
||||||
|
evtValidation: "VALIDATION",
|
||||||
decision: { authorize: "authorized", deny: "denied" },
|
decision: { authorize: "authorized", deny: "denied" },
|
||||||
evtAnomaly: "ANOMALY",
|
evtAnomaly: "ANOMALY",
|
||||||
evtRefused: "REFUSED",
|
evtRefused: "REFUSED",
|
||||||
@@ -320,12 +333,21 @@ export const en: Catalog = {
|
|||||||
hoursUnit: "hours",
|
hoursUnit: "hours",
|
||||||
egHours: "e.g. 2",
|
egHours: "e.g. 2",
|
||||||
pricePerIncrement: "Price / increment (per hour)",
|
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)",
|
thereafter: "thereafter (open-ended)",
|
||||||
remove: "Remove",
|
remove: "Remove",
|
||||||
addBlock: "+ Add block",
|
addBlock: "+ Add block",
|
||||||
publishNewVersion: "Publish new version",
|
publishNewVersion: "Publish new version",
|
||||||
publishing: "Publishing…",
|
publishing: "Publishing…",
|
||||||
versionNamePh: "Version name (optional), e.g. Summer 2026",
|
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.",
|
publishedOk: "New tariff version published — it's now the active rate.",
|
||||||
defaultCard: "Base rate (always active)",
|
defaultCard: "Base rate (always active)",
|
||||||
defaultCardHint: "The base rate applied when no time/seasonal tier matches. This alone is enough for most car parks.",
|
defaultCardHint: "The base rate applied when no time/seasonal tier matches. This alone is enough for most car parks.",
|
||||||
@@ -399,6 +421,8 @@ export const en: Catalog = {
|
|||||||
scan: "Scan for controllers",
|
scan: "Scan for controllers",
|
||||||
scanning: "Scanning…",
|
scanning: "Scanning…",
|
||||||
noControllersFound: "No controllers found on the LAN.",
|
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",
|
use: "Use",
|
||||||
test: "Test connection",
|
test: "Test connection",
|
||||||
testing: "Testing…",
|
testing: "Testing…",
|
||||||
@@ -549,6 +573,16 @@ export const en: Catalog = {
|
|||||||
graceExpires: "Grace expires",
|
graceExpires: "Grace expires",
|
||||||
curve: "Duration curve",
|
curve: "Duration curve",
|
||||||
curveHint: "Fee from entry at several durations — see where the daily cap flattens or windows shift.",
|
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: {
|
subs: {
|
||||||
title: "Subscriptions",
|
title: "Subscriptions",
|
||||||
@@ -703,6 +737,51 @@ export const en: Catalog = {
|
|||||||
fieldPhone: "Phone",
|
fieldPhone: "Phone",
|
||||||
fieldEmail: "Email",
|
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: {
|
users: {
|
||||||
title: "Users",
|
title: "Users",
|
||||||
add: "+ Add user",
|
add: "+ Add user",
|
||||||
@@ -853,14 +932,23 @@ export const en: Catalog = {
|
|||||||
payments: "Payments",
|
payments: "Payments",
|
||||||
avgStay: "Avg stay",
|
avgStay: "Avg stay",
|
||||||
subscribers: "Subscribers",
|
subscribers: "Subscribers",
|
||||||
|
peakOcc: "Peak occupancy",
|
||||||
|
voids: "Voided tickets",
|
||||||
|
anomalies: "Anomalies",
|
||||||
},
|
},
|
||||||
chart: {
|
chart: {
|
||||||
flow: "Entries & exits over time",
|
flow: "Entries & exits over time",
|
||||||
|
occupancy: "Occupancy — cars inside",
|
||||||
|
occupancySeries: "Cars inside",
|
||||||
revenue: "Revenue ({{currency}})",
|
revenue: "Revenue ({{currency}})",
|
||||||
mix: "Revenue mix",
|
mix: "Revenue mix",
|
||||||
peakHours: "Entries by hour of day",
|
stay: "Stay duration (closed sessions)",
|
||||||
|
heatmap: "Entries heatmap — hour × day",
|
||||||
breakdown: "Breakdown",
|
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" },
|
mix: { ticket: "Transient", subSales: "Subscriptions", subWindow: "Out-of-window" },
|
||||||
row: {
|
row: {
|
||||||
cash: "Cash",
|
cash: "Cash",
|
||||||
@@ -906,6 +994,7 @@ export const en: Catalog = {
|
|||||||
status: "Status",
|
status: "Status",
|
||||||
path: "Path",
|
path: "Path",
|
||||||
empty: "No logs.",
|
empty: "No logs.",
|
||||||
|
repeated: "Repeated {{count}} times (first at {{firstAt}})",
|
||||||
},
|
},
|
||||||
backup: {
|
backup: {
|
||||||
title: "Backup",
|
title: "Backup",
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ export const sq = {
|
|||||||
"Nëntor",
|
"Nëntor",
|
||||||
"Dhjetor",
|
"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: {
|
auth: {
|
||||||
title: "Sistemi i Parkimit",
|
title: "Sistemi i Parkimit",
|
||||||
@@ -66,8 +68,19 @@ export const sq = {
|
|||||||
logs: "Loget",
|
logs: "Loget",
|
||||||
backup: "Kopje rezervë",
|
backup: "Kopje rezervë",
|
||||||
profile: "Profili",
|
profile: "Profili",
|
||||||
|
validate: "Validime",
|
||||||
},
|
},
|
||||||
drawer: {
|
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",
|
recordTitle: "Regjistro një lëvizje arke",
|
||||||
amount: "shuma",
|
amount: "shuma",
|
||||||
reasonPlaceholder: "arsyeja (p.sh. pagesë furnitori, depozitë banke)",
|
reasonPlaceholder: "arsyeja (p.sh. pagesë furnitori, depozitë banke)",
|
||||||
@@ -223,6 +236,7 @@ export const sq = {
|
|||||||
evtCashOut: "PAGESË",
|
evtCashOut: "PAGESË",
|
||||||
evtCashReview: "SHQYRTIM",
|
evtCashReview: "SHQYRTIM",
|
||||||
evtConfigChange: "KONFIG",
|
evtConfigChange: "KONFIG",
|
||||||
|
evtValidation: "VALIDIM",
|
||||||
decision: { authorize: "autorizuar", deny: "refuzuar" },
|
decision: { authorize: "autorizuar", deny: "refuzuar" },
|
||||||
evtAnomaly: "ANOMALI",
|
evtAnomaly: "ANOMALI",
|
||||||
evtRefused: "REFUZUAR",
|
evtRefused: "REFUZUAR",
|
||||||
@@ -322,13 +336,22 @@ export const sq = {
|
|||||||
bandDuration: "Kohëzgjatja e brezit",
|
bandDuration: "Kohëzgjatja e brezit",
|
||||||
hoursUnit: "orë",
|
hoursUnit: "orë",
|
||||||
egHours: "p.sh. 2",
|
egHours: "p.sh. 2",
|
||||||
pricePerIncrement: "Çmimi / interval (orë)",
|
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)",
|
thereafter: "më pas (i hapur)",
|
||||||
remove: "Hiq",
|
remove: "Hiq",
|
||||||
addBlock: "+ Shto bllok",
|
addBlock: "+ Shto bllok",
|
||||||
publishNewVersion: "Publiko version të ri",
|
publishNewVersion: "Publiko version të ri",
|
||||||
publishing: "Duke publikuar…",
|
publishing: "Duke publikuar…",
|
||||||
versionNamePh: "Emri i versionit (opsional), p.sh. Vera 2026",
|
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.",
|
publishedOk: "U publikua versioni i ri i tarifës — tani është tarifa aktive.",
|
||||||
defaultCard: "Tarifa bazë (gjithmonë 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.",
|
defaultCardHint: "Çmimi bazë i zbatuar kur asnjë nivel kohor/sezonal nuk vlen. Kjo e vetme është mjaftueshëm për shumicën e parkimeve.",
|
||||||
@@ -407,6 +430,8 @@ export const sq = {
|
|||||||
scan: "Skano për kontroller",
|
scan: "Skano për kontroller",
|
||||||
scanning: "Duke skanuar…",
|
scanning: "Duke skanuar…",
|
||||||
noControllersFound: "Asnjë kontroller në LAN.",
|
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",
|
use: "Përdor",
|
||||||
test: "Testo lidhjen",
|
test: "Testo lidhjen",
|
||||||
testing: "Duke testuar…",
|
testing: "Duke testuar…",
|
||||||
@@ -499,10 +524,10 @@ export const sq = {
|
|||||||
// Anashkalimi i portës së pranisë (radar/kamera me defekt) — admini heq një sinjal si kusht.
|
// Anashkalimi i portës së pranisë (radar/kamera me defekt) — admini heq një sinjal si kusht.
|
||||||
presenceGateTitle: "Porta e pranisë në hyrje",
|
presenceGateTitle: "Porta e pranisë në hyrje",
|
||||||
presenceGateHint:
|
presenceGateHint:
|
||||||
"Butoni i hyrjes normalisht kërkon edhe radarin/lakun edhe një zbulim nga kamera për të konfirmuar një automjet real. Nëse një pajisje ka defekt, anashkaloje që kalimtarët të mund të hyjnë derisa ta rregullojë ekipi i mbështetjes. Çdo ndryshim regjistrohet në ledger, dhe biletat e lëshuara gjatë anashkalimit shënohen.",
|
"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 / lakun (sensor prania me defekt)",
|
presenceBypassRadar: "Anashkalo radarin (radari me defekt)",
|
||||||
presenceBypassCamera: "Anashkalo kamerën (zbulim automjeti me defekt)",
|
presenceBypassCamera: "Anashkalo kamerën (kamera me defekt)",
|
||||||
presenceBypassActive: "Anashkalimi i pranisë aktiv — porta e hyrjes është dobësuar. Fike sapo pajisja të rregullohet.",
|
presenceBypassActive: "Anashkalimi i pranisë aktiv — siguria e hyrjes është dobësuar.",
|
||||||
// Reveal/hide toggle for a secret field (e.g. the device web password).
|
// Reveal/hide toggle for a secret field (e.g. the device web password).
|
||||||
revealSecret: "Shfaq fjalëkalimin",
|
revealSecret: "Shfaq fjalëkalimin",
|
||||||
hideSecret: "Fshih fjalëkalimin",
|
hideSecret: "Fshih fjalëkalimin",
|
||||||
@@ -561,6 +586,16 @@ export const sq = {
|
|||||||
graceExpires: "Afati skadon",
|
graceExpires: "Afati skadon",
|
||||||
curve: "Kurba sipas kohëzgjatjes",
|
curve: "Kurba sipas kohëzgjatjes",
|
||||||
curveHint: "Tarifa nga hyrja për disa kohëzgjatje — shih ku rrafshohet kufiri ditor ose ndryshojnë dritaret.",
|
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: {
|
subs: {
|
||||||
title: "Abonimet",
|
title: "Abonimet",
|
||||||
@@ -715,6 +750,51 @@ export const sq = {
|
|||||||
fieldPhone: "Telefoni",
|
fieldPhone: "Telefoni",
|
||||||
fieldEmail: "Email",
|
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: {
|
users: {
|
||||||
title: "Përdoruesit",
|
title: "Përdoruesit",
|
||||||
add: "+ Shto përdorues",
|
add: "+ Shto përdorues",
|
||||||
@@ -868,14 +948,23 @@ export const sq = {
|
|||||||
payments: "Pagesa",
|
payments: "Pagesa",
|
||||||
avgStay: "Qëndrim mes.",
|
avgStay: "Qëndrim mes.",
|
||||||
subscribers: "Abonentë",
|
subscribers: "Abonentë",
|
||||||
|
peakOcc: "Zënia maksimale",
|
||||||
|
voids: "Bileta të anuluara",
|
||||||
|
anomalies: "Anomali",
|
||||||
},
|
},
|
||||||
chart: {
|
chart: {
|
||||||
flow: "Hyrjet & daljet me kalimin e kohës",
|
flow: "Hyrjet & daljet me kalimin e kohës",
|
||||||
|
occupancy: "Zënia — makina brenda",
|
||||||
|
occupancySeries: "Makina brenda",
|
||||||
revenue: "Të ardhurat ({{currency}})",
|
revenue: "Të ardhurat ({{currency}})",
|
||||||
mix: "Përbërja e të ardhurave",
|
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",
|
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" },
|
mix: { ticket: "Tranzit", subSales: "Abonime", subWindow: "Jashtë orarit" },
|
||||||
row: {
|
row: {
|
||||||
cash: "Para në dorë",
|
cash: "Para në dorë",
|
||||||
@@ -921,6 +1010,7 @@ export const sq = {
|
|||||||
status: "Statusi",
|
status: "Statusi",
|
||||||
path: "Rruga",
|
path: "Rruga",
|
||||||
empty: "Asnjë regjistër.",
|
empty: "Asnjë regjistër.",
|
||||||
|
repeated: "Përsëritur {{count}} herë (hera e parë {{firstAt}})",
|
||||||
},
|
},
|
||||||
backup: {
|
backup: {
|
||||||
title: "Kopje rezervë",
|
title: "Kopje rezervë",
|
||||||
|
|||||||
@@ -23,7 +23,14 @@ type WsMessage =
|
|||||||
| { kind: "plate-recognized"; plate: { identity: string; plate: string; direction: "entry" | "exit" } };
|
| { 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 qc = useQueryClient();
|
||||||
const { setStatus, setOccupancy, pushEvent, setDevices, upsertDevice, setLanes, setRadar, patchPlate } =
|
const { setStatus, setOccupancy, pushEvent, setDevices, upsertDevice, setLanes, setRadar, patchPlate } =
|
||||||
useLiveStore();
|
useLiveStore();
|
||||||
@@ -34,6 +41,10 @@ export function useLiveFeed(): void {
|
|||||||
const closedRef = useRef(false);
|
const closedRef = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!enabled) {
|
||||||
|
setStatus("closed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
closedRef.current = false;
|
closedRef.current = false;
|
||||||
|
|
||||||
const connect = () => {
|
const connect = () => {
|
||||||
@@ -117,7 +128,8 @@ export function useLiveFeed(): void {
|
|||||||
sockRef.current?.close();
|
sockRef.current?.close();
|
||||||
sockRef.current = null;
|
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
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, [enabled]);
|
||||||
}
|
}
|
||||||
|
|||||||
+70
-12
@@ -14,6 +14,7 @@ import {
|
|||||||
can,
|
can,
|
||||||
closeShift,
|
closeShift,
|
||||||
fetchShiftReport,
|
fetchShiftReport,
|
||||||
|
fetchVersion,
|
||||||
logout,
|
logout,
|
||||||
openShift,
|
openShift,
|
||||||
setLanguagePref,
|
setLanguagePref,
|
||||||
@@ -25,6 +26,7 @@ import {
|
|||||||
} from "./api.js";
|
} from "./api.js";
|
||||||
import { qk, queryClient } from "./lib/query.js";
|
import { qk, queryClient } from "./lib/query.js";
|
||||||
import { Modal } from "./ui/Modal.js";
|
import { Modal } from "./ui/Modal.js";
|
||||||
|
import { Spinner } from "./ui/Spinner.js";
|
||||||
import { setLanguage } from "./lib/i18n/index.js";
|
import { setLanguage } from "./lib/i18n/index.js";
|
||||||
import { applyTheme, applyFontScale } from "./lib/theme.js";
|
import { applyTheme, applyFontScale } from "./lib/theme.js";
|
||||||
import { useLiveFeed } from "./lib/use-live-feed.js";
|
import { useLiveFeed } from "./lib/use-live-feed.js";
|
||||||
@@ -45,6 +47,7 @@ import { DrawerManager } from "./DrawerManager.js";
|
|||||||
import { CARD_PAYMENTS_ENABLED } from "./lib/features.js";
|
import { CARD_PAYMENTS_ENABLED } from "./lib/features.js";
|
||||||
import { LogsViewer } from "./LogsViewer.js";
|
import { LogsViewer } from "./LogsViewer.js";
|
||||||
import { BackupSettings } from "./BackupSettings.js";
|
import { BackupSettings } from "./BackupSettings.js";
|
||||||
|
import { ValidateScreen } from "./ValidateScreen.js";
|
||||||
import { RecycleBin } from "./RecycleBin.js";
|
import { RecycleBin } from "./RecycleBin.js";
|
||||||
import { Profile } from "./Profile.js";
|
import { Profile } from "./Profile.js";
|
||||||
// Reports pulls in Recharts (~heavy) — lazy-loaded so it stays OUT of the booth's
|
// Reports pulls in Recharts (~heavy) — lazy-loaded so it stays OUT of the booth's
|
||||||
@@ -92,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
|
/** 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
|
* 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. */
|
* deep links and the back button work and a denied tab redirects to the booth. */
|
||||||
@@ -110,6 +124,7 @@ function SetupLayout() {
|
|||||||
{show("recyclebin:read") && <SetupTab to="/setup/recycle-bin" label={t("nav.recycleBin")} />}
|
{show("recyclebin:read") && <SetupTab to="/setup/recycle-bin" label={t("nav.recycleBin")} />}
|
||||||
{show("log:read") && <SetupTab to="/setup/logs" label={t("nav.logs")} />}
|
{show("log:read") && <SetupTab to="/setup/logs" label={t("nav.logs")} />}
|
||||||
{show("backup:read") && <SetupTab to="/setup/backup" label={t("nav.backup")} />}
|
{show("backup:read") && <SetupTab to="/setup/backup" label={t("nav.backup")} />}
|
||||||
|
{show("site:read") && <VersionBadge />}
|
||||||
</nav>
|
</nav>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
@@ -335,9 +350,15 @@ function ShiftButton() {
|
|||||||
disabled={busy || blockedByOther}
|
disabled={busy || blockedByOther}
|
||||||
title={blockedByOther ? t("shift.headerHeldBy", { operator: heldBy ?? "?" }) : undefined}
|
title={blockedByOther ? t("shift.headerHeldBy", { operator: heldBy ?? "?" }) : undefined}
|
||||||
onClick={onClick}
|
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>
|
</button>
|
||||||
{!isOpen && (
|
{!isOpen && (
|
||||||
<span className="text-[0.625rem] uppercase tracking-wider text-term-amber">{t("shift.headerNoShift")}</span>
|
<span className="text-[0.625rem] uppercase tracking-wider text-term-amber">{t("shift.headerNoShift")}</span>
|
||||||
@@ -409,7 +430,13 @@ function CloseShiftConfirm({
|
|||||||
{t("subs.cancel")}
|
{t("subs.cancel")}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className="btn btn-sm btn-danger" onClick={onConfirm} disabled={busy || !x}>
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -434,19 +461,28 @@ function ConfirmFigure({ label, value, bold, sub }: { label: string; value: stri
|
|||||||
function RootLayout() {
|
function RootLayout() {
|
||||||
const { user, setUser } = rootRoute.useRouteContext();
|
const { user, setUser } = rootRoute.useRouteContext();
|
||||||
const { t } = useTranslation();
|
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
|
// 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).
|
// the permission its screen needs (the route guards enforce the same server-side).
|
||||||
const show = (perm: Permission) => can(user, perm);
|
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 (
|
return (
|
||||||
<div className="flex h-screen flex-col bg-term-bg text-term-text">
|
<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">
|
<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>
|
<span className="text-sm font-bold uppercase tracking-widest text-term-amber">▮ Parking</span>
|
||||||
<nav className="flex items-center gap-1">
|
<nav className="flex items-center gap-1">
|
||||||
<NavLink to="/booth" label={t("nav.booth")} />
|
{show("session:read") && <NavLink to="/booth" label={t("nav.booth")} />}
|
||||||
<NavLink to="/shifts" label={t("nav.shifts")} />
|
{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
|
{/* Drawer — record cash movements (operator) / review them (admin). Shown if the
|
||||||
user can do either. See wiki/concepts/shift.md. */}
|
user can do either. See wiki/concepts/shift.md. */}
|
||||||
{(show("drawer:create") || show("drawer:review")) && (
|
{(show("drawer:create") || show("drawer:review")) && (
|
||||||
@@ -472,11 +508,11 @@ function RootLayout() {
|
|||||||
show("shift:read")) && <NavLink to="/setup" label={t("nav.setup")} />}
|
show("shift:read")) && <NavLink to="/setup" label={t("nav.setup")} />}
|
||||||
</nav>
|
</nav>
|
||||||
<div className="ml-auto flex items-center gap-3">
|
<div className="ml-auto flex items-center gap-3">
|
||||||
{user && <ShiftButton />}
|
{user && show("shift:read") && <ShiftButton />}
|
||||||
{user && <LanguageToggle user={user} setUser={setUser} />}
|
{user && <LanguageToggle user={user} setUser={setUser} />}
|
||||||
{user && <ThemeToggle user={user} setUser={setUser} />}
|
{user && <ThemeToggle user={user} setUser={setUser} />}
|
||||||
{user && <FontScaleToggle user={user} setUser={setUser} />}
|
{user && <FontScaleToggle user={user} setUser={setUser} />}
|
||||||
<StatusDot />
|
{canWatch && <StatusDot />}
|
||||||
{user && (
|
{user && (
|
||||||
<Link
|
<Link
|
||||||
to="/profile"
|
to="/profile"
|
||||||
@@ -501,8 +537,10 @@ function RootLayout() {
|
|||||||
<main className="min-h-0 flex-1 overflow-auto p-3">
|
<main className="min-h-0 flex-1 overflow-auto p-3">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
{/* Fixed device-status footer — relays, readers, cameras, printers. */}
|
{/* Fixed device-status footer — relays, readers, cameras, printers. Its REST
|
||||||
{user && <DeviceFooter />}
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -510,7 +548,12 @@ function RootLayout() {
|
|||||||
const indexRoute = createRoute({
|
const indexRoute = createRoute({
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
path: "/",
|
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" });
|
throw redirect({ to: "/booth" });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -521,6 +564,20 @@ const boothRoute = createRoute({
|
|||||||
component: BoothScreen,
|
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;
|
// 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/Plans/Tariff-Lab were promoted OUT of /setup into the standalone
|
||||||
// /subscriptions section (2026-06-21) — redirect the old /setup/* paths too so existing
|
// /subscriptions section (2026-06-21) — redirect the old /setup/* paths too so existing
|
||||||
@@ -766,6 +823,7 @@ const profileRoute = createRoute({
|
|||||||
const routeTree = rootRoute.addChildren([
|
const routeTree = rootRoute.addChildren([
|
||||||
indexRoute,
|
indexRoute,
|
||||||
boothRoute,
|
boothRoute,
|
||||||
|
validateRoute,
|
||||||
...legacyRedirects,
|
...legacyRedirects,
|
||||||
profileRoute,
|
profileRoute,
|
||||||
shiftRoute,
|
shiftRoute,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { fetchDeviceStatus, type DeviceStatus } from "../api.js";
|
import { fetchDeviceStatus, type DeviceStatus } from "../api.js";
|
||||||
|
import { formatClock } from "../lib/format.js";
|
||||||
import { qk } from "../lib/query.js";
|
import { qk } from "../lib/query.js";
|
||||||
import { useLiveStore } from "../lib/live-store.js";
|
import { useLiveStore } from "../lib/live-store.js";
|
||||||
|
|
||||||
@@ -184,7 +185,7 @@ export function DeviceFooter() {
|
|||||||
</div>
|
</div>
|
||||||
{d.detail && <div className="mt-0.5 break-words text-[0.6875rem] text-term-muted">{d.detail}</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">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { formatDateTime } from "../lib/format.js";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { fetchSnapshots, snapshotImageUrl, type PlateRead } from "../api.js";
|
import { fetchSnapshots, snapshotImageUrl, type PlateRead } from "../api.js";
|
||||||
|
|
||||||
@@ -52,7 +53,7 @@ export function SnapshotStrip({ identity }: { identity: string }) {
|
|||||||
key={`${p.plate}-${p.direction}-${i}`}
|
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]"
|
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}` : ""}${
|
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>
|
<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"
|
type="button"
|
||||||
onClick={() => setZoom(s.id)}
|
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"
|
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
|
<img
|
||||||
src={snapshotImageUrl(s.id)}
|
src={snapshotImageUrl(s.id)}
|
||||||
@@ -97,7 +98,7 @@ export function SnapshotStrip({ identity }: { identity: string }) {
|
|||||||
<div
|
<div
|
||||||
key={`fail-${f.direction ?? "both"}-${i}`}
|
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"
|
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-lg leading-none text-term-amber">⚠</span>
|
||||||
<span className="text-[0.5625rem] uppercase tracking-wider text-term-amber">{dirLabel(f.direction)}</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 { useTranslation } from "react-i18next";
|
||||||
import { type ReactNode } from "react";
|
import { type ReactNode } from "react";
|
||||||
import { type LedgerEvent } from "../api.js";
|
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 { renderReason } from "../lib/reason.js";
|
||||||
import { Modal } from "./Modal.js";
|
import { Modal } from "./Modal.js";
|
||||||
import { SnapshotStrip } from "./SnapshotStrip.js";
|
import { SnapshotStrip } from "./SnapshotStrip.js";
|
||||||
@@ -25,6 +25,7 @@ export const EVENT_STYLE: Record<string, { labelKey: string; color: string }> =
|
|||||||
cash_out: { labelKey: "booth.evtCashOut", color: "text-term-amber" },
|
cash_out: { labelKey: "booth.evtCashOut", color: "text-term-amber" },
|
||||||
cash_review: { labelKey: "booth.evtCashReview", color: "text-term-cyan" },
|
cash_review: { labelKey: "booth.evtCashReview", color: "text-term-cyan" },
|
||||||
config_change: { labelKey: "booth.evtConfigChange", color: "text-term-amber" },
|
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" },
|
anomaly: { labelKey: "booth.evtAnomaly", color: "text-term-red" },
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -226,7 +227,7 @@ export function EventDetailModal({ e, onClose }: { e: LedgerEvent; onClose: () =
|
|||||||
|
|
||||||
{/* Humanized fields — labelled rows, not raw JSON. Only what applies renders. */}
|
{/* Humanized fields — labelled rows, not raw JSON. Only what applies renders. */}
|
||||||
<div>
|
<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>
|
<DetailRow label={t("booth.edIndex")}>#{e.index}</DetailRow>
|
||||||
{e.direction && <DetailRow label={t("booth.edDirection")}>{e.direction}</DetailRow>}
|
{e.direction && <DetailRow label={t("booth.edDirection")}>{e.direction}</DetailRow>}
|
||||||
{e.source && <DetailRow label={t("booth.edSource")}>{e.source}</DetailRow>}
|
{e.source && <DetailRow label={t("booth.edSource")}>{e.source}</DetailRow>}
|
||||||
|
|||||||
+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
|
# 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
|
# :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.
|
# exists as the pointer; we deploy the sha, not the mover.
|
||||||
TAG=stage-365b648
|
TAG=stage-28bd838
|
||||||
COOKIE_SECURE=0
|
COOKIE_SECURE=0
|
||||||
VISION_ENABLED=1
|
VISION_ENABLED=1
|
||||||
WS_ALLOWED_ORIGINS=
|
WS_ALLOWED_ORIGINS=
|
||||||
@@ -57,3 +57,33 @@ JWT_SECRET=[[park_buzi_jwt_secret]]
|
|||||||
EVENT_SIGNING_KEY=[[park_buzi_event_signing_key]]
|
EVENT_SIGNING_KEY=[[park_buzi_event_signing_key]]
|
||||||
BACKUP_KEY=[[park_buzi_backup_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]]
|
||||||
|
"""
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- Driver rename (2026-07-06): "cashino" → "escpos". The driver was always the GENERIC
|
||||||
|
-- ESC/POS printer driver (reachability-only clones); it carried the first unit's vendor
|
||||||
|
-- name, which read as misleading in the setup UI once other clones (ICS/Xprinter
|
||||||
|
-- XP-K200L) used it. Rewrite stored device rows; the registry also keeps a permanent
|
||||||
|
-- cashino→escpos alias so restored pre-rename backups still resolve.
|
||||||
|
UPDATE `devices` SET `driver_id` = 'escpos' WHERE `driver_id` = 'cashino';
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
-- Merchant validation programs (2026-07-13). In-park merchants (bar / lavazh) validate a
|
||||||
|
-- customer's ticket so the BOOTH settlement discounts the fee — the merchant only
|
||||||
|
-- validates, all money and paper stay at the booth. The /setup/site checkboxes toggle the
|
||||||
|
-- WELL-KNOWN rows ("bar", "lavazh"); a future merchant is a new row, not a migration.
|
||||||
|
-- Config is plainly MUTABLE (no versioning): the applied validation is a signed ledger
|
||||||
|
-- event carrying the RESOLVED values, so reproducibility never depends on these rows.
|
||||||
|
-- See wiki/concepts/validation-discounts.md.
|
||||||
|
CREATE TABLE `validation_programs` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`name` text NOT NULL,
|
||||||
|
`mode` text DEFAULT 'comp' NOT NULL,
|
||||||
|
`minutes` integer,
|
||||||
|
`percent` integer,
|
||||||
|
`max_amount_minor` integer,
|
||||||
|
`max_per_day` integer,
|
||||||
|
`active` integer DEFAULT 0 NOT NULL,
|
||||||
|
`created_at` text DEFAULT (current_timestamp) NOT NULL,
|
||||||
|
`deleted_at` text,
|
||||||
|
`deleted_by` text
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
-- WHICH users may apply a program: the apply guard is `validation:create` AND a binding
|
||||||
|
-- row here — a bar user can never apply the lavazh program.
|
||||||
|
CREATE TABLE `validation_program_users` (
|
||||||
|
`program_id` text NOT NULL,
|
||||||
|
`user_id` text NOT NULL,
|
||||||
|
FOREIGN KEY (`program_id`) REFERENCES `validation_programs`(`id`) ON UPDATE no action ON DELETE no action,
|
||||||
|
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `validation_program_users_program_id_user_id_unique` ON `validation_program_users` (`program_id`,`user_id`);
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
-- Last-success/last-error for the encrypted DB backup were previously tracked only as
|
||||||
|
-- in-process fields on BackupService (never written to the DB) — so every server restart
|
||||||
|
-- (deploy/crash/OOM/host reboot, all routine under `restart: always`) silently reset the admin
|
||||||
|
-- UI's "last successful backup" to "Never", even with valid, correctly-rotating backups already
|
||||||
|
-- on disk (2026-08-30 field incident, park-buzi). Four additive, nullable columns; null = no
|
||||||
|
-- run recorded yet (or, for the error pair, no failure since the last success). See
|
||||||
|
-- wiki/concepts/backup-recovery.md.
|
||||||
|
ALTER TABLE `site_config` ADD `backup_last_success_at` text;--> statement-breakpoint
|
||||||
|
ALTER TABLE `site_config` ADD `backup_last_result_json` text;--> statement-breakpoint
|
||||||
|
ALTER TABLE `site_config` ADD `backup_last_error_at` text;--> statement-breakpoint
|
||||||
|
ALTER TABLE `site_config` ADD `backup_last_error` text;
|
||||||
@@ -162,6 +162,27 @@
|
|||||||
"when": 1781886500000,
|
"when": 1781886500000,
|
||||||
"tag": "0022_tariff_version_name",
|
"tag": "0022_tariff_version_name",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 23,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1781886600000,
|
||||||
|
"tag": "0023_driver_id_escpos",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 24,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1783948800000,
|
||||||
|
"tag": "0024_validation_programs",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 25,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1788078414270,
|
||||||
|
"tag": "0025_backup_last_status",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -17,9 +17,17 @@
|
|||||||
// their credentials/plates, blocklist. KEEPS users, devices, config,
|
// their credentials/plates, blocklist. KEEPS users, devices, config,
|
||||||
// tariffs, subscription PLANS.
|
// tariffs, subscription PLANS.
|
||||||
// --config site_config, devices, setup_state (re-runs first-run setup),
|
// --config site_config, devices, setup_state (re-runs first-run setup),
|
||||||
// tariffs + tariff_versions, subscription_plans.
|
// tariffs + tariff_versions + tariff_drafts, subscription_plans.
|
||||||
// --users users, roles, role_permissions, auth sessions. (After this or --all,
|
// --users users, roles, role_permissions, auth sessions. (After this or --all,
|
||||||
// re-seed an admin: apps/server/scripts/seed-admin.mjs.)
|
// re-seed an admin: apps/server/scripts/seed-admin.mjs.)
|
||||||
|
// --diagnostics app_logs (the unsigned diagnostic store behind the /setup/logs
|
||||||
|
// viewer). Separate from --financial: logs are evidence about the BOX,
|
||||||
|
// not the traffic — wipe them only when handing over a blank slate.
|
||||||
|
//
|
||||||
|
// DRIFT GUARD: before doing anything, the script compares the union of the categories
|
||||||
|
// above against the tables actually present in the DB and REFUSES if any table is
|
||||||
|
// uncategorized — so a new table can't silently survive resets (app_logs and
|
||||||
|
// tariff_drafts did exactly that until 2026-07-08).
|
||||||
//
|
//
|
||||||
// Safety gates (BOTH required):
|
// Safety gates (BOTH required):
|
||||||
// 1. env RESET_ALLOWED=1 — a real booth never sets this.
|
// 1. env RESET_ALLOWED=1 — a real booth never sets this.
|
||||||
@@ -44,10 +52,44 @@ const CATEGORIES = {
|
|||||||
"subscriptions",
|
"subscriptions",
|
||||||
"blocklist",
|
"blocklist",
|
||||||
],
|
],
|
||||||
config: ["site_config", "devices", "setup_state", "tariff_versions", "tariffs", "subscription_plans"],
|
config: [
|
||||||
|
"site_config",
|
||||||
|
"devices",
|
||||||
|
"setup_state",
|
||||||
|
"tariff_drafts",
|
||||||
|
"tariff_versions",
|
||||||
|
"tariffs",
|
||||||
|
"subscription_plans",
|
||||||
|
// Merchant validation programs (bar/lavazh) + their user bindings (child first).
|
||||||
|
// A --users reset without --config may orphan a binding row; harmless — a binding
|
||||||
|
// whose user is gone grants nothing.
|
||||||
|
"validation_program_users",
|
||||||
|
"validation_programs",
|
||||||
|
],
|
||||||
users: ["sessions", "role_permissions", "users", "roles"],
|
users: ["sessions", "role_permissions", "users", "roles"],
|
||||||
|
diagnostics: ["app_logs"],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Every user table in the DB must belong to a category above (internal bookkeeping
|
||||||
|
* like sqlite_* and drizzle's __* migration table excepted). Dies listing offenders —
|
||||||
|
* the fix is a one-line addition to CATEGORIES, decided deliberately, not by omission. */
|
||||||
|
function assertNoUncategorizedTables(sqlite) {
|
||||||
|
const known = new Set(Object.values(CATEGORIES).flat());
|
||||||
|
const actual = sqlite
|
||||||
|
.prepare(`SELECT name FROM sqlite_master WHERE type = 'table'`)
|
||||||
|
.all()
|
||||||
|
.map((r) => r.name)
|
||||||
|
.filter((n) => !n.startsWith("sqlite_") && !n.startsWith("__"));
|
||||||
|
const uncategorized = actual.filter((n) => !known.has(n));
|
||||||
|
if (uncategorized.length > 0) {
|
||||||
|
die(
|
||||||
|
`schema drift — table(s) not covered by any reset category: ${uncategorized.join(", ")}\n` +
|
||||||
|
` add them to CATEGORIES in packages/db/scripts/reset-db.mjs (this guard exists so\n` +
|
||||||
|
` new tables can't silently survive resets).`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function parseArgs(argv) {
|
function parseArgs(argv) {
|
||||||
const flags = new Set(argv.filter((a) => a.startsWith("--")).map((a) => a.slice(2)));
|
const flags = new Set(argv.filter((a) => a.startsWith("--")).map((a) => a.slice(2)));
|
||||||
const wantAll = flags.has("all");
|
const wantAll = flags.has("all");
|
||||||
@@ -75,7 +117,7 @@ async function main() {
|
|||||||
|
|
||||||
const { cats, autoYes, wantAll } = parseArgs(process.argv.slice(2));
|
const { cats, autoYes, wantAll } = parseArgs(process.argv.slice(2));
|
||||||
if (cats.length === 0) {
|
if (cats.length === 0) {
|
||||||
die("nothing to do — pass --all, --financial, --config, and/or --users");
|
die("nothing to do — pass --all, --financial, --config, --users, and/or --diagnostics");
|
||||||
}
|
}
|
||||||
|
|
||||||
// GATE 1: env opt-in. A production booth never sets this.
|
// GATE 1: env opt-in. A production booth never sets this.
|
||||||
@@ -86,6 +128,11 @@ async function main() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Open early: the drift guard must run BEFORE anything is printed or confirmed, so
|
||||||
|
// an uncategorized table aborts the whole run rather than surviving a "successful" reset.
|
||||||
|
const sqlite = new Database(dbPath);
|
||||||
|
assertNoUncategorizedTables(sqlite);
|
||||||
|
|
||||||
// Resolve the ordered, de-duplicated table list for the chosen categories.
|
// Resolve the ordered, de-duplicated table list for the chosen categories.
|
||||||
const tables = [];
|
const tables = [];
|
||||||
for (const c of cats) for (const t of CATEGORIES[c]) if (!tables.includes(t)) tables.push(t);
|
for (const c of cats) for (const t of CATEGORIES[c]) if (!tables.includes(t)) tables.push(t);
|
||||||
@@ -106,7 +153,6 @@ async function main() {
|
|||||||
if (!ok) die("confirmation did not match — aborted, nothing changed.");
|
if (!ok) die("confirmation did not match — aborted, nothing changed.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const sqlite = new Database(dbPath);
|
|
||||||
try {
|
try {
|
||||||
// FKs OFF for the wipe so we can delete in any order without ordering hazards;
|
// FKs OFF for the wipe so we can delete in any order without ordering hazards;
|
||||||
// a single transaction makes it all-or-nothing.
|
// a single transaction makes it all-or-nothing.
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import * as schema from "./schema.js";
|
|||||||
export * from "./schema.js";
|
export * from "./schema.js";
|
||||||
// Re-export the query helpers consumers need, so they don't depend on
|
// Re-export the query helpers consumers need, so they don't depend on
|
||||||
// drizzle-orm directly (it's an implementation detail of this package).
|
// drizzle-orm directly (it's an implementation detail of this package).
|
||||||
export { eq, ne, and, or, asc, desc, gte, lte, isNull, isNotNull, inArray, sql } from "drizzle-orm";
|
export { eq, ne, and, or, asc, desc, gt, gte, lt, lte, isNull, isNotNull, inArray, sql } from "drizzle-orm";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Open the local SQLite database in WAL mode. WAL allows many concurrent readers
|
* Open the local SQLite database in WAL mode. WAL allows many concurrent readers
|
||||||
|
|||||||
@@ -288,6 +288,20 @@ export const siteConfig = sqliteTable("site_config", {
|
|||||||
backupKeepLast: integer("backup_keep_last"),
|
backupKeepLast: integer("backup_keep_last"),
|
||||||
/** Beyond keepLast, keep one backup per day for this many days. null ⇒ code default (30). */
|
/** Beyond keepLast, keep one backup per day for this many days. null ⇒ code default (30). */
|
||||||
backupKeepDailyDays: integer("backup_keep_daily_days"),
|
backupKeepDailyDays: integer("backup_keep_daily_days"),
|
||||||
|
/** ISO timestamp of the last backup that actually completed successfully. Persisted here
|
||||||
|
* (not just in-process memory) so the admin UI's "last successful backup" survives a
|
||||||
|
* server restart — before this column existed, a restart silently reset that status to
|
||||||
|
* "Never" even with valid backups already on disk. null = no successful run recorded yet.
|
||||||
|
* See wiki/concepts/backup-recovery.md. */
|
||||||
|
backupLastSuccessAt: text("backup_last_success_at"),
|
||||||
|
/** JSON-encoded { path, bytes, prunedFiles } of the last successful run, for the same
|
||||||
|
* restart-durability reason as backupLastSuccessAt. null = none recorded yet. */
|
||||||
|
backupLastResultJson: text("backup_last_result_json"),
|
||||||
|
/** ISO timestamp of the last FAILED scheduled/manual backup attempt, persisted for the same
|
||||||
|
* reason. null = no failure recorded (or none since the last success). */
|
||||||
|
backupLastErrorAt: text("backup_last_error_at"),
|
||||||
|
/** Error message of the last failed attempt. Cleared (set null) on the next success. */
|
||||||
|
backupLastError: text("backup_last_error"),
|
||||||
updatedAt: text("updated_at")
|
updatedAt: text("updated_at")
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(sql`(current_timestamp)`),
|
.default(sql`(current_timestamp)`),
|
||||||
@@ -458,6 +472,57 @@ export const subscriptionPlates = sqliteTable("subscription_plates", {
|
|||||||
plate: text("plate").notNull(),
|
plate: text("plate").notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Merchant validation programs (bar / lavazh) --------------------------
|
||||||
|
// Admin-composed master data for in-park merchant discounts: the /setup/site
|
||||||
|
// checkboxes toggle the WELL-KNOWN rows ("bar", "lavazh") — a future merchant is a
|
||||||
|
// new row, not a migration. Config is plainly MUTABLE (no versioning): the applied
|
||||||
|
// validation is a signed ledger event carrying the RESOLVED values, so historical
|
||||||
|
// reproducibility never depends on this row. Enabling/saving signs a config_change.
|
||||||
|
// See wiki/concepts/validation-discounts.md.
|
||||||
|
export const validationPrograms = sqliteTable("validation_programs", {
|
||||||
|
// Well-known slug ("bar" | "lavazh"); generic text so future merchants are rows.
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
// Receipt label printed on the booth settlement line (e.g. "Lavazh — 1 orë falas").
|
||||||
|
name: text("name").notNull(),
|
||||||
|
// How the program discounts — see @parking/shared ValidationMode.
|
||||||
|
mode: text("mode", { enum: ["comp", "timeCredit", "fixed", "percent"] })
|
||||||
|
.notNull()
|
||||||
|
.default("comp"),
|
||||||
|
// timeCredit: the free minutes.
|
||||||
|
minutes: integer("minutes"),
|
||||||
|
// percent: 1..100 off the fee.
|
||||||
|
percent: integer("percent"),
|
||||||
|
// fixed: cap on the amount the merchant may type at scan time (minor units).
|
||||||
|
maxAmountMinor: integer("max_amount_minor"),
|
||||||
|
// Anti-abuse cap: max applications per local day (null = unlimited).
|
||||||
|
maxPerDay: integer("max_per_day"),
|
||||||
|
// The /setup/site checkbox. Inactive = merchants can't apply it (row + history kept).
|
||||||
|
active: integer("active", { mode: "boolean" }).notNull().default(false),
|
||||||
|
createdAt: text("created_at")
|
||||||
|
.notNull()
|
||||||
|
.default(sql`(current_timestamp)`),
|
||||||
|
// Soft delete (recycle bin) — see roles.deletedAt.
|
||||||
|
deletedAt: text("deleted_at"),
|
||||||
|
deletedBy: text("deleted_by"),
|
||||||
|
});
|
||||||
|
|
||||||
|
// The program↔user binding: WHICH users may apply a program (the guard is
|
||||||
|
// `validation:create` AND a binding row — a bar user can never apply lavazh).
|
||||||
|
export const validationProgramUsers = sqliteTable(
|
||||||
|
"validation_program_users",
|
||||||
|
{
|
||||||
|
programId: text("program_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => validationPrograms.id),
|
||||||
|
userId: text("user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id),
|
||||||
|
},
|
||||||
|
(t) => ({
|
||||||
|
uniq: unique().on(t.programId, t.userId),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
// --- Blocklist (banlist) -------------------------------------------------
|
// --- Blocklist (banlist) -------------------------------------------------
|
||||||
// Plates/cards refused at ENTRY (never at exit — never trap a vehicle). A hit appends
|
// Plates/cards refused at ENTRY (never at exit — never trap a vehicle). A hit appends
|
||||||
// a signed anomaly/refused-entry ledger event. See wiki/entities/blocklist.md.
|
// a signed anomaly/refused-entry ledger event. See wiki/entities/blocklist.md.
|
||||||
@@ -546,5 +611,7 @@ export type SubscriptionPlanRow = typeof subscriptionPlans.$inferSelect;
|
|||||||
export type SubscriptionCredentialRow = typeof subscriptionCredentials.$inferSelect;
|
export type SubscriptionCredentialRow = typeof subscriptionCredentials.$inferSelect;
|
||||||
export type SubscriptionPlateRow = typeof subscriptionPlates.$inferSelect;
|
export type SubscriptionPlateRow = typeof subscriptionPlates.$inferSelect;
|
||||||
export type BlocklistRow = typeof blocklist.$inferSelect;
|
export type BlocklistRow = typeof blocklist.$inferSelect;
|
||||||
|
export type ValidationProgramRow = typeof validationPrograms.$inferSelect;
|
||||||
|
export type ValidationProgramUserRow = typeof validationProgramUsers.$inferSelect;
|
||||||
export type SessionRow = typeof sessions.$inferSelect;
|
export type SessionRow = typeof sessions.$inferSelect;
|
||||||
export type AppLogRow = typeof appLogs.$inferSelect;
|
export type AppLogRow = typeof appLogs.$inferSelect;
|
||||||
|
|||||||
@@ -7,7 +7,11 @@ import type { DigestGetResult } from "./http-digest.js";
|
|||||||
// fail FAST on a config error (401 auth / 404 path). See camera.ts.
|
// fail FAST on a config error (401 auth / 404 path). See camera.ts.
|
||||||
|
|
||||||
const digestGet = vi.fn<(...a: unknown[]) => Promise<DigestGetResult>>();
|
const digestGet = vi.fn<(...a: unknown[]) => Promise<DigestGetResult>>();
|
||||||
vi.mock("./http-digest.js", () => ({ digestGet: (...a: unknown[]) => digestGet(...a) }));
|
const digestRequest = vi.fn<(...a: unknown[]) => Promise<DigestGetResult>>();
|
||||||
|
vi.mock("./http-digest.js", () => ({
|
||||||
|
digestGet: (...a: unknown[]) => digestGet(...a),
|
||||||
|
digestRequest: (...a: unknown[]) => digestRequest(...a),
|
||||||
|
}));
|
||||||
|
|
||||||
// Import the driver AFTER the mock is registered.
|
// Import the driver AFTER the mock is registered.
|
||||||
const { hikvisionDriver } = await import("./camera.js");
|
const { hikvisionDriver } = await import("./camera.js");
|
||||||
@@ -22,6 +26,7 @@ function makeCamera() {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
digestGet.mockReset();
|
digestGet.mockReset();
|
||||||
|
digestRequest.mockReset();
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
});
|
});
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -113,3 +118,94 @@ describe("hikvision snapshot stream selection (main vs sub)", () => {
|
|||||||
expect(pathFor({ host: "1.2.3.4", channel: 1, stream: 9 })).toBe("/ISAPI/Streaming/channels/101/picture");
|
expect(pathFor({ host: "1.2.3.4", channel: 1, stream: 9 })).toBe("/ISAPI/Streaming/channels/101/picture");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("healthCheck detail is a STABLE size bucket (log-noise fix, 2026-07-05)", () => {
|
||||||
|
// The device monitor logs + re-emits whenever the detail string changes. JPEG
|
||||||
|
// frame size differs on every frame, so an exact byte count made healthy cameras
|
||||||
|
// "change" on nearly every poll. The detail must stay identical across ordinary
|
||||||
|
// frame-size jitter and only move on a real shift (different stream/res, tiny body).
|
||||||
|
it("frames of similar size land in the same bucket", async () => {
|
||||||
|
const cam = makeCamera();
|
||||||
|
digestGet.mockResolvedValueOnce(reply(200, "x".repeat(16_716)));
|
||||||
|
const a = await cam.healthCheck();
|
||||||
|
digestGet.mockResolvedValueOnce(reply(200, "x".repeat(17_902)));
|
||||||
|
const b = await cam.healthCheck();
|
||||||
|
expect(a).toEqual({ status: "ready", detail: "snapshot ≈16 KB" });
|
||||||
|
expect(b.detail).toBe(a.detail); // jitter does NOT change the detail
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a genuinely different size (sub vs main stream) lands in a different bucket", async () => {
|
||||||
|
const cam = makeCamera();
|
||||||
|
digestGet.mockResolvedValueOnce(reply(200, "x".repeat(299_395)));
|
||||||
|
const big = await cam.healthCheck();
|
||||||
|
expect(big.detail).toBe("snapshot ≈256 KB");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an empty-ish 200 body is flagged, not bucketed away", async () => {
|
||||||
|
const cam = makeCamera();
|
||||||
|
digestGet.mockResolvedValueOnce(reply(200, "xx"));
|
||||||
|
const tiny = await cam.healthCheck();
|
||||||
|
expect(tiny.detail).toBe("snapshot <1 KB");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("hikvision syncClock — the 1970 power-cut recovery (ISAPI /System/time)", () => {
|
||||||
|
const timeXml = (localTime: string) =>
|
||||||
|
reply(
|
||||||
|
200,
|
||||||
|
`<?xml version="1.0"?><Time><timeMode>NTP</timeMode><localTime>${localTime}</localTime><timeZone>CST-2:00:00DST01:00:00</timeZone></Time>`,
|
||||||
|
);
|
||||||
|
const HOST_NOW = "2026-07-07T12:00:00+02:00";
|
||||||
|
|
||||||
|
function cam() {
|
||||||
|
return makeCamera() as unknown as {
|
||||||
|
syncClock(localIso: string, maxDriftSec: number): Promise<{ driftSeconds: number | null; synced: boolean }>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it("in-sync camera: reads, does NOT set", async () => {
|
||||||
|
digestRequest.mockResolvedValueOnce(timeXml("2026-07-07T12:00:10+02:00"));
|
||||||
|
const r = await cam().syncClock(HOST_NOW, 60);
|
||||||
|
expect(r).toEqual({ driftSeconds: 10, synced: false });
|
||||||
|
expect(digestRequest).toHaveBeenCalledTimes(1); // GET only
|
||||||
|
});
|
||||||
|
|
||||||
|
it("1970 camera: PUTs manual time with the host instant, echoing the camera's timeZone", async () => {
|
||||||
|
digestRequest
|
||||||
|
.mockResolvedValueOnce(timeXml("1970-01-01T03:12:44+01:00"))
|
||||||
|
.mockResolvedValueOnce(reply(200, "<ResponseStatus/>"));
|
||||||
|
const r = await cam().syncClock(HOST_NOW, 60);
|
||||||
|
expect(r.synced).toBe(true);
|
||||||
|
expect(r.driftSeconds).toBeGreaterThan(1_000_000_000); // ~56 years
|
||||||
|
const put = digestRequest.mock.calls[1]![0] as { method: string; path: string; body: string };
|
||||||
|
expect(put.method).toBe("PUT");
|
||||||
|
expect(put.path).toBe("/ISAPI/System/time");
|
||||||
|
expect(put.body).toContain("<timeMode>manual</timeMode>");
|
||||||
|
expect(put.body).toContain(`<localTime>${HOST_NOW}</localTime>`);
|
||||||
|
expect(put.body).toContain("<timeZone>CST-2:00:00DST01:00:00</timeZone>"); // echoed, never invented
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unparseable camera time = infinite drift → syncs", async () => {
|
||||||
|
digestRequest
|
||||||
|
.mockResolvedValueOnce(reply(200, "<Time><localTime>garbage</localTime></Time>"))
|
||||||
|
.mockResolvedValueOnce(reply(200, "<ResponseStatus/>"));
|
||||||
|
const r = await cam().syncClock(HOST_NOW, 60);
|
||||||
|
expect(r).toEqual({ driftSeconds: null, synced: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a failed set surfaces as an error (monitor logs it, backstop retries)", async () => {
|
||||||
|
digestRequest
|
||||||
|
.mockResolvedValueOnce(timeXml("1970-01-01T01:00:00+01:00"))
|
||||||
|
.mockResolvedValueOnce(reply(403, "denied"));
|
||||||
|
await expect(cam().syncClock(HOST_NOW, 60)).rejects.toThrow("clock set failed: HTTP 403");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the dahua driver does NOT claim the capability (no ISAPI time endpoint)", async () => {
|
||||||
|
const { dahuaDriver, hikvisionDriver } = await import("./camera.js");
|
||||||
|
const { isClockSyncable } = await import("../interfaces.js");
|
||||||
|
const mk = (d: typeof dahuaDriver) =>
|
||||||
|
d.create({ host: "10.0.10.12", port: 80, username: "admin", password: "x", channel: 1 });
|
||||||
|
expect(isClockSyncable(mk(dahuaDriver))).toBe(false);
|
||||||
|
expect(isClockSyncable(mk(hikvisionDriver))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type {
|
import type {
|
||||||
CameraDevice,
|
CameraDevice,
|
||||||
|
ClockSyncResult,
|
||||||
DeviceHealth,
|
DeviceHealth,
|
||||||
Snapshot,
|
Snapshot,
|
||||||
SnapshotContext,
|
SnapshotContext,
|
||||||
@@ -12,7 +13,7 @@ import {
|
|||||||
usernameField,
|
usernameField,
|
||||||
stubLog,
|
stubLog,
|
||||||
} from "./common.js";
|
} from "./common.js";
|
||||||
import { digestGet } from "./http-digest.js";
|
import { digestGet, digestRequest } from "./http-digest.js";
|
||||||
|
|
||||||
// Camera drivers — entry/exit snapshot-on-event. The host pulls a still over
|
// Camera drivers — entry/exit snapshot-on-event. The host pulls a still over
|
||||||
// HTTP when an event fires; the bytes are stored and referenced from the signed
|
// HTTP when an event fires; the bytes are stored and referenced from the signed
|
||||||
@@ -40,6 +41,19 @@ const SNAPSHOT_RETRY_BASE_MS = 250;
|
|||||||
|
|
||||||
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
|
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
/** Coarse, STABLE size label for the health-check detail: nearest power-of-two KB
|
||||||
|
* (`≈16 KB`, `≈256 KB`). JPEG frame size varies with every frame, and the device
|
||||||
|
* monitor logs + re-emits a status whenever the detail string changes — an exact
|
||||||
|
* byte count made every healthy camera "change" on nearly every poll, spamming the
|
||||||
|
* rotated container logs. A pow-2 bucket keeps the diagnostic value (a suddenly
|
||||||
|
* tiny frame still shows) while flapping only on a real scene/stream shift. */
|
||||||
|
function sizeBucket(bytes: number): string {
|
||||||
|
const kb = bytes / 1024;
|
||||||
|
if (kb < 1) return "<1 KB"; // empty-ish 200 body — suspicious, worth seeing as-is
|
||||||
|
const pow = Math.round(Math.log2(kb));
|
||||||
|
return `≈${2 ** pow} KB`;
|
||||||
|
}
|
||||||
|
|
||||||
class HttpCamera implements CameraDevice {
|
class HttpCamera implements CameraDevice {
|
||||||
readonly #host: string;
|
readonly #host: string;
|
||||||
readonly #port: number;
|
readonly #port: number;
|
||||||
@@ -85,7 +99,7 @@ class HttpCamera implements CameraDevice {
|
|||||||
try {
|
try {
|
||||||
const res = await this.#get();
|
const res = await this.#get();
|
||||||
if (res.status === 200)
|
if (res.status === 200)
|
||||||
return { status: "ready", detail: `${res.body.length} bytes` };
|
return { status: "ready", detail: `snapshot ${sizeBucket(res.body.length)}` };
|
||||||
if (res.status === 401)
|
if (res.status === 401)
|
||||||
return {
|
return {
|
||||||
status: "degraded",
|
status: "degraded",
|
||||||
@@ -147,6 +161,73 @@ class HttpCamera implements CameraDevice {
|
|||||||
localAddress: this.#localAddress,
|
localAddress: this.#localAddress,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Digest request against an arbitrary device path (ISAPI config reads/writes). */
|
||||||
|
protected isapi(method: "GET" | "PUT", path: string, body?: string) {
|
||||||
|
return digestRequest({
|
||||||
|
host: this.#host,
|
||||||
|
port: this.#port,
|
||||||
|
path,
|
||||||
|
method,
|
||||||
|
body,
|
||||||
|
user: this.#user,
|
||||||
|
password: this.#password,
|
||||||
|
timeoutMs: this.#timeout,
|
||||||
|
localAddress: this.#localAddress,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Hikvision clock sync (ISAPI /System/time) ---------------------------------
|
||||||
|
// These cameras lose their clock on a power cut (no/dead RTC battery): they reboot
|
||||||
|
// at the 1970 epoch and stay there until a human logs into the web UI (which
|
||||||
|
// silently pushes the browser clock). A wrong camera clock corrupts the OSD
|
||||||
|
// timestamp burned into every snapshot — the evidence trail — and the times on
|
||||||
|
// ANPR pushes. So the host (the site's time authority — offline-first, no NTP
|
||||||
|
// dependency) re-syncs the camera over the same Digest-auth ISAPI used for
|
||||||
|
// snapshots. The device monitor calls this at the offline→ready edge (the
|
||||||
|
// power-restored moment) + a daily backstop. See wiki/entities/lpr-camera.md.
|
||||||
|
|
||||||
|
class HikvisionCamera extends HttpCamera {
|
||||||
|
/**
|
||||||
|
* Read the camera clock; when it drifts more than `maxDriftSec` from `localIso`
|
||||||
|
* (the site's wall-clock now, WITH utc offset), set it via
|
||||||
|
* PUT /ISAPI/System/time. The camera's own `timeZone` string is echoed back
|
||||||
|
* verbatim — we correct the CLOCK, never fight the tz/DST config; `localIso`'s
|
||||||
|
* explicit offset makes the instant unambiguous regardless of that config.
|
||||||
|
*/
|
||||||
|
async syncClock(localIso: string, maxDriftSec: number): Promise<ClockSyncResult> {
|
||||||
|
const read = await this.isapi("GET", "/ISAPI/System/time");
|
||||||
|
if (read.status !== 200) {
|
||||||
|
throw new Error(`clock read failed: HTTP ${read.status}`);
|
||||||
|
}
|
||||||
|
const xml = read.body.toString("utf8");
|
||||||
|
const cameraTime = xml.match(/<localTime>([^<]+)<\/localTime>/)?.[1]?.trim() ?? null;
|
||||||
|
const timeZone = xml.match(/<timeZone>([^<]+)<\/timeZone>/)?.[1]?.trim() ?? "";
|
||||||
|
|
||||||
|
// Drift: parse both sides as instants. A camera reply without a UTC offset (or
|
||||||
|
// otherwise unparseable) can't be trusted → treat as infinite drift and sync.
|
||||||
|
const cameraMs = cameraTime ? Date.parse(cameraTime) : NaN;
|
||||||
|
const hostMs = Date.parse(localIso);
|
||||||
|
const driftSeconds = Number.isFinite(cameraMs)
|
||||||
|
? Math.round(Math.abs(hostMs - cameraMs) / 1000)
|
||||||
|
: null;
|
||||||
|
if (driftSeconds != null && driftSeconds <= maxDriftSec) {
|
||||||
|
return { driftSeconds, synced: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const body =
|
||||||
|
`<?xml version="1.0" encoding="UTF-8"?>` +
|
||||||
|
`<Time><timeMode>manual</timeMode><localTime>${localIso}</localTime>` +
|
||||||
|
(timeZone ? `<timeZone>${timeZone}</timeZone>` : "") +
|
||||||
|
`</Time>`;
|
||||||
|
const put = await this.isapi("PUT", "/ISAPI/System/time", body);
|
||||||
|
if (put.status !== 200) {
|
||||||
|
throw new Error(`clock set failed: HTTP ${put.status}`);
|
||||||
|
}
|
||||||
|
stubLog(this.driverId, `clock synced (was ${driftSeconds ?? "unparseable"}s off)`);
|
||||||
|
return { driftSeconds, synced: true };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const channelField: ConfigField = {
|
const channelField: ConfigField = {
|
||||||
@@ -237,7 +318,7 @@ export const hikvisionDriver: CameraDriver = {
|
|||||||
// ISAPI channel id: <channel><stream>, e.g. ch1 main = 101, ch1 sub = 102, ch2 main = 201.
|
// ISAPI channel id: <channel><stream>, e.g. ch1 main = 101, ch1 sub = 102, ch2 main = 201.
|
||||||
// stream 1 → "01" (main), 2 → "02" (sub).
|
// stream 1 → "01" (main), 2 → "02" (sub).
|
||||||
create: (c) =>
|
create: (c) =>
|
||||||
new HttpCamera(
|
new HikvisionCamera(
|
||||||
"hikvision",
|
"hikvision",
|
||||||
c,
|
c,
|
||||||
(ch, stream) => `/ISAPI/Streaming/channels/${ch}0${stream}/picture`,
|
(ch, stream) => `/ISAPI/Streaming/channels/${ch}0${stream}/picture`,
|
||||||
|
|||||||
@@ -73,19 +73,33 @@ export interface DigestGetOptions {
|
|||||||
readonly localAddress?: string;
|
readonly localAddress?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getOnce(
|
/** digestGet + a method and optional body — for ISAPI configuration writes
|
||||||
o: DigestGetOptions,
|
* (e.g. PUT /ISAPI/System/time). The digest handshake is method-aware (HA2
|
||||||
|
* hashes the method), so this generalisation is the real one, not a shortcut. */
|
||||||
|
export interface DigestRequestOptions extends DigestGetOptions {
|
||||||
|
readonly method: "GET" | "PUT" | "POST";
|
||||||
|
readonly body?: Buffer | string;
|
||||||
|
readonly contentType?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestOnce(
|
||||||
|
o: DigestRequestOptions,
|
||||||
authHeader?: string,
|
authHeader?: string,
|
||||||
): Promise<{ res: IncomingMessage; body: Buffer }> {
|
): Promise<{ res: IncomingMessage; body: Buffer }> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
|
const payload = o.body == null ? null : Buffer.isBuffer(o.body) ? o.body : Buffer.from(o.body, "utf8");
|
||||||
const headers: Record<string, string> = {};
|
const headers: Record<string, string> = {};
|
||||||
if (authHeader) headers["authorization"] = authHeader;
|
if (authHeader) headers["authorization"] = authHeader;
|
||||||
|
if (payload) {
|
||||||
|
headers["content-type"] = o.contentType ?? "application/xml";
|
||||||
|
headers["content-length"] = String(payload.length);
|
||||||
|
}
|
||||||
const req = httpRequest(
|
const req = httpRequest(
|
||||||
{
|
{
|
||||||
host: o.host,
|
host: o.host,
|
||||||
port: o.port,
|
port: o.port,
|
||||||
path: o.path,
|
path: o.path,
|
||||||
method: "GET",
|
method: o.method,
|
||||||
timeout: o.timeoutMs,
|
timeout: o.timeoutMs,
|
||||||
localAddress: o.localAddress,
|
localAddress: o.localAddress,
|
||||||
headers,
|
headers,
|
||||||
@@ -97,19 +111,21 @@ function getOnce(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
req.on("error", reject);
|
req.on("error", reject);
|
||||||
req.on("timeout", () => req.destroy(new Error("digest GET timeout")));
|
req.on("timeout", () => req.destroy(new Error(`digest ${o.method} timeout`)));
|
||||||
|
if (payload) req.write(payload);
|
||||||
req.end();
|
req.end();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET a resource with HTTP Digest auth. Does the standard two-shot handshake:
|
* Request a resource with HTTP Digest auth. Does the standard two-shot handshake:
|
||||||
* the first request (no Authorization) draws a 401 + challenge, the second
|
* the first request (no Authorization) draws a 401 + challenge, the second
|
||||||
* carries the computed response. If the server doesn't challenge (200 straight
|
* carries the computed response (the body is sent BOTH times — the challenge shot
|
||||||
|
* needs the same request shape). If the server doesn't challenge (200 straight
|
||||||
* away, or no auth required), the first response is returned as-is.
|
* away, or no auth required), the first response is returned as-is.
|
||||||
*/
|
*/
|
||||||
export async function digestGet(o: DigestGetOptions): Promise<DigestGetResult> {
|
export async function digestRequest(o: DigestRequestOptions): Promise<DigestGetResult> {
|
||||||
const first = await getOnce(o);
|
const first = await requestOnce(o);
|
||||||
if (first.res.statusCode !== 401) {
|
if (first.res.statusCode !== 401) {
|
||||||
return {
|
return {
|
||||||
status: first.res.statusCode ?? 0,
|
status: first.res.statusCode ?? 0,
|
||||||
@@ -129,11 +145,16 @@ export async function digestGet(o: DigestGetOptions): Promise<DigestGetResult> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const challenge = parseChallenge(challengeHeader);
|
const challenge = parseChallenge(challengeHeader);
|
||||||
const auth = buildAuthHeader(challenge, o.user, o.password, "GET", o.path);
|
const auth = buildAuthHeader(challenge, o.user, o.password, o.method, o.path);
|
||||||
const second = await getOnce(o, auth);
|
const second = await requestOnce(o, auth);
|
||||||
return {
|
return {
|
||||||
status: second.res.statusCode ?? 0,
|
status: second.res.statusCode ?? 0,
|
||||||
contentType: String(second.res.headers["content-type"] ?? ""),
|
contentType: String(second.res.headers["content-type"] ?? ""),
|
||||||
body: second.body,
|
body: second.body,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** GET with Digest auth (the original entry point; snapshots and status reads). */
|
||||||
|
export function digestGet(o: DigestGetOptions): Promise<DigestGetResult> {
|
||||||
|
return digestRequest({ ...o, method: "GET" });
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { registry } from "../registry.js";
|
|||||||
import { dingtianDriver } from "./access-dingtian.js";
|
import { dingtianDriver } from "./access-dingtian.js";
|
||||||
import { stubAccessDriver } from "./access-stub.js";
|
import { stubAccessDriver } from "./access-stub.js";
|
||||||
import { dahuaDriver, hikvisionDriver } from "./camera.js";
|
import { dahuaDriver, hikvisionDriver } from "./camera.js";
|
||||||
import { cashinoDriver } from "./printer-cashino.js";
|
import { escposDriver } from "./printer-generic.js";
|
||||||
import { rongtaDriver } from "./printer-rongta.js";
|
import { rongtaDriver } from "./printer-rongta.js";
|
||||||
import { dingtianQrReaderDriver, tcpipReaderDriver, wiegandReaderDriver } from "./reader.js";
|
import { dingtianQrReaderDriver, tcpipReaderDriver, wiegandReaderDriver } from "./reader.js";
|
||||||
|
|
||||||
@@ -23,7 +23,7 @@ export function registerBuiltinDrivers(): void {
|
|||||||
registry.register(hikvisionDriver);
|
registry.register(hikvisionDriver);
|
||||||
registry.register(dahuaDriver);
|
registry.register(dahuaDriver);
|
||||||
registry.register(rongtaDriver);
|
registry.register(rongtaDriver);
|
||||||
registry.register(cashinoDriver);
|
registry.register(escposDriver);
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
@@ -35,5 +35,5 @@ export {
|
|||||||
hikvisionDriver,
|
hikvisionDriver,
|
||||||
dahuaDriver,
|
dahuaDriver,
|
||||||
rongtaDriver,
|
rongtaDriver,
|
||||||
cashinoDriver,
|
escposDriver,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
probeUsb,
|
probeUsb,
|
||||||
sendRawUsb,
|
sendRawUsb,
|
||||||
transportFromConfig,
|
transportFromConfig,
|
||||||
|
writeAllUsb,
|
||||||
stamp,
|
stamp,
|
||||||
} from "./printer-escpos.js";
|
} from "./printer-escpos.js";
|
||||||
|
|
||||||
@@ -183,3 +184,72 @@ describe("stamp (Albanian date format)", () => {
|
|||||||
expect(stamp("not-a-date")).toBe("not-a-date");
|
expect(stamp("not-a-date")).toBe("not-a-date");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("writeAllUsb — partial writes / EAGAIN / close-cancel (field bugs 2026-07-06/07)", () => {
|
||||||
|
// A NONBLOCK usblp fd accepts only what fits the printer's USB buffer per write,
|
||||||
|
// write() returns at URB submission, and close() KILLS the in-flight URB — so the
|
||||||
|
// loop must deliver every byte AND certify delivery before the caller may close
|
||||||
|
// (final byte written alone; its acceptance proves all prior bytes landed). A
|
||||||
|
// regular file can't reproduce any of that, so these drive a fake handle.
|
||||||
|
|
||||||
|
/** Accepts at most `cap` bytes per call; records everything accepted in order. */
|
||||||
|
function slowHandle(cap: number) {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
return {
|
||||||
|
chunks,
|
||||||
|
write(buffer: Buffer, offset: number, length: number) {
|
||||||
|
const n = Math.min(cap, length);
|
||||||
|
chunks.push(Buffer.from(buffer.subarray(offset, offset + n)));
|
||||||
|
return Promise.resolve({ bytesWritten: n });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it("delivers the WHOLE payload across many short writes (barcode + cut included)", async () => {
|
||||||
|
const payload = renderTicket({ ticketId: "12345678901", issuedAt: "2026-06-21T10:00:00.000Z" });
|
||||||
|
const h = slowHandle(100); // way smaller than the job → many partial writes
|
||||||
|
await writeAllUsb(h, payload, Date.now() + 2000, 5);
|
||||||
|
expect(Buffer.concat(h.chunks).equals(payload)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the FINAL byte is written alone — the delivery certificate before close", async () => {
|
||||||
|
const payload = Buffer.from("x".repeat(5000)); // > one 4K chunk
|
||||||
|
const h = slowHandle(100_000); // accepts anything → chunking is ours, not the cap's
|
||||||
|
await writeAllUsb(h, payload, Date.now() + 2000, 5);
|
||||||
|
expect(Buffer.concat(h.chunks).equals(payload)).toBe(true);
|
||||||
|
expect(h.chunks.at(-1)!.length).toBe(1); // usblp: its acceptance proves the rest landed
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retries EAGAIN (buffer full) until the kernel accepts the rest", async () => {
|
||||||
|
const payload = Buffer.from("x".repeat(300));
|
||||||
|
let calls = 0;
|
||||||
|
const accepted: Buffer[] = [];
|
||||||
|
const h = {
|
||||||
|
write(buffer: Buffer, offset: number, length: number) {
|
||||||
|
calls++;
|
||||||
|
if (calls % 2 === 0) {
|
||||||
|
const err = new Error("EAGAIN") as NodeJS.ErrnoException;
|
||||||
|
err.code = "EAGAIN";
|
||||||
|
return Promise.reject(err);
|
||||||
|
}
|
||||||
|
const n = Math.min(120, length);
|
||||||
|
accepted.push(Buffer.from(buffer.subarray(offset, offset + n)));
|
||||||
|
return Promise.resolve({ bytesWritten: n });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await writeAllUsb(h, payload, Date.now() + 2000, 5);
|
||||||
|
expect(Buffer.concat(accepted).equals(payload)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a wedged printer (never accepts a byte) fails at the deadline instead of hanging", async () => {
|
||||||
|
const h = { write: () => Promise.resolve({ bytesWritten: 0 }) };
|
||||||
|
await expect(writeAllUsb(h, Buffer.from("job"), Date.now() + 60)).rejects.toThrow(/usb write timeout/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a non-EAGAIN error surfaces immediately", async () => {
|
||||||
|
const err = new Error("EIO") as NodeJS.ErrnoException;
|
||||||
|
err.code = "EIO";
|
||||||
|
const h = { write: () => Promise.reject(err) };
|
||||||
|
await expect(writeAllUsb(h, Buffer.from("job"), Date.now() + 1000)).rejects.toThrow("EIO");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -202,6 +202,9 @@ const STR = {
|
|||||||
tenderCard: "Kartë",
|
tenderCard: "Kartë",
|
||||||
/** "Paid:" amount label (precedes the large total). */
|
/** "Paid:" amount label (precedes the large total). */
|
||||||
amountLabel: "PAGUAR",
|
amountLabel: "PAGUAR",
|
||||||
|
/** Merchant-validation lines: the pre-discount fee + one line per discount. */
|
||||||
|
gross: (v: string) => `Tarifa: ${v}`,
|
||||||
|
discount: (label: string, v: string) => `${label}: -${v}`,
|
||||||
/** Walk-back grace emphasis (voucher mode) — two short lines that each fit the
|
/** Walk-back grace emphasis (voucher mode) — two short lines that each fit the
|
||||||
* 80mm width, so neither wraps mid-word. */
|
* 80mm width, so neither wraps mid-word. */
|
||||||
graceLines: (min: number): readonly string[] => [
|
graceLines: (min: number): readonly string[] => [
|
||||||
@@ -413,6 +416,16 @@ export function renderReceipt(data: ReceiptData): Buffer {
|
|||||||
line(
|
line(
|
||||||
STR.tender(data.tender === "card" ? STR.tenderCard : STR.tenderCash),
|
STR.tender(data.tender === "card" ? STR.tenderCard : STR.tenderCash),
|
||||||
),
|
),
|
||||||
|
// Merchant validations: gross fee + one line per discount, so the customer sees
|
||||||
|
// the full gross → discounts → net story (the big amount below is the NET).
|
||||||
|
...(data.validationLines?.length
|
||||||
|
? [
|
||||||
|
line(STR.gross(money(data.grossMinor ?? data.amountMinor, data.currency))),
|
||||||
|
...data.validationLines.map((v) =>
|
||||||
|
line(STR.discount(v.label, money(v.discountMinor, data.currency))),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
: []),
|
||||||
line(),
|
line(),
|
||||||
// The amount, large and centred.
|
// The amount, large and centred.
|
||||||
ALIGN_CENTER,
|
ALIGN_CENTER,
|
||||||
@@ -600,10 +613,83 @@ function withTimeout<T>(p: Promise<T>, ms: number, msg: string): Promise<T> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** usblp accepts only what fits its kernel buffer (~8 KB) per write on a NONBLOCK fd,
|
||||||
|
* so jobs are pushed in chunks safely under that. */
|
||||||
|
const USB_WRITE_CHUNK = 4096;
|
||||||
|
|
||||||
|
/** Pause after the FINAL byte's write is accepted, before close. Its acceptance
|
||||||
|
* proves everything before it is physically in the printer (see writeAllUsb); this
|
||||||
|
* covers the one-byte URB still in flight — a single bulk packet the printer ACKs
|
||||||
|
* immediately (it just freed buffer space by ACKing the previous chunk). */
|
||||||
|
const USB_DRAIN_MS = 300;
|
||||||
|
|
||||||
|
const delay = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
/** The slice of FileHandle the USB write loop needs (injectable for tests — a real
|
||||||
|
* regular file can't reproduce the char device's partial writes / EAGAIN). */
|
||||||
|
export interface UsbWriteHandle {
|
||||||
|
write(buffer: Buffer, offset: number, length: number): Promise<{ bytesWritten: number }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Push the WHOLE payload through a non-blocking usblp fd AND ensure the printer has
|
||||||
|
* physically received it before the caller may close. TWO field-verified truncation
|
||||||
|
* modes on the ICS XP-K200L (same symptom: text head prints, barcode/feed/CUT tail
|
||||||
|
* lost; TCP fine):
|
||||||
|
*
|
||||||
|
* 1. SHORT WRITES (2026-07-06): a single fire-and-forget write() only delivers what
|
||||||
|
* the kernel accepts. Fix: chunked loop, retry EAGAIN, until all bytes accepted.
|
||||||
|
* 2. CLOSE CANCELS THE LAST TRANSFER (2026-07-07, lab bench): per usblp.c, write()
|
||||||
|
* returns at URB *submission*, only ONE write URB is in flight at a time, and
|
||||||
|
* usblp_release() (our close) KILLS in-flight URBs. The printer consumes bulk
|
||||||
|
* data at PRINT speed (tiny internal buffer), so closing right after the last
|
||||||
|
* accepted write cancels the still-transferring tail — which is exactly where
|
||||||
|
* the feed + GS V cut live ("have to press the feed button to see the text").
|
||||||
|
*
|
||||||
|
* The delivery guarantee follows from usblp's one-URB rule: ACCEPTANCE OF WRITE N
|
||||||
|
* PROVES WRITE N−1 FULLY COMPLETED (the driver EAGAINs until the previous URB's
|
||||||
|
* completion). So the payload is pushed as chunks, then its FINAL BYTE alone: when
|
||||||
|
* that 1-byte write is accepted, every byte before it is physically in the printer.
|
||||||
|
* A short drain pause then covers the lone final-byte URB (one bulk packet), and
|
||||||
|
* close is safe. `drainMs` is parameterised only for tests.
|
||||||
|
*/
|
||||||
|
export async function writeAllUsb(
|
||||||
|
handle: UsbWriteHandle,
|
||||||
|
payload: Buffer,
|
||||||
|
deadlineMs: number,
|
||||||
|
drainMs: number = USB_DRAIN_MS,
|
||||||
|
): Promise<void> {
|
||||||
|
if (payload.length === 0) return;
|
||||||
|
const lastByteAt = payload.length - 1;
|
||||||
|
let off = 0;
|
||||||
|
while (off < payload.length) {
|
||||||
|
if (Date.now() > deadlineMs) {
|
||||||
|
throw new Error(`usb write timeout (${off}/${payload.length} bytes accepted)`);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// Never let the final byte ride a bigger chunk: it is written ALONE so its
|
||||||
|
// acceptance certifies delivery of everything before it (see doc above).
|
||||||
|
const len = off === lastByteAt ? 1 : Math.min(USB_WRITE_CHUNK, lastByteAt - off);
|
||||||
|
const { bytesWritten } = await handle.write(payload, off, len);
|
||||||
|
off += bytesWritten;
|
||||||
|
if (bytesWritten === 0) await delay(5); // buffer full, no error — breathe
|
||||||
|
} catch (err) {
|
||||||
|
if ((err as NodeJS.ErrnoException).code === "EAGAIN") {
|
||||||
|
await delay(10); // printer draining its buffer — retry until the deadline
|
||||||
|
} else {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// All bytes accepted; only the 1-byte final URB can still be in flight. Give it a
|
||||||
|
// moment to land before the caller closes (close would cancel it).
|
||||||
|
await delay(drainMs);
|
||||||
|
}
|
||||||
|
|
||||||
/** Write an ESC/POS payload to a USB-lp character device (e.g. /dev/usb/lp0). usblp
|
/** Write an ESC/POS payload to a USB-lp character device (e.g. /dev/usb/lp0). usblp
|
||||||
* is a RAW character device: a single open + write delivers the job — there is no
|
* is a RAW character device — no FIN/half-close dance (that was a TCP concern) —
|
||||||
* FIN/half-close dance (that was a TCP concern, where an early destroy() could
|
* but delivery must go through the chunked loop above (see its doc for why). We
|
||||||
* truncate the stream). We always close the handle (even on a failed write). */
|
* always close the handle (even on a failed write). */
|
||||||
export async function sendRawUsb(
|
export async function sendRawUsb(
|
||||||
devicePath: string,
|
devicePath: string,
|
||||||
payload: Buffer,
|
payload: Buffer,
|
||||||
@@ -615,7 +701,7 @@ export async function sendRawUsb(
|
|||||||
"usb open timeout",
|
"usb open timeout",
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
await withTimeout(handle.write(payload), timeoutMs, "usb write timeout");
|
await writeAllUsb(handle, payload, Date.now() + timeoutMs);
|
||||||
} finally {
|
} finally {
|
||||||
await handle.close();
|
await handle.close();
|
||||||
}
|
}
|
||||||
@@ -701,8 +787,8 @@ export const transportField: ConfigField = {
|
|||||||
required: true,
|
required: true,
|
||||||
default: "tcp-ip",
|
default: "tcp-ip",
|
||||||
options: [
|
options: [
|
||||||
{ value: "tcp-ip", label: "Network (raw TCP, port 9100)" },
|
{ value: "tcp-ip", label: "Network (raw TCP)" },
|
||||||
{ value: "usb", label: "USB (local /dev/usb/lp0)" },
|
{ value: "usb", label: "USB (local printer)" },
|
||||||
],
|
],
|
||||||
help: "USB drives a printer plugged into the appliance (usblp); Network drives one on the isolated device VLAN.",
|
help: "USB drives a printer plugged into the appliance (usblp); Network drives one on the isolated device VLAN.",
|
||||||
};
|
};
|
||||||
@@ -714,5 +800,5 @@ export const devicePathField: ConfigField = {
|
|||||||
type: "string",
|
type: "string",
|
||||||
required: false,
|
required: false,
|
||||||
default: "/dev/usb/lp0",
|
default: "/dev/usb/lp0",
|
||||||
help: "Character device for a USB printer (usblp), e.g. /dev/usb/lp0. Only used when Connection is USB.",
|
help: "usblp character device (/dev/usb/lpN). The setup UI lists the printers actually present; the kernel numbers them (lp0, lp1, …) by plug/boot order. Only used when Connection is USB.",
|
||||||
};
|
};
|
||||||
|
|||||||
+10
-9
@@ -2,19 +2,20 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|||||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { cashinoDriver } from "./printer-cashino.js";
|
import { escposDriver } from "./printer-generic.js";
|
||||||
import { renderTicket } from "./printer-escpos.js";
|
import { renderTicket } from "./printer-escpos.js";
|
||||||
|
|
||||||
// End-to-end transport routing through the real driver: a USB-configured Cashino must
|
// End-to-end transport routing through the real driver: a USB-configured generic
|
||||||
|
// ESC/POS printer (Cashino / ICS XP-K200L family) must
|
||||||
// resolve to the char-device transport and write the SAME ESC/POS bytes the TCP path
|
// resolve to the char-device transport and write the SAME ESC/POS bytes the TCP path
|
||||||
// would. (The TCP path is exercised by the routing/escpos suites and on hardware.)
|
// would. (The TCP path is exercised by the routing/escpos suites and on hardware.)
|
||||||
|
|
||||||
describe("cashinoDriver — USB transport", () => {
|
describe("escposDriver (generic ESC/POS) — USB transport", () => {
|
||||||
let dir: string;
|
let dir: string;
|
||||||
let devicePath: string;
|
let devicePath: string;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
dir = mkdtempSync(join(tmpdir(), "cashino-usb-"));
|
dir = mkdtempSync(join(tmpdir(), "escpos-usb-"));
|
||||||
devicePath = join(dir, "lp0");
|
devicePath = join(dir, "lp0");
|
||||||
// Stand in for an enumerated usblp node (the kernel creates it; we only open it).
|
// Stand in for an enumerated usblp node (the kernel creates it; we only open it).
|
||||||
writeFileSync(devicePath, "");
|
writeFileSync(devicePath, "");
|
||||||
@@ -24,7 +25,7 @@ describe("cashinoDriver — USB transport", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("prints a ticket to the configured USB device path", async () => {
|
it("prints a ticket to the configured USB device path", async () => {
|
||||||
const printer = cashinoDriver.create({ transport: "usb", devicePath, timeoutMs: 1000 });
|
const printer = escposDriver.create({ transport: "usb", devicePath, timeoutMs: 1000 });
|
||||||
const data = { ticketId: "12345678901", issuedAt: "2026-06-21T10:00:00.000Z" };
|
const data = { ticketId: "12345678901", issuedAt: "2026-06-21T10:00:00.000Z" };
|
||||||
await printer.printTicket(data);
|
await printer.printTicket(data);
|
||||||
const written = readFileSync(devicePath);
|
const written = readFileSync(devicePath);
|
||||||
@@ -32,10 +33,10 @@ describe("cashinoDriver — USB transport", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("healthCheck reports ready when the node exists, offline when it doesn't", async () => {
|
it("healthCheck reports ready when the node exists, offline when it doesn't", async () => {
|
||||||
const present = cashinoDriver.create({ transport: "usb", devicePath, timeoutMs: 1000 });
|
const present = escposDriver.create({ transport: "usb", devicePath, timeoutMs: 1000 });
|
||||||
expect((await present.healthCheck()).status).toBe("ready");
|
expect((await present.healthCheck()).status).toBe("ready");
|
||||||
// An absent device node (printer unplugged / not enumerated) → offline.
|
// An absent device node (printer unplugged / not enumerated) → offline.
|
||||||
const absent = cashinoDriver.create({
|
const absent = escposDriver.create({
|
||||||
transport: "usb",
|
transport: "usb",
|
||||||
devicePath: join(dir, "absent-lp0"),
|
devicePath: join(dir, "absent-lp0"),
|
||||||
timeoutMs: 1000,
|
timeoutMs: 1000,
|
||||||
@@ -44,7 +45,7 @@ describe("cashinoDriver — USB transport", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("advertises both transports", () => {
|
it("advertises both transports", () => {
|
||||||
expect(cashinoDriver.transports).toContain("usb");
|
expect(escposDriver.transports).toContain("usb");
|
||||||
expect(cashinoDriver.transports).toContain("tcp-ip");
|
expect(escposDriver.transports).toContain("tcp-ip");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+21
-16
@@ -23,25 +23,26 @@ import {
|
|||||||
type Transport,
|
type Transport,
|
||||||
} from "./printer-escpos.js";
|
} from "./printer-escpos.js";
|
||||||
|
|
||||||
// Cashino 80mm thermal printer driver (network OR USB). The Cashino is an ESC/POS
|
// GENERIC ESC/POS 80mm thermal printer driver (network OR USB) — any clone that
|
||||||
// clone: it PRINTS identically to the Rongta (same byte stream — see
|
// PRINTS the shared ESC/POS byte stream (see ./printer-escpos.ts) but serves no
|
||||||
// ./printer-escpos.ts), so tickets, reports and subscription cards render the same,
|
// Rongta-style decoded status page (/prn_stat.htm). Verified fits: Cashino (the
|
||||||
// over either transport. What it does NOT have is the Rongta board's decoded status
|
// first unit we drove — the driver carried its name until 2026-07-06), ICS/Xprinter
|
||||||
// web page (/prn_stat.htm). It cannot report paper-out / cover-open / cutter faults
|
// XP-K200L. Tickets, reports and subscription cards render identically to the
|
||||||
// in a form we trust.
|
// Rongta, over either transport; what these clones can NOT do is report paper-out /
|
||||||
|
// cover-open / cutter faults in a form we trust.
|
||||||
//
|
//
|
||||||
// TRANSPORT: a single `config.transport` ("tcp-ip" | "usb") picks the wire; the
|
// TRANSPORT: a single `config.transport` ("tcp-ip" | "usb") picks the wire; the
|
||||||
// driver resolves it ONCE into a Transport and every print/probe stays transport-
|
// driver resolves it ONCE into a Transport and every print/probe stays transport-
|
||||||
// blind (see transportFromConfig/sendTo/probeTo). USB writes the same bytes to a
|
// blind (see transportFromConfig/sendTo/probeTo). USB writes the same bytes to a
|
||||||
// local usblp char device (/dev/usb/lp0); TCP writes to the raw print socket. This
|
// local usblp char device (/dev/usb/lp0); TCP writes to the raw print socket. This
|
||||||
// clone is the natural USB candidate — reachability-only, no status page to lose.
|
// clone family is the natural USB candidate — reachability-only, no page to lose.
|
||||||
//
|
//
|
||||||
// Therefore this driver deliberately does NOT implement MonitorableDevice
|
// Therefore this driver deliberately does NOT implement MonitorableDevice
|
||||||
// (no readStatus). The device monitor then falls back to the generic
|
// (no readStatus). The device monitor then falls back to the generic
|
||||||
// `healthCheck()` — a plain TCP reachability PING of the print socket. So the
|
// `healthCheck()` — a plain TCP reachability PING of the print socket. So the
|
||||||
// booth footer shows this printer as "ready" when it's reachable and "offline"
|
// booth footer shows this printer as "ready" when it's reachable and "offline"
|
||||||
// when it isn't, and never a wrong paper/cover verdict it cannot actually sense.
|
// when it isn't, and never a wrong paper/cover verdict it cannot actually sense.
|
||||||
// (Reusing the Rongta driver made it scrape a status page the Cashino doesn't
|
// (Reusing the Rongta driver made it scrape a status page these clones don't
|
||||||
// serve, producing the bogus "degraded" feedback this driver fixes.)
|
// serve, producing the bogus "degraded" feedback this driver fixes.)
|
||||||
//
|
//
|
||||||
// No auth on the print socket — like the other field devices it lives on the
|
// No auth on the print socket — like the other field devices it lives on the
|
||||||
@@ -49,8 +50,8 @@ import {
|
|||||||
// (entry-dispenser / booth-receipt + failoverRank); the server owns selection.
|
// (entry-dispenser / booth-receipt + failoverRank); the server owns selection.
|
||||||
// See wiki/concepts/printer-status-monitoring.md and printer-roles-failover.md.
|
// See wiki/concepts/printer-status-monitoring.md and printer-roles-failover.md.
|
||||||
|
|
||||||
class CashinoPrinter implements PrinterDevice {
|
class GenericEscposPrinter implements PrinterDevice {
|
||||||
readonly driverId = "cashino";
|
readonly driverId = "escpos";
|
||||||
readonly #transport: Transport;
|
readonly #transport: Transport;
|
||||||
readonly #timeout: number;
|
readonly #timeout: number;
|
||||||
|
|
||||||
@@ -69,7 +70,7 @@ class CashinoPrinter implements PrinterDevice {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Reachability only — a connect probe (TCP) or char-device open probe (USB) of
|
* Reachability only — a connect probe (TCP) or char-device open probe (USB) of
|
||||||
* the print path. The Cashino has no trustworthy status protocol, so this is the
|
* the print path. These clones have no trustworthy status protocol, so this is the
|
||||||
* floor and the ceiling of what we report: reachable → ready, unreachable →
|
* floor and the ceiling of what we report: reachable → ready, unreachable →
|
||||||
* offline. Deliberately NO readStatus(): the monitor uses this for the
|
* offline. Deliberately NO readStatus(): the monitor uses this for the
|
||||||
* traffic-light, never a guessed paper/cover state.
|
* traffic-light, never a guessed paper/cover state.
|
||||||
@@ -140,12 +141,16 @@ const rankField: ConfigField = {
|
|||||||
help: "Higher = tried first within the same role. The booth printer also backs up the entry dispenser.",
|
help: "Higher = tried first within the same role. The booth printer also backs up the entry dispenser.",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const cashinoDriver: PrinterDriver = {
|
export const escposDriver: PrinterDriver = {
|
||||||
id: "cashino",
|
// Renamed from id "cashino" (the first clone we drove) on 2026-07-06 — the vendor
|
||||||
|
// name was misleading in the setup UI once other clones (ICS/Xprinter XP-K200L)
|
||||||
|
// used it. Stored configs with driverId "cashino" still resolve via the registry
|
||||||
|
// alias + are rewritten by migration 0023.
|
||||||
|
id: "escpos",
|
||||||
category: "printer",
|
category: "printer",
|
||||||
label: "Cashino 80mm thermal printer",
|
label: "Generic ESC/POS 80mm printer (Cashino, ICS/Xprinter…)",
|
||||||
description:
|
description:
|
||||||
"Cashino 80mm thermal printer (ESC/POS over raw TCP port 9100, OR local USB /dev/usb/lp0). Prints like the Rongta but has no status page — monitored by reachability only (no paper/cover/cutter reporting). No auth on the print socket — isolate the VLAN.",
|
"Generic ESC/POS 80mm thermal printer over raw TCP (port 9100) OR local USB /dev/usb/lp0 — Cashino, ICS/Xprinter XP-K200L, and similar clones. Prints like the Rongta but has no status page — monitored by reachability only (no paper/cover/cutter reporting). No auth on the print socket — isolate the VLAN.",
|
||||||
transports: ["tcp-ip", "usb"],
|
transports: ["tcp-ip", "usb"],
|
||||||
configFields: [
|
configFields: [
|
||||||
transportField,
|
transportField,
|
||||||
@@ -167,5 +172,5 @@ export const cashinoDriver: PrinterDriver = {
|
|||||||
default: 3000,
|
default: 3000,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
create: (c) => new CashinoPrinter(c),
|
create: (c) => new GenericEscposPrinter(c),
|
||||||
};
|
};
|
||||||
@@ -18,7 +18,7 @@ export {
|
|||||||
hikvisionDriver,
|
hikvisionDriver,
|
||||||
dahuaDriver,
|
dahuaDriver,
|
||||||
rongtaDriver,
|
rongtaDriver,
|
||||||
cashinoDriver,
|
escposDriver,
|
||||||
} from "./drivers/index.js";
|
} from "./drivers/index.js";
|
||||||
export { isPrinter, type PrinterRole } from "./drivers/printer-rongta.js";
|
export { isPrinter, type PrinterRole } from "./drivers/printer-rongta.js";
|
||||||
// Albanian human date/time for printed slips (receipts, tickets, shift Z-report),
|
// Albanian human date/time for printed slips (receipts, tickets, shift Z-report),
|
||||||
|
|||||||
@@ -194,6 +194,30 @@ export function isCamera(device: Device): device is Device & CameraDevice {
|
|||||||
return typeof (device as Partial<CameraDevice>).captureSnapshot === "function";
|
return typeof (device as Partial<CameraDevice>).captureSnapshot === "function";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Outcome of a camera clock sync attempt (see ClockSyncDevice). */
|
||||||
|
export interface ClockSyncResult {
|
||||||
|
/** Camera-vs-host drift in whole seconds at check time; null = the camera's
|
||||||
|
* reply was unparseable (treated as infinite drift → sync). */
|
||||||
|
readonly driftSeconds: number | null;
|
||||||
|
/** True when the camera clock was actually set (drift exceeded the threshold). */
|
||||||
|
readonly synced: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Optional capability: a device whose clock the HOST can read + set. Hikvision
|
||||||
|
* cameras lose their clock on power cuts (no/dead RTC battery, reboot at the 1970
|
||||||
|
* epoch) and only heal when a human logs into the web UI — so the device monitor
|
||||||
|
* re-syncs them from the host clock at the offline→ready edge + a daily backstop.
|
||||||
|
* See wiki/entities/lpr-camera.md (clock sync). */
|
||||||
|
export interface ClockSyncDevice {
|
||||||
|
/** Compare the device clock to `localIso` (the site's wall-clock now, WITH utc
|
||||||
|
* offset) and set it when drift exceeds `maxDriftSec`. */
|
||||||
|
syncClock(localIso: string, maxDriftSec: number): Promise<ClockSyncResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isClockSyncable(device: Device): device is Device & ClockSyncDevice {
|
||||||
|
return typeof (device as Partial<ClockSyncDevice>).syncClock === "function";
|
||||||
|
}
|
||||||
|
|
||||||
export interface SnapshotContext {
|
export interface SnapshotContext {
|
||||||
readonly direction: "entry" | "exit";
|
readonly direction: "entry" | "exit";
|
||||||
}
|
}
|
||||||
@@ -251,6 +275,11 @@ export interface ReceiptData {
|
|||||||
readonly voucher: boolean;
|
readonly voucher: boolean;
|
||||||
/** Minutes the customer has to reach the exit after paying (voucher mode only). */
|
/** Minutes the customer has to reach the exit after paying (voucher mode only). */
|
||||||
readonly graceExitMin?: number | null;
|
readonly graceExitMin?: number | null;
|
||||||
|
/** Merchant validations (bar/lavazh): the PRE-discount fee and the per-validation
|
||||||
|
* lines. When present, `amountMinor` is the NET actually paid and the receipt
|
||||||
|
* shows the full gross → discounts → net story. See validation-discounts.md. */
|
||||||
|
readonly grossMinor?: number | null;
|
||||||
|
readonly validationLines?: readonly { label: string; discountMinor: number }[];
|
||||||
readonly header?: TicketHeader;
|
readonly header?: TicketHeader;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -97,6 +97,14 @@ export function isDiscoverable(
|
|||||||
return typeof (driver as Partial<DiscoverableDriver>).discover === "function";
|
return typeof (driver as Partial<DiscoverableDriver>).discover === "function";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Renamed driver ids: what a STORED config may still say → the current id. Kept
|
||||||
|
* tiny + permanent so old DB rows, exports, and backups resolve across renames
|
||||||
|
* (migration 0023 rewrites live rows, but a restored old backup may reintroduce
|
||||||
|
* the historical id). */
|
||||||
|
const DRIVER_ID_ALIASES: Record<string, string> = {
|
||||||
|
cashino: "escpos", // renamed 2026-07-06 — it was always the generic ESC/POS driver
|
||||||
|
};
|
||||||
|
|
||||||
class DeviceRegistry {
|
class DeviceRegistry {
|
||||||
readonly #drivers = new Map<string, DeviceDriver>();
|
readonly #drivers = new Map<string, DeviceDriver>();
|
||||||
|
|
||||||
@@ -114,12 +122,12 @@ class DeviceRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get(id: string): DeviceDriver | undefined {
|
get(id: string): DeviceDriver | undefined {
|
||||||
return this.#drivers.get(id);
|
return this.#drivers.get(DRIVER_ID_ALIASES[id] ?? id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Validate config against a driver's declared fields and build the adapter. */
|
/** Validate config against a driver's declared fields and build the adapter. */
|
||||||
create(id: string, config: DeviceConfig): Device {
|
create(id: string, config: DeviceConfig): Device {
|
||||||
const driver = this.#drivers.get(id);
|
const driver = this.get(id);
|
||||||
if (!driver) throw new Error(`unknown driver: ${id}`);
|
if (!driver) throw new Error(`unknown driver: ${id}`);
|
||||||
for (const field of driver.configFields) {
|
for (const field of driver.configFields) {
|
||||||
if (field.required && config[field.key] === undefined) {
|
if (field.required && config[field.key] === undefined) {
|
||||||
|
|||||||
+316
-17
@@ -20,6 +20,7 @@ export const RESOURCES = [
|
|||||||
"tariff", // read / publish a new version
|
"tariff", // read / publish a new version
|
||||||
"subscription", // the subscription registry
|
"subscription", // the subscription registry
|
||||||
"site", // site_config + device setup/assign
|
"site", // site_config + device setup/assign
|
||||||
|
"validation", // merchant validations: apply a discount to a session (bar/lavazh)
|
||||||
"device", // device status / printers / snapshots / catalog
|
"device", // device status / printers / snapshots / catalog
|
||||||
"shift", // open/close own shift
|
"shift", // open/close own shift
|
||||||
"drawer", // record cash receipts/disbursements (operator); review them (admin)
|
"drawer", // record cash receipts/disbursements (operator); review them (admin)
|
||||||
@@ -54,6 +55,13 @@ export const PERMISSIONS: readonly Permission[] = [
|
|||||||
"subscription:plan", // compose the plan catalog (admin-grade); selling = subscription:create
|
"subscription:plan", // compose the plan catalog (admin-grade); selling = subscription:create
|
||||||
|
|
||||||
"site:read", "site:update",
|
"site:read", "site:update",
|
||||||
|
// Merchant validations (bar/lavazh): create = APPLY a validation to a session (the
|
||||||
|
// merchant user's one permission — guarded further by the program↔user binding, so a
|
||||||
|
// bar user can never apply the lavazh program) + void their OWN unused validation;
|
||||||
|
// read = see applied validations (reports/history). Program COMPOSITION needs no new
|
||||||
|
// permission — it lives on /setup/site behind site:update. See
|
||||||
|
// wiki/concepts/validation-discounts.md.
|
||||||
|
"validation:create", "validation:read",
|
||||||
"device:read",
|
"device:read",
|
||||||
"shift:read", "shift:create", "shift:cash",
|
"shift:read", "shift:create", "shift:cash",
|
||||||
// Drawer cash movements: create (operator RECORDS a receipt/disbursement — freely, no
|
// Drawer cash movements: create (operator RECORDS a receipt/disbursement — freely, no
|
||||||
@@ -272,6 +280,14 @@ export type LedgerEventType =
|
|||||||
// the admin is NOT the adversary, but weakening an anti-fraud gate must still be
|
// the admin is NOT the adversary, but weakening an anti-fraud gate must still be
|
||||||
// attributed + auditable). See wiki/concepts/entry-presence-bypass.md.
|
// attributed + auditable). See wiki/concepts/entry-presence-bypass.md.
|
||||||
| "config_change"
|
| "config_change"
|
||||||
|
// A merchant validation applied to (or voided from) a transient session: the bar/
|
||||||
|
// lavazh user scanned the customer's ticket, so the booth settlement discounts the
|
||||||
|
// fee. Payload carries the RESOLVED values (programId, label, mode, minutes/
|
||||||
|
// amountMinor/percent) — reproducible even if the program config later changes —
|
||||||
|
// plus `operator` (the merchant username). A payload with `refId` set is a VOID of
|
||||||
|
// the referenced validation event (append-only correction, mirrors cash_review).
|
||||||
|
// See wiki/concepts/validation-discounts.md.
|
||||||
|
| "validation"
|
||||||
| "anomaly";
|
| "anomaly";
|
||||||
|
|
||||||
/** How money was tendered (for payment events + the shift Z-report). */
|
/** How money was tendered (for payment events + the shift Z-report). */
|
||||||
@@ -291,9 +307,25 @@ export interface LedgerPayload {
|
|||||||
readonly tender?: Tender;
|
readonly tender?: Tender;
|
||||||
/** payment: which tariff_version priced it (reproducible repricing). */
|
/** payment: which tariff_version priced it (reproducible repricing). */
|
||||||
readonly tariffVersionId?: string;
|
readonly tariffVersionId?: string;
|
||||||
/** payment: gross/discount/net split when a validation applied. */
|
/** payment: gross/discount/net split when a validation applied. `amountMinor` is the
|
||||||
|
* NET collected; grossMinor the pre-discount fee; discountMinor what validations took
|
||||||
|
* off. `validationIds` = the validation event ids this payment CONSUMED (so an
|
||||||
|
* overstay's fresh period never re-applies them). */
|
||||||
readonly grossMinor?: number;
|
readonly grossMinor?: number;
|
||||||
readonly discountMinor?: number;
|
readonly discountMinor?: number;
|
||||||
|
readonly validationIds?: string[];
|
||||||
|
/** payment: the per-validation receipt lines as settled (label + amount taken off) —
|
||||||
|
* stamped so the printed receipt reproduces without re-deriving the fold. */
|
||||||
|
readonly validationLines?: { programId: string; label: string; mode: string; discountMinor: number }[];
|
||||||
|
/** validation: which program (bar/lavazh) + its receipt label, frozen at apply time. */
|
||||||
|
readonly programId?: string;
|
||||||
|
readonly programLabel?: string;
|
||||||
|
/** validation: resolved values by mode — timeCredit's free minutes / percent off.
|
||||||
|
* A fixed amount rides the shared `amountMinor`. */
|
||||||
|
readonly minutes?: number;
|
||||||
|
readonly percent?: number;
|
||||||
|
/** validation / cash vouchers: the username of the user who recorded it. */
|
||||||
|
readonly operator?: string;
|
||||||
/** FX-ready, deferred: rate applied (null/absent now). See open-questions #8. */
|
/** FX-ready, deferred: rate applied (null/absent now). See open-questions #8. */
|
||||||
readonly fxRate?: number | null;
|
readonly fxRate?: number | null;
|
||||||
/** void / anomaly / override: a human-readable English sentence, signed as the
|
/** void / anomaly / override: a human-readable English sentence, signed as the
|
||||||
@@ -326,7 +358,8 @@ export interface LedgerPayload {
|
|||||||
* cash_review event, so new movements do NOT carry this. Kept so historical events
|
* cash_review event, so new movements do NOT carry this. Kept so historical events
|
||||||
* still verify + display. See wiki/concepts/shift.md. */
|
* still verify + display. See wiki/concepts/shift.md. */
|
||||||
readonly authorizedBy?: string;
|
readonly authorizedBy?: string;
|
||||||
/** cash_review: the id of the cash_in/cash_out event this review decides on. */
|
/** cash_review: the id of the cash_in/cash_out event this review decides on.
|
||||||
|
* validation: set = this event VOIDS the referenced validation event. */
|
||||||
readonly refId?: string;
|
readonly refId?: string;
|
||||||
/** cash_review: the admin's decision on the referenced movement. A FLAG only —
|
/** cash_review: the admin's decision on the referenced movement. A FLAG only —
|
||||||
* neither value moves cash or touches the drawer balance. */
|
* neither value moves cash or touches the drawer balance. */
|
||||||
@@ -702,6 +735,58 @@ export interface SessionPayment {
|
|||||||
readonly graceExitMin: number | null;
|
readonly graceExitMin: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Merchant validations (bar / lavazh discounts) ---------------------------
|
||||||
|
// An in-park merchant validates a customer's ticket so the BOOTH settlement charges
|
||||||
|
// less or nothing. The program is admin-composed MUTABLE master data (no versioning:
|
||||||
|
// the applied validation is a signed ledger event carrying the RESOLVED values, so
|
||||||
|
// reproducibility never depends on the row). All money stays at the booth — the
|
||||||
|
// merchant only validates. See wiki/concepts/validation-discounts.md.
|
||||||
|
|
||||||
|
/** How a program discounts: full comp / first-N-minutes free / a fixed amount (typed
|
||||||
|
* by the merchant at scan time, capped) / a percentage off. */
|
||||||
|
export type ValidationMode = "comp" | "timeCredit" | "fixed" | "percent";
|
||||||
|
export const VALIDATION_MODES: readonly ValidationMode[] = ["comp", "timeCredit", "fixed", "percent"];
|
||||||
|
|
||||||
|
/** An admin-composed validation program (one per merchant station; `bar` and `lavazh`
|
||||||
|
* are the well-known ids the /setup/site checkboxes toggle). */
|
||||||
|
export interface ValidationProgram {
|
||||||
|
readonly id: string; // well-known slug ("bar" | "lavazh"); generic for future merchants
|
||||||
|
/** Receipt label, e.g. "Lavazh — 1 orë falas". Printed on the booth receipt line. */
|
||||||
|
readonly name: string;
|
||||||
|
readonly mode: ValidationMode;
|
||||||
|
/** timeCredit: the free minutes. */
|
||||||
|
readonly minutes: number | null;
|
||||||
|
/** percent: 1..100 off the fee. */
|
||||||
|
readonly percent: number | null;
|
||||||
|
/** fixed: cap on the amount the merchant may type at scan time (minor units). */
|
||||||
|
readonly maxAmountMinor: number | null;
|
||||||
|
/** Cap: max applications of this program per local day (null = unlimited). */
|
||||||
|
readonly maxPerDay: number | null;
|
||||||
|
readonly active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An APPLIED validation as pricing cares about it — the RESOLVED values folded off
|
||||||
|
* the signed validation event (never the mutable program row). */
|
||||||
|
export interface SessionValidation {
|
||||||
|
/** The validation event id (payments record which ids they consumed). */
|
||||||
|
readonly eventId?: string;
|
||||||
|
readonly programId: string;
|
||||||
|
readonly label: string;
|
||||||
|
readonly mode: ValidationMode;
|
||||||
|
readonly minutes?: number; // timeCredit
|
||||||
|
readonly amountMinor?: number; // fixed
|
||||||
|
readonly percent?: number; // percent
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One receipt/display line: what a validation actually saved on this settlement. */
|
||||||
|
export interface ValidationLine {
|
||||||
|
readonly programId: string;
|
||||||
|
readonly label: string;
|
||||||
|
readonly mode: ValidationMode;
|
||||||
|
/** The (positive) amount this line took off the fee. */
|
||||||
|
readonly discountMinor: number;
|
||||||
|
}
|
||||||
|
|
||||||
/** The full pricing outcome for a session at a moment in time — what the booth's
|
/** The full pricing outcome for a session at a moment in time — what the booth's
|
||||||
* `quote()` and the exit flow compute, made PURE so it can be tested or previewed
|
* `quote()` and the exit flow compute, made PURE so it can be tested or previewed
|
||||||
* without a real ledger. See wiki/concepts/booth-exit-flow.md (overstay pricing). */
|
* without a real ledger. See wiki/concepts/booth-exit-flow.md (overstay pricing). */
|
||||||
@@ -709,8 +794,14 @@ export interface SessionPricing {
|
|||||||
/** The window actually billed now: entry→asOf normally, or grace-expiry→asOf for an
|
/** The window actually billed now: entry→asOf normally, or grace-expiry→asOf for an
|
||||||
* overstay (a paid session whose walk-back grace lapsed — a new period began). */
|
* overstay (a paid session whose walk-back grace lapsed — a new period began). */
|
||||||
readonly periodStart: string;
|
readonly periodStart: string;
|
||||||
/** Fee for [periodStart, asOf]. */
|
/** Amount DUE for [periodStart, asOf] — NET of any merchant validations. */
|
||||||
readonly amountMinor: number;
|
readonly amountMinor: number;
|
||||||
|
/** The pre-validation fee for the same period (= amountMinor when no validations). */
|
||||||
|
readonly grossMinor: number;
|
||||||
|
/** Total the validations took off (grossMinor − amountMinor). */
|
||||||
|
readonly discountMinor: number;
|
||||||
|
/** Per-validation receipt lines, in the canonical application order. */
|
||||||
|
readonly validationLines: ValidationLine[];
|
||||||
/** True when the latest payment's grace has lapsed (overstay = new period). */
|
/** True when the latest payment's grace has lapsed (overstay = new period). */
|
||||||
readonly overstay: boolean;
|
readonly overstay: boolean;
|
||||||
/** True when paid AND still inside the walk-back window (a settled, exitable stay). */
|
/** True when paid AND still inside the walk-back window (a settled, exitable stay). */
|
||||||
@@ -732,6 +823,15 @@ export interface SessionPricing {
|
|||||||
* `payments` is the session's payment history (only the LATEST matters for grace);
|
* `payments` is the session's payment history (only the LATEST matters for grace);
|
||||||
* pass [] for an unpaid session. The tariff version is the one frozen at entry — the
|
* pass [] for an unpaid session. The tariff version is the one frozen at entry — the
|
||||||
* customer keeps their rate card even across an overstay. See booth-exit-flow.md.
|
* customer keeps their rate card even across an overstay. See booth-exit-flow.md.
|
||||||
|
*
|
||||||
|
* `validations` are the UNCONSUMED merchant validations on the session (the caller
|
||||||
|
* filters out ids already recorded on a prior payment's `validationIds`, so an
|
||||||
|
* overstay's fresh period never re-applies them). Canonical application order —
|
||||||
|
* deterministic regardless of scan order: timeCredit (shifts the billed period's
|
||||||
|
* start forward, so "first hour free" is literal and windowed/stepped cards price
|
||||||
|
* the remainder correctly) → percent (of the remaining fee) → fixed amounts
|
||||||
|
* (clamped to the remainder) → comp (zeroes whatever is left). Net never goes
|
||||||
|
* below 0. See wiki/concepts/validation-discounts.md.
|
||||||
*/
|
*/
|
||||||
export function priceSession(
|
export function priceSession(
|
||||||
enteredAt: string,
|
enteredAt: string,
|
||||||
@@ -739,6 +839,7 @@ export function priceSession(
|
|||||||
tariff: TariffStructure,
|
tariff: TariffStructure,
|
||||||
payments: readonly SessionPayment[] = [],
|
payments: readonly SessionPayment[] = [],
|
||||||
category?: string,
|
category?: string,
|
||||||
|
validations: readonly SessionValidation[] = [],
|
||||||
): SessionPricing {
|
): SessionPricing {
|
||||||
const last = payments.length ? payments[payments.length - 1] : null;
|
const last = payments.length ? payments[payments.length - 1] : null;
|
||||||
const graceExpiryMs =
|
const graceExpiryMs =
|
||||||
@@ -748,10 +849,50 @@ export function priceSession(
|
|||||||
const withinGrace = graceExpiryMs != null && asOfMs <= graceExpiryMs;
|
const withinGrace = graceExpiryMs != null && asOfMs <= graceExpiryMs;
|
||||||
const periodStart = overstay ? new Date(graceExpiryMs!).toISOString() : enteredAt;
|
const periodStart = overstay ? new Date(graceExpiryMs!).toISOString() : enteredAt;
|
||||||
// A settled (paid + within grace) session owes nothing more; otherwise bill the period.
|
// A settled (paid + within grace) session owes nothing more; otherwise bill the period.
|
||||||
const amountMinor = withinGrace ? 0 : computeFee(periodStart, asOf, tariff, category);
|
const grossMinor = withinGrace ? 0 : computeFee(periodStart, asOf, tariff, category);
|
||||||
|
|
||||||
|
// Fold the validations (nothing to discount on a settled session or a zero fee is
|
||||||
|
// still folded so the receipt can show "Lavazh — falas" even when gross is 0-adjacent).
|
||||||
|
const lines: ValidationLine[] = [];
|
||||||
|
let net = grossMinor;
|
||||||
|
if (!withinGrace && validations.length) {
|
||||||
|
const byMode = (m: ValidationMode) => validations.filter((v) => v.mode === m);
|
||||||
|
// 1. Time credits: bill as if the period started later (clamped at asOf). The
|
||||||
|
// marginal saving of each credit is its line amount.
|
||||||
|
let startMs = Date.parse(periodStart);
|
||||||
|
for (const v of byMode("timeCredit")) {
|
||||||
|
const minutes = v.minutes ?? 0;
|
||||||
|
const shiftedMs = Math.min(startMs + minutes * 60_000, asOfMs);
|
||||||
|
const newFee = computeFee(new Date(shiftedMs).toISOString(), asOf, tariff, category);
|
||||||
|
lines.push({ programId: v.programId, label: v.label, mode: v.mode, discountMinor: net - newFee });
|
||||||
|
startMs = shiftedMs;
|
||||||
|
net = newFee;
|
||||||
|
}
|
||||||
|
// 2. Percent of the remaining fee (floor — integer minor units).
|
||||||
|
for (const v of byMode("percent")) {
|
||||||
|
const off = Math.floor((net * Math.min(Math.max(v.percent ?? 0, 0), 100)) / 100);
|
||||||
|
lines.push({ programId: v.programId, label: v.label, mode: v.mode, discountMinor: off });
|
||||||
|
net -= off;
|
||||||
|
}
|
||||||
|
// 3. Fixed amounts, clamped to the remainder so Σ lines ≡ gross − net.
|
||||||
|
for (const v of byMode("fixed")) {
|
||||||
|
const off = Math.min(Math.max(v.amountMinor ?? 0, 0), net);
|
||||||
|
lines.push({ programId: v.programId, label: v.label, mode: v.mode, discountMinor: off });
|
||||||
|
net -= off;
|
||||||
|
}
|
||||||
|
// 4. Comp: zero whatever is left.
|
||||||
|
for (const v of byMode("comp")) {
|
||||||
|
lines.push({ programId: v.programId, label: v.label, mode: v.mode, discountMinor: net });
|
||||||
|
net = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
periodStart,
|
periodStart,
|
||||||
amountMinor,
|
amountMinor: net,
|
||||||
|
grossMinor,
|
||||||
|
discountMinor: grossMinor - net,
|
||||||
|
validationLines: lines,
|
||||||
overstay,
|
overstay,
|
||||||
withinGrace,
|
withinGrace,
|
||||||
graceExpiresAt: graceExpiryMs != null ? new Date(graceExpiryMs).toISOString() : null,
|
graceExpiresAt: graceExpiryMs != null ? new Date(graceExpiryMs).toISOString() : null,
|
||||||
@@ -764,6 +905,115 @@ export function hasSteps(s: { steps?: readonly TariffStep[] }): boolean {
|
|||||||
return Array.isArray(s.steps) && s.steps.length > 0;
|
return Array.isArray(s.steps) && s.steps.length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Fee breakdown (explainability) -------------------------------------------
|
||||||
|
// One line item per priced "reason": a run of same-priced increments, a window
|
||||||
|
// package occurrence, a stepped day total, a daily-cap clamp, or the entry grace.
|
||||||
|
// Produced by the SAME walk computeFee runs (an optional trace collector inside
|
||||||
|
// computeFeeV1/V2), so Σ item amounts ≡ the fee by construction — the breakdown can
|
||||||
|
// never tell a different story than the bill. Built for the Tariff Lab's "how is
|
||||||
|
// this sum produced" view (2026-07-06). Minutes are offsets from the priced
|
||||||
|
// period's start.
|
||||||
|
|
||||||
|
export type FeeBreakdownItem =
|
||||||
|
/** The whole stay fit inside the free entry-grace window (fee 0). */
|
||||||
|
| { readonly kind: "grace"; readonly minutes: number }
|
||||||
|
/** A contiguous run of increments billed at one unit price by one card.
|
||||||
|
* `card` is the windowed card's name, or null for the base/default rate. */
|
||||||
|
| {
|
||||||
|
readonly kind: "band";
|
||||||
|
readonly card: string | null;
|
||||||
|
readonly fromMin: number;
|
||||||
|
readonly toMin: number;
|
||||||
|
readonly increments: number;
|
||||||
|
readonly unitMinor: number;
|
||||||
|
readonly amountMinor: number;
|
||||||
|
}
|
||||||
|
/** One window-package occurrence (charged once per contiguous run the card wins). */
|
||||||
|
| { readonly kind: "package"; readonly card: string; readonly fromMin: number; readonly amountMinor: number }
|
||||||
|
/** A stepped ("up-to") day total: day N used `dayMinutes`, priced by the tier at
|
||||||
|
* `uptoMin` (`repeated` = past the top tier, so the top total repeats as a cap). */
|
||||||
|
| {
|
||||||
|
readonly kind: "step";
|
||||||
|
readonly day: number;
|
||||||
|
readonly dayMinutes: number;
|
||||||
|
readonly uptoMin: number;
|
||||||
|
readonly amountMinor: number;
|
||||||
|
readonly repeated: boolean;
|
||||||
|
}
|
||||||
|
/** The daily cap clamped day N: amountMinor is the (negative) adjustment. */
|
||||||
|
| { readonly kind: "cap"; readonly day: number; readonly capMinor: number; readonly amountMinor: number };
|
||||||
|
|
||||||
|
export interface FeeBreakdown {
|
||||||
|
/** Actual stay length in whole minutes (before increment rounding). */
|
||||||
|
readonly rawMinutes: number;
|
||||||
|
/** Minutes billed after rounding UP to the increment (0 within grace). */
|
||||||
|
readonly billedMinutes: number;
|
||||||
|
readonly incrementMin: number;
|
||||||
|
readonly items: FeeBreakdownItem[];
|
||||||
|
/** Σ item amounts — always equals computeFee for the same arguments. */
|
||||||
|
readonly totalMinor: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Explain a fee: run the exact computeFee walk with a trace collector and return
|
||||||
|
* the line items plus the total. Same arguments as computeFee; the total returned
|
||||||
|
* here IS computeFee's answer (one code path, not a parallel calculation).
|
||||||
|
*/
|
||||||
|
export function explainFee(
|
||||||
|
enteredAt: string,
|
||||||
|
asOf: string,
|
||||||
|
tariff: TariffStructure,
|
||||||
|
category?: string,
|
||||||
|
): FeeBreakdown {
|
||||||
|
const items: FeeBreakdownItem[] = [];
|
||||||
|
const totalMinor = isTariffV2(tariff)
|
||||||
|
? computeFeeV2(enteredAt, asOf, tariff, category, items)
|
||||||
|
: computeFeeV1(enteredAt, asOf, tariff, items);
|
||||||
|
const ms = Date.parse(asOf) - Date.parse(enteredAt);
|
||||||
|
const rawMinutes = Number.isFinite(ms) && ms > 0 ? Math.round(ms / 60_000) : 0;
|
||||||
|
const inc = Math.max(1, tariff.incrementMin);
|
||||||
|
const inGrace = items.length === 1 && items[0]!.kind === "grace";
|
||||||
|
const billedMinutes =
|
||||||
|
inGrace || rawMinutes === 0 || ms / 60_000 <= tariff.gracePeriodEntryMin
|
||||||
|
? 0
|
||||||
|
: Math.ceil(ms / 60_000 / inc) * inc;
|
||||||
|
return { rawMinutes, billedMinutes, incrementMin: inc, items, totalMinor };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Band-merging helper for the trace: accumulate consecutive increments that share
|
||||||
|
* a (card, unit price) and flush them as one `band` item. */
|
||||||
|
class BandTracer {
|
||||||
|
#card: string | null = null;
|
||||||
|
#unit = 0;
|
||||||
|
#from = 0;
|
||||||
|
#count = 0;
|
||||||
|
constructor(private readonly items: FeeBreakdownItem[], private readonly inc: number) {}
|
||||||
|
add(card: string | null, unitMinor: number, atMin: number): void {
|
||||||
|
if (this.#count > 0 && this.#card === card && this.#unit === unitMinor) {
|
||||||
|
this.#count++;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.flush();
|
||||||
|
this.#card = card;
|
||||||
|
this.#unit = unitMinor;
|
||||||
|
this.#from = atMin;
|
||||||
|
this.#count = 1;
|
||||||
|
}
|
||||||
|
flush(): void {
|
||||||
|
if (this.#count === 0) return;
|
||||||
|
this.items.push({
|
||||||
|
kind: "band",
|
||||||
|
card: this.#card,
|
||||||
|
fromMin: this.#from,
|
||||||
|
toMin: this.#from + this.#count * this.inc,
|
||||||
|
increments: this.#count,
|
||||||
|
unitMinor: this.#unit,
|
||||||
|
amountMinor: this.#count * this.#unit,
|
||||||
|
});
|
||||||
|
this.#count = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Total fee for ELAPSED minutes under a STEPPED tariff, per the rolling-24h-day rule.
|
* Total fee for ELAPSED minutes under a STEPPED tariff, per the rolling-24h-day rule.
|
||||||
* Pure + integer. The smallest tier whose `uptoMin ≥` the day's minutes wins (≤ /
|
* Pure + integer. The smallest tier whose `uptoMin ≥` the day's minutes wins (≤ /
|
||||||
@@ -771,7 +1021,7 @@ export function hasSteps(s: { steps?: readonly TariffStep[] }): boolean {
|
|||||||
* FULL day (a daily-cap repeat) and price the remainder on the next day's ladder.
|
* FULL day (a daily-cap repeat) and price the remainder on the next day's ladder.
|
||||||
* `steps` need not be sorted; we sort defensively. See wiki/concepts/tariff.md.
|
* `steps` need not be sorted; we sort defensively. See wiki/concepts/tariff.md.
|
||||||
*/
|
*/
|
||||||
function steppedFee(minutes: number, steps: readonly TariffStep[]): number {
|
function steppedFee(minutes: number, steps: readonly TariffStep[], trace?: FeeBreakdownItem[]): number {
|
||||||
if (minutes <= 0 || steps.length === 0) return 0;
|
if (minutes <= 0 || steps.length === 0) return 0;
|
||||||
const sorted = [...steps].sort((a, b) => a.uptoMin - b.uptoMin);
|
const sorted = [...steps].sort((a, b) => a.uptoMin - b.uptoMin);
|
||||||
const top = sorted[sorted.length - 1]!;
|
const top = sorted[sorted.length - 1]!;
|
||||||
@@ -780,8 +1030,17 @@ function steppedFee(minutes: number, steps: readonly TariffStep[]): number {
|
|||||||
for (let dayStart = 0; dayStart < minutes; dayStart += DAY) {
|
for (let dayStart = 0; dayStart < minutes; dayStart += DAY) {
|
||||||
const dayMin = Math.min(DAY, minutes - dayStart); // minutes within this rolling day
|
const dayMin = Math.min(DAY, minutes - dayStart); // minutes within this rolling day
|
||||||
// Beyond the largest tier → the whole day is the top total (per-day cap repeat).
|
// Beyond the largest tier → the whole day is the top total (per-day cap repeat).
|
||||||
const tier = sorted.find((s) => dayMin <= s.uptoMin) ?? top;
|
const found = sorted.find((s) => dayMin <= s.uptoMin);
|
||||||
|
const tier = found ?? top;
|
||||||
total += tier.totalMinor;
|
total += tier.totalMinor;
|
||||||
|
trace?.push({
|
||||||
|
kind: "step",
|
||||||
|
day: dayStart / DAY + 1,
|
||||||
|
dayMinutes: dayMin,
|
||||||
|
uptoMin: tier.uptoMin,
|
||||||
|
amountMinor: tier.totalMinor,
|
||||||
|
repeated: found == null,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return total;
|
return total;
|
||||||
}
|
}
|
||||||
@@ -791,30 +1050,50 @@ function steppedFee(minutes: number, steps: readonly TariffStep[]): number {
|
|||||||
* identically. Do not "unify" this into the V2 path: a rounding divergence would
|
* identically. Do not "unify" this into the V2 path: a rounding divergence would
|
||||||
* corrupt repricing of already-signed sessions. A `steps` table (when present)
|
* corrupt repricing of already-signed sessions. A `steps` table (when present)
|
||||||
* REPLACES the ladder via {@link steppedFee}. */
|
* REPLACES the ladder via {@link steppedFee}. */
|
||||||
function computeFeeV1(enteredAt: string, asOf: string, tariff: TariffStructureV1): number {
|
function computeFeeV1(
|
||||||
|
enteredAt: string,
|
||||||
|
asOf: string,
|
||||||
|
tariff: TariffStructureV1,
|
||||||
|
trace?: FeeBreakdownItem[],
|
||||||
|
): number {
|
||||||
const ms = Date.parse(asOf) - Date.parse(enteredAt);
|
const ms = Date.parse(asOf) - Date.parse(enteredAt);
|
||||||
if (!Number.isFinite(ms) || ms <= 0) return 0;
|
if (!Number.isFinite(ms) || ms <= 0) return 0;
|
||||||
const rawMinutes = ms / 60_000;
|
const rawMinutes = ms / 60_000;
|
||||||
// Grace uses the RAW duration (a 10-min stay is free even if the increment is
|
// Grace uses the RAW duration (a 10-min stay is free even if the increment is
|
||||||
// 60 min — otherwise rounding-up would defeat the grace window).
|
// 60 min — otherwise rounding-up would defeat the grace window).
|
||||||
if (rawMinutes <= tariff.gracePeriodEntryMin) return 0;
|
if (rawMinutes <= tariff.gracePeriodEntryMin) {
|
||||||
|
trace?.push({ kind: "grace", minutes: Math.round(rawMinutes) });
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
const inc = Math.max(1, tariff.incrementMin);
|
const inc = Math.max(1, tariff.incrementMin);
|
||||||
const minutes = Math.ceil(rawMinutes / inc) * inc; // round UP to the increment
|
const minutes = Math.ceil(rawMinutes / inc) * inc; // round UP to the increment
|
||||||
|
|
||||||
// STEPPED pricing: a total-by-duration table replaces the marginal ladder.
|
// STEPPED pricing: a total-by-duration table replaces the marginal ladder.
|
||||||
if (hasSteps(tariff)) return steppedFee(minutes, tariff.steps!);
|
if (hasSteps(tariff)) return steppedFee(minutes, tariff.steps!, trace);
|
||||||
|
|
||||||
const DAY = 24 * 60;
|
const DAY = 24 * 60;
|
||||||
let total = 0;
|
let total = 0;
|
||||||
for (let segStart = 0; segStart < minutes; segStart += DAY) {
|
for (let segStart = 0; segStart < minutes; segStart += DAY) {
|
||||||
const segEnd = Math.min(segStart + DAY, minutes);
|
const segEnd = Math.min(segStart + DAY, minutes);
|
||||||
let segFee = 0;
|
let segFee = 0;
|
||||||
|
const bands = trace ? new BandTracer(trace, inc) : null;
|
||||||
// The block ladder RESETS each rolling-24h day: `within` is minutes elapsed
|
// The block ladder RESETS each rolling-24h day: `within` is minutes elapsed
|
||||||
// WITHIN this day, so day 2 starts at the first block again (decision 2026-06-15).
|
// WITHIN this day, so day 2 starts at the first block again (decision 2026-06-15).
|
||||||
for (let within = 0; segStart + within < segEnd; within += inc) {
|
for (let within = 0; segStart + within < segEnd; within += inc) {
|
||||||
segFee += rateAt(tariff.blocks, within);
|
const unit = rateAt(tariff.blocks, within);
|
||||||
|
segFee += unit;
|
||||||
|
bands?.add(null, unit, segStart + within);
|
||||||
|
}
|
||||||
|
bands?.flush();
|
||||||
|
if (tariff.dailyCapMinor != null && segFee > tariff.dailyCapMinor) {
|
||||||
|
trace?.push({
|
||||||
|
kind: "cap",
|
||||||
|
day: segStart / DAY + 1,
|
||||||
|
capMinor: tariff.dailyCapMinor,
|
||||||
|
amountMinor: tariff.dailyCapMinor - segFee,
|
||||||
|
});
|
||||||
|
segFee = tariff.dailyCapMinor;
|
||||||
}
|
}
|
||||||
if (tariff.dailyCapMinor != null) segFee = Math.min(segFee, tariff.dailyCapMinor);
|
|
||||||
total += segFee;
|
total += segFee;
|
||||||
}
|
}
|
||||||
return total;
|
return total;
|
||||||
@@ -837,12 +1116,16 @@ function computeFeeV2(
|
|||||||
asOf: string,
|
asOf: string,
|
||||||
tariff: TariffStructureV2,
|
tariff: TariffStructureV2,
|
||||||
category?: string,
|
category?: string,
|
||||||
|
trace?: FeeBreakdownItem[],
|
||||||
): number {
|
): number {
|
||||||
const enteredMs = Date.parse(enteredAt);
|
const enteredMs = Date.parse(enteredAt);
|
||||||
const ms = Date.parse(asOf) - enteredMs;
|
const ms = Date.parse(asOf) - enteredMs;
|
||||||
if (!Number.isFinite(ms) || ms <= 0) return 0;
|
if (!Number.isFinite(ms) || ms <= 0) return 0;
|
||||||
const rawMinutes = ms / 60_000;
|
const rawMinutes = ms / 60_000;
|
||||||
if (rawMinutes <= tariff.gracePeriodEntryMin) return 0; // grace on RAW duration (V1 rule)
|
if (rawMinutes <= tariff.gracePeriodEntryMin) {
|
||||||
|
trace?.push({ kind: "grace", minutes: Math.round(rawMinutes) });
|
||||||
|
return 0; // grace on RAW duration (V1 rule)
|
||||||
|
}
|
||||||
const inc = Math.max(1, tariff.incrementMin);
|
const inc = Math.max(1, tariff.incrementMin);
|
||||||
const minutes = Math.ceil(rawMinutes / inc) * inc; // round UP (V1 rule)
|
const minutes = Math.ceil(rawMinutes / inc) * inc; // round UP (V1 rule)
|
||||||
|
|
||||||
@@ -862,7 +1145,11 @@ function computeFeeV2(
|
|||||||
// defaultCard is stepped we price the WHOLE stay by the stepped day rule and ignore
|
// defaultCard is stepped we price the WHOLE stay by the stepped day rule and ignore
|
||||||
// windowed cards (they have nothing to override at the increment level). This is the
|
// windowed cards (they have nothing to override at the increment level). This is the
|
||||||
// only sound place for steps in V2. See wiki/concepts/tariff.md.
|
// only sound place for steps in V2. See wiki/concepts/tariff.md.
|
||||||
if (hasSteps(tariff.defaultCard)) return steppedFee(minutes, tariff.defaultCard.steps!);
|
if (hasSteps(tariff.defaultCard)) return steppedFee(minutes, tariff.defaultCard.steps!, trace);
|
||||||
|
|
||||||
|
// Trace labels: the defaultCard reads as the base rate (null), a windowed card by
|
||||||
|
// its name.
|
||||||
|
const traceName = (card: TariffCard): string | null => (card === tariff.defaultCard ? null : card.name);
|
||||||
|
|
||||||
let total = 0;
|
let total = 0;
|
||||||
// WINDOW-PACKAGE tracking (2026-07-05): a `packageMinor` card charges ONE total per
|
// WINDOW-PACKAGE tracking (2026-07-05): a `packageMinor` card charges ONE total per
|
||||||
@@ -876,21 +1163,33 @@ function computeFeeV2(
|
|||||||
for (let segStart = 0; segStart < minutes; segStart += DAY) {
|
for (let segStart = 0; segStart < minutes; segStart += DAY) {
|
||||||
const segEnd = Math.min(segStart + DAY, minutes);
|
const segEnd = Math.min(segStart + DAY, minutes);
|
||||||
let segFee = 0;
|
let segFee = 0;
|
||||||
|
const bands = trace ? new BandTracer(trace, inc) : null;
|
||||||
for (let within = segStart; within < segEnd; within += inc) {
|
for (let within = segStart; within < segEnd; within += inc) {
|
||||||
const wall = localBreakdown(enteredMs + within * 60_000, tariff.tz);
|
const wall = localBreakdown(enteredMs + within * 60_000, tariff.tz);
|
||||||
const card = selectCard(cards, wall);
|
const card = selectCard(cards, wall);
|
||||||
if (card.packageMinor != null) {
|
if (card.packageMinor != null) {
|
||||||
// First increment of a new occurrence pays the package; the rest ride free.
|
// First increment of a new occurrence pays the package; the rest ride free.
|
||||||
if (prevWinner !== card) segFee += card.packageMinor;
|
if (prevWinner !== card) {
|
||||||
|
segFee += card.packageMinor;
|
||||||
|
bands?.flush();
|
||||||
|
trace?.push({ kind: "package", card: card.name, fromMin: within, amountMinor: card.packageMinor });
|
||||||
|
}
|
||||||
} else if (card.flatMinor != null) {
|
} else if (card.flatMinor != null) {
|
||||||
segFee += card.flatMinor;
|
segFee += card.flatMinor;
|
||||||
|
bands?.add(traceName(card), card.flatMinor, within);
|
||||||
} else {
|
} else {
|
||||||
// Ladder position = minutes into THIS rolling-24h day (resets each day, V1 rule).
|
// Ladder position = minutes into THIS rolling-24h day (resets each day, V1 rule).
|
||||||
segFee += rateAt(card.blocks ?? [], within - segStart);
|
const unit = rateAt(card.blocks ?? [], within - segStart);
|
||||||
|
segFee += unit;
|
||||||
|
bands?.add(traceName(card), unit, within);
|
||||||
}
|
}
|
||||||
prevWinner = card;
|
prevWinner = card;
|
||||||
}
|
}
|
||||||
if (dayCap != null) segFee = Math.min(segFee, dayCap);
|
bands?.flush();
|
||||||
|
if (dayCap != null && segFee > dayCap) {
|
||||||
|
trace?.push({ kind: "cap", day: segStart / DAY + 1, capMinor: dayCap, amountMinor: dayCap - segFee });
|
||||||
|
segFee = dayCap;
|
||||||
|
}
|
||||||
total += segFee;
|
total += segFee;
|
||||||
}
|
}
|
||||||
return total;
|
return total;
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import {
|
import {
|
||||||
computeFee,
|
computeFee,
|
||||||
|
explainFee,
|
||||||
priceSession,
|
priceSession,
|
||||||
validateTariffStructure,
|
validateTariffStructure,
|
||||||
|
type FeeBreakdownItem,
|
||||||
|
type TariffStructure,
|
||||||
type TariffStructureV1,
|
type TariffStructureV1,
|
||||||
type TariffStructureV2,
|
type TariffStructureV2,
|
||||||
type TariffCard,
|
type TariffCard,
|
||||||
@@ -437,3 +440,196 @@ describe("V2 window package (whole-window total)", () => {
|
|||||||
expect(validateTariffStructure(capped).some((e) => /dailyCapMinor does not apply to a window package/.test(e))).toBe(true);
|
expect(validateTariffStructure(capped).some((e) => /dailyCapMinor does not apply to a window package/.test(e))).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("explainFee — the breakdown IS the fee (2026-07-06)", () => {
|
||||||
|
const v1: TariffStructure = {
|
||||||
|
gracePeriodEntryMin: 5,
|
||||||
|
incrementMin: 60,
|
||||||
|
lostTicketMinor: 2000,
|
||||||
|
gracePeriodExitMin: 10,
|
||||||
|
overstay: "reprice",
|
||||||
|
blocks: [
|
||||||
|
{ uptoMin: 120, priceMinorPerIncrement: 200 },
|
||||||
|
{ uptoMin: null, priceMinorPerIncrement: 100 },
|
||||||
|
],
|
||||||
|
dailyCapMinor: 500,
|
||||||
|
};
|
||||||
|
|
||||||
|
const sum = (b: ReturnType<typeof explainFee>) => b.items.reduce((a, i) => a + ("amountMinor" in i ? i.amountMinor : 0), 0);
|
||||||
|
|
||||||
|
it("V1 ladder: bands merge per rate, the cap shows as a negative line, sum == fee", () => {
|
||||||
|
const from = "2026-07-06T08:00:00.000Z";
|
||||||
|
const to = "2026-07-06T15:02:00.000Z"; // 7h2m → 8 increments: 2×200 + 6×100 = 1000 → cap 500
|
||||||
|
const b = explainFee(from, to, v1);
|
||||||
|
expect(b.totalMinor).toBe(computeFee(from, to, v1));
|
||||||
|
expect(b.totalMinor).toBe(500);
|
||||||
|
expect(sum(b)).toBe(b.totalMinor);
|
||||||
|
expect(b.items.map((i) => i.kind)).toEqual(["band", "band", "cap"]);
|
||||||
|
const [first, second, cap] = b.items as [
|
||||||
|
Extract<FeeBreakdownItem, { kind: "band" }>,
|
||||||
|
Extract<FeeBreakdownItem, { kind: "band" }>,
|
||||||
|
Extract<FeeBreakdownItem, { kind: "cap" }>,
|
||||||
|
];
|
||||||
|
expect([first.increments, first.unitMinor, first.amountMinor]).toEqual([2, 200, 400]);
|
||||||
|
expect([second.increments, second.unitMinor, second.amountMinor]).toEqual([6, 100, 600]);
|
||||||
|
expect(cap.amountMinor).toBe(-500);
|
||||||
|
expect(b.billedMinutes).toBe(480);
|
||||||
|
expect(b.rawMinutes).toBe(422);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("grace: one zero line, billed 0", () => {
|
||||||
|
const b = explainFee("2026-07-06T08:00:00.000Z", "2026-07-06T08:04:00.000Z", v1);
|
||||||
|
expect(b.items).toEqual([{ kind: "grace", minutes: 4 }]);
|
||||||
|
expect(b.totalMinor).toBe(0);
|
||||||
|
expect(b.billedMinutes).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stepped: one line per rolling day, top tier repeats flagged", () => {
|
||||||
|
const stepped: TariffStructure = {
|
||||||
|
...v1,
|
||||||
|
blocks: [],
|
||||||
|
dailyCapMinor: null,
|
||||||
|
steps: [
|
||||||
|
{ uptoMin: 180, totalMinor: 500 },
|
||||||
|
{ uptoMin: 1440, totalMinor: 1000 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const from = "2026-07-04T08:00:00.000Z";
|
||||||
|
const to = "2026-07-05T10:00:00.000Z"; // 26h → day1 top(1000) + day2 ≤180 (500)
|
||||||
|
const b = explainFee(from, to, stepped);
|
||||||
|
expect(b.totalMinor).toBe(computeFee(from, to, stepped));
|
||||||
|
expect(sum(b)).toBe(b.totalMinor);
|
||||||
|
expect(b.items).toEqual([
|
||||||
|
{ kind: "step", day: 1, dayMinutes: 1440, uptoMin: 1440, amountMinor: 1000, repeated: false },
|
||||||
|
{ kind: "step", day: 2, dayMinutes: 120, uptoMin: 180, amountMinor: 500, repeated: false },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("V2 night package + base ladder: package is one line, bands name the card, sum == fee", () => {
|
||||||
|
const v2: TariffStructure = {
|
||||||
|
version: 2,
|
||||||
|
tz: "Europe/Tirane",
|
||||||
|
gracePeriodEntryMin: 5,
|
||||||
|
incrementMin: 60,
|
||||||
|
lostTicketMinor: 2000,
|
||||||
|
gracePeriodExitMin: 10,
|
||||||
|
overstay: "reprice",
|
||||||
|
defaultCard: { name: "default", priority: 0, blocks: [{ uptoMin: null, priceMinorPerIncrement: 10000 }], dailyCapMinor: null },
|
||||||
|
windowedCards: [
|
||||||
|
{ name: "night", priority: 10, window: { fromHour: "20:00", toHour: "07:00" }, packageMinor: 40000 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
// 18:00 → 22:30 local (16:00Z→20:30Z in July, UTC+2): 2 base hours + the night package.
|
||||||
|
const from = "2026-07-06T16:00:00.000Z";
|
||||||
|
const to = "2026-07-06T20:30:00.000Z";
|
||||||
|
const b = explainFee(from, to, v2);
|
||||||
|
expect(b.totalMinor).toBe(computeFee(from, to, v2));
|
||||||
|
expect(sum(b)).toBe(b.totalMinor);
|
||||||
|
expect(b.totalMinor).toBe(2 * 10000 + 40000);
|
||||||
|
expect(b.items).toEqual([
|
||||||
|
{ kind: "band", card: null, fromMin: 0, toMin: 120, increments: 2, unitMinor: 10000, amountMinor: 20000 },
|
||||||
|
{ kind: "package", card: "night", fromMin: 120, amountMinor: 40000 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// (i) Merchant validations — the priceSession discount fold (2026-07-13).
|
||||||
|
// liveV1: 5-min entry grace, 60-min increment, blocks 20000(1h)/10000(to 3h),
|
||||||
|
// daily cap 100000, exit grace 5 min. See wiki/concepts/validation-discounts.md.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe("priceSession merchant validations", () => {
|
||||||
|
const val = (
|
||||||
|
mode: "comp" | "timeCredit" | "fixed" | "percent",
|
||||||
|
over: Partial<import("./index.js").SessionValidation> = {},
|
||||||
|
): import("./index.js").SessionValidation => ({
|
||||||
|
programId: "bar",
|
||||||
|
label: "Bar",
|
||||||
|
mode,
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
it("no validations → gross == net, no lines (back-compat)", () => {
|
||||||
|
const r = priceSession(entered, at(120), liveV1, []);
|
||||||
|
expect(r.grossMinor).toBe(30000);
|
||||||
|
expect(r.amountMinor).toBe(30000);
|
||||||
|
expect(r.discountMinor).toBe(0);
|
||||||
|
expect(r.validationLines).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("comp zeroes the fee and the line carries the whole gross", () => {
|
||||||
|
const r = priceSession(entered, at(120), liveV1, [], undefined, [val("comp")]);
|
||||||
|
expect(r.grossMinor).toBe(30000);
|
||||||
|
expect(r.amountMinor).toBe(0);
|
||||||
|
expect(r.discountMinor).toBe(30000);
|
||||||
|
expect(r.validationLines).toEqual([{ programId: "bar", label: "Bar", mode: "comp", discountMinor: 30000 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fixed subtracts, floors at 0, and clamps the line to the remainder", () => {
|
||||||
|
// 2h → 30000 gross; 300-off style: fixed 20000 → net 10000.
|
||||||
|
const r = priceSession(entered, at(120), liveV1, [], undefined, [val("fixed", { amountMinor: 20000 })]);
|
||||||
|
expect(r.amountMinor).toBe(10000);
|
||||||
|
expect(r.discountMinor).toBe(20000);
|
||||||
|
// Bigger than the fee → net 0, line clamped to the gross (Σ lines ≡ gross − net).
|
||||||
|
const r2 = priceSession(entered, at(120), liveV1, [], undefined, [val("fixed", { amountMinor: 99999 })]);
|
||||||
|
expect(r2.amountMinor).toBe(0);
|
||||||
|
expect(r2.validationLines[0]!.discountMinor).toBe(30000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("timeCredit prices as if entered later — 'first hour free' is literal", () => {
|
||||||
|
// 2h stay, 60 free minutes → bill the remaining 1h at the FIRST block (20000),
|
||||||
|
// exactly what a 1h stay costs.
|
||||||
|
const r = priceSession(entered, at(120), liveV1, [], undefined, [val("timeCredit", { minutes: 60 })]);
|
||||||
|
expect(r.grossMinor).toBe(30000);
|
||||||
|
expect(r.amountMinor).toBe(computeFee(at(60), at(120), liveV1));
|
||||||
|
expect(r.amountMinor).toBe(20000);
|
||||||
|
expect(r.validationLines[0]!.discountMinor).toBe(10000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("timeCredit covering the whole stay → net 0", () => {
|
||||||
|
const r = priceSession(entered, at(50), liveV1, [], undefined, [val("timeCredit", { minutes: 120 })]);
|
||||||
|
expect(r.amountMinor).toBe(0);
|
||||||
|
expect(r.discountMinor).toBe(r.grossMinor);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("percent takes a floor'd share of the remaining fee", () => {
|
||||||
|
const r = priceSession(entered, at(120), liveV1, [], undefined, [val("percent", { percent: 50 })]);
|
||||||
|
expect(r.amountMinor).toBe(15000);
|
||||||
|
expect(r.discountMinor).toBe(15000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stacking is canonical-order (timeCredit → percent → fixed → comp) and Σ lines ≡ gross − net", () => {
|
||||||
|
// Scan order deliberately reversed; the fold must still do time first.
|
||||||
|
const r = priceSession(entered, at(120), liveV1, [], undefined, [
|
||||||
|
val("fixed", { amountMinor: 5000, programId: "bar" }),
|
||||||
|
val("timeCredit", { minutes: 60, programId: "lavazh", label: "Lavazh" }),
|
||||||
|
]);
|
||||||
|
// gross 30000 → time credit leaves 20000 → fixed 5000 → net 15000.
|
||||||
|
expect(r.grossMinor).toBe(30000);
|
||||||
|
expect(r.amountMinor).toBe(15000);
|
||||||
|
const sum = r.validationLines.reduce((a, l) => a + l.discountMinor, 0);
|
||||||
|
expect(sum).toBe(r.discountMinor);
|
||||||
|
expect(r.validationLines.map((l) => l.mode)).toEqual(["timeCredit", "fixed"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a settled (paid + within grace) session ignores validations", () => {
|
||||||
|
const r = priceSession(entered, at(123), liveV1, [{ paidAt: at(120), graceExitMin: 5 }], undefined, [
|
||||||
|
val("comp"),
|
||||||
|
]);
|
||||||
|
expect(r.withinGrace).toBe(true);
|
||||||
|
expect(r.amountMinor).toBe(0);
|
||||||
|
expect(r.validationLines).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an overstay period applies (unconsumed) validations to the FRESH period", () => {
|
||||||
|
// Paid at 120, grace 5 → overstay period starts at 125. A 60-min credit eats the
|
||||||
|
// overstay's first hour: net = fee(185→245 from period start) = the 1h price… i.e.
|
||||||
|
// fee of (245−125−60)=60 min from the ladder start.
|
||||||
|
const r = priceSession(entered, at(245), liveV1, [{ paidAt: at(120), graceExitMin: 5 }], undefined, [
|
||||||
|
val("timeCredit", { minutes: 60 }),
|
||||||
|
]);
|
||||||
|
expect(r.overstay).toBe(true);
|
||||||
|
expect(r.grossMinor).toBe(computeFee(at(125), at(245), liveV1));
|
||||||
|
expect(r.amountMinor).toBe(computeFee(at(185), at(245), liveV1));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
type: concept
|
type: concept
|
||||||
tags: [parking, observability, diagnostics, logging, frontend, backend]
|
tags: [parking, observability, diagnostics, logging, frontend, backend]
|
||||||
sources: []
|
sources: []
|
||||||
updated: 2026-07-04
|
updated: 2026-07-08
|
||||||
status: open
|
status: open
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -62,6 +62,16 @@ column — the failed request, error name, component stack, anything), plus pull
|
|||||||
logged — that's the recursion we guard). Diagnostics must never break the path they observe.
|
logged — that's the recursion we guard). Diagnostics must never break the path they observe.
|
||||||
- **Bounded.** Frontend queue capped (drops oldest); message/stack/context clamped per row;
|
- **Bounded.** Frontend queue capped (drops oldest); message/stack/context clamped per row;
|
||||||
ingest batch capped.
|
ingest batch capped.
|
||||||
|
- **Storm coalescing (2026-07-08).** A line identical to the *last persisted row*
|
||||||
|
(level+source+message+path) arriving within **5 min** of its previous occurrence **updates that
|
||||||
|
row** instead of inserting: `context._repeat` counts the fold, `context._firstAt` keeps the first
|
||||||
|
occurrence, `createdAt` moves to the latest (so the storm stays at the top of the newest-first
|
||||||
|
viewer, which badges it `×N`). A *continuous* storm refreshes the window each hit, so it stays ONE
|
||||||
|
row however long it rages. Motivation: the 2026-07-07 field incident — one unreachable controller
|
||||||
|
(`ENETUNREACH`) produced hundreds of identical error rows per minute, evicting unrelated history
|
||||||
|
(see [[button-light-indicator]] for the send-side fix: retry backoff + rate-limited logging).
|
||||||
|
In-memory last-row cache only (a restart just starts a fresh row); if the row was pruned
|
||||||
|
underneath, it falls through to a fresh insert.
|
||||||
|
|
||||||
## Retention (offline appliance ⇒ must be bounded)
|
## Retention (offline appliance ⇒ must be bounded)
|
||||||
|
|
||||||
@@ -71,6 +81,10 @@ was 30) **and** keep only the newest `LOG_RETENTION_MAX_ROWS` (default 50 000).
|
|||||||
(unref'd timer) + once at startup. Both env-configurable. Same "prunable, not precious"
|
(unref'd timer) + once at startup. Both env-configurable. Same "prunable, not precious"
|
||||||
durability class as `device_events` — the opposite of the append-only ledger.
|
durability class as `device_events` — the opposite of the append-only ledger.
|
||||||
|
|
||||||
|
Also wipeable on demand: `reset-db.mjs --diagnostics` (new category 2026-07-08 — `app_logs`
|
||||||
|
previously belonged to NO category and silently survived even `--all`; a drift guard in the script
|
||||||
|
now refuses to run if any table is uncategorized). See [[local-dev-workflow]].
|
||||||
|
|
||||||
## Container (stdout) logs — the OTHER log store (2026-07-04)
|
## Container (stdout) logs — the OTHER log store (2026-07-04)
|
||||||
|
|
||||||
`docker logs` is a separate, size-bounded store from `app_logs` — it holds **everything**
|
`docker logs` is a separate, size-bounded store from `app_logs` — it holds **everything**
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
type: concept
|
type: concept
|
||||||
tags: [parking, durability, backup, recovery, security, crypto]
|
tags: [parking, durability, backup, recovery, security, crypto]
|
||||||
sources: []
|
sources: []
|
||||||
updated: 2026-06-29
|
updated: 2026-08-30
|
||||||
---
|
---
|
||||||
|
|
||||||
# Backup & Disaster Recovery
|
# Backup & Disaster Recovery
|
||||||
@@ -207,12 +207,68 @@ timer + the manual route**. What landed:
|
|||||||
**SMB/NFS already work** — they're just a mounted path the admin enters as the target. **Deferred to
|
**SMB/NFS already work** — they're just a mounted path the admin enters as the target. **Deferred to
|
||||||
follow-up slices:** an **SFTP** target and a **restore runbook / CLI**.
|
follow-up slices:** an **SFTP** target and a **restore runbook / CLI**.
|
||||||
|
|
||||||
|
## Field bug — "last successful backup: Never" despite valid, rotating backups on disk (found + fixed 2026-08-30)
|
||||||
|
|
||||||
|
**Symptom (park-buzi):** the admin noticed the backup directory held 7 real, correctly-sized,
|
||||||
|
correctly-rotating encrypted backups (`parking-backup-*.sqlite.enc`, retention working exactly as
|
||||||
|
designed) — yet the Backup screen's "Kopja e fundit e suksesshme" (last successful backup) showed
|
||||||
|
**"Asnjëherë" (Never)**. Separately, the most recent file was 2 days old rather than ~1.
|
||||||
|
|
||||||
|
**Root cause — two independent, disconnected code paths, both traced to `setInterval`-since-
|
||||||
|
process-start:**
|
||||||
|
|
||||||
|
1. **Status was never persisted.** `BackupService` tracked `lastSuccessAt`/`lastResult`/
|
||||||
|
`lastErrorAt`/`lastError` as **plain in-process private fields** — set only inside `run()`,
|
||||||
|
read only by `status()` on the *same running instance*. Nothing wrote them to `site_config` or
|
||||||
|
anywhere else durable. The actual backup-writing engine (`backup.ts`: consistent copy → encrypt
|
||||||
|
→ `pruneOldBackups`) is a completely separate code path that only touches the filesystem and
|
||||||
|
has no notion of this status object. So "7 valid files on disk" and "status says Never" were
|
||||||
|
never contradictory — they were two unrelated signals, and **any** server restart (deploy,
|
||||||
|
crash, OOM, host reboot — all routine under `restart: always` in `docker-compose.prod.yml`)
|
||||||
|
silently reset the in-memory fields to `null` regardless of what had actually happened on disk.
|
||||||
|
2. **The schedule was measured from process start, not from the last real backup.** The daily
|
||||||
|
timer was `setInterval(() => backupService.runScheduled(), 24h)` — a fixed 24h period counted
|
||||||
|
from whenever the *process* last started, not from wall-clock time or from when a backup last
|
||||||
|
actually succeeded. The exact same restart that wiped the in-memory status also reset this
|
||||||
|
countdown, which is why the cadence can silently drift or skip past a day with no error ever
|
||||||
|
surfacing anywhere.
|
||||||
|
|
||||||
|
Both symptoms are one cause: **the server process restarted after the Aug 28 backup, and nothing
|
||||||
|
about this design was built to survive that.**
|
||||||
|
|
||||||
|
### Fix (2026-08-30)
|
||||||
|
|
||||||
|
- **`packages/db/src/schema.ts`** / migration `0025_backup_last_status.sql` — four new nullable
|
||||||
|
`site_config` columns: `backup_last_success_at`, `backup_last_result_json`,
|
||||||
|
`backup_last_error_at`, `backup_last_error`. Same table, same upsert pattern as
|
||||||
|
`backup_target_dir`/`backup_keep_last`/`backup_keep_daily_days` (migrations 0016/0017).
|
||||||
|
- **`backup-service.ts`** — `run()` now writes success/error outcomes to these columns (via a
|
||||||
|
`#persist` upsert helper) instead of private fields; `status()` reads them fresh from the DB on
|
||||||
|
every call. A brand-new `BackupService` instance (i.e. a fresh process) now sees exactly what
|
||||||
|
the previous instance last recorded — no more restart amnesia.
|
||||||
|
- **New `isDue(now, intervalMs = 24h)`** method: due iff `now - backupLastSuccessAt >= 24h` (or
|
||||||
|
immediately due if no success was ever recorded), computed from the **persisted** timestamp —
|
||||||
|
never from process uptime.
|
||||||
|
- **`server.ts`** — the daily `setInterval` was replaced with a **15-minute poll** calling
|
||||||
|
`runScheduled()`, which now itself no-ops unless `isDue()` is true. This makes the actual backup
|
||||||
|
cadence immune to restart timing entirely: however often the process happens to restart, the
|
||||||
|
next backup fires within 15 minutes of 24h having genuinely elapsed since the last real success
|
||||||
|
— not 24h after whatever moment the process most recently came back up.
|
||||||
|
- Covered by a new `backup-service.test.ts`: a fresh `BackupService` over the same DB handle
|
||||||
|
(simulating a restart) sees the prior instance's last success/error and its cleared-on-success
|
||||||
|
behavior; `isDue()` is exercised directly against injected timestamps rather than real sleeps.
|
||||||
|
|
||||||
|
No change to the `BackupStatus` shape returned by `GET /api/backup/status` or to
|
||||||
|
`BackupSettings.tsx` — this was purely a durability fix underneath the same contract.
|
||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
Design settled 2026-06-29; **engine + admin-configured local/mounted target + admin UI BUILT
|
Design settled 2026-06-29; **engine + admin-configured local/mounted target + admin UI BUILT
|
||||||
2026-06-29** (SFTP + restore tooling pending). The target directory is **admin-chosen in the UI**
|
2026-06-29** (SFTP + restore tooling pending). The target directory is **admin-chosen in the UI**
|
||||||
(`site_config`, migration 0016), not an env var — the on-site admin picks where backups land; only
|
(`site_config`, migration 0016), not an env var — the on-site admin picks where backups land; only
|
||||||
`BACKUP_KEY` stays a server secret. Resolves the *design* half of [[open-questions]] #5 and the first
|
`BACKUP_KEY` stays a server secret. **Last-success/last-error status + the scheduling cadence are
|
||||||
build slices; records the key-custody stance that bears on #6 (signing stays decoupled from the TPM) and
|
now restart-durable (migration 0025, 2026-08-30)** — see field bug above. Resolves the *design*
|
||||||
#10 (snapshots bloat backups → future exclude toggle). See [[append-only-event-chain]],
|
half of [[open-questions]] #5 and the first build slices; records the key-custody stance that bears
|
||||||
[[disk-os-hardening]], [[tpm]], [[fleet-deployment-komodo]], [[reconciliation]].
|
on #6 (signing stays decoupled from the TPM) and #10 (snapshots bloat backups → future exclude
|
||||||
|
toggle). See [[append-only-event-chain]], [[disk-os-hardening]], [[tpm]], [[fleet-deployment-komodo]],
|
||||||
|
[[reconciliation]].
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
type: concept
|
type: concept
|
||||||
tags: [parking, device, indicator, radar, camera, aux-output, barrier-not-a-door, event-relay]
|
tags: [parking, device, indicator, radar, camera, aux-output, barrier-not-a-door, event-relay]
|
||||||
sources: []
|
sources: []
|
||||||
updated: 2026-06-28
|
updated: 2026-07-08
|
||||||
status: settled
|
status: settled
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -72,6 +72,16 @@ aux-output** capability.
|
|||||||
re-converges to the latest desired state. So the **final state is always authoritative** and a
|
re-converges to the latest desired state. So the **final state is always authoritative** and a
|
||||||
lost/stale packet self-corrects. This also de-dupes (it skips a send when `confirmedOn === desiredOn`),
|
lost/stale packet self-corrects. This also de-dupes (it skips a send when `confirmedOn === desiredOn`),
|
||||||
so the input stream never spams the controller.
|
so the input stream never spams the controller.
|
||||||
|
- **Failure backoff + rate-limited logging (2026-07-08).** The first serialized-worker cut re-pumped
|
||||||
|
*immediately* after a FAILED send (`confirmedOn` unchanged → converge again) — correct for a lost
|
||||||
|
packet, but an **unreachable controller** (`ENETUNREACH`, rejects instantly) turned it into a hot
|
||||||
|
loop: hundreds of identical error lines per minute into stdout AND [[app-logs]] (field incident
|
||||||
|
2026-07-07, park-buzi). Now a failed send arms a **retry backoff — 1 s doubling to a 30 s cap,
|
||||||
|
reset on success**; during the window `desiredOn` keeps tracking the truth table and the armed
|
||||||
|
retry converges to whatever it says when it fires (`#finalOff` waives the backoff for the one-shot
|
||||||
|
last-gasp OFF). Logging: only the **first** failure of a streak is logged, then **one summary per
|
||||||
|
minute** (`still failing (attempt N…)`), and a single `info` on recovery. The app_logs sink
|
||||||
|
additionally coalesces identical rows (see [[app-logs]]) as defense in depth.
|
||||||
- **Hot-reloads the config (no restart).** The lamp map is reconciled against the live device config
|
- **Hot-reloads the config (no restart).** The lamp map is reconciled against the live device config
|
||||||
at start AND before each event (mirroring [[device-status-monitoring|DeviceMonitor]], which re-reads
|
at start AND before each event (mirroring [[device-status-monitoring|DeviceMonitor]], which re-reads
|
||||||
the device set each tick) — adding/updating/dropping lamps. So a button light added or re-pointed in
|
the device set each tick) — adding/updating/dropping lamps. So a button light added or re-pointed in
|
||||||
@@ -102,7 +112,8 @@ Built 2026-06-24 for the first booth (button I1, radar I2, lamp on a spare relay
|
|||||||
a `radarAlert` event-relay (carrying its own `triggerInput`), so the operator can add arbitrary
|
a `radarAlert` event-relay (carrying its own `triggerInput`), so the operator can add arbitrary
|
||||||
event-driven blinkers (e.g. R4) without code changes; the 3-state machine itself is unchanged.
|
event-driven blinkers (e.g. R4) without code changes; the 3-state machine itself is unchanged.
|
||||||
Covered by `apps/server/src/button-light.test.ts` (the truth table, blink toggling asserted on the
|
Covered by `apps/server/src/button-light.test.ts` (the truth table, blink toggling asserted on the
|
||||||
device's *confirmed* state, fail-OFF, de-dupe, lamp-added-after-start reconcile, and two independent
|
device's *confirmed* state, fail-OFF, de-dupe, lamp-added-after-start reconcile, two independent
|
||||||
alert relays on one controller).
|
alert relays on one controller, and — since 2026-07-08 — backoff cadence on an unreachable
|
||||||
|
controller, log rate-limiting, and single-recovery-line + backoff-reset after success).
|
||||||
Related: [[hikvision-radar]], [[entry-double-press]], [[lpr-camera]], [[dingtian-relay]],
|
Related: [[hikvision-radar]], [[entry-double-press]], [[lpr-camera]], [[dingtian-relay]],
|
||||||
[[entry-exit-points]], [[barrier-not-a-door]].
|
[[entry-exit-points]], [[barrier-not-a-door]].
|
||||||
|
|||||||
@@ -71,6 +71,18 @@ Two patterns added on top of the catalog approach:
|
|||||||
> this box must avoid relying on `Intl` for Albanian or it leaks English. (Printed slips already
|
> this box must avoid relying on `Intl` for Albanian or it leaks English. (Printed slips already
|
||||||
> solved this with the `SQ_MONTHS` table in [[rongta-printer]].)
|
> solved this with the `SQ_MONTHS` table in [[rongta-printer]].)
|
||||||
|
|
||||||
|
## UI-wide date standard — "25 Qer 14:30" (2026-07-06)
|
||||||
|
|
||||||
|
Dates were a mix of catalog-formatted "25 Qershor 20:01" and browser-locale "7/6/2026, 9:34 AM"
|
||||||
|
(raw `toLocaleString` in ~20 call sites) — operator called it out. The standard now: **short
|
||||||
|
month from the catalog** (`common.monthsShort`: Jan/Shk/…/Qer/Korr/…/Dhj), **24h clock**, year
|
||||||
|
only when ≠ current. Helpers in `lib/format.ts`: `formatDate` ("25 Qer"), `formatDateTime`
|
||||||
|
("25 Qer 14:30", optional seconds — the event-detail modal keeps them), `formatClock` ("HH:mm");
|
||||||
|
`formatRelativeDateTime` keeps Sot/Dje and uses the same short months beyond that. Rule for new
|
||||||
|
code: **never call `toLocale*String` for a DATE** — catalog months exist because the appliance
|
||||||
|
browser's ICU may lack Albanian data; number formatting (thousand separators on money) still uses
|
||||||
|
the locale. Swept everywhere 2026-07-06.
|
||||||
|
|
||||||
## Open / deferred
|
## Open / deferred
|
||||||
|
|
||||||
- **SetupWizard chrome is now translated (2026-06-19)**; the remaining gap is the **backend
|
- **SetupWizard chrome is now translated (2026-06-19)**; the remaining gap is the **backend
|
||||||
|
|||||||
@@ -71,10 +71,16 @@ RESET_ALLOWED=1 DATABASE_URL=/path node packages/db/scripts/reset-db.mjs --all
|
|||||||
| Flag | Wipes | Keeps |
|
| Flag | Wipes | Keeps |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `--financial` | `ledger_events` (entry/exit/payment/void/shift/cash/anomaly), `device_events`, `snapshots`, subscription **instances** + credentials/plates, `blocklist` | users, devices, config, tariffs, subscription **plans** |
|
| `--financial` | `ledger_events` (entry/exit/payment/void/shift/cash/anomaly), `device_events`, `snapshots`, subscription **instances** + credentials/plates, `blocklist` | users, devices, config, tariffs, subscription **plans** |
|
||||||
| `--config` | `site_config`, `devices`, `setup_state` (→ re-runs first-run setup), tariffs + versions, subscription plans | everything else |
|
| `--config` | `site_config`, `devices`, `setup_state` (→ re-runs first-run setup), tariffs + versions + **drafts**, subscription plans | everything else |
|
||||||
| `--users` | `users`, `roles`, `role_permissions`, auth `sessions` | everything else |
|
| `--users` | `users`, `roles`, `role_permissions`, auth `sessions` | everything else |
|
||||||
|
| `--diagnostics` | `app_logs` (the unsigned [[app-logs]] store behind `/setup/logs`) | everything else |
|
||||||
| `--all` | every table (blank slate) | — |
|
| `--all` | every table (blank slate) | — |
|
||||||
|
|
||||||
|
**Drift guard** (2026-07-08): before doing anything, the script compares the category union against
|
||||||
|
`sqlite_master` and **refuses if any table is uncategorized** — `app_logs` and `tariff_drafts` had
|
||||||
|
silently survived every reset (including `--all`) because the hand-maintained table list lagged the
|
||||||
|
schema. A new table now forces a deliberate one-line categorization decision.
|
||||||
|
|
||||||
> **⚠ `--financial`/`--all` TRUNCATE the append-only, signed [[append-only-event-chain|ledger]].**
|
> **⚠ `--financial`/`--all` TRUNCATE the append-only, signed [[append-only-event-chain|ledger]].**
|
||||||
> That is the anti-fraud record; a *partial* delete would break the hash chain, so a financial reset
|
> That is the anti-fraud record; a *partial* delete would break the hash chain, so a financial reset
|
||||||
> wipes the whole ledger back to empty (re-seeding starts a NEW chain under the **same**
|
> wipes the whole ledger back to empty (re-seeding starts a NEW chain under the **same**
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
type: concept
|
type: concept
|
||||||
tags: [parking, device, printer, transport, usb, escpos, provisioning]
|
tags: [parking, device, printer, transport, usb, escpos, provisioning]
|
||||||
sources: []
|
sources: []
|
||||||
updated: 2026-06-24
|
updated: 2026-08-30
|
||||||
status: settled
|
status: settled
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -87,5 +87,123 @@ The setup UI offers a **Connection** select (Network / USB) + a **USB device** p
|
|||||||
to the node and reports ready/offline). The on-hardware confirmation + the udev/usblp provisioning
|
to the node and reports ready/offline). The on-hardware confirmation + the udev/usblp provisioning
|
||||||
are pending (open-questions #14).
|
are pending (open-questions #14).
|
||||||
|
|
||||||
|
## Field bug — the NONBLOCK partial-write truncation (found + fixed 2026-07-06)
|
||||||
|
|
||||||
|
First on-hardware USB test (ICS XP-K200L, an ESC/POS clone): over TCP it printed + cut fine; over
|
||||||
|
USB it printed the ticket's TEXT but **no barcode and no cut**. Root cause was in OUR transport,
|
||||||
|
not the printer: `sendRawUsb` opened the node with `O_NONBLOCK` and issued ONE `write()` for the
|
||||||
|
whole job. On a non-blocking usblp fd the kernel accepts only what fits the printer's USB buffer
|
||||||
|
(~8 KB) and returns a **short write**; the old code never checked `bytesWritten`, closed the
|
||||||
|
handle, and silently dropped the tail — which is exactly where the barcode (mid-payload) and the
|
||||||
|
CUT (last bytes) live. Small jobs fit one buffer, hence "text prints fine". The regular-file test
|
||||||
|
stand-in can't short-write, so tests never caught it.
|
||||||
|
|
||||||
|
Fix: `writeAllUsb` — chunked loop (4 KB, safely under the usblp buffer) that continues after
|
||||||
|
partial writes, retries `EAGAIN`/zero-byte writes with a short pause, and fails at the caller's
|
||||||
|
deadline with a `(N/M bytes accepted)` diagnostic. Driven by fake-handle tests (short writes,
|
||||||
|
EAGAIN interleave, wedged-printer timeout, non-EAGAIN passthrough) since a real file can't
|
||||||
|
reproduce the char device's behaviour.
|
||||||
|
|
||||||
|
**Second truncation mode — close() cancels the in-flight transfer (lab bench, 2026-07-07).** The
|
||||||
|
chunked loop alone STILL truncated on hardware (test slip stopped mid-sentence, no feed, no cut —
|
||||||
|
"press the feed button to see the text"). Verified against `drivers/usb/class/usblp.c`: `write()`
|
||||||
|
returns at URB *submission* (not completion), only ONE write URB is in flight at a time (the next
|
||||||
|
write EAGAINs until it completes), and `usblp_release()` — i.e. our `close()` — **kills in-flight
|
||||||
|
URBs**. The printer drains bulk data at PRINT speed (tiny internal buffer on these clones), so
|
||||||
|
closing right after the last accepted write cancels the still-transferring tail — exactly where
|
||||||
|
the feed + `GS V` cut bytes live. Kernel-accepted ≠ printer-received.
|
||||||
|
|
||||||
|
Fix: the one-URB rule makes acceptance of write N a **completion certificate for write N−1**. So
|
||||||
|
`writeAllUsb` now writes the payload's FINAL BYTE alone: when that 1-byte write is accepted, every
|
||||||
|
byte before it is physically in the printer; a short drain pause (`USB_DRAIN_MS` 300 ms) covers
|
||||||
|
the lone final-byte packet, then close is safe. (usblp also implements `poll(POLLOUT)` as the true
|
||||||
|
completion signal, but Node cannot poll an arbitrary char-device fd without a native dep — the
|
||||||
|
hold-back + drain gets the same guarantee for all but the final byte, whose packet the printer
|
||||||
|
ACKs immediately after having just freed its buffer.)
|
||||||
|
|
||||||
|
**✅ HARDWARE-VERIFIED (lab bench, 2026-07-07):** with both fixes, the ICS XP-K200L over USB
|
||||||
|
prints the complete slip, feeds, and CUTS — parity with TCP. The USB transport is done.
|
||||||
|
|
||||||
|
**Device discovery (2026-07-07).** The kernel numbers usblp nodes by plug/boot order — park-buzi's
|
||||||
|
printer is `lp1`, and the admin had to shell in and `ls /dev/usb` to learn that. The wizard now
|
||||||
|
lists REAL printers: `GET /api/setup/usb-printers` enumerates `/dev/usb/lpN` (visible via the
|
||||||
|
compose bind-mount) and enriches each with the printer's self-reported make/model from sysfs
|
||||||
|
(`/sys/class/usbmisc/lpN/device/ieee1284_id` — readable through Docker's default ro `/sys`). The
|
||||||
|
devicePath field becomes a SELECT ("/dev/usb/lp1 — Xprinter XP-K200L") with a fresh form
|
||||||
|
preselecting the first present device; a saved-but-unplugged path stays selectable, flagged
|
||||||
|
"saved — not present now"; zero devices found falls back to the free-text path + a check-the-cable
|
||||||
|
hint. The transport option label no longer hardcodes lp0.
|
||||||
|
|
||||||
|
> Driver-choice note for this clone: the ICS XP-K200L does NOT serve the Rongta `/prn_stat.htm`
|
||||||
|
> status page (checked on hardware at 10.0.10.11 — print socket 9100 open, status page absent),
|
||||||
|
> so on NETWORK the honest driver is **cashino** (reachability-only monitoring); under `rongta`
|
||||||
|
> the monitor would mark a perfectly working printer offline/degraded. Over USB the two drivers
|
||||||
|
> behave identically (reachability floor), so either works post-fix. See [[rongta-printer]].
|
||||||
|
|
||||||
|
## Field bug — cover-open re-enumeration wedges the container's `/dev/usb` view; only `docker restart`, not a host reboot, clears it (investigated 2026-08-30, unconfirmed root cause)
|
||||||
|
|
||||||
|
**Symptom (park-buzi, unknown/"Generic" USB printer, model not yet identified — see below):** every
|
||||||
|
time the booth operator opens the printer's paper-roll cover to reload paper, the printer's status
|
||||||
|
goes `offline`/faulty in the app and **never self-recovers** — not after the cover closes, not after
|
||||||
|
a full appliance reboot. The only fix found so far is SSH in and `docker restart server`.
|
||||||
|
|
||||||
|
**Ruled out at the application layer.** Traced `sendRawUsb`/`probeUsb` in `printer-escpos.ts`: every
|
||||||
|
print AND every poll tick (`device-monitor.ts` 8s / `printer-monitor.ts` 5s) does a fresh
|
||||||
|
`open()` → write/probe → `close()` against the configured `devicePath`. **No fd, socket, or driver
|
||||||
|
instance is held across calls** — `driver.create(config)` is a throwaway object with no persistent
|
||||||
|
handle. So a naive "stale Node file descriptor" explanation does not fit this codebase; the
|
||||||
|
app-layer retry-by-fresh-open-every-poll should self-heal within one poll cycle if the kernel's view
|
||||||
|
of the device node is current.
|
||||||
|
|
||||||
|
**Leading hypothesis: the container's bind-mount of `/dev/usb`, not the Node process, holds the
|
||||||
|
stale state.** Docker Compose wires the printer in as a **directory bind-mount**
|
||||||
|
(`docker-compose.prod.yml`, `volumes: - /dev/usb:/dev/usb`), chosen deliberately (per its own
|
||||||
|
comment) so the app survives the printer renumbering to a different `lpN`. But many USB thermal
|
||||||
|
printers cut power to their own USB interface board when the cover-open microswitch trips (a
|
||||||
|
hardware safety/power feature, not just a status flag) — the printer drops off the bus and
|
||||||
|
re-enumerates, potentially as a new device node, when the cover closes. The **host** kernel picks
|
||||||
|
this up fine; the **container's mount namespace**, once established, is a known Docker/OverlayFS
|
||||||
|
sharp edge for `/dev` subtree bind-mounts — it can keep resolving the old node until the mount
|
||||||
|
itself is redone.
|
||||||
|
|
||||||
|
- `docker restart server` recreates the container's mount namespace → the `/dev/usb` bind-mount is
|
||||||
|
redone against current host state → the new node is picked up → fixed.
|
||||||
|
- A full host reboot restarts the container too (`restart: always`), but as a boot-time race: if the
|
||||||
|
container starts before the USB subsystem finishes settling, or the printer re-enumerated some
|
||||||
|
time *before* the reboot and Docker doesn't necessarily redo an already-satisfied bind-mount
|
||||||
|
target on a policy-driven restart, the container can come back up still bound to the pre-incident
|
||||||
|
view. This matches the exact reported asymmetry (reboot doesn't fix it; explicit restart does).
|
||||||
|
|
||||||
|
**Not yet confirmed on hardware** — this is the leading theory, not a verified root cause. To
|
||||||
|
confirm at the next occurrence, BEFORE restarting anything:
|
||||||
|
```bash
|
||||||
|
# host:
|
||||||
|
ls -la /dev/usb/ && stat /dev/usb/lp1
|
||||||
|
# container:
|
||||||
|
docker exec server ls -la /dev/usb/ && docker exec server stat /dev/usb/lp1
|
||||||
|
```
|
||||||
|
A major:minor or inode mismatch between host and container is the smoking gun. Also worth
|
||||||
|
capturing on the lab RONGTA (different printer, but same cover-open mechanism is plausible):
|
||||||
|
`watch -n1 lsusb` + `sudo dmesg -w | grep -i -E 'usb|disconnect'` while cycling the cover, to see
|
||||||
|
whether the Bus/Device number changes.
|
||||||
|
|
||||||
|
**Candidate fixes, not yet implemented** (ranked cheapest-to-most-invasive):
|
||||||
|
1. A host-side watchdog/udev rule that detects re-enumeration of this printer (match vendor:product
|
||||||
|
ID) and runs `docker restart server` automatically — turns the manual SSH fix into a self-healing
|
||||||
|
one without touching app code.
|
||||||
|
2. Same idea but event-driven via a udev rule or systemd path unit watching `/dev/usb`, rather than
|
||||||
|
polling.
|
||||||
|
3. Switch the compose device wiring from the directory bind-mount to a specific `--device=` cgroup
|
||||||
|
passthrough + a udev rule pinning a stable symlink name — reintroduces the renumbering fragility
|
||||||
|
the directory bind-mount was chosen to avoid, so only worth doing alongside (1)/(2), not instead.
|
||||||
|
|
||||||
|
**Open sub-question — printer identity.** The park-buzi unit shows as "Generic (unknown)" in the
|
||||||
|
app; not yet identified by vendor/product ID. Lab reproduction uses a **RONGTA** unit instead (not
|
||||||
|
the same hardware), so the lab cannot currently reproduce the park-buzi symptom directly — only
|
||||||
|
validate the general re-enumeration mechanism. Commands to identify the real park-buzi printer next
|
||||||
|
time it's reachable via SSH: `lsusb`, `udevadm info -q property -n /dev/usb/lp1`, `udevadm info -a
|
||||||
|
-n /dev/usb/lp1`. This mirrors the same discovery gap already noted above under "Device discovery"
|
||||||
|
(sysfs `ieee1284_id` enrichment) — once identified, fold the model into that mechanism's coverage.
|
||||||
|
|
||||||
Related: [[rongta-printer]], [[printer-status-monitoring]], [[printer-roles-failover]],
|
Related: [[rongta-printer]], [[printer-status-monitoring]], [[printer-roles-failover]],
|
||||||
[[appliance-provisioning]], [[network-isolation]], [[technology-stack]].
|
[[appliance-provisioning]], [[network-isolation]], [[technology-stack]].
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
type: concept
|
type: concept
|
||||||
tags: [parking, domain, business, reporting]
|
tags: [parking, domain, business, reporting]
|
||||||
sources: []
|
sources: []
|
||||||
updated: 2026-06-22
|
updated: 2026-07-05
|
||||||
status: open
|
status: open
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -73,6 +73,30 @@ disputes ("I was charged for a car that left earlier"), lost-ticket lookup, and
|
|||||||
- **Export** for [[reconciliation]] / accounting (CSV/PDF) — the periodic external-authority path
|
- **Export** for [[reconciliation]] / accounting (CSV/PDF) — the periodic external-authority path
|
||||||
([[open-questions]] #4).
|
([[open-questions]] #4).
|
||||||
|
|
||||||
|
## As-built additions (2026-07-05) — the parking-shaped graphics
|
||||||
|
|
||||||
|
Operator ask: "check /reports for improvements and meaningful graphics." The dashboard (Recharts,
|
||||||
|
ledger-first aggregation in `apps/server/src/reports.ts`) gained the three views that are
|
||||||
|
parking-specific rather than generic BI, plus fraud counters:
|
||||||
|
|
||||||
|
- **Occupancy curve** — cars-inside step-area over the range with the configured capacity as a
|
||||||
|
red reference line. `occupancyStart` folds the ENTIRE prior ledger (voided entries excluded,
|
||||||
|
clamped ≥0); each series point carries `occupancyEnd`. The at-a-glance answer to "when are we
|
||||||
|
near full" — the input for capacity and dynamic-window decisions.
|
||||||
|
- **Entries heatmap (hour × day-of-week)** — 7×24 matrix (`entriesByDowHour`, row 0 = Monday,
|
||||||
|
site-tz), rendered as a pure CSS-grid amber-intensity map. Shows weekday-vs-weekend and
|
||||||
|
morning/evening patterns — the direct evidence for tariff windows (night rates, weekend cards,
|
||||||
|
early-bird — see [[tariff-industry-survey]]). Replaces the flat entries-by-hour bar (strictly
|
||||||
|
contains it).
|
||||||
|
- **Stay-duration histogram** — closed sessions bucketed at 30m/1h/2h/4h/8h/24h/tail
|
||||||
|
(`stayHistogram`): where the ladder/up-to breakpoints should sit.
|
||||||
|
- **Look-closer counters** — voids + anomalies in range as KPI cards (accented when >0): a spike
|
||||||
|
is exactly what the signed chain exists to surface (the operator is the threat model).
|
||||||
|
- Revenue bars now **stack cash vs card** per bucket (`cashMinor`/`cardMinor` on each point —
|
||||||
|
the drawer's money vs the bank's); peak-occupancy KPI (`peak / capacity`); CSV export gained
|
||||||
|
cash, card, occupancy_end columns. `localParts` now caches its Intl formatter per tz (was one
|
||||||
|
`new Intl.DateTimeFormat` per ledger row).
|
||||||
|
|
||||||
## Open
|
## Open
|
||||||
|
|
||||||
- Which reports matter at launch vs. later; the export format/cadence.
|
- Which reports matter at launch vs. later; the export format/cadence.
|
||||||
|
|||||||
+37
-1
@@ -2,7 +2,7 @@
|
|||||||
type: concept
|
type: concept
|
||||||
tags: [parking, domain, business, shifts, anti-fraud]
|
tags: [parking, domain, business, shifts, anti-fraud]
|
||||||
sources: []
|
sources: []
|
||||||
updated: 2026-07-01
|
updated: 2026-07-05
|
||||||
status: open
|
status: open
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -237,6 +237,35 @@ after**. This removes the friction while keeping accountability.
|
|||||||
+ own movements; admin: the review queue + all movements). `/shifts` is now just open/close +
|
+ own movements; admin: the review queue + all movements). `/shifts` is now just open/close +
|
||||||
Z-report. Server: `routes/drawer.ts` (lifted out of `routes/shift.ts`); UI: `DrawerManager.tsx`.
|
Z-report. Server: `routes/drawer.ts` (lifted out of `routes/shift.ts`); UI: `DrawerManager.tsx`.
|
||||||
|
|
||||||
|
## Drawer hub — the page answers "what's in the till and why" (2026-07-05)
|
||||||
|
|
||||||
|
Operator feedback: `/drawer` was **too simple** — record + review only, with no current balance, no
|
||||||
|
sight of the open shift's incomings, no daily activity, no shift history. Rebuilt as a hub of five
|
||||||
|
panels, all reads over data that already existed:
|
||||||
|
|
||||||
|
- **Drawer now** — the running balance (new `GET /api/drawer/balance`, `shift:read`; a passthrough to
|
||||||
|
the service's existing `drawerBalance()`, which was never exposed). While a shift is open, the
|
||||||
|
X-report breakdown sits beside it so the number is always explainable: *opening float + cash takings
|
||||||
|
(with payment count — the "current shift incomings") + vouchers in − out = expected = balance*, and
|
||||||
|
a **"This shift: ±X"** figure under the balance shows the shift's OWN contribution (expected −
|
||||||
|
opening float), separating what this operator moved from what they inherited. With no shift open it
|
||||||
|
reads as the carried-forward closing balance.
|
||||||
|
- **Today's cash activity** — every drawer-touching signed event since local midnight (cash payments +
|
||||||
|
vouchers; card never enters the till), live (15s), with day totals. Source: the existing
|
||||||
|
`/api/events` window query, filtered client-side (`event:read`).
|
||||||
|
- **Record** + **movements/review** — the 2026-07-01 flow, unchanged.
|
||||||
|
- **Closed shifts** — drawer-focused history via the existing scope-aware `/api/shifts`
|
||||||
|
(operators: own; admins: all): float → takings ± vouchers, expected drawer per shift.
|
||||||
|
|
||||||
|
Visibility note: the balance endpoint is `shift:read` on purpose — the drawer is a **single site-wide
|
||||||
|
till**, the same exposure the open shift's X-report already had, not per-operator data.
|
||||||
|
|
||||||
|
**Busy feedback on shift buttons (2026-07-05).** Opening a shift can take seconds (see the open item
|
||||||
|
below), and the buttons' only feedback was a label swap ("Opening…") — subtle enough that operators
|
||||||
|
read a slow open as a dead click. Every shift open/close button (header, /shifts, the pay-modal's
|
||||||
|
"open shift now", the end-shift confirm) now pairs the busy label with an animated spinner
|
||||||
|
(`ui/Spinner.tsx`, reusable) and dims while disabled.
|
||||||
|
|
||||||
## Where the fraud control actually lives
|
## Where the fraud control actually lives
|
||||||
|
|
||||||
Deliberately **not** in a shift-close ceremony. Because every payment is a **signed event in the
|
Deliberately **not** in a shift-close ceremony. Because every payment is a **signed event in the
|
||||||
@@ -253,6 +282,13 @@ reconciles the signed Z-report against the actual drawer and the bank/POS batch
|
|||||||
|
|
||||||
## Open
|
## Open
|
||||||
|
|
||||||
|
- **Drawer/shift reads fold the WHOLE chain — O(chain) growth (flagged 2026-07-05).**
|
||||||
|
`#drawerBalanceAt`, `currentOpenShift`, and `listShifts` select every ledger event and fold in JS.
|
||||||
|
Fine pre-opening; after months of operation this is a linearly growing pause on every shift open,
|
||||||
|
X-report, and drawer-balance read (the observed "opening a shift is slow"). Clean fix when it
|
||||||
|
bites: fold **from the last `shift_z_report` forward** — its `expectedDrawerMinor` is already the
|
||||||
|
signed balance snapshot at that point — instead of from genesis. Not built; the UI got busy
|
||||||
|
spinners in the meantime.
|
||||||
- **Drawer carry-over (decided 2026-06-18, built; vouchers re-modelled 2026-06-20; review reworked
|
- **Drawer carry-over (decided 2026-06-18, built; vouchers re-modelled 2026-06-20; review reworked
|
||||||
2026-07-01):** opening float auto-inherits the prior shift's expected drawer; drawer movements are the
|
2026-07-01):** opening float auto-inherits the prior shift's expected drawer; drawer movements are the
|
||||||
**`cash_in` / `cash_out` voucher pair** (Mandat Arkëtimi / Mandat Pagese — direction is the type),
|
**`cash_in` / `cash_out` voucher pair** (Mandat Arkëtimi / Mandat Pagese — direction is the type),
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
---
|
||||||
|
type: reference
|
||||||
|
tags: [parking, domain, business, pricing, research]
|
||||||
|
sources: []
|
||||||
|
updated: 2026-07-05
|
||||||
|
---
|
||||||
|
|
||||||
|
# Tariff systems in the parking industry — survey (2026-07)
|
||||||
|
|
||||||
|
Web research (2026-07-05) into the tariff/pricing structures the parking industry actually uses,
|
||||||
|
to sanity-check the [[tariff]] engine's coverage and rank the gaps. Sources at the end; legacy
|
||||||
|
cross-check in [[parksql2017-legacy-schema]].
|
||||||
|
|
||||||
|
## The taxonomy — what operators run in the field
|
||||||
|
|
||||||
|
**1. Time-based accrual (the bread and butter everywhere)**
|
||||||
|
- **Linear hourly** — €/hr **per started hour**; billing increments of 30/60 min dominate.
|
||||||
|
**Per-minute billing is rejected in practice**: garages that tried it (e.g. Leuven) rolled it
|
||||||
|
back within months — customers couldn't predict the price and complaints rose. Vindicates our
|
||||||
|
`incrementMin` default of 60.
|
||||||
|
- **Degressive ladder** — marginal price FALLS with duration (€2.50 first hour, €0.50/30min
|
||||||
|
after): the standard shape for hospitals, city-centre garages, anywhere encouraging longer
|
||||||
|
stays. Exactly our V1/V2 `blocks`.
|
||||||
|
- **Progressive ladder** — marginal price RISES with duration: rarer, used to force turnover
|
||||||
|
(short-stay curbside). Same `blocks` primitive, ascending.
|
||||||
|
- **Daily / weekly caps** — a ceiling regardless of accrual ("day max €7"). Universal in
|
||||||
|
commercial garages. Our `dailyCapMinor`.
|
||||||
|
- **Free grace period** — 15–30 min free at entry (airports: pickup/dropoff support). Our
|
||||||
|
`gracePeriodEntryMin`.
|
||||||
|
|
||||||
|
**2. Whole-stay prices (not accrual)**
|
||||||
|
- **Flat rate / day ticket** — one price for the day (Delft inner city: €29/day). Our stepped
|
||||||
|
("up-to") table with one row, or a flat V1.
|
||||||
|
- **Up-to matrices** — "stay up to N hours costs TOTAL" tables: the region's habit (legacy
|
||||||
|
ParkSQL confirms). Our `steps`.
|
||||||
|
- **Evening/overnight package** — "€8 after 18:00 until 06:00", whole window one price. VERY
|
||||||
|
common municipally. Exactly our `packageMinor` (built 2026-07-05).
|
||||||
|
- **Event rate** — flat premium price during a known event window, set in advance. Expressible
|
||||||
|
today as a date-ranged windowed card (flat or package).
|
||||||
|
|
||||||
|
**3. Entry-time-conditioned rates (the notable GAP)**
|
||||||
|
- **Early bird** — a discounted all-day flat for cars that ENTER before a cutoff (typically
|
||||||
|
09:00–10:00), sometimes with an exit-window condition too; no forgiveness for missing the
|
||||||
|
cutoff. A CBD-commuter staple worldwide. The defining trait: the rate is selected by the
|
||||||
|
ENTRY instant and governs the whole stay — which is precisely the
|
||||||
|
**pick-table-by-entry-time** design we identified as the sound future shape (also solves
|
||||||
|
weekend menus). Anti-arbitrage conditions (min stay / exit window) exist to stop short-stay
|
||||||
|
parkers grabbing the flat.
|
||||||
|
|
||||||
|
**4. Calendar windows** — day/night rates, weekday vs weekend/holiday, seasonal date ranges
|
||||||
|
(Berlin: free nights + Sundays in some zones). Our V2 windowed cards cover all of these.
|
||||||
|
|
||||||
|
**5. Category pricing** — by vehicle type (car/bus/truck) and by customer class
|
||||||
|
(resident/visitor — SKIDATA advertises residency + vehicle-type tariffs). Our `card.category` +
|
||||||
|
frozen session category.
|
||||||
|
|
||||||
|
**6. Contracts / recurring** — weekly/monthly fixed rates for commuters; steady-revenue anchor
|
||||||
|
everywhere. Ours lives outside the tariff: [[subscription]] plans.
|
||||||
|
|
||||||
|
**7. Discount overlays (validations) — the second GAP**
|
||||||
|
- **Merchant validation** — a sponsor (shop/hotel/cinema) reduces the parker's fee: mechanisms
|
||||||
|
range from stamped/barcoded tickets to one-use codes to LPR-keyed auto-validation. Discount
|
||||||
|
shapes in PARCS products: fixed amount, percentage (up to 100% comp), TIME CREDIT ("first 2h
|
||||||
|
free"), or a full re-rate to a different rate table. Fully vs partially sponsor-subsidised.
|
||||||
|
- **Channel discounts** — cheaper pre-booked/online/app rates (airports especially).
|
||||||
|
|
||||||
|
**8. Dynamic / demand-based** — price moves with occupancy. SFpark (the canonical study):
|
||||||
|
target 60–80% block occupancy, ±$0.25 adjustments, → −43% search time, −30% GHG; average rate
|
||||||
|
actually FELL. Airports run revenue-management engines inside operator-set floor/ceiling.
|
||||||
|
Almost exclusively municipal-curbside + large-airport territory, needs demand telemetry +
|
||||||
|
price-communication infrastructure — not single-lot booth territory.
|
||||||
|
|
||||||
|
**9. Fees/penalties** — lost ticket = worst-case exposure (max daily × assumed duration): ours
|
||||||
|
(`lostTicketMinor`). Post-payment exit grace, then overstay repricing: ours.
|
||||||
|
|
||||||
|
## Coverage map — our engine vs the industry
|
||||||
|
|
||||||
|
| Industry structure | Status in our engine |
|
||||||
|
| --- | --- |
|
||||||
|
| Linear hourly, per-started-increment | ✅ V1/V2 blocks + incrementMin |
|
||||||
|
| Degressive / progressive ladder | ✅ blocks |
|
||||||
|
| Daily cap, entry grace, exit grace | ✅ |
|
||||||
|
| Up-to matrix / day ticket | ✅ steps (defaultCard) |
|
||||||
|
| Day/night, weekend/holiday, seasonal | ✅ V2 windowed cards |
|
||||||
|
| Night/evening whole-window package | ✅ packageMinor (2026-07-05) |
|
||||||
|
| Event rate | ✅ expressible (date-ranged card) |
|
||||||
|
| Vehicle/customer category | ✅ card.category (capture seam pending) |
|
||||||
|
| Monthly/weekly contracts | ✅ subscription plans (separate) |
|
||||||
|
| Lost ticket, overstay reprice | ✅ |
|
||||||
|
| **Early bird / entry-time-conditioned** | ❌ gap — needs pick-table-by-entry-time (see [[tariff]] up-to/tiers incompatibility discussion) |
|
||||||
|
| **Validations / merchant discounts** | ❌ gap — no discount overlay mechanism; would need signed discount events + sponsor accounting |
|
||||||
|
| Pre-book/online channel rates | ❌ out of scope (no online channel; offline-first) |
|
||||||
|
| Dynamic/demand pricing | ❌ deliberately out — municipal/airport scale, needs telemetry; conflicts with printed-price predictability at a booth lot |
|
||||||
|
|
||||||
|
## Takeaways for the roadmap
|
||||||
|
|
||||||
|
1. **Coverage is already strong**: everything a single staffed lot typically advertises is
|
||||||
|
expressible today. The 2026-07-05 package mode closed the last everyday gap (real night
|
||||||
|
price).
|
||||||
|
2. **Early bird is the highest-value missing structure** and shares its mechanism with the
|
||||||
|
weekend-menu ask: select the ENTIRE rate table by entry instant (window → table), instead of
|
||||||
|
pricing per wall-clock slice. One future mechanism, two market features. Include min-stay /
|
||||||
|
exit-window conditions if built (anti-arbitrage is part of the product, not a nicety).
|
||||||
|
3. **Validations are the second candidate** once merchants near the site matter: time-credit +
|
||||||
|
percent + full-comp shapes, as signed appended events (fits the ledger model); sponsor
|
||||||
|
settlement stays outside the app (like drawer-review denials).
|
||||||
|
4. **Do not build**: per-minute billing (industry tried it, customers rejected it), dynamic
|
||||||
|
pricing (wrong scale + breaks price predictability at a booth).
|
||||||
|
|
||||||
|
## Sources
|
||||||
|
|
||||||
|
- FHWA, *Contemporary Approaches to Parking Pricing: A Primer* — <https://ops.fhwa.dot.gov/publications/fhwahop12026/sec_2.htm>
|
||||||
|
- Arivo, *The Right Parking Tariff* (practitioner taxonomy) — <https://arivo.co/en/blog/optimal-parking-pricing>
|
||||||
|
- Pitane, *Per-minute parking creates chaos* (Leuven rollback) — <https://pitane.blue/en/2025/01/16/Parking-rates-in-garages-per-minute-parking-causes-chaos-according-to-experts/>
|
||||||
|
- Secure Parking, *Early Bird Parking* (entry/exit conditions) — <https://www.secureparking.com.au/en-au/parking-solutions/early-bird-parking/>
|
||||||
|
- ParkWhiz help, *What is Early Bird* — <https://help.parkwhiz.com/support/solutions/articles/60001010507-what-is-early-bird->
|
||||||
|
- Parking BOXX, *Validation programs guide* — <https://blog.parkingboxx.com/industry/parking-validation-programs-guide/>
|
||||||
|
- Amano McGann, *Validation solutions* (discount shapes incl. re-rate) — <https://amanomcgann.com/our-solutions-parking-management/validation/>
|
||||||
|
- ITS DOT evaluation of SFpark — <https://www.itskrs.its.dot.gov/2024-b01818>
|
||||||
|
- UCLA ITS, *Pricing Parking by Demand (SFpark)* — <https://www.its.ucla.edu/publication/pricing-parking-by-demand-sfpark/>
|
||||||
|
- IDeaS, *Airport parking dynamic pricing* — <https://ideas.com/airport-parking-dynamic-pricing/>
|
||||||
|
- SKIDATA, mobility & parking solutions (tariff axes) — <https://www.skidata.com/en-us/solutions/mobility-parking>
|
||||||
|
- City of Tampa / MPLS Parking / RDU T&Cs (published rate-card examples) —
|
||||||
|
<https://www.tampa.gov/parking/info/parking-hourly-and-daily-rates>,
|
||||||
|
<https://www.mplsparking.com/parking-rates>, <https://www.rdu.com/terms-conditions/>
|
||||||
@@ -174,5 +174,6 @@ in `packages/shared/src/index.ts` (+ `tariff.test.ts`, 36 cases incl. the golden
|
|||||||
holiday calendar (one date list, referenced by cards) is a future nicety, not built.
|
holiday calendar (one date list, referenced by cards) is a future nicety, not built.
|
||||||
- Per-relay/lane **category capture** at a transient gate (the "bus lane") — seam noted in
|
- Per-relay/lane **category capture** at a transient gate (the "bus lane") — seam noted in
|
||||||
`entry-flow.ts`; today every transient takes the site default category.
|
`entry-flow.ts`; today every transient takes the site default category.
|
||||||
- A composer **price preview** ("at 14:30 Tue a 2h stay costs …") — high-value for operator trust,
|
- ~~A composer **price preview**~~ — largely DELIVERED 2026-07-06 by the Tariff Lab's fee
|
||||||
deferred.
|
BREAKDOWN (see [[tariff]] §Tariff Lab): simulate any stay against a draft/version and read the
|
||||||
|
line items. A live preview inside the composer form itself remains a possible nicety.
|
||||||
|
|||||||
+18
-2
@@ -193,11 +193,27 @@ The admin authors the rate card at runtime — no hand-seeding:
|
|||||||
composer accumulates per-band hours into the engine's cumulative `uptoMin` (minutes) on submit. The
|
composer accumulates per-band hours into the engine's cumulative `uptoMin` (minutes) on submit. The
|
||||||
**last band is always the open-ended "thereafter"** row (not removable, no hours field), so a
|
**last band is always the open-ended "thereafter"** row (not removable, no hours field), so a
|
||||||
published card always satisfies the open-ended-last rule. Shows the active version + history;
|
published card always satisfies the open-ended-last rule. Shows the active version + history;
|
||||||
"Publish" creates a new version (past sessions keep their pricing).
|
"Publish" creates a new version (past sessions keep their pricing). Since 2026-07-05 a **right
|
||||||
|
sidebar lists the published history** (name or effective date, active badge — mirrors the lab's
|
||||||
|
sidebar); clicking a version **loads it into the editor as the starting point** for the next
|
||||||
|
publish (currency select ALL/EUR/USD; optional version-name field). Publishing never edits the
|
||||||
|
clicked version — the sidebar hint says so explicitly.
|
||||||
- Ships **blank** — until a version is published, `GET /api/tariff` returns `active: null` and the
|
- Ships **blank** — until a version is published, `GET /api/tariff` returns `active: null` and the
|
||||||
pay station returns `409 no active tariff`. Verified end to end (publish → pay station prices).
|
pay station returns `409 no active tariff`. Verified end to end (publish → pay station prices).
|
||||||
|
|
||||||
### Tariff Lab (simulator, as-built 2026-06-20; drafts redesign 2026-07-05)
|
### Tariff Lab (simulator, as-built 2026-06-20; drafts redesign 2026-07-05; fee breakdown 2026-07-06)
|
||||||
|
|
||||||
|
> **Fee breakdown ("how is this sum produced").** The lab's Outcome panel lists the fee's LINE
|
||||||
|
> ITEMS: banded same-price increment runs (time window · N × unit · tier-card name), window-package
|
||||||
|
> occurrences, stepped day totals (top-tier repeat flagged), daily-cap clamps as NEGATIVE
|
||||||
|
> adjustments, entry grace, plus a rounding note (raw min → billed min at the increment). Produced
|
||||||
|
> by `explainFee` in `@parking/shared` — the SAME computeFee walk with a trace collector, so
|
||||||
|
> Σ items ≡ the amount by construction (golden V1 regression unchanged). `/api/tariff/simulate`
|
||||||
|
> returns it as `breakdown` (null when settled). Also the composer grew INCREMENT-UNIT guards
|
||||||
|
> (2026-07-06): price labels state the real unit live ("Çmimi / orë" at 60, "Çmimi / N min"
|
||||||
|
> otherwise), an amber warning fires when increment ≠ 60, and each ladder/flat price shows its
|
||||||
|
> "= X / orë" equivalence — closing the 60→10 ×6-prices trap; example defaults are
|
||||||
|
> currency-scaled (ALL: 200/100, not the euro-scale 2.00/1.00).
|
||||||
|
|
||||||
The tariff engine is a **pure function of time**, but you could previously only *exercise* it by
|
The tariff engine is a **pure function of time**, but you could previously only *exercise* it by
|
||||||
waiting (the only clock the booth reads is the real wall-clock). The **Tariff Lab** closes that gap:
|
waiting (the only clock the booth reads is the real wall-clock). The **Tariff Lab** closes that gap:
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
type: concept
|
type: concept
|
||||||
tags: [parking, domain, business, pricing, revenue]
|
tags: [parking, domain, business, pricing, revenue]
|
||||||
sources: []
|
sources: []
|
||||||
updated: 2026-06-15
|
updated: 2026-07-13
|
||||||
status: open
|
status: open
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -11,6 +11,137 @@ status: open
|
|||||||
A merchant (shop, hotel, clinic) **validates** a customer's parking so they pay less or nothing —
|
A merchant (shop, hotel, clinic) **validates** a customer's parking so they pay less or nothing —
|
||||||
a common revenue/retention feature that modifies what a [[parking-session]] owes.
|
a common revenue/retention feature that modifies what a [[parking-session]] owes.
|
||||||
|
|
||||||
|
## Driving cases (owner requirements, 2026-07-13)
|
||||||
|
|
||||||
|
The feature moved from "industry gap" to **asked-for**: the park may contain an in-park
|
||||||
|
**car-wash (al. "lavazh")** and/or a **bar**, and the owner wants their customers discharged
|
||||||
|
(fully or partly) for the parking stay:
|
||||||
|
|
||||||
|
- **Car-wash**: parking free entirely, **or** free for an owner-set duration (30 min / 1 h / 2 h …)
|
||||||
|
after which the stay prices like any transient → `comp` or `time-credit`.
|
||||||
|
- **Bar**: subtract the bar consumption from the parking fee (consumed 300 ALL, park fee 500 ALL →
|
||||||
|
pay 200 ALL) → `fixed` with a **per-use variable amount**; or parking free for bar customers → `comp`.
|
||||||
|
- These must be **admin-composable at runtime like tariffs/subscription plans** — the owner
|
||||||
|
defines the programs and their parameters; nothing hard-coded.
|
||||||
|
|
||||||
|
**Refined the same day (settled): the merchant is a VALIDATION-ONLY system user; ALL money and
|
||||||
|
paper stay at the booth.** Ownership is immaterial and the [[validation-sponsorship]]
|
||||||
|
sponsor/settlement layer is **not needed** for this. The model:
|
||||||
|
|
||||||
|
- A **merchant user** (the "bar user", "lavazh user") logs into the system on their own device and
|
||||||
|
**scans the customer's ticket** there — the scan-and-apply *is* the validation, a signed event
|
||||||
|
attributed to that user (accountability sits with the merchant, not the booth operator). That is
|
||||||
|
the merchant's ENTIRE surface: no payment collection, no printer, no shift.
|
||||||
|
- **Every car still checks in at the booth to settle** — even a fully-comped one. The booth quote
|
||||||
|
applies the session's validation events (`gross − discounts`, floor 0); the operator collects the
|
||||||
|
**net** (possibly 0 — a zero-amount settlement is still a signed `payment` event so grace/exit
|
||||||
|
work unchanged) and **prints the detailed receipt there** (gross fee, each validation line, net
|
||||||
|
paid).
|
||||||
|
- Exit is the unchanged [[booth-exit-flow]] (immediate exit or voucher self-exit at the reader).
|
||||||
|
|
||||||
|
This DISSOLVES the two consequences flagged by the earlier merchant-collects variant (rejected
|
||||||
|
2026-07-13, same conversation): the [[shift]] site-wide single-open invariant and single till stay
|
||||||
|
as built (Z/X-reports just gain gross/discount/net lines so cash reconciles to net), and the exit
|
||||||
|
reader needs no live due=0 branch (the booth settlement covers the zero-due case; time-credit is
|
||||||
|
priced at booth check-in, inside the normal walk-back-grace flow).
|
||||||
|
|
||||||
|
## Settled design (2026-07-13) — setup UX, storage, RBAC
|
||||||
|
|
||||||
|
- **Setup lives on `/setup/site`** (gated by the page's existing `site:update`): the left card
|
||||||
|
gains **Bar** and **Lavazh** checkboxes; the empty right column renders the enabled station's
|
||||||
|
config panel (tabs when both). Panel per station: **mode** (comp / time-credit N-min / fixed
|
||||||
|
amount-typed-at-scan with a max cap / percent), **caps** (max per validation, max per day,
|
||||||
|
one-per-session default), **receipt label**, **bound users**.
|
||||||
|
- **Fixed UI, generic storage**: a `validation_programs` table (+ user binding) where Bar and
|
||||||
|
Lavazh are two **well-known rows** created on first enable — a third merchant later is a data
|
||||||
|
row, not a migration (honours the "composable like tariffs" requirement). Config is plainly
|
||||||
|
**mutable, no versioning**: the applied validation is a signed ledger event carrying the
|
||||||
|
RESOLVED values (minutes/amountMinor + programId), so reproducibility never depends on the row.
|
||||||
|
Enabling/saving signs a `config_change` ([[entry-presence-bypass]] precedent).
|
||||||
|
- **RBAC**: new `validation` resource in the code-defined grid — `validation:create` (apply; the
|
||||||
|
merchant's only permission) + `validation:read` (reports/history). Guard = permission **AND**
|
||||||
|
station binding (data), so a bar user can never apply the lavazh program. Merchant users land on
|
||||||
|
a new **`/validate`** screen (scan → session → apply); the permission-driven nav shows them
|
||||||
|
nothing else. Program composition needs no new permission (`site:update`).
|
||||||
|
- **Mistake handling**: a merchant may **void their own validation while unused** (before it
|
||||||
|
entered a payment) — a signed void event, never a delete. Booth/admin can void via the normal
|
||||||
|
event-void path.
|
||||||
|
- Open (non-blocking): per-customer mode choice (v1 = one mode per station); merchant scan
|
||||||
|
hardware — lean: also print a **QR** of the ticket id so any phone camera works
|
||||||
|
([[ticket-encoding]]).
|
||||||
|
|
||||||
|
## As-built (2026-07-13)
|
||||||
|
|
||||||
|
- **Shared (`@parking/shared`)**: `validation` resource (`validation:create`/`read`) in the
|
||||||
|
permission grid; `ValidationMode`/`ValidationProgram`/`SessionValidation`/`ValidationLine`;
|
||||||
|
`priceSession(…, validations[])` folds the discounts in a **canonical order** — timeCredit
|
||||||
|
(shifts the billed period's start forward, so grace/steps/windowed cards price the remainder
|
||||||
|
correctly) → percent (of the remainder) → fixed (clamped) → comp — net floors at 0 and
|
||||||
|
**Σ lines ≡ gross − net** by construction. Unit-tested (incl. overstay + settled cases).
|
||||||
|
- **Ledger**: new `validation` event type — payload carries the **resolved** values
|
||||||
|
(`programId`, `programLabel`, `mode`, `minutes`/`amountMinor`/`percent`) + `operator` (the
|
||||||
|
merchant username); `refId` set = a VOID of the referenced validation (append-only, mirrors
|
||||||
|
`cash_review`). The settling `payment` records `grossMinor`/`discountMinor`/`validationIds`
|
||||||
|
(**consumption** — an overstay's fresh period never re-applies them) + `validationLines`
|
||||||
|
(receipt reproducibility).
|
||||||
|
- **DB**: `validation_programs` + `validation_program_users` (migration `0024`; both in
|
||||||
|
reset-db's `config` category). Mutable master data, soft-deletable.
|
||||||
|
- **Server**: `routes/validations.ts` — programs GET/PUT (`site:read`/`site:update`, signed
|
||||||
|
`config_change` on real change only), `/mine`, `/session/:identity` (deliberately no money
|
||||||
|
data), `/apply` (guards in order: program live+active → user **bound** → open **transient** →
|
||||||
|
no live duplicate of the program → `maxPerDay` → fixed-amount bounds), `/void` (own +
|
||||||
|
unconsumed only). `PayStation.quote/lookup/pay` fold `liveValidations` (applied − voided −
|
||||||
|
consumed); `activeSessions` amounts are net automatically. Receipt (`renderReceipt`) prints
|
||||||
|
gross (`Tarifa`) + one line per discount; the big amount is the NET. Z/X-report gained
|
||||||
|
`discountTotalMinor` (leakage; takings stay net) — printed as `Zbritje (validime)` only when
|
||||||
|
non-zero, so old slips stay byte-identical.
|
||||||
|
- **Web**: `/setup/site` is two-column — Bar/Lavazh checkboxes on the left card (a flip persists
|
||||||
|
`active` at once = signed config change), `ValidationSetup.tsx` panel on the right (tabs when
|
||||||
|
both; mode/params/caps/receipt-label/bound-users). `/validate` (`ValidateScreen.tsx`) is the
|
||||||
|
merchant's whole surface (scan/key → apply → void own unused), mobile-friendly, autofocused
|
||||||
|
input works with HID scanners; merchant-only users (no `session:read`) land there on login and
|
||||||
|
the permission-gated nav shows them nothing else. The app SHELL also degrades by permission
|
||||||
|
(2026-07-13 follow-up): the live-feed WebSocket connects only with `report:read` (the server's
|
||||||
|
WS guard — a merchant's socket would 403 and the capped-backoff reconnect would spam the server
|
||||||
|
log forever), and the StatusDot / ShiftButton / DeviceFooter widgets render only with their
|
||||||
|
backing permissions (`report:read` / `shift:read` / `device:read`). Booth pay modal shows gross → lines → net;
|
||||||
|
the zero-net comp settles through the normal pay path (grace starts, voucher/exit unchanged).
|
||||||
|
Feed label `VALIDIM`/`VALIDATION`. RolesManager picks the new resource up generically.
|
||||||
|
- **Verified**: 8 route-level integration tests (guards, signed events, money cycle, void locks,
|
||||||
|
per-day cap) + the shared fold suite; whole-workspace build/typecheck/test green; migration
|
||||||
|
applied to the dev DB.
|
||||||
|
- **Remaining polish (not blocking)**: show `discountTotalMinor` in the X-report/close-modal/
|
||||||
|
shift-history UI (it's already in the signed payload + printed Z); a validations/leakage
|
||||||
|
**report** (per program/user/day) under [[reporting-analytics]].
|
||||||
|
|
||||||
|
## Merchant scan input — DECIDED 2026-07-13: barcode scanner on the web/desktop app; camera paths POSTPONED
|
||||||
|
|
||||||
|
**v1 (in force):** the merchant scans with a **USB/HID barcode scanner** into the `/validate`
|
||||||
|
screen on the web (or desktop) app — the scanner types the 11-digit id + Enter into the
|
||||||
|
autofocused input, exactly like the booth. Hand-keying is the zero-hardware fallback; the
|
||||||
|
[[ticket-encoding|Luhn check digit]] catches typos. The park site is expected to equip the
|
||||||
|
bar/lavazh station accordingly — no phone-camera path for now.
|
||||||
|
|
||||||
|
**Postponed (evaluated 2026-07-13, both viable, deliberately deferred):**
|
||||||
|
|
||||||
|
1. **Web camera scanning** — `BarcodeDetector` (Chromium/Android native) + the `barcode-detector`
|
||||||
|
polyfill on **zxing-wasm** (Apache/MIT — license-clean, bundles offline). Two prerequisites
|
||||||
|
killed it for now: (a) `getUserMedia` needs a **secure context** — a merchant phone on
|
||||||
|
`http://<booth-ip>` gets NO camera, so the appliance needs a TLS story (realistically a
|
||||||
|
self-signed CA minted on the booth + one-time cert install per device — fold into the
|
||||||
|
[[booth-deploy-networking|reverse-proxy]] plan); (b) Code128 via phone camera on thermal
|
||||||
|
paper decodes poorly — would want the **QR-of-ticket-id** addition first (the ESC/POS driver
|
||||||
|
already has `qrCode()`; `renderTicket` is a one-line change — still a good idea whenever any
|
||||||
|
camera path revives).
|
||||||
|
2. **Tauri Android merchant app** (a SECOND small Tauri target, e.g. `apps/validator` — NOT an
|
||||||
|
extension of [[desktop-shell-tauri|apps/desktop]], which is a booth kiosk hardwired to
|
||||||
|
localhost:3000): Tauri v2 mobile + the official `barcode-scanner` plugin (ML Kit — reads
|
||||||
|
Code128 well natively, and the tauri:// origin is secure so the TLS problem vanishes).
|
||||||
|
Costs that drove the postponement: Android SDK/NDK + Rust-target build infra (+CI), APK
|
||||||
|
sideload distribution/updates to merchant devices, effectively Android-only (iOS needs a
|
||||||
|
paid signing account), and it needs the configurable-server-URL work the desktop shell also
|
||||||
|
wants. Revisit if the owner issues dedicated Android tablets to merchants.
|
||||||
|
|
||||||
## Model: a discount is a signed event, applied at fee time
|
## Model: a discount is a signed event, applied at fee time
|
||||||
|
|
||||||
A validation is **not** an edit to the session or a mutable "discount applied" flag — same reason
|
A validation is **not** an edit to the session or a mutable "discount applied" flag — same reason
|
||||||
|
|||||||
@@ -55,6 +55,17 @@ Mirrored networking is necessary but **not sufficient** — these still bit us:
|
|||||||
- **`localhost` → IPv6 first.** `localhost` resolves to `::1`, but the backend binds IPv4
|
- **`localhost` → IPv6 first.** `localhost` resolves to `::1`, but the backend binds IPv4
|
||||||
(`127.0.0.1`). Node's Vite proxy can stall on the v6 attempt before falling back — point the
|
(`127.0.0.1`). Node's Vite proxy can stall on the v6 attempt before falling back — point the
|
||||||
proxy at `127.0.0.1` explicitly. (See [[local-dev-workflow]].)
|
proxy at `127.0.0.1` explicitly. (See [[local-dev-workflow]].)
|
||||||
|
- **Windows-side listeners collide with WSL binds — INVISIBLY (2026-07-13).** Under mirrored
|
||||||
|
mode, a process listening on the WINDOWS side makes the same port `EADDRINUSE` inside WSL,
|
||||||
|
but it never appears in Linux `ss`/`lsof` — the port looks free yet won't bind. Bit us as
|
||||||
|
"tauri dev: Could not connect to http://localhost:5173 after 180s": a DIFFERENT React app's
|
||||||
|
dev server running on the Windows side held `::1:5173`, so the WSL Vite silently
|
||||||
|
auto-incremented to 5174 while Tauri's `devUrl` is the FIXED string `http://localhost:5173`
|
||||||
|
in `tauri.conf.json` (it cannot follow the auto-increment). Diagnose from WSL with
|
||||||
|
`powershell.exe -NoProfile -Command "Get-NetTCPConnection -LocalPort 5173 -State Listen"`
|
||||||
|
(then `Get-Process -Id <OwningProcess>`); kill with `taskkill.exe /PID <pid> /F`. Guard:
|
||||||
|
`strictPort: true` in the web `vite.config` so the mismatch fails in a second with a clear
|
||||||
|
error instead of a 3-minute hang on the wrong port.
|
||||||
|
|
||||||
## Multi-subnet source-address trap (the "ARP works but ping/TCP dies" bug)
|
## Multi-subnet source-address trap (the "ARP works but ping/TCP dies" bug)
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
type: reference
|
type: reference
|
||||||
tags: [parking, deployment, appliance, hardening, runbook, offline-first]
|
tags: [parking, deployment, appliance, hardening, runbook, offline-first]
|
||||||
sources: []
|
sources: []
|
||||||
updated: 2026-06-30
|
updated: 2026-09-02
|
||||||
status: settled
|
status: settled
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -216,17 +216,24 @@ adversary). Create a dedicated **admin** (real password, sudo, NO auto-login) an
|
|||||||
```bash
|
```bash
|
||||||
sudo adduser admin && sudo usermod -aG sudo admin
|
sudo adduser admin && sudo usermod -aG sudo admin
|
||||||
# VERIFY in a second session: log in as admin → `sudo whoami` prints root — BEFORE the next step:
|
# VERIFY in a second session: log in as admin → `sudo whoami` prints root — BEFORE the next step:
|
||||||
sudo deluser <operator> sudo # demote the auto-login operator
|
sudo gpasswd -d <operator> sudo # demote the auto-login operator
|
||||||
groups <operator> # confirm: no 'sudo'
|
groups <operator> # confirm: no 'sudo'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> Use **`gpasswd -d`**, not `deluser <user> <group>`: on this Ubuntu the perl adduser tooling
|
||||||
|
> rejects hyphenated usernames (`sanitize_string: invalid characters in 'park-operator'` —
|
||||||
|
> VERIFIED on park-buzi 2026-07-06). And group removal applies at **next login** — the auto-login
|
||||||
|
> operator session keeps its old memberships until the box reboots (or the session relogs);
|
||||||
|
> re-verify `groups` from inside the operator session afterwards.
|
||||||
|
|
||||||
⚠ Order matters: confirm the new admin's sudo works **before** demoting the operator, or you lock
|
⚠ Order matters: confirm the new admin's sudo works **before** demoting the operator, or you lock
|
||||||
yourself out. Keep auto-login on the OPERATOR, not admin. **Leave root password disabled** (Ubuntu
|
yourself out. Keep auto-login on the OPERATOR, not admin. **Leave root password disabled** (Ubuntu
|
||||||
default) — `admin`+sudo IS the root path; enabling root adds risk, no gain.
|
default) — `admin`+sudo IS the root path; enabling root adds risk, no gain.
|
||||||
|
|
||||||
> Strip latent escalation groups from the operator: **`sudo deluser <operator> lxd`** (lxd group =
|
> Strip latent escalation groups from the operator: **`sudo gpasswd -d <operator> lxd`** (lxd group
|
||||||
> launch a privileged container that mounts host `/` as root — undoes the no-sudo hardening) and
|
> = launch a privileged container that mounts host `/` as root — undoes the no-sudo hardening) and
|
||||||
> `lpadmin` (printer admin, unneeded). And NEVER add the operator to `docker` (also root-equivalent).
|
> `sudo gpasswd -d <operator> lpadmin` (printer admin, unneeded). And NEVER add the operator to
|
||||||
|
> `docker` (also root-equivalent).
|
||||||
|
|
||||||
## 5b. Further hardening (TODO — not yet done)
|
## 5b. Further hardening (TODO — not yet done)
|
||||||
|
|
||||||
@@ -275,16 +282,38 @@ sudo loginctl enable-linger admin # so the user service starts at boot witho
|
|||||||
```
|
```
|
||||||
|
|
||||||
- `--connect-as` is the **Server name in Core** — unique, stable, site-meaningful (the fleet's
|
- `--connect-as` is the **Server name in Core** — unique, stable, site-meaningful (the fleet's
|
||||||
primary key). Booth #2 = a different name (e.g. `park-durres`); never reuse one.
|
primary key). Booth #2 = a different name (e.g. `park-durres`); never reuse one. **Get this
|
||||||
|
right in the command itself** — it's a plain field in `periphery.config.toml` on the host, so a
|
||||||
|
typo/placeholder here needs a config edit + agent restart to fix, NOT a rename in Core's UI
|
||||||
|
(which only relabels Core's record, not the agent's real identity — gotcha #12 below).
|
||||||
- `--core-address` is Core's **reverse-proxy URL** (the URL you load the Core UI at over the mesh),
|
- `--core-address` is Core's **reverse-proxy URL** (the URL you load the Core UI at over the mesh),
|
||||||
NOT `:9120` — Core's container port `9120` is exposed-not-published; the agent reaches it through
|
NOT `:9120` — Core's container port `9120` is exposed-not-published; the agent reaches it through
|
||||||
the proxy. (Gotcha #7 below.)
|
the proxy. (Gotcha #7 below.)
|
||||||
- Config lands at `~/.config/komodo/periphery.config.toml`. The key field is **`core_address`**
|
- Config lands at `~/.config/komodo/periphery.config.toml`. The key field is **`core_address`**
|
||||||
(singular); `root_directory` must be a path `admin` can write (user-mode default is fine — a
|
(singular).
|
||||||
`/etc/komodo` default from a system install would `Permission denied` for the user service).
|
|
||||||
|
|
||||||
Verify: `systemctl --user status periphery` → active; the server **`park-buzi`** appears and goes
|
> ⚠ **ALWAYS CHECK THIS — every install so far has hit it (lab box 2026-07-07, booth `park-2`
|
||||||
**OK/green** in Core → Servers. Then **delete the onboarding key**.
|
> 2026-09-02).** `root_directory` must be a path `admin` can write, but **Periphery's installer
|
||||||
|
> writes `root_directory = "/etc/komodo"` even with `--user`** (still true as of v2.3.3). Result:
|
||||||
|
> panic `Failed to write private key pem to "/etc/komodo/keys/periphery.key" … Permission denied`,
|
||||||
|
> crash-loop until systemd gives up (`Start request repeated too quickly`).
|
||||||
|
>
|
||||||
|
> **Fix + restart:**
|
||||||
|
> ```bash
|
||||||
|
> sed -i 's|^root_directory = .*|root_directory = "'"$HOME"'/.komodo"|' ~/.config/komodo/periphery.config.toml
|
||||||
|
> systemctl --user reset-failed periphery && systemctl --user restart periphery
|
||||||
|
> ```
|
||||||
|
> NB `sudo systemctl restart periphery` says *unit not found* — it's a USER unit; always
|
||||||
|
> `systemctl --user …`. The onboarding key survives a pre-connect crash (unused until first dial).
|
||||||
|
>
|
||||||
|
> **➜ Do not stop here once it's green.** This fix only gets Periphery *running* — the Stack still
|
||||||
|
> isn't deployed. Immediately continue to **verify below, then §7b**.
|
||||||
|
|
||||||
|
**Verify:** `systemctl --user status periphery` → active; the server **`park-buzi`** appears and
|
||||||
|
goes **OK/green** in Core → Servers. Then **delete the onboarding key**.
|
||||||
|
|
||||||
|
**➜ Next step is §7b below — the Stack itself is not deployed yet.** A green Server in Core just
|
||||||
|
means the agent connected; it runs nothing until you add the Registry/Git accounts and deploy.
|
||||||
|
|
||||||
### 7b. Deploy the Stack (in Core — by hand once, then code)
|
### 7b. Deploy the Stack (in Core — by hand once, then code)
|
||||||
|
|
||||||
@@ -366,8 +395,11 @@ entrypoint). `DATABASE_URL` in-container is **`/data/parking.sqlite`** (the `par
|
|||||||
# --financial ledger (entry/exit/payment/void/shift/cash/anomaly) + device_events + snapshots +
|
# --financial ledger (entry/exit/payment/void/shift/cash/anomaly) + device_events + snapshots +
|
||||||
# subscription INSTANCES/credentials/plates + blocklist. KEEPS users/devices/config/
|
# subscription INSTANCES/credentials/plates + blocklist. KEEPS users/devices/config/
|
||||||
# tariffs/subscription PLANS.
|
# tariffs/subscription PLANS.
|
||||||
# --config site_config, devices, setup_state (re-runs first-run setup), tariffs + versions, plans.
|
# --config site_config, devices, setup_state (re-runs first-run setup), tariffs + versions
|
||||||
# --users users, roles, role_permissions, auth sessions. --all every table.
|
# + drafts, plans.
|
||||||
|
# --users users, roles, role_permissions, auth sessions.
|
||||||
|
# --diagnostics app_logs (the /setup/logs store). --all every table.
|
||||||
|
# A drift guard refuses to run if the DB has a table no category covers (2026-07-08).
|
||||||
docker exec -it \
|
docker exec -it \
|
||||||
-e RESET_ALLOWED=1 \
|
-e RESET_ALLOWED=1 \
|
||||||
-e DATABASE_URL=/data/parking.sqlite \
|
-e DATABASE_URL=/data/parking.sqlite \
|
||||||
@@ -384,8 +416,41 @@ docker exec -it \
|
|||||||
> setup only). It is a **training/demo** tool — never run on a production booth's data.
|
> setup only). It is a **training/demo** tool — never run on a production booth's data.
|
||||||
|
|
||||||
After `--users`/`--all` (users cleared), re-seed the first admin exactly as in §7b
|
After `--users`/`--all` (users cleared), re-seed the first admin exactly as in §7b
|
||||||
(`docker exec … node scripts/seed-admin.mjs`) so someone can log back in. For dev (where `pnpm` exists)
|
(`docker exec … node scripts/seed-admin.mjs`) so someone can log back in. Since 2026-07-06 the seed
|
||||||
the same script is `pnpm db:reset --financial` — see [[local-dev-workflow]].
|
script **self-heals the built-in `admin` role row** that this reset also wipes — before that fix the
|
||||||
|
documented re-seed died on a `role_id` FOREIGN KEY error (field failure on `park-buzi`). For dev
|
||||||
|
(where `pnpm` exists) the same script is `pnpm db:reset --financial` — see [[local-dev-workflow]].
|
||||||
|
|
||||||
|
### 7e. Lost APP admin password — reset from the Linux admin account (2026-07-06)
|
||||||
|
|
||||||
|
The app's admin password lives only as a bcrypt hash in the booth DB; there is no in-app recovery
|
||||||
|
(nobody above the admin exists to send a reset). The recovery path is the **Linux `admin` account**
|
||||||
|
(the only user in the `docker` group): the seed script doubles as the password-reset tool via
|
||||||
|
`FORCE=1` — on an existing username it RESETS that user's password (and restores `roleId: admin`,
|
||||||
|
so it also rescues a demoted admin).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Interactive (preferred — the password never lands in shell history):
|
||||||
|
docker exec -it -e FORCE=1 park-buzi-server-1 node scripts/seed-admin.mjs
|
||||||
|
# → prompts: username (Enter = admin), new password (min 8 chars)
|
||||||
|
|
||||||
|
# Non-interactive (scripted; NB the password enters the HOST's shell history):
|
||||||
|
docker exec -e FORCE=1 -e ADMIN_USER=admin -e ADMIN_PASS='new-strong-pass' \
|
||||||
|
park-buzi-server-1 node scripts/seed-admin.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Attributable, not gated.** Whoever holds Linux root owns the DB file — the app cannot defend
|
||||||
|
against that actor and doesn't pretend to. What it CAN do: the script appends a **signed
|
||||||
|
`config_change` ledger event** (`admin.passwordReset` / `admin.seeded` on first seed, operator
|
||||||
|
`console:seed-admin`) so a console reset stays visible in the chain afterwards. If the signing key
|
||||||
|
is unavailable (e.g. a dev shell), it warns loudly and proceeds — locking an admin out to protect
|
||||||
|
an audit line would invert the priority. The [[threat-model]] adversary remains the *operator*,
|
||||||
|
who has no Linux account at all.
|
||||||
|
- **Sessions are NOT revoked** by a password reset — issued JWT cookies ride to expiry. A *forgotten*
|
||||||
|
password needs nothing more; a *suspected-stolen* one should also rotate the booth's `JWT_SECRET`
|
||||||
|
(Komodo Variables → redeploy), which invalidates every session instantly.
|
||||||
|
- Works on a fresh/reset DB too (the role-row self-heal above), so §7b first-seed, §7d post-reset
|
||||||
|
re-seed, and this recovery are all the same one command.
|
||||||
|
|
||||||
### Healthy startup + web-access
|
### Healthy startup + web-access
|
||||||
|
|
||||||
@@ -431,8 +496,36 @@ works; the desktop app is a separate workstream.
|
|||||||
separate Komodo credentials. A blank registry account on the Stack → anonymous pull →
|
separate Komodo credentials. A blank registry account on the Stack → anonymous pull →
|
||||||
`no basic auth credentials`. Set the Stack's **Registry Account** (`komodo`).
|
`no basic auth credentials`. Set the Stack's **Registry Account** (`komodo`).
|
||||||
9. **User-mode Periphery + `/etc/komodo` `root_directory` = `Permission denied`** writing the agent
|
9. **User-mode Periphery + `/etc/komodo` `root_directory` = `Permission denied`** writing the agent
|
||||||
key. User-mode (runs as `admin`, no root daemon) must keep `root_directory` under `$HOME`.
|
key. User-mode (runs as `admin`, no root daemon) must keep `root_directory` under `$HOME`. Hit
|
||||||
|
on every install so far (lab box 2026-07-07, booth `park-2` 2026-09-02, still on v2.3.3) —
|
||||||
|
**check this first** whenever a fresh Periphery install crash-loops; see the boxed callout in
|
||||||
|
§7a for the fix. Easy to fix-and-move-on without realizing the Stack still isn't deployed —
|
||||||
|
§7a's fix only starts the agent, §7b deploys the Stack.
|
||||||
10. The config key is **`core_address`** (singular). And `--core-address` derives `wss://` from
|
10. The config key is **`core_address`** (singular). And `--core-address` derives `wss://` from
|
||||||
`https://` — if Core were plain-HTTP you'd need `http://` (→ `ws://`).
|
`https://` — if Core were plain-HTTP you'd need `http://` (→ `ws://`).
|
||||||
11. ResourceSync **Execute disabled + file shown clean in Info = empty diff = already in sync**
|
11. ResourceSync **Execute disabled + file shown clean in Info = empty diff = already in sync**
|
||||||
(success). Execute only enables when the file and Core diverge (e.g. you edit `TAG`).
|
(success). Execute only enables when the file and Core diverge (e.g. you edit `TAG`).
|
||||||
|
12. **Renaming a Server in Core's UI does NOT change the agent's actual identity.**
|
||||||
|
`connect_as` is a plain field persisted in the agent's own
|
||||||
|
`~/.config/komodo/periphery.config.toml` — Core's UI rename only relabels Core's *record*,
|
||||||
|
the agent keeps re-announcing under its original `connect_as` on every reconnect. Symptom (hit
|
||||||
|
2026-08-30, lab box): a server named via a leftover template placeholder in the install
|
||||||
|
command kept reappearing in Core no matter how many times it was renamed there, while the
|
||||||
|
intended name sat permanently NOT OK (nothing was ever checking in as that name). **Fix: edit
|
||||||
|
`connect_as` directly in `periphery.config.toml` on the host, then `systemctl --user restart
|
||||||
|
periphery`** — no reinstall/re-onboarding needed. Delete the stray old-name Server record in
|
||||||
|
Core afterward. Lesson: always double-check `--connect-as` is a REAL name (never leave a
|
||||||
|
template placeholder like `<new-server-name>` in a copy-pasted install command) — Core will
|
||||||
|
happily create a server with that literal string.
|
||||||
|
13. **Upgrading an already-installed Periphery is: re-run the same installer, unchanged
|
||||||
|
`--connect-as`.** No separate update mechanism, no update-only flag. The installer script
|
||||||
|
explicitly skips rewriting `periphery.config.toml` if one already exists ("Config already
|
||||||
|
exists, skipping...") — it only stops the service, replaces the binary, and restarts — so a
|
||||||
|
re-run is **config-preserving** and a fresh/dummy `--onboarding-key` value on that re-run is
|
||||||
|
simply unused (confirmed against Komodo's own `setup-periphery.py` source, 2026-08-30; no
|
||||||
|
Periphery-specific breaking changes between v2.2.0 and v2.3.2 per Komodo's release notes).
|
||||||
|
Verified end-to-end on `art-docker-station` (lab, dry run) then `park-buzi` (live booth,
|
||||||
|
2026-08-30): same command as §7a step 2, same `--connect-as`, app containers untouched
|
||||||
|
throughout (Periphery restarting itself never touches the already-running compose stack).
|
||||||
|
**Always dry-run a version bump on a lab/dev box before a live booth**, even with a clean
|
||||||
|
release-notes check — this project only had one lab box to test against and used it first.
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
---
|
||||||
|
type: decision
|
||||||
|
tags: [parking, cloud, saas, multi-tenant, monitoring, netbird, threat-model, offline-first]
|
||||||
|
sources: []
|
||||||
|
updated: 2026-07-13
|
||||||
|
status: open
|
||||||
|
---
|
||||||
|
|
||||||
|
# Cloud service — multi-tenant SaaS for fleet monitoring & control
|
||||||
|
|
||||||
|
> **Status: postponed (2026-07-13).** Captured as context, not a commitment. This records an
|
||||||
|
> early requirements/architecture discussion so it isn't re-derived from scratch later. No app
|
||||||
|
> code, no schema. Two of the initial requirements were **corrected in-discussion** (see
|
||||||
|
> "Corrections" below) — read those before treating any first-pass answer as settled.
|
||||||
|
|
||||||
|
## The idea
|
||||||
|
|
||||||
|
The offline backup model we ship today is the right **tradeoff for offline sites** and stays.
|
||||||
|
On top of it, the user wants an **online, multi-tenant SaaS** — the "**cloud service**" — that
|
||||||
|
subscribing park sites connect to for **real-time (link-up) monitoring**: the signed ledger,
|
||||||
|
device status, financial reports, and whatever else a site reports. One **admin owns more than
|
||||||
|
one site** (a portfolio). The cloud also **custodies per-site secrets**. Business model: recurring
|
||||||
|
per-site monthly/yearly fee — a revenue line the offline appliance alone can't produce.
|
||||||
|
|
||||||
|
This is the customer-facing evolution of the off-site control plane that [[fleet-deployment-komodo]]
|
||||||
|
already stood up (**Komodo Core**, **NetBird** mesh, **Gitea** registry). Much of the transport and
|
||||||
|
Tier-0 reasoning there carries over directly; this page is about turning that internal ops plane into
|
||||||
|
a **multi-tenant product**.
|
||||||
|
|
||||||
|
## The four hard tensions (what makes a naïve SaaS wrong here)
|
||||||
|
|
||||||
|
The booth's two governing forces ([[offline-first]], [[threat-model]]) plus the signed ledger
|
||||||
|
([[append-only-event-chain]]) make the "obvious" SaaS shape wrong. Four tensions dominate:
|
||||||
|
|
||||||
|
1. **Offline-first vs. real-time monitoring.** The cloud must **never be in the critical path** of
|
||||||
|
entry/exit/payment/barrier ([[offline-first]]). It is a **read-mostly mirror + control-plane**, fed
|
||||||
|
by the booth when the link is up, tolerant of hours/days offline, and unable to block booth
|
||||||
|
operation by being down. "Real-time" = *near*-real-time when up, **gracefully stale** when not —
|
||||||
|
and the UI must show staleness **honestly** (last-seen everywhere), never paint a dark site green.
|
||||||
|
|
||||||
|
2. **The signed ledger must stay *verifiable* in the cloud, not merely displayed.** If subscribers
|
||||||
|
see "their ledger" in the cloud, the cloud copy must be **re-verified server-side** — re-check the
|
||||||
|
hash chain + signatures on ingest, flag gaps/breaks/forks loudly. The [[threat-model|operator-as-
|
||||||
|
adversary]] extends upward: an operator may want the cloud *not* to see certain events, so the sync
|
||||||
|
must be **gap-evident** (sequence continuity). This is both the anti-tamper mechanism **and** a
|
||||||
|
headline feature — *"we can prove your revenue record wasn't altered, even by your own night
|
||||||
|
shift."* See [[reconciliation]] (this is reconciliation, productised).
|
||||||
|
|
||||||
|
3. **Secrets for every site — the scariest requirement.** A central secret store for hundreds of
|
||||||
|
sites is a single juicy target. The custody boundary must be deliberate — see "Secrets boundary".
|
||||||
|
|
||||||
|
4. **Multi-tenancy under operator-as-adversary — now at two levels.** One admin, many sites ⇒ a new
|
||||||
|
**portfolio-owner** role *above* the existing per-site roles ([[local-jwt-auth]] admin/operator/
|
||||||
|
cashier/readonly). Row-level tenant isolation must be **airtight** — a bug now leaks *another
|
||||||
|
company's* revenue, not just an intra-site escalation. Every row carries `tenant_id` + `site_id`,
|
||||||
|
non-optional in the query path (not a filter someone can forget). **Cloud identity is separate from
|
||||||
|
booth-local auth** — the booth keeps its offline JWT/bcrypt login untouched; a site never
|
||||||
|
authenticates its *users* against the cloud (that would break [[offline-first]]).
|
||||||
|
|
||||||
|
## Secrets boundary (settled-in-principle 2026-07-13)
|
||||||
|
|
||||||
|
User confirmed the cloud custodies **three** classes — and **not** the crown jewel:
|
||||||
|
|
||||||
|
| Class | Cloud custodies? | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Sync/connection creds + ledger **public** (verify) key | ✅ yes | Per-site uplink credential + the public half to *verify* signatures. Smallest blast radius. |
|
||||||
|
| **Device/controller passwords** (Dingtian `relay_pw`, camera creds, push tokens) | ✅ yes, as **escrow** | Solves the real pain: lost `relay_pw` after a DB reset ([[dingtian-relay]]). See escrow rules below. |
|
||||||
|
| App/admin identity (portfolio login) | ✅ yes | Cloud-side identity for portfolio admins. Separate from booth-local auth. |
|
||||||
|
| Ledger **signing** key / [[atecc608\|ATECC608]] private key, LUKS/TPM material | ❌ **never** | Centralising the signer **kills the anti-fraud model** ([[append-only-event-chain]], [[hardware-signer-options]]). The user did **not** pick this. |
|
||||||
|
|
||||||
|
**How device-password escrow must work (so it earns its keep instead of becoming the breach):**
|
||||||
|
|
||||||
|
- **Envelope encryption, per-tenant DEK**, DEKs wrapped by a KMS master key; a DB dump is ciphertext,
|
||||||
|
every decrypt is KMS-audited.
|
||||||
|
- The cloud is an **escrow, not an operational credential store**. Its job is "**give the booth back
|
||||||
|
its `relay_pw`** after a wipe," *not* "the cloud logs into the Dingtian." Decryption happens **at the
|
||||||
|
booth** (booth fetches its own wrapped blob, unwraps locally); ideally the cloud never holds
|
||||||
|
plaintext device secrets in memory. This keeps the [[dingtian-http-api-unauthenticated|unauthenticated-
|
||||||
|
CGI]] exposure host-local.
|
||||||
|
- **The booth threat model applies upward:** writes to escrow are append/version ops the operator
|
||||||
|
can't silently rewrite; reads are logged where the operator can't scrub them.
|
||||||
|
- Sellable as: *"your device credentials survive any wipe, encrypted so even we can't read them in
|
||||||
|
bulk."*
|
||||||
|
|
||||||
|
## Corrections made in-discussion (2026-07-13) — read these
|
||||||
|
|
||||||
|
The first pass argued *against* the user's two boldest choices ("cloud reaches into the booth";
|
||||||
|
implicitly, "no remote barrier open"). **The user corrected both, and the corrections stand.**
|
||||||
|
|
||||||
|
### Correction 1 — NetBird already solves the isolation objection
|
||||||
|
|
||||||
|
Initial worry: a cloud tunnel *into* the booth is a new inbound attack surface on every site. **But
|
||||||
|
park-buzi is already monitored remotely over a NetBird private mesh** (WireGuard) — the same
|
||||||
|
mesh [[fleet-deployment-komodo]] uses. The booth **dials out** to join the overlay; **nothing is
|
||||||
|
exposed** on the booth PC. So "cloud reaches booth" is the booth-dialed reverse-channel pattern
|
||||||
|
**already in production**, not a new hole. The objection is **withdrawn.** What it *shifts* rather than
|
||||||
|
removes:
|
||||||
|
|
||||||
|
- Trust moves to the **overlay's identity/ACL layer**: "cloud can reach the booth" now means "any
|
||||||
|
peer the mesh authorizes can reach the booth host." **Mesh ACLs must enforce the same tenant
|
||||||
|
isolation as the app layer** — site A's admin never gets a route to site B's booth. Multi-tenant
|
||||||
|
isolation in a different hat.
|
||||||
|
- **The access-controller VLAN still holds:** the mesh terminates at the **host**, not the controller
|
||||||
|
segment. A cloud peer talks to the booth API; the **booth** talks to the Dingtian/UHPPOTE
|
||||||
|
([[network-isolation]], [[access-direction-is-per-relay]]). The cloud never gets an L3 route to the
|
||||||
|
UDP relay.
|
||||||
|
- **NetBird's control plane joins the trust base** (self-hosted = another service to harden; their
|
||||||
|
SaaS = a third party who can authorize peers). A conscious call, not an architecture change.
|
||||||
|
|
||||||
|
### Correction 2 — remote barrier-open is *compatible* with barrier-not-a-door, and the unmanned future *requires* it
|
||||||
|
|
||||||
|
Initial worry: the cloud must never open a barrier. **The user's driver is the [[autonomous-direction|
|
||||||
|
unmanned-site]] future** — no operator on-site; if the exit reader or payment dies, *someone* must open
|
||||||
|
the barrier remotely rather than trap people ("we can't take hostages because a stupid device is not
|
||||||
|
responsive"). This is **right**, and it does **not** violate [[barrier-not-a-door]]:
|
||||||
|
|
||||||
|
- That rule was **never** "no remote open." It forbids driving the barrier as a **timed auto-close**
|
||||||
|
("open for N ms"); physical safety (loop-detector, anti-crush reversal) lives in the **barrier
|
||||||
|
firmware**. A remote human pressing "open" is an **intent expression** — exactly `pulseOpen`. It's
|
||||||
|
the [[fail-state-safety|exit-fails-open]] value, triggered by a remote human instead of a power-loss.
|
||||||
|
- Constrain the **how**, not the whether (this is the command where [[threat-model|operator-as-
|
||||||
|
adversary]] bites hardest — a remote "let this car out free" is the classic fraud):
|
||||||
|
- **Every remote open is a first-class signed ledger event** ([[append-only-event-chain]]): appended,
|
||||||
|
hash-chained, signed, with **actor** (which cloud identity), **reason code**, and **site/relay**.
|
||||||
|
Control power and audit come as a **pair** — the same discipline [[setup-relay-test]] and
|
||||||
|
[[booth-exit-flow|audited re-open]] already apply locally.
|
||||||
|
- A **distinct, high-privilege capability**, not bundled into "monitoring" — a readonly portfolio
|
||||||
|
viewer can't open barriers.
|
||||||
|
- **The booth stays the enforcer:** cloud sends *intent*; the booth validates (for-me? authorized
|
||||||
|
peer? signed?) and issues `pulseOpen` to its own relay. Cloud never touches the relay.
|
||||||
|
- **Cloud can't be the *sole* egress path.** A fully unattended site needs a **local fail-open on
|
||||||
|
host-loss** + physical override too — offline-first means the cloud is a *convenience* remote-open
|
||||||
|
path, not the *only* one, or you've recreated "device down = hostages" one layer up.
|
||||||
|
|
||||||
|
> **Emergent tenet:** an unattended site is a **higher** safety bar than an attended one, not a lower
|
||||||
|
> one. Every local failure mode (barrier stuck, payment dead, network down) needs an answer that
|
||||||
|
> **doesn't require the cloud**; the cloud makes resolution *nicer*, not *possible*. Fold into
|
||||||
|
> [[autonomous-direction]] and [[fail-state-safety]] when this is picked up.
|
||||||
|
|
||||||
|
## What looks straightforward (agreed quickly)
|
||||||
|
|
||||||
|
- **Transport:** the existing **NetBird overlay** (booth-dialed, nothing exposed) — not a bespoke
|
||||||
|
channel. Reuses [[fleet-deployment-komodo]].
|
||||||
|
- **Sync:** **booth-push, verify-on-ingest** — booth streams ledger + device telemetry
|
||||||
|
([[device-events]]) + snapshot metadata + financial data outbound; cloud **re-verifies the chain +
|
||||||
|
signatures** and flags gaps.
|
||||||
|
- **Staleness first-class in the UI:** every site tile shows last-seen; a dark site is visibly stale.
|
||||||
|
- **DB:** almost certainly **PostgreSQL** — already the named deferred sync target ([[drizzle-orm]],
|
||||||
|
[[technology-stack]]); the Drizzle schemas are meant to port to it.
|
||||||
|
|
||||||
|
## The genuinely open questions (postponed — pick up here)
|
||||||
|
|
||||||
|
1. **What does "real-time" mean to the buyer?** Live-ish (seconds, streaming uplink → **heavier
|
||||||
|
booth**) vs. every-few-minutes rollups (cheap, still sells "monitoring"). This gap is **most of the
|
||||||
|
engineering cost** and drives how heavy the booth-side uplink must be.
|
||||||
|
2. **Financial reports computed where?** Cloud **re-derives** revenue from the verified ledger →
|
||||||
|
independently trustworthy (*"we don't take the booth's word for it"*) but the cloud must implement
|
||||||
|
the [[tariff]] pricing logic. Vs. booth sends **pre-computed rollups** (cheaper, but trusts the
|
||||||
|
booth's math). Lean: **cloud re-derives** — the whole point of [[threat-model|operator-adversary]]
|
||||||
|
is not to trust the site's self-report ([[reporting-analytics]] is already "projections over the
|
||||||
|
signed log").
|
||||||
|
3. **Hosting + licensing.** The booth stack is deliberately all-MIT/Apache/BSD ([[technology-stack]]);
|
||||||
|
a SaaS the user **hosts** has more freedom (like the [[fleet-deployment-komodo|Komodo GPL]] /
|
||||||
|
[[vision-service|AGPL]] self-host exceptions) — but anything that ever ships **on-premise** re-binds
|
||||||
|
the constraint.
|
||||||
|
4. **Custodianship is leverage *and* liability.** Holding other companies' financial records + device
|
||||||
|
secrets is what makes the subscription **sticky** — and what pulls in **backups, retention policy,
|
||||||
|
breach disclosure, data-residency**. A deliberate "yes, we want to be the custodian" call, with the
|
||||||
|
obligations that implies. (Cloud/Core is a **Tier-0 asset** for the whole fleet — the same bar
|
||||||
|
[[fleet-deployment-komodo]] already sets for Core.)
|
||||||
|
|
||||||
|
## Relates
|
||||||
|
|
||||||
|
- [[fleet-deployment-komodo]] — the off-site control plane (Komodo Core + NetBird) this productises;
|
||||||
|
Core-as-Tier-0 reasoning carries over.
|
||||||
|
- [[autonomous-direction]] — the unmanned future that *drives* remote barrier-open (Correction 2).
|
||||||
|
- [[reconciliation]] — the cloud *is* reconciliation, productised (verify-on-ingest, gap-evidence).
|
||||||
|
- [[append-only-event-chain]] / [[hardware-signer-options]] — why the **signing** key stays on the
|
||||||
|
booth even as everything else centralises.
|
||||||
|
- [[threat-model]] / [[offline-first]] — the two forces every tension above traces back to.
|
||||||
|
- [[network-isolation]] / [[access-direction-is-per-relay]] — why the mesh terminates at the host.
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
type: decision
|
type: decision
|
||||||
tags: [parking, decisions, desktop, frontend]
|
tags: [parking, decisions, desktop, frontend]
|
||||||
sources: []
|
sources: []
|
||||||
updated: 2026-06-21
|
updated: 2026-09-03
|
||||||
status: settled
|
status: settled
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -140,20 +140,27 @@ Per the user's choices — the operator **keeps OS access** (no fullscreen lockd
|
|||||||
- **Right-click:** the context menu is blocked in **prod only** (`apps/web/src/lib/kiosk.ts`,
|
- **Right-click:** the context menu is blocked in **prod only** (`apps/web/src/lib/kiosk.ts`,
|
||||||
guarded on `import.meta.env.PROD`); dev keeps right-click + devtools. Applies to both the browser
|
guarded on `import.meta.env.PROD`); dev keeps right-click + devtools. Applies to both the browser
|
||||||
prod build and the desktop build (same SPA).
|
prod build and the desktop build (same SPA).
|
||||||
- **`VITE_API_BASE` wired to the environment:** `apps/web/.env.production` (committed, non-secret,
|
- **`VITE_API_BASE` — desktop vs. browser (regression found + fixed 2026-09-03):**
|
||||||
allow-listed in `.gitignore`) sets `VITE_API_BASE=http://127.0.0.1:3000`, auto-loaded by
|
`apps/web/.env.production` (committed, shared by both builds) sets `VITE_API_BASE=` (empty) — this
|
||||||
`vite build` (which the desktop bundle runs). So the desktop build targets Fastify with no manual
|
is correct for the **browser/booth** build (Fastify same-origin, stays relative) since commit
|
||||||
export; the browser-served-by-Fastify build should override to `""`.
|
`96fd97e` (2026-06-27), but that same change silently broke the **desktop** build, which was never
|
||||||
|
given its own override. Result: the desktop shell's `apiUrl()` returned a bare relative path
|
||||||
|
(`/api/auth/login`) to `fetch()` from a page loaded at `tauri://localhost` — WebKitGTK has no base
|
||||||
|
to resolve a relative URL against from a non-`http(s)` origin, and threw `DOMException: "The
|
||||||
|
string did not match the expected pattern."` on the first authenticated request (login). Login
|
||||||
|
worked fine in the browser (same-origin, no absolute URL needed) the whole time, which is what
|
||||||
|
made this easy to miss. **Fix:** `tauri.conf.json`'s `build.beforeBuildCommand` now sets
|
||||||
|
`VITE_API_BASE=http://127.0.0.1:3000` inline (`VITE_API_BASE=http://127.0.0.1:3000 pnpm --filter
|
||||||
|
@parking/web build`) — process env vars override `.env.production` in Vite's load order, so this
|
||||||
|
overrides the shared file for the desktop build only, without touching it (the browser/booth build
|
||||||
|
still gets the empty value, unaffected). Verified: rebuilding with the override bakes
|
||||||
|
`127.0.0.1:3000` into the bundle; rebuilding without it stays clean/relative.
|
||||||
- **Auto-update (prompt-on-update, self-hosted):** `tauri-plugin-updater` + `tauri-plugin-process`.
|
- **Auto-update (prompt-on-update, self-hosted):** `tauri-plugin-updater` + `tauri-plugin-process`.
|
||||||
On launch the SPA checks the endpoint (`apps/web/src/lib/desktop-updater.ts`, no-op in browser /
|
On launch the SPA checks the endpoint (`apps/web/src/lib/desktop-updater.ts`, no-op in browser /
|
||||||
offline), prompts the operator (i18n `update.prompt`), then `downloadAndInstall()` + `relaunch()`.
|
offline), prompts the operator (i18n `update.prompt`), then `downloadAndInstall()` + `relaunch()`.
|
||||||
Accepts that the appliance may be **offline** day-to-day and brought online (phone hotspot) only
|
Accepts that the appliance may be **offline** day-to-day and brought online (phone hotspot) only
|
||||||
when an update is wanted — consistent with [[offline-first]] (no network dependency in *core*
|
when an update is wanted — consistent with [[offline-first]] (no network dependency in *core*
|
||||||
operation; updates are out-of-band). Endpoint is the **self-hosted Gitea** "latest release"
|
operation; updates are out-of-band). **WS origin:** the desktop window's origin
|
||||||
path — `https://git.infra.msai.al/mca/parking_solution/releases/latest/download/latest.json`
|
|
||||||
— which redirects to the newest tag's `latest.json` (published by `.gitea/workflows/release.yml`).
|
|
||||||
The updater GETs it (200 + manifest, or 204 = up-to-date), reads `platforms.linux-x86_64.
|
|
||||||
{signature,url}`, and downloads the signed installer. **WS origin:** the desktop window's origin
|
|
||||||
is `tauri://localhost` (Linux may also send `http://tauri.localhost`), so the backend's
|
is `tauri://localhost` (Linux may also send `http://tauri.localhost`), so the backend's
|
||||||
`WS_ALLOWED_ORIGINS` must include both or the live feed won't connect (documented in
|
`WS_ALLOWED_ORIGINS` must include both or the live feed won't connect (documented in
|
||||||
`apps/server/.env.example`).
|
`apps/server/.env.example`).
|
||||||
@@ -165,8 +172,27 @@ Per the user's choices — the operator **keeps OS access** (no fullscreen lockd
|
|||||||
produced `.deb`/`.rpm`/`.AppImage` **plus their `.sig` updater signatures**; full `turbo run build
|
produced `.deb`/`.rpm`/`.AppImage` **plus their `.sig` updater signatures**; full `turbo run build
|
||||||
lint` 14/14 green. *(This is the **updater** signing — distinct from OS-installer signing for
|
lint` 14/14 green. *(This is the **updater** signing — distinct from OS-installer signing for
|
||||||
Windows/macOS "unknown publisher", and from the [[atecc608]]/[[tpm]] **event** signing.)*
|
Windows/macOS "unknown publisher", and from the [[atecc608]]/[[tpm]] **event** signing.)*
|
||||||
- **Still deferred:** the actual update-hosting URL, OS-level installer signing
|
- **Update-hosting endpoint (found broken, fixed 2026-09-03):** the endpoint originally pointed at
|
||||||
(Windows/macOS publisher trust), and the Windows kiosk-browser fallback path.
|
the **source repo's own** Gitea "latest release" redirect
|
||||||
|
(`.../mca/parking_solution/releases/latest/download/latest.json`) — but `mca/parking_solution` is
|
||||||
|
**private**, and the updater runs on offline-first field appliances with **no Gitea credentials**.
|
||||||
|
Every deployed update check was silently failing (swallowed by a `try/catch` in
|
||||||
|
`desktop-updater.ts`) — this was never field-verified, and it couldn't have worked as configured.
|
||||||
|
**Fix:** signed installers are now mirrored to a separate **public**, releases-only repo,
|
||||||
|
`mca/public_releases` (shared across apps in the org — see [[fleet-deployment-komodo]] sibling
|
||||||
|
infra), holding **only compiled installers, no source**. `tauri.conf.json`'s endpoint now points
|
||||||
|
there at a fixed `desktop-latest` tag (NOT that repo's generic "latest release" redirect, since
|
||||||
|
other apps publishing there would shadow ours — see the `desktop-latest` vs `desktop-<TAG>`
|
||||||
|
split below). `.gitea/workflows/release.yml` pushes to both repos: the private source repo (own
|
||||||
|
record) and the public mirror (what the updater and any human downloader actually use).
|
||||||
|
**Rejected alternative:** embedding a `read:repository` Gitea token in `tauri.conf.json`'s
|
||||||
|
updater `headers` so it could read the private repo directly — ruled out because that token would
|
||||||
|
ship inside every installed binary in the field, and this appliance's own threat model names the
|
||||||
|
**booth operator as the primary adversary** (see root `CLAUDE.md`); a leaked token scoped to the
|
||||||
|
whole private repo, with no cheap way to rotate it across appliances already in the field, was
|
||||||
|
judged worse than publishing installers-only.
|
||||||
|
- **Still deferred:** OS-level installer signing (Windows/macOS publisher trust) and the Windows
|
||||||
|
kiosk-browser fallback path.
|
||||||
|
|
||||||
### Desktop in CI — two workflows, two purposes (added 2026-06-24)
|
### Desktop in CI — two workflows, two purposes (added 2026-06-24)
|
||||||
|
|
||||||
@@ -174,7 +200,15 @@ The desktop bundle now runs in CI under **two distinct workflows** — keep the
|
|||||||
|
|
||||||
- **`.gitea/workflows/release.yml`** (tag `v*`) — the **signed, versioned release**: builds
|
- **`.gitea/workflows/release.yml`** (tag `v*`) — the **signed, versioned release**: builds
|
||||||
`.deb`/`.rpm`/`.AppImage` **+ their `.sig`** (updater key from secrets), assembles `latest.json`,
|
`.deb`/`.rpm`/`.AppImage` **+ their `.sig`** (updater key from secrets), assembles `latest.json`,
|
||||||
and publishes a Gitea Release. This is what the auto-updater consumes. Unchanged.
|
and publishes a Gitea Release **on `mca/parking_solution` (source, own record) AND mirrors it to
|
||||||
|
`mca/public_releases`** (public, installers-only — see the update-hosting-endpoint entry above for
|
||||||
|
why). The mirror step uses a second token, `RELEASES_MIRROR_TOKEN`
|
||||||
|
(`write:repository`, scoped for pushing into `public_releases` only — a CI-side secret, never
|
||||||
|
shipped to any client, distinct from the embedded updater *pubkey*). It publishes two tags there:
|
||||||
|
`desktop-<TAG>` (versioned, permanent, for audit/rollback) and `desktop-latest` (moving — existing
|
||||||
|
assets deleted then re-uploaded each release, since Gitea has no per-app "latest" concept and this
|
||||||
|
repo is shared across apps). `latest.json`'s asset URL and `tauri.conf.json`'s updater endpoint
|
||||||
|
both point at `desktop-latest`. This is what the auto-updater actually consumes.
|
||||||
- **`.gitea/workflows/build-desktop.yml`** (push to `dev`/`main`) — a **per-commit test build**:
|
- **`.gitea/workflows/build-desktop.yml`** (push to `dev`/`main`) — a **per-commit test build**:
|
||||||
compiles `.deb` + `.AppImage` only (`pnpm --filter @parking/desktop bundle --bundles deb,appimage`)
|
compiles `.deb` + `.AppImage` only (`pnpm --filter @parking/desktop bundle --bundles deb,appimage`)
|
||||||
and publishes them to a **rolling per-branch pre-release** (tag `desktop-<branch>`). **Unsigned** —
|
and publishes them to a **rolling per-branch pre-release** (tag `desktop-<branch>`). **Unsigned** —
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
type: decision
|
type: decision
|
||||||
tags: [parking, deployment, fleet, komodo, netbird, offline-first, threat-model]
|
tags: [parking, deployment, fleet, komodo, netbird, offline-first, threat-model]
|
||||||
sources: []
|
sources: []
|
||||||
updated: 2026-06-29
|
updated: 2026-07-07
|
||||||
status: settled
|
status: settled
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -176,3 +176,19 @@ here so it isn't re-litigated.
|
|||||||
- Companion: the `komodo/` infra-as-code sketch (in the repo, not the wiki),
|
- Companion: the `komodo/` infra-as-code sketch (in the repo, not the wiki),
|
||||||
[[appliance-provisioning]] (what runs *before* Periphery), [[disk-os-hardening]] (the
|
[[appliance-provisioning]] (what runs *before* Periphery), [[disk-os-hardening]] (the
|
||||||
appliance's hardening surface).
|
appliance's hardening surface).
|
||||||
|
|
||||||
|
## park-lab — the lab bench joins the fleet (2026-07-07)
|
||||||
|
|
||||||
|
Second `[[stack]]` in `komodo/resources.toml`: **`park-lab`** (server = the lab box's Periphery
|
||||||
|
`connect_as`), the first non-booth member and the proof of the tier model in practice:
|
||||||
|
|
||||||
|
| Stack | compose branch | image tag | secrets |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| park-lab | `dev` | **moving `dev`** (a lab may float) | `park_lab_*` |
|
||||||
|
| park-buzi | `stage` | pinned `stage-<sha>` | `park_buzi_*` |
|
||||||
|
|
||||||
|
The three knobs are independent per stack — the ResourceSync's own branch only governs where the
|
||||||
|
FILE is read from, each stack's `branch` picks its compose files, `TAG` picks the image. Per-box
|
||||||
|
secrets even in the lab (blast radius). The lab box earned its keep immediately: it caught the
|
||||||
|
USB close-cancel truncation, the printer/controller wizard gate, and the Periphery v2.2.0
|
||||||
|
root_directory default before any of them reached a real booth ([[appliance-provisioning]] §7a).
|
||||||
|
|||||||
+136
-2
@@ -1,8 +1,8 @@
|
|||||||
---
|
---
|
||||||
type: entity
|
type: entity
|
||||||
tags: [parking, hardware, readers, offline-first]
|
tags: [parking, hardware, readers, offline-first]
|
||||||
sources: [parking-system-architecture]
|
sources: [parking-system-architecture, DS-2CD1047G3H-LIU]
|
||||||
updated: 2026-06-27
|
updated: 2026-08-23
|
||||||
---
|
---
|
||||||
|
|
||||||
# LPR Camera
|
# LPR Camera
|
||||||
@@ -110,6 +110,140 @@ Covered by `packages/devices/src/drivers/camera.test.ts` (retry behaviour + the
|
|||||||
selection). `healthCheck()` deliberately reports a live 503 as `degraded` (it surfaces a genuinely
|
selection). `healthCheck()` deliberately reports a live 503 as `degraded` (it surfaces a genuinely
|
||||||
saturated main stream rather than hiding it behind a retry).
|
saturated main stream rather than hiding it behind a retry).
|
||||||
|
|
||||||
|
### Main-stream ISAPI snapshot 503 is model-specific, not config — and RTSP routes around it (2026-08-23)
|
||||||
|
|
||||||
|
Live comparison, same site (park-buzi), same day, both cameras reachable via the site's port
|
||||||
|
forwards (`park-buzi.msai.al:8081` / `:8082`) and directly on the LAN (`10.0.10.13` = the `:8082`
|
||||||
|
unit):
|
||||||
|
|
||||||
|
| | `:8081` (works) | `:8082` = `10.0.10.13` (503s) |
|
||||||
|
|---|---|---|
|
||||||
|
| Model | **DS-2CD1043G2-LIU** | **DS-2CD1047G3H-LIU** |
|
||||||
|
| Firmware | V5.8.10 | V5.8.11 |
|
||||||
|
| Channel 101 config | 2560×1440, VBR, 6144 Kbps cap, 20fps | **identical** — 2560×1440, VBR, 6144 Kbps cap, 20fps |
|
||||||
|
| `SmartCodec` | disabled | disabled |
|
||||||
|
| `GET /ISAPI/Streaming/channels/101/picture` | **200**, valid JPEG | **503**, `statusCode 2 / deviceBusy` (3/3 retries, instant) |
|
||||||
|
| `GET /ISAPI/Streaming/channels/102/picture` (sub) | — | **200**, valid JPEG |
|
||||||
|
|
||||||
|
Channel-101 config is **byte-identical** between the two units (bitrate, resolution, frame rate,
|
||||||
|
SmartCodec) — this rules out "misconfigured over some ceiling" definitively; the only things that
|
||||||
|
differ are model + firmware. Combined with the [[#Source: HIKVISION DS-2CD1047G3H-LIU-F datasheet|
|
||||||
|
vendor datasheet]] fact that the G3H's **main stream has no MJPEG option** (sub-stream does), the
|
||||||
|
working theory is that this SKU's snapshot codepath has to transcode a live H.264/H.265 frame into
|
||||||
|
JPEG on demand for main, and its firmware/encoder can't do that reliably at this resolution —
|
||||||
|
while sub can serve JPEG more natively. **Treat this as a `DS-2CD1047G3H-LIU`-model limitation
|
||||||
|
(this firmware line), not a config or ISAPI-usage bug** — matches every earlier finding on this
|
||||||
|
same unit (`10.0.10.13`) in the sections below, now cross-confirmed against a working sibling model
|
||||||
|
on the same network with identical settings.
|
||||||
|
|
||||||
|
**RTSP main-stream frame-grab works and routes around it entirely**, confirmed live against
|
||||||
|
`10.0.10.13`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ffmpeg -rtsp_transport tcp -y \
|
||||||
|
-i "rtsp://admin:<pw>@10.0.10.13:554/Streaming/Channels/101" \
|
||||||
|
-frames:v 1 -update 1 snapshot.jpg
|
||||||
|
```
|
||||||
|
|
||||||
|
Returned a valid 2560×1440 JPEG (94 KB) on the first try — same camera, same main-stream
|
||||||
|
resolution the ISAPI endpoint 503s on. This makes sense mechanically: RTSP just taps the H.264
|
||||||
|
stream the encoder is **already producing continuously** for live-view/recording; there's no
|
||||||
|
on-demand "pause and re-encode as standalone JPEG" step for the firmware to choke on, unlike the
|
||||||
|
ISAPI snapshot path. Port 554 is open on the LAN (`10.0.10.13`) but **not** forwarded through the
|
||||||
|
site's public port-forward (`park-buzi.msai.al` only exposes the HTTP/ISAPI ports, consistent with
|
||||||
|
[[network-isolation|the camera/controller network staying LAN-only]] — RTSP was only reachable
|
||||||
|
from inside the site network, never tested through the public forward).
|
||||||
|
|
||||||
|
**Not yet built**: `packages/devices/src/drivers/camera.ts` is HTTP-Digest/ISAPI only today: no
|
||||||
|
RTSP client, no `ffmpeg` child-process dependency. Adding an RTSP fallback (or RTSP-first path for
|
||||||
|
cameras that report persistent `deviceBusy` on ISAPI main) is a real architectural addition — new
|
||||||
|
process-spawn dependency, RTSP auth handling, transport selection (TCP confirmed working; UDP
|
||||||
|
untested) — not implemented as of this writing.
|
||||||
|
|
||||||
|
**Escalated from "nice to have" to a real requirement (2026-08-23): the sub-stream (768×432) is
|
||||||
|
too weak for reliable plate reads** — it fails to read plates "from time to time" in practice, so
|
||||||
|
sub-stream-only is not an acceptable permanent mitigation for this camera; RTSP-for-main is needed
|
||||||
|
for ANPR accuracy, not just for a higher-res evidence photo.
|
||||||
|
|
||||||
|
**Firmware update tested and RULED OUT as the fix (2026-08-23).** Before building the RTSP path,
|
||||||
|
checked whether this was simply a day-one bug: the camera shipped on `V5.8.11` build 250415 —
|
||||||
|
confirmed via the official Hikvision release note to be **the very first H13U firmware build that
|
||||||
|
added support for the DS-2CD1XX7G3H-LIU family at all** ("Newly add 1 series 4MP fixed-focus
|
||||||
|
cameras: DS-2CD1XX7G3H-LIU"), a plausible day-one-bug candidate. Upgraded live to **`V5.11.0` build
|
||||||
|
260701** (over a year of firmware progress, incl. an intermediate `V5.8.21_SP1` release explicitly
|
||||||
|
noting "Fix network and image potential bugs to enhance device stability"). **Result: NO CHANGE.**
|
||||||
|
Post-upgrade, `channels/101/picture` still returns `HTTP 503 / statusCode 2 / deviceBusy`, 5/5
|
||||||
|
consecutive attempts, byte-identical error body to pre-upgrade. Sub-stream (`102`) still healthy
|
||||||
|
(200, ~15KB) — camera is fine post-upgrade, just this one limitation persists.
|
||||||
|
|
||||||
|
**Superseded finding, below: "durable hardware/encoder ceiling" was the wrong framing.** At this
|
||||||
|
point in the investigation it looked like a real capacity limit (reproduced across ~15 months of
|
||||||
|
firmware). The channel-sweep test below shows that's not what's actually happening.
|
||||||
|
|
||||||
|
### The real cause: a broken/incomplete ISAPI snapshot handler, not "busy" (2026-08-23)
|
||||||
|
|
||||||
|
`deviceBusy` never meant "busy." Swept every channel/stream ID against the snapshot endpoint in
|
||||||
|
one sitting, including IDs that don't exist on this camera at all:
|
||||||
|
|
||||||
|
| channel | `GET .../channels/<id>/picture` |
|
||||||
|
|---|---|
|
||||||
|
| 1 | 503 `deviceBusy` |
|
||||||
|
| 100 | 503 `deviceBusy` |
|
||||||
|
| **101** (real main) | **503 `deviceBusy`** |
|
||||||
|
| **102** (real sub) | **200 OK** |
|
||||||
|
| 103 | 503 `deviceBusy` |
|
||||||
|
| 201 (channel 2 doesn't exist — single-channel camera) | 503 `deviceBusy` |
|
||||||
|
| 999 (garbage) | 503 `deviceBusy` |
|
||||||
|
|
||||||
|
**Every ID fails identically except exactly `102`.** A genuinely busy/saturated encoder would not
|
||||||
|
succeed on one specific value and fail the same way on nonexistent channel IDs — a real resource
|
||||||
|
contention error would 404 or behave differently on garbage input, not return the identical `Device
|
||||||
|
Busy` XML body regardless of whether the target exists. This is the signature of a **generic
|
||||||
|
fallback error path**: the firmware's snapshot handler appears to only be correctly wired for
|
||||||
|
`102` (the one channel/stream combination Hikvision evidently tested for this SKU) and returns a
|
||||||
|
stock, misleading `deviceBusy` for every other case — valid main-stream `101` included. It is a
|
||||||
|
**firmware bug that mislabels itself as resource contention**, not a real capacity ceiling — which
|
||||||
|
also fits the firmware-upgrade non-result above (a wrong-code-path bug doesn't get fixed by
|
||||||
|
"more capacity," so no firmware version fixing it would be surprising).
|
||||||
|
|
||||||
|
**Decision (2026-08-23): replace this camera line rather than build around it.** RTSP main-stream
|
||||||
|
capture is proven to work (see above) and could still be built as a `camera.ts` addition, but given
|
||||||
|
the ISAPI snapshot path is flatly broken for anything but one hardcoded channel value, and the
|
||||||
|
site's actual need (reliable plate reads — sub-stream alone isn't accurate enough) requires
|
||||||
|
full-resolution captures, the owner chose to swap out the `DS-2CD1047G3H-LIU` units rather than
|
||||||
|
carry a `ffmpeg`/RTSP dependency to route around a vendor firmware bug. The working `DS-2CD1043G2-
|
||||||
|
LIU` (`:8081` in the comparison above) has no such issue — ISAPI main-stream snapshot works
|
||||||
|
natively — and is the reference model for replacements. RTSP-frame-grab remains documented above
|
||||||
|
as a viable fallback if a `G3H`-family camera is ever unavoidable.
|
||||||
|
|
||||||
|
## Clock sync — the 1970 power-cut reset (built 2026-07-07)
|
||||||
|
|
||||||
|
Field observation (park-buzi): after a power cut these cameras come back with their clock at the
|
||||||
|
**1970 epoch** (no/dead RTC battery, no NTP) and stay there until a human logs into the web UI —
|
||||||
|
Hikvision's web login silently pushes the browser clock. A wrong camera clock corrupts the OSD
|
||||||
|
timestamp burned into every snapshot (the evidence trail) and the times on ANPR pushes.
|
||||||
|
|
||||||
|
Built: the HOST is the site's time authority (offline-first — no NTP infra dependency), and the
|
||||||
|
device monitor re-syncs each Hikvision camera over the same Digest-auth ISAPI used for snapshots:
|
||||||
|
|
||||||
|
- **Trigger:** the camera's **offline → ready transition** (exactly the power-restored moment) +
|
||||||
|
a **24h backstop**; the attempt timestamp is stamped BEFORE the async call, so a failing camera
|
||||||
|
retries at backstop cadence, never every 8s poll.
|
||||||
|
- **Mechanics** (`HikvisionCamera.syncClock`): `GET /ISAPI/System/time`, parse `localTime`; drift ≤
|
||||||
|
60s → leave alone. Beyond that → `PUT /ISAPI/System/time` with `timeMode=manual`, the site's
|
||||||
|
wall-clock now WITH explicit utc offset (`localIsoWithOffset(siteTz)`, e.g.
|
||||||
|
`2026-07-07T15:30:22+02:00` — the offset makes the instant unambiguous), and the camera's own
|
||||||
|
`timeZone` string **echoed back verbatim** (we correct the clock, never fight its tz/DST config).
|
||||||
|
An unparseable camera reply counts as infinite drift → sync.
|
||||||
|
- **Visibility:** a sync after a big jump (>1h — the power-cut signature) logs at **warn**
|
||||||
|
(persisted to app_logs); small corrections log info. Failures log warn.
|
||||||
|
- **Scope:** Hikvision only (`isClockSyncable` capability guard); the Dahua driver's CGI has no
|
||||||
|
ISAPI time endpoint — a Dahua clock sync would be its own driver work.
|
||||||
|
- Rejected alternative: camera-side **NTP against the booth** (chrony on the appliance). More
|
||||||
|
standard, but adds a provisioning dependency per booth and the camera polls NTP on ITS schedule
|
||||||
|
— a freshly power-cycled camera could still sit at 1970 for a while, which is precisely the
|
||||||
|
moment that matters.
|
||||||
|
|
||||||
## Camera PUSH — "Alarm Server" event notifications (2026-06-22)
|
## Camera PUSH — "Alarm Server" event notifications (2026-06-22)
|
||||||
|
|
||||||
Separate from the **pull** snapshot path above: newer Hikvision firmware can **push** an event to
|
Separate from the **pull** snapshot path above: newer Hikvision firmware can **push** an event to
|
||||||
|
|||||||
@@ -49,8 +49,9 @@ many ESC/POS-compatible OEM clones that share its firmware). Driver `rongta` in
|
|||||||
- **Two printers configured (as of 2026-06-18)**, by role — see [[printer-roles-failover]]:
|
- **Two printers configured (as of 2026-06-18)**, by role — see [[printer-roles-failover]]:
|
||||||
- **entry-dispenser** — `10.0.10.9`, outside at the lane; the driver takes the entry
|
- **entry-dispenser** — `10.0.10.9`, outside at the lane; the driver takes the entry
|
||||||
ticket. This unit is a **Cashino**, not a Rongta: it prints the same ESC/POS stream but
|
ticket. This unit is a **Cashino**, not a Rongta: it prints the same ESC/POS stream but
|
||||||
serves no `/prn_stat.htm` status page, so it runs on the `cashino` driver (reachability
|
serves no `/prn_stat.htm` status page, so it runs on the generic ESC/POS driver
|
||||||
PING only, no paper/cover status) — see [[printer-status-monitoring]].
|
(id `escpos` — renamed from `cashino` 2026-07-06, migration 0023; reachability PING
|
||||||
|
only, no paper/cover status) — see [[printer-status-monitoring]], [[printer-usb-transport]].
|
||||||
- **booth-receipt** — `10.0.10.10`, inside the booth; receipts, AND the backup that prints
|
- **booth-receipt** — `10.0.10.10`, inside the booth; receipts, AND the backup that prints
|
||||||
the entry ticket if the outside dispenser is offline. This unit is a **Rongta** (`rongta`
|
the entry ticket if the outside dispenser is offline. This unit is a **Rongta** (`rongta`
|
||||||
driver, full status-page monitoring).
|
driver, full status-page monitoring).
|
||||||
|
|||||||
+9
-6
@@ -7,7 +7,7 @@ updated: 2026-07-02
|
|||||||
# Index
|
# Index
|
||||||
|
|
||||||
Content catalog for the wiki. Start at [[overview]]. Maintained on every ingest.
|
Content catalog for the wiki. Start at [[overview]]. Maintained on every ingest.
|
||||||
Counts: 4 sources · 19 entities · 47 concepts · 7 decision records.
|
Counts: 4 sources · 19 entities · 47 concepts · 8 decision records.
|
||||||
|
|
||||||
## Overview & navigation
|
## Overview & navigation
|
||||||
- [[overview]] — the top-level synthesis and entry point.
|
- [[overview]] — the top-level synthesis and entry point.
|
||||||
@@ -19,6 +19,7 @@ Counts: 4 sources · 19 entities · 47 concepts · 7 decision records.
|
|||||||
- [[dingtian-dt008]] — Dingtian DT-008 product page: QR/RFID access reader (QR/barcode + ID/IC/NFC; Wiegand/TCP-IP/USB/RS485; HTTP-GET push).
|
- [[dingtian-dt008]] — Dingtian DT-008 product page: QR/RFID access reader (QR/barcode + ID/IC/NFC; Wiegand/TCP-IP/USB/RS485; HTTP-GET push).
|
||||||
- [[qrcode-sdk]] — QRCode SDK v1.6.5: the reader's HTTP-GET-poll protocol + JSON verdict (beep/output).
|
- [[qrcode-sdk]] — QRCode SDK v1.6.5: the reader's HTTP-GET-poll protocol + JSON verdict (beep/output).
|
||||||
- [[parksql2017-legacy-schema]] — predecessor SQL Server schema (Albanian market): legacy tariff/discount/membership/shift/fiscal model; confirms blocks, adds time-windows + categories, lacks postpaid sponsors.
|
- [[parksql2017-legacy-schema]] — predecessor SQL Server schema (Albanian market): legacy tariff/discount/membership/shift/fiscal model; confirms blocks, adds time-windows + categories, lacks postpaid sponsors.
|
||||||
|
- [[ds-2cd1047g3h-liu]] — HIKVISION DS-2CD1047G3H-LIU-F datasheet: 4MP, main stream has no MJPEG option (sub does) — likely explains the model's persistent ISAPI snapshot 503 on main; confirms ISAPI/RTSP both fully in-spec.
|
||||||
|
|
||||||
## Entities — technology stack
|
## Entities — technology stack
|
||||||
- [[technology-stack]] — the full stack table; all MIT/Apache/BSD, chosen to avoid lock-in.
|
- [[technology-stack]] — the full stack table; all MIT/Apache/BSD, chosen to avoid lock-in.
|
||||||
@@ -39,7 +40,7 @@ Counts: 4 sources · 19 entities · 47 concepts · 7 decision records.
|
|||||||
- [[esp32-custom-controller]] — prevention-grade upgrade; device-level auth.
|
- [[esp32-custom-controller]] — prevention-grade upgrade; device-level auth.
|
||||||
- [[atecc608]] — secure element; non-extractable signing key (host events + controller auth).
|
- [[atecc608]] — secure element; non-extractable signing key (host events + controller auth).
|
||||||
- [[wiegand]] — reader standard feeding the controller directly (autonomous permit-holder path).
|
- [[wiegand]] — reader standard feeding the controller directly (autonomous permit-holder path).
|
||||||
- [[lpr-camera]] — edge-AI plate recognition; host-side casual-identity source.
|
- [[lpr-camera]] — edge-AI plate recognition; host-side casual-identity source; DS-2CD1047G3H-LIU main-stream ISAPI snapshot 503 is a firmware bug (only channel 102 ever works, ALL other IDs incl. garbage 503 identically) — firmware update tested, no fix; owner decided to replace the camera line rather than build an RTSP workaround.
|
||||||
- [[dingtian-dt008-reader]] — Dingtian DT-008 QR/RFID reader on hand; host-side serial → `read` bus (the QR-ticket scanner).
|
- [[dingtian-dt008-reader]] — Dingtian DT-008 QR/RFID reader on hand; host-side serial → `read` bus (the QR-ticket scanner).
|
||||||
- [[zkteco-controller]] — ❌ rejected/historical; aux-input path was a contender, not pursued.
|
- [[zkteco-controller]] — ❌ rejected/historical; aux-input path was a contender, not pursued.
|
||||||
- [[dingtian-relay]] — ✅ CHOSEN access controller; decoupled inputs solve the button blocker (driver verified on hardware); spare relays drive aux outputs (`setAux`).
|
- [[dingtian-relay]] — ✅ CHOSEN access controller; decoupled inputs solve the button blocker (driver verified on hardware); spare relays drive aux outputs (`setAux`).
|
||||||
@@ -57,7 +58,7 @@ Counts: 4 sources · 19 entities · 47 concepts · 7 decision records.
|
|||||||
- [[hardware-signer-options]] — where the ledger signing key should live (TPM interim → USB-HSM target; ATECC608 upcoming, not on-site) so a host-owner can't forge the chain.
|
- [[hardware-signer-options]] — where the ledger signing key should live (TPM interim → USB-HSM target; ATECC608 upcoming, not on-site) so a host-owner can't forge the chain.
|
||||||
- [[reconciliation]] — the real anti-fraud control; what remote sync actually is.
|
- [[reconciliation]] — the real anti-fraud control; what remote sync actually is.
|
||||||
- [[disk-os-hardening]] — the *why* of host hardening: LUKS FDE + TPM-sealed auto-unlock (PCR 7) + Secure Boot + GRUB edit-lock + unprivileged operator + firmware/dbx lockdown; secondary control (reconciliation is the main event). Commands → [[appliance-provisioning]].
|
- [[disk-os-hardening]] — the *why* of host hardening: LUKS FDE + TPM-sealed auto-unlock (PCR 7) + Secure Boot + GRUB edit-lock + unprivileged operator + firmware/dbx lockdown; secondary control (reconciliation is the main event). Commands → [[appliance-provisioning]].
|
||||||
- [[backup-recovery]] — admin-driven encrypted full-DB backup (local/SMB/SFTP) + DR; signing key escrowed & decoupled from TPM so the ledger survives total hardware loss; restore is admin-only.
|
- [[backup-recovery]] — admin-driven encrypted full-DB backup (local/SMB/SFTP) + DR; signing key escrowed & decoupled from TPM so the ledger survives total hardware loss; restore is admin-only; last-success/error status + schedule are now restart-durable (migration 0025, fixed a "shows Never despite valid backups" bug).
|
||||||
|
|
||||||
## Concepts — device architecture & safety
|
## Concepts — device architecture & safety
|
||||||
- [[device-adapter-pattern]] — business logic talks to interfaces; swap hardware → new adapter.
|
- [[device-adapter-pattern]] — business logic talks to interfaces; swap hardware → new adapter.
|
||||||
@@ -68,7 +69,7 @@ Counts: 4 sources · 19 entities · 47 concepts · 7 decision records.
|
|||||||
- [[barrier-not-a-door]] — never timed-close a barrier; safety lives in barrier firmware.
|
- [[barrier-not-a-door]] — never timed-close a barrier; safety lives in barrier firmware.
|
||||||
- [[printer-roles-failover]] — ≥2 printers by role; entry ticket falls back outside→booth.
|
- [[printer-roles-failover]] — ≥2 printers by role; entry ticket falls back outside→booth.
|
||||||
- [[printer-status-monitoring]] — live poll of paper/cover/cutter/offline via the device's status page; SSE to the booth UI.
|
- [[printer-status-monitoring]] — live poll of paper/cover/cutter/offline via the device's status page; SSE to the booth UI.
|
||||||
- [[printer-usb-transport]] — ESC/POS drivers drive TCP (9100) OR local USB (/dev/usb/lp0) behind one render layer; USB = usblp char device, reachability-only status; provisioning open (oq#14).
|
- [[printer-usb-transport]] — ESC/POS drivers drive TCP (9100) OR local USB (/dev/usb/lp0) behind one render layer; USB = usblp char device, reachability-only status; provisioning open (oq#14); park-buzi cover-open-wedges-USB-status bug (docker restart-only fix) under investigation.
|
||||||
- [[device-status-monitoring]] — unified live status across ALL device categories (healthCheck + printer readStatus) → the booth footer over /api/ws.
|
- [[device-status-monitoring]] — unified live status across ALL device categories (healthCheck + printer readStatus) → the booth footer over /api/ws.
|
||||||
- [[trust-boundary]] — the core fork: network vs. device; auditable vs. unforgeable.
|
- [[trust-boundary]] — the core fork: network vs. device; auditable vs. unforgeable.
|
||||||
- [[fail-state-safety]] — entry fails closed, exit fails open; manual override; watchdog.
|
- [[fail-state-safety]] — entry fails closed, exit fails open; manual override; watchdog.
|
||||||
@@ -90,6 +91,7 @@ Counts: 4 sources · 19 entities · 47 concepts · 7 decision records.
|
|||||||
- [[parking-session]] — the core domain entity; a projection over the signed log, never a mutable table.
|
- [[parking-session]] — the core domain entity; a projection over the signed log, never a mutable table.
|
||||||
- [[tariff]] — fee model; pure, data-driven, offline; pay-on-foot adds a walk-back grace window.
|
- [[tariff]] — fee model; pure, data-driven, offline; pay-on-foot adds a walk-back grace window.
|
||||||
- [[tariff-time-tiers]] — BUILT (V2 tariff): happy-hour/off-peak/weekend/seasonal + vehicle category + flat rate via wall-clock windowed cards; tz frozen per version.
|
- [[tariff-time-tiers]] — BUILT (V2 tariff): happy-hour/off-peak/weekend/seasonal + vehicle category + flat rate via wall-clock windowed cards; tz frozen per version.
|
||||||
|
- [[tariff-industry-survey]] — 2026-07 web survey of industry tariff structures: coverage map vs our engine; gaps = early bird (pick-table-by-entry-time) + merchant validations; per-minute & dynamic pricing ruled out.
|
||||||
- [[booth-exit-flow]] — manned booth: pay → voucher (self-exit later) or immediate exit; active sessions; audited barrier re-open.
|
- [[booth-exit-flow]] — manned booth: pay → voucher (self-exit later) or immediate exit; active sessions; audited barrier re-open.
|
||||||
- [[operator-issued-entry]] — operator mints an entry ticket when the physical button is broken; presence-gated (radar AND camera, both sides), flagged (source=manual + operatorInitiated + anomaly), capacity-override allowed; needs `session:create` (2026-07-01).
|
- [[operator-issued-entry]] — operator mints an entry ticket when the physical button is broken; presence-gated (radar AND camera, both sides), flagged (source=manual + operatorInitiated + anomaly), capacity-override allowed; needs `session:create` (2026-07-01).
|
||||||
- [[plate-reconciliation]] — ANPR plate-as-invariant catches the ticket-swap fraud (paid car let out on a fresh $0 ticket, original lingers "inside"); exact/high-conf match vs open sessions; booth = flag + operator override, reader = log-only fail-open (2026-07-01).
|
- [[plate-reconciliation]] — ANPR plate-as-invariant catches the ticket-swap fraud (paid car let out on a fresh $0 ticket, original lingers "inside"); exact/high-conf match vs open sessions; booth = flag + operator override, reader = log-only fail-open (2026-07-01).
|
||||||
@@ -98,7 +100,7 @@ Counts: 4 sources · 19 entities · 47 concepts · 7 decision records.
|
|||||||
- [[capacity-occupancy]] — live count = open sessions; refuse entry + FULL sign when full (soft policy); exit never blocked.
|
- [[capacity-occupancy]] — live count = open sessions; refuse entry + FULL sign when full (soft policy); exit never blocked.
|
||||||
- [[site-metadata]] — optional park identity (name, operator, VAT, address, contact) in site_config; feeds the ticket header.
|
- [[site-metadata]] — optional park identity (name, operator, VAT, address, contact) in site_config; feeds the ticket header.
|
||||||
- [[valet-overcapacity]] — "full" is soft: operator may valet-accept over capacity (keys handed over, custody). Manned, deferred.
|
- [[valet-overcapacity]] — "full" is soft: operator may valet-accept over capacity (keys handed over, custody). Manned, deferred.
|
||||||
- [[validation-discounts]] — merchant validates a ticket → signed discount event applied at fee time.
|
- [[validation-discounts]] — BUILT (2026-07-13): in-park merchant (bar/lavazh) users scan-and-validate on their device (signed event, program↔user binding); booth settles NET + prints gross/discount/net; comp/time-credit/fixed/percent, caps, /setup/site panel, /validate screen.
|
||||||
- [[validation-sponsorship]] — design: sponsor accounts + postpaid B2B (customers park free, business billed monthly); not a permit.
|
- [[validation-sponsorship]] — design: sponsor accounts + postpaid B2B (customers park free, business billed monthly); not a permit.
|
||||||
- [[reporting-analytics]] — revenue/occupancy/stay reports + plate-search, all projections over the signed log.
|
- [[reporting-analytics]] — revenue/occupancy/stay reports + plate-search, all projections over the signed log.
|
||||||
- [[clock-integrity]] — fees depend on the host clock; detect/flag backdating on an offline box.
|
- [[clock-integrity]] — fees depend on the host clock; detect/flag backdating on an offline box.
|
||||||
@@ -126,12 +128,13 @@ Counts: 4 sources · 19 entities · 47 concepts · 7 decision records.
|
|||||||
- [[open-questions]] — 9 open items (procurement + JWT key + FX + pay-station money corners); ESP32 device auth deferred.
|
- [[open-questions]] — 9 open items (procurement + JWT key + FX + pay-station money corners); ESP32 device auth deferred.
|
||||||
- [[access-controller-button-flow]] — ✅ RESOLVED: Dingtian decoupled inputs enable ticket-first entry (was a UHPPOTE/ZKTeco blocker).
|
- [[access-controller-button-flow]] — ✅ RESOLVED: Dingtian decoupled inputs enable ticket-first entry (was a UHPPOTE/ZKTeco blocker).
|
||||||
- [[autonomous-direction]] — roadmap: toward fully unmanned (no booth); reshapes threat model + fail-state.
|
- [[autonomous-direction]] — roadmap: toward fully unmanned (no booth); reshapes threat model + fail-state.
|
||||||
|
- [[cloud-service-saas]] — 📌 POSTPONED: multi-tenant SaaS for fleet monitoring/control; productises the NetBird/Komodo control plane. Four tensions (offline-first vs real-time, verifiable-ledger-in-cloud, secrets custody, two-level tenancy); signing key stays on the booth; NetBird already solves isolation; remote barrier-open is `pulseOpen`+signed (the unmanned driver).
|
||||||
- [[dingtian-vs-mqtt]] — transport choice: direct HTTP/UDP now, MQTT parked until multi-lane scale.
|
- [[dingtian-vs-mqtt]] — transport choice: direct HTTP/UDP now, MQTT parked until multi-lane scale.
|
||||||
- [[session-model]] — business layer start: session = projection; transient-first; pay-on-foot. New event types.
|
- [[session-model]] — business layer start: session = projection; transient-first; pay-on-foot. New event types.
|
||||||
- [[vision-service]] — build a host-side ANPR + vehicle-verification service; replaces edge-LPR; scoped AGPL exception.
|
- [[vision-service]] — build a host-side ANPR + vehicle-verification service; replaces edge-LPR; scoped AGPL exception.
|
||||||
- [[vision-service-packaging]] — the vision service lives in this monorepo (apps/vision/), separate process, wired into Turbo via a package.json shim; uv-managed Python.
|
- [[vision-service-packaging]] — the vision service lives in this monorepo (apps/vision/), separate process, wired into Turbo via a package.json shim; uv-managed Python.
|
||||||
- [[event-streams-split]] — split the signed business ledger (ledger_events) from unsigned device telemetry (device_events).
|
- [[event-streams-split]] — split the signed business ledger (ledger_events) from unsigned device telemetry (device_events).
|
||||||
- [[desktop-shell-tauri]] — ✅ Tauri v2 chosen over Electron for the desktop kiosk shell; thin wrapper, server keeps all logic. Best case Ubuntu 26.04 LTS (resolves WebKitGTK); worst case Windows+WSL → kiosk browser, no native shell.
|
- [[desktop-shell-tauri]] — ✅ Tauri v2 chosen over Electron for the desktop kiosk shell; thin wrapper, server keeps all logic. Best case Ubuntu 26.04 LTS (resolves WebKitGTK); worst case Windows+WSL → kiosk browser, no native shell. Auto-updater mirrors signed releases to public `mca/public_releases` (source repo is private — field appliances have no Gitea creds).
|
||||||
- [[container-deployment]] — Docker images for the non-desktop apps: parking-server (Fastify API + bundled SPA via @fastify/static) + parking-vision (Python/uv ANPR); branch+SHA tags, per-env compose, Gitea registry, build-images.yml CI; pnpm deploy (not prune) for native better-sqlite3; migrate-at-boot.
|
- [[container-deployment]] — Docker images for the non-desktop apps: parking-server (Fastify API + bundled SPA via @fastify/static) + parking-vision (Python/uv ANPR); branch+SHA tags, per-env compose, Gitea registry, build-images.yml CI; pnpm deploy (not prune) for native better-sqlite3; migrate-at-boot.
|
||||||
- [[fleet-deployment-komodo]] — fleet control plane: Komodo Periphery on each booth, driven by Komodo Core over a NetBird mesh, running the same compose files. Deploys manual + pinned to dev-<sha> (no webhook); secrets Komodo-managed per-booth+unique; booth.sh demoted to break-glass. Threat-model caveats: Periphery is a root agent (mesh-bound only), EVENT_SIGNING_KEY-in-Core is a fraud-root blast radius until ATECC608 signs. komodo/ is infra-as-code.
|
- [[fleet-deployment-komodo]] — fleet control plane: Komodo Periphery on each booth, driven by Komodo Core over a NetBird mesh, running the same compose files. Deploys manual + pinned to dev-<sha> (no webhook); secrets Komodo-managed per-booth+unique; booth.sh demoted to break-glass. Threat-model caveats: Periphery is a root agent (mesh-bound only), EVENT_SIGNING_KEY-in-Core is a fraud-root blast radius until ATECC608 signs. komodo/ is infra-as-code.
|
||||||
- [[appliance-provisioning]] — booth-PC provisioning runbook (Dell 7070, i5-8500, discrete Nuvoton TPM): BIOS/Secure-Boot → direct-flash Ubuntu 26.04 USB (not Ventoy) → passphrase-LUKS install → manual PCR-7 TPM seal (workaround for the installer's dbt PCR_UNUSABLE error) → Docker. Verified on hardware 2026-06-23; TPM auto-unlock works.
|
- [[appliance-provisioning]] — booth-PC provisioning runbook (Dell 7070, i5-8500, discrete Nuvoton TPM): BIOS/Secure-Boot → direct-flash Ubuntu 26.04 USB (not Ventoy) → passphrase-LUKS install → manual PCR-7 TPM seal (workaround for the installer's dbt PCR_UNUSABLE error) → Docker. Verified on hardware 2026-06-23; TPM auto-unlock works.
|
||||||
|
|||||||
+434
@@ -2320,3 +2320,437 @@ Follow-up to the lab redesign (same session): the sidebar now also lists the PUB
|
|||||||
the row. Publishing a lab draft carries the draft's name onto the version; the composer page grew
|
the row. Publishing a lab draft carries the draft's name onto the version; the composer page grew
|
||||||
an optional version-name field (never prefilled — republishing a tweak under last season's name
|
an optional version-name field (never prefilled — republishing a tweak under last season's name
|
||||||
would mislabel history). Details on [[tariff]] (Tariff Lab section).
|
would mislabel history). Details on [[tariff]] (Tariff Lab section).
|
||||||
|
|
||||||
|
## [2026-07-05] update | Drawer hub: balance now, live daily activity, shift history
|
||||||
|
|
||||||
|
Operator: "/drawer is too simple — no daily activity, current shift incomings not reflected, closed
|
||||||
|
shifts history missing, drawer current state not shown." Rebuilt DrawerManager as a five-panel hub
|
||||||
|
(details on [[shift]] §Drawer hub): Drawer-now panel (new GET /api/drawer/balance exposing the
|
||||||
|
service's existing drawerBalance(); open-shift X-report breakdown alongside so float + takings +
|
||||||
|
vouchers = expected = balance is explicit), today's cash feed (existing /api/events since local
|
||||||
|
midnight, cash-only, live), the unchanged record/review flow, and a drawer-focused closed-shifts
|
||||||
|
list (existing scope-aware /api/shifts). No new ledger surface — one read-only endpoint, everything
|
||||||
|
else composes what the chain already records. RBAC test added (shift:read 200 / other 403 / anon 401).
|
||||||
|
|
||||||
|
## [2026-07-05] update | Drawer "this shift" figure + spinners on shift buttons; O(chain) flagged
|
||||||
|
|
||||||
|
Follow-ups to the drawer hub (same session): the Drawer-now panel gained "This shift: ±X"
|
||||||
|
(expected − opening float — the shift's own contribution vs what it inherited), and every shift
|
||||||
|
open/close button got an animated spinner + dim while busy (ui/Spinner.tsx) — the old label-swap
|
||||||
|
read as a dead click on a slow open. Root cause of the slowness recorded as an open item on
|
||||||
|
[[shift]]: drawer/shift reads fold the WHOLE chain (O(chain) — grows forever); fix when it bites =
|
||||||
|
fold from the last z-report's signed expectedDrawerMinor forward. Also from this session: dev-DB
|
||||||
|
migrations are MANUAL (pnpm db:migrate in packages/db) — a 500 "no such column" after pulling a
|
||||||
|
migration means it was skipped; booth containers migrate on boot and are immune.
|
||||||
|
|
||||||
|
## [2026-07-05] update | Camera health-check detail bucketed — device-monitor log noise fix
|
||||||
|
|
||||||
|
The monitor logs + re-emits a device status only when state OR detail changes, but the camera
|
||||||
|
probe's detail was the exact snapshot byte count — which differs on every JPEG frame, so healthy
|
||||||
|
cameras "changed" on nearly every poll (a log line + websocket event each, inflating the freshly
|
||||||
|
budgeted container logs). Camera healthCheck detail is now a stable power-of-two bucket
|
||||||
|
("snapshot ≈16 KB" / "≈256 KB"; "<1 KB" stays exact as a suspicious-frame flag), so it moves only
|
||||||
|
on a real shift (stream/resolution change, empty body). packages/devices camera.ts + 3 tests.
|
||||||
|
|
||||||
|
## [2026-07-05] query | Industry survey: most-used parking tariff systems
|
||||||
|
|
||||||
|
Web research filed as [[tariff-industry-survey]] (reference). Field taxonomy: linear-hourly per
|
||||||
|
started increment (per-MINUTE tried and rolled back — Leuven), degressive ladders, day caps,
|
||||||
|
grace; whole-stay prices (day tickets, up-to matrices, evening/overnight packages, event rates);
|
||||||
|
entry-time-conditioned EARLY BIRD (enter-before cutoff, anti-arbitrage exit conditions); calendar
|
||||||
|
windows (day/night, weekend/holiday, seasonal); category pricing; monthly contracts; merchant
|
||||||
|
VALIDATIONS (amount/percent/time-credit/full-comp/re-rate); dynamic demand pricing (SFpark 60-80%
|
||||||
|
occupancy banding — municipal/airport scale only). Coverage map: our engine expresses everything
|
||||||
|
a staffed single lot advertises; the two real gaps are early bird (= the pick-table-by-entry-time
|
||||||
|
future design, same mechanism as weekend menus) and validation overlays. Anti-features confirmed:
|
||||||
|
per-minute billing, dynamic pricing.
|
||||||
|
|
||||||
|
## [2026-07-05] update | Composer page gets the published-versions sidebar too
|
||||||
|
|
||||||
|
The published-history sidebar built for the lab landed only there; the operator expected it on
|
||||||
|
/setup/tariff as well. TariffComposer now mirrors it: right sidebar of published versions (name or
|
||||||
|
effective date, active badge), click → formFromVersion loads it into the editor as the seed for
|
||||||
|
the next publish (which, per the sidebar hint, always creates a NEW immutable version). Details on
|
||||||
|
[[tariff]] (Composer section).
|
||||||
|
|
||||||
|
## [2026-07-05] update | Reports dashboard: occupancy curve, hour×dow heatmap, stay histogram, fraud KPIs
|
||||||
|
|
||||||
|
Operator ask on /reports. Added the parking-shaped graphics (details on [[reporting-analytics]]):
|
||||||
|
occupancy step-area vs capacity line (prior-ledger fold for range-start, per-bucket occupancyEnd),
|
||||||
|
entries heatmap hour×day-of-week (replaces the flat hour histogram; feeds tariff-window design per
|
||||||
|
[[tariff-industry-survey]]), stay-duration histogram at tariff-shaped edges (30m/1h/2h/4h/8h/24h),
|
||||||
|
voids+anomalies KPI counters (accented when >0 — the look-closer signal), cash/card stacked revenue
|
||||||
|
bars, peak-occupancy KPI, richer CSV (cash/card/occupancy_end). Server: reports.ts aggregation +
|
||||||
|
Intl formatter cached per tz; db package re-exports lt/gt. 5 new tests (295 server green).
|
||||||
|
|
||||||
|
## [2026-07-06] update | USB printer truncation fixed (NONBLOCK partial write) — ICS XP-K200L
|
||||||
|
|
||||||
|
First on-hardware USB print test failed exactly as the transport bug predicts: text printed,
|
||||||
|
barcode + cut missing (both live in the dropped tail). sendRawUsb did ONE write() on an O_NONBLOCK
|
||||||
|
usblp fd and never checked bytesWritten — anything past the printer's ~8 KB USB buffer was
|
||||||
|
silently discarded; TCP was immune. Fixed with writeAllUsb (4 KB chunks, partial-write
|
||||||
|
continuation, EAGAIN retry, deadline with N/M diagnostic) + 4 fake-handle tests. Also verified on
|
||||||
|
hardware (10.0.10.11): the ICS XP-K200L serves NO /prn_stat.htm → on network use the cashino
|
||||||
|
driver (reachability-only), not rongta, or monitoring calls a working printer offline. Details on
|
||||||
|
[[printer-usb-transport]].
|
||||||
|
|
||||||
|
## [2026-07-06] update | Driver rename: "cashino" → "escpos" (generic ESC/POS printer)
|
||||||
|
|
||||||
|
The ICS XP-K200L exposed that the reachability-only clone driver carried its first unit's vendor
|
||||||
|
name — "cashino" in the setup UI was misleading for every other clone. Renamed properly:
|
||||||
|
printer-cashino.ts → printer-generic.ts, id "cashino" → "escpos", label "Generic ESC/POS 80mm
|
||||||
|
printer (Cashino, ICS/Xprinter…)". Stored rows rewritten by migration 0023; the registry keeps a
|
||||||
|
PERMANENT cashino→escpos alias so restored pre-rename backups still resolve. Prose mentions of the
|
||||||
|
Cashino as hardware stay (it's a real printer). Driver guidance for the XP-K200L: escpos on both
|
||||||
|
transports (it serves no /prn_stat.htm — verified; rongta would false-flag it). See
|
||||||
|
[[printer-usb-transport]], [[printer-status-monitoring]].
|
||||||
|
|
||||||
|
## [2026-07-06] update | Setup wizard: printers no longer forced to bind to a barrier relay
|
||||||
|
|
||||||
|
Operator hit the wizard's blanket "pick the controller and relay this device sits at" gate while
|
||||||
|
adding the ICS printer. The binding (controllerId+relay → which barrier a scan opens + inherited
|
||||||
|
direction) is load-bearing for READERS and CAMERAS only; nothing consumes it on a printer —
|
||||||
|
printer routing is role + failoverRank (printer-routing.ts). Wizard now skips the requirement,
|
||||||
|
hides the "Cilën barrierë shërben kjo pajisje?" panel, and stops persisting the binding for
|
||||||
|
printers (a stale pre-fix binding drops off on next edit); the device list shows the printer's
|
||||||
|
ROLE instead of a bogus amber "unbound". Server never required it (no validation change).
|
||||||
|
|
||||||
|
## [2026-07-06] update | Tariff Lab: fee breakdown — "how is this sum produced"
|
||||||
|
|
||||||
|
Operator: a lab outcome of "ALL 740 / 3h 2m" gave no derivation. Added explainFee to
|
||||||
|
@parking/shared: the SAME computeFee walk with an optional trace collector (zero fee change —
|
||||||
|
golden V1 regression still green), so Σ line items ≡ the amount by construction. Items: banded
|
||||||
|
same-price increment runs (time window · N × unit · card name), window-package occurrences,
|
||||||
|
stepped day totals (top-tier repeat flagged), daily-cap clamps as NEGATIVE adjustments, entry
|
||||||
|
grace. /api/tariff/simulate returns `breakdown` (null when settled); the lab's Outcome panel
|
||||||
|
renders it as a lined table with the rounding note (raw min → billed min at the increment) and a
|
||||||
|
total row. 4 new engine tests pin the sum invariant + item shapes. This also largely delivers the
|
||||||
|
wiki's open "composer price preview" item — see [[tariff]].
|
||||||
|
|
||||||
|
## [2026-07-06] update | Composer: increment-unit price labels + ≠60 warning (the 60→10 trap)
|
||||||
|
|
||||||
|
Operator walked into the sharp edge the wiki flat-rate warning had already named: ladder/flat
|
||||||
|
prices are PER BILLING INCREMENT, so changing "Intervali i faturimit" 60→10 silently multiplies
|
||||||
|
every price ×6, while the price header just said "Çmimi / interval". Composer now: price labels
|
||||||
|
are DYNAMIC ("Çmimi / orë" at 60, "Çmimi / {{N}} min" otherwise — same for the flat-mode radio),
|
||||||
|
and an amber warning appears whenever the increment ≠ 60 ("çdo çmim faturohet për çdo N minuta,
|
||||||
|
JO për orë"). Band DURATIONS stay in hours — they're real wall time, increment-independent (the
|
||||||
|
operator asked if "orë" there was wrong; it isn't). See [[tariff]] (§increment).
|
||||||
|
|
||||||
|
## [2026-07-06] update | UI-wide date standard ("25 Qer") + currency-scaled composer examples
|
||||||
|
|
||||||
|
Two operator UX complaints. (1) Dates were a mix of browser-locale "7/6/2026" (raw
|
||||||
|
toLocaleString) and catalog "25 Qershor" — unified: formatDate/formatDateTime/formatClock in
|
||||||
|
lib/format.ts using new common.monthsShort ("25 Qer 14:30", year only when ≠ current, 24h clock);
|
||||||
|
formatRelativeDateTime switched to short months; ALL ~20 raw toLocale* date call sites swept
|
||||||
|
(shifts, subs, plans, drawer, snapshots, device footer, event detail, tariff composer + lab incl.
|
||||||
|
the fee-breakdown row times). Number toLocaleString (thousand separators on money) untouched.
|
||||||
|
(2) Composer example defaults were euro-scaled ("2.00"/hour ≈ 2 lekë) — now currency-aware
|
||||||
|
(ALL: 200/100 ladder, 200/500 steps, 2000 lost ticket; EUR/USD keep 2/1/2/5/20), threaded through
|
||||||
|
emptyForm/emptyLadder/emptyTier/pricingFromCard so a mode switch on an ALL card also shows lek-
|
||||||
|
plausible templates. Blank-form currency stays ALL.
|
||||||
|
|
||||||
|
## [2026-07-06] update | seed-admin signs a ledger event; lost-app-admin-password runbook (§7e)
|
||||||
|
|
||||||
|
Follow-through on the FK fix: seed-admin.mjs now appends a signed config_change
|
||||||
|
(admin.passwordReset / admin.seeded, operator console:seed-admin) via the server's compiled
|
||||||
|
EventLog + signer from dist/ — a console reset by the Linux admin can't gate on the app, but it
|
||||||
|
stays attributable in the chain. Best-effort: no build/key → loud warning, seed still proceeds
|
||||||
|
(verified both paths on a scratch DB). [[appliance-provisioning]] gained §7e: FORCE=1 reset
|
||||||
|
commands (interactive preferred — keeps the password out of shell history), sessions-not-revoked
|
||||||
|
caveat + JWT_SECRET rotation for suspected theft, role-row self-heal note added to §7d.
|
||||||
|
|
||||||
|
## [2026-07-06] update | Runbook §5c: gpasswd -d, not deluser (hyphenated-username perl bug)
|
||||||
|
|
||||||
|
Demoting the operator on park-buzi hit `sanitize_string: invalid characters in 'park-operator'` —
|
||||||
|
Ubuntu's perl adduser/deluser tooling rejects the hyphenated username. [[appliance-provisioning]]
|
||||||
|
§5c now uses `gpasswd -d <operator> sudo|lxd|lpadmin` (shadow-suite, no perl sanitize) and notes
|
||||||
|
that group removal applies at NEXT login — the auto-login operator session keeps old memberships
|
||||||
|
until reboot/relog, so verify `groups` from inside the session afterwards.
|
||||||
|
|
||||||
|
## [2026-07-07] update | Periphery v2.2.0 --user installer writes /etc/komodo root_directory
|
||||||
|
|
||||||
|
Lab test box (park-test): periphery crash-looped at startup — panic writing the private key to
|
||||||
|
/etc/komodo/keys/periphery.key, Permission denied. Gotcha #9 was documented as a hand-config
|
||||||
|
hazard, but v2.2.0's installer now DEFAULTS root_directory to /etc/komodo even with --user (the
|
||||||
|
June park-buzi install defaulted under $HOME). [[appliance-provisioning]] §7a now says: verify
|
||||||
|
root_directory after every install + the sed one-liner fix; also notes `sudo systemctl restart
|
||||||
|
periphery` → "unit not found" (user unit) and that the single-use onboarding key survives a
|
||||||
|
pre-connect crash.
|
||||||
|
|
||||||
|
## [2026-07-07] update | Setup: printers addable with NO controller configured
|
||||||
|
|
||||||
|
Lab bench (USB printer test, no relays on hand) hit a SECOND printer/relay coupling the
|
||||||
|
2026-07-06 fix missed: the category section's add-button gate ("Shto fillimisht një kontroller…")
|
||||||
|
blocks every non-access category while zero controllers exist. Printers are now exempt there too
|
||||||
|
— the binding fix removed the requirement inside the form; this removes the gate in front of it.
|
||||||
|
A controller-less box can configure + test a printer.
|
||||||
|
|
||||||
|
## [2026-07-07] update | USB truncation, mode 2: close() kills the in-flight usblp URB
|
||||||
|
|
||||||
|
Lab hardware test of the chunked-write fix STILL truncated (slip stopped mid-sentence, no cut,
|
||||||
|
text hidden until the feed button). Root cause verified against kernel usblp.c: write() returns at
|
||||||
|
URB submission; one URB in flight; usblp_release (close) kills it; the printer drains at print
|
||||||
|
speed — so the accepted-but-untransferred tail (incl. feed+cut, always the last bytes) died at
|
||||||
|
close. writeAllUsb now holds back the FINAL byte as its own write — usblp's one-URB rule makes its
|
||||||
|
acceptance a completion certificate for everything before it — then drains 300ms for that single
|
||||||
|
packet before close. Tests updated (+ final-byte-alone assertion). [[printer-usb-transport]] has
|
||||||
|
the full kernel-level account.
|
||||||
|
|
||||||
|
## [2026-07-07] update | USB printing HARDWARE-VERIFIED; wizard lists real /dev/usb devices
|
||||||
|
|
||||||
|
Lab retest after the close-cancel fix: full slip + feed + CUT over USB — parity with TCP; the
|
||||||
|
transport is done. Follow-up UX (operator had to `ls /dev/usb` to find lp1 on park-buzi): new
|
||||||
|
GET /api/setup/usb-printers enumerates /dev/usb/lpN + sysfs ieee1284_id make/model; the wizard's
|
||||||
|
devicePath is now a select of PRESENT printers (fresh form preselects the first; a saved-but-
|
||||||
|
absent path stays selectable, flagged; none found → free-text + hint). Transport option label no
|
||||||
|
longer hardcodes lp0. Details on [[printer-usb-transport]].
|
||||||
|
|
||||||
|
## [2026-07-07] update | Camera clock sync via ISAPI — the 1970 power-cut reset healed
|
||||||
|
|
||||||
|
park-buzi observation: power-cut Hik cameras reboot at the 1970 epoch (no RTC battery, no NTP)
|
||||||
|
until a web-UI login pushes the browser clock — corrupting snapshot OSD timestamps (evidence) and
|
||||||
|
ANPR push times meanwhile. Built host-as-time-authority sync (details on [[lpr-camera]] §Clock
|
||||||
|
sync): device monitor triggers at the offline→ready edge + 24h backstop; HikvisionCamera.syncClock
|
||||||
|
GETs /ISAPI/System/time, and beyond 60s drift PUTs manual time with the site's wall-clock + explicit
|
||||||
|
offset, echoing the camera's timeZone verbatim; >1h jumps log warn (persisted). digest client
|
||||||
|
generalised GET→GET/PUT/POST with body (the handshake was already method-aware). Capability-guarded
|
||||||
|
(isClockSyncable — hikvision only). 8 new tests (5 devices, 3 tz-offset).
|
||||||
|
|
||||||
|
## [2026-07-07] lint | Wiki catch-up sweep after the lab-bench sprint
|
||||||
|
|
||||||
|
Audit found five pages lagging the log: [[rongta-printer]] still named the `cashino` driver id
|
||||||
|
(→ escpos + migration note); [[tariff-time-tiers]] still listed the composer price preview as
|
||||||
|
deferred (→ delivered by the lab fee breakdown); [[tariff]] lab section gained the breakdown +
|
||||||
|
composer increment-guard paragraph; [[i18n]] now records the "25 Qer 14:30" date standard and the
|
||||||
|
never-toLocaleString-for-dates rule; [[fleet-deployment-komodo]] gained the park-lab stack + tier
|
||||||
|
table (also logging the park-lab addition itself, which had slipped the log).
|
||||||
|
|
||||||
|
## [2026-07-08] update | Log-storm hardening + reset-db drift guard
|
||||||
|
|
||||||
|
Field incident 2026-07-07: an unreachable UHPPOTE (`ENETUNREACH 10.0.10.5:60000`) put the
|
||||||
|
button-light `#pump` worker in a zero-backoff hot loop — hundreds of identical `setAux failed`
|
||||||
|
error rows per minute into [[app-logs]]. Three-layer fix: (1) failed sends now arm a 1s→30s
|
||||||
|
exponential retry (reset on success), with only the first failure logged, one summary/minute
|
||||||
|
after, and one info on recovery ([[button-light-indicator]] §Implementation); (2) LogService
|
||||||
|
coalesces a row identical to the last (level+source+message+path, 5-min refreshing window) by
|
||||||
|
bumping `context._repeat` instead of inserting — the viewer badges `×N` ([[app-logs]]);
|
||||||
|
(3) the user's training reset had ALSO left logs behind: `app_logs` and `tariff_drafts` belonged
|
||||||
|
to no reset-db category, silently surviving even `--all`. Added `--diagnostics` (app_logs), put
|
||||||
|
tariff_drafts under `--config`, and a drift guard that refuses to run when any table is
|
||||||
|
uncategorized ([[local-dev-workflow]], [[appliance-provisioning]] §7d). 8 new tests
|
||||||
|
(3 button-light backoff, 5 coalescing); guard + both new wipes verified on a scratch DB.
|
||||||
|
|
||||||
|
## [2026-07-13] decision | Cloud service — multi-tenant SaaS (postponed, context captured)
|
||||||
|
From a design conversation, not a source. The user floated an online, multi-tenant SaaS (the
|
||||||
|
"cloud service") on TOP of the offline backup model (which stays, as the offline-site tradeoff):
|
||||||
|
subscribing park sites get real-time (link-up) monitoring of the signed ledger, device status,
|
||||||
|
and financial reports; one admin owns many sites; the cloud custodies per-site secrets; recurring
|
||||||
|
per-site fee = a revenue line. Recorded as [[cloud-service-saas]] (status: open, POSTPONED per the
|
||||||
|
user) so it isn't re-derived later. It productises the off-site control plane already stood up in
|
||||||
|
[[fleet-deployment-komodo]] (Komodo Core + NetBird). Captured: the four hard tensions (offline-first
|
||||||
|
vs real-time; the ledger must be VERIFIABLE not just displayed in the cloud; central secret custody;
|
||||||
|
two-level tenancy under operator-as-adversary), the secrets boundary the user confirmed (sync creds
|
||||||
|
+ device-password ESCROW + app identity — but NOT the signing/ATECC608 key, which stays on the
|
||||||
|
booth), and TWO in-discussion corrections that stand: (1) NetBird already solves the "cloud reaches
|
||||||
|
booth" isolation objection — park-buzi is monitored that way today, booth-dialed, nothing exposed;
|
||||||
|
(2) remote barrier-open is COMPATIBLE with [[barrier-not-a-door]] (it's `pulseOpen`/intent, never
|
||||||
|
timed-close) and is DRIVEN by the [[autonomous-direction]] unmanned future — gated as a distinct
|
||||||
|
privilege + a signed ledger event with actor+reason, with the booth as enforcer and a local
|
||||||
|
fail-open that can't depend on the cloud. Four open questions parked (real-time definition, where
|
||||||
|
reports are computed, hosting/licensing, custodianship-as-liability). Cross-linked; index count
|
||||||
|
7→8 decisions.
|
||||||
|
|
||||||
|
## [2026-07-13] update | Owner requirement — in-park merchant validations (car-wash "lavazh", bar)
|
||||||
|
|
||||||
|
The [[validation-discounts]] feature is now asked-for, not just an industry-survey gap: the park
|
||||||
|
may host an in-park car-wash and/or bar whose customers the owner wants discharged for the stay —
|
||||||
|
full comp, free-first-N-minutes (`time-credit`), or consumption-offset (`fixed`, variable amount:
|
||||||
|
300 ALL consumed vs 500 ALL fee → pay 200). Must be admin-composable at runtime like
|
||||||
|
tariffs/subscription plans. Driving-cases section added to [[validation-discounts]]. Open: merchant
|
||||||
|
ownership (owner-run → pure discount; tenant → [[validation-sponsorship]] settlement), who applies
|
||||||
|
(operator vs merchant code/portal), stacking rules, caps.
|
||||||
|
|
||||||
|
## [2026-07-13] update | Merchant validations refined — merchant STATIONS (users), not sponsors
|
||||||
|
|
||||||
|
Second pass on the [[validation-discounts]] requirement: ownership immaterial, sponsor layer
|
||||||
|
dropped. Merchant = a system user on their own device who scans the ticket to validate (signed,
|
||||||
|
attributed); admin checkbox per station = may collect parking payments (then shift + till +
|
||||||
|
Z-report apply to them like the booth); paid/zero-due tickets self-exit at the reader.
|
||||||
|
Consequences: per-station shifts/drawers (breaks the site-wide single-open invariant), exit-reader
|
||||||
|
live due=0 branch. Details on [[validation-discounts]].
|
||||||
|
|
||||||
|
## [2026-07-13] update | Merchant validations settled — validation-only merchants, all money at the booth
|
||||||
|
|
||||||
|
Third pass, settled: the merchant-collects-payments variant is REJECTED. Merchant users only scan
|
||||||
|
+ validate (signed, attributed); every car checks in at the booth to settle (net may be 0 — still
|
||||||
|
a signed payment) and gets the detailed gross/discount/net receipt there. Per-station
|
||||||
|
shifts/drawers and the exit-reader due=0 branch are no longer needed — shift/drawer/exit flows
|
||||||
|
stay as built; Z/X-reports gain discount lines. Build surface: validation_programs master data,
|
||||||
|
signed validation event, priceSession validations[] extension, merchant scan page, booth
|
||||||
|
quote/receipt/Z-report lines. Details on [[validation-discounts]].
|
||||||
|
|
||||||
|
## [2026-07-13] decision | Merchant validations — design SETTLED, build started
|
||||||
|
|
||||||
|
Setup UX on /setup/site (Bar/Lavazh checkboxes → right-column config panel, tabs when both);
|
||||||
|
fixed UI over generic storage (validation_programs + user binding, well-known bar/lavazh rows,
|
||||||
|
mutable config — the signed validation event carries resolved values); RBAC = new `validation`
|
||||||
|
resource (create/read), guard = permission AND station binding; merchant-only users land on
|
||||||
|
/validate; merchants may void their own unused validation. See [[validation-discounts]].
|
||||||
|
|
||||||
|
## [2026-07-13] update | Merchant validations BUILT end-to-end (bar / lavazh)
|
||||||
|
|
||||||
|
Shipped the settled design: `validation` permission + ledger event (resolved values, refId-void),
|
||||||
|
priceSession validations[] canonical fold (timeCredit→percent→fixed→comp, Σ lines ≡ gross−net),
|
||||||
|
validation_programs(+users) tables (migration 0024, reset-db config category), routes/validations.ts
|
||||||
|
(programs PUT signs config_change; apply guards: binding → open transient → no dup → maxPerDay →
|
||||||
|
amount cap; void own-unused-only), PayStation quote/pay/lookup net folding + payment consumption
|
||||||
|
(grossMinor/discountMinor/validationIds/validationLines), receipt gross+discount lines, Z/X-report
|
||||||
|
discountTotalMinor ("Zbritje (validime)", printed only when >0), /setup/site two-column Bar/Lavazh
|
||||||
|
checkboxes + config panel (tabs), /validate merchant screen (merchant-only users land there),
|
||||||
|
booth-modal gross→lines→net, feed label VALIDIM. 8 new route integration tests + shared fold suite;
|
||||||
|
workspace build/typecheck/test green. As-built + remaining polish on [[validation-discounts]].
|
||||||
|
|
||||||
|
## [2026-07-13] decision | Merchant scan input: HID barcode scanner on web/desktop; camera paths postponed
|
||||||
|
|
||||||
|
The bar/lavazh stations use a USB/HID scanner (or hand-keying + Luhn) into /validate on the
|
||||||
|
web/desktop app. Two evaluated camera alternatives deliberately POSTPONED: web getUserMedia
|
||||||
|
scanning (blocked on secure-context TLS for LAN phones + weak Code128-via-camera — would want
|
||||||
|
QR-on-ticket first) and a Tauri v2 Android merchant app (native ML Kit scanning via the official
|
||||||
|
barcode-scanner plugin; deferred over Android build/distribution overhead + the
|
||||||
|
configurable-server-URL prerequisite). Full analysis on [[validation-discounts]].
|
||||||
|
|
||||||
|
## [2026-08-23] ingest | HIKVISION DS-2CD1047G3H-LIU-F datasheet
|
||||||
|
|
||||||
|
Vendor datasheet dropped in `raw/DS-2CD1047G3H-LIU.md`. Key new fact: main stream on this model
|
||||||
|
supports H.265+/H.265/H.264+/H.264 only — **no MJPEG**; sub-stream adds MJPEG. Likely mechanical
|
||||||
|
explanation for the persistent ISAPI main-stream snapshot 503 (`deviceBusy`) already logged on this
|
||||||
|
model in [[lpr-camera]] (2026-06-26/27): the on-demand JPEG snapshot has no native path on main,
|
||||||
|
so it has to transcode from H.264/H.265 live, which this SKU's firmware apparently can't do
|
||||||
|
reliably at 2560×1440. Bitrate spec (32Kbps–16Mbps) also confirms the site's main-stream config
|
||||||
|
(6144–12288Kbps) was never out of range — rules out misconfiguration definitively.
|
||||||
|
|
||||||
|
## [2026-08-23] query | Main-stream 503: model-specific or config? RTSP as a workaround?
|
||||||
|
|
||||||
|
Live-compared two Hikvision units at park-buzi via their public port forwards
|
||||||
|
(`park-buzi.msai.al:8081`/`:8082`) plus the `:8082` unit directly on the LAN (`10.0.10.13`).
|
||||||
|
Channel-101 (main) config is **byte-identical** between a working `DS-2CD1043G2-LIU` (8081, 200 OK)
|
||||||
|
and the failing `DS-2CD1047G3H-LIU` (8082 / `10.0.10.13`, persistent 503 `deviceBusy`, 3/3 retries
|
||||||
|
instant) — same resolution/bitrate/framerate/SmartCodec state. Rules out config as the cause;
|
||||||
|
confirms it's model/firmware-specific (matches the datasheet finding above). Then tested RTSP
|
||||||
|
(`rtsp://…@10.0.10.13:554/Streaming/Channels/101` via ffmpeg, TCP transport) against the SAME
|
||||||
|
failing camera: returned a valid 2560×1440 JPEG on the first try. RTSP taps the continuously-
|
||||||
|
running encode rather than asking for an on-demand re-encoded JPEG, so it sidesteps whatever the
|
||||||
|
ISAPI snapshot path chokes on. Not yet built into `camera.ts` (would add an `ffmpeg` child-process
|
||||||
|
dependency + RTSP auth/transport handling) — filed as a viable, proven fallback if full-resolution
|
||||||
|
main-stream stills are ever needed; sub-stream ISAPI snapshot remains sufficient for current ANPR
|
||||||
|
use. Full comparison table + RTSP command on [[lpr-camera]].
|
||||||
|
|
||||||
|
## [2026-08-23] update | Firmware update tested and ruled out; sub-stream confirmed too weak for ANPR — RTSP is now required
|
||||||
|
|
||||||
|
Two developments on the DS-2CD1047G3H-LIU (`10.0.10.13`) main-stream 503: (1) the owner reports the
|
||||||
|
sub-stream (768×432) **fails to read plates "from time to time"** in real use — sub-stream-only is
|
||||||
|
no longer an acceptable mitigation, it's an accuracy problem. (2) Before building RTSP, tested
|
||||||
|
whether this was a day-one firmware bug: the camera's original `V5.8.11`/250415 build was confirmed
|
||||||
|
(via Hikvision's own release note) to be the FIRST H13U firmware to support this camera family at
|
||||||
|
all. Upgraded live to `V5.11.0`/260701 (~15 months newer, spanning an intermediate release that
|
||||||
|
explicitly claimed "image stability" fixes). Result: **no change** — identical `deviceBusy` 503,
|
||||||
|
5/5 attempts, post-upgrade. Firmware is now a ruled-out cause, not a theory; this looks like a real
|
||||||
|
encoder/hardware ceiling on this SKU. Next step: build the RTSP-based main-stream capture path into
|
||||||
|
`packages/devices/src/drivers/camera.ts` (not yet started). Full detail on [[lpr-camera]].
|
||||||
|
|
||||||
|
## [2026-08-23] update | Root cause nailed down: broken ISAPI handler, not "busy" — decision to REPLACE the camera line
|
||||||
|
|
||||||
|
Final test on the DS-2CD1047G3H-LIU snapshot 503: swept every channel/stream ID against
|
||||||
|
`GET .../channels/<id>/picture`, including nonexistent ones (1, 100, 103, 201, 999). Every single
|
||||||
|
ID returns the identical `503 deviceBusy` body EXCEPT exactly `102` (the real sub-stream), which is
|
||||||
|
always 200. A real busy/saturated encoder would not succeed on one specific value while failing
|
||||||
|
garbage IDs identically — this is a generic fallback error: the firmware's snapshot handler is only
|
||||||
|
correctly wired for channel 102, and everything else (valid main-stream 101 included) falls through
|
||||||
|
to a stock, mislabeled "Device Busy" response. Confirms the firmware-upgrade non-result from
|
||||||
|
earlier today (a wrong-code-path bug wouldn't be fixed by more capacity). Owner's decision: replace
|
||||||
|
the DS-2CD1047G3H-LIU units rather than carry an RTSP/ffmpeg workaround dependency — the sibling
|
||||||
|
DS-2CD1043G2-LIU (no such bug, ISAPI main-stream snapshot works natively) is the reference model
|
||||||
|
going forward. RTSP main-stream capture remains documented as a proven, viable fallback if a G3H
|
||||||
|
camera is ever unavoidable, but is not being built. Full sweep table + reasoning on [[lpr-camera]].
|
||||||
|
|
||||||
|
## [2026-08-30] update | Booth USB printer cover-open bug: leading theory is a stale container bind-mount, not a stale app-layer handle
|
||||||
|
|
||||||
|
Live troubleshooting request (park-buzi): opening the printer's paper-roll cover reliably wedges its
|
||||||
|
status to offline/faulty, surviving a full appliance reboot; only `docker restart server` clears it.
|
||||||
|
Traced `sendRawUsb`/`probeUsb` end-to-end in `printer-escpos.ts` plus both poll loops
|
||||||
|
(`device-monitor.ts`, `printer-monitor.ts`): every print AND every poll does a fresh
|
||||||
|
open→write/probe→close with no persistent fd/socket/driver instance anywhere — ruling out a naive
|
||||||
|
"stale Node handle" explanation. Leading hypothesis instead: the cover-open microswitch cuts power
|
||||||
|
to the printer's USB interface board, causing a real bus re-enumeration; the container's directory
|
||||||
|
bind-mount of `/dev/usb` (chosen specifically to survive `lpN` renumbering) can retain a stale view
|
||||||
|
of the old device node until the container's mount namespace is recreated — which `docker restart`
|
||||||
|
does and a policy-driven reboot-time restart may not (boot-order race). Not yet confirmed on
|
||||||
|
hardware (host-vs-container `stat`/inode comparison at the next occurrence is the next step); lab
|
||||||
|
repro is blocked because the lab has a RONGTA, not the park-buzi unit's actual (still unidentified,
|
||||||
|
"Generic (unknown)") model. Full writeup, confirmation commands, and candidate fixes on
|
||||||
|
[[printer-usb-transport]].
|
||||||
|
|
||||||
|
## [2026-08-30] update | Backup status "Never" despite valid rotating backups — restart amnesia in BackupService, fixed
|
||||||
|
|
||||||
|
Admin noticed park-buzi's Backup screen showed "last successful backup: Never" despite 7 real,
|
||||||
|
correctly-rotating encrypted backup files on disk, plus a 2-day gap since the last file. Traced
|
||||||
|
both symptoms to the same cause: `BackupService` tracked last-success/last-error as PLAIN
|
||||||
|
IN-PROCESS FIELDS (never written to the DB), and the daily schedule was a `setInterval(...,24h)`
|
||||||
|
measured from PROCESS START, not wall-clock time since the last real backup — so any server
|
||||||
|
restart (routine under `restart: always`: deploy/crash/OOM/host reboot) simultaneously wiped the
|
||||||
|
visible status back to "Never" and reset the 24h countdown, independent of the actual
|
||||||
|
file-writing/retention engine (`backup.ts`), which was working correctly the whole time and
|
||||||
|
explains why files existed on disk despite the UI's contradictory-seeming status. Fix: four new
|
||||||
|
nullable `site_config` columns (migration `0025_backup_last_status.sql`) persist last-success/
|
||||||
|
error there instead of in memory; `BackupService.status()` reads them fresh each call so a new
|
||||||
|
instance (= a restart) sees the prior instance's outcome; a new `isDue()` method computes
|
||||||
|
schedule-due-ness from the persisted last-success timestamp; `server.ts`'s scheduler is now a
|
||||||
|
15-minute poll gated by `isDue()` instead of a 24h `setInterval`, making the real cadence immune
|
||||||
|
to restart timing. New test file `backup-service.test.ts` (6 tests) covers restart-durability and
|
||||||
|
`isDue()` directly; full existing suite (319 tests) still green. No API/UI contract change. Not
|
||||||
|
yet committed (holding per instruction). Full writeup on [[backup-recovery]].
|
||||||
|
|
||||||
|
## [2026-08-30] update | Two Komodo Periphery gotchas: connect_as renaming, agent upgrade procedure
|
||||||
|
|
||||||
|
Two real incidents this session, both closed out as new gotchas (#12, #13) on
|
||||||
|
[[appliance-provisioning]] §7: (1) a lab box installed with a leftover template placeholder
|
||||||
|
left in `--connect-as` kept reappearing under that name in Core no matter how many times it was
|
||||||
|
renamed in the UI — because `connect_as` is a plain field in the agent's own
|
||||||
|
`periphery.config.toml`, and a Core-UI rename never touches it; fixed by editing the field
|
||||||
|
directly on the host + `systemctl --user restart periphery`, no reinstall needed. (2) Upgrading
|
||||||
|
Periphery from a version-mismatch (Core bumped to v2.3.2, an agent still on v2.2.0) has no
|
||||||
|
separate update mechanism — confirmed against Komodo's own `setup-periphery.py` source that
|
||||||
|
re-running the same installer with unchanged `--connect-as` is config-preserving (it explicitly
|
||||||
|
skips rewriting an existing config) and safe; verified dry-run on `art-docker-station` (lab) then
|
||||||
|
applied to `park-buzi` (live booth) with no disruption to the running app containers. Full detail
|
||||||
|
+ exact commands on [[appliance-provisioning]].
|
||||||
|
|
||||||
|
## [2026-09-03] fix | Desktop updater endpoint was unreachable — pointed at a private repo
|
||||||
|
|
||||||
|
The Tauri auto-updater ([[desktop-shell-tauri]]) was fully implemented — signed builds, keypair,
|
||||||
|
`latest.json`, `release.yml` — but its endpoint pointed at `mca/parking_solution`'s own Gitea
|
||||||
|
"latest release" redirect, and that repo is **private**. Field appliances have no Gitea
|
||||||
|
credentials, so every update check was silently failing (caught by a `try/catch`); this was never
|
||||||
|
actually field-verified end to end. Fix: signed installers now mirror to a new public,
|
||||||
|
installers-only repo `mca/public_releases` (org-shared, not parking-specific), published to a fixed
|
||||||
|
`desktop-latest` tag so other apps releasing there later can't shadow ours. Considered and rejected
|
||||||
|
embedding a `read:repository` token in the app instead — ruled out given the appliance's own threat
|
||||||
|
model (booth operator as primary adversary) makes an extractable, hard-to-rotate credential in every
|
||||||
|
deployed binary worse than just publishing installers publicly. `release.yml`,
|
||||||
|
`apps/desktop/src-tauri/tauri.conf.json`, `apps/desktop/README.md` updated; full detail on
|
||||||
|
[[desktop-shell-tauri]].
|
||||||
|
|
||||||
|
## [2026-09-03] fix | Desktop login broken by a VITE_API_BASE regression from the booth same-origin fix
|
||||||
|
|
||||||
|
The 2026-06-27 booth fix (commit 96fd97e) correctly blanked `apps/web/.env.production`'s
|
||||||
|
`VITE_API_BASE` for the browser/booth same-origin case, but the desktop build shares that same
|
||||||
|
file and was never given its own override — the desktop shell has been building with an empty
|
||||||
|
API base since that commit, unnoticed until now. Symptom: login threw `DOMException: "The string
|
||||||
|
did not match the expected pattern."` — WebKitGTK rejecting a relative `fetch()` URL with no base
|
||||||
|
to resolve against, since the desktop window's origin is `tauri://localhost`. Browser login was
|
||||||
|
unaffected (same-origin, no absolute URL needed), which is why this went unnoticed through the CI
|
||||||
|
mirror-repo debugging session. Fixed by setting `VITE_API_BASE=http://127.0.0.1:3000` inline in
|
||||||
|
`tauri.conf.json`'s `beforeBuildCommand`, overriding the shared `.env.production` for the desktop
|
||||||
|
build only (process env wins in Vite's load order) — verified both builds independently. Full
|
||||||
|
detail on [[desktop-shell-tauri]].
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
#### HIKVISION DS-2CD1047G3H-LIU-F 4 MP ColorVu 3.0 Fixed Bullet Network Camera
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
#### FEATURE
|
||||||
|
|
||||||
|
- HikAI-ISP for excellent noise reduction effect
|
||||||
|
- Super clear 24/7 colorful imaging with ColorVu 3.0 technology
|
||||||
|
- Motion Detection 3.0, more accurate on person and vehicle classification
|
||||||
|
- Strobe Light & Audio Alarm to deter intruders (optional)
|
||||||
|
- Two-Way Audio via camera and Hik-Connect
|
||||||
|
- Smart Hybrid Light: integrates IR and white lights, 3 supplemental lighting modes
|
||||||
|
- On-board storage with SD card up to 512 GB (optional)
|
||||||
|
- Water and dust resistant (IP67)
|
||||||
|
|
||||||
|
#### Specification
|
||||||
|
|
||||||
|
<table><tbody><tr><td colspan="2" width="681">n <strong>Specification</strong></td></tr><tr><td colspan="2" width="681"><strong>Camera</strong></td></tr><tr><td width="194">Max. Resolution</td><td width="487">2560 × 1440</td></tr><tr><td width="194">Min. Illumination</td><td width="487">Color: 0.0001 Lux @ (F1.0, AGC ON)</td></tr><tr><td width="194">Shutter Time</td><td width="487">1 s to 1/100,000 s</td></tr><tr><td width="194">Day & Night</td><td width="487">IR cut filter</td></tr><tr><td width="194">Angle Adjustment</td><td width="487">Pan: 0° to 360°,tilt: 0° to 90°,rotate: 0° to 360°</td></tr><tr><td colspan="2" width="681"><strong>Lens</strong></td></tr><tr><td width="194">Lens Type</td><td width="487">Fixed focal lens, 2.8 and 4 mm optional</td></tr><tr><td width="194">Focal Length & FOV</td><td width="487">2.8 mm, horizontal FOV 104°, vertical FOV 54.4°, diagonal FOV 126.7°<p>4 mm, horizontal FOV 89.3°, vertical FOV 48.2°, diagonal FOV 106.5°</p></td></tr><tr><td width="194">Lens Mount</td><td width="487">M16</td></tr><tr><td width="194">Iris Type</td><td width="487">Fixed</td></tr><tr><td width="194">Aperture</td><td width="487">F1.0</td></tr><tr><td width="194">Depth of Field</td><td width="487">2.8 mm: 2.5 m to ∞<p>4 mm: 2.8 m to ∞</p></td></tr><tr><td colspan="2" width="681"><strong>DORI</strong></td></tr><tr><td width="194">DORI</td><td width="487">2.8 mm, D: 61 m, O: 24 m, R: 12 m, I: 6 m<p>4 mm, D: 68 m, O: 27 m, R: 13 m, I: 6 m</p></td></tr><tr><td colspan="2" width="681"><strong>Illuminator</strong></td></tr><tr><td width="194">Supplement Light Type</td><td width="487">IR,White Light</td></tr><tr><td width="194">Supplement Light Range</td><td width="487">IR: up to 30 m<p>White Light: up to 20 m</p></td></tr><tr><td width="194">Smart Supplement Light</td><td width="487">Yes</td></tr><tr><td width="194">IR Wavelength</td><td width="487">850 nm</td></tr><tr><td colspan="2" width="681"><strong>Video</strong></td></tr><tr><td width="194"><strong> </strong><p><strong> </strong></p><p>Main Stream</p></td><td width="487">50 Hz:<p>25 fps (2560 × 1440, 1920 × 1080, 1280 × 720)</p><p>60 Hz:</p><p>24 fps (2560 × 1440, 1920 × 1080, 1280 × 720)</p></td></tr><tr><td width="194">Sub-Stream</td><td width="487">50 Hz: 25 fps (768 × 432, 640 × 360)<p>60 Hz: 24 fps (768 × 432, 640 × 360)</p></td></tr><tr><td width="194">Video Compression</td><td width="487">Main stream: H.265+/H.265/H.264+/H.264,<p>Sub-stream: H.265/H.264/MJPEG</p></td></tr><tr><td width="194">Video Bit Rate</td><td width="487">32 Kbps to 16 Mbps</td></tr><tr><td width="194">H.264 Type</td><td width="487">Baseline Profile,Main Profile,High Profile</td></tr><tr><td width="194">H.265 Type</td><td width="487">Main Profile</td></tr><tr><td width="194">Bit Rate Control</td><td width="487">CBR,VBR</td></tr><tr><td width="194">Scalable Video Coding (SVC)</td><td width="487">H.264 and H.265 encoding</td></tr><tr><td width="194">Region of Interest (ROI)</td><td width="487">1 fixed region for main stream</td></tr><tr><td colspan="2" width="681"><strong>Audio</strong></td></tr><tr><td width="194">Audio Type</td><td width="487">Mono sound</td></tr><tr><td width="194">Audio Compression</td><td width="487">G.711/G.722.1/G.726/MP2L2/PCM/MP3/AAC-LC</td></tr><tr><td width="194">Audio Bit Rate</td><td width="487">64 Kbps (G.711)/16 Kbps (G.722.1)/16 Kbps (G.726)/32 to 160 Kbps (MP2L2)/16 to 64<p>Kbps (AAC-LC)</p></td></tr><tr><td width="194">Audio Sampling Rate</td><td width="487">8 kHz/16 kHz</td></tr></tbody></table>
|
||||||
|
|
||||||
|
<table><tbody><tr><td width="199">Environment Noise Filtering</td><td width="482">Yes</td></tr><tr><td colspan="2" width="681"><strong>Network</strong></td></tr><tr><td width="199">Protocols</td><td width="482">TCP/IP, ICMP, DHCP, DNS, HTTP, RTP, RTSP, RTCP, NTP, IPv4, IPv6, IGMP, UDP, QoS,<p>FTP, SMTP</p></td></tr><tr><td width="199">Simultaneous Live View</td><td width="482">Up to 6 channels</td></tr><tr><td width="199">API</td><td width="482">ONVIF (Profile S, Profile G),ISAPI,SDK</td></tr><tr><td width="199">User/Host</td><td width="482">Up to 32 users<p>3 user levels: administrator, operator, and user</p></td></tr><tr><td width="199"><strong> </strong><p>Security</p></td><td width="482">Password protection, complicated password, watermark, basic and digest<p>authentication for HTTP, WSSE and digest authentication for Open Network Video</p><p>Interface, security audit log, host authentication (MAC address)</p></td></tr><tr><td width="199">Client</td><td width="482">iVMS-4200,Hik-Connect</td></tr><tr><td width="199">Web Browser</td><td width="482">Plug-in required live view: Chrome 80+, Firefox 80+, Edge 89+, Safari 13+,<p>Plug-in free live view: Chrome 80+, Firefox 80+, Edge 89+</p></td></tr><tr><td colspan="2" width="681"><strong>Image</strong></td></tr><tr><td width="199">Image Settings</td><td width="482">Rotate mode,saturation,brightness,contrast,sharpness,gain,white balance,adjustable<p>by client software or web browser</p></td></tr><tr><td width="199">Day/Night Switch</td><td width="482">Day,Night,Auto,Schedule</td></tr><tr><td width="199">Wide Dynamic Range (WDR)</td><td width="482">120 dB</td></tr><tr><td width="199">SNR</td><td width="482">≥ 52 dB</td></tr><tr><td width="199">Image Enhancement</td><td width="482">BLC,HLC,3D DNR</td></tr><tr><td width="199">Privacy Mask</td><td width="482">4 programmable polygon privacy masks</td></tr><tr><td colspan="2" width="681"><strong>Interface</strong></td></tr><tr><td width="199">Ethernet Interface</td><td width="482">1 RJ45 10 M/100 M self-adaptive Ethernet port</td></tr><tr><td width="199"><strong> </strong><p><strong> </strong></p><p><strong> </strong></p><p><strong> </strong></p><p><strong> </strong></p><p><strong> </strong></p><p><strong> </strong></p><p><strong> </strong></p><p>On-Board Storage</p></td><td width="482">DS-2CD1047G3H-LIU(F)(/SL)(/SRB):-SL/SRB/LIUF: Yes<p>-LIU: NA</p></td></tr><tr><td width="199">Built-in Microphone</td><td width="482">Yes,1 built-in microphone</td></tr></tbody></table>
|
||||||
|
|
||||||
|
| Built-in Speaker | DS-2CD1047G3H-LIU(F)(/SL)(/SRB):-SL/SRB: Yes \-LIU/LIUF: NA |
|
||||||
|
| --- | --- |
|
||||||
|
| Audio | DS-2CD1047G3H-LIU(F)(/SL)(/SRB):-LIU: NA |
|
||||||
|
| Alarm | DS-2CD1047G3H-LIU(F)(/SL)(/SRB):-LIU: NA |
|
||||||
|
| Reset Key | DS-2CD1047G3H-LIU(F)(/SL)(/SRB):-SL/SRB/LIUF: Yes, \-LIU: NA |
|
||||||
|
|
||||||
|
<table><tbody><tr><td colspan="2" width="681"><strong>Event</strong></td></tr><tr><td width="192">Basic Event</td><td width="489">Motion detection (support alarm triggering by specified target types (human and<p>vehicle)),video tampering alarm,exception</p></td></tr><tr><td width="192">Linkage</td><td width="489">Upload to FTP,notify surveillance center,send email,trigger recording,trigger capture</td></tr><tr><td colspan="2" width="681"><strong>General</strong></td></tr><tr><td width="192">Power</td><td width="489">DS-2CD1047G3H-LIU(F)(/SL)(/SRB):-LIU: 12 VDC ± 25%, 0.58 A, max. 7 W, Ø5.5 mm<p>coaxial power plug, reverse polarity protection, PoE: IEEE 802.3af, Class 3,max. 9 W LIUF: 12 VDC ± 25%, 0.67 A, max. 8 W, Ø5.5 mm coaxial power plug, reverse polarity protection, PoE: IEEE 802.3af, Class 3,max. 10 W</p><p>-SL/SRB: 12 VDC ± 25%, 0.84 A, max. 10.1 W, Ø5.5 mm coaxial power plug, reverse polarity protection, PoE: IEEE 802.3af, Class 3,max. 12.1 W</p><p> </p></td></tr><tr><td width="192"><strong> </strong><p><strong> </strong></p><p><strong> </strong></p><p><strong> </strong></p><p><strong> </strong></p><p><strong> </strong></p><p><strong> </strong></p><p><strong> </strong></p><p>Dimension</p></td><td width="489">DS-2CD1047G3H-LIU(F)(/SL)(/SRB):-LIU: 69.1 mm × 66.9 mm × 172.9 mm (2.7″ × 2.6″ ×<p>6.8″)</p><p>-LIUF/SL/SRB: 69.1 mm × 67.4 mm × 179 mm (2.7″ × 2.7″ × 7.1″)</p><p> </p></td></tr><tr><td width="192">Package Dimension</td><td width="489">210 mm × 116 mm × 106 mm (8.3″ × 4.6″ × 4.2″)</td></tr></tbody></table>
|
||||||
|
|
||||||
|
| Weight | DS-2CD1047G3H-LIU(F)(/SL)(/SRB):-LIU: Approx. 310 g (0.7 lb.) \-LIUF: Approx. 315 g (0.7 lb.) \-SL/SRB: Approx. 325 g (0.7 lb.) |
|
||||||
|
| --- | --- |
|
||||||
|
| With Package Weight | DS-2CD1047G3H-LIU(F)(/SL)(/SRB):-LIU: Approx. 465 g (1.0 lb.) \-LIUF: Approx. 470 g (1.0 lb.) \-SL/SRB: Approx. 495 g (1.1 lb.) |
|
||||||
|
| Storage Conditions | \-30 °C to 60 °C (-22 °F to 140 °F). Humidity 95% or less (non-condensing) |
|
||||||
|
| Startup and Operating Conditions | \-30 °C to 60 °C (-22 °F to 140 °F). Humidity 95% or less (non-condensing) |
|
||||||
|
| Language | English, Russian, Ukrainian, Arabic, Spanish, French, Portuguese, Turkish, Polish, German |
|
||||||
|
| General Function | Heartbeat,mirror,flash log,password reset via email,password protection,anti -banding |
|
||||||
|
| Flashing Light | DS-2CD1047G3H-LIU(F)(/SL)(/SRB):-SL/SRB: Yes \-LIU/LIUF: NA |
|
||||||
|
|
||||||
|
<table><tbody><tr><td colspan="2" width="681"><strong>Approval</strong></td></tr><tr><td width="194">EMC</td><td width="487">CE-EMC: EN 55032:2015+A1:2020, EN 50130-4:2011+A1:2014, EN IEC<p>61000-3-2:2019+A1:2021, EN 61000-3-3:2013+A1:2019+A2:2021</p></td></tr><tr><td width="194">Safety</td><td width="487">CB: IEC 62368-1: 2014+A11,<p>CE-LVD: EN 62368-1: 2014/A11: 2017</p></td></tr><tr><td width="194">Environment</td><td width="487">CE-RoHS: 2011/65/EU,<p>WEEE: 2012/19/EU</p></td></tr><tr><td width="194">Protection</td><td width="487">IP67: IEC 60529-2013</td></tr></tbody></table>
|
||||||
|
|
||||||
|
#### Available Model
|
||||||
|
|
||||||
|
- DS-2CD1047G3H-LIUF/SRB(2.8mm)
|
||||||
|
- DS-2CD1047G3H-LIUF/SRB(4mm)
|
||||||
|
- DS-2CD1047G3H-LIU(2.8mm)
|
||||||
|
- DS-2CD1047G3H-LIU(4mm)
|
||||||
|
- DS-2CD1047G3H-LIUF(2.8mm)
|
||||||
|
- DS-2CD1047G3H-LIUF(4mm)
|
||||||
|
- DS-2CD1047G3H-LIUF/SL(2.8mm)
|
||||||
|
- DS-2CD1047G3H-LIUF/SL(4mm)
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
---
|
||||||
|
type: source
|
||||||
|
tags: [parking, hardware, cameras, hikvision, datasheet]
|
||||||
|
sources: [DS-2CD1047G3H-LIU]
|
||||||
|
updated: 2026-08-23
|
||||||
|
---
|
||||||
|
|
||||||
|
# Source: HIKVISION DS-2CD1047G3H-LIU-F datasheet
|
||||||
|
|
||||||
|
Vendor spec sheet (`raw/DS-2CD1047G3H-LIU.md`) for the **DS-2CD1047G3H-LIU** — the exit-lane
|
||||||
|
camera at `10.0.10.13` (park-buzi) already covered extensively in [[lpr-camera]] for its
|
||||||
|
persistent main-stream ISAPI 503 (`deviceBusy`) and its 2026-06-27 config-DB-corruption incident.
|
||||||
|
This is the first *vendor-sourced* spec data for the model; everything before it was field-derived.
|
||||||
|
|
||||||
|
## Key takeaways
|
||||||
|
|
||||||
|
- **4 MP, max resolution 2560×1440** (ColorVu 3.0 — color night imaging, not IR-only).
|
||||||
|
- **Main stream: H.265+/H.265/H.264+/H.264 only — no MJPEG option.** **Sub-stream: H.265/H.264/
|
||||||
|
MJPEG** (MJPEG is sub-only). This is a concrete, spec-level asymmetry that plausibly explains
|
||||||
|
*why* the ISAPI on-demand JPEG snapshot is reliable on sub but structurally broken on main: sub
|
||||||
|
can serve a snapshot natively, main has to transcode out of H.264/H.265 on demand. Consistent
|
||||||
|
with — and a likely root cause for — the persistent `deviceBusy` behavior already logged in
|
||||||
|
[[lpr-camera]].
|
||||||
|
- **Bit rate range 32 Kbps–16 Mbps.** The site's main-stream config (6144–12288 Kbps, confirmed
|
||||||
|
live 2026-08-23) is well inside spec — rules out "misconfigured over the camera's own ceiling"
|
||||||
|
as a cause, confirming what live testing already showed.
|
||||||
|
- **ROI: 1 fixed region for main stream** — another main-only constraint/asymmetry vs. sub.
|
||||||
|
- **API: ONVIF (Profile S, Profile G), ISAPI, SDK.** ISAPI is fully in-spec for this model — the
|
||||||
|
503 is a real firmware/hardware limitation, not a case of using an unsupported API.
|
||||||
|
- **Protocols include RTSP** (alongside HTTP/ONVIF/etc.) — confirms RTSP is a documented, supported
|
||||||
|
surface on this model, not a workaround outside its design.
|
||||||
|
- Simultaneous live view: up to 6 channels. Available in `-LIU`/`-LIUF`/`-LIUF/SL`/`-LIUF/SRB`
|
||||||
|
variants at 2.8mm/4mm focal lengths; the deployed unit is the base `-LIU`.
|
||||||
|
|
||||||
|
## How this changes the picture
|
||||||
|
|
||||||
|
Confirms rather than overturns [[lpr-camera]]'s existing conclusion (sub-stream-only for ISAPI
|
||||||
|
snapshots on this model) — but gives it a concrete mechanical explanation (main has no MJPEG path)
|
||||||
|
instead of just an empirically-observed limitation. See [[lpr-camera]] § "Main-stream ISAPI
|
||||||
|
snapshot 503 vs. RTSP" for the 2026-08-23 live comparison against a `DS-2CD1043G2-LIU` sibling
|
||||||
|
(main-stream ISAPI snapshot works fine there) and the confirmed RTSP frame-grab workaround.
|
||||||
Reference in New Issue
Block a user