docs(recurring): record the invariants and what they cost

Two rules that only review catches, so they go in AGENTS.md §1: occurrences are
computed and never stored, and a confirmation's date and its slot are different
facts. Collapsing the second pair into one field would silently delete the
phone-bill behaviour, which is why it is written down rather than left to the
types.

§2 gets the termination guard. n >= 1 is rejected at both write boundaries
rather than clamped, and the walk throws rather than breaking, because a bill
silently dropped from a budget is worse than a visible error.

Demo data seeds a monthly rent and a 28-day subscription — the second is what
puts two occurrences in one month, which is the case worth looking at. It also
seeds a biweekly cost and a lastCharge-anchored one, so every recurrence kind
and both anchorings show up in Settings and the month view.

DECISIONS.md, PRODUCT.md, ARCHITECTURE.md and TODO.md are updated to match:
schema is at 7, the month view carries a second Projected balance beside
Remaining, and TODO.md records what a week of real use — not a test — will
have to answer.
This commit is contained in:
Lucas Winther committed 2026-09-03 19:03:35 +02:00
1 parent 824e94a9bd
commit 0d2aa519b7
6 files changed
+149 -7

No files matched your search

+52
View File
@@ -41,6 +41,58 @@ actions.setRuleFrom(d, transport.id, "2026-01", { kind: "fixed", amount: { amoun
actions.setRuleFrom(d, savings.id, "2026-03", { kind: "percentOfIncome", percent: 10 });
actions.setRuleOverride(d, "2026-09", social.id, { kind: "fixed", amount: { amount: 2400, currency: "DKK" } });
// Recurring costs: bills that project their own expected charges, shown as a
// second balance ("Projected") beside the allocation-rule figures above. All
// three recurrence kinds and both anchorings appear, so the screenshots show
// every combination at once.
const wowSub = actions.addRecurringCost(d, {
name: "World of Warcraft",
archived: false,
amount: { amount: 13, currency: "USD" },
startDate: "2026-06-05",
recurrence: { kind: "everyNDays", n: 28 },
anchoring: "calendar",
splitMode: "percent",
splits: [{ postId: games.id, value: 100, absorbsRemainder: true }],
});
actions.addRecurringCost(d, {
name: "Rent",
archived: false,
amount: { amount: 8200, currency: "DKK" },
startDate: "2026-06",
recurrence: { kind: "everyNMonths", n: 1 },
anchoring: "calendar",
splitMode: "percent",
splits: [{ postId: rent.id, value: 100, absorbsRemainder: true }],
});
actions.addRecurringCost(d, {
name: "Veg box",
archived: false,
amount: { amount: 175, currency: "DKK" },
startDate: "2026-06-06",
recurrence: { kind: "everyNWeeks", n: 2, weekday: 6 },
anchoring: "calendar",
splitMode: "percent",
splits: [{ postId: food.id, value: 100, absorbsRemainder: true }],
});
// 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, {
name: "Car insurance",
archived: false,
amount: { amount: 349, currency: "DKK" },
startDate: "2026-08-14",
recurrence: { kind: "everyNDays", n: 30 },
anchoring: "lastCharge",
splitMode: "percent",
splits: [{ postId: transport.id, value: 100, absorbsRemainder: true }],
});
// One confirmed occurrence, so a screenshot shows both states at once: the
// expected band lists what is still due, and the fold's two tracks have
// already reconverged for this one.
actions.confirmOccurrence(d, wowSub.id, "2026-06-05", { date: "2026-06-05" });
const buy = (date: string, description: string, amount: number, currency: "DKK" | "EUR" | "USD", postId: string, note?: string) =>
actions.addPurchase(d, {
date, description, note,