feat(types): recurring costs in the dataset, schema 7

Recurrence is two orthogonal fields, not a list of special cases: a kind
(everyNMonths / everyNDays / everyNWeeks) and an anchoring (calendar /
lastCharge). The phone bill that recharges early when its data cap is hit is
everyNDays(30) anchored to lastCharge — the domain never learns what a data cap
is, because confirming the occurrence early already records the date.

Purchase.source pairs occurrenceDate (identity: which slot this claims) with
Purchase.date (truth: when the money moved). They differ whenever a bill is
paid off schedule, and lastCharge measures from the latter.

RecurringCost.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.

The 6 -> 7 step writes an empty array and reads nothing. Decimals took 6.
This commit is contained in:
Lucas Winther
2026-09-03 03:41:48 +02:00
parent de333685a2
commit a456aadf35
9 changed files with 156 additions and 1 deletions
+2
View File
@@ -53,6 +53,7 @@ function dataset(overrides: Partial<Dataset> = {}): Dataset {
{ id: "2026-01", income: { amount: 20000, currency: "DKK" }, ruleOverrides: {} },
],
purchases: [],
recurring: [],
...overrides,
};
}
@@ -204,6 +205,7 @@ describe("the fold across a rule change", () => {
ruleOverrides: {},
})),
purchases: [],
recurring: [],
};
test("the allocation steps up in the version's month", () => {
+2
View File
@@ -219,6 +219,7 @@ describe("chargesForMonth", () => {
purchase({ id: "b", total: { amount: 50, currency: "DKK" } }),
purchase({ id: "c", date: "2026-10-01" }),
],
recurring: [],
};
const charges = chargesForMonth(data, "2026-09");
expect(charges.get("food")).toBe(250);
@@ -233,6 +234,7 @@ describe("chargesForMonth", () => {
posts: [],
months: [],
purchases: [],
recurring: [],
};
expect(chargesForMonth(data, "2026-09").size).toBe(0);
});
+1
View File
@@ -109,6 +109,7 @@ describe("the fold at zero decimals", () => {
{ id: "2026-02", income: { amount: 10_000, currency: "JPY" }, ruleOverrides: {} },
],
purchases: [],
recurring: [],
};
}
+1
View File
@@ -47,6 +47,7 @@ function dataset(posts: Post[], purchases: Purchase[]): Dataset {
{ id: "2026-03", income: { amount: 10000, currency: "DKK" }, ruleOverrides: {} },
],
purchases,
recurring: [],
};
}
+5 -1
View File
@@ -1,7 +1,7 @@
import { DEFAULT_DIGITS, SEED_CURRENCIES } from "./types.ts";
import type { CurrencyDef, Dataset, FxRate, MonthId, Post } from "./types.ts";
export const SCHEMA_VERSION = 6;
export const SCHEMA_VERSION = 7;
const SEED_POST_NAMES = ["Video Games", "Food", "Events and Social"] as const;
@@ -47,5 +47,9 @@ export function createSeedDataset(
{ id: startMonth, income: { amount: 0, currency: "DKK" }, ruleOverrides: {} },
],
purchases: [],
// A new dataset seeds no recurring costs. Unlike currencies and FX rates,
// there is no sensible default bill — the app cannot guess what the owner
// pays.
recurring: [],
};
}
+75
View File
@@ -105,6 +105,65 @@ export interface Schedule {
cancelledFromMonth?: MonthId;
}
export type RecurringCostId = string;
/**
* How occurrence dates are generated.
*
* Extensible on purpose: a new variant here plus one case in `stepFrom`
* (`occurrences.ts`) is the whole change — nothing else in the app switches on
* this. `n` must be an integer >= 1 in every variant, which is what makes the
* projection walk terminate; both write boundaries enforce it.
*/
export type Recurrence =
| { kind: "everyNMonths"; n: number }
| { kind: "everyNDays"; n: number }
/** `weekday` is 0 = Sunday, matching `weekdayOf` in `days.ts`. */
| { kind: "everyNWeeks"; n: number; weekday: number };
/**
* Where the NEXT occurrence is measured from.
*
* `calendar` — from the previous slot, wherever it fell. Rent, and a
* subscription that bills on its own schedule whatever you do.
*
* `lastCharge` — from the date the previous charge ACTUALLY happened. This is
* the phone bill: hitting the data cap makes a charge happen early, and the
* next 30 days run from there. The domain never learns what a data cap is;
* confirming the occurrence early is the whole mechanism.
*/
export type Anchoring = "calendar" | "lastCharge";
export interface RecurringCost {
id: RecurringCostId;
name: string;
order: number;
/**
* A UI flag ONLY. Projection is governed by `startDate` and `endedFrom`,
* exactly as an archived post still folds — archiving must not retroactively
* remove past expected charges, because that would move a historical figure.
*/
archived: boolean;
/**
* Expected amount, in its own currency, converted to base at fold time.
*
* A single mutable value with no dated series, unlike `Post.rules`. A rent
* increase edits it, and history survives because every past occurrence is
* a confirmed `Purchase` holding what was actually paid. Only unconfirmed
* future occurrences move.
*/
amount: Money;
/** The first occurrence. "YYYY-MM" for everyNMonths, "YYYY-MM-DD" otherwise. */
startDate: IsoDate;
/** Occurrences on or after this date 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[];
}
export interface Purchase {
id: PurchaseId;
date: IsoDate;
@@ -117,6 +176,16 @@ export interface Purchase {
splits: Split[];
/** null means the whole total is charged in the month of `date`. */
schedule: Schedule | null;
/**
* Present when this purchase confirms a projected occurrence.
*
* `occurrenceDate` is IDENTITY — the slot the projector generated, which
* this purchase claims so the slot stops being projected. `Purchase.date` is
* TRUTH — when the money moved. They differ whenever a bill is paid off
* schedule, and under `lastCharge` anchoring it is `date`, not
* `occurrenceDate`, that the next occurrence is measured from.
*/
source?: { recurringId: RecurringCostId; occurrenceDate: IsoDate };
}
export interface FxRate {
@@ -155,4 +224,10 @@ export interface Dataset {
posts: Post[];
months: Month[];
purchases: Purchase[];
/**
* Bills that repeat. Their occurrences are COMPUTED on every fold and never
* stored — the original design rejected materialised snapshots, and an
* unbounded recurrence would otherwise need an arbitrary horizon.
*/
recurring: RecurringCost[];
}
+1
View File
@@ -49,6 +49,7 @@ const data: Dataset = {
},
],
purchases: [spend("a", "food", 500, "2026-01-10"), spend("b", "games", 300, "2026-02-10")],
recurring: [],
};
describe("monthView", () => {
+50
View File
@@ -1,6 +1,7 @@
import { test, expect, describe } from "bun:test";
import { SCHEMA_VERSION, createSeedDataset } from "../domain/seed.ts";
import { migrate, UnsupportedSchemaError } from "./migrations.ts";
import { figuresFor, foldBalances } from "../domain/fold.ts";
describe("migrate", () => {
test("passes a current-version dataset through unchanged", () => {
@@ -443,3 +444,52 @@ test("no migration step imports a live default that a later commit could change"
);
expect(offenders).toEqual([]);
});
describe("6 -> 7: recurring costs join the dataset", () => {
test("a v6 dataset gains an empty recurring array", () => {
const v6 = { ...createSeedDataset("2026-09"), recurring: undefined };
delete (v6 as any).recurring;
(v6 as any).settings = { ...v6.settings, schemaVersion: 6 };
const migrated = migrate(v6 as any);
expect(migrated.recurring).toEqual([]);
expect(migrated.settings.schemaVersion).toBe(SCHEMA_VERSION);
});
test("recurring costs the owner already has are left alone", () => {
// A hand-edited file could already carry them; discarding would be data loss.
// Typed loosely on purpose: a hand-edited entry is not guaranteed to be a
// fully-shaped RecurringCost either, and this step must preserve it as-is
// regardless.
const existing: any[] = [{ id: "r1", name: "Rent" }];
const v6: any = { ...createSeedDataset("2026-09"), recurring: existing };
v6.settings = { ...v6.settings, schemaVersion: 6 };
expect(migrate(v6).recurring).toEqual(existing);
});
test("NO figure changes: every post in every month folds identically", () => {
const v6: any = { ...createSeedDataset("2026-09"), settings: { ...createSeedDataset("2026-09").settings, schemaVersion: 6 } };
delete v6.recurring;
const migrated = migrate(v6);
const fold = foldBalances(migrated, "2026-12");
for (const post of migrated.posts) {
for (const month of ["2026-09", "2026-10", "2026-11", "2026-12"]) {
const figures = figuresFor(fold, post.id, month);
// `projected` and `expected` don't exist on PostMonthFigures until
// Task 5 adds them — this is the deliberate known-red guard the task
// brief calls for. The ts-expect-error, not a weaker cast, means this
// line breaks loudly (as an unused-suppression compile error) the
// moment Task 5 lands the fields, forcing it to be revisited rather
// than silently starting to pass.
// @ts-expect-error PostMonthFigures gains `projected` in Task 5.
expect(figures.projected).toBe(figures.remaining);
// @ts-expect-error PostMonthFigures gains `expected` in Task 5.
expect(figures.expected).toBe(0);
}
}
});
});
+19
View File
@@ -174,6 +174,25 @@ const MIGRATIONS: Array<(data: any) => any> = [
currencies: currencies.map(({ digits, ...rest }: any) => rest),
};
},
// 6 -> 7: recurring costs join the dataset.
//
// Behaviour-preserving by construction, and more strongly than most: an
// empty array projects no occurrences, so `expected` is zero everywhere and
// `projected` equals `remaining` in every month of every existing dataset.
// Not one stored figure is read, let alone written.
//
// An existing `recurring` field is left alone rather than overwritten, for
// the same reason the 2 -> 3 step leaves `currencies` alone: a hand-edited
// file could already carry them, and discarding them would be data loss.
//
// Nothing is frozen here because nothing is copied — the step writes a
// literal empty array and reads no default, live or otherwise.
(data: any) => ({
...data,
settings: { ...data.settings, schemaVersion: 7 },
recurring: Array.isArray(data.recurring) ? data.recurring : [],
}),
];
export function migrate(raw: unknown): Dataset {