Compare commits
59 Commits
a2e102f3dd
..
v0.1.6
| Author | SHA1 | Date | |
|---|---|---|---|
| 8fa66c9911 | |||
| 70e1e9939f | |||
| 5c6a21e2c3 | |||
| 969bf2b191 | |||
| 7d67934a10 | |||
| 56904422af | |||
| 8bcdea9e4a | |||
| 7804285dec | |||
| 4a7029cea6 | |||
| 7317042e8d | |||
| 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 |
@@ -92,6 +92,8 @@ jobs:
|
|||||||
context: .
|
context: .
|
||||||
file: apps/server/Dockerfile
|
file: apps/server/Dockerfile
|
||||||
push: true
|
push: true
|
||||||
|
build-args: |
|
||||||
|
BUILD_VERSION=${{ steps.meta.outputs.branch }}-${{ steps.meta.outputs.sha }}
|
||||||
tags: |
|
tags: |
|
||||||
${{ env.REGISTRY }}/parking-server:${{ steps.meta.outputs.branch }}
|
${{ env.REGISTRY }}/parking-server:${{ steps.meta.outputs.branch }}
|
||||||
${{ env.REGISTRY }}/parking-server:${{ steps.meta.outputs.branch }}-${{ steps.meta.outputs.sha }}
|
${{ env.REGISTRY }}/parking-server:${{ steps.meta.outputs.branch }}-${{ steps.meta.outputs.sha }}
|
||||||
|
|||||||
+135
-12
@@ -1,12 +1,24 @@
|
|||||||
name: Release desktop
|
name: Release desktop
|
||||||
|
|
||||||
# Build the signed Tauri desktop installers on a version tag and publish them as
|
# Build the signed Tauri desktop installers on a version tag and publish them as
|
||||||
# a Gitea Release. The Tauri auto-updater (apps/web/src/lib/desktop-updater.ts)
|
# a Gitea Release — TWICE: once on this (private, source) repo for our own
|
||||||
# fetches these; latest.json + each installer + its .sig are what it needs.
|
# records/history, and once mirrored to mca/public_releases, which is what the
|
||||||
|
# Tauri auto-updater (apps/web/src/lib/desktop-updater.ts) actually points at.
|
||||||
|
#
|
||||||
|
# WHY a separate public repo: the updater runs on offline-first field appliances
|
||||||
|
# with no Gitea credentials, so its endpoint + installer downloads must be
|
||||||
|
# reachable unauthenticated. Mirroring compiled installers to a public
|
||||||
|
# releases-only repo avoids embedding any read token in the shipped app (which
|
||||||
|
# would leak the moment a booth PC is compromised — this box's threat model
|
||||||
|
# names the operator/booth as the primary adversary, see CLAUDE.md). Source
|
||||||
|
# stays private; only signed installers become public, same as most desktop
|
||||||
|
# software. mca/public_releases is shared across apps in the org, not
|
||||||
|
# parking-specific — namespace release tags/asset names accordingly if another
|
||||||
|
# app starts publishing there too.
|
||||||
#
|
#
|
||||||
# Trigger: push a tag like v0.1.0. The job builds .deb/.rpm/.AppImage, signs them
|
# Trigger: push a tag like v0.1.0. The job builds .deb/.rpm/.AppImage, signs them
|
||||||
# with the updater key (Gitea secrets), assembles latest.json, and uploads
|
# with the updater key (Gitea secrets), assembles latest.json pointing at the
|
||||||
# everything to the Release for that tag.
|
# MIRROR repo's asset URLs, uploads to both repos, and mirrors the same assets.
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -63,6 +75,27 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
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
|
- name: Build + sign desktop bundle
|
||||||
env:
|
env:
|
||||||
# Updater signing key (Gitea repo/org secrets). Without these the
|
# Updater signing key (Gitea repo/org secrets). Without these the
|
||||||
@@ -73,30 +106,41 @@ jobs:
|
|||||||
|
|
||||||
- name: Collect artifacts
|
- name: Collect artifacts
|
||||||
id: collect
|
id: collect
|
||||||
# Gather the installers + their .sig into a flat dist/ for upload.
|
# Gather the installers + their .sig into a flat dist/ for upload, spaces
|
||||||
|
# stripped from filenames. productName is "Parking System" (a space), so
|
||||||
|
# Tauri's bundle output is e.g. "Parking System_0.1.0_amd64.deb" — an
|
||||||
|
# unescaped space in a filename breaks the later curl asset-upload URL
|
||||||
|
# ("URL rejected: Malformed input to a URL function", hit on the very
|
||||||
|
# first v0.1.0 release) AND would land in latest.json's asset url, which
|
||||||
|
# the updater's plain HTTP GET can't handle either. Rename on copy.
|
||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
BUNDLE=apps/desktop/src-tauri/target/release/bundle
|
BUNDLE=apps/desktop/src-tauri/target/release/bundle
|
||||||
mkdir -p dist
|
mkdir -p dist
|
||||||
find "$BUNDLE" \( -name '*.AppImage' -o -name '*.deb' -o -name '*.rpm' \
|
find "$BUNDLE" \( -name '*.AppImage' -o -name '*.deb' -o -name '*.rpm' \
|
||||||
-o -name '*.AppImage.sig' -o -name '*.deb.sig' -o -name '*.rpm.sig' \) \
|
-o -name '*.AppImage.sig' -o -name '*.deb.sig' -o -name '*.rpm.sig' \) \
|
||||||
-exec cp {} dist/ \;
|
-print0 | while IFS= read -r -d '' f; do
|
||||||
|
name=$(basename "$f" | tr ' ' '-')
|
||||||
|
cp "$f" "dist/${name}"
|
||||||
|
done
|
||||||
echo "Artifacts:"; ls -la dist/
|
echo "Artifacts:"; ls -la dist/
|
||||||
|
|
||||||
- name: Assemble latest.json
|
- name: Assemble latest.json
|
||||||
# The Tauri updater fetches a manifest describing the newest version, its
|
# The Tauri updater fetches a manifest describing the newest version, its
|
||||||
# notes, and per-target {signature, url}. We point the AppImage target at
|
# notes, and per-target {signature, url}. The URL points at the MIRROR
|
||||||
# this release's asset URL. Adjust the platform keys you actually ship.
|
# repo (mca/public_releases) — that's the unauthenticated endpoint field
|
||||||
|
# appliances actually reach; see the workflow header for why. Adjust the
|
||||||
|
# platform keys you actually ship.
|
||||||
env:
|
env:
|
||||||
SERVER_URL: ${{ github.server_url }}
|
SERVER_URL: ${{ github.server_url }}
|
||||||
REPO: ${{ github.repository }}
|
MIRROR_REPO: mca/public_releases
|
||||||
TAG: ${{ github.ref_name }}
|
TAG: ${{ github.ref_name }}
|
||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
VERSION="${TAG#v}"
|
VERSION="${TAG#v}"
|
||||||
APPIMAGE=$(cd dist && ls *.AppImage | head -1)
|
APPIMAGE=$(cd dist && ls *.AppImage | head -1)
|
||||||
SIG=$(cat "dist/${APPIMAGE}.sig")
|
SIG=$(cat "dist/${APPIMAGE}.sig")
|
||||||
ASSET_URL="${SERVER_URL}/${REPO}/releases/download/${TAG}/${APPIMAGE}"
|
ASSET_URL="${SERVER_URL}/${MIRROR_REPO}/releases/download/desktop-latest/${APPIMAGE}"
|
||||||
cat > dist/latest.json <<JSON
|
cat > dist/latest.json <<JSON
|
||||||
{
|
{
|
||||||
"version": "${VERSION}",
|
"version": "${VERSION}",
|
||||||
@@ -129,12 +173,12 @@ jobs:
|
|||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d "{\"tag_name\":\"${TAG}\",\"name\":\"${TAG}\",\"draft\":false,\"prerelease\":false}" \
|
-d "{\"tag_name\":\"${TAG}\",\"name\":\"${TAG}\",\"draft\":false,\"prerelease\":false}" \
|
||||||
"${API}/repos/${REPO}/releases" || true)
|
"${API}/repos/${REPO}/releases" || true)
|
||||||
REL_ID=$(printf '%s' "$REL" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2)
|
REL_ID=$(printf '%s' "$REL" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2 || true)
|
||||||
if [ -z "$REL_ID" ]; then
|
if [ -z "$REL_ID" ]; then
|
||||||
# Release may already exist for this tag — look it up by tag.
|
# Release may already exist for this tag — look it up by tag.
|
||||||
REL_ID=$(curl -sS -H "Authorization: token ${TOKEN}" \
|
REL_ID=$(curl -sS -H "Authorization: token ${TOKEN}" \
|
||||||
"${API}/repos/${REPO}/releases/tags/${TAG}" \
|
"${API}/repos/${REPO}/releases/tags/${TAG}" \
|
||||||
| grep -o '"id":[0-9]*' | head -1 | cut -d: -f2)
|
| grep -o '"id":[0-9]*' | head -1 | cut -d: -f2 || true)
|
||||||
fi
|
fi
|
||||||
echo "release id: ${REL_ID}"
|
echo "release id: ${REL_ID}"
|
||||||
for f in dist/*; do
|
for f in dist/*; do
|
||||||
@@ -147,3 +191,82 @@ jobs:
|
|||||||
"${API}/repos/${REPO}/releases/${REL_ID}/assets?name=${name}" >/dev/null
|
"${API}/repos/${REPO}/releases/${REL_ID}/assets?name=${name}" >/dev/null
|
||||||
done
|
done
|
||||||
echo "done"
|
echo "done"
|
||||||
|
|
||||||
|
- name: Mirror release to mca/public_releases (Gitea API)
|
||||||
|
# This is the release the updater and any human downloader actually use —
|
||||||
|
# public_releases has no source, only installers, so it can be public
|
||||||
|
# without exposing this repo. RELEASES_MIRROR_TOKEN is a write:repository
|
||||||
|
# token scoped for pushing releases into that repo (Gitea's org secrets,
|
||||||
|
# not exposed to any deployed client).
|
||||||
|
#
|
||||||
|
# Publishes to TWO tags there, since public_releases is shared across
|
||||||
|
# apps in the org and Gitea's "latest release" redirect resolves by
|
||||||
|
# newest tag on the WHOLE repo (would break the moment another app
|
||||||
|
# publishes something newer):
|
||||||
|
# - desktop-<TAG> versioned, permanent — audit trail / rollback.
|
||||||
|
# - desktop-latest moving — assets deleted + re-uploaded each release.
|
||||||
|
# This is the fixed URL tauri.conf.json's updater endpoint points at
|
||||||
|
# (a stable name every appliance can always resolve, regardless of
|
||||||
|
# what else gets released in this repo meanwhile).
|
||||||
|
env:
|
||||||
|
TOKEN: ${{ secrets.RELEASES_MIRROR_TOKEN }}
|
||||||
|
API: ${{ github.api_url }}
|
||||||
|
MIRROR_REPO: mca/public_releases
|
||||||
|
TAG: ${{ github.ref_name }}
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
create_or_get_release() {
|
||||||
|
local mirror_tag="$1" prerelease="$2"
|
||||||
|
REL=$(curl -sS -w '\n%{http_code}' -X POST \
|
||||||
|
-H "Authorization: token ${TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"tag_name\":\"${mirror_tag}\",\"name\":\"Parking System ${TAG}\",\"draft\":false,\"prerelease\":${prerelease}}" \
|
||||||
|
"${API}/repos/${MIRROR_REPO}/releases" || true)
|
||||||
|
echo "create response (${mirror_tag}): ${REL}"
|
||||||
|
REL_ID=$(printf '%s' "$REL" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2 || true)
|
||||||
|
if [ -z "$REL_ID" ]; then
|
||||||
|
LOOKUP=$(curl -sS -w '\n%{http_code}' -H "Authorization: token ${TOKEN}" \
|
||||||
|
"${API}/repos/${MIRROR_REPO}/releases/tags/${mirror_tag}")
|
||||||
|
echo "tag lookup response (${mirror_tag}): ${LOOKUP}"
|
||||||
|
REL_ID=$(printf '%s' "$LOOKUP" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2 || true)
|
||||||
|
fi
|
||||||
|
if [ -z "$REL_ID" ]; then
|
||||||
|
echo "::error::could not create or find release for tag ${mirror_tag} on ${MIRROR_REPO} — see responses above"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
upload_assets() {
|
||||||
|
local rel_id="$1"
|
||||||
|
for f in dist/*; do
|
||||||
|
name=$(basename "$f")
|
||||||
|
echo "mirroring ${name} -> release ${rel_id}"
|
||||||
|
HTTP_CODE=$(curl -sS -o /tmp/upload_resp.json -w '%{http_code}' -X POST \
|
||||||
|
-H "Authorization: token ${TOKEN}" \
|
||||||
|
-H "Content-Type: application/octet-stream" \
|
||||||
|
--data-binary @"${f}" \
|
||||||
|
"${API}/repos/${MIRROR_REPO}/releases/${rel_id}/assets?name=${name}")
|
||||||
|
if [ "$HTTP_CODE" -ge 300 ]; then
|
||||||
|
echo "::error::upload of ${name} failed (HTTP ${HTTP_CODE}): $(cat /tmp/upload_resp.json)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# 1. Versioned, permanent.
|
||||||
|
create_or_get_release "desktop-${TAG}" false
|
||||||
|
echo "versioned mirror release id: ${REL_ID}"
|
||||||
|
upload_assets "${REL_ID}"
|
||||||
|
|
||||||
|
# 2. Moving desktop-latest — delete existing assets first (re-upload
|
||||||
|
# with the same name 409s otherwise), then re-upload.
|
||||||
|
create_or_get_release "desktop-latest" false
|
||||||
|
LATEST_REL_ID="${REL_ID}"
|
||||||
|
echo "latest mirror release id: ${LATEST_REL_ID}"
|
||||||
|
EXISTING=$(curl -sS -H "Authorization: token ${TOKEN}" \
|
||||||
|
"${API}/repos/${MIRROR_REPO}/releases/${LATEST_REL_ID}/assets")
|
||||||
|
printf '%s' "$EXISTING" | grep -o '"id":[0-9]*' | cut -d: -f2 | while read -r asset_id; do
|
||||||
|
curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \
|
||||||
|
"${API}/repos/${MIRROR_REPO}/releases/${LATEST_REL_ID}/assets/${asset_id}" >/dev/null
|
||||||
|
done || true
|
||||||
|
upload_assets "${LATEST_REL_ID}"
|
||||||
|
echo "done"
|
||||||
|
|||||||
@@ -27,3 +27,4 @@ dist/
|
|||||||
# Graphify knowledge-graph output (dev tool; generated, not committed)
|
# Graphify knowledge-graph output (dev tool; generated, not committed)
|
||||||
graphify-out/
|
graphify-out/
|
||||||
parking.sqlite*.bak-*
|
parking.sqlite*.bak-*
|
||||||
|
questions.txt
|
||||||
|
|||||||
+32
-3
@@ -35,8 +35,37 @@ pnpm --filter @parking/desktop bundle # build the SPA + bundle the desktop app
|
|||||||
Requires the Rust toolchain and (on Linux) WebKitGTK 4.1 + libsoup-3 dev libraries. Under WSL2 the
|
Requires the Rust toolchain and (on Linux) WebKitGTK 4.1 + libsoup-3 dev libraries. Under WSL2 the
|
||||||
window needs a display (WSLg or an X server).
|
window needs a display (WSLg or an X server).
|
||||||
|
|
||||||
|
## Auto-update
|
||||||
|
|
||||||
|
Signed updates are built and published by `.gitea/workflows/release.yml` on a `vX.Y.Z` tag, mirrored
|
||||||
|
to the public `mca/public_releases` repo (this repo is private; the updater runs on offline-first
|
||||||
|
field appliances with no Gitea credentials, so its endpoint must be reachable unauthenticated —
|
||||||
|
see that workflow's header and `wiki/decisions/desktop-shell-tauri.md`). The updater config and
|
||||||
|
signing pubkey live in `tauri.conf.json`; the private signing key is held outside the repo, never
|
||||||
|
committed.
|
||||||
|
|
||||||
|
## Release gate — run the REAL bundle locally before tagging
|
||||||
|
|
||||||
|
`tauri dev` loads the SPA from `http://localhost:5173`, a plain http origin. The shipped bundle
|
||||||
|
loads it from `tauri://localhost`, a *secure* custom-scheme origin — and every desktop-only bug
|
||||||
|
found in the field on 2026-09-03/04 (relative-URL DOMException, mixed content, missing WS
|
||||||
|
`Origin`, the reqwest-vs-webview cookie split, the WS handshake that can't carry the cookie)
|
||||||
|
depends on that difference. **Dev mode cannot reproduce any of them**, so "works in `tauri dev`"
|
||||||
|
carries no information about a release. Before pushing a `vX.Y.Z` tag:
|
||||||
|
|
||||||
|
1. `pnpm --filter @parking/server dev` (local backend; `.env` must have `COOKIE_SECURE=0` and
|
||||||
|
`tauri://localhost` in `WS_ALLOWED_ORIGINS`).
|
||||||
|
2. `pnpm --filter @parking/desktop bundle` and run the produced AppImage from
|
||||||
|
`src-tauri/target/release/bundle/appimage/` (WSLg is enough).
|
||||||
|
3. On the ConnectScreen enter `127.0.0.1:3000`, **Test** must say reachable, then **Save**.
|
||||||
|
4. Log in. The booth header must show **LIVE** (not "JASHTË LINJË") within a few seconds.
|
||||||
|
5. Perform one mutation (e.g. change your UI language) — it must succeed (proves CSRF).
|
||||||
|
6. Open Setup → Logs and confirm a `frontend`-sourced row from this desktop session exists
|
||||||
|
(proves the desktop log channel; historically it was silently 403'd).
|
||||||
|
|
||||||
|
Only then tag. If a release still fails in the field, the gap is in this list — fix the list.
|
||||||
|
|
||||||
## Not here (deliberately)
|
## Not here (deliberately)
|
||||||
|
|
||||||
Kiosk lockdown (fullscreen/no-decorations), auto-update, code signing, and launching Fastify from
|
Kiosk lockdown (fullscreen/no-decorations) and launching Fastify from the shell are out of scope for
|
||||||
the shell are out of scope for the scaffold — on the appliance Fastify runs as its own service and
|
the scaffold — on the appliance Fastify runs as its own service and this shell connects to it.
|
||||||
this shell connects to it.
|
|
||||||
|
|||||||
Generated
+578
-8
@@ -318,6 +318,23 @@ version = "1.0.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
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]]
|
[[package]]
|
||||||
name = "chrono"
|
name = "chrono"
|
||||||
version = "0.4.45"
|
version = "0.4.45"
|
||||||
@@ -346,10 +363,39 @@ version = "0.18.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747"
|
checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"percent-encoding",
|
||||||
"time",
|
"time",
|
||||||
"version_check",
|
"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]]
|
[[package]]
|
||||||
name = "core-foundation"
|
name = "core-foundation"
|
||||||
version = "0.10.1"
|
version = "0.10.1"
|
||||||
@@ -373,7 +419,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97"
|
checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.0",
|
"bitflags 2.13.0",
|
||||||
"core-foundation",
|
"core-foundation 0.10.1",
|
||||||
"core-graphics-types",
|
"core-graphics-types",
|
||||||
"foreign-types",
|
"foreign-types",
|
||||||
"libc",
|
"libc",
|
||||||
@@ -386,7 +432,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
|
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.0",
|
"bitflags 2.13.0",
|
||||||
"core-foundation",
|
"core-foundation 0.10.1",
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -399,6 +445,15 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cpufeatures"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crc32fast"
|
name = "crc32fast"
|
||||||
version = "1.5.0"
|
version = "1.5.0"
|
||||||
@@ -506,6 +561,18 @@ dependencies = [
|
|||||||
"syn 2.0.118",
|
"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]]
|
[[package]]
|
||||||
name = "dbus"
|
name = "dbus"
|
||||||
version = "0.9.11"
|
version = "0.9.11"
|
||||||
@@ -635,6 +702,15 @@ dependencies = [
|
|||||||
"syn 2.0.118",
|
"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]]
|
[[package]]
|
||||||
name = "dom_query"
|
name = "dom_query"
|
||||||
version = "0.27.0"
|
version = "0.27.0"
|
||||||
@@ -721,6 +797,15 @@ version = "1.2.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
|
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]]
|
[[package]]
|
||||||
name = "equivalent"
|
name = "equivalent"
|
||||||
version = "1.0.2"
|
version = "1.0.2"
|
||||||
@@ -1034,8 +1119,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
|
"js-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"wasi",
|
"wasi",
|
||||||
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1057,8 +1144,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
|
"js-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"r-efi 6.0.0",
|
"r-efi 6.0.0",
|
||||||
|
"rand_core 0.10.1",
|
||||||
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1209,6 +1299,25 @@ dependencies = [
|
|||||||
"syn 2.0.118",
|
"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]]
|
[[package]]
|
||||||
name = "hashbrown"
|
name = "hashbrown"
|
||||||
version = "0.12.3"
|
version = "0.12.3"
|
||||||
@@ -1298,6 +1407,7 @@ dependencies = [
|
|||||||
"bytes",
|
"bytes",
|
||||||
"futures-channel",
|
"futures-channel",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
|
"h2",
|
||||||
"http",
|
"http",
|
||||||
"http-body",
|
"http-body",
|
||||||
"httparse",
|
"httparse",
|
||||||
@@ -1321,6 +1431,7 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
"tokio-rustls",
|
"tokio-rustls",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
|
"webpki-roots 1.0.9",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1341,9 +1452,11 @@ dependencies = [
|
|||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"socket2",
|
"socket2",
|
||||||
|
"system-configuration",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
"windows-registry",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1744,6 +1857,12 @@ version = "0.8.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
|
checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "litrs"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lock_api"
|
name = "lock_api"
|
||||||
version = "0.4.14"
|
version = "0.4.14"
|
||||||
@@ -1759,6 +1878,12 @@ version = "0.4.33"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lru-slab"
|
||||||
|
version = "0.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "markup5ever"
|
name = "markup5ever"
|
||||||
version = "0.38.0"
|
version = "0.38.0"
|
||||||
@@ -2178,8 +2303,11 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"tauri",
|
"tauri",
|
||||||
"tauri-build",
|
"tauri-build",
|
||||||
|
"tauri-plugin-http",
|
||||||
"tauri-plugin-process",
|
"tauri-plugin-process",
|
||||||
|
"tauri-plugin-store",
|
||||||
"tauri-plugin-updater",
|
"tauri-plugin-updater",
|
||||||
|
"tauri-plugin-websocket",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2330,6 +2458,15 @@ version = "0.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
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]]
|
[[package]]
|
||||||
name = "precomputed-hash"
|
name = "precomputed-hash"
|
||||||
version = "0.1.1"
|
version = "0.1.1"
|
||||||
@@ -2398,6 +2535,22 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"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]]
|
[[package]]
|
||||||
name = "quick-xml"
|
name = "quick-xml"
|
||||||
version = "0.39.4"
|
version = "0.39.4"
|
||||||
@@ -2407,6 +2560,62 @@ dependencies = [
|
|||||||
"memchr",
|
"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]]
|
[[package]]
|
||||||
name = "quote"
|
name = "quote"
|
||||||
version = "1.0.45"
|
version = "1.0.45"
|
||||||
@@ -2428,6 +2637,61 @@ version = "6.0.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
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]]
|
[[package]]
|
||||||
name = "raw-window-handle"
|
name = "raw-window-handle"
|
||||||
version = "0.6.2"
|
version = "0.6.2"
|
||||||
@@ -2503,6 +2767,49 @@ version = "0.8.11"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
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]]
|
[[package]]
|
||||||
name = "reqwest"
|
name = "reqwest"
|
||||||
version = "0.13.4"
|
version = "0.13.4"
|
||||||
@@ -2616,6 +2923,7 @@ version = "1.14.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
|
checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"web-time",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2625,7 +2933,7 @@ version = "0.7.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0"
|
checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"core-foundation",
|
"core-foundation 0.10.1",
|
||||||
"core-foundation-sys",
|
"core-foundation-sys",
|
||||||
"jni 0.22.4",
|
"jni 0.22.4",
|
||||||
"log",
|
"log",
|
||||||
@@ -2663,6 +2971,12 @@ version = "1.0.22"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ryu"
|
||||||
|
version = "1.0.23"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "same-file"
|
name = "same-file"
|
||||||
version = "1.0.6"
|
version = "1.0.6"
|
||||||
@@ -2745,7 +3059,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
|
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.0",
|
"bitflags 2.13.0",
|
||||||
"core-foundation",
|
"core-foundation 0.10.1",
|
||||||
"core-foundation-sys",
|
"core-foundation-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"security-framework-sys",
|
"security-framework-sys",
|
||||||
@@ -2885,6 +3199,18 @@ dependencies = [
|
|||||||
"serde_core",
|
"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]]
|
[[package]]
|
||||||
name = "serde_with"
|
name = "serde_with"
|
||||||
version = "3.21.0"
|
version = "3.21.0"
|
||||||
@@ -2948,6 +3274,17 @@ dependencies = [
|
|||||||
"stable_deref_trait",
|
"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]]
|
[[package]]
|
||||||
name = "sha2"
|
name = "sha2"
|
||||||
version = "0.10.9"
|
version = "0.10.9"
|
||||||
@@ -2955,7 +3292,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"cpufeatures",
|
"cpufeatures 0.2.17",
|
||||||
"digest",
|
"digest",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -3137,6 +3474,17 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"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]]
|
[[package]]
|
||||||
name = "sync_wrapper"
|
name = "sync_wrapper"
|
||||||
version = "1.0.2"
|
version = "1.0.2"
|
||||||
@@ -3157,6 +3505,27 @@ dependencies = [
|
|||||||
"syn 2.0.118",
|
"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]]
|
[[package]]
|
||||||
name = "system-deps"
|
name = "system-deps"
|
||||||
version = "6.2.2"
|
version = "6.2.2"
|
||||||
@@ -3178,7 +3547,7 @@ checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.0",
|
"bitflags 2.13.0",
|
||||||
"block2",
|
"block2",
|
||||||
"core-foundation",
|
"core-foundation 0.10.1",
|
||||||
"core-graphics",
|
"core-graphics",
|
||||||
"crossbeam-channel",
|
"crossbeam-channel",
|
||||||
"dbus",
|
"dbus",
|
||||||
@@ -3268,7 +3637,7 @@ dependencies = [
|
|||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"plist",
|
"plist",
|
||||||
"raw-window-handle",
|
"raw-window-handle",
|
||||||
"reqwest",
|
"reqwest 0.13.4",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_repr",
|
"serde_repr",
|
||||||
@@ -3367,6 +3736,54 @@ dependencies = [
|
|||||||
"walkdir",
|
"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]]
|
[[package]]
|
||||||
name = "tauri-plugin-process"
|
name = "tauri-plugin-process"
|
||||||
version = "2.3.1"
|
version = "2.3.1"
|
||||||
@@ -3377,6 +3794,22 @@ dependencies = [
|
|||||||
"tauri-plugin",
|
"tauri-plugin",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tauri-plugin-store"
|
||||||
|
version = "2.4.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6708afbe549f176b712066e71648ba8fafba20789453718260c7ca356733cb0c"
|
||||||
|
dependencies = [
|
||||||
|
"dunce",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"tauri",
|
||||||
|
"tauri-plugin",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"tokio",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri-plugin-updater"
|
name = "tauri-plugin-updater"
|
||||||
version = "2.10.1"
|
version = "2.10.1"
|
||||||
@@ -3393,7 +3826,7 @@ dependencies = [
|
|||||||
"minisign-verify",
|
"minisign-verify",
|
||||||
"osakit",
|
"osakit",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"reqwest",
|
"reqwest 0.13.4",
|
||||||
"rustls",
|
"rustls",
|
||||||
"semver",
|
"semver",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -3410,6 +3843,26 @@ dependencies = [
|
|||||||
"zip",
|
"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]]
|
[[package]]
|
||||||
name = "tauri-runtime"
|
name = "tauri-runtime"
|
||||||
version = "2.11.3"
|
version = "2.11.3"
|
||||||
@@ -3639,9 +4092,21 @@ dependencies = [
|
|||||||
"mio",
|
"mio",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"socket2",
|
"socket2",
|
||||||
|
"tokio-macros",
|
||||||
"windows-sys 0.61.2",
|
"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]]
|
[[package]]
|
||||||
name = "tokio-rustls"
|
name = "tokio-rustls"
|
||||||
version = "0.26.4"
|
version = "0.26.4"
|
||||||
@@ -3652,6 +4117,22 @@ dependencies = [
|
|||||||
"tokio",
|
"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]]
|
[[package]]
|
||||||
name = "tokio-util"
|
name = "tokio-util"
|
||||||
version = "0.7.18"
|
version = "0.7.18"
|
||||||
@@ -3837,9 +4318,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
|
"tracing-attributes",
|
||||||
"tracing-core",
|
"tracing-core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tracing-attributes"
|
||||||
|
version = "0.1.31"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.118",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tracing-core"
|
name = "tracing-core"
|
||||||
version = "0.1.36"
|
version = "0.1.36"
|
||||||
@@ -3877,6 +4370,24 @@ version = "0.2.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
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]]
|
[[package]]
|
||||||
name = "typeid"
|
name = "typeid"
|
||||||
version = "1.0.3"
|
version = "1.0.3"
|
||||||
@@ -4141,6 +4652,16 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"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]]
|
[[package]]
|
||||||
name = "web_atoms"
|
name = "web_atoms"
|
||||||
version = "0.2.5"
|
version = "0.2.5"
|
||||||
@@ -4206,6 +4727,24 @@ dependencies = [
|
|||||||
"rustls-pki-types",
|
"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]]
|
[[package]]
|
||||||
name = "webview2-com"
|
name = "webview2-com"
|
||||||
version = "0.38.2"
|
version = "0.38.2"
|
||||||
@@ -4391,6 +4930,17 @@ dependencies = [
|
|||||||
"windows-link 0.1.3",
|
"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]]
|
[[package]]
|
||||||
name = "windows-result"
|
name = "windows-result"
|
||||||
version = "0.3.4"
|
version = "0.3.4"
|
||||||
@@ -4820,6 +5370,26 @@ dependencies = [
|
|||||||
"synstructure",
|
"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]]
|
[[package]]
|
||||||
name = "zerofrom"
|
name = "zerofrom"
|
||||||
version = "0.1.8"
|
version = "0.1.8"
|
||||||
|
|||||||
@@ -22,6 +22,21 @@ serde_json = "1"
|
|||||||
# Auto-update: prompt the operator, download a signed update, relaunch.
|
# Auto-update: prompt the operator, download a signed update, relaunch.
|
||||||
tauri-plugin-updater = "2"
|
tauri-plugin-updater = "2"
|
||||||
tauri-plugin-process = "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"
|
||||||
|
# Persists the operator-configured backend URL (host:port of the Fastify
|
||||||
|
# server this install talks to) across restarts. Read before any API call —
|
||||||
|
# see apps/web/src/lib/backend-config.ts.
|
||||||
|
tauri-plugin-store = "2"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
# Used by `tauri dev`/CLI for hot-reload of the Rust side.
|
# Used by `tauri dev`/CLI for hot-reload of the Rust side.
|
||||||
|
|||||||
@@ -6,6 +6,18 @@
|
|||||||
"permissions": [
|
"permissions": [
|
||||||
"core:default",
|
"core:default",
|
||||||
"updater:default",
|
"updater:default",
|
||||||
"process:default"
|
"process:default",
|
||||||
|
"websocket:default",
|
||||||
|
"store:default",
|
||||||
|
{
|
||||||
|
"identifier": "http:default",
|
||||||
|
"//": "Backend address is operator-configured at runtime (backend-config.ts) so the exact host:port can't be allow-listed at build time. Wildcarded to any host — the CSP forces ALL backend traffic through this plugin (see tauri.conf.json), so this scope is the real boundary; a compromised/malicious page still can't reach anything the operator hasn't pointed the app at, since the app only ever calls the one configured origin. All 4 forms needed: a known Tauri scope-matching quirk drops http://*:PORT unless both bare and :* variants are listed.",
|
||||||
|
"allow": [
|
||||||
|
{ "url": "http://*" },
|
||||||
|
{ "url": "https://*" },
|
||||||
|
{ "url": "http://*:*" },
|
||||||
|
{ "url": "https://*:*" }
|
||||||
|
]
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,10 @@
|
|||||||
// Intentionally minimal: build the default Tauri app and run it. The window
|
// Intentionally minimal: build the default Tauri app and run it. The window
|
||||||
// config (kiosk, fullscreen, which URL/assets to load) lives in tauri.conf.json.
|
// config (kiosk, fullscreen, which URL/assets to load) lives in tauri.conf.json.
|
||||||
// No custom commands are registered — the renderer (the @parking/web SPA) reaches
|
// No custom commands are registered — the renderer (the @parking/web SPA) reaches
|
||||||
// the backend over HTTP to the local Fastify server, NOT through Tauri IPC. This
|
// the backend over HTTP to a Fastify server (address operator-configured at
|
||||||
// keeps the shell a thin presentation wrapper with a deny-by-default native
|
// runtime, not baked in — see apps/web/src/lib/backend-config.ts), NOT through
|
||||||
// surface (see wiki/decisions/desktop-shell-tauri.md).
|
// Tauri IPC. This keeps the shell a thin presentation wrapper with a
|
||||||
|
// deny-by-default native surface (see wiki/decisions/desktop-shell-tauri.md).
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
@@ -16,6 +17,16 @@ pub fn run() {
|
|||||||
// endpoint + signing pubkey live in tauri.conf.json.
|
// endpoint + signing pubkey live in tauri.conf.json.
|
||||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||||
.plugin(tauri_plugin_process::init())
|
.plugin(tauri_plugin_process::init())
|
||||||
|
// Routes the SPA's fetch()/WS calls to the operator-configured Fastify
|
||||||
|
// server through Tauri's native HTTP client — see the Cargo.toml
|
||||||
|
// comment on why the webview's own fetch() can't reach it 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())
|
||||||
|
// Persists the operator-configured backend URL across restarts (JSON
|
||||||
|
// file in the app's config dir) — see backend-config.ts.
|
||||||
|
.plugin(tauri_plugin_store::Builder::new().build())
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running the Parking System desktop shell");
|
.expect("error while running the Parking System desktop shell");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "Parking System",
|
"productName": "Parking System",
|
||||||
"version": "0.0.0",
|
"version": "0.1.0",
|
||||||
"identifier": "com.parking.desktop",
|
"identifier": "com.parking.desktop",
|
||||||
"build": {
|
"build": {
|
||||||
"devUrl": "http://localhost:5173",
|
"devUrl": "http://localhost:5173",
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"security": {
|
"security": {
|
||||||
"csp": "default-src 'self'; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; connect-src 'self' http://127.0.0.1:3000 http://localhost:3000 ws://127.0.0.1:3000 ws://localhost:3000"
|
"csp": "default-src 'self'; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; connect-src 'self'"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"bundle": {
|
"bundle": {
|
||||||
@@ -41,9 +41,9 @@
|
|||||||
},
|
},
|
||||||
"plugins": {
|
"plugins": {
|
||||||
"updater": {
|
"updater": {
|
||||||
"//": "Stable 'latest release' path on Gitea — redirects to the newest tag's latest.json (published by .gitea/workflows/release.yml). The updater GETs this, gets the manifest (platforms.linux-x86_64.{signature,url}), and compares versions. The release is reachable to the appliance only when it's brought online (phone hotspot); offline-first means a failed check is a no-op.",
|
"//": "Points at mca/public_releases, NOT this (private, source) repo — the updater runs on offline-first field appliances with no Gitea credentials, so the endpoint must be reachable unauthenticated. That repo is public and holds only compiled installers (no source), mirrored here by .gitea/workflows/release.yml. NOT the 'latest release' redirect: public_releases is shared across apps in the org, so 'latest' there could be someone else's release. This URL names our own most-recent tag directly (desktop-vX.Y.Z, bumped by the release workflow each publish) so a newer unrelated app release never shadows ours. The updater GETs this, gets the manifest (platforms.linux-x86_64.{signature,url}), and compares versions. The release is reachable to the appliance only when it's brought online (phone hotspot); offline-first means a failed check is a no-op.",
|
||||||
"endpoints": [
|
"endpoints": [
|
||||||
"https://git.infra.msai.al/mca/parking_solution/releases/latest/download/latest.json"
|
"https://git.infra.msai.al/mca/public_releases/releases/download/desktop-latest/latest.json"
|
||||||
],
|
],
|
||||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDgxNzg5RUQ1QkM0Q0FDRjYKUldUMnJFeTgxWjU0Z1RlNmhneDVZQlVVTVZZdGhJTkUxTGdDeGYwQSttZmNKVVp5WEdVMWlBb1YK"
|
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDgxNzg5RUQ1QkM0Q0FDRjYKUldUMnJFeTgxWjU0Z1RlNmhneDVZQlVVTVZZdGhJTkUxTGdDeGYwQSttZmNKVVp5WEdVMWlBb1YK"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,11 @@ RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
|
|||||||
# ---- runtime: slim, non-root ----
|
# ---- runtime: slim, non-root ----
|
||||||
FROM node:22-alpine AS runtime
|
FROM node:22-alpine AS runtime
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
# Set by CI to "<branch>-<short-sha>" (e.g. "stage-28bd838"), matching the same string used
|
||||||
|
# as the Komodo Stack's TAG (komodo/resources.toml) — so the version shown in the app is the
|
||||||
|
# same string an admin would look up there. Empty/absent on a local `docker build` (dev only).
|
||||||
|
ARG BUILD_VERSION=""
|
||||||
|
ENV BUILD_VERSION=$BUILD_VERSION
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
RUN apk add --no-cache libstdc++ # better-sqlite3 native runtime
|
RUN apk add --no-cache libstdc++ # better-sqlite3 native runtime
|
||||||
RUN addgroup -S app && adduser -S -G app app
|
RUN addgroup -S app && adduser -S -G app app
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { createRequire } from "node:module";
|
|||||||
|
|
||||||
const require = createRequire(import.meta.url);
|
const require = createRequire(import.meta.url);
|
||||||
const bcrypt = require("bcrypt");
|
const bcrypt = require("bcrypt");
|
||||||
const { createDb, users, eq } = require("@parking/db");
|
const { createDb, users, roles, eq } = require("@parking/db");
|
||||||
|
|
||||||
const DEFAULT_USERNAME = "admin";
|
const DEFAULT_USERNAME = "admin";
|
||||||
|
|
||||||
@@ -53,6 +53,14 @@ if (!password || password.length < 8) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const db = createDb();
|
const db = createDb();
|
||||||
|
|
||||||
|
// Self-heal the built-in `admin` ROLE row. Migration 0007 seeds it once, but the
|
||||||
|
// training reset (reset-db.mjs --users/--all) wipes the roles table and points here
|
||||||
|
// to re-seed — without this, the user insert dies on the role_id FOREIGN KEY (field
|
||||||
|
// failure 2026-07-06). The admin permission SET is resolved in code (auth.ts), so
|
||||||
|
// the row alone is all the FK needs.
|
||||||
|
await db.insert(roles).values({ id: "admin", name: "Admin", builtin: 1 }).onConflictDoNothing();
|
||||||
|
|
||||||
const existing = await db.select().from(users).where(eq(users.username, username)).get();
|
const existing = await db.select().from(users).where(eq(users.username, username)).get();
|
||||||
if (existing && process.env.FORCE !== "1") {
|
if (existing && process.env.FORCE !== "1") {
|
||||||
console.error(`user "${username}" already exists (set FORCE=1 to reset the password)`);
|
console.error(`user "${username}" already exists (set FORCE=1 to reset the password)`);
|
||||||
@@ -73,4 +81,30 @@ if (existing) {
|
|||||||
});
|
});
|
||||||
console.log(`created admin "${username}"`);
|
console.log(`created admin "${username}"`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Record the action into the SIGNED ledger (config_change). A console seed/reset is
|
||||||
|
// a Linux-admin action the app can't gate — but it must stay ATTRIBUTABLE after the
|
||||||
|
// fact (the chain is the audit record; whoever holds root can reset a password, they
|
||||||
|
// can't do it silently). Uses the server's own compiled EventLog + signer from dist/
|
||||||
|
// (present in the container; in a dev checkout run `pnpm build` first). Best-effort:
|
||||||
|
// a missing build or signing key WARNS loudly but never blocks the seed — locking an
|
||||||
|
// admin out to protect an audit line would invert the priority.
|
||||||
|
try {
|
||||||
|
const { EventLog } = await import("../dist/event-log.js");
|
||||||
|
const { buildSigner } = await import("../dist/signer.js");
|
||||||
|
const log = new EventLog(db, buildSigner());
|
||||||
|
await log.append({
|
||||||
|
type: "config_change",
|
||||||
|
source: "manual",
|
||||||
|
identity: `user:${username}`,
|
||||||
|
payload: {
|
||||||
|
setting: existing ? "admin.passwordReset" : "admin.seeded",
|
||||||
|
username,
|
||||||
|
operator: "console:seed-admin",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log("recorded to the signed ledger (config_change)");
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`WARNING: NOT recorded to the signed ledger: ${err.message}`);
|
||||||
|
}
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { eq, siteConfig } from "@parking/db";
|
||||||
|
import { createTestDb } from "@parking/db/testing";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { BackupService } from "./backup-service.js";
|
||||||
|
|
||||||
|
// BackupService previously tracked last-success/last-error as plain in-process fields, so a
|
||||||
|
// server restart (a fresh BackupService instance, exactly as happens on every deploy/crash/OOM
|
||||||
|
// reboot under `restart: always`) silently reset the admin UI to "last successful backup:
|
||||||
|
// Never" — even with valid, correctly-rotating backups already on disk (2026-08-30 field
|
||||||
|
// incident, park-buzi). These tests exercise the fix: status is read from site_config, so a new
|
||||||
|
// BackupService instance pointed at the same DB sees the prior instance's last-run outcome, and
|
||||||
|
// the schedule is wall-clock-based (isDue()) rather than time-since-process-start.
|
||||||
|
// See wiki/concepts/backup-recovery.md.
|
||||||
|
|
||||||
|
const KEY = "a-test-backup-key-that-is-long-enough";
|
||||||
|
|
||||||
|
let workDir: string;
|
||||||
|
let target: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
workDir = mkdtempSync(join(tmpdir(), "pk-backup-service-test-"));
|
||||||
|
target = join(workDir, "target");
|
||||||
|
process.env.BACKUP_KEY = KEY;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
rmSync(workDir, { recursive: true, force: true });
|
||||||
|
delete process.env.BACKUP_KEY;
|
||||||
|
});
|
||||||
|
|
||||||
|
function setTargetDir(db: ReturnType<typeof createTestDb>["db"], dir: string): void {
|
||||||
|
const existing = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||||
|
if (existing) {
|
||||||
|
db.update(siteConfig).set({ backupTargetDir: dir }).where(eq(siteConfig.id, 1)).run();
|
||||||
|
} else {
|
||||||
|
db.insert(siteConfig).values({ id: 1, backupTargetDir: dir }).run();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("BackupService — persisted status survives a restart", () => {
|
||||||
|
it("a fresh instance sees the previous instance's last success", async () => {
|
||||||
|
const t = createTestDb();
|
||||||
|
setTargetDir(t.db, target);
|
||||||
|
|
||||||
|
const first = new BackupService(t.db);
|
||||||
|
expect(first.status().lastSuccessAt).toBeNull();
|
||||||
|
const result = await first.run("manual");
|
||||||
|
|
||||||
|
// Simulate a process restart: a brand-new BackupService over the SAME db handle (in
|
||||||
|
// production this would be a fresh process re-opening the same sqlite file).
|
||||||
|
const second = new BackupService(t.db);
|
||||||
|
const status = second.status();
|
||||||
|
expect(status.lastSuccessAt).not.toBeNull();
|
||||||
|
expect(status.lastResult).toEqual({ path: result.path, bytes: result.bytes, prunedFiles: result.prunedFiles });
|
||||||
|
expect(status.lastError).toBeNull();
|
||||||
|
|
||||||
|
t.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a fresh instance sees the previous instance's last error, and it clears on next success", async () => {
|
||||||
|
const t = createTestDb();
|
||||||
|
// Target dir set, but as a FILE (not a directory) — runBackup's mkdir(recursive) will
|
||||||
|
// throw, giving us a real, deterministic failure without needing to mock anything.
|
||||||
|
const badTarget = join(workDir, "not-a-dir");
|
||||||
|
writeFileSync(badTarget, "x");
|
||||||
|
setTargetDir(t.db, badTarget);
|
||||||
|
|
||||||
|
const first = new BackupService(t.db);
|
||||||
|
await expect(first.run("manual")).rejects.toThrow();
|
||||||
|
|
||||||
|
const second = new BackupService(t.db);
|
||||||
|
const status = second.status();
|
||||||
|
expect(status.lastError).not.toBeNull();
|
||||||
|
expect(status.lastErrorAt).not.toBeNull();
|
||||||
|
expect(status.lastSuccessAt).toBeNull();
|
||||||
|
|
||||||
|
// Now point at a real directory and succeed — the persisted error must clear.
|
||||||
|
setTargetDir(t.db, target);
|
||||||
|
await second.run("manual");
|
||||||
|
const third = new BackupService(t.db);
|
||||||
|
const finalStatus = third.status();
|
||||||
|
expect(finalStatus.lastSuccessAt).not.toBeNull();
|
||||||
|
expect(finalStatus.lastError).toBeNull();
|
||||||
|
expect(finalStatus.lastErrorAt).toBeNull();
|
||||||
|
|
||||||
|
t.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("BackupService — isDue() is wall-clock-based, not process-uptime-based", () => {
|
||||||
|
it("is due immediately when no success has ever been recorded", () => {
|
||||||
|
const t = createTestDb();
|
||||||
|
const svc = new BackupService(t.db);
|
||||||
|
expect(svc.isDue()).toBe(true);
|
||||||
|
t.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is NOT due right after a fresh instance is constructed, if a recent success is persisted", async () => {
|
||||||
|
const t = createTestDb();
|
||||||
|
setTargetDir(t.db, target);
|
||||||
|
const first = new BackupService(t.db);
|
||||||
|
await first.run("manual");
|
||||||
|
|
||||||
|
// The whole point of the fix: a brand-new instance (simulating a restart moments after a
|
||||||
|
// real backup completed) must NOT think a backup is due just because ITS OWN uptime is ~0.
|
||||||
|
const second = new BackupService(t.db);
|
||||||
|
expect(second.isDue()).toBe(false);
|
||||||
|
t.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is due once the persisted last-success timestamp is old enough", async () => {
|
||||||
|
const t = createTestDb();
|
||||||
|
setTargetDir(t.db, target);
|
||||||
|
const svc = new BackupService(t.db);
|
||||||
|
await svc.run("manual");
|
||||||
|
|
||||||
|
const almostADayLater = new Date(Date.now() + 23 * 60 * 60 * 1000);
|
||||||
|
expect(svc.isDue(almostADayLater)).toBe(false);
|
||||||
|
|
||||||
|
const overADayLater = new Date(Date.now() + 24 * 60 * 60 * 1000 + 1000);
|
||||||
|
expect(svc.isDue(overADayLater)).toBe(true);
|
||||||
|
t.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("runScheduled() is a no-op when not yet due, even if configured", async () => {
|
||||||
|
const t = createTestDb();
|
||||||
|
setTargetDir(t.db, target);
|
||||||
|
const svc = new BackupService(t.db);
|
||||||
|
await svc.run("manual");
|
||||||
|
const afterFirst = svc.status().lastSuccessAt;
|
||||||
|
|
||||||
|
await svc.runScheduled(); // not due yet — must not run again
|
||||||
|
expect(svc.status().lastSuccessAt).toBe(afterFirst);
|
||||||
|
t.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -11,6 +11,12 @@ import { DEFAULT_BACKUP_RETENTION, runBackup, type BackupResult, type BackupRete
|
|||||||
// a key must never live in the DB it backs up. Remembers the last outcome so the route + UI can
|
// a key must never live in the DB it backs up. Remembers the last outcome so the route + UI can
|
||||||
// show last-success / last-error, and serializes concurrent runs (manual + timer). See
|
// show last-success / last-error, and serializes concurrent runs (manual + timer). See
|
||||||
// wiki/concepts/backup-recovery.md.
|
// wiki/concepts/backup-recovery.md.
|
||||||
|
//
|
||||||
|
// Last-success/last-error are PERSISTED to site_config (backup_last_*), not just held in
|
||||||
|
// memory — an earlier version tracked these as plain in-process fields only, so every server
|
||||||
|
// restart (deploy, crash, OOM, host reboot — all routine under `restart: always`) silently
|
||||||
|
// reset the admin UI to "last successful backup: Never", even with valid, correctly-rotating
|
||||||
|
// backups already on disk (2026-08-30 field incident, park-buzi). See wiki/concepts/backup-recovery.md.
|
||||||
|
|
||||||
/** The dedicated backup-encryption key, from env (NOT the DB). Separate from EVENT_SIGNING_KEY. */
|
/** The dedicated backup-encryption key, from env (NOT the DB). Separate from EVENT_SIGNING_KEY. */
|
||||||
export function backupKeyFromEnv(): string {
|
export function backupKeyFromEnv(): string {
|
||||||
@@ -65,16 +71,33 @@ export class BackupService {
|
|||||||
readonly #logger?: FastifyBaseLogger;
|
readonly #logger?: FastifyBaseLogger;
|
||||||
|
|
||||||
#running = false;
|
#running = false;
|
||||||
#lastSuccessAt: string | null = null;
|
|
||||||
#lastResult: BackupResult | null = null;
|
|
||||||
#lastErrorAt: string | null = null;
|
|
||||||
#lastError: string | null = null;
|
|
||||||
|
|
||||||
constructor(db: Db, logger?: FastifyBaseLogger) {
|
constructor(db: Db, logger?: FastifyBaseLogger) {
|
||||||
this.#db = db;
|
this.#db = db;
|
||||||
this.#logger = logger;
|
this.#logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Fresh read of the persisted row (single source of truth — no in-memory cache to go stale
|
||||||
|
* or reset on restart). */
|
||||||
|
#row(): { backupLastSuccessAt: string | null; backupLastResultJson: string | null; backupLastErrorAt: string | null; backupLastError: string | null } | undefined {
|
||||||
|
return this.#db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||||
|
}
|
||||||
|
|
||||||
|
#persist(patch: {
|
||||||
|
backupLastSuccessAt?: string | null;
|
||||||
|
backupLastResultJson?: string | null;
|
||||||
|
backupLastErrorAt?: string | null;
|
||||||
|
backupLastError?: string | null;
|
||||||
|
}): void {
|
||||||
|
const updatedAt = new Date().toISOString();
|
||||||
|
const existing = this.#row();
|
||||||
|
if (existing) {
|
||||||
|
this.#db.update(siteConfig).set({ ...patch, updatedAt }).where(eq(siteConfig.id, 1)).run();
|
||||||
|
} else {
|
||||||
|
this.#db.insert(siteConfig).values({ id: 1, ...patch, updatedAt }).run();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** The admin-chosen target dir from site_config (null/empty = unset). Read fresh each call. */
|
/** The admin-chosen target dir from site_config (null/empty = unset). Read fresh each call. */
|
||||||
targetDir(): string | null {
|
targetDir(): string | null {
|
||||||
const row = this.#db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
const row = this.#db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||||
@@ -104,6 +127,15 @@ export class BackupService {
|
|||||||
|
|
||||||
status(): BackupStatus {
|
status(): BackupStatus {
|
||||||
const r = this.retention();
|
const r = this.retention();
|
||||||
|
const row = this.#row();
|
||||||
|
let lastResult: BackupStatus["lastResult"] = null;
|
||||||
|
if (row?.backupLastResultJson) {
|
||||||
|
try {
|
||||||
|
lastResult = JSON.parse(row.backupLastResultJson) as BackupStatus["lastResult"];
|
||||||
|
} catch {
|
||||||
|
lastResult = null; // corrupt/foreign value in the column — don't let it crash status()
|
||||||
|
}
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
configured: this.configured,
|
configured: this.configured,
|
||||||
targetDir: this.targetDir(),
|
targetDir: this.targetDir(),
|
||||||
@@ -111,12 +143,10 @@ export class BackupService {
|
|||||||
keepDailyDays: r.keepDailyDays,
|
keepDailyDays: r.keepDailyDays,
|
||||||
keyPresent: this.keyPresent,
|
keyPresent: this.keyPresent,
|
||||||
running: this.#running,
|
running: this.#running,
|
||||||
lastSuccessAt: this.#lastSuccessAt,
|
lastSuccessAt: row?.backupLastSuccessAt ?? null,
|
||||||
lastResult: this.#lastResult
|
lastResult,
|
||||||
? { path: this.#lastResult.path, bytes: this.#lastResult.bytes, prunedFiles: this.#lastResult.prunedFiles }
|
lastErrorAt: row?.backupLastErrorAt ?? null,
|
||||||
: null,
|
lastError: row?.backupLastError ?? null,
|
||||||
lastErrorAt: this.#lastErrorAt,
|
|
||||||
lastError: this.#lastError,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,14 +169,17 @@ export class BackupService {
|
|||||||
try {
|
try {
|
||||||
this.#logger?.info(`backup: starting (${trigger}) → ${targetDir}`);
|
this.#logger?.info(`backup: starting (${trigger}) → ${targetDir}`);
|
||||||
const res = await runBackup(this.#db, { targetDir, key, retention: this.retention() }, this.#logger);
|
const res = await runBackup(this.#db, { targetDir, key, retention: this.retention() }, this.#logger);
|
||||||
this.#lastResult = res;
|
this.#persist({
|
||||||
this.#lastSuccessAt = new Date().toISOString();
|
backupLastSuccessAt: new Date().toISOString(),
|
||||||
this.#lastError = null;
|
backupLastResultJson: JSON.stringify({ path: res.path, bytes: res.bytes, prunedFiles: res.prunedFiles }),
|
||||||
|
backupLastErrorAt: null,
|
||||||
|
backupLastError: null,
|
||||||
|
});
|
||||||
return res;
|
return res;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.#lastError = (err as Error).message;
|
const message = (err as Error).message;
|
||||||
this.#lastErrorAt = new Date().toISOString();
|
this.#persist({ backupLastErrorAt: new Date().toISOString(), backupLastError: message });
|
||||||
this.#logger?.error(`backup: failed (${trigger}): ${this.#lastError}`);
|
this.#logger?.error(`backup: failed (${trigger}): ${message}`);
|
||||||
throw err;
|
throw err;
|
||||||
} finally {
|
} finally {
|
||||||
this.#running = false;
|
this.#running = false;
|
||||||
@@ -156,13 +189,34 @@ export class BackupService {
|
|||||||
return this.#inflight;
|
return this.#inflight;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Scheduled-run wrapper: never throws (a timer must not crash the process). */
|
/**
|
||||||
|
* Scheduled-run wrapper: never throws (a timer must not crash the process). Safe to call on
|
||||||
|
* a short, frequent poll (see server.ts) — it's a no-op unless `isDue()` says a full interval
|
||||||
|
* has actually elapsed since the last recorded success, so frequent polling doesn't cause
|
||||||
|
* frequent backups.
|
||||||
|
*/
|
||||||
async runScheduled(): Promise<void> {
|
async runScheduled(): Promise<void> {
|
||||||
if (!this.configured) return; // silent no-op when backups aren't set up
|
if (!this.configured) return; // silent no-op when backups aren't set up
|
||||||
|
if (!this.isDue()) return;
|
||||||
try {
|
try {
|
||||||
await this.run("scheduled");
|
await this.run("scheduled");
|
||||||
} catch {
|
} catch {
|
||||||
/* recorded in last-error; already logged */
|
/* recorded in last-error; already logged */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wall-clock check: has enough time elapsed since the last successful backup for a new one
|
||||||
|
* to be due? Deliberately based on the PERSISTED last-success instant, not "time since this
|
||||||
|
* process started" — a `setInterval(..., 24h)` measured from process start silently drifts
|
||||||
|
* (or skips a whole day) across every restart, since the countdown restarts from zero each
|
||||||
|
* time regardless of when the last real backup happened. See wiki/concepts/backup-recovery.md.
|
||||||
|
*/
|
||||||
|
isDue(now: Date = new Date(), intervalMs = 24 * 60 * 60 * 1000): boolean {
|
||||||
|
const lastSuccessAt = this.#row()?.backupLastSuccessAt;
|
||||||
|
if (!lastSuccessAt) return true; // never recorded a success → due immediately once configured
|
||||||
|
const last = new Date(lastSuccessAt).getTime();
|
||||||
|
if (Number.isNaN(last)) return true;
|
||||||
|
return now.getTime() - last >= intervalMs;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,6 +80,8 @@ function receiptFigures(
|
|||||||
currency?: string;
|
currency?: string;
|
||||||
tender?: "cash" | "card";
|
tender?: "cash" | "card";
|
||||||
graceExitMin?: number;
|
graceExitMin?: number;
|
||||||
|
grossMinor?: number;
|
||||||
|
validationLines?: { label: string; discountMinor: number }[];
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
ticketId,
|
ticketId,
|
||||||
@@ -89,6 +91,9 @@ function receiptFigures(
|
|||||||
currency: p.currency ?? "ALL",
|
currency: p.currency ?? "ALL",
|
||||||
tender: p.tender === "card" ? "card" : "cash",
|
tender: p.tender === "card" ? "card" : "cash",
|
||||||
graceExitMin: typeof p.graceExitMin === "number" ? p.graceExitMin : null,
|
graceExitMin: typeof p.graceExitMin === "number" ? p.graceExitMin : null,
|
||||||
|
// Merchant validations, as settled on the signed payment (gross → lines → net).
|
||||||
|
grossMinor: typeof p.grossMinor === "number" ? p.grossMinor : null,
|
||||||
|
validationLines: Array.isArray(p.validationLines) ? p.validationLines : undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -192,11 +192,78 @@ describe("ButtonLightController truth table", () => {
|
|||||||
// First write (initial off) throws — must be swallowed.
|
// First write (initial off) throws — must be swallowed.
|
||||||
expect(() => ctl.start()).not.toThrow();
|
expect(() => ctl.start()).not.toThrow();
|
||||||
await flush();
|
await flush();
|
||||||
// Subsequent writes work; driving to solid still converges to ON.
|
// The failure arms a backoff (1s) rather than retrying inline; desired-state
|
||||||
|
// changes during the window just update the target the retry will assert.
|
||||||
|
lane(true);
|
||||||
|
radar(true);
|
||||||
|
await flush();
|
||||||
|
expect(ctl.confirmedOf(CONTROLLER)).toBeNull(); // still backing off
|
||||||
|
await vi.advanceTimersByTimeAsync(1000); // retry fires; aux is healthy again
|
||||||
|
expect(ctl.confirmedOf(CONTROLLER)).toBe(true); // converged to solid ON
|
||||||
|
ctl.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an unreachable controller backs off (1s→30s), not a hot retry loop", async () => {
|
||||||
|
let attempts = 0;
|
||||||
|
const aux: AuxOutputDevice = {
|
||||||
|
async setAux() {
|
||||||
|
attempts += 1;
|
||||||
|
throw new Error("send ENETUNREACH 10.0.10.5:60000");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const errors: string[] = [];
|
||||||
|
const logger = silentLogger();
|
||||||
|
(logger as { error: (msg: string) => void }).error = (msg) => errors.push(msg);
|
||||||
|
const ctl = new ButtonLightController(db, logger, () => aux);
|
||||||
|
ctl.start(); // initial OFF write → attempt 1 fails at t=0
|
||||||
|
await flush();
|
||||||
|
expect(attempts).toBe(1); // the old code hot-looped here
|
||||||
|
|
||||||
|
// Failures at t≈0,1,3,7,15,31 (doubling, capped 30s) → 6 attempts in the first
|
||||||
|
// minute instead of thousands.
|
||||||
|
await vi.advanceTimersByTimeAsync(60_000);
|
||||||
|
expect(attempts).toBeGreaterThanOrEqual(5);
|
||||||
|
expect(attempts).toBeLessThanOrEqual(7);
|
||||||
|
|
||||||
|
// Only the FIRST failure was logged so far; the next log is a ≥60s summary.
|
||||||
|
expect(errors).toHaveLength(1);
|
||||||
|
await vi.advanceTimersByTimeAsync(35_000); // t≈95s → the t=61s attempt logged a summary
|
||||||
|
expect(errors.length).toBe(2);
|
||||||
|
expect(errors[1]).toContain("still failing");
|
||||||
|
ctl.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs a single recovery line and resets the backoff after success", async () => {
|
||||||
|
let failing = true;
|
||||||
|
let attempts = 0;
|
||||||
|
const aux: AuxOutputDevice = {
|
||||||
|
async setAux() {
|
||||||
|
attempts += 1;
|
||||||
|
if (failing) throw new Error("send ENETUNREACH 10.0.10.5:60000");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const infos: string[] = [];
|
||||||
|
const logger = silentLogger();
|
||||||
|
(logger as { info: (msg: string) => void }).info = (msg) => infos.push(msg);
|
||||||
|
const ctl = new ButtonLightController(db, logger, () => aux);
|
||||||
|
ctl.start();
|
||||||
|
await flush();
|
||||||
|
await vi.advanceTimersByTimeAsync(3_000); // attempts at t=0,1,3 all fail
|
||||||
|
const failed = attempts;
|
||||||
|
expect(failed).toBeGreaterThanOrEqual(3);
|
||||||
|
|
||||||
|
failing = false; // controller reachable again
|
||||||
|
await vi.advanceTimersByTimeAsync(8_000); // next armed retry succeeds
|
||||||
|
expect(ctl.confirmedOf(CONTROLLER)).toBe(false); // OFF asserted on the device
|
||||||
|
expect(infos.filter((m) => m.includes("recovered"))).toHaveLength(1);
|
||||||
|
|
||||||
|
// Backoff reset: a fresh state change sends immediately (no lingering retryAt).
|
||||||
|
const before = attempts;
|
||||||
lane(true);
|
lane(true);
|
||||||
radar(true);
|
radar(true);
|
||||||
await flush();
|
await flush();
|
||||||
expect(ctl.confirmedOf(CONTROLLER)).toBe(true);
|
expect(ctl.confirmedOf(CONTROLLER)).toBe(true);
|
||||||
|
expect(attempts).toBe(before + 1);
|
||||||
ctl.stop();
|
ctl.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,14 @@ type LightState = "off" | "solid" | "blink";
|
|||||||
|
|
||||||
const DEFAULT_BLINK_MS = 500;
|
const DEFAULT_BLINK_MS = 500;
|
||||||
|
|
||||||
|
// Failed-send retry backoff: 1s doubling to 30s, reset on success. Without this an
|
||||||
|
// unreachable controller (ENETUNREACH) became a hot loop — the failure re-pump retried
|
||||||
|
// instantly, thousands of sends + error lines per minute (field incident 2026-07-07).
|
||||||
|
const RETRY_BASE_MS = 1_000;
|
||||||
|
const RETRY_MAX_MS = 30_000;
|
||||||
|
/** After the first failure of a streak, log at most one summary line per this window. */
|
||||||
|
const FAIL_LOG_EVERY_MS = 60_000;
|
||||||
|
|
||||||
/** Per-lamp live state for the alert rule (one per radarAlert relay). */
|
/** Per-lamp live state for the alert rule (one per radarAlert relay). */
|
||||||
interface LampState {
|
interface LampState {
|
||||||
/** The controller this lamp lives on (its deviceId) — for resolving the aux adapter. */
|
/** The controller this lamp lives on (its deviceId) — for resolving the aux adapter. */
|
||||||
@@ -44,6 +52,16 @@ interface LampState {
|
|||||||
/** True while a send is in flight for this lamp — serializes UDP so on/off can't
|
/** True while a send is in flight for this lamp — serializes UDP so on/off can't
|
||||||
* overlap or reorder (UDP is unordered; concurrent toggles left the relay stuck). */
|
* overlap or reorder (UDP is unordered; concurrent toggles left the relay stuck). */
|
||||||
sending: boolean;
|
sending: boolean;
|
||||||
|
/** Consecutive failed sends (0 = healthy). Drives the backoff delay + log summaries. */
|
||||||
|
failCount: number;
|
||||||
|
/** Epoch ms before which #pump must not send (0 = no backoff). The armed retry
|
||||||
|
* timer re-pumps when it elapses; desired-state changes in between just update
|
||||||
|
* `desiredOn` and are picked up by that same retry. */
|
||||||
|
retryAt: number;
|
||||||
|
/** The armed backoff retry, if any. */
|
||||||
|
retryTimer: ReturnType<typeof setTimeout> | null;
|
||||||
|
/** Epoch ms of the last failure line we actually logged (rate-limits the flood). */
|
||||||
|
lastFailLogAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Resolves a controller's live aux-output adapter. The default goes through the
|
/** Resolves a controller's live aux-output adapter. The default goes through the
|
||||||
@@ -111,6 +129,10 @@ export class ButtonLightController {
|
|||||||
desiredOn: false,
|
desiredOn: false,
|
||||||
confirmedOn: null,
|
confirmedOn: null,
|
||||||
sending: false,
|
sending: false,
|
||||||
|
failCount: 0,
|
||||||
|
retryAt: 0,
|
||||||
|
retryTimer: null,
|
||||||
|
lastFailLogAt: 0,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -118,10 +140,7 @@ export class ButtonLightController {
|
|||||||
// Drop lamps whose controller no longer declares one (or was disabled/removed).
|
// Drop lamps whose controller no longer declares one (or was disabled/removed).
|
||||||
for (const [key, lamp] of this.#lamps) {
|
for (const [key, lamp] of this.#lamps) {
|
||||||
if (seen.has(key)) continue;
|
if (seen.has(key)) continue;
|
||||||
if (lamp.blink) {
|
this.#disarm(lamp);
|
||||||
clearInterval(lamp.blink);
|
|
||||||
lamp.blink = null;
|
|
||||||
}
|
|
||||||
this.#finalOff(lamp); // best-effort fail-OFF before forgetting it
|
this.#finalOff(lamp); // best-effort fail-OFF before forgetting it
|
||||||
this.#lamps.delete(key);
|
this.#lamps.delete(key);
|
||||||
}
|
}
|
||||||
@@ -207,10 +226,17 @@ export class ButtonLightController {
|
|||||||
* time. Because UDP is unordered, concurrent on/off sends previously raced and left
|
* time. Because UDP is unordered, concurrent on/off sends previously raced and left
|
||||||
* the relay stuck on a stale packet. Here a single in-flight send is guaranteed
|
* the relay stuck on a stale packet. Here a single in-flight send is guaranteed
|
||||||
* (`sending` guard); when it resolves, if the desired state moved on we send again —
|
* (`sending` guard); when it resolves, if the desired state moved on we send again —
|
||||||
* so the LAST desired state is always the one finally asserted on the device. */
|
* so the LAST desired state is always the one finally asserted on the device.
|
||||||
|
*
|
||||||
|
* Failures back off (1s → 30s, reset on success) instead of retrying inline: an
|
||||||
|
* unreachable controller rejects instantly, and an immediate re-pump was a hot loop.
|
||||||
|
* During backoff `desiredOn` keeps tracking the truth table; the armed retry timer
|
||||||
|
* converges to whatever it says when it fires. Only the FIRST failure of a streak is
|
||||||
|
* logged, then one summary per minute, and an info line on recovery. */
|
||||||
#pump(lamp: LampState): void {
|
#pump(lamp: LampState): void {
|
||||||
if (lamp.sending) return; // a send is already in flight; it'll re-check on completion
|
if (lamp.sending) return; // a send is already in flight; it'll re-check on completion
|
||||||
if (lamp.confirmedOn === lamp.desiredOn) return; // already there — no redundant UDP
|
if (lamp.confirmedOn === lamp.desiredOn) return; // already there — no redundant UDP
|
||||||
|
if (Date.now() < lamp.retryAt) return; // backing off — the retry timer will re-pump
|
||||||
const aux = this.#resolveAux(lamp.controllerId);
|
const aux = this.#resolveAux(lamp.controllerId);
|
||||||
if (!aux) return;
|
if (!aux) return;
|
||||||
const target = lamp.desiredOn;
|
const target = lamp.desiredOn;
|
||||||
@@ -219,15 +245,42 @@ export class ButtonLightController {
|
|||||||
.setAux(lamp.spec.relay, target)
|
.setAux(lamp.spec.relay, target)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
lamp.confirmedOn = target;
|
lamp.confirmedOn = target;
|
||||||
|
if (lamp.failCount > 0) {
|
||||||
|
this.#logger.info(
|
||||||
|
`button-light setAux recovered (${lamp.controllerId} R${lamp.spec.relay}) after ${lamp.failCount} failed attempts`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
lamp.failCount = 0;
|
||||||
|
lamp.retryAt = 0;
|
||||||
|
lamp.lastFailLogAt = 0;
|
||||||
})
|
})
|
||||||
.catch((err: unknown) => {
|
.catch((err: unknown) => {
|
||||||
// Leave confirmedOn unchanged so the next pump retries this state. Never escalates.
|
// Leave confirmedOn unchanged so the armed retry re-asserts the (then-current)
|
||||||
this.#logger.error(`button-light setAux failed (${lamp.controllerId} R${lamp.spec.relay}): ${(err as Error).message}`);
|
// desired state. Never escalates — a dead lamp is "no hint", never a fault.
|
||||||
|
lamp.failCount += 1;
|
||||||
|
const delay = Math.min(RETRY_BASE_MS * 2 ** (lamp.failCount - 1), RETRY_MAX_MS);
|
||||||
|
lamp.retryAt = Date.now() + delay;
|
||||||
|
const now = Date.now();
|
||||||
|
if (lamp.failCount === 1 || now - lamp.lastFailLogAt >= FAIL_LOG_EVERY_MS) {
|
||||||
|
lamp.lastFailLogAt = now;
|
||||||
|
const streak =
|
||||||
|
lamp.failCount > 1 ? ` — still failing (attempt ${lamp.failCount}, retrying ≤${RETRY_MAX_MS / 1000}s)` : "";
|
||||||
|
this.#logger.error(
|
||||||
|
`button-light setAux failed (${lamp.controllerId} R${lamp.spec.relay}): ${(err as Error).message}${streak}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (lamp.retryTimer) clearTimeout(lamp.retryTimer);
|
||||||
|
lamp.retryTimer = setTimeout(() => {
|
||||||
|
lamp.retryTimer = null;
|
||||||
|
this.#pump(lamp);
|
||||||
|
}, delay);
|
||||||
|
lamp.retryTimer.unref?.();
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
lamp.sending = false;
|
lamp.sending = false;
|
||||||
// Desired state may have changed (or the send failed) while we were busy —
|
// Desired state may have changed while we were busy — re-pump to converge (the
|
||||||
// re-pump to converge. This is what makes the final state authoritative.
|
// backoff gate above makes this a no-op right after a failure). This is what
|
||||||
|
// makes the final state authoritative.
|
||||||
if (lamp.confirmedOn !== lamp.desiredOn) this.#pump(lamp);
|
if (lamp.confirmedOn !== lamp.desiredOn) this.#pump(lamp);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -261,20 +314,31 @@ export class ButtonLightController {
|
|||||||
this.#unsubInput = null;
|
this.#unsubInput = null;
|
||||||
this.#unsubLane = null;
|
this.#unsubLane = null;
|
||||||
for (const lamp of this.#lamps.values()) {
|
for (const lamp of this.#lamps.values()) {
|
||||||
if (lamp.blink) {
|
this.#disarm(lamp);
|
||||||
clearInterval(lamp.blink);
|
|
||||||
lamp.blink = null;
|
|
||||||
}
|
|
||||||
// Best-effort fail-OFF on shutdown.
|
// Best-effort fail-OFF on shutdown.
|
||||||
this.#finalOff(lamp);
|
this.#finalOff(lamp);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Stop a lamp's timers (blink + backoff retry) without touching the device. */
|
||||||
|
#disarm(lamp: LampState): void {
|
||||||
|
if (lamp.blink) {
|
||||||
|
clearInterval(lamp.blink);
|
||||||
|
lamp.blink = null;
|
||||||
|
}
|
||||||
|
if (lamp.retryTimer) {
|
||||||
|
clearTimeout(lamp.retryTimer);
|
||||||
|
lamp.retryTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Drive a lamp OFF as a one-shot (used when dropping/stopping a lamp): set desired
|
/** Drive a lamp OFF as a one-shot (used when dropping/stopping a lamp): set desired
|
||||||
* OFF and pump. The serialized worker still applies, so this can't collide with an
|
* OFF and pump. The serialized worker still applies, so this can't collide with an
|
||||||
* in-flight send — it converges to OFF. */
|
* in-flight send — it converges to OFF. Any backoff is waived so the last-gasp OFF
|
||||||
|
* gets one immediate try (a lamp mid-backoff may just have recovered). */
|
||||||
#finalOff(lamp: LampState): void {
|
#finalOff(lamp: LampState): void {
|
||||||
lamp.desiredOn = false;
|
lamp.desiredOn = false;
|
||||||
|
lamp.retryAt = 0;
|
||||||
this.#pump(lamp);
|
this.#pump(lamp);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { localIsoWithOffset } from "./device-monitor.js";
|
||||||
|
|
||||||
|
// The camera clock-sync sends the SITE's wall-clock now with an explicit UTC offset
|
||||||
|
// (ISAPI localTime) — the offset is what makes the instant unambiguous regardless of
|
||||||
|
// the camera's own tz/DST config. Pin the DST both-sides behaviour for the site tz.
|
||||||
|
|
||||||
|
describe("localIsoWithOffset (camera clock sync payload)", () => {
|
||||||
|
it("Tirane summer = +02:00 (CEST)", () => {
|
||||||
|
expect(localIsoWithOffset("Europe/Tirane", new Date("2026-07-07T10:00:00Z"))).toBe(
|
||||||
|
"2026-07-07T12:00:00+02:00",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
it("Tirane winter = +01:00 (CET)", () => {
|
||||||
|
expect(localIsoWithOffset("Europe/Tirane", new Date("2026-01-15T10:00:00Z"))).toBe(
|
||||||
|
"2026-01-15T11:00:00+01:00",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
it("UTC = +00:00", () => {
|
||||||
|
expect(localIsoWithOffset("UTC", new Date("2026-07-07T10:00:00Z"))).toBe(
|
||||||
|
"2026-07-07T10:00:00+00:00",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import type { FastifyBaseLogger } from "fastify";
|
import type { FastifyBaseLogger } from "fastify";
|
||||||
import { devices, type Db, type DeviceRow } from "@parking/db";
|
import { devices, type Db, type DeviceRow } from "@parking/db";
|
||||||
import { isMonitorable, registry } from "@parking/devices";
|
import { isClockSyncable, isMonitorable, registry, type Device } from "@parking/devices";
|
||||||
import { deviceEvents, type DeviceStatusEvent } from "./device-events.js";
|
import { deviceEvents, type DeviceStatusEvent } from "./device-events.js";
|
||||||
import { directionOf, relaysOf } from "./device-resolve.js";
|
import { directionOf, relaysOf } from "./device-resolve.js";
|
||||||
import type { VisionClient } from "./vision-client.js";
|
import type { VisionClient } from "./vision-client.js";
|
||||||
|
import { siteTz } from "./subscription-window.js";
|
||||||
|
|
||||||
/** Synthetic device id for the vision service in the status footer (it's a service,
|
/** Synthetic device id for the vision service in the status footer (it's a service,
|
||||||
* not a device row, but shares the footer's traffic-light + WS plumbing). */
|
* not a device row, but shares the footer's traffic-light + WS plumbing). */
|
||||||
@@ -23,6 +24,40 @@ const VISION_STATUS_ID = "vision-service";
|
|||||||
|
|
||||||
const POLL_MS = Number(process.env.DEVICE_POLL_MS ?? 8000);
|
const POLL_MS = Number(process.env.DEVICE_POLL_MS ?? 8000);
|
||||||
|
|
||||||
|
// Camera clock sync (Hikvision loses its clock on power cuts — reboots at the 1970
|
||||||
|
// epoch until a human logs into its web UI). The monitor re-syncs from the HOST
|
||||||
|
// clock (the site's offline time authority) at the offline→ready edge — exactly the
|
||||||
|
// power-restored moment — plus a daily backstop; drift under the threshold is left
|
||||||
|
// alone. See wiki/entities/lpr-camera.md (clock sync).
|
||||||
|
const CLOCK_SYNC_BACKSTOP_MS = 24 * 60 * 60 * 1000;
|
||||||
|
const CLOCK_MAX_DRIFT_SEC = 60;
|
||||||
|
|
||||||
|
/** The site's wall-clock now as ISO WITH utc offset (e.g. 2026-07-07T15:30:22+02:00)
|
||||||
|
* — what ISAPI's localTime wants. Derived via Intl for the site tz (no dep). */
|
||||||
|
export function localIsoWithOffset(tz: string, at = new Date()): string {
|
||||||
|
const fmt = new Intl.DateTimeFormat("en-CA", {
|
||||||
|
timeZone: tz,
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit",
|
||||||
|
hourCycle: "h23",
|
||||||
|
});
|
||||||
|
const p = Object.fromEntries(fmt.formatToParts(at).map((x) => [x.type, x.value]));
|
||||||
|
const wallAsUtcMs = Date.UTC(
|
||||||
|
Number(p.year), Number(p.month) - 1, Number(p.day),
|
||||||
|
Number(p.hour), Number(p.minute), Number(p.second),
|
||||||
|
);
|
||||||
|
const offMin = Math.round((wallAsUtcMs - at.getTime()) / 60_000);
|
||||||
|
const sign = offMin < 0 ? "-" : "+";
|
||||||
|
const abs = Math.abs(offMin);
|
||||||
|
const hh = String(Math.floor(abs / 60)).padStart(2, "0");
|
||||||
|
const mm = String(abs % 60).padStart(2, "0");
|
||||||
|
return `${p.year}-${p.month}-${p.day}T${p.hour}:${p.minute}:${p.second}${sign}${hh}:${mm}`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The device's ROLE descriptor for the footer (never the vendor). Direction-style
|
* The device's ROLE descriptor for the footer (never the vendor). Direction-style
|
||||||
* tokens the client localises next to the category:
|
* tokens the client localises next to the category:
|
||||||
@@ -139,6 +174,7 @@ export class DeviceMonitor {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let next: DeviceStatusEvent;
|
let next: DeviceStatusEvent;
|
||||||
|
let device: Device | null = null;
|
||||||
const driver = registry.get(row.driverId);
|
const driver = registry.get(row.driverId);
|
||||||
if (!driver) {
|
if (!driver) {
|
||||||
// Configured against a driver that's no longer registered — surface it,
|
// Configured against a driver that's no longer registered — surface it,
|
||||||
@@ -146,7 +182,7 @@ export class DeviceMonitor {
|
|||||||
next = { ...base, state: "offline", detail: "driver not registered", checkedAt: new Date().toISOString() };
|
next = { ...base, state: "offline", detail: "driver not registered", checkedAt: new Date().toISOString() };
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
const device = driver.create(cfg as never);
|
device = driver.create(cfg as never);
|
||||||
// Printers expose richer paper/cover/cutter status; everything else uses
|
// Printers expose richer paper/cover/cutter status; everything else uses
|
||||||
// the generic reachability probe. Both flatten to the same traffic-light.
|
// the generic reachability probe. Both flatten to the same traffic-light.
|
||||||
if (isMonitorable(device)) {
|
if (isMonitorable(device)) {
|
||||||
@@ -163,6 +199,33 @@ export class DeviceMonitor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Camera clock re-sync at the power-restored edge (prev offline/unknown →
|
||||||
|
// ready) + a daily backstop. Stamped BEFORE the async attempt so a failing
|
||||||
|
// camera is retried at backstop cadence, never every poll.
|
||||||
|
if (row.category === "camera" && next.state === "ready" && device && isClockSyncable(device)) {
|
||||||
|
const prev = this.#latest.get(row.id);
|
||||||
|
const cameBack = !prev || prev.state === "offline";
|
||||||
|
const last = this.#clockSyncedAt.get(row.id) ?? 0;
|
||||||
|
if (cameBack || Date.now() - last > CLOCK_SYNC_BACKSTOP_MS) {
|
||||||
|
this.#clockSyncedAt.set(row.id, Date.now());
|
||||||
|
const cam = device;
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const r = await cam.syncClock(localIsoWithOffset(siteTz(this.#db)), CLOCK_MAX_DRIFT_SEC);
|
||||||
|
if (r.synced) {
|
||||||
|
// A large jump is the 1970 power-cut signature — warn (persisted) so
|
||||||
|
// the reboot stays visible; a small correction is routine info.
|
||||||
|
const msg = `device-monitor: camera ${row.id} clock synced (was ${r.driftSeconds ?? "unparseable"}s off)`;
|
||||||
|
if (r.driftSeconds == null || r.driftSeconds > 3600) this.#log.warn(msg);
|
||||||
|
else this.#log.info(msg);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.#log.warn(`device-monitor: camera ${row.id} clock sync failed: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.#publish(row.id, next);
|
this.#publish(row.id, next);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,6 +246,9 @@ export class DeviceMonitor {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Per-camera timestamp of the last clock-sync ATTEMPT (backstop pacing). */
|
||||||
|
readonly #clockSyncedAt = new Map<string, number>();
|
||||||
|
|
||||||
/** Cache + emit a status, but only when it CHANGED (state or detail). */
|
/** Cache + emit a status, but only when it CHANGED (state or detail). */
|
||||||
#publish(id: string, next: DeviceStatusEvent): void {
|
#publish(id: string, next: DeviceStatusEvent): void {
|
||||||
const prev = this.#latest.get(id);
|
const prev = this.#latest.get(id);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { beforeEach, describe, expect, it } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { appLogs, type Db } from "@parking/db";
|
import { appLogs, type Db } from "@parking/db";
|
||||||
import { createTestDb } from "@parking/db/testing";
|
import { createTestDb } from "@parking/db/testing";
|
||||||
import { LogService, pinoDbStream } from "./log-service.js";
|
import { LogService, pinoDbStream } from "./log-service.js";
|
||||||
@@ -52,3 +52,65 @@ describe("pinoDbStream level encodings", () => {
|
|||||||
expect(teed).toHaveLength(2);
|
expect(teed).toHaveLength(2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Storm coalescing: a line identical to the LAST persisted row (level+source+message+
|
||||||
|
// path), arriving within 5 min of its previous occurrence, UPDATES that row (bumping
|
||||||
|
// context._repeat) instead of inserting — one screaming device can't evict unrelated
|
||||||
|
// history. The row's createdAt tracks the LATEST occurrence; the first is preserved in
|
||||||
|
// context._firstAt.
|
||||||
|
describe("storm coalescing", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("folds a burst of identical error lines into ONE row with a repeat counter", () => {
|
||||||
|
for (let i = 0; i < 200; i++) {
|
||||||
|
stream.write(`{"level":"error","msg":"button-light setAux failed (ctl R3): send ENETUNREACH"}\n`);
|
||||||
|
}
|
||||||
|
const all = rows();
|
||||||
|
expect(all).toHaveLength(1);
|
||||||
|
expect(all[0].context).toMatchObject({ _repeat: 200 });
|
||||||
|
expect(teed).toHaveLength(200); // stdout still gets every line
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps first-occurrence time in _firstAt while createdAt tracks the latest", () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date("2026-07-08T10:00:00.000Z"));
|
||||||
|
stream.write(`{"level":"warn","msg":"same"}\n`);
|
||||||
|
vi.setSystemTime(new Date("2026-07-08T10:02:00.000Z"));
|
||||||
|
stream.write(`{"level":"warn","msg":"same"}\n`);
|
||||||
|
const [row] = rows();
|
||||||
|
expect(row.createdAt).toBe("2026-07-08T10:02:00.000Z");
|
||||||
|
expect(row.context).toMatchObject({ _repeat: 2, _firstAt: "2026-07-08T10:00:00.000Z" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a different message (or level) breaks the run — separate rows", () => {
|
||||||
|
stream.write(`{"level":"error","msg":"boom A"}\n`);
|
||||||
|
stream.write(`{"level":"error","msg":"boom A"}\n`);
|
||||||
|
stream.write(`{"level":"error","msg":"boom B"}\n`);
|
||||||
|
stream.write(`{"level":"warn","msg":"boom B"}\n`);
|
||||||
|
expect(rows()).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an occurrence past the 5-minute window starts a fresh row", () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date("2026-07-08T10:00:00.000Z"));
|
||||||
|
stream.write(`{"level":"error","msg":"slow leak"}\n`);
|
||||||
|
vi.setSystemTime(new Date("2026-07-08T10:06:00.000Z"));
|
||||||
|
stream.write(`{"level":"error","msg":"slow leak"}\n`);
|
||||||
|
expect(rows()).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a CONTINUOUS storm stays one row past the window (each hit refreshes it)", () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
let t = new Date("2026-07-08T10:00:00.000Z").getTime();
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
vi.setSystemTime(new Date(t));
|
||||||
|
stream.write(`{"level":"error","msg":"storm"}\n`);
|
||||||
|
t += 240_000; // 4 min apart — each inside the window of the PREVIOUS hit
|
||||||
|
}
|
||||||
|
const all = rows();
|
||||||
|
expect(all).toHaveLength(1);
|
||||||
|
expect(all[0].context).toMatchObject({ _repeat: 10 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -25,6 +25,14 @@ const MAX_MESSAGE = 4_000;
|
|||||||
const MAX_STACK = 16_000;
|
const MAX_STACK = 16_000;
|
||||||
const MAX_CONTEXT_JSON = 16_000;
|
const MAX_CONTEXT_JSON = 16_000;
|
||||||
|
|
||||||
|
/** Storm coalescing: a line identical to the LAST persisted one (level+source+message+
|
||||||
|
* path) within this window of its previous occurrence UPDATES that row (bumping a
|
||||||
|
* `_repeat` counter in its context) instead of inserting a new one. A continuous storm
|
||||||
|
* keeps refreshing the window, so it stays ONE row however long it rages — repeated
|
||||||
|
* errors can't evict unrelated history or grind the appliance disk (field incident
|
||||||
|
* 2026-07-07: one unreachable controller ≈ hundreds of identical rows/minute). */
|
||||||
|
const COALESCE_WINDOW_MS = 300_000;
|
||||||
|
|
||||||
export interface LogRetention {
|
export interface LogRetention {
|
||||||
/** Delete logs older than this many days. */
|
/** Delete logs older than this many days. */
|
||||||
readonly maxAgeDays: number;
|
readonly maxAgeDays: number;
|
||||||
@@ -62,6 +70,16 @@ export class LogService {
|
|||||||
readonly #retention: LogRetention;
|
readonly #retention: LogRetention;
|
||||||
/** Reentrancy guard: never let persisting a log itself emit a persisted log. */
|
/** Reentrancy guard: never let persisting a log itself emit a persisted log. */
|
||||||
#writing = false;
|
#writing = false;
|
||||||
|
/** The last persisted row, for storm coalescing (in-memory only; a restart just
|
||||||
|
* starts a fresh row — best-effort, like everything in this sink). */
|
||||||
|
#last: {
|
||||||
|
id: string;
|
||||||
|
key: string;
|
||||||
|
count: number;
|
||||||
|
firstAt: string;
|
||||||
|
lastAtMs: number;
|
||||||
|
baseContext: Record<string, unknown> | null;
|
||||||
|
} | null = null;
|
||||||
|
|
||||||
constructor(db: Db, retention: LogRetention = DEFAULT_RETENTION) {
|
constructor(db: Db, retention: LogRetention = DEFAULT_RETENTION) {
|
||||||
this.#db = db;
|
this.#db = db;
|
||||||
@@ -85,22 +103,53 @@ export class LogService {
|
|||||||
if (this.#writing) return;
|
if (this.#writing) return;
|
||||||
this.#writing = true;
|
this.#writing = true;
|
||||||
try {
|
try {
|
||||||
|
const createdAt = row.createdAt ?? new Date().toISOString();
|
||||||
|
const message = clamp(row.message, MAX_MESSAGE) ?? "";
|
||||||
|
const path = clamp(row.path, 512);
|
||||||
|
const key = `${row.level}|${row.source}|${message}|${path ?? ""}`;
|
||||||
|
const nowMs = Date.now();
|
||||||
|
|
||||||
|
// Storm coalescing: identical to the last persisted row, within the window →
|
||||||
|
// bump that row instead of inserting. createdAt moves to the LATEST occurrence
|
||||||
|
// (keeps the storm visible at the top of the newest-first viewer); the first
|
||||||
|
// occurrence's time is preserved in context._firstAt.
|
||||||
|
const last = this.#last;
|
||||||
|
if (last && last.key === key && nowMs - last.lastAtMs <= COALESCE_WINDOW_MS) {
|
||||||
|
const res = this.#db
|
||||||
|
.update(appLogs)
|
||||||
|
.set({
|
||||||
|
context: { ...(last.baseContext ?? {}), _repeat: last.count + 1, _firstAt: last.firstAt },
|
||||||
|
createdAt,
|
||||||
|
})
|
||||||
|
.where(eq(appLogs.id, last.id))
|
||||||
|
.run();
|
||||||
|
if ((res.changes ?? 0) > 0) {
|
||||||
|
last.count += 1;
|
||||||
|
last.lastAtMs = nowMs;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// The row was pruned out from under us — fall through to a fresh insert.
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = randomUUID();
|
||||||
|
const baseContext = safeContext(row.context);
|
||||||
this.#db
|
this.#db
|
||||||
.insert(appLogs)
|
.insert(appLogs)
|
||||||
.values({
|
.values({
|
||||||
id: randomUUID(),
|
id,
|
||||||
level: row.level,
|
level: row.level,
|
||||||
source: row.source,
|
source: row.source,
|
||||||
message: clamp(row.message, MAX_MESSAGE) ?? "",
|
message,
|
||||||
context: safeContext(row.context),
|
context: baseContext,
|
||||||
httpStatus: row.httpStatus ?? null,
|
httpStatus: row.httpStatus ?? null,
|
||||||
path: clamp(row.path, 512),
|
path,
|
||||||
stack: clamp(row.stack, MAX_STACK),
|
stack: clamp(row.stack, MAX_STACK),
|
||||||
userId: row.userId ?? null,
|
userId: row.userId ?? null,
|
||||||
userAgent: clamp(row.userAgent, 512),
|
userAgent: clamp(row.userAgent, 512),
|
||||||
createdAt: row.createdAt ?? new Date().toISOString(),
|
createdAt,
|
||||||
})
|
})
|
||||||
.run();
|
.run();
|
||||||
|
this.#last = { id, key, count: 1, firstAt: createdAt, lastAtMs: nowMs, baseContext };
|
||||||
} catch {
|
} catch {
|
||||||
// Swallow — diagnostics must never take down the path they observe. (Can't log
|
// Swallow — diagnostics must never take down the path they observe. (Can't log
|
||||||
// it; that's the recursion we're guarding against.)
|
// it; that's the recursion we're guarding against.)
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { desc, eq, ledgerEvents, sessions, subscriptions, tariffVersions, tariffs, type Db } from "@parking/db";
|
import { desc, eq, ledgerEvents, sessions, subscriptions, tariffVersions, tariffs, type Db } from "@parking/db";
|
||||||
import { priceSession, type TariffStructure, type Tender } from "@parking/shared";
|
import { priceSession, type TariffStructure, type Tender, type ValidationLine } from "@parking/shared";
|
||||||
import type { FastifyBaseLogger } from "fastify";
|
import type { FastifyBaseLogger } from "fastify";
|
||||||
import type { EventLog } from "./event-log.js";
|
import type { EventLog } from "./event-log.js";
|
||||||
import { plateForIdentity, platesForIdentities } from "./plate-lookup.js";
|
import { plateForIdentity, platesForIdentities } from "./plate-lookup.js";
|
||||||
import { windowOwedBetween } from "./subscription-window.js";
|
import { windowOwedBetween } from "./subscription-window.js";
|
||||||
|
import { liveValidations } from "./validations.js";
|
||||||
|
|
||||||
// The PAY STATION: a customer pays for an open session BEFORE walking back to the
|
// The PAY STATION: a customer pays for an open session BEFORE walking back to the
|
||||||
// car (pay-on-foot — payment is decoupled from exit). Two steps:
|
// car (pay-on-foot — payment is decoupled from exit). Two steps:
|
||||||
@@ -38,8 +39,17 @@ export interface Quote {
|
|||||||
* is priced as a fresh stay from there → now, with its own daily-cap ladder, NOT
|
* is priced as a fresh stay from there → now, with its own daily-cap ladder, NOT
|
||||||
* "full stay minus paid" (which a daily cap collapses toward zero). */
|
* "full stay minus paid" (which a daily cap collapses toward zero). */
|
||||||
readonly periodStart: string;
|
readonly periodStart: string;
|
||||||
/** Amount owed now: the fee for [periodStart → now]. */
|
/** Amount owed now: the fee for [periodStart → now], NET of merchant validations. */
|
||||||
readonly amountMinor: number;
|
readonly amountMinor: number;
|
||||||
|
/** The pre-validation fee (= amountMinor when no validations apply). */
|
||||||
|
readonly grossMinor: number;
|
||||||
|
/** Total the merchant validations took off (gross − net). */
|
||||||
|
readonly discountMinor: number;
|
||||||
|
/** Per-validation receipt/display lines (empty when none apply). */
|
||||||
|
readonly validationLines: ValidationLine[];
|
||||||
|
/** The validation event ids this quote applied — the payment stamps them as
|
||||||
|
* CONSUMED so an overstay's fresh period never re-applies them. */
|
||||||
|
readonly validationIds: string[];
|
||||||
/** True when this quote prices an overstay period (grace lapsed), not the first stay. */
|
/** True when this quote prices an overstay period (grace lapsed), not the first stay. */
|
||||||
readonly overstay: boolean;
|
readonly overstay: boolean;
|
||||||
readonly currency: string;
|
readonly currency: string;
|
||||||
@@ -117,6 +127,12 @@ export interface SessionLookup {
|
|||||||
/** Advisory licence plate recognized for this session (ANPR-on-snapshot). Null when
|
/** Advisory licence plate recognized for this session (ANPR-on-snapshot). Null when
|
||||||
* none. Display/audit only — never an access decision. */
|
* none. Display/audit only — never an access decision. */
|
||||||
readonly plate: string | null;
|
readonly plate: string | null;
|
||||||
|
/** Merchant validations folded into `amountMinor` (which is NET): the pre-discount
|
||||||
|
* fee, the total taken off, and the per-validation lines for the modal/receipt.
|
||||||
|
* grossMinor/discountMinor are null when no quote resolved. */
|
||||||
|
readonly grossMinor: number | null;
|
||||||
|
readonly discountMinor: number | null;
|
||||||
|
readonly validationLines: ValidationLine[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export class PayStation {
|
export class PayStation {
|
||||||
@@ -155,19 +171,28 @@ export class PayStation {
|
|||||||
// Pure pricing shared with the Tariff Lab (priceSession). Only the latest payment
|
// Pure pricing shared with the Tariff Lab (priceSession). Only the latest payment
|
||||||
// matters for grace/overstay; pass it through. Overstay → fresh period from
|
// matters for grace/overstay; pass it through. Overstay → fresh period from
|
||||||
// grace-expiry; within-grace → settled; unpaid → entry→now running total.
|
// grace-expiry; within-grace → settled; unpaid → entry→now running total.
|
||||||
|
// Merchant validations: fold the LIVE ones (applied, unvoided, not consumed by a
|
||||||
|
// prior payment) so the quote is NET — the payment then stamps their ids as
|
||||||
|
// consumed. See wiki/concepts/validation-discounts.md.
|
||||||
const last = this.#lastPayment(identity);
|
const last = this.#lastPayment(identity);
|
||||||
|
const validations = liveValidations(this.#db, identity);
|
||||||
const p = priceSession(
|
const p = priceSession(
|
||||||
entry.occurredAt,
|
entry.occurredAt,
|
||||||
new Date().toISOString(),
|
new Date().toISOString(),
|
||||||
structure,
|
structure,
|
||||||
last ? [last] : [],
|
last ? [last] : [],
|
||||||
category,
|
category,
|
||||||
|
validations,
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
identity,
|
identity,
|
||||||
enteredAt: entry.occurredAt,
|
enteredAt: entry.occurredAt,
|
||||||
periodStart: p.periodStart,
|
periodStart: p.periodStart,
|
||||||
amountMinor: p.amountMinor,
|
amountMinor: p.amountMinor,
|
||||||
|
grossMinor: p.grossMinor,
|
||||||
|
discountMinor: p.discountMinor,
|
||||||
|
validationLines: p.validationLines,
|
||||||
|
validationIds: validations.map((v) => v.eventId),
|
||||||
overstay: p.overstay,
|
overstay: p.overstay,
|
||||||
currency: tv.currency,
|
currency: tv.currency,
|
||||||
tariffVersionId: tv.id,
|
tariffVersionId: tv.id,
|
||||||
@@ -246,6 +271,18 @@ export class PayStation {
|
|||||||
// The exit flow reads graceExitMin off the payment to validate the
|
// The exit flow reads graceExitMin off the payment to validate the
|
||||||
// walk-back window without re-resolving the tariff.
|
// walk-back window without re-resolving the tariff.
|
||||||
graceExitMin: q.graceExitMin,
|
graceExitMin: q.graceExitMin,
|
||||||
|
// Merchant validations: record the gross/discount split + CONSUME the applied
|
||||||
|
// validation ids, so reporting sees the leakage and a later overstay period
|
||||||
|
// never re-applies them. A zero-net settlement (full comp) is still a signed
|
||||||
|
// payment — grace/voucher/exit work unchanged. See validation-discounts.md.
|
||||||
|
...(q.validationIds.length
|
||||||
|
? {
|
||||||
|
grossMinor: q.grossMinor,
|
||||||
|
discountMinor: q.discountMinor,
|
||||||
|
validationIds: q.validationIds,
|
||||||
|
validationLines: q.validationLines.map((l) => ({ ...l })),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
...(overrideMinor != null ? { reason: "operator-set amount", quotedMinor: q.amountMinor } : {}),
|
...(overrideMinor != null ? { reason: "operator-set amount", quotedMinor: q.amountMinor } : {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -282,6 +319,7 @@ export class PayStation {
|
|||||||
paidAt: null, amountMinor: null, currency: null, paidMinor: null, paidCurrency: null,
|
paidAt: null, amountMinor: null, currency: null, paidMinor: null, paidCurrency: null,
|
||||||
withinGrace: false, graceExpiresAt: null,
|
withinGrace: false, graceExpiresAt: null,
|
||||||
overstay: false, subscription: false, subscriptionId: null, subscriptionHolder: null, plate: null,
|
overstay: false, subscription: false, subscriptionId: null, subscriptionHolder: null, plate: null,
|
||||||
|
grossMinor: null, discountMinor: null, validationLines: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
// Subscription occurrence? The entry payload carries permit:true + permitId.
|
// Subscription occurrence? The entry payload carries permit:true + permitId.
|
||||||
@@ -319,11 +357,17 @@ export class PayStation {
|
|||||||
// exit gate clears. See wiki/entities/subscription.md.
|
// exit gate clears. See wiki/entities/subscription.md.
|
||||||
let amountMinor: number | null = null;
|
let amountMinor: number | null = null;
|
||||||
let currency: string | null = null;
|
let currency: string | null = null;
|
||||||
|
let grossMinor: number | null = null;
|
||||||
|
let discountMinor: number | null = null;
|
||||||
|
let validationLines: ValidationLine[] = [];
|
||||||
if (open && !isSubscription) {
|
if (open && !isSubscription) {
|
||||||
try {
|
try {
|
||||||
const q = this.quote(id);
|
const q = this.quote(id);
|
||||||
amountMinor = q.amountMinor;
|
amountMinor = q.amountMinor;
|
||||||
currency = q.currency;
|
currency = q.currency;
|
||||||
|
grossMinor = q.grossMinor;
|
||||||
|
discountMinor = q.discountMinor;
|
||||||
|
validationLines = q.validationLines;
|
||||||
} catch {
|
} catch {
|
||||||
/* no active tariff — leave null; modal shows session without a price */
|
/* no active tariff — leave null; modal shows session without a price */
|
||||||
}
|
}
|
||||||
@@ -344,6 +388,7 @@ export class PayStation {
|
|||||||
subscription: isSubscription, subscriptionId,
|
subscription: isSubscription, subscriptionId,
|
||||||
subscriptionHolder: this.#holderOf(subscriptionId),
|
subscriptionHolder: this.#holderOf(subscriptionId),
|
||||||
plate: plateForIdentity(this.#db, id)?.plate ?? null,
|
plate: plateForIdentity(this.#db, id)?.plate ?? null,
|
||||||
|
grossMinor, discountMinor, validationLines,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -181,3 +181,68 @@ describe("reportSummary — duration (sessions cache) + subscriptions", () => {
|
|||||||
expect(r.subscriptions.coveredCars).toBe(2);
|
expect(r.subscriptions.coveredCars).toBe(2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("reportSummary — occupancy, heatmap, stay histogram, look-closer counters (2026-07-05)", () => {
|
||||||
|
it("folds prior ledger into occupancyStart and walks occupancyEnd through the series", async () => {
|
||||||
|
// Before the range: 3 entries, 1 exit → 2 cars inside when June opens.
|
||||||
|
await entry(at("2026-05-20T08:00:00Z"));
|
||||||
|
await entry(at("2026-05-20T09:00:00Z"));
|
||||||
|
await entry(at("2026-05-21T10:00:00Z"));
|
||||||
|
await exit(at("2026-05-21T12:00:00Z"));
|
||||||
|
// In range: +2 on the 10th, −1 on the 11th.
|
||||||
|
await entry(at("2026-06-10T08:00:00Z"));
|
||||||
|
await entry(at("2026-06-10T09:00:00Z"));
|
||||||
|
await exit(at("2026-06-11T09:00:00Z"));
|
||||||
|
|
||||||
|
const r = reportSummary(db, { ...RANGE, bucket: "day" });
|
||||||
|
expect(r.occupancyStart).toBe(2);
|
||||||
|
expect(r.series.map((p) => [p.bucket, p.occupancyEnd])).toEqual([
|
||||||
|
["2026-06-10", 4],
|
||||||
|
["2026-06-11", 3],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a voided pre-range entry does not inflate occupancyStart", async () => {
|
||||||
|
const id = randomUUID();
|
||||||
|
await log.append({ type: "vehicle_entry", direction: "entry", identity: id, occurredAt: at("2026-05-20T08:00:00Z") });
|
||||||
|
await log.append({ type: "void", identity: id, occurredAt: at("2026-05-20T08:05:00Z"), payload: { reason: "misprint" } });
|
||||||
|
const r = reportSummary(db, { ...RANGE, bucket: "day" });
|
||||||
|
expect(r.occupancyStart).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("entriesByDowHour lands on the local weekday/hour (row 0 = Monday)", async () => {
|
||||||
|
// 2026-06-10 is a WEDNESDAY; 08:00Z = 10:00 in Tirane (UTC+2 in June).
|
||||||
|
await entry(at("2026-06-10T08:00:00Z"));
|
||||||
|
const r = reportSummary(db, { ...RANGE, bucket: "day" });
|
||||||
|
expect(r.entriesByDowHour[2]![10]).toBe(1); // Wed row, 10h column
|
||||||
|
expect(r.entriesByDowHour.flat().reduce((a, b) => a + b, 0)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stay histogram buckets closed sessions; series carries the cash/card split", async () => {
|
||||||
|
db.insert(sessions).values({ id: "h1", identity: "h1", enteredAt: at("2026-06-10T08:00:00Z"), exitedAt: at("2026-06-10T08:20:00Z"), state: "closed" }).run(); // 20m → ≤30
|
||||||
|
db.insert(sessions).values({ id: "h2", identity: "h2", enteredAt: at("2026-06-10T08:00:00Z"), exitedAt: at("2026-06-10T09:30:00Z"), state: "closed" }).run(); // 90m → ≤120
|
||||||
|
db.insert(sessions).values({ id: "h3", identity: "h3", enteredAt: at("2026-06-08T08:00:00Z"), exitedAt: at("2026-06-10T09:00:00Z"), state: "closed" }).run(); // 2 days → >24h tail
|
||||||
|
await payment(at("2026-06-10T09:00:00Z"), 500, { tender: "cash" });
|
||||||
|
await payment(at("2026-06-10T09:30:00Z"), 700, { tender: "card" });
|
||||||
|
|
||||||
|
const r = reportSummary(db, { ...RANGE, bucket: "day" });
|
||||||
|
const counts = Object.fromEntries(r.stayHistogram.map((b) => [String(b.uptoMin), b.count]));
|
||||||
|
expect(counts["30"]).toBe(1);
|
||||||
|
expect(counts["120"]).toBe(1);
|
||||||
|
expect(counts["null"]).toBe(1);
|
||||||
|
const day = r.series.find((p) => p.bucket === "2026-06-10")!;
|
||||||
|
expect(day.cashMinor).toBe(500);
|
||||||
|
expect(day.cardMinor).toBe(700);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("counts voids and anomalies in range (the look-closer counters)", async () => {
|
||||||
|
const id = randomUUID();
|
||||||
|
await log.append({ type: "vehicle_entry", direction: "entry", identity: id, occurredAt: at("2026-06-10T08:00:00Z") });
|
||||||
|
await log.append({ type: "void", identity: id, occurredAt: at("2026-06-10T08:05:00Z"), payload: { reason: "misprint" } });
|
||||||
|
await log.append({ type: "anomaly", identity: "X", occurredAt: at("2026-06-10T09:00:00Z"), payload: { reason: "test" } });
|
||||||
|
const r = reportSummary(db, { ...RANGE, bucket: "day" });
|
||||||
|
expect(r.totals.voids).toBe(1);
|
||||||
|
expect(r.totals.anomalies).toBe(1);
|
||||||
|
expect(r.totals.entries).toBe(0); // the voided entry stays excluded
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ import {
|
|||||||
desc,
|
desc,
|
||||||
eq,
|
eq,
|
||||||
gte,
|
gte,
|
||||||
|
lt,
|
||||||
lte,
|
lte,
|
||||||
ledgerEvents,
|
ledgerEvents,
|
||||||
sessions,
|
sessions,
|
||||||
|
siteConfig,
|
||||||
subscriptions,
|
subscriptions,
|
||||||
tariffVersions,
|
tariffVersions,
|
||||||
tariffs,
|
tariffs,
|
||||||
@@ -46,8 +48,13 @@ export interface SeriesPoint {
|
|||||||
readonly exits: number;
|
readonly exits: number;
|
||||||
/** Net transient revenue collected in the bucket (minor units), all tenders. */
|
/** Net transient revenue collected in the bucket (minor units), all tenders. */
|
||||||
readonly revenueMinor: number;
|
readonly revenueMinor: number;
|
||||||
|
/** Tender split of the bucket's revenue (cash = everything not card). */
|
||||||
|
readonly cashMinor: number;
|
||||||
|
readonly cardMinor: number;
|
||||||
/** Payment COUNT in the bucket (transactions, not amount). */
|
/** Payment COUNT in the bucket (transactions, not amount). */
|
||||||
readonly payments: number;
|
readonly payments: number;
|
||||||
|
/** Cars inside at the END of the bucket (occupancyStart + running entries−exits). */
|
||||||
|
readonly occupancyEnd: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReportTotals {
|
export interface ReportTotals {
|
||||||
@@ -67,6 +74,10 @@ export interface ReportTotals {
|
|||||||
readonly totalParkedMinutes: number;
|
readonly totalParkedMinutes: number;
|
||||||
readonly avgParkedMinutes: number;
|
readonly avgParkedMinutes: number;
|
||||||
readonly medianParkedMinutes: number;
|
readonly medianParkedMinutes: number;
|
||||||
|
/** Cancelled tickets + signed anomalies in range — the "look closer" counters
|
||||||
|
* (the operator at the booth is the threat model's primary adversary). */
|
||||||
|
readonly voids: number;
|
||||||
|
readonly anomalies: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SubscriptionStats {
|
export interface SubscriptionStats {
|
||||||
@@ -79,6 +90,13 @@ export interface SubscriptionStats {
|
|||||||
readonly coveredCars: number;
|
readonly coveredCars: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One bar of the stay-duration histogram: stays up to `uptoMin` minutes (null = the
|
||||||
|
* open-ended tail). Edges chosen to mirror how tariffs are designed (see tariff.md). */
|
||||||
|
export interface StayBucket {
|
||||||
|
readonly uptoMin: number | null;
|
||||||
|
readonly count: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ReportSummary {
|
export interface ReportSummary {
|
||||||
readonly from: string;
|
readonly from: string;
|
||||||
readonly to: string;
|
readonly to: string;
|
||||||
@@ -89,25 +107,43 @@ export interface ReportSummary {
|
|||||||
readonly series: SeriesPoint[];
|
readonly series: SeriesPoint[];
|
||||||
/** Entries by local hour-of-day (0–23), summed across the range — the peak-hour view. */
|
/** Entries by local hour-of-day (0–23), summed across the range — the peak-hour view. */
|
||||||
readonly entriesByHour: number[];
|
readonly entriesByHour: number[];
|
||||||
|
/** Entries by [day-of-week][hour-of-day] — 7×24, row 0 = Monday. The heatmap that
|
||||||
|
* shows weekday-vs-weekend patterns (feeds tariff-window design). */
|
||||||
|
readonly entriesByDowHour: number[][];
|
||||||
|
/** Stay-duration histogram over closed sessions in range. */
|
||||||
|
readonly stayHistogram: StayBucket[];
|
||||||
|
/** Cars inside when the range OPENS (folded from the whole prior ledger). */
|
||||||
|
readonly occupancyStart: number;
|
||||||
|
/** Nominal capacity from site config (null = uncapped) — the reference line. */
|
||||||
|
readonly capacity: number | null;
|
||||||
readonly subscriptions: SubscriptionStats;
|
readonly subscriptions: SubscriptionStats;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Local wall-clock parts of an ISO instant in a given IANA tz. Reuses Intl (no dep). */
|
/** Local wall-clock parts of an ISO instant in a given IANA tz. Reuses Intl (no dep). */
|
||||||
function localParts(iso: string, tz: string): { y: number; mo: number; d: number; h: number } {
|
const fmtCache = new Map<string, Intl.DateTimeFormat>();
|
||||||
const fmt = new Intl.DateTimeFormat("en-CA", {
|
const DOW_INDEX: Record<string, number> = { Mon: 0, Tue: 1, Wed: 2, Thu: 3, Fri: 4, Sat: 5, Sun: 6 };
|
||||||
|
function localParts(iso: string, tz: string): { y: number; mo: number; d: number; h: number; dow: number } {
|
||||||
|
// Cached per tz — this runs once per ledger row in a report.
|
||||||
|
let fmt = fmtCache.get(tz);
|
||||||
|
if (!fmt) {
|
||||||
|
fmt = new Intl.DateTimeFormat("en-US", {
|
||||||
timeZone: tz,
|
timeZone: tz,
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
month: "2-digit",
|
month: "2-digit",
|
||||||
day: "2-digit",
|
day: "2-digit",
|
||||||
hour: "2-digit",
|
hour: "2-digit",
|
||||||
hourCycle: "h23",
|
hourCycle: "h23",
|
||||||
|
weekday: "short",
|
||||||
});
|
});
|
||||||
|
fmtCache.set(tz, fmt);
|
||||||
|
}
|
||||||
const parts = Object.fromEntries(fmt.formatToParts(new Date(iso)).map((p) => [p.type, p.value]));
|
const parts = Object.fromEntries(fmt.formatToParts(new Date(iso)).map((p) => [p.type, p.value]));
|
||||||
return {
|
return {
|
||||||
y: Number(parts.year),
|
y: Number(parts.year),
|
||||||
mo: Number(parts.month),
|
mo: Number(parts.month),
|
||||||
d: Number(parts.day),
|
d: Number(parts.day),
|
||||||
h: Number(parts.hour),
|
h: Number(parts.hour),
|
||||||
|
dow: DOW_INDEX[parts.weekday ?? ""] ?? 0, // row 0 = Monday
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,6 +198,7 @@ export function reportSummary(db: Db, q: ReportQuery): ReportSummary {
|
|||||||
|
|
||||||
const seriesMap = new Map<string, SeriesPoint>();
|
const seriesMap = new Map<string, SeriesPoint>();
|
||||||
const entriesByHour = new Array<number>(24).fill(0);
|
const entriesByHour = new Array<number>(24).fill(0);
|
||||||
|
const entriesByDowHour = Array.from({ length: 7 }, () => new Array<number>(24).fill(0));
|
||||||
const totals = {
|
const totals = {
|
||||||
entries: 0,
|
entries: 0,
|
||||||
exits: 0,
|
exits: 0,
|
||||||
@@ -172,12 +209,14 @@ export function reportSummary(db: Db, q: ReportQuery): ReportSummary {
|
|||||||
ticketMinor: 0,
|
ticketMinor: 0,
|
||||||
subscriptionSalesMinor: 0,
|
subscriptionSalesMinor: 0,
|
||||||
subscriptionWindowMinor: 0,
|
subscriptionWindowMinor: 0,
|
||||||
|
voids: 0,
|
||||||
|
anomalies: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
function point(label: string): SeriesPoint {
|
function point(label: string): SeriesPoint {
|
||||||
let p = seriesMap.get(label);
|
let p = seriesMap.get(label);
|
||||||
if (!p) {
|
if (!p) {
|
||||||
p = { bucket: label, entries: 0, exits: 0, revenueMinor: 0, payments: 0 };
|
p = { bucket: label, entries: 0, exits: 0, revenueMinor: 0, cashMinor: 0, cardMinor: 0, payments: 0, occupancyEnd: 0 };
|
||||||
seriesMap.set(label, p);
|
seriesMap.set(label, p);
|
||||||
}
|
}
|
||||||
return p;
|
return p;
|
||||||
@@ -196,11 +235,16 @@ export function reportSummary(db: Db, q: ReportQuery): ReportSummary {
|
|||||||
if (row.identity && voided.has(row.identity)) continue; // cancelled — not a real entry
|
if (row.identity && voided.has(row.identity)) continue; // cancelled — not a real entry
|
||||||
totals.entries++;
|
totals.entries++;
|
||||||
p.entries++;
|
p.entries++;
|
||||||
const h = localParts(row.occurredAt, tz).h;
|
const lp = localParts(row.occurredAt, tz);
|
||||||
entriesByHour[h] = (entriesByHour[h] ?? 0) + 1;
|
entriesByHour[lp.h] = (entriesByHour[lp.h] ?? 0) + 1;
|
||||||
|
entriesByDowHour[lp.dow]![lp.h] = (entriesByDowHour[lp.dow]![lp.h] ?? 0) + 1;
|
||||||
} else if (row.type === "vehicle_exit") {
|
} else if (row.type === "vehicle_exit") {
|
||||||
totals.exits++;
|
totals.exits++;
|
||||||
p.exits++;
|
p.exits++;
|
||||||
|
} else if (row.type === "void") {
|
||||||
|
totals.voids++;
|
||||||
|
} else if (row.type === "anomaly") {
|
||||||
|
totals.anomalies++;
|
||||||
} else if (row.type === "payment") {
|
} else if (row.type === "payment") {
|
||||||
const pl = (row.payload ?? {}) as PaymentPayload;
|
const pl = (row.payload ?? {}) as PaymentPayload;
|
||||||
const amt = typeof pl.amountMinor === "number" ? pl.amountMinor : 0;
|
const amt = typeof pl.amountMinor === "number" ? pl.amountMinor : 0;
|
||||||
@@ -209,8 +253,13 @@ export function reportSummary(db: Db, q: ReportQuery): ReportSummary {
|
|||||||
totals.revenueMinor += amt;
|
totals.revenueMinor += amt;
|
||||||
p.payments++;
|
p.payments++;
|
||||||
p.revenueMinor += amt;
|
p.revenueMinor += amt;
|
||||||
if (pl.tender === "card") totals.cardMinor += amt;
|
if (pl.tender === "card") {
|
||||||
else totals.cashMinor += amt;
|
totals.cardMinor += amt;
|
||||||
|
p.cardMinor += amt;
|
||||||
|
} else {
|
||||||
|
totals.cashMinor += amt;
|
||||||
|
p.cashMinor += amt;
|
||||||
|
}
|
||||||
// Revenue split mirrors the shift Z-report: subscription sale / window charge /
|
// Revenue split mirrors the shift Z-report: subscription sale / window charge /
|
||||||
// (the rest is) transient ticket revenue.
|
// (the rest is) transient ticket revenue.
|
||||||
if (pl.subscriptionSale === true) totals.subscriptionSalesMinor += amt;
|
if (pl.subscriptionSale === true) totals.subscriptionSalesMinor += amt;
|
||||||
@@ -221,6 +270,31 @@ export function reportSummary(db: Db, q: ReportQuery): ReportSummary {
|
|||||||
|
|
||||||
const series = [...seriesMap.values()].sort((a, b) => a.bucket.localeCompare(b.bucket));
|
const series = [...seriesMap.values()].sort((a, b) => a.bucket.localeCompare(b.bucket));
|
||||||
|
|
||||||
|
// --- Occupancy: fold the PRIOR ledger for cars-inside at range start, then walk the
|
||||||
|
// series. Voided pre-range entries cancel out the same way the in-range pass does.
|
||||||
|
// Sparse buckets (no events) simply carry the previous level — the step line is exact
|
||||||
|
// at every plotted point.
|
||||||
|
const prior = db
|
||||||
|
.select({ type: ledgerEvents.type, identity: ledgerEvents.identity })
|
||||||
|
.from(ledgerEvents)
|
||||||
|
.where(lt(ledgerEvents.occurredAt, q.from))
|
||||||
|
.all();
|
||||||
|
const priorVoided = new Set<string>();
|
||||||
|
for (const r of prior) if (r.type === "void" && r.identity) priorVoided.add(r.identity);
|
||||||
|
let occupancyStart = 0;
|
||||||
|
for (const r of prior) {
|
||||||
|
if (r.type === "vehicle_entry" && !(r.identity && priorVoided.has(r.identity))) occupancyStart++;
|
||||||
|
else if (r.type === "vehicle_exit") occupancyStart--;
|
||||||
|
}
|
||||||
|
occupancyStart = Math.max(0, occupancyStart);
|
||||||
|
let running = occupancyStart;
|
||||||
|
for (const p of series) {
|
||||||
|
running = Math.max(0, running + p.entries - p.exits);
|
||||||
|
(p as { -readonly [K in keyof SeriesPoint]: SeriesPoint[K] }).occupancyEnd = running;
|
||||||
|
}
|
||||||
|
|
||||||
|
const capacity = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get()?.capacity ?? null;
|
||||||
|
|
||||||
// No payment in range? Fall back to the site tariff's latest version currency, so a
|
// No payment in range? Fall back to the site tariff's latest version currency, so a
|
||||||
// zero-revenue range still labels its money column.
|
// zero-revenue range still labels its money column.
|
||||||
if (!currency) {
|
if (!currency) {
|
||||||
@@ -251,6 +325,19 @@ export function reportSummary(db: Db, q: ReportQuery): ReportSummary {
|
|||||||
durations.sort((a, b) => a - b);
|
durations.sort((a, b) => a - b);
|
||||||
const totalParkedMinutes = durations.reduce((a, b) => a + b, 0);
|
const totalParkedMinutes = durations.reduce((a, b) => a + b, 0);
|
||||||
|
|
||||||
|
// Stay-duration histogram. Edges mirror how rate cards are designed (30m/1h bands,
|
||||||
|
// the 8h working day, the 24h rolling day) so the chart answers "where should the
|
||||||
|
// ladder/up-to breakpoints sit". Last bucket is the open-ended >24h tail.
|
||||||
|
const STAY_EDGES_MIN = [30, 60, 120, 240, 480, 1440];
|
||||||
|
const stayHistogram: { uptoMin: number | null; count: number }[] = [
|
||||||
|
...STAY_EDGES_MIN.map((uptoMin) => ({ uptoMin, count: 0 })),
|
||||||
|
{ uptoMin: null, count: 0 },
|
||||||
|
];
|
||||||
|
for (const mins of durations) {
|
||||||
|
const i = STAY_EDGES_MIN.findIndex((edge) => mins <= edge);
|
||||||
|
stayHistogram[i === -1 ? STAY_EDGES_MIN.length : i]!.count++;
|
||||||
|
}
|
||||||
|
|
||||||
// --- Subscriptions: status counts + currently-valid (window covers `to`).
|
// --- Subscriptions: status counts + currently-valid (window covers `to`).
|
||||||
const subs = db.select().from(subscriptions).all();
|
const subs = db.select().from(subscriptions).all();
|
||||||
const subStats = { active: 0, suspended: 0, revoked: 0, currentlyValid: 0, coveredCars: 0 };
|
const subStats = { active: 0, suspended: 0, revoked: 0, currentlyValid: 0, coveredCars: 0 };
|
||||||
@@ -283,6 +370,10 @@ export function reportSummary(db: Db, q: ReportQuery): ReportSummary {
|
|||||||
},
|
},
|
||||||
series,
|
series,
|
||||||
entriesByHour,
|
entriesByHour,
|
||||||
|
entriesByDowHour,
|
||||||
|
stayHistogram,
|
||||||
|
occupancyStart,
|
||||||
|
capacity,
|
||||||
subscriptions: subStats,
|
subscriptions: subStats,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,7 +65,21 @@ function cleanProfileField(v: string | null | undefined): string | null | undefi
|
|||||||
|
|
||||||
/** The session shape the SPA bootstraps from: identity + role + its permission
|
/** The session shape the SPA bootstraps from: identity + role + its permission
|
||||||
* list (so the UI can gate nav/routes) + language. Role NAME is for display; the
|
* list (so the UI can gate nav/routes) + language. Role NAME is for display; the
|
||||||
* permissions are the source of truth. */
|
* permissions are the source of truth.
|
||||||
|
*
|
||||||
|
* `csrf`, when passed, echoes the SAME value already sent as the readable
|
||||||
|
* parking_csrf cookie — not a new secret, just a second channel to learn it.
|
||||||
|
* The desktop shell needs this: tauri-plugin-http's fetch() runs through
|
||||||
|
* Rust's reqwest, which keeps its own cookie jar separate from the webview,
|
||||||
|
* so document.cookie on the tauri://localhost page never sees a cookie set
|
||||||
|
* on a plugin-routed response (open upstream bug, tauri-apps/tauri#13045).
|
||||||
|
* The cookie itself IS still sent back to the server by reqwest on
|
||||||
|
* subsequent requests — only the *client-side read* is broken — so
|
||||||
|
* api.ts's desktop path stashes this body value in memory instead of
|
||||||
|
* reading document.cookie, and echoes it in X-CSRF-Token exactly as the
|
||||||
|
* browser path echoes the cookie. See lib/api.ts and assertCsrf() in
|
||||||
|
* ../auth.ts (unchanged — this never touches verification, only how the
|
||||||
|
* desktop client learns what to send). */
|
||||||
function sessionView(
|
function sessionView(
|
||||||
db: Db,
|
db: Db,
|
||||||
user: {
|
user: {
|
||||||
@@ -78,6 +92,7 @@ function sessionView(
|
|||||||
fullName?: string | null;
|
fullName?: string | null;
|
||||||
email?: string | null;
|
email?: string | null;
|
||||||
},
|
},
|
||||||
|
csrf?: string,
|
||||||
) {
|
) {
|
||||||
const role = db.select().from(roles).where(eq(roles.id, user.roleId)).get();
|
const role = db.select().from(roles).where(eq(roles.id, user.roleId)).get();
|
||||||
const permissions = [...permissionsFor(user.roleId)];
|
const permissions = [...permissionsFor(user.roleId)];
|
||||||
@@ -92,6 +107,7 @@ function sessionView(
|
|||||||
fontScale: user.fontScale,
|
fontScale: user.fontScale,
|
||||||
fullName: user.fullName ?? null,
|
fullName: user.fullName ?? null,
|
||||||
email: user.email ?? null,
|
email: user.email ?? null,
|
||||||
|
...(csrf ? { csrfToken: csrf } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,7 +142,7 @@ export async function authRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
|||||||
setAuthCookies(reply, token, csrf);
|
setAuthCookies(reply, token, csrf);
|
||||||
// `language` is NOT in the JWT (identity/role only) — it's a mutable preference
|
// `language` is NOT in the JWT (identity/role only) — it's a mutable preference
|
||||||
// read from the DB, so changing it needs no token refresh.
|
// read from the DB, so changing it needs no token refresh.
|
||||||
return sessionView(db, user);
|
return sessionView(db, user, csrf);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/auth/logout", async (_req, reply) => {
|
app.post("/api/auth/logout", async (_req, reply) => {
|
||||||
@@ -146,7 +162,9 @@ export async function authRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
|||||||
clearAuthCookies(reply);
|
clearAuthCookies(reply);
|
||||||
return reply.code(401).send({ error: "session no longer valid" });
|
return reply.code(401).send({ error: "session no longer valid" });
|
||||||
}
|
}
|
||||||
return sessionView(db, row);
|
// req.user.csrf is the value bound into the JWT at login (see assertCsrf in
|
||||||
|
// ../auth.ts) — same value as the cookie, re-surfaced for the desktop path.
|
||||||
|
return sessionView(db, row, req.user.csrf);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -48,9 +48,18 @@ export async function reportRoutes(app: FastifyInstance, db: Db): Promise<void>
|
|||||||
async (req, reply) => {
|
async (req, reply) => {
|
||||||
const summary = reportSummary(db, parseQuery(req.query));
|
const summary = reportSummary(db, parseQuery(req.query));
|
||||||
const lines = [
|
const lines = [
|
||||||
"bucket,entries,exits,payments,revenue",
|
"bucket,entries,exits,payments,revenue,cash,card,occupancy_end",
|
||||||
...summary.series.map((p) =>
|
...summary.series.map((p) =>
|
||||||
[p.bucket, p.entries, p.exits, p.payments, (p.revenueMinor / 100).toFixed(2)].join(","),
|
[
|
||||||
|
p.bucket,
|
||||||
|
p.entries,
|
||||||
|
p.exits,
|
||||||
|
p.payments,
|
||||||
|
(p.revenueMinor / 100).toFixed(2),
|
||||||
|
(p.cashMinor / 100).toFixed(2),
|
||||||
|
(p.cardMinor / 100).toFixed(2),
|
||||||
|
p.occupancyEnd,
|
||||||
|
].join(","),
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
reply
|
reply
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ describe("health + login", () => {
|
|||||||
it("GET /health is open", async () => {
|
it("GET /health is open", async () => {
|
||||||
const res = await app.inject({ method: "GET", url: "/health" });
|
const res = await app.inject({ method: "GET", url: "/health" });
|
||||||
expect(res.statusCode).toBe(200);
|
expect(res.statusCode).toBe(200);
|
||||||
expect(res.json()).toEqual({ status: "ok" });
|
expect(res.json()).toEqual({ status: "ok", app: "parking-system" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("login with bad credentials is rejected", async () => {
|
it("login with bad credentials is rejected", async () => {
|
||||||
@@ -56,6 +56,37 @@ describe("auth guard — no token", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("GET /api/version", () => {
|
||||||
|
it("without a session is 401", async () => {
|
||||||
|
const res = await app.inject({ method: "GET", url: "/api/version" });
|
||||||
|
expect(res.statusCode).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a site:read user gets the BUILD_VERSION env var, null when unset", async () => {
|
||||||
|
const { username, password } = await seedUser(db, {
|
||||||
|
username: "viewer2", roleId: "viewer2", permissions: ["site:read"],
|
||||||
|
});
|
||||||
|
const { cookie } = await login(app, username, password);
|
||||||
|
const res = await app.inject({ method: "GET", url: "/api/version", headers: { cookie } });
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.json()).toEqual({ buildVersion: null }); // no BUILD_VERSION set in the test env
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reflects a real BUILD_VERSION when the env var is set", async () => {
|
||||||
|
process.env.BUILD_VERSION = "stage-abc1234";
|
||||||
|
try {
|
||||||
|
const { username, password } = await seedUser(db, {
|
||||||
|
username: "viewer3", roleId: "viewer3", permissions: ["site:read"],
|
||||||
|
});
|
||||||
|
const { cookie } = await login(app, username, password);
|
||||||
|
const res = await app.inject({ method: "GET", url: "/api/version", headers: { cookie } });
|
||||||
|
expect(res.json()).toEqual({ buildVersion: "stage-abc1234" });
|
||||||
|
} finally {
|
||||||
|
delete process.env.BUILD_VERSION;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("RBAC permission gate", () => {
|
describe("RBAC permission gate", () => {
|
||||||
it("a site:read-only user can GET occupancy but is 403 on PUT site-config", async () => {
|
it("a site:read-only user can GET occupancy but is 403 on PUT site-config", async () => {
|
||||||
const { username, password } = await seedUser(db, {
|
const { username, password } = await seedUser(db, {
|
||||||
|
|||||||
@@ -560,6 +560,39 @@ export async function setupRoutes(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// USB printers PRESENT on the box: enumerate /dev/usb/lpN (the usblp nodes the
|
||||||
|
// container sees via the /dev/usb bind-mount) and enrich each with the printer's
|
||||||
|
// self-reported make/model from sysfs (ieee1284_id — readable through Docker's
|
||||||
|
// default ro /sys). The wizard offers these as a SELECT so the admin never has to
|
||||||
|
// shell in and `ls /dev/usb` to learn the kernel picked lp1 (field friction,
|
||||||
|
// park-buzi 2026-07-07). Empty list = no usblp printer plugged/visible.
|
||||||
|
app.get("/api/setup/usb-printers", { preHandler: adminGuard }, async () => {
|
||||||
|
const { readdir, readFile } = await import("node:fs/promises");
|
||||||
|
let names: string[] = [];
|
||||||
|
try {
|
||||||
|
names = (await readdir("/dev/usb")).filter((n) => /^lp\d+$/.test(n)).sort();
|
||||||
|
} catch {
|
||||||
|
return { printers: [] }; // no /dev/usb at all — nothing plugged (or no mount)
|
||||||
|
}
|
||||||
|
const printers = await Promise.all(
|
||||||
|
names.map(async (n) => {
|
||||||
|
// ieee1284_id: "MFG:Xprinter;CMD:ESCPOS;MDL:XP-K200L;…" — best-effort.
|
||||||
|
let description: string | null = null;
|
||||||
|
try {
|
||||||
|
const id = await readFile(`/sys/class/usbmisc/${n}/device/ieee1284_id`, "utf8");
|
||||||
|
const pick = (key: string) => id.match(new RegExp(`(?:^|;)\\s*${key}:([^;]+)`, "i"))?.[1]?.trim();
|
||||||
|
const mfg = pick("MFG") ?? pick("MANUFACTURER");
|
||||||
|
const mdl = pick("MDL") ?? pick("MODEL");
|
||||||
|
description = [mfg, mdl].filter(Boolean).join(" ") || null;
|
||||||
|
} catch {
|
||||||
|
/* sysfs not readable / attribute absent — path alone is still useful */
|
||||||
|
}
|
||||||
|
return { path: `/dev/usb/${n}`, description };
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return { printers };
|
||||||
|
});
|
||||||
|
|
||||||
// Assign a device. Validates the chosen driver + config, configures the device
|
// Assign a device. Validates the chosen driver + config, configures the device
|
||||||
// (fix preconditions + set up Digest-authenticated input push — no manual device-
|
// (fix preconditions + set up Digest-authenticated input push — no manual device-
|
||||||
// web-UI step by the admin), then persists. Fails the save if the device can't be
|
// web-UI step by the admin), then persists. Fails the save if the device can't be
|
||||||
|
|||||||
@@ -78,6 +78,15 @@ export async function siteRoutes(app: FastifyInstance, db: Db, eventLog?: EventL
|
|||||||
// Live occupancy: cars inside, capacity, free, full. Any signed-in role.
|
// Live occupancy: cars inside, capacity, free, full. Any signed-in role.
|
||||||
app.get("/api/occupancy", { preHandler: readGuard }, async () => getOccupancy(db));
|
app.get("/api/occupancy", { preHandler: readGuard }, async () => getOccupancy(db));
|
||||||
|
|
||||||
|
// Running build version ("<branch>-<short-sha>", matching the Komodo Stack's TAG in
|
||||||
|
// komodo/resources.toml) — baked in at image build time (apps/server/Dockerfile
|
||||||
|
// BUILD_VERSION ARG), read here from the running process env. null on a local/dev
|
||||||
|
// build with no CI-supplied value. Purely informational (Setup nav display); not
|
||||||
|
// site config, so it isn't stored in site_config.
|
||||||
|
app.get("/api/version", { preHandler: readGuard }, async () => ({
|
||||||
|
buildVersion: process.env.BUILD_VERSION?.trim() || null,
|
||||||
|
}));
|
||||||
|
|
||||||
// Read site config (capacity + park metadata).
|
// Read site config (capacity + park metadata).
|
||||||
app.get("/api/site-config", { preHandler: readGuard }, async () => {
|
app.get("/api/site-config", { preHandler: readGuard }, async () => {
|
||||||
const row = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
const row = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { FastifyInstance } from "fastify";
|
|||||||
import { and, desc, eq, isNull, ledgerEvents, siteConfig, tariffDrafts, tariffVersions, tariffs, type Db } from "@parking/db";
|
import { and, desc, eq, isNull, ledgerEvents, siteConfig, tariffDrafts, tariffVersions, tariffs, type Db } from "@parking/db";
|
||||||
import {
|
import {
|
||||||
computeFee,
|
computeFee,
|
||||||
|
explainFee,
|
||||||
isTariffV2,
|
isTariffV2,
|
||||||
priceSession,
|
priceSession,
|
||||||
validateTariffStructure,
|
validateTariffStructure,
|
||||||
@@ -188,6 +189,12 @@ export async function tariffRoutes(app: FastifyInstance, db: Db): Promise<void>
|
|||||||
const payments = Array.isArray(b.payments) ? b.payments : [];
|
const payments = Array.isArray(b.payments) ? b.payments : [];
|
||||||
const pricing = priceSession(b.enteredAt, b.asOf, structure, payments, b.category);
|
const pricing = priceSession(b.enteredAt, b.asOf, structure, payments, b.category);
|
||||||
|
|
||||||
|
// HOW the amount is produced — the same engine walk with a trace collector
|
||||||
|
// (Σ lines ≡ amountMinor by construction). Null when settled (nothing billed).
|
||||||
|
const breakdown = pricing.withinGrace
|
||||||
|
? null
|
||||||
|
: explainFee(pricing.periodStart, b.asOf, structure, b.category);
|
||||||
|
|
||||||
// A duration curve from entry: handy to SEE where the cap flattens / windows shift.
|
// A duration curve from entry: handy to SEE where the cap flattens / windows shift.
|
||||||
const SAMPLES_MIN = [30, 60, 120, 180, 360, 720, 1440, 2880, 4320];
|
const SAMPLES_MIN = [30, 60, 120, 180, 360, 720, 1440, 2880, 4320];
|
||||||
const enteredMs = Date.parse(b.enteredAt);
|
const enteredMs = Date.parse(b.enteredAt);
|
||||||
@@ -196,7 +203,7 @@ export async function tariffRoutes(app: FastifyInstance, db: Db): Promise<void>
|
|||||||
amountMinor: computeFee(b.enteredAt, new Date(enteredMs + min * 60_000).toISOString(), structure!, b.category),
|
amountMinor: computeFee(b.enteredAt, new Date(enteredMs + min * 60_000).toISOString(), structure!, b.category),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return { currency, pricing, curve, gracePeriodExitMin: structure.gracePeriodExitMin };
|
return { currency, pricing, breakdown, curve, gracePeriodExitMin: structure.gracePeriodExitMin };
|
||||||
});
|
});
|
||||||
|
|
||||||
// Prefill the lab from a REAL session: fold its ledger into entry + payments so the
|
// Prefill the lab from a REAL session: fold its ledger into entry + payments so the
|
||||||
|
|||||||
@@ -0,0 +1,272 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { eq, ledgerEvents, users, type Db } from "@parking/db";
|
||||||
|
import { createTestDb } from "@parking/db/testing";
|
||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import { buildServer } from "../server.js";
|
||||||
|
import { login, makeLog, minutesAgo, seedTariff, seedUser } from "../test-helpers.js";
|
||||||
|
import type { EventLog } from "../event-log.js";
|
||||||
|
|
||||||
|
// Merchant validations (bar/lavazh): the merchant user scans a ticket and applies
|
||||||
|
// their program (a SIGNED, attributed ledger event); the booth settlement quotes NET
|
||||||
|
// and the payment CONSUMES the validation ids. These tests pin the route guards
|
||||||
|
// (binding, caps, session state), the signed apply/void events, and the money cycle
|
||||||
|
// through /api/pay/quote + /api/pay. See wiki/concepts/validation-discounts.md.
|
||||||
|
|
||||||
|
let db: Db;
|
||||||
|
let close: () => void;
|
||||||
|
let app: FastifyInstance;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const t = createTestDb();
|
||||||
|
db = t.db;
|
||||||
|
close = t.close;
|
||||||
|
app = await buildServer({ db });
|
||||||
|
await app.ready();
|
||||||
|
});
|
||||||
|
afterEach(async () => {
|
||||||
|
await app.close();
|
||||||
|
close();
|
||||||
|
});
|
||||||
|
|
||||||
|
type Auth = { cookie: string; csrf: string };
|
||||||
|
const hdrs = (a: Auth) => ({ cookie: a.cookie, "x-csrf-token": a.csrf });
|
||||||
|
|
||||||
|
async function seedMerchant(username = "bari"): Promise<{ auth: Auth; userId: string }> {
|
||||||
|
await seedUser(db, { username, password: "pw123456", roleId: "validues", permissions: ["validation:create"] });
|
||||||
|
const auth = await login(app, username, "pw123456");
|
||||||
|
const row = db.select().from(users).where(eq(users.username, username)).get()!;
|
||||||
|
return { auth, userId: row.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedAdmin(): Promise<Auth> {
|
||||||
|
await seedUser(db, { username: "admin", password: "pw123456" });
|
||||||
|
return login(app, "admin", "pw123456");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Admin-upserts the "bar" program bound to the given user. */
|
||||||
|
async function putProgram(auth: Auth, body: Record<string, unknown>, id = "bar") {
|
||||||
|
return app.inject({ method: "PUT", url: `/api/validation/programs/${id}`, headers: hdrs(auth), payload: body });
|
||||||
|
}
|
||||||
|
|
||||||
|
const fixedProgram = (userId: string, over: Record<string, unknown> = {}) => ({
|
||||||
|
name: "Bar",
|
||||||
|
mode: "fixed",
|
||||||
|
maxAmountMinor: 100000,
|
||||||
|
active: true,
|
||||||
|
userIds: [userId],
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("merchant validations", () => {
|
||||||
|
let log: EventLog;
|
||||||
|
beforeEach(() => {
|
||||||
|
log = makeLog(db);
|
||||||
|
});
|
||||||
|
|
||||||
|
const mint = (identity: string, minAgo: number, payload: Record<string, unknown> | null = null) =>
|
||||||
|
log.append({ type: "vehicle_entry", direction: "entry", identity, occurredAt: minutesAgo(minAgo), payload });
|
||||||
|
|
||||||
|
it("program upsert is admin-gated and signs a config_change; a no-op save signs nothing", async () => {
|
||||||
|
const admin = await seedAdmin();
|
||||||
|
const { auth: merchant, userId } = await seedMerchant();
|
||||||
|
|
||||||
|
expect((await putProgram(merchant, fixedProgram(userId))).statusCode).toBe(403);
|
||||||
|
|
||||||
|
const res = await putProgram(admin, fixedProgram(userId));
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.json()).toMatchObject({ id: "bar", mode: "fixed", active: true, userIds: [userId] });
|
||||||
|
|
||||||
|
const changes = () => db.select().from(ledgerEvents).all().filter((r) => r.type === "config_change");
|
||||||
|
expect(changes()).toHaveLength(1);
|
||||||
|
expect(changes()[0].payload).toMatchObject({ setting: "validationProgram.bar", operator: "admin" });
|
||||||
|
|
||||||
|
// Identical second save → no second config_change.
|
||||||
|
await putProgram(admin, fixedProgram(userId));
|
||||||
|
expect(changes()).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("per-mode validation: timeCredit needs minutes, percent needs percent, fixed needs a cap", async () => {
|
||||||
|
const admin = await seedAdmin();
|
||||||
|
expect((await putProgram(admin, { name: "X", mode: "timeCredit", active: true })).statusCode).toBe(400);
|
||||||
|
expect((await putProgram(admin, { name: "X", mode: "percent", active: true })).statusCode).toBe(400);
|
||||||
|
expect((await putProgram(admin, { name: "X", mode: "fixed", active: true })).statusCode).toBe(400);
|
||||||
|
expect((await putProgram(admin, { name: "X", mode: "timeCredit", minutes: 60, active: true })).statusCode).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("GET /mine returns only MY bound, active programs", async () => {
|
||||||
|
const admin = await seedAdmin();
|
||||||
|
const { auth: merchant, userId } = await seedMerchant();
|
||||||
|
await putProgram(admin, fixedProgram(userId));
|
||||||
|
await putProgram(admin, { name: "Lavazh", mode: "comp", active: true, userIds: [] }, "lavazh");
|
||||||
|
|
||||||
|
const res = await app.inject({ method: "GET", url: "/api/validation/mine", headers: hdrs(merchant) });
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const programs = res.json().programs as { id: string }[];
|
||||||
|
expect(programs.map((p) => p.id)).toEqual(["bar"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("apply: binding, session-state, duplicate and amount guards", async () => {
|
||||||
|
const admin = await seedAdmin();
|
||||||
|
const { auth: merchant, userId } = await seedMerchant();
|
||||||
|
const { auth: other } = await seedMerchant("tjetri");
|
||||||
|
await putProgram(admin, fixedProgram(userId));
|
||||||
|
seedTariff(db);
|
||||||
|
await mint("T1", 120);
|
||||||
|
|
||||||
|
const apply = (auth: Auth, payload: Record<string, unknown>) =>
|
||||||
|
app.inject({ method: "POST", url: "/api/validation/apply", headers: hdrs(auth), payload });
|
||||||
|
|
||||||
|
// Unbound merchant → 403; unknown ticket → 404; missing amount (fixed) → 400;
|
||||||
|
// amount above the cap → 400.
|
||||||
|
expect((await apply(other, { identity: "T1", programId: "bar", amountMinor: 5000 })).statusCode).toBe(403);
|
||||||
|
expect((await apply(merchant, { identity: "NOPE", programId: "bar", amountMinor: 5000 })).statusCode).toBe(404);
|
||||||
|
expect((await apply(merchant, { identity: "T1", programId: "bar" })).statusCode).toBe(400);
|
||||||
|
expect((await apply(merchant, { identity: "T1", programId: "bar", amountMinor: 999999 })).statusCode).toBe(400);
|
||||||
|
|
||||||
|
// Subscriber sessions are never validated (prepaid).
|
||||||
|
await mint("SUB1", 60, { permit: true, permitId: "s-1" });
|
||||||
|
expect((await apply(merchant, { identity: "SUB1", programId: "bar", amountMinor: 5000 })).statusCode).toBe(409);
|
||||||
|
|
||||||
|
// Success → a SIGNED validation event with resolved values + the merchant username.
|
||||||
|
const ok = await apply(merchant, { identity: "T1", programId: "bar", amountMinor: 5000 });
|
||||||
|
expect(ok.statusCode).toBe(201);
|
||||||
|
const ev = db.select().from(ledgerEvents).all().find((r) => r.type === "validation")!;
|
||||||
|
expect(ev.payload).toMatchObject({
|
||||||
|
programId: "bar",
|
||||||
|
programLabel: "Bar",
|
||||||
|
mode: "fixed",
|
||||||
|
amountMinor: 5000,
|
||||||
|
operator: "bari",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Same program twice on one ticket → 409.
|
||||||
|
expect((await apply(merchant, { identity: "T1", programId: "bar", amountMinor: 1000 })).statusCode).toBe(409);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the money cycle: quote nets the validation, pay records gross/discount and CONSUMES it", async () => {
|
||||||
|
const admin = await seedAdmin();
|
||||||
|
const { auth: merchant, userId } = await seedMerchant();
|
||||||
|
await putProgram(admin, fixedProgram(userId));
|
||||||
|
// 100/h flat; 2h → gross 20000.
|
||||||
|
seedTariff(db, { pricePerIncrementMinor: 10000, incrementMin: 60 });
|
||||||
|
await mint("T1", 119);
|
||||||
|
|
||||||
|
await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/validation/apply",
|
||||||
|
headers: hdrs(merchant),
|
||||||
|
payload: { identity: "T1", programId: "bar", amountMinor: 5000 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const q1 = await app.inject({ method: "GET", url: "/api/pay/quote?identity=T1", headers: hdrs(admin) });
|
||||||
|
expect(q1.json()).toMatchObject({
|
||||||
|
grossMinor: 20000,
|
||||||
|
discountMinor: 5000,
|
||||||
|
amountMinor: 15000,
|
||||||
|
});
|
||||||
|
expect(q1.json().validationLines).toEqual([
|
||||||
|
{ programId: "bar", label: "Bar", mode: "fixed", discountMinor: 5000 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Pay (needs an open shift) → the payment carries the split + consumed ids.
|
||||||
|
await app.inject({ method: "POST", url: "/api/shift/open", headers: hdrs(admin) });
|
||||||
|
const pay = await app.inject({ method: "POST", url: "/api/pay", headers: hdrs(admin), payload: { identity: "T1", tender: "cash" } });
|
||||||
|
expect(pay.statusCode).toBe(201);
|
||||||
|
expect(pay.json().amountMinor).toBe(15000);
|
||||||
|
|
||||||
|
const payment = db.select().from(ledgerEvents).all().find((r) => r.type === "payment")!;
|
||||||
|
expect(payment.payload).toMatchObject({ amountMinor: 15000, grossMinor: 20000, discountMinor: 5000 });
|
||||||
|
expect((payment.payload as { validationIds?: string[] }).validationIds).toHaveLength(1);
|
||||||
|
|
||||||
|
// Settled: the follow-up quote owes 0 and applies nothing further.
|
||||||
|
const q2 = await app.inject({ method: "GET", url: "/api/pay/quote?identity=T1", headers: hdrs(admin) });
|
||||||
|
expect(q2.json().amountMinor).toBe(0);
|
||||||
|
expect(q2.json().validationLines).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a full comp settles at 0 through the normal pay path (grace starts, chain verifies)", async () => {
|
||||||
|
const admin = await seedAdmin();
|
||||||
|
const { auth: merchant, userId } = await seedMerchant();
|
||||||
|
await putProgram(admin, { name: "Lavazh falas", mode: "comp", active: true, userIds: [userId] }, "lavazh");
|
||||||
|
seedTariff(db, { pricePerIncrementMinor: 10000 });
|
||||||
|
await mint("T1", 90);
|
||||||
|
|
||||||
|
await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/validation/apply",
|
||||||
|
headers: hdrs(merchant),
|
||||||
|
payload: { identity: "T1", programId: "lavazh" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const q = await app.inject({ method: "GET", url: "/api/pay/quote?identity=T1", headers: hdrs(admin) });
|
||||||
|
expect(q.json().amountMinor).toBe(0);
|
||||||
|
expect(q.json().grossMinor).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
await app.inject({ method: "POST", url: "/api/shift/open", headers: hdrs(admin) });
|
||||||
|
const pay = await app.inject({ method: "POST", url: "/api/pay", headers: hdrs(admin), payload: { identity: "T1", tender: "cash" } });
|
||||||
|
expect(pay.statusCode).toBe(201);
|
||||||
|
expect(pay.json().amountMinor).toBe(0);
|
||||||
|
|
||||||
|
// The 0-net settlement still grants walk-back grace (the session reads settled).
|
||||||
|
const view = await app.inject({ method: "GET", url: "/api/session/T1", headers: hdrs(admin) });
|
||||||
|
expect(view.json()).toMatchObject({ withinGrace: true, amountMinor: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("void: own unused only; a consumed validation is locked", async () => {
|
||||||
|
const admin = await seedAdmin();
|
||||||
|
const { auth: merchant, userId } = await seedMerchant();
|
||||||
|
const { auth: other, userId: otherId } = await seedMerchant("tjetri");
|
||||||
|
await putProgram(admin, fixedProgram(userId, { userIds: [userId, otherId] }));
|
||||||
|
seedTariff(db, { pricePerIncrementMinor: 10000 });
|
||||||
|
await mint("T1", 90);
|
||||||
|
|
||||||
|
const applied = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/validation/apply",
|
||||||
|
headers: hdrs(merchant),
|
||||||
|
payload: { identity: "T1", programId: "bar", amountMinor: 5000 },
|
||||||
|
});
|
||||||
|
const eventId = applied.json().eventId as string;
|
||||||
|
|
||||||
|
const voidReq = (auth: Auth) =>
|
||||||
|
app.inject({ method: "POST", url: "/api/validation/void", headers: hdrs(auth), payload: { eventId, identity: "T1" } });
|
||||||
|
|
||||||
|
// Someone else's validation → 403. Own → ok, and the quote returns to gross.
|
||||||
|
expect((await voidReq(other)).statusCode).toBe(403);
|
||||||
|
expect((await voidReq(merchant)).statusCode).toBe(200);
|
||||||
|
const q = await app.inject({ method: "GET", url: "/api/pay/quote?identity=T1", headers: hdrs(admin) });
|
||||||
|
expect(q.json().discountMinor).toBe(0);
|
||||||
|
|
||||||
|
// Re-apply (the void freed the per-session slot), consume it with a payment, then
|
||||||
|
// a void must refuse — the settlement already happened.
|
||||||
|
const re = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/validation/apply",
|
||||||
|
headers: hdrs(merchant),
|
||||||
|
payload: { identity: "T1", programId: "bar", amountMinor: 5000 },
|
||||||
|
});
|
||||||
|
await app.inject({ method: "POST", url: "/api/shift/open", headers: hdrs(admin) });
|
||||||
|
await app.inject({ method: "POST", url: "/api/pay", headers: hdrs(admin), payload: { identity: "T1", tender: "cash" } });
|
||||||
|
const locked = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/validation/void",
|
||||||
|
headers: hdrs(merchant),
|
||||||
|
payload: { eventId: re.json().eventId, identity: "T1" },
|
||||||
|
});
|
||||||
|
expect(locked.statusCode).toBe(409);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maxPerDay caps applications across tickets", async () => {
|
||||||
|
const admin = await seedAdmin();
|
||||||
|
const { auth: merchant, userId } = await seedMerchant();
|
||||||
|
await putProgram(admin, { name: "Lavazh", mode: "comp", maxPerDay: 1, active: true, userIds: [userId] }, "lavazh");
|
||||||
|
seedTariff(db);
|
||||||
|
await mint("T1", 60);
|
||||||
|
await mint("T2", 30);
|
||||||
|
|
||||||
|
const apply = (identity: string) =>
|
||||||
|
app.inject({ method: "POST", url: "/api/validation/apply", headers: hdrs(merchant), payload: { identity, programId: "lavazh" } });
|
||||||
|
expect((await apply("T1")).statusCode).toBe(201);
|
||||||
|
expect((await apply("T2")).statusCode).toBe(409);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,360 @@
|
|||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import {
|
||||||
|
and,
|
||||||
|
eq,
|
||||||
|
isNull,
|
||||||
|
inArray,
|
||||||
|
ledgerEvents,
|
||||||
|
users,
|
||||||
|
validationProgramUsers,
|
||||||
|
validationPrograms,
|
||||||
|
type Db,
|
||||||
|
} from "@parking/db";
|
||||||
|
import { VALIDATION_MODES, type ValidationMode } from "@parking/shared";
|
||||||
|
import { requirePermission } from "../auth.js";
|
||||||
|
import type { EventLog } from "../event-log.js";
|
||||||
|
import { liveValidations, sessionValidations } from "../validations.js";
|
||||||
|
|
||||||
|
// Merchant validations (bar / lavazh). The merchant is VALIDATION-ONLY: they scan the
|
||||||
|
// customer's ticket on their own device and apply their program — all money and paper
|
||||||
|
// stay at the booth, which settles net of these events. Program config is admin-composed
|
||||||
|
// on /setup/site (site:read/update — no dedicated permission); applying is the merchant
|
||||||
|
// user's `validation:create`, guarded FURTHER by the program↔user binding so a bar user
|
||||||
|
// can never apply the lavazh program. Every apply/void is a signed, attributed ledger
|
||||||
|
// event. See wiki/concepts/validation-discounts.md.
|
||||||
|
// - GET /api/validation/programs : all programs + bound users. (site:read)
|
||||||
|
// - PUT /api/validation/programs/:id : upsert config + bindings; (site:update)
|
||||||
|
// signs a config_change.
|
||||||
|
// - GET /api/validation/mine : my bound ACTIVE programs. (validation:create)
|
||||||
|
// - GET /api/validation/session/:identity : minimal session view for (validation:create)
|
||||||
|
// the merchant screen (no money data).
|
||||||
|
// - POST /api/validation/apply : apply my program (signed). (validation:create)
|
||||||
|
// - POST /api/validation/void : void my OWN unused apply. (validation:create)
|
||||||
|
|
||||||
|
/** Well-formed program ids: kebab slugs ("bar", "lavazh", a future "hotel-2"). */
|
||||||
|
const ID_RE = /^[a-z][a-z0-9-]{1,31}$/;
|
||||||
|
|
||||||
|
interface ProgramBody {
|
||||||
|
name?: string;
|
||||||
|
mode?: ValidationMode;
|
||||||
|
minutes?: number | null;
|
||||||
|
percent?: number | null;
|
||||||
|
maxAmountMinor?: number | null;
|
||||||
|
maxPerDay?: number | null;
|
||||||
|
active?: boolean;
|
||||||
|
/** Full replacement set of bound user ids. */
|
||||||
|
userIds?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ApplyBody {
|
||||||
|
identity: string;
|
||||||
|
programId: string;
|
||||||
|
/** fixed mode only: the discount the merchant grants (minor units, ≤ maxAmountMinor). */
|
||||||
|
amountMinor?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VoidBody {
|
||||||
|
eventId: string;
|
||||||
|
identity: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** null when valid, else the 400 message. Checks the per-mode parameter. */
|
||||||
|
function validateProgram(b: ProgramBody): string | null {
|
||||||
|
if (!b.name || !String(b.name).trim()) return "name is required";
|
||||||
|
if (!VALIDATION_MODES.includes(b.mode as ValidationMode)) return "mode must be comp|timeCredit|fixed|percent";
|
||||||
|
const intOrNull = (v: unknown) => v == null || (Number.isInteger(v) && (v as number) > 0);
|
||||||
|
if (!intOrNull(b.minutes)) return "minutes must be a positive integer";
|
||||||
|
if (!intOrNull(b.maxAmountMinor)) return "maxAmountMinor must be a positive integer";
|
||||||
|
if (!intOrNull(b.maxPerDay)) return "maxPerDay must be a positive integer";
|
||||||
|
if (b.percent != null && (!Number.isInteger(b.percent) || b.percent < 1 || b.percent > 100))
|
||||||
|
return "percent must be 1..100";
|
||||||
|
if (b.mode === "timeCredit" && b.minutes == null) return "timeCredit needs minutes";
|
||||||
|
if (b.mode === "percent" && b.percent == null) return "percent mode needs percent";
|
||||||
|
if (b.mode === "fixed" && b.maxAmountMinor == null) return "fixed mode needs maxAmountMinor";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function validationRoutes(app: FastifyInstance, db: Db, eventLog: EventLog): Promise<void> {
|
||||||
|
const siteRead = requirePermission("site:read");
|
||||||
|
const siteWrite = requirePermission("site:update");
|
||||||
|
const applyGuard = requirePermission("validation:create");
|
||||||
|
|
||||||
|
const liveProgram = (id: string) =>
|
||||||
|
db
|
||||||
|
.select()
|
||||||
|
.from(validationPrograms)
|
||||||
|
.where(and(eq(validationPrograms.id, id), isNull(validationPrograms.deletedAt)))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
const boundUserIds = (programId: string): string[] =>
|
||||||
|
db
|
||||||
|
.select({ userId: validationProgramUsers.userId })
|
||||||
|
.from(validationProgramUsers)
|
||||||
|
.where(eq(validationProgramUsers.programId, programId))
|
||||||
|
.all()
|
||||||
|
.map((r) => r.userId);
|
||||||
|
|
||||||
|
// The setup panel's read: every live program with its bound users.
|
||||||
|
app.get("/api/validation/programs", { preHandler: siteRead }, async () => {
|
||||||
|
const programs = db.select().from(validationPrograms).where(isNull(validationPrograms.deletedAt)).all();
|
||||||
|
return {
|
||||||
|
programs: programs.map((p) => ({ ...p, userIds: boundUserIds(p.id) })),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Upsert a program (the /setup/site checkbox + panel). Creates the well-known row on
|
||||||
|
// first enable; replaces the binding set; signs an attributed config_change when
|
||||||
|
// anything actually changed (the entry-presence-bypass precedent — enabling a discount
|
||||||
|
// program is fraud-relevant config).
|
||||||
|
app.put<{ Params: { id: string }; Body: ProgramBody }>(
|
||||||
|
"/api/validation/programs/:id",
|
||||||
|
{ preHandler: siteWrite },
|
||||||
|
async (req, reply) => {
|
||||||
|
const id = (req.params.id ?? "").trim();
|
||||||
|
if (!ID_RE.test(id)) return reply.code(400).send({ error: "invalid program id" });
|
||||||
|
const b = req.body ?? ({} as ProgramBody);
|
||||||
|
const bad = validateProgram(b);
|
||||||
|
if (bad) return reply.code(400).send({ error: bad });
|
||||||
|
|
||||||
|
const userIds = Array.isArray(b.userIds) ? [...new Set(b.userIds)] : [];
|
||||||
|
if (userIds.length) {
|
||||||
|
const found = db
|
||||||
|
.select({ id: users.id })
|
||||||
|
.from(users)
|
||||||
|
.where(and(inArray(users.id, userIds), isNull(users.deletedAt)))
|
||||||
|
.all();
|
||||||
|
if (found.length !== userIds.length) return reply.code(400).send({ error: "unknown user in userIds" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const prev = liveProgram(id);
|
||||||
|
const prevUserIds = prev ? boundUserIds(id).sort() : [];
|
||||||
|
const next = {
|
||||||
|
name: String(b.name).trim(),
|
||||||
|
mode: b.mode as ValidationMode,
|
||||||
|
minutes: b.minutes ?? null,
|
||||||
|
percent: b.percent ?? null,
|
||||||
|
maxAmountMinor: b.maxAmountMinor ?? null,
|
||||||
|
maxPerDay: b.maxPerDay ?? null,
|
||||||
|
active: b.active === true,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (prev) {
|
||||||
|
db.update(validationPrograms).set(next).where(eq(validationPrograms.id, id)).run();
|
||||||
|
} else {
|
||||||
|
db.insert(validationPrograms).values({ id, ...next }).run();
|
||||||
|
}
|
||||||
|
db.delete(validationProgramUsers).where(eq(validationProgramUsers.programId, id)).run();
|
||||||
|
for (const userId of userIds) {
|
||||||
|
db.insert(validationProgramUsers).values({ programId: id, userId }).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sign the change (attributed) — enabling/reshaping a discount program is
|
||||||
|
// fraud-relevant config. Compare against the previous row + binding set so a
|
||||||
|
// no-op save signs nothing.
|
||||||
|
const summary = (row: typeof next, ids: string[]) => JSON.stringify({ ...row, userIds: [...ids].sort() });
|
||||||
|
const prevSummary = prev
|
||||||
|
? summary(
|
||||||
|
{ name: prev.name, mode: prev.mode, minutes: prev.minutes, percent: prev.percent,
|
||||||
|
maxAmountMinor: prev.maxAmountMinor, maxPerDay: prev.maxPerDay, active: prev.active },
|
||||||
|
prevUserIds,
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
if (prevSummary !== summary(next, userIds)) {
|
||||||
|
await eventLog.append({
|
||||||
|
type: "config_change",
|
||||||
|
source: "manual",
|
||||||
|
identity: `validation-program:${id}`,
|
||||||
|
payload: {
|
||||||
|
setting: `validationProgram.${id}`,
|
||||||
|
value: { ...next, userCount: userIds.length },
|
||||||
|
prev: prev
|
||||||
|
? { name: prev.name, mode: prev.mode, minutes: prev.minutes, percent: prev.percent,
|
||||||
|
maxAmountMinor: prev.maxAmountMinor, maxPerDay: prev.maxPerDay, active: prev.active }
|
||||||
|
: null,
|
||||||
|
operator: req.user?.username ?? "unknown",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = liveProgram(id);
|
||||||
|
return { ...row, userIds: boundUserIds(id) };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// The merchant screen's program list: MY bound, active programs.
|
||||||
|
app.get("/api/validation/mine", { preHandler: applyGuard }, async (req) => {
|
||||||
|
const rows = db
|
||||||
|
.select()
|
||||||
|
.from(validationPrograms)
|
||||||
|
.innerJoin(validationProgramUsers, eq(validationProgramUsers.programId, validationPrograms.id))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(validationProgramUsers.userId, req.user.sub),
|
||||||
|
eq(validationPrograms.active, true),
|
||||||
|
isNull(validationPrograms.deletedAt),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.all();
|
||||||
|
return { programs: rows.map((r) => r.validation_programs) };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Minimal session view for the merchant screen — deliberately NO money data (the
|
||||||
|
// merchant validates; the booth settles): found/open/entry time + the validations
|
||||||
|
// already on the session (so the UI can show "already validated" and offer void).
|
||||||
|
app.get<{ Params: { identity: string } }>(
|
||||||
|
"/api/validation/session/:identity",
|
||||||
|
{ preHandler: applyGuard },
|
||||||
|
async (req, reply) => {
|
||||||
|
const identity = (req.params.identity ?? "").trim();
|
||||||
|
if (!identity) return reply.code(400).send({ error: "identity required" });
|
||||||
|
const rows = db
|
||||||
|
.select({ type: ledgerEvents.type, occurredAt: ledgerEvents.occurredAt, payload: ledgerEvents.payload })
|
||||||
|
.from(ledgerEvents)
|
||||||
|
.where(eq(ledgerEvents.identity, identity))
|
||||||
|
.orderBy(ledgerEvents.index)
|
||||||
|
.all();
|
||||||
|
const entry = rows.find((r) => r.type === "vehicle_entry");
|
||||||
|
if (!entry) return { identity, found: false, open: false, enteredAt: null, subscription: false, validations: [] };
|
||||||
|
const entryPl = (entry.payload ?? {}) as { permit?: boolean; permitId?: string };
|
||||||
|
const subscription = entryPl.permit === true || entryPl.permitId != null;
|
||||||
|
const open = !rows.some((r) => r.type === "vehicle_exit" || r.type === "void");
|
||||||
|
return {
|
||||||
|
identity,
|
||||||
|
found: true,
|
||||||
|
open,
|
||||||
|
enteredAt: entry.occurredAt,
|
||||||
|
subscription,
|
||||||
|
validations: sessionValidations(db, identity),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// APPLY: the merchant's one action. Guards, in order: program live+active → the
|
||||||
|
// user is BOUND to it → the session is an OPEN TRANSIENT → not already carrying a
|
||||||
|
// live application of this program → per-day cap → fixed-amount bounds. Appends the
|
||||||
|
// signed validation event with the RESOLVED values.
|
||||||
|
app.post<{ Body: ApplyBody }>("/api/validation/apply", { preHandler: applyGuard }, async (req, reply) => {
|
||||||
|
const identity = (req.body?.identity ?? "").trim();
|
||||||
|
const programId = (req.body?.programId ?? "").trim();
|
||||||
|
if (!identity || !programId) return reply.code(400).send({ error: "identity and programId required" });
|
||||||
|
|
||||||
|
const program = liveProgram(programId);
|
||||||
|
if (!program || !program.active) return reply.code(404).send({ error: "program not found or inactive" });
|
||||||
|
if (!boundUserIds(programId).includes(req.user.sub)) {
|
||||||
|
return reply.code(403).send({ error: "you are not bound to this program" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Session state — an open transient (subscriptions are prepaid; nothing to discount).
|
||||||
|
const rows = db
|
||||||
|
.select({ type: ledgerEvents.type, payload: ledgerEvents.payload })
|
||||||
|
.from(ledgerEvents)
|
||||||
|
.where(eq(ledgerEvents.identity, identity))
|
||||||
|
.orderBy(ledgerEvents.index)
|
||||||
|
.all();
|
||||||
|
const entry = rows.find((r) => r.type === "vehicle_entry");
|
||||||
|
if (!entry) return reply.code(404).send({ error: "no session for ticket" });
|
||||||
|
const entryPl = (entry.payload ?? {}) as { permit?: boolean; permitId?: string };
|
||||||
|
if (entryPl.permit === true || entryPl.permitId != null) {
|
||||||
|
return reply.code(409).send({ error: "subscription sessions cannot be validated" });
|
||||||
|
}
|
||||||
|
if (rows.some((r) => r.type === "vehicle_exit" || r.type === "void")) {
|
||||||
|
return reply.code(409).send({ error: "session is closed" });
|
||||||
|
}
|
||||||
|
if (liveValidations(db, identity).some((v) => v.programId === programId)) {
|
||||||
|
return reply.code(409).send({ error: "this program is already applied to the ticket" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-day cap: unvoided applications of this program since LOCAL midnight (the
|
||||||
|
// appliance runs in site time).
|
||||||
|
if (program.maxPerDay != null) {
|
||||||
|
const midnight = new Date();
|
||||||
|
midnight.setHours(0, 0, 0, 0);
|
||||||
|
const todays = db
|
||||||
|
.select({ id: ledgerEvents.id, occurredAt: ledgerEvents.occurredAt, payload: ledgerEvents.payload, type: ledgerEvents.type })
|
||||||
|
.from(ledgerEvents)
|
||||||
|
.where(eq(ledgerEvents.type, "validation"))
|
||||||
|
.all()
|
||||||
|
.filter((r) => Date.parse(r.occurredAt) >= midnight.getTime());
|
||||||
|
const voidedIds = new Set(
|
||||||
|
todays.map((r) => (r.payload as { refId?: string } | null)?.refId).filter(Boolean) as string[],
|
||||||
|
);
|
||||||
|
const count = todays.filter((r) => {
|
||||||
|
const p = (r.payload ?? {}) as { programId?: string; refId?: string };
|
||||||
|
return p.programId === programId && !p.refId && !voidedIds.has(r.id);
|
||||||
|
}).length;
|
||||||
|
if (count >= program.maxPerDay) {
|
||||||
|
return reply.code(409).send({ error: "daily cap reached for this program" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the values off the program row (frozen into the signed event).
|
||||||
|
let amountMinor: number | undefined;
|
||||||
|
if (program.mode === "fixed") {
|
||||||
|
const a = req.body?.amountMinor;
|
||||||
|
if (a == null || !Number.isInteger(a) || a <= 0) {
|
||||||
|
return reply.code(400).send({ error: "amountMinor (positive integer) required for this program" });
|
||||||
|
}
|
||||||
|
if (program.maxAmountMinor != null && a > program.maxAmountMinor) {
|
||||||
|
return reply.code(400).send({ error: `amount exceeds the program cap (${program.maxAmountMinor})` });
|
||||||
|
}
|
||||||
|
amountMinor = a;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ev = await eventLog.append({
|
||||||
|
type: "validation",
|
||||||
|
source: "manual",
|
||||||
|
identity,
|
||||||
|
payload: {
|
||||||
|
sessionRef: identity,
|
||||||
|
programId,
|
||||||
|
programLabel: program.name,
|
||||||
|
mode: program.mode,
|
||||||
|
...(program.mode === "timeCredit" && program.minutes != null ? { minutes: program.minutes } : {}),
|
||||||
|
...(program.mode === "percent" && program.percent != null ? { percent: program.percent } : {}),
|
||||||
|
...(amountMinor != null ? { amountMinor } : {}),
|
||||||
|
operator: req.user.username,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return reply.code(201).send({
|
||||||
|
ok: true,
|
||||||
|
eventId: ev.id,
|
||||||
|
programId,
|
||||||
|
label: program.name,
|
||||||
|
mode: program.mode,
|
||||||
|
minutes: program.mode === "timeCredit" ? program.minutes : undefined,
|
||||||
|
percent: program.mode === "percent" ? program.percent : undefined,
|
||||||
|
amountMinor,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// VOID my own UNUSED validation (fat-fingered amount / wrong ticket). Append-only:
|
||||||
|
// a validation event with refId, never a delete. Refused once a payment consumed it
|
||||||
|
// (the settlement already happened — that dispute goes to the booth/admin).
|
||||||
|
app.post<{ Body: VoidBody }>("/api/validation/void", { preHandler: applyGuard }, async (req, reply) => {
|
||||||
|
const eventId = (req.body?.eventId ?? "").trim();
|
||||||
|
const identity = (req.body?.identity ?? "").trim();
|
||||||
|
if (!eventId || !identity) return reply.code(400).send({ error: "eventId and identity required" });
|
||||||
|
const target = sessionValidations(db, identity).find((v) => v.eventId === eventId);
|
||||||
|
if (!target) return reply.code(404).send({ error: "validation not found" });
|
||||||
|
if (target.operator !== req.user.username) {
|
||||||
|
return reply.code(403).send({ error: "you may only void your own validation" });
|
||||||
|
}
|
||||||
|
if (target.voided) return reply.code(409).send({ error: "already voided" });
|
||||||
|
if (target.consumedBy != null) {
|
||||||
|
return reply.code(409).send({ error: "already used in a payment — ask the booth/admin" });
|
||||||
|
}
|
||||||
|
await eventLog.append({
|
||||||
|
type: "validation",
|
||||||
|
source: "manual",
|
||||||
|
identity,
|
||||||
|
payload: {
|
||||||
|
sessionRef: identity,
|
||||||
|
refId: eventId,
|
||||||
|
programId: target.programId,
|
||||||
|
programLabel: target.label,
|
||||||
|
operator: req.user.username,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
|
import { randomBytes } from "node:crypto";
|
||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
import type { Db } from "@parking/db";
|
import type { Db } from "@parking/db";
|
||||||
import type { LedgerEvent } from "@parking/shared";
|
import type { LedgerEvent } from "@parking/shared";
|
||||||
import { roleHasPermissions } from "../auth.js";
|
import { requireAuth, roleHasPermissions } from "../auth.js";
|
||||||
import {
|
import {
|
||||||
deviceEvents,
|
deviceEvents,
|
||||||
type LaneStatusEvent,
|
type LaneStatusEvent,
|
||||||
@@ -31,11 +32,57 @@ import { getOccupancy } from "../occupancy.js";
|
|||||||
// an Origin allowlist: the handshake's Origin must be same-origin (or an explicitly
|
// an Origin allowlist: the handshake's Origin must be same-origin (or an explicitly
|
||||||
// allowed booth UI origin). Non-browser clients (no Origin) are rejected too.
|
// allowed booth UI origin). Non-browser clients (no Origin) are rejected too.
|
||||||
// See auth.ts, event-log.ts (emitLedger), capacity-occupancy.md.
|
// See auth.ts, event-log.ts (emitLedger), capacity-occupancy.md.
|
||||||
|
//
|
||||||
|
// Desktop shell (Tauri) exception — the WS TICKET. The desktop app's HTTP goes
|
||||||
|
// through tauri-plugin-http (reqwest, its own cookie jar) and its WebSocket
|
||||||
|
// through tauri-plugin-websocket (bare tungstenite, NO cookie jar at all), so
|
||||||
|
// the JWT cookie set at login can never ride on the WS handshake — jwtVerify()
|
||||||
|
// would 401 every connect (found 2026-09-04: the desktop live feed reconnected
|
||||||
|
// every 10s forever). The JWT is HttpOnly and must stay out of JS, so instead
|
||||||
|
// the desktop client POSTs /api/ws/ticket (normal cookie + CSRF auth) to get a
|
||||||
|
// single-use, 30-second random ticket bound to its user, and presents it in an
|
||||||
|
// `x-ws-ticket` header on the handshake. A browser page cannot set custom
|
||||||
|
// headers on a WebSocket, so this path is unreachable from a browser and adds
|
||||||
|
// no CSWSH surface; the Origin allowlist still applies to both paths.
|
||||||
|
|
||||||
/** Permission required to watch the live feed (a read-only stream of ledger +
|
/** Permission required to watch the live feed (a read-only stream of ledger +
|
||||||
* device status). Any role granted `report:read` may watch. */
|
* device status). Any role granted `report:read` may watch. */
|
||||||
const WATCH_PERMISSION = "report:read" as const;
|
const WATCH_PERMISSION = "report:read" as const;
|
||||||
|
|
||||||
|
/** Handshake header carrying a desktop WS ticket (see file header). */
|
||||||
|
const WS_TICKET_HEADER = "x-ws-ticket";
|
||||||
|
/** A ticket is only good for the connect that immediately follows its issue. */
|
||||||
|
const WS_TICKET_TTL_MS = 30_000;
|
||||||
|
|
||||||
|
interface WsTicket {
|
||||||
|
sub: string;
|
||||||
|
roleId: string;
|
||||||
|
expiresAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Outstanding tickets. Tiny (one per desktop connect attempt), in-memory only —
|
||||||
|
* a server restart invalidates them, which is fine: the client just asks for
|
||||||
|
* another on its next reconnect. */
|
||||||
|
const tickets = new Map<string, WsTicket>();
|
||||||
|
|
||||||
|
function issueWsTicket(sub: string, roleId: string): string {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [key, t] of tickets) {
|
||||||
|
if (t.expiresAt <= now) tickets.delete(key);
|
||||||
|
}
|
||||||
|
const ticket = randomBytes(32).toString("hex");
|
||||||
|
tickets.set(ticket, { sub, roleId, expiresAt: now + WS_TICKET_TTL_MS });
|
||||||
|
return ticket;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Single-use: the ticket is removed whether or not it turns out to be valid. */
|
||||||
|
function consumeWsTicket(ticket: string): WsTicket | null {
|
||||||
|
const t = tickets.get(ticket);
|
||||||
|
if (!t) return null;
|
||||||
|
tickets.delete(ticket);
|
||||||
|
return t.expiresAt > Date.now() ? t : null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Is the handshake's Origin trusted? Same-origin (Origin host === Host header) is
|
* Is the handshake's Origin trusted? Same-origin (Origin host === Host header) is
|
||||||
* always allowed; additional origins can be allowlisted via WS_ALLOWED_ORIGINS
|
* always allowed; additional origins can be allowlisted via WS_ALLOWED_ORIGINS
|
||||||
@@ -80,19 +127,43 @@ export async function wsRoutes(
|
|||||||
laneStatus: LaneStatus,
|
laneStatus: LaneStatus,
|
||||||
lanePresence: LanePresence,
|
lanePresence: LanePresence,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
// Desktop-only: mint a WS ticket for the signed-in session (see file header).
|
||||||
|
// Ordinary cookie + CSRF auth — the desktop client CAN do that over HTTP (via
|
||||||
|
// tauri-plugin-http), it just can't carry the cookie onto the WebSocket.
|
||||||
|
app.post("/api/ws/ticket", { preHandler: requireAuth }, async (req) => ({
|
||||||
|
ticket: issueWsTicket(req.user.sub, req.user.roleId),
|
||||||
|
expiresInMs: WS_TICKET_TTL_MS,
|
||||||
|
}));
|
||||||
|
|
||||||
app.get(
|
app.get(
|
||||||
"/api/ws",
|
"/api/ws",
|
||||||
{
|
{
|
||||||
websocket: true,
|
websocket: true,
|
||||||
// Origin allowlist (anti-CSWSH, replaces CSRF — see file header) THEN JWT +
|
// Origin allowlist (anti-CSWSH, replaces CSRF — see file header) THEN
|
||||||
// role. Reject a cross/absent origin before touching the token, so a hijack
|
// session (JWT cookie, or a desktop WS ticket) THEN role. Reject a
|
||||||
// attempt never reaches an authenticated socket. jwtVerify reads the cookie.
|
// cross/absent origin before touching either credential, so a hijack
|
||||||
|
// attempt never reaches an authenticated socket.
|
||||||
preHandler: async (req) => {
|
preHandler: async (req) => {
|
||||||
if (!isAllowedOrigin(req.headers.origin, req.headers.host)) {
|
if (!isAllowedOrigin(req.headers.origin, req.headers.host)) {
|
||||||
throw Object.assign(new Error("forbidden origin"), { statusCode: 403 });
|
throw Object.assign(new Error("forbidden origin"), { statusCode: 403 });
|
||||||
}
|
}
|
||||||
await req.jwtVerify();
|
const rawTicket = req.headers[WS_TICKET_HEADER];
|
||||||
if (!req.user || !roleHasPermissions(req.user.roleId, [WATCH_PERMISSION])) {
|
const ticket = Array.isArray(rawTicket) ? rawTicket[0] : rawTicket;
|
||||||
|
let roleId: string;
|
||||||
|
if (ticket !== undefined) {
|
||||||
|
const t = consumeWsTicket(ticket);
|
||||||
|
if (!t) {
|
||||||
|
throw Object.assign(new Error("invalid or expired ws ticket"), { statusCode: 401 });
|
||||||
|
}
|
||||||
|
roleId = t.roleId;
|
||||||
|
} else {
|
||||||
|
await req.jwtVerify(); // reads the HttpOnly cookie (browser path)
|
||||||
|
if (!req.user) {
|
||||||
|
throw Object.assign(new Error("forbidden"), { statusCode: 403 });
|
||||||
|
}
|
||||||
|
roleId = req.user.roleId;
|
||||||
|
}
|
||||||
|
if (!roleHasPermissions(roleId, [WATCH_PERMISSION])) {
|
||||||
throw Object.assign(new Error("forbidden"), { statusCode: 403 });
|
throw Object.assign(new Error("forbidden"), { statusCode: 403 });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ import { shiftRoutes } from "./routes/shift.js";
|
|||||||
import { drawerRoutes } from "./routes/drawer.js";
|
import { drawerRoutes } from "./routes/drawer.js";
|
||||||
import { entryRoutes } from "./routes/entry.js";
|
import { entryRoutes } from "./routes/entry.js";
|
||||||
import { siteRoutes } from "./routes/site.js";
|
import { siteRoutes } from "./routes/site.js";
|
||||||
|
import { validationRoutes } from "./routes/validations.js";
|
||||||
import { snapshotRoutes } from "./routes/snapshots.js";
|
import { snapshotRoutes } from "./routes/snapshots.js";
|
||||||
import { tariffRoutes } from "./routes/tariffs.js";
|
import { tariffRoutes } from "./routes/tariffs.js";
|
||||||
import { printerRoutes } from "./routes/printers.js";
|
import { printerRoutes } from "./routes/printers.js";
|
||||||
@@ -105,7 +106,10 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
|||||||
cookie: { cookieName: TOKEN_COOKIE, signed: false },
|
cookie: { cookieName: TOKEN_COOKIE, signed: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get("/health", async () => ({ status: "ok" }));
|
// Unauthenticated liveness probe. `app` lets a client (the desktop ConnectScreen
|
||||||
|
// test — apps/web/src/lib/backend-config.ts) tell THIS server apart from any
|
||||||
|
// other service that happens to answer on the address the operator typed.
|
||||||
|
app.get("/health", async () => ({ status: "ok", app: "parking-system" }));
|
||||||
|
|
||||||
// Local username/password login → JWT in an HttpOnly cookie + CSRF cookie.
|
// Local username/password login → JWT in an HttpOnly cookie + CSRF cookie.
|
||||||
await authRoutes(app, db);
|
await authRoutes(app, db);
|
||||||
@@ -292,6 +296,11 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
|||||||
// at capacity) is in the entry flow. See wiki/concepts/capacity-occupancy.md.
|
// at capacity) is in the entry flow. See wiki/concepts/capacity-occupancy.md.
|
||||||
await siteRoutes(app, db, eventLog);
|
await siteRoutes(app, db, eventLog);
|
||||||
|
|
||||||
|
// Merchant validations (bar / lavazh): setup panel config + the merchant user's
|
||||||
|
// scan-and-apply. The booth settlement folds the applied validations into its
|
||||||
|
// quote (pay-station.ts). See wiki/concepts/validation-discounts.md.
|
||||||
|
await validationRoutes(app, db, eventLog);
|
||||||
|
|
||||||
// Application logs: ingest frontend errors (POST /api/logs, any signed-in user) +
|
// Application logs: ingest frontend errors (POST /api/logs, any signed-in user) +
|
||||||
// read the store (GET /api/logs, log:read). See wiki/concepts/app-logs.md.
|
// read the store (GET /api/logs, log:read). See wiki/concepts/app-logs.md.
|
||||||
await logRoutes(app, logService);
|
await logRoutes(app, logService);
|
||||||
@@ -330,12 +339,20 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
|||||||
void runSnapPrune(); // once at startup
|
void runSnapPrune(); // once at startup
|
||||||
app.addHook("onClose", async () => clearInterval(snapPruneTimer));
|
app.addHook("onClose", async () => clearInterval(snapPruneTimer));
|
||||||
|
|
||||||
// Scheduled encrypted backup — daily, unref'd. A no-op (silent) until BACKUP_TARGET_DIR +
|
// Scheduled encrypted backup — checked every 15 min, unref'd; `runScheduled()` itself is a
|
||||||
// BACKUP_KEY are configured; tolerates an unreachable/unmounted target by recording the
|
// no-op unless a full 24h has actually elapsed since the last PERSISTED success (isDue(), in
|
||||||
// error and trying again next run. NOT run once at startup (a just-booted appliance after a
|
// backup-service.ts), so this frequent poll does not cause frequent backups. Deliberately
|
||||||
// power cut shouldn't immediately write to a possibly-not-yet-mounted disk; the daily cadence
|
// NOT a `setInterval(..., 24h)` measured from process start: that design silently reset its
|
||||||
// and the manual button cover it). See wiki/concepts/backup-recovery.md.
|
// own countdown on every restart (deploy/crash/OOM/reboot, all routine under `restart:
|
||||||
const backupTimer = setInterval(() => void backupService.runScheduled(), 24 * 60 * 60 * 1000);
|
// always`), which could push a day's backup out arbitrarily far AND — before last-success was
|
||||||
|
// persisted — made the admin UI show "Never" despite valid backups already on disk
|
||||||
|
// (2026-08-30 field incident, park-buzi). A short poll against a persisted, wall-clock
|
||||||
|
// timestamp is immune to both restart timing and to any single restart cadence. A no-op
|
||||||
|
// (silent) until BACKUP_TARGET_DIR + BACKUP_KEY are configured; tolerates an
|
||||||
|
// unreachable/unmounted target by recording the error and trying again next check. NOT run
|
||||||
|
// once at startup (a just-booted appliance after a power cut shouldn't immediately write to a
|
||||||
|
// possibly-not-yet-mounted disk). See wiki/concepts/backup-recovery.md.
|
||||||
|
const backupTimer = setInterval(() => void backupService.runScheduled(), 15 * 60 * 1000);
|
||||||
backupTimer.unref();
|
backupTimer.unref();
|
||||||
app.addHook("onClose", async () => clearInterval(backupTimer));
|
app.addHook("onClose", async () => clearInterval(backupTimer));
|
||||||
if (backupService.configured) {
|
if (backupService.configured) {
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ export interface ShiftSummary {
|
|||||||
readonly subscriptionTotalMinor: number;
|
readonly subscriptionTotalMinor: number;
|
||||||
readonly subscriptionSalesMinor: number;
|
readonly subscriptionSalesMinor: number;
|
||||||
readonly subscriptionWindowMinor: number;
|
readonly subscriptionWindowMinor: number;
|
||||||
|
readonly discountTotalMinor: number;
|
||||||
readonly openingFloatMinor: number;
|
readonly openingFloatMinor: number;
|
||||||
readonly cashAddedMinor: number;
|
readonly cashAddedMinor: number;
|
||||||
readonly cashRemovedMinor: number;
|
readonly cashRemovedMinor: number;
|
||||||
@@ -78,6 +79,9 @@ export interface ShiftReport {
|
|||||||
readonly subscriptionSalesMinor: number;
|
readonly subscriptionSalesMinor: number;
|
||||||
/** Subscriber OUT-OF-WINDOW transient-tariff charges only. */
|
/** Subscriber OUT-OF-WINDOW transient-tariff charges only. */
|
||||||
readonly subscriptionWindowMinor: number;
|
readonly subscriptionWindowMinor: number;
|
||||||
|
/** Merchant-validation DISCOUNT total given away in the window (leakage — the
|
||||||
|
* cash/card figures above are already NET of it). See validation-discounts.md. */
|
||||||
|
readonly discountTotalMinor: number;
|
||||||
// --- Drawer (physical cash till; carries across shifts) ---
|
// --- Drawer (physical cash till; carries across shifts) ---
|
||||||
/** Cash in the drawer at shift start = prior shift's expected closing drawer. */
|
/** Cash in the drawer at shift start = prior shift's expected closing drawer. */
|
||||||
readonly openingFloatMinor: number;
|
readonly openingFloatMinor: number;
|
||||||
@@ -220,6 +224,7 @@ export class ShiftService {
|
|||||||
subscriptionTotalMinor?: number;
|
subscriptionTotalMinor?: number;
|
||||||
subscriptionSalesMinor?: number;
|
subscriptionSalesMinor?: number;
|
||||||
subscriptionWindowMinor?: number;
|
subscriptionWindowMinor?: number;
|
||||||
|
discountTotalMinor?: number;
|
||||||
openingFloatMinor?: number;
|
openingFloatMinor?: number;
|
||||||
cashAddedMinor?: number;
|
cashAddedMinor?: number;
|
||||||
cashRemovedMinor?: number;
|
cashRemovedMinor?: number;
|
||||||
@@ -250,6 +255,8 @@ export class ShiftService {
|
|||||||
ticketTotalMinor:
|
ticketTotalMinor:
|
||||||
pl.ticketTotalMinor ??
|
pl.ticketTotalMinor ??
|
||||||
(pl.cashTotalMinor ?? 0) + (pl.cardTotalMinor ?? 0) - (pl.subscriptionTotalMinor ?? 0),
|
(pl.cashTotalMinor ?? 0) + (pl.cardTotalMinor ?? 0) - (pl.subscriptionTotalMinor ?? 0),
|
||||||
|
// Merchant-validation leakage (added 2026-07-13). Old reports lack it → 0.
|
||||||
|
discountTotalMinor: pl.discountTotalMinor ?? 0,
|
||||||
openingFloatMinor: pl.openingFloatMinor ?? 0,
|
openingFloatMinor: pl.openingFloatMinor ?? 0,
|
||||||
cashAddedMinor: pl.cashAddedMinor ?? 0,
|
cashAddedMinor: pl.cashAddedMinor ?? 0,
|
||||||
cashRemovedMinor: pl.cashRemovedMinor ?? 0,
|
cashRemovedMinor: pl.cashRemovedMinor ?? 0,
|
||||||
@@ -522,6 +529,9 @@ export class ShiftService {
|
|||||||
// the subscription sale path).
|
// the subscription sale path).
|
||||||
let subscriptionSalesMinor = 0;
|
let subscriptionSalesMinor = 0;
|
||||||
let subscriptionWindowMinor = 0;
|
let subscriptionWindowMinor = 0;
|
||||||
|
// Merchant-validation leakage: Σ discountMinor across the window's payments. The
|
||||||
|
// tender totals are already NET; this is the "given away" figure beside them.
|
||||||
|
let discountTotalMinor = 0;
|
||||||
let currency: string | null = null;
|
let currency: string | null = null;
|
||||||
for (const p of payments) {
|
for (const p of payments) {
|
||||||
const pl = (p.payload ?? {}) as LedgerPayload & {
|
const pl = (p.payload ?? {}) as LedgerPayload & {
|
||||||
@@ -534,6 +544,7 @@ export class ShiftService {
|
|||||||
if (pl.subscriptionSale === true) subscriptionSalesMinor += amt;
|
if (pl.subscriptionSale === true) subscriptionSalesMinor += amt;
|
||||||
else if (pl.subscriptionWindowCharge === true) subscriptionWindowMinor += amt;
|
else if (pl.subscriptionWindowCharge === true) subscriptionWindowMinor += amt;
|
||||||
// (else → transient ticket; derived below as total − subscription)
|
// (else → transient ticket; derived below as total − subscription)
|
||||||
|
if (typeof pl.discountMinor === "number") discountTotalMinor += pl.discountMinor;
|
||||||
if (pl.currency) currency = pl.currency;
|
if (pl.currency) currency = pl.currency;
|
||||||
}
|
}
|
||||||
const subscriptionTotalMinor = subscriptionSalesMinor + subscriptionWindowMinor;
|
const subscriptionTotalMinor = subscriptionSalesMinor + subscriptionWindowMinor;
|
||||||
@@ -589,6 +600,7 @@ export class ShiftService {
|
|||||||
subscriptionTotalMinor,
|
subscriptionTotalMinor,
|
||||||
subscriptionSalesMinor,
|
subscriptionSalesMinor,
|
||||||
subscriptionWindowMinor,
|
subscriptionWindowMinor,
|
||||||
|
discountTotalMinor,
|
||||||
openingFloatMinor,
|
openingFloatMinor,
|
||||||
cashAddedMinor,
|
cashAddedMinor,
|
||||||
cashRemovedMinor,
|
cashRemovedMinor,
|
||||||
@@ -627,6 +639,7 @@ export class ShiftService {
|
|||||||
subscriptionTotalMinor,
|
subscriptionTotalMinor,
|
||||||
subscriptionSalesMinor,
|
subscriptionSalesMinor,
|
||||||
subscriptionWindowMinor,
|
subscriptionWindowMinor,
|
||||||
|
discountTotalMinor,
|
||||||
openingFloatMinor,
|
openingFloatMinor,
|
||||||
cashAddedMinor,
|
cashAddedMinor,
|
||||||
cashRemovedMinor,
|
cashRemovedMinor,
|
||||||
@@ -649,6 +662,7 @@ export class ShiftService {
|
|||||||
subscriptionTotalMinor,
|
subscriptionTotalMinor,
|
||||||
subscriptionSalesMinor,
|
subscriptionSalesMinor,
|
||||||
subscriptionWindowMinor,
|
subscriptionWindowMinor,
|
||||||
|
discountTotalMinor,
|
||||||
openingFloatMinor,
|
openingFloatMinor,
|
||||||
cashAddedMinor,
|
cashAddedMinor,
|
||||||
cashRemovedMinor,
|
cashRemovedMinor,
|
||||||
@@ -691,14 +705,17 @@ export class ShiftService {
|
|||||||
// Abonime is the subscription TOTAL; only the out-of-window part is broken out.
|
// Abonime is the subscription TOTAL; only the out-of-window part is broken out.
|
||||||
// (subscriptionSalesMinor stays in the signed payload — it's just not printed.)
|
// (subscriptionSalesMinor stays in the signed payload — it's just not printed.)
|
||||||
`Abonime: ${money(r.subscriptionTotalMinor)} ${cur}`,
|
`Abonime: ${money(r.subscriptionTotalMinor)} ${cur}`,
|
||||||
` jashtë orarit: ${money(r.subscriptionWindowMinor)} ${cur}`,
|
`Jashtë orarit: ${money(r.subscriptionWindowMinor)} ${cur}`,
|
||||||
|
// Merchant-validation leakage — printed only when the shift actually gave any
|
||||||
|
// (older slips stay byte-identical). The takings above are already NET of it.
|
||||||
|
...(r.discountTotalMinor > 0 ? [`Zbritje (validime): ${money(r.discountTotalMinor)} ${cur}`] : []),
|
||||||
"",
|
"",
|
||||||
"-- Arka --",
|
"-- Arka --",
|
||||||
`Fillimi: ${money(r.openingFloatMinor)} ${cur}`,
|
`Gjëndje fillestare: ${money(r.openingFloatMinor)} ${cur}`,
|
||||||
`Para të marra: ${money(r.cashTotalMinor)} ${cur}`,
|
`Para të grumbulluara: ${money(r.cashTotalMinor)} ${cur}`,
|
||||||
`Para të shtuara: ${money(r.cashAddedMinor)} ${cur}`,
|
`Arkëtime: ${money(r.cashAddedMinor)} ${cur}`,
|
||||||
`Para të hequra: ${money(r.cashRemovedMinor)} ${cur}`,
|
`Pagesa: ${money(r.cashRemovedMinor)} ${cur}`,
|
||||||
`Arka e pritur: ${money(r.expectedDrawerMinor)} ${cur}`,
|
`Gjëndje aktuale: ${money(r.expectedDrawerMinor)} ${cur}`,
|
||||||
];
|
];
|
||||||
try {
|
try {
|
||||||
await printer.printReport({ title: "RAPORT TURNI", lines });
|
await printer.printReport({ title: "RAPORT TURNI", lines });
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { eq, ledgerEvents, type Db } from "@parking/db";
|
||||||
|
import type { SessionValidation, ValidationMode } from "@parking/shared";
|
||||||
|
|
||||||
|
// Merchant-validation ledger folds. A validation is a SIGNED, appended event on the
|
||||||
|
// session (never a mutable flag): payload carries the RESOLVED values (programId,
|
||||||
|
// label, mode, minutes/amountMinor/percent) + the merchant username. A validation
|
||||||
|
// event with `refId` set VOIDS the referenced one; a payment's `validationIds` marks
|
||||||
|
// which validations it CONSUMED (so an overstay's fresh period never re-applies
|
||||||
|
// them). See wiki/concepts/validation-discounts.md.
|
||||||
|
|
||||||
|
/** A validation event folded with its lifecycle state. */
|
||||||
|
export interface AppliedValidation extends SessionValidation {
|
||||||
|
readonly eventId: string;
|
||||||
|
readonly occurredAt: string;
|
||||||
|
/** The merchant username who applied it. */
|
||||||
|
readonly operator: string | null;
|
||||||
|
/** Voided by a later validation event referencing it. */
|
||||||
|
readonly voided: boolean;
|
||||||
|
/** The payment event id that consumed it, if settled. */
|
||||||
|
readonly consumedBy: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** All validations ever applied to a session (newest last), with voided/consumed
|
||||||
|
* state folded from the chain. One identity-scoped ledger scan. */
|
||||||
|
export function sessionValidations(db: Db, identity: string): AppliedValidation[] {
|
||||||
|
const rows = db
|
||||||
|
.select({
|
||||||
|
id: ledgerEvents.id,
|
||||||
|
type: ledgerEvents.type,
|
||||||
|
occurredAt: ledgerEvents.occurredAt,
|
||||||
|
payload: ledgerEvents.payload,
|
||||||
|
})
|
||||||
|
.from(ledgerEvents)
|
||||||
|
.where(eq(ledgerEvents.identity, identity))
|
||||||
|
.orderBy(ledgerEvents.index)
|
||||||
|
.all();
|
||||||
|
|
||||||
|
const voided = new Set<string>();
|
||||||
|
const consumedBy = new Map<string, string>();
|
||||||
|
const applies: AppliedValidation[] = [];
|
||||||
|
|
||||||
|
for (const r of rows) {
|
||||||
|
const p = (r.payload ?? {}) as {
|
||||||
|
refId?: string;
|
||||||
|
programId?: string;
|
||||||
|
programLabel?: string;
|
||||||
|
mode?: ValidationMode;
|
||||||
|
minutes?: number;
|
||||||
|
amountMinor?: number;
|
||||||
|
percent?: number;
|
||||||
|
operator?: string;
|
||||||
|
validationIds?: string[];
|
||||||
|
};
|
||||||
|
if (r.type === "validation") {
|
||||||
|
if (p.refId) {
|
||||||
|
voided.add(p.refId);
|
||||||
|
} else if (p.programId && p.mode) {
|
||||||
|
applies.push({
|
||||||
|
eventId: r.id,
|
||||||
|
occurredAt: r.occurredAt,
|
||||||
|
programId: p.programId,
|
||||||
|
label: p.programLabel ?? p.programId,
|
||||||
|
mode: p.mode,
|
||||||
|
...(typeof p.minutes === "number" ? { minutes: p.minutes } : {}),
|
||||||
|
...(typeof p.amountMinor === "number" ? { amountMinor: p.amountMinor } : {}),
|
||||||
|
...(typeof p.percent === "number" ? { percent: p.percent } : {}),
|
||||||
|
operator: p.operator ?? null,
|
||||||
|
voided: false,
|
||||||
|
consumedBy: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (r.type === "payment" && Array.isArray(p.validationIds)) {
|
||||||
|
for (const vid of p.validationIds) consumedBy.set(vid, r.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return applies.map((a) => ({
|
||||||
|
...a,
|
||||||
|
voided: voided.has(a.eventId),
|
||||||
|
consumedBy: consumedBy.get(a.eventId) ?? null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The LIVE validations for pricing: applied, not voided, not consumed by a prior
|
||||||
|
* payment. This is exactly what `priceSession(..., validations)` expects. */
|
||||||
|
export function liveValidations(db: Db, identity: string): AppliedValidation[] {
|
||||||
|
return sessionValidations(db, identity).filter((v) => !v.voided && v.consumedBy == null);
|
||||||
|
}
|
||||||
@@ -18,8 +18,12 @@
|
|||||||
"@radix-ui/react-tabs": "^1.1.15",
|
"@radix-ui/react-tabs": "^1.1.15",
|
||||||
"@tanstack/react-query": "^5.101.0",
|
"@tanstack/react-query": "^5.101.0",
|
||||||
"@tanstack/react-router": "^1.170.16",
|
"@tanstack/react-router": "^1.170.16",
|
||||||
|
"@tauri-apps/api": "^2.11.1",
|
||||||
|
"@tauri-apps/plugin-http": "^2.5.2",
|
||||||
"@tauri-apps/plugin-process": "^2.3.1",
|
"@tauri-apps/plugin-process": "^2.3.1",
|
||||||
|
"@tauri-apps/plugin-store": "^2.4.0",
|
||||||
"@tauri-apps/plugin-updater": "^2.10.1",
|
"@tauri-apps/plugin-updater": "^2.10.1",
|
||||||
|
"@tauri-apps/plugin-websocket": "^2.3.0",
|
||||||
"i18next": "^26.3.1",
|
"i18next": "^26.3.1",
|
||||||
"react": "19.2.7",
|
"react": "19.2.7",
|
||||||
"react-dom": "19.2.7",
|
"react-dom": "19.2.7",
|
||||||
|
|||||||
@@ -3,25 +3,42 @@ import { QueryClientProvider } from "@tanstack/react-query";
|
|||||||
import { RouterProvider } from "@tanstack/react-router";
|
import { RouterProvider } from "@tanstack/react-router";
|
||||||
import { fetchMe, type SessionUser } from "./api.js";
|
import { fetchMe, type SessionUser } from "./api.js";
|
||||||
import { Login } from "./Login.js";
|
import { Login } from "./Login.js";
|
||||||
|
import { ConnectScreen } from "./ConnectScreen.js";
|
||||||
import { queryClient } from "./lib/query.js";
|
import { queryClient } from "./lib/query.js";
|
||||||
import { setLanguage } from "./lib/i18n/index.js";
|
import { setLanguage } from "./lib/i18n/index.js";
|
||||||
import { applyTheme, applyFontScale } from "./lib/theme.js";
|
import { applyTheme, applyFontScale } from "./lib/theme.js";
|
||||||
import { router } from "./router.js";
|
import { router } from "./router.js";
|
||||||
|
import { initApiBase, inTauri } from "./lib/origin.js";
|
||||||
|
|
||||||
// App root: bootstraps the session (cookie-based, from /api/auth/me), then hands
|
// App root: bootstraps the session (cookie-based, from /api/auth/me), then hands
|
||||||
// off to TanStack Router inside the QueryClient provider. The router renders the
|
// off to TanStack Router inside the QueryClient provider. The router renders the
|
||||||
// terminal chrome + screens; auth gating stays here (Login until signed in), and
|
// terminal chrome + screens; auth gating stays here (Login until signed in), and
|
||||||
// the signed-in user flows into the router context for role-based route guards.
|
// the signed-in user flows into the router context for role-based route guards.
|
||||||
// See wiki/entities/react-vite-spa.md and local-jwt-auth.md.
|
// See wiki/entities/react-vite-spa.md and local-jwt-auth.md.
|
||||||
|
//
|
||||||
|
// Desktop shell only: BEFORE any of that, the backend origin itself must be
|
||||||
|
// known — the same installer is used at every booth (see lib/origin.ts /
|
||||||
|
// backend-config.ts), so on first launch (or after the operator clears it)
|
||||||
|
// there is no server to call fetchMe() against yet. ConnectScreen gates that;
|
||||||
|
// a browser build always has a same-origin backend, so `needsConnect` is
|
||||||
|
// always false there and this is skipped entirely.
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
const [user, setUser] = useState<SessionUser | null>(null);
|
const [user, setUser] = useState<SessionUser | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [needsConnect, setNeedsConnect] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
initApiBase().then((saved) => {
|
||||||
|
if (inTauri() && !saved) {
|
||||||
|
setNeedsConnect(true);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
fetchMe()
|
fetchMe()
|
||||||
.then(setUser)
|
.then(setUser)
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Apply the signed-in user's preferred language + theme + font scale whenever they
|
// Apply the signed-in user's preferred language + theme + font scale whenever they
|
||||||
@@ -41,6 +58,19 @@ export function App() {
|
|||||||
if (loading) {
|
if (loading) {
|
||||||
return <div className="flex h-screen items-center justify-center text-term-muted">loading…</div>;
|
return <div className="flex h-screen items-center justify-center text-term-muted">loading…</div>;
|
||||||
}
|
}
|
||||||
|
if (needsConnect) {
|
||||||
|
return (
|
||||||
|
<ConnectScreen
|
||||||
|
onConnected={() => {
|
||||||
|
setNeedsConnect(false);
|
||||||
|
setLoading(true);
|
||||||
|
fetchMe()
|
||||||
|
.then(setUser)
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
import { rootRoute } from "./router.js";
|
import { rootRoute } from "./router.js";
|
||||||
import { qk } from "./lib/query.js";
|
import { qk } from "./lib/query.js";
|
||||||
import { useShift } from "./lib/use-shift.js";
|
import { useShift } from "./lib/use-shift.js";
|
||||||
import { formatDuration, formatMoney, formatTime, formatRelativeDateTime } from "./lib/format.js";
|
import { formatDuration, formatMoney, formatRelativeDateTime } from "./lib/format.js";
|
||||||
import { CARD_PAYMENTS_ENABLED } from "./lib/features.js";
|
import { CARD_PAYMENTS_ENABLED } from "./lib/features.js";
|
||||||
import { SnapshotStrip } from "./ui/SnapshotStrip.js";
|
import { SnapshotStrip } from "./ui/SnapshotStrip.js";
|
||||||
import { Spinner } from "./ui/Spinner.js";
|
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.
|
// figures, and the snapshot strip read-only. No tender / voucher / open here.
|
||||||
<>
|
<>
|
||||||
<div className="rounded-term border border-term-amber px-3 py-2 text-term-amber">
|
<div className="rounded-term border border-term-amber px-3 py-2 text-term-amber">
|
||||||
{t("pay.alreadyClosed", { time: formatTime(s.exitedAt) })}
|
{t("pay.alreadyClosed", { time: formatRelativeDateTime(s.exitedAt, t, { seconds: true }) })}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-x-6 gap-y-1 tabular-nums">
|
<div className="grid grid-cols-2 gap-x-6 gap-y-1 tabular-nums">
|
||||||
<Row label={t("pay.entry")} value={formatRelativeDateTime(s.enteredAt, t)} />
|
<Row label={t("pay.entry")} value={formatRelativeDateTime(s.enteredAt, t, { seconds: true })} />
|
||||||
<Row label={t("pay.exit")} value={formatTime(s.exitedAt)} />
|
<Row label={t("pay.exit")} value={formatRelativeDateTime(s.exitedAt, t, { seconds: true })} />
|
||||||
<Row
|
<Row
|
||||||
label={t("pay.duration")}
|
label={t("pay.duration")}
|
||||||
value={
|
value={
|
||||||
@@ -335,11 +335,15 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
|||||||
<>
|
<>
|
||||||
{/* Session figures */}
|
{/* Session figures */}
|
||||||
<div className="grid grid-cols-2 gap-x-6 gap-y-1 tabular-nums">
|
<div className="grid grid-cols-2 gap-x-6 gap-y-1 tabular-nums">
|
||||||
<Row label={t("pay.entry")} value={formatRelativeDateTime(s.enteredAt, t)} />
|
<Row label={t("pay.entry")} value={formatRelativeDateTime(s.enteredAt, t, { seconds: true })} />
|
||||||
{/* Closed-within-grace shows the recorded EXIT; an open session shows now. */}
|
{/* Closed-within-grace shows the recorded EXIT; an open session shows now. */}
|
||||||
<Row
|
<Row
|
||||||
label={closedWithinGrace ? t("pay.exit") : t("pay.now")}
|
label={closedWithinGrace ? t("pay.exit") : t("pay.now")}
|
||||||
value={closedWithinGrace ? formatTime(s.exitedAt) : formatTime(new Date().toISOString())}
|
value={formatRelativeDateTime(
|
||||||
|
closedWithinGrace ? s.exitedAt : new Date().toISOString(),
|
||||||
|
t,
|
||||||
|
{ seconds: true },
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
<Row
|
<Row
|
||||||
label={t("pay.duration")}
|
label={t("pay.duration")}
|
||||||
@@ -379,6 +383,30 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Merchant validations (bar/lavazh): the gross fee + one line per
|
||||||
|
discount — the Total below is the NET the customer pays. The lines
|
||||||
|
ride the quote (SessionLookup.validationLines) and reprint on the
|
||||||
|
receipt. See wiki/concepts/validation-discounts.md. */}
|
||||||
|
{!isSubscription &&
|
||||||
|
(s.validationLines ?? []).length > 0 &&
|
||||||
|
s.currency != null &&
|
||||||
|
s.amountMinor != null && (
|
||||||
|
<div className="rounded-term bg-term-panel-2 px-3 py-2 text-[0.75rem]">
|
||||||
|
<div className="flex justify-between text-term-text">
|
||||||
|
<span>{t("val.gross")}</span>
|
||||||
|
<span className="tabular-nums">
|
||||||
|
{formatMoney(s.grossMinor ?? s.amountMinor, s.currency)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{(s.validationLines ?? []).map((v, i) => (
|
||||||
|
<div key={i} className="flex justify-between text-term-green">
|
||||||
|
<span>{v.label}</span>
|
||||||
|
<span className="tabular-nums">−{formatMoney(v.discountMinor, s.currency!)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Total — a subscription is prepaid (no amount) UNLESS it owes an
|
{/* Total — a subscription is prepaid (no amount) UNLESS it owes an
|
||||||
out-of-window window charge; then show that amount. For an overstay the
|
out-of-window window charge; then show that amount. For an overstay the
|
||||||
amount is the TOP-UP delta, not the whole stay. */}
|
amount is the TOP-UP delta, not the whole stay. */}
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { setApiBase } from "./lib/origin.js";
|
||||||
|
|
||||||
|
// Desktop-only gate shown BEFORE Login whenever no backend has been
|
||||||
|
// configured yet (first launch of a generic .deb/.AppImage install, or after
|
||||||
|
// the operator clears it from Settings). Same installer works at any booth —
|
||||||
|
// see backend-config.ts for why this can't be a build-time value.
|
||||||
|
//
|
||||||
|
// backend-config.ts is imported dynamically (not at module top-level) purely
|
||||||
|
// to keep bundling consistent with origin.ts/router.tsx's other Tauri-only
|
||||||
|
// imports — this component itself only ever renders inside Tauri anyway, so
|
||||||
|
// it's not a functional requirement, just avoids an INEFFECTIVE_DYNAMIC_IMPORT
|
||||||
|
// warning from Vite (a static import here would defeat those other dynamic
|
||||||
|
// imports' chunk-splitting intent).
|
||||||
|
|
||||||
|
function normalizeHost(raw: string): string {
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (!trimmed) return trimmed;
|
||||||
|
return /^https?:\/\//i.test(trimmed) ? trimmed : `http://${trimmed}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConnectScreen({ onConnected }: { onConnected: () => void }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [host, setHost] = useState("");
|
||||||
|
const [testing, setTesting] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [result, setResult] = useState<"ok" | "unreachable" | "bad_response" | null>(null);
|
||||||
|
const [detail, setDetail] = useState<string | undefined>(undefined);
|
||||||
|
|
||||||
|
const url = normalizeHost(host);
|
||||||
|
const canSubmit = url.length > 0 && !testing && !saving;
|
||||||
|
|
||||||
|
async function handleTest(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!canSubmit) return;
|
||||||
|
setTesting(true);
|
||||||
|
setResult(null);
|
||||||
|
setDetail(undefined);
|
||||||
|
try {
|
||||||
|
const { testBackendUrl } = await import("./lib/backend-config.js");
|
||||||
|
const check = await testBackendUrl(url);
|
||||||
|
setResult(check.ok ? "ok" : (check.reason ?? "unreachable"));
|
||||||
|
setDetail(check.detail);
|
||||||
|
} finally {
|
||||||
|
setTesting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const { saveBackendUrl } = await import("./lib/backend-config.js");
|
||||||
|
await saveBackendUrl(url);
|
||||||
|
setApiBase(url);
|
||||||
|
onConnected();
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="flex min-h-screen items-center justify-center bg-term-bg px-4">
|
||||||
|
<form onSubmit={handleTest} className="card w-full max-w-sm p-6">
|
||||||
|
<h1 className="mb-1 text-h5 font-semibold uppercase tracking-widest text-term-amber">
|
||||||
|
{t("connect.title")}
|
||||||
|
</h1>
|
||||||
|
<p className="mb-5 text-[0.75rem] text-term-muted">{t("connect.hint")}</p>
|
||||||
|
|
||||||
|
<div className="field mb-3">
|
||||||
|
<label className="label">{t("connect.serverAddress")}</label>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={host}
|
||||||
|
onChange={(e) => {
|
||||||
|
setHost(e.target.value);
|
||||||
|
setResult(null);
|
||||||
|
}}
|
||||||
|
placeholder="192.168.1.50:3000"
|
||||||
|
autoFocus
|
||||||
|
autoCapitalize="off"
|
||||||
|
autoCorrect="off"
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{result === "ok" && (
|
||||||
|
<p className="mb-3 text-[0.75rem] text-term-green">{t("connect.testOk")}</p>
|
||||||
|
)}
|
||||||
|
{result === "unreachable" && (
|
||||||
|
<p className="mb-3 text-[0.75rem] text-term-red">
|
||||||
|
{t("connect.testUnreachable")}
|
||||||
|
{detail ? ` (${detail})` : ""}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{result === "bad_response" && (
|
||||||
|
<p className="mb-3 text-[0.75rem] text-term-red">{t("connect.testBadResponse")}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button type="submit" className="btn flex-1" disabled={!canSubmit}>
|
||||||
|
{testing ? t("connect.testing") : t("connect.test")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary flex-1"
|
||||||
|
disabled={!canSubmit || result !== "ok"}
|
||||||
|
onClick={handleSave}
|
||||||
|
>
|
||||||
|
{saving ? t("connect.saving") : t("connect.save")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
type MovementStatus,
|
type MovementStatus,
|
||||||
type ShiftSummary,
|
type ShiftSummary,
|
||||||
} from "./api.js";
|
} from "./api.js";
|
||||||
import { formatMoney, formatRelativeDateTime } from "./lib/format.js";
|
import { formatClock, formatMoney, formatRelativeDateTime } from "./lib/format.js";
|
||||||
import { Panel } from "./ui/Panel.js";
|
import { Panel } from "./ui/Panel.js";
|
||||||
import type { LedgerEvent } from "@parking/shared";
|
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 pl = (e.payload ?? {}) as { amountMinor?: number; currency?: string; voucherNo?: string; reason?: string };
|
||||||
const amt = pl.amountMinor ?? 0;
|
const amt = pl.amountMinor ?? 0;
|
||||||
const signed = e.type === "cash_out" ? -Math.abs(amt) : Math.abs(amt);
|
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 =
|
const label =
|
||||||
e.type === "payment"
|
e.type === "payment"
|
||||||
? `${t("drawer.payment")}${e.identity ? ` · ${e.identity}` : ""}`
|
? `${t("drawer.payment")}${e.identity ? ` · ${e.identity}` : ""}`
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ function LogRow({ log }: { log: AppLogRecord }) {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const hasDetail = (log.context && Object.keys(log.context).length > 0) || log.stack;
|
const hasDetail = (log.context && Object.keys(log.context).length > 0) || log.stack;
|
||||||
|
// Storm-coalesced row: the server folds repeated identical lines into one row and
|
||||||
|
// counts them in context._repeat (first occurrence kept in _firstAt).
|
||||||
|
const repeat = typeof log.context?._repeat === "number" ? (log.context?._repeat as number) : null;
|
||||||
|
const firstAt = typeof log.context?._firstAt === "string" ? (log.context?._firstAt as string) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`border-b border-term-border/50 ${log.level === "error" || log.level === "fatal" ? "bg-term-red/5" : ""}`}>
|
<div className={`border-b border-term-border/50 ${log.level === "error" || log.level === "fatal" ? "bg-term-red/5" : ""}`}>
|
||||||
@@ -38,7 +42,20 @@ function LogRow({ log }: { log: AppLogRecord }) {
|
|||||||
<span className="text-term-muted tabular-nums">{formatRelativeDateTime(log.createdAt, t)}</span>
|
<span className="text-term-muted tabular-nums">{formatRelativeDateTime(log.createdAt, t)}</span>
|
||||||
<span className={`font-semibold uppercase ${LEVEL_COLOR[log.level]}`}>{log.level}</span>
|
<span className={`font-semibold uppercase ${LEVEL_COLOR[log.level]}`}>{log.level}</span>
|
||||||
<span className="text-term-muted">{t(log.source === "frontend" ? "logs.frontend" : "logs.backend")}</span>
|
<span className="text-term-muted">{t(log.source === "frontend" ? "logs.frontend" : "logs.backend")}</span>
|
||||||
<span className="truncate text-term-text">{log.message}</span>
|
<span className="truncate text-term-text">
|
||||||
|
{repeat != null && repeat > 1 && (
|
||||||
|
<span
|
||||||
|
className="mr-1.5 rounded-term border border-term-amber/50 px-1 text-[0.625rem] font-semibold text-term-amber"
|
||||||
|
title={t("logs.repeated", {
|
||||||
|
count: repeat,
|
||||||
|
firstAt: firstAt ? formatRelativeDateTime(firstAt, t) : "—",
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
×{repeat}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{log.message}
|
||||||
|
</span>
|
||||||
<span className="text-term-muted tabular-nums">{log.httpStatus ?? ""}</span>
|
<span className="text-term-muted tabular-nums">{log.httpStatus ?? ""}</span>
|
||||||
</button>
|
</button>
|
||||||
{open && hasDetail && (
|
{open && hasDetail && (
|
||||||
|
|||||||
+95
-12
@@ -1,8 +1,10 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { Fragment, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { TFunction } from "i18next";
|
import type { TFunction } from "i18next";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
|
Area,
|
||||||
|
AreaChart,
|
||||||
Bar,
|
Bar,
|
||||||
BarChart,
|
BarChart,
|
||||||
CartesianGrid,
|
CartesianGrid,
|
||||||
@@ -12,6 +14,7 @@ import {
|
|||||||
LineChart,
|
LineChart,
|
||||||
Pie,
|
Pie,
|
||||||
PieChart,
|
PieChart,
|
||||||
|
ReferenceLine,
|
||||||
ResponsiveContainer,
|
ResponsiveContainer,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
XAxis,
|
XAxis,
|
||||||
@@ -37,6 +40,7 @@ const C = {
|
|||||||
border: "#2a2f38",
|
border: "#2a2f38",
|
||||||
text: "#f2f2ee",
|
text: "#f2f2ee",
|
||||||
panel: "#14171c",
|
panel: "#14171c",
|
||||||
|
panel2: "#1e222a",
|
||||||
};
|
};
|
||||||
|
|
||||||
type PresetKey = "today" | "7d" | "30d" | "90d";
|
type PresetKey = "today" | "7d" | "30d" | "90d";
|
||||||
@@ -140,27 +144,37 @@ function ReportBody({ data, t }: { data: ReportSummary; t: TFunction }) {
|
|||||||
...p,
|
...p,
|
||||||
label: data.bucket === "hour" ? p.bucket.slice(11) + "h" : p.bucket,
|
label: data.bucket === "hour" ? p.bucket.slice(11) + "h" : p.bucket,
|
||||||
revenue: p.revenueMinor / 100,
|
revenue: p.revenueMinor / 100,
|
||||||
|
cash: p.cashMinor / 100,
|
||||||
|
card: p.cardMinor / 100,
|
||||||
}));
|
}));
|
||||||
const hours = data.entriesByHour.map((entries, h) => ({ hour: `${h}`, entries }));
|
|
||||||
const mix = [
|
const mix = [
|
||||||
{ name: t("reports.mix.ticket"), value: tot.ticketMinor, color: C.amber },
|
{ name: t("reports.mix.ticket"), value: tot.ticketMinor, color: C.amber },
|
||||||
{ name: t("reports.mix.subSales"), value: tot.subscriptionSalesMinor, color: C.cyan },
|
{ name: t("reports.mix.subSales"), value: tot.subscriptionSalesMinor, color: C.cyan },
|
||||||
{ name: t("reports.mix.subWindow"), value: tot.subscriptionWindowMinor, color: C.green },
|
{ name: t("reports.mix.subWindow"), value: tot.subscriptionWindowMinor, color: C.green },
|
||||||
].filter((s) => s.value > 0);
|
].filter((s) => s.value > 0);
|
||||||
|
const peakOcc = Math.max(data.occupancyStart, ...data.series.map((p) => p.occupancyEnd));
|
||||||
|
// Stay-duration bars: "≤30m … ≤24h" + the open-ended tail.
|
||||||
|
const stay = data.stayHistogram.map((b) => ({
|
||||||
|
label: b.uptoMin == null ? `>24${t("reports.stay.h")}` : b.uptoMin < 60 ? `≤${b.uptoMin}${t("reports.stay.m")}` : `≤${b.uptoMin / 60}${t("reports.stay.h")}`,
|
||||||
|
count: b.count,
|
||||||
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* KPI cards. */}
|
{/* KPI cards. */}
|
||||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-6">
|
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4 xl:grid-cols-8">
|
||||||
<Kpi label={t("reports.kpi.entries")} value={String(tot.entries)} accent="green" />
|
<Kpi label={t("reports.kpi.entries")} value={String(tot.entries)} accent="green" />
|
||||||
<Kpi label={t("reports.kpi.exits")} value={String(tot.exits)} accent="red" />
|
<Kpi label={t("reports.kpi.exits")} value={String(tot.exits)} accent="red" />
|
||||||
<Kpi label={t("reports.kpi.revenue")} value={money(tot.revenueMinor)} accent="amber" />
|
<Kpi label={t("reports.kpi.revenue")} value={money(tot.revenueMinor)} accent="amber" />
|
||||||
<Kpi label={t("reports.kpi.payments")} value={String(tot.payments)} accent="cyan" />
|
<Kpi label={t("reports.kpi.payments")} value={String(tot.payments)} accent="cyan" />
|
||||||
<Kpi label={t("reports.kpi.avgStay")} value={formatMinutes(tot.avgParkedMinutes)} />
|
<Kpi label={t("reports.kpi.avgStay")} value={formatMinutes(tot.avgParkedMinutes)} />
|
||||||
<Kpi
|
<Kpi
|
||||||
label={t("reports.kpi.subscribers")}
|
label={t("reports.kpi.peakOcc")}
|
||||||
value={String(data.subscriptions.currentlyValid)}
|
value={data.capacity ? `${peakOcc} / ${data.capacity}` : String(peakOcc)}
|
||||||
/>
|
/>
|
||||||
|
{/* The "look closer" counters — a spike here is what the signed chain is FOR. */}
|
||||||
|
<Kpi label={t("reports.kpi.voids")} value={String(tot.voids)} accent={tot.voids > 0 ? "amber" : undefined} />
|
||||||
|
<Kpi label={t("reports.kpi.anomalies")} value={String(tot.anomalies)} accent={tot.anomalies > 0 ? "red" : undefined} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Entry / exit over time. */}
|
{/* Entry / exit over time. */}
|
||||||
@@ -192,8 +206,38 @@ function ReportBody({ data, t }: { data: ReportSummary; t: TFunction }) {
|
|||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
|
{/* Occupancy over time — THE parking curve: cars inside vs capacity. Step-shaped
|
||||||
|
(occupancy only moves at entries/exits); the red line is the configured cap. */}
|
||||||
|
<Panel title={t("reports.chart.occupancy")}>
|
||||||
|
<ResponsiveContainer width="100%" height={220}>
|
||||||
|
<AreaChart data={series} margin={{ top: 8, right: 12, bottom: 0, left: -8 }}>
|
||||||
|
<CartesianGrid stroke={C.border} strokeDasharray="3 3" />
|
||||||
|
<XAxis dataKey="label" stroke={C.muted} fontSize={11} />
|
||||||
|
<YAxis stroke={C.muted} fontSize={11} allowDecimals={false} />
|
||||||
|
<Tooltip contentStyle={tooltipStyle} />
|
||||||
|
{data.capacity != null && (
|
||||||
|
<ReferenceLine
|
||||||
|
y={data.capacity}
|
||||||
|
stroke={C.red}
|
||||||
|
strokeDasharray="4 4"
|
||||||
|
label={{ value: t("reports.capacityLine"), fill: C.red, fontSize: 11, position: "insideTopRight" }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Area
|
||||||
|
type="stepAfter"
|
||||||
|
dataKey="occupancyEnd"
|
||||||
|
name={t("reports.chart.occupancySeries")}
|
||||||
|
stroke={C.cyan}
|
||||||
|
fill={C.cyan}
|
||||||
|
fillOpacity={0.15}
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
<div className="grid gap-4 lg:grid-cols-2">
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
{/* Revenue per bucket. */}
|
{/* Revenue per bucket, stacked by tender — the drawer's cash vs the bank's card. */}
|
||||||
<Panel title={t("reports.chart.revenue", { currency: cur })}>
|
<Panel title={t("reports.chart.revenue", { currency: cur })}>
|
||||||
<ResponsiveContainer width="100%" height={240}>
|
<ResponsiveContainer width="100%" height={240}>
|
||||||
<BarChart data={series} margin={{ top: 8, right: 12, bottom: 0, left: -8 }}>
|
<BarChart data={series} margin={{ top: 8, right: 12, bottom: 0, left: -8 }}>
|
||||||
@@ -201,7 +245,9 @@ function ReportBody({ data, t }: { data: ReportSummary; t: TFunction }) {
|
|||||||
<XAxis dataKey="label" stroke={C.muted} fontSize={11} />
|
<XAxis dataKey="label" stroke={C.muted} fontSize={11} />
|
||||||
<YAxis stroke={C.muted} fontSize={11} />
|
<YAxis stroke={C.muted} fontSize={11} />
|
||||||
<Tooltip contentStyle={tooltipStyle} formatter={(v) => money(Math.round(Number(v) * 100))} />
|
<Tooltip contentStyle={tooltipStyle} formatter={(v) => money(Math.round(Number(v) * 100))} />
|
||||||
<Bar dataKey="revenue" name={t("reports.kpi.revenue")} fill={C.amber} />
|
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||||
|
<Bar dataKey="cash" stackId="tender" name={t("reports.row.cash")} fill={C.amber} />
|
||||||
|
<Bar dataKey="card" stackId="tender" name={t("reports.row.card")} fill={C.cyan} />
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</Panel>
|
</Panel>
|
||||||
@@ -232,15 +278,15 @@ function ReportBody({ data, t }: { data: ReportSummary; t: TFunction }) {
|
|||||||
)}
|
)}
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
{/* Peak hours (entries by hour-of-day). */}
|
{/* Stay-duration histogram — where the ladder/up-to breakpoints should sit. */}
|
||||||
<Panel title={t("reports.chart.peakHours")}>
|
<Panel title={t("reports.chart.stay")}>
|
||||||
<ResponsiveContainer width="100%" height={240}>
|
<ResponsiveContainer width="100%" height={240}>
|
||||||
<BarChart data={hours} margin={{ top: 8, right: 12, bottom: 0, left: -8 }}>
|
<BarChart data={stay} margin={{ top: 8, right: 12, bottom: 0, left: -8 }}>
|
||||||
<CartesianGrid stroke={C.border} strokeDasharray="3 3" />
|
<CartesianGrid stroke={C.border} strokeDasharray="3 3" />
|
||||||
<XAxis dataKey="hour" stroke={C.muted} fontSize={11} interval={1} />
|
<XAxis dataKey="label" stroke={C.muted} fontSize={11} />
|
||||||
<YAxis stroke={C.muted} fontSize={11} allowDecimals={false} />
|
<YAxis stroke={C.muted} fontSize={11} allowDecimals={false} />
|
||||||
<Tooltip contentStyle={tooltipStyle} />
|
<Tooltip contentStyle={tooltipStyle} />
|
||||||
<Bar dataKey="entries" name={t("reports.kpi.entries")} fill={C.cyan} />
|
<Bar dataKey="count" name={t("reports.row.closed")} fill={C.green} />
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</Panel>
|
</Panel>
|
||||||
@@ -262,6 +308,12 @@ function ReportBody({ data, t }: { data: ReportSummary; t: TFunction }) {
|
|||||||
</Panel>
|
</Panel>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Entries heatmap: hour × day-of-week. Weekday-vs-weekend patterns at a glance —
|
||||||
|
the direct input for tariff windows (night rates, weekend cards, early bird). */}
|
||||||
|
<Panel title={t("reports.chart.heatmap")}>
|
||||||
|
<Heatmap matrix={data.entriesByDowHour} dows={t("reports.dowShort", { returnObjects: true }) as string[]} />
|
||||||
|
</Panel>
|
||||||
|
|
||||||
<p className="text-[0.6875rem] text-term-muted">
|
<p className="text-[0.6875rem] text-term-muted">
|
||||||
{t("reports.footnote", { tz: data.tz })}
|
{t("reports.footnote", { tz: data.tz })}
|
||||||
</p>
|
</p>
|
||||||
@@ -269,6 +321,37 @@ function ReportBody({ data, t }: { data: ReportSummary; t: TFunction }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Hour-of-day × day-of-week entries heatmap: pure CSS grid, amber intensity scaled to
|
||||||
|
* the busiest cell. Row 0 = Monday (server contract). Cell tooltip = exact count. */
|
||||||
|
function Heatmap({ matrix, dows }: { matrix: number[][]; dows: string[] }) {
|
||||||
|
const max = Math.max(1, ...matrix.flat());
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<div className="grid min-w-[560px] grid-cols-[max-content_repeat(24,1fr)] gap-px text-[0.625rem]">
|
||||||
|
<span />
|
||||||
|
{Array.from({ length: 24 }, (_, h) => (
|
||||||
|
<span key={h} className="pb-0.5 text-center text-term-muted">
|
||||||
|
{h % 3 === 0 ? h : ""}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{matrix.map((row, d) => (
|
||||||
|
<Fragment key={d}>
|
||||||
|
<span className="pr-1.5 leading-4 text-term-muted">{dows[d]}</span>
|
||||||
|
{row.map((v, h) => (
|
||||||
|
<span
|
||||||
|
key={h}
|
||||||
|
title={`${dows[d]} ${String(h).padStart(2, "0")}:00 — ${v}`}
|
||||||
|
className="h-4 rounded-[1px]"
|
||||||
|
style={{ background: v === 0 ? C.panel2 : C.amber, opacity: v === 0 ? 1 : 0.25 + 0.75 * (v / max) }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const tooltipStyle = {
|
const tooltipStyle = {
|
||||||
background: C.panel,
|
background: C.panel,
|
||||||
border: `1px solid ${C.border}`,
|
border: `1px solid ${C.border}`,
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
type RelayEvent,
|
type RelayEvent,
|
||||||
type RelaySpec,
|
type RelaySpec,
|
||||||
type TestResult,
|
type TestResult,
|
||||||
|
fetchUsbPrinters,
|
||||||
} from "./api.js";
|
} from "./api.js";
|
||||||
import { Modal } from "./ui/Modal.js";
|
import { Modal } from "./ui/Modal.js";
|
||||||
|
|
||||||
@@ -257,8 +258,10 @@ function CategorySection({
|
|||||||
const [formFor, setFormFor] = useState<Assignment | "new" | null>(null);
|
const [formFor, setFormFor] = useState<Assignment | "new" | null>(null);
|
||||||
const [warnings, setWarnings] = useState<string[]>([]);
|
const [warnings, setWarnings] = useState<string[]>([]);
|
||||||
|
|
||||||
// Binding categories need a controller to point at first.
|
// Binding categories need a controller to point at first. Printers do NOT bind
|
||||||
const isBound = category !== "access";
|
// (role + failoverRank route print jobs — see printer-routing.ts), so they are
|
||||||
|
// addable on a controller-less box (e.g. the lab bench testing a USB printer).
|
||||||
|
const isBound = category !== "access" && category !== "printer";
|
||||||
const blockedNoController = isBound && controllers.length === 0;
|
const blockedNoController = isBound && controllers.length === 0;
|
||||||
const editing = formFor && formFor !== "new" ? formFor : undefined;
|
const editing = formFor && formFor !== "new" ? formFor : undefined;
|
||||||
|
|
||||||
@@ -463,6 +466,16 @@ function DeviceSummary({ assignment, controllers }: { assignment: Assignment; co
|
|||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// Printers don't bind to a barrier (routing is role + failoverRank) — show the
|
||||||
|
// role instead of a bogus "unbound" warning.
|
||||||
|
if (assignment.category === "printer") {
|
||||||
|
const role = typeof cfg.role === "string" ? cfg.role : null;
|
||||||
|
return role ? (
|
||||||
|
<span className="text-term-muted">
|
||||||
|
{t(role === "booth-receipt" ? "devices.role.booth" : "devices.role.lane")}
|
||||||
|
</span>
|
||||||
|
) : null;
|
||||||
|
}
|
||||||
// Bound device: show controller + relay it points at, with inherited direction.
|
// Bound device: show controller + relay it points at, with inherited direction.
|
||||||
const controllerId = typeof cfg.controllerId === "string" ? cfg.controllerId : null;
|
const controllerId = typeof cfg.controllerId === "string" ? cfg.controllerId : null;
|
||||||
const relay = typeof cfg.relay === "number" ? cfg.relay : null;
|
const relay = typeof cfg.relay === "number" ? cfg.relay : null;
|
||||||
@@ -534,6 +547,28 @@ function DeviceForm({
|
|||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
});
|
});
|
||||||
|
// USB printers PRESENT on the box (/dev/usb/lpN + sysfs model) — fetched when a
|
||||||
|
// printer form is on the USB transport, so devicePath becomes a SELECT of real
|
||||||
|
// devices instead of a guessed path (the kernel may pick lp1 — park-buzi did).
|
||||||
|
const [usbPrinters, setUsbPrinters] = useState<{ path: string; description: string | null }[] | null>(null);
|
||||||
|
const usbTransport = isPrinter && String(config.transport ?? "tcp-ip") === "usb";
|
||||||
|
useEffect(() => {
|
||||||
|
if (!usbTransport) return;
|
||||||
|
let alive = true;
|
||||||
|
fetchUsbPrinters()
|
||||||
|
.then((r) => {
|
||||||
|
if (!alive) return;
|
||||||
|
setUsbPrinters(r.printers);
|
||||||
|
// Fresh form with no explicit path yet → preselect the first REAL device.
|
||||||
|
if (r.printers.length > 0) {
|
||||||
|
setConfig((c) => (c.devicePath == null ? { ...c, devicePath: r.printers[0]!.path } : c));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => alive && setUsbPrinters([]));
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
};
|
||||||
|
}, [usbTransport]);
|
||||||
// Controllers: the unified relay map. Each relay reacts to an EVENT — entry/exit/both
|
// Controllers: the unified relay map. Each relay reacts to an EVENT — entry/exit/both
|
||||||
// (pulse a barrier) or radarAlert (drive an alert lamp). Alert relays carry a trigger
|
// (pulse a barrier) or radarAlert (drive an alert lamp). Alert relays carry a trigger
|
||||||
// input + blink cadence; barriers carry no input wiring (that lives in `inputs` below).
|
// input + blink cadence; barriers carry no input wiring (that lives in `inputs` below).
|
||||||
@@ -686,7 +721,11 @@ function DeviceForm({
|
|||||||
...(i.role === "presence" && i.activeLow ? { activeLow: true } : {}),
|
...(i.role === "presence" && i.activeLow ? { activeLow: true } : {}),
|
||||||
...(i.role === "button" && i.cooldownSec ? { cooldownSec: i.cooldownSec } : {}),
|
...(i.role === "button" && i.cooldownSec ? { cooldownSec: i.cooldownSec } : {}),
|
||||||
}));
|
}));
|
||||||
} else if (controllerId && boundRelay !== "") {
|
} else if (!isPrinter && controllerId && boundRelay !== "") {
|
||||||
|
// Readers/cameras bind to a controller relay (which barrier a scan opens +
|
||||||
|
// inherited direction). Printers do NOT — routing is role+failoverRank only,
|
||||||
|
// so no binding is emitted (and a stale one saved before 2026-07-06 drops
|
||||||
|
// off on the next edit).
|
||||||
out.controllerId = controllerId;
|
out.controllerId = controllerId;
|
||||||
out.relay = boundRelay;
|
out.relay = boundRelay;
|
||||||
}
|
}
|
||||||
@@ -759,7 +798,9 @@ function DeviceForm({
|
|||||||
if (!selected) return;
|
if (!selected) return;
|
||||||
// Bound devices must point at a controller relay (binding is optional in the
|
// Bound devices must point at a controller relay (binding is optional in the
|
||||||
// model with a fallback, but the wizard guides the admin to bind explicitly).
|
// model with a fallback, but the wizard guides the admin to bind explicitly).
|
||||||
if (!isController && (!controllerId || boundRelay === "")) {
|
// Printers are exempt: nothing consumes a printer's binding — their routing is
|
||||||
|
// role + failoverRank (see printer-routing.ts).
|
||||||
|
if (!isController && !isPrinter && (!controllerId || boundRelay === "")) {
|
||||||
setSaveError("Pick the controller and relay this device sits at.");
|
setSaveError("Pick the controller and relay this device sits at.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -872,7 +913,32 @@ function DeviceForm({
|
|||||||
{f.label}
|
{f.label}
|
||||||
{f.required ? " *" : ""}
|
{f.required ? " *" : ""}
|
||||||
</label>
|
</label>
|
||||||
{f.type === "select" ? (
|
{f.key === "devicePath" && usbPrinters != null && usbPrinters.length > 0 ? (
|
||||||
|
// Real devices found → a select (path + self-reported model). A saved
|
||||||
|
// path that is NOT currently present stays selectable, flagged.
|
||||||
|
<select
|
||||||
|
className="select"
|
||||||
|
value={String(config.devicePath ?? (f.default as string | undefined) ?? "")}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.value;
|
||||||
|
setConfig((c) => ({ ...c, devicePath: v }));
|
||||||
|
resetStatus();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(() => {
|
||||||
|
const cur = String(config.devicePath ?? (f.default as string | undefined) ?? "");
|
||||||
|
const missing = cur && !usbPrinters.some((u) => u.path === cur);
|
||||||
|
return [
|
||||||
|
...(missing ? [{ path: cur, description: t("setup.usbSavedMissing") }] : []),
|
||||||
|
...usbPrinters,
|
||||||
|
].map((u) => (
|
||||||
|
<option key={u.path} value={u.path}>
|
||||||
|
{u.description ? `${u.path} — ${u.description}` : u.path}
|
||||||
|
</option>
|
||||||
|
));
|
||||||
|
})()}
|
||||||
|
</select>
|
||||||
|
) : f.type === "select" ? (
|
||||||
<select
|
<select
|
||||||
className="select"
|
className="select"
|
||||||
value={String(config[f.key] ?? (f.default as string | number | undefined) ?? "")}
|
value={String(config[f.key] ?? (f.default as string | number | undefined) ?? "")}
|
||||||
@@ -927,6 +993,9 @@ function DeviceForm({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{f.key === "devicePath" && usbPrinters != null && usbPrinters.length === 0 && (
|
||||||
|
<p className="hint mt-1">{t("setup.usbNoneFound")}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
@@ -959,8 +1028,9 @@ function DeviceForm({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* BOUND device: which controller + relay it sits at. */}
|
{/* BOUND device: which controller + relay it sits at. Not printers —
|
||||||
{!isController && (
|
nothing consumes a printer binding (role+rank routes print jobs). */}
|
||||||
|
{!isController && !isPrinter && (
|
||||||
<BindingPicker
|
<BindingPicker
|
||||||
controllers={controllers}
|
controllers={controllers}
|
||||||
controllerId={controllerId}
|
controllerId={controllerId}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
type ShiftSummary,
|
type ShiftSummary,
|
||||||
type SessionUser,
|
type SessionUser,
|
||||||
} from "./api.js";
|
} from "./api.js";
|
||||||
import { formatMoney, formatDuration, formatRelativeDateTime } from "./lib/format.js";
|
import { formatMoney, formatDuration, formatDateTime, formatRelativeDateTime } from "./lib/format.js";
|
||||||
import { CARD_PAYMENTS_ENABLED } from "./lib/features.js";
|
import { CARD_PAYMENTS_ENABLED } from "./lib/features.js";
|
||||||
import { Modal } from "./ui/Modal.js";
|
import { Modal } from "./ui/Modal.js";
|
||||||
import { Spinner } from "./ui/Spinner.js";
|
import { 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>
|
<p className="text-[0.75rem] text-term-muted">{t("common.loading")}</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-[0.8125rem] tabular-nums">
|
<div className="text-[0.8125rem] tabular-nums">
|
||||||
<div className="text-term-muted">{t("shift.asOf")} {new Date(x.asOf).toLocaleString()}</div>
|
<div className="text-term-muted">{t("shift.asOf")} {formatDateTime(x.asOf, t)}</div>
|
||||||
<div className="mt-1 grid grid-cols-2 gap-x-6 gap-y-0.5">
|
<div className="mt-1 grid grid-cols-2 gap-x-6 gap-y-0.5">
|
||||||
<Figure label={t("shift.payments")} value={String(x.paymentCount)} />
|
<Figure label={t("shift.payments")} value={String(x.paymentCount)} />
|
||||||
<span />
|
<span />
|
||||||
|
|||||||
@@ -1,6 +1,16 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { fetchOccupancy, fetchSiteConfig, saveSiteConfig, type Occupancy, type SiteConfig } from "./api.js";
|
import {
|
||||||
|
fetchOccupancy,
|
||||||
|
fetchSiteConfig,
|
||||||
|
fetchValidationPrograms,
|
||||||
|
saveSiteConfig,
|
||||||
|
saveValidationProgram,
|
||||||
|
type Occupancy,
|
||||||
|
type SiteConfig,
|
||||||
|
type ValidationProgramView,
|
||||||
|
} from "./api.js";
|
||||||
|
import { STATIONS, ValidationStationsPanel, defaultProgram, type StationId } from "./ValidationSetup.js";
|
||||||
|
|
||||||
// Live occupancy + capacity + park metadata. Occupancy is shown to everyone (it's a
|
// Live occupancy + capacity + park metadata. Occupancy is shown to everyone (it's a
|
||||||
// fold over the signed ledger); capacity and the metadata fields are admin-editable.
|
// fold over the signed ledger); capacity and the metadata fields are admin-editable.
|
||||||
@@ -28,12 +38,21 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
|
|||||||
const [reserveSubs, setReserveSubs] = useState(false);
|
const [reserveSubs, setReserveSubs] = useState(false);
|
||||||
const [anprEntry, setAnprEntry] = useState(true);
|
const [anprEntry, setAnprEntry] = useState(true);
|
||||||
const [msg, setMsg] = useState<string | null>(null);
|
const [msg, setMsg] = useState<string | null>(null);
|
||||||
|
// Merchant-validation programs (bar / lavazh). The checkboxes below toggle a
|
||||||
|
// station's `active` (persisted at once — each flip signs a config_change); the
|
||||||
|
// right-column panel edits the enabled stations. See validation-discounts.md.
|
||||||
|
const [programs, setPrograms] = useState<ValidationProgramView[]>([]);
|
||||||
|
|
||||||
function reload() {
|
function reload() {
|
||||||
fetchOccupancy().then(setOcc).catch(() => {});
|
fetchOccupancy().then(setOcc).catch(() => {});
|
||||||
}
|
}
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
reload();
|
reload();
|
||||||
|
if (canEdit) {
|
||||||
|
fetchValidationPrograms()
|
||||||
|
.then((r) => setPrograms(r.programs))
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
fetchSiteConfig()
|
fetchSiteConfig()
|
||||||
.then((c) => {
|
.then((c) => {
|
||||||
setCapInput(c.capacity == null ? "" : String(c.capacity));
|
setCapInput(c.capacity == null ? "" : String(c.capacity));
|
||||||
@@ -45,7 +64,23 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
|
|||||||
setMeta(m);
|
setMeta(m);
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}, []);
|
}, [canEdit]);
|
||||||
|
|
||||||
|
/** Flip a merchant station's checkbox: persist `active` at once (a signed
|
||||||
|
* config_change server-side), creating the well-known row with comp defaults on
|
||||||
|
* the first enable. Config details are edited in the right-column panel. */
|
||||||
|
async function toggleStation(id: StationId, active: boolean) {
|
||||||
|
const existing = programs.find((p) => p.id === id);
|
||||||
|
const body = existing
|
||||||
|
? { ...existing, active }
|
||||||
|
: { ...defaultProgram(id, t(id === "bar" ? "val.enableBar" : "val.enableLavazh")), active };
|
||||||
|
try {
|
||||||
|
const saved = await saveValidationProgram(id, body);
|
||||||
|
setPrograms((ps) => [...ps.filter((p) => p.id !== id), saved]);
|
||||||
|
} catch (e) {
|
||||||
|
setMsg((e as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
setMsg(null);
|
setMsg(null);
|
||||||
@@ -68,7 +103,8 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="card mt-6 max-w-md p-4">
|
<div className="mt-6 flex flex-wrap items-start gap-6">
|
||||||
|
<section className="card w-full max-w-md p-4">
|
||||||
<div className="flex flex-wrap items-center gap-1.5 text-[0.8125rem]">
|
<div className="flex flex-wrap items-center gap-1.5 text-[0.8125rem]">
|
||||||
<strong className="uppercase tracking-wider text-term-muted">{t("site.occupancy")}</strong>
|
<strong className="uppercase tracking-wider text-term-muted">{t("site.occupancy")}</strong>
|
||||||
{occ == null ? (
|
{occ == null ? (
|
||||||
@@ -127,6 +163,23 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
|
|||||||
<span className="hint block">{t("site.anprEntryHint")}</span>
|
<span className="hint block">{t("site.anprEntryHint")}</span>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
<div className="border-t border-term-border pt-3 text-[0.6875rem] uppercase tracking-wider text-term-muted">
|
||||||
|
{t("val.sectionTitle")}
|
||||||
|
</div>
|
||||||
|
<span className="hint -mt-2">{t("val.sectionHint")}</span>
|
||||||
|
<div className="flex gap-6">
|
||||||
|
{STATIONS.map((id) => (
|
||||||
|
<label key={id} className="flex items-center gap-2 text-[0.75rem] text-term-text">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="accent-term-amber"
|
||||||
|
checked={programs.find((p) => p.id === id)?.active ?? false}
|
||||||
|
onChange={(e) => toggleStation(id, e.target.checked)}
|
||||||
|
/>
|
||||||
|
{t(id === "bar" ? "val.enableBar" : "val.enableLavazh")}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
<div className="border-t border-term-border pt-3 text-[0.6875rem] uppercase tracking-wider text-term-muted">
|
<div className="border-t border-term-border pt-3 text-[0.6875rem] uppercase tracking-wider text-term-muted">
|
||||||
{t("site.parkDetails")}
|
{t("site.parkDetails")}
|
||||||
</div>
|
</div>
|
||||||
@@ -158,5 +211,12 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
{canEdit && (
|
||||||
|
<ValidationStationsPanel
|
||||||
|
programs={programs}
|
||||||
|
onSaved={(p) => setPrograms((ps) => [...ps.filter((x) => x.id !== p.id), p])}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
} from "./api.js";
|
} from "./api.js";
|
||||||
import { CARD_PAYMENTS_ENABLED } from "./lib/features.js";
|
import { CARD_PAYMENTS_ENABLED } from "./lib/features.js";
|
||||||
import { Modal } from "./ui/Modal.js";
|
import { Modal } from "./ui/Modal.js";
|
||||||
|
import { formatDateTime, type TFn } from "./lib/format.js";
|
||||||
|
|
||||||
// Subscription admin. Create/edit/revoke/delete subscriptions + their credentials
|
// Subscription admin. Create/edit/revoke/delete subscriptions + their credentials
|
||||||
// (card/QR) and bound plates. A SALE is priced by selecting an admin-defined PLAN over
|
// (card/QR) and bound plates. A SALE is priced by selecting an admin-defined PLAN over
|
||||||
@@ -179,9 +180,9 @@ function daysLabel(days: number[] | undefined, t: (k: string) => string): string
|
|||||||
|
|
||||||
/** A one-line label for a plan VERSION in the correction picker: effective date + its
|
/** A one-line label for a plan VERSION in the correction picker: effective date + its
|
||||||
* timeframe summary (or "24/7" when the version has no window). */
|
* timeframe summary (or "24/7" when the version has no window). */
|
||||||
function versionLabel(v: SubscriptionPlan, t: (k: string) => string): string {
|
function versionLabel(v: SubscriptionPlan, t: TFn): string {
|
||||||
const eff = new Date(v.effectiveFrom);
|
const eff = new Date(v.effectiveFrom);
|
||||||
const date = Number.isNaN(eff.getTime()) ? v.effectiveFrom : eff.toLocaleString();
|
const date = Number.isNaN(eff.getTime()) ? v.effectiveFrom : formatDateTime(v.effectiveFrom, t);
|
||||||
const tf = v.timeframes;
|
const tf = v.timeframes;
|
||||||
const rules = tf ? `${daysLabel(tf.days, t)} ${hhmm(tf.fromMin)}–${hhmm(tf.toMin)}` : t("subs.allDay");
|
const rules = tf ? `${daysLabel(tf.days, t)} ${hhmm(tf.fromMin)}–${hhmm(tf.toMin)}` : t("subs.allDay");
|
||||||
return `${date} · ${rules}`;
|
return `${date} · ${rules}`;
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
type SubscriptionPlan,
|
type SubscriptionPlan,
|
||||||
} from "./api.js";
|
} from "./api.js";
|
||||||
import { Modal } from "./ui/Modal.js";
|
import { Modal } from "./ui/Modal.js";
|
||||||
|
import { formatDate } from "./lib/format.js";
|
||||||
import { currencyOptions } from "./lib/currencies.js";
|
import { currencyOptions } from "./lib/currencies.js";
|
||||||
|
|
||||||
// Admin-only subscription PLAN catalog. Plans are admin-composed, versioned config the
|
// Admin-only subscription PLAN catalog. Plans are admin-composed, versioned config the
|
||||||
@@ -277,7 +278,7 @@ export function SubscriptionPlansManager() {
|
|||||||
{(p.pricePerPeriodMinor / 100).toLocaleString()} {p.currency} / {t(PERIOD_KEY[p.period])}
|
{(p.pricePerPeriodMinor / 100).toLocaleString()} {p.currency} / {t(PERIOD_KEY[p.period])}
|
||||||
</span>
|
</span>
|
||||||
<span>{timeframesSummary(p.timeframes, t)}</span>
|
<span>{timeframesSummary(p.timeframes, t)}</span>
|
||||||
<span>{t("plans.colEffective")}: {new Date(p.effectiveFrom).toLocaleDateString()}</span>
|
<span>{t("plans.colEffective")}: {formatDate(p.effectiveFrom, t)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Used by */}
|
{/* Used by */}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ApiError, fetchTariff, publishTariffVersion, type TariffState, type TariffVersion } from "./api.js";
|
import { ApiError, fetchTariff, publishTariffVersion, type TariffState, type TariffVersion } from "./api.js";
|
||||||
import { TariffEditorForm, emptyForm, formFromActive, formFromVersion, toStructure, type FormState } from "./TariffEditorForm.js";
|
import { TariffEditorForm, emptyForm, formFromActive, formFromVersion, toStructure, type FormState } from "./TariffEditorForm.js";
|
||||||
|
import { formatDateTime } from "./lib/format.js";
|
||||||
|
|
||||||
// Tariff composer — the admin edits + publishes the LIVE rate card. Publishing
|
// Tariff composer — the admin edits + publishes the LIVE rate card. Publishing
|
||||||
// creates a new IMMUTABLE version (the active card); old versions are kept so past
|
// creates a new IMMUTABLE version (the active card); old versions are kept so past
|
||||||
@@ -77,7 +78,7 @@ export function TariffComposer() {
|
|||||||
<p className="mb-4 text-[0.75rem] text-term-muted">
|
<p className="mb-4 text-[0.75rem] text-term-muted">
|
||||||
{state.active.name ? `${state.active.name} — ` : ""}
|
{state.active.name ? `${state.active.name} — ` : ""}
|
||||||
{t("tariff.activeSince", {
|
{t("tariff.activeSince", {
|
||||||
date: new Date(state.active.effectiveFrom).toLocaleString(),
|
date: formatDateTime(state.active.effectiveFrom, t),
|
||||||
count: state.versions.length,
|
count: state.versions.length,
|
||||||
})}
|
})}
|
||||||
</p>
|
</p>
|
||||||
@@ -126,7 +127,7 @@ export function TariffComposer() {
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="flex items-center gap-2 font-semibold">
|
<span className="flex items-center gap-2 font-semibold">
|
||||||
{v.name ?? new Date(v.effectiveFrom).toLocaleString()}
|
{v.name ?? formatDateTime(v.effectiveFrom, t)}
|
||||||
{isActive && (
|
{isActive && (
|
||||||
<span className="rounded border border-term-green px-1 text-[0.625rem] uppercase text-term-green">
|
<span className="rounded border border-term-green px-1 text-[0.625rem] uppercase text-term-green">
|
||||||
{t("tariff.activeBadge")}
|
{t("tariff.activeBadge")}
|
||||||
@@ -134,7 +135,7 @@ export function TariffComposer() {
|
|||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span className="block text-[0.6875rem] text-term-muted">
|
<span className="block text-[0.6875rem] text-term-muted">
|
||||||
{v.name ? `${new Date(v.effectiveFrom).toLocaleString()} · ` : ""}
|
{v.name ? `${formatDateTime(v.effectiveFrom, t)} · ` : ""}
|
||||||
{v.currency}
|
{v.currency}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -70,23 +70,35 @@ export interface FormState {
|
|||||||
const toMinor = (major: string): number => Math.round(parseFloat(major || "0") * 100);
|
const toMinor = (major: string): number => Math.round(parseFloat(major || "0") * 100);
|
||||||
const toMajor = (minor: number): string => (minor / 100).toFixed(2);
|
const toMajor = (minor: number): string => (minor / 100).toFixed(2);
|
||||||
|
|
||||||
function emptySteps(): StepForm[] {
|
/** Currency-plausible EXAMPLE amounts for fresh forms/rows. The old hardcoded
|
||||||
|
* "2.00 / 1.00" examples were euro-scaled — displayed under ALL they read as
|
||||||
|
* 2 lekë/hour, i.e. nonsense (operator feedback 2026-07-06). Lek amounts are
|
||||||
|
* ~100× the euro ones; USD rides with EUR. */
|
||||||
|
function examples(currency: string): { hi: string; lo: string; stepSmall: string; stepBig: string; lost: string } {
|
||||||
|
return currency.trim().toUpperCase() === "ALL"
|
||||||
|
? { hi: "200.00", lo: "100.00", stepSmall: "200.00", stepBig: "500.00", lost: "2000.00" }
|
||||||
|
: { hi: "2.00", lo: "1.00", stepSmall: "2.00", stepBig: "5.00", lost: "20.00" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptySteps(currency: string): StepForm[] {
|
||||||
|
const ex = examples(currency);
|
||||||
return [
|
return [
|
||||||
{ hours: "1", total: "2.00" },
|
{ hours: "1", total: ex.stepSmall },
|
||||||
{ hours: "3", total: "5.00" },
|
{ hours: "3", total: ex.stepBig },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
function emptyLadder(): PricingForm {
|
function emptyLadder(currency: string): PricingForm {
|
||||||
|
const ex = examples(currency);
|
||||||
return {
|
return {
|
||||||
mode: "ladder",
|
mode: "ladder",
|
||||||
flat: "0.00",
|
flat: "0.00",
|
||||||
packageTotal: "0.00",
|
packageTotal: "0.00",
|
||||||
dailyCap: "",
|
dailyCap: "",
|
||||||
blocks: [{ hours: "1", price: "2.00" }, { hours: "", price: "1.00" }],
|
blocks: [{ hours: "1", price: ex.hi }, { hours: "", price: ex.lo }],
|
||||||
steps: emptySteps(),
|
steps: emptySteps(currency),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
function emptyTier(): TierForm {
|
function emptyTier(currency: string): TierForm {
|
||||||
return {
|
return {
|
||||||
name: "",
|
name: "",
|
||||||
priority: "10",
|
priority: "10",
|
||||||
@@ -96,18 +108,19 @@ function emptyTier(): TierForm {
|
|||||||
toHour: "",
|
toHour: "",
|
||||||
dateFrom: "",
|
dateFrom: "",
|
||||||
dateTo: "",
|
dateTo: "",
|
||||||
pricing: { ...emptyLadder(), blocks: [{ hours: "", price: "1.00" }] },
|
pricing: { ...emptyLadder(currency), blocks: [{ hours: "", price: examples(currency).lo }] },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function emptyForm(): FormState {
|
export function emptyForm(): FormState {
|
||||||
|
const currency = "ALL"; // the site's currency — examples scale with it
|
||||||
return {
|
return {
|
||||||
currency: "ALL",
|
currency,
|
||||||
gracePeriodEntryMin: "15",
|
gracePeriodEntryMin: "15",
|
||||||
incrementMin: "60",
|
incrementMin: "60",
|
||||||
lostTicket: "20.00",
|
lostTicket: examples(currency).lost,
|
||||||
gracePeriodExitMin: "15",
|
gracePeriodExitMin: "15",
|
||||||
base: emptyLadder(),
|
base: emptyLadder(currency),
|
||||||
tiers: [],
|
tiers: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -132,31 +145,34 @@ function stepsToForm(steps: TariffStep[]): StepForm[] {
|
|||||||
|
|
||||||
// A stored card (V2) or bare-V1 body → the form's PricingForm (flat, ladder, stepped,
|
// A stored card (V2) or bare-V1 body → the form's PricingForm (flat, ladder, stepped,
|
||||||
// or window package).
|
// or window package).
|
||||||
function pricingFromCard(c: {
|
function pricingFromCard(
|
||||||
|
c: {
|
||||||
flatMinor?: number;
|
flatMinor?: number;
|
||||||
blocks?: TariffBlock[];
|
blocks?: TariffBlock[];
|
||||||
steps?: TariffStep[];
|
steps?: TariffStep[];
|
||||||
packageMinor?: number;
|
packageMinor?: number;
|
||||||
dailyCapMinor?: number | null;
|
dailyCapMinor?: number | null;
|
||||||
}): PricingForm {
|
},
|
||||||
|
currency: string,
|
||||||
|
): PricingForm {
|
||||||
if (c.steps != null && c.steps.length > 0) {
|
if (c.steps != null && c.steps.length > 0) {
|
||||||
return { ...emptyLadder(), mode: "stepped", steps: stepsToForm(c.steps) };
|
return { ...emptyLadder(currency), mode: "stepped", steps: stepsToForm(c.steps) };
|
||||||
}
|
}
|
||||||
if (c.packageMinor != null) {
|
if (c.packageMinor != null) {
|
||||||
return { ...emptyLadder(), mode: "package", packageTotal: toMajor(c.packageMinor) };
|
return { ...emptyLadder(currency), mode: "package", packageTotal: toMajor(c.packageMinor) };
|
||||||
}
|
}
|
||||||
if (c.flatMinor != null) {
|
if (c.flatMinor != null) {
|
||||||
return { ...emptyLadder(), mode: "flat", flat: toMajor(c.flatMinor) };
|
return { ...emptyLadder(currency), mode: "flat", flat: toMajor(c.flatMinor) };
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
...emptyLadder(),
|
...emptyLadder(currency),
|
||||||
mode: "ladder",
|
mode: "ladder",
|
||||||
dailyCap: c.dailyCapMinor == null ? "" : toMajor(c.dailyCapMinor),
|
dailyCap: c.dailyCapMinor == null ? "" : toMajor(c.dailyCapMinor),
|
||||||
blocks: blocksToForm(c.blocks ?? []),
|
blocks: blocksToForm(c.blocks ?? []),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function tierFromCard(c: TariffCard): TierForm {
|
function tierFromCard(c: TariffCard, currency: string): TierForm {
|
||||||
const w = c.window ?? {};
|
const w = c.window ?? {};
|
||||||
return {
|
return {
|
||||||
name: c.name,
|
name: c.name,
|
||||||
@@ -167,7 +183,7 @@ function tierFromCard(c: TariffCard): TierForm {
|
|||||||
toHour: w.toHour ?? "",
|
toHour: w.toHour ?? "",
|
||||||
dateFrom: w.dateFrom ?? "",
|
dateFrom: w.dateFrom ?? "",
|
||||||
dateTo: w.dateTo ?? "",
|
dateTo: w.dateTo ?? "",
|
||||||
pricing: pricingFromCard(c),
|
pricing: pricingFromCard(c, currency),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,10 +198,14 @@ export function formFromVersion(currency: string, st: TariffStructure): FormStat
|
|||||||
gracePeriodExitMin: String(st.gracePeriodExitMin),
|
gracePeriodExitMin: String(st.gracePeriodExitMin),
|
||||||
};
|
};
|
||||||
if (isTariffV2(st)) {
|
if (isTariffV2(st)) {
|
||||||
return { ...common, base: pricingFromCard(st.defaultCard), tiers: (st.windowedCards ?? []).map(tierFromCard) };
|
return {
|
||||||
|
...common,
|
||||||
|
base: pricingFromCard(st.defaultCard, currency),
|
||||||
|
tiers: (st.windowedCards ?? []).map((c) => tierFromCard(c, currency)),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
// V1: the bare ladder becomes the default card body; no tiers.
|
// V1: the bare ladder becomes the default card body; no tiers.
|
||||||
return { ...common, base: pricingFromCard(st), tiers: [] };
|
return { ...common, base: pricingFromCard(st, currency), tiers: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formFromActive(s: TariffState): FormState {
|
export function formFromActive(s: TariffState): FormState {
|
||||||
@@ -278,6 +298,8 @@ export function TariffEditorForm({
|
|||||||
onChange: (update: (f: FormState) => FormState) => void;
|
onChange: (update: (f: FormState) => FormState) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
// The billing unit all flat/ladder prices are entered in (labels reflect it live).
|
||||||
|
const inc = Math.max(1, Math.round(Number(form.incrementMin)) || 60);
|
||||||
|
|
||||||
function set<K extends keyof FormState>(key: K, value: FormState[K]) {
|
function set<K extends keyof FormState>(key: K, value: FormState[K]) {
|
||||||
onChange((f) => ({ ...f, [key]: value }));
|
onChange((f) => ({ ...f, [key]: value }));
|
||||||
@@ -322,7 +344,7 @@ export function TariffEditorForm({
|
|||||||
onChange((f) => ({ ...f, tiers: f.tiers.map((tr, j) => (j === i ? { ...tr, ...patch } : tr)) }));
|
onChange((f) => ({ ...f, tiers: f.tiers.map((tr, j) => (j === i ? { ...tr, ...patch } : tr)) }));
|
||||||
}
|
}
|
||||||
function addTier() {
|
function addTier() {
|
||||||
onChange((f) => ({ ...f, tiers: [...f.tiers, emptyTier()] }));
|
onChange((f) => ({ ...f, tiers: [...f.tiers, emptyTier(f.currency)] }));
|
||||||
}
|
}
|
||||||
function removeTier(i: number) {
|
function removeTier(i: number) {
|
||||||
onChange((f) => ({ ...f, tiers: f.tiers.filter((_, j) => j !== i) }));
|
onChange((f) => ({ ...f, tiers: f.tiers.filter((_, j) => j !== i) }));
|
||||||
@@ -355,6 +377,15 @@ export function TariffEditorForm({
|
|||||||
<input className="input w-32" value={form.gracePeriodExitMin} onChange={(e) => set("gracePeriodExitMin", e.target.value)} />
|
<input className="input w-32" value={form.gracePeriodExitMin} onChange={(e) => set("gracePeriodExitMin", e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* The increment is the UNIT every flat/ladder price is charged in. At 60 the
|
||||||
|
form reads naturally as per-hour; any other value silently redefines every
|
||||||
|
price below, so shout it (the 60→10 "six charges per hour" trap). */}
|
||||||
|
{inc !== 60 && (
|
||||||
|
<p className="mt-2 rounded-term border border-term-amber/50 bg-term-amber/10 px-3 py-2 text-[0.75rem] text-term-amber">
|
||||||
|
{t("tariff.incrementWarning", { min: inc })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* The DEFAULT card — always-active rate. Front-and-centre; a site that never
|
{/* The DEFAULT card — always-active rate. Front-and-centre; a site that never
|
||||||
wants tiers just edits this and publishes a bare V1 structure. */}
|
wants tiers just edits this and publishes a bare V1 structure. */}
|
||||||
<h3 className="mt-6 mb-0.5 text-h6 font-semibold uppercase tracking-wider text-term-text">{t("tariff.defaultCard")}</h3>
|
<h3 className="mt-6 mb-0.5 text-h6 font-semibold uppercase tracking-wider text-term-text">{t("tariff.defaultCard")}</h3>
|
||||||
@@ -363,6 +394,7 @@ export function TariffEditorForm({
|
|||||||
<PricingEditor
|
<PricingEditor
|
||||||
t={t}
|
t={t}
|
||||||
pricing={form.base}
|
pricing={form.base}
|
||||||
|
incrementMin={inc}
|
||||||
allowStepped
|
allowStepped
|
||||||
onMode={(mode) => updatePricing("base", (p) => ({ ...p, mode }))}
|
onMode={(mode) => updatePricing("base", (p) => ({ ...p, mode }))}
|
||||||
onFlat={(flat) => updatePricing("base", (p) => ({ ...p, flat }))}
|
onFlat={(flat) => updatePricing("base", (p) => ({ ...p, flat }))}
|
||||||
@@ -434,6 +466,7 @@ export function TariffEditorForm({
|
|||||||
<PricingEditor
|
<PricingEditor
|
||||||
t={t}
|
t={t}
|
||||||
pricing={tr.pricing}
|
pricing={tr.pricing}
|
||||||
|
incrementMin={inc}
|
||||||
allowPackage
|
allowPackage
|
||||||
onMode={(mode) => updatePricing(i, (p) => ({ ...p, mode }))}
|
onMode={(mode) => updatePricing(i, (p) => ({ ...p, mode }))}
|
||||||
onFlat={(flat) => updatePricing(i, (p) => ({ ...p, flat }))}
|
onFlat={(flat) => updatePricing(i, (p) => ({ ...p, flat }))}
|
||||||
@@ -459,8 +492,11 @@ export function TariffEditorForm({
|
|||||||
// (the default card); the package mode only where `allowPackage` (tier cards — the
|
// (the default card); the package mode only where `allowPackage` (tier cards — the
|
||||||
// engine needs a window to be an occurrence of).
|
// engine needs a window to be an occurrence of).
|
||||||
function PricingEditor(props: {
|
function PricingEditor(props: {
|
||||||
t: (k: string) => string;
|
t: (k: string, opts?: Record<string, unknown>) => string;
|
||||||
pricing: PricingForm;
|
pricing: PricingForm;
|
||||||
|
/** Current billing increment (minutes) — every flat/ladder price is PER this unit,
|
||||||
|
* so the price labels state it explicitly instead of a vague "per increment". */
|
||||||
|
incrementMin: number;
|
||||||
allowStepped?: boolean;
|
allowStepped?: boolean;
|
||||||
allowPackage?: boolean;
|
allowPackage?: boolean;
|
||||||
onMode: (m: "ladder" | "flat" | "stepped" | "package") => void;
|
onMode: (m: "ladder" | "flat" | "stepped" | "package") => void;
|
||||||
@@ -475,6 +511,18 @@ function PricingEditor(props: {
|
|||||||
onRemoveStep?: (i: number) => void;
|
onRemoveStep?: (i: number) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t, pricing: p } = props;
|
const { t, pricing: p } = props;
|
||||||
|
/** "= N / orë" equivalence for a per-increment price (only shown when the tick
|
||||||
|
* isn't an hour — at 60 the price already IS the hourly price). */
|
||||||
|
const perHour = (major: string): string | null => {
|
||||||
|
if (props.incrementMin === 60) return null;
|
||||||
|
const v = Number(major);
|
||||||
|
if (!Number.isFinite(v) || v <= 0) return null;
|
||||||
|
return t("tariff.perHourEquiv", { amount: ((v * 60) / props.incrementMin).toFixed(2) });
|
||||||
|
};
|
||||||
|
const unitLabel =
|
||||||
|
props.incrementMin === 60 ? t("tariff.pricePerHour") : t("tariff.pricePerN", { min: props.incrementMin });
|
||||||
|
const flatLabel =
|
||||||
|
props.incrementMin === 60 ? t("tariff.modeFlat") : t("tariff.modeFlatN", { min: props.incrementMin });
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-3 flex flex-wrap gap-4 text-[0.75rem]">
|
<div className="mb-3 flex flex-wrap gap-4 text-[0.75rem]">
|
||||||
@@ -484,7 +532,7 @@ function PricingEditor(props: {
|
|||||||
</label>
|
</label>
|
||||||
<label className="inline-flex items-center gap-1.5 text-term-text">
|
<label className="inline-flex items-center gap-1.5 text-term-text">
|
||||||
<input type="radio" className="accent-term-amber" checked={p.mode === "flat"} onChange={() => props.onMode("flat")} />
|
<input type="radio" className="accent-term-amber" checked={p.mode === "flat"} onChange={() => props.onMode("flat")} />
|
||||||
{t("tariff.modeFlat")}
|
{flatLabel}
|
||||||
</label>
|
</label>
|
||||||
{props.allowStepped && (
|
{props.allowStepped && (
|
||||||
<label className="inline-flex items-center gap-1.5 text-term-text">
|
<label className="inline-flex items-center gap-1.5 text-term-text">
|
||||||
@@ -550,8 +598,9 @@ function PricingEditor(props: {
|
|||||||
</>
|
</>
|
||||||
) : p.mode === "flat" ? (
|
) : p.mode === "flat" ? (
|
||||||
<div className="inline-flex items-center gap-2">
|
<div className="inline-flex items-center gap-2">
|
||||||
<span className="label">{t("tariff.pricePerIncrement")}</span>
|
<span className="label">{unitLabel}</span>
|
||||||
<input className="input w-28" value={p.flat} onChange={(e) => props.onFlat(e.target.value)} />
|
<input className="input w-28" value={p.flat} onChange={(e) => props.onFlat(e.target.value)} />
|
||||||
|
{perHour(p.flat) && <span className="text-[0.6875rem] text-term-muted">{perHour(p.flat)}</span>}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -559,7 +608,7 @@ function PricingEditor(props: {
|
|||||||
<thead>
|
<thead>
|
||||||
<tr className="text-left">
|
<tr className="text-left">
|
||||||
<th className="label px-2 pb-1 font-normal">{t("tariff.bandDuration")}</th>
|
<th className="label px-2 pb-1 font-normal">{t("tariff.bandDuration")}</th>
|
||||||
<th className="label px-2 pb-1 font-normal">{t("tariff.pricePerIncrement")}</th>
|
<th className="label px-2 pb-1 font-normal">{unitLabel}</th>
|
||||||
<th />
|
<th />
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -579,7 +628,10 @@ function PricingEditor(props: {
|
|||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-2 py-1">
|
<td className="px-2 py-1">
|
||||||
|
<span className="inline-flex items-center gap-2">
|
||||||
<input className="input w-28" value={b.price} onChange={(e) => props.onBlock(i, { price: e.target.value })} />
|
<input className="input w-28" value={b.price} onChange={(e) => props.onBlock(i, { price: e.target.value })} />
|
||||||
|
{perHour(b.price) && <span className="text-[0.6875rem] text-term-muted">{perHour(b.price)}</span>}
|
||||||
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-2">
|
<td className="px-2">
|
||||||
{!isTail && (
|
{!isTail && (
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ import {
|
|||||||
} from "./api.js";
|
} from "./api.js";
|
||||||
import { TariffEditorForm, emptyForm, formFromActive, formFromVersion, toStructure, type FormState } from "./TariffEditorForm.js";
|
import { TariffEditorForm, emptyForm, formFromActive, formFromVersion, toStructure, type FormState } from "./TariffEditorForm.js";
|
||||||
import { Modal } from "./ui/Modal.js";
|
import { Modal } from "./ui/Modal.js";
|
||||||
import { formatMoney, formatDuration } from "./lib/format.js";
|
import { formatClock, formatDateTime, formatMoney, formatDuration } from "./lib/format.js";
|
||||||
|
import type { FeeBreakdown } from "@parking/shared";
|
||||||
|
import type { TFunction } from "i18next";
|
||||||
|
|
||||||
// The TARIFF LAB — a sandbox for composing + pricing EXPERIMENTAL rate cards. Drafts
|
// The TARIFF LAB — a sandbox for composing + pricing EXPERIMENTAL rate cards. Drafts
|
||||||
// live in their own mutable table (tariff_drafts), so experimenting never churns the
|
// live in their own mutable table (tariff_drafts), so experimenting never churns the
|
||||||
@@ -202,7 +204,7 @@ export function TariffLab() {
|
|||||||
{selectedDraft
|
{selectedDraft
|
||||||
? selectedDraft.name
|
? selectedDraft.name
|
||||||
: selectedVersion
|
: selectedVersion
|
||||||
? selectedVersion.name ?? new Date(selectedVersion.effectiveFrom).toLocaleString()
|
? selectedVersion.name ?? formatDateTime(selectedVersion.effectiveFrom, t)
|
||||||
: t("lab.activeTariff")}
|
: t("lab.activeTariff")}
|
||||||
</span>
|
</span>
|
||||||
{selectedDraft && (
|
{selectedDraft && (
|
||||||
@@ -264,14 +266,19 @@ export function TariffLab() {
|
|||||||
)}
|
)}
|
||||||
</dd>
|
</dd>
|
||||||
<dt className="text-term-muted">{t("lab.periodStart")}</dt>
|
<dt className="text-term-muted">{t("lab.periodStart")}</dt>
|
||||||
<dd className="text-term-text">{new Date(result.pricing.periodStart).toLocaleString()}</dd>
|
<dd className="text-term-text">{formatDateTime(result.pricing.periodStart, t)}</dd>
|
||||||
{result.pricing.graceExpiresAt && (
|
{result.pricing.graceExpiresAt && (
|
||||||
<>
|
<>
|
||||||
<dt className="text-term-muted">{t("lab.graceExpires")}</dt>
|
<dt className="text-term-muted">{t("lab.graceExpires")}</dt>
|
||||||
<dd className="text-term-text">{new Date(result.pricing.graceExpiresAt).toLocaleString()}</dd>
|
<dd className="text-term-text">{formatDateTime(result.pricing.graceExpiresAt, t)}</dd>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</dl>
|
</dl>
|
||||||
|
{/* HOW the sum is produced — line items from the SAME engine walk
|
||||||
|
(their sum is the amount by construction). */}
|
||||||
|
{result.breakdown && (
|
||||||
|
<BreakdownTable b={result.breakdown} periodStart={result.pricing.periodStart} currency={currency} t={t} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Duration curve from entry — see where the cap flattens / windows shift. */}
|
{/* Duration curve from entry — see where the cap flattens / windows shift. */}
|
||||||
@@ -316,7 +323,7 @@ export function TariffLab() {
|
|||||||
>
|
>
|
||||||
<span className="block font-semibold">{d.name}</span>
|
<span className="block font-semibold">{d.name}</span>
|
||||||
<span className="block text-[0.6875rem] text-term-muted">
|
<span className="block text-[0.6875rem] text-term-muted">
|
||||||
{d.currency} · {new Date(d.updatedAt).toLocaleString()}
|
{d.currency} · {formatDateTime(d.updatedAt, t)}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
@@ -345,7 +352,7 @@ export function TariffLab() {
|
|||||||
{state?.active?.name ? ` — ${state.active.name}` : ""}
|
{state?.active?.name ? ` — ${state.active.name}` : ""}
|
||||||
</span>
|
</span>
|
||||||
<span className="block text-[0.6875rem] text-term-muted">
|
<span className="block text-[0.6875rem] text-term-muted">
|
||||||
{state?.active ? new Date(state.active.effectiveFrom).toLocaleString() : t("tariff.noRateCard")}
|
{state?.active ? formatDateTime(state.active.effectiveFrom, t) : t("tariff.noRateCard")}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
@@ -363,10 +370,10 @@ export function TariffLab() {
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="block font-semibold">
|
<span className="block font-semibold">
|
||||||
{v.name ?? new Date(v.effectiveFrom).toLocaleString()}
|
{v.name ?? formatDateTime(v.effectiveFrom, t)}
|
||||||
</span>
|
</span>
|
||||||
<span className="block text-[0.6875rem] text-term-muted">
|
<span className="block text-[0.6875rem] text-term-muted">
|
||||||
{v.name ? `${new Date(v.effectiveFrom).toLocaleString()} · ` : ""}
|
{v.name ? `${formatDateTime(v.effectiveFrom, t)} · ` : ""}
|
||||||
{v.currency}
|
{v.currency}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -416,3 +423,82 @@ function labelMin(min: number): string {
|
|||||||
if (min < 1440) return `${min / 60}h`;
|
if (min < 1440) return `${min / 60}h`;
|
||||||
return `${min / 1440}d`;
|
return `${min / 1440}d`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The fee's line items — every row states its time window / rule and its amount, so
|
||||||
|
* the operator can retrace the exact sum (caps show as negative adjustments). */
|
||||||
|
function BreakdownTable({
|
||||||
|
b,
|
||||||
|
periodStart,
|
||||||
|
currency,
|
||||||
|
t,
|
||||||
|
}: {
|
||||||
|
b: FeeBreakdown;
|
||||||
|
periodStart: string;
|
||||||
|
currency: string;
|
||||||
|
t: TFunction;
|
||||||
|
}) {
|
||||||
|
const startMs = Date.parse(periodStart);
|
||||||
|
const multiDay = b.billedMinutes > 1440;
|
||||||
|
const at = (min: number) => {
|
||||||
|
const iso = new Date(startMs + min * 60_000).toISOString();
|
||||||
|
return multiDay ? formatDateTime(iso, t) : formatClock(iso);
|
||||||
|
};
|
||||||
|
const money = (m: number) => formatMoney(m, currency);
|
||||||
|
const hours = (min: number) => (min % 60 === 0 ? `${min / 60}` : (min / 60).toFixed(1));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-3 border-t border-term-border pt-2">
|
||||||
|
<div className="mb-1 text-[0.6875rem] uppercase tracking-wider text-term-muted">{t("lab.bd.title")}</div>
|
||||||
|
{b.billedMinutes > 0 && (
|
||||||
|
<p className="hint mb-1.5">
|
||||||
|
{t("lab.bd.rounding", { raw: b.rawMinutes, billed: b.billedMinutes, inc: b.incrementMin })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<table className="w-full text-[0.75rem] tabular-nums">
|
||||||
|
<tbody>
|
||||||
|
{b.items.map((it, i) => {
|
||||||
|
let label: string;
|
||||||
|
let amount: number;
|
||||||
|
let cls = "text-term-text";
|
||||||
|
switch (it.kind) {
|
||||||
|
case "grace":
|
||||||
|
label = t("lab.bd.grace", { min: it.minutes });
|
||||||
|
amount = 0;
|
||||||
|
cls = "text-term-green";
|
||||||
|
break;
|
||||||
|
case "band":
|
||||||
|
label = `${at(it.fromMin)}–${at(it.toMin)} · ${it.increments} × ${money(it.unitMinor)}${it.card ? ` · ${it.card}` : ""}`;
|
||||||
|
amount = it.amountMinor;
|
||||||
|
break;
|
||||||
|
case "package":
|
||||||
|
label = `${at(it.fromMin)} · ${it.card} — ${t("lab.bd.package")}`;
|
||||||
|
amount = it.amountMinor;
|
||||||
|
break;
|
||||||
|
case "step":
|
||||||
|
label = it.repeated
|
||||||
|
? t("lab.bd.stepRepeated", { day: it.day })
|
||||||
|
: t("lab.bd.step", { day: it.day, hours: hours(it.uptoMin) });
|
||||||
|
amount = it.amountMinor;
|
||||||
|
break;
|
||||||
|
case "cap":
|
||||||
|
label = t("lab.bd.cap", { day: it.day, cap: money(it.capMinor) });
|
||||||
|
amount = it.amountMinor;
|
||||||
|
cls = "text-term-red";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<tr key={i} className="border-b border-term-border/40">
|
||||||
|
<td className="py-0.5 pr-2 text-term-muted">{label}</td>
|
||||||
|
<td className={`whitespace-nowrap py-0.5 text-right ${cls}`}>{money(amount)}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<tr>
|
||||||
|
<td className="py-1 pr-2 font-semibold text-term-text">{t("lab.bd.total")}</td>
|
||||||
|
<td className="whitespace-nowrap py-1 text-right font-semibold text-term-cyan">{money(b.totalMinor)}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,252 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
applyValidation,
|
||||||
|
fetchMyValidationPrograms,
|
||||||
|
fetchValidationSession,
|
||||||
|
voidValidation,
|
||||||
|
type SessionUser,
|
||||||
|
type ValidationProgramView,
|
||||||
|
type ValidationSessionView,
|
||||||
|
} from "./api.js";
|
||||||
|
import { formatDuration, formatMoney, formatRelativeDateTime } from "./lib/format.js";
|
||||||
|
|
||||||
|
// The MERCHANT screen (/validate): the bar/lavazh user's ENTIRE surface. Scan or key
|
||||||
|
// the customer's ticket → see the session (deliberately NO money data — the booth
|
||||||
|
// settles) → apply the bound program → done. Mobile-friendly: a phone/tablet on the
|
||||||
|
// site LAN, or a booth-style USB HID scanner (it types digits + Enter into the
|
||||||
|
// focused input). A mistake can be voided while UNUSED (append-only, signed).
|
||||||
|
// Gated by validation:create + the server-side program↔user binding.
|
||||||
|
// See wiki/concepts/validation-discounts.md.
|
||||||
|
|
||||||
|
type Program = Omit<ValidationProgramView, "userIds">;
|
||||||
|
|
||||||
|
/** Human line for what a program grants (the params live on the program row). */
|
||||||
|
function programSummary(p: Program, t: (k: string, o?: Record<string, unknown>) => string): string {
|
||||||
|
if (p.mode === "comp") return t("val.modeComp");
|
||||||
|
if (p.mode === "timeCredit") return `${t("val.modeTimeCredit")}: ${p.minutes ?? 0} min`;
|
||||||
|
if (p.mode === "percent") return `${t("val.modePercent")}: ${p.percent ?? 0}%`;
|
||||||
|
return t("val.modeFixed");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ValidateScreen({ user }: { user: SessionUser }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [programs, setPrograms] = useState<Program[] | null>(null);
|
||||||
|
const [programId, setProgramId] = useState<string | null>(null);
|
||||||
|
const [ticket, setTicket] = useState("");
|
||||||
|
const [view, setView] = useState<ValidationSessionView | null>(null);
|
||||||
|
const [amount, setAmount] = useState("");
|
||||||
|
const [msg, setMsg] = useState<{ kind: "ok" | "err"; text: string } | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchMyValidationPrograms()
|
||||||
|
.then((r) => {
|
||||||
|
setPrograms(r.programs);
|
||||||
|
if (r.programs.length === 1) setProgramId(r.programs[0]!.id);
|
||||||
|
})
|
||||||
|
.catch(() => setPrograms([]));
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const program = programs?.find((p) => p.id === programId) ?? null;
|
||||||
|
|
||||||
|
async function lookup(id?: string) {
|
||||||
|
const identity = (id ?? ticket).trim();
|
||||||
|
if (!identity) return;
|
||||||
|
setMsg(null);
|
||||||
|
try {
|
||||||
|
setView(await fetchValidationSession(identity));
|
||||||
|
} catch (e) {
|
||||||
|
setMsg({ kind: "err", text: (e as Error).message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apply() {
|
||||||
|
if (!view || !program) return;
|
||||||
|
setBusy(true);
|
||||||
|
setMsg(null);
|
||||||
|
try {
|
||||||
|
const body: { identity: string; programId: string; amountMinor?: number } = {
|
||||||
|
identity: view.identity,
|
||||||
|
programId: program.id,
|
||||||
|
};
|
||||||
|
if (program.mode === "fixed") {
|
||||||
|
const n = Number(amount);
|
||||||
|
body.amountMinor = Number.isFinite(n) ? Math.round(n * 100) : 0;
|
||||||
|
}
|
||||||
|
await applyValidation(body);
|
||||||
|
setMsg({ kind: "ok", text: t("val.applied") });
|
||||||
|
setAmount("");
|
||||||
|
await lookup(view.identity);
|
||||||
|
} catch (e) {
|
||||||
|
setMsg({ kind: "err", text: (e as Error).message });
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function voidOne(eventId: string) {
|
||||||
|
if (!view) return;
|
||||||
|
if (!window.confirm(t("val.confirmVoid"))) return;
|
||||||
|
setMsg(null);
|
||||||
|
try {
|
||||||
|
await voidValidation({ eventId, identity: view.identity });
|
||||||
|
await lookup(view.identity);
|
||||||
|
} catch (e) {
|
||||||
|
setMsg({ kind: "err", text: (e as Error).message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The session's blocking condition, if any (not found / closed / subscriber).
|
||||||
|
const blocked =
|
||||||
|
view == null
|
||||||
|
? null
|
||||||
|
: !view.found
|
||||||
|
? t("val.notFound")
|
||||||
|
: view.subscription
|
||||||
|
? t("val.subscription")
|
||||||
|
: !view.open
|
||||||
|
? t("val.closed")
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const alreadyApplied =
|
||||||
|
view != null &&
|
||||||
|
program != null &&
|
||||||
|
view.validations.some((v) => v.programId === program.id && !v.voided && v.consumedBy == null);
|
||||||
|
|
||||||
|
const fixedAmountOk =
|
||||||
|
program?.mode !== "fixed" ||
|
||||||
|
(Number(amount) > 0 &&
|
||||||
|
(program.maxAmountMinor == null || Math.round(Number(amount) * 100) <= program.maxAmountMinor));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto mt-6 w-full max-w-md">
|
||||||
|
<section className="card p-4">
|
||||||
|
<div className="text-[0.6875rem] uppercase tracking-wider text-term-muted">{t("val.title")}</div>
|
||||||
|
|
||||||
|
{programs != null && programs.length === 0 && (
|
||||||
|
<p className="mt-3 text-[0.8125rem] text-term-red">{t("val.noPrograms")}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{programs != null && programs.length > 1 && (
|
||||||
|
<div className="mt-3 flex gap-1">
|
||||||
|
{programs.map((p) => (
|
||||||
|
<button
|
||||||
|
key={p.id}
|
||||||
|
type="button"
|
||||||
|
className={`btn btn-sm ${p.id === programId ? "btn-primary" : "btn-ghost"}`}
|
||||||
|
onClick={() => setProgramId(p.id)}
|
||||||
|
>
|
||||||
|
{p.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{program && <p className="mt-1 text-[0.75rem] text-term-muted">{program.name} — {programSummary(program, t)}</p>}
|
||||||
|
|
||||||
|
<form
|
||||||
|
className="mt-3 flex gap-2"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
void lookup();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
className="input flex-1 tabular-nums"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={ticket}
|
||||||
|
onChange={(e) => setTicket(e.target.value)}
|
||||||
|
placeholder={t("val.scanPrompt")}
|
||||||
|
/>
|
||||||
|
<button type="submit" className="btn btn-primary btn-sm">{t("val.lookup")}</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{msg && (
|
||||||
|
<p className={`mt-2 text-[0.8125rem] ${msg.kind === "ok" ? "text-term-green" : "text-term-red"}`}>
|
||||||
|
{msg.text}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{view && (
|
||||||
|
<div className="mt-3 border-t border-term-border pt-3">
|
||||||
|
{blocked ? (
|
||||||
|
<p className="text-[0.8125rem] text-term-red">{blocked}</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex items-baseline justify-between text-[0.8125rem]">
|
||||||
|
<span className="font-semibold tabular-nums text-term-text">{view.identity}</span>
|
||||||
|
<span className="text-term-muted">
|
||||||
|
{t("val.entry")} {formatRelativeDateTime(view.enteredAt, t)}
|
||||||
|
{view.enteredAt && <> · {formatDuration(view.enteredAt, new Date().toISOString())}</>}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{program && !alreadyApplied && (
|
||||||
|
<div className="mt-3 grid gap-2">
|
||||||
|
{program.mode === "fixed" && (
|
||||||
|
<div className="field">
|
||||||
|
<span className="label">
|
||||||
|
{t("val.amountLabel")}
|
||||||
|
{program.maxAmountMinor != null && (
|
||||||
|
<span className="hint ml-2">
|
||||||
|
{t("val.amountHint", { max: formatMoney(program.maxAmountMinor, "") })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
className="input w-40 tabular-nums"
|
||||||
|
inputMode="decimal"
|
||||||
|
value={amount}
|
||||||
|
onChange={(e) => setAmount(e.target.value)}
|
||||||
|
placeholder="300"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
disabled={busy || !fixedAmountOk}
|
||||||
|
onClick={apply}
|
||||||
|
>
|
||||||
|
{t("val.apply")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{view.validations.length > 0 && (
|
||||||
|
<div className="mt-3">
|
||||||
|
<div className="label">{t("val.existing")}</div>
|
||||||
|
<ul className="mt-1 grid gap-1">
|
||||||
|
{view.validations.map((v) => (
|
||||||
|
<li key={v.eventId} className="flex items-center gap-2 text-[0.75rem] text-term-text">
|
||||||
|
<span>{v.label}</span>
|
||||||
|
{v.amountMinor != null && <span className="tabular-nums">−{formatMoney(v.amountMinor, "")}</span>}
|
||||||
|
{v.minutes != null && <span>{v.minutes} min</span>}
|
||||||
|
{v.percent != null && <span>{v.percent}%</span>}
|
||||||
|
{v.voided ? (
|
||||||
|
<span className="text-term-muted">({t("val.voided")})</span>
|
||||||
|
) : v.consumedBy != null ? (
|
||||||
|
<span className="text-term-muted">({t("val.used")})</span>
|
||||||
|
) : (
|
||||||
|
v.operator === user.username && (
|
||||||
|
<button type="button" className="btn btn-ghost btn-sm ml-auto" onClick={() => voidOne(v.eventId)}>
|
||||||
|
{t("val.void")}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
fetchUsers,
|
||||||
|
saveValidationProgram,
|
||||||
|
type ManagedUser,
|
||||||
|
type ValidationMode,
|
||||||
|
type ValidationProgramView,
|
||||||
|
} from "./api.js";
|
||||||
|
|
||||||
|
// The /setup/site RIGHT panel: per-station merchant-validation config (Bar / Lavazh).
|
||||||
|
// The checkboxes on the left card toggle a station's `active`; this panel edits the
|
||||||
|
// enabled stations' programs — one panel, tabs when both are on. Storage is generic
|
||||||
|
// (validation_programs rows keyed "bar"/"lavazh"); the UI is deliberately these two
|
||||||
|
// fixed stations. Amounts are entered in MAJOR units and stored in integer minor
|
||||||
|
// units (the tariff-composer convention). See wiki/concepts/validation-discounts.md.
|
||||||
|
|
||||||
|
/** The two well-known stations the checkboxes toggle. */
|
||||||
|
export const STATIONS = ["bar", "lavazh"] as const;
|
||||||
|
export type StationId = (typeof STATIONS)[number];
|
||||||
|
|
||||||
|
/** A blank program draft for a station enabled for the first time. */
|
||||||
|
export function defaultProgram(id: StationId, label: string): Omit<ValidationProgramView, "id"> {
|
||||||
|
return {
|
||||||
|
name: label,
|
||||||
|
mode: "comp",
|
||||||
|
minutes: null,
|
||||||
|
percent: null,
|
||||||
|
maxAmountMinor: null,
|
||||||
|
maxPerDay: null,
|
||||||
|
active: true,
|
||||||
|
userIds: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const toMinor = (s: string): number | null => {
|
||||||
|
const v = s.trim();
|
||||||
|
if (v === "") return null;
|
||||||
|
const n = Number(v);
|
||||||
|
return Number.isFinite(n) && n > 0 ? Math.round(n * 100) : null;
|
||||||
|
};
|
||||||
|
const fromMinor = (m: number | null): string => (m == null ? "" : String(m / 100));
|
||||||
|
const toInt = (s: string): number | null => {
|
||||||
|
const v = s.trim();
|
||||||
|
if (v === "") return null;
|
||||||
|
const n = Number(v);
|
||||||
|
return Number.isInteger(n) && n > 0 ? n : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function StationForm({
|
||||||
|
program,
|
||||||
|
onSaved,
|
||||||
|
}: {
|
||||||
|
program: ValidationProgramView;
|
||||||
|
onSaved: (p: ValidationProgramView) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [name, setName] = useState(program.name);
|
||||||
|
const [mode, setMode] = useState<ValidationMode>(program.mode);
|
||||||
|
const [minutes, setMinutes] = useState(program.minutes == null ? "" : String(program.minutes));
|
||||||
|
const [percent, setPercent] = useState(program.percent == null ? "" : String(program.percent));
|
||||||
|
const [maxAmount, setMaxAmount] = useState(fromMinor(program.maxAmountMinor));
|
||||||
|
const [maxPerDay, setMaxPerDay] = useState(program.maxPerDay == null ? "" : String(program.maxPerDay));
|
||||||
|
const [userIds, setUserIds] = useState<Set<string>>(new Set(program.userIds));
|
||||||
|
const [users, setUsers] = useState<ManagedUser[] | null>(null);
|
||||||
|
const [msg, setMsg] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Reset the form when the tab switches to another station.
|
||||||
|
useEffect(() => {
|
||||||
|
setName(program.name);
|
||||||
|
setMode(program.mode);
|
||||||
|
setMinutes(program.minutes == null ? "" : String(program.minutes));
|
||||||
|
setPercent(program.percent == null ? "" : String(program.percent));
|
||||||
|
setMaxAmount(fromMinor(program.maxAmountMinor));
|
||||||
|
setMaxPerDay(program.maxPerDay == null ? "" : String(program.maxPerDay));
|
||||||
|
setUserIds(new Set(program.userIds));
|
||||||
|
setMsg(null);
|
||||||
|
}, [program.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchUsers()
|
||||||
|
.then((r) => setUsers(r.users))
|
||||||
|
.catch(() => setUsers([]));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const valid = useMemo(() => {
|
||||||
|
if (!name.trim()) return false;
|
||||||
|
if (mode === "timeCredit") return toInt(minutes) != null;
|
||||||
|
if (mode === "percent") {
|
||||||
|
const p = toInt(percent);
|
||||||
|
return p != null && p <= 100;
|
||||||
|
}
|
||||||
|
if (mode === "fixed") return toMinor(maxAmount) != null;
|
||||||
|
return true;
|
||||||
|
}, [name, mode, minutes, percent, maxAmount]);
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
setMsg(null);
|
||||||
|
try {
|
||||||
|
const saved = await saveValidationProgram(program.id, {
|
||||||
|
name: name.trim(),
|
||||||
|
mode,
|
||||||
|
minutes: mode === "timeCredit" ? toInt(minutes) : null,
|
||||||
|
percent: mode === "percent" ? toInt(percent) : null,
|
||||||
|
maxAmountMinor: mode === "fixed" ? toMinor(maxAmount) : null,
|
||||||
|
maxPerDay: toInt(maxPerDay),
|
||||||
|
active: program.active,
|
||||||
|
userIds: [...userIds],
|
||||||
|
});
|
||||||
|
onSaved(saved);
|
||||||
|
setMsg(t("val.saved"));
|
||||||
|
} catch (e) {
|
||||||
|
setMsg((e as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleUser = (id: string) =>
|
||||||
|
setUserIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.has(id) ? next.delete(id) : next.add(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-3 grid gap-3">
|
||||||
|
<div className="field">
|
||||||
|
<span className="label">{t("val.labelName")}</span>
|
||||||
|
<input className="input" value={name} onChange={(e) => setName(e.target.value)} placeholder={t("val.labelNamePh")} />
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<span className="label">{t("val.mode")}</span>
|
||||||
|
<select className="input w-fit" value={mode} onChange={(e) => setMode(e.target.value as ValidationMode)}>
|
||||||
|
<option value="comp">{t("val.modeComp")}</option>
|
||||||
|
<option value="timeCredit">{t("val.modeTimeCredit")}</option>
|
||||||
|
<option value="fixed">{t("val.modeFixed")}</option>
|
||||||
|
<option value="percent">{t("val.modePercent")}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{mode === "timeCredit" && (
|
||||||
|
<div className="field">
|
||||||
|
<span className="label">{t("val.minutes")}</span>
|
||||||
|
<input className="input w-32" value={minutes} onChange={(e) => setMinutes(e.target.value)} placeholder="60" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{mode === "percent" && (
|
||||||
|
<div className="field">
|
||||||
|
<span className="label">{t("val.percent")}</span>
|
||||||
|
<input className="input w-32" value={percent} onChange={(e) => setPercent(e.target.value)} placeholder="100" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{mode === "fixed" && (
|
||||||
|
<div className="field">
|
||||||
|
<span className="label">{t("val.maxAmount")}</span>
|
||||||
|
<input className="input w-32" value={maxAmount} onChange={(e) => setMaxAmount(e.target.value)} placeholder="1000" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="field">
|
||||||
|
<span className="label">{t("val.maxPerDay")}</span>
|
||||||
|
<input className="input w-32" value={maxPerDay} onChange={(e) => setMaxPerDay(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="label">{t("val.users")}</div>
|
||||||
|
<span className="hint block">{t("val.usersHint")}</span>
|
||||||
|
<div className="mt-1 grid gap-1">
|
||||||
|
{users == null ? (
|
||||||
|
<span className="text-term-muted">…</span>
|
||||||
|
) : users.length === 0 ? (
|
||||||
|
<span className="text-[0.75rem] text-term-muted">{t("val.noUsers")}</span>
|
||||||
|
) : (
|
||||||
|
users.map((u) => (
|
||||||
|
<label key={u.id} className="flex items-center gap-2 text-[0.75rem] text-term-text">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="accent-term-amber"
|
||||||
|
checked={userIds.has(u.id)}
|
||||||
|
onChange={() => toggleUser(u.id)}
|
||||||
|
/>
|
||||||
|
{u.username}
|
||||||
|
{u.fullName && <span className="text-term-muted">({u.fullName})</span>}
|
||||||
|
</label>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button type="button" className="btn btn-primary btn-sm" disabled={!valid} onClick={save}>
|
||||||
|
{t("site.save")}
|
||||||
|
</button>
|
||||||
|
{msg && <span className="text-[0.75rem] text-term-muted">{msg}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The right-column panel: tabs across the ENABLED stations, one form each. */
|
||||||
|
export function ValidationStationsPanel({
|
||||||
|
programs,
|
||||||
|
onSaved,
|
||||||
|
}: {
|
||||||
|
programs: ValidationProgramView[];
|
||||||
|
onSaved: (p: ValidationProgramView) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const enabled = STATIONS.map((id) => programs.find((p) => p.id === id)).filter(
|
||||||
|
(p): p is ValidationProgramView => p != null && p.active,
|
||||||
|
);
|
||||||
|
const [tab, setTab] = useState<string | null>(null);
|
||||||
|
const current = enabled.find((p) => p.id === tab) ?? enabled[0];
|
||||||
|
if (!current) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="card w-full max-w-md p-4">
|
||||||
|
<div className="text-[0.6875rem] uppercase tracking-wider text-term-muted">{t("val.sectionTitle")}</div>
|
||||||
|
{enabled.length > 1 && (
|
||||||
|
<div className="mt-2 flex gap-1">
|
||||||
|
{enabled.map((p) => (
|
||||||
|
<button
|
||||||
|
key={p.id}
|
||||||
|
type="button"
|
||||||
|
className={`btn btn-sm ${p.id === current.id ? "btn-primary" : "btn-ghost"}`}
|
||||||
|
onClick={() => setTab(p.id)}
|
||||||
|
>
|
||||||
|
{t(p.id === "bar" ? "val.enableBar" : "val.enableLavazh")}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<StationForm program={current} onSaved={onSaved} />
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
+166
-12
@@ -1,13 +1,22 @@
|
|||||||
// Thin API client for the operator/admin UI.
|
// Thin API client for the operator/admin UI.
|
||||||
//
|
//
|
||||||
// Auth is cookie-based: the JWT lives in an HttpOnly cookie the browser sends
|
// Auth is cookie-based: the JWT lives in an HttpOnly cookie sent automatically
|
||||||
// automatically (credentials: 'include'). For mutations we echo the readable
|
// (credentials: 'include'). For mutations we echo the readable CSRF cookie
|
||||||
// CSRF cookie back in the X-CSRF-Token header (double-submit). See
|
// back in the X-CSRF-Token header (double-submit). See
|
||||||
// wiki/entities/local-jwt-auth.md.
|
// wiki/entities/local-jwt-auth.md.
|
||||||
|
//
|
||||||
|
// Desktop shell exception: document.cookie can't see the CSRF cookie there
|
||||||
|
// (reqwest's separate jar — see lib/desktop-csrf.ts), so the server also
|
||||||
|
// echoes the token in the login/me response BODY (sessionView's csrfToken —
|
||||||
|
// see routes/auth.ts) and setSessionUser() (called wherever a SessionUser is
|
||||||
|
// received) stashes it via setDesktopCsrfToken(). The browser path is
|
||||||
|
// untouched — it still reads document.cookie.
|
||||||
|
|
||||||
|
import { getDesktopCsrfToken, setDesktopCsrfToken } from "./lib/desktop-csrf.js";
|
||||||
import { logFailedRequest } from "./lib/logger.js";
|
import { logFailedRequest } from "./lib/logger.js";
|
||||||
import { apiUrl } from "./lib/origin.js";
|
import { apiUrl, platformFetch } from "./lib/origin.js";
|
||||||
import type { AppLogRecord } from "@parking/shared";
|
import { inTauri } from "./lib/tauri-env.js";
|
||||||
|
import type { AppLogRecord, ValidationLine, ValidationMode } from "@parking/shared";
|
||||||
|
|
||||||
const CSRF_COOKIE = "parking_csrf";
|
const CSRF_COOKIE = "parking_csrf";
|
||||||
const CSRF_HEADER = "X-CSRF-Token";
|
const CSRF_HEADER = "X-CSRF-Token";
|
||||||
@@ -17,6 +26,13 @@ function readCookie(name: string): string | null {
|
|||||||
return m ? decodeURIComponent(m[1]!) : null;
|
return m ? decodeURIComponent(m[1]!) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Update the desktop CSRF stash. Called wherever a SessionUser is received
|
||||||
|
* (login, fetchMe). No-op / cheap in the browser (the value just goes
|
||||||
|
* unused there — reads still come from document.cookie). */
|
||||||
|
function setSessionUser(user: SessionUser): void {
|
||||||
|
if (user.csrfToken) setDesktopCsrfToken(user.csrfToken);
|
||||||
|
}
|
||||||
|
|
||||||
/** fetch wrapper: sends cookies, adds CSRF header on mutations, parses errors. */
|
/** fetch wrapper: sends cookies, adds CSRF header on mutations, parses errors. */
|
||||||
export async function apiFetch<T>(path: string, init: RequestInit = {}): Promise<T> {
|
export async function apiFetch<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||||
const method = (init.method ?? "GET").toUpperCase();
|
const method = (init.method ?? "GET").toUpperCase();
|
||||||
@@ -25,10 +41,10 @@ export async function apiFetch<T>(path: string, init: RequestInit = {}): Promise
|
|||||||
headers.set("content-type", "application/json");
|
headers.set("content-type", "application/json");
|
||||||
}
|
}
|
||||||
if (method !== "GET" && method !== "HEAD") {
|
if (method !== "GET" && method !== "HEAD") {
|
||||||
const csrf = readCookie(CSRF_COOKIE);
|
const csrf = inTauri() ? getDesktopCsrfToken() : readCookie(CSRF_COOKIE);
|
||||||
if (csrf) headers.set(CSRF_HEADER, csrf);
|
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) {
|
if (!res.ok) {
|
||||||
const msg = (await res.json().catch(() => ({}))) as { error?: string; problems?: string[]; [k: string]: unknown };
|
const msg = (await res.json().catch(() => ({}))) as { error?: string; problems?: string[]; [k: string]: unknown };
|
||||||
const error = msg.error ?? `${path}: ${res.status}`;
|
const error = msg.error ?? `${path}: ${res.status}`;
|
||||||
@@ -82,6 +98,10 @@ export interface SessionUser {
|
|||||||
fullName: string | null;
|
fullName: string | null;
|
||||||
/** Optional contact email (profile metadata); null if unset. */
|
/** Optional contact email (profile metadata); null if unset. */
|
||||||
email: string | null;
|
email: string | null;
|
||||||
|
/** Desktop-only: the CSRF token also echoed via the (JS-unreadable, on
|
||||||
|
* desktop) parking_csrf cookie — see the file header. Absent/unused in the
|
||||||
|
* browser build, which reads the cookie directly instead. */
|
||||||
|
csrfToken?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Does this session grant the permission? Central authz check for the SPA. */
|
/** Does this session grant the permission? Central authz check for the SPA. */
|
||||||
@@ -89,15 +109,28 @@ export function can(user: SessionUser | null, perm: Permission): boolean {
|
|||||||
return !!user && user.permissions.includes(perm);
|
return !!user && user.permissions.includes(perm);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function login(username: string, password: string): Promise<SessionUser> {
|
export async function login(username: string, password: string): Promise<SessionUser> {
|
||||||
return apiFetch<SessionUser>("/api/auth/login", {
|
const user = await apiFetch<SessionUser>("/api/auth/login", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ username, password }),
|
body: JSON.stringify({ username, password }),
|
||||||
});
|
});
|
||||||
|
setSessionUser(user);
|
||||||
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function logout(): Promise<{ ok: boolean }> {
|
export async function logout(): Promise<{ ok: boolean }> {
|
||||||
return apiFetch("/api/auth/logout", { method: "POST" });
|
const res = await apiFetch<{ ok: boolean }>("/api/auth/logout", { method: "POST" });
|
||||||
|
setDesktopCsrfToken(null);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Desktop only: mint a single-use, short-lived ticket that authenticates the
|
||||||
|
* live-feed WebSocket handshake in place of the session cookie — the native WS
|
||||||
|
* plugin has no cookie jar, so the cookie can never ride along (see
|
||||||
|
* routes/ws.ts and lib/platform-ws.ts). Normal cookie + CSRF auth on the way in. */
|
||||||
|
export async function fetchWsTicket(): Promise<string> {
|
||||||
|
const { ticket } = await apiFetch<{ ticket: string }>("/api/ws/ticket", { method: "POST" });
|
||||||
|
return ticket;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Persist the current user's UI language preference (restored on next login). */
|
/** Persist the current user's UI language preference (restored on next login). */
|
||||||
@@ -146,7 +179,9 @@ export function changeMyPassword(
|
|||||||
/** Returns the current user, or null if not authenticated. */
|
/** Returns the current user, or null if not authenticated. */
|
||||||
export async function fetchMe(): Promise<SessionUser | null> {
|
export async function fetchMe(): Promise<SessionUser | null> {
|
||||||
try {
|
try {
|
||||||
return await apiFetch<SessionUser>("/api/auth/me");
|
const user = await apiFetch<SessionUser>("/api/auth/me");
|
||||||
|
setSessionUser(user);
|
||||||
|
return user;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof ApiError && (e.status === 401 || e.status === 403)) return null;
|
if (e instanceof ApiError && (e.status === 401 || e.status === 403)) return null;
|
||||||
throw e;
|
throw e;
|
||||||
@@ -253,6 +288,15 @@ export async function fetchBackupStatus(): Promise<BackupStatus> {
|
|||||||
return apiFetch("/api/backup/status");
|
return apiFetch("/api/backup/status");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface VersionInfo {
|
||||||
|
/** "<branch>-<short-sha>" baked in at image build time; null on a local/dev build. */
|
||||||
|
buildVersion: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchVersion(): Promise<VersionInfo> {
|
||||||
|
return apiFetch("/api/version");
|
||||||
|
}
|
||||||
|
|
||||||
export interface BackupConfigPatch {
|
export interface BackupConfigPatch {
|
||||||
/** "" clears the target. Omit a field to leave it unchanged; null resets retention to default. */
|
/** "" clears the target. Omit a field to leave it unchanged; null resets retention to default. */
|
||||||
targetDir?: string | null;
|
targetDir?: string | null;
|
||||||
@@ -483,7 +527,11 @@ export interface ReportSeriesPoint {
|
|||||||
entries: number;
|
entries: number;
|
||||||
exits: number;
|
exits: number;
|
||||||
revenueMinor: number;
|
revenueMinor: number;
|
||||||
|
cashMinor: number;
|
||||||
|
cardMinor: number;
|
||||||
payments: number;
|
payments: number;
|
||||||
|
/** Cars inside at the END of the bucket. */
|
||||||
|
occupancyEnd: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReportTotals {
|
export interface ReportTotals {
|
||||||
@@ -500,6 +548,8 @@ export interface ReportTotals {
|
|||||||
totalParkedMinutes: number;
|
totalParkedMinutes: number;
|
||||||
avgParkedMinutes: number;
|
avgParkedMinutes: number;
|
||||||
medianParkedMinutes: number;
|
medianParkedMinutes: number;
|
||||||
|
voids: number;
|
||||||
|
anomalies: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReportSubscriptionStats {
|
export interface ReportSubscriptionStats {
|
||||||
@@ -519,6 +569,12 @@ export interface ReportSummary {
|
|||||||
totals: ReportTotals;
|
totals: ReportTotals;
|
||||||
series: ReportSeriesPoint[];
|
series: ReportSeriesPoint[];
|
||||||
entriesByHour: number[];
|
entriesByHour: number[];
|
||||||
|
/** 7×24, row 0 = Monday — entries heatmap (weekday-vs-weekend patterns). */
|
||||||
|
entriesByDowHour: number[][];
|
||||||
|
/** Stay-duration histogram; last bucket has uptoMin null (>24h tail). */
|
||||||
|
stayHistogram: { uptoMin: number | null; count: number }[];
|
||||||
|
occupancyStart: number;
|
||||||
|
capacity: number | null;
|
||||||
subscriptions: ReportSubscriptionStats;
|
subscriptions: ReportSubscriptionStats;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -592,6 +648,11 @@ export interface AssignBody {
|
|||||||
backendIp?: string;
|
backendIp?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** USB printers currently visible on the appliance (/dev/usb/lpN + sysfs model). */
|
||||||
|
export function fetchUsbPrinters(): Promise<{ printers: { path: string; description: string | null }[] }> {
|
||||||
|
return apiFetch("/api/setup/usb-printers");
|
||||||
|
}
|
||||||
|
|
||||||
/** Save + configure the device (preconditions, push setup), then persist. */
|
/** Save + configure the device (preconditions, push setup), then persist. */
|
||||||
export function assignDevice(body: AssignBody): Promise<AssignResult> {
|
export function assignDevice(body: AssignBody): Promise<AssignResult> {
|
||||||
return apiFetch("/api/setup/assign", { method: "POST", body: JSON.stringify(body) });
|
return apiFetch("/api/setup/assign", { method: "POST", body: JSON.stringify(body) });
|
||||||
@@ -750,6 +811,9 @@ export interface SimSessionPricing {
|
|||||||
export interface SimulateResult {
|
export interface SimulateResult {
|
||||||
currency: string | null;
|
currency: string | null;
|
||||||
pricing: SimSessionPricing;
|
pricing: SimSessionPricing;
|
||||||
|
/** Line items explaining pricing.amountMinor (same engine walk, Σ ≡ amount);
|
||||||
|
* null when the session is settled (within walk-back grace). */
|
||||||
|
breakdown: import("@parking/shared").FeeBreakdown | null;
|
||||||
curve: { minutes: number; amountMinor: number }[];
|
curve: { minutes: number; amountMinor: number }[];
|
||||||
gracePeriodExitMin: number;
|
gracePeriodExitMin: number;
|
||||||
}
|
}
|
||||||
@@ -1281,6 +1345,11 @@ export interface SessionLookup {
|
|||||||
subscriptionHolder: string | null;
|
subscriptionHolder: string | null;
|
||||||
/** Advisory licence plate recognized for this session (ANPR). Null when none. */
|
/** Advisory licence plate recognized for this session (ANPR). Null when none. */
|
||||||
plate: string | null;
|
plate: string | null;
|
||||||
|
/** Merchant validations folded into `amountMinor` (which is NET): pre-discount fee,
|
||||||
|
* total taken off, and the per-validation lines. See validation-discounts.md. */
|
||||||
|
grossMinor: number | null;
|
||||||
|
discountMinor: number | null;
|
||||||
|
validationLines: ValidationLine[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Look up a ticket/session for the booth modal (entry/exit, paid, amount owed). */
|
/** Look up a ticket/session for the booth modal (entry/exit, paid, amount owed). */
|
||||||
@@ -1455,3 +1524,88 @@ export function updatePresenceBypass(patch: { radar?: boolean; camera?: boolean
|
|||||||
export function setCapacity(capacity: number | null): Promise<SiteConfig> {
|
export function setCapacity(capacity: number | null): Promise<SiteConfig> {
|
||||||
return saveSiteConfig({ capacity });
|
return saveSiteConfig({ capacity });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Merchant validations (bar / lavazh) -----------------------------------
|
||||||
|
// The merchant is VALIDATION-ONLY: they scan the ticket on their device and apply
|
||||||
|
// their program; the booth settles NET of the applied validations and prints the
|
||||||
|
// detailed receipt. Program config lives on /setup/site. See validation-discounts.md.
|
||||||
|
|
||||||
|
export type { ValidationLine, ValidationMode } from "@parking/shared";
|
||||||
|
|
||||||
|
/** An admin-composed program (mirrors the server row + its bound users). */
|
||||||
|
export interface ValidationProgramView {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
mode: ValidationMode;
|
||||||
|
minutes: number | null;
|
||||||
|
percent: number | null;
|
||||||
|
maxAmountMinor: number | null;
|
||||||
|
maxPerDay: number | null;
|
||||||
|
active: boolean;
|
||||||
|
userIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A validation applied to a session, with its lifecycle state. */
|
||||||
|
export interface AppliedValidationView {
|
||||||
|
eventId: string;
|
||||||
|
occurredAt: string;
|
||||||
|
programId: string;
|
||||||
|
label: string;
|
||||||
|
mode: ValidationMode;
|
||||||
|
minutes?: number;
|
||||||
|
amountMinor?: number;
|
||||||
|
percent?: number;
|
||||||
|
operator: string | null;
|
||||||
|
voided: boolean;
|
||||||
|
consumedBy: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The merchant screen's minimal session view — deliberately no money data. */
|
||||||
|
export interface ValidationSessionView {
|
||||||
|
identity: string;
|
||||||
|
found: boolean;
|
||||||
|
open: boolean;
|
||||||
|
enteredAt: string | null;
|
||||||
|
subscription: boolean;
|
||||||
|
validations: AppliedValidationView[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** All programs + bound users (the /setup/site panel). site:read. */
|
||||||
|
export function fetchValidationPrograms(): Promise<{ programs: ValidationProgramView[] }> {
|
||||||
|
return apiFetch("/api/validation/programs");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Upsert a program's config + binding set (site:update; signs a config_change). */
|
||||||
|
export function saveValidationProgram(
|
||||||
|
id: string,
|
||||||
|
body: Omit<ValidationProgramView, "id">,
|
||||||
|
): Promise<ValidationProgramView> {
|
||||||
|
return apiFetch(`/api/validation/programs/${encodeURIComponent(id)}`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** MY bound, active programs (the merchant screen). validation:create. */
|
||||||
|
export function fetchMyValidationPrograms(): Promise<{ programs: Omit<ValidationProgramView, "userIds">[] }> {
|
||||||
|
return apiFetch("/api/validation/mine");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Merchant lookup of a scanned ticket (no money data). validation:create. */
|
||||||
|
export function fetchValidationSession(identity: string): Promise<ValidationSessionView> {
|
||||||
|
return apiFetch(`/api/validation/session/${encodeURIComponent(identity)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Apply my program to a ticket (signed, attributed). `amountMinor` only for fixed mode. */
|
||||||
|
export function applyValidation(body: {
|
||||||
|
identity: string;
|
||||||
|
programId: string;
|
||||||
|
amountMinor?: number;
|
||||||
|
}): Promise<{ ok: true; eventId: string; label: string }> {
|
||||||
|
return apiFetch("/api/validation/apply", { method: "POST", body: JSON.stringify(body) });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Void my own UNUSED validation (append-only correction). */
|
||||||
|
export function voidValidation(body: { eventId: string; identity: string }): Promise<{ ok: true }> {
|
||||||
|
return apiFetch("/api/validation/void", { method: "POST", body: JSON.stringify(body) });
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
// Desktop-only: the operator-configured backend origin (host:port of the
|
||||||
|
// Fastify server this install talks to), persisted across restarts.
|
||||||
|
//
|
||||||
|
// The desktop shell is a generic .deb/.AppImage — it is NOT built for one
|
||||||
|
// specific booth, so the backend address can't be baked in at build time
|
||||||
|
// (that was the old VITE_API_BASE approach; a rebuild was needed to point the
|
||||||
|
// same installer at a different appliance). Instead the operator enters it
|
||||||
|
// once in the ConnectScreen (shown before login whenever nothing usable is
|
||||||
|
// stored yet) and it's saved to a JSON file in the OS config dir via
|
||||||
|
// tauri-plugin-store, read back on every launch before any API call.
|
||||||
|
//
|
||||||
|
// Browser build: this module is never reached (inTauri() gates every call
|
||||||
|
// site — see origin.ts), so there is no browser equivalent or fallback here.
|
||||||
|
|
||||||
|
import type { Store } from "@tauri-apps/plugin-store";
|
||||||
|
|
||||||
|
const STORE_FILE = "backend-config.json";
|
||||||
|
const KEY = "backendUrl";
|
||||||
|
|
||||||
|
let storeHandle: Store | null = null;
|
||||||
|
async function getStore(): Promise<Store> {
|
||||||
|
if (!storeHandle) {
|
||||||
|
const { load } = await import("@tauri-apps/plugin-store");
|
||||||
|
storeHandle = await load(STORE_FILE, { autoSave: true });
|
||||||
|
}
|
||||||
|
return storeHandle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The saved backend origin (no trailing slash), or null if never configured.
|
||||||
|
* Desktop only — throws if called from a browser build. */
|
||||||
|
export async function loadBackendUrl(): Promise<string | null> {
|
||||||
|
const store = await getStore();
|
||||||
|
const v = await store.get<string>(KEY);
|
||||||
|
return typeof v === "string" && v.length > 0 ? v.replace(/\/$/, "") : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Persist a new backend origin (validated + reachable — call testBackendUrl
|
||||||
|
* first). Takes effect immediately for future platformFetch/wsUrl calls. */
|
||||||
|
export async function saveBackendUrl(url: string): Promise<void> {
|
||||||
|
const store = await getStore();
|
||||||
|
await store.set(KEY, url.replace(/\/$/, ""));
|
||||||
|
await store.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clear the saved backend (forces the ConnectScreen back up next launch). */
|
||||||
|
export async function clearBackendUrl(): Promise<void> {
|
||||||
|
const store = await getStore();
|
||||||
|
await store.delete(KEY);
|
||||||
|
await store.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackendCheck {
|
||||||
|
ok: boolean;
|
||||||
|
/** "unreachable" (network/DNS/refused) | "bad_response" (reachable, not our API). */
|
||||||
|
reason?: "unreachable" | "bad_response";
|
||||||
|
detail?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Probe a candidate origin via GET /health — the server's one unauthenticated
|
||||||
|
* route (server.ts), which answers `{status:"ok", app:"parking-system"}`. We
|
||||||
|
* require BOTH a 2xx and that `app` value: the previous probe hit an
|
||||||
|
* auth-guarded route and accepted 401/403 as "ours", which any password-
|
||||||
|
* protected service on the LAN would also have passed. Uses the same
|
||||||
|
* tauri-plugin-http path platformFetch does (raw fetch from the webview can't
|
||||||
|
* reach an arbitrary LAN host — mixed content, see origin.ts). */
|
||||||
|
export async function testBackendUrl(url: string): Promise<BackendCheck> {
|
||||||
|
const origin = url.replace(/\/$/, "");
|
||||||
|
try {
|
||||||
|
const { fetch: tauriFetch } = await import("@tauri-apps/plugin-http");
|
||||||
|
const res = await tauriFetch(`${origin}/health`, {
|
||||||
|
method: "GET",
|
||||||
|
signal: AbortSignal.timeout(5000),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
return { ok: false, reason: "bad_response", detail: `HTTP ${res.status}` };
|
||||||
|
}
|
||||||
|
const body = (await res.json().catch(() => null)) as { app?: unknown } | null;
|
||||||
|
if (body?.app !== "parking-system") {
|
||||||
|
return { ok: false, reason: "bad_response", detail: "unexpected /health body" };
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
reason: "unreachable",
|
||||||
|
detail: err instanceof Error ? err.message : String(err),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
// Desktop-only in-memory CSRF token stash.
|
||||||
|
//
|
||||||
|
// tauri-plugin-http's fetch() runs through Rust's reqwest, which keeps its OWN
|
||||||
|
// cookie jar separate from the webview — document.cookie on tauri://localhost
|
||||||
|
// never sees the parking_csrf cookie the server sets (open upstream bug,
|
||||||
|
// tauri-apps/tauri#13045). The cookie IS still sent to the server by reqwest;
|
||||||
|
// only the client-side READ is broken. So the server echoes the same value in
|
||||||
|
// the login / me response body (sessionView's csrfToken, routes/auth.ts) and
|
||||||
|
// the desktop client keeps it here, echoing THIS in X-CSRF-Token instead of
|
||||||
|
// reading document.cookie.
|
||||||
|
//
|
||||||
|
// One module, no imports, so BOTH echo sites can share it without a cycle:
|
||||||
|
// api.ts (sets it, uses it for apiFetch mutations) and logger.ts (uses it for
|
||||||
|
// the /api/logs flush — which api.ts imports, so it can't import api.ts back).
|
||||||
|
// Never persisted: a fresh launch re-learns it via login or /api/auth/me.
|
||||||
|
|
||||||
|
let token: string | null = null;
|
||||||
|
|
||||||
|
export function setDesktopCsrfToken(value: string | null): void {
|
||||||
|
token = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDesktopCsrfToken(): string | null {
|
||||||
|
return token;
|
||||||
|
}
|
||||||
@@ -9,11 +9,18 @@
|
|||||||
// and never tries to resolve the Tauri APIs. Offline-first: a failed check (no
|
// and never tries to resolve the Tauri APIs. Offline-first: a failed check (no
|
||||||
// network — the appliance is usually offline) is swallowed; updates only happen
|
// network — the appliance is usually offline) is swallowed; updates only happen
|
||||||
// when someone has brought the box online (e.g. a phone hotspot) on purpose.
|
// when someone has brought the box online (e.g. a phone hotspot) on purpose.
|
||||||
|
//
|
||||||
|
// A release build's console.error is invisible with no way to attach devtools
|
||||||
|
// in the field (kiosk mode blocks the context menu; this WebKitGTK build's
|
||||||
|
// remote inspector doesn't answer standard discovery endpoints either — both
|
||||||
|
// confirmed dead ends 2026-09-03). logClient() ships straight to the
|
||||||
|
// server-side app_logs store regardless of the client's console-forward log
|
||||||
|
// level (that gate is meant for noisy console chatter, not this), so a real
|
||||||
|
// post-accept install failure is visible via wiki/concepts/app-logs.md /
|
||||||
|
// LogsViewer.tsx without needing a terminal or devtools at all.
|
||||||
|
|
||||||
/** True when running inside the Tauri webview (not a normal browser). */
|
import { logClient } from "./logger.js";
|
||||||
function inTauri(): boolean {
|
import { inTauri } from "./tauri-env.js";
|
||||||
return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UpdatePrompt {
|
export interface UpdatePrompt {
|
||||||
/** Newer version string offered by the server. */
|
/** Newer version string offered by the server. */
|
||||||
@@ -41,11 +48,39 @@ export async function checkForDesktopUpdate(
|
|||||||
|
|
||||||
// Download + install the signed update (signature verified against the
|
// Download + install the signed update (signature verified against the
|
||||||
// pubkey in tauri.conf.json), then relaunch into the new version.
|
// pubkey in tauri.conf.json), then relaunch into the new version.
|
||||||
await update.downloadAndInstall();
|
try {
|
||||||
|
await update.downloadAndInstall((progress) => {
|
||||||
|
logClient({
|
||||||
|
level: "info",
|
||||||
|
message: `desktop update download progress: ${progress.event}`,
|
||||||
|
context: { kind: "desktop_update_progress", version: update.version, event: progress.event },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} 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.
|
||||||
|
logClient({
|
||||||
|
level: "error",
|
||||||
|
message: `desktop update download/install failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
stack: err instanceof Error ? err.stack : undefined,
|
||||||
|
context: { kind: "desktop_update_install_failed", version: update.version },
|
||||||
|
});
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
const { relaunch } = await import("@tauri-apps/plugin-process");
|
const { relaunch } = await import("@tauri-apps/plugin-process");
|
||||||
await relaunch();
|
await relaunch();
|
||||||
} catch {
|
} catch (err) {
|
||||||
// Offline / endpoint unreachable / no update server yet → ignore. The app
|
// 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 (info, not error — this path is expected/normal far more often
|
||||||
|
// than it's a real problem) so a real install failure (rethrown above,
|
||||||
|
// logged as error) isn't lost among routine offline checks.
|
||||||
|
logClient({
|
||||||
|
level: "info",
|
||||||
|
message: `desktop update check/apply skipped: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
context: { kind: "desktop_update_skipped" },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { formatMoney, formatDuration, formatTime, formatRelativeDateTime, type TFn } from "./format.js";
|
import { formatMoney, formatDuration, formatRelativeDateTime, type TFn } from "./format.js";
|
||||||
|
|
||||||
// The booth's display formatters. Money is integer MINOR units (never a float, matching
|
// The booth's display formatters. Money is integer MINOR units (never a float, matching
|
||||||
// the ledger/tariff model); duration is whole minutes; relative dates drive the session/
|
// the ledger/tariff model); duration is whole minutes; relative dates drive the session/
|
||||||
@@ -35,16 +35,6 @@ describe("formatDuration", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("formatTime", () => {
|
|
||||||
it("returns an em dash for null/invalid", () => {
|
|
||||||
expect(formatTime(null)).toBe("—");
|
|
||||||
expect(formatTime("not-a-date")).toBe("—");
|
|
||||||
});
|
|
||||||
it("renders HH:MM:SS local time", () => {
|
|
||||||
expect(formatTime("2026-06-21T10:48:25.000Z")).toMatch(/^\d{2}:\d{2}:\d{2}$/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("formatRelativeDateTime", () => {
|
describe("formatRelativeDateTime", () => {
|
||||||
// A tiny fake t(): today/yesterday words + the month-name array.
|
// A tiny fake t(): today/yesterday words + the month-name array.
|
||||||
const months = ["Jan","Shkurt","Mars","Prill","Maj","Qershor","Korrik","Gusht","Sht","Tet","Nën","Dhj"];
|
const months = ["Jan","Shkurt","Mars","Prill","Maj","Qershor","Korrik","Gusht","Sht","Tet","Nën","Dhj"];
|
||||||
@@ -61,6 +51,12 @@ describe("formatRelativeDateTime", () => {
|
|||||||
expect(formatRelativeDateTime(now.toISOString(), t)).toMatch(/^Sot \d{2}:\d{2}$/);
|
expect(formatRelativeDateTime(now.toISOString(), t)).toMatch(/^Sot \d{2}:\d{2}$/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("appends :ss with the seconds option (entry/exit rows read alike)", () => {
|
||||||
|
const now = new Date();
|
||||||
|
now.setHours(19, 25, 44, 0);
|
||||||
|
expect(formatRelativeDateTime(now.toISOString(), t, { seconds: true })).toMatch(/^Sot \d{2}:\d{2}:44$/);
|
||||||
|
});
|
||||||
|
|
||||||
it("labels yesterday with the localized word", () => {
|
it("labels yesterday with the localized word", () => {
|
||||||
const y = new Date();
|
const y = new Date();
|
||||||
y.setDate(y.getDate() - 1);
|
y.setDate(y.getDate() - 1);
|
||||||
|
|||||||
+49
-18
@@ -46,13 +46,6 @@ export function formatMinutes(mins: number): string {
|
|||||||
return h > 0 ? `${h}h ${m % 60}m` : `${m}m`;
|
return h > 0 ? `${h}h ${m % 60}m` : `${m}m`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Local time-of-day HH:MM:SS from an ISO string. */
|
|
||||||
export function formatTime(iso: string | null): string {
|
|
||||||
if (!iso) return "—";
|
|
||||||
const d = new Date(iso);
|
|
||||||
return Number.isNaN(d.getTime()) ? "—" : d.toTimeString().slice(0, 8);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Calendar-day difference (local) between two dates: 0 = same day, 1 = d is one day
|
/** Calendar-day difference (local) between two dates: 0 = same day, 1 = d is one day
|
||||||
* before ref, etc. Compares date parts only (ignores time-of-day). */
|
* before ref, etc. Compares date parts only (ignores time-of-day). */
|
||||||
function dayDiff(d: Date, ref: Date): number {
|
function dayDiff(d: Date, ref: Date): number {
|
||||||
@@ -61,10 +54,11 @@ function dayDiff(d: Date, ref: Date): number {
|
|||||||
return Math.round((b.getTime() - a.getTime()) / 86_400_000);
|
return Math.round((b.getTime() - a.getTime()) / 86_400_000);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** HH:MM (local, 24h) for the relative-day labels. */
|
/** HH:MM (local, 24h) for the relative-day labels; ":ss" appended when `seconds`. */
|
||||||
function hhmm(d: Date): string {
|
function hhmm(d: Date, seconds = false): string {
|
||||||
const p = (n: number) => String(n).padStart(2, "0");
|
const p = (n: number) => String(n).padStart(2, "0");
|
||||||
return `${p(d.getHours())}:${p(d.getMinutes())}`;
|
const base = `${p(d.getHours())}:${p(d.getMinutes())}`;
|
||||||
|
return seconds ? `${base}:${p(d.getSeconds())}` : base;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Minimal shape of i18next's `t` that we rely on: a string lookup, plus the
|
/** Minimal shape of i18next's `t` that we rely on: a string lookup, plus the
|
||||||
@@ -85,6 +79,42 @@ function monthName(d: Date, t: TFn): string {
|
|||||||
return String(d.getMonth() + 1);
|
return String(d.getMonth() + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Short month ("Qer", "Korr") from the catalog — the UI-wide date standard
|
||||||
|
* (2026-07-06): every visible date reads "25 Qer" / "7 Korr 2025", never the
|
||||||
|
* browser-locale "7/6/2026". Falls back to the full name, then the number. */
|
||||||
|
function monthShort(d: Date, t: TFn): string {
|
||||||
|
const months = t("common.monthsShort", { returnObjects: true });
|
||||||
|
if (Array.isArray(months) && typeof months[d.getMonth()] === "string") {
|
||||||
|
return months[d.getMonth()] as string;
|
||||||
|
}
|
||||||
|
return monthName(d, t);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "HH:mm" (local, 24h) — the unified time-of-day everywhere ("—" for bad input). */
|
||||||
|
export function formatClock(iso: string | null): string {
|
||||||
|
if (!iso) return "—";
|
||||||
|
const d = new Date(iso);
|
||||||
|
return Number.isNaN(d.getTime()) ? "—" : hhmm(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "25 Qer" (current year) / "25 Qer 2025" (other years) — the unified DATE. */
|
||||||
|
export function formatDate(iso: string | null, t: TFn): string {
|
||||||
|
if (!iso) return "—";
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (Number.isNaN(d.getTime())) return "—";
|
||||||
|
const base = `${d.getDate()} ${monthShort(d, t)}`;
|
||||||
|
return d.getFullYear() === new Date().getFullYear() ? base : `${base} ${d.getFullYear()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "25 Qer 14:30" (+ ":ss" when `seconds`) — the unified absolute DATE+TIME. Use
|
||||||
|
* formatRelativeDateTime instead where "Sot/Dje" reads better (feeds, history). */
|
||||||
|
export function formatDateTime(iso: string | null, t: TFn, opts?: { seconds?: boolean }): string {
|
||||||
|
if (!iso) return "—";
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (Number.isNaN(d.getTime())) return "—";
|
||||||
|
return `${formatDate(iso, t)} ${hhmm(d, opts?.seconds)}`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Human, day-relative date+time for sessions/logs/history. An event from earlier
|
* Human, day-relative date+time for sessions/logs/history. An event from earlier
|
||||||
* today reads "Sot 10:48", yesterday "Dje 17:33", and anything older a localized
|
* today reads "Sot 10:48", yesterday "Dje 17:33", and anything older a localized
|
||||||
@@ -93,17 +123,18 @@ function monthName(d: Date, t: TFn): string {
|
|||||||
*
|
*
|
||||||
* `t` supplies the today/yesterday words AND the month names (the appliance browser
|
* `t` supplies the today/yesterday words AND the month names (the appliance browser
|
||||||
* may lack Albanian Intl data, so month names come from the catalog, not Intl).
|
* may lack Albanian Intl data, so month names come from the catalog, not Intl).
|
||||||
|
*
|
||||||
|
* `seconds` appends ":ss" — use it where a timestamp sits next to another that shows
|
||||||
|
* seconds (e.g. the booth pay modal's entry vs. exit rows), so the two read alike.
|
||||||
*/
|
*/
|
||||||
export function formatRelativeDateTime(iso: string | null, t: TFn): string {
|
export function formatRelativeDateTime(iso: string | null, t: TFn, opts?: { seconds?: boolean }): string {
|
||||||
if (!iso) return "—";
|
if (!iso) return "—";
|
||||||
const d = new Date(iso);
|
const d = new Date(iso);
|
||||||
if (Number.isNaN(d.getTime())) return "—";
|
if (Number.isNaN(d.getTime())) return "—";
|
||||||
|
const time = hhmm(d, opts?.seconds);
|
||||||
const diff = dayDiff(d, new Date());
|
const diff = dayDiff(d, new Date());
|
||||||
if (diff === 0) return `${t("common.today")} ${hhmm(d)}`;
|
if (diff === 0) return `${t("common.today")} ${time}`;
|
||||||
if (diff === 1) return `${t("common.yesterday")} ${hhmm(d)}`;
|
if (diff === 1) return `${t("common.yesterday")} ${time}`;
|
||||||
// Older (or future): "17 Qershor 10:48", with the year only if it differs.
|
// Older (or future): "17 Qer 10:48" — the short-month standard, year only if it differs.
|
||||||
const sameYear = d.getFullYear() === new Date().getFullYear();
|
return `${formatDate(iso, t)} ${time}`;
|
||||||
const month = monthName(d, t);
|
|
||||||
const date = sameYear ? `${d.getDate()} ${month}` : `${d.getDate()} ${month} ${d.getFullYear()}`;
|
|
||||||
return `${date} ${hhmm(d)}`;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export const en: Catalog = {
|
|||||||
"November",
|
"November",
|
||||||
"December",
|
"December",
|
||||||
],
|
],
|
||||||
|
monthsShort: ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"],
|
||||||
},
|
},
|
||||||
auth: {
|
auth: {
|
||||||
title: "Parking System",
|
title: "Parking System",
|
||||||
@@ -41,6 +42,20 @@ export const en: Catalog = {
|
|||||||
signIn: "Sign in",
|
signIn: "Sign in",
|
||||||
signingIn: "Signing in…",
|
signingIn: "Signing in…",
|
||||||
},
|
},
|
||||||
|
connect: {
|
||||||
|
title: "Connect to server",
|
||||||
|
hint: "Enter the address of the parking system server for this booth.",
|
||||||
|
serverAddress: "Server address",
|
||||||
|
test: "Test",
|
||||||
|
testing: "Testing…",
|
||||||
|
save: "Save & continue",
|
||||||
|
saving: "Saving…",
|
||||||
|
testOk: "Reachable — this looks like a Parking System server.",
|
||||||
|
testUnreachable: "Could not reach this address.",
|
||||||
|
testBadResponse: "Reachable, but this doesn't look like a Parking System server.",
|
||||||
|
changeServer: "Change server",
|
||||||
|
changeServerConfirm: "This signs you out and asks for a new server address on next launch. Continue?",
|
||||||
|
},
|
||||||
update: {
|
update: {
|
||||||
available: "Update available",
|
available: "Update available",
|
||||||
prompt: "Version {{version}} is available. Install now and restart?",
|
prompt: "Version {{version}} is available. Install now and restart?",
|
||||||
@@ -64,6 +79,7 @@ export const en: Catalog = {
|
|||||||
logs: "Logs",
|
logs: "Logs",
|
||||||
backup: "Backup",
|
backup: "Backup",
|
||||||
profile: "Profile",
|
profile: "Profile",
|
||||||
|
validate: "Validations",
|
||||||
},
|
},
|
||||||
drawer: {
|
drawer: {
|
||||||
stateTitle: "Drawer now",
|
stateTitle: "Drawer now",
|
||||||
@@ -229,6 +245,7 @@ export const en: Catalog = {
|
|||||||
evtCashOut: "PAY-OUT",
|
evtCashOut: "PAY-OUT",
|
||||||
evtCashReview: "REVIEW",
|
evtCashReview: "REVIEW",
|
||||||
evtConfigChange: "CONFIG",
|
evtConfigChange: "CONFIG",
|
||||||
|
evtValidation: "VALIDATION",
|
||||||
decision: { authorize: "authorized", deny: "denied" },
|
decision: { authorize: "authorized", deny: "denied" },
|
||||||
evtAnomaly: "ANOMALY",
|
evtAnomaly: "ANOMALY",
|
||||||
evtRefused: "REFUSED",
|
evtRefused: "REFUSED",
|
||||||
@@ -330,6 +347,12 @@ export const en: Catalog = {
|
|||||||
hoursUnit: "hours",
|
hoursUnit: "hours",
|
||||||
egHours: "e.g. 2",
|
egHours: "e.g. 2",
|
||||||
pricePerIncrement: "Price / increment (per hour)",
|
pricePerIncrement: "Price / increment (per hour)",
|
||||||
|
pricePerHour: "Price / hour",
|
||||||
|
pricePerN: "Price / {{min}} min",
|
||||||
|
modeFlatN: "Flat price / {{min}} min",
|
||||||
|
perHourEquiv: "= {{amount}} / hour",
|
||||||
|
incrementWarning:
|
||||||
|
"Careful: the billing increment is {{min}} min — every price below is charged per started {{min}} minutes, NOT per hour.",
|
||||||
thereafter: "thereafter (open-ended)",
|
thereafter: "thereafter (open-ended)",
|
||||||
remove: "Remove",
|
remove: "Remove",
|
||||||
addBlock: "+ Add block",
|
addBlock: "+ Add block",
|
||||||
@@ -412,6 +435,8 @@ export const en: Catalog = {
|
|||||||
scan: "Scan for controllers",
|
scan: "Scan for controllers",
|
||||||
scanning: "Scanning…",
|
scanning: "Scanning…",
|
||||||
noControllersFound: "No controllers found on the LAN.",
|
noControllersFound: "No controllers found on the LAN.",
|
||||||
|
usbNoneFound: "No USB printer found (/dev/usb/lpN) — check cable/power; the path can be typed manually.",
|
||||||
|
usbSavedMissing: "saved — not present now",
|
||||||
use: "Use",
|
use: "Use",
|
||||||
test: "Test connection",
|
test: "Test connection",
|
||||||
testing: "Testing…",
|
testing: "Testing…",
|
||||||
@@ -562,6 +587,16 @@ export const en: Catalog = {
|
|||||||
graceExpires: "Grace expires",
|
graceExpires: "Grace expires",
|
||||||
curve: "Duration curve",
|
curve: "Duration curve",
|
||||||
curveHint: "Fee from entry at several durations — see where the daily cap flattens or windows shift.",
|
curveHint: "Fee from entry at several durations — see where the daily cap flattens or windows shift.",
|
||||||
|
bd: {
|
||||||
|
title: "How the amount is produced",
|
||||||
|
rounding: "{{raw}} min parked → {{billed}} min billed ({{inc}}-min increments)",
|
||||||
|
grace: "Free — within the entry grace ({{min}} min)",
|
||||||
|
package: "window package",
|
||||||
|
step: "Day {{day}}: stay up to {{hours}}h — total",
|
||||||
|
stepRepeated: "Day {{day}}: beyond the top tier — full-day total",
|
||||||
|
cap: "Daily cap {{cap}} applied (day {{day}})",
|
||||||
|
total: "Total",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
subs: {
|
subs: {
|
||||||
title: "Subscriptions",
|
title: "Subscriptions",
|
||||||
@@ -716,6 +751,51 @@ export const en: Catalog = {
|
|||||||
fieldPhone: "Phone",
|
fieldPhone: "Phone",
|
||||||
fieldEmail: "Email",
|
fieldEmail: "Email",
|
||||||
},
|
},
|
||||||
|
// Merchant validations (bar / lavazh) — the /setup/site panel, the merchant's
|
||||||
|
// /validate screen, and the booth-modal discount lines. See validation-discounts.md.
|
||||||
|
val: {
|
||||||
|
// /setup/site
|
||||||
|
sectionTitle: "Merchant validations",
|
||||||
|
sectionHint: "An in-park merchant (bar / car-wash) scans the customer's ticket and grants a parking discount — payment and the receipt always stay at the booth.",
|
||||||
|
enableBar: "Bar",
|
||||||
|
enableLavazh: "Car wash",
|
||||||
|
labelName: "Receipt label",
|
||||||
|
labelNamePh: "e.g. Car wash — first hour free",
|
||||||
|
mode: "Discount type",
|
||||||
|
modeComp: "Parking fully free",
|
||||||
|
modeTimeCredit: "First minutes free",
|
||||||
|
modeFixed: "Amount off (typed at scan)",
|
||||||
|
modePercent: "Percent off",
|
||||||
|
minutes: "Free minutes",
|
||||||
|
percent: "Percent (%)",
|
||||||
|
maxAmount: "Cap per validation",
|
||||||
|
maxPerDay: "Max validations per day (blank = unlimited)",
|
||||||
|
users: "Validating users",
|
||||||
|
usersHint: "Only the selected users (whose role grants validation:create) can apply this program from their device.",
|
||||||
|
noUsers: "No users in the system — create one under Users.",
|
||||||
|
saved: "Saved.",
|
||||||
|
// /validate (the merchant screen)
|
||||||
|
title: "Ticket validation",
|
||||||
|
scanPrompt: "Scan or type the ticket number",
|
||||||
|
lookup: "Look up",
|
||||||
|
entry: "Entry:",
|
||||||
|
notFound: "No ticket found with this number.",
|
||||||
|
closed: "The ticket is closed (exited or voided).",
|
||||||
|
subscription: "This is a subscriber entry — not validatable.",
|
||||||
|
amountLabel: "Discount amount",
|
||||||
|
amountHint: "max {{max}}",
|
||||||
|
apply: "Apply validation",
|
||||||
|
applied: "Validation applied.",
|
||||||
|
existing: "Validations on this ticket",
|
||||||
|
voided: "voided",
|
||||||
|
used: "used in a payment",
|
||||||
|
void: "Void",
|
||||||
|
confirmVoid: "Void this validation?",
|
||||||
|
noPrograms: "You have no validation program bound to you — contact the administrator.",
|
||||||
|
// booth pay modal / receipts
|
||||||
|
gross: "Fee",
|
||||||
|
discount: "Discount",
|
||||||
|
},
|
||||||
users: {
|
users: {
|
||||||
title: "Users",
|
title: "Users",
|
||||||
add: "+ Add user",
|
add: "+ Add user",
|
||||||
@@ -866,14 +946,23 @@ export const en: Catalog = {
|
|||||||
payments: "Payments",
|
payments: "Payments",
|
||||||
avgStay: "Avg stay",
|
avgStay: "Avg stay",
|
||||||
subscribers: "Subscribers",
|
subscribers: "Subscribers",
|
||||||
|
peakOcc: "Peak occupancy",
|
||||||
|
voids: "Voided tickets",
|
||||||
|
anomalies: "Anomalies",
|
||||||
},
|
},
|
||||||
chart: {
|
chart: {
|
||||||
flow: "Entries & exits over time",
|
flow: "Entries & exits over time",
|
||||||
|
occupancy: "Occupancy — cars inside",
|
||||||
|
occupancySeries: "Cars inside",
|
||||||
revenue: "Revenue ({{currency}})",
|
revenue: "Revenue ({{currency}})",
|
||||||
mix: "Revenue mix",
|
mix: "Revenue mix",
|
||||||
peakHours: "Entries by hour of day",
|
stay: "Stay duration (closed sessions)",
|
||||||
|
heatmap: "Entries heatmap — hour × day",
|
||||||
breakdown: "Breakdown",
|
breakdown: "Breakdown",
|
||||||
},
|
},
|
||||||
|
capacityLine: "capacity",
|
||||||
|
stay: { m: "m", h: "h" },
|
||||||
|
dowShort: ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"],
|
||||||
mix: { ticket: "Transient", subSales: "Subscriptions", subWindow: "Out-of-window" },
|
mix: { ticket: "Transient", subSales: "Subscriptions", subWindow: "Out-of-window" },
|
||||||
row: {
|
row: {
|
||||||
cash: "Cash",
|
cash: "Cash",
|
||||||
@@ -919,6 +1008,7 @@ export const en: Catalog = {
|
|||||||
status: "Status",
|
status: "Status",
|
||||||
path: "Path",
|
path: "Path",
|
||||||
empty: "No logs.",
|
empty: "No logs.",
|
||||||
|
repeated: "Repeated {{count}} times (first at {{firstAt}})",
|
||||||
},
|
},
|
||||||
backup: {
|
backup: {
|
||||||
title: "Backup",
|
title: "Backup",
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ export const sq = {
|
|||||||
"Nëntor",
|
"Nëntor",
|
||||||
"Dhjetor",
|
"Dhjetor",
|
||||||
],
|
],
|
||||||
|
// Short month names — the UI-wide date standard ("25 Qer", "7 Korr").
|
||||||
|
monthsShort: ["Jan", "Shk", "Mar", "Pri", "Maj", "Qer", "Korr", "Gush", "Sht", "Tet", "Nën", "Dhj"],
|
||||||
},
|
},
|
||||||
auth: {
|
auth: {
|
||||||
title: "Sistemi i Parkimit",
|
title: "Sistemi i Parkimit",
|
||||||
@@ -43,6 +45,20 @@ export const sq = {
|
|||||||
signIn: "Hyr",
|
signIn: "Hyr",
|
||||||
signingIn: "Duke hyrë…",
|
signingIn: "Duke hyrë…",
|
||||||
},
|
},
|
||||||
|
connect: {
|
||||||
|
title: "Lidhu me serverin",
|
||||||
|
hint: "Vendos adresën e serverit të sistemit të parkimit për këtë kabinë.",
|
||||||
|
serverAddress: "Adresa e serverit",
|
||||||
|
test: "Testo",
|
||||||
|
testing: "Duke testuar…",
|
||||||
|
save: "Ruaj & vazhdo",
|
||||||
|
saving: "Duke ruajtur…",
|
||||||
|
testOk: "I arritshëm — duket si server i Sistemit të Parkimit.",
|
||||||
|
testUnreachable: "Nuk u arrit kjo adresë.",
|
||||||
|
testBadResponse: "I arritshëm, por nuk duket si server i Sistemit të Parkimit.",
|
||||||
|
changeServer: "Ndrysho serverin",
|
||||||
|
changeServerConfirm: "Kjo do t'ju dalë nga sesioni dhe do kërkojë adresë të re serveri në hapjen tjetër. Vazhdo?",
|
||||||
|
},
|
||||||
update: {
|
update: {
|
||||||
available: "Përditësim i disponueshëm",
|
available: "Përditësim i disponueshëm",
|
||||||
prompt: "Versioni {{version}} është i disponueshëm. Ta instaloj tani dhe ta rinis?",
|
prompt: "Versioni {{version}} është i disponueshëm. Ta instaloj tani dhe ta rinis?",
|
||||||
@@ -66,6 +82,7 @@ export const sq = {
|
|||||||
logs: "Loget",
|
logs: "Loget",
|
||||||
backup: "Kopje rezervë",
|
backup: "Kopje rezervë",
|
||||||
profile: "Profili",
|
profile: "Profili",
|
||||||
|
validate: "Validime",
|
||||||
},
|
},
|
||||||
drawer: {
|
drawer: {
|
||||||
stateTitle: "Arka tani",
|
stateTitle: "Arka tani",
|
||||||
@@ -233,6 +250,7 @@ export const sq = {
|
|||||||
evtCashOut: "PAGESË",
|
evtCashOut: "PAGESË",
|
||||||
evtCashReview: "SHQYRTIM",
|
evtCashReview: "SHQYRTIM",
|
||||||
evtConfigChange: "KONFIG",
|
evtConfigChange: "KONFIG",
|
||||||
|
evtValidation: "VALIDIM",
|
||||||
decision: { authorize: "autorizuar", deny: "refuzuar" },
|
decision: { authorize: "autorizuar", deny: "refuzuar" },
|
||||||
evtAnomaly: "ANOMALI",
|
evtAnomaly: "ANOMALI",
|
||||||
evtRefused: "REFUZUAR",
|
evtRefused: "REFUZUAR",
|
||||||
@@ -332,7 +350,13 @@ export const sq = {
|
|||||||
bandDuration: "Kohëzgjatja e brezit",
|
bandDuration: "Kohëzgjatja e brezit",
|
||||||
hoursUnit: "orë",
|
hoursUnit: "orë",
|
||||||
egHours: "p.sh. 2",
|
egHours: "p.sh. 2",
|
||||||
pricePerIncrement: "Çmimi / interval (orë)",
|
pricePerIncrement: "Çmimi / interval (min)",
|
||||||
|
pricePerHour: "Çmimi / orë",
|
||||||
|
pricePerN: "Çmimi / {{min}} min",
|
||||||
|
modeFlatN: "Çmim fiks / {{min}} min",
|
||||||
|
perHourEquiv: "= {{amount}} / orë",
|
||||||
|
incrementWarning:
|
||||||
|
"Kujdes: intervali i faturimit është {{min}} min — çdo çmim më poshtë faturohet për çdo {{min}} minuta të filluara, JO për orë.",
|
||||||
thereafter: "më pas (i hapur)",
|
thereafter: "më pas (i hapur)",
|
||||||
remove: "Hiq",
|
remove: "Hiq",
|
||||||
addBlock: "+ Shto bllok",
|
addBlock: "+ Shto bllok",
|
||||||
@@ -420,6 +444,8 @@ export const sq = {
|
|||||||
scan: "Skano për kontroller",
|
scan: "Skano për kontroller",
|
||||||
scanning: "Duke skanuar…",
|
scanning: "Duke skanuar…",
|
||||||
noControllersFound: "Asnjë kontroller në LAN.",
|
noControllersFound: "Asnjë kontroller në LAN.",
|
||||||
|
usbNoneFound: "Nuk u gjet asnjë printer USB (/dev/usb/lpN) — kontrollo kabllon/ushqimin; rruga mund të shkruhet me dorë.",
|
||||||
|
usbSavedMissing: "i ruajtur — jo i pranishëm tani",
|
||||||
use: "Përdor",
|
use: "Përdor",
|
||||||
test: "Testo lidhjen",
|
test: "Testo lidhjen",
|
||||||
testing: "Duke testuar…",
|
testing: "Duke testuar…",
|
||||||
@@ -574,6 +600,16 @@ export const sq = {
|
|||||||
graceExpires: "Afati skadon",
|
graceExpires: "Afati skadon",
|
||||||
curve: "Kurba sipas kohëzgjatjes",
|
curve: "Kurba sipas kohëzgjatjes",
|
||||||
curveHint: "Tarifa nga hyrja për disa kohëzgjatje — shih ku rrafshohet kufiri ditor ose ndryshojnë dritaret.",
|
curveHint: "Tarifa nga hyrja për disa kohëzgjatje — shih ku rrafshohet kufiri ditor ose ndryshojnë dritaret.",
|
||||||
|
bd: {
|
||||||
|
title: "Si prodhohet shuma",
|
||||||
|
rounding: "{{raw}} min qëndrim → {{billed}} min të faturuara (njësi {{inc}} min)",
|
||||||
|
grace: "Falas — brenda minutave të hirit ({{min}} min)",
|
||||||
|
package: "paketë dritareje",
|
||||||
|
step: "Dita {{day}}: qëndrim deri në {{hours}}h — total",
|
||||||
|
stepRepeated: "Dita {{day}}: mbi shkallën më të lartë — totali ditor",
|
||||||
|
cap: "U zbatua kufiri ditor {{cap}} (dita {{day}})",
|
||||||
|
total: "Totali",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
subs: {
|
subs: {
|
||||||
title: "Abonimet",
|
title: "Abonimet",
|
||||||
@@ -728,6 +764,51 @@ export const sq = {
|
|||||||
fieldPhone: "Telefoni",
|
fieldPhone: "Telefoni",
|
||||||
fieldEmail: "Email",
|
fieldEmail: "Email",
|
||||||
},
|
},
|
||||||
|
// Merchant validations (bar / lavazh) — the /setup/site panel, the merchant's
|
||||||
|
// /validate screen, and the booth-modal discount lines. See validation-discounts.md.
|
||||||
|
val: {
|
||||||
|
// /setup/site
|
||||||
|
sectionTitle: "Validime tregtare",
|
||||||
|
sectionHint: "Shërbime të tjera brenda parkut (bar / lavazh) skanojnë biletën e hyrjes dhe bëjnë zbritje — pagesa dhe fatura bëhen në kabinë.",
|
||||||
|
enableBar: "Bar",
|
||||||
|
enableLavazh: "Lavazh",
|
||||||
|
labelName: "Etiketa në faturë",
|
||||||
|
labelNamePh: "p.sh. Lavazh — 1 orë falas",
|
||||||
|
mode: "Lloji i zbritjes",
|
||||||
|
modeComp: "Parkimi falas plotësisht",
|
||||||
|
modeTimeCredit: "Minutat e para falas",
|
||||||
|
modeFixed: "Zbritje shume (shkruhet në skanim)",
|
||||||
|
modePercent: "Zbritje në përqindje",
|
||||||
|
minutes: "Minuta falas",
|
||||||
|
percent: "Përqindja (%)",
|
||||||
|
maxAmount: "Tavani i zbritjes për validim",
|
||||||
|
maxPerDay: "Maks. validime në ditë (bosh = pa kufi)",
|
||||||
|
users: "Përdoruesit që validojnë",
|
||||||
|
usersHint: "Vetëm përdoruesit e zgjedhur (me lejen validation:create në rolin e tyre) mund të aplikojnë këtë program nga pajisja e tyre.",
|
||||||
|
noUsers: "Asnjë përdorues në sistem — krijojeni te Përdoruesit.",
|
||||||
|
saved: "U ruajt.",
|
||||||
|
// /validate (the merchant screen)
|
||||||
|
title: "Validim biletash",
|
||||||
|
scanPrompt: "Skanoni ose shkruani numrin e biletës",
|
||||||
|
lookup: "Kërko",
|
||||||
|
entry: "Hyrja:",
|
||||||
|
notFound: "Nuk u gjet biletë me këtë numër.",
|
||||||
|
closed: "Bileta është e mbyllur (ka dalë ose është anuluar).",
|
||||||
|
subscription: "Kjo është hyrje abonenti — nuk validohet.",
|
||||||
|
amountLabel: "Shuma e zbritjes",
|
||||||
|
amountHint: "maks. {{max}}",
|
||||||
|
apply: "Apliko validimin",
|
||||||
|
applied: "Validimi u aplikua.",
|
||||||
|
existing: "Validime në këtë biletë",
|
||||||
|
voided: "anuluar",
|
||||||
|
used: "përdorur në pagesë",
|
||||||
|
void: "Anulo",
|
||||||
|
confirmVoid: "Të anulohet ky validim?",
|
||||||
|
noPrograms: "Nuk keni asnjë program validimi të lidhur me ju — kontaktoni administratorin.",
|
||||||
|
// booth pay modal / receipts
|
||||||
|
gross: "Tarifa",
|
||||||
|
discount: "Zbritje",
|
||||||
|
},
|
||||||
users: {
|
users: {
|
||||||
title: "Përdoruesit",
|
title: "Përdoruesit",
|
||||||
add: "+ Shto përdorues",
|
add: "+ Shto përdorues",
|
||||||
@@ -881,14 +962,23 @@ export const sq = {
|
|||||||
payments: "Pagesa",
|
payments: "Pagesa",
|
||||||
avgStay: "Qëndrim mes.",
|
avgStay: "Qëndrim mes.",
|
||||||
subscribers: "Abonentë",
|
subscribers: "Abonentë",
|
||||||
|
peakOcc: "Zënia maksimale",
|
||||||
|
voids: "Bileta të anuluara",
|
||||||
|
anomalies: "Anomali",
|
||||||
},
|
},
|
||||||
chart: {
|
chart: {
|
||||||
flow: "Hyrjet & daljet me kalimin e kohës",
|
flow: "Hyrjet & daljet me kalimin e kohës",
|
||||||
|
occupancy: "Zënia — makina brenda",
|
||||||
|
occupancySeries: "Makina brenda",
|
||||||
revenue: "Të ardhurat ({{currency}})",
|
revenue: "Të ardhurat ({{currency}})",
|
||||||
mix: "Përbërja e të ardhurave",
|
mix: "Përbërja e të ardhurave",
|
||||||
peakHours: "Hyrjet sipas orës së ditës",
|
stay: "Kohëzgjatja e qëndrimit (sesione të mbyllura)",
|
||||||
|
heatmap: "Harta e hyrjeve — orë × ditë",
|
||||||
breakdown: "Ndarja",
|
breakdown: "Ndarja",
|
||||||
},
|
},
|
||||||
|
capacityLine: "kapaciteti",
|
||||||
|
stay: { m: "m", h: "o" },
|
||||||
|
dowShort: ["Hën", "Mar", "Mër", "Enj", "Pre", "Sht", "Die"],
|
||||||
mix: { ticket: "Tranzit", subSales: "Abonime", subWindow: "Jashtë orarit" },
|
mix: { ticket: "Tranzit", subSales: "Abonime", subWindow: "Jashtë orarit" },
|
||||||
row: {
|
row: {
|
||||||
cash: "Para në dorë",
|
cash: "Para në dorë",
|
||||||
@@ -934,6 +1024,7 @@ export const sq = {
|
|||||||
status: "Statusi",
|
status: "Statusi",
|
||||||
path: "Rruga",
|
path: "Rruga",
|
||||||
empty: "Asnjë regjistër.",
|
empty: "Asnjë regjistër.",
|
||||||
|
repeated: "Përsëritur {{count}} herë (hera e parë {{firstAt}})",
|
||||||
},
|
},
|
||||||
backup: {
|
backup: {
|
||||||
title: "Kopje rezervë",
|
title: "Kopje rezervë",
|
||||||
|
|||||||
@@ -14,6 +14,9 @@
|
|||||||
// high-signal sources (failed requests, uncaught errors) are always captured.
|
// high-signal sources (failed requests, uncaught errors) are always captured.
|
||||||
|
|
||||||
import { LOG_LEVEL_ORDER, type ClientLogInput, type LogLevel } from "@parking/shared";
|
import { LOG_LEVEL_ORDER, type ClientLogInput, type LogLevel } from "@parking/shared";
|
||||||
|
import { getDesktopCsrfToken } from "./desktop-csrf.js";
|
||||||
|
import { apiUrl, platformFetch } from "./origin.js";
|
||||||
|
import { inTauri } from "./tauri-env.js";
|
||||||
|
|
||||||
const ENDPOINT = "/api/logs";
|
const ENDPOINT = "/api/logs";
|
||||||
const FLUSH_MS = 4000;
|
const FLUSH_MS = 4000;
|
||||||
@@ -74,9 +77,13 @@ async function flush(): Promise<void> {
|
|||||||
flushing = true;
|
flushing = true;
|
||||||
try {
|
try {
|
||||||
const headers: Record<string, string> = { "content-type": "application/json" };
|
const headers: Record<string, string> = { "content-type": "application/json" };
|
||||||
const csrf = readCookie(CSRF_COOKIE);
|
// /api/logs is behind requireAuth → assertCsrf on POST. On desktop the
|
||||||
|
// cookie is unreadable (see desktop-csrf.ts) — without this branch every
|
||||||
|
// desktop flush 403'd and was dropped here, silently, by design (found
|
||||||
|
// 2026-09-04: no desktop client log had EVER reached app_logs).
|
||||||
|
const csrf = inTauri() ? getDesktopCsrfToken() : readCookie(CSRF_COOKIE);
|
||||||
if (csrf) headers[CSRF_HEADER] = csrf;
|
if (csrf) headers[CSRF_HEADER] = csrf;
|
||||||
await fetch(ENDPOINT, {
|
await platformFetch(apiUrl(ENDPOINT), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers,
|
headers,
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
@@ -90,7 +97,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 {
|
function flushBeacon(): void {
|
||||||
if (queue.length === 0) return;
|
if (queue.length === 0) return;
|
||||||
const entries = queue.splice(0, queue.length);
|
const entries = queue.splice(0, queue.length);
|
||||||
@@ -99,7 +109,7 @@ function flushBeacon(): void {
|
|||||||
// sendBeacon can't set the CSRF header; the server accepts the ingest for any
|
// 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,
|
// signed-in session (cookie sent automatically). If CSRF later guards it strictly,
|
||||||
// this path degrades to "lost on unload" — acceptable for diagnostics.
|
// this path degrades to "lost on unload" — acceptable for diagnostics.
|
||||||
navigator.sendBeacon(ENDPOINT, blob);
|
navigator.sendBeacon(apiUrl(ENDPOINT), blob);
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,16 +3,49 @@
|
|||||||
// In a browser (dev via the Vite proxy, or prod where Fastify serves the built
|
// In a browser (dev via the Vite proxy, or prod where Fastify serves the built
|
||||||
// SPA) this is EMPTY — requests stay relative (`/api/...`) and same-origin, so
|
// SPA) this is EMPTY — requests stay relative (`/api/...`) and same-origin, so
|
||||||
// nothing changes. The Tauri desktop shell (apps/desktop) serves the bundled
|
// nothing changes. The Tauri desktop shell (apps/desktop) serves the bundled
|
||||||
// SPA from `tauri://localhost`, which has no backend and no proxy; there we set
|
// SPA from `tauri://localhost`, which has no backend and no proxy; there the
|
||||||
// VITE_API_BASE to the appliance's Fastify origin (e.g. http://127.0.0.1:3000)
|
// operator enters the appliance's Fastify origin (e.g. http://192.168.1.50:3000)
|
||||||
// at build time so /api and the live WS feed resolve to the real server.
|
// once in the ConnectScreen and it's persisted via tauri-plugin-store (see
|
||||||
|
// backend-config.ts) — a RUNTIME value, not a build-time one, since the same
|
||||||
|
// installer is used across every booth and the backend can move (new box, new
|
||||||
|
// IP) without a rebuild. main.tsx calls initApiBase() before the app mounts.
|
||||||
//
|
//
|
||||||
// Keep this the SINGLE source for the backend origin — api.ts and the live-feed
|
// 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
|
// WebSocket both read it, so the web app and the desktop shell stay identical
|
||||||
// except for this one build-time value.
|
// except for this one runtime value.
|
||||||
|
//
|
||||||
|
// platformFetch(): WebKitGTK treats tauri://localhost as a SECURE origin, so a
|
||||||
|
// plain http://192.168.1.50: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. */
|
import { inTauri } from "./tauri-env.js";
|
||||||
export const API_BASE: string = (import.meta.env.VITE_API_BASE ?? "").replace(/\/$/, "");
|
|
||||||
|
/** Backend HTTP origin, no trailing slash. Empty string = same-origin/relative
|
||||||
|
* (browser) or not-yet-configured (desktop, before the ConnectScreen runs). */
|
||||||
|
export let API_BASE: string = "";
|
||||||
|
|
||||||
|
/** Desktop only: load the persisted backend URL (if any) before the app
|
||||||
|
* mounts, so the very first fetchMe() call already has the right origin.
|
||||||
|
* No-op in the browser. Returns the loaded value (null = not configured yet,
|
||||||
|
* meaning main.tsx should show the ConnectScreen instead of the normal app). */
|
||||||
|
export async function initApiBase(): Promise<string | null> {
|
||||||
|
if (!inTauri()) return null;
|
||||||
|
const { loadBackendUrl } = await import("./backend-config.js");
|
||||||
|
const saved = await loadBackendUrl();
|
||||||
|
if (saved) API_BASE = saved;
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Desktop only: change the backend origin at runtime (after the operator
|
||||||
|
* saves a new one in Settings) without requiring a full app restart. */
|
||||||
|
export function setApiBase(url: string): void {
|
||||||
|
API_BASE = url.replace(/\/$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
/** Resolve an API path to a full URL (or a relative path when API_BASE is empty). */
|
/** Resolve an API path to a full URL (or a relative path when API_BASE is empty). */
|
||||||
export function apiUrl(path: string): string {
|
export function apiUrl(path: string): string {
|
||||||
@@ -28,3 +61,19 @@ export function wsUrl(path: string): string {
|
|||||||
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
|
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||||
return `${proto}//${window.location.host}${path}`;
|
return `${proto}//${window.location.host}${path}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { inTauri };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,132 @@
|
|||||||
|
// 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).
|
||||||
|
|
||||||
|
import { fetchWsTicket } from "../api.js";
|
||||||
|
import { logClient } from "./logger.js";
|
||||||
|
import { inTauri } from "./tauri-env.js";
|
||||||
|
|
||||||
|
/** Rate-limit the "connect failed" log: use-live-feed reconnects every ≤10s
|
||||||
|
* forever, and each attempt is a fresh adapter, so without this an outage
|
||||||
|
* would write six near-identical app_logs rows a minute. */
|
||||||
|
const CONNECT_FAIL_LOG_INTERVAL_MS = 60_000;
|
||||||
|
let lastConnectFailLogAt = 0;
|
||||||
|
|
||||||
|
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
|
||||||
|
// The native WS plugin is a bare tungstenite client: no page context AND
|
||||||
|
// no cookie jar. Two consequences, both handled via explicit headers:
|
||||||
|
// - Origin: nothing auto-attaches `Origin: tauri://localhost` the way a
|
||||||
|
// browser WebSocket would, and routes/ws.ts's anti-CSWSH check rejects a
|
||||||
|
// missing/mismatched Origin — so set it to match WS_ALLOWED_ORIGINS.
|
||||||
|
// - Session: the HttpOnly JWT cookie lives in tauri-plugin-http's reqwest
|
||||||
|
// jar and can't ride on this handshake, so jwtVerify() would 401 every
|
||||||
|
// connect (the 2026-09-04 "reconnects every 10s forever" bug). Instead,
|
||||||
|
// mint a single-use ticket over normal HTTP auth and present it in the
|
||||||
|
// x-ws-ticket header (see routes/ws.ts).
|
||||||
|
const ticket = await fetchWsTicket();
|
||||||
|
if (this.#closed) return;
|
||||||
|
const conn = await TauriWebSocket.connect(url, {
|
||||||
|
headers: { Origin: "tauri://localhost", "x-ws-ticket": ticket },
|
||||||
|
});
|
||||||
|
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 (err) {
|
||||||
|
// logClient, not console.error: console output only reaches app_logs at
|
||||||
|
// debug/trace level, which is how the ticket-less 401 stayed invisible
|
||||||
|
// for a full day. A closed-before-connect race isn't a failure.
|
||||||
|
if (!this.#closed && Date.now() - lastConnectFailLogAt > CONNECT_FAIL_LOG_INTERVAL_MS) {
|
||||||
|
lastConnectFailLogAt = Date.now();
|
||||||
|
logClient({
|
||||||
|
level: "error",
|
||||||
|
message: `desktop live-feed connect failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
context: { kind: "desktop_ws_connect_failed", url },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.onerror?.();
|
||||||
|
this.onclose?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
close(): void {
|
||||||
|
this.#closed = true;
|
||||||
|
void this.#conn?.disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
/** True when running inside the Tauri webview (not a normal browser). Single
|
||||||
|
* source for this check — origin.ts, platform-ws.ts, desktop-updater.ts, and
|
||||||
|
* backend-config.ts all gate their Tauri-only code paths on it. */
|
||||||
|
export function inTauri(): boolean {
|
||||||
|
return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import type { DeviceStatus, LedgerEvent, Occupancy } from "../api.js";
|
|||||||
import { qk } from "./query.js";
|
import { qk } from "./query.js";
|
||||||
import { useLiveStore, type LaneStatus, type LanePresence } from "./live-store.js";
|
import { useLiveStore, type LaneStatus, type LanePresence } from "./live-store.js";
|
||||||
import { wsUrl } from "./origin.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
|
// 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
|
// (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" } };
|
| { kind: "plate-recognized"; plate: { identity: string; plate: string; direction: "entry" | "exit" } };
|
||||||
|
|
||||||
|
|
||||||
export function useLiveFeed(): void {
|
/**
|
||||||
|
* @param enabled Gate on the WATCHER permission (`report:read` — mirrors the server's
|
||||||
|
* WS guard in routes/ws.ts). A user whose role lacks it (e.g. a merchant validator
|
||||||
|
* with only `validation:create`) must not attempt the socket at all: the server
|
||||||
|
* 403s the upgrade and the capped-backoff reconnect would otherwise hammer it
|
||||||
|
* forever, filling the server log with a 403 every few seconds.
|
||||||
|
*/
|
||||||
|
export function useLiveFeed(enabled: boolean = true): void {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const { setStatus, setOccupancy, pushEvent, setDevices, upsertDevice, setLanes, setRadar, patchPlate } =
|
const { setStatus, setOccupancy, pushEvent, setDevices, upsertDevice, setLanes, setRadar, patchPlate } =
|
||||||
useLiveStore();
|
useLiveStore();
|
||||||
// Hold the socket + reconnect timer across renders; guard against StrictMode
|
// Hold the socket + reconnect timer across renders; guard against StrictMode
|
||||||
// double-invoke and unmount.
|
// double-invoke and unmount.
|
||||||
const sockRef = useRef<WebSocket | null>(null);
|
const sockRef = useRef<PlatformSocket | null>(null);
|
||||||
const retryRef = useRef(0);
|
const retryRef = useRef(0);
|
||||||
const closedRef = useRef(false);
|
const closedRef = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!enabled) {
|
||||||
|
setStatus("closed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
closedRef.current = false;
|
closedRef.current = false;
|
||||||
|
|
||||||
const connect = () => {
|
const connect = () => {
|
||||||
if (closedRef.current) return;
|
if (closedRef.current) return;
|
||||||
setStatus(retryRef.current === 0 ? "connecting" : "connecting");
|
setStatus(retryRef.current === 0 ? "connecting" : "connecting");
|
||||||
const sock = new WebSocket(wsUrl("/api/ws"));
|
const sock = createPlatformSocket(wsUrl("/api/ws"));
|
||||||
sockRef.current = sock;
|
sockRef.current = sock;
|
||||||
|
|
||||||
sock.onopen = () => {
|
sock.onopen = () => {
|
||||||
@@ -117,7 +129,8 @@ export function useLiveFeed(): void {
|
|||||||
sockRef.current?.close();
|
sockRef.current?.close();
|
||||||
sockRef.current = null;
|
sockRef.current = null;
|
||||||
};
|
};
|
||||||
// qc / store setters are stable; run once on mount.
|
// qc / store setters are stable; re-run only if the permission gate flips
|
||||||
|
// (login as a different role without a full reload).
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, [enabled]);
|
||||||
}
|
}
|
||||||
|
|||||||
+130
-10
@@ -6,7 +6,7 @@ import {
|
|||||||
Outlet,
|
Outlet,
|
||||||
redirect,
|
redirect,
|
||||||
} from "@tanstack/react-router";
|
} from "@tanstack/react-router";
|
||||||
import { lazy, Suspense, useState } from "react";
|
import { lazy, Suspense, useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import type { Lang, Permission, SessionUser, Theme } from "./api.js";
|
import type { Lang, Permission, SessionUser, Theme } from "./api.js";
|
||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
can,
|
can,
|
||||||
closeShift,
|
closeShift,
|
||||||
fetchShiftReport,
|
fetchShiftReport,
|
||||||
|
fetchVersion,
|
||||||
logout,
|
logout,
|
||||||
openShift,
|
openShift,
|
||||||
setLanguagePref,
|
setLanguagePref,
|
||||||
@@ -29,6 +30,7 @@ import { Spinner } from "./ui/Spinner.js";
|
|||||||
import { setLanguage } from "./lib/i18n/index.js";
|
import { setLanguage } from "./lib/i18n/index.js";
|
||||||
import { applyTheme, applyFontScale } from "./lib/theme.js";
|
import { applyTheme, applyFontScale } from "./lib/theme.js";
|
||||||
import { useLiveFeed } from "./lib/use-live-feed.js";
|
import { useLiveFeed } from "./lib/use-live-feed.js";
|
||||||
|
import { inTauri } from "./lib/origin.js";
|
||||||
import { useShift } from "./lib/use-shift.js";
|
import { useShift } from "./lib/use-shift.js";
|
||||||
import { DeviceFooter } from "./ui/DeviceFooter.js";
|
import { DeviceFooter } from "./ui/DeviceFooter.js";
|
||||||
import { StatusDot } from "./ui/StatusDot.js";
|
import { StatusDot } from "./ui/StatusDot.js";
|
||||||
@@ -46,6 +48,7 @@ import { DrawerManager } from "./DrawerManager.js";
|
|||||||
import { CARD_PAYMENTS_ENABLED } from "./lib/features.js";
|
import { CARD_PAYMENTS_ENABLED } from "./lib/features.js";
|
||||||
import { LogsViewer } from "./LogsViewer.js";
|
import { LogsViewer } from "./LogsViewer.js";
|
||||||
import { BackupSettings } from "./BackupSettings.js";
|
import { BackupSettings } from "./BackupSettings.js";
|
||||||
|
import { ValidateScreen } from "./ValidateScreen.js";
|
||||||
import { RecycleBin } from "./RecycleBin.js";
|
import { RecycleBin } from "./RecycleBin.js";
|
||||||
import { Profile } from "./Profile.js";
|
import { Profile } from "./Profile.js";
|
||||||
// Reports pulls in Recharts (~heavy) — lazy-loaded so it stays OUT of the booth's
|
// Reports pulls in Recharts (~heavy) — lazy-loaded so it stays OUT of the booth's
|
||||||
@@ -93,6 +96,89 @@ 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>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The installed Tauri app's own "vX.Y.Z" (from tauri.conf.json, synced to the git tag by
|
||||||
|
* release.yml — see wiki/decisions/desktop-shell-tauri.md) — the client's version, distinct
|
||||||
|
* from VersionBadge's SERVER build. No-op / renders nothing in a browser (there's no Tauri
|
||||||
|
* API to call). Was invisible before this: an operator had no way to tell which desktop
|
||||||
|
* build was actually installed short of reading the update-available prompt. */
|
||||||
|
function DesktopVersionBadge() {
|
||||||
|
const [version, setVersion] = useState<string | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!inTauri()) return;
|
||||||
|
let cancelled = false;
|
||||||
|
void import("@tauri-apps/api/app").then(({ getVersion }) =>
|
||||||
|
getVersion().then((v) => {
|
||||||
|
if (!cancelled) setVersion(v);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
if (!version) return null;
|
||||||
|
return <span className="ml-auto shrink-0 pl-3 text-[0.7rem] text-term-muted">app v{version}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Desktop-only "change which server this install talks to" control. No-op /
|
||||||
|
* renders nothing in a browser (the concept doesn't apply — same-origin).
|
||||||
|
* Simplest correct action: clear the saved backend URL and reload, which
|
||||||
|
* drops the app back to ConnectScreen (see App.tsx) to re-enter it — this
|
||||||
|
* mirrors clearing the session (logout → back to Login), not an inline
|
||||||
|
* editor, since repointing the app is a rare, deliberate admin action. */
|
||||||
|
function DesktopServerButton() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [confirming, setConfirming] = useState(false);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
if (!inTauri()) return null;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost btn-sm ml-2"
|
||||||
|
onClick={() => setConfirming(true)}
|
||||||
|
>
|
||||||
|
{t("connect.changeServer")}
|
||||||
|
</button>
|
||||||
|
{confirming && (
|
||||||
|
<Modal open onClose={() => setConfirming(false)} title={t("connect.changeServer")} width="max-w-sm">
|
||||||
|
<div className="text-[0.8125rem]">
|
||||||
|
<p className="text-term-muted">{t("connect.changeServerConfirm")}</p>
|
||||||
|
<div className="mt-3 flex justify-end gap-2">
|
||||||
|
<button type="button" className="btn btn-sm" onClick={() => setConfirming(false)} disabled={busy}>
|
||||||
|
{t("subs.cancel")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm btn-danger"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={async () => {
|
||||||
|
setBusy(true);
|
||||||
|
const { clearBackendUrl } = await import("./lib/backend-config.js");
|
||||||
|
await clearBackendUrl();
|
||||||
|
window.location.reload();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{busy ? <Spinner /> : t("connect.changeServer")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Setup layout — the config hub. Renders a permission-gated tab bar and the active
|
/** Setup layout — the config hub. Renders a permission-gated tab bar and the active
|
||||||
* tab's screen via <Outlet>. Each tab is a child route (its own URL + guard), so
|
* tab's screen via <Outlet>. Each tab is a child route (its own URL + guard), so
|
||||||
* deep links and the back button work and a denied tab redirects to the booth. */
|
* deep links and the back button work and a denied tab redirects to the booth. */
|
||||||
@@ -111,6 +197,9 @@ function SetupLayout() {
|
|||||||
{show("recyclebin:read") && <SetupTab to="/setup/recycle-bin" label={t("nav.recycleBin")} />}
|
{show("recyclebin:read") && <SetupTab to="/setup/recycle-bin" label={t("nav.recycleBin")} />}
|
||||||
{show("log:read") && <SetupTab to="/setup/logs" label={t("nav.logs")} />}
|
{show("log:read") && <SetupTab to="/setup/logs" label={t("nav.logs")} />}
|
||||||
{show("backup:read") && <SetupTab to="/setup/backup" label={t("nav.backup")} />}
|
{show("backup:read") && <SetupTab to="/setup/backup" label={t("nav.backup")} />}
|
||||||
|
{show("site:read") && <VersionBadge />}
|
||||||
|
<DesktopVersionBadge />
|
||||||
|
<DesktopServerButton />
|
||||||
</nav>
|
</nav>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
@@ -447,19 +536,28 @@ function ConfirmFigure({ label, value, bold, sub }: { label: string; value: stri
|
|||||||
function RootLayout() {
|
function RootLayout() {
|
||||||
const { user, setUser } = rootRoute.useRouteContext();
|
const { user, setUser } = rootRoute.useRouteContext();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
// One app-wide WebSocket for the live feed (booth + any live widget).
|
|
||||||
useLiveFeed();
|
|
||||||
// Nav is gated by PERMISSION, not role — a tab shows iff the user's role grants
|
// Nav is gated by PERMISSION, not role — a tab shows iff the user's role grants
|
||||||
// the permission its screen needs (the route guards enforce the same server-side).
|
// the permission its screen needs (the route guards enforce the same server-side).
|
||||||
const show = (perm: Permission) => can(user, perm);
|
const show = (perm: Permission) => can(user, perm);
|
||||||
|
// One app-wide WebSocket for the live feed (booth + any live widget) — but ONLY
|
||||||
|
// for roles the server would accept (routes/ws.ts gates on report:read). A
|
||||||
|
// merchant validator must not even attempt it: the 403'd upgrade would reconnect
|
||||||
|
// on backoff forever and spam the server log. Same rule for the widgets that feed
|
||||||
|
// off it (StatusDot) or make their own gated calls (ShiftButton → shift:read,
|
||||||
|
// DeviceFooter → device:read).
|
||||||
|
const canWatch = show("report:read");
|
||||||
|
useLiveFeed(canWatch);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen flex-col bg-term-bg text-term-text">
|
<div className="flex h-screen flex-col bg-term-bg text-term-text">
|
||||||
<header className="flex items-center gap-4 border-b border-term-border bg-term-panel px-4 py-2">
|
<header className="flex items-center gap-4 border-b border-term-border bg-term-panel px-4 py-2">
|
||||||
<span className="text-sm font-bold uppercase tracking-widest text-term-amber">▮ Parking</span>
|
<span className="text-sm font-bold uppercase tracking-widest text-term-amber">▮ Parking</span>
|
||||||
<nav className="flex items-center gap-1">
|
<nav className="flex items-center gap-1">
|
||||||
<NavLink to="/booth" label={t("nav.booth")} />
|
{show("session:read") && <NavLink to="/booth" label={t("nav.booth")} />}
|
||||||
<NavLink to="/shifts" label={t("nav.shifts")} />
|
{show("shift:read") && <NavLink to="/shifts" label={t("nav.shifts")} />}
|
||||||
|
{/* The merchant's (bar/lavazh) scan-and-validate screen. Their typical role
|
||||||
|
grants ONLY validation:create, so this is often their whole nav. */}
|
||||||
|
{show("validation:create") && <NavLink to="/validate" label={t("nav.validate")} />}
|
||||||
{/* Drawer — record cash movements (operator) / review them (admin). Shown if the
|
{/* Drawer — record cash movements (operator) / review them (admin). Shown if the
|
||||||
user can do either. See wiki/concepts/shift.md. */}
|
user can do either. See wiki/concepts/shift.md. */}
|
||||||
{(show("drawer:create") || show("drawer:review")) && (
|
{(show("drawer:create") || show("drawer:review")) && (
|
||||||
@@ -485,11 +583,11 @@ function RootLayout() {
|
|||||||
show("shift:read")) && <NavLink to="/setup" label={t("nav.setup")} />}
|
show("shift:read")) && <NavLink to="/setup" label={t("nav.setup")} />}
|
||||||
</nav>
|
</nav>
|
||||||
<div className="ml-auto flex items-center gap-3">
|
<div className="ml-auto flex items-center gap-3">
|
||||||
{user && <ShiftButton />}
|
{user && show("shift:read") && <ShiftButton />}
|
||||||
{user && <LanguageToggle user={user} setUser={setUser} />}
|
{user && <LanguageToggle user={user} setUser={setUser} />}
|
||||||
{user && <ThemeToggle user={user} setUser={setUser} />}
|
{user && <ThemeToggle user={user} setUser={setUser} />}
|
||||||
{user && <FontScaleToggle user={user} setUser={setUser} />}
|
{user && <FontScaleToggle user={user} setUser={setUser} />}
|
||||||
<StatusDot />
|
{canWatch && <StatusDot />}
|
||||||
{user && (
|
{user && (
|
||||||
<Link
|
<Link
|
||||||
to="/profile"
|
to="/profile"
|
||||||
@@ -514,8 +612,10 @@ function RootLayout() {
|
|||||||
<main className="min-h-0 flex-1 overflow-auto p-3">
|
<main className="min-h-0 flex-1 overflow-auto p-3">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
{/* Fixed device-status footer — relays, readers, cameras, printers. */}
|
{/* Fixed device-status footer — relays, readers, cameras, printers. Its REST
|
||||||
{user && <DeviceFooter />}
|
seed needs device:read (and its live updates ride the report:read WS), so
|
||||||
|
it's hidden for roles without device visibility (e.g. merchant validators). */}
|
||||||
|
{user && show("device:read") && <DeviceFooter />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -523,7 +623,12 @@ function RootLayout() {
|
|||||||
const indexRoute = createRoute({
|
const indexRoute = createRoute({
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
path: "/",
|
path: "/",
|
||||||
beforeLoad: () => {
|
beforeLoad: ({ context }) => {
|
||||||
|
// A merchant-only user (validation:create without the booth's session:read)
|
||||||
|
// lands on their scan-and-validate screen; everyone else on the booth.
|
||||||
|
if (can(context.user, "validation:create") && !can(context.user, "session:read")) {
|
||||||
|
throw redirect({ to: "/validate" });
|
||||||
|
}
|
||||||
throw redirect({ to: "/booth" });
|
throw redirect({ to: "/booth" });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -534,6 +639,20 @@ const boothRoute = createRoute({
|
|||||||
component: BoothScreen,
|
component: BoothScreen,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The merchant (bar/lavazh) scan-and-validate screen — usually the ONLY page a
|
||||||
|
// merchant user's role can reach. The server enforces the program↔user binding on
|
||||||
|
// apply; this gate is defence in depth. See wiki/concepts/validation-discounts.md.
|
||||||
|
const validateRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: "/validate",
|
||||||
|
beforeLoad: ({ context }) => requirePerm("validation:create")(context),
|
||||||
|
component: function ValidateRoute() {
|
||||||
|
const { user } = rootRoute.useRouteContext();
|
||||||
|
if (!user) return null;
|
||||||
|
return <ValidateScreen user={user} />;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// Back-compat redirects for paths that moved. Most config screens live under /setup;
|
// Back-compat redirects for paths that moved. Most config screens live under /setup;
|
||||||
// Subscriptions/Plans/Tariff-Lab were promoted OUT of /setup into the standalone
|
// Subscriptions/Plans/Tariff-Lab were promoted OUT of /setup into the standalone
|
||||||
// /subscriptions section (2026-06-21) — redirect the old /setup/* paths too so existing
|
// /subscriptions section (2026-06-21) — redirect the old /setup/* paths too so existing
|
||||||
@@ -779,6 +898,7 @@ const profileRoute = createRoute({
|
|||||||
const routeTree = rootRoute.addChildren([
|
const routeTree = rootRoute.addChildren([
|
||||||
indexRoute,
|
indexRoute,
|
||||||
boothRoute,
|
boothRoute,
|
||||||
|
validateRoute,
|
||||||
...legacyRedirects,
|
...legacyRedirects,
|
||||||
profileRoute,
|
profileRoute,
|
||||||
shiftRoute,
|
shiftRoute,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { fetchDeviceStatus, type DeviceStatus } from "../api.js";
|
import { fetchDeviceStatus, type DeviceStatus } from "../api.js";
|
||||||
|
import { formatClock } from "../lib/format.js";
|
||||||
import { qk } from "../lib/query.js";
|
import { qk } from "../lib/query.js";
|
||||||
import { useLiveStore } from "../lib/live-store.js";
|
import { useLiveStore } from "../lib/live-store.js";
|
||||||
|
|
||||||
@@ -184,7 +185,7 @@ export function DeviceFooter() {
|
|||||||
</div>
|
</div>
|
||||||
{d.detail && <div className="mt-0.5 break-words text-[0.6875rem] text-term-muted">{d.detail}</div>}
|
{d.detail && <div className="mt-0.5 break-words text-[0.6875rem] text-term-muted">{d.detail}</div>}
|
||||||
<div className="mt-0.5 text-[0.625rem] tabular-nums text-term-muted/70">
|
<div className="mt-0.5 text-[0.625rem] tabular-nums text-term-muted/70">
|
||||||
{t("devices.checkedAt", { time: new Date(d.checkedAt).toLocaleTimeString() })}
|
{t("devices.checkedAt", { time: formatClock(d.checkedAt) })}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { formatDateTime } from "../lib/format.js";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { fetchSnapshots, snapshotImageUrl, type PlateRead } from "../api.js";
|
import { fetchSnapshots, snapshotImageUrl, type PlateRead } from "../api.js";
|
||||||
|
|
||||||
@@ -52,7 +53,7 @@ export function SnapshotStrip({ identity }: { identity: string }) {
|
|||||||
key={`${p.plate}-${p.direction}-${i}`}
|
key={`${p.plate}-${p.direction}-${i}`}
|
||||||
className="inline-flex items-center gap-1.5 rounded-term border border-term-cyan/40 bg-term-cyan/10 px-2 py-0.5 text-[0.6875rem]"
|
className="inline-flex items-center gap-1.5 rounded-term border border-term-cyan/40 bg-term-cyan/10 px-2 py-0.5 text-[0.6875rem]"
|
||||||
title={`${dirLabel(p.direction)}${p.region ? ` · ${p.region}` : ""}${
|
title={`${dirLabel(p.direction)}${p.region ? ` · ${p.region}` : ""}${
|
||||||
p.at ? ` · ${new Date(p.at).toLocaleString()}` : ""
|
p.at ? ` · ${formatDateTime(p.at, t)}` : ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="text-[0.5625rem] uppercase tracking-wider text-term-muted">{t("pay.plate")}</span>
|
<span className="text-[0.5625rem] uppercase tracking-wider text-term-muted">{t("pay.plate")}</span>
|
||||||
@@ -72,7 +73,7 @@ export function SnapshotStrip({ identity }: { identity: string }) {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => setZoom(s.id)}
|
onClick={() => setZoom(s.id)}
|
||||||
className="group flex flex-col items-center gap-1 rounded-term border border-term-border bg-term-panel-2 p-1 hover:border-term-amber"
|
className="group flex flex-col items-center gap-1 rounded-term border border-term-border bg-term-panel-2 p-1 hover:border-term-amber"
|
||||||
title={`${dirLabel(s.direction)} · ${new Date(s.capturedAt).toLocaleString()}`}
|
title={`${dirLabel(s.direction)} · ${formatDateTime(s.capturedAt, t)}`}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={snapshotImageUrl(s.id)}
|
src={snapshotImageUrl(s.id)}
|
||||||
@@ -97,7 +98,7 @@ export function SnapshotStrip({ identity }: { identity: string }) {
|
|||||||
<div
|
<div
|
||||||
key={`fail-${f.direction ?? "both"}-${i}`}
|
key={`fail-${f.direction ?? "both"}-${i}`}
|
||||||
className="flex h-[6.75rem] w-28 flex-col items-center justify-center gap-1 rounded-term border border-dashed border-term-amber/60 bg-term-amber/5 p-1 text-center"
|
className="flex h-[6.75rem] w-28 flex-col items-center justify-center gap-1 rounded-term border border-dashed border-term-amber/60 bg-term-amber/5 p-1 text-center"
|
||||||
title={`${dirLabel(f.direction)} · ${f.error}${f.occurredAt ? ` · ${new Date(f.occurredAt).toLocaleString()}` : ""}`}
|
title={`${dirLabel(f.direction)} · ${f.error}${f.occurredAt ? ` · ${formatDateTime(f.occurredAt, t)}` : ""}`}
|
||||||
>
|
>
|
||||||
<span className="text-lg leading-none text-term-amber">⚠</span>
|
<span className="text-lg leading-none text-term-amber">⚠</span>
|
||||||
<span className="text-[0.5625rem] uppercase tracking-wider text-term-amber">{dirLabel(f.direction)}</span>
|
<span className="text-[0.5625rem] uppercase tracking-wider text-term-amber">{dirLabel(f.direction)}</span>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { type ReactNode } from "react";
|
import { type ReactNode } from "react";
|
||||||
import { type LedgerEvent } from "../api.js";
|
import { type LedgerEvent } from "../api.js";
|
||||||
import { formatMoney } from "../lib/format.js";
|
import { formatMoney, formatDateTime } from "../lib/format.js";
|
||||||
import { renderReason } from "../lib/reason.js";
|
import { renderReason } from "../lib/reason.js";
|
||||||
import { Modal } from "./Modal.js";
|
import { Modal } from "./Modal.js";
|
||||||
import { SnapshotStrip } from "./SnapshotStrip.js";
|
import { SnapshotStrip } from "./SnapshotStrip.js";
|
||||||
@@ -25,6 +25,7 @@ export const EVENT_STYLE: Record<string, { labelKey: string; color: string }> =
|
|||||||
cash_out: { labelKey: "booth.evtCashOut", color: "text-term-amber" },
|
cash_out: { labelKey: "booth.evtCashOut", color: "text-term-amber" },
|
||||||
cash_review: { labelKey: "booth.evtCashReview", color: "text-term-cyan" },
|
cash_review: { labelKey: "booth.evtCashReview", color: "text-term-cyan" },
|
||||||
config_change: { labelKey: "booth.evtConfigChange", color: "text-term-amber" },
|
config_change: { labelKey: "booth.evtConfigChange", color: "text-term-amber" },
|
||||||
|
validation: { labelKey: "booth.evtValidation", color: "text-term-green" },
|
||||||
anomaly: { labelKey: "booth.evtAnomaly", color: "text-term-red" },
|
anomaly: { labelKey: "booth.evtAnomaly", color: "text-term-red" },
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -226,7 +227,7 @@ export function EventDetailModal({ e, onClose }: { e: LedgerEvent; onClose: () =
|
|||||||
|
|
||||||
{/* Humanized fields — labelled rows, not raw JSON. Only what applies renders. */}
|
{/* Humanized fields — labelled rows, not raw JSON. Only what applies renders. */}
|
||||||
<div>
|
<div>
|
||||||
<DetailRow label={t("booth.edTime")}>{new Date(e.occurredAt).toLocaleString()}</DetailRow>
|
<DetailRow label={t("booth.edTime")}>{formatDateTime(e.occurredAt, t, { seconds: true })}</DetailRow>
|
||||||
<DetailRow label={t("booth.edIndex")}>#{e.index}</DetailRow>
|
<DetailRow label={t("booth.edIndex")}>#{e.index}</DetailRow>
|
||||||
{e.direction && <DetailRow label={t("booth.edDirection")}>{e.direction}</DetailRow>}
|
{e.direction && <DetailRow label={t("booth.edDirection")}>{e.direction}</DetailRow>}
|
||||||
{e.source && <DetailRow label={t("booth.edSource")}>{e.source}</DetailRow>}
|
{e.source && <DetailRow label={t("booth.edSource")}>{e.source}</DetailRow>}
|
||||||
|
|||||||
+38
-2
@@ -49,11 +49,47 @@ REGISTRY=git.infra.msai.al/mca/parking_solution
|
|||||||
# Staging booth: pinned immutable stage-<sha>. After each promotion (merge dev → stage, CI builds
|
# Staging booth: pinned immutable stage-<sha>. After each promotion (merge dev → stage, CI builds
|
||||||
# :stage-<sha>), bump this to the new sha and re-sync/deploy from Core. The moving `:stage` tag
|
# :stage-<sha>), bump this to the new sha and re-sync/deploy from Core. The moving `:stage` tag
|
||||||
# exists as the pointer; we deploy the sha, not the mover.
|
# exists as the pointer; we deploy the sha, not the mover.
|
||||||
TAG=stage-14638c2
|
TAG=stage-5c6a21e
|
||||||
COOKIE_SECURE=0
|
COOKIE_SECURE=0
|
||||||
VISION_ENABLED=1
|
VISION_ENABLED=1
|
||||||
WS_ALLOWED_ORIGINS=
|
# Desktop app WS handshake: Origin is tauri://localhost (set explicitly by
|
||||||
|
# platform-ws.ts, since the native WS plugin has no page context to auto-attach
|
||||||
|
# one). Linux may also send http://tauri.localhost. See routes/ws.ts anti-CSWSH check.
|
||||||
|
WS_ALLOWED_ORIGINS=tauri://localhost,http://tauri.localhost
|
||||||
JWT_SECRET=[[park_buzi_jwt_secret]]
|
JWT_SECRET=[[park_buzi_jwt_secret]]
|
||||||
EVENT_SIGNING_KEY=[[park_buzi_event_signing_key]]
|
EVENT_SIGNING_KEY=[[park_buzi_event_signing_key]]
|
||||||
BACKUP_KEY=[[park_buzi_backup_key]]
|
BACKUP_KEY=[[park_buzi_backup_key]]
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
##############################################################################
|
||||||
|
|
||||||
|
[[stack]]
|
||||||
|
name = "park-2"
|
||||||
|
[stack.config]
|
||||||
|
server = "park-2"
|
||||||
|
git_provider = "git.infra.msai.al"
|
||||||
|
git_account = "komodo"
|
||||||
|
repo = "mca/parking_solution"
|
||||||
|
branch = "stage"
|
||||||
|
file_paths = [
|
||||||
|
"docker-compose.yml",
|
||||||
|
"docker-compose.prod.yml"
|
||||||
|
]
|
||||||
|
registry_provider = "git.infra.msai.al"
|
||||||
|
registry_account = "komodo"
|
||||||
|
environment = """
|
||||||
|
REGISTRY=git.infra.msai.al/mca/parking_solution
|
||||||
|
# Staging booth: pinned immutable stage-<sha>. After each promotion (merge dev → stage, CI builds
|
||||||
|
# :stage-<sha>), bump this to the new sha and re-sync/deploy from Core. The moving `:stage` tag
|
||||||
|
# exists as the pointer; we deploy the sha, not the mover.
|
||||||
|
TAG=stage-5c6a21e
|
||||||
|
COOKIE_SECURE=0
|
||||||
|
VISION_ENABLED=1
|
||||||
|
# Desktop app WS handshake: Origin is tauri://localhost (set explicitly by
|
||||||
|
# platform-ws.ts, since the native WS plugin has no page context to auto-attach
|
||||||
|
# one). Linux may also send http://tauri.localhost. See routes/ws.ts anti-CSWSH check.
|
||||||
|
WS_ALLOWED_ORIGINS=tauri://localhost,http://tauri.localhost
|
||||||
|
JWT_SECRET=[[park_2_jwt_secret]]
|
||||||
|
EVENT_SIGNING_KEY=[[park_2_event_signing_key]]
|
||||||
|
BACKUP_KEY=[[park_2_backup_key]]
|
||||||
|
"""
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- Driver rename (2026-07-06): "cashino" → "escpos". The driver was always the GENERIC
|
||||||
|
-- ESC/POS printer driver (reachability-only clones); it carried the first unit's vendor
|
||||||
|
-- name, which read as misleading in the setup UI once other clones (ICS/Xprinter
|
||||||
|
-- XP-K200L) used it. Rewrite stored device rows; the registry also keeps a permanent
|
||||||
|
-- cashino→escpos alias so restored pre-rename backups still resolve.
|
||||||
|
UPDATE `devices` SET `driver_id` = 'escpos' WHERE `driver_id` = 'cashino';
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
-- Merchant validation programs (2026-07-13). In-park merchants (bar / lavazh) validate a
|
||||||
|
-- customer's ticket so the BOOTH settlement discounts the fee — the merchant only
|
||||||
|
-- validates, all money and paper stay at the booth. The /setup/site checkboxes toggle the
|
||||||
|
-- WELL-KNOWN rows ("bar", "lavazh"); a future merchant is a new row, not a migration.
|
||||||
|
-- Config is plainly MUTABLE (no versioning): the applied validation is a signed ledger
|
||||||
|
-- event carrying the RESOLVED values, so reproducibility never depends on these rows.
|
||||||
|
-- See wiki/concepts/validation-discounts.md.
|
||||||
|
CREATE TABLE `validation_programs` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`name` text NOT NULL,
|
||||||
|
`mode` text DEFAULT 'comp' NOT NULL,
|
||||||
|
`minutes` integer,
|
||||||
|
`percent` integer,
|
||||||
|
`max_amount_minor` integer,
|
||||||
|
`max_per_day` integer,
|
||||||
|
`active` integer DEFAULT 0 NOT NULL,
|
||||||
|
`created_at` text DEFAULT (current_timestamp) NOT NULL,
|
||||||
|
`deleted_at` text,
|
||||||
|
`deleted_by` text
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
-- WHICH users may apply a program: the apply guard is `validation:create` AND a binding
|
||||||
|
-- row here — a bar user can never apply the lavazh program.
|
||||||
|
CREATE TABLE `validation_program_users` (
|
||||||
|
`program_id` text NOT NULL,
|
||||||
|
`user_id` text NOT NULL,
|
||||||
|
FOREIGN KEY (`program_id`) REFERENCES `validation_programs`(`id`) ON UPDATE no action ON DELETE no action,
|
||||||
|
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `validation_program_users_program_id_user_id_unique` ON `validation_program_users` (`program_id`,`user_id`);
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
-- Last-success/last-error for the encrypted DB backup were previously tracked only as
|
||||||
|
-- in-process fields on BackupService (never written to the DB) — so every server restart
|
||||||
|
-- (deploy/crash/OOM/host reboot, all routine under `restart: always`) silently reset the admin
|
||||||
|
-- UI's "last successful backup" to "Never", even with valid, correctly-rotating backups already
|
||||||
|
-- on disk (2026-08-30 field incident, park-buzi). Four additive, nullable columns; null = no
|
||||||
|
-- run recorded yet (or, for the error pair, no failure since the last success). See
|
||||||
|
-- wiki/concepts/backup-recovery.md.
|
||||||
|
ALTER TABLE `site_config` ADD `backup_last_success_at` text;--> statement-breakpoint
|
||||||
|
ALTER TABLE `site_config` ADD `backup_last_result_json` text;--> statement-breakpoint
|
||||||
|
ALTER TABLE `site_config` ADD `backup_last_error_at` text;--> statement-breakpoint
|
||||||
|
ALTER TABLE `site_config` ADD `backup_last_error` text;
|
||||||
@@ -162,6 +162,27 @@
|
|||||||
"when": 1781886500000,
|
"when": 1781886500000,
|
||||||
"tag": "0022_tariff_version_name",
|
"tag": "0022_tariff_version_name",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 23,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1781886600000,
|
||||||
|
"tag": "0023_driver_id_escpos",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 24,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1783948800000,
|
||||||
|
"tag": "0024_validation_programs",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 25,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1788078414270,
|
||||||
|
"tag": "0025_backup_last_status",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -17,9 +17,17 @@
|
|||||||
// their credentials/plates, blocklist. KEEPS users, devices, config,
|
// their credentials/plates, blocklist. KEEPS users, devices, config,
|
||||||
// tariffs, subscription PLANS.
|
// tariffs, subscription PLANS.
|
||||||
// --config site_config, devices, setup_state (re-runs first-run setup),
|
// --config site_config, devices, setup_state (re-runs first-run setup),
|
||||||
// tariffs + tariff_versions, subscription_plans.
|
// tariffs + tariff_versions + tariff_drafts, subscription_plans.
|
||||||
// --users users, roles, role_permissions, auth sessions. (After this or --all,
|
// --users users, roles, role_permissions, auth sessions. (After this or --all,
|
||||||
// re-seed an admin: apps/server/scripts/seed-admin.mjs.)
|
// re-seed an admin: apps/server/scripts/seed-admin.mjs.)
|
||||||
|
// --diagnostics app_logs (the unsigned diagnostic store behind the /setup/logs
|
||||||
|
// viewer). Separate from --financial: logs are evidence about the BOX,
|
||||||
|
// not the traffic — wipe them only when handing over a blank slate.
|
||||||
|
//
|
||||||
|
// DRIFT GUARD: before doing anything, the script compares the union of the categories
|
||||||
|
// above against the tables actually present in the DB and REFUSES if any table is
|
||||||
|
// uncategorized — so a new table can't silently survive resets (app_logs and
|
||||||
|
// tariff_drafts did exactly that until 2026-07-08).
|
||||||
//
|
//
|
||||||
// Safety gates (BOTH required):
|
// Safety gates (BOTH required):
|
||||||
// 1. env RESET_ALLOWED=1 — a real booth never sets this.
|
// 1. env RESET_ALLOWED=1 — a real booth never sets this.
|
||||||
@@ -44,10 +52,44 @@ const CATEGORIES = {
|
|||||||
"subscriptions",
|
"subscriptions",
|
||||||
"blocklist",
|
"blocklist",
|
||||||
],
|
],
|
||||||
config: ["site_config", "devices", "setup_state", "tariff_versions", "tariffs", "subscription_plans"],
|
config: [
|
||||||
|
"site_config",
|
||||||
|
"devices",
|
||||||
|
"setup_state",
|
||||||
|
"tariff_drafts",
|
||||||
|
"tariff_versions",
|
||||||
|
"tariffs",
|
||||||
|
"subscription_plans",
|
||||||
|
// Merchant validation programs (bar/lavazh) + their user bindings (child first).
|
||||||
|
// A --users reset without --config may orphan a binding row; harmless — a binding
|
||||||
|
// whose user is gone grants nothing.
|
||||||
|
"validation_program_users",
|
||||||
|
"validation_programs",
|
||||||
|
],
|
||||||
users: ["sessions", "role_permissions", "users", "roles"],
|
users: ["sessions", "role_permissions", "users", "roles"],
|
||||||
|
diagnostics: ["app_logs"],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Every user table in the DB must belong to a category above (internal bookkeeping
|
||||||
|
* like sqlite_* and drizzle's __* migration table excepted). Dies listing offenders —
|
||||||
|
* the fix is a one-line addition to CATEGORIES, decided deliberately, not by omission. */
|
||||||
|
function assertNoUncategorizedTables(sqlite) {
|
||||||
|
const known = new Set(Object.values(CATEGORIES).flat());
|
||||||
|
const actual = sqlite
|
||||||
|
.prepare(`SELECT name FROM sqlite_master WHERE type = 'table'`)
|
||||||
|
.all()
|
||||||
|
.map((r) => r.name)
|
||||||
|
.filter((n) => !n.startsWith("sqlite_") && !n.startsWith("__"));
|
||||||
|
const uncategorized = actual.filter((n) => !known.has(n));
|
||||||
|
if (uncategorized.length > 0) {
|
||||||
|
die(
|
||||||
|
`schema drift — table(s) not covered by any reset category: ${uncategorized.join(", ")}\n` +
|
||||||
|
` add them to CATEGORIES in packages/db/scripts/reset-db.mjs (this guard exists so\n` +
|
||||||
|
` new tables can't silently survive resets).`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function parseArgs(argv) {
|
function parseArgs(argv) {
|
||||||
const flags = new Set(argv.filter((a) => a.startsWith("--")).map((a) => a.slice(2)));
|
const flags = new Set(argv.filter((a) => a.startsWith("--")).map((a) => a.slice(2)));
|
||||||
const wantAll = flags.has("all");
|
const wantAll = flags.has("all");
|
||||||
@@ -75,7 +117,7 @@ async function main() {
|
|||||||
|
|
||||||
const { cats, autoYes, wantAll } = parseArgs(process.argv.slice(2));
|
const { cats, autoYes, wantAll } = parseArgs(process.argv.slice(2));
|
||||||
if (cats.length === 0) {
|
if (cats.length === 0) {
|
||||||
die("nothing to do — pass --all, --financial, --config, and/or --users");
|
die("nothing to do — pass --all, --financial, --config, --users, and/or --diagnostics");
|
||||||
}
|
}
|
||||||
|
|
||||||
// GATE 1: env opt-in. A production booth never sets this.
|
// GATE 1: env opt-in. A production booth never sets this.
|
||||||
@@ -86,6 +128,11 @@ async function main() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Open early: the drift guard must run BEFORE anything is printed or confirmed, so
|
||||||
|
// an uncategorized table aborts the whole run rather than surviving a "successful" reset.
|
||||||
|
const sqlite = new Database(dbPath);
|
||||||
|
assertNoUncategorizedTables(sqlite);
|
||||||
|
|
||||||
// Resolve the ordered, de-duplicated table list for the chosen categories.
|
// Resolve the ordered, de-duplicated table list for the chosen categories.
|
||||||
const tables = [];
|
const tables = [];
|
||||||
for (const c of cats) for (const t of CATEGORIES[c]) if (!tables.includes(t)) tables.push(t);
|
for (const c of cats) for (const t of CATEGORIES[c]) if (!tables.includes(t)) tables.push(t);
|
||||||
@@ -106,7 +153,6 @@ async function main() {
|
|||||||
if (!ok) die("confirmation did not match — aborted, nothing changed.");
|
if (!ok) die("confirmation did not match — aborted, nothing changed.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const sqlite = new Database(dbPath);
|
|
||||||
try {
|
try {
|
||||||
// FKs OFF for the wipe so we can delete in any order without ordering hazards;
|
// FKs OFF for the wipe so we can delete in any order without ordering hazards;
|
||||||
// a single transaction makes it all-or-nothing.
|
// a single transaction makes it all-or-nothing.
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import * as schema from "./schema.js";
|
|||||||
export * from "./schema.js";
|
export * from "./schema.js";
|
||||||
// Re-export the query helpers consumers need, so they don't depend on
|
// Re-export the query helpers consumers need, so they don't depend on
|
||||||
// drizzle-orm directly (it's an implementation detail of this package).
|
// drizzle-orm directly (it's an implementation detail of this package).
|
||||||
export { eq, ne, and, or, asc, desc, gte, lte, isNull, isNotNull, inArray, sql } from "drizzle-orm";
|
export { eq, ne, and, or, asc, desc, gt, gte, lt, lte, isNull, isNotNull, inArray, sql } from "drizzle-orm";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Open the local SQLite database in WAL mode. WAL allows many concurrent readers
|
* Open the local SQLite database in WAL mode. WAL allows many concurrent readers
|
||||||
|
|||||||
@@ -288,6 +288,20 @@ export const siteConfig = sqliteTable("site_config", {
|
|||||||
backupKeepLast: integer("backup_keep_last"),
|
backupKeepLast: integer("backup_keep_last"),
|
||||||
/** Beyond keepLast, keep one backup per day for this many days. null ⇒ code default (30). */
|
/** Beyond keepLast, keep one backup per day for this many days. null ⇒ code default (30). */
|
||||||
backupKeepDailyDays: integer("backup_keep_daily_days"),
|
backupKeepDailyDays: integer("backup_keep_daily_days"),
|
||||||
|
/** ISO timestamp of the last backup that actually completed successfully. Persisted here
|
||||||
|
* (not just in-process memory) so the admin UI's "last successful backup" survives a
|
||||||
|
* server restart — before this column existed, a restart silently reset that status to
|
||||||
|
* "Never" even with valid backups already on disk. null = no successful run recorded yet.
|
||||||
|
* See wiki/concepts/backup-recovery.md. */
|
||||||
|
backupLastSuccessAt: text("backup_last_success_at"),
|
||||||
|
/** JSON-encoded { path, bytes, prunedFiles } of the last successful run, for the same
|
||||||
|
* restart-durability reason as backupLastSuccessAt. null = none recorded yet. */
|
||||||
|
backupLastResultJson: text("backup_last_result_json"),
|
||||||
|
/** ISO timestamp of the last FAILED scheduled/manual backup attempt, persisted for the same
|
||||||
|
* reason. null = no failure recorded (or none since the last success). */
|
||||||
|
backupLastErrorAt: text("backup_last_error_at"),
|
||||||
|
/** Error message of the last failed attempt. Cleared (set null) on the next success. */
|
||||||
|
backupLastError: text("backup_last_error"),
|
||||||
updatedAt: text("updated_at")
|
updatedAt: text("updated_at")
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(sql`(current_timestamp)`),
|
.default(sql`(current_timestamp)`),
|
||||||
@@ -458,6 +472,57 @@ export const subscriptionPlates = sqliteTable("subscription_plates", {
|
|||||||
plate: text("plate").notNull(),
|
plate: text("plate").notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Merchant validation programs (bar / lavazh) --------------------------
|
||||||
|
// Admin-composed master data for in-park merchant discounts: the /setup/site
|
||||||
|
// checkboxes toggle the WELL-KNOWN rows ("bar", "lavazh") — a future merchant is a
|
||||||
|
// new row, not a migration. Config is plainly MUTABLE (no versioning): the applied
|
||||||
|
// validation is a signed ledger event carrying the RESOLVED values, so historical
|
||||||
|
// reproducibility never depends on this row. Enabling/saving signs a config_change.
|
||||||
|
// See wiki/concepts/validation-discounts.md.
|
||||||
|
export const validationPrograms = sqliteTable("validation_programs", {
|
||||||
|
// Well-known slug ("bar" | "lavazh"); generic text so future merchants are rows.
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
// Receipt label printed on the booth settlement line (e.g. "Lavazh — 1 orë falas").
|
||||||
|
name: text("name").notNull(),
|
||||||
|
// How the program discounts — see @parking/shared ValidationMode.
|
||||||
|
mode: text("mode", { enum: ["comp", "timeCredit", "fixed", "percent"] })
|
||||||
|
.notNull()
|
||||||
|
.default("comp"),
|
||||||
|
// timeCredit: the free minutes.
|
||||||
|
minutes: integer("minutes"),
|
||||||
|
// percent: 1..100 off the fee.
|
||||||
|
percent: integer("percent"),
|
||||||
|
// fixed: cap on the amount the merchant may type at scan time (minor units).
|
||||||
|
maxAmountMinor: integer("max_amount_minor"),
|
||||||
|
// Anti-abuse cap: max applications per local day (null = unlimited).
|
||||||
|
maxPerDay: integer("max_per_day"),
|
||||||
|
// The /setup/site checkbox. Inactive = merchants can't apply it (row + history kept).
|
||||||
|
active: integer("active", { mode: "boolean" }).notNull().default(false),
|
||||||
|
createdAt: text("created_at")
|
||||||
|
.notNull()
|
||||||
|
.default(sql`(current_timestamp)`),
|
||||||
|
// Soft delete (recycle bin) — see roles.deletedAt.
|
||||||
|
deletedAt: text("deleted_at"),
|
||||||
|
deletedBy: text("deleted_by"),
|
||||||
|
});
|
||||||
|
|
||||||
|
// The program↔user binding: WHICH users may apply a program (the guard is
|
||||||
|
// `validation:create` AND a binding row — a bar user can never apply lavazh).
|
||||||
|
export const validationProgramUsers = sqliteTable(
|
||||||
|
"validation_program_users",
|
||||||
|
{
|
||||||
|
programId: text("program_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => validationPrograms.id),
|
||||||
|
userId: text("user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id),
|
||||||
|
},
|
||||||
|
(t) => ({
|
||||||
|
uniq: unique().on(t.programId, t.userId),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
// --- Blocklist (banlist) -------------------------------------------------
|
// --- Blocklist (banlist) -------------------------------------------------
|
||||||
// Plates/cards refused at ENTRY (never at exit — never trap a vehicle). A hit appends
|
// Plates/cards refused at ENTRY (never at exit — never trap a vehicle). A hit appends
|
||||||
// a signed anomaly/refused-entry ledger event. See wiki/entities/blocklist.md.
|
// a signed anomaly/refused-entry ledger event. See wiki/entities/blocklist.md.
|
||||||
@@ -546,5 +611,7 @@ export type SubscriptionPlanRow = typeof subscriptionPlans.$inferSelect;
|
|||||||
export type SubscriptionCredentialRow = typeof subscriptionCredentials.$inferSelect;
|
export type SubscriptionCredentialRow = typeof subscriptionCredentials.$inferSelect;
|
||||||
export type SubscriptionPlateRow = typeof subscriptionPlates.$inferSelect;
|
export type SubscriptionPlateRow = typeof subscriptionPlates.$inferSelect;
|
||||||
export type BlocklistRow = typeof blocklist.$inferSelect;
|
export type BlocklistRow = typeof blocklist.$inferSelect;
|
||||||
|
export type ValidationProgramRow = typeof validationPrograms.$inferSelect;
|
||||||
|
export type ValidationProgramUserRow = typeof validationProgramUsers.$inferSelect;
|
||||||
export type SessionRow = typeof sessions.$inferSelect;
|
export type SessionRow = typeof sessions.$inferSelect;
|
||||||
export type AppLogRow = typeof appLogs.$inferSelect;
|
export type AppLogRow = typeof appLogs.$inferSelect;
|
||||||
|
|||||||
@@ -7,7 +7,11 @@ import type { DigestGetResult } from "./http-digest.js";
|
|||||||
// fail FAST on a config error (401 auth / 404 path). See camera.ts.
|
// fail FAST on a config error (401 auth / 404 path). See camera.ts.
|
||||||
|
|
||||||
const digestGet = vi.fn<(...a: unknown[]) => Promise<DigestGetResult>>();
|
const digestGet = vi.fn<(...a: unknown[]) => Promise<DigestGetResult>>();
|
||||||
vi.mock("./http-digest.js", () => ({ digestGet: (...a: unknown[]) => digestGet(...a) }));
|
const digestRequest = vi.fn<(...a: unknown[]) => Promise<DigestGetResult>>();
|
||||||
|
vi.mock("./http-digest.js", () => ({
|
||||||
|
digestGet: (...a: unknown[]) => digestGet(...a),
|
||||||
|
digestRequest: (...a: unknown[]) => digestRequest(...a),
|
||||||
|
}));
|
||||||
|
|
||||||
// Import the driver AFTER the mock is registered.
|
// Import the driver AFTER the mock is registered.
|
||||||
const { hikvisionDriver } = await import("./camera.js");
|
const { hikvisionDriver } = await import("./camera.js");
|
||||||
@@ -22,6 +26,7 @@ function makeCamera() {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
digestGet.mockReset();
|
digestGet.mockReset();
|
||||||
|
digestRequest.mockReset();
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
});
|
});
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -143,3 +148,64 @@ describe("healthCheck detail is a STABLE size bucket (log-noise fix, 2026-07-05)
|
|||||||
expect(tiny.detail).toBe("snapshot <1 KB");
|
expect(tiny.detail).toBe("snapshot <1 KB");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("hikvision syncClock — the 1970 power-cut recovery (ISAPI /System/time)", () => {
|
||||||
|
const timeXml = (localTime: string) =>
|
||||||
|
reply(
|
||||||
|
200,
|
||||||
|
`<?xml version="1.0"?><Time><timeMode>NTP</timeMode><localTime>${localTime}</localTime><timeZone>CST-2:00:00DST01:00:00</timeZone></Time>`,
|
||||||
|
);
|
||||||
|
const HOST_NOW = "2026-07-07T12:00:00+02:00";
|
||||||
|
|
||||||
|
function cam() {
|
||||||
|
return makeCamera() as unknown as {
|
||||||
|
syncClock(localIso: string, maxDriftSec: number): Promise<{ driftSeconds: number | null; synced: boolean }>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it("in-sync camera: reads, does NOT set", async () => {
|
||||||
|
digestRequest.mockResolvedValueOnce(timeXml("2026-07-07T12:00:10+02:00"));
|
||||||
|
const r = await cam().syncClock(HOST_NOW, 60);
|
||||||
|
expect(r).toEqual({ driftSeconds: 10, synced: false });
|
||||||
|
expect(digestRequest).toHaveBeenCalledTimes(1); // GET only
|
||||||
|
});
|
||||||
|
|
||||||
|
it("1970 camera: PUTs manual time with the host instant, echoing the camera's timeZone", async () => {
|
||||||
|
digestRequest
|
||||||
|
.mockResolvedValueOnce(timeXml("1970-01-01T03:12:44+01:00"))
|
||||||
|
.mockResolvedValueOnce(reply(200, "<ResponseStatus/>"));
|
||||||
|
const r = await cam().syncClock(HOST_NOW, 60);
|
||||||
|
expect(r.synced).toBe(true);
|
||||||
|
expect(r.driftSeconds).toBeGreaterThan(1_000_000_000); // ~56 years
|
||||||
|
const put = digestRequest.mock.calls[1]![0] as { method: string; path: string; body: string };
|
||||||
|
expect(put.method).toBe("PUT");
|
||||||
|
expect(put.path).toBe("/ISAPI/System/time");
|
||||||
|
expect(put.body).toContain("<timeMode>manual</timeMode>");
|
||||||
|
expect(put.body).toContain(`<localTime>${HOST_NOW}</localTime>`);
|
||||||
|
expect(put.body).toContain("<timeZone>CST-2:00:00DST01:00:00</timeZone>"); // echoed, never invented
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unparseable camera time = infinite drift → syncs", async () => {
|
||||||
|
digestRequest
|
||||||
|
.mockResolvedValueOnce(reply(200, "<Time><localTime>garbage</localTime></Time>"))
|
||||||
|
.mockResolvedValueOnce(reply(200, "<ResponseStatus/>"));
|
||||||
|
const r = await cam().syncClock(HOST_NOW, 60);
|
||||||
|
expect(r).toEqual({ driftSeconds: null, synced: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a failed set surfaces as an error (monitor logs it, backstop retries)", async () => {
|
||||||
|
digestRequest
|
||||||
|
.mockResolvedValueOnce(timeXml("1970-01-01T01:00:00+01:00"))
|
||||||
|
.mockResolvedValueOnce(reply(403, "denied"));
|
||||||
|
await expect(cam().syncClock(HOST_NOW, 60)).rejects.toThrow("clock set failed: HTTP 403");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the dahua driver does NOT claim the capability (no ISAPI time endpoint)", async () => {
|
||||||
|
const { dahuaDriver, hikvisionDriver } = await import("./camera.js");
|
||||||
|
const { isClockSyncable } = await import("../interfaces.js");
|
||||||
|
const mk = (d: typeof dahuaDriver) =>
|
||||||
|
d.create({ host: "10.0.10.12", port: 80, username: "admin", password: "x", channel: 1 });
|
||||||
|
expect(isClockSyncable(mk(dahuaDriver))).toBe(false);
|
||||||
|
expect(isClockSyncable(mk(hikvisionDriver))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type {
|
import type {
|
||||||
CameraDevice,
|
CameraDevice,
|
||||||
|
ClockSyncResult,
|
||||||
DeviceHealth,
|
DeviceHealth,
|
||||||
Snapshot,
|
Snapshot,
|
||||||
SnapshotContext,
|
SnapshotContext,
|
||||||
@@ -12,7 +13,7 @@ import {
|
|||||||
usernameField,
|
usernameField,
|
||||||
stubLog,
|
stubLog,
|
||||||
} from "./common.js";
|
} from "./common.js";
|
||||||
import { digestGet } from "./http-digest.js";
|
import { digestGet, digestRequest } from "./http-digest.js";
|
||||||
|
|
||||||
// Camera drivers — entry/exit snapshot-on-event. The host pulls a still over
|
// Camera drivers — entry/exit snapshot-on-event. The host pulls a still over
|
||||||
// HTTP when an event fires; the bytes are stored and referenced from the signed
|
// HTTP when an event fires; the bytes are stored and referenced from the signed
|
||||||
@@ -160,6 +161,73 @@ class HttpCamera implements CameraDevice {
|
|||||||
localAddress: this.#localAddress,
|
localAddress: this.#localAddress,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Digest request against an arbitrary device path (ISAPI config reads/writes). */
|
||||||
|
protected isapi(method: "GET" | "PUT", path: string, body?: string) {
|
||||||
|
return digestRequest({
|
||||||
|
host: this.#host,
|
||||||
|
port: this.#port,
|
||||||
|
path,
|
||||||
|
method,
|
||||||
|
body,
|
||||||
|
user: this.#user,
|
||||||
|
password: this.#password,
|
||||||
|
timeoutMs: this.#timeout,
|
||||||
|
localAddress: this.#localAddress,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Hikvision clock sync (ISAPI /System/time) ---------------------------------
|
||||||
|
// These cameras lose their clock on a power cut (no/dead RTC battery): they reboot
|
||||||
|
// at the 1970 epoch and stay there until a human logs into the web UI (which
|
||||||
|
// silently pushes the browser clock). A wrong camera clock corrupts the OSD
|
||||||
|
// timestamp burned into every snapshot — the evidence trail — and the times on
|
||||||
|
// ANPR pushes. So the host (the site's time authority — offline-first, no NTP
|
||||||
|
// dependency) re-syncs the camera over the same Digest-auth ISAPI used for
|
||||||
|
// snapshots. The device monitor calls this at the offline→ready edge (the
|
||||||
|
// power-restored moment) + a daily backstop. See wiki/entities/lpr-camera.md.
|
||||||
|
|
||||||
|
class HikvisionCamera extends HttpCamera {
|
||||||
|
/**
|
||||||
|
* Read the camera clock; when it drifts more than `maxDriftSec` from `localIso`
|
||||||
|
* (the site's wall-clock now, WITH utc offset), set it via
|
||||||
|
* PUT /ISAPI/System/time. The camera's own `timeZone` string is echoed back
|
||||||
|
* verbatim — we correct the CLOCK, never fight the tz/DST config; `localIso`'s
|
||||||
|
* explicit offset makes the instant unambiguous regardless of that config.
|
||||||
|
*/
|
||||||
|
async syncClock(localIso: string, maxDriftSec: number): Promise<ClockSyncResult> {
|
||||||
|
const read = await this.isapi("GET", "/ISAPI/System/time");
|
||||||
|
if (read.status !== 200) {
|
||||||
|
throw new Error(`clock read failed: HTTP ${read.status}`);
|
||||||
|
}
|
||||||
|
const xml = read.body.toString("utf8");
|
||||||
|
const cameraTime = xml.match(/<localTime>([^<]+)<\/localTime>/)?.[1]?.trim() ?? null;
|
||||||
|
const timeZone = xml.match(/<timeZone>([^<]+)<\/timeZone>/)?.[1]?.trim() ?? "";
|
||||||
|
|
||||||
|
// Drift: parse both sides as instants. A camera reply without a UTC offset (or
|
||||||
|
// otherwise unparseable) can't be trusted → treat as infinite drift and sync.
|
||||||
|
const cameraMs = cameraTime ? Date.parse(cameraTime) : NaN;
|
||||||
|
const hostMs = Date.parse(localIso);
|
||||||
|
const driftSeconds = Number.isFinite(cameraMs)
|
||||||
|
? Math.round(Math.abs(hostMs - cameraMs) / 1000)
|
||||||
|
: null;
|
||||||
|
if (driftSeconds != null && driftSeconds <= maxDriftSec) {
|
||||||
|
return { driftSeconds, synced: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const body =
|
||||||
|
`<?xml version="1.0" encoding="UTF-8"?>` +
|
||||||
|
`<Time><timeMode>manual</timeMode><localTime>${localIso}</localTime>` +
|
||||||
|
(timeZone ? `<timeZone>${timeZone}</timeZone>` : "") +
|
||||||
|
`</Time>`;
|
||||||
|
const put = await this.isapi("PUT", "/ISAPI/System/time", body);
|
||||||
|
if (put.status !== 200) {
|
||||||
|
throw new Error(`clock set failed: HTTP ${put.status}`);
|
||||||
|
}
|
||||||
|
stubLog(this.driverId, `clock synced (was ${driftSeconds ?? "unparseable"}s off)`);
|
||||||
|
return { driftSeconds, synced: true };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const channelField: ConfigField = {
|
const channelField: ConfigField = {
|
||||||
@@ -250,7 +318,7 @@ export const hikvisionDriver: CameraDriver = {
|
|||||||
// ISAPI channel id: <channel><stream>, e.g. ch1 main = 101, ch1 sub = 102, ch2 main = 201.
|
// ISAPI channel id: <channel><stream>, e.g. ch1 main = 101, ch1 sub = 102, ch2 main = 201.
|
||||||
// stream 1 → "01" (main), 2 → "02" (sub).
|
// stream 1 → "01" (main), 2 → "02" (sub).
|
||||||
create: (c) =>
|
create: (c) =>
|
||||||
new HttpCamera(
|
new HikvisionCamera(
|
||||||
"hikvision",
|
"hikvision",
|
||||||
c,
|
c,
|
||||||
(ch, stream) => `/ISAPI/Streaming/channels/${ch}0${stream}/picture`,
|
(ch, stream) => `/ISAPI/Streaming/channels/${ch}0${stream}/picture`,
|
||||||
|
|||||||
@@ -73,19 +73,33 @@ export interface DigestGetOptions {
|
|||||||
readonly localAddress?: string;
|
readonly localAddress?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getOnce(
|
/** digestGet + a method and optional body — for ISAPI configuration writes
|
||||||
o: DigestGetOptions,
|
* (e.g. PUT /ISAPI/System/time). The digest handshake is method-aware (HA2
|
||||||
|
* hashes the method), so this generalisation is the real one, not a shortcut. */
|
||||||
|
export interface DigestRequestOptions extends DigestGetOptions {
|
||||||
|
readonly method: "GET" | "PUT" | "POST";
|
||||||
|
readonly body?: Buffer | string;
|
||||||
|
readonly contentType?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestOnce(
|
||||||
|
o: DigestRequestOptions,
|
||||||
authHeader?: string,
|
authHeader?: string,
|
||||||
): Promise<{ res: IncomingMessage; body: Buffer }> {
|
): Promise<{ res: IncomingMessage; body: Buffer }> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
|
const payload = o.body == null ? null : Buffer.isBuffer(o.body) ? o.body : Buffer.from(o.body, "utf8");
|
||||||
const headers: Record<string, string> = {};
|
const headers: Record<string, string> = {};
|
||||||
if (authHeader) headers["authorization"] = authHeader;
|
if (authHeader) headers["authorization"] = authHeader;
|
||||||
|
if (payload) {
|
||||||
|
headers["content-type"] = o.contentType ?? "application/xml";
|
||||||
|
headers["content-length"] = String(payload.length);
|
||||||
|
}
|
||||||
const req = httpRequest(
|
const req = httpRequest(
|
||||||
{
|
{
|
||||||
host: o.host,
|
host: o.host,
|
||||||
port: o.port,
|
port: o.port,
|
||||||
path: o.path,
|
path: o.path,
|
||||||
method: "GET",
|
method: o.method,
|
||||||
timeout: o.timeoutMs,
|
timeout: o.timeoutMs,
|
||||||
localAddress: o.localAddress,
|
localAddress: o.localAddress,
|
||||||
headers,
|
headers,
|
||||||
@@ -97,19 +111,21 @@ function getOnce(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
req.on("error", reject);
|
req.on("error", reject);
|
||||||
req.on("timeout", () => req.destroy(new Error("digest GET timeout")));
|
req.on("timeout", () => req.destroy(new Error(`digest ${o.method} timeout`)));
|
||||||
|
if (payload) req.write(payload);
|
||||||
req.end();
|
req.end();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET a resource with HTTP Digest auth. Does the standard two-shot handshake:
|
* Request a resource with HTTP Digest auth. Does the standard two-shot handshake:
|
||||||
* the first request (no Authorization) draws a 401 + challenge, the second
|
* the first request (no Authorization) draws a 401 + challenge, the second
|
||||||
* carries the computed response. If the server doesn't challenge (200 straight
|
* carries the computed response (the body is sent BOTH times — the challenge shot
|
||||||
|
* needs the same request shape). If the server doesn't challenge (200 straight
|
||||||
* away, or no auth required), the first response is returned as-is.
|
* away, or no auth required), the first response is returned as-is.
|
||||||
*/
|
*/
|
||||||
export async function digestGet(o: DigestGetOptions): Promise<DigestGetResult> {
|
export async function digestRequest(o: DigestRequestOptions): Promise<DigestGetResult> {
|
||||||
const first = await getOnce(o);
|
const first = await requestOnce(o);
|
||||||
if (first.res.statusCode !== 401) {
|
if (first.res.statusCode !== 401) {
|
||||||
return {
|
return {
|
||||||
status: first.res.statusCode ?? 0,
|
status: first.res.statusCode ?? 0,
|
||||||
@@ -129,11 +145,16 @@ export async function digestGet(o: DigestGetOptions): Promise<DigestGetResult> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const challenge = parseChallenge(challengeHeader);
|
const challenge = parseChallenge(challengeHeader);
|
||||||
const auth = buildAuthHeader(challenge, o.user, o.password, "GET", o.path);
|
const auth = buildAuthHeader(challenge, o.user, o.password, o.method, o.path);
|
||||||
const second = await getOnce(o, auth);
|
const second = await requestOnce(o, auth);
|
||||||
return {
|
return {
|
||||||
status: second.res.statusCode ?? 0,
|
status: second.res.statusCode ?? 0,
|
||||||
contentType: String(second.res.headers["content-type"] ?? ""),
|
contentType: String(second.res.headers["content-type"] ?? ""),
|
||||||
body: second.body,
|
body: second.body,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** GET with Digest auth (the original entry point; snapshots and status reads). */
|
||||||
|
export function digestGet(o: DigestGetOptions): Promise<DigestGetResult> {
|
||||||
|
return digestRequest({ ...o, method: "GET" });
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { registry } from "../registry.js";
|
|||||||
import { dingtianDriver } from "./access-dingtian.js";
|
import { dingtianDriver } from "./access-dingtian.js";
|
||||||
import { stubAccessDriver } from "./access-stub.js";
|
import { stubAccessDriver } from "./access-stub.js";
|
||||||
import { dahuaDriver, hikvisionDriver } from "./camera.js";
|
import { dahuaDriver, hikvisionDriver } from "./camera.js";
|
||||||
import { cashinoDriver } from "./printer-cashino.js";
|
import { escposDriver } from "./printer-generic.js";
|
||||||
import { rongtaDriver } from "./printer-rongta.js";
|
import { rongtaDriver } from "./printer-rongta.js";
|
||||||
import { dingtianQrReaderDriver, tcpipReaderDriver, wiegandReaderDriver } from "./reader.js";
|
import { dingtianQrReaderDriver, tcpipReaderDriver, wiegandReaderDriver } from "./reader.js";
|
||||||
|
|
||||||
@@ -23,7 +23,7 @@ export function registerBuiltinDrivers(): void {
|
|||||||
registry.register(hikvisionDriver);
|
registry.register(hikvisionDriver);
|
||||||
registry.register(dahuaDriver);
|
registry.register(dahuaDriver);
|
||||||
registry.register(rongtaDriver);
|
registry.register(rongtaDriver);
|
||||||
registry.register(cashinoDriver);
|
registry.register(escposDriver);
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
@@ -35,5 +35,5 @@ export {
|
|||||||
hikvisionDriver,
|
hikvisionDriver,
|
||||||
dahuaDriver,
|
dahuaDriver,
|
||||||
rongtaDriver,
|
rongtaDriver,
|
||||||
cashinoDriver,
|
escposDriver,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
probeUsb,
|
probeUsb,
|
||||||
sendRawUsb,
|
sendRawUsb,
|
||||||
transportFromConfig,
|
transportFromConfig,
|
||||||
|
writeAllUsb,
|
||||||
stamp,
|
stamp,
|
||||||
} from "./printer-escpos.js";
|
} from "./printer-escpos.js";
|
||||||
|
|
||||||
@@ -183,3 +184,72 @@ describe("stamp (Albanian date format)", () => {
|
|||||||
expect(stamp("not-a-date")).toBe("not-a-date");
|
expect(stamp("not-a-date")).toBe("not-a-date");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("writeAllUsb — partial writes / EAGAIN / close-cancel (field bugs 2026-07-06/07)", () => {
|
||||||
|
// A NONBLOCK usblp fd accepts only what fits the printer's USB buffer per write,
|
||||||
|
// write() returns at URB submission, and close() KILLS the in-flight URB — so the
|
||||||
|
// loop must deliver every byte AND certify delivery before the caller may close
|
||||||
|
// (final byte written alone; its acceptance proves all prior bytes landed). A
|
||||||
|
// regular file can't reproduce any of that, so these drive a fake handle.
|
||||||
|
|
||||||
|
/** Accepts at most `cap` bytes per call; records everything accepted in order. */
|
||||||
|
function slowHandle(cap: number) {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
return {
|
||||||
|
chunks,
|
||||||
|
write(buffer: Buffer, offset: number, length: number) {
|
||||||
|
const n = Math.min(cap, length);
|
||||||
|
chunks.push(Buffer.from(buffer.subarray(offset, offset + n)));
|
||||||
|
return Promise.resolve({ bytesWritten: n });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it("delivers the WHOLE payload across many short writes (barcode + cut included)", async () => {
|
||||||
|
const payload = renderTicket({ ticketId: "12345678901", issuedAt: "2026-06-21T10:00:00.000Z" });
|
||||||
|
const h = slowHandle(100); // way smaller than the job → many partial writes
|
||||||
|
await writeAllUsb(h, payload, Date.now() + 2000, 5);
|
||||||
|
expect(Buffer.concat(h.chunks).equals(payload)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the FINAL byte is written alone — the delivery certificate before close", async () => {
|
||||||
|
const payload = Buffer.from("x".repeat(5000)); // > one 4K chunk
|
||||||
|
const h = slowHandle(100_000); // accepts anything → chunking is ours, not the cap's
|
||||||
|
await writeAllUsb(h, payload, Date.now() + 2000, 5);
|
||||||
|
expect(Buffer.concat(h.chunks).equals(payload)).toBe(true);
|
||||||
|
expect(h.chunks.at(-1)!.length).toBe(1); // usblp: its acceptance proves the rest landed
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retries EAGAIN (buffer full) until the kernel accepts the rest", async () => {
|
||||||
|
const payload = Buffer.from("x".repeat(300));
|
||||||
|
let calls = 0;
|
||||||
|
const accepted: Buffer[] = [];
|
||||||
|
const h = {
|
||||||
|
write(buffer: Buffer, offset: number, length: number) {
|
||||||
|
calls++;
|
||||||
|
if (calls % 2 === 0) {
|
||||||
|
const err = new Error("EAGAIN") as NodeJS.ErrnoException;
|
||||||
|
err.code = "EAGAIN";
|
||||||
|
return Promise.reject(err);
|
||||||
|
}
|
||||||
|
const n = Math.min(120, length);
|
||||||
|
accepted.push(Buffer.from(buffer.subarray(offset, offset + n)));
|
||||||
|
return Promise.resolve({ bytesWritten: n });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await writeAllUsb(h, payload, Date.now() + 2000, 5);
|
||||||
|
expect(Buffer.concat(accepted).equals(payload)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a wedged printer (never accepts a byte) fails at the deadline instead of hanging", async () => {
|
||||||
|
const h = { write: () => Promise.resolve({ bytesWritten: 0 }) };
|
||||||
|
await expect(writeAllUsb(h, Buffer.from("job"), Date.now() + 60)).rejects.toThrow(/usb write timeout/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a non-EAGAIN error surfaces immediately", async () => {
|
||||||
|
const err = new Error("EIO") as NodeJS.ErrnoException;
|
||||||
|
err.code = "EIO";
|
||||||
|
const h = { write: () => Promise.reject(err) };
|
||||||
|
await expect(writeAllUsb(h, Buffer.from("job"), Date.now() + 1000)).rejects.toThrow("EIO");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -202,6 +202,9 @@ const STR = {
|
|||||||
tenderCard: "Kartë",
|
tenderCard: "Kartë",
|
||||||
/** "Paid:" amount label (precedes the large total). */
|
/** "Paid:" amount label (precedes the large total). */
|
||||||
amountLabel: "PAGUAR",
|
amountLabel: "PAGUAR",
|
||||||
|
/** Merchant-validation lines: the pre-discount fee + one line per discount. */
|
||||||
|
gross: (v: string) => `Tarifa: ${v}`,
|
||||||
|
discount: (label: string, v: string) => `${label}: -${v}`,
|
||||||
/** Walk-back grace emphasis (voucher mode) — two short lines that each fit the
|
/** Walk-back grace emphasis (voucher mode) — two short lines that each fit the
|
||||||
* 80mm width, so neither wraps mid-word. */
|
* 80mm width, so neither wraps mid-word. */
|
||||||
graceLines: (min: number): readonly string[] => [
|
graceLines: (min: number): readonly string[] => [
|
||||||
@@ -413,6 +416,16 @@ export function renderReceipt(data: ReceiptData): Buffer {
|
|||||||
line(
|
line(
|
||||||
STR.tender(data.tender === "card" ? STR.tenderCard : STR.tenderCash),
|
STR.tender(data.tender === "card" ? STR.tenderCard : STR.tenderCash),
|
||||||
),
|
),
|
||||||
|
// Merchant validations: gross fee + one line per discount, so the customer sees
|
||||||
|
// the full gross → discounts → net story (the big amount below is the NET).
|
||||||
|
...(data.validationLines?.length
|
||||||
|
? [
|
||||||
|
line(STR.gross(money(data.grossMinor ?? data.amountMinor, data.currency))),
|
||||||
|
...data.validationLines.map((v) =>
|
||||||
|
line(STR.discount(v.label, money(v.discountMinor, data.currency))),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
: []),
|
||||||
line(),
|
line(),
|
||||||
// The amount, large and centred.
|
// The amount, large and centred.
|
||||||
ALIGN_CENTER,
|
ALIGN_CENTER,
|
||||||
@@ -600,10 +613,83 @@ function withTimeout<T>(p: Promise<T>, ms: number, msg: string): Promise<T> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** usblp accepts only what fits its kernel buffer (~8 KB) per write on a NONBLOCK fd,
|
||||||
|
* so jobs are pushed in chunks safely under that. */
|
||||||
|
const USB_WRITE_CHUNK = 4096;
|
||||||
|
|
||||||
|
/** Pause after the FINAL byte's write is accepted, before close. Its acceptance
|
||||||
|
* proves everything before it is physically in the printer (see writeAllUsb); this
|
||||||
|
* covers the one-byte URB still in flight — a single bulk packet the printer ACKs
|
||||||
|
* immediately (it just freed buffer space by ACKing the previous chunk). */
|
||||||
|
const USB_DRAIN_MS = 300;
|
||||||
|
|
||||||
|
const delay = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
/** The slice of FileHandle the USB write loop needs (injectable for tests — a real
|
||||||
|
* regular file can't reproduce the char device's partial writes / EAGAIN). */
|
||||||
|
export interface UsbWriteHandle {
|
||||||
|
write(buffer: Buffer, offset: number, length: number): Promise<{ bytesWritten: number }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Push the WHOLE payload through a non-blocking usblp fd AND ensure the printer has
|
||||||
|
* physically received it before the caller may close. TWO field-verified truncation
|
||||||
|
* modes on the ICS XP-K200L (same symptom: text head prints, barcode/feed/CUT tail
|
||||||
|
* lost; TCP fine):
|
||||||
|
*
|
||||||
|
* 1. SHORT WRITES (2026-07-06): a single fire-and-forget write() only delivers what
|
||||||
|
* the kernel accepts. Fix: chunked loop, retry EAGAIN, until all bytes accepted.
|
||||||
|
* 2. CLOSE CANCELS THE LAST TRANSFER (2026-07-07, lab bench): per usblp.c, write()
|
||||||
|
* returns at URB *submission*, only ONE write URB is in flight at a time, and
|
||||||
|
* usblp_release() (our close) KILLS in-flight URBs. The printer consumes bulk
|
||||||
|
* data at PRINT speed (tiny internal buffer), so closing right after the last
|
||||||
|
* accepted write cancels the still-transferring tail — which is exactly where
|
||||||
|
* the feed + GS V cut live ("have to press the feed button to see the text").
|
||||||
|
*
|
||||||
|
* The delivery guarantee follows from usblp's one-URB rule: ACCEPTANCE OF WRITE N
|
||||||
|
* PROVES WRITE N−1 FULLY COMPLETED (the driver EAGAINs until the previous URB's
|
||||||
|
* completion). So the payload is pushed as chunks, then its FINAL BYTE alone: when
|
||||||
|
* that 1-byte write is accepted, every byte before it is physically in the printer.
|
||||||
|
* A short drain pause then covers the lone final-byte URB (one bulk packet), and
|
||||||
|
* close is safe. `drainMs` is parameterised only for tests.
|
||||||
|
*/
|
||||||
|
export async function writeAllUsb(
|
||||||
|
handle: UsbWriteHandle,
|
||||||
|
payload: Buffer,
|
||||||
|
deadlineMs: number,
|
||||||
|
drainMs: number = USB_DRAIN_MS,
|
||||||
|
): Promise<void> {
|
||||||
|
if (payload.length === 0) return;
|
||||||
|
const lastByteAt = payload.length - 1;
|
||||||
|
let off = 0;
|
||||||
|
while (off < payload.length) {
|
||||||
|
if (Date.now() > deadlineMs) {
|
||||||
|
throw new Error(`usb write timeout (${off}/${payload.length} bytes accepted)`);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// Never let the final byte ride a bigger chunk: it is written ALONE so its
|
||||||
|
// acceptance certifies delivery of everything before it (see doc above).
|
||||||
|
const len = off === lastByteAt ? 1 : Math.min(USB_WRITE_CHUNK, lastByteAt - off);
|
||||||
|
const { bytesWritten } = await handle.write(payload, off, len);
|
||||||
|
off += bytesWritten;
|
||||||
|
if (bytesWritten === 0) await delay(5); // buffer full, no error — breathe
|
||||||
|
} catch (err) {
|
||||||
|
if ((err as NodeJS.ErrnoException).code === "EAGAIN") {
|
||||||
|
await delay(10); // printer draining its buffer — retry until the deadline
|
||||||
|
} else {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// All bytes accepted; only the 1-byte final URB can still be in flight. Give it a
|
||||||
|
// moment to land before the caller closes (close would cancel it).
|
||||||
|
await delay(drainMs);
|
||||||
|
}
|
||||||
|
|
||||||
/** Write an ESC/POS payload to a USB-lp character device (e.g. /dev/usb/lp0). usblp
|
/** Write an ESC/POS payload to a USB-lp character device (e.g. /dev/usb/lp0). usblp
|
||||||
* is a RAW character device: a single open + write delivers the job — there is no
|
* is a RAW character device — no FIN/half-close dance (that was a TCP concern) —
|
||||||
* FIN/half-close dance (that was a TCP concern, where an early destroy() could
|
* but delivery must go through the chunked loop above (see its doc for why). We
|
||||||
* truncate the stream). We always close the handle (even on a failed write). */
|
* always close the handle (even on a failed write). */
|
||||||
export async function sendRawUsb(
|
export async function sendRawUsb(
|
||||||
devicePath: string,
|
devicePath: string,
|
||||||
payload: Buffer,
|
payload: Buffer,
|
||||||
@@ -615,7 +701,7 @@ export async function sendRawUsb(
|
|||||||
"usb open timeout",
|
"usb open timeout",
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
await withTimeout(handle.write(payload), timeoutMs, "usb write timeout");
|
await writeAllUsb(handle, payload, Date.now() + timeoutMs);
|
||||||
} finally {
|
} finally {
|
||||||
await handle.close();
|
await handle.close();
|
||||||
}
|
}
|
||||||
@@ -701,8 +787,8 @@ export const transportField: ConfigField = {
|
|||||||
required: true,
|
required: true,
|
||||||
default: "tcp-ip",
|
default: "tcp-ip",
|
||||||
options: [
|
options: [
|
||||||
{ value: "tcp-ip", label: "Network (raw TCP, port 9100)" },
|
{ value: "tcp-ip", label: "Network (raw TCP)" },
|
||||||
{ value: "usb", label: "USB (local /dev/usb/lp0)" },
|
{ value: "usb", label: "USB (local printer)" },
|
||||||
],
|
],
|
||||||
help: "USB drives a printer plugged into the appliance (usblp); Network drives one on the isolated device VLAN.",
|
help: "USB drives a printer plugged into the appliance (usblp); Network drives one on the isolated device VLAN.",
|
||||||
};
|
};
|
||||||
@@ -714,5 +800,5 @@ export const devicePathField: ConfigField = {
|
|||||||
type: "string",
|
type: "string",
|
||||||
required: false,
|
required: false,
|
||||||
default: "/dev/usb/lp0",
|
default: "/dev/usb/lp0",
|
||||||
help: "Character device for a USB printer (usblp), e.g. /dev/usb/lp0. Only used when Connection is USB.",
|
help: "usblp character device (/dev/usb/lpN). The setup UI lists the printers actually present; the kernel numbers them (lp0, lp1, …) by plug/boot order. Only used when Connection is USB.",
|
||||||
};
|
};
|
||||||
|
|||||||
+10
-9
@@ -2,19 +2,20 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|||||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { cashinoDriver } from "./printer-cashino.js";
|
import { escposDriver } from "./printer-generic.js";
|
||||||
import { renderTicket } from "./printer-escpos.js";
|
import { renderTicket } from "./printer-escpos.js";
|
||||||
|
|
||||||
// End-to-end transport routing through the real driver: a USB-configured Cashino must
|
// End-to-end transport routing through the real driver: a USB-configured generic
|
||||||
|
// ESC/POS printer (Cashino / ICS XP-K200L family) must
|
||||||
// resolve to the char-device transport and write the SAME ESC/POS bytes the TCP path
|
// resolve to the char-device transport and write the SAME ESC/POS bytes the TCP path
|
||||||
// would. (The TCP path is exercised by the routing/escpos suites and on hardware.)
|
// would. (The TCP path is exercised by the routing/escpos suites and on hardware.)
|
||||||
|
|
||||||
describe("cashinoDriver — USB transport", () => {
|
describe("escposDriver (generic ESC/POS) — USB transport", () => {
|
||||||
let dir: string;
|
let dir: string;
|
||||||
let devicePath: string;
|
let devicePath: string;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
dir = mkdtempSync(join(tmpdir(), "cashino-usb-"));
|
dir = mkdtempSync(join(tmpdir(), "escpos-usb-"));
|
||||||
devicePath = join(dir, "lp0");
|
devicePath = join(dir, "lp0");
|
||||||
// Stand in for an enumerated usblp node (the kernel creates it; we only open it).
|
// Stand in for an enumerated usblp node (the kernel creates it; we only open it).
|
||||||
writeFileSync(devicePath, "");
|
writeFileSync(devicePath, "");
|
||||||
@@ -24,7 +25,7 @@ describe("cashinoDriver — USB transport", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("prints a ticket to the configured USB device path", async () => {
|
it("prints a ticket to the configured USB device path", async () => {
|
||||||
const printer = cashinoDriver.create({ transport: "usb", devicePath, timeoutMs: 1000 });
|
const printer = escposDriver.create({ transport: "usb", devicePath, timeoutMs: 1000 });
|
||||||
const data = { ticketId: "12345678901", issuedAt: "2026-06-21T10:00:00.000Z" };
|
const data = { ticketId: "12345678901", issuedAt: "2026-06-21T10:00:00.000Z" };
|
||||||
await printer.printTicket(data);
|
await printer.printTicket(data);
|
||||||
const written = readFileSync(devicePath);
|
const written = readFileSync(devicePath);
|
||||||
@@ -32,10 +33,10 @@ describe("cashinoDriver — USB transport", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("healthCheck reports ready when the node exists, offline when it doesn't", async () => {
|
it("healthCheck reports ready when the node exists, offline when it doesn't", async () => {
|
||||||
const present = cashinoDriver.create({ transport: "usb", devicePath, timeoutMs: 1000 });
|
const present = escposDriver.create({ transport: "usb", devicePath, timeoutMs: 1000 });
|
||||||
expect((await present.healthCheck()).status).toBe("ready");
|
expect((await present.healthCheck()).status).toBe("ready");
|
||||||
// An absent device node (printer unplugged / not enumerated) → offline.
|
// An absent device node (printer unplugged / not enumerated) → offline.
|
||||||
const absent = cashinoDriver.create({
|
const absent = escposDriver.create({
|
||||||
transport: "usb",
|
transport: "usb",
|
||||||
devicePath: join(dir, "absent-lp0"),
|
devicePath: join(dir, "absent-lp0"),
|
||||||
timeoutMs: 1000,
|
timeoutMs: 1000,
|
||||||
@@ -44,7 +45,7 @@ describe("cashinoDriver — USB transport", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("advertises both transports", () => {
|
it("advertises both transports", () => {
|
||||||
expect(cashinoDriver.transports).toContain("usb");
|
expect(escposDriver.transports).toContain("usb");
|
||||||
expect(cashinoDriver.transports).toContain("tcp-ip");
|
expect(escposDriver.transports).toContain("tcp-ip");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+21
-16
@@ -23,25 +23,26 @@ import {
|
|||||||
type Transport,
|
type Transport,
|
||||||
} from "./printer-escpos.js";
|
} from "./printer-escpos.js";
|
||||||
|
|
||||||
// Cashino 80mm thermal printer driver (network OR USB). The Cashino is an ESC/POS
|
// GENERIC ESC/POS 80mm thermal printer driver (network OR USB) — any clone that
|
||||||
// clone: it PRINTS identically to the Rongta (same byte stream — see
|
// PRINTS the shared ESC/POS byte stream (see ./printer-escpos.ts) but serves no
|
||||||
// ./printer-escpos.ts), so tickets, reports and subscription cards render the same,
|
// Rongta-style decoded status page (/prn_stat.htm). Verified fits: Cashino (the
|
||||||
// over either transport. What it does NOT have is the Rongta board's decoded status
|
// first unit we drove — the driver carried its name until 2026-07-06), ICS/Xprinter
|
||||||
// web page (/prn_stat.htm). It cannot report paper-out / cover-open / cutter faults
|
// XP-K200L. Tickets, reports and subscription cards render identically to the
|
||||||
// in a form we trust.
|
// Rongta, over either transport; what these clones can NOT do is report paper-out /
|
||||||
|
// cover-open / cutter faults in a form we trust.
|
||||||
//
|
//
|
||||||
// TRANSPORT: a single `config.transport` ("tcp-ip" | "usb") picks the wire; the
|
// TRANSPORT: a single `config.transport` ("tcp-ip" | "usb") picks the wire; the
|
||||||
// driver resolves it ONCE into a Transport and every print/probe stays transport-
|
// driver resolves it ONCE into a Transport and every print/probe stays transport-
|
||||||
// blind (see transportFromConfig/sendTo/probeTo). USB writes the same bytes to a
|
// blind (see transportFromConfig/sendTo/probeTo). USB writes the same bytes to a
|
||||||
// local usblp char device (/dev/usb/lp0); TCP writes to the raw print socket. This
|
// local usblp char device (/dev/usb/lp0); TCP writes to the raw print socket. This
|
||||||
// clone is the natural USB candidate — reachability-only, no status page to lose.
|
// clone family is the natural USB candidate — reachability-only, no page to lose.
|
||||||
//
|
//
|
||||||
// Therefore this driver deliberately does NOT implement MonitorableDevice
|
// Therefore this driver deliberately does NOT implement MonitorableDevice
|
||||||
// (no readStatus). The device monitor then falls back to the generic
|
// (no readStatus). The device monitor then falls back to the generic
|
||||||
// `healthCheck()` — a plain TCP reachability PING of the print socket. So the
|
// `healthCheck()` — a plain TCP reachability PING of the print socket. So the
|
||||||
// booth footer shows this printer as "ready" when it's reachable and "offline"
|
// booth footer shows this printer as "ready" when it's reachable and "offline"
|
||||||
// when it isn't, and never a wrong paper/cover verdict it cannot actually sense.
|
// when it isn't, and never a wrong paper/cover verdict it cannot actually sense.
|
||||||
// (Reusing the Rongta driver made it scrape a status page the Cashino doesn't
|
// (Reusing the Rongta driver made it scrape a status page these clones don't
|
||||||
// serve, producing the bogus "degraded" feedback this driver fixes.)
|
// serve, producing the bogus "degraded" feedback this driver fixes.)
|
||||||
//
|
//
|
||||||
// No auth on the print socket — like the other field devices it lives on the
|
// No auth on the print socket — like the other field devices it lives on the
|
||||||
@@ -49,8 +50,8 @@ import {
|
|||||||
// (entry-dispenser / booth-receipt + failoverRank); the server owns selection.
|
// (entry-dispenser / booth-receipt + failoverRank); the server owns selection.
|
||||||
// See wiki/concepts/printer-status-monitoring.md and printer-roles-failover.md.
|
// See wiki/concepts/printer-status-monitoring.md and printer-roles-failover.md.
|
||||||
|
|
||||||
class CashinoPrinter implements PrinterDevice {
|
class GenericEscposPrinter implements PrinterDevice {
|
||||||
readonly driverId = "cashino";
|
readonly driverId = "escpos";
|
||||||
readonly #transport: Transport;
|
readonly #transport: Transport;
|
||||||
readonly #timeout: number;
|
readonly #timeout: number;
|
||||||
|
|
||||||
@@ -69,7 +70,7 @@ class CashinoPrinter implements PrinterDevice {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Reachability only — a connect probe (TCP) or char-device open probe (USB) of
|
* Reachability only — a connect probe (TCP) or char-device open probe (USB) of
|
||||||
* the print path. The Cashino has no trustworthy status protocol, so this is the
|
* the print path. These clones have no trustworthy status protocol, so this is the
|
||||||
* floor and the ceiling of what we report: reachable → ready, unreachable →
|
* floor and the ceiling of what we report: reachable → ready, unreachable →
|
||||||
* offline. Deliberately NO readStatus(): the monitor uses this for the
|
* offline. Deliberately NO readStatus(): the monitor uses this for the
|
||||||
* traffic-light, never a guessed paper/cover state.
|
* traffic-light, never a guessed paper/cover state.
|
||||||
@@ -140,12 +141,16 @@ const rankField: ConfigField = {
|
|||||||
help: "Higher = tried first within the same role. The booth printer also backs up the entry dispenser.",
|
help: "Higher = tried first within the same role. The booth printer also backs up the entry dispenser.",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const cashinoDriver: PrinterDriver = {
|
export const escposDriver: PrinterDriver = {
|
||||||
id: "cashino",
|
// Renamed from id "cashino" (the first clone we drove) on 2026-07-06 — the vendor
|
||||||
|
// name was misleading in the setup UI once other clones (ICS/Xprinter XP-K200L)
|
||||||
|
// used it. Stored configs with driverId "cashino" still resolve via the registry
|
||||||
|
// alias + are rewritten by migration 0023.
|
||||||
|
id: "escpos",
|
||||||
category: "printer",
|
category: "printer",
|
||||||
label: "Cashino 80mm thermal printer",
|
label: "Generic ESC/POS 80mm printer (Cashino, ICS/Xprinter…)",
|
||||||
description:
|
description:
|
||||||
"Cashino 80mm thermal printer (ESC/POS over raw TCP port 9100, OR local USB /dev/usb/lp0). Prints like the Rongta but has no status page — monitored by reachability only (no paper/cover/cutter reporting). No auth on the print socket — isolate the VLAN.",
|
"Generic ESC/POS 80mm thermal printer over raw TCP (port 9100) OR local USB /dev/usb/lp0 — Cashino, ICS/Xprinter XP-K200L, and similar clones. Prints like the Rongta but has no status page — monitored by reachability only (no paper/cover/cutter reporting). No auth on the print socket — isolate the VLAN.",
|
||||||
transports: ["tcp-ip", "usb"],
|
transports: ["tcp-ip", "usb"],
|
||||||
configFields: [
|
configFields: [
|
||||||
transportField,
|
transportField,
|
||||||
@@ -167,5 +172,5 @@ export const cashinoDriver: PrinterDriver = {
|
|||||||
default: 3000,
|
default: 3000,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
create: (c) => new CashinoPrinter(c),
|
create: (c) => new GenericEscposPrinter(c),
|
||||||
};
|
};
|
||||||
@@ -18,7 +18,7 @@ export {
|
|||||||
hikvisionDriver,
|
hikvisionDriver,
|
||||||
dahuaDriver,
|
dahuaDriver,
|
||||||
rongtaDriver,
|
rongtaDriver,
|
||||||
cashinoDriver,
|
escposDriver,
|
||||||
} from "./drivers/index.js";
|
} from "./drivers/index.js";
|
||||||
export { isPrinter, type PrinterRole } from "./drivers/printer-rongta.js";
|
export { isPrinter, type PrinterRole } from "./drivers/printer-rongta.js";
|
||||||
// Albanian human date/time for printed slips (receipts, tickets, shift Z-report),
|
// Albanian human date/time for printed slips (receipts, tickets, shift Z-report),
|
||||||
|
|||||||
@@ -194,6 +194,30 @@ export function isCamera(device: Device): device is Device & CameraDevice {
|
|||||||
return typeof (device as Partial<CameraDevice>).captureSnapshot === "function";
|
return typeof (device as Partial<CameraDevice>).captureSnapshot === "function";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Outcome of a camera clock sync attempt (see ClockSyncDevice). */
|
||||||
|
export interface ClockSyncResult {
|
||||||
|
/** Camera-vs-host drift in whole seconds at check time; null = the camera's
|
||||||
|
* reply was unparseable (treated as infinite drift → sync). */
|
||||||
|
readonly driftSeconds: number | null;
|
||||||
|
/** True when the camera clock was actually set (drift exceeded the threshold). */
|
||||||
|
readonly synced: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Optional capability: a device whose clock the HOST can read + set. Hikvision
|
||||||
|
* cameras lose their clock on power cuts (no/dead RTC battery, reboot at the 1970
|
||||||
|
* epoch) and only heal when a human logs into the web UI — so the device monitor
|
||||||
|
* re-syncs them from the host clock at the offline→ready edge + a daily backstop.
|
||||||
|
* See wiki/entities/lpr-camera.md (clock sync). */
|
||||||
|
export interface ClockSyncDevice {
|
||||||
|
/** Compare the device clock to `localIso` (the site's wall-clock now, WITH utc
|
||||||
|
* offset) and set it when drift exceeds `maxDriftSec`. */
|
||||||
|
syncClock(localIso: string, maxDriftSec: number): Promise<ClockSyncResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isClockSyncable(device: Device): device is Device & ClockSyncDevice {
|
||||||
|
return typeof (device as Partial<ClockSyncDevice>).syncClock === "function";
|
||||||
|
}
|
||||||
|
|
||||||
export interface SnapshotContext {
|
export interface SnapshotContext {
|
||||||
readonly direction: "entry" | "exit";
|
readonly direction: "entry" | "exit";
|
||||||
}
|
}
|
||||||
@@ -251,6 +275,11 @@ export interface ReceiptData {
|
|||||||
readonly voucher: boolean;
|
readonly voucher: boolean;
|
||||||
/** Minutes the customer has to reach the exit after paying (voucher mode only). */
|
/** Minutes the customer has to reach the exit after paying (voucher mode only). */
|
||||||
readonly graceExitMin?: number | null;
|
readonly graceExitMin?: number | null;
|
||||||
|
/** Merchant validations (bar/lavazh): the PRE-discount fee and the per-validation
|
||||||
|
* lines. When present, `amountMinor` is the NET actually paid and the receipt
|
||||||
|
* shows the full gross → discounts → net story. See validation-discounts.md. */
|
||||||
|
readonly grossMinor?: number | null;
|
||||||
|
readonly validationLines?: readonly { label: string; discountMinor: number }[];
|
||||||
readonly header?: TicketHeader;
|
readonly header?: TicketHeader;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -97,6 +97,14 @@ export function isDiscoverable(
|
|||||||
return typeof (driver as Partial<DiscoverableDriver>).discover === "function";
|
return typeof (driver as Partial<DiscoverableDriver>).discover === "function";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Renamed driver ids: what a STORED config may still say → the current id. Kept
|
||||||
|
* tiny + permanent so old DB rows, exports, and backups resolve across renames
|
||||||
|
* (migration 0023 rewrites live rows, but a restored old backup may reintroduce
|
||||||
|
* the historical id). */
|
||||||
|
const DRIVER_ID_ALIASES: Record<string, string> = {
|
||||||
|
cashino: "escpos", // renamed 2026-07-06 — it was always the generic ESC/POS driver
|
||||||
|
};
|
||||||
|
|
||||||
class DeviceRegistry {
|
class DeviceRegistry {
|
||||||
readonly #drivers = new Map<string, DeviceDriver>();
|
readonly #drivers = new Map<string, DeviceDriver>();
|
||||||
|
|
||||||
@@ -114,12 +122,12 @@ class DeviceRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get(id: string): DeviceDriver | undefined {
|
get(id: string): DeviceDriver | undefined {
|
||||||
return this.#drivers.get(id);
|
return this.#drivers.get(DRIVER_ID_ALIASES[id] ?? id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Validate config against a driver's declared fields and build the adapter. */
|
/** Validate config against a driver's declared fields and build the adapter. */
|
||||||
create(id: string, config: DeviceConfig): Device {
|
create(id: string, config: DeviceConfig): Device {
|
||||||
const driver = this.#drivers.get(id);
|
const driver = this.get(id);
|
||||||
if (!driver) throw new Error(`unknown driver: ${id}`);
|
if (!driver) throw new Error(`unknown driver: ${id}`);
|
||||||
for (const field of driver.configFields) {
|
for (const field of driver.configFields) {
|
||||||
if (field.required && config[field.key] === undefined) {
|
if (field.required && config[field.key] === undefined) {
|
||||||
|
|||||||
+316
-17
@@ -20,6 +20,7 @@ export const RESOURCES = [
|
|||||||
"tariff", // read / publish a new version
|
"tariff", // read / publish a new version
|
||||||
"subscription", // the subscription registry
|
"subscription", // the subscription registry
|
||||||
"site", // site_config + device setup/assign
|
"site", // site_config + device setup/assign
|
||||||
|
"validation", // merchant validations: apply a discount to a session (bar/lavazh)
|
||||||
"device", // device status / printers / snapshots / catalog
|
"device", // device status / printers / snapshots / catalog
|
||||||
"shift", // open/close own shift
|
"shift", // open/close own shift
|
||||||
"drawer", // record cash receipts/disbursements (operator); review them (admin)
|
"drawer", // record cash receipts/disbursements (operator); review them (admin)
|
||||||
@@ -54,6 +55,13 @@ export const PERMISSIONS: readonly Permission[] = [
|
|||||||
"subscription:plan", // compose the plan catalog (admin-grade); selling = subscription:create
|
"subscription:plan", // compose the plan catalog (admin-grade); selling = subscription:create
|
||||||
|
|
||||||
"site:read", "site:update",
|
"site:read", "site:update",
|
||||||
|
// Merchant validations (bar/lavazh): create = APPLY a validation to a session (the
|
||||||
|
// merchant user's one permission — guarded further by the program↔user binding, so a
|
||||||
|
// bar user can never apply the lavazh program) + void their OWN unused validation;
|
||||||
|
// read = see applied validations (reports/history). Program COMPOSITION needs no new
|
||||||
|
// permission — it lives on /setup/site behind site:update. See
|
||||||
|
// wiki/concepts/validation-discounts.md.
|
||||||
|
"validation:create", "validation:read",
|
||||||
"device:read",
|
"device:read",
|
||||||
"shift:read", "shift:create", "shift:cash",
|
"shift:read", "shift:create", "shift:cash",
|
||||||
// Drawer cash movements: create (operator RECORDS a receipt/disbursement — freely, no
|
// Drawer cash movements: create (operator RECORDS a receipt/disbursement — freely, no
|
||||||
@@ -272,6 +280,14 @@ export type LedgerEventType =
|
|||||||
// the admin is NOT the adversary, but weakening an anti-fraud gate must still be
|
// the admin is NOT the adversary, but weakening an anti-fraud gate must still be
|
||||||
// attributed + auditable). See wiki/concepts/entry-presence-bypass.md.
|
// attributed + auditable). See wiki/concepts/entry-presence-bypass.md.
|
||||||
| "config_change"
|
| "config_change"
|
||||||
|
// A merchant validation applied to (or voided from) a transient session: the bar/
|
||||||
|
// lavazh user scanned the customer's ticket, so the booth settlement discounts the
|
||||||
|
// fee. Payload carries the RESOLVED values (programId, label, mode, minutes/
|
||||||
|
// amountMinor/percent) — reproducible even if the program config later changes —
|
||||||
|
// plus `operator` (the merchant username). A payload with `refId` set is a VOID of
|
||||||
|
// the referenced validation event (append-only correction, mirrors cash_review).
|
||||||
|
// See wiki/concepts/validation-discounts.md.
|
||||||
|
| "validation"
|
||||||
| "anomaly";
|
| "anomaly";
|
||||||
|
|
||||||
/** How money was tendered (for payment events + the shift Z-report). */
|
/** How money was tendered (for payment events + the shift Z-report). */
|
||||||
@@ -291,9 +307,25 @@ export interface LedgerPayload {
|
|||||||
readonly tender?: Tender;
|
readonly tender?: Tender;
|
||||||
/** payment: which tariff_version priced it (reproducible repricing). */
|
/** payment: which tariff_version priced it (reproducible repricing). */
|
||||||
readonly tariffVersionId?: string;
|
readonly tariffVersionId?: string;
|
||||||
/** payment: gross/discount/net split when a validation applied. */
|
/** payment: gross/discount/net split when a validation applied. `amountMinor` is the
|
||||||
|
* NET collected; grossMinor the pre-discount fee; discountMinor what validations took
|
||||||
|
* off. `validationIds` = the validation event ids this payment CONSUMED (so an
|
||||||
|
* overstay's fresh period never re-applies them). */
|
||||||
readonly grossMinor?: number;
|
readonly grossMinor?: number;
|
||||||
readonly discountMinor?: number;
|
readonly discountMinor?: number;
|
||||||
|
readonly validationIds?: string[];
|
||||||
|
/** payment: the per-validation receipt lines as settled (label + amount taken off) —
|
||||||
|
* stamped so the printed receipt reproduces without re-deriving the fold. */
|
||||||
|
readonly validationLines?: { programId: string; label: string; mode: string; discountMinor: number }[];
|
||||||
|
/** validation: which program (bar/lavazh) + its receipt label, frozen at apply time. */
|
||||||
|
readonly programId?: string;
|
||||||
|
readonly programLabel?: string;
|
||||||
|
/** validation: resolved values by mode — timeCredit's free minutes / percent off.
|
||||||
|
* A fixed amount rides the shared `amountMinor`. */
|
||||||
|
readonly minutes?: number;
|
||||||
|
readonly percent?: number;
|
||||||
|
/** validation / cash vouchers: the username of the user who recorded it. */
|
||||||
|
readonly operator?: string;
|
||||||
/** FX-ready, deferred: rate applied (null/absent now). See open-questions #8. */
|
/** FX-ready, deferred: rate applied (null/absent now). See open-questions #8. */
|
||||||
readonly fxRate?: number | null;
|
readonly fxRate?: number | null;
|
||||||
/** void / anomaly / override: a human-readable English sentence, signed as the
|
/** void / anomaly / override: a human-readable English sentence, signed as the
|
||||||
@@ -326,7 +358,8 @@ export interface LedgerPayload {
|
|||||||
* cash_review event, so new movements do NOT carry this. Kept so historical events
|
* cash_review event, so new movements do NOT carry this. Kept so historical events
|
||||||
* still verify + display. See wiki/concepts/shift.md. */
|
* still verify + display. See wiki/concepts/shift.md. */
|
||||||
readonly authorizedBy?: string;
|
readonly authorizedBy?: string;
|
||||||
/** cash_review: the id of the cash_in/cash_out event this review decides on. */
|
/** cash_review: the id of the cash_in/cash_out event this review decides on.
|
||||||
|
* validation: set = this event VOIDS the referenced validation event. */
|
||||||
readonly refId?: string;
|
readonly refId?: string;
|
||||||
/** cash_review: the admin's decision on the referenced movement. A FLAG only —
|
/** cash_review: the admin's decision on the referenced movement. A FLAG only —
|
||||||
* neither value moves cash or touches the drawer balance. */
|
* neither value moves cash or touches the drawer balance. */
|
||||||
@@ -702,6 +735,58 @@ export interface SessionPayment {
|
|||||||
readonly graceExitMin: number | null;
|
readonly graceExitMin: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Merchant validations (bar / lavazh discounts) ---------------------------
|
||||||
|
// An in-park merchant validates a customer's ticket so the BOOTH settlement charges
|
||||||
|
// less or nothing. The program is admin-composed MUTABLE master data (no versioning:
|
||||||
|
// the applied validation is a signed ledger event carrying the RESOLVED values, so
|
||||||
|
// reproducibility never depends on the row). All money stays at the booth — the
|
||||||
|
// merchant only validates. See wiki/concepts/validation-discounts.md.
|
||||||
|
|
||||||
|
/** How a program discounts: full comp / first-N-minutes free / a fixed amount (typed
|
||||||
|
* by the merchant at scan time, capped) / a percentage off. */
|
||||||
|
export type ValidationMode = "comp" | "timeCredit" | "fixed" | "percent";
|
||||||
|
export const VALIDATION_MODES: readonly ValidationMode[] = ["comp", "timeCredit", "fixed", "percent"];
|
||||||
|
|
||||||
|
/** An admin-composed validation program (one per merchant station; `bar` and `lavazh`
|
||||||
|
* are the well-known ids the /setup/site checkboxes toggle). */
|
||||||
|
export interface ValidationProgram {
|
||||||
|
readonly id: string; // well-known slug ("bar" | "lavazh"); generic for future merchants
|
||||||
|
/** Receipt label, e.g. "Lavazh — 1 orë falas". Printed on the booth receipt line. */
|
||||||
|
readonly name: string;
|
||||||
|
readonly mode: ValidationMode;
|
||||||
|
/** timeCredit: the free minutes. */
|
||||||
|
readonly minutes: number | null;
|
||||||
|
/** percent: 1..100 off the fee. */
|
||||||
|
readonly percent: number | null;
|
||||||
|
/** fixed: cap on the amount the merchant may type at scan time (minor units). */
|
||||||
|
readonly maxAmountMinor: number | null;
|
||||||
|
/** Cap: max applications of this program per local day (null = unlimited). */
|
||||||
|
readonly maxPerDay: number | null;
|
||||||
|
readonly active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An APPLIED validation as pricing cares about it — the RESOLVED values folded off
|
||||||
|
* the signed validation event (never the mutable program row). */
|
||||||
|
export interface SessionValidation {
|
||||||
|
/** The validation event id (payments record which ids they consumed). */
|
||||||
|
readonly eventId?: string;
|
||||||
|
readonly programId: string;
|
||||||
|
readonly label: string;
|
||||||
|
readonly mode: ValidationMode;
|
||||||
|
readonly minutes?: number; // timeCredit
|
||||||
|
readonly amountMinor?: number; // fixed
|
||||||
|
readonly percent?: number; // percent
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One receipt/display line: what a validation actually saved on this settlement. */
|
||||||
|
export interface ValidationLine {
|
||||||
|
readonly programId: string;
|
||||||
|
readonly label: string;
|
||||||
|
readonly mode: ValidationMode;
|
||||||
|
/** The (positive) amount this line took off the fee. */
|
||||||
|
readonly discountMinor: number;
|
||||||
|
}
|
||||||
|
|
||||||
/** The full pricing outcome for a session at a moment in time — what the booth's
|
/** The full pricing outcome for a session at a moment in time — what the booth's
|
||||||
* `quote()` and the exit flow compute, made PURE so it can be tested or previewed
|
* `quote()` and the exit flow compute, made PURE so it can be tested or previewed
|
||||||
* without a real ledger. See wiki/concepts/booth-exit-flow.md (overstay pricing). */
|
* without a real ledger. See wiki/concepts/booth-exit-flow.md (overstay pricing). */
|
||||||
@@ -709,8 +794,14 @@ export interface SessionPricing {
|
|||||||
/** The window actually billed now: entry→asOf normally, or grace-expiry→asOf for an
|
/** The window actually billed now: entry→asOf normally, or grace-expiry→asOf for an
|
||||||
* overstay (a paid session whose walk-back grace lapsed — a new period began). */
|
* overstay (a paid session whose walk-back grace lapsed — a new period began). */
|
||||||
readonly periodStart: string;
|
readonly periodStart: string;
|
||||||
/** Fee for [periodStart, asOf]. */
|
/** Amount DUE for [periodStart, asOf] — NET of any merchant validations. */
|
||||||
readonly amountMinor: number;
|
readonly amountMinor: number;
|
||||||
|
/** The pre-validation fee for the same period (= amountMinor when no validations). */
|
||||||
|
readonly grossMinor: number;
|
||||||
|
/** Total the validations took off (grossMinor − amountMinor). */
|
||||||
|
readonly discountMinor: number;
|
||||||
|
/** Per-validation receipt lines, in the canonical application order. */
|
||||||
|
readonly validationLines: ValidationLine[];
|
||||||
/** True when the latest payment's grace has lapsed (overstay = new period). */
|
/** True when the latest payment's grace has lapsed (overstay = new period). */
|
||||||
readonly overstay: boolean;
|
readonly overstay: boolean;
|
||||||
/** True when paid AND still inside the walk-back window (a settled, exitable stay). */
|
/** True when paid AND still inside the walk-back window (a settled, exitable stay). */
|
||||||
@@ -732,6 +823,15 @@ export interface SessionPricing {
|
|||||||
* `payments` is the session's payment history (only the LATEST matters for grace);
|
* `payments` is the session's payment history (only the LATEST matters for grace);
|
||||||
* pass [] for an unpaid session. The tariff version is the one frozen at entry — the
|
* pass [] for an unpaid session. The tariff version is the one frozen at entry — the
|
||||||
* customer keeps their rate card even across an overstay. See booth-exit-flow.md.
|
* customer keeps their rate card even across an overstay. See booth-exit-flow.md.
|
||||||
|
*
|
||||||
|
* `validations` are the UNCONSUMED merchant validations on the session (the caller
|
||||||
|
* filters out ids already recorded on a prior payment's `validationIds`, so an
|
||||||
|
* overstay's fresh period never re-applies them). Canonical application order —
|
||||||
|
* deterministic regardless of scan order: timeCredit (shifts the billed period's
|
||||||
|
* start forward, so "first hour free" is literal and windowed/stepped cards price
|
||||||
|
* the remainder correctly) → percent (of the remaining fee) → fixed amounts
|
||||||
|
* (clamped to the remainder) → comp (zeroes whatever is left). Net never goes
|
||||||
|
* below 0. See wiki/concepts/validation-discounts.md.
|
||||||
*/
|
*/
|
||||||
export function priceSession(
|
export function priceSession(
|
||||||
enteredAt: string,
|
enteredAt: string,
|
||||||
@@ -739,6 +839,7 @@ export function priceSession(
|
|||||||
tariff: TariffStructure,
|
tariff: TariffStructure,
|
||||||
payments: readonly SessionPayment[] = [],
|
payments: readonly SessionPayment[] = [],
|
||||||
category?: string,
|
category?: string,
|
||||||
|
validations: readonly SessionValidation[] = [],
|
||||||
): SessionPricing {
|
): SessionPricing {
|
||||||
const last = payments.length ? payments[payments.length - 1] : null;
|
const last = payments.length ? payments[payments.length - 1] : null;
|
||||||
const graceExpiryMs =
|
const graceExpiryMs =
|
||||||
@@ -748,10 +849,50 @@ export function priceSession(
|
|||||||
const withinGrace = graceExpiryMs != null && asOfMs <= graceExpiryMs;
|
const withinGrace = graceExpiryMs != null && asOfMs <= graceExpiryMs;
|
||||||
const periodStart = overstay ? new Date(graceExpiryMs!).toISOString() : enteredAt;
|
const periodStart = overstay ? new Date(graceExpiryMs!).toISOString() : enteredAt;
|
||||||
// A settled (paid + within grace) session owes nothing more; otherwise bill the period.
|
// A settled (paid + within grace) session owes nothing more; otherwise bill the period.
|
||||||
const amountMinor = withinGrace ? 0 : computeFee(periodStart, asOf, tariff, category);
|
const grossMinor = withinGrace ? 0 : computeFee(periodStart, asOf, tariff, category);
|
||||||
|
|
||||||
|
// Fold the validations (nothing to discount on a settled session or a zero fee is
|
||||||
|
// still folded so the receipt can show "Lavazh — falas" even when gross is 0-adjacent).
|
||||||
|
const lines: ValidationLine[] = [];
|
||||||
|
let net = grossMinor;
|
||||||
|
if (!withinGrace && validations.length) {
|
||||||
|
const byMode = (m: ValidationMode) => validations.filter((v) => v.mode === m);
|
||||||
|
// 1. Time credits: bill as if the period started later (clamped at asOf). The
|
||||||
|
// marginal saving of each credit is its line amount.
|
||||||
|
let startMs = Date.parse(periodStart);
|
||||||
|
for (const v of byMode("timeCredit")) {
|
||||||
|
const minutes = v.minutes ?? 0;
|
||||||
|
const shiftedMs = Math.min(startMs + minutes * 60_000, asOfMs);
|
||||||
|
const newFee = computeFee(new Date(shiftedMs).toISOString(), asOf, tariff, category);
|
||||||
|
lines.push({ programId: v.programId, label: v.label, mode: v.mode, discountMinor: net - newFee });
|
||||||
|
startMs = shiftedMs;
|
||||||
|
net = newFee;
|
||||||
|
}
|
||||||
|
// 2. Percent of the remaining fee (floor — integer minor units).
|
||||||
|
for (const v of byMode("percent")) {
|
||||||
|
const off = Math.floor((net * Math.min(Math.max(v.percent ?? 0, 0), 100)) / 100);
|
||||||
|
lines.push({ programId: v.programId, label: v.label, mode: v.mode, discountMinor: off });
|
||||||
|
net -= off;
|
||||||
|
}
|
||||||
|
// 3. Fixed amounts, clamped to the remainder so Σ lines ≡ gross − net.
|
||||||
|
for (const v of byMode("fixed")) {
|
||||||
|
const off = Math.min(Math.max(v.amountMinor ?? 0, 0), net);
|
||||||
|
lines.push({ programId: v.programId, label: v.label, mode: v.mode, discountMinor: off });
|
||||||
|
net -= off;
|
||||||
|
}
|
||||||
|
// 4. Comp: zero whatever is left.
|
||||||
|
for (const v of byMode("comp")) {
|
||||||
|
lines.push({ programId: v.programId, label: v.label, mode: v.mode, discountMinor: net });
|
||||||
|
net = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
periodStart,
|
periodStart,
|
||||||
amountMinor,
|
amountMinor: net,
|
||||||
|
grossMinor,
|
||||||
|
discountMinor: grossMinor - net,
|
||||||
|
validationLines: lines,
|
||||||
overstay,
|
overstay,
|
||||||
withinGrace,
|
withinGrace,
|
||||||
graceExpiresAt: graceExpiryMs != null ? new Date(graceExpiryMs).toISOString() : null,
|
graceExpiresAt: graceExpiryMs != null ? new Date(graceExpiryMs).toISOString() : null,
|
||||||
@@ -764,6 +905,115 @@ export function hasSteps(s: { steps?: readonly TariffStep[] }): boolean {
|
|||||||
return Array.isArray(s.steps) && s.steps.length > 0;
|
return Array.isArray(s.steps) && s.steps.length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Fee breakdown (explainability) -------------------------------------------
|
||||||
|
// One line item per priced "reason": a run of same-priced increments, a window
|
||||||
|
// package occurrence, a stepped day total, a daily-cap clamp, or the entry grace.
|
||||||
|
// Produced by the SAME walk computeFee runs (an optional trace collector inside
|
||||||
|
// computeFeeV1/V2), so Σ item amounts ≡ the fee by construction — the breakdown can
|
||||||
|
// never tell a different story than the bill. Built for the Tariff Lab's "how is
|
||||||
|
// this sum produced" view (2026-07-06). Minutes are offsets from the priced
|
||||||
|
// period's start.
|
||||||
|
|
||||||
|
export type FeeBreakdownItem =
|
||||||
|
/** The whole stay fit inside the free entry-grace window (fee 0). */
|
||||||
|
| { readonly kind: "grace"; readonly minutes: number }
|
||||||
|
/** A contiguous run of increments billed at one unit price by one card.
|
||||||
|
* `card` is the windowed card's name, or null for the base/default rate. */
|
||||||
|
| {
|
||||||
|
readonly kind: "band";
|
||||||
|
readonly card: string | null;
|
||||||
|
readonly fromMin: number;
|
||||||
|
readonly toMin: number;
|
||||||
|
readonly increments: number;
|
||||||
|
readonly unitMinor: number;
|
||||||
|
readonly amountMinor: number;
|
||||||
|
}
|
||||||
|
/** One window-package occurrence (charged once per contiguous run the card wins). */
|
||||||
|
| { readonly kind: "package"; readonly card: string; readonly fromMin: number; readonly amountMinor: number }
|
||||||
|
/** A stepped ("up-to") day total: day N used `dayMinutes`, priced by the tier at
|
||||||
|
* `uptoMin` (`repeated` = past the top tier, so the top total repeats as a cap). */
|
||||||
|
| {
|
||||||
|
readonly kind: "step";
|
||||||
|
readonly day: number;
|
||||||
|
readonly dayMinutes: number;
|
||||||
|
readonly uptoMin: number;
|
||||||
|
readonly amountMinor: number;
|
||||||
|
readonly repeated: boolean;
|
||||||
|
}
|
||||||
|
/** The daily cap clamped day N: amountMinor is the (negative) adjustment. */
|
||||||
|
| { readonly kind: "cap"; readonly day: number; readonly capMinor: number; readonly amountMinor: number };
|
||||||
|
|
||||||
|
export interface FeeBreakdown {
|
||||||
|
/** Actual stay length in whole minutes (before increment rounding). */
|
||||||
|
readonly rawMinutes: number;
|
||||||
|
/** Minutes billed after rounding UP to the increment (0 within grace). */
|
||||||
|
readonly billedMinutes: number;
|
||||||
|
readonly incrementMin: number;
|
||||||
|
readonly items: FeeBreakdownItem[];
|
||||||
|
/** Σ item amounts — always equals computeFee for the same arguments. */
|
||||||
|
readonly totalMinor: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Explain a fee: run the exact computeFee walk with a trace collector and return
|
||||||
|
* the line items plus the total. Same arguments as computeFee; the total returned
|
||||||
|
* here IS computeFee's answer (one code path, not a parallel calculation).
|
||||||
|
*/
|
||||||
|
export function explainFee(
|
||||||
|
enteredAt: string,
|
||||||
|
asOf: string,
|
||||||
|
tariff: TariffStructure,
|
||||||
|
category?: string,
|
||||||
|
): FeeBreakdown {
|
||||||
|
const items: FeeBreakdownItem[] = [];
|
||||||
|
const totalMinor = isTariffV2(tariff)
|
||||||
|
? computeFeeV2(enteredAt, asOf, tariff, category, items)
|
||||||
|
: computeFeeV1(enteredAt, asOf, tariff, items);
|
||||||
|
const ms = Date.parse(asOf) - Date.parse(enteredAt);
|
||||||
|
const rawMinutes = Number.isFinite(ms) && ms > 0 ? Math.round(ms / 60_000) : 0;
|
||||||
|
const inc = Math.max(1, tariff.incrementMin);
|
||||||
|
const inGrace = items.length === 1 && items[0]!.kind === "grace";
|
||||||
|
const billedMinutes =
|
||||||
|
inGrace || rawMinutes === 0 || ms / 60_000 <= tariff.gracePeriodEntryMin
|
||||||
|
? 0
|
||||||
|
: Math.ceil(ms / 60_000 / inc) * inc;
|
||||||
|
return { rawMinutes, billedMinutes, incrementMin: inc, items, totalMinor };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Band-merging helper for the trace: accumulate consecutive increments that share
|
||||||
|
* a (card, unit price) and flush them as one `band` item. */
|
||||||
|
class BandTracer {
|
||||||
|
#card: string | null = null;
|
||||||
|
#unit = 0;
|
||||||
|
#from = 0;
|
||||||
|
#count = 0;
|
||||||
|
constructor(private readonly items: FeeBreakdownItem[], private readonly inc: number) {}
|
||||||
|
add(card: string | null, unitMinor: number, atMin: number): void {
|
||||||
|
if (this.#count > 0 && this.#card === card && this.#unit === unitMinor) {
|
||||||
|
this.#count++;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.flush();
|
||||||
|
this.#card = card;
|
||||||
|
this.#unit = unitMinor;
|
||||||
|
this.#from = atMin;
|
||||||
|
this.#count = 1;
|
||||||
|
}
|
||||||
|
flush(): void {
|
||||||
|
if (this.#count === 0) return;
|
||||||
|
this.items.push({
|
||||||
|
kind: "band",
|
||||||
|
card: this.#card,
|
||||||
|
fromMin: this.#from,
|
||||||
|
toMin: this.#from + this.#count * this.inc,
|
||||||
|
increments: this.#count,
|
||||||
|
unitMinor: this.#unit,
|
||||||
|
amountMinor: this.#count * this.#unit,
|
||||||
|
});
|
||||||
|
this.#count = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Total fee for ELAPSED minutes under a STEPPED tariff, per the rolling-24h-day rule.
|
* Total fee for ELAPSED minutes under a STEPPED tariff, per the rolling-24h-day rule.
|
||||||
* Pure + integer. The smallest tier whose `uptoMin ≥` the day's minutes wins (≤ /
|
* Pure + integer. The smallest tier whose `uptoMin ≥` the day's minutes wins (≤ /
|
||||||
@@ -771,7 +1021,7 @@ export function hasSteps(s: { steps?: readonly TariffStep[] }): boolean {
|
|||||||
* FULL day (a daily-cap repeat) and price the remainder on the next day's ladder.
|
* FULL day (a daily-cap repeat) and price the remainder on the next day's ladder.
|
||||||
* `steps` need not be sorted; we sort defensively. See wiki/concepts/tariff.md.
|
* `steps` need not be sorted; we sort defensively. See wiki/concepts/tariff.md.
|
||||||
*/
|
*/
|
||||||
function steppedFee(minutes: number, steps: readonly TariffStep[]): number {
|
function steppedFee(minutes: number, steps: readonly TariffStep[], trace?: FeeBreakdownItem[]): number {
|
||||||
if (minutes <= 0 || steps.length === 0) return 0;
|
if (minutes <= 0 || steps.length === 0) return 0;
|
||||||
const sorted = [...steps].sort((a, b) => a.uptoMin - b.uptoMin);
|
const sorted = [...steps].sort((a, b) => a.uptoMin - b.uptoMin);
|
||||||
const top = sorted[sorted.length - 1]!;
|
const top = sorted[sorted.length - 1]!;
|
||||||
@@ -780,8 +1030,17 @@ function steppedFee(minutes: number, steps: readonly TariffStep[]): number {
|
|||||||
for (let dayStart = 0; dayStart < minutes; dayStart += DAY) {
|
for (let dayStart = 0; dayStart < minutes; dayStart += DAY) {
|
||||||
const dayMin = Math.min(DAY, minutes - dayStart); // minutes within this rolling day
|
const dayMin = Math.min(DAY, minutes - dayStart); // minutes within this rolling day
|
||||||
// Beyond the largest tier → the whole day is the top total (per-day cap repeat).
|
// Beyond the largest tier → the whole day is the top total (per-day cap repeat).
|
||||||
const tier = sorted.find((s) => dayMin <= s.uptoMin) ?? top;
|
const found = sorted.find((s) => dayMin <= s.uptoMin);
|
||||||
|
const tier = found ?? top;
|
||||||
total += tier.totalMinor;
|
total += tier.totalMinor;
|
||||||
|
trace?.push({
|
||||||
|
kind: "step",
|
||||||
|
day: dayStart / DAY + 1,
|
||||||
|
dayMinutes: dayMin,
|
||||||
|
uptoMin: tier.uptoMin,
|
||||||
|
amountMinor: tier.totalMinor,
|
||||||
|
repeated: found == null,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return total;
|
return total;
|
||||||
}
|
}
|
||||||
@@ -791,30 +1050,50 @@ function steppedFee(minutes: number, steps: readonly TariffStep[]): number {
|
|||||||
* identically. Do not "unify" this into the V2 path: a rounding divergence would
|
* identically. Do not "unify" this into the V2 path: a rounding divergence would
|
||||||
* corrupt repricing of already-signed sessions. A `steps` table (when present)
|
* corrupt repricing of already-signed sessions. A `steps` table (when present)
|
||||||
* REPLACES the ladder via {@link steppedFee}. */
|
* REPLACES the ladder via {@link steppedFee}. */
|
||||||
function computeFeeV1(enteredAt: string, asOf: string, tariff: TariffStructureV1): number {
|
function computeFeeV1(
|
||||||
|
enteredAt: string,
|
||||||
|
asOf: string,
|
||||||
|
tariff: TariffStructureV1,
|
||||||
|
trace?: FeeBreakdownItem[],
|
||||||
|
): number {
|
||||||
const ms = Date.parse(asOf) - Date.parse(enteredAt);
|
const ms = Date.parse(asOf) - Date.parse(enteredAt);
|
||||||
if (!Number.isFinite(ms) || ms <= 0) return 0;
|
if (!Number.isFinite(ms) || ms <= 0) return 0;
|
||||||
const rawMinutes = ms / 60_000;
|
const rawMinutes = ms / 60_000;
|
||||||
// Grace uses the RAW duration (a 10-min stay is free even if the increment is
|
// Grace uses the RAW duration (a 10-min stay is free even if the increment is
|
||||||
// 60 min — otherwise rounding-up would defeat the grace window).
|
// 60 min — otherwise rounding-up would defeat the grace window).
|
||||||
if (rawMinutes <= tariff.gracePeriodEntryMin) return 0;
|
if (rawMinutes <= tariff.gracePeriodEntryMin) {
|
||||||
|
trace?.push({ kind: "grace", minutes: Math.round(rawMinutes) });
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
const inc = Math.max(1, tariff.incrementMin);
|
const inc = Math.max(1, tariff.incrementMin);
|
||||||
const minutes = Math.ceil(rawMinutes / inc) * inc; // round UP to the increment
|
const minutes = Math.ceil(rawMinutes / inc) * inc; // round UP to the increment
|
||||||
|
|
||||||
// STEPPED pricing: a total-by-duration table replaces the marginal ladder.
|
// STEPPED pricing: a total-by-duration table replaces the marginal ladder.
|
||||||
if (hasSteps(tariff)) return steppedFee(minutes, tariff.steps!);
|
if (hasSteps(tariff)) return steppedFee(minutes, tariff.steps!, trace);
|
||||||
|
|
||||||
const DAY = 24 * 60;
|
const DAY = 24 * 60;
|
||||||
let total = 0;
|
let total = 0;
|
||||||
for (let segStart = 0; segStart < minutes; segStart += DAY) {
|
for (let segStart = 0; segStart < minutes; segStart += DAY) {
|
||||||
const segEnd = Math.min(segStart + DAY, minutes);
|
const segEnd = Math.min(segStart + DAY, minutes);
|
||||||
let segFee = 0;
|
let segFee = 0;
|
||||||
|
const bands = trace ? new BandTracer(trace, inc) : null;
|
||||||
// The block ladder RESETS each rolling-24h day: `within` is minutes elapsed
|
// The block ladder RESETS each rolling-24h day: `within` is minutes elapsed
|
||||||
// WITHIN this day, so day 2 starts at the first block again (decision 2026-06-15).
|
// WITHIN this day, so day 2 starts at the first block again (decision 2026-06-15).
|
||||||
for (let within = 0; segStart + within < segEnd; within += inc) {
|
for (let within = 0; segStart + within < segEnd; within += inc) {
|
||||||
segFee += rateAt(tariff.blocks, within);
|
const unit = rateAt(tariff.blocks, within);
|
||||||
|
segFee += unit;
|
||||||
|
bands?.add(null, unit, segStart + within);
|
||||||
|
}
|
||||||
|
bands?.flush();
|
||||||
|
if (tariff.dailyCapMinor != null && segFee > tariff.dailyCapMinor) {
|
||||||
|
trace?.push({
|
||||||
|
kind: "cap",
|
||||||
|
day: segStart / DAY + 1,
|
||||||
|
capMinor: tariff.dailyCapMinor,
|
||||||
|
amountMinor: tariff.dailyCapMinor - segFee,
|
||||||
|
});
|
||||||
|
segFee = tariff.dailyCapMinor;
|
||||||
}
|
}
|
||||||
if (tariff.dailyCapMinor != null) segFee = Math.min(segFee, tariff.dailyCapMinor);
|
|
||||||
total += segFee;
|
total += segFee;
|
||||||
}
|
}
|
||||||
return total;
|
return total;
|
||||||
@@ -837,12 +1116,16 @@ function computeFeeV2(
|
|||||||
asOf: string,
|
asOf: string,
|
||||||
tariff: TariffStructureV2,
|
tariff: TariffStructureV2,
|
||||||
category?: string,
|
category?: string,
|
||||||
|
trace?: FeeBreakdownItem[],
|
||||||
): number {
|
): number {
|
||||||
const enteredMs = Date.parse(enteredAt);
|
const enteredMs = Date.parse(enteredAt);
|
||||||
const ms = Date.parse(asOf) - enteredMs;
|
const ms = Date.parse(asOf) - enteredMs;
|
||||||
if (!Number.isFinite(ms) || ms <= 0) return 0;
|
if (!Number.isFinite(ms) || ms <= 0) return 0;
|
||||||
const rawMinutes = ms / 60_000;
|
const rawMinutes = ms / 60_000;
|
||||||
if (rawMinutes <= tariff.gracePeriodEntryMin) return 0; // grace on RAW duration (V1 rule)
|
if (rawMinutes <= tariff.gracePeriodEntryMin) {
|
||||||
|
trace?.push({ kind: "grace", minutes: Math.round(rawMinutes) });
|
||||||
|
return 0; // grace on RAW duration (V1 rule)
|
||||||
|
}
|
||||||
const inc = Math.max(1, tariff.incrementMin);
|
const inc = Math.max(1, tariff.incrementMin);
|
||||||
const minutes = Math.ceil(rawMinutes / inc) * inc; // round UP (V1 rule)
|
const minutes = Math.ceil(rawMinutes / inc) * inc; // round UP (V1 rule)
|
||||||
|
|
||||||
@@ -862,7 +1145,11 @@ function computeFeeV2(
|
|||||||
// defaultCard is stepped we price the WHOLE stay by the stepped day rule and ignore
|
// defaultCard is stepped we price the WHOLE stay by the stepped day rule and ignore
|
||||||
// windowed cards (they have nothing to override at the increment level). This is the
|
// windowed cards (they have nothing to override at the increment level). This is the
|
||||||
// only sound place for steps in V2. See wiki/concepts/tariff.md.
|
// only sound place for steps in V2. See wiki/concepts/tariff.md.
|
||||||
if (hasSteps(tariff.defaultCard)) return steppedFee(minutes, tariff.defaultCard.steps!);
|
if (hasSteps(tariff.defaultCard)) return steppedFee(minutes, tariff.defaultCard.steps!, trace);
|
||||||
|
|
||||||
|
// Trace labels: the defaultCard reads as the base rate (null), a windowed card by
|
||||||
|
// its name.
|
||||||
|
const traceName = (card: TariffCard): string | null => (card === tariff.defaultCard ? null : card.name);
|
||||||
|
|
||||||
let total = 0;
|
let total = 0;
|
||||||
// WINDOW-PACKAGE tracking (2026-07-05): a `packageMinor` card charges ONE total per
|
// WINDOW-PACKAGE tracking (2026-07-05): a `packageMinor` card charges ONE total per
|
||||||
@@ -876,21 +1163,33 @@ function computeFeeV2(
|
|||||||
for (let segStart = 0; segStart < minutes; segStart += DAY) {
|
for (let segStart = 0; segStart < minutes; segStart += DAY) {
|
||||||
const segEnd = Math.min(segStart + DAY, minutes);
|
const segEnd = Math.min(segStart + DAY, minutes);
|
||||||
let segFee = 0;
|
let segFee = 0;
|
||||||
|
const bands = trace ? new BandTracer(trace, inc) : null;
|
||||||
for (let within = segStart; within < segEnd; within += inc) {
|
for (let within = segStart; within < segEnd; within += inc) {
|
||||||
const wall = localBreakdown(enteredMs + within * 60_000, tariff.tz);
|
const wall = localBreakdown(enteredMs + within * 60_000, tariff.tz);
|
||||||
const card = selectCard(cards, wall);
|
const card = selectCard(cards, wall);
|
||||||
if (card.packageMinor != null) {
|
if (card.packageMinor != null) {
|
||||||
// First increment of a new occurrence pays the package; the rest ride free.
|
// First increment of a new occurrence pays the package; the rest ride free.
|
||||||
if (prevWinner !== card) segFee += card.packageMinor;
|
if (prevWinner !== card) {
|
||||||
|
segFee += card.packageMinor;
|
||||||
|
bands?.flush();
|
||||||
|
trace?.push({ kind: "package", card: card.name, fromMin: within, amountMinor: card.packageMinor });
|
||||||
|
}
|
||||||
} else if (card.flatMinor != null) {
|
} else if (card.flatMinor != null) {
|
||||||
segFee += card.flatMinor;
|
segFee += card.flatMinor;
|
||||||
|
bands?.add(traceName(card), card.flatMinor, within);
|
||||||
} else {
|
} else {
|
||||||
// Ladder position = minutes into THIS rolling-24h day (resets each day, V1 rule).
|
// Ladder position = minutes into THIS rolling-24h day (resets each day, V1 rule).
|
||||||
segFee += rateAt(card.blocks ?? [], within - segStart);
|
const unit = rateAt(card.blocks ?? [], within - segStart);
|
||||||
|
segFee += unit;
|
||||||
|
bands?.add(traceName(card), unit, within);
|
||||||
}
|
}
|
||||||
prevWinner = card;
|
prevWinner = card;
|
||||||
}
|
}
|
||||||
if (dayCap != null) segFee = Math.min(segFee, dayCap);
|
bands?.flush();
|
||||||
|
if (dayCap != null && segFee > dayCap) {
|
||||||
|
trace?.push({ kind: "cap", day: segStart / DAY + 1, capMinor: dayCap, amountMinor: dayCap - segFee });
|
||||||
|
segFee = dayCap;
|
||||||
|
}
|
||||||
total += segFee;
|
total += segFee;
|
||||||
}
|
}
|
||||||
return total;
|
return total;
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import {
|
import {
|
||||||
computeFee,
|
computeFee,
|
||||||
|
explainFee,
|
||||||
priceSession,
|
priceSession,
|
||||||
validateTariffStructure,
|
validateTariffStructure,
|
||||||
|
type FeeBreakdownItem,
|
||||||
|
type TariffStructure,
|
||||||
type TariffStructureV1,
|
type TariffStructureV1,
|
||||||
type TariffStructureV2,
|
type TariffStructureV2,
|
||||||
type TariffCard,
|
type TariffCard,
|
||||||
@@ -437,3 +440,196 @@ describe("V2 window package (whole-window total)", () => {
|
|||||||
expect(validateTariffStructure(capped).some((e) => /dailyCapMinor does not apply to a window package/.test(e))).toBe(true);
|
expect(validateTariffStructure(capped).some((e) => /dailyCapMinor does not apply to a window package/.test(e))).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("explainFee — the breakdown IS the fee (2026-07-06)", () => {
|
||||||
|
const v1: TariffStructure = {
|
||||||
|
gracePeriodEntryMin: 5,
|
||||||
|
incrementMin: 60,
|
||||||
|
lostTicketMinor: 2000,
|
||||||
|
gracePeriodExitMin: 10,
|
||||||
|
overstay: "reprice",
|
||||||
|
blocks: [
|
||||||
|
{ uptoMin: 120, priceMinorPerIncrement: 200 },
|
||||||
|
{ uptoMin: null, priceMinorPerIncrement: 100 },
|
||||||
|
],
|
||||||
|
dailyCapMinor: 500,
|
||||||
|
};
|
||||||
|
|
||||||
|
const sum = (b: ReturnType<typeof explainFee>) => b.items.reduce((a, i) => a + ("amountMinor" in i ? i.amountMinor : 0), 0);
|
||||||
|
|
||||||
|
it("V1 ladder: bands merge per rate, the cap shows as a negative line, sum == fee", () => {
|
||||||
|
const from = "2026-07-06T08:00:00.000Z";
|
||||||
|
const to = "2026-07-06T15:02:00.000Z"; // 7h2m → 8 increments: 2×200 + 6×100 = 1000 → cap 500
|
||||||
|
const b = explainFee(from, to, v1);
|
||||||
|
expect(b.totalMinor).toBe(computeFee(from, to, v1));
|
||||||
|
expect(b.totalMinor).toBe(500);
|
||||||
|
expect(sum(b)).toBe(b.totalMinor);
|
||||||
|
expect(b.items.map((i) => i.kind)).toEqual(["band", "band", "cap"]);
|
||||||
|
const [first, second, cap] = b.items as [
|
||||||
|
Extract<FeeBreakdownItem, { kind: "band" }>,
|
||||||
|
Extract<FeeBreakdownItem, { kind: "band" }>,
|
||||||
|
Extract<FeeBreakdownItem, { kind: "cap" }>,
|
||||||
|
];
|
||||||
|
expect([first.increments, first.unitMinor, first.amountMinor]).toEqual([2, 200, 400]);
|
||||||
|
expect([second.increments, second.unitMinor, second.amountMinor]).toEqual([6, 100, 600]);
|
||||||
|
expect(cap.amountMinor).toBe(-500);
|
||||||
|
expect(b.billedMinutes).toBe(480);
|
||||||
|
expect(b.rawMinutes).toBe(422);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("grace: one zero line, billed 0", () => {
|
||||||
|
const b = explainFee("2026-07-06T08:00:00.000Z", "2026-07-06T08:04:00.000Z", v1);
|
||||||
|
expect(b.items).toEqual([{ kind: "grace", minutes: 4 }]);
|
||||||
|
expect(b.totalMinor).toBe(0);
|
||||||
|
expect(b.billedMinutes).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stepped: one line per rolling day, top tier repeats flagged", () => {
|
||||||
|
const stepped: TariffStructure = {
|
||||||
|
...v1,
|
||||||
|
blocks: [],
|
||||||
|
dailyCapMinor: null,
|
||||||
|
steps: [
|
||||||
|
{ uptoMin: 180, totalMinor: 500 },
|
||||||
|
{ uptoMin: 1440, totalMinor: 1000 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const from = "2026-07-04T08:00:00.000Z";
|
||||||
|
const to = "2026-07-05T10:00:00.000Z"; // 26h → day1 top(1000) + day2 ≤180 (500)
|
||||||
|
const b = explainFee(from, to, stepped);
|
||||||
|
expect(b.totalMinor).toBe(computeFee(from, to, stepped));
|
||||||
|
expect(sum(b)).toBe(b.totalMinor);
|
||||||
|
expect(b.items).toEqual([
|
||||||
|
{ kind: "step", day: 1, dayMinutes: 1440, uptoMin: 1440, amountMinor: 1000, repeated: false },
|
||||||
|
{ kind: "step", day: 2, dayMinutes: 120, uptoMin: 180, amountMinor: 500, repeated: false },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("V2 night package + base ladder: package is one line, bands name the card, sum == fee", () => {
|
||||||
|
const v2: TariffStructure = {
|
||||||
|
version: 2,
|
||||||
|
tz: "Europe/Tirane",
|
||||||
|
gracePeriodEntryMin: 5,
|
||||||
|
incrementMin: 60,
|
||||||
|
lostTicketMinor: 2000,
|
||||||
|
gracePeriodExitMin: 10,
|
||||||
|
overstay: "reprice",
|
||||||
|
defaultCard: { name: "default", priority: 0, blocks: [{ uptoMin: null, priceMinorPerIncrement: 10000 }], dailyCapMinor: null },
|
||||||
|
windowedCards: [
|
||||||
|
{ name: "night", priority: 10, window: { fromHour: "20:00", toHour: "07:00" }, packageMinor: 40000 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
// 18:00 → 22:30 local (16:00Z→20:30Z in July, UTC+2): 2 base hours + the night package.
|
||||||
|
const from = "2026-07-06T16:00:00.000Z";
|
||||||
|
const to = "2026-07-06T20:30:00.000Z";
|
||||||
|
const b = explainFee(from, to, v2);
|
||||||
|
expect(b.totalMinor).toBe(computeFee(from, to, v2));
|
||||||
|
expect(sum(b)).toBe(b.totalMinor);
|
||||||
|
expect(b.totalMinor).toBe(2 * 10000 + 40000);
|
||||||
|
expect(b.items).toEqual([
|
||||||
|
{ kind: "band", card: null, fromMin: 0, toMin: 120, increments: 2, unitMinor: 10000, amountMinor: 20000 },
|
||||||
|
{ kind: "package", card: "night", fromMin: 120, amountMinor: 40000 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// (i) Merchant validations — the priceSession discount fold (2026-07-13).
|
||||||
|
// liveV1: 5-min entry grace, 60-min increment, blocks 20000(1h)/10000(to 3h),
|
||||||
|
// daily cap 100000, exit grace 5 min. See wiki/concepts/validation-discounts.md.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe("priceSession merchant validations", () => {
|
||||||
|
const val = (
|
||||||
|
mode: "comp" | "timeCredit" | "fixed" | "percent",
|
||||||
|
over: Partial<import("./index.js").SessionValidation> = {},
|
||||||
|
): import("./index.js").SessionValidation => ({
|
||||||
|
programId: "bar",
|
||||||
|
label: "Bar",
|
||||||
|
mode,
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
it("no validations → gross == net, no lines (back-compat)", () => {
|
||||||
|
const r = priceSession(entered, at(120), liveV1, []);
|
||||||
|
expect(r.grossMinor).toBe(30000);
|
||||||
|
expect(r.amountMinor).toBe(30000);
|
||||||
|
expect(r.discountMinor).toBe(0);
|
||||||
|
expect(r.validationLines).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("comp zeroes the fee and the line carries the whole gross", () => {
|
||||||
|
const r = priceSession(entered, at(120), liveV1, [], undefined, [val("comp")]);
|
||||||
|
expect(r.grossMinor).toBe(30000);
|
||||||
|
expect(r.amountMinor).toBe(0);
|
||||||
|
expect(r.discountMinor).toBe(30000);
|
||||||
|
expect(r.validationLines).toEqual([{ programId: "bar", label: "Bar", mode: "comp", discountMinor: 30000 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fixed subtracts, floors at 0, and clamps the line to the remainder", () => {
|
||||||
|
// 2h → 30000 gross; 300-off style: fixed 20000 → net 10000.
|
||||||
|
const r = priceSession(entered, at(120), liveV1, [], undefined, [val("fixed", { amountMinor: 20000 })]);
|
||||||
|
expect(r.amountMinor).toBe(10000);
|
||||||
|
expect(r.discountMinor).toBe(20000);
|
||||||
|
// Bigger than the fee → net 0, line clamped to the gross (Σ lines ≡ gross − net).
|
||||||
|
const r2 = priceSession(entered, at(120), liveV1, [], undefined, [val("fixed", { amountMinor: 99999 })]);
|
||||||
|
expect(r2.amountMinor).toBe(0);
|
||||||
|
expect(r2.validationLines[0]!.discountMinor).toBe(30000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("timeCredit prices as if entered later — 'first hour free' is literal", () => {
|
||||||
|
// 2h stay, 60 free minutes → bill the remaining 1h at the FIRST block (20000),
|
||||||
|
// exactly what a 1h stay costs.
|
||||||
|
const r = priceSession(entered, at(120), liveV1, [], undefined, [val("timeCredit", { minutes: 60 })]);
|
||||||
|
expect(r.grossMinor).toBe(30000);
|
||||||
|
expect(r.amountMinor).toBe(computeFee(at(60), at(120), liveV1));
|
||||||
|
expect(r.amountMinor).toBe(20000);
|
||||||
|
expect(r.validationLines[0]!.discountMinor).toBe(10000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("timeCredit covering the whole stay → net 0", () => {
|
||||||
|
const r = priceSession(entered, at(50), liveV1, [], undefined, [val("timeCredit", { minutes: 120 })]);
|
||||||
|
expect(r.amountMinor).toBe(0);
|
||||||
|
expect(r.discountMinor).toBe(r.grossMinor);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("percent takes a floor'd share of the remaining fee", () => {
|
||||||
|
const r = priceSession(entered, at(120), liveV1, [], undefined, [val("percent", { percent: 50 })]);
|
||||||
|
expect(r.amountMinor).toBe(15000);
|
||||||
|
expect(r.discountMinor).toBe(15000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stacking is canonical-order (timeCredit → percent → fixed → comp) and Σ lines ≡ gross − net", () => {
|
||||||
|
// Scan order deliberately reversed; the fold must still do time first.
|
||||||
|
const r = priceSession(entered, at(120), liveV1, [], undefined, [
|
||||||
|
val("fixed", { amountMinor: 5000, programId: "bar" }),
|
||||||
|
val("timeCredit", { minutes: 60, programId: "lavazh", label: "Lavazh" }),
|
||||||
|
]);
|
||||||
|
// gross 30000 → time credit leaves 20000 → fixed 5000 → net 15000.
|
||||||
|
expect(r.grossMinor).toBe(30000);
|
||||||
|
expect(r.amountMinor).toBe(15000);
|
||||||
|
const sum = r.validationLines.reduce((a, l) => a + l.discountMinor, 0);
|
||||||
|
expect(sum).toBe(r.discountMinor);
|
||||||
|
expect(r.validationLines.map((l) => l.mode)).toEqual(["timeCredit", "fixed"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a settled (paid + within grace) session ignores validations", () => {
|
||||||
|
const r = priceSession(entered, at(123), liveV1, [{ paidAt: at(120), graceExitMin: 5 }], undefined, [
|
||||||
|
val("comp"),
|
||||||
|
]);
|
||||||
|
expect(r.withinGrace).toBe(true);
|
||||||
|
expect(r.amountMinor).toBe(0);
|
||||||
|
expect(r.validationLines).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an overstay period applies (unconsumed) validations to the FRESH period", () => {
|
||||||
|
// Paid at 120, grace 5 → overstay period starts at 125. A 60-min credit eats the
|
||||||
|
// overstay's first hour: net = fee(185→245 from period start) = the 1h price… i.e.
|
||||||
|
// fee of (245−125−60)=60 min from the ladder start.
|
||||||
|
const r = priceSession(entered, at(245), liveV1, [{ paidAt: at(120), graceExitMin: 5 }], undefined, [
|
||||||
|
val("timeCredit", { minutes: 60 }),
|
||||||
|
]);
|
||||||
|
expect(r.overstay).toBe(true);
|
||||||
|
expect(r.grossMinor).toBe(computeFee(at(125), at(245), liveV1));
|
||||||
|
expect(r.amountMinor).toBe(computeFee(at(185), at(245), liveV1));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Generated
+33
@@ -108,12 +108,24 @@ importers:
|
|||||||
'@tanstack/react-router':
|
'@tanstack/react-router':
|
||||||
specifier: ^1.170.16
|
specifier: ^1.170.16
|
||||||
version: 1.170.16(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
version: 1.170.16(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||||
|
'@tauri-apps/api':
|
||||||
|
specifier: ^2.11.1
|
||||||
|
version: 2.11.1
|
||||||
|
'@tauri-apps/plugin-http':
|
||||||
|
specifier: ^2.5.2
|
||||||
|
version: 2.6.0
|
||||||
'@tauri-apps/plugin-process':
|
'@tauri-apps/plugin-process':
|
||||||
specifier: ^2.3.1
|
specifier: ^2.3.1
|
||||||
version: 2.3.1
|
version: 2.3.1
|
||||||
|
'@tauri-apps/plugin-store':
|
||||||
|
specifier: ^2.4.0
|
||||||
|
version: 2.4.4
|
||||||
'@tauri-apps/plugin-updater':
|
'@tauri-apps/plugin-updater':
|
||||||
specifier: ^2.10.1
|
specifier: ^2.10.1
|
||||||
version: 2.10.1
|
version: 2.10.1
|
||||||
|
'@tauri-apps/plugin-websocket':
|
||||||
|
specifier: ^2.3.0
|
||||||
|
version: 2.4.3
|
||||||
i18next:
|
i18next:
|
||||||
specifier: ^26.3.1
|
specifier: ^26.3.1
|
||||||
version: 26.3.1(typescript@6.0.3)
|
version: 26.3.1(typescript@6.0.3)
|
||||||
@@ -1577,12 +1589,21 @@ packages:
|
|||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
hasBin: true
|
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':
|
'@tauri-apps/plugin-process@2.3.1':
|
||||||
resolution: {integrity: sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA==}
|
resolution: {integrity: sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA==}
|
||||||
|
|
||||||
|
'@tauri-apps/plugin-store@2.4.4':
|
||||||
|
resolution: {integrity: sha512-oxSMaj/QpVfJcBMYX5aOQV94fWvga0MwQMfD6TLlbK2dh+ShPWAzefd8HWXhvOKjPRJdGVAkW7ZGO76JzzjaDA==}
|
||||||
|
|
||||||
'@tauri-apps/plugin-updater@2.10.1':
|
'@tauri-apps/plugin-updater@2.10.1':
|
||||||
resolution: {integrity: sha512-NFYMg+tWOZPJdzE/PpFj2qfqwAWwNS3kXrb1tm1gnBJ9mYzZ4WDRrwy8udzWoAnfGCHLuePNLY1WVCNHnh3eRA==}
|
resolution: {integrity: sha512-NFYMg+tWOZPJdzE/PpFj2qfqwAWwNS3kXrb1tm1gnBJ9mYzZ4WDRrwy8udzWoAnfGCHLuePNLY1WVCNHnh3eRA==}
|
||||||
|
|
||||||
|
'@tauri-apps/plugin-websocket@2.4.3':
|
||||||
|
resolution: {integrity: sha512-c85ykljg6AzY6Zw4KpYsEBaLirjPIs6m8xxC6hZcdwAchckaBU368US+oSsa5B43PjSLukjwD5vOOqzYYnswWA==}
|
||||||
|
|
||||||
'@testing-library/dom@10.4.1':
|
'@testing-library/dom@10.4.1':
|
||||||
resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==}
|
resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -4089,14 +4110,26 @@ snapshots:
|
|||||||
'@tauri-apps/cli-win32-ia32-msvc': 2.11.3
|
'@tauri-apps/cli-win32-ia32-msvc': 2.11.3
|
||||||
'@tauri-apps/cli-win32-x64-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':
|
'@tauri-apps/plugin-process@2.3.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tauri-apps/api': 2.11.1
|
'@tauri-apps/api': 2.11.1
|
||||||
|
|
||||||
|
'@tauri-apps/plugin-store@2.4.4':
|
||||||
|
dependencies:
|
||||||
|
'@tauri-apps/api': 2.11.1
|
||||||
|
|
||||||
'@tauri-apps/plugin-updater@2.10.1':
|
'@tauri-apps/plugin-updater@2.10.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tauri-apps/api': 2.11.1
|
'@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':
|
'@testing-library/dom@10.4.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/code-frame': 7.29.7
|
'@babel/code-frame': 7.29.7
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
type: concept
|
type: concept
|
||||||
tags: [parking, observability, diagnostics, logging, frontend, backend]
|
tags: [parking, observability, diagnostics, logging, frontend, backend]
|
||||||
sources: []
|
sources: []
|
||||||
updated: 2026-07-04
|
updated: 2026-07-08
|
||||||
status: open
|
status: open
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -62,6 +62,16 @@ column — the failed request, error name, component stack, anything), plus pull
|
|||||||
logged — that's the recursion we guard). Diagnostics must never break the path they observe.
|
logged — that's the recursion we guard). Diagnostics must never break the path they observe.
|
||||||
- **Bounded.** Frontend queue capped (drops oldest); message/stack/context clamped per row;
|
- **Bounded.** Frontend queue capped (drops oldest); message/stack/context clamped per row;
|
||||||
ingest batch capped.
|
ingest batch capped.
|
||||||
|
- **Storm coalescing (2026-07-08).** A line identical to the *last persisted row*
|
||||||
|
(level+source+message+path) arriving within **5 min** of its previous occurrence **updates that
|
||||||
|
row** instead of inserting: `context._repeat` counts the fold, `context._firstAt` keeps the first
|
||||||
|
occurrence, `createdAt` moves to the latest (so the storm stays at the top of the newest-first
|
||||||
|
viewer, which badges it `×N`). A *continuous* storm refreshes the window each hit, so it stays ONE
|
||||||
|
row however long it rages. Motivation: the 2026-07-07 field incident — one unreachable controller
|
||||||
|
(`ENETUNREACH`) produced hundreds of identical error rows per minute, evicting unrelated history
|
||||||
|
(see [[button-light-indicator]] for the send-side fix: retry backoff + rate-limited logging).
|
||||||
|
In-memory last-row cache only (a restart just starts a fresh row); if the row was pruned
|
||||||
|
underneath, it falls through to a fresh insert.
|
||||||
|
|
||||||
## Retention (offline appliance ⇒ must be bounded)
|
## Retention (offline appliance ⇒ must be bounded)
|
||||||
|
|
||||||
@@ -71,6 +81,10 @@ was 30) **and** keep only the newest `LOG_RETENTION_MAX_ROWS` (default 50 000).
|
|||||||
(unref'd timer) + once at startup. Both env-configurable. Same "prunable, not precious"
|
(unref'd timer) + once at startup. Both env-configurable. Same "prunable, not precious"
|
||||||
durability class as `device_events` — the opposite of the append-only ledger.
|
durability class as `device_events` — the opposite of the append-only ledger.
|
||||||
|
|
||||||
|
Also wipeable on demand: `reset-db.mjs --diagnostics` (new category 2026-07-08 — `app_logs`
|
||||||
|
previously belonged to NO category and silently survived even `--all`; a drift guard in the script
|
||||||
|
now refuses to run if any table is uncategorized). See [[local-dev-workflow]].
|
||||||
|
|
||||||
## Container (stdout) logs — the OTHER log store (2026-07-04)
|
## Container (stdout) logs — the OTHER log store (2026-07-04)
|
||||||
|
|
||||||
`docker logs` is a separate, size-bounded store from `app_logs` — it holds **everything**
|
`docker logs` is a separate, size-bounded store from `app_logs` — it holds **everything**
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
type: concept
|
type: concept
|
||||||
tags: [parking, durability, backup, recovery, security, crypto]
|
tags: [parking, durability, backup, recovery, security, crypto]
|
||||||
sources: []
|
sources: []
|
||||||
updated: 2026-06-29
|
updated: 2026-08-30
|
||||||
---
|
---
|
||||||
|
|
||||||
# Backup & Disaster Recovery
|
# Backup & Disaster Recovery
|
||||||
@@ -207,12 +207,68 @@ timer + the manual route**. What landed:
|
|||||||
**SMB/NFS already work** — they're just a mounted path the admin enters as the target. **Deferred to
|
**SMB/NFS already work** — they're just a mounted path the admin enters as the target. **Deferred to
|
||||||
follow-up slices:** an **SFTP** target and a **restore runbook / CLI**.
|
follow-up slices:** an **SFTP** target and a **restore runbook / CLI**.
|
||||||
|
|
||||||
|
## Field bug — "last successful backup: Never" despite valid, rotating backups on disk (found + fixed 2026-08-30)
|
||||||
|
|
||||||
|
**Symptom (park-buzi):** the admin noticed the backup directory held 7 real, correctly-sized,
|
||||||
|
correctly-rotating encrypted backups (`parking-backup-*.sqlite.enc`, retention working exactly as
|
||||||
|
designed) — yet the Backup screen's "Kopja e fundit e suksesshme" (last successful backup) showed
|
||||||
|
**"Asnjëherë" (Never)**. Separately, the most recent file was 2 days old rather than ~1.
|
||||||
|
|
||||||
|
**Root cause — two independent, disconnected code paths, both traced to `setInterval`-since-
|
||||||
|
process-start:**
|
||||||
|
|
||||||
|
1. **Status was never persisted.** `BackupService` tracked `lastSuccessAt`/`lastResult`/
|
||||||
|
`lastErrorAt`/`lastError` as **plain in-process private fields** — set only inside `run()`,
|
||||||
|
read only by `status()` on the *same running instance*. Nothing wrote them to `site_config` or
|
||||||
|
anywhere else durable. The actual backup-writing engine (`backup.ts`: consistent copy → encrypt
|
||||||
|
→ `pruneOldBackups`) is a completely separate code path that only touches the filesystem and
|
||||||
|
has no notion of this status object. So "7 valid files on disk" and "status says Never" were
|
||||||
|
never contradictory — they were two unrelated signals, and **any** server restart (deploy,
|
||||||
|
crash, OOM, host reboot — all routine under `restart: always` in `docker-compose.prod.yml`)
|
||||||
|
silently reset the in-memory fields to `null` regardless of what had actually happened on disk.
|
||||||
|
2. **The schedule was measured from process start, not from the last real backup.** The daily
|
||||||
|
timer was `setInterval(() => backupService.runScheduled(), 24h)` — a fixed 24h period counted
|
||||||
|
from whenever the *process* last started, not from wall-clock time or from when a backup last
|
||||||
|
actually succeeded. The exact same restart that wiped the in-memory status also reset this
|
||||||
|
countdown, which is why the cadence can silently drift or skip past a day with no error ever
|
||||||
|
surfacing anywhere.
|
||||||
|
|
||||||
|
Both symptoms are one cause: **the server process restarted after the Aug 28 backup, and nothing
|
||||||
|
about this design was built to survive that.**
|
||||||
|
|
||||||
|
### Fix (2026-08-30)
|
||||||
|
|
||||||
|
- **`packages/db/src/schema.ts`** / migration `0025_backup_last_status.sql` — four new nullable
|
||||||
|
`site_config` columns: `backup_last_success_at`, `backup_last_result_json`,
|
||||||
|
`backup_last_error_at`, `backup_last_error`. Same table, same upsert pattern as
|
||||||
|
`backup_target_dir`/`backup_keep_last`/`backup_keep_daily_days` (migrations 0016/0017).
|
||||||
|
- **`backup-service.ts`** — `run()` now writes success/error outcomes to these columns (via a
|
||||||
|
`#persist` upsert helper) instead of private fields; `status()` reads them fresh from the DB on
|
||||||
|
every call. A brand-new `BackupService` instance (i.e. a fresh process) now sees exactly what
|
||||||
|
the previous instance last recorded — no more restart amnesia.
|
||||||
|
- **New `isDue(now, intervalMs = 24h)`** method: due iff `now - backupLastSuccessAt >= 24h` (or
|
||||||
|
immediately due if no success was ever recorded), computed from the **persisted** timestamp —
|
||||||
|
never from process uptime.
|
||||||
|
- **`server.ts`** — the daily `setInterval` was replaced with a **15-minute poll** calling
|
||||||
|
`runScheduled()`, which now itself no-ops unless `isDue()` is true. This makes the actual backup
|
||||||
|
cadence immune to restart timing entirely: however often the process happens to restart, the
|
||||||
|
next backup fires within 15 minutes of 24h having genuinely elapsed since the last real success
|
||||||
|
— not 24h after whatever moment the process most recently came back up.
|
||||||
|
- Covered by a new `backup-service.test.ts`: a fresh `BackupService` over the same DB handle
|
||||||
|
(simulating a restart) sees the prior instance's last success/error and its cleared-on-success
|
||||||
|
behavior; `isDue()` is exercised directly against injected timestamps rather than real sleeps.
|
||||||
|
|
||||||
|
No change to the `BackupStatus` shape returned by `GET /api/backup/status` or to
|
||||||
|
`BackupSettings.tsx` — this was purely a durability fix underneath the same contract.
|
||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
Design settled 2026-06-29; **engine + admin-configured local/mounted target + admin UI BUILT
|
Design settled 2026-06-29; **engine + admin-configured local/mounted target + admin UI BUILT
|
||||||
2026-06-29** (SFTP + restore tooling pending). The target directory is **admin-chosen in the UI**
|
2026-06-29** (SFTP + restore tooling pending). The target directory is **admin-chosen in the UI**
|
||||||
(`site_config`, migration 0016), not an env var — the on-site admin picks where backups land; only
|
(`site_config`, migration 0016), not an env var — the on-site admin picks where backups land; only
|
||||||
`BACKUP_KEY` stays a server secret. Resolves the *design* half of [[open-questions]] #5 and the first
|
`BACKUP_KEY` stays a server secret. **Last-success/last-error status + the scheduling cadence are
|
||||||
build slices; records the key-custody stance that bears on #6 (signing stays decoupled from the TPM) and
|
now restart-durable (migration 0025, 2026-08-30)** — see field bug above. Resolves the *design*
|
||||||
#10 (snapshots bloat backups → future exclude toggle). See [[append-only-event-chain]],
|
half of [[open-questions]] #5 and the first build slices; records the key-custody stance that bears
|
||||||
[[disk-os-hardening]], [[tpm]], [[fleet-deployment-komodo]], [[reconciliation]].
|
on #6 (signing stays decoupled from the TPM) and #10 (snapshots bloat backups → future exclude
|
||||||
|
toggle). See [[append-only-event-chain]], [[disk-os-hardening]], [[tpm]], [[fleet-deployment-komodo]],
|
||||||
|
[[reconciliation]].
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
type: concept
|
type: concept
|
||||||
tags: [parking, device, indicator, radar, camera, aux-output, barrier-not-a-door, event-relay]
|
tags: [parking, device, indicator, radar, camera, aux-output, barrier-not-a-door, event-relay]
|
||||||
sources: []
|
sources: []
|
||||||
updated: 2026-06-28
|
updated: 2026-07-08
|
||||||
status: settled
|
status: settled
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -72,6 +72,16 @@ aux-output** capability.
|
|||||||
re-converges to the latest desired state. So the **final state is always authoritative** and a
|
re-converges to the latest desired state. So the **final state is always authoritative** and a
|
||||||
lost/stale packet self-corrects. This also de-dupes (it skips a send when `confirmedOn === desiredOn`),
|
lost/stale packet self-corrects. This also de-dupes (it skips a send when `confirmedOn === desiredOn`),
|
||||||
so the input stream never spams the controller.
|
so the input stream never spams the controller.
|
||||||
|
- **Failure backoff + rate-limited logging (2026-07-08).** The first serialized-worker cut re-pumped
|
||||||
|
*immediately* after a FAILED send (`confirmedOn` unchanged → converge again) — correct for a lost
|
||||||
|
packet, but an **unreachable controller** (`ENETUNREACH`, rejects instantly) turned it into a hot
|
||||||
|
loop: hundreds of identical error lines per minute into stdout AND [[app-logs]] (field incident
|
||||||
|
2026-07-07, park-buzi). Now a failed send arms a **retry backoff — 1 s doubling to a 30 s cap,
|
||||||
|
reset on success**; during the window `desiredOn` keeps tracking the truth table and the armed
|
||||||
|
retry converges to whatever it says when it fires (`#finalOff` waives the backoff for the one-shot
|
||||||
|
last-gasp OFF). Logging: only the **first** failure of a streak is logged, then **one summary per
|
||||||
|
minute** (`still failing (attempt N…)`), and a single `info` on recovery. The app_logs sink
|
||||||
|
additionally coalesces identical rows (see [[app-logs]]) as defense in depth.
|
||||||
- **Hot-reloads the config (no restart).** The lamp map is reconciled against the live device config
|
- **Hot-reloads the config (no restart).** The lamp map is reconciled against the live device config
|
||||||
at start AND before each event (mirroring [[device-status-monitoring|DeviceMonitor]], which re-reads
|
at start AND before each event (mirroring [[device-status-monitoring|DeviceMonitor]], which re-reads
|
||||||
the device set each tick) — adding/updating/dropping lamps. So a button light added or re-pointed in
|
the device set each tick) — adding/updating/dropping lamps. So a button light added or re-pointed in
|
||||||
@@ -102,7 +112,8 @@ Built 2026-06-24 for the first booth (button I1, radar I2, lamp on a spare relay
|
|||||||
a `radarAlert` event-relay (carrying its own `triggerInput`), so the operator can add arbitrary
|
a `radarAlert` event-relay (carrying its own `triggerInput`), so the operator can add arbitrary
|
||||||
event-driven blinkers (e.g. R4) without code changes; the 3-state machine itself is unchanged.
|
event-driven blinkers (e.g. R4) without code changes; the 3-state machine itself is unchanged.
|
||||||
Covered by `apps/server/src/button-light.test.ts` (the truth table, blink toggling asserted on the
|
Covered by `apps/server/src/button-light.test.ts` (the truth table, blink toggling asserted on the
|
||||||
device's *confirmed* state, fail-OFF, de-dupe, lamp-added-after-start reconcile, and two independent
|
device's *confirmed* state, fail-OFF, de-dupe, lamp-added-after-start reconcile, two independent
|
||||||
alert relays on one controller).
|
alert relays on one controller, and — since 2026-07-08 — backoff cadence on an unreachable
|
||||||
|
controller, log rate-limiting, and single-recovery-line + backoff-reset after success).
|
||||||
Related: [[hikvision-radar]], [[entry-double-press]], [[lpr-camera]], [[dingtian-relay]],
|
Related: [[hikvision-radar]], [[entry-double-press]], [[lpr-camera]], [[dingtian-relay]],
|
||||||
[[entry-exit-points]], [[barrier-not-a-door]].
|
[[entry-exit-points]], [[barrier-not-a-door]].
|
||||||
|
|||||||
@@ -71,6 +71,18 @@ Two patterns added on top of the catalog approach:
|
|||||||
> this box must avoid relying on `Intl` for Albanian or it leaks English. (Printed slips already
|
> this box must avoid relying on `Intl` for Albanian or it leaks English. (Printed slips already
|
||||||
> solved this with the `SQ_MONTHS` table in [[rongta-printer]].)
|
> solved this with the `SQ_MONTHS` table in [[rongta-printer]].)
|
||||||
|
|
||||||
|
## UI-wide date standard — "25 Qer 14:30" (2026-07-06)
|
||||||
|
|
||||||
|
Dates were a mix of catalog-formatted "25 Qershor 20:01" and browser-locale "7/6/2026, 9:34 AM"
|
||||||
|
(raw `toLocaleString` in ~20 call sites) — operator called it out. The standard now: **short
|
||||||
|
month from the catalog** (`common.monthsShort`: Jan/Shk/…/Qer/Korr/…/Dhj), **24h clock**, year
|
||||||
|
only when ≠ current. Helpers in `lib/format.ts`: `formatDate` ("25 Qer"), `formatDateTime`
|
||||||
|
("25 Qer 14:30", optional seconds — the event-detail modal keeps them), `formatClock` ("HH:mm");
|
||||||
|
`formatRelativeDateTime` keeps Sot/Dje and uses the same short months beyond that. Rule for new
|
||||||
|
code: **never call `toLocale*String` for a DATE** — catalog months exist because the appliance
|
||||||
|
browser's ICU may lack Albanian data; number formatting (thousand separators on money) still uses
|
||||||
|
the locale. Swept everywhere 2026-07-06.
|
||||||
|
|
||||||
## Open / deferred
|
## Open / deferred
|
||||||
|
|
||||||
- **SetupWizard chrome is now translated (2026-06-19)**; the remaining gap is the **backend
|
- **SetupWizard chrome is now translated (2026-06-19)**; the remaining gap is the **backend
|
||||||
|
|||||||
@@ -71,10 +71,16 @@ RESET_ALLOWED=1 DATABASE_URL=/path node packages/db/scripts/reset-db.mjs --all
|
|||||||
| Flag | Wipes | Keeps |
|
| Flag | Wipes | Keeps |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `--financial` | `ledger_events` (entry/exit/payment/void/shift/cash/anomaly), `device_events`, `snapshots`, subscription **instances** + credentials/plates, `blocklist` | users, devices, config, tariffs, subscription **plans** |
|
| `--financial` | `ledger_events` (entry/exit/payment/void/shift/cash/anomaly), `device_events`, `snapshots`, subscription **instances** + credentials/plates, `blocklist` | users, devices, config, tariffs, subscription **plans** |
|
||||||
| `--config` | `site_config`, `devices`, `setup_state` (→ re-runs first-run setup), tariffs + versions, subscription plans | everything else |
|
| `--config` | `site_config`, `devices`, `setup_state` (→ re-runs first-run setup), tariffs + versions + **drafts**, subscription plans | everything else |
|
||||||
| `--users` | `users`, `roles`, `role_permissions`, auth `sessions` | everything else |
|
| `--users` | `users`, `roles`, `role_permissions`, auth `sessions` | everything else |
|
||||||
|
| `--diagnostics` | `app_logs` (the unsigned [[app-logs]] store behind `/setup/logs`) | everything else |
|
||||||
| `--all` | every table (blank slate) | — |
|
| `--all` | every table (blank slate) | — |
|
||||||
|
|
||||||
|
**Drift guard** (2026-07-08): before doing anything, the script compares the category union against
|
||||||
|
`sqlite_master` and **refuses if any table is uncategorized** — `app_logs` and `tariff_drafts` had
|
||||||
|
silently survived every reset (including `--all`) because the hand-maintained table list lagged the
|
||||||
|
schema. A new table now forces a deliberate one-line categorization decision.
|
||||||
|
|
||||||
> **⚠ `--financial`/`--all` TRUNCATE the append-only, signed [[append-only-event-chain|ledger]].**
|
> **⚠ `--financial`/`--all` TRUNCATE the append-only, signed [[append-only-event-chain|ledger]].**
|
||||||
> That is the anti-fraud record; a *partial* delete would break the hash chain, so a financial reset
|
> That is the anti-fraud record; a *partial* delete would break the hash chain, so a financial reset
|
||||||
> wipes the whole ledger back to empty (re-seeding starts a NEW chain under the **same**
|
> wipes the whole ledger back to empty (re-seeding starts a NEW chain under the **same**
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
type: concept
|
type: concept
|
||||||
tags: [parking, device, printer, transport, usb, escpos, provisioning]
|
tags: [parking, device, printer, transport, usb, escpos, provisioning]
|
||||||
sources: []
|
sources: []
|
||||||
updated: 2026-06-24
|
updated: 2026-08-30
|
||||||
status: settled
|
status: settled
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -87,5 +87,123 @@ The setup UI offers a **Connection** select (Network / USB) + a **USB device** p
|
|||||||
to the node and reports ready/offline). The on-hardware confirmation + the udev/usblp provisioning
|
to the node and reports ready/offline). The on-hardware confirmation + the udev/usblp provisioning
|
||||||
are pending (open-questions #14).
|
are pending (open-questions #14).
|
||||||
|
|
||||||
|
## Field bug — the NONBLOCK partial-write truncation (found + fixed 2026-07-06)
|
||||||
|
|
||||||
|
First on-hardware USB test (ICS XP-K200L, an ESC/POS clone): over TCP it printed + cut fine; over
|
||||||
|
USB it printed the ticket's TEXT but **no barcode and no cut**. Root cause was in OUR transport,
|
||||||
|
not the printer: `sendRawUsb` opened the node with `O_NONBLOCK` and issued ONE `write()` for the
|
||||||
|
whole job. On a non-blocking usblp fd the kernel accepts only what fits the printer's USB buffer
|
||||||
|
(~8 KB) and returns a **short write**; the old code never checked `bytesWritten`, closed the
|
||||||
|
handle, and silently dropped the tail — which is exactly where the barcode (mid-payload) and the
|
||||||
|
CUT (last bytes) live. Small jobs fit one buffer, hence "text prints fine". The regular-file test
|
||||||
|
stand-in can't short-write, so tests never caught it.
|
||||||
|
|
||||||
|
Fix: `writeAllUsb` — chunked loop (4 KB, safely under the usblp buffer) that continues after
|
||||||
|
partial writes, retries `EAGAIN`/zero-byte writes with a short pause, and fails at the caller's
|
||||||
|
deadline with a `(N/M bytes accepted)` diagnostic. Driven by fake-handle tests (short writes,
|
||||||
|
EAGAIN interleave, wedged-printer timeout, non-EAGAIN passthrough) since a real file can't
|
||||||
|
reproduce the char device's behaviour.
|
||||||
|
|
||||||
|
**Second truncation mode — close() cancels the in-flight transfer (lab bench, 2026-07-07).** The
|
||||||
|
chunked loop alone STILL truncated on hardware (test slip stopped mid-sentence, no feed, no cut —
|
||||||
|
"press the feed button to see the text"). Verified against `drivers/usb/class/usblp.c`: `write()`
|
||||||
|
returns at URB *submission* (not completion), only ONE write URB is in flight at a time (the next
|
||||||
|
write EAGAINs until it completes), and `usblp_release()` — i.e. our `close()` — **kills in-flight
|
||||||
|
URBs**. The printer drains bulk data at PRINT speed (tiny internal buffer on these clones), so
|
||||||
|
closing right after the last accepted write cancels the still-transferring tail — exactly where
|
||||||
|
the feed + `GS V` cut bytes live. Kernel-accepted ≠ printer-received.
|
||||||
|
|
||||||
|
Fix: the one-URB rule makes acceptance of write N a **completion certificate for write N−1**. So
|
||||||
|
`writeAllUsb` now writes the payload's FINAL BYTE alone: when that 1-byte write is accepted, every
|
||||||
|
byte before it is physically in the printer; a short drain pause (`USB_DRAIN_MS` 300 ms) covers
|
||||||
|
the lone final-byte packet, then close is safe. (usblp also implements `poll(POLLOUT)` as the true
|
||||||
|
completion signal, but Node cannot poll an arbitrary char-device fd without a native dep — the
|
||||||
|
hold-back + drain gets the same guarantee for all but the final byte, whose packet the printer
|
||||||
|
ACKs immediately after having just freed its buffer.)
|
||||||
|
|
||||||
|
**✅ HARDWARE-VERIFIED (lab bench, 2026-07-07):** with both fixes, the ICS XP-K200L over USB
|
||||||
|
prints the complete slip, feeds, and CUTS — parity with TCP. The USB transport is done.
|
||||||
|
|
||||||
|
**Device discovery (2026-07-07).** The kernel numbers usblp nodes by plug/boot order — park-buzi's
|
||||||
|
printer is `lp1`, and the admin had to shell in and `ls /dev/usb` to learn that. The wizard now
|
||||||
|
lists REAL printers: `GET /api/setup/usb-printers` enumerates `/dev/usb/lpN` (visible via the
|
||||||
|
compose bind-mount) and enriches each with the printer's self-reported make/model from sysfs
|
||||||
|
(`/sys/class/usbmisc/lpN/device/ieee1284_id` — readable through Docker's default ro `/sys`). The
|
||||||
|
devicePath field becomes a SELECT ("/dev/usb/lp1 — Xprinter XP-K200L") with a fresh form
|
||||||
|
preselecting the first present device; a saved-but-unplugged path stays selectable, flagged
|
||||||
|
"saved — not present now"; zero devices found falls back to the free-text path + a check-the-cable
|
||||||
|
hint. The transport option label no longer hardcodes lp0.
|
||||||
|
|
||||||
|
> Driver-choice note for this clone: the ICS XP-K200L does NOT serve the Rongta `/prn_stat.htm`
|
||||||
|
> status page (checked on hardware at 10.0.10.11 — print socket 9100 open, status page absent),
|
||||||
|
> so on NETWORK the honest driver is **cashino** (reachability-only monitoring); under `rongta`
|
||||||
|
> the monitor would mark a perfectly working printer offline/degraded. Over USB the two drivers
|
||||||
|
> behave identically (reachability floor), so either works post-fix. See [[rongta-printer]].
|
||||||
|
|
||||||
|
## Field bug — cover-open re-enumeration wedges the container's `/dev/usb` view; only `docker restart`, not a host reboot, clears it (investigated 2026-08-30, unconfirmed root cause)
|
||||||
|
|
||||||
|
**Symptom (park-buzi, unknown/"Generic" USB printer, model not yet identified — see below):** every
|
||||||
|
time the booth operator opens the printer's paper-roll cover to reload paper, the printer's status
|
||||||
|
goes `offline`/faulty in the app and **never self-recovers** — not after the cover closes, not after
|
||||||
|
a full appliance reboot. The only fix found so far is SSH in and `docker restart server`.
|
||||||
|
|
||||||
|
**Ruled out at the application layer.** Traced `sendRawUsb`/`probeUsb` in `printer-escpos.ts`: every
|
||||||
|
print AND every poll tick (`device-monitor.ts` 8s / `printer-monitor.ts` 5s) does a fresh
|
||||||
|
`open()` → write/probe → `close()` against the configured `devicePath`. **No fd, socket, or driver
|
||||||
|
instance is held across calls** — `driver.create(config)` is a throwaway object with no persistent
|
||||||
|
handle. So a naive "stale Node file descriptor" explanation does not fit this codebase; the
|
||||||
|
app-layer retry-by-fresh-open-every-poll should self-heal within one poll cycle if the kernel's view
|
||||||
|
of the device node is current.
|
||||||
|
|
||||||
|
**Leading hypothesis: the container's bind-mount of `/dev/usb`, not the Node process, holds the
|
||||||
|
stale state.** Docker Compose wires the printer in as a **directory bind-mount**
|
||||||
|
(`docker-compose.prod.yml`, `volumes: - /dev/usb:/dev/usb`), chosen deliberately (per its own
|
||||||
|
comment) so the app survives the printer renumbering to a different `lpN`. But many USB thermal
|
||||||
|
printers cut power to their own USB interface board when the cover-open microswitch trips (a
|
||||||
|
hardware safety/power feature, not just a status flag) — the printer drops off the bus and
|
||||||
|
re-enumerates, potentially as a new device node, when the cover closes. The **host** kernel picks
|
||||||
|
this up fine; the **container's mount namespace**, once established, is a known Docker/OverlayFS
|
||||||
|
sharp edge for `/dev` subtree bind-mounts — it can keep resolving the old node until the mount
|
||||||
|
itself is redone.
|
||||||
|
|
||||||
|
- `docker restart server` recreates the container's mount namespace → the `/dev/usb` bind-mount is
|
||||||
|
redone against current host state → the new node is picked up → fixed.
|
||||||
|
- A full host reboot restarts the container too (`restart: always`), but as a boot-time race: if the
|
||||||
|
container starts before the USB subsystem finishes settling, or the printer re-enumerated some
|
||||||
|
time *before* the reboot and Docker doesn't necessarily redo an already-satisfied bind-mount
|
||||||
|
target on a policy-driven restart, the container can come back up still bound to the pre-incident
|
||||||
|
view. This matches the exact reported asymmetry (reboot doesn't fix it; explicit restart does).
|
||||||
|
|
||||||
|
**Not yet confirmed on hardware** — this is the leading theory, not a verified root cause. To
|
||||||
|
confirm at the next occurrence, BEFORE restarting anything:
|
||||||
|
```bash
|
||||||
|
# host:
|
||||||
|
ls -la /dev/usb/ && stat /dev/usb/lp1
|
||||||
|
# container:
|
||||||
|
docker exec server ls -la /dev/usb/ && docker exec server stat /dev/usb/lp1
|
||||||
|
```
|
||||||
|
A major:minor or inode mismatch between host and container is the smoking gun. Also worth
|
||||||
|
capturing on the lab RONGTA (different printer, but same cover-open mechanism is plausible):
|
||||||
|
`watch -n1 lsusb` + `sudo dmesg -w | grep -i -E 'usb|disconnect'` while cycling the cover, to see
|
||||||
|
whether the Bus/Device number changes.
|
||||||
|
|
||||||
|
**Candidate fixes, not yet implemented** (ranked cheapest-to-most-invasive):
|
||||||
|
1. A host-side watchdog/udev rule that detects re-enumeration of this printer (match vendor:product
|
||||||
|
ID) and runs `docker restart server` automatically — turns the manual SSH fix into a self-healing
|
||||||
|
one without touching app code.
|
||||||
|
2. Same idea but event-driven via a udev rule or systemd path unit watching `/dev/usb`, rather than
|
||||||
|
polling.
|
||||||
|
3. Switch the compose device wiring from the directory bind-mount to a specific `--device=` cgroup
|
||||||
|
passthrough + a udev rule pinning a stable symlink name — reintroduces the renumbering fragility
|
||||||
|
the directory bind-mount was chosen to avoid, so only worth doing alongside (1)/(2), not instead.
|
||||||
|
|
||||||
|
**Open sub-question — printer identity.** The park-buzi unit shows as "Generic (unknown)" in the
|
||||||
|
app; not yet identified by vendor/product ID. Lab reproduction uses a **RONGTA** unit instead (not
|
||||||
|
the same hardware), so the lab cannot currently reproduce the park-buzi symptom directly — only
|
||||||
|
validate the general re-enumeration mechanism. Commands to identify the real park-buzi printer next
|
||||||
|
time it's reachable via SSH: `lsusb`, `udevadm info -q property -n /dev/usb/lp1`, `udevadm info -a
|
||||||
|
-n /dev/usb/lp1`. This mirrors the same discovery gap already noted above under "Device discovery"
|
||||||
|
(sysfs `ieee1284_id` enrichment) — once identified, fold the model into that mechanism's coverage.
|
||||||
|
|
||||||
Related: [[rongta-printer]], [[printer-status-monitoring]], [[printer-roles-failover]],
|
Related: [[rongta-printer]], [[printer-status-monitoring]], [[printer-roles-failover]],
|
||||||
[[appliance-provisioning]], [[network-isolation]], [[technology-stack]].
|
[[appliance-provisioning]], [[network-isolation]], [[technology-stack]].
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
type: concept
|
type: concept
|
||||||
tags: [parking, domain, business, reporting]
|
tags: [parking, domain, business, reporting]
|
||||||
sources: []
|
sources: []
|
||||||
updated: 2026-06-22
|
updated: 2026-07-05
|
||||||
status: open
|
status: open
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -73,6 +73,30 @@ disputes ("I was charged for a car that left earlier"), lost-ticket lookup, and
|
|||||||
- **Export** for [[reconciliation]] / accounting (CSV/PDF) — the periodic external-authority path
|
- **Export** for [[reconciliation]] / accounting (CSV/PDF) — the periodic external-authority path
|
||||||
([[open-questions]] #4).
|
([[open-questions]] #4).
|
||||||
|
|
||||||
|
## As-built additions (2026-07-05) — the parking-shaped graphics
|
||||||
|
|
||||||
|
Operator ask: "check /reports for improvements and meaningful graphics." The dashboard (Recharts,
|
||||||
|
ledger-first aggregation in `apps/server/src/reports.ts`) gained the three views that are
|
||||||
|
parking-specific rather than generic BI, plus fraud counters:
|
||||||
|
|
||||||
|
- **Occupancy curve** — cars-inside step-area over the range with the configured capacity as a
|
||||||
|
red reference line. `occupancyStart` folds the ENTIRE prior ledger (voided entries excluded,
|
||||||
|
clamped ≥0); each series point carries `occupancyEnd`. The at-a-glance answer to "when are we
|
||||||
|
near full" — the input for capacity and dynamic-window decisions.
|
||||||
|
- **Entries heatmap (hour × day-of-week)** — 7×24 matrix (`entriesByDowHour`, row 0 = Monday,
|
||||||
|
site-tz), rendered as a pure CSS-grid amber-intensity map. Shows weekday-vs-weekend and
|
||||||
|
morning/evening patterns — the direct evidence for tariff windows (night rates, weekend cards,
|
||||||
|
early-bird — see [[tariff-industry-survey]]). Replaces the flat entries-by-hour bar (strictly
|
||||||
|
contains it).
|
||||||
|
- **Stay-duration histogram** — closed sessions bucketed at 30m/1h/2h/4h/8h/24h/tail
|
||||||
|
(`stayHistogram`): where the ladder/up-to breakpoints should sit.
|
||||||
|
- **Look-closer counters** — voids + anomalies in range as KPI cards (accented when >0): a spike
|
||||||
|
is exactly what the signed chain exists to surface (the operator is the threat model).
|
||||||
|
- Revenue bars now **stack cash vs card** per bucket (`cashMinor`/`cardMinor` on each point —
|
||||||
|
the drawer's money vs the bank's); peak-occupancy KPI (`peak / capacity`); CSV export gained
|
||||||
|
cash, card, occupancy_end columns. `localParts` now caches its Intl formatter per tz (was one
|
||||||
|
`new Intl.DateTimeFormat` per ledger row).
|
||||||
|
|
||||||
## Open
|
## Open
|
||||||
|
|
||||||
- Which reports matter at launch vs. later; the export format/cadence.
|
- Which reports matter at launch vs. later; the export format/cadence.
|
||||||
|
|||||||
@@ -174,5 +174,6 @@ in `packages/shared/src/index.ts` (+ `tariff.test.ts`, 36 cases incl. the golden
|
|||||||
holiday calendar (one date list, referenced by cards) is a future nicety, not built.
|
holiday calendar (one date list, referenced by cards) is a future nicety, not built.
|
||||||
- Per-relay/lane **category capture** at a transient gate (the "bus lane") — seam noted in
|
- Per-relay/lane **category capture** at a transient gate (the "bus lane") — seam noted in
|
||||||
`entry-flow.ts`; today every transient takes the site default category.
|
`entry-flow.ts`; today every transient takes the site default category.
|
||||||
- A composer **price preview** ("at 14:30 Tue a 2h stay costs …") — high-value for operator trust,
|
- ~~A composer **price preview**~~ — largely DELIVERED 2026-07-06 by the Tariff Lab's fee
|
||||||
deferred.
|
BREAKDOWN (see [[tariff]] §Tariff Lab): simulate any stay against a draft/version and read the
|
||||||
|
line items. A live preview inside the composer form itself remains a possible nicety.
|
||||||
|
|||||||
+13
-1
@@ -201,7 +201,19 @@ The admin authors the rate card at runtime — no hand-seeding:
|
|||||||
- Ships **blank** — until a version is published, `GET /api/tariff` returns `active: null` and the
|
- Ships **blank** — until a version is published, `GET /api/tariff` returns `active: null` and the
|
||||||
pay station returns `409 no active tariff`. Verified end to end (publish → pay station prices).
|
pay station returns `409 no active tariff`. Verified end to end (publish → pay station prices).
|
||||||
|
|
||||||
### Tariff Lab (simulator, as-built 2026-06-20; drafts redesign 2026-07-05)
|
### Tariff Lab (simulator, as-built 2026-06-20; drafts redesign 2026-07-05; fee breakdown 2026-07-06)
|
||||||
|
|
||||||
|
> **Fee breakdown ("how is this sum produced").** The lab's Outcome panel lists the fee's LINE
|
||||||
|
> ITEMS: banded same-price increment runs (time window · N × unit · tier-card name), window-package
|
||||||
|
> occurrences, stepped day totals (top-tier repeat flagged), daily-cap clamps as NEGATIVE
|
||||||
|
> adjustments, entry grace, plus a rounding note (raw min → billed min at the increment). Produced
|
||||||
|
> by `explainFee` in `@parking/shared` — the SAME computeFee walk with a trace collector, so
|
||||||
|
> Σ items ≡ the amount by construction (golden V1 regression unchanged). `/api/tariff/simulate`
|
||||||
|
> returns it as `breakdown` (null when settled). Also the composer grew INCREMENT-UNIT guards
|
||||||
|
> (2026-07-06): price labels state the real unit live ("Çmimi / orë" at 60, "Çmimi / N min"
|
||||||
|
> otherwise), an amber warning fires when increment ≠ 60, and each ladder/flat price shows its
|
||||||
|
> "= X / orë" equivalence — closing the 60→10 ×6-prices trap; example defaults are
|
||||||
|
> currency-scaled (ALL: 200/100, not the euro-scale 2.00/1.00).
|
||||||
|
|
||||||
The tariff engine is a **pure function of time**, but you could previously only *exercise* it by
|
The tariff engine is a **pure function of time**, but you could previously only *exercise* it by
|
||||||
waiting (the only clock the booth reads is the real wall-clock). The **Tariff Lab** closes that gap:
|
waiting (the only clock the booth reads is the real wall-clock). The **Tariff Lab** closes that gap:
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user