split-free.openapps.xyz

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.
Owned app · rev 9ed962174401
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.
← All files
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
9ed9621744 · 5 hours ago
Bind agent address in policy constants
7effc1cbbd · 5 hours ago
Quality pass: cleaner group page, phone polish, fuller API, sourced comparison
5345219781 · 5 hours ago
Bounded source sync
277fb9ad96 · 1 day ago
Bounded source sync
e944552325 · 1 day ago