fix: Nordea Business CSV variants, API keys UI polish, transaction categorization (#183)
* fix: MCP OAuth 303 redirect, send dialog auto-close, bank details null payload - OAuth authorize: use 303 See Other instead of default 307, which preserved POST method and caused Claude's callback to return 405 - SendInvoiceDialog: close dialog and show toast after email send instead of leaving a success message that requires manual close - BankDetailsSetupDialog: omit empty fields from payload instead of sending null, which fails Zod validation on non-nullable schema fields Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: remove dead sentMessage state and fix stale comment Remove sentMessage state, its success banner JSX, and the CheckCircle2 import — all unreachable after the dialog now auto-closes on email send. Fix stale "to null" comment in BankDetailsSetupDialog. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: support Nordea Business CSV variants, polish API keys UI, fix transaction categorization - Extend Nordea Business bank file parser to handle three CSV export formats (classic, Betalare/Mottagare variant, Bokföringsdatum variant) with proper detection guards against SEB/LF misidentification - Rework ApiKeysPanel: add CopyBlock component, destructive confirm on revoke, collapsible API-key-based connection methods, Claude.ai OAuth instructions as recommended path, simplified scope badges - Stop deriving is_business from category on manual transaction creation; set null so categorization flow handles it correctly - Show categorize button when journal_entry_id is missing regardless of is_business value Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address PR review — await clipboard, fix zero-scope label, simplify condition - Await navigator.clipboard.writeText and catch failures - Change zero-scope label from "Enbart läs" to "Inga behörigheter" - Simplify redundant ternary condition in TransactionHistoryList Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: repair broken ternary in TransactionHistoryList JSX Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
e42da5c32b
commit
a25e75be25
@@ -189,6 +189,20 @@ const NORDEA_BUSINESS_CSV_SWEDISH_CHARS = [
|
||||
|
||||
const HEADER_ONLY_NORDEA_BUSINESS = 'Bokföringsdag;Belopp;Avsändare;Mottagare;Namn;Rubrik;Saldo;Valuta\n'
|
||||
|
||||
const NORDEA_BUSINESS_CSV_VARIANT_A = [
|
||||
'Bokföringsdag;Värdedag;Betalningstyp;Betalare/Mottagare;Meddelande/Referens;Belopp;Saldo',
|
||||
'2024-01-15;2024-01-15;Kortbetalning;SPOTIFY AB;Spotify Premium;-99,00;12 345,67',
|
||||
'2024-01-14;2024-01-14;Kortbetalning;ICA MAXI;Dagligvaror;-432,50;12 444,67',
|
||||
'2024-01-13;2024-01-13;Inbetalning;ARBETSGIVAREN AB;Lön jan;25 000,00;12 877,17',
|
||||
].join('\n')
|
||||
|
||||
const NORDEA_BUSINESS_CSV_VARIANT_B = [
|
||||
'Bokföringsdatum;Valutadatum;Text;Belopp;Saldo',
|
||||
'2024-01-15;2024-01-15;SPOTIFY AB;-99,00;12 345,67',
|
||||
'2024-01-14;2024-01-14;ICA MAXI LINDHAGEN;-432,50;12 444,67',
|
||||
'2024-01-13;2024-01-13;LÖNEUTBETALNING;25 000,00;12 877,17',
|
||||
].join('\n')
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -212,6 +226,25 @@ describe('detectFileFormat', () => {
|
||||
expect(format!.id).toBe('nordea_business')
|
||||
})
|
||||
|
||||
it('detects Nordea Business CSV variant with Betalare/Mottagare combined column', () => {
|
||||
const format = detectFileFormat(NORDEA_BUSINESS_CSV_VARIANT_A, 'nordea_ftg.csv')
|
||||
expect(format).not.toBeNull()
|
||||
expect(format!.id).toBe('nordea_business')
|
||||
})
|
||||
|
||||
it('detects Nordea Business CSV variant with Bokföringsdatum header', () => {
|
||||
const format = detectFileFormat(NORDEA_BUSINESS_CSV_VARIANT_B, 'nordea_ftg.csv')
|
||||
expect(format).not.toBeNull()
|
||||
expect(format!.id).toBe('nordea_business')
|
||||
})
|
||||
|
||||
it('does not misidentify SEB as Nordea Business when valutadag is present', () => {
|
||||
const sebLike = 'Bokföringsdag;Valutadag;Verifikationsnummer;Text;Belopp;Saldo\n2024-01-15;2024-01-15;123;SPOTIFY;-99,00;12345,67'
|
||||
const format = detectFileFormat(sebLike, 'export.csv')
|
||||
expect(format).not.toBeNull()
|
||||
expect(format!.id).toBe('seb')
|
||||
})
|
||||
|
||||
it('detects SEB CSV from semicolon-delimited header with bokföringsdag', () => {
|
||||
const format = detectFileFormat(SEB_CSV, 'kontoutdrag.csv')
|
||||
expect(format).not.toBeNull()
|
||||
@@ -498,6 +531,71 @@ describe('parseBankFile — Nordea Business format', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseBankFile — Nordea Business variant A (Betalare/Mottagare)', () => {
|
||||
it('parses the alternate Nordea Business format with combined party column', () => {
|
||||
const result = parseBankFile(NORDEA_BUSINESS_CSV_VARIANT_A, 'nordea_ftg.csv')
|
||||
|
||||
expect(result.format).toBe('nordea_business')
|
||||
expect(result.transactions).toHaveLength(3)
|
||||
expect(result.issues).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('builds description from Betalningstyp and Meddelande/Referens', () => {
|
||||
const result = parseBankFile(NORDEA_BUSINESS_CSV_VARIANT_A, 'nordea_ftg.csv')
|
||||
|
||||
expect(result.transactions[0].description).toBe('Kortbetalning — Spotify Premium')
|
||||
expect(result.transactions[2].description).toBe('Inbetalning — Lön jan')
|
||||
})
|
||||
|
||||
it('extracts counterparty from combined Betalare/Mottagare column', () => {
|
||||
const result = parseBankFile(NORDEA_BUSINESS_CSV_VARIANT_A, 'nordea_ftg.csv')
|
||||
|
||||
expect(result.transactions[0].counterparty).toBe('SPOTIFY AB')
|
||||
expect(result.transactions[2].counterparty).toBe('ARBETSGIVAREN AB')
|
||||
})
|
||||
|
||||
it('parses amounts and dates correctly', () => {
|
||||
const result = parseBankFile(NORDEA_BUSINESS_CSV_VARIANT_A, 'nordea_ftg.csv')
|
||||
|
||||
expect(result.transactions[0].amount).toBe(-99)
|
||||
expect(result.transactions[0].date).toBe('2024-01-15')
|
||||
expect(result.transactions[2].amount).toBe(25000)
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseBankFile — Nordea Business variant B (Bokföringsdatum)', () => {
|
||||
it('parses the simple Nordea Business format with Bokföringsdatum', () => {
|
||||
const result = parseBankFile(NORDEA_BUSINESS_CSV_VARIANT_B, 'nordea_ftg.csv')
|
||||
|
||||
expect(result.format).toBe('nordea_business')
|
||||
expect(result.transactions).toHaveLength(3)
|
||||
expect(result.issues).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('builds description from Text column', () => {
|
||||
const result = parseBankFile(NORDEA_BUSINESS_CSV_VARIANT_B, 'nordea_ftg.csv')
|
||||
|
||||
expect(result.transactions[0].description).toBe('SPOTIFY AB')
|
||||
expect(result.transactions[2].description).toBe('LÖNEUTBETALNING')
|
||||
})
|
||||
|
||||
it('parses amounts correctly', () => {
|
||||
const result = parseBankFile(NORDEA_BUSINESS_CSV_VARIANT_B, 'nordea_ftg.csv')
|
||||
|
||||
expect(result.transactions[0].amount).toBe(-99)
|
||||
expect(result.transactions[1].amount).toBe(-432.5)
|
||||
expect(result.transactions[2].amount).toBe(25000)
|
||||
})
|
||||
|
||||
it('calculates correct stats', () => {
|
||||
const result = parseBankFile(NORDEA_BUSINESS_CSV_VARIANT_B, 'nordea_ftg.csv')
|
||||
|
||||
expect(result.stats.total_income).toBe(25000)
|
||||
expect(result.stats.total_expenses).toBe(-531.5)
|
||||
expect(result.stats.parsed_rows).toBe(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseBankFile — SEB format', () => {
|
||||
it('parses semicolon-delimited CSV with comma decimal separator', () => {
|
||||
const result = parseBankFile(SEB_CSV, 'seb.csv')
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
/**
|
||||
* Nordea Business CSV format parser
|
||||
*
|
||||
* Format: Semicolon-delimited, comma decimal separator
|
||||
* Columns: Bokföringsdag, Belopp, Avsändare, Mottagare, Namn, Rubrik, Saldo, Valuta
|
||||
* Supports multiple Nordea Business / Internetbanken Företag export formats:
|
||||
*
|
||||
* Format A (classic): Semicolon-delimited, comma decimal separator
|
||||
* Columns: Bokföringsdag, Belopp, Avsändare, Mottagare, Namn, Rubrik, Saldo, Valuta
|
||||
*
|
||||
* Format B (alternate): Semicolon-delimited
|
||||
* Columns: Bokföringsdag, Värdedag, Betalningstyp, Betalare/Mottagare, Meddelande/Referens, Belopp, Saldo
|
||||
*
|
||||
* Format C (simple): Semicolon-delimited
|
||||
* Columns: Bokföringsdatum, Valutadatum, Text, Belopp, Saldo
|
||||
*
|
||||
* Date format: YYYY-MM-DD
|
||||
* Encoding: UTF-8 or Windows-1252
|
||||
*
|
||||
* This is the format used by Nordea Business / Internetbanken Företag
|
||||
* (netbank.nordea.se), including Plusgiro and corporate accounts.
|
||||
* It differs from the personal banking format which is comma-delimited.
|
||||
*/
|
||||
|
||||
import type { BankFileFormat, BankFileParseResult, ParsedBankTransaction, BankFileParseIssue } from '../types'
|
||||
@@ -22,18 +27,46 @@ function parseCommaDecimal(value: string): number {
|
||||
export const nordeaBusinessFormat: BankFileFormat = {
|
||||
id: 'nordea_business',
|
||||
name: 'Nordea Företag',
|
||||
description: 'Nordea Företag CSV (Bokföringsdag;Belopp;Avsändare;Mottagare;Namn;Rubrik;Saldo;Valuta)',
|
||||
description: 'Nordea Företag CSV (semicolon-delimited business banking export)',
|
||||
fileExtensions: ['.csv', '.txt'],
|
||||
|
||||
detect(content: string, _filename: string): boolean {
|
||||
const prepared = prepareContent(content)
|
||||
const firstLine = prepared.split('\n')[0]?.toLowerCase() || ''
|
||||
// Nordea Business: semicolon-delimited with "bokföringsdag" and "rubrik"
|
||||
// "rubrik" distinguishes from SEB (which has "valutadag"/"verifikationsnummer")
|
||||
|
||||
if (!firstLine.includes(';')) return false
|
||||
|
||||
// Must have a date column that looks like Nordea Business
|
||||
const hasNordeaDateCol =
|
||||
firstLine.includes('bokföringsdag') ||
|
||||
firstLine.includes('bokforingsdag') ||
|
||||
firstLine.includes('bokföringsdatum') ||
|
||||
firstLine.includes('bokforingsdatum')
|
||||
|
||||
if (!hasNordeaDateCol) return false
|
||||
|
||||
// Exclude SEB (which also has bokföringsdag/bokföringsdatum but adds valutadag/verifikationsnummer)
|
||||
if (firstLine.includes('valutadag') || firstLine.includes('verifikationsnummer')) return false
|
||||
|
||||
// Exclude Länsförsäkringar (has separate "datum" column alongside "bokföringsdag" + "typ")
|
||||
// LF headers are quoted: "Datum";"Bokföringsdag";"Typ";"Text";"Belopp";"Saldo"
|
||||
const headers = firstLine.split(';').map(h => h.replace(/"/g, '').trim())
|
||||
const hasSeparateDatum = headers.some(h => h === 'datum')
|
||||
if (hasSeparateDatum && headers.some(h => h === 'typ')) return false
|
||||
|
||||
// Accept any of these Nordea Business patterns:
|
||||
return (
|
||||
firstLine.includes(';') &&
|
||||
(firstLine.includes('bokföringsdag') || firstLine.includes('bokforingsdag')) &&
|
||||
(firstLine.includes('rubrik') || (firstLine.includes('avsändare') && firstLine.includes('mottagare')))
|
||||
// Pattern 1: "rubrik" column (classic format)
|
||||
firstLine.includes('rubrik') ||
|
||||
// Pattern 2: separate "avsändare" + "mottagare" columns
|
||||
(firstLine.includes('avsändare') && firstLine.includes('mottagare')) ||
|
||||
(firstLine.includes('avsandare') && firstLine.includes('mottagare')) ||
|
||||
// Pattern 3: "betalare" (e.g., combined "Betalare/Mottagare" column)
|
||||
firstLine.includes('betalare') ||
|
||||
// Pattern 4: "betalningstyp" column (Nordea business payment type indicator)
|
||||
firstLine.includes('betalningstyp') ||
|
||||
// Pattern 5: simple format with "text" + "belopp" (for Bokföringsdatum;...;Text;Belopp;Saldo)
|
||||
(firstLine.includes('text') && firstLine.includes('belopp'))
|
||||
)
|
||||
},
|
||||
|
||||
@@ -49,21 +82,35 @@ export const nordeaBusinessFormat: BankFileFormat = {
|
||||
const headerLine = lines[0] || ''
|
||||
const headers = headerLine.split(';').map((h) => h.trim().toLowerCase().replace(/"/g, ''))
|
||||
|
||||
// Date column: accept multiple Nordea naming patterns
|
||||
const dateIdx = headers.findIndex(
|
||||
(h) => h.includes('bokföringsdag') || h.includes('bokforingsdag')
|
||||
(h) => h.includes('bokföringsdag') || h.includes('bokforingsdag') ||
|
||||
h.includes('bokföringsdatum') || h.includes('bokforingsdatum')
|
||||
)
|
||||
const amountIdx = headers.findIndex((h) => h === 'belopp' || h.includes('belopp'))
|
||||
const senderIdx = headers.findIndex((h) => h.includes('avsändare') || h.includes('avsandare'))
|
||||
const receiverIdx = headers.findIndex((h) => h.includes('mottagare'))
|
||||
// Receiver: standalone "mottagare" (not combined "betalare/mottagare")
|
||||
const receiverIdx = headers.findIndex(
|
||||
(h) => h.includes('mottagare') && !h.includes('betalare') && !h.includes('/')
|
||||
)
|
||||
// Combined "Betalare/Mottagare" column
|
||||
const combinedPartyIdx = headers.findIndex(
|
||||
(h) => (h.includes('betalare') && h.includes('mottagare')) || h === 'betalare/mottagare'
|
||||
)
|
||||
const nameIdx = headers.findIndex((h) => h === 'namn')
|
||||
const subjectIdx = headers.findIndex((h) => h === 'rubrik')
|
||||
// Description fallbacks: "text", "meddelande", "meddelande/referens", "beskrivning"
|
||||
const textIdx = headers.findIndex(
|
||||
(h) => h === 'text' || h.includes('meddelande') || h.includes('beskrivning')
|
||||
)
|
||||
const paymentTypeIdx = headers.findIndex((h) => h.includes('betalningstyp'))
|
||||
const balanceIdx = headers.findIndex((h) => h === 'saldo' || h.includes('saldo'))
|
||||
const currencyIdx = headers.findIndex((h) => h === 'valuta' || h.includes('valuta'))
|
||||
|
||||
if (dateIdx === -1 || amountIdx === -1) {
|
||||
issues.push({
|
||||
row: 1,
|
||||
message: 'Could not identify required columns (Bokföringsdag, Belopp)',
|
||||
message: 'Could not identify required columns (Bokföringsdag/Bokföringsdatum, Belopp)',
|
||||
severity: 'error',
|
||||
})
|
||||
return {
|
||||
@@ -106,15 +153,32 @@ export const nordeaBusinessFormat: BankFileFormat = {
|
||||
continue
|
||||
}
|
||||
|
||||
// Build description from Namn + Rubrik (name is the counterparty, rubrik is the subject/memo)
|
||||
// Build description from available columns with fallback chain
|
||||
const name = nameIdx >= 0 ? fields[nameIdx]?.trim() : ''
|
||||
const subject = subjectIdx >= 0 ? fields[subjectIdx]?.trim() : ''
|
||||
const description = [name, subject].filter(Boolean).join(' — ') || 'Unknown'
|
||||
const text = textIdx >= 0 ? fields[textIdx]?.trim() : ''
|
||||
const paymentType = paymentTypeIdx >= 0 ? fields[paymentTypeIdx]?.trim() : ''
|
||||
|
||||
// Counterparty from Avsändare (incoming) or Mottagare (outgoing)
|
||||
const sender = senderIdx >= 0 ? fields[senderIdx]?.trim() : null
|
||||
const receiver = receiverIdx >= 0 ? fields[receiverIdx]?.trim() : null
|
||||
const counterparty = (amount > 0 ? sender : receiver) || null
|
||||
let description: string
|
||||
if (name || subject) {
|
||||
// Classic format: Namn — Rubrik
|
||||
description = [name, subject].filter(Boolean).join(' — ') || 'Unknown'
|
||||
} else if (text) {
|
||||
// Alternate format: use Text/Meddelande column
|
||||
description = [paymentType, text].filter(Boolean).join(' — ') || text
|
||||
} else {
|
||||
description = 'Unknown'
|
||||
}
|
||||
|
||||
// Counterparty from sender/receiver or combined column
|
||||
let counterparty: string | null = null
|
||||
if (combinedPartyIdx >= 0) {
|
||||
counterparty = fields[combinedPartyIdx]?.trim() || null
|
||||
} else {
|
||||
const sender = senderIdx >= 0 ? fields[senderIdx]?.trim() : null
|
||||
const receiver = receiverIdx >= 0 ? fields[receiverIdx]?.trim() : null
|
||||
counterparty = (amount > 0 ? sender : receiver) || null
|
||||
}
|
||||
|
||||
const balance = balanceIdx >= 0 && fields[balanceIdx] ? parseCommaDecimal(fields[balanceIdx]) : null
|
||||
const currency = currencyIdx >= 0 && fields[currencyIdx] ? fields[currencyIdx].trim() : 'SEK'
|
||||
|
||||
Reference in New Issue
Block a user