feat(store): recurring cost actions, and confirming an occurrence

confirmOccurrence writes an ordinary Purchase: the slot it claims goes in
source.occurrenceDate, the date the money moved goes on the purchase, and the
two differ whenever a bill is paid off schedule. Splits are copied rather than
shared, so editing the purchase cannot rewrite the cost.

endRecurringCost sets endedFrom AND archived, because they do different jobs —
one stops the projection, the other only hides the row. Archiving alone would
leave a cancelled bill projecting forever. Neither touches a past occurrence,
so no historical figure moves.

requireRecurrence rejects n below 1 and non-integers rather than clamping.
The walk terminates only if every step advances, and the importer is not the
only write path.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Lucas Winther
2026-09-03 18:08:32 +02:00
co-authored by Claude Opus 5
parent 3d3121aca2
commit bbe313b009
2 changed files with 296 additions and 0 deletions
+154
View File
@@ -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/);
});
});
+142
View File
@@ -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, "id" | "order">,
): 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<Omit<RecurringCost, "id">>,
): void {
const cost = requireRecurringCost(draft, id);
const resolved: Partial<Omit<RecurringCost, "id">> = { ...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 },
});
}