feat(reconciliation): explain the difference instead of just printing it (#1737)

The bankavstämning card showed three movement sums and a red difference,
leaving the user to work out what the difference consisted of. The page
already knew, exactly: every krona of it is (unmatched bank rows) minus
(unmatched vouchers). Verified on prod for Arcim 1930 over 2025-07-17..
2026-08-20: 403 565,42 bank, 332 680,93 booked, 70 884,49 difference, of
which -277 799,92 sits in 74 unmatched transactions and -348 684,41 in 4
unmatched vouchers, leaving exactly 0,00 unexplained.

Engine: getReconciliationStatus gains unmatched_transaction_total,
unmatched_gl_line_total and unexplained_difference. The residual, not the
raw difference, is the figure that can mean something is wrong: a
difference is expected to be large mid-year and says nothing on its own.
unmatched_gl_line_total is null rather than 0 on a foreign account, whose
candidate lines carry no amount in that currency, and the card falls back
to the flat figures there.

Also fixes the candidate fetch's window: it used the caller's raw dateFrom
while both other sides were clamped to the opening-balance floor, so a
window opening before the account's IB (the v1 endpoint's default, or any
multi-year range) counted vouchers from a period the reconciliation
deliberately drops.

UI: the card becomes a bridge whose two middle rows both explain the
number and navigate to the list that resolves them, above a matched/total
progress rule. Three stacked paragraphs of legal prose collapse into one
line plus a tooltip, keeping the amounts on screen. The permanent
destructive "Ej avstämd" badge is gone: being mid-year and unreconciled is
the normal state, so it marked nothing (convention 5); Avstämd is now what
gets the chip.

The unmatched list becomes one line per transaction (convention 4). It
rendered a ~230px card per row, each with an always-open, always-empty
match field: for a real backlog that is thousands of pixels of empty
search boxes, and it gave the rarest action the only visible affordance
while bokför and ignorera hid behind the row menu. The picker, and its
ranked-candidate fetch, now run for the one row the user opens.

A non-zero residual is stated factually, never in destructive red:
measured over the 206 single-1930-account companies with >=10
transactions, 136 are exactly 0,00 and 63 are >=100 kr out, dominated by
ledger lines the candidate RPC hides (posted/storno on 127 companies)
rather than user error. Surfacing those is follow-up work.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-20 12:41:55 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Opus 5
parent a92c492dbe
commit b5e908f9ea
9 changed files with 699 additions and 231 deletions
@@ -30,12 +30,21 @@ const StatusResponse = z.object({
gl_1930_opening_balance: z.number(),
/** Net storno/correction activity in the window. Informational; included in the movement. */
gl_1930_correction_adjustment: z.number(),
/** bank_transaction_total − gl_1930_period_movement. */
/** bank_transaction_total minus gl_1930_period_movement: how far apart the two
* sides stand. Expected to be large mid-year; see unexplained_difference. */
difference: z.number(),
is_reconciled: z.boolean(),
matched_count: z.number().int(),
unmatched_transaction_count: z.number().int(),
/** Sum of the unmatched bank transactions: one of the two components of difference. */
unmatched_transaction_total: z.number(),
unmatched_gl_line_count: z.number().int(),
/** Sum of the unmatched ledger lines, signed like a bank movement. null on a
* foreign account, whose candidate lines carry no amount in that currency. */
unmatched_gl_line_total: z.number().nullable(),
/** difference - unmatched_transaction_total + unmatched_gl_line_total: what is
* left once both work lists are accounted for. null when the GL total is. */
unexplained_difference: z.number().nullable(),
})
registerEndpoint({
@@ -53,6 +62,7 @@ registerEndpoint({
'A non-zero difference is normal between sync runs (uncleared cheques, in-flight transfers). Investigate only if it persists across reconciliations.',
'difference compares against gl_1930_period_movement (movement excl. opening balance), NOT gl_1930_balance. Do not display gl_1930_balance next to difference.',
'is_reconciled means |difference| < 0.01 for the window, an aggregate check, not a per-transaction guarantee.',
'Judge health on unexplained_difference, NOT on difference. difference is just the gap between the two sides and is expected to be large mid-year; it is fully explained while every krona of it sits in unmatched_transaction_total or unmatched_gl_line_total. A non-zero unexplained_difference is the real finding: a matched pair disagreeing in amount, a voucher with several lines on the account, or a storno/correction line the candidate list hides.',
'Ignored transactions are excluded from bank_transaction_total and difference (they never get a ledger counterpart); their count and sum are reported separately.',
],
example: {
@@ -69,7 +79,10 @@ registerEndpoint({
is_reconciled: true,
matched_count: 142,
unmatched_transaction_count: 3,
unmatched_transaction_total: 1250,
unmatched_gl_line_count: 2,
unmatched_gl_line_total: 1250,
unexplained_difference: 0,
},
meta: { request_id: 'req_…', api_version: '2026-05-12' },
},