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) => (
|
||||
<tr key={i}>
|
||||
<td style={{ padding: "0.15rem 0.5rem" }}>
|
||||
<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 }}
|
||||
/>
|
||||
</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}>
|
||||
{t("tariff.remove")}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{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.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>
|
||||
{!isTail && (
|
||||
<button type="button" onClick={() => removeBlock(i)}>
|
||||
{t("tariff.remove")}
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
<button type="button" onClick={addBlock} style={{ marginTop: "0.4rem" }}>
|
||||
|
||||
Reference in New Issue
Block a user