Pattern: reconciliation
Who uses it: whoever must prove two independent records agree — an accountant clearing the bank statement against the ledger, AP matching payments to invoices, treasury clearing suspense. How often: daily to monthly, in sittings that end only at zero. What "done" looks like: every line matched or explicitly excepted, the running difference reads exactly zero, and Reconcile — disabled until then — commits the period. Two independent lists matched against each other, with the system proposing and a human confirming (the shape Xero, QuickBooks, Business Central and SAP's three-way match all share). Not for validating one imported list — rows checked and corrected against rules, with no counter-list, is staging; and not for acting on many rows of one list — that is bulk actions. Reach for this only when TWO sources must agree line by line.
The screen
| Statement line | Amount | Suggested match | Decision |
|---|---|---|---|
| 08-19 · ACME SUPPLY CO | -9,240.00 | PO-88213 payment · Acme Supply Co. · -9,240.00 | |
| 08-20 · NORTHWIND REFUND | 2,930.00 | CR-1180 credit note · Northwind Supply · 2,930.00 | |
| 08-21 · UNKNOWN REMITTER 4471 | 1,480.50 | no suggestion |
One row per statement line; the system's proposal sits beside it, and confirming a match removes both sides from the working set at once — the matched pair leaves this list and the difference shrinks by its amount. A line with no suggestion goes down the explicit exception path (create a transaction, search the ledger, or transfer) — it can be deferred, but never silently skipped: the zero gate at the top makes a forgotten line impossible to sign off.
Why Reconcile is disabled until zero
The running difference is the pattern's whole contract: it is the sum of everything not yet matched, and the finish action is gated on it reaching exactly zero (a tolerance, where policy allows one, is a server rule — the UI still shows the residual it wrote off). Every reference behaves this way, because a reconciliation signed off at non-zero is not a reconciliation. Two channels as always: the amount is text beside its badge, and the disabled button carries the reason in the markup, not only in its state.
Markup
<!-- One row per BANK line: statement side | the system's proposed
match | the decision. Confirming removes BOTH sides from the working set
at once; the running difference in the header gates Reconcile at zero.
The shape every reference shares (Xero, QBO, Business Central, SAP). -->
<header class="bo-cluster">
<h1>Bank reconciliation — Operating · August</h1>
<span class="bo-badge bo-badge--warning">Unmatched difference:
<span class="bo-amount"><span class="bo-amount__currency">$</span><span class="bo-amount__value">4,410.50</span></span>
</span>
<button class="bo-btn" type="submit" disabled>Reconcile</button>
</header>
<div class="bo-data-table-container" tabindex="0">
<table class="bo-data-table">
<thead><tr>
<th scope="col">Statement line</th>
<th scope="col" class="bo-data-table__col--numeric">Amount</th>
<th scope="col">Suggested match</th>
<th scope="col">Decision</th>
</tr></thead>
<tbody>
<tr>
<td>08-19 · ACME SUPPLY CO</td>
<td class="bo-data-table__col--numeric">-9,240.00</td>
<td>PO-88213 payment · Acme Supply</td>
<td>
<button class="bo-btn bo-btn--secondary" type="button">Match</button>
<button class="bo-btn bo-btn--ghost" type="button">Other…</button>
</td>
</tr>
<tr>
<td>08-20 · UNKNOWN REMITTER</td>
<td class="bo-data-table__col--numeric">1,480.50</td>
<td><span class="bo-badge">no suggestion</span></td>
<td><button class="bo-btn bo-btn--ghost" type="button">Create / find…</button></td>
</tr>
</tbody>
</table>
</div> Anatomy
- Period header — account + period, the running difference (Amount in a badge), and the gated Reconcile button.
- The working list — a
data table, one row per
statement line, tone on the amount column
(
data-tone). - Suggested match — the system's proposal in its own column; "no suggestion" is a badge, never an empty cell.
- Decision cluster — Match confirms the pair; "Other…" opens a value-help picker over the ledger; "Create / find…" is the exception path for lines the ledger doesn't know yet.
Data contract
| Request | Returns / on failure |
|---|---|
| POST /recon/:id/match | confirms one pair ATOMICALLY (both sides or neither); returns the removed row + the updated difference fragment for the header |
| POST /recon/:id/match (already matched) | 409 — someone else cleared one side; re-render the row as it now is with a banner naming who, never silently double-match |
| GET /recon/:id/candidates?line=… | the "Other…" picker's list — server-side search over open ledger items, same contract as value help |
| POST /recon/:id/close | 422 unless the difference is zero (or within the server's stated tolerance, returned with the residual it wrote off) — the gate is enforced server-side, the disabled button is only its reflection |
States
| State | What ships |
|---|---|
| Suggested | proposal shown beside the line; Match is one click. Suggestions are ranked server-side — the UI never pretends to certainty the matcher doesn't have |
| No suggestion | a visible "no suggestion" badge + the exception path — an empty cell reads as "not loaded", which this is not |
| Confirmed | the pair leaves the working set; the difference updates in the same swap. An undo lives in the matched view, not here |
| Concurrent match (409) | the row re-renders as it now is, with who-and-when; the difference re-syncs |
| All matched, zero difference | Reconcile enables; the badge flips to success. This is the only state where the period can close |
| Deferred exceptions remain | difference non-zero → Reconcile stays disabled; the header names how many lines remain, so "why can't I finish" is always answered on screen |
| No JS | every decision is a form POST returning the full page; the difference and gate are server-computed truth either way — HTMX only narrows the swap |
Components used
complexity 3 of 4