Bug/mcp connection issue (#541)

* feat(api): implement caching and logging in health check endpoint

- Added in-memory caching for health check responses to reduce load on Postgres.
- Introduced logging for error handling in health check.
- Updated response structure to exclude error details from public responses.

feat(api): enhance OAuth consent UI and scope handling

- Improved consent UI to reflect exact requested scopes and added better user guidance.
- Updated scope handling logic to ensure least-privilege access.
- Enhanced styling for better user experience and accessibility.

chore(docker): improve security and resource management in Docker setup

- Updated Docker Compose configuration to enforce read-only file systems and resource limits.
- Added health checks and logging options for better observability.
- Introduced optional Caddy reverse proxy for TLS termination.

fix(migrations): resolve ambiguity in create_company_with_owner function

- Dropped orphaned 3-arg overload of create_company_with_owner function.
- Recreated canonical 4-arg version with cash account seeding logic.
- Ensured proper permissions for function execution in Postgres.

* feat: enhance security checks for team membership in company creation

* test: add CSP tests for OAuth authorization endpoint

* feat: enhance error handling and reporting in bank file import process
This commit is contained in:
Mattsson
2026-05-20 10:08:32 +02:00
committed by GitHub
parent 9aced4790c
commit 566ed72984
14 changed files with 674 additions and 48 deletions
+157 -1
View File
@@ -8,7 +8,7 @@
import { detectFileFormat, parseBankFile, generateExternalId, generateFileHash, getFormat, getAllFormats } from '../parser'
import type { ParsedBankTransaction, BankFileFormatId } from '../types'
import { parseGenericCSV } from '../formats/generic-csv'
import { parseGenericCSV, normalizeMinusSign } from '../formats/generic-csv'
import { parseCSVLine } from '../formats/nordea'
// ---------------------------------------------------------------------------
@@ -171,6 +171,25 @@ const LUNAR_CSV = [
'2024-01-13,LÖNEUTBETALNING,"25.000,00","12.877,17"',
].join('\n')
// Northmill exports include a 5-line metadata preamble (Kontonummer, Saldo,
// Kontohavare, Org. Nr, Period) plus blank lines before the actual transaction
// header. Negative amounts use Unicode minus (U+2212), not ASCII hyphen.
const NORTHMILL_CSV = [
'Kontonummer,9750-8770139',
'Saldo,"251495,41",SEK',
'Kontohavare,Arcim Technology AB',
'Org. Nr,559538-6219',
'Period,2025-10-01,2026-04-07',
'',
'',
'',
'Bokföringsdag,Beskrivning,Belopp,Saldo,Valuta',
'2026-04-01,Månadsavgift företagspaket april,"\u2212139,00","251495,41",SEK',
'2026-01-22,200176580348155,"\u22121000,00","251912,41",SEK',
'2025-10-16,092221155575,"400000,00","422005,00",SEK',
'2025-10-01,Inbetalning av aktiekapital,"25000,00","25000,00",SEK',
].join('\n')
const UNKNOWN_CSV = [
'id,name,value,timestamp',
'1,Widget A,100,2024-01-15T10:00:00',
@@ -357,6 +376,22 @@ describe('detectFileFormat', () => {
expect(format!.id).toBe('lunar')
})
it('detects Northmill CSV from Kontonummer preamble + transaction header', () => {
const format = detectFileFormat(NORTHMILL_CSV, 'Northmill-Account-Statement.csv')
expect(format).not.toBeNull()
expect(format!.id).toBe('northmill')
})
it('does not detect Northmill on a file that just happens to mention Kontonummer in transactions', () => {
const fake = [
'Datum,Transaktion,Kategori,Belopp,Saldo',
'2024-01-15,Överföring kontonummer 1234,Inkomst,"100,00","1000,00"',
].join('\n')
const format = detectFileFormat(fake, 'test.csv')
// Should detect as Nordea, not Northmill — Northmill needs Kontonummer at start of first line
expect(format!.id).toBe('nordea')
})
it('returns null for unrecognized CSV content', () => {
const format = detectFileFormat(UNKNOWN_CSV, 'data.csv')
expect(format).toBeNull()
@@ -1054,6 +1089,53 @@ describe('parseBankFile — Lunar format', () => {
})
})
describe('parseBankFile — Northmill format', () => {
it('skips the 5-line metadata preamble and blank lines, parses transaction rows', () => {
const result = parseBankFile(NORTHMILL_CSV, 'Northmill.csv')
expect(result.format).toBe('northmill')
expect(result.format_name).toBe('Northmill')
expect(result.transactions).toHaveLength(4)
expect(result.issues).toHaveLength(0)
})
it('correctly parses negative amounts that use Unicode minus (U+2212)', () => {
const result = parseBankFile(NORTHMILL_CSV, 'Northmill.csv')
// First transaction is "−139,00" with U+2212 — must become -139, not NaN
expect(result.transactions[0].amount).toBe(-139)
expect(result.transactions[0].description).toBe('Månadsavgift företagspaket april')
expect(result.transactions[0].date).toBe('2026-04-01')
expect(result.transactions[1].amount).toBe(-1000)
expect(result.transactions[2].amount).toBe(400000)
expect(result.transactions[3].amount).toBe(25000)
})
it('extracts the saldo (running balance) column', () => {
const result = parseBankFile(NORTHMILL_CSV, 'Northmill.csv')
expect(result.transactions[0].balance).toBe(251495.41)
expect(result.transactions[3].balance).toBe(25000)
})
it('calculates income vs expenses correctly with Unicode minus amounts', () => {
const result = parseBankFile(NORTHMILL_CSV, 'Northmill.csv')
expect(result.stats.total_income).toBe(425000)
expect(result.stats.total_expenses).toBe(-1139)
expect(result.stats.parsed_rows).toBe(4)
expect(result.stats.skipped_rows).toBe(0)
})
it('extracts the correct date range from the transactions, not the Period metadata row', () => {
const result = parseBankFile(NORTHMILL_CSV, 'Northmill.csv')
expect(result.date_from).toBe('2025-10-01')
expect(result.date_to).toBe('2026-04-01')
})
})
describe('parseBankFile — camt.053 XML format', () => {
it('parses XML with credit and debit entries', () => {
const result = parseBankFile(CAMT053_XML, 'statement.xml')
@@ -1716,6 +1798,80 @@ describe('parseGenericCSV — column bounds checking', () => {
expect(result.stats.skipped_rows).toBe(0)
expect(result.issues).toHaveLength(0)
})
it('parses amounts that use Unicode minus (U+2212) — was NaN before normalization', () => {
const content = [
'Date,Description,Amount',
'2024-01-15,SPOTIFY,"\u221299,00"',
'2024-01-16,REFUND,"\u201350,00"',
'2024-01-17,SALARY,"25000,00"',
].join('\n')
const result = parseGenericCSV(content, {
date: 0,
description: 1,
amount: 2,
delimiter: ',',
decimal_separator: ',',
skip_rows: 1,
date_format: 'YYYY-MM-DD',
})
expect(result.transactions).toHaveLength(3)
expect(result.transactions[0].amount).toBe(-99)
expect(result.transactions[1].amount).toBe(-50)
expect(result.transactions[2].amount).toBe(25000)
})
it('skips metadata rows when skip_rows is set higher than 1 (multi-row preamble)', () => {
// Mimics a file with 5 metadata rows + header + 2 transactions.
const content = [
'Account,12345',
'Owner,Acme AB',
'Period,2024-01,2024-12',
'Currency,SEK',
'Type,Statement',
'Date,Description,Amount',
'2024-01-15,SPOTIFY,-99.00',
'2024-01-16,SALARY,25000.00',
].join('\n')
const result = parseGenericCSV(content, {
date: 0,
description: 1,
amount: 2,
delimiter: ',',
decimal_separator: '.',
skip_rows: 6,
date_format: 'YYYY-MM-DD',
})
expect(result.transactions).toHaveLength(2)
expect(result.transactions[0].amount).toBe(-99)
expect(result.transactions[1].amount).toBe(25000)
expect(result.issues).toHaveLength(0)
})
})
describe('normalizeMinusSign', () => {
it('replaces U+2212 (minus sign) with ASCII hyphen', () => {
expect(normalizeMinusSign('\u2212139,00')).toBe('-139,00')
})
it('replaces U+2013 (en dash) and U+2014 (em dash) with ASCII hyphen', () => {
expect(normalizeMinusSign('\u2013100')).toBe('-100')
expect(normalizeMinusSign('\u2014250')).toBe('-250')
})
it('leaves ASCII hyphen and digits untouched', () => {
expect(normalizeMinusSign('-139.00')).toBe('-139.00')
expect(normalizeMinusSign('139.00')).toBe('139.00')
})
it('makes parseFloat work on Unicode-minus strings (regression for Northmill)', () => {
expect(parseFloat('\u2212139.00')).toBeNaN()
expect(parseFloat(normalizeMinusSign('\u2212139.00'))).toBe(-139)
})
})
// --- Fix 8: parseCSVLine unclosed quote handling ---
+18 -5
View File
@@ -10,6 +10,15 @@ import { prepareContent } from '../../shared/encoding'
import { parseCSVLine } from './nordea'
import { normalizeDate } from '../date-utils'
/**
* Normalize Unicode minus variants (U+2212 "−", U+2013 "–", U+2014 "—", U+2010 "‐")
* to ASCII hyphen-minus so parseFloat can read them. Northmill and some other
* banks export negatives with U+2212; parseFloat returns NaN for those.
*/
export function normalizeMinusSign(value: string): string {
return value.replace(/[\u2212\u2013\u2014\u2010]/g, '-')
}
/**
* Parse a generic CSV with user-provided column mapping
*/
@@ -89,12 +98,15 @@ export function parseGenericCSV(
continue
}
// Parse amount based on configured decimal separator
// Parse amount based on configured decimal separator.
// Normalize Unicode minus first — some banks (e.g. Northmill) use U+2212
// instead of ASCII hyphen, which parseFloat treats as NaN.
const normalizedAmount = normalizeMinusSign(amountStr)
let amount: number
if (mapping.decimal_separator === ',') {
amount = parseFloat(amountStr.replace(/\s/g, '').replace(',', '.'))
amount = parseFloat(normalizedAmount.replace(/\s/g, '').replace(',', '.'))
} else {
amount = parseFloat(amountStr.replace(/\s/g, ''))
amount = parseFloat(normalizedAmount.replace(/\s/g, ''))
}
if (isNaN(amount)) {
@@ -113,10 +125,11 @@ export function parseGenericCSV(
let balance: number | null = null
if (balanceStr) {
const normalizedBalance = normalizeMinusSign(balanceStr)
if (mapping.decimal_separator === ',') {
balance = parseFloat(balanceStr.replace(/\s/g, '').replace(',', '.'))
balance = parseFloat(normalizedBalance.replace(/\s/g, '').replace(',', '.'))
} else {
balance = parseFloat(balanceStr.replace(/\s/g, ''))
balance = parseFloat(normalizedBalance.replace(/\s/g, ''))
}
if (isNaN(balance)) balance = null
}
+165
View File
@@ -0,0 +1,165 @@
/**
* Northmill CSV format parser
*
* Format: Comma-delimited, comma decimal separator (amounts are quoted)
* Preamble: 5 metadata rows (Kontonummer, Saldo, Kontohavare, Org. Nr, Period)
* followed by blank lines before the transaction header.
* Header: Bokföringsdag,Beskrivning,Belopp,Saldo,Valuta
* Date format: YYYY-MM-DD
* Encoding: UTF-8
*
* Notes:
* - Negative amounts use Unicode minus sign (U+2212 "−"), not ASCII hyphen.
* The parser normalizes the minus before calling parseFloat.
*/
import type { BankFileFormat, BankFileParseResult, ParsedBankTransaction, BankFileParseIssue } from '../types'
import { prepareContent } from '../../shared/encoding'
import { normalizeDate } from '../date-utils'
import { parseCSVLine } from './nordea'
import { normalizeMinusSign } from './generic-csv'
const HEADER_KEYWORDS = ['bokföringsdag', 'beskrivning', 'belopp']
function isNorthmillHeader(line: string): boolean {
const lower = line.toLowerCase()
if (lower.includes(';')) return false
return HEADER_KEYWORDS.every((kw) => lower.includes(kw))
}
function parseAmount(value: string): number {
// Northmill: "−139,00" (Unicode minus) or "400000,00" (no thousand separator on positives)
const cleaned = normalizeMinusSign(value).replace(/\s/g, '').replace(',', '.')
return parseFloat(cleaned)
}
export const northmillFormat: BankFileFormat = {
id: 'northmill',
name: 'Northmill',
description: 'Northmill kontoutdrag (CSV med metadata-rader och Unicode-minus)',
fileExtensions: ['.csv', '.txt'],
detect(content: string, _filename: string): boolean {
const prepared = prepareContent(content)
const lines = prepared.split('\n')
// Northmill preamble's first non-empty line is "Kontonummer,<account>"
const firstNonEmpty = lines.find((l) => l.trim() !== '') || ''
if (!/^kontonummer\s*,/i.test(firstNonEmpty.trim())) return false
// Also look for the transaction header within the first ~20 lines to be sure
return lines.slice(0, 20).some(isNorthmillHeader)
},
parse(content: string): BankFileParseResult {
const prepared = prepareContent(content)
const rawLines = prepared.split('\n')
const transactions: ParsedBankTransaction[] = []
const issues: BankFileParseIssue[] = []
let skippedRows = 0
const headerIdx = rawLines.findIndex(isNorthmillHeader)
if (headerIdx === -1) {
return {
format: 'northmill',
format_name: 'Northmill',
transactions: [],
date_from: null,
date_to: null,
issues: [{ row: 0, message: 'Kunde inte hitta rubrikraden (Bokföringsdag, Beskrivning, Belopp).', severity: 'error' }],
stats: { total_rows: 0, parsed_rows: 0, skipped_rows: 0, total_income: 0, total_expenses: 0 },
}
}
const headers = parseCSVLine(rawLines[headerIdx], ',').map((h) => h.trim().toLowerCase().replace(/"/g, ''))
const dateIdx = headers.findIndex((h) => h.includes('bokföringsdag') || h.includes('bokforingsdag'))
const descIdx = headers.findIndex((h) => h.includes('beskrivning'))
const amountIdx = headers.findIndex((h) => h.includes('belopp'))
const balanceIdx = headers.findIndex((h) => h.includes('saldo'))
if (dateIdx === -1 || descIdx === -1 || amountIdx === -1) {
issues.push({
row: headerIdx + 1,
message: 'Rubrikraden saknar nödvändiga kolumner (Bokföringsdag, Beskrivning, Belopp).',
severity: 'error',
})
return {
format: 'northmill',
format_name: 'Northmill',
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 = headerIdx + 1; i < rawLines.length; i++) {
const line = rawLines[i].trim()
if (!line) continue
const fields = parseCSVLine(line, ',').map((f) => f.trim().replace(/^"|"$/g, ''))
const dateStr = fields[dateIdx]
const description = fields[descIdx] || 'Unknown'
const amountStr = fields[amountIdx]
const balanceStr = balanceIdx >= 0 ? fields[balanceIdx] : undefined
if (!dateStr || !amountStr) {
const missing: string[] = []
if (!dateStr) missing.push('datum')
if (!amountStr) missing.push('belopp')
issues.push({ row: i + 1, message: `Saknar ${missing.join(' och ')}`, severity: 'warning' })
skippedRows++
continue
}
const date = normalizeDate(dateStr)
if (!date) {
issues.push({ row: i + 1, message: `Ogiltigt datumformat: ${dateStr}`, severity: 'warning' })
skippedRows++
continue
}
const amount = parseAmount(amountStr)
if (isNaN(amount)) {
issues.push({ row: i + 1, message: `Ogiltigt belopp: ${amountStr}`, severity: 'warning' })
skippedRows++
continue
}
let balance: number | null = null
if (balanceStr) {
const b = parseAmount(balanceStr)
balance = isNaN(b) ? null : b
}
transactions.push({
date,
description: description.trim(),
amount,
currency: 'SEK',
balance,
reference: null,
counterparty: null,
raw_line: line,
})
}
const dates = transactions.map((t) => t.date).sort()
return {
format: 'northmill',
format_name: 'Northmill',
transactions,
date_from: dates[0] || null,
date_to: dates[dates.length - 1] || null,
issues,
stats: {
total_rows: rawLines.length - headerIdx - 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,
},
}
},
}
+2
View File
@@ -16,6 +16,7 @@ import { lansforsakringarFormat } from './formats/lansforsakringar'
import { icaBankenFormat } from './formats/ica-banken'
import { skandiaFormat } from './formats/skandia'
import { lunarFormat } from './formats/lunar'
import { northmillFormat } from './formats/northmill'
import { camt053Format } from './formats/camt053'
import { genericCSVFormat } from './formats/generic-csv'
@@ -36,6 +37,7 @@ const FORMATS: BankFileFormat[] = [
icaBankenFormat,
skandiaFormat,
lunarFormat,
northmillFormat,
genericCSVFormat,
]
+1
View File
@@ -52,6 +52,7 @@ export type BankFileFormatId =
| 'ica_banken'
| 'skandia'
| 'lunar'
| 'northmill'
| 'generic_csv'
| 'camt053'
+8
View File
@@ -263,6 +263,14 @@ export async function ingestTransactions(
if (insertError || !newTransaction) {
result.errors++
if (!result.first_error && insertError) {
result.first_error = {
message: insertError.message,
code: insertError.code ?? null,
details: insertError.details ?? null,
hint: insertError.hint ?? null,
}
}
continue
}