docs(recurring): two schedules, and why they must not be unified

AGENTS.md §1's old invariant was half wrong the moment anchored costs lost
their slots: occurrenceDate is identity for calendar only. The replacement says
what each schedule is and, more usefully, warns against collapsing them —
that collapse is exactly the bug where paying twice in September pushed the
next charge to November.

§2's termination bullet now describes a defensive assert rather than a live
guard, because the anchored walk steps forward from a real date and the grid
walk never rebases. Also added the anchoring-change-refused-once-charged
guard, which was undocumented in AGENTS.md despite having its own test suite
and UI-level guard.

DECISIONS.md, PRODUCT.md, ARCHITECTURE.md and TODO.md catch up to what
shipped: two projection models and the three guards it deleted, a recurring
cost charging more than once per cycle and being creatable from the purchase
dialog, the anchored walk beside the grid walk, and schema 8 at 730 tests.
The 2026-09-03 spec gets a header note pointing at the new spec and nothing
else — it stays an accurate record of what was decided then.

Demo data seeds two charges in one month on an anchored cost (Car insurance),
so the fixed behaviour is the first thing a screenshot shows.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5 committed 2026-09-06 00:05:36 +02:00
1 parent 35caa123cc
commit 49811f6795
7 files changed
+111 -25

No files matched your search

