feat: add 4 new Swedish bank CSV parsers and improve transaction categorization
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
431f385b4b
commit
bbb82866ee
@@ -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<string | null> {
|
||||
const journalEntryId = await handleCategorize(id, true, category, vatTreatment, accountOverride)
|
||||
if (journalEntryId) {
|
||||
setQuickReviewOpen(false)
|
||||
setQuickReviewTransaction(null)
|
||||
setQuickReviewCategory(null)
|
||||
}
|
||||
return journalEntryId
|
||||
}
|
||||
|
||||
// Swipe view
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<string, SuggestedCategory[]> = {}
|
||||
const template_suggestions: Record<string, SuggestedTemplate[]> = {}
|
||||
|
||||
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 })
|
||||
}
|
||||
|
||||
@@ -27,6 +27,10 @@ const FORMAT_NAMES: Record<string, string> = {
|
||||
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({
|
||||
<SelectItem value="seb">SEB</SelectItem>
|
||||
<SelectItem value="swedbank">Swedbank</SelectItem>
|
||||
<SelectItem value="handelsbanken">Handelsbanken</SelectItem>
|
||||
<SelectItem value="lansforsakringar">Länsförsäkringar</SelectItem>
|
||||
<SelectItem value="ica_banken">ICA Banken</SelectItem>
|
||||
<SelectItem value="skandia">Skandia</SelectItem>
|
||||
<SelectItem value="lunar">Lunar</SelectItem>
|
||||
<SelectItem value="camt053">ISO 20022 camt.053 (XML)</SelectItem>
|
||||
<SelectItem value="generic_csv">Annan CSV (manuell mappning)</SelectItem>
|
||||
</SelectContent>
|
||||
@@ -229,6 +237,30 @@ export default function BankFileUploadStep({
|
||||
Logga in → Konton → Transaktioner → Ladda ner (CSV)
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">Länsförsäkringar</p>
|
||||
<p className="text-muted-foreground">
|
||||
Logga in → Konton → Kontoutdrag → Exportera (CSV)
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">ICA Banken</p>
|
||||
<p className="text-muted-foreground">
|
||||
Logga in → Konton → Transaktioner → Exportera till fil (CSV)
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">Skandia</p>
|
||||
<p className="text-muted-foreground">
|
||||
Logga in → Konton → Transaktioner → Exportera (CSV)
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">Lunar</p>
|
||||
<p className="text-muted-foreground">
|
||||
Logga in → Konto → Transaktioner → Exportera (CSV)
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -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({
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4 py-2">
|
||||
{/* Underlag reminder */}
|
||||
<div className="flex items-start gap-2 rounded-lg bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-800 p-3">
|
||||
<Paperclip className="h-4 w-4 text-amber-600 dark:text-amber-400 mt-0.5 shrink-0" />
|
||||
<p className="text-xs text-amber-800 dark:text-amber-300">
|
||||
Underlag behover bifogas separat for varje transaktion efter bokforing.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-muted-foreground mb-2">Momsbehandling</h4>
|
||||
<VatTreatmentSelect
|
||||
|
||||
@@ -4,10 +4,13 @@ import { useState, useEffect, useCallback } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { formatCurrency, formatDate } from '@/lib/utils'
|
||||
import { ArrowUpRight, ArrowDownRight, Check } from 'lucide-react'
|
||||
import { ArrowUpRight, ArrowDownRight, Check, Paperclip, ChevronDown, ChevronUp } from 'lucide-react'
|
||||
import { getDefaultAccountForCategory } from '@/lib/bookkeeping/category-mapping'
|
||||
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
|
||||
import DocumentUploadZone from '@/components/bookkeeping/DocumentUploadZone'
|
||||
import type { UploadedFile } from '@/components/bookkeeping/DocumentUploadZone'
|
||||
import VatTreatmentSelect from './VatTreatmentSelect'
|
||||
import type { TransactionWithInvoice } from './transaction-types'
|
||||
import type { TransactionCategory, VatTreatment, BASAccount } from '@/types'
|
||||
@@ -25,7 +28,7 @@ interface QuickReviewDialogProps {
|
||||
category: TransactionCategory,
|
||||
vatTreatment: VatTreatment | undefined,
|
||||
accountOverride: string | undefined
|
||||
) => Promise<void>
|
||||
) => Promise<string | null>
|
||||
}
|
||||
|
||||
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<VatTreatment | 'none'>(defaultVat)
|
||||
const [accounts, setAccounts] = useState<BASAccount[]>([])
|
||||
const [isProcessing, setIsProcessing] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [uploadedFiles, setUploadedFiles] = useState<UploadedFile[]>([])
|
||||
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 (
|
||||
<Dialog open={open} onOpenChange={isProcessing ? undefined : onOpenChange}>
|
||||
<Dialog open={open} onOpenChange={isProcessing ? undefined : (o) => {
|
||||
if (!o) {
|
||||
setUploadedFiles([])
|
||||
setShowUploadZone(false)
|
||||
}
|
||||
onOpenChange(o)
|
||||
}}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Granska bokföring</DialogTitle>
|
||||
@@ -164,6 +203,39 @@ export default function QuickReviewDialog({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Document upload */}
|
||||
<div className="rounded-lg border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowUploadZone(!showUploadZone)}
|
||||
className="flex items-center justify-between w-full px-3 py-2.5 text-sm hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Paperclip className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="font-medium">Underlag</span>
|
||||
{uploadedFiles.filter((f) => f.status === 'uploaded').length > 0 && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{uploadedFiles.filter((f) => f.status === 'uploaded').length} bifogade
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{showUploadZone ? (
|
||||
<ChevronUp className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
{showUploadZone && (
|
||||
<div className="px-3 pb-3">
|
||||
<DocumentUploadZone
|
||||
files={uploadedFiles}
|
||||
onFilesChange={setUploadedFiles}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 rounded-lg bg-destructive/10 text-destructive text-sm">
|
||||
{error}
|
||||
|
||||
@@ -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<VatTreatment | 'none'>('standard_25')
|
||||
const [accounts, setAccounts] = useState<BASAccount[]>([])
|
||||
const [uploadedFiles, setUploadedFiles] = useState<UploadedFile[]>([])
|
||||
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 && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Ingen moms för skuld-/eget kapital-konton
|
||||
Ingen moms for skuld-/eget kapital-konton
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Document upload */}
|
||||
<div className="rounded-lg border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowUploadZone(!showUploadZone)}
|
||||
className="flex items-center justify-between w-full px-3 py-2.5 text-sm hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Paperclip className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="font-medium">Underlag</span>
|
||||
{uploadedFiles.filter((f) => f.status === 'uploaded').length > 0 && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{uploadedFiles.filter((f) => f.status === 'uploaded').length} bifogade
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{showUploadZone ? (
|
||||
<ChevronUp className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
{showUploadZone && (
|
||||
<div className="px-3 pb-3">
|
||||
<DocumentUploadZone
|
||||
files={uploadedFiles}
|
||||
onFilesChange={setUploadedFiles}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 rounded-lg bg-destructive/10 text-destructive text-sm">
|
||||
{error}
|
||||
@@ -373,7 +443,7 @@ export default function SwipeCategorizationView({
|
||||
disabled={isProcessing || !accountOverride}
|
||||
>
|
||||
<Check className="mr-2 h-4 w-4" />
|
||||
{isProcessing ? 'Bokför...' : 'Bokför'}
|
||||
{isProcessing ? 'Bokfor...' : 'Bokfor'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
@@ -10,13 +10,14 @@ export type ViewMode = 'inbox' | 'history'
|
||||
export type HistoryFilter = 'all' | 'business' | 'private'
|
||||
|
||||
// Handler types
|
||||
// Returns the journal_entry_id on success, null on failure
|
||||
export type CategorizeHandler = (
|
||||
id: string,
|
||||
isBusiness: boolean,
|
||||
category?: TransactionCategory,
|
||||
vatTreatment?: VatTreatment,
|
||||
accountOverride?: string
|
||||
) => Promise<boolean>
|
||||
) => Promise<string | null>
|
||||
|
||||
export type MatchInvoiceHandler = (
|
||||
transactionId: string,
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
import 'server-only'
|
||||
import Anthropic from '@anthropic-ai/sdk'
|
||||
import { BOOKING_TEMPLATES, type BookingTemplate } from '@/lib/bookkeeping/booking-templates'
|
||||
import type { TransactionCategory, EntityType } from '@/types'
|
||||
|
||||
// ============================================================
|
||||
@@ -39,6 +40,7 @@ export interface CategorizationSuggestion {
|
||||
confidence: number
|
||||
reasoning: string
|
||||
isPrivate: boolean
|
||||
templateId?: string
|
||||
}
|
||||
|
||||
export interface CategorizationProvider {
|
||||
@@ -73,6 +75,17 @@ function getCategoryAccountMap(entityType: EntityType): Record<string, { account
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an abbreviated template reference for the AI prompt.
|
||||
* Filters by direction (expense/income) to keep prompt concise.
|
||||
*/
|
||||
function getTemplateReference(direction: 'expense' | 'income'): string {
|
||||
return BOOKING_TEMPLATES
|
||||
.filter((t) => t.direction === direction || t.direction === 'transfer')
|
||||
.map((t) => `${t.id}: ${t.name_sv} → ${t.debit_account}/${t.credit_account}`)
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
const NON_DEDUCTIBLE_RULES = `
|
||||
ICKE-AVDRAGSGILLA KOSTNADER (svensk skatterätt):
|
||||
- Kläder: Normalt inte avdragsgilla (RÅ 1988 ref. 35)
|
||||
@@ -112,10 +125,21 @@ export class AnthropicCategorizationProvider implements CategorizationProvider {
|
||||
const privateAccount = context.entityType === 'aktiebolag' ? '2893' : '2013'
|
||||
const categoryAccountMap = getCategoryAccountMap(context.entityType)
|
||||
|
||||
const systemPrompt = `Du är expert på svensk bokföring och kategorisering av banktransaktioner enligt BAS-kontoplanen.
|
||||
Din uppgift är att kategorisera varje transaktion till rätt kategori och BAS-konto.
|
||||
// Build template references based on batch direction (most transactions will be same direction)
|
||||
const hasExpenses = batch.some((t) => t.amount < 0)
|
||||
const hasIncome = batch.some((t) => t.amount > 0)
|
||||
const templateRef = [
|
||||
hasExpenses ? `UTGIFTSMALLAR:\n${getTemplateReference('expense')}` : '',
|
||||
hasIncome ? `INTÄKTSMALLAR:\n${getTemplateReference('income')}` : '',
|
||||
].filter(Boolean).join('\n\n')
|
||||
|
||||
KATEGORIER OCH BAS-KONTON:
|
||||
const systemPrompt = `Du är expert på svensk bokföring och kategorisering av banktransaktioner enligt BAS-kontoplanen.
|
||||
Din uppgift är att kategorisera varje transaktion till rätt mall-ID (templateId) och BAS-konto.
|
||||
|
||||
BOKFÖRINGSMALLAR (id: namn → debitkonto/kreditkonto):
|
||||
${templateRef}
|
||||
|
||||
KATEGORIER (fallback om ingen mall matchar):
|
||||
${Object.entries(categoryAccountMap)
|
||||
.map(([cat, info]) => `- ${cat}: ${info.account} (${info.label})`)
|
||||
.join('\n')}
|
||||
@@ -136,7 +160,8 @@ REGLER:
|
||||
3. Ange confidence 0.0-1.0 baserat på hur säker du är
|
||||
4. Ange kort reasoning på svenska
|
||||
5. Om en transaktion liknar privat konsumtion (kläder, gym, etc.), sätt category: "private"
|
||||
6. taxCode: "MPI" för avdragsgilla affärskostnader med moms, "MP1" för intäkter med moms, null för momsfria/privata`
|
||||
6. taxCode: "MPI" för avdragsgilla affärskostnader med moms, "MP1" för intäkter med moms, null för momsfria/privata
|
||||
7. Ange templateId om en bokföringsmall matchar (föredra mallar framför generiska kategorier)`
|
||||
|
||||
const historyContext =
|
||||
context.recentHistory.length > 0
|
||||
@@ -168,6 +193,7 @@ Returnera ett JSON-objekt med följande struktur:
|
||||
{
|
||||
"transactionId": "id",
|
||||
"category": "expense_software",
|
||||
"templateId": "it_saas_subscription",
|
||||
"basAccount": "5420",
|
||||
"taxCode": "MPI",
|
||||
"confidence": 0.9,
|
||||
@@ -265,6 +291,7 @@ Returnera ENDAST JSON-objektet, ingen annan text.`
|
||||
confidence: Math.max(0, Math.min(1, Number(s.confidence) || 0.5)),
|
||||
reasoning: (s.reasoning as string) || '',
|
||||
isPrivate: category === 'private' || Boolean(s.isPrivate),
|
||||
templateId: (s.templateId as string) || undefined,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -347,11 +347,18 @@ describe('CreateInvoiceSchema', () => {
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects vat_rate > 1 (must be decimal)', () => {
|
||||
it('rejects vat_rate > 100', () => {
|
||||
const result = CreateInvoiceSchema.safeParse(validInvoice({
|
||||
items: [validInvoiceItem({ vat_rate: 101 })],
|
||||
}))
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts vat_rate of 25 (standard Swedish VAT)', () => {
|
||||
const result = CreateInvoiceSchema.safeParse(validInvoice({
|
||||
items: [validInvoiceItem({ vat_rate: 25 })],
|
||||
}))
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts vat_rate of 0 (export/exempt)', () => {
|
||||
|
||||
+7
-6
@@ -148,7 +148,7 @@ export const CreateInvoiceItemSchema = z.object({
|
||||
quantity: z.number().positive('Quantity must be positive'),
|
||||
unit: z.string().min(1, 'Unit is required'),
|
||||
unit_price: z.number(),
|
||||
vat_rate: z.number().min(0).max(1).optional(),
|
||||
vat_rate: z.number().min(0).max(100).optional(),
|
||||
})
|
||||
|
||||
export const CreateInvoiceSchema = z.object({
|
||||
@@ -213,7 +213,7 @@ export const CreateSupplierSchema = z.object({
|
||||
bic: z.string().optional(),
|
||||
default_expense_account: accountNumber.optional(),
|
||||
default_payment_terms: z.number().int().positive().optional(),
|
||||
default_currency: z.string().optional(),
|
||||
default_currency: CurrencySchema.nullable().optional(),
|
||||
notes: z.string().optional(),
|
||||
})
|
||||
|
||||
@@ -227,7 +227,7 @@ export const CreateSupplierInvoiceItemSchema = z.object({
|
||||
description: z.string().min(1, 'Item description is required'),
|
||||
amount: z.number().optional(),
|
||||
account_number: accountNumber,
|
||||
vat_rate: z.number().min(0).max(1).optional(),
|
||||
vat_rate: z.number().min(0).max(100).optional(),
|
||||
vat_code: z.string().optional(),
|
||||
quantity: z.number().optional(),
|
||||
unit: z.string().optional(),
|
||||
@@ -240,7 +240,7 @@ export const CreateSupplierInvoiceSchema = z.object({
|
||||
invoice_date: isoDate,
|
||||
due_date: isoDate,
|
||||
delivery_date: isoDate.optional(),
|
||||
currency: z.string().optional(),
|
||||
currency: CurrencySchema.optional(),
|
||||
exchange_rate: z.number().positive().optional(),
|
||||
vat_treatment: VatTreatmentSchema.optional(),
|
||||
reverse_charge: z.boolean().optional(),
|
||||
@@ -303,6 +303,7 @@ export const CorrectJournalEntrySchema = z.object({
|
||||
export const CategorizeTransactionSchema = z.object({
|
||||
is_business: z.boolean(),
|
||||
category: TransactionCategorySchema.optional(),
|
||||
template_id: z.string().optional(),
|
||||
vat_treatment: VatTreatmentSchema.optional(),
|
||||
account_override: accountNumber.optional(),
|
||||
})
|
||||
@@ -347,10 +348,10 @@ export const UpdateSettingsSchema = z.object({
|
||||
iban: z.string().optional(),
|
||||
bic: z.string().optional(),
|
||||
accounting_method: AccountingMethodSchema.optional(),
|
||||
invoice_prefix: z.string().optional(),
|
||||
invoice_prefix: z.string().nullable().optional(),
|
||||
next_invoice_number: z.number().int().positive().optional(),
|
||||
invoice_default_days: z.number().int().positive().optional(),
|
||||
invoice_default_notes: z.string().optional(),
|
||||
invoice_default_notes: z.string().nullable().optional(),
|
||||
email: z.string().email().optional(),
|
||||
pays_salaries: z.boolean().optional(),
|
||||
sector_slug: z.string().nullable().optional(),
|
||||
|
||||
@@ -0,0 +1,416 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { makeTransaction } from '@/tests/helpers'
|
||||
import {
|
||||
BOOKING_TEMPLATES,
|
||||
getTemplateById,
|
||||
getTemplatesByGroup,
|
||||
getTemplatesByMcc,
|
||||
getTemplateGroups,
|
||||
searchTemplates,
|
||||
findMatchingTemplates,
|
||||
buildMappingResultFromTemplate,
|
||||
type BookingTemplate,
|
||||
} from '../booking-templates'
|
||||
|
||||
// ============================================================
|
||||
// Template Data Integrity
|
||||
// ============================================================
|
||||
|
||||
describe('BOOKING_TEMPLATES data integrity', () => {
|
||||
it('has exactly 100 templates', () => {
|
||||
expect(BOOKING_TEMPLATES).toHaveLength(100)
|
||||
})
|
||||
|
||||
it('all template IDs are unique', () => {
|
||||
const ids = BOOKING_TEMPLATES.map((t) => t.id)
|
||||
expect(new Set(ids).size).toBe(ids.length)
|
||||
})
|
||||
|
||||
it('all templates have valid required fields', () => {
|
||||
for (const t of BOOKING_TEMPLATES) {
|
||||
expect(t.id).toBeTruthy()
|
||||
expect(t.name_sv).toBeTruthy()
|
||||
expect(t.name_en).toBeTruthy()
|
||||
expect(t.group).toBeTruthy()
|
||||
expect(['expense', 'income', 'transfer']).toContain(t.direction)
|
||||
expect(['all', 'enskild_firma', 'aktiebolag']).toContain(t.entity_applicability)
|
||||
expect(t.debit_account).toMatch(/^\d{4}$/)
|
||||
expect(t.credit_account).toMatch(/^\d{4}$/)
|
||||
expect(['full', 'non_deductible', 'conditional']).toContain(t.deductibility)
|
||||
expect(['NONE', 'LOW', 'MEDIUM', 'HIGH', 'VERY_HIGH']).toContain(t.risk_level)
|
||||
expect(typeof t.requires_review).toBe('boolean')
|
||||
expect(t.impact_score).toBeGreaterThanOrEqual(1)
|
||||
expect(t.impact_score).toBeLessThanOrEqual(10)
|
||||
expect(t.auto_match_confidence).toBeGreaterThanOrEqual(0.5)
|
||||
expect(t.auto_match_confidence).toBeLessThanOrEqual(1.0)
|
||||
expect(typeof t.default_private).toBe('boolean')
|
||||
expect(t.fallback_category).toBeTruthy()
|
||||
expect(t.description_sv).toBeTruthy()
|
||||
expect(Array.isArray(t.mcc_codes)).toBe(true)
|
||||
expect(Array.isArray(t.keywords)).toBe(true)
|
||||
expect(t.keywords.length).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
|
||||
it('all AB-specific override accounts are valid 4-digit strings', () => {
|
||||
for (const t of BOOKING_TEMPLATES) {
|
||||
if (t.debit_account_ab) {
|
||||
expect(t.debit_account_ab).toMatch(/^\d{4}$/)
|
||||
}
|
||||
if (t.credit_account_ab) {
|
||||
expect(t.credit_account_ab).toMatch(/^\d{4}$/)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('vat_rate is consistent with vat_treatment', () => {
|
||||
for (const t of BOOKING_TEMPLATES) {
|
||||
if (t.vat_treatment === 'standard_25') {
|
||||
expect(t.vat_rate).toBe(0.25)
|
||||
} else if (t.vat_treatment === 'reduced_12') {
|
||||
expect(t.vat_rate).toBe(0.12)
|
||||
} else if (t.vat_treatment === 'reduced_6') {
|
||||
expect(t.vat_rate).toBe(0.06)
|
||||
} else if (t.vat_treatment === 'reverse_charge' || t.vat_treatment === 'export' || t.vat_treatment === 'exempt' || t.vat_treatment === null) {
|
||||
expect(t.vat_rate).toBe(0)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// Lookup Functions
|
||||
// ============================================================
|
||||
|
||||
describe('getTemplateById', () => {
|
||||
it('returns correct template for known ID', () => {
|
||||
const t = getTemplateById('it_saas_subscription')
|
||||
expect(t).toBeDefined()
|
||||
expect(t!.name_sv).toBe('Programvara / SaaS-prenumeration')
|
||||
expect(t!.debit_account).toBe('5420')
|
||||
})
|
||||
|
||||
it('returns undefined for unknown ID', () => {
|
||||
expect(getTemplateById('nonexistent')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getTemplatesByGroup', () => {
|
||||
it('returns templates for the premises group', () => {
|
||||
const templates = getTemplatesByGroup('premises')
|
||||
expect(templates.length).toBeGreaterThan(0)
|
||||
for (const t of templates) {
|
||||
expect(t.group).toBe('premises')
|
||||
}
|
||||
})
|
||||
|
||||
it('returns empty array for non-existent group', () => {
|
||||
expect(getTemplatesByGroup('nonexistent' as never)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('getTemplatesByMcc', () => {
|
||||
it('returns templates for MCC 5541 (fuel)', () => {
|
||||
const templates = getTemplatesByMcc(5541)
|
||||
expect(templates.length).toBeGreaterThan(0)
|
||||
expect(templates.some((t) => t.id === 'vehicle_fuel')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns empty array for unknown MCC', () => {
|
||||
expect(getTemplatesByMcc(9999)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('getTemplateGroups', () => {
|
||||
it('returns all 18 groups', () => {
|
||||
const groups = getTemplateGroups()
|
||||
expect(groups).toHaveLength(18)
|
||||
for (const g of groups) {
|
||||
expect(g.group).toBeTruthy()
|
||||
expect(g.label_sv).toBeTruthy()
|
||||
expect(g.label_en).toBeTruthy()
|
||||
expect(Array.isArray(g.templates)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('every template is in exactly one group', () => {
|
||||
const groups = getTemplateGroups()
|
||||
const allTemplates = groups.flatMap((g) => g.templates)
|
||||
expect(allTemplates).toHaveLength(100)
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// Search
|
||||
// ============================================================
|
||||
|
||||
describe('searchTemplates', () => {
|
||||
it('finds templates by Swedish name', () => {
|
||||
const results = searchTemplates('lokalhyra')
|
||||
expect(results.length).toBeGreaterThan(0)
|
||||
expect(results.some((t) => t.id === 'premises_rent')).toBe(true)
|
||||
})
|
||||
|
||||
it('finds templates by English name', () => {
|
||||
const results = searchTemplates('software')
|
||||
expect(results.length).toBeGreaterThan(0)
|
||||
expect(results.some((t) => t.id === 'it_saas_subscription')).toBe(true)
|
||||
})
|
||||
|
||||
it('finds templates by keywords', () => {
|
||||
const results = searchTemplates('spotify')
|
||||
expect(results.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('returns empty for empty query', () => {
|
||||
expect(searchTemplates('')).toEqual([])
|
||||
})
|
||||
|
||||
it('filters by entity type', () => {
|
||||
const results = searchTemplates('pension', 'enskild_firma')
|
||||
// Should include EF-specific and 'all', but not AB-only
|
||||
for (const t of results) {
|
||||
expect(t.entity_applicability).not.toBe('aktiebolag')
|
||||
}
|
||||
})
|
||||
|
||||
it('supports multi-token search', () => {
|
||||
const results = searchTemplates('digital annons')
|
||||
expect(results.some((t) => t.id === 'marketing_online_ads')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// findMatchingTemplates
|
||||
// ============================================================
|
||||
|
||||
describe('findMatchingTemplates', () => {
|
||||
it('matches by MCC code with high confidence', () => {
|
||||
const tx = makeTransaction({
|
||||
amount: -500,
|
||||
mcc_code: 5541,
|
||||
description: 'Gas station',
|
||||
merchant_name: 'OKQ8',
|
||||
})
|
||||
const matches = findMatchingTemplates(tx)
|
||||
expect(matches.length).toBeGreaterThan(0)
|
||||
expect(matches[0].template.id).toBe('vehicle_fuel')
|
||||
expect(matches[0].confidence).toBeGreaterThan(0.3)
|
||||
})
|
||||
|
||||
it('matches by keywords in description', () => {
|
||||
const tx = makeTransaction({
|
||||
amount: -299,
|
||||
description: 'Google Ads campaign',
|
||||
merchant_name: 'Google',
|
||||
})
|
||||
const matches = findMatchingTemplates(tx)
|
||||
expect(matches.some((m) => m.template.id === 'marketing_online_ads')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns empty for a transaction with no signals', () => {
|
||||
const tx = makeTransaction({
|
||||
amount: -100,
|
||||
description: 'XYZ123ABC',
|
||||
mcc_code: null,
|
||||
merchant_name: null,
|
||||
})
|
||||
const matches = findMatchingTemplates(tx)
|
||||
expect(matches).toEqual([])
|
||||
})
|
||||
|
||||
it('filters by entity type', () => {
|
||||
const tx = makeTransaction({
|
||||
amount: -5000,
|
||||
description: 'Löneutbetalning',
|
||||
})
|
||||
const matches = findMatchingTemplates(tx, 'enskild_firma')
|
||||
// Personnel salary is AB-only, should not appear
|
||||
for (const m of matches) {
|
||||
expect(m.template.entity_applicability).not.toBe('aktiebolag')
|
||||
}
|
||||
})
|
||||
|
||||
it('does not match expense templates for positive amounts', () => {
|
||||
const tx = makeTransaction({
|
||||
amount: 1000,
|
||||
description: 'Bensin okq8',
|
||||
mcc_code: 5541,
|
||||
})
|
||||
const matches = findMatchingTemplates(tx)
|
||||
// vehicle_fuel is an expense template, should not match positive amount
|
||||
expect(matches.every((m) => m.template.direction !== 'expense')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns max 5 results', () => {
|
||||
const tx = makeTransaction({
|
||||
amount: -100,
|
||||
description: 'software subscription cloud hosting domain',
|
||||
mcc_code: 5817,
|
||||
})
|
||||
const matches = findMatchingTemplates(tx)
|
||||
expect(matches.length).toBeLessThanOrEqual(5)
|
||||
})
|
||||
|
||||
it('results are sorted by confidence descending', () => {
|
||||
const tx = makeTransaction({
|
||||
amount: -999,
|
||||
description: 'Google cloud hosting',
|
||||
mcc_code: 4816,
|
||||
})
|
||||
const matches = findMatchingTemplates(tx)
|
||||
for (let i = 1; i < matches.length; i++) {
|
||||
expect(matches[i - 1].confidence).toBeGreaterThanOrEqual(matches[i].confidence)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// buildMappingResultFromTemplate
|
||||
// ============================================================
|
||||
|
||||
describe('buildMappingResultFromTemplate', () => {
|
||||
const getTemplate = (id: string): BookingTemplate => {
|
||||
const t = getTemplateById(id)
|
||||
if (!t) throw new Error(`Template not found: ${id}`)
|
||||
return t
|
||||
}
|
||||
|
||||
it('produces valid MappingResult for expense with 25% VAT', () => {
|
||||
const template = getTemplate('it_saas_subscription')
|
||||
const tx = makeTransaction({ amount: -1250 })
|
||||
const result = buildMappingResultFromTemplate(template, tx, 'enskild_firma')
|
||||
|
||||
expect(result.debit_account).toBe('5420')
|
||||
expect(result.credit_account).toBe('1930')
|
||||
expect(result.template_id).toBe('it_saas_subscription')
|
||||
expect(result.rule).toBeNull()
|
||||
expect(result.confidence).toBe(1.0)
|
||||
expect(result.vat_lines).toHaveLength(1)
|
||||
expect(result.vat_lines[0].account_number).toBe('2641')
|
||||
expect(result.vat_lines[0].debit_amount).toBe(250) // 1250 * 0.25 / 1.25 = 250
|
||||
})
|
||||
|
||||
it('produces valid MappingResult for expense with 12% VAT', () => {
|
||||
const template = getTemplate('travel_hotel')
|
||||
const tx = makeTransaction({ amount: -1120 })
|
||||
const result = buildMappingResultFromTemplate(template, tx, 'enskild_firma')
|
||||
|
||||
expect(result.debit_account).toBe('5820')
|
||||
expect(result.vat_lines).toHaveLength(1)
|
||||
expect(result.vat_lines[0].account_number).toBe('2641')
|
||||
expect(result.vat_lines[0].debit_amount).toBe(120) // 1120 * 0.12 / 1.12 = 120
|
||||
})
|
||||
|
||||
it('produces valid MappingResult for expense with 6% VAT', () => {
|
||||
const template = getTemplate('travel_train')
|
||||
const tx = makeTransaction({ amount: -530 })
|
||||
const result = buildMappingResultFromTemplate(template, tx, 'enskild_firma')
|
||||
|
||||
expect(result.vat_lines).toHaveLength(1)
|
||||
expect(result.vat_lines[0].account_number).toBe('2641')
|
||||
expect(result.vat_lines[0].debit_amount).toBe(30) // 530 * 0.06 / 1.06 = 30
|
||||
})
|
||||
|
||||
it('produces reverse charge lines for EU purchases', () => {
|
||||
const template = getTemplate('it_saas_eu')
|
||||
const tx = makeTransaction({ amount: -1000 })
|
||||
const result = buildMappingResultFromTemplate(template, tx, 'enskild_firma')
|
||||
|
||||
expect(result.vat_lines).toHaveLength(2)
|
||||
// Fiktiv ingående moms
|
||||
expect(result.vat_lines[0].account_number).toBe('2645')
|
||||
expect(result.vat_lines[0].debit_amount).toBe(250)
|
||||
// Fiktiv utgående moms
|
||||
expect(result.vat_lines[1].account_number).toBe('2614')
|
||||
expect(result.vat_lines[1].credit_amount).toBe(250)
|
||||
})
|
||||
|
||||
it('produces no VAT lines for exempt expenses', () => {
|
||||
const template = getTemplate('premises_rent')
|
||||
const tx = makeTransaction({ amount: -10000 })
|
||||
const result = buildMappingResultFromTemplate(template, tx, 'enskild_firma')
|
||||
|
||||
expect(result.vat_lines).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('produces no VAT lines for non-deductible templates', () => {
|
||||
const template = getTemplate('private_withdrawal_ef')
|
||||
const tx = makeTransaction({ amount: -5000 })
|
||||
const result = buildMappingResultFromTemplate(template, tx, 'enskild_firma')
|
||||
|
||||
expect(result.vat_lines).toHaveLength(0)
|
||||
expect(result.default_private).toBe(true)
|
||||
})
|
||||
|
||||
it('produces output VAT lines for income with 25% VAT', () => {
|
||||
const template = getTemplate('revenue_services_25')
|
||||
const tx = makeTransaction({ amount: 12500 })
|
||||
const result = buildMappingResultFromTemplate(template, tx, 'enskild_firma')
|
||||
|
||||
expect(result.debit_account).toBe('1930')
|
||||
expect(result.credit_account).toBe('3001')
|
||||
expect(result.vat_lines).toHaveLength(1)
|
||||
expect(result.vat_lines[0].account_number).toBe('2611')
|
||||
expect(result.vat_lines[0].credit_amount).toBe(2500) // 12500 * 0.25 / 1.25 = 2500
|
||||
})
|
||||
|
||||
it('produces output VAT lines for income with 12% VAT', () => {
|
||||
const template = getTemplate('revenue_products_12')
|
||||
const tx = makeTransaction({ amount: 1120 })
|
||||
const result = buildMappingResultFromTemplate(template, tx, 'enskild_firma')
|
||||
|
||||
expect(result.vat_lines).toHaveLength(1)
|
||||
expect(result.vat_lines[0].account_number).toBe('2621')
|
||||
expect(result.vat_lines[0].credit_amount).toBe(120)
|
||||
})
|
||||
|
||||
it('produces output VAT lines for income with 6% VAT', () => {
|
||||
const template = getTemplate('revenue_products_6')
|
||||
const tx = makeTransaction({ amount: 1060 })
|
||||
const result = buildMappingResultFromTemplate(template, tx, 'enskild_firma')
|
||||
|
||||
expect(result.vat_lines).toHaveLength(1)
|
||||
expect(result.vat_lines[0].account_number).toBe('2631')
|
||||
expect(result.vat_lines[0].credit_amount).toBe(60)
|
||||
})
|
||||
|
||||
it('resolves AB-specific accounts for aktiebolag', () => {
|
||||
const template = getTemplate('education_course')
|
||||
const tx = makeTransaction({ amount: -5000 })
|
||||
|
||||
const efResult = buildMappingResultFromTemplate(template, tx, 'enskild_firma')
|
||||
expect(efResult.debit_account).toBe('6991')
|
||||
|
||||
const abResult = buildMappingResultFromTemplate(template, tx, 'aktiebolag')
|
||||
expect(abResult.debit_account).toBe('7610')
|
||||
})
|
||||
|
||||
it('resolves AB-specific private account', () => {
|
||||
const template = getTemplate('private_expense')
|
||||
const tx = makeTransaction({ amount: -300 })
|
||||
|
||||
const efResult = buildMappingResultFromTemplate(template, tx, 'enskild_firma')
|
||||
expect(efResult.debit_account).toBe('2013')
|
||||
|
||||
const abResult = buildMappingResultFromTemplate(template, tx, 'aktiebolag')
|
||||
expect(abResult.debit_account).toBe('2893')
|
||||
})
|
||||
|
||||
it('includes template_id in the MappingResult', () => {
|
||||
const template = getTemplate('bank_fees')
|
||||
const tx = makeTransaction({ amount: -49 })
|
||||
const result = buildMappingResultFromTemplate(template, tx, 'enskild_firma')
|
||||
|
||||
expect(result.template_id).toBe('bank_fees')
|
||||
expect(result.rule).toBeNull()
|
||||
})
|
||||
|
||||
it('sets description with template name and transaction description', () => {
|
||||
const template = getTemplate('vehicle_fuel')
|
||||
const tx = makeTransaction({ amount: -800, description: 'OKQ8 tankstation' })
|
||||
const result = buildMappingResultFromTemplate(template, tx, 'enskild_firma')
|
||||
|
||||
expect(result.description).toBe('Drivmedel: OKQ8 tankstation')
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,11 +5,13 @@ import {
|
||||
extractNetAmount,
|
||||
extractVatAmount,
|
||||
} from './vat-entries'
|
||||
import { findMatchingTemplates, buildMappingResultFromTemplate } from './booking-templates'
|
||||
import type {
|
||||
MappingRule,
|
||||
MappingResult,
|
||||
RiskLevel,
|
||||
Transaction,
|
||||
EntityType,
|
||||
VatJournalLine,
|
||||
} from '@/types'
|
||||
|
||||
@@ -28,7 +30,8 @@ const CAPITALIZATION_THRESHOLD = 29400
|
||||
*/
|
||||
export async function evaluateMappingRules(
|
||||
userId: string,
|
||||
transaction: Transaction
|
||||
transaction: Transaction,
|
||||
entityType?: EntityType
|
||||
): Promise<MappingResult> {
|
||||
const supabase = await createClient()
|
||||
|
||||
@@ -41,6 +44,9 @@ export async function evaluateMappingRules(
|
||||
.order('priority', { ascending: true })
|
||||
|
||||
if (error || !rules || rules.length === 0) {
|
||||
// Try template-based matching before default fallback
|
||||
const templateResult = evaluateTemplateRules(transaction, entityType)
|
||||
if (templateResult) return templateResult
|
||||
return getDefaultResult(transaction)
|
||||
}
|
||||
|
||||
@@ -51,9 +57,35 @@ export async function evaluateMappingRules(
|
||||
}
|
||||
}
|
||||
|
||||
// Try template-based matching before default fallback
|
||||
const templateResult = evaluateTemplateRules(transaction, entityType)
|
||||
if (templateResult) return templateResult
|
||||
|
||||
return getDefaultResult(transaction)
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate booking templates as a fallback when no DB mapping rule matches.
|
||||
* Returns the best template match if confidence >= 0.3, otherwise null.
|
||||
*/
|
||||
function evaluateTemplateRules(
|
||||
transaction: Transaction,
|
||||
entityType?: EntityType
|
||||
): MappingResult | null {
|
||||
const matches = findMatchingTemplates(transaction, entityType)
|
||||
if (matches.length === 0 || matches[0].confidence < 0.3) return null
|
||||
|
||||
const best = matches[0]
|
||||
const result = buildMappingResultFromTemplate(
|
||||
best.template,
|
||||
transaction,
|
||||
entityType || 'enskild_firma'
|
||||
)
|
||||
// Override the confidence with the auto-match confidence (not 1.0)
|
||||
result.confidence = best.confidence
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a transaction matches a mapping rule
|
||||
*/
|
||||
|
||||
@@ -116,6 +116,51 @@ const CAMT053_XML_WITH_STRUCTURED_REF = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
</BkToCstmrStmt>
|
||||
</Document>`
|
||||
|
||||
const LANSFORSAKRINGAR_CSV = [
|
||||
'"Datum";"Bokföringsdag";"Typ";"Text";"Belopp";"Saldo"',
|
||||
'"2024-01-15";"2024-01-15";"Kortköp";"SPOTIFY AB";"-99,00";"12 345,67"',
|
||||
'"2024-01-14";"2024-01-14";"Kortköp";"ICA MAXI";"-432,50";"12 444,67"',
|
||||
'"2024-01-13";"2024-01-13";"Insättning";"LÖNEUTBETALNING";"25 000,00";"12 877,17"',
|
||||
].join('\n')
|
||||
|
||||
const LANSFORSAKRINGAR_CSV_NO_HEADER = [
|
||||
'"2024-01-15";"2024-01-15";"Kortköp";"SPOTIFY AB";"-99,00";"12 345,67"',
|
||||
'"2024-01-14";"2024-01-14";"Kortköp";"ICA MAXI";"-432,50";"12 444,67"',
|
||||
].join('\n')
|
||||
|
||||
const ICA_BANKEN_CSV = [
|
||||
'Kontonamn: Lönekonto',
|
||||
'Kontonummer: 1234 567 890',
|
||||
'Saldo: 12 877,17',
|
||||
'Tillgängligt belopp: 12 877,17',
|
||||
'Period: 2024-01-01 - 2024-01-31',
|
||||
'Exporterad: 2024-02-01',
|
||||
'Datum;Text;Belopp;Saldo',
|
||||
'2024-01-15;SPOTIFY AB;-99,00;12345,67',
|
||||
'2024-01-14;ICA MAXI LINDHAGEN;-432,50;12444,67',
|
||||
'2024-01-13;LÖNEUTBETALNING;25000,00;12877,17',
|
||||
].join('\n')
|
||||
|
||||
const SKANDIA_CSV = [
|
||||
'Datum;Beskrivning;Belopp;Saldo',
|
||||
'2024-01-15;SPOTIFY AB;-99,00;12345,67',
|
||||
'2024-01-14;HEMKÖP FRIDHEMSPLAN;-432,50;12444,67',
|
||||
'2024-01-13;LÖNEUTBETALNING;25000,00;12877,17',
|
||||
].join('\n')
|
||||
|
||||
const SKANDIA_CSV_WITH_BANKKATEGORI = [
|
||||
'Datum;Beskrivning;Belopp;Saldo;Bankkategori',
|
||||
'2024-01-15;SPOTIFY AB;-99,00;12345,67;Underhållning',
|
||||
'2024-01-14;ICA MAXI;-432,50;12444,67;Livsmedel',
|
||||
].join('\n')
|
||||
|
||||
const LUNAR_CSV = [
|
||||
'Date,Text,Amount,Balance',
|
||||
'2024-01-15,SPOTIFY AB,"-99,00","12.345,67"',
|
||||
'2024-01-14,ICA MAXI LINDHAGEN,"-432,50","12.444,67"',
|
||||
'2024-01-13,LÖNEUTBETALNING,"25.000,00","12.877,17"',
|
||||
].join('\n')
|
||||
|
||||
const UNKNOWN_CSV = [
|
||||
'id,name,value,timestamp',
|
||||
'1,Widget A,100,2024-01-15T10:00:00',
|
||||
@@ -185,6 +230,42 @@ describe('detectFileFormat', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('detects Länsförsäkringar CSV from header with "typ" keyword', () => {
|
||||
const format = detectFileFormat(LANSFORSAKRINGAR_CSV, 'lansforsakringar.csv')
|
||||
expect(format).not.toBeNull()
|
||||
expect(format!.id).toBe('lansforsakringar')
|
||||
})
|
||||
|
||||
it('detects Länsförsäkringar CSV from two adjacent date fields (no header)', () => {
|
||||
const format = detectFileFormat(LANSFORSAKRINGAR_CSV_NO_HEADER, 'export.csv')
|
||||
expect(format).not.toBeNull()
|
||||
expect(format!.id).toBe('lansforsakringar')
|
||||
})
|
||||
|
||||
it('detects ICA Banken CSV from metadata rows before header', () => {
|
||||
const format = detectFileFormat(ICA_BANKEN_CSV, 'ica.csv')
|
||||
expect(format).not.toBeNull()
|
||||
expect(format!.id).toBe('ica_banken')
|
||||
})
|
||||
|
||||
it('detects Skandia CSV from "beskrivning" header keyword', () => {
|
||||
const format = detectFileFormat(SKANDIA_CSV, 'skandia.csv')
|
||||
expect(format).not.toBeNull()
|
||||
expect(format!.id).toBe('skandia')
|
||||
})
|
||||
|
||||
it('detects Skandia CSV from "bankkategori" header keyword', () => {
|
||||
const format = detectFileFormat(SKANDIA_CSV_WITH_BANKKATEGORI, 'skandia.csv')
|
||||
expect(format).not.toBeNull()
|
||||
expect(format!.id).toBe('skandia')
|
||||
})
|
||||
|
||||
it('detects Lunar CSV from English headers (date, text, amount, balance)', () => {
|
||||
const format = detectFileFormat(LUNAR_CSV, 'lunar.csv')
|
||||
expect(format).not.toBeNull()
|
||||
expect(format!.id).toBe('lunar')
|
||||
})
|
||||
|
||||
it('returns null for unrecognized CSV content', () => {
|
||||
const format = detectFileFormat(UNKNOWN_CSV, 'data.csv')
|
||||
expect(format).toBeNull()
|
||||
@@ -426,6 +507,216 @@ describe('parseBankFile — Handelsbanken format', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseBankFile — Länsförsäkringar format', () => {
|
||||
it('parses semicolon-delimited CSV with quoted fields and comma decimal separator', () => {
|
||||
const result = parseBankFile(LANSFORSAKRINGAR_CSV, 'lf.csv')
|
||||
|
||||
expect(result.format).toBe('lansforsakringar')
|
||||
expect(result.format_name).toBe('Länsförsäkringar')
|
||||
expect(result.transactions).toHaveLength(3)
|
||||
expect(result.issues).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('correctly parses amounts with comma decimal and space thousands', () => {
|
||||
const result = parseBankFile(LANSFORSAKRINGAR_CSV, 'lf.csv')
|
||||
|
||||
expect(result.transactions[0].amount).toBe(-99)
|
||||
expect(result.transactions[0].description).toBe('SPOTIFY AB')
|
||||
expect(result.transactions[0].date).toBe('2024-01-15')
|
||||
|
||||
expect(result.transactions[1].amount).toBe(-432.5)
|
||||
expect(result.transactions[2].amount).toBe(25000)
|
||||
})
|
||||
|
||||
it('parses balance field', () => {
|
||||
const result = parseBankFile(LANSFORSAKRINGAR_CSV, 'lf.csv')
|
||||
|
||||
expect(result.transactions[0].balance).toBe(12345.67)
|
||||
})
|
||||
|
||||
it('handles files without a header row (data-only)', () => {
|
||||
const result = parseBankFile(LANSFORSAKRINGAR_CSV_NO_HEADER, 'lf.csv')
|
||||
|
||||
expect(result.format).toBe('lansforsakringar')
|
||||
expect(result.transactions).toHaveLength(2)
|
||||
expect(result.transactions[0].amount).toBe(-99)
|
||||
expect(result.transactions[1].amount).toBe(-432.5)
|
||||
})
|
||||
|
||||
it('calculates stats correctly', () => {
|
||||
const result = parseBankFile(LANSFORSAKRINGAR_CSV, 'lf.csv')
|
||||
|
||||
expect(result.stats.total_income).toBe(25000)
|
||||
expect(result.stats.total_expenses).toBe(-531.5)
|
||||
expect(result.stats.parsed_rows).toBe(3)
|
||||
})
|
||||
|
||||
it('extracts correct date range', () => {
|
||||
const result = parseBankFile(LANSFORSAKRINGAR_CSV, 'lf.csv')
|
||||
|
||||
expect(result.date_from).toBe('2024-01-13')
|
||||
expect(result.date_to).toBe('2024-01-15')
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseBankFile — ICA Banken format', () => {
|
||||
it('parses semicolon-delimited CSV with metadata rows before header', () => {
|
||||
const result = parseBankFile(ICA_BANKEN_CSV, 'ica.csv')
|
||||
|
||||
expect(result.format).toBe('ica_banken')
|
||||
expect(result.format_name).toBe('ICA Banken')
|
||||
expect(result.transactions).toHaveLength(3)
|
||||
expect(result.issues).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('skips metadata rows and finds the correct header', () => {
|
||||
const result = parseBankFile(ICA_BANKEN_CSV, 'ica.csv')
|
||||
|
||||
// No transaction should contain metadata text
|
||||
const descriptions = result.transactions.map((t) => t.description)
|
||||
expect(descriptions).not.toContain(expect.stringContaining('Kontonamn'))
|
||||
expect(descriptions).not.toContain(expect.stringContaining('Exporterad'))
|
||||
})
|
||||
|
||||
it('correctly parses amounts with comma decimal separator', () => {
|
||||
const result = parseBankFile(ICA_BANKEN_CSV, 'ica.csv')
|
||||
|
||||
expect(result.transactions[0].amount).toBe(-99)
|
||||
expect(result.transactions[0].description).toBe('SPOTIFY AB')
|
||||
expect(result.transactions[0].date).toBe('2024-01-15')
|
||||
|
||||
expect(result.transactions[1].amount).toBe(-432.5)
|
||||
expect(result.transactions[2].amount).toBe(25000)
|
||||
})
|
||||
|
||||
it('parses balance field', () => {
|
||||
const result = parseBankFile(ICA_BANKEN_CSV, 'ica.csv')
|
||||
|
||||
expect(result.transactions[0].balance).toBe(12345.67)
|
||||
})
|
||||
|
||||
it('calculates stats correctly', () => {
|
||||
const result = parseBankFile(ICA_BANKEN_CSV, 'ica.csv')
|
||||
|
||||
expect(result.stats.total_income).toBe(25000)
|
||||
expect(result.stats.total_expenses).toBe(-531.5)
|
||||
expect(result.stats.parsed_rows).toBe(3)
|
||||
})
|
||||
|
||||
it('extracts correct date range', () => {
|
||||
const result = parseBankFile(ICA_BANKEN_CSV, 'ica.csv')
|
||||
|
||||
expect(result.date_from).toBe('2024-01-13')
|
||||
expect(result.date_to).toBe('2024-01-15')
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseBankFile — Skandia format', () => {
|
||||
it('parses semicolon-delimited CSV with comma decimal separator', () => {
|
||||
const result = parseBankFile(SKANDIA_CSV, 'skandia.csv')
|
||||
|
||||
expect(result.format).toBe('skandia')
|
||||
expect(result.format_name).toBe('Skandia')
|
||||
expect(result.transactions).toHaveLength(3)
|
||||
expect(result.issues).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('correctly parses amounts and descriptions', () => {
|
||||
const result = parseBankFile(SKANDIA_CSV, 'skandia.csv')
|
||||
|
||||
expect(result.transactions[0].amount).toBe(-99)
|
||||
expect(result.transactions[0].description).toBe('SPOTIFY AB')
|
||||
expect(result.transactions[0].date).toBe('2024-01-15')
|
||||
|
||||
expect(result.transactions[1].amount).toBe(-432.5)
|
||||
expect(result.transactions[1].description).toBe('HEMKÖP FRIDHEMSPLAN')
|
||||
expect(result.transactions[2].amount).toBe(25000)
|
||||
})
|
||||
|
||||
it('parses balance field', () => {
|
||||
const result = parseBankFile(SKANDIA_CSV, 'skandia.csv')
|
||||
|
||||
expect(result.transactions[0].balance).toBe(12345.67)
|
||||
})
|
||||
|
||||
it('handles files with bankkategori column', () => {
|
||||
const result = parseBankFile(SKANDIA_CSV_WITH_BANKKATEGORI, 'skandia.csv')
|
||||
|
||||
expect(result.format).toBe('skandia')
|
||||
expect(result.transactions).toHaveLength(2)
|
||||
expect(result.transactions[0].amount).toBe(-99)
|
||||
expect(result.transactions[1].amount).toBe(-432.5)
|
||||
})
|
||||
|
||||
it('calculates stats correctly', () => {
|
||||
const result = parseBankFile(SKANDIA_CSV, 'skandia.csv')
|
||||
|
||||
expect(result.stats.total_income).toBe(25000)
|
||||
expect(result.stats.total_expenses).toBe(-531.5)
|
||||
expect(result.stats.parsed_rows).toBe(3)
|
||||
})
|
||||
|
||||
it('extracts correct date range', () => {
|
||||
const result = parseBankFile(SKANDIA_CSV, 'skandia.csv')
|
||||
|
||||
expect(result.date_from).toBe('2024-01-13')
|
||||
expect(result.date_to).toBe('2024-01-15')
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseBankFile — Lunar format', () => {
|
||||
it('parses comma-delimited CSV with English headers', () => {
|
||||
const result = parseBankFile(LUNAR_CSV, 'lunar.csv')
|
||||
|
||||
expect(result.format).toBe('lunar')
|
||||
expect(result.format_name).toBe('Lunar')
|
||||
expect(result.transactions).toHaveLength(3)
|
||||
expect(result.issues).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('correctly parses amounts with comma decimal and period thousand separator', () => {
|
||||
const result = parseBankFile(LUNAR_CSV, 'lunar.csv')
|
||||
|
||||
expect(result.transactions[0].amount).toBe(-99)
|
||||
expect(result.transactions[0].description).toBe('SPOTIFY AB')
|
||||
expect(result.transactions[0].date).toBe('2024-01-15')
|
||||
|
||||
expect(result.transactions[1].amount).toBe(-432.5)
|
||||
expect(result.transactions[2].amount).toBe(25000)
|
||||
})
|
||||
|
||||
it('parses balance field with period thousand separator', () => {
|
||||
const result = parseBankFile(LUNAR_CSV, 'lunar.csv')
|
||||
|
||||
expect(result.transactions[0].balance).toBe(12345.67)
|
||||
expect(result.transactions[2].balance).toBe(12877.17)
|
||||
})
|
||||
|
||||
it('calculates stats correctly', () => {
|
||||
const result = parseBankFile(LUNAR_CSV, 'lunar.csv')
|
||||
|
||||
expect(result.stats.total_income).toBe(25000)
|
||||
expect(result.stats.total_expenses).toBe(-531.5)
|
||||
expect(result.stats.parsed_rows).toBe(3)
|
||||
})
|
||||
|
||||
it('extracts correct date range', () => {
|
||||
const result = parseBankFile(LUNAR_CSV, 'lunar.csv')
|
||||
|
||||
expect(result.date_from).toBe('2024-01-13')
|
||||
expect(result.date_to).toBe('2024-01-15')
|
||||
})
|
||||
|
||||
it('does not confuse Lunar (English) with Nordea (Swedish) headers', () => {
|
||||
// Nordea has Swedish headers, Lunar has English
|
||||
const nordeaResult = detectFileFormat(NORDEA_CSV, 'test.csv')
|
||||
const lunarResult = detectFileFormat(LUNAR_CSV, 'test.csv')
|
||||
|
||||
expect(nordeaResult!.id).toBe('nordea')
|
||||
expect(lunarResult!.id).toBe('lunar')
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseBankFile — camt.053 XML format', () => {
|
||||
it('parses XML with credit and debit entries', () => {
|
||||
const result = parseBankFile(CAMT053_XML, 'statement.xml')
|
||||
@@ -850,13 +1141,17 @@ describe('getFormat and getAllFormats', () => {
|
||||
it('getAllFormats returns all registered formats', () => {
|
||||
const formats = getAllFormats()
|
||||
|
||||
expect(formats.length).toBeGreaterThanOrEqual(6)
|
||||
expect(formats.length).toBeGreaterThanOrEqual(10)
|
||||
|
||||
const ids = formats.map((f) => f.id)
|
||||
expect(ids).toContain('nordea')
|
||||
expect(ids).toContain('seb')
|
||||
expect(ids).toContain('swedbank')
|
||||
expect(ids).toContain('handelsbanken')
|
||||
expect(ids).toContain('lansforsakringar')
|
||||
expect(ids).toContain('ica_banken')
|
||||
expect(ids).toContain('skandia')
|
||||
expect(ids).toContain('lunar')
|
||||
expect(ids).toContain('camt053')
|
||||
expect(ids).toContain('generic_csv')
|
||||
})
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* ICA Banken CSV format parser
|
||||
*
|
||||
* Format: Semicolon-delimited, comma decimal separator
|
||||
* Columns: Datum, Text, Belopp, Saldo
|
||||
* Date format: YYYY-MM-DD
|
||||
* Encoding: UTF-8 or Windows-1252
|
||||
*
|
||||
* Notes:
|
||||
* - ~6 metadata rows before the actual data (account info, period, etc.)
|
||||
* - Header row contains "datum" and "belopp"
|
||||
* - Must skip metadata lines to find the real header
|
||||
*/
|
||||
|
||||
import type { BankFileFormat, BankFileParseResult, ParsedBankTransaction, BankFileParseIssue } from '../types'
|
||||
import { prepareContent } from '../encoding'
|
||||
|
||||
function parseCommaDecimal(value: string): number {
|
||||
const cleaned = value.replace(/\s/g, '').replace(',', '.')
|
||||
return parseFloat(cleaned)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a line looks like the ICA Banken data header.
|
||||
* ICA Banken header: semicolon-delimited with "datum", "text", "belopp", "saldo"
|
||||
*/
|
||||
function isICAHeader(line: string): boolean {
|
||||
const lower = line.toLowerCase().replace(/"/g, '')
|
||||
if (!lower.includes(';')) return false
|
||||
const fields = lower.split(';').map((f) => f.trim())
|
||||
return (
|
||||
fields.some((f) => f === 'datum') &&
|
||||
fields.some((f) => f === 'belopp') &&
|
||||
fields.some((f) => f === 'text')
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect ICA Banken format: semicolon-delimited file with metadata lines
|
||||
* before a header containing "datum" and "belopp".
|
||||
*/
|
||||
function detectICABanken(lines: string[]): boolean {
|
||||
// Look for a header row within the first ~10 lines (skipping metadata)
|
||||
let metadataCount = 0
|
||||
for (let i = 0; i < Math.min(lines.length, 10); i++) {
|
||||
if (isICAHeader(lines[i])) {
|
||||
// Must have at least 2 metadata rows before header to distinguish from
|
||||
// other semicolon-delimited formats (SEB, Handelsbanken, LF)
|
||||
return metadataCount >= 2
|
||||
}
|
||||
metadataCount++
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export const icaBankenFormat: BankFileFormat = {
|
||||
id: 'ica_banken',
|
||||
name: 'ICA Banken',
|
||||
description: 'ICA Banken CSV (semicolon-delimited, metadata rows before header)',
|
||||
fileExtensions: ['.csv', '.txt'],
|
||||
|
||||
detect(content: string, _filename: string): boolean {
|
||||
const prepared = prepareContent(content)
|
||||
const lines = prepared.split('\n').filter((l) => l.trim() !== '')
|
||||
return detectICABanken(lines)
|
||||
},
|
||||
|
||||
parse(content: string): BankFileParseResult {
|
||||
const prepared = prepareContent(content)
|
||||
const lines = prepared.split('\n').filter((line) => line.trim() !== '')
|
||||
|
||||
const transactions: ParsedBankTransaction[] = []
|
||||
const issues: BankFileParseIssue[] = []
|
||||
let skippedRows = 0
|
||||
|
||||
// Find the header row
|
||||
let headerLineIdx = -1
|
||||
for (let i = 0; i < Math.min(lines.length, 10); i++) {
|
||||
if (isICAHeader(lines[i])) {
|
||||
headerLineIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (headerLineIdx === -1) {
|
||||
issues.push({
|
||||
row: 1,
|
||||
message: 'Could not find ICA Banken header row',
|
||||
severity: 'error',
|
||||
})
|
||||
return {
|
||||
format: 'ica_banken',
|
||||
format_name: 'ICA Banken',
|
||||
transactions: [],
|
||||
date_from: null,
|
||||
date_to: null,
|
||||
issues,
|
||||
stats: { total_rows: 0, parsed_rows: 0, skipped_rows: 0, total_income: 0, total_expenses: 0 },
|
||||
}
|
||||
}
|
||||
|
||||
// Parse header columns
|
||||
const headers = lines[headerLineIdx].split(';').map((h) => h.trim().toLowerCase().replace(/"/g, ''))
|
||||
const dateIdx = headers.findIndex((h) => h === 'datum')
|
||||
const descIdx = headers.findIndex((h) => h === 'text')
|
||||
const amountIdx = headers.findIndex((h) => h === 'belopp')
|
||||
const balanceIdx = headers.findIndex((h) => h === 'saldo')
|
||||
|
||||
if (dateIdx === -1 || amountIdx === -1) {
|
||||
issues.push({
|
||||
row: headerLineIdx + 1,
|
||||
message: 'Could not identify required columns (datum, belopp)',
|
||||
severity: 'error',
|
||||
})
|
||||
return {
|
||||
format: 'ica_banken',
|
||||
format_name: 'ICA Banken',
|
||||
transactions: [],
|
||||
date_from: null,
|
||||
date_to: null,
|
||||
issues,
|
||||
stats: { total_rows: 0, parsed_rows: 0, skipped_rows: 0, total_income: 0, total_expenses: 0 },
|
||||
}
|
||||
}
|
||||
|
||||
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 date = fields[dateIdx]
|
||||
const description = descIdx >= 0 ? fields[descIdx] : 'Unknown'
|
||||
const amountStr = fields[amountIdx]
|
||||
const balanceStr = balanceIdx >= 0 ? fields[balanceIdx] : undefined
|
||||
|
||||
if (!date || !amountStr) {
|
||||
skippedRows++
|
||||
continue
|
||||
}
|
||||
|
||||
const amount = parseCommaDecimal(amountStr)
|
||||
if (isNaN(amount)) {
|
||||
issues.push({ row: i + 1, message: `Invalid amount: ${amountStr}`, severity: 'warning' })
|
||||
skippedRows++
|
||||
continue
|
||||
}
|
||||
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
||||
issues.push({ row: i + 1, message: `Invalid date: ${date}`, severity: 'warning' })
|
||||
skippedRows++
|
||||
continue
|
||||
}
|
||||
|
||||
const balance = balanceStr ? parseCommaDecimal(balanceStr) : null
|
||||
|
||||
transactions.push({
|
||||
date,
|
||||
description: (description || 'Unknown').trim(),
|
||||
amount,
|
||||
currency: 'SEK',
|
||||
balance: isNaN(balance as number) ? null : balance,
|
||||
reference: null,
|
||||
counterparty: null,
|
||||
raw_line: line,
|
||||
})
|
||||
}
|
||||
|
||||
const dates = transactions.map((t) => t.date).sort()
|
||||
|
||||
return {
|
||||
format: 'ica_banken',
|
||||
format_name: 'ICA Banken',
|
||||
transactions,
|
||||
date_from: dates[0] || null,
|
||||
date_to: dates[dates.length - 1] || null,
|
||||
issues,
|
||||
stats: {
|
||||
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,
|
||||
total_expenses: Math.round(transactions.filter((t) => t.amount < 0).reduce((s, t) => s + t.amount, 0) * 100) / 100,
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* Länsförsäkringar CSV format parser
|
||||
*
|
||||
* Format: Semicolon-delimited, comma decimal separator, double-quoted fields
|
||||
* Columns: Datum, Bokföringsdag, Typ, Text, Belopp, (Saldo optional)
|
||||
* Date format: YYYY-MM-DD
|
||||
* Encoding: UTF-8 or Windows-1252
|
||||
*
|
||||
* Notes:
|
||||
* - Fields are double-quoted
|
||||
* - Two adjacent date columns (Datum + Bokföringsdag) is unique to Länsförsäkringar
|
||||
* - No guaranteed header row — detect by structure
|
||||
*/
|
||||
|
||||
import type { BankFileFormat, BankFileParseResult, ParsedBankTransaction, BankFileParseIssue } from '../types'
|
||||
import { prepareContent } from '../encoding'
|
||||
import { parseCSVLine } from './nordea'
|
||||
|
||||
function parseCommaDecimal(value: string): number {
|
||||
const cleaned = value.replace(/\s/g, '').replace(',', '.')
|
||||
return parseFloat(cleaned)
|
||||
}
|
||||
|
||||
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/
|
||||
|
||||
/**
|
||||
* Check if a line has the Länsförsäkringar structure:
|
||||
* two adjacent YYYY-MM-DD date fields in a semicolon-delimited, quoted row.
|
||||
*/
|
||||
function isLFRow(line: string): boolean {
|
||||
if (!line.includes(';')) return false
|
||||
const fields = parseCSVLine(line, ';').map((f) => f.trim())
|
||||
return fields.length >= 5 && DATE_RE.test(fields[0]) && DATE_RE.test(fields[1])
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a line looks like a Länsförsäkringar header row.
|
||||
*/
|
||||
function isLFHeader(line: string): boolean {
|
||||
const lower = line.toLowerCase().replace(/"/g, '')
|
||||
return (
|
||||
lower.includes(';') &&
|
||||
lower.includes('datum') &&
|
||||
lower.includes('typ') &&
|
||||
lower.includes('belopp')
|
||||
)
|
||||
}
|
||||
|
||||
export const lansforsakringarFormat: BankFileFormat = {
|
||||
id: 'lansforsakringar',
|
||||
name: 'Länsförsäkringar',
|
||||
description: 'Länsförsäkringar CSV (semicolon-delimited, quoted fields)',
|
||||
fileExtensions: ['.csv', '.txt'],
|
||||
|
||||
detect(content: string, _filename: string): boolean {
|
||||
const prepared = prepareContent(content)
|
||||
const lines = prepared.split('\n').filter((l) => l.trim() !== '')
|
||||
if (lines.length < 1) return false
|
||||
|
||||
// Check for header with "typ" keyword (unique to LF among semicolon formats)
|
||||
if (isLFHeader(lines[0])) return true
|
||||
|
||||
// Alternatively: detect data rows with two adjacent date fields
|
||||
// Check first few non-empty lines for the two-date pattern
|
||||
for (let i = 0; i < Math.min(lines.length, 3); i++) {
|
||||
if (isLFRow(lines[i])) return true
|
||||
}
|
||||
|
||||
return false
|
||||
},
|
||||
|
||||
parse(content: string): BankFileParseResult {
|
||||
const prepared = prepareContent(content)
|
||||
const lines = prepared.split('\n').filter((line) => line.trim() !== '')
|
||||
|
||||
const transactions: ParsedBankTransaction[] = []
|
||||
const issues: BankFileParseIssue[] = []
|
||||
let skippedRows = 0
|
||||
|
||||
// Determine if first row is a header or data
|
||||
let startIdx = 0
|
||||
let dateIdx = 0
|
||||
let descIdx = 3
|
||||
let amountIdx = 4
|
||||
let balanceIdx = 5
|
||||
|
||||
if (isLFHeader(lines[0])) {
|
||||
// Parse header to find column indices
|
||||
const headers = parseCSVLine(lines[0], ';').map((h) => h.trim().toLowerCase().replace(/"/g, ''))
|
||||
dateIdx = headers.findIndex((h) => h === 'datum')
|
||||
if (dateIdx === -1) dateIdx = 0
|
||||
descIdx = headers.findIndex((h) => h === 'text' || h === 'beskrivning')
|
||||
if (descIdx === -1) descIdx = 3
|
||||
amountIdx = headers.findIndex((h) => h === 'belopp')
|
||||
if (amountIdx === -1) amountIdx = 4
|
||||
balanceIdx = headers.findIndex((h) => h === 'saldo')
|
||||
startIdx = 1
|
||||
}
|
||||
|
||||
for (let i = startIdx; i < lines.length; i++) {
|
||||
const line = lines[i].trim()
|
||||
if (!line) continue
|
||||
|
||||
const fields = parseCSVLine(line, ';').map((f) => f.trim().replace(/^"|"$/g, ''))
|
||||
|
||||
if (fields.length < 5) {
|
||||
issues.push({ row: i + 1, message: 'Too few columns', severity: 'warning' })
|
||||
skippedRows++
|
||||
continue
|
||||
}
|
||||
|
||||
const date = fields[dateIdx]
|
||||
const description = fields[descIdx] || 'Unknown'
|
||||
const amountStr = fields[amountIdx]
|
||||
const balanceStr = balanceIdx >= 0 && balanceIdx < fields.length ? fields[balanceIdx] : undefined
|
||||
|
||||
if (!date || !amountStr) {
|
||||
skippedRows++
|
||||
continue
|
||||
}
|
||||
|
||||
const amount = parseCommaDecimal(amountStr)
|
||||
if (isNaN(amount)) {
|
||||
issues.push({ row: i + 1, message: `Invalid amount: ${amountStr}`, severity: 'warning' })
|
||||
skippedRows++
|
||||
continue
|
||||
}
|
||||
|
||||
if (!DATE_RE.test(date)) {
|
||||
issues.push({ row: i + 1, message: `Invalid date: ${date}`, severity: 'warning' })
|
||||
skippedRows++
|
||||
continue
|
||||
}
|
||||
|
||||
const balance = balanceStr ? parseCommaDecimal(balanceStr) : null
|
||||
|
||||
transactions.push({
|
||||
date,
|
||||
description: description.trim(),
|
||||
amount,
|
||||
currency: 'SEK',
|
||||
balance: isNaN(balance as number) ? null : balance,
|
||||
reference: null,
|
||||
counterparty: null,
|
||||
raw_line: line,
|
||||
})
|
||||
}
|
||||
|
||||
const dates = transactions.map((t) => t.date).sort()
|
||||
const totalDataRows = lines.length - startIdx
|
||||
|
||||
return {
|
||||
format: 'lansforsakringar',
|
||||
format_name: 'Länsförsäkringar',
|
||||
transactions,
|
||||
date_from: dates[0] || null,
|
||||
date_to: dates[dates.length - 1] || null,
|
||||
issues,
|
||||
stats: {
|
||||
total_rows: totalDataRows,
|
||||
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,
|
||||
total_expenses: Math.round(transactions.filter((t) => t.amount < 0).reduce((s, t) => s + t.amount, 0) * 100) / 100,
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Lunar CSV format parser
|
||||
*
|
||||
* Format: Comma-delimited, comma decimal separator (amounts are quoted)
|
||||
* Columns: Date, Text, Amount, Balance (English headers)
|
||||
* Date format: YYYY-MM-DD
|
||||
* Encoding: UTF-8
|
||||
*
|
||||
* Notes:
|
||||
* - English headers distinguish Lunar from Nordea (Swedish headers)
|
||||
* - Amounts use comma as decimal separator but are quoted since the file
|
||||
* delimiter is also comma
|
||||
* - Thousand separator is period (e.g. "1.234,56")
|
||||
*/
|
||||
|
||||
import type { BankFileFormat, BankFileParseResult, ParsedBankTransaction, BankFileParseIssue } from '../types'
|
||||
import { prepareContent } from '../encoding'
|
||||
import { parseCSVLine } from './nordea'
|
||||
|
||||
function parseLunarAmount(value: string): number {
|
||||
// Lunar format: "1.234,56" or "-1.234,56"
|
||||
// Remove period (thousand separator), replace comma (decimal separator) with period
|
||||
const cleaned = value.replace(/\./g, '').replace(',', '.')
|
||||
return parseFloat(cleaned)
|
||||
}
|
||||
|
||||
export const lunarFormat: BankFileFormat = {
|
||||
id: 'lunar',
|
||||
name: 'Lunar',
|
||||
description: 'Lunar CSV (comma-delimited, English headers)',
|
||||
fileExtensions: ['.csv', '.txt'],
|
||||
|
||||
detect(content: string, _filename: string): boolean {
|
||||
const prepared = prepareContent(content)
|
||||
const firstLine = prepared.split('\n')[0]?.toLowerCase() || ''
|
||||
// Lunar: comma-delimited with English headers
|
||||
// Must NOT contain semicolons, must have "date", "text", "amount", "balance"
|
||||
return (
|
||||
!firstLine.includes(';') &&
|
||||
firstLine.includes('date') &&
|
||||
firstLine.includes('text') &&
|
||||
firstLine.includes('amount') &&
|
||||
firstLine.includes('balance')
|
||||
)
|
||||
},
|
||||
|
||||
parse(content: string): BankFileParseResult {
|
||||
const prepared = prepareContent(content)
|
||||
const lines = prepared.split('\n').filter((line) => line.trim() !== '')
|
||||
|
||||
const transactions: ParsedBankTransaction[] = []
|
||||
const issues: BankFileParseIssue[] = []
|
||||
let skippedRows = 0
|
||||
|
||||
// Parse header
|
||||
const headerLine = lines[0] || ''
|
||||
const headers = parseCSVLine(headerLine, ',').map((h) => h.trim().toLowerCase().replace(/"/g, ''))
|
||||
|
||||
const dateIdx = headers.findIndex((h) => h === 'date')
|
||||
const descIdx = headers.findIndex((h) => h === 'text')
|
||||
const amountIdx = headers.findIndex((h) => h === 'amount')
|
||||
const balanceIdx = headers.findIndex((h) => h === 'balance')
|
||||
|
||||
if (dateIdx === -1 || amountIdx === -1) {
|
||||
issues.push({
|
||||
row: 1,
|
||||
message: 'Could not identify required columns (date, amount)',
|
||||
severity: 'error',
|
||||
})
|
||||
return {
|
||||
format: 'lunar',
|
||||
format_name: 'Lunar',
|
||||
transactions: [],
|
||||
date_from: null,
|
||||
date_to: null,
|
||||
issues,
|
||||
stats: { total_rows: 0, parsed_rows: 0, skipped_rows: 0, total_income: 0, total_expenses: 0 },
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const line = lines[i].trim()
|
||||
if (!line) continue
|
||||
|
||||
const fields = parseCSVLine(line, ',').map((f) => f.trim().replace(/^"|"$/g, ''))
|
||||
|
||||
const date = fields[dateIdx]
|
||||
const description = descIdx >= 0 ? fields[descIdx] : 'Unknown'
|
||||
const amountStr = fields[amountIdx]
|
||||
const balanceStr = balanceIdx >= 0 ? fields[balanceIdx] : undefined
|
||||
|
||||
if (!date || !amountStr) {
|
||||
skippedRows++
|
||||
continue
|
||||
}
|
||||
|
||||
const amount = parseLunarAmount(amountStr)
|
||||
if (isNaN(amount)) {
|
||||
issues.push({ row: i + 1, message: `Invalid amount: ${amountStr}`, severity: 'warning' })
|
||||
skippedRows++
|
||||
continue
|
||||
}
|
||||
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
||||
issues.push({ row: i + 1, message: `Invalid date: ${date}`, severity: 'warning' })
|
||||
skippedRows++
|
||||
continue
|
||||
}
|
||||
|
||||
const balance = balanceStr ? parseLunarAmount(balanceStr) : null
|
||||
|
||||
transactions.push({
|
||||
date,
|
||||
description: (description || 'Unknown').trim(),
|
||||
amount,
|
||||
currency: 'SEK',
|
||||
balance: isNaN(balance as number) ? null : balance,
|
||||
reference: null,
|
||||
counterparty: null,
|
||||
raw_line: line,
|
||||
})
|
||||
}
|
||||
|
||||
const dates = transactions.map((t) => t.date).sort()
|
||||
|
||||
return {
|
||||
format: 'lunar',
|
||||
format_name: 'Lunar',
|
||||
transactions,
|
||||
date_from: dates[0] || null,
|
||||
date_to: dates[dates.length - 1] || null,
|
||||
issues,
|
||||
stats: {
|
||||
total_rows: lines.length - 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,
|
||||
total_expenses: Math.round(transactions.filter((t) => t.amount < 0).reduce((s, t) => s + t.amount, 0) * 100) / 100,
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -25,12 +25,14 @@ export const sebFormat: BankFileFormat = {
|
||||
detect(content: string, _filename: string): boolean {
|
||||
const prepared = prepareContent(content)
|
||||
const firstLine = prepared.split('\n')[0]?.toLowerCase() || ''
|
||||
// SEB headers contain "bokföringsdag" or "bokforingsdatum" and use semicolons
|
||||
// SEB headers contain "bokföringsdag"/"bokforingsdatum" AND "valutadag"/"verifikationsnummer"
|
||||
// The secondary check distinguishes SEB from Länsförsäkringar (which also has "bokföringsdag")
|
||||
return (
|
||||
firstLine.includes(';') &&
|
||||
(firstLine.includes('bokföringsdag') ||
|
||||
firstLine.includes('bokforingsdatum') ||
|
||||
firstLine.includes('bokföringsdag'))
|
||||
firstLine.includes('bokföringsdag')) &&
|
||||
(firstLine.includes('valutadag') || firstLine.includes('verifikationsnummer'))
|
||||
)
|
||||
},
|
||||
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* Skandia CSV format parser
|
||||
*
|
||||
* Format: Semicolon-delimited, comma decimal separator
|
||||
* Columns: Datum, Beskrivning/Text, Belopp, Saldo (possibly Bankkategori)
|
||||
* Date format: YYYY-MM-DD
|
||||
* Encoding: UTF-8 or Windows-1252
|
||||
*
|
||||
* Notes:
|
||||
* - Header contains "beskrivning" (unique keyword not used by SEB/Handelsbanken)
|
||||
* or "bankkategori" (Skandia-specific column)
|
||||
*/
|
||||
|
||||
import type { BankFileFormat, BankFileParseResult, ParsedBankTransaction, BankFileParseIssue } from '../types'
|
||||
import { prepareContent } from '../encoding'
|
||||
|
||||
function parseCommaDecimal(value: string): number {
|
||||
const cleaned = value.replace(/\s/g, '').replace(',', '.')
|
||||
return parseFloat(cleaned)
|
||||
}
|
||||
|
||||
export const skandiaFormat: BankFileFormat = {
|
||||
id: 'skandia',
|
||||
name: 'Skandia',
|
||||
description: 'Skandia CSV (semicolon-delimited)',
|
||||
fileExtensions: ['.csv', '.txt'],
|
||||
|
||||
detect(content: string, _filename: string): boolean {
|
||||
const prepared = prepareContent(content)
|
||||
const firstLine = prepared.split('\n')[0]?.toLowerCase().replace(/"/g, '') || ''
|
||||
if (!firstLine.includes(';')) return false
|
||||
|
||||
const fields = firstLine.split(';').map((f) => f.trim())
|
||||
|
||||
// "bankkategori" is unique to Skandia
|
||||
if (fields.some((f) => f.includes('bankkategori'))) return true
|
||||
|
||||
// "beskrivning" as a standalone column header with semicolon delimiter
|
||||
// Must also have "datum" and "belopp" to confirm it's a bank export
|
||||
// Note: Handelsbanken uses "beskrivning" only as a fallback in descIdx logic,
|
||||
// but its header detection is "reskontradatum"/"transaktionsdatum" which is checked first
|
||||
if (
|
||||
fields.some((f) => f === 'beskrivning') &&
|
||||
fields.some((f) => f === 'datum') &&
|
||||
fields.some((f) => f === 'belopp')
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
},
|
||||
|
||||
parse(content: string): BankFileParseResult {
|
||||
const prepared = prepareContent(content)
|
||||
const lines = prepared.split('\n').filter((line) => line.trim() !== '')
|
||||
|
||||
const transactions: ParsedBankTransaction[] = []
|
||||
const issues: BankFileParseIssue[] = []
|
||||
let skippedRows = 0
|
||||
|
||||
// Parse header
|
||||
const headerLine = lines[0] || ''
|
||||
const headers = headerLine.split(';').map((h) => h.trim().toLowerCase().replace(/"/g, ''))
|
||||
|
||||
const dateIdx = headers.findIndex((h) => h === 'datum')
|
||||
const descIdx = headers.findIndex((h) => h === 'beskrivning' || h === 'text')
|
||||
const amountIdx = headers.findIndex((h) => h === 'belopp')
|
||||
const balanceIdx = headers.findIndex((h) => h === 'saldo')
|
||||
|
||||
if (dateIdx === -1 || amountIdx === -1) {
|
||||
issues.push({
|
||||
row: 1,
|
||||
message: 'Could not identify required columns (datum, belopp)',
|
||||
severity: 'error',
|
||||
})
|
||||
return {
|
||||
format: 'skandia',
|
||||
format_name: 'Skandia',
|
||||
transactions: [],
|
||||
date_from: null,
|
||||
date_to: null,
|
||||
issues,
|
||||
stats: { total_rows: 0, parsed_rows: 0, skipped_rows: 0, total_income: 0, total_expenses: 0 },
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const line = lines[i].trim()
|
||||
if (!line) continue
|
||||
|
||||
const fields = line.split(';').map((f) => f.trim().replace(/^"|"$/g, ''))
|
||||
|
||||
const date = fields[dateIdx]
|
||||
const description = descIdx >= 0 ? fields[descIdx] : 'Unknown'
|
||||
const amountStr = fields[amountIdx]
|
||||
const balanceStr = balanceIdx >= 0 ? fields[balanceIdx] : undefined
|
||||
|
||||
if (!date || !amountStr) {
|
||||
skippedRows++
|
||||
continue
|
||||
}
|
||||
|
||||
const amount = parseCommaDecimal(amountStr)
|
||||
if (isNaN(amount)) {
|
||||
issues.push({ row: i + 1, message: `Invalid amount: ${amountStr}`, severity: 'warning' })
|
||||
skippedRows++
|
||||
continue
|
||||
}
|
||||
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
||||
issues.push({ row: i + 1, message: `Invalid date: ${date}`, severity: 'warning' })
|
||||
skippedRows++
|
||||
continue
|
||||
}
|
||||
|
||||
const balance = balanceStr ? parseCommaDecimal(balanceStr) : null
|
||||
|
||||
transactions.push({
|
||||
date,
|
||||
description: (description || 'Unknown').trim(),
|
||||
amount,
|
||||
currency: 'SEK',
|
||||
balance: isNaN(balance as number) ? null : balance,
|
||||
reference: null,
|
||||
counterparty: null,
|
||||
raw_line: line,
|
||||
})
|
||||
}
|
||||
|
||||
const dates = transactions.map((t) => t.date).sort()
|
||||
|
||||
return {
|
||||
format: 'skandia',
|
||||
format_name: 'Skandia',
|
||||
transactions,
|
||||
date_from: dates[0] || null,
|
||||
date_to: dates[dates.length - 1] || null,
|
||||
issues,
|
||||
stats: {
|
||||
total_rows: lines.length - 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,
|
||||
total_expenses: Math.round(transactions.filter((t) => t.amount < 0).reduce((s, t) => s + t.amount, 0) * 100) / 100,
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -11,12 +11,17 @@ import { nordeaFormat } from './formats/nordea'
|
||||
import { sebFormat } from './formats/seb'
|
||||
import { swedbankFormat } from './formats/swedbank'
|
||||
import { handelsbankenFormat } from './formats/handelsbanken'
|
||||
import { lansforsakringarFormat } from './formats/lansforsakringar'
|
||||
import { icaBankenFormat } from './formats/ica-banken'
|
||||
import { skandiaFormat } from './formats/skandia'
|
||||
import { lunarFormat } from './formats/lunar'
|
||||
import { camt053Format } from './formats/camt053'
|
||||
import { genericCSVFormat } from './formats/generic-csv'
|
||||
|
||||
/**
|
||||
* Ordered list of format detectors.
|
||||
* camt.053 first (XML detection is unambiguous), then bank-specific CSV formats.
|
||||
* New bank formats go after existing ones but before generic_csv.
|
||||
* Generic CSV is last — it never auto-detects (manual fallback only).
|
||||
*/
|
||||
const FORMATS: BankFileFormat[] = [
|
||||
@@ -25,6 +30,10 @@ const FORMATS: BankFileFormat[] = [
|
||||
sebFormat,
|
||||
swedbankFormat,
|
||||
handelsbankenFormat,
|
||||
lansforsakringarFormat,
|
||||
icaBankenFormat,
|
||||
skandiaFormat,
|
||||
lunarFormat,
|
||||
genericCSVFormat,
|
||||
]
|
||||
|
||||
|
||||
@@ -47,6 +47,10 @@ export type BankFileFormatId =
|
||||
| 'seb'
|
||||
| 'swedbank'
|
||||
| 'handelsbanken'
|
||||
| 'lansforsakringar'
|
||||
| 'ica_banken'
|
||||
| 'skandia'
|
||||
| 'lunar'
|
||||
| 'generic_csv'
|
||||
| 'camt053'
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { suggestCategory } from '@/lib/tax/expense-warnings'
|
||||
import { getExpenseAccountForCategory } from '@/lib/bookkeeping/category-mapping'
|
||||
import type { Transaction, TransactionCategory, MappingRule } from '@/types'
|
||||
import { findMatchingTemplates, type TemplateMatch } from '@/lib/bookkeeping/booking-templates'
|
||||
import type { Transaction, TransactionCategory, EntityType, MappingRule } from '@/types'
|
||||
|
||||
export interface SuggestedCategory {
|
||||
category: TransactionCategory
|
||||
@@ -177,3 +178,43 @@ export function mergeAiSuggestions(
|
||||
.sort((a, b) => b.confidence - a.confidence)
|
||||
.slice(0, 5)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Template Suggestions
|
||||
// ============================================================
|
||||
|
||||
export interface SuggestedTemplate {
|
||||
template_id: string
|
||||
name_sv: string
|
||||
name_en: string
|
||||
group: string
|
||||
debit_account: string
|
||||
credit_account: string
|
||||
confidence: number
|
||||
description_sv: string
|
||||
risk_level: string
|
||||
requires_review: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Get suggested booking templates for a transaction.
|
||||
* Uses multi-signal matching (MCC, keywords, description patterns).
|
||||
*/
|
||||
export function getSuggestedTemplates(
|
||||
transaction: Transaction,
|
||||
entityType?: EntityType
|
||||
): SuggestedTemplate[] {
|
||||
const matches = findMatchingTemplates(transaction, entityType)
|
||||
return matches.map((m: TemplateMatch) => ({
|
||||
template_id: m.template.id,
|
||||
name_sv: m.template.name_sv,
|
||||
name_en: m.template.name_en,
|
||||
group: m.template.group,
|
||||
debit_account: m.template.debit_account,
|
||||
credit_account: m.template.credit_account,
|
||||
confidence: m.confidence,
|
||||
description_sv: m.template.description_sv,
|
||||
risk_level: m.template.risk_level,
|
||||
requires_review: m.template.requires_review,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -904,6 +904,7 @@ export interface MappingRule {
|
||||
// Mapping engine result
|
||||
export interface MappingResult {
|
||||
rule: MappingRule | null
|
||||
template_id?: string
|
||||
debit_account: string
|
||||
credit_account: string
|
||||
risk_level: RiskLevel
|
||||
|
||||
Reference in New Issue
Block a user