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/format.ts
// Display helpers shared by the on-screen paper and the PDF, so both read the same.
import { computeTotals, docWord, formatMoney, isBlankItem, isDate, type InvoiceData } from "../functions/lib/invoice";

export function fmtDate(day: string, locale?: string): string {
  if (!isDate(day)) return day;
  const [y, m, d] = day.split("-").map(Number);
  return new Date(y, m - 1, d).toLocaleDateString(locale, { year: "numeric", month: "short", day: "numeric" });
}

export function fmtQty(q: string, locale?: string): string {
  const n = Number(String(q).replace(/[\s,]/g, ""));
  return Number.isFinite(n) ? new Intl.NumberFormat(locale, { maximumFractionDigits: 4 }).format(n) : q;
}

/** US-style Letter paper where it is the norm, A4 everywhere else. */
export function paperSize(): "Letter" | "A4" {
  try {
    const region = new Intl.Locale(navigator.language).maximize().region || "US";
    return ["US", "CA", "MX", "PH", "CL", "CO", "VE", "PR", "GT", "CR", "PA", "DO", "SV", "NI", "HN", "BO"].includes(region) ? "Letter" : "A4";
  } catch {
    return "Letter";
  }
}

/** The invoice as it is saved and printed: no empty lines, no empty optional fields. */
export function cleanData(d: InvoiceData): InvoiceData {
  const out: InvoiceData = { ...d, items: d.items.filter((it) => !isBlankItem(it)) };
  if (out.kind !== "estimate") delete out.kind;
  if (!out.taxRate?.trim()) delete out.taxRate;
  out.items = out.items.map(({ taxRate, ...it }) => (taxRate != null && taxRate.trim() !== "" ? { ...it, taxRate } : it));
  if (!out.discount?.value?.trim()) delete out.discount;
  if (!out.notes?.trim()) delete out.notes;
  if (!out.paymentInstructions?.trim()) delete out.paymentInstructions;
  return out;
}

/** True when lines carry their own tax rates (the table then shows a tax column). */
export function perLineTax(d: InvoiceData): boolean {
  return d.items.some((it) => it.taxRate != null && String(it.taxRate).trim() !== "");
}

export function fileName(d: InvoiceData): string {
  return `${docWord(d)} ${d.number || ""}`.trim().replace(/[\\/:*?"<>|]+/g, "-") + ".pdf";
}

/**
 * A mailto: link that opens the person's own email app with a message to the client: the amount,
 * the date and the link. A reminder once an invoice is overdue, a receipt once it is paid.
 * Invoice never sends email itself.
 */
export function clientEmail(d: InvoiceData, url: string, mode: "send" | "remind" | "receipt" = "send", paidDay = ""): string {
  const word = docWord(d);
  const total = formatMoney(computeTotals(d).total, d.currency);
  const date = d.dueDate ? fmtDate(d.dueDate) : "";
  const line = d.kind === "estimate"
    ? `Here is estimate ${d.number} for ${total}${date ? `, valid until ${date}` : ""}.`
    : mode === "receipt"
      ? `Thank you for paying invoice ${d.number} for ${total}${paidDay ? ` on ${paidDay}` : ""}. The invoice, marked paid, is your receipt.`
      : mode === "remind"
        ? `This is a reminder that invoice ${d.number} for ${total} ${date ? `was due on ${date}` : "is still unpaid"}.`
        : `Here is invoice ${d.number} for ${total}${date ? `, due ${date}` : ""}.`;
  const pay = d.kind !== "estimate" && mode !== "receipt" && d.paymentInstructions?.trim() ? ["", "How to pay:", d.paymentInstructions.trim()] : [];
  const body = [d.to.name.trim() ? `Hi ${d.to.name.trim()},` : "Hi,", "", line, "", `View it and download the PDF: ${url}`, ...pay, "", "Thank you,", d.from.name].join("\n");
  const subject = mode === "receipt" ? `Receipt for invoice ${d.number} from ${d.from.name}` : `${mode === "remind" ? "Reminder: " : ""}${word} ${d.number} from ${d.from.name}`;
  const to = encodeURIComponent((d.to.email ?? "").trim()).replace(/%40/g, "@");
  return `mailto:${to}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
}

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