feat(purchase): tag a purchase to a recurring cost, or create one from it

Two requests in one control. Tagging an existing cost is the refresh: the data
runs out, the owner enters the purchase, picks the plan, and the money is
booked while the cycle restarts from that date — because the anchor is the
latest charge. The data cap stops needing a flow of its own.

"+ New recurring cost" seeds a cost from the purchase being typed, and that
purchase becomes its first charge, so an anchored cost created this way is
anchored from the moment it exists. Both land in one mutate call, so a failure
cannot leave a cost with no first charge.

A calendar cost is offered only where its grid has a slot in the purchase's
month. sourceFor resolves that slot by LOOKUP (walking occurrencesOf and
matching the purchase's month) rather than by deriving it from monthOf alone —
everyNDays + calendar is a legal combination whose slots are specific days, so
a derived slot could name one that does not exist on the grid.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lucas Winther
2026-09-05 23:32:38 +02:00
co-authored by Claude Opus 5
parent ccde30b8ea
commit 87f309a32c
4 changed files with 437 additions and 7 deletions
+154
View File
@@ -0,0 +1,154 @@
import { describe, expect, test } from "bun:test";
import { offerableCosts, seedCostFromDraft, sourceFor } from "./recurringForm.ts";
import { createSeedDataset } from "../domain/seed.ts";
import * as actions from "../store/actions.ts";
import type { Dataset } from "../domain/types.ts";
function withCosts(): Dataset {
const d = createSeedDataset("2026-09");
const post = d.posts[0]!;
actions.addRecurringCost(d, {
name: "Rent",
archived: false,
amount: { amount: 8200, currency: "DKK" },
startDate: "2026-09",
recurrence: { kind: "everyNMonths", n: 1 },
anchoring: "calendar",
splitMode: "percent",
splits: [{ postId: post.id, value: 100, absorbsRemainder: true }],
});
actions.addRecurringCost(d, {
name: "Phone",
archived: false,
amount: { amount: 300, currency: "DKK" },
startDate: "2026-09-01",
recurrence: { kind: "everyNDays", n: 30 },
anchoring: "lastCharge",
splitMode: "percent",
splits: [{ postId: post.id, value: 100, absorbsRemainder: true }],
});
return d;
}
describe("offerableCosts", () => {
test("an anchored cost is offerable on any date", () => {
const names = offerableCosts(withCosts(), "2027-05-11").map((c) => c.name);
expect(names).toContain("Phone");
});
test("a calendar cost is offerable only when its grid has a slot in that month", () => {
const d = withCosts();
expect(offerableCosts(d, "2026-09-20").map((c) => c.name)).toContain("Rent");
// Before the cost started, its grid has no slot to settle.
expect(offerableCosts(d, "2026-07-20").map((c) => c.name)).not.toContain("Rent");
});
test("an archived cost is not offered", () => {
const d = withCosts();
actions.endRecurringCost(d, d.recurring[0]!.id, "2026-09");
expect(offerableCosts(d, "2026-09-20").map((c) => c.name)).not.toContain("Rent");
});
});
describe("sourceFor", () => {
test("an anchored cost's source carries no slot", () => {
const d = withCosts();
const phone = d.recurring.find((c) => c.name === "Phone")!;
expect(sourceFor(d, phone, "2026-09-20")).toEqual({ recurringId: phone.id });
});
test("a calendar cost's source names the slot in the purchase's own month", () => {
const d = withCosts();
const rent = d.recurring.find((c) => c.name === "Rent")!;
expect(sourceFor(d, rent, "2026-10-03")).toEqual({
recurringId: rent.id,
occurrenceDate: "2026-10",
});
});
test("throws when the cost has no occurrence in the purchase's month", () => {
const d = withCosts();
const rent = d.recurring.find((c) => c.name === "Rent")!;
// Before the cost started: its grid has no slot in July.
expect(() => sourceFor(d, rent, "2026-07-20")).toThrow();
});
// CONTROLLER RULING: everyNDays + calendar is a legal combination whose
// slots are specific DAYS, not months. Deriving the slot from
// monthOf(purchaseDate) alone would name a slot ("2026-09") that does not
// exist on this grid — the real slot is a day like "2026-09-01". Resolving
// by lookup (via occurrencesOf) gets the real slot; deriving it would not.
test("a day-granular calendar cost's source names the REAL slot date, not the bare month", () => {
const d = createSeedDataset("2026-09");
const post = d.posts[0]!;
const cost = actions.addRecurringCost(d, {
name: "Groceries plan",
archived: false,
amount: { amount: 500, currency: "DKK" },
startDate: "2026-09-01",
recurrence: { kind: "everyNDays", n: 30 },
anchoring: "calendar",
splitMode: "percent",
splits: [{ postId: post.id, value: 100, absorbsRemainder: true }],
});
// Occurrences from 2026-09-01 stepping by 30 days: 09-01, then 10-01 —
// only one slot, 2026-09-01, falls in September.
expect(sourceFor(d, cost, "2026-09-20")).toEqual({
recurringId: cost.id,
occurrenceDate: "2026-09-01",
});
});
});
describe("seedCostFromDraft", () => {
test("carries the purchase's amount, currency, splits and date", () => {
const draft = {
date: "2026-09-20",
description: "Gym",
note: "",
amount: 349,
currency: "DKK" as const,
splitMode: "percent" as const,
splits: [{ postId: "p1", value: 100, absorbsRemainder: true }],
plan: null,
};
const seeded = seedCostFromDraft(draft, { kind: "everyNMonths", n: 1 }, "calendar");
expect(seeded.name).toBe("Gym");
expect(seeded.amount).toEqual({ amount: 349, currency: "DKK" });
expect(seeded.splits).toEqual([{ postId: "p1", value: 100, absorbsRemainder: true }]);
expect(seeded.anchoring).toBe("calendar");
});
test("a calendar monthly cost starts in the purchase's MONTH", () => {
const draft = {
date: "2026-09-20",
description: "Rent",
note: "",
amount: 8200,
currency: "DKK" as const,
splitMode: "percent" as const,
splits: [{ postId: "p1", value: 100, absorbsRemainder: true }],
plan: null,
};
expect(
seedCostFromDraft(draft, { kind: "everyNMonths", n: 1 }, "calendar").startDate,
).toBe("2026-09");
});
test("an anchored monthly cost starts on the purchase's DAY", () => {
// Day precision: the cycle runs from the charge, so it needs a real day.
const draft = {
date: "2026-09-20",
description: "Phone",
note: "",
amount: 300,
currency: "DKK" as const,
splitMode: "percent" as const,
splits: [{ postId: "p1", value: 100, absorbsRemainder: true }],
plan: null,
};
expect(
seedCostFromDraft(draft, { kind: "everyNMonths", n: 1 }, "lastCharge").startDate,
).toBe("2026-09-20");
});
});