@get-asset/sdk

Invoices

Headless compound for accounts receivable. It lists the business's invoices (invoice date, description, customer, due date, status, balance) with cursor pagination behind LoadMore, filters by status and by invoice number, expands a row in place to reconcile what has been attached to it, and opens one in full beside its own numbers. CreateTrigger opens a draft with lines, a customer picker and a nested add-a-customer sub-draft; StatusAction changes a row's status and DeleteTrigger removes it. EditTriggeropens a line editor on the full view — lines are the one thing an existing invoice can change; see Editing: lines only. MatchTrigger opens a row at the matching surface, which TransactionMatch renders — see Matching.

Anatomy

Import the primitive and compose the parts you need.

<Invoices.Root>
{/* The due-date window the list is scoped to. */}
<Invoices.Period />
{/* Header controls. ListView hides them while the full view is open —
filtering and creating make no sense against a single record. */}
<Invoices.ListView>
<Invoices.StatusFilter>
<Select> {/* any Select-Root-shaped child */}
<Invoices.StatusFilterItems>
<Invoices.StatusFilterItem />
</Invoices.StatusFilterItems>
</Select>
</Invoices.StatusFilter>
<Invoices.CreateTrigger />
</Invoices.ListView>
{/* The create draft */}
<Invoices.Create>
<Invoices.SaveError />
<Invoices.DraftNumber />
<Invoices.DraftDescription />
<Invoices.DraftCustomer>
<Select>
<Invoices.DraftCustomerItems>
<Invoices.DraftCustomerItem />
</Invoices.DraftCustomerItems>
</Select>
</Invoices.DraftCustomer>
<Invoices.DraftError field="customerId" />
{/* A customer added without leaving the draft */}
<Invoices.AddCustomerTrigger />
<Invoices.CustomerDraft>
<Invoices.CustomerError />
<Invoices.CustomerDraftName />
<Invoices.CustomerDraftEmail />
<Invoices.CustomerDraftPhone />
<Invoices.CancelCustomerDraft />
<Invoices.SaveCustomer />
</Invoices.CustomerDraft>
<Invoices.DraftIssuedOn />
<Invoices.DraftDueOn />
<Invoices.DraftError field="dueOn" />
<Invoices.DraftCurrency>
<Select>
<Invoices.DraftCurrencyItems>
<Invoices.DraftCurrencyItem />
</Invoices.DraftCurrencyItems>
</Select>
</Invoices.DraftCurrency>
<Invoices.DraftError field="currency" />
<Invoices.DraftLines>
<Invoices.DraftLineDescription />
<Invoices.DraftLineLedger>
<Select>
<Invoices.DraftLineLedgerItems>
<Invoices.DraftLineLedgerItem />
</Invoices.DraftLineLedgerItems>
</Select>
</Invoices.DraftLineLedger>
<Invoices.DraftLineAmount />
<Invoices.RemoveLineTrigger />
</Invoices.DraftLines>
<Invoices.AddLineTrigger />
<Invoices.DraftError field="lines" />
<Invoices.DraftTotal />
<Invoices.CancelCreate />
<Invoices.Save />
</Invoices.Create>
{/* The full view. Replaces the list, and provides the viewed record to
every row-scoped part inside it. */}
<Invoices.Viewer>
<Invoices.ViewerBack />
<Invoices.CustomerName />
<Invoices.Number />
<Invoices.Status />
<Invoices.IssuedAt />
<Invoices.DueAt />
<Invoices.Lines>
<Invoices.LineDescription />
<Invoices.LineAmount />
</Invoices.Lines>
<Invoices.TotalAmount />
<Invoices.TotalDue />
<Invoices.DeleteTrigger />
</Invoices.Viewer>
<Invoices.ListView>
<Invoices.ActionError />
<Invoices.Loading>
{/* skeleton rows */}
</Invoices.Loading>
<Invoices.Error>
<Invoices.ErrorMessage />
<Invoices.Retry />
</Invoices.Error>
<Invoices.Empty>
{/* empty copy */}
</Invoices.Empty>
<Invoices.Success>
<Invoices.Rows>
{/* Spread useRowExpandProps() on the row to make the row itself the
toggle for its detail. */}
<Invoices.IssuedAt />
<Invoices.Description />
<Invoices.CustomerName />
<Invoices.DueAt />
<Invoices.Status />
<Invoices.TotalDue />
<Invoices.Currency />
<Invoices.ViewTrigger />
<Invoices.ViewerTrigger />
{/* Opens the row at its matching surface. Renders nothing once the
row has nothing left to match — useRowNeedsMatch() is the same
test, for picking what to render in its place. */}
<Invoices.MatchTrigger />
<Invoices.StatusAction status="paid" />
<Invoices.StatusAction status="uncollectible" />
<Invoices.DeleteTrigger />
</Invoices.Rows>
{/* The open record's detail. Provides it as row context, so the same
parts the table uses work here. */}
<Invoices.Detail>
<Invoices.TotalAmount />
<Invoices.TotalDue />
<Invoices.Payments>
<Invoices.PaymentKind />
</Invoices.Payments>
<Invoices.NoPayments />
{/* Where the matching surface goes: a TransactionMatch.Root for the
open record, kind="invoice". See the Matching section. */}
<Invoices.CloseDetail />
</Invoices.Detail>
<Invoices.LoadMore />
</Invoices.Success>
</Invoices.ListView>
</Invoices.Root>

