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
@@ -1416,6 +1416,132 @@ describe('getReconciliationStatus', () => {
expect(status.bank_transaction_total).toBe(0)
})
it('decomposes the difference into the two work lists (unexplained residual 0)', async () => {
const { supabase, enqueue } = createQueueMockSupabase()
// Bank side: one matched 1000 deposit + one unbooked 500 deposit.
enqueue({
data: [
{ amount: 1000, journal_entry_id: 'je-matched', reconciliation_method: 'auto_exact' },
{ amount: 500, journal_entry_id: null, reconciliation_method: null },
],
})
// GL side: the matched 1000 debit + a 300 credit voucher with no bank row.
enqueue({
data: [
{ id: 'je-matched', status: 'posted', source_type: 'bank_transaction' },
{ id: 'je-lonely', status: 'posted', source_type: 'manual' },
],
})
enqueue({
data: [
{ debit_amount: 1000, credit_amount: 0, journal_entry_id: 'je-matched' },
{ debit_amount: 0, credit_amount: 300, journal_entry_id: 'je-lonely' },
],
})
// Candidate RPC: the lonely voucher is the one unmatched GL line.
enqueue({
data: [
{
line_id: 'l-lonely',
journal_entry_id: 'je-lonely',
debit_amount: 0,
credit_amount: 300,
entry_date: '2026-03-01',
voucher_number: 7,
voucher_series: 'A',
entry_description: 'Avgift',
source_type: 'manual',
linked_transaction_count: 0,
},
],
})
const status = await getReconciliationStatus(supabase as never, 'company-1')
expect(status.bank_transaction_total).toBe(1500)
expect(status.gl_1930_period_movement).toBe(700)
expect(status.difference).toBe(800)
// The two lists the user can actually open...
expect(status.unmatched_transaction_total).toBe(500)
expect(status.unmatched_gl_line_total).toBe(-300)
// ...account for every krona of it: 800 - 500 + (-300) = 0.
expect(status.unexplained_difference).toBe(0)
})
it('surfaces a residual when a matched pair disagrees in amount', async () => {
const { supabase, enqueue } = createQueueMockSupabase()
// The bank moved 1000 but the voucher it is matched to books only 900:
// nothing sits in either work list, yet the sides do not agree. This is the
// finding `difference` alone can never distinguish from ordinary backlog.
enqueue({
data: [{ amount: 1000, journal_entry_id: 'je-short', reconciliation_method: 'manual' }],
})
enqueue({ data: [{ id: 'je-short', status: 'posted', source_type: 'bank_transaction' }] })
enqueue({ data: [{ debit_amount: 900, credit_amount: 0, journal_entry_id: 'je-short' }] })
enqueue({ data: [] })
const status = await getReconciliationStatus(supabase as never, 'company-1')
expect(status.difference).toBe(100)
expect(status.unmatched_transaction_total).toBe(0)
expect(status.unmatched_gl_line_total).toBe(0)
expect(status.unexplained_difference).toBe(100)
})
it('reports no residual on a foreign account whose candidate lines carry no FX amount', async () => {
const { supabase, enqueue } = createQueueMockSupabase()
// EUR account. The candidate RPC projects neither currency nor
// amount_in_currency, so its lines cannot be expressed in EUR: summing the
// raw SEK columns would claim a EUR figure that is off by the rate.
enqueue({ data: [{ amount: 100, journal_entry_id: null, reconciliation_method: null }] })
enqueue({ data: [{ id: 'je-eur', status: 'posted', source_type: 'manual' }] })
enqueue({
data: [
{
debit_amount: 1150,
credit_amount: 0,
currency: 'EUR',
amount_in_currency: 100,
journal_entry_id: 'je-eur',
},
],
})
enqueue({
data: [
{
line_id: 'l-eur',
journal_entry_id: 'je-eur',
debit_amount: 1150,
credit_amount: 0,
entry_date: '2026-03-01',
voucher_number: 8,
voucher_series: 'A',
entry_description: 'EU-faktura',
source_type: 'manual',
linked_transaction_count: 0,
},
],
})
const status = await getReconciliationStatus(
supabase as never,
'company-1',
undefined,
undefined,
'1932',
'EUR',
)
// Never 0: that would assert the vouchers net to nothing.
expect(status.unmatched_gl_line_total).toBeNull()
expect(status.unexplained_difference).toBeNull()
// The count still works; only the sum is withheld.
expect(status.unmatched_gl_line_count).toBe(1)
})
it('reconciles a corrected bank receipt and keeps gl_1930_balance equal to the balance sheet', async () => {
// A +25000 deposit was booked to the wrong counter-account, then corrected
// via the storno flow: the original flips to 'reversed', a storno (credit
+86 -4
View File
@@ -158,7 +158,52 @@ export interface ReconciliationStatus {
is_reconciled: boolean
matched_count: number
unmatched_transaction_count: number
/**
* Sum of the unmatched bank transactions behind `unmatched_transaction_count`,
* in `currency`. Together with {@link unmatched_gl_line_total} this decomposes
* `difference` into the two work lists the user can actually open, instead of
* leaving it an unexplained scalar.
*/
unmatched_transaction_total: number
unmatched_gl_line_count: number
/**
* Sum of the unmatched ledger lines behind `unmatched_gl_line_count`, in
* `currency`, signed like a bank movement (+ in, - out).
*
* `null` when at least one of those lines carries no amount in `currency`:
* the two candidate RPCs project neither `currency` nor `amount_in_currency`,
* so on a foreign account every line is unconvertible and there is no honest
* sum to report. Reporting 0 there would claim the vouchers net to nothing.
* Always a number on a SEK account (see {@link ledgerLineAmountIn}).
*/
unmatched_gl_line_total: number | null
/**
* What is left of `difference` once both work lists are accounted for:
* `difference - unmatched_transaction_total + unmatched_gl_line_total`.
*
* `difference` is merely how far apart the two sides currently stand; mid-year
* it is expected to be large and it is fully explained as long as every krona
* of it sits in one of the two lists. The residual is what does NOT.
*
* It reduces to (sum of matched transactions - sum of the ledger lines they
* settle), so it is non-zero when a matched pair disagrees in amount, when one
* voucher carries several lines on this account, or when a ledger line the
* candidate RPC hides has no bank counterpart. That last cause dominates:
* `get_account_gl_lines_for_matching` returns only `status='posted'` entries
* and excludes storno / correction outright, so an unlinked storno moves this
* account's movement while staying invisible in "Omatchade verifikationer".
* Measured on prod 2026-08-20 over the 206 single-1930-account companies with
* >=10 transactions: 136 reconcile to exactly 0,00, 63 land >=100 kr out, and
* the unlinked-hidden-line buckets behind that are posted/storno (127
* companies), reversed/bank_transaction (66) and posted/correction (49).
*
* A non-zero residual is therefore a real finding but usually NOT user error,
* so the UI states it factually rather than in destructive red.
*
* `null` whenever `unmatched_gl_line_total` is null: no honest residual
* exists then either.
*/
unexplained_difference: number | null
/** Counted ledger lines on the account that carry no amount in `currency`
* (see {@link ledgerLineAmountIn}). Always 0 on a SEK account. */
unconvertible_gl_line_count: number
@@ -877,9 +922,12 @@ export async function getReconciliationStatus(
// rows behind bank_transaction_total.
const matchedCount = reconcilableTx.filter((tx) => tx.journal_entry_id !== null).length
const unmatchedTransactionCount = reconcilableTx.filter(
(tx) => tx.journal_entry_id === null
).length
const unmatchedTx = reconcilableTx.filter((tx) => tx.journal_entry_id === null)
const unmatchedTransactionCount = unmatchedTx.length
const unmatchedTransactionTotal = unmatchedTx.reduce(
(sum, tx) => sum + (Number(tx.amount) || 0),
0
)
// Unmatched GL lines count (RPC excludes opening_balance, storno and correction
// since 20260601120000_unlinked_gl_lines_exclude_storno_correction.sql).
@@ -887,10 +935,41 @@ export async function getReconciliationStatus(
// cash account (a transfer's other leg) counts as unmatched HERE, keeping this
// number in agreement with the "Omatchade verifikationer" table the
// reconciliation view derives from the same RPC.
const unlinkedLines = await fetchGLLinesForMatching(supabase, companyId, bankAccount, dateFrom, dateTo)
// effectiveFrom, NOT the caller's dateFrom: countedLines and countedTx are both
// clamped to the IB floor above, and this list has to describe the SAME window
// or the card contradicts itself. With the raw dateFrom, a window that opens
// before the account's opening balance (the v1 endpoint's "company history"
// default, or any multi-year range) counted vouchers from a period whose
// movements the reconciliation deliberately drops: unmatched_gl_line_count was
// inflated by prior-period history, and the bridge below could never close.
const unlinkedLines = await fetchGLLinesForMatching(
supabase,
companyId,
bankAccount,
effectiveFrom ?? undefined,
dateTo
)
const difference = Math.round((bankTotal - glPeriodMovement) * 100) / 100
// The candidate RPCs project neither `currency` nor `amount_in_currency`, so
// on a foreign account every line resolves to null and there is no sum to
// report. Deliberately all-or-nothing: a partial sum silently understates the
// side it is meant to explain. On SEK, ledgerLineAmountIn never returns null,
// so this is always a number for the 95% case.
const unmatchedGlAmounts = unlinkedLines.map((line) => ledgerLineAmountIn(line, currency))
const unmatchedGlLineTotal = unmatchedGlAmounts.some((a) => a === null)
? null
: roundOre(unmatchedGlAmounts.reduce((sum: number, a) => sum + (a ?? 0), 0))
// difference - unmatched transactions + unmatched vouchers. See the field doc:
// zero means every krona of the difference is identified and sitting in a list
// the user can open.
const unexplainedDifference =
unmatchedGlLineTotal === null
? null
: roundOre(difference - roundOre(unmatchedTransactionTotal) + unmatchedGlLineTotal)
const notReconcilableReason =
unconvertibleLines.length > 0 ? 'gl_lines_missing_currency_amount' : null
@@ -919,7 +998,10 @@ export async function getReconciliationStatus(
unmatchedTransactionCount === 0,
matched_count: matchedCount,
unmatched_transaction_count: unmatchedTransactionCount,
unmatched_transaction_total: roundOre(unmatchedTransactionTotal),
unmatched_gl_line_count: unlinkedLines.length,
unmatched_gl_line_total: unmatchedGlLineTotal,
unexplained_difference: unexplainedDifference,
unconvertible_gl_line_count: unconvertibleLines.length,
not_reconcilable_reason: notReconcilableReason,
}