diff --git a/src/domain/index.ts b/src/domain/index.ts index ab0b404..94c7962 100644 --- a/src/domain/index.ts +++ b/src/domain/index.ts @@ -9,3 +9,4 @@ export * from "./charges.ts"; export * from "./fold.ts"; export * from "./views.ts"; export * from "./seed.ts"; +export * from "./plans.ts"; diff --git a/src/domain/plans.test.ts b/src/domain/plans.test.ts new file mode 100644 index 0000000..bcdbbe8 --- /dev/null +++ b/src/domain/plans.test.ts @@ -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); +}); diff --git a/src/domain/plans.ts b/src/domain/plans.ts new file mode 100644 index 0000000..8f481fa --- /dev/null +++ b/src/domain/plans.ts @@ -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); +} diff --git a/src/store/actions.test.ts b/src/store/actions.test.ts new file mode 100644 index 0000000..5c9b587 --- /dev/null +++ b/src/store/actions.test.ts @@ -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(); + }); +}); diff --git a/src/store/actions.ts b/src/store/actions.ts new file mode 100644 index 0000000..7c311d7 --- /dev/null +++ b/src/store/actions.ts @@ -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>, +): void { + const post = requirePost(draft, postId); + const resolved: Partial> = { ...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 { + 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>, +): void { + const purchase = requirePurchase(draft, purchaseId); + const resolved: Partial> = { ...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); +}