feat: add bank file import as core, move Enable Banking to extension
Replace PSD2 bank integration as the default with file-based bank import (CSV/XML), which better suits Swedish sole traders and small companies. Enable Banking is now an opt-in extension. - Phase 1: Extract generic transaction ingestion service (ingest.ts) with dedup, auto-categorization, and OCR-based invoice matching - Phase 2: Bank file parser library supporting Nordea, SEB, Swedbank, Handelsbanken CSV formats and ISO 20022 camt.053 XML - Phase 3: Database migration adding import_source, reference columns and bank_file_imports tracking table - Phase 4: Import wizard UI (5-step flow) and API routes for parse/execute - Phase 5: Move Enable Banking to extensions/enable-banking/ with commented-out loader entry for opt-in activation - Phase 6: 104 new tests (ingestion + all parser formats), fixing Nordea detection overlap and camt.053 XML tag collision bugs Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
838dc6b8b5
commit
885f362a29
@@ -0,0 +1,130 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
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 { ParsedBankTransaction, BankFileFormatId } from '@/lib/import/bank-file/types'
|
||||
import type { Transaction } from '@/types'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
interface ExecuteRequest {
|
||||
transactions: ParsedBankTransaction[]
|
||||
format: BankFileFormatId
|
||||
filename: string
|
||||
file_hash: string
|
||||
skip_duplicates: boolean
|
||||
auto_categorize: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/import/bank-file/execute
|
||||
*
|
||||
* Executes the import of confirmed bank transactions.
|
||||
* Records import in bank_file_imports, calls ingestTransactions(),
|
||||
* emits transaction.synced event.
|
||||
*/
|
||||
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 body: ExecuteRequest = await request.json()
|
||||
const { transactions, format, filename, file_hash, skip_duplicates = true, auto_categorize = true } = body
|
||||
|
||||
if (!transactions || transactions.length === 0) {
|
||||
return NextResponse.json({ error: 'No transactions to import' }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
// Create import record
|
||||
const { data: importRecord, error: importError } = await supabase
|
||||
.from('bank_file_imports')
|
||||
.upsert({
|
||||
user_id: user.id,
|
||||
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: 'user_id,file_hash',
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (importError) {
|
||||
console.error('Failed to create import record:', importError)
|
||||
return NextResponse.json({ error: 'Failed to create import record' }, { status: 500 })
|
||||
}
|
||||
|
||||
// Convert parsed transactions to RawTransaction format
|
||||
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}`,
|
||||
}))
|
||||
|
||||
// Run ingestion pipeline
|
||||
const ingestResult = await ingestTransactions(supabase, user.id, rawTransactions)
|
||||
|
||||
// Update import record with results
|
||||
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: ingestResult.errors > 0
|
||||
? `${ingestResult.errors} transactions failed to import`
|
||||
: null,
|
||||
})
|
||||
.eq('id', importRecord.id)
|
||||
|
||||
// Emit event with newly imported transactions
|
||||
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,
|
||||
},
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
// Non-critical event emission
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
import_id: importRecord.id,
|
||||
...ingestResult,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Bank file execute error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Import failed' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { parseBankFile, generateFileHash, detectFileFormat } from '@/lib/import/bank-file/parser'
|
||||
import { decodeFileContent } from '@/lib/import/bank-file/encoding'
|
||||
import type { BankFileFormatId } from '@/lib/import/bank-file/types'
|
||||
|
||||
/**
|
||||
* POST /api/import/bank-file/parse
|
||||
*
|
||||
* Accepts a bank file (CSV/XML) via FormData, auto-detects format,
|
||||
* returns parsed transactions preview with duplicate detection.
|
||||
*/
|
||||
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 formData = await request.formData()
|
||||
const file = formData.get('file') as File | null
|
||||
const formatOverride = formData.get('format') as BankFileFormatId | null
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json({ error: 'No file provided' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Validate file size (10MB max)
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
return NextResponse.json({ error: 'File too large (max 10MB)' }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
// Read and decode file content
|
||||
const arrayBuffer = await file.arrayBuffer()
|
||||
const content = decodeFileContent(arrayBuffer)
|
||||
const fileHash = generateFileHash(content)
|
||||
|
||||
// Check if this exact file has been imported before
|
||||
const { data: existingImport } = await supabase
|
||||
.from('bank_file_imports')
|
||||
.select('id, status, imported_count, created_at')
|
||||
.eq('user_id', user.id)
|
||||
.eq('file_hash', fileHash)
|
||||
.single()
|
||||
|
||||
if (existingImport && existingImport.status === 'completed') {
|
||||
return NextResponse.json({
|
||||
error: 'duplicate',
|
||||
message: `Den här filen har redan importerats (${existingImport.imported_count} transaktioner, ${new Date(existingImport.created_at).toLocaleDateString('sv-SE')})`,
|
||||
}, { status: 409 })
|
||||
}
|
||||
|
||||
// Auto-detect or use specified format
|
||||
const detectedFormat = formatOverride
|
||||
? null
|
||||
: detectFileFormat(content, file.name)
|
||||
|
||||
// Parse the file
|
||||
const parseResult = parseBankFile(content, file.name, formatOverride || undefined)
|
||||
|
||||
// Check for existing transactions (duplicate detection for preview)
|
||||
let existingCount = 0
|
||||
if (parseResult.transactions.length > 0) {
|
||||
// Sample check: look for transactions with matching dates and amounts
|
||||
const { count } = await supabase
|
||||
.from('transactions')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.eq('user_id', user.id)
|
||||
.gte('date', parseResult.date_from || '1970-01-01')
|
||||
.lte('date', parseResult.date_to || '2099-12-31')
|
||||
|
||||
existingCount = count || 0
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
parse_result: parseResult,
|
||||
detected_format: detectedFormat?.id || formatOverride || null,
|
||||
detected_format_name: detectedFormat?.name || parseResult.format_name,
|
||||
file_hash: fileHash,
|
||||
filename: file.name,
|
||||
existing_transaction_count: existingCount,
|
||||
// Return first row headers for generic CSV column mapping
|
||||
headers: parseResult.format === 'generic_csv'
|
||||
? content.split('\n')[0]?.split(',').map(h => h.trim()) || []
|
||||
: null,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Bank file parse error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Failed to parse file' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user