diff --git a/src/domain/fold.test.ts b/src/domain/fold.test.ts index 864ed7f..d3f11fb 100644 --- a/src/domain/fold.test.ts +++ b/src/domain/fold.test.ts @@ -1,5 +1,6 @@ import { test, expect, describe } from "bun:test"; -import { foldBalances, figuresFor } from "./fold.ts"; +import { foldBalances, figuresFor, EMPTY_FIGURES } from "./fold.ts"; +import { createSeedDataset } from "./seed.ts"; import type { Dataset, Post, Purchase } from "./types.ts"; /** Digits for the currencies these tests use; all real-world 2dp. */ @@ -59,6 +60,9 @@ describe("envelope rollover", () => { allocation: 500, charges: 0, remaining: 500, + expected: 0, + projectedCarriedIn: 0, + projected: 500, }); }); @@ -71,6 +75,9 @@ describe("envelope rollover", () => { allocation: 500, charges: 0, remaining: 700, + expected: 0, + projectedCarriedIn: 200, + projected: 700, }); }); @@ -157,6 +164,9 @@ test("figuresFor returns zeroes for a month outside the fold", () => { allocation: 0, charges: 0, remaining: 0, + expected: 0, + projectedCarriedIn: 0, + projected: 0, }); }); @@ -175,3 +185,103 @@ describe("the empty-figures fallback", () => { expect(figuresFor(fold, "another-missing-post", "2026-01").allocation).toBe(0); }); }); + +function datasetWithRent(): Dataset { + return { + settings: { baseCurrency: "DKK", foldStartMonth: "2026-01", schemaVersion: 7, digits: 2 }, + currencies: [{ code: "DKK", symbol: "kr", name: "Danish krone" }], + fxRates: [], + posts: [ + { + id: "housing", + name: "Housing", + order: 0, + archived: false, + currency: "DKK", + rules: [{ from: "2026-01", rule: { kind: "fixed", amount: { amount: 10000, currency: "DKK" } } }], + }, + ], + months: [], + purchases: [], + recurring: [ + { + id: "rent", + name: "Rent", + order: 0, + archived: false, + amount: { amount: 8000, currency: "DKK" }, + startDate: "2026-01", + recurrence: { kind: "everyNMonths", n: 1 }, + anchoring: "calendar", + splitMode: "percent", + splits: [{ postId: "housing", value: 100, absorbsRemainder: true }], + }, + ], + }; +} + +describe("the projected track", () => { + test("with no recurring costs, projected shadows remaining exactly", () => { + // The behaviour-preservation guard. Every existing dataset must fold to + // figures it already folded to, with the new track adding nothing. + const data = createSeedDataset("2026-01"); + data.months.push({ id: "2026-01", income: { amount: 20000, currency: "DKK" }, ruleOverrides: {} }); + const fold = foldBalances(data, "2026-06"); + + for (const post of data.posts) { + for (const month of ["2026-01", "2026-02", "2026-03", "2026-04", "2026-05", "2026-06"]) { + const figures = figuresFor(fold, post.id, month); + expect(figures.expected).toBe(0); + expect(figures.projectedCarriedIn).toBe(figures.carriedIn); + expect(figures.projected).toBe(figures.remaining); + } + } + }); + + test("an unconfirmed occurrence lowers projected and leaves remaining alone", () => { + const data = datasetWithRent(); + const figures = figuresFor(foldBalances(data, "2026-01"), "housing", "2026-01"); + + expect(figures.charges).toBe(0); + expect(figures.expected).toBe(8000); + expect(figures.remaining).toBe(10000); + expect(figures.projected).toBe(2000); + }); + + test("the projected shortfall carries forward on its own track", () => { + const data = datasetWithRent(); + const fold = foldBalances(data, "2026-02"); + const february = figuresFor(fold, "housing", "2026-02"); + + expect(february.carriedIn).toBe(10000); + expect(february.projectedCarriedIn).toBe(2000); + expect(february.remaining).toBe(20000); + // 2000 carried on the projected track + 10000 allocated - 8000 expected. + expect(february.projected).toBe(4000); + }); + + test("confirming every occurrence reconverges the two tracks", () => { + const data = datasetWithRent(); + data.purchases.push({ + id: "p1", + date: "2026-01", + description: "Rent", + total: { amount: 8000, currency: "DKK" }, + splitMode: "percent", + splits: [{ postId: "housing", value: 100, absorbsRemainder: true }], + schedule: null, + source: { recurringId: "rent", occurrenceDate: "2026-01" }, + }); + + const figures = figuresFor(foldBalances(data, "2026-01"), "housing", "2026-01"); + expect(figures.charges).toBe(8000); + expect(figures.expected).toBe(0); + expect(figures.projected).toBe(figures.remaining); + }); + + test("EMPTY_FIGURES carries the new fields", () => { + expect(EMPTY_FIGURES.expected).toBe(0); + expect(EMPTY_FIGURES.projected).toBe(0); + expect(EMPTY_FIGURES.projectedCarriedIn).toBe(0); + }); +}); diff --git a/src/domain/fold.ts b/src/domain/fold.ts index cfe6291..e78207f 100644 --- a/src/domain/fold.ts +++ b/src/domain/fold.ts @@ -1,7 +1,8 @@ import { allocationFor } from "./allocation.ts"; -import { chargesForMonth } from "./charges.ts"; +import { chargesForMonth, expectedForMonth } from "./charges.ts"; import { roundMoney } from "./money.ts"; import { compareMonths, monthRange } from "./months.ts"; +import { occurrencesByMonth } from "./occurrences.ts"; import type { Dataset, MonthId, PostId } from "./types.ts"; export interface PostMonthFigures { @@ -10,6 +11,21 @@ export interface PostMonthFigures { charges: number; /** carriedIn + allocation - charges. Negative means overspent. */ remaining: number; + /** + * Unconfirmed recurring occurrences landing in this month, base currency. + * Money committed but not yet moved. + */ + expected: number; + /** The previous month's `projected`. */ + projectedCarriedIn: number; + /** + * projectedCarriedIn + allocation - charges - expected. + * + * A second, parallel balance: what is left once commitments are counted. + * It never influences `remaining`, so adding a recurring cost cannot move a + * figure the owner already reconciled against their bank. + */ + projected: number; } /** @@ -22,6 +38,9 @@ export const EMPTY_FIGURES: PostMonthFigures = Object.freeze({ allocation: 0, charges: 0, remaining: 0, + expected: 0, + projectedCarriedIn: 0, + projected: 0, }); export type Fold = Map>; @@ -35,31 +54,48 @@ export type Fold = Map>; * has to fold. */ export function foldBalances(dataset: Dataset, upToMonth: MonthId): Fold { - const { foldStartMonth, baseCurrency } = dataset.settings; + const { foldStartMonth } = dataset.settings; const baseDigits = dataset.settings.digits; const fold: Fold = new Map(); if (compareMonths(upToMonth, foldStartMonth) < 0) return fold; + // One walk for every recurring cost, because under `lastCharge` anchoring a + // month's occurrences depend on every confirmation before it. + const occurrences = occurrencesByMonth(dataset, upToMonth); + const carried = new Map(); + const carriedProjected = new Map(); for (const monthId of monthRange(foldStartMonth, upToMonth)) { const charges = chargesForMonth(dataset, monthId); + const expected = expectedForMonth(dataset, occurrences.get(monthId) ?? []); const monthFigures = new Map(); for (const post of dataset.posts) { const carriedIn = carried.get(post.id) ?? 0; + const projectedCarriedIn = carriedProjected.get(post.id) ?? 0; const allocation = allocationFor(dataset, post.id, monthId); const spent = charges.get(post.id) ?? 0; + const due = expected.get(post.id) ?? 0; + const remaining = roundMoney(carriedIn + allocation - spent, baseDigits); + const projected = roundMoney( + projectedCarriedIn + allocation - spent - due, + baseDigits, + ); monthFigures.set(post.id, { carriedIn, allocation, charges: spent, remaining, + expected: due, + projectedCarriedIn, + projected, }); carried.set(post.id, remaining); + carriedProjected.set(post.id, projected); } fold.set(monthId, monthFigures); diff --git a/src/store/migrations.test.ts b/src/store/migrations.test.ts index efd2430..d1f27ce 100644 --- a/src/store/migrations.test.ts +++ b/src/store/migrations.test.ts @@ -479,15 +479,7 @@ describe("6 -> 7: recurring costs join the dataset", () => { 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/ui/meterSegments.test.ts b/src/ui/meterSegments.test.ts index e189104..6b0024b 100644 --- a/src/ui/meterSegments.test.ts +++ b/src/ui/meterSegments.test.ts @@ -12,11 +12,15 @@ function figures(partial: Partial): PostMonthFigures { const carriedIn = partial.carriedIn ?? 0; const allocation = partial.allocation ?? 0; const charges = partial.charges ?? 0; + const remaining = partial.remaining ?? carriedIn + allocation - charges; return { carriedIn, allocation, charges, - remaining: partial.remaining ?? carriedIn + allocation - charges, + remaining, + expected: partial.expected ?? 0, + projectedCarriedIn: partial.projectedCarriedIn ?? carriedIn, + projected: partial.projected ?? remaining, }; }