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
@@ -0,0 +1,139 @@
import { createServiceClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
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)
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')
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'
if (error) {
const errorMessage = errorDescription || error
console.error('Bank authorization error:', errorMessage)
return NextResponse.redirect(
`${baseUrl}/settings?bank_error=${encodeURIComponent(errorMessage)}`
)
}
if (!code || !state) {
return NextResponse.redirect(`${baseUrl}/settings?bank_error=missing_parameters`)
}
const supabase = await createServiceClient()
try {
const sessionData = await createSession(code)
const { session_id, accounts, access, aspsp } = sessionData
const consentExpiresAt = access.valid_until
const accountsWithBalances: StoredAccount[] = await Promise.all(
accounts.map(async (account: AccountInfo) => {
try {
const balance = await getAccountBalance(account.uid)
return {
uid: account.uid,
iban: account.account_id?.iban,
name: account.name || account.product,
currency: account.currency,
balance: balance.amount,
}
} catch (balanceError) {
console.error(`Failed to get balance for account ${account.uid}:`, balanceError)
return {
uid: account.uid,
iban: account.account_id?.iban,
name: account.name || account.product,
currency: account.currency,
balance: undefined,
}
}
})
)
const { data: pendingConnection, error: findError } = await supabase
.from('bank_connections')
.select('id')
.eq('user_id', state)
.eq('status', 'pending')
.order('created_at', { ascending: false })
.limit(1)
.single()
if (findError || !pendingConnection) {
console.error('Could not find pending connection:', findError)
const { error: insertError } = await supabase
.from('bank_connections')
.insert({
user_id: state,
provider: `${aspsp.name.toLowerCase().replace(/\s+/g, '-')}-${aspsp.country.toLowerCase()}`,
bank_name: aspsp.name,
session_id,
status: 'active',
accounts_data: accountsWithBalances,
consent_expires: consentExpiresAt,
last_synced_at: new Date().toISOString(),
})
if (insertError) {
console.error('Insert error:', insertError)
throw new Error('Failed to create connection')
}
} else {
const { error: updateError } = await supabase
.from('bank_connections')
.update({
session_id,
status: 'active',
accounts_data: accountsWithBalances,
consent_expires: consentExpiresAt,
last_synced_at: new Date().toISOString(),
})
.eq('id', pendingConnection.id)
if (updateError) {
throw new Error('Failed to update connection')
}
}
const { data: userSettings } = await supabase
.from('company_settings')
.select('onboarding_complete')
.eq('user_id', state)
.single()
const redirectTarget = userSettings?.onboarding_complete
? '/settings?bank_connected=true'
: '/onboarding?bank_connected=true'
return NextResponse.redirect(`${baseUrl}${redirectTarget}`)
} catch (error) {
console.error('Bank callback error:', error)
try {
await supabase
.from('bank_connections')
.update({ status: 'error' })
.eq('user_id', state)
.eq('status', 'pending')
} catch {
// Ignore cleanup errors
}
return NextResponse.redirect(
`${baseUrl}/settings?bank_error=${encodeURIComponent('Connection failed')}`
)
}
}
@@ -0,0 +1,163 @@
import { createClient } from '@supabase/supabase-js'
import { NextResponse } from 'next/server'
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/extensions/enable-banking/sync/cron
* Automatic daily bank transaction sync
* Runs at 05:00 UTC (07:00 Swedish time)
*
* Processes up to 10 connections per run (Vercel Hobby 60s timeout).
* Prioritizes connections not synced for the longest time.
* Deduplication via external_id makes repeated runs safe.
*/
export async function GET(request: Request) {
// Verify cron secret
const authHeader = request.headers.get('authorization')
const cronSecret = process.env.CRON_SECRET
if (cronSecret && authHeader !== `Bearer ${cronSecret}`) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY
if (!supabaseUrl || !supabaseServiceKey) {
return NextResponse.json(
{ error: 'Missing Supabase configuration' },
{ status: 500 }
)
}
const supabase = createClient(supabaseUrl, supabaseServiceKey)
const { data: connections, error: connError } = await supabase
.from('bank_connections')
.select('*')
.eq('status', 'active')
.order('last_synced_at', { ascending: true, nullsFirst: true })
.limit(10)
if (connError) {
console.error('Failed to fetch bank connections:', connError)
return NextResponse.json({ error: 'Failed to fetch connections' }, { status: 500 })
}
if (!connections || connections.length === 0) {
return NextResponse.json({ message: 'No active connections to sync', processed: 0 })
}
const results: {
connectionId: string
userId: string
bankName: string
imported: number
duplicates: number
errors: number
status: 'synced' | 'expired' | 'expiring_soon' | 'error'
daysUntilExpiry?: number | null
}[] = []
for (const connection of connections) {
try {
const daysLeft = getDaysUntilExpiry(connection.consent_expires)
const isExpired = daysLeft !== null && daysLeft <= 0
if (isExpired) {
await supabase
.from('bank_connections')
.update({ status: 'expired' })
.eq('id', connection.id)
results.push({
connectionId: connection.id,
userId: connection.user_id,
bankName: connection.bank_name,
imported: 0,
duplicates: 0,
errors: 0,
status: 'expired',
daysUntilExpiry: 0,
})
continue
}
const expiringSoon = isConsentExpiringSoon(connection.consent_expires)
const toDate = new Date().toISOString().split('T')[0]
const fromDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
.toISOString()
.split('T')[0]
const accounts = (connection.accounts_data as StoredAccount[] || []).map(a => ({ ...a }))
let totalImported = 0
let totalDuplicates = 0
let totalErrors = 0
for (const account of accounts) {
const result = await syncAccountTransactions(
supabase,
connection.user_id,
connection.id,
account,
fromDate,
toDate
)
totalImported += result.imported
totalDuplicates += result.duplicates
totalErrors += result.errors
}
await supabase
.from('bank_connections')
.update({
accounts_data: accounts,
last_synced_at: new Date().toISOString(),
})
.eq('id', connection.id)
results.push({
connectionId: connection.id,
userId: connection.user_id,
bankName: connection.bank_name,
imported: totalImported,
duplicates: totalDuplicates,
errors: totalErrors,
status: expiringSoon ? 'expiring_soon' : 'synced',
daysUntilExpiry: daysLeft,
})
} catch (error) {
console.error(`Sync failed for connection ${connection.id}:`, error)
results.push({
connectionId: connection.id,
userId: connection.user_id,
bankName: connection.bank_name,
imported: 0,
duplicates: 0,
errors: 1,
status: 'error',
})
}
}
const totalImported = results.reduce((sum, r) => sum + r.imported, 0)
const totalExpired = results.filter(r => r.status === 'expired').length
const totalExpiringSoon = results.filter(r => r.status === 'expiring_soon').length
const totalFailed = results.filter(r => r.status === 'error').length
console.log(`[bank-sync-cron] Processed ${results.length} connections: ${totalImported} imported, ${totalExpired} expired, ${totalExpiringSoon} expiring soon, ${totalFailed} failed`)
return NextResponse.json({
processed: results.length,
totalImported,
totalExpired,
totalExpiringSoon,
totalFailed,
results,
})
}