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