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
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
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