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
+4 -1
View File
@@ -36,7 +36,7 @@ export async function POST(request: Request) {
// endpoint, which is likewise lenient for '1930'.
const { data: cashAccount } = await supabase
.from('cash_accounts')
.select('id, currency')
.select('id, currency, is_primary')
.eq('company_id', companyId)
.eq('ledger_account', accountNumber)
.maybeSingle()
@@ -55,6 +55,9 @@ export async function POST(request: Request) {
accountNumber,
currency,
cashAccountId: cashAccount?.id as string | undefined,
// Only the primary account claims unassigned (NULL cash_account_id) rows —
// a secondary same-currency account must scope strictly to its own id.
includeUnassigned: Boolean(cashAccount?.is_primary),
dryRun: dry_run ?? false,
})
+6 -1
View File
@@ -23,7 +23,7 @@ export async function GET(request: Request) {
// produces nonsense.
const { data: cashAccount } = await supabase
.from('cash_accounts')
.select('id, currency')
.select('id, currency, is_primary')
.eq('company_id', companyId)
.eq('ledger_account', accountNumber)
.maybeSingle()
@@ -37,6 +37,10 @@ export async function GET(request: Request) {
const currency = (cashAccount?.currency as string | undefined) ?? 'SEK'
const cashAccountId = cashAccount?.id as string | undefined
// Only the primary account claims unassigned (NULL cash_account_id) rows.
// A secondary same-currency account (e.g. a 1931 savings account) must not, or
// 1930's unassigned rows inflate its bank total and show a bogus difference.
const includeUnassigned = Boolean(cashAccount?.is_primary)
const status = await getReconciliationStatus(
supabase,
@@ -46,6 +50,7 @@ export async function GET(request: Request) {
accountNumber,
currency,
cashAccountId,
includeUnassigned,
)
return NextResponse.json({ data: status })
@@ -1,6 +1,6 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { fetchUnlinkedGLLines, tryReconcileTransaction } from '@/lib/reconciliation/bank-reconciliation'
import { fetchGLLinesForMatching, tryReconcileTransaction } from '@/lib/reconciliation/bank-reconciliation'
import { requireCompanyId } from '@/lib/company/context'
import type { Transaction } from '@/types'
@@ -24,6 +24,11 @@ export async function GET(request: Request) {
// lib/reconciliation/bank-reconciliation pulls in server-only deps (event
// bus, match-log) and must never reach the client bundle.
const transactionId = searchParams.get('transaction_id') || undefined
// When true, also return vouchers already matched to a bank transaction (each
// carries linked_transaction_count) so the user can attach a second/third
// transaction to the same verifikat — the N:1 "lägga på flera" case. Default
// false keeps the list to unmatched candidates only.
const includeMatched = searchParams.get('include_matched') === 'true'
// Defense-in-depth: only allow account numbers that the company has actually
// registered as a cash account. Without this, a curious caller could probe
@@ -45,7 +50,7 @@ export async function GET(request: Request) {
)
}
const lines = await fetchUnlinkedGLLines(supabase, companyId, accountNumber, dateFrom, dateTo)
const lines = await fetchGLLinesForMatching(supabase, companyId, accountNumber, dateFrom, dateTo, includeMatched)
if (transactionId) {
// company-scoped fetch (defense-in-depth). A malformed/foreign id yields no
+7 -2
View File
@@ -41,15 +41,20 @@ export async function GET(request: Request) {
let derivedCurrency = currency
let cashAccountId: string | undefined
// Only the primary account claims unassigned (NULL cash_account_id) rows, so
// a secondary same-currency account's lists match its status card instead of
// pooling the primary's unassigned rows. See scopeTransactionsToAccount.
let includeUnassigned = true
if (accountNumberParam) {
const { data: cashAccount } = await supabase
.from('cash_accounts')
.select('id, currency')
.select('id, currency, is_primary')
.eq('company_id', companyId)
.eq('ledger_account', accountNumberParam)
.maybeSingle()
if (cashAccount) {
cashAccountId = cashAccount.id as string
includeUnassigned = Boolean(cashAccount.is_primary)
if (!derivedCurrency && cashAccount.currency) derivedCurrency = cashAccount.currency as string
}
}
@@ -79,7 +84,7 @@ export async function GET(request: Request) {
// Shares one implementation with the reconciliation lib so the filter shape
// can't drift between the status card and these lists.
if (cashAccountId || derivedCurrency) {
query = scopeTransactionsToAccount(query, cashAccountId, derivedCurrency ?? 'SEK')
query = scopeTransactionsToAccount(query, cashAccountId, derivedCurrency ?? 'SEK', includeUnassigned)
}
if (dateFrom) query = query.gte('date', dateFrom)
if (dateTo) query = query.lte('date', dateTo)
@@ -123,7 +123,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>(
const accountNumber = body.account_number ?? '1930'
const { data: cashAccount } = await ctx.supabase
.from('cash_accounts')
.select('id, currency')
.select('id, currency, is_primary')
.eq('company_id', ctx.companyId!)
.eq('ledger_account', accountNumber)
.maybeSingle()
@@ -144,6 +144,8 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>(
accountNumber,
currency: (cashAccount?.currency as string | undefined) ?? 'SEK',
cashAccountId: cashAccount?.id as string | undefined,
// Only the primary account claims unassigned (NULL cash_account_id) rows.
includeUnassigned: Boolean(cashAccount?.is_primary),
dryRun: ctx.dryRun,
})
} catch (err) {
@@ -89,7 +89,7 @@ export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>(
const accountNumber = parsed.data.account_number ?? '1930'
const { data: cashAccount } = await ctx.supabase
.from('cash_accounts')
.select('id, currency')
.select('id, currency, is_primary')
.eq('company_id', ctx.companyId!)
.eq('ledger_account', accountNumber)
.maybeSingle()
@@ -111,6 +111,8 @@ export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>(
accountNumber,
(cashAccount?.currency as string | undefined) ?? 'SEK',
cashAccount?.id as string | undefined,
// Only the primary account claims unassigned (NULL cash_account_id) rows.
Boolean(cashAccount?.is_primary),
)
return ok(status, { requestId: ctx.requestId })
} catch (err) {