fix(reconciliation): secondary-account scoping, dialog clipping, and N:1 matching (#624)

* fix(reconciliation): secondary-account scoping, dialog clipping, and N:1 matching

Three follow-ups to per-account bank reconciliation (PR #623):

- Secondary same-currency accounts (e.g. a 1931 savings account) double-counted
  the company's unassigned (NULL cash_account_id) transactions, inflating their
  bank total and showing a large bogus difference while 1930 still reconciled.
  Only the primary cash account now claims NULL rows; every other account scopes
  strictly to its own id. `includeUnassigned` is threaded through all
  status/run/list call sites from cash_accounts.is_primary.

- The "Matcha mot befintlig verifikation" picker's dropdown was absolutely
  positioned inside the dialog's overflow-y-auto container and got clipped. Add
  an `inline` mode that renders the candidate list in normal flow; the dialog
  uses it, the reconciliation view keeps the compact overlay.

- N:1 matching: several bank transactions can now settle one verifikat (a salary
  run paid in multiple transfers, an invoice paid in instalments). New
  get_account_gl_lines_for_matching RPC surfaces already-matched vouchers with a
  linked_transaction_count behind a "Visa även matchade verifikationer" toggle;
  manualLink's 1:1 guard is relaxed (the aggregate difference still catches
  mis-links).

Tests: extended bank-reconciliation unit tests (strict scope + N:1), rewrote the
cash_account_id isolation pg test to prove NULL rows land on the primary account
only, and added a pg test for the new RPC.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(reconciliation): address PR review — accurate "att matcha mot" count

- BankReconciliationView: the "N verifikationer att matcha mot" hint counted
  glLines (which includes already-matched vouchers when "Visa matchade" is on),
  overcounting the vouchers that still need a transaction. Use unmatchedGlLines
  so the label is correct regardless of the toggle (matches the table below).
- MatchVerifikationPicker: document that `open` is overlay-only; the setOpen()
  writes are intentional no-ops in inline mode.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-06-02 09:37:30 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent b5c3c3ec04
commit 094bd85e81
14 changed files with 635 additions and 151 deletions
@@ -0,0 +1,134 @@
/**
* pg-real test for get_account_gl_lines_for_matching
* (20260610120000_gl_lines_for_matching.sql).
*
* This RPC backs the N:1 "lägga på flera" feature: it mirrors get_unlinked_gl_lines
* but can ALSO surface already-matched vouchers (so a second/third bank
* transaction can be attached to one verifikat), each carrying how many
* transactions already point at it.
*/
import { describe, it, expect } from 'vitest'
import { randomUUID } from 'node:crypto'
import { getPool } from './setup'
import { insertAuthUser, insertCompany, insertFiscalPeriod, insertTransaction } from './fixtures'
async function insertPostedJournalEntry(params: {
userId: string
companyId: string
fiscalPeriodId: string
entryDate: string
sourceType: 'opening_balance' | 'manual' | 'bank_transaction' | 'import' | 'storno' | 'correction'
voucherNumber: number
amount?: number
}): Promise<string> {
const id = randomUUID()
const amount = params.amount ?? 1000
await getPool().query(
`INSERT INTO public.journal_entries
(id, user_id, company_id, fiscal_period_id, voucher_number, voucher_series,
entry_date, description, source_type, status)
VALUES ($1, $2, $3, $4, $5, 'A', $6, $7, $8, 'posted')`,
[
id,
params.userId,
params.companyId,
params.fiscalPeriodId,
params.voucherNumber,
params.entryDate,
`Test ${params.sourceType}`,
params.sourceType,
],
)
await getPool().query(
`INSERT INTO public.journal_entry_lines
(journal_entry_id, account_number, debit_amount, credit_amount)
VALUES ($1, '1930', $2, 0),
($1, '2091', 0, $2)`,
[id, amount],
)
return id
}
describe('get_account_gl_lines_for_matching RPC — N:1 candidates', () => {
it('returns already-matched vouchers (with link count) only when p_include_matched is true', async () => {
const userId = await insertAuthUser()
const companyId = await insertCompany({ createdBy: userId })
const fiscalPeriodId = await insertFiscalPeriod({
userId, companyId, periodStart: '2026-01-01', periodEnd: '2026-12-31',
})
// One unmatched voucher, one voucher already settled by TWO transactions
// (the salary-run-paid-in-two-transfers shape).
const unmatchedEntry = await insertPostedJournalEntry({
userId, companyId, fiscalPeriodId,
entryDate: '2026-03-15', sourceType: 'bank_transaction', voucherNumber: 1, amount: 1500,
})
const matchedEntry = await insertPostedJournalEntry({
userId, companyId, fiscalPeriodId,
entryDate: '2026-03-20', sourceType: 'manual', voucherNumber: 2, amount: 30000,
})
await insertTransaction({ companyId, userId, currency: 'SEK', journalEntryId: matchedEntry })
await insertTransaction({ companyId, userId, currency: 'SEK', journalEntryId: matchedEntry })
// Default (p_include_matched=false): parity with get_unlinked_gl_lines — only
// the unmatched voucher, count 0.
const { rows: unmatchedOnly } = await getPool().query(
`SELECT journal_entry_id, linked_transaction_count
FROM public.get_account_gl_lines_for_matching(p_company_id => $1)`,
[companyId],
)
const unmatchedIds = new Set(unmatchedOnly.map((r) => r.journal_entry_id))
expect(unmatchedIds.has(unmatchedEntry)).toBe(true)
expect(unmatchedIds.has(matchedEntry)).toBe(false)
expect(unmatchedOnly.find((r) => r.journal_entry_id === unmatchedEntry).linked_transaction_count).toBe(0)
// p_include_matched=true: the matched voucher appears too, reporting both links.
const { rows: withMatched } = await getPool().query(
`SELECT journal_entry_id, linked_transaction_count
FROM public.get_account_gl_lines_for_matching(p_company_id => $1, p_include_matched => true)`,
[companyId],
)
const byId = new Map(withMatched.map((r) => [r.journal_entry_id, r.linked_transaction_count]))
expect(byId.get(unmatchedEntry)).toBe(0)
expect(byId.get(matchedEntry)).toBe(2)
})
it('still excludes opening_balance / storno / correction even with p_include_matched', async () => {
const userId = await insertAuthUser()
const companyId = await insertCompany({ createdBy: userId })
const fiscalPeriodId = await insertFiscalPeriod({
userId, companyId, periodStart: '2026-01-01', periodEnd: '2026-12-31',
})
// These book-only / IB vouchers have no bank-feed counterpart and can never
// be a match target — the include_matched opt-in must not resurrect them.
await insertPostedJournalEntry({
userId, companyId, fiscalPeriodId,
entryDate: '2026-01-01', sourceType: 'opening_balance', voucherNumber: 1, amount: 50000,
})
await insertPostedJournalEntry({
userId, companyId, fiscalPeriodId,
entryDate: '2026-05-02', sourceType: 'storno', voucherNumber: 2, amount: 25000,
})
await insertPostedJournalEntry({
userId, companyId, fiscalPeriodId,
entryDate: '2026-05-02', sourceType: 'correction', voucherNumber: 3, amount: 25000,
})
const bankEntry = await insertPostedJournalEntry({
userId, companyId, fiscalPeriodId,
entryDate: '2026-05-03', sourceType: 'bank_transaction', voucherNumber: 4, amount: 1500,
})
const { rows } = await getPool().query(
`SELECT journal_entry_id, source_type
FROM public.get_account_gl_lines_for_matching(p_company_id => $1, p_include_matched => true)`,
[companyId],
)
const returnedIds = new Set(rows.map((r) => r.journal_entry_id))
expect(returnedIds.has(bankEntry)).toBe(true)
expect(rows.find((r) => r.source_type === 'opening_balance')).toBeUndefined()
expect(rows.find((r) => r.source_type === 'storno')).toBeUndefined()
expect(rows.find((r) => r.source_type === 'correction')).toBeUndefined()
})
})
@@ -207,7 +207,7 @@ describe('transactions.cash_account_id — backfill pass (c) single-account-of-c
})
describe('transactions.cash_account_id — account-scoped query isolation', () => {
it('scopes to one account with a NULL→currency fallback, never leaking same-currency rows', async () => {
it('only the primary account claims NULL rows; a secondary same-currency account stays strict', async () => {
const { userId, companyId } = await seedCompany()
const ca1930 = await insertCashAccount({ companyId, ledgerAccount: '1930', currency: 'SEK' })
const ca1931 = await insertCashAccount({ companyId, ledgerAccount: '1931', currency: 'SEK' })
@@ -217,27 +217,41 @@ describe('transactions.cash_account_id — account-scoped query isolation', () =
const txNullSek = await insertTransaction({ companyId, userId, currency: 'SEK' })
const txNullEur = await insertTransaction({ companyId, userId, currency: 'EUR' })
// Mirror the runtime predicate:
// cash_account_id = X OR (cash_account_id IS NULL AND currency = cur)
const scoped = async (cashAccountId: string, currency: string): Promise<string[]> => {
// Mirror the runtime predicate from scopeTransactionsToAccount(). The
// `includeUnassigned` flag is the account's cash_accounts.is_primary in the
// real code: ONLY the primary account claims unassigned (NULL) rows.
// primary: cash_account_id = X OR (cash_account_id IS NULL AND currency = cur)
// secondary: cash_account_id = X AND currency = cur
const scoped = async (
cashAccountId: string,
currency: string,
includeUnassigned: boolean,
): Promise<string[]> => {
const predicate = includeUnassigned
? `(cash_account_id = $2 OR (cash_account_id IS NULL AND currency = $3))`
: `(cash_account_id = $2 AND currency = $3)`
const { rows } = await getPool().query(
`SELECT id FROM public.transactions
WHERE company_id = $1
AND (cash_account_id = $2 OR (cash_account_id IS NULL AND currency = $3))`,
WHERE company_id = $1 AND ${predicate}`,
[companyId, cashAccountId, currency],
)
return rows.map((r) => r.id)
}
const for1930 = await scoped(ca1930, 'SEK')
// Primary (1930) claims the legacy NULL SEK row via the fallback.
const for1930 = await scoped(ca1930, 'SEK', true)
expect(for1930).toContain(tx1930)
expect(for1930).toContain(txNullSek) // legacy NULL row visible via fallback
expect(for1930).not.toContain(tx1931) // the other account never leaks
expect(for1930).not.toContain(txNullEur) // wrong-currency NULL excluded
const for1931 = await scoped(ca1931, 'SEK')
// Secondary (1931) is strict. Pulling in the NULL row would double-count
// what belongs to 1930 — the user-reported "1930 works but the other
// accounts go wonky" bug, where 1930's unassigned rows inflated 1931's bank
// total and produced a large bogus difference.
const for1931 = await scoped(ca1931, 'SEK', false)
expect(for1931).toContain(tx1931)
expect(for1931).toContain(txNullSek)
expect(for1931).not.toContain(txNullSek) // the fix: no double-count onto 1931
expect(for1931).not.toContain(tx1930)
})
})