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:
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.
|
||||
Reference in new issue
Block a user