invoice-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 333971bf96d2
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.
← All files
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
333971bf96 · 6 hours ago
Bounded source sync
088b53bddb · 1 day ago
Bounded source sync
3f3e9ff8f9 · 1 day ago
Bounded source sync
209bec4267 · 1 day ago
Bounded source sync
11f3d98cda · 1 day ago
Bounded source sync
722948ebc3 · 1 day ago
Bounded source sync
3e9d04b2c0 · 1 day ago