Files
accounted/app/api/reconciliation/bank/run/route.ts
T
MattssonandClaude Fable 5 08440fed94 feat(reconciliation): match migrated bank history against imported SIE verifikat (#1598)
* feat(reconciliation): match migrated bank history against imported SIE verifikat

A first-class Fortnox/SIE migrator path: after SIE import plus bank connect
or bank CSV upload, historical bank rows are auto-matched (>= 0.9) or
suggestion-matched (0.75-0.89, persisted for review) against the imported
verifikat, with a guided review surface, instead of landing as anonymous
"Att bokfora" rows.

Phase 0: per-cash-account unattended sweep (fixes #1298 cross-account
pooling); widen payment_match_log action CHECK with
linked_to_existing_voucher (silently unlogged since March).
Phase 1: potential_journal_entry_id/method/confidence on transactions with
CHECK + invalidation triggers; persistSuggestions in runReconciliation;
sweep after bank CSV import with SIE overlap (suppressing
auto-categorization); sweep summaries stamped on bank_connections and
bank_file_imports; POST /api/reconciliation/bank/confirm-suggestions with
per-pair server-side revalidation (voucher consumption + bank-leg amount
and direction).
Phase 2: "Granska forslag" review tab on Transactions with chunked bulk
confirm, per-row fallbacks, "Kor matchning igen" (all_accounts sweep mode,
mutually exclusive with dry_run), attn line, pre-migration row marker.
Phase 3: ImportResultStep dual CTA (bank connect + CSV), migrator variant
of the account-picker #917 nudge, sweep outcome on the onboarding
checklist bank step.

Non-selection apply runs on /api/reconciliation/bank/run now floor at 0.9
and persist the review band instead of auto-committing fuzzy matches.
Migrations already applied to staging under the same versions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(reconciliation): resolve PR review findings in one pass

Swedish accounting review (both previously-deferred holes closed):
- runReconciliation's >= 0.9 auto-apply now writes 'matched' to
  payment_match_log (behandlingshistorik, BFNAR 2013:2 kap 8); the bus
  event alone lands in the 30-day event_log and is not an audit record.
- The three match-route storno-conflict branches detach reconciliation
  links via unlinkReconciliation instead of storno-reversing the linked
  verifikat: a reconciliation link points at an independent verifikat
  that may evidence other affarshandelser, and a wholesale reversal is
  an over-broad rattelse (BFL 5 kap 5 §).
- Historical gap quantified on prod (read-only, recorded in DECISIONS):
  762 unlogged manual links across 52 companies since 2026-03-23.

CodeRabbit:
- confirm-suggestions route: maxDuration 300 for full 500-item batches.
- AccountPickerDialog: migrator-nudge buttons set lookbackTouched so the
  async gap-fill probe cannot override an explicit choice.
- enable-banking post-backfill sweep: persistSuggestions so the review
  band is not dropped.
- bank-file execute: sie_sweep stamp errors are logged, not swallowed.
- ImportResultStep: sandbox keeps the CSV CTA (file import works there).
- payment_match_log CHECK swap: NOT VALID + VALIDATE, no table scan
  under ACCESS EXCLUSIVE.
- logMatchEvent calls awaited (serverless can freeze unawaited work).
- DECISIONS.md stale version reference annotated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(reconciliation): defer reconciliation-link detach until the match commits

Round-2 review findings:
- CodeRabbit: the eager unlinkReconciliation call could orphan a
  transaction if the match flow failed after it. All three match routes
  now persist NOTHING up front: the final transaction update overwrites
  journal_entry_id and clears reconciliation_method in the same write,
  so any failure in between leaves the existing link intact. The release
  is logged as 'unmatched' after the commit.
- Swedish review: the auto_suggested logMatchEvent in runReconciliation
  is now awaited like every other audit write.
- DECISIONS entry split into compliance/CodeRabbit lines and updated to
  describe the deferred detach.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(reconciliation): literal reconciliation_method payloads for the phantom-column scanner

The conditional spreads introduced with the deferred detach pushed the
scanner's unresolvable-expression count past its ceiling (380 > 378).
reconciliation_method: null is correct unconditionally on a confirmed
invoice/supplier match (null is already the value on every row that was
not reconciliation-linked), so the payloads become plain literals the
guard can verify. No behavior change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 23:12:27 +02:00

149 lines
5.6 KiB
TypeScript

import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context'
import {
runReconciliation,
DEFAULT_UNATTENDED_CONFIDENCE_THRESHOLD,
} from '@/lib/reconciliation/bank-reconciliation'
import { runUnattendedReconciliationSweep } from '@/lib/reconciliation/unattended-sweep'
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,
all_accounts,
} = validation.data
// "Kör matchning igen": the per-account sweep across every enabled cash
// account, exactly what the unattended post-sync path runs. New >= 0.9
// matches auto-link; the 0.75-0.89 band persists as suggestions.
//
// The sweep ALWAYS writes at its own fixed floor: there is no dry-run form
// of it, and silently ignoring dry_run (or a client-sent floor) here would
// turn a requested preview into applied links (the documented
// dry-run-gotcha P0 class). Enforce the mutual exclusion instead of just
// documenting it.
if (
all_accounts &&
(dry_run !== undefined ||
account_number ||
selected_matches ||
confidence_threshold !== undefined)
) {
return NextResponse.json(
{
error:
'all_accounts kan inte kombineras med dry_run, account_number, selected_matches eller confidence_threshold',
},
{ status: 400 },
)
}
if (all_accounts) {
const sweep = await runUnattendedReconciliationSweep(supabase, companyId, user.id, {
dateFrom: date_from,
dateTo: date_to,
})
return NextResponse.json({
data: {
applied: sweep.applied,
errors: sweep.errors,
suggested: sweep.suggested,
unmatched: sweep.unmatched,
skipped_below_threshold: sweep.skippedBelowThreshold,
accounts: sweep.accounts.map((a) => ({
account_number: a.accountNumber,
applied: a.applied,
suggested: a.suggested,
})),
},
})
}
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. A client-sent confidence_threshold
// always wins (mirrors the v1 route: pairs the fresh re-run scores below
// it are skipped, not applied). Without one: a no-selection apply run is
// effectively unattended, so it floors at 0.9 and persists the 0.75-0.89
// band as reviewable suggestions instead of auto-committing fuzzy
// matches; applyOnly runs keep the legacy no-floor behavior (the user
// already reviewed the pairs in the dry-run preview). Ignored on dry runs.
confidenceThreshold:
confidence_threshold ??
(selected_matches ? undefined : DEFAULT_UNATTENDED_CONFIDENCE_THRESHOLD),
...(selected_matches ? {} : { persistSuggestions: true }),
})
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,
suggested: result.suggested,
skipped_below_threshold: result.skippedBelowThreshold,
dry_run: dry_run ?? false,
},
})
},
{ requireWrite: true },
)