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/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
333971bf96 · 5 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