+29 -10
View File
@@ -56,12 +56,15 @@ and `docs/specs/`, and is not repeated here.
"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.
- **A recurring cost has one of two schedules, and they are not the same
shape.** A `calendar` cost is a fixed grid: `source.occurrenceDate` is the
slot a charge settles, and the grid never moves. A `lastCharge` cost has NO
grid — its next due date is one cycle after the latest charge, so a charge
carries no slot at all and its own `Purchase.date` is the whole story. That
is what lets a phone plan charge many times in one cycle, each restarting
the clock. Do not "unify" them: the grid is right for rent and wrong for a
bill that restarts on payment, and collapsing them reintroduces the bug
where paying twice in September pushed the next charge to November.
- **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.
@@ -117,12 +120,28 @@ ship twice — re-introducing one is a regression, not a style choice.
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/store/actions.ts` and in `src/export/json.ts`. That is what makes both
projection walks terminate: the grid walk never rebases, and the anchored
walk only ever steps forward from a real date (the latest charge, or
`startDate` if there is none), so with `n >= 1` enforced at both boundaries
every step strictly advances. Each walk still throws if a step ever fails to
advance — a defensive assert against a state the two boundaries should
already have made unreachable, not a condition either walk can currently
hit — and that throw must never be softened into a `break`: a clamped zero,
or a silently dropped bill, would hand the owner a schedule their data does
not describe.
→ `src/domain/occurrences.test.ts`, `src/store/actions.test.ts`,
`src/export/json.test.ts`
- **A recurring cost's `anchoring` cannot change once it has a charge**, in
`updateRecurringCost`. `calendar` -> `lastCharge` could strip the
now-forbidden slot ids mechanically, but the reverse cannot: there is no
honest way to invent which slot each existing charge settled. Allowing it
would let the write succeed and leave charges that silently fail
`requireSourcePairing`'s pairing rule the next time anything touches them,
or reach `confirmationsFor`, which throws by design on exactly this shape
(see §1's schedule invariant) — several frames below the edit that caused
it. End the cost and start a new one instead; history stays intact either
way. → `src/store/actions.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 -4
View File
@@ -28,9 +28,20 @@ The dependency rule is one-directional: `ui -> store -> domain`. `domain` import
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.
arithmetic with no `Date` at all, and `occurrences.ts` computes a recurring
cost's occurrences fresh on every fold rather than storing them, by one of two
walks chosen on `cost.anchoring`. A `calendar` cost (`gridOccurrences`) is a
fixed grid of slots stepped forward from `startDate`; nothing a payment does
moves it, so paying September AND October's rent correctly leaves nothing due
until November. A `lastCharge` cost (`anchoredOccurrences`) has no grid at
all — its next due date is one cycle after whichever charge is most recent, so
it can be charged any number of times in one cycle and each charge restarts
the clock, which is what a phone plan that recharges when its data runs out
actually does. The two walks share only `stepFrom`, which is also where
`everyNMonths` splits: month-granular (`addMonths`) under `calendar`, day-precise
and clamping (`addMonthsToDate`, `days.ts`) under `lastCharge`, because a plan
refreshed on the 20th is next due on the 20th, not at the start of the
following month.
`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
@@ -63,7 +74,12 @@ 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`.
counted in `charges` and skipped by `expected`. A `lastCharge` cost reconverges
the same way but by a different mechanism: it has no `confirmedBy` to skip, so
it reconverges because the anchored walk itself never re-projects a cycle that
has already been charged — it starts one cycle after the latest charge, full
stop. Whether a still-open cycle's next due date lands in this month or a
later one depends on the recurrence, same as it always has.
If you find yourself writing `carriedIn + allocation - charges` outside
`fold.ts`, that is the invariant breaking. It matters because two screens
+16 -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 7.
user's only copy of their financial data. Now at version 8.
→ [`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`
@@ -105,6 +105,21 @@ record.
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)
- **Two projection models, one per anchoring, over a single unified model.**
`calendar` walks a fixed grid of slots; `lastCharge` has no grid and steps
from whichever charge is most recent. Chosen over one model where a payment
never claims a slot and a `calendar` occurrence counts as settled by falling
in its period (rejected: two rent payments in a month would settle one slot
and leave the other floating), and over keeping slots but letting one hold
several payments (rejected: it caps a phone plan at one extra charge per
cycle rather than the "infinite number of times a month" the owner
described). Deleted three guards that existed only to stop a confirmation
landing behind the slot it claims — `wouldAdvancePast` and `rebasedFrom` in
`src/domain/occurrences.ts`, the "Coming up" offerability guard in
`src/ui/components/ExpectedBand.tsx`, and `requireConfirmationAdvances` in
`src/store/actions.ts` — because with no slots on the anchored path the
situation they guarded against cannot arise.
→ [`specs/2026-09-05-recurring-costs-anchored-model.md`](specs/2026-09-05-recurring-costs-anchored-model.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
+13 -5
View File
@@ -68,11 +68,19 @@ Confirmed and in place:
- 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.
every N weeks on a weekday, split across posts like any purchase. A cost is
anchored one of two ways: `calendar` holds a fixed grid of due dates, so
paying two months at once still leaves nothing due until the one after; a
cost anchored to its **last charge** has no grid at all and can be charged
any number of times in one cycle, its next due date always one cycle after
whichever charge was most recent — a phone plan that recharges early when
its data runs out is the case this exists for. Each due date on the
calendar model is a projected occurrence the owner confirms into a real
purchase; a recurring cost can also be tagged onto a purchase, or created
from one, while it is being entered, rather than only from Settings. 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
+15 -4
View File
@@ -8,16 +8,20 @@ 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`. 583 tests passing,
**Current state:** merged to `main`. 730 tests passing,
`bunx tsc --noEmit` clean, `bun run build` succeeds on both the live-fetch and
offline paths.
**Schema is at version 7.** v1 -> v2 made allocation rules a dated series;
**Schema is at version 8.** 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;
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.
adds `Dataset.recurring`; v7 -> v8 moves a `lastCharge` cost off slots — it
strips `occurrenceDate` from every anchored charge and expands a month-only
`startDate` on an anchored `everyNMonths` cost to a day-granular one. Unlike
the first six, this step is NOT behaviour-preserving by construction: an
anchored cost's projected due dates genuinely change shape, because they no
longer come from a grid at all. It is still tested, the same as the others.
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
@@ -146,6 +150,13 @@ Genuinely outstanding:
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.
- **Whether the anchored (`lastCharge`) model actually matches how the
owner's phone plan bills.** The design
(`docs/specs/2026-09-05-recurring-costs-anchored-model.md`) rebases the
series on whatever was last charged, restarting the clock every time — a
reasonable model of "refreshes when the data runs out", built from the
owner's own description rather than from watching a real bill arrive. Only
a cycle or two of real use confirms the shape is right.
- **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
@@ -1,5 +1,12 @@
# Recurring costs
> **Note (2026-09-05):** the `lastCharge` design this document describes —
> confirming an occurrence early rebases the whole series from that slot — was
> replaced. `lastCharge` costs no longer have slots at all; see
> `docs/specs/2026-09-05-recurring-costs-anchored-model.md`. This document is
> left as written: an accurate record of what was decided and built at the
> time, not a description of the system as it stands today.
Bills that repeat: rent every month, a subscription every 28 days, a phone bill
every 30 days whose clock restarts whenever the data cap is hit early.
+11 -1
View File
@@ -77,7 +77,7 @@ actions.addRecurringCost(d, {
});
// 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, {
const carInsurance = actions.addRecurringCost(d, {
name: "Car insurance",
archived: false,
amount: { amount: 349, currency: "DKK" },
@@ -93,6 +93,16 @@ actions.addRecurringCost(d, {
// already reconverged for this one.
actions.confirmOccurrence(d, wowSub.id, "2026-06-05", { date: "2026-06-05" });
// TWO CHARGES IN ONE MONTH on an anchored cost — the reported case. Car
// insurance rebases on whatever it last actually charged, not on a calendar
// slot, so charging twice in September must not push the next due date two
// cycles out to November; it lands one cycle (30 days) after the LATER
// charge, in October. Confirming these through `confirmOccurrence` carries no
// `occurrenceDate` (anchored costs have no slots to settle) — only
// `Purchase.date` matters here.
actions.confirmOccurrence(d, carInsurance.id, "2026-09-03");
actions.confirmOccurrence(d, carInsurance.id, "2026-09-19");
const buy = (date: string, description: string, amount: number, currency: "DKK" | "EUR" | "USD", postId: string, note?: string) =>
actions.addPurchase(d, {
date, description, note,