* 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>
107 lines
4.8 KiB
TypeScript
107 lines
4.8 KiB
TypeScript
import { createClient } from '@/lib/supabase/server'
|
|
import { NextResponse } from 'next/server'
|
|
import { requireCompanyId } from '@/lib/company/context'
|
|
import { scopeTransactionsToAccount } from '@/lib/reconciliation/bank-reconciliation'
|
|
|
|
const MAX_ROWS = 500
|
|
|
|
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 unmatched = searchParams.get('unmatched') === 'true'
|
|
const reconciled = searchParams.get('reconciled') === 'true'
|
|
const currency = searchParams.get('currency') || undefined
|
|
// currency is interpolated into the PostgREST .or() filter below, so reject
|
|
// anything that isn't a 3-letter ISO code. RLS still scopes results to the
|
|
// company, but an unsanitized value could otherwise malform or widen the
|
|
// filter (PostgREST filter injection).
|
|
if (currency && !/^[A-Z]{3}$/.test(currency)) {
|
|
return NextResponse.json({ error: 'Ogiltig valutakod' }, { status: 400 })
|
|
}
|
|
const dateFrom = searchParams.get('date_from') || undefined
|
|
const dateTo = searchParams.get('date_to') || undefined
|
|
// When set, return only ignored rows — used by the reconciliation view to
|
|
// surface a "Visa ignorerade" undo list. The default (no param) behaviour
|
|
// continues to exclude ignored rows from unmatched results.
|
|
const onlyIgnored = searchParams.get('only_ignored') === 'true'
|
|
// account_number selects which cash account to scope to. We resolve it to a
|
|
// cash_accounts.id (ledger_account is unique per company) and scope
|
|
// transactions by that id, falling back to currency for legacy rows whose
|
|
// cash_account_id hasn't been backfilled yet. This is what stops two
|
|
// same-currency accounts from showing each other's transactions.
|
|
const accountNumberParam = searchParams.get('account_number') || undefined
|
|
|
|
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, 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
|
|
}
|
|
}
|
|
|
|
let query = supabase
|
|
.from('transactions')
|
|
.select('id, date, description, amount, currency, amount_sek, exchange_rate, reference, journal_entry_id, reconciliation_method, is_ignored')
|
|
.eq('company_id', companyId)
|
|
|
|
// unmatched and reconciled are mutually exclusive — unmatched wins if both set
|
|
if (unmatched) {
|
|
query = query.is('journal_entry_id', null)
|
|
// Hide rows the user has explicitly suppressed from the reconciliation
|
|
// view. Other callers (e.g. BookDirectlyDialog) also benefit — once
|
|
// ignored, the row stops surfacing in the "to book" funnel everywhere.
|
|
if (!onlyIgnored) query = query.eq('is_ignored', false)
|
|
} else if (reconciled) {
|
|
query = query.not('journal_entry_id', 'is', null)
|
|
}
|
|
|
|
if (onlyIgnored) query = query.eq('is_ignored', true)
|
|
|
|
// Scope to the selected cash account. With a resolved id, match that account
|
|
// OR legacy NULL rows of the same currency (so nothing disappears mid-
|
|
// backfill). With only a currency (no account), filter by currency. With
|
|
// neither (e.g. the company-wide only_ignored recovery list), no scope.
|
|
// 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', includeUnassigned)
|
|
}
|
|
if (dateFrom) query = query.gte('date', dateFrom)
|
|
if (dateTo) query = query.lte('date', dateTo)
|
|
|
|
// Fetch one extra row so we can tell the caller whether the result was truncated.
|
|
query = query.order('date', { ascending: false }).limit(MAX_ROWS + 1)
|
|
|
|
const { data, error } = await query
|
|
|
|
if (error) {
|
|
return NextResponse.json({ error: error.message }, { status: 500 })
|
|
}
|
|
|
|
const rows = data || []
|
|
const hasMore = rows.length > MAX_ROWS
|
|
const truncated = hasMore ? rows.slice(0, MAX_ROWS) : rows
|
|
|
|
return NextResponse.json({ data: truncated, has_more: hasMore, limit: MAX_ROWS })
|
|
}
|