diff --git a/src/store/actions.test.ts b/src/store/actions.test.ts index 3bc7c1b..59620b6 100644 --- a/src/store/actions.test.ts +++ b/src/store/actions.test.ts @@ -453,3 +453,157 @@ describe("split values are rounded by what they MEAN", () => { }); }); }); + +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/); + }); + + 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); + }); + + 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/); + }); +}); diff --git a/src/store/actions.ts b/src/store/actions.ts index 8f834e4..9a3d57e 100755 --- a/src/store/actions.ts +++ b/src/store/actions.ts @@ -7,6 +7,7 @@ import type { CurrencyDef, Dataset, FxRate, + IsoDate, Money, Month, MonthId, @@ -14,6 +15,9 @@ import type { PostId, Purchase, PurchaseId, + Recurrence, + RecurringCost, + RecurringCostId, Rule, RuleVersion, Schedule, @@ -362,3 +366,141 @@ export function setBaseCurrency(draft: Dataset, currency: Currency): void { draft.settings.baseCurrency = currency; draft.fxRates = draft.fxRates.filter((r) => r.currency !== currency); } + +function requireRecurringCost(draft: Dataset, id: RecurringCostId): RecurringCost { + const cost = draft.recurring.find((c) => c.id === id); + if (!cost) throw new Error(`Unknown recurring cost: ${id}`); + return cost; +} + +/** + * The projection walk in `domain/occurrences.ts` terminates only if every step + * strictly advances, and `n` is what guarantees that. The JSON importer checks + * the same thing; this is the other write path, and both have to hold or the + * fold can hang. + */ +function requireRecurrence(recurrence: Recurrence): Recurrence { + if (!Number.isInteger(recurrence.n)) { + throw new Error(`A recurrence interval must be a whole number, not ${recurrence.n}`); + } + if (recurrence.n < 1) { + throw new Error(`A recurrence interval must be at least 1, not ${recurrence.n}`); + } + if (recurrence.kind === "everyNWeeks") { + if (!Number.isInteger(recurrence.weekday) || recurrence.weekday < 0 || recurrence.weekday > 6) { + throw new Error(`A weekday must be 0-6 (0 is Sunday), not ${recurrence.weekday}`); + } + } + return recurrence; +} + +export function addRecurringCost( + draft: Dataset, + cost: Omit, +): RecurringCost { + const created: RecurringCost = { + ...cost, + id: newId(), + order: draft.recurring.length, + recurrence: requireRecurrence(cost.recurrence), + amount: roundMoneyValue(draft, cost.amount), + splits: roundSplits(draft, cost.splits, cost.splitMode, cost.amount.currency), + }; + draft.recurring.push(created); + return created; +} + +export function updateRecurringCost( + draft: Dataset, + id: RecurringCostId, + changes: Partial>, +): void { + const cost = requireRecurringCost(draft, id); + const resolved: Partial> = { ...changes }; + + if (changes.recurrence) { + resolved.recurrence = requireRecurrence(changes.recurrence); + } + if (changes.amount) { + resolved.amount = roundMoneyValue(draft, changes.amount); + } + if (changes.splits) { + // Either field may be absent from a partial update; the stored cost + // supplies whichever one is, so the mode and currency always agree with + // the values being rounded. + resolved.splits = roundSplits( + draft, + changes.splits, + changes.splitMode ?? cost.splitMode, + (resolved.amount ?? cost.amount).currency, + ); + } + + Object.assign(cost, resolved); +} + +export function moveRecurringCost( + draft: Dataset, + id: RecurringCostId, + direction: -1 | 1, +): void { + const ordered = [...draft.recurring].sort((a, b) => a.order - b.order); + const index = ordered.findIndex((c) => c.id === id); + 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]; +} + +/** + * Stops a bill from `from` onward and hides it from the list. + * + * Both fields, because they mean different things: `endedFrom` stops the + * PROJECTION, and `archived` only hides the row. Archiving alone would leave + * the bill projecting forever; setting `endedFrom` alone would leave a dead + * bill cluttering the list. Neither touches a past occurrence, so no + * historical figure moves. + */ +export function endRecurringCost(draft: Dataset, id: RecurringCostId, from: IsoDate): void { + const cost = requireRecurringCost(draft, id); + cost.endedFrom = from; + cost.archived = true; +} + +export function restoreRecurringCost(draft: Dataset, id: RecurringCostId): void { + const cost = requireRecurringCost(draft, id); + delete cost.endedFrom; + cost.archived = false; +} + +/** + * Turns one projected occurrence into a real purchase. + * + * `occurrenceDate` is the slot being claimed and goes into `source`; + * `overrides.date` is when the money actually moved and goes on the purchase. + * They differ whenever a bill is paid off schedule, and under `lastCharge` + * anchoring it is the latter that rebases the series — which is the whole of + * the phone-bill behaviour. + * + * Deleting the purchase later un-confirms the slot, with nothing to reconcile. + */ +export function confirmOccurrence( + draft: Dataset, + recurringId: RecurringCostId, + occurrenceDate: IsoDate, + overrides: { date?: IsoDate; amount?: Money } = {}, +): Purchase { + const cost = requireRecurringCost(draft, recurringId); + return addPurchase(draft, { + date: overrides.date ?? occurrenceDate, + description: cost.name, + total: overrides.amount ?? cost.amount, + splitMode: cost.splitMode, + // Copied, not shared: editing the purchase's splits must not rewrite the + // cost's, and `mutate` clones the draft rather than deep-freezing it. + splits: cost.splits.map((split) => ({ ...split })), + schedule: null, + source: { recurringId, occurrenceDate }, + }); +}