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.
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
Bounded source sync
Bounded source sync
Bounded source sync
Bounded source sync
Bounded source sync
Bounded source sync