From 71d69c20b30da756f27d2ad991815f8b526259fc Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Tue, 1 Sep 2026 19:40:32 +0200 Subject: [PATCH] feat(domain): distribute purchase slices across posts Slices are weighted by the whole-purchase split parts, so financed split purchases need no special case and parts always sum to the slice total. --- src/domain/charges.test.ts | 224 +++++++++++++++++++++++++++++++++++++ src/domain/charges.ts | 112 +++++++++++++++++++ 2 files changed, 336 insertions(+) create mode 100644 src/domain/charges.test.ts create mode 100644 src/domain/charges.ts diff --git a/src/domain/charges.test.ts b/src/domain/charges.test.ts new file mode 100644 index 0000000..70ea6d7 --- /dev/null +++ b/src/domain/charges.test.ts @@ -0,0 +1,224 @@ +import { test, expect, describe } from "bun:test"; +import { + splitPartsOfTotal, + sliceAmountForMonth, + chargesForPurchaseInMonth, + chargesForMonth, +} from "./charges.ts"; +import { roundMoney } from "./money.ts"; +import type { Dataset, FxRate, Purchase } from "./types.ts"; + +const rates: FxRate[] = [ + { currency: "EUR", baseUnitsPerOne: 7.46, updatedAt: "2026-09-01", source: "manual" }, + { currency: "USD", baseUnitsPerOne: 6.9, updatedAt: "2026-09-01", source: "manual" }, +]; + +function purchase(overrides: Partial = {}): Purchase { + return { + id: "x1", + date: "2026-09-14", + description: "Groceries", + total: { amount: 200, currency: "DKK" }, + splitMode: "percent", + splits: [{ postId: "food", value: 100, absorbsRemainder: true }], + schedule: null, + ...overrides, + }; +} + +describe("splitPartsOfTotal", () => { + test("a single split takes the whole total", () => { + expect(splitPartsOfTotal(purchase())).toEqual([200]); + }); + + test("percent mode divides proportionally", () => { + const parts = splitPartsOfTotal( + purchase({ + splits: [ + { postId: "food", value: 60, absorbsRemainder: true }, + { postId: "events", value: 40, absorbsRemainder: false }, + ], + }), + ); + expect(parts).toEqual([120, 80]); + }); + + test("fixed mode uses the given amounts, remainder absorbing the shortfall", () => { + const parts = splitPartsOfTotal( + purchase({ + splitMode: "fixed", + splits: [ + { postId: "food", value: 120, absorbsRemainder: false }, + { postId: "events", value: 50, absorbsRemainder: true }, + ], + }), + ); + expect(parts).toEqual([120, 80]); + }); + + test("three-way percent splits sum exactly to the total", () => { + const parts = splitPartsOfTotal( + purchase({ + total: { amount: 100, currency: "DKK" }, + splits: [ + { postId: "a", value: 33.33, absorbsRemainder: false }, + { postId: "b", value: 33.33, absorbsRemainder: false }, + { postId: "c", value: 33.34, absorbsRemainder: true }, + ], + }), + ); + expect(roundMoney(parts.reduce((a, b) => a + b, 0))).toBe(100); + }); +}); + +describe("sliceAmountForMonth", () => { + test("an unscheduled purchase charges its whole total in the month of its date", () => { + expect(sliceAmountForMonth(purchase(), "2026-09")).toEqual({ + amount: 200, + currency: "DKK", + }); + }); + + test("an unscheduled purchase charges nothing in other months", () => { + expect(sliceAmountForMonth(purchase(), "2026-10")).toBeNull(); + }); + + test("a scheduled purchase charges only its slice for that month", () => { + const financed = purchase({ + total: { amount: 3000, currency: "DKK" }, + schedule: { + slices: [ + { month: "2026-10", amount: { amount: 1000, currency: "DKK" } }, + { month: "2026-11", amount: { amount: 500, currency: "DKK" } }, + { month: "2026-12", amount: { amount: 1500, currency: "DKK" } }, + ], + }, + }); + expect(sliceAmountForMonth(financed, "2026-11")).toEqual({ + amount: 500, + currency: "DKK", + }); + expect(sliceAmountForMonth(financed, "2026-09")).toBeNull(); + }); + + test("cancellation drops the cancelled month and every later one", () => { + const cancelled = purchase({ + total: { amount: 3000, currency: "DKK" }, + schedule: { + slices: [ + { month: "2026-10", amount: { amount: 1000, currency: "DKK" } }, + { month: "2026-11", amount: { amount: 1000, currency: "DKK" } }, + { month: "2026-12", amount: { amount: 1000, currency: "DKK" } }, + ], + cancelledFromMonth: "2026-11", + }, + }); + expect(sliceAmountForMonth(cancelled, "2026-10")).not.toBeNull(); + expect(sliceAmountForMonth(cancelled, "2026-11")).toBeNull(); + expect(sliceAmountForMonth(cancelled, "2026-12")).toBeNull(); + }); +}); + +describe("chargesForPurchaseInMonth", () => { + test("a simple purchase charges one post", () => { + expect(chargesForPurchaseInMonth(purchase(), "2026-09", "DKK", rates)).toEqual([ + { postId: "food", amount: 200 }, + ]); + }); + + test("converts a foreign-currency purchase into base", () => { + const eur = purchase({ total: { amount: 10, currency: "EUR" } }); + expect(chargesForPurchaseInMonth(eur, "2026-09", "DKK", rates)).toEqual([ + { postId: "food", amount: 74.6 }, + ]); + }); + + test("a financed split purchase divides each slice by the split ratio", () => { + const financedSplit = purchase({ + total: { amount: 3000, currency: "DKK" }, + splits: [ + { postId: "games", value: 70, absorbsRemainder: true }, + { postId: "events", value: 30, absorbsRemainder: false }, + ], + schedule: { + slices: [ + { month: "2026-10", amount: { amount: 1000, currency: "DKK" } }, + { month: "2026-11", amount: { amount: 2000, currency: "DKK" } }, + ], + }, + }); + expect(chargesForPurchaseInMonth(financedSplit, "2026-10", "DKK", rates)).toEqual([ + { postId: "games", amount: 700 }, + { postId: "events", amount: 300 }, + ]); + expect(chargesForPurchaseInMonth(financedSplit, "2026-11", "DKK", rates)).toEqual([ + { postId: "games", amount: 1400 }, + { postId: "events", amount: 600 }, + ]); + }); + + test("a financed fixed-mode split scales the fixed amounts down per slice", () => { + const financed = purchase({ + total: { amount: 3000, currency: "DKK" }, + splitMode: "fixed", + splits: [ + { postId: "games", value: 2000, absorbsRemainder: true }, + { postId: "events", value: 1000, absorbsRemainder: false }, + ], + schedule: { + slices: [{ month: "2026-10", amount: { amount: 600, currency: "DKK" } }], + }, + }); + expect(chargesForPurchaseInMonth(financed, "2026-10", "DKK", rates)).toEqual([ + { postId: "games", amount: 400 }, + { postId: "events", amount: 200 }, + ]); + }); + + test("charges in a month the purchase does not touch are empty", () => { + expect(chargesForPurchaseInMonth(purchase(), "2026-01", "DKK", rates)).toEqual([]); + }); + + test("slice charges always sum to the slice total", () => { + const odd = purchase({ + total: { amount: 100, currency: "DKK" }, + splits: [ + { postId: "a", value: 1, absorbsRemainder: false }, + { postId: "b", value: 1, absorbsRemainder: false }, + { postId: "c", value: 1, absorbsRemainder: true }, + ], + }); + const charges = chargesForPurchaseInMonth(odd, "2026-09", "DKK", rates); + expect(roundMoney(charges.reduce((sum, c) => sum + c.amount, 0))).toBe(100); + }); +}); + +describe("chargesForMonth", () => { + test("sums charges per post across all purchases", () => { + const data: Dataset = { + settings: { baseCurrency: "DKK", foldStartMonth: "2026-01", schemaVersion: 1 }, + fxRates: rates, + posts: [], + months: [], + purchases: [ + purchase({ id: "a" }), + purchase({ id: "b", total: { amount: 50, currency: "DKK" } }), + purchase({ id: "c", date: "2026-10-01" }), + ], + }; + const charges = chargesForMonth(data, "2026-09"); + expect(charges.get("food")).toBe(250); + expect(charges.size).toBe(1); + }); + + test("a month with no purchases yields an empty map", () => { + const data: Dataset = { + settings: { baseCurrency: "DKK", foldStartMonth: "2026-01", schemaVersion: 1 }, + fxRates: rates, + posts: [], + months: [], + purchases: [], + }; + expect(chargesForMonth(data, "2026-09").size).toBe(0); + }); +}); diff --git a/src/domain/charges.ts b/src/domain/charges.ts new file mode 100644 index 0000000..4d726ed --- /dev/null +++ b/src/domain/charges.ts @@ -0,0 +1,112 @@ +import { toBase } from "./fx.ts"; +import { distributeByAmount, distributeByWeight, roundMoney } from "./money.ts"; +import { compareMonths, monthOf } from "./months.ts"; +import type { + Currency, + Dataset, + FxRate, + Money, + MonthId, + PostId, + Purchase, +} from "./types.ts"; + +export interface Charge { + postId: PostId; + /** Base currency. */ + amount: number; +} + +export function remainderIndexOf(purchase: Purchase): number { + const index = purchase.splits.findIndex((s) => s.absorbsRemainder); + if (index === -1) { + throw new Error( + `Purchase ${purchase.id} has no split flagged absorbsRemainder`, + ); + } + return index; +} + +/** + * 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): number[] { + const index = remainderIndexOf(purchase); + const values = purchase.splits.map((s) => s.value); + const total = purchase.total.amount; + const currency = purchase.total.currency; + return purchase.splitMode === "percent" + ? distributeByWeight(total, values, index, currency) + : distributeByAmount(total, values, index, currency); +} + +/** + * The amount charged in `monthId`, in the purchase's currency, or null if the + * purchase does not touch that month. + */ +export function sliceAmountForMonth( + purchase: Purchase, + monthId: MonthId, +): Money | null { + if (!purchase.schedule) { + return monthOf(purchase.date) === monthId ? purchase.total : null; + } + const { slices, cancelledFromMonth } = purchase.schedule; + if (cancelledFromMonth && compareMonths(monthId, cancelledFromMonth) >= 0) { + return null; + } + const slice = slices.find((s) => s.month === monthId); + return slice ? slice.amount : null; +} + +/** + * Distributes this month's slice across the purchase's posts, in base currency. + * + * Weights are the whole-purchase split parts, so a financed purchase divides + * each slice in the same proportion as the purchase as a whole — which is what + * makes splits and finance plans compose without a special case. + */ +export function chargesForPurchaseInMonth( + purchase: Purchase, + monthId: MonthId, + baseCurrency: Currency, + rates: FxRate[], +): Charge[] { + const slice = sliceAmountForMonth(purchase, monthId); + if (!slice) return []; + + const sliceBase = toBase(slice, baseCurrency, rates); + const index = remainderIndexOf(purchase); + const weights = splitPartsOfTotal(purchase); + const parts = distributeByWeight(sliceBase, weights, index, baseCurrency); + + return purchase.splits.map((split, i) => ({ + postId: split.postId, + amount: parts[i]!, + })); +} + +/** Total charged per post in `monthId`, in base currency. */ +export function chargesForMonth( + dataset: Dataset, + monthId: MonthId, +): Map { + const totals = new Map(); + for (const purchase of dataset.purchases) { + const charges = chargesForPurchaseInMonth( + purchase, + monthId, + dataset.settings.baseCurrency, + dataset.fxRates, + ); + for (const charge of charges) { + const previous = totals.get(charge.postId) ?? 0; + totals.set( + charge.postId, + roundMoney(previous + charge.amount, dataset.settings.baseCurrency), + ); + } + } + return totals; +}