8 Commits

Author SHA1 Message Date
julian eb47016ae3 feat(shift): confirm-before-close with X-report + split tickets vs subscriptions; fix dark <select>
CI / check (push) Failing after 31s
Three changes:

1. Confirm-before-close. The header shift button closed the shift directly — a
   stray click would sign the irreversible Z-report. It now opens a confirm modal
   showing the live X-report (takings split by source + expected drawer) with
   Cancel / End-shift. Opening a shift stays immediate (no such risk).

2. Split takings by SOURCE. The report separates Tickets (transient) from
   Subscriptions (monthly sales + a subscriber's out-of-window charge), so the
   operator sees subscriber money apart from ticket money. Buckets are derived
   from the signed payment payload flags (subscriptionSale /
   subscriptionWindowCharge) and always reconcile to cash + card (a payment with
   neither flag is a ticket). Computed in #summariseWindow, carried on the signed
   shift_z_report payload, and shown in the X-report, the close modal, the shift
   history detail, and the printed Z-report. Reports predating the fields default
   subscription to 0 (ticket absorbs the whole take), so old shifts still
   reconcile.

3. Fix dark-theme native <select> popups rendering WHITE on WebKitGTK (the Tauri
   Linux WebView): set color-scheme dark/light on <html> per theme + explicit
   <option> colours, so the OS-drawn dropdown list follows the theme.

Verified the split on a read-only DB copy: tickets 0, subscriptions 10,200
(10,000 sale + 200 out-of-window), reconciles to cash+card. build+lint 14/14,
i18n parity (sq+en).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-21 14:30:14 +02:00
julian 78d1f6808a feat(subs): admin can correct a subscription's plan VERSION
A subscription froze its planVersionId at sale (reproducible pricing). There was
no way to move a sold sub onto a different VERSION of the SAME plan — needed when
an admin publishes v2 with different timeframes (e.g. mujor-naten-cdo-dite v1
"every day" → v2 "weekdays only") and wants an existing subscriber on it, or back
on v1.

Backend (PUT /api/subscriptions/:id):
- accept planVersionId; honored only with the subscription:plan permission
  (stronger than subscription:update — a plan-management action). Non-privileged
  caller sending a change → 403, not silently dropped.
- validated to belong to the sub's EXISTING planId (a different plan = a
  different price basis = a re-sale → 400).
- price/currency/period/planId stay frozen; only planVersionId moves. The swap is
  server-logged for audit (the row is mutable master data, not on the ledger).
  Past signed entry/exit events keep their own windowTariffVersionId, so history
  reprices identically — only future access uses the new version's windows.

Frontend (SubscriptionManager):
- pass the session user through the route (like RolesManager).
- admin-only "Versioni" picker in the edit modal: lists every version of the
  sub's plan by effective date + a timeframe summary (days + window, or 24/7),
  current pre-selected. The plan itself stays read-only. Sends planVersionId only
  when it changed.
- i18n: subs.version/versionHint/versionCurrent/versionOnlyOne/everyDay/allDay
  in both sq + en.

Verified on a writable DB copy: version changed, price + planId frozen,
cross-plan version rejected. Live DB untouched. build+lint 14/14.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-21 14:08:58 +02:00
julian 31f116a068 fix(print): center the out-of-window slip barcode again
The previous width fix also forced ALIGN_LEFT inside code128(), which moved the
slip's barcode to the left. But the no-print bug was the barcode WIDTH (too wide
to fit the head at module width 3), not the centering — at width 2 it fits and
centers fine. So code128() no longer touches alignment; the caller controls it.
The out-of-window slip block is ALIGN_CENTER, so the Code128 + QR center as they
did before, just narrow enough (width 2, ~510 dots) to actually print. The
voucher receipt barcode likewise centers as it originally did.

Verified: alignment-in-effect at the barcode = CENTER, module width = 2, QR
present; build+lint 14/14.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-21 13:52:42 +02:00
julian 663bf0e925 fix(print): out-of-window slip Code128 was too wide to print — width 2 + left-align
The slip printed the QR but NOT the Code128 barcode on the Rongta. Root cause:
the ~20-char occurrence id (SUBSESS-…) at module width 3 is ~765 dots wide —
over the 80mm head's ~576 printable dots — so the firmware silently aborts the
barcode (prints nothing). It was also emitted while ALIGN_CENTER (set for the
title) was active, which shifts the start point right and makes it overflow
even sooner. The QR, being compact, rendered fine — hence QR-only output.

code128() now takes a moduleWidth (default 3, so the shorter entry-ticket id is
unchanged) and forces ALIGN_LEFT (a wide barcode must hug the margin). The slip
passes width 2 (~510 dots — fits with quiet zones) and re-centers the QR/text
after. renderReceipt's voucher barcode re-asserts ALIGN_CENTER for the lines
that follow it.

Verified: width n=2 in the byte stream, est 510 dots; Code128 + QR both present;
build+lint 14/14.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-21 13:44:01 +02:00
julian 8acef0464c fix(subs): price out-of-window charge from minutes actually parked, not a fixed entry stamp
An out-of-window subscriber entry stamped a FIXED windowOwedMinor = the whole
gap to window-open (e.g. 800 ALL for a 13:21 arrival to a 20:00 window) and
deferred it to exit. That over-charged anyone who left before the window
opened — a 1-hour visit was billed as 6.5 hours.

The amount isn't knowable at entry: a subscriber may enter early, leave after
an hour, come and go several times before the window opens, and linger past
window-close. They should pay only for the minutes actually parked outside the
window (capped at the window edges) — exactly what minutesOutsideWindow already
computes.

So the entry now stamps a MARKER only (outOfWindow: true + windowTariffVersionId
for reproducible pricing), no fixed amount. The exit gate and booth quote price
it live via windowOwedBetween(entry → settle-time), which already caps at the
window edges (early entry stops accruing at window-open; the in-window portion
of a crossing stay is free; the late-exit tail keeps accruing until payment).
Both already called that one function, so they agree.

- subscription-flow: entry stamps outOfWindow marker; the advisory slip is now a
  scannable out-of-window TICKET (Code128 + QR of the occurrence id).
- shared LedgerPayload: add outOfWindow; mark windowOwedMinor/windowGap*/
  windowCurrency deprecated read-only (historic signed events still type-check).
- BoothScreen: window-charge badge keys on outOfWindow (or the old stamp).
- ActiveSessions: drop the always-on "Open barrier" for subscribers — the
  assist-open / window-charge payment live in the pay modal, so the list can't
  one-click past an unpaid out-of-window charge.

Verified the live model on a DB copy: 13:21→14:30 = 200 ALL; 19:55(in grace)→
23:00 = 0; 19:00→21:30 (crosses into window) = 100 ALL. Existing signed
occurrences left untouched (immutable). build+lint 14/14, shared 87/87.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-21 13:34:35 +02:00
julian df5caf8d87 feat(subs): scannable out-of-window slip + two-step booth flow
The advisory out-of-window slip for a subscriber had two problems:

1. Faulty character codes. It rendered via the generic text printReport,
   which has no CP852 mapping for the em dash, ellipsis, or warning sign in
   the composed strings — so they printed as "?" ("PARKIM ? JASHTE ORARIT").
   Added ASCII transliterations for that typographic punctuation in the
   ESC/POS encoder (— → -, ⚠ → !, … → ..., curly quotes/bullet), so they
   degrade to a readable glyph instead of "?".

2. Not scannable. The slip printed only "Nr: SUBSESS-…" as plain text, so
   the operator had to hand-key it. Gave the notice its own render function
   (renderWindowChargeNotice) + a printWindowChargeNotice device method that
   prints the occurrence id as a Code128 AND a QR — the same scan path as a
   transient ticket, so the operator scans it straight into the booth pay
   modal, which then quotes the combined window charge. Implemented on both
   the rongta and cashino drivers.

Also fixed the booth pay modal: "Open barrier" no longer shows by default
for a subscriber. A prepaid subscriber with nothing owed sees only a small
"assist open" reveal (the audited manual open for a faulty reader / lost
card stays available, just not the default). A subscriber owing an
out-of-window charge is now two steps — take payment first, then "Open
barrier" appears — instead of an always-on open button.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-21 13:12:05 +02:00
julian 0cbae94842 feat(desktop): wire updater endpoint to self-hosted Gitea + document Tauri WS origin
Point the Tauri updater at the real self-hosted Gitea "latest release" path:
https://git.infra.msai.al/mca/parking_solution/releases/latest/download/latest.json
— redirects to the newest tag's latest.json published by release.yml. Verified
against tauri-plugin-updater: it GETs the endpoint (200 + manifest / 204 = up to
date) and reads platforms.linux-x86_64.{signature,url}.

