A confirmation is an ordinary Purchase, and the ordinary purchase dialog can edit its date. Under lastCharge anchoring that date is what the walk measures the next occurrence from, so moving it back more than a cycle made the walk step backwards and throw out of foldBalances -- every month route, the year view and the summary down at once, from an edit that never mentioned recurring costs. updatePurchase is the write that can invalidate an already-valid confirmation, so it now asks wouldAdvancePast -- the same helper ExpectedBand asks before offering a row -- and refuses the edit instead. The walk's throw is unchanged: a step that lands backwards really is bad data, and this stops it being created. A purchase with no source is not checked at all, and a source naming a cost that no longer exists falls through. PurchaseDialog was closing on save regardless of the outcome, with its useMutate error rendered nowhere, so a refusal looked like a save that did nothing. mutate now reports whether the write landed, and the dialog stays open and shows the reason when it did not. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
922 lines
34 KiB
TypeScript
922 lines
34 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 type { Dataset } from "../domain/types.ts";
|
|
|
|
/**
|
|
* 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("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",
|
|
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("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(0);
|
|
expect(second.order).toBe(1);
|
|
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(0);
|
|
expect(data.recurring.find((c) => c.id === first.id)!.order).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe("confirmOccurrence", () => {
|
|
function draft(): Dataset {
|
|
const data = createSeedDataset("2026-01");
|
|
data.posts[0]!.id = "housing";
|
|
actions.addRecurringCost(data, {
|
|
name: "Rent",
|
|
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",
|
|
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");
|
|
});
|
|
});
|