Compare commits
8 Commits
d0536da3d7
...
eb47016ae3
| Author | SHA1 | Date | |
|---|---|---|---|
| eb47016ae3 | |||
| 78d1f6808a | |||
| 31f116a068 | |||
| 663bf0e925 | |||
| 8acef0464c | |||
| df5caf8d87 | |||
| 0cbae94842 | |||
| ae5c122980 |
@@ -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
|
||||||
@@ -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"
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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}`,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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,10 +333,9 @@ 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) => (
|
||||||
@@ -321,7 +351,8 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Voucher checkbox (default from site config) */}
|
{/* Voucher checkbox (transient only; a subscriber doesn't self-exit). */}
|
||||||
|
{phase !== "done" && !isSubscription && (
|
||||||
<label className="flex items-center gap-2 text-[12px]">
|
<label className="flex items-center gap-2 text-[12px]">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -332,7 +363,6 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
|||||||
{t("pay.printExitVoucher")}
|
{t("pay.printExitVoucher")}
|
||||||
<span className="text-term-muted">{t("pay.selfExitHint")}</span>
|
<span className="text-term-muted">{t("pay.selfExitHint")}</span>
|
||||||
</label>
|
</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,8 +404,21 @@ 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
|
||||||
|
// barrier open is the explicit next step (revealed once paid).
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handlePaySubscriptionWindow}
|
||||||
|
disabled={!shiftReady || phase === "paying"}
|
||||||
|
className="btn btn-go btn-lg"
|
||||||
|
>
|
||||||
|
{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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleOpenBarrier}
|
onClick={handleOpenBarrier}
|
||||||
@@ -384,6 +427,18 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
|||||||
>
|
>
|
||||||
{phase === "finishing" ? t("pay.opening") : t("booth.openBarrier")}
|
{phase === "finishing" ? t("pay.opening") : t("booth.openBarrier")}
|
||||||
</button>
|
</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"
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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 = {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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`,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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".
|
||||||
|
|||||||
Reference in New Issue
Block a user