docs(specs): the anchored model for recurring costs
The first version modelled the phone bill as "confirm a projected slot early", and that was wrong at the root. A charge is not a slot claimed ahead of schedule — it is an event that can happen at any moment, any number of times per cycle, each one restarting the clock. There is no grid to claim; there is a moving anchor. Confirming twice in one September pushed the next due date to November, because each confirmation consumed a future slot instead of resetting the anchor. Reproduced under BOTH anchorings, which is what proves the defect is in the slot model rather than in the rebase rule. So the two anchorings get two projections, and the asymmetry is the design. Paying September AND October's rent really does mean nothing is due until November — for a calendar bill, consuming two slots is correct. Only the anchored case is wrong, so only the anchored case changes. The anchored walk cannot fail to advance: it steps forward from a real date with n >= 1. That makes the entire class of "confirmation dated behind its slot" defects unreachable — the class that produced three Criticals in round 1 — so wouldAdvancePast, the band's offerability guard and PATH A's updatePurchase guard are all deleted. This change removes more code than it adds. everyNMonths becomes day-granular under lastCharge, since a plan refreshing on 20 September is next due 20 October, not "October". That finally forces a month-clamp rule (31 Jan + 1 month is 28 Feb), confined to the anchored path. Also closes the two additive requests: a cost's splits become editable through the same SplitEditor purchases use, and the purchase dialog can tag a purchase as a charge for a cost or create one from the purchase being typed. Tagging is the refresh mechanism, so the data cap stops needing a flow of its own. Self-review caught the band's Confirm being removed for anchored costs, which would have made paying a bill on time slower to serve the exceptional case.
This commit is contained in:
1 parent
f20a220dae
commit
c2b3d00381
1 file changed
+397
@@ -0,0 +1,397 @@
|
||||
# Recurring costs, round 2: the anchored model
|
||||
|
||||
Three problems the owner hit within a day of the first version shipping:
|
||||
|
||||
1. A recurring cost's post is fixed at creation and cannot be changed.
|
||||
2. There is no way to create a recurring cost while entering a purchase.
|
||||
3. Confirming twice in one month pushes the next due date two cycles out.
|
||||
|
||||
The third is the one that reshapes the design. The first two are additive.
|
||||
|
||||
## What the phone bill actually is
|
||||
|
||||
The original spec collapsed the data-cap case into "confirm a projected slot
|
||||
early", and that was wrong at the root. Restated by the owner:
|
||||
|
||||
> a phone bill, that refreshes either every month, or everytime the user runs
|
||||
> out of data. Meaning it can theoretically refresh an infinite number of times
|
||||
> a month, and the cost should be associated with it — however the next
|
||||
> [due date] will always be the time its active — be that monthly, quarterly,
|
||||
> etc.
|
||||
|
||||
So a charge is not a slot being claimed ahead of schedule. A charge is an event
|
||||
that can happen at any moment, any number of times per cycle, and **each one
|
||||
restarts the clock**. There is no grid to claim against; there is a moving
|
||||
anchor.
|
||||
|
||||
Confirmed by probe against the shipped code — an `everyNMonths` cost with both
|
||||
September's and October's slots confirmed, both paid in September:
|
||||
|
||||
```
|
||||
series: 2026-09* 2026-10* 2026-11 ...
|
||||
next due: 2026-11 <- should be 2026-10
|
||||
```
|
||||
|
||||
Note this reproduces under BOTH anchorings, which is what proves the defect is
|
||||
in the slot model rather than in the rebase rule.
|
||||
|
||||
## The split: only `lastCharge` is wrong
|
||||
|
||||
Paying September and October's rent really does mean nothing is due until
|
||||
November. For a `calendar` bill, consuming two slots is CORRECT. The bug exists
|
||||
only where a second payment is this cycle restarting rather than next month's
|
||||
bill arriving early.
|
||||
|
||||
So the two anchorings get two projection models, and that asymmetry is the
|
||||
design rather than a compromise:
|
||||
|
||||
| | `calendar` (rent) | `lastCharge` (phone) |
|
||||
| --- | --- | --- |
|
||||
| Occurrences | fixed grid from `startDate` | next due = last charge + cycle |
|
||||
| A purchase | settles the slot it names | is a dated charge, names no slot |
|
||||
| Charges per cycle | one | any number |
|
||||
| Two payments in September | Sept and Oct settled | next due = Oct, from the later |
|
||||
| Granularity of `everyNMonths` | month (`"2026-09"`) | day (`"2026-09-20"`) |
|
||||
|
||||
## The shape
|
||||
|
||||
### 1. Types
|
||||
|
||||
`Purchase.source.occurrenceDate` becomes OPTIONAL:
|
||||
|
||||
```ts
|
||||
/**
|
||||
* Present when this purchase is a charge for a recurring cost.
|
||||
*
|
||||
* `occurrenceDate` is the slot this purchase settles, and exists ONLY for a
|
||||
* `calendar` cost, where a fixed grid of slots is what a payment settles.
|
||||
* A `lastCharge` cost has no grid — its next due date is derived from the
|
||||
* purchase's own `date` — so the field is absent, and its presence there
|
||||
* would be a lie about how the schedule is computed.
|
||||
*/
|
||||
source?: { recurringId: RecurringCostId; occurrenceDate?: IsoDate };
|
||||
```
|
||||
|
||||
`RecurringCost` is unchanged. `Recurrence` and `Anchoring` are unchanged.
|
||||
|
||||
### 2. The two projections
|
||||
|
||||
`occurrencesOf` splits on `cost.anchoring` at the top and runs one of two
|
||||
loops. They share `stepFrom` and nothing else.
|
||||
|
||||
**`calendar` — unchanged from what shipped.** A grid from `startDate`, stepped
|
||||
by the recurrence, each slot confirmed or not by the purchase whose
|
||||
`source.occurrenceDate` names it. `everyNMonths` stays month-granular. This
|
||||
path keeps every test it already has.
|
||||
|
||||
**`lastCharge` — new.**
|
||||
|
||||
```
|
||||
charges = purchases whose source.recurringId is this cost, sorted by date
|
||||
latest = the LATEST charge's date, or null when there are none
|
||||
anchor = latest === null ? null : max(latest, cost.startDate)
|
||||
cursor = anchor === null ? cost.startDate : stepFrom(anchor, recurrence)
|
||||
while monthOf(cursor) <= upToMonth:
|
||||
if endedFrom and cursor >= endedFrom: break
|
||||
emit { date: cursor, amount, confirmedBy: null }
|
||||
cursor = stepFrom(cursor, recurrence)
|
||||
```
|
||||
|
||||
Three things follow, and they are the argument for this shape:
|
||||
|
||||
- **Every `lastCharge` occurrence is unconfirmed by definition.** A charge that
|
||||
happened is a `Purchase`; it is not an occurrence at all. So there is no
|
||||
suppression logic, no `confirmationsFor` lookup, and `confirmedBy` is always
|
||||
null on this path.
|
||||
- **The walk cannot fail to advance.** It steps forward from a real date with
|
||||
`n >= 1`. The entire class of "confirmation dated behind its slot" defects —
|
||||
which produced three Criticals in the first version — becomes unreachable.
|
||||
- **Unlimited refreshes need no mechanism.** Three charges in September? The
|
||||
anchor is the third. Nothing has to fit anywhere.
|
||||
|
||||
The `max` in the anchor line is why a charge back-dated before the cost
|
||||
existed cannot pull the series backwards: the anchor never precedes
|
||||
`startDate`.
|
||||
|
||||
Per the owner's decision, the projection keeps cycling to the fold horizon
|
||||
rather than stopping after the next charge: the forward picture stays full, and
|
||||
every month past the first is understood to be a guess the data cap can revise.
|
||||
|
||||
### 3. Day-precise month stepping
|
||||
|
||||
A monthly phone plan that refreshes on 20 September is next due 20 October, not
|
||||
"October". So `everyNMonths` under `lastCharge` is day-granular, and month
|
||||
arithmetic needs a clamp rule for the first time in this codebase:
|
||||
|
||||
```ts
|
||||
/**
|
||||
* `date` plus `n` months, staying day-granular, clamping to the last day of
|
||||
* the target month. 31 Jan + 1 month is 28 Feb (29 in a leap year), which is
|
||||
* how a billing cycle behaves and what every calendar library settles on.
|
||||
*
|
||||
* Only `lastCharge` needs this. A `calendar` monthly cost is month-granular
|
||||
* and steps with `addMonths` on a MonthId, where the question cannot arise.
|
||||
*/
|
||||
export function addMonthsToDate(date: IsoDate, n: number): IsoDate
|
||||
```
|
||||
|
||||
It goes in `src/domain/days.ts`, which already has the private `daysInMonth`
|
||||
the clamp needs, and stays `Date`-free like everything else there.
|
||||
|
||||
`stepFrom` therefore needs the cost's anchoring, not just its recurrence:
|
||||
`stepFrom(date, recurrence, anchoring)`. For `everyNMonths` it picks
|
||||
`addMonths(monthOf(date), n)` under `calendar` and `addMonthsToDate(date, n)`
|
||||
under `lastCharge`. The other two kinds ignore the argument.
|
||||
|
||||
### 4. What gets DELETED
|
||||
|
||||
This change removes more code than it adds. All three of these exist to stop a
|
||||
confirmation landing behind the slot it claims, and with no slots on the
|
||||
anchored path the situation cannot arise:
|
||||
|
||||
- `wouldAdvancePast` in `src/domain/occurrences.ts`, and `rebasedFrom`, which
|
||||
only serves it and the old inline rebase.
|
||||
- The "Coming up" offerability guard in `src/ui/components/ExpectedBand.tsx`.
|
||||
- `requireConfirmationAdvances` in `src/store/actions.ts` — the guard added for
|
||||
PATH A, where editing a confirmed purchase's date through the ordinary
|
||||
purchase dialog could brick every money route. Under the anchored model,
|
||||
editing that date simply moves the anchor, which is correct behaviour and
|
||||
needs no permission.
|
||||
|
||||
The walk's termination throw STAYS, as a defensive assert. It should now be
|
||||
unreachable on both paths; if it ever fires again, something upstream is wrong
|
||||
and a visible error is still better than a hang.
|
||||
|
||||
The `useMutate` change that PATH A's fix brought with it — `mutate` returning
|
||||
`Promise<boolean>` so `PurchaseDialog` can stay open on a rejected write — is
|
||||
KEPT. It fixed a real defect (a save that silently did nothing) that has
|
||||
nothing to do with recurring costs.
|
||||
|
||||
### 5. The "Coming up" group
|
||||
|
||||
For a `calendar` cost it stays exactly as it is: paying rent early is real, and
|
||||
settling a future slot is what it means.
|
||||
|
||||
For a `lastCharge` cost the two rows split by what they mean:
|
||||
|
||||
- **This month's pending row keeps its one-click Confirm.** It records a charge
|
||||
dated the occurrence's own date — "paid on time" — which re-anchors from
|
||||
there. Entry speed is a product principle, and paying a bill on schedule is
|
||||
the common case; routing it through the full purchase dialog would make the
|
||||
ordinary act slower to serve the exceptional one.
|
||||
- **A "Coming up" row becomes INFORMATIONAL** — it names the next due date with
|
||||
no Confirm button. Confirming a future anchored charge IS the refresh case,
|
||||
and the refresh needs a real date and possibly a different amount, which is
|
||||
the purchase dialog's job (§6). A one-click button there would have to invent
|
||||
a date, and inventing it is what produced the round-1 Critical.
|
||||
|
||||
For `calendar` costs both rows keep their Confirm exactly as shipped. Deleting
|
||||
the offerability guard (§4) is safe there precisely because a calendar grid
|
||||
never rebases: confirming a future slot at today's date cannot move anything.
|
||||
|
||||
### 6. The purchase dialog
|
||||
|
||||
One new control, which serves both the owner's second request and the data-cap
|
||||
refresh:
|
||||
|
||||
```
|
||||
Recurring cost [ None | <each active cost> | + New recurring cost... ]
|
||||
```
|
||||
|
||||
- **Tagging an existing cost** is the refresh. Data runs out on the 20th; the
|
||||
owner enters the purchase and picks *Phone plan*. The money is booked and the
|
||||
cycle restarts from that date, because the anchor is the latest charge. For a
|
||||
`calendar` cost, tagging settles the occurrence in the purchase's own month,
|
||||
and a cost whose grid has no slot in that month is not offered for that date.
|
||||
- **"+ New recurring cost..."** reveals the cycle and anchoring fields and
|
||||
seeds the cost from the purchase being typed: its amount, its splits, its
|
||||
currency, its date as `startDate`. The purchase becomes the cost's first
|
||||
charge, so a `lastCharge` cost created this way is anchored from the moment
|
||||
it is created.
|
||||
|
||||
The dialog's existing rules bind here unchanged: capture `event.target.value`
|
||||
into a `const` before `mutate`, route writes through `useMutate`, keep the
|
||||
amount field `type="text"`.
|
||||
|
||||
### 7. `RecurringSection` gets the real split editor
|
||||
|
||||
The owner's first problem. `RecurringCost` has carried `splits` and `splitMode`
|
||||
with the same shape as `Purchase` since the first version, so this is wiring up
|
||||
a capability the data model already has rather than adding one.
|
||||
|
||||
`SplitEditor` is currently typed to `PurchaseDraft`. It reads only `amount`,
|
||||
`currency`, `splitMode` and `splits`, so it gets narrowed to what it uses:
|
||||
|
||||
```ts
|
||||
export interface SplitTarget {
|
||||
amount: number;
|
||||
currency: Currency;
|
||||
splitMode: "percent" | "fixed";
|
||||
splits: SplitDraft[];
|
||||
}
|
||||
|
||||
export function SplitEditor<T extends SplitTarget>(props: {
|
||||
draft: T;
|
||||
posts: Post[];
|
||||
onChange: (next: T) => void;
|
||||
}): JSX.Element
|
||||
```
|
||||
|
||||
`PurchaseDraft` satisfies `SplitTarget` structurally, so `PurchaseDialog`'s
|
||||
call site does not change at all. `RecurringSection` passes a cost-shaped
|
||||
object. `splitBalance` narrows the same way.
|
||||
|
||||
The per-row post picker, the percent/fixed toggle and the remainder absorber
|
||||
all come along for free — which means a bill can span envelopes, and the
|
||||
absorber rule stays identical to a purchase's.
|
||||
|
||||
### 8. Migration 7 -> 8
|
||||
|
||||
Two data changes, both narrow:
|
||||
|
||||
- **Strip `occurrenceDate`** from any purchase whose `source.recurringId`
|
||||
names a cost with `anchoring: "lastCharge"`. The field is meaningless on that
|
||||
path and leaving it would invite a future reader to trust it.
|
||||
- **Expand a month-only `startDate` to the 1st of that month** for any cost
|
||||
that is `everyNMonths` AND `lastCharge`, because day precision now requires a
|
||||
real day.
|
||||
|
||||
Not behaviour-preserving, and it must not claim to be: a `lastCharge` cost's
|
||||
projected dates deliberately change, which is the entire point of the change.
|
||||
What IS preserved is every recorded figure — no `Money` is touched, no purchase
|
||||
is added or removed, and `calendar` costs are not touched at all.
|
||||
|
||||
Per AGENTS.md, the step reads no live default. The two literals it needs
|
||||
(`"lastCharge"`, `"everyNMonths"`) are inline strings, not imported constants.
|
||||
|
||||
### 9. Validation
|
||||
|
||||
`src/export/json.ts` and `src/store/actions.ts` both change, and must keep
|
||||
agreeing with each other:
|
||||
|
||||
- `occurrenceDate` is REQUIRED when the named cost is `calendar`, and
|
||||
FORBIDDEN when it is `lastCharge`. Both boundaries enforce both directions —
|
||||
a file that carries one on an anchored cost is describing a schedule the app
|
||||
will not compute.
|
||||
- `startDate` granularity: day-granular for `everyNDays` and `everyNWeeks` as
|
||||
now; ALSO day-granular for `everyNMonths` + `lastCharge`; month-or-day for
|
||||
`everyNMonths` + `calendar`, as now. Calendar-impossible dates stay rejected
|
||||
through `toDayOrdinal`, per the round-1 residual fix.
|
||||
- Because a cost's anchoring now determines what a purchase's `source` may
|
||||
contain, **`updateRecurringCost` refuses to change `anchoring` on a cost that
|
||||
already has charges.** Switching `calendar` -> `lastCharge` could strip the
|
||||
now-forbidden `occurrenceDate`s mechanically, but the reverse cannot: there
|
||||
is no honest way to invent which slot each existing charge settled, and
|
||||
guessing would leave purchases claiming slots that are not on the new grid —
|
||||
real money settling nothing. Ending the cost and creating a new one preserves
|
||||
the history and states the change plainly. The UI disables the control with
|
||||
that reason rather than failing on save.
|
||||
|
||||
AGENTS.md §3 is untouched: over-budget, percentages past 100, splits that do
|
||||
not sum, and negative amounts all stay deliberately unvalidated.
|
||||
|
||||
### 10. Store actions
|
||||
|
||||
- `confirmOccurrence` keeps its shape for `calendar`. For `lastCharge` it
|
||||
writes `source` without `occurrenceDate`, and `overrides.date` is simply the
|
||||
charge's date.
|
||||
- A new `chargeRecurringCost(draft, recurringId, purchase)` is NOT added.
|
||||
Tagging a purchase in the dialog goes through `addPurchase` with a `source`,
|
||||
which is the same write path an ordinary purchase takes. One less action to
|
||||
keep in step.
|
||||
- `updatePurchase` loses `requireConfirmationAdvances` (§4) and gains nothing.
|
||||
|
||||
### 11. Tests
|
||||
|
||||
- **`domain/days.test.ts`** — `addMonthsToDate` clamping: 31 Jan -> 28 Feb, and
|
||||
29 Feb in a leap year; 31 Mar -> 30 Apr; a 12-month step landing on the same
|
||||
day; a step across a year boundary.
|
||||
- **`domain/occurrences.test.ts`** — the owner's exact case: two charges in one
|
||||
September, next due October and NOT November. Three charges in one month.
|
||||
A charge back-dated before `startDate` clamping to `startDate`. The full
|
||||
phone-bill flow: monthly cycle, refresh on the 20th, next due the 20th of the
|
||||
following month. And that `calendar` behaviour is byte-identical to what
|
||||
shipped — its existing tests must pass unmodified.
|
||||
- **`domain/fold.test.ts`** — `expected` and `projected` under the anchored
|
||||
model, including that a month containing three charges shows three actual
|
||||
charges and no expectation.
|
||||
- **`store/migrations.test.ts`** — 7 -> 8 strips `occurrenceDate` only from
|
||||
anchored costs' purchases, expands only anchored monthly `startDate`s, and
|
||||
moves no recorded figure.
|
||||
- **`export/json.test.ts`** — the required/forbidden `occurrenceDate` pairing
|
||||
in both directions, and the widened `startDate` granularity rule.
|
||||
- **`ui/components/PurchaseDialog`** — the new control's pure helpers: which
|
||||
costs are offerable for a given date, and the cost seeded by "+ New".
|
||||
- **`ui/routes/settings/RecurringSection`** — splits editable, absorber rule
|
||||
holds.
|
||||
|
||||
Each Critical from round 1 that this change makes structurally impossible keeps
|
||||
its regression test where the test still describes reachable behaviour, and
|
||||
loses it where the behaviour no longer exists. A test asserting a guard that
|
||||
has been deleted is noise, not coverage — but the deletion must be argued in
|
||||
the commit, not done quietly.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **One model for both anchorings**, with payments never claiming slots and a
|
||||
`calendar` occurrence counted as settled if any payment falls in its period.
|
||||
Rejected: two rent payments in one month would settle one slot and leave a
|
||||
charge floating with nothing to attach to, and "settled by period" is fuzzier
|
||||
than "settled by identity" for a bill that genuinely has a fixed grid.
|
||||
- **Keep slots, let a slot hold several payments, re-anchor from the latest.**
|
||||
The smallest possible change. Rejected because it does not deliver the
|
||||
requirement: it handles one extra payment per cycle, not "an infinite number
|
||||
of times a month" — the second refresh has no slot left to attach to, and
|
||||
attaching it to the NEXT slot is exactly the bug being fixed.
|
||||
- **Only the current month is confirmable** — drop "Coming up" entirely.
|
||||
Rejected: it removes paying rent early, which is legitimate under `calendar`,
|
||||
and it solves the anchored case only by forbidding the flow rather than
|
||||
modelling it.
|
||||
- **Track data usage in the app.** Never considered seriously; the app cannot
|
||||
know, and the owner recording a charge is the signal.
|
||||
|
||||
## If this needs undoing
|
||||
|
||||
The `calendar` path is untouched, so a revert is confined to the anchored one:
|
||||
restore the slot walk, make `occurrenceDate` required again, and reinstate the
|
||||
three deleted guards. The 8 -> 9 migration would need to invent an
|
||||
`occurrenceDate` for each anchored charge, which is lossy — the honest reverse
|
||||
is to migrate anchored costs to `calendar` and accept that their schedules
|
||||
shift. Which is a reason to be confident about this change before taking it,
|
||||
not a reason to avoid it.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- **Year and summary views** still show the actual track only. Unchanged from
|
||||
round 1 and still that sub-project's call.
|
||||
- **ODS export** still exports actual figures only.
|
||||
- **No data-cap or usage concept.** The owner recording a charge is the whole
|
||||
mechanism, exactly as in round 1.
|
||||
- **No bulk charge entry.** One purchase per refresh until there is evidence
|
||||
that is too slow.
|
||||
- **The deferred minors from round 1** that this change does not touch stay
|
||||
deferred: the demo dataset's Rent double-charge is fixed, but the inline
|
||||
"doesn't look like an amount" message still flashes on intermediate typing,
|
||||
and settings sections other than the purchase dialog still swallow write
|
||||
errors.
|
||||
|
||||
## Documents to update
|
||||
|
||||
- **AGENTS.md §1** — the invariant "a confirmation's `Purchase.date` is truth
|
||||
while `source.occurrenceDate` is identity" is now HALF wrong and must be
|
||||
rewritten: identity applies to `calendar` only, and on the anchored path the
|
||||
purchase's date is the whole story.
|
||||
- **AGENTS.md §2** — the `n >= 1` termination guard bullet stays, but its
|
||||
reasoning changes: the throw is now a defensive assert rather than a live
|
||||
guard, and the bullet should say so rather than describing a condition the
|
||||
code can no longer reach.
|
||||
- **docs/DECISIONS.md** — a new entry under "Recurring costs": two projection
|
||||
models over one, and what that was chosen over.
|
||||
- **docs/PRODUCT.md** — a recurring cost can charge more than once per cycle,
|
||||
and can be created while entering a purchase.
|
||||
- **docs/ARCHITECTURE.md** — the anchored walk beside the grid walk.
|
||||
- **docs/specs/2026-09-03-recurring-costs-design.md** — a header note pointing
|
||||
here, since its §4 and §5 now describe a model that only half survives. The
|
||||
old spec is not edited beyond that pointer: it is an accurate record of what
|
||||
was decided then, and rewriting history to match the present is how a
|
||||
decisions log stops being worth reading.
|
||||
Reference in new issue
Block a user