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:
Jakob Wennberg
2026-02-20 14:21:28 +01:00
co-authored by Claude Opus 4.6
parent 838dc6b8b5
commit 885f362a29
60 changed files with 8232 additions and 853 deletions
-92
View File
@@ -1,92 +0,0 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { startAuthorization, getASPSPs, type ASPSP } from '@/lib/banking/enable-banking'
export async function GET() {
try {
const aspsps = await getASPSPs('SE')
// Transform to frontend-friendly format
const banks = aspsps.map((aspsp: ASPSP) => ({
name: aspsp.name,
country: aspsp.country,
logo: aspsp.logo,
bic: aspsp.bic,
}))
return NextResponse.json({ banks })
} catch (error) {
console.error('Error fetching banks:', error)
// Return fallback list
return NextResponse.json({
banks: [
{ name: 'Nordea', country: 'SE', bic: 'NDEASESS' },
{ name: 'SEB', country: 'SE', bic: 'ESSESESS' },
{ name: 'Swedbank', country: 'SE', bic: 'SWEDSESS' },
{ name: 'Handelsbanken', country: 'SE', bic: 'HANDSESS' },
]
})
}
}
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 { aspsp_name, aspsp_country } = await request.json()
if (!aspsp_name || !aspsp_country) {
return NextResponse.json(
{ error: 'aspsp_name and aspsp_country are required' },
{ status: 400 }
)
}
try {
const redirectUrl = `${process.env.NEXT_PUBLIC_APP_URL}/api/banking/callback`
// Start the authorization flow with Enable Banking
const { url, authorization_id } = await startAuthorization(
aspsp_name,
aspsp_country,
redirectUrl,
user.id, // state parameter - returned in callback
'personal'
)
// Store pending connection in database with authorization_id
// Note: session_id will be set after callback receives the code
const { data: connection, error } = await supabase
.from('bank_connections')
.insert({
user_id: user.id,
provider: `${aspsp_name.toLowerCase().replace(/\s+/g, '-')}-${aspsp_country.toLowerCase()}`,
bank_name: aspsp_name,
authorization_id,
status: 'pending',
})
.select()
.single()
if (error) {
console.error('Database error:', error)
throw new Error('Failed to store connection')
}
return NextResponse.json({
connection_id: connection.id,
authorization_url: url,
})
} catch (error) {
console.error('Bank connection error:', error)
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Connection failed' },
{ status: 500 }
)
}
}
-111
View File
@@ -1,111 +0,0 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { eventBus } from '@/lib/events'
import { ensureInitialized } from '@/lib/init'
import { syncAccountTransactions } from '@/lib/banking/sync-transactions'
import type { Transaction } from '@/types'
ensureInitialized()
interface StoredAccount {
uid: string
iban?: string
name?: string
currency: string
balance?: number
}
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 { connection_id, days_back = 30 } = await request.json()
// Get the bank connection
const { data: connection, error: connectionError } = await supabase
.from('bank_connections')
.select('*')
.eq('id', connection_id)
.eq('user_id', user.id)
.single()
if (connectionError || !connection) {
return NextResponse.json({ error: 'Connection not found' }, { status: 404 })
}
if (connection.status !== 'active') {
return NextResponse.json({ error: 'Connection is not active' }, { status: 400 })
}
try {
const accounts = (connection.accounts_data as StoredAccount[] || []).map(a => ({ ...a }))
const toDate = new Date().toISOString().split('T')[0]
const fromDate = new Date(Date.now() - days_back * 24 * 60 * 60 * 1000)
.toISOString()
.split('T')[0]
let totalImported = 0
let totalDuplicates = 0
for (const account of accounts) {
const result = await syncAccountTransactions(
supabase,
user.id,
connection.id,
account,
fromDate,
toDate
)
totalImported += result.imported
totalDuplicates += result.duplicates
}
// Update connection with new account balances and sync timestamp
const syncedAt = new Date().toISOString()
await supabase
.from('bank_connections')
.update({
accounts_data: accounts,
last_synced_at: syncedAt,
})
.eq('id', connection.id)
// Emit event with newly synced transactions
if (totalImported > 0) {
const { data: syncedTransactions } = await supabase
.from('transactions')
.select('*')
.eq('user_id', user.id)
.eq('bank_connection_id', connection.id)
.gte('created_at', fromDate)
.order('created_at', { ascending: false })
.limit(totalImported)
if (syncedTransactions && syncedTransactions.length > 0) {
await eventBus.emit({
type: 'transaction.synced',
payload: { transactions: syncedTransactions as Transaction[], userId: user.id },
})
}
}
return NextResponse.json({
imported: totalImported,
duplicates: totalDuplicates,
last_synced_at: syncedAt,
})
} catch (error) {
console.error('Sync error:', error)
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Sync failed' },
{ status: 500 }
)
}
}
@@ -1,28 +1,25 @@
import { createServiceClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { createSession, getAccountBalance, type AccountInfo } from '@/lib/banking/enable-banking'
interface StoredAccount {
uid: string
iban?: string
name?: string
currency: string
balance?: number
}
import { createSession, getAccountBalance, type AccountInfo } from '@/extensions/enable-banking/lib/api-client'
import type { StoredAccount } from '@/extensions/enable-banking/types'
/**
* GET /api/extensions/enable-banking/callback
*
* OAuth callback for Enable Banking PSD2 authorization.
* Must be a real Next.js route (not extension handler) because
* banks redirect to this URL directly.
*/
export async function GET(request: Request) {
const { searchParams } = new URL(request.url)
// Enable Banking returns: ?code=XXX&state=user_id or ?error=XXX&error_description=YYY
const code = searchParams.get('code')
const state = searchParams.get('state') // This is the user_id we passed during authorization
const error = searchParams.get('error')
const errorDescription = searchParams.get('error_description')
// Redirect URL for success/error
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'
// Handle errors from bank authorization
if (error) {
const errorMessage = errorDescription || error
console.error('Bank authorization error:', errorMessage)
@@ -38,14 +35,10 @@ export async function GET(request: Request) {
const supabase = await createServiceClient()
try {
// Create session from authorization code
const sessionData = await createSession(code)
// Extract data from session response
const { session_id, accounts, access, aspsp } = sessionData
const consentExpiresAt = access.valid_until
// Get balances for each account
const accountsWithBalances: StoredAccount[] = await Promise.all(
accounts.map(async (account: AccountInfo) => {
try {
@@ -70,8 +63,6 @@ export async function GET(request: Request) {
})
)
// Find the pending connection for this user
// We match by user_id (state) and status='pending'
const { data: pendingConnection, error: findError } = await supabase
.from('bank_connections')
.select('id')
@@ -83,7 +74,6 @@ export async function GET(request: Request) {
if (findError || !pendingConnection) {
console.error('Could not find pending connection:', findError)
// Create a new connection if no pending one exists
const { error: insertError } = await supabase
.from('bank_connections')
.insert({
@@ -102,7 +92,6 @@ export async function GET(request: Request) {
throw new Error('Failed to create connection')
}
} else {
// Update the pending connection with session data
const { error: updateError } = await supabase
.from('bank_connections')
.update({
@@ -119,7 +108,6 @@ export async function GET(request: Request) {
}
}
// Check if the user has completed onboarding to decide redirect target
const { data: userSettings } = await supabase
.from('company_settings')
.select('onboarding_complete')
@@ -134,7 +122,6 @@ export async function GET(request: Request) {
} catch (error) {
console.error('Bank callback error:', error)
// Try to update connection status to error
try {
await supabase
.from('bank_connections')
@@ -1,18 +1,11 @@
import { createClient } from '@supabase/supabase-js'
import { NextResponse } from 'next/server'
import { syncAccountTransactions } from '@/lib/banking/sync-transactions'
import { isConsentExpiringSoon, getDaysUntilExpiry } from '@/lib/banking/enable-banking'
interface StoredAccount {
uid: string
iban?: string
name?: string
currency: string
balance?: number
}
import { syncAccountTransactions } from '@/extensions/enable-banking/lib/sync'
import { isConsentExpiringSoon, getDaysUntilExpiry } from '@/extensions/enable-banking/lib/api-client'
import type { StoredAccount } from '@/extensions/enable-banking/types'
/**
* GET /api/banking/sync/cron
* GET /api/extensions/enable-banking/sync/cron
* Automatic daily bank transaction sync
* Runs at 05:00 UTC (07:00 Swedish time)
*
@@ -41,7 +34,6 @@ export async function GET(request: Request) {
const supabase = createClient(supabaseUrl, supabaseServiceKey)
// Fetch active bank connections, prioritize least recently synced
const { data: connections, error: connError } = await supabase
.from('bank_connections')
.select('*')
@@ -71,12 +63,10 @@ export async function GET(request: Request) {
for (const connection of connections) {
try {
// Check consent expiry
const daysLeft = getDaysUntilExpiry(connection.consent_expires)
const isExpired = daysLeft !== null && daysLeft <= 0
if (isExpired) {
// Mark as expired, skip sync
await supabase
.from('bank_connections')
.update({ status: 'expired' })
@@ -97,7 +87,6 @@ export async function GET(request: Request) {
const expiringSoon = isConsentExpiringSoon(connection.consent_expires)
// Sync last 7 days (daily cron, with overlap for safety)
const toDate = new Date().toISOString().split('T')[0]
const fromDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
.toISOString()
@@ -124,7 +113,6 @@ export async function GET(request: Request) {
totalErrors += result.errors
}
// Update connection with new account balances and sync timestamp
await supabase
.from('bank_connections')
.update({
@@ -154,7 +142,6 @@ export async function GET(request: Request) {
errors: 1,
status: 'error',
})
// Continue with other connections
}
}
+130
View File
@@ -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 }
)
}
}
+98
View File
@@ -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 }
)
}
}
+5 -3
View File
@@ -4,7 +4,7 @@ import {
createInvoicePaymentJournalEntry,
createInvoiceCashEntry,
} from '@/lib/bookkeeping/invoice-entries'
import type { Invoice } from '@/types'
import type { EntityType, Invoice } from '@/types'
/**
* POST /api/invoices/[id]/mark-paid
@@ -70,11 +70,12 @@ export async function POST(
// Fetch accounting method
const { data: settings } = await supabase
.from('company_settings')
.select('accounting_method')
.select('accounting_method, entity_type')
.eq('user_id', user.id)
.single()
const accountingMethod = settings?.accounting_method || 'accrual'
const entityType = (settings?.entity_type as EntityType) || 'enskild_firma'
let journalEntryId: string | null = null
@@ -92,7 +93,8 @@ export async function POST(
const journalEntry = await createInvoiceCashEntry(
user.id,
invoice as Invoice,
paymentDate
paymentDate,
entityType
)
journalEntryId = journalEntry?.id ?? null
}
+4 -3
View File
@@ -1,7 +1,7 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries'
import type { Invoice } from '@/types'
import type { EntityType, Invoice } from '@/types'
/**
* POST /api/invoices/[id]/mark-sent
@@ -59,7 +59,7 @@ export async function POST(
// Fetch accounting method
const { data: settings } = await supabase
.from('company_settings')
.select('accounting_method')
.select('accounting_method, entity_type')
.eq('user_id', user.id)
.single()
@@ -71,7 +71,8 @@ export async function POST(
try {
const journalEntry = await createInvoiceJournalEntry(
user.id,
invoice as Invoice
invoice as Invoice,
(settings?.entity_type as EntityType) || 'enskild_firma'
)
if (journalEntry) {
journalEntryId = journalEntry.id
+2 -1
View File
@@ -164,7 +164,8 @@ export async function POST(
try {
const journalEntry = await createInvoiceJournalEntry(
user.id,
invoice as Invoice
invoice as Invoice,
(company as CompanySettings).entity_type
)
if (journalEntry) {
await supabase
+12 -2
View File
@@ -2,7 +2,7 @@ import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { eventBus } from '@/lib/events'
import { ensureInitialized } from '@/lib/init'
import type { CreateInvoiceInput, Invoice, CreditNote } from '@/types'
import type { CreateInvoiceInput, EntityType, Invoice, CreditNote } from '@/types'
import { getVatRules, calculateVat, calculateTotal } from '@/lib/invoice/vat-rules'
import { fetchExchangeRate, convertToSEK } from '@/lib/currency/riksbanken'
import {
@@ -292,12 +292,22 @@ async function createCreditNote(
.eq('id', creditNote.id)
.single()
// Fetch entity type for correct account mapping
const { data: creditNoteSettings } = await supabase
.from('company_settings')
.select('entity_type')
.eq('user_id', userId)
.single()
const entityType = (creditNoteSettings?.entity_type as EntityType) || 'enskild_firma'
// Create journal entry for the credit note (non-blocking)
if (completeCreditNote) {
try {
const journalEntry = await createCreditNoteJournalEntry(
userId,
completeCreditNote as Invoice
completeCreditNote as Invoice,
entityType
)
if (journalEntry) {
await supabase
@@ -5,7 +5,7 @@ import {
getRevenueAccount,
getOutputVatAccount,
} from '@/lib/bookkeeping/invoice-entries'
import type { Transaction, Invoice, CreateJournalEntryInput, VatTreatment } from '@/types'
import type { Transaction, Invoice, CreateJournalEntryInput, EntityType, VatTreatment } from '@/types'
interface MatchInvoiceRequest {
invoice_id: string
@@ -139,11 +139,12 @@ export async function POST(
// Fetch accounting method
const { data: settings } = await supabase
.from('company_settings')
.select('accounting_method')
.select('accounting_method, entity_type')
.eq('user_id', user.id)
.single()
const accountingMethod = settings?.accounting_method || 'accrual'
const entityType = (settings?.entity_type as EntityType) || 'enskild_firma'
// Create journal entry for payment receipt (method-aware)
let journalEntryId: string | null = null
@@ -158,7 +159,7 @@ export async function POST(
if (accountingMethod === 'cash') {
// Kontantmetoden: combined revenue entry at payment
// Debit 1930 Företagskonto, Credit 30xx Försäljning, Credit 26xx Utgående moms
const revenueAccount = getRevenueAccount(invoice.vat_treatment as VatTreatment)
const revenueAccount = getRevenueAccount(invoice.vat_treatment as VatTreatment, entityType)
const lines: CreateJournalEntryInput['lines'] = [
{
account_number: '1930',