test(domain): fold coverage for the anchored model, plus a startDate doc fix

fold.test.ts had no case exercising foldBalances/figuresFor against a
lastCharge cost at all. Adds: three same-cycle charges landing in one month
count fully as ACTUAL charges with nothing left EXPECTED in that month (an
anchored occurrence is only ever a future projection, never a settled slot);
and a no-recurring-costs dataset keeps projected == remaining, pinned
separately from the calendar-shaped fixture already covering that shape.

RecurringCost.startDate's doc comment still said "YYYY-MM for everyNMonths,
YYYY-MM-DD otherwise" — no longer true now that an anchored everyNMonths
cost needs a real day too. Rewritten to name the actual rule and point at
where both boundaries enforce it.
This commit is contained in:
Lucas Winther
2026-09-05 22:42:35 +02:00
parent 8d8407becf
commit 77bd049fb9
2 changed files with 83 additions and 1 deletions
+74
View File
@@ -285,3 +285,77 @@ describe("the projected track", () => {
expect(EMPTY_FIGURES.projectedCarriedIn).toBe(0);
});
});
/**
* A `lastCharge` cost with no recurring charges yet: `foldBalances` sees only
* `startDate` and no confirmations. Kept separate from `datasetWithRent`
* (which is `calendar`) so an anchored fixture never accidentally inherits a
* grid that anchoring does not have.
*/
function datasetWithAnchoredPlan(): Dataset {
return {
settings: { baseCurrency: "DKK", foldStartMonth: "2026-01", schemaVersion: 8, digits: 2 },
currencies: [{ code: "DKK", symbol: "kr", name: "Danish krone" }],
fxRates: [],
posts: [
{
id: "phone",
name: "Phone",
order: 0,
archived: false,
currency: "DKK",
rules: [{ from: "2026-01", rule: { kind: "fixed", amount: { amount: 10000, currency: "DKK" } } }],
},
],
months: [],
purchases: [],
recurring: [
{
id: "plan",
name: "Phone plan",
order: 0,
archived: false,
amount: { amount: 300, currency: "DKK" },
startDate: "2026-01-01",
recurrence: { kind: "everyNDays", n: 30 },
anchoring: "lastCharge",
splitMode: "percent",
splits: [{ postId: "phone", value: 100, absorbsRemainder: true }],
},
],
};
}
describe("the anchored (lastCharge) track", () => {
test("three charges in one month are all ACTUAL, with NO expectation left in it", () => {
// The phone-bill case: hitting the data cap three times in January means
// three real purchases, each restarting the cycle. None of them is a
// "confirmation" of anything — an anchored cost has no slot to confirm —
// so every occurrence the walk still emits is a genuinely FUTURE one, and
// none of it should land back in January alongside the charges that
// already happened there.
const data = datasetWithAnchoredPlan();
data.purchases.push(
{ id: "p1", date: "2026-01-05", description: "Phone plan", total: { amount: 300, currency: "DKK" }, splitMode: "percent", splits: [{ postId: "phone", value: 100, absorbsRemainder: true }], schedule: null, source: { recurringId: "plan" } },
{ id: "p2", date: "2026-01-15", description: "Phone plan", total: { amount: 300, currency: "DKK" }, splitMode: "percent", splits: [{ postId: "phone", value: 100, absorbsRemainder: true }], schedule: null, source: { recurringId: "plan" } },
{ id: "p3", date: "2026-01-25", description: "Phone plan", total: { amount: 300, currency: "DKK" }, splitMode: "percent", splits: [{ postId: "phone", value: 100, absorbsRemainder: true }], schedule: null, source: { recurringId: "plan" } },
);
const january = figuresFor(foldBalances(data, "2026-01"), "phone", "2026-01");
expect(january.charges).toBe(900); // 3 x 300, all real money already moved
expect(january.expected).toBe(0); // nothing left uncharged in January itself
});
test("with no recurring costs, projected still equals remaining", () => {
// The anchored model adds a second walk (`anchoredOccurrences`), but a
// dataset that never defines a recurring cost must never see it: both
// walks are keyed off `dataset.recurring`, which is empty here.
const data = dataset([post("food", 500)], [spend("a", "food", 300, "2026-01-05")]);
const fold = foldBalances(data, "2026-03");
for (const month of ["2026-01", "2026-02", "2026-03"]) {
const figures = figuresFor(fold, "food", month);
expect(figures.expected).toBe(0);
expect(figures.projected).toBe(figures.remaining);
}
});
});
+9 -1
View File
@@ -153,7 +153,15 @@ export interface RecurringCost {
* future occurrences move.
*/
amount: Money;
/** The first occurrence. "YYYY-MM" for everyNMonths, "YYYY-MM-DD" otherwise. */
/**
* The first occurrence. "YYYY-MM-DD" always, except `everyNMonths` under
* `calendar` anchoring, where "YYYY-MM" is also allowed — that grid is
* month-granular, but every other combination needs a real day: `everyNDays`
* and `everyNWeeks` hand this straight to `addDays`, and `everyNMonths`
* under `lastCharge` steps day-precisely from a real charge date. Both write
* boundaries (`requireStartDateGranularity` in `store/actions.ts`, the
* mirrored check in `export/json.ts`) enforce this pairing.
*/
startDate: IsoDate;
/** Occurrences on or after this date are not projected. This is cancellation. */
endedFrom?: IsoDate;