feat(domain): expected charges, sharing one split implementation
expectedForMonth mirrors chargesForMonth for money that has not moved yet, and
skips confirmed occurrences — those are ordinary purchases that chargesForMonth
has already counted, so counting both would double every settled bill.
The split machinery is extracted rather than duplicated. Two implementations
could round differently or absorb the remainder differently, and the gap would
show up as a penny of drift between the fold's two tracks with no cause the
owner could find. remainderIndexOf and splitPartsOfTotal keep their signatures
as wrappers, so no existing caller changes.
Smaller than planned: 8d2b2ca already made these functions take digits rather
than a currency table, so the shared helper needs no currency plumbing.
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
+123
-24
@@ -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<PostId, number> {
|
||||
const totals = new Map<PostId, number>();
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user