feat: subscription v2 — quantity pricing, plan timeframes (tariff bridge), reserved spots

Three subscriber enhancements driven by real scenarios (migration 0011, all
additive columns — backward-compatible).

1. QUANTITY. One subscription covers N cars (a family pays once for two). Sale
   amount = span price × quantity; maxConcurrent defaults to the quantity so all
   N cars can be inside. Quantity rides in the payment payload.

2. PLAN TIMEFRAMES → TARIFF BRIDGE. A plan may restrict WHEN a subscriber may
   park (e.g. weekday 20:00→08:00, weekend all-day). A scan outside the window is
   NOT refused — the out-of-window minutes are charged at the normal TRANSIENT
   tariff (the subscriber is a transient for that time):
     - early entry: arrival → window-open, DEFERRED (signed as windowOwedMinor on
       the vehicle_entry payload), collected at exit;
     - late exit: window-close → departure, and exit is GATED
       (sub.refused.unpaidWindow) until paid at the booth.
   Pure, tz-aware outOfWindowGap in @parking/shared (12 unit tests); pricing
   reuses computeFee + the active tariff version
   (apps/server/src/subscription-window.ts). The exit refusal is a host-ONLINE
   business gate — the fail-open rule still governs the offline path.

3. RESERVED SPOTS. Site toggle reserve_subscriber_spots: occupancy holds
   max(0, quantity − itsCarsInside) per active subscription, so transients see
   "full" sooner; effectiveFree = capacity − count − reserved. Subscribers are
   never gated by full.

UI: quantity field + ×N quote (SubscriptionManager); timeframes editor
(SubscriptionPlansManager); reserve checkbox (SiteSettings); booth pay modal
shows an "OUT-OF-WINDOW" charge and takes payment to clear the exit gate.

