feat: choose recurring bill destinations

This commit is contained in:
Lucas Winther
2026-09-29 20:49:18 +02:00
parent 1e30671d76
commit 6de115843b
6 changed files with 318 additions and 20 deletions
+62
View File
@@ -2,6 +2,8 @@ import { test, expect, describe } from "bun:test";
import * as actions from "./actions.ts";
import { createSeedDataset } from "../domain/seed.ts";
import { occurrencesByMonth } from "../domain/occurrences.ts";
import { monthView } from "../domain/views.ts";
import { createSnapshotStore } from "./snapshot.ts";
import { exportDatasetJson, parseDatasetJson } from "../export/json.ts";
import type { Dataset } from "../domain/types.ts";
@@ -570,6 +572,66 @@ describe("recurring costs", () => {
splits: [{ postId: "housing", value: 100, absorbsRemainder: true }],
};
test("dedicated post and bill save together", () => {
const data = draft();
const folder = actions.addBudgetFolder(data, "Housing", null);
const bill = actions.addRecurringCostWithPost(data, {
name: "Rent", folderId: folder.id, archived: false,
amount: { amount: 8000, currency: "DKK" }, startDate: "2026-09",
recurrence: { kind: "everyNMonths", n: 1 }, anchoring: "calendar",
}, "Rent post");
const post = data.posts.find(p => p.id === bill.splits[0]!.postId)!;
expect(post.name).toBe("Rent post");
expect(post.folderId).toBe(folder.id);
expect(bill.folderId).toBe(folder.id);
expect(bill.splits).toEqual([{ postId: post.id, value: 100, absorbsRemainder: true }]);
});
test("a rejected store mutation leaves neither dedicated record", async () => {
const data = draft();
let writes = 0;
const snapshot = createSnapshotStore({ read: async () => data, write: async () => { writes++; throw new Error("Disk full"); } }, "2026-01");
await snapshot.load();
await expect(snapshot.mutate(draft => {
actions.addRecurringCostWithPost(draft, {
name: "Rent", folderId: null, archived: false,
amount: { amount: 8000, currency: "DKK" }, startDate: "2026-09",
recurrence: { kind: "everyNMonths", n: 1 }, anchoring: "calendar",
}, "Rent post");
})).rejects.toThrow("Disk full");
expect(writes).toBe(1);
expect(snapshot.get().posts.some(post => post.name === "Rent post")).toBe(false);
expect(snapshot.get().recurring).toHaveLength(0);
});
test("editing split mode, values and absorber moves projected figures", () => {
const data = draft();
const other = data.posts[1]!;
const bill = actions.addRecurringCost(data, { ...rentInput, amount: { amount: 30, currency: "DKK" }, startDate: "2026-09" });
expect(monthView(data, "2026-09").rows.find(row => row.post.id === "housing")!.figures.expected).toBe(30);
actions.updateRecurringCost(data, bill.id, {
splitMode: "fixed",
splits: [
{ postId: "housing", value: 10, absorbsRemainder: false },
{ postId: other.id, value: 0, absorbsRemainder: true },
],
});
expect(bill.splitMode).toBe("fixed");
expect(bill.splits.map(split => split.absorbsRemainder)).toEqual([false, true]);
expect(monthView(data, "2026-09").rows.find(row => row.post.id === "housing")!.figures.expected).toBe(10);
expect(monthView(data, "2026-09").rows.find(row => row.post.id === other.id)!.figures.expected).toBe(20);
});
test("recurring split destinations must be active posts", () => {
const data = draft();
data.posts[1]!.archived = true;
expect(() => actions.addRecurringCost(data, { ...rentInput, splits: [{ postId: "ghost", value: 100, absorbsRemainder: true }] })).toThrow(/post/i);
expect(() => actions.addRecurringCost(data, { ...rentInput, splits: [{ postId: data.posts[1]!.id, value: 100, absorbsRemainder: true }] })).toThrow(/archived/i);
const bill = actions.addRecurringCost(data, rentInput);
expect(() => actions.updateRecurringCost(data, bill.id, { splits: [{ postId: "ghost", value: 100, absorbsRemainder: true }] })).toThrow(/post/i);
expect(bill.splits).toEqual(rentInput.splits);
});
test("adding assigns an id and an order", () => {
const data = draft();
const first = actions.addRecurringCost(data, rentInput);
+26
View File
@@ -176,6 +176,14 @@ function roundSplits(
}));
}
function requireRecurringSplitDestinations(draft: Dataset, splits: Split[]): void {
if (splits.length === 0) throw new Error("A bill needs a destination post");
for (const split of splits) {
const post = requirePost(draft, split.postId);
if (post.archived) throw new Error(`Post ${post.name} is archived`);
}
}
function roundSchedule(draft: Dataset, schedule: Schedule | null): Schedule | null {
if (!schedule) return null;
return {
@@ -573,6 +581,7 @@ export function addRecurringCost(
const recurrence = requireRecurrence(cost.recurrence);
requireStartDateGranularity(recurrence, cost.startDate);
if (cost.folderId !== null) requireFolder(draft, cost.folderId);
requireRecurringSplitDestinations(draft, cost.splits);
const created: RecurringCost = {
...cost,
id: newId(),
@@ -585,6 +594,22 @@ export function addRecurringCost(
return created;
}
/** Creates the dedicated envelope and its bill in one store mutation. */
export function addRecurringCostWithPost(
draft: Dataset,
cost: Omit<RecurringCost, "id" | "order" | "splits" | "splitMode">,
newPostName: string,
): RecurringCost {
const name = newPostName.trim();
if (!name) throw new Error("Post name cannot be blank");
const post = addPost(draft, name, cost.amount.currency, cost.folderId);
return addRecurringCost(draft, {
...cost,
splitMode: "percent",
splits: [{ postId: post.id, value: 100, absorbsRemainder: true }],
});
}
/**
* `archived` and `endedFrom` are excluded on purpose: they are a coherent
* pair (`archived` mirrors "`endedFrom` is set") maintained ONLY by
@@ -617,6 +642,7 @@ export function updateRecurringCost(
resolved.amount = roundMoneyValue(draft, changes.amount);
}
if (changes.splits) {
requireRecurringSplitDestinations(draft, 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.
@@ -0,0 +1,30 @@
import { GlobalRegistrator } from "@happy-dom/global-registrator";
if (!GlobalRegistrator.isRegistered) GlobalRegistrator.register();
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
import { expect, test } from "bun:test";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { createSeedDataset } from "../../domain/seed.ts";
import { RecurringSplitEditor, type RecurringSplitSelection } from "./RecurringSplitEditor.tsx";
test("bill can split across active posts", () => {
const data = createSeedDataset("2026-09");
data.posts[0]!.archived = true;
const active = data.posts.filter(post => !post.archived);
let selection: RecurringSplitSelection = { splitMode: "percent", splits: [{ postId: active[0]!.id, value: 100, absorbsRemainder: true }] };
const container = document.createElement("div");
const root = createRoot(container);
const render = () => root.render(<RecurringSplitEditor {...selection} posts={active} onChange={next => { selection = next; render(); }} />);
act(render);
expect(container.querySelector(`option[value="${data.posts[0]!.id}"]`)).toBeNull();
act(() => { (Array.from(container.querySelectorAll("button")).find(button => button.textContent?.includes("Add another post"))!).click(); });
expect(selection.splits).toHaveLength(2);
const selects = container.querySelectorAll("select");
act(() => { selects[2]!.value = active[1]!.id; selects[2]!.dispatchEvent(new Event("change", { bubbles: true })); });
const radios = container.querySelectorAll('input[type="radio"]');
act(() => { (radios[1] as HTMLInputElement).click(); });
expect(selection.splits.map(split => split.postId)).toEqual([active[0]!.id, active[1]!.id]);
expect(selection.splits.map(split => split.absorbsRemainder)).toEqual([false, true]);
act(() => root.unmount());
});
@@ -0,0 +1,60 @@
import { Button } from "@/components/ui/button";
import { useId } from "react";
import { Input } from "@/components/ui/input";
import { NativeSelect } from "@/components/ui/native-select";
import type { Post, RecurringCost, Split } from "../../domain/types.ts";
export type RecurringSplitSelection = Pick<RecurringCost, "splitMode" | "splits">;
interface Props extends RecurringSplitSelection {
posts: Post[];
onChange: (next: RecurringSplitSelection) => void;
}
export function RecurringSplitEditor({ splitMode, splits, posts, onChange }: Props) {
const absorberGroup = useId();
function changeSplit(index: number, changes: Partial<Split>) {
onChange({ splitMode, splits: splits.map((split, i) => i === index ? { ...split, ...changes } : split) });
}
return (
<fieldset className="space-y-2">
<legend className="text-sm font-medium">Destination posts</legend>
{splits.length > 1 && (
<NativeSelect aria-label="Split by" value={splitMode} onChange={event => {
const mode = event.target.value as RecurringCost["splitMode"];
onChange({ splitMode: mode, splits: splits.map((split, index) => ({ ...split, value: mode === "percent" && index === 0 ? 100 : 0 })) });
}}>
<option value="percent">Percentages</option>
<option value="fixed">Amounts</option>
</NativeSelect>
)}
{splits.map((split, index) => (
<div key={index} className="flex flex-wrap items-center gap-2">
<NativeSelect
aria-label={index === 0 ? "Destination post" : `Destination post ${index + 1}`}
className="min-w-36 flex-1"
value={split.postId}
onChange={event => changeSplit(index, { postId: event.target.value })}
>
<option value="">Choose a post…</option>
{posts.map(post => <option key={post.id} value={post.id}>{post.name}</option>)}
</NativeSelect>
{splits.length > 1 && (
<>
<Input aria-label={`Split value ${index + 1}`} type="number" step="0.01" className="w-24" value={split.value} onChange={event => changeSplit(index, { value: Number(event.target.value) || 0 })} />
<span className="text-xs text-budget-ink-muted">{splitMode === "percent" ? "%" : "amount"}</span>
<label className="flex items-center gap-1 text-xs"><input type="radio" name={absorberGroup} checked={split.absorbsRemainder} onChange={() => onChange({ splitMode, splits: splits.map((item, i) => ({ ...item, absorbsRemainder: i === index })) })} />rounding</label>
<Button type="button" size="xs" variant="ghost" onClick={() => {
const remaining = splits.filter((_, i) => i !== index);
if (!remaining.some(item => item.absorbsRemainder)) remaining[0] = { ...remaining[0]!, absorbsRemainder: true };
onChange({ splitMode, splits: remaining });
}}>remove</Button>
</>
)}
</div>
))}
<Button type="button" size="sm" variant="outline" onClick={() => onChange({ splitMode, splits: [...splits, { postId: "", value: 0, absorbsRemainder: false }] })}>Add another post</Button>
</fieldset>
);
}
@@ -1,5 +1,77 @@
import { describe, expect, test } from "bun:test";
import { isValidEndedFrom, isValidStartDate, normalizedStartDateFor } from "./RecurringSection.tsx";
import { GlobalRegistrator } from "@happy-dom/global-registrator";
if (!GlobalRegistrator.isRegistered) GlobalRegistrator.register();
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
import { act } from "react";
import { createRoot } from "react-dom/client";
import { createSeedDataset } from "../../../domain/seed.ts";
import { store } from "../../../store/index.ts";
import { addRecurringCost } from "../../../store/actions.ts";
import { RecurringSection } from "./RecurringSection.tsx";
test("bill requires an explicit destination", async () => {
const data = createSeedDataset("2026-09");
data.posts[0]!.archived = true;
const original = { get: store.get, subscribe: store.subscribe, mutate: store.mutate };
store.get = () => data;
store.subscribe = () => () => {};
store.mutate = async change => { change(data); };
const container = document.createElement("div");
const root = createRoot(container);
document.body.append(container);
try {
act(() => root.render(<RecurringSection />));
const name = container.querySelector('input[placeholder="New cost name"]') as HTMLInputElement;
act(() => { name.focus(); Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(name, "Phone"); name.dispatchEvent(new InputEvent("input", { bubbles: true, data: "Phone", inputType: "insertText" })); });
const add = Array.from(container.querySelectorAll("button")).find(button => button.textContent === "Add recurring cost")!;
expect(add.disabled).toBe(true);
const chosen = data.posts[1]!;
const destination = container.querySelector('select[aria-label="Destination post"]') as HTMLSelectElement;
expect(destination.querySelector(`option[value="${data.posts[0]!.id}"]`)).toBeNull();
act(() => { destination.value = chosen.id; destination.dispatchEvent(new Event("change", { bubbles: true })); });
const amount = container.querySelector('input[aria-label="New bill amount"]') as HTMLInputElement;
act(() => { Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(amount, "30$"); amount.dispatchEvent(new InputEvent("input", { bubbles: true, data: "30$", inputType: "insertText" })); });
expect(add.disabled).toBe(false);
await act(async () => { add.click(); });
const saved = data.recurring[0]!;
expect(saved.splits[0]!.postId).toBe(chosen.id);
expect(saved.amount).toEqual({ amount: 30, currency: "USD" });
} finally {
act(() => root.unmount()); container.remove();
store.get = original.get; store.subscribe = original.subscribe; store.mutate = original.mutate;
}
});
test("failed destination edit keeps its editor and error visible", async () => {
const data = createSeedDataset("2026-09");
const cost = addRecurringCost(data, {
name: "Phone", folderId: null, archived: false,
amount: { amount: 30, currency: "DKK" }, startDate: "2026-09",
recurrence: { kind: "everyNMonths", n: 1 }, anchoring: "calendar",
splitMode: "percent", splits: [{ postId: data.posts[0]!.id, value: 100, absorbsRemainder: true }],
});
const original = { get: store.get, subscribe: store.subscribe, mutate: store.mutate };
store.get = () => data;
store.subscribe = () => () => {};
store.mutate = async () => { throw new Error("Disk full"); };
const container = document.createElement("div");
const root = createRoot(container);
document.body.append(container);
try {
act(() => root.render(<RecurringSection />));
const edit = container.querySelector(`#bill-${cost.id}`) as HTMLButtonElement;
expect(edit).not.toBeNull();
act(() => edit.click());
await act(async () => { (Array.from(container.querySelectorAll("button")).find(button => button.textContent === "Save destinations")!).click(); });
expect(container.textContent).toContain("Save destinations");
expect(container.querySelector('[role="alert"]')?.textContent).toContain("Disk full");
expect(cost.splits[0]!.postId).toBe(data.posts[0]!.id);
} finally {
act(() => root.unmount()); container.remove();
store.get = original.get; store.subscribe = original.subscribe; store.mutate = original.mutate;
}
});
// C2: the "Starts" field must never be able to persist an empty or
// malformed value — `isValidStartDate` is the guard the onBlur handler uses
+68 -20
View File
@@ -1,4 +1,4 @@
import { useState } from "react";
import { Fragment, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { NativeSelect } from "@/components/ui/native-select";
@@ -6,6 +6,7 @@ import { useDataset } from "../../hooks/useDataset.ts";
import { useMutate } from "../../hooks/useMutate.ts";
import {
addRecurringCost,
addRecurringCostWithPost,
endRecurringCost,
moveRecurringCost,
restoreRecurringCost,
@@ -15,6 +16,7 @@ import {
import { parseMoneyInput } from "../../moneyInput.ts";
import { currentMonth } from "../../../store/index.ts";
import { Section } from "../../components/Section.tsx";
import { RecurringSplitEditor, type RecurringSplitSelection } from "../../components/RecurringSplitEditor.tsx";
import { monthOf } from "../../../domain/months.ts";
import { toDayOrdinal } from "../../../domain/days.ts";
import type { IsoDate, Recurrence, RecurringCost } from "../../../domain/types.ts";
@@ -97,9 +99,18 @@ export function RecurringSection() {
const dataset = useDataset();
const { mutate, error } = useMutate();
const [newName, setNewName] = useState("");
const [newAmount, setNewAmount] = useState("0");
const [destinationKind, setDestinationKind] = useState<"existing" | "dedicated">("existing");
const [newPostName, setNewPostName] = useState("");
const [folderId, setFolderId] = useState<string | null>(null);
const [newSelection, setNewSelection] = useState<RecurringSplitSelection>({ splitMode: "percent", splits: [{ postId: "", value: 100, absorbsRemainder: true }] });
const [editingId, setEditingId] = useState<string | null>(null);
const [editSelection, setEditSelection] = useState<RecurringSplitSelection | null>(null);
const ordered = [...dataset.recurring].sort((a, b) => a.order - b.order);
const base = dataset.settings.baseCurrency;
const firstPost = [...dataset.posts].sort((a, b) => a.order - b.order)[0];
const activePosts = dataset.posts.filter(post => !post.archived).sort((a, b) => a.order - b.order);
const parsedNewAmount = parseMoneyInput(newAmount, dataset.currencies, base);
const validNewSplits = newSelection.splits.length > 0 && newSelection.splits.every(split => activePosts.some(post => post.id === split.postId));
function amountCell(cost: RecurringCost) {
return (
@@ -222,9 +233,9 @@ export function RecurringSection() {
</tr>
</thead>
<tbody>
{ordered.map((cost, index) => (
{ordered.map((cost, index) => (<Fragment key={cost.id}>
<tr
key={cost.id}
data-bill-id={cost.id}
className={`border-b last:border-0 ${cost.archived ? "opacity-50" : ""}`}
>
<td className="py-2">
@@ -310,6 +321,10 @@ export function RecurringSection() {
</td>
<td className="py-2">
<div className="flex items-center justify-end gap-1">
<Button id={`bill-${cost.id}`} size="xs" variant="ghost" onClick={() => {
setEditingId(cost.id);
setEditSelection({ splitMode: cost.splitMode, splits: cost.splits.map(split => ({ ...split })) });
}}>Edit</Button>
<Button
size="xs"
variant="ghost"
@@ -343,7 +358,21 @@ export function RecurringSection() {
</div>
</td>
</tr>
))}
{editingId === cost.id && editSelection && (
<tr key={`${cost.id}-editor`}><td colSpan={7} className="py-3">
<div className="space-y-3 rounded-md border border-budget-rule p-3">
<RecurringSplitEditor {...editSelection} posts={activePosts} onChange={setEditSelection} />
<div className="flex gap-2">
<Button size="sm" disabled={!editSelection.splits.every(split => activePosts.some(post => post.id === split.postId))} onClick={() => {
const selected = { splitMode: editSelection.splitMode, splits: editSelection.splits.map(split => ({ ...split })) };
void mutate(draft => updateRecurringCost(draft, cost.id, selected)).then(saved => { if (saved) setEditingId(null); });
}}>Save destinations</Button>
<Button size="sm" variant="outline" onClick={() => setEditingId(null)}>Cancel</Button>
</div>
</div>
</td></tr>
)}
</Fragment>))}
</tbody>
</table>
</div>
@@ -363,35 +392,54 @@ export function RecurringSection() {
className="w-48"
placeholder="New cost name"
value={newName}
onChange={(event) => setNewName(event.target.value)}
onInput={(event) => setNewName(event.currentTarget.value)}
/>
<Input className="w-28" type="text" aria-label="New bill amount" value={newAmount} onInput={event => setNewAmount(event.currentTarget.value)} />
<NativeSelect aria-label="Bill folder" value={folderId ?? ""} onChange={event => setFolderId(event.target.value || null)}>
<option value="">Unfiled</option>
{dataset.folders.map(folder => <option key={folder.id} value={folder.id}>{folder.name}</option>)}
</NativeSelect>
<NativeSelect aria-label="Destination type" value={destinationKind} onChange={event => setDestinationKind(event.target.value as "existing" | "dedicated")}>
<option value="existing">Use existing post</option>
<option value="dedicated">Create dedicated post</option>
</NativeSelect>
{destinationKind === "dedicated" ? (
<Input aria-label="Dedicated post name" placeholder="New post name" value={newPostName} onChange={event => setNewPostName(event.target.value)} />
) : (
<RecurringSplitEditor {...newSelection} posts={activePosts} onChange={setNewSelection} />
)}
<Button
variant="outline"
disabled={newName.trim() === "" || !firstPost}
disabled={newName.trim() === "" || !parsedNewAmount || (destinationKind === "dedicated" ? !newPostName.trim() : !validNewSplits)}
onClick={() => {
const name = newName.trim();
void mutate((draft) =>
addRecurringCost(draft, {
const amount = parsedNewAmount;
if (!amount) return;
const selectedFolder = folderId;
const selectedKind = destinationKind;
const dedicatedName = newPostName.trim();
const selectedSplits = newSelection.splits.map(split => ({ ...split }));
const selectedMode = newSelection.splitMode;
void mutate((draft) => {
const input = {
name,
folderId: null,
folderId: selectedFolder,
archived: false,
amount: { amount: 0, currency: base },
amount,
startDate: currentMonth,
recurrence: { kind: "everyNMonths", n: 1 },
anchoring: "calendar",
splitMode: "percent",
splits: [{ postId: firstPost!.id, value: 100, absorbsRemainder: true }],
}),
recurrence: { kind: "everyNMonths" as const, n: 1 },
anchoring: "calendar" as const,
};
if (selectedKind === "dedicated") addRecurringCostWithPost(draft, input, dedicatedName);
else addRecurringCost(draft, { ...input, splitMode: selectedMode, splits: selectedSplits });
},
).then((saved) => {
if (saved) setNewName("");
if (saved) { setNewName(""); setNewPostName(""); setNewAmount("0"); setNewSelection({ splitMode: "percent", splits: [{ postId: "", value: 100, absorbsRemainder: true }] }); }
});
}}
>
Add recurring cost
</Button>
{!firstPost && (
<span className="text-xs text-budget-ink-muted">Create a post first.</span>
)}
</div>
</Section>
);