From 3d3121aca2cea540750cbb262c617f1c6cfbd171 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Thu, 3 Sep 2026 18:02:49 +0200 Subject: [PATCH] feat(fold): a second balance for money committed but not moved MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit remaining is untouched: carriedIn + allocation - charges, exactly as before, so no figure the owner has reconciled against their bank can move when they add a recurring cost. projected runs beside it on its own carry, subtracting unconfirmed occurrences as well. Two invariants pin it, both tested. With no recurring costs the tracks are identical in every month — that is the behaviour-preservation guard for every dataset that exists today. Once a month's occurrences are all confirmed they reconverge there, because a confirmation is an ordinary purchase counted in charges and skipped by expected. An unconfirmed occurrence in a PAST month therefore holds projected below remaining forever. That is the honest reading — an unreconciled commitment — and the month view surfaces the count so it is not silent. Also required to typecheck once PostMonthFigures gains these fields: - src/store/migrations.test.ts: delete the two @ts-expect-error directives that stood over the "NO figure changes" guard test since Task 2. That test is now green — the point of this task. - src/ui/meterSegments.test.ts: the figures() test helper builds a complete PostMonthFigures literal; default expected to 0, projectedCarriedIn to carriedIn, and projected to remaining, matching its existing partial.X ?? style. Co-Authored-By: Claude Opus 5 (1M context) --- src/domain/fold.test.ts | 112 ++++++++++++++++++++++++++++++++++- src/domain/fold.ts | 40 ++++++++++++- src/store/migrations.test.ts | 8 --- src/ui/meterSegments.test.ts | 6 +- 4 files changed, 154 insertions(+), 12 deletions(-) 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, }; }