Files
accounted/tests/pg/transactions-cash-account-id.pg.test.ts
T
Jakob WennbergandClaude Opus 4.8 094bd85e81 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>
2026-06-02 09:37:30 +02:00

381 lines
14 KiB
TypeScript

import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import {
seedCompany,
insertCashAccount,
insertTransaction,
insertDraftJournalEntry,
} from '@/tests/pg/fixtures'
import { getPool } from '@/tests/pg/setup'
/**
* pg-real coverage for transactions.cash_account_id
* (20260606120000_transactions_cash_account_id.sql + the paired backfill
* 20260606120100_..._backfill.sql).
*
* Locks in:
* - FK ON DELETE SET NULL — deleting a cash account never deletes the bank
* transaction (räkenskapsinformation, BFL 7 kap), only nulls the link.
* - The four backfill passes (booked single 19xx line, PSD2 external_id,
* single-account-of-currency, leave NULL) and their guards.
* - The headline isolation: a query scoped to one account (with the
* NULL→currency fallback) never returns another same-currency account's
* rows. This is the regression test for "shows 1930 even when you switch /
* sums all transactions".
* - Cross-company isolation of the backfill.
*/
// Run the real backfill migration SQL (idempotent: only touches NULL rows) so
// the test exercises exactly what ships, not a re-implementation.
const BACKFILL_SQL = readFileSync(
join(
process.cwd(),
'supabase/migrations/20260606120100_transactions_cash_account_id_backfill.sql',
),
'utf8',
)
async function runBackfill(): Promise<void> {
await getPool().query(BACKFILL_SQL)
}
// The repair migration re-derives cash_account_id and CORRECTS mis-assignments
// (the NULL-only original backfill could not). Run the real SQL so the test
// exercises exactly what ships.
const REPAIR_SQL = readFileSync(
join(
process.cwd(),
'supabase/migrations/20260609120000_transactions_cash_account_id_repair_backfill.sql',
),
'utf8',
)
async function runRepair(): Promise<void> {
await getPool().query(REPAIR_SQL)
}
// Insert a journal entry (draft) with the given bank-class line account
// numbers. One line per account at amount 100 (debit). Balance isn't required
// for a draft entry — the balance trigger only fires on draft→posted.
async function insertEntryWithBankLines(params: {
userId: string
companyId: string
fiscalPeriodId: string
bankAccounts: string[]
}): Promise<string> {
const jeId = await insertDraftJournalEntry({
userId: params.userId,
companyId: params.companyId,
fiscalPeriodId: params.fiscalPeriodId,
})
for (const acct of params.bankAccounts) {
await getPool().query(
`INSERT INTO public.journal_entry_lines
(journal_entry_id, account_number, debit_amount, credit_amount)
VALUES ($1, $2, 100, 0)`,
[jeId, acct],
)
}
return jeId
}
async function getCashAccountId(txId: string): Promise<string | null> {
const { rows } = await getPool().query(
`SELECT cash_account_id FROM public.transactions WHERE id = $1`,
[txId],
)
return rows[0]?.cash_account_id ?? null
}
describe('transactions.cash_account_id — schema + FK', () => {
it('ON DELETE SET NULL keeps the transaction when its cash account is deleted', async () => {
const { userId, companyId } = await seedCompany()
const caId = await insertCashAccount({ companyId, ledgerAccount: '1930' })
const txId = await insertTransaction({ companyId, userId, cashAccountId: caId })
await getPool().query(`DELETE FROM public.cash_accounts WHERE id = $1`, [caId])
const { rows } = await getPool().query(
`SELECT id, cash_account_id FROM public.transactions WHERE id = $1`,
[txId],
)
expect(rows).toHaveLength(1) // transaction survived
expect(rows[0].cash_account_id).toBeNull() // link was nulled, not cascaded
})
})
describe('transactions.cash_account_id — backfill pass (a) booked rows', () => {
it('binds a booked transaction via its single bank line; skips multi-bank-line vouchers', async () => {
const { userId, companyId, fiscalPeriodId } = await seedCompany()
// Two SEK accounts so the single-account fallback (pass c) cannot fire.
await insertCashAccount({ companyId, ledgerAccount: '1930' })
const ca1931 = await insertCashAccount({ companyId, ledgerAccount: '1931' })
// Single 1931 line → should bind to the 1931 cash account.
const jeSingle = await insertEntryWithBankLines({
userId,
companyId,
fiscalPeriodId,
bankAccounts: ['1931'],
})
const txSingle = await insertTransaction({
companyId,
userId,
journalEntryId: jeSingle,
})
// Two bank lines (1930 + 1931) → ambiguous transfer, must stay NULL.
const jeTransfer = await insertEntryWithBankLines({
userId,
companyId,
fiscalPeriodId,
bankAccounts: ['1930', '1931'],
})
const txTransfer = await insertTransaction({
companyId,
userId,
journalEntryId: jeTransfer,
})
await runBackfill()
expect(await getCashAccountId(txSingle)).toBe(ca1931)
expect(await getCashAccountId(txTransfer)).toBeNull()
})
})
describe('transactions.cash_account_id — backfill pass (b) PSD2 external_id', () => {
it('routes by IBAN and by external_uid embedded in external_id', async () => {
const { userId, companyId } = await seedCompany()
// Two SEK accounts → pass (c) cannot fire, so only the PSD2 identity binds.
const caIban = await insertCashAccount({
companyId,
ledgerAccount: '1930',
iban: 'SE4550000000058398257466',
})
const caUid = await insertCashAccount({
companyId,
ledgerAccount: '1931',
externalUid: 'psd2-uid-b',
})
const txIban = await insertTransaction({
companyId,
userId,
externalId: 'eb_SE4550000000058398257466_tx1',
})
const txUid = await insertTransaction({
companyId,
userId,
externalId: 'eb_psd2-uid-b_tx2',
})
const txUnknown = await insertTransaction({
companyId,
userId,
externalId: 'eb_nomatch_tx3',
})
await runBackfill()
expect(await getCashAccountId(txIban)).toBe(caIban)
expect(await getCashAccountId(txUid)).toBe(caUid)
expect(await getCashAccountId(txUnknown)).toBeNull()
})
})
describe('transactions.cash_account_id — backfill pass (c) single-account-of-currency', () => {
it('binds when the company has exactly one enabled account of the currency', async () => {
const { userId, companyId } = await seedCompany()
const ca = await insertCashAccount({ companyId, ledgerAccount: '1930', currency: 'SEK' })
// CSV-style row: no external_id, unbooked.
const tx = await insertTransaction({ companyId, userId, currency: 'SEK' })
await runBackfill()
expect(await getCashAccountId(tx)).toBe(ca)
})
it('leaves NULL when the company has two same-currency accounts', async () => {
const { userId, companyId } = await seedCompany()
await insertCashAccount({ companyId, ledgerAccount: '1930', currency: 'SEK' })
await insertCashAccount({ companyId, ledgerAccount: '1931', currency: 'SEK' })
const tx = await insertTransaction({ companyId, userId, currency: 'SEK' })
await runBackfill()
expect(await getCashAccountId(tx)).toBeNull()
})
})
describe('transactions.cash_account_id — account-scoped query isolation', () => {
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' })
const tx1930 = await insertTransaction({ companyId, userId, currency: 'SEK', cashAccountId: ca1930 })
const tx1931 = await insertTransaction({ companyId, userId, currency: 'SEK', cashAccountId: ca1931 })
const txNullSek = await insertTransaction({ companyId, userId, currency: 'SEK' })
const txNullEur = await insertTransaction({ companyId, userId, currency: 'EUR' })
// 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 ${predicate}`,
[companyId, cashAccountId, currency],
)
return rows.map((r) => r.id)
}
// 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
// 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).not.toContain(txNullSek) // the fix: no double-count onto 1931
expect(for1931).not.toContain(tx1930)
})
})
describe('transactions.cash_account_id — repair backfill (20260609120000)', () => {
it('re-seeds a default 1930 SEK cash account for a company that has none', async () => {
const { companyId } = await seedCompany()
// seedCompany inserts the company directly (no cash account).
await runRepair()
const { rows } = await getPool().query(
`SELECT ledger_account, currency, is_primary
FROM public.cash_accounts WHERE company_id = $1`,
[companyId],
)
expect(rows).toHaveLength(1)
expect(rows[0].ledger_account).toBe('1930')
expect(rows[0].currency).toBe('SEK')
expect(rows[0].is_primary).toBe(true)
})
it('CORRECTS a booked row mis-assigned to the wrong cash account', async () => {
const { userId, companyId, fiscalPeriodId } = await seedCompany()
const ca1930 = await insertCashAccount({ companyId, ledgerAccount: '1930', currency: 'SEK' })
const ca1931 = await insertCashAccount({ companyId, ledgerAccount: '1931', currency: 'SEK' })
// The voucher settled on 1930, but the row was wrongly bound to 1931 — the
// exact mis-assignment the NULL-only original backfill can never undo.
const je = await insertEntryWithBankLines({
userId,
companyId,
fiscalPeriodId,
bankAccounts: ['1930'],
})
const tx = await insertTransaction({ companyId, userId, journalEntryId: je, cashAccountId: ca1931 })
await runRepair()
expect(await getCashAccountId(tx)).toBe(ca1930)
})
it('CORRECTS a mis-assigned unbooked row in a single-SEK-account company', async () => {
// The headline Arcim regression: one SEK account; a buggy backfill bound a
// SEK row to the wrong account, so per-account scoping dropped it and
// Bankavstämning showed 0 transactions. Repair rebinds it to the sole SEK account.
const { userId, companyId } = await seedCompany()
const caSek = await insertCashAccount({ companyId, ledgerAccount: '1930', currency: 'SEK' })
const caEur = await insertCashAccount({ companyId, ledgerAccount: '1932', currency: 'EUR' })
const tx = await insertTransaction({ companyId, userId, currency: 'SEK', cashAccountId: caEur })
await runRepair()
expect(await getCashAccountId(tx)).toBe(caSek)
})
it('binds NULL unbooked rows to the single account of their currency', async () => {
const { userId, companyId } = await seedCompany()
const ca = await insertCashAccount({ companyId, ledgerAccount: '1930', currency: 'SEK' })
const tx = await insertTransaction({ companyId, userId, currency: 'SEK' })
await runRepair()
expect(await getCashAccountId(tx)).toBe(ca)
})
it('does NOT touch an unbooked row when two same-currency accounts exist', async () => {
const { userId, companyId } = await seedCompany()
await insertCashAccount({ companyId, ledgerAccount: '1930', currency: 'SEK' })
await insertCashAccount({ companyId, ledgerAccount: '1931', currency: 'SEK' })
const tx = await insertTransaction({ companyId, userId, currency: 'SEK' })
await runRepair()
expect(await getCashAccountId(tx)).toBeNull()
})
it('is idempotent — a second run changes nothing', async () => {
const { userId, companyId } = await seedCompany()
const ca = await insertCashAccount({ companyId, ledgerAccount: '1930', currency: 'SEK' })
const tx = await insertTransaction({ companyId, userId, currency: 'SEK' })
await runRepair()
const first = await getCashAccountId(tx)
await runRepair()
expect(first).toBe(ca)
expect(await getCashAccountId(tx)).toBe(ca)
})
it('never rebinds across companies', async () => {
const a = await seedCompany()
const b = await seedCompany()
const caA = await insertCashAccount({ companyId: a.companyId, ledgerAccount: '1930', currency: 'SEK' })
await insertCashAccount({ companyId: b.companyId, ledgerAccount: '1930', currency: 'SEK' })
const txA = await insertTransaction({ companyId: a.companyId, userId: a.userId, currency: 'SEK' })
await runRepair()
expect(await getCashAccountId(txA)).toBe(caA)
})
})
describe('transactions.cash_account_id — cross-company isolation', () => {
it('backfill never binds a transaction to another company\'s cash account', async () => {
const a = await seedCompany()
const b = await seedCompany()
const caA = await insertCashAccount({ companyId: a.companyId, ledgerAccount: '1930' })
const caB = await insertCashAccount({ companyId: b.companyId, ledgerAccount: '1930' })
const jeA = await insertEntryWithBankLines({
userId: a.userId,
companyId: a.companyId,
fiscalPeriodId: a.fiscalPeriodId,
bankAccounts: ['1930'],
})
const txA = await insertTransaction({ companyId: a.companyId, userId: a.userId, journalEntryId: jeA })
await runBackfill()
const boundA = await getCashAccountId(txA)
expect(boundA).toBe(caA)
expect(boundA).not.toBe(caB)
})
})