feat(types): recurring costs in the dataset, schema 7
Recurrence is two orthogonal fields, not a list of special cases: a kind (everyNMonths / everyNDays / everyNWeeks) and an anchoring (calendar / lastCharge). The phone bill that recharges early when its data cap is hit is everyNDays(30) anchored to lastCharge — the domain never learns what a data cap is, because confirming the occurrence early already records the date. Purchase.source pairs occurrenceDate (identity: which slot this claims) with Purchase.date (truth: when the money moved). They differ whenever a bill is paid off schedule, and lastCharge measures from the latter. RecurringCost.amount is a single mutable Money with no RuleVersion-style series. Safe only because confirmed purchases hold what was actually paid; under projection-only it would destroy history. The 6 -> 7 step writes an empty array and reads nothing. Decimals took 6.
This commit is contained in:
1 parent
de333685a2
commit
a456aadf35
9 files changed
+156
-1
No files matched your search
@@ -53,6 +53,7 @@ function dataset(overrides: Partial<Dataset> = {}): Dataset {
|
||||
{ id: "2026-01", income: { amount: 20000, currency: "DKK" }, ruleOverrides: {} },
|
||||
],
|
||||
purchases: [],
|
||||
recurring: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -204,6 +205,7 @@ describe("the fold across a rule change", () => {
|
||||
ruleOverrides: {},
|
||||
})),
|
||||
purchases: [],
|
||||
recurring: [],
|
||||
};
|
||||
|
||||
test("the allocation steps up in the version's month", () => {
|
||||
|
||||
@@ -219,6 +219,7 @@ describe("chargesForMonth", () => {
|
||||
purchase({ id: "b", total: { amount: 50, currency: "DKK" } }),
|
||||
purchase({ id: "c", date: "2026-10-01" }),
|
||||
],
|
||||
recurring: [],
|
||||
};
|
||||
const charges = chargesForMonth(data, "2026-09");
|
||||
expect(charges.get("food")).toBe(250);
|
||||
@@ -233,6 +234,7 @@ describe("chargesForMonth", () => {
|
||||
posts: [],
|
||||
months: [],
|
||||
purchases: [],
|
||||
recurring: [],
|
||||
};
|
||||
expect(chargesForMonth(data, "2026-09").size).toBe(0);
|
||||
});
|
||||
|
||||
@@ -109,6 +109,7 @@ describe("the fold at zero decimals", () => {
|
||||
{ id: "2026-02", income: { amount: 10_000, currency: "JPY" }, ruleOverrides: {} },
|
||||
],
|
||||
purchases: [],
|
||||
recurring: [],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ function dataset(posts: Post[], purchases: Purchase[]): Dataset {
|
||||
{ id: "2026-03", income: { amount: 10000, currency: "DKK" }, ruleOverrides: {} },
|
||||
],
|
||||
purchases,
|
||||
recurring: [],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+5
-1
@@ -1,7 +1,7 @@
|
||||
import { DEFAULT_DIGITS, SEED_CURRENCIES } from "./types.ts";
|
||||
import type { CurrencyDef, Dataset, FxRate, MonthId, Post } from "./types.ts";
|
||||
|
||||
export const SCHEMA_VERSION = 6;
|
||||
export const SCHEMA_VERSION = 7;
|
||||
|
||||
const SEED_POST_NAMES = ["Video Games", "Food", "Events and Social"] as const;
|
||||
|
||||
@@ -47,5 +47,9 @@ export function createSeedDataset(
|
||||
{ id: startMonth, income: { amount: 0, currency: "DKK" }, ruleOverrides: {} },
|
||||
],
|
||||
purchases: [],
|
||||
// A new dataset seeds no recurring costs. Unlike currencies and FX rates,
|
||||
// there is no sensible default bill — the app cannot guess what the owner
|
||||
// pays.
|
||||
recurring: [],
|
||||
};
|
||||
}
|
||||
@@ -105,6 +105,65 @@ export interface Schedule {
|
||||
cancelledFromMonth?: MonthId;
|
||||
}
|
||||
|
||||
export type RecurringCostId = string;
|
||||
|
||||
/**
|
||||
* How occurrence dates are generated.
|
||||
*
|
||||
* Extensible on purpose: a new variant here plus one case in `stepFrom`
|
||||
* (`occurrences.ts`) is the whole change — nothing else in the app switches on
|
||||
* this. `n` must be an integer >= 1 in every variant, which is what makes the
|
||||
* projection walk terminate; both write boundaries enforce it.
|
||||
*/
|
||||
export type Recurrence =
|
||||
| { kind: "everyNMonths"; n: number }
|
||||
| { kind: "everyNDays"; n: number }
|
||||
/** `weekday` is 0 = Sunday, matching `weekdayOf` in `days.ts`. */
|
||||
| { kind: "everyNWeeks"; n: number; weekday: number };
|
||||
|
||||
/**
|
||||
* Where the NEXT occurrence is measured from.
|
||||
*
|
||||
* `calendar` — from the previous slot, wherever it fell. Rent, and a
|
||||
* subscription that bills on its own schedule whatever you do.
|
||||
*
|
||||
* `lastCharge` — from the date the previous charge ACTUALLY happened. This is
|
||||
* the phone bill: hitting the data cap makes a charge happen early, and the
|
||||
* next 30 days run from there. The domain never learns what a data cap is;
|
||||
* confirming the occurrence early is the whole mechanism.
|
||||
*/
|
||||
export type Anchoring = "calendar" | "lastCharge";
|
||||
|
||||
export interface RecurringCost {
|
||||
id: RecurringCostId;
|
||||
name: string;
|
||||
order: number;
|
||||
/**
|
||||
* A UI flag ONLY. Projection is governed by `startDate` and `endedFrom`,
|
||||
* exactly as an archived post still folds — archiving must not retroactively
|
||||
* remove past expected charges, because that would move a historical figure.
|
||||
*/
|
||||
archived: boolean;
|
||||
/**
|
||||
* Expected amount, in its own currency, converted to base at fold time.
|
||||
*
|
||||
* A single mutable value with no dated series, unlike `Post.rules`. A rent
|
||||
* increase edits it, and history survives because every past occurrence is
|
||||
* a confirmed `Purchase` holding what was actually paid. Only unconfirmed
|
||||
* future occurrences move.
|
||||
*/
|
||||
amount: Money;
|
||||
/** The first occurrence. "YYYY-MM" for everyNMonths, "YYYY-MM-DD" otherwise. */
|
||||
startDate: IsoDate;
|
||||
/** Occurrences on or after this date are not projected. This is cancellation. */
|
||||
endedFrom?: IsoDate;
|
||||
recurrence: Recurrence;
|
||||
anchoring: Anchoring;
|
||||
/** Same shape and same invariant as Purchase: exactly one absorbsRemainder. */
|
||||
splitMode: "percent" | "fixed";
|
||||
splits: Split[];
|
||||
}
|
||||
|
||||
export interface Purchase {
|
||||
id: PurchaseId;
|
||||
date: IsoDate;
|
||||
@@ -117,6 +176,16 @@ export interface Purchase {
|
||||
splits: Split[];
|
||||
/** null means the whole total is charged in the month of `date`. */
|
||||
schedule: Schedule | null;
|
||||
/**
|
||||
* Present when this purchase confirms a projected occurrence.
|
||||
*
|
||||
* `occurrenceDate` is IDENTITY — the slot the projector generated, which
|
||||
* this purchase claims so the slot stops being projected. `Purchase.date` is
|
||||
* TRUTH — when the money moved. They differ whenever a bill is paid off
|
||||
* schedule, and under `lastCharge` anchoring it is `date`, not
|
||||
* `occurrenceDate`, that the next occurrence is measured from.
|
||||
*/
|
||||
source?: { recurringId: RecurringCostId; occurrenceDate: IsoDate };
|
||||
}
|
||||
|
||||
export interface FxRate {
|
||||
@@ -155,4 +224,10 @@ export interface Dataset {
|
||||
posts: Post[];
|
||||
months: Month[];
|
||||
purchases: Purchase[];
|
||||
/**
|
||||
* Bills that repeat. Their occurrences are COMPUTED on every fold and never
|
||||
* stored — the original design rejected materialised snapshots, and an
|
||||
* unbounded recurrence would otherwise need an arbitrary horizon.
|
||||
*/
|
||||
recurring: RecurringCost[];
|
||||
}
|
||||
@@ -49,6 +49,7 @@ const data: Dataset = {
|
||||
},
|
||||
],
|
||||
purchases: [spend("a", "food", 500, "2026-01-10"), spend("b", "games", 300, "2026-02-10")],
|
||||
recurring: [],
|
||||
};
|
||||
|
||||
describe("monthView", () => {
|
||||
|
||||
Reference in new issue
Block a user