Required scopes

The access token used by AssetProvider must include these scopes for this primitive to fetch data successfully:

  • invoice:read
  • invoice:create
  • invoice:update
  • invoice:delete
  • customer:read
  • customer:create
  • ledger:read
  • transaction:read
  • external_account:read

API reference

Root props

PropTypeDefaultDescription
dateFromCalendarDateStringInclusive start (YYYY-MM-DD) of a fixed due-date window. Setting it detaches the list from the provider's shared range entirely; must be paired with dateTo (one without the other throws). Use periodDateRange to derive the pair from a month, quarter, or year.
dateToCalendarDateStringInclusive end (YYYY-MM-DD) of a fixed due-date window. Must be paired with dateFrom. Omit both to follow the provider's DatePicker-driven range.
pageSizenumber25Rows per request. Further pages are fetched by LoadMore.
defaultStatusFilterInvoiceStatus | "all""all"Initial status filter. "all" clears it. Live, the filter is a query parameter — the endpoint returns the matching invoices.
defaultSelectedIdstringExpand this invoice's detail on mount, so a host can deep-link straight to an invoice rather than making the user find it in the list.
defaultViewingIdstringOpen this invoice's full document view on mount — the deep link for "show me this invoice", where defaultSelectedId is "open its row in the list".
defaultCreatingbooleanfalseOpen the create draft on mount.
invoicesInvoiceOverride[]Override the fetched invoices (skips the list request). The status filter and period are not re-applied, so supply the rows you want shown. Used by storybook/tests.
state"loading" | "error" | "empty"Force a specific state for testing. Skips every request this compound makes, the customer and ledger directories included.
showCurrencybooleanForce the ISO code beside each amount on or off, skipping the business fetch (storybook/tests). By default it shows only for a business that holds money in more than one currency.
hasMorebooleanForce the LoadMore affordance to render. Only consulted while the list isn't being fetched — live, pagination comes from the API's next page token.
customersCustomerOption[]Override the selectable customers, which also resolves each row's customer name offline. Live, the directory is read once for the whole table; an overridden list still reads it while a draft is open, because supplying rows says nothing about the picker.
ledgersLedgerOption[]Override the categories selectable on a draft line. Live, this is only read once a draft is open — a line's category is optional.
childrenrequiredReactNodeCompound parts.

Parts

Container

Always rendered. Fetches the list, resolves the customer directory and the bank descriptions behind matched payments, owns the draft and every write, and provides context. Invoices is a management surface rather than an entitled report, so it has no locked state.

PartDescription
Invoices.RootOwns fetching, cursor pagination, the status filter, the expanded row, the full view, the create draft and the status/delete writes.

