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.
This commit is contained in:
@@ -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> = {}): 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);
|
||||
});
|
||||
});
|
||||
@@ -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<PostId, number> {
|
||||
const totals = new Map<PostId, number>();
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user