Document the desktop WS origin: the Tauri window loads from tauri://localhost
(Linux may also send http://tauri.localhost), which is NOT same-origin with the
backend, so WS_ALLOWED_ORIGINS must include both or the live feed won't connect.
Added both to apps/server/.env.example.

Updated the as-built in wiki/decisions/desktop-shell-tauri.md. Also carries an
unrelated plans.namePlaceholder copy tweak already in the tree. turbo build lint
14/14 green.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-21 12:51:11 +02:00
julian ae5c122980 ci(gitea): CI checks + tag-triggered signed Tauri desktop release
Mirror the house Gitea Actions pattern (cf. trm/processor): corepack pnpm +
frozen install on ubuntu-latest.

ci.yml — push/PR to dev → pnpm turbo run build lint + test (covers tsc, vite
build, i18n catalog type-parity, and the shared vitest suite).

release.yml — on a v* tag → install Tauri Linux deps (webkit2gtk-4.1, libsoup-3,
gtk-3, appindicator, rsvg, patchelf) + rustup, cache cargo/target, then
`pnpm --filter @parking/desktop bundle` signed with the updater key from Gitea
secrets (TAURI_SIGNING_PRIVATE_KEY + _PASSWORD). Collects .deb/.rpm/.AppImage +
their .sig, assembles latest.json (platform key linux-x86_64 — verified against
the tauri-plugin-updater target format), and publishes a Gitea Release via the
API with the built-in token (no marketplace release action needed).

Both workflows validated (PyYAML parse). No secret values committed — only
${{ secrets.* }} references.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-21 12:33:20 +02:00
27 changed files with 998 additions and 153 deletions
+40
View File
@@ -0,0 +1,40 @@
name: CI
# Lint/typecheck/test the whole Turborepo on every push/PR to dev. Mirrors the
# house pattern (cf. trm/processor): setup-node + corepack pnpm + frozen install.
# No Docker, no signing — pure checks. The desktop bundle is a separate, tag-only
# pipeline (see release.yml).
on:
push:
branches: [dev]
pull_request:
branches: [dev, main]
workflow_dispatch:
jobs:
check:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Node 22
uses: actions/setup-node@v4
with:
node-version: 22
- name: Enable pnpm
# Pin to the repo's packageManager version (pnpm 10), not latest.
run: corepack enable && corepack prepare pnpm@10.24.0 --activate
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build + lint (Turbo)
# Covers tsc typecheck, vite build, and i18n catalog type-parity (a missing
# sq/en key fails the build). 14 tasks across the workspace.
run: pnpm turbo run build lint
- name: Test
run: pnpm turbo run test
+149
View File
@@ -0,0 +1,149 @@
name: Release desktop
# Build the signed Tauri desktop installers on a version tag and publish them as
# a Gitea Release. The Tauri auto-updater (apps/web/src/lib/desktop-updater.ts)
# fetches these; latest.json + each installer + its .sig are what it needs.
#
# Trigger: push a tag like v0.1.0. The job builds .deb/.rpm/.AppImage, signs them
# with the updater key (Gitea secrets), assembles latest.json, and uploads
# everything to the Release for that tag.
on:
push:
tags:
- 'v*'
workflow_dispatch:
jobs:
bundle:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Node 22
uses: actions/setup-node@v4
with:
node-version: 22
- name: Enable pnpm
run: corepack enable && corepack prepare pnpm@10.24.0 --activate
- name: Install Tauri system deps
# ubuntu-latest runner has no GUI/webkit libs by default. These are the
# exact deps a Tauri v2 Linux build needs (verified locally): WebKitGTK
# 4.1 + libsoup-3 + the GTK/appindicator/rsvg stack + AppImage tooling.
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libwebkit2gtk-4.1-dev \
libsoup-3.0-dev \
libgtk-3-dev \
libayatana-appindicator3-dev \
librsvg2-dev \
patchelf \
file \
build-essential \
curl \
wget
- name: Set up Rust
uses: dtolnay/rust-toolchain@stable
- name: Cache cargo + target
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
apps/desktop/src-tauri/target
key: ${{ runner.os }}-cargo-${{ hashFiles('apps/desktop/src-tauri/Cargo.lock') }}
restore-keys: ${{ runner.os }}-cargo-
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build + sign desktop bundle
env:
# Updater signing key (Gitea repo/org secrets). Without these the
# bundle is unsigned and the updater would reject it.
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
run: pnpm --filter @parking/desktop bundle
- name: Collect artifacts
id: collect
# Gather the installers + their .sig into a flat dist/ for upload.
run: |
set -e
BUNDLE=apps/desktop/src-tauri/target/release/bundle
mkdir -p dist
find "$BUNDLE" \( -name '*.AppImage' -o -name '*.deb' -o -name '*.rpm' \
-o -name '*.AppImage.sig' -o -name '*.deb.sig' -o -name '*.rpm.sig' \) \
-exec cp {} dist/ \;
echo "Artifacts:"; ls -la dist/
- name: Assemble latest.json
# The Tauri updater fetches a manifest describing the newest version, its
# notes, and per-target {signature, url}. We point the AppImage target at
# this release's asset URL. Adjust the platform keys you actually ship.
env:
SERVER_URL: ${{ github.server_url }}
REPO: ${{ github.repository }}
TAG: ${{ github.ref_name }}
run: |
set -e
VERSION="${TAG#v}"
APPIMAGE=$(cd dist && ls *.AppImage | head -1)
SIG=$(cat "dist/${APPIMAGE}.sig")
ASSET_URL="${SERVER_URL}/${REPO}/releases/download/${TAG}/${APPIMAGE}"
cat > dist/latest.json <<JSON
{
"version": "${VERSION}",
"notes": "Parking System ${TAG}",
"pub_date": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
"platforms": {
"linux-x86_64": {
"signature": "${SIG}",
"url": "${ASSET_URL}"
}
}
}
JSON
echo "latest.json:"; cat dist/latest.json
- name: Create release + upload assets (Gitea API)
# Uses the built-in token; no marketplace release action required. Creates
# the release for this tag (idempotent-ish: ignores "already exists") and
# uploads every file in dist/ as an asset.
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
API: ${{ github.api_url }}
REPO: ${{ github.repository }}
TAG: ${{ github.ref_name }}
run: |
set -e
# Create the release (capture id; tolerate an existing one).
REL=$(curl -sS -X POST \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"tag_name\":\"${TAG}\",\"name\":\"${TAG}\",\"draft\":false,\"prerelease\":false}" \
"${API}/repos/${REPO}/releases" || true)
REL_ID=$(printf '%s' "$REL" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2)
if [ -z "$REL_ID" ]; then
# Release may already exist for this tag — look it up by tag.
REL_ID=$(curl -sS -H "Authorization: token ${TOKEN}" \
"${API}/repos/${REPO}/releases/tags/${TAG}" \
| grep -o '"id":[0-9]*' | head -1 | cut -d: -f2)
fi
echo "release id: ${REL_ID}"
for f in dist/*; do
name=$(basename "$f")
echo "uploading ${name}"
curl -sS -X POST \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/octet-stream" \
--data-binary @"${f}" \
"${API}/repos/${REPO}/releases/${REL_ID}/assets?name=${name}" >/dev/null
done
echo "done"
+2 -1
View File
@@ -41,8 +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.",
"endpoints": [ "endpoints": [
"https://UPDATES.EXAMPLE.invalid/parking/{{target}}/{{arch}}/{{current_version}}" "https://git.infra.msai.al/mca/parking_solution/releases/latest/download/latest.json"
], ],
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDgxNzg5RUQ1QkM0Q0FDRjYKUldUMnJFeTgxWjU0Z1RlNmhneDVZQlVVTVZZdGhJTkUxTGdDeGYwQSttZmNKVVp5WEdVMWlBb1YK" "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDgxNzg5RUQ1QkM0Q0FDRjYKUldUMnJFeTgxWjU0Z1RlNmhneDVZQlVVTVZZdGhJTkUxTGdDeGYwQSttZmNKVVp5WEdVMWlBb1YK"
} }
+4 -1
View File
@@ -28,7 +28,10 @@ EVENT_SIGNING_KEY=
# Comma-separated extra origins allowed to open the booth WebSocket (/api/ws). # Comma-separated extra origins allowed to open the booth WebSocket (/api/ws).
# In dev, set the Vite SPA origin. Same-origin is always allowed without this. # In dev, set the Vite SPA origin. Same-origin is always allowed without this.
WS_ALLOWED_ORIGINS=http://localhost:5173 # The Tauri DESKTOP shell loads from tauri://localhost (Linux may also send
# http://tauri.localhost), which is NOT same-origin with the backend — add both
# so the desktop app's live feed connects. See apps/desktop.
WS_ALLOWED_ORIGINS=http://localhost:5173,tauri://localhost,http://tauri.localhost
# Vision / ANPR (optional) ------------------------------------------------- # Vision / ANPR (optional) -------------------------------------------------
# OFF by default. The Node SERVER's view of the vision microservice (apps/vision), # OFF by default. The Node SERVER's view of the vision microservice (apps/vision),
+9 -17
View File
@@ -1,6 +1,5 @@
import { eq, ledgerEvents, siteConfig, type Db } from "@parking/db"; import { eq, ledgerEvents, siteConfig, type Db } from "@parking/db";
import { import {
formatStampSq,
printWithFailover, printWithFailover,
registry, registry,
type PrinterDevice, type PrinterDevice,
@@ -166,26 +165,19 @@ export async function printSubscriptionCard(
*/ */
export async function printWindowChargeNotice( export async function printWindowChargeNotice(
db: Db, db: Db,
notice: { occurrenceId: string; holderName?: string | null; at: string; windowOpensMin?: number; edge: "entry" | "exit" }, notice: { occurrenceId: string; holderName?: string | null; at: string; windowOpensMin?: number | null; edge: "entry" | "exit" },
logger: FastifyBaseLogger, logger: FastifyBaseLogger,
): Promise<string> { ): Promise<string> {
const printers = loadPrinters(db); const printers = loadPrinters(db);
const hhmm = (m?: number) =>
m == null ? "" : `${String(Math.floor(m / 60)).padStart(2, "0")}:${String(m % 60).padStart(2, "0")}`;
const lines = [
`Abonent: ${notice.holderName || "-"}`,
`${notice.edge === "entry" ? "Hyrje" : "Dalje"}: ${formatStampSq(notice.at)}`,
notice.edge === "entry"
? `Ka hyrë jashtë orarit${notice.windowOpensMin != null ? ` (orari hap ${hhmm(notice.windowOpensMin)})` : ""}`
: "Ka dalë jashtë orarit",
"",
"⚠ Detyrim do të llogaritet në dalje",
" (paguhet në kabinë para se të dilni)",
"",
`Nr: ${notice.occurrenceId}`,
];
const printedBy = await printWithFailover(printers, "booth-receipt", (d: PrinterDevice) => const printedBy = await printWithFailover(printers, "booth-receipt", (d: PrinterDevice) =>
d.printReport({ title: "PARKIM — JASHTË ORARIT", lines }), d.printWindowChargeNotice({
occurrenceId: notice.occurrenceId,
holderName: notice.holderName ?? null,
at: notice.at,
edge: notice.edge,
windowOpensMin: notice.windowOpensMin ?? null,
header: ticketHeader(db),
}),
); );
logger.info(`out-of-window notice printed for ${notice.occurrenceId} on ${printedBy}`); logger.info(`out-of-window notice printed for ${notice.occurrenceId} on ${printedBy}`);
return printedBy; return printedBy;
+45 -6
View File
@@ -1,9 +1,9 @@
import { randomBytes, randomUUID } from "node:crypto"; import { randomBytes, randomUUID } from "node:crypto";
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
import { eq, devices, subscriptionCredentials, subscriptionPlates, subscriptions, type Db } from "@parking/db"; import { eq, devices, subscriptionCredentials, subscriptionPlans, subscriptionPlates, subscriptions, type Db } from "@parking/db";
import { NoPrinterAvailableError } from "@parking/devices"; import { NoPrinterAvailableError } from "@parking/devices";
import type { SubscriptionPlan, SubscriptionQuote, Tender } from "@parking/shared"; import type { SubscriptionPlan, SubscriptionQuote, Tender } from "@parking/shared";
import { requirePermission } from "../auth.js"; import { requirePermission, roleHasPermissions } from "../auth.js";
import { invalidateHolder } from "../event-enrich.js"; import { invalidateHolder } from "../event-enrich.js";
import { printSubscriptionCard } from "../booth-print.js"; import { printSubscriptionCard } from "../booth-print.js";
import type { CredentialCapture } from "../credential-capture.js"; import type { CredentialCapture } from "../credential-capture.js";
@@ -57,6 +57,13 @@ interface SubscriptionBody {
/** How the sale fee was tendered (cash → drawer, card → bank). Used at CREATE when a /** How the sale fee was tendered (cash → drawer, card → bank). Used at CREATE when a
* plan is sold; ignored on update (master-data edit, no money moves). Default "cash". */ * plan is sold; ignored on update (master-data edit, no money moves). Default "cash". */
tender?: Tender; tender?: Tender;
/** UPDATE-only CORRECTION: move this sub to a different VERSION of its SAME plan (e.g.
* an admin published v2 with different timeframes and wants an existing subscriber on
* it, or back on v1). Must be a version of the sub's existing planId; price/currency/
* period stay FROZEN (not a re-sale — only the access rules change going forward).
* Gated on `subscription:plan` (plan-management, stronger than subscription:update);
* ignored from a non-privileged caller. See wiki/entities/subscription.md. */
planVersionId?: string;
} }
/** Body for POST /api/subscriptions/quote — price a span against a plan, no write. */ /** Body for POST /api/subscriptions/quote — price a span against a plan, no write. */
@@ -433,10 +440,41 @@ export async function subscriptionRoutes(
const b = req.body ?? {}; const b = req.body ?? {};
const problems = validate(b); const problems = validate(b);
if (problems.length) return reply.code(400).send({ error: "invalid subscription", problems }); if (problems.length) return reply.code(400).send({ error: "invalid subscription", problems });
// An update is a MASTER-DATA edit — it never re-sells or re-prices. The price,
// plan, version and currency are FROZEN as the original sale recorded them (a new // PLAN-VERSION CORRECTION (opt-in, privileged). Move the sub to a different VERSION
// price means a new sale = a new subscription). Editable here: holder/contact, // of its SAME plan — e.g. an admin published v2 (different timeframes) and wants this
// car-count, the validity window, status, and credentials/plates. // subscriber on it, or back on v1. Price/currency/period stay frozen (not a re-sale).
// Guarded HERE on `subscription:plan` (stronger than the route's subscription:update),
// so a plain operator's edit can't move a version; a non-privileged caller sending it
// is rejected rather than silently ignored.
let planVersionId = existing.planVersionId;
if (b.planVersionId !== undefined && b.planVersionId !== existing.planVersionId) {
if (!req.user || !roleHasPermissions(req.user.roleId, ["subscription:plan"])) {
return reply.code(403).send({ error: "changing the plan version requires the subscription:plan permission" });
}
const target = db
.select()
.from(subscriptionPlans)
.where(eq(subscriptionPlans.id, b.planVersionId))
.get();
if (!target) return reply.code(404).send({ error: "plan version not found" });
// Must be a version of the SAME plan — this field corrects the version, never the
// plan itself (a different plan = a different price basis = a re-sale).
if (target.planId !== existing.planId) {
return reply.code(400).send({
error: `plan version belongs to "${target.planId}", not this subscription's plan "${existing.planId}"`,
});
}
planVersionId = b.planVersionId;
req.log.info(
`subscription ${req.params.id} plan version ${existing.planVersionId} → ${b.planVersionId} (plan ${existing.planId}) by ${req.user.username ?? "?"}`,
);
}
// An update is otherwise a MASTER-DATA edit — it never re-sells or re-prices. Price,
// plan and currency are FROZEN as the original sale recorded them (a new price means a
// new sale = a new subscription). Editable here: holder/contact, car-count, the
// validity window, status, credentials/plates, and (privileged) the plan version.
db.update(subscriptions) db.update(subscriptions)
.set({ .set({
holderName: b.holderName ?? null, holderName: b.holderName ?? null,
@@ -445,6 +483,7 @@ export async function subscriptionRoutes(
validFrom: b.validFrom === undefined ? existing.validFrom : (b.validFrom ?? null), validFrom: b.validFrom === undefined ? existing.validFrom : (b.validFrom ?? null),
validTo: resolveValidTo(b, existing.validTo), validTo: resolveValidTo(b, existing.validTo),
status: b.status ?? existing.status, status: b.status ?? existing.status,
planVersionId,
}) })
.where(eq(subscriptions.id, req.params.id)) .where(eq(subscriptions.id, req.params.id))
.run(); .run();
+61 -1
View File
@@ -51,6 +51,10 @@ export interface ShiftSummary {
readonly cardTotalMinor: number; readonly cardTotalMinor: number;
readonly currency: string | null; readonly currency: string | null;
readonly paymentCount: number; readonly paymentCount: number;
readonly ticketTotalMinor: number;
readonly subscriptionTotalMinor: number;
readonly subscriptionSalesMinor: number;
readonly subscriptionWindowMinor: number;
readonly openingFloatMinor: number; readonly openingFloatMinor: number;
readonly cashAddedMinor: number; readonly cashAddedMinor: number;
readonly cashRemovedMinor: number; readonly cashRemovedMinor: number;
@@ -65,6 +69,15 @@ export interface ShiftReport {
readonly cardTotalMinor: number; readonly cardTotalMinor: number;
readonly currency: string | null; readonly currency: string | null;
readonly paymentCount: number; readonly paymentCount: number;
// --- Takings split by SOURCE (cash+card combined; the drawer cash/card stay above) ---
/** Transient TICKET money (the default — any payment not flagged subscription). */
readonly ticketTotalMinor: number;
/** All SUBSCRIBER money = monthly sales + out-of-window charges. */
readonly subscriptionTotalMinor: number;
/** Subscription SALES only (the prepaid monthly/period fee). */
readonly subscriptionSalesMinor: number;
/** Subscriber OUT-OF-WINDOW transient-tariff charges only. */
readonly subscriptionWindowMinor: 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;
@@ -160,6 +173,10 @@ export class ShiftService {
cashTotalMinor?: number; cashTotalMinor?: number;
cardTotalMinor?: number; cardTotalMinor?: number;
paymentCount?: number; paymentCount?: number;
ticketTotalMinor?: number;
subscriptionTotalMinor?: number;
subscriptionSalesMinor?: number;
subscriptionWindowMinor?: number;
openingFloatMinor?: number; openingFloatMinor?: number;
cashAddedMinor?: number; cashAddedMinor?: number;
cashRemovedMinor?: number; cashRemovedMinor?: number;
@@ -180,6 +197,16 @@ export class ShiftService {
cardTotalMinor: pl.cardTotalMinor ?? 0, cardTotalMinor: pl.cardTotalMinor ?? 0,
currency: pl.currency ?? null, currency: pl.currency ?? null,
paymentCount: pl.paymentCount ?? 0, paymentCount: pl.paymentCount ?? 0,
// Split-by-source fields (added 2026-06-21). Old reports lack them → default the
// subscription buckets to 0 and let ticket absorb the whole take, so the buckets
// still reconcile to cash+card for a pre-split shift.
subscriptionSalesMinor: pl.subscriptionSalesMinor ?? 0,
subscriptionWindowMinor: pl.subscriptionWindowMinor ?? 0,
subscriptionTotalMinor:
pl.subscriptionTotalMinor ?? (pl.subscriptionSalesMinor ?? 0) + (pl.subscriptionWindowMinor ?? 0),
ticketTotalMinor:
pl.ticketTotalMinor ??
(pl.cashTotalMinor ?? 0) + (pl.cardTotalMinor ?? 0) - (pl.subscriptionTotalMinor ?? 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,
@@ -348,14 +375,29 @@ export class ShiftService {
let cashTotalMinor = 0; let cashTotalMinor = 0;
let cardTotalMinor = 0; let cardTotalMinor = 0;
// Split by SOURCE: subscription SALES (the prepaid fee), subscriber OUT-OF-WINDOW
// charges, and everything else = transient TICKET money. Both subscriber kinds roll
// up into subscriptionTotal; the rest is ticketTotal. The flags ride the signed
// payment payload (subscriptionSale / subscriptionWindowCharge — see pay-station +
// the subscription sale path).
let subscriptionSalesMinor = 0;
let subscriptionWindowMinor = 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 & {
subscriptionSale?: boolean;
subscriptionWindowCharge?: boolean;
};
const amt = typeof pl.amountMinor === "number" ? pl.amountMinor : 0; const amt = typeof pl.amountMinor === "number" ? pl.amountMinor : 0;
if (pl.tender === "card") cardTotalMinor += amt; if (pl.tender === "card") cardTotalMinor += amt;
else cashTotalMinor += amt; else cashTotalMinor += amt;
if (pl.subscriptionSale === true) subscriptionSalesMinor += amt;
else if (pl.subscriptionWindowCharge === true) subscriptionWindowMinor += amt;
// (else → transient ticket; derived below as total − subscription)
if (pl.currency) currency = pl.currency; if (pl.currency) currency = pl.currency;
} }
const subscriptionTotalMinor = subscriptionSalesMinor + subscriptionWindowMinor;
const ticketTotalMinor = cashTotalMinor + cardTotalMinor - subscriptionTotalMinor;
// --- Drawer figures --- // --- Drawer figures ---
// Opening float was fixed on shift_open (inherited from the chain at start); // Opening float was fixed on shift_open (inherited from the chain at start);
@@ -403,6 +445,10 @@ export class ShiftService {
cardTotalMinor, cardTotalMinor,
currency, currency,
paymentCount: payments.length, paymentCount: payments.length,
ticketTotalMinor,
subscriptionTotalMinor,
subscriptionSalesMinor,
subscriptionWindowMinor,
openingFloatMinor, openingFloatMinor,
cashAddedMinor, cashAddedMinor,
cashRemovedMinor, cashRemovedMinor,
@@ -437,6 +483,10 @@ export class ShiftService {
cardTotalMinor, cardTotalMinor,
currency, currency,
paymentCount, paymentCount,
ticketTotalMinor,
subscriptionTotalMinor,
subscriptionSalesMinor,
subscriptionWindowMinor,
openingFloatMinor, openingFloatMinor,
cashAddedMinor, cashAddedMinor,
cashRemovedMinor, cashRemovedMinor,
@@ -455,6 +505,10 @@ export class ShiftService {
cardTotalMinor, cardTotalMinor,
currency: currency ?? undefined, currency: currency ?? undefined,
paymentCount, paymentCount,
ticketTotalMinor,
subscriptionTotalMinor,
subscriptionSalesMinor,
subscriptionWindowMinor,
openingFloatMinor, openingFloatMinor,
cashAddedMinor, cashAddedMinor,
cashRemovedMinor, cashRemovedMinor,
@@ -492,6 +546,12 @@ export class ShiftService {
`Para në dorë: ${money(r.cashTotalMinor)} ${cur}`, `Para në dorë: ${money(r.cashTotalMinor)} ${cur}`,
`Kartë: ${money(r.cardTotalMinor)} ${cur}`, `Kartë: ${money(r.cardTotalMinor)} ${cur}`,
"", "",
"-- Arkëtime sipas burimit --",
`Bileta: ${money(r.ticketTotalMinor)} ${cur}`,
`Abonime: ${money(r.subscriptionTotalMinor)} ${cur}`,
` shitje: ${money(r.subscriptionSalesMinor)} ${cur}`,
` jashtë orarit: ${money(r.subscriptionWindowMinor)} ${cur}`,
"",
"-- Arka --", "-- Arka --",
`Fillimi (kusur): ${money(r.openingFloatMinor)} ${cur}`, `Fillimi (kusur): ${money(r.openingFloatMinor)} ${cur}`,
`Para të marra: ${money(r.cashTotalMinor)} ${cur}`, `Para të marra: ${money(r.cashTotalMinor)} ${cur}`,
+24 -22
View File
@@ -189,13 +189,16 @@ export class SubscriptionFlow {
return { accepted: false, direction: "entry", reason }; return { accepted: false, direction: "entry", reason };
} }
// TARIFF BRIDGE — early entry. If the plan has time windows and this scan is before // TARIFF BRIDGE — out-of-window entry. If the plan has time windows and this scan is
// the window opens, the subscriber owes the transient tariff for arrival→window-open. // OUTSIDE the allowed window, the subscriber will owe the transient tariff for the time
// We DEFER it (open now, collect at exit): stamp the owed amount on the SIGNED entry // they actually park out-of-window. The AMOUNT is NOT knowable now — it depends on when
// payload (the source of truth — `windowOwedMinor`), so the exit gate reads it back // they leave (a subscriber who enters early and leaves before the window opens owes only
// from the chain. Plans without timeframes return null → nothing owed. See // their parked minutes, NOT the whole gap-to-window-open). So we stamp only a MARKER
// wiki/entities/subscription.md. // (`outOfWindow`) + the tariff version, and price it live at settlement from
const entryCharge = windowCharge(this.#db, sub.planVersionId, now, "entry"); // minutesOutsideWindow(entry → pay-time), which caps at the window edges. Open now
// (never trap); the charge is gated at exit. Plans without timeframes → null → no
// marker. See wiki/entities/subscription.md ("tariff bridge").
const outOfWindow = windowCharge(this.#db, sub.planVersionId, now, "entry");
// A short, unique occurrence id. The subscription id is NOT embedded — it rides in // A short, unique occurrence id. The subscription id is NOT embedded — it rides in
// the payload's `permitId` (which every fold matches on), so the key stays compact. // the payload's `permitId` (which every fold matches on), so the key stays compact.
@@ -205,29 +208,27 @@ export class SubscriptionFlow {
direction: "entry", direction: "entry",
source, source,
identity: occurrenceId, identity: occurrenceId,
// No ticket, no fee — the subscription IS the authorization. Recorded for audit. // The subscription IS the authorization (no fee for in-window use). `permitId`/`permit`
// `permitId`/`permit` are the on-chain field names (immutable). A deferred early- // are the on-chain field names (immutable). An out-of-window entry is MARKED here
// entry charge is signed here (windowOwedMinor + the priced gap) so it's owed at exit. // (`outOfWindow` + the tariff version for reproducible pricing) so the booth/exit gate
// know to charge the parked-out-of-window minutes — priced live, not a fixed amount.
payload: { payload: {
sessionRef: occurrenceId, sessionRef: occurrenceId,
permitId: m.subscriptionId, permitId: m.subscriptionId,
permit: true, permit: true,
via: m.via, via: m.via,
...(entryCharge ...(outOfWindow
? { ? {
windowOwedMinor: entryCharge.amountMinor, outOfWindow: true,
windowCurrency: entryCharge.currency, windowTariffVersionId: outOfWindow.tariffVersionId,
windowTariffVersionId: entryCharge.tariffVersionId,
windowGapStart: entryCharge.gapStart,
windowGapEnd: entryCharge.gapEnd,
} }
: {}), : {}),
}, },
occurredAt: now, occurredAt: now,
}); });
if (entryCharge) { if (outOfWindow) {
this.#logger.info( this.#logger.info(
`subscription early-entry charge ${entryCharge.amountMinor} ${entryCharge.currency} (${entryCharge.minutes}min) deferred on ${occurrenceId}`, `subscription out-of-window entry marked on ${occurrenceId} (charge priced from parked minutes at exit)`,
); );
} }
await this.#open(resolved, "entry", occurrenceId, "subscription entry"); await this.#open(resolved, "entry", occurrenceId, "subscription entry");
@@ -246,11 +247,12 @@ export class SubscriptionFlow {
} catch (err) { } catch (err) {
this.#logger.error(`session-cache insert failed for ${occurrenceId}: ${(err as Error).message}`); this.#logger.error(`session-cache insert failed for ${occurrenceId}: ${(err as Error).message}`);
} }
// BEST-EFFORT: print an advisory "out-of-window" slip so the subscriber has paper // BEST-EFFORT: print the out-of-window TICKET so the subscriber has the paper the
// proof a fee is pending (the final amount is computed at the booth on settlement, // operator scans to settle at the booth. It carries the occurrence id as a scannable
// combining early-entry + any late-exit time). AFTER the open + cache, and fully // code; the amount is computed at settlement from the minutes actually parked
// out-of-window (capped at the window edges). AFTER the open + cache, and fully
// swallowed — a missing/failed printer must NEVER block or delay the barrier. // swallowed — a missing/failed printer must NEVER block or delay the barrier.
if (entryCharge) { if (outOfWindow) {
const tf = (planVersionById(this.#db, sub.planVersionId)?.timeframes ?? null) as PlanTimeframes | null; const tf = (planVersionById(this.#db, sub.planVersionId)?.timeframes ?? null) as PlanTimeframes | null;
void printWindowChargeNotice( void printWindowChargeNotice(
this.#db, this.#db,
+15 -9
View File
@@ -12,9 +12,13 @@ import { FilterBar, SegGroup, type SegOption } from "./ui/FilterBar.js";
// within-grace (the barrier is UNCONFIRMED, so a paid/exited car is presumed // within-grace (the barrier is UNCONFIRMED, so a paid/exited car is presumed
// possibly-present until grace runs out). Lets the operator find a stuck car — // possibly-present until grace runs out). Lets the operator find a stuck car —
// damaged ticket, dead scanner, or a phantom barrier re-close — without a scan: // damaged ticket, dead scanner, or a phantom barrier re-close — without a scan:
// - click a row → the pay/exit modal (pay an unpaid car, or review), // - click a row → the pay/exit modal (pay an unpaid car, settle a subscriber's
// - "Open barrier" (PAID sessions only) → an audited human-intervention re-pulse. // out-of-window charge, assist-open a prepaid subscriber, or review),
// No payment → no Open barrier button (the no-unpaid-bypass rule). // - "Open barrier" (PAID transient sessions only) → an audited human-intervention
// re-pulse for a car that paid but whose barrier didn't confirm.
// No payment → no Open barrier button (the no-unpaid-bypass rule). Subscriptions get
// NO inline open here — their assist-open / window-charge payment is modal-only, so
// the list can't one-click past an unpaid out-of-window charge.
// //
// OVERSTAY sessions (paid, grace expired, no signed exit) are no longer aged out — they // OVERSTAY sessions (paid, grace expired, no signed exit) are no longer aged out — they
// stay listed with a distinct badge. A new period has begun (the car re-parked or is // stay listed with a distinct badge. A new period has begun (the car re-parked or is
@@ -173,12 +177,14 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void
</span> </span>
</button> </button>
{/* Open barrier — PAID-and-still-in-grace transient OR a SUBSCRIPTION {/* Open barrier — PAID-and-still-in-grace TRANSIENT only: an audited
(prepaid). NOT an OVERSTAY session: its grace has expired, so the car re-pulse for a car that paid but the barrier didn't confirm. NOT an
owes a top-up — the row routes to the pay/exit modal instead (no OVERSTAY (grace expired → owes a top-up; routes to the pay/exit modal)
free overstay exit). An unpaid transient also has no button and NOT a SUBSCRIPTION (the assist-open, and any out-of-window payment,
(no-unpaid-bypass). Mirrors reopenBarrier's server-side guard. */} live in the pay/exit modal — the list must not offer a one-click open,
{(s.paidAt && !s.overstay) || s.subscription ? ( which would bypass an unpaid window charge). An unpaid transient has no
button either (no-unpaid-bypass). Mirrors reopenBarrier's server guard. */}
{s.paidAt && !s.overstay && !s.subscription ? (
<button <button
type="button" type="button"
disabled={reopen.isPending || !shiftReady} disabled={reopen.isPending || !shiftReady}
+100 -45
View File
@@ -45,6 +45,13 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
const [result, setResult] = useState<string | null>(null); const [result, setResult] = useState<string | null>(null);
const [openingShift, setOpeningShift] = useState(false); const [openingShift, setOpeningShift] = useState(false);
const [reprinting, setReprinting] = useState(false); const [reprinting, setReprinting] = useState(false);
// For a PREPAID subscriber with nothing owed, the audited manual barrier open
// (assist a faulty reader / lost card) is no longer the default action — the
// operator reveals it explicitly so the modal isn't an always-on "open" button.
const [assistRevealed, setAssistRevealed] = useState(false);
// For a subscriber WINDOW CHARGE, payment and the barrier open are two steps: pay
// first, then the modal reveals "Open barrier". This flips true once paid.
const [windowPaid, setWindowPaid] = useState(false);
const s: SessionLookup | undefined = session.data; const s: SessionLookup | undefined = session.data;
// Checkbox default comes from config the first time it loads; operator can toggle. // Checkbox default comes from config the first time it loads; operator can toggle.
@@ -87,6 +94,25 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
} }
} }
// Subscriber out-of-window charge: take the payment, but DON'T exit yet. The
// barrier open is the operator's explicit second step (so the flow reads:
// pay → then Open barrier), mirroring the two-step the operator asked for.
async function handlePaySubscriptionWindow() {
if (!s) return;
setError(null);
setPhase("paying");
try {
await paySession(identity, tender);
setWindowPaid(true);
setPhase("review");
void qc.invalidateQueries({ queryKey: ["session", identity] });
void qc.invalidateQueries({ queryKey: qk.events });
} catch (e) {
setError((e as Error).message);
setPhase("error");
}
}
async function handleOpenShift() { async function handleOpenShift() {
setOpeningShift(true); setOpeningShift(true);
setError(null); setError(null);
@@ -280,17 +306,22 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
</span> </span>
</div> </div>
{/* For a subscription with a window charge, explain why it's payable. For a {/* Subscription guidance: an unpaid window charge explains the pay-first
plain prepaid subscription, explain the assist-open is the only action. */} gate; once paid, prompt the operator to open the barrier; a prepaid
{subWindowDue ? ( subscriber sees the assist explanation only after revealing it. */}
{subWindowDue && !windowPaid ? (
<div className="rounded-term border border-term-amber/40 bg-term-amber/5 px-3 py-2 text-[12px] text-term-text"> <div className="rounded-term border border-term-amber/40 bg-term-amber/5 px-3 py-2 text-[12px] text-term-text">
{t("pay.windowChargeHint")} {t("pay.windowChargeHint")}
</div> </div>
) : isSubscription && ( ) : isSubscription && windowPaid ? (
<div className="rounded-term border border-term-green/40 bg-term-green/5 px-3 py-2 text-[12px] text-term-text">
{t("pay.windowPaidHint")}
</div>
) : isSubscription && assistRevealed ? (
<div className="rounded-term border border-term-cyan/40 bg-term-cyan/5 px-3 py-2 text-[12px] text-term-text"> <div className="rounded-term border border-term-cyan/40 bg-term-cyan/5 px-3 py-2 text-[12px] text-term-text">
{t("pay.subAssistHint")} {t("pay.subAssistHint")}
</div> </div>
)} ) : null}
{/* For an overstay, explain why a top-up is required (no free exit). */} {/* For an overstay, explain why a top-up is required (no free exit). */}
{isOverstay && ( {isOverstay && (
@@ -302,37 +333,36 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
{/* Snapshots */} {/* Snapshots */}
<SnapshotStrip identity={identity} /> <SnapshotStrip identity={identity} />
{phase !== "done" && !isSubscription && ( {/* Tender — shown for any payable case (transient, overstay, OR a
<> subscriber window charge that's still unpaid). */}
{/* Tender */} {phase !== "done" && canPay && !(subWindowDue && windowPaid) && (
{canPay && ( <div className="flex items-center gap-2">
<div className="flex items-center gap-2"> <span className="text-[11px] uppercase tracking-wider text-term-muted">{t("pay.tender")}</span>
<span className="text-[11px] uppercase tracking-wider text-term-muted">{t("pay.tender")}</span> {(["cash", "card"] as const).map((tn) => (
{(["cash", "card"] as const).map((tn) => ( <button
<button key={tn}
key={tn} type="button"
type="button" onClick={() => setTender(tn)}
onClick={() => setTender(tn)} className={tender === tn ? "btn btn-primary btn-sm" : "btn btn-sm"}
className={tender === tn ? "btn btn-primary btn-sm" : "btn btn-sm"} >
> {t(`pay.${tn}`)}
{t(`pay.${tn}`)} </button>
</button> ))}
))} </div>
</div> )}
)}
{/* Voucher checkbox (default from site config) */} {/* Voucher checkbox (transient only; a subscriber doesn't self-exit). */}
<label className="flex items-center gap-2 text-[12px]"> {phase !== "done" && !isSubscription && (
<input <label className="flex items-center gap-2 text-[12px]">
type="checkbox" <input
className="accent-term-amber" type="checkbox"
checked={voucher} className="accent-term-amber"
onChange={(e) => setPrintVoucherChecked(e.target.checked)} checked={voucher}
/> onChange={(e) => setPrintVoucherChecked(e.target.checked)}
{t("pay.printExitVoucher")} />
<span className="text-term-muted">{t("pay.selfExitHint")}</span> {t("pay.printExitVoucher")}
</label> <span className="text-term-muted">{t("pay.selfExitHint")}</span>
</> </label>
)} )}
{error && <div className="rounded-term border border-term-red px-3 py-2 text-term-red">{error}</div>} {error && <div className="rounded-term border border-term-red px-3 py-2 text-term-red">{error}</div>}
@@ -374,16 +404,41 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
{t("common.cancel")} {t("common.cancel")}
</button> </button>
{isSubscription ? ( {isSubscription ? (
// Prepaid — the only action is the audited barrier open (assist subWindowDue && !windowPaid ? (
// a faulty exit reader / missing card). Gated on an open shift. // Step 1 — a window charge is owed: take payment first. The
<button // barrier open is the explicit next step (revealed once paid).
type="button" <button
onClick={handleOpenBarrier} type="button"
disabled={!shiftReady || phase === "finishing"} onClick={handlePaySubscriptionWindow}
className="btn btn-pay btn-lg" disabled={!shiftReady || phase === "paying"}
> className="btn btn-go btn-lg"
{phase === "finishing" ? t("pay.opening") : t("booth.openBarrier")} >
</button> {phase === "paying" ? t("pay.takingPayment") : t("pay.payWindowCharge")}
</button>
) : windowPaid || assistRevealed ? (
// The audited barrier open. Shown only AFTER a window charge is
// settled, or after the operator explicitly reveals the assist —
// never as the default action for a prepaid subscriber.
<button
type="button"
onClick={handleOpenBarrier}
disabled={!shiftReady || phase === "finishing"}
className="btn btn-pay btn-lg"
>
{phase === "finishing" ? t("pay.opening") : t("booth.openBarrier")}
</button>
) : (
// Prepaid, nothing owed: no default open. A small reveal exposes
// the audited manual open for a faulty reader / lost card.
<button
type="button"
onClick={() => setAssistRevealed(true)}
disabled={!shiftReady}
className="btn btn-ghost btn-sm"
>
{t("pay.assistOpenReveal")}
</button>
)
) : ( ) : (
<button <button
type="button" type="button"
+6 -3
View File
@@ -112,9 +112,12 @@ function eventBadges(p: LedgerEvent["payload"]): string[] {
if (p.permitRefused) keys.push("booth.badgeSubRefused"); if (p.permitRefused) keys.push("booth.badgeSubRefused");
if (p.ticketPrinted === false) keys.push("booth.badgeNoTicket"); if (p.ticketPrinted === false) keys.push("booth.badgeNoTicket");
if (p.subscriptionSale) keys.push("booth.badgeSubSale"); if (p.subscriptionSale) keys.push("booth.badgeSubSale");
// Subscriber entered/exited outside their plan's allowed window → owes a deferred // Subscriber entered outside their plan's allowed window → will owe a transient charge
// transient charge, collected (gated) at exit. Flag it so the operator KNOWS now. // for the minutes actually parked out-of-window, priced + collected (gated) at exit.
if (typeof p.windowOwedMinor === "number" && p.windowOwedMinor > 0) keys.push("booth.badgeWindowCharge"); // Flag it so the operator KNOWS now. (`windowOwedMinor` is the old fixed-amount stamp,
// kept so historic events still badge.)
if (p.outOfWindow === true || (typeof p.windowOwedMinor === "number" && p.windowOwedMinor > 0))
keys.push("booth.badgeWindowCharge");
if (p.source === "manual" && !p.subscriptionSale) keys.push("booth.badgeManualOpen"); if (p.source === "manual" && !p.subscriptionSale) keys.push("booth.badgeManualOpen");
return keys; return keys;
} }
+32 -4
View File
@@ -90,6 +90,10 @@ function useCurrentShift(): { current: (ShiftSummary & { open: true }) | null; i
cardTotalMinor: x.cardTotalMinor, cardTotalMinor: x.cardTotalMinor,
currency: x.currency, currency: x.currency,
paymentCount: x.paymentCount, paymentCount: x.paymentCount,
ticketTotalMinor: x.ticketTotalMinor,
subscriptionTotalMinor: x.subscriptionTotalMinor,
subscriptionSalesMinor: x.subscriptionSalesMinor,
subscriptionWindowMinor: x.subscriptionWindowMinor,
openingFloatMinor: x.openingFloatMinor, openingFloatMinor: x.openingFloatMinor,
cashAddedMinor: x.cashAddedMinor, cashAddedMinor: x.cashAddedMinor,
cashRemovedMinor: x.cashRemovedMinor, cashRemovedMinor: x.cashRemovedMinor,
@@ -320,6 +324,10 @@ function ShiftActivityLog({
)} )}
</div> </div>
<div className="mt-1 grid grid-cols-2 gap-x-6 gap-y-0.5 text-[11px] tabular-nums sm:grid-cols-4"> <div className="mt-1 grid grid-cols-2 gap-x-6 gap-y-0.5 text-[11px] tabular-nums sm:grid-cols-4">
<Figure label={t("shifts.srcTickets")} value={money(shift.ticketTotalMinor, cur)} />
<Figure label={t("shifts.srcSubscriptions")} value={money(shift.subscriptionTotalMinor, cur)} />
<Figure label={t("shifts.srcSubSales")} value={money(shift.subscriptionSalesMinor, cur)} sub />
<Figure label={t("shifts.srcSubWindow")} value={money(shift.subscriptionWindowMinor, cur)} sub />
<Figure label={t("shifts.openingFloat")} value={money(shift.openingFloatMinor, cur)} /> <Figure label={t("shifts.openingFloat")} value={money(shift.openingFloatMinor, cur)} />
<Figure label={t("shifts.cashTaken")} value={money(shift.cashTotalMinor, cur)} /> <Figure label={t("shifts.cashTaken")} value={money(shift.cashTotalMinor, cur)} />
<Figure label={t("shifts.cashAdded")} value={money(shift.cashAddedMinor, cur)} /> <Figure label={t("shifts.cashAdded")} value={money(shift.cashAddedMinor, cur)} />
@@ -374,6 +382,13 @@ function EndShiftModal({ shift, onClose, onDone }: { shift: ShiftSummary; onClos
<div className="font-semibold text-term-text">{t("shift.zReport")} — {report.operator}</div> <div className="font-semibold text-term-text">{t("shift.zReport")} — {report.operator}</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(report.paymentCount)} /> <Figure label={t("shift.payments")} value={String(report.paymentCount)} />
<span />
<Figure label={t("shift.srcTickets")} value={money(report.ticketTotalMinor, report.currency)} />
<Figure label={t("shift.srcSubscriptions")} value={money(report.subscriptionTotalMinor, report.currency)} />
<Figure label={t("shift.srcSubSales")} value={money(report.subscriptionSalesMinor, report.currency)} sub />
<Figure label={t("shift.srcSubWindow")} value={money(report.subscriptionWindowMinor, report.currency)} sub />
</div>
<div className="mt-1 grid grid-cols-2 gap-x-6 gap-y-0.5 border-t border-term-border pt-1">
<Figure label={t("shift.cash")} value={money(report.cashTotalMinor, report.currency)} /> <Figure label={t("shift.cash")} value={money(report.cashTotalMinor, report.currency)} />
<Figure label={t("shift.card")} value={money(report.cardTotalMinor, report.currency)} /> <Figure label={t("shift.card")} value={money(report.cardTotalMinor, report.currency)} />
<Figure label={t("shift.openingFloat")} value={money(report.openingFloatMinor, report.currency)} /> <Figure label={t("shift.openingFloat")} value={money(report.openingFloatMinor, report.currency)} />
@@ -389,10 +404,16 @@ function EndShiftModal({ shift, onClose, onDone }: { shift: ShiftSummary; onClos
</div> </div>
</div> </div>
) : ( ) : (
// Confirm — show the live takings/drawer before closing. // Confirm — show the live takings (split by source) + drawer before closing.
<div className="text-[13px] tabular-nums"> <div className="text-[13px] tabular-nums">
<p className="text-term-muted">{t("shift.endConfirm")}</p> <p className="text-term-muted">{t("shift.endConfirm")}</p>
<div className="mt-2 grid grid-cols-2 gap-x-6 gap-y-0.5"> <div className="mt-2 grid grid-cols-2 gap-x-6 gap-y-0.5">
<Figure label={t("shift.srcTickets")} value={money(shift.ticketTotalMinor, cur)} />
<Figure label={t("shift.srcSubscriptions")} value={money(shift.subscriptionTotalMinor, cur)} />
<Figure label={t("shift.srcSubSales")} value={money(shift.subscriptionSalesMinor, cur)} sub />
<Figure label={t("shift.srcSubWindow")} value={money(shift.subscriptionWindowMinor, cur)} sub />
</div>
<div className="mt-2 grid grid-cols-2 gap-x-6 gap-y-0.5 border-t border-term-border pt-2">
<Figure label={t("shift.cash")} value={money(shift.cashTotalMinor, cur)} /> <Figure label={t("shift.cash")} value={money(shift.cashTotalMinor, cur)} />
<Figure label={t("shift.card")} value={money(shift.cardTotalMinor, cur)} /> <Figure label={t("shift.card")} value={money(shift.cardTotalMinor, cur)} />
<Figure label={t("shift.expectedDrawer")} value={money(shift.expectedDrawerMinor, cur)} bold /> <Figure label={t("shift.expectedDrawer")} value={money(shift.expectedDrawerMinor, cur)} bold />
@@ -471,6 +492,13 @@ function TakingsModal({ onClose }: { onClose: () => void }) {
<div className="text-term-muted">{t("shift.asOf")} {new Date(x.asOf).toLocaleString()}</div> <div className="text-term-muted">{t("shift.asOf")} {new Date(x.asOf).toLocaleString()}</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 />
<Figure label={t("shift.srcTickets")} value={money(x.ticketTotalMinor, x.currency)} />
<Figure label={t("shift.srcSubscriptions")} value={money(x.subscriptionTotalMinor, x.currency)} />
<Figure label={t("shift.srcSubSales")} value={money(x.subscriptionSalesMinor, x.currency)} sub />
<Figure label={t("shift.srcSubWindow")} value={money(x.subscriptionWindowMinor, x.currency)} sub />
</div>
<div className="mt-1 grid grid-cols-2 gap-x-6 gap-y-0.5 border-t border-term-border pt-1">
<Figure label={t("shift.cash")} value={money(x.cashTotalMinor, x.currency)} /> <Figure label={t("shift.cash")} value={money(x.cashTotalMinor, x.currency)} />
<Figure label={t("shift.card")} value={money(x.cardTotalMinor, x.currency)} /> <Figure label={t("shift.card")} value={money(x.cardTotalMinor, x.currency)} />
<Figure label={t("shift.openingFloat")} value={money(x.openingFloatMinor, x.currency)} /> <Figure label={t("shift.openingFloat")} value={money(x.openingFloatMinor, x.currency)} />
@@ -505,10 +533,10 @@ function ActivityRow({ e }: { e: LedgerEvent }) {
); );
} }
function Figure({ label, value, bold }: { label: string; value: string; bold?: boolean }) { function Figure({ label, value, bold, sub }: { label: string; value: string; bold?: boolean; sub?: boolean }) {
return ( return (
<div className="flex justify-between gap-2"> <div className={`flex justify-between gap-2 ${sub ? "pl-3" : ""}`}>
<span className="text-term-muted">{label}</span> <span className={sub ? "text-term-muted/70" : "text-term-muted"}>{label}</span>
<span className={bold ? "font-semibold text-term-text" : "text-term-text"}>{value}</span> <span className={bold ? "font-semibold text-term-text" : "text-term-text"}>{value}</span>
</div> </div>
); );
+96 -3
View File
@@ -14,7 +14,10 @@ import {
quoteSubscription, quoteSubscription,
revokeSubscription, revokeSubscription,
updateSubscription, updateSubscription,
can,
type Permission,
type ReaderInfo, type ReaderInfo,
type SessionUser,
type Subscription, type Subscription,
type SubscriptionCredential, type SubscriptionCredential,
type SubscriptionInput, type SubscriptionInput,
@@ -34,6 +37,11 @@ interface FormState {
holderName: string; holderName: string;
contact: string; contact: string;
planId: string; // selected plan (sells/prices it); "" = comp (no charge) planId: string; // selected plan (sells/prices it); "" = comp (no charge)
// EDIT-only: which VERSION of planId this sub is on. Admins can correct it to another
// version of the SAME plan; "" when the sub has no plan. origPlanVersionId is the
// loaded value, so we only send a change.
planVersionId: string;
origPlanVersionId: string;
quantity: string; // cars covered by this one subscription (price ×N) quantity: string; // cars covered by this one subscription (price ×N)
count: string; // HOW MANY of the plan's period (e.g. 3 months) — drives the end date count: string; // HOW MANY of the plan's period (e.g. 3 months) — drives the end date
tender: "cash" | "card"; // how the sale fee is collected (cash → drawer, card → bank) tender: "cash" | "card"; // how the sale fee is collected (cash → drawer, card → bank)
@@ -55,6 +63,8 @@ function emptyForm(): FormState {
holderName: "", holderName: "",
contact: "", contact: "",
planId: "", planId: "",
planVersionId: "",
origPlanVersionId: "",
quantity: "1", quantity: "1",
count: "1", count: "1",
tender: "cash", tender: "cash",
@@ -70,7 +80,9 @@ function formFrom(s: Subscription): FormState {
return { return {
holderName: s.holderName ?? "", holderName: s.holderName ?? "",
contact: s.contact ?? "", contact: s.contact ?? "",
planId: s.planId ?? "", // edit doesn't re-sell; plan shown read-only planId: s.planId ?? "", // edit doesn't re-sell; the plan itself stays frozen
planVersionId: s.planVersionId ?? "", // but an admin may correct WHICH version
origPlanVersionId: s.planVersionId ?? "",
quantity: String(s.quantity ?? 1), quantity: String(s.quantity ?? 1),
count: "1", count: "1",
tender: "cash", // edit doesn't re-collect money; tender only matters on a new sale tender: "cash", // edit doesn't re-collect money; tender only matters on a new sale
@@ -131,6 +143,11 @@ function toInput(f: FormState, isNew: boolean): SubscriptionInput {
.filter((c) => c.kind === "qr" || c.value.trim()) .filter((c) => c.kind === "qr" || c.value.trim())
.map((c) => (c.value.trim() ? { kind: c.kind, value: c.value.trim() } : { kind: c.kind })), .map((c) => (c.value.trim() ? { kind: c.kind, value: c.value.trim() } : { kind: c.kind })),
plates: f.platesText.split(/[,\s]+/).map((s) => s.trim()).filter(Boolean), plates: f.platesText.split(/[,\s]+/).map((s) => s.trim()).filter(Boolean),
// EDIT-only correction: send the version ONLY when an admin picked a different one
// (same plan, different timeframes). Server gates it on subscription:plan.
...(!isNew && f.planVersionId && f.planVersionId !== f.origPlanVersionId
? { planVersionId: f.planVersionId }
: {}),
}; };
} }
@@ -145,10 +162,40 @@ function priceLabel(s: Subscription, t: (k: string) => string): string {
return `${(s.priceMinor / 100).toLocaleString()} ${s.currency ?? ""}`.trim(); return `${(s.priceMinor / 100).toLocaleString()} ${s.currency ?? ""}`.trim();
} }
export function SubscriptionManager() { /** "HH:MM" from minutes-of-day. */
function hhmm(min: number): string {
return `${String(Math.floor(min / 60)).padStart(2, "0")}:${String(min % 60).padStart(2, "0")}`;
}
/** Day-of-week shorthand for a version label: "çdo ditë" when all 7 (or none), else the
* Mon-first short names (tariff.dow* keys, e.g. "Hën–Pre"). */
function daysLabel(days: number[] | undefined, t: (k: string) => string): string {
const set = days && days.length > 0 ? days : [0, 1, 2, 3, 4, 5, 6];
if (set.length === 7) return t("subs.everyDay");
const order = [1, 2, 3, 4, 5, 6, 0];
return order.filter((d) => set.includes(d)).map((d) => t(`tariff.dow${d}`)).join(", ");
}
/** A one-line label for a plan VERSION in the correction picker: effective date + its
* timeframe summary (or "24/7" when the version has no window). */
function versionLabel(v: SubscriptionPlan, t: (k: string) => string): string {
const eff = new Date(v.effectiveFrom);
const date = Number.isNaN(eff.getTime()) ? v.effectiveFrom : eff.toLocaleString();
const tf = v.timeframes;
const rules = tf ? `${daysLabel(tf.days, t)} ${hhmm(tf.fromMin)}–${hhmm(tf.toMin)}` : t("subs.allDay");
return `${date} · ${rules}`;
}
export function SubscriptionManager({ user }: { user: SessionUser | null }) {
const { t } = useTranslation(); const { t } = useTranslation();
// Correcting which plan VERSION a sold sub is on is a plan-management action (changes
// its access rules), so it's gated on subscription:plan, not routine subscription:update.
const canChangeVersion = can(user, "subscription:plan" as Permission);
const [subs, setSubs] = useState<Subscription[] | null>(null); const [subs, setSubs] = useState<Subscription[] | null>(null);
const [plans, setPlans] = useState<SubscriptionPlan[]>([]); const [plans, setPlans] = useState<SubscriptionPlan[]>([]);
// ALL plan versions (history) — only needed to populate the admin version-correction
// picker on edit; the sale form uses the active-only `plans` above.
const [allPlanVersions, setAllPlanVersions] = useState<SubscriptionPlan[]>([]);
const [quote, setQuote] = useState<SubscriptionQuote | null>(null); const [quote, setQuote] = useState<SubscriptionQuote | null>(null);
const [quoting, setQuoting] = useState(false); const [quoting, setQuoting] = useState(false);
const [editing, setEditing] = useState<string | "new" | null>(null); const [editing, setEditing] = useState<string | "new" | null>(null);
@@ -173,7 +220,15 @@ export function SubscriptionManager() {
.catch(() => { .catch(() => {
/* non-fatal — the form will show "no plans" */ /* non-fatal — the form will show "no plans" */
}); });
}, []); // Admins can correct a sub's version — load EVERY version (history) for that picker.
if (canChangeVersion) {
fetchSubscriptionPlans(true)
.then((r) => setAllPlanVersions(r.plans))
.catch(() => {
/* non-fatal — the version picker just won't populate */
});
}
}, [canChangeVersion]);
// The currently-selected plan (for its period, to drive the count → end-date math). // The currently-selected plan (for its period, to drive the count → end-date math).
const selectedPlan = plans.find((p) => p.planId === form.planId.trim()) ?? null; const selectedPlan = plans.find((p) => p.planId === form.planId.trim()) ?? null;
@@ -416,6 +471,44 @@ export function SubscriptionManager() {
<> <>
<label className="label">{t("subs.plan")}</label> <label className="label">{t("subs.plan")}</label>
<span className="text-[13px] text-term-text">{form.planId || t("subs.noPrice")}</span> <span className="text-[13px] text-term-text">{form.planId || t("subs.noPrice")}</span>
{/* VERSION CORRECTION (admins). The plan itself is frozen, but an admin may
move the sub to a different VERSION of that same plan (e.g. one with
different timeframes). Price stays as billed. Only shown when the sub has
a plan AND there's more than one version of it. */}
{canChangeVersion && form.planId.trim() !== "" && (() => {
const versions = allPlanVersions
.filter((v) => v.planId === form.planId.trim())
.sort((a, b) => b.effectiveFrom.localeCompare(a.effectiveFrom));
// Include the sub's current version even if it's been retired/superseded
// off the list, so the dropdown always shows where it stands.
if (!versions.some((v) => v.id === form.planVersionId) && form.planVersionId) {
const cur = allPlanVersions.find((v) => v.id === form.planVersionId);
if (cur) versions.unshift(cur);
}
if (versions.length < 2 && versions.some((v) => v.id === form.planVersionId)) {
return <span className="text-[12px] text-term-muted">{t("subs.versionOnlyOne")}</span>;
}
return (
<>
<label className="label">{t("subs.version")}</label>
<span className="flex flex-col gap-1">
<select
className="select input w-auto"
value={form.planVersionId}
onChange={(e) => setForm((f) => ({ ...f, planVersionId: e.target.value }))}
>
{versions.map((v) => (
<option key={v.id} value={v.id}>
{versionLabel(v, t)}
{v.id === form.origPlanVersionId ? ` — ${t("subs.versionCurrent")}` : ""}
</option>
))}
</select>
<span className="text-[12px] text-term-muted">{t("subs.versionHint")}</span>
</span>
</>
);
})()}
</> </>
)} )}
{/* Quantity — cars covered by this ONE subscription (a family pays once for {/* Quantity — cars covered by this ONE subscription (a family pays once for
+16 -3
View File
@@ -565,6 +565,9 @@ export type SubscriptionInput = {
tender?: "cash" | "card"; tender?: "cash" | "card";
credentials: SubscriptionCredentialInput[]; credentials: SubscriptionCredentialInput[];
plates: string[]; plates: string[];
/** UPDATE-only correction: move the sub to a different VERSION of its SAME plan. Price
* stays frozen; only the access rules change going forward. Requires subscription:plan. */
planVersionId?: string;
}; };
/** A server-computed quote: periods (ceil) × per-period price × quantity for a span. */ /** A server-computed quote: periods (ceil) × per-period price × quantity for a span. */
@@ -687,7 +690,17 @@ export interface ShiftStatus {
drawerMinor: number; drawerMinor: number;
currency: string | null; currency: string | null;
} }
export interface ShiftReport { /** Takings split by SOURCE — transient tickets vs subscriber money (monthly sales +
* out-of-window charges). Cash+card combined; the per-tender totals stay separate for
* the drawer. Shared by the X-report, the close Z-report, and the history summary. */
export interface ShiftSourceSplit {
ticketTotalMinor: number;
subscriptionTotalMinor: number;
subscriptionSalesMinor: number;
subscriptionWindowMinor: number;
}
export interface ShiftReport extends ShiftSourceSplit {
operator: string; operator: string;
startedAt: string; startedAt: string;
endedAt: string; endedAt: string;
@@ -716,7 +729,7 @@ export function closeShift(): Promise<ShiftReport> {
/** Mid-shift X-report: the open shift's takings + drawer "so far" (read-only — no /** Mid-shift X-report: the open shift's takings + drawer "so far" (read-only — no
* event is appended). Same figures the Z-report will print at close. `asOf` is the * event is appended). Same figures the Z-report will print at close. `asOf` is the
* snapshot instant. */ * snapshot instant. */
export interface XReport { export interface XReport extends ShiftSourceSplit {
operator: string; operator: string;
startedAt: string; startedAt: string;
endedAt: string; // = asOf endedAt: string; // = asOf
@@ -759,7 +772,7 @@ export function recordCashVoucher(args: {
} }
/** A completed shift (reconstructed from its signed Z-report). */ /** A completed shift (reconstructed from its signed Z-report). */
export interface ShiftSummary { export interface ShiftSummary extends ShiftSourceSplit {
id: string; id: string;
index: number; index: number;
operator: string; operator: string;
+19
View File
@@ -164,6 +164,17 @@ body,
height: 100%; height: 100%;
} }
/* Tell the engine the UI is dark so NATIVE controls — the <select> option popup,
scrollbars, date pickers, form widgets — render dark too. WebKitGTK (the Tauri
Linux WebView) otherwise paints the dropdown list with the OS light palette, so a
dark-theme <select> opened to a WHITE option list. `.theme-light` flips it back. */
html {
color-scheme: dark;
}
html.theme-light {
color-scheme: light;
}
body { body {
margin: 0; margin: 0;
background: var(--color-term-bg); background: var(--color-term-bg);
@@ -244,6 +255,14 @@ body {
.textarea:disabled { .textarea:disabled {
@apply cursor-not-allowed opacity-50; @apply cursor-not-allowed opacity-50;
} }
/* Native <option> popup colours. `color-scheme: dark` (on <html>) handles most
engines, but WebKitGTK (Tauri Linux) needs the option row colours set explicitly
or the open dropdown list stays white-on-light. The light theme re-lightens below. */
.select option,
.select optgroup {
background-color: var(--color-term-panel);
color: var(--color-term-text);
}
/* Small / dense variant for inline table cells */ /* Small / dense variant for inline table cells */
.input-sm { .input-sm {
height: var(--control-h-sm); height: var(--control-h-sm);
+19 -2
View File
@@ -111,7 +111,7 @@ export const en: Catalog = {
badgeOverstay: "overstay", badgeOverstay: "overstay",
badgeOverstayTitle: badgeOverstayTitle:
"Paid session. The customer failed to exit during the grace period. A new period began.", "Paid session. The customer failed to exit during the grace period. A new period began.",
plateTitle: "Licence plate recognized by the camera (advisory — not an access decision).", plateTitle: "Licence plate recognized ANPR.",
// filters // filters
filterSearchSessions: "Search ticket / subscriber / plate…", filterSearchSessions: "Search ticket / subscriber / plate…",
filterSearchFeed: "Search event / identity / plate…", filterSearchFeed: "Search event / identity / plate…",
@@ -398,6 +398,12 @@ export const en: Catalog = {
count: "How many", count: "How many",
planNone: "— comp / no charge —", planNone: "— comp / no charge —",
planNoneAvail: "No plans defined — an admin must create one first.", planNoneAvail: "No plans defined — an admin must create one first.",
version: "Version",
versionCurrent: "current",
versionHint: "Move this subscriber to another version of the same plan. The price stays as billed; only the access hours change going forward.",
versionOnlyOne: "only one version of this plan",
everyDay: "every day",
allDay: "24/7 (no window)",
quoting: "pricing…", quoting: "pricing…",
quotePrompt: "pick an end date", quotePrompt: "pick an end date",
quoteLine: "{{periods}} × {{unit}} · {{amount}} {{currency}}", quoteLine: "{{periods}} × {{unit}} · {{amount}} {{currency}}",
@@ -598,6 +604,10 @@ export const en: Catalog = {
payments: "Payments:", payments: "Payments:",
cash: "Cash:", cash: "Cash:",
card: "Card:", card: "Card:",
srcTickets: "Tickets:",
srcSubscriptions: "Subscriptions:",
srcSubSales: "sales",
srcSubWindow: "out-of-window",
drawerSection: "— Drawer —", drawerSection: "— Drawer —",
openingFloat: "Opening float:", openingFloat: "Opening float:",
cashTaken: "Cash taken:", cashTaken: "Cash taken:",
@@ -631,6 +641,10 @@ export const en: Catalog = {
payments: "Payments", payments: "Payments",
cash: "Cash", cash: "Cash",
card: "Card", card: "Card",
srcTickets: "Tickets",
srcSubscriptions: "Subscriptions",
srcSubSales: "subs sales",
srcSubWindow: "out-of-window",
expectedDrawer: "Expected drawer", expectedDrawer: "Expected drawer",
filterFrom: "From", filterFrom: "From",
filterTo: "To", filterTo: "To",
@@ -705,8 +719,11 @@ export const en: Catalog = {
plan: "Plan", plan: "Plan",
prepaid: "PREPAID", prepaid: "PREPAID",
subAssistHint: "Prepaid subscription. Open the barrier to assist the exit (faulty reader / missing card). No payment.", subAssistHint: "Prepaid subscription. Open the barrier to assist the exit (faulty reader / missing card). No payment.",
assistOpenReveal: "Assist open (faulty reader / lost card)…",
payWindowCharge: "Take payment",
windowPaidHint: "Window charge paid. Open the barrier to let the subscriber out.",
windowCharge: "OUT-OF-WINDOW", windowCharge: "OUT-OF-WINDOW",
windowChargeHint: "This subscriber parked outside their plan's allowed hours. They owe the transient tariff for the out-of-window time — take payment to allow the exit.", windowChargeHint: "This subscriber parked outside their plan's allowed hours. They owe the transient tariff for the out-of-window time — take payment, then open the barrier.",
subBarrierOpened: "Barrier opened for the subscriber (intervention recorded).", subBarrierOpened: "Barrier opened for the subscriber (intervention recorded).",
voucherPrinted: "Exit voucher printed on {{printer}}. Customer self-exits at the exit.", voucherPrinted: "Exit voucher printed on {{printer}}. Customer self-exits at the exit.",
// payment receipt (transparency slip) // payment receipt (transparency slip)
+20 -3
View File
@@ -113,7 +113,7 @@ export const sq = {
badgeOverstay: "tej afatit", badgeOverstay: "tej afatit",
badgeOverstayTitle: badgeOverstayTitle:
"Sesion i paguar. Klienti nuk doli brënda afatit kohor. Ka filluar një periudhë e re tarifimi.", "Sesion i paguar. Klienti nuk doli brënda afatit kohor. Ka filluar një periudhë e re tarifimi.",
plateTitle: "Targa e njohur nga kamera (orientuese — nuk është vendim aksesi).", plateTitle: "Targa e njohur nga ANPR",
// filtra // filtra
filterSearchSessions: "Kërko biletë / abonent / targë…", filterSearchSessions: "Kërko biletë / abonent / targë…",
filterSearchFeed: "Kërko event / identitet / targë…", filterSearchFeed: "Kërko event / identitet / targë…",
@@ -409,6 +409,12 @@ export const sq = {
count: "Sa", count: "Sa",
planNone: "— pa pagesë / falas —", planNone: "— pa pagesë / falas —",
planNoneAvail: "Asnjë plan i përcaktuar — admini duhet të krijojë një të parin.", planNoneAvail: "Asnjë plan i përcaktuar — admini duhet të krijojë një të parin.",
version: "Versioni",
versionCurrent: "aktual",
versionHint: "Zhvendos këtë abonent në një version tjetër të të njëjtit plan. Çmimi mbetet siç u faturua; ndryshon vetëm orari i lejuar nga këtu e tutje.",
versionOnlyOne: "vetëm një version i këtij plani",
everyDay: "çdo ditë",
allDay: "24/7 (pa orar)",
quoting: "duke llogaritur…", quoting: "duke llogaritur…",
quotePrompt: "zgjidh datën e mbarimit", quotePrompt: "zgjidh datën e mbarimit",
quoteLine: "{{periods}} × {{unit}} · {{amount}} {{currency}}", quoteLine: "{{periods}} × {{unit}} · {{amount}} {{currency}}",
@@ -496,7 +502,7 @@ export const sq = {
newVersionHint: "Kjo publikon një version TË RI të planit — shitjet ekzistuese ruajnë çmimin origjinal.", newVersionHint: "Kjo publikon një version TË RI të planit — shitjet ekzistuese ruajnë çmimin origjinal.",
period: "Periudha", period: "Periudha",
pricePer: "Çmimi për periudhë", pricePer: "Çmimi për periudhë",
namePlaceholder: "p.sh. Hotel ditor", namePlaceholder: "p.sh. Mujor standard, ose Mujor natën, ose Hotel ditor",
needName: "Emri i planit është i detyrueshëm.", needName: "Emri i planit është i detyrueshëm.",
needPrice: "Shkruaj një çmim më të madh se zero.", needPrice: "Shkruaj një çmim më të madh se zero.",
saved: "Plani u ruajt.", saved: "Plani u ruajt.",
@@ -610,6 +616,10 @@ export const sq = {
payments: "Pagesa:", payments: "Pagesa:",
cash: "Para:", cash: "Para:",
card: "Kartë:", card: "Kartë:",
srcTickets: "Bileta:",
srcSubscriptions: "Abonime:",
srcSubSales: "shitje",
srcSubWindow: "jashtë orarit",
drawerSection: "— Arka —", drawerSection: "— Arka —",
openingFloat: "Bilanci fillestar:", openingFloat: "Bilanci fillestar:",
cashTaken: "Para të marra:", cashTaken: "Para të marra:",
@@ -643,6 +653,10 @@ export const sq = {
payments: "Pagesa", payments: "Pagesa",
cash: "Para", cash: "Para",
card: "Kartë", card: "Kartë",
srcTickets: "Bileta",
srcSubscriptions: "Abonime",
srcSubSales: "shitje abonimesh",
srcSubWindow: "jashtë orarit",
expectedDrawer: "Gjëndje arke", expectedDrawer: "Gjëndje arke",
// Filter (admin only). // Filter (admin only).
filterFrom: "Nga", filterFrom: "Nga",
@@ -719,8 +733,11 @@ export const sq = {
plan: "Plani", plan: "Plani",
prepaid: "I PARAPAGUAR", prepaid: "I PARAPAGUAR",
subAssistHint: "Abonim i parapaguar. Hap barrierën për të ndihmuar daljen (lexues me defekt / kartë e munguar). S'ka pagesë.", subAssistHint: "Abonim i parapaguar. Hap barrierën për të ndihmuar daljen (lexues me defekt / kartë e munguar). S'ka pagesë.",
assistOpenReveal: "Ndihmë për hapje (lexues me defekt / kartë e humbur)…",
payWindowCharge: "Merr pagesën",
windowPaidHint: "Pagesa jashtë orarit u krye. Hap barrierën që abonenti të dalë.",
windowCharge: "JASHTË ORARIT", windowCharge: "JASHTË ORARIT",
windowChargeHint: "Ky abonent parkoi jashtë orarit të lejuar të planit. Detyrohet të paguajë tarifën kalimtare për kohën jashtë orarit — merr pagesën për të lejuar daljen.", windowChargeHint: "Ky abonent parkoi jashtë orarit të lejuar të planit. Detyrohet të paguajë tarifën kalimtare për kohën jashtë orarit — merr pagesën, pastaj hap barrierën.",
subBarrierOpened: "Barriera u hap për abonentin (ndërhyrje e regjistruar).", subBarrierOpened: "Barriera u hap për abonentin (ndërhyrje e regjistruar).",
voucherPrinted: "Bileta e daljes u printua në {{printer}}. Klienti del vetë te dalja.", voucherPrinted: "Bileta e daljes u printua në {{printer}}. Klienti del vetë te dalja.",
// payment receipt (transparency slip) // payment receipt (transparency slip)
+96 -4
View File
@@ -8,10 +8,11 @@ import {
} from "@tanstack/react-router"; } from "@tanstack/react-router";
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { 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";
import { can, closeShift, logout, openShift, setLanguagePref, setThemePref } from "./api.js"; import { can, closeShift, fetchShiftReport, logout, openShift, setLanguagePref, setThemePref } from "./api.js";
import { qk, queryClient } from "./lib/query.js"; import { qk, queryClient } from "./lib/query.js";
import { Modal } from "./ui/Modal.js";
import { setLanguage } from "./lib/i18n/index.js"; import { setLanguage } from "./lib/i18n/index.js";
import { applyTheme } from "./lib/theme.js"; import { applyTheme } from "./lib/theme.js";
import { useLiveFeed } from "./lib/use-live-feed.js"; import { useLiveFeed } from "./lib/use-live-feed.js";
@@ -199,6 +200,18 @@ function ShiftButton() {
const { isOpen, isMine, blockedByOther, heldBy } = useShift(); const { isOpen, isMine, blockedByOther, heldBy } = useShift();
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [err, setErr] = useState<string | null>(null); const [err, setErr] = useState<string | null>(null);
// Closing a shift signs the Z-report and is irreversible, so the header button never
// closes directly (a stray click would end the shift) — it opens a confirm modal that
// shows the live X-report first. Opening a shift has no such risk → immediate.
const [confirmingClose, setConfirmingClose] = useState(false);
function onClick() {
if (isMine) {
setConfirmingClose(true);
} else {
void act("open");
}
}
async function act(kind: "open" | "close") { async function act(kind: "open" | "close") {
setBusy(true); setBusy(true);
@@ -235,7 +248,7 @@ function ShiftButton() {
type="button" type="button"
disabled={busy || blockedByOther} disabled={busy || blockedByOther}
title={blockedByOther ? t("shift.headerHeldBy", { operator: heldBy ?? "?" }) : undefined} title={blockedByOther ? t("shift.headerHeldBy", { operator: heldBy ?? "?" }) : undefined}
onClick={() => act(isMine ? "close" : "open")} onClick={onClick}
className={`rounded-term border px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wider ${tone}`} className={`rounded-term border px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wider ${tone}`}
> >
{busy ? t("shift.opening") : label} {busy ? t("shift.opening") : label}
@@ -244,6 +257,82 @@ function ShiftButton() {
<span className="text-[10px] uppercase tracking-wider text-term-amber">{t("shift.headerNoShift")}</span> <span className="text-[10px] uppercase tracking-wider text-term-amber">{t("shift.headerNoShift")}</span>
)} )}
{err && <span className="text-[10px] text-term-red">{err}</span>} {err && <span className="text-[10px] text-term-red">{err}</span>}
{confirmingClose && (
<CloseShiftConfirm
busy={busy}
onCancel={() => setConfirmingClose(false)}
onConfirm={async () => {
await act("close");
setConfirmingClose(false);
}}
/>
)}
</div>
);
}
/** Confirm-before-close modal for the header shift button. Fetches the live X-report so
* the operator SEES their takings (split by source: tickets vs subscriptions) and the
* expected drawer before committing the irreversible Z-report. */
function CloseShiftConfirm({
busy,
onCancel,
onConfirm,
}: {
busy: boolean;
onCancel: () => void;
onConfirm: () => void;
}) {
const { t } = useTranslation();
const q = useQuery({ queryKey: ["shift", "xreport", "close-confirm"], queryFn: fetchShiftReport });
const x = q.data;
const cur = x?.currency ?? null;
const fmt = (m: number) => `${(m / 100).toLocaleString()} ${cur ?? ""}`.trim();
return (
<Modal open onClose={onCancel} title={t("shift.endShift")} width="max-w-md">
<div className="text-[13px] tabular-nums">
<p className="text-term-muted">{t("shift.endConfirm")}</p>
{!x ? (
<p className="mt-2 text-term-muted">{t("common.loading")}</p>
) : (
<>
<div className="mt-2 grid grid-cols-2 gap-x-6 gap-y-0.5">
<ConfirmFigure label={t("shift.payments")} value={String(x.paymentCount)} />
<span />
{/* Split by source — the operator's ask: subscription money apart from tickets. */}
<ConfirmFigure label={t("shift.srcTickets")} value={fmt(x.ticketTotalMinor)} />
<ConfirmFigure label={t("shift.srcSubscriptions")} value={fmt(x.subscriptionTotalMinor)} />
<ConfirmFigure label={t("shift.srcSubSales")} value={fmt(x.subscriptionSalesMinor)} sub />
<ConfirmFigure label={t("shift.srcSubWindow")} value={fmt(x.subscriptionWindowMinor)} sub />
</div>
<div className="mt-2 grid grid-cols-2 gap-x-6 gap-y-0.5 border-t border-term-border pt-2">
<ConfirmFigure label={t("shift.cash")} value={fmt(x.cashTotalMinor)} />
<ConfirmFigure label={t("shift.card")} value={fmt(x.cardTotalMinor)} />
<ConfirmFigure label={t("shift.expectedDrawer")} value={fmt(x.expectedDrawerMinor)} bold />
</div>
</>
)}
<div className="mt-3 flex justify-end gap-2">
<button type="button" className="btn btn-sm" onClick={onCancel} disabled={busy}>
{t("subs.cancel")}
</button>
<button type="button" className="btn btn-sm btn-danger" onClick={onConfirm} disabled={busy || !x}>
{busy ? t("shift.ending") : t("shift.endShift")}
</button>
</div>
</div>
</Modal>
);
}
function ConfirmFigure({ label, value, bold, sub }: { label: string; value: string; bold?: boolean; sub?: boolean }) {
return (
<div className={`flex items-baseline justify-between gap-2 ${sub ? "pl-3" : ""}`}>
<span className={`text-[11px] uppercase tracking-wider ${sub ? "text-term-muted/70" : "text-term-muted"}`}>
{label}
</span>
<span className={bold ? "font-semibold text-term-text" : "text-term-text"}>{value}</span>
</div> </div>
); );
} }
@@ -417,7 +506,10 @@ const subscriptionsRoute = createRoute({
getParentRoute: () => setupRoute, getParentRoute: () => setupRoute,
path: "subscriptions", path: "subscriptions",
beforeLoad: ({ context }) => requirePerm("subscription:read")(context), beforeLoad: ({ context }) => requirePerm("subscription:read")(context),
component: () => <SubscriptionManager />, component: function SubscriptionsRoute() {
const { user } = rootRoute.useRouteContext();
return <SubscriptionManager user={user} />;
},
}); });
const subscriptionPlansRoute = createRoute({ const subscriptionPlansRoute = createRoute({
getParentRoute: () => setupRoute, getParentRoute: () => setupRoute,
@@ -5,6 +5,7 @@ import type {
ReceiptData, ReceiptData,
SubscriptionCardData, SubscriptionCardData,
TicketData, TicketData,
WindowChargeNoticeData,
} from "../interfaces.js"; } from "../interfaces.js";
import type { ConfigField, DeviceConfig, PrinterDriver } from "../registry.js"; import type { ConfigField, DeviceConfig, PrinterDriver } from "../registry.js";
import { hostField, portField, stubLog } from "./common.js"; import { hostField, portField, stubLog } from "./common.js";
@@ -14,6 +15,7 @@ import {
renderReport, renderReport,
renderSubscriptionCard, renderSubscriptionCard,
renderTicket, renderTicket,
renderWindowChargeNotice,
sendRaw, sendRaw,
} from "./printer-escpos.js"; } from "./printer-escpos.js";
@@ -102,6 +104,11 @@ class CashinoPrinter implements PrinterDevice {
`printed ${data.voucher ? "voucher" : "receipt"} ${data.ticketId}`, `printed ${data.voucher ? "voucher" : "receipt"} ${data.ticketId}`,
); );
} }
async printWindowChargeNotice(data: WindowChargeNoticeData): Promise<void> {
await sendRaw(this.#host, this.#port, renderWindowChargeNotice(data), this.#timeout);
stubLog(this.driverId, `printed out-of-window slip ${data.occurrenceId}`);
}
} }
const roleField: ConfigField = { const roleField: ConfigField = {
+85 -4
View File
@@ -4,6 +4,7 @@ import type {
ReceiptData, ReceiptData,
SubscriptionCardData, SubscriptionCardData,
TicketData, TicketData,
WindowChargeNoticeData,
} from "../interfaces.js"; } from "../interfaces.js";
// Shared ESC/POS rendering + raw-TCP transport for 80mm thermal printers. // Shared ESC/POS rendering + raw-TCP transport for 80mm thermal printers.
@@ -68,6 +69,19 @@ const ASCII_FALLBACK: Record<string, string> = {
í: "i", í: "i",
ó: "o", ó: "o",
ú: "u", ú: "u",
// Typographic punctuation that creeps in from composed strings — CP852 has no
// em/en dash, ellipsis, curly quotes, or the warning sign, so without these they
// print as "?" (the cause of the "PARKIM ? JASHTË ORARIT" misprint). Degrade to
// the obvious ASCII equivalent rather than a literal "?".
"—": "-", // em dash U+2014
"–": "-", // en dash U+2013
"…": "...", // ellipsis U+2026
"‘": "'",
"’": "'",
"“": '"',
"”": '"',
"⚠": "!", // warning sign U+26A0 — no glyph on a thermal head; "!" reads as a flag
"•": "*",
}; };
/** Encode one line of text to CP852 bytes + a line feed. ASCII (<0x80) passes /** Encode one line of text to CP852 bytes + a line feed. ASCII (<0x80) passes
@@ -105,13 +119,19 @@ function line(text = ""): Buffer {
// dependency). The same code is printed as large human-readable digits below, so // dependency). The same code is printed as large human-readable digits below, so
// the operator can hand-key it if every reader fails. // the operator can hand-key it if every reader fails.
/** GS k — Code128 1D barcode. Height/width set first, then HRI off, then data. */ /** GS k — Code128 1D barcode. Height/width set first, then HRI off, then data.
function code128(data: string): Buffer { * `moduleWidth` (narrow-bar dots, 1–6) trades scan-tolerance for total width: at ~11
* modules/char a 13-char id fits 80mm (576 printable dots) at width 3, but a ~20-char
* id needs width 2 or it OVERFLOWS the head and the firmware silently aborts the
* barcode (prints nothing). It does NOT set alignment — the caller does (a barcode that
* FITS the head centers fine; the no-print bug was width, not centering). */
function code128(data: string, moduleWidth = 3): Buffer {
// Code128 code set B (printable ASCII) — prefix the data with the {B selector. // Code128 code set B (printable ASCII) — prefix the data with the {B selector.
const payload = Buffer.from(`{B${data}`, "ascii"); const payload = Buffer.from(`{B${data}`, "ascii");
const w = Math.max(1, Math.min(6, moduleWidth));
return Buffer.concat([ return Buffer.concat([
Buffer.from([GS, 0x68, 0x64]), // GS h 100 — barcode height = 100 dots (taller = tolerant of scan angle) Buffer.from([GS, 0x68, 0x64]), // GS h 100 — barcode height = 100 dots (taller = tolerant of scan angle)
Buffer.from([GS, 0x77, 0x03]), // GS w 3 — module width = 3 (wider bars for the short-range "Simple" QR/barcode engine; 13-digit Code128 ≈ 495/576 dots, fits 80mm with quiet zones) Buffer.from([GS, 0x77, w]), // GS w n — module (narrow-bar) width in dots
Buffer.from([GS, 0x48, 0x00]), // GS H 0 — HRI text off (we print the id ourselves) Buffer.from([GS, 0x48, 0x00]), // GS H 0 — HRI text off (we print the id ourselves)
// GS k 73 n <data> — function B form: 73 = Code128, n = data byte length. // GS k 73 n <data> — function B form: 73 = Code128, n = data byte length.
Buffer.from([GS, 0x6b, 0x49, payload.length]), Buffer.from([GS, 0x6b, 0x49, payload.length]),
@@ -188,6 +208,21 @@ const STR = {
], ],
/** Thank-you footer. */ /** Thank-you footer. */
thanks: "Faleminderit!", thanks: "Faleminderit!",
// --- out-of-window advisory slip (subscriber) ---
/** Slip title. ASCII dash (not em dash) so no codepage surprise. */
windowTitle: "PARKIM - JASHTË ORARIT",
/** "Subscriber: <name>" line. */
windowHolder: (name: string) => `Abonent: ${name}`,
/** Entry-edge line; appends the window-open time when known. */
windowEnteredEarly: (opensHHMM?: string) =>
opensHHMM ? `Ka hyrë jashtë orarit (orari hap ${opensHHMM})` : "Ka hyrë jashtë orarit",
/** Exit-edge line. */
windowExitedLate: "Ka dalë jashtë orarit",
/** "Entry:" / "Exit:" stamp label per edge. */
windowStamp: (edge: "entry" | "exit", v: string) => (edge === "entry" ? `Hyrja: ${v}` : `Dalja: ${v}`),
/** Two short lines (each fits 80mm) telling the customer a fee is pending and
* is settled at the booth before exit. ASCII "!" flag (no glyph for ⚠). */
windowPending: ["! Detyrim do të llogaritet në dalje", " (paguhet në kabinë para se të dilni)"] as const,
} as const; } as const;
/** Format integer minor units + ISO-4217 currency as a major-unit string for the /** Format integer minor units + ISO-4217 currency as a major-unit string for the
@@ -389,7 +424,8 @@ export function renderReceipt(data: ReceiptData): Buffer {
]; ];
if (data.voucher) { if (data.voucher) {
// The same ticket id, scannable at the exit reader, + the grace emphasis. // The same ticket id, scannable at the exit reader, + the grace emphasis. The block
// is ALIGN_CENTER (set at the amount above), so the barcode + id center as before.
parts.push( parts.push(
code128(data.ticketId), code128(data.ticketId),
line(), line(),
@@ -409,6 +445,51 @@ export function renderReceipt(data: ReceiptData): Buffer {
return Buffer.concat(parts); return Buffer.concat(parts);
} }
/** Build the ESC/POS byte stream for the ADVISORY out-of-window slip. Header →
* title → a SCANNABLE Code128 + QR of the occurrence id (so the operator scans it
* straight into the booth pay modal — same path as a transient ticket) → the id in
* text (hand-key fallback) → holder + entry/exit stamp → the "pay at booth" notice.
* Carries NO amount (the booth quotes the combined charge at settlement). Albanian. */
export function renderWindowChargeNotice(data: WindowChargeNoticeData): Buffer {
const hhmm = (m?: number | null) =>
m == null ? undefined : `${String(Math.floor(m / 60)).padStart(2, "0")}:${String(m % 60).padStart(2, "0")}`;
const parts: Buffer[] = [
INIT,
SELECT_CP852,
renderHeader(data.header),
line(),
ALIGN_CENTER,
BOLD_ON,
line(STR.windowTitle),
BOLD_OFF,
line(),
// The occurrence id, scannable two ways, both CENTERED (the surrounding block is
// ALIGN_CENTER). Code128 for a 1D laser scanner FIRST — module width 2 because the
// ~20-char occurrence id is too wide to fit the 80mm head at width 3 (the firmware
// would abort it); at width 2 (~510 dots) it fits and centers fine. Then the QR for
// the booth's combo reader. Either pulls the occurrence up in the pay modal.
code128(data.occurrenceId, 2),
line(),
qrCode(data.occurrenceId),
line(),
// The id in text, as the hand-key fallback if neither scans.
line(data.occurrenceId),
ALIGN_LEFT,
line(),
];
if (data.holderName) parts.push(line(STR.windowHolder(data.holderName)));
parts.push(line(STR.windowStamp(data.edge, stamp(data.at))));
parts.push(
line(data.edge === "entry" ? STR.windowEnteredEarly(hhmm(data.windowOpensMin)) : STR.windowExitedLate),
line(),
BOLD_ON,
...STR.windowPending.map((l) => line(l)),
BOLD_OFF,
FEED_AND_CUT,
);
return Buffer.concat(parts);
}
/** Open a TCP socket, write the bytes, and close GRACEFULLY so the printer reads the /** Open a TCP socket, write the bytes, and close GRACEFULLY so the printer reads the
* whole stream before the connection tears down. * whole stream before the connection tears down.
* *
@@ -8,6 +8,7 @@ import type {
PrintReport, PrintReport,
SubscriptionCardData, SubscriptionCardData,
TicketData, TicketData,
WindowChargeNoticeData,
} from "../interfaces.js"; } from "../interfaces.js";
import type { ConfigField, DeviceConfig, PrinterDriver } from "../registry.js"; import type { ConfigField, DeviceConfig, PrinterDriver } from "../registry.js";
import { hostField, portField, stubLog } from "./common.js"; import { hostField, portField, stubLog } from "./common.js";
@@ -17,6 +18,7 @@ import {
renderReport, renderReport,
renderSubscriptionCard, renderSubscriptionCard,
renderTicket, renderTicket,
renderWindowChargeNotice,
sendRaw, sendRaw,
} from "./printer-escpos.js"; } from "./printer-escpos.js";
@@ -186,6 +188,11 @@ class RongtaPrinter implements PrinterDevice, MonitorableDevice {
); );
} }
async printWindowChargeNotice(data: WindowChargeNoticeData): Promise<void> {
await sendRaw(this.#host, this.#port, renderWindowChargeNotice(data), this.#timeout);
stubLog(this.driverId, `printed out-of-window slip ${data.occurrenceId}`);
}
/** /**
* Live operator-actionable status, scraped from the device's own status page. * Live operator-actionable status, scraped from the device's own status page.
* The board decodes the ESC/POS status bits itself, so we trust its Yes/No * The board decodes the ESC/POS status bits itself, so we trust its Yes/No
+22
View File
@@ -247,6 +247,26 @@ export interface SubscriptionCardData {
readonly header?: TicketHeader; readonly header?: TicketHeader;
} }
/** An ADVISORY "out-of-window" slip for a subscriber who entered/exited outside
* their plan's allowed hours. NOT a payable ticket and carries NO final amount —
* the total is computed at the booth on settlement. It carries the OCCURRENCE id as
* a SCANNABLE Code128 + QR so the operator scans it straight into the booth pay
* modal (which then quotes the window charge) instead of hand-keying it — the same
* scan path as a transient ticket. See wiki/entities/subscription.md. */
export interface WindowChargeNoticeData {
/** The occurrence id (e.g. "SUBSESS-…") — the session identity the booth pay
* modal looks up. Encoded as the scannable code. */
readonly occurrenceId: string;
readonly holderName?: string | null;
/** When the scan happened (ISO-8601), printed as the human stamp. */
readonly at: string;
/** Entry (early) vs exit (late) — selects the wording. */
readonly edge: "entry" | "exit";
/** Minutes-from-midnight the allowed window opens, when known (entry slips). */
readonly windowOpensMin?: number | null;
readonly header?: TicketHeader;
}
export interface PrinterDevice extends Device { export interface PrinterDevice extends Device {
printTicket(data: TicketData): Promise<void>; printTicket(data: TicketData): Promise<void>;
/** Print a free-form text report (a shift Z-report, a receipt). `lines` are /** Print a free-form text report (a shift Z-report, a receipt). `lines` are
@@ -259,6 +279,8 @@ export interface PrinterDevice extends Device {
* voucher mode it also carries the ticket-id barcode + grace window so it * voucher mode it also carries the ticket-id barcode + grace window so it
* doubles as the self-exit voucher. See ReceiptData. */ * doubles as the self-exit voucher. See ReceiptData. */
printReceipt(data: ReceiptData): Promise<void>; printReceipt(data: ReceiptData): Promise<void>;
/** Print the advisory out-of-window slip with a scannable occurrence-id code. */
printWindowChargeNotice(data: WindowChargeNoticeData): Promise<void>;
} }
export interface PrintReport { export interface PrintReport {
+12 -5
View File
@@ -287,13 +287,20 @@ export interface LedgerPayload {
/** cash_in / cash_out voucher: a human-facing voucher number printed on the slip /** cash_in / cash_out voucher: a human-facing voucher number printed on the slip
* (Mandat Nr.). Sequential per type; signed for reproducibility. */ * (Mandat Nr.). Sequential per type; signed for reproducibility. */
readonly voucherNo?: string; readonly voucherNo?: string;
/** subscription tariff-bridge: an early-entry / late-exit transient charge OWED for /** subscription tariff-bridge: this occurrence opened OUTSIDE the plan's allowed window,
* parking outside the plan's allowed window, stamped on the vehicle_entry and collected * so the minutes actually parked out-of-window are charged at the transient tariff and
* (gated) at exit. The priced gap + tariff version travel alongside for reproducibility. * collected (gated) at exit. The AMOUNT is NOT fixed at entry — it depends on how long
* See wiki/entities/subscription.md ("tariff bridge"). */ * they actually park out-of-window (capped at the window edges), so it's priced live at
* settlement from minutesOutsideWindow(entry → pay-time). Only the marker + the tariff
* version (for reproducible pricing) are stamped. See wiki/entities/subscription.md
* ("tariff bridge"). */
readonly outOfWindow?: boolean;
readonly windowTariffVersionId?: string;
/** DEPRECATED stamp — a FIXED full-gap amount written by an earlier model. No longer
* produced (it over-charged a subscriber who left before the window opened); retained
* here only so historic signed events still type-check. Never read for pricing. */
readonly windowOwedMinor?: number; readonly windowOwedMinor?: number;
readonly windowCurrency?: string; readonly windowCurrency?: string;
readonly windowTariffVersionId?: string;
readonly windowGapStart?: string; readonly windowGapStart?: string;
readonly windowGapEnd?: string; readonly windowGapEnd?: string;
/** Free-form for forward-compat without a schema change. */ /** Free-form for forward-compat without a schema change. */
+13
View File
@@ -80,6 +80,19 @@ login ————————————————————————
That's the whole human-side requirement: **print the cash and the POS (if any).** No blind count, That's the whole human-side requirement: **print the cash and the POS (if any).** No blind count,
no variance gate, no manager override. no variance gate, no manager override.
> **Takings split by SOURCE + confirm-before-close (2026-06-21).** Two related changes:
> 1. The report now splits takings into **Tickets** (transient) vs **Subscriptions** (monthly
> `subscriptionSale` + a subscriber's out-of-window `subscriptionWindowCharge`), so the operator
> sees subscriber money apart from ticket money. The buckets are derived from the signed payment
> payload flags and always reconcile to `cash + card` (a payment with neither flag is a ticket).
> Computed once in `#summariseWindow`, carried on the signed `shift_z_report` payload
> (`ticketTotalMinor`/`subscriptionTotalMinor`/`subscriptionSalesMinor`/`subscriptionWindowMinor`),
> shown in the X-report, the close modal, the history detail, and the printed Z-report; old reports
> that predate the fields default subscription to 0 (ticket absorbs the whole take).
> 2. The **header shift button no longer closes directly** — a stray click would sign an irreversible
> Z-report. It opens a **confirm modal showing the live X-report** (the source split + expected
> drawer) with Cancel / End-shift. Opening a shift stays immediate (no such risk).
> **Z-report is now Albanian (2026-06-19).** The printed Z-report labels were hardcoded English > **Z-report is now Albanian (2026-06-19).** The printed Z-report labels were hardcoded English
> (`Operator:`/`From:`/`Cash:`) with raw ISO timestamps; now fully Albanian (`Operatori`/`Nga`/`Deri`/ > (`Operator:`/`From:`/`Cash:`) with raw ISO timestamps; now fully Albanian (`Operatori`/`Nga`/`Deri`/
> `Para në dorë`/`-- Arka --`/`Arka e pritur`…) with the human date format `19 Qershor 2026 10:48:25`, > `Para në dorë`/`-- Arka --`/`Arka e pritur`…) with the human date format `19 Qershor 2026 10:48:25`,
+8 -3
View File
@@ -149,9 +149,14 @@ Per the user's choices — the operator **keeps OS access** (no fullscreen lockd
offline), prompts the operator (i18n `update.prompt`), then `downloadAndInstall()` + `relaunch()`. offline), prompts the operator (i18n `update.prompt`), then `downloadAndInstall()` + `relaunch()`.
Accepts that the appliance may be **offline** day-to-day and brought online (phone hotspot) only Accepts that the appliance may be **offline** day-to-day and brought online (phone hotspot) only
when an update is wanted — consistent with [[offline-first]] (no network dependency in *core* when an update is wanted — consistent with [[offline-first]] (no network dependency in *core*
operation; updates are out-of-band). Endpoint in `tauri.conf.json` is a **placeholder** operation; updates are out-of-band). Endpoint is the **self-hosted Gitea** "latest release"
(`https://UPDATES.EXAMPLE.invalid/...`) to fill in once the self-hosted update URL exists; the path — `https://git.infra.msai.al/mca/parking_solution/releases/latest/download/latest.json`
server must serve `latest.json` + the signed installer + its `.sig`. — which redirects to the newest tag's `latest.json` (published by `.gitea/workflows/release.yml`).
The updater GETs it (200 + manifest, or 204 = up-to-date), reads `platforms.linux-x86_64.
{signature,url}`, and downloads the signed installer. **WS origin:** the desktop window's origin
is `tauri://localhost` (Linux may also send `http://tauri.localhost`), so the backend's
`WS_ALLOWED_ORIGINS` must include both or the live feed won't connect (documented in
`apps/server/.env.example`).
- **Code-signing (updater):** an Ed25519 **updater keypair** was generated. The **public key is - **Code-signing (updater):** an Ed25519 **updater keypair** was generated. The **public key is
embedded** in `tauri.conf.json` (`plugins.updater.pubkey`); the **private key + password live embedded** in `tauri.conf.json` (`plugins.updater.pubkey`); the **private key + password live
OUTSIDE the repo** at `~/.parking-updater-keys/` (0600) and as the build-time secrets OUTSIDE the repo** at `~/.parking-updater-keys/` (0600) and as the build-time secrets
+52 -17
View File
@@ -57,6 +57,18 @@ mutates an old one — past sales keep their recorded `planVersionId` and repric
sale, plus `planId` + `planVersionId` (which version priced it — reproducible, like a payment's sale, plus `planId` + `planVersionId` (which version priced it — reproducible, like a payment's
`tariffVersionId`). An **update never re-sells** (price/plan frozen); a new price = a new sale. `tariffVersionId`). An **update never re-sells** (price/plan frozen); a new price = a new sale.
> **Version correction (admin, built 2026-06-21).** The one update that may move `planVersionId`:
> an admin can re-point a sub to a **different VERSION of its SAME plan** — e.g. v2 changed the
> timeframes (`days [0–6]` → weekdays-only) and an existing subscriber should be on it, or back on v1.
> `PUT /api/subscriptions/:id` accepts `planVersionId`, **gated on `subscription:plan`** (plan-mgmt,
> stronger than `subscription:update`; a non-privileged caller is 403'd, not silently ignored). It is
> validated to belong to the sub's existing `planId` (a different plan = a different price basis = a
> re-sale, refused with 400). **Price/currency/period stay frozen** — only the access rules change,
> and only going forward (past signed `vehicle_entry`/`exit` events keep their own frozen
> `windowTariffVersionId`, so history reprices identically). The swap is server-logged for audit (the
> `subscriptions` row is mutable master data, not on the signed ledger). UI: an admin-only "Version"
> picker in the edit modal, listing every version of that plan by effective date + timeframe summary.
> **Superseded — per-row typed price (built 2026-06-18).** Originally each subscription stored its own > **Superseded — per-row typed price (built 2026-06-18).** Originally each subscription stored its own
> `priceMinor` + `period:"monthly"`, typed by the operator and pre-filled from > `priceMinor` + `period:"monthly"`, typed by the operator and pre-filled from
> `site_config.subscription_monthly_price_minor`. That column is **kept only to seed a "Monthly" plan** > `site_config.subscription_monthly_price_minor`. That column is **kept only to seed a "Monthly" plan**
@@ -98,23 +110,33 @@ out-of-window scans, the system **charges the out-of-window minutes at the norma
the V2 [[tariff]]**, Hën–Die; empty = every day). On a day NOT in the set the subscriber parks free. the V2 [[tariff]]**, Hën–Die; empty = every day). On a day NOT in the set the subscriber parks free.
`tz` is frozen in the plan version (like a V2 tariff's tz). null timeframes = 24/7, no charge ever. `tz` is frozen in the plan version (like a V2 tariff's tz). null timeframes = 24/7, no charge ever.
*(A "night plan, free weekends" is just `days:[Mon..Fri], 20:00→08:00`.)* *(A "night plan, free weekends" is just `days:[Mon..Fri], 20:00→08:00`.)*
- `outOfWindowGap(timeframes, tz, at, edge)` (pure, tz-aware, unit-tested in `@parking/shared`) - **An out-of-window subscriber is a transient ONLY for the minutes actually parked outside the
returns the `[start, end]` portion outside the window. **Early entry**: gap = arrival → next window — the amount is NOT knowable at entry.** A night-plan subscriber (window opens 20:00) who
window-open (a 09:00 arrival to a 20:00 window owes 09:00→20:00, capped by the tariff's daily cap). arrives at 13:21 and leaves at 14:30 parked **~1 hour** out-of-window and owes **one hour's transient
**Late exit**: gap = window-close → departure. The gap is priced with `computeFee` (the same engine fee** — NOT the whole 13:21→20:00 gap. They may come and go several times before the window opens;
transient stays use) at the active tariff version (`apps/server/src/subscription-window.ts`). each parked interval is its own short transient charge. So nothing fixed can be billed at entry.
- **The owed amount is ONE computation over the whole stay** (`windowOwedBetween` → - **The owed amount is ONE live computation over the whole stay** (`windowOwedBetween` →
`minutesOutsideWindow(timeframes, tz, entry, now)`): the minutes within `[entry, now]` that fall `minutesOutsideWindow(timeframes, tz, entry, settle-time)`): the minutes within `[entry, settle]`
outside the allowed window — covering **early entry AND late exit together**, bounded by the stay, that fall outside the allowed window, **capped at the window edges** — covering early entry AND late
off-days free. Priced once as a transient duration (so increments + the daily cap apply). This exit together, off-days free. Priced once as a transient duration with `computeFee` (so increments +
replaced an earlier buggy "entry-gap + exit-gap" sum whose exit gap reached back to a *previous* the daily cap apply) at the active tariff version (`apps/server/src/subscription-window.ts`). Both
day's close, charging a phantom ~12h to a car that had just entered early (the 4,100 ALL bug, the exit gate and the booth quote call this one function against the **current time**, so they agree
fixed 2026-06-20). Both the exit gate and the booth quote call this one function, so they agree. and the amount reflects exactly the out-of-window minutes parked. `settle-time` is the exit-scan at
- **Early entry is DEFERRED:** the barrier opens now; an advisory `windowOwedMinor` + priced gap are the gate, and the pay-time at the booth; the **late-exit tail keeps accruing until payment** (it
signed onto the `vehicle_entry` for the feed badge, and a **best-effort advisory slip prints** doesn't stop at the refused scan), so a subscriber who lingers past window-close pays for that time.
("PARKIM — JASHTË ORARIT": entered out-of-window, *fee computed at exit*, occurrence no.) so the - **Capping is automatic:** once a subscriber crosses INTO the window (e.g. parked 19:00→21:30 with
subscriber has paper proof. A missing/failed printer NEVER blocks the barrier (`printWindowChargeNotice`, a 20:00 open), only the 19:00→20:00 portion is charged; the in-window time is free. An early
fully swallowed, after the open). arrival who is still parked when the window opens stops accruing at window-open.
- This corrected the earlier model that **stamped a FIXED `windowOwedMinor` = full gap-to-window-open
at entry** (e.g. 800 ALL for 13:21→20:00) and deferred it — which over-charged anyone who left
before the window opened. The fixed stamp is gone; see [[#tariff-bridge-history]].
- **Out-of-window entry opens the barrier and prints a window-bounded TICKET.** The `vehicle_entry`
carries only a **marker** (`outOfWindow: true` + `windowTariffVersionId` for reproducible pricing),
NO fixed amount. A **best-effort ticket slip prints** ("PARKIM — JASHTË ORARIT": entered
out-of-window, *fee computed at exit*, occurrence no.) carrying the occurrence id as a **scannable
Code128 + QR** — the operator scans it straight into the booth pay modal at settlement, the same
scan path as a transient ticket. A missing/failed printer NEVER blocks the barrier
(`printWindowChargeNotice`, fully swallowed, after the open).
- **Late exit is GATED:** at exit, `owed = windowOwedBetween(entry, now) − payments`. If `> 0`, the - **Late exit is GATED:** at exit, `owed = windowOwedBetween(entry, now) − payments`. If `> 0`, the
exit is **REFUSED** with the signed reason `sub.refused.unpaidWindow`; the subscriber settles at exit is **REFUSED** with the signed reason `sub.refused.unpaidWindow`; the subscriber settles at
the booth (a signed `payment` keyed to the occurrence — folds into the shift/drawer/Z-report like the booth (a signed `payment` keyed to the occurrence — folds into the shift/drawer/Z-report like
@@ -126,6 +148,19 @@ out-of-window scans, the system **charges the out-of-window minutes at the norma
> *choosing* to refuse an unpaid car. The standing **fail-open** rule governs the *can't-decide* > *choosing* to refuse an unpaid car. The standing **fail-open** rule governs the *can't-decide*
> (power/host/network loss) path, which still opens. The two are not in conflict; don't conflate them. > (power/host/network loss) path, which still opens. The two are not in conflict; don't conflate them.
##### tariff-bridge history
The out-of-window charge has had two superseded models, both over-charging:
1. **entry-gap + exit-gap sum** whose exit gap reached back to a *previous* day's close → a phantom
~12h on a car that had just entered early (the 4,100 ALL bug, fixed 2026-06-20 by switching to the
single `windowOwedBetween(entry, now)` computation).
2. **a FIXED `windowOwedMinor` stamped at entry** = the whole gap-to-window-open (e.g. 800 ALL for a
13:21 arrival to a 20:00 window), deferred and billed at exit → over-charged anyone who left before
the window opened (a 1-hour visit billed as 6.5 hours). Fixed 2026-06-21: the entry stamp is now a
**marker only** (`outOfWindow` + `windowTariffVersionId`); the amount is priced live from the
minutes **actually** parked out-of-window, capped at the window edges. `windowOwedMinor` and the
`windowGap*`/`windowCurrency` fields remain in the `LedgerPayload` type as **deprecated, read-only**
so historic signed events still type-check; they are never produced or read for pricing.
**Reserved subscriber spots** — see [[capacity-occupancy]] (an admin toggle that holds a spot per **Reserved subscriber spots** — see [[capacity-occupancy]] (an admin toggle that holds a spot per
active subscriber's car in the [[occupancy]] full-gate). The subscriber flow itself is never gated by active subscriber's car in the [[occupancy]] full-gate). The subscriber flow itself is never gated by
"full"; reservation only tightens the *transient* gate. "full"; reservation only tightens the *transient* gate.
+39
View File
@@ -1229,3 +1229,42 @@ keypair generated: pubkey embedded in tauri.conf.json; private key + password ke
.deb/.rpm/.AppImage + .sig updater signatures; turbo run build lint 14/14 green; no key material in .deb/.rpm/.AppImage + .sig updater signatures; turbo run build lint 14/14 green; no key material in
the repo. Updated As-built in [[desktop-shell-tauri]]. Deferred: real update URL, OS installer the repo. Updated As-built in [[desktop-shell-tauri]]. Deferred: real update URL, OS installer
signing, Windows kiosk-browser fallback. signing, Windows kiosk-browser fallback.
## [2026-06-21] fix | Subscription out-of-window charge — marker-not-fixed-amount; scannable ticket; booth flow
Corrected the [[subscription]] tariff-bridge charging model after operator feedback. The entry path
stamped a FIXED `windowOwedMinor` = the whole gap-to-window-open (e.g. 800 ALL for a 13:21 arrival to
a 20:00 window) and deferred it — over-charging anyone who left before the window opened (a 1-hour
visit billed as 6.5h). Now the `vehicle_entry` carries only a MARKER (`outOfWindow` +
`windowTariffVersionId`); the amount is priced LIVE from `minutesOutsideWindow(entry → settle-time)`,
which caps at the window edges, so one hour parked = one hour's transient fee, in-window time free, and
the late-exit tail keeps accruing until payment. The advisory slip is now a scannable Code128 + QR
TICKET of the occurrence id (operator scans it into the booth pay modal). Also: removed the always-on
"Open barrier" from the active-sessions list AND modal for subscribers — a prepaid sub shows only a
small "assist open" reveal; an out-of-window sub is pay-first-then-open. Fixed the ESC/POS encoder so
typographic chars (— ⚠ … ' ") transliterate to ASCII instead of "?". `windowOwedMinor`/`windowGap*`
kept as deprecated read-only in `LedgerPayload` for historic events. Verified live model on a DB copy
(13:21→14:30 = 200 ALL; 19:55-grace→23:00 = 0; 19:00→21:30-cross = 100 ALL). build+lint 14/14, shared
87/87. Existing signed occurrences left untouched (immutable). See [[subscription]] tariff-bridge-history.
## [2026-06-21] feat | Subscription plan-version correction (admin)
Added an admin-only path to move an existing [[subscription]] to a different VERSION of its SAME plan
(e.g. v1 "every day" → v2 "weekdays only" of mujor-naten-cdo-dite). PUT /api/subscriptions/:id now
accepts planVersionId, gated on subscription:plan (403 for non-privileged), validated to share the
sub's existing planId (cross-plan = 400 — that'd be a re-sale). Price/currency/period stay frozen;
only the access rules change going forward (past signed events keep their own windowTariffVersionId).
Server-logged for audit. UI: admin-only "Versioni" picker in the edit modal, listing every version by
effective date + timeframe summary, current pre-selected. Verified on a writable DB copy: version
changed, price + planId frozen, cross-plan rejected. build+lint 14/14, i18n parity (sq+en). Live DB
untouched. See [[subscription]] "Version correction".
## [2026-06-21] feat | Shift report split (tickets vs subscriptions) + confirm-before-close + dark <select>
Three UI/report changes. (1) The [[shift]] report now splits takings by SOURCE — Tickets (transient)
vs Subscriptions (monthly sales + a subscriber's out-of-window charge), derived from the signed
payment payload flags (subscriptionSale / subscriptionWindowCharge), always reconciling to cash+card.
Carried on the signed shift_z_report payload + shown in X-report, close modal, history detail, and the
printed Z-report; pre-split reports default subscription to 0. (2) The header shift button no longer
closes directly — it opens a confirm modal showing the live X-report (the split + expected drawer)
before signing the irreversible Z-report. Opening stays immediate. (3) Fixed dark-theme native
<select> popups rendering WHITE on WebKitGTK (Tauri Linux) via color-scheme + explicit option colours.
Verified the split on a read-only DB copy (tickets 0, subs 10,200 = 10,000 sale + 200 out-of-window,
reconciles). build+lint 14/14, i18n parity (sq+en). See [[shift]] "Takings split by source".