* 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>
94 lines
4.1 KiB
TypeScript
94 lines
4.1 KiB
TypeScript
import { createClient } from '@/lib/supabase/server'
|
|
import { NextResponse } from 'next/server'
|
|
import { fetchGLLinesForMatching, tryReconcileTransaction } from '@/lib/reconciliation/bank-reconciliation'
|
|
import { requireCompanyId } from '@/lib/company/context'
|
|
import type { Transaction } from '@/types'
|
|
|
|
export async function GET(request: Request) {
|
|
const supabase = await createClient()
|
|
const { data: { user } } = await supabase.auth.getUser()
|
|
|
|
if (!user) {
|
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
}
|
|
|
|
const companyId = await requireCompanyId(supabase, user.id)
|
|
|
|
const { searchParams } = new URL(request.url)
|
|
const dateFrom = searchParams.get('date_from') || undefined
|
|
const dateTo = searchParams.get('date_to') || undefined
|
|
const accountNumber = searchParams.get('account_number') || '1930'
|
|
// Optional: when set, rank the returned candidates for this specific bank
|
|
// transaction (used by the Transactions-page "Matcha mot befintlig
|
|
// verifikation" dialog). Ranking happens server-side on purpose —
|
|
// 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
|
|
// arbitrary GL accounts for posted-but-unmatched amounts. Applies uniformly
|
|
// including '1930' — the cash_accounts backfill seeds 1930 for every company
|
|
// that had a SEK PSD2 account, and the AccountPickerDialog seeds it for new
|
|
// companies on first connection.
|
|
const { data: cashAccount } = await supabase
|
|
.from('cash_accounts')
|
|
.select('id')
|
|
.eq('company_id', companyId)
|
|
.eq('ledger_account', accountNumber)
|
|
.maybeSingle()
|
|
|
|
if (!cashAccount) {
|
|
return NextResponse.json(
|
|
{ error: 'Okänt kassakonto för det här företaget' },
|
|
{ status: 400 },
|
|
)
|
|
}
|
|
|
|
const lines = await fetchGLLinesForMatching(supabase, companyId, accountNumber, dateFrom, dateTo, includeMatched)
|
|
|
|
if (transactionId) {
|
|
// company-scoped fetch (defense-in-depth). A malformed/foreign id yields no
|
|
// row → we fall through to the unranked list rather than erroring.
|
|
const { data: tx } = await supabase
|
|
.from('transactions')
|
|
.select('id, amount, date, currency, reference')
|
|
.eq('id', transactionId)
|
|
.eq('company_id', companyId)
|
|
.maybeSingle()
|
|
|
|
if (!tx) {
|
|
// transaction_id was supplied but doesn't resolve to a row in the
|
|
// caller's company — the ranking context is invalid. Return no candidates
|
|
// rather than silently falling back to the full unranked list, so a
|
|
// fabricated or foreign id can never yield a broader result set.
|
|
return NextResponse.json({ data: [] })
|
|
}
|
|
|
|
const txCurrency = (tx.currency as string | null) ?? 'SEK'
|
|
const txDate = tx.date as string
|
|
const ranked = lines
|
|
.map((line) => {
|
|
// Score each line in isolation; confidence 0 means "no auto-match
|
|
// rule fired" — the line still appears so the user can pick it
|
|
// manually (e.g. a salary or Fortnox voucher with a tweaked date).
|
|
const match = tryReconcileTransaction(tx as unknown as Transaction, [line], txCurrency)
|
|
return { ...line, confidence: match?.confidence ?? 0 }
|
|
})
|
|
.sort((a, b) => {
|
|
if (b.confidence !== a.confidence) return b.confidence - a.confidence
|
|
const da = Math.abs(new Date(a.entry_date).getTime() - new Date(txDate).getTime())
|
|
const db = Math.abs(new Date(b.entry_date).getTime() - new Date(txDate).getTime())
|
|
return da - db
|
|
})
|
|
return NextResponse.json({ data: ranked })
|
|
}
|
|
|
|
return NextResponse.json({ data: lines })
|
|
}
|