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:
Jakob Wennberg
2026-04-07 11:11:47 +02:00
committed by GitHub
co-authored by Claude Opus 4.6
parent e42da5c32b
commit a25e75be25
6 changed files with 325 additions and 100 deletions
+1 -1
View File
@@ -488,7 +488,7 @@ export default function TransactionsPage() {
amount: data.amount,
currency: data.currency,
category: data.category || 'uncategorized',
is_business: data.is_business,
is_business: null,
notes: data.notes,
})
.select()
+137 -72
View File
@@ -5,6 +5,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Badge } from '@/components/ui/badge'
import {
Dialog,
DialogContent,
@@ -14,8 +15,9 @@ import {
DialogTitle,
} from '@/components/ui/dialog'
import { Checkbox } from '@/components/ui/checkbox'
import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui/destructive-confirm-dialog'
import { useToast } from '@/components/ui/use-toast'
import { Loader2, Plus, Copy, Check, Trash2, Key } from 'lucide-react'
import { Loader2, Plus, Copy, Check, Trash2, Key, ChevronDown } from 'lucide-react'
const SCOPE_GROUPS = [
{
@@ -104,19 +106,55 @@ interface ApiKey {
created_at: string
}
function CopyBlock({ text }: { text: string }) {
const [copied, setCopied] = useState(false)
async function handleCopy() {
try {
await navigator.clipboard.writeText(text)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch {
// clipboard unavailable (insecure context) — silently ignore
}
}
return (
<div className="relative group">
<pre className="rounded-md bg-muted p-4 pr-12 text-xs font-mono overflow-x-auto whitespace-pre-wrap break-all">
{text}
</pre>
<Button
variant="ghost"
size="sm"
className="absolute right-1.5 top-1.5 h-7 w-7 p-0 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={handleCopy}
aria-label="Kopiera"
>
{copied ? (
<Check className="h-3.5 w-3.5 text-green-600" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
</Button>
</div>
)
}
export function ApiKeysPanel() {
const { toast } = useToast()
const { dialogProps: revokeDialogProps, confirm: confirmRevoke } = useDestructiveConfirm()
const [keys, setKeys] = useState<ApiKey[]>([])
const [isLoading, setIsLoading] = useState(true)
const [isCreating, setIsCreating] = useState(false)
const [showCreateDialog, setShowCreateDialog] = useState(false)
const [showKeyDialog, setShowKeyDialog] = useState(false)
const [showApiKeyMethods, setShowApiKeyMethods] = useState(false)
const [newKeyName, setNewKeyName] = useState('')
const [newKeyScopes, setNewKeyScopes] = useState<Set<Scope>>(new Set(ALL_SCOPES))
const [newKeyValue, setNewKeyValue] = useState('')
const [copied, setCopied] = useState(false)
const [revokingId, setRevokingId] = useState<string | null>(null)
const fetchKeys = useCallback(async () => {
try {
@@ -164,16 +202,20 @@ export function ApiKeysPanel() {
}
}
async function handleRevoke(id: string) {
setRevokingId(id)
async function handleRevoke(id: string, name: string) {
const ok = await confirmRevoke({
title: 'Återkalla API-nyckel',
description: `"${name}" återkallas permanent. Alla klienter som använder nyckeln slutar fungera omedelbart.`,
confirmLabel: 'Återkalla',
})
if (!ok) return
try {
await fetch(`/api/settings/api-keys/${id}`, { method: 'DELETE' })
setKeys((prev) => prev.filter((k) => k.id !== id))
toast({ title: 'Nyckel återkallad' })
} catch {
toast({ title: 'Fel', description: 'Kunde inte återkalla nyckel', variant: 'destructive' })
} finally {
setRevokingId(null)
}
}
@@ -232,59 +274,50 @@ export function ApiKeysPanel() {
</div>
) : (
<div className="space-y-3">
{keys.map((key) => (
<div
key={key.id}
className="flex items-center justify-between rounded-md border px-4 py-3"
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<p className="text-sm font-medium truncate">{key.name}</p>
<div className="flex items-center gap-1 flex-wrap">
{(key.scopes ?? []).map((s) => (
<span
key={s}
className="inline-flex items-center rounded-full border px-1.5 py-0.5 text-[10px] text-muted-foreground"
>
{SCOPE_LABELS[s as Scope] ?? s}
</span>
))}
{(!key.scopes || key.scopes.length === 0) && (
<span className="inline-flex items-center rounded-full border px-1.5 py-0.5 text-[10px] text-muted-foreground">
Enbart läs
</span>
)}
{keys.map((key) => {
const scopeCount = key.scopes?.length ?? 0
return (
<div
key={key.id}
className="flex items-center justify-between rounded-md border px-4 py-3"
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<p className="text-sm font-medium truncate">{key.name}</p>
<span className="text-xs text-muted-foreground">
{scopeCount === ALL_SCOPES.length
? 'Alla behörigheter'
: scopeCount === 0
? 'Inga behörigheter'
: `${scopeCount} behörigheter`}
</span>
</div>
<div className="flex items-center gap-3 mt-1">
<code className="text-xs text-muted-foreground font-mono">
{key.key_prefix}...
</code>
<span className="text-xs text-muted-foreground">
Skapad {formatDate(key.created_at)}
</span>
<span className="text-xs text-muted-foreground">
{key.last_used_at
? `Använd ${formatDate(key.last_used_at)}`
: 'Aldrig använd'}
</span>
</div>
</div>
<div className="flex items-center gap-3 mt-1">
<code className="text-xs text-muted-foreground font-mono">
{key.key_prefix}...
</code>
<span className="text-xs text-muted-foreground">
Skapad {formatDate(key.created_at)}
</span>
<span className="text-xs text-muted-foreground">
{key.last_used_at
? `Använd ${formatDate(key.last_used_at)}`
: 'Aldrig använd'}
</span>
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => handleRevoke(key.id)}
disabled={revokingId === key.id}
className="text-destructive hover:text-destructive"
>
{revokingId === key.id ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Button
variant="ghost"
size="sm"
onClick={() => handleRevoke(key.id, key.name)}
aria-label={`Återkalla ${key.name}`}
className="text-destructive hover:text-destructive"
>
<Trash2 className="h-3.5 w-3.5" />
)}
</Button>
</div>
))}
</Button>
</div>
)
})}
</div>
)}
</CardContent>
@@ -296,12 +329,42 @@ export function ApiKeysPanel() {
</CardHeader>
<CardContent className="space-y-6">
<div>
<p className="text-sm font-medium mb-1">Claude Desktop</p>
<div className="flex items-center gap-2 mb-2">
<p className="text-sm font-medium">Claude.ai</p>
<Badge variant="secondary" className="text-[10px] font-normal px-1.5 py-0">Rekommenderat</Badge>
</div>
<p className="text-xs text-muted-foreground mb-2">
Lägg till i <code className="text-xs">claude_desktop_config.json</code> (Inställningar &rarr; Developer):
Gå till <strong>Settings &rarr; Integrations &rarr; Add Integration</strong> och klistra in MCP-serverns URL.
Du loggas in via ditt gnubok-konto — ingen API-nyckel behövs.
</p>
<pre className="rounded-md bg-muted p-4 text-xs font-mono overflow-x-auto select-all">
{`{
<CopyBlock text={mcpUrl} />
</div>
<div>
<p className="text-sm font-medium mb-2">Claude Code / Cursor</p>
<p className="text-xs text-muted-foreground mb-2">
Kör i terminalen — loggar in via webbläsaren:
</p>
<CopyBlock text={`claude mcp add gnubok --transport http ${mcpUrl}`} />
</div>
<div className="border-t pt-4">
<button
type="button"
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
onClick={() => setShowApiKeyMethods(!showApiKeyMethods)}
>
<ChevronDown className={`h-3.5 w-3.5 transition-transform ${showApiKeyMethods ? '' : '-rotate-90'}`} />
Anslut med API-nyckel istället
</button>
{showApiKeyMethods && (
<div className="space-y-6 pt-4 animate-in slide-in-from-top-1 duration-150">
<div>
<p className="text-sm font-medium mb-1">Claude Desktop</p>
<p className="text-xs text-muted-foreground mb-2">
Lägg till i <code className="text-xs">claude_desktop_config.json</code> (Inställningar &rarr; Developer):
</p>
<CopyBlock text={`{
"mcpServers": {
"gnubok": {
"command": "npx",
@@ -311,20 +374,20 @@ export function ApiKeysPanel() {
}
}
}
}`}
</pre>
</div>
}`} />
</div>
<div>
<p className="text-sm font-medium mb-1">Claude Code / Cursor</p>
<p className="text-xs text-muted-foreground mb-2">
Kör i terminalen med en API-nyckel:
</p>
<pre className="rounded-md bg-muted p-4 text-xs font-mono overflow-x-auto">
{`claude mcp add gnubok --transport http \\
<div>
<p className="text-sm font-medium mb-1">Claude Code / Cursor</p>
<p className="text-xs text-muted-foreground mb-2">
Kör i terminalen med en API-nyckel:
</p>
<CopyBlock text={`claude mcp add gnubok --transport http \\
--url ${mcpUrl} \\
--header "Authorization: Bearer gnubok_sk_..."`}
</pre>
--header "Authorization: Bearer gnubok_sk_..."`} />
</div>
</div>
)}
</div>
</CardContent>
</Card>
@@ -419,6 +482,8 @@ export function ApiKeysPanel() {
</DialogContent>
</Dialog>
<DestructiveConfirmDialog {...revokeDialogProps} />
{/* Show key once dialog */}
<Dialog open={showKeyDialog} onOpenChange={(open) => {
if (!open) {
+1 -3
View File
@@ -78,15 +78,13 @@ export default function TransactionForm({ onSubmit, isLoading }: TransactionForm
const isIncome = categories.find((c) => c.value === watchCategory)?.isIncome
const onFormSubmit = (data: FormData) => {
const isBusiness = data.category ? data.category !== 'private' : undefined
onSubmit({
date: data.date,
description: data.description,
amount: data.amount,
currency: data.currency,
category: data.category as TransactionCategory,
is_business: isBusiness,
is_business: undefined,
notes: data.notes,
})
}
@@ -138,7 +138,7 @@ export default function TransactionHistoryList({
Bokförd
</Badge>
</>
) : transaction.is_business === null ? (
) : (
<>
<span>·</span>
<button
@@ -149,7 +149,7 @@ export default function TransactionHistoryList({
Ej bokförd
</button>
</>
) : null}
)}
{transaction.potential_invoice && !transaction.invoice_id && (
<>
<span>·</span>
@@ -167,7 +167,7 @@ export default function TransactionHistoryList({
</div>
</div>
<div className="flex items-center gap-3">
{transaction.is_business === null && !transaction.journal_entry_id && (
{!transaction.journal_entry_id && (
<Button
size="sm"
variant="default"
@@ -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')
+85 -21
View File
@@ -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'