feat(tariff): complete the progressive ladder — require open-ended last block, hours-based composer
The stepped-block engine already does "first N hrs x X, next N hrs x Y, ...,
24h cap" (ordered blocks, per-block rate, rolling-24h cap). No new axis; this
completes the model and removes its footgun.
- validateTariffStructure (shared) now REQUIRES the last block to be open-ended
(uptoMin: null). A bounded final block silently inherited its own rate past
its bound (a hidden, never-stated price — e.g. the live ALL tariff billed
hour 4+ at the 3rd-hour rate). rateAt() still prices legacy bounded-tail
versions; validation is publish-only, so published immutable versions are
unaffected (no migration).
- TariffComposer edits bands as a DURATION in hours ("first 2 hours, then next
3 hours"), accumulated into the engine's cumulative uptoMin (minutes) on
submit. The last row is a pinned, non-removable "thereafter (open-ended)"
band, so a published card always satisfies the open-ended-last rule.
blocksToForm round-trips stored minutes back to band hours (legacy loads).
- i18n: replaced upToMin/egExample with bandDuration/hoursUnit/egHours (sq+en,
catalog parity green).
Verified: validator rejects bounded-last / accepts open-ended; computeFee
correct at 1/2/3/5/6/24h for a 0-2h@200,2-5h@100,5h+@50 + 1000 cap card. Full
build green. Wiki (tariff.md, log.md) updated.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
@@ -15,8 +15,13 @@ import {
|
||||
// usability and converted to integer minor units on submit. See wiki/concepts/tariff.md.
|
||||
|
||||
// Editable form mirror of TariffStructure, but money in major-unit strings.
|
||||
// Blocks are edited as a DURATION in hours ("this band lasts N hours") — the
|
||||
// owner thinks "first 2 hours, then next 3 hours", not in cumulative minutes.
|
||||
// The LAST block is always open-ended ("thereafter"): its hours field is unused
|
||||
// and it has no bound. On submit, per-block hours accumulate into the engine's
|
||||
// cumulative `uptoMin` (minutes), and the last block emits uptoMin: null.
|
||||
interface BlockForm {
|
||||
uptoMin: string; // "" = open-ended (last block)
|
||||
hours: string; // duration of THIS band, in hours (ignored for the last block)
|
||||
price: string; // major units, e.g. "2.00"
|
||||
}
|
||||
interface FormState {
|
||||
@@ -40,10 +45,25 @@ function emptyForm(): FormState {
|
||||
dailyCap: "",
|
||||
lostTicket: "20.00",
|
||||
gracePeriodExitMin: "15",
|
||||
blocks: [{ uptoMin: "60", price: "2.00" }, { uptoMin: "", price: "1.00" }],
|
||||
blocks: [{ hours: "1", price: "2.00" }, { hours: "", price: "1.00" }],
|
||||
};
|
||||
}
|
||||
|
||||
// Convert a published structure's cumulative `uptoMin` (minutes) back into the
|
||||
// per-band hours the form edits. Each band's hours = (its bound − previous bound)
|
||||
// / 60; the open-ended last band has no hours. Legacy versions whose last block is
|
||||
// bounded (pre-2026-06-18, before open-ended was required) still load: the bounded
|
||||
// tail simply shows as its own band and the operator adds/keeps an open-ended one.
|
||||
function blocksToForm(blocks: TariffStructure["blocks"]): BlockForm[] {
|
||||
let prev = 0;
|
||||
return blocks.map((b) => {
|
||||
if (b.uptoMin == null) return { hours: "", price: toMajor(b.priceMinorPerIncrement) };
|
||||
const hours = (b.uptoMin - prev) / 60;
|
||||
prev = b.uptoMin;
|
||||
return { hours: String(hours), price: toMajor(b.priceMinorPerIncrement) };
|
||||
});
|
||||
}
|
||||
|
||||
function formFromActive(s: TariffState): FormState {
|
||||
const v = s.active;
|
||||
if (!v) return emptyForm();
|
||||
@@ -55,18 +75,22 @@ function formFromActive(s: TariffState): FormState {
|
||||
dailyCap: st.dailyCapMinor == null ? "" : toMajor(st.dailyCapMinor),
|
||||
lostTicket: toMajor(st.lostTicketMinor),
|
||||
gracePeriodExitMin: String(st.gracePeriodExitMin),
|
||||
blocks: st.blocks.map((b) => ({
|
||||
uptoMin: b.uptoMin == null ? "" : String(b.uptoMin),
|
||||
price: toMajor(b.priceMinorPerIncrement),
|
||||
})),
|
||||
blocks: blocksToForm(st.blocks),
|
||||
};
|
||||
}
|
||||
|
||||
function toStructure(f: FormState): TariffStructure {
|
||||
const blocks: TariffBlock[] = f.blocks.map((b) => ({
|
||||
uptoMin: b.uptoMin.trim() === "" ? null : Math.round(Number(b.uptoMin)),
|
||||
priceMinorPerIncrement: toMinor(b.price),
|
||||
}));
|
||||
// Accumulate each band's DURATION (hours) into the engine's cumulative `uptoMin`
|
||||
// (minutes). The LAST band is always open-ended (uptoMin null) — its hours are
|
||||
// ignored — so the published structure always satisfies the "last block must be
|
||||
// open-ended" rule (the thereafter-rate is explicit). See wiki/concepts/tariff.md.
|
||||
const last = f.blocks.length - 1;
|
||||
let cumulativeMin = 0;
|
||||
const blocks: TariffBlock[] = f.blocks.map((b, i) => {
|
||||
if (i === last) return { uptoMin: null, priceMinorPerIncrement: toMinor(b.price) };
|
||||
cumulativeMin += Math.round(Number(b.hours || "0") * 60);
|
||||
return { uptoMin: cumulativeMin, priceMinorPerIncrement: toMinor(b.price) };
|
||||
});
|
||||
return {
|
||||
gracePeriodEntryMin: Math.round(Number(f.gracePeriodEntryMin)),
|
||||
incrementMin: Math.round(Number(f.incrementMin)),
|
||||
@@ -100,11 +124,23 @@ export function TariffComposer() {
|
||||
function setBlock(i: number, patch: Partial<BlockForm>) {
|
||||
setForm((f) => ({ ...f, blocks: f.blocks.map((b, j) => (j === i ? { ...b, ...patch } : b)) }));
|
||||
}
|
||||
// Insert a new bounded band just BEFORE the open-ended "thereafter" tail, so the
|
||||
// last block always stays open-ended.
|
||||
function addBlock() {
|
||||
setForm((f) => ({ ...f, blocks: [...f.blocks, { uptoMin: "", price: "0.00" }] }));
|
||||
setForm((f) => {
|
||||
const tailIdx = f.blocks.length - 1;
|
||||
const next = [...f.blocks];
|
||||
next.splice(tailIdx, 0, { hours: "1", price: "0.00" });
|
||||
return { ...f, blocks: next };
|
||||
});
|
||||
}
|
||||
// Remove a bounded band. The open-ended tail (last row) can't be removed (it's the
|
||||
// required thereafter-rate); the guard also keeps at least the tail present.
|
||||
function removeBlock(i: number) {
|
||||
setForm((f) => ({ ...f, blocks: f.blocks.filter((_, j) => j !== i) }));
|
||||
setForm((f) => {
|
||||
if (i === f.blocks.length - 1 || f.blocks.length <= 1) return f;
|
||||
return { ...f, blocks: f.blocks.filter((_, j) => j !== i) };
|
||||
});
|
||||
}
|
||||
|
||||
async function publish() {
|
||||
@@ -160,32 +196,44 @@ export function TariffComposer() {
|
||||
<table style={{ borderCollapse: "collapse" }}>
|
||||
<thead>
|
||||
<tr style={{ textAlign: "left", color: "#555" }}>
|
||||
<th style={{ padding: "0 0.5rem" }}>{t("tariff.upToMin")}</th>
|
||||
<th style={{ padding: "0 0.5rem" }}>{t("tariff.bandDuration")}</th>
|
||||
<th style={{ padding: "0 0.5rem" }}>{t("tariff.pricePerIncrement")}</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{form.blocks.map((b, i) => (
|
||||
{form.blocks.map((b, i) => {
|
||||
const isTail = i === form.blocks.length - 1;
|
||||
return (
|
||||
<tr key={i}>
|
||||
<td style={{ padding: "0.15rem 0.5rem" }}>
|
||||
{isTail ? (
|
||||
<span style={{ color: "#777", fontStyle: "italic" }}>{t("tariff.thereafter")}</span>
|
||||
) : (
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: "0.3rem" }}>
|
||||
<input
|
||||
value={b.uptoMin}
|
||||
onChange={(e) => setBlock(i, { uptoMin: e.target.value })}
|
||||
placeholder={i === form.blocks.length - 1 ? t("tariff.thereafter") : t("tariff.egExample")}
|
||||
style={{ width: 110 }}
|
||||
value={b.hours}
|
||||
onChange={(e) => setBlock(i, { hours: e.target.value })}
|
||||
placeholder={t("tariff.egHours")}
|
||||
style={{ width: 70 }}
|
||||
/>
|
||||
<span style={{ color: "#777" }}>{t("tariff.hoursUnit")}</span>
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ padding: "0.15rem 0.5rem" }}>
|
||||
<input value={b.price} onChange={(e) => setBlock(i, { price: e.target.value })} style={{ width: 90 }} />
|
||||
</td>
|
||||
<td>
|
||||
<button type="button" onClick={() => removeBlock(i)} disabled={form.blocks.length <= 1}>
|
||||
{!isTail && (
|
||||
<button type="button" onClick={() => removeBlock(i)}>
|
||||
{t("tariff.remove")}
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
<button type="button" onClick={addBlock} style={{ marginTop: "0.4rem" }}>
|
||||
|
||||
@@ -109,11 +109,12 @@ export const en: Catalog = {
|
||||
lostTicketFee: "Lost-ticket fee",
|
||||
exitGrace: "Exit walk-back grace (min)",
|
||||
rateBlocks: "Rate blocks",
|
||||
rateBlocksHint: "Consumed in order as time accrues. \"Up to (min)\" is the block's upper bound; leave the last block's bound blank for \"thereafter\". Price is per billing increment.",
|
||||
upToMin: "Up to (min)",
|
||||
rateBlocksHint: "Each band lasts a number of hours and bills at its own price; bands are consumed in order (the first hours, then the next hours). The last band is \"thereafter\" (open-ended) — its price applies once the ladder is exhausted. Price is per billing increment.",
|
||||
bandDuration: "Band duration",
|
||||
hoursUnit: "hours",
|
||||
egHours: "e.g. 2",
|
||||
pricePerIncrement: "Price / increment",
|
||||
thereafter: "thereafter",
|
||||
egExample: "e.g. 60",
|
||||
thereafter: "thereafter (open-ended)",
|
||||
remove: "Remove",
|
||||
addBlock: "+ Add block",
|
||||
publishNewVersion: "Publish new version",
|
||||
|
||||
@@ -71,8 +71,8 @@ export const sq = {
|
||||
free: "Vende të lira",
|
||||
lotFull: "● parkimi plot",
|
||||
liveFeed: "Aktiviteti live",
|
||||
events: "ngjarje",
|
||||
noEventsYet: "Asnjë ngjarje ende — hyrjet dhe daljet do të shfaqen këtu.",
|
||||
events: "Evente",
|
||||
noEventsYet: "Asnjë event ende — hyrjet dhe daljet do të shfaqen këtu.",
|
||||
activeSessions: "Sesionet aktive",
|
||||
insideCount: "brenda",
|
||||
noActiveSessions: "Asnjë sesion aktiv.",
|
||||
@@ -111,11 +111,12 @@ export const sq = {
|
||||
lostTicketFee: "Tarifa për biletë të humbur",
|
||||
exitGrace: "Periudha e kthimit në dalje (min)",
|
||||
rateBlocks: "Blloqet tarifore",
|
||||
rateBlocksHint: "Konsumohen me radhë me kalimin e kohës. \"Deri në (min)\" është kufiri i sipërm i bllokut; lëre bosh kufirin e bllokut të fundit për \"më pas\". Çmimi është për interval faturimi.",
|
||||
upToMin: "Deri në (min)",
|
||||
rateBlocksHint: "Çdo brez zgjat një numër orësh dhe faturohet me çmimin e tij; brezat konsumohen me radhë (orët e para, pastaj orët në vijim). Brezi i fundit është \"më pas\" (i hapur) — çmimi i tij zbatohet pas mbarimit të shkallës. Çmimi është për interval faturimi.",
|
||||
bandDuration: "Kohëzgjatja e brezit",
|
||||
hoursUnit: "orë",
|
||||
egHours: "p.sh. 2",
|
||||
pricePerIncrement: "Çmimi / interval",
|
||||
thereafter: "më pas",
|
||||
egExample: "p.sh. 60",
|
||||
thereafter: "më pas (i hapur)",
|
||||
remove: "Hiq",
|
||||
addBlock: "+ Shto bllok",
|
||||
publishNewVersion: "Publiko version të ri",
|
||||
@@ -179,7 +180,7 @@ export const sq = {
|
||||
printCode: "Printo kodin",
|
||||
printedOn: "Kodi u printua te {{printer}}.",
|
||||
confirmRevoke: "Të anulohet abonimi për {{name}}? Do të refuzohet te barriera.",
|
||||
confirmDelete: "Të fshihet abonimi për {{name}}? (Ngjarjet e kaluara ruhen.)",
|
||||
confirmDelete: "Të fshihet abonimi për {{name}}? (t e kaluara ruhen.)",
|
||||
statusActive: "aktiv",
|
||||
statusSuspended: "pezulluar",
|
||||
statusRevoked: "anuluar",
|
||||
|
||||
@@ -200,6 +200,16 @@ export function validateTariffStructure(s: unknown): string[] {
|
||||
}
|
||||
}
|
||||
});
|
||||
// The LAST block must be open-ended (uptoMin null) so the "thereafter" rate is
|
||||
// always explicit. A bounded final block silently inherits its own rate past
|
||||
// its bound (a hidden, never-stated price) — forbidden on publish so the admin
|
||||
// must state what time beyond the ladder costs. See wiki/concepts/tariff.md.
|
||||
// (Read/pricing of already-published versions is unaffected — validation runs
|
||||
// only on publish; rateAt() still gracefully handles legacy bounded tails.)
|
||||
const lastBlock = t.blocks[t.blocks.length - 1];
|
||||
if (lastBlock && lastBlock.uptoMin != null) {
|
||||
errs.push("the last block must be open-ended (uptoMin: null) — the thereafter-rate must be stated explicitly");
|
||||
}
|
||||
}
|
||||
return errs;
|
||||
}
|
||||
|
||||
+38
-8
@@ -45,11 +45,12 @@ code. All amounts are **integer minor units** in the tariff's currency.
|
||||
"currency": "EUR", // ISO 4217; selectable per tariff version
|
||||
"gracePeriodEntryMin": 15, // free if exited within this (drop-off/turnaround)
|
||||
"incrementMin": 60, // billing granularity; partial increments round UP
|
||||
"blocks": [ // consumed in order as duration accrues
|
||||
"blocks": [ // consumed in order as duration accrues; uptoMin is
|
||||
// the CUMULATIVE upper bound in minutes
|
||||
{ "uptoMin": 60, "priceMinorPerIncrement": 200 }, // first hour
|
||||
{ "uptoMin": 180, "priceMinorPerIncrement": 150 }, // 60→180 min
|
||||
{ "uptoMin": null, "priceMinorPerIncrement": 100 } // null = open-ended, thereafter
|
||||
],
|
||||
{ "uptoMin": null, "priceMinorPerIncrement": 100 } // REQUIRED open-ended last
|
||||
], // block — the explicit "thereafter" rate
|
||||
"dailyCapMinor": 1200, // cap per rolling 24h (null = no cap)
|
||||
"lostTicketMinor": 2000, // flat charge when there's no entry id
|
||||
"gracePeriodExitMin": 15, // pay-on-foot walk-back window
|
||||
@@ -92,6 +93,12 @@ because the chain + reconciliation depend on the result being reproducible.
|
||||
increment would round it up (else rounding defeats the grace window).
|
||||
- **The block ladder RESETS each rolling-24h day** — day 2 starts at the first block again (a 25h
|
||||
stay = day-1 capped + day-2 first-hour rate), so the "daily" rate truly resets daily.
|
||||
- **The LAST block MUST be open-ended (`uptoMin: null`)** — enforced on publish (2026-06-18). A
|
||||
bounded final block silently inherited its own rate past its bound (a hidden, never-stated price);
|
||||
forcing an open-ended tail makes the "thereafter" rate explicit. `rateAt()` still gracefully prices
|
||||
legacy bounded-tail versions (validation runs only on publish, never on read), so already-published
|
||||
immutable versions keep pricing unchanged. This is the "first N hrs × X, next N hrs × Y, …, 24h
|
||||
cap" model made complete — the same engine, no new axis; the only gap was the unstated tail.
|
||||
|
||||
**As-built:** `computeFee(enteredAt, asOf, structure)` in `packages/shared` (pure). Unit-tested
|
||||
across grace, block steps, daily cap, and multi-day reset.
|
||||
@@ -103,12 +110,17 @@ The admin authors the rate card at runtime — no hand-seeding:
|
||||
- **API** (`apps/server/src/routes/tariffs.ts`): `GET /api/tariff` (active version + history; any
|
||||
signed-in role) and `POST /api/tariff/versions` (publish a new immutable version; **admin only**).
|
||||
Publishing validates the structure via `validateTariffStructure` (shared) — non-negative integers,
|
||||
ordered/ascending block bounds, only the last block open-ended — so a malformed card can never be
|
||||
published. The single site `tariffs` row is created lazily on first read/publish.
|
||||
ordered/ascending block bounds, **the last block open-ended (enforced)**, and **`effectiveFrom`
|
||||
not in the past** (no backdating) — so a malformed or retroactive card can never be published. The
|
||||
single site `tariffs` row is created lazily on first read/publish.
|
||||
- **UI** (`apps/web/src/TariffComposer.tsx`, admin shell): edit currency, grace windows, increment,
|
||||
daily cap, lost-ticket fee, and add/remove rate blocks; amounts entered in major units, converted
|
||||
to integer minor units on submit. Shows the active version + history; "Publish" creates a new
|
||||
version (past sessions keep their pricing).
|
||||
daily cap, lost-ticket fee, and add/remove rate bands; amounts entered in major units, converted to
|
||||
integer minor units on submit. **Bands are edited as a DURATION in hours** ("this band lasts N
|
||||
hours") — the owner thinks "first 2 hours, then next 3 hours", not in cumulative minutes; the
|
||||
composer accumulates per-band hours into the engine's cumulative `uptoMin` (minutes) on submit. The
|
||||
**last band is always the open-ended "thereafter"** row (not removable, no hours field), so a
|
||||
published card always satisfies the open-ended-last rule. Shows the active version + history;
|
||||
"Publish" creates a new version (past sessions keep their pricing).
|
||||
- Ships **blank** — until a version is published, `GET /api/tariff` returns `active: null` and the
|
||||
pay station returns `409 no active tariff`. Verified end to end (publish → pay station prices).
|
||||
|
||||
@@ -163,6 +175,24 @@ that was in force when it was incurred — never today's. So a tariff is **never
|
||||
- An **in-progress** session that crosses a version boundary uses the version in force at **entry**
|
||||
(consistent, predictable) — confirm vs. pro-rating if an operator ever wants the latter.
|
||||
|
||||
### No backdating — versioning would otherwise be retroactive (fixed 2026-06-18)
|
||||
|
||||
The two bullets above only hold if a new version's `effectiveFrom` **cannot be in the past**. The
|
||||
selector is "latest `effectiveFrom ≤ entry time`", so publishing a version with a **backdated**
|
||||
`effectiveFrom` would silently re-select it for sessions that **already entered** — retroactively
|
||||
repricing in-progress (and re-quotable) stays. That is exactly the rewrite the versioning exists to
|
||||
prevent, and it was **publishable** until this fix (the publish handler accepted any `effectiveFrom`,
|
||||
defaulting to now).
|
||||
|
||||
**Rule (enforced server-side in `routes/tariffs.ts`):** on publish, `effectiveFrom` must be **≥ now**
|
||||
(a 60 s skew tolerance absorbs clock drift + round-trip). A **future** `effectiveFrom` is allowed —
|
||||
scheduling a forthcoming price change is legitimate and forward-only. A past one is rejected `400`.
|
||||
Combined with entry-time selection, this makes the guarantee structural: **once a car has entered, no
|
||||
later publish can change its price**, because no new version can carry an `effectiveFrom` that
|
||||
predates the entry. We deliberately did **not** also pin `tariffVersionId` onto the `vehicle_entry`
|
||||
event (entry-time selection + no-backdating already freezes the price); revisit only if multi-tariff
|
||||
`scope` makes entry-time resolution ambiguous.
|
||||
|
||||
## Data model (first cut — with [[session-model]])
|
||||
|
||||
| Table / field | Notes |
|
||||
|
||||
+10
@@ -844,3 +844,13 @@ Decoupled subscription exit from the entry credential. Previously the session wa
|
||||
## [2026-06-18] fix | Subscription occurrences in the booth — prepaid, barrier-open assist (not transient)
|
||||
|
||||
A subscription occurrence (SUBSESS-…) showed in Active Sessions but was wrongly treated as an unpaid transient: the modal tried to quote/charge it and the "open barrier" button only appeared for PAID sessions, so a subscriber with a faulty exit reader / missing card couldn't be assisted. Fix: `pay-station.ts` lookup/activeSessions now flag `subscription`/`subscriptionId`/`subscriptionHolder` from the entry payload (permit:true/permitId) and DON'T quote a subscription (amountMinor null). `exit-flow.ts` reopenBarrier now authorizes `paidAt != null || subscription` (prepaid). UI: the pay modal renders a SUBSCRIPTION mode (PREPAID badge, snapshots, single Open-barrier action, no tender/voucher) and the active row badges "abonim" + shows the holder name; both labelled by holder, not the raw key. Also SHORTENED the occurrence id (was SUBSESS-<subId>-<uuid>, ~80 chars) to `SUBSESS-<12hex>` — the subscriptionId lives in the payload (which every fold matches on), so it needn't be embedded in the key. Verified 9/9 (subscription flagged + not charged in lookup/active, reopen works without payment, unpaid-transient guard intact). Updated [[booth-exit-flow]]. No migration.
|
||||
|
||||
## [2026-06-18] fix | Tariff versioning was retroactive — forbid backdated effectiveFrom
|
||||
|
||||
The version selector picks "latest tariff_version with effectiveFrom ≤ session entry time" (correct intent: a past session reprices against the rate in force when it was incurred). But the publish handler (`routes/tariffs.ts`) accepted ANY `effectiveFrom` (defaulting to now). So an admin could publish a version with a **backdated** effectiveFrom and silently reprice sessions that had already entered — exactly the retroactive rewrite the versioning exists to prevent. Pricing itself was already sound: `quote()` resolves by `entry.occurredAt` and the `payment` event records `tariffVersionId`, so a COMPLETED session is frozen; the leak was entirely the publish side. Fix: reject `effectiveFrom` earlier than now (60 s skew tolerance); future-dated (scheduling a price change) stays allowed; bad ISO → 400. Decision (with user): forbid backdating + keep entry-time pinning; did NOT add tariffVersionId to vehicle_entry (entry-time selection + no-backdating already freezes the price). Together these make it structural: once a car has entered, no later publish can reprice it. Verified 5/5 via inject against a copy of the live DB (now→201, -1h→400, +1h→201, garbage→400, -10s skew→201). Updated [[tariff]]. No migration.
|
||||
|
||||
## [2026-06-18] feat | Tariff: complete the progressive ladder — open-ended last block required + hours-based composer
|
||||
|
||||
The owner asked for "first N hours × X, next N hours × Y, …, 24h cap" — which the stepped-block engine ALREADY does (ordered blocks, per-block rate, rolling-24h cap; computeFee tested). So no new axis: the work was making the model complete + footgun-free. Two changes. (1) **Forbid a bounded last block** — `validateTariffStructure` (shared) now rejects a final block with a non-null `uptoMin`, so the "thereafter" rate is always explicit; previously a bounded tail silently inherited its own rate past its bound (a hidden, never-stated price — e.g. the live ALL tariff's 180-min last block billed hour 4+ at the 3rd-hour rate). `rateAt()` still prices legacy bounded-tail versions gracefully and validation is publish-only, so immutable published versions are unaffected (no migration). (2) **Composer edits bands as a DURATION in hours**, not cumulative minutes — `BlockForm` carries `hours`; `toStructure` accumulates into cumulative `uptoMin` minutes; the last row is a pinned, non-removable, hours-less "thereafter (open-ended)" band; `blocksToForm` round-trips stored minutes back to band hours (legacy bounded tails still load). i18n: replaced `upToMin`/`egExample` with `bandDuration`/`hoursUnit`/`egHours` in sq+en (catalog parity green). Verified: validator rejects bounded-last / accepts open-ended; computeFee correct at 1/2/3/5/6/24h for a 0-2h@200,2-5h@100,5h+@50 + 1000 cap card. Full build green (shared/server/web). Updated [[tariff]]. No migration.
|
||||
|
||||
NB considered-and-rejected: time-of-day / weekday wall-clock tiers ("timeframe") were offered but the owner explicitly chose the elapsed-duration ladder only — see [[tariff-time-tiers]] for the deferred wall-clock axis.
|
||||
|
||||
Reference in New Issue
Block a user