Compare commits
53 Commits
0180394c45
...
v0.1.3
| Author | SHA1 | Date | |
|---|---|---|---|
| 439b11d16d | |||
| 276b048fa9 | |||
| 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 |
@@ -92,6 +92,8 @@ jobs:
|
||||
context: .
|
||||
file: apps/server/Dockerfile
|
||||
push: true
|
||||
build-args: |
|
||||
BUILD_VERSION=${{ steps.meta.outputs.branch }}-${{ steps.meta.outputs.sha }}
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/parking-server:${{ steps.meta.outputs.branch }}
|
||||
${{ env.REGISTRY }}/parking-server:${{ steps.meta.outputs.branch }}-${{ steps.meta.outputs.sha }}
|
||||
|
||||
+135
-12
@@ -1,12 +1,24 @@
|
||||
name: Release desktop
|
||||
|
||||
# Build the signed Tauri desktop installers on a version tag and publish them as
|
||||
# a Gitea Release. The Tauri auto-updater (apps/web/src/lib/desktop-updater.ts)
|
||||
# fetches these; latest.json + each installer + its .sig are what it needs.
|
||||
# a Gitea Release — TWICE: once on this (private, source) repo for our own
|
||||
# records/history, and once mirrored to mca/public_releases, which is what the
|
||||
# Tauri auto-updater (apps/web/src/lib/desktop-updater.ts) actually points at.
|
||||
#
|
||||
# WHY a separate public repo: the updater runs on offline-first field appliances
|
||||
# with no Gitea credentials, so its endpoint + installer downloads must be
|
||||
# reachable unauthenticated. Mirroring compiled installers to a public
|
||||
# releases-only repo avoids embedding any read token in the shipped app (which
|
||||
# would leak the moment a booth PC is compromised — this box's threat model
|
||||
# names the operator/booth as the primary adversary, see CLAUDE.md). Source
|
||||
# stays private; only signed installers become public, same as most desktop
|
||||
# software. mca/public_releases is shared across apps in the org, not
|
||||
# parking-specific — namespace release tags/asset names accordingly if another
|
||||
# app starts publishing there too.
|
||||
#
|
||||
# Trigger: push a tag like v0.1.0. The job builds .deb/.rpm/.AppImage, signs them
|
||||
# with the updater key (Gitea secrets), assembles latest.json, and uploads
|
||||
# everything to the Release for that tag.
|
||||
# with the updater key (Gitea secrets), assembles latest.json pointing at the
|
||||
# MIRROR repo's asset URLs, uploads to both repos, and mirrors the same assets.
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -63,6 +75,27 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Sync tauri.conf.json version to the git tag
|
||||
# tauri.conf.json's own "version" field is what Tauri bakes into the
|
||||
# bundle filename, the app's internal version, AND the updater's
|
||||
# "current vs. new" comparison — it is NOT derived from the git tag
|
||||
# automatically. Hit in v0.1.1: the tag was bumped but this file
|
||||
# wasn't, so the signed binary + its .sig were still built (and
|
||||
# named) as 0.1.0 while latest.json (built from TAG below) claimed
|
||||
# 0.1.1 — the updater found the "update", downloaded a file whose
|
||||
# signature didn't match what the manifest claimed to sign, and
|
||||
# silently failed (a separate bug in desktop-updater.ts's error
|
||||
# handling made this invisible — also fixed). Patch it here so the
|
||||
# checked-in value is only ever a placeholder for local dev builds;
|
||||
# a real release's version is always driven by the tag.
|
||||
run: |
|
||||
set -e
|
||||
VERSION="${TAG#v}"
|
||||
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"${VERSION}\"/" apps/desktop/src-tauri/tauri.conf.json
|
||||
grep '"version"' apps/desktop/src-tauri/tauri.conf.json
|
||||
env:
|
||||
TAG: ${{ github.ref_name }}
|
||||
|
||||
- name: Build + sign desktop bundle
|
||||
env:
|
||||
# Updater signing key (Gitea repo/org secrets). Without these the
|
||||
@@ -73,30 +106,41 @@ jobs:
|
||||
|
||||
- name: Collect artifacts
|
||||
id: collect
|
||||
# Gather the installers + their .sig into a flat dist/ for upload.
|
||||
# Gather the installers + their .sig into a flat dist/ for upload, spaces
|
||||
# stripped from filenames. productName is "Parking System" (a space), so
|
||||
# Tauri's bundle output is e.g. "Parking System_0.1.0_amd64.deb" — an
|
||||
# unescaped space in a filename breaks the later curl asset-upload URL
|
||||
# ("URL rejected: Malformed input to a URL function", hit on the very
|
||||
# first v0.1.0 release) AND would land in latest.json's asset url, which
|
||||
# the updater's plain HTTP GET can't handle either. Rename on copy.
|
||||
run: |
|
||||
set -e
|
||||
BUNDLE=apps/desktop/src-tauri/target/release/bundle
|
||||
mkdir -p dist
|
||||
find "$BUNDLE" \( -name '*.AppImage' -o -name '*.deb' -o -name '*.rpm' \
|
||||
-o -name '*.AppImage.sig' -o -name '*.deb.sig' -o -name '*.rpm.sig' \) \
|
||||
-exec cp {} dist/ \;
|
||||
-print0 | while IFS= read -r -d '' f; do
|
||||
name=$(basename "$f" | tr ' ' '-')
|
||||
cp "$f" "dist/${name}"
|
||||
done
|
||||
echo "Artifacts:"; ls -la dist/
|
||||
|
||||
- name: Assemble latest.json
|
||||
# The Tauri updater fetches a manifest describing the newest version, its
|
||||
# notes, and per-target {signature, url}. We point the AppImage target at
|
||||
# this release's asset URL. Adjust the platform keys you actually ship.
|
||||
# notes, and per-target {signature, url}. The URL points at the MIRROR
|
||||
# repo (mca/public_releases) — that's the unauthenticated endpoint field
|
||||
# appliances actually reach; see the workflow header for why. Adjust the
|
||||
# platform keys you actually ship.
|
||||
env:
|
||||
SERVER_URL: ${{ github.server_url }}
|
||||
REPO: ${{ github.repository }}
|
||||
MIRROR_REPO: mca/public_releases
|
||||
TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -e
|
||||
VERSION="${TAG#v}"
|
||||
APPIMAGE=$(cd dist && ls *.AppImage | head -1)
|
||||
SIG=$(cat "dist/${APPIMAGE}.sig")
|
||||
ASSET_URL="${SERVER_URL}/${REPO}/releases/download/${TAG}/${APPIMAGE}"
|
||||
ASSET_URL="${SERVER_URL}/${MIRROR_REPO}/releases/download/desktop-latest/${APPIMAGE}"
|
||||
cat > dist/latest.json <<JSON
|
||||
{
|
||||
"version": "${VERSION}",
|
||||
@@ -129,12 +173,12 @@ jobs:
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"tag_name\":\"${TAG}\",\"name\":\"${TAG}\",\"draft\":false,\"prerelease\":false}" \
|
||||
"${API}/repos/${REPO}/releases" || true)
|
||||
REL_ID=$(printf '%s' "$REL" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2)
|
||||
REL_ID=$(printf '%s' "$REL" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2 || true)
|
||||
if [ -z "$REL_ID" ]; then
|
||||
# Release may already exist for this tag — look it up by tag.
|
||||
REL_ID=$(curl -sS -H "Authorization: token ${TOKEN}" \
|
||||
"${API}/repos/${REPO}/releases/tags/${TAG}" \
|
||||
| grep -o '"id":[0-9]*' | head -1 | cut -d: -f2)
|
||||
| grep -o '"id":[0-9]*' | head -1 | cut -d: -f2 || true)
|
||||
fi
|
||||
echo "release id: ${REL_ID}"
|
||||
for f in dist/*; do
|
||||
@@ -147,3 +191,82 @@ jobs:
|
||||
"${API}/repos/${REPO}/releases/${REL_ID}/assets?name=${name}" >/dev/null
|
||||
done
|
||||
echo "done"
|
||||
|
||||
- name: Mirror release to mca/public_releases (Gitea API)
|
||||
# This is the release the updater and any human downloader actually use —
|
||||
# public_releases has no source, only installers, so it can be public
|
||||
# without exposing this repo. RELEASES_MIRROR_TOKEN is a write:repository
|
||||
# token scoped for pushing releases into that repo (Gitea's org secrets,
|
||||
# not exposed to any deployed client).
|
||||
#
|
||||
# Publishes to TWO tags there, since public_releases is shared across
|
||||
# apps in the org and Gitea's "latest release" redirect resolves by
|
||||
# newest tag on the WHOLE repo (would break the moment another app
|
||||
# publishes something newer):
|
||||
# - desktop-<TAG> versioned, permanent — audit trail / rollback.
|
||||
# - desktop-latest moving — assets deleted + re-uploaded each release.
|
||||
# This is the fixed URL tauri.conf.json's updater endpoint points at
|
||||
# (a stable name every appliance can always resolve, regardless of
|
||||
# what else gets released in this repo meanwhile).
|
||||
env:
|
||||
TOKEN: ${{ secrets.RELEASES_MIRROR_TOKEN }}
|
||||
API: ${{ github.api_url }}
|
||||
MIRROR_REPO: mca/public_releases
|
||||
TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -e
|
||||
create_or_get_release() {
|
||||
local mirror_tag="$1" prerelease="$2"
|
||||
REL=$(curl -sS -w '\n%{http_code}' -X POST \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"tag_name\":\"${mirror_tag}\",\"name\":\"Parking System ${TAG}\",\"draft\":false,\"prerelease\":${prerelease}}" \
|
||||
"${API}/repos/${MIRROR_REPO}/releases" || true)
|
||||
echo "create response (${mirror_tag}): ${REL}"
|
||||
REL_ID=$(printf '%s' "$REL" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2 || true)
|
||||
if [ -z "$REL_ID" ]; then
|
||||
LOOKUP=$(curl -sS -w '\n%{http_code}' -H "Authorization: token ${TOKEN}" \
|
||||
"${API}/repos/${MIRROR_REPO}/releases/tags/${mirror_tag}")
|
||||
echo "tag lookup response (${mirror_tag}): ${LOOKUP}"
|
||||
REL_ID=$(printf '%s' "$LOOKUP" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2 || true)
|
||||
fi
|
||||
if [ -z "$REL_ID" ]; then
|
||||
echo "::error::could not create or find release for tag ${mirror_tag} on ${MIRROR_REPO} — see responses above"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
upload_assets() {
|
||||
local rel_id="$1"
|
||||
for f in dist/*; do
|
||||
name=$(basename "$f")
|
||||
echo "mirroring ${name} -> release ${rel_id}"
|
||||
HTTP_CODE=$(curl -sS -o /tmp/upload_resp.json -w '%{http_code}' -X POST \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary @"${f}" \
|
||||
"${API}/repos/${MIRROR_REPO}/releases/${rel_id}/assets?name=${name}")
|
||||
if [ "$HTTP_CODE" -ge 300 ]; then
|
||||
echo "::error::upload of ${name} failed (HTTP ${HTTP_CODE}): $(cat /tmp/upload_resp.json)"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# 1. Versioned, permanent.
|
||||
create_or_get_release "desktop-${TAG}" false
|
||||
echo "versioned mirror release id: ${REL_ID}"
|
||||
upload_assets "${REL_ID}"
|
||||
|
||||
# 2. Moving desktop-latest — delete existing assets first (re-upload
|
||||
# with the same name 409s otherwise), then re-upload.
|
||||
create_or_get_release "desktop-latest" false
|
||||
LATEST_REL_ID="${REL_ID}"
|
||||
echo "latest mirror release id: ${LATEST_REL_ID}"
|
||||
EXISTING=$(curl -sS -H "Authorization: token ${TOKEN}" \
|
||||
"${API}/repos/${MIRROR_REPO}/releases/${LATEST_REL_ID}/assets")
|
||||
printf '%s' "$EXISTING" | grep -o '"id":[0-9]*' | cut -d: -f2 | while read -r asset_id; do
|
||||
curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \
|
||||
"${API}/repos/${MIRROR_REPO}/releases/${LATEST_REL_ID}/assets/${asset_id}" >/dev/null
|
||||
done || true
|
||||
upload_assets "${LATEST_REL_ID}"
|
||||
echo "done"
|
||||
|
||||
@@ -27,3 +27,4 @@ dist/
|
||||
# Graphify knowledge-graph output (dev tool; generated, not committed)
|
||||
graphify-out/
|
||||
parking.sqlite*.bak-*
|
||||
questions.txt
|
||||
|
||||
+11
-3
@@ -35,8 +35,16 @@ pnpm --filter @parking/desktop bundle # build the SPA + bundle the desktop app
|
||||
Requires the Rust toolchain and (on Linux) WebKitGTK 4.1 + libsoup-3 dev libraries. Under WSL2 the
|
||||
window needs a display (WSLg or an X server).
|
||||
|
||||
## Auto-update
|
||||
|
||||
Signed updates are built and published by `.gitea/workflows/release.yml` on a `vX.Y.Z` tag, mirrored
|
||||
to the public `mca/public_releases` repo (this repo is private; the updater runs on offline-first
|
||||
field appliances with no Gitea credentials, so its endpoint must be reachable unauthenticated —
|
||||
see that workflow's header and `wiki/decisions/desktop-shell-tauri.md`). The updater config and
|
||||
signing pubkey live in `tauri.conf.json`; the private signing key is held outside the repo, never
|
||||
committed.
|
||||
|
||||
## Not here (deliberately)
|
||||
|
||||
Kiosk lockdown (fullscreen/no-decorations), auto-update, code signing, and launching Fastify from
|
||||
the shell are out of scope for the scaffold — on the appliance Fastify runs as its own service and
|
||||
this shell connects to it.
|
||||
Kiosk lockdown (fullscreen/no-decorations) and launching Fastify from the shell are out of scope for
|
||||
the scaffold — on the appliance Fastify runs as its own service and this shell connects to it.
|
||||
|
||||
Generated
+549
-8
@@ -318,6 +318,23 @@ version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "cfg_aliases"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
|
||||
|
||||
[[package]]
|
||||
name = "chacha20"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.3.1",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.45"
|
||||
@@ -346,10 +363,39 @@ version = "0.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747"
|
||||
dependencies = [
|
||||
"percent-encoding",
|
||||
"time",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cookie_store"
|
||||
version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206"
|
||||
dependencies = [
|
||||
"cookie",
|
||||
"document-features",
|
||||
"idna",
|
||||
"log",
|
||||
"publicsuffix",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"time",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.9.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.10.1"
|
||||
@@ -373,7 +419,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"core-foundation",
|
||||
"core-foundation 0.10.1",
|
||||
"core-graphics-types",
|
||||
"foreign-types",
|
||||
"libc",
|
||||
@@ -386,7 +432,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"core-foundation",
|
||||
"core-foundation 0.10.1",
|
||||
"libc",
|
||||
]
|
||||
|
||||
@@ -399,6 +445,15 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.0"
|
||||
@@ -506,6 +561,18 @@ dependencies = [
|
||||
"syn 2.0.118",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "data-encoding"
|
||||
version = "2.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
|
||||
|
||||
[[package]]
|
||||
name = "data-url"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376"
|
||||
|
||||
[[package]]
|
||||
name = "dbus"
|
||||
version = "0.9.11"
|
||||
@@ -635,6 +702,15 @@ dependencies = [
|
||||
"syn 2.0.118",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "document-features"
|
||||
version = "0.2.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
|
||||
dependencies = [
|
||||
"litrs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dom_query"
|
||||
version = "0.27.0"
|
||||
@@ -721,6 +797,15 @@ version = "1.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
|
||||
|
||||
[[package]]
|
||||
name = "encoding_rs"
|
||||
version = "0.8.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
@@ -1034,8 +1119,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"wasi",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1057,8 +1144,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"r-efi 6.0.0",
|
||||
"rand_core 0.10.1",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1209,6 +1299,25 @@ dependencies = [
|
||||
"syn 2.0.118",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h2"
|
||||
version = "0.4.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"bytes",
|
||||
"fnv",
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"http",
|
||||
"indexmap 2.14.0",
|
||||
"slab",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.12.3"
|
||||
@@ -1298,6 +1407,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"h2",
|
||||
"http",
|
||||
"http-body",
|
||||
"httparse",
|
||||
@@ -1321,6 +1431,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower-service",
|
||||
"webpki-roots 1.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1341,9 +1452,11 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"socket2",
|
||||
"system-configuration",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
"windows-registry",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1744,6 +1857,12 @@ version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
|
||||
|
||||
[[package]]
|
||||
name = "litrs"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
|
||||
|
||||
[[package]]
|
||||
name = "lock_api"
|
||||
version = "0.4.14"
|
||||
@@ -1759,6 +1878,12 @@ version = "0.4.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||
|
||||
[[package]]
|
||||
name = "lru-slab"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||
|
||||
[[package]]
|
||||
name = "markup5ever"
|
||||
version = "0.38.0"
|
||||
@@ -2178,8 +2303,10 @@ dependencies = [
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-http",
|
||||
"tauri-plugin-process",
|
||||
"tauri-plugin-updater",
|
||||
"tauri-plugin-websocket",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2330,6 +2457,15 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
||||
|
||||
[[package]]
|
||||
name = "ppv-lite86"
|
||||
version = "0.2.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
|
||||
dependencies = [
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "precomputed-hash"
|
||||
version = "0.1.1"
|
||||
@@ -2398,6 +2534,22 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "psl-types"
|
||||
version = "2.0.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac"
|
||||
|
||||
[[package]]
|
||||
name = "publicsuffix"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6f42ea446cab60335f76979ec15e12619a2165b5ae2c12166bef27d283a9fadf"
|
||||
dependencies = [
|
||||
"idna",
|
||||
"psl-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.39.4"
|
||||
@@ -2407,6 +2559,62 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn"
|
||||
version = "0.11.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"cfg_aliases",
|
||||
"pin-project-lite",
|
||||
"quinn-proto",
|
||||
"quinn-udp",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
"socket2",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn-proto"
|
||||
version = "0.11.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"getrandom 0.4.3",
|
||||
"lru-slab",
|
||||
"rand 0.10.2",
|
||||
"rand_pcg",
|
||||
"ring",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"slab",
|
||||
"thiserror 2.0.18",
|
||||
"tinyvec",
|
||||
"tracing",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn-udp"
|
||||
version = "0.5.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694"
|
||||
dependencies = [
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"socket2",
|
||||
"tracing",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.45"
|
||||
@@ -2428,6 +2636,61 @@ version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
|
||||
dependencies = [
|
||||
"rand_chacha",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
|
||||
dependencies = [
|
||||
"chacha20",
|
||||
"getrandom 0.4.3",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
|
||||
dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
||||
|
||||
[[package]]
|
||||
name = "rand_pcg"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
|
||||
dependencies = [
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "raw-window-handle"
|
||||
version = "0.6.2"
|
||||
@@ -2503,6 +2766,49 @@ version = "0.8.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
||||
|
||||
[[package]]
|
||||
name = "reqwest"
|
||||
version = "0.12.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"cookie",
|
||||
"cookie_store",
|
||||
"encoding_rs",
|
||||
"futures-core",
|
||||
"h2",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-rustls",
|
||||
"hyper-util",
|
||||
"js-sys",
|
||||
"log",
|
||||
"mime",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"quinn",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"tower-service",
|
||||
"url",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
"webpki-roots 1.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "reqwest"
|
||||
version = "0.13.4"
|
||||
@@ -2616,6 +2922,7 @@ version = "1.14.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
|
||||
dependencies = [
|
||||
"web-time",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
@@ -2625,7 +2932,7 @@ version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0"
|
||||
dependencies = [
|
||||
"core-foundation",
|
||||
"core-foundation 0.10.1",
|
||||
"core-foundation-sys",
|
||||
"jni 0.22.4",
|
||||
"log",
|
||||
@@ -2663,6 +2970,12 @@ version = "1.0.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
||||
|
||||
[[package]]
|
||||
name = "ryu"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
||||
|
||||
[[package]]
|
||||
name = "same-file"
|
||||
version = "1.0.6"
|
||||
@@ -2745,7 +3058,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"core-foundation",
|
||||
"core-foundation 0.10.1",
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
"security-framework-sys",
|
||||
@@ -2885,6 +3198,18 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_urlencoded"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
|
||||
dependencies = [
|
||||
"form_urlencoded",
|
||||
"itoa",
|
||||
"ryu",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_with"
|
||||
version = "3.21.0"
|
||||
@@ -2948,6 +3273,17 @@ dependencies = [
|
||||
"stable_deref_trait",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha1"
|
||||
version = "0.10.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.2.17",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.10.9"
|
||||
@@ -2955,7 +3291,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"cpufeatures 0.2.17",
|
||||
"digest",
|
||||
]
|
||||
|
||||
@@ -3137,6 +3473,17 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sync_wrapper"
|
||||
version = "1.0.2"
|
||||
@@ -3157,6 +3504,27 @@ dependencies = [
|
||||
"syn 2.0.118",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-configuration"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"core-foundation 0.9.4",
|
||||
"system-configuration-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-configuration-sys"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-deps"
|
||||
version = "6.2.2"
|
||||
@@ -3178,7 +3546,7 @@ checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"block2",
|
||||
"core-foundation",
|
||||
"core-foundation 0.10.1",
|
||||
"core-graphics",
|
||||
"crossbeam-channel",
|
||||
"dbus",
|
||||
@@ -3268,7 +3636,7 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
"plist",
|
||||
"raw-window-handle",
|
||||
"reqwest",
|
||||
"reqwest 0.13.4",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_repr",
|
||||
@@ -3367,6 +3735,54 @@ dependencies = [
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-fs"
|
||||
version = "2.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "de22eef34fd78c0da050e748710edd50bf127e651d02ea1b2bfada1523cc5c51"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"dunce",
|
||||
"glob",
|
||||
"log",
|
||||
"objc2-foundation",
|
||||
"percent-encoding",
|
||||
"schemars 0.8.22",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_repr",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"tauri-utils",
|
||||
"thiserror 2.0.18",
|
||||
"toml 1.1.2+spec-1.1.0",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-http"
|
||||
version = "2.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7241a0c762649be8fba7dd4cc84684d0e409f26b335a978ef4dd5fe78da74ce6"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"cookie_store",
|
||||
"data-url",
|
||||
"http",
|
||||
"regex",
|
||||
"reqwest 0.12.28",
|
||||
"schemars 0.8.22",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"tauri-plugin-fs",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"url",
|
||||
"urlpattern",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-process"
|
||||
version = "2.3.1"
|
||||
@@ -3393,7 +3809,7 @@ dependencies = [
|
||||
"minisign-verify",
|
||||
"osakit",
|
||||
"percent-encoding",
|
||||
"reqwest",
|
||||
"reqwest 0.13.4",
|
||||
"rustls",
|
||||
"semver",
|
||||
"serde",
|
||||
@@ -3410,6 +3826,26 @@ dependencies = [
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-websocket"
|
||||
version = "2.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ca243c7f0bf935cd81123e07f82188ccb919b19fbfc74518b947eedc4619bbb"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"http",
|
||||
"log",
|
||||
"rand 0.9.5",
|
||||
"rustls",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tokio-tungstenite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-runtime"
|
||||
version = "2.11.3"
|
||||
@@ -3639,9 +4075,21 @@ dependencies = [
|
||||
"mio",
|
||||
"pin-project-lite",
|
||||
"socket2",
|
||||
"tokio-macros",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-macros"
|
||||
version = "2.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-rustls"
|
||||
version = "0.26.4"
|
||||
@@ -3652,6 +4100,22 @@ dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-tungstenite"
|
||||
version = "0.29.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"log",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tungstenite",
|
||||
"webpki-roots 0.26.11",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-util"
|
||||
version = "0.7.18"
|
||||
@@ -3877,6 +4341,24 @@ version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||
|
||||
[[package]]
|
||||
name = "tungstenite"
|
||||
version = "0.29.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"data-encoding",
|
||||
"http",
|
||||
"httparse",
|
||||
"log",
|
||||
"rand 0.9.5",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"sha1",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typeid"
|
||||
version = "1.0.3"
|
||||
@@ -4141,6 +4623,16 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web-time"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web_atoms"
|
||||
version = "0.2.5"
|
||||
@@ -4206,6 +4698,24 @@ dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "0.26.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
|
||||
dependencies = [
|
||||
"webpki-roots 1.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webview2-com"
|
||||
version = "0.38.2"
|
||||
@@ -4391,6 +4901,17 @@ dependencies = [
|
||||
"windows-link 0.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-registry"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
|
||||
dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
"windows-result 0.4.1",
|
||||
"windows-strings 0.5.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.3.4"
|
||||
@@ -4820,6 +5341,26 @@ dependencies = [
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.56"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb"
|
||||
dependencies = [
|
||||
"zerocopy-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy-derive"
|
||||
version = "0.8.56"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.118",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerofrom"
|
||||
version = "0.1.8"
|
||||
|
||||
@@ -22,6 +22,17 @@ serde_json = "1"
|
||||
# Auto-update: prompt the operator, download a signed update, relaunch.
|
||||
tauri-plugin-updater = "2"
|
||||
tauri-plugin-process = "2"
|
||||
# HTTP client for the SPA's API/WS calls to the local Fastify server. The window
|
||||
# runs at tauri://localhost, which WebKitGTK treats as a secure origin — a plain
|
||||
# http://127.0.0.1:3000 fetch() from inside it is blocked as mixed content (a
|
||||
# long-standing WebKit limitation, not fixable via CSP). Routing through this
|
||||
# plugin sends the request via Tauri's Rust side instead of the webview's own
|
||||
# fetch, sidestepping the browser mixed-content check entirely.
|
||||
tauri-plugin-http = "2"
|
||||
# Same mixed-content problem as above, but for the live-feed WebSocket
|
||||
# (ws://127.0.0.1:3000 from the secure tauri://localhost origin) — HTTP and WS
|
||||
# are separate browser checks, so this needs its own plugin.
|
||||
tauri-plugin-websocket = "2"
|
||||
|
||||
[features]
|
||||
# Used by `tauri dev`/CLI for hot-reload of the Rust side.
|
||||
|
||||
@@ -6,6 +6,14 @@
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"updater:default",
|
||||
"process:default"
|
||||
"process:default",
|
||||
"websocket:default",
|
||||
{
|
||||
"identifier": "http:default",
|
||||
"allow": [
|
||||
{ "url": "http://127.0.0.1:3000" },
|
||||
{ "url": "http://localhost:3000" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -16,6 +16,13 @@ pub fn run() {
|
||||
// endpoint + signing pubkey live in tauri.conf.json.
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
.plugin(tauri_plugin_process::init())
|
||||
// Routes the SPA's fetch()/WS calls to the local Fastify server through
|
||||
// Tauri's native HTTP client — see the Cargo.toml comment on why the
|
||||
// webview's own fetch() can't reach http://127.0.0.1:3000 directly.
|
||||
.plugin(tauri_plugin_http::init())
|
||||
// Live-feed WebSocket — same mixed-content reason as the HTTP plugin
|
||||
// above, but WS needs its own plugin (separate browser check).
|
||||
.plugin(tauri_plugin_websocket::init())
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running the Parking System desktop shell");
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Parking System",
|
||||
"version": "0.0.0",
|
||||
"version": "0.1.0",
|
||||
"identifier": "com.parking.desktop",
|
||||
"build": {
|
||||
"devUrl": "http://localhost:5173",
|
||||
"frontendDist": "../../web/dist",
|
||||
"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": {
|
||||
"windows": [
|
||||
@@ -41,9 +41,9 @@
|
||||
},
|
||||
"plugins": {
|
||||
"updater": {
|
||||
"//": "Stable 'latest release' path on Gitea — redirects to the newest tag's latest.json (published by .gitea/workflows/release.yml). The updater GETs this, gets the manifest (platforms.linux-x86_64.{signature,url}), and compares versions. The release is reachable to the appliance only when it's brought online (phone hotspot); offline-first means a failed check is a no-op.",
|
||||
"//": "Points at mca/public_releases, NOT this (private, source) repo — the updater runs on offline-first field appliances with no Gitea credentials, so the endpoint must be reachable unauthenticated. That repo is public and holds only compiled installers (no source), mirrored here by .gitea/workflows/release.yml. NOT the 'latest release' redirect: public_releases is shared across apps in the org, so 'latest' there could be someone else's release. This URL names our own most-recent tag directly (desktop-vX.Y.Z, bumped by the release workflow each publish) so a newer unrelated app release never shadows ours. The updater GETs this, gets the manifest (platforms.linux-x86_64.{signature,url}), and compares versions. The release is reachable to the appliance only when it's brought online (phone hotspot); offline-first means a failed check is a no-op.",
|
||||
"endpoints": [
|
||||
"https://git.infra.msai.al/mca/parking_solution/releases/latest/download/latest.json"
|
||||
"https://git.infra.msai.al/mca/public_releases/releases/download/desktop-latest/latest.json"
|
||||
],
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDgxNzg5RUQ1QkM0Q0FDRjYKUldUMnJFeTgxWjU0Z1RlNmhneDVZQlVVTVZZdGhJTkUxTGdDeGYwQSttZmNKVVp5WEdVMWlBb1YK"
|
||||
}
|
||||
|
||||
@@ -47,6 +47,11 @@ RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
|
||||
# ---- runtime: slim, non-root ----
|
||||
FROM node:22-alpine AS runtime
|
||||
WORKDIR /app
|
||||
# Set by CI to "<branch>-<short-sha>" (e.g. "stage-28bd838"), matching the same string used
|
||||
# as the Komodo Stack's TAG (komodo/resources.toml) — so the version shown in the app is the
|
||||
# same string an admin would look up there. Empty/absent on a local `docker build` (dev only).
|
||||
ARG BUILD_VERSION=""
|
||||
ENV BUILD_VERSION=$BUILD_VERSION
|
||||
ENV NODE_ENV=production
|
||||
RUN apk add --no-cache libstdc++ # better-sqlite3 native runtime
|
||||
RUN addgroup -S app && adduser -S -G app app
|
||||
|
||||
@@ -16,7 +16,7 @@ import { createRequire } from "node:module";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const bcrypt = require("bcrypt");
|
||||
const { createDb, users, eq } = require("@parking/db");
|
||||
const { createDb, users, roles, eq } = require("@parking/db");
|
||||
|
||||
const DEFAULT_USERNAME = "admin";
|
||||
|
||||
@@ -53,6 +53,14 @@ if (!password || password.length < 8) {
|
||||
}
|
||||
|
||||
const db = createDb();
|
||||
|
||||
// Self-heal the built-in `admin` ROLE row. Migration 0007 seeds it once, but the
|
||||
// training reset (reset-db.mjs --users/--all) wipes the roles table and points here
|
||||
// to re-seed — without this, the user insert dies on the role_id FOREIGN KEY (field
|
||||
// failure 2026-07-06). The admin permission SET is resolved in code (auth.ts), so
|
||||
// the row alone is all the FK needs.
|
||||
await db.insert(roles).values({ id: "admin", name: "Admin", builtin: 1 }).onConflictDoNothing();
|
||||
|
||||
const existing = await db.select().from(users).where(eq(users.username, username)).get();
|
||||
if (existing && process.env.FORCE !== "1") {
|
||||
console.error(`user "${username}" already exists (set FORCE=1 to reset the password)`);
|
||||
@@ -73,4 +81,30 @@ if (existing) {
|
||||
});
|
||||
console.log(`created admin "${username}"`);
|
||||
}
|
||||
|
||||
// Record the action into the SIGNED ledger (config_change). A console seed/reset is
|
||||
// a Linux-admin action the app can't gate — but it must stay ATTRIBUTABLE after the
|
||||
// fact (the chain is the audit record; whoever holds root can reset a password, they
|
||||
// can't do it silently). Uses the server's own compiled EventLog + signer from dist/
|
||||
// (present in the container; in a dev checkout run `pnpm build` first). Best-effort:
|
||||
// a missing build or signing key WARNS loudly but never blocks the seed — locking an
|
||||
// admin out to protect an audit line would invert the priority.
|
||||
try {
|
||||
const { EventLog } = await import("../dist/event-log.js");
|
||||
const { buildSigner } = await import("../dist/signer.js");
|
||||
const log = new EventLog(db, buildSigner());
|
||||
await log.append({
|
||||
type: "config_change",
|
||||
source: "manual",
|
||||
identity: `user:${username}`,
|
||||
payload: {
|
||||
setting: existing ? "admin.passwordReset" : "admin.seeded",
|
||||
username,
|
||||
operator: "console:seed-admin",
|
||||
},
|
||||
});
|
||||
console.log("recorded to the signed ledger (config_change)");
|
||||
} catch (err) {
|
||||
console.warn(`WARNING: NOT recorded to the signed ledger: ${err.message}`);
|
||||
}
|
||||
process.exit(0);
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { eq, siteConfig } from "@parking/db";
|
||||
import { createTestDb } from "@parking/db/testing";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { BackupService } from "./backup-service.js";
|
||||
|
||||
// BackupService previously tracked last-success/last-error as plain in-process fields, so a
|
||||
// server restart (a fresh BackupService instance, exactly as happens on every deploy/crash/OOM
|
||||
// reboot under `restart: always`) silently reset the admin UI to "last successful backup:
|
||||
// Never" — even with valid, correctly-rotating backups already on disk (2026-08-30 field
|
||||
// incident, park-buzi). These tests exercise the fix: status is read from site_config, so a new
|
||||
// BackupService instance pointed at the same DB sees the prior instance's last-run outcome, and
|
||||
// the schedule is wall-clock-based (isDue()) rather than time-since-process-start.
|
||||
// See wiki/concepts/backup-recovery.md.
|
||||
|
||||
const KEY = "a-test-backup-key-that-is-long-enough";
|
||||
|
||||
let workDir: string;
|
||||
let target: string;
|
||||
|
||||
beforeEach(() => {
|
||||
workDir = mkdtempSync(join(tmpdir(), "pk-backup-service-test-"));
|
||||
target = join(workDir, "target");
|
||||
process.env.BACKUP_KEY = KEY;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(workDir, { recursive: true, force: true });
|
||||
delete process.env.BACKUP_KEY;
|
||||
});
|
||||
|
||||
function setTargetDir(db: ReturnType<typeof createTestDb>["db"], dir: string): void {
|
||||
const existing = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||
if (existing) {
|
||||
db.update(siteConfig).set({ backupTargetDir: dir }).where(eq(siteConfig.id, 1)).run();
|
||||
} else {
|
||||
db.insert(siteConfig).values({ id: 1, backupTargetDir: dir }).run();
|
||||
}
|
||||
}
|
||||
|
||||
describe("BackupService — persisted status survives a restart", () => {
|
||||
it("a fresh instance sees the previous instance's last success", async () => {
|
||||
const t = createTestDb();
|
||||
setTargetDir(t.db, target);
|
||||
|
||||
const first = new BackupService(t.db);
|
||||
expect(first.status().lastSuccessAt).toBeNull();
|
||||
const result = await first.run("manual");
|
||||
|
||||
// Simulate a process restart: a brand-new BackupService over the SAME db handle (in
|
||||
// production this would be a fresh process re-opening the same sqlite file).
|
||||
const second = new BackupService(t.db);
|
||||
const status = second.status();
|
||||
expect(status.lastSuccessAt).not.toBeNull();
|
||||
expect(status.lastResult).toEqual({ path: result.path, bytes: result.bytes, prunedFiles: result.prunedFiles });
|
||||
expect(status.lastError).toBeNull();
|
||||
|
||||
t.close();
|
||||
});
|
||||
|
||||
it("a fresh instance sees the previous instance's last error, and it clears on next success", async () => {
|
||||
const t = createTestDb();
|
||||
// Target dir set, but as a FILE (not a directory) — runBackup's mkdir(recursive) will
|
||||
// throw, giving us a real, deterministic failure without needing to mock anything.
|
||||
const badTarget = join(workDir, "not-a-dir");
|
||||
writeFileSync(badTarget, "x");
|
||||
setTargetDir(t.db, badTarget);
|
||||
|
||||
const first = new BackupService(t.db);
|
||||
await expect(first.run("manual")).rejects.toThrow();
|
||||
|
||||
const second = new BackupService(t.db);
|
||||
const status = second.status();
|
||||
expect(status.lastError).not.toBeNull();
|
||||
expect(status.lastErrorAt).not.toBeNull();
|
||||
expect(status.lastSuccessAt).toBeNull();
|
||||
|
||||
// Now point at a real directory and succeed — the persisted error must clear.
|
||||
setTargetDir(t.db, target);
|
||||
await second.run("manual");
|
||||
const third = new BackupService(t.db);
|
||||
const finalStatus = third.status();
|
||||
expect(finalStatus.lastSuccessAt).not.toBeNull();
|
||||
expect(finalStatus.lastError).toBeNull();
|
||||
expect(finalStatus.lastErrorAt).toBeNull();
|
||||
|
||||
t.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("BackupService — isDue() is wall-clock-based, not process-uptime-based", () => {
|
||||
it("is due immediately when no success has ever been recorded", () => {
|
||||
const t = createTestDb();
|
||||
const svc = new BackupService(t.db);
|
||||
expect(svc.isDue()).toBe(true);
|
||||
t.close();
|
||||
});
|
||||
|
||||
it("is NOT due right after a fresh instance is constructed, if a recent success is persisted", async () => {
|
||||
const t = createTestDb();
|
||||
setTargetDir(t.db, target);
|
||||
const first = new BackupService(t.db);
|
||||
await first.run("manual");
|
||||
|
||||
// The whole point of the fix: a brand-new instance (simulating a restart moments after a
|
||||
// real backup completed) must NOT think a backup is due just because ITS OWN uptime is ~0.
|
||||
const second = new BackupService(t.db);
|
||||
expect(second.isDue()).toBe(false);
|
||||
t.close();
|
||||
});
|
||||
|
||||
it("is due once the persisted last-success timestamp is old enough", async () => {
|
||||
const t = createTestDb();
|
||||
setTargetDir(t.db, target);
|
||||
const svc = new BackupService(t.db);
|
||||
await svc.run("manual");
|
||||
|
||||
const almostADayLater = new Date(Date.now() + 23 * 60 * 60 * 1000);
|
||||
expect(svc.isDue(almostADayLater)).toBe(false);
|
||||
|
||||
const overADayLater = new Date(Date.now() + 24 * 60 * 60 * 1000 + 1000);
|
||||
expect(svc.isDue(overADayLater)).toBe(true);
|
||||
t.close();
|
||||
});
|
||||
|
||||
it("runScheduled() is a no-op when not yet due, even if configured", async () => {
|
||||
const t = createTestDb();
|
||||
setTargetDir(t.db, target);
|
||||
const svc = new BackupService(t.db);
|
||||
await svc.run("manual");
|
||||
const afterFirst = svc.status().lastSuccessAt;
|
||||
|
||||
await svc.runScheduled(); // not due yet — must not run again
|
||||
expect(svc.status().lastSuccessAt).toBe(afterFirst);
|
||||
t.close();
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,12 @@ import { DEFAULT_BACKUP_RETENTION, runBackup, type BackupResult, type BackupRete
|
||||
// a key must never live in the DB it backs up. Remembers the last outcome so the route + UI can
|
||||
// show last-success / last-error, and serializes concurrent runs (manual + timer). See
|
||||
// wiki/concepts/backup-recovery.md.
|
||||
//
|
||||
// Last-success/last-error are PERSISTED to site_config (backup_last_*), not just held in
|
||||
// memory — an earlier version tracked these as plain in-process fields only, so every server
|
||||
// restart (deploy, crash, OOM, host reboot — all routine under `restart: always`) silently
|
||||
// reset the admin UI to "last successful backup: Never", even with valid, correctly-rotating
|
||||
// backups already on disk (2026-08-30 field incident, park-buzi). See wiki/concepts/backup-recovery.md.
|
||||
|
||||
/** The dedicated backup-encryption key, from env (NOT the DB). Separate from EVENT_SIGNING_KEY. */
|
||||
export function backupKeyFromEnv(): string {
|
||||
@@ -65,16 +71,33 @@ export class BackupService {
|
||||
readonly #logger?: FastifyBaseLogger;
|
||||
|
||||
#running = false;
|
||||
#lastSuccessAt: string | null = null;
|
||||
#lastResult: BackupResult | null = null;
|
||||
#lastErrorAt: string | null = null;
|
||||
#lastError: string | null = null;
|
||||
|
||||
constructor(db: Db, logger?: FastifyBaseLogger) {
|
||||
this.#db = db;
|
||||
this.#logger = logger;
|
||||
}
|
||||
|
||||
/** Fresh read of the persisted row (single source of truth — no in-memory cache to go stale
|
||||
* or reset on restart). */
|
||||
#row(): { backupLastSuccessAt: string | null; backupLastResultJson: string | null; backupLastErrorAt: string | null; backupLastError: string | null } | undefined {
|
||||
return this.#db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||
}
|
||||
|
||||
#persist(patch: {
|
||||
backupLastSuccessAt?: string | null;
|
||||
backupLastResultJson?: string | null;
|
||||
backupLastErrorAt?: string | null;
|
||||
backupLastError?: string | null;
|
||||
}): void {
|
||||
const updatedAt = new Date().toISOString();
|
||||
const existing = this.#row();
|
||||
if (existing) {
|
||||
this.#db.update(siteConfig).set({ ...patch, updatedAt }).where(eq(siteConfig.id, 1)).run();
|
||||
} else {
|
||||
this.#db.insert(siteConfig).values({ id: 1, ...patch, updatedAt }).run();
|
||||
}
|
||||
}
|
||||
|
||||
/** The admin-chosen target dir from site_config (null/empty = unset). Read fresh each call. */
|
||||
targetDir(): string | null {
|
||||
const row = this.#db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||
@@ -104,6 +127,15 @@ export class BackupService {
|
||||
|
||||
status(): BackupStatus {
|
||||
const r = this.retention();
|
||||
const row = this.#row();
|
||||
let lastResult: BackupStatus["lastResult"] = null;
|
||||
if (row?.backupLastResultJson) {
|
||||
try {
|
||||
lastResult = JSON.parse(row.backupLastResultJson) as BackupStatus["lastResult"];
|
||||
} catch {
|
||||
lastResult = null; // corrupt/foreign value in the column — don't let it crash status()
|
||||
}
|
||||
}
|
||||
return {
|
||||
configured: this.configured,
|
||||
targetDir: this.targetDir(),
|
||||
@@ -111,12 +143,10 @@ export class BackupService {
|
||||
keepDailyDays: r.keepDailyDays,
|
||||
keyPresent: this.keyPresent,
|
||||
running: this.#running,
|
||||
lastSuccessAt: this.#lastSuccessAt,
|
||||
lastResult: this.#lastResult
|
||||
? { path: this.#lastResult.path, bytes: this.#lastResult.bytes, prunedFiles: this.#lastResult.prunedFiles }
|
||||
: null,
|
||||
lastErrorAt: this.#lastErrorAt,
|
||||
lastError: this.#lastError,
|
||||
lastSuccessAt: row?.backupLastSuccessAt ?? null,
|
||||
lastResult,
|
||||
lastErrorAt: row?.backupLastErrorAt ?? null,
|
||||
lastError: row?.backupLastError ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -139,14 +169,17 @@ export class BackupService {
|
||||
try {
|
||||
this.#logger?.info(`backup: starting (${trigger}) → ${targetDir}`);
|
||||
const res = await runBackup(this.#db, { targetDir, key, retention: this.retention() }, this.#logger);
|
||||
this.#lastResult = res;
|
||||
this.#lastSuccessAt = new Date().toISOString();
|
||||
this.#lastError = null;
|
||||
this.#persist({
|
||||
backupLastSuccessAt: new Date().toISOString(),
|
||||
backupLastResultJson: JSON.stringify({ path: res.path, bytes: res.bytes, prunedFiles: res.prunedFiles }),
|
||||
backupLastErrorAt: null,
|
||||
backupLastError: null,
|
||||
});
|
||||
return res;
|
||||
} catch (err) {
|
||||
this.#lastError = (err as Error).message;
|
||||
this.#lastErrorAt = new Date().toISOString();
|
||||
this.#logger?.error(`backup: failed (${trigger}): ${this.#lastError}`);
|
||||
const message = (err as Error).message;
|
||||
this.#persist({ backupLastErrorAt: new Date().toISOString(), backupLastError: message });
|
||||
this.#logger?.error(`backup: failed (${trigger}): ${message}`);
|
||||
throw err;
|
||||
} finally {
|
||||
this.#running = false;
|
||||
@@ -156,13 +189,34 @@ export class BackupService {
|
||||
return this.#inflight;
|
||||
}
|
||||
|
||||
/** Scheduled-run wrapper: never throws (a timer must not crash the process). */
|
||||
/**
|
||||
* Scheduled-run wrapper: never throws (a timer must not crash the process). Safe to call on
|
||||
* a short, frequent poll (see server.ts) — it's a no-op unless `isDue()` says a full interval
|
||||
* has actually elapsed since the last recorded success, so frequent polling doesn't cause
|
||||
* frequent backups.
|
||||
*/
|
||||
async runScheduled(): Promise<void> {
|
||||
if (!this.configured) return; // silent no-op when backups aren't set up
|
||||
if (!this.isDue()) return;
|
||||
try {
|
||||
await this.run("scheduled");
|
||||
} catch {
|
||||
/* recorded in last-error; already logged */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wall-clock check: has enough time elapsed since the last successful backup for a new one
|
||||
* to be due? Deliberately based on the PERSISTED last-success instant, not "time since this
|
||||
* process started" — a `setInterval(..., 24h)` measured from process start silently drifts
|
||||
* (or skips a whole day) across every restart, since the countdown restarts from zero each
|
||||
* time regardless of when the last real backup happened. See wiki/concepts/backup-recovery.md.
|
||||
*/
|
||||
isDue(now: Date = new Date(), intervalMs = 24 * 60 * 60 * 1000): boolean {
|
||||
const lastSuccessAt = this.#row()?.backupLastSuccessAt;
|
||||
if (!lastSuccessAt) return true; // never recorded a success → due immediately once configured
|
||||
const last = new Date(lastSuccessAt).getTime();
|
||||
if (Number.isNaN(last)) return true;
|
||||
return now.getTime() - last >= intervalMs;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,6 +80,8 @@ function receiptFigures(
|
||||
currency?: string;
|
||||
tender?: "cash" | "card";
|
||||
graceExitMin?: number;
|
||||
grossMinor?: number;
|
||||
validationLines?: { label: string; discountMinor: number }[];
|
||||
};
|
||||
return {
|
||||
ticketId,
|
||||
@@ -89,6 +91,9 @@ function receiptFigures(
|
||||
currency: p.currency ?? "ALL",
|
||||
tender: p.tender === "card" ? "card" : "cash",
|
||||
graceExitMin: typeof p.graceExitMin === "number" ? p.graceExitMin : null,
|
||||
// Merchant validations, as settled on the signed payment (gross → lines → net).
|
||||
grossMinor: typeof p.grossMinor === "number" ? p.grossMinor : null,
|
||||
validationLines: Array.isArray(p.validationLines) ? p.validationLines : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -192,11 +192,78 @@ describe("ButtonLightController truth table", () => {
|
||||
// First write (initial off) throws — must be swallowed.
|
||||
expect(() => ctl.start()).not.toThrow();
|
||||
await flush();
|
||||
// Subsequent writes work; driving to solid still converges to ON.
|
||||
// The failure arms a backoff (1s) rather than retrying inline; desired-state
|
||||
// changes during the window just update the target the retry will assert.
|
||||
lane(true);
|
||||
radar(true);
|
||||
await flush();
|
||||
expect(ctl.confirmedOf(CONTROLLER)).toBeNull(); // still backing off
|
||||
await vi.advanceTimersByTimeAsync(1000); // retry fires; aux is healthy again
|
||||
expect(ctl.confirmedOf(CONTROLLER)).toBe(true); // converged to solid ON
|
||||
ctl.stop();
|
||||
});
|
||||
|
||||
it("an unreachable controller backs off (1s→30s), not a hot retry loop", async () => {
|
||||
let attempts = 0;
|
||||
const aux: AuxOutputDevice = {
|
||||
async setAux() {
|
||||
attempts += 1;
|
||||
throw new Error("send ENETUNREACH 10.0.10.5:60000");
|
||||
},
|
||||
};
|
||||
const errors: string[] = [];
|
||||
const logger = silentLogger();
|
||||
(logger as { error: (msg: string) => void }).error = (msg) => errors.push(msg);
|
||||
const ctl = new ButtonLightController(db, logger, () => aux);
|
||||
ctl.start(); // initial OFF write → attempt 1 fails at t=0
|
||||
await flush();
|
||||
expect(attempts).toBe(1); // the old code hot-looped here
|
||||
|
||||
// Failures at t≈0,1,3,7,15,31 (doubling, capped 30s) → 6 attempts in the first
|
||||
// minute instead of thousands.
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
expect(attempts).toBeGreaterThanOrEqual(5);
|
||||
expect(attempts).toBeLessThanOrEqual(7);
|
||||
|
||||
// Only the FIRST failure was logged so far; the next log is a ≥60s summary.
|
||||
expect(errors).toHaveLength(1);
|
||||
await vi.advanceTimersByTimeAsync(35_000); // t≈95s → the t=61s attempt logged a summary
|
||||
expect(errors.length).toBe(2);
|
||||
expect(errors[1]).toContain("still failing");
|
||||
ctl.stop();
|
||||
});
|
||||
|
||||
it("logs a single recovery line and resets the backoff after success", async () => {
|
||||
let failing = true;
|
||||
let attempts = 0;
|
||||
const aux: AuxOutputDevice = {
|
||||
async setAux() {
|
||||
attempts += 1;
|
||||
if (failing) throw new Error("send ENETUNREACH 10.0.10.5:60000");
|
||||
},
|
||||
};
|
||||
const infos: string[] = [];
|
||||
const logger = silentLogger();
|
||||
(logger as { info: (msg: string) => void }).info = (msg) => infos.push(msg);
|
||||
const ctl = new ButtonLightController(db, logger, () => aux);
|
||||
ctl.start();
|
||||
await flush();
|
||||
await vi.advanceTimersByTimeAsync(3_000); // attempts at t=0,1,3 all fail
|
||||
const failed = attempts;
|
||||
expect(failed).toBeGreaterThanOrEqual(3);
|
||||
|
||||
failing = false; // controller reachable again
|
||||
await vi.advanceTimersByTimeAsync(8_000); // next armed retry succeeds
|
||||
expect(ctl.confirmedOf(CONTROLLER)).toBe(false); // OFF asserted on the device
|
||||
expect(infos.filter((m) => m.includes("recovered"))).toHaveLength(1);
|
||||
|
||||
// Backoff reset: a fresh state change sends immediately (no lingering retryAt).
|
||||
const before = attempts;
|
||||
lane(true);
|
||||
radar(true);
|
||||
await flush();
|
||||
expect(ctl.confirmedOf(CONTROLLER)).toBe(true);
|
||||
expect(attempts).toBe(before + 1);
|
||||
ctl.stop();
|
||||
});
|
||||
|
||||
|
||||
@@ -21,6 +21,14 @@ type LightState = "off" | "solid" | "blink";
|
||||
|
||||
const DEFAULT_BLINK_MS = 500;
|
||||
|
||||
// Failed-send retry backoff: 1s doubling to 30s, reset on success. Without this an
|
||||
// unreachable controller (ENETUNREACH) became a hot loop — the failure re-pump retried
|
||||
// instantly, thousands of sends + error lines per minute (field incident 2026-07-07).
|
||||
const RETRY_BASE_MS = 1_000;
|
||||
const RETRY_MAX_MS = 30_000;
|
||||
/** After the first failure of a streak, log at most one summary line per this window. */
|
||||
const FAIL_LOG_EVERY_MS = 60_000;
|
||||
|
||||
/** Per-lamp live state for the alert rule (one per radarAlert relay). */
|
||||
interface LampState {
|
||||
/** The controller this lamp lives on (its deviceId) — for resolving the aux adapter. */
|
||||
@@ -44,6 +52,16 @@ interface LampState {
|
||||
/** True while a send is in flight for this lamp — serializes UDP so on/off can't
|
||||
* overlap or reorder (UDP is unordered; concurrent toggles left the relay stuck). */
|
||||
sending: boolean;
|
||||
/** Consecutive failed sends (0 = healthy). Drives the backoff delay + log summaries. */
|
||||
failCount: number;
|
||||
/** Epoch ms before which #pump must not send (0 = no backoff). The armed retry
|
||||
* timer re-pumps when it elapses; desired-state changes in between just update
|
||||
* `desiredOn` and are picked up by that same retry. */
|
||||
retryAt: number;
|
||||
/** The armed backoff retry, if any. */
|
||||
retryTimer: ReturnType<typeof setTimeout> | null;
|
||||
/** Epoch ms of the last failure line we actually logged (rate-limits the flood). */
|
||||
lastFailLogAt: number;
|
||||
}
|
||||
|
||||
/** Resolves a controller's live aux-output adapter. The default goes through the
|
||||
@@ -111,6 +129,10 @@ export class ButtonLightController {
|
||||
desiredOn: false,
|
||||
confirmedOn: null,
|
||||
sending: false,
|
||||
failCount: 0,
|
||||
retryAt: 0,
|
||||
retryTimer: null,
|
||||
lastFailLogAt: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -118,10 +140,7 @@ export class ButtonLightController {
|
||||
// Drop lamps whose controller no longer declares one (or was disabled/removed).
|
||||
for (const [key, lamp] of this.#lamps) {
|
||||
if (seen.has(key)) continue;
|
||||
if (lamp.blink) {
|
||||
clearInterval(lamp.blink);
|
||||
lamp.blink = null;
|
||||
}
|
||||
this.#disarm(lamp);
|
||||
this.#finalOff(lamp); // best-effort fail-OFF before forgetting it
|
||||
this.#lamps.delete(key);
|
||||
}
|
||||
@@ -207,10 +226,17 @@ export class ButtonLightController {
|
||||
* time. Because UDP is unordered, concurrent on/off sends previously raced and left
|
||||
* the relay stuck on a stale packet. Here a single in-flight send is guaranteed
|
||||
* (`sending` guard); when it resolves, if the desired state moved on we send again —
|
||||
* so the LAST desired state is always the one finally asserted on the device. */
|
||||
* so the LAST desired state is always the one finally asserted on the device.
|
||||
*
|
||||
* Failures back off (1s → 30s, reset on success) instead of retrying inline: an
|
||||
* unreachable controller rejects instantly, and an immediate re-pump was a hot loop.
|
||||
* During backoff `desiredOn` keeps tracking the truth table; the armed retry timer
|
||||
* converges to whatever it says when it fires. Only the FIRST failure of a streak is
|
||||
* logged, then one summary per minute, and an info line on recovery. */
|
||||
#pump(lamp: LampState): void {
|
||||
if (lamp.sending) return; // a send is already in flight; it'll re-check on completion
|
||||
if (lamp.confirmedOn === lamp.desiredOn) return; // already there — no redundant UDP
|
||||
if (Date.now() < lamp.retryAt) return; // backing off — the retry timer will re-pump
|
||||
const aux = this.#resolveAux(lamp.controllerId);
|
||||
if (!aux) return;
|
||||
const target = lamp.desiredOn;
|
||||
@@ -219,15 +245,42 @@ export class ButtonLightController {
|
||||
.setAux(lamp.spec.relay, target)
|
||||
.then(() => {
|
||||
lamp.confirmedOn = target;
|
||||
if (lamp.failCount > 0) {
|
||||
this.#logger.info(
|
||||
`button-light setAux recovered (${lamp.controllerId} R${lamp.spec.relay}) after ${lamp.failCount} failed attempts`,
|
||||
);
|
||||
}
|
||||
lamp.failCount = 0;
|
||||
lamp.retryAt = 0;
|
||||
lamp.lastFailLogAt = 0;
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
// Leave confirmedOn unchanged so the next pump retries this state. Never escalates.
|
||||
this.#logger.error(`button-light setAux failed (${lamp.controllerId} R${lamp.spec.relay}): ${(err as Error).message}`);
|
||||
// Leave confirmedOn unchanged so the armed retry re-asserts the (then-current)
|
||||
// desired state. Never escalates — a dead lamp is "no hint", never a fault.
|
||||
lamp.failCount += 1;
|
||||
const delay = Math.min(RETRY_BASE_MS * 2 ** (lamp.failCount - 1), RETRY_MAX_MS);
|
||||
lamp.retryAt = Date.now() + delay;
|
||||
const now = Date.now();
|
||||
if (lamp.failCount === 1 || now - lamp.lastFailLogAt >= FAIL_LOG_EVERY_MS) {
|
||||
lamp.lastFailLogAt = now;
|
||||
const streak =
|
||||
lamp.failCount > 1 ? ` — still failing (attempt ${lamp.failCount}, retrying ≤${RETRY_MAX_MS / 1000}s)` : "";
|
||||
this.#logger.error(
|
||||
`button-light setAux failed (${lamp.controllerId} R${lamp.spec.relay}): ${(err as Error).message}${streak}`,
|
||||
);
|
||||
}
|
||||
if (lamp.retryTimer) clearTimeout(lamp.retryTimer);
|
||||
lamp.retryTimer = setTimeout(() => {
|
||||
lamp.retryTimer = null;
|
||||
this.#pump(lamp);
|
||||
}, delay);
|
||||
lamp.retryTimer.unref?.();
|
||||
})
|
||||
.finally(() => {
|
||||
lamp.sending = false;
|
||||
// Desired state may have changed (or the send failed) while we were busy —
|
||||
// re-pump to converge. This is what makes the final state authoritative.
|
||||
// Desired state may have changed while we were busy — re-pump to converge (the
|
||||
// backoff gate above makes this a no-op right after a failure). This is what
|
||||
// makes the final state authoritative.
|
||||
if (lamp.confirmedOn !== lamp.desiredOn) this.#pump(lamp);
|
||||
});
|
||||
}
|
||||
@@ -261,20 +314,31 @@ export class ButtonLightController {
|
||||
this.#unsubInput = null;
|
||||
this.#unsubLane = null;
|
||||
for (const lamp of this.#lamps.values()) {
|
||||
if (lamp.blink) {
|
||||
clearInterval(lamp.blink);
|
||||
lamp.blink = null;
|
||||
}
|
||||
this.#disarm(lamp);
|
||||
// Best-effort fail-OFF on shutdown.
|
||||
this.#finalOff(lamp);
|
||||
}
|
||||
}
|
||||
|
||||
/** Stop a lamp's timers (blink + backoff retry) without touching the device. */
|
||||
#disarm(lamp: LampState): void {
|
||||
if (lamp.blink) {
|
||||
clearInterval(lamp.blink);
|
||||
lamp.blink = null;
|
||||
}
|
||||
if (lamp.retryTimer) {
|
||||
clearTimeout(lamp.retryTimer);
|
||||
lamp.retryTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Drive a lamp OFF as a one-shot (used when dropping/stopping a lamp): set desired
|
||||
* OFF and pump. The serialized worker still applies, so this can't collide with an
|
||||
* in-flight send — it converges to OFF. */
|
||||
* in-flight send — it converges to OFF. Any backoff is waived so the last-gasp OFF
|
||||
* gets one immediate try (a lamp mid-backoff may just have recovered). */
|
||||
#finalOff(lamp: LampState): void {
|
||||
lamp.desiredOn = false;
|
||||
lamp.retryAt = 0;
|
||||
this.#pump(lamp);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { localIsoWithOffset } from "./device-monitor.js";
|
||||
|
||||
// The camera clock-sync sends the SITE's wall-clock now with an explicit UTC offset
|
||||
// (ISAPI localTime) — the offset is what makes the instant unambiguous regardless of
|
||||
// the camera's own tz/DST config. Pin the DST both-sides behaviour for the site tz.
|
||||
|
||||
describe("localIsoWithOffset (camera clock sync payload)", () => {
|
||||
it("Tirane summer = +02:00 (CEST)", () => {
|
||||
expect(localIsoWithOffset("Europe/Tirane", new Date("2026-07-07T10:00:00Z"))).toBe(
|
||||
"2026-07-07T12:00:00+02:00",
|
||||
);
|
||||
});
|
||||
it("Tirane winter = +01:00 (CET)", () => {
|
||||
expect(localIsoWithOffset("Europe/Tirane", new Date("2026-01-15T10:00:00Z"))).toBe(
|
||||
"2026-01-15T11:00:00+01:00",
|
||||
);
|
||||
});
|
||||
it("UTC = +00:00", () => {
|
||||
expect(localIsoWithOffset("UTC", new Date("2026-07-07T10:00:00Z"))).toBe(
|
||||
"2026-07-07T10:00:00+00:00",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { FastifyBaseLogger } from "fastify";
|
||||
import { devices, type Db, type DeviceRow } from "@parking/db";
|
||||
import { isMonitorable, registry } from "@parking/devices";
|
||||
import { isClockSyncable, isMonitorable, registry, type Device } from "@parking/devices";
|
||||
import { deviceEvents, type DeviceStatusEvent } from "./device-events.js";
|
||||
import { directionOf, relaysOf } from "./device-resolve.js";
|
||||
import type { VisionClient } from "./vision-client.js";
|
||||
import { siteTz } from "./subscription-window.js";
|
||||
|
||||
/** Synthetic device id for the vision service in the status footer (it's a service,
|
||||
* not a device row, but shares the footer's traffic-light + WS plumbing). */
|
||||
@@ -23,6 +24,40 @@ const VISION_STATUS_ID = "vision-service";
|
||||
|
||||
const POLL_MS = Number(process.env.DEVICE_POLL_MS ?? 8000);
|
||||
|
||||
// Camera clock sync (Hikvision loses its clock on power cuts — reboots at the 1970
|
||||
// epoch until a human logs into its web UI). The monitor re-syncs from the HOST
|
||||
// clock (the site's offline time authority) at the offline→ready edge — exactly the
|
||||
// power-restored moment — plus a daily backstop; drift under the threshold is left
|
||||
// alone. See wiki/entities/lpr-camera.md (clock sync).
|
||||
const CLOCK_SYNC_BACKSTOP_MS = 24 * 60 * 60 * 1000;
|
||||
const CLOCK_MAX_DRIFT_SEC = 60;
|
||||
|
||||
/** The site's wall-clock now as ISO WITH utc offset (e.g. 2026-07-07T15:30:22+02:00)
|
||||
* — what ISAPI's localTime wants. Derived via Intl for the site tz (no dep). */
|
||||
export function localIsoWithOffset(tz: string, at = new Date()): string {
|
||||
const fmt = new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: tz,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hourCycle: "h23",
|
||||
});
|
||||
const p = Object.fromEntries(fmt.formatToParts(at).map((x) => [x.type, x.value]));
|
||||
const wallAsUtcMs = Date.UTC(
|
||||
Number(p.year), Number(p.month) - 1, Number(p.day),
|
||||
Number(p.hour), Number(p.minute), Number(p.second),
|
||||
);
|
||||
const offMin = Math.round((wallAsUtcMs - at.getTime()) / 60_000);
|
||||
const sign = offMin < 0 ? "-" : "+";
|
||||
const abs = Math.abs(offMin);
|
||||
const hh = String(Math.floor(abs / 60)).padStart(2, "0");
|
||||
const mm = String(abs % 60).padStart(2, "0");
|
||||
return `${p.year}-${p.month}-${p.day}T${p.hour}:${p.minute}:${p.second}${sign}${hh}:${mm}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The device's ROLE descriptor for the footer (never the vendor). Direction-style
|
||||
* tokens the client localises next to the category:
|
||||
@@ -139,6 +174,7 @@ export class DeviceMonitor {
|
||||
};
|
||||
|
||||
let next: DeviceStatusEvent;
|
||||
let device: Device | null = null;
|
||||
const driver = registry.get(row.driverId);
|
||||
if (!driver) {
|
||||
// Configured against a driver that's no longer registered — surface it,
|
||||
@@ -146,7 +182,7 @@ export class DeviceMonitor {
|
||||
next = { ...base, state: "offline", detail: "driver not registered", checkedAt: new Date().toISOString() };
|
||||
} else {
|
||||
try {
|
||||
const device = driver.create(cfg as never);
|
||||
device = driver.create(cfg as never);
|
||||
// Printers expose richer paper/cover/cutter status; everything else uses
|
||||
// the generic reachability probe. Both flatten to the same traffic-light.
|
||||
if (isMonitorable(device)) {
|
||||
@@ -163,6 +199,33 @@ export class DeviceMonitor {
|
||||
}
|
||||
}
|
||||
|
||||
// Camera clock re-sync at the power-restored edge (prev offline/unknown →
|
||||
// ready) + a daily backstop. Stamped BEFORE the async attempt so a failing
|
||||
// camera is retried at backstop cadence, never every poll.
|
||||
if (row.category === "camera" && next.state === "ready" && device && isClockSyncable(device)) {
|
||||
const prev = this.#latest.get(row.id);
|
||||
const cameBack = !prev || prev.state === "offline";
|
||||
const last = this.#clockSyncedAt.get(row.id) ?? 0;
|
||||
if (cameBack || Date.now() - last > CLOCK_SYNC_BACKSTOP_MS) {
|
||||
this.#clockSyncedAt.set(row.id, Date.now());
|
||||
const cam = device;
|
||||
void (async () => {
|
||||
try {
|
||||
const r = await cam.syncClock(localIsoWithOffset(siteTz(this.#db)), CLOCK_MAX_DRIFT_SEC);
|
||||
if (r.synced) {
|
||||
// A large jump is the 1970 power-cut signature — warn (persisted) so
|
||||
// the reboot stays visible; a small correction is routine info.
|
||||
const msg = `device-monitor: camera ${row.id} clock synced (was ${r.driftSeconds ?? "unparseable"}s off)`;
|
||||
if (r.driftSeconds == null || r.driftSeconds > 3600) this.#log.warn(msg);
|
||||
else this.#log.info(msg);
|
||||
}
|
||||
} catch (err) {
|
||||
this.#log.warn(`device-monitor: camera ${row.id} clock sync failed: ${(err as Error).message}`);
|
||||
}
|
||||
})();
|
||||
}
|
||||
}
|
||||
|
||||
this.#publish(row.id, next);
|
||||
}
|
||||
|
||||
@@ -183,6 +246,9 @@ export class DeviceMonitor {
|
||||
});
|
||||
}
|
||||
|
||||
/** Per-camera timestamp of the last clock-sync ATTEMPT (backstop pacing). */
|
||||
readonly #clockSyncedAt = new Map<string, number>();
|
||||
|
||||
/** Cache + emit a status, but only when it CHANGED (state or detail). */
|
||||
#publish(id: string, next: DeviceStatusEvent): void {
|
||||
const prev = this.#latest.get(id);
|
||||
|
||||
@@ -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 { createTestDb } from "@parking/db/testing";
|
||||
import { LogService, pinoDbStream } from "./log-service.js";
|
||||
@@ -52,3 +52,65 @@ describe("pinoDbStream level encodings", () => {
|
||||
expect(teed).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
// Storm coalescing: a line identical to the LAST persisted row (level+source+message+
|
||||
// path), arriving within 5 min of its previous occurrence, UPDATES that row (bumping
|
||||
// context._repeat) instead of inserting — one screaming device can't evict unrelated
|
||||
// history. The row's createdAt tracks the LATEST occurrence; the first is preserved in
|
||||
// context._firstAt.
|
||||
describe("storm coalescing", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("folds a burst of identical error lines into ONE row with a repeat counter", () => {
|
||||
for (let i = 0; i < 200; i++) {
|
||||
stream.write(`{"level":"error","msg":"button-light setAux failed (ctl R3): send ENETUNREACH"}\n`);
|
||||
}
|
||||
const all = rows();
|
||||
expect(all).toHaveLength(1);
|
||||
expect(all[0].context).toMatchObject({ _repeat: 200 });
|
||||
expect(teed).toHaveLength(200); // stdout still gets every line
|
||||
});
|
||||
|
||||
it("keeps first-occurrence time in _firstAt while createdAt tracks the latest", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-07-08T10:00:00.000Z"));
|
||||
stream.write(`{"level":"warn","msg":"same"}\n`);
|
||||
vi.setSystemTime(new Date("2026-07-08T10:02:00.000Z"));
|
||||
stream.write(`{"level":"warn","msg":"same"}\n`);
|
||||
const [row] = rows();
|
||||
expect(row.createdAt).toBe("2026-07-08T10:02:00.000Z");
|
||||
expect(row.context).toMatchObject({ _repeat: 2, _firstAt: "2026-07-08T10:00:00.000Z" });
|
||||
});
|
||||
|
||||
it("a different message (or level) breaks the run — separate rows", () => {
|
||||
stream.write(`{"level":"error","msg":"boom A"}\n`);
|
||||
stream.write(`{"level":"error","msg":"boom A"}\n`);
|
||||
stream.write(`{"level":"error","msg":"boom B"}\n`);
|
||||
stream.write(`{"level":"warn","msg":"boom B"}\n`);
|
||||
expect(rows()).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("an occurrence past the 5-minute window starts a fresh row", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-07-08T10:00:00.000Z"));
|
||||
stream.write(`{"level":"error","msg":"slow leak"}\n`);
|
||||
vi.setSystemTime(new Date("2026-07-08T10:06:00.000Z"));
|
||||
stream.write(`{"level":"error","msg":"slow leak"}\n`);
|
||||
expect(rows()).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("a CONTINUOUS storm stays one row past the window (each hit refreshes it)", () => {
|
||||
vi.useFakeTimers();
|
||||
let t = new Date("2026-07-08T10:00:00.000Z").getTime();
|
||||
for (let i = 0; i < 10; i++) {
|
||||
vi.setSystemTime(new Date(t));
|
||||
stream.write(`{"level":"error","msg":"storm"}\n`);
|
||||
t += 240_000; // 4 min apart — each inside the window of the PREVIOUS hit
|
||||
}
|
||||
const all = rows();
|
||||
expect(all).toHaveLength(1);
|
||||
expect(all[0].context).toMatchObject({ _repeat: 10 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,6 +25,14 @@ const MAX_MESSAGE = 4_000;
|
||||
const MAX_STACK = 16_000;
|
||||
const MAX_CONTEXT_JSON = 16_000;
|
||||
|
||||
/** Storm coalescing: a line identical to the LAST persisted one (level+source+message+
|
||||
* path) within this window of its previous occurrence UPDATES that row (bumping a
|
||||
* `_repeat` counter in its context) instead of inserting a new one. A continuous storm
|
||||
* keeps refreshing the window, so it stays ONE row however long it rages — repeated
|
||||
* errors can't evict unrelated history or grind the appliance disk (field incident
|
||||
* 2026-07-07: one unreachable controller ≈ hundreds of identical rows/minute). */
|
||||
const COALESCE_WINDOW_MS = 300_000;
|
||||
|
||||
export interface LogRetention {
|
||||
/** Delete logs older than this many days. */
|
||||
readonly maxAgeDays: number;
|
||||
@@ -62,6 +70,16 @@ export class LogService {
|
||||
readonly #retention: LogRetention;
|
||||
/** Reentrancy guard: never let persisting a log itself emit a persisted log. */
|
||||
#writing = false;
|
||||
/** The last persisted row, for storm coalescing (in-memory only; a restart just
|
||||
* starts a fresh row — best-effort, like everything in this sink). */
|
||||
#last: {
|
||||
id: string;
|
||||
key: string;
|
||||
count: number;
|
||||
firstAt: string;
|
||||
lastAtMs: number;
|
||||
baseContext: Record<string, unknown> | null;
|
||||
} | null = null;
|
||||
|
||||
constructor(db: Db, retention: LogRetention = DEFAULT_RETENTION) {
|
||||
this.#db = db;
|
||||
@@ -85,22 +103,53 @@ export class LogService {
|
||||
if (this.#writing) return;
|
||||
this.#writing = true;
|
||||
try {
|
||||
const createdAt = row.createdAt ?? new Date().toISOString();
|
||||
const message = clamp(row.message, MAX_MESSAGE) ?? "";
|
||||
const path = clamp(row.path, 512);
|
||||
const key = `${row.level}|${row.source}|${message}|${path ?? ""}`;
|
||||
const nowMs = Date.now();
|
||||
|
||||
// Storm coalescing: identical to the last persisted row, within the window →
|
||||
// bump that row instead of inserting. createdAt moves to the LATEST occurrence
|
||||
// (keeps the storm visible at the top of the newest-first viewer); the first
|
||||
// occurrence's time is preserved in context._firstAt.
|
||||
const last = this.#last;
|
||||
if (last && last.key === key && nowMs - last.lastAtMs <= COALESCE_WINDOW_MS) {
|
||||
const res = this.#db
|
||||
.update(appLogs)
|
||||
.set({
|
||||
context: { ...(last.baseContext ?? {}), _repeat: last.count + 1, _firstAt: last.firstAt },
|
||||
createdAt,
|
||||
})
|
||||
.where(eq(appLogs.id, last.id))
|
||||
.run();
|
||||
if ((res.changes ?? 0) > 0) {
|
||||
last.count += 1;
|
||||
last.lastAtMs = nowMs;
|
||||
return;
|
||||
}
|
||||
// The row was pruned out from under us — fall through to a fresh insert.
|
||||
}
|
||||
|
||||
const id = randomUUID();
|
||||
const baseContext = safeContext(row.context);
|
||||
this.#db
|
||||
.insert(appLogs)
|
||||
.values({
|
||||
id: randomUUID(),
|
||||
id,
|
||||
level: row.level,
|
||||
source: row.source,
|
||||
message: clamp(row.message, MAX_MESSAGE) ?? "",
|
||||
context: safeContext(row.context),
|
||||
message,
|
||||
context: baseContext,
|
||||
httpStatus: row.httpStatus ?? null,
|
||||
path: clamp(row.path, 512),
|
||||
path,
|
||||
stack: clamp(row.stack, MAX_STACK),
|
||||
userId: row.userId ?? null,
|
||||
userAgent: clamp(row.userAgent, 512),
|
||||
createdAt: row.createdAt ?? new Date().toISOString(),
|
||||
createdAt,
|
||||
})
|
||||
.run();
|
||||
this.#last = { id, key, count: 1, firstAt: createdAt, lastAtMs: nowMs, baseContext };
|
||||
} catch {
|
||||
// Swallow — diagnostics must never take down the path they observe. (Can't log
|
||||
// it; that's the recursion we're guarding against.)
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { desc, eq, ledgerEvents, sessions, subscriptions, tariffVersions, tariffs, type Db } from "@parking/db";
|
||||
import { priceSession, type TariffStructure, type Tender } from "@parking/shared";
|
||||
import { priceSession, type TariffStructure, type Tender, type ValidationLine } from "@parking/shared";
|
||||
import type { FastifyBaseLogger } from "fastify";
|
||||
import type { EventLog } from "./event-log.js";
|
||||
import { plateForIdentity, platesForIdentities } from "./plate-lookup.js";
|
||||
import { windowOwedBetween } from "./subscription-window.js";
|
||||
import { liveValidations } from "./validations.js";
|
||||
|
||||
// The PAY STATION: a customer pays for an open session BEFORE walking back to the
|
||||
// car (pay-on-foot — payment is decoupled from exit). Two steps:
|
||||
@@ -38,8 +39,17 @@ export interface Quote {
|
||||
* is priced as a fresh stay from there → now, with its own daily-cap ladder, NOT
|
||||
* "full stay minus paid" (which a daily cap collapses toward zero). */
|
||||
readonly periodStart: string;
|
||||
/** Amount owed now: the fee for [periodStart → now]. */
|
||||
/** Amount owed now: the fee for [periodStart → now], NET of merchant validations. */
|
||||
readonly amountMinor: number;
|
||||
/** The pre-validation fee (= amountMinor when no validations apply). */
|
||||
readonly grossMinor: number;
|
||||
/** Total the merchant validations took off (gross − net). */
|
||||
readonly discountMinor: number;
|
||||
/** Per-validation receipt/display lines (empty when none apply). */
|
||||
readonly validationLines: ValidationLine[];
|
||||
/** The validation event ids this quote applied — the payment stamps them as
|
||||
* CONSUMED so an overstay's fresh period never re-applies them. */
|
||||
readonly validationIds: string[];
|
||||
/** True when this quote prices an overstay period (grace lapsed), not the first stay. */
|
||||
readonly overstay: boolean;
|
||||
readonly currency: string;
|
||||
@@ -117,6 +127,12 @@ export interface SessionLookup {
|
||||
/** Advisory licence plate recognized for this session (ANPR-on-snapshot). Null when
|
||||
* none. Display/audit only — never an access decision. */
|
||||
readonly plate: string | null;
|
||||
/** Merchant validations folded into `amountMinor` (which is NET): the pre-discount
|
||||
* fee, the total taken off, and the per-validation lines for the modal/receipt.
|
||||
* grossMinor/discountMinor are null when no quote resolved. */
|
||||
readonly grossMinor: number | null;
|
||||
readonly discountMinor: number | null;
|
||||
readonly validationLines: ValidationLine[];
|
||||
}
|
||||
|
||||
export class PayStation {
|
||||
@@ -155,19 +171,28 @@ export class PayStation {
|
||||
// Pure pricing shared with the Tariff Lab (priceSession). Only the latest payment
|
||||
// matters for grace/overstay; pass it through. Overstay → fresh period from
|
||||
// grace-expiry; within-grace → settled; unpaid → entry→now running total.
|
||||
// Merchant validations: fold the LIVE ones (applied, unvoided, not consumed by a
|
||||
// prior payment) so the quote is NET — the payment then stamps their ids as
|
||||
// consumed. See wiki/concepts/validation-discounts.md.
|
||||
const last = this.#lastPayment(identity);
|
||||
const validations = liveValidations(this.#db, identity);
|
||||
const p = priceSession(
|
||||
entry.occurredAt,
|
||||
new Date().toISOString(),
|
||||
structure,
|
||||
last ? [last] : [],
|
||||
category,
|
||||
validations,
|
||||
);
|
||||
return {
|
||||
identity,
|
||||
enteredAt: entry.occurredAt,
|
||||
periodStart: p.periodStart,
|
||||
amountMinor: p.amountMinor,
|
||||
grossMinor: p.grossMinor,
|
||||
discountMinor: p.discountMinor,
|
||||
validationLines: p.validationLines,
|
||||
validationIds: validations.map((v) => v.eventId),
|
||||
overstay: p.overstay,
|
||||
currency: tv.currency,
|
||||
tariffVersionId: tv.id,
|
||||
@@ -246,6 +271,18 @@ export class PayStation {
|
||||
// The exit flow reads graceExitMin off the payment to validate the
|
||||
// walk-back window without re-resolving the tariff.
|
||||
graceExitMin: q.graceExitMin,
|
||||
// Merchant validations: record the gross/discount split + CONSUME the applied
|
||||
// validation ids, so reporting sees the leakage and a later overstay period
|
||||
// never re-applies them. A zero-net settlement (full comp) is still a signed
|
||||
// payment — grace/voucher/exit work unchanged. See validation-discounts.md.
|
||||
...(q.validationIds.length
|
||||
? {
|
||||
grossMinor: q.grossMinor,
|
||||
discountMinor: q.discountMinor,
|
||||
validationIds: q.validationIds,
|
||||
validationLines: q.validationLines.map((l) => ({ ...l })),
|
||||
}
|
||||
: {}),
|
||||
...(overrideMinor != null ? { reason: "operator-set amount", quotedMinor: q.amountMinor } : {}),
|
||||
},
|
||||
});
|
||||
@@ -282,6 +319,7 @@ export class PayStation {
|
||||
paidAt: null, amountMinor: null, currency: null, paidMinor: null, paidCurrency: null,
|
||||
withinGrace: false, graceExpiresAt: null,
|
||||
overstay: false, subscription: false, subscriptionId: null, subscriptionHolder: null, plate: null,
|
||||
grossMinor: null, discountMinor: null, validationLines: [],
|
||||
};
|
||||
}
|
||||
// Subscription occurrence? The entry payload carries permit:true + permitId.
|
||||
@@ -319,11 +357,17 @@ export class PayStation {
|
||||
// exit gate clears. See wiki/entities/subscription.md.
|
||||
let amountMinor: number | null = null;
|
||||
let currency: string | null = null;
|
||||
let grossMinor: number | null = null;
|
||||
let discountMinor: number | null = null;
|
||||
let validationLines: ValidationLine[] = [];
|
||||
if (open && !isSubscription) {
|
||||
try {
|
||||
const q = this.quote(id);
|
||||
amountMinor = q.amountMinor;
|
||||
currency = q.currency;
|
||||
grossMinor = q.grossMinor;
|
||||
discountMinor = q.discountMinor;
|
||||
validationLines = q.validationLines;
|
||||
} catch {
|
||||
/* no active tariff — leave null; modal shows session without a price */
|
||||
}
|
||||
@@ -344,6 +388,7 @@ export class PayStation {
|
||||
subscription: isSubscription, subscriptionId,
|
||||
subscriptionHolder: this.#holderOf(subscriptionId),
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
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,
|
||||
eq,
|
||||
gte,
|
||||
lt,
|
||||
lte,
|
||||
ledgerEvents,
|
||||
sessions,
|
||||
siteConfig,
|
||||
subscriptions,
|
||||
tariffVersions,
|
||||
tariffs,
|
||||
@@ -46,8 +48,13 @@ export interface SeriesPoint {
|
||||
readonly exits: number;
|
||||
/** Net transient revenue collected in the bucket (minor units), all tenders. */
|
||||
readonly revenueMinor: number;
|
||||
/** Tender split of the bucket's revenue (cash = everything not card). */
|
||||
readonly cashMinor: number;
|
||||
readonly cardMinor: number;
|
||||
/** Payment COUNT in the bucket (transactions, not amount). */
|
||||
readonly payments: number;
|
||||
/** Cars inside at the END of the bucket (occupancyStart + running entries−exits). */
|
||||
readonly occupancyEnd: number;
|
||||
}
|
||||
|
||||
export interface ReportTotals {
|
||||
@@ -67,6 +74,10 @@ export interface ReportTotals {
|
||||
readonly totalParkedMinutes: number;
|
||||
readonly avgParkedMinutes: number;
|
||||
readonly medianParkedMinutes: number;
|
||||
/** Cancelled tickets + signed anomalies in range — the "look closer" counters
|
||||
* (the operator at the booth is the threat model's primary adversary). */
|
||||
readonly voids: number;
|
||||
readonly anomalies: number;
|
||||
}
|
||||
|
||||
export interface SubscriptionStats {
|
||||
@@ -79,6 +90,13 @@ export interface SubscriptionStats {
|
||||
readonly coveredCars: number;
|
||||
}
|
||||
|
||||
/** One bar of the stay-duration histogram: stays up to `uptoMin` minutes (null = the
|
||||
* open-ended tail). Edges chosen to mirror how tariffs are designed (see tariff.md). */
|
||||
export interface StayBucket {
|
||||
readonly uptoMin: number | null;
|
||||
readonly count: number;
|
||||
}
|
||||
|
||||
export interface ReportSummary {
|
||||
readonly from: string;
|
||||
readonly to: string;
|
||||
@@ -89,25 +107,43 @@ export interface ReportSummary {
|
||||
readonly series: SeriesPoint[];
|
||||
/** Entries by local hour-of-day (0–23), summed across the range — the peak-hour view. */
|
||||
readonly entriesByHour: number[];
|
||||
/** Entries by [day-of-week][hour-of-day] — 7×24, row 0 = Monday. The heatmap that
|
||||
* shows weekday-vs-weekend patterns (feeds tariff-window design). */
|
||||
readonly entriesByDowHour: number[][];
|
||||
/** Stay-duration histogram over closed sessions in range. */
|
||||
readonly stayHistogram: StayBucket[];
|
||||
/** Cars inside when the range OPENS (folded from the whole prior ledger). */
|
||||
readonly occupancyStart: number;
|
||||
/** Nominal capacity from site config (null = uncapped) — the reference line. */
|
||||
readonly capacity: number | null;
|
||||
readonly subscriptions: SubscriptionStats;
|
||||
}
|
||||
|
||||
/** Local wall-clock parts of an ISO instant in a given IANA tz. Reuses Intl (no dep). */
|
||||
function localParts(iso: string, tz: string): { y: number; mo: number; d: number; h: number } {
|
||||
const fmt = new Intl.DateTimeFormat("en-CA", {
|
||||
const fmtCache = new Map<string, Intl.DateTimeFormat>();
|
||||
const DOW_INDEX: Record<string, number> = { Mon: 0, Tue: 1, Wed: 2, Thu: 3, Fri: 4, Sat: 5, Sun: 6 };
|
||||
function localParts(iso: string, tz: string): { y: number; mo: number; d: number; h: number; dow: number } {
|
||||
// Cached per tz — this runs once per ledger row in a report.
|
||||
let fmt = fmtCache.get(tz);
|
||||
if (!fmt) {
|
||||
fmt = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: tz,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
hourCycle: "h23",
|
||||
weekday: "short",
|
||||
});
|
||||
fmtCache.set(tz, fmt);
|
||||
}
|
||||
const parts = Object.fromEntries(fmt.formatToParts(new Date(iso)).map((p) => [p.type, p.value]));
|
||||
return {
|
||||
y: Number(parts.year),
|
||||
mo: Number(parts.month),
|
||||
d: Number(parts.day),
|
||||
h: Number(parts.hour),
|
||||
dow: DOW_INDEX[parts.weekday ?? ""] ?? 0, // row 0 = Monday
|
||||
};
|
||||
}
|
||||
|
||||
@@ -162,6 +198,7 @@ export function reportSummary(db: Db, q: ReportQuery): ReportSummary {
|
||||
|
||||
const seriesMap = new Map<string, SeriesPoint>();
|
||||
const entriesByHour = new Array<number>(24).fill(0);
|
||||
const entriesByDowHour = Array.from({ length: 7 }, () => new Array<number>(24).fill(0));
|
||||
const totals = {
|
||||
entries: 0,
|
||||
exits: 0,
|
||||
@@ -172,12 +209,14 @@ export function reportSummary(db: Db, q: ReportQuery): ReportSummary {
|
||||
ticketMinor: 0,
|
||||
subscriptionSalesMinor: 0,
|
||||
subscriptionWindowMinor: 0,
|
||||
voids: 0,
|
||||
anomalies: 0,
|
||||
};
|
||||
|
||||
function point(label: string): SeriesPoint {
|
||||
let p = seriesMap.get(label);
|
||||
if (!p) {
|
||||
p = { bucket: label, entries: 0, exits: 0, revenueMinor: 0, payments: 0 };
|
||||
p = { bucket: label, entries: 0, exits: 0, revenueMinor: 0, cashMinor: 0, cardMinor: 0, payments: 0, occupancyEnd: 0 };
|
||||
seriesMap.set(label, p);
|
||||
}
|
||||
return p;
|
||||
@@ -196,11 +235,16 @@ export function reportSummary(db: Db, q: ReportQuery): ReportSummary {
|
||||
if (row.identity && voided.has(row.identity)) continue; // cancelled — not a real entry
|
||||
totals.entries++;
|
||||
p.entries++;
|
||||
const h = localParts(row.occurredAt, tz).h;
|
||||
entriesByHour[h] = (entriesByHour[h] ?? 0) + 1;
|
||||
const lp = localParts(row.occurredAt, tz);
|
||||
entriesByHour[lp.h] = (entriesByHour[lp.h] ?? 0) + 1;
|
||||
entriesByDowHour[lp.dow]![lp.h] = (entriesByDowHour[lp.dow]![lp.h] ?? 0) + 1;
|
||||
} else if (row.type === "vehicle_exit") {
|
||||
totals.exits++;
|
||||
p.exits++;
|
||||
} else if (row.type === "void") {
|
||||
totals.voids++;
|
||||
} else if (row.type === "anomaly") {
|
||||
totals.anomalies++;
|
||||
} else if (row.type === "payment") {
|
||||
const pl = (row.payload ?? {}) as PaymentPayload;
|
||||
const amt = typeof pl.amountMinor === "number" ? pl.amountMinor : 0;
|
||||
@@ -209,8 +253,13 @@ export function reportSummary(db: Db, q: ReportQuery): ReportSummary {
|
||||
totals.revenueMinor += amt;
|
||||
p.payments++;
|
||||
p.revenueMinor += amt;
|
||||
if (pl.tender === "card") totals.cardMinor += amt;
|
||||
else totals.cashMinor += amt;
|
||||
if (pl.tender === "card") {
|
||||
totals.cardMinor += amt;
|
||||
p.cardMinor += amt;
|
||||
} else {
|
||||
totals.cashMinor += amt;
|
||||
p.cashMinor += amt;
|
||||
}
|
||||
// Revenue split mirrors the shift Z-report: subscription sale / window charge /
|
||||
// (the rest is) transient ticket revenue.
|
||||
if (pl.subscriptionSale === true) totals.subscriptionSalesMinor += amt;
|
||||
@@ -221,6 +270,31 @@ export function reportSummary(db: Db, q: ReportQuery): ReportSummary {
|
||||
|
||||
const series = [...seriesMap.values()].sort((a, b) => a.bucket.localeCompare(b.bucket));
|
||||
|
||||
// --- Occupancy: fold the PRIOR ledger for cars-inside at range start, then walk the
|
||||
// series. Voided pre-range entries cancel out the same way the in-range pass does.
|
||||
// Sparse buckets (no events) simply carry the previous level — the step line is exact
|
||||
// at every plotted point.
|
||||
const prior = db
|
||||
.select({ type: ledgerEvents.type, identity: ledgerEvents.identity })
|
||||
.from(ledgerEvents)
|
||||
.where(lt(ledgerEvents.occurredAt, q.from))
|
||||
.all();
|
||||
const priorVoided = new Set<string>();
|
||||
for (const r of prior) if (r.type === "void" && r.identity) priorVoided.add(r.identity);
|
||||
let occupancyStart = 0;
|
||||
for (const r of prior) {
|
||||
if (r.type === "vehicle_entry" && !(r.identity && priorVoided.has(r.identity))) occupancyStart++;
|
||||
else if (r.type === "vehicle_exit") occupancyStart--;
|
||||
}
|
||||
occupancyStart = Math.max(0, occupancyStart);
|
||||
let running = occupancyStart;
|
||||
for (const p of series) {
|
||||
running = Math.max(0, running + p.entries - p.exits);
|
||||
(p as { -readonly [K in keyof SeriesPoint]: SeriesPoint[K] }).occupancyEnd = running;
|
||||
}
|
||||
|
||||
const capacity = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get()?.capacity ?? null;
|
||||
|
||||
// No payment in range? Fall back to the site tariff's latest version currency, so a
|
||||
// zero-revenue range still labels its money column.
|
||||
if (!currency) {
|
||||
@@ -251,6 +325,19 @@ export function reportSummary(db: Db, q: ReportQuery): ReportSummary {
|
||||
durations.sort((a, b) => a - b);
|
||||
const totalParkedMinutes = durations.reduce((a, b) => a + b, 0);
|
||||
|
||||
// Stay-duration histogram. Edges mirror how rate cards are designed (30m/1h bands,
|
||||
// the 8h working day, the 24h rolling day) so the chart answers "where should the
|
||||
// ladder/up-to breakpoints sit". Last bucket is the open-ended >24h tail.
|
||||
const STAY_EDGES_MIN = [30, 60, 120, 240, 480, 1440];
|
||||
const stayHistogram: { uptoMin: number | null; count: number }[] = [
|
||||
...STAY_EDGES_MIN.map((uptoMin) => ({ uptoMin, count: 0 })),
|
||||
{ uptoMin: null, count: 0 },
|
||||
];
|
||||
for (const mins of durations) {
|
||||
const i = STAY_EDGES_MIN.findIndex((edge) => mins <= edge);
|
||||
stayHistogram[i === -1 ? STAY_EDGES_MIN.length : i]!.count++;
|
||||
}
|
||||
|
||||
// --- Subscriptions: status counts + currently-valid (window covers `to`).
|
||||
const subs = db.select().from(subscriptions).all();
|
||||
const subStats = { active: 0, suspended: 0, revoked: 0, currentlyValid: 0, coveredCars: 0 };
|
||||
@@ -283,6 +370,10 @@ export function reportSummary(db: Db, q: ReportQuery): ReportSummary {
|
||||
},
|
||||
series,
|
||||
entriesByHour,
|
||||
entriesByDowHour,
|
||||
stayHistogram,
|
||||
occupancyStart,
|
||||
capacity,
|
||||
subscriptions: subStats,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -48,9 +48,18 @@ export async function reportRoutes(app: FastifyInstance, db: Db): Promise<void>
|
||||
async (req, reply) => {
|
||||
const summary = reportSummary(db, parseQuery(req.query));
|
||||
const lines = [
|
||||
"bucket,entries,exits,payments,revenue",
|
||||
"bucket,entries,exits,payments,revenue,cash,card,occupancy_end",
|
||||
...summary.series.map((p) =>
|
||||
[p.bucket, p.entries, p.exits, p.payments, (p.revenueMinor / 100).toFixed(2)].join(","),
|
||||
[
|
||||
p.bucket,
|
||||
p.entries,
|
||||
p.exits,
|
||||
p.payments,
|
||||
(p.revenueMinor / 100).toFixed(2),
|
||||
(p.cashMinor / 100).toFixed(2),
|
||||
(p.cardMinor / 100).toFixed(2),
|
||||
p.occupancyEnd,
|
||||
].join(","),
|
||||
),
|
||||
];
|
||||
reply
|
||||
|
||||
@@ -56,6 +56,37 @@ describe("auth guard — no token", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/version", () => {
|
||||
it("without a session is 401", async () => {
|
||||
const res = await app.inject({ method: "GET", url: "/api/version" });
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it("a site:read user gets the BUILD_VERSION env var, null when unset", async () => {
|
||||
const { username, password } = await seedUser(db, {
|
||||
username: "viewer2", roleId: "viewer2", permissions: ["site:read"],
|
||||
});
|
||||
const { cookie } = await login(app, username, password);
|
||||
const res = await app.inject({ method: "GET", url: "/api/version", headers: { cookie } });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json()).toEqual({ buildVersion: null }); // no BUILD_VERSION set in the test env
|
||||
});
|
||||
|
||||
it("reflects a real BUILD_VERSION when the env var is set", async () => {
|
||||
process.env.BUILD_VERSION = "stage-abc1234";
|
||||
try {
|
||||
const { username, password } = await seedUser(db, {
|
||||
username: "viewer3", roleId: "viewer3", permissions: ["site:read"],
|
||||
});
|
||||
const { cookie } = await login(app, username, password);
|
||||
const res = await app.inject({ method: "GET", url: "/api/version", headers: { cookie } });
|
||||
expect(res.json()).toEqual({ buildVersion: "stage-abc1234" });
|
||||
} finally {
|
||||
delete process.env.BUILD_VERSION;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("RBAC permission gate", () => {
|
||||
it("a site:read-only user can GET occupancy but is 403 on PUT site-config", async () => {
|
||||
const { username, password } = await seedUser(db, {
|
||||
|
||||
@@ -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
|
||||
// (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
|
||||
|
||||
@@ -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.
|
||||
app.get("/api/occupancy", { preHandler: readGuard }, async () => getOccupancy(db));
|
||||
|
||||
// Running build version ("<branch>-<short-sha>", matching the Komodo Stack's TAG in
|
||||
// komodo/resources.toml) — baked in at image build time (apps/server/Dockerfile
|
||||
// BUILD_VERSION ARG), read here from the running process env. null on a local/dev
|
||||
// build with no CI-supplied value. Purely informational (Setup nav display); not
|
||||
// site config, so it isn't stored in site_config.
|
||||
app.get("/api/version", { preHandler: readGuard }, async () => ({
|
||||
buildVersion: process.env.BUILD_VERSION?.trim() || null,
|
||||
}));
|
||||
|
||||
// Read site config (capacity + park metadata).
|
||||
app.get("/api/site-config", { preHandler: readGuard }, async () => {
|
||||
const row = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||
|
||||
@@ -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 {
|
||||
computeFee,
|
||||
explainFee,
|
||||
isTariffV2,
|
||||
priceSession,
|
||||
validateTariffStructure,
|
||||
@@ -188,6 +189,12 @@ export async function tariffRoutes(app: FastifyInstance, db: Db): Promise<void>
|
||||
const payments = Array.isArray(b.payments) ? b.payments : [];
|
||||
const pricing = priceSession(b.enteredAt, b.asOf, structure, payments, b.category);
|
||||
|
||||
// HOW the amount is produced — the same engine walk with a trace collector
|
||||
// (Σ lines ≡ amountMinor by construction). Null when settled (nothing billed).
|
||||
const breakdown = pricing.withinGrace
|
||||
? null
|
||||
: explainFee(pricing.periodStart, b.asOf, structure, b.category);
|
||||
|
||||
// A duration curve from entry: handy to SEE where the cap flattens / windows shift.
|
||||
const SAMPLES_MIN = [30, 60, 120, 180, 360, 720, 1440, 2880, 4320];
|
||||
const enteredMs = Date.parse(b.enteredAt);
|
||||
@@ -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),
|
||||
}));
|
||||
|
||||
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
|
||||
|
||||
@@ -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 { entryRoutes } from "./routes/entry.js";
|
||||
import { siteRoutes } from "./routes/site.js";
|
||||
import { validationRoutes } from "./routes/validations.js";
|
||||
import { snapshotRoutes } from "./routes/snapshots.js";
|
||||
import { tariffRoutes } from "./routes/tariffs.js";
|
||||
import { printerRoutes } from "./routes/printers.js";
|
||||
@@ -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.
|
||||
await siteRoutes(app, db, eventLog);
|
||||
|
||||
// Merchant validations (bar / lavazh): setup panel config + the merchant user's
|
||||
// scan-and-apply. The booth settlement folds the applied validations into its
|
||||
// quote (pay-station.ts). See wiki/concepts/validation-discounts.md.
|
||||
await validationRoutes(app, db, eventLog);
|
||||
|
||||
// Application logs: ingest frontend errors (POST /api/logs, any signed-in user) +
|
||||
// read the store (GET /api/logs, log:read). See wiki/concepts/app-logs.md.
|
||||
await logRoutes(app, logService);
|
||||
@@ -330,12 +336,20 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
||||
void runSnapPrune(); // once at startup
|
||||
app.addHook("onClose", async () => clearInterval(snapPruneTimer));
|
||||
|
||||
// Scheduled encrypted backup — daily, unref'd. A no-op (silent) until BACKUP_TARGET_DIR +
|
||||
// BACKUP_KEY are configured; tolerates an unreachable/unmounted target by recording the
|
||||
// error and trying again next run. NOT run once at startup (a just-booted appliance after a
|
||||
// power cut shouldn't immediately write to a possibly-not-yet-mounted disk; the daily cadence
|
||||
// and the manual button cover it). See wiki/concepts/backup-recovery.md.
|
||||
const backupTimer = setInterval(() => void backupService.runScheduled(), 24 * 60 * 60 * 1000);
|
||||
// Scheduled encrypted backup — checked every 15 min, unref'd; `runScheduled()` itself is a
|
||||
// no-op unless a full 24h has actually elapsed since the last PERSISTED success (isDue(), in
|
||||
// backup-service.ts), so this frequent poll does not cause frequent backups. Deliberately
|
||||
// NOT a `setInterval(..., 24h)` measured from process start: that design silently reset its
|
||||
// own countdown on every restart (deploy/crash/OOM/reboot, all routine under `restart:
|
||||
// always`), which could push a day's backup out arbitrarily far AND — before last-success was
|
||||
// persisted — made the admin UI show "Never" despite valid backups already on disk
|
||||
// (2026-08-30 field incident, park-buzi). A short poll against a persisted, wall-clock
|
||||
// timestamp is immune to both restart timing and to any single restart cadence. A no-op
|
||||
// (silent) until BACKUP_TARGET_DIR + BACKUP_KEY are configured; tolerates an
|
||||
// unreachable/unmounted target by recording the error and trying again next check. NOT run
|
||||
// once at startup (a just-booted appliance after a power cut shouldn't immediately write to a
|
||||
// possibly-not-yet-mounted disk). See wiki/concepts/backup-recovery.md.
|
||||
const backupTimer = setInterval(() => void backupService.runScheduled(), 15 * 60 * 1000);
|
||||
backupTimer.unref();
|
||||
app.addHook("onClose", async () => clearInterval(backupTimer));
|
||||
if (backupService.configured) {
|
||||
|
||||
@@ -55,6 +55,7 @@ export interface ShiftSummary {
|
||||
readonly subscriptionTotalMinor: number;
|
||||
readonly subscriptionSalesMinor: number;
|
||||
readonly subscriptionWindowMinor: number;
|
||||
readonly discountTotalMinor: number;
|
||||
readonly openingFloatMinor: number;
|
||||
readonly cashAddedMinor: number;
|
||||
readonly cashRemovedMinor: number;
|
||||
@@ -78,6 +79,9 @@ export interface ShiftReport {
|
||||
readonly subscriptionSalesMinor: number;
|
||||
/** Subscriber OUT-OF-WINDOW transient-tariff charges only. */
|
||||
readonly subscriptionWindowMinor: number;
|
||||
/** Merchant-validation DISCOUNT total given away in the window (leakage — the
|
||||
* cash/card figures above are already NET of it). See validation-discounts.md. */
|
||||
readonly discountTotalMinor: number;
|
||||
// --- Drawer (physical cash till; carries across shifts) ---
|
||||
/** Cash in the drawer at shift start = prior shift's expected closing drawer. */
|
||||
readonly openingFloatMinor: number;
|
||||
@@ -220,6 +224,7 @@ export class ShiftService {
|
||||
subscriptionTotalMinor?: number;
|
||||
subscriptionSalesMinor?: number;
|
||||
subscriptionWindowMinor?: number;
|
||||
discountTotalMinor?: number;
|
||||
openingFloatMinor?: number;
|
||||
cashAddedMinor?: number;
|
||||
cashRemovedMinor?: number;
|
||||
@@ -250,6 +255,8 @@ export class ShiftService {
|
||||
ticketTotalMinor:
|
||||
pl.ticketTotalMinor ??
|
||||
(pl.cashTotalMinor ?? 0) + (pl.cardTotalMinor ?? 0) - (pl.subscriptionTotalMinor ?? 0),
|
||||
// Merchant-validation leakage (added 2026-07-13). Old reports lack it → 0.
|
||||
discountTotalMinor: pl.discountTotalMinor ?? 0,
|
||||
openingFloatMinor: pl.openingFloatMinor ?? 0,
|
||||
cashAddedMinor: pl.cashAddedMinor ?? 0,
|
||||
cashRemovedMinor: pl.cashRemovedMinor ?? 0,
|
||||
@@ -522,6 +529,9 @@ export class ShiftService {
|
||||
// the subscription sale path).
|
||||
let subscriptionSalesMinor = 0;
|
||||
let subscriptionWindowMinor = 0;
|
||||
// Merchant-validation leakage: Σ discountMinor across the window's payments. The
|
||||
// tender totals are already NET; this is the "given away" figure beside them.
|
||||
let discountTotalMinor = 0;
|
||||
let currency: string | null = null;
|
||||
for (const p of payments) {
|
||||
const pl = (p.payload ?? {}) as LedgerPayload & {
|
||||
@@ -534,6 +544,7 @@ export class ShiftService {
|
||||
if (pl.subscriptionSale === true) subscriptionSalesMinor += amt;
|
||||
else if (pl.subscriptionWindowCharge === true) subscriptionWindowMinor += amt;
|
||||
// (else → transient ticket; derived below as total − subscription)
|
||||
if (typeof pl.discountMinor === "number") discountTotalMinor += pl.discountMinor;
|
||||
if (pl.currency) currency = pl.currency;
|
||||
}
|
||||
const subscriptionTotalMinor = subscriptionSalesMinor + subscriptionWindowMinor;
|
||||
@@ -589,6 +600,7 @@ export class ShiftService {
|
||||
subscriptionTotalMinor,
|
||||
subscriptionSalesMinor,
|
||||
subscriptionWindowMinor,
|
||||
discountTotalMinor,
|
||||
openingFloatMinor,
|
||||
cashAddedMinor,
|
||||
cashRemovedMinor,
|
||||
@@ -627,6 +639,7 @@ export class ShiftService {
|
||||
subscriptionTotalMinor,
|
||||
subscriptionSalesMinor,
|
||||
subscriptionWindowMinor,
|
||||
discountTotalMinor,
|
||||
openingFloatMinor,
|
||||
cashAddedMinor,
|
||||
cashRemovedMinor,
|
||||
@@ -649,6 +662,7 @@ export class ShiftService {
|
||||
subscriptionTotalMinor,
|
||||
subscriptionSalesMinor,
|
||||
subscriptionWindowMinor,
|
||||
discountTotalMinor,
|
||||
openingFloatMinor,
|
||||
cashAddedMinor,
|
||||
cashRemovedMinor,
|
||||
@@ -691,14 +705,17 @@ export class ShiftService {
|
||||
// Abonime is the subscription TOTAL; only the out-of-window part is broken out.
|
||||
// (subscriptionSalesMinor stays in the signed payload — it's just not printed.)
|
||||
`Abonime: ${money(r.subscriptionTotalMinor)} ${cur}`,
|
||||
` jashtë orarit: ${money(r.subscriptionWindowMinor)} ${cur}`,
|
||||
`Jashtë orarit: ${money(r.subscriptionWindowMinor)} ${cur}`,
|
||||
// Merchant-validation leakage — printed only when the shift actually gave any
|
||||
// (older slips stay byte-identical). The takings above are already NET of it.
|
||||
...(r.discountTotalMinor > 0 ? [`Zbritje (validime): ${money(r.discountTotalMinor)} ${cur}`] : []),
|
||||
"",
|
||||
"-- Arka --",
|
||||
`Fillimi: ${money(r.openingFloatMinor)} ${cur}`,
|
||||
`Para të marra: ${money(r.cashTotalMinor)} ${cur}`,
|
||||
`Para të shtuara: ${money(r.cashAddedMinor)} ${cur}`,
|
||||
`Para të hequra: ${money(r.cashRemovedMinor)} ${cur}`,
|
||||
`Arka e pritur: ${money(r.expectedDrawerMinor)} ${cur}`,
|
||||
`Gjëndje fillestare: ${money(r.openingFloatMinor)} ${cur}`,
|
||||
`Para të grumbulluara: ${money(r.cashTotalMinor)} ${cur}`,
|
||||
`Arkëtime: ${money(r.cashAddedMinor)} ${cur}`,
|
||||
`Pagesa: ${money(r.cashRemovedMinor)} ${cur}`,
|
||||
`Gjëndje aktuale: ${money(r.expectedDrawerMinor)} ${cur}`,
|
||||
];
|
||||
try {
|
||||
await printer.printReport({ title: "RAPORT TURNI", lines });
|
||||
|
||||
@@ -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,8 +18,10 @@
|
||||
"@radix-ui/react-tabs": "^1.1.15",
|
||||
"@tanstack/react-query": "^5.101.0",
|
||||
"@tanstack/react-router": "^1.170.16",
|
||||
"@tauri-apps/plugin-http": "^2.5.2",
|
||||
"@tauri-apps/plugin-process": "^2.3.1",
|
||||
"@tauri-apps/plugin-updater": "^2.10.1",
|
||||
"@tauri-apps/plugin-websocket": "^2.3.0",
|
||||
"i18next": "^26.3.1",
|
||||
"react": "19.2.7",
|
||||
"react-dom": "19.2.7",
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
import { rootRoute } from "./router.js";
|
||||
import { qk } from "./lib/query.js";
|
||||
import { useShift } from "./lib/use-shift.js";
|
||||
import { formatDuration, formatMoney, formatTime, formatRelativeDateTime } from "./lib/format.js";
|
||||
import { formatDuration, formatMoney, formatRelativeDateTime } from "./lib/format.js";
|
||||
import { CARD_PAYMENTS_ENABLED } from "./lib/features.js";
|
||||
import { SnapshotStrip } from "./ui/SnapshotStrip.js";
|
||||
import { Spinner } from "./ui/Spinner.js";
|
||||
@@ -310,12 +310,12 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
||||
// figures, and the snapshot strip read-only. No tender / voucher / open here.
|
||||
<>
|
||||
<div className="rounded-term border border-term-amber px-3 py-2 text-term-amber">
|
||||
{t("pay.alreadyClosed", { time: formatTime(s.exitedAt) })}
|
||||
{t("pay.alreadyClosed", { time: formatRelativeDateTime(s.exitedAt, t, { seconds: true }) })}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-1 tabular-nums">
|
||||
<Row label={t("pay.entry")} value={formatRelativeDateTime(s.enteredAt, t)} />
|
||||
<Row label={t("pay.exit")} value={formatTime(s.exitedAt)} />
|
||||
<Row label={t("pay.entry")} value={formatRelativeDateTime(s.enteredAt, t, { seconds: true })} />
|
||||
<Row label={t("pay.exit")} value={formatRelativeDateTime(s.exitedAt, t, { seconds: true })} />
|
||||
<Row
|
||||
label={t("pay.duration")}
|
||||
value={
|
||||
@@ -335,11 +335,15 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
||||
<>
|
||||
{/* Session figures */}
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-1 tabular-nums">
|
||||
<Row label={t("pay.entry")} value={formatRelativeDateTime(s.enteredAt, t)} />
|
||||
<Row label={t("pay.entry")} value={formatRelativeDateTime(s.enteredAt, t, { seconds: true })} />
|
||||
{/* Closed-within-grace shows the recorded EXIT; an open session shows now. */}
|
||||
<Row
|
||||
label={closedWithinGrace ? t("pay.exit") : t("pay.now")}
|
||||
value={closedWithinGrace ? formatTime(s.exitedAt) : formatTime(new Date().toISOString())}
|
||||
value={formatRelativeDateTime(
|
||||
closedWithinGrace ? s.exitedAt : new Date().toISOString(),
|
||||
t,
|
||||
{ seconds: true },
|
||||
)}
|
||||
/>
|
||||
<Row
|
||||
label={t("pay.duration")}
|
||||
@@ -379,6 +383,30 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Merchant validations (bar/lavazh): the gross fee + one line per
|
||||
discount — the Total below is the NET the customer pays. The lines
|
||||
ride the quote (SessionLookup.validationLines) and reprint on the
|
||||
receipt. See wiki/concepts/validation-discounts.md. */}
|
||||
{!isSubscription &&
|
||||
(s.validationLines ?? []).length > 0 &&
|
||||
s.currency != null &&
|
||||
s.amountMinor != null && (
|
||||
<div className="rounded-term bg-term-panel-2 px-3 py-2 text-[0.75rem]">
|
||||
<div className="flex justify-between text-term-text">
|
||||
<span>{t("val.gross")}</span>
|
||||
<span className="tabular-nums">
|
||||
{formatMoney(s.grossMinor ?? s.amountMinor, s.currency)}
|
||||
</span>
|
||||
</div>
|
||||
{(s.validationLines ?? []).map((v, i) => (
|
||||
<div key={i} className="flex justify-between text-term-green">
|
||||
<span>{v.label}</span>
|
||||
<span className="tabular-nums">−{formatMoney(v.discountMinor, s.currency!)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Total — a subscription is prepaid (no amount) UNLESS it owes an
|
||||
out-of-window window charge; then show that amount. For an overstay the
|
||||
amount is the TOP-UP delta, not the whole stay. */}
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
type MovementStatus,
|
||||
type ShiftSummary,
|
||||
} 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 type { LedgerEvent } from "@parking/shared";
|
||||
|
||||
@@ -223,7 +223,7 @@ function TodayRow({ e }: { e: LedgerEvent }) {
|
||||
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 = new Date(e.occurredAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||
const time = formatClock(e.occurredAt);
|
||||
const label =
|
||||
e.type === "payment"
|
||||
? `${t("drawer.payment")}${e.identity ? ` · ${e.identity}` : ""}`
|
||||
|
||||
@@ -25,6 +25,10 @@ function LogRow({ log }: { log: AppLogRecord }) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const hasDetail = (log.context && Object.keys(log.context).length > 0) || log.stack;
|
||||
// Storm-coalesced row: the server folds repeated identical lines into one row and
|
||||
// counts them in context._repeat (first occurrence kept in _firstAt).
|
||||
const repeat = typeof log.context?._repeat === "number" ? (log.context?._repeat as number) : null;
|
||||
const firstAt = typeof log.context?._firstAt === "string" ? (log.context?._firstAt as string) : null;
|
||||
|
||||
return (
|
||||
<div className={`border-b border-term-border/50 ${log.level === "error" || log.level === "fatal" ? "bg-term-red/5" : ""}`}>
|
||||
@@ -38,7 +42,20 @@ function LogRow({ log }: { log: AppLogRecord }) {
|
||||
<span className="text-term-muted tabular-nums">{formatRelativeDateTime(log.createdAt, t)}</span>
|
||||
<span className={`font-semibold uppercase ${LEVEL_COLOR[log.level]}`}>{log.level}</span>
|
||||
<span className="text-term-muted">{t(log.source === "frontend" ? "logs.frontend" : "logs.backend")}</span>
|
||||
<span className="truncate text-term-text">{log.message}</span>
|
||||
<span className="truncate text-term-text">
|
||||
{repeat != null && repeat > 1 && (
|
||||
<span
|
||||
className="mr-1.5 rounded-term border border-term-amber/50 px-1 text-[0.625rem] font-semibold text-term-amber"
|
||||
title={t("logs.repeated", {
|
||||
count: repeat,
|
||||
firstAt: firstAt ? formatRelativeDateTime(firstAt, t) : "—",
|
||||
})}
|
||||
>
|
||||
×{repeat}
|
||||
</span>
|
||||
)}
|
||||
{log.message}
|
||||
</span>
|
||||
<span className="text-term-muted tabular-nums">{log.httpStatus ?? ""}</span>
|
||||
</button>
|
||||
{open && hasDetail && (
|
||||
|
||||
+95
-12
@@ -1,8 +1,10 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Fragment, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TFunction } from "i18next";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
@@ -12,6 +14,7 @@ import {
|
||||
LineChart,
|
||||
Pie,
|
||||
PieChart,
|
||||
ReferenceLine,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
@@ -37,6 +40,7 @@ const C = {
|
||||
border: "#2a2f38",
|
||||
text: "#f2f2ee",
|
||||
panel: "#14171c",
|
||||
panel2: "#1e222a",
|
||||
};
|
||||
|
||||
type PresetKey = "today" | "7d" | "30d" | "90d";
|
||||
@@ -140,27 +144,37 @@ function ReportBody({ data, t }: { data: ReportSummary; t: TFunction }) {
|
||||
...p,
|
||||
label: data.bucket === "hour" ? p.bucket.slice(11) + "h" : p.bucket,
|
||||
revenue: p.revenueMinor / 100,
|
||||
cash: p.cashMinor / 100,
|
||||
card: p.cardMinor / 100,
|
||||
}));
|
||||
const hours = data.entriesByHour.map((entries, h) => ({ hour: `${h}`, entries }));
|
||||
const mix = [
|
||||
{ name: t("reports.mix.ticket"), value: tot.ticketMinor, color: C.amber },
|
||||
{ name: t("reports.mix.subSales"), value: tot.subscriptionSalesMinor, color: C.cyan },
|
||||
{ name: t("reports.mix.subWindow"), value: tot.subscriptionWindowMinor, color: C.green },
|
||||
].filter((s) => s.value > 0);
|
||||
const peakOcc = Math.max(data.occupancyStart, ...data.series.map((p) => p.occupancyEnd));
|
||||
// Stay-duration bars: "≤30m … ≤24h" + the open-ended tail.
|
||||
const stay = data.stayHistogram.map((b) => ({
|
||||
label: b.uptoMin == null ? `>24${t("reports.stay.h")}` : b.uptoMin < 60 ? `≤${b.uptoMin}${t("reports.stay.m")}` : `≤${b.uptoMin / 60}${t("reports.stay.h")}`,
|
||||
count: b.count,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* KPI cards. */}
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-6">
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4 xl:grid-cols-8">
|
||||
<Kpi label={t("reports.kpi.entries")} value={String(tot.entries)} accent="green" />
|
||||
<Kpi label={t("reports.kpi.exits")} value={String(tot.exits)} accent="red" />
|
||||
<Kpi label={t("reports.kpi.revenue")} value={money(tot.revenueMinor)} accent="amber" />
|
||||
<Kpi label={t("reports.kpi.payments")} value={String(tot.payments)} accent="cyan" />
|
||||
<Kpi label={t("reports.kpi.avgStay")} value={formatMinutes(tot.avgParkedMinutes)} />
|
||||
<Kpi
|
||||
label={t("reports.kpi.subscribers")}
|
||||
value={String(data.subscriptions.currentlyValid)}
|
||||
label={t("reports.kpi.peakOcc")}
|
||||
value={data.capacity ? `${peakOcc} / ${data.capacity}` : String(peakOcc)}
|
||||
/>
|
||||
{/* The "look closer" counters — a spike here is what the signed chain is FOR. */}
|
||||
<Kpi label={t("reports.kpi.voids")} value={String(tot.voids)} accent={tot.voids > 0 ? "amber" : undefined} />
|
||||
<Kpi label={t("reports.kpi.anomalies")} value={String(tot.anomalies)} accent={tot.anomalies > 0 ? "red" : undefined} />
|
||||
</div>
|
||||
|
||||
{/* Entry / exit over time. */}
|
||||
@@ -192,8 +206,38 @@ function ReportBody({ data, t }: { data: ReportSummary; t: TFunction }) {
|
||||
</ResponsiveContainer>
|
||||
</Panel>
|
||||
|
||||
{/* Occupancy over time — THE parking curve: cars inside vs capacity. Step-shaped
|
||||
(occupancy only moves at entries/exits); the red line is the configured cap. */}
|
||||
<Panel title={t("reports.chart.occupancy")}>
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<AreaChart data={series} margin={{ top: 8, right: 12, bottom: 0, left: -8 }}>
|
||||
<CartesianGrid stroke={C.border} strokeDasharray="3 3" />
|
||||
<XAxis dataKey="label" stroke={C.muted} fontSize={11} />
|
||||
<YAxis stroke={C.muted} fontSize={11} allowDecimals={false} />
|
||||
<Tooltip contentStyle={tooltipStyle} />
|
||||
{data.capacity != null && (
|
||||
<ReferenceLine
|
||||
y={data.capacity}
|
||||
stroke={C.red}
|
||||
strokeDasharray="4 4"
|
||||
label={{ value: t("reports.capacityLine"), fill: C.red, fontSize: 11, position: "insideTopRight" }}
|
||||
/>
|
||||
)}
|
||||
<Area
|
||||
type="stepAfter"
|
||||
dataKey="occupancyEnd"
|
||||
name={t("reports.chart.occupancySeries")}
|
||||
stroke={C.cyan}
|
||||
fill={C.cyan}
|
||||
fillOpacity={0.15}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</Panel>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
{/* Revenue per bucket. */}
|
||||
{/* Revenue per bucket, stacked by tender — the drawer's cash vs the bank's card. */}
|
||||
<Panel title={t("reports.chart.revenue", { currency: cur })}>
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
<BarChart data={series} margin={{ top: 8, right: 12, bottom: 0, left: -8 }}>
|
||||
@@ -201,7 +245,9 @@ function ReportBody({ data, t }: { data: ReportSummary; t: TFunction }) {
|
||||
<XAxis dataKey="label" stroke={C.muted} fontSize={11} />
|
||||
<YAxis stroke={C.muted} fontSize={11} />
|
||||
<Tooltip contentStyle={tooltipStyle} formatter={(v) => money(Math.round(Number(v) * 100))} />
|
||||
<Bar dataKey="revenue" name={t("reports.kpi.revenue")} fill={C.amber} />
|
||||
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||
<Bar dataKey="cash" stackId="tender" name={t("reports.row.cash")} fill={C.amber} />
|
||||
<Bar dataKey="card" stackId="tender" name={t("reports.row.card")} fill={C.cyan} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</Panel>
|
||||
@@ -232,15 +278,15 @@ function ReportBody({ data, t }: { data: ReportSummary; t: TFunction }) {
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
{/* Peak hours (entries by hour-of-day). */}
|
||||
<Panel title={t("reports.chart.peakHours")}>
|
||||
{/* Stay-duration histogram — where the ladder/up-to breakpoints should sit. */}
|
||||
<Panel title={t("reports.chart.stay")}>
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
<BarChart data={hours} margin={{ top: 8, right: 12, bottom: 0, left: -8 }}>
|
||||
<BarChart data={stay} margin={{ top: 8, right: 12, bottom: 0, left: -8 }}>
|
||||
<CartesianGrid stroke={C.border} strokeDasharray="3 3" />
|
||||
<XAxis dataKey="hour" stroke={C.muted} fontSize={11} interval={1} />
|
||||
<XAxis dataKey="label" stroke={C.muted} fontSize={11} />
|
||||
<YAxis stroke={C.muted} fontSize={11} allowDecimals={false} />
|
||||
<Tooltip contentStyle={tooltipStyle} />
|
||||
<Bar dataKey="entries" name={t("reports.kpi.entries")} fill={C.cyan} />
|
||||
<Bar dataKey="count" name={t("reports.row.closed")} fill={C.green} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</Panel>
|
||||
@@ -262,6 +308,12 @@ function ReportBody({ data, t }: { data: ReportSummary; t: TFunction }) {
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
{/* Entries heatmap: hour × day-of-week. Weekday-vs-weekend patterns at a glance —
|
||||
the direct input for tariff windows (night rates, weekend cards, early bird). */}
|
||||
<Panel title={t("reports.chart.heatmap")}>
|
||||
<Heatmap matrix={data.entriesByDowHour} dows={t("reports.dowShort", { returnObjects: true }) as string[]} />
|
||||
</Panel>
|
||||
|
||||
<p className="text-[0.6875rem] text-term-muted">
|
||||
{t("reports.footnote", { tz: data.tz })}
|
||||
</p>
|
||||
@@ -269,6 +321,37 @@ function ReportBody({ data, t }: { data: ReportSummary; t: TFunction }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Hour-of-day × day-of-week entries heatmap: pure CSS grid, amber intensity scaled to
|
||||
* the busiest cell. Row 0 = Monday (server contract). Cell tooltip = exact count. */
|
||||
function Heatmap({ matrix, dows }: { matrix: number[][]; dows: string[] }) {
|
||||
const max = Math.max(1, ...matrix.flat());
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<div className="grid min-w-[560px] grid-cols-[max-content_repeat(24,1fr)] gap-px text-[0.625rem]">
|
||||
<span />
|
||||
{Array.from({ length: 24 }, (_, h) => (
|
||||
<span key={h} className="pb-0.5 text-center text-term-muted">
|
||||
{h % 3 === 0 ? h : ""}
|
||||
</span>
|
||||
))}
|
||||
{matrix.map((row, d) => (
|
||||
<Fragment key={d}>
|
||||
<span className="pr-1.5 leading-4 text-term-muted">{dows[d]}</span>
|
||||
{row.map((v, h) => (
|
||||
<span
|
||||
key={h}
|
||||
title={`${dows[d]} ${String(h).padStart(2, "0")}:00 — ${v}`}
|
||||
className="h-4 rounded-[1px]"
|
||||
style={{ background: v === 0 ? C.panel2 : C.amber, opacity: v === 0 ? 1 : 0.25 + 0.75 * (v / max) }}
|
||||
/>
|
||||
))}
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const tooltipStyle = {
|
||||
background: C.panel,
|
||||
border: `1px solid ${C.border}`,
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
type RelayEvent,
|
||||
type RelaySpec,
|
||||
type TestResult,
|
||||
fetchUsbPrinters,
|
||||
} from "./api.js";
|
||||
import { Modal } from "./ui/Modal.js";
|
||||
|
||||
@@ -257,8 +258,10 @@ function CategorySection({
|
||||
const [formFor, setFormFor] = useState<Assignment | "new" | null>(null);
|
||||
const [warnings, setWarnings] = useState<string[]>([]);
|
||||
|
||||
// Binding categories need a controller to point at first.
|
||||
const isBound = category !== "access";
|
||||
// Binding categories need a controller to point at first. Printers do NOT bind
|
||||
// (role + failoverRank route print jobs — see printer-routing.ts), so they are
|
||||
// addable on a controller-less box (e.g. the lab bench testing a USB printer).
|
||||
const isBound = category !== "access" && category !== "printer";
|
||||
const blockedNoController = isBound && controllers.length === 0;
|
||||
const editing = formFor && formFor !== "new" ? formFor : undefined;
|
||||
|
||||
@@ -463,6 +466,16 @@ function DeviceSummary({ assignment, controllers }: { assignment: Assignment; co
|
||||
</span>
|
||||
);
|
||||
}
|
||||
// Printers don't bind to a barrier (routing is role + failoverRank) — show the
|
||||
// role instead of a bogus "unbound" warning.
|
||||
if (assignment.category === "printer") {
|
||||
const role = typeof cfg.role === "string" ? cfg.role : null;
|
||||
return role ? (
|
||||
<span className="text-term-muted">
|
||||
{t(role === "booth-receipt" ? "devices.role.booth" : "devices.role.lane")}
|
||||
</span>
|
||||
) : null;
|
||||
}
|
||||
// Bound device: show controller + relay it points at, with inherited direction.
|
||||
const controllerId = typeof cfg.controllerId === "string" ? cfg.controllerId : null;
|
||||
const relay = typeof cfg.relay === "number" ? cfg.relay : null;
|
||||
@@ -534,6 +547,28 @@ function DeviceForm({
|
||||
}
|
||||
return out;
|
||||
});
|
||||
// USB printers PRESENT on the box (/dev/usb/lpN + sysfs model) — fetched when a
|
||||
// printer form is on the USB transport, so devicePath becomes a SELECT of real
|
||||
// devices instead of a guessed path (the kernel may pick lp1 — park-buzi did).
|
||||
const [usbPrinters, setUsbPrinters] = useState<{ path: string; description: string | null }[] | null>(null);
|
||||
const usbTransport = isPrinter && String(config.transport ?? "tcp-ip") === "usb";
|
||||
useEffect(() => {
|
||||
if (!usbTransport) return;
|
||||
let alive = true;
|
||||
fetchUsbPrinters()
|
||||
.then((r) => {
|
||||
if (!alive) return;
|
||||
setUsbPrinters(r.printers);
|
||||
// Fresh form with no explicit path yet → preselect the first REAL device.
|
||||
if (r.printers.length > 0) {
|
||||
setConfig((c) => (c.devicePath == null ? { ...c, devicePath: r.printers[0]!.path } : c));
|
||||
}
|
||||
})
|
||||
.catch(() => alive && setUsbPrinters([]));
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [usbTransport]);
|
||||
// Controllers: the unified relay map. Each relay reacts to an EVENT — entry/exit/both
|
||||
// (pulse a barrier) or radarAlert (drive an alert lamp). Alert relays carry a trigger
|
||||
// input + blink cadence; barriers carry no input wiring (that lives in `inputs` below).
|
||||
@@ -686,7 +721,11 @@ function DeviceForm({
|
||||
...(i.role === "presence" && i.activeLow ? { activeLow: true } : {}),
|
||||
...(i.role === "button" && i.cooldownSec ? { cooldownSec: i.cooldownSec } : {}),
|
||||
}));
|
||||
} else if (controllerId && boundRelay !== "") {
|
||||
} else if (!isPrinter && controllerId && boundRelay !== "") {
|
||||
// Readers/cameras bind to a controller relay (which barrier a scan opens +
|
||||
// inherited direction). Printers do NOT — routing is role+failoverRank only,
|
||||
// so no binding is emitted (and a stale one saved before 2026-07-06 drops
|
||||
// off on the next edit).
|
||||
out.controllerId = controllerId;
|
||||
out.relay = boundRelay;
|
||||
}
|
||||
@@ -759,7 +798,9 @@ function DeviceForm({
|
||||
if (!selected) return;
|
||||
// Bound devices must point at a controller relay (binding is optional in the
|
||||
// model with a fallback, but the wizard guides the admin to bind explicitly).
|
||||
if (!isController && (!controllerId || boundRelay === "")) {
|
||||
// Printers are exempt: nothing consumes a printer's binding — their routing is
|
||||
// role + failoverRank (see printer-routing.ts).
|
||||
if (!isController && !isPrinter && (!controllerId || boundRelay === "")) {
|
||||
setSaveError("Pick the controller and relay this device sits at.");
|
||||
return;
|
||||
}
|
||||
@@ -872,7 +913,32 @@ function DeviceForm({
|
||||
{f.label}
|
||||
{f.required ? " *" : ""}
|
||||
</label>
|
||||
{f.type === "select" ? (
|
||||
{f.key === "devicePath" && usbPrinters != null && usbPrinters.length > 0 ? (
|
||||
// Real devices found → a select (path + self-reported model). A saved
|
||||
// path that is NOT currently present stays selectable, flagged.
|
||||
<select
|
||||
className="select"
|
||||
value={String(config.devicePath ?? (f.default as string | undefined) ?? "")}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
setConfig((c) => ({ ...c, devicePath: v }));
|
||||
resetStatus();
|
||||
}}
|
||||
>
|
||||
{(() => {
|
||||
const cur = String(config.devicePath ?? (f.default as string | undefined) ?? "");
|
||||
const missing = cur && !usbPrinters.some((u) => u.path === cur);
|
||||
return [
|
||||
...(missing ? [{ path: cur, description: t("setup.usbSavedMissing") }] : []),
|
||||
...usbPrinters,
|
||||
].map((u) => (
|
||||
<option key={u.path} value={u.path}>
|
||||
{u.description ? `${u.path} — ${u.description}` : u.path}
|
||||
</option>
|
||||
));
|
||||
})()}
|
||||
</select>
|
||||
) : f.type === "select" ? (
|
||||
<select
|
||||
className="select"
|
||||
value={String(config[f.key] ?? (f.default as string | number | undefined) ?? "")}
|
||||
@@ -927,6 +993,9 @@ function DeviceForm({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{f.key === "devicePath" && usbPrinters != null && usbPrinters.length === 0 && (
|
||||
<p className="hint mt-1">{t("setup.usbNoneFound")}</p>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
@@ -959,8 +1028,9 @@ function DeviceForm({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* BOUND device: which controller + relay it sits at. */}
|
||||
{!isController && (
|
||||
{/* BOUND device: which controller + relay it sits at. Not printers —
|
||||
nothing consumes a printer binding (role+rank routes print jobs). */}
|
||||
{!isController && !isPrinter && (
|
||||
<BindingPicker
|
||||
controllers={controllers}
|
||||
controllerId={controllerId}
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
type ShiftSummary,
|
||||
type SessionUser,
|
||||
} from "./api.js";
|
||||
import { formatMoney, formatDuration, formatRelativeDateTime } from "./lib/format.js";
|
||||
import { formatMoney, formatDuration, formatDateTime, formatRelativeDateTime } from "./lib/format.js";
|
||||
import { CARD_PAYMENTS_ENABLED } from "./lib/features.js";
|
||||
import { Modal } from "./ui/Modal.js";
|
||||
import { Spinner } from "./ui/Spinner.js";
|
||||
@@ -457,7 +457,7 @@ function TakingsModal({ onClose }: { onClose: () => void }) {
|
||||
<p className="text-[0.75rem] text-term-muted">{t("common.loading")}</p>
|
||||
) : (
|
||||
<div className="text-[0.8125rem] tabular-nums">
|
||||
<div className="text-term-muted">{t("shift.asOf")} {new Date(x.asOf).toLocaleString()}</div>
|
||||
<div className="text-term-muted">{t("shift.asOf")} {formatDateTime(x.asOf, t)}</div>
|
||||
<div className="mt-1 grid grid-cols-2 gap-x-6 gap-y-0.5">
|
||||
<Figure label={t("shift.payments")} value={String(x.paymentCount)} />
|
||||
<span />
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { fetchOccupancy, fetchSiteConfig, saveSiteConfig, type Occupancy, type SiteConfig } from "./api.js";
|
||||
import {
|
||||
fetchOccupancy,
|
||||
fetchSiteConfig,
|
||||
fetchValidationPrograms,
|
||||
saveSiteConfig,
|
||||
saveValidationProgram,
|
||||
type Occupancy,
|
||||
type SiteConfig,
|
||||
type ValidationProgramView,
|
||||
} from "./api.js";
|
||||
import { STATIONS, ValidationStationsPanel, defaultProgram, type StationId } from "./ValidationSetup.js";
|
||||
|
||||
// Live occupancy + capacity + park metadata. Occupancy is shown to everyone (it's a
|
||||
// fold over the signed ledger); capacity and the metadata fields are admin-editable.
|
||||
@@ -28,12 +38,21 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
|
||||
const [reserveSubs, setReserveSubs] = useState(false);
|
||||
const [anprEntry, setAnprEntry] = useState(true);
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
// Merchant-validation programs (bar / lavazh). The checkboxes below toggle a
|
||||
// station's `active` (persisted at once — each flip signs a config_change); the
|
||||
// right-column panel edits the enabled stations. See validation-discounts.md.
|
||||
const [programs, setPrograms] = useState<ValidationProgramView[]>([]);
|
||||
|
||||
function reload() {
|
||||
fetchOccupancy().then(setOcc).catch(() => {});
|
||||
}
|
||||
useEffect(() => {
|
||||
reload();
|
||||
if (canEdit) {
|
||||
fetchValidationPrograms()
|
||||
.then((r) => setPrograms(r.programs))
|
||||
.catch(() => {});
|
||||
}
|
||||
fetchSiteConfig()
|
||||
.then((c) => {
|
||||
setCapInput(c.capacity == null ? "" : String(c.capacity));
|
||||
@@ -45,7 +64,23 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
|
||||
setMeta(m);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
}, [canEdit]);
|
||||
|
||||
/** Flip a merchant station's checkbox: persist `active` at once (a signed
|
||||
* config_change server-side), creating the well-known row with comp defaults on
|
||||
* the first enable. Config details are edited in the right-column panel. */
|
||||
async function toggleStation(id: StationId, active: boolean) {
|
||||
const existing = programs.find((p) => p.id === id);
|
||||
const body = existing
|
||||
? { ...existing, active }
|
||||
: { ...defaultProgram(id, t(id === "bar" ? "val.enableBar" : "val.enableLavazh")), active };
|
||||
try {
|
||||
const saved = await saveValidationProgram(id, body);
|
||||
setPrograms((ps) => [...ps.filter((p) => p.id !== id), saved]);
|
||||
} catch (e) {
|
||||
setMsg((e as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
setMsg(null);
|
||||
@@ -68,7 +103,8 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="card mt-6 max-w-md p-4">
|
||||
<div className="mt-6 flex flex-wrap items-start gap-6">
|
||||
<section className="card w-full max-w-md p-4">
|
||||
<div className="flex flex-wrap items-center gap-1.5 text-[0.8125rem]">
|
||||
<strong className="uppercase tracking-wider text-term-muted">{t("site.occupancy")}</strong>
|
||||
{occ == null ? (
|
||||
@@ -127,6 +163,23 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
|
||||
<span className="hint block">{t("site.anprEntryHint")}</span>
|
||||
</span>
|
||||
</label>
|
||||
<div className="border-t border-term-border pt-3 text-[0.6875rem] uppercase tracking-wider text-term-muted">
|
||||
{t("val.sectionTitle")}
|
||||
</div>
|
||||
<span className="hint -mt-2">{t("val.sectionHint")}</span>
|
||||
<div className="flex gap-6">
|
||||
{STATIONS.map((id) => (
|
||||
<label key={id} className="flex items-center gap-2 text-[0.75rem] text-term-text">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="accent-term-amber"
|
||||
checked={programs.find((p) => p.id === id)?.active ?? false}
|
||||
onChange={(e) => toggleStation(id, e.target.checked)}
|
||||
/>
|
||||
{t(id === "bar" ? "val.enableBar" : "val.enableLavazh")}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<div className="border-t border-term-border pt-3 text-[0.6875rem] uppercase tracking-wider text-term-muted">
|
||||
{t("site.parkDetails")}
|
||||
</div>
|
||||
@@ -158,5 +211,12 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
{canEdit && (
|
||||
<ValidationStationsPanel
|
||||
programs={programs}
|
||||
onSaved={(p) => setPrograms((ps) => [...ps.filter((x) => x.id !== p.id), p])}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
} from "./api.js";
|
||||
import { CARD_PAYMENTS_ENABLED } from "./lib/features.js";
|
||||
import { Modal } from "./ui/Modal.js";
|
||||
import { formatDateTime, type TFn } from "./lib/format.js";
|
||||
|
||||
// Subscription admin. Create/edit/revoke/delete subscriptions + their credentials
|
||||
// (card/QR) and bound plates. A SALE is priced by selecting an admin-defined PLAN over
|
||||
@@ -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
|
||||
* timeframe summary (or "24/7" when the version has no window). */
|
||||
function versionLabel(v: SubscriptionPlan, t: (k: string) => string): string {
|
||||
function versionLabel(v: SubscriptionPlan, t: TFn): string {
|
||||
const eff = new Date(v.effectiveFrom);
|
||||
const date = Number.isNaN(eff.getTime()) ? v.effectiveFrom : eff.toLocaleString();
|
||||
const date = Number.isNaN(eff.getTime()) ? v.effectiveFrom : formatDateTime(v.effectiveFrom, t);
|
||||
const tf = v.timeframes;
|
||||
const rules = tf ? `${daysLabel(tf.days, t)} ${hhmm(tf.fromMin)}–${hhmm(tf.toMin)}` : t("subs.allDay");
|
||||
return `${date} · ${rules}`;
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
type SubscriptionPlan,
|
||||
} from "./api.js";
|
||||
import { Modal } from "./ui/Modal.js";
|
||||
import { formatDate } from "./lib/format.js";
|
||||
import { currencyOptions } from "./lib/currencies.js";
|
||||
|
||||
// Admin-only subscription PLAN catalog. Plans are admin-composed, versioned config the
|
||||
@@ -277,7 +278,7 @@ export function SubscriptionPlansManager() {
|
||||
{(p.pricePerPeriodMinor / 100).toLocaleString()} {p.currency} / {t(PERIOD_KEY[p.period])}
|
||||
</span>
|
||||
<span>{timeframesSummary(p.timeframes, t)}</span>
|
||||
<span>{t("plans.colEffective")}: {new Date(p.effectiveFrom).toLocaleDateString()}</span>
|
||||
<span>{t("plans.colEffective")}: {formatDate(p.effectiveFrom, t)}</span>
|
||||
</div>
|
||||
|
||||
{/* Used by */}
|
||||
|
||||
@@ -1,19 +1,24 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ApiError, fetchTariff, publishTariffVersion, type TariffState } from "./api.js";
|
||||
import { TariffEditorForm, emptyForm, formFromActive, toStructure, type FormState } from "./TariffEditorForm.js";
|
||||
import { ApiError, fetchTariff, publishTariffVersion, type TariffState, type TariffVersion } from "./api.js";
|
||||
import { TariffEditorForm, emptyForm, formFromActive, formFromVersion, toStructure, type FormState } from "./TariffEditorForm.js";
|
||||
import { formatDateTime } from "./lib/format.js";
|
||||
|
||||
// Tariff composer — the admin edits + publishes the LIVE rate card. Publishing
|
||||
// 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
|
||||
// draft modal — see TariffEditorForm.tsx. To experiment without publishing, use the
|
||||
// lab (a draft only becomes real through this same publish path). See
|
||||
// sessions reprice correctly. A right sidebar lists the published history (named
|
||||
// since 2026-07-05); clicking a version loads it into the editor as the STARTING
|
||||
// POINT — publishing always creates a new version effective now, it never edits the
|
||||
// clicked one. The form machinery is shared with the Tariff Lab's draft modal — see
|
||||
// TariffEditorForm.tsx. To experiment without publishing, use the lab. See
|
||||
// wiki/concepts/tariff.md.
|
||||
|
||||
export function TariffComposer() {
|
||||
const { t } = useTranslation();
|
||||
const [state, setState] = useState<TariffState | null>(null);
|
||||
const [form, setForm] = useState<FormState>(emptyForm);
|
||||
// Which published version the editor was last loaded from (sidebar highlight).
|
||||
const [loadedId, setLoadedId] = useState<string | null>(null);
|
||||
// Optional label for the version about to be published. Deliberately NOT prefilled
|
||||
// from the active version — a tweaked card republished under last season's name
|
||||
// would mislabel the history.
|
||||
@@ -26,10 +31,17 @@ export function TariffComposer() {
|
||||
.then((s) => {
|
||||
setState(s);
|
||||
setForm(formFromActive(s));
|
||||
setLoadedId(s.active?.id ?? null);
|
||||
})
|
||||
.catch((e) => setMsg({ kind: "err", text: (e as Error).message }));
|
||||
}, []);
|
||||
|
||||
function loadVersion(v: TariffVersion) {
|
||||
setForm(formFromVersion(v.currency, v.structure));
|
||||
setLoadedId(v.id);
|
||||
setMsg(null);
|
||||
}
|
||||
|
||||
async function publish() {
|
||||
setSaving(true);
|
||||
setMsg(null);
|
||||
@@ -41,6 +53,7 @@ export function TariffComposer() {
|
||||
});
|
||||
const fresh = await fetchTariff();
|
||||
setState(fresh);
|
||||
setLoadedId(fresh.active?.id ?? null);
|
||||
setVersionName("");
|
||||
setMsg({ kind: "ok", text: t("tariff.publishedOk") });
|
||||
} catch (e) {
|
||||
@@ -65,12 +78,14 @@ export function TariffComposer() {
|
||||
<p className="mb-4 text-[0.75rem] text-term-muted">
|
||||
{state.active.name ? `${state.active.name} — ` : ""}
|
||||
{t("tariff.activeSince", {
|
||||
date: new Date(state.active.effectiveFrom).toLocaleString(),
|
||||
date: formatDateTime(state.active.effectiveFrom, t),
|
||||
count: state.versions.length,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-4 lg:flex-row">
|
||||
<div className="min-w-0 flex-1">
|
||||
<TariffEditorForm form={form} onChange={setForm} />
|
||||
|
||||
<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>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -70,23 +70,35 @@ export interface FormState {
|
||||
const toMinor = (major: string): number => Math.round(parseFloat(major || "0") * 100);
|
||||
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 [
|
||||
{ hours: "1", total: "2.00" },
|
||||
{ hours: "3", total: "5.00" },
|
||||
{ hours: "1", total: ex.stepSmall },
|
||||
{ hours: "3", total: ex.stepBig },
|
||||
];
|
||||
}
|
||||
function emptyLadder(): PricingForm {
|
||||
function emptyLadder(currency: string): PricingForm {
|
||||
const ex = examples(currency);
|
||||
return {
|
||||
mode: "ladder",
|
||||
flat: "0.00",
|
||||
packageTotal: "0.00",
|
||||
dailyCap: "",
|
||||
blocks: [{ hours: "1", price: "2.00" }, { hours: "", price: "1.00" }],
|
||||
steps: emptySteps(),
|
||||
blocks: [{ hours: "1", price: ex.hi }, { hours: "", price: ex.lo }],
|
||||
steps: emptySteps(currency),
|
||||
};
|
||||
}
|
||||
function emptyTier(): TierForm {
|
||||
function emptyTier(currency: string): TierForm {
|
||||
return {
|
||||
name: "",
|
||||
priority: "10",
|
||||
@@ -96,18 +108,19 @@ function emptyTier(): TierForm {
|
||||
toHour: "",
|
||||
dateFrom: "",
|
||||
dateTo: "",
|
||||
pricing: { ...emptyLadder(), blocks: [{ hours: "", price: "1.00" }] },
|
||||
pricing: { ...emptyLadder(currency), blocks: [{ hours: "", price: examples(currency).lo }] },
|
||||
};
|
||||
}
|
||||
|
||||
export function emptyForm(): FormState {
|
||||
const currency = "ALL"; // the site's currency — examples scale with it
|
||||
return {
|
||||
currency: "ALL",
|
||||
currency,
|
||||
gracePeriodEntryMin: "15",
|
||||
incrementMin: "60",
|
||||
lostTicket: "20.00",
|
||||
lostTicket: examples(currency).lost,
|
||||
gracePeriodExitMin: "15",
|
||||
base: emptyLadder(),
|
||||
base: emptyLadder(currency),
|
||||
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,
|
||||
// or window package).
|
||||
function pricingFromCard(c: {
|
||||
function pricingFromCard(
|
||||
c: {
|
||||
flatMinor?: number;
|
||||
blocks?: TariffBlock[];
|
||||
steps?: TariffStep[];
|
||||
packageMinor?: number;
|
||||
dailyCapMinor?: number | null;
|
||||
}): PricingForm {
|
||||
},
|
||||
currency: string,
|
||||
): PricingForm {
|
||||
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) {
|
||||
return { ...emptyLadder(), mode: "package", packageTotal: toMajor(c.packageMinor) };
|
||||
return { ...emptyLadder(currency), mode: "package", packageTotal: toMajor(c.packageMinor) };
|
||||
}
|
||||
if (c.flatMinor != null) {
|
||||
return { ...emptyLadder(), mode: "flat", flat: toMajor(c.flatMinor) };
|
||||
return { ...emptyLadder(currency), mode: "flat", flat: toMajor(c.flatMinor) };
|
||||
}
|
||||
return {
|
||||
...emptyLadder(),
|
||||
...emptyLadder(currency),
|
||||
mode: "ladder",
|
||||
dailyCap: c.dailyCapMinor == null ? "" : toMajor(c.dailyCapMinor),
|
||||
blocks: blocksToForm(c.blocks ?? []),
|
||||
};
|
||||
}
|
||||
|
||||
function tierFromCard(c: TariffCard): TierForm {
|
||||
function tierFromCard(c: TariffCard, currency: string): TierForm {
|
||||
const w = c.window ?? {};
|
||||
return {
|
||||
name: c.name,
|
||||
@@ -167,7 +183,7 @@ function tierFromCard(c: TariffCard): TierForm {
|
||||
toHour: w.toHour ?? "",
|
||||
dateFrom: w.dateFrom ?? "",
|
||||
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),
|
||||
};
|
||||
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.
|
||||
return { ...common, base: pricingFromCard(st), tiers: [] };
|
||||
return { ...common, base: pricingFromCard(st, currency), tiers: [] };
|
||||
}
|
||||
|
||||
export function formFromActive(s: TariffState): FormState {
|
||||
@@ -278,6 +298,8 @@ export function TariffEditorForm({
|
||||
onChange: (update: (f: FormState) => FormState) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
// The billing unit all flat/ladder prices are entered in (labels reflect it live).
|
||||
const inc = Math.max(1, Math.round(Number(form.incrementMin)) || 60);
|
||||
|
||||
function set<K extends keyof FormState>(key: K, value: FormState[K]) {
|
||||
onChange((f) => ({ ...f, [key]: value }));
|
||||
@@ -322,7 +344,7 @@ export function TariffEditorForm({
|
||||
onChange((f) => ({ ...f, tiers: f.tiers.map((tr, j) => (j === i ? { ...tr, ...patch } : tr)) }));
|
||||
}
|
||||
function addTier() {
|
||||
onChange((f) => ({ ...f, tiers: [...f.tiers, emptyTier()] }));
|
||||
onChange((f) => ({ ...f, tiers: [...f.tiers, emptyTier(f.currency)] }));
|
||||
}
|
||||
function removeTier(i: number) {
|
||||
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)} />
|
||||
</div>
|
||||
|
||||
{/* The increment is the UNIT every flat/ladder price is charged in. At 60 the
|
||||
form reads naturally as per-hour; any other value silently redefines every
|
||||
price below, so shout it (the 60→10 "six charges per hour" trap). */}
|
||||
{inc !== 60 && (
|
||||
<p className="mt-2 rounded-term border border-term-amber/50 bg-term-amber/10 px-3 py-2 text-[0.75rem] text-term-amber">
|
||||
{t("tariff.incrementWarning", { min: inc })}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* The DEFAULT card — always-active rate. Front-and-centre; a site that never
|
||||
wants tiers just edits this and publishes a bare V1 structure. */}
|
||||
<h3 className="mt-6 mb-0.5 text-h6 font-semibold uppercase tracking-wider text-term-text">{t("tariff.defaultCard")}</h3>
|
||||
@@ -363,6 +394,7 @@ export function TariffEditorForm({
|
||||
<PricingEditor
|
||||
t={t}
|
||||
pricing={form.base}
|
||||
incrementMin={inc}
|
||||
allowStepped
|
||||
onMode={(mode) => updatePricing("base", (p) => ({ ...p, mode }))}
|
||||
onFlat={(flat) => updatePricing("base", (p) => ({ ...p, flat }))}
|
||||
@@ -434,6 +466,7 @@ export function TariffEditorForm({
|
||||
<PricingEditor
|
||||
t={t}
|
||||
pricing={tr.pricing}
|
||||
incrementMin={inc}
|
||||
allowPackage
|
||||
onMode={(mode) => updatePricing(i, (p) => ({ ...p, mode }))}
|
||||
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
|
||||
// engine needs a window to be an occurrence of).
|
||||
function PricingEditor(props: {
|
||||
t: (k: string) => string;
|
||||
t: (k: string, opts?: Record<string, unknown>) => string;
|
||||
pricing: PricingForm;
|
||||
/** Current billing increment (minutes) — every flat/ladder price is PER this unit,
|
||||
* so the price labels state it explicitly instead of a vague "per increment". */
|
||||
incrementMin: number;
|
||||
allowStepped?: boolean;
|
||||
allowPackage?: boolean;
|
||||
onMode: (m: "ladder" | "flat" | "stepped" | "package") => void;
|
||||
@@ -475,6 +511,18 @@ function PricingEditor(props: {
|
||||
onRemoveStep?: (i: number) => void;
|
||||
}) {
|
||||
const { t, pricing: p } = props;
|
||||
/** "= N / orë" equivalence for a per-increment price (only shown when the tick
|
||||
* isn't an hour — at 60 the price already IS the hourly price). */
|
||||
const perHour = (major: string): string | null => {
|
||||
if (props.incrementMin === 60) return null;
|
||||
const v = Number(major);
|
||||
if (!Number.isFinite(v) || v <= 0) return null;
|
||||
return t("tariff.perHourEquiv", { amount: ((v * 60) / props.incrementMin).toFixed(2) });
|
||||
};
|
||||
const unitLabel =
|
||||
props.incrementMin === 60 ? t("tariff.pricePerHour") : t("tariff.pricePerN", { min: props.incrementMin });
|
||||
const flatLabel =
|
||||
props.incrementMin === 60 ? t("tariff.modeFlat") : t("tariff.modeFlatN", { min: props.incrementMin });
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-3 flex flex-wrap gap-4 text-[0.75rem]">
|
||||
@@ -484,7 +532,7 @@ function PricingEditor(props: {
|
||||
</label>
|
||||
<label className="inline-flex items-center gap-1.5 text-term-text">
|
||||
<input type="radio" className="accent-term-amber" checked={p.mode === "flat"} onChange={() => props.onMode("flat")} />
|
||||
{t("tariff.modeFlat")}
|
||||
{flatLabel}
|
||||
</label>
|
||||
{props.allowStepped && (
|
||||
<label className="inline-flex items-center gap-1.5 text-term-text">
|
||||
@@ -550,8 +598,9 @@ function PricingEditor(props: {
|
||||
</>
|
||||
) : p.mode === "flat" ? (
|
||||
<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)} />
|
||||
{perHour(p.flat) && <span className="text-[0.6875rem] text-term-muted">{perHour(p.flat)}</span>}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
@@ -559,7 +608,7 @@ function PricingEditor(props: {
|
||||
<thead>
|
||||
<tr className="text-left">
|
||||
<th className="label px-2 pb-1 font-normal">{t("tariff.bandDuration")}</th>
|
||||
<th className="label px-2 pb-1 font-normal">{t("tariff.pricePerIncrement")}</th>
|
||||
<th className="label px-2 pb-1 font-normal">{unitLabel}</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -579,7 +628,10 @@ function PricingEditor(props: {
|
||||
)}
|
||||
</td>
|
||||
<td className="px-2 py-1">
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<input className="input w-28" value={b.price} onChange={(e) => props.onBlock(i, { price: e.target.value })} />
|
||||
{perHour(b.price) && <span className="text-[0.6875rem] text-term-muted">{perHour(b.price)}</span>}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-2">
|
||||
{!isTail && (
|
||||
|
||||
@@ -15,7 +15,9 @@ import {
|
||||
} from "./api.js";
|
||||
import { TariffEditorForm, emptyForm, formFromActive, formFromVersion, toStructure, type FormState } from "./TariffEditorForm.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
|
||||
// live in their own mutable table (tariff_drafts), so experimenting never churns the
|
||||
@@ -202,7 +204,7 @@ export function TariffLab() {
|
||||
{selectedDraft
|
||||
? selectedDraft.name
|
||||
: selectedVersion
|
||||
? selectedVersion.name ?? new Date(selectedVersion.effectiveFrom).toLocaleString()
|
||||
? selectedVersion.name ?? formatDateTime(selectedVersion.effectiveFrom, t)
|
||||
: t("lab.activeTariff")}
|
||||
</span>
|
||||
{selectedDraft && (
|
||||
@@ -264,14 +266,19 @@ export function TariffLab() {
|
||||
)}
|
||||
</dd>
|
||||
<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 && (
|
||||
<>
|
||||
<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>
|
||||
{/* 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>
|
||||
|
||||
{/* 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 text-[0.6875rem] text-term-muted">
|
||||
{d.currency} · {new Date(d.updatedAt).toLocaleString()}
|
||||
{d.currency} · {formatDateTime(d.updatedAt, t)}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
@@ -345,7 +352,7 @@ export function TariffLab() {
|
||||
{state?.active?.name ? ` — ${state.active.name}` : ""}
|
||||
</span>
|
||||
<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>
|
||||
</button>
|
||||
</li>
|
||||
@@ -363,10 +370,10 @@ export function TariffLab() {
|
||||
}`}
|
||||
>
|
||||
<span className="block font-semibold">
|
||||
{v.name ?? new Date(v.effectiveFrom).toLocaleString()}
|
||||
{v.name ?? formatDateTime(v.effectiveFrom, t)}
|
||||
</span>
|
||||
<span className="block text-[0.6875rem] text-term-muted">
|
||||
{v.name ? `${new Date(v.effectiveFrom).toLocaleString()} · ` : ""}
|
||||
{v.name ? `${formatDateTime(v.effectiveFrom, t)} · ` : ""}
|
||||
{v.currency}
|
||||
</span>
|
||||
</button>
|
||||
@@ -416,3 +423,82 @@ function labelMin(min: number): string {
|
||||
if (min < 1440) return `${min / 60}h`;
|
||||
return `${min / 1440}d`;
|
||||
}
|
||||
|
||||
/** The fee's line items — every row states its time window / rule and its amount, so
|
||||
* the operator can retrace the exact sum (caps show as negative adjustments). */
|
||||
function BreakdownTable({
|
||||
b,
|
||||
periodStart,
|
||||
currency,
|
||||
t,
|
||||
}: {
|
||||
b: FeeBreakdown;
|
||||
periodStart: string;
|
||||
currency: string;
|
||||
t: TFunction;
|
||||
}) {
|
||||
const startMs = Date.parse(periodStart);
|
||||
const multiDay = b.billedMinutes > 1440;
|
||||
const at = (min: number) => {
|
||||
const iso = new Date(startMs + min * 60_000).toISOString();
|
||||
return multiDay ? formatDateTime(iso, t) : formatClock(iso);
|
||||
};
|
||||
const money = (m: number) => formatMoney(m, currency);
|
||||
const hours = (min: number) => (min % 60 === 0 ? `${min / 60}` : (min / 60).toFixed(1));
|
||||
|
||||
return (
|
||||
<div className="mt-3 border-t border-term-border pt-2">
|
||||
<div className="mb-1 text-[0.6875rem] uppercase tracking-wider text-term-muted">{t("lab.bd.title")}</div>
|
||||
{b.billedMinutes > 0 && (
|
||||
<p className="hint mb-1.5">
|
||||
{t("lab.bd.rounding", { raw: b.rawMinutes, billed: b.billedMinutes, inc: b.incrementMin })}
|
||||
</p>
|
||||
)}
|
||||
<table className="w-full text-[0.75rem] tabular-nums">
|
||||
<tbody>
|
||||
{b.items.map((it, i) => {
|
||||
let label: string;
|
||||
let amount: number;
|
||||
let cls = "text-term-text";
|
||||
switch (it.kind) {
|
||||
case "grace":
|
||||
label = t("lab.bd.grace", { min: it.minutes });
|
||||
amount = 0;
|
||||
cls = "text-term-green";
|
||||
break;
|
||||
case "band":
|
||||
label = `${at(it.fromMin)}–${at(it.toMin)} · ${it.increments} × ${money(it.unitMinor)}${it.card ? ` · ${it.card}` : ""}`;
|
||||
amount = it.amountMinor;
|
||||
break;
|
||||
case "package":
|
||||
label = `${at(it.fromMin)} · ${it.card} — ${t("lab.bd.package")}`;
|
||||
amount = it.amountMinor;
|
||||
break;
|
||||
case "step":
|
||||
label = it.repeated
|
||||
? t("lab.bd.stepRepeated", { day: it.day })
|
||||
: t("lab.bd.step", { day: it.day, hours: hours(it.uptoMin) });
|
||||
amount = it.amountMinor;
|
||||
break;
|
||||
case "cap":
|
||||
label = t("lab.bd.cap", { day: it.day, cap: money(it.capMinor) });
|
||||
amount = it.amountMinor;
|
||||
cls = "text-term-red";
|
||||
break;
|
||||
}
|
||||
return (
|
||||
<tr key={i} className="border-b border-term-border/40">
|
||||
<td className="py-0.5 pr-2 text-term-muted">{label}</td>
|
||||
<td className={`whitespace-nowrap py-0.5 text-right ${cls}`}>{money(amount)}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
<tr>
|
||||
<td className="py-1 pr-2 font-semibold text-term-text">{t("lab.bd.total")}</td>
|
||||
<td className="whitespace-nowrap py-1 text-right font-semibold text-term-cyan">{money(b.totalMinor)}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
applyValidation,
|
||||
fetchMyValidationPrograms,
|
||||
fetchValidationSession,
|
||||
voidValidation,
|
||||
type SessionUser,
|
||||
type ValidationProgramView,
|
||||
type ValidationSessionView,
|
||||
} from "./api.js";
|
||||
import { formatDuration, formatMoney, formatRelativeDateTime } from "./lib/format.js";
|
||||
|
||||
// The MERCHANT screen (/validate): the bar/lavazh user's ENTIRE surface. Scan or key
|
||||
// the customer's ticket → see the session (deliberately NO money data — the booth
|
||||
// settles) → apply the bound program → done. Mobile-friendly: a phone/tablet on the
|
||||
// site LAN, or a booth-style USB HID scanner (it types digits + Enter into the
|
||||
// focused input). A mistake can be voided while UNUSED (append-only, signed).
|
||||
// Gated by validation:create + the server-side program↔user binding.
|
||||
// See wiki/concepts/validation-discounts.md.
|
||||
|
||||
type Program = Omit<ValidationProgramView, "userIds">;
|
||||
|
||||
/** Human line for what a program grants (the params live on the program row). */
|
||||
function programSummary(p: Program, t: (k: string, o?: Record<string, unknown>) => string): string {
|
||||
if (p.mode === "comp") return t("val.modeComp");
|
||||
if (p.mode === "timeCredit") return `${t("val.modeTimeCredit")}: ${p.minutes ?? 0} min`;
|
||||
if (p.mode === "percent") return `${t("val.modePercent")}: ${p.percent ?? 0}%`;
|
||||
return t("val.modeFixed");
|
||||
}
|
||||
|
||||
export function ValidateScreen({ user }: { user: SessionUser }) {
|
||||
const { t } = useTranslation();
|
||||
const [programs, setPrograms] = useState<Program[] | null>(null);
|
||||
const [programId, setProgramId] = useState<string | null>(null);
|
||||
const [ticket, setTicket] = useState("");
|
||||
const [view, setView] = useState<ValidationSessionView | null>(null);
|
||||
const [amount, setAmount] = useState("");
|
||||
const [msg, setMsg] = useState<{ kind: "ok" | "err"; text: string } | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchMyValidationPrograms()
|
||||
.then((r) => {
|
||||
setPrograms(r.programs);
|
||||
if (r.programs.length === 1) setProgramId(r.programs[0]!.id);
|
||||
})
|
||||
.catch(() => setPrograms([]));
|
||||
inputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
const program = programs?.find((p) => p.id === programId) ?? null;
|
||||
|
||||
async function lookup(id?: string) {
|
||||
const identity = (id ?? ticket).trim();
|
||||
if (!identity) return;
|
||||
setMsg(null);
|
||||
try {
|
||||
setView(await fetchValidationSession(identity));
|
||||
} catch (e) {
|
||||
setMsg({ kind: "err", text: (e as Error).message });
|
||||
}
|
||||
}
|
||||
|
||||
async function apply() {
|
||||
if (!view || !program) return;
|
||||
setBusy(true);
|
||||
setMsg(null);
|
||||
try {
|
||||
const body: { identity: string; programId: string; amountMinor?: number } = {
|
||||
identity: view.identity,
|
||||
programId: program.id,
|
||||
};
|
||||
if (program.mode === "fixed") {
|
||||
const n = Number(amount);
|
||||
body.amountMinor = Number.isFinite(n) ? Math.round(n * 100) : 0;
|
||||
}
|
||||
await applyValidation(body);
|
||||
setMsg({ kind: "ok", text: t("val.applied") });
|
||||
setAmount("");
|
||||
await lookup(view.identity);
|
||||
} catch (e) {
|
||||
setMsg({ kind: "err", text: (e as Error).message });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function voidOne(eventId: string) {
|
||||
if (!view) return;
|
||||
if (!window.confirm(t("val.confirmVoid"))) return;
|
||||
setMsg(null);
|
||||
try {
|
||||
await voidValidation({ eventId, identity: view.identity });
|
||||
await lookup(view.identity);
|
||||
} catch (e) {
|
||||
setMsg({ kind: "err", text: (e as Error).message });
|
||||
}
|
||||
}
|
||||
|
||||
// The session's blocking condition, if any (not found / closed / subscriber).
|
||||
const blocked =
|
||||
view == null
|
||||
? null
|
||||
: !view.found
|
||||
? t("val.notFound")
|
||||
: view.subscription
|
||||
? t("val.subscription")
|
||||
: !view.open
|
||||
? t("val.closed")
|
||||
: null;
|
||||
|
||||
const alreadyApplied =
|
||||
view != null &&
|
||||
program != null &&
|
||||
view.validations.some((v) => v.programId === program.id && !v.voided && v.consumedBy == null);
|
||||
|
||||
const fixedAmountOk =
|
||||
program?.mode !== "fixed" ||
|
||||
(Number(amount) > 0 &&
|
||||
(program.maxAmountMinor == null || Math.round(Number(amount) * 100) <= program.maxAmountMinor));
|
||||
|
||||
return (
|
||||
<div className="mx-auto mt-6 w-full max-w-md">
|
||||
<section className="card p-4">
|
||||
<div className="text-[0.6875rem] uppercase tracking-wider text-term-muted">{t("val.title")}</div>
|
||||
|
||||
{programs != null && programs.length === 0 && (
|
||||
<p className="mt-3 text-[0.8125rem] text-term-red">{t("val.noPrograms")}</p>
|
||||
)}
|
||||
|
||||
{programs != null && programs.length > 1 && (
|
||||
<div className="mt-3 flex gap-1">
|
||||
{programs.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
className={`btn btn-sm ${p.id === programId ? "btn-primary" : "btn-ghost"}`}
|
||||
onClick={() => setProgramId(p.id)}
|
||||
>
|
||||
{p.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{program && <p className="mt-1 text-[0.75rem] text-term-muted">{program.name} — {programSummary(program, t)}</p>}
|
||||
|
||||
<form
|
||||
className="mt-3 flex gap-2"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void lookup();
|
||||
}}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="input flex-1 tabular-nums"
|
||||
inputMode="numeric"
|
||||
value={ticket}
|
||||
onChange={(e) => setTicket(e.target.value)}
|
||||
placeholder={t("val.scanPrompt")}
|
||||
/>
|
||||
<button type="submit" className="btn btn-primary btn-sm">{t("val.lookup")}</button>
|
||||
</form>
|
||||
|
||||
{msg && (
|
||||
<p className={`mt-2 text-[0.8125rem] ${msg.kind === "ok" ? "text-term-green" : "text-term-red"}`}>
|
||||
{msg.text}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{view && (
|
||||
<div className="mt-3 border-t border-term-border pt-3">
|
||||
{blocked ? (
|
||||
<p className="text-[0.8125rem] text-term-red">{blocked}</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-baseline justify-between text-[0.8125rem]">
|
||||
<span className="font-semibold tabular-nums text-term-text">{view.identity}</span>
|
||||
<span className="text-term-muted">
|
||||
{t("val.entry")} {formatRelativeDateTime(view.enteredAt, t)}
|
||||
{view.enteredAt && <> · {formatDuration(view.enteredAt, new Date().toISOString())}</>}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{program && !alreadyApplied && (
|
||||
<div className="mt-3 grid gap-2">
|
||||
{program.mode === "fixed" && (
|
||||
<div className="field">
|
||||
<span className="label">
|
||||
{t("val.amountLabel")}
|
||||
{program.maxAmountMinor != null && (
|
||||
<span className="hint ml-2">
|
||||
{t("val.amountHint", { max: formatMoney(program.maxAmountMinor, "") })}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<input
|
||||
className="input w-40 tabular-nums"
|
||||
inputMode="decimal"
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
placeholder="300"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={busy || !fixedAmountOk}
|
||||
onClick={apply}
|
||||
>
|
||||
{t("val.apply")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{view.validations.length > 0 && (
|
||||
<div className="mt-3">
|
||||
<div className="label">{t("val.existing")}</div>
|
||||
<ul className="mt-1 grid gap-1">
|
||||
{view.validations.map((v) => (
|
||||
<li key={v.eventId} className="flex items-center gap-2 text-[0.75rem] text-term-text">
|
||||
<span>{v.label}</span>
|
||||
{v.amountMinor != null && <span className="tabular-nums">−{formatMoney(v.amountMinor, "")}</span>}
|
||||
{v.minutes != null && <span>{v.minutes} min</span>}
|
||||
{v.percent != null && <span>{v.percent}%</span>}
|
||||
{v.voided ? (
|
||||
<span className="text-term-muted">({t("val.voided")})</span>
|
||||
) : v.consumedBy != null ? (
|
||||
<span className="text-term-muted">({t("val.used")})</span>
|
||||
) : (
|
||||
v.operator === user.username && (
|
||||
<button type="button" className="btn btn-ghost btn-sm ml-auto" onClick={() => voidOne(v.eventId)}>
|
||||
{t("val.void")}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
fetchUsers,
|
||||
saveValidationProgram,
|
||||
type ManagedUser,
|
||||
type ValidationMode,
|
||||
type ValidationProgramView,
|
||||
} from "./api.js";
|
||||
|
||||
// The /setup/site RIGHT panel: per-station merchant-validation config (Bar / Lavazh).
|
||||
// The checkboxes on the left card toggle a station's `active`; this panel edits the
|
||||
// enabled stations' programs — one panel, tabs when both are on. Storage is generic
|
||||
// (validation_programs rows keyed "bar"/"lavazh"); the UI is deliberately these two
|
||||
// fixed stations. Amounts are entered in MAJOR units and stored in integer minor
|
||||
// units (the tariff-composer convention). See wiki/concepts/validation-discounts.md.
|
||||
|
||||
/** The two well-known stations the checkboxes toggle. */
|
||||
export const STATIONS = ["bar", "lavazh"] as const;
|
||||
export type StationId = (typeof STATIONS)[number];
|
||||
|
||||
/** A blank program draft for a station enabled for the first time. */
|
||||
export function defaultProgram(id: StationId, label: string): Omit<ValidationProgramView, "id"> {
|
||||
return {
|
||||
name: label,
|
||||
mode: "comp",
|
||||
minutes: null,
|
||||
percent: null,
|
||||
maxAmountMinor: null,
|
||||
maxPerDay: null,
|
||||
active: true,
|
||||
userIds: [],
|
||||
};
|
||||
}
|
||||
|
||||
const toMinor = (s: string): number | null => {
|
||||
const v = s.trim();
|
||||
if (v === "") return null;
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) && n > 0 ? Math.round(n * 100) : null;
|
||||
};
|
||||
const fromMinor = (m: number | null): string => (m == null ? "" : String(m / 100));
|
||||
const toInt = (s: string): number | null => {
|
||||
const v = s.trim();
|
||||
if (v === "") return null;
|
||||
const n = Number(v);
|
||||
return Number.isInteger(n) && n > 0 ? n : null;
|
||||
};
|
||||
|
||||
function StationForm({
|
||||
program,
|
||||
onSaved,
|
||||
}: {
|
||||
program: ValidationProgramView;
|
||||
onSaved: (p: ValidationProgramView) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [name, setName] = useState(program.name);
|
||||
const [mode, setMode] = useState<ValidationMode>(program.mode);
|
||||
const [minutes, setMinutes] = useState(program.minutes == null ? "" : String(program.minutes));
|
||||
const [percent, setPercent] = useState(program.percent == null ? "" : String(program.percent));
|
||||
const [maxAmount, setMaxAmount] = useState(fromMinor(program.maxAmountMinor));
|
||||
const [maxPerDay, setMaxPerDay] = useState(program.maxPerDay == null ? "" : String(program.maxPerDay));
|
||||
const [userIds, setUserIds] = useState<Set<string>>(new Set(program.userIds));
|
||||
const [users, setUsers] = useState<ManagedUser[] | null>(null);
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
|
||||
// Reset the form when the tab switches to another station.
|
||||
useEffect(() => {
|
||||
setName(program.name);
|
||||
setMode(program.mode);
|
||||
setMinutes(program.minutes == null ? "" : String(program.minutes));
|
||||
setPercent(program.percent == null ? "" : String(program.percent));
|
||||
setMaxAmount(fromMinor(program.maxAmountMinor));
|
||||
setMaxPerDay(program.maxPerDay == null ? "" : String(program.maxPerDay));
|
||||
setUserIds(new Set(program.userIds));
|
||||
setMsg(null);
|
||||
}, [program.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers()
|
||||
.then((r) => setUsers(r.users))
|
||||
.catch(() => setUsers([]));
|
||||
}, []);
|
||||
|
||||
const valid = useMemo(() => {
|
||||
if (!name.trim()) return false;
|
||||
if (mode === "timeCredit") return toInt(minutes) != null;
|
||||
if (mode === "percent") {
|
||||
const p = toInt(percent);
|
||||
return p != null && p <= 100;
|
||||
}
|
||||
if (mode === "fixed") return toMinor(maxAmount) != null;
|
||||
return true;
|
||||
}, [name, mode, minutes, percent, maxAmount]);
|
||||
|
||||
async function save() {
|
||||
setMsg(null);
|
||||
try {
|
||||
const saved = await saveValidationProgram(program.id, {
|
||||
name: name.trim(),
|
||||
mode,
|
||||
minutes: mode === "timeCredit" ? toInt(minutes) : null,
|
||||
percent: mode === "percent" ? toInt(percent) : null,
|
||||
maxAmountMinor: mode === "fixed" ? toMinor(maxAmount) : null,
|
||||
maxPerDay: toInt(maxPerDay),
|
||||
active: program.active,
|
||||
userIds: [...userIds],
|
||||
});
|
||||
onSaved(saved);
|
||||
setMsg(t("val.saved"));
|
||||
} catch (e) {
|
||||
setMsg((e as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
const toggleUser = (id: string) =>
|
||||
setUserIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.has(id) ? next.delete(id) : next.add(id);
|
||||
return next;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="mt-3 grid gap-3">
|
||||
<div className="field">
|
||||
<span className="label">{t("val.labelName")}</span>
|
||||
<input className="input" value={name} onChange={(e) => setName(e.target.value)} placeholder={t("val.labelNamePh")} />
|
||||
</div>
|
||||
<div className="field">
|
||||
<span className="label">{t("val.mode")}</span>
|
||||
<select className="input w-fit" value={mode} onChange={(e) => setMode(e.target.value as ValidationMode)}>
|
||||
<option value="comp">{t("val.modeComp")}</option>
|
||||
<option value="timeCredit">{t("val.modeTimeCredit")}</option>
|
||||
<option value="fixed">{t("val.modeFixed")}</option>
|
||||
<option value="percent">{t("val.modePercent")}</option>
|
||||
</select>
|
||||
</div>
|
||||
{mode === "timeCredit" && (
|
||||
<div className="field">
|
||||
<span className="label">{t("val.minutes")}</span>
|
||||
<input className="input w-32" value={minutes} onChange={(e) => setMinutes(e.target.value)} placeholder="60" />
|
||||
</div>
|
||||
)}
|
||||
{mode === "percent" && (
|
||||
<div className="field">
|
||||
<span className="label">{t("val.percent")}</span>
|
||||
<input className="input w-32" value={percent} onChange={(e) => setPercent(e.target.value)} placeholder="100" />
|
||||
</div>
|
||||
)}
|
||||
{mode === "fixed" && (
|
||||
<div className="field">
|
||||
<span className="label">{t("val.maxAmount")}</span>
|
||||
<input className="input w-32" value={maxAmount} onChange={(e) => setMaxAmount(e.target.value)} placeholder="1000" />
|
||||
</div>
|
||||
)}
|
||||
<div className="field">
|
||||
<span className="label">{t("val.maxPerDay")}</span>
|
||||
<input className="input w-32" value={maxPerDay} onChange={(e) => setMaxPerDay(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="label">{t("val.users")}</div>
|
||||
<span className="hint block">{t("val.usersHint")}</span>
|
||||
<div className="mt-1 grid gap-1">
|
||||
{users == null ? (
|
||||
<span className="text-term-muted">…</span>
|
||||
) : users.length === 0 ? (
|
||||
<span className="text-[0.75rem] text-term-muted">{t("val.noUsers")}</span>
|
||||
) : (
|
||||
users.map((u) => (
|
||||
<label key={u.id} className="flex items-center gap-2 text-[0.75rem] text-term-text">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="accent-term-amber"
|
||||
checked={userIds.has(u.id)}
|
||||
onChange={() => toggleUser(u.id)}
|
||||
/>
|
||||
{u.username}
|
||||
{u.fullName && <span className="text-term-muted">({u.fullName})</span>}
|
||||
</label>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button type="button" className="btn btn-primary btn-sm" disabled={!valid} onClick={save}>
|
||||
{t("site.save")}
|
||||
</button>
|
||||
{msg && <span className="text-[0.75rem] text-term-muted">{msg}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The right-column panel: tabs across the ENABLED stations, one form each. */
|
||||
export function ValidationStationsPanel({
|
||||
programs,
|
||||
onSaved,
|
||||
}: {
|
||||
programs: ValidationProgramView[];
|
||||
onSaved: (p: ValidationProgramView) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const enabled = STATIONS.map((id) => programs.find((p) => p.id === id)).filter(
|
||||
(p): p is ValidationProgramView => p != null && p.active,
|
||||
);
|
||||
const [tab, setTab] = useState<string | null>(null);
|
||||
const current = enabled.find((p) => p.id === tab) ?? enabled[0];
|
||||
if (!current) return null;
|
||||
|
||||
return (
|
||||
<section className="card w-full max-w-md p-4">
|
||||
<div className="text-[0.6875rem] uppercase tracking-wider text-term-muted">{t("val.sectionTitle")}</div>
|
||||
{enabled.length > 1 && (
|
||||
<div className="mt-2 flex gap-1">
|
||||
{enabled.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
className={`btn btn-sm ${p.id === current.id ? "btn-primary" : "btn-ghost"}`}
|
||||
onClick={() => setTab(p.id)}
|
||||
>
|
||||
{t(p.id === "bar" ? "val.enableBar" : "val.enableLavazh")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<StationForm program={current} onSaved={onSaved} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
+122
-3
@@ -6,8 +6,8 @@
|
||||
// wiki/entities/local-jwt-auth.md.
|
||||
|
||||
import { logFailedRequest } from "./lib/logger.js";
|
||||
import { apiUrl } from "./lib/origin.js";
|
||||
import type { AppLogRecord } from "@parking/shared";
|
||||
import { apiUrl, platformFetch } from "./lib/origin.js";
|
||||
import type { AppLogRecord, ValidationLine, ValidationMode } from "@parking/shared";
|
||||
|
||||
const CSRF_COOKIE = "parking_csrf";
|
||||
const CSRF_HEADER = "X-CSRF-Token";
|
||||
@@ -28,7 +28,7 @@ export async function apiFetch<T>(path: string, init: RequestInit = {}): Promise
|
||||
const csrf = readCookie(CSRF_COOKIE);
|
||||
if (csrf) headers.set(CSRF_HEADER, csrf);
|
||||
}
|
||||
const res = await fetch(apiUrl(path), { ...init, headers, credentials: "include" });
|
||||
const res = await platformFetch(apiUrl(path), { ...init, headers, credentials: "include" });
|
||||
if (!res.ok) {
|
||||
const msg = (await res.json().catch(() => ({}))) as { error?: string; problems?: string[]; [k: string]: unknown };
|
||||
const error = msg.error ?? `${path}: ${res.status}`;
|
||||
@@ -253,6 +253,15 @@ export async function fetchBackupStatus(): Promise<BackupStatus> {
|
||||
return apiFetch("/api/backup/status");
|
||||
}
|
||||
|
||||
export interface VersionInfo {
|
||||
/** "<branch>-<short-sha>" baked in at image build time; null on a local/dev build. */
|
||||
buildVersion: string | null;
|
||||
}
|
||||
|
||||
export async function fetchVersion(): Promise<VersionInfo> {
|
||||
return apiFetch("/api/version");
|
||||
}
|
||||
|
||||
export interface BackupConfigPatch {
|
||||
/** "" clears the target. Omit a field to leave it unchanged; null resets retention to default. */
|
||||
targetDir?: string | null;
|
||||
@@ -483,7 +492,11 @@ export interface ReportSeriesPoint {
|
||||
entries: number;
|
||||
exits: number;
|
||||
revenueMinor: number;
|
||||
cashMinor: number;
|
||||
cardMinor: number;
|
||||
payments: number;
|
||||
/** Cars inside at the END of the bucket. */
|
||||
occupancyEnd: number;
|
||||
}
|
||||
|
||||
export interface ReportTotals {
|
||||
@@ -500,6 +513,8 @@ export interface ReportTotals {
|
||||
totalParkedMinutes: number;
|
||||
avgParkedMinutes: number;
|
||||
medianParkedMinutes: number;
|
||||
voids: number;
|
||||
anomalies: number;
|
||||
}
|
||||
|
||||
export interface ReportSubscriptionStats {
|
||||
@@ -519,6 +534,12 @@ export interface ReportSummary {
|
||||
totals: ReportTotals;
|
||||
series: ReportSeriesPoint[];
|
||||
entriesByHour: number[];
|
||||
/** 7×24, row 0 = Monday — entries heatmap (weekday-vs-weekend patterns). */
|
||||
entriesByDowHour: number[][];
|
||||
/** Stay-duration histogram; last bucket has uptoMin null (>24h tail). */
|
||||
stayHistogram: { uptoMin: number | null; count: number }[];
|
||||
occupancyStart: number;
|
||||
capacity: number | null;
|
||||
subscriptions: ReportSubscriptionStats;
|
||||
}
|
||||
|
||||
@@ -592,6 +613,11 @@ export interface AssignBody {
|
||||
backendIp?: string;
|
||||
}
|
||||
|
||||
/** USB printers currently visible on the appliance (/dev/usb/lpN + sysfs model). */
|
||||
export function fetchUsbPrinters(): Promise<{ printers: { path: string; description: string | null }[] }> {
|
||||
return apiFetch("/api/setup/usb-printers");
|
||||
}
|
||||
|
||||
/** Save + configure the device (preconditions, push setup), then persist. */
|
||||
export function assignDevice(body: AssignBody): Promise<AssignResult> {
|
||||
return apiFetch("/api/setup/assign", { method: "POST", body: JSON.stringify(body) });
|
||||
@@ -750,6 +776,9 @@ export interface SimSessionPricing {
|
||||
export interface SimulateResult {
|
||||
currency: string | null;
|
||||
pricing: SimSessionPricing;
|
||||
/** Line items explaining pricing.amountMinor (same engine walk, Σ ≡ amount);
|
||||
* null when the session is settled (within walk-back grace). */
|
||||
breakdown: import("@parking/shared").FeeBreakdown | null;
|
||||
curve: { minutes: number; amountMinor: number }[];
|
||||
gracePeriodExitMin: number;
|
||||
}
|
||||
@@ -1281,6 +1310,11 @@ export interface SessionLookup {
|
||||
subscriptionHolder: string | null;
|
||||
/** Advisory licence plate recognized for this session (ANPR). Null when none. */
|
||||
plate: string | null;
|
||||
/** Merchant validations folded into `amountMinor` (which is NET): pre-discount fee,
|
||||
* total taken off, and the per-validation lines. See validation-discounts.md. */
|
||||
grossMinor: number | null;
|
||||
discountMinor: number | null;
|
||||
validationLines: ValidationLine[];
|
||||
}
|
||||
|
||||
/** Look up a ticket/session for the booth modal (entry/exit, paid, amount owed). */
|
||||
@@ -1455,3 +1489,88 @@ export function updatePresenceBypass(patch: { radar?: boolean; camera?: boolean
|
||||
export function setCapacity(capacity: number | null): Promise<SiteConfig> {
|
||||
return saveSiteConfig({ capacity });
|
||||
}
|
||||
|
||||
// --- Merchant validations (bar / lavazh) -----------------------------------
|
||||
// The merchant is VALIDATION-ONLY: they scan the ticket on their device and apply
|
||||
// their program; the booth settles NET of the applied validations and prints the
|
||||
// detailed receipt. Program config lives on /setup/site. See validation-discounts.md.
|
||||
|
||||
export type { ValidationLine, ValidationMode } from "@parking/shared";
|
||||
|
||||
/** An admin-composed program (mirrors the server row + its bound users). */
|
||||
export interface ValidationProgramView {
|
||||
id: string;
|
||||
name: string;
|
||||
mode: ValidationMode;
|
||||
minutes: number | null;
|
||||
percent: number | null;
|
||||
maxAmountMinor: number | null;
|
||||
maxPerDay: number | null;
|
||||
active: boolean;
|
||||
userIds: string[];
|
||||
}
|
||||
|
||||
/** A validation applied to a session, with its lifecycle state. */
|
||||
export interface AppliedValidationView {
|
||||
eventId: string;
|
||||
occurredAt: string;
|
||||
programId: string;
|
||||
label: string;
|
||||
mode: ValidationMode;
|
||||
minutes?: number;
|
||||
amountMinor?: number;
|
||||
percent?: number;
|
||||
operator: string | null;
|
||||
voided: boolean;
|
||||
consumedBy: string | null;
|
||||
}
|
||||
|
||||
/** The merchant screen's minimal session view — deliberately no money data. */
|
||||
export interface ValidationSessionView {
|
||||
identity: string;
|
||||
found: boolean;
|
||||
open: boolean;
|
||||
enteredAt: string | null;
|
||||
subscription: boolean;
|
||||
validations: AppliedValidationView[];
|
||||
}
|
||||
|
||||
/** All programs + bound users (the /setup/site panel). site:read. */
|
||||
export function fetchValidationPrograms(): Promise<{ programs: ValidationProgramView[] }> {
|
||||
return apiFetch("/api/validation/programs");
|
||||
}
|
||||
|
||||
/** Upsert a program's config + binding set (site:update; signs a config_change). */
|
||||
export function saveValidationProgram(
|
||||
id: string,
|
||||
body: Omit<ValidationProgramView, "id">,
|
||||
): Promise<ValidationProgramView> {
|
||||
return apiFetch(`/api/validation/programs/${encodeURIComponent(id)}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
/** MY bound, active programs (the merchant screen). validation:create. */
|
||||
export function fetchMyValidationPrograms(): Promise<{ programs: Omit<ValidationProgramView, "userIds">[] }> {
|
||||
return apiFetch("/api/validation/mine");
|
||||
}
|
||||
|
||||
/** Merchant lookup of a scanned ticket (no money data). validation:create. */
|
||||
export function fetchValidationSession(identity: string): Promise<ValidationSessionView> {
|
||||
return apiFetch(`/api/validation/session/${encodeURIComponent(identity)}`);
|
||||
}
|
||||
|
||||
/** Apply my program to a ticket (signed, attributed). `amountMinor` only for fixed mode. */
|
||||
export function applyValidation(body: {
|
||||
identity: string;
|
||||
programId: string;
|
||||
amountMinor?: number;
|
||||
}): Promise<{ ok: true; eventId: string; label: string }> {
|
||||
return apiFetch("/api/validation/apply", { method: "POST", body: JSON.stringify(body) });
|
||||
}
|
||||
|
||||
/** Void my own UNUSED validation (append-only correction). */
|
||||
export function voidValidation(body: { eventId: string; identity: string }): Promise<{ ok: true }> {
|
||||
return apiFetch("/api/validation/void", { method: "POST", body: JSON.stringify(body) });
|
||||
}
|
||||
|
||||
@@ -41,11 +41,22 @@ export async function checkForDesktopUpdate(
|
||||
|
||||
// Download + install the signed update (signature verified against the
|
||||
// pubkey in tauri.conf.json), then relaunch into the new version.
|
||||
try {
|
||||
await update.downloadAndInstall();
|
||||
} catch (err) {
|
||||
// A real update WAS found and accepted — this is a genuine install
|
||||
// failure (bad signature, corrupted download, disk/permission issue),
|
||||
// not "offline". Surface it instead of silently reverting to the old
|
||||
// version with no explanation.
|
||||
console.error("desktop update download/install failed:", err);
|
||||
throw err;
|
||||
}
|
||||
const { relaunch } = await import("@tauri-apps/plugin-process");
|
||||
await relaunch();
|
||||
} catch {
|
||||
} catch (err) {
|
||||
// Offline / endpoint unreachable / no update server yet → ignore. The app
|
||||
// keeps running on the current version; checking again next launch.
|
||||
// keeps running on the current version; checking again next launch. Still
|
||||
// log it so a real install failure (rethrown above) isn't invisible.
|
||||
console.warn("desktop update check/apply skipped:", err);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatMoney, formatDuration, formatTime, formatRelativeDateTime, type TFn } from "./format.js";
|
||||
import { formatMoney, formatDuration, formatRelativeDateTime, type TFn } from "./format.js";
|
||||
|
||||
// The booth's display formatters. Money is integer MINOR units (never a float, matching
|
||||
// the ledger/tariff model); duration is whole minutes; relative dates drive the session/
|
||||
@@ -35,16 +35,6 @@ describe("formatDuration", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatTime", () => {
|
||||
it("returns an em dash for null/invalid", () => {
|
||||
expect(formatTime(null)).toBe("—");
|
||||
expect(formatTime("not-a-date")).toBe("—");
|
||||
});
|
||||
it("renders HH:MM:SS local time", () => {
|
||||
expect(formatTime("2026-06-21T10:48:25.000Z")).toMatch(/^\d{2}:\d{2}:\d{2}$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatRelativeDateTime", () => {
|
||||
// A tiny fake t(): today/yesterday words + the month-name array.
|
||||
const months = ["Jan","Shkurt","Mars","Prill","Maj","Qershor","Korrik","Gusht","Sht","Tet","Nën","Dhj"];
|
||||
@@ -61,6 +51,12 @@ describe("formatRelativeDateTime", () => {
|
||||
expect(formatRelativeDateTime(now.toISOString(), t)).toMatch(/^Sot \d{2}:\d{2}$/);
|
||||
});
|
||||
|
||||
it("appends :ss with the seconds option (entry/exit rows read alike)", () => {
|
||||
const now = new Date();
|
||||
now.setHours(19, 25, 44, 0);
|
||||
expect(formatRelativeDateTime(now.toISOString(), t, { seconds: true })).toMatch(/^Sot \d{2}:\d{2}:44$/);
|
||||
});
|
||||
|
||||
it("labels yesterday with the localized word", () => {
|
||||
const y = new Date();
|
||||
y.setDate(y.getDate() - 1);
|
||||
|
||||
+49
-18
@@ -46,13 +46,6 @@ export function formatMinutes(mins: number): string {
|
||||
return h > 0 ? `${h}h ${m % 60}m` : `${m}m`;
|
||||
}
|
||||
|
||||
/** Local time-of-day HH:MM:SS from an ISO string. */
|
||||
export function formatTime(iso: string | null): string {
|
||||
if (!iso) return "—";
|
||||
const d = new Date(iso);
|
||||
return Number.isNaN(d.getTime()) ? "—" : d.toTimeString().slice(0, 8);
|
||||
}
|
||||
|
||||
/** Calendar-day difference (local) between two dates: 0 = same day, 1 = d is one day
|
||||
* before ref, etc. Compares date parts only (ignores time-of-day). */
|
||||
function dayDiff(d: Date, ref: Date): number {
|
||||
@@ -61,10 +54,11 @@ function dayDiff(d: Date, ref: Date): number {
|
||||
return Math.round((b.getTime() - a.getTime()) / 86_400_000);
|
||||
}
|
||||
|
||||
/** HH:MM (local, 24h) for the relative-day labels. */
|
||||
function hhmm(d: Date): string {
|
||||
/** HH:MM (local, 24h) for the relative-day labels; ":ss" appended when `seconds`. */
|
||||
function hhmm(d: Date, seconds = false): string {
|
||||
const p = (n: number) => String(n).padStart(2, "0");
|
||||
return `${p(d.getHours())}:${p(d.getMinutes())}`;
|
||||
const base = `${p(d.getHours())}:${p(d.getMinutes())}`;
|
||||
return seconds ? `${base}:${p(d.getSeconds())}` : base;
|
||||
}
|
||||
|
||||
/** Minimal shape of i18next's `t` that we rely on: a string lookup, plus the
|
||||
@@ -85,6 +79,42 @@ function monthName(d: Date, t: TFn): string {
|
||||
return String(d.getMonth() + 1);
|
||||
}
|
||||
|
||||
/** Short month ("Qer", "Korr") from the catalog — the UI-wide date standard
|
||||
* (2026-07-06): every visible date reads "25 Qer" / "7 Korr 2025", never the
|
||||
* browser-locale "7/6/2026". Falls back to the full name, then the number. */
|
||||
function monthShort(d: Date, t: TFn): string {
|
||||
const months = t("common.monthsShort", { returnObjects: true });
|
||||
if (Array.isArray(months) && typeof months[d.getMonth()] === "string") {
|
||||
return months[d.getMonth()] as string;
|
||||
}
|
||||
return monthName(d, t);
|
||||
}
|
||||
|
||||
/** "HH:mm" (local, 24h) — the unified time-of-day everywhere ("—" for bad input). */
|
||||
export function formatClock(iso: string | null): string {
|
||||
if (!iso) return "—";
|
||||
const d = new Date(iso);
|
||||
return Number.isNaN(d.getTime()) ? "—" : hhmm(d);
|
||||
}
|
||||
|
||||
/** "25 Qer" (current year) / "25 Qer 2025" (other years) — the unified DATE. */
|
||||
export function formatDate(iso: string | null, t: TFn): string {
|
||||
if (!iso) return "—";
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return "—";
|
||||
const base = `${d.getDate()} ${monthShort(d, t)}`;
|
||||
return d.getFullYear() === new Date().getFullYear() ? base : `${base} ${d.getFullYear()}`;
|
||||
}
|
||||
|
||||
/** "25 Qer 14:30" (+ ":ss" when `seconds`) — the unified absolute DATE+TIME. Use
|
||||
* formatRelativeDateTime instead where "Sot/Dje" reads better (feeds, history). */
|
||||
export function formatDateTime(iso: string | null, t: TFn, opts?: { seconds?: boolean }): string {
|
||||
if (!iso) return "—";
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return "—";
|
||||
return `${formatDate(iso, t)} ${hhmm(d, opts?.seconds)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Human, day-relative date+time for sessions/logs/history. An event from earlier
|
||||
* today reads "Sot 10:48", yesterday "Dje 17:33", and anything older a localized
|
||||
@@ -93,17 +123,18 @@ function monthName(d: Date, t: TFn): string {
|
||||
*
|
||||
* `t` supplies the today/yesterday words AND the month names (the appliance browser
|
||||
* may lack Albanian Intl data, so month names come from the catalog, not Intl).
|
||||
*
|
||||
* `seconds` appends ":ss" — use it where a timestamp sits next to another that shows
|
||||
* seconds (e.g. the booth pay modal's entry vs. exit rows), so the two read alike.
|
||||
*/
|
||||
export function formatRelativeDateTime(iso: string | null, t: TFn): string {
|
||||
export function formatRelativeDateTime(iso: string | null, t: TFn, opts?: { seconds?: boolean }): string {
|
||||
if (!iso) return "—";
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return "—";
|
||||
const time = hhmm(d, opts?.seconds);
|
||||
const diff = dayDiff(d, new Date());
|
||||
if (diff === 0) return `${t("common.today")} ${hhmm(d)}`;
|
||||
if (diff === 1) return `${t("common.yesterday")} ${hhmm(d)}`;
|
||||
// Older (or future): "17 Qershor 10:48", with the year only if it differs.
|
||||
const sameYear = d.getFullYear() === new Date().getFullYear();
|
||||
const month = monthName(d, t);
|
||||
const date = sameYear ? `${d.getDate()} ${month}` : `${d.getDate()} ${month} ${d.getFullYear()}`;
|
||||
return `${date} ${hhmm(d)}`;
|
||||
if (diff === 0) return `${t("common.today")} ${time}`;
|
||||
if (diff === 1) return `${t("common.yesterday")} ${time}`;
|
||||
// Older (or future): "17 Qer 10:48" — the short-month standard, year only if it differs.
|
||||
return `${formatDate(iso, t)} ${time}`;
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ export const en: Catalog = {
|
||||
"November",
|
||||
"December",
|
||||
],
|
||||
monthsShort: ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"],
|
||||
},
|
||||
auth: {
|
||||
title: "Parking System",
|
||||
@@ -64,6 +65,7 @@ export const en: Catalog = {
|
||||
logs: "Logs",
|
||||
backup: "Backup",
|
||||
profile: "Profile",
|
||||
validate: "Validations",
|
||||
},
|
||||
drawer: {
|
||||
stateTitle: "Drawer now",
|
||||
@@ -229,6 +231,7 @@ export const en: Catalog = {
|
||||
evtCashOut: "PAY-OUT",
|
||||
evtCashReview: "REVIEW",
|
||||
evtConfigChange: "CONFIG",
|
||||
evtValidation: "VALIDATION",
|
||||
decision: { authorize: "authorized", deny: "denied" },
|
||||
evtAnomaly: "ANOMALY",
|
||||
evtRefused: "REFUSED",
|
||||
@@ -330,12 +333,21 @@ export const en: Catalog = {
|
||||
hoursUnit: "hours",
|
||||
egHours: "e.g. 2",
|
||||
pricePerIncrement: "Price / increment (per hour)",
|
||||
pricePerHour: "Price / hour",
|
||||
pricePerN: "Price / {{min}} min",
|
||||
modeFlatN: "Flat price / {{min}} min",
|
||||
perHourEquiv: "= {{amount}} / hour",
|
||||
incrementWarning:
|
||||
"Careful: the billing increment is {{min}} min — every price below is charged per started {{min}} minutes, NOT per hour.",
|
||||
thereafter: "thereafter (open-ended)",
|
||||
remove: "Remove",
|
||||
addBlock: "+ Add block",
|
||||
publishNewVersion: "Publish new version",
|
||||
publishing: "Publishing…",
|
||||
versionNamePh: "Version name (optional), e.g. Summer 2026",
|
||||
versionsTitle: "Published versions",
|
||||
versionsHint: "Click one to load it into the editor. Publishing always creates a new version — past versions never change.",
|
||||
activeBadge: "active",
|
||||
publishedOk: "New tariff version published — it's now the active rate.",
|
||||
defaultCard: "Base rate (always active)",
|
||||
defaultCardHint: "The base rate applied when no time/seasonal tier matches. This alone is enough for most car parks.",
|
||||
@@ -409,6 +421,8 @@ export const en: Catalog = {
|
||||
scan: "Scan for controllers",
|
||||
scanning: "Scanning…",
|
||||
noControllersFound: "No controllers found on the LAN.",
|
||||
usbNoneFound: "No USB printer found (/dev/usb/lpN) — check cable/power; the path can be typed manually.",
|
||||
usbSavedMissing: "saved — not present now",
|
||||
use: "Use",
|
||||
test: "Test connection",
|
||||
testing: "Testing…",
|
||||
@@ -559,6 +573,16 @@ export const en: Catalog = {
|
||||
graceExpires: "Grace expires",
|
||||
curve: "Duration curve",
|
||||
curveHint: "Fee from entry at several durations — see where the daily cap flattens or windows shift.",
|
||||
bd: {
|
||||
title: "How the amount is produced",
|
||||
rounding: "{{raw}} min parked → {{billed}} min billed ({{inc}}-min increments)",
|
||||
grace: "Free — within the entry grace ({{min}} min)",
|
||||
package: "window package",
|
||||
step: "Day {{day}}: stay up to {{hours}}h — total",
|
||||
stepRepeated: "Day {{day}}: beyond the top tier — full-day total",
|
||||
cap: "Daily cap {{cap}} applied (day {{day}})",
|
||||
total: "Total",
|
||||
},
|
||||
},
|
||||
subs: {
|
||||
title: "Subscriptions",
|
||||
@@ -713,6 +737,51 @@ export const en: Catalog = {
|
||||
fieldPhone: "Phone",
|
||||
fieldEmail: "Email",
|
||||
},
|
||||
// Merchant validations (bar / lavazh) — the /setup/site panel, the merchant's
|
||||
// /validate screen, and the booth-modal discount lines. See validation-discounts.md.
|
||||
val: {
|
||||
// /setup/site
|
||||
sectionTitle: "Merchant validations",
|
||||
sectionHint: "An in-park merchant (bar / car-wash) scans the customer's ticket and grants a parking discount — payment and the receipt always stay at the booth.",
|
||||
enableBar: "Bar",
|
||||
enableLavazh: "Car wash",
|
||||
labelName: "Receipt label",
|
||||
labelNamePh: "e.g. Car wash — first hour free",
|
||||
mode: "Discount type",
|
||||
modeComp: "Parking fully free",
|
||||
modeTimeCredit: "First minutes free",
|
||||
modeFixed: "Amount off (typed at scan)",
|
||||
modePercent: "Percent off",
|
||||
minutes: "Free minutes",
|
||||
percent: "Percent (%)",
|
||||
maxAmount: "Cap per validation",
|
||||
maxPerDay: "Max validations per day (blank = unlimited)",
|
||||
users: "Validating users",
|
||||
usersHint: "Only the selected users (whose role grants validation:create) can apply this program from their device.",
|
||||
noUsers: "No users in the system — create one under Users.",
|
||||
saved: "Saved.",
|
||||
// /validate (the merchant screen)
|
||||
title: "Ticket validation",
|
||||
scanPrompt: "Scan or type the ticket number",
|
||||
lookup: "Look up",
|
||||
entry: "Entry:",
|
||||
notFound: "No ticket found with this number.",
|
||||
closed: "The ticket is closed (exited or voided).",
|
||||
subscription: "This is a subscriber entry — not validatable.",
|
||||
amountLabel: "Discount amount",
|
||||
amountHint: "max {{max}}",
|
||||
apply: "Apply validation",
|
||||
applied: "Validation applied.",
|
||||
existing: "Validations on this ticket",
|
||||
voided: "voided",
|
||||
used: "used in a payment",
|
||||
void: "Void",
|
||||
confirmVoid: "Void this validation?",
|
||||
noPrograms: "You have no validation program bound to you — contact the administrator.",
|
||||
// booth pay modal / receipts
|
||||
gross: "Fee",
|
||||
discount: "Discount",
|
||||
},
|
||||
users: {
|
||||
title: "Users",
|
||||
add: "+ Add user",
|
||||
@@ -863,14 +932,23 @@ export const en: Catalog = {
|
||||
payments: "Payments",
|
||||
avgStay: "Avg stay",
|
||||
subscribers: "Subscribers",
|
||||
peakOcc: "Peak occupancy",
|
||||
voids: "Voided tickets",
|
||||
anomalies: "Anomalies",
|
||||
},
|
||||
chart: {
|
||||
flow: "Entries & exits over time",
|
||||
occupancy: "Occupancy — cars inside",
|
||||
occupancySeries: "Cars inside",
|
||||
revenue: "Revenue ({{currency}})",
|
||||
mix: "Revenue mix",
|
||||
peakHours: "Entries by hour of day",
|
||||
stay: "Stay duration (closed sessions)",
|
||||
heatmap: "Entries heatmap — hour × day",
|
||||
breakdown: "Breakdown",
|
||||
},
|
||||
capacityLine: "capacity",
|
||||
stay: { m: "m", h: "h" },
|
||||
dowShort: ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"],
|
||||
mix: { ticket: "Transient", subSales: "Subscriptions", subWindow: "Out-of-window" },
|
||||
row: {
|
||||
cash: "Cash",
|
||||
@@ -916,6 +994,7 @@ export const en: Catalog = {
|
||||
status: "Status",
|
||||
path: "Path",
|
||||
empty: "No logs.",
|
||||
repeated: "Repeated {{count}} times (first at {{firstAt}})",
|
||||
},
|
||||
backup: {
|
||||
title: "Backup",
|
||||
|
||||
@@ -35,6 +35,8 @@ export const sq = {
|
||||
"Nëntor",
|
||||
"Dhjetor",
|
||||
],
|
||||
// Short month names — the UI-wide date standard ("25 Qer", "7 Korr").
|
||||
monthsShort: ["Jan", "Shk", "Mar", "Pri", "Maj", "Qer", "Korr", "Gush", "Sht", "Tet", "Nën", "Dhj"],
|
||||
},
|
||||
auth: {
|
||||
title: "Sistemi i Parkimit",
|
||||
@@ -66,6 +68,7 @@ export const sq = {
|
||||
logs: "Loget",
|
||||
backup: "Kopje rezervë",
|
||||
profile: "Profili",
|
||||
validate: "Validime",
|
||||
},
|
||||
drawer: {
|
||||
stateTitle: "Arka tani",
|
||||
@@ -233,6 +236,7 @@ export const sq = {
|
||||
evtCashOut: "PAGESË",
|
||||
evtCashReview: "SHQYRTIM",
|
||||
evtConfigChange: "KONFIG",
|
||||
evtValidation: "VALIDIM",
|
||||
decision: { authorize: "autorizuar", deny: "refuzuar" },
|
||||
evtAnomaly: "ANOMALI",
|
||||
evtRefused: "REFUZUAR",
|
||||
@@ -332,13 +336,22 @@ export const sq = {
|
||||
bandDuration: "Kohëzgjatja e brezit",
|
||||
hoursUnit: "orë",
|
||||
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)",
|
||||
remove: "Hiq",
|
||||
addBlock: "+ Shto bllok",
|
||||
publishNewVersion: "Publiko version të ri",
|
||||
publishing: "Duke publikuar…",
|
||||
versionNamePh: "Emri i versionit (opsional), p.sh. Vera 2026",
|
||||
versionsTitle: "Versione të publikuara",
|
||||
versionsHint: "Kliko një për ta ngarkuar në editor. Publikimi krijon gjithmonë version të ri — versionet e kaluara nuk ndryshojnë kurrë.",
|
||||
activeBadge: "aktive",
|
||||
publishedOk: "U publikua versioni i ri i tarifës — tani është tarifa aktive.",
|
||||
defaultCard: "Tarifa bazë (gjithmonë aktive)",
|
||||
defaultCardHint: "Çmimi bazë i zbatuar kur asnjë nivel kohor/sezonal nuk vlen. Kjo e vetme është mjaftueshëm për shumicën e parkimeve.",
|
||||
@@ -417,6 +430,8 @@ export const sq = {
|
||||
scan: "Skano për kontroller",
|
||||
scanning: "Duke skanuar…",
|
||||
noControllersFound: "Asnjë kontroller në LAN.",
|
||||
usbNoneFound: "Nuk u gjet asnjë printer USB (/dev/usb/lpN) — kontrollo kabllon/ushqimin; rruga mund të shkruhet me dorë.",
|
||||
usbSavedMissing: "i ruajtur — jo i pranishëm tani",
|
||||
use: "Përdor",
|
||||
test: "Testo lidhjen",
|
||||
testing: "Duke testuar…",
|
||||
@@ -571,6 +586,16 @@ export const sq = {
|
||||
graceExpires: "Afati skadon",
|
||||
curve: "Kurba sipas kohëzgjatjes",
|
||||
curveHint: "Tarifa nga hyrja për disa kohëzgjatje — shih ku rrafshohet kufiri ditor ose ndryshojnë dritaret.",
|
||||
bd: {
|
||||
title: "Si prodhohet shuma",
|
||||
rounding: "{{raw}} min qëndrim → {{billed}} min të faturuara (njësi {{inc}} min)",
|
||||
grace: "Falas — brenda minutave të hirit ({{min}} min)",
|
||||
package: "paketë dritareje",
|
||||
step: "Dita {{day}}: qëndrim deri në {{hours}}h — total",
|
||||
stepRepeated: "Dita {{day}}: mbi shkallën më të lartë — totali ditor",
|
||||
cap: "U zbatua kufiri ditor {{cap}} (dita {{day}})",
|
||||
total: "Totali",
|
||||
},
|
||||
},
|
||||
subs: {
|
||||
title: "Abonimet",
|
||||
@@ -725,6 +750,51 @@ export const sq = {
|
||||
fieldPhone: "Telefoni",
|
||||
fieldEmail: "Email",
|
||||
},
|
||||
// Merchant validations (bar / lavazh) — the /setup/site panel, the merchant's
|
||||
// /validate screen, and the booth-modal discount lines. See validation-discounts.md.
|
||||
val: {
|
||||
// /setup/site
|
||||
sectionTitle: "Validime tregtare",
|
||||
sectionHint: "Shërbime të tjera brenda parkut (bar / lavazh) skanojnë biletën e hyrjes dhe bëjnë zbritje — pagesa dhe fatura bëhen në kabinë.",
|
||||
enableBar: "Bar",
|
||||
enableLavazh: "Lavazh",
|
||||
labelName: "Etiketa në faturë",
|
||||
labelNamePh: "p.sh. Lavazh — 1 orë falas",
|
||||
mode: "Lloji i zbritjes",
|
||||
modeComp: "Parkimi falas plotësisht",
|
||||
modeTimeCredit: "Minutat e para falas",
|
||||
modeFixed: "Zbritje shume (shkruhet në skanim)",
|
||||
modePercent: "Zbritje në përqindje",
|
||||
minutes: "Minuta falas",
|
||||
percent: "Përqindja (%)",
|
||||
maxAmount: "Tavani i zbritjes për validim",
|
||||
maxPerDay: "Maks. validime në ditë (bosh = pa kufi)",
|
||||
users: "Përdoruesit që validojnë",
|
||||
usersHint: "Vetëm përdoruesit e zgjedhur (me lejen validation:create në rolin e tyre) mund të aplikojnë këtë program nga pajisja e tyre.",
|
||||
noUsers: "Asnjë përdorues në sistem — krijojeni te Përdoruesit.",
|
||||
saved: "U ruajt.",
|
||||
// /validate (the merchant screen)
|
||||
title: "Validim biletash",
|
||||
scanPrompt: "Skanoni ose shkruani numrin e biletës",
|
||||
lookup: "Kërko",
|
||||
entry: "Hyrja:",
|
||||
notFound: "Nuk u gjet biletë me këtë numër.",
|
||||
closed: "Bileta është e mbyllur (ka dalë ose është anuluar).",
|
||||
subscription: "Kjo është hyrje abonenti — nuk validohet.",
|
||||
amountLabel: "Shuma e zbritjes",
|
||||
amountHint: "maks. {{max}}",
|
||||
apply: "Apliko validimin",
|
||||
applied: "Validimi u aplikua.",
|
||||
existing: "Validime në këtë biletë",
|
||||
voided: "anuluar",
|
||||
used: "përdorur në pagesë",
|
||||
void: "Anulo",
|
||||
confirmVoid: "Të anulohet ky validim?",
|
||||
noPrograms: "Nuk keni asnjë program validimi të lidhur me ju — kontaktoni administratorin.",
|
||||
// booth pay modal / receipts
|
||||
gross: "Tarifa",
|
||||
discount: "Zbritje",
|
||||
},
|
||||
users: {
|
||||
title: "Përdoruesit",
|
||||
add: "+ Shto përdorues",
|
||||
@@ -878,14 +948,23 @@ export const sq = {
|
||||
payments: "Pagesa",
|
||||
avgStay: "Qëndrim mes.",
|
||||
subscribers: "Abonentë",
|
||||
peakOcc: "Zënia maksimale",
|
||||
voids: "Bileta të anuluara",
|
||||
anomalies: "Anomali",
|
||||
},
|
||||
chart: {
|
||||
flow: "Hyrjet & daljet me kalimin e kohës",
|
||||
occupancy: "Zënia — makina brenda",
|
||||
occupancySeries: "Makina brenda",
|
||||
revenue: "Të ardhurat ({{currency}})",
|
||||
mix: "Përbërja e të ardhurave",
|
||||
peakHours: "Hyrjet sipas orës së ditës",
|
||||
stay: "Kohëzgjatja e qëndrimit (sesione të mbyllura)",
|
||||
heatmap: "Harta e hyrjeve — orë × ditë",
|
||||
breakdown: "Ndarja",
|
||||
},
|
||||
capacityLine: "kapaciteti",
|
||||
stay: { m: "m", h: "o" },
|
||||
dowShort: ["Hën", "Mar", "Mër", "Enj", "Pre", "Sht", "Die"],
|
||||
mix: { ticket: "Tranzit", subSales: "Abonime", subWindow: "Jashtë orarit" },
|
||||
row: {
|
||||
cash: "Para në dorë",
|
||||
@@ -931,6 +1010,7 @@ export const sq = {
|
||||
status: "Statusi",
|
||||
path: "Rruga",
|
||||
empty: "Asnjë regjistër.",
|
||||
repeated: "Përsëritur {{count}} herë (hera e parë {{firstAt}})",
|
||||
},
|
||||
backup: {
|
||||
title: "Kopje rezervë",
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
// high-signal sources (failed requests, uncaught errors) are always captured.
|
||||
|
||||
import { LOG_LEVEL_ORDER, type ClientLogInput, type LogLevel } from "@parking/shared";
|
||||
import { apiUrl, platformFetch } from "./origin.js";
|
||||
|
||||
const ENDPOINT = "/api/logs";
|
||||
const FLUSH_MS = 4000;
|
||||
@@ -76,7 +77,7 @@ async function flush(): Promise<void> {
|
||||
const headers: Record<string, string> = { "content-type": "application/json" };
|
||||
const csrf = readCookie(CSRF_COOKIE);
|
||||
if (csrf) headers[CSRF_HEADER] = csrf;
|
||||
await fetch(ENDPOINT, {
|
||||
await platformFetch(apiUrl(ENDPOINT), {
|
||||
method: "POST",
|
||||
headers,
|
||||
credentials: "include",
|
||||
@@ -90,7 +91,10 @@ async function flush(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/** Best-effort synchronous flush on page hide (sendBeacon survives unload). */
|
||||
/** Best-effort synchronous flush on page hide (sendBeacon survives unload). Browser
|
||||
* only — sendBeacon is a native browser API with no Tauri-HTTP-plugin equivalent,
|
||||
* so this drops silently in the desktop shell (unload is rare there; the regular
|
||||
* 4s-interval flush above covers the common case). */
|
||||
function flushBeacon(): void {
|
||||
if (queue.length === 0) return;
|
||||
const entries = queue.splice(0, queue.length);
|
||||
@@ -99,7 +103,7 @@ function flushBeacon(): void {
|
||||
// sendBeacon can't set the CSRF header; the server accepts the ingest for any
|
||||
// signed-in session (cookie sent automatically). If CSRF later guards it strictly,
|
||||
// this path degrades to "lost on unload" — acceptable for diagnostics.
|
||||
navigator.sendBeacon(ENDPOINT, blob);
|
||||
navigator.sendBeacon(apiUrl(ENDPOINT), blob);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
@@ -10,6 +10,15 @@
|
||||
// Keep this the SINGLE source for the backend origin — api.ts and the live-feed
|
||||
// WebSocket both read it, so the web app and the desktop shell stay identical
|
||||
// except for this one build-time value.
|
||||
//
|
||||
// platformFetch(): WebKitGTK treats tauri://localhost as a SECURE origin, so a
|
||||
// plain http://127.0.0.1:3000 fetch() from inside it is blocked as mixed
|
||||
// content (a WebKit limitation — CSP's connect-src does NOT override this;
|
||||
// found 2026-09-03 as "Load failed" on every desktop request). Inside Tauri we
|
||||
// dynamically import @tauri-apps/plugin-http's fetch, which routes the request
|
||||
// through Tauri's native side instead of the webview's own fetch, sidestepping
|
||||
// the check entirely. Browser build never imports the plugin (dynamic import,
|
||||
// same pattern as desktop-updater.ts).
|
||||
|
||||
/** Backend HTTP origin, no trailing slash. Empty string = same-origin/relative. */
|
||||
export const API_BASE: string = (import.meta.env.VITE_API_BASE ?? "").replace(/\/$/, "");
|
||||
@@ -28,3 +37,22 @@ export function wsUrl(path: string): string {
|
||||
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
return `${proto}//${window.location.host}${path}`;
|
||||
}
|
||||
|
||||
/** True when running inside the Tauri webview (not a normal browser). */
|
||||
function inTauri(): boolean {
|
||||
return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
|
||||
}
|
||||
|
||||
/**
|
||||
* fetch(), but routed through @tauri-apps/plugin-http inside the desktop
|
||||
* shell (see the file header for why the webview's own fetch can't reach
|
||||
* the local backend). Same signature as the global fetch; a plain pass-
|
||||
* through in the browser.
|
||||
*/
|
||||
export async function platformFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
|
||||
if (inTauri()) {
|
||||
const { fetch: tauriFetch } = await import("@tauri-apps/plugin-http");
|
||||
return tauriFetch(input, init);
|
||||
}
|
||||
return fetch(input, init);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
// Desktop-only WebSocket adapter.
|
||||
//
|
||||
// WebKitGTK treats tauri://localhost as a SECURE origin, so a plain
|
||||
// ws://127.0.0.1:3000 connection from inside it is blocked as mixed content —
|
||||
// same root cause as the HTTP fetch() issue (see origin.ts's platformFetch),
|
||||
// but WS is a separate browser check with its own plugin
|
||||
// (@tauri-apps/plugin-websocket), which routes the connection through Tauri's
|
||||
// native side instead of the webview's own WebSocket.
|
||||
//
|
||||
// That plugin's API is async/listener-based, not the synchronous
|
||||
// onopen/onmessage/onclose event surface use-live-feed.ts is written against
|
||||
// (and has already been hardened for — reconnect backoff, StrictMode
|
||||
// double-invoke, cleanup). Rather than rewrite that hook around a different
|
||||
// API shape, this adapter presents the same native-WebSocket-like interface
|
||||
// use-live-feed.ts already expects, so that hook needs no changes at all.
|
||||
//
|
||||
// Browser build: plain pass-through to the real WebSocket (this file's
|
||||
// createPlatformSocket is only called from inside inTauri() callers).
|
||||
|
||||
export interface PlatformSocket {
|
||||
onopen: (() => void) | null;
|
||||
onmessage: ((ev: { data: string }) => void) | null;
|
||||
onclose: (() => void) | null;
|
||||
onerror: (() => void) | null;
|
||||
close(): void;
|
||||
}
|
||||
|
||||
class NativeSocketAdapter implements PlatformSocket {
|
||||
onopen: (() => void) | null = null;
|
||||
onmessage: ((ev: { data: string }) => void) | null = null;
|
||||
onclose: (() => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
#sock: WebSocket;
|
||||
|
||||
constructor(url: string) {
|
||||
this.#sock = new WebSocket(url);
|
||||
this.#sock.onopen = () => this.onopen?.();
|
||||
this.#sock.onmessage = (ev) => this.onmessage?.({ data: ev.data as string });
|
||||
this.#sock.onclose = () => this.onclose?.();
|
||||
this.#sock.onerror = () => this.onerror?.();
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.#sock.close();
|
||||
}
|
||||
}
|
||||
|
||||
class TauriSocketAdapter implements PlatformSocket {
|
||||
onopen: (() => void) | null = null;
|
||||
onmessage: ((ev: { data: string }) => void) | null = null;
|
||||
onclose: (() => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
#closed = false;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
#conn: any = null;
|
||||
|
||||
constructor(url: string) {
|
||||
void this.#connect(url);
|
||||
}
|
||||
|
||||
async #connect(url: string): Promise<void> {
|
||||
try {
|
||||
const { default: TauriWebSocket } = await import("@tauri-apps/plugin-websocket");
|
||||
if (this.#closed) return; // close() called before connect resolved
|
||||
const conn = await TauriWebSocket.connect(url);
|
||||
if (this.#closed) {
|
||||
void conn.disconnect();
|
||||
return;
|
||||
}
|
||||
this.#conn = conn;
|
||||
conn.addListener((msg: { type: string; data: unknown }) => {
|
||||
if (msg.type === "Text") {
|
||||
this.onmessage?.({ data: msg.data as string });
|
||||
} else if (msg.type === "Close") {
|
||||
this.onclose?.();
|
||||
}
|
||||
// Binary/Ping/Pong: the server protocol here is text-JSON only (see
|
||||
// routes/ws.ts) — nothing else is expected.
|
||||
});
|
||||
this.onopen?.();
|
||||
} catch {
|
||||
this.onerror?.();
|
||||
this.onclose?.();
|
||||
}
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.#closed = true;
|
||||
void this.#conn?.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
/** True when running inside the Tauri webview (not a normal browser). */
|
||||
function inTauri(): boolean {
|
||||
return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
|
||||
}
|
||||
|
||||
/** Open a live-feed socket, routed through the Tauri WebSocket plugin inside the
|
||||
* desktop shell (mixed-content workaround), or the native WebSocket in a browser. */
|
||||
export function createPlatformSocket(url: string): PlatformSocket {
|
||||
return inTauri() ? new TauriSocketAdapter(url) : new NativeSocketAdapter(url);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import type { DeviceStatus, LedgerEvent, Occupancy } from "../api.js";
|
||||
import { qk } from "./query.js";
|
||||
import { useLiveStore, type LaneStatus, type LanePresence } from "./live-store.js";
|
||||
import { wsUrl } from "./origin.js";
|
||||
import { createPlatformSocket, type PlatformSocket } from "./platform-ws.js";
|
||||
|
||||
// Booth WebSocket client. Opens ONE socket to /api/ws and turns server pushes into
|
||||
// (a) live-store updates for the ticker/occupancy and (b) Query cache invalidations
|
||||
@@ -23,23 +24,34 @@ type WsMessage =
|
||||
| { kind: "plate-recognized"; plate: { identity: string; plate: string; direction: "entry" | "exit" } };
|
||||
|
||||
|
||||
export function useLiveFeed(): void {
|
||||
/**
|
||||
* @param enabled Gate on the WATCHER permission (`report:read` — mirrors the server's
|
||||
* WS guard in routes/ws.ts). A user whose role lacks it (e.g. a merchant validator
|
||||
* with only `validation:create`) must not attempt the socket at all: the server
|
||||
* 403s the upgrade and the capped-backoff reconnect would otherwise hammer it
|
||||
* forever, filling the server log with a 403 every few seconds.
|
||||
*/
|
||||
export function useLiveFeed(enabled: boolean = true): void {
|
||||
const qc = useQueryClient();
|
||||
const { setStatus, setOccupancy, pushEvent, setDevices, upsertDevice, setLanes, setRadar, patchPlate } =
|
||||
useLiveStore();
|
||||
// Hold the socket + reconnect timer across renders; guard against StrictMode
|
||||
// double-invoke and unmount.
|
||||
const sockRef = useRef<WebSocket | null>(null);
|
||||
const sockRef = useRef<PlatformSocket | null>(null);
|
||||
const retryRef = useRef(0);
|
||||
const closedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
setStatus("closed");
|
||||
return;
|
||||
}
|
||||
closedRef.current = false;
|
||||
|
||||
const connect = () => {
|
||||
if (closedRef.current) return;
|
||||
setStatus(retryRef.current === 0 ? "connecting" : "connecting");
|
||||
const sock = new WebSocket(wsUrl("/api/ws"));
|
||||
const sock = createPlatformSocket(wsUrl("/api/ws"));
|
||||
sockRef.current = sock;
|
||||
|
||||
sock.onopen = () => {
|
||||
@@ -117,7 +129,8 @@ export function useLiveFeed(): void {
|
||||
sockRef.current?.close();
|
||||
sockRef.current = null;
|
||||
};
|
||||
// qc / store setters are stable; run once on mount.
|
||||
// qc / store setters are stable; re-run only if the permission gate flips
|
||||
// (login as a different role without a full reload).
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
}, [enabled]);
|
||||
}
|
||||
|
||||
+54
-9
@@ -14,6 +14,7 @@ import {
|
||||
can,
|
||||
closeShift,
|
||||
fetchShiftReport,
|
||||
fetchVersion,
|
||||
logout,
|
||||
openShift,
|
||||
setLanguagePref,
|
||||
@@ -46,6 +47,7 @@ import { DrawerManager } from "./DrawerManager.js";
|
||||
import { CARD_PAYMENTS_ENABLED } from "./lib/features.js";
|
||||
import { LogsViewer } from "./LogsViewer.js";
|
||||
import { BackupSettings } from "./BackupSettings.js";
|
||||
import { ValidateScreen } from "./ValidateScreen.js";
|
||||
import { RecycleBin } from "./RecycleBin.js";
|
||||
import { Profile } from "./Profile.js";
|
||||
// Reports pulls in Recharts (~heavy) — lazy-loaded so it stays OUT of the booth's
|
||||
@@ -93,6 +95,17 @@ function SetupTab({ to, label, exact = false }: { to: string; label: string; exa
|
||||
);
|
||||
}
|
||||
|
||||
/** The running deploy's "<branch>-<short-sha>" (matches the Komodo Stack's TAG in
|
||||
* komodo/resources.toml), gated the same as the "Park" tab (site:read) since it's the
|
||||
* same kind of read-only app metadata. Renders nothing if the value isn't known (e.g. a
|
||||
* local/dev build with no CI-supplied BUILD_VERSION) rather than showing an empty badge. */
|
||||
function VersionBadge() {
|
||||
const q = useQuery({ queryKey: ["version"], queryFn: fetchVersion, staleTime: Infinity });
|
||||
const version = q.data?.buildVersion;
|
||||
if (!version) return null;
|
||||
return <span className="ml-auto shrink-0 pl-3 text-[0.7rem] text-term-muted">{version}</span>;
|
||||
}
|
||||
|
||||
/** Setup layout — the config hub. Renders a permission-gated tab bar and the active
|
||||
* tab's screen via <Outlet>. Each tab is a child route (its own URL + guard), so
|
||||
* deep links and the back button work and a denied tab redirects to the booth. */
|
||||
@@ -111,6 +124,7 @@ function SetupLayout() {
|
||||
{show("recyclebin:read") && <SetupTab to="/setup/recycle-bin" label={t("nav.recycleBin")} />}
|
||||
{show("log:read") && <SetupTab to="/setup/logs" label={t("nav.logs")} />}
|
||||
{show("backup:read") && <SetupTab to="/setup/backup" label={t("nav.backup")} />}
|
||||
{show("site:read") && <VersionBadge />}
|
||||
</nav>
|
||||
<Outlet />
|
||||
</div>
|
||||
@@ -447,19 +461,28 @@ function ConfirmFigure({ label, value, bold, sub }: { label: string; value: stri
|
||||
function RootLayout() {
|
||||
const { user, setUser } = rootRoute.useRouteContext();
|
||||
const { t } = useTranslation();
|
||||
// One app-wide WebSocket for the live feed (booth + any live widget).
|
||||
useLiveFeed();
|
||||
// Nav is gated by PERMISSION, not role — a tab shows iff the user's role grants
|
||||
// the permission its screen needs (the route guards enforce the same server-side).
|
||||
const show = (perm: Permission) => can(user, perm);
|
||||
// One app-wide WebSocket for the live feed (booth + any live widget) — but ONLY
|
||||
// for roles the server would accept (routes/ws.ts gates on report:read). A
|
||||
// merchant validator must not even attempt it: the 403'd upgrade would reconnect
|
||||
// on backoff forever and spam the server log. Same rule for the widgets that feed
|
||||
// off it (StatusDot) or make their own gated calls (ShiftButton → shift:read,
|
||||
// DeviceFooter → device:read).
|
||||
const canWatch = show("report:read");
|
||||
useLiveFeed(canWatch);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col bg-term-bg text-term-text">
|
||||
<header className="flex items-center gap-4 border-b border-term-border bg-term-panel px-4 py-2">
|
||||
<span className="text-sm font-bold uppercase tracking-widest text-term-amber">▮ Parking</span>
|
||||
<nav className="flex items-center gap-1">
|
||||
<NavLink to="/booth" label={t("nav.booth")} />
|
||||
<NavLink to="/shifts" label={t("nav.shifts")} />
|
||||
{show("session:read") && <NavLink to="/booth" label={t("nav.booth")} />}
|
||||
{show("shift:read") && <NavLink to="/shifts" label={t("nav.shifts")} />}
|
||||
{/* The merchant's (bar/lavazh) scan-and-validate screen. Their typical role
|
||||
grants ONLY validation:create, so this is often their whole nav. */}
|
||||
{show("validation:create") && <NavLink to="/validate" label={t("nav.validate")} />}
|
||||
{/* Drawer — record cash movements (operator) / review them (admin). Shown if the
|
||||
user can do either. See wiki/concepts/shift.md. */}
|
||||
{(show("drawer:create") || show("drawer:review")) && (
|
||||
@@ -485,11 +508,11 @@ function RootLayout() {
|
||||
show("shift:read")) && <NavLink to="/setup" label={t("nav.setup")} />}
|
||||
</nav>
|
||||
<div className="ml-auto flex items-center gap-3">
|
||||
{user && <ShiftButton />}
|
||||
{user && show("shift:read") && <ShiftButton />}
|
||||
{user && <LanguageToggle user={user} setUser={setUser} />}
|
||||
{user && <ThemeToggle user={user} setUser={setUser} />}
|
||||
{user && <FontScaleToggle user={user} setUser={setUser} />}
|
||||
<StatusDot />
|
||||
{canWatch && <StatusDot />}
|
||||
{user && (
|
||||
<Link
|
||||
to="/profile"
|
||||
@@ -514,8 +537,10 @@ function RootLayout() {
|
||||
<main className="min-h-0 flex-1 overflow-auto p-3">
|
||||
<Outlet />
|
||||
</main>
|
||||
{/* Fixed device-status footer — relays, readers, cameras, printers. */}
|
||||
{user && <DeviceFooter />}
|
||||
{/* Fixed device-status footer — relays, readers, cameras, printers. Its REST
|
||||
seed needs device:read (and its live updates ride the report:read WS), so
|
||||
it's hidden for roles without device visibility (e.g. merchant validators). */}
|
||||
{user && show("device:read") && <DeviceFooter />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -523,7 +548,12 @@ function RootLayout() {
|
||||
const indexRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/",
|
||||
beforeLoad: () => {
|
||||
beforeLoad: ({ context }) => {
|
||||
// A merchant-only user (validation:create without the booth's session:read)
|
||||
// lands on their scan-and-validate screen; everyone else on the booth.
|
||||
if (can(context.user, "validation:create") && !can(context.user, "session:read")) {
|
||||
throw redirect({ to: "/validate" });
|
||||
}
|
||||
throw redirect({ to: "/booth" });
|
||||
},
|
||||
});
|
||||
@@ -534,6 +564,20 @@ const boothRoute = createRoute({
|
||||
component: BoothScreen,
|
||||
});
|
||||
|
||||
// The merchant (bar/lavazh) scan-and-validate screen — usually the ONLY page a
|
||||
// merchant user's role can reach. The server enforces the program↔user binding on
|
||||
// apply; this gate is defence in depth. See wiki/concepts/validation-discounts.md.
|
||||
const validateRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/validate",
|
||||
beforeLoad: ({ context }) => requirePerm("validation:create")(context),
|
||||
component: function ValidateRoute() {
|
||||
const { user } = rootRoute.useRouteContext();
|
||||
if (!user) return null;
|
||||
return <ValidateScreen user={user} />;
|
||||
},
|
||||
});
|
||||
|
||||
// Back-compat redirects for paths that moved. Most config screens live under /setup;
|
||||
// Subscriptions/Plans/Tariff-Lab were promoted OUT of /setup into the standalone
|
||||
// /subscriptions section (2026-06-21) — redirect the old /setup/* paths too so existing
|
||||
@@ -779,6 +823,7 @@ const profileRoute = createRoute({
|
||||
const routeTree = rootRoute.addChildren([
|
||||
indexRoute,
|
||||
boothRoute,
|
||||
validateRoute,
|
||||
...legacyRedirects,
|
||||
profileRoute,
|
||||
shiftRoute,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchDeviceStatus, type DeviceStatus } from "../api.js";
|
||||
import { formatClock } from "../lib/format.js";
|
||||
import { qk } from "../lib/query.js";
|
||||
import { useLiveStore } from "../lib/live-store.js";
|
||||
|
||||
@@ -184,7 +185,7 @@ export function DeviceFooter() {
|
||||
</div>
|
||||
{d.detail && <div className="mt-0.5 break-words text-[0.6875rem] text-term-muted">{d.detail}</div>}
|
||||
<div className="mt-0.5 text-[0.625rem] tabular-nums text-term-muted/70">
|
||||
{t("devices.checkedAt", { time: new Date(d.checkedAt).toLocaleTimeString() })}
|
||||
{t("devices.checkedAt", { time: formatClock(d.checkedAt) })}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { formatDateTime } from "../lib/format.js";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchSnapshots, snapshotImageUrl, type PlateRead } from "../api.js";
|
||||
|
||||
@@ -52,7 +53,7 @@ export function SnapshotStrip({ identity }: { identity: string }) {
|
||||
key={`${p.plate}-${p.direction}-${i}`}
|
||||
className="inline-flex items-center gap-1.5 rounded-term border border-term-cyan/40 bg-term-cyan/10 px-2 py-0.5 text-[0.6875rem]"
|
||||
title={`${dirLabel(p.direction)}${p.region ? ` · ${p.region}` : ""}${
|
||||
p.at ? ` · ${new Date(p.at).toLocaleString()}` : ""
|
||||
p.at ? ` · ${formatDateTime(p.at, t)}` : ""
|
||||
}`}
|
||||
>
|
||||
<span className="text-[0.5625rem] uppercase tracking-wider text-term-muted">{t("pay.plate")}</span>
|
||||
@@ -72,7 +73,7 @@ export function SnapshotStrip({ identity }: { identity: string }) {
|
||||
type="button"
|
||||
onClick={() => setZoom(s.id)}
|
||||
className="group flex flex-col items-center gap-1 rounded-term border border-term-border bg-term-panel-2 p-1 hover:border-term-amber"
|
||||
title={`${dirLabel(s.direction)} · ${new Date(s.capturedAt).toLocaleString()}`}
|
||||
title={`${dirLabel(s.direction)} · ${formatDateTime(s.capturedAt, t)}`}
|
||||
>
|
||||
<img
|
||||
src={snapshotImageUrl(s.id)}
|
||||
@@ -97,7 +98,7 @@ export function SnapshotStrip({ identity }: { identity: string }) {
|
||||
<div
|
||||
key={`fail-${f.direction ?? "both"}-${i}`}
|
||||
className="flex h-[6.75rem] w-28 flex-col items-center justify-center gap-1 rounded-term border border-dashed border-term-amber/60 bg-term-amber/5 p-1 text-center"
|
||||
title={`${dirLabel(f.direction)} · ${f.error}${f.occurredAt ? ` · ${new Date(f.occurredAt).toLocaleString()}` : ""}`}
|
||||
title={`${dirLabel(f.direction)} · ${f.error}${f.occurredAt ? ` · ${formatDateTime(f.occurredAt, t)}` : ""}`}
|
||||
>
|
||||
<span className="text-lg leading-none text-term-amber">⚠</span>
|
||||
<span className="text-[0.5625rem] uppercase tracking-wider text-term-amber">{dirLabel(f.direction)}</span>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { type ReactNode } from "react";
|
||||
import { type LedgerEvent } from "../api.js";
|
||||
import { formatMoney } from "../lib/format.js";
|
||||
import { formatMoney, formatDateTime } from "../lib/format.js";
|
||||
import { renderReason } from "../lib/reason.js";
|
||||
import { Modal } from "./Modal.js";
|
||||
import { SnapshotStrip } from "./SnapshotStrip.js";
|
||||
@@ -25,6 +25,7 @@ export const EVENT_STYLE: Record<string, { labelKey: string; color: string }> =
|
||||
cash_out: { labelKey: "booth.evtCashOut", color: "text-term-amber" },
|
||||
cash_review: { labelKey: "booth.evtCashReview", color: "text-term-cyan" },
|
||||
config_change: { labelKey: "booth.evtConfigChange", color: "text-term-amber" },
|
||||
validation: { labelKey: "booth.evtValidation", color: "text-term-green" },
|
||||
anomaly: { labelKey: "booth.evtAnomaly", color: "text-term-red" },
|
||||
};
|
||||
|
||||
@@ -226,7 +227,7 @@ export function EventDetailModal({ e, onClose }: { e: LedgerEvent; onClose: () =
|
||||
|
||||
{/* Humanized fields — labelled rows, not raw JSON. Only what applies renders. */}
|
||||
<div>
|
||||
<DetailRow label={t("booth.edTime")}>{new Date(e.occurredAt).toLocaleString()}</DetailRow>
|
||||
<DetailRow label={t("booth.edTime")}>{formatDateTime(e.occurredAt, t, { seconds: true })}</DetailRow>
|
||||
<DetailRow label={t("booth.edIndex")}>#{e.index}</DetailRow>
|
||||
{e.direction && <DetailRow label={t("booth.edDirection")}>{e.direction}</DetailRow>}
|
||||
{e.source && <DetailRow label={t("booth.edSource")}>{e.source}</DetailRow>}
|
||||
|
||||
+31
-1
@@ -49,7 +49,7 @@ REGISTRY=git.infra.msai.al/mca/parking_solution
|
||||
# Staging booth: pinned immutable stage-<sha>. After each promotion (merge dev → stage, CI builds
|
||||
# :stage-<sha>), bump this to the new sha and re-sync/deploy from Core. The moving `:stage` tag
|
||||
# exists as the pointer; we deploy the sha, not the mover.
|
||||
TAG=stage-d905dd1
|
||||
TAG=stage-28bd838
|
||||
COOKIE_SECURE=0
|
||||
VISION_ENABLED=1
|
||||
WS_ALLOWED_ORIGINS=
|
||||
@@ -57,3 +57,33 @@ JWT_SECRET=[[park_buzi_jwt_secret]]
|
||||
EVENT_SIGNING_KEY=[[park_buzi_event_signing_key]]
|
||||
BACKUP_KEY=[[park_buzi_backup_key]]
|
||||
"""
|
||||
|
||||
##############################################################################
|
||||
|
||||
[[stack]]
|
||||
name = "park-2"
|
||||
[stack.config]
|
||||
server = "park-2"
|
||||
git_provider = "git.infra.msai.al"
|
||||
git_account = "komodo"
|
||||
repo = "mca/parking_solution"
|
||||
branch = "stage"
|
||||
file_paths = [
|
||||
"docker-compose.yml",
|
||||
"docker-compose.prod.yml"
|
||||
]
|
||||
registry_provider = "git.infra.msai.al"
|
||||
registry_account = "komodo"
|
||||
environment = """
|
||||
REGISTRY=git.infra.msai.al/mca/parking_solution
|
||||
# Staging booth: pinned immutable stage-<sha>. After each promotion (merge dev → stage, CI builds
|
||||
# :stage-<sha>), bump this to the new sha and re-sync/deploy from Core. The moving `:stage` tag
|
||||
# exists as the pointer; we deploy the sha, not the mover.
|
||||
TAG=stage-28bd838
|
||||
COOKIE_SECURE=0
|
||||
VISION_ENABLED=1
|
||||
WS_ALLOWED_ORIGINS=
|
||||
JWT_SECRET=[[park_2_jwt_secret]]
|
||||
EVENT_SIGNING_KEY=[[park_2_event_signing_key]]
|
||||
BACKUP_KEY=[[park_2_backup_key]]
|
||||
"""
|
||||
|
||||
@@ -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,
|
||||
"tag": "0022_tariff_version_name",
|
||||
"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,
|
||||
// tariffs, subscription PLANS.
|
||||
// --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,
|
||||
// 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):
|
||||
// 1. env RESET_ALLOWED=1 — a real booth never sets this.
|
||||
@@ -44,10 +52,44 @@ const CATEGORIES = {
|
||||
"subscriptions",
|
||||
"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"],
|
||||
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) {
|
||||
const flags = new Set(argv.filter((a) => a.startsWith("--")).map((a) => a.slice(2)));
|
||||
const wantAll = flags.has("all");
|
||||
@@ -75,7 +117,7 @@ async function main() {
|
||||
|
||||
const { cats, autoYes, wantAll } = parseArgs(process.argv.slice(2));
|
||||
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.
|
||||
@@ -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.
|
||||
const tables = [];
|
||||
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.");
|
||||
}
|
||||
|
||||
const sqlite = new Database(dbPath);
|
||||
try {
|
||||
// FKs OFF for the wipe so we can delete in any order without ordering hazards;
|
||||
// a single transaction makes it all-or-nothing.
|
||||
|
||||
@@ -5,7 +5,7 @@ import * as schema from "./schema.js";
|
||||
export * from "./schema.js";
|
||||
// Re-export the query helpers consumers need, so they don't depend on
|
||||
// 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
|
||||
|
||||
@@ -288,6 +288,20 @@ export const siteConfig = sqliteTable("site_config", {
|
||||
backupKeepLast: integer("backup_keep_last"),
|
||||
/** Beyond keepLast, keep one backup per day for this many days. null ⇒ code default (30). */
|
||||
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")
|
||||
.notNull()
|
||||
.default(sql`(current_timestamp)`),
|
||||
@@ -458,6 +472,57 @@ export const subscriptionPlates = sqliteTable("subscription_plates", {
|
||||
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) -------------------------------------------------
|
||||
// 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.
|
||||
@@ -546,5 +611,7 @@ export type SubscriptionPlanRow = typeof subscriptionPlans.$inferSelect;
|
||||
export type SubscriptionCredentialRow = typeof subscriptionCredentials.$inferSelect;
|
||||
export type SubscriptionPlateRow = typeof subscriptionPlates.$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 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.
|
||||
|
||||
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.
|
||||
const { hikvisionDriver } = await import("./camera.js");
|
||||
@@ -22,6 +26,7 @@ function makeCamera() {
|
||||
|
||||
beforeEach(() => {
|
||||
digestGet.mockReset();
|
||||
digestRequest.mockReset();
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
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 {
|
||||
CameraDevice,
|
||||
ClockSyncResult,
|
||||
DeviceHealth,
|
||||
Snapshot,
|
||||
SnapshotContext,
|
||||
@@ -12,7 +13,7 @@ import {
|
||||
usernameField,
|
||||
stubLog,
|
||||
} 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
|
||||
// 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));
|
||||
|
||||
/** 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 {
|
||||
readonly #host: string;
|
||||
readonly #port: number;
|
||||
@@ -85,7 +99,7 @@ class HttpCamera implements CameraDevice {
|
||||
try {
|
||||
const res = await this.#get();
|
||||
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)
|
||||
return {
|
||||
status: "degraded",
|
||||
@@ -147,6 +161,73 @@ class HttpCamera implements CameraDevice {
|
||||
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 = {
|
||||
@@ -237,7 +318,7 @@ export const hikvisionDriver: CameraDriver = {
|
||||
// ISAPI channel id: <channel><stream>, e.g. ch1 main = 101, ch1 sub = 102, ch2 main = 201.
|
||||
// stream 1 → "01" (main), 2 → "02" (sub).
|
||||
create: (c) =>
|
||||
new HttpCamera(
|
||||
new HikvisionCamera(
|
||||
"hikvision",
|
||||
c,
|
||||
(ch, stream) => `/ISAPI/Streaming/channels/${ch}0${stream}/picture`,
|
||||
|
||||
@@ -73,19 +73,33 @@ export interface DigestGetOptions {
|
||||
readonly localAddress?: string;
|
||||
}
|
||||
|
||||
function getOnce(
|
||||
o: DigestGetOptions,
|
||||
/** digestGet + a method and optional body — for ISAPI configuration writes
|
||||
* (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,
|
||||
): Promise<{ res: IncomingMessage; body: Buffer }> {
|
||||
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> = {};
|
||||
if (authHeader) headers["authorization"] = authHeader;
|
||||
if (payload) {
|
||||
headers["content-type"] = o.contentType ?? "application/xml";
|
||||
headers["content-length"] = String(payload.length);
|
||||
}
|
||||
const req = httpRequest(
|
||||
{
|
||||
host: o.host,
|
||||
port: o.port,
|
||||
path: o.path,
|
||||
method: "GET",
|
||||
method: o.method,
|
||||
timeout: o.timeoutMs,
|
||||
localAddress: o.localAddress,
|
||||
headers,
|
||||
@@ -97,19 +111,21 @@ function getOnce(
|
||||
},
|
||||
);
|
||||
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();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* 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.
|
||||
*/
|
||||
export async function digestGet(o: DigestGetOptions): Promise<DigestGetResult> {
|
||||
const first = await getOnce(o);
|
||||
export async function digestRequest(o: DigestRequestOptions): Promise<DigestGetResult> {
|
||||
const first = await requestOnce(o);
|
||||
if (first.res.statusCode !== 401) {
|
||||
return {
|
||||
status: first.res.statusCode ?? 0,
|
||||
@@ -129,11 +145,16 @@ export async function digestGet(o: DigestGetOptions): Promise<DigestGetResult> {
|
||||
}
|
||||
|
||||
const challenge = parseChallenge(challengeHeader);
|
||||
const auth = buildAuthHeader(challenge, o.user, o.password, "GET", o.path);
|
||||
const second = await getOnce(o, auth);
|
||||
const auth = buildAuthHeader(challenge, o.user, o.password, o.method, o.path);
|
||||
const second = await requestOnce(o, auth);
|
||||
return {
|
||||
status: second.res.statusCode ?? 0,
|
||||
contentType: String(second.res.headers["content-type"] ?? ""),
|
||||
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 { stubAccessDriver } from "./access-stub.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 { dingtianQrReaderDriver, tcpipReaderDriver, wiegandReaderDriver } from "./reader.js";
|
||||
|
||||
@@ -23,7 +23,7 @@ export function registerBuiltinDrivers(): void {
|
||||
registry.register(hikvisionDriver);
|
||||
registry.register(dahuaDriver);
|
||||
registry.register(rongtaDriver);
|
||||
registry.register(cashinoDriver);
|
||||
registry.register(escposDriver);
|
||||
}
|
||||
|
||||
export {
|
||||
@@ -35,5 +35,5 @@ export {
|
||||
hikvisionDriver,
|
||||
dahuaDriver,
|
||||
rongtaDriver,
|
||||
cashinoDriver,
|
||||
escposDriver,
|
||||
};
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
probeUsb,
|
||||
sendRawUsb,
|
||||
transportFromConfig,
|
||||
writeAllUsb,
|
||||
stamp,
|
||||
} from "./printer-escpos.js";
|
||||
|
||||
@@ -183,3 +184,72 @@ describe("stamp (Albanian date format)", () => {
|
||||
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ë",
|
||||
/** "Paid:" amount label (precedes the large total). */
|
||||
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
|
||||
* 80mm width, so neither wraps mid-word. */
|
||||
graceLines: (min: number): readonly string[] => [
|
||||
@@ -413,6 +416,16 @@ export function renderReceipt(data: ReceiptData): Buffer {
|
||||
line(
|
||||
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(),
|
||||
// The amount, large and centred.
|
||||
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
|
||||
* is a RAW character device: a single open + write delivers the job — there is no
|
||||
* FIN/half-close dance (that was a TCP concern, where an early destroy() could
|
||||
* truncate the stream). We always close the handle (even on a failed write). */
|
||||
* is a RAW character device — no FIN/half-close dance (that was a TCP concern) —
|
||||
* but delivery must go through the chunked loop above (see its doc for why). We
|
||||
* always close the handle (even on a failed write). */
|
||||
export async function sendRawUsb(
|
||||
devicePath: string,
|
||||
payload: Buffer,
|
||||
@@ -615,7 +701,7 @@ export async function sendRawUsb(
|
||||
"usb open timeout",
|
||||
);
|
||||
try {
|
||||
await withTimeout(handle.write(payload), timeoutMs, "usb write timeout");
|
||||
await writeAllUsb(handle, payload, Date.now() + timeoutMs);
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
@@ -701,8 +787,8 @@ export const transportField: ConfigField = {
|
||||
required: true,
|
||||
default: "tcp-ip",
|
||||
options: [
|
||||
{ value: "tcp-ip", label: "Network (raw TCP, port 9100)" },
|
||||
{ value: "usb", label: "USB (local /dev/usb/lp0)" },
|
||||
{ value: "tcp-ip", label: "Network (raw TCP)" },
|
||||
{ value: "usb", label: "USB (local printer)" },
|
||||
],
|
||||
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",
|
||||
required: false,
|
||||
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 { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { cashinoDriver } from "./printer-cashino.js";
|
||||
import { escposDriver } from "./printer-generic.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
|
||||
// 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 devicePath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), "cashino-usb-"));
|
||||
dir = mkdtempSync(join(tmpdir(), "escpos-usb-"));
|
||||
devicePath = join(dir, "lp0");
|
||||
// Stand in for an enumerated usblp node (the kernel creates it; we only open it).
|
||||
writeFileSync(devicePath, "");
|
||||
@@ -24,7 +25,7 @@ describe("cashinoDriver — USB transport", () => {
|
||||
});
|
||||
|
||||
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" };
|
||||
await printer.printTicket(data);
|
||||
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 () => {
|
||||
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");
|
||||
// An absent device node (printer unplugged / not enumerated) → offline.
|
||||
const absent = cashinoDriver.create({
|
||||
const absent = escposDriver.create({
|
||||
transport: "usb",
|
||||
devicePath: join(dir, "absent-lp0"),
|
||||
timeoutMs: 1000,
|
||||
@@ -44,7 +45,7 @@ describe("cashinoDriver — USB transport", () => {
|
||||
});
|
||||
|
||||
it("advertises both transports", () => {
|
||||
expect(cashinoDriver.transports).toContain("usb");
|
||||
expect(cashinoDriver.transports).toContain("tcp-ip");
|
||||
expect(escposDriver.transports).toContain("usb");
|
||||
expect(escposDriver.transports).toContain("tcp-ip");
|
||||
});
|
||||
});
|
||||
+21
-16
@@ -23,25 +23,26 @@ import {
|
||||
type Transport,
|
||||
} from "./printer-escpos.js";
|
||||
|
||||
// Cashino 80mm thermal printer driver (network OR USB). The Cashino is an ESC/POS
|
||||
// clone: it PRINTS identically to the Rongta (same byte stream — see
|
||||
// ./printer-escpos.ts), so tickets, reports and subscription cards render the same,
|
||||
// over either transport. What it does NOT have is the Rongta board's decoded status
|
||||
// web page (/prn_stat.htm). It cannot report paper-out / cover-open / cutter faults
|
||||
// in a form we trust.
|
||||
// GENERIC ESC/POS 80mm thermal printer driver (network OR USB) — any clone that
|
||||
// PRINTS the shared ESC/POS byte stream (see ./printer-escpos.ts) but serves no
|
||||
// Rongta-style decoded status page (/prn_stat.htm). Verified fits: Cashino (the
|
||||
// first unit we drove — the driver carried its name until 2026-07-06), ICS/Xprinter
|
||||
// XP-K200L. Tickets, reports and subscription cards render identically to the
|
||||
// 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
|
||||
// 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
|
||||
// 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
|
||||
// (no readStatus). The device monitor then falls back to the generic
|
||||
// `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"
|
||||
// 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.)
|
||||
//
|
||||
// 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.
|
||||
// See wiki/concepts/printer-status-monitoring.md and printer-roles-failover.md.
|
||||
|
||||
class CashinoPrinter implements PrinterDevice {
|
||||
readonly driverId = "cashino";
|
||||
class GenericEscposPrinter implements PrinterDevice {
|
||||
readonly driverId = "escpos";
|
||||
readonly #transport: Transport;
|
||||
readonly #timeout: number;
|
||||
|
||||
@@ -69,7 +70,7 @@ class CashinoPrinter implements PrinterDevice {
|
||||
|
||||
/**
|
||||
* 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 →
|
||||
* offline. Deliberately NO readStatus(): the monitor uses this for the
|
||||
* 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.",
|
||||
};
|
||||
|
||||
export const cashinoDriver: PrinterDriver = {
|
||||
id: "cashino",
|
||||
export const escposDriver: PrinterDriver = {
|
||||
// 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",
|
||||
label: "Cashino 80mm thermal printer",
|
||||
label: "Generic ESC/POS 80mm printer (Cashino, ICS/Xprinter…)",
|
||||
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"],
|
||||
configFields: [
|
||||
transportField,
|
||||
@@ -167,5 +172,5 @@ export const cashinoDriver: PrinterDriver = {
|
||||
default: 3000,
|
||||
},
|
||||
],
|
||||
create: (c) => new CashinoPrinter(c),
|
||||
create: (c) => new GenericEscposPrinter(c),
|
||||
};
|
||||
@@ -18,7 +18,7 @@ export {
|
||||
hikvisionDriver,
|
||||
dahuaDriver,
|
||||
rongtaDriver,
|
||||
cashinoDriver,
|
||||
escposDriver,
|
||||
} from "./drivers/index.js";
|
||||
export { isPrinter, type PrinterRole } from "./drivers/printer-rongta.js";
|
||||
// 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";
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
readonly direction: "entry" | "exit";
|
||||
}
|
||||
@@ -251,6 +275,11 @@ export interface ReceiptData {
|
||||
readonly voucher: boolean;
|
||||
/** Minutes the customer has to reach the exit after paying (voucher mode only). */
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -97,6 +97,14 @@ export function isDiscoverable(
|
||||
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 {
|
||||
readonly #drivers = new Map<string, DeviceDriver>();
|
||||
|
||||
@@ -114,12 +122,12 @@ class DeviceRegistry {
|
||||
}
|
||||
|
||||
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. */
|
||||
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}`);
|
||||
for (const field of driver.configFields) {
|
||||
if (field.required && config[field.key] === undefined) {
|
||||
|
||||
+316
-17
@@ -20,6 +20,7 @@ export const RESOURCES = [
|
||||
"tariff", // read / publish a new version
|
||||
"subscription", // the subscription registry
|
||||
"site", // site_config + device setup/assign
|
||||
"validation", // merchant validations: apply a discount to a session (bar/lavazh)
|
||||
"device", // device status / printers / snapshots / catalog
|
||||
"shift", // open/close own shift
|
||||
"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
|
||||
|
||||
"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",
|
||||
"shift:read", "shift:create", "shift:cash",
|
||||
// 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
|
||||
// attributed + auditable). See wiki/concepts/entry-presence-bypass.md.
|
||||
| "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";
|
||||
|
||||
/** How money was tendered (for payment events + the shift Z-report). */
|
||||
@@ -291,9 +307,25 @@ export interface LedgerPayload {
|
||||
readonly tender?: Tender;
|
||||
/** payment: which tariff_version priced it (reproducible repricing). */
|
||||
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 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. */
|
||||
readonly fxRate?: number | null;
|
||||
/** 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
|
||||
* still verify + display. See wiki/concepts/shift.md. */
|
||||
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;
|
||||
/** cash_review: the admin's decision on the referenced movement. A FLAG only —
|
||||
* neither value moves cash or touches the drawer balance. */
|
||||
@@ -702,6 +735,58 @@ export interface SessionPayment {
|
||||
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
|
||||
* `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). */
|
||||
@@ -709,8 +794,14 @@ export interface SessionPricing {
|
||||
/** 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). */
|
||||
readonly periodStart: string;
|
||||
/** Fee for [periodStart, asOf]. */
|
||||
/** Amount DUE for [periodStart, asOf] — NET of any merchant validations. */
|
||||
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). */
|
||||
readonly overstay: boolean;
|
||||
/** 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);
|
||||
* 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.
|
||||
*
|
||||
* `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(
|
||||
enteredAt: string,
|
||||
@@ -739,6 +839,7 @@ export function priceSession(
|
||||
tariff: TariffStructure,
|
||||
payments: readonly SessionPayment[] = [],
|
||||
category?: string,
|
||||
validations: readonly SessionValidation[] = [],
|
||||
): SessionPricing {
|
||||
const last = payments.length ? payments[payments.length - 1] : null;
|
||||
const graceExpiryMs =
|
||||
@@ -748,10 +849,50 @@ export function priceSession(
|
||||
const withinGrace = graceExpiryMs != null && asOfMs <= graceExpiryMs;
|
||||
const periodStart = overstay ? new Date(graceExpiryMs!).toISOString() : enteredAt;
|
||||
// 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 {
|
||||
periodStart,
|
||||
amountMinor,
|
||||
amountMinor: net,
|
||||
grossMinor,
|
||||
discountMinor: grossMinor - net,
|
||||
validationLines: lines,
|
||||
overstay,
|
||||
withinGrace,
|
||||
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;
|
||||
}
|
||||
|
||||
// --- 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.
|
||||
* 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.
|
||||
* `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;
|
||||
const sorted = [...steps].sort((a, b) => a.uptoMin - b.uptoMin);
|
||||
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) {
|
||||
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).
|
||||
const tier = sorted.find((s) => dayMin <= s.uptoMin) ?? top;
|
||||
const found = sorted.find((s) => dayMin <= s.uptoMin);
|
||||
const tier = found ?? top;
|
||||
total += tier.totalMinor;
|
||||
trace?.push({
|
||||
kind: "step",
|
||||
day: dayStart / DAY + 1,
|
||||
dayMinutes: dayMin,
|
||||
uptoMin: tier.uptoMin,
|
||||
amountMinor: tier.totalMinor,
|
||||
repeated: found == null,
|
||||
});
|
||||
}
|
||||
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
|
||||
* corrupt repricing of already-signed sessions. A `steps` table (when present)
|
||||
* 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);
|
||||
if (!Number.isFinite(ms) || ms <= 0) return 0;
|
||||
const rawMinutes = ms / 60_000;
|
||||
// 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).
|
||||
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 minutes = Math.ceil(rawMinutes / inc) * inc; // round UP to the increment
|
||||
|
||||
// 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;
|
||||
let total = 0;
|
||||
for (let segStart = 0; segStart < minutes; segStart += DAY) {
|
||||
const segEnd = Math.min(segStart + DAY, minutes);
|
||||
let segFee = 0;
|
||||
const bands = trace ? new BandTracer(trace, inc) : null;
|
||||
// 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).
|
||||
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;
|
||||
}
|
||||
return total;
|
||||
@@ -837,12 +1116,16 @@ function computeFeeV2(
|
||||
asOf: string,
|
||||
tariff: TariffStructureV2,
|
||||
category?: string,
|
||||
trace?: FeeBreakdownItem[],
|
||||
): number {
|
||||
const enteredMs = Date.parse(enteredAt);
|
||||
const ms = Date.parse(asOf) - enteredMs;
|
||||
if (!Number.isFinite(ms) || ms <= 0) return 0;
|
||||
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 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
|
||||
// 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.
|
||||
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;
|
||||
// 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) {
|
||||
const segEnd = Math.min(segStart + DAY, minutes);
|
||||
let segFee = 0;
|
||||
const bands = trace ? new BandTracer(trace, inc) : null;
|
||||
for (let within = segStart; within < segEnd; within += inc) {
|
||||
const wall = localBreakdown(enteredMs + within * 60_000, tariff.tz);
|
||||
const card = selectCard(cards, wall);
|
||||
if (card.packageMinor != null) {
|
||||
// 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) {
|
||||
segFee += card.flatMinor;
|
||||
bands?.add(traceName(card), card.flatMinor, within);
|
||||
} else {
|
||||
// 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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
return total;
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
computeFee,
|
||||
explainFee,
|
||||
priceSession,
|
||||
validateTariffStructure,
|
||||
type FeeBreakdownItem,
|
||||
type TariffStructure,
|
||||
type TariffStructureV1,
|
||||
type TariffStructureV2,
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
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));
|
||||
});
|
||||
});
|
||||
|
||||
Generated
+20
@@ -108,12 +108,18 @@ importers:
|
||||
'@tanstack/react-router':
|
||||
specifier: ^1.170.16
|
||||
version: 1.170.16(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@tauri-apps/plugin-http':
|
||||
specifier: ^2.5.2
|
||||
version: 2.6.0
|
||||
'@tauri-apps/plugin-process':
|
||||
specifier: ^2.3.1
|
||||
version: 2.3.1
|
||||
'@tauri-apps/plugin-updater':
|
||||
specifier: ^2.10.1
|
||||
version: 2.10.1
|
||||
'@tauri-apps/plugin-websocket':
|
||||
specifier: ^2.3.0
|
||||
version: 2.4.3
|
||||
i18next:
|
||||
specifier: ^26.3.1
|
||||
version: 26.3.1(typescript@6.0.3)
|
||||
@@ -1577,12 +1583,18 @@ packages:
|
||||
engines: {node: '>= 10'}
|
||||
hasBin: true
|
||||
|
||||
'@tauri-apps/plugin-http@2.6.0':
|
||||
resolution: {integrity: sha512-QYXwbGb4hQ9/8Riv/ejU/kPFFnbBIrBcWwV1LIXv2xBKfoj8lkWfGkd9pkCSsBI/pljPtz+IPqfrE3t3bVl3mg==}
|
||||
|
||||
'@tauri-apps/plugin-process@2.3.1':
|
||||
resolution: {integrity: sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA==}
|
||||
|
||||
'@tauri-apps/plugin-updater@2.10.1':
|
||||
resolution: {integrity: sha512-NFYMg+tWOZPJdzE/PpFj2qfqwAWwNS3kXrb1tm1gnBJ9mYzZ4WDRrwy8udzWoAnfGCHLuePNLY1WVCNHnh3eRA==}
|
||||
|
||||
'@tauri-apps/plugin-websocket@2.4.3':
|
||||
resolution: {integrity: sha512-c85ykljg6AzY6Zw4KpYsEBaLirjPIs6m8xxC6hZcdwAchckaBU368US+oSsa5B43PjSLukjwD5vOOqzYYnswWA==}
|
||||
|
||||
'@testing-library/dom@10.4.1':
|
||||
resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -4089,6 +4101,10 @@ snapshots:
|
||||
'@tauri-apps/cli-win32-ia32-msvc': 2.11.3
|
||||
'@tauri-apps/cli-win32-x64-msvc': 2.11.3
|
||||
|
||||
'@tauri-apps/plugin-http@2.6.0':
|
||||
dependencies:
|
||||
'@tauri-apps/api': 2.11.1
|
||||
|
||||
'@tauri-apps/plugin-process@2.3.1':
|
||||
dependencies:
|
||||
'@tauri-apps/api': 2.11.1
|
||||
@@ -4097,6 +4113,10 @@ snapshots:
|
||||
dependencies:
|
||||
'@tauri-apps/api': 2.11.1
|
||||
|
||||
'@tauri-apps/plugin-websocket@2.4.3':
|
||||
dependencies:
|
||||
'@tauri-apps/api': 2.11.1
|
||||
|
||||
'@testing-library/dom@10.4.1':
|
||||
dependencies:
|
||||
'@babel/code-frame': 7.29.7
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
type: concept
|
||||
tags: [parking, observability, diagnostics, logging, frontend, backend]
|
||||
sources: []
|
||||
updated: 2026-07-04
|
||||
updated: 2026-07-08
|
||||
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.
|
||||
- **Bounded.** Frontend queue capped (drops oldest); message/stack/context clamped per row;
|
||||
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)
|
||||
|
||||
@@ -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"
|
||||
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)
|
||||
|
||||
`docker logs` is a separate, size-bounded store from `app_logs` — it holds **everything**
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
type: concept
|
||||
tags: [parking, durability, backup, recovery, security, crypto]
|
||||
sources: []
|
||||
updated: 2026-06-29
|
||||
updated: 2026-08-30
|
||||
---
|
||||
|
||||
# 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
|
||||
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
|
||||
|
||||
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**
|
||||
(`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
|
||||
build slices; records the key-custody stance that bears 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]].
|
||||
`BACKUP_KEY` stays a server secret. **Last-success/last-error status + the scheduling cadence are
|
||||
now restart-durable (migration 0025, 2026-08-30)** — see field bug above. Resolves the *design*
|
||||
half of [[open-questions]] #5 and the first build slices; records the key-custody stance that bears
|
||||
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
|
||||
tags: [parking, device, indicator, radar, camera, aux-output, barrier-not-a-door, event-relay]
|
||||
sources: []
|
||||
updated: 2026-06-28
|
||||
updated: 2026-07-08
|
||||
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
|
||||
lost/stale packet self-corrects. This also de-dupes (it skips a send when `confirmedOn === desiredOn`),
|
||||
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
|
||||
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
|
||||
@@ -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
|
||||
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
|
||||
device's *confirmed* state, fail-OFF, de-dupe, lamp-added-after-start reconcile, and two independent
|
||||
alert relays on one controller).
|
||||
device's *confirmed* state, fail-OFF, de-dupe, lamp-added-after-start reconcile, two independent
|
||||
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]],
|
||||
[[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
|
||||
> 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
|
||||
|
||||
- **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 |
|
||||
| --- | --- | --- |
|
||||
| `--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 |
|
||||
| `--diagnostics` | `app_logs` (the unsigned [[app-logs]] store behind `/setup/logs`) | everything else |
|
||||
| `--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]].**
|
||||
> 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**
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
type: concept
|
||||
tags: [parking, device, printer, transport, usb, escpos, provisioning]
|
||||
sources: []
|
||||
updated: 2026-06-24
|
||||
updated: 2026-08-30
|
||||
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
|
||||
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]],
|
||||
[[appliance-provisioning]], [[network-isolation]], [[technology-stack]].
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
type: concept
|
||||
tags: [parking, domain, business, reporting]
|
||||
sources: []
|
||||
updated: 2026-06-22
|
||||
updated: 2026-07-05
|
||||
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
|
||||
([[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
|
||||
|
||||
- Which reports matter at launch vs. later; the export format/cadence.
|
||||
|
||||
@@ -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.
|
||||
- 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.
|
||||
- A composer **price preview** ("at 14:30 Tue a 2h stay costs …") — high-value for operator trust,
|
||||
deferred.
|
||||
- ~~A composer **price preview**~~ — largely DELIVERED 2026-07-06 by the Tariff Lab's fee
|
||||
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
|
||||
**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;
|
||||
"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
|
||||
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
|
||||
waiting (the only clock the booth reads is the real wall-clock). The **Tariff Lab** closes that gap:
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
type: concept
|
||||
tags: [parking, domain, business, pricing, revenue]
|
||||
sources: []
|
||||
updated: 2026-06-15
|
||||
updated: 2026-07-13
|
||||
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 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
|
||||
|
||||
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
|
||||
(`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]].)
|
||||
- **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)
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
type: reference
|
||||
tags: [parking, deployment, appliance, hardening, runbook, offline-first]
|
||||
sources: []
|
||||
updated: 2026-06-30
|
||||
updated: 2026-09-02
|
||||
status: settled
|
||||
---
|
||||
|
||||
@@ -216,17 +216,24 @@ adversary). Create a dedicated **admin** (real password, sudo, NO auto-login) an
|
||||
```bash
|
||||
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:
|
||||
sudo deluser <operator> sudo # demote the auto-login operator
|
||||
sudo gpasswd -d <operator> sudo # demote the auto-login operator
|
||||
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
|
||||
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.
|
||||
|
||||
> Strip latent escalation groups from the operator: **`sudo deluser <operator> lxd`** (lxd group =
|
||||
> 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).
|
||||
> 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
|
||||
> `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)
|
||||
|
||||
@@ -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
|
||||
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),
|
||||
NOT `:9120` — Core's container port `9120` is exposed-not-published; the agent reaches it through
|
||||
the proxy. (Gotcha #7 below.)
|
||||
- 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
|
||||
`/etc/komodo` default from a system install would `Permission denied` for the user service).
|
||||
(singular).
|
||||
|
||||
Verify: `systemctl --user status periphery` → active; the server **`park-buzi`** appears and goes
|
||||
**OK/green** in Core → Servers. Then **delete the onboarding key**.
|
||||
> ⚠ **ALWAYS CHECK THIS — every install so far has hit it (lab box 2026-07-07, booth `park-2`
|
||||
> 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)
|
||||
|
||||
@@ -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 +
|
||||
# subscription INSTANCES/credentials/plates + blocklist. KEEPS users/devices/config/
|
||||
# tariffs/subscription PLANS.
|
||||
# --config site_config, devices, setup_state (re-runs first-run setup), tariffs + versions, plans.
|
||||
# --users users, roles, role_permissions, auth sessions. --all every table.
|
||||
# --config site_config, devices, setup_state (re-runs first-run setup), tariffs + versions
|
||||
# + 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 \
|
||||
-e RESET_ALLOWED=1 \
|
||||
-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.
|
||||
|
||||
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)
|
||||
the same script is `pnpm db:reset --financial` — see [[local-dev-workflow]].
|
||||
(`docker exec … node scripts/seed-admin.mjs`) so someone can log back in. Since 2026-07-06 the seed
|
||||
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
|
||||
|
||||
@@ -431,8 +496,36 @@ works; the desktop app is a separate workstream.
|
||||
separate Komodo credentials. A blank registry account on the Stack → anonymous pull →
|
||||
`no basic auth credentials`. Set the Stack's **Registry Account** (`komodo`).
|
||||
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
|
||||
`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**
|
||||
(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
|
||||
tags: [parking, decisions, desktop, frontend]
|
||||
sources: []
|
||||
updated: 2026-06-21
|
||||
updated: 2026-09-03
|
||||
status: settled
|
||||
---
|
||||
|
||||
@@ -140,20 +140,51 @@ 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`,
|
||||
guarded on `import.meta.env.PROD`); dev keeps right-click + devtools. Applies to both the browser
|
||||
prod build and the desktop build (same SPA).
|
||||
- **`VITE_API_BASE` wired to the environment:** `apps/web/.env.production` (committed, non-secret,
|
||||
allow-listed in `.gitignore`) sets `VITE_API_BASE=http://127.0.0.1:3000`, auto-loaded by
|
||||
`vite build` (which the desktop bundle runs). So the desktop build targets Fastify with no manual
|
||||
export; the browser-served-by-Fastify build should override to `""`.
|
||||
- **Mixed content blocks http(s)/ws(s) from the webview — fixed 2026-09-03.** Even with
|
||||
`VITE_API_BASE` correctly set (below), login still failed with WebKit's generic `"Load failed"`.
|
||||
Root cause is a separate, deeper issue: WebKitGTK treats `tauri://localhost` as a **secure
|
||||
origin**, so a plain `http://127.0.0.1:3000` `fetch()` — or a `ws://127.0.0.1:3000` WebSocket —
|
||||
from inside it is blocked as **mixed content**, a long-standing WebKit limitation
|
||||
([bugs.webkit.org #171934](https://bugs.webkit.org/show_bug.cgi?id=171934)). `connect-src` in the
|
||||
CSP does **not** override this — it's a different browser security layer entirely, so the request
|
||||
never even reaches the network layer to be diagnosable via server logs. **Fix:** two Tauri plugins
|
||||
route the SPA's traffic through Tauri's native (Rust) side instead of the webview's own
|
||||
fetch/WebSocket, which sidesteps the check entirely:
|
||||
- **`tauri-plugin-http`** — `apps/web/src/lib/origin.ts`'s `platformFetch()` dynamically imports
|
||||
`@tauri-apps/plugin-http`'s `fetch` (a genuine drop-in for the standard Fetch API) inside Tauri,
|
||||
plain `fetch` in the browser. `api.ts` and `logger.ts` both call `platformFetch` instead of the
|
||||
global `fetch` now.
|
||||
- **`tauri-plugin-websocket`** — NOT a drop-in (async `connect()`/listener-callback API, not
|
||||
`onopen`/`onmessage`/sync `send`/`close`). `apps/web/src/lib/platform-ws.ts` adapts it behind
|
||||
the same native-`WebSocket`-shaped interface `use-live-feed.ts` already expects (hardened for
|
||||
reconnect backoff + StrictMode double-invoke), so that hook needed zero changes.
|
||||
- Capability grants: `apps/desktop/src-tauri/capabilities/default.json` adds `websocket:default`
|
||||
and a scoped `http:default` (`allow: [{url: "http://127.0.0.1:3000"}, {url:
|
||||
"http://localhost:3000"}]`) — deny-by-default, matching the CSP's existing allowlist.
|
||||
- `logger.ts`'s `flushBeacon()` (page-hide `navigator.sendBeacon`) is a native browser API with no
|
||||
Tauri equivalent — it still drops silently in the desktop shell on unload. Accepted: the regular
|
||||
4s-interval flush (now fixed, routes through `platformFetch`) covers the common case.
|
||||
- **`VITE_API_BASE` — desktop vs. browser (regression found + fixed 2026-09-03):**
|
||||
`apps/web/.env.production` (committed, shared by both builds) sets `VITE_API_BASE=` (empty) — this
|
||||
is correct for the **browser/booth** build (Fastify same-origin, stays relative) since commit
|
||||
`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`.
|
||||
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()`.
|
||||
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*
|
||||
operation; updates are out-of-band). Endpoint is the **self-hosted Gitea** "latest release"
|
||||
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
|
||||
operation; updates are out-of-band). **WS origin:** the desktop window's origin
|
||||
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
|
||||
`apps/server/.env.example`).
|
||||
@@ -165,8 +196,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
|
||||
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.)*
|
||||
- **Still deferred:** the actual update-hosting URL, OS-level installer signing
|
||||
(Windows/macOS publisher trust), and the Windows kiosk-browser fallback path.
|
||||
- **Update-hosting endpoint (found broken, fixed 2026-09-03):** the endpoint originally pointed at
|
||||
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)
|
||||
|
||||
@@ -174,7 +224,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
|
||||
`.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**:
|
||||
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** —
|
||||
@@ -198,3 +256,25 @@ The desktop bundle now runs in CI under **two distinct workflows** — keep the
|
||||
The unsigned CI build therefore overrides it off with
|
||||
`--config '{"bundle":{"createUpdaterArtifacts":false}}'` (a JSON patch merged over the config),
|
||||
so no `.sig` is attempted and no key is required. `release.yml` keeps the config default (signs).
|
||||
- **Gotcha (tag ≠ tauri.conf.json version — found + fixed 2026-09-03, v0.1.1).** The git tag
|
||||
(`v0.1.1`) and `tauri.conf.json`'s own `"version"` field are two independent values with nothing
|
||||
syncing them. Tauri bakes `"version"` into the bundle filename, the app's internal version, AND
|
||||
what the updater compares against — NOT the git tag. Bumping only the tag (as the release
|
||||
procedure implied) left the file at the prior `0.1.0`: the signed binary was built and named as
|
||||
`0.1.0`, `latest.json` (built from `TAG`) correctly claimed `0.1.1`, and the updater found an
|
||||
"update," downloaded it, then failed signature verification against a manifest that didn't
|
||||
actually describe the file it pointed at. Compounded by a second bug (below) that made this
|
||||
failure completely invisible to the operator. **Fix:** `release.yml` now has a "Sync
|
||||
tauri.conf.json version to the git tag" step (`sed`-patches `"version"` from `TAG` right before
|
||||
`tauri build`) — the checked-in value is now only a placeholder for local dev builds; every real
|
||||
release derives its version from the tag automatically.
|
||||
- **Gotcha (silent updater failure — found + fixed 2026-09-03).** `desktop-updater.ts`'s
|
||||
`checkForDesktopUpdate` wrapped the ENTIRE check-download-install-relaunch sequence in one
|
||||
`catch {}` that swallowed everything, by design, for the offline/no-server case. But that meant
|
||||
a REAL failure after the operator already accepted the prompt (bad signature, corrupted
|
||||
download, disk/permission error) failed exactly the same way as "endpoint unreachable" — no
|
||||
error, no log, the app just silently reverted to the old version and re-showed the same "update
|
||||
available" prompt on next launch, forever. This is what actually surfaced the tag-sync bug
|
||||
above (download traffic visible, then nothing). Fixed by nesting `downloadAndInstall()` in its
|
||||
own try/catch that logs and rethrows — offline/no-update still no-ops silently (outer catch),
|
||||
but a failure *after* the operator accepted now logs to the console instead of vanishing.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
type: decision
|
||||
tags: [parking, deployment, fleet, komodo, netbird, offline-first, threat-model]
|
||||
sources: []
|
||||
updated: 2026-06-29
|
||||
updated: 2026-07-07
|
||||
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),
|
||||
[[appliance-provisioning]] (what runs *before* Periphery), [[disk-os-hardening]] (the
|
||||
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).
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user