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
+12
View File
@@ -1421,6 +1421,18 @@ export const RunReconciliationSchema = z.object({
.regex(/^[0-9]{4}$/, 'Kontonummer måste vara 4 siffror')
.optional(),
dry_run: z.boolean().optional(),
// Pairs the user ticked in the dry-run preview. When present on an apply
// (dry_run false), only these pairs are committed — intersected server-side
// with a fresh match run, so a stale or fabricated pair is never applied.
selected_matches: z
.array(
z.object({
transaction_id: uuid,
journal_entry_id: uuid,
}),
)
.max(500)
.optional(),
})
// ============================================================
+4 -1
View File
@@ -126,7 +126,10 @@ export async function buildBokslutReadinessReport(
reconciliation.unmatched_transaction_count > 0
? `${reconciliation.unmatched_transaction_count} banktransaktioner är inte matchade. Avstäm banken innan bokslut.`
: `Bankavstämningen visar en differens på ${reconciliation.difference.toFixed(2)} kr.`,
href: '/reconciliation/bank',
// Bankavstämning's real route — the earlier '/reconciliation/bank' href
// pointed at a page that has never existed, so the wizard's "Öppna"
// link 404ed.
href: '/reports/bank-reconciliation',
})
}
@@ -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')
})
})
// ============================================================
+140 -62
View File
@@ -2,6 +2,7 @@ import type { SupabaseClient } from '@supabase/supabase-js'
import type { Transaction, ReconciliationMethod } from '@/types'
import { eventBus } from '@/lib/events/bus'
import { logMatchEvent } from '@/lib/invoices/match-log'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
// ============================================================
// Types
@@ -102,6 +103,15 @@ export interface ReconciliationOptions {
* currency-only callers (where cashAccountId is omitted and this is moot).
*/
includeUnassigned?: boolean
/**
* Apply only these transaction↔journal-entry pairs (ignored on dry runs).
* The UI's dry-run preview lets the user untick suspicious matches; a
* subsequent apply passes the ticked pairs here so the server never commits
* a match the user excluded — and never commits a pair the matcher itself
* didn't propose on the re-run, since the filter intersects with the fresh
* match set rather than trusting the client's pairs blindly.
*/
applyOnly?: Array<{ transactionId: string; journalEntryId: string }>
}
/**
@@ -251,43 +261,62 @@ export async function runReconciliation(
currency = 'SEK',
cashAccountId,
includeUnassigned = true,
applyOnly,
} = options
// Fetch unlinked GL lines via RPC
const glLines = await fetchUnlinkedGLLines(supabase, companyId, accountNumber, dateFrom, dateTo)
// Fetch unmatched transactions, scoped to the selected cash account.
let query = supabase
.from('transactions')
.select('*')
.eq('company_id', companyId)
.is('journal_entry_id', null)
.eq('is_ignored', false)
query = scopeTransactionsToAccount(query, cashAccountId, currency, includeUnassigned)
// Paginated: a busy company can exceed PostgREST's silent 1000-row cap, which
// would make the matcher skip transactions without any signal. Ordered on id
// (unique) so pages never duplicate or skip rows.
const transactions = await fetchAllRows<Transaction>(({ from, to }) => {
let query = supabase
.from('transactions')
.select('*')
.eq('company_id', companyId)
.is('journal_entry_id', null)
.eq('is_ignored', false)
query = scopeTransactionsToAccount(query, cashAccountId, currency, includeUnassigned)
if (dateFrom) query = query.gte('date', dateFrom)
if (dateTo) query = query.lte('date', dateTo)
return query.order('id').range(from, to)
})
if (dateFrom) query = query.gte('date', dateFrom)
if (dateTo) query = query.lte('date', dateTo)
const { data: transactions } = await query
if (!transactions || transactions.length === 0 || glLines.length === 0) {
if (transactions.length === 0 || glLines.length === 0) {
return { matches: [], applied: 0, errors: 0 }
}
// Run greedy matching, highest confidence first
const matches = greedyMatch(transactions as Transaction[], glLines, currency)
let matches = greedyMatch(transactions, glLines, currency)
if (dryRun) {
return { matches, applied: 0, errors: 0 }
}
// When the caller reviewed a dry-run and ticked a subset, apply ONLY pairs
// that BOTH the user selected AND the fresh match run still proposes — the
// intersection guards against data that changed between preview and apply.
if (applyOnly) {
const selected = new Set(applyOnly.map((p) => `${p.transactionId}:${p.journalEntryId}`))
matches = matches.filter((m) =>
selected.has(`${m.transaction.id}:${m.glLine.journal_entry_id}`),
)
}
// Apply matches
let applied = 0
let errors = 0
for (const match of matches) {
try {
const { error } = await supabase
// .is('journal_entry_id', null) is an optimistic-lock guard: if a
// concurrent user (or another surface) linked this transaction between
// the read above and this write, the update matches zero rows instead of
// silently re-pointing an existing link. Same pattern as
// lib/transactions/link-journal-entry.ts.
const { data: updatedRows, error } = await supabase
.from('transactions')
.update({
journal_entry_id: match.glLine.journal_entry_id,
@@ -296,8 +325,10 @@ export async function runReconciliation(
})
.eq('id', match.transaction.id)
.eq('company_id', companyId)
.is('journal_entry_id', null)
.select('id')
if (error) {
if (error || !updatedRows || updatedRows.length === 0) {
errors++
} else {
applied++
@@ -353,16 +384,27 @@ export async function getReconciliationStatus(
// user has explicitly said they don't want them surfacing as something to
// reconcile. Scoping by cash account (not just currency) is what stops a
// second same-currency account from inflating bankTotal here.
let txQuery = supabase
.from('transactions')
.select('date, amount, journal_entry_id, reconciliation_method, is_ignored')
.eq('company_id', companyId)
txQuery = scopeTransactionsToAccount(txQuery, cashAccountId, currency, includeUnassigned)
if (dateFrom) txQuery = txQuery.gte('date', dateFrom)
if (dateTo) txQuery = txQuery.lte('date', dateTo)
const { data: transactions } = await txQuery
// Paginated (fetchAllRows): PostgREST silently caps un-ranged selects at 1000
// rows, which would undercount bank_transaction_total for a busy company and
// manufacture a phantom, unexplainable difference. Ordered on id (unique) so
// pages never duplicate or skip rows across boundaries.
type StatusTxRow = {
date: string | null
amount: number | string | null
journal_entry_id: string | null
reconciliation_method: string | null
is_ignored: boolean | null
}
const transactions = await fetchAllRows<StatusTxRow>(({ from, to }) => {
let txQuery = supabase
.from('transactions')
.select('date, amount, journal_entry_id, reconciliation_method, is_ignored')
.eq('company_id', companyId)
txQuery = scopeTransactionsToAccount(txQuery, cashAccountId, currency, includeUnassigned)
if (dateFrom) txQuery = txQuery.gte('date', dateFrom)
if (dateTo) txQuery = txQuery.lte('date', dateTo)
return txQuery.order('id').range(from, to)
})
// Get GL bank-account lines. We fetch posted AND reversed entries and count
// them TOGETHER — the exact inclusion rule the trial balance and balance sheet
@@ -374,18 +416,6 @@ export async function getReconciliationStatus(
// the headline bug this widget had (a corrected bank receipt showed one figure
// here and a different one on the balance sheet). source_type is still pulled
// so we can split out the opening balance and surface correction activity.
let glQuery = supabase
.from('journal_entry_lines')
.select('debit_amount, credit_amount, journal_entries!inner(id, company_id, entry_date, status, source_type)')
.eq('account_number', bankAccount)
.eq('journal_entries.company_id', companyId)
.in('journal_entries.status', ['posted', 'reversed'])
if (dateFrom) glQuery = glQuery.gte('journal_entries.entry_date', dateFrom)
if (dateTo) glQuery = glQuery.lte('journal_entries.entry_date', dateTo)
const { data: glLines } = await glQuery
type GlEntry = {
id?: string | null
status?: string | null
@@ -410,7 +440,19 @@ export async function getReconciliationStatus(
// posted + reversed = the ledger balance, exactly as the trial balance counts
// it. The .in() filter on the query already excludes draft/cancelled.
const fetchedLines = (glLines || []) as GlLineRow[]
// Paginated for the same 1000-row-cap reason as the transactions above — a
// silently truncated GL side would corrupt gl_1930_balance and the difference.
const fetchedLines = await fetchAllRows<GlLineRow>(({ from, to }) => {
let glQuery = supabase
.from('journal_entry_lines')
.select('debit_amount, credit_amount, journal_entries!inner(id, company_id, entry_date, status, source_type)')
.eq('account_number', bankAccount)
.eq('journal_entries.company_id', companyId)
.in('journal_entries.status', ['posted', 'reversed'])
if (dateFrom) glQuery = glQuery.gte('journal_entries.entry_date', dateFrom)
if (dateTo) glQuery = glQuery.lte('journal_entries.entry_date', dateTo)
return glQuery.order('id').range(from, to)
})
// Floor the window at the most recent opening-balance date on this account
// (issue #751). Everything dated before that IB is prior history the IB entry
@@ -597,8 +639,11 @@ export async function manualLink(
// already-matched voucher when the user opts in via "Visa även matchade
// verifikationer", so this can't happen by accident.
// Apply link
const { error: updateError } = await supabase
// Apply link. The .is('journal_entry_id', null) guard re-checks the "not
// already linked" precondition inside the write itself — the read above is
// advisory, and two concurrent linkers would otherwise silently re-point the
// row (same optimistic-lock pattern as lib/transactions/link-journal-entry.ts).
const { data: updatedRows, error: updateError } = await supabase
.from('transactions')
.update({
journal_entry_id: journalEntryId,
@@ -607,10 +652,15 @@ export async function manualLink(
})
.eq('id', transactionId)
.eq('company_id', companyId)
.is('journal_entry_id', null)
.select('id')
if (updateError) {
return { success: false, error: 'Kunde inte koppla transaktionen. Försök igen.' }
}
if (!updatedRows || updatedRows.length === 0) {
return { success: false, error: 'Transaktionen är redan kopplad till en verifikation.' }
}
try {
eventBus.emit({
@@ -637,7 +687,8 @@ export async function manualLink(
export async function unlinkReconciliation(
supabase: SupabaseClient,
companyId: string,
transactionId: string
transactionId: string,
userId: string,
): Promise<{ success: boolean; error?: string }> {
// Fetch transaction
const { data: tx, error: txError } = await supabase
@@ -673,7 +724,7 @@ export async function unlinkReconciliation(
return { success: false, error: 'Failed to unlink transaction' }
}
logMatchEvent(supabase, companyId, transactionId, 'unmatched', {
logMatchEvent(supabase, userId, transactionId, 'unmatched', {
previousState: {
journal_entry_id: tx.journal_entry_id,
reconciliation_method: tx.reconciliation_method,
@@ -900,15 +951,30 @@ export async function fetchUnlinkedGLLines(
dateFrom?: string,
dateTo?: string,
): Promise<UnlinkedGLLine[]> {
const { data, error } = await supabase.rpc('get_unlinked_gl_lines', {
p_company_id: companyId,
p_account_number: accountNumber,
p_date_from: dateFrom || null,
p_date_to: dateTo || null,
})
if (error || !data) return []
return data as UnlinkedGLLine[]
// Paginated: the RPC returns SETOF and is subject to the same silent
// 1000-row PostgREST cap as table selects; truncation here would hide match
// candidates and undercount unmatched_gl_line_count. The .order() chain
// preserves the RPC's chronological order for consumers (the UI table, the
// picker) while the unique line_id tiebreaker keeps pages stable — several
// lines of one entry share entry_date/voucher_number. Errors keep the legacy
// contract: callers get [] rather than a throw.
try {
return await fetchAllRows<UnlinkedGLLine>(({ from, to }) =>
supabase
.rpc('get_unlinked_gl_lines', {
p_company_id: companyId,
p_account_number: accountNumber,
p_date_from: dateFrom || null,
p_date_to: dateTo || null,
})
.order('entry_date')
.order('voucher_number')
.order('line_id')
.range(from, to),
)
} catch {
return []
}
}
/** A match candidate that carries how many transactions already point at it. */
@@ -933,17 +999,29 @@ export async function fetchGLLinesForMatching(
dateTo?: string,
includeMatched: boolean = false,
): Promise<GLLineForMatching[]> {
const { data, error } = await supabase.rpc('get_account_gl_lines_for_matching', {
p_company_id: companyId,
p_account_number: accountNumber,
p_date_from: dateFrom || null,
p_date_to: dateTo || null,
p_include_matched: includeMatched,
})
if (error || !data) return []
// Paginated + ordered chronologically with the unique line_id tiebreaker,
// for the same reasons as fetchUnlinkedGLLines.
let data: GLLineForMatching[]
try {
data = await fetchAllRows<GLLineForMatching>(({ from, to }) =>
supabase
.rpc('get_account_gl_lines_for_matching', {
p_company_id: companyId,
p_account_number: accountNumber,
p_date_from: dateFrom || null,
p_date_to: dateTo || null,
p_include_matched: includeMatched,
})
.order('entry_date')
.order('voucher_number')
.order('line_id')
.range(from, to),
)
} catch {
return []
}
// count(*) can arrive as a bigint string over the wire — coerce defensively.
return (data as GLLineForMatching[]).map((line) => ({
return data.map((line) => ({
...line,
linked_transaction_count: Number(line.linked_transaction_count) || 0,
}))