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/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
9ed9621744 · 6 hours ago
Bind agent address in policy constants
7effc1cbbd · 6 hours ago
Quality pass: cleaner group page, phone polish, fuller API, sourced comparison
5345219781 · 6 hours ago
Bounded source sync
277fb9ad96 · 1 day ago
Bounded source sync
e944552325 · 1 day ago