Verified on a copy of the live DB: qty 2 = 2× price; a night-plan 19:30 entry →
30min/15,000 ALL owed, stamped + paid → gate clears, chain verifies; the reserve
toggle holds a qty-2 sub's 2 spots. Build+lint 12/12; 80 shared tests pass.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
2026-06-20 18:22:50 +02:00
parent fd4608a8f1
commit 53e1e7b25c
23 changed files with 929 additions and 40 deletions
+32 -16
View File
@@ -58,10 +58,18 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
// exit. A normal within-grace paid session is NOT payable (it's settled). See
// booth-exit-flow.md / reopenBarrier server guard.
const isOverstay = s?.overstay === true;
// A subscription is prepaid: never charged. The only booth action is an audited
// barrier open to ASSIST (faulty exit reader / lost card). Transient pay path is off.
// Allow pay for an unpaid session OR an overstay (new-period top-up) one.
const canPay = !!(shiftReady && s?.found && s.open && (!alreadyPaid || isOverstay) && !isSubscription);
// A subscription is normally prepaid (never charged). EXCEPTION: a time-window plan can
// owe an out-of-window TARIFF-BRIDGE charge (early entry / late exit) — lookup() returns
// it as s.amountMinor, and exit is GATED until it's paid. So a subscription IS payable
// when it has an amount due. Otherwise the only action is an audited assist-open.
const subWindowDue = !!(isSubscription && (s?.amountMinor ?? 0) > 0);
// Allow pay for an unpaid transient, an overstay top-up, or a subscriber window charge.
const canPay = !!(
shiftReady &&
s?.found &&
s.open &&
((!alreadyPaid && !isSubscription) || isOverstay || subWindowDue)
);
async function handleOpenBarrier() {
if (!s) return;
@@ -252,25 +260,33 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
/>
</div>
{/* Total — a subscription is prepaid (no amount); show a badge. For an
overstay the amount is the TOP-UP delta, not the whole stay. */}
{/* Total — a subscription is prepaid (no amount) UNLESS it owes an
out-of-window window charge; then show that amount. For an overstay the
amount is the TOP-UP delta, not the whole stay. */}
<div className="flex items-end justify-between rounded-term bg-term-panel-2 px-3 py-2">
<span className="text-[11px] uppercase tracking-wider text-term-muted">
{isSubscription ? t("pay.plan") : isOverstay ? t("pay.topUp") : t("pay.total")}
{subWindowDue ? t("pay.windowCharge") : isSubscription ? t("pay.plan") : isOverstay ? t("pay.topUp") : t("pay.total")}
</span>
<span className="text-3xl font-bold text-term-cyan">
{isSubscription
? t("pay.prepaid")
: s.amountMinor != null && s.currency
? formatMoney(s.amountMinor, s.currency)
: alreadyPaid
? t("booth.badgePaid")
: t("pay.noTariff")}
{subWindowDue && s.amountMinor != null && s.currency
? formatMoney(s.amountMinor, s.currency)
: isSubscription
? t("pay.prepaid")
: s.amountMinor != null && s.currency
? formatMoney(s.amountMinor, s.currency)
: alreadyPaid
? t("booth.badgePaid")
: t("pay.noTariff")}
</span>
</div>
{/* For a subscription, explain the only available action. */}
{isSubscription && (
{/* 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 ? (
<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 && (
<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>
+15
View File
@@ -25,6 +25,7 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
const [capInput, setCapInput] = useState("");
const [meta, setMeta] = useState<Record<string, string>>({});
const [exitVoucherDefault, setExitVoucherDefault] = useState(false);
const [reserveSubs, setReserveSubs] = useState(false);
const [msg, setMsg] = useState<string | null>(null);
function reload() {
@@ -36,6 +37,7 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
.then((c) => {
setCapInput(c.capacity == null ? "" : String(c.capacity));
setExitVoucherDefault(c.exitVoucherDefault);
setReserveSubs(c.reserveSubscriberSpots);
const m: Record<string, string> = {};
for (const { key } of META_FIELDS) m[key] = c[key] == null ? "" : String(c[key]);
setMeta(m);
@@ -49,6 +51,7 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
const patch: Partial<SiteConfig> = {
capacity: raw === "" ? null : Math.round(Number(raw)),
exitVoucherDefault,
reserveSubscriberSpots: reserveSubs,
};
// Send each metadata field; "" → null is applied server-side.
for (const { key } of META_FIELDS) (patch as Record<string, string | null>)[key] = meta[key] ?? "";
@@ -97,6 +100,18 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
{t("site.printExitDefault")}
<span className="hint">{t("site.printExitHint")}</span>
</label>
<label className="flex items-start gap-2 text-[12px] text-term-text">
<input
type="checkbox"
className="mt-0.5 accent-term-amber"
checked={reserveSubs}
onChange={(e) => setReserveSubs(e.target.checked)}
/>
<span>
{t("site.reserveSubs")}
<span className="hint block">{t("site.reserveSubsHint")}</span>
</span>
</label>
<div className="border-t border-term-border pt-3 text-[11px] uppercase tracking-wider text-term-muted">
{t("site.parkDetails")}
</div>
+24 -3
View File
@@ -33,6 +33,7 @@ interface FormState {
holderName: string;
contact: string;
planId: string; // selected plan (sells/prices it); "" = comp (no charge)
quantity: string; // cars covered by this one subscription (price ×N)
tender: "cash" | "card"; // how the sale fee is collected (cash → drawer, card → bank)
carBound: boolean; // false = unbound (maxConcurrent null)
maxConcurrent: string;
@@ -52,6 +53,7 @@ function emptyForm(): FormState {
holderName: "",
contact: "",
planId: "",
quantity: "1",
tender: "cash",
carBound: true,
maxConcurrent: "1",
@@ -66,6 +68,7 @@ function formFrom(s: Subscription): FormState {
holderName: s.holderName ?? "",
contact: s.contact ?? "",
planId: s.planId ?? "", // edit doesn't re-sell; plan shown read-only
quantity: String(s.quantity ?? 1),
tender: "cash", // edit doesn't re-collect money; tender only matters on a new sale
carBound: s.maxConcurrent != null,
maxConcurrent: s.maxConcurrent != null ? String(s.maxConcurrent) : "1",
@@ -97,6 +100,7 @@ function toInput(f: FormState, isNew: boolean): SubscriptionInput {
// A SALE: send the chosen plan; price is looked up server-side. On edit we never
// re-sell, so no planId is sent (price/plan stay frozen).
planId: planSelected ? f.planId.trim() : null,
quantity: Math.max(1, Math.round(Number(f.quantity) || 1)),
tender: f.tender,
maxConcurrent: f.carBound ? Math.max(1, Math.round(Number(f.maxConcurrent) || 1)) : null,
validFrom: dateToISO(f.validFrom),
@@ -165,10 +169,11 @@ export function SubscriptionManager() {
setQuote(null);
return;
}
const quantity = Math.max(1, Math.round(Number(form.quantity) || 1));
let cancelled = false;
setQuoting(true);
const h = setTimeout(() => {
quoteSubscription({ planId: form.planId.trim(), validFrom: from, validTo: to })
quoteSubscription({ planId: form.planId.trim(), validFrom: from, validTo: to, quantity })
.then((q) => !cancelled && setQuote(q))
.catch(() => !cancelled && setQuote(null))
.finally(() => !cancelled && setQuoting(false));
@@ -177,7 +182,7 @@ export function SubscriptionManager() {
cancelled = true;
clearTimeout(h);
};
}, [editing, form.planId, form.validFrom, form.validTo]);
}, [editing, form.planId, form.validFrom, form.validTo, form.quantity]);
function startNew() {
setForm(emptyForm());
@@ -378,6 +383,22 @@ export function SubscriptionManager() {
<span className="text-[13px] text-term-text">{form.planId || t("subs.noPrice")}</span>
</>
)}
{/* Quantity — cars covered by this ONE subscription (a family pays once for
N cars). Price ×N; maxConcurrent below pre-fills to it. */}
{form.planId.trim() !== "" && editing === "new" && (
<>
<label className="label">{t("subs.quantity")}</label>
<span className="flex flex-wrap items-center gap-2">
<input
className="input w-16"
value={form.quantity}
inputMode="numeric"
onChange={(e) => setForm((f) => ({ ...f, quantity: e.target.value, maxConcurrent: e.target.value }))}
/>
<span className="text-[12px] text-term-muted">{t("subs.quantityHint")}</span>
</span>
</>
)}
{/* Tender — only relevant when selling a plan (a SALE). The sale appends a
signed payment so the money shows in the feed/drawer/Z-report. */}
{form.planId.trim() !== "" && editing === "new" && (
@@ -434,7 +455,7 @@ export function SubscriptionManager() {
unit: t(PERIOD_KEY[quote.period]),
amount: (quote.amountMinor / 100).toLocaleString(),
currency: quote.currency,
})
}) + (quote.quantity && quote.quantity > 1 ? ` (×${quote.quantity})` : "")
: t("subs.quotePrompt")}
</span>
)}
+95 -1
View File
@@ -29,10 +29,40 @@ interface PlanForm {
period: SubscriptionPeriod;
priceMajor: string;
currency: string;
// Timeframes (tariff bridge). Off → 24/7. On → weekday window (enter-after / exit-before
// as HH:MM) + weekend all-day toggle + grace minutes.
restrictTimes: boolean;
wdFrom: string; // weekday window opens (HH:MM) — when the subscriber may enter
wdTo: string; // weekday window closes (HH:MM) — by when they should exit
weekendAllDay: boolean;
graceMin: string;
}
function emptyForm(): PlanForm {
return { planId: "", name: "", period: "month", priceMajor: "", currency: DEFAULT_CURRENCY };
return {
planId: "",
name: "",
period: "month",
priceMajor: "",
currency: DEFAULT_CURRENCY,
restrictTimes: false,
wdFrom: "20:00",
wdTo: "08:00",
weekendAllDay: true,
graceMin: "0",
};
}
/** "HH:MM" → minutes-of-day, or null if blank/invalid. */
function hhmmToMin(s: string): number | null {
const m = /^(\d{1,2}):(\d{2})$/.exec(s.trim());
if (!m) return null;
const min = Number(m[1]) * 60 + Number(m[2]);
return min >= 0 && min <= 1439 ? min : null;
}
/** minutes-of-day → "HH:MM". */
function minToHHMM(min: number): string {
return `${String(Math.floor(min / 60)).padStart(2, "0")}:${String(min % 60).padStart(2, "0")}`;
}
export function SubscriptionPlansManager() {
@@ -55,6 +85,20 @@ export function SubscriptionPlansManager() {
const major = Number(form.priceMajor);
if (!form.name.trim()) return setMsg({ kind: "err", text: t("plans.needName") });
if (!Number.isFinite(major) || major <= 0) return setMsg({ kind: "err", text: t("plans.needPrice") });
// Build the timeframes blob from the form (null = 24/7). The weekday window is the
// allowed interval [wdFrom, wdTo) (wraps midnight for a night plan); weekend is all-day
// or inherits the weekday window. The server stamps the site tz.
let timeframes = null as Parameters<typeof createSubscriptionPlan>[0]["timeframes"];
if (form.restrictTimes) {
const from = hhmmToMin(form.wdFrom);
const to = hhmmToMin(form.wdTo);
if (from == null || to == null) return setMsg({ kind: "err", text: t("plans.needWindow") });
timeframes = {
weekday: { fromMin: from, toMin: to },
weekend: form.weekendAllDay ? { allDay: true } : { fromMin: from, toMin: to },
graceMin: Math.max(0, Math.round(Number(form.graceMin) || 0)),
};
}
try {
await createSubscriptionPlan({
planId: form.planId.trim() || undefined,
@@ -62,6 +106,7 @@ export function SubscriptionPlansManager() {
period: form.period,
pricePerPeriodMinor: Math.round(major * 100),
currency: form.currency.trim() || DEFAULT_CURRENCY,
timeframes,
});
setForm(null);
reload();
@@ -80,12 +125,19 @@ export function SubscriptionPlansManager() {
/** Publish a new version of an existing plan (pre-fills its identity + last values). */
function newVersionOf(p: SubscriptionPlan) {
const tf = p.timeframes ?? null;
const wd = tf?.weekday;
setForm({
planId: p.planId,
name: p.name,
period: p.period,
priceMajor: String(p.pricePerPeriodMinor / 100),
currency: p.currency,
restrictTimes: tf != null,
wdFrom: wd?.fromMin != null ? minToHHMM(wd.fromMin) : "20:00",
wdTo: wd?.toMin != null ? minToHHMM(wd.toMin) : "08:00",
weekendAllDay: tf?.weekend?.allDay ?? true,
graceMin: String(tf?.graceMin ?? 0),
});
setMsg(null);
}
@@ -179,6 +231,48 @@ export function SubscriptionPlansManager() {
<span className="text-[12px] text-term-muted">/ {t(PERIOD_KEY[form.period])}</span>
</span>
</div>
{/* Timeframes (tariff bridge): restrict WHEN a subscriber may park. Outside the
window they're charged the transient tariff for the gap. Off = 24/7. */}
<div className="mt-3 border-t border-term-border pt-3">
<label className="flex items-center gap-2 text-[12px] text-term-text">
<input
type="checkbox"
className="accent-term-amber"
checked={form.restrictTimes}
onChange={(e) => setForm((f) => f && { ...f, restrictTimes: e.target.checked })}
/>
{t("plans.restrictTimes")}
</label>
{form.restrictTimes && (
<div className="mt-2 grid grid-cols-[max-content_1fr] items-center gap-x-4 gap-y-2">
<label className="label">{t("plans.weekdayWindow")}</label>
<span className="flex flex-wrap items-center gap-2 text-[12px] text-term-muted">
{t("plans.enterAfter")}
<input type="time" className="input w-28" value={form.wdFrom} onChange={(e) => setForm((f) => f && { ...f, wdFrom: e.target.value })} />
{t("plans.exitBefore")}
<input type="time" className="input w-28" value={form.wdTo} onChange={(e) => setForm((f) => f && { ...f, wdTo: e.target.value })} />
</span>
<label className="label">{t("plans.weekend")}</label>
<label className="flex items-center gap-2 text-[12px] text-term-text">
<input
type="checkbox"
className="accent-term-amber"
checked={form.weekendAllDay}
onChange={(e) => setForm((f) => f && { ...f, weekendAllDay: e.target.checked })}
/>
{t("plans.weekendAllDay")}
</label>
<label className="label">{t("plans.grace")}</label>
<span className="flex items-center gap-2">
<input className="input w-16" value={form.graceMin} inputMode="numeric" onChange={(e) => setForm((f) => f && { ...f, graceMin: e.target.value })} />
<span className="text-[12px] text-term-muted">{t("plans.graceHint")}</span>
</span>
</div>
)}
<p className="mt-1.5 text-[11px] text-term-muted">{t("plans.timeframesHint")}</p>
</div>
{form.planId && <p className="mt-2 text-[11px] text-term-amber">{t("plans.newVersionHint")}</p>}
<div className="mt-4 flex justify-end gap-2">
<button type="button" className="btn btn-sm" onClick={() => setForm(null)}>{t("subs.cancel")}</button>
+26 -1
View File
@@ -494,6 +494,20 @@ export interface SubscriptionCredential {
}
export type SubscriptionPeriod = "day" | "week" | "month";
/** A subscriber's allowed parking window for a day-type (minutes-from-local-midnight).
* A scan outside the window is charged the transient tariff for the gap. */
export interface DayWindow {
allDay?: boolean;
fromMin?: number;
toMin?: number;
}
export interface PlanTimeframes {
weekday?: DayWindow;
weekend?: DayWindow;
graceMin?: number;
tz?: string;
}
/** A subscription PLAN version — admin-composed, versioned config the operator sells
* from (so they never type a price). */
export interface SubscriptionPlan {
@@ -505,6 +519,8 @@ export interface SubscriptionPlan {
currency: string;
effectiveFrom: string;
active: boolean;
/** Allowed-time windows (tariff bridge); null/absent = 24/7, no time charge. */
timeframes?: PlanTimeframes | null;
}
export interface Subscription {
@@ -518,6 +534,8 @@ export interface Subscription {
/** Which plan + immutable version priced this sale (null for legacy/comp). */
planId: string | null;
planVersionId: string | null;
/** Cars covered by this one subscription (price was ×N). Default 1. */
quantity: number;
maxConcurrent: number | null;
validFrom: string | null;
validTo: string | null;
@@ -540,6 +558,8 @@ export type SubscriptionInput = {
/** Coverage window. Priced sale: validFrom defaults to now, validTo required. */
validFrom: string | null;
validTo: string | null;
/** Cars covered (price ×N). Default 1. */
quantity?: number;
maxConcurrent: number | null;
status?: Subscription["status"];
/** How the sale fee was tendered (cash → drawer, card → bank). Used at CREATE when a
@@ -549,12 +569,13 @@ export type SubscriptionInput = {
plates: string[];
};
/** A server-computed quote: periods (ceil) × per-period price for a span. */
/** A server-computed quote: periods (ceil) × per-period price × quantity for a span. */
export interface SubscriptionQuote {
periods: number;
amountMinor: number;
currency: string;
period: SubscriptionPeriod;
quantity?: number;
plan: SubscriptionPlan;
}
@@ -589,6 +610,7 @@ export function createSubscriptionPlan(body: {
pricePerPeriodMinor: number;
currency: string;
effectiveFrom?: string;
timeframes?: PlanTimeframes | null;
}): Promise<SubscriptionPlan> {
return apiFetch("/api/subscription-plans", { method: "POST", body: JSON.stringify(body) });
}
@@ -600,6 +622,7 @@ export function quoteSubscription(body: {
planId: string;
validFrom: string | null;
validTo: string;
quantity?: number;
}): Promise<SubscriptionQuote> {
return apiFetch("/api/subscriptions/quote", { method: "POST", body: JSON.stringify(body) });
}
@@ -779,6 +802,8 @@ export interface SiteConfig {
exitVoucherDefault: boolean;
/** Site default monthly subscription price (minor units); pre-fills the form. */
subscriptionMonthlyPriceMinor: number | null;
/** Reserve a spot for each active subscriber's car(s) in the occupancy/full gate. */
reserveSubscriberSpots: boolean;
parkName: string | null;
operatorName: string | null;
/** NIUS — Albanian tax/identification number. */
+16
View File
@@ -387,6 +387,8 @@ export const en: Catalog = {
perWeek: "week",
perMonth: "month",
plan: "Plan",
quantity: "Cars",
quantityHint: "cars covered by this subscription (price ×N)",
planNone: "— comp / no charge —",
planNoneAvail: "No plans defined — an admin must create one first.",
quoting: "pricing…",
@@ -466,6 +468,16 @@ export const en: Catalog = {
needPrice: "Enter a price greater than zero.",
saved: "Plan saved.",
confirmRetire: "Retire the plan “{{name}}”? It will no longer be sellable (history is kept).",
needWindow: "Enter valid window times (HH:MM).",
restrictTimes: "Restrict parking times (charge transient tariff outside the window)",
weekdayWindow: "Weekday",
enterAfter: "enter after",
exitBefore: "· exit before",
weekend: "Weekend",
weekendAllDay: "all day (no restriction)",
grace: "Grace",
graceHint: "minutes tolerance around the window edges",
timeframesHint: "A scan outside the allowed window is charged the normal transient tariff for the out-of-window minutes (early entry is deferred to exit; late exit is gated until paid).",
},
site: {
occupancy: "Occupancy:",
@@ -476,6 +488,8 @@ export const en: Catalog = {
capacityPlaceholder: "e.g. 120",
printExitDefault: "Print exit ticket by default",
printExitHint: "(booth far from exit → customer self-exits with a voucher)",
reserveSubs: "Reserve subscriber spots",
reserveSubsHint: "Hold a spot for each active subscriber's car(s) even when they're not parked — transients see 'full' sooner. Off: only cars inside count (handle overflow by valet).",
parkDetails: "Park details (optional — shown on tickets/receipts)",
save: "Save",
saved: "Saved.",
@@ -659,6 +673,8 @@ export const en: Catalog = {
plan: "Plan",
prepaid: "PREPAID",
subAssistHint: "Prepaid subscription. Open the barrier to assist the exit (faulty reader / missing card). No payment.",
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.",
subBarrierOpened: "Barrier opened for the subscriber (intervention recorded).",
voucherPrinted: "Exit voucher printed on {{printer}}. Customer self-exits at the exit.",
// payment receipt (transparency slip)
+16
View File
@@ -398,6 +398,8 @@ export const sq = {
perWeek: "javë",
perMonth: "muaj",
plan: "Plani",
quantity: "Makina",
quantityHint: "makina të mbuluara nga ky abonim (çmimi ×N)",
planNone: "— pa pagesë / falas —",
planNoneAvail: "Asnjë plan i përcaktuar — admini duhet të krijojë një të parin.",
quoting: "duke llogaritur…",
@@ -477,6 +479,16 @@ export const sq = {
needPrice: "Shkruaj një çmim më të madh se zero.",
saved: "Plani u ruajt.",
confirmRetire: "Të tërhiqet plani “{{name}}”? Nuk do të jetë më i shitshëm (historiku ruhet).",
needWindow: "Shkruaj orare të vlefshme (HH:MM).",
restrictTimes: "Kufizo oraret e parkimit (tarifë kalimtare jashtë intervalit)",
weekdayWindow: "Ditë pune",
enterAfter: "hyrje pas",
exitBefore: "· dalje para",
weekend: "Fundjavë",
weekendAllDay: "gjithë ditën (pa kufizim)",
grace: "Tolerancë",
graceHint: "minuta tolerancë rreth kufijve të intervalit",
timeframesHint: "Një skanim jashtë intervalit të lejuar tarifohet me tarifën normale kalimtare për minutat jashtë intervalit (hyrja e hershme shtyhet në dalje; dalja e vonuar bllokohet derisa paguhet).",
},
site: {
occupancy: "Prania:",
@@ -487,6 +499,8 @@ export const sq = {
capacityPlaceholder: "p.sh. 120",
printExitDefault: "Printo biletën e daljes si parazgjedhje",
printExitHint: "(klienti skanon biletën në dalje)",
reserveSubs: "Rezervo vendet e abonentëve",
reserveSubsHint: "Mban një vend për makinat e çdo abonenti aktiv edhe kur nuk janë të parkuar — kalimtarët e shohin 'plot' më shpejt. Joaktiv: numërohen vetëm makinat brenda (mbingarkesa menaxhohet me parkim manual).",
parkDetails: "Të dhënat e parkimit (opsionale — shfaqen në bileta/fatura)",
save: "Ruaj",
saved: "U ruajt.",
@@ -673,6 +687,8 @@ 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ë.",
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.",
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)