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:
co-authored by
Claude Opus 5
parent
3d3121aca2
commit
bbe313b009
@@ -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/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user