docs(specs): recurring costs, expected against actual

Bills that repeat: rent monthly, a subscription every 28 days, a phone bill
every 30 days whose clock restarts when the data cap is hit early.

A recurring cost projects expected charges forward; each occurrence is
confirmed into a real Purchase when the money moves. The fold gains a second
balance, so `remaining` never shifts and `projected` says what is left after
commitments. The two reconverge as a month settles.

The phone bill reads like a third recurrence kind and is not one. It charges
30 days after the PREVIOUS CHARGE, and the data cap only makes a charge happen
early — a date two-stage confirmation already records. So `anchoring` is one
field, `calendar` or `lastCharge`, and the domain never learns what a data cap
is. That collapse is most of why this design is small.

Two consequences worth not re-litigating later:

- `everyNMonths` carries no day. Rent occurs IN September, and `IsoDate`
  already permits a bare "YYYY-MM", so the commonest case needs no day
  arithmetic and no February clamping rule.
- `amount` is a single mutable Money with no RuleVersion-style series. Safe
  ONLY because confirmed purchases hold what was actually paid; under
  projection-only it would destroy history.

Occurrences are computed, never stored — materialised snapshots were rejected
in the original design, and an unbounded recurrence would otherwise need a
horizon. `upToMonth` comes from the caller, so src/domain/ stays clock-free.

Takes schema 6. The global-decimals spec claimed 6 too and is reassigned to 7;
both specs now say so. The steps are independent and compose in either order.

