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
@@ -79,6 +79,25 @@ describe('scopeTransactionsToAccount', () => {
|
||||
expect(String(orCall?.args[0])).not.toContain('and(')
|
||||
})
|
||||
|
||||
it('scopes strictly to the account (no NULL fallback) when includeUnassigned is false', () => {
|
||||
const { self, calls } = makeQueryStub()
|
||||
const id = '22222222-2222-2222-2222-222222222222'
|
||||
|
||||
// includeUnassigned=false is the non-primary account case: a secondary
|
||||
// same-currency account (e.g. a 1931 savings account) must NOT pull in the
|
||||
// company's unassigned NULL rows — those belong to the primary account.
|
||||
// Double-counting them inflated the secondary account's bank total and
|
||||
// showed a large bogus difference ("1930 works, the other accounts go wonky").
|
||||
scopeTransactionsToAccount(self as never, id, 'SEK', false)
|
||||
|
||||
expect(calls).toEqual([
|
||||
{ method: 'eq', args: ['currency', 'SEK'] },
|
||||
{ method: 'eq', args: ['cash_account_id', id] },
|
||||
])
|
||||
// No OR — the IS NULL fallback must not appear for a non-primary account.
|
||||
expect(calls.find((c) => c.method === 'or')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('falls back to a pure currency filter when no cash account id is given', () => {
|
||||
const { self, calls } = makeQueryStub()
|
||||
|
||||
@@ -550,8 +569,6 @@ 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' }] })
|
||||
// No existing link
|
||||
enqueue({ data: null, error: null })
|
||||
// Update succeeds
|
||||
enqueue({ data: null, error: null })
|
||||
|
||||
@@ -574,8 +591,6 @@ describe('manualLink', () => {
|
||||
enqueue({ data: { ledger_account: '1930' } })
|
||||
// Line exists on 1930
|
||||
enqueue({ data: [{ debit_amount: 1000, credit_amount: 0, account_number: '1930' }] })
|
||||
// No existing link
|
||||
enqueue({ data: null, error: null })
|
||||
// Update succeeds
|
||||
enqueue({ data: null, error: null })
|
||||
|
||||
@@ -583,6 +598,26 @@ describe('manualLink', () => {
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('allows N:1 — does not reject when the verifikat already has a linked transaction', async () => {
|
||||
const { supabase, enqueue } = createQueueMockSupabase()
|
||||
// This transaction is itself unlinked; the TARGET entry already has another
|
||||
// transaction pointing at it. manualLink no longer queries for / rejects
|
||||
// that — several bank transactions may settle one verifikat (a salary run
|
||||
// paid in multiple transfers). The only per-transaction guard is that THIS
|
||||
// transaction isn't already linked (tx.journal_entry_id), still enforced.
|
||||
const tx = makeTransaction({ id: 'tx-2', 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' }] })
|
||||
// Update succeeds — note there is NO existing-link lookup in the sequence.
|
||||
enqueue({ data: null, error: null })
|
||||
|
||||
const result = await manualLink(supabase as never, 'company-1', 'tx-2', 'je-1', 'user-1', '1930')
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
|
||||
Reference in New Issue
Block a user