Files
accounted/app/api/transactions/route.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

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 })
}