Files
accounted/lib/import/bank-file/formats/northmill.ts
T
Mattsson 566ed72984 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
2026-05-20 10:08:32 +02:00

166 lines
5.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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,
},
}
},
}