impl/typescript.ts
3,860 bytes · the TypeScript implementation · view raw
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
import { allocate } from "./money_allocate.ts"; ← from money.allocate ^1.0.0 · built alongside by fune
import { type Money, money } from "./money_amount.ts"; ← from money.amount ^1.0.0 · built alongside by fune
import { splitEven } from "./money_split_even.ts"; ← from money.split-even ^1.0.0 · built alongside by fune
import { sumMoney } from "./money_sum.ts"; ← from money.sum ^1.0.0 · built alongside by fune
import { type BillItem, type DinerShare, type SplitMethod } from "./hospitality_bill_split_types.ts";
/**
* Split a bill between diners so that the shares add up to the bill exactly.
*
* even and by-share divide the whole bill (items plus service) by weight, so
* even totals never differ by more than a penny. by-item gives each diner what
* they ordered, with shared items split evenly between the people who shared
* them. In every method the service charge is then shared in proportion to
* what each diner pays for, so nobody pays service on someone else's steak.
*/
export function billSplit(
items: readonly BillItem[],
serviceCharge: Money,
diners: number,
method: SplitMethod,
shares: readonly number[],
): readonly DinerShare[] {
if (!Number.isInteger(diners) || diners < 1) throw new RangeError(`diners must be at least 1, received ${diners}`);
if (method !== "even" && method !== "by-item" && method !== "by-share") {
throw new RangeError(`unknown split method "${method}"`);
}
if (serviceCharge.minor < 0) throw new RangeError(`serviceCharge must not be negative, received ${serviceCharge.minor}`);
for (const item of items) {
if (item.amount.minor < 0) throw new RangeError(`item amounts must not be negative, received ${item.amount.minor} for "${item.description}"`);
}
const currency = serviceCharge.currency;
const itemsTotal = sumMoney(
items.map((item) => item.amount),
currency,
);
if (method === "by-share") {
if (shares.length !== diners) throw new RangeError(`shares must have one entry per diner: ${diners} diners, ${shares.length} shares`);
for (const s of shares) {
if (!Number.isInteger(s) || s < 0) throw new RangeError(`shares must not be negative, received ${s}`);
}
if (shares.every((s) => s === 0)) throw new RangeError("shares must not all be zero");
} else if (shares.length !== 0) {
throw new RangeError("shares are only used by the by-share method; pass []");
}
let itemShares: number[];
let scShares: number[];
if (method === "by-item") {
itemShares = new Array<number>(diners).fill(0);
for (const item of items) {
const who = item.diners.length === 0 ? [...Array(diners).keys()] : [...item.diners];
for (let i = 0; i < who.length; i++) {
const d = who[i];
if (!Number.isInteger(d) || d < 0 || d >= diners) {
throw new RangeError(`diner ${d} is not at the table: diners are numbered 0 to ${diners - 1}`);
}
if (who.indexOf(d) !== i) throw new RangeError(`item "${item.description}" lists diner ${d} twice`);
}
const parts = splitEven(item.amount, who.length);
who.forEach((d, i) => (itemShares[d] += parts[i].minor));
}
if (itemsTotal.minor === 0) {
if (serviceCharge.minor !== 0) throw new RangeError("a service charge cannot be shared on a bill with no items");
scShares = new Array<number>(diners).fill(0);
} else {
scShares = allocate(serviceCharge, itemShares).map((m) => m.minor);
}
} else {
const weights = method === "even" ? new Array<number>(diners).fill(1) : [...shares];
const grand = money(itemsTotal.minor + serviceCharge.minor, currency);
const totals = allocate(grand, weights).map((m) => m.minor);
scShares = grand.minor === 0 ? new Array<number>(diners).fill(0) : allocate(serviceCharge, totals).map((m) => m.minor);
itemShares = totals.map((t, i) => t - scShares[i]);
}
return itemShares.map((minor, diner) => ({
diner,
items: money(minor, currency),
serviceCharge: money(scShares[diner], currency),
total: money(minor + scShares[diner], currency),
}));
}