* 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>
85 lines
3.1 KiB
TypeScript
85 lines
3.1 KiB
TypeScript
import { createClient } from '@/lib/supabase/server'
|
|
import { NextResponse } from 'next/server'
|
|
import { ensureInitialized } from '@/lib/init'
|
|
import { runReconciliation } from '@/lib/reconciliation/bank-reconciliation'
|
|
import { validateBody } from '@/lib/api/validate'
|
|
import { RunReconciliationSchema } from '@/lib/api/schemas'
|
|
import { requireCompanyId } from '@/lib/company/context'
|
|
import { requireWritePermission } from '@/lib/auth/require-write'
|
|
|
|
ensureInitialized()
|
|
|
|
export async function POST(request: Request) {
|
|
const supabase = await createClient()
|
|
const { data: { user } } = await supabase.auth.getUser()
|
|
|
|
if (!user) {
|
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
}
|
|
|
|
const writeCheck = await requireWritePermission(supabase, user.id)
|
|
if (!writeCheck.ok) return writeCheck.response
|
|
|
|
const companyId = await requireCompanyId(supabase, user.id)
|
|
|
|
const validation = await validateBody(request, RunReconciliationSchema)
|
|
if (!validation.success) return validation.response
|
|
const { date_from, date_to, account_number, dry_run } = validation.data
|
|
|
|
const accountNumber = account_number ?? '1930'
|
|
|
|
// Defense-in-depth: reject a non-default account the company hasn't
|
|
// registered as a cash account. The default '1930' is exempt — when no
|
|
// cash_accounts row exists it falls back to currency-only scoping
|
|
// (cashAccountId undefined), so a company reconciling its primary SEK account
|
|
// without a row behaves exactly as before this feature. Matches the status
|
|
// endpoint, which is likewise lenient for '1930'.
|
|
const { data: cashAccount } = await supabase
|
|
.from('cash_accounts')
|
|
.select('id, currency, is_primary')
|
|
.eq('company_id', companyId)
|
|
.eq('ledger_account', accountNumber)
|
|
.maybeSingle()
|
|
|
|
if (!cashAccount && accountNumber !== '1930') {
|
|
return NextResponse.json(
|
|
{ error: 'Okänt kassakonto för det här företaget' },
|
|
{ status: 400 },
|
|
)
|
|
}
|
|
const currency = (cashAccount?.currency as string | undefined) ?? 'SEK'
|
|
|
|
const result = await runReconciliation(supabase, companyId, user.id, {
|
|
dateFrom: date_from,
|
|
dateTo: date_to,
|
|
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,
|
|
})
|
|
|
|
return NextResponse.json({
|
|
data: {
|
|
matches: result.matches.map((m) => ({
|
|
transaction_id: m.transaction.id,
|
|
transaction_date: m.transaction.date,
|
|
transaction_description: m.transaction.description,
|
|
transaction_amount: m.transaction.amount,
|
|
journal_entry_id: m.glLine.journal_entry_id,
|
|
voucher_number: m.glLine.voucher_number,
|
|
voucher_series: m.glLine.voucher_series,
|
|
entry_date: m.glLine.entry_date,
|
|
entry_description: m.glLine.entry_description,
|
|
method: m.method,
|
|
confidence: m.confidence,
|
|
})),
|
|
applied: result.applied,
|
|
errors: result.errors,
|
|
dry_run: dry_run ?? false,
|
|
},
|
|
})
|
|
}
|