diff --git a/src/domain/fold.test.ts b/src/domain/fold.test.ts index d3f11fb..1426888 100644 --- a/src/domain/fold.test.ts +++ b/src/domain/fold.test.ts @@ -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); + } + }); +}); diff --git a/src/domain/types.ts b/src/domain/types.ts index 1b0c281..9dbf687 100755 --- a/src/domain/types.ts +++ b/src/domain/types.ts @@ -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;