InvoiceLast updated on
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
Installation
npx shadcn@4 add @paradoc/invoiceOr, with the Paradoc CLI, which writes the namespace into components.json for you:
npx paradoc-cli add invoiceInstalls 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.