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:
Lucas Winther
2026-09-04 23:57:30 +02:00
co-authored by Claude Opus 5
41 changed files with 4048 additions and 95 deletions
+23
View File
@@ -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.
+20
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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);
+2
View File
@@ -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", () => {
+97 -1
View File
@@ -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
View File
@@ -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;
}
+76 -5
View File
@@ -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);
});
});
+83
View File
@@ -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);
});
});
+95
View File
@@ -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
View File
@@ -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
View File
@@ -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);
+2
View File
@@ -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";
+9
View File
@@ -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/);
});
+7 -1
View File
@@ -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}`);
+287
View File
@@ -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);
});
});
+224
View File
@@ -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
View File
@@ -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: [],
};
}
+75
View File
@@ -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[];
}
+35
View File
@@ -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
View File
@@ -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
);
}
+310
View File
@@ -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
View File
@@ -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;
+466
View File
@@ -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");
});
});
+264
View File
@@ -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 },
});
}
+10
View File
@@ -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,
+42
View File
@@ -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);
}
}
});
});
+19
View File
@@ -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 {
+352
View File
@@ -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();
});
});
+301
View File
@@ -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>
);
}
+16 -2
View File
@@ -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. */}
+28 -17
View File
@@ -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>
+14 -4
View File
@@ -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), []) };
+5 -1
View File
@@ -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,
};
}
+3
View File
@@ -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} />}
+2
View File
@@ -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");
});
});
+395
View File
@@ -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>
);
}