feat(fold): a second balance for money committed but not moved

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) <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5 committed 2026-09-03 18:02:49 +02:00
1 parent 324b6bd272
commit 3d3121aca2
4 files changed
+154 -12

No files matched your search

+111 -1
View File
@@ -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);
});
});
+38 -2
View File
@@ -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<MonthId, Map<PostId, PostMonthFigures>>;
@@ -35,31 +54,48 @@ export type Fold = Map<MonthId, Map<PostId, PostMonthFigures>>;
* 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<PostId, number>();
const carriedProjected = new Map<PostId, number>();
for (const monthId of monthRange(foldStartMonth, upToMonth)) {
const charges = chargesForMonth(dataset, monthId);
const expected = expectedForMonth(dataset, occurrences.get(monthId) ?? []);
const monthFigures = new Map<PostId, PostMonthFigures>();
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);
-8
View File
@@ -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);
}
}
+5 -1
View File
@@ -12,11 +12,15 @@ function figures(partial: Partial<PostMonthFigures>): 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,
};
}