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:
co-authored by
Claude Opus 5
parent
ccde30b8ea
commit
87f309a32c
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user