fix(reconciliation): Bankavstämning phase 0 — correctness + feedback batch (+ nav IA regrouping) (#879)

* feat(nav): interaction-mode sidebar grouping — Arbeta/Analys/Data/Skatt & bokslut

Nav IA redesign phase 0 (dev_docs/nav_ia_redesign.md): same routes,
regrouped by what the user is doing. CLAUDE.md restructured around Hard
Rules (doc references updated); pending-page explainer removed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(reconciliation): correctness + feedback batch for Bankavstämning (phase 0)

Engine: fetchAllRows pagination on status/run/RPC fetches (silent 1000-row
cap corrupted totals), optimistic-lock guards on manualLink + apply,
unlink audit rows attributed to the acting user (was: company UUID),
selected_matches partial apply intersected with a fresh match run.

View: silent in-place refresh instead of a full-page skeleton per action,
checkbox-gated apply with confidence badges (fuzzy unticked) in chunks of
500, honest result toasts, dry-run errors surfaced, ranked per-row picker
candidates pinned to the applied date window, currency-correct amounts
(bank side in account currency, GL side SEK), voucher links, translated
source types, colored differens, dirty-date-filter guard.

Discovery: year-end preflight 404 href fixed (/reconciliation/bank never
existed), ⌘K palette entry, real links from the transactions page.

v1: status registry schema now matches the actual ReconciliationStatus
payload, errors documented as a count, false ~0.85-threshold pitfall
replaced, route test mocks the real shape.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-03 11:21:38 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 59ecaee650
commit ea236cbcdf
20 changed files with 1124 additions and 563 deletions
@@ -55,7 +55,7 @@ function enqueueManualLinkSuccess(
enqueue({ data: makeTransaction({ id: 'tx-1', journal_entry_id: null, cash_account_id: null, amount: txAmount, currency: 'SEK' }) })
enqueue({ data: { id: 'je-1', user_id: 'company-1', status: 'posted' } })
enqueue({ data: [{ debit_amount: Math.max(txAmount, 0), credit_amount: Math.max(-txAmount, 0), account_number: '1930' }] })
enqueue({ data: null, error: null }) // update
enqueue({ data: [{ id: 'tx-1' }] }) // update — .select('id') returns the updated row
}
describe('autoReconcileTransactionForLinkedVoucher', () => {
@@ -445,8 +445,8 @@ describe('runReconciliation', () => {
enqueue({ data: [glLine] })
// from('transactions') returns unmatched transactions
enqueue({ data: [tx] })
// Update transaction with link
enqueue({ data: null, error: null })
// Update transaction with link — .select('id') returns the updated row
enqueue({ data: [{ id: 'tx-1' }] })
const result = await runReconciliation(supabase as never, 'company-1', 'user-1', { dryRun: false })
@@ -454,6 +454,90 @@ describe('runReconciliation', () => {
expect(result.applied).toBe(1)
expect(result.errors).toBe(0)
})
it('counts a conflicted apply (0 rows updated) as an error, not applied', async () => {
const { supabase, enqueue } = createQueueMockSupabase()
const tx = makeTransaction({ id: 'tx-1', amount: -500, date: '2024-06-15', currency: 'SEK' })
const glLine: UnlinkedGLLine = makeGLLine({
line_id: 'line-1',
journal_entry_id: 'je-1',
credit_amount: 500,
entry_date: '2024-06-15',
})
enqueue({ data: [glLine] })
enqueue({ data: [tx] })
// Optimistic-lock guard: a concurrent linker got there first — the
// .is('journal_entry_id', null) filter matches zero rows.
enqueue({ data: [] })
const result = await runReconciliation(supabase as never, 'company-1', 'user-1', { dryRun: false })
expect(result.applied).toBe(0)
expect(result.errors).toBe(1)
})
it('applies only the pairs in applyOnly, intersected with the fresh match run', async () => {
const { supabase, enqueue } = createQueueMockSupabase()
const tx1 = makeTransaction({ id: 'tx-1', amount: 1000, date: '2024-06-15', currency: 'SEK' })
const tx2 = makeTransaction({ id: 'tx-2', amount: -500, date: '2024-06-15', currency: 'SEK' })
const line1 = makeGLLine({
line_id: 'line-1',
journal_entry_id: 'je-1',
debit_amount: 1000,
entry_date: '2024-06-15',
})
const line2 = makeGLLine({
line_id: 'line-2',
journal_entry_id: 'je-2',
credit_amount: 500,
entry_date: '2024-06-15',
})
enqueue({ data: [line1, line2] })
enqueue({ data: [tx1, tx2] })
// Only ONE update should run — for the single selected pair.
enqueue({ data: [{ id: 'tx-2' }] })
const result = await runReconciliation(supabase as never, 'company-1', 'user-1', {
dryRun: false,
applyOnly: [
{ transactionId: 'tx-2', journalEntryId: 'je-2' },
// A pair the matcher never proposed must be ignored, not applied.
{ transactionId: 'tx-99', journalEntryId: 'je-99' },
],
})
expect(result.matches).toHaveLength(1)
expect(result.matches[0].transaction.id).toBe('tx-2')
expect(result.applied).toBe(1)
expect(result.errors).toBe(0)
})
it('ignores applyOnly on dry runs and returns the full match set', async () => {
const { supabase, enqueue } = createQueueMockSupabase()
const tx1 = makeTransaction({ id: 'tx-1', amount: 1000, date: '2024-06-15', currency: 'SEK' })
const line1 = makeGLLine({
line_id: 'line-1',
journal_entry_id: 'je-1',
debit_amount: 1000,
entry_date: '2024-06-15',
})
enqueue({ data: [line1] })
enqueue({ data: [tx1] })
const result = await runReconciliation(supabase as never, 'company-1', 'user-1', {
dryRun: true,
applyOnly: [],
})
expect(result.matches).toHaveLength(1)
expect(result.applied).toBe(0)
})
})
// ============================================================
@@ -569,14 +653,31 @@ describe('manualLink', () => {
enqueue({ data: { id: 'je-1', user_id: 'company-1', status: 'posted' } })
// Line exists on the selected account
enqueue({ data: [{ debit_amount: 1000, credit_amount: 0, account_number: '1930' }] })
// Update succeeds
enqueue({ data: null, error: null })
// Update succeeds — .select('id') returns the updated row
enqueue({ data: [{ id: 'tx-1' }] })
const result = await manualLink(supabase as never, 'company-1', 'tx-1', 'je-1', 'user-1', '1930')
expect(result.success).toBe(true)
})
it('rejects when a concurrent linker won the race (0 rows updated)', async () => {
const { supabase, enqueue } = createQueueMockSupabase()
const tx = makeTransaction({ id: 'tx-1', journal_entry_id: null })
enqueue({ data: tx })
enqueue({ data: { id: 'je-1', user_id: 'company-1', status: 'posted' } })
enqueue({ data: [{ debit_amount: 1000, credit_amount: 0, account_number: '1930' }] })
// The .is('journal_entry_id', null) optimistic-lock filter matched nothing:
// another session linked the transaction between our read and this write.
enqueue({ data: [] })
const result = await manualLink(supabase as never, 'company-1', 'tx-1', 'je-1', 'user-1', '1930')
expect(result.success).toBe(false)
expect(result.error).toBe('Transaktionen är redan kopplad till en verifikation.')
})
it('succeeds for a bound transaction when the account matches', async () => {
const { supabase, enqueue } = createQueueMockSupabase()
const tx = makeTransaction({
@@ -591,8 +692,8 @@ describe('manualLink', () => {
enqueue({ data: { ledger_account: '1930' } })
// Line exists on 1930
enqueue({ data: [{ debit_amount: 1000, credit_amount: 0, account_number: '1930' }] })
// Update succeeds
enqueue({ data: null, error: null })
// Update succeeds — .select('id') returns the updated row
enqueue({ data: [{ id: 'tx-1' }] })
const result = await manualLink(supabase as never, 'company-1', 'tx-1', 'je-1', 'user-1', '1930')
@@ -612,7 +713,7 @@ describe('manualLink', () => {
enqueue({ data: { id: 'je-1', user_id: 'company-1', status: 'posted' } })
enqueue({ data: [{ debit_amount: 1000, credit_amount: 0, account_number: '1930' }] })
// Update succeeds — note there is NO existing-link lookup in the sequence.
enqueue({ data: null, error: null })
enqueue({ data: [{ id: 'tx-2' }] })
const result = await manualLink(supabase as never, 'company-1', 'tx-2', 'je-1', 'user-1', '1930')
@@ -672,7 +773,7 @@ describe('unlinkReconciliation', () => {
},
})
const result = await unlinkReconciliation(supabase as never, 'company-1', 'tx-1')
const result = await unlinkReconciliation(supabase as never, 'company-1', 'tx-1', 'user-1')
expect(result.success).toBe(false)
expect(result.error).toContain('Cannot unlink')
@@ -692,10 +793,52 @@ describe('unlinkReconciliation', () => {
// Update succeeds
enqueue({ data: null, error: null })
const result = await unlinkReconciliation(supabase as never, 'company-1', 'tx-1')
const result = await unlinkReconciliation(supabase as never, 'company-1', 'tx-1', 'user-1')
expect(result.success).toBe(true)
})
it('attributes the audit log row to the acting user, not the company', async () => {
// Regression: unlinkReconciliation used to pass companyId where
// logMatchEvent expects userId, so payment_match_log.user_id recorded the
// company UUID (or the insert failed its FK silently).
const inserts: Record<string, unknown>[] = []
const resultQueue: { data: unknown; error: unknown }[] = [
{
data: { id: 'tx-1', journal_entry_id: 'je-1', reconciliation_method: 'manual' },
error: null,
},
{ data: null, error: null }, // update
]
const buildChain = (table?: string): unknown => {
const handler: ProxyHandler<object> = {
get(_target, prop) {
if (prop === 'then') {
const next = resultQueue.shift() ?? { data: null, error: null }
return (resolve: (v: unknown) => void) => resolve(next)
}
if (prop === 'insert') {
return (row: Record<string, unknown>) => {
if (table === 'payment_match_log') inserts.push(row)
return buildChain(table)
}
}
return (..._args: unknown[]) => buildChain(table)
},
}
return new Proxy({}, handler)
}
const supabase = {
from: vi.fn().mockImplementation((table: string) => buildChain(table)),
rpc: vi.fn().mockImplementation(() => buildChain()),
}
const result = await unlinkReconciliation(supabase as never, 'company-1', 'tx-1', 'user-1')
expect(result.success).toBe(true)
expect(inserts).toHaveLength(1)
expect(inserts[0].user_id).toBe('user-1')
})
})
// ============================================================