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/InvoicePage.tsx
// A shared invoice or estimate. /i/<id> is the client's view: the paper, its status, the PDF and a
// pay link when the owner gave one. /i/<id>/admin#k=<key> is the owner's: mark it paid, email it,
// edit it, start the next one from a copy.
import { useEffect, useState, type ReactNode } from "react";
import { get, set, setMany, useQuery, serverTimestamp } from "@bounded-sh/client";
import { computeTotals, docWord, formatMoney, statusOf, localDay, validateInvoice, type InvoiceData, type Kind, type Status } from "../functions/lib/invoice";
import { me, explain } from "./lib/bounded";
import { navigate } from "./lib/router";
import { toast, share, copy, sharesNatively, Menu } from "./ui";
import { Paper } from "./Paper";
import { Editor } from "./Editor";
import { cleanData, clientEmail, fmtDate } from "./format";
import { copyAsDraft, draftStarted, keyFor, loadDraft, saveDraft } from "./store";
import { remember } from "./NewInvoice";
import { PageSkeleton } from "./Skeleton";
interface InvoiceDoc { data: string; total: number; currency: string; status: string; paidAt?: number | null; createdAt: number }
function keyFromHash(): string | null {
const m = location.hash.match(/(?:^#|&)k=([A-Za-z0-9]{20,64})/);
return m ? m[1] : null;
}
/** Back to the landing page, scrolled to Your invoices. */
function toYourInvoices() {
navigate("/");
requestAnimationFrame(() => requestAnimationFrame(() => document.getElementById("your-invoices")?.scrollIntoView()));
}
function Problem({ title, text, children }: { title: string; text: string; children: ReactNode }) {
return (
<div className="wrap page-pad">
<div className="empty-state problem enter">
<img className="art" src="/art/card-invoice.webp" alt="" width="180" height="149" />
<h1>{title}</h1>
<p>{text}</p>
{children}
</div>
</div>
);
}
export function InvoicePage({ id, admin }: { id: string; admin: boolean }) {
const [state, setState] = useState<"opening" | "ready" | "missing" | "badkey" | "error">("opening");
const [error, setError] = useState("");
const [key, setKey] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
const done = (s: typeof state) => { if (!cancelled) setState(s); };
(async () => {
try {
const uid = await me();
if (!(await get(`invoices/${id}/joins/${uid}`))) {
try {
await set(`invoices/${id}/joins/${uid}`, { at: serverTimestamp() });
} catch {
return done("missing");
}
}
if (admin) {
const k = keyFromHash() || keyFor(id);
if (!k) return done("badkey");
if (!(await get(`invoices/${id}/owners/${uid}`))) {
try {
// Presenting the private key makes this device an owner (the policy checks it).
await setMany([
{ path: `invoiceclaims/${id}/c/${uid}`, document: { key: k } },
{ path: `invoices/${id}/owners/${uid}`, document: { at: serverTimestamp() } },
]);
} catch {
return done("badkey");
}
}
if (!cancelled) setKey(k);
}
done("ready");
} catch (err) {
if (!cancelled) { setError(explain(err)); setState("error"); }
}
})();
return () => { cancelled = true; };
}, [id, admin]);
if (state === "opening") return <PageSkeleton />;
if (state === "missing") return (
<Problem title="This invoice does not exist." text="Check the link you were sent. Invoice links are long, and every character counts.">
<a className="btn" href="/" onClick={(e) => { e.preventDefault(); navigate("/"); }}>Make a free invoice</a>
</Problem>
);
if (state === "badkey") return (
<Problem title="This private link does not match." text="The key at the end of the link is not this invoice's. Copy the whole private link again, including the part after #k=.">
<a className="btn btn-line" href={`/i/${id}`} onClick={(e) => { e.preventDefault(); navigate(`/i/${id}`); }}>View the invoice</a>
</Problem>
);
if (state === "error") return (
<Problem title="The invoice did not open." text={error}>
<button className="btn" type="button" onClick={() => location.reload()}>Try again</button>
</Problem>
);
return <InvoiceView id={id} ownerKey={admin ? key : null} />;
}
const LABEL: Record<Status, string> = { paid: "Paid", overdue: "Overdue", unpaid: "Unpaid", estimate: "Estimate" };
const CHIP: Record<Status, string> = { paid: "chip-free", overdue: "chip-overdue", unpaid: "", estimate: "chip-cobalt" };
/** The first web address in the payment instructions: the client's Pay button. */
function payLink(text?: string): string | null {
return text?.match(/https?:\/\/[^\s<>"]+[^\s<>".,;:!?)\]]/)?.[0] ?? null;
}
const Icon = {
check: <svg width="16" height="16" viewBox="0 0 20 20" aria-hidden="true"><path d="m4.5 10.5 3.5 3.5 7.5-8" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" /></svg>,
download: <svg width="16" height="16" viewBox="0 0 20 20" aria-hidden="true"><path d="M10 3v10m0 0 4-4m-4 4L6 9M4 16h12" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" /></svg>,
mail: <svg width="16" height="16" viewBox="0 0 20 20" aria-hidden="true"><rect x="2.5" y="4.5" width="15" height="11" rx="2" fill="none" stroke="currentColor" strokeWidth="1.5" /><path d="m3 5.5 7 5.5 7-5.5" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" /></svg>,
more: <svg width="18" height="18" viewBox="0 0 20 20" aria-hidden="true"><circle cx="4.5" cy="10" r="1.6" fill="currentColor" /><circle cx="10" cy="10" r="1.6" fill="currentColor" /><circle cx="15.5" cy="10" r="1.6" fill="currentColor" /></svg>,
back: <svg width="16" height="16" viewBox="0 0 20 20" aria-hidden="true"><path d="M12 15 7 10l5-5" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" /></svg>,
};
function InvoiceView({ id, ownerKey }: { id: string; ownerKey: string | null }) {
const { data: doc } = useQuery(`invoices/${id}`) as { data: InvoiceDoc | null | undefined };
const [busy, setBusy] = useState<string | null>(null);
const [editing, setEditing] = useState<InvoiceData | null>(null);
const [editError, setEditError] = useState<string | null>(null);
const data = (() => {
try { return doc ? (JSON.parse(doc.data) as InvoiceData) : null; } catch { return null; }
})();
useEffect(() => { if (data) document.title = `${docWord(data)} ${data.number} from ${data.from.name} | Invoice`; }, [data?.number, data?.from.name, data?.kind]);
// The owner's device keeps the invoice in "Your invoices", with the key.
useEffect(() => { if (ownerKey && data) remember(id, ownerKey, data); }, [ownerKey, doc?.data]);
if (doc === undefined) return <PageSkeleton />;
if (!doc || !data) return (
<Problem title="This invoice could not be read." text="Its saved data is damaged. Ask the person who sent it for a new link.">
<a className="btn" href="/" onClick={(e) => { e.preventDefault(); navigate("/"); }}>Make a free invoice</a>
</Problem>
);
const word = docWord(data);
const estimate = data.kind === "estimate";
const cur = data.currency;
const total = computeTotals(data).total;
const status = statusOf(doc.status, data.dueDate, localDay(), data.kind);
const paidAt = doc.status === "paid" && !estimate ? doc.paidAt ?? null : null;
const paidDay = paidAt ? fmtDate(localDay(new Date(paidAt * 1000))) : "";
const clientUrl = `${location.origin}/i/${id}`;
const privateUrl = ownerKey ? `${location.origin}/i/${id}/admin#k=${ownerKey}` : "";
const mineKey = !ownerKey ? keyFor(id) : null;
const pay = !ownerKey && (status === "unpaid" || status === "overdue") ? payLink(data.paymentInstructions) : null;
const pdf = async () => {
setBusy("pdf");
try {
const { downloadPdf } = await import("./pdf");
await downloadPdf(data, { paidAt });
} catch (err) {
toast(`The PDF could not be made: ${explain(err)}`);
} finally {
setBusy(null);
}
};
const mark = async (paid: boolean) => {
setBusy("status");
try {
await set(`invoices/${id}`, paid ? { status: "paid", paidAt: serverTimestamp() } : { status: "unpaid", paidAt: null });
toast(paid ? "Marked as paid. Your client sees it too." : "Marked as unpaid.");
} catch (err) {
toast(explain(err));
} finally {
setBusy(null);
}
};
/** Puts a copy in the editor on the main page, with the next number and today's date. */
const startFrom = (kind: Kind) => {
if (draftStarted(loadDraft()) && !window.confirm("Replace what is in the editor with this copy?")) return;
saveDraft(copyAsDraft(data, kind));
navigate("/");
toast(estimate && kind === "invoice" ? "Invoice made from the estimate. Check it, then send it." : `Copied with the next number. Check it, then send it.`);
};
const save = async () => {
if (!editing) return;
const next = cleanData(editing);
const err = validateInvoice(next);
if (err) return setEditError(err);
setBusy("save");
try {
await set(`invoices/${id}`, { data: JSON.stringify(next), total: computeTotals(next).total, currency: next.currency });
setEditing(null);
toast("Saved. Your client sees the change right away.");
} catch (e) {
setEditError(explain(e));
} finally {
setBusy(null);
}
};
if (ownerKey && editing) {
return (
<div className="wrap page-pad-sm enter">
<div className="inv-edit-head">
<p className="eyebrow">Editing</p>
<h1 className="inv-title">{word} {data.number}</h1>
</div>
<Editor data={editing} onChange={(d) => { setEditing(d); setEditError(null); }} error={editError} paidAt={paidAt} actions={
<>
<button className="btn" type="button" onClick={save} disabled={busy !== null && busy !== "save"} aria-busy={busy === "save" || undefined}><span>Save changes</span></button>
<button className="btn btn-quiet" type="button" onClick={() => { setEditing(null); setEditError(null); }} disabled={busy !== null}>Cancel</button>
</>
} />
</div>
);
}
const due = estimate
? data.dueDate ? `valid until ${fmtDate(data.dueDate)}` : "estimate"
: status === "paid" ? `paid ${paidDay}` : data.dueDate ? `due ${fmtDate(data.dueDate)}` : "due on receipt";
return (
<div className="inv enter">
<header className="inv-head">
<div className="wrap inv-head-inner">
<div className="inv-head-text">
{ownerKey
? <a className="inv-back" href="/#your-invoices" onClick={(e) => { e.preventDefault(); toYourInvoices(); }}>{Icon.back}Your invoices</a>
: <p className="eyebrow">{word} from {data.from.name}</p>}
<h1 className="inv-title">
{formatMoney(total, cur)}
<span className={`chip ${CHIP[status]}`}>{LABEL[status]}{status === "paid" && paidDay ? ` ${paidDay}` : ""}</span>
</h1>
<p className="muted inv-sub">{data.number} · to {data.to.name} · {due}</p>
</div>
{ownerKey ? (
<div className="inv-actions">
{estimate
? <button className="btn" type="button" onClick={() => startFrom("invoice")}>Make invoice</button>
: doc.status === "paid"
? <button className="btn btn-line" type="button" onClick={() => mark(false)} disabled={busy !== null && busy !== "status"} aria-busy={busy === "status" || undefined}><span>Mark as unpaid</span></button>
: <button className="btn" type="button" onClick={() => mark(true)} disabled={busy !== null && busy !== "status"} aria-busy={busy === "status" || undefined}>{Icon.check}<span>Mark as paid</span></button>}
<a className="btn btn-line" href={clientEmail(data, clientUrl, status === "paid" ? "receipt" : status === "overdue" ? "remind" : "send", paidDay)}>
{Icon.mail}{status === "paid" ? "Email receipt" : status === "overdue" ? "Send reminder" : "Email client"}
</a>
<button className="btn btn-line" type="button" onClick={() => setEditing({ ...data })} disabled={busy !== null}>Edit</button>
<button className="btn btn-line" type="button" onClick={pdf} disabled={busy !== null && busy !== "pdf"} aria-busy={busy === "pdf" || undefined}>{Icon.download}<span>Download PDF</span></button>
<Menu label={Icon.more} title="More actions">
<button type="button" onClick={() => startFrom(data.kind ?? "invoice")}>Duplicate</button>
<button type="button" onClick={() => copy(privateUrl, "Private link copied. Keep it to yourself.")}>Copy private link</button>
</Menu>
</div>
) : (
<div className="inv-actions">
{pay && <a className="btn" href={pay} target="_blank" rel="noopener noreferrer">Pay now</a>}
<button className={`btn ${pay ? "btn-line" : ""}`} type="button" onClick={pdf} disabled={busy !== null && busy !== "pdf"} aria-busy={busy === "pdf" || undefined}>{Icon.download}<span>Download PDF</span></button>
</div>
)}
</div>
</header>
<div className="wrap inv-body">
{ownerKey && (
<div className="card owner-card">
<div className="field">
<span className="label">Link for your client</span>
<div className="link-row">
<code className="link-code">{clientUrl}</code>
<button className="btn btn-sm" type="button" onClick={() => share(clientUrl, `${word} ${data.number}`)}>{sharesNatively() ? "Share" : "Copy"}</button>
</div>
</div>
<p className="hint">This page is your private link. Bookmark it, or <button className="linklike" type="button" onClick={() => copy(privateUrl, "Private link copied. Keep it to yourself.")}>copy it</button>, to open this {word.toLowerCase()} on any device. Keep it to yourself.</p>
</div>
)}
{mineKey && (
<p className="notice owner-note">
You sent this {word.toLowerCase()}. <a href={`/i/${id}/admin#k=${mineKey}`} onClick={(e) => { e.preventDefault(); navigate(`/i/${id}/admin#k=${mineKey}`); }}>Manage it</a> to {estimate ? "edit it" : "mark it paid or edit it"}.
</p>
)}
<div className="inv-paper">
<Paper data={data} paidAt={paidAt} />
</div>
{!ownerKey && (
<p className="hint center made-with">Made with <a href="/">Invoice</a>, a free invoice maker with no sign-up.</p>
)}
</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