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:
co-authored by
Claude Opus 4.8
parent
b5c3c3ec04
commit
094bd85e81
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user