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": {
|
||||
"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": [
|
||||
"https://UPDATES.EXAMPLE.invalid/parking/{{target}}/{{arch}}/{{current_version}}"
|
||||
"https://git.infra.msai.al/mca/parking_solution/releases/latest/download/latest.json"
|
||||
],
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDgxNzg5RUQ1QkM0Q0FDRjYKUldUMnJFeTgxWjU0Z1RlNmhneDVZQlVVTVZZdGhJTkUxTGdDeGYwQSttZmNKVVp5WEdVMWlBb1YK"
|
||||
}
|
||||
|
||||
@@ -28,7 +28,10 @@ EVENT_SIGNING_KEY=
|
||||
|
||||
# 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.
|
||||
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) -------------------------------------------------
|
||||
# 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 {
|
||||
formatStampSq,
|
||||
printWithFailover,
|
||||
registry,
|
||||
type PrinterDevice,
|
||||
@@ -166,26 +165,19 @@ export async function printSubscriptionCard(
|
||||
*/
|
||||
export async function printWindowChargeNotice(
|
||||
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,
|
||||
): Promise<string> {
|
||||
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) =>
|
||||
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}`);
|
||||
return printedBy;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { randomBytes, randomUUID } from "node:crypto";
|
||||
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 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 { printSubscriptionCard } from "../booth-print.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
|
||||
* plan is sold; ignored on update (master-data edit, no money moves). Default "cash". */
|
||||
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. */
|
||||
@@ -433,10 +440,41 @@ export async function subscriptionRoutes(
|
||||
const b = req.body ?? {};
|
||||
const problems = validate(b);
|
||||
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
|
||||
// price means a new sale = a new subscription). Editable here: holder/contact,
|
||||
// car-count, the validity window, status, and credentials/plates.
|
||||
|
||||
// PLAN-VERSION CORRECTION (opt-in, privileged). Move the sub to a different VERSION
|
||||
// of its SAME plan — e.g. an admin published v2 (different timeframes) and wants this
|
||||
// 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)
|
||||
.set({
|
||||
holderName: b.holderName ?? null,
|
||||
@@ -445,6 +483,7 @@ export async function subscriptionRoutes(
|
||||
validFrom: b.validFrom === undefined ? existing.validFrom : (b.validFrom ?? null),
|
||||
validTo: resolveValidTo(b, existing.validTo),
|
||||
status: b.status ?? existing.status,
|
||||
planVersionId,
|
||||
})
|
||||
.where(eq(subscriptions.id, req.params.id))
|
||||
.run();
|
||||
|
||||
@@ -51,6 +51,10 @@ export interface ShiftSummary {
|
||||
readonly cardTotalMinor: number;
|
||||
readonly currency: string | null;
|
||||
readonly paymentCount: number;
|
||||
readonly ticketTotalMinor: number;
|
||||
readonly subscriptionTotalMinor: number;
|
||||
readonly subscriptionSalesMinor: number;
|
||||
readonly subscriptionWindowMinor: number;
|
||||
readonly openingFloatMinor: number;
|
||||
readonly cashAddedMinor: number;
|
||||
readonly cashRemovedMinor: number;
|
||||
@@ -65,6 +69,15 @@ export interface ShiftReport {
|
||||
readonly cardTotalMinor: number;
|
||||
readonly currency: string | null;
|
||||
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) ---
|
||||
/** Cash in the drawer at shift start = prior shift's expected closing drawer. */
|
||||
readonly openingFloatMinor: number;
|
||||
@@ -160,6 +173,10 @@ export class ShiftService {
|
||||
cashTotalMinor?: number;
|
||||
cardTotalMinor?: number;
|
||||
paymentCount?: number;
|
||||
ticketTotalMinor?: number;
|
||||
subscriptionTotalMinor?: number;
|
||||
subscriptionSalesMinor?: number;
|
||||
subscriptionWindowMinor?: number;
|
||||
openingFloatMinor?: number;
|
||||
cashAddedMinor?: number;
|
||||
cashRemovedMinor?: number;
|
||||
@@ -180,6 +197,16 @@ export class ShiftService {
|
||||
cardTotalMinor: pl.cardTotalMinor ?? 0,
|
||||
currency: pl.currency ?? null,
|
||||
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,
|
||||
cashAddedMinor: pl.cashAddedMinor ?? 0,
|
||||
cashRemovedMinor: pl.cashRemovedMinor ?? 0,
|
||||
@@ -348,14 +375,29 @@ export class ShiftService {
|
||||
|
||||
let cashTotalMinor = 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;
|
||||
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;
|
||||
if (pl.tender === "card") cardTotalMinor += 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;
|
||||
}
|
||||
const subscriptionTotalMinor = subscriptionSalesMinor + subscriptionWindowMinor;
|
||||
const ticketTotalMinor = cashTotalMinor + cardTotalMinor - subscriptionTotalMinor;
|
||||
|
||||
// --- Drawer figures ---
|
||||
// Opening float was fixed on shift_open (inherited from the chain at start);
|
||||
@@ -403,6 +445,10 @@ export class ShiftService {
|
||||
cardTotalMinor,
|
||||
currency,
|
||||
paymentCount: payments.length,
|
||||
ticketTotalMinor,
|
||||
subscriptionTotalMinor,
|
||||
subscriptionSalesMinor,
|
||||
subscriptionWindowMinor,
|
||||
openingFloatMinor,
|
||||
cashAddedMinor,
|
||||
cashRemovedMinor,
|
||||
@@ -437,6 +483,10 @@ export class ShiftService {
|
||||
cardTotalMinor,
|
||||
currency,
|
||||
paymentCount,
|
||||
ticketTotalMinor,
|
||||
subscriptionTotalMinor,
|
||||
subscriptionSalesMinor,
|
||||
subscriptionWindowMinor,
|
||||
openingFloatMinor,
|
||||
cashAddedMinor,
|
||||
cashRemovedMinor,
|
||||
@@ -455,6 +505,10 @@ export class ShiftService {
|
||||
cardTotalMinor,
|
||||
currency: currency ?? undefined,
|
||||
paymentCount,
|
||||
ticketTotalMinor,
|
||||
subscriptionTotalMinor,
|
||||
subscriptionSalesMinor,
|
||||
subscriptionWindowMinor,
|
||||
openingFloatMinor,
|
||||
cashAddedMinor,
|
||||
cashRemovedMinor,
|
||||
@@ -492,6 +546,12 @@ export class ShiftService {
|
||||
`Para në dorë: ${money(r.cashTotalMinor)} ${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 --",
|
||||
`Fillimi (kusur): ${money(r.openingFloatMinor)} ${cur}`,
|
||||
`Para të marra: ${money(r.cashTotalMinor)} ${cur}`,
|
||||
|
||||
@@ -189,13 +189,16 @@ export class SubscriptionFlow {
|
||||
return { accepted: false, direction: "entry", reason };
|
||||
}
|
||||
|
||||
// TARIFF BRIDGE — early entry. If the plan has time windows and this scan is before
|
||||
// the window opens, the subscriber owes the transient tariff for arrival→window-open.
|
||||
// We DEFER it (open now, collect at exit): stamp the owed amount on the SIGNED entry
|
||||
// payload (the source of truth — `windowOwedMinor`), so the exit gate reads it back
|
||||
// from the chain. Plans without timeframes return null → nothing owed. See
|
||||
// wiki/entities/subscription.md.
|
||||
const entryCharge = windowCharge(this.#db, sub.planVersionId, now, "entry");
|
||||
// TARIFF BRIDGE — out-of-window entry. If the plan has time windows and this scan is
|
||||
// OUTSIDE the allowed window, the subscriber will owe the transient tariff for the time
|
||||
// they actually park out-of-window. The AMOUNT is NOT knowable now — it depends on when
|
||||
// they leave (a subscriber who enters early and leaves before the window opens owes only
|
||||
// their parked minutes, NOT the whole gap-to-window-open). So we stamp only a MARKER
|
||||
// (`outOfWindow`) + the tariff version, and price it live at settlement from
|
||||
// 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
|
||||
// the payload's `permitId` (which every fold matches on), so the key stays compact.
|
||||
@@ -205,29 +208,27 @@ export class SubscriptionFlow {
|
||||
direction: "entry",
|
||||
source,
|
||||
identity: occurrenceId,
|
||||
// No ticket, no fee — the subscription IS the authorization. Recorded for audit.
|
||||
// `permitId`/`permit` are the on-chain field names (immutable). A deferred early-
|
||||
// entry charge is signed here (windowOwedMinor + the priced gap) so it's owed at exit.
|
||||
// The subscription IS the authorization (no fee for in-window use). `permitId`/`permit`
|
||||
// are the on-chain field names (immutable). An out-of-window entry is MARKED here
|
||||
// (`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: {
|
||||
sessionRef: occurrenceId,
|
||||
permitId: m.subscriptionId,
|
||||
permit: true,
|
||||
via: m.via,
|
||||
...(entryCharge
|
||||
...(outOfWindow
|
||||
? {
|
||||
windowOwedMinor: entryCharge.amountMinor,
|
||||
windowCurrency: entryCharge.currency,
|
||||
windowTariffVersionId: entryCharge.tariffVersionId,
|
||||
windowGapStart: entryCharge.gapStart,
|
||||
windowGapEnd: entryCharge.gapEnd,
|
||||
outOfWindow: true,
|
||||
windowTariffVersionId: outOfWindow.tariffVersionId,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
occurredAt: now,
|
||||
});
|
||||
if (entryCharge) {
|
||||
if (outOfWindow) {
|
||||
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");
|
||||
@@ -246,11 +247,12 @@ export class SubscriptionFlow {
|
||||
} catch (err) {
|
||||
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
|
||||
// proof a fee is pending (the final amount is computed at the booth on settlement,
|
||||
// combining early-entry + any late-exit time). AFTER the open + cache, and fully
|
||||
// BEST-EFFORT: print the out-of-window TICKET so the subscriber has the paper the
|
||||
// operator scans to settle at the booth. It carries the occurrence id as a scannable
|
||||
// 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.
|
||||
if (entryCharge) {
|
||||
if (outOfWindow) {
|
||||
const tf = (planVersionById(this.#db, sub.planVersionId)?.timeframes ?? null) as PlanTimeframes | null;
|
||||
void printWindowChargeNotice(
|
||||
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
|
||||
// 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:
|
||||
// - click a row → the pay/exit modal (pay an unpaid car, or review),
|
||||
// - "Open barrier" (PAID sessions only) → an audited human-intervention re-pulse.
|
||||
// No payment → no Open barrier button (the no-unpaid-bypass rule).
|
||||
// - click a row → the pay/exit modal (pay an unpaid car, settle a subscriber's
|
||||
// out-of-window charge, assist-open a prepaid subscriber, or review),
|
||||
// - "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
|
||||
// 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>
|
||||
</button>
|
||||
|
||||
{/* Open barrier — PAID-and-still-in-grace transient OR a SUBSCRIPTION
|
||||
(prepaid). NOT an OVERSTAY session: its grace has expired, so the car
|
||||
owes a top-up — the row routes to the pay/exit modal instead (no
|
||||
free overstay exit). An unpaid transient also has no button
|
||||
(no-unpaid-bypass). Mirrors reopenBarrier's server-side guard. */}
|
||||
{(s.paidAt && !s.overstay) || s.subscription ? (
|
||||
{/* Open barrier — PAID-and-still-in-grace TRANSIENT only: an audited
|
||||
re-pulse for a car that paid but the barrier didn't confirm. NOT an
|
||||
OVERSTAY (grace expired → owes a top-up; routes to the pay/exit modal)
|
||||
and NOT a SUBSCRIPTION (the assist-open, and any out-of-window payment,
|
||||
live in the pay/exit modal — the list must not offer a one-click open,
|
||||
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
|
||||
type="button"
|
||||
disabled={reopen.isPending || !shiftReady}
|
||||
|
||||
+100
-45
@@ -45,6 +45,13 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
||||
const [result, setResult] = useState<string | null>(null);
|
||||
const [openingShift, setOpeningShift] = 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;
|
||||
// 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() {
|
||||
setOpeningShift(true);
|
||||
setError(null);
|
||||
@@ -280,17 +306,22 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* For a subscription with a window charge, explain why it's payable. For a
|
||||
plain prepaid subscription, explain the assist-open is the only action. */}
|
||||
{subWindowDue ? (
|
||||
{/* Subscription guidance: an unpaid window charge explains the pay-first
|
||||
gate; once paid, prompt the operator to open the barrier; a prepaid
|
||||
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">
|
||||
{t("pay.windowChargeHint")}
|
||||
</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">
|
||||
{t("pay.subAssistHint")}
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
{/* For an overstay, explain why a top-up is required (no free exit). */}
|
||||
{isOverstay && (
|
||||
@@ -302,37 +333,36 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
||||
{/* Snapshots */}
|
||||
<SnapshotStrip identity={identity} />
|
||||
|
||||
{phase !== "done" && !isSubscription && (
|
||||
<>
|
||||
{/* Tender */}
|
||||
{canPay && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[11px] uppercase tracking-wider text-term-muted">{t("pay.tender")}</span>
|
||||
{(["cash", "card"] as const).map((tn) => (
|
||||
<button
|
||||
key={tn}
|
||||
type="button"
|
||||
onClick={() => setTender(tn)}
|
||||
className={tender === tn ? "btn btn-primary btn-sm" : "btn btn-sm"}
|
||||
>
|
||||
{t(`pay.${tn}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* Tender — shown for any payable case (transient, overstay, OR a
|
||||
subscriber window charge that's still unpaid). */}
|
||||
{phase !== "done" && canPay && !(subWindowDue && windowPaid) && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[11px] uppercase tracking-wider text-term-muted">{t("pay.tender")}</span>
|
||||
{(["cash", "card"] as const).map((tn) => (
|
||||
<button
|
||||
key={tn}
|
||||
type="button"
|
||||
onClick={() => setTender(tn)}
|
||||
className={tender === tn ? "btn btn-primary btn-sm" : "btn btn-sm"}
|
||||
>
|
||||
{t(`pay.${tn}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Voucher checkbox (default from site config) */}
|
||||
<label className="flex items-center gap-2 text-[12px]">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="accent-term-amber"
|
||||
checked={voucher}
|
||||
onChange={(e) => setPrintVoucherChecked(e.target.checked)}
|
||||
/>
|
||||
{t("pay.printExitVoucher")}
|
||||
<span className="text-term-muted">{t("pay.selfExitHint")}</span>
|
||||
</label>
|
||||
</>
|
||||
{/* Voucher checkbox (transient only; a subscriber doesn't self-exit). */}
|
||||
{phase !== "done" && !isSubscription && (
|
||||
<label className="flex items-center gap-2 text-[12px]">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="accent-term-amber"
|
||||
checked={voucher}
|
||||
onChange={(e) => setPrintVoucherChecked(e.target.checked)}
|
||||
/>
|
||||
{t("pay.printExitVoucher")}
|
||||
<span className="text-term-muted">{t("pay.selfExitHint")}</span>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{error && <div className="rounded-term border border-term-red px-3 py-2 text-term-red">{error}</div>}
|
||||
@@ -374,16 +404,41 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
||||
{t("common.cancel")}
|
||||
</button>
|
||||
{isSubscription ? (
|
||||
// Prepaid — the only action is the audited barrier open (assist
|
||||
// a faulty exit reader / missing card). Gated on an open shift.
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpenBarrier}
|
||||
disabled={!shiftReady || phase === "finishing"}
|
||||
className="btn btn-pay btn-lg"
|
||||
>
|
||||
{phase === "finishing" ? t("pay.opening") : t("booth.openBarrier")}
|
||||
</button>
|
||||
subWindowDue && !windowPaid ? (
|
||||
// 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
|
||||
type="button"
|
||||
onClick={handleOpenBarrier}
|
||||
disabled={!shiftReady || phase === "finishing"}
|
||||
className="btn btn-pay btn-lg"
|
||||
>
|
||||
{phase === "finishing" ? t("pay.opening") : t("booth.openBarrier")}
|
||||
</button>
|
||||
) : (
|
||||
// Prepaid, nothing owed: no default open. A small reveal exposes
|
||||
// the audited manual open for a faulty reader / lost card.
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAssistRevealed(true)}
|
||||
disabled={!shiftReady}
|
||||
className="btn btn-ghost btn-sm"
|
||||
>
|
||||
{t("pay.assistOpenReveal")}
|
||||
</button>
|
||||
)
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -112,9 +112,12 @@ function eventBadges(p: LedgerEvent["payload"]): string[] {
|
||||
if (p.permitRefused) keys.push("booth.badgeSubRefused");
|
||||
if (p.ticketPrinted === false) keys.push("booth.badgeNoTicket");
|
||||
if (p.subscriptionSale) keys.push("booth.badgeSubSale");
|
||||
// Subscriber entered/exited outside their plan's allowed window → owes a deferred
|
||||
// transient charge, collected (gated) at exit. Flag it so the operator KNOWS now.
|
||||
if (typeof p.windowOwedMinor === "number" && p.windowOwedMinor > 0) keys.push("booth.badgeWindowCharge");
|
||||
// Subscriber entered outside their plan's allowed window → will owe a transient charge
|
||||
// for the minutes actually parked out-of-window, priced + collected (gated) at exit.
|
||||
// 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");
|
||||
return keys;
|
||||
}
|
||||
|
||||
@@ -90,6 +90,10 @@ function useCurrentShift(): { current: (ShiftSummary & { open: true }) | null; i
|
||||
cardTotalMinor: x.cardTotalMinor,
|
||||
currency: x.currency,
|
||||
paymentCount: x.paymentCount,
|
||||
ticketTotalMinor: x.ticketTotalMinor,
|
||||
subscriptionTotalMinor: x.subscriptionTotalMinor,
|
||||
subscriptionSalesMinor: x.subscriptionSalesMinor,
|
||||
subscriptionWindowMinor: x.subscriptionWindowMinor,
|
||||
openingFloatMinor: x.openingFloatMinor,
|
||||
cashAddedMinor: x.cashAddedMinor,
|
||||
cashRemovedMinor: x.cashRemovedMinor,
|
||||
@@ -320,6 +324,10 @@ function ShiftActivityLog({
|
||||
)}
|
||||
</div>
|
||||
<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.cashTaken")} value={money(shift.cashTotalMinor, 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="mt-1 grid grid-cols-2 gap-x-6 gap-y-0.5">
|
||||
<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.card")} value={money(report.cardTotalMinor, 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>
|
||||
) : (
|
||||
// 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">
|
||||
<p className="text-term-muted">{t("shift.endConfirm")}</p>
|
||||
<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.card")} value={money(shift.cardTotalMinor, cur)} />
|
||||
<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="mt-1 grid grid-cols-2 gap-x-6 gap-y-0.5">
|
||||
<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.card")} value={money(x.cardTotalMinor, 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 (
|
||||
<div className="flex justify-between gap-2">
|
||||
<span className="text-term-muted">{label}</span>
|
||||
<div className={`flex justify-between gap-2 ${sub ? "pl-3" : ""}`}>
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -14,7 +14,10 @@ import {
|
||||
quoteSubscription,
|
||||
revokeSubscription,
|
||||
updateSubscription,
|
||||
can,
|
||||
type Permission,
|
||||
type ReaderInfo,
|
||||
type SessionUser,
|
||||
type Subscription,
|
||||
type SubscriptionCredential,
|
||||
type SubscriptionInput,
|
||||
@@ -34,6 +37,11 @@ interface FormState {
|
||||
holderName: string;
|
||||
contact: string;
|
||||
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)
|
||||
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)
|
||||
@@ -55,6 +63,8 @@ function emptyForm(): FormState {
|
||||
holderName: "",
|
||||
contact: "",
|
||||
planId: "",
|
||||
planVersionId: "",
|
||||
origPlanVersionId: "",
|
||||
quantity: "1",
|
||||
count: "1",
|
||||
tender: "cash",
|
||||
@@ -70,7 +80,9 @@ function formFrom(s: Subscription): FormState {
|
||||
return {
|
||||
holderName: s.holderName ?? "",
|
||||
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),
|
||||
count: "1",
|
||||
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())
|
||||
.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),
|
||||
// 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();
|
||||
}
|
||||
|
||||
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();
|
||||
// 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 [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 [quoting, setQuoting] = useState(false);
|
||||
const [editing, setEditing] = useState<string | "new" | null>(null);
|
||||
@@ -173,7 +220,15 @@ export function SubscriptionManager() {
|
||||
.catch(() => {
|
||||
/* 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).
|
||||
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>
|
||||
<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
|
||||
|
||||
+16
-3
@@ -565,6 +565,9 @@ export type SubscriptionInput = {
|
||||
tender?: "cash" | "card";
|
||||
credentials: SubscriptionCredentialInput[];
|
||||
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. */
|
||||
@@ -687,7 +690,17 @@ export interface ShiftStatus {
|
||||
drawerMinor: number;
|
||||
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;
|
||||
startedAt: 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
|
||||
* event is appended). Same figures the Z-report will print at close. `asOf` is the
|
||||
* snapshot instant. */
|
||||
export interface XReport {
|
||||
export interface XReport extends ShiftSourceSplit {
|
||||
operator: string;
|
||||
startedAt: string;
|
||||
endedAt: string; // = asOf
|
||||
@@ -759,7 +772,7 @@ export function recordCashVoucher(args: {
|
||||
}
|
||||
|
||||
/** A completed shift (reconstructed from its signed Z-report). */
|
||||
export interface ShiftSummary {
|
||||
export interface ShiftSummary extends ShiftSourceSplit {
|
||||
id: string;
|
||||
index: number;
|
||||
operator: string;
|
||||
|
||||
@@ -164,6 +164,17 @@ body,
|
||||
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 {
|
||||
margin: 0;
|
||||
background: var(--color-term-bg);
|
||||
@@ -244,6 +255,14 @@ body {
|
||||
.textarea:disabled {
|
||||
@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 */
|
||||
.input-sm {
|
||||
height: var(--control-h-sm);
|
||||
|
||||
@@ -111,7 +111,7 @@ export const en: Catalog = {
|
||||
badgeOverstay: "overstay",
|
||||
badgeOverstayTitle:
|
||||
"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
|
||||
filterSearchSessions: "Search ticket / subscriber / plate…",
|
||||
filterSearchFeed: "Search event / identity / plate…",
|
||||
@@ -398,6 +398,12 @@ export const en: Catalog = {
|
||||
count: "How many",
|
||||
planNone: "— comp / no charge —",
|
||||
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…",
|
||||
quotePrompt: "pick an end date",
|
||||
quoteLine: "{{periods}} × {{unit}} · {{amount}} {{currency}}",
|
||||
@@ -598,6 +604,10 @@ export const en: Catalog = {
|
||||
payments: "Payments:",
|
||||
cash: "Cash:",
|
||||
card: "Card:",
|
||||
srcTickets: "Tickets:",
|
||||
srcSubscriptions: "Subscriptions:",
|
||||
srcSubSales: "sales",
|
||||
srcSubWindow: "out-of-window",
|
||||
drawerSection: "— Drawer —",
|
||||
openingFloat: "Opening float:",
|
||||
cashTaken: "Cash taken:",
|
||||
@@ -631,6 +641,10 @@ export const en: Catalog = {
|
||||
payments: "Payments",
|
||||
cash: "Cash",
|
||||
card: "Card",
|
||||
srcTickets: "Tickets",
|
||||
srcSubscriptions: "Subscriptions",
|
||||
srcSubSales: "subs sales",
|
||||
srcSubWindow: "out-of-window",
|
||||
expectedDrawer: "Expected drawer",
|
||||
filterFrom: "From",
|
||||
filterTo: "To",
|
||||
@@ -705,8 +719,11 @@ export const en: Catalog = {
|
||||
plan: "Plan",
|
||||
prepaid: "PREPAID",
|
||||
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",
|
||||
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).",
|
||||
voucherPrinted: "Exit voucher printed on {{printer}}. Customer self-exits at the exit.",
|
||||
// payment receipt (transparency slip)
|
||||
|
||||
@@ -113,7 +113,7 @@ export const sq = {
|
||||
badgeOverstay: "tej afatit",
|
||||
badgeOverstayTitle:
|
||||
"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
|
||||
filterSearchSessions: "Kërko biletë / abonent / targë…",
|
||||
filterSearchFeed: "Kërko event / identitet / targë…",
|
||||
@@ -409,6 +409,12 @@ export const sq = {
|
||||
count: "Sa",
|
||||
planNone: "— pa pagesë / falas —",
|
||||
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…",
|
||||
quotePrompt: "zgjidh datën e mbarimit",
|
||||
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.",
|
||||
period: "Periudha",
|
||||
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.",
|
||||
needPrice: "Shkruaj një çmim më të madh se zero.",
|
||||
saved: "Plani u ruajt.",
|
||||
@@ -610,6 +616,10 @@ export const sq = {
|
||||
payments: "Pagesa:",
|
||||
cash: "Para:",
|
||||
card: "Kartë:",
|
||||
srcTickets: "Bileta:",
|
||||
srcSubscriptions: "Abonime:",
|
||||
srcSubSales: "shitje",
|
||||
srcSubWindow: "jashtë orarit",
|
||||
drawerSection: "— Arka —",
|
||||
openingFloat: "Bilanci fillestar:",
|
||||
cashTaken: "Para të marra:",
|
||||
@@ -643,6 +653,10 @@ export const sq = {
|
||||
payments: "Pagesa",
|
||||
cash: "Para",
|
||||
card: "Kartë",
|
||||
srcTickets: "Bileta",
|
||||
srcSubscriptions: "Abonime",
|
||||
srcSubSales: "shitje abonimesh",
|
||||
srcSubWindow: "jashtë orarit",
|
||||
expectedDrawer: "Gjëndje arke",
|
||||
// Filter (admin only).
|
||||
filterFrom: "Nga",
|
||||
@@ -719,8 +733,11 @@ export const sq = {
|
||||
plan: "Plani",
|
||||
prepaid: "I PARAPAGUAR",
|
||||
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",
|
||||
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).",
|
||||
voucherPrinted: "Bileta e daljes u printua në {{printer}}. Klienti del vetë te dalja.",
|
||||
// payment receipt (transparency slip)
|
||||
|
||||
+96
-4
@@ -8,10 +8,11 @@ import {
|
||||
} from "@tanstack/react-router";
|
||||
import { useState } from "react";
|
||||
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 { 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 { Modal } from "./ui/Modal.js";
|
||||
import { setLanguage } from "./lib/i18n/index.js";
|
||||
import { applyTheme } from "./lib/theme.js";
|
||||
import { useLiveFeed } from "./lib/use-live-feed.js";
|
||||
@@ -199,6 +200,18 @@ function ShiftButton() {
|
||||
const { isOpen, isMine, blockedByOther, heldBy } = useShift();
|
||||
const [busy, setBusy] = useState(false);
|
||||
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") {
|
||||
setBusy(true);
|
||||
@@ -235,7 +248,7 @@ function ShiftButton() {
|
||||
type="button"
|
||||
disabled={busy || blockedByOther}
|
||||
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}`}
|
||||
>
|
||||
{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>
|
||||
)}
|
||||
{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>
|
||||
);
|
||||
}
|
||||
@@ -417,7 +506,10 @@ const subscriptionsRoute = createRoute({
|
||||
getParentRoute: () => setupRoute,
|
||||
path: "subscriptions",
|
||||
beforeLoad: ({ context }) => requirePerm("subscription:read")(context),
|
||||
component: () => <SubscriptionManager />,
|
||||
component: function SubscriptionsRoute() {
|
||||
const { user } = rootRoute.useRouteContext();
|
||||
return <SubscriptionManager user={user} />;
|
||||
},
|
||||
});
|
||||
const subscriptionPlansRoute = createRoute({
|
||||
getParentRoute: () => setupRoute,
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
ReceiptData,
|
||||
SubscriptionCardData,
|
||||
TicketData,
|
||||
WindowChargeNoticeData,
|
||||
} from "../interfaces.js";
|
||||
import type { ConfigField, DeviceConfig, PrinterDriver } from "../registry.js";
|
||||
import { hostField, portField, stubLog } from "./common.js";
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
renderReport,
|
||||
renderSubscriptionCard,
|
||||
renderTicket,
|
||||
renderWindowChargeNotice,
|
||||
sendRaw,
|
||||
} from "./printer-escpos.js";
|
||||
|
||||
@@ -102,6 +104,11 @@ class CashinoPrinter implements PrinterDevice {
|
||||
`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 = {
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
ReceiptData,
|
||||
SubscriptionCardData,
|
||||
TicketData,
|
||||
WindowChargeNoticeData,
|
||||
} from "../interfaces.js";
|
||||
|
||||
// Shared ESC/POS rendering + raw-TCP transport for 80mm thermal printers.
|
||||
@@ -68,6 +69,19 @@ const ASCII_FALLBACK: Record<string, string> = {
|
||||
í: "i",
|
||||
ó: "o",
|
||||
ú: "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
|
||||
@@ -105,13 +119,19 @@ function line(text = ""): Buffer {
|
||||
// dependency). The same code is printed as large human-readable digits below, so
|
||||
// the operator can hand-key it if every reader fails.
|
||||
|
||||
/** GS k — Code128 1D barcode. Height/width set first, then HRI off, then data. */
|
||||
function code128(data: string): Buffer {
|
||||
/** GS k — Code128 1D barcode. Height/width set first, then HRI off, then data.
|
||||
* `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.
|
||||
const payload = Buffer.from(`{B${data}`, "ascii");
|
||||
const w = Math.max(1, Math.min(6, moduleWidth));
|
||||
return Buffer.concat([
|
||||
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)
|
||||
// GS k 73 n <data> — function B form: 73 = Code128, n = data byte length.
|
||||
Buffer.from([GS, 0x6b, 0x49, payload.length]),
|
||||
@@ -188,6 +208,21 @@ const STR = {
|
||||
],
|
||||
/** Thank-you footer. */
|
||||
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;
|
||||
|
||||
/** 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) {
|
||||
// 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(
|
||||
code128(data.ticketId),
|
||||
line(),
|
||||
@@ -409,6 +445,51 @@ export function renderReceipt(data: ReceiptData): Buffer {
|
||||
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
|
||||
* whole stream before the connection tears down.
|
||||
*
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
PrintReport,
|
||||
SubscriptionCardData,
|
||||
TicketData,
|
||||
WindowChargeNoticeData,
|
||||
} from "../interfaces.js";
|
||||
import type { ConfigField, DeviceConfig, PrinterDriver } from "../registry.js";
|
||||
import { hostField, portField, stubLog } from "./common.js";
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
renderReport,
|
||||
renderSubscriptionCard,
|
||||
renderTicket,
|
||||
renderWindowChargeNotice,
|
||||
sendRaw,
|
||||
} 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.
|
||||
* 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;
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
printTicket(data: TicketData): Promise<void>;
|
||||
/** 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
|
||||
* doubles as the self-exit voucher. See ReceiptData. */
|
||||
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 {
|
||||
|
||||
@@ -287,13 +287,20 @@ export interface LedgerPayload {
|
||||
/** cash_in / cash_out voucher: a human-facing voucher number printed on the slip
|
||||
* (Mandat Nr.). Sequential per type; signed for reproducibility. */
|
||||
readonly voucherNo?: string;
|
||||
/** subscription tariff-bridge: an early-entry / late-exit transient charge OWED for
|
||||
* parking outside the plan's allowed window, stamped on the vehicle_entry and collected
|
||||
* (gated) at exit. The priced gap + tariff version travel alongside for reproducibility.
|
||||
* See wiki/entities/subscription.md ("tariff bridge"). */
|
||||
/** subscription tariff-bridge: this occurrence opened OUTSIDE the plan's allowed window,
|
||||
* so the minutes actually parked out-of-window are charged at the transient tariff and
|
||||
* collected (gated) at exit. The AMOUNT is NOT fixed at entry — it depends on how long
|
||||
* 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 windowCurrency?: string;
|
||||
readonly windowTariffVersionId?: string;
|
||||
readonly windowGapStart?: string;
|
||||
readonly windowGapEnd?: string;
|
||||
/** 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,
|
||||
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
|
||||
> (`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`,
|
||||
|
||||
@@ -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()`.
|
||||
Accepts that the appliance may be **offline** day-to-day and brought online (phone hotspot) only
|
||||
when an update is wanted — consistent with [[offline-first]] (no network dependency in *core*
|
||||
operation; updates are out-of-band). Endpoint in `tauri.conf.json` is a **placeholder**
|
||||
(`https://UPDATES.EXAMPLE.invalid/...`) to fill in once the self-hosted update URL exists; the
|
||||
server must serve `latest.json` + the signed installer + its `.sig`.
|
||||
operation; updates are out-of-band). Endpoint is the **self-hosted Gitea** "latest release"
|
||||
path — `https://git.infra.msai.al/mca/parking_solution/releases/latest/download/latest.json`
|
||||
— which redirects to the newest tag's `latest.json` (published by `.gitea/workflows/release.yml`).
|
||||
The updater GETs it (200 + manifest, or 204 = up-to-date), reads `platforms.linux-x86_64.
|
||||
{signature,url}`, and downloads the signed installer. **WS origin:** the desktop window's origin
|
||||
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
|
||||
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
|
||||
|
||||
@@ -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
|
||||
`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
|
||||
> `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**
|
||||
@@ -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.
|
||||
`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`.)*
|
||||
- `outOfWindowGap(timeframes, tz, at, edge)` (pure, tz-aware, unit-tested in `@parking/shared`)
|
||||
returns the `[start, end]` portion outside the window. **Early entry**: gap = arrival → next
|
||||
window-open (a 09:00 arrival to a 20:00 window owes 09:00→20:00, capped by the tariff's daily cap).
|
||||
**Late exit**: gap = window-close → departure. The gap is priced with `computeFee` (the same engine
|
||||
transient stays use) at the active tariff version (`apps/server/src/subscription-window.ts`).
|
||||
- **The owed amount is ONE computation over the whole stay** (`windowOwedBetween` →
|
||||
`minutesOutsideWindow(timeframes, tz, entry, now)`): the minutes within `[entry, now]` that fall
|
||||
outside the allowed window — covering **early entry AND late exit together**, bounded by the stay,
|
||||
off-days free. Priced once as a transient duration (so increments + the daily cap apply). This
|
||||
replaced an earlier buggy "entry-gap + exit-gap" sum whose exit gap reached back to a *previous*
|
||||
day's close, charging a phantom ~12h to a car that had just entered early (the 4,100 ALL bug,
|
||||
fixed 2026-06-20). Both the exit gate and the booth quote call this one function, so they agree.
|
||||
- **Early entry is DEFERRED:** the barrier opens now; an advisory `windowOwedMinor` + priced gap are
|
||||
signed onto the `vehicle_entry` for the feed badge, and a **best-effort advisory slip prints**
|
||||
("PARKIM — JASHTË ORARIT": entered out-of-window, *fee computed at exit*, occurrence no.) so the
|
||||
subscriber has paper proof. A missing/failed printer NEVER blocks the barrier (`printWindowChargeNotice`,
|
||||
fully swallowed, after the open).
|
||||
- **An out-of-window subscriber is a transient ONLY for the minutes actually parked outside the
|
||||
window — the amount is NOT knowable at entry.** A night-plan subscriber (window opens 20:00) who
|
||||
arrives at 13:21 and leaves at 14:30 parked **~1 hour** out-of-window and owes **one hour's transient
|
||||
fee** — NOT the whole 13:21→20:00 gap. They may come and go several times before the window opens;
|
||||
each parked interval is its own short transient charge. So nothing fixed can be billed at entry.
|
||||
- **The owed amount is ONE live computation over the whole stay** (`windowOwedBetween` →
|
||||
`minutesOutsideWindow(timeframes, tz, entry, settle-time)`): the minutes within `[entry, settle]`
|
||||
that fall outside the allowed window, **capped at the window edges** — covering early entry AND late
|
||||
exit together, off-days free. Priced once as a transient duration with `computeFee` (so increments +
|
||||
the daily cap apply) at the active tariff version (`apps/server/src/subscription-window.ts`). Both
|
||||
the exit gate and the booth quote call this one function against the **current time**, so they agree
|
||||
and the amount reflects exactly the out-of-window minutes parked. `settle-time` is the exit-scan at
|
||||
the gate, and the pay-time at the booth; the **late-exit tail keeps accruing until payment** (it
|
||||
doesn't stop at the refused scan), so a subscriber who lingers past window-close pays for that time.
|
||||
- **Capping is automatic:** once a subscriber crosses INTO the window (e.g. parked 19:00→21:30 with
|
||||
a 20:00 open), only the 19:00→20:00 portion is charged; the in-window time is free. An early
|
||||
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
|
||||
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
|
||||
@@ -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*
|
||||
> (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
|
||||
active subscriber's car in the [[occupancy]] full-gate). The subscriber flow itself is never gated by
|
||||
"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
|
||||
the repo. Updated As-built in [[desktop-shell-tauri]]. Deferred: real update URL, OS installer
|
||||
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