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
-472
View File
@@ -1,472 +0,0 @@
/**
* Enable Banking API integration for PSD2 bank connections
*
* Documentation: https://enablebanking.com/docs/api/reference/
*
* Flow:
* 1. POST /auth → { url, authorization_id }
* 2. User redirects to URL, authenticates with bank
* 3. Callback receives ?code=XXX&state=YYY
* 4. POST /sessions { code } → { session_id, accounts }
* 5. GET /accounts/{uid}/balances
* 6. GET /accounts/{uid}/transactions
*/
import { getAuthorizationHeader } from './jwt'
const ENABLE_BANKING_API_URL = 'https://api.enablebanking.com'
// Types
export interface ASPSP {
name: string
country: string
logo?: string
bic?: string
beta?: boolean
max_consent_validity?: number
available_auth_methods?: AuthMethod[]
}
export interface AuthMethod {
name: string
title?: string
psu_types?: ('personal' | 'business')[]
}
export interface AuthResponse {
url: string
authorization_id: string
}
export interface SessionResponse {
session_id: string
access: {
valid_until: string
}
accounts: AccountInfo[]
aspsp: {
name: string
country: string
}
psu_type: string
}
export interface AccountInfo {
uid: string
account_id?: {
iban?: string
bban?: string
other?: string
}
name?: string
product?: string
currency: string
identification_hash?: string
}
export interface Balance {
balance_amount: {
amount: string
currency: string
}
balance_type: string
reference_date?: string
last_change_date_time?: string
}
export interface BalanceResponse {
balances: Balance[]
}
export interface Transaction {
entry_reference?: string
transaction_id?: string
booking_date?: string
value_date?: string
transaction_amount: {
amount: string
currency: string
}
credit_debit_indicator?: 'CRDT' | 'DBIT' // CRDT = credit (income), DBIT = debit (expense)
creditor_name?: string
creditor_account?: {
iban?: string
bban?: string
}
creditor?: {
name?: string
}
debtor_name?: string
debtor_account?: {
iban?: string
bban?: string
}
debtor?: {
name?: string
}
remittance_information?: string[]
merchant_category_code?: string
bank_transaction_code?: string
proprietary_bank_transaction_code?: string
}
export interface TransactionsResponse {
transactions: Transaction[]
continuation_key?: string
}
// Legacy types for backward compatibility
export interface Bank {
id: string
name: string
bic?: string
countries: string[]
logo_url?: string
}
export interface BankTransaction {
id: string
date: string
booking_date: string
amount: number
currency: string
description: string
counterparty_name?: string
counterparty_account?: string
reference?: string
merchant_category_code?: string
}
// API Helper
async function authenticatedFetch(
endpoint: string,
options: RequestInit = {}
): Promise<Response> {
const url = `${ENABLE_BANKING_API_URL}${endpoint}`
const response = await fetch(url, {
...options,
headers: {
'Authorization': getAuthorizationHeader(),
'Content-Type': 'application/json',
...options.headers,
},
})
return response
}
// API Functions
/**
* Get list of supported banks (ASPSPs) for a country
*/
export async function getASPSPs(country: string = 'SE'): Promise<ASPSP[]> {
const response = await authenticatedFetch(`/aspsps?country=${country}`)
if (!response.ok) {
const error = await response.text()
console.error('Failed to fetch ASPSPs:', error)
throw new Error('Failed to fetch banks')
}
const data = await response.json()
return data.aspsps || []
}
/**
* Get list of supported banks (legacy format for backward compatibility)
*/
export async function getSupportedBanks(): Promise<Bank[]> {
try {
const aspsps = await getASPSPs('SE')
return aspsps.map((aspsp) => ({
id: `${aspsp.name.toLowerCase().replace(/\s+/g, '-')}-se`,
name: aspsp.name,
bic: aspsp.bic,
countries: [aspsp.country],
logo_url: aspsp.logo,
}))
} catch (error) {
console.error('Error fetching banks:', error)
// Return fallback list
return [
{ id: 'nordea-se', name: 'Nordea', bic: 'NDEASESS', countries: ['SE'] },
{ id: 'seb-se', name: 'SEB', bic: 'ESSESESS', countries: ['SE'] },
{ id: 'swedbank-se', name: 'Swedbank', bic: 'SWEDSESS', countries: ['SE'] },
{ id: 'handelsbanken-se', name: 'Handelsbanken', bic: 'HANDSESS', countries: ['SE'] },
]
}
}
/**
* Start bank authorization flow
*
* @param aspspName - The name of the ASPSP (bank) exactly as returned from /aspsps
* @param aspspCountry - The country code (e.g., 'SE')
* @param redirectUrl - URL to redirect user after bank authorization
* @param state - State parameter returned in callback (e.g., user ID)
* @param psuType - Type of user: 'personal' or 'business'
*/
export async function startAuthorization(
aspspName: string,
aspspCountry: string,
redirectUrl: string,
state: string,
psuType: 'personal' | 'business' = 'personal'
): Promise<AuthResponse> {
// Calculate consent validity (90 days)
const validUntil = new Date()
validUntil.setDate(validUntil.getDate() + 90)
const requestBody = {
access: {
valid_until: validUntil.toISOString()
},
aspsp: {
name: aspspName,
country: aspspCountry
},
state,
redirect_url: redirectUrl,
psu_type: psuType
}
const response = await authenticatedFetch('/auth', {
method: 'POST',
body: JSON.stringify(requestBody)
})
if (!response.ok) {
const error = await response.text()
console.error('Failed to start authorization:', error)
throw new Error('Failed to start bank connection')
}
return response.json()
}
/**
* Create a session after user completes bank authorization
*
* @param code - The authorization code from callback
*/
export async function createSession(code: string): Promise<SessionResponse> {
const response = await authenticatedFetch('/sessions', {
method: 'POST',
body: JSON.stringify({ code })
})
if (!response.ok) {
const error = await response.text()
console.error('Failed to create session:', error)
throw new Error('Failed to create bank session')
}
return response.json()
}
/**
* Get session details
*
* @param sessionId - The session ID
*/
export async function getSession(sessionId: string): Promise<SessionResponse> {
const response = await authenticatedFetch(`/sessions/${sessionId}`)
if (!response.ok) {
throw new Error('Failed to get session')
}
return response.json()
}
/**
* Delete/revoke a session
*
* @param sessionId - The session ID to revoke
*/
export async function deleteSession(sessionId: string): Promise<void> {
const response = await authenticatedFetch(`/sessions/${sessionId}`, {
method: 'DELETE'
})
if (!response.ok) {
throw new Error('Failed to revoke session')
}
}
/**
* Get account balances
*
* @param accountUid - The account UID (from session.accounts[].uid)
*/
export async function getAccountBalances(accountUid: string): Promise<Balance[]> {
const response = await authenticatedFetch(`/accounts/${accountUid}/balances`)
if (!response.ok) {
throw new Error('Failed to get account balances')
}
const data: BalanceResponse = await response.json()
return data.balances || []
}
/**
* Get account balance (returns booked balance amount)
*/
export async function getAccountBalance(
accountUid: string
): Promise<{ amount: number; date: string }> {
const balances = await getAccountBalances(accountUid)
// Prefer closingBooked, then expected, then first available
const balance =
balances.find(b => b.balance_type === 'closingBooked') ||
balances.find(b => b.balance_type === 'expected') ||
balances[0]
if (!balance) {
return { amount: 0, date: new Date().toISOString().split('T')[0] }
}
return {
amount: parseFloat(balance.balance_amount.amount),
date: balance.reference_date || new Date().toISOString().split('T')[0]
}
}
/**
* Get account transactions
*
* @param accountUid - The account UID
* @param dateFrom - Start date (YYYY-MM-DD)
* @param dateTo - End date (YYYY-MM-DD)
* @param continuationKey - Pagination key from previous response
*/
export async function getAccountTransactions(
accountUid: string,
dateFrom?: string,
dateTo?: string,
continuationKey?: string
): Promise<TransactionsResponse> {
const params = new URLSearchParams()
if (dateFrom) params.set('date_from', dateFrom)
if (dateTo) params.set('date_to', dateTo)
if (continuationKey) params.set('continuation_key', continuationKey)
const queryString = params.toString()
const endpoint = `/accounts/${accountUid}/transactions${queryString ? `?${queryString}` : ''}`
const response = await authenticatedFetch(endpoint)
if (!response.ok) {
throw new Error('Failed to get transactions')
}
return response.json()
}
/**
* Get all transactions with pagination
*/
export async function getAllTransactions(
accountUid: string,
dateFrom?: string,
dateTo?: string
): Promise<Transaction[]> {
const allTransactions: Transaction[] = []
let continuationKey: string | undefined
do {
const response = await getAccountTransactions(
accountUid,
dateFrom,
dateTo,
continuationKey
)
allTransactions.push(...response.transactions)
continuationKey = response.continuation_key
} while (continuationKey)
return allTransactions
}
/**
* Convert Enable Banking transaction to legacy format
*/
export function convertTransaction(tx: Transaction, accountCurrency: string): BankTransaction {
const rawAmount = parseFloat(tx.transaction_amount.amount)
// Use credit_debit_indicator to determine sign
// CRDT = credit (money in) = positive
// DBIT = debit (money out) = negative
const isCredit = tx.credit_debit_indicator === 'CRDT'
const amount = isCredit ? Math.abs(rawAmount) : -Math.abs(rawAmount)
// Get counterparty name from creditor/debtor objects or direct fields
const creditorName = tx.creditor?.name || tx.creditor_name
const debtorName = tx.debtor?.name || tx.debtor_name
return {
id: tx.entry_reference || tx.transaction_id || `${tx.booking_date}_${rawAmount}`,
date: tx.value_date || tx.booking_date || new Date().toISOString().split('T')[0],
booking_date: tx.booking_date || tx.value_date || new Date().toISOString().split('T')[0],
amount,
currency: tx.transaction_amount.currency || accountCurrency,
description: tx.remittance_information?.filter(r => r.trim()).join(' ') ||
(isCredit ? debtorName : creditorName) ||
'Unknown',
counterparty_name: isCredit ? debtorName : creditorName,
counterparty_account: isCredit
? tx.debtor_account?.iban || tx.debtor_account?.bban
: tx.creditor_account?.iban || tx.creditor_account?.bban,
merchant_category_code: tx.merchant_category_code
}
}
/**
* Get transactions in legacy format
*/
export async function getTransactions(
accountUid: string,
fromDate?: string,
toDate?: string,
accountCurrency: string = 'SEK'
): Promise<BankTransaction[]> {
const transactions = await getAllTransactions(accountUid, fromDate, toDate)
return transactions.map(tx => convertTransaction(tx, accountCurrency))
}
// Utility functions
/**
* Check if consent is expiring soon (within 7 days)
*/
export function isConsentExpiringSoon(expiresAt: string | null): boolean {
if (!expiresAt) return false
const expiryDate = new Date(expiresAt)
const warningDate = new Date()
warningDate.setDate(warningDate.getDate() + 7)
return expiryDate <= warningDate
}
/**
* Get days until consent expires
*/
export function getDaysUntilExpiry(expiresAt: string | null): number | null {
if (!expiresAt) return null
const expiryDate = new Date(expiresAt)
const now = new Date()
const diffTime = expiryDate.getTime() - now.getTime()
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24))
return Math.max(0, diffDays)
}
-91
View File
@@ -1,91 +0,0 @@
/**
* JWT generation for Enable Banking API authentication
*
* Enable Banking requires JWT tokens signed with RS256 using your private key.
* The JWT is included in the Authorization header for all API calls.
*/
import * as crypto from 'crypto'
const APP_ID = process.env.ENABLE_BANKING_APP_ID
const PRIVATE_KEY_BASE64 = process.env.ENABLE_BANKING_PRIVATE_KEY
interface JWTHeader {
typ: string
alg: string
kid: string
}
interface JWTPayload {
iss: string
aud: string
iat: number
exp: number
}
function base64UrlEncode(data: Buffer | string): string {
const str = typeof data === 'string' ? data : data.toString('base64')
return str.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
function getPrivateKey(): string {
if (!PRIVATE_KEY_BASE64) {
throw new Error('ENABLE_BANKING_PRIVATE_KEY environment variable is not set')
}
// Decode base64 to get PEM format private key
const privateKeyPem = Buffer.from(PRIVATE_KEY_BASE64, 'base64').toString('utf-8')
return privateKeyPem
}
/**
* Generate a JWT token for Enable Banking API authentication
*
* @param expiresInSeconds - Token validity in seconds (default: 3600 = 1 hour)
* @returns Signed JWT token
*/
export function generateJWT(expiresInSeconds: number = 3600): string {
if (!APP_ID) {
throw new Error('ENABLE_BANKING_APP_ID environment variable is not set')
}
const now = Math.floor(Date.now() / 1000)
const header: JWTHeader = {
typ: 'JWT',
alg: 'RS256',
kid: APP_ID
}
const payload: JWTPayload = {
iss: 'enablebanking.com',
aud: 'api.enablebanking.com',
iat: now,
exp: now + expiresInSeconds
}
// Encode header and payload
const headerBase64 = base64UrlEncode(Buffer.from(JSON.stringify(header)))
const payloadBase64 = base64UrlEncode(Buffer.from(JSON.stringify(payload)))
// Create signature
const signatureInput = `${headerBase64}.${payloadBase64}`
const privateKey = getPrivateKey()
const sign = crypto.createSign('RSA-SHA256')
sign.update(signatureInput)
sign.end()
const signature = sign.sign(privateKey)
const signatureBase64 = base64UrlEncode(signature)
return `${headerBase64}.${payloadBase64}.${signatureBase64}`
}
/**
* Get the Authorization header value for Enable Banking API
*/
export function getAuthorizationHeader(): string {
const token = generateJWT()
return `Bearer ${token}`
}
-143
View File
@@ -1,143 +0,0 @@
import { SupabaseClient } from '@supabase/supabase-js'
import { getTransactions, getAccountBalance } from '@/lib/banking/enable-banking'
import { evaluateMappingRules } from '@/lib/bookkeeping/mapping-engine'
import { createTransactionJournalEntry } from '@/lib/bookkeeping/transaction-entries'
import { getBestInvoiceMatch } from '@/lib/invoice/invoice-matching'
import type { Transaction } from '@/types'
interface StoredAccount {
uid: string
iban?: string
name?: string
currency: string
balance?: number
}
export interface SyncResult {
imported: number
duplicates: number
errors: number
}
/**
* Sync transactions for a single bank account
* Shared logic used by both manual sync and cron job
*/
export async function syncAccountTransactions(
supabase: SupabaseClient,
userId: string,
connectionId: string,
account: StoredAccount,
fromDate: string,
toDate: string
): Promise<SyncResult> {
const result: SyncResult = { imported: 0, duplicates: 0, errors: 0 }
const bankTransactions = await getTransactions(
account.uid,
fromDate,
toDate,
account.currency
)
for (const tx of bankTransactions) {
const externalId = `${connectionId}_${tx.id}`
// Check for duplicates
const { data: existing } = await supabase
.from('transactions')
.select('id')
.eq('user_id', userId)
.eq('external_id', externalId)
.single()
if (existing) {
result.duplicates++
continue
}
// Insert new transaction
const { data: newTransaction, error: insertError } = await supabase
.from('transactions')
.insert({
user_id: userId,
bank_connection_id: connectionId,
external_id: externalId,
date: tx.booking_date || tx.date,
description: tx.description || tx.counterparty_name || 'Unknown',
amount: tx.amount,
currency: tx.currency || account.currency,
category: 'uncategorized',
is_business: null,
mcc_code: tx.merchant_category_code || null,
merchant_name: tx.counterparty_name || null,
})
.select()
.single()
if (insertError || !newTransaction) {
result.errors++
continue
}
result.imported++
// For income transactions, try to find matching invoices
if (newTransaction.amount > 0) {
try {
const bestMatch = await getBestInvoiceMatch(
userId,
newTransaction as Transaction,
0.50
)
if (bestMatch) {
await supabase
.from('transactions')
.update({ potential_invoice_id: bestMatch.invoice.id })
.eq('id', newTransaction.id)
}
} catch {
// Non-critical
}
}
// Evaluate mapping rules for auto-categorization
try {
const mappingResult = await evaluateMappingRules(
userId,
newTransaction as Transaction
)
if (mappingResult.confidence >= 0.8 && !mappingResult.requires_review) {
const journalEntry = await createTransactionJournalEntry(
userId,
newTransaction as Transaction,
mappingResult
)
if (journalEntry) {
await supabase
.from('transactions')
.update({
journal_entry_id: journalEntry.id,
is_business: !mappingResult.default_private,
})
.eq('id', newTransaction.id)
}
}
} catch {
// Non-critical
}
}
// Update account balance
try {
const balance = await getAccountBalance(account.uid)
account.balance = balance.amount
} catch {
// Ignore balance fetch errors
}
return result
}
@@ -0,0 +1,45 @@
import { describe, it, expect } from 'vitest'
import { getCategoryAccountMapping, getExpenseAccountForCategory } from '../category-mapping'
describe('getCategoryAccountMapping', () => {
describe('income_products uses correct account', () => {
it('maps income_products to 3001 (25% moms)', () => {
const result = getCategoryAccountMapping('income_products', 1000, true)
expect(result.creditAccount).toBe('3001')
})
it('income_products matches income_services account', () => {
const products = getCategoryAccountMapping('income_products', 1000, true)
const services = getCategoryAccountMapping('income_services', 1000, true)
expect(products.creditAccount).toBe(services.creditAccount)
})
})
describe('expense_education entity-type-aware', () => {
it('defaults to 6991 for enskild_firma', () => {
const result = getCategoryAccountMapping('expense_education', -500, true, 'enskild_firma')
expect(result.debitAccount).toBe('6991')
})
it('uses 7610 for aktiebolag', () => {
const result = getCategoryAccountMapping('expense_education', -500, true, 'aktiebolag')
expect(result.debitAccount).toBe('7610')
})
it('defaults to 6991 when no entityType provided', () => {
const result = getCategoryAccountMapping('expense_education', -500, true)
expect(result.debitAccount).toBe('6991')
})
})
})
describe('getExpenseAccountForCategory', () => {
it('returns null for non-expense categories', () => {
expect(getExpenseAccountForCategory('income_services')).toBeNull()
})
it('returns correct accounts for expense categories', () => {
expect(getExpenseAccountForCategory('expense_equipment')).toBe('5410')
expect(getExpenseAccountForCategory('expense_bank_fees')).toBe('6570')
})
})
@@ -0,0 +1,39 @@
import { describe, it, expect } from 'vitest'
import { getRevenueAccount } from '../invoice-entries'
describe('getRevenueAccount', () => {
it('standard_25 returns 3001', () => {
expect(getRevenueAccount('standard_25')).toBe('3001')
})
it('reduced_12 returns 3002', () => {
expect(getRevenueAccount('reduced_12')).toBe('3002')
})
it('reduced_6 returns 3003', () => {
expect(getRevenueAccount('reduced_6')).toBe('3003')
})
it('reverse_charge returns 3308', () => {
expect(getRevenueAccount('reverse_charge')).toBe('3308')
})
it('export returns 3305', () => {
expect(getRevenueAccount('export')).toBe('3305')
})
it('exempt defaults to 3100 for enskild_firma', () => {
expect(getRevenueAccount('exempt')).toBe('3100')
expect(getRevenueAccount('exempt', 'enskild_firma')).toBe('3100')
})
it('exempt returns 3004 for aktiebolag', () => {
expect(getRevenueAccount('exempt', 'aktiebolag')).toBe('3004')
})
it('entityType does not affect non-exempt treatments', () => {
expect(getRevenueAccount('standard_25', 'aktiebolag')).toBe('3001')
expect(getRevenueAccount('reduced_12', 'aktiebolag')).toBe('3002')
expect(getRevenueAccount('export', 'aktiebolag')).toBe('3305')
})
})
+3 -2
View File
@@ -62,6 +62,7 @@ export function getCategoryAccountMapping(
}
// Business expense categories
const educationAccount = entityType === 'aktiebolag' ? '7610' : '6991' // Utbildning (AB) / Övriga avdragsgilla kostnader (EF)
const expenseMapping: Record<string, string> = {
expense_equipment: '5410', // Förbrukningsinventarier
expense_software: '5420', // Programvaror
@@ -69,7 +70,7 @@ export function getCategoryAccountMapping(
expense_office: '5010', // Lokalhyra
expense_marketing: '5910', // Annonsering
expense_professional_services: '6530', // Redovisningstjänster
expense_education: '6991', // Övriga avdragsgilla kostnader
expense_education: educationAccount,
expense_bank_fees: '6570', // Bankavgifter
expense_card_fees: '6570', // Kortavgifter
expense_currency_exchange: '7960', // Valutakursförluster
@@ -79,7 +80,7 @@ export function getCategoryAccountMapping(
// Business income categories
const incomeMapping: Record<string, string> = {
income_services: '3001', // Försäljning tjänster 25%
income_products: '3002', // Försäljning varor 25%
income_products: '3001', // Försäljning varor 25% moms
income_other: '3900', // Övriga rörelseintäkter
}
+15 -8
View File
@@ -3,6 +3,7 @@ import { generateSalesVatLines, generateReverseChargeLines } from './vat-entries
import type {
CreateJournalEntryInput,
CreateJournalEntryLineInput,
EntityType,
Invoice,
JournalEntry,
VatTreatment,
@@ -26,7 +27,8 @@ import type {
*/
export async function createInvoiceJournalEntry(
userId: string,
invoice: Invoice
invoice: Invoice,
entityType: EntityType = 'enskild_firma'
): Promise<JournalEntry | null> {
const fiscalPeriodId = await findFiscalPeriod(userId, invoice.invoice_date)
if (!fiscalPeriodId) {
@@ -37,7 +39,7 @@ export async function createInvoiceJournalEntry(
const lines: CreateJournalEntryLineInput[] = []
// Determine revenue account based on VAT treatment
const revenueAccount = getRevenueAccount(invoice.vat_treatment)
const revenueAccount = getRevenueAccount(invoice.vat_treatment, entityType)
// Debit: Kundfordringar (total including VAT)
lines.push({
@@ -133,7 +135,8 @@ export async function createInvoicePaymentJournalEntry(
*/
export async function createCreditNoteJournalEntry(
userId: string,
creditNote: Invoice
creditNote: Invoice,
entityType: EntityType = 'enskild_firma'
): Promise<JournalEntry | null> {
const fiscalPeriodId = await findFiscalPeriod(userId, creditNote.invoice_date)
if (!fiscalPeriodId) {
@@ -141,7 +144,7 @@ export async function createCreditNoteJournalEntry(
return null
}
const revenueAccount = getRevenueAccount(creditNote.vat_treatment)
const revenueAccount = getRevenueAccount(creditNote.vat_treatment, entityType)
const absSubtotal = Math.abs(creditNote.subtotal)
const absVat = Math.abs(creditNote.vat_amount)
const absTotal = Math.abs(creditNote.total)
@@ -198,7 +201,8 @@ export async function createCreditNoteJournalEntry(
export async function createInvoiceCashEntry(
userId: string,
invoice: Invoice,
paymentDate: string
paymentDate: string,
entityType: EntityType = 'enskild_firma'
): Promise<JournalEntry | null> {
const fiscalPeriodId = await findFiscalPeriod(userId, paymentDate)
if (!fiscalPeriodId) {
@@ -206,7 +210,7 @@ export async function createInvoiceCashEntry(
return null
}
const revenueAccount = getRevenueAccount(invoice.vat_treatment)
const revenueAccount = getRevenueAccount(invoice.vat_treatment, entityType)
const lines: CreateJournalEntryLineInput[] = []
// Debit: Företagskonto (total received)
@@ -250,8 +254,11 @@ export async function createInvoiceCashEntry(
/**
* Get the appropriate revenue account based on VAT treatment
*
* For 'exempt': AB uses 3004 (Försäljning inom Sverige, momsfri),
* EF uses 3100 (Momsfria intäkter, mapped to R2 in NE engine).
*/
export function getRevenueAccount(vatTreatment: VatTreatment): string {
export function getRevenueAccount(vatTreatment: VatTreatment, entityType: EntityType = 'enskild_firma'): string {
switch (vatTreatment) {
case 'standard_25':
return '3001' // Försäljning 25%
@@ -264,7 +271,7 @@ export function getRevenueAccount(vatTreatment: VatTreatment): string {
case 'export':
return '3305' // Försäljning tjänst Export
case 'exempt':
return '3100' // Momsfria intäkter
return entityType === 'aktiebolag' ? '3004' : '3100'
default:
return '3001'
}
+7
View File
@@ -7,6 +7,12 @@ import { neBilagaExtension } from '@/extensions/ne-bilaga'
import { aiChatExtension } from '@/extensions/ai-chat'
import type { Extension } from './types'
// ── Enable Banking (PSD2) — opt-in extension ───────────────────────────
// Uncomment the following line to enable automatic PSD2 bank transaction sync.
// Requires ENABLE_BANKING_APP_ID and ENABLE_BANKING_PRIVATE_KEY env vars.
//
// import { enableBankingExtension } from '@/extensions/enable-banking'
/**
* Explicit list of first-party extensions.
*
@@ -20,6 +26,7 @@ const FIRST_PARTY_EXTENSIONS: Extension[] = [
sruExportExtension,
neBilagaExtension,
aiChatExtension,
// enableBankingExtension, // Uncomment to activate PSD2 bank sync
]
let loaded = false
@@ -0,0 +1,972 @@
/**
* Comprehensive tests for the bank file parser library.
*
* Covers auto-detection, parsing for all Swedish bank formats (Nordea, SEB,
* Swedbank, Handelsbanken), ISO 20022 camt.053 XML, external ID generation,
* file hashing, stats calculation, date range extraction, and edge cases.
*/
import { detectFileFormat, parseBankFile, generateExternalId, generateFileHash, getFormat, getAllFormats } from '../parser'
import type { ParsedBankTransaction, BankFileFormatId } from '../types'
// ---------------------------------------------------------------------------
// Test data — realistic CSV/XML content for each Swedish bank format
// ---------------------------------------------------------------------------
const NORDEA_CSV = [
'Datum,Transaktion,Kategori,Belopp,Saldo',
'2024-01-15,SPOTIFY AB,,"-99,00","12 345,67"',
'2024-01-14,ICA MAXI LINDHAGEN,,"-432,50","12 444,67"',
'2024-01-13,LÖNEUTBETALNING,,"25 000,00","12 877,17"',
].join('\n')
const NORDEA_CSV_WITH_RESERVED = [
'Datum,Transaktion,Kategori,Belopp,Saldo',
'2024-01-15,SPOTIFY AB,,"-99,00","12 345,67"',
'2024-01-14,Reserverat köp CLAS OHLSON,,"-199,00","12 444,67"',
'2024-01-13,LÖNEUTBETALNING,,"25 000,00","12 643,67"',
].join('\n')
const NORDEA_CSV_SWEDISH_CHARS = [
'Datum,Transaktion,Kategori,Belopp,Saldo',
'2024-03-01,GÖTEBORGS HAMNCAFÉ,,"-85,00","5 000,00"',
'2024-03-02,ÅHLENS CITY,,"-249,00","4 751,00"',
'2024-03-03,ÄRLA GÅRD AB,,"1 200,00","5 951,00"',
].join('\n')
const SEB_CSV = [
'Bokföringsdag;Valutadag;Verifikationsnummer;Text;Belopp;Saldo',
'2024-01-15;2024-01-15;12345;SPOTIFY AB;-99,00;12345,67',
'2024-01-14;2024-01-14;12346;HEMKÖP FRIDHEMSPLAN;-432,50;12444,67',
'2024-01-13;2024-01-13;12347;LÖNEUTBETALNING;25000,00;12877,17',
].join('\n')
const SWEDBANK_CSV = [
'Kontouppgifter',
'Clearingnummer,Kontonummer,Datum,Text,Belopp,Saldo',
'8123,12345678,2024-01-15,SPOTIFY AB,-99.00,12345.67',
'8123,12345678,2024-01-14,ICA MAXI,-432.50,12444.67',
'8123,12345678,2024-01-13,LÖNEUTBETALNING,25000.00,12877.17',
].join('\n')
const SWEDBANK_CSV_NO_METADATA = [
'Clearingnummer,Kontonummer,Datum,Text,Belopp,Saldo',
'8123,12345678,2024-02-01,TELIA SVERIGE,-299.00,10000.00',
'8123,12345678,2024-02-02,SKATTEVERKET INBETALNING,5000.00,15000.00',
].join('\n')
const HANDELSBANKEN_CSV = [
'Reskontradatum;Transaktionsdatum;Text;Belopp;Saldo',
'2024-01-15;2024-01-15;SPOTIFY AB;-99,00;12345,67',
'2024-01-14;2024-01-14;HEMKÖP;-432,50;12444,67',
'2024-01-13;2024-01-13;LÖNEUTBETALNING;25000,00;12877,17',
].join('\n')
const HANDELSBANKEN_CSV_WITH_PREL = [
'Reskontradatum;Transaktionsdatum;Text;Belopp;Saldo',
'2024-01-15;2024-01-15;SPOTIFY AB;-99,00;12345,67',
'2024-01-14;2024-01-14;Prel kortköp CLAS OHLSON;-199,00;12444,67',
'2024-01-13;2024-01-13;LÖNEUTBETALNING;25000,00;12643,67',
].join('\n')
const CAMT053_XML = `<?xml version="1.0" encoding="UTF-8"?>
<Document xmlns="urn:iso:std:iso:20022:tech:xsd:camt.053.001.02">
<BkToCstmrStmt>
<Stmt>
<Acct><Ccy>SEK</Ccy></Acct>
<Ntry>
<BookgDt><Dt>2024-01-15</Dt></BookgDt>
<Amt Ccy="SEK">99.00</Amt>
<CdtDbtInd>DBIT</CdtDbtInd>
<NtryRef>REF001</NtryRef>
<NtryDtls><TxDtls>
<RmtInf><Ustrd>SPOTIFY AB</Ustrd></RmtInf>
</TxDtls></NtryDtls>
</Ntry>
<Ntry>
<BookgDt><Dt>2024-01-14</Dt></BookgDt>
<Amt Ccy="SEK">25000.00</Amt>
<CdtDbtInd>CRDT</CdtDbtInd>
<NtryRef>REF002</NtryRef>
<NtryDtls><TxDtls>
<RmtInf><Ustrd>LÖNEUTBETALNING</Ustrd></RmtInf>
</TxDtls></NtryDtls>
</Ntry>
</Stmt>
</BkToCstmrStmt>
</Document>`
const CAMT053_XML_WITH_STRUCTURED_REF = `<?xml version="1.0" encoding="UTF-8"?>
<Document xmlns="urn:iso:std:iso:20022:tech:xsd:camt.053.001.02">
<BkToCstmrStmt>
<Stmt>
<Ntry>
<BookgDt><Dt>2024-02-01</Dt></BookgDt>
<Amt Ccy="SEK">1500.00</Amt>
<CdtDbtInd>CRDT</CdtDbtInd>
<NtryRef>REF100</NtryRef>
<NtryDtls><TxDtls>
<RmtInf>
<Strd><CdtrRefInf><Ref>OCR123456789</Ref></CdtrRefInf></Strd>
<Ustrd>Betalning faktura 1001</Ustrd>
</RmtInf>
</TxDtls></NtryDtls>
</Ntry>
</Stmt>
</BkToCstmrStmt>
</Document>`
const UNKNOWN_CSV = [
'id,name,value,timestamp',
'1,Widget A,100,2024-01-15T10:00:00',
'2,Widget B,200,2024-01-16T11:00:00',
].join('\n')
const EMPTY_FILE = ''
const HEADER_ONLY_NORDEA = 'Datum,Transaktion,Kategori,Belopp,Saldo\n'
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('detectFileFormat', () => {
it('detects Nordea CSV from header keywords', () => {
const format = detectFileFormat(NORDEA_CSV, 'transaktioner.csv')
expect(format).not.toBeNull()
expect(format!.id).toBe('nordea')
})
it('detects SEB CSV from semicolon-delimited header with bokföringsdag', () => {
const format = detectFileFormat(SEB_CSV, 'kontoutdrag.csv')
expect(format).not.toBeNull()
expect(format!.id).toBe('seb')
})
it('detects Swedbank CSV from clearingnummer header', () => {
const format = detectFileFormat(SWEDBANK_CSV, 'export.csv')
expect(format).not.toBeNull()
expect(format!.id).toBe('swedbank')
})
it('detects Swedbank CSV when header is on the first line (no metadata)', () => {
const format = detectFileFormat(SWEDBANK_CSV_NO_METADATA, 'export.csv')
expect(format).not.toBeNull()
expect(format!.id).toBe('swedbank')
})
it('detects Handelsbanken CSV from reskontradatum/transaktionsdatum header', () => {
const format = detectFileFormat(HANDELSBANKEN_CSV, 'handelsbanken.csv')
expect(format).not.toBeNull()
expect(format!.id).toBe('handelsbanken')
})
it('detects camt.053 XML from namespace and .xml extension', () => {
const format = detectFileFormat(CAMT053_XML, 'statement.xml')
expect(format).not.toBeNull()
expect(format!.id).toBe('camt053')
})
it('detects camt.053 XML when content includes BkToCstmrStmt tag', () => {
const xmlContent = '<?xml version="1.0"?><Document><BkToCstmrStmt></BkToCstmrStmt></Document>'
const format = detectFileFormat(xmlContent, 'data.xml')
expect(format).not.toBeNull()
expect(format!.id).toBe('camt053')
})
it('does not detect camt.053 without .xml extension', () => {
// camt053 detection requires .xml extension
const format = detectFileFormat(CAMT053_XML, 'statement.csv')
// It should not match camt053 since extension is .csv
// But it could match something else if the content resembles a CSV header
// The important check is that it does NOT return camt053
if (format) {
expect(format.id).not.toBe('camt053')
}
})
it('returns null for unrecognized CSV content', () => {
const format = detectFileFormat(UNKNOWN_CSV, 'data.csv')
expect(format).toBeNull()
})
it('returns null for empty content', () => {
const format = detectFileFormat(EMPTY_FILE, 'empty.csv')
expect(format).toBeNull()
})
it('generic_csv format never auto-detects', () => {
// Even with simple CSV content, generic should not be picked
const simpleCSV = 'date,description,amount\n2024-01-15,Test,-100'
const format = detectFileFormat(simpleCSV, 'test.csv')
if (format) {
expect(format.id).not.toBe('generic_csv')
}
})
it('is case-insensitive on header detection', () => {
const upperNordea = 'DATUM,TRANSAKTION,KATEGORI,BELOPP,SALDO\n2024-01-15,Test,,"-100,00","5000,00"'
const format = detectFileFormat(upperNordea, 'test.csv')
expect(format).not.toBeNull()
expect(format!.id).toBe('nordea')
})
})
describe('parseBankFile — Nordea format', () => {
it('parses comma-delimited CSV with comma decimal separator', () => {
const result = parseBankFile(NORDEA_CSV, 'nordea.csv')
expect(result.format).toBe('nordea')
expect(result.format_name).toBe('Nordea')
expect(result.transactions).toHaveLength(3)
expect(result.issues).toHaveLength(0)
})
it('correctly parses negative amounts with comma decimal and space thousands', () => {
const result = parseBankFile(NORDEA_CSV, 'nordea.csv')
const spotify = result.transactions[0]
expect(spotify.amount).toBe(-99)
expect(spotify.description).toBe('SPOTIFY AB')
expect(spotify.date).toBe('2024-01-15')
expect(spotify.currency).toBe('SEK')
const ica = result.transactions[1]
expect(ica.amount).toBe(-432.5)
})
it('correctly parses positive amounts with space thousands separator', () => {
const result = parseBankFile(NORDEA_CSV, 'nordea.csv')
const salary = result.transactions[2]
expect(salary.amount).toBe(25000)
expect(salary.description).toBe('LÖNEUTBETALNING')
})
it('parses balance field with space thousands separator', () => {
const result = parseBankFile(NORDEA_CSV, 'nordea.csv')
const spotify = result.transactions[0]
expect(spotify.balance).toBe(12345.67)
})
it('filters out "Reserverat" (pending) transactions', () => {
const result = parseBankFile(NORDEA_CSV_WITH_RESERVED, 'nordea.csv')
expect(result.transactions).toHaveLength(2)
expect(result.stats.skipped_rows).toBe(1)
const descriptions = result.transactions.map((t) => t.description)
expect(descriptions).not.toContain(expect.stringContaining('Reserverat'))
})
it('handles Swedish characters (a-ring, a-diaeresis, o-diaeresis)', () => {
const result = parseBankFile(NORDEA_CSV_SWEDISH_CHARS, 'nordea.csv')
expect(result.transactions).toHaveLength(3)
expect(result.transactions[0].description).toBe('GÖTEBORGS HAMNCAFÉ')
expect(result.transactions[1].description).toBe('ÅHLENS CITY')
expect(result.transactions[2].description).toBe('ÄRLA GÅRD AB')
})
it('stores raw_line for each transaction', () => {
const result = parseBankFile(NORDEA_CSV, 'nordea.csv')
result.transactions.forEach((tx) => {
expect(tx.raw_line).toBeDefined()
expect(tx.raw_line!.length).toBeGreaterThan(0)
})
})
it('handles header-only file with no data rows', () => {
const result = parseBankFile(HEADER_ONLY_NORDEA, 'nordea.csv')
expect(result.format).toBe('nordea')
expect(result.transactions).toHaveLength(0)
expect(result.date_from).toBeNull()
expect(result.date_to).toBeNull()
expect(result.stats.parsed_rows).toBe(0)
})
})
describe('parseBankFile — SEB format', () => {
it('parses semicolon-delimited CSV with comma decimal separator', () => {
const result = parseBankFile(SEB_CSV, 'seb.csv')
expect(result.format).toBe('seb')
expect(result.format_name).toBe('SEB')
expect(result.transactions).toHaveLength(3)
expect(result.issues).toHaveLength(0)
})
it('correctly extracts columns using dynamic header mapping', () => {
const result = parseBankFile(SEB_CSV, 'seb.csv')
const spotify = result.transactions[0]
expect(spotify.date).toBe('2024-01-15')
expect(spotify.description).toBe('SPOTIFY AB')
expect(spotify.amount).toBe(-99)
expect(spotify.balance).toBe(12345.67)
})
it('parses positive income amounts correctly', () => {
const result = parseBankFile(SEB_CSV, 'seb.csv')
const salary = result.transactions[2]
expect(salary.amount).toBe(25000)
expect(salary.description).toBe('LÖNEUTBETALNING')
})
it('handles alternative SEB header names', () => {
const altSEB = [
'Bokforingsdatum;Valutadag;Verifikationsnummer;Text;Belopp;Saldo',
'2024-01-15;2024-01-15;12345;TEST;-50,00;1000,00',
].join('\n')
const result = parseBankFile(altSEB, 'seb_alt.csv')
expect(result.format).toBe('seb')
expect(result.transactions).toHaveLength(1)
expect(result.transactions[0].amount).toBe(-50)
})
})
describe('parseBankFile — Swedbank format', () => {
it('parses comma-delimited CSV with PERIOD decimal separator', () => {
const result = parseBankFile(SWEDBANK_CSV, 'swedbank.csv')
expect(result.format).toBe('swedbank')
expect(result.format_name).toBe('Swedbank')
expect(result.transactions).toHaveLength(3)
expect(result.issues).toHaveLength(0)
})
it('correctly handles period decimal separator (the Swedish exception)', () => {
const result = parseBankFile(SWEDBANK_CSV, 'swedbank.csv')
const spotify = result.transactions[0]
expect(spotify.amount).toBe(-99)
expect(spotify.balance).toBe(12345.67)
const ica = result.transactions[1]
expect(ica.amount).toBe(-432.5)
})
it('skips metadata line when present (first line is account info)', () => {
const result = parseBankFile(SWEDBANK_CSV, 'swedbank.csv')
// With metadata line, there are 3 data rows after headerLineIdx=1
expect(result.transactions).toHaveLength(3)
// No transaction should have "Kontouppgifter" as description
const descriptions = result.transactions.map((t) => t.description)
expect(descriptions).not.toContain('Kontouppgifter')
})
it('works when header is on the first line (no metadata)', () => {
const result = parseBankFile(SWEDBANK_CSV_NO_METADATA, 'swedbank.csv')
expect(result.format).toBe('swedbank')
expect(result.transactions).toHaveLength(2)
expect(result.transactions[0].amount).toBe(-299)
expect(result.transactions[1].amount).toBe(5000)
})
it('extracts dates correctly', () => {
const result = parseBankFile(SWEDBANK_CSV, 'swedbank.csv')
expect(result.transactions[0].date).toBe('2024-01-15')
expect(result.transactions[2].date).toBe('2024-01-13')
})
})
describe('parseBankFile — Handelsbanken format', () => {
it('parses semicolon-delimited CSV with comma decimal separator', () => {
const result = parseBankFile(HANDELSBANKEN_CSV, 'handelsbanken.csv')
expect(result.format).toBe('handelsbanken')
expect(result.format_name).toBe('Handelsbanken')
expect(result.transactions).toHaveLength(3)
expect(result.issues).toHaveLength(0)
})
it('correctly parses amounts and balances', () => {
const result = parseBankFile(HANDELSBANKEN_CSV, 'handelsbanken.csv')
expect(result.transactions[0].amount).toBe(-99)
expect(result.transactions[0].balance).toBe(12345.67)
expect(result.transactions[2].amount).toBe(25000)
})
it('filters out "Prel" (preliminary) transactions', () => {
const result = parseBankFile(HANDELSBANKEN_CSV_WITH_PREL, 'handelsbanken.csv')
expect(result.transactions).toHaveLength(2)
expect(result.stats.skipped_rows).toBe(1)
const descriptions = result.transactions.map((t) => t.description)
expect(descriptions).not.toContain(expect.stringContaining('Prel'))
})
it('prefers transaktionsdatum over reskontradatum when both are present', () => {
// Handelsbanken has both columns; transaktionsdatum should be used
const result = parseBankFile(HANDELSBANKEN_CSV, 'handelsbanken.csv')
// In our test data both dates are the same, but verify it selects dates properly
expect(result.transactions[0].date).toBe('2024-01-15')
})
it('uses transaktionsdatum as the primary date field', () => {
// Create data where reskontradatum differs from transaktionsdatum
const diffDates = [
'Reskontradatum;Transaktionsdatum;Text;Belopp;Saldo',
'2024-01-16;2024-01-15;PURCHASE;-100,00;5000,00',
].join('\n')
const result = parseBankFile(diffDates, 'shb.csv')
expect(result.transactions[0].date).toBe('2024-01-15')
})
})
describe('parseBankFile — camt.053 XML format', () => {
it('parses XML with credit and debit entries', () => {
const result = parseBankFile(CAMT053_XML, 'statement.xml')
expect(result.format).toBe('camt053')
expect(result.format_name).toBe('ISO 20022 camt.053')
expect(result.transactions).toHaveLength(2)
})
it('applies DBIT indicator as negative amount', () => {
const result = parseBankFile(CAMT053_XML, 'statement.xml')
const debit = result.transactions.find((t) => t.description === 'SPOTIFY AB')
expect(debit).toBeDefined()
expect(debit!.amount).toBe(-99)
})
it('applies CRDT indicator as positive amount', () => {
const result = parseBankFile(CAMT053_XML, 'statement.xml')
const credit = result.transactions.find((t) => t.description === 'LÖNEUTBETALNING')
expect(credit).toBeDefined()
expect(credit!.amount).toBe(25000)
})
it('extracts entry reference into raw_line for external ID generation', () => {
const result = parseBankFile(CAMT053_XML, 'statement.xml')
const debit = result.transactions[0]
expect(debit.raw_line).toBe('REF001')
})
it('extracts OCR reference from structured remittance info', () => {
const result = parseBankFile(CAMT053_XML_WITH_STRUCTURED_REF, 'statement.xml')
expect(result.transactions).toHaveLength(1)
expect(result.transactions[0].reference).toBe('OCR123456789')
})
it('uses unstructured remittance info as description', () => {
const result = parseBankFile(CAMT053_XML, 'statement.xml')
expect(result.transactions[0].description).toBe('SPOTIFY AB')
})
it('extracts currency from Amount element', () => {
const result = parseBankFile(CAMT053_XML, 'statement.xml')
result.transactions.forEach((tx) => {
expect(tx.currency).toBe('SEK')
})
})
it('handles XML with no Ntry elements', () => {
const emptyXml = `<?xml version="1.0"?>
<Document xmlns="urn:iso:std:iso:20022:tech:xsd:camt.053.001.02">
<BkToCstmrStmt><Stmt></Stmt></BkToCstmrStmt></Document>`
const result = parseBankFile(emptyXml, 'empty.xml')
expect(result.format).toBe('camt053')
expect(result.transactions).toHaveLength(0)
expect(result.issues.length).toBeGreaterThan(0)
expect(result.issues[0].message).toContain('No <Ntry> elements')
})
})
describe('parseBankFile — explicit format override', () => {
it('uses the specified format instead of auto-detection', () => {
// Force parsing Nordea content as SEB (will produce issues but should use SEB format)
const result = parseBankFile(
'Datum,Transaktion,Kategori,Belopp,Saldo\n2024-01-15,Test,,"-100,00","5000,00"',
'nordea.csv',
'seb'
)
expect(result.format).toBe('seb')
})
it('returns error for unknown formatId', () => {
const result = parseBankFile(NORDEA_CSV, 'test.csv', 'unknown_format' as BankFileFormatId)
expect(result.format).toBe('unknown_format')
expect(result.format_name).toBe('Unknown')
expect(result.transactions).toHaveLength(0)
expect(result.issues).toHaveLength(1)
expect(result.issues[0].severity).toBe('error')
expect(result.issues[0].message).toContain('Unknown format')
})
it('returns format detection error when no format matches and no override given', () => {
const result = parseBankFile(UNKNOWN_CSV, 'unknown.csv')
expect(result.format).toBe('generic_csv')
expect(result.format_name).toBe('Unknown')
expect(result.transactions).toHaveLength(0)
expect(result.issues).toHaveLength(1)
expect(result.issues[0].message).toContain('Could not auto-detect')
})
it('can force generic_csv format by explicit ID', () => {
const csvContent = '2024-01-15,Some purchase,-50.00\n2024-01-16,Income,1000.00'
const result = parseBankFile(csvContent, 'test.csv', 'generic_csv')
// generic_csv uses a default mapping (date=0, description=1, amount=2)
// But the first line is treated as header (skip_rows=1), so only second row is data
expect(result.format).toBe('generic_csv')
})
})
describe('generateExternalId', () => {
const baseTx: ParsedBankTransaction = {
date: '2024-01-15',
description: 'SPOTIFY AB',
amount: -99,
currency: 'SEK',
balance: 12345.67,
reference: null,
counterparty: null,
raw_line: '2024-01-15,SPOTIFY AB,,"-99,00","12 345,67"',
}
it('generates SHA-256 composite key for CSV formats', () => {
const id = generateExternalId(baseTx, 'nordea', 0)
expect(id).toMatch(/^nordea_[0-9a-f]{16}$/)
})
it('generates different IDs for different row indices (same transaction data)', () => {
const id1 = generateExternalId(baseTx, 'nordea', 0)
const id2 = generateExternalId(baseTx, 'nordea', 1)
expect(id1).not.toBe(id2)
})
it('generates different IDs for different formats (same data, same row index)', () => {
const nordeaId = generateExternalId(baseTx, 'nordea', 0)
const sebId = generateExternalId(baseTx, 'seb', 0)
expect(nordeaId).not.toBe(sebId)
})
it('generates deterministic IDs for the same inputs', () => {
const id1 = generateExternalId(baseTx, 'nordea', 0)
const id2 = generateExternalId(baseTx, 'nordea', 0)
expect(id1).toBe(id2)
})
it('uses entry reference for camt.053 transactions with NtryRef', () => {
const camtTx: ParsedBankTransaction = {
date: '2024-01-15',
description: 'SPOTIFY AB',
amount: -99,
currency: 'SEK',
raw_line: 'REF001', // NtryRef stored in raw_line
}
const id = generateExternalId(camtTx, 'camt053', 0)
expect(id).toBe('camt053_REF001')
})
it('falls back to hash for camt.053 when raw_line starts with camt053_entry_', () => {
const camtTx: ParsedBankTransaction = {
date: '2024-01-15',
description: 'SPOTIFY AB',
amount: -99,
currency: 'SEK',
raw_line: 'camt053_entry_0', // Auto-generated fallback reference
}
const id = generateExternalId(camtTx, 'camt053', 0)
// Should fall through to hash-based ID since raw_line starts with 'camt053_entry_'
expect(id).toMatch(/^camt053_[0-9a-f]{16}$/)
})
it('falls back to hash for camt.053 when raw_line is undefined', () => {
const camtTx: ParsedBankTransaction = {
date: '2024-01-15',
description: 'SPOTIFY AB',
amount: -99,
currency: 'SEK',
}
const id = generateExternalId(camtTx, 'camt053', 0)
expect(id).toMatch(/^camt053_[0-9a-f]{16}$/)
})
it('includes amount in hash so different amounts produce different IDs', () => {
const tx1 = { ...baseTx, amount: -99 }
const tx2 = { ...baseTx, amount: -100 }
const id1 = generateExternalId(tx1, 'nordea', 0)
const id2 = generateExternalId(tx2, 'nordea', 0)
expect(id1).not.toBe(id2)
})
it('includes description in hash so different descriptions produce different IDs', () => {
const tx1 = { ...baseTx, description: 'SPOTIFY AB' }
const tx2 = { ...baseTx, description: 'NETFLIX' }
const id1 = generateExternalId(tx1, 'nordea', 0)
const id2 = generateExternalId(tx2, 'nordea', 0)
expect(id1).not.toBe(id2)
})
})
describe('generateFileHash', () => {
it('returns a SHA-256 hex string', () => {
const hash = generateFileHash(NORDEA_CSV)
expect(hash).toMatch(/^[0-9a-f]{64}$/)
})
it('produces deterministic output for the same input', () => {
const hash1 = generateFileHash(NORDEA_CSV)
const hash2 = generateFileHash(NORDEA_CSV)
expect(hash1).toBe(hash2)
})
it('produces different hashes for different content', () => {
const hash1 = generateFileHash(NORDEA_CSV)
const hash2 = generateFileHash(SEB_CSV)
expect(hash1).not.toBe(hash2)
})
it('produces different hash even for tiny content differences', () => {
const hash1 = generateFileHash('abc')
const hash2 = generateFileHash('abd')
expect(hash1).not.toBe(hash2)
})
it('handles empty string', () => {
const hash = generateFileHash('')
expect(hash).toMatch(/^[0-9a-f]{64}$/)
})
})
describe('stats calculation', () => {
it('calculates total_income as sum of positive amounts (Nordea)', () => {
const result = parseBankFile(NORDEA_CSV, 'nordea.csv')
expect(result.stats.total_income).toBe(25000)
})
it('calculates total_expenses as sum of negative amounts (Nordea)', () => {
const result = parseBankFile(NORDEA_CSV, 'nordea.csv')
// -99 + -432.5 = -531.5
expect(result.stats.total_expenses).toBe(-531.5)
})
it('calculates parsed_rows correctly', () => {
const result = parseBankFile(NORDEA_CSV, 'nordea.csv')
expect(result.stats.parsed_rows).toBe(3)
})
it('calculates total_rows (excluding header)', () => {
const result = parseBankFile(NORDEA_CSV, 'nordea.csv')
// 4 lines total - 1 header = 3 data rows
expect(result.stats.total_rows).toBe(3)
})
it('tracks skipped_rows for reserved/preliminary transactions', () => {
const result = parseBankFile(NORDEA_CSV_WITH_RESERVED, 'nordea.csv')
expect(result.stats.skipped_rows).toBe(1)
expect(result.stats.parsed_rows).toBe(2)
})
it('calculates stats correctly for SEB format', () => {
const result = parseBankFile(SEB_CSV, 'seb.csv')
expect(result.stats.total_income).toBe(25000)
expect(result.stats.total_expenses).toBe(-531.5)
expect(result.stats.parsed_rows).toBe(3)
expect(result.stats.skipped_rows).toBe(0)
})
it('calculates stats correctly for Swedbank format', () => {
const result = parseBankFile(SWEDBANK_CSV, 'swedbank.csv')
expect(result.stats.total_income).toBe(25000)
expect(result.stats.total_expenses).toBe(-531.5)
expect(result.stats.parsed_rows).toBe(3)
})
it('calculates stats correctly for camt.053 XML', () => {
const result = parseBankFile(CAMT053_XML, 'statement.xml')
expect(result.stats.total_income).toBe(25000)
expect(result.stats.total_expenses).toBe(-99)
expect(result.stats.parsed_rows).toBe(2)
expect(result.stats.total_rows).toBe(2)
})
it('uses Math.round(x * 100) / 100 for monetary precision', () => {
// Create a file that would produce floating point imprecision
const precisionCSV = [
'Datum,Transaktion,Kategori,Belopp,Saldo',
'2024-01-01,TX1,,"-0,10","100,00"',
'2024-01-02,TX2,,"-0,20","99,90"',
'2024-01-03,TX3,,"-0,30","99,70"',
].join('\n')
const result = parseBankFile(precisionCSV, 'precision.csv')
// 0.1 + 0.2 + 0.3 = 0.6000000000000001 without rounding
// With Math.round(x * 100) / 100, it should be -0.6
expect(result.stats.total_expenses).toBe(-0.6)
})
})
describe('date range extraction', () => {
it('sets date_from to the earliest date', () => {
const result = parseBankFile(NORDEA_CSV, 'nordea.csv')
expect(result.date_from).toBe('2024-01-13')
})
it('sets date_to to the latest date', () => {
const result = parseBankFile(NORDEA_CSV, 'nordea.csv')
expect(result.date_to).toBe('2024-01-15')
})
it('returns null dates for empty transaction set', () => {
const result = parseBankFile(HEADER_ONLY_NORDEA, 'nordea.csv')
expect(result.date_from).toBeNull()
expect(result.date_to).toBeNull()
})
it('handles single-transaction file (date_from equals date_to)', () => {
const singleRow = [
'Datum,Transaktion,Kategori,Belopp,Saldo',
'2024-06-15,ENSKILD BETALNING,,"-500,00","10 000,00"',
].join('\n')
const result = parseBankFile(singleRow, 'single.csv')
expect(result.date_from).toBe('2024-06-15')
expect(result.date_to).toBe('2024-06-15')
})
it('calculates correct date range for camt.053', () => {
const result = parseBankFile(CAMT053_XML, 'statement.xml')
expect(result.date_from).toBe('2024-01-14')
expect(result.date_to).toBe('2024-01-15')
})
it('sorts dates lexicographically (YYYY-MM-DD is naturally sortable)', () => {
const multiMonth = [
'Datum,Transaktion,Kategori,Belopp,Saldo',
'2024-12-31,DEC TX,,"-10,00","1000,00"',
'2024-01-01,JAN TX,,"-20,00","990,00"',
'2024-06-15,JUN TX,,"-30,00","960,00"',
].join('\n')
const result = parseBankFile(multiMonth, 'multimonth.csv')
expect(result.date_from).toBe('2024-01-01')
expect(result.date_to).toBe('2024-12-31')
})
})
describe('empty file handling', () => {
it('returns error result for completely empty file (no auto-detect match)', () => {
const result = parseBankFile(EMPTY_FILE, 'empty.csv')
expect(result.transactions).toHaveLength(0)
expect(result.issues.length).toBeGreaterThan(0)
expect(result.date_from).toBeNull()
expect(result.date_to).toBeNull()
expect(result.stats.parsed_rows).toBe(0)
})
it('returns zero transactions for file with only whitespace', () => {
const whitespace = ' \n \n '
const result = parseBankFile(whitespace, 'blank.csv')
expect(result.transactions).toHaveLength(0)
})
it('returns zero transactions for Nordea header-only file', () => {
const result = parseBankFile(HEADER_ONLY_NORDEA, 'nordea.csv')
expect(result.format).toBe('nordea')
expect(result.transactions).toHaveLength(0)
expect(result.stats.total_rows).toBe(0)
})
})
describe('getFormat and getAllFormats', () => {
it('getFormat returns the correct format by ID', () => {
const nordea = getFormat('nordea')
expect(nordea).toBeDefined()
expect(nordea!.id).toBe('nordea')
expect(nordea!.name).toBe('Nordea')
const seb = getFormat('seb')
expect(seb).toBeDefined()
expect(seb!.id).toBe('seb')
})
it('getFormat returns undefined for unknown ID', () => {
const unknown = getFormat('nonexistent' as BankFileFormatId)
expect(unknown).toBeUndefined()
})
it('getAllFormats returns all registered formats', () => {
const formats = getAllFormats()
expect(formats.length).toBeGreaterThanOrEqual(6)
const ids = formats.map((f) => f.id)
expect(ids).toContain('nordea')
expect(ids).toContain('seb')
expect(ids).toContain('swedbank')
expect(ids).toContain('handelsbanken')
expect(ids).toContain('camt053')
expect(ids).toContain('generic_csv')
})
it('camt053 is listed before bank-specific CSV formats (detection priority)', () => {
const formats = getAllFormats()
const camtIdx = formats.findIndex((f) => f.id === 'camt053')
const nordeaIdx = formats.findIndex((f) => f.id === 'nordea')
expect(camtIdx).toBeLessThan(nordeaIdx)
})
it('generic_csv is listed last (manual fallback only)', () => {
const formats = getAllFormats()
const genericIdx = formats.findIndex((f) => f.id === 'generic_csv')
expect(genericIdx).toBe(formats.length - 1)
})
})
describe('edge cases and robustness', () => {
it('handles Windows-style line endings (CRLF)', () => {
const crlfContent = 'Datum,Transaktion,Kategori,Belopp,Saldo\r\n2024-01-15,SPOTIFY AB,,"-99,00","12 345,67"\r\n'
const result = parseBankFile(crlfContent, 'nordea.csv')
expect(result.format).toBe('nordea')
expect(result.transactions).toHaveLength(1)
expect(result.transactions[0].amount).toBe(-99)
})
it('handles BOM (Byte Order Mark) prefix', () => {
const bomContent = '\uFEFF' + NORDEA_CSV
const result = parseBankFile(bomContent, 'nordea.csv')
expect(result.format).toBe('nordea')
expect(result.transactions).toHaveLength(3)
})
it('handles rows with invalid dates gracefully', () => {
const invalidDate = [
'Bokföringsdag;Valutadag;Verifikationsnummer;Text;Belopp;Saldo',
'not-a-date;2024-01-15;12345;SPOTIFY AB;-99,00;12345,67',
'2024-01-14;2024-01-14;12346;VALID TX;-50,00;12395,67',
].join('\n')
const result = parseBankFile(invalidDate, 'seb.csv')
expect(result.transactions).toHaveLength(1)
expect(result.transactions[0].description).toBe('VALID TX')
expect(result.issues.length).toBeGreaterThan(0)
expect(result.stats.skipped_rows).toBe(1)
})
it('handles rows with invalid amounts gracefully', () => {
const invalidAmount = [
'Datum,Transaktion,Kategori,Belopp,Saldo',
'2024-01-15,SPOTIFY AB,,"abc","12 345,67"',
'2024-01-14,VALID TX,,"-50,00","12 395,67"',
].join('\n')
const result = parseBankFile(invalidAmount, 'nordea.csv')
expect(result.transactions).toHaveLength(1)
expect(result.transactions[0].description).toBe('VALID TX')
expect(result.stats.skipped_rows).toBe(1)
})
it('handles trailing blank lines', () => {
const trailing = NORDEA_CSV + '\n\n\n'
const result = parseBankFile(trailing, 'nordea.csv')
expect(result.transactions).toHaveLength(3)
})
it('handles Handelsbanken CSV with only reskontradatum (no transaktionsdatum)', () => {
const onlyReskontra = [
'Reskontradatum;Text;Belopp;Saldo',
'2024-01-15;SPOTIFY AB;-99,00;12345,67',
].join('\n')
const format = detectFileFormat(onlyReskontra, 'shb.csv')
expect(format).not.toBeNull()
expect(format!.id).toBe('handelsbanken')
})
it('handles large amounts without overflow', () => {
const largeAmounts = [
'Datum,Transaktion,Kategori,Belopp,Saldo',
'2024-01-15,BIG TRANSFER,,"1 500 000,00","2 000 000,00"',
].join('\n')
const result = parseBankFile(largeAmounts, 'nordea.csv')
expect(result.transactions).toHaveLength(1)
expect(result.transactions[0].amount).toBe(1500000)
expect(result.transactions[0].balance).toBe(2000000)
})
it('handles zero amounts', () => {
const zeroAmount = [
'Datum,Transaktion,Kategori,Belopp,Saldo',
'2024-01-15,FEE REVERSAL,,"0,00","5 000,00"',
].join('\n')
const result = parseBankFile(zeroAmount, 'nordea.csv')
expect(result.transactions).toHaveLength(1)
expect(result.transactions[0].amount).toBe(0)
})
})
+87
View File
@@ -0,0 +1,87 @@
/**
* Encoding detection and conversion for Swedish bank files.
*
* Swedish bank exports use either UTF-8 or Windows-1252 (ISO-8859-1).
* We detect encoding by checking for valid Swedish characters.
*/
/**
* Decode file content, handling both UTF-8 and Windows-1252 encodings.
*
* Strategy: Try UTF-8 first. If the result contains replacement characters
* (U+FFFD) or garbled Swedish chars, fall back to Windows-1252.
*/
export function decodeFileContent(buffer: ArrayBuffer): string {
// Try UTF-8 first
const utf8Decoder = new TextDecoder('utf-8', { fatal: false })
const utf8Result = utf8Decoder.decode(buffer)
// Check if UTF-8 decode produced valid Swedish text
if (!hasEncodingIssues(utf8Result)) {
return utf8Result
}
// Fall back to Windows-1252 (superset of ISO-8859-1)
const latin1Decoder = new TextDecoder('windows-1252', { fatal: false })
return latin1Decoder.decode(buffer)
}
/**
* Decode a string that may have been incorrectly decoded as UTF-8
* when the source was actually Windows-1252.
*/
export function decodeStringContent(content: string): string {
// If the string already contains valid Swedish chars, return as-is
if (!hasEncodingIssues(content)) {
return content
}
// Try re-encoding as Latin-1 and decoding as Windows-1252
try {
const bytes = new Uint8Array(content.length)
for (let i = 0; i < content.length; i++) {
bytes[i] = content.charCodeAt(i) & 0xff
}
const decoder = new TextDecoder('windows-1252', { fatal: false })
return decoder.decode(bytes)
} catch {
return content
}
}
/**
* Check if a string has encoding issues (garbled Swedish characters).
*/
function hasEncodingIssues(text: string): boolean {
// U+FFFD = replacement character (means invalid UTF-8 byte sequences)
if (text.includes('\uFFFD')) return true
// Common garbled patterns when Windows-1252 is read as UTF-8:
// Ã¥ = å, ä = ä, ö = ö, Ã… = Å, Ä = Ä, Ö = Ö
const garbledPatterns = ['Ã¥', 'ä', 'ö', 'Ã\u0085', 'Ã\u0084', 'Ã\u0096']
return garbledPatterns.some((pattern) => text.includes(pattern))
}
/**
* Normalize line endings to \n
*/
export function normalizeLineEndings(content: string): string {
return content.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
}
/**
* Strip BOM (Byte Order Mark) from start of content
*/
export function stripBOM(content: string): string {
if (content.charCodeAt(0) === 0xfeff) {
return content.slice(1)
}
return content
}
/**
* Prepare file content for parsing: strip BOM, normalize line endings, handle encoding
*/
export function prepareContent(content: string): string {
return normalizeLineEndings(stripBOM(decodeStringContent(content)))
}
+208
View File
@@ -0,0 +1,208 @@
/**
* ISO 20022 camt.053 (BankToCustomerStatement) XML parser
*
* This is the EU standard for bank statements, increasingly used by Swedish banks.
* Namespace: urn:iso:std:iso:20022:tech:xsd:camt.053.001.XX (various versions)
*
* Key elements:
* - <BkToCstmrStmt> — root container
* - <Stmt> — one statement per account
* - <Ntry> — individual transaction entries
* - <NtryRef> / <AcctSvcrRef> — unique entry reference (external_id)
* - <CdtDbtInd> — CRDT/DBIT indicator
* - <RmtInf><Strd><CdtrRefInf> — structured remittance (OCR/Bankgiro reference)
*/
import type { BankFileFormat, BankFileParseResult, ParsedBankTransaction, BankFileParseIssue } from '../types'
import { prepareContent } from '../encoding'
export const camt053Format: BankFileFormat = {
id: 'camt053',
name: 'ISO 20022 camt.053',
description: 'ISO 20022 BankToCustomerStatement (XML)',
fileExtensions: ['.xml'],
detect(content: string, filename: string): boolean {
// Check for XML with camt.053 namespace
if (filename.toLowerCase().endsWith('.xml')) {
const lower = content.toLowerCase()
return (
lower.includes('camt.053') ||
lower.includes('bktocstmrstmt') ||
lower.includes('banktoCustomerstatement'.toLowerCase())
)
}
return false
},
parse(content: string): BankFileParseResult {
const prepared = prepareContent(content)
const transactions: ParsedBankTransaction[] = []
const issues: BankFileParseIssue[] = []
// Simple XML parsing without external dependencies
// Extract <Ntry> elements
const entries = extractElements(prepared, 'Ntry')
if (entries.length === 0) {
issues.push({
row: 0,
message: 'No <Ntry> elements found in camt.053 file',
severity: 'error',
})
}
// Try to extract currency from statement level
const stmtCcy = extractTextContent(prepared, 'Ccy') || 'SEK'
for (let i = 0; i < entries.length; i++) {
const entry = entries[i]
try {
// Date: <BookgDt><Dt> or <ValDt><Dt>
const bookingDate = extractTextContent(entry, 'BookgDt>.*?<Dt') ||
extractNestedText(entry, 'BookgDt', 'Dt')
const valueDate = extractTextContent(entry, 'ValDt>.*?<Dt') ||
extractNestedText(entry, 'ValDt', 'Dt')
const date = bookingDate || valueDate
if (!date || !/^\d{4}-\d{2}-\d{2}$/.test(date)) {
issues.push({ row: i + 1, message: `Invalid or missing date in entry ${i}`, severity: 'warning' })
continue
}
// Amount: <Amt Ccy="SEK">1234.56</Amt>
const amountMatch = entry.match(/<Amt[^>]*>([^<]+)<\/Amt>/i)
const amountStr = amountMatch?.[1]
const currencyMatch = entry.match(/<Amt[^>]*Ccy="([^"]+)"[^>]*>/i)
const currency = currencyMatch?.[1] || stmtCcy
if (!amountStr) {
issues.push({ row: i + 1, message: `Missing amount in entry ${i}`, severity: 'warning' })
continue
}
let amount = parseFloat(amountStr)
if (isNaN(amount)) {
issues.push({ row: i + 1, message: `Invalid amount: ${amountStr}`, severity: 'warning' })
continue
}
// Credit/Debit indicator: <CdtDbtInd>CRDT</CdtDbtInd> or DBIT
const cdtDbtInd = extractTextContent(entry, 'CdtDbtInd')
if (cdtDbtInd === 'DBIT') {
amount = -Math.abs(amount)
} else {
amount = Math.abs(amount)
}
// Description: <AddtlNtryInf> or <RmtInf><Ustrd>
const additionalInfo = extractTextContent(entry, 'AddtlNtryInf')
const unstructuredRemittance = extractTextContent(entry, 'Ustrd')
const description = additionalInfo || unstructuredRemittance || 'Unknown'
// Reference: Try structured remittance info first, then entry reference
const structuredRef = extractTextContent(entry, 'Ref') // Inside <CdtrRefInf><Ref>
const entryRef = extractTextContent(entry, 'NtryRef')
const acctSvcrRef = extractTextContent(entry, 'AcctSvcrRef')
const reference = structuredRef || null
// Counterparty
const creditorName = extractTextContent(entry, 'CdtrNm') ||
extractNestedText(entry, 'Cdtr', 'Nm')
const debtorName = extractTextContent(entry, 'DbtrNm') ||
extractNestedText(entry, 'Dbtr', 'Nm')
const counterparty = cdtDbtInd === 'DBIT' ? creditorName : debtorName
// Balance after entry
const balanceStr = extractTextContent(entry, 'ClsgAvlblAmt') ||
extractTextContent(entry, 'ClsgBookdAmt')
const balance = balanceStr ? parseFloat(balanceStr) : null
transactions.push({
date,
description: description.trim(),
amount: Math.round(amount * 100) / 100,
currency,
balance: balance !== null && !isNaN(balance) ? balance : null,
reference,
counterparty: counterparty?.trim() || null,
raw_line: entryRef || acctSvcrRef || `camt053_entry_${i}`,
})
} catch (err) {
issues.push({
row: i + 1,
message: `Error parsing entry ${i}: ${err instanceof Error ? err.message : 'Unknown'}`,
severity: 'warning',
})
}
}
const dates = transactions.map((t) => t.date).sort()
return {
format: 'camt053',
format_name: 'ISO 20022 camt.053',
transactions,
date_from: dates[0] || null,
date_to: dates[dates.length - 1] || null,
issues,
stats: {
total_rows: entries.length,
parsed_rows: transactions.length,
skipped_rows: entries.length - transactions.length,
total_income: Math.round(transactions.filter((t) => t.amount > 0).reduce((s, t) => s + t.amount, 0) * 100) / 100,
total_expenses: Math.round(transactions.filter((t) => t.amount < 0).reduce((s, t) => s + t.amount, 0) * 100) / 100,
},
}
},
}
/**
* Extract all occurrences of a named XML element (simple parser, no dependencies)
*/
function extractElements(xml: string, tagName: string): string[] {
const elements: string[] = []
// Match exact tag name (followed by > or whitespace, not a longer tag name)
const regex = new RegExp(`<${tagName}(?=[\\s>/])`, 'gi')
let match
while ((match = regex.exec(xml)) !== null) {
const startIdx = match.index
// Find matching closing tag
const closeTag = `</${tagName}>`
const closeIdx = xml.indexOf(closeTag, startIdx + match[0].length)
if (closeIdx === -1) continue
elements.push(xml.substring(startIdx, closeIdx + closeTag.length))
// Advance regex past this element to avoid re-matching inside it
regex.lastIndex = closeIdx + closeTag.length
}
return elements
}
/**
* Extract text content of the first occurrence of a tag
*/
function extractTextContent(xml: string, tagName: string): string | null {
const regex = new RegExp(`<${tagName}[^>]*>([^<]+)<`, 'i')
const match = xml.match(regex)
return match?.[1]?.trim() || null
}
/**
* Extract text content of a nested tag within a parent tag
*/
function extractNestedText(xml: string, parentTag: string, childTag: string): string | null {
const parentRegex = new RegExp(`<${parentTag}[^>]*>([\\s\\S]*?)<\\/${parentTag}>`, 'i')
const parentMatch = xml.match(parentRegex)
if (!parentMatch) return null
const childRegex = new RegExp(`<${childTag}[^>]*>([^<]+)<`, 'i')
const childMatch = parentMatch[1].match(childRegex)
return childMatch?.[1]?.trim() || null
}
+161
View File
@@ -0,0 +1,161 @@
/**
* Generic CSV format parser
*
* Fallback parser that requires the user to map columns manually.
* Supports configurable delimiter, decimal separator, and column mapping.
*/
import type { BankFileFormat, BankFileParseResult, ParsedBankTransaction, BankFileParseIssue, GenericCSVColumnMapping } from '../types'
import { prepareContent } from '../encoding'
import { parseCSVLine } from './nordea'
/**
* Parse a generic CSV with user-provided column mapping
*/
export function parseGenericCSV(
content: string,
mapping: GenericCSVColumnMapping
): BankFileParseResult {
const prepared = prepareContent(content)
const lines = prepared.split('\n').filter((line) => line.trim() !== '')
const transactions: ParsedBankTransaction[] = []
const issues: BankFileParseIssue[] = []
let skippedRows = 0
// Skip configured number of header/metadata rows
const startRow = mapping.skip_rows
for (let i = startRow; i < lines.length; i++) {
const line = lines[i].trim()
if (!line) continue
const fields = parseCSVLine(line, mapping.delimiter).map((f) =>
f.trim().replace(/^"|"$/g, '')
)
const dateStr = fields[mapping.date]
const description = fields[mapping.description] || 'Unknown'
const amountStr = fields[mapping.amount]
const referenceStr = mapping.reference !== undefined ? fields[mapping.reference] : undefined
const counterpartyStr = mapping.counterparty !== undefined ? fields[mapping.counterparty] : undefined
const balanceStr = mapping.balance !== undefined ? fields[mapping.balance] : undefined
if (!dateStr || !amountStr) {
skippedRows++
continue
}
// Parse amount based on configured decimal separator
let amount: number
if (mapping.decimal_separator === ',') {
amount = parseFloat(amountStr.replace(/\s/g, '').replace(',', '.'))
} else {
amount = parseFloat(amountStr.replace(/\s/g, ''))
}
if (isNaN(amount)) {
issues.push({ row: i + 1, message: `Invalid amount: ${amountStr}`, severity: 'warning' })
skippedRows++
continue
}
// Parse date - expect YYYY-MM-DD
const date = dateStr.trim()
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
issues.push({ row: i + 1, message: `Invalid date format: ${date} (expected YYYY-MM-DD)`, severity: 'warning' })
skippedRows++
continue
}
let balance: number | null = null
if (balanceStr) {
if (mapping.decimal_separator === ',') {
balance = parseFloat(balanceStr.replace(/\s/g, '').replace(',', '.'))
} else {
balance = parseFloat(balanceStr.replace(/\s/g, ''))
}
if (isNaN(balance)) balance = null
}
transactions.push({
date,
description: description.trim(),
amount,
currency: 'SEK',
balance,
reference: referenceStr?.trim() || null,
counterparty: counterpartyStr?.trim() || null,
raw_line: line,
})
}
const dates = transactions.map((t) => t.date).sort()
return {
format: 'generic_csv',
format_name: 'CSV (manuell mappning)',
transactions,
date_from: dates[0] || null,
date_to: dates[dates.length - 1] || null,
issues,
stats: {
total_rows: lines.length - startRow,
parsed_rows: transactions.length,
skipped_rows: skippedRows,
total_income: Math.round(transactions.filter((t) => t.amount > 0).reduce((s, t) => s + t.amount, 0) * 100) / 100,
total_expenses: Math.round(transactions.filter((t) => t.amount < 0).reduce((s, t) => s + t.amount, 0) * 100) / 100,
},
}
}
/**
* Get column headers from a CSV file for the mapping UI
*/
export function getCSVHeaders(content: string, delimiter: string = ','): string[] {
const prepared = prepareContent(content)
const firstLine = prepared.split('\n')[0] || ''
return parseCSVLine(firstLine, delimiter).map((h) => h.trim().replace(/^"|"$/g, ''))
}
/**
* Get a preview of the first few rows of a CSV file
*/
export function getCSVPreview(content: string, delimiter: string = ',', rows: number = 5): string[][] {
const prepared = prepareContent(content)
const lines = prepared.split('\n').filter((line) => line.trim() !== '')
return lines.slice(0, rows).map((line) =>
parseCSVLine(line, delimiter).map((f) => f.trim().replace(/^"|"$/g, ''))
)
}
/**
* Generic CSV format definition (used for format detection)
* Always returns false for detect() since it's a fallback requiring user mapping
*/
export const genericCSVFormat: BankFileFormat = {
id: 'generic_csv',
name: 'CSV (manuell mappning)',
description: 'Generisk CSV-fil med manuell kolumnmappning',
fileExtensions: ['.csv', '.txt'],
detect(_content: string, _filename: string): boolean {
// Generic CSV never auto-detects — it's the manual fallback
return false
},
parse(content: string): BankFileParseResult {
// Default mapping for a basic CSV: date, description, amount
const defaultMapping: GenericCSVColumnMapping = {
date: 0,
description: 1,
amount: 2,
delimiter: ',',
decimal_separator: ',',
skip_rows: 1,
date_format: 'YYYY-MM-DD',
}
return parseGenericCSV(content, defaultMapping)
},
}
@@ -0,0 +1,143 @@
/**
* Handelsbanken CSV format parser
*
* Format: Semicolon-delimited, comma decimal separator
* Columns: Reskontradatum, Transaktionsdatum, Text, Belopp, Saldo
* Date format: YYYY-MM-DD
* Encoding: UTF-8 or Windows-1252
*
* Notes:
* - Filter rows with "Prel" prefix (preliminary/pending transactions)
*/
import type { BankFileFormat, BankFileParseResult, ParsedBankTransaction, BankFileParseIssue } from '../types'
import { prepareContent } from '../encoding'
function parseCommaDecimal(value: string): number {
const cleaned = value.replace(/\s/g, '').replace(',', '.')
return parseFloat(cleaned)
}
export const handelsbankenFormat: BankFileFormat = {
id: 'handelsbanken',
name: 'Handelsbanken',
description: 'Handelsbanken CSV (semicolon-delimited)',
fileExtensions: ['.csv', '.txt'],
detect(content: string, _filename: string): boolean {
const prepared = prepareContent(content)
const firstLine = prepared.split('\n')[0]?.toLowerCase() || ''
return (
firstLine.includes(';') &&
(firstLine.includes('reskontradatum') || firstLine.includes('transaktionsdatum'))
)
},
parse(content: string): BankFileParseResult {
const prepared = prepareContent(content)
const lines = prepared.split('\n').filter((line) => line.trim() !== '')
const transactions: ParsedBankTransaction[] = []
const issues: BankFileParseIssue[] = []
let skippedRows = 0
// Parse header
const headerLine = lines[0] || ''
const headers = headerLine.split(';').map((h) => h.trim().toLowerCase().replace(/"/g, ''))
const dateIdx = headers.findIndex(
(h) => h.includes('reskontradatum') || h.includes('transaktionsdatum')
)
const txDateIdx = headers.findIndex((h) => h.includes('transaktionsdatum'))
const descIdx = headers.findIndex((h) => h === 'text' || h.includes('beskrivning'))
const amountIdx = headers.findIndex((h) => h.includes('belopp'))
const balanceIdx = headers.findIndex((h) => h.includes('saldo'))
if (dateIdx === -1 || amountIdx === -1) {
issues.push({
row: 1,
message: 'Could not identify required columns',
severity: 'error',
})
return {
format: 'handelsbanken',
format_name: 'Handelsbanken',
transactions: [],
date_from: null,
date_to: null,
issues,
stats: { total_rows: 0, parsed_rows: 0, skipped_rows: 0, total_income: 0, total_expenses: 0 },
}
}
// Prefer transaktionsdatum over reskontradatum if available
const primaryDateIdx = txDateIdx >= 0 ? txDateIdx : dateIdx
for (let i = 1; i < lines.length; i++) {
const line = lines[i].trim()
if (!line) continue
const fields = line.split(';').map((f) => f.trim().replace(/^"|"$/g, ''))
const date = fields[primaryDateIdx]
const description = descIdx >= 0 ? fields[descIdx] : 'Unknown'
const amountStr = fields[amountIdx]
const balanceStr = balanceIdx >= 0 ? fields[balanceIdx] : undefined
// Skip preliminary transactions
if (description?.toLowerCase().startsWith('prel')) {
skippedRows++
continue
}
if (!date || !amountStr) {
skippedRows++
continue
}
const amount = parseCommaDecimal(amountStr)
if (isNaN(amount)) {
issues.push({ row: i + 1, message: `Invalid amount: ${amountStr}`, severity: 'warning' })
skippedRows++
continue
}
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
issues.push({ row: i + 1, message: `Invalid date: ${date}`, severity: 'warning' })
skippedRows++
continue
}
const balance = balanceStr ? parseCommaDecimal(balanceStr) : null
transactions.push({
date,
description: (description || 'Unknown').trim(),
amount,
currency: 'SEK',
balance: isNaN(balance as number) ? null : balance,
reference: null,
counterparty: null,
raw_line: line,
})
}
const dates = transactions.map((t) => t.date).sort()
return {
format: 'handelsbanken',
format_name: 'Handelsbanken',
transactions,
date_from: dates[0] || null,
date_to: dates[dates.length - 1] || null,
issues,
stats: {
total_rows: lines.length - 1,
parsed_rows: transactions.length,
skipped_rows: skippedRows,
total_income: Math.round(transactions.filter((t) => t.amount > 0).reduce((s, t) => s + t.amount, 0) * 100) / 100,
total_expenses: Math.round(transactions.filter((t) => t.amount < 0).reduce((s, t) => s + t.amount, 0) * 100) / 100,
},
}
},
}
+151
View File
@@ -0,0 +1,151 @@
/**
* Nordea CSV format parser
*
* Format: Comma-delimited, comma decimal separator
* Columns: Datum, Transaktion, Kategori, Belopp, Saldo
* Date format: YYYY-MM-DD
* Encoding: UTF-8 or Windows-1252
*
* Notes:
* - Skip rows with "Reserverat" in Transaktion (pending transactions)
* - Skip trailing blank lines
*/
import type { BankFileFormat, BankFileParseResult, ParsedBankTransaction, BankFileParseIssue } from '../types'
import { prepareContent } from '../encoding'
function parseCommaDecimal(value: string): number {
// Swedish format: "1 234,56" or "-1 234,56"
const cleaned = value.replace(/\s/g, '').replace(',', '.')
return parseFloat(cleaned)
}
export const nordeaFormat: BankFileFormat = {
id: 'nordea',
name: 'Nordea',
description: 'Nordea CSV (Datum, Transaktion, Kategori, Belopp, Saldo)',
fileExtensions: ['.csv', '.txt'],
detect(content: string, _filename: string): boolean {
const prepared = prepareContent(content)
const firstLine = prepared.split('\n')[0]?.toLowerCase() || ''
// Nordea header: comma-delimited with "datum", "transaktion", "belopp"
// Must NOT contain semicolons (that would be SEB or Handelsbanken)
return (
!firstLine.includes(';') &&
firstLine.includes('datum') &&
firstLine.includes('transaktion') &&
firstLine.includes('belopp')
)
},
parse(content: string): BankFileParseResult {
const prepared = prepareContent(content)
const lines = prepared.split('\n').filter((line) => line.trim() !== '')
const transactions: ParsedBankTransaction[] = []
const issues: BankFileParseIssue[] = []
let skippedRows = 0
// Skip header row
for (let i = 1; i < lines.length; i++) {
const line = lines[i].trim()
if (!line) continue
// Parse CSV with comma delimiter
// Handle quoted fields that may contain commas
const fields = parseCSVLine(line, ',')
if (fields.length < 4) {
issues.push({ row: i + 1, message: 'Too few columns', severity: 'warning' })
skippedRows++
continue
}
const [date, description, _category, amountStr, balanceStr] = fields
// Skip reserved/pending transactions
if (description?.toLowerCase().includes('reserverat')) {
skippedRows++
continue
}
const amount = parseCommaDecimal(amountStr)
if (isNaN(amount)) {
issues.push({ row: i + 1, message: `Invalid amount: ${amountStr}`, severity: 'warning' })
skippedRows++
continue
}
// Validate date format
if (!/^\d{4}-\d{2}-\d{2}$/.test(date?.trim())) {
issues.push({ row: i + 1, message: `Invalid date: ${date}`, severity: 'warning' })
skippedRows++
continue
}
const balance = balanceStr ? parseCommaDecimal(balanceStr) : null
transactions.push({
date: date.trim(),
description: description?.trim() || 'Unknown',
amount,
currency: 'SEK',
balance: isNaN(balance as number) ? null : balance,
reference: null,
counterparty: null,
raw_line: line,
})
}
const dates = transactions.map((t) => t.date).sort()
return {
format: 'nordea',
format_name: 'Nordea',
transactions,
date_from: dates[0] || null,
date_to: dates[dates.length - 1] || null,
issues,
stats: {
total_rows: lines.length - 1,
parsed_rows: transactions.length,
skipped_rows: skippedRows,
total_income: Math.round(transactions.filter((t) => t.amount > 0).reduce((s, t) => s + t.amount, 0) * 100) / 100,
total_expenses: Math.round(transactions.filter((t) => t.amount < 0).reduce((s, t) => s + t.amount, 0) * 100) / 100,
},
}
},
}
/**
* Parse a CSV line respecting quoted fields
*/
function parseCSVLine(line: string, delimiter: string): string[] {
const fields: string[] = []
let current = ''
let inQuotes = false
for (let i = 0; i < line.length; i++) {
const char = line[i]
if (char === '"') {
if (inQuotes && line[i + 1] === '"') {
current += '"'
i++ // Skip escaped quote
} else {
inQuotes = !inQuotes
}
} else if (char === delimiter && !inQuotes) {
fields.push(current)
current = ''
} else {
current += char
}
}
fields.push(current)
return fields
}
export { parseCSVLine }
+138
View File
@@ -0,0 +1,138 @@
/**
* SEB CSV format parser
*
* Format: Semicolon-delimited, comma decimal separator
* Columns vary but typically: Bokföringsdag, Valutadag, Verifikationsnummer,
* Text/mottagare, Belopp, Saldo
* Date format: YYYY-MM-DD
* Encoding: UTF-8 or Windows-1252
*/
import type { BankFileFormat, BankFileParseResult, ParsedBankTransaction, BankFileParseIssue } from '../types'
import { prepareContent } from '../encoding'
function parseCommaDecimal(value: string): number {
const cleaned = value.replace(/\s/g, '').replace(',', '.')
return parseFloat(cleaned)
}
export const sebFormat: BankFileFormat = {
id: 'seb',
name: 'SEB',
description: 'SEB CSV (semicolon-delimited)',
fileExtensions: ['.csv', '.txt'],
detect(content: string, _filename: string): boolean {
const prepared = prepareContent(content)
const firstLine = prepared.split('\n')[0]?.toLowerCase() || ''
// SEB headers contain "bokföringsdag" or "bokforingsdatum" and use semicolons
return (
firstLine.includes(';') &&
(firstLine.includes('bokföringsdag') ||
firstLine.includes('bokforingsdatum') ||
firstLine.includes('bokföringsdag'))
)
},
parse(content: string): BankFileParseResult {
const prepared = prepareContent(content)
const lines = prepared.split('\n').filter((line) => line.trim() !== '')
const transactions: ParsedBankTransaction[] = []
const issues: BankFileParseIssue[] = []
let skippedRows = 0
// Parse header to find column indices
const headerLine = lines[0] || ''
const headers = headerLine.split(';').map((h) => h.trim().toLowerCase().replace(/"/g, ''))
// Find column indices dynamically
const dateIdx = headers.findIndex(
(h) => h.includes('bokföringsdag') || h.includes('bokforingsdatum') || h.includes('bokföringsdag')
)
const descIdx = headers.findIndex(
(h) => h.includes('text') || h.includes('mottagare') || h.includes('beskrivning')
)
const amountIdx = headers.findIndex((h) => h.includes('belopp'))
const balanceIdx = headers.findIndex((h) => h.includes('saldo'))
if (dateIdx === -1 || amountIdx === -1) {
issues.push({
row: 1,
message: 'Could not identify required columns (date, amount)',
severity: 'error',
})
return {
format: 'seb',
format_name: 'SEB',
transactions: [],
date_from: null,
date_to: null,
issues,
stats: { total_rows: 0, parsed_rows: 0, skipped_rows: 0, total_income: 0, total_expenses: 0 },
}
}
for (let i = 1; i < lines.length; i++) {
const line = lines[i].trim()
if (!line) continue
const fields = line.split(';').map((f) => f.trim().replace(/^"|"$/g, ''))
const date = fields[dateIdx]
const description = fields[descIdx >= 0 ? descIdx : dateIdx + 1] || 'Unknown'
const amountStr = fields[amountIdx]
const balanceStr = balanceIdx >= 0 ? fields[balanceIdx] : undefined
if (!date || !amountStr) {
issues.push({ row: i + 1, message: 'Missing required fields', severity: 'warning' })
skippedRows++
continue
}
const amount = parseCommaDecimal(amountStr)
if (isNaN(amount)) {
issues.push({ row: i + 1, message: `Invalid amount: ${amountStr}`, severity: 'warning' })
skippedRows++
continue
}
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
issues.push({ row: i + 1, message: `Invalid date: ${date}`, severity: 'warning' })
skippedRows++
continue
}
const balance = balanceStr ? parseCommaDecimal(balanceStr) : null
transactions.push({
date,
description: description.trim(),
amount,
currency: 'SEK',
balance: isNaN(balance as number) ? null : balance,
reference: null,
counterparty: null,
raw_line: line,
})
}
const dates = transactions.map((t) => t.date).sort()
return {
format: 'seb',
format_name: 'SEB',
transactions,
date_from: dates[0] || null,
date_to: dates[dates.length - 1] || null,
issues,
stats: {
total_rows: lines.length - 1,
parsed_rows: transactions.length,
skipped_rows: skippedRows,
total_income: Math.round(transactions.filter((t) => t.amount > 0).reduce((s, t) => s + t.amount, 0) * 100) / 100,
total_expenses: Math.round(transactions.filter((t) => t.amount < 0).reduce((s, t) => s + t.amount, 0) * 100) / 100,
},
}
},
}
+149
View File
@@ -0,0 +1,149 @@
/**
* Swedbank CSV format parser
*
* Format: Comma-delimited, PERIOD decimal separator (exception among Swedish banks!)
* Columns: Clearingnummer, Kontonummer, Datum, Text, Belopp, Saldo, and more (12 columns)
* Date format: YYYY-MM-DD
* Encoding: UTF-8 or Windows-1252
*
* Notes:
* - First line is metadata (account info), SKIP it
* - Second line is the actual header
* - Uses period as decimal separator (unlike Nordea/SEB/Handelsbanken)
*/
import type { BankFileFormat, BankFileParseResult, ParsedBankTransaction, BankFileParseIssue } from '../types'
import { prepareContent } from '../encoding'
import { parseCSVLine } from './nordea'
export const swedbankFormat: BankFileFormat = {
id: 'swedbank',
name: 'Swedbank',
description: 'Swedbank CSV (comma-delimited, period decimal)',
fileExtensions: ['.csv', '.txt'],
detect(content: string, _filename: string): boolean {
const prepared = prepareContent(content)
const lines = prepared.split('\n')
// Check first two lines — Swedbank has metadata line, then header
const line1 = lines[0]?.toLowerCase() || ''
const line2 = lines[1]?.toLowerCase() || ''
return (
(line1.includes('clearingnummer') || line2.includes('clearingnummer') ||
line1.includes('radnummer') || line2.includes('radnummer'))
)
},
parse(content: string): BankFileParseResult {
const prepared = prepareContent(content)
const lines = prepared.split('\n').filter((line) => line.trim() !== '')
const transactions: ParsedBankTransaction[] = []
const issues: BankFileParseIssue[] = []
let skippedRows = 0
// Determine where the header is
// Line 0 might be metadata, line 1 might be header
let headerLineIdx = 0
const line0Lower = lines[0]?.toLowerCase() || ''
const line1Lower = lines[1]?.toLowerCase() || ''
if (line1Lower.includes('clearingnummer') || line1Lower.includes('radnummer')) {
headerLineIdx = 1
} else if (line0Lower.includes('clearingnummer') || line0Lower.includes('radnummer')) {
headerLineIdx = 0
}
const headerLine = lines[headerLineIdx] || ''
const headers = parseCSVLine(headerLine, ',').map((h) =>
h.trim().toLowerCase().replace(/"/g, '')
)
// Find column indices
const dateIdx = headers.findIndex((h) => h === 'datum' || h.includes('bokföringsdatum'))
const descIdx = headers.findIndex((h) => h === 'text' || h.includes('beskrivning'))
const amountIdx = headers.findIndex((h) => h === 'belopp')
const balanceIdx = headers.findIndex((h) => h === 'saldo')
if (dateIdx === -1 || amountIdx === -1) {
issues.push({
row: 1,
message: 'Could not identify required columns (datum, belopp)',
severity: 'error',
})
return {
format: 'swedbank',
format_name: 'Swedbank',
transactions: [],
date_from: null,
date_to: null,
issues,
stats: { total_rows: 0, parsed_rows: 0, skipped_rows: 0, total_income: 0, total_expenses: 0 },
}
}
// Data starts after header
for (let i = headerLineIdx + 1; i < lines.length; i++) {
const line = lines[i].trim()
if (!line) continue
const fields = parseCSVLine(line, ',').map((f) => f.trim().replace(/^"|"$/g, ''))
const date = fields[dateIdx]
const description = descIdx >= 0 ? fields[descIdx] : 'Unknown'
const amountStr = fields[amountIdx]
const balanceStr = balanceIdx >= 0 ? fields[balanceIdx] : undefined
if (!date || !amountStr) {
skippedRows++
continue
}
// Swedbank uses PERIOD decimal separator
const amount = parseFloat(amountStr.replace(/\s/g, ''))
if (isNaN(amount)) {
issues.push({ row: i + 1, message: `Invalid amount: ${amountStr}`, severity: 'warning' })
skippedRows++
continue
}
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
issues.push({ row: i + 1, message: `Invalid date: ${date}`, severity: 'warning' })
skippedRows++
continue
}
const balance = balanceStr ? parseFloat(balanceStr.replace(/\s/g, '')) : null
transactions.push({
date,
description: (description || 'Unknown').trim(),
amount,
currency: 'SEK',
balance: isNaN(balance as number) ? null : balance,
reference: null,
counterparty: null,
raw_line: line,
})
}
const dates = transactions.map((t) => t.date).sort()
return {
format: 'swedbank',
format_name: 'Swedbank',
transactions,
date_from: dates[0] || null,
date_to: dates[dates.length - 1] || null,
issues,
stats: {
total_rows: lines.length - headerLineIdx - 1,
parsed_rows: transactions.length,
skipped_rows: skippedRows,
total_income: Math.round(transactions.filter((t) => t.amount > 0).reduce((s, t) => s + t.amount, 0) * 100) / 100,
total_expenses: Math.round(transactions.filter((t) => t.amount < 0).reduce((s, t) => s + t.amount, 0) * 100) / 100,
},
}
},
}
+138
View File
@@ -0,0 +1,138 @@
/**
* Bank file parser — main entry point
*
* Auto-detects Swedish bank file formats and parses to normalized transactions.
* Supports Nordea, SEB, Swedbank, Handelsbanken CSV and ISO 20022 camt.053 XML.
*/
import * as crypto from 'crypto'
import type { BankFileFormat, BankFileFormatId, BankFileParseResult, ParsedBankTransaction } from './types'
import { nordeaFormat } from './formats/nordea'
import { sebFormat } from './formats/seb'
import { swedbankFormat } from './formats/swedbank'
import { handelsbankenFormat } from './formats/handelsbanken'
import { camt053Format } from './formats/camt053'
import { genericCSVFormat } from './formats/generic-csv'
/**
* Ordered list of format detectors.
* camt.053 first (XML detection is unambiguous), then bank-specific CSV formats.
* Generic CSV is last — it never auto-detects (manual fallback only).
*/
const FORMATS: BankFileFormat[] = [
camt053Format,
nordeaFormat,
sebFormat,
swedbankFormat,
handelsbankenFormat,
genericCSVFormat,
]
/**
* Get a format by its ID
*/
export function getFormat(id: BankFileFormatId): BankFileFormat | undefined {
return FORMATS.find((f) => f.id === id)
}
/**
* Get all available formats
*/
export function getAllFormats(): BankFileFormat[] {
return FORMATS
}
/**
* Auto-detect the bank file format from content and filename
*
* Returns the first matching format, or null if no format matches.
* Uses filename extension as a hint (e.g. .xml for camt.053).
*/
export function detectFileFormat(content: string, filename: string): BankFileFormat | null {
for (const format of FORMATS) {
if (format.detect(content, filename)) {
return format
}
}
return null
}
/**
* Parse a bank file with auto-detection or explicit format
*
* @param content - File content as string (already decoded)
* @param filename - Original filename (used for format detection hints)
* @param formatId - Optional explicit format to use (skips auto-detection)
*/
export function parseBankFile(
content: string,
filename: string,
formatId?: BankFileFormatId
): BankFileParseResult {
let format: BankFileFormat | undefined
if (formatId) {
format = getFormat(formatId)
if (!format) {
return {
format: formatId,
format_name: 'Unknown',
transactions: [],
date_from: null,
date_to: null,
issues: [{ row: 0, message: `Unknown format: ${formatId}`, severity: 'error' }],
stats: { total_rows: 0, parsed_rows: 0, skipped_rows: 0, total_income: 0, total_expenses: 0 },
}
}
} else {
format = detectFileFormat(content, filename) || undefined
if (!format) {
return {
format: 'generic_csv',
format_name: 'Unknown',
transactions: [],
date_from: null,
date_to: null,
issues: [{
row: 0,
message: 'Could not auto-detect file format. Please select your bank manually.',
severity: 'error',
}],
stats: { total_rows: 0, parsed_rows: 0, skipped_rows: 0, total_income: 0, total_expenses: 0 },
}
}
}
return format.parse(content)
}
/**
* Generate a stable external_id for a parsed bank transaction.
*
* For CSV files: SHA-256 of (format + date + description + amount + row_index)
* For camt.053: Uses the entry reference from the XML if available
*
* Two identical transactions on the same day will get different IDs due to row_index.
*/
export function generateExternalId(
tx: ParsedBankTransaction,
formatId: BankFileFormatId,
rowIndex: number
): string {
// For camt.053, prefer the raw_line which contains the entry reference
if (formatId === 'camt053' && tx.raw_line && !tx.raw_line.startsWith('camt053_entry_')) {
return `camt053_${tx.raw_line}`
}
// For CSV formats, create a composite hash
const composite = `${formatId}|${tx.date}|${tx.description}|${tx.amount}|${rowIndex}`
const hash = crypto.createHash('sha256').update(composite).digest('hex').substring(0, 16)
return `${formatId}_${hash}`
}
/**
* Generate a file hash for dedup of the same file being uploaded twice
*/
export function generateFileHash(content: string): string {
return crypto.createHash('sha256').update(content).digest('hex')
}
+94
View File
@@ -0,0 +1,94 @@
/**
* Bank file import types
*
* Supports Swedish bank CSV formats (Nordea, SEB, Swedbank, Handelsbanken)
* and ISO 20022 camt.053 XML.
*/
/** Parsed and normalized bank transaction from any file format */
export interface ParsedBankTransaction {
date: string // YYYY-MM-DD
description: string
amount: number // Positive = income, negative = expense
currency: string
balance?: number | null
reference?: string | null // OCR number, Bankgiro reference
counterparty?: string | null
raw_line?: string // Original CSV line for debugging
}
/** Result from parsing a bank file */
export interface BankFileParseResult {
format: BankFileFormatId
format_name: string
transactions: ParsedBankTransaction[]
date_from: string | null
date_to: string | null
issues: BankFileParseIssue[]
stats: {
total_rows: number
parsed_rows: number
skipped_rows: number
total_income: number
total_expenses: number
}
}
/** Issue encountered during parsing */
export interface BankFileParseIssue {
row: number
message: string
severity: 'warning' | 'error'
}
/** Supported bank file format identifiers */
export type BankFileFormatId =
| 'nordea'
| 'seb'
| 'swedbank'
| 'handelsbanken'
| 'generic_csv'
| 'camt053'
/** Format definition with detection and parsing capability */
export interface BankFileFormat {
id: BankFileFormatId
name: string
description: string
fileExtensions: string[]
detect: (content: string, filename: string) => boolean
parse: (content: string) => BankFileParseResult
}
/** Import tracking record stored in DB */
export interface BankFileImport {
id: string
user_id: string
filename: string
file_hash: string
file_format: string
transaction_count: number
imported_count: number
duplicate_count: number
matched_count: number
date_from: string | null
date_to: string | null
status: 'pending' | 'processing' | 'completed' | 'failed'
error_message: string | null
created_at: string
updated_at: string
}
/** Column mapping for generic CSV format */
export interface GenericCSVColumnMapping {
date: number
description: number
amount: number
reference?: number
counterparty?: number
balance?: number
delimiter: string
decimal_separator: ',' | '.'
skip_rows: number
date_format: string // e.g. 'YYYY-MM-DD'
}
+24
View File
@@ -11,6 +11,7 @@ export interface InvoiceMatch {
* Confidence thresholds for invoice matching
*/
const CONFIDENCE = {
OCR_REFERENCE_MATCH: 0.99,
EXACT_AMOUNT_CUSTOMER: 0.95,
EXACT_AMOUNT_ONLY: 0.80,
FUZZY_AMOUNT_CUSTOMER: 0.70,
@@ -144,6 +145,29 @@ export async function findMatchingInvoices(
const matches: InvoiceMatch[] = []
// OCR/Bankgiro reference matching — highest confidence
// Swedish standard: match transaction reference to invoice OCR number
const txReference = (transaction as Transaction & { reference?: string | null }).reference
if (txReference) {
const normalizedRef = txReference.replace(/\s+/g, '')
for (const invoice of invoices) {
// Match against invoice_number (used as OCR reference in Swedish payments)
const invoiceRef = invoice.invoice_number?.replace(/\s+/g, '')
if (invoiceRef && normalizedRef === invoiceRef) {
matches.push({
invoice: invoice as Invoice & { customer?: Customer },
confidence: CONFIDENCE.OCR_REFERENCE_MATCH,
matchReason: `OCR-referens matchar fakturanummer ${invoice.invoice_number}`,
})
}
}
// If we found an OCR match, return immediately (highest possible confidence)
if (matches.length > 0) {
return matches
}
}
for (const invoice of invoices) {
// Currency filter - must match or be SEK equivalent
const currencyMatch =
+477
View File
@@ -0,0 +1,477 @@
/**
* Tests for the generic transaction ingestion pipeline.
*
* Covers deduplication, insert, invoice matching, auto-categorization,
* and result aggregation.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { ingestTransactions, type RawTransaction } from '../ingest'
import { makeJournalEntry, makeTransaction } from '@/tests/helpers'
// ---------------------------------------------------------------------------
// Mocks
// ---------------------------------------------------------------------------
vi.mock('@/lib/supabase/server')
const mockEvaluateMappingRules = vi.fn()
vi.mock('@/lib/bookkeeping/mapping-engine', () => ({
evaluateMappingRules: (...args: unknown[]) => mockEvaluateMappingRules(...args),
}))
const mockCreateTransactionJournalEntry = vi.fn()
vi.mock('@/lib/bookkeeping/transaction-entries', () => ({
createTransactionJournalEntry: (...args: unknown[]) =>
mockCreateTransactionJournalEntry(...args),
}))
const mockGetBestInvoiceMatch = vi.fn()
vi.mock('@/lib/invoice/invoice-matching', () => ({
getBestInvoiceMatch: (...args: unknown[]) => mockGetBestInvoiceMatch(...args),
}))
// ---------------------------------------------------------------------------
// Queue-based Supabase mock
// ---------------------------------------------------------------------------
function createQueueMockSupabase() {
const resultQueue: { data: unknown; error: unknown }[] = []
/**
* Push one or more results onto the queue.
* Each awaited Supabase chain pops the next result in FIFO order.
*/
const enqueue = (...results: { data?: unknown; error?: unknown }[]) => {
for (const r of results) {
resultQueue.push({ data: r.data ?? null, error: r.error ?? null })
}
}
const buildChain = (): unknown => {
const handler: ProxyHandler<object> = {
get(_target, prop) {
if (prop === 'then') {
const next = resultQueue.shift() ?? { data: null, error: null }
return (resolve: (v: unknown) => void) => resolve(next)
}
return (..._args: unknown[]) => buildChain()
},
}
return new Proxy({}, handler)
}
const supabase = {
from: vi.fn().mockImplementation(() => buildChain()),
rpc: vi.fn().mockImplementation(() => buildChain()),
}
return { supabase, enqueue }
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const USER_ID = 'user-1'
function makeRaw(overrides: Partial<RawTransaction> = {}): RawTransaction {
return {
date: '2024-06-15',
description: 'Test transaction',
amount: -250.0,
currency: 'SEK',
external_id: `ext-${Math.random().toString(36).slice(2, 8)}`,
mcc_code: null,
merchant_name: null,
reference: null,
bank_connection_id: null,
import_source: 'test',
...overrides,
}
}
function makeMappingResult(overrides: Record<string, unknown> = {}) {
return {
rule: null,
debit_account: '5410',
credit_account: '1930',
risk_level: 'low',
confidence: 0.9,
requires_review: false,
default_private: false,
vat_lines: [],
description: 'Office supplies',
...overrides,
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('ingestTransactions', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// -----------------------------------------------------------------------
// 1. Successfully imports new transactions
// -----------------------------------------------------------------------
it('imports new transactions when no duplicate exists', async () => {
const { supabase, enqueue } = createQueueMockSupabase()
const raw = makeRaw({ amount: -100 })
const inserted = makeTransaction({ id: 'tx-1', external_id: raw.external_id })
// Dedup check returns null (no existing row)
enqueue({ data: null, error: null })
// Insert returns the new transaction
enqueue({ data: inserted, error: null })
// evaluateMappingRules will be called but we want low confidence
mockEvaluateMappingRules.mockResolvedValue(makeMappingResult({ confidence: 0.5 }))
const result = await ingestTransactions(supabase as never, USER_ID, [raw])
expect(result.imported).toBe(1)
expect(result.duplicates).toBe(0)
expect(result.errors).toBe(0)
expect(result.transaction_ids).toEqual(['tx-1'])
})
// -----------------------------------------------------------------------
// 2. Detects duplicates
// -----------------------------------------------------------------------
it('detects duplicates via external_id', async () => {
const { supabase, enqueue } = createQueueMockSupabase()
const raw = makeRaw()
// Dedup check returns an existing record
enqueue({ data: { id: 'existing-tx-1' }, error: null })
const result = await ingestTransactions(supabase as never, USER_ID, [raw])
expect(result.duplicates).toBe(1)
expect(result.imported).toBe(0)
expect(result.transaction_ids).toEqual([])
})
// -----------------------------------------------------------------------
// 3. Counts errors when insert fails
// -----------------------------------------------------------------------
it('counts errors when insert fails', async () => {
const { supabase, enqueue } = createQueueMockSupabase()
const raw = makeRaw()
// Dedup check: no duplicate
enqueue({ data: null, error: null })
// Insert fails
enqueue({ data: null, error: { message: 'DB constraint violation' } })
const result = await ingestTransactions(supabase as never, USER_ID, [raw])
expect(result.errors).toBe(1)
expect(result.imported).toBe(0)
expect(result.transaction_ids).toEqual([])
})
// -----------------------------------------------------------------------
// 4. Auto-matches invoices for income transactions (amount > 0)
// -----------------------------------------------------------------------
it('auto-matches invoices for income transactions', async () => {
const { supabase, enqueue } = createQueueMockSupabase()
const raw = makeRaw({ amount: 5000, description: 'Payment received' })
const inserted = makeTransaction({
id: 'tx-income',
amount: 5000,
external_id: raw.external_id,
})
// Dedup: no duplicate
enqueue({ data: null, error: null })
// Insert returns the new transaction
enqueue({ data: inserted, error: null })
// Invoice match update (supabase.from('transactions').update(...))
enqueue({ data: null, error: null })
// Mapping rules auto-categorization update (if triggered)
enqueue({ data: null, error: null })
mockGetBestInvoiceMatch.mockResolvedValue({
invoice: { id: 'inv-1' },
confidence: 0.95,
matchReason: 'OCR reference match',
})
mockEvaluateMappingRules.mockResolvedValue(makeMappingResult({ confidence: 0.5 }))
const result = await ingestTransactions(supabase as never, USER_ID, [raw])
expect(result.auto_matched_invoices).toBe(1)
expect(mockGetBestInvoiceMatch).toHaveBeenCalledWith(
USER_ID,
expect.objectContaining({ id: 'tx-income' }),
0.50
)
})
// -----------------------------------------------------------------------
// 5. Does not attempt invoice matching for expenses (amount < 0)
// -----------------------------------------------------------------------
it('does not attempt invoice matching for expenses', async () => {
const { supabase, enqueue } = createQueueMockSupabase()
const raw = makeRaw({ amount: -350 })
const inserted = makeTransaction({
id: 'tx-expense',
amount: -350,
external_id: raw.external_id,
})
// Dedup: no duplicate
enqueue({ data: null, error: null })
// Insert
enqueue({ data: inserted, error: null })
mockEvaluateMappingRules.mockResolvedValue(makeMappingResult({ confidence: 0.5 }))
const result = await ingestTransactions(supabase as never, USER_ID, [raw])
expect(result.imported).toBe(1)
expect(result.auto_matched_invoices).toBe(0)
expect(mockGetBestInvoiceMatch).not.toHaveBeenCalled()
})
// -----------------------------------------------------------------------
// 6. Auto-categorizes when mapping confidence >= 0.8
// -----------------------------------------------------------------------
it('auto-categorizes when mapping confidence is at least 0.8', async () => {
const { supabase, enqueue } = createQueueMockSupabase()
const raw = makeRaw({ amount: -500, mcc_code: 5411, merchant_name: 'ICA' })
const inserted = makeTransaction({
id: 'tx-cat',
amount: -500,
external_id: raw.external_id,
})
const journalEntry = makeJournalEntry({ id: 'je-1' })
// Dedup: no duplicate
enqueue({ data: null, error: null })
// Insert
enqueue({ data: inserted, error: null })
// Update after journal entry creation
enqueue({ data: null, error: null })
mockEvaluateMappingRules.mockResolvedValue(
makeMappingResult({ confidence: 0.85, requires_review: false })
)
mockCreateTransactionJournalEntry.mockResolvedValue(journalEntry)
const result = await ingestTransactions(supabase as never, USER_ID, [raw])
expect(result.auto_categorized).toBe(1)
expect(mockCreateTransactionJournalEntry).toHaveBeenCalledWith(
USER_ID,
expect.objectContaining({ id: 'tx-cat' }),
expect.objectContaining({ confidence: 0.85 })
)
})
// -----------------------------------------------------------------------
// 7. Skips auto-categorization when confidence < 0.8
// -----------------------------------------------------------------------
it('skips auto-categorization when confidence is below 0.8', async () => {
const { supabase, enqueue } = createQueueMockSupabase()
const raw = makeRaw({ amount: -200 })
const inserted = makeTransaction({
id: 'tx-lowconf',
amount: -200,
external_id: raw.external_id,
})
// Dedup: no duplicate
enqueue({ data: null, error: null })
// Insert
enqueue({ data: inserted, error: null })
mockEvaluateMappingRules.mockResolvedValue(
makeMappingResult({ confidence: 0.6 })
)
const result = await ingestTransactions(supabase as never, USER_ID, [raw])
expect(result.auto_categorized).toBe(0)
expect(mockCreateTransactionJournalEntry).not.toHaveBeenCalled()
})
// -----------------------------------------------------------------------
// 7b. Skips auto-categorization when requires_review is true
// -----------------------------------------------------------------------
it('skips auto-categorization when requires_review is true even if confidence is high', async () => {
const { supabase, enqueue } = createQueueMockSupabase()
const raw = makeRaw({ amount: -800 })
const inserted = makeTransaction({
id: 'tx-review',
amount: -800,
external_id: raw.external_id,
})
// Dedup: no duplicate
enqueue({ data: null, error: null })
// Insert
enqueue({ data: inserted, error: null })
mockEvaluateMappingRules.mockResolvedValue(
makeMappingResult({ confidence: 0.95, requires_review: true })
)
const result = await ingestTransactions(supabase as never, USER_ID, [raw])
expect(result.auto_categorized).toBe(0)
expect(mockCreateTransactionJournalEntry).not.toHaveBeenCalled()
})
// -----------------------------------------------------------------------
// 8. Returns correct IngestResult totals
// -----------------------------------------------------------------------
it('returns correct IngestResult totals', async () => {
const { supabase, enqueue } = createQueueMockSupabase()
const raw1 = makeRaw({ external_id: 'ext-a', amount: -100 })
const raw2 = makeRaw({ external_id: 'ext-b', amount: -200 })
const inserted1 = makeTransaction({ id: 'tx-a', amount: -100 })
const inserted2 = makeTransaction({ id: 'tx-b', amount: -200 })
// Transaction 1: dedup (no match), insert OK
enqueue({ data: null, error: null })
enqueue({ data: inserted1, error: null })
// Transaction 2: dedup (no match), insert OK
enqueue({ data: null, error: null })
enqueue({ data: inserted2, error: null })
mockEvaluateMappingRules.mockResolvedValue(makeMappingResult({ confidence: 0.5 }))
const result = await ingestTransactions(supabase as never, USER_ID, [raw1, raw2])
expect(result.imported).toBe(2)
expect(result.duplicates).toBe(0)
expect(result.errors).toBe(0)
expect(result.auto_categorized).toBe(0)
expect(result.auto_matched_invoices).toBe(0)
expect(result.transaction_ids).toEqual(['tx-a', 'tx-b'])
})
// -----------------------------------------------------------------------
// 9. Handles mixed batch (new, duplicates, errors)
// -----------------------------------------------------------------------
it('handles a mixed batch of new transactions, duplicates, and errors', async () => {
const { supabase, enqueue } = createQueueMockSupabase()
const rawNew = makeRaw({ external_id: 'ext-new', amount: 3000 })
const rawDup = makeRaw({ external_id: 'ext-dup', amount: -150 })
const rawErr = makeRaw({ external_id: 'ext-err', amount: -75 })
const insertedNew = makeTransaction({
id: 'tx-new',
amount: 3000,
external_id: 'ext-new',
})
// Transaction rawNew: dedup (no match), insert OK
enqueue({ data: null, error: null })
enqueue({ data: insertedNew, error: null })
// Invoice match update for income transaction
enqueue({ data: null, error: null })
// Auto-categorization update
enqueue({ data: null, error: null })
// Transaction rawDup: dedup returns existing record
enqueue({ data: { id: 'existing-dup' }, error: null })
// Transaction rawErr: dedup (no match), insert fails
enqueue({ data: null, error: null })
enqueue({ data: null, error: { message: 'Insert failed' } })
// Income transaction gets an invoice match
mockGetBestInvoiceMatch.mockResolvedValue({
invoice: { id: 'inv-match' },
confidence: 0.95,
matchReason: 'Exact amount match',
})
// Auto-categorization with high confidence
mockEvaluateMappingRules.mockResolvedValue(
makeMappingResult({ confidence: 0.85 })
)
const journalEntry = makeJournalEntry({ id: 'je-mixed' })
mockCreateTransactionJournalEntry.mockResolvedValue(journalEntry)
const result = await ingestTransactions(
supabase as never,
USER_ID,
[rawNew, rawDup, rawErr]
)
expect(result.imported).toBe(1)
expect(result.duplicates).toBe(1)
expect(result.errors).toBe(1)
expect(result.auto_matched_invoices).toBe(1)
expect(result.auto_categorized).toBe(1)
expect(result.transaction_ids).toEqual(['tx-new'])
})
// -----------------------------------------------------------------------
// Edge: empty input array
// -----------------------------------------------------------------------
it('returns zero totals for an empty input array', async () => {
const { supabase } = createQueueMockSupabase()
const result = await ingestTransactions(supabase as never, USER_ID, [])
expect(result).toEqual({
imported: 0,
duplicates: 0,
auto_categorized: 0,
auto_matched_invoices: 0,
errors: 0,
transaction_ids: [],
})
})
// -----------------------------------------------------------------------
// Edge: invoice matching error is non-critical
// -----------------------------------------------------------------------
it('continues processing when invoice matching throws', async () => {
const { supabase, enqueue } = createQueueMockSupabase()
const raw = makeRaw({ amount: 1000 })
const inserted = makeTransaction({ id: 'tx-inv-err', amount: 1000 })
enqueue({ data: null, error: null })
enqueue({ data: inserted, error: null })
mockGetBestInvoiceMatch.mockRejectedValue(new Error('Network error'))
mockEvaluateMappingRules.mockResolvedValue(makeMappingResult({ confidence: 0.5 }))
const result = await ingestTransactions(supabase as never, USER_ID, [raw])
// Should still count as imported even though invoice matching failed
expect(result.imported).toBe(1)
expect(result.auto_matched_invoices).toBe(0)
expect(result.errors).toBe(0)
})
// -----------------------------------------------------------------------
// Edge: auto-categorization error is non-critical
// -----------------------------------------------------------------------
it('continues processing when auto-categorization throws', async () => {
const { supabase, enqueue } = createQueueMockSupabase()
const raw = makeRaw({ amount: -400 })
const inserted = makeTransaction({ id: 'tx-cat-err', amount: -400 })
enqueue({ data: null, error: null })
enqueue({ data: inserted, error: null })
mockEvaluateMappingRules.mockRejectedValue(new Error('Mapping error'))
const result = await ingestTransactions(supabase as never, USER_ID, [raw])
expect(result.imported).toBe(1)
expect(result.auto_categorized).toBe(0)
expect(result.errors).toBe(0)
})
})
+159
View File
@@ -0,0 +1,159 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { evaluateMappingRules } from '@/lib/bookkeeping/mapping-engine'
import { createTransactionJournalEntry } from '@/lib/bookkeeping/transaction-entries'
import { getBestInvoiceMatch } from '@/lib/invoice/invoice-matching'
import type { Transaction } from '@/types'
/**
* Normalized transaction input for the generic ingestion pipeline.
* Both file import and PSD2 sync convert to this format before ingesting.
*/
export interface RawTransaction {
date: string
description: string
amount: number
currency: string
external_id: string // dedup key
mcc_code?: number | null
merchant_name?: string | null
reference?: string | null // OCR number, Bankgiro ref, etc.
bank_connection_id?: string | null
import_source?: string // 'csv_nordea', 'camt053', 'enable_banking', etc.
}
export interface IngestResult {
imported: number
duplicates: number
auto_categorized: number
auto_matched_invoices: number
errors: number
transaction_ids: string[]
}
/**
* Generic transaction ingestion pipeline.
*
* Handles:
* 1. Deduplication via external_id
* 2. Insert into transactions table
* 3. OCR/reference-based invoice matching (highest confidence)
* 4. Amount+customer fallback invoice matching
* 5. Mapping rule evaluation for auto-categorization
* 6. Auto-journal-entry creation for high-confidence matches
*
* Used by both bank file import and Enable Banking PSD2 sync.
*/
export async function ingestTransactions(
supabase: SupabaseClient,
userId: string,
rawTransactions: RawTransaction[]
): Promise<IngestResult> {
const result: IngestResult = {
imported: 0,
duplicates: 0,
auto_categorized: 0,
auto_matched_invoices: 0,
errors: 0,
transaction_ids: [],
}
for (const raw of rawTransactions) {
// 1. Check for duplicates via external_id
const { data: existing } = await supabase
.from('transactions')
.select('id')
.eq('user_id', userId)
.eq('external_id', raw.external_id)
.single()
if (existing) {
result.duplicates++
continue
}
// 2. Insert new transaction
const { data: newTransaction, error: insertError } = await supabase
.from('transactions')
.insert({
user_id: userId,
bank_connection_id: raw.bank_connection_id || null,
external_id: raw.external_id,
date: raw.date,
description: raw.description,
amount: raw.amount,
currency: raw.currency,
category: 'uncategorized',
is_business: null,
mcc_code: raw.mcc_code || null,
merchant_name: raw.merchant_name || null,
reference: raw.reference || null,
import_source: raw.import_source || null,
})
.select()
.single()
if (insertError || !newTransaction) {
result.errors++
continue
}
result.imported++
result.transaction_ids.push(newTransaction.id)
// 3. For income transactions, try invoice matching
if (newTransaction.amount > 0) {
try {
// OCR/reference matching is handled inside getBestInvoiceMatch
// (which calls findMatchingInvoices, which now checks references)
const bestMatch = await getBestInvoiceMatch(
userId,
newTransaction as Transaction,
0.50
)
if (bestMatch) {
await supabase
.from('transactions')
.update({ potential_invoice_id: bestMatch.invoice.id })
.eq('id', newTransaction.id)
result.auto_matched_invoices++
}
} catch {
// Non-critical — continue processing
}
}
// 4. Evaluate mapping rules for auto-categorization
try {
const mappingResult = await evaluateMappingRules(
userId,
newTransaction as Transaction
)
if (mappingResult.confidence >= 0.8 && !mappingResult.requires_review) {
const journalEntry = await createTransactionJournalEntry(
userId,
newTransaction as Transaction,
mappingResult
)
if (journalEntry) {
await supabase
.from('transactions')
.update({
journal_entry_id: journalEntry.id,
is_business: !mappingResult.default_private,
})
.eq('id', newTransaction.id)
result.auto_categorized++
}
}
} catch {
// Non-critical — continue processing
}
}
return result
}