@get-asset/sdk

Bills

Headless compound for accounts payable. It lists the business's bills (bill date, description, vendor, due date, status, balance) with cursor pagination behind LoadMore, filters by status and by bill 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 vendor picker and a nested add-a-vendor 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 bill changes from this surface; 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.

<Bills.Root>
{/* The due-date window the list is scoped to. */}
<Bills.Period />
{/* Header controls. ListView hides them while the full view is open —
filtering and creating make no sense against a single record. */}
<Bills.ListView>
<Bills.StatusFilter>
<Select> {/* any Select-Root-shaped child */}
<Bills.StatusFilterItems>
<Bills.StatusFilterItem />
</Bills.StatusFilterItems>
</Select>
</Bills.StatusFilter>
<Bills.CreateTrigger />
</Bills.ListView>
{/* The create draft */}
<Bills.Create>
<Bills.SaveError />
<Bills.DraftNumber />
<Bills.DraftDescription />
<Bills.DraftVendor>
<Select>
<Bills.DraftVendorItems>
<Bills.DraftVendorItem />
</Bills.DraftVendorItems>
</Select>
</Bills.DraftVendor>
<Bills.DraftError field="vendorId" />
{/* A vendor added without leaving the draft */}
<Bills.AddVendorTrigger />
<Bills.VendorDraft>
<Bills.VendorError />
<Bills.VendorDraftName />
<Bills.VendorDraftEmail />
<Bills.VendorDraftPhone />
<Bills.CancelVendorDraft />
<Bills.SaveVendor />
</Bills.VendorDraft>
<Bills.DraftIssuedOn />
<Bills.DraftDueOn />
<Bills.DraftError field="dueOn" />
<Bills.DraftCurrency>
<Select>
<Bills.DraftCurrencyItems>
<Bills.DraftCurrencyItem />
</Bills.DraftCurrencyItems>
</Select>
</Bills.DraftCurrency>
<Bills.DraftError field="currency" />
<Bills.DraftLines>
<Bills.DraftLineDescription />
<Bills.DraftLineLedger>
<Select>
<Bills.DraftLineLedgerItems>
<Bills.DraftLineLedgerItem />
</Bills.DraftLineLedgerItems>
</Select>
</Bills.DraftLineLedger>
<Bills.DraftLineAmount />
<Bills.RemoveLineTrigger />
</Bills.DraftLines>
<Bills.AddLineTrigger />
<Bills.DraftError field="lines" />
<Bills.DraftTotal />
<Bills.CancelCreate />
<Bills.Save />
</Bills.Create>
{/* The full view. Replaces the list, and provides the viewed record to
every row-scoped part inside it. */}
<Bills.Viewer>
<Bills.ViewerBack />
<Bills.VendorName />
<Bills.Number />
<Bills.Status />
<Bills.IssuedAt />
<Bills.DueAt />
<Bills.Lines>
<Bills.LineDescription />
<Bills.LineAmount />
</Bills.Lines>
<Bills.TotalAmount />
<Bills.TotalDue />
<Bills.DeleteTrigger />
</Bills.Viewer>
<Bills.ListView>
<Bills.ActionError />
<Bills.Loading>
{/* skeleton rows */}
</Bills.Loading>
<Bills.Error>
<Bills.ErrorMessage />
<Bills.Retry />
</Bills.Error>
<Bills.Empty>
{/* empty copy */}
</Bills.Empty>
<Bills.Success>
<Bills.Rows>
{/* Spread useRowExpandProps() on the row to make the row itself the
toggle for its detail. */}
<Bills.IssuedAt />
<Bills.Description />
<Bills.VendorName />
<Bills.DueAt />
<Bills.Status />
<Bills.TotalDue />
<Bills.Currency />
<Bills.ViewTrigger />
<Bills.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. */}
<Bills.MatchTrigger />
<Bills.StatusAction status="paid" />
<Bills.StatusAction status="voided" />
<Bills.DeleteTrigger />
</Bills.Rows>
{/* The open record's detail. Provides it as row context, so the same
parts the table uses work here. */}
<Bills.Detail>
<Bills.TotalAmount />
<Bills.TotalDue />
<Bills.Payments>
<Bills.PaymentKind />
</Bills.Payments>
<Bills.NoPayments />
{/* Where the matching surface goes: a TransactionMatch.Root for the
open record, kind="bill". See the Matching section. */}
<Bills.CloseDetail />
</Bills.Detail>
<Bills.LoadMore />
</Bills.Success>
</Bills.ListView>
</Bills.Root>

Required scopes

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

  • bill:read
  • bill:create
  • bill:update
  • bill:delete
  • vendor:read
  • vendor: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.
