Mcp/template data feedback (#617)

* fix(booking-templates): scope template list to the active company

GET /api/settings/booking-templates relied solely on the btl_select RLS
policy, which is membership-wide (user_company_ids) and returns templates
from every company the user belongs to. A user who owns multiple companies
saw all their templates merged regardless of which company was active.

Narrow the list in the API layer (mirroring counterparty-templates) to
system + the active company + the active company's team. RLS stays the
security backstop; this fixes the cross-company merge within a single
user's own view (it was never a cross-tenant data leak).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(import): show proper message for duplicate bank file upload

The bank file import page mis-parsed the structured error envelope
({ error: { code, message, details } }), so a BANK_FILE_DUPLICATE
(409) fell through to the generic "Kunde inte läsa filen" fallback.
The upload step also hardcoded that same string as the error heading,
so duplicates were doubly misreported as parse failures.

- Parse the structured envelope by error.code; surface error.message
  for all codes instead of rendering the error object.
- Add a dedicated BANK_FILE_DUPLICATE message using the importedAt /
  importedCount details the route already returns.
- Add an optional errorTitle prop to BankFileUploadStep (defaults to
  the previous text) and pass "Filen är redan importerad" for dupes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(tests): add comprehensive tests for recordateEntry, inbox-linking, and external-id handling

- Implemented unit tests for recordateEntry in the bookkeeping module to validate various scenarios including date changes, non-posted entries, and fiscal period restrictions.
- Created tests for inbox-linking status in pending operations to ensure correct handling of invoice inbox items and supplier invoices, addressing historical bugs related to status updates.
- Added tests for external-id utilities to ensure consistent handling of monetary amounts and deduplication keys across different transaction sources.
- Introduced new functions in external-id.ts for stable external ID generation and normalization of imported descriptions, enhancing transaction deduplication reliability.

feat(migrations): add new database migrations for transaction handling

- Created migration to exclude storno and correction vouchers from unmatched GL lines, ensuring accurate reconciliation.
- Added a migration to preserve original bank transaction descriptions in a new immutable column, allowing for user edits while maintaining audit trails and deduplication integrity.

* feat(migrations): add function to exclude storno/correction vouchers from unmatched GL lines

* feat(transactions): enhance transaction handling with improved description normalization and preloaded original entries

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-06-01 14:45:49 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 2c59c3633f
commit c6c86cded4
54 changed files with 3673 additions and 158 deletions
@@ -79,6 +79,34 @@ const HANDELSBANKEN_CSV_WITH_PREL = [
'2024-01-13;2024-01-13;LÖNEUTBETALNING;25000,00;12643,67',
].join('\n')
// Real Handelsbanken web exports can prepend account/period metadata rows
// (and a blank line) before the actual column header.
const HANDELSBANKEN_CSV_WITH_PREAMBLE = [
'Kontonummer;6789 123 456 789',
'Kontohavare;Wiklund, Cristel',
'Period;2024-01-01 - 2024-01-31',
'',
'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')
// Negative amounts exported with a Unicode minus (U+2212) instead of ASCII '-'.
const HANDELSBANKEN_CSV_UNICODE_MINUS = [
'Reskontradatum;Transaktionsdatum;Text;Belopp;Saldo',
'2024-01-15;2024-01-15;SPOTIFY AB;−139,00;12345,67',
'2024-01-14;2024-01-14;HEMKÖP;−1 432,50;12444,67',
'2024-01-13;2024-01-13;LÖNEUTBETALNING;25000,00;12877,17',
].join('\n')
// A quoted Text field that itself contains the semicolon delimiter.
const HANDELSBANKEN_CSV_QUOTED_SEMICOLON = [
'Reskontradatum;Transaktionsdatum;Text;Belopp;Saldo',
'2024-01-15;2024-01-15;"BETALNING; FAKTURA 100";-99,00;12345,67',
'2024-01-14;2024-01-14;HEMKÖP;-432,50;12444,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>
@@ -877,6 +905,47 @@ describe('parseBankFile — Handelsbanken format', () => {
const result = parseBankFile(diffDates, 'shb.csv')
expect(result.transactions[0].date).toBe('2024-01-15')
})
it('detects Handelsbanken CSV when a metadata preamble precedes the header', () => {
const format = detectFileFormat(HANDELSBANKEN_CSV_WITH_PREAMBLE, 'kontoutdrag.csv')
expect(format).not.toBeNull()
expect(format!.id).toBe('handelsbanken')
})
it('skips the metadata preamble rows and parses the transactions', () => {
const result = parseBankFile(HANDELSBANKEN_CSV_WITH_PREAMBLE, 'kontoutdrag.csv')
expect(result.format).toBe('handelsbanken')
expect(result.transactions).toHaveLength(3)
expect(result.issues).toHaveLength(0)
expect(result.stats.skipped_rows).toBe(0)
const descriptions = result.transactions.map((t) => t.description)
expect(descriptions).not.toContain('Kontonummer')
expect(result.transactions[0].description).toBe('SPOTIFY AB')
expect(result.transactions[2].amount).toBe(25000)
})
it('parses negative amounts that use a Unicode minus (U+2212) instead of dropping them', () => {
const result = parseBankFile(HANDELSBANKEN_CSV_UNICODE_MINUS, 'shb.csv')
expect(result.transactions).toHaveLength(3)
expect(result.issues).toHaveLength(0)
expect(result.stats.skipped_rows).toBe(0)
expect(result.transactions[0].amount).toBe(-139)
expect(result.transactions[1].amount).toBe(-1432.5)
expect(result.transactions[2].amount).toBe(25000)
})
it('handles a quoted Text field that contains the semicolon delimiter', () => {
const result = parseBankFile(HANDELSBANKEN_CSV_QUOTED_SEMICOLON, 'shb.csv')
expect(result.transactions).toHaveLength(2)
expect(result.issues).toHaveLength(0)
expect(result.transactions[0].description).toBe('BETALNING; FAKTURA 100')
expect(result.transactions[0].amount).toBe(-99)
expect(result.transactions[1].amount).toBe(-432.5)
})
})
describe('parseBankFile — Länsförsäkringar format', () => {
+70 -30
View File
@@ -7,18 +7,45 @@
* Encoding: UTF-8 or Windows-1252
*
* Notes:
* - Filter rows with "Prel" prefix (preliminary/pending transactions)
* - Real Handelsbanken exports may prepend metadata rows (account number, period,
* balance) before the column header, so we scan the first lines for the header
* rather than assuming it is line 0.
* - Fields may be double-quoted and a quoted "Text" field can itself contain a
* semicolon, so we use the quote-aware parseCSVLine rather than split(';').
* - Negative amounts may use a Unicode minus (U+2212) or dash; normalizeMinusSign
* maps those to ASCII '-' so parseFloat does not return NaN.
* - Filter rows with "Prel" prefix (preliminary/pending transactions).
*/
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'
// How many leading lines to scan for the header (allows for a metadata preamble)
const HEADER_SCAN_LIMIT = 15
function parseCommaDecimal(value: string): number {
const cleaned = value.replace(/\s/g, '').replace(',', '.')
// Swedish format "1 234,56" / "-1 234,56", tolerating Unicode minus on negatives
const cleaned = normalizeMinusSign(value).replace(/\s/g, '').replace(',', '.')
return parseFloat(cleaned)
}
/**
* Check if a line is the Handelsbanken transaction header: semicolon-delimited
* and carrying a Handelsbanken date column plus the amount column. Specific
* enough not to steal SEB / Nordea Företag / ICA / Skandia files, which use
* different date labels (bokföringsdag, valutadag, datum).
*/
function isHandelsbankenHeader(line: string): boolean {
const lower = line.toLowerCase()
if (!lower.includes(';')) return false
const hasDate = lower.includes('reskontradatum') || lower.includes('transaktionsdatum')
const hasAmount = lower.includes('belopp')
return hasDate && hasAmount
}
export const handelsbankenFormat: BankFileFormat = {
id: 'handelsbanken',
name: 'Handelsbanken',
@@ -27,11 +54,8 @@ export const handelsbankenFormat: BankFileFormat = {
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'))
)
const lines = prepared.split('\n')
return lines.slice(0, HEADER_SCAN_LIMIT).some(isHandelsbankenHeader)
},
parse(content: string): BankFileParseResult {
@@ -42,43 +66,59 @@ export const handelsbankenFormat: BankFileFormat = {
const issues: BankFileParseIssue[] = []
let skippedRows = 0
// Parse header
const headerLine = lines[0] || ''
const headers = headerLine.split(';').map((h) => h.trim().toLowerCase().replace(/"/g, ''))
const emptyResult = (issue: BankFileParseIssue): BankFileParseResult => ({
format: 'handelsbanken',
format_name: 'Handelsbanken',
transactions: [],
date_from: null,
date_to: null,
issues: [issue],
stats: { total_rows: 0, parsed_rows: 0, skipped_rows: 0, total_income: 0, total_expenses: 0 },
})
const dateIdx = headers.findIndex(
(h) => h.includes('reskontradatum') || h.includes('transaktionsdatum')
// Find the header row, skipping any metadata preamble.
let headerLineIdx = -1
for (let i = 0; i < Math.min(lines.length, HEADER_SCAN_LIMIT); i++) {
if (isHandelsbankenHeader(lines[i])) {
headerLineIdx = i
break
}
}
if (headerLineIdx === -1) {
return emptyResult({
row: 1,
message: 'Kunde inte hitta rubrikraden (Transaktionsdatum/Reskontradatum, Belopp).',
severity: 'error',
})
}
const headers = parseCSVLine(lines[headerLineIdx], ';').map((h) =>
h.trim().toLowerCase().replace(/"/g, '')
)
const reskontraIdx = headers.findIndex((h) => h.includes('reskontradatum'))
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,
// Prefer transaktionsdatum (real transaction date) over reskontradatum (booking date)
const primaryDateIdx = txDateIdx >= 0 ? txDateIdx : reskontraIdx
if (primaryDateIdx === -1 || amountIdx === -1) {
return emptyResult({
row: headerLineIdx + 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++) {
for (let i = headerLineIdx + 1; i < lines.length; i++) {
const line = lines[i].trim()
if (!line) continue
const fields = line.split(';').map((f) => f.trim().replace(/^"|"$/g, ''))
const fields = parseCSVLine(line, ';').map((f) => f.trim().replace(/^"|"$/g, ''))
const date = fields[primaryDateIdx]
const description = descIdx >= 0 ? fields[descIdx] : 'Unknown'
@@ -138,7 +178,7 @@ export const handelsbankenFormat: BankFileFormat = {
date_to: dates[dates.length - 1] || null,
issues,
stats: {
total_rows: lines.length - 1,
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,