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/Paper.tsx
// The invoice as a sheet of paper, laid out like the PDF and scaled to fit its column. On a narrow
// screen a scaled page would be too small to read, so the sheet reflows at full text size instead.
import { Fragment, useLayoutEffect, useRef, useState, type ReactNode } from "react";
import { computeTotals, docWord, formatMoney, lineAmount, lineRate, localDay, isBlankItem, type InvoiceData } from "../functions/lib/invoice";
import { fmtDate, fmtQty, paperSize, perLineTax } from "./format";

const SIZES = { Letter: [816, 1056], A4: [794, 1123] } as const;
/** Below this column width the sheet reflows instead of shrinking. */
const FLUID_BELOW = 600;

/** Text with its web addresses as links, so a payment link is one tap away. */
function linked(text: string): ReactNode {
  return text.split(/(https?:\/\/[^\s<>"]+[^\s<>".,;:!?)\]])/g).map((part, i) =>
    i % 2 ? <a key={i} href={part} target="_blank" rel="noopener noreferrer">{part}</a> : part);
}

export function Paper({ data, paidAt }: { data: InvoiceData; paidAt?: number | null }) {
  const [w, h] = SIZES[paperSize()];
  const fit = useRef<HTMLDivElement>(null);
  const [width, setWidth] = useState(0);
  useLayoutEffect(() => {
    const el = fit.current;
    if (!el) return;
    const measure = () => setWidth(el.clientWidth);
    measure();
    const ro = new ResizeObserver(measure);
    ro.observe(el);
    return () => ro.disconnect();
  }, []);
  const fluid = width > 0 && width < FLUID_BELOW;
  const zoom = Math.min(1, width / w);

  const cur = data.currency;
  const money = (m: number) => formatMoney(m, cur);
  const items = data.items.filter((it) => !isBlankItem(it));
  const shown = { ...data, items };
  const t = computeTotals(shown);
  const showTax = perLineTax(shown);
  const estimate = data.kind === "estimate";
  const paidDay = paidAt && !estimate ? fmtDate(localDay(new Date(paidAt * 1000))) : null;
  const ph = (value: string | undefined, placeholder: string) => (value?.trim() ? value : <span className="ph">{placeholder}</span>);
  const unit = (it: (typeof items)[number]) => money(lineAmount({ quantity: "1", unitPrice: it.unitPrice }, cur) || 0);

  return (
    <div className="paper-fit" ref={fit}>
      <article
        className={`sheet ${fluid ? "sheet-fluid" : ""}`}
        style={fluid ? undefined : { width: w, minHeight: h, zoom: zoom || undefined, visibility: width ? "visible" : "hidden" }}
        aria-label={`${docWord(data)} preview`}
      >
        <header className="sheet-head">
          <div className="sheet-from">
            {data.from.logo && <img className="sheet-logo" src={data.from.logo} alt="" />}
            <p className="sheet-name">{ph(data.from.name, "Your business")}</p>
            <div className="sheet-muted">
              {data.from.address && <p className="pre">{data.from.address}</p>}
              {data.from.email && <p>{data.from.email}</p>}
              {data.from.taxId && <p>Tax ID: {data.from.taxId}</p>}
            </div>
          </div>
          <div className="sheet-meta">
            <p className="sheet-title">{docWord(data).toUpperCase()}</p>
            <dl>
              <dt>{docWord(data)} no.</dt><dd>{data.number}</dd>
              <dt>Issue date</dt><dd>{fmtDate(data.issueDate)}</dd>
              {estimate
                ? data.dueDate && <><dt>Valid until</dt><dd>{fmtDate(data.dueDate)}</dd></>
                : <><dt>Due date</dt><dd>{data.dueDate ? fmtDate(data.dueDate) : "On receipt"}</dd></>}
              {paidDay && <><dt>Paid on</dt><dd className="sheet-paid">{paidDay}</dd></>}
            </dl>
          </div>
        </header>

        <section className="sheet-bill">
          <div>
            <p className="sheet-label">{estimate ? "For" : "Bill to"}</p>
            <p className="sheet-client">{ph(data.to.name, "Client name")}</p>
            <div className="sheet-muted">
              {data.to.address && <p className="pre">{data.to.address}</p>}
              {data.to.email && <p>{data.to.email}</p>}
            </div>
          </div>
          <div className="sheet-due">
            <p className={`sheet-label ${paidDay ? "sheet-paid" : ""}`}>{paidDay ? "Paid" : estimate ? "Estimate total" : "Amount due"}</p>
            <p className="sheet-amount">{money(t.total)}</p>
          </div>
        </section>

        <table className="sheet-table">
          <thead>
            <tr><th>Description</th><th className="num qp">Qty</th><th className="num qp">Price</th>{showTax && <th className="num qp">Tax</th>}<th className="num">Amount</th></tr>
          </thead>
          <tbody>
            {items.length ? items.map((it, i) => (
              <tr key={i}>
                <td className="pre">
                  {it.description || <span className="ph">Item</span>}
                  <span className="sheet-line-meta">{fmtQty(it.quantity)} × {unit(it)}{showTax ? `, tax ${lineRate(it, shown)}%` : ""}</span>
                </td>
                <td className="num qp">{fmtQty(it.quantity)}</td>
                <td className="num qp">{unit(it)}</td>
                {showTax && <td className="num qp sheet-muted">{lineRate(it, shown)}%</td>}
                <td className="num">{money(t.lines[i])}</td>
              </tr>
            )) : (
              <tr><td className="ph">Your lines show here</td><td className="num qp ph">1</td><td className="num qp ph">{money(0)}</td><td className="num ph">{money(0)}</td></tr>
            )}
          </tbody>
        </table>

        <div className="sheet-totals">
          <dl>
            <dt>Subtotal</dt><dd>{money(t.subtotal)}</dd>
            {t.discount > 0 && <><dt>{data.discount?.type === "percent" ? `Discount (${data.discount.value}%)` : "Discount"}</dt><dd>-{money(t.discount)}</dd></>}
            {t.taxes.map((x) => <Fragment key={x.rate}><dt>Tax ({x.rate}%)</dt><dd>{money(x.amount)}</dd></Fragment>)}
            <dt className="sheet-total">Total</dt><dd className="sheet-total">{money(t.total)}</dd>
          </dl>
        </div>

        {data.paymentInstructions?.trim() && (
          <section className="sheet-block"><p className="sheet-label">Payment instructions</p><p className="pre">{linked(data.paymentInstructions)}</p></section>
        )}
        {data.notes?.trim() && (
          <section className="sheet-block"><p className="sheet-label">Notes</p><p className="pre">{linked(data.notes)}</p></section>
        )}
      </article>
    </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