defaultStatusFilterBillStatus | "all""all"Initial status filter. "all" clears it. Live, the filter is a query parameter — the endpoint returns the matching bills.
defaultSelectedIdstringExpand this bill's detail on mount, so a host can deep-link straight to a bill rather than making the user find it in the list.
defaultViewingIdstringOpen this bill's full document view on mount — the deep link for "show me this bill", where defaultSelectedId is "open its row in the list".
defaultCreatingbooleanfalseOpen the create draft on mount.
billsBillOverride[]Override the fetched bills (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 vendor 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.
vendorsVendorOption[]Override the selectable vendors, which also resolves each row's vendor 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 vendor directory and the bank descriptions behind matched payments, owns the draft and every write, and provides context. Bills is a management surface rather than an entitled report, so it has no locked state.

PartDescription
Bills.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
Bills.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.
Bills.StatusFilterWires the status filter to a single Select-Root-shaped child, cloning it with value (an BillStatus or "all") and onValueChange. A value it does not recognise is ignored.
Bills.StatusFilterItemsRenders its children once per option, providing the option as context — "All" followed by open, paid and voided. Put it inside the select's content.
Bills.StatusFilterItem
asChild?
One option — sets value and renders the label.
Bills.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 bill".
Bills.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 bill is available.

PartDescription
Bills.SuccessState wrapper. Compose the table inside.
Bills.RowsIterates the bills, providing a per-row context to each.
Bills.Number
asChild?
The bill number.
Bills.Description
asChild?
The bill's description.
Bills.VendorName
asChild?
The vendor's name, resolved from the directory, or an em dash when it cannot be resolved — the bill payload carries only an id.
Bills.IssuedAt
asChild?, format?
The date on the document (the API's issued_at), as a raw ISO-8601 string unless format is given. Not the day the record was made.
Bills.DueAt
asChild?, format?
The due date, same shape as IssuedAt.
Bills.Status
asChild?
The status as a label, with data-status on the element for tone. Open, paid and voided all render and can all be set from a row.
Bills.TotalAmount
asChild?, format?
What the bill is worth. format receives (amount, currency) — the ISO 4217 code the bill was issued in, or null — so it can print the symbol that goes with this bill's money.
Bills.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. format receives (amount, currency), as TotalAmount does.
Bills.TotalPaid
asChild?, format?
The total amount paid that has been recorded against the bill.
Bills.Currency
asChild?
The ISO 4217 code the bill was issued in — meant to sit beside one of its figures. Every amount on a bill is in this currency. Renders nothing when the bill names none. Adds data-currency. Renders nothing at all unless the business holds money in more than one currency.
Bills.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 bill <number>", and stops the click from reaching the row. Default label "View".
Bills.ViewerTrigger
asChild?
Opens the full view for this row and collapses the expander — one surface at a time. Default label "View bill".
Bills.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
Bills.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 Statuses.
Bills.MatchTrigger
asChild?
Expands this row, where the matching surface is. Renders nothing once the row has nothing left to match — a settled or voided row, so a control that would book a payment against a closed document is never on screen. Names itself “Match transactions to bill <number>”. Default label “Match”. See Matching.
Bills.DeleteTrigger
asChild?
Deletes the row's bill, and collapses the expander it was open in. Names itself "Delete bill <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 a bill 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
Bills.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 useBillRowExpanded().
Bills.CloseDetail
asChild?
Collapses the expander. Default label "Close".
Bills.ViewerThe full view. Renders nothing while the list is showing, and provides the viewed record as row context.
Bills.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.
Bills.ViewerBack
asChild?
Back to the list. Default label "Back to bills".
Bills.LinesIterates the open record's line items. Needs row context — Rows, Detail or Viewer.
Bills.LineDescription
asChild?
One line's description.
Bills.LineAmount
asChild?, format?
One line's amount.
Bills.PaymentsIterates the payments already recorded against the open record, whatever settled them — a bank transaction or a vendor credit. Read-only: attaching and detaching are TransactionMatch, which takes these same payments as its matched prop.
Bills.PaymentKind
asChild?
"Bank payment" or "Credit applied", per how the payment was recorded.
Bills.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 vendor 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
Bills.CreateRenders children only while a create draft is open.
Bills.DraftNumber
asChild?, plus input attributes
The draft's bill number — required. Injects value and onChange into an input-shaped child; renders a bare input otherwise.
Bills.DraftDescription
asChild?, plus input attributes
The draft's description — required.
Bills.DraftIssuedOn
asChild?, plus input attributes
The bill date, as a yyyy-mm-dd date input defaulted to today. Sent as a UTC instant so the calendar day cannot shift.
Bills.DraftDueOn
asChild?, plus input attributes
The due date, same shape. Cannot be before the bill date — that check reports on the dueOn field.
Bills.DraftCurrencyThe currency picker. Put a select inside it and it keeps that select and the draft in step. Required, and empty on a fresh draft: every amount on a bill is in this currency, so it is picked rather than guessed.
Bills.DraftCurrencyItemsRenders its children once for each currency a bill can be raised in.
Bills.DraftCurrencyItem
asChild?
One currency in the list. Shows its code (USD, CAD) and uses that code as the option's value.
Bills.DraftVendorWires the draft's vendor to a single Select-Root-shaped child, cloning it with value and onValueChange. Required.
Bills.DraftVendorItemsRenders its children once per selectable vendor, providing the option as context.
Bills.DraftVendorItem
asChild?
One vendor — sets value to the id and renders the name.
Bills.DraftLinesIterates the draft's lines. A fresh draft has one empty line to type into.
Bills.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.
Bills.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.
Bills.DraftLineLedgerWires this line's category to a Select-Root-shaped child. Optional — a cleared category is no category rather than an empty id.
Bills.DraftLineLedgerItemsRenders its children once per selectable ledger.
Bills.DraftLineLedgerItem
asChild?
One ledger — sets value to the id and renders the name.
Bills.AddLineTrigger
asChild?
Appends an empty line. Default label "Add line".
Bills.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".
Bills.DraftTotal
asChild?, format?
The draft's running total, summed over the lines as they are typed.
Bills.DraftError
asChild?, field
The blocking problem for one draft field, or nothing while it is fine. Fields: number, description, vendorId, issuedOn, dueOn, lines.
Bills.Save
asChild?
Creates the bill. Disabled until the draft validates and while the write is in flight; the label follows suit (“Create bill” / “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.
Bills.CancelCreate
asChild?
Discards the whole draft, the add-a-vendor sub-draft included. Default label "Cancel".
Bills.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 bill. Lines are the one thing this surface changes — the number, vendor and dates are fixed at creation. See Editing: lines only.

PartDescription
Bills.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 bill <number>" ("Add an item to bill <number>").
Bills.EditingRenders children only while this row's line editor is open.
Bills.NotEditingThe inverse — children while the row reads as a document.
Bills.EditLinesIterates the open editor's lines, providing line context to the field parts.
Bills.EditLineDescription
asChild?, …input props
The line's description, as a controlled input.
Bills.EditLineAmount
asChild?, …input props
The line's amount, as a controlled input. type="number" unless overridden.
Bills.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".
Bills.AddEditLineTrigger
asChild?
Adds a blank line to the open editor. Default label "Add item".
Bills.EditTotal
asChild?, format?
The editor's running total, summed over the lines as they are typed.
Bills.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.
Bills.CancelEdit
asChild?
Discards the editor, the bill unchanged. Default label "Cancel".
Bills.EditError
asChild?
Renders the save failure, and nothing when the last save succeeded. The editor stays open over a failure.

Add a vendor

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

PartDescription
Bills.AddVendorTrigger
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 vendor".
Bills.VendorDraftRenders children only while the add-a-vendor sub-draft is open.
Bills.VendorDraftName
asChild?, plus input attributes
The sub-draft's name field — the only required one, matching the API. Sets required on the input.
Bills.VendorDraftEmail
asChild?, plus input attributes
Optional email. A blank field is sent as null rather than empty.
Bills.VendorDraftPhone
asChild?, plus input attributes
Optional phone, with the same blank-is-null handling.
Bills.SaveVendor
asChild?
Creates the vendor and selects them on the bill draft. Disabled until a name is present and while the write is in flight; label follows suit ("Add vendor" / "Adding…").
Bills.CancelVendorDraft
asChild?
Discards the sub-draft, leaving the bill draft open. Default "Cancel".
Bills.VendorError
asChild?
Renders the vendor-create failure, if the last attempt failed.

Loading

Renders while the list fetch is in flight.

PartDescription
Bills.LoadingState wrapper. Render skeleton rows inside.

Empty

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

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

Error

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

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

Hooks

PartDescription
useBills()Hook returning the full context: bills, 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 vendors and ledgers directories, and the vendorDraft with its own start/cancel/setField/save/canSave/creating/error.
useBillRow()Hook returning the current bill inside Rows, Detail or Viewer. Includes the wire record on bill, or null on an overridden row.
useBillRowExpanded()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 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 bill 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. Credit-sourced payments are dropped because they did not settle 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 a bill 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 bills holding that status, and "all" clears it. Open, paid and voided are all offered and all can be set from a row.

A bills 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

Three statuses exist on the wire — open, paid and voided. All three render on Status, all three are offered by the filter, and StatusAction can set any transition that changes the row.

Every status transition is a plain PATCH to the Bill update endpoint. There is no payable-side write-off route and no display-only refund status to special-case.

StatusAction only needs the target status. The write reports through pendingStatusId and refetches the list on success, and a failure lands on ActionError.

Editing: lines only

An existing bill edits through one narrow door: its lines. The number, the vendor and the dates stay as created — the same door the invoice editor opens, so the two surfaces edit alike — and the editor swaps the lines for inputs while everything else stays 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 bill, and a 200 can still carry a per-bill 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 a bill that was never created.

This compound sends one bill 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 vendor creation

An bill needs a vendor, and the vendor who needs invoicing is often the one who is not in the directory yet. AddVendorTrigger opens a sub-draft inside the bill draft; SaveVendor POSTs the vendor, selects the new id on the bill draft, closes the sub-draft and refetches the vendor 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: VendorError is separate from SaveError, so a vendor that could not be added never reads as a bill that could not be created. Cancelling the bill 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 bills 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 bill can name what paid it without the panel fetching anything. See Matching.

Matching

Attaching a bank transaction to a bill 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 bill 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="bill", 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 bill 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 bills 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 bill and posts excess transaction value to vendor credit — see Guardrails.