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:
1 parent
35caa123cc
commit
49811f6795
7 files changed
+111
-25
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user