feat(store): add plan slicing and dataset actions

Every mutation is a pure function over a draft dataset, so the write
surface is unit-tested without React. Money amounts (income, purchase
totals, split values, schedule slices, fixed/percent rule amounts) are
rounded to their currency's minor unit before they land in the draft,
per CLAUDE.md's round-before-persisting rule -- actions.ts is the
single boundary every UI mutation passes through. FxRate.baseUnitsPerOne
is deliberately exempt: it is stored at six decimal places, not money.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5 committed 2026-09-01 20:25:14 +02:00
1 parent 7edcc7f31e
commit ccb97818d7
5 files changed
+612

No files matched your search

+1
View File
@@ -9,3 +9,4 @@ export * from "./charges.ts";
export * from "./fold.ts";
export * from "./views.ts";
export * from "./seed.ts";
export * from "./plans.ts";
+36
View File
@@ -0,0 +1,36 @@
import { test, expect } from "bun:test";
import { equalSlices, sliceTotal, slicesBalance } from "./plans.ts";
test("equal slices divide the total across consecutive months", () => {
const slices = equalSlices({ amount: 3000, currency: "DKK" }, "2026-10", 6);
expect(slices).toHaveLength(6);
expect(slices[0]).toEqual({ month: "2026-10", amount: { amount: 500, currency: "DKK" } });
expect(slices[5]!.month).toBe("2027-03");
});
test("uneven totals still sum exactly, the last slice absorbing the drift", () => {
const slices = equalSlices({ amount: 100, currency: "DKK" }, "2026-01", 3);
expect(slices.map((s) => s.amount.amount)).toEqual([33.33, 33.33, 33.34]);
expect(sliceTotal(slices)).toBe(100);
});
test("a single slice takes the whole total", () => {
const slices = equalSlices({ amount: 99.99, currency: "DKK" }, "2026-01", 1);
expect(slices).toEqual([
{ month: "2026-01", amount: { amount: 99.99, currency: "DKK" } },
]);
});
test("a count below one is rejected", () => {
expect(() => equalSlices({ amount: 100, currency: "DKK" }, "2026-01", 0)).toThrow(
/at least one month/i,
);
});
test("slicesBalance reports the shortfall the editor must show", () => {
const slices = equalSlices({ amount: 100, currency: "DKK" }, "2026-01", 2);
expect(slicesBalance({ amount: 100, currency: "DKK" }, slices)).toBe(0);
slices[0]!.amount.amount = 30;
expect(slicesBalance({ amount: 100, currency: "DKK" }, slices)).toBe(20);
});
+34
View File
@@ -0,0 +1,34 @@
import { distributeByWeight, roundMoney } from "./money.ts";
import { addMonths } from "./months.ts";
import type { Money, MonthId, ScheduleSlice } from "./types.ts";
/** Splits a total into `count` consecutive monthly slices that sum exactly. */
export function equalSlices(
total: Money,
startMonth: MonthId,
count: number,
): ScheduleSlice[] {
if (!Number.isInteger(count) || count < 1) {
throw new Error("A finance plan needs at least one month");
}
const weights = new Array(count).fill(1);
const amounts = distributeByWeight(
total.amount,
weights,
count - 1,
total.currency,
);
return amounts.map((amount, i) => ({
month: addMonths(startMonth, i),
amount: { amount, currency: total.currency },
}));
}
export function sliceTotal(slices: ScheduleSlice[]): number {
return roundMoney(slices.reduce((sum, s) => sum + s.amount.amount, 0));
}
/** total - sum(slices). Positive means the plan is short of the total. */
export function slicesBalance(total: Money, slices: ScheduleSlice[]): number {
return roundMoney(total.amount - sliceTotal(slices), total.currency);
}
+315
View File
@@ -0,0 +1,315 @@
import { test, expect, describe } from "bun:test";
import * as actions from "./actions.ts";
import { createSeedDataset } from "../domain/seed.ts";
import type { Dataset } from "../domain/types.ts";
function draft(): Dataset {
return createSeedDataset("2026-09");
}
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 currency's minor unit", () => {
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", {
kind: "percentOfIncome",
percent: 5,
});
expect(post.order).toBe(3);
expect(post.archived).toBe(false);
expect(data.posts).toHaveLength(4);
});
test("addPost rounds a fixed standing rule's amount and a percent's percent", () => {
const data = draft();
const fixedPost = actions.addPost(data, "Rent", "DKK", {
kind: "fixed",
amount: { amount: 12.345, currency: "DKK" },
});
expect(fixedPost.standingRule).toEqual({
kind: "fixed",
amount: { amount: 12.35, currency: "DKK" },
});
const percentPost = actions.addPost(data, "Travel", "EUR", {
kind: "percentOfIncome",
percent: 12.3456,
});
expect(percentPost.standingRule).toEqual({
kind: "percentOfIncome",
percent: 12.35,
});
});
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("updatePost rounds a fixed standing rule's amount and a percent's percent", () => {
const data = draft();
const postId = data.posts[0]!.id;
actions.updatePost(data, postId, {
standingRule: { kind: "fixed", amount: { amount: 12.345, currency: "DKK" } },
});
expect(data.posts.find((p) => p.id === postId)!.standingRule).toEqual({
kind: "fixed",
amount: { amount: 12.35, currency: "DKK" },
});
actions.updatePost(data, postId, {
standingRule: { kind: "percentOfIncome", percent: 12.3456 },
});
expect(data.posts.find((p) => p.id === postId)!.standingRule).toEqual({
kind: "percentOfIncome",
percent: 12.35,
});
});
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();
});
});
+226
View File
@@ -0,0 +1,226 @@
import { newId } from "../domain/seed.ts";
import { monthOf } from "../domain/months.ts";
import { roundMoney } from "../domain/money.ts";
import type {
Currency,
Dataset,
FxRate,
Money,
Month,
MonthId,
Post,
PostId,
Purchase,
PurchaseId,
Rule,
Schedule,
Split,
} from "../domain/types.ts";
/**
* Every function here mutates a draft Dataset in place. They are called from
* store.mutate(), which clones before applying and writes through afterwards.
*
* Every MONEY amount that flows through here is rounded to its currency's
* minor unit (via roundMoney) before it lands in the draft, per CLAUDE.md's
* "round before persisting" rule. FxRate.baseUnitsPerOne is deliberately
* exempted in setFxRate below — an exchange rate is not money, and rounding
* it to cents would be wrong in kind (see the comment there).
*/
function requirePost(draft: Dataset, postId: PostId): Post {
const post = draft.posts.find((p) => p.id === postId);
if (!post) throw new Error(`Unknown post: ${postId}`);
return post;
}
function requirePurchase(draft: Dataset, purchaseId: PurchaseId): Purchase {
const purchase = draft.purchases.find((p) => p.id === purchaseId);
if (!purchase) throw new Error(`Unknown purchase: ${purchaseId}`);
return purchase;
}
/** Rounds a non-money percentage (e.g. Rule.percent, Split.value) to 2 decimals. */
function round2(value: number): number {
return Number(value.toFixed(2));
}
function roundMoneyValue(money: Money): Money {
return { amount: roundMoney(money.amount, money.currency), currency: money.currency };
}
function roundRule(rule: Rule): Rule {
if (rule.kind === "fixed") {
return { kind: "fixed", amount: roundMoneyValue(rule.amount) };
}
return { kind: "percentOfIncome", percent: round2(rule.percent) };
}
function roundSplits(splits: Split[]): Split[] {
return splits.map((split) => ({ ...split, value: round2(split.value) }));
}
function roundSchedule(schedule: Schedule | null): Schedule | null {
if (!schedule) return null;
return {
...schedule,
slices: schedule.slices.map((slice) => ({
month: slice.month,
amount: roundMoneyValue(slice.amount),
})),
};
}
export function ensureMonth(draft: Dataset, monthId: MonthId): Month {
const existing = draft.months.find((m) => m.id === monthId);
if (existing) return existing;
const created: Month = {
id: monthId,
income: { amount: 0, currency: draft.settings.baseCurrency },
ruleOverrides: {},
};
draft.months.push(created);
draft.months.sort((a, b) => a.id.localeCompare(b.id));
return created;
}
export function setIncome(draft: Dataset, monthId: MonthId, income: Money): void {
ensureMonth(draft, monthId).income = roundMoneyValue(income);
}
export function setRuleOverride(
draft: Dataset,
monthId: MonthId,
postId: PostId,
rule: Rule | null,
): void {
const month = ensureMonth(draft, monthId);
if (rule === null) {
delete month.ruleOverrides[postId];
return;
}
month.ruleOverrides[postId] = roundRule(rule);
}
export function addPost(
draft: Dataset,
name: string,
currency: Currency,
standingRule: Rule,
): Post {
const post: Post = {
id: newId(),
name,
order: draft.posts.length,
archived: false,
currency,
standingRule: roundRule(standingRule),
};
draft.posts.push(post);
return post;
}
export function updatePost(
draft: Dataset,
postId: PostId,
changes: Partial<Omit<Post, "id">>,
): void {
const post = requirePost(draft, postId);
const resolved: Partial<Omit<Post, "id">> = { ...changes };
if (changes.standingRule) {
resolved.standingRule = roundRule(changes.standingRule);
}
Object.assign(post, resolved);
}
/** Archives rather than deletes, because purchases reference posts. */
export function setPostArchived(
draft: Dataset,
postId: PostId,
archived: boolean,
): void {
requirePost(draft, postId).archived = archived;
}
export function movePost(draft: Dataset, postId: PostId, direction: -1 | 1): void {
const ordered = [...draft.posts].sort((a, b) => a.order - b.order);
const index = ordered.findIndex((p) => p.id === postId);
const target = index + direction;
if (index === -1 || target < 0 || target >= ordered.length) return;
const a = ordered[index]!;
const b = ordered[target]!;
[a.order, b.order] = [b.order, a.order];
}
export function addPurchase(
draft: Dataset,
purchase: Omit<Purchase, "id">,
): Purchase {
const created: Purchase = {
...purchase,
id: newId(),
total: roundMoneyValue(purchase.total),
splits: roundSplits(purchase.splits),
schedule: roundSchedule(purchase.schedule),
};
draft.purchases.push(created);
// Make sure every month the purchase touches exists, so income can be
// entered against it later.
const months = created.schedule
? created.schedule.slices.map((s) => s.month)
: [monthOf(created.date)];
for (const monthId of months) ensureMonth(draft, monthId);
return created;
}
export function updatePurchase(
draft: Dataset,
purchaseId: PurchaseId,
changes: Partial<Omit<Purchase, "id">>,
): void {
const purchase = requirePurchase(draft, purchaseId);
const resolved: Partial<Omit<Purchase, "id">> = { ...changes };
if (changes.total) {
resolved.total = roundMoneyValue(changes.total);
}
if (changes.splits) {
resolved.splits = roundSplits(changes.splits);
}
if (changes.schedule !== undefined) {
resolved.schedule = roundSchedule(changes.schedule);
}
Object.assign(purchase, resolved);
}
export function deletePurchase(draft: Dataset, purchaseId: PurchaseId): void {
draft.purchases = draft.purchases.filter((p) => p.id !== purchaseId);
}
export function cancelScheduleFrom(
draft: Dataset,
purchaseId: PurchaseId,
monthId: MonthId,
): void {
const purchase = requirePurchase(draft, purchaseId);
if (!purchase.schedule) {
throw new Error(`Purchase ${purchaseId} has no finance plan to cancel`);
}
purchase.schedule.cancelledFromMonth = monthId;
}
/**
* Stores the rate exactly as passed. Exchange rates are not money: they are
* kept at six decimal places (see fxApi.ts's toFixed(6)), and rounding to a
* currency's minor unit would quantize a rate below 0.01 straight to zero.
*/
export function setFxRate(draft: Dataset, rate: FxRate): void {
const index = draft.fxRates.findIndex((r) => r.currency === rate.currency);
if (index === -1) draft.fxRates.push(rate);
else draft.fxRates[index] = rate;
}
/** The base currency never has a rate row of its own. */
export function setBaseCurrency(draft: Dataset, currency: Currency): void {
draft.settings.baseCurrency = currency;
draft.fxRates = draft.fxRates.filter((r) => r.currency !== currency);
}