Spec only. The two settings-layout fixes in the tree are unrelated and stay
unstaged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5 committed 2026-09-03 02:50:05 +02:00
1 parent d69e896c4c
commit 596de45525
1 file changed
+409
@@ -0,0 +1,409 @@
# Recurring costs
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.
A recurring cost projects *expected* charges forward, and each occurrence is
confirmed into a real `Purchase` when the money actually moves. The fold
therefore carries two balances per post per month: what is actually left, and
what is left after commitments.
## What this is not
It is not a new way to spend money. `Purchase` remains the only record of money
that moved, and `chargesForMonth` is untouched — a confirmed occurrence flows
through it as an ordinary purchase. What is new is a second, parallel track of
money that is *expected* to move, which never touches `remaining`.
It is also not a materialised schedule. Occurrences are computed on every fold
and never persisted, because the original design rejected materialised month
snapshots and because an unbounded recurrence would otherwise need an arbitrary
horizon. The fold's `upToMonth` is the bound, and it comes from the caller — so
`src/domain/` stays clock-free.
## The three cases, and why there are only two mechanisms
| case | recurrence | anchoring |
| --- | --- | --- |
| Rent | `everyNMonths(1)` | `calendar` |
| World of Warcraft | `everyNDays(28)` | `calendar` |
| Phone bill with a data cap | `everyNDays(30)` | `lastCharge` |
The phone bill reads like a third kind of recurrence and is not one. Restated:
it charges 30 days after the *previous charge*, and hitting the data cap simply
causes a charge to happen early. Under two-stage confirmation the date a charge
actually happened is already recorded — it is `Purchase.date` on the
confirmation. So "every 30 days from the last actual charge" is the whole
behaviour, and `anchoring` is one field rather than a subsystem.
Nothing in the domain knows what a data cap is, and nothing needs to. The owner
hits the cap, confirms that occurrence dated the day it charged, and the series
rebases itself.
## The shape
### 1. Types
```ts
/** How occurrence dates are generated. Extensible: a new variant here plus one
* case in `stepFrom` — nothing else in the app switches on this. */
export type Recurrence =
| { kind: "everyNMonths"; n: number }
| { kind: "everyNDays"; n: number }
| { kind: "everyNWeeks"; n: number; weekday: number };
/** Where the NEXT occurrence is measured from. */
export type Anchoring = "calendar" | "lastCharge";
export type RecurringCostId = string;
export interface RecurringCost {
id: RecurringCostId;
name: string;
order: number;
/** Archived, never deleted — confirmed purchases reference it. */
archived: boolean;
/** Expected amount, in its own currency. Converted to base at fold time. */
amount: Money;
/** The first occurrence. "YYYY-MM" for everyNMonths, "YYYY-MM-DD" otherwise. */
startDate: IsoDate;
/** Occurrences on or after this 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[];
}
```
`Dataset` gains `recurring: RecurringCost[]`. `Purchase` gains one optional
field:
```ts
export interface Purchase {
// ...unchanged...
/** Present when this purchase confirms a projected occurrence. */
source?: { recurringId: RecurringCostId; occurrenceDate: IsoDate };
}
```
**`everyNMonths` carries no day.** Rent occurs *in* September, not on the 3rd
of it. `IsoDate` already permits a bare `"YYYY-MM"` — "the app is about monthly
spending, so a day is optional" (`types.ts`) — so a monthly cost produces
month-granular occurrences, needs no day arithmetic, and no clamping rule for
February ever has to exist. Only `everyNDays` and `everyNWeeks` are
day-granular.
**`source.occurrenceDate` and `Purchase.date` are different facts**, and the
pair is what makes the phone bill work:
- `source.occurrenceDate` is **identity** — the slot the projector generated.
Projection suppresses a slot that a confirmed purchase already claims.
- `Purchase.date` is **truth** — when the money moved. The fold uses it, and
under `lastCharge` the next occurrence is measured from it.
They are equal when a bill is paid on schedule and diverge when it is not.
### 2. No dated amount series, and why that is safe
`amount` is a single mutable `Money`. A rent increase edits the number.
This would be wrong for `Post.rules`, where changing an allocation must never
destroy history (PRODUCT principle 2) and a `RuleVersion[]` series is what
guarantees it. Recurring costs need no equivalent, because every past
occurrence is already a confirmed `Purchase` holding the amount actually paid.
The rule describes what is expected next; the ledger holds what happened. Only
unconfirmed future occurrences move when the amount is edited, which is exactly
what editing it means.
This is a direct consequence of two-stage confirmation. Under projection-only
it would not hold, and a dated series would be required.
### 3. `src/domain/days.ts`
The first day-level arithmetic in the domain, and deliberately in the same
character as `months.ts`: integer math on a day ordinal, no `Date`, no
timezones anywhere.
`toDayOrdinal` / `fromDayOrdinal` (civil-date conversion), `addDays`, and
`weekdayOf` = `(ordinal + 4) % 7`, since day 0 is a Thursday.
Date **comparison stays lexicographic**. ISO strings already sort correctly,
and it gives mixed granularity the right meaning for free:
`"2026-09" < "2026-09-17" < "2026-10"`, so a month-only date reads as the start
of that month — which is what `endedFrom` needs when a monthly cost is
cancelled.
### 4. `src/domain/occurrences.ts` — the walk
Occurrences cannot be queried one month at a time. Under `lastCharge`, where
the series sits in month M depends on every confirmation before it. So the
projector walks from `startDate`, and the fold calls it once per fold.
```
cursor = cost.startDate
while monthOf(cursor) <= upToMonth:
if cost.endedFrom and cursor >= cost.endedFrom: break
confirmation = confirmations.get(cursor) // keyed by source.occurrenceDate
emit { date: cursor, amount, confirmedBy: confirmation?.id ?? null }
next = stepFrom(cost.anchoring === "lastCharge" && confirmation
? confirmation.date // actual — rebases the series
: cursor) // projected — assume paid on time
if next <= cursor: throw
cursor = next
```
`stepFrom` per kind:
| kind | step |
| --- | --- |
| `everyNMonths(n)` | `addMonths(monthOf(d), n)` — result stays month-granular |
| `everyNDays(n)` | `addDays(d, n)` |
| `everyNWeeks(n, weekday)` | `addDays(d, 7n)`, then snap forward to `weekday` |
The weekly snap is identity when the cursor is already on the named weekday, and
does the right thing when a `lastCharge` rebase lands off it.
An unconfirmed occurrence advances from its own projected date — the projection
assumes bills are paid on time — so the series is deterministic however far
ahead it runs, and self-corrects as occurrences are confirmed.
**Termination.** The walk is safe only if `stepFrom` strictly increases. `n` is
validated as an integer `>= 1` at both write boundaries (§8, §9), and the loop
additionally throws if the cursor fails to advance. A throw rather than a break,
per AGENTS.md: a hang is the one failure mode worse than a visible error, and
silently dropping a bill would be worse still.
**Phase.** The walk always starts at `startDate` even when that precedes
`foldStartMonth`, because under `lastCharge` the phase depends on the whole
history. The fold reads only the months it folds. The cost is bounded and
trivial — thirty-five years of a 28-day cycle is about thirteen thousand
integer additions.
### 5. Confirmation, and the occurrence nobody confirms
Suppression is by `source.occurrenceDate`, which means there is no
reconciliation pass and no stored link to keep consistent:
- Delete the confirming purchase and the slot un-confirms itself, reappearing
as expected.
- Edit its amount and only the actual figure moves; the expectation is
unchanged.
- If a `lastCharge` rebase shifts the series so a confirmed slot is no longer
generated, the purchase stands — real money moved — and simply suppresses
nothing. There is nothing to reconcile, because the ledger is not wrong.
**A slot may be confirmed with a date earlier than the slot itself**, and this
is the normal path for the phone bill rather than an edge case. The pending
occurrence sits 30 days out; the cap is hit on day 12; the owner confirms that
pending slot with a purchase dated day 12. The slot is *which* charge this is,
not *when* it was due, so confirming one early is meaningful and is what rebases
the series. The month view therefore offers confirmation on the next pending
occurrence, not only on ones whose date has passed.
An occurrence in a **past** month that is never confirmed stays expected
forever, holding the `projected` track permanently below `remaining`. That is
the correct reading: an unreconciled commitment. But it is silent drift, so the
overdue count in §10 is a required part of this change, not a nicety.
### 6. Fold integration
`chargesForMonth` is untouched. Confirmations are ordinary purchases and
already flow through it.
One extraction pays for itself: the split-distribution half of
`chargesForPurchaseInMonth` becomes a shared helper taking
`(amount, splits, splitMode, ...)`, called by both real charges and expected
ones. Without it the two paths could round differently or absorb the remainder
differently, and the difference would show up as a penny of phantom drift
between the tracks.
`PostMonthFigures` gains three fields, and `foldBalances` runs two accumulators
over its existing loop:
```
remaining = carriedIn + allocation - charges
projected = projectedCarriedIn + allocation - charges - expected
```
Both are rounded with the same base digits, in the same place, as `remaining`
is today.
Two invariants keep this honest, and both are tests:
- **With no recurring costs, `projected === remaining` and
`projectedCarriedIn === carriedIn` in every month.** Nothing existing moves.
- **Once every occurrence in a month is confirmed, the two tracks reconverge
there.** The past settles; only the future is a forecast.
### 7. Migration 5 -> 6
One appended step: `recurring: []` on the dataset. Nothing else —
`Purchase.source` is optional, so no purchase is rewritten.
Behaviour-preserving by construction: an empty array projects no occurrences,
so every existing dataset folds to the same `remaining` it did before, and
`projected` equals it everywhere.
The step writes a literal `[]` and reads no live default, so the
no-live-default guard is satisfied with nothing to freeze.
> **Ordering, settled.** `docs/specs/2026-09-02-global-decimals-design.md`
> originally claimed 6 as well. Recurring costs take 6; that change becomes
> 6 -> 7 and its spec has been updated to say so. The two steps are independent
> — this one writes `recurring: []`, that one rewrites where digits live — so
> they compose in either implementation order as long as the version numbers
> stay as assigned here.
### 8. Validation — `export/json.ts`
Per `recurring` entry: `amount` through the existing `requireCurrency` against
the currency table; `startDate` and optional `endedFrom` parse as `IsoDate`;
the `recurrence` discriminant is one of the three kinds with **`n` an integer
`>= 1`**; `weekday` is 0-6 for `everyNWeeks`; `anchoring` is one of the two
literals; `splits` is non-empty with exactly one `absorbsRemainder` and every
`postId` resolving to a post.
`n >= 1` is a hard reject rather than a clamp because it is what makes §4
terminate. A clamped zero would silently change the bill's schedule; a rejected
one tells the owner their file is wrong.
`Purchase.source`, when present: `recurringId` resolves and `occurrenceDate`
parses.
### 9. Store actions
`addRecurringCost`, `updateRecurringCost`, `archiveRecurringCost`,
`reorderRecurringCost`, and:
```ts
confirmOccurrence(draft, recurringId, occurrenceDate, overrides?: {
date?: IsoDate;
amount?: Money;
}): PurchaseId
```
which builds an ordinary `Purchase` carrying `source`, copying the cost's
`splits` and `splitMode`, and ensuring the target month exists the way
`addPurchase` already does — so income can be entered for a month a
confirmation was the first thing to touch.
`addRecurringCost` and `updateRecurringCost` apply the same `recurrence`
checks as §8 — integer `n >= 1`, `weekday` in range, a known `kind` — because
§4 terminates only if every write path enforces them, and the JSON importer is
not the only write path.
Archived, never deleted, for the same reason posts are: confirmed purchases
reference the cost.
### 10. UI
**`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.
**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.
**The overdue count** — unconfirmed occurrences from earlier months — appears in
that band. §5 is otherwise silent drift.
Two existing rules apply directly to the inline amount field: capture
`event.target.value` into a `const` before `mutate` (AGENTS.md §2, the
deferred-write rule), and route every `store.*` call through `useMutate`.
### 11. Tests
- **`domain/days.test.ts`** — ordinal round-trip across leap years and century
boundaries, weekday against known dates.
- **`domain/occurrences.test.ts`** — all three kinds; `calendar` against
`lastCharge`; the phone bill end to end (30-day cycle, cap hit on day 12,
series rebases to day 42, cap hit again); `endedFrom`; suppression;
un-confirming by deleting the purchase; and the strictly-increasing throw.
- **`domain/fold.test.ts`** — the two convergence invariants from §6.
- **`store/migrations.test.ts`** — 5 -> 6 adds the empty array and moves no
figure.
- **`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`** — a recurring cost in the zero-decimal
currency, so the expected track rounds through the same guard as everything
else.
`scripts/demo-data.ts` seeds a monthly rent and a 28-day subscription, so the
band is screenshot-verifiable.
## Alternatives considered
- **Projection only, with no confirmation.** Recurring costs charge
automatically and are never reconciled. Much smaller: no `source`, no
suppression, no second balance, no overdue count. Rejected by the owner in
favour of keeping expected and actual distinguishable. It would also have
forced a dated amount series (§2), since nothing else would hold the history
of what was actually paid.
- **Generation only: materialise real purchases from a template.** The fold,
splits, FX and ODS export need no changes at all. Rejected because it needs a
horizon and a notion of "today" to know how far to generate, both of which
the domain is built to avoid, and because editing the template raises an
unanswerable question about already-generated future rows.
- **Extending `Purchase.schedule` with a generative variant**, preserving the
original design's "one `Purchase` entity covers all three cases". Rejected:
under two-stage confirmation that purchase's job is to spawn purchases, so
every consumer of `Purchase` — the month list, ODS export, the split editor,
JSON validation — would have to learn to skip templates. The one-entity rule
was about splits and finance plans composing; a template is not a third case
of a purchase.
- **Recurrence as a property of a `Post`.** Rejected: a post is a budget
envelope, and a bill can split across several of them.
- **A `dataCap` concept for the phone bill.** Rejected once it became clear
that `lastCharge` anchoring plus the confirmation date reproduces the
behaviour exactly, with no usage tracking and no third recurrence kind.
- **`monthlyOnDay(day)` for rent**, with clamping for short months. Rejected in
favour of day-less `everyNMonths`: the owner does not think about rent as
landing on a date, `IsoDate` already supports month-only values, and it keeps
day arithmetic out of the most common case entirely.
## If this needs undoing
Deleting `Dataset.recurring` and the `expected` / `projected` fields is a 6 -> 7
migration that loses no ledger data — every confirmed occurrence is already an
ordinary `Purchase` and stays one, with a dangling `source` that validation
would then ignore. The reverse of §7, plus dropping two modules.
Narrowing rather than reverting is cheaper still: keeping the projector but
dropping the second balance means deleting three fields from
`PostMonthFigures` and leaving §6's first invariant as the only behaviour.
## Out of scope
- **ODS export keeps exporting actual figures only.** The spreadsheet is a
record of what happened, not a forecast.
- **Year and summary views show the actual track only.** They are queued for
redesign in `docs/TODO.md` §1; a second track there is that sub-project's
call.
- **The carry meter is unchanged.** `meterSegments.ts` takes
`PostMonthFigures` and keeps compiling; painting expected onto the meter is a
design question, not a data one.
- **No usage tracking, no data caps, no notifications, no reminders.** The
phone bill works through confirmation dates alone.
- **No bulk confirmation.** One click per occurrence until there is evidence
that is too slow.
## Documents to update
- **AGENTS.md §1** — two invariants: occurrences are computed and never stored;
a confirmation's `Purchase.date` is truth while `source.occurrenceDate` is
identity.
- **AGENTS.md §2** — the termination guard, and why `n >= 1` is rejected rather
than clamped.
- **docs/DECISIONS.md** — a new "Recurring costs" heading pointing here, and
"Now at version 5" becomes 6.
- **docs/PRODUCT.md** — the capability list and the terminology section gain
the concept.
- **docs/ARCHITECTURE.md** — the layer diagram and the derived-values list gain
`occurrences.ts` and the second balance.