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/api.ts
// Split's public HTTP API: no key, no account. A group's id is its secret, exactly like its link.
// Every route answers JSON; GET / describes the routes. Writes go through the same policy rules
// as the web app, as this route's public principal.
import { SITE_URL } from "./lib/config";
import {
balances, settle, owedBy, toMinor, fromMinor, formatMoney, validateExpense, randomId, newestFirst, CURRENCIES,
type Expense, type Person, type SplitMode,
} from "./lib/split";
const NOW = { operation: "time", value: "now" };
const ID_RE = /^[A-Za-z0-9]{8,32}$/;
const cors = {
"access-control-allow-origin": "*",
"access-control-allow-methods": "GET, POST, PATCH, DELETE, OPTIONS",
"access-control-allow-headers": "content-type, idempotency-key",
};
const json = (body: unknown, status = 200) =>
new Response(JSON.stringify(body, null, 2), { status, headers: { "content-type": "application/json; charset=utf-8", ...cors } });
const fail = (status: number, error: string) => json({ error }, status);
class HttpError extends Error { constructor(public status: number, message: string) { super(message); } }
async function body(req: Request): Promise<Record<string, any>> {
try {
const parsed = await req.json();
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
} catch { /* fall through */ }
throw new HttpError(400, "Send a JSON object body.");
}
async function listAll(ctx: any, path: string): Promise<any[]> {
const rows: any[] = [];
let cursor: string | undefined;
for (let page = 0; page < 20; page++) {
const res = await ctx.bounded.get(path, { limit: 500, ...(cursor ? { cursor } : {}) });
// Rows name their path in _id (or pathId); the bare id is its last segment.
for (const r of res?.data || []) rows.push({ ...r, id: r.id ?? String(r._id ?? r.pathId ?? "").split("/").pop() });
cursor = res?.nextCursor;
if (!cursor) break;
}
return rows;
}
/** Joins the group as this route (knowing the id is the permission), then reads it. */
async function openGroup(ctx: any, groupId: string) {
if (!ID_RE.test(groupId)) throw new HttpError(404, "No group with that id.");
const me = ctx.user.id;
const joined = await ctx.bounded.get(`groups/${groupId}/joins/${me}`);
if (!joined) {
try {
await ctx.bounded.set(`groups/${groupId}/joins/${me}`, { at: NOW });
} catch {
throw new HttpError(404, "No group with that id.");
}
}
const group = await ctx.bounded.get(`groups/${groupId}`);
if (!group) throw new HttpError(404, "No group with that id.");
const people = (await listAll(ctx, `groups/${groupId}/people`)) as (Person & { createdAt: number })[];
const expenses = (await listAll(ctx, `groups/${groupId}/expenses`)) as Expense[];
return { group, people, expenses };
}
function findPerson(people: Person[], ref: unknown): Person | undefined {
const s = String(ref ?? "").trim();
if (!s) return undefined;
return people.find((p) => p.id === s) || people.find((p) => !p.removed && p.name.toLowerCase() === s.toLowerCase());
}
function summary(groupId: string, group: any, people: Person[], expenses: Expense[]) {
const cur = group.currency;
const live = expenses.filter((e) => !e.deleted);
const bal = balances(people, live);
const nameOf = (id: string) => people.find((p) => p.id === id)?.name ?? "Someone";
const spent = live.filter((e) => e.kind === "expense");
const total = spent.reduce((a, e) => a + e.amount, 0);
const paid: Record<string, number> = {};
const part: Record<string, number> = {};
for (const e of spent) {
paid[e.paidBy] = (paid[e.paidBy] || 0) + e.amount;
for (const [id, v] of Object.entries(owedBy(e))) part[id] = (part[id] || 0) + v;
}
return {
group: { id: groupId, name: group.name, currency: cur, url: `${SITE_URL}/g/${groupId}` },
people: people.filter((p) => !p.removed).map((p) => ({ id: p.id, name: p.name })),
totalSpent: fromMinor(total, cur),
balances: people.filter((p) => !p.removed || bal[p.id]).map((p) => ({
personId: p.id, name: p.name, amount: fromMinor(bal[p.id] || 0, cur), display: formatMoney(bal[p.id] || 0, cur, "en-US"),
meaning: (bal[p.id] || 0) > 0 ? "gets money back" : (bal[p.id] || 0) < 0 ? "owes" : "settled",
paid: fromMinor(paid[p.id] || 0, cur), share: fromMinor(part[p.id] || 0, cur),
})),
settleUp: settle(bal).map((t) => ({ from: nameOf(t.from), fromId: t.from, to: nameOf(t.to), toId: t.to, amount: fromMinor(t.amount, cur), display: formatMoney(t.amount, cur, "en-US") })),
expenses: live.sort(newestFirst).map((e) => ({
id: e.id, kind: e.kind, title: e.title, amount: fromMinor(e.amount, cur), paidBy: nameOf(e.paidBy),
...(e.kind === "payment" ? { to: nameOf(Object.keys(owedBy(e))[0] || "") } : {}),
date: new Date(e.date * 1000).toISOString().slice(0, 10), splitMode: e.splitMode,
split: Object.fromEntries(Object.entries(owedBy(e)).map(([id, v]) => [nameOf(id), fromMinor(v, cur)])),
})),
};
}
function parseDate(value: unknown): number {
if (value == null || value === "") return Math.floor(Date.now() / 1000);
const text = String(value).trim();
// A plain day is stored as noon UTC, so it reads as that same day in every time zone people live in.
const t = Date.parse(/^\d{4}-\d{2}-\d{2}$/.test(text) ? `${text}T12:00:00Z` : text);
if (!Number.isFinite(t)) throw new HttpError(400, "date must look like 2026-10-18.");
return Math.floor(t / 1000);
}
/** Turns the friendly expense input into the stored shape. */
function buildExpense(input: Record<string, any>, people: Person[], currency: string, kind: "expense" | "payment", current?: Expense) {
const active = people.filter((p) => !p.removed);
const amount = toMinor(input.amount, currency);
if (!Number.isFinite(amount)) throw new HttpError(400, `amount must be a positive number in ${currency}, like "12.50".`);
if (kind === "payment") {
const from = findPerson(people, input.from);
const to = findPerson(people, input.to);
if (!from || !to) throw new HttpError(400, "from and to must name people in the group.");
if (from.id === to.id) throw new HttpError(400, "from and to must be different people.");
return { title: "Payment", amount, paidBy: from.id, splitMode: "exact" as SplitMode, shares: JSON.stringify({ [to.id]: amount }), kind };
}
const payer = findPerson(people, input.paidBy);
if (!payer) throw new HttpError(400, "paidBy must name a person in the group.");
if (current && input.split === undefined && input.splitMode === undefined) {
// Editing without a new split keeps the old one.
return { title: String(input.title ?? "").trim(), amount, paidBy: payer.id, splitMode: current.splitMode, shares: current.shares, kind };
}
let splitMode: SplitMode = ["equal", "shares", "exact"].includes(input.splitMode) ? input.splitMode : "equal";
let shares: Record<string, number> = {};
const split = input.split;
if (split == null) {
for (const p of active) shares[p.id] = 1;
} else if (Array.isArray(split)) {
splitMode = "equal";
for (const ref of split) {
const p = findPerson(people, ref);
if (!p) throw new HttpError(400, `"${ref}" is not in the group.`);
shares[p.id] = 1;
}
} else if (typeof split === "object") {
if (splitMode === "equal") splitMode = "shares";
for (const [ref, v] of Object.entries(split)) {
const p = findPerson(people, ref);
if (!p) throw new HttpError(400, `"${ref}" is not in the group.`);
const n = splitMode === "exact" ? toMinor(v as any, currency) : Number(v);
if (!Number.isFinite(n) || n < 0) throw new HttpError(400, `The share for ${p.name} is not a valid number.`);
shares[p.id] = n;
}
} else {
throw new HttpError(400, "split must be a list of names, or an object of name to share.");
}
return { title: String(input.title ?? "").trim(), amount, paidBy: payer.id, splitMode, shares: JSON.stringify(shares), kind };
}
const routes = {
name: "Split by OpenApps",
about: "Free expense splitting. No key and no account: a group's id is its secret, like its link.",
docs: `${SITE_URL}/llms.txt`,
routes: [
"POST /groups {name, currency?, people: [names]} -> the new group, its link and its people",
"GET /groups/:id -> people, expenses, balances and the fewest payments to settle up",
"PATCH /groups/:id {name?, currency?}",
"POST /groups/:id/people {name}",
"PATCH /groups/:id/people/:personId {name} renames a person",
"DELETE /groups/:id/people/:personId removes a person whose balance is zero",
"POST /groups/:id/expenses {title, amount, paidBy, split?, splitMode?, date?}",
"PATCH /groups/:id/expenses/:expenseId {any expense or payment field} edits it",
"DELETE /groups/:id/expenses/:expenseId deletes an expense or a payment",
"POST /groups/:id/payments {from, to, amount, date?}",
],
links: {
group: `${SITE_URL}/g/<groupId>`,
addExpense: `${SITE_URL}/g/<groupId>?add=1&title=Dinner&amount=84.60 opens a filled-in expense for the person to check and save`,
settleUp: `${SITE_URL}/g/<groupId>?settle=1 opens Record a payment, filled in with what they owe`,
},
};
export default async function api(req: Request, ctx: any) {
if (req.method === "OPTIONS") return new Response(null, { status: 204, headers: cors });
const parts = new URL(req.url).pathname.split("/").filter(Boolean);
// Accept both /groups/... and /api/groups/... so a copied path works either way.
if (parts[0] === "api") parts.shift();
try {
if (!parts.length && req.method === "GET") return json(routes);
if (parts[0] !== "groups") return fail(404, "Unknown route. GET / lists the routes.");
const me = ctx.user.id;
if (parts.length === 1 && req.method === "POST") {
const input = await body(req);
const name = String(input.name ?? "").trim();
const currency = String(input.currency ?? "USD").trim().toUpperCase();
const names: string[] = Array.isArray(input.people) ? input.people.map((n: unknown) => String(n ?? "").trim()).filter(Boolean) : [];
if (!name || name.length > 80) return fail(400, "name is required (up to 80 characters).");
if (!CURRENCIES.includes(currency)) return fail(400, `currency must be one of ${CURRENCIES.join(", ")}.`);
if (names.length > 50) return fail(400, "A group can start with up to 50 people.");
if (names.some((n) => n.length > 40)) return fail(400, "Keep each name under 40 characters.");
if (new Set(names.map((n) => n.toLowerCase())).size !== names.length) return fail(400, "Each person needs a different name.");
const groupId = randomId(20);
const people = names.map((n) => ({ id: randomId(10), name: n }));
await ctx.bounded.setMany([
{ path: `groups/${groupId}`, document: { name, currency, createdBy: me, createdAt: NOW } },
{ path: `groups/${groupId}/joins/${me}`, document: { at: NOW } },
...people.map((p) => ({ path: `groups/${groupId}/people/${p.id}`, document: { name: p.name, createdAt: NOW } })),
]);
return json({ ...summary(groupId, { name, currency }, people, []), share: `Anyone with ${SITE_URL}/g/${groupId} can see and add to this group.` }, 201);
}
const groupId = parts[1];
const { group, people, expenses } = await openGroup(ctx, groupId);
if (parts.length === 2 && req.method === "GET") return json(summary(groupId, group, people, expenses));
if (parts.length === 2 && req.method === "PATCH") {
const input = await body(req);
const name = input.name == null ? group.name : String(input.name).trim();
const currency = input.currency == null ? group.currency : String(input.currency).trim().toUpperCase();
if (!name || name.length > 80) return fail(400, "name must be 1 to 80 characters.");
if (!CURRENCIES.includes(currency)) return fail(400, `currency must be one of ${CURRENCIES.join(", ")}.`);
if (currency !== group.currency && expenses.some((e) => !e.deleted)) return fail(409, "The currency can only change before the first expense.");
await ctx.bounded.set(`groups/${groupId}`, { name, currency });
return json(summary(groupId, { ...group, name, currency }, people, expenses));
}
if (parts[2] === "people" && parts.length === 3 && req.method === "POST") {
const input = await body(req);
const name = String(input.name ?? "").trim();
if (!name || name.length > 40) return fail(400, "name is required (up to 40 characters).");
if (findPerson(people.filter((p) => !p.removed), name)) return fail(409, `${name} is already in the group.`);
const id = randomId(10);
await ctx.bounded.set(`groups/${groupId}/people/${id}`, { name, createdAt: NOW });
return json(summary(groupId, group, [...people, { id, name }], expenses), 201);
}
if ((parts[2] === "expenses" || parts[2] === "payments") && parts.length === 3 && req.method === "POST") {
const input = await body(req);
const kind = parts[2] === "payments" ? "payment" : "expense";
const built = buildExpense(input, people, group.currency, kind);
const error = validateExpense(built, new Set(people.filter((p) => !p.removed).map((p) => p.id)));
if (error) return fail(400, error);
const id = randomId(12);
const date = parseDate(input.date);
await ctx.bounded.set(`groups/${groupId}/expenses/${id}`, { ...built, date, createdBy: me, createdAt: NOW });
return json({ created: id, ...summary(groupId, group, people, [...expenses, { id, ...built, date, createdAt: Math.floor(Date.now() / 1000) }]) }, 201);
}
if (parts[2] === "people" && parts.length === 4 && (req.method === "PATCH" || req.method === "DELETE")) {
const p = people.find((x) => x.id === parts[3] && !x.removed);
if (!p) return fail(404, "No person with that id in this group. GET the group to see people and their ids.");
if (req.method === "PATCH") {
const input = await body(req);
const name = String(input.name ?? "").trim();
if (!name || name.length > 40) return fail(400, "name is required (up to 40 characters).");
if (people.some((x) => !x.removed && x.id !== p.id && x.name.toLowerCase() === name.toLowerCase())) return fail(409, `${name} is already in the group.`);
await ctx.bounded.set(`groups/${groupId}/people/${p.id}`, { name });
return json(summary(groupId, group, people.map((x) => (x.id === p.id ? { ...x, name } : x)), expenses));
}
const owed = balances(people, expenses.filter((e) => !e.deleted))[p.id] || 0;
if (owed) return fail(409, `${p.name} ${owed > 0 ? "gets back" : "owes"} ${formatMoney(Math.abs(owed), group.currency, "en-US")}. Record payments until their balance is zero, then remove them.`);
await ctx.bounded.set(`groups/${groupId}/people/${p.id}`, { removed: true });
return json(summary(groupId, group, people.map((x) => (x.id === p.id ? { ...x, removed: true } : x)), expenses));
}
if (parts[2] === "expenses" && parts.length === 4 && req.method === "PATCH") {
const e = expenses.find((x) => x.id === parts[3] && !x.deleted);
if (!e) return fail(404, "No expense with that id in this group.");
const input = await body(req);
const merged = e.kind === "payment"
? { from: input.from ?? e.paidBy, to: input.to ?? Object.keys(owedBy(e))[0], amount: input.amount ?? fromMinor(e.amount, group.currency) }
: { ...input, title: input.title ?? e.title, amount: input.amount ?? fromMinor(e.amount, group.currency), paidBy: input.paidBy ?? e.paidBy };
const built = buildExpense(merged, people, group.currency, e.kind, e);
const error = validateExpense(built, new Set(people.filter((p) => !p.removed).map((p) => p.id)));
if (error) return fail(400, error);
const date = input.date === undefined ? e.date : parseDate(input.date);
await ctx.bounded.set(`groups/${groupId}/expenses/${e.id}`, { ...built, date });
return json({ updated: e.id, ...summary(groupId, group, people, expenses.map((x) => (x.id === e.id ? { ...x, ...built, date } : x))) });
}
if (parts[2] === "expenses" && parts.length === 4 && req.method === "DELETE") {
const e = expenses.find((x) => x.id === parts[3] && !x.deleted);
if (!e) return fail(404, "No expense with that id in this group.");
await ctx.bounded.set(`groups/${groupId}/expenses/${e.id}`, { deleted: true });
return json(summary(groupId, group, people, expenses.map((x) => (x.id === e.id ? { ...x, deleted: true } : x))));
}
return fail(404, "Unknown route. GET / lists the routes.");
} catch (err) {
if (err instanceof HttpError) return fail(err.status, err.message);
const message = err instanceof Error ? err.message : String(err);
return fail(500, `Something went wrong: ${message}`);
}
}
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