busy-office-ui
Menu

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

Bank reconciliation — Operating · August Unmatched difference: $4,410.50
Statement lines awaiting reconciliation
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

  1. Period header — account + period, the running difference (Amount in a badge), and the gated Reconcile button.
  2. The working list — a data table, one row per statement line, tone on the amount column (data-tone).
  3. Suggested match — the system's proposal in its own column; "no suggestion" is a badge, never an empty cell.
  4. 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

RequestReturns / on failure
POST /recon/:id/matchconfirms 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/close422 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

StateWhat ships
Suggestedproposal 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 suggestiona visible "no suggestion" badge + the exception path — an empty cell reads as "not loaded", which this is not
Confirmedthe 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 differenceReconcile enables; the badge flips to success. This is the only state where the period can close
Deferred exceptions remaindifference non-zero → Reconcile stays disabled; the header names how many lines remain, so "why can't I finish" is always answered on screen
No JSevery 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

↑↓ move Enter open Esc close