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/Editor.tsx
// The invoice editor: the form on the left, the paper on the right (a Preview switch on phones).
import { useMemo, useRef, useState, type ReactNode } from "react";
import { CURRENCIES, computeTotals, formatMoney, lineAmount, minorDigits, type InvoiceData, type Item, type Kind } from "../functions/lib/invoice";
import { blankItem, loadClients, loadDefaults, loadItems } from "./store";
import { perLineTax } from "./format";
import { Paper } from "./Paper";
import { toast } from "./ui";
const LOGO_MAX = 150_000;
/** Shrinks a picked image to a small PNG (or JPEG on white when a PNG stays too big) data URL. */
async function downscaleLogo(file: File): Promise<string> {
const url = URL.createObjectURL(file);
try {
const img = new Image();
img.src = url;
await img.decode();
const nw = img.naturalWidth || 600;
const nh = img.naturalHeight || 200;
let maxW = 640;
let maxH = 240;
for (let attempt = 0; attempt < 6; attempt++) {
const s = Math.min(1, maxW / nw, maxH / nh);
const canvas = document.createElement("canvas");
canvas.width = Math.max(1, Math.round(nw * s));
canvas.height = Math.max(1, Math.round(nh * s));
const ctx = canvas.getContext("2d")!;
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
let out = canvas.toDataURL("image/png");
if (out.length > LOGO_MAX) {
ctx.globalCompositeOperation = "destination-over";
ctx.fillStyle = "#fff";
ctx.fillRect(0, 0, canvas.width, canvas.height);
out = canvas.toDataURL("image/jpeg", 0.86);
}
if (out.length <= LOGO_MAX) return out;
maxW *= 0.7;
maxH *= 0.7;
}
throw new Error("too big");
} finally {
URL.revokeObjectURL(url);
}
}
function invalidMoney(value: string, currency: string) {
return value.trim() !== "" && !Number.isFinite(lineAmount({ quantity: "1", unitPrice: value }, currency));
}
export function InvoiceForm({ data, onChange, collapseBusiness = false }: { data: InvoiceData; onChange: (d: InvoiceData) => void; collapseBusiness?: boolean }) {
const set = (patch: Partial<InvoiceData>) => onChange({ ...data, ...patch });
const setFrom = (patch: Partial<InvoiceData["from"]>) => set({ from: { ...data.from, ...patch } });
const setTo = (patch: Partial<InvoiceData["to"]>) => set({ to: { ...data.to, ...patch } });
const setItem = (i: number, patch: Partial<Item>) => set({ items: data.items.map((it, j) => (j === i ? { ...it, ...patch } : it)) });
const [perLine, setPerLine] = useState(() => perLineTax(data));
const [bizOpen, setBizOpen] = useState(() => !collapseBusiness || !data.from.name.trim());
const logoInput = useRef<HTMLInputElement>(null);
const cur = data.currency;
const step = minorDigits(cur) ? `0.${"0".repeat(minorDigits(cur) - 1)}1` : "1";
const discount = data.discount ?? { type: "percent" as const, value: "" };
const estimate = data.kind === "estimate";
// Clients and lines billed before on this device, offered as you type.
const clients = useMemo(loadClients, []);
const savedItems = useMemo(() => loadItems().filter((it) => it.currency === cur), [cur]);
const setClientName = (name: string) => {
const known = clients.find((c) => c.name.toLowerCase() === name.trim().toLowerCase());
setTo(known && !data.to.email?.trim() && !data.to.address?.trim() ? { ...known, name } : { name });
};
const setDescription = (i: number, description: string) => {
const known = savedItems.find((it) => it.description.toLowerCase() === description.trim().toLowerCase());
setItem(i, known && !String(data.items[i].unitPrice).trim() ? { description, unitPrice: known.unitPrice } : { description });
};
/** Invoice or estimate. A number still at the default moves to the other kind's next number. */
const setKind = (kind: Kind) => {
if ((data.kind ?? "invoice") === kind) return;
const d = loadDefaults();
const number = data.number === (kind === "estimate" ? d.next : d.nextEstimate) ? (kind === "estimate" ? d.nextEstimate : d.next) : data.number;
const { kind: _, ...rest } = data;
onChange({ ...rest, ...(kind === "estimate" ? { kind } : {}), number });
};
const pickLogo = async (file: File | undefined) => {
if (!file) return;
try {
setFrom({ logo: await downscaleLogo(file) });
} catch {
toast("That image could not be used. Try a PNG or JPEG.");
}
};
const togglePerLine = (on: boolean) => {
setPerLine(on);
if (on) {
const { taxRate, ...rest } = data;
onChange({ ...rest, items: data.items.map((it) => ({ ...it, taxRate: it.taxRate || taxRate || "" })) });
} else {
const first = data.items.find((it) => it.taxRate?.trim())?.taxRate ?? "";
onChange({ ...data, taxRate: first, items: data.items.map(({ taxRate: _, ...it }) => it) });
}
};
return (
<div className="form">
<section className="form-section">
<div className="form-head">
<h2 className="form-title">Your business</h2>
{!bizOpen && <button className="btn btn-sm btn-quiet" type="button" onClick={() => setBizOpen(true)}>Edit</button>}
</div>
{bizOpen ? (
<div className="stack" style={{ ["--gap" as string]: "12px" }}>
<label className="field"><span>Business or your name</span>
<input className="input" value={data.from.name} onChange={(e) => setFrom({ name: e.target.value })} maxLength={120} placeholder="Sam Rivera Design" autoComplete="organization" />
</label>
<div className="two">
<label className="field"><span>Email</span>
<input className="input" type="email" value={data.from.email ?? ""} onChange={(e) => setFrom({ email: e.target.value })} maxLength={200} placeholder="sam@example.com" autoComplete="email" />
</label>
<label className="field"><span>Tax ID <em>optional</em></span>
<input className="input" value={data.from.taxId ?? ""} onChange={(e) => setFrom({ taxId: e.target.value })} maxLength={60} placeholder="VAT, EIN or GST number" />
</label>
</div>
<label className="field"><span>Address</span>
<textarea className="textarea textarea-sm" value={data.from.address ?? ""} onChange={(e) => setFrom({ address: e.target.value })} maxLength={400} rows={2} placeholder={"12 Harbor Street\nPortland, OR 97201"} autoComplete="street-address" />
</label>
<div className="logo-row">
{data.from.logo ? (
<>
<img className="logo-thumb" src={data.from.logo} alt="Your logo" />
<button className="btn btn-sm btn-line" type="button" onClick={() => logoInput.current?.click()}>Change logo</button>
<button className="btn btn-sm btn-quiet" type="button" onClick={() => setFrom({ logo: undefined })}>Remove</button>
</>
) : (
<button className="btn btn-sm btn-line" type="button" onClick={() => logoInput.current?.click()}>
<svg width="15" height="15" viewBox="0 0 20 20" aria-hidden="true"><rect x="3" y="4" width="14" height="12" rx="2" fill="none" stroke="currentColor" strokeWidth="1.5" /><path d="m4 14 4-4 3 3 2-2 3 3" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" /></svg>
Add logo
</button>
)}
<input ref={logoInput} type="file" accept="image/png,image/jpeg,image/webp,image/svg+xml" hidden onChange={(e) => { pickLogo(e.target.files?.[0]); e.target.value = ""; }} />
</div>
{collapseBusiness && <small className="hint">Saved on this device for your next invoice.</small>}
</div>
) : (
<button className="biz-summary" type="button" onClick={() => setBizOpen(true)}>
{data.from.logo && <img src={data.from.logo} alt="" />}
<span><strong>{data.from.name}</strong>{data.from.email && <span className="muted"> · {data.from.email}</span>}</span>
</button>
)}
</section>
<section className="form-section">
<h2 className="form-title">Bill to</h2>
<div className="stack" style={{ ["--gap" as string]: "12px" }}>
<div className="two">
<label className="field"><span>Client name</span>
<input className="input" value={data.to.name} onChange={(e) => setClientName(e.target.value)} maxLength={120} placeholder="Acme Inc." autoComplete="off" list={clients.length ? "saved-clients" : undefined} />
</label>
<label className="field"><span>Client email</span>
<input className="input" type="email" value={data.to.email ?? ""} onChange={(e) => setTo({ email: e.target.value })} maxLength={200} placeholder="billing@acme.com" autoComplete="off" />
</label>
</div>
<label className="field"><span>Client address</span>
<textarea className="textarea textarea-sm" value={data.to.address ?? ""} onChange={(e) => setTo({ address: e.target.value })} maxLength={400} rows={2} placeholder={"400 Market Street\nSan Francisco, CA 94105"} autoComplete="off" />
</label>
{clients.length > 0 && <datalist id="saved-clients">{clients.map((c) => <option key={c.name} value={c.name} />)}</datalist>}
</div>
</section>
<section className="form-section">
<h2 className="form-title">Details</h2>
<div className="segmented segmented-2 kind-switch" role="group" aria-label="Document type">
<button type="button" aria-pressed={!estimate} onClick={() => setKind("invoice")}>Invoice</button>
<button type="button" aria-pressed={estimate} onClick={() => setKind("estimate")}>Estimate</button>
</div>
<div className="grid-4">
<label className="field"><span>{estimate ? "Estimate no." : "Invoice no."}</span>
<input className="input" value={data.number} onChange={(e) => set({ number: e.target.value })} maxLength={40} />
</label>
<label className="field"><span>Currency</span>
<select className="select" value={cur} onChange={(e) => set({ currency: e.target.value })}>
{CURRENCIES.map((c) => <option key={c} value={c}>{c}</option>)}
</select>
</label>
<label className="field"><span>Issued</span>
<input className="input" type="date" value={data.issueDate} onChange={(e) => set({ issueDate: e.target.value })} required />
</label>
<label className="field"><span>{estimate ? "Valid until" : "Due"}</span>
<input className="input" type="date" value={data.dueDate} min={data.issueDate} onChange={(e) => set({ dueDate: e.target.value })} />
</label>
</div>
</section>
<section className="form-section">
<h2 className="form-title">Items</h2>
<ol className="items">
{data.items.map((it, i) => {
const amount = lineAmount(it, cur);
const badQty = it.quantity.trim() !== "" && !Number.isFinite(lineAmount({ quantity: it.quantity, unitPrice: "0" }, cur));
return (
<li className="item" key={i}>
<div className="item-top">
<input className="input" value={it.description} onChange={(e) => setDescription(i, e.target.value)} maxLength={500}
placeholder={i === 0 ? "Website design, 10 hours of consulting..." : "Description"} aria-label={`Line ${i + 1} description`}
list={savedItems.length ? "saved-items" : undefined} />
<button className="btn btn-quiet btn-icon btn-sm" type="button" aria-label={`Remove line ${i + 1}`}
onClick={() => set({ items: data.items.length > 1 ? data.items.filter((_, j) => j !== i) : [perLine ? { ...blankItem(), taxRate: "" } : blankItem()] })}>
<svg width="16" height="16" viewBox="0 0 20 20" aria-hidden="true"><path d="M5 5l10 10M15 5 5 15" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" /></svg>
</button>
</div>
<div className={`item-nums ${perLine ? "with-tax" : ""}`}>
<label className="mini"><span>Qty</span>
<input className="input input-sm" inputMode="decimal" value={it.quantity} onChange={(e) => setItem(i, { quantity: e.target.value })} aria-invalid={badQty || undefined} aria-label={`Line ${i + 1} quantity`} />
</label>
<label className="mini"><span>Price</span>
<input className="input input-sm" inputMode="decimal" value={it.unitPrice} onChange={(e) => setItem(i, { unitPrice: e.target.value })} placeholder={step === "1" ? "0" : "0.00"} aria-invalid={invalidMoney(it.unitPrice, cur) || undefined} aria-label={`Line ${i + 1} price`} />
</label>
{perLine && (
<label className="mini"><span>Tax %</span>
<input className="input input-sm" inputMode="decimal" value={it.taxRate ?? ""} onChange={(e) => setItem(i, { taxRate: e.target.value })} placeholder="0" aria-label={`Line ${i + 1} tax percent`} />
</label>
)}
<span className={`item-amount ${Number.isFinite(amount) ? "" : "faint"}`}>{formatMoney(Number.isFinite(amount) ? amount : 0, cur)}</span>
</div>
</li>
);
})}
</ol>
{savedItems.length > 0 && <datalist id="saved-items">{savedItems.map((it) => <option key={it.description} value={it.description} />)}</datalist>}
<button className="btn btn-sm btn-line add-line" type="button" onClick={() => set({ items: [...data.items, perLine ? { ...blankItem(), taxRate: "" } : blankItem()] })}>
<svg width="14" height="14" viewBox="0 0 20 20" aria-hidden="true"><path d="M10 4v12M4 10h12" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /></svg>
Add line
</button>
</section>
<section className="form-section">
<h2 className="form-title">Tax and discount</h2>
<div className="two tax-discount">
<div className="field">
<label className="label" htmlFor="tax-rate">Tax</label>
{perLine ? <p className="hint tax-note">Set on each line above.</p> : (
<div className="suffix-input">
<input id="tax-rate" className="input" inputMode="decimal" value={data.taxRate ?? ""} onChange={(e) => set({ taxRate: e.target.value })} placeholder="0" />
<span>%</span>
</div>
)}
<label className="check small"><input type="checkbox" checked={perLine} onChange={(e) => togglePerLine(e.target.checked)} /> Different rate per line</label>
</div>
<div className="field">
<label className="label" htmlFor="discount">Discount</label>
<div className="discount-row">
<div className="segmented segmented-2" role="group" aria-label="Discount type">
<button type="button" aria-pressed={discount.type === "percent"} aria-label="Percent" onClick={() => set({ discount: { ...discount, type: "percent" } })}>%</button>
<button type="button" aria-pressed={discount.type === "amount"} aria-label={`Amount in ${cur}`} onClick={() => set({ discount: { ...discount, type: "amount" } })}>{cur}</button>
</div>
<input id="discount" className="input" inputMode="decimal" value={discount.value} onChange={(e) => set({ discount: { ...discount, value: e.target.value } })} placeholder="0" />
</div>
</div>
</div>
</section>
<section className="form-section">
<label className="field"><span className="form-title">Payment instructions</span>
<textarea className="textarea textarea-sm" value={data.paymentInstructions ?? ""} onChange={(e) => set({ paymentInstructions: e.target.value })} maxLength={2000} rows={3}
placeholder={"Bank transfer to Sam Rivera\nIBAN DE89 3704 0044 0532 0130 00\nor pay at https://..."} />
<small>Your client pays you directly. Links you add here can be clicked on the shared invoice.</small>
</label>
<label className="field" style={{ marginTop: 16 }}><span className="form-title">Notes</span>
<textarea className="textarea textarea-sm" value={data.notes ?? ""} onChange={(e) => set({ notes: e.target.value })} maxLength={2000} rows={2} placeholder="Thank you for your business." />
</label>
</section>
</div>
);
}
/**
* Form and paper side by side, with the actions above the paper and at the end of the form; on phones
* an Edit / Preview switch (with `aside` next to it) and a bar with the total and actions.
*/
export function Editor({ data, onChange, actions, aside, error, paidAt, collapseBusiness }: {
data: InvoiceData;
onChange: (d: InvoiceData) => void;
actions: ReactNode;
aside?: ReactNode;
error?: string | null;
paidAt?: number | null;
collapseBusiness?: boolean;
}) {
const [view, setView] = useState<"edit" | "preview">("edit");
const root = useRef<HTMLDivElement>(null);
const total = computeTotals(data).total;
return (
<div className="editor" data-view={view} ref={root} tabIndex={-1} role="region" aria-label="Invoice editor">
<div className="editor-switch">
<div className="segmented segmented-2" role="group" aria-label="Editor view">
<button type="button" aria-pressed={view === "edit"} onClick={() => setView("edit")}>Edit</button>
<button type="button" aria-pressed={view === "preview"} onClick={() => { setView("preview"); root.current?.scrollIntoView(); }}>Preview</button>
</div>
{aside}
</div>
<div className="editor-grid">
<div className="editor-form card">
<InvoiceForm data={data} onChange={onChange} collapseBusiness={collapseBusiness} />
<div className="form-end">
{error && <p className="notice notice-error" role="alert">{error}</p>}
<div className="row">{actions}</div>
</div>
</div>
<div className="editor-preview">
<div className="preview-sticky">
<div className="editor-actions">
{error && <p className="notice notice-error" role="alert">{error}</p>}
<div className="row">{actions}</div>
</div>
<div className="preview-scroll"><Paper data={data} paidAt={paidAt} /></div>
</div>
</div>
</div>
<div className="editor-bar">
{error && <p className="notice notice-error" role="alert">{error}</p>}
<div className="editor-bar-row">
<span className="editor-bar-total"><span className="muted">Total</span> <strong>{formatMoney(total, data.currency)}</strong></span>
<div className="row">{actions}</div>
</div>
</div>
</div>
);
}
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