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:
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user