diff --git a/src/store/actions.test.ts b/src/store/actions.test.ts index 2fcddb3..6484003 100644 --- a/src/store/actions.test.ts +++ b/src/store/actions.test.ts @@ -302,3 +302,79 @@ describe("settings", () => { 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/, + ); + }); +}); diff --git a/src/store/actions.ts b/src/store/actions.ts index c1d93e6..340b7f2 100644 --- a/src/store/actions.ts +++ b/src/store/actions.ts @@ -1,5 +1,5 @@ import { newId } from "../domain/seed.ts"; -import { monthOf } from "../domain/months.ts"; +import { compareMonths, monthOf } from "../domain/months.ts"; import { roundMoney } from "../domain/money.ts"; import type { Currency, @@ -13,6 +13,7 @@ import type { Purchase, PurchaseId, Rule, + RuleVersion, Schedule, Split, } from "../domain/types.ts"; @@ -144,6 +145,35 @@ export function movePost(draft: Dataset, postId: PostId, direction: -1 | 1): voi [a.order, b.order] = [b.order, a.order]; } +/** + * Inserts or replaces the version starting at `from`, keeping `rules` sorted. + * Replacing rather than appending is what keeps the effective rule + * unambiguous: two versions sharing a month would both be "in effect", and + * ruleAt would resolve to whichever the sort happened to leave last. + */ +export function setRuleFrom( + draft: Dataset, + postId: PostId, + from: MonthId, + rule: Rule, +): void { + const post = requirePost(draft, postId); + const version: RuleVersion = { from, rule: roundRule(rule) }; + const existing = post.rules.findIndex((v) => v.from === from); + if (existing === -1) post.rules.push(version); + else post.rules[existing] = version; + post.rules.sort((a, b) => compareMonths(a.from, b.from)); +} + +/** + * Deletes the version starting at `from`. Removing the earliest one means the + * months before the next version now resolve to no rule, and allocate zero. + */ +export function removeRuleFrom(draft: Dataset, postId: PostId, from: MonthId): void { + const post = requirePost(draft, postId); + post.rules = post.rules.filter((v) => v.from !== from); +} + export function addPurchase( draft: Dataset, purchase: Omit,