Header controls

These read the root context rather than a row, so they sit outside the state gates and keep working while the list is loading, empty or errored. Wrap them in ListView if they should disappear while the full view is open.

PartDescription
Invoices.Period
asChild?, format?
Renders the due-date window as a label — defaults to MMM YYYY, Q<N> YYYY or YYYY when the range is exactly one calendar period, and the range label (e.g. 7 – 14 Jan 26) otherwise. Pass format(range, period) to override.
Invoices.StatusFilterWires the status filter to a single Select-Root-shaped child, cloning it with value (an InvoiceStatus or "all") and onValueChange. A value it does not recognise is ignored.
Invoices.StatusFilterItemsRenders its children once per option, providing the option as context — "All" followed by all six statuses. Put it inside the select's content.
Invoices.StatusFilterItem
asChild?
One option — sets value and renders the label.
Invoices.CreateTrigger
asChild?
Opens a clean create draft, dated today with one empty line. Sets aria-expanded while a draft is open. Default label "Create new invoice".
Invoices.ActionError
asChild?
Renders the last failed status change or delete, and nothing when all is well. Starting any of those actions clears it. Create failures surface on SaveError instead.

Success

Renders when at least one invoice is available.

PartDescription
Invoices.SuccessState wrapper. Compose the table inside.
Invoices.RowsIterates the invoices, providing a per-row context to each.
Invoices.Number
asChild?
The invoice number.
Invoices.Description
asChild?
The invoice's description.
Invoices.CustomerName
asChild?
The customer's name, resolved from the directory, or an em dash when it cannot be resolved — the invoice payload carries only an id.
Invoices.IssuedAt
asChild?, format?
The date on the document (the API's sent_at), as a raw ISO-8601 string unless format is given. Not the day the record was made.
Invoices.DueAt
asChild?, format?
The due date, same shape as IssuedAt.
Invoices.Status
asChild?
The status as a label, with data-status on the element for tone. All six statuses render; only four can be set from a row (see Statuses).
Invoices.TotalAmount
asChild?, format?
What the invoice is worth.
Invoices.TotalPaid
asChild?, format?
What has been recorded against the invoice.
Invoices.TotalDue
asChild?, format?
The balance — total less what has been recorded against it. If imported or host-supplied data records more than the total, this value is negative..
Invoices.Currency
asChild?
The ISO 4217 code the invoice was raised in. Renders nothing at all unless the business holds money in more than one currency.
Invoices.ViewTrigger
asChild?
Toggles this row's detail — a toggle rather than an open, because the expanded panel repeats the row above it and carries no close of its own. Sets aria-expanded, names itself "View invoice <number>", and stops the click from reaching the row. Default label "View".
Invoices.ViewerTrigger
asChild?
Opens the full view for this row and collapses the expander — one surface at a time. Default label "View invoice".
Invoices.LoadMore
asChild?
Fetches and appends the next page. Renders nothing once the list is fully loaded. Default label "Load more".

Row actions

Interactive parts meant to sit inside a row all stop their own clicks, so they still work when the row itself is the expand toggle.

PartDescription
Invoices.StatusAction
asChild?, status
Sets this row to status. Renders nothing when the row already holds it, so a menu only ever offers real transitions, and nothing for a status this compound cannot set. Disables itself while that row's write is in flight. Default label “Mark as paid” and so on. See Statusesuncollectible takes a different endpoint.
Invoices.MatchTrigger
asChild?
Expands this row, where the matching surface is. Renders nothing once the row has nothing left to match — a settled, voided or written-off row, so a control that would book a payment against a closed document is never on screen. Names itself “Match transactions to invoice <number>”. Default label “Match”. See Matching.
Invoices.DeleteTrigger
asChild?
Deletes the row's invoice, and collapses the expander it was open in. Names itself "Delete invoice <number>". Also usable inside Viewer, which provides the same row context. Default label "Delete".

Expansion and full view

Two surfaces, deliberately separate: the in-place expander answers whether an invoice adds up and what is left on it, while the full view is where it is read whole against its own numbers. A record can be open in one without being open in the other.

PartDescription
Invoices.DetailRenders children only while a row is expanded, and provides that record as row context — so the same Status, TotalDue and Lines the table uses work inside it. It answers whether any row is open, so put it outside Rows for one detached panel; to expand a panel from each row, render it per row and gate on useInvoiceRowExpanded().
Invoices.CloseDetail
asChild?
Collapses the expander. Default label "Close".
Invoices.ViewerThe full view. Renders nothing while the list is showing, and provides the viewed record as row context.
Invoices.ListViewThe list. Renders nothing while the full view is open, so what is being read is not a footnote to a table nobody is looking at. Wrap the header controls and the state gates in it.
Invoices.ViewerBack
asChild?
Back to the list. Default label "Back to invoices".
Invoices.LinesIterates the open record's line items. Needs row context — Rows, Detail or Viewer.
Invoices.LineDescription
asChild?
One line's description.
Invoices.LineAmount
asChild?, format?
One line's amount.
Invoices.PaymentsIterates the payments already recorded against the open record, whatever settled them — a bank transaction, a credit or a write-off. Read-only: attaching and detaching are TransactionMatch, which takes these same payments as its matched prop.
Invoices.PaymentKind
asChild?
"Write off", "Bank payment" or "Credit applied", per how the payment was recorded.
Invoices.NoPaymentsRenders children only when the open record has no recorded payments.

Create draft

One draft at a time, opened by CreateTrigger. It opens clean however the last one ended — no errors on show, no half-typed customer left over. A field's error appears once it has been touched or a save has been attempted, so a blank draft does not open covered in Required.

PartDescription
Invoices.CreateRenders children only while a create draft is open.
Invoices.DraftNumber
asChild?, plus input attributes
The draft's invoice number — required. Injects value and onChange into an input-shaped child; renders a bare input otherwise.
Invoices.DraftDescription
asChild?, plus input attributes
The draft's description — required.
Invoices.DraftIssuedOn
asChild?, plus input attributes
The invoice date, as a yyyy-mm-dd date input defaulted to today. Sent as a UTC instant so the calendar day cannot shift.
Invoices.DraftDueOn
asChild?, plus input attributes
The due date, same shape. Cannot be before the invoice date — that check reports on the dueOn field.
Invoices.DraftCurrencyWires the draft's currency to a single Select-Root-shaped child, cloning it with value and onValueChange. Required, and deliberately unset on a fresh draft: every figure on an invoice is in this currency, so one is chosen rather than guessed.
Invoices.DraftCurrencyItemsRenders its children once per currency an invoice can be raised in, providing the code as context.
Invoices.DraftCurrencyItem
asChild?
One currency — sets value to the ISO 4217 code and renders it.
Invoices.DraftCustomerWires the draft's customer to a single Select-Root-shaped child, cloning it with value and onValueChange. Required.
Invoices.DraftCustomerItemsRenders its children once per selectable customer, providing the option as context.
Invoices.DraftCustomerItem
asChild?
One customer — sets value to the id and renders the name.
Invoices.DraftLinesIterates the draft's lines. A fresh draft has one empty line to type into.
Invoices.DraftLineDescription
asChild?, plus input attributes
One line's description. A line with no description was never filled in, so it is dropped from the request rather than sent empty — at least one such line is required.
Invoices.DraftLineAmount
asChild?, plus input attributes
One line's amount, as a number input. Kept as typed; anything unparseable counts as nothing, and a negative amount blocks the save.
Invoices.DraftLineLedgerWires this line's category to a Select-Root-shaped child. Optional — a cleared category is no category rather than an empty id.
Invoices.DraftLineLedgerItemsRenders its children once per selectable ledger.
Invoices.DraftLineLedgerItem
asChild?
One ledger — sets value to the id and renders the name.
Invoices.AddLineTrigger
asChild?
Appends an empty line. Default label "Add line".
Invoices.RemoveLineTrigger
asChild?
Removes this line. The last one is cleared rather than removed, so the form never collapses to nothing to type into. Default label "Remove".
Invoices.DraftTotal
asChild?, format?
The draft's running total, summed over the lines as they are typed.
Invoices.DraftError
asChild?, field
The blocking problem for one draft field, or nothing while it is fine. Fields: number, description, customerId, issuedOn, dueOn, lines.
Invoices.Save
asChild?
Creates the invoice. Disabled until the draft validates and while the write is in flight; the label follows suit (“Create invoice” / “Creating…”). A successful create closes the draft and refetches every list read. Attempting a save is also what reveals whatever is still missing. See partial success.
Invoices.CancelCreate
asChild?
Discards the whole draft, the add-a-customer sub-draft included. Default label "Cancel".
Invoices.SaveError
asChild?
Renders the create failure, and nothing when the last create succeeded. Scoped to the create — a failed status change surfaces on ActionError instead.

Editing lines

The line editor for an existing invoice. Lines are the one thing the update endpoint can change — the number, customer and dates are fixed at creation. See Editing: lines only.

PartDescription
Invoices.EditTrigger
asChild?, appendLine?
Opens the editor seeded from the row's lines; appendLine opens it with a fresh blank line — the shape of an "Add item" affordance on a read view. Renders nothing while an editor is open. Default label "Edit" ("Add item" with appendLine); named "Edit invoice <number>" ("Add an item to invoice <number>").
Invoices.EditingRenders children only while this row's line editor is open.
Invoices.NotEditingThe inverse — children while the row reads as a document.
Invoices.EditLinesIterates the open editor's lines, providing line context to the field parts.
Invoices.EditLineDescription
asChild?, …input props
The line's description, as a controlled input.
Invoices.EditLineAmount
asChild?, …input props
The line's amount, as a controlled input. type="number" unless overridden.
Invoices.RemoveEditLineTrigger
asChild?
Removes this line from the editor. The last one is cleared to a fresh blank rather than removed, so there is always something to type into. Named "Remove line".
Invoices.AddEditLineTrigger
asChild?
Adds a blank line to the open editor. Default label "Add item".
Invoices.EditTotal
asChild?, format?
The editor's running total, summed over the lines as they are typed.
Invoices.SaveEdit
asChild?
Sends what changed as line_changes — a modified line is deleted and recreated, category riding along — and closes on success. An edit that changed nothing just closes. Disabled while invalid (at least one described line, no negative amounts) or in flight.
Invoices.CancelEdit
asChild?
Discards the editor, the invoice unchanged. Default label "Cancel".
Invoices.EditError
asChild?
Renders the save failure, and nothing when the last save succeeded. The editor stays open over a failure.

Add a customer

A sub-draft nested inside the create draft, so an invoice can be raised for someone not yet in the directory without leaving the form. See Inline customer creation.

PartDescription
Invoices.AddCustomerTrigger
asChild?
Opens the sub-draft, and renders nothing while it is open — the affordance and the panel it opens are never on screen together. Default label "Add a new customer".
Invoices.CustomerDraftRenders children only while the add-a-customer sub-draft is open.
Invoices.CustomerDraftName
asChild?, plus input attributes
The sub-draft's name field — the only required one, matching the API. Sets required on the input.
Invoices.CustomerDraftEmail
asChild?, plus input attributes
Optional email. A blank field is sent as null rather than empty.
Invoices.CustomerDraftPhone
asChild?, plus input attributes
Optional phone, with the same blank-is-null handling.
Invoices.SaveCustomer
asChild?
Creates the customer and selects them on the invoice draft. Disabled until a name is present and while the write is in flight; label follows suit ("Add customer" / "Adding…").
Invoices.CancelCustomerDraft
asChild?
Discards the sub-draft, leaving the invoice draft open. Default "Cancel".
Invoices.CustomerError
asChild?
Renders the customer-create failure, if the last attempt failed.

Loading

Renders while the list fetch is in flight.

PartDescription
Invoices.LoadingState wrapper. Render skeleton rows inside.

Empty

Renders when no invoices are available for the current status and period.

PartDescription
Invoices.EmptyState wrapper. Render the empty-state message inside.

Error

Renders on list-fetch error. Write failures surface on ActionError, SaveError and CustomerError instead.

PartDescription
Invoices.ErrorState wrapper. Compose the error UI inside.
Invoices.ErrorMessage
asChild?, format?
Renders the error message string.
Invoices.Retry
asChild?
Button that re-runs the list fetch.

Hooks

PartDescription
useInvoices()Hook returning the full context: invoices, the four state flags, retry, hasMore/loadMore, statusFilter/setStatusFilter, selected/select/clearSelection, viewing/openViewer/closeViewer, setStatus/pendingStatusId, remove/pendingDeleteId, actionError, the draft with startCreate/cancelCreate/setDraftField/addLine/removeLine/setLineField/draftTotal/draftErrors/canSave/save/saving/saveError, the edit with startEdit/cancelEdit/setEditLineField/addEditLine/removeEditLine/editTotal/canSaveEdit/saveEdit/savingEdit/editError, the customers and ledgers directories, and the customerDraft with its own start/cancel/setField/save/canSave/creating/error.
useInvoiceRow()Hook returning the current invoice inside Rows, Detail or Viewer. Includes the wire record on invoice, or null on an overridden row.
useInvoiceRowExpanded()Hook returning whether this row is the expanded one. Detail unmounts its children, which cannot animate out — this gives the same state as a boolean, for driving an expand transition.
useRowExpandProps()Hook returning the props that make the whole row a toggle for its detail — role, tabIndex, aria-expanded, onClick and onKeyDown (Enter and Space only). Spread it on the row element. Every interactive part inside a row must stop its own click, which the parts above all do.
useRowNeedsMatch()Hook returning whether the row still has a payment to take — money outstanding, and a status that can still take one (voided and uncollectible cannot). The same test MatchTrigger uses, exposed so a host can decide what to render in its place. Compared in cents, so a rounding remainder cannot leave a settled invoice asking for one more payment.
useRowMatchedTransactions()Hook returning the row's matched bank payments with their descriptions joined on — id, transactionId, amount, transactionAmount, datetime, description and account. Write-offs and credit-sourced payments are dropped: neither settled through the bank. See Matched transactions.

Period and status filter

The list is scoped to the AssetProvider's shared range, the same as the report cards, so it stays in step with the rest of the dashboard. The dateFrom and dateToprops pin a fixed window for this compound only, detaching it from the provider's range; they are both-or-neither. The resolved range becomes the endpoint's only date filter — the due-date window, due_at_start (inclusive) and due_at_end(exclusive) — so an invoice is in the list when it falls due inside the period, whenever it was raised. Both bounds are part of the query key, so changing the period re-requests. Periodrenders the window's label.

The status filter is a query parameter: the endpoint returns the invoices holding that status, and "all" clears it. All six statuses are offered, so a refunded invoice can still be found and read even though this compound cannot set that status.

An invoices override supplies rows directly, so the status filter and period are not re-applied. Supply the rows the filter and period you are demonstrating would have returned.

Statuses

Six statuses exist on the wire — open, paid, voided, uncollectible, partially_refunded and refunded. All six render on Status and all six are offered by the filter. Four of them can be set: StatusAction renders nothing for the refunded pair, which is display-only here.

A refund is not a status write. It creates a Refund object and needs its own transaction match on the way back out — the money in that clears the receivable and the money out are two opposite matches. Flipping the field alone would label an invoice refunded with no money movement behind it, which is worse than not offering it at all, so refunds wait for their own endpoints and their own flow.

uncollectible is not a plain status write either. It POSTs to the write-off endpoint with the invoice's outstanding balance, which posts its own journal entry; the other three are a plain PATCH of the status field. StatusAction picks the right one, so a consumer only names the status it wants. Either way the write reports through pendingStatusId and refetches the list on success, and a failure lands on ActionError.

A written-off invoice carries the write-off as a payment, which is why PaymentKind distinguishes it from a bank payment, and why useRowMatchedTransactions() leaves it out.

Editing: lines only

An existing invoice edits through one narrow door: its lines. The number, the customer and the dates are fixed at creation — the update endpoint has no write for them — so the editor swaps the lines for inputs and leaves everything else read.

There is no in-place line write either. The endpoint's line_changes only creates and deletes, so a modified line is replaced: its original id goes into delete_lines and the edited content into create_lines, with its category riding along. An edit that changed nothing closes without a request.

Order is held through that replacement. Lines read back in creation order, and a recreated line is the newest — replacing one alone would send it to the bottom of the list. So replacement starts at the first change and carries the rest of the list with it: every line from that point is recreated in the editor's order, only the untouched head keeps its ids, and removals on their own reorder nothing, so they only delete.

Validation mirrors the create draft: at least one described line, and no negative amounts. A line the user never described was never filled in, so it sends nothing. SaveEdit commits; a failure surfaces on EditError with the editor still open.

Creating: partial success

The create endpoint is a bulk one: the body is an array even for a single invoice, and a 200 can still carry a per-invoice failure in errors instead of throwing. Savereads the response before deciding whether it worked — the first entry in errors is raised as the failure, so the draft stays open with its message on SaveError rather than closing over an invoice that was never created.

This compound sends one invoice per request, so there is only ever one entry to read. Nothing here batches; the array is the endpoint's shape, not a feature of the surface.

Inline customer creation

An invoice needs a customer, and the customer who needs invoicing is often the one who is not in the directory yet. AddCustomerTrigger opens a sub-draft inside the invoice draft; SaveCustomer POSTs the customer, selects the new id on the invoice draft, closes the sub-draft and refetches the customer directory. Name is the only required field, matching the API, and a blank email or phone is sent as null rather than an empty string.

The sub-draft's failure is its own: CustomerError is separate from SaveError, so a customer that could not be added never reads as an invoice that could not be created. Cancelling the invoice draft discards both.

Matched transactions

A payment payload carries only a transaction_id, so the bank description behind it has to be joined on. Root does that with one query for the whole table rather than one per row: the date window is derived from the payments actually on screen, so it is bounded by the data rather than by a guessed range, and every row reads out of the same cache entry. Nothing matched anywhere in the list means no lookup at all.

useRowMatchedTransactions() is where a consumer reads the result. A description identifies a transaction in a way an amount cannot — two invoices settled by different transactions of the same value are indistinguishable by figure — and it is null while the lookup is in flight, or when the compound is running on overridden rows that do not supply one. Fall back to the amount rather than rendering a blank.

This lookup is what the matching surface reads its descriptions from too, which is why a settled invoice can name what paid it without the panel fetching anything. See Matching.

Matching

Attaching a bank transaction to an invoice is TransactionMatch — one compound serving both sides of the ledger, because Invoices and Bills want the same guarantees and a second copy is exactly where a wrong-match bug would appear. This compound's part in it is two: the trigger that gets the user there, and the payments the surface is given to work from.

MatchTrigger expands the row, which is where the surface is mounted, and renders nothing once the row has nothing left to match. That test is useRowNeedsMatch(): money still outstanding, and a status that can still take a payment. A voided or written-off invoice is closed as far as money is concerned, so offering to match one would invite booking a payment against a document that no longer stands. A host that wants an affordance in the trigger's place on a settled row reads the same hook and renders ViewTrigger instead.

Mount TransactionMatch.Root inside Detail or Viewer with kind="invoice", the row's id, its totalDue as outstanding, its dueAt, and useRowMatchedTransactions() as matched. Passing the payments in is what lets the surface offer to detach one and name it — a settled invoice runs no candidate query, so those descriptions are the only ones it has. A successful match invalidates this list, so the balance and the Transactions column move on their own.

Which invoices can take a match, and what happens when a transaction is worth more than the balance, are the matching compound's decisions rather than this one's. The attach request leaves allocation to the API: it settles the invoice and posts excess transaction value to customer credit — see Guardrails.