Merge: recurring costs — bills that repeat, expected against actual
Rent every month, a subscription every 28 days, and a phone bill every 30 days whose clock RESTARTS when its data cap is hit early. A recurring cost projects EXPECTED charges forward; confirming one writes a real Purchase. The fold now carries two balances per post per month: `remaining`, untouched by this feature, and `projected`, which subtracts commitments that have not been paid yet. The data-cap behaviour is not a feature of its own. Under `lastCharge` anchoring the next occurrence is measured from the confirming purchase's OWN date, so confirming a slot early rebases the series — and two-stage confirmation already records that date. The domain never learns what a data cap is. That collapse is most of why this design is small: three recurrence kinds and one anchoring field, not a list of special cases. Two consequences worth not re-litigating. `everyNMonths` carries no day, because rent occurs IN September and IsoDate already permits "YYYY-MM" — which keeps day arithmetic out of the commonest case. And `amount` is a single mutable Money with no dated series, safe ONLY because confirmed purchases hold what was actually paid; under projection-only it would destroy history. Occurrences are computed, never stored. `upToMonth` comes from the caller, so src/domain/ stays clock-free. Schema 7. 651 tests, tsc clean, build green. Four defects were found by review rather than by the suite, each reachable and each invisible to a passing test: - Creating a cost then switching its unit stored a month-only startDate on a day-granular kind, which threw out of the fold and took every money route down. Two clicks. The validators checked date SHAPE where the UI checked VALIDITY, so a calendar-impossible "2026-09-31" survived a second round. - Confirming a future occurrence recorded today against a slot more than one cycle out, which walks the series backwards. Same crash, introduced by the change that closed a spec gap. - Editing a confirmed purchase's date in the ordinary purchase dialog — an act with nothing to do with recurring costs — could do the same thing, and the dialog swallowed the refusal so a save appeared to silently do nothing. - Import accepted a splitMode the fold would silently read as "fixed", turning intended percentages into absolute amounts. AGENTS.md §3 held throughout: negatives, percentages past 100 and non-summing splits are all still accepted, deliberately. Design: docs/specs/2026-09-03-recurring-costs-design.md Plan: docs/plans/2026-09-03-recurring-costs.md Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
@@ -47,6 +47,21 @@ and `docs/specs/`, and is not repeated here.
|
||||
the owner never chose.
|
||||
- **`MonthId` is the string `"YYYY-MM"`.** Stored data never contains `Date`
|
||||
objects.
|
||||
- **A recurring cost's occurrences are COMPUTED, never stored.** `Dataset`
|
||||
holds the rule and the confirmations; `occurrencesByMonth` derives the rest
|
||||
on every fold, and `upToMonth` comes from the caller so `src/domain/` stays
|
||||
clock-free. Nothing is ever written ahead of time — `ExpectedBand`'s
|
||||
`horizonMonths` only widens how far a single read-only walk looks before
|
||||
throwing its result away; it is a DISPLAY bound on one function call, not a
|
||||
"generate ahead" step, and nothing it produces is persisted. Storing
|
||||
occurrences would reintroduce the materialised snapshot the original design
|
||||
rejected.
|
||||
- **On a confirmation, `Purchase.date` is TRUTH and `source.occurrenceDate` is
|
||||
IDENTITY.** The slot is what a confirmation claims, so the projector stops
|
||||
emitting it; the date is when the money moved, so the fold counts it there.
|
||||
They differ whenever a bill is paid off schedule, and under `lastCharge`
|
||||
anchoring it is `date` that rebases the series. Do not "simplify" them into
|
||||
one field — that erases the phone-bill behaviour entirely.
|
||||
- **The envelope rollover fold has exactly one implementation**, in
|
||||
`src/domain/fold.ts`. Year and summary views aggregate over it — they never
|
||||
reimplement the math.
|
||||
@@ -100,6 +115,14 @@ ship twice — re-introducing one is a regression, not a style choice.
|
||||
fold and the month view, and asserts that raising the setting to 2 changes
|
||||
what the fold produces. It cannot prove digits vary per currency, because
|
||||
they no longer do.
|
||||
- **A recurrence interval must be an integer `>= 1`, rejected rather than
|
||||
clamped**, at BOTH write boundaries — `requireRecurrence` in
|
||||
`src/store/actions.ts` and in `src/export/json.ts`. The projection walk
|
||||
terminates only because every step strictly advances, and a clamped zero
|
||||
would hand the owner a schedule their data does not describe. The walk throws
|
||||
if a step fails to advance; do not soften that into a `break`.
|
||||
→ `src/domain/occurrences.test.ts`, `src/store/actions.test.ts`,
|
||||
`src/export/json.test.ts`
|
||||
- **`process.env.BUN_PUBLIC_*` must be read via `try/catch`** — see
|
||||
`readBasePathEnv`. Bun inlines the literal only when the variable is set;
|
||||
unset, the bare reference reaches the browser and throws before the app boots.
|
||||
|
||||
@@ -27,6 +27,11 @@ server.ts the Docker image's static file server.
|
||||
The dependency rule is one-directional: `ui -> store -> domain`. `domain` imports
|
||||
nothing from the others; `store` never imports from `ui`.
|
||||
|
||||
Two `src/domain/` modules worth naming individually: `days.ts` is day-ordinal
|
||||
arithmetic with no `Date` at all, and `occurrences.ts` walks a recurring cost's
|
||||
schedule forward from `startDate`, computing its occurrences fresh on every
|
||||
fold rather than storing them.
|
||||
|
||||
`src/domain/smoke.test.ts` asserts the domain is importable with no DOM. That is
|
||||
why happy-dom is registered **per test file** rather than as a global bunfig
|
||||
preload — a global DOM would make that assertion vacuously true and silently
|
||||
@@ -45,6 +50,21 @@ The month view, year matrix, summary, per-post views and the ODS export all
|
||||
aggregate over `foldBalances` / `figuresFor`. A closing balance is read from
|
||||
`byMonth[11].remaining`, never re-summed.
|
||||
|
||||
A recurring cost adds a SECOND balance beside it, on its own carry:
|
||||
|
||||
```
|
||||
projected = projectedCarriedIn + allocation − charges − expected
|
||||
```
|
||||
|
||||
`remaining` never changes shape or value because of a recurring cost — it is
|
||||
still the figure the owner reconciles against their bank statement. `projected`
|
||||
runs beside it, additionally subtracting this month's unconfirmed occurrences,
|
||||
so the month view shows both a `Remaining` and a `Projected` column per post
|
||||
rather than only a separate list of upcoming bills. With no recurring costs the
|
||||
two tracks are identical in every month; once every occurrence in a month is
|
||||
confirmed they reconverge, because a confirmation is an ordinary purchase
|
||||
counted in `charges` and skipped by `expected`.
|
||||
|
||||
If you find yourself writing `carriedIn + allocation - charges` outside
|
||||
`fold.ts`, that is the invariant breaking. It matters because two screens
|
||||
silently disagreeing about someone's money is the worst failure this app has.
|
||||
|
||||
+43
-1
@@ -18,7 +18,7 @@ record.
|
||||
no household mode. → [`PRODUCT.md`](PRODUCT.md)
|
||||
- **Schema migrations are mandatory, not optional.** `schemaVersion` plus
|
||||
ordered migration functions applied on load, because IndexedDB holds the
|
||||
user's only copy of their financial data. Now at version 6.
|
||||
user's only copy of their financial data. Now at version 7.
|
||||
→ [`specs/2026-09-01-budget-app-design.md`](specs/2026-09-01-budget-app-design.md)
|
||||
- **A destructive write downloads a backup first.** Import and reset both export
|
||||
before replacing, and abort if the export fails. → commit `ac3d18a`
|
||||
@@ -75,6 +75,48 @@ record.
|
||||
- **Posts are archived, never deleted**, because purchases reference them.
|
||||
Archived posts still fold. → [`ARCHITECTURE.md`](ARCHITECTURE.md)
|
||||
|
||||
## Recurring costs
|
||||
|
||||
- **Two-stage confirmation, over projection-only or generation-only.** A
|
||||
recurring cost projects expected occurrences that stay expected until the
|
||||
owner confirms one into an ordinary `Purchase`. Projection-only would charge
|
||||
automatically with no reconciliation and would also force a dated amount
|
||||
series, since nothing else would hold what was actually paid; generation-only
|
||||
needs a horizon and a notion of "today" the domain is built to avoid.
|
||||
→ [`specs/2026-09-03-recurring-costs-design.md`](specs/2026-09-03-recurring-costs-design.md)
|
||||
- **A new `RecurringCost` entity, over extending `Purchase.schedule` with a
|
||||
generative variant.** Under two-stage confirmation a template's only job is
|
||||
to spawn purchases, so every consumer of `Purchase` would have to learn to
|
||||
skip templates — a different case from splits and finance plans composing on
|
||||
one entity.
|
||||
→ [`specs/2026-09-03-recurring-costs-design.md`](specs/2026-09-03-recurring-costs-design.md)
|
||||
- **`lastCharge` anchoring, over a `dataCap` concept for the phone-bill case.**
|
||||
Rebasing the series on the confirmation date reproduces the behaviour exactly,
|
||||
with no usage tracking and no third recurrence kind.
|
||||
→ [`specs/2026-09-03-recurring-costs-design.md`](specs/2026-09-03-recurring-costs-design.md)
|
||||
- **Day-less `everyNMonths`, over `monthlyOnDay(day)` with short-month
|
||||
clamping.** The owner does not think of rent as landing on a date, `IsoDate`
|
||||
already supports month-only values, and it keeps day arithmetic out of the
|
||||
most common case entirely.
|
||||
→ [`specs/2026-09-03-recurring-costs-design.md`](specs/2026-09-03-recurring-costs-design.md)
|
||||
- **A mutable `amount`, over a dated series like `Post.rules`.** Safe only
|
||||
because two-stage confirmation already holds every past amount in a confirmed
|
||||
`Purchase`; the rule describes what is expected next, the ledger holds what
|
||||
happened. Under projection-only this would not have held, and a dated series
|
||||
would have been required.
|
||||
→ [`specs/2026-09-03-recurring-costs-design.md`](specs/2026-09-03-recurring-costs-design.md)
|
||||
- **`archived` MIRRORS "`endedFrom` is set"**, maintained in one place
|
||||
(`setRecurringCostEndedFrom`) rather than two independent fields a caller
|
||||
could desynchronise. `endedFrom` stops the projection; `archived` only dims
|
||||
the row, the same way an archived post stays listed rather than
|
||||
disappearing. Neither touches a past occurrence. One consequence is visible
|
||||
and undocumented until now: dating a cancellation in the future sets
|
||||
`archived` immediately, so a bill still live for months still renders
|
||||
dimmed with a "restart" button while it keeps projecting rows right up to
|
||||
`endedFrom`. Defensible under the rule as chosen, not a bug.
|
||||
→ [`specs/2026-09-03-recurring-costs-design.md`](specs/2026-09-03-recurring-costs-design.md),
|
||||
`src/store/actions.ts`
|
||||
|
||||
## Design system
|
||||
|
||||
- **Two token layers.** `--budget-*` is the app's own vocabulary; shadcn's
|
||||
|
||||
+9
-1
@@ -67,10 +67,18 @@ Confirmed and in place:
|
||||
browser.
|
||||
- Offline-capable installable PWA. Currently deploys as one static artifact to
|
||||
both GitHub Pages (under a subpath) and a self-hosted Docker image.
|
||||
- Recurring costs — a bill that repeats every N months, every N days, or
|
||||
every N weeks on a weekday, split across posts like any purchase. Each due date is a
|
||||
projected occurrence the owner confirms into a real purchase; the month view
|
||||
shows what is still expected alongside a second, projected balance per post,
|
||||
so a known bill is visible before it is paid without touching the reconciled
|
||||
figure a bank statement is checked against.
|
||||
|
||||
Terminology: an envelope is called a **post**. A **finance plan** is a purchase
|
||||
spread across months. An **override** is a one-month exception to a post's
|
||||
allocation.
|
||||
allocation. A **recurring cost** is a bill expected to repeat; each due date it
|
||||
projects is an **occurrence**; **confirming** an occurrence turns it into a
|
||||
real purchase.
|
||||
|
||||
Undecided: nothing outstanding at the product level.
|
||||
|
||||
|
||||
+17
-5
@@ -8,15 +8,16 @@ decisions behind it are in `docs/specs/` — one spec per sub-project,
|
||||
each recording the alternatives that were rejected. Rules that outlived their
|
||||
task moved to `AGENTS.md`.
|
||||
|
||||
**Current state:** merged to `main`. 464 tests passing,
|
||||
**Current state:** merged to `main`. 583 tests passing,
|
||||
`bunx tsc --noEmit` clean, `bun run build` succeeds on both the live-fetch and
|
||||
offline paths.
|
||||
|
||||
**Schema is at version 5.** v1 -> v2 made allocation rules a dated series;
|
||||
**Schema is at version 7.** v1 -> v2 made allocation rules a dated series;
|
||||
v2 -> v3 moved currencies into the dataset; v3 -> v4 drops a stored rate-service
|
||||
URL that can no longer work; v4 -> v5 adds sterling to an existing dataset. All
|
||||
four are behaviour-preserving by construction — not one changes a stored figure
|
||||
— and are tested as such.
|
||||
URL that can no longer work; v4 -> v5 adds sterling to an existing dataset;
|
||||
v5 -> v6 collapses per-currency decimals into one dataset-wide setting; v6 -> v7
|
||||
adds `Dataset.recurring`. All six are behaviour-preserving by construction —
|
||||
not one changes a stored figure — and are tested as such.
|
||||
|
||||
A migration applies **on load, in memory**; it is written back on the next
|
||||
mutation, not by `load()` itself. So a freshly migrated dataset shows the new
|
||||
@@ -140,3 +141,14 @@ Genuinely outstanding:
|
||||
focus placement after a row auto-appends. Drivable over CDP in principle; not
|
||||
yet done.
|
||||
- **GitHub Actions** has never been executed.
|
||||
- **Whether two-stage confirmation matches how the owner actually reconciles a
|
||||
bank statement.** Recurring costs (`docs/specs/2026-09-03-recurring-costs-design.md`)
|
||||
project expected occurrences and require a click to turn one into a real
|
||||
purchase, rather than charging automatically. That is a product bet a week of
|
||||
real use answers and no test can.
|
||||
- **The overdue count against a large, real backlog.** `ExpectedBand` counts
|
||||
unconfirmed occurrences from earlier months, and demo data does now
|
||||
accumulate a modest one (a few months of an unconfirmed monthly bill plus a
|
||||
biweekly one, ~14 occurrences by September) that renders without incident.
|
||||
Nobody has seen the count or the layout against years of neglect or dozens of
|
||||
distinct bills.
|
||||
|
||||
@@ -305,18 +305,62 @@ reference the cost.
|
||||
|
||||
**`RecurringSection` in Settings**, alongside `PostsSection` — the same CRUD,
|
||||
reorder and archive idiom, because a recurring cost is defined once and then
|
||||
left alone. Definition is not the repeated act.
|
||||
left alone. Definition is not the repeated act. An **"Ends" column** lets the
|
||||
owner type a cancellation date directly, doing exactly what the "end"/"restart"
|
||||
button does; both go through `setRecurringCostEndedFrom`, the one place that
|
||||
keeps `endedFrom` and `archived` paired (§9, and `DECISIONS.md`). Dating a
|
||||
cancellation in the future dims the row immediately even though it keeps
|
||||
projecting rows until `endedFrom` — a visible consequence of that pairing,
|
||||
recorded in `DECISIONS.md` rather than treated as a bug. `updateRecurringCost`
|
||||
does not accept `archived` or `endedFrom` at all, so a caller cannot split the
|
||||
pair even by mistake.
|
||||
|
||||
**An "Expected" band in the month view**, which is where the repeated act
|
||||
happens. Each unconfirmed occurrence for the month is one row with a one-click
|
||||
confirm that writes the purchase at the expected date and amount. The amount is
|
||||
editable inline before confirming, because paying a different figure is the
|
||||
common case rather than the exception; anything more unusual is edited
|
||||
afterwards like any other purchase. Entry speed is a product principle, and a
|
||||
dialog per bill would defeat it.
|
||||
happens, in two groups:
|
||||
|
||||
**The overdue count** — unconfirmed occurrences from earlier months — appears in
|
||||
that band. §5 is otherwise silent drift.
|
||||
- **This month's unconfirmed occurrences** (`pending`). Each is one row with a
|
||||
one-click confirm that writes the purchase at the expected date and amount.
|
||||
The amount is editable inline before confirming, because paying a different
|
||||
figure is the common case rather than the exception; anything more unusual is
|
||||
edited afterwards like any other purchase. Entry speed is a product
|
||||
principle, and a dialog per bill would defeat it. Confirming here records
|
||||
the slot's own date — the projection assumes bills are paid on time, and a
|
||||
`lastCharge` series only rebases when the recorded date says otherwise.
|
||||
- **"Coming up"**: at most one row per cost, its next unconfirmed occurrence
|
||||
strictly after the displayed month, out to a bounded `horizonMonths` (twelve
|
||||
by default — a read-ahead bound on one function call, nothing stored; see
|
||||
AGENTS.md §1). Confirming a coming-up row records TODAY, not the future
|
||||
slot — paying a `lastCharge` bill early, before its projected date arrives,
|
||||
is exactly what rebases the series, and recording anything else would defeat
|
||||
the point. A this-month row's subtitle is just its date; a coming-up row's
|
||||
says "Due `<slot>` — confirming now records it `<today>`" so the early date
|
||||
is never a surprise. `recordedDateFor(group, today)` is the one function that
|
||||
decides which date a click records — extracted specifically so that decision
|
||||
has a test that fails if its two branches are swapped, since nothing else in
|
||||
the suite would catch it.
|
||||
|
||||
A coming-up row is only ever OFFERED when confirming it would leave the
|
||||
series coherent, checked before the purchase is ever written rather than
|
||||
disabled-with-an-explanation after the fact:
|
||||
|
||||
- **Never for a cost with an unconfirmed occurrence in the displayed month or
|
||||
any earlier foldable month.** Settle the earlier bill first; offering a
|
||||
later one while an earlier sits outstanding would let confirming the later
|
||||
one leave the earlier projection standing, silently double-counting the
|
||||
bill.
|
||||
- **Never for a pairing `occurrencesOf`'s walk could not survive.** A
|
||||
`lastCharge` bill recorded today for a slot far enough in the future can
|
||||
rebase the series BACKWARDS — the walk's own "did not advance" throw, which
|
||||
is correct and stays a throw (AGENTS.md §2). `wouldAdvancePast` (in
|
||||
`domain/occurrences.ts`, built on the same `stepFrom` the walk uses)
|
||||
answers this without re-deriving the rule.
|
||||
|
||||
Both checks omit the row outright rather than rendering it disabled: nobody
|
||||
asked for an explanation of why they can't pay early yet.
|
||||
|
||||
**The overdue count** — unconfirmed occurrences from earlier, foldable
|
||||
months — appears in that band too, and shares its foldability bound with the
|
||||
coherence check above.
|
||||
|
||||
Three existing rules apply directly to the inline amount field: capture
|
||||
`event.target.value` into a `const` before `mutate` (AGENTS.md §2, the
|
||||
@@ -337,12 +381,23 @@ fixed. The field itself stays `type="text"` and parses through
|
||||
- **`domain/fold.test.ts`** — the two convergence invariants from §6.
|
||||
- **`store/migrations.test.ts`** — 6 -> 7 adds the empty array and moves no
|
||||
figure.
|
||||
- **`store/actions.test.ts`** — `updateRecurringCost` rejects a bad recurrence
|
||||
or a granularity mismatch, and (its type alone) cannot touch
|
||||
`archived`/`endedFrom`; `setRecurringCostEndedFrom` keeps the pair coherent
|
||||
through the button and the "Ends" column alike.
|
||||
- **`export/json.test.ts`** — acceptance and rejection, including `n: 0`,
|
||||
`n: 1.5`, a `weekday` of 7, and an unknown `kind`.
|
||||
- **`domain/currencyDigits.test.ts`** — the file now drives the whole dataset
|
||||
at zero decimals and asserts that raising the setting to 2 changes what the
|
||||
fold produces. The `expected` track joins that sweep, so it cannot be the one
|
||||
path that quietly assumes 2.
|
||||
- **`ui/components/ExpectedBand.test.ts`** — `resolveExpectedAmount`;
|
||||
`recordedDateFor`, directly, so the this-month/coming-up date decision has a
|
||||
test that fails if its two branches are swapped; `expectedGroups`'s horizon
|
||||
bound and one-row-per-cost dedup; and the coherence rules above, including a
|
||||
regression built from the exact reported shape (a 30-day `lastCharge` cost
|
||||
confirmed today for a slot 39 days out) asserting that confirming everything
|
||||
`expectedGroups` offers and then folding never throws.
|
||||
|
||||
`scripts/demo-data.ts` seeds a monthly rent and a 28-day subscription, so the
|
||||
band is screenshot-verifiable.
|
||||
|
||||
+58
-1
@@ -41,6 +41,58 @@ actions.setRuleFrom(d, transport.id, "2026-01", { kind: "fixed", amount: { amoun
|
||||
actions.setRuleFrom(d, savings.id, "2026-03", { kind: "percentOfIncome", percent: 10 });
|
||||
actions.setRuleOverride(d, "2026-09", social.id, { kind: "fixed", amount: { amount: 2400, currency: "DKK" } });
|
||||
|
||||
// Recurring costs: bills that project their own expected charges, shown as a
|
||||
// second balance ("Projected") beside the allocation-rule figures above. All
|
||||
// three recurrence kinds and both anchorings appear, so the screenshots show
|
||||
// every combination at once.
|
||||
const wowSub = actions.addRecurringCost(d, {
|
||||
name: "World of Warcraft",
|
||||
archived: false,
|
||||
amount: { amount: 13, currency: "USD" },
|
||||
startDate: "2026-06-05",
|
||||
recurrence: { kind: "everyNDays", n: 28 },
|
||||
anchoring: "calendar",
|
||||
splitMode: "percent",
|
||||
splits: [{ postId: games.id, value: 100, absorbsRemainder: true }],
|
||||
});
|
||||
actions.addRecurringCost(d, {
|
||||
name: "Rent",
|
||||
archived: false,
|
||||
amount: { amount: 8200, currency: "DKK" },
|
||||
startDate: "2026-06",
|
||||
recurrence: { kind: "everyNMonths", n: 1 },
|
||||
anchoring: "calendar",
|
||||
splitMode: "percent",
|
||||
splits: [{ postId: rent.id, value: 100, absorbsRemainder: true }],
|
||||
});
|
||||
actions.addRecurringCost(d, {
|
||||
name: "Veg box",
|
||||
archived: false,
|
||||
amount: { amount: 175, currency: "DKK" },
|
||||
startDate: "2026-06-06",
|
||||
recurrence: { kind: "everyNWeeks", n: 2, weekday: 6 },
|
||||
anchoring: "calendar",
|
||||
splitMode: "percent",
|
||||
splits: [{ postId: food.id, value: 100, absorbsRemainder: true }],
|
||||
});
|
||||
// Rebases on the actual charge date rather than the calendar, so an
|
||||
// off-schedule payment moves the whole series — the phone-bill behaviour.
|
||||
actions.addRecurringCost(d, {
|
||||
name: "Car insurance",
|
||||
archived: false,
|
||||
amount: { amount: 349, currency: "DKK" },
|
||||
startDate: "2026-08-14",
|
||||
recurrence: { kind: "everyNDays", n: 30 },
|
||||
anchoring: "lastCharge",
|
||||
splitMode: "percent",
|
||||
splits: [{ postId: transport.id, value: 100, absorbsRemainder: true }],
|
||||
});
|
||||
|
||||
// One confirmed occurrence, so a screenshot shows both states at once: the
|
||||
// expected band lists what is still due, and the fold's two tracks have
|
||||
// already reconverged for this one.
|
||||
actions.confirmOccurrence(d, wowSub.id, "2026-06-05", { date: "2026-06-05" });
|
||||
|
||||
const buy = (date: string, description: string, amount: number, currency: "DKK" | "EUR" | "USD", postId: string, note?: string) =>
|
||||
actions.addPurchase(d, {
|
||||
date, description, note,
|
||||
@@ -52,7 +104,12 @@ const buy = (date: string, description: string, amount: number, currency: "DKK"
|
||||
|
||||
for (let m = 1; m <= 9; m++) {
|
||||
const mm = String(m).padStart(2, "0");
|
||||
buy(`2026-${mm}-01`, "Rent", 8200, "DKK", rent.id);
|
||||
// The "Rent" recurring cost above starts 2026-06 and is left unconfirmed,
|
||||
// so it already projects an expected charge for June onward. An ordinary
|
||||
// Rent purchase in those same months would double-charge the post in the
|
||||
// screenshots — one from `charges`, one from `expected` — so this ordinary
|
||||
// purchase only covers the months before the recurring cost exists.
|
||||
if (m < 6) buy(`2026-${mm}-01`, "Rent", 8200, "DKK", rent.id);
|
||||
buy(`2026-${mm}-04`, "Groceries", 1180 + m * 37, "DKK", food.id);
|
||||
buy(`2026-${mm}-18`, "Groceries", 1420 - m * 12, "DKK", food.id);
|
||||
buy(`2026-${mm}-06`, "Commuter pass", 1100, "DKK", transport.id);
|
||||
|
||||
@@ -53,6 +53,7 @@ function dataset(overrides: Partial<Dataset> = {}): Dataset {
|
||||
{ id: "2026-01", income: { amount: 20000, currency: "DKK" }, ruleOverrides: {} },
|
||||
],
|
||||
purchases: [],
|
||||
recurring: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -204,6 +205,7 @@ describe("the fold across a rule change", () => {
|
||||
ruleOverrides: {},
|
||||
})),
|
||||
purchases: [],
|
||||
recurring: [],
|
||||
};
|
||||
|
||||
test("the allocation steps up in the version's month", () => {
|
||||
|
||||
@@ -4,9 +4,11 @@ import {
|
||||
sliceAmountForMonth,
|
||||
chargesForPurchaseInMonth,
|
||||
chargesForMonth,
|
||||
expectedForMonth,
|
||||
} from "./charges.ts";
|
||||
import { roundMoney } from "./money.ts";
|
||||
import type { Dataset, FxRate, Purchase } from "./types.ts";
|
||||
import { occurrencesByMonth } from "./occurrences.ts";
|
||||
import type { Dataset, FxRate, Purchase, RecurringCost } from "./types.ts";
|
||||
|
||||
/** Digits for the currencies these tests use; all real-world 2dp. */
|
||||
const TEST_CURRENCIES = [
|
||||
@@ -219,6 +221,7 @@ describe("chargesForMonth", () => {
|
||||
purchase({ id: "b", total: { amount: 50, currency: "DKK" } }),
|
||||
purchase({ id: "c", date: "2026-10-01" }),
|
||||
],
|
||||
recurring: [],
|
||||
};
|
||||
const charges = chargesForMonth(data, "2026-09");
|
||||
expect(charges.get("food")).toBe(250);
|
||||
@@ -233,7 +236,100 @@ describe("chargesForMonth", () => {
|
||||
posts: [],
|
||||
months: [],
|
||||
purchases: [],
|
||||
recurring: [],
|
||||
};
|
||||
expect(chargesForMonth(data, "2026-09").size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("expectedForMonth", () => {
|
||||
function dataset(cost: RecurringCost, purchases: Purchase[] = []): Dataset {
|
||||
return {
|
||||
settings: { baseCurrency: "DKK", foldStartMonth: "2026-01", schemaVersion: 7, digits: 2 },
|
||||
currencies: [
|
||||
{ code: "DKK", symbol: "kr", name: "Danish krone" },
|
||||
{ code: "USD", symbol: "$", name: "US dollar" },
|
||||
],
|
||||
fxRates: [{ currency: "USD", baseUnitsPerOne: 7, updatedAt: "2026-01-01", source: "manual" }],
|
||||
posts: [],
|
||||
months: [],
|
||||
purchases,
|
||||
recurring: [cost],
|
||||
};
|
||||
}
|
||||
|
||||
const rent: RecurringCost = {
|
||||
id: "r1",
|
||||
name: "Rent",
|
||||
order: 0,
|
||||
archived: false,
|
||||
amount: { amount: 8000, currency: "DKK" },
|
||||
startDate: "2026-01",
|
||||
recurrence: { kind: "everyNMonths", n: 1 },
|
||||
anchoring: "calendar",
|
||||
splitMode: "percent",
|
||||
splits: [{ postId: "housing", value: 100, absorbsRemainder: true }],
|
||||
};
|
||||
|
||||
test("an unconfirmed occurrence charges its post", () => {
|
||||
const data = dataset(rent);
|
||||
const expected = expectedForMonth(data, occurrencesByMonth(data, "2026-01").get("2026-01")!);
|
||||
expect(expected.get("housing")).toBe(8000);
|
||||
});
|
||||
|
||||
test("a CONFIRMED occurrence contributes nothing — it is a real purchase now", () => {
|
||||
const confirmed: Purchase = {
|
||||
id: "p1",
|
||||
date: "2026-01",
|
||||
description: "Rent",
|
||||
total: { amount: 8000, currency: "DKK" },
|
||||
splitMode: "percent",
|
||||
splits: [{ postId: "housing", value: 100, absorbsRemainder: true }],
|
||||
schedule: null,
|
||||
source: { recurringId: "r1", occurrenceDate: "2026-01" },
|
||||
};
|
||||
const data = dataset(rent, [confirmed]);
|
||||
const expected = expectedForMonth(data, occurrencesByMonth(data, "2026-01").get("2026-01")!);
|
||||
expect(expected.get("housing")).toBeUndefined();
|
||||
});
|
||||
|
||||
test("a split cost divides exactly, with the remainder absorbed", () => {
|
||||
const split = {
|
||||
...rent,
|
||||
amount: { amount: 1000, currency: "DKK" },
|
||||
splits: [
|
||||
{ postId: "a", value: 33.333, absorbsRemainder: false },
|
||||
{ postId: "b", value: 33.333, absorbsRemainder: false },
|
||||
{ postId: "c", value: 33.334, absorbsRemainder: true },
|
||||
],
|
||||
};
|
||||
const data = dataset(split);
|
||||
const expected = expectedForMonth(data, occurrencesByMonth(data, "2026-01").get("2026-01")!);
|
||||
const total = ["a", "b", "c"].reduce((sum, id) => sum + expected.get(id)!, 0);
|
||||
expect(total).toBe(1000);
|
||||
});
|
||||
|
||||
test("a foreign-currency cost converts to base", () => {
|
||||
const inUsd = { ...rent, amount: { amount: 15, currency: "USD" } };
|
||||
const data = dataset(inUsd);
|
||||
const expected = expectedForMonth(data, occurrencesByMonth(data, "2026-01").get("2026-01")!);
|
||||
expect(expected.get("housing")).toBe(105);
|
||||
});
|
||||
|
||||
test("two occurrences in one month sum", () => {
|
||||
const fortnightly = {
|
||||
...rent,
|
||||
amount: { amount: 100, currency: "DKK" },
|
||||
startDate: "2026-01-05",
|
||||
recurrence: { kind: "everyNDays" as const, n: 14 },
|
||||
};
|
||||
const data = dataset(fortnightly);
|
||||
const january = occurrencesByMonth(data, "2026-01").get("2026-01")!;
|
||||
expect(january.length).toBe(2);
|
||||
expect(expectedForMonth(data, january).get("housing")).toBe(200);
|
||||
});
|
||||
|
||||
test("no occurrences means an empty map, not zeros", () => {
|
||||
expect(expectedForMonth(dataset(rent), []).size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
+123
-24
@@ -1,6 +1,7 @@
|
||||
import { toBase } from "./fx.ts";
|
||||
import { distributeByAmount, distributeByWeight, roundMoney } from "./money.ts";
|
||||
import { compareMonths, monthOf } from "./months.ts";
|
||||
import type { Occurrence } from "./occurrences.ts";
|
||||
import type {
|
||||
Currency,
|
||||
Dataset,
|
||||
@@ -9,6 +10,7 @@ import type {
|
||||
MonthId,
|
||||
PostId,
|
||||
Purchase,
|
||||
Split,
|
||||
} from "./types.ts";
|
||||
|
||||
export interface Charge {
|
||||
@@ -17,27 +19,75 @@ export interface Charge {
|
||||
amount: number;
|
||||
}
|
||||
|
||||
export function remainderIndexOf(purchase: Purchase): number {
|
||||
const index = purchase.splits.findIndex((s) => s.absorbsRemainder);
|
||||
/**
|
||||
* Exactly one split absorbs the remainder, which is what makes the parts sum
|
||||
* exactly to the total. `label` names the owning record so the message is
|
||||
* actionable — this is reachable from a hand-edited import.
|
||||
*/
|
||||
export function remainderIndexIn(splits: readonly Split[], label: string): number {
|
||||
const index = splits.findIndex((s) => s.absorbsRemainder);
|
||||
if (index === -1) {
|
||||
throw new Error(
|
||||
`Purchase ${purchase.id} has no split flagged absorbsRemainder`,
|
||||
);
|
||||
throw new Error(`${label} has no split flagged absorbsRemainder`);
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
export function remainderIndexOf(purchase: Purchase): number {
|
||||
return remainderIndexIn(purchase.splits, `Purchase ${purchase.id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* How an amount divides across splits, in the currency the amount is in.
|
||||
* Parts sum exactly to the total.
|
||||
*/
|
||||
export function splitParts(
|
||||
total: number,
|
||||
splits: readonly Split[],
|
||||
splitMode: "percent" | "fixed",
|
||||
remainderIndex: number,
|
||||
digits: number,
|
||||
): number[] {
|
||||
const values = splits.map((s) => s.value);
|
||||
return splitMode === "percent"
|
||||
? distributeByWeight(total, values, remainderIndex, digits)
|
||||
: distributeByAmount(total, values, remainderIndex, digits);
|
||||
}
|
||||
|
||||
/**
|
||||
* How the whole purchase divides across its posts, in the purchase's own
|
||||
* currency. Parts sum exactly to the total.
|
||||
*/
|
||||
export function splitPartsOfTotal(purchase: Purchase, digits: number): number[] {
|
||||
const index = remainderIndexOf(purchase);
|
||||
const values = purchase.splits.map((s) => s.value);
|
||||
const total = purchase.total.amount;
|
||||
return purchase.splitMode === "percent"
|
||||
? distributeByWeight(total, values, index, digits)
|
||||
: distributeByAmount(total, values, index, digits);
|
||||
return splitParts(
|
||||
purchase.total.amount,
|
||||
purchase.splits,
|
||||
purchase.splitMode,
|
||||
remainderIndexOf(purchase),
|
||||
digits,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts `amount` to base and distributes it across `splits` in the
|
||||
* proportions `weights` describes.
|
||||
*
|
||||
* Shared by real charges and expected ones deliberately. Two implementations
|
||||
* could round differently or absorb the remainder differently, and the gap
|
||||
* would surface as a penny of phantom drift between the fold's two tracks —
|
||||
* a difference with no cause the owner could ever find.
|
||||
*/
|
||||
export function distributeToSplits(
|
||||
amount: Money,
|
||||
splits: readonly Split[],
|
||||
weights: number[],
|
||||
remainderIndex: number,
|
||||
baseCurrency: Currency,
|
||||
rates: FxRate[],
|
||||
digits: number,
|
||||
): Charge[] {
|
||||
const base = toBase(amount, baseCurrency, rates, digits);
|
||||
const parts = distributeByWeight(base, weights, remainderIndex, digits);
|
||||
return splits.map((split, i) => ({ postId: split.postId, amount: parts[i]! }));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -76,19 +126,18 @@ export function chargesForPurchaseInMonth(
|
||||
const slice = sliceAmountForMonth(purchase, monthId);
|
||||
if (!slice) return [];
|
||||
|
||||
const sliceBase = toBase(slice, baseCurrency, rates, digits);
|
||||
const index = remainderIndexOf(purchase);
|
||||
// Split proportions are computed in the PURCHASE's own currency, then the
|
||||
// month's slice is distributed to those proportions in the BASE currency.
|
||||
// One setting covers both roundings now; it used to be two lookups, one per
|
||||
// currency, which is the only thing that changed here.
|
||||
const weights = splitPartsOfTotal(purchase, digits);
|
||||
const parts = distributeByWeight(sliceBase, weights, index, digits);
|
||||
|
||||
return purchase.splits.map((split, i) => ({
|
||||
postId: split.postId,
|
||||
amount: parts[i]!,
|
||||
}));
|
||||
// Split proportions come from the WHOLE purchase, so a financed purchase
|
||||
// divides each slice in the same ratio as the purchase as a whole — which is
|
||||
// what makes splits and finance plans compose without a special case.
|
||||
return distributeToSplits(
|
||||
slice,
|
||||
purchase.splits,
|
||||
splitPartsOfTotal(purchase, digits),
|
||||
remainderIndexOf(purchase),
|
||||
baseCurrency,
|
||||
rates,
|
||||
digits,
|
||||
);
|
||||
}
|
||||
|
||||
/** Total charged per post in `monthId`, in base currency. */
|
||||
@@ -116,3 +165,53 @@ export function chargesForMonth(
|
||||
}
|
||||
return totals;
|
||||
}
|
||||
|
||||
/**
|
||||
* Total EXPECTED per post in one month, in base currency.
|
||||
*
|
||||
* The mirror of `chargesForMonth` for money that has not moved yet. Confirmed
|
||||
* occurrences are skipped: they are ordinary purchases and `chargesForMonth`
|
||||
* has already counted them. Counting both would double every settled bill.
|
||||
*
|
||||
* Takes the month's occurrences rather than a MonthId because the fold
|
||||
* computes them all in one walk — see `occurrencesByMonth`.
|
||||
*/
|
||||
export function expectedForMonth(
|
||||
dataset: Dataset,
|
||||
occurrences: readonly Occurrence[],
|
||||
): Map<PostId, number> {
|
||||
const totals = new Map<PostId, number>();
|
||||
const digits = dataset.settings.digits;
|
||||
const byId = new Map(dataset.recurring.map((cost) => [cost.id, cost]));
|
||||
|
||||
for (const occurrence of occurrences) {
|
||||
if (occurrence.confirmedBy) continue;
|
||||
const cost = byId.get(occurrence.recurringId);
|
||||
if (!cost) continue;
|
||||
|
||||
const index = remainderIndexIn(cost.splits, `Recurring cost "${cost.name}"`);
|
||||
const weights = splitParts(
|
||||
cost.amount.amount,
|
||||
cost.splits,
|
||||
cost.splitMode,
|
||||
index,
|
||||
digits,
|
||||
);
|
||||
const charges = distributeToSplits(
|
||||
occurrence.amount,
|
||||
cost.splits,
|
||||
weights,
|
||||
index,
|
||||
dataset.settings.baseCurrency,
|
||||
dataset.fxRates,
|
||||
digits,
|
||||
);
|
||||
|
||||
for (const charge of charges) {
|
||||
const previous = totals.get(charge.postId) ?? 0;
|
||||
totals.set(charge.postId, roundMoney(previous + charge.amount, digits));
|
||||
}
|
||||
}
|
||||
|
||||
return totals;
|
||||
}
|
||||
|
||||
@@ -93,7 +93,9 @@ describe("the fold at zero decimals", () => {
|
||||
digits: DIGITS,
|
||||
},
|
||||
currencies: CURRENCIES,
|
||||
fxRates: [],
|
||||
fxRates: [
|
||||
{ currency: "USD", baseUnitsPerOne: 151.234567, updatedAt: "2026-01-01", source: "manual" },
|
||||
],
|
||||
posts: [
|
||||
{
|
||||
id: "food",
|
||||
@@ -103,26 +105,80 @@ describe("the fold at zero decimals", () => {
|
||||
currency: "JPY",
|
||||
rules: [{ from: "2026-01", rule: { kind: "percentOfIncome", percent: 33.333 } }],
|
||||
},
|
||||
// Never budgeted (rules: []) — allocation is always zero, so this
|
||||
// post's only figures are what the recurring cost below projects onto
|
||||
// it. Isolates the expected/projected track from the allocation math
|
||||
// "food" already covers.
|
||||
{
|
||||
id: "fun",
|
||||
name: "Fun",
|
||||
order: 1,
|
||||
archived: false,
|
||||
currency: "JPY",
|
||||
rules: [],
|
||||
},
|
||||
],
|
||||
months: [
|
||||
{ id: "2026-01", income: { amount: 10_000, currency: "JPY" }, ruleOverrides: {} },
|
||||
{ id: "2026-02", income: { amount: 10_000, currency: "JPY" }, ruleOverrides: {} },
|
||||
],
|
||||
purchases: [],
|
||||
// Unconfirmed on purpose: a confirmed occurrence becomes an ordinary
|
||||
// Purchase and would be counted as `charges`, not `expected` — the
|
||||
// wrong track for this guard. $7 at 151.234567 converts to a non-whole
|
||||
// 1058.641969, and split 3/4 (fixed, not percent — the OTHER split
|
||||
// mode) divides unevenly too, so both the FX conversion and the split
|
||||
// division are exercised at zero decimals.
|
||||
recurring: [
|
||||
{
|
||||
id: "gym",
|
||||
name: "Gym",
|
||||
order: 0,
|
||||
archived: false,
|
||||
amount: { amount: 7, currency: "USD" },
|
||||
startDate: "2026-01",
|
||||
recurrence: { kind: "everyNMonths", n: 1 },
|
||||
anchoring: "calendar",
|
||||
splitMode: "fixed",
|
||||
splits: [
|
||||
{ postId: "food", value: 3, absorbsRemainder: false },
|
||||
{ postId: "fun", value: 0, absorbsRemainder: true },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
test("no figure the fold produces has a fractional yen in it", () => {
|
||||
const fold = foldBalances(dataset(), "2026-02");
|
||||
for (const month of ["2026-01", "2026-02"]) {
|
||||
const figures = figuresFor(fold, "food", month);
|
||||
for (const [name, value] of Object.entries(figures)) {
|
||||
expect(Number.isInteger(value)).toBe(true);
|
||||
expect(`${month} ${name} = ${value}`).toBe(`${month} ${name} = ${Math.round(value)}`);
|
||||
for (const postId of ["food", "fun"]) {
|
||||
const figures = figuresFor(fold, postId, month);
|
||||
for (const [name, value] of Object.entries(figures)) {
|
||||
expect(Number.isInteger(value)).toBe(true);
|
||||
expect(`${postId} ${month} ${name} = ${value}`).toBe(
|
||||
`${postId} ${month} ${name} = ${Math.round(value)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(figuresFor(fold, "food", "2026-01").allocation).toBe(3333);
|
||||
expect(figuresFor(fold, "food", "2026-02").carriedIn).toBe(3333);
|
||||
|
||||
// The recurring cost's $7 converts to ¥1059, split 3/4 (fixed) onto
|
||||
// food/fun — a wrong implementation that rounds either the FX conversion
|
||||
// or the split at 2 decimals produces fractional yen here, which the
|
||||
// Number.isInteger sweep above would already fail; these pin the exact
|
||||
// whole-unit figures so a regression names the wrong number, not just
|
||||
// "not an integer".
|
||||
expect(figuresFor(fold, "food", "2026-01").expected).toBe(454);
|
||||
expect(figuresFor(fold, "food", "2026-01").projected).toBe(2879);
|
||||
expect(figuresFor(fold, "fun", "2026-01").expected).toBe(605);
|
||||
expect(figuresFor(fold, "fun", "2026-01").projected).toBe(-605);
|
||||
// Second month: the cost recurs, so the projected balance keeps
|
||||
// absorbing it — proof this is a running division, not a one-off.
|
||||
expect(figuresFor(fold, "food", "2026-02").projected).toBe(5758);
|
||||
expect(figuresFor(fold, "fun", "2026-02").projected).toBe(-1210);
|
||||
});
|
||||
|
||||
test("the month view's totals are whole units too", () => {
|
||||
@@ -130,6 +186,13 @@ describe("the fold at zero decimals", () => {
|
||||
expect(Number.isInteger(view.totalAllocation)).toBe(true);
|
||||
expect(Number.isInteger(view.unallocated)).toBe(true);
|
||||
expect(view.unallocated).toBe(10_000 - 3333);
|
||||
// The view embeds each post's expected/projected figures too — same
|
||||
// sweep as the fold test above, through the view's own aggregation path.
|
||||
for (const row of view.rows) {
|
||||
for (const [name, value] of Object.entries(row.figures)) {
|
||||
expect(Number.isInteger(value)).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -140,5 +203,13 @@ describe("the fold at zero decimals", () => {
|
||||
const twoPlaces = { ...dataset(), settings: { ...dataset().settings, digits: 2 } };
|
||||
const fold = foldBalances(twoPlaces, "2026-01");
|
||||
expect(figuresFor(fold, "food", "2026-01").allocation).toBe(3333.3);
|
||||
|
||||
// Same recurring cost, same $7 — at two decimals the FX conversion and
|
||||
// the fixed split both leave a fraction: 3333 → 3333.3, and the projected
|
||||
// track moves right along with it.
|
||||
expect(figuresFor(fold, "food", "2026-01").expected).toBe(453.7);
|
||||
expect(figuresFor(fold, "food", "2026-01").projected).toBe(2879.6);
|
||||
expect(figuresFor(fold, "fun", "2026-01").expected).toBe(604.94);
|
||||
expect(figuresFor(fold, "fun", "2026-01").projected).toBe(-604.94);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { addDays, fromDayOrdinal, toDayOrdinal, weekdayOf } from "./days.ts";
|
||||
|
||||
describe("day ordinals", () => {
|
||||
test("the epoch is day zero", () => {
|
||||
expect(toDayOrdinal("1970-01-01")).toBe(0);
|
||||
});
|
||||
|
||||
test("known dates convert to known ordinals", () => {
|
||||
expect(toDayOrdinal("1969-12-31")).toBe(-1);
|
||||
expect(toDayOrdinal("2000-03-01")).toBe(11017);
|
||||
expect(toDayOrdinal("2026-09-03")).toBe(20699);
|
||||
});
|
||||
|
||||
test("every date in a four-year window round-trips", () => {
|
||||
// 2024 is a leap year, 2025 and 2026 are not, so this covers both.
|
||||
for (let ordinal = toDayOrdinal("2024-01-01"); ordinal <= toDayOrdinal("2027-12-31"); ordinal++) {
|
||||
expect(toDayOrdinal(fromDayOrdinal(ordinal))).toBe(ordinal);
|
||||
}
|
||||
});
|
||||
|
||||
test("leap day exists in 2024 and not in 2026", () => {
|
||||
expect(fromDayOrdinal(toDayOrdinal("2024-02-28") + 1)).toBe("2024-02-29");
|
||||
expect(fromDayOrdinal(toDayOrdinal("2026-02-28") + 1)).toBe("2026-03-01");
|
||||
});
|
||||
|
||||
test("century rules: 1900 is not a leap year, 2000 is", () => {
|
||||
expect(fromDayOrdinal(toDayOrdinal("1900-02-28") + 1)).toBe("1900-03-01");
|
||||
expect(fromDayOrdinal(toDayOrdinal("2000-02-28") + 1)).toBe("2000-02-29");
|
||||
});
|
||||
|
||||
test("a month-only date is rejected, not silently treated as the first", () => {
|
||||
expect(() => toDayOrdinal("2026-09")).toThrow(/day-granular/);
|
||||
});
|
||||
|
||||
test("an impossible day is rejected", () => {
|
||||
expect(() => toDayOrdinal("2026-02-30")).toThrow();
|
||||
expect(() => toDayOrdinal("2026-13-01")).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("addDays", () => {
|
||||
test("crosses a month boundary", () => {
|
||||
expect(addDays("2026-01-30", 3)).toBe("2026-02-02");
|
||||
});
|
||||
|
||||
test("crosses a year boundary", () => {
|
||||
expect(addDays("2026-12-30", 5)).toBe("2027-01-04");
|
||||
});
|
||||
|
||||
test("a 28-day cycle lands on the same weekday every time", () => {
|
||||
let date = "2026-01-05";
|
||||
const weekday = weekdayOf(date);
|
||||
for (let i = 0; i < 13; i++) {
|
||||
date = addDays(date, 28);
|
||||
expect(weekdayOf(date)).toBe(weekday);
|
||||
}
|
||||
});
|
||||
|
||||
test("a 30-day cycle drifts through the calendar", () => {
|
||||
// The phone bill's shape: 30 days is not a month, so it walks backwards
|
||||
// through the month over a year.
|
||||
expect(addDays("2026-01-31", 30)).toBe("2026-03-02");
|
||||
});
|
||||
});
|
||||
|
||||
describe("weekdayOf", () => {
|
||||
test("the epoch was a Thursday", () => {
|
||||
expect(weekdayOf("1970-01-01")).toBe(4);
|
||||
});
|
||||
|
||||
test("known weekdays", () => {
|
||||
expect(weekdayOf("2026-09-03")).toBe(4); // Thursday
|
||||
expect(weekdayOf("2026-09-06")).toBe(0); // Sunday
|
||||
expect(weekdayOf("2026-09-07")).toBe(1); // Monday
|
||||
});
|
||||
|
||||
test("seven consecutive days cover every weekday exactly once", () => {
|
||||
const seen = new Set<number>();
|
||||
for (let i = 0; i < 7; i++) seen.add(weekdayOf(addDays("2026-09-03", i)));
|
||||
expect(seen.size).toBe(7);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import type { IsoDate } from "./types.ts";
|
||||
|
||||
/**
|
||||
* Day-level arithmetic, in the same character as `months.ts`: integer maths on
|
||||
* a day ordinal, never a `Date`.
|
||||
*
|
||||
* `months.ts` avoids `Date` to avoid timezones, and the reasoning applies here
|
||||
* with more force. "2026-03-29" names the same day everywhere; the instant a
|
||||
* `Date` would give it does not, and a recurrence that steps 28 days must not
|
||||
* gain or lose one at a DST boundary or in a different host timezone.
|
||||
*
|
||||
* Only the day-granular recurrences need this. `everyNMonths` stays on
|
||||
* `months.ts`, because rent occurs IN a month rather than on a date.
|
||||
*/
|
||||
|
||||
const DAY_DATE = /^(\d{4})-(\d{2})-(\d{2})$/;
|
||||
|
||||
function isLeapYear(year: number): boolean {
|
||||
return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
|
||||
}
|
||||
|
||||
function daysInMonth(year: number, month: number): number {
|
||||
if (month === 2) return isLeapYear(year) ? 29 : 28;
|
||||
return month === 4 || month === 6 || month === 9 || month === 11 ? 30 : 31;
|
||||
}
|
||||
|
||||
/**
|
||||
* Days since 1970-01-01, by Howard Hinnant's `days_from_civil`. Proleptic
|
||||
* Gregorian, exact for every date this app can hold, and pure integer maths.
|
||||
*
|
||||
* The algorithm shifts the year to start in March so that the leap day is the
|
||||
* LAST day of the year rather than an insertion in the middle, which is what
|
||||
* removes every special case from the day-of-year term.
|
||||
*/
|
||||
export function toDayOrdinal(date: IsoDate): number {
|
||||
const match = DAY_DATE.exec(date);
|
||||
if (!match) {
|
||||
throw new Error(`Not a day-granular IsoDate: ${date}`);
|
||||
}
|
||||
const year = Number(match[1]);
|
||||
const month = Number(match[2]);
|
||||
const day = Number(match[3]);
|
||||
if (month < 1 || month > 12) {
|
||||
throw new Error(`Invalid month in IsoDate: ${date}`);
|
||||
}
|
||||
if (day < 1 || day > daysInMonth(year, month)) {
|
||||
throw new Error(`Invalid day in IsoDate: ${date}`);
|
||||
}
|
||||
|
||||
const shifted = month <= 2 ? year - 1 : year;
|
||||
const era = Math.floor(shifted / 400);
|
||||
const yearOfEra = shifted - era * 400;
|
||||
const dayOfYear =
|
||||
Math.floor((153 * (month + (month > 2 ? -3 : 9)) + 2) / 5) + day - 1;
|
||||
const dayOfEra =
|
||||
yearOfEra * 365 +
|
||||
Math.floor(yearOfEra / 4) -
|
||||
Math.floor(yearOfEra / 100) +
|
||||
dayOfYear;
|
||||
return era * 146097 + dayOfEra - 719468;
|
||||
}
|
||||
|
||||
/** The exact inverse of `toDayOrdinal` (Hinnant's `civil_from_days`). */
|
||||
export function fromDayOrdinal(ordinal: number): IsoDate {
|
||||
const z = ordinal + 719468;
|
||||
const era = Math.floor(z / 146097);
|
||||
const dayOfEra = z - era * 146097;
|
||||
const yearOfEra = Math.floor(
|
||||
(dayOfEra -
|
||||
Math.floor(dayOfEra / 1460) +
|
||||
Math.floor(dayOfEra / 36524) -
|
||||
Math.floor(dayOfEra / 146096)) /
|
||||
365,
|
||||
);
|
||||
const year = yearOfEra + era * 400;
|
||||
const dayOfYear =
|
||||
dayOfEra -
|
||||
(365 * yearOfEra + Math.floor(yearOfEra / 4) - Math.floor(yearOfEra / 100));
|
||||
const shiftedMonth = Math.floor((5 * dayOfYear + 2) / 153);
|
||||
const day = dayOfYear - Math.floor((153 * shiftedMonth + 2) / 5) + 1;
|
||||
const month = shiftedMonth + (shiftedMonth < 10 ? 3 : -9);
|
||||
const calendarYear = month <= 2 ? year + 1 : year;
|
||||
|
||||
return `${String(calendarYear).padStart(4, "0")}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export function addDays(date: IsoDate, count: number): IsoDate {
|
||||
return fromDayOrdinal(toDayOrdinal(date) + count);
|
||||
}
|
||||
|
||||
/** 0 = Sunday. Day 0 of the ordinal was a Thursday, hence the + 4. */
|
||||
export function weekdayOf(date: IsoDate): number {
|
||||
const shifted = (toDayOrdinal(date) + 4) % 7;
|
||||
return shifted < 0 ? shifted + 7 : shifted;
|
||||
}
|
||||
+112
-1
@@ -1,5 +1,6 @@
|
||||
import { test, expect, describe } from "bun:test";
|
||||
import { foldBalances, figuresFor } from "./fold.ts";
|
||||
import { foldBalances, figuresFor, EMPTY_FIGURES } from "./fold.ts";
|
||||
import { createSeedDataset } from "./seed.ts";
|
||||
import type { Dataset, Post, Purchase } from "./types.ts";
|
||||
|
||||
/** Digits for the currencies these tests use; all real-world 2dp. */
|
||||
@@ -47,6 +48,7 @@ function dataset(posts: Post[], purchases: Purchase[]): Dataset {
|
||||
{ id: "2026-03", income: { amount: 10000, currency: "DKK" }, ruleOverrides: {} },
|
||||
],
|
||||
purchases,
|
||||
recurring: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -58,6 +60,9 @@ describe("envelope rollover", () => {
|
||||
allocation: 500,
|
||||
charges: 0,
|
||||
remaining: 500,
|
||||
expected: 0,
|
||||
projectedCarriedIn: 0,
|
||||
projected: 500,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -70,6 +75,9 @@ describe("envelope rollover", () => {
|
||||
allocation: 500,
|
||||
charges: 0,
|
||||
remaining: 700,
|
||||
expected: 0,
|
||||
projectedCarriedIn: 200,
|
||||
projected: 700,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -156,6 +164,9 @@ test("figuresFor returns zeroes for a month outside the fold", () => {
|
||||
allocation: 0,
|
||||
charges: 0,
|
||||
remaining: 0,
|
||||
expected: 0,
|
||||
projectedCarriedIn: 0,
|
||||
projected: 0,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -174,3 +185,103 @@ describe("the empty-figures fallback", () => {
|
||||
expect(figuresFor(fold, "another-missing-post", "2026-01").allocation).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
function datasetWithRent(): Dataset {
|
||||
return {
|
||||
settings: { baseCurrency: "DKK", foldStartMonth: "2026-01", schemaVersion: 7, digits: 2 },
|
||||
currencies: [{ code: "DKK", symbol: "kr", name: "Danish krone" }],
|
||||
fxRates: [],
|
||||
posts: [
|
||||
{
|
||||
id: "housing",
|
||||
name: "Housing",
|
||||
order: 0,
|
||||
archived: false,
|
||||
currency: "DKK",
|
||||
rules: [{ from: "2026-01", rule: { kind: "fixed", amount: { amount: 10000, currency: "DKK" } } }],
|
||||
},
|
||||
],
|
||||
months: [],
|
||||
purchases: [],
|
||||
recurring: [
|
||||
{
|
||||
id: "rent",
|
||||
name: "Rent",
|
||||
order: 0,
|
||||
archived: false,
|
||||
amount: { amount: 8000, currency: "DKK" },
|
||||
startDate: "2026-01",
|
||||
recurrence: { kind: "everyNMonths", n: 1 },
|
||||
anchoring: "calendar",
|
||||
splitMode: "percent",
|
||||
splits: [{ postId: "housing", value: 100, absorbsRemainder: true }],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe("the projected track", () => {
|
||||
test("with no recurring costs, projected shadows remaining exactly", () => {
|
||||
// The behaviour-preservation guard. Every existing dataset must fold to
|
||||
// figures it already folded to, with the new track adding nothing.
|
||||
const data = createSeedDataset("2026-01");
|
||||
data.months.push({ id: "2026-01", income: { amount: 20000, currency: "DKK" }, ruleOverrides: {} });
|
||||
const fold = foldBalances(data, "2026-06");
|
||||
|
||||
for (const post of data.posts) {
|
||||
for (const month of ["2026-01", "2026-02", "2026-03", "2026-04", "2026-05", "2026-06"]) {
|
||||
const figures = figuresFor(fold, post.id, month);
|
||||
expect(figures.expected).toBe(0);
|
||||
expect(figures.projectedCarriedIn).toBe(figures.carriedIn);
|
||||
expect(figures.projected).toBe(figures.remaining);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("an unconfirmed occurrence lowers projected and leaves remaining alone", () => {
|
||||
const data = datasetWithRent();
|
||||
const figures = figuresFor(foldBalances(data, "2026-01"), "housing", "2026-01");
|
||||
|
||||
expect(figures.charges).toBe(0);
|
||||
expect(figures.expected).toBe(8000);
|
||||
expect(figures.remaining).toBe(10000);
|
||||
expect(figures.projected).toBe(2000);
|
||||
});
|
||||
|
||||
test("the projected shortfall carries forward on its own track", () => {
|
||||
const data = datasetWithRent();
|
||||
const fold = foldBalances(data, "2026-02");
|
||||
const february = figuresFor(fold, "housing", "2026-02");
|
||||
|
||||
expect(february.carriedIn).toBe(10000);
|
||||
expect(february.projectedCarriedIn).toBe(2000);
|
||||
expect(february.remaining).toBe(20000);
|
||||
// 2000 carried on the projected track + 10000 allocated - 8000 expected.
|
||||
expect(february.projected).toBe(4000);
|
||||
});
|
||||
|
||||
test("confirming every occurrence reconverges the two tracks", () => {
|
||||
const data = datasetWithRent();
|
||||
data.purchases.push({
|
||||
id: "p1",
|
||||
date: "2026-01",
|
||||
description: "Rent",
|
||||
total: { amount: 8000, currency: "DKK" },
|
||||
splitMode: "percent",
|
||||
splits: [{ postId: "housing", value: 100, absorbsRemainder: true }],
|
||||
schedule: null,
|
||||
source: { recurringId: "rent", occurrenceDate: "2026-01" },
|
||||
});
|
||||
|
||||
const figures = figuresFor(foldBalances(data, "2026-01"), "housing", "2026-01");
|
||||
expect(figures.charges).toBe(8000);
|
||||
expect(figures.expected).toBe(0);
|
||||
expect(figures.projected).toBe(figures.remaining);
|
||||
});
|
||||
|
||||
test("EMPTY_FIGURES carries the new fields", () => {
|
||||
expect(EMPTY_FIGURES.expected).toBe(0);
|
||||
expect(EMPTY_FIGURES.projected).toBe(0);
|
||||
expect(EMPTY_FIGURES.projectedCarriedIn).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
+38
-2
@@ -1,7 +1,8 @@
|
||||
import { allocationFor } from "./allocation.ts";
|
||||
import { chargesForMonth } from "./charges.ts";
|
||||
import { chargesForMonth, expectedForMonth } from "./charges.ts";
|
||||
import { roundMoney } from "./money.ts";
|
||||
import { compareMonths, monthRange } from "./months.ts";
|
||||
import { occurrencesByMonth } from "./occurrences.ts";
|
||||
import type { Dataset, MonthId, PostId } from "./types.ts";
|
||||
|
||||
export interface PostMonthFigures {
|
||||
@@ -10,6 +11,21 @@ export interface PostMonthFigures {
|
||||
charges: number;
|
||||
/** carriedIn + allocation - charges. Negative means overspent. */
|
||||
remaining: number;
|
||||
/**
|
||||
* Unconfirmed recurring occurrences landing in this month, base currency.
|
||||
* Money committed but not yet moved.
|
||||
*/
|
||||
expected: number;
|
||||
/** The previous month's `projected`. */
|
||||
projectedCarriedIn: number;
|
||||
/**
|
||||
* projectedCarriedIn + allocation - charges - expected.
|
||||
*
|
||||
* A second, parallel balance: what is left once commitments are counted.
|
||||
* It never influences `remaining`, so adding a recurring cost cannot move a
|
||||
* figure the owner already reconciled against their bank.
|
||||
*/
|
||||
projected: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -22,6 +38,9 @@ export const EMPTY_FIGURES: PostMonthFigures = Object.freeze({
|
||||
allocation: 0,
|
||||
charges: 0,
|
||||
remaining: 0,
|
||||
expected: 0,
|
||||
projectedCarriedIn: 0,
|
||||
projected: 0,
|
||||
});
|
||||
|
||||
export type Fold = Map<MonthId, Map<PostId, PostMonthFigures>>;
|
||||
@@ -35,31 +54,48 @@ export type Fold = Map<MonthId, Map<PostId, PostMonthFigures>>;
|
||||
* has to fold.
|
||||
*/
|
||||
export function foldBalances(dataset: Dataset, upToMonth: MonthId): Fold {
|
||||
const { foldStartMonth, baseCurrency } = dataset.settings;
|
||||
const { foldStartMonth } = dataset.settings;
|
||||
const baseDigits = dataset.settings.digits;
|
||||
const fold: Fold = new Map();
|
||||
|
||||
if (compareMonths(upToMonth, foldStartMonth) < 0) return fold;
|
||||
|
||||
// One walk for every recurring cost, because under `lastCharge` anchoring a
|
||||
// month's occurrences depend on every confirmation before it.
|
||||
const occurrences = occurrencesByMonth(dataset, upToMonth);
|
||||
|
||||
const carried = new Map<PostId, number>();
|
||||
const carriedProjected = new Map<PostId, number>();
|
||||
|
||||
for (const monthId of monthRange(foldStartMonth, upToMonth)) {
|
||||
const charges = chargesForMonth(dataset, monthId);
|
||||
const expected = expectedForMonth(dataset, occurrences.get(monthId) ?? []);
|
||||
const monthFigures = new Map<PostId, PostMonthFigures>();
|
||||
|
||||
for (const post of dataset.posts) {
|
||||
const carriedIn = carried.get(post.id) ?? 0;
|
||||
const projectedCarriedIn = carriedProjected.get(post.id) ?? 0;
|
||||
const allocation = allocationFor(dataset, post.id, monthId);
|
||||
const spent = charges.get(post.id) ?? 0;
|
||||
const due = expected.get(post.id) ?? 0;
|
||||
|
||||
const remaining = roundMoney(carriedIn + allocation - spent, baseDigits);
|
||||
const projected = roundMoney(
|
||||
projectedCarriedIn + allocation - spent - due,
|
||||
baseDigits,
|
||||
);
|
||||
|
||||
monthFigures.set(post.id, {
|
||||
carriedIn,
|
||||
allocation,
|
||||
charges: spent,
|
||||
remaining,
|
||||
expected: due,
|
||||
projectedCarriedIn,
|
||||
projected,
|
||||
});
|
||||
carried.set(post.id, remaining);
|
||||
carriedProjected.set(post.id, projected);
|
||||
}
|
||||
|
||||
fold.set(monthId, monthFigures);
|
||||
|
||||
@@ -3,9 +3,11 @@ export const DOMAIN_READY = true;
|
||||
export * from "./types.ts";
|
||||
export * from "./money.ts";
|
||||
export * from "./months.ts";
|
||||
export * from "./days.ts";
|
||||
export * from "./fx.ts";
|
||||
export * from "./allocation.ts";
|
||||
export * from "./charges.ts";
|
||||
export * from "./occurrences.ts";
|
||||
export * from "./fold.ts";
|
||||
export * from "./views.ts";
|
||||
export * from "./seed.ts";
|
||||
|
||||
@@ -108,3 +108,12 @@ test("monthOf still rejects out-of-range and malformed month-only values", () =>
|
||||
expect(() => monthOf("2026-00")).toThrow(/2026-00/);
|
||||
expect(() => monthOf("2026-9")).toThrow(/2026-9/);
|
||||
});
|
||||
|
||||
// Residual C1: the regex used to be `/^(\d{4})-(\d{2})/` with no trailing
|
||||
// anchor, so it matched a "YYYY-MM" PREFIX of a longer string and silently
|
||||
// dropped the rest — "2026-091" read back as September 2026. Such a value
|
||||
// would then be un-re-importable, since PURCHASE_DATE (src/export/json.ts)
|
||||
// rejects it outright.
|
||||
test("monthOf rejects a value that is only a prefix match, not a full one", () => {
|
||||
expect(() => monthOf("2026-091")).toThrow(/2026-091/);
|
||||
});
|
||||
|
||||
@@ -23,8 +23,14 @@ function fromOrdinal(ordinal: number): MonthId {
|
||||
return format(Math.floor(ordinal / 12), (ordinal % 12) + 1);
|
||||
}
|
||||
|
||||
// Anchored at both ends: "YYYY-MM" or "YYYY-MM-DD" only, never a prefix of
|
||||
// something longer. Dual acceptance (with or without a day) is deliberate and
|
||||
// load-bearing — do not narrow it to one form. An unanchored match once let
|
||||
// "2026-091" through as September 2026, silently dropping the trailing "1".
|
||||
const MONTH_OF_DATE = /^(\d{4})-(\d{2})(?:-\d{2})?$/;
|
||||
|
||||
export function monthOf(date: IsoDate): MonthId {
|
||||
const match = /^(\d{4})-(\d{2})/.exec(date);
|
||||
const match = MONTH_OF_DATE.exec(date);
|
||||
if (!match) throw new Error(`Invalid IsoDate: ${date}`);
|
||||
const month = Number(match[2]);
|
||||
if (month < 1 || month > 12) throw new Error(`Invalid IsoDate: ${date}`);
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { occurrencesByMonth, occurrencesOf, stepFrom } from "./occurrences.ts";
|
||||
import type { Dataset, Purchase, RecurringCost } from "./types.ts";
|
||||
|
||||
function cost(overrides: Partial<RecurringCost> = {}): RecurringCost {
|
||||
return {
|
||||
id: "r1",
|
||||
name: "Rent",
|
||||
order: 0,
|
||||
archived: false,
|
||||
amount: { amount: 8000, currency: "DKK" },
|
||||
startDate: "2026-01",
|
||||
recurrence: { kind: "everyNMonths", n: 1 },
|
||||
anchoring: "calendar",
|
||||
splitMode: "percent",
|
||||
splits: [{ postId: "housing", value: 100, absorbsRemainder: true }],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function confirmation(occurrenceDate: string, paidOn: string, id = "p1"): Purchase {
|
||||
return {
|
||||
id,
|
||||
date: paidOn,
|
||||
description: "Rent",
|
||||
total: { amount: 8000, currency: "DKK" },
|
||||
splitMode: "percent",
|
||||
splits: [{ postId: "housing", value: 100, absorbsRemainder: true }],
|
||||
schedule: null,
|
||||
source: { recurringId: "r1", occurrenceDate },
|
||||
};
|
||||
}
|
||||
|
||||
const none = new Map<string, Purchase>();
|
||||
|
||||
describe("stepFrom", () => {
|
||||
test("everyNMonths steps whole months and stays month-granular", () => {
|
||||
expect(stepFrom("2026-01", { kind: "everyNMonths", n: 1 })).toBe("2026-02");
|
||||
expect(stepFrom("2026-11", { kind: "everyNMonths", n: 3 })).toBe("2027-02");
|
||||
});
|
||||
|
||||
test("everyNMonths from a day-granular date returns the month", () => {
|
||||
// Reachable under lastCharge anchoring, where the step runs from an actual
|
||||
// purchase date.
|
||||
expect(stepFrom("2026-01-17", { kind: "everyNMonths", n: 1 })).toBe("2026-02");
|
||||
});
|
||||
|
||||
test("everyNDays steps days", () => {
|
||||
expect(stepFrom("2026-01-05", { kind: "everyNDays", n: 28 })).toBe("2026-02-02");
|
||||
expect(stepFrom("2026-01-31", { kind: "everyNDays", n: 30 })).toBe("2026-03-02");
|
||||
});
|
||||
|
||||
test("everyNWeeks lands on the named weekday", () => {
|
||||
// 2026-09-03 is a Thursday (4). Two weeks on is 2026-09-17, also Thursday.
|
||||
expect(stepFrom("2026-09-03", { kind: "everyNWeeks", n: 2, weekday: 4 })).toBe("2026-09-17");
|
||||
});
|
||||
|
||||
test("everyNWeeks snaps forward when the cursor is off-weekday", () => {
|
||||
// Reachable under lastCharge: paid on a Saturday, but the series is Thursdays.
|
||||
// 2026-09-05 is a Saturday; +1 week is 2026-09-12 (Saturday), snapping
|
||||
// forward to the next Thursday gives 2026-09-17.
|
||||
expect(stepFrom("2026-09-05", { kind: "everyNWeeks", n: 1, weekday: 4 })).toBe("2026-09-17");
|
||||
});
|
||||
});
|
||||
|
||||
describe("occurrencesOf — calendar anchoring", () => {
|
||||
test("a monthly cost yields one occurrence per month", () => {
|
||||
const dates = occurrencesOf(cost(), none, "2026-04").map((o) => o.date);
|
||||
expect(dates).toEqual(["2026-01", "2026-02", "2026-03", "2026-04"]);
|
||||
});
|
||||
|
||||
test("a 28-day cost puts two occurrences in some months", () => {
|
||||
const subscription = cost({
|
||||
startDate: "2026-01-05",
|
||||
recurrence: { kind: "everyNDays", n: 28 },
|
||||
});
|
||||
const dates = occurrencesOf(subscription, none, "2026-03").map((o) => o.date);
|
||||
expect(dates).toEqual(["2026-01-05", "2026-02-02", "2026-03-02", "2026-03-30"]);
|
||||
});
|
||||
|
||||
test("nothing is produced before the start date", () => {
|
||||
const later = cost({ startDate: "2026-03" });
|
||||
expect(occurrencesOf(later, none, "2026-04").map((o) => o.date)).toEqual([
|
||||
"2026-03",
|
||||
"2026-04",
|
||||
]);
|
||||
});
|
||||
|
||||
test("endedFrom stops the series, and the boundary date itself is excluded", () => {
|
||||
const ended = cost({ endedFrom: "2026-03" });
|
||||
expect(occurrencesOf(ended, none, "2026-06").map((o) => o.date)).toEqual([
|
||||
"2026-01",
|
||||
"2026-02",
|
||||
]);
|
||||
});
|
||||
|
||||
test("a confirmation does NOT move a calendar-anchored series", () => {
|
||||
const paidLate = new Map([["2026-01", confirmation("2026-01", "2026-01-28")]]);
|
||||
const dates = occurrencesOf(cost(), paidLate, "2026-03").map((o) => o.date);
|
||||
expect(dates).toEqual(["2026-01", "2026-02", "2026-03"]);
|
||||
});
|
||||
|
||||
test("archived does not stop projection", () => {
|
||||
// Archiving is a UI flag. Stopping a bill is endedFrom, because archiving
|
||||
// must not retroactively remove a past expected charge.
|
||||
const dates = occurrencesOf(cost({ archived: true }), none, "2026-02").map((o) => o.date);
|
||||
expect(dates).toEqual(["2026-01", "2026-02"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("occurrencesOf — confirmation", () => {
|
||||
test("a confirmed slot carries the purchase id", () => {
|
||||
const claimed = new Map([["2026-02", confirmation("2026-02", "2026-02", "pX")]]);
|
||||
const found = occurrencesOf(cost(), claimed, "2026-03");
|
||||
expect(found.map((o) => o.confirmedBy)).toEqual([null, "pX", null]);
|
||||
});
|
||||
|
||||
test("a slot is claimed by occurrenceDate, not by the purchase's own date", () => {
|
||||
// Paid on the 28th for the slot dated 2026-01. The slot is claimed; a new
|
||||
// one does not appear.
|
||||
const claimed = new Map([["2026-01", confirmation("2026-01", "2026-01-28", "pY")]]);
|
||||
const found = occurrencesOf(cost(), claimed, "2026-02");
|
||||
expect(found[0]!.confirmedBy).toBe("pY");
|
||||
expect(found[1]!.confirmedBy).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("occurrencesOf — lastCharge anchoring: the phone bill", () => {
|
||||
const phone = cost({
|
||||
id: "r1",
|
||||
name: "Phone",
|
||||
startDate: "2026-01-01",
|
||||
recurrence: { kind: "everyNDays", n: 30 },
|
||||
anchoring: "lastCharge",
|
||||
});
|
||||
|
||||
test("unconfirmed, it behaves exactly like a calendar series", () => {
|
||||
// Each occurrence steps from its own projected date: the projection assumes
|
||||
// bills are paid on time.
|
||||
const dates = occurrencesOf(phone, none, "2026-03").map((o) => o.date);
|
||||
expect(dates).toEqual(["2026-01-01", "2026-01-31", "2026-03-02"]);
|
||||
});
|
||||
|
||||
test("hitting the data cap early rebases the whole series", () => {
|
||||
// The slot sits at 2026-01-31. The cap is hit on the 12th, so the owner
|
||||
// confirms THAT slot with a purchase dated 2026-01-12. The next occurrence
|
||||
// is 30 days after the 12th, not after the 31st.
|
||||
const capHit = new Map([
|
||||
["2026-01-01", confirmation("2026-01-01", "2026-01-01", "pA")],
|
||||
["2026-01-31", confirmation("2026-01-31", "2026-01-12", "pB")],
|
||||
]);
|
||||
const dates = occurrencesOf(phone, capHit, "2026-03").map((o) => o.date);
|
||||
expect(dates).toEqual(["2026-01-01", "2026-01-31", "2026-02-11", "2026-03-13"]);
|
||||
});
|
||||
|
||||
test("the cap can be hit twice", () => {
|
||||
const twice = new Map([
|
||||
["2026-01-01", confirmation("2026-01-01", "2026-01-01", "pA")],
|
||||
["2026-01-31", confirmation("2026-01-31", "2026-01-12", "pB")],
|
||||
["2026-02-11", confirmation("2026-02-11", "2026-01-20", "pC")],
|
||||
]);
|
||||
const dates = occurrencesOf(phone, twice, "2026-03").map((o) => o.date);
|
||||
expect(dates).toEqual(["2026-01-01", "2026-01-31", "2026-02-11", "2026-02-19", "2026-03-21"]);
|
||||
});
|
||||
|
||||
test("deleting the confirming purchase un-confirms the slot and restores the series", () => {
|
||||
// CONTROLLER RULING: the brief's original version of this test was
|
||||
// byte-identical to "unconfirmed, it behaves exactly like a calendar
|
||||
// series" above and asserted nothing new. Rewritten to actually contrast
|
||||
// the two states: walk WITH the capHit confirmations (rebased dates),
|
||||
// then walk the SAME cost with an empty map (as if the confirming
|
||||
// purchase had been deleted) and assert the dates differ and match the
|
||||
// plain unconfirmed series.
|
||||
const capHit = new Map([
|
||||
["2026-01-01", confirmation("2026-01-01", "2026-01-01", "pA")],
|
||||
["2026-01-31", confirmation("2026-01-31", "2026-01-12", "pB")],
|
||||
]);
|
||||
const rebased = occurrencesOf(phone, capHit, "2026-03").map((o) => o.date);
|
||||
expect(rebased).toEqual(["2026-01-01", "2026-01-31", "2026-02-11", "2026-03-13"]);
|
||||
|
||||
// Deleting the confirming purchase leaves no entry in the confirmations
|
||||
// map for that slot — this is what an empty map models here, since the
|
||||
// slot in question (2026-01-31) is the only one whose confirmation
|
||||
// affects downstream dates.
|
||||
const afterDelete = occurrencesOf(phone, none, "2026-03").map((o) => o.date);
|
||||
expect(afterDelete).not.toEqual(rebased);
|
||||
expect(afterDelete).toEqual(["2026-01-01", "2026-01-31", "2026-03-02"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("occurrencesOf — lastCharge anchoring: a monthly bill paid early (I3)", () => {
|
||||
const rent = cost({
|
||||
startDate: "2026-08",
|
||||
recurrence: { kind: "everyNMonths", n: 1 },
|
||||
anchoring: "lastCharge",
|
||||
});
|
||||
|
||||
test("paying a few days before month-end does not throw or rebase", () => {
|
||||
// The slot is 2026-09; the owner pays on the 28th of August, four days
|
||||
// early. That is completely ordinary and must not move the series
|
||||
// backwards or make it fail to advance.
|
||||
const paidEarly = new Map([["2026-09", confirmation("2026-09", "2026-08-28", "pE")]]);
|
||||
const dates = occurrencesOf(rent, paidEarly, "2026-10").map((o) => o.date);
|
||||
expect(dates).toEqual(["2026-08", "2026-09", "2026-10"]);
|
||||
});
|
||||
|
||||
test("paying after the month it was for rebases forward, same as the phone bill", () => {
|
||||
// Paid on 2026-09-05 for the 2026-08 slot: the confirmation's month is
|
||||
// AFTER the cursor, so (mirroring the day-granular cap-hit case) the
|
||||
// series rebases from there.
|
||||
const paidLate = new Map([["2026-08", confirmation("2026-08", "2026-09-05", "pL")]]);
|
||||
const dates = occurrencesOf(rent, paidLate, "2026-11").map((o) => o.date);
|
||||
expect(dates).toEqual(["2026-08", "2026-10", "2026-11"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("occurrencesOf — termination", () => {
|
||||
test("a confirmation dated absurdly early throws rather than walking backwards", () => {
|
||||
// The ONLY way a valid recurrence fails to advance. Under lastCharge the
|
||||
// step runs from the confirmation's own date, so a payment recorded more
|
||||
// than n days before the slot it claims would move the cursor backwards.
|
||||
// A visible error beats a fold that silently emits duplicate months.
|
||||
const phone = cost({
|
||||
startDate: "2026-01-01",
|
||||
recurrence: { kind: "everyNDays", n: 30 },
|
||||
anchoring: "lastCharge",
|
||||
});
|
||||
const outOfOrder = new Map([
|
||||
["2026-01-31", confirmation("2026-01-31", "2025-11-02", "pBad")],
|
||||
]);
|
||||
expect(() => occurrencesOf(phone, outOfOrder, "2026-03")).toThrow(/did not advance/);
|
||||
});
|
||||
|
||||
test("a recurrence that cannot advance throws rather than hanging", () => {
|
||||
const broken = cost({ recurrence: { kind: "everyNDays", n: 0 }, startDate: "2026-01-01" });
|
||||
expect(() => occurrencesOf(broken, none, "2026-02")).toThrow(/did not advance/);
|
||||
});
|
||||
|
||||
test("a negative step throws too", () => {
|
||||
const backwards = cost({ recurrence: { kind: "everyNDays", n: -7 }, startDate: "2026-01-01" });
|
||||
expect(() => occurrencesOf(backwards, none, "2026-02")).toThrow(/did not advance/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("occurrencesByMonth", () => {
|
||||
function dataset(recurring: RecurringCost[], purchases: Purchase[] = []): Dataset {
|
||||
return {
|
||||
settings: { baseCurrency: "DKK", foldStartMonth: "2026-01", schemaVersion: 7, digits: 2 },
|
||||
currencies: [{ code: "DKK", symbol: "kr", name: "Danish krone" }],
|
||||
fxRates: [],
|
||||
posts: [],
|
||||
months: [],
|
||||
purchases,
|
||||
recurring,
|
||||
};
|
||||
}
|
||||
|
||||
test("groups every cost's occurrences by the month they land in", () => {
|
||||
const byMonth = occurrencesByMonth(
|
||||
dataset([
|
||||
cost({ id: "rent", startDate: "2026-01" }),
|
||||
cost({ id: "wow", startDate: "2026-01-05", recurrence: { kind: "everyNDays", n: 28 } }),
|
||||
]),
|
||||
"2026-02",
|
||||
);
|
||||
|
||||
expect(byMonth.get("2026-01")!.map((o) => o.recurringId).sort()).toEqual(["rent", "wow"]);
|
||||
expect(byMonth.get("2026-02")!.map((o) => o.recurringId).sort()).toEqual(["rent", "wow"]);
|
||||
});
|
||||
|
||||
test("a purchase confirming one cost does not claim another cost's slot", () => {
|
||||
const shared = confirmation("2026-01", "2026-01", "pZ");
|
||||
const byMonth = occurrencesByMonth(
|
||||
dataset([cost({ id: "r1" }), cost({ id: "r2" })], [shared]),
|
||||
"2026-01",
|
||||
);
|
||||
const january = byMonth.get("2026-01")!;
|
||||
expect(january.find((o) => o.recurringId === "r1")!.confirmedBy).toBe("pZ");
|
||||
expect(january.find((o) => o.recurringId === "r2")!.confirmedBy).toBeNull();
|
||||
});
|
||||
|
||||
test("months with no occurrence have no entry at all", () => {
|
||||
const byMonth = occurrencesByMonth(dataset([cost({ endedFrom: "2026-02" })]), "2026-04");
|
||||
expect(byMonth.has("2026-01")).toBe(true);
|
||||
expect(byMonth.has("2026-02")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,224 @@
|
||||
import { addDays, weekdayOf } from "./days.ts";
|
||||
import { addMonths, compareMonths, monthOf } from "./months.ts";
|
||||
import type {
|
||||
Dataset,
|
||||
IsoDate,
|
||||
Money,
|
||||
MonthId,
|
||||
Purchase,
|
||||
PurchaseId,
|
||||
Recurrence,
|
||||
RecurringCost,
|
||||
RecurringCostId,
|
||||
} from "./types.ts";
|
||||
|
||||
/**
|
||||
* One projected charge of one recurring cost.
|
||||
*
|
||||
* Occurrences are COMPUTED on every fold and never stored. The dataset holds
|
||||
* the rule and the confirmations; everything here is derived from those two.
|
||||
*/
|
||||
export interface Occurrence {
|
||||
recurringId: RecurringCostId;
|
||||
/**
|
||||
* The slot this walk generated. IDENTITY: a confirmation claims a slot by
|
||||
* this date, which is not necessarily the date it was paid.
|
||||
*/
|
||||
date: IsoDate;
|
||||
/** Expected amount, in the cost's own currency. */
|
||||
amount: Money;
|
||||
/** The purchase that confirmed this slot, or null while it is still expected. */
|
||||
confirmedBy: PurchaseId | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* ISO dates compare correctly as strings, and mixed granularity falls out
|
||||
* right: "2026-09" < "2026-09-17" < "2026-10", so a month-only date reads as
|
||||
* the start of that month. That is exactly what `endedFrom` needs when a
|
||||
* monthly cost is cancelled.
|
||||
*/
|
||||
function isBefore(a: IsoDate, b: IsoDate): boolean {
|
||||
return a < b;
|
||||
}
|
||||
|
||||
/** Moves forward to the next occurrence of `weekday`, or stays put if already on it. */
|
||||
function snapForward(date: IsoDate, weekday: number): IsoDate {
|
||||
const delta = (weekday - weekdayOf(date) + 7) % 7;
|
||||
return delta === 0 ? date : addDays(date, delta);
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the next occurrence falls, given the one before it.
|
||||
*
|
||||
* Adding a recurrence kind means adding a case here and a variant to
|
||||
* `Recurrence`. Nothing else in the app switches on the kind.
|
||||
*/
|
||||
export function stepFrom(date: IsoDate, recurrence: Recurrence): IsoDate {
|
||||
switch (recurrence.kind) {
|
||||
case "everyNMonths":
|
||||
// Month-granular in, month-granular out. `monthOf` also accepts a
|
||||
// day-granular date, which is what a lastCharge rebase supplies.
|
||||
return addMonths(monthOf(date), recurrence.n);
|
||||
case "everyNDays":
|
||||
return addDays(date, recurrence.n);
|
||||
case "everyNWeeks":
|
||||
return snapForward(addDays(date, 7 * recurrence.n), recurrence.weekday);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The purchases confirming one cost, keyed by the slot each one claims.
|
||||
*
|
||||
* Keyed by `source.occurrenceDate` rather than by `Purchase.date`, because the
|
||||
* slot is what a confirmation claims. The two differ whenever a bill is paid
|
||||
* off schedule.
|
||||
*/
|
||||
export function confirmationsFor(
|
||||
purchases: readonly Purchase[],
|
||||
recurringId: RecurringCostId,
|
||||
): Map<IsoDate, Purchase> {
|
||||
const found = new Map<IsoDate, Purchase>();
|
||||
for (const purchase of purchases) {
|
||||
if (purchase.source?.recurringId === recurringId) {
|
||||
found.set(purchase.source.occurrenceDate, purchase);
|
||||
}
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
/**
|
||||
* The date `occurrencesOf`'s walk would step from after confirming `cost`'s
|
||||
* slot `cursor` as having been paid on `confirmedDate` — the same rebase rule
|
||||
* the walk applies inline. Extracted so a caller can ask "would this pairing
|
||||
* advance the series" (see `wouldAdvancePast` below) without re-deriving it.
|
||||
*/
|
||||
function rebasedFrom(cost: RecurringCost, cursor: IsoDate, confirmedDate: IsoDate): IsoDate {
|
||||
if (cost.anchoring !== "lastCharge") return cursor;
|
||||
if (cost.recurrence.kind === "everyNMonths") {
|
||||
return compareMonths(monthOf(confirmedDate), cursor) > 0 ? confirmedDate : cursor;
|
||||
}
|
||||
return confirmedDate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether recording `cost`'s slot `slot` as paid on `recordedDate` would
|
||||
* leave `occurrencesOf` able to step strictly past `slot` — i.e. whether that
|
||||
* pairing is one the walk can survive at all, checked BEFORE a purchase
|
||||
* carrying it is ever written.
|
||||
*
|
||||
* Calendar anchoring always advances, because it never rebases from the
|
||||
* confirmation date; `everyNMonths` under `lastCharge` also always advances,
|
||||
* because it only rebases forward within the slot's own month. The one case
|
||||
* that can fail is a day/week-granular `lastCharge` cost recorded far enough
|
||||
* before its slot that stepping from `recordedDate` still lands at or before
|
||||
* `slot` — exactly the shape of `occurrencesOf`'s "did not advance" throw.
|
||||
*
|
||||
* Exported for `ExpectedBand.tsx`'s "Coming up" group, whose whole premise is
|
||||
* recording a slot's confirmation on a date OTHER than the slot itself
|
||||
* (today, because paying early). The walk's throw is correct — a step that
|
||||
* lands backwards really is bad data — so the band, which chooses
|
||||
* `recordedDate`, must not offer a row that would produce that pairing.
|
||||
*/
|
||||
export function wouldAdvancePast(cost: RecurringCost, slot: IsoDate, recordedDate: IsoDate): boolean {
|
||||
return isBefore(slot, stepFrom(rebasedFrom(cost, slot, recordedDate), cost.recurrence));
|
||||
}
|
||||
|
||||
/**
|
||||
* Every occurrence of one cost from its start through `upToMonth`, in order.
|
||||
*
|
||||
* A walk rather than a per-month query, because under `lastCharge` anchoring
|
||||
* where the series sits in a given month depends on every confirmation before
|
||||
* it. The walk always begins at `startDate` even when that precedes the fold
|
||||
* start, because the phase depends on the whole history; callers read only the
|
||||
* months they need. The cost is trivial — thirty-five years of a 28-day cycle
|
||||
* is about thirteen thousand integer additions.
|
||||
*
|
||||
* `upToMonth` comes from the caller. This module reads no clock, which is what
|
||||
* keeps `src/domain/` pure.
|
||||
*/
|
||||
export function occurrencesOf(
|
||||
cost: RecurringCost,
|
||||
confirmations: ReadonlyMap<IsoDate, Purchase>,
|
||||
upToMonth: MonthId,
|
||||
): Occurrence[] {
|
||||
const found: Occurrence[] = [];
|
||||
|
||||
// A monthly cost is month-granular throughout, even if its start date was
|
||||
// typed with a day. Rent occurs IN a month.
|
||||
let cursor =
|
||||
cost.recurrence.kind === "everyNMonths" ? monthOf(cost.startDate) : cost.startDate;
|
||||
|
||||
while (compareMonths(monthOf(cursor), upToMonth) <= 0) {
|
||||
if (cost.endedFrom && !isBefore(cursor, cost.endedFrom)) break;
|
||||
|
||||
const confirmation = confirmations.get(cursor) ?? null;
|
||||
found.push({
|
||||
recurringId: cost.id,
|
||||
date: cursor,
|
||||
amount: cost.amount,
|
||||
confirmedBy: confirmation?.id ?? null,
|
||||
});
|
||||
|
||||
// `lastCharge` measures from when the money ACTUALLY moved, which is what
|
||||
// makes an early charge rebase the series. An unconfirmed occurrence steps
|
||||
// from its own projected date: the projection assumes bills are paid on
|
||||
// time, and self-corrects as they are confirmed.
|
||||
//
|
||||
// For `everyNMonths`, "moved" means the confirmation's MONTH, and paying
|
||||
// a few days before the month turns over is ordinary — it must not rebase
|
||||
// the series backwards. So a month-granular step runs from whichever is
|
||||
// later, the cursor or the confirmation's month; an early payment within
|
||||
// the same month simply does not rebase. Day/week-granular kinds keep
|
||||
// stepping from the confirmation's exact date, unchanged.
|
||||
const from = confirmation ? rebasedFrom(cost, cursor, confirmation.date) : cursor;
|
||||
const next = stepFrom(from, cost.recurrence);
|
||||
|
||||
// The loop terminates only if the step strictly increases. `n >= 1` is
|
||||
// enforced at both write boundaries, so reaching this is a bug rather than
|
||||
// bad input — and a hang is the one failure mode worse than a visible
|
||||
// error. Never soften this into a break: silently dropping a bill would
|
||||
// put a wrong number on the screen, which AGENTS.md forbids outright.
|
||||
if (!isBefore(cursor, next)) {
|
||||
// Two causes, and the message names both because they need different
|
||||
// fixes. With a valid `n >= 1` a CALENDAR series can never reach this:
|
||||
// every kind adds at least one day. Only `lastCharge` can, and only for
|
||||
// a day/week-granular kind whose confirmation is dated far enough
|
||||
// before the slot it claims that stepping from it still lands at or
|
||||
// before the cursor — `everyNMonths` never reaches this branch, because
|
||||
// it never steps from a confirmation earlier than the cursor.
|
||||
throw new Error(
|
||||
`Recurring cost "${cost.name}" did not advance past ${cursor} (produced ${next}). ` +
|
||||
(confirmation
|
||||
? `Purchase ${confirmation.id} claims that slot but is dated ${confirmation.date}, ` +
|
||||
`which does not move the series past ${cursor} — correct its date so the series advances.`
|
||||
: `A recurrence must move strictly forward; check that n is at least 1.`),
|
||||
);
|
||||
}
|
||||
cursor = next;
|
||||
}
|
||||
|
||||
return found;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every cost's occurrences through `upToMonth`, grouped by the month they land
|
||||
* in. The fold calls this once and reads a month at a time.
|
||||
*/
|
||||
export function occurrencesByMonth(
|
||||
dataset: Dataset,
|
||||
upToMonth: MonthId,
|
||||
): Map<MonthId, Occurrence[]> {
|
||||
const byMonth = new Map<MonthId, Occurrence[]>();
|
||||
|
||||
for (const cost of dataset.recurring) {
|
||||
const confirmations = confirmationsFor(dataset.purchases, cost.id);
|
||||
for (const occurrence of occurrencesOf(cost, confirmations, upToMonth)) {
|
||||
const month = monthOf(occurrence.date);
|
||||
const existing = byMonth.get(month);
|
||||
if (existing) existing.push(occurrence);
|
||||
else byMonth.set(month, [occurrence]);
|
||||
}
|
||||
}
|
||||
|
||||
return byMonth;
|
||||
}
|
||||
+5
-1
@@ -1,7 +1,7 @@
|
||||
import { DEFAULT_DIGITS, SEED_CURRENCIES } from "./types.ts";
|
||||
import type { CurrencyDef, Dataset, FxRate, MonthId, Post } from "./types.ts";
|
||||
|
||||
export const SCHEMA_VERSION = 6;
|
||||
export const SCHEMA_VERSION = 7;
|
||||
|
||||
const SEED_POST_NAMES = ["Video Games", "Food", "Events and Social"] as const;
|
||||
|
||||
@@ -47,5 +47,9 @@ export function createSeedDataset(
|
||||
{ id: startMonth, income: { amount: 0, currency: "DKK" }, ruleOverrides: {} },
|
||||
],
|
||||
purchases: [],
|
||||
// A new dataset seeds no recurring costs. Unlike currencies and FX rates,
|
||||
// there is no sensible default bill — the app cannot guess what the owner
|
||||
// pays.
|
||||
recurring: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -105,6 +105,65 @@ export interface Schedule {
|
||||
cancelledFromMonth?: MonthId;
|
||||
}
|
||||
|
||||
export type RecurringCostId = string;
|
||||
|
||||
/**
|
||||
* How occurrence dates are generated.
|
||||
*
|
||||
* Extensible on purpose: a new variant here plus one case in `stepFrom`
|
||||
* (`occurrences.ts`) is the whole change — nothing else in the app switches on
|
||||
* this. `n` must be an integer >= 1 in every variant, which is what makes the
|
||||
* projection walk terminate; both write boundaries enforce it.
|
||||
*/
|
||||
export type Recurrence =
|
||||
| { kind: "everyNMonths"; n: number }
|
||||
| { kind: "everyNDays"; n: number }
|
||||
/** `weekday` is 0 = Sunday, matching `weekdayOf` in `days.ts`. */
|
||||
| { kind: "everyNWeeks"; n: number; weekday: number };
|
||||
|
||||
/**
|
||||
* Where the NEXT occurrence is measured from.
|
||||
*
|
||||
* `calendar` — from the previous slot, wherever it fell. Rent, and a
|
||||
* subscription that bills on its own schedule whatever you do.
|
||||
*
|
||||
* `lastCharge` — from the date the previous charge ACTUALLY happened. This is
|
||||
* the phone bill: hitting the data cap makes a charge happen early, and the
|
||||
* next 30 days run from there. The domain never learns what a data cap is;
|
||||
* confirming the occurrence early is the whole mechanism.
|
||||
*/
|
||||
export type Anchoring = "calendar" | "lastCharge";
|
||||
|
||||
export interface RecurringCost {
|
||||
id: RecurringCostId;
|
||||
name: string;
|
||||
order: number;
|
||||
/**
|
||||
* A UI flag ONLY. Projection is governed by `startDate` and `endedFrom`,
|
||||
* exactly as an archived post still folds — archiving must not retroactively
|
||||
* remove past expected charges, because that would move a historical figure.
|
||||
*/
|
||||
archived: boolean;
|
||||
/**
|
||||
* Expected amount, in its own currency, converted to base at fold time.
|
||||
*
|
||||
* A single mutable value with no dated series, unlike `Post.rules`. A rent
|
||||
* increase edits it, and history survives because every past occurrence is
|
||||
* a confirmed `Purchase` holding what was actually paid. Only unconfirmed
|
||||
* future occurrences move.
|
||||
*/
|
||||
amount: Money;
|
||||
/** The first occurrence. "YYYY-MM" for everyNMonths, "YYYY-MM-DD" otherwise. */
|
||||
startDate: IsoDate;
|
||||
/** Occurrences on or after this date are not projected. This is cancellation. */
|
||||
endedFrom?: IsoDate;
|
||||
recurrence: Recurrence;
|
||||
anchoring: Anchoring;
|
||||
/** Same shape and same invariant as Purchase: exactly one absorbsRemainder. */
|
||||
splitMode: "percent" | "fixed";
|
||||
splits: Split[];
|
||||
}
|
||||
|
||||
export interface Purchase {
|
||||
id: PurchaseId;
|
||||
date: IsoDate;
|
||||
@@ -117,6 +176,16 @@ export interface Purchase {
|
||||
splits: Split[];
|
||||
/** null means the whole total is charged in the month of `date`. */
|
||||
schedule: Schedule | null;
|
||||
/**
|
||||
* Present when this purchase confirms a projected occurrence.
|
||||
*
|
||||
* `occurrenceDate` is IDENTITY — the slot the projector generated, which
|
||||
* this purchase claims so the slot stops being projected. `Purchase.date` is
|
||||
* TRUTH — when the money moved. They differ whenever a bill is paid off
|
||||
* schedule, and under `lastCharge` anchoring it is `date`, not
|
||||
* `occurrenceDate`, that the next occurrence is measured from.
|
||||
*/
|
||||
source?: { recurringId: RecurringCostId; occurrenceDate: IsoDate };
|
||||
}
|
||||
|
||||
export interface FxRate {
|
||||
@@ -155,4 +224,10 @@ export interface Dataset {
|
||||
posts: Post[];
|
||||
months: Month[];
|
||||
purchases: Purchase[];
|
||||
/**
|
||||
* Bills that repeat. Their occurrences are COMPUTED on every fold and never
|
||||
* stored — the original design rejected materialised snapshots, and an
|
||||
* unbounded recurrence would otherwise need an arbitrary horizon.
|
||||
*/
|
||||
recurring: RecurringCost[];
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ const data: Dataset = {
|
||||
},
|
||||
],
|
||||
purchases: [spend("a", "food", 500, "2026-01-10"), spend("b", "games", 300, "2026-02-10")],
|
||||
recurring: [],
|
||||
};
|
||||
|
||||
describe("monthView", () => {
|
||||
@@ -121,6 +122,40 @@ describe("monthView", () => {
|
||||
"old",
|
||||
]);
|
||||
});
|
||||
|
||||
test("an archived post with only an unconfirmed occurrence still appears, with its projected figure (I4)", () => {
|
||||
// "old" has a zero-percent rule (no allocation) and no purchases at all,
|
||||
// so carriedIn/allocation/charges are all zero this month — its only
|
||||
// figure is the unconfirmed recurring occurrence's `expected`/`projected`.
|
||||
// Before I4's fix, hasActivity ignored those two fields and dropped the
|
||||
// row, even though ExpectedBand lists the same occurrence.
|
||||
const withRecurring: Dataset = {
|
||||
...data,
|
||||
posts: [...data.posts, post("old", 2, 0, true)],
|
||||
recurring: [
|
||||
{
|
||||
id: "r1",
|
||||
name: "Storage unit",
|
||||
order: 0,
|
||||
archived: false,
|
||||
amount: { amount: 400, currency: "DKK" },
|
||||
startDate: "2026-01",
|
||||
recurrence: { kind: "everyNMonths", n: 1 },
|
||||
anchoring: "calendar",
|
||||
splitMode: "percent",
|
||||
splits: [{ postId: "old", value: 100, absorbsRemainder: true }],
|
||||
},
|
||||
],
|
||||
};
|
||||
const view = monthView(withRecurring, "2026-01");
|
||||
const row = view.rows.find((r) => r.post.id === "old");
|
||||
expect(row).toBeDefined();
|
||||
expect(row!.figures.carriedIn).toBe(0);
|
||||
expect(row!.figures.allocation).toBe(0);
|
||||
expect(row!.figures.charges).toBe(0);
|
||||
expect(row!.figures.expected).toBe(400);
|
||||
expect(row!.figures.projected).toBe(-400);
|
||||
});
|
||||
});
|
||||
|
||||
describe("yearView", () => {
|
||||
|
||||
+11
-1
@@ -28,8 +28,18 @@ export interface MonthViewModel {
|
||||
}
|
||||
|
||||
function hasActivity(figures: PostMonthFigures): boolean {
|
||||
// `remaining` and `projected` are both fully derived from these five raw
|
||||
// fields, so checking all five is what keeps a post visible whenever either
|
||||
// column would show a nonzero figure. `expected` and `projectedCarriedIn`
|
||||
// are the two the recurring-costs feature added: an archived post whose
|
||||
// only activity this month is an unconfirmed occurrence has a zero
|
||||
// `remaining` but a nonzero `projected`, and must not be dropped.
|
||||
return (
|
||||
figures.carriedIn !== 0 || figures.allocation !== 0 || figures.charges !== 0
|
||||
figures.carriedIn !== 0 ||
|
||||
figures.allocation !== 0 ||
|
||||
figures.charges !== 0 ||
|
||||
figures.expected !== 0 ||
|
||||
figures.projectedCarriedIn !== 0
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -62,6 +62,14 @@ describe("validation", () => {
|
||||
expect(() => parseDatasetJson(JSON.stringify(data))).toThrow(/purchases/);
|
||||
});
|
||||
|
||||
test("rejects a non-array recurring field instead of silently importing it as empty (M7)", () => {
|
||||
// The other four sibling collections already go through requireArray;
|
||||
// recurring used to fall back to [] instead of being rejected here.
|
||||
const data = populated() as any;
|
||||
data.recurring = "not an array";
|
||||
expect(() => parseDatasetJson(JSON.stringify(data))).toThrow(/recurring/);
|
||||
});
|
||||
|
||||
test("rejects a purchase whose splits reference an unknown post", () => {
|
||||
const data = populated();
|
||||
data.purchases[0]!.splits[0]!.postId = "ghost";
|
||||
@@ -253,3 +261,305 @@ describe("the decimal-places setting on import", () => {
|
||||
expect(out.settings.digits).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("recurring cost validation", () => {
|
||||
function withRecurring(recurring: unknown[]): string {
|
||||
const data: any = createSeedDataset("2026-01");
|
||||
data.posts[0].id = "housing";
|
||||
data.recurring = recurring;
|
||||
return JSON.stringify(data);
|
||||
}
|
||||
|
||||
const valid = {
|
||||
id: "r1",
|
||||
name: "Rent",
|
||||
order: 0,
|
||||
archived: false,
|
||||
amount: { amount: 8000, currency: "DKK" },
|
||||
startDate: "2026-01",
|
||||
recurrence: { kind: "everyNMonths", n: 1 },
|
||||
anchoring: "calendar",
|
||||
splitMode: "percent",
|
||||
splits: [{ postId: "housing", value: 100, absorbsRemainder: true }],
|
||||
};
|
||||
|
||||
test("a well-formed cost is accepted", () => {
|
||||
expect(parseDatasetJson(withRecurring([valid])).recurring.length).toBe(1);
|
||||
});
|
||||
|
||||
test("an undefined currency is refused", () => {
|
||||
expect(() => parseDatasetJson(withRecurring([{ ...valid, amount: { amount: 1, currency: "XYZ" } }])))
|
||||
.toThrow(/Unsupported currency/);
|
||||
});
|
||||
|
||||
test("a malformed start date is refused", () => {
|
||||
expect(() => parseDatasetJson(withRecurring([{ ...valid, startDate: "2026-13" }])))
|
||||
.toThrow(/start date/);
|
||||
});
|
||||
|
||||
test("a malformed endedFrom is refused", () => {
|
||||
expect(() => parseDatasetJson(withRecurring([{ ...valid, endedFrom: "nope" }])))
|
||||
.toThrow(/ended-from date/);
|
||||
});
|
||||
|
||||
// Same residual-C1 gap as startDate: PURCHASE_DATE checks shape and digit
|
||||
// range, not calendar validity, so "2026-02-30" passes it. §10: endedFrom
|
||||
// carries no granularity rule, but it must still be a real date.
|
||||
test("a calendar-impossible endedFrom is refused (day- or month-shaped)", () => {
|
||||
expect(() => parseDatasetJson(withRecurring([{ ...valid, endedFrom: "2026-02-30" }])))
|
||||
.toThrow(/calendar-impossible ended-from/);
|
||||
expect(() => parseDatasetJson(withRecurring([{ ...valid, endedFrom: "2026-02-29" }]))) // not a leap year
|
||||
.toThrow(/calendar-impossible ended-from/);
|
||||
});
|
||||
|
||||
test("a valid endedFrom of either granularity is accepted, regardless of recurrence kind", () => {
|
||||
expect(
|
||||
parseDatasetJson(withRecurring([{ ...valid, endedFrom: "2026-06" }])).recurring.length,
|
||||
).toBe(1);
|
||||
expect(
|
||||
parseDatasetJson(
|
||||
withRecurring([{ ...valid, endedFrom: "2026-06-17" }]), // month-only recurrence, day-granular end
|
||||
).recurring.length,
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
test("an interval below 1 is refused, not clamped", () => {
|
||||
// Clamping would silently change the bill's schedule. The walk in
|
||||
// occurrences.ts cannot terminate without this.
|
||||
expect(() => parseDatasetJson(withRecurring([{ ...valid, recurrence: { kind: "everyNDays", n: 0 } }])))
|
||||
.toThrow(/at least 1/);
|
||||
});
|
||||
|
||||
test("a fractional interval is refused", () => {
|
||||
expect(() => parseDatasetJson(withRecurring([{ ...valid, recurrence: { kind: "everyNDays", n: 1.5 } }])))
|
||||
.toThrow(/whole number/);
|
||||
});
|
||||
|
||||
test("everyNMonths accepts either startDate granularity (C1)", () => {
|
||||
expect(parseDatasetJson(withRecurring([{ ...valid, startDate: "2026-01" }])).recurring.length).toBe(1);
|
||||
expect(parseDatasetJson(withRecurring([{ ...valid, startDate: "2026-01-15" }])).recurring.length).toBe(1);
|
||||
});
|
||||
|
||||
test("everyNDays with a month-only startDate is refused (C1)", () => {
|
||||
// This is the exact reachable bug: occurrencesOf hands a month-only
|
||||
// startDate straight to addDays for a day-granular kind and throws deep
|
||||
// inside the fold. Reject it here instead, at the import boundary.
|
||||
expect(() =>
|
||||
parseDatasetJson(
|
||||
withRecurring([{ ...valid, startDate: "2026-01", recurrence: { kind: "everyNDays", n: 30 } }]),
|
||||
),
|
||||
).toThrow(/month-only start date/);
|
||||
});
|
||||
|
||||
test("everyNWeeks with a month-only startDate is refused (C1)", () => {
|
||||
expect(() =>
|
||||
parseDatasetJson(
|
||||
withRecurring([
|
||||
{ ...valid, startDate: "2026-01", recurrence: { kind: "everyNWeeks", n: 1, weekday: 2 } },
|
||||
]),
|
||||
),
|
||||
).toThrow(/month-only start date/);
|
||||
});
|
||||
|
||||
test("everyNDays with a day-granular startDate is accepted (C1)", () => {
|
||||
expect(
|
||||
parseDatasetJson(
|
||||
withRecurring([{ ...valid, startDate: "2026-01-05", recurrence: { kind: "everyNDays", n: 30 } }]),
|
||||
).recurring.length,
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
// Residual C1: PURCHASE_DATE/DAY_GRANULAR_DATE check shape and range
|
||||
// ("31" is a valid day-of-month digit pair), not calendar validity.
|
||||
// September has 30 days, so "2026-09-31" passed both regexes and the
|
||||
// fold later threw "Invalid day in IsoDate" out of addDays/toDayOrdinal.
|
||||
test("an impossible day on a day-granular kind is refused, not just shape-checked (residual C1)", () => {
|
||||
expect(() =>
|
||||
parseDatasetJson(
|
||||
withRecurring([
|
||||
{ ...valid, startDate: "2026-02-30", recurrence: { kind: "everyNDays", n: 1 } },
|
||||
]),
|
||||
),
|
||||
).toThrow(/calendar-impossible/);
|
||||
});
|
||||
|
||||
test("an impossible day is refused even under everyNMonths (residual C1)", () => {
|
||||
expect(() =>
|
||||
parseDatasetJson(withRecurring([{ ...valid, startDate: "2026-09-31" }])),
|
||||
).toThrow(/calendar-impossible/);
|
||||
});
|
||||
|
||||
test("leap-year and non-leap-year Februaries are accepted (residual C1)", () => {
|
||||
expect(
|
||||
parseDatasetJson(
|
||||
withRecurring([
|
||||
{ ...valid, startDate: "2024-02-29", recurrence: { kind: "everyNDays", n: 1 } }, // 2024 is a leap year
|
||||
]),
|
||||
).recurring.length,
|
||||
).toBe(1);
|
||||
expect(
|
||||
parseDatasetJson(
|
||||
withRecurring([
|
||||
{ ...valid, startDate: "2026-02-28", recurrence: { kind: "everyNDays", n: 1 } }, // 2026 is not
|
||||
]),
|
||||
).recurring.length,
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
test("an unknown recurrence kind is refused", () => {
|
||||
expect(() => parseDatasetJson(withRecurring([{ ...valid, recurrence: { kind: "everyFullMoon", n: 1 } }])))
|
||||
.toThrow(/unknown kind/);
|
||||
});
|
||||
|
||||
test("a weekday outside 0-6 is refused", () => {
|
||||
expect(() =>
|
||||
parseDatasetJson(withRecurring([{ ...valid, recurrence: { kind: "everyNWeeks", n: 1, weekday: 7 } }])),
|
||||
).toThrow(/weekday/);
|
||||
});
|
||||
|
||||
test("an unknown anchoring is refused", () => {
|
||||
expect(() => parseDatasetJson(withRecurring([{ ...valid, anchoring: "whenever" }])))
|
||||
.toThrow(/anchoring/);
|
||||
});
|
||||
|
||||
test("splits must have exactly one remainder absorber", () => {
|
||||
expect(() =>
|
||||
parseDatasetJson(withRecurring([{
|
||||
...valid,
|
||||
splits: [
|
||||
{ postId: "housing", value: 50, absorbsRemainder: true },
|
||||
{ postId: "housing", value: 50, absorbsRemainder: true },
|
||||
],
|
||||
}])),
|
||||
).toThrow(/absorbsRemainder/);
|
||||
});
|
||||
|
||||
test("a split naming an unknown post is refused", () => {
|
||||
expect(() =>
|
||||
parseDatasetJson(withRecurring([{
|
||||
...valid,
|
||||
splits: [{ postId: "ghost", value: 100, absorbsRemainder: true }],
|
||||
}])),
|
||||
).toThrow(/unknown post/);
|
||||
});
|
||||
|
||||
test("no splits at all is refused", () => {
|
||||
expect(() => parseDatasetJson(withRecurring([{ ...valid, splits: [] }])))
|
||||
.toThrow(/no splits/);
|
||||
});
|
||||
|
||||
test("a null recurring cost entry is refused, not a raw TypeError", () => {
|
||||
// `recurring: [null]` used to reach `cost.id`/`cost.name` unguarded and
|
||||
// throw TypeError instead of the file's own ImportValidationError.
|
||||
expect(() => parseDatasetJson(withRecurring([null]))).toThrow(ImportValidationError);
|
||||
});
|
||||
|
||||
test("an unknown splitMode is refused", () => {
|
||||
// charges.ts picks distributeByWeight/distributeByAmount on
|
||||
// `splitMode === "percent"`; any other string silently takes the "fixed"
|
||||
// branch and treats a percentage as an absolute amount.
|
||||
expect(() => parseDatasetJson(withRecurring([{ ...valid, splitMode: "ratio" }])))
|
||||
.toThrow(/splitMode/);
|
||||
});
|
||||
|
||||
test("a non-numeric split value is refused", () => {
|
||||
// A non-finite value would otherwise propagate as NaN through the fold,
|
||||
// and roundMoney(NaN, digits) returns NaN rather than throwing.
|
||||
expect(() =>
|
||||
parseDatasetJson(withRecurring([{
|
||||
...valid,
|
||||
splits: [{ postId: "housing", value: "oops", absorbsRemainder: true }],
|
||||
}])),
|
||||
).toThrow(/finite number/);
|
||||
});
|
||||
|
||||
test("a negative split value is still accepted", () => {
|
||||
// Deliberately unchecked per AGENTS.md §3: a refund is a normal line.
|
||||
// Only non-numbers are rejected, never a negative or over-100 value.
|
||||
expect(() =>
|
||||
parseDatasetJson(withRecurring([{
|
||||
...valid,
|
||||
splits: [{ postId: "housing", value: -50, absorbsRemainder: true }],
|
||||
}])),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Purchase.source validation", () => {
|
||||
function withSource(source: unknown): string {
|
||||
const data: any = createSeedDataset("2026-01");
|
||||
data.posts[0].id = "housing";
|
||||
data.recurring = [];
|
||||
data.purchases = [{
|
||||
id: "p1",
|
||||
date: "2026-01",
|
||||
description: "Rent",
|
||||
total: { amount: 8000, currency: "DKK" },
|
||||
splitMode: "percent",
|
||||
splits: [{ postId: "housing", value: 100, absorbsRemainder: true }],
|
||||
schedule: null,
|
||||
source,
|
||||
}];
|
||||
return JSON.stringify(data);
|
||||
}
|
||||
|
||||
test("a purchase with no source is fine", () => {
|
||||
const data: any = createSeedDataset("2026-01");
|
||||
data.recurring = [];
|
||||
expect(() => parseDatasetJson(JSON.stringify(data))).not.toThrow();
|
||||
});
|
||||
|
||||
test("a source naming an unknown cost is refused", () => {
|
||||
expect(() => parseDatasetJson(withSource({ recurringId: "ghost", occurrenceDate: "2026-01" })))
|
||||
.toThrow(/unknown recurring cost/);
|
||||
});
|
||||
|
||||
test("a source with a malformed occurrence date is refused", () => {
|
||||
const data: any = JSON.parse(withSource({ recurringId: "r1", occurrenceDate: "nope" }));
|
||||
data.recurring = [{
|
||||
id: "r1", name: "Rent", order: 0, archived: false,
|
||||
amount: { amount: 8000, currency: "DKK" },
|
||||
startDate: "2026-01",
|
||||
recurrence: { kind: "everyNMonths", n: 1 },
|
||||
anchoring: "calendar",
|
||||
splitMode: "percent",
|
||||
splits: [{ postId: "housing", value: 100, absorbsRemainder: true }],
|
||||
}];
|
||||
expect(() => parseDatasetJson(JSON.stringify(data))).toThrow(/occurrence date/);
|
||||
});
|
||||
|
||||
test("a source of null is refused, not a raw TypeError", () => {
|
||||
// `source: null` used to reach `null.recurringId` unguarded — a plausible
|
||||
// hand-edit, since the sibling field on the same object literal IS
|
||||
// `schedule: null`. `source` is typed absent-or-object, never null, so
|
||||
// this must fail as ImportValidationError, not TypeError.
|
||||
expect(() => parseDatasetJson(withSource(null))).toThrow(ImportValidationError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("purchase splits validation", () => {
|
||||
// I1/I2 apply identically to purchases: the shared requireSplits() helper
|
||||
// must not check purchases to a lower standard than recurring costs.
|
||||
test("an unknown splitMode on a purchase is refused", () => {
|
||||
const data: any = populated();
|
||||
data.purchases[0].splitMode = "ratio";
|
||||
expect(() => parseDatasetJson(JSON.stringify(data))).toThrow(/splitMode/);
|
||||
});
|
||||
|
||||
test("a non-numeric split value on a purchase is refused", () => {
|
||||
const data: any = populated();
|
||||
data.purchases[0].splits[0].value = "oops";
|
||||
expect(() => parseDatasetJson(JSON.stringify(data))).toThrow(/finite number/);
|
||||
});
|
||||
|
||||
test("a negative split value on a purchase is still accepted", () => {
|
||||
const data: any = populated();
|
||||
data.purchases[0].splits[0].value = -70;
|
||||
expect(() => parseDatasetJson(JSON.stringify(data))).not.toThrow();
|
||||
});
|
||||
|
||||
test("a well-formed purchase still round-trips through the shared helper", () => {
|
||||
const data = populated();
|
||||
expect(parseDatasetJson(exportDatasetJson(data)).purchases.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
+206
-18
@@ -1,5 +1,7 @@
|
||||
import type { Currency, CurrencyDef, Dataset, MonthId } from "../domain/types.ts";
|
||||
import { migrate } from "../store/migrations.ts";
|
||||
import { monthOf } from "../domain/months.ts";
|
||||
import { toDayOrdinal } from "../domain/days.ts";
|
||||
|
||||
export class ImportValidationError extends Error {
|
||||
constructor(message: string) {
|
||||
@@ -35,6 +37,18 @@ const MONTH_ID = /^\d{4}-(0[1-9]|1[0-2])$/;
|
||||
* throwing out of `monthOf()` deep inside the balance fold.
|
||||
*/
|
||||
const PURCHASE_DATE = /^\d{4}-(0[1-9]|1[0-2])(-(0[1-9]|[12]\d|3[01]))?$/;
|
||||
/**
|
||||
* The day-granular subset of `PURCHASE_DATE`: "YYYY-MM-DD" only, no month-only
|
||||
* form. `everyNDays` and `everyNWeeks` hand `startDate` straight to `addDays`
|
||||
* (`src/domain/occurrences.ts`), which throws on a month-only date — so those
|
||||
* kinds need this narrower shape, checked here rather than three months later
|
||||
* inside a fold.
|
||||
*/
|
||||
const DAY_GRANULAR_DATE = /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/;
|
||||
|
||||
const ANCHORINGS = new Set(["calendar", "lastCharge"]);
|
||||
const RECURRENCE_KINDS = new Set(["everyNMonths", "everyNDays", "everyNWeeks"]);
|
||||
const SPLIT_MODES = new Set(["percent", "fixed"]);
|
||||
|
||||
/**
|
||||
* `Currency` is an open string now, so this — not the type system — is what
|
||||
@@ -118,11 +132,107 @@ function requireArray(data: Record<string, unknown>, key: string): unknown[] {
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* `n` is checked hard rather than clamped. The projection walk in
|
||||
* `domain/occurrences.ts` terminates only if every step strictly advances, and
|
||||
* a clamped zero would silently give the owner a schedule their file does not
|
||||
* describe. Rejecting tells them the file is wrong; clamping tells them
|
||||
* nothing and quietly changes a bill.
|
||||
*/
|
||||
function requireRecurrence(recurrence: any, label: string): void {
|
||||
if (!recurrence || !RECURRENCE_KINDS.has(recurrence.kind)) {
|
||||
throw new ImportValidationError(
|
||||
`${label} has a recurrence of unknown kind "${String(recurrence?.kind)}"`,
|
||||
);
|
||||
}
|
||||
if (typeof recurrence.n !== "number" || !Number.isInteger(recurrence.n)) {
|
||||
throw new ImportValidationError(
|
||||
`${label} has a recurrence interval that is not a whole number: ${String(recurrence.n)}`,
|
||||
);
|
||||
}
|
||||
if (recurrence.n < 1) {
|
||||
throw new ImportValidationError(
|
||||
`${label} has a recurrence interval of ${recurrence.n}; it must be at least 1`,
|
||||
);
|
||||
}
|
||||
if (recurrence.kind === "everyNWeeks") {
|
||||
const { weekday } = recurrence;
|
||||
if (typeof weekday !== "number" || !Number.isInteger(weekday) || weekday < 0 || weekday > 6) {
|
||||
throw new ImportValidationError(
|
||||
`${label} has a weekday of ${String(weekday)}; it must be 0-6, where 0 is Sunday`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared by the purchases loop and the recurring-costs loop below — the only
|
||||
* two places a `splitMode`/`splits` pair occurs, and both feed the identical
|
||||
* `distributeByWeight`/`distributeByAmount` branch in `domain/charges.ts`.
|
||||
* One helper means the two write paths cannot silently drift into checking
|
||||
* to different standards; the two loops used to each carry their own copy of
|
||||
* the "no splits" / "one absorber" / "unknown post" checks, so this replaces
|
||||
* both rather than adding a third copy.
|
||||
*
|
||||
* `splitMode` must be one of the two known modes: `charges.ts` picks its
|
||||
* distribution function with `splitMode === "percent" ? ... : ...`, so any
|
||||
* OTHER string silently takes the "fixed" branch and treats an intended
|
||||
* percentage as an absolute amount — a silently wrong number, not a throw.
|
||||
*
|
||||
* Every split's `value` must be a finite number. A non-finite value (missing,
|
||||
* a string, NaN, Infinity) would otherwise propagate through
|
||||
* `distributeByWeight`/`distributeByAmount` as NaN, and `roundMoney(NaN,
|
||||
* digits)` returns NaN rather than throwing — it would corrupt a total
|
||||
* silently, deep inside the fold.
|
||||
*
|
||||
* NEGATIVE values, values over 100, and splits that do not sum to the total
|
||||
* are all deliberately UNCHECKED — AGENTS.md lists these as intentional
|
||||
* (a refund is a normal line, and the absorbing split reconciles the sum by
|
||||
* design). Only non-numbers are rejected here.
|
||||
*/
|
||||
function requireSplits(
|
||||
splits: unknown,
|
||||
splitMode: unknown,
|
||||
label: string,
|
||||
postIds: ReadonlySet<string>,
|
||||
): void {
|
||||
if (!SPLIT_MODES.has(splitMode as string)) {
|
||||
throw new ImportValidationError(
|
||||
`${label} has an unknown splitMode "${String(splitMode)}"`,
|
||||
);
|
||||
}
|
||||
if (!Array.isArray(splits) || splits.length === 0) {
|
||||
throw new ImportValidationError(`${label} has no splits; at least one split is required`);
|
||||
}
|
||||
const absorbers = (splits as Array<Record<string, unknown>>).filter(
|
||||
(s) => s?.absorbsRemainder,
|
||||
).length;
|
||||
if (absorbers !== 1) {
|
||||
throw new ImportValidationError(
|
||||
`${label} has ${absorbers} splits flagged absorbsRemainder; exactly one is required`,
|
||||
);
|
||||
}
|
||||
for (const split of splits as Array<Record<string, unknown>>) {
|
||||
if (!postIds.has(split?.postId as string)) {
|
||||
throw new ImportValidationError(`${label} references unknown post "${String(split?.postId)}"`);
|
||||
}
|
||||
if (typeof split?.value !== "number" || !Number.isFinite(split.value)) {
|
||||
throw new ImportValidationError(
|
||||
`${label} has a split value that is not a finite number: ${String(split?.value)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates far enough that the fold cannot throw on the imported data: every
|
||||
* split points at a real post, every purchase has exactly one remainder
|
||||
* absorber, every MonthId parses, and every rule version resolves
|
||||
* unambiguously to a number rather than to NaN or a missing rate.
|
||||
* absorber, every MonthId parses, every rule version resolves unambiguously
|
||||
* to a number rather than to NaN or a missing rate, and every recurring
|
||||
* cost's startDate is both granular enough for its recurrence kind
|
||||
* (day-granular for everyNDays/everyNWeeks, since occurrencesOf hands it
|
||||
* straight to addDays) and calendar-valid (no "2026-09-31"), since shape and
|
||||
* range alone let an impossible date through.
|
||||
*/
|
||||
export function parseDatasetJson(text: string): Dataset {
|
||||
let raw: unknown;
|
||||
@@ -210,6 +320,75 @@ export function parseDatasetJson(text: string): Dataset {
|
||||
}
|
||||
}
|
||||
|
||||
const recurring = requireArray(data, "recurring") as any[];
|
||||
dataset.recurring = recurring;
|
||||
// Optional chaining, not `.id` directly: a null/non-object entry must fall
|
||||
// through to the loop below and fail there with an ImportValidationError,
|
||||
// not throw a raw TypeError here while merely collecting ids.
|
||||
const recurringIds = new Set(recurring.map((cost: any) => cost?.id));
|
||||
|
||||
for (const cost of recurring) {
|
||||
if (!cost || typeof cost !== "object") {
|
||||
throw new ImportValidationError(
|
||||
`A recurring cost is missing or not an object: ${JSON.stringify(cost)}`,
|
||||
);
|
||||
}
|
||||
const label = `Recurring cost "${cost.name}"`;
|
||||
requireCurrency(cost.amount?.currency, label, defined);
|
||||
|
||||
if (!PURCHASE_DATE.test(cost.startDate)) {
|
||||
throw new ImportValidationError(`${label} has an invalid start date "${cost.startDate}"`);
|
||||
}
|
||||
if (cost.endedFrom !== undefined && !PURCHASE_DATE.test(cost.endedFrom)) {
|
||||
throw new ImportValidationError(`${label} has an invalid ended-from date "${cost.endedFrom}"`);
|
||||
}
|
||||
requireRecurrence(cost.recurrence, label);
|
||||
// `everyNMonths` walks through `monthOf`, which accepts either
|
||||
// granularity; the other two kinds reach `addDays` and need a
|
||||
// day-granular startDate specifically. `endedFrom` needs no such check —
|
||||
// it is only ever compared lexicographically, never fed to `addDays`.
|
||||
const dayGranular = DAY_GRANULAR_DATE.test(cost.startDate);
|
||||
if (cost.recurrence.kind !== "everyNMonths" && !dayGranular) {
|
||||
throw new ImportValidationError(
|
||||
`${label} has a "${cost.recurrence.kind}" recurrence but a month-only start date "${cost.startDate}"; it needs "YYYY-MM-DD"`,
|
||||
);
|
||||
}
|
||||
// Shape and range (checked above by PURCHASE_DATE/DAY_GRANULAR_DATE) still
|
||||
// let a calendar-impossible date through, e.g. "2026-09-31" (September has
|
||||
// 30 days) or "2026-02-30". Route it through the same domain parsers the
|
||||
// fold itself uses, so an impossible date is refused here instead of
|
||||
// throwing three months later out of `foldBalances`.
|
||||
try {
|
||||
if (dayGranular) toDayOrdinal(cost.startDate);
|
||||
else monthOf(cost.startDate);
|
||||
} catch (error) {
|
||||
throw new ImportValidationError(
|
||||
`${label} has a calendar-impossible start date "${cost.startDate}" (${(error as Error).message})`,
|
||||
);
|
||||
}
|
||||
|
||||
// `endedFrom` carries no granularity rule — checked above by not
|
||||
// requiring it to match the recurrence kind's day-granularity — but it
|
||||
// still has to be a real calendar date, the same way `startDate` does.
|
||||
if (cost.endedFrom !== undefined) {
|
||||
try {
|
||||
if (DAY_GRANULAR_DATE.test(cost.endedFrom)) toDayOrdinal(cost.endedFrom);
|
||||
else monthOf(cost.endedFrom);
|
||||
} catch (error) {
|
||||
throw new ImportValidationError(
|
||||
`${label} has a calendar-impossible ended-from date "${cost.endedFrom}" (${(error as Error).message})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!ANCHORINGS.has(cost.anchoring)) {
|
||||
throw new ImportValidationError(
|
||||
`${label} has an unknown anchoring "${String(cost.anchoring)}"`,
|
||||
);
|
||||
}
|
||||
requireSplits(cost.splits, cost.splitMode, label, postIds);
|
||||
}
|
||||
|
||||
for (const month of dataset.months) {
|
||||
if (!MONTH_ID.test(month.id)) {
|
||||
throw new ImportValidationError(`Invalid month id "${month.id}"`);
|
||||
@@ -224,28 +403,37 @@ export function parseDatasetJson(text: string): Dataset {
|
||||
if (!PURCHASE_DATE.test(purchase.date)) {
|
||||
throw new ImportValidationError(`${label} has an invalid date "${purchase.date}"`);
|
||||
}
|
||||
if (purchase.splits.length === 0) {
|
||||
throw new ImportValidationError(`${label} has no splits; at least one split is required`);
|
||||
}
|
||||
const absorbers = purchase.splits.filter((s) => s.absorbsRemainder).length;
|
||||
if (absorbers !== 1) {
|
||||
throw new ImportValidationError(
|
||||
`${label} has ${absorbers} splits flagged absorbsRemainder; exactly one is required`,
|
||||
);
|
||||
}
|
||||
for (const split of purchase.splits) {
|
||||
if (!postIds.has(split.postId)) {
|
||||
throw new ImportValidationError(
|
||||
`${label} references unknown post "${split.postId}"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
requireSplits(purchase.splits, purchase.splitMode, label, postIds);
|
||||
for (const slice of purchase.schedule?.slices ?? []) {
|
||||
if (!MONTH_ID.test(slice.month)) {
|
||||
throw new ImportValidationError(`${label} has invalid slice month "${slice.month}"`);
|
||||
}
|
||||
requireCurrency(slice.amount.currency, `${label} slice ${slice.month}`, defined);
|
||||
}
|
||||
|
||||
if (purchase.source !== undefined) {
|
||||
// `null` is not `undefined`, so it reaches here without being "no
|
||||
// source". The field is typed as absent-or-object, never null, so a
|
||||
// null (or any other non-object) is malformed input, not a missing
|
||||
// field — reject it cleanly rather than dereferencing it into a raw
|
||||
// TypeError. `schedule?.slices ?? []` two blocks up is the same
|
||||
// defensive style applied to a sibling field that IS legitimately null.
|
||||
if (!purchase.source || typeof purchase.source !== "object") {
|
||||
throw new ImportValidationError(
|
||||
`${label} has a source that is not an object: ${JSON.stringify(purchase.source)}`,
|
||||
);
|
||||
}
|
||||
if (!recurringIds.has(purchase.source.recurringId)) {
|
||||
throw new ImportValidationError(
|
||||
`${label} names unknown recurring cost "${purchase.source.recurringId}"`,
|
||||
);
|
||||
}
|
||||
if (!PURCHASE_DATE.test(purchase.source.occurrenceDate)) {
|
||||
throw new ImportValidationError(
|
||||
`${label} has an invalid occurrence date "${purchase.source.occurrenceDate}"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return dataset;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { test, expect, describe } from "bun:test";
|
||||
import * as actions from "./actions.ts";
|
||||
import { createSeedDataset } from "../domain/seed.ts";
|
||||
import { occurrencesByMonth } from "../domain/occurrences.ts";
|
||||
import type { Dataset } from "../domain/types.ts";
|
||||
|
||||
/**
|
||||
@@ -453,3 +454,468 @@ describe("split values are rounded by what they MEAN", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("recurring costs", () => {
|
||||
function draft(): Dataset {
|
||||
const data = createSeedDataset("2026-01");
|
||||
data.posts[0]!.id = "housing";
|
||||
return data;
|
||||
}
|
||||
|
||||
const rentInput = {
|
||||
name: "Rent",
|
||||
archived: false,
|
||||
amount: { amount: 8000, currency: "DKK" },
|
||||
startDate: "2026-01",
|
||||
recurrence: { kind: "everyNMonths" as const, n: 1 },
|
||||
anchoring: "calendar" as const,
|
||||
splitMode: "percent" as const,
|
||||
splits: [{ postId: "housing", value: 100, absorbsRemainder: true }],
|
||||
};
|
||||
|
||||
test("adding assigns an id and an order", () => {
|
||||
const data = draft();
|
||||
const first = actions.addRecurringCost(data, rentInput);
|
||||
const second = actions.addRecurringCost(data, { ...rentInput, name: "Phone" });
|
||||
|
||||
expect(first.id).not.toBe(second.id);
|
||||
expect(first.order).toBe(0);
|
||||
expect(second.order).toBe(1);
|
||||
expect(data.recurring.length).toBe(2);
|
||||
});
|
||||
|
||||
test("the amount is rounded to the dataset's digits before it lands", () => {
|
||||
const data = draft();
|
||||
data.settings.digits = 0;
|
||||
const cost = actions.addRecurringCost(data, { ...rentInput, amount: { amount: 8000.7, currency: "DKK" } });
|
||||
expect(cost.amount.amount).toBe(8001);
|
||||
});
|
||||
|
||||
test("a recurrence with n below 1 is refused", () => {
|
||||
// The projection walk terminates only if every step advances, and the
|
||||
// importer is not the only write path.
|
||||
const data = draft();
|
||||
expect(() => actions.addRecurringCost(data, { ...rentInput, recurrence: { kind: "everyNDays", n: 0 } }))
|
||||
.toThrow(/at least 1/);
|
||||
expect(() => actions.addRecurringCost(data, { ...rentInput, recurrence: { kind: "everyNDays", n: 1.5 } }))
|
||||
.toThrow(/whole number/);
|
||||
});
|
||||
|
||||
test("a weekday outside 0-6 is refused", () => {
|
||||
const data = draft();
|
||||
expect(() =>
|
||||
actions.addRecurringCost(data, { ...rentInput, recurrence: { kind: "everyNWeeks", n: 1, weekday: 7 } }),
|
||||
).toThrow(/weekday/);
|
||||
});
|
||||
|
||||
test("updating validates the new recurrence too", () => {
|
||||
const data = draft();
|
||||
const cost = actions.addRecurringCost(data, rentInput);
|
||||
expect(() =>
|
||||
actions.updateRecurringCost(data, cost.id, { recurrence: { kind: "everyNDays", n: 0 } }),
|
||||
).toThrow(/at least 1/);
|
||||
});
|
||||
|
||||
describe("startDate granularity (C1)", () => {
|
||||
test("adding a day-granular kind with a month-only startDate is refused", () => {
|
||||
const data = draft();
|
||||
expect(() =>
|
||||
actions.addRecurringCost(data, {
|
||||
...rentInput,
|
||||
startDate: "2026-01",
|
||||
recurrence: { kind: "everyNDays", n: 30 },
|
||||
}),
|
||||
).toThrow(/day-granular/);
|
||||
expect(() =>
|
||||
actions.addRecurringCost(data, {
|
||||
...rentInput,
|
||||
startDate: "2026-01",
|
||||
recurrence: { kind: "everyNWeeks", n: 1, weekday: 1 },
|
||||
}),
|
||||
).toThrow(/day-granular/);
|
||||
});
|
||||
|
||||
test("adding a day-granular kind with a day-granular startDate succeeds", () => {
|
||||
const data = draft();
|
||||
const cost = actions.addRecurringCost(data, {
|
||||
...rentInput,
|
||||
startDate: "2026-01-05",
|
||||
recurrence: { kind: "everyNDays", n: 30 },
|
||||
});
|
||||
expect(cost.startDate).toBe("2026-01-05");
|
||||
});
|
||||
|
||||
test("everyNMonths accepts either granularity", () => {
|
||||
const data = draft();
|
||||
expect(() => actions.addRecurringCost(data, { ...rentInput, startDate: "2026-01" })).not.toThrow();
|
||||
expect(() =>
|
||||
actions.addRecurringCost(data, { ...rentInput, startDate: "2026-01-15" }),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
// This is the exact bug: a monthly cost is created (startDate "2026-09"),
|
||||
// then the unit dropdown switches it to a day-granular kind WITHOUT
|
||||
// touching startDate — the only way a real edit reaches this, since the
|
||||
// add form always starts as everyNMonths.
|
||||
test("updating just the recurrence kind against an untouched month-only startDate is refused", () => {
|
||||
const data = draft();
|
||||
const cost = actions.addRecurringCost(data, { ...rentInput, startDate: "2026-09" });
|
||||
expect(() =>
|
||||
actions.updateRecurringCost(data, cost.id, { recurrence: { kind: "everyNDays", n: 30 } }),
|
||||
).toThrow(/day-granular/);
|
||||
expect(() =>
|
||||
actions.updateRecurringCost(data, cost.id, {
|
||||
recurrence: { kind: "everyNWeeks", n: 1, weekday: 3 },
|
||||
}),
|
||||
).toThrow(/day-granular/);
|
||||
// Refused, so nothing committed — still the original month-granular kind.
|
||||
expect(data.recurring[0]!.recurrence.kind).toBe("everyNMonths");
|
||||
});
|
||||
|
||||
test("updating just startDate to a month-only value against a stored day-granular kind is refused", () => {
|
||||
const data = draft();
|
||||
const cost = actions.addRecurringCost(data, {
|
||||
...rentInput,
|
||||
startDate: "2026-01-05",
|
||||
recurrence: { kind: "everyNDays", n: 30 },
|
||||
});
|
||||
expect(() => actions.updateRecurringCost(data, cost.id, { startDate: "2026-01" })).toThrow(
|
||||
/day-granular/,
|
||||
);
|
||||
});
|
||||
|
||||
test("updating recurrence and startDate together to a consistent day-granular pair succeeds", () => {
|
||||
const data = draft();
|
||||
const cost = actions.addRecurringCost(data, { ...rentInput, startDate: "2026-09" });
|
||||
actions.updateRecurringCost(data, cost.id, {
|
||||
recurrence: { kind: "everyNDays", n: 30 },
|
||||
startDate: "2026-09-01",
|
||||
});
|
||||
expect(data.recurring[0]!.startDate).toBe("2026-09-01");
|
||||
expect(data.recurring[0]!.recurrence.kind).toBe("everyNDays");
|
||||
});
|
||||
|
||||
// Residual C1: shape alone ("YYYY-MM-DD") is not calendar validity.
|
||||
// September has 30 days, so "2026-09-31" is day-SHAPED but impossible.
|
||||
// `requireStartDateGranularity` used a shape-only regex and let this
|
||||
// through even under everyNMonths; downstream, foldBalances throws
|
||||
// "Invalid day in IsoDate" out of addDays/toDayOrdinal.
|
||||
test("a calendar-impossible startDate is refused, even under everyNMonths", () => {
|
||||
const data = draft();
|
||||
expect(() =>
|
||||
actions.addRecurringCost(data, { ...rentInput, startDate: "2026-09-31" }),
|
||||
).toThrow(/2026-09-31/);
|
||||
|
||||
const cost = actions.addRecurringCost(data, rentInput);
|
||||
expect(() =>
|
||||
actions.updateRecurringCost(data, cost.id, { startDate: "2026-09-31" }),
|
||||
).toThrow(/2026-09-31/);
|
||||
// Refused, so nothing committed beyond the one successful add above.
|
||||
expect(data.recurring.length).toBe(1);
|
||||
expect(data.recurring[0]!.startDate).toBe(rentInput.startDate);
|
||||
});
|
||||
|
||||
// Second half of the same reproduction: even if a calendar-impossible
|
||||
// startDate is already sitting in the draft (e.g. from data written
|
||||
// before this fix existed), switching the recurrence kind must not wave
|
||||
// it through — requireStartDateGranularity re-validates on every update,
|
||||
// not only ones that touch startDate.
|
||||
test("switching kind against an already-impossible stored startDate is refused", () => {
|
||||
const data = draft();
|
||||
const cost = actions.addRecurringCost(data, rentInput);
|
||||
// Bypass the public API to simulate a bad value already on disk.
|
||||
data.recurring[0]!.startDate = "2026-09-31";
|
||||
expect(() =>
|
||||
actions.updateRecurringCost(data, cost.id, { recurrence: { kind: "everyNDays", n: 7 } }),
|
||||
).toThrow(/2026-09-31/);
|
||||
expect(data.recurring[0]!.recurrence.kind).toBe("everyNMonths");
|
||||
});
|
||||
|
||||
// Residual C1: monthOf's regex used to be unanchored (`/^(\d{4})-(\d{2})/`
|
||||
// with no trailing `$`), so it matched a "YYYY-MM" PREFIX of a longer,
|
||||
// malformed string and silently dropped the rest.
|
||||
test("an unanchored-month-shaped garbage value is refused", () => {
|
||||
const data = draft();
|
||||
expect(() =>
|
||||
actions.addRecurringCost(data, { ...rentInput, startDate: "2026-091" }),
|
||||
).toThrow(/2026-091/);
|
||||
|
||||
const cost = actions.addRecurringCost(data, rentInput);
|
||||
expect(() =>
|
||||
actions.updateRecurringCost(data, cost.id, { startDate: "2026-091" }),
|
||||
).toThrow(/2026-091/);
|
||||
});
|
||||
|
||||
test("legitimate calendar-valid dates still pass", () => {
|
||||
const data = draft();
|
||||
expect(() => actions.addRecurringCost(data, { ...rentInput, startDate: "2026-09" })).not.toThrow();
|
||||
expect(() =>
|
||||
actions.addRecurringCost(data, {
|
||||
...rentInput,
|
||||
startDate: "2024-02-29", // 2024 is a leap year
|
||||
recurrence: { kind: "everyNDays", n: 1 },
|
||||
}),
|
||||
).not.toThrow();
|
||||
expect(() =>
|
||||
actions.addRecurringCost(data, {
|
||||
...rentInput,
|
||||
startDate: "2026-02-28", // 2026 is not a leap year
|
||||
recurrence: { kind: "everyNDays", n: 1 },
|
||||
}),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
test("ending sets both endedFrom and archived", () => {
|
||||
const data = draft();
|
||||
const cost = actions.addRecurringCost(data, rentInput);
|
||||
actions.endRecurringCost(data, cost.id, "2026-06");
|
||||
|
||||
expect(data.recurring[0]!.endedFrom).toBe("2026-06");
|
||||
expect(data.recurring[0]!.archived).toBe(true);
|
||||
});
|
||||
|
||||
test("restoring clears both", () => {
|
||||
const data = draft();
|
||||
const cost = actions.addRecurringCost(data, rentInput);
|
||||
actions.endRecurringCost(data, cost.id, "2026-06");
|
||||
actions.restoreRecurringCost(data, cost.id);
|
||||
|
||||
expect(data.recurring[0]!.endedFrom).toBeUndefined();
|
||||
expect(data.recurring[0]!.archived).toBe(false);
|
||||
});
|
||||
|
||||
describe("setRecurringCostEndedFrom", () => {
|
||||
test("setting a date ends AND archives, whether it is past or future", () => {
|
||||
const data = draft();
|
||||
const cost = actions.addRecurringCost(data, rentInput);
|
||||
|
||||
actions.setRecurringCostEndedFrom(data, cost.id, "2020-01"); // past
|
||||
expect(data.recurring[0]!.endedFrom).toBe("2020-01");
|
||||
expect(data.recurring[0]!.archived).toBe(true);
|
||||
|
||||
actions.setRecurringCostEndedFrom(data, cost.id, "2099-06"); // future
|
||||
expect(data.recurring[0]!.endedFrom).toBe("2099-06");
|
||||
expect(data.recurring[0]!.archived).toBe(true);
|
||||
});
|
||||
|
||||
test("clearing (undefined) un-ends AND un-archives — the column doubles as un-end", () => {
|
||||
const data = draft();
|
||||
const cost = actions.addRecurringCost(data, rentInput);
|
||||
actions.setRecurringCostEndedFrom(data, cost.id, "2026-06");
|
||||
|
||||
actions.setRecurringCostEndedFrom(data, cost.id, undefined);
|
||||
expect(data.recurring[0]!.endedFrom).toBeUndefined();
|
||||
expect(data.recurring[0]!.archived).toBe(false);
|
||||
});
|
||||
|
||||
test("a past or future endedFrom set through the store action takes effect in the projection", () => {
|
||||
// Integration check for the "Ends" column: the store write and the
|
||||
// domain walk agree, for both directions — a backdated cancellation
|
||||
// (import-only before this change) and a forward-dated one.
|
||||
const past = draft();
|
||||
const pastCost = actions.addRecurringCost(past, rentInput); // starts 2026-01
|
||||
actions.setRecurringCostEndedFrom(past, pastCost.id, "2026-03"); // backdated
|
||||
const pastOccurrences = occurrencesByMonth(past, "2026-06").get("2026-01") ?? [];
|
||||
expect(pastOccurrences.length).toBe(1); // Jan still occurred
|
||||
expect(occurrencesByMonth(past, "2026-06").has("2026-03")).toBe(false); // Mar+ stopped
|
||||
|
||||
const future = draft();
|
||||
const futureCost = actions.addRecurringCost(future, rentInput);
|
||||
actions.setRecurringCostEndedFrom(future, futureCost.id, "2026-08"); // forward-dated
|
||||
const futureByMonth = occurrencesByMonth(future, "2026-09");
|
||||
expect(futureByMonth.has("2026-07")).toBe(true); // still occurs right up to the cutoff
|
||||
expect(futureByMonth.has("2026-08")).toBe(false); // stopped from the cutoff onward
|
||||
});
|
||||
|
||||
test("endRecurringCost and restoreRecurringCost now delegate here, so all three stay coherent", () => {
|
||||
const data = draft();
|
||||
const cost = actions.addRecurringCost(data, rentInput);
|
||||
|
||||
actions.endRecurringCost(data, cost.id, "2026-06");
|
||||
expect(data.recurring[0]!.archived).toBe(true);
|
||||
|
||||
actions.setRecurringCostEndedFrom(data, cost.id, undefined);
|
||||
expect(data.recurring[0]!.archived).toBe(false);
|
||||
|
||||
actions.setRecurringCostEndedFrom(data, cost.id, "2027-01");
|
||||
expect(data.recurring[0]!.archived).toBe(true);
|
||||
|
||||
actions.restoreRecurringCost(data, cost.id);
|
||||
expect(data.recurring[0]!.endedFrom).toBeUndefined();
|
||||
expect(data.recurring[0]!.archived).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
test("moving swaps order with its neighbour", () => {
|
||||
const data = draft();
|
||||
const first = actions.addRecurringCost(data, rentInput);
|
||||
const second = actions.addRecurringCost(data, { ...rentInput, name: "Phone" });
|
||||
actions.moveRecurringCost(data, second.id, -1);
|
||||
|
||||
expect(data.recurring.find((c) => c.id === second.id)!.order).toBe(0);
|
||||
expect(data.recurring.find((c) => c.id === first.id)!.order).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("confirmOccurrence", () => {
|
||||
function draft(): Dataset {
|
||||
const data = createSeedDataset("2026-01");
|
||||
data.posts[0]!.id = "housing";
|
||||
actions.addRecurringCost(data, {
|
||||
name: "Rent",
|
||||
archived: false,
|
||||
amount: { amount: 8000, currency: "DKK" },
|
||||
startDate: "2026-01",
|
||||
recurrence: { kind: "everyNMonths", n: 1 },
|
||||
anchoring: "calendar",
|
||||
splitMode: "percent",
|
||||
splits: [{ postId: "housing", value: 100, absorbsRemainder: true }],
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
test("writes an ordinary purchase carrying the slot it claims", () => {
|
||||
const data = draft();
|
||||
const purchase = actions.confirmOccurrence(data, data.recurring[0]!.id, "2026-03");
|
||||
|
||||
expect(purchase.source).toEqual({ recurringId: data.recurring[0]!.id, occurrenceDate: "2026-03" });
|
||||
expect(purchase.date).toBe("2026-03");
|
||||
expect(purchase.total).toEqual({ amount: 8000, currency: "DKK" });
|
||||
expect(purchase.description).toBe("Rent");
|
||||
expect(purchase.schedule).toBeNull();
|
||||
expect(data.purchases.length).toBe(1);
|
||||
});
|
||||
|
||||
test("the purchase date may differ from the slot — this is the data-cap case", () => {
|
||||
const data = draft();
|
||||
const purchase = actions.confirmOccurrence(data, data.recurring[0]!.id, "2026-03", { date: "2026-02-12" });
|
||||
|
||||
expect(purchase.source!.occurrenceDate).toBe("2026-03");
|
||||
expect(purchase.date).toBe("2026-02-12");
|
||||
});
|
||||
|
||||
test("an overridden amount is what gets recorded", () => {
|
||||
const data = draft();
|
||||
const purchase = actions.confirmOccurrence(data, data.recurring[0]!.id, "2026-03", {
|
||||
amount: { amount: 8250, currency: "DKK" },
|
||||
});
|
||||
expect(purchase.total.amount).toBe(8250);
|
||||
});
|
||||
|
||||
test("the month the purchase lands in is created, so income can be entered", () => {
|
||||
const data = draft();
|
||||
actions.confirmOccurrence(data, data.recurring[0]!.id, "2026-05");
|
||||
expect(data.months.some((m) => m.id === "2026-05")).toBe(true);
|
||||
});
|
||||
|
||||
test("splits are copied, not shared with the cost", () => {
|
||||
const data = draft();
|
||||
const purchase = actions.confirmOccurrence(data, data.recurring[0]!.id, "2026-03");
|
||||
purchase.splits[0]!.value = 50;
|
||||
expect(data.recurring[0]!.splits[0]!.value).toBe(100);
|
||||
});
|
||||
|
||||
test("an unknown cost throws", () => {
|
||||
expect(() => actions.confirmOccurrence(draft(), "nope", "2026-03")).toThrow(/Unknown recurring cost/);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Confirming a slot writes an ORDINARY purchase, and the ordinary purchase
|
||||
* dialog can then edit its date — an action with nothing on its face to do
|
||||
* with recurring costs. Under `lastCharge` anchoring that date is what the
|
||||
* walk measures the next occurrence from, so moving it back far enough makes
|
||||
* the walk step backwards and throw out of `foldBalances`, taking every month
|
||||
* route, the year view and the summary down together. The write is refused
|
||||
* here so that state cannot be reached.
|
||||
*/
|
||||
describe("editing a confirmation's date", () => {
|
||||
function carDraft(anchoring: "calendar" | "lastCharge" = "lastCharge"): Dataset {
|
||||
const data = createSeedDataset("2026-08");
|
||||
data.posts[0]!.id = "housing";
|
||||
actions.addRecurringCost(data, {
|
||||
name: "Car insurance",
|
||||
archived: false,
|
||||
amount: { amount: 900, currency: "DKK" },
|
||||
startDate: "2026-08-14",
|
||||
recurrence: { kind: "everyNDays", n: 30 },
|
||||
anchoring,
|
||||
splitMode: "percent",
|
||||
splits: [{ postId: "housing", value: 100, absorbsRemainder: true }],
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
/** The walk every month route, the year view and the summary go through. */
|
||||
function folds(data: Dataset): () => unknown {
|
||||
return () => occurrencesByMonth(data, "2026-12");
|
||||
}
|
||||
|
||||
function dateOf(data: Dataset, purchaseId: string): string {
|
||||
return data.purchases.find((p) => p.id === purchaseId)!.date;
|
||||
}
|
||||
|
||||
test("a date more than one cycle before the slot it claims is refused", () => {
|
||||
const data = carDraft();
|
||||
const purchase = actions.confirmOccurrence(data, data.recurring[0]!.id, "2026-09-13", {
|
||||
date: "2026-09-13",
|
||||
});
|
||||
expect(folds(data)).not.toThrow();
|
||||
|
||||
expect(() => actions.updatePurchase(data, purchase.id, { date: "2026-08-01" })).toThrow(
|
||||
/Car insurance/,
|
||||
);
|
||||
expect(dateOf(data, purchase.id)).toBe("2026-09-13");
|
||||
expect(folds(data)).not.toThrow();
|
||||
});
|
||||
|
||||
test("a date that keeps the series advancing is still allowed", () => {
|
||||
const data = carDraft();
|
||||
const purchase = actions.confirmOccurrence(data, data.recurring[0]!.id, "2026-09-13", {
|
||||
date: "2026-09-13",
|
||||
});
|
||||
|
||||
actions.updatePurchase(data, purchase.id, { date: "2026-09-08" });
|
||||
expect(dateOf(data, purchase.id)).toBe("2026-09-08");
|
||||
expect(folds(data)).not.toThrow();
|
||||
});
|
||||
|
||||
test("a purchase with no slot is an ordinary purchase and moves freely", () => {
|
||||
const data = carDraft();
|
||||
const purchase = actions.addPurchase(data, {
|
||||
date: "2026-09-13",
|
||||
description: "Sofa",
|
||||
total: { amount: 100, currency: "DKK" },
|
||||
splitMode: "percent",
|
||||
splits: [{ postId: "housing", value: 100, absorbsRemainder: true }],
|
||||
schedule: null,
|
||||
});
|
||||
|
||||
actions.updatePurchase(data, purchase.id, { date: "2026-01-01" });
|
||||
expect(dateOf(data, purchase.id)).toBe("2026-01-01");
|
||||
expect(folds(data)).not.toThrow();
|
||||
});
|
||||
|
||||
test("a calendar-anchored cost's confirmation moves freely, because it never rebases", () => {
|
||||
const data = carDraft("calendar");
|
||||
const purchase = actions.confirmOccurrence(data, data.recurring[0]!.id, "2026-09-13", {
|
||||
date: "2026-09-13",
|
||||
});
|
||||
|
||||
actions.updatePurchase(data, purchase.id, { date: "2026-08-01" });
|
||||
expect(dateOf(data, purchase.id)).toBe("2026-08-01");
|
||||
expect(folds(data)).not.toThrow();
|
||||
});
|
||||
|
||||
test("a slot naming a cost that no longer exists does not block the edit", () => {
|
||||
const data = carDraft();
|
||||
const purchase = actions.confirmOccurrence(data, data.recurring[0]!.id, "2026-09-13", {
|
||||
date: "2026-09-13",
|
||||
});
|
||||
data.recurring = [];
|
||||
|
||||
actions.updatePurchase(data, purchase.id, { date: "2026-08-01" });
|
||||
expect(dateOf(data, purchase.id)).toBe("2026-08-01");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { newId } from "../domain/seed.ts";
|
||||
import { compareMonths, monthOf } from "../domain/months.ts";
|
||||
import { toDayOrdinal } from "../domain/days.ts";
|
||||
import { roundMoney } from "../domain/money.ts";
|
||||
import { currencyUsage, normalizeCurrencyCode } from "../domain/currencies.ts";
|
||||
import { wouldAdvancePast } from "../domain/occurrences.ts";
|
||||
import type {
|
||||
Currency,
|
||||
CurrencyDef,
|
||||
Dataset,
|
||||
FxRate,
|
||||
IsoDate,
|
||||
Money,
|
||||
Month,
|
||||
MonthId,
|
||||
@@ -14,6 +17,9 @@ import type {
|
||||
PostId,
|
||||
Purchase,
|
||||
PurchaseId,
|
||||
Recurrence,
|
||||
RecurringCost,
|
||||
RecurringCostId,
|
||||
Rule,
|
||||
RuleVersion,
|
||||
Schedule,
|
||||
@@ -215,12 +221,58 @@ export function addPurchase(
|
||||
return created;
|
||||
}
|
||||
|
||||
/**
|
||||
* A confirmation's `date` is TRUTH, and under `lastCharge` anchoring it is
|
||||
* what `occurrencesOf` measures the NEXT occurrence from. Moved back far
|
||||
* enough, the step from it lands at or before the slot the purchase claims,
|
||||
* and the walk throws its "did not advance" error — out of `foldBalances`, so
|
||||
* every month route, the year view and the summary go down at once, from an
|
||||
* edit made in the ordinary purchase dialog that never mentioned recurring
|
||||
* costs. Refusing the write is the only place that state can be prevented:
|
||||
* the walk's throw is correct and must not be softened, and by the time it
|
||||
* fires the bad data is already saved.
|
||||
*
|
||||
* `wouldAdvancePast` answers exactly this question and is what `ExpectedBand`
|
||||
* asks before OFFERING a row; both must agree with the walk, so neither
|
||||
* re-derives the condition.
|
||||
*
|
||||
* Two deliberate pass-throughs. A purchase with no slot is an ordinary
|
||||
* purchase and is not checked at all — this is the hot path for every
|
||||
* ordinary edit. A slot naming a cost that no longer exists is not checked
|
||||
* either: a dangling source is a separate concern, and refusing here would
|
||||
* only strand the purchase.
|
||||
*/
|
||||
function requireConfirmationAdvances(
|
||||
draft: Dataset,
|
||||
purchase: Purchase,
|
||||
source: Purchase["source"],
|
||||
date: IsoDate,
|
||||
): void {
|
||||
if (!source) return;
|
||||
const cost = draft.recurring.find((c) => c.id === source.recurringId);
|
||||
if (!cost) return;
|
||||
if (wouldAdvancePast(cost, source.occurrenceDate, date)) return;
|
||||
throw new Error(
|
||||
`Purchase ${purchase.id} confirms "${cost.name}" for ${source.occurrenceDate}, and ` +
|
||||
`dating it ${date} would not move the series past that occurrence — ` +
|
||||
`pick a later date, or delete the purchase to un-confirm the occurrence`,
|
||||
);
|
||||
}
|
||||
|
||||
export function updatePurchase(
|
||||
draft: Dataset,
|
||||
purchaseId: PurchaseId,
|
||||
changes: Partial<Omit<Purchase, "id">>,
|
||||
): void {
|
||||
const purchase = requirePurchase(draft, purchaseId);
|
||||
if (changes.date !== undefined && changes.date !== purchase.date) {
|
||||
requireConfirmationAdvances(
|
||||
draft,
|
||||
purchase,
|
||||
"source" in changes ? changes.source : purchase.source,
|
||||
changes.date,
|
||||
);
|
||||
}
|
||||
const resolved: Partial<Omit<Purchase, "id">> = { ...changes };
|
||||
if (changes.total) {
|
||||
resolved.total = roundMoneyValue(draft, changes.total);
|
||||
@@ -362,3 +414,215 @@ export function setBaseCurrency(draft: Dataset, currency: Currency): void {
|
||||
draft.settings.baseCurrency = currency;
|
||||
draft.fxRates = draft.fxRates.filter((r) => r.currency !== currency);
|
||||
}
|
||||
|
||||
function requireRecurringCost(draft: Dataset, id: RecurringCostId): RecurringCost {
|
||||
const cost = draft.recurring.find((c) => c.id === id);
|
||||
if (!cost) throw new Error(`Unknown recurring cost: ${id}`);
|
||||
return cost;
|
||||
}
|
||||
|
||||
/**
|
||||
* The projection walk in `domain/occurrences.ts` terminates only if every step
|
||||
* strictly advances, and `n` is what guarantees that. The JSON importer checks
|
||||
* the same thing; this is the other write path, and both have to hold or the
|
||||
* fold can hang.
|
||||
*/
|
||||
function requireRecurrence(recurrence: Recurrence): Recurrence {
|
||||
if (!Number.isInteger(recurrence.n)) {
|
||||
throw new Error(`A recurrence interval must be a whole number, not ${recurrence.n}`);
|
||||
}
|
||||
if (recurrence.n < 1) {
|
||||
throw new Error(`A recurrence interval must be at least 1, not ${recurrence.n}`);
|
||||
}
|
||||
if (recurrence.kind === "everyNWeeks") {
|
||||
if (!Number.isInteger(recurrence.weekday) || recurrence.weekday < 0 || recurrence.weekday > 6) {
|
||||
throw new Error(`A weekday must be 0-6 (0 is Sunday), not ${recurrence.weekday}`);
|
||||
}
|
||||
}
|
||||
return recurrence;
|
||||
}
|
||||
|
||||
const DAY_GRANULAR_DATE = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
/**
|
||||
* `everyNMonths` walks the cursor through `monthOf`, so it tolerates either
|
||||
* granularity of `startDate`. `everyNDays` and `everyNWeeks` hand it straight
|
||||
* to `addDays`, which throws on a month-only date — so those kinds require a
|
||||
* day-granular `startDate` here, at the boundary, rather than three months
|
||||
* later when a fold walks off the end of it.
|
||||
*
|
||||
* Shape alone is not enough: "2026-09-31" is day-SHAPED but calendar-
|
||||
* impossible (September has 30 days), and would otherwise reach `addDays` (or
|
||||
* sit unnoticed under `everyNMonths`) and throw three months later out of the
|
||||
* fold instead of here. Route a day-granular value through `toDayOrdinal` and
|
||||
* a month-granular one through `monthOf` — both already throw on exactly
|
||||
* that — and translate whatever they throw into this file's own error style.
|
||||
*/
|
||||
function requireStartDateGranularity(recurrence: Recurrence, startDate: IsoDate): void {
|
||||
const dayGranular = DAY_GRANULAR_DATE.test(startDate);
|
||||
if (recurrence.kind !== "everyNMonths" && !dayGranular) {
|
||||
throw new Error(
|
||||
`A "${recurrence.kind}" recurrence needs a day-granular startDate ("YYYY-MM-DD"), not "${startDate}"`,
|
||||
);
|
||||
}
|
||||
try {
|
||||
if (dayGranular) toDayOrdinal(startDate);
|
||||
else monthOf(startDate);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`A "${recurrence.kind}" recurrence needs a valid startDate, not "${startDate}" (${(error as Error).message})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function addRecurringCost(
|
||||
draft: Dataset,
|
||||
cost: Omit<RecurringCost, "id" | "order">,
|
||||
): RecurringCost {
|
||||
const recurrence = requireRecurrence(cost.recurrence);
|
||||
requireStartDateGranularity(recurrence, cost.startDate);
|
||||
const created: RecurringCost = {
|
||||
...cost,
|
||||
id: newId(),
|
||||
order: draft.recurring.length,
|
||||
recurrence,
|
||||
amount: roundMoneyValue(draft, cost.amount),
|
||||
splits: roundSplits(draft, cost.splits, cost.splitMode, cost.amount.currency),
|
||||
};
|
||||
draft.recurring.push(created);
|
||||
return created;
|
||||
}
|
||||
|
||||
/**
|
||||
* `archived` and `endedFrom` are excluded on purpose: they are a coherent
|
||||
* pair (`archived` mirrors "`endedFrom` is set") maintained ONLY by
|
||||
* `setRecurringCostEndedFrom`, and a caller able to pass either through here
|
||||
* could desynchronise them — a bill that reads as live but is dimmed, or
|
||||
* ended but not. Route a change to either field through
|
||||
* `setRecurringCostEndedFrom` (or its `endRecurringCost`/`restoreRecurringCost`
|
||||
* convenience wrappers) instead.
|
||||
*/
|
||||
export function updateRecurringCost(
|
||||
draft: Dataset,
|
||||
id: RecurringCostId,
|
||||
changes: Partial<Omit<RecurringCost, "id" | "archived" | "endedFrom">>,
|
||||
): void {
|
||||
const cost = requireRecurringCost(draft, id);
|
||||
const resolved: Partial<Omit<RecurringCost, "id" | "archived" | "endedFrom">> = { ...changes };
|
||||
|
||||
if (changes.recurrence) {
|
||||
resolved.recurrence = requireRecurrence(changes.recurrence);
|
||||
}
|
||||
// Runs on every update, not just one that touches recurrence or startDate:
|
||||
// either field alone can break the pairing (a kind flip against an
|
||||
// untouched month-only startDate is exactly how this bug was reached).
|
||||
requireStartDateGranularity(
|
||||
resolved.recurrence ?? cost.recurrence,
|
||||
resolved.startDate ?? cost.startDate,
|
||||
);
|
||||
if (changes.amount) {
|
||||
resolved.amount = roundMoneyValue(draft, changes.amount);
|
||||
}
|
||||
if (changes.splits) {
|
||||
// Either field may be absent from a partial update; the stored cost
|
||||
// supplies whichever one is, so the mode and currency always agree with
|
||||
// the values being rounded.
|
||||
resolved.splits = roundSplits(
|
||||
draft,
|
||||
changes.splits,
|
||||
changes.splitMode ?? cost.splitMode,
|
||||
(resolved.amount ?? cost.amount).currency,
|
||||
);
|
||||
}
|
||||
|
||||
Object.assign(cost, resolved);
|
||||
}
|
||||
|
||||
export function moveRecurringCost(
|
||||
draft: Dataset,
|
||||
id: RecurringCostId,
|
||||
direction: -1 | 1,
|
||||
): void {
|
||||
const ordered = [...draft.recurring].sort((a, b) => a.order - b.order);
|
||||
const index = ordered.findIndex((c) => c.id === id);
|
||||
const target = index + direction;
|
||||
if (index === -1 || target < 0 || target >= ordered.length) return;
|
||||
const a = ordered[index]!;
|
||||
const b = ordered[target]!;
|
||||
[a.order, b.order] = [b.order, a.order];
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets or clears `endedFrom` directly — what the Settings "Ends" column edits
|
||||
* — keeping `archived` coherent with it. The rule: `archived` mirrors
|
||||
* "`endedFrom` is set", same as `endRecurringCost`/`restoreRecurringCost`
|
||||
* below, which now both delegate here. That is what stops the two fields
|
||||
* diverging into a bill that reads as live but is dimmed, or ended but not.
|
||||
*
|
||||
* Both fields, because they mean different things: `endedFrom` stops the
|
||||
* PROJECTION, and `archived` only flags the row — same as a post, it stays
|
||||
* listed (sorted in, rendered at reduced opacity) rather than being filtered
|
||||
* out. Archiving alone would leave the bill projecting forever; setting
|
||||
* `endedFrom` alone would leave a dead bill undimmed in the list. Neither
|
||||
* touches a past occurrence, so no historical figure moves.
|
||||
*
|
||||
* `endedFrom` itself is unchecked here — no granularity rule applies to it
|
||||
* (§10; it is only ever compared lexicographically, never reaches
|
||||
* `addDays`) — so the caller validates it is a real `IsoDate` before this is
|
||||
* called, the same way the "Starts" field already does for `startDate`.
|
||||
*/
|
||||
export function setRecurringCostEndedFrom(
|
||||
draft: Dataset,
|
||||
id: RecurringCostId,
|
||||
endedFrom: IsoDate | undefined,
|
||||
): void {
|
||||
const cost = requireRecurringCost(draft, id);
|
||||
if (endedFrom === undefined) {
|
||||
delete cost.endedFrom;
|
||||
cost.archived = false;
|
||||
} else {
|
||||
cost.endedFrom = endedFrom;
|
||||
cost.archived = true;
|
||||
}
|
||||
}
|
||||
|
||||
/** The one-click convenience: stop a bill from `from` onward and dim it. */
|
||||
export function endRecurringCost(draft: Dataset, id: RecurringCostId, from: IsoDate): void {
|
||||
setRecurringCostEndedFrom(draft, id, from);
|
||||
}
|
||||
|
||||
/** The one-click convenience: clear the cancellation and un-dim the row. */
|
||||
export function restoreRecurringCost(draft: Dataset, id: RecurringCostId): void {
|
||||
setRecurringCostEndedFrom(draft, id, undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns one projected occurrence into a real purchase.
|
||||
*
|
||||
* `occurrenceDate` is the slot being claimed and goes into `source`;
|
||||
* `overrides.date` is when the money actually moved and goes on the purchase.
|
||||
* They differ whenever a bill is paid off schedule, and under `lastCharge`
|
||||
* anchoring it is the latter that rebases the series — which is the whole of
|
||||
* the phone-bill behaviour.
|
||||
*
|
||||
* Deleting the purchase later un-confirms the slot, with nothing to reconcile.
|
||||
*/
|
||||
export function confirmOccurrence(
|
||||
draft: Dataset,
|
||||
recurringId: RecurringCostId,
|
||||
occurrenceDate: IsoDate,
|
||||
overrides: { date?: IsoDate; amount?: Money } = {},
|
||||
): Purchase {
|
||||
const cost = requireRecurringCost(draft, recurringId);
|
||||
return addPurchase(draft, {
|
||||
date: overrides.date ?? occurrenceDate,
|
||||
description: cost.name,
|
||||
total: overrides.amount ?? cost.amount,
|
||||
splitMode: cost.splitMode,
|
||||
// Copied, not shared: editing the purchase's splits must not rewrite the
|
||||
// cost's, and `mutate` clones the draft rather than deep-freezing it.
|
||||
splits: cost.splits.map((split) => ({ ...split })),
|
||||
schedule: null,
|
||||
source: { recurringId, occurrenceDate },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,10 +2,20 @@ import { monthOf } from "../domain/months.ts";
|
||||
import { readDataset, writeDataset } from "./db.ts";
|
||||
import { createSnapshotStore } from "./snapshot.ts";
|
||||
import { BAKED_FX_RATES } from "./bakedRates.ts";
|
||||
import type { IsoDate } from "../domain/types.ts";
|
||||
|
||||
/** The current month, read at module load. The UI owns the clock; the domain does not. */
|
||||
export const currentMonth = monthOf(new Date().toISOString().slice(0, 10));
|
||||
|
||||
/**
|
||||
* Today, day-granular, read at module load. The UI owns the clock; the domain
|
||||
* does not. `ExpectedBand`'s "Coming up" group needs the actual day — a
|
||||
* coming-up occurrence is confirmed EARLY, so the purchase it writes must be
|
||||
* dated today rather than the future slot, which is exactly what rebases a
|
||||
* `lastCharge` series.
|
||||
*/
|
||||
export const today: IsoDate = new Date().toISOString().slice(0, 10);
|
||||
|
||||
export const store = createSnapshotStore(
|
||||
{ read: readDataset, write: writeDataset },
|
||||
currentMonth,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { test, expect, describe } from "bun:test";
|
||||
import { SCHEMA_VERSION, createSeedDataset } from "../domain/seed.ts";
|
||||
import { migrate, UnsupportedSchemaError } from "./migrations.ts";
|
||||
import { figuresFor, foldBalances } from "../domain/fold.ts";
|
||||
|
||||
describe("migrate", () => {
|
||||
test("passes a current-version dataset through unchanged", () => {
|
||||
@@ -443,3 +444,44 @@ test("no migration step imports a live default that a later commit could change"
|
||||
);
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
describe("6 -> 7: recurring costs join the dataset", () => {
|
||||
test("a v6 dataset gains an empty recurring array", () => {
|
||||
const v6 = { ...createSeedDataset("2026-09"), recurring: undefined };
|
||||
delete (v6 as any).recurring;
|
||||
(v6 as any).settings = { ...v6.settings, schemaVersion: 6 };
|
||||
|
||||
const migrated = migrate(v6 as any);
|
||||
|
||||
expect(migrated.recurring).toEqual([]);
|
||||
expect(migrated.settings.schemaVersion).toBe(SCHEMA_VERSION);
|
||||
});
|
||||
|
||||
test("recurring costs the owner already has are left alone", () => {
|
||||
// A hand-edited file could already carry them; discarding would be data loss.
|
||||
// Typed loosely on purpose: a hand-edited entry is not guaranteed to be a
|
||||
// fully-shaped RecurringCost either, and this step must preserve it as-is
|
||||
// regardless.
|
||||
const existing: any[] = [{ id: "r1", name: "Rent" }];
|
||||
const v6: any = { ...createSeedDataset("2026-09"), recurring: existing };
|
||||
v6.settings = { ...v6.settings, schemaVersion: 6 };
|
||||
|
||||
expect(migrate(v6).recurring).toEqual(existing);
|
||||
});
|
||||
|
||||
test("NO figure changes: every post in every month folds identically", () => {
|
||||
const v6: any = { ...createSeedDataset("2026-09"), settings: { ...createSeedDataset("2026-09").settings, schemaVersion: 6 } };
|
||||
delete v6.recurring;
|
||||
|
||||
const migrated = migrate(v6);
|
||||
const fold = foldBalances(migrated, "2026-12");
|
||||
|
||||
for (const post of migrated.posts) {
|
||||
for (const month of ["2026-09", "2026-10", "2026-11", "2026-12"]) {
|
||||
const figures = figuresFor(fold, post.id, month);
|
||||
expect(figures.projected).toBe(figures.remaining);
|
||||
expect(figures.expected).toBe(0);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -174,6 +174,25 @@ const MIGRATIONS: Array<(data: any) => any> = [
|
||||
currencies: currencies.map(({ digits, ...rest }: any) => rest),
|
||||
};
|
||||
},
|
||||
|
||||
// 6 -> 7: recurring costs join the dataset.
|
||||
//
|
||||
// Behaviour-preserving by construction, and more strongly than most: an
|
||||
// empty array projects no occurrences, so `expected` is zero everywhere and
|
||||
// `projected` equals `remaining` in every month of every existing dataset.
|
||||
// Not one stored figure is read, let alone written.
|
||||
//
|
||||
// An existing `recurring` field is left alone rather than overwritten, for
|
||||
// the same reason the 2 -> 3 step leaves `currencies` alone: a hand-edited
|
||||
// file could already carry them, and discarding them would be data loss.
|
||||
//
|
||||
// Nothing is frozen here because nothing is copied — the step writes a
|
||||
// literal empty array and reads no default, live or otherwise.
|
||||
(data: any) => ({
|
||||
...data,
|
||||
settings: { ...data.settings, schemaVersion: 7 },
|
||||
recurring: Array.isArray(data.recurring) ? data.recurring : [],
|
||||
}),
|
||||
];
|
||||
|
||||
export function migrate(raw: unknown): Dataset {
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { expectedGroups, recordedDateFor, resolveExpectedAmount } from "./ExpectedBand.tsx";
|
||||
import { occurrencesByMonth, wouldAdvancePast } from "../../domain/occurrences.ts";
|
||||
import { confirmOccurrence } from "../../store/actions.ts";
|
||||
import { createSeedDataset } from "../../domain/seed.ts";
|
||||
import type { Dataset, Purchase, RecurringCost } from "../../domain/types.ts";
|
||||
|
||||
const DKK_USD = [
|
||||
{ code: "DKK", symbol: "kr" },
|
||||
{ code: "USD", symbol: "$" },
|
||||
];
|
||||
|
||||
// I5: an unrecognised currency must not silently confirm at the cost's own
|
||||
// amount. `resolveExpectedAmount` is the logic ExpectedBand's Confirm button
|
||||
// uses to decide whether to write a purchase at all.
|
||||
describe("resolveExpectedAmount", () => {
|
||||
test("untouched (typed undefined) is not invalid, and parses to nothing — the fast path", () => {
|
||||
const result = resolveExpectedAmount(undefined, DKK_USD, "DKK");
|
||||
expect(result.invalid).toBe(false);
|
||||
expect(result.parsed).toBeNull();
|
||||
});
|
||||
|
||||
test("edited text that parses is not invalid", () => {
|
||||
const result = resolveExpectedAmount("450", DKK_USD, "DKK");
|
||||
expect(result.invalid).toBe(false);
|
||||
expect(result.parsed).toEqual({ amount: 450, currency: "DKK" });
|
||||
});
|
||||
|
||||
test("edited text naming a currency the dataset doesn't have is invalid, not a silent fallback", () => {
|
||||
// This is the exact bug: "30 GBP" in a DKK/USD dataset used to fall back
|
||||
// to the cost's own amount via `parsed ?? undefined`.
|
||||
const result = resolveExpectedAmount("30 GBP", DKK_USD, "DKK");
|
||||
expect(result.invalid).toBe(true);
|
||||
expect(result.parsed).toBeNull();
|
||||
});
|
||||
|
||||
test("edited text that is empty or otherwise unparseable is invalid", () => {
|
||||
expect(resolveExpectedAmount("", DKK_USD, "DKK").invalid).toBe(true);
|
||||
expect(resolveExpectedAmount("thirty", DKK_USD, "DKK").invalid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// X2: the load-bearing decision behind the phone-bill behaviour is which date
|
||||
// a Confirm click records. Nothing else in the suite would catch the two
|
||||
// cases being swapped — this test must FAIL if `recordedDateFor`'s two arms
|
||||
// are exchanged (verified by hand: swapping them and re-running does fail).
|
||||
describe("recordedDateFor", () => {
|
||||
test("a this-month ('pending') row records at the slot's own date — null, confirmOccurrence's default", () => {
|
||||
expect(recordedDateFor("pending", "2026-09-04")).toBeNull();
|
||||
});
|
||||
|
||||
test("a 'coming up' row records at today — paying early is the whole point", () => {
|
||||
expect(recordedDateFor("comingUp", "2026-09-04")).toBe("2026-09-04");
|
||||
});
|
||||
});
|
||||
|
||||
function baseDataset(): Dataset {
|
||||
const data = createSeedDataset("2026-02");
|
||||
data.posts[0]!.id = "housing";
|
||||
return data;
|
||||
}
|
||||
|
||||
function cost(overrides: Partial<RecurringCost> = {}): RecurringCost {
|
||||
return {
|
||||
id: "r1",
|
||||
name: "Rent",
|
||||
order: 0,
|
||||
archived: false,
|
||||
amount: { amount: 8000, currency: "DKK" },
|
||||
startDate: "2026-01",
|
||||
recurrence: { kind: "everyNMonths", n: 1 },
|
||||
anchoring: "calendar",
|
||||
splitMode: "percent",
|
||||
splits: [{ postId: "housing", value: 100, absorbsRemainder: true }],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function confirmed(recurringId: string, occurrenceDate: string, id: string): Purchase {
|
||||
return {
|
||||
id,
|
||||
date: occurrenceDate,
|
||||
description: "x",
|
||||
total: { amount: 8000, currency: "DKK" },
|
||||
splitMode: "percent",
|
||||
splits: [{ postId: "housing", value: 100, absorbsRemainder: true }],
|
||||
schedule: null,
|
||||
source: { recurringId, occurrenceDate },
|
||||
};
|
||||
}
|
||||
|
||||
// A fixed "today" for tests where its exact value doesn't matter (any
|
||||
// calendar-anchored cost advances regardless of it).
|
||||
const TODAY = "2026-02-15";
|
||||
|
||||
// Gap 1 (spec §5): the band offers confirmation on the next pending
|
||||
// occurrence, not only ones landing in the displayed month.
|
||||
describe("expectedGroups (Coming up)", () => {
|
||||
test("a dataset with no recurring costs shows nothing — the component must return null", () => {
|
||||
const data = baseDataset();
|
||||
const groups = expectedGroups(data, "2026-02", TODAY);
|
||||
expect(groups.pending).toEqual([]);
|
||||
expect(groups.overdue).toBe(0);
|
||||
expect(groups.comingUp).toEqual([]);
|
||||
});
|
||||
|
||||
test("a lastCharge bill's next occurrence next month is offered as 'coming up', not hidden until you navigate", () => {
|
||||
// The flagship scenario: displayed month is February, the pending slot
|
||||
// sits in March because rent-like accounting isn't in play here — a
|
||||
// monthly cost's only occurrence for a month IS next month once this
|
||||
// month's is confirmed.
|
||||
const data = baseDataset();
|
||||
data.recurring = [cost({ id: "phone", anchoring: "lastCharge" })];
|
||||
data.purchases = [confirmed("phone", "2026-02", "p1")]; // this month's is done
|
||||
|
||||
const groups = expectedGroups(data, "2026-02", TODAY);
|
||||
expect(groups.pending).toEqual([]);
|
||||
expect(groups.comingUp.length).toBe(1);
|
||||
expect(groups.comingUp[0]!.recurringId).toBe("phone");
|
||||
expect(groups.comingUp[0]!.date).toBe("2026-03");
|
||||
});
|
||||
|
||||
test("at most one row per cost, even when several future occurrences fall within the horizon", () => {
|
||||
// A weekly cost produces many unconfirmed occurrences across the next
|
||||
// several months. Only the soonest one past the displayed month may
|
||||
// appear — never a list of everything the future holds. February's own
|
||||
// occurrences are confirmed first so X3 doesn't suppress the row this
|
||||
// test exists to check (a cost with an outstanding slot this month must
|
||||
// settle it before "coming up" offers a later one — covered separately
|
||||
// below).
|
||||
const data = baseDataset();
|
||||
data.recurring = [
|
||||
cost({
|
||||
id: "gym",
|
||||
startDate: "2026-02-02",
|
||||
recurrence: { kind: "everyNDays", n: 7 },
|
||||
}),
|
||||
];
|
||||
data.purchases = [
|
||||
confirmed("gym", "2026-02-02", "p1"),
|
||||
confirmed("gym", "2026-02-09", "p2"),
|
||||
confirmed("gym", "2026-02-16", "p3"),
|
||||
confirmed("gym", "2026-02-23", "p4"),
|
||||
];
|
||||
|
||||
const groups = expectedGroups(data, "2026-02", TODAY);
|
||||
expect(groups.pending).toEqual([]);
|
||||
// Many more unconfirmed occurrences exist in March, April, ... within the
|
||||
// horizon — but comingUp holds exactly one.
|
||||
const forThisCost = groups.comingUp.filter((o) => o.recurringId === "gym");
|
||||
expect(forThisCost.length).toBe(1);
|
||||
expect(forThisCost[0]!.date).toBe("2026-03-02"); // the soonest one past February
|
||||
});
|
||||
|
||||
test("the horizon is bounded: a cost whose next occurrence lands beyond it does not appear", () => {
|
||||
const data = baseDataset();
|
||||
// Interval bigger than the default horizon: confirmed this month, the
|
||||
// next occurrence is 15 months out — past the 12-month default.
|
||||
data.recurring = [
|
||||
cost({
|
||||
id: "insurance",
|
||||
startDate: "2026-02",
|
||||
recurrence: { kind: "everyNMonths", n: 15 },
|
||||
}),
|
||||
];
|
||||
data.purchases = [confirmed("insurance", "2026-02", "p1")];
|
||||
|
||||
expect(expectedGroups(data, "2026-02", TODAY).comingUp).toEqual([]); // default (12) horizon
|
||||
// Raising the horizon far enough reveals the same occurrence, proving the
|
||||
// emptiness above is the bound, not a bug elsewhere.
|
||||
const wider = expectedGroups(data, "2026-02", TODAY, 16).comingUp;
|
||||
expect(wider.length).toBe(1);
|
||||
expect(wider[0]!.date).toBe("2027-05");
|
||||
});
|
||||
|
||||
test("a confirmed future occurrence never appears in comingUp", () => {
|
||||
const data = baseDataset();
|
||||
// Ends right after March, so April onward is never generated at all —
|
||||
// isolating the point of the test (a confirmed slot is excluded) from an
|
||||
// unrelated, genuinely-unconfirmed one the walk would otherwise reach.
|
||||
data.recurring = [cost({ id: "rent", endedFrom: "2026-04" })];
|
||||
data.purchases = [confirmed("rent", "2026-02", "p1"), confirmed("rent", "2026-03", "p2")];
|
||||
|
||||
const groups = expectedGroups(data, "2026-02", TODAY);
|
||||
expect(groups.pending).toEqual([]);
|
||||
expect(groups.comingUp).toEqual([]);
|
||||
});
|
||||
|
||||
test("overdue counts only earlier, foldable months and is unaffected by extending the walk for comingUp", () => {
|
||||
const data = baseDataset();
|
||||
data.settings.foldStartMonth = "2026-01";
|
||||
data.recurring = [cost({ id: "rent", startDate: "2026-01" })]; // Jan unconfirmed
|
||||
|
||||
const groups = expectedGroups(data, "2026-02", TODAY);
|
||||
expect(groups.overdue).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// X3: a cost must never appear in both "Expected" and "Coming up" at once —
|
||||
// that is the path into X1 (confirming the later row while the earlier one
|
||||
// is still outstanding writes a real purchase AND leaves the earlier
|
||||
// projection standing, double-counting the bill).
|
||||
describe("expectedGroups — X3 (a cost with an outstanding slot never also shows a coming-up row)", () => {
|
||||
test("this month's slot still pending suppresses the coming-up row for the SAME cost", () => {
|
||||
const data = baseDataset();
|
||||
data.recurring = [
|
||||
cost({
|
||||
id: "car",
|
||||
name: "Car insurance",
|
||||
amount: { amount: 349, currency: "DKK" },
|
||||
startDate: "2026-08-14",
|
||||
recurrence: { kind: "everyNDays", n: 30 },
|
||||
anchoring: "lastCharge",
|
||||
}),
|
||||
];
|
||||
// Matches the reported demo shape exactly: no purchases at all, viewed
|
||||
// 2026-09. 2026-09-13 is pending; 2026-10-13 would otherwise be the
|
||||
// soonest future occurrence.
|
||||
data.settings.foldStartMonth = "2026-01";
|
||||
|
||||
const groups = expectedGroups(data, "2026-09", "2026-09-04");
|
||||
expect(groups.pending.map((o) => o.date)).toEqual(["2026-09-13"]);
|
||||
expect(groups.comingUp).toEqual([]); // suppressed — settle September first
|
||||
});
|
||||
|
||||
test("once the pending slot is settled, a coming-up row reappears — for a pairing that is itself safe", () => {
|
||||
// A monthly lastCharge cost, unlike the 30-day Car insurance above, is
|
||||
// always safe to confirm early (X1 never suppresses `everyNMonths`), so
|
||||
// this isolates X3's own effect: suppressed while this month is
|
||||
// outstanding, offered again once it is settled.
|
||||
const data = baseDataset();
|
||||
data.recurring = [cost({ id: "phone", anchoring: "lastCharge" })];
|
||||
|
||||
let groups = expectedGroups(data, "2026-02", TODAY);
|
||||
expect(groups.pending.map((o) => o.date)).toEqual(["2026-02"]);
|
||||
expect(groups.comingUp).toEqual([]);
|
||||
|
||||
confirmOccurrence(data, "phone", "2026-02", { date: "2026-02" });
|
||||
groups = expectedGroups(data, "2026-02", TODAY);
|
||||
expect(groups.pending).toEqual([]);
|
||||
expect(groups.comingUp.map((o) => o.date)).toEqual(["2026-03"]);
|
||||
});
|
||||
|
||||
test("an outstanding occurrence in an earlier FOLDABLE month also suppresses the row", () => {
|
||||
const data = baseDataset();
|
||||
data.settings.foldStartMonth = "2026-01";
|
||||
data.recurring = [
|
||||
cost({ id: "rent", startDate: "2026-01", endedFrom: "2026-04" }),
|
||||
];
|
||||
// January is unconfirmed; February (displayed month) and March are
|
||||
// confirmed, so nothing is `pending` and the only reason a coming-up row
|
||||
// would be suppressed is the outstanding January slot.
|
||||
data.purchases = [confirmed("rent", "2026-02", "p1"), confirmed("rent", "2026-03", "p2")];
|
||||
|
||||
const groups = expectedGroups(data, "2026-02", TODAY);
|
||||
expect(groups.pending).toEqual([]);
|
||||
expect(groups.comingUp).toEqual([]); // January is still outstanding
|
||||
});
|
||||
});
|
||||
|
||||
// X1: a coming-up row confirms at today's date rather than its own slot, and
|
||||
// `occurrencesOf` throws — permanently, since the purchase persists — if that
|
||||
// pairing cannot advance the series. The band must never OFFER such a row.
|
||||
describe("expectedGroups — X1 (a coming-up row that cannot be confirmed coherently is never offered)", () => {
|
||||
test("wouldAdvancePast identifies the exact reported pairing as unsafe", () => {
|
||||
const car = cost({
|
||||
id: "car",
|
||||
name: "Car insurance",
|
||||
recurrence: { kind: "everyNDays", n: 30 },
|
||||
anchoring: "lastCharge",
|
||||
});
|
||||
// 39 days between "today" and the slot — more than one 30-day cycle.
|
||||
expect(wouldAdvancePast(car, "2026-10-13", "2026-09-04")).toBe(false);
|
||||
// A slot close enough to today (a few days, the "paid early" case) is fine.
|
||||
expect(wouldAdvancePast(car, "2026-09-13", "2026-09-04")).toBe(true);
|
||||
});
|
||||
|
||||
test("stays suppressed even once X3 no longer applies — settling this month doesn't make a too-early payment safe", () => {
|
||||
// Same Car insurance shape, but September's slot is settled ON TIME
|
||||
// first, so X3 no longer has anything to suppress. The October row is
|
||||
// still 39 days from `today` — more than one 30-day cycle — so X1 alone
|
||||
// must keep it off the list.
|
||||
const data = baseDataset();
|
||||
data.recurring = [
|
||||
cost({
|
||||
id: "car",
|
||||
name: "Car insurance",
|
||||
amount: { amount: 349, currency: "DKK" },
|
||||
startDate: "2026-08-14",
|
||||
recurrence: { kind: "everyNDays", n: 30 },
|
||||
anchoring: "lastCharge",
|
||||
}),
|
||||
];
|
||||
confirmOccurrence(data, "car", "2026-09-13", { date: "2026-09-13" });
|
||||
|
||||
const groups = expectedGroups(data, "2026-09", "2026-09-04");
|
||||
expect(groups.pending).toEqual([]);
|
||||
expect(groups.comingUp).toEqual([]);
|
||||
});
|
||||
|
||||
test("confirming an unsafe pairing directly still throws — the domain guard is untouched", () => {
|
||||
const data = baseDataset();
|
||||
data.recurring = [
|
||||
cost({
|
||||
id: "car",
|
||||
name: "Car insurance",
|
||||
amount: { amount: 349, currency: "DKK" },
|
||||
startDate: "2026-08-14",
|
||||
recurrence: { kind: "everyNDays", n: 30 },
|
||||
anchoring: "lastCharge",
|
||||
}),
|
||||
];
|
||||
confirmOccurrence(data, "car", "2026-10-13", { date: "2026-09-04" });
|
||||
expect(() => occurrencesByMonth(data, "2026-10")).toThrow(
|
||||
/did not advance past 2026-10-13 \(produced 2026-10-04\)/,
|
||||
);
|
||||
});
|
||||
|
||||
test("regression: whatever expectedGroups offers as 'coming up', confirming it all today and folding never throws (demo Car insurance shape)", () => {
|
||||
const data = baseDataset();
|
||||
data.recurring = [
|
||||
cost({
|
||||
id: "car",
|
||||
name: "Car insurance",
|
||||
amount: { amount: 349, currency: "DKK" },
|
||||
startDate: "2026-08-14",
|
||||
recurrence: { kind: "everyNDays", n: 30 },
|
||||
anchoring: "lastCharge",
|
||||
}),
|
||||
cost({
|
||||
id: "phone",
|
||||
name: "Phone",
|
||||
startDate: "2026-01",
|
||||
recurrence: { kind: "everyNMonths", n: 1 },
|
||||
anchoring: "lastCharge",
|
||||
}),
|
||||
];
|
||||
data.purchases = [confirmed("phone", "2026-02", "p0")]; // settled through Feb, unlike Car
|
||||
const today = "2026-09-04";
|
||||
|
||||
const groups = expectedGroups(data, "2026-09", today);
|
||||
// Car insurance is suppressed entirely (X3: 2026-09-13 is still pending),
|
||||
// and even confirming everything else offered must never break the fold.
|
||||
expect(groups.comingUp.some((o) => o.recurringId === "car")).toBe(false);
|
||||
|
||||
for (const occurrence of groups.comingUp) {
|
||||
confirmOccurrence(data, occurrence.recurringId, occurrence.date, { date: today });
|
||||
}
|
||||
|
||||
expect(() => occurrencesByMonth(data, "2026-10")).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,301 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useDataset } from "../hooks/useDataset.ts";
|
||||
import { useMutate } from "../hooks/useMutate.ts";
|
||||
import { today } from "../../store/index.ts";
|
||||
import { confirmOccurrence } from "../../store/actions.ts";
|
||||
import {
|
||||
occurrencesByMonth,
|
||||
wouldAdvancePast,
|
||||
type Occurrence,
|
||||
} from "../../domain/occurrences.ts";
|
||||
import { addMonths, compareMonths } from "../../domain/months.ts";
|
||||
import { parseMoneyInput, type CurrencyOption, type ParsedMoney } from "../moneyInput.ts";
|
||||
import { Section } from "./Section.tsx";
|
||||
import type { Currency, Dataset, IsoDate, MonthId } from "../../domain/types.ts";
|
||||
|
||||
/**
|
||||
* `typed` is `undefined` when the field has never been edited — that always
|
||||
* confirms at the cost's own amount, the designed one-click fast path.
|
||||
* Once edited, though, text that does not parse (an unrecognised currency
|
||||
* like "30 GBP" in a DKK/USD dataset, or anything else `parseMoneyInput`
|
||||
* rejects) must NOT be conflated with "untouched" and silently fall back to
|
||||
* the cost's amount — that would write a figure the owner never typed.
|
||||
* `invalid` is what tells the two apart; the caller must refuse to confirm
|
||||
* while it is true.
|
||||
*
|
||||
* Exported for `ExpectedBand.test.ts` (I5).
|
||||
*/
|
||||
export function resolveExpectedAmount(
|
||||
typed: string | undefined,
|
||||
currencies: readonly CurrencyOption[],
|
||||
fallbackCurrency: Currency,
|
||||
): { parsed: ParsedMoney | null; invalid: boolean } {
|
||||
if (typed === undefined) return { parsed: null, invalid: false };
|
||||
const parsed = parseMoneyInput(typed, currencies, fallbackCurrency);
|
||||
return { parsed, invalid: parsed === null };
|
||||
}
|
||||
|
||||
export type ExpectedRowGroup = "pending" | "comingUp";
|
||||
|
||||
/**
|
||||
* What a Confirm click should record, given which group the row is in. A
|
||||
* this-month row confirms at the slot's own date — `null`, `confirmOccurrence`'s
|
||||
* default — and a coming-up row confirms at `today` instead, because it is
|
||||
* being paid early on purpose (see `ExpectedBand`'s doc comment).
|
||||
*
|
||||
* This one branch (previously inlined as `row(occurrence, null)` versus
|
||||
* `row(occurrence, today)`) is the whole of the phone-bill behaviour, and
|
||||
* nothing else in the suite would catch the two being swapped — a DOM test
|
||||
* isn't needed for that, a direct one is. Exported for `ExpectedBand.test.ts`
|
||||
* (X2).
|
||||
*/
|
||||
export function recordedDateFor(group: ExpectedRowGroup, today: IsoDate): IsoDate | null {
|
||||
return group === "comingUp" ? today : null;
|
||||
}
|
||||
|
||||
export interface ExpectedGroups {
|
||||
/** This month's unconfirmed occurrences. */
|
||||
pending: Occurrence[];
|
||||
/** Count of unconfirmed occurrences from earlier, foldable months. */
|
||||
overdue: number;
|
||||
/**
|
||||
* At most ONE row per recurring cost: its next unconfirmed occurrence
|
||||
* strictly after `monthId`, out to `horizonMonths` ahead. Never a list of
|
||||
* everything the future holds.
|
||||
*/
|
||||
comingUp: Occurrence[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything the band has to show, in three groups. §5 offers confirmation on
|
||||
* "the next pending occurrence, not only on ones whose date has passed" — the
|
||||
* flagship case is a `lastCharge` bill whose pending slot sits next month, and
|
||||
* `occurrencesByMonth` only walks TO the month it is given, so this walks past
|
||||
* the displayed month on purpose.
|
||||
*
|
||||
* The walk stays bounded — `horizonMonths` ahead, twelve by default, which is
|
||||
* enough to catch an annual `everyNMonths` cost — and is a band-local concern.
|
||||
* `foldBalances`'s own `upToMonth` bound is untouched.
|
||||
*
|
||||
* `today` is what a "Coming up" row would record if confirmed (see `row` in
|
||||
* `ExpectedBand` below) — passed in rather than read from a clock, because
|
||||
* this function is exercised directly by tests with no DOM.
|
||||
*
|
||||
* Two rules keep a "coming up" row from ever being offered when confirming it
|
||||
* cannot go well (X1, X3):
|
||||
*
|
||||
* - A cost with an unresolved occurrence in `monthId` or any earlier
|
||||
* FOLDABLE month (same bound `overdue` uses) is skipped entirely. Settle
|
||||
* the earlier bill first — offering a later one while an earlier sits
|
||||
* outstanding would let confirming the later one leave the earlier
|
||||
* projection standing, double-counting the bill.
|
||||
* - Otherwise, the row is offered only if recording it at `today` would
|
||||
* leave `occurrencesOf`'s walk able to advance past that slot
|
||||
* (`wouldAdvancePast`). A bill confirmed today for a slot far enough in
|
||||
* the future can rebase the series backwards, which is exactly what
|
||||
* `occurrencesOf` throws on — so a row that would do that must never be
|
||||
* offered in the first place, not offered-and-disabled.
|
||||
*
|
||||
* Exported for `ExpectedBand.test.ts`.
|
||||
*/
|
||||
export function expectedGroups(
|
||||
dataset: Dataset,
|
||||
monthId: MonthId,
|
||||
today: IsoDate,
|
||||
horizonMonths = 12,
|
||||
): ExpectedGroups {
|
||||
const byMonth = occurrencesByMonth(dataset, addMonths(monthId, horizonMonths));
|
||||
const pending = (byMonth.get(monthId) ?? []).filter((o) => !o.confirmedBy);
|
||||
|
||||
let overdue = 0;
|
||||
const seen = new Set<string>();
|
||||
// Costs with an outstanding slot at or before `monthId` — X3: none of
|
||||
// these may get a "coming up" row until that slot is settled.
|
||||
const unresolved = new Set<string>(pending.map((o) => o.recurringId));
|
||||
const comingUp: Occurrence[] = [];
|
||||
|
||||
for (const month of [...byMonth.keys()].sort(compareMonths)) {
|
||||
const cmp = compareMonths(month, monthId);
|
||||
if (cmp === 0) continue; // already `pending`, above.
|
||||
|
||||
const occurrences = byMonth.get(month)!;
|
||||
if (cmp < 0) {
|
||||
// Everything unconfirmed in an EARLIER month, but not before
|
||||
// foldStartMonth: foldBalances never folds those, so an occurrence out
|
||||
// there never held the projected balance down in the first place, and
|
||||
// counting it here would make the sentence below claim a gap that isn't
|
||||
// in the number the owner is looking at. The same bound applies to X3's
|
||||
// suppression, for the same reason — a slot the fold never tracked was
|
||||
// never "outstanding" in any number the owner can see.
|
||||
if (compareMonths(month, dataset.settings.foldStartMonth) < 0) continue;
|
||||
for (const o of occurrences) {
|
||||
if (o.confirmedBy) continue;
|
||||
overdue += 1;
|
||||
unresolved.add(o.recurringId);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// A future month within the horizon. Months are visited in ascending
|
||||
// order, and a single cost's own occurrences are already emitted in date
|
||||
// order, so the first unconfirmed one seen for a given recurringId is
|
||||
// that cost's soonest pending occurrence beyond the displayed month.
|
||||
for (const occurrence of occurrences) {
|
||||
if (occurrence.confirmedBy) continue;
|
||||
if (seen.has(occurrence.recurringId)) continue;
|
||||
seen.add(occurrence.recurringId);
|
||||
if (unresolved.has(occurrence.recurringId)) continue; // X3
|
||||
const cost = dataset.recurring.find((c) => c.id === occurrence.recurringId);
|
||||
if (!cost || !wouldAdvancePast(cost, occurrence.date, today)) continue; // X1
|
||||
comingUp.push(occurrence);
|
||||
}
|
||||
}
|
||||
|
||||
return { pending, overdue, comingUp };
|
||||
}
|
||||
|
||||
/**
|
||||
* The month's expected charges, and the one click that turns each into a real
|
||||
* purchase.
|
||||
*
|
||||
* Confirmation is offered on any unconfirmed occurrence in this month,
|
||||
* INCLUDING one whose date has not arrived yet. That is not an oversight: a
|
||||
* bill anchored to its last charge can charge early — a phone plan whose data
|
||||
* cap is hit on the 12th when the slot sat on the 30th — and confirming the
|
||||
* pending slot with the real date is exactly how the series rebases.
|
||||
*
|
||||
* The same is true one month further out: "Coming up" offers the next
|
||||
* pending occurrence even when it lands after the displayed month, because
|
||||
* confirming it EARLY — dated today rather than the future slot — is what
|
||||
* rebases a `lastCharge` series. A this-month row still confirms at the
|
||||
* slot's own date; only a coming-up row records today instead.
|
||||
*/
|
||||
export function ExpectedBand({ monthId }: { monthId: MonthId }) {
|
||||
const dataset = useDataset();
|
||||
const { mutate } = useMutate();
|
||||
const [amounts, setAmounts] = useState<Record<string, string>>({});
|
||||
|
||||
const { pending, overdue, comingUp } = useMemo(
|
||||
() => expectedGroups(dataset, monthId, today),
|
||||
[dataset, monthId, today],
|
||||
);
|
||||
|
||||
if (pending.length === 0 && overdue === 0 && comingUp.length === 0) return null;
|
||||
|
||||
const nameOf = (recurringId: string) =>
|
||||
dataset.recurring.find((c) => c.id === recurringId)?.name ?? "Unknown";
|
||||
|
||||
/**
|
||||
* One row, shared by "Expected" and "Coming up" — they differ only in the
|
||||
* subtitle and in what date confirming records, per `recordedDateFor`.
|
||||
*/
|
||||
function row(occurrence: Occurrence, recordedDate: IsoDate | null) {
|
||||
const key = `${occurrence.recurringId}:${occurrence.date}`;
|
||||
const typed = amounts[key];
|
||||
const { parsed, invalid } = resolveExpectedAmount(
|
||||
typed,
|
||||
dataset.currencies,
|
||||
occurrence.amount.currency,
|
||||
);
|
||||
return (
|
||||
<li key={key} className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2 py-2.5">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm">{nameOf(occurrence.recurringId)}</div>
|
||||
<div className="text-xs text-budget-ink-muted">
|
||||
{recordedDate ? (
|
||||
<>
|
||||
Due {occurrence.date} — confirming now records it {recordedDate}
|
||||
</>
|
||||
) : (
|
||||
occurrence.date
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div>
|
||||
<Input
|
||||
className="h-8 w-28"
|
||||
// text, not number: a number input discards the "$" before
|
||||
// parseMoneyInput can read it.
|
||||
type="text"
|
||||
aria-label={`Amount for ${nameOf(occurrence.recurringId)} on ${occurrence.date}`}
|
||||
aria-invalid={invalid}
|
||||
value={typed ?? String(occurrence.amount.amount)}
|
||||
onChange={(event) => {
|
||||
const next = event.target.value;
|
||||
setAmounts((current) => ({ ...current, [key]: next }));
|
||||
}}
|
||||
/>
|
||||
{invalid && (
|
||||
<div className="mt-1 text-xs text-destructive">
|
||||
Doesn't look like an amount — Confirm is disabled.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-budget-ink-muted">{occurrence.amount.currency}</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={invalid}
|
||||
onClick={() => {
|
||||
// Captured before mutate, per AGENTS.md: mutate defers behind
|
||||
// the write queue and React resets the input first, so reading
|
||||
// in the callback commits stale text. `invalid` already guards
|
||||
// this (and disables the button), so reaching here means
|
||||
// `parsed` is either a real amount or the field was never
|
||||
// touched.
|
||||
if (invalid) return;
|
||||
mutate((draft) =>
|
||||
confirmOccurrence(draft, occurrence.recurringId, occurrence.date, {
|
||||
date: recordedDate ?? undefined,
|
||||
amount: parsed ?? undefined,
|
||||
}),
|
||||
);
|
||||
setAmounts((current) => {
|
||||
const next = { ...current };
|
||||
delete next[key];
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Section
|
||||
title="Expected"
|
||||
hint="Committed but not yet spent. These do not touch a post's balance until you confirm one, which records it as an ordinary purchase you can edit or delete afterwards."
|
||||
>
|
||||
{pending.length > 0 && (
|
||||
<ul className="divide-y divide-budget-rule">
|
||||
{pending.map((occurrence) => row(occurrence, recordedDateFor("pending", today)))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{comingUp.length > 0 && (
|
||||
<div className={pending.length > 0 ? "mt-4" : ""}>
|
||||
<h3 className="mb-1 text-[0.6875rem] font-medium uppercase tracking-wider text-budget-ink-muted">
|
||||
Coming up
|
||||
</h3>
|
||||
<ul className="divide-y divide-budget-rule">
|
||||
{comingUp.map((occurrence) => row(occurrence, recordedDateFor("comingUp", today)))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{overdue > 0 && (
|
||||
<p className="mt-3 text-xs leading-relaxed text-budget-ink-muted">
|
||||
{overdue} earlier {overdue === 1 ? "occurrence has" : "occurrences have"} never
|
||||
been confirmed. Until {overdue === 1 ? "it is" : "they are"}, the projected
|
||||
balance stays lower than the real one by that amount.
|
||||
</p>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -38,6 +38,10 @@ interface RowCells {
|
||||
charges: string;
|
||||
remaining: string;
|
||||
remainingTone: string;
|
||||
/** What is left once unconfirmed recurring bills are counted too. Always
|
||||
* rendered in the muted ink token, never toned, so `remaining` stays the
|
||||
* one figure the eye lands on first. */
|
||||
projected: string;
|
||||
segments: MeterSegment[];
|
||||
}
|
||||
|
||||
@@ -59,6 +63,7 @@ function postRowCells({ figures }: MonthPostRow, fmt: MoneyFormat): RowCells {
|
||||
charges: fmt.amount(figures.charges),
|
||||
remaining: fmt.signedAmount(figures.remaining),
|
||||
remainingTone: figures.remaining < 0 ? "text-overspend" : "",
|
||||
projected: fmt.signedAmount(figures.projected),
|
||||
segments: carryMeterSegments(figures),
|
||||
};
|
||||
}
|
||||
@@ -70,7 +75,9 @@ function postRowCells({ figures }: MonthPostRow, fmt: MoneyFormat): RowCells {
|
||||
export function PostTableLegend({ baseCurrency }: { baseCurrency: Currency }) {
|
||||
return (
|
||||
<span className="flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<span>All figures in {baseCurrency}.</span>
|
||||
<span>
|
||||
All figures in {baseCurrency}. Projected subtracts bills not yet confirmed.
|
||||
</span>
|
||||
<Swatch token="--surplus" label="carried in" />
|
||||
<Swatch token="--budget-accent" label="spent" />
|
||||
<Swatch token="--budget-rule" label="unspent" />
|
||||
@@ -178,6 +185,7 @@ export function PostTable({ monthId, rows, onChangeRule }: Props) {
|
||||
<th className="py-2 pl-6 text-right font-medium">Allocated</th>
|
||||
<th className="py-2 pl-6 text-right font-medium">Spent</th>
|
||||
<th className="py-2 pl-6 text-right font-medium">Remaining</th>
|
||||
<th className="py-2 pl-6 text-right font-medium">Projected</th>
|
||||
</tr>
|
||||
</thead>
|
||||
{/* One tbody per post, holding the figures AND their meter. Multiple
|
||||
@@ -214,11 +222,14 @@ export function PostTable({ monthId, rows, onChangeRule }: Props) {
|
||||
>
|
||||
{cells.remaining}
|
||||
</td>
|
||||
<td className="font-money pb-0 pl-6 pt-3 text-right text-budget-ink-muted">
|
||||
{cells.projected}
|
||||
</td>
|
||||
</tr>
|
||||
{/* The meter IS the row divider: tight under its own figures,
|
||||
with the gap below it. */}
|
||||
<tr className="transition-colors group-hover:bg-accent/60">
|
||||
<td colSpan={5} className="px-0 pb-4 pt-1.5">
|
||||
<td colSpan={6} className="px-0 pb-4 pt-1.5">
|
||||
<Meter segments={cells.segments} className="h-1" />
|
||||
</td>
|
||||
</tr>
|
||||
@@ -255,6 +266,9 @@ export function PostTable({ monthId, rows, onChangeRule }: Props) {
|
||||
<span>
|
||||
spent <span className="font-money">{cells.charges}</span>
|
||||
</span>
|
||||
<span>
|
||||
projected <span className="font-money">{cells.projected}</span>
|
||||
</span>
|
||||
{/* Pushed to the right end of the line it wraps onto, so six
|
||||
of these down a phone screen read as a quiet affordance
|
||||
rather than as each row's primary action. */}
|
||||
|
||||
@@ -43,7 +43,7 @@ interface Props {
|
||||
|
||||
export function PurchaseDialog({ monthId, purchase, trigger }: Props) {
|
||||
const dataset = useDataset();
|
||||
const { mutate } = useMutate();
|
||||
const { mutate, error, clearError } = useMutate();
|
||||
const activePosts = dataset.posts
|
||||
.filter((p) => !p.archived)
|
||||
.sort((a, b) => a.order - b.order);
|
||||
@@ -74,23 +74,25 @@ export function PurchaseDialog({ monthId, purchase, trigger }: Props) {
|
||||
setBulk(emptyBulkDraft(monthId, activePosts[0]?.id ?? ""));
|
||||
}
|
||||
|
||||
// A write can be REFUSED, not merely fail on a full disk: editing the date
|
||||
// of a purchase that confirms a recurring occurrence is rejected when the
|
||||
// new date would stop the series advancing. Closing the dialog regardless
|
||||
// would show the user a save that silently did nothing, so the dialog stays
|
||||
// open and shows `error` instead. `mutate` never rejects.
|
||||
function save() {
|
||||
if (errors.length > 0) return;
|
||||
if (bulkMode) {
|
||||
// One mutate for the whole batch: a single write, one queue entry, and no
|
||||
// way to half-commit a shopping trip.
|
||||
const created = toPurchases(bulk);
|
||||
mutate((data) => {
|
||||
for (const p of created) addPurchase(data, p);
|
||||
});
|
||||
} else {
|
||||
mutate((data) => {
|
||||
if (purchase) updatePurchase(data, purchase.id, toPurchase(draft));
|
||||
else addPurchase(data, toPurchase(draft));
|
||||
});
|
||||
}
|
||||
setOpen(false);
|
||||
if (!purchase) reset();
|
||||
// One mutate for the whole batch: a single write, one queue entry, and no
|
||||
// way to half-commit a shopping trip.
|
||||
const created = bulkMode ? toPurchases(bulk) : null;
|
||||
void mutate((data) => {
|
||||
if (created) for (const p of created) addPurchase(data, p);
|
||||
else if (purchase) updatePurchase(data, purchase.id, toPurchase(draft));
|
||||
else addPurchase(data, toPurchase(draft));
|
||||
}).then((saved) => {
|
||||
if (!saved) return;
|
||||
setOpen(false);
|
||||
if (!purchase) reset();
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -98,7 +100,10 @@ export function PurchaseDialog({ monthId, purchase, trigger }: Props) {
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
setOpen(next);
|
||||
if (next) reset();
|
||||
if (next) {
|
||||
reset();
|
||||
clearError();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>{trigger}</DialogTrigger>
|
||||
@@ -321,6 +326,12 @@ export function PurchaseDialog({ monthId, purchase, trigger }: Props) {
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
Could not save: {error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
|
||||
@@ -6,14 +6,24 @@ import type { Dataset } from "../../domain/types.ts";
|
||||
* Wraps store.mutate so a failed write surfaces to the user instead of
|
||||
* vanishing into an unhandled rejection. The snapshot is unchanged on failure,
|
||||
* so the UI keeps showing the last saved state.
|
||||
*
|
||||
* `mutate` returns whether the write landed, and the returned promise never
|
||||
* rejects — the error is already in `error` by the time it settles. A caller
|
||||
* that only fires and forgets can ignore it; one that would otherwise dismiss
|
||||
* its own UI (a dialog, say) can wait and keep it open, because a refusal the
|
||||
* user cannot see is barely better than one that never happened.
|
||||
*/
|
||||
export function useMutate() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const mutate = useCallback((fn: (draft: Dataset) => void) => {
|
||||
store.mutate(fn).catch((cause: unknown) => {
|
||||
setError(cause instanceof Error ? cause.message : String(cause));
|
||||
});
|
||||
const mutate = useCallback((fn: (draft: Dataset) => void): Promise<boolean> => {
|
||||
return store.mutate(fn).then(
|
||||
() => true,
|
||||
(cause: unknown) => {
|
||||
setError(cause instanceof Error ? cause.message : String(cause));
|
||||
return false;
|
||||
},
|
||||
);
|
||||
}, []);
|
||||
|
||||
return { mutate, error, clearError: useCallback(() => setError(null), []) };
|
||||
|
||||
@@ -12,11 +12,15 @@ function figures(partial: Partial<PostMonthFigures>): PostMonthFigures {
|
||||
const carriedIn = partial.carriedIn ?? 0;
|
||||
const allocation = partial.allocation ?? 0;
|
||||
const charges = partial.charges ?? 0;
|
||||
const remaining = partial.remaining ?? carriedIn + allocation - charges;
|
||||
return {
|
||||
carriedIn,
|
||||
allocation,
|
||||
charges,
|
||||
remaining: partial.remaining ?? carriedIn + allocation - charges,
|
||||
remaining,
|
||||
expected: partial.expected ?? 0,
|
||||
projectedCarriedIn: partial.projectedCarriedIn ?? carriedIn,
|
||||
projected: partial.projected ?? remaining,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import { sliceAmountForMonth } from "../../domain/charges.ts";
|
||||
import { useMoneyFormat } from "../hooks/useMoneyFormat.ts";
|
||||
import { PostTable, PostTableLegend } from "../components/PostTable.tsx";
|
||||
import { PurchaseDialog } from "../components/PurchaseDialog.tsx";
|
||||
import { ExpectedBand } from "../components/ExpectedBand.tsx";
|
||||
import { Meter } from "../components/Meter.tsx";
|
||||
import { Section, Stat } from "../components/Section.tsx";
|
||||
import { allocatedPercentOfIncome, allocationMeterSegments } from "../meterSegments.ts";
|
||||
@@ -124,6 +125,8 @@ export function MonthRoute() {
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<ExpectedBand monthId={monthId} />
|
||||
|
||||
<Section
|
||||
title="Posts"
|
||||
hint={<PostTableLegend baseCurrency={base} />}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { CurrencySection } from "./settings/CurrencySection.tsx";
|
||||
import { PostsSection } from "./settings/PostsSection.tsx";
|
||||
import { RecurringSection } from "./settings/RecurringSection.tsx";
|
||||
import { DataSection } from "./settings/DataSection.tsx";
|
||||
import { ColophonSection } from "./settings/ColophonSection.tsx";
|
||||
|
||||
@@ -21,6 +22,7 @@ export function SettingsRoute() {
|
||||
<h1 className="text-2xl">Settings</h1>
|
||||
<CurrencySection />
|
||||
<PostsSection />
|
||||
<RecurringSection />
|
||||
<DataSection />
|
||||
<ColophonSection />
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { isValidEndedFrom, isValidStartDate, normalizedStartDateFor } from "./RecurringSection.tsx";
|
||||
|
||||
// C2: the "Starts" field must never be able to persist an empty or
|
||||
// malformed value — `isValidStartDate` is the guard the onBlur handler uses
|
||||
// to decide whether to call `mutate` at all.
|
||||
describe("isValidStartDate", () => {
|
||||
test("an empty string is never valid, for any kind", () => {
|
||||
expect(isValidStartDate("", "everyNMonths")).toBe(false);
|
||||
expect(isValidStartDate("", "everyNDays")).toBe(false);
|
||||
expect(isValidStartDate("", "everyNWeeks")).toBe(false);
|
||||
});
|
||||
|
||||
test("everyNMonths accepts either granularity", () => {
|
||||
expect(isValidStartDate("2026-09", "everyNMonths")).toBe(true);
|
||||
expect(isValidStartDate("2026-09-17", "everyNMonths")).toBe(true);
|
||||
});
|
||||
|
||||
test("everyNMonths still rejects garbage", () => {
|
||||
expect(isValidStartDate("not-a-date", "everyNMonths")).toBe(false);
|
||||
expect(isValidStartDate("2026-13", "everyNMonths")).toBe(false);
|
||||
});
|
||||
|
||||
test("a day-granular kind rejects a month-only value", () => {
|
||||
// This is the exact C1/C2 crash: monthOf() would happily hand this back,
|
||||
// but occurrencesOf hands it to addDays, which throws.
|
||||
expect(isValidStartDate("2026-09", "everyNDays")).toBe(false);
|
||||
expect(isValidStartDate("2026-09", "everyNWeeks")).toBe(false);
|
||||
});
|
||||
|
||||
test("a day-granular kind accepts a day-granular value", () => {
|
||||
expect(isValidStartDate("2026-09-17", "everyNDays")).toBe(true);
|
||||
expect(isValidStartDate("2026-09-17", "everyNWeeks")).toBe(true);
|
||||
});
|
||||
|
||||
test("a day-granular kind rejects a malformed day-granular-shaped value", () => {
|
||||
expect(isValidStartDate("2026-02-30", "everyNDays")).toBe(false); // no Feb 30
|
||||
});
|
||||
|
||||
// Residual C1: `monthOf` only extracts the month and never checks the day,
|
||||
// so a day-SHAPED but calendar-impossible value ("2026-09-31" — September
|
||||
// has 30 days) used to pass here even under everyNMonths, and could then be
|
||||
// stored, only for foldBalances to throw three months later.
|
||||
test("everyNMonths still rejects a day-granular value with an impossible day", () => {
|
||||
expect(isValidStartDate("2026-09-31", "everyNMonths")).toBe(false);
|
||||
});
|
||||
|
||||
test("everyNMonths accepts a real leap-day and rejects a non-leap Feb 29", () => {
|
||||
expect(isValidStartDate("2024-02-29", "everyNMonths")).toBe(true); // 2024 is a leap year
|
||||
expect(isValidStartDate("2026-02-29", "everyNMonths")).toBe(false); // 2026 is not
|
||||
expect(isValidStartDate("2026-02-28", "everyNMonths")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// GAP 2 (spec §10): "Ends" needs no granularity rule (unlike "Starts"), but
|
||||
// still has to be a real calendar date, and empty must be accepted since it
|
||||
// is how the field un-ends a cost.
|
||||
describe("isValidEndedFrom", () => {
|
||||
test("an empty string is valid — it means 'not ended'", () => {
|
||||
expect(isValidEndedFrom("")).toBe(true);
|
||||
});
|
||||
|
||||
test("both granularities are valid, unconditionally — no recurrence kind is involved", () => {
|
||||
expect(isValidEndedFrom("2026-09")).toBe(true);
|
||||
expect(isValidEndedFrom("2026-09-17")).toBe(true);
|
||||
});
|
||||
|
||||
test("garbage is rejected", () => {
|
||||
expect(isValidEndedFrom("not-a-date")).toBe(false);
|
||||
expect(isValidEndedFrom("2026-13")).toBe(false);
|
||||
});
|
||||
|
||||
test("a calendar-impossible date is rejected, day- or month-shaped", () => {
|
||||
expect(isValidEndedFrom("2026-02-30")).toBe(false); // no Feb 30
|
||||
expect(isValidEndedFrom("2026-02-29")).toBe(false); // 2026 is not a leap year
|
||||
});
|
||||
|
||||
test("a real leap day is accepted", () => {
|
||||
expect(isValidEndedFrom("2024-02-29")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// C1: switching the unit dropdown to a day-granular kind must expand a
|
||||
// month-only startDate in the same mutate call, never leaving the pairing
|
||||
// unvalidated for even one render.
|
||||
describe("normalizedStartDateFor", () => {
|
||||
test("expands a month-only date when switching to a day-granular kind", () => {
|
||||
expect(normalizedStartDateFor("everyNDays", "2026-09")).toBe("2026-09-01");
|
||||
expect(normalizedStartDateFor("everyNWeeks", "2026-09")).toBe("2026-09-01");
|
||||
});
|
||||
|
||||
test("leaves an already day-granular date untouched", () => {
|
||||
expect(normalizedStartDateFor("everyNDays", "2026-09-17")).toBe("2026-09-17");
|
||||
});
|
||||
|
||||
test("leaves the date untouched when switching to everyNMonths", () => {
|
||||
expect(normalizedStartDateFor("everyNMonths", "2026-09")).toBe("2026-09");
|
||||
expect(normalizedStartDateFor("everyNMonths", "2026-09-17")).toBe("2026-09-17");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,395 @@
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { NativeSelect } from "@/components/ui/native-select";
|
||||
import { useDataset } from "../../hooks/useDataset.ts";
|
||||
import { useMutate } from "../../hooks/useMutate.ts";
|
||||
import {
|
||||
addRecurringCost,
|
||||
endRecurringCost,
|
||||
moveRecurringCost,
|
||||
restoreRecurringCost,
|
||||
setRecurringCostEndedFrom,
|
||||
updateRecurringCost,
|
||||
} from "../../../store/actions.ts";
|
||||
import { parseMoneyInput } from "../../moneyInput.ts";
|
||||
import { currentMonth } from "../../../store/index.ts";
|
||||
import { Section } from "../../components/Section.tsx";
|
||||
import { monthOf } from "../../../domain/months.ts";
|
||||
import { toDayOrdinal } from "../../../domain/days.ts";
|
||||
import type { IsoDate, Recurrence, RecurringCost } from "../../../domain/types.ts";
|
||||
|
||||
const WEEKDAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
||||
|
||||
/** "YYYY-MM" is 7 characters; "YYYY-MM-DD" is 10. Same idiom PurchaseDialog uses. */
|
||||
export function isDayGranular(date: IsoDate): boolean {
|
||||
return date.length > 7;
|
||||
}
|
||||
|
||||
/**
|
||||
* True only for a string this cost's recurrence kind could actually walk:
|
||||
* `everyNMonths` tolerates either granularity (the walk normalises through
|
||||
* `monthOf`), the other two require a day-granular date because they reach
|
||||
* `addDays` directly. Reuses the domain's own parsers rather than a second,
|
||||
* possibly-diverging regex.
|
||||
*
|
||||
* A day-SHAPED value is routed through `toDayOrdinal` even under
|
||||
* `everyNMonths`: `monthOf` only extracts the month and never checks the day,
|
||||
* so without this a calendar-impossible date like "2026-09-31" (September has
|
||||
* 30 days) would pass here and could still be stored, only to throw three
|
||||
* months later out of the fold.
|
||||
*
|
||||
* Exported for `RecurringSection.test.ts` — this is the one thing standing
|
||||
* between the "Starts" field and C2 (an empty or malformed value bricking
|
||||
* every route the fold touches), so it is tested directly rather than only
|
||||
* through a DOM interaction.
|
||||
*/
|
||||
export function isValidStartDate(value: string, kind: Recurrence["kind"]): boolean {
|
||||
try {
|
||||
if (kind === "everyNMonths" && !isDayGranular(value)) monthOf(value);
|
||||
else toDayOrdinal(value);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the "Ends" column. Unlike `startDate`, `endedFrom` carries NO
|
||||
* granularity rule (§10 of the design) — it is only ever compared
|
||||
* lexicographically via `isBefore` in `domain/occurrences.ts` and never
|
||||
* reaches `addDays`, so a month-only cancellation is exactly as valid as a
|
||||
* day-granular one regardless of the cost's recurrence kind. It still has to
|
||||
* be a real calendar date: "2026-02-30" is refused the same way an
|
||||
* impossible `startDate` already is.
|
||||
*
|
||||
* An empty string is valid — it means "clear the cancellation" — and is what
|
||||
* lets the field double as the un-end control.
|
||||
*
|
||||
* Exported for `RecurringSection.test.ts`.
|
||||
*/
|
||||
export function isValidEndedFrom(value: string): boolean {
|
||||
if (value === "") return true;
|
||||
try {
|
||||
if (isDayGranular(value)) toDayOrdinal(value);
|
||||
else monthOf(value);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What the "Starts" value becomes when the owner switches to a day-granular
|
||||
* unit: a month-only value expands to the first of that month, in the SAME
|
||||
* mutate call that changes the kind (C1) — the walk must never see the old
|
||||
* pairing even for one render. `everyNMonths` and an already day-granular
|
||||
* value pass through untouched.
|
||||
*
|
||||
* Exported for `RecurringSection.test.ts`.
|
||||
*/
|
||||
export function normalizedStartDateFor(kind: Recurrence["kind"], startDate: IsoDate): IsoDate {
|
||||
if (kind === "everyNMonths" || isDayGranular(startDate)) return startDate;
|
||||
return `${startDate}-01`;
|
||||
}
|
||||
|
||||
export function RecurringSection() {
|
||||
const dataset = useDataset();
|
||||
const { mutate } = useMutate();
|
||||
const [newName, setNewName] = useState("");
|
||||
const ordered = [...dataset.recurring].sort((a, b) => a.order - b.order);
|
||||
const base = dataset.settings.baseCurrency;
|
||||
const firstPost = [...dataset.posts].sort((a, b) => a.order - b.order)[0];
|
||||
|
||||
function amountCell(cost: RecurringCost) {
|
||||
return (
|
||||
<Input
|
||||
className="h-8 w-28"
|
||||
// text, not number: a number input strips the "$" before
|
||||
// parseMoneyInput ever sees it, so "30$" would lose its currency.
|
||||
type="text"
|
||||
aria-label={`Amount for ${cost.name}`}
|
||||
defaultValue={`${cost.amount.amount} ${cost.amount.currency}`}
|
||||
onBlur={(event) => {
|
||||
// Captured before mutate: mutate defers behind the write queue and
|
||||
// React resets the DOM value first, so reading event.target.value
|
||||
// inside the callback would commit the previous keystroke.
|
||||
const typed = event.target.value;
|
||||
// (text, currency options, fallback) — the same three arguments
|
||||
// PurchaseDialog passes. The options list is what lets "30$" resolve
|
||||
// to USD; the fallback is used when no symbol or code is typed.
|
||||
const parsed = parseMoneyInput(typed, dataset.currencies, cost.amount.currency);
|
||||
if (!parsed) return;
|
||||
mutate((draft) => updateRecurringCost(draft, cost.id, { amount: parsed }));
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function intervalCell(cost: RecurringCost) {
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<Input
|
||||
className="h-8 w-20"
|
||||
type="number"
|
||||
min={1}
|
||||
step={1}
|
||||
aria-label={`Interval for ${cost.name}`}
|
||||
value={cost.recurrence.n}
|
||||
onChange={(event) => {
|
||||
// Captured BEFORE mutate: mutate defers behind the write queue and
|
||||
// React resets the DOM value first, so reading it inside the
|
||||
// callback commits the previous keystroke.
|
||||
const n = Number(event.target.value);
|
||||
if (!Number.isInteger(n) || n < 1) return;
|
||||
mutate((draft) =>
|
||||
updateRecurringCost(draft, cost.id, { recurrence: { ...cost.recurrence, n } }),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<NativeSelect
|
||||
className="h-8 w-auto text-xs md:text-xs"
|
||||
aria-label={`Unit for ${cost.name}`}
|
||||
value={cost.recurrence.kind}
|
||||
onChange={(event) => {
|
||||
const kind = event.target.value as Recurrence["kind"];
|
||||
const recurrence: Recurrence =
|
||||
kind === "everyNWeeks"
|
||||
? { kind, n: cost.recurrence.n, weekday: 1 }
|
||||
: { kind, n: cost.recurrence.n };
|
||||
// Switching to a day-granular kind against a month-only
|
||||
// startDate is exactly how C1 bricked the app: the walk hands
|
||||
// "2026-09" to addDays and throws. Expand it to the first of
|
||||
// that month IN THIS SAME mutate call, so the pairing the store
|
||||
// now validates never has a chance to go wrong.
|
||||
const startDate = normalizedStartDateFor(kind, cost.startDate);
|
||||
mutate((draft) =>
|
||||
updateRecurringCost(draft, cost.id, { recurrence, startDate }),
|
||||
);
|
||||
}}
|
||||
>
|
||||
<option value="everyNDays">days</option>
|
||||
<option value="everyNWeeks">weeks</option>
|
||||
<option value="everyNMonths">months</option>
|
||||
</NativeSelect>
|
||||
{cost.recurrence.kind === "everyNWeeks" && (
|
||||
<NativeSelect
|
||||
className="h-8 w-auto text-xs md:text-xs"
|
||||
aria-label={`Weekday for ${cost.name}`}
|
||||
value={cost.recurrence.weekday}
|
||||
onChange={(event) => {
|
||||
const weekday = Number(event.target.value);
|
||||
mutate((draft) =>
|
||||
updateRecurringCost(draft, cost.id, {
|
||||
recurrence: { kind: "everyNWeeks", n: cost.recurrence.n, weekday },
|
||||
}),
|
||||
);
|
||||
}}
|
||||
>
|
||||
{WEEKDAYS.map((day, index) => (
|
||||
<option key={day} value={index}>
|
||||
{day}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Section
|
||||
title="Recurring costs"
|
||||
hint="Bills that repeat. Each one projects forward as EXPECTED spending and is confirmed from the month view when the money actually moves — expected never touches a post's real balance. Anchoring decides where the next charge is measured from: the calendar, or the date the last one actually charged. Use the second for a bill that can charge early, like a phone plan that recharges when its data runs out."
|
||||
>
|
||||
{ordered.length === 0 ? (
|
||||
<p className="text-sm text-budget-ink-muted">
|
||||
No recurring costs yet. Add rent, a subscription, or a phone bill below.
|
||||
</p>
|
||||
) : (
|
||||
<div className="-mr-2 overflow-x-auto pr-2">
|
||||
<table className="w-full min-w-[46rem] text-sm">
|
||||
<thead className="text-left">
|
||||
<tr className="border-b border-budget-rule text-[0.6875rem] uppercase tracking-wider text-budget-ink-muted">
|
||||
<th className="py-2 font-medium">Name</th>
|
||||
<th className="py-2 font-medium">Amount</th>
|
||||
<th className="py-2 font-medium">Every</th>
|
||||
<th className="py-2 font-medium">Measured from</th>
|
||||
<th className="py-2 font-medium">Starts</th>
|
||||
<th className="py-2 font-medium">Ends</th>
|
||||
<th className="py-2" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ordered.map((cost, index) => (
|
||||
<tr
|
||||
key={cost.id}
|
||||
className={`border-b last:border-0 ${cost.archived ? "opacity-50" : ""}`}
|
||||
>
|
||||
<td className="py-2">
|
||||
<Input
|
||||
className="h-8 w-40"
|
||||
value={cost.name}
|
||||
onChange={(event) => {
|
||||
const name = event.target.value;
|
||||
mutate((draft) => updateRecurringCost(draft, cost.id, { name }));
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2">{amountCell(cost)}</td>
|
||||
<td className="py-2">{intervalCell(cost)}</td>
|
||||
<td className="py-2">
|
||||
<NativeSelect
|
||||
className="h-8 w-auto text-xs md:text-xs"
|
||||
aria-label={`Anchoring for ${cost.name}`}
|
||||
value={cost.anchoring}
|
||||
onChange={(event) => {
|
||||
const anchoring = event.target.value as RecurringCost["anchoring"];
|
||||
mutate((draft) => updateRecurringCost(draft, cost.id, { anchoring }));
|
||||
}}
|
||||
>
|
||||
<option value="calendar">the calendar</option>
|
||||
<option value="lastCharge">the last charge</option>
|
||||
</NativeSelect>
|
||||
</td>
|
||||
<td className="py-2">
|
||||
{/* Uncontrolled: committing only happens on blur, and only
|
||||
when the text parses as an IsoDate of the granularity
|
||||
this cost's recurrence kind requires. Keyed on the
|
||||
stored value so an external change (kind flip
|
||||
auto-expanding it, or a rejected edit) is reflected
|
||||
rather than left showing stale typed text. */}
|
||||
<Input
|
||||
key={`${cost.id}-${cost.startDate}`}
|
||||
className="h-8 w-32"
|
||||
type="text"
|
||||
aria-label={`Start date for ${cost.name}`}
|
||||
defaultValue={cost.startDate}
|
||||
onBlur={(event) => {
|
||||
const typed = event.target.value;
|
||||
if (!isValidStartDate(typed, cost.recurrence.kind)) {
|
||||
event.target.value = cost.startDate;
|
||||
return;
|
||||
}
|
||||
mutate((draft) =>
|
||||
updateRecurringCost(draft, cost.id, { startDate: typed }),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2">
|
||||
{/* Same idiom as "Starts": uncontrolled, committed on
|
||||
blur, keyed on the stored value so an external change
|
||||
(the end/restart button, or a rejected edit) shows up
|
||||
rather than leaving stale typed text. Empty means "not
|
||||
ended" and is itself a valid value — clearing it here
|
||||
does the same thing as the "restart" button. */}
|
||||
<Input
|
||||
key={`${cost.id}-ends-${cost.endedFrom ?? ""}`}
|
||||
className="h-8 w-32"
|
||||
type="text"
|
||||
placeholder="—"
|
||||
aria-label={`Ends date for ${cost.name}`}
|
||||
defaultValue={cost.endedFrom ?? ""}
|
||||
onBlur={(event) => {
|
||||
const typed = event.target.value.trim();
|
||||
if (!isValidEndedFrom(typed)) {
|
||||
event.target.value = cost.endedFrom ?? "";
|
||||
return;
|
||||
}
|
||||
mutate((draft) =>
|
||||
setRecurringCostEndedFrom(
|
||||
draft,
|
||||
cost.id,
|
||||
typed === "" ? undefined : typed,
|
||||
),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
disabled={index === 0}
|
||||
onClick={() => mutate((draft) => moveRecurringCost(draft, cost.id, -1))}
|
||||
>
|
||||
up
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
disabled={index === ordered.length - 1}
|
||||
onClick={() => mutate((draft) => moveRecurringCost(draft, cost.id, 1))}
|
||||
>
|
||||
down
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
className="-mr-2"
|
||||
onClick={() =>
|
||||
mutate((draft) =>
|
||||
cost.archived
|
||||
? restoreRecurringCost(draft, cost.id)
|
||||
: endRecurringCost(draft, cost.id, currentMonth),
|
||||
)
|
||||
}
|
||||
>
|
||||
{cost.archived ? "restart" : "end"}
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="mt-3 max-w-[70ch] text-xs leading-relaxed text-budget-ink-muted">
|
||||
Ending a cost — from the "end" button, or by typing a date into
|
||||
"Ends" directly — stops it from that date onward and leaves every past
|
||||
occurrence exactly as it was; clearing the date restarts it. Changing
|
||||
the amount only moves occurrences that have not been confirmed yet —
|
||||
what you already paid is recorded on the purchases themselves, so a
|
||||
price rise never rewrites history.
|
||||
</p>
|
||||
|
||||
<div className="mt-5 flex flex-wrap items-end gap-2 border-t border-budget-rule pt-5">
|
||||
<Input
|
||||
className="w-48"
|
||||
placeholder="New cost name"
|
||||
value={newName}
|
||||
onChange={(event) => setNewName(event.target.value)}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={newName.trim() === "" || !firstPost}
|
||||
onClick={() => {
|
||||
const name = newName.trim();
|
||||
mutate((draft) =>
|
||||
addRecurringCost(draft, {
|
||||
name,
|
||||
archived: false,
|
||||
amount: { amount: 0, currency: base },
|
||||
startDate: currentMonth,
|
||||
recurrence: { kind: "everyNMonths", n: 1 },
|
||||
anchoring: "calendar",
|
||||
splitMode: "percent",
|
||||
splits: [{ postId: firstPost!.id, value: 100, absorbsRemainder: true }],
|
||||
}),
|
||||
);
|
||||
setNewName("");
|
||||
}}
|
||||
>
|
||||
Add recurring cost
|
||||
</Button>
|
||||
{!firstPost && (
|
||||
<span className="text-xs text-budget-ink-muted">Create a post first.</span>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user