Files
accounted/app/api/import/bank-file/execute/route.ts
T
Jakob Wennberg f266c386f3 chore: repo-wide bloat sweep, remove dead code and fold duplicate helpers (#2150)
* chore: repo-wide bloat sweep, remove dead code and fold duplicate helpers

Remove 33 dead files, ~270 unreferenced exports/types, 13 dead i18n
namespaces and 4 unused dependencies; fold byte-identical helper copies
into one canonical home each (lib/utils chunk/sleep/utcDateStamp,
lib/dates/iso, lib/invariants/uuid, lib/xml/escape, lib/reports/sru/format,
lib/pdf/number-text, lib/browser/panel-request, lib/api/v1/body +
v1ValidationError rolled out to ~55 v1 routes, booking-template schemas).

No behaviour change: v1 bodies and status codes, MCP tool schemas, DB
writes and money math are untouched. Naive ore rounding was deliberately
not swapped for roundOre; see DECISIONS.md 2026-09-02 for the full list
of things left alone on purpose.

tsc, lint, 19588 unit tests and check:guards green; antipattern baseline
ratcheted (naive-ore-round 622 -> 620, hand-rolled-invariant 115 -> 113).

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

* test(transactions): import RawTransaction from @/types after the ingest re-export removal

CI's type ratchet (check:types, full tsconfig) caught the one test file
that still imported the type through lib/transactions/ingest.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 11:51:16 +02:00

241 lines
9.5 KiB
TypeScript

import { NextResponse } from 'next/server'
import { eventBus } from '@/lib/events'
import { ensureInitialized } from '@/lib/init'
import { ingestTransactions } from '@/lib/transactions/ingest'
import type { RawTransaction } from '@/types'
import { generateExternalId } from '@/lib/import/bank-file/parser'
import type { IngestOptions } from '@/types'
import { getCompanyRole } from '@/lib/auth/require-write'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import type { ParsedBankTransaction, BankFileFormatId } from '@/lib/import/bank-file/types'
import type { Transaction } from '@/types'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
import {
runUnattendedReconciliationSweep,
toSweepSummary,
} from '@/lib/reconciliation/unattended-sweep'
ensureInitialized()
// Bank-file imports run a sequential, per-row ingest (insert + invoice/supplier
// matching + FX lookup). A full-year file (300+ rows) takes ~85s of server time,
// which sits right on the platform's default function limit and gets killed
// mid-run: the import "spins then aborts" for the user. Give it the same 5-minute
// budget the SIE import route uses (app/api/import/sie/execute/route.ts).
export const maxDuration = 300
interface ExecuteRequest {
transactions: ParsedBankTransaction[]
format: BankFileFormatId
filename: string
file_hash: string
skip_duplicates: boolean
auto_categorize: boolean
settlement_account?: string
}
/**
* POST /api/import/bank-file/execute
*
* Executes the import of confirmed bank transactions. Records the import in
* `bank_file_imports`, calls `ingestTransactions`, and emits `transaction.synced`.
*/
export const POST = withRouteContext(
'bank_file.execute',
async (request, ctx) => {
const { user, supabase, log, requestId } = ctx
// We still call getCompanyRole because viewers are allowed through with
// rawInsertOnly behavior: `requireWrite: true` would block them.
const roleCheck = await getCompanyRole(supabase, user.id)
if (!roleCheck.ok) {
// Inject the request id for traceability and pass through.
if (!roleCheck.response.headers.get('X-Request-Id')) {
roleCheck.response.headers.set('X-Request-Id', requestId)
}
return roleCheck.response
}
const { role, companyId } = roleCheck
const body: ExecuteRequest = await request.json()
const {
transactions, format, filename, file_hash,
skip_duplicates: _skip_duplicates = true,
auto_categorize: _auto_categorize = true,
settlement_account,
} = body
if (!transactions || transactions.length === 0) {
return errorResponseFromCode('BANK_FILE_NO_TRANSACTIONS', log, { requestId })
}
const opLog = log.child({ filename, fileHash: file_hash, txCount: transactions.length })
try {
const { data: importRecord, error: importError } = await supabase
.from('bank_file_imports')
.upsert({
user_id: user.id,
company_id: companyId,
filename,
file_hash,
file_format: format,
transaction_count: transactions.length,
status: 'processing',
date_from: transactions.map((t) => t.date).sort()[0] || null,
date_to: transactions.map((t) => t.date).sort().reverse()[0] || null,
}, { onConflict: 'company_id,file_hash' })
.select()
.single()
if (importError) {
opLog.error('failed to create bank_file_imports record', importError)
return errorResponseFromCode('BANK_FILE_IMPORT_RECORD_FAILED', opLog, {
requestId,
details: { reason: getUserErrorMessage(importError) },
})
}
const rawTransactions: RawTransaction[] = transactions.map((tx, index) => ({
date: tx.date,
description: tx.description,
amount: tx.amount,
currency: tx.currency || 'SEK',
external_id: generateExternalId(tx, format, index),
reference: tx.reference || null,
import_source: format === 'camt053' ? 'camt053' : `csv_${format}`,
}))
// Detect SIE overlap, mirroring the enable-banking sync paths: a bank
// file covering a period a completed SIE import already booked must be
// matched against the imported verifikat, not re-booked. CSV is the only
// way a migrator gets deep history (PSD2 windows stop at ~90 days), so
// this path is the primary one for the Fortnox/SIE migrator journey.
const fileDateFrom = transactions.map((t) => t.date).sort()[0] || undefined
const fileDateTo = transactions.map((t) => t.date).sort().reverse()[0] || undefined
let sieOverlap: { id: string } | null = null
if (fileDateFrom) {
const { data } = await supabase
.from('sie_imports')
.select('id')
.eq('company_id', companyId)
.eq('status', 'completed')
.gte('fiscal_year_end', fileDateFrom)
.limit(1)
.maybeSingle()
sieOverlap = data ?? null
}
const ingestOptions: IngestOptions = {
// Stamp every inserted row with this batch so the owner/admin
// "undo this import" action can scope its bulk delete exactly.
bankFileImportId: importRecord.id,
}
if (settlement_account) ingestOptions.settlementAccount = settlement_account
if (role === 'viewer') ingestOptions.rawInsertOnly = true
if (sieOverlap) ingestOptions.skipAutoCategorization = true
const ingestResult = await ingestTransactions(supabase, companyId, user.id, rawTransactions, ingestOptions)
if (ingestResult.errors > 0 && ingestResult.first_error) {
opLog.error('bank file ingest reported insert errors', new Error(ingestResult.first_error.message), {
errorCount: ingestResult.errors,
code: ingestResult.first_error.code,
details: ingestResult.first_error.details,
hint: ingestResult.first_error.hint,
})
}
const errorMessage = ingestResult.errors > 0
? ingestResult.first_error
? `${ingestResult.errors} fel: ${ingestResult.first_error.message}${ingestResult.first_error.details ? ` (${ingestResult.first_error.details})` : ''}`
: `${ingestResult.errors} transactions failed to import`
: null
await supabase
.from('bank_file_imports')
.update({
imported_count: ingestResult.imported,
duplicate_count: ingestResult.duplicates,
matched_count: ingestResult.auto_matched_invoices,
status: ingestResult.errors > 0 && ingestResult.imported === 0 ? 'failed' : 'completed',
error_message: errorMessage,
})
.eq('id', importRecord.id)
// SIE-overlap-gated reconciliation sweep (issue: no sweep fired after a
// bank CSV import, yet CSV is how a migrator gets pre-PSD2 history). One
// scoped run per enabled cash account; >= 0.9 auto-links, the 0.75-0.89
// band persists as reviewable suggestions. Viewers skip it: the sweep
// updates transactions, which viewers cannot do.
if (sieOverlap && ingestResult.imported > 0 && role !== 'viewer') {
try {
const sweepResult = await runUnattendedReconciliationSweep(supabase, companyId, user.id, {
dateFrom: fileDateFrom,
dateTo: fileDateTo,
})
const { error: stampError } = await supabase
.from('bank_file_imports')
.update({
sie_sweep: toSweepSummary(sweepResult, {
dateFrom: fileDateFrom,
dateTo: fileDateTo,
}),
})
.eq('id', importRecord.id)
if (stampError) {
// The links/suggestions are already written; only the UI summary
// is missing. Say so instead of letting the sweep look unrun.
opLog.warn('failed to stamp sie_sweep summary on bank_file_imports', stampError)
}
if (sweepResult.applied > 0 || sweepResult.suggested > 0) {
opLog.info('post-import SIE reconciliation sweep', {
applied: sweepResult.applied,
suggested: sweepResult.suggested,
unmatched: sweepResult.unmatched,
})
}
} catch (err) {
// Non-critical: rows stay in "Att bokföra" for manual matching.
opLog.warn('post-import SIE reconciliation sweep failed', err as Error)
}
}
if (ingestResult.imported > 0 && ingestResult.transaction_ids.length > 0) {
try {
const { data: importedTransactions } = await supabase
.from('transactions')
.select('*')
.in('id', ingestResult.transaction_ids)
if (importedTransactions && importedTransactions.length > 0) {
await eventBus.emit({
type: 'transaction.synced',
payload: {
transactions: importedTransactions as Transaction[],
userId: user.id,
companyId,
},
})
}
} catch (err) {
opLog.warn('transaction.synced event emission failed', err as Error)
}
}
return NextResponse.json({
data: {
import_id: importRecord.id,
...ingestResult,
},
})
} catch (err) {
opLog.error('bank file execute failed', err as Error)
return errorResponseFromCode('BANK_FILE_EXECUTE_FAILED', opLog, {
requestId,
details: { reason: err instanceof Error ? getUserErrorMessage(err) : 'unknown' },
})
}
},
)