* feat(import): undo a bank file import including ignored transactions (#1672) A mis-parsed bank CSV could not be cleaned up: re-importing dedup-skips the bad rows, the single-row DELETE refuses imported rows by design (TRANSACTION_DELETE_IMPORTED), and there was no bulk action. Transactions also never recorded which import batch inserted them, so a strictly scoped undo was impossible. - transactions.bank_file_import_id: batch link stamped at ingest by both bank-file import paths (dashboard execute route, v1 REST route). PSD2/ manual/MCP rows stay NULL. No retroactive backfill: fuzzy attribution could delete rows belonging to a different import. - undo_bank_file_import RPC: owner/admin-only bulk delete of the batch's unbooked rows, ignored INCLUDED. Booked rows (journal link, payment rows, voucher links) and rows with append-only payment_match_log history are skipped and reported, mirroring the single-row route's guards. Marks the import 'undone' (re-import reuses the row via the company_id+file_hash upsert), writes one audit_log summary row, and hardens the actor gate like undo_sie_import: p_user_id honored only for service_role callers, 42501 otherwise, no anon EXECUTE. - DELETE /api/import/bank-file/[id]/undo returns the deletion report; RPC 42501 maps to BANK_FILE_UNDO_FORBIDDEN (403). Closes #1672 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Emil <emilmattsson14@gmail.com> * fix(import): return 404 when the bank-file undo target does not exist An unknown or out-of-company import id answered 400 BANK_FILE_UNDO_FAILED, hiding the not-found semantics the SIE import routes already expose ('Import not found', 404). Flag the case in undoBankFileImport (notFound) and map it to a new BANK_FILE_UNDO_NOT_FOUND structured error (404); status-refusals and RPC failures keep the 400 envelope. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Emil <emilmattsson14@gmail.com> * feat(import): show bank file import history with undo on the import tab The undo shipped for issue #1672 was API-only: no surface listed a company's bank_file_imports, so neither users nor founders could reach DELETE /api/import/bank-file/[id]/undo, and the deletion report existed only in JSON. Mirror the SIE pattern (SIEImportHistory, #1574): - GET /api/import/bank-file: list the company's imports newest-first, same { data, count, limit, offset } shape as GET /api/import/sie. - BankFileImportHistory: fold-open 'Tidigare bankfilsimporter' row on the Importera tab with filename, date, format, imported count and status per import, plus an undo action on completed rows behind a DestructiveConfirmDialog. The undo stays owner/admin-only via the undo_bank_file_import RPC's actor gate, like the SIE one. - After undo the toast shows the full report: transactions removed, booked rows skipped, rows with match history skipped, so nothing disappears silently from the ledger's surroundings. - i18n strings in messages/sv.json and messages/en.json following the sie_history_* key style; list-route test mirroring the SIE list test. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Emil <emilmattsson14@gmail.com> * chore(migrations): move undo_bank_file_import after main's 2026-08-19 migrations Signed-off-by: Emil <emilmattsson14@gmail.com> * fix(import): validate bank-file list params, fail closed on undo lookup, log lost batch attribution Review findings on #1764 (CodeRabbit): - GET /api/import/bank-file rejects non-integer/negative/oversized limit and offset and unknown status with a mapped 400 (BANK_FILE_LIST_INVALID_QUERY), limit capped at 100; boundary and invalid-input tests added. - undoBankFileImport distinguishes PGRST116 (zero rows -> notFound/404) from other lookup failures, which now return an error instead of masquerading as a permanent 404. - The v1 import route no longer discards the bank_file_imports upsert error: kept non-fatal by design (an unattributed batch imports fine and never appears in undo history), but the failure is now logged loudly. - Route test beforeEach clears the event bus (repo convention). Signed-off-by: Emil <emilmattsson14@gmail.com> --------- Signed-off-by: Emil <emilmattsson14@gmail.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
240 lines
9.5 KiB
TypeScript
240 lines
9.5 KiB
TypeScript
import { NextResponse } from 'next/server'
|
|
import { eventBus } from '@/lib/events'
|
|
import { ensureInitialized } from '@/lib/init'
|
|
import { ingestTransactions, type RawTransaction } from '@/lib/transactions/ingest'
|
|
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' },
|
|
})
|
|
}
|
|
},
|
|
)
|