(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() {
) : (
- {keys.map((key) => (
-
-
-
-
{key.name}
-
- {(key.scopes ?? []).map((s) => (
-
- {SCOPE_LABELS[s as Scope] ?? s}
-
- ))}
- {(!key.scopes || key.scopes.length === 0) && (
-
- Enbart läs
-
- )}
+ {keys.map((key) => {
+ const scopeCount = key.scopes?.length ?? 0
+ return (
+
+
+
+
{key.name}
+
+ {scopeCount === ALL_SCOPES.length
+ ? 'Alla behörigheter'
+ : scopeCount === 0
+ ? 'Inga behörigheter'
+ : `${scopeCount} behörigheter`}
+
+
+
+
+ {key.key_prefix}...
+
+
+ Skapad {formatDate(key.created_at)}
+
+
+ {key.last_used_at
+ ? `Använd ${formatDate(key.last_used_at)}`
+ : 'Aldrig använd'}
+
-
-
- {key.key_prefix}...
-
-
- Skapad {formatDate(key.created_at)}
-
-
- {key.last_used_at
- ? `Använd ${formatDate(key.last_used_at)}`
- : 'Aldrig använd'}
-
-
-
-
handleRevoke(key.id)}
- disabled={revokingId === key.id}
- className="text-destructive hover:text-destructive"
- >
- {revokingId === key.id ? (
-
- ) : (
+ handleRevoke(key.id, key.name)}
+ aria-label={`Återkalla ${key.name}`}
+ className="text-destructive hover:text-destructive"
+ >
- )}
-
-
- ))}
+
+
+ )
+ })}
)}
@@ -296,12 +329,42 @@ export function ApiKeysPanel() {
-
Claude Desktop
+
+
Claude.ai
+
Rekommenderat
+
- Lägg till i claude_desktop_config.json (Inställningar → Developer):
+ Gå till Settings → Integrations → Add Integration och klistra in MCP-serverns URL.
+ Du loggas in via ditt gnubok-konto — ingen API-nyckel behövs.
-
-{`{
+
+
+
+
+
Claude Code / Cursor
+
+ Kör i terminalen — loggar in via webbläsaren:
+
+
+
+
+
+
setShowApiKeyMethods(!showApiKeyMethods)}
+ >
+
+ Anslut med API-nyckel istället
+
+ {showApiKeyMethods && (
+
+
+
Claude Desktop
+
+ Lägg till i claude_desktop_config.json (Inställningar → Developer):
+
+
-
+}`} />
+
-
-
Claude Code / Cursor
-
- Kör i terminalen med en API-nyckel:
-
-
-{`claude mcp add gnubok --transport http \\
+
+
Claude Code / Cursor
+
+ Kör i terminalen med en API-nyckel:
+
+
+ --header "Authorization: Bearer gnubok_sk_..."`} />
+
+
+ )}
@@ -419,6 +482,8 @@ export function ApiKeysPanel() {
+
+
{/* Show key once dialog */}
{
if (!open) {
diff --git a/components/transactions/TransactionForm.tsx b/components/transactions/TransactionForm.tsx
index 7dfd065b..e4912330 100644
--- a/components/transactions/TransactionForm.tsx
+++ b/components/transactions/TransactionForm.tsx
@@ -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,
})
}
diff --git a/components/transactions/TransactionHistoryList.tsx b/components/transactions/TransactionHistoryList.tsx
index ea52f7d0..121dd5ce 100644
--- a/components/transactions/TransactionHistoryList.tsx
+++ b/components/transactions/TransactionHistoryList.tsx
@@ -138,7 +138,7 @@ export default function TransactionHistoryList({
Bokförd
>
- ) : transaction.is_business === null ? (
+ ) : (
<>
·
>
- ) : null}
+ )}
{transaction.potential_invoice && !transaction.invoice_id && (
<>
·
@@ -167,7 +167,7 @@ export default function TransactionHistoryList({
- {transaction.is_business === null && !transaction.journal_entry_id && (
+ {!transaction.journal_entry_id && (
{
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')
diff --git a/lib/import/bank-file/formats/nordea-business.ts b/lib/import/bank-file/formats/nordea-business.ts
index c238cacd..94bb5818 100644
--- a/lib/import/bank-file/formats/nordea-business.ts
+++ b/lib/import/bank-file/formats/nordea-business.ts
@@ -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'