a20400c2c5
Creating a priced subscription wrote only the mutable `subscriptions`
master row and appended NOTHING to the signed ledger — so the cash an
operator collected showed in the live feed, drawer, and shift Z-report
nowhere, leaving no signed trace. A booth operator could sell
subscriptions and pocket the money untraceably — the exact
operator-as-adversary path the append-only signed ledger exists to close.
Found live: 3 priced subscriptions (27,000 ALL) had zero payment events.
Selling a priced subscription now appends a signed `payment` event at
create time: amount = priceMinor x months (full multi-month prepay),
operator-chosen tender (cash->drawer / card->bank), payload
{ subscriptionSale: true, permitId, operator, months }. Folds into the
shift Z-report/drawer with no new summing logic; the feed badges it
"subscription sale" and resolves the holder name. The create response
returns the recorded { sale }; subscriptionRoutes now takes the EventLog
and ShiftService.
Not hard-gated on an open shift (a sale can happen outside the booth money
path) — it warns instead. The 3 historical off-book sales are not
back-fillable (append-only forbids forging dated events) — reconcile via
cash_movement or a Z-report note.
Verified against a copy of the live DB with the real signing modules:
signed payment appended, hash-chain still verifies, lands in shift cash
totals. Build + lint 12/12.
Wiki: subscription "Collecting the fee" deferred -> BUILT (+ the off-book
hole and why); shift sale-folds-in; threat-model worked example
("store the price != account for the sale").
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
903 lines
32 KiB
TypeScript
903 lines
32 KiB
TypeScript
// Thin API client for the operator/admin UI.
|
||
//
|
||
// Auth is cookie-based: the JWT lives in an HttpOnly cookie the browser sends
|
||
// automatically (credentials: 'include'). For mutations we echo the readable
|
||
// CSRF cookie back in the X-CSRF-Token header (double-submit). See
|
||
// wiki/entities/local-jwt-auth.md.
|
||
|
||
import { logFailedRequest } from "./lib/logger.js";
|
||
import type { AppLogRecord } from "@parking/shared";
|
||
|
||
const CSRF_COOKIE = "parking_csrf";
|
||
const CSRF_HEADER = "X-CSRF-Token";
|
||
|
||
function readCookie(name: string): string | null {
|
||
const m = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`));
|
||
return m ? decodeURIComponent(m[1]!) : null;
|
||
}
|
||
|
||
/** fetch wrapper: sends cookies, adds CSRF header on mutations, parses errors. */
|
||
export async function apiFetch<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||
const method = (init.method ?? "GET").toUpperCase();
|
||
const headers = new Headers(init.headers);
|
||
if (init.body && !headers.has("content-type")) {
|
||
headers.set("content-type", "application/json");
|
||
}
|
||
if (method !== "GET" && method !== "HEAD") {
|
||
const csrf = readCookie(CSRF_COOKIE);
|
||
if (csrf) headers.set(CSRF_HEADER, csrf);
|
||
}
|
||
const res = await fetch(path, { ...init, headers, credentials: "include" });
|
||
if (!res.ok) {
|
||
const msg = (await res.json().catch(() => ({}))) as { error?: string; problems?: string[] };
|
||
const error = msg.error ?? `${path}: ${res.status}`;
|
||
// Ship the failed request to the backend log store (best-effort, loop-safe — the
|
||
// logger itself never logs the /api/logs call). 401s are normal pre-login churn,
|
||
// so we don't report them as errors. See lib/logger.ts.
|
||
if (res.status !== 401) {
|
||
logFailedRequest({ path, method, status: res.status, error });
|
||
}
|
||
throw new ApiError(error, res.status, msg.problems);
|
||
}
|
||
if (res.status === 204) return undefined as T;
|
||
return res.json() as Promise<T>;
|
||
}
|
||
|
||
export class ApiError extends Error {
|
||
constructor(
|
||
message: string,
|
||
readonly status: number,
|
||
/** Field-level problems from a validation error (e.g. tariff publish), if any. */
|
||
readonly problems?: string[],
|
||
) {
|
||
super(message);
|
||
}
|
||
}
|
||
|
||
// --- Auth -----------------------------------------------------------------
|
||
|
||
export type Lang = "sq" | "en";
|
||
export type Theme = "dark" | "light";
|
||
/** A `resource:action` permission string (the server is the source of truth for
|
||
* the full grid; the role composer fetches it via /api/roles). */
|
||
export type Permission = string;
|
||
export interface SessionUser {
|
||
id: string;
|
||
username: string;
|
||
roleId: string;
|
||
roleName: string;
|
||
/** The permissions this user's role grants — the UI gates nav/routes on these. */
|
||
permissions: Permission[];
|
||
/** Preferred UI language (loaded from the server on login). */
|
||
language: Lang;
|
||
/** Preferred UI theme (loaded from the server on login). */
|
||
theme: Theme;
|
||
/** Optional display name (profile metadata); null if unset. */
|
||
fullName: string | null;
|
||
}
|
||
|
||
/** Does this session grant the permission? Central authz check for the SPA. */
|
||
export function can(user: SessionUser | null, perm: Permission): boolean {
|
||
return !!user && user.permissions.includes(perm);
|
||
}
|
||
|
||
export function login(username: string, password: string): Promise<SessionUser> {
|
||
return apiFetch<SessionUser>("/api/auth/login", {
|
||
method: "POST",
|
||
body: JSON.stringify({ username, password }),
|
||
});
|
||
}
|
||
|
||
export function logout(): Promise<{ ok: boolean }> {
|
||
return apiFetch("/api/auth/logout", { method: "POST" });
|
||
}
|
||
|
||
/** Persist the current user's UI language preference (restored on next login). */
|
||
export function setLanguagePref(language: Lang): Promise<{ language: Lang }> {
|
||
return apiFetch("/api/auth/language", { method: "PUT", body: JSON.stringify({ language }) });
|
||
}
|
||
|
||
/** Persist the current user's UI theme preference (restored on next login). */
|
||
export function setThemePref(theme: Theme): Promise<{ theme: Theme }> {
|
||
return apiFetch("/api/auth/theme", { method: "PUT", body: JSON.stringify({ theme }) });
|
||
}
|
||
|
||
/** Returns the current user, or null if not authenticated. */
|
||
export async function fetchMe(): Promise<SessionUser | null> {
|
||
try {
|
||
return await apiFetch<SessionUser>("/api/auth/me");
|
||
} catch (e) {
|
||
if (e instanceof ApiError && (e.status === 401 || e.status === 403)) return null;
|
||
throw e;
|
||
}
|
||
}
|
||
|
||
// --- User & role management (RBAC) ----------------------------------------
|
||
|
||
/** Optional profile metadata on a managed user (all nullable). */
|
||
export interface UserProfile {
|
||
fullName: string | null;
|
||
phone: string | null;
|
||
email: string | null;
|
||
address: string | null;
|
||
}
|
||
export interface ManagedUser extends UserProfile {
|
||
id: string;
|
||
username: string;
|
||
roleId: string;
|
||
roleName: string;
|
||
language: Lang;
|
||
createdAt: string;
|
||
}
|
||
export interface ManagedRole {
|
||
id: string;
|
||
name: string;
|
||
builtin: boolean;
|
||
permissions: Permission[];
|
||
userCount: number;
|
||
}
|
||
|
||
export function fetchUsers(): Promise<{ users: ManagedUser[] }> {
|
||
return apiFetch("/api/users");
|
||
}
|
||
export function createUser(
|
||
body: { username: string; password: string; roleId: string } & Partial<UserProfile>,
|
||
): Promise<ManagedUser> {
|
||
return apiFetch("/api/users", { method: "POST", body: JSON.stringify(body) });
|
||
}
|
||
export function updateUser(
|
||
id: string,
|
||
body: { username?: string; roleId?: string } & Partial<UserProfile>,
|
||
): Promise<ManagedUser> {
|
||
return apiFetch(`/api/users/${id}`, { method: "PUT", body: JSON.stringify(body) });
|
||
}
|
||
export function resetUserPassword(id: string, password: string): Promise<{ ok: boolean }> {
|
||
return apiFetch(`/api/users/${id}/password`, { method: "PUT", body: JSON.stringify({ password }) });
|
||
}
|
||
export function deleteUser(id: string): Promise<{ ok: boolean }> {
|
||
return apiFetch(`/api/users/${id}`, { method: "DELETE" });
|
||
}
|
||
|
||
/** Roles + the full permission catalog (for the composer checkbox grid). */
|
||
export function fetchRoles(): Promise<{ catalog: Permission[]; roles: ManagedRole[] }> {
|
||
return apiFetch("/api/roles");
|
||
}
|
||
export function createRole(body: { name: string; permissions: Permission[] }): Promise<ManagedRole> {
|
||
return apiFetch("/api/roles", { method: "POST", body: JSON.stringify(body) });
|
||
}
|
||
export function updateRole(id: string, body: { name?: string; permissions?: Permission[] }): Promise<ManagedRole> {
|
||
return apiFetch(`/api/roles/${id}`, { method: "PUT", body: JSON.stringify(body) });
|
||
}
|
||
export function deleteRole(id: string): Promise<{ ok: boolean }> {
|
||
return apiFetch(`/api/roles/${id}`, { method: "DELETE" });
|
||
}
|
||
|
||
// --- Application logs (app_logs) ------------------------------------------
|
||
/** Read recent diagnostic logs (gated server-side by log:read). */
|
||
export function fetchLogs(params: {
|
||
limit?: number;
|
||
level?: string;
|
||
source?: string;
|
||
since?: string;
|
||
} = {}): Promise<{ logs: AppLogRecord[] }> {
|
||
const q = new URLSearchParams();
|
||
if (params.limit) q.set("limit", String(params.limit));
|
||
if (params.level) q.set("level", params.level);
|
||
if (params.source) q.set("source", params.source);
|
||
if (params.since) q.set("since", params.since);
|
||
const qs = q.toString();
|
||
return apiFetch(`/api/logs${qs ? `?${qs}` : ""}`);
|
||
}
|
||
|
||
// --- Device setup ---------------------------------------------------------
|
||
|
||
export interface ConfigField {
|
||
key: string;
|
||
label: string;
|
||
type: "string" | "number" | "boolean" | "host" | "port" | "secret" | "select";
|
||
required: boolean;
|
||
default?: string | number | boolean;
|
||
options?: { value: string; label: string }[];
|
||
help?: string;
|
||
}
|
||
|
||
export interface CatalogEntry {
|
||
id: string;
|
||
label: string;
|
||
description: string;
|
||
transports: string[];
|
||
configFields: ConfigField[];
|
||
}
|
||
|
||
export type DeviceCategory = "access" | "reader" | "camera" | "printer";
|
||
export type Catalog = Record<DeviceCategory, CatalogEntry[]> & {
|
||
/** Driver ids that support LAN discovery. */
|
||
discoverable: string[];
|
||
/** Driver ids that push to the backend (need a backend IP at assign time). */
|
||
pushCapable: string[];
|
||
};
|
||
|
||
export function fetchCatalog(): Promise<Catalog> {
|
||
return apiFetch<Catalog>("/api/setup/catalog");
|
||
}
|
||
|
||
export interface DiscoveredDevice {
|
||
id: string;
|
||
label: string;
|
||
config: Record<string, string | number | boolean>;
|
||
info?: Record<string, string>;
|
||
health: { status: string; detail?: string };
|
||
}
|
||
|
||
/** Scan the LAN for devices a driver can discover. Admin-only. */
|
||
export async function discoverDevices(driverId: string): Promise<DiscoveredDevice[]> {
|
||
const body = await apiFetch<{ devices: DiscoveredDevice[] }>(
|
||
`/api/setup/discover/${driverId}`,
|
||
);
|
||
return body.devices;
|
||
}
|
||
|
||
export type ConfigValue =
|
||
| string
|
||
| number
|
||
| boolean
|
||
| null
|
||
| ConfigValue[]
|
||
| { [k: string]: ConfigValue };
|
||
export type DeviceConfig = Record<string, ConfigValue>;
|
||
|
||
/** Direction a barrier/relay (or a device bound to it) serves. */
|
||
export type Direction = "entry" | "exit" | "both";
|
||
|
||
/** One relay on an access controller: which barrier it opens, in which direction,
|
||
* and (optionally) the input terminal its entry button is wired to. */
|
||
export interface RelaySpec {
|
||
relay: number;
|
||
direction: Direction;
|
||
/** Input terminal of the entry button that fires this relay (transient entry). */
|
||
button?: number;
|
||
/** Anti-double-press (one car = one ticket). PRESENCE: input terminal of a vehicle
|
||
* loop/barrier-feedback signal; a press prints only with a car present + re-arms when
|
||
* it clears. COOLDOWN (fallback, no feedback): suppress repeat presses for N seconds. */
|
||
presenceInput?: number;
|
||
entryCooldownSec?: number;
|
||
}
|
||
|
||
export interface TestResult {
|
||
health: { status: string; detail?: string };
|
||
preconditions: {
|
||
ok: boolean;
|
||
issues: { key: string; message: string; fixable: boolean }[];
|
||
};
|
||
}
|
||
|
||
/** Test a device config (reachability + preconditions) without saving. */
|
||
export function testDevice(driverId: string, config: DeviceConfig): Promise<TestResult> {
|
||
return apiFetch<TestResult>("/api/setup/test", {
|
||
method: "POST",
|
||
body: JSON.stringify({ driverId, config }),
|
||
});
|
||
}
|
||
|
||
export interface BackendIpCandidate {
|
||
ip: string;
|
||
iface: string;
|
||
onDeviceSubnet: boolean;
|
||
}
|
||
|
||
/** Local IPs the device could push to (on-subnet first), for the wizard to
|
||
* pre-fill/override. Matters on multi-NIC hosts. */
|
||
export function fetchBackendIps(
|
||
host: string,
|
||
): Promise<{ candidates: BackendIpCandidate[]; port: number }> {
|
||
return apiFetch(`/api/setup/backend-ips?host=${encodeURIComponent(host)}`);
|
||
}
|
||
|
||
export interface AssignBody {
|
||
category: DeviceCategory;
|
||
driverId: string;
|
||
// Direction/binding lives in config: access → config.relays=[{relay,direction,button?}];
|
||
// reader/camera → config.controllerId + config.relay.
|
||
config: DeviceConfig;
|
||
/** Backend IP the device should push to (overrides auto-pick). */
|
||
backendIp?: string;
|
||
}
|
||
|
||
/** Save + configure the device (preconditions, push setup), then persist. */
|
||
export function assignDevice(body: AssignBody): Promise<AssignResult> {
|
||
return apiFetch("/api/setup/assign", { method: "POST", body: JSON.stringify(body) });
|
||
}
|
||
|
||
/** Re-configure an existing device in place, keeping its id (and so its push
|
||
* URL). Category/driver are fixed at create time, so only config changes. */
|
||
export function editDevice(
|
||
id: string,
|
||
body: Omit<AssignBody, "category" | "driverId">,
|
||
): Promise<AssignResult> {
|
||
return apiFetch(`/api/setup/assign/${id}`, { method: "PATCH", body: JSON.stringify(body) });
|
||
}
|
||
|
||
/** A persisted device assignment (one per instance; machine-only secrets stripped). */
|
||
export interface Assignment {
|
||
id: string;
|
||
category: DeviceCategory;
|
||
driverId: string;
|
||
config: DeviceConfig;
|
||
enabled: boolean;
|
||
createdAt?: string;
|
||
}
|
||
|
||
/** Assign response = the saved assignment plus any residual-risk warnings
|
||
* (e.g. "string protocol could not be disabled — finish in the device web UI"). */
|
||
export interface AssignResult extends Assignment {
|
||
warnings?: string[];
|
||
}
|
||
|
||
export interface SetupState {
|
||
completedAt: string | null;
|
||
assignments: Assignment[];
|
||
}
|
||
|
||
/** Current setup status + all assigned device instances. */
|
||
export function fetchState(): Promise<SetupState> {
|
||
return apiFetch<SetupState>("/api/setup/state");
|
||
}
|
||
|
||
/** Remove one assigned device instance by id. */
|
||
export function unassignDevice(id: string): Promise<void> {
|
||
return apiFetch(`/api/setup/assign/${id}`, { method: "DELETE" });
|
||
}
|
||
|
||
// --- Tariff composer ------------------------------------------------------
|
||
|
||
export interface TariffBlock {
|
||
uptoMin: number | null;
|
||
priceMinorPerIncrement: number;
|
||
}
|
||
// Mirrors @parking/shared. Two shapes: V1 (bare ladder) and V2 (default + windowed
|
||
// cards by time-of-day / dow / date / category, flat or laddered). The discriminant
|
||
// is the presence of `defaultCard`. See wiki/concepts/tariff-time-tiers.md.
|
||
export interface TariffStructureV1 {
|
||
gracePeriodEntryMin: number;
|
||
incrementMin: number;
|
||
blocks: TariffBlock[];
|
||
/** STEPPED ("up-to") total-by-duration table; when non-empty it replaces `blocks`. */
|
||
steps?: TariffStep[];
|
||
dailyCapMinor: number | null;
|
||
lostTicketMinor: number;
|
||
gracePeriodExitMin: number;
|
||
overstay: "reprice";
|
||
}
|
||
export interface TariffWindow {
|
||
dow?: number[];
|
||
dateFrom?: string;
|
||
dateTo?: string;
|
||
fromHour?: string;
|
||
toHour?: string;
|
||
}
|
||
export interface TariffCard {
|
||
name: string;
|
||
priority: number;
|
||
category?: string;
|
||
window?: TariffWindow;
|
||
flatMinor?: number;
|
||
blocks?: TariffBlock[];
|
||
/** STEPPED ("up-to") table (defaultCard only); mutually exclusive with flat/blocks. */
|
||
steps?: TariffStep[];
|
||
dailyCapMinor?: number | null;
|
||
}
|
||
/** One row of a STEPPED ("up-to") tariff: a TOTAL price for a stay up to and including
|
||
* `uptoMin` minutes (cumulative, not marginal). Mirrors @parking/shared TariffStep. */
|
||
export interface TariffStep {
|
||
uptoMin: number;
|
||
totalMinor: number;
|
||
}
|
||
|
||
export interface TariffStructureV2 {
|
||
version: 2;
|
||
tz: string;
|
||
gracePeriodEntryMin: number;
|
||
incrementMin: number;
|
||
lostTicketMinor: number;
|
||
gracePeriodExitMin: number;
|
||
overstay: "reprice";
|
||
defaultCard: TariffCard;
|
||
windowedCards?: TariffCard[];
|
||
}
|
||
export type TariffStructure = TariffStructureV1 | TariffStructureV2;
|
||
|
||
/** True when a structure is the windowed V2 shape (mirrors @parking/shared isTariffV2). */
|
||
export function isTariffV2(t: TariffStructure): t is TariffStructureV2 {
|
||
return (t as TariffStructureV2).defaultCard != null;
|
||
}
|
||
export interface TariffVersion {
|
||
id: string;
|
||
tariffId: string;
|
||
effectiveFrom: string;
|
||
currency: string;
|
||
structure: TariffStructure;
|
||
createdBy?: string | null;
|
||
createdAt?: string;
|
||
}
|
||
export interface TariffState {
|
||
tariffId: string;
|
||
active: TariffVersion | null;
|
||
versions: TariffVersion[];
|
||
}
|
||
|
||
export function fetchTariff(): Promise<TariffState> {
|
||
return apiFetch<TariffState>("/api/tariff");
|
||
}
|
||
|
||
/** Publish a new immutable tariff version (becomes the active rate card). */
|
||
export function publishTariffVersion(body: {
|
||
currency: string;
|
||
structure: TariffStructure;
|
||
effectiveFrom?: string;
|
||
}): Promise<TariffVersion> {
|
||
return apiFetch("/api/tariff/versions", { method: "POST", body: JSON.stringify(body) });
|
||
}
|
||
|
||
// --- Tariff Lab (simulator) -----------------------------------------------
|
||
|
||
export interface SimPayment {
|
||
paidAt: string;
|
||
graceExitMin: number | null;
|
||
}
|
||
export interface SimSessionPricing {
|
||
periodStart: string;
|
||
amountMinor: number;
|
||
overstay: boolean;
|
||
withinGrace: boolean;
|
||
graceExpiresAt: string | null;
|
||
}
|
||
export interface SimulateResult {
|
||
currency: string | null;
|
||
pricing: SimSessionPricing;
|
||
curve: { minutes: number; amountMinor: number }[];
|
||
gracePeriodExitMin: number;
|
||
}
|
||
export interface SimulateBody {
|
||
enteredAt: string;
|
||
asOf: string;
|
||
payments?: SimPayment[];
|
||
category?: string;
|
||
tariffVersionId?: string;
|
||
structure?: TariffStructure;
|
||
currency?: string;
|
||
}
|
||
|
||
/** Price a hypothetical session — pure, no ledger write. See Tariff Lab. */
|
||
export function simulateTariff(body: SimulateBody): Promise<SimulateResult> {
|
||
return apiFetch("/api/tariff/simulate", { method: "POST", body: JSON.stringify(body) });
|
||
}
|
||
|
||
export interface SimSessionLoad {
|
||
identity: string;
|
||
enteredAt: string;
|
||
exitedAt: string | null;
|
||
payments: SimPayment[];
|
||
category: string | null;
|
||
tariffVersionId: string | null;
|
||
}
|
||
|
||
/** Prefill the lab from a real ledger session. */
|
||
export function loadSimSession(identity: string): Promise<SimSessionLoad> {
|
||
return apiFetch(`/api/tariff/simulate/session/${encodeURIComponent(identity)}`);
|
||
}
|
||
|
||
// --- Subscriptions --------------------------------------------------------
|
||
|
||
export interface SubscriptionCredential {
|
||
kind: "rf" | "qr";
|
||
value: string;
|
||
}
|
||
export interface Subscription {
|
||
id: string;
|
||
holderName: string | null;
|
||
contact: string | null;
|
||
/** Recurring price in minor units (e.g. 1000000 = 10,000.00). null = not set. */
|
||
priceMinor: number | null;
|
||
period: "monthly";
|
||
currency: string | null;
|
||
maxConcurrent: number | null;
|
||
validFrom: string | null;
|
||
validTo: string | null;
|
||
status: "active" | "suspended" | "revoked";
|
||
credentials: SubscriptionCredential[];
|
||
plates: string[];
|
||
}
|
||
/** A credential as SENT to the server: a QR value may be omitted/blank → the server
|
||
* auto-generates an unguessable code. RF must carry the card id. */
|
||
export interface SubscriptionCredentialInput {
|
||
kind: "rf" | "qr";
|
||
value?: string;
|
||
}
|
||
export type SubscriptionInput = {
|
||
holderName: string | null;
|
||
contact: string | null;
|
||
priceMinor: number | null;
|
||
period: "monthly";
|
||
currency: string | null;
|
||
maxConcurrent: number | null;
|
||
validFrom: string | null;
|
||
validTo: string | null;
|
||
/** Months paid for: when set (with validFrom), validTo = validFrom + months. */
|
||
months?: number | null;
|
||
status?: Subscription["status"];
|
||
/** How the sale fee was tendered (cash → drawer, card → bank). Used at CREATE when a
|
||
* price is set (the sale appends a signed payment); ignored on update. */
|
||
tender?: "cash" | "card";
|
||
credentials: SubscriptionCredentialInput[];
|
||
plates: string[];
|
||
};
|
||
|
||
/** The create response = the saved subscription + the auto-print outcome, plus the
|
||
* recorded SALE (the signed payment) when a price was collected. */
|
||
export type SubscriptionCreated = Subscription & {
|
||
printed: boolean;
|
||
printedBy?: string;
|
||
printError?: string;
|
||
/** Present when a priced subscription was sold: the signed payment just appended. */
|
||
sale?: { amountMinor: number; currency: string | null; tender: "cash" | "card"; inShift: boolean };
|
||
};
|
||
|
||
export function fetchSubscriptions(): Promise<{ subscriptions: Subscription[] }> {
|
||
return apiFetch("/api/subscriptions");
|
||
}
|
||
export function createSubscription(body: SubscriptionInput): Promise<SubscriptionCreated> {
|
||
return apiFetch("/api/subscriptions", { method: "POST", body: JSON.stringify(body) });
|
||
}
|
||
/** Re-print a subscription's QR card (failed auto-print / lost card). */
|
||
export function printSubscription(id: string): Promise<{ ok: boolean; printedBy: string }> {
|
||
return apiFetch(`/api/subscriptions/${id}/print`, { method: "POST" });
|
||
}
|
||
|
||
// --- Credential capture ("enroll a card" on a chosen reader) ---------------
|
||
|
||
export interface ReaderInfo {
|
||
id: string;
|
||
driverId: string;
|
||
direction: "entry" | "exit" | "both";
|
||
}
|
||
export type CaptureState =
|
||
| { status: "idle" }
|
||
| { status: "armed"; deviceId: string; armedAt: number; expiresAt: number }
|
||
| { status: "captured"; deviceId: string; value: string; capturedAt: number }
|
||
| { status: "expired"; deviceId: string };
|
||
|
||
export function fetchReaders(): Promise<{ readers: ReaderInfo[] }> {
|
||
return apiFetch("/api/subscriptions/readers");
|
||
}
|
||
export function armCapture(deviceId: string): Promise<{ expiresAt: number }> {
|
||
return apiFetch("/api/subscriptions/capture/arm", { method: "POST", body: JSON.stringify({ deviceId }) });
|
||
}
|
||
export function pollCapture(): Promise<CaptureState> {
|
||
return apiFetch("/api/subscriptions/capture");
|
||
}
|
||
export function cancelCapture(): Promise<{ ok: boolean }> {
|
||
return apiFetch("/api/subscriptions/capture/cancel", { method: "POST" });
|
||
}
|
||
export function updateSubscription(id: string, body: SubscriptionInput): Promise<Subscription> {
|
||
return apiFetch(`/api/subscriptions/${id}`, { method: "PUT", body: JSON.stringify(body) });
|
||
}
|
||
export function revokeSubscription(id: string): Promise<Subscription> {
|
||
return apiFetch(`/api/subscriptions/${id}/revoke`, { method: "POST" });
|
||
}
|
||
export function deleteSubscription(id: string): Promise<void> {
|
||
return apiFetch(`/api/subscriptions/${id}`, { method: "DELETE" });
|
||
}
|
||
|
||
// --- Shifts ---------------------------------------------------------------
|
||
|
||
export interface ShiftStatus {
|
||
/** The requesting (logged-in) operator. */
|
||
operator: string;
|
||
/** The SINGLE site-wide open shift (startedAt + whose), or null if none open. */
|
||
open: { startedAt: string; operator: string | null } | null;
|
||
/** True iff the open shift belongs to the requesting operator (can close it). */
|
||
isMine: boolean;
|
||
/** Live physical drawer balance (cash payments + cash movements). */
|
||
drawerMinor: number;
|
||
currency: string | null;
|
||
}
|
||
export interface ShiftReport {
|
||
operator: string;
|
||
startedAt: string;
|
||
endedAt: string;
|
||
cashTotalMinor: number;
|
||
cardTotalMinor: number;
|
||
currency: string | null;
|
||
paymentCount: number;
|
||
// Drawer (carries across shifts).
|
||
openingFloatMinor: number;
|
||
cashAddedMinor: number;
|
||
cashRemovedMinor: number;
|
||
expectedDrawerMinor: number;
|
||
printed: boolean;
|
||
}
|
||
|
||
export function fetchShift(): Promise<ShiftStatus> {
|
||
return apiFetch("/api/shift/current");
|
||
}
|
||
export function openShift(): Promise<{ startedAt: string; openingFloatMinor: number }> {
|
||
return apiFetch("/api/shift/open", { method: "POST" });
|
||
}
|
||
export function closeShift(): Promise<ShiftReport> {
|
||
return apiFetch("/api/shift/close", { method: "POST" });
|
||
}
|
||
|
||
/** Admin loads/removes physical drawer cash. amountMinor signed: + load, − remove. */
|
||
export function recordCashMovement(
|
||
amountMinor: number,
|
||
reason: string,
|
||
): Promise<{ amountMinor: number; balanceMinor: number }> {
|
||
return apiFetch("/api/cash-movement", {
|
||
method: "POST",
|
||
body: JSON.stringify({ amountMinor, reason }),
|
||
});
|
||
}
|
||
|
||
/** A completed shift (reconstructed from its signed Z-report). */
|
||
export interface ShiftSummary {
|
||
id: string;
|
||
index: number;
|
||
operator: string;
|
||
startedAt: string;
|
||
endedAt: string;
|
||
cashTotalMinor: number;
|
||
cardTotalMinor: number;
|
||
currency: string | null;
|
||
paymentCount: number;
|
||
openingFloatMinor: number;
|
||
cashAddedMinor: number;
|
||
cashRemovedMinor: number;
|
||
expectedDrawerMinor: number;
|
||
}
|
||
|
||
/** Completed shift history. The server scopes by permission: operators get their
|
||
* own shifts only (filter args ignored); admins (shift:cash) get all, optionally
|
||
* filtered by operator + a from/to window over the shift start. `scope` echoes
|
||
* which the server applied, so the UI can show/hide the filter. */
|
||
export function fetchShifts(params: { operator?: string; from?: string; to?: string } = {}): Promise<{
|
||
shifts: ShiftSummary[];
|
||
scope: "all" | "self";
|
||
}> {
|
||
const qs = new URLSearchParams();
|
||
if (params.operator) qs.set("operator", params.operator);
|
||
if (params.from) qs.set("from", params.from);
|
||
if (params.to) qs.set("to", params.to);
|
||
const q = qs.toString();
|
||
return apiFetch(`/api/shifts${q ? `?${q}` : ""}`);
|
||
}
|
||
|
||
// --- Site config / occupancy ----------------------------------------------
|
||
|
||
export interface Occupancy {
|
||
count: number;
|
||
capacity: number | null;
|
||
free: number | null;
|
||
full: boolean;
|
||
}
|
||
|
||
/** Capacity + optional park metadata (all nullable). Mirrors site_config. */
|
||
export interface SiteConfig {
|
||
capacity: number | null;
|
||
/** Default for the booth "print exit ticket" checkbox (booth-geography knob). */
|
||
exitVoucherDefault: boolean;
|
||
/** Site default monthly subscription price (minor units); pre-fills the form. */
|
||
subscriptionMonthlyPriceMinor: number | null;
|
||
parkName: string | null;
|
||
operatorName: string | null;
|
||
/** NIUS — Albanian tax/identification number. */
|
||
nius: string | null;
|
||
address: string | null;
|
||
phone: string | null;
|
||
email: string | null;
|
||
/** IANA timezone for tariff wall-clock windows (e.g. "Europe/Tirane"). Copied into
|
||
* each published tariff version so its windows are frozen. */
|
||
timezone: string | null;
|
||
/** Default vehicle/customer category frozen onto each transient entry (V2 pricing). */
|
||
defaultVehicleCategory: string | null;
|
||
}
|
||
|
||
export function fetchOccupancy(): Promise<Occupancy> {
|
||
return apiFetch("/api/occupancy");
|
||
}
|
||
|
||
// --- Device status (the booth footer) -------------------------------------
|
||
|
||
/** Live status of one configured device — mirrors the server's DeviceStatusEvent.
|
||
* Every enabled device is polled (printers via rich readStatus, the rest via
|
||
* healthCheck) and flattened to one traffic-light. Pushed over the WS; the REST
|
||
* snapshot below is the initial load / fallback. */
|
||
export interface DeviceStatus {
|
||
deviceId: string;
|
||
driverId: string;
|
||
category: "access" | "reader" | "camera" | "printer" | "vision";
|
||
/** Role/direction token for the footer label (NOT the vendor) — the client
|
||
* localises it next to the category, e.g. "Lexuesi hyrje", "Printer kabina". */
|
||
roleKind: "entry" | "exit" | "both" | "mixed" | "lane" | "booth" | null;
|
||
state: "ready" | "degraded" | "offline";
|
||
detail?: string;
|
||
checkedAt: string;
|
||
}
|
||
|
||
export function fetchDeviceStatus(): Promise<{ devices: DeviceStatus[] }> {
|
||
return apiFetch("/api/devices/status");
|
||
}
|
||
|
||
// --- Ledger events (the signed audit trail; read-only) --------------------
|
||
|
||
/** A persisted ledger row. Re-exported from shared so UI code has one source of
|
||
* truth for the event shape (the same type the WS pushes). */
|
||
export type { LedgerEvent, LogLevel, LogSource } from "@parking/shared";
|
||
export type { AppLogRecord };
|
||
|
||
/** Recent ledger events, newest first (default 100, max 1000). Used for the
|
||
* booth feed's initial load; live updates then arrive over the WS. `since` (ISO)
|
||
* scopes to events at/after that instant — the booth passes the current shift's
|
||
* start so the feed shows ONLY this shift's activity. */
|
||
export function fetchEvents(
|
||
limit = 100,
|
||
since?: string,
|
||
): Promise<{ events: import("@parking/shared").LedgerEvent[] }> {
|
||
const qs = new URLSearchParams({ limit: String(limit) });
|
||
if (since) qs.set("since", since);
|
||
return apiFetch(`/api/events?${qs.toString()}`);
|
||
}
|
||
|
||
// --- Booth: session lookup, payment, exit ---------------------------------
|
||
|
||
/** One-read session view for the booth pay/exit modal (mirrors server SessionLookup). */
|
||
export interface SessionLookup {
|
||
identity: string;
|
||
found: boolean;
|
||
open: boolean;
|
||
enteredAt: string | null;
|
||
exitedAt: string | null;
|
||
paidAt: string | null;
|
||
amountMinor: number | null;
|
||
currency: string | null;
|
||
withinGrace: boolean;
|
||
graceExpiresAt: string | null;
|
||
/** OVERSTAY: paid transient, walk-back grace expired, no exit — a new period began;
|
||
* owes a fresh top-up (amountMinor); cannot exit for free. */
|
||
overstay: boolean;
|
||
/** A subscription occurrence (prepaid — no pay flow; barrier-open assist only). */
|
||
subscription: boolean;
|
||
subscriptionId: string | null;
|
||
subscriptionHolder: string | null;
|
||
/** Advisory licence plate recognized for this session (ANPR). Null when none. */
|
||
plate: string | null;
|
||
}
|
||
|
||
/** Look up a ticket/session for the booth modal (entry/exit, paid, amount owed). */
|
||
export function lookupSession(identity: string): Promise<SessionLookup> {
|
||
return apiFetch(`/api/session/${encodeURIComponent(identity)}`);
|
||
}
|
||
|
||
/** One row in the booth Active Sessions list (mirrors server ActiveSession). */
|
||
export interface ActiveSession {
|
||
identity: string;
|
||
source: string | null;
|
||
enteredAt: string;
|
||
exitedAt: string | null;
|
||
open: boolean;
|
||
paidAt: string | null;
|
||
amountMinor: number | null;
|
||
currency: string | null;
|
||
withinGrace: boolean;
|
||
graceExpiresAt: string | null;
|
||
/** OVERSTAY: paid transient whose walk-back grace lapsed with no signed exit — a new
|
||
* period began (re-parked) or the car is faulty/abandoned. Owes a fresh top-up;
|
||
* flagged so the operator reconciles, never a free exit. */
|
||
overstay: boolean;
|
||
/** A subscription occurrence (prepaid — no pay flow; barrier-open assist only). */
|
||
subscription: boolean;
|
||
subscriptionId: string | null;
|
||
subscriptionHolder: string | null;
|
||
/** Advisory licence plate recognized for this session (ANPR). Null when none. */
|
||
plate: string | null;
|
||
}
|
||
|
||
/** Active sessions: still-inside OR exited-but-within-grace (barrier unconfirmed). */
|
||
export function fetchActiveSessions(): Promise<{ sessions: ActiveSession[] }> {
|
||
return apiFetch("/api/sessions/active");
|
||
}
|
||
|
||
/** Human-intervention barrier re-open for a paid active session (damaged ticket /
|
||
* phantom re-close). Signs an audited anomaly; never a 2nd exit. */
|
||
export function reopenBarrier(identity: string): Promise<{ ok: true; opened: boolean; reason?: string }> {
|
||
return apiFetch("/api/barrier/reopen", { method: "POST", body: JSON.stringify({ identity }) });
|
||
}
|
||
|
||
/** Take payment for a session → signed payment event. `overrideMinor` sets an
|
||
* operator amount (lost ticket / dispute). */
|
||
export function paySession(
|
||
identity: string,
|
||
tender: "cash" | "card",
|
||
overrideMinor?: number,
|
||
): Promise<{ amountMinor: number; currency: string }> {
|
||
return apiFetch("/api/pay", {
|
||
method: "POST",
|
||
body: JSON.stringify({ identity, tender, ...(overrideMinor != null ? { overrideMinor } : {}) }),
|
||
});
|
||
}
|
||
|
||
/** Booth-driven exit result. `opened:false` = exit recorded but barrier didn't
|
||
* open (payment stands; operator opens manually). */
|
||
export type BoothExitResult = { ok: true; opened: boolean; reason?: string };
|
||
|
||
/** Validate + open the barrier for a session from the booth (when near the exit). */
|
||
export function boothExit(identity: string): Promise<BoothExitResult> {
|
||
return apiFetch("/api/exit", { method: "POST", body: JSON.stringify({ identity }) });
|
||
}
|
||
|
||
/** Print an exit voucher (paid ticket id reprinted as a barcode) + payment detail,
|
||
* for self-exit at a distant exit. Requires the session to be paid. */
|
||
export function printVoucher(identity: string): Promise<{ ok: boolean; printedBy: string }> {
|
||
return apiFetch("/api/voucher", { method: "POST", body: JSON.stringify({ identity }) });
|
||
}
|
||
|
||
/** Print a standalone PAYMENT RECEIPT (entry/paid/duration/amount, no barcode).
|
||
* Auto-printed after a payment when no voucher is issued; also the "reprint"
|
||
* action. Requires the session to be paid. */
|
||
export function printReceipt(identity: string): Promise<{ ok: boolean; printedBy: string }> {
|
||
return apiFetch("/api/receipt", { method: "POST", body: JSON.stringify({ identity }) });
|
||
}
|
||
|
||
// --- Snapshots (entry/exit evidence images) -------------------------------
|
||
|
||
export interface SnapshotMeta {
|
||
id: string;
|
||
direction: "entry" | "exit" | null;
|
||
deviceId: string;
|
||
identity: string;
|
||
contentType: string;
|
||
capturedAt: string;
|
||
}
|
||
|
||
/** A capture that was ATTEMPTED but failed (camera offline, config) — surfaced so a
|
||
* missing image isn't a silent gap. From snapshot telemetry, not the image store. */
|
||
export interface SnapshotFailure {
|
||
direction: "entry" | "exit" | null;
|
||
deviceId: string;
|
||
error: string;
|
||
occurredAt: string;
|
||
}
|
||
|
||
/** A licence plate recognized for this session by the ANPR-on-snapshot path (advisory
|
||
* record — see opencv-anpr-service.md). `snapshotId` links to the image it was read from. */
|
||
export interface PlateRead {
|
||
plate: string;
|
||
confidence: number | null;
|
||
region: string | null;
|
||
direction: "entry" | "exit" | null;
|
||
snapshotId: string | null;
|
||
at: string;
|
||
}
|
||
|
||
/** Snapshot metadata for a session identity (newest first) PLUS failed capture
|
||
* attempts PLUS any recognized plates. Image bytes are at `/api/snapshots/:id`. */
|
||
export function fetchSnapshots(
|
||
identity: string,
|
||
): Promise<{ snapshots: SnapshotMeta[]; failures?: SnapshotFailure[]; plates?: PlateRead[] }> {
|
||
return apiFetch(`/api/snapshots/by-identity/${encodeURIComponent(identity)}`);
|
||
}
|
||
|
||
/** URL for a snapshot's image bytes (cookie-authed; usable as <img src>). */
|
||
export function snapshotImageUrl(id: string): string {
|
||
return `/api/snapshots/${encodeURIComponent(id)}`;
|
||
}
|
||
export function fetchSiteConfig(): Promise<SiteConfig> {
|
||
return apiFetch("/api/site-config");
|
||
}
|
||
/** PUT a partial config — only the fields supplied are changed. */
|
||
export function saveSiteConfig(patch: Partial<SiteConfig>): Promise<SiteConfig> {
|
||
return apiFetch("/api/site-config", { method: "PUT", body: JSON.stringify(patch) });
|
||
}
|
||
export function setCapacity(capacity: number | null): Promise<SiteConfig> {
|
||
return saveSiteConfig({ capacity });
|
||
}
|