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 b3b80918bc912dda8c53a48d
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
// Invoice's public HTTP API: no key and no account to create an invoice. An invoice's id is what
// lets someone read it, exactly like its link; changing it takes the owner key handed back at
// creation (x-owner-key header). 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 {
computeTotals, validateInvoice, fromMinor, formatMoney, statusOf, lineAmount, lineRate, isBlankItem, randomId, docWord, CURRENCIES,
type InvoiceData, type Item, type Party,
} from "./lib/invoice";
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, OPTIONS",
"access-control-allow-headers": "content-type, x-owner-key, 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, optional = false): Promise<Record<string, any>> {
const text = await req.text();
if (optional && !text.trim()) return {};
try {
const parsed = JSON.parse(text);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
} catch { /* fall through */ }
throw new HttpError(400, "Send a JSON object body.");
}
const str = (v: unknown) => (v == null ? "" : String(v).trim());
const today = () => new Date().toISOString().slice(0, 10);
function party(input: unknown, base: Party | undefined, label: string): Party {
if (input == null) {
if (base) return base;
throw new HttpError(400, `${label} is required, like {"name": "..."}.`);
}
if (typeof input !== "object" || Array.isArray(input)) throw new HttpError(400, `${label} must be an object, like {"name": "..."}.`);
const p = input as Record<string, unknown>;
const out: Party = { ...(base || { name: "" }) };
for (const k of ["name", "email", "address", "taxId"] as const) if (k in p) out[k] = str(p[k]);
return out;
}
function items(input: unknown): Item[] {
if (!Array.isArray(input)) throw new HttpError(400, 'items must be a list, like [{"description": "Design", "quantity": 2, "unitPrice": "80.00"}].');
return input.map((raw, i) => {
if (!raw || typeof raw !== "object") throw new HttpError(400, `Line ${i + 1} must be an object with description, quantity and unitPrice.`);
const it = raw as Record<string, unknown>;
const out: Item = { description: str(it.description), quantity: it.quantity == null || it.quantity === "" ? "1" : str(it.quantity), unitPrice: str(it.unitPrice) };
if (it.taxRate != null && it.taxRate !== "") out.taxRate = str(it.taxRate);
return out;
}).filter((it) => !isBlankItem(it));
}
/** Turns the friendly API input into the stored invoice, on top of an existing one for PATCH. */
function build(input: Record<string, any>, base: InvoiceData | null, invoiceId: string): InvoiceData {
const has = (k: string) => k in input && input[k] !== undefined;
const data: InvoiceData = base ? { ...base } : {
from: { name: "" }, to: { name: "" }, number: "", issueDate: today(), dueDate: "", currency: "USD", items: [],
};
if (has("kind")) {
const kind = str(input.kind).toLowerCase();
if (kind === "estimate") data.kind = "estimate";
else if (kind === "invoice" || kind === "") delete data.kind;
else throw new HttpError(400, 'kind must be "invoice" or "estimate".');
}
if (!base) data.number = `${data.kind === "estimate" ? "EST" : "INV"}-${invoiceId.slice(0, 6).toUpperCase()}`;
data.from = { ...party(has("from") ? input.from : null, base?.from, "from"), ...(base?.from.logo ? { logo: base.from.logo } : {}) };
data.to = party(has("to") ? input.to : null, base?.to, "to");
if (has("items")) data.items = items(input.items);
else if (!base) throw new HttpError(400, 'items is required, like [{"description": "Design", "quantity": 2, "unitPrice": "80.00"}].');
if (has("currency")) data.currency = str(input.currency).toUpperCase();
if (has("number")) data.number = str(input.number);
if (has("issueDate")) data.issueDate = str(input.issueDate) || today();
if (has("dueDate")) data.dueDate = str(input.dueDate);
if (has("taxRate")) {
if (input.taxRate == null || input.taxRate === "") delete data.taxRate;
else data.taxRate = str(input.taxRate);
}
if (has("discount")) {
const d = input.discount;
if (d == null) delete data.discount;
else if (typeof d !== "object" || Array.isArray(d)) throw new HttpError(400, 'discount must look like {"type": "percent", "value": 10}.');
else data.discount = { type: d.type === "amount" ? "amount" : d.type === "percent" ? "percent" : d.type, value: str(d.value) };
}
for (const k of ["notes", "paymentInstructions"] as const) {
if (!has(k)) continue;
const v = str(input[k]);
if (v) data[k] = v; else delete data[k];
}
if (!CURRENCIES.includes(data.currency)) throw new HttpError(400, `currency must be one of ${CURRENCIES.join(", ")}.`);
const error = validateInvoice(data);
if (error) throw new HttpError(400, error);
return data;
}
function money(cur: string) {
return (m: number) => fromMinor(m, cur);
}
function summary(invoiceId: string, doc: any, data: InvoiceData) {
const cur = data.currency;
const m = money(cur);
const t = computeTotals(data);
const status = statusOf(doc.status, data.dueDate, today(), data.kind);
const { logo, ...from } = data.from;
return {
invoiceId,
url: `${SITE_URL}/i/${invoiceId}`,
kind: data.kind ?? "invoice",
status,
paidAt: doc.status === "paid" && doc.paidAt ? new Date(doc.paidAt * 1000).toISOString().slice(0, 10) : null,
number: data.number,
issueDate: data.issueDate,
dueDate: data.dueDate || null,
currency: cur,
from: { ...from, hasLogo: Boolean(logo) },
to: data.to,
items: data.items.map((it, i) => ({
description: it.description, quantity: it.quantity, unitPrice: m(lineAmount({ quantity: "1", unitPrice: it.unitPrice }, cur)),
taxRate: lineRate(it, data), amount: m(t.lines[i]),
})),
subtotal: m(t.subtotal),
discount: data.discount && t.discount ? { type: data.discount.type, value: data.discount.value, amount: m(t.discount) } : null,
taxes: t.taxes.map((x) => ({ rate: x.rate, amount: m(x.amount) })),
tax: m(t.tax),
total: m(t.total),
totalDisplay: formatMoney(t.total, cur, "en-US"),
notes: data.notes ?? null,
paymentInstructions: data.paymentInstructions ?? null,
pdf: `Open ${SITE_URL}/i/${invoiceId} and press Download PDF.`,
};
}
/** Joins the invoice as this route (knowing the id is the permission), then reads it. */
async function openInvoice(ctx: any, invoiceId: string) {
if (!ID_RE.test(invoiceId)) throw new HttpError(404, "No invoice with that id.");
const me = ctx.user.id;
const joined = await ctx.bounded.get(`invoices/${invoiceId}/joins/${me}`);
if (!joined) {
try {
await ctx.bounded.set(`invoices/${invoiceId}/joins/${me}`, { at: NOW });
} catch {
throw new HttpError(404, "No invoice with that id.");
}
}
const doc = await ctx.bounded.get(`invoices/${invoiceId}`);
if (!doc) throw new HttpError(404, "No invoice with that id.");
let data: InvoiceData;
try {
data = JSON.parse(doc.data);
} catch {
throw new HttpError(500, "This invoice could not be read.");
}
return { doc, data };
}
/** Checks the owner key before anything else, then makes this route an owner if it is not one yet. */
async function asOwner(ctx: any, req: Request, invoiceId: string) {
if (!ID_RE.test(invoiceId)) throw new HttpError(404, "No invoice with that id.");
const key = str(req.headers.get("x-owner-key"));
if (!key) throw new HttpError(401, "Send the invoice's owner key in the x-owner-key header.");
const stored = await ctx.bounded.get(`invoicekeys/${invoiceId}`);
if (!stored?.key) throw new HttpError(404, "No invoice with that id.");
if (stored.key !== key) throw new HttpError(403, "That owner key does not match this invoice.");
const opened = await openInvoice(ctx, invoiceId);
const me = ctx.user.id;
if (!(await ctx.bounded.get(`invoices/${invoiceId}/owners/${me}`))) {
await ctx.bounded.setMany([
{ path: `invoiceclaims/${invoiceId}/c/${me}`, document: { key } },
{ path: `invoices/${invoiceId}/owners/${me}`, document: { at: NOW } },
]);
}
return opened;
}
function parseDay(value: unknown): number | null {
const s = str(value);
if (!s) return null;
const t = Date.parse(`${s}T12:00:00Z`);
if (!/^\d{4}-\d{2}-\d{2}$/.test(s) || !Number.isFinite(t)) throw new HttpError(400, "date must look like 2026-10-18.");
return Math.floor(t / 1000);
}
const routes = {
name: "Invoice by OpenApps",
about: "Free invoices and estimates with no watermark. No key and no account to create one. An invoice's id lets anyone read it, like its link; changes need the owner key returned at creation, sent as the x-owner-key header.",
docs: `${SITE_URL}/llms.txt`,
openapi: `${SITE_URL}/openapi.json`,
routes: [
"POST /invoices {kind?: invoice|estimate, from: {name, email?, address?, taxId?}, to: {name, email?, address?}, items: [{description, quantity, unitPrice, taxRate?}], currency?, number?, issueDate?, dueDate?, taxRate?, discount?: {type: percent|amount, value}, notes?, paymentInstructions?} -> invoiceId, url, adminUrl, ownerKey and totals",
"GET /invoices/:id -> the invoice with its totals and status (unpaid, paid, overdue, or estimate)",
"PATCH /invoices/:id (x-owner-key) {any field of POST}",
"POST /invoices/:id/paid (x-owner-key) {date?}",
"POST /invoices/:id/unpaid (x-owner-key)",
],
};
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 /invoices/... and /api/invoices/... 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] !== "invoices") return fail(404, "Unknown route. GET / lists the routes.");
const me = ctx.user.id;
if (parts.length === 1 && req.method === "POST") {
const invoiceId = randomId(20);
const data = build(await body(req), null, invoiceId);
const ownerKey = randomId(24);
const t = computeTotals(data);
await ctx.bounded.setMany([
{ path: `invoices/${invoiceId}`, document: { data: JSON.stringify(data), total: t.total, currency: data.currency, status: "unpaid", createdBy: me, createdAt: NOW } },
{ path: `invoices/${invoiceId}/joins/${me}`, document: { at: NOW } },
{ path: `invoices/${invoiceId}/owners/${me}`, document: { at: NOW } },
{ path: `invoicekeys/${invoiceId}`, document: { key: ownerKey } },
]);
const m = money(data.currency);
return json({
invoiceId,
url: `${SITE_URL}/i/${invoiceId}`,
adminUrl: `${SITE_URL}/i/${invoiceId}/admin#k=${ownerKey}`,
ownerKey,
kind: data.kind ?? "invoice",
number: data.number,
currency: data.currency,
subtotal: m(t.subtotal),
discount: m(t.discount),
tax: m(t.tax),
total: m(t.total),
totalDisplay: formatMoney(t.total, data.currency, "en-US"),
share: `Send url to the client: they can view the ${docWord(data).toLowerCase()} and download the PDF. Give adminUrl only to the person who sent it: it lets them edit it${data.kind === "estimate" ? "" : " and mark it paid"}.`,
}, 201);
}
const invoiceId = parts[1];
if (parts.length === 2 && req.method === "GET") {
const { doc, data } = await openInvoice(ctx, invoiceId);
return json(summary(invoiceId, doc, data));
}
if (parts.length === 2 && req.method === "PATCH") {
const input = await body(req);
const { doc, data: base } = await asOwner(ctx, req, invoiceId);
const data = build(input, base, invoiceId);
const t = computeTotals(data);
await ctx.bounded.set(`invoices/${invoiceId}`, { data: JSON.stringify(data), total: t.total, currency: data.currency });
return json(summary(invoiceId, doc, data));
}
if (parts.length === 3 && (parts[2] === "paid" || parts[2] === "unpaid") && req.method === "POST") {
const input = await body(req, true);
const { doc, data } = await asOwner(ctx, req, invoiceId);
if (data.kind === "estimate") throw new HttpError(400, "This is an estimate, so it cannot be paid. To bill it, create an invoice with the same fields (POST /invoices).");
const patch = parts[2] === "paid"
? { status: "paid", paidAt: parseDay(input.date) ?? NOW }
: { status: "unpaid", paidAt: null };
await ctx.bounded.set(`invoices/${invoiceId}`, patch);
const after = await ctx.bounded.get(`invoices/${invoiceId}`);
return json(summary(invoiceId, after || { ...doc, ...patch }, data));
}
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
Quality pass: phone-readable invoice, estimates, email client, saved clients, CSV export, openapi.json
Bounded source sync
Bounded source sync
Bounded source sync
Bounded source sync
Bounded source sync
Bounded source sync