* feat(reconciliation): accept confidence_threshold on the bank run route Mirror the v1 route: RunReconciliationSchema gains an optional confidence_threshold (0..1) that passes through to runReconciliation as the server-side floor on the apply path. The UI sends 0.85 with a strong-only apply so a pair the fresh re-run scores lower is skipped instead of committed; omitting it keeps the legacy behavior where every selected pair applies. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(reconciliation): promote the bulk match flow and bridge it from the inbox The dry-run preview with pre-ticked strong matches existed but was never found: users matched whole migrations row by row. Three discoverability changes, no engine changes: - Bankavstamning: an attention line above the toolbar while unmatched transactions exist and no preview has run, with Forhandsgranska promoted to the filled variant. When every ticked preview pair is a strong match (>= 0.85) the apply button relabels to 'Matcha X starka traffar' and the apply sends confidence_threshold 0.85; mixed selections keep the plain label and omit the floor so manually ticked weaker pairs still apply. - Autorun bridge: ?autorun=1 on /reports/bank-reconciliation runs the preview once, only after appliedDates is set and not while datesDirty, so it can never cover a different window than the on-screen lists. - Transactions inbox: with >= 5 unbooked bank rows visible, an attention line links to the reconciliation with autorun (static text + count, no probe; the preview is the honest source of how many actually match). The review step stays: autorun lands on the preview table, one click from apply, and the server intersection guard is untouched. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
83 lines
3.2 KiB
TypeScript
83 lines
3.2 KiB
TypeScript
import { NextResponse } from 'next/server'
|
|
import { ensureInitialized } from '@/lib/init'
|
|
import { withRouteContext } from '@/lib/api/with-route-context'
|
|
import { runReconciliation } from '@/lib/reconciliation/bank-reconciliation'
|
|
import { validateBody } from '@/lib/api/validate'
|
|
import { RunReconciliationSchema } from '@/lib/api/schemas'
|
|
|
|
ensureInitialized()
|
|
|
|
export const POST = withRouteContext(
|
|
'reconciliation.bank.run',
|
|
async (request, { supabase, user, companyId }) => {
|
|
const validation = await validateBody(request, RunReconciliationSchema)
|
|
if (!validation.success) return validation.response
|
|
const { date_from, date_to, account_number, dry_run, selected_matches, confidence_threshold } =
|
|
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,
|
|
applyOnly: selected_matches?.map((m) => ({
|
|
transactionId: m.transaction_id,
|
|
journalEntryId: m.journal_entry_id,
|
|
})),
|
|
// Server-side floor on the apply path (mirrors the v1 route): pairs the
|
|
// fresh re-run scores below it are skipped, not applied.
|
|
confidenceThreshold: confidence_threshold,
|
|
})
|
|
|
|
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,
|
|
},
|
|
})
|
|
},
|
|
{ requireWrite: true },
|
|
)
|