Invoice

Last updated on

An invoice nothing signs: the artifact with its React layer, the composition branded from its own tokens, and two samples — one page, and one whose table runs past two breaks.

An invoice is a demand for money already owed, not an agreement, so nothing about it needs a signature: the artifact declares no signature slot and the composition draws no signing block. It also brands itself: the masthead's mark and accent come from the document's own tokens rather than a prop a caller fills in, so a tenant who passes their own token set replaces both at once.

Preview

Rendering live preview requires JavaScript.

Installation

npx shadcn@4 add @paradoc/invoice

Or, with the Paradoc CLI, which writes the namespace into components.json for you:

npx paradoc-cli add invoice

Installs to artifacts/paradoc/invoice.artifact.ts, artifacts/paradoc/invoice.data.ts, and components/paradoc/invoice.tsx and brings along document, field, image, page-number, party, priced-line-items, section, table, text, totals.

Usage

A block has no single call site — it is a whole document — so this shows the composition and its sample data exactly as installing invoice gives them to you, both real and currently shipping.

Composition

components/paradoc/invoice.tsx

/** @jsxRuntime classic */import React from "react";import type { Form } from "@paradoc/types";import { Document } from "@/components/paradoc/document";import {  markDocumentRoot,  useDocumentTokens,  type DocumentData,  type DocumentTokensInput,  type FormatOptions,  type PageFurniture,} from "@paradoc/react";import { Field } from "@/components/paradoc/field";import { Image } from "@/components/paradoc/image";import { PageNumber } from "@/components/paradoc/page-number";import { Party } from "@/components/paradoc/party";import { Section } from "@/components/paradoc/section";import { Table } from "@/components/paradoc/table";import { Text } from "@/components/paradoc/text";import { Totals } from "@/components/paradoc/totals";import { invoiceForm, invoiceTokens } from "../../artifacts/paradoc/invoice.artifact";/** * The invoice, composed from the components. * * One tree, built only from `Document`, `Section`, `Text`, `Party`, `Field`, * `Image`, `Table` and `Totals`, that carries no copy of any label, format, or * total and sizes none of its own text. It is the same tree the preview * paginates and the PDF renders; `invoiceFurniture` numbers its pages in both. * * **The parties are `Party` blocks, and what a party record does not carry * sits beside them.** The issuer and the customer are the artifact's parties, * so each is named by its own block. Their addresses and the customer's * accounts contact are fields: the party schema models the party itself, not * where it is or who in it the invoice is addressed to, so those stay `Field`s * beside the block rather than members of the record. * * **There is no `Signature`.** An invoice is a demand for payment rather than * an agreement, so the artifact declares no signature slot and the composition * draws no signing block. What the document ends on is the terms. * * **The branding is a token set, not markup.** The accent reaches the section * headings and the emphasised total through `Document`; the mark is read from * the same tokens rather than from a prop, so a tenant who passes their own set * replaces both at once. Bytes resolve to a `data:` URI, so neither the browser * nor the engine fetches anything and the composition needs no image wiring. * * Like the purchase order, this composition does not wrap itself in a `Bundle`: * it renders bare so a caller who puts it inside a packet supplies the bundle. *//** Rendered size of the issuer's mark, in CSS pixels. The engine needs both stated. */const MARK_SIZE_PX = 40;/** * The masthead's organization mark. * * It is a component of its own so it can read the document's tokens: a hook * called in `InvoiceDocument`'s own body would sit above the `Document` that * supplies them and would see the package's defaults instead. A tenant who * passes no `logo` gets a masthead with no mark rather than someone else's. */function IssuerMark() {  const { logo } = useDocumentTokens();  if (logo === undefined) return null;  return (    // Decorative: the issuer is named beside it. The mark sits in the row    // rather than above it so it costs the page no height.    <Image      keepId="logo"      src={logo}      alt=""      width={MARK_SIZE_PX}      height={MARK_SIZE_PX}      className="h-10 w-10"    />  );}/** * The invoice's page furniture: the page number and the count, in the footer. * * Hand the same object to `<Pages furniture>` and to `renderPdf`, so the * preview and the PDF number the same pages. It is drawn inside the margin, so * the page plan and the page count are what they are without it. */export const invoiceFurniture: PageFurniture = { footer: <PageNumber /> };export interface InvoiceDocumentProps {  /** The invoice data to render. */  data: DocumentData;  /** Overrides the artifact, for tests that vary it. */  artifact?: Form;  /** How values the serializer registry does not cover are formatted, and which registry (US or EU) covers the rest. */  format?: FormatOptions;  /** Tenant branding. Defaults to the issuer's own accent and mark. */  tokens?: DocumentTokensInput;  /** Application-owned classes applied to the document root. */  className?: string;}/** * The composition's content, below the `Document` that supplies its tokens: the * mark reads them, and a hook above the `Document` would see the package's * defaults. Nothing here sizes its own text: the title is a `Text` heading, the * parties are `Party` blocks, and every value inherits the document's body * size, so the whole invoice follows `typography`. */function InvoiceBody({ artifact }: { artifact: Form }) {  return (    <>      <Section id="masthead" className="flex flex-row justify-between gap-8 border-b border-neutral-800 pb-4">        <div className="flex basis-1/2 flex-row gap-3">          <IssuerMark />          <div className="flex flex-col gap-1">            <Text keepId="title" role="heading" as="span">              {artifact.title}            </Text>            <Party role="issuer" label={false} />            <Field path="issuerEmail" label={false} />          </div>        </div>        <div className="flex basis-1/3 flex-col gap-2">          <Field path="invoiceNumber" />          <Field path="issuedOn" />          <Field path="dueOn" />          <Field path="currency" />        </div>      </Section>      <Section id="parties" title="Addresses">        <div className="flex flex-row gap-10">          <div className="flex basis-1/2 flex-col gap-2">            <Field path="issuerAddress" label="From" />            <Field path="purchaseOrderNumber" />          </div>          {/* The customer is a party, named from its own record. Its accounts              contact and its address are not members of that record, so they              are fields beside it. */}          <div className="flex basis-1/2 flex-col gap-1">            <Party role="customer" className="flex flex-col gap-0.5 font-medium" />            <Field path="customerContact" label={false} />            <Field path="customerAddress" label={false} />          </div>        </div>      </Section>      <Section id="line-items" title="Billed items" className="flex flex-col gap-3">        <Table          path="lineItems"          id="line-items"          columns={[            { field: "description", width: "basis-1/2" },            { field: "quantity", header: "Qty", width: "basis-1/12", align: "right" },            { field: "unit", width: "basis-1/12" },            { field: "unitPrice", header: "Unit price", width: "basis-1/6", align: "right" },            { field: "amount", width: "basis-1/6", align: "right" },          ]}        />        <Totals          rows={[            { def: "subtotal" },            { def: "tax", ratePath: "taxRatePercent" },            { def: "total", emphasis: true },          ]}        />      </Section>      <Section id="terms" title="Payment" className="flex flex-col gap-2">        <Field path="paymentTerms" label={false} />        <Field path="notes" label={false} />      </Section>    </>  );}/** * The composed invoice. * * Exported by name and as the module's default. The default is what a React * layer binds to when the renderer imports the module the layer's path names, * which is the convention a composition module follows. */export function InvoiceDocument({  data,  artifact = invoiceForm,  format,  tokens = invoiceTokens,  className,}: InvoiceDocumentProps) {  return (    <Document artifact={artifact} data={data} format={format} tokens={tokens} id="invoice" className={className}>      <InvoiceBody artifact={artifact} />    </Document>  );}markDocumentRoot(InvoiceDocument);export default InvoiceDocument;

Sample data

artifacts/paradoc/invoice.data.ts

import type { RuntimeParty } from "@paradoc/types";import type { DocumentData } from "@paradoc/react";import { computeLineAmounts, type LineItemInput } from "@/artifacts/paradoc/line-items";/** * Sample data for the invoice. * * Two sizes, both measured against the one page of content `Paper` exposes as * `PAGE_CONTENT_HEIGHT_PX` (960 pixels: US Letter at 96 dpi less both margins), * for the reason the proposal carries two: a document that only ever overflows * says nothing about the single-page case, and a document that never overflows * says nothing about a break. * * - `shortInvoiceData` is 5 rows and sits inside one page. * - `overflowInvoiceData` is 48 rows and runs past two breaks, so the table's *   header is copied onto more than one continued page. * * The row counts are asserted in `@paradoc/react`'s `tests/invoice-artifact.test.ts`, so shrinking * either set fails rather than quietly breaking the budget. */const CURRENCY = "USD";const TAX_RATE_PERCENT = 8.25;const issuer = {  name: "Northgate Systems",  legalName: "Northgate Systems, LLC",  domicile: "US",  entityType: "Limited liability company",  taxId: "47-2938471",};const customer = {  name: "Harbor Freight Collective",  legalName: "Harbor Freight Collective, Inc.",  domicile: "US",  entityType: "Corporation",  taxId: "58-1029384",};/** Five rows: one month of delivery work, and it fits one page. */const SHORT_ITEMS: LineItemInput[] = [  { description: "Integration architecture, senior engineer", quantity: 6, unit: "day", unitPrice: { amount: 2200, currency: CURRENCY } },  { description: "Dispatch board build", quantity: 11, unit: "day", unitPrice: { amount: 2050, currency: CURRENCY } },  { description: "Carrier rate ingestion", quantity: 4, unit: "day", unitPrice: { amount: 2050, currency: CURRENCY } },  { description: "Release engineering and cutover rehearsal", quantity: 3, unit: "day", unitPrice: { amount: 1900, currency: CURRENCY } },  { description: "Platform subscription, September", quantity: 1, unit: "month", unitPrice: { amount: 3400, currency: CURRENCY } },];/** What the long invoice bills for, one entry per work stream. */const BILLED_WORK = [  "Dispatch board build",  "Route optimization service",  "Driver check-in flow",  "Proof-of-delivery capture",  "Carrier rate ingestion",  "Invoice export adapter",  "Telemetry dashboard",  "Alerting and on-call runbook",  "Access control review",  "Load test and tuning",  "Disaster-recovery rehearsal",  "Operator training",  "Cutover rehearsal",  "Post-launch support",  "Reference data cleanup",  "Interface contract review",  "Reconciliation reporting",  "Documentation pass",  "Accessibility remediation",  "Regional rollout support",  "Billing reconciliation",  "Support handover",  "Environment provisioning",  "Release pipeline maintenance",];/** 48 rows: the table continues across more than one break. */const OVERFLOW_ITEMS: LineItemInput[] = BILLED_WORK.flatMap((work, index) => [  {    description: `${work} — August`,    quantity: 1 + (index % 5),    unit: "day",    unitPrice: { amount: 1850 + (index % 6) * 115, currency: CURRENCY },  },  {    description: `${work} — September`,    quantity: 2 + (index % 4),    unit: "day",    unitPrice: { amount: 1950 + (index % 5) * 105, currency: CURRENCY },  },]);/** * The sample's data, whose parties carry runtime ids. * * A document only prints a party, so `DocumentData` asks for the wider `Party`. * Nothing signs an invoice, so no seal reads these ids; they are here because a * filled artifact carries them and a sample that dropped them would not be one. */export interface InvoiceData extends DocumentData {  parties: Record<string, RuntimeParty | RuntimeParty[]>;}function build(  items: LineItemInput[],  invoiceNumber: string,  notes: string): InvoiceData {  const { lineItems } = computeLineAmounts(items, CURRENCY);  return {    fields: {      invoiceNumber,      issuedOn: "2026-10-01",      dueOn: "2026-10-31",      issuerAddress: {        line1: "1400 Rio Grande Street",        line2: "Suite 220",        locality: "Austin",        region: "TX",        postalCode: "78701",        country: "US",      },      issuerEmail: "billing@northgate-systems.example",      customerAddress: {        line1: "88 Wharf Road",        locality: "Oakland",        region: "CA",        postalCode: "94607",        country: "US",      },      customerContact: { name: "Marisol Vega", firstName: "Marisol", lastName: "Vega", title: "Ms." },      purchaseOrderNumber: "PO-2026-0512",      currency: CURRENCY,      lineItems: lineItems.map((item) => ({ ...item, taxable: true })),      taxRatePercent: TAX_RATE_PERCENT,      paymentTerms:        "Payment is due 30 days from the issue date, by transfer to the account on file. Late amounts carry interest at 1.5 percent a month.",      notes,    },    parties: {      issuer: { id: "issuer-0", ...issuer },      customer: { id: "customer-0", ...customer },    },  };}/** A short invoice, sized to fit inside one page of content. */export const shortInvoiceData: InvoiceData = build(  SHORT_ITEMS,  "INV-2026-0431",  "Quote the invoice number on the transfer so the payment can be matched.");/** A long invoice, sized to run its table past more than one page break. */export const overflowInvoiceData: InvoiceData = build(  OVERFLOW_ITEMS,  "INV-2026-0432",  "Covers the August and September delivery windows on one invoice, as agreed. Quote the invoice number on the transfer so the payment can be matched, and send queries to the billing address above rather than to the delivery team.");

Composition

The invoice composes Document, Section, Text, Party, Field, Image, Table, and Totals — deliberately no Signature. An invoice bills for work already done; it asks to be paid rather than agreed to, and the artifact carries no signature slot to draw one from.

Each masthead reads the issuer's mark and accent from Document's own tokens rather than a prop passed to the composition, so a tenant's own branding replaces both together. The title is a Text heading, and no line of the invoice sets its own text size: every size comes from the document's typography token. The issuer and the customer are named by Party blocks, read from the artifact's parties; their addresses and the customer's accounts contact are fields beside them, because a party record does not carry either. A Page Number in the footer, exported as invoiceFurniture, numbers every page of the preview and the PDF alike. The line-item Table sits in the same section as the Totals that follows it, and neither carries a copy of the subtotal, tax, or total — @paradoc/core evaluates each from the artifact's own defs. The subtotal is sum(fields.lineItems.amount).amount, and the taxable subtotal is coalesce(sum(fields.lineItems.amount, fields.lineItems.taxable).amount, 0), using the rows marked taxable, so no caller supplies a total.

Like the purchase order, the composition renders bare, with no self-wrapping Pages: it is meant to sit inside whatever pagination or packet a caller supplies. This page's Preview wraps it in Pages exactly the way a consumer's own page would. Install pages explicitly for that wrapper; the block still brings @paradoc/core, which evaluates its defs.

Variants

Two samples exist today, both real and currently shipping — a block's Variants section shows an existing alternate sample-data scenario, never a new prop configuration.

Overflow

The 48-row sample: the line-item table runs past two page breaks, and the page plan copies the table's header onto every page it continues onto.

Rendering live preview requires JavaScript.

On this page