From bbb82866ee8941f0e041a8068e67b7abfc82b6af Mon Sep 17 00:00:00 2001
From: Jakob Wennberg
Date: Mon, 23 Feb 2026 20:38:44 +0100
Subject: [PATCH] feat: add 4 new Swedish bank CSV parsers and improve
transaction categorization
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Add auto-detecting CSV parsers for Länsförsäkringar, ICA Banken, Skandia,
and Lunar. Refine SEB detection to avoid false matches. Update bank file
upload UI with new bank options and export instructions. Include booking
templates, improved AI categorization, and transaction review enhancements.
Co-Authored-By: Claude Opus 4.6
---
app/(dashboard)/transactions/page.tsx | 13 +-
app/api/transactions/[id]/categorize/route.ts | 41 +-
.../transactions/suggest-categories/route.ts | 16 +-
components/import/BankFileUploadStep.tsx | 32 +
.../transactions/BatchCategorySelector.tsx | 9 +
components/transactions/QuickReviewDialog.tsx | 80 +-
.../transactions/SwipeCategorizationView.tsx | 82 +-
components/transactions/transaction-types.ts | 3 +-
.../general/ai-categorization/categorizer.ts | 35 +-
lib/api/__tests__/schemas.test.ts | 11 +-
lib/api/schemas.ts | 13 +-
.../__tests__/booking-templates.test.ts | 416 +++
lib/bookkeeping/booking-templates.ts | 2624 +++++++++++++++++
lib/bookkeeping/mapping-engine.ts | 34 +-
lib/import/bank-file/__tests__/parser.test.ts | 297 +-
lib/import/bank-file/formats/ica-banken.ts | 187 ++
.../bank-file/formats/lansforsakringar.ts | 168 ++
lib/import/bank-file/formats/lunar.ts | 142 +
lib/import/bank-file/formats/seb.ts | 6 +-
lib/import/bank-file/formats/skandia.ts | 148 +
lib/import/bank-file/parser.ts | 9 +
lib/import/bank-file/types.ts | 4 +
lib/transactions/category-suggestions.ts | 43 +-
types/index.ts | 1 +
24 files changed, 4366 insertions(+), 48 deletions(-)
create mode 100644 lib/bookkeeping/__tests__/booking-templates.test.ts
create mode 100644 lib/bookkeeping/booking-templates.ts
create mode 100644 lib/import/bank-file/formats/ica-banken.ts
create mode 100644 lib/import/bank-file/formats/lansforsakringar.ts
create mode 100644 lib/import/bank-file/formats/lunar.ts
create mode 100644 lib/import/bank-file/formats/skandia.ts
diff --git a/app/(dashboard)/transactions/page.tsx b/app/(dashboard)/transactions/page.tsx
index 36c8274f..5bba42f0 100644
--- a/app/(dashboard)/transactions/page.tsx
+++ b/app/(dashboard)/transactions/page.tsx
@@ -185,7 +185,7 @@ export default function TransactionsPage() {
if (!response.ok) {
toast({ title: 'Fel', description: result.error || 'Kunde inte uppdatera transaktion', variant: 'destructive' })
setProcessingId(null)
- return false
+ return null
}
// Mark as exiting for animation, then update state
@@ -217,11 +217,11 @@ export default function TransactionsPage() {
setProcessingId(null)
}, 350)
- return true
+ return result.journal_entry_id || null
} catch {
toast({ title: 'Fel', description: 'Något gick fel vid bokföring', variant: 'destructive' })
setProcessingId(null)
- return false
+ return null
}
}
@@ -458,13 +458,14 @@ export default function TransactionsPage() {
category: TransactionCategory,
vatTreatment: VatTreatment | undefined,
accountOverride: string | undefined
- ) {
- const success = await handleCategorize(id, true, category, vatTreatment, accountOverride)
- if (success) {
+ ): Promise {
+ const journalEntryId = await handleCategorize(id, true, category, vatTreatment, accountOverride)
+ if (journalEntryId) {
setQuickReviewOpen(false)
setQuickReviewTransaction(null)
setQuickReviewCategory(null)
}
+ return journalEntryId
}
// Swipe view
diff --git a/app/api/transactions/[id]/categorize/route.ts b/app/api/transactions/[id]/categorize/route.ts
index db27329e..128151dc 100644
--- a/app/api/transactions/[id]/categorize/route.ts
+++ b/app/api/transactions/[id]/categorize/route.ts
@@ -3,6 +3,7 @@ import { NextResponse } from 'next/server'
import { eventBus } from '@/lib/events'
import { ensureInitialized } from '@/lib/init'
import { buildMappingResultFromCategory } from '@/lib/bookkeeping/category-mapping'
+import { getTemplateById, buildMappingResultFromTemplate } from '@/lib/bookkeeping/booking-templates'
import { createTransactionJournalEntry } from '@/lib/bookkeeping/transaction-entries'
import { saveUserMappingRule } from '@/lib/bookkeeping/mapping-engine'
import { validateBody } from '@/lib/api/validate'
@@ -153,18 +154,36 @@ export async function POST(
const fiscalYearStartMonth: number = settings?.fiscal_year_start_month ?? 1
// Determine the category to use
- const finalCategory: TransactionCategory = is_business
- ? (category || 'uncategorized')
- : 'private'
+ let finalCategory: TransactionCategory
+ if (body.template_id) {
+ const template = getTemplateById(body.template_id)
+ if (template) {
+ finalCategory = is_business ? template.fallback_category : 'private'
+ } else {
+ return NextResponse.json({ error: 'Invalid template_id' }, { status: 400 })
+ }
+ } else {
+ finalCategory = is_business ? (category || 'uncategorized') : 'private'
+ }
- // Build mapping result from category
- const mappingResult = buildMappingResultFromCategory(
- finalCategory,
- transaction as Transaction,
- is_business,
- entityType,
- body.vat_treatment
- )
+ // Build mapping result from template or category
+ let mappingResult
+ if (body.template_id) {
+ const template = getTemplateById(body.template_id)!
+ mappingResult = buildMappingResultFromTemplate(
+ template,
+ transaction as Transaction,
+ entityType
+ )
+ } else {
+ mappingResult = buildMappingResultFromCategory(
+ finalCategory,
+ transaction as Transaction,
+ is_business,
+ entityType,
+ body.vat_treatment
+ )
+ }
// Apply account override if provided (only for business transactions)
if (is_business && body.account_override) {
diff --git a/app/api/transactions/suggest-categories/route.ts b/app/api/transactions/suggest-categories/route.ts
index a4773834..5f2e219a 100644
--- a/app/api/transactions/suggest-categories/route.ts
+++ b/app/api/transactions/suggest-categories/route.ts
@@ -1,7 +1,7 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
-import { getSuggestedCategories, mergeAiSuggestions, type SuggestedCategory } from '@/lib/transactions/category-suggestions'
-import type { Transaction, TransactionCategory } from '@/types'
+import { getSuggestedCategories, mergeAiSuggestions, getSuggestedTemplates, type SuggestedCategory, type SuggestedTemplate } from '@/lib/transactions/category-suggestions'
+import type { Transaction, TransactionCategory, EntityType } from '@/types'
/**
* POST /api/transactions/suggest-categories
@@ -78,8 +78,17 @@ export async function POST(request: Request) {
}
}
+ // Fetch entity type for template matching
+ const { data: settings } = await supabase
+ .from('company_settings')
+ .select('entity_type')
+ .eq('user_id', user.id)
+ .single()
+ const entityType = (settings?.entity_type as EntityType) || undefined
+
// Generate suggestions for each transaction
const suggestions: Record = {}
+ const template_suggestions: Record = {}
for (const tx of transactions) {
let result = getSuggestedCategories(
@@ -95,7 +104,8 @@ export async function POST(request: Request) {
}
suggestions[tx.id] = result
+ template_suggestions[tx.id] = getSuggestedTemplates(tx as Transaction, entityType)
}
- return NextResponse.json({ suggestions })
+ return NextResponse.json({ suggestions, template_suggestions })
}
diff --git a/components/import/BankFileUploadStep.tsx b/components/import/BankFileUploadStep.tsx
index d81550b2..5cc7b81e 100644
--- a/components/import/BankFileUploadStep.tsx
+++ b/components/import/BankFileUploadStep.tsx
@@ -27,6 +27,10 @@ const FORMAT_NAMES: Record = {
seb: 'SEB',
swedbank: 'Swedbank',
handelsbanken: 'Handelsbanken',
+ lansforsakringar: 'Länsförsäkringar',
+ ica_banken: 'ICA Banken',
+ skandia: 'Skandia',
+ lunar: 'Lunar',
generic_csv: 'CSV (manuell mappning)',
camt053: 'ISO 20022 camt.053',
}
@@ -122,6 +126,10 @@ export default function BankFileUploadStep({
SEB
Swedbank
Handelsbanken
+ Länsförsäkringar
+ ICA Banken
+ Skandia
+ Lunar
ISO 20022 camt.053 (XML)
Annan CSV (manuell mappning)
@@ -229,6 +237,30 @@ export default function BankFileUploadStep({
Logga in → Konton → Transaktioner → Ladda ner (CSV)
+
+
Länsförsäkringar
+
+ Logga in → Konton → Kontoutdrag → Exportera (CSV)
+
+
+
+
ICA Banken
+
+ Logga in → Konton → Transaktioner → Exportera till fil (CSV)
+
+
+
+
Skandia
+
+ Logga in → Konton → Transaktioner → Exportera (CSV)
+
+
+
+
Lunar
+
+ Logga in → Konto → Transaktioner → Exportera (CSV)
+
+
diff --git a/components/transactions/BatchCategorySelector.tsx b/components/transactions/BatchCategorySelector.tsx
index 9ffa5159..e7c52f30 100644
--- a/components/transactions/BatchCategorySelector.tsx
+++ b/components/transactions/BatchCategorySelector.tsx
@@ -4,6 +4,7 @@ import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
import { Progress } from '@/components/ui/progress'
+import { Paperclip } from 'lucide-react'
import VatTreatmentSelect from './VatTreatmentSelect'
import { EXPENSE_CATEGORIES, INCOME_CATEGORIES } from './transaction-types'
import type { TransactionCategory, VatTreatment } from '@/types'
@@ -59,6 +60,14 @@ export default function BatchCategorySelector({
) : (
+ {/* Underlag reminder */}
+
+
+
+ Underlag behover bifogas separat for varje transaktion efter bokforing.
+
+
+
Momsbehandling
Promise
+ ) => Promise
}
export default function QuickReviewDialog({
@@ -38,11 +41,14 @@ export default function QuickReviewDialog({
defaultVat,
onConfirm,
}: QuickReviewDialogProps) {
+ const { toast } = useToast()
const [accountOverride, setAccountOverride] = useState(defaultAccount)
const [vatTreatment, setVatTreatment] = useState(defaultVat)
const [accounts, setAccounts] = useState([])
const [isProcessing, setIsProcessing] = useState(false)
const [error, setError] = useState(null)
+ const [uploadedFiles, setUploadedFiles] = useState([])
+ const [showUploadZone, setShowUploadZone] = useState(false)
// Handle account changes — clear VAT for liability/equity accounts (class 2)
const handleAccountChange = useCallback((account: string) => {
@@ -85,7 +91,34 @@ export default function QuickReviewDialog({
? accountOverride
: undefined
- await onConfirm(transaction.id, category, resolvedVat, override)
+ const journalEntryId = await onConfirm(transaction.id, category, resolvedVat, override)
+
+ // Link uploaded documents to the journal entry
+ if (journalEntryId && uploadedFiles.length > 0) {
+ const filesToLink = uploadedFiles.filter((f) => f.status === 'uploaded' && f.id)
+ let linkFailCount = 0
+ for (const file of filesToLink) {
+ try {
+ await fetch(`/api/documents/${file.id}/link`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ journal_entry_id: journalEntryId }),
+ })
+ } catch {
+ linkFailCount++
+ }
+ }
+ if (linkFailCount > 0) {
+ toast({
+ title: 'Underlag kunde inte bifogas',
+ description: `${linkFailCount} fil(er) kunde inte lankas till verifikationen.`,
+ variant: 'destructive',
+ })
+ }
+ }
+
+ setUploadedFiles([])
+ setShowUploadZone(false)
} catch {
setError('Ett fel uppstod vid bokföring.')
setIsProcessing(false)
@@ -93,7 +126,13 @@ export default function QuickReviewDialog({
}
return (
-
+ {/* Document upload */}
+
+
+ {showUploadZone && (
+
+
+
+ )}
+
+
{error && (
{error}
diff --git a/components/transactions/SwipeCategorizationView.tsx b/components/transactions/SwipeCategorizationView.tsx
index 7bc7508b..9db0c5d8 100644
--- a/components/transactions/SwipeCategorizationView.tsx
+++ b/components/transactions/SwipeCategorizationView.tsx
@@ -5,12 +5,15 @@ import { motion, useMotionValue, useTransform, AnimatePresence, type PanInfo } f
import { Card, CardContent, CardHeader } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
+import { useToast } from '@/components/ui/use-toast'
import VatTreatmentSelect from './VatTreatmentSelect'
import { formatCurrency, formatDate } from '@/lib/utils'
import { checkExpenseWarnings } from '@/lib/tax/expense-warnings'
import { getDefaultAccountForCategory, getDefaultVatTreatmentForCategory } from '@/lib/bookkeeping/category-mapping'
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
-import { X, ArrowLeft, ArrowRight, Building, AlertTriangle, Check, FileText, Link2, Receipt as ReceiptIcon, SkipForward } from 'lucide-react'
+import DocumentUploadZone from '@/components/bookkeeping/DocumentUploadZone'
+import type { UploadedFile } from '@/components/bookkeeping/DocumentUploadZone'
+import { X, ArrowLeft, ArrowRight, Building, AlertTriangle, Check, FileText, Link2, Receipt as ReceiptIcon, SkipForward, Paperclip, ChevronDown, ChevronUp } from 'lucide-react'
import type { TransactionCategory, VatTreatment, BASAccount } from '@/types'
import type { SuggestedCategory } from '@/lib/transactions/category-suggestions'
import type { TransactionWithInvoice, CategorizeHandler, MatchInvoiceHandler } from './transaction-types'
@@ -34,6 +37,7 @@ export default function SwipeCategorizationView({
onMatchInvoice,
onClose,
}: SwipeCategorizationViewProps) {
+ const { toast } = useToast()
const [showAllCategories, setShowAllCategories] = useState(false)
const [currentIndex, setCurrentIndex] = useState(0)
const [showCategorySelect, setShowCategorySelect] = useState(false)
@@ -46,6 +50,8 @@ export default function SwipeCategorizationView({
const [accountOverride, setAccountOverride] = useState('')
const [vatTreatment, setVatTreatment] = useState
('standard_25')
const [accounts, setAccounts] = useState([])
+ const [uploadedFiles, setUploadedFiles] = useState([])
+ const [showUploadZone, setShowUploadZone] = useState(false)
// Clear VAT treatment when switching to a liability/equity account (class 2)
useEffect(() => {
@@ -143,6 +149,11 @@ export default function SwipeCategorizationView({
[isProcessing, currentTransaction, handleCategorySelect, x, moveToNext]
)
+ const resetUploadState = useCallback(() => {
+ setUploadedFiles([])
+ setShowUploadZone(false)
+ }, [])
+
const handleReviewConfirm = async () => {
if (!pendingCategory) return
@@ -156,14 +167,39 @@ export default function SwipeCategorizationView({
? accountOverride
: undefined
- const success = await onCategorize(
+ const journalEntryId = await onCategorize(
currentTransaction.id,
true,
pendingCategory,
resolvedVat,
override
)
- if (success) {
+ if (journalEntryId) {
+ // Link uploaded documents to the journal entry
+ if (uploadedFiles.length > 0) {
+ const filesToLink = uploadedFiles.filter((f) => f.status === 'uploaded' && f.id)
+ let linkFailCount = 0
+ for (const file of filesToLink) {
+ try {
+ await fetch(`/api/documents/${file.id}/link`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ journal_entry_id: journalEntryId }),
+ })
+ } catch {
+ linkFailCount++
+ }
+ }
+ if (linkFailCount > 0) {
+ toast({
+ title: 'Underlag kunde inte bifogas',
+ description: `${linkFailCount} fil(er) kunde inte lankas till verifikationen.`,
+ variant: 'destructive',
+ })
+ }
+ }
+
+ resetUploadState()
setShowReviewStep(false)
setPendingCategory(null)
moveToNext()
@@ -204,8 +240,9 @@ export default function SwipeCategorizationView({
setShowCategorySelect(false)
setShowReviewStep(false)
setPendingCategory(null)
+ resetUploadState()
moveToNext()
- }, [moveToNext])
+ }, [moveToNext, resetUploadState])
if (!currentTransaction) {
return (
@@ -352,12 +389,45 @@ export default function SwipeCategorizationView({
/>
{isLiabilityAccount && (
- Ingen moms för skuld-/eget kapital-konton
+ Ingen moms for skuld-/eget kapital-konton
)}
+ {/* Document upload */}
+
+
+ {showUploadZone && (
+
+
+
+ )}
+
+
{error && (
{error}
@@ -373,7 +443,7 @@ export default function SwipeCategorizationView({
disabled={isProcessing || !accountOverride}
>
- {isProcessing ? 'Bokför...' : 'Bokför'}
+ {isProcessing ? 'Bokfor...' : 'Bokfor'}