diff --git a/src/domain/charges.test.ts b/src/domain/charges.test.ts index 26e9589..c7e91c0 100644 --- a/src/domain/charges.test.ts +++ b/src/domain/charges.test.ts @@ -4,9 +4,11 @@ import { sliceAmountForMonth, chargesForPurchaseInMonth, chargesForMonth, + expectedForMonth, } from "./charges.ts"; import { roundMoney } from "./money.ts"; -import type { Dataset, FxRate, Purchase } from "./types.ts"; +import { occurrencesByMonth } from "./occurrences.ts"; +import type { Dataset, FxRate, Purchase, RecurringCost } from "./types.ts"; /** Digits for the currencies these tests use; all real-world 2dp. */ const TEST_CURRENCIES = [ @@ -239,3 +241,95 @@ describe("chargesForMonth", () => { expect(chargesForMonth(data, "2026-09").size).toBe(0); }); }); + +describe("expectedForMonth", () => { + function dataset(cost: RecurringCost, purchases: Purchase[] = []): Dataset { + return { + settings: { baseCurrency: "DKK", foldStartMonth: "2026-01", schemaVersion: 7, digits: 2 }, + currencies: [ + { code: "DKK", symbol: "kr", name: "Danish krone" }, + { code: "USD", symbol: "$", name: "US dollar" }, + ], + fxRates: [{ currency: "USD", baseUnitsPerOne: 7, updatedAt: "2026-01-01", source: "manual" }], + posts: [], + months: [], + purchases, + recurring: [cost], + }; + } + + const rent: RecurringCost = { + id: "r1", + name: "Rent", + order: 0, + archived: false, + amount: { amount: 8000, currency: "DKK" }, + startDate: "2026-01", + recurrence: { kind: "everyNMonths", n: 1 }, + anchoring: "calendar", + splitMode: "percent", + splits: [{ postId: "housing", value: 100, absorbsRemainder: true }], + }; + + test("an unconfirmed occurrence charges its post", () => { + const data = dataset(rent); + const expected = expectedForMonth(data, occurrencesByMonth(data, "2026-01").get("2026-01")!); + expect(expected.get("housing")).toBe(8000); + }); + + test("a CONFIRMED occurrence contributes nothing — it is a real purchase now", () => { + const confirmed: Purchase = { + id: "p1", + date: "2026-01", + description: "Rent", + total: { amount: 8000, currency: "DKK" }, + splitMode: "percent", + splits: [{ postId: "housing", value: 100, absorbsRemainder: true }], + schedule: null, + source: { recurringId: "r1", occurrenceDate: "2026-01" }, + }; + const data = dataset(rent, [confirmed]); + const expected = expectedForMonth(data, occurrencesByMonth(data, "2026-01").get("2026-01")!); + expect(expected.get("housing")).toBeUndefined(); + }); + + test("a split cost divides exactly, with the remainder absorbed", () => { + const split = { + ...rent, + amount: { amount: 1000, currency: "DKK" }, + splits: [ + { postId: "a", value: 33.333, absorbsRemainder: false }, + { postId: "b", value: 33.333, absorbsRemainder: false }, + { postId: "c", value: 33.334, absorbsRemainder: true }, + ], + }; + const data = dataset(split); + const expected = expectedForMonth(data, occurrencesByMonth(data, "2026-01").get("2026-01")!); + const total = ["a", "b", "c"].reduce((sum, id) => sum + expected.get(id)!, 0); + expect(total).toBe(1000); + }); + + test("a foreign-currency cost converts to base", () => { + const inUsd = { ...rent, amount: { amount: 15, currency: "USD" } }; + const data = dataset(inUsd); + const expected = expectedForMonth(data, occurrencesByMonth(data, "2026-01").get("2026-01")!); + expect(expected.get("housing")).toBe(105); + }); + + test("two occurrences in one month sum", () => { + const fortnightly = { + ...rent, + amount: { amount: 100, currency: "DKK" }, + startDate: "2026-01-05", + recurrence: { kind: "everyNDays" as const, n: 14 }, + }; + const data = dataset(fortnightly); + const january = occurrencesByMonth(data, "2026-01").get("2026-01")!; + expect(january.length).toBe(2); + expect(expectedForMonth(data, january).get("housing")).toBe(200); + }); + + test("no occurrences means an empty map, not zeros", () => { + expect(expectedForMonth(dataset(rent), []).size).toBe(0); + }); +}); diff --git a/src/domain/charges.ts b/src/domain/charges.ts index de00426..25406b3 100644 --- a/src/domain/charges.ts +++ b/src/domain/charges.ts @@ -1,6 +1,7 @@ import { toBase } from "./fx.ts"; import { distributeByAmount, distributeByWeight, roundMoney } from "./money.ts"; import { compareMonths, monthOf } from "./months.ts"; +import type { Occurrence } from "./occurrences.ts"; import type { Currency, Dataset, @@ -9,6 +10,7 @@ import type { MonthId, PostId, Purchase, + Split, } from "./types.ts"; export interface Charge { @@ -17,27 +19,75 @@ export interface Charge { amount: number; } -export function remainderIndexOf(purchase: Purchase): number { - const index = purchase.splits.findIndex((s) => s.absorbsRemainder); +/** + * Exactly one split absorbs the remainder, which is what makes the parts sum + * exactly to the total. `label` names the owning record so the message is + * actionable — this is reachable from a hand-edited import. + */ +export function remainderIndexIn(splits: readonly Split[], label: string): number { + const index = splits.findIndex((s) => s.absorbsRemainder); if (index === -1) { - throw new Error( - `Purchase ${purchase.id} has no split flagged absorbsRemainder`, - ); + throw new Error(`${label} has no split flagged absorbsRemainder`); } return index; } +export function remainderIndexOf(purchase: Purchase): number { + return remainderIndexIn(purchase.splits, `Purchase ${purchase.id}`); +} + +/** + * How an amount divides across splits, in the currency the amount is in. + * Parts sum exactly to the total. + */ +export function splitParts( + total: number, + splits: readonly Split[], + splitMode: "percent" | "fixed", + remainderIndex: number, + digits: number, +): number[] { + const values = splits.map((s) => s.value); + return splitMode === "percent" + ? distributeByWeight(total, values, remainderIndex, digits) + : distributeByAmount(total, values, remainderIndex, digits); +} + /** * How the whole purchase divides across its posts, in the purchase's own * currency. Parts sum exactly to the total. */ export function splitPartsOfTotal(purchase: Purchase, digits: number): number[] { - const index = remainderIndexOf(purchase); - const values = purchase.splits.map((s) => s.value); - const total = purchase.total.amount; - return purchase.splitMode === "percent" - ? distributeByWeight(total, values, index, digits) - : distributeByAmount(total, values, index, digits); + return splitParts( + purchase.total.amount, + purchase.splits, + purchase.splitMode, + remainderIndexOf(purchase), + digits, + ); +} + +/** + * Converts `amount` to base and distributes it across `splits` in the + * proportions `weights` describes. + * + * Shared by real charges and expected ones deliberately. Two implementations + * could round differently or absorb the remainder differently, and the gap + * would surface as a penny of phantom drift between the fold's two tracks — + * a difference with no cause the owner could ever find. + */ +export function distributeToSplits( + amount: Money, + splits: readonly Split[], + weights: number[], + remainderIndex: number, + baseCurrency: Currency, + rates: FxRate[], + digits: number, +): Charge[] { + const base = toBase(amount, baseCurrency, rates, digits); + const parts = distributeByWeight(base, weights, remainderIndex, digits); + return splits.map((split, i) => ({ postId: split.postId, amount: parts[i]! })); } /** @@ -76,19 +126,18 @@ export function chargesForPurchaseInMonth( const slice = sliceAmountForMonth(purchase, monthId); if (!slice) return []; - const sliceBase = toBase(slice, baseCurrency, rates, digits); - const index = remainderIndexOf(purchase); - // Split proportions are computed in the PURCHASE's own currency, then the - // month's slice is distributed to those proportions in the BASE currency. - // One setting covers both roundings now; it used to be two lookups, one per - // currency, which is the only thing that changed here. - const weights = splitPartsOfTotal(purchase, digits); - const parts = distributeByWeight(sliceBase, weights, index, digits); - - return purchase.splits.map((split, i) => ({ - postId: split.postId, - amount: parts[i]!, - })); + // Split proportions come from the WHOLE purchase, so a financed purchase + // divides each slice in the same ratio as the purchase as a whole — which is + // what makes splits and finance plans compose without a special case. + return distributeToSplits( + slice, + purchase.splits, + splitPartsOfTotal(purchase, digits), + remainderIndexOf(purchase), + baseCurrency, + rates, + digits, + ); } /** Total charged per post in `monthId`, in base currency. */ @@ -116,3 +165,53 @@ export function chargesForMonth( } return totals; } + +/** + * Total EXPECTED per post in one month, in base currency. + * + * The mirror of `chargesForMonth` for money that has not moved yet. Confirmed + * occurrences are skipped: they are ordinary purchases and `chargesForMonth` + * has already counted them. Counting both would double every settled bill. + * + * Takes the month's occurrences rather than a MonthId because the fold + * computes them all in one walk — see `occurrencesByMonth`. + */ +export function expectedForMonth( + dataset: Dataset, + occurrences: readonly Occurrence[], +): Map { + const totals = new Map(); + const digits = dataset.settings.digits; + const byId = new Map(dataset.recurring.map((cost) => [cost.id, cost])); + + for (const occurrence of occurrences) { + if (occurrence.confirmedBy) continue; + const cost = byId.get(occurrence.recurringId); + if (!cost) continue; + + const index = remainderIndexIn(cost.splits, `Recurring cost "${cost.name}"`); + const weights = splitParts( + cost.amount.amount, + cost.splits, + cost.splitMode, + index, + digits, + ); + const charges = distributeToSplits( + occurrence.amount, + cost.splits, + weights, + index, + dataset.settings.baseCurrency, + dataset.fxRates, + digits, + ); + + for (const charge of charges) { + const previous = totals.get(charge.postId) ?? 0; + totals.set(charge.postId, roundMoney(previous + charge.amount, digits)); + } + } + + return totals; +}