Source
This app’s code and revision history are public. Download a copy or explore the files below.
Public
The app’s code and revision history are public.
Run it yourself
Take the whole tree and run it on your own machine. The download needs no account. Cloning needs a sign-in.
Download .zip
bounded clone 20ace674a35b849ffda63b30
A copy you download is not bounded. The guarantees this app runs under - declared egress, proven invariants, writes that fail closed - live in the runtime serving this page, not in the files. What you get is auditable source you can read and run, with no runtime guarantees attached.
functions/lib/split.ts
// Split's money math, shared by the web app and the public API.
// Amounts are integers in the currency's minor unit (cents for USD, yen for JPY),
// so every split adds up exactly and balances always sum to zero.
export type SplitMode = "equal" | "shares" | "exact";
export type Kind = "expense" | "payment";
export interface Person { id: string; name: string; removed?: boolean | null }
export interface Expense {
id: string;
title: string;
amount: number;
paidBy: string;
splitMode: SplitMode;
/** JSON object of personId -> weight (equal: 1 each, shares: weight, exact: minor units). */
shares: string;
kind: Kind;
date: number;
/** When it was added (server seconds): orders expenses on the same day, newest first. */
createdAt?: number;
deleted?: boolean | null;
}
/** Newest first: by date, then by when it was added. */
export const newestFirst = (a: Expense, b: Expense) => b.date - a.date || (Number(b.createdAt) || 0) - (Number(a.createdAt) || 0);
export interface Transfer { from: string; to: string; amount: number }
export const CURRENCIES = [
"USD", "EUR", "GBP", "CAD", "AUD", "NZD", "JPY", "CHF", "SEK", "NOK", "DKK", "PLN", "CZK", "HUF",
"INR", "SGD", "HKD", "CNY", "KRW", "MXN", "BRL", "ARS", "CLP", "COP", "ZAR", "AED", "ILS", "TRY", "THB", "IDR", "PHP", "VND",
];
export function minorDigits(currency: string): number {
try {
return new Intl.NumberFormat("en", { style: "currency", currency }).resolvedOptions().maximumFractionDigits ?? 2;
} catch {
return 2;
}
}
/** "12.5" or 12.5 in USD -> 1250. Returns NaN for anything that is not a positive amount. */
export function toMinor(value: string | number, currency: string): number {
const digits = minorDigits(currency);
let text = String(value).trim().replace(/\s/g, "");
// A comma before the last cents digits is a decimal comma ("12,50"), which phone keypads type in much of Europe.
const comma = text.lastIndexOf(",");
const after = text.length - comma - 1;
if (digits && comma > text.lastIndexOf(".") && after >= 1 && after <= digits) text = `${text.slice(0, comma).replace(/\./g, "")}.${text.slice(comma + 1)}`;
text = text.replace(/,/g, "");
if (!/^\d+(\.\d+)?$/.test(text)) return NaN;
const [whole, frac = ""] = text.split(".");
if (frac.length > digits) return NaN;
return Number(whole) * 10 ** digits + Number((frac + "0".repeat(digits)).slice(0, digits) || 0);
}
export function fromMinor(minor: number, currency: string): string {
const digits = minorDigits(currency);
const sign = minor < 0 ? "-" : "";
const abs = Math.abs(minor);
if (digits === 0) return `${sign}${abs}`;
const s = String(abs).padStart(digits + 1, "0");
return `${sign}${s.slice(0, -digits)}.${s.slice(-digits)}`;
}
export function formatMoney(minor: number, currency: string, locale?: string): string {
try {
return new Intl.NumberFormat(locale, { style: "currency", currency }).format(minor / 10 ** minorDigits(currency));
} catch {
return `${fromMinor(minor, currency)} ${currency}`;
}
}
export function parseShares(raw: string): Record<string, number> {
try {
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
const out: Record<string, number> = {};
for (const [k, v] of Object.entries(parsed)) {
if (typeof v === "number" && Number.isFinite(v) && v >= 0) out[k] = v;
}
return out;
} catch {
return {};
}
}
/**
* What each person owes for one expense, in minor units. The parts always add up to the
* amount: leftover units from rounding go to the largest remainders, ties broken by id.
*/
export function owedBy(e: Pick<Expense, "amount" | "splitMode" | "shares">): Record<string, number> {
const shares = parseShares(e.shares);
const ids = Object.keys(shares).filter((id) => shares[id] > 0).sort();
if (!ids.length || e.amount <= 0) return {};
if (e.splitMode === "exact") {
const out: Record<string, number> = {};
for (const id of ids) out[id] = Math.round(shares[id]);
return out;
}
const weights = ids.map((id) => (e.splitMode === "equal" ? 1 : shares[id]));
const total = weights.reduce((a, b) => a + b, 0);
const raw = weights.map((w) => (e.amount * w) / total);
const floors = raw.map(Math.floor);
let left = e.amount - floors.reduce((a, b) => a + b, 0);
const order = raw.map((r, i) => ({ i, frac: r - floors[i] })).sort((a, b) => b.frac - a.frac || (ids[a.i] < ids[b.i] ? -1 : 1));
for (const { i } of order) {
if (left <= 0) break;
floors[i] += 1;
left -= 1;
}
return Object.fromEntries(ids.map((id, i) => [id, floors[i]]));
}
/** Net balance per person: positive means they get money back, negative means they owe. Sums to zero. */
export function balances(people: Person[], expenses: Expense[]): Record<string, number> {
const out: Record<string, number> = Object.fromEntries(people.map((p) => [p.id, 0]));
for (const e of expenses) {
if (e.deleted) continue;
out[e.paidBy] = (out[e.paidBy] || 0) + e.amount;
for (const [id, part] of Object.entries(owedBy(e))) out[id] = (out[id] || 0) - part;
}
return out;
}
/** The fewest payments that bring every balance to zero (greedy: biggest debtor pays biggest creditor). */
export function settle(bal: Record<string, number>): Transfer[] {
const debtors = Object.entries(bal).filter(([, v]) => v < 0).map(([id, v]) => ({ id, v: -v })).sort((a, b) => b.v - a.v || (a.id < b.id ? -1 : 1));
const creditors = Object.entries(bal).filter(([, v]) => v > 0).map(([id, v]) => ({ id, v })).sort((a, b) => b.v - a.v || (a.id < b.id ? -1 : 1));
const out: Transfer[] = [];
let i = 0;
let j = 0;
while (i < debtors.length && j < creditors.length) {
const pay = Math.min(debtors[i].v, creditors[j].v);
if (pay > 0) out.push({ from: debtors[i].id, to: creditors[j].id, amount: pay });
debtors[i].v -= pay;
creditors[j].v -= pay;
if (debtors[i].v === 0) i++;
if (creditors[j].v === 0) j++;
}
return out;
}
/** Checks an expense before it is written. Returns an error sentence, or null when it is fine. */
export function validateExpense(e: Pick<Expense, "title" | "amount" | "paidBy" | "splitMode" | "shares" | "kind">, peopleIds: Set<string>): string | null {
if (!Number.isSafeInteger(e.amount) || e.amount <= 0) return "Enter an amount above zero.";
if (e.amount > 1e12) return "That amount is too large.";
if (e.kind === "expense" && !e.title.trim()) return "Give the expense a name.";
if (e.title.length > 120) return "Keep the name under 120 characters.";
if (!peopleIds.has(e.paidBy)) return "Pick who paid.";
if (!["equal", "shares", "exact"].includes(e.splitMode)) return "Pick how to split it.";
const shares = parseShares(e.shares);
const ids = Object.keys(shares).filter((id) => shares[id] > 0);
if (!ids.length) return "Pick at least one person to split with.";
if (ids.some((id) => !peopleIds.has(id))) return "Someone in the split is not in the group.";
if (e.splitMode === "exact") {
if (ids.some((id) => !Number.isSafeInteger(shares[id]))) return "Exact amounts must be whole minor units.";
const sum = ids.reduce((a, id) => a + shares[id], 0);
if (sum !== e.amount) return "The exact amounts must add up to the total.";
}
return null;
}
const ALPHABET = "abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789";
/** A random id from an unambiguous alphabet; 20 characters is about 116 bits. */
export function randomId(length = 20): string {
const bytes = crypto.getRandomValues(new Uint8Array(length));
let out = "";
for (const b of bytes) out += ALPHABET[b % ALPHABET.length];
return out;
}
Change history
Publish quality pass from owner chat
Bind agent address in policy constants
Quality pass: cleaner group page, phone polish, fuller API, sourced comparison
Bounded source sync
Bounded source sync