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/lib/invoice.ts
// Invoice's money math, shared by the web app and the public API so both always agree.
// Amounts are integers in the currency's minor unit (cents for USD, yen for JPY). Quantities
// keep up to 4 decimals and rates up to 3; products are computed exactly with BigInt and
// rounded half up once per line, once for the discount and once per tax rate.
export interface Party { name: string; email?: string; address?: string; taxId?: string }
export interface Item {
description: string;
/** Decimal string, up to 4 decimals: "1", "2.5", "0.3333". */
quantity: string;
/** Decimal string in the invoice currency's major unit: "120.00". */
unitPrice: string;
/** Percent for this line only; when absent the invoice's taxRate applies. */
taxRate?: string;
}
export interface Discount { type: "percent" | "amount"; value: string }
/** An estimate (a quote) is laid out like an invoice, with "Valid until" for its date; it is never paid or overdue. */
export type Kind = "invoice" | "estimate";
export interface InvoiceData {
/** Absent means an invoice. */
kind?: Kind;
from: Party & { logo?: string };
to: Party;
number: string;
/** YYYY-MM-DD */
issueDate: string;
/** YYYY-MM-DD, or "" for no due date. On an estimate, the date it is valid until. */
dueDate: string;
currency: string;
items: Item[];
/** Percent applied to every line without its own rate. */
taxRate?: string;
discount?: Discount;
notes?: string;
paymentInstructions?: string;
}
export interface TaxLine { rate: string; amount: number }
export interface Totals {
/** Each item's quantity x unit price, in minor units (0 while a line is incomplete). */
lines: number[];
subtotal: number;
discount: number;
taxes: TaxLine[];
tax: number;
total: number;
}
export type Status = "unpaid" | "paid" | "overdue" | "estimate";
/** "Invoice" or "Estimate", for titles, labels and file names. */
export function docWord(data: Pick<InvoiceData, "kind">): "Invoice" | "Estimate" {
return data.kind === "estimate" ? "Estimate" : "Invoice";
}
export const CURRENCIES = [
"USD", "EUR", "GBP", "CAD", "AUD", "NZD", "JPY", "CHF", "SEK", "NOK", "DKK", "PLN", "CZK", "HUF",
"INR", "SGD", "HKD", "CNY", "KRW", "MXN", "BRL", "ARS", "CLP", "COP", "ZAR", "AED", "ILS", "TRY", "THB", "IDR", "PHP", "VND",
];
/** The whole invoice JSON stays under this many characters (the policy enforces the same cap). */
export const MAX_DATA = 200_000;
export const MAX_ITEMS = 200;
const QTY_DIGITS = 4;
const RATE_DIGITS = 3;
const MAX_TOTAL = 1e14;
export function minorDigits(currency: string): number {
try {
return new Intl.NumberFormat("en", { style: "currency", currency }).resolvedOptions().maximumFractionDigits ?? 2;
} catch {
return 2;
}
}
/** A non-negative decimal with at most `digits` decimals, scaled to an integer; null otherwise. */
function scaled(value: unknown, digits: number): bigint | null {
const text = String(value ?? "").trim().replace(/[\s,]/g, "");
if (!/^\d+(\.\d+)?$|^\.\d+$/.test(text)) return null;
const [whole, frac = ""] = text.split(".");
if (frac.length > digits) return null;
return BigInt(whole || "0") * 10n ** BigInt(digits) + BigInt((frac + "0".repeat(digits)).slice(0, digits) || "0");
}
/** "12.5" or 12.5 in USD -> 1250. NaN for anything that is not a non-negative amount. */
export function toMinor(value: string | number, currency: string): number {
const v = scaled(value, minorDigits(currency));
return v === null ? NaN : Number(v);
}
export function fromMinor(minor: number, currency: string): string {
const digits = minorDigits(currency);
const sign = minor < 0 ? "-" : "";
const abs = Math.abs(minor);
if (digits === 0) return `${sign}${abs}`;
const s = String(abs).padStart(digits + 1, "0");
return `${sign}${s.slice(0, -digits)}.${s.slice(-digits)}`;
}
export function formatMoney(minor: number, currency: string, locale?: string): string {
try {
return new Intl.NumberFormat(locale, { style: "currency", currency }).format(minor / 10 ** minorDigits(currency));
} catch {
return `${fromMinor(minor, currency)} ${currency}`;
}
}
/** a / b rounded half up, for non-negative a and positive b. */
const divRound = (a: bigint, b: bigint) => (2n * a + b) / (2n * b);
const parseQty = (q: unknown) => scaled(q, QTY_DIGITS);
const parseRate = (r: unknown) => {
const v = scaled(r, RATE_DIGITS);
return v !== null && v <= 100n * 10n ** BigInt(RATE_DIGITS) ? v : null;
};
/** 8875n -> "8.875", 20000n -> "20". */
const rateText = (v: bigint) => {
const s = String(v).padStart(RATE_DIGITS + 1, "0");
const frac = s.slice(-RATE_DIGITS).replace(/0+$/, "");
return s.slice(0, -RATE_DIGITS) + (frac ? `.${frac}` : "");
};
/** One line's quantity x unit price in minor units, or NaN while either is not a valid number. */
export function lineAmount(item: Pick<Item, "quantity" | "unitPrice">, currency: string): number {
const q = parseQty(item.quantity);
const p = scaled(item.unitPrice, minorDigits(currency));
if (q === null || p === null) return NaN;
return Number(divRound(q * p, 10n ** BigInt(QTY_DIGITS)));
}
/** The rate that applies to a line: its own, else the invoice's, else none. */
export function lineRate(item: Item, data: Pick<InvoiceData, "taxRate">): string {
const own = item.taxRate != null && String(item.taxRate).trim() !== "" ? item.taxRate : data.taxRate;
const v = parseRate(own ?? "");
return v === null ? "0" : rateText(v);
}
/**
* Subtotal, discount, taxes per rate and total, in minor units. Incomplete lines count as zero so
* a half-filled form still previews; validateInvoice reports what is missing. The discount is
* taken before tax and shared across lines in proportion to their amounts (largest remainder),
* so each tax rate is charged on its discounted share.
*/
export function computeTotals(data: InvoiceData): Totals {
const cur = data.currency;
const lines = data.items.map((it) => {
const a = lineAmount(it, cur);
return Number.isFinite(a) ? a : 0;
});
const S = lines.reduce((a, b) => a + b, 0);
let D = 0;
if (data.discount && String(data.discount.value ?? "").trim() !== "") {
if (data.discount.type === "percent") {
const p = parseRate(data.discount.value);
if (p !== null) D = Number(divRound(BigInt(S) * p, 100n * 10n ** BigInt(RATE_DIGITS)));
} else {
const m = toMinor(data.discount.value, cur);
if (Number.isFinite(m)) D = Math.min(m, S);
}
}
// Share the discount across lines: floors first, then leftover units to the largest remainders.
const alloc = lines.map(() => 0);
if (D > 0 && S > 0) {
const rems: { i: number; r: bigint }[] = [];
let left = D;
lines.forEach((l, i) => {
const num = BigInt(D) * BigInt(l);
alloc[i] = Number(num / BigInt(S));
left -= alloc[i];
rems.push({ i, r: num % BigInt(S) });
});
rems.sort((a, b) => (b.r > a.r ? 1 : b.r < a.r ? -1 : a.i - b.i));
for (const { i } of rems) {
if (left <= 0) break;
alloc[i] += 1;
left -= 1;
}
}
const byRate = new Map<string, bigint>();
data.items.forEach((it, i) => {
const rate = lineRate(it, data);
if (rate === "0") return;
byRate.set(rate, (byRate.get(rate) ?? 0n) + BigInt(lines[i] - alloc[i]));
});
const taxes: TaxLine[] = [...byRate].map(([rate, taxable]) => ({
rate,
amount: Number(divRound(taxable * (parseRate(rate) as bigint), 100n * 10n ** BigInt(RATE_DIGITS))),
}));
const tax = taxes.reduce((a, t) => a + t.amount, 0);
return { lines, subtotal: S, discount: D, taxes, tax, total: S - D + tax };
}
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
export function isDate(s: string): boolean {
if (!DATE_RE.test(s)) return false;
const d = new Date(`${s}T00:00:00Z`);
return !Number.isNaN(d.getTime()) && d.toISOString().slice(0, 10) === s;
}
/** Lines with nothing typed in them are left out of saved and printed invoices. */
export function isBlankItem(it: Item): boolean {
return !it.description.trim() && !String(it.unitPrice ?? "").trim();
}
/** Checks an invoice before it is saved or shared. Returns an error sentence, or null when it is fine. */
export function validateInvoice(data: InvoiceData): string | null {
const len = (s: unknown) => String(s ?? "").length;
const word = docWord(data).toLowerCase();
if (data.kind != null && data.kind !== "invoice" && data.kind !== "estimate") return 'kind must be "invoice" or "estimate".';
if (!CURRENCIES.includes(data.currency)) return `Currency must be one of ${CURRENCIES.join(", ")}.`;
if (!data.from.name.trim()) return "Add your business name.";
if (len(data.from.name) > 120 || len(data.to.name) > 120) return "Keep names under 120 characters.";
if (len(data.from.address) > 400 || len(data.to.address) > 400) return "Keep addresses under 400 characters.";
if (len(data.from.email) > 200 || len(data.to.email) > 200) return "Keep emails under 200 characters.";
if (len(data.from.taxId) > 60) return "Keep the tax id under 60 characters.";
if (!data.to.name.trim()) return `Add who the ${word} is for.`;
if (!data.number.trim()) return `Give the ${word} a number.`;
if (len(data.number) > 40) return `Keep the ${word} number under 40 characters.`;
if (!isDate(data.issueDate)) return "The issue date must look like 2026-10-18.";
if (data.dueDate && !isDate(data.dueDate)) return "The due date must look like 2026-11-01.";
if (data.dueDate && data.dueDate < data.issueDate) return `The ${data.kind === "estimate" ? "valid until" : "due"} date is before the issue date.`;
if (!data.items.length) return "Add at least one line.";
if (data.items.length > MAX_ITEMS) return `An invoice can have up to ${MAX_ITEMS} lines.`;
const digits = minorDigits(data.currency);
for (const [i, it] of data.items.entries()) {
const n = i + 1;
if (!it.description.trim()) return `Describe line ${n}.`;
if (len(it.description) > 500) return `Keep line ${n} under 500 characters.`;
if (parseQty(it.quantity) === null) return `The quantity on line ${n} must be a number, with up to 4 decimals.`;
if (scaled(it.unitPrice, digits) === null) {
return digits === 0
? `The price on line ${n} must be a whole number in ${data.currency}.`
: `The price on line ${n} must be a number like 120.00.`;
}
if (it.taxRate != null && String(it.taxRate).trim() !== "" && parseRate(it.taxRate) === null) return `The tax on line ${n} must be a percent from 0 to 100.`;
}
if (data.taxRate != null && String(data.taxRate).trim() !== "" && parseRate(data.taxRate) === null) return "Tax must be a percent from 0 to 100.";
if (data.discount && String(data.discount.value ?? "").trim() !== "") {
if (data.discount.type !== "percent" && data.discount.type !== "amount") return 'Discount type must be "percent" or "amount".';
if (data.discount.type === "percent" && parseRate(data.discount.value) === null) return "The discount must be a percent from 0 to 100.";
if (data.discount.type === "amount" && !Number.isFinite(toMinor(data.discount.value, data.currency))) return "The discount must be an amount like 50.00.";
}
if (len(data.notes) > 2000 || len(data.paymentInstructions) > 2000) return "Keep notes and payment instructions under 2,000 characters each.";
if (computeTotals(data).total > MAX_TOTAL) return "That total is too large.";
if (JSON.stringify(data).length > MAX_DATA) return `This ${word} is too large to share. Try a smaller logo.`;
return null;
}
/** Unpaid invoices past their due date are overdue; an estimate is always just an estimate. `today` is YYYY-MM-DD. */
export function statusOf(status: string, dueDate: string, today: string, kind?: Kind): Status {
if (kind === "estimate") return "estimate";
if (status === "paid") return "paid";
return dueDate && dueDate < today ? "overdue" : "unpaid";
}
/** YYYY-MM-DD for a Date in the local time zone. */
export function localDay(d = new Date()): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
export function addDays(day: string, n: number): string {
const d = new Date(`${day}T12:00:00`);
d.setDate(d.getDate() + n);
return localDay(d);
}
/** The number after this one, keeping its padding: INV-0009 -> INV-0010, 2026-7 -> 2026-8, A -> A-2. */
export function nextNumber(n: string): string {
const m = n.match(/^(.*?)(\d+)$/);
if (!m) return n.trim() ? `${n}-2` : "INV-0001";
const next = String(Number(m[2]) + 1).padStart(m[2].length, "0");
return m[1] + next;
}
const ALPHABET = "abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789";
/** A random id from an unambiguous alphabet; 20 characters is about 116 bits. */
export function randomId(length = 20): string {
const bytes = crypto.getRandomValues(new Uint8Array(length));
let out = "";
for (const b of bytes) out += ALPHABET[b % ALPHABET.length];
return out;
}
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