Files
budget2.0/docs/specs/2026-09-03-recurring-costs-design.md
T
Lucas WintherandClaude Opus 5 596de45525 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>
2026-09-03 02:50:05 +02:00

18 KiB

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

/** 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:

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:

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.