diff --git a/src/ui/components/PlanEditor.tsx b/src/ui/components/PlanEditor.tsx
new file mode 100644
index 0000000..9ef729d
--- /dev/null
+++ b/src/ui/components/PlanEditor.tsx
@@ -0,0 +1,86 @@
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { formatAmount } from "../format.ts";
+import {
+ planBalance,
+ setSliceAmount,
+ withPlan,
+ withoutPlan,
+ type PurchaseDraft,
+} from "../purchaseForm.ts";
+import { monthOf } from "../../domain/months.ts";
+
+interface Props {
+ draft: PurchaseDraft;
+ onChange: (next: PurchaseDraft) => void;
+}
+
+export function PlanEditor({ draft, onChange }: Props) {
+ const balance = planBalance(draft);
+
+ if (!draft.plan) {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+}
diff --git a/src/ui/components/PurchaseDialog.tsx b/src/ui/components/PurchaseDialog.tsx
index ad8e105..8456555 100644
--- a/src/ui/components/PurchaseDialog.tsx
+++ b/src/ui/components/PurchaseDialog.tsx
@@ -11,6 +11,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { SplitEditor } from "./SplitEditor.tsx";
+import { PlanEditor } from "./PlanEditor.tsx";
import {
emptyDraft,
fromPurchase,
@@ -20,7 +21,7 @@ import {
} from "../purchaseForm.ts";
import { useDataset } from "../hooks/useDataset.ts";
import { useMutate } from "../hooks/useMutate.ts";
-import { addPurchase, updatePurchase } from "../../store/actions.ts";
+import { addPurchase, cancelScheduleFrom, updatePurchase } from "../../store/actions.ts";
import { CURRENCIES, type MonthId, type Purchase } from "../../domain/types.ts";
interface Props {
@@ -128,6 +129,22 @@ export function PurchaseDialog({ monthId, purchase, trigger }: Props) {
+
+
+ {purchase?.schedule && !purchase.schedule.cancelledFromMonth && (
+
+ )}
+
{errors.length > 0 && (
{errors.map((message) => (
diff --git a/src/ui/purchaseForm.test.ts b/src/ui/purchaseForm.test.ts
index 9de8107..1053a60 100644
--- a/src/ui/purchaseForm.test.ts
+++ b/src/ui/purchaseForm.test.ts
@@ -5,6 +5,10 @@ import {
validatePurchase,
toPurchase,
fromPurchase,
+ withPlan,
+ withoutPlan,
+ setSliceAmount,
+ planBalance,
type PurchaseDraft,
} from "./purchaseForm.ts";
@@ -114,3 +118,84 @@ describe("toPurchase and fromPurchase", () => {
expect(toPurchase(draft()).schedule).toBeNull();
});
});
+
+describe("finance plans", () => {
+ test("withPlan generates equal slices from the given month", () => {
+ const d = withPlan(draft({ amount: 3000 }), "2026-10", 6);
+ expect(d.plan!.slices).toHaveLength(6);
+ expect(d.plan!.slices[0]).toEqual({
+ month: "2026-10",
+ amount: { amount: 500, currency: "DKK" },
+ });
+ expect(d.plan!.slices[5]!.month).toBe("2027-03");
+ });
+
+ test("withPlan keeps the splits untouched, so plans and splits compose", () => {
+ const split = draft({
+ amount: 3000,
+ splits: [
+ { postId: "games", value: 70, absorbsRemainder: true },
+ { postId: "events", value: 30, absorbsRemainder: false },
+ ],
+ });
+ expect(withPlan(split, "2026-10", 2).splits).toEqual(split.splits);
+ });
+
+ test("withoutPlan clears the schedule", () => {
+ expect(withoutPlan(withPlan(draft(), "2026-10", 3)).plan).toBeNull();
+ });
+
+ test("setSliceAmount edits one slice and leaves the others alone", () => {
+ const d = setSliceAmount(withPlan(draft({ amount: 3000 }), "2026-10", 3), 0, 1500);
+ expect(d.plan!.slices.map((s) => s.amount.amount)).toEqual([1500, 1000, 1000]);
+ });
+
+ test("planBalance reports the difference between the slices and the total", () => {
+ const even = withPlan(draft({ amount: 3000 }), "2026-10", 3);
+ expect(planBalance(even)).toBe(0);
+ expect(planBalance(setSliceAmount(even, 0, 1500))).toBe(-500);
+ });
+
+ test("planBalance is zero when there is no plan", () => {
+ expect(planBalance(draft())).toBe(0);
+ });
+
+ test("a plan with unbalanced slices is still valid", () => {
+ // A deposit-then-instalments plan may legitimately not sum to the total
+ // yet while the user is typing; the editor warns rather than blocking.
+ const d = setSliceAmount(withPlan(draft({ amount: 3000 }), "2026-10", 3), 0, 1500);
+ expect(validatePurchase(d)).toEqual([]);
+ });
+
+ test("a plan with zero months is rejected", () => {
+ expect(() => withPlan(draft(), "2026-10", 0)).toThrow(/at least one month/i);
+ });
+
+ test("split×plan composition: each slice divides across posts in the split ratio", () => {
+ // Pins this task's headline behaviour: plans and splits compose. The
+ // plan editor only ever changes draft.plan; the ratio in draft.splits
+ // is applied per-slice by the store/domain layer, not here — this test
+ // just confirms withPlan never disturbs the splits that make that
+ // composition possible.
+ const composed = withPlan(
+ draft({
+ amount: 3000,
+ splits: [
+ { postId: "games", value: 70, absorbsRemainder: true },
+ { postId: "events", value: 30, absorbsRemainder: false },
+ ],
+ }),
+ "2026-10",
+ 3,
+ );
+ expect(composed.splits).toEqual([
+ { postId: "games", value: 70, absorbsRemainder: true },
+ { postId: "events", value: 30, absorbsRemainder: false },
+ ]);
+ expect(sliceSum(composed)).toBe(3000);
+ });
+});
+
+function sliceSum(draft: PurchaseDraft): number {
+ return draft.plan!.slices.reduce((sum, s) => sum + s.amount.amount, 0);
+}
diff --git a/src/ui/purchaseForm.ts b/src/ui/purchaseForm.ts
index 9013f79..a7388e5 100644
--- a/src/ui/purchaseForm.ts
+++ b/src/ui/purchaseForm.ts
@@ -1,4 +1,5 @@
import { roundMoney } from "../domain/money.ts";
+import { equalSlices, slicesBalance } from "../domain/plans.ts";
import type {
Currency,
IsoDate,
@@ -94,6 +95,51 @@ export function toPurchase(draft: PurchaseDraft): Omit {
};
}
+export function withPlan(
+ draft: PurchaseDraft,
+ startMonth: MonthId,
+ months: number,
+): PurchaseDraft {
+ return {
+ ...draft,
+ plan: {
+ startMonth,
+ slices: equalSlices({ amount: draft.amount, currency: draft.currency }, startMonth, months),
+ },
+ };
+}
+
+export function withoutPlan(draft: PurchaseDraft): PurchaseDraft {
+ return { ...draft, plan: null };
+}
+
+export function setSliceAmount(
+ draft: PurchaseDraft,
+ index: number,
+ amount: number,
+): PurchaseDraft {
+ if (!draft.plan) return draft;
+ return {
+ ...draft,
+ plan: {
+ ...draft.plan,
+ slices: draft.plan.slices.map((slice, i) =>
+ i === index ? { ...slice, amount: { ...slice.amount, amount } } : slice,
+ ),
+ },
+ };
+}
+
+/**
+ * slices - total, delegated to the domain's slicesBalance so the "sum the
+ * slices and compare to the total" arithmetic has exactly one implementation.
+ * Positive means the slices fall short of the purchase total.
+ */
+export function planBalance(draft: PurchaseDraft): number {
+ if (!draft.plan) return 0;
+ return slicesBalance({ amount: draft.amount, currency: draft.currency }, draft.plan.slices);
+}
+
export function fromPurchase(purchase: Purchase): PurchaseDraft {
return {
date: purchase.date,