diff --git a/src/store/actions.test.ts b/src/store/actions.test.ts index e9ac285..244563f 100644 --- a/src/store/actions.test.ts +++ b/src/store/actions.test.ts @@ -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); diff --git a/src/store/actions.ts b/src/store/actions.ts index 68a23fe..1d7be5e 100755 --- a/src/store/actions.ts +++ b/src/store/actions.ts @@ -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, + 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. diff --git a/src/ui/components/RecurringSplitEditor.test.tsx b/src/ui/components/RecurringSplitEditor.test.tsx new file mode 100644 index 0000000..abfc8bc --- /dev/null +++ b/src/ui/components/RecurringSplitEditor.test.tsx @@ -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( { 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()); +}); diff --git a/src/ui/components/RecurringSplitEditor.tsx b/src/ui/components/RecurringSplitEditor.tsx new file mode 100644 index 0000000..7fd41c1 --- /dev/null +++ b/src/ui/components/RecurringSplitEditor.tsx @@ -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; + +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) { + onChange({ splitMode, splits: splits.map((split, i) => i === index ? { ...split, ...changes } : split) }); + } + + return ( +
+ Destination posts + {splits.length > 1 && ( + { + const mode = event.target.value as RecurringCost["splitMode"]; + onChange({ splitMode: mode, splits: splits.map((split, index) => ({ ...split, value: mode === "percent" && index === 0 ? 100 : 0 })) }); + }}> + + + + )} + {splits.map((split, index) => ( +
+ changeSplit(index, { postId: event.target.value })} + > + + {posts.map(post => )} + + {splits.length > 1 && ( + <> + changeSplit(index, { value: Number(event.target.value) || 0 })} /> + {splitMode === "percent" ? "%" : "amount"} + + + + )} +
+ ))} + +
+ ); +} diff --git a/src/ui/routes/settings/RecurringSection.test.ts b/src/ui/routes/settings/RecurringSection.test.tsx similarity index 51% rename from src/ui/routes/settings/RecurringSection.test.ts rename to src/ui/routes/settings/RecurringSection.test.tsx index c276506..3532edc 100644 --- a/src/ui/routes/settings/RecurringSection.test.ts +++ b/src/ui/routes/settings/RecurringSection.test.tsx @@ -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()); + 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()); + 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 diff --git a/src/ui/routes/settings/RecurringSection.tsx b/src/ui/routes/settings/RecurringSection.tsx index b4f8072..8f7108a 100644 --- a/src/ui/routes/settings/RecurringSection.tsx +++ b/src/ui/routes/settings/RecurringSection.tsx @@ -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(null); + const [newSelection, setNewSelection] = useState({ splitMode: "percent", splits: [{ postId: "", value: 100, absorbsRemainder: true }] }); + const [editingId, setEditingId] = useState(null); + const [editSelection, setEditSelection] = useState(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() { - {ordered.map((cost, index) => ( + {ordered.map((cost, index) => ( @@ -310,6 +321,10 @@ export function RecurringSection() {
+ + +
+ + + )} +
))} @@ -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)} /> + setNewAmount(event.currentTarget.value)} /> + setFolderId(event.target.value || null)}> + + {dataset.folders.map(folder => )} + + setDestinationKind(event.target.value as "existing" | "dedicated")}> + + + + {destinationKind === "dedicated" ? ( + setNewPostName(event.target.value)} /> + ) : ( + + )} - {!firstPost && ( - Create a post first. - )} );