diff --git a/src/domain/allocation.test.ts b/src/domain/allocation.test.ts index c2c16a2..f18fa24 100644 --- a/src/domain/allocation.test.ts +++ b/src/domain/allocation.test.ts @@ -53,6 +53,7 @@ function dataset(overrides: Partial = {}): 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", () => { diff --git a/src/domain/charges.test.ts b/src/domain/charges.test.ts index c5a0512..26e9589 100644 --- a/src/domain/charges.test.ts +++ b/src/domain/charges.test.ts @@ -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); }); diff --git a/src/domain/currencyDigits.test.ts b/src/domain/currencyDigits.test.ts index dc15ed6..83733c7 100644 --- a/src/domain/currencyDigits.test.ts +++ b/src/domain/currencyDigits.test.ts @@ -109,6 +109,7 @@ describe("the fold at zero decimals", () => { { id: "2026-02", income: { amount: 10_000, currency: "JPY" }, ruleOverrides: {} }, ], purchases: [], + recurring: [], }; } diff --git a/src/domain/fold.test.ts b/src/domain/fold.test.ts index 4028d48..864ed7f 100644 --- a/src/domain/fold.test.ts +++ b/src/domain/fold.test.ts @@ -47,6 +47,7 @@ function dataset(posts: Post[], purchases: Purchase[]): Dataset { { id: "2026-03", income: { amount: 10000, currency: "DKK" }, ruleOverrides: {} }, ], purchases, + recurring: [], }; } diff --git a/src/domain/seed.ts b/src/domain/seed.ts index d345d12..77b99f7 100644 --- a/src/domain/seed.ts +++ b/src/domain/seed.ts @@ -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: [], }; } diff --git a/src/domain/types.ts b/src/domain/types.ts index ae735c9..193c5e7 100755 --- a/src/domain/types.ts +++ b/src/domain/types.ts @@ -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[]; } diff --git a/src/domain/views.test.ts b/src/domain/views.test.ts index e86c04e..2b2f96f 100644 --- a/src/domain/views.test.ts +++ b/src/domain/views.test.ts @@ -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", () => { diff --git a/src/store/migrations.test.ts b/src/store/migrations.test.ts index 9f2b948..efd2430 100644 --- a/src/store/migrations.test.ts +++ b/src/store/migrations.test.ts @@ -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); + } + } + }); +}); diff --git a/src/store/migrations.ts b/src/store/migrations.ts index ae82068..de9ba06 100644 --- a/src/store/migrations.ts +++ b/src/store/migrations.ts @@ -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 {