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
src/pdf.ts
// The PDF, made in the browser with pdf-lib and the standard Helvetica fonts. Clean, no watermark,
// the logo when there is one, and as many pages as the lines need (the table header repeats).
import { PDFDocument, StandardFonts, rgb, type PDFFont, type PDFPage, type PDFImage } from "pdf-lib";
import { computeTotals, docWord, formatMoney, lineAmount, lineRate, localDay, minorDigits, type InvoiceData } from "../functions/lib/invoice";
import { fmtDate, fmtQty, paperSize, perLineTax, fileName } from "./format";

const INK = rgb(0.08, 0.09, 0.11);
const MUTED = rgb(0.37, 0.39, 0.42);
const LINE = rgb(0.86, 0.87, 0.89);
const FILL = rgb(0.955, 0.96, 0.97);
const GREEN = rgb(0.18, 0.42, 0.27);

export interface PdfStatus { paidAt?: number | null }

export async function makePdf(data: InvoiceData, status: PdfStatus = {}): Promise<Uint8Array> {
  const doc = await PDFDocument.create();
  const word = docWord(data);
  const estimate = data.kind === "estimate";
  const paidAt = estimate ? null : status.paidAt;
  doc.setTitle(`${word} ${data.number}`);
  doc.setAuthor(data.from.name);
  doc.setSubject(`${word} ${data.number} for ${data.to.name}`);
  doc.setCreator("Invoice by OpenApps");
  doc.setProducer("Invoice by OpenApps");
  const font = await doc.embedFont(StandardFonts.Helvetica);
  const bold = await doc.embedFont(StandardFonts.HelveticaBold);
  const charset = new Set(font.getCharacterSet());

  // The standard fonts cover Western European text. Anything else is written without its accent,
  // or as "?" when there is no close letter, instead of failing the whole download.
  const safe = (s: string) => {
    let out = "";
    for (const ch of s.replace(/[\u202f\u2009\u2007\t]/g, " ").replace(/\u2212/g, "-")) {
      const cp = ch.codePointAt(0)!;
      if (charset.has(cp)) { out += ch; continue; }
      const plain = ch.normalize("NFD").replace(/[\u0300-\u036f]/g, "");
      const extra: Record<string, string> = { "ł": "l", "Ł": "L", "đ": "d", "Đ": "D", "ı": "i" };
      if (plain && [...plain].every((c) => charset.has(c.codePointAt(0)!))) out += plain;
      else out += extra[ch] ?? "?";
    }
    return out;
  };
  const cur = data.currency;
  const money = (minor: number) => {
    const s = formatMoney(minor, cur).replace(/[\u202f\u2009\u2007]/g, " ");
    if ([...s].every((c) => charset.has(c.codePointAt(0)!))) return s;
    try {
      return safe(new Intl.NumberFormat(undefined, { style: "currency", currency: cur, currencyDisplay: "code" }).format(minor / 10 ** minorDigits(cur)));
    } catch {
      return safe(s);
    }
  };

  const [W, H] = paperSize() === "A4" ? [595.28, 841.89] : [612, 792];
  const M = 50;
  const BOTTOM = M + 24;
  const pages: PDFPage[] = [];
  let page = doc.addPage([W, H]);
  pages.push(page);
  let y = H - M;

  const width = (s: string, f: PDFFont, size: number) => f.widthOfTextAtSize(s, size);
  const draw = (s: string, x: number, yy: number, size: number, f = font, color = INK) => {
    if (s) page.drawText(safe(s), { x, y: yy, size, font: f, color });
  };
  const drawRight = (s: string, right: number, yy: number, size: number, f = font, color = INK) => {
    const t = safe(s);
    if (t) page.drawText(t, { x: right - width(t, f, size), y: yy, size, font: f, color });
  };
  /** Splits text into lines that fit, keeping the person's own line breaks. */
  const wrap = (s: string, f: PDFFont, size: number, max: number): string[] => {
    const out: string[] = [];
    for (const para of s.split(/\r?\n/).map(safe)) {
      let line = "";
      for (const word of para.split(/ +/)) {
        let w = word;
        while (width(w, f, size) > max) {
          // A word longer than the column is broken where it no longer fits.
          let cut = w.length - 1;
          while (cut > 1 && width(w.slice(0, cut), f, size) > max) cut--;
          if (line) { out.push(line); line = ""; }
          out.push(w.slice(0, cut));
          w = w.slice(cut);
        }
        const next = line ? `${line} ${w}` : w;
        if (width(next, f, size) <= max) line = next;
        else { out.push(line); line = w; }
      }
      out.push(line);
    }
    return out;
  };
  const newPage = () => {
    page = doc.addPage([W, H]);
    pages.push(page);
    y = H - M;
  };
  const ensure = (needed: number) => { if (y - needed < BOTTOM) newPage(); };

  // Header: logo and business on the left, the title and dates on the right.
  let logo: PDFImage | null = null;
  if (data.from.logo) {
    try {
      const bytes = Uint8Array.from(atob(data.from.logo.split(",")[1] || ""), (c) => c.charCodeAt(0));
      logo = data.from.logo.startsWith("data:image/png") ? await doc.embedPng(bytes) : await doc.embedJpg(bytes);
    } catch { logo = null; }
  }
  const colW = (W - 2 * M) * 0.55;
  let left = y;
  if (logo) {
    const s = Math.min(160 / logo.width, 56 / logo.height, 1);
    const lw = logo.width * s;
    const lh = logo.height * s;
    page.drawImage(logo, { x: M, y: left - lh, width: lw, height: lh });
    left -= lh + 16;
  }
  for (const line of wrap(data.from.name, bold, 13, colW)) { draw(line, M, left - 12, 13, bold); left -= 17; }
  const fromLines = [...(data.from.address ? wrap(data.from.address, font, 9.5, colW) : []), ...(data.from.email ? [data.from.email] : []), ...(data.from.taxId ? [`Tax ID: ${data.from.taxId}`] : [])];
  left -= 2;
  for (const line of fromLines) { draw(line, M, left - 10, 9.5, font, MUTED); left -= 13.5; }

  let right = y;
  drawRight(word.toUpperCase(), W - M, right - 22, 24, bold);
  right -= 42;
  const meta: [string, string][] = [[`${word} no.`, data.number], ["Issue date", fmtDate(data.issueDate)]];
  if (!estimate) meta.push(["Due date", data.dueDate ? fmtDate(data.dueDate) : "On receipt"]);
  else if (data.dueDate) meta.push(["Valid until", fmtDate(data.dueDate)]);
  if (paidAt) meta.push(["Paid on", fmtDate(localDay(new Date(paidAt * 1000)))]);
  for (const [label, value] of meta) {
    draw(label, W - M - 190, right - 10, 9.5, font, MUTED);
    drawRight(value, W - M, right - 10, 9.5, bold, label === "Paid on" ? GREEN : INK);
    right -= 15;
  }
  y = Math.min(left, right) - 26;

  // Bill to, and the amount due.
  const totals = computeTotals(data);
  let billY = y;
  draw(estimate ? "FOR" : "BILL TO", M, billY - 9, 8, bold, MUTED);
  billY -= 22;
  for (const line of wrap(data.to.name, bold, 11.5, colW)) { draw(line, M, billY - 10, 11.5, bold); billY -= 15; }
  const toLines = [...(data.to.address ? wrap(data.to.address, font, 9.5, colW) : []), ...(data.to.email ? [data.to.email] : [])];
  for (const line of toLines) { draw(line, M, billY - 10, 9.5, font, MUTED); billY -= 13.5; }
  drawRight(paidAt ? "PAID" : estimate ? "ESTIMATE TOTAL" : "AMOUNT DUE", W - M, y - 9, 8, bold, paidAt ? GREEN : MUTED);
  drawRight(money(totals.total), W - M, y - 40, 22, bold);
  y = Math.min(billY, y - 48) - 24;

  // Lines.
  const showTax = perLineTax(data);
  const numW = { qty: 54, price: 86, tax: 50, amount: 92 };
  const xAmount = W - M;
  const xTax = xAmount - numW.amount;
  const xPrice = showTax ? xTax - numW.tax : xTax;
  const xQty = xPrice - numW.price;
  const descW = xQty - numW.qty - M - 12;
  const header = () => {
    page.drawRectangle({ x: M, y: y - 22, width: W - 2 * M, height: 22, color: FILL });
    draw("DESCRIPTION", M + 8, y - 14.5, 7.5, bold, MUTED);
    drawRight("QTY", xQty, y - 14.5, 7.5, bold, MUTED);
    drawRight("PRICE", xPrice, y - 14.5, 7.5, bold, MUTED);
    if (showTax) drawRight("TAX", xTax, y - 14.5, 7.5, bold, MUTED);
    drawRight("AMOUNT", xAmount - 8, y - 14.5, 7.5, bold, MUTED);
    y -= 22;
  };
  header();
  data.items.forEach((it, i) => {
    const lines = wrap(it.description, font, 9.5, descW);
    const rowH = lines.length * 13 + 12;
    if (y - rowH < BOTTOM) { newPage(); header(); }
    let ly = y - 16;
    for (const line of lines) { draw(line, M + 8, ly, 9.5); ly -= 13; }
    drawRight(fmtQty(it.quantity), xQty, y - 16, 9.5);
    drawRight(money(lineAmount({ quantity: "1", unitPrice: it.unitPrice }, cur) || 0), xPrice, y - 16, 9.5);
    if (showTax) drawRight(`${lineRate(it, data)}%`, xTax, y - 16, 9.5, font, MUTED);
    drawRight(money(totals.lines[i]), xAmount - 8, y - 16, 9.5);
    y -= rowH;
    page.drawLine({ start: { x: M, y }, end: { x: W - M, y }, thickness: 0.6, color: LINE });
  });

  // Totals, right-aligned.
  const rows: [string, string][] = [["Subtotal", money(totals.subtotal)]];
  if (totals.discount > 0) rows.push([data.discount?.type === "percent" ? `Discount (${data.discount.value}%)` : "Discount", `-${money(totals.discount)}`]);
  for (const t of totals.taxes) rows.push([`Tax (${t.rate}%)`, money(t.amount)]);
  ensure(rows.length * 17 + 50);
  y -= 14;
  const labelX = W - M - 230;
  for (const [label, value] of rows) {
    draw(label, labelX, y - 10, 9.5, font, MUTED);
    drawRight(value, xAmount - 8, y - 10, 9.5);
    y -= 17;
  }
  y -= 4;
  page.drawLine({ start: { x: labelX, y }, end: { x: W - M, y }, thickness: 0.8, color: INK });
  y -= 6;
  draw("Total", labelX, y - 13, 12, bold);
  drawRight(money(totals.total), xAmount - 8, y - 13, 12, bold);
  y -= 22;

  // Notes and payment instructions, full width.
  const block = (title: string, text?: string) => {
    if (!text?.trim()) return;
    y -= 18;
    ensure(34);
    draw(title.toUpperCase(), M, y - 9, 8, bold, MUTED);
    y -= 22;
    for (const line of wrap(text, font, 9.5, W - 2 * M)) {
      ensure(14);
      draw(line, M, y - 10, 9.5);
      y -= 13.5;
    }
  };
  block("Payment instructions", data.paymentInstructions);
  block("Notes", data.notes);

  // Footer on every page: the invoice number, and page numbers when there is more than one.
  pages.forEach((p, i) => {
    p.drawText(safe(`${data.number} · ${data.from.name}`).slice(0, 90), { x: M, y: M - 16, size: 8, font, color: MUTED });
    if (pages.length > 1) {
      const t = `Page ${i + 1} of ${pages.length}`;
      p.drawText(t, { x: W - M - font.widthOfTextAtSize(t, 8), y: M - 16, size: 8, font, color: MUTED });
    }
  });

  return doc.save();
}

/** Builds the PDF and hands it to the browser as a download. */
export async function downloadPdf(data: InvoiceData, status: PdfStatus = {}) {
  const bytes = await makePdf(data, status);
  const url = URL.createObjectURL(new Blob([bytes], { type: "application/pdf" }));
  const a = document.createElement("a");
  a.href = url;
  a.download = fileName(data);
  document.body.appendChild(a);
  a.click();
  a.remove();
  setTimeout(() => URL.revokeObjectURL(url), 10_000);
}

Change history

Quality pass: phone-readable invoice, estimates, email client, saved clients, CSV export, openapi.json
333971bf96 · 4 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