Files
budget2.0/src/store/actions.test.ts
T
Lucas Winther b0e8145d2e
Continuous Integration / lint-and-test (push) Successful in 13s
Continuous Integration / docker-build-and-push (push) Successful in 35s
feat: support unassigned purchases and allocation status
Allow month-only purchases to start finance plans. Keep unbudgeted posts distinct from explicit zero rules in overspend reporting.
2026-09-30 13:10:10 +02:00

1126 lines
44 KiB
TypeScript

import { test, expect, describe } from "bun:test";
import * as actions from "./actions.ts";
import { createSeedDataset } from "../domain/seed.ts";
import { occurrencesByMonth } from "../domain/occurrences.ts";
import { monthView } from "../domain/views.ts";
import { createSnapshotStore } from "./snapshot.ts";
import { exportDatasetJson, parseDatasetJson } from "../export/json.ts";
import type { Dataset } from "../domain/types.ts";
const billInput = (postId: string, folderId: string | null = null) => ({
name: "Phone", folderId, archived: false,
amount: { amount: 30, currency: "DKK" }, startDate: "2026-09",
recurrence: { kind: "everyNMonths" as const, n: 1 }, anchoring: "calendar" as const,
splitMode: "percent" as const,
splits: [{ postId, value: 100, absorbsRemainder: true }],
});
describe("budget folder placement", () => {
test("removing a folder moves its direct contents to its parent in order", () => {
const data = createSeedDataset("2026-09");
const parent = actions.addBudgetFolder(data, "Parent", null);
const before = actions.addPost(data, "Before", "DKK", parent.id);
const removed = actions.addBudgetFolder(data, "Removed", parent.id);
const after = actions.addPost(data, "After", "DKK", parent.id);
const child = actions.addBudgetFolder(data, "Child", removed.id);
const post = actions.addPost(data, "Kept", "DKK", removed.id);
const bill = actions.addRecurringCost(data, billInput(post.id, removed.id));
actions.removeBudgetFolder(data, removed.id);
expect(data.folders.some(folder => folder.id === removed.id)).toBe(false);
expect([child.parentId, post.folderId, bill.folderId]).toEqual([parent.id, parent.id, parent.id]);
expect(actions.budgetSiblings(data, parent.id).map(item => item.id)).toEqual([before.id, child.id, post.id, bill.id, after.id]);
expect(bill.splits[0]!.postId).toBe(post.id);
expect(() => parseDatasetJson(exportDatasetJson(data))).not.toThrow();
});
test("mixed siblings reorder densely", () => {
const data = createSeedDataset("2026-09");
const folder = actions.addBudgetFolder(data, "Utilities", null);
actions.addBudgetFolder(data, "Subscriptions", folder.id);
const post = actions.addPost(data, "Bills", "DKK", folder.id);
const bill = actions.addRecurringCost(data, billInput(post.id, folder.id));
actions.placeBudgetItem(data, { kind: "recurring", id: bill.id }, folder.id, 0);
const siblings = actions.budgetSiblings(data, folder.id);
expect(siblings.map(item => item.order)).toEqual([0, 1, 2]);
expect(siblings[0]!.id).toBe(bill.id);
actions.placeBudgetItem(data, { kind: "folder", id: folder.id }, null, 0);
expect(actions.budgetSiblings(data, null)[0]!.id).toBe(folder.id);
});
test("invalid placement is atomic", () => {
const data = createSeedDataset("2026-09");
const parent = actions.addBudgetFolder(data, "Parent", null);
const child = actions.addBudgetFolder(data, "Child", parent.id);
const post = actions.addPost(data, "Bills", "DKK", parent.id);
const bill = actions.addRecurringCost(data, billInput(post.id));
const invalid = [
() => actions.placeBudgetItem(data, { kind: "post", id: post.id }, "ghost", 0),
() => actions.placeBudgetItem(data, { kind: "folder", id: parent.id }, parent.id, 0),
() => actions.placeBudgetItem(data, { kind: "folder", id: parent.id }, child.id, 0),
() => actions.placeBudgetItem(data, { kind: "post", id: "ghost" }, null, 0),
() => actions.placeBudgetItem(data, { kind: "recurring", id: bill.id }, null, 99),
() => actions.addBudgetFolder(data, " ", null),
() => actions.addBudgetFolder(data, "Unknown parent", "ghost"),
() => actions.renameBudgetFolder(data, parent.id, " "),
() => actions.addPost(data, "Orphan", "DKK", "ghost"),
() => actions.addRecurringCost(data, billInput(post.id, "ghost")),
() => actions.updatePost(data, post.id, { folderId: null } as never),
() => actions.updateRecurringCost(data, bill.id, { order: 0 } as never),
];
for (const operation of invalid) {
const before = JSON.stringify(data);
expect(operation).toThrow();
expect(JSON.stringify(data)).toBe(before);
}
actions.renameBudgetFolder(data, parent.id, "New name");
expect(parent.name).toBe("New name");
actions.placeBudgetItem(data, { kind: "folder", id: child.id }, null, 0);
expect(child.parentId).toBe(null);
expect(actions.budgetSiblings(data, parent.id).map(item => item.order)).toEqual([0]);
});
test("legacy move buttons use mixed order", () => {
const data = createSeedDataset("2026-09");
const bill = actions.addRecurringCost(data, billInput(data.posts[0]!.id));
actions.moveRecurringCost(data, bill.id, -1);
expect(actions.budgetSiblings(data, null).map(item => item.id)).toEqual([
data.posts[0]!.id, data.posts[1]!.id, bill.id, data.posts[2]!.id,
]);
actions.movePost(data, data.posts[2]!.id, -1);
expect(actions.budgetSiblings(data, null).map(item => item.id)).toEqual([
data.posts[0]!.id, data.posts[1]!.id, data.posts[2]!.id, bill.id,
]);
const before = JSON.stringify(data);
actions.movePost(data, data.posts[0]!.id, -1);
expect(JSON.stringify(data)).toBe(before);
});
test("new items append after sparse imported sibling orders", () => {
const data = createSeedDataset("2026-09");
data.posts[2]!.order = 3;
const folder = actions.addBudgetFolder(data, "Utilities", null);
const post = actions.addPost(data, "Bills", "DKK");
const bill = actions.addRecurringCost(data, billInput(post.id));
expect([folder.order, post.order, bill.order]).toEqual([4, 5, 6]);
});
test("adding after the largest finite sibling order stays importable", () => {
for (const add of [
(data: Dataset) => actions.addBudgetFolder(data, "Utilities", null),
(data: Dataset) => actions.addPost(data, "Bills", "DKK"),
(data: Dataset) => actions.addRecurringCost(data, billInput(data.posts[0]!.id)),
]) {
const data = createSeedDataset("2026-09");
data.posts[2]!.order = Number.MAX_VALUE;
const added = add(data);
expect(added.order).not.toBe(Number.MAX_VALUE);
expect(() => parseDatasetJson(exportDatasetJson(data))).not.toThrow();
}
});
});
/**
* Decimal places are one dataset-wide setting, so a different minor unit is
* expressed by moving `settings.digits` — no monkey-patching a module
* constant, which is what this file used to need, and no per-currency entry
* either, which is what it needed before that.
*/
function withDigits(digits: number, body: () => void) {
const original = overriddenDigits;
overriddenDigits = digits;
try {
body();
} finally {
overriddenDigits = original;
}
}
let overriddenDigits: number | null = null;
function draft(): Dataset {
const data = createSeedDataset("2026-09");
if (overriddenDigits !== null) data.settings.digits = overriddenDigits;
return data;
}
describe("months", () => {
test("ensureMonth creates a missing month with zero income", () => {
const data = draft();
const month = actions.ensureMonth(data, "2026-10");
expect(month.income.amount).toBe(0);
expect(data.months.map((m) => m.id)).toEqual(["2026-09", "2026-10"]);
});
test("ensureMonth returns the existing month rather than duplicating it", () => {
const data = draft();
actions.ensureMonth(data, "2026-09");
expect(data.months).toHaveLength(1);
});
test("setIncome creates the month if needed", () => {
const data = draft();
actions.setIncome(data, "2026-11", { amount: 25000, currency: "DKK" });
expect(data.months.find((m) => m.id === "2026-11")!.income.amount).toBe(25000);
});
test("setIncome rounds the amount to the dataset's decimal places", () => {
const data = draft();
actions.setIncome(data, "2026-11", { amount: 25000.005, currency: "DKK" });
expect(data.months.find((m) => m.id === "2026-11")!.income.amount).toBe(25000.01);
actions.setIncome(data, "2026-12", { amount: 12.345, currency: "DKK" });
expect(data.months.find((m) => m.id === "2026-12")!.income.amount).toBe(12.35);
});
test("setRuleOverride sets and clears an override", () => {
const data = draft();
const postId = data.posts[0]!.id;
actions.setRuleOverride(data, "2026-09", postId, {
kind: "percentOfIncome",
percent: 15,
});
expect(data.months[0]!.ruleOverrides[postId]).toEqual({
kind: "percentOfIncome",
percent: 15,
});
actions.setRuleOverride(data, "2026-09", postId, null);
expect(data.months[0]!.ruleOverrides[postId]).toBeUndefined();
});
test("setRuleOverride rounds a fixed rule's amount and a percent rule's percent", () => {
const data = draft();
const postId = data.posts[0]!.id;
actions.setRuleOverride(data, "2026-09", postId, {
kind: "fixed",
amount: { amount: 12.345, currency: "DKK" },
});
expect(data.months[0]!.ruleOverrides[postId]).toEqual({
kind: "fixed",
amount: { amount: 12.35, currency: "DKK" },
});
actions.setRuleOverride(data, "2026-09", postId, {
kind: "percentOfIncome",
percent: 12.3456,
});
expect(data.months[0]!.ruleOverrides[postId]).toEqual({
kind: "percentOfIncome",
percent: 12.35,
});
});
});
describe("posts", () => {
test("addPost appends with the next order value", () => {
const data = draft();
const post = actions.addPost(data, "Travel", "EUR");
expect(post.order).toBe(3);
expect(post.archived).toBe(false);
expect(data.posts).toHaveLength(4);
});
test("addPost starts the post unbudgeted", () => {
const data = draft();
const post = actions.addPost(data, "Travel", "EUR");
expect(post.rules).toEqual([]);
});
test("setPostArchived toggles without deleting", () => {
const data = draft();
const postId = data.posts[0]!.id;
actions.setPostArchived(data, postId, true);
expect(data.posts.find((p) => p.id === postId)!.archived).toBe(true);
expect(data.posts).toHaveLength(3);
});
test("movePost swaps order with its neighbour", () => {
const data = draft();
actions.movePost(data, data.posts[1]!.id, -1);
expect([...data.posts].sort((a, b) => a.order - b.order).map((p) => p.name)).toEqual([
"Food",
"Video Games",
"Events and Social",
]);
});
test("movePost at the boundary is a no-op", () => {
const data = draft();
actions.movePost(data, data.posts[0]!.id, -1);
expect(data.posts[0]!.order).toBe(0);
});
test("updatePost renames without touching other fields", () => {
const data = draft();
const postId = data.posts[0]!.id;
actions.updatePost(data, postId, { name: "Games" });
const post = data.posts.find((p) => p.id === postId)!;
expect(post.name).toBe("Games");
expect(post.currency).toBe("DKK");
});
test("an unknown post id throws", () => {
expect(() => actions.updatePost(draft(), "ghost", { name: "x" })).toThrow(/ghost/);
});
});
describe("purchases", () => {
const newPurchase = (postId: string) => ({
date: "2026-09-14",
description: "Groceries",
total: { amount: 200, currency: "DKK" as const },
splitMode: "percent" as const,
splits: [{ postId, value: 100, absorbsRemainder: true }],
schedule: null,
});
test("addPurchase assigns an id and ensures the month exists", () => {
const data = draft();
const purchase = actions.addPurchase(data, {
...newPurchase(data.posts[0]!.id),
date: "2026-12-01",
});
expect(purchase.id).toBeTruthy();
expect(data.months.map((m) => m.id)).toContain("2026-12");
});
test("addPurchase rounds the total amount, split values, and schedule slice amounts", () => {
const data = draft();
const postId = data.posts[0]!.id;
const purchase = actions.addPurchase(data, {
date: "2026-09-14",
description: "Sofa",
total: { amount: 199.995, currency: "DKK" },
splitMode: "percent",
splits: [{ postId, value: 33.3333, absorbsRemainder: true }],
schedule: {
slices: [
{ month: "2026-09", amount: { amount: 66.665, currency: "DKK" } },
{ month: "2026-10", amount: { amount: 66.665, currency: "DKK" } },
{ month: "2026-11", amount: { amount: 66.665, currency: "DKK" } },
],
},
});
expect(purchase.total.amount).toBe(200);
expect(purchase.splits[0]!.value).toBe(33.33);
expect(purchase.schedule!.slices.map((s) => s.amount.amount)).toEqual([
66.67, 66.67, 66.67,
]);
});
test("updatePurchase replaces the given fields", () => {
const data = draft();
const purchase = actions.addPurchase(data, newPurchase(data.posts[0]!.id));
actions.updatePurchase(data, purchase.id, { description: "Dinner" });
expect(data.purchases[0]!.description).toBe("Dinner");
});
test("an edited purchase can lose its post and bill association", () => {
const data = draft();
const bill = actions.addRecurringCost(data, billInput(data.posts[0]!.id));
const purchase = actions.confirmOccurrence(data, bill.id, "2026-09");
actions.updatePurchase(data, purchase.id, { splits: [], source: undefined });
expect(data.purchases[0]!.splits).toEqual([]);
expect(data.purchases[0]!.source).toBeUndefined();
expect(monthView(data, "2026-09").looseCharges).toBe(30);
});
test("updatePurchase rounds the total amount, split values, and schedule slice amounts", () => {
const data = draft();
const postId = data.posts[0]!.id;
const purchase = actions.addPurchase(data, newPurchase(postId));
actions.updatePurchase(data, purchase.id, {
total: { amount: 199.995, currency: "DKK" },
splits: [{ postId, value: 33.3333, absorbsRemainder: true }],
schedule: {
slices: [
{ month: "2026-09", amount: { amount: 66.665, currency: "DKK" } },
],
},
});
const updated = data.purchases.find((p) => p.id === purchase.id)!;
expect(updated.total.amount).toBe(200);
expect(updated.splits[0]!.value).toBe(33.33);
expect(updated.schedule!.slices[0]!.amount.amount).toBe(66.67);
});
test("deletePurchase removes it", () => {
const data = draft();
const purchase = actions.addPurchase(data, newPurchase(data.posts[0]!.id));
actions.deletePurchase(data, purchase.id);
expect(data.purchases).toHaveLength(0);
});
test("cancelScheduleFrom records the cancellation month", () => {
const data = draft();
const purchase = actions.addPurchase(data, {
...newPurchase(data.posts[0]!.id),
schedule: {
slices: [
{ month: "2026-09", amount: { amount: 100, currency: "DKK" } },
{ month: "2026-10", amount: { amount: 100, currency: "DKK" } },
],
},
});
actions.cancelScheduleFrom(data, purchase.id, "2026-10");
expect(data.purchases[0]!.schedule!.cancelledFromMonth).toBe("2026-10");
});
test("cancelling an unscheduled purchase throws", () => {
const data = draft();
const purchase = actions.addPurchase(data, newPurchase(data.posts[0]!.id));
expect(() => actions.cancelScheduleFrom(data, purchase.id, "2026-10")).toThrow(
/no finance plan/i,
);
});
});
describe("settings", () => {
test("setFxRate inserts then updates in place", () => {
const data = draft();
actions.setFxRate(data, {
currency: "EUR",
baseUnitsPerOne: 7.46,
updatedAt: "2026-09-01",
source: "manual",
});
expect(data.fxRates).toHaveLength(1);
actions.setFxRate(data, {
currency: "EUR",
baseUnitsPerOne: 7.5,
updatedAt: "2026-09-02",
source: "api",
});
expect(data.fxRates).toHaveLength(1);
expect(data.fxRates[0]!.baseUnitsPerOne).toBe(7.5);
});
test("setFxRate preserves a 6-decimal rate unrounded", () => {
const data = draft();
actions.setFxRate(data, {
currency: "USD",
baseUnitsPerOne: 7.123456,
updatedAt: "2026-09-01",
source: "api",
});
expect(data.fxRates.find((r) => r.currency === "USD")!.baseUnitsPerOne).toBe(
7.123456,
);
});
test("setBaseCurrency drops the new base's own rate row", () => {
const data = draft();
actions.setFxRate(data, {
currency: "EUR",
baseUnitsPerOne: 7.46,
updatedAt: "2026-09-01",
source: "manual",
});
actions.setBaseCurrency(data, "EUR");
expect(data.settings.baseCurrency).toBe("EUR");
expect(data.fxRates.find((r) => r.currency === "EUR")).toBeUndefined();
});
test("removeFxRate filters the currency out entirely, leaving no row", () => {
const data = draft();
actions.setFxRate(data, {
currency: "EUR",
baseUnitsPerOne: 7.46,
updatedAt: "2026-09-01",
source: "manual",
});
actions.setFxRate(data, {
currency: "USD",
baseUnitsPerOne: 6.9,
updatedAt: "2026-09-01",
source: "manual",
});
actions.removeFxRate(data, "EUR");
expect(data.fxRates.map((r) => r.currency)).toEqual(["USD"]);
expect(data.fxRates.find((r) => r.currency === "EUR")).toBeUndefined();
});
test("removeFxRate on a currency with no row is a no-op", () => {
const data = draft();
actions.removeFxRate(data, "EUR");
expect(data.fxRates).toEqual([]);
});
});
describe("rule versions", () => {
const pct = (percent: number) => ({ kind: "percentOfIncome" as const, percent });
test("setRuleFrom adds a version to a post that had none", () => {
const data = draft();
const postId = data.posts[0]!.id;
actions.setRuleFrom(data, postId, "2026-04", pct(10));
expect(data.posts[0]!.rules).toEqual([{ from: "2026-04", rule: pct(10) }]);
});
test("setRuleFrom REPLACES an existing version for the same month", () => {
const data = draft();
const postId = data.posts[0]!.id;
actions.setRuleFrom(data, postId, "2026-04", pct(10));
actions.setRuleFrom(data, postId, "2026-04", pct(15));
expect(data.posts[0]!.rules).toHaveLength(1);
expect(data.posts[0]!.rules[0]!.rule).toEqual(pct(15));
});
test("versions are kept sorted regardless of insertion order", () => {
const data = draft();
const postId = data.posts[0]!.id;
actions.setRuleFrom(data, postId, "2026-07", pct(15));
actions.setRuleFrom(data, postId, "2026-04", pct(10));
actions.setRuleFrom(data, postId, "2026-05", pct(12));
expect(data.posts[0]!.rules.map((v) => v.from)).toEqual([
"2026-04",
"2026-05",
"2026-07",
]);
});
test("a fixed rule is rounded at the mutation boundary", () => {
const data = draft();
const postId = data.posts[0]!.id;
actions.setRuleFrom(data, postId, "2026-04", {
kind: "fixed",
amount: { amount: 12.345, currency: "DKK" },
});
const stored = data.posts[0]!.rules[0]!.rule;
expect(stored.kind === "fixed" && stored.amount.amount).toBe(12.35);
});
test("a percentage above 100 is stored, not clamped", () => {
const data = draft();
actions.setRuleFrom(data, data.posts[0]!.id, "2026-04", pct(150));
expect(data.posts[0]!.rules[0]!.rule).toEqual(pct(150));
});
test("removeRuleFrom deletes exactly that version", () => {
const data = draft();
const postId = data.posts[0]!.id;
actions.setRuleFrom(data, postId, "2026-04", pct(10));
actions.setRuleFrom(data, postId, "2026-07", pct(15));
actions.removeRuleFrom(data, postId, "2026-04");
expect(data.posts[0]!.rules).toEqual([{ from: "2026-07", rule: pct(15) }]);
});
test("removing a month that has no version is a no-op, not an error", () => {
const data = draft();
const postId = data.posts[0]!.id;
actions.setRuleFrom(data, postId, "2026-04", pct(10));
actions.removeRuleFrom(data, postId, "2026-09");
expect(data.posts[0]!.rules).toHaveLength(1);
});
test("both throw on an unknown post id", () => {
expect(() => actions.setRuleFrom(draft(), "ghost", "2026-04", pct(10))).toThrow(
/Unknown post: ghost/,
);
expect(() => actions.removeRuleFrom(draft(), "ghost", "2026-04")).toThrow(
/Unknown post: ghost/,
);
});
});
describe("split values are rounded by what they MEAN", () => {
// In "fixed" mode a split value is money, so it must round to the dataset's
// decimal places. In "percent" mode it is a percentage and has nothing to do
// with money at all, so it stays at two places whatever the setting says.
test("a fixed-mode split rounds to the dataset's decimal places", () => {
withDigits(0, () => {
const data = draft();
const purchase = actions.addPurchase(data, {
date: "2026-09-14",
description: "Sofa",
total: { amount: 100, currency: "EUR" },
splitMode: "fixed",
splits: [
{ postId: data.posts[0]!.id, value: 10.44, absorbsRemainder: false },
{ postId: data.posts[1]!.id, value: 89.56, absorbsRemainder: true },
],
schedule: null,
});
expect(purchase.splits[0]!.value).toBe(10);
});
});
test("a fixed-mode split still rounds to the setting when only splits change", () => {
withDigits(0, () => {
const data = draft();
const purchase = actions.addPurchase(data, {
date: "2026-09-14",
description: "Sofa",
total: { amount: 100, currency: "EUR" },
splitMode: "fixed",
splits: [{ postId: data.posts[0]!.id, value: 100, absorbsRemainder: true }],
schedule: null,
});
actions.updatePurchase(data, purchase.id, {
splits: [{ postId: data.posts[0]!.id, value: 42.61, absorbsRemainder: true }],
});
expect(data.purchases[0]!.splits[0]!.value).toBe(43);
});
});
test("a percent-mode split keeps 2 decimals whatever the currency does", () => {
withDigits(0, () => {
const data = draft();
const purchase = actions.addPurchase(data, {
date: "2026-09-14",
description: "Sofa",
total: { amount: 100, currency: "EUR" },
splitMode: "percent",
splits: [{ postId: data.posts[0]!.id, value: 33.3333, absorbsRemainder: true }],
schedule: null,
});
expect(purchase.splits[0]!.value).toBe(33.33);
});
});
});
describe("recurring costs", () => {
function draft(): Dataset {
const data = createSeedDataset("2026-01");
data.posts[0]!.id = "housing";
return data;
}
const rentInput = {
name: "Rent",
folderId: null,
archived: false,
amount: { amount: 8000, currency: "DKK" },
startDate: "2026-01",
recurrence: { kind: "everyNMonths" as const, n: 1 },
anchoring: "calendar" as const,
splitMode: "percent" as const,
splits: [{ postId: "housing", value: 100, absorbsRemainder: true }],
};
test("dedicated post and bill save together", () => {
const data = draft();
const folder = actions.addBudgetFolder(data, "Housing", null);
const bill = actions.addRecurringCostWithPost(data, {
name: "Rent", folderId: folder.id, archived: false,
amount: { amount: 8000, currency: "DKK" }, startDate: "2026-09",
recurrence: { kind: "everyNMonths", n: 1 }, anchoring: "calendar",
}, "Rent post");
const post = data.posts.find(p => p.id === bill.splits[0]!.postId)!;
expect(post.name).toBe("Rent post");
expect(post.folderId).toBe(folder.id);
expect(bill.folderId).toBe(folder.id);
expect(bill.splits).toEqual([{ postId: post.id, value: 100, absorbsRemainder: true }]);
expect(post.rules).toEqual([{ from: "2026-09", rule: { kind: "fixed", amount: { amount: 8000, currency: "DKK" } } }]);
actions.confirmOccurrence(data, bill.id, "2026-09");
const figures = monthView(data, "2026-09").rows.find(row => row.post.id === post.id)!.figures;
expect(figures.allocation).toBe(8000);
expect(figures.charges).toBe(8000);
expect(figures.remaining).toBe(0);
});
test("dedicated post allocates a rounded monthly share of a less frequent bill", () => {
const data = draft();
const bill = actions.addRecurringCostWithPost(data, {
name: "Insurance", folderId: null, archived: false,
amount: { amount: 1200, currency: "DKK" }, startDate: "2026-10",
recurrence: { kind: "everyNMonths", n: 12 }, anchoring: "calendar",
}, "Insurance");
const post = data.posts.find(p => p.id === bill.splits[0]!.postId)!;
expect(post.rules[0]).toEqual({ from: "2026-10", rule: { kind: "fixed", amount: { amount: 100, currency: "DKK" } } });
});
test("a rejected store mutation leaves neither dedicated record", async () => {
const data = draft();
let writes = 0;
const snapshot = createSnapshotStore({ read: async () => data, write: async () => { writes++; throw new Error("Disk full"); } }, "2026-01");
await snapshot.load();
await expect(snapshot.mutate(draft => {
actions.addRecurringCostWithPost(draft, {
name: "Rent", folderId: null, archived: false,
amount: { amount: 8000, currency: "DKK" }, startDate: "2026-09",
recurrence: { kind: "everyNMonths", n: 1 }, anchoring: "calendar",
}, "Rent post");
})).rejects.toThrow("Disk full");
expect(writes).toBe(1);
expect(snapshot.get().posts.some(post => post.name === "Rent post")).toBe(false);
expect(snapshot.get().recurring).toHaveLength(0);
});
test("editing split mode, values and absorber moves projected figures", () => {
const data = draft();
const other = data.posts[1]!;
const bill = actions.addRecurringCost(data, { ...rentInput, amount: { amount: 30, currency: "DKK" }, startDate: "2026-09" });
expect(monthView(data, "2026-09").rows.find(row => row.post.id === "housing")!.figures.expected).toBe(30);
actions.updateRecurringCost(data, bill.id, {
splitMode: "fixed",
splits: [
{ postId: "housing", value: 10, absorbsRemainder: false },
{ postId: other.id, value: 0, absorbsRemainder: true },
],
});
expect(bill.splitMode).toBe("fixed");
expect(bill.splits.map(split => split.absorbsRemainder)).toEqual([false, true]);
expect(monthView(data, "2026-09").rows.find(row => row.post.id === "housing")!.figures.expected).toBe(10);
expect(monthView(data, "2026-09").rows.find(row => row.post.id === other.id)!.figures.expected).toBe(20);
});
test("recurring split destinations must be active posts", () => {
const data = draft();
data.posts[1]!.archived = true;
expect(() => actions.addRecurringCost(data, { ...rentInput, splits: [{ postId: "ghost", value: 100, absorbsRemainder: true }] })).toThrow(/post/i);
expect(() => actions.addRecurringCost(data, { ...rentInput, splits: [{ postId: data.posts[1]!.id, value: 100, absorbsRemainder: true }] })).toThrow(/archived/i);
const bill = actions.addRecurringCost(data, rentInput);
expect(() => actions.updateRecurringCost(data, bill.id, { splits: [{ postId: "ghost", value: 100, absorbsRemainder: true }] })).toThrow(/post/i);
expect(bill.splits).toEqual(rentInput.splits);
});
test("adding assigns an id and an order", () => {
const data = draft();
const first = actions.addRecurringCost(data, rentInput);
const second = actions.addRecurringCost(data, { ...rentInput, name: "Phone" });
expect(first.id).not.toBe(second.id);
expect(first.order).toBe(3);
expect(second.order).toBe(4);
expect(data.recurring.length).toBe(2);
});
test("the amount is rounded to the dataset's digits before it lands", () => {
const data = draft();
data.settings.digits = 0;
const cost = actions.addRecurringCost(data, { ...rentInput, amount: { amount: 8000.7, currency: "DKK" } });
expect(cost.amount.amount).toBe(8001);
});
test("a recurrence with n below 1 is refused", () => {
// The projection walk terminates only if every step advances, and the
// importer is not the only write path.
const data = draft();
expect(() => actions.addRecurringCost(data, { ...rentInput, recurrence: { kind: "everyNDays", n: 0 } }))
.toThrow(/at least 1/);
expect(() => actions.addRecurringCost(data, { ...rentInput, recurrence: { kind: "everyNDays", n: 1.5 } }))
.toThrow(/whole number/);
});
test("a weekday outside 0-6 is refused", () => {
const data = draft();
expect(() =>
actions.addRecurringCost(data, { ...rentInput, recurrence: { kind: "everyNWeeks", n: 1, weekday: 7 } }),
).toThrow(/weekday/);
});
test("updating validates the new recurrence too", () => {
const data = draft();
const cost = actions.addRecurringCost(data, rentInput);
expect(() =>
actions.updateRecurringCost(data, cost.id, { recurrence: { kind: "everyNDays", n: 0 } }),
).toThrow(/at least 1/);
});
describe("startDate granularity (C1)", () => {
test("adding a day-granular kind with a month-only startDate is refused", () => {
const data = draft();
expect(() =>
actions.addRecurringCost(data, {
...rentInput,
startDate: "2026-01",
recurrence: { kind: "everyNDays", n: 30 },
}),
).toThrow(/day-granular/);
expect(() =>
actions.addRecurringCost(data, {
...rentInput,
startDate: "2026-01",
recurrence: { kind: "everyNWeeks", n: 1, weekday: 1 },
}),
).toThrow(/day-granular/);
});
test("adding a day-granular kind with a day-granular startDate succeeds", () => {
const data = draft();
const cost = actions.addRecurringCost(data, {
...rentInput,
startDate: "2026-01-05",
recurrence: { kind: "everyNDays", n: 30 },
});
expect(cost.startDate).toBe("2026-01-05");
});
test("everyNMonths accepts either granularity", () => {
const data = draft();
expect(() => actions.addRecurringCost(data, { ...rentInput, startDate: "2026-01" })).not.toThrow();
expect(() =>
actions.addRecurringCost(data, { ...rentInput, startDate: "2026-01-15" }),
).not.toThrow();
});
// This is the exact bug: a monthly cost is created (startDate "2026-09"),
// then the unit dropdown switches it to a day-granular kind WITHOUT
// touching startDate — the only way a real edit reaches this, since the
// add form always starts as everyNMonths.
test("updating just the recurrence kind against an untouched month-only startDate is refused", () => {
const data = draft();
const cost = actions.addRecurringCost(data, { ...rentInput, startDate: "2026-09" });
expect(() =>
actions.updateRecurringCost(data, cost.id, { recurrence: { kind: "everyNDays", n: 30 } }),
).toThrow(/day-granular/);
expect(() =>
actions.updateRecurringCost(data, cost.id, {
recurrence: { kind: "everyNWeeks", n: 1, weekday: 3 },
}),
).toThrow(/day-granular/);
// Refused, so nothing committed — still the original month-granular kind.
expect(data.recurring[0]!.recurrence.kind).toBe("everyNMonths");
});
test("updating just startDate to a month-only value against a stored day-granular kind is refused", () => {
const data = draft();
const cost = actions.addRecurringCost(data, {
...rentInput,
startDate: "2026-01-05",
recurrence: { kind: "everyNDays", n: 30 },
});
expect(() => actions.updateRecurringCost(data, cost.id, { startDate: "2026-01" })).toThrow(
/day-granular/,
);
});
test("updating recurrence and startDate together to a consistent day-granular pair succeeds", () => {
const data = draft();
const cost = actions.addRecurringCost(data, { ...rentInput, startDate: "2026-09" });
actions.updateRecurringCost(data, cost.id, {
recurrence: { kind: "everyNDays", n: 30 },
startDate: "2026-09-01",
});
expect(data.recurring[0]!.startDate).toBe("2026-09-01");
expect(data.recurring[0]!.recurrence.kind).toBe("everyNDays");
});
// Residual C1: shape alone ("YYYY-MM-DD") is not calendar validity.
// September has 30 days, so "2026-09-31" is day-SHAPED but impossible.
// `requireStartDateGranularity` used a shape-only regex and let this
// through even under everyNMonths; downstream, foldBalances throws
// "Invalid day in IsoDate" out of addDays/toDayOrdinal.
test("a calendar-impossible startDate is refused, even under everyNMonths", () => {
const data = draft();
expect(() =>
actions.addRecurringCost(data, { ...rentInput, startDate: "2026-09-31" }),
).toThrow(/2026-09-31/);
const cost = actions.addRecurringCost(data, rentInput);
expect(() =>
actions.updateRecurringCost(data, cost.id, { startDate: "2026-09-31" }),
).toThrow(/2026-09-31/);
// Refused, so nothing committed beyond the one successful add above.
expect(data.recurring.length).toBe(1);
expect(data.recurring[0]!.startDate).toBe(rentInput.startDate);
});
// Second half of the same reproduction: even if a calendar-impossible
// startDate is already sitting in the draft (e.g. from data written
// before this fix existed), switching the recurrence kind must not wave
// it through — requireStartDateGranularity re-validates on every update,
// not only ones that touch startDate.
test("switching kind against an already-impossible stored startDate is refused", () => {
const data = draft();
const cost = actions.addRecurringCost(data, rentInput);
// Bypass the public API to simulate a bad value already on disk.
data.recurring[0]!.startDate = "2026-09-31";
expect(() =>
actions.updateRecurringCost(data, cost.id, { recurrence: { kind: "everyNDays", n: 7 } }),
).toThrow(/2026-09-31/);
expect(data.recurring[0]!.recurrence.kind).toBe("everyNMonths");
});
// Residual C1: monthOf's regex used to be unanchored (`/^(\d{4})-(\d{2})/`
// with no trailing `$`), so it matched a "YYYY-MM" PREFIX of a longer,
// malformed string and silently dropped the rest.
test("an unanchored-month-shaped garbage value is refused", () => {
const data = draft();
expect(() =>
actions.addRecurringCost(data, { ...rentInput, startDate: "2026-091" }),
).toThrow(/2026-091/);
const cost = actions.addRecurringCost(data, rentInput);
expect(() =>
actions.updateRecurringCost(data, cost.id, { startDate: "2026-091" }),
).toThrow(/2026-091/);
});
test("legitimate calendar-valid dates still pass", () => {
const data = draft();
expect(() => actions.addRecurringCost(data, { ...rentInput, startDate: "2026-09" })).not.toThrow();
expect(() =>
actions.addRecurringCost(data, {
...rentInput,
startDate: "2024-02-29", // 2024 is a leap year
recurrence: { kind: "everyNDays", n: 1 },
}),
).not.toThrow();
expect(() =>
actions.addRecurringCost(data, {
...rentInput,
startDate: "2026-02-28", // 2026 is not a leap year
recurrence: { kind: "everyNDays", n: 1 },
}),
).not.toThrow();
});
});
test("ending sets both endedFrom and archived", () => {
const data = draft();
const cost = actions.addRecurringCost(data, rentInput);
actions.endRecurringCost(data, cost.id, "2026-06");
expect(data.recurring[0]!.endedFrom).toBe("2026-06");
expect(data.recurring[0]!.archived).toBe(true);
});
test("restoring clears both", () => {
const data = draft();
const cost = actions.addRecurringCost(data, rentInput);
actions.endRecurringCost(data, cost.id, "2026-06");
actions.restoreRecurringCost(data, cost.id);
expect(data.recurring[0]!.endedFrom).toBeUndefined();
expect(data.recurring[0]!.archived).toBe(false);
});
describe("setRecurringCostEndedFrom", () => {
test("setting a date ends AND archives, whether it is past or future", () => {
const data = draft();
const cost = actions.addRecurringCost(data, rentInput);
actions.setRecurringCostEndedFrom(data, cost.id, "2020-01"); // past
expect(data.recurring[0]!.endedFrom).toBe("2020-01");
expect(data.recurring[0]!.archived).toBe(true);
actions.setRecurringCostEndedFrom(data, cost.id, "2099-06"); // future
expect(data.recurring[0]!.endedFrom).toBe("2099-06");
expect(data.recurring[0]!.archived).toBe(true);
});
test("clearing (undefined) un-ends AND un-archives — the column doubles as un-end", () => {
const data = draft();
const cost = actions.addRecurringCost(data, rentInput);
actions.setRecurringCostEndedFrom(data, cost.id, "2026-06");
actions.setRecurringCostEndedFrom(data, cost.id, undefined);
expect(data.recurring[0]!.endedFrom).toBeUndefined();
expect(data.recurring[0]!.archived).toBe(false);
});
test("a past or future endedFrom set through the store action takes effect in the projection", () => {
// Integration check for the "Ends" column: the store write and the
// domain walk agree, for both directions — a backdated cancellation
// (import-only before this change) and a forward-dated one.
const past = draft();
const pastCost = actions.addRecurringCost(past, rentInput); // starts 2026-01
actions.setRecurringCostEndedFrom(past, pastCost.id, "2026-03"); // backdated
const pastOccurrences = occurrencesByMonth(past, "2026-06").get("2026-01") ?? [];
expect(pastOccurrences.length).toBe(1); // Jan still occurred
expect(occurrencesByMonth(past, "2026-06").has("2026-03")).toBe(false); // Mar+ stopped
const future = draft();
const futureCost = actions.addRecurringCost(future, rentInput);
actions.setRecurringCostEndedFrom(future, futureCost.id, "2026-08"); // forward-dated
const futureByMonth = occurrencesByMonth(future, "2026-09");
expect(futureByMonth.has("2026-07")).toBe(true); // still occurs right up to the cutoff
expect(futureByMonth.has("2026-08")).toBe(false); // stopped from the cutoff onward
});
test("endRecurringCost and restoreRecurringCost now delegate here, so all three stay coherent", () => {
const data = draft();
const cost = actions.addRecurringCost(data, rentInput);
actions.endRecurringCost(data, cost.id, "2026-06");
expect(data.recurring[0]!.archived).toBe(true);
actions.setRecurringCostEndedFrom(data, cost.id, undefined);
expect(data.recurring[0]!.archived).toBe(false);
actions.setRecurringCostEndedFrom(data, cost.id, "2027-01");
expect(data.recurring[0]!.archived).toBe(true);
actions.restoreRecurringCost(data, cost.id);
expect(data.recurring[0]!.endedFrom).toBeUndefined();
expect(data.recurring[0]!.archived).toBe(false);
});
});
test("moving swaps order with its neighbour", () => {
const data = draft();
const first = actions.addRecurringCost(data, rentInput);
const second = actions.addRecurringCost(data, { ...rentInput, name: "Phone" });
actions.moveRecurringCost(data, second.id, -1);
expect(data.recurring.find((c) => c.id === second.id)!.order).toBe(3);
expect(data.recurring.find((c) => c.id === first.id)!.order).toBe(4);
});
});
describe("confirmOccurrence", () => {
function draft(): Dataset {
const data = createSeedDataset("2026-01");
data.posts[0]!.id = "housing";
actions.addRecurringCost(data, {
name: "Rent",
folderId: null,
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 }],
});
return data;
}
test("writes an ordinary purchase carrying the slot it claims", () => {
const data = draft();
const purchase = actions.confirmOccurrence(data, data.recurring[0]!.id, "2026-03");
expect(purchase.source).toEqual({ recurringId: data.recurring[0]!.id, occurrenceDate: "2026-03" });
expect(purchase.date).toBe("2026-03");
expect(purchase.total).toEqual({ amount: 8000, currency: "DKK" });
expect(purchase.description).toBe("Rent");
expect(purchase.schedule).toBeNull();
expect(data.purchases.length).toBe(1);
});
test("the purchase date may differ from the slot — this is the data-cap case", () => {
const data = draft();
const purchase = actions.confirmOccurrence(data, data.recurring[0]!.id, "2026-03", { date: "2026-02-12" });
expect(purchase.source!.occurrenceDate).toBe("2026-03");
expect(purchase.date).toBe("2026-02-12");
});
test("an overridden amount is what gets recorded", () => {
const data = draft();
const purchase = actions.confirmOccurrence(data, data.recurring[0]!.id, "2026-03", {
amount: { amount: 8250, currency: "DKK" },
});
expect(purchase.total.amount).toBe(8250);
});
test("the month the purchase lands in is created, so income can be entered", () => {
const data = draft();
actions.confirmOccurrence(data, data.recurring[0]!.id, "2026-05");
expect(data.months.some((m) => m.id === "2026-05")).toBe(true);
});
test("splits are copied, not shared with the cost", () => {
const data = draft();
const purchase = actions.confirmOccurrence(data, data.recurring[0]!.id, "2026-03");
purchase.splits[0]!.value = 50;
expect(data.recurring[0]!.splits[0]!.value).toBe(100);
});
test("an unknown cost throws", () => {
expect(() => actions.confirmOccurrence(draft(), "nope", "2026-03")).toThrow(/Unknown recurring cost/);
});
});
/**
* Confirming a slot writes an ORDINARY purchase, and the ordinary purchase
* dialog can then edit its date — an action with nothing on its face to do
* with recurring costs. Under `lastCharge` anchoring that date is what the
* walk measures the next occurrence from, so moving it back far enough makes
* the walk step backwards and throw out of `foldBalances`, taking every month
* route, the year view and the summary down together. The write is refused
* here so that state cannot be reached.
*/
describe("editing a confirmation's date", () => {
function carDraft(anchoring: "calendar" | "lastCharge" = "lastCharge"): Dataset {
const data = createSeedDataset("2026-08");
data.posts[0]!.id = "housing";
actions.addRecurringCost(data, {
name: "Car insurance",
folderId: null,
archived: false,
amount: { amount: 900, currency: "DKK" },
startDate: "2026-08-14",
recurrence: { kind: "everyNDays", n: 30 },
anchoring,
splitMode: "percent",
splits: [{ postId: "housing", value: 100, absorbsRemainder: true }],
});
return data;
}
/** The walk every month route, the year view and the summary go through. */
function folds(data: Dataset): () => unknown {
return () => occurrencesByMonth(data, "2026-12");
}
function dateOf(data: Dataset, purchaseId: string): string {
return data.purchases.find((p) => p.id === purchaseId)!.date;
}
test("a date more than one cycle before the slot it claims is refused", () => {
const data = carDraft();
const purchase = actions.confirmOccurrence(data, data.recurring[0]!.id, "2026-09-13", {
date: "2026-09-13",
});
expect(folds(data)).not.toThrow();
expect(() => actions.updatePurchase(data, purchase.id, { date: "2026-08-01" })).toThrow(
/Car insurance/,
);
expect(dateOf(data, purchase.id)).toBe("2026-09-13");
expect(folds(data)).not.toThrow();
});
test("a date that keeps the series advancing is still allowed", () => {
const data = carDraft();
const purchase = actions.confirmOccurrence(data, data.recurring[0]!.id, "2026-09-13", {
date: "2026-09-13",
});
actions.updatePurchase(data, purchase.id, { date: "2026-09-08" });
expect(dateOf(data, purchase.id)).toBe("2026-09-08");
expect(folds(data)).not.toThrow();
});
test("a purchase with no slot is an ordinary purchase and moves freely", () => {
const data = carDraft();
const purchase = actions.addPurchase(data, {
date: "2026-09-13",
description: "Sofa",
total: { amount: 100, currency: "DKK" },
splitMode: "percent",
splits: [{ postId: "housing", value: 100, absorbsRemainder: true }],
schedule: null,
});
actions.updatePurchase(data, purchase.id, { date: "2026-01-01" });
expect(dateOf(data, purchase.id)).toBe("2026-01-01");
expect(folds(data)).not.toThrow();
});
test("a calendar-anchored cost's confirmation moves freely, because it never rebases", () => {
const data = carDraft("calendar");
const purchase = actions.confirmOccurrence(data, data.recurring[0]!.id, "2026-09-13", {
date: "2026-09-13",
});
actions.updatePurchase(data, purchase.id, { date: "2026-08-01" });
expect(dateOf(data, purchase.id)).toBe("2026-08-01");
expect(folds(data)).not.toThrow();
});
test("a slot naming a cost that no longer exists does not block the edit", () => {
const data = carDraft();
const purchase = actions.confirmOccurrence(data, data.recurring[0]!.id, "2026-09-13", {
date: "2026-09-13",
});
data.recurring = [];
actions.updatePurchase(data, purchase.id, { date: "2026-08-01" });
expect(dateOf(data, purchase.id)).toBe("2026-08-01");
});
});