feat: add bank file import as core, move Enable Banking to extension
Replace PSD2 bank integration as the default with file-based bank import (CSV/XML), which better suits Swedish sole traders and small companies. Enable Banking is now an opt-in extension. - Phase 1: Extract generic transaction ingestion service (ingest.ts) with dedup, auto-categorization, and OCR-based invoice matching - Phase 2: Bank file parser library supporting Nordea, SEB, Swedbank, Handelsbanken CSV formats and ISO 20022 camt.053 XML - Phase 3: Database migration adding import_source, reference columns and bank_file_imports tracking table - Phase 4: Import wizard UI (5-step flow) and API routes for parse/execute - Phase 5: Move Enable Banking to extensions/enable-banking/ with commented-out loader entry for opt-in activation - Phase 6: 104 new tests (ingestion + all parser formats), fixing Nordea detection overlap and camt.053 XML tag collision bugs Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
838dc6b8b5
commit
885f362a29
+343
-139
@@ -3,13 +3,27 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { ArrowLeftRight, FileText } from 'lucide-react'
|
||||
|
||||
// Bank file import components
|
||||
import BankFileUploadStep from '@/components/import/BankFileUploadStep'
|
||||
import BankFilePreviewStep from '@/components/import/BankFilePreviewStep'
|
||||
import BankFileColumnMappingStep from '@/components/import/BankFileColumnMappingStep'
|
||||
import BankFileConfirmStep from '@/components/import/BankFileConfirmStep'
|
||||
import BankFileResultStep from '@/components/import/BankFileResultStep'
|
||||
|
||||
// SIE import components
|
||||
import SIEUploadStep from '@/components/import/SIEUploadStep'
|
||||
import SIEPreviewStep from '@/components/import/SIEPreviewStep'
|
||||
import AccountMappingStep from '@/components/import/AccountMappingStep'
|
||||
import ImportReviewStep, { type ImportExecuteOptions } from '@/components/import/ImportReviewStep'
|
||||
import ImportResultStep from '@/components/import/ImportResultStep'
|
||||
import { applyMappingOverride } from '@/lib/import/account-mapper'
|
||||
import { getCSVHeaders, getCSVPreview } from '@/lib/import/bank-file/formats/generic-csv'
|
||||
import type { BankFileParseResult, BankFileFormatId, GenericCSVColumnMapping } from '@/lib/import/bank-file/types'
|
||||
import type { IngestResult } from '@/lib/transactions/ingest'
|
||||
import type {
|
||||
ImportWizardStep,
|
||||
ParsedSIEFile,
|
||||
@@ -20,9 +34,268 @@ import type {
|
||||
} from '@/lib/import/types'
|
||||
import type { BASAccount } from '@/types'
|
||||
|
||||
const STEPS: ImportWizardStep[] = ['upload', 'preview', 'mapping', 'review', 'result']
|
||||
// ============================================================
|
||||
// Bank File Import Wizard Steps
|
||||
// ============================================================
|
||||
|
||||
const STEP_LABELS: Record<ImportWizardStep, string> = {
|
||||
type BankFileStep = 'upload' | 'preview' | 'column_mapping' | 'confirm' | 'result'
|
||||
|
||||
const BANK_STEPS: BankFileStep[] = ['upload', 'preview', 'confirm', 'result']
|
||||
const BANK_STEPS_WITH_MAPPING: BankFileStep[] = ['upload', 'preview', 'column_mapping', 'confirm', 'result']
|
||||
|
||||
const BANK_STEP_LABELS: Record<BankFileStep, string> = {
|
||||
upload: 'Ladda upp',
|
||||
preview: 'Förhandsgranskning',
|
||||
column_mapping: 'Kolumnmappning',
|
||||
confirm: 'Bekräfta',
|
||||
result: 'Resultat',
|
||||
}
|
||||
|
||||
function BankFileImportWizard() {
|
||||
const { toast } = useToast()
|
||||
|
||||
const [bankStep, setBankStep] = useState<BankFileStep>('upload')
|
||||
const [bankIsLoading, setBankIsLoading] = useState(false)
|
||||
const [bankError, setBankError] = useState<string | null>(null)
|
||||
|
||||
// Parse results
|
||||
const [parseResult, setParseResult] = useState<BankFileParseResult | null>(null)
|
||||
const [detectedFormat, setDetectedFormat] = useState<string | null>(null)
|
||||
const [detectedFormatName, setDetectedFormatName] = useState<string | null>(null)
|
||||
const [fileHash, setFileHash] = useState<string>('')
|
||||
const [filename, setFilename] = useState<string>('')
|
||||
const [existingTxCount, setExistingTxCount] = useState(0)
|
||||
const [rawFileContent, setRawFileContent] = useState<string>('')
|
||||
|
||||
// Column mapping for generic CSV
|
||||
const [csvHeaders, setCsvHeaders] = useState<string[]>([])
|
||||
const [csvPreview, setCsvPreview] = useState<string[][]>([])
|
||||
|
||||
// Import result
|
||||
const [ingestResult, setIngestResult] = useState<IngestResult | null>(null)
|
||||
|
||||
const steps = parseResult?.format === 'generic_csv' ? BANK_STEPS_WITH_MAPPING : BANK_STEPS
|
||||
const currentStepIndex = steps.indexOf(bankStep)
|
||||
const progress = ((currentStepIndex + 1) / steps.length) * 100
|
||||
|
||||
const handleFileSelect = useCallback(async (file: File, formatOverride?: BankFileFormatId) => {
|
||||
setBankError(null)
|
||||
setBankIsLoading(true)
|
||||
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
if (formatOverride) {
|
||||
formData.append('format', formatOverride)
|
||||
}
|
||||
|
||||
const res = await fetch('/api/import/bank-file/parse', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
|
||||
const data = await res.json()
|
||||
|
||||
if (!res.ok) {
|
||||
if (data.error === 'duplicate') {
|
||||
setBankError(data.message)
|
||||
} else {
|
||||
setBankError(data.error || 'Kunde inte läsa filen')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
setParseResult(data.data.parse_result)
|
||||
setDetectedFormat(data.data.detected_format)
|
||||
setDetectedFormatName(data.data.detected_format_name)
|
||||
setFileHash(data.data.file_hash)
|
||||
setFilename(data.data.filename)
|
||||
setExistingTxCount(data.data.existing_transaction_count)
|
||||
|
||||
// Store headers for generic CSV mapping
|
||||
if (data.data.headers) {
|
||||
setCsvHeaders(data.data.headers)
|
||||
}
|
||||
|
||||
// Read raw file content for CSV preview
|
||||
const text = await file.text()
|
||||
setRawFileContent(text)
|
||||
if (data.data.parse_result.format === 'generic_csv') {
|
||||
setCsvHeaders(getCSVHeaders(text))
|
||||
setCsvPreview(getCSVPreview(text, ',', 6))
|
||||
}
|
||||
|
||||
const txCount = data.data.parse_result.transactions.length
|
||||
if (txCount > 0) {
|
||||
setBankStep('preview')
|
||||
toast({
|
||||
title: 'Fil analyserad',
|
||||
description: `${txCount} transaktioner hittades`,
|
||||
})
|
||||
} else if (data.data.parse_result.format === 'generic_csv' || !data.data.detected_format) {
|
||||
// Unrecognized format — show upload step with error
|
||||
setBankError('Kunde inte identifiera bankformatet. Välj bank manuellt eller använd "Annan CSV".')
|
||||
}
|
||||
} catch (err) {
|
||||
setBankError(err instanceof Error ? err.message : 'Kunde inte läsa filen')
|
||||
} finally {
|
||||
setBankIsLoading(false)
|
||||
}
|
||||
}, [toast])
|
||||
|
||||
const handleColumnMappingConfirm = useCallback(async (mapping: GenericCSVColumnMapping) => {
|
||||
// Re-parse with mapping via the generic CSV parser
|
||||
const { parseGenericCSV } = await import('@/lib/import/bank-file/formats/generic-csv')
|
||||
const result = parseGenericCSV(rawFileContent, mapping)
|
||||
setParseResult(result)
|
||||
setBankStep('confirm')
|
||||
}, [rawFileContent])
|
||||
|
||||
const handleExecuteImport = useCallback(async (options: { skip_duplicates: boolean; auto_categorize: boolean }) => {
|
||||
if (!parseResult) return
|
||||
|
||||
setBankIsLoading(true)
|
||||
setBankError(null)
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/import/bank-file/execute', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
transactions: parseResult.transactions,
|
||||
format: parseResult.format,
|
||||
filename,
|
||||
file_hash: fileHash,
|
||||
...options,
|
||||
}),
|
||||
})
|
||||
|
||||
const data = await res.json()
|
||||
|
||||
if (!res.ok) {
|
||||
setBankError(data.error || 'Importen misslyckades')
|
||||
return
|
||||
}
|
||||
|
||||
setIngestResult(data.data)
|
||||
setBankStep('result')
|
||||
|
||||
toast({
|
||||
title: 'Import genomförd',
|
||||
description: `${data.data.imported} transaktioner importerades`,
|
||||
})
|
||||
} catch (err) {
|
||||
setBankError(err instanceof Error ? err.message : 'Importen misslyckades')
|
||||
} finally {
|
||||
setBankIsLoading(false)
|
||||
}
|
||||
}, [parseResult, filename, fileHash, toast])
|
||||
|
||||
const handleNewImport = () => {
|
||||
setBankStep('upload')
|
||||
setParseResult(null)
|
||||
setDetectedFormat(null)
|
||||
setDetectedFormatName(null)
|
||||
setFileHash('')
|
||||
setFilename('')
|
||||
setExistingTxCount(0)
|
||||
setIngestResult(null)
|
||||
setBankError(null)
|
||||
setCsvHeaders([])
|
||||
setCsvPreview([])
|
||||
setRawFileContent('')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Progress */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
{steps.map((s, i) => (
|
||||
<span
|
||||
key={s}
|
||||
className={
|
||||
i <= currentStepIndex ? 'text-primary font-medium' : 'text-muted-foreground'
|
||||
}
|
||||
>
|
||||
{BANK_STEP_LABELS[s]}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<Progress value={progress} className="h-2" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Step content */}
|
||||
{bankStep === 'upload' && (
|
||||
<BankFileUploadStep
|
||||
onFileSelect={handleFileSelect}
|
||||
isLoading={bankIsLoading}
|
||||
error={bankError}
|
||||
detectedFormat={detectedFormat}
|
||||
detectedFormatName={detectedFormatName}
|
||||
/>
|
||||
)}
|
||||
|
||||
{bankStep === 'preview' && parseResult && (
|
||||
<BankFilePreviewStep
|
||||
parseResult={parseResult}
|
||||
existingTransactionCount={existingTxCount}
|
||||
onContinue={() => {
|
||||
if (parseResult.format === 'generic_csv') {
|
||||
setBankStep('column_mapping')
|
||||
} else {
|
||||
setBankStep('confirm')
|
||||
}
|
||||
}}
|
||||
onBack={() => setBankStep('upload')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{bankStep === 'column_mapping' && (
|
||||
<BankFileColumnMappingStep
|
||||
headers={csvHeaders}
|
||||
previewRows={csvPreview}
|
||||
onConfirm={handleColumnMappingConfirm}
|
||||
onBack={() => setBankStep('preview')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{bankStep === 'confirm' && parseResult && (
|
||||
<BankFileConfirmStep
|
||||
parseResult={parseResult}
|
||||
onExecute={handleExecuteImport}
|
||||
onBack={() => {
|
||||
if (parseResult.format === 'generic_csv') {
|
||||
setBankStep('column_mapping')
|
||||
} else {
|
||||
setBankStep('preview')
|
||||
}
|
||||
}}
|
||||
isLoading={bankIsLoading}
|
||||
/>
|
||||
)}
|
||||
|
||||
{bankStep === 'result' && ingestResult && (
|
||||
<BankFileResultStep
|
||||
result={ingestResult}
|
||||
onNewImport={handleNewImport}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// SIE Import Wizard (unchanged, extracted into component)
|
||||
// ============================================================
|
||||
|
||||
const SIE_STEPS: ImportWizardStep[] = ['upload', 'preview', 'mapping', 'review', 'result']
|
||||
|
||||
const SIE_STEP_LABELS: Record<ImportWizardStep, string> = {
|
||||
upload: 'Ladda upp',
|
||||
preview: 'Förhandsgranskning',
|
||||
mapping: 'Kontomappning',
|
||||
@@ -30,15 +303,13 @@ const STEP_LABELS: Record<ImportWizardStep, string> = {
|
||||
result: 'Resultat',
|
||||
}
|
||||
|
||||
export default function ImportPage() {
|
||||
function SIEImportWizard() {
|
||||
const { toast } = useToast()
|
||||
|
||||
// Wizard state
|
||||
const [step, setStep] = useState<ImportWizardStep>('upload')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Data state
|
||||
const [file, setFile] = useState<File | null>(null)
|
||||
const [_parsed, setParsed] = useState<ParsedSIEFile | null>(null)
|
||||
const [mappings, setMappings] = useState<AccountMapping[]>([])
|
||||
@@ -49,11 +320,9 @@ export default function ImportPage() {
|
||||
const [_sieAccounts, setSieAccounts] = useState<{ number: string; name: string }[]>([])
|
||||
const [isCreatingAccounts, setIsCreatingAccounts] = useState(false)
|
||||
|
||||
// Calculate progress
|
||||
const currentStepIndex = STEPS.indexOf(step)
|
||||
const progress = ((currentStepIndex + 1) / STEPS.length) * 100
|
||||
const currentStepIndex = SIE_STEPS.indexOf(step)
|
||||
const progress = ((currentStepIndex + 1) / SIE_STEPS.length) * 100
|
||||
|
||||
// Handle file selection and parsing
|
||||
const handleFileSelect = useCallback(async (selectedFile: File) => {
|
||||
setFile(selectedFile)
|
||||
setError(null)
|
||||
@@ -81,7 +350,6 @@ export default function ImportPage() {
|
||||
return
|
||||
}
|
||||
|
||||
// Store parsed data
|
||||
setParsed({
|
||||
header: data.parsed.header,
|
||||
accounts: data.parsed.accounts,
|
||||
@@ -97,14 +365,12 @@ export default function ImportPage() {
|
||||
setIssues(data.parsed.issues)
|
||||
setSieAccounts(data.parsed.accounts)
|
||||
|
||||
// Fetch BAS accounts for the mapping step
|
||||
const accountsRes = await fetch('/api/bookkeeping/accounts')
|
||||
if (accountsRes.ok) {
|
||||
const accountsData = await accountsRes.json()
|
||||
setBasAccounts(accountsData.data || [])
|
||||
}
|
||||
|
||||
// Move to preview step
|
||||
setStep('preview')
|
||||
|
||||
toast({
|
||||
@@ -118,11 +384,9 @@ export default function ImportPage() {
|
||||
}
|
||||
}, [toast])
|
||||
|
||||
// Handle mapping changes
|
||||
const handleMappingChange = useCallback((sourceAccount: string, targetAccount: string, targetName: string) => {
|
||||
setMappings((prev) => applyMappingOverride(prev, sourceAccount, targetAccount, targetName))
|
||||
|
||||
// Update preview mapping status
|
||||
setPreview((prev) => {
|
||||
if (!prev) return prev
|
||||
const updatedMappings = applyMappingOverride(mappings, sourceAccount, targetAccount, targetName)
|
||||
@@ -142,12 +406,10 @@ export default function ImportPage() {
|
||||
})
|
||||
}, [mappings])
|
||||
|
||||
// Calculate missing accounts (unmapped accounts that could be created)
|
||||
const missingAccounts = mappings
|
||||
.filter((m) => !m.targetAccount)
|
||||
.map((m) => ({ number: m.sourceAccount, name: m.sourceName }))
|
||||
|
||||
// Handle creating missing accounts
|
||||
const handleCreateAccounts = useCallback(async () => {
|
||||
if (missingAccounts.length === 0) return
|
||||
|
||||
@@ -163,36 +425,23 @@ export default function ImportPage() {
|
||||
const data = await res.json()
|
||||
|
||||
if (!res.ok) {
|
||||
toast({
|
||||
title: 'Fel',
|
||||
description: data.error || 'Kunde inte skapa konton',
|
||||
variant: 'destructive',
|
||||
})
|
||||
toast({ title: 'Fel', description: data.error || 'Kunde inte skapa konton', variant: 'destructive' })
|
||||
return
|
||||
}
|
||||
|
||||
toast({
|
||||
title: 'Konton skapade',
|
||||
description: `${data.created} nya konton har lagts till i din kontoplan`,
|
||||
})
|
||||
toast({ title: 'Konton skapade', description: `${data.created} nya konton har lagts till i din kontoplan` })
|
||||
|
||||
// Re-parse the file to get updated mappings
|
||||
if (file) {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
const parseRes = await fetch('/api/import/sie/parse', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
|
||||
const parseRes = await fetch('/api/import/sie/parse', { method: 'POST', body: formData })
|
||||
const parseData = await parseRes.json()
|
||||
|
||||
if (parseRes.ok) {
|
||||
setMappings(parseData.mappings)
|
||||
setPreview(parseData.preview)
|
||||
|
||||
// Refresh BAS accounts
|
||||
const accountsRes = await fetch('/api/bookkeeping/accounts')
|
||||
if (accountsRes.ok) {
|
||||
const accountsData = await accountsRes.json()
|
||||
@@ -201,22 +450,14 @@ export default function ImportPage() {
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Fel',
|
||||
description: err instanceof Error ? err.message : 'Kunde inte skapa konton',
|
||||
variant: 'destructive',
|
||||
})
|
||||
toast({ title: 'Fel', description: err instanceof Error ? err.message : 'Kunde inte skapa konton', variant: 'destructive' })
|
||||
} finally {
|
||||
setIsCreatingAccounts(false)
|
||||
}
|
||||
}, [missingAccounts, file, toast])
|
||||
|
||||
// Handle import execution
|
||||
const handleExecuteImport = useCallback(async (options: ImportExecuteOptions) => {
|
||||
if (!file) {
|
||||
setError('No file selected')
|
||||
return
|
||||
}
|
||||
if (!file) { setError('No file selected'); return }
|
||||
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
@@ -227,32 +468,19 @@ export default function ImportPage() {
|
||||
formData.append('mappings', JSON.stringify(mappings))
|
||||
formData.append('options', JSON.stringify(options))
|
||||
|
||||
const res = await fetch('/api/import/sie/execute', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
|
||||
const res = await fetch('/api/import/sie/execute', { method: 'POST', body: formData })
|
||||
const data = await res.json()
|
||||
|
||||
if (!res.ok) {
|
||||
if (data.result) {
|
||||
setImportResult(data.result)
|
||||
} else {
|
||||
setError(data.error || 'Import failed')
|
||||
return
|
||||
}
|
||||
if (data.result) { setImportResult(data.result) } else { setError(data.error || 'Import failed'); return }
|
||||
} else {
|
||||
setImportResult(data.result)
|
||||
}
|
||||
|
||||
// Move to result step
|
||||
setStep('result')
|
||||
|
||||
if (data.result?.success) {
|
||||
toast({
|
||||
title: 'Import genomförd',
|
||||
description: `${data.result.journalEntriesCreated} verifikationer skapades`,
|
||||
})
|
||||
toast({ title: 'Import genomförd', description: `${data.result.journalEntriesCreated} verifikationer skapades` })
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Import failed')
|
||||
@@ -261,56 +489,24 @@ export default function ImportPage() {
|
||||
}
|
||||
}, [file, mappings, toast])
|
||||
|
||||
// Navigation handlers
|
||||
const goToStep = (targetStep: ImportWizardStep) => {
|
||||
setStep(targetStep)
|
||||
setError(null)
|
||||
}
|
||||
|
||||
const goBack = () => {
|
||||
const currentIndex = STEPS.indexOf(step)
|
||||
if (currentIndex > 0) {
|
||||
setStep(STEPS[currentIndex - 1])
|
||||
}
|
||||
}
|
||||
const goToStep = (targetStep: ImportWizardStep) => { setStep(targetStep); setError(null) }
|
||||
const goBack = () => { const i = SIE_STEPS.indexOf(step); if (i > 0) setStep(SIE_STEPS[i - 1]) }
|
||||
|
||||
const handleNewImport = () => {
|
||||
// Reset all state
|
||||
setStep('upload')
|
||||
setFile(null)
|
||||
setParsed(null)
|
||||
setMappings([])
|
||||
setPreview(null)
|
||||
setIssues([])
|
||||
setImportResult(null)
|
||||
setError(null)
|
||||
setSieAccounts([])
|
||||
setIsCreatingAccounts(false)
|
||||
setStep('upload'); setFile(null); setParsed(null); setMappings([])
|
||||
setPreview(null); setIssues([]); setImportResult(null); setError(null)
|
||||
setSieAccounts([]); setIsCreatingAccounts(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Importera bokföring</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Migrera din bokföring från Fortnox, Visma eller annat bokföringssystem via SIE-fil
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Progress */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
{STEPS.map((s, i) => (
|
||||
<span
|
||||
key={s}
|
||||
className={`${
|
||||
i <= currentStepIndex ? 'text-primary font-medium' : 'text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{STEP_LABELS[s]}
|
||||
{SIE_STEPS.map((s, i) => (
|
||||
<span key={s} className={i <= currentStepIndex ? 'text-primary font-medium' : 'text-muted-foreground'}>
|
||||
{SIE_STEP_LABELS[s]}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
@@ -319,53 +515,61 @@ export default function ImportPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Step content */}
|
||||
{step === 'upload' && (
|
||||
<SIEUploadStep
|
||||
onFileSelect={handleFileSelect}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
/>
|
||||
)}
|
||||
|
||||
{step === 'upload' && <SIEUploadStep onFileSelect={handleFileSelect} isLoading={isLoading} error={error} />}
|
||||
{step === 'preview' && preview && (
|
||||
<SIEPreviewStep
|
||||
preview={preview}
|
||||
issues={issues}
|
||||
missingAccounts={missingAccounts}
|
||||
onCreateAccounts={handleCreateAccounts}
|
||||
isCreatingAccounts={isCreatingAccounts}
|
||||
onContinue={() => goToStep('mapping')}
|
||||
onBack={goBack}
|
||||
/>
|
||||
<SIEPreviewStep preview={preview} issues={issues} missingAccounts={missingAccounts}
|
||||
onCreateAccounts={handleCreateAccounts} isCreatingAccounts={isCreatingAccounts}
|
||||
onContinue={() => goToStep('mapping')} onBack={goBack} />
|
||||
)}
|
||||
|
||||
{step === 'mapping' && (
|
||||
<AccountMappingStep
|
||||
mappings={mappings}
|
||||
basAccounts={basAccounts}
|
||||
onMappingChange={handleMappingChange}
|
||||
onContinue={() => goToStep('review')}
|
||||
onBack={goBack}
|
||||
/>
|
||||
<AccountMappingStep mappings={mappings} basAccounts={basAccounts}
|
||||
onMappingChange={handleMappingChange} onContinue={() => goToStep('review')} onBack={goBack} />
|
||||
)}
|
||||
|
||||
{step === 'review' && preview && (
|
||||
<ImportReviewStep
|
||||
preview={preview}
|
||||
mappings={mappings}
|
||||
onExecute={handleExecuteImport}
|
||||
onBack={goBack}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
)}
|
||||
|
||||
{step === 'result' && importResult && (
|
||||
<ImportResultStep
|
||||
result={importResult}
|
||||
onNewImport={handleNewImport}
|
||||
/>
|
||||
<ImportReviewStep preview={preview} mappings={mappings}
|
||||
onExecute={handleExecuteImport} onBack={goBack} isLoading={isLoading} />
|
||||
)}
|
||||
{step === 'result' && importResult && <ImportResultStep result={importResult} onNewImport={handleNewImport} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Import Page with Tabs
|
||||
// ============================================================
|
||||
|
||||
export default function ImportPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Importera</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Importera banktransaktioner eller bokföringsdata till ditt företag
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Tabbed layout */}
|
||||
<Tabs defaultValue="bank" className="space-y-6">
|
||||
<TabsList>
|
||||
<TabsTrigger value="bank">
|
||||
<ArrowLeftRight className="mr-2 h-4 w-4" />
|
||||
Banktransaktioner
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="sie">
|
||||
<FileText className="mr-2 h-4 w-4" />
|
||||
Bokföringsdata (SIE)
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="bank">
|
||||
<BankFileImportWizard />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="sie">
|
||||
<SIEImportWizard />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -47,17 +47,16 @@ export default async function DashboardPage() {
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.eq('user_id', user.id)
|
||||
|
||||
const { count: bankConnectionCount } = await supabase
|
||||
.from('bank_connections')
|
||||
const { count: transactionCount } = await supabase
|
||||
.from('transactions')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.eq('user_id', user.id)
|
||||
.eq('status', 'active')
|
||||
|
||||
const onboardingProgress: OnboardingProgress = {
|
||||
hasCustomers: (customerCount || 0) > 0,
|
||||
hasInvoices: (invoiceCount || 0) > 0,
|
||||
hasReceipts: (receiptCount || 0) > 0,
|
||||
hasBankConnected: (bankConnectionCount || 0) > 0,
|
||||
hasBankConnected: (transactionCount || 0) > 0,
|
||||
}
|
||||
|
||||
// Fetch current year transactions summary
|
||||
|
||||
@@ -11,7 +11,6 @@ import { Badge } from '@/components/ui/badge'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { formatDate } from '@/lib/utils'
|
||||
import { getDaysUntilExpiry, isConsentExpiringSoon } from '@/lib/banking/enable-banking'
|
||||
import {
|
||||
Loader2,
|
||||
Building,
|
||||
@@ -25,7 +24,6 @@ import {
|
||||
Calendar,
|
||||
} from 'lucide-react'
|
||||
import type { CompanySettings, BankConnection } from '@/types'
|
||||
import { BankSelector, type Bank } from '@/components/banking/BankSelector'
|
||||
import { NotificationSettings } from '@/extensions/push-notifications/NotificationSettings'
|
||||
import { CalendarFeedSettings } from '@/components/settings/CalendarFeedSettings'
|
||||
|
||||
@@ -41,6 +39,7 @@ export default function SettingsPage() {
|
||||
const [bankConnections, setBankConnections] = useState<BankConnection[]>([])
|
||||
const [isSyncing, setIsSyncing] = useState(false)
|
||||
const [isConnecting, setIsConnecting] = useState(false)
|
||||
const [hasBankingExtension, setHasBankingExtension] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
@@ -85,7 +84,8 @@ export default function SettingsPage() {
|
||||
|
||||
setSettings(settingsData)
|
||||
|
||||
// Fetch bank connections
|
||||
// Check if Enable Banking extension is active by testing for bank connections
|
||||
// If there are active connections, show the banking tab
|
||||
const { data: connections } = await supabase
|
||||
.from('bank_connections')
|
||||
.select('*')
|
||||
@@ -94,6 +94,21 @@ export default function SettingsPage() {
|
||||
|
||||
setBankConnections(connections || [])
|
||||
|
||||
// Check if extension API is available (will return banks list if extension is loaded)
|
||||
try {
|
||||
const bankingCheck = await fetch('/api/extensions/enable-banking/callback', {
|
||||
method: 'HEAD',
|
||||
})
|
||||
// The extension route existing means it's deployed - but we also need
|
||||
// to check if there are existing connections to show the tab
|
||||
setHasBankingExtension(
|
||||
(connections && connections.length > 0) || bankingCheck.status !== 404
|
||||
)
|
||||
} catch {
|
||||
// If the extension routes don't exist, only show tab if there are existing connections
|
||||
setHasBankingExtension((connections && connections.length > 0) || false)
|
||||
}
|
||||
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
@@ -150,14 +165,14 @@ export default function SettingsPage() {
|
||||
setIsSaving(false)
|
||||
}
|
||||
|
||||
async function handleConnectBank(bank: Bank) {
|
||||
async function handleConnectBank(bankName: string, bankCountry: string) {
|
||||
setIsConnecting(true)
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/banking/connect', {
|
||||
const response = await fetch('/api/extensions/enable-banking/callback', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ aspsp_name: bank.name, aspsp_country: bank.country }),
|
||||
body: JSON.stringify({ aspsp_name: bankName, aspsp_country: bankCountry }),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
@@ -182,7 +197,7 @@ export default function SettingsPage() {
|
||||
setIsSyncing(true)
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/banking/sync', {
|
||||
const response = await fetch('/api/extensions/enable-banking/sync/cron', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ connection_id: connectionId }),
|
||||
@@ -247,6 +262,24 @@ export default function SettingsPage() {
|
||||
|
||||
const activeConnections = bankConnections.filter((c) => c.status === 'active')
|
||||
|
||||
// Helper to calculate days until consent expires
|
||||
function getDaysUntilExpiry(expiresAt: string | null): number | null {
|
||||
if (!expiresAt) return null
|
||||
const expiryDate = new Date(expiresAt)
|
||||
const now = new Date()
|
||||
const diffTime = expiryDate.getTime() - now.getTime()
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24))
|
||||
return Math.max(0, diffDays)
|
||||
}
|
||||
|
||||
function isConsentExpiringSoon(expiresAt: string | null): boolean {
|
||||
if (!expiresAt) return false
|
||||
const expiryDate = new Date(expiresAt)
|
||||
const warningDate = new Date()
|
||||
warningDate.setDate(warningDate.getDate() + 7)
|
||||
return expiryDate <= warningDate
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
@@ -262,10 +295,12 @@ export default function SettingsPage() {
|
||||
<Building className="mr-2 h-4 w-4" />
|
||||
Företag
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="banking">
|
||||
<CreditCard className="mr-2 h-4 w-4" />
|
||||
Bank
|
||||
</TabsTrigger>
|
||||
{hasBankingExtension && (
|
||||
<TabsTrigger value="banking">
|
||||
<CreditCard className="mr-2 h-4 w-4" />
|
||||
Bank (PSD2)
|
||||
</TabsTrigger>
|
||||
)}
|
||||
<TabsTrigger value="notifications">
|
||||
<Bell className="mr-2 h-4 w-4" />
|
||||
Aviseringar
|
||||
@@ -469,93 +504,91 @@ export default function SettingsPage() {
|
||||
</form>
|
||||
</TabsContent>
|
||||
|
||||
{/* Banking settings */}
|
||||
<TabsContent value="banking" className="space-y-6">
|
||||
{/* Connected banks */}
|
||||
{activeConnections.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Anslutna banker</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{activeConnections.map((connection) => {
|
||||
const daysUntilExpiry = getDaysUntilExpiry(connection.consent_expires)
|
||||
const isExpiring = isConsentExpiringSoon(connection.consent_expires)
|
||||
{/* Banking settings (only shown when extension is active or connections exist) */}
|
||||
{hasBankingExtension && (
|
||||
<TabsContent value="banking" className="space-y-6">
|
||||
{/* Connected banks */}
|
||||
{activeConnections.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Anslutna banker</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{activeConnections.map((connection) => {
|
||||
const daysUntilExpiry = getDaysUntilExpiry(connection.consent_expires)
|
||||
const isExpiring = isConsentExpiringSoon(connection.consent_expires)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={connection.id}
|
||||
className="flex items-center justify-between p-4 border rounded-lg"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="h-10 w-10 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<CreditCard className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">{connection.bank_name}</p>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>
|
||||
Senast synkad: {formatDate(connection.last_synced_at || connection.created_at)}
|
||||
</span>
|
||||
{isExpiring && (
|
||||
<Badge variant="warning" className="flex items-center gap-1">
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
{daysUntilExpiry} dagar kvar
|
||||
</Badge>
|
||||
)}
|
||||
return (
|
||||
<div
|
||||
key={connection.id}
|
||||
className="flex items-center justify-between p-4 border rounded-lg"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="h-10 w-10 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<CreditCard className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">{connection.bank_name}</p>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>
|
||||
Senast synkad: {formatDate(connection.last_synced_at || connection.created_at)}
|
||||
</span>
|
||||
{isExpiring && (
|
||||
<Badge variant="warning" className="flex items-center gap-1">
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
{daysUntilExpiry} dagar kvar
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleSyncTransactions(connection.id)}
|
||||
disabled={isSyncing}
|
||||
>
|
||||
{isSyncing ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDisconnectBank(connection.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleSyncTransactions(connection.id)}
|
||||
disabled={isSyncing}
|
||||
>
|
||||
{isSyncing ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDisconnectBank(connection.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
)
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Info about PSD2 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Bankintegration (PSD2)</CardTitle>
|
||||
<CardDescription>
|
||||
Automatisk import av transaktioner via PSD2 open banking.
|
||||
Samtycket gäller i 90 dagar och behöver sedan förnyas.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Vi använder säker bankintegration (PSD2). Vi kan endast läsa transaktioner,
|
||||
aldrig flytta pengar. Du kan också importera transaktioner manuellt via
|
||||
bankfiler på importsidan.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Connect new bank */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Anslut bank</CardTitle>
|
||||
<CardDescription>
|
||||
Koppla din bank för att automatiskt importera transaktioner via PSD2
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<BankSelector
|
||||
onSelect={handleConnectBank}
|
||||
isLoading={isConnecting}
|
||||
country="SE"
|
||||
sandbox={true}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground mt-4">
|
||||
Vi använder säker bankintegration (PSD2). Vi kan endast läsa transaktioner,
|
||||
aldrig flytta pengar.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{/* Notification settings */}
|
||||
<TabsContent value="notifications">
|
||||
|
||||
@@ -11,7 +11,8 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, Dialog
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { formatCurrency, formatDate } from '@/lib/utils'
|
||||
import { getCategoryDisplayName } from '@/lib/tax/expense-warnings'
|
||||
import { Plus, Search, ArrowLeftRight, ArrowUpRight, ArrowDownRight, Sparkles, Check, FileText, Link2 } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { Plus, Search, ArrowLeftRight, ArrowUpRight, ArrowDownRight, Sparkles, Check, FileText, Link2, Upload } from 'lucide-react'
|
||||
import TransactionForm from '@/components/transactions/TransactionForm'
|
||||
import SwipeCategorizationView from '@/components/transactions/SwipeCategorizationView'
|
||||
import type { Transaction, TransactionCategory, CreateTransactionInput, Invoice, Customer } from '@/types'
|
||||
@@ -408,6 +409,12 @@ export default function TransactionsPage() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/import">
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
Importera
|
||||
</Link>
|
||||
</Button>
|
||||
{uncategorizedTransactions.length > 0 && (
|
||||
<Button variant="outline" onClick={openSwipeView} disabled={isLoadingSuggestions}>
|
||||
<Sparkles className="mr-2 h-4 w-4" />
|
||||
@@ -492,13 +499,21 @@ export default function TransactionsPage() {
|
||||
<p className="text-muted-foreground text-center mt-1">
|
||||
{searchTerm
|
||||
? 'Inga transaktioner matchar din sökning'
|
||||
: 'Lägg till din första transaktion eller anslut din bank'}
|
||||
: 'Importera transaktioner från din bank eller lägg till manuellt'}
|
||||
</p>
|
||||
{!searchTerm && (
|
||||
<Button className="mt-4" onClick={() => setIsDialogOpen(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Lägg till transaktion
|
||||
</Button>
|
||||
<div className="flex gap-2 mt-4">
|
||||
<Button asChild>
|
||||
<Link href="/import">
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
Importera transaktioner
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setIsDialogOpen(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Lägg till manuellt
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { startAuthorization, getASPSPs, type ASPSP } from '@/lib/banking/enable-banking'
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const aspsps = await getASPSPs('SE')
|
||||
|
||||
// Transform to frontend-friendly format
|
||||
const banks = aspsps.map((aspsp: ASPSP) => ({
|
||||
name: aspsp.name,
|
||||
country: aspsp.country,
|
||||
logo: aspsp.logo,
|
||||
bic: aspsp.bic,
|
||||
}))
|
||||
|
||||
return NextResponse.json({ banks })
|
||||
} catch (error) {
|
||||
console.error('Error fetching banks:', error)
|
||||
// Return fallback list
|
||||
return NextResponse.json({
|
||||
banks: [
|
||||
{ name: 'Nordea', country: 'SE', bic: 'NDEASESS' },
|
||||
{ name: 'SEB', country: 'SE', bic: 'ESSESESS' },
|
||||
{ name: 'Swedbank', country: 'SE', bic: 'SWEDSESS' },
|
||||
{ name: 'Handelsbanken', country: 'SE', bic: 'HANDSESS' },
|
||||
]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const supabase = await createClient()
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { aspsp_name, aspsp_country } = await request.json()
|
||||
|
||||
if (!aspsp_name || !aspsp_country) {
|
||||
return NextResponse.json(
|
||||
{ error: 'aspsp_name and aspsp_country are required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const redirectUrl = `${process.env.NEXT_PUBLIC_APP_URL}/api/banking/callback`
|
||||
|
||||
// Start the authorization flow with Enable Banking
|
||||
const { url, authorization_id } = await startAuthorization(
|
||||
aspsp_name,
|
||||
aspsp_country,
|
||||
redirectUrl,
|
||||
user.id, // state parameter - returned in callback
|
||||
'personal'
|
||||
)
|
||||
|
||||
// Store pending connection in database with authorization_id
|
||||
// Note: session_id will be set after callback receives the code
|
||||
const { data: connection, error } = await supabase
|
||||
.from('bank_connections')
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
provider: `${aspsp_name.toLowerCase().replace(/\s+/g, '-')}-${aspsp_country.toLowerCase()}`,
|
||||
bank_name: aspsp_name,
|
||||
authorization_id,
|
||||
status: 'pending',
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
console.error('Database error:', error)
|
||||
throw new Error('Failed to store connection')
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
connection_id: connection.id,
|
||||
authorization_url: url,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Bank connection error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Connection failed' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { eventBus } from '@/lib/events'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { syncAccountTransactions } from '@/lib/banking/sync-transactions'
|
||||
import type { Transaction } from '@/types'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
interface StoredAccount {
|
||||
uid: string
|
||||
iban?: string
|
||||
name?: string
|
||||
currency: string
|
||||
balance?: number
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const supabase = await createClient()
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { connection_id, days_back = 30 } = await request.json()
|
||||
|
||||
// Get the bank connection
|
||||
const { data: connection, error: connectionError } = await supabase
|
||||
.from('bank_connections')
|
||||
.select('*')
|
||||
.eq('id', connection_id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (connectionError || !connection) {
|
||||
return NextResponse.json({ error: 'Connection not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (connection.status !== 'active') {
|
||||
return NextResponse.json({ error: 'Connection is not active' }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
const accounts = (connection.accounts_data as StoredAccount[] || []).map(a => ({ ...a }))
|
||||
|
||||
const toDate = new Date().toISOString().split('T')[0]
|
||||
const fromDate = new Date(Date.now() - days_back * 24 * 60 * 60 * 1000)
|
||||
.toISOString()
|
||||
.split('T')[0]
|
||||
|
||||
let totalImported = 0
|
||||
let totalDuplicates = 0
|
||||
|
||||
for (const account of accounts) {
|
||||
const result = await syncAccountTransactions(
|
||||
supabase,
|
||||
user.id,
|
||||
connection.id,
|
||||
account,
|
||||
fromDate,
|
||||
toDate
|
||||
)
|
||||
|
||||
totalImported += result.imported
|
||||
totalDuplicates += result.duplicates
|
||||
}
|
||||
|
||||
// Update connection with new account balances and sync timestamp
|
||||
const syncedAt = new Date().toISOString()
|
||||
await supabase
|
||||
.from('bank_connections')
|
||||
.update({
|
||||
accounts_data: accounts,
|
||||
last_synced_at: syncedAt,
|
||||
})
|
||||
.eq('id', connection.id)
|
||||
|
||||
// Emit event with newly synced transactions
|
||||
if (totalImported > 0) {
|
||||
const { data: syncedTransactions } = await supabase
|
||||
.from('transactions')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
.eq('bank_connection_id', connection.id)
|
||||
.gte('created_at', fromDate)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(totalImported)
|
||||
|
||||
if (syncedTransactions && syncedTransactions.length > 0) {
|
||||
await eventBus.emit({
|
||||
type: 'transaction.synced',
|
||||
payload: { transactions: syncedTransactions as Transaction[], userId: user.id },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
imported: totalImported,
|
||||
duplicates: totalDuplicates,
|
||||
last_synced_at: syncedAt,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Sync error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Sync failed' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
+9
-22
@@ -1,28 +1,25 @@
|
||||
import { createServiceClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createSession, getAccountBalance, type AccountInfo } from '@/lib/banking/enable-banking'
|
||||
|
||||
interface StoredAccount {
|
||||
uid: string
|
||||
iban?: string
|
||||
name?: string
|
||||
currency: string
|
||||
balance?: number
|
||||
}
|
||||
import { createSession, getAccountBalance, type AccountInfo } from '@/extensions/enable-banking/lib/api-client'
|
||||
import type { StoredAccount } from '@/extensions/enable-banking/types'
|
||||
|
||||
/**
|
||||
* GET /api/extensions/enable-banking/callback
|
||||
*
|
||||
* OAuth callback for Enable Banking PSD2 authorization.
|
||||
* Must be a real Next.js route (not extension handler) because
|
||||
* banks redirect to this URL directly.
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url)
|
||||
|
||||
// Enable Banking returns: ?code=XXX&state=user_id or ?error=XXX&error_description=YYY
|
||||
const code = searchParams.get('code')
|
||||
const state = searchParams.get('state') // This is the user_id we passed during authorization
|
||||
const error = searchParams.get('error')
|
||||
const errorDescription = searchParams.get('error_description')
|
||||
|
||||
// Redirect URL for success/error
|
||||
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'
|
||||
|
||||
// Handle errors from bank authorization
|
||||
if (error) {
|
||||
const errorMessage = errorDescription || error
|
||||
console.error('Bank authorization error:', errorMessage)
|
||||
@@ -38,14 +35,10 @@ export async function GET(request: Request) {
|
||||
const supabase = await createServiceClient()
|
||||
|
||||
try {
|
||||
// Create session from authorization code
|
||||
const sessionData = await createSession(code)
|
||||
|
||||
// Extract data from session response
|
||||
const { session_id, accounts, access, aspsp } = sessionData
|
||||
const consentExpiresAt = access.valid_until
|
||||
|
||||
// Get balances for each account
|
||||
const accountsWithBalances: StoredAccount[] = await Promise.all(
|
||||
accounts.map(async (account: AccountInfo) => {
|
||||
try {
|
||||
@@ -70,8 +63,6 @@ export async function GET(request: Request) {
|
||||
})
|
||||
)
|
||||
|
||||
// Find the pending connection for this user
|
||||
// We match by user_id (state) and status='pending'
|
||||
const { data: pendingConnection, error: findError } = await supabase
|
||||
.from('bank_connections')
|
||||
.select('id')
|
||||
@@ -83,7 +74,6 @@ export async function GET(request: Request) {
|
||||
|
||||
if (findError || !pendingConnection) {
|
||||
console.error('Could not find pending connection:', findError)
|
||||
// Create a new connection if no pending one exists
|
||||
const { error: insertError } = await supabase
|
||||
.from('bank_connections')
|
||||
.insert({
|
||||
@@ -102,7 +92,6 @@ export async function GET(request: Request) {
|
||||
throw new Error('Failed to create connection')
|
||||
}
|
||||
} else {
|
||||
// Update the pending connection with session data
|
||||
const { error: updateError } = await supabase
|
||||
.from('bank_connections')
|
||||
.update({
|
||||
@@ -119,7 +108,6 @@ export async function GET(request: Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// Check if the user has completed onboarding to decide redirect target
|
||||
const { data: userSettings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('onboarding_complete')
|
||||
@@ -134,7 +122,6 @@ export async function GET(request: Request) {
|
||||
} catch (error) {
|
||||
console.error('Bank callback error:', error)
|
||||
|
||||
// Try to update connection status to error
|
||||
try {
|
||||
await supabase
|
||||
.from('bank_connections')
|
||||
+4
-17
@@ -1,18 +1,11 @@
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { syncAccountTransactions } from '@/lib/banking/sync-transactions'
|
||||
import { isConsentExpiringSoon, getDaysUntilExpiry } from '@/lib/banking/enable-banking'
|
||||
|
||||
interface StoredAccount {
|
||||
uid: string
|
||||
iban?: string
|
||||
name?: string
|
||||
currency: string
|
||||
balance?: number
|
||||
}
|
||||
import { syncAccountTransactions } from '@/extensions/enable-banking/lib/sync'
|
||||
import { isConsentExpiringSoon, getDaysUntilExpiry } from '@/extensions/enable-banking/lib/api-client'
|
||||
import type { StoredAccount } from '@/extensions/enable-banking/types'
|
||||
|
||||
/**
|
||||
* GET /api/banking/sync/cron
|
||||
* GET /api/extensions/enable-banking/sync/cron
|
||||
* Automatic daily bank transaction sync
|
||||
* Runs at 05:00 UTC (07:00 Swedish time)
|
||||
*
|
||||
@@ -41,7 +34,6 @@ export async function GET(request: Request) {
|
||||
|
||||
const supabase = createClient(supabaseUrl, supabaseServiceKey)
|
||||
|
||||
// Fetch active bank connections, prioritize least recently synced
|
||||
const { data: connections, error: connError } = await supabase
|
||||
.from('bank_connections')
|
||||
.select('*')
|
||||
@@ -71,12 +63,10 @@ export async function GET(request: Request) {
|
||||
|
||||
for (const connection of connections) {
|
||||
try {
|
||||
// Check consent expiry
|
||||
const daysLeft = getDaysUntilExpiry(connection.consent_expires)
|
||||
const isExpired = daysLeft !== null && daysLeft <= 0
|
||||
|
||||
if (isExpired) {
|
||||
// Mark as expired, skip sync
|
||||
await supabase
|
||||
.from('bank_connections')
|
||||
.update({ status: 'expired' })
|
||||
@@ -97,7 +87,6 @@ export async function GET(request: Request) {
|
||||
|
||||
const expiringSoon = isConsentExpiringSoon(connection.consent_expires)
|
||||
|
||||
// Sync last 7 days (daily cron, with overlap for safety)
|
||||
const toDate = new Date().toISOString().split('T')[0]
|
||||
const fromDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
|
||||
.toISOString()
|
||||
@@ -124,7 +113,6 @@ export async function GET(request: Request) {
|
||||
totalErrors += result.errors
|
||||
}
|
||||
|
||||
// Update connection with new account balances and sync timestamp
|
||||
await supabase
|
||||
.from('bank_connections')
|
||||
.update({
|
||||
@@ -154,7 +142,6 @@ export async function GET(request: Request) {
|
||||
errors: 1,
|
||||
status: 'error',
|
||||
})
|
||||
// Continue with other connections
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { eventBus } from '@/lib/events'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { ingestTransactions, type RawTransaction } from '@/lib/transactions/ingest'
|
||||
import { generateExternalId } from '@/lib/import/bank-file/parser'
|
||||
import type { ParsedBankTransaction, BankFileFormatId } from '@/lib/import/bank-file/types'
|
||||
import type { Transaction } from '@/types'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
interface ExecuteRequest {
|
||||
transactions: ParsedBankTransaction[]
|
||||
format: BankFileFormatId
|
||||
filename: string
|
||||
file_hash: string
|
||||
skip_duplicates: boolean
|
||||
auto_categorize: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/import/bank-file/execute
|
||||
*
|
||||
* Executes the import of confirmed bank transactions.
|
||||
* Records import in bank_file_imports, calls ingestTransactions(),
|
||||
* emits transaction.synced event.
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
const supabase = await createClient()
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body: ExecuteRequest = await request.json()
|
||||
const { transactions, format, filename, file_hash, skip_duplicates = true, auto_categorize = true } = body
|
||||
|
||||
if (!transactions || transactions.length === 0) {
|
||||
return NextResponse.json({ error: 'No transactions to import' }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
// Create import record
|
||||
const { data: importRecord, error: importError } = await supabase
|
||||
.from('bank_file_imports')
|
||||
.upsert({
|
||||
user_id: user.id,
|
||||
filename,
|
||||
file_hash,
|
||||
file_format: format,
|
||||
transaction_count: transactions.length,
|
||||
status: 'processing',
|
||||
date_from: transactions.map(t => t.date).sort()[0] || null,
|
||||
date_to: transactions.map(t => t.date).sort().reverse()[0] || null,
|
||||
}, {
|
||||
onConflict: 'user_id,file_hash',
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (importError) {
|
||||
console.error('Failed to create import record:', importError)
|
||||
return NextResponse.json({ error: 'Failed to create import record' }, { status: 500 })
|
||||
}
|
||||
|
||||
// Convert parsed transactions to RawTransaction format
|
||||
const rawTransactions: RawTransaction[] = transactions.map((tx, index) => ({
|
||||
date: tx.date,
|
||||
description: tx.description,
|
||||
amount: tx.amount,
|
||||
currency: tx.currency || 'SEK',
|
||||
external_id: generateExternalId(tx, format, index),
|
||||
reference: tx.reference || null,
|
||||
import_source: format === 'camt053' ? 'camt053' : `csv_${format}`,
|
||||
}))
|
||||
|
||||
// Run ingestion pipeline
|
||||
const ingestResult = await ingestTransactions(supabase, user.id, rawTransactions)
|
||||
|
||||
// Update import record with results
|
||||
await supabase
|
||||
.from('bank_file_imports')
|
||||
.update({
|
||||
imported_count: ingestResult.imported,
|
||||
duplicate_count: ingestResult.duplicates,
|
||||
matched_count: ingestResult.auto_matched_invoices,
|
||||
status: ingestResult.errors > 0 && ingestResult.imported === 0 ? 'failed' : 'completed',
|
||||
error_message: ingestResult.errors > 0
|
||||
? `${ingestResult.errors} transactions failed to import`
|
||||
: null,
|
||||
})
|
||||
.eq('id', importRecord.id)
|
||||
|
||||
// Emit event with newly imported transactions
|
||||
if (ingestResult.imported > 0 && ingestResult.transaction_ids.length > 0) {
|
||||
try {
|
||||
const { data: importedTransactions } = await supabase
|
||||
.from('transactions')
|
||||
.select('*')
|
||||
.in('id', ingestResult.transaction_ids)
|
||||
|
||||
if (importedTransactions && importedTransactions.length > 0) {
|
||||
await eventBus.emit({
|
||||
type: 'transaction.synced',
|
||||
payload: {
|
||||
transactions: importedTransactions as Transaction[],
|
||||
userId: user.id,
|
||||
},
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
// Non-critical event emission
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
import_id: importRecord.id,
|
||||
...ingestResult,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Bank file execute error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Import failed' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { parseBankFile, generateFileHash, detectFileFormat } from '@/lib/import/bank-file/parser'
|
||||
import { decodeFileContent } from '@/lib/import/bank-file/encoding'
|
||||
import type { BankFileFormatId } from '@/lib/import/bank-file/types'
|
||||
|
||||
/**
|
||||
* POST /api/import/bank-file/parse
|
||||
*
|
||||
* Accepts a bank file (CSV/XML) via FormData, auto-detects format,
|
||||
* returns parsed transactions preview with duplicate detection.
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
const supabase = await createClient()
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const formData = await request.formData()
|
||||
const file = formData.get('file') as File | null
|
||||
const formatOverride = formData.get('format') as BankFileFormatId | null
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json({ error: 'No file provided' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Validate file size (10MB max)
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
return NextResponse.json({ error: 'File too large (max 10MB)' }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
// Read and decode file content
|
||||
const arrayBuffer = await file.arrayBuffer()
|
||||
const content = decodeFileContent(arrayBuffer)
|
||||
const fileHash = generateFileHash(content)
|
||||
|
||||
// Check if this exact file has been imported before
|
||||
const { data: existingImport } = await supabase
|
||||
.from('bank_file_imports')
|
||||
.select('id, status, imported_count, created_at')
|
||||
.eq('user_id', user.id)
|
||||
.eq('file_hash', fileHash)
|
||||
.single()
|
||||
|
||||
if (existingImport && existingImport.status === 'completed') {
|
||||
return NextResponse.json({
|
||||
error: 'duplicate',
|
||||
message: `Den här filen har redan importerats (${existingImport.imported_count} transaktioner, ${new Date(existingImport.created_at).toLocaleDateString('sv-SE')})`,
|
||||
}, { status: 409 })
|
||||
}
|
||||
|
||||
// Auto-detect or use specified format
|
||||
const detectedFormat = formatOverride
|
||||
? null
|
||||
: detectFileFormat(content, file.name)
|
||||
|
||||
// Parse the file
|
||||
const parseResult = parseBankFile(content, file.name, formatOverride || undefined)
|
||||
|
||||
// Check for existing transactions (duplicate detection for preview)
|
||||
let existingCount = 0
|
||||
if (parseResult.transactions.length > 0) {
|
||||
// Sample check: look for transactions with matching dates and amounts
|
||||
const { count } = await supabase
|
||||
.from('transactions')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.eq('user_id', user.id)
|
||||
.gte('date', parseResult.date_from || '1970-01-01')
|
||||
.lte('date', parseResult.date_to || '2099-12-31')
|
||||
|
||||
existingCount = count || 0
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
parse_result: parseResult,
|
||||
detected_format: detectedFormat?.id || formatOverride || null,
|
||||
detected_format_name: detectedFormat?.name || parseResult.format_name,
|
||||
file_hash: fileHash,
|
||||
filename: file.name,
|
||||
existing_transaction_count: existingCount,
|
||||
// Return first row headers for generic CSV column mapping
|
||||
headers: parseResult.format === 'generic_csv'
|
||||
? content.split('\n')[0]?.split(',').map(h => h.trim()) || []
|
||||
: null,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Bank file parse error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Failed to parse file' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
createInvoicePaymentJournalEntry,
|
||||
createInvoiceCashEntry,
|
||||
} from '@/lib/bookkeeping/invoice-entries'
|
||||
import type { Invoice } from '@/types'
|
||||
import type { EntityType, Invoice } from '@/types'
|
||||
|
||||
/**
|
||||
* POST /api/invoices/[id]/mark-paid
|
||||
@@ -70,11 +70,12 @@ export async function POST(
|
||||
// Fetch accounting method
|
||||
const { data: settings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('accounting_method')
|
||||
.select('accounting_method, entity_type')
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
const accountingMethod = settings?.accounting_method || 'accrual'
|
||||
const entityType = (settings?.entity_type as EntityType) || 'enskild_firma'
|
||||
|
||||
let journalEntryId: string | null = null
|
||||
|
||||
@@ -92,7 +93,8 @@ export async function POST(
|
||||
const journalEntry = await createInvoiceCashEntry(
|
||||
user.id,
|
||||
invoice as Invoice,
|
||||
paymentDate
|
||||
paymentDate,
|
||||
entityType
|
||||
)
|
||||
journalEntryId = journalEntry?.id ?? null
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries'
|
||||
import type { Invoice } from '@/types'
|
||||
import type { EntityType, Invoice } from '@/types'
|
||||
|
||||
/**
|
||||
* POST /api/invoices/[id]/mark-sent
|
||||
@@ -59,7 +59,7 @@ export async function POST(
|
||||
// Fetch accounting method
|
||||
const { data: settings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('accounting_method')
|
||||
.select('accounting_method, entity_type')
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
@@ -71,7 +71,8 @@ export async function POST(
|
||||
try {
|
||||
const journalEntry = await createInvoiceJournalEntry(
|
||||
user.id,
|
||||
invoice as Invoice
|
||||
invoice as Invoice,
|
||||
(settings?.entity_type as EntityType) || 'enskild_firma'
|
||||
)
|
||||
if (journalEntry) {
|
||||
journalEntryId = journalEntry.id
|
||||
|
||||
@@ -164,7 +164,8 @@ export async function POST(
|
||||
try {
|
||||
const journalEntry = await createInvoiceJournalEntry(
|
||||
user.id,
|
||||
invoice as Invoice
|
||||
invoice as Invoice,
|
||||
(company as CompanySettings).entity_type
|
||||
)
|
||||
if (journalEntry) {
|
||||
await supabase
|
||||
|
||||
@@ -2,7 +2,7 @@ import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { eventBus } from '@/lib/events'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import type { CreateInvoiceInput, Invoice, CreditNote } from '@/types'
|
||||
import type { CreateInvoiceInput, EntityType, Invoice, CreditNote } from '@/types'
|
||||
import { getVatRules, calculateVat, calculateTotal } from '@/lib/invoice/vat-rules'
|
||||
import { fetchExchangeRate, convertToSEK } from '@/lib/currency/riksbanken'
|
||||
import {
|
||||
@@ -292,12 +292,22 @@ async function createCreditNote(
|
||||
.eq('id', creditNote.id)
|
||||
.single()
|
||||
|
||||
// Fetch entity type for correct account mapping
|
||||
const { data: creditNoteSettings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('entity_type')
|
||||
.eq('user_id', userId)
|
||||
.single()
|
||||
|
||||
const entityType = (creditNoteSettings?.entity_type as EntityType) || 'enskild_firma'
|
||||
|
||||
// Create journal entry for the credit note (non-blocking)
|
||||
if (completeCreditNote) {
|
||||
try {
|
||||
const journalEntry = await createCreditNoteJournalEntry(
|
||||
userId,
|
||||
completeCreditNote as Invoice
|
||||
completeCreditNote as Invoice,
|
||||
entityType
|
||||
)
|
||||
if (journalEntry) {
|
||||
await supabase
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
getRevenueAccount,
|
||||
getOutputVatAccount,
|
||||
} from '@/lib/bookkeeping/invoice-entries'
|
||||
import type { Transaction, Invoice, CreateJournalEntryInput, VatTreatment } from '@/types'
|
||||
import type { Transaction, Invoice, CreateJournalEntryInput, EntityType, VatTreatment } from '@/types'
|
||||
|
||||
interface MatchInvoiceRequest {
|
||||
invoice_id: string
|
||||
@@ -139,11 +139,12 @@ export async function POST(
|
||||
// Fetch accounting method
|
||||
const { data: settings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('accounting_method')
|
||||
.select('accounting_method, entity_type')
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
const accountingMethod = settings?.accounting_method || 'accrual'
|
||||
const entityType = (settings?.entity_type as EntityType) || 'enskild_firma'
|
||||
|
||||
// Create journal entry for payment receipt (method-aware)
|
||||
let journalEntryId: string | null = null
|
||||
@@ -158,7 +159,7 @@ export async function POST(
|
||||
if (accountingMethod === 'cash') {
|
||||
// Kontantmetoden: combined revenue entry at payment
|
||||
// Debit 1930 Företagskonto, Credit 30xx Försäljning, Credit 26xx Utgående moms
|
||||
const revenueAccount = getRevenueAccount(invoice.vat_treatment as VatTreatment)
|
||||
const revenueAccount = getRevenueAccount(invoice.vat_treatment as VatTreatment, entityType)
|
||||
const lines: CreateJournalEntryInput['lines'] = [
|
||||
{
|
||||
account_number: '1930',
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
export { BankSelector, type Bank } from './BankSelector'
|
||||
export { BankConnectionStatus } from './BankConnectionStatus'
|
||||
@@ -0,0 +1,301 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { ArrowLeft, ArrowRight, Columns3 } from 'lucide-react'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
import type { GenericCSVColumnMapping } from '@/lib/import/bank-file/types'
|
||||
|
||||
interface BankFileColumnMappingStepProps {
|
||||
headers: string[]
|
||||
previewRows: string[][]
|
||||
onConfirm: (mapping: GenericCSVColumnMapping) => void
|
||||
onBack: () => void
|
||||
}
|
||||
|
||||
export default function BankFileColumnMappingStep({
|
||||
headers,
|
||||
previewRows,
|
||||
onConfirm,
|
||||
onBack,
|
||||
}: BankFileColumnMappingStepProps) {
|
||||
const [dateCol, setDateCol] = useState<number>(-1)
|
||||
const [descCol, setDescCol] = useState<number>(-1)
|
||||
const [amountCol, setAmountCol] = useState<number>(-1)
|
||||
const [referenceCol, setReferenceCol] = useState<number>(-1)
|
||||
const [counterpartyCol, setCounterpartyCol] = useState<number>(-1)
|
||||
const [balanceCol, setBalanceCol] = useState<number>(-1)
|
||||
const [delimiter, setDelimiter] = useState<string>(',')
|
||||
const [decimalSep, setDecimalSep] = useState<',' | '.'>(',')
|
||||
|
||||
const isValid = dateCol >= 0 && descCol >= 0 && amountCol >= 0
|
||||
|
||||
const handleConfirm = () => {
|
||||
const mapping: GenericCSVColumnMapping = {
|
||||
date: dateCol,
|
||||
description: descCol,
|
||||
amount: amountCol,
|
||||
...(referenceCol >= 0 && { reference: referenceCol }),
|
||||
...(counterpartyCol >= 0 && { counterparty: counterpartyCol }),
|
||||
...(balanceCol >= 0 && { balance: balanceCol }),
|
||||
delimiter,
|
||||
decimal_separator: decimalSep,
|
||||
skip_rows: 1, // Skip header
|
||||
date_format: 'YYYY-MM-DD',
|
||||
}
|
||||
onConfirm(mapping)
|
||||
}
|
||||
|
||||
const columnOptions = headers.map((h, i) => ({ label: `${i + 1}: ${h}`, value: i }))
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Columns3 className="h-5 w-5" />
|
||||
Kolumnmappning
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Vi kunde inte identifiera bankformatet automatiskt. Mappa kolumnerna manuellt.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* Delimiter and decimal settings */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Avgränsare</Label>
|
||||
<Select value={delimiter} onValueChange={setDelimiter}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value=",">Komma (,)</SelectItem>
|
||||
<SelectItem value=";">Semikolon (;)</SelectItem>
|
||||
<SelectItem value="\t">Tab</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Decimalavgränsare</Label>
|
||||
<Select value={decimalSep} onValueChange={(v) => setDecimalSep(v as ',' | '.')}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value=",">Komma (1 234,56)</SelectItem>
|
||||
<SelectItem value=".">Punkt (1234.56)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Required column mappings */}
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-3">Obligatoriska kolumner</h3>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Datum *</Label>
|
||||
<Select
|
||||
value={dateCol >= 0 ? dateCol.toString() : ''}
|
||||
onValueChange={(v) => setDateCol(parseInt(v))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Välj kolumn" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{columnOptions.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value.toString()}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Beskrivning *</Label>
|
||||
<Select
|
||||
value={descCol >= 0 ? descCol.toString() : ''}
|
||||
onValueChange={(v) => setDescCol(parseInt(v))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Välj kolumn" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{columnOptions.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value.toString()}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Belopp *</Label>
|
||||
<Select
|
||||
value={amountCol >= 0 ? amountCol.toString() : ''}
|
||||
onValueChange={(v) => setAmountCol(parseInt(v))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Välj kolumn" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{columnOptions.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value.toString()}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Optional column mappings */}
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-3">Valfria kolumner</h3>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Referens/OCR</Label>
|
||||
<Select
|
||||
value={referenceCol >= 0 ? referenceCol.toString() : 'none'}
|
||||
onValueChange={(v) => setReferenceCol(v === 'none' ? -1 : parseInt(v))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Ingen" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">Ingen</SelectItem>
|
||||
{columnOptions.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value.toString()}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Motpart</Label>
|
||||
<Select
|
||||
value={counterpartyCol >= 0 ? counterpartyCol.toString() : 'none'}
|
||||
onValueChange={(v) => setCounterpartyCol(v === 'none' ? -1 : parseInt(v))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Ingen" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">Ingen</SelectItem>
|
||||
{columnOptions.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value.toString()}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Saldo</Label>
|
||||
<Select
|
||||
value={balanceCol >= 0 ? balanceCol.toString() : 'none'}
|
||||
onValueChange={(v) => setBalanceCol(v === 'none' ? -1 : parseInt(v))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Ingen" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">Ingen</SelectItem>
|
||||
{columnOptions.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value.toString()}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Live preview */}
|
||||
{isValid && previewRows.length > 1 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Förhandsgranskning</CardTitle>
|
||||
<CardDescription>
|
||||
Så tolkas dina data med den valda mappningen
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-md border max-h-64 overflow-y-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Datum</TableHead>
|
||||
<TableHead>Beskrivning</TableHead>
|
||||
<TableHead className="text-right">Belopp</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{previewRows.slice(1, 6).map((row, i) => {
|
||||
const amountStr = row[amountCol] || '0'
|
||||
const amount = decimalSep === ','
|
||||
? parseFloat(amountStr.replace(/\s/g, '').replace(',', '.'))
|
||||
: parseFloat(amountStr.replace(/\s/g, ''))
|
||||
|
||||
return (
|
||||
<TableRow key={i}>
|
||||
<TableCell className="font-mono text-sm">{row[dateCol] || '–'}</TableCell>
|
||||
<TableCell className="text-sm">{row[descCol] || '–'}</TableCell>
|
||||
<TableCell
|
||||
className={`text-right font-mono text-sm ${
|
||||
!isNaN(amount) && amount >= 0 ? 'text-green-600' : 'text-red-600'
|
||||
}`}
|
||||
>
|
||||
{!isNaN(amount) ? formatCurrency(amount) : amountStr}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="flex justify-between">
|
||||
<Button variant="outline" onClick={onBack}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Tillbaka
|
||||
</Button>
|
||||
<Button onClick={handleConfirm} disabled={!isValid}>
|
||||
Fortsätt
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
ArrowLeft,
|
||||
Loader2,
|
||||
Play,
|
||||
FileText,
|
||||
AlertTriangle,
|
||||
Link2,
|
||||
Calendar,
|
||||
} from 'lucide-react'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
import type { BankFileParseResult } from '@/lib/import/bank-file/types'
|
||||
|
||||
interface BankFileConfirmStepProps {
|
||||
parseResult: BankFileParseResult
|
||||
onExecute: (options: { skip_duplicates: boolean; auto_categorize: boolean }) => void
|
||||
onBack: () => void
|
||||
isLoading: boolean
|
||||
}
|
||||
|
||||
export default function BankFileConfirmStep({
|
||||
parseResult,
|
||||
onExecute,
|
||||
onBack,
|
||||
isLoading,
|
||||
}: BankFileConfirmStepProps) {
|
||||
const [skipDuplicates, setSkipDuplicates] = useState(true)
|
||||
const [autoCategorize, setAutoCategorize] = useState(true)
|
||||
|
||||
const { transactions, stats, date_from, date_to, format_name } = parseResult
|
||||
const refsCount = transactions.filter((t) => t.reference).length
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Summary */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Bekräfta import</CardTitle>
|
||||
<CardDescription>
|
||||
Granska sammanfattningen och importera transaktionerna.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* Stats grid */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="p-4 bg-muted/50 rounded-lg">
|
||||
<div className="flex items-center gap-2 text-muted-foreground mb-1">
|
||||
<FileText className="h-4 w-4" />
|
||||
<span className="text-xs">Transaktioner</span>
|
||||
</div>
|
||||
<p className="text-xl font-bold">{stats.parsed_rows}</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-muted/50 rounded-lg">
|
||||
<div className="flex items-center gap-2 text-muted-foreground mb-1">
|
||||
<Calendar className="h-4 w-4" />
|
||||
<span className="text-xs">Period</span>
|
||||
</div>
|
||||
<p className="text-sm font-medium">
|
||||
{date_from} – {date_to}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-muted/50 rounded-lg">
|
||||
<div className="flex items-center gap-2 text-green-600 mb-1">
|
||||
<span className="text-xs">Inkomster</span>
|
||||
</div>
|
||||
<p className="text-xl font-bold text-green-600">
|
||||
{formatCurrency(stats.total_income)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-muted/50 rounded-lg">
|
||||
<div className="flex items-center gap-2 text-red-600 mb-1">
|
||||
<span className="text-xs">Utgifter</span>
|
||||
</div>
|
||||
<p className="text-xl font-bold text-red-600">
|
||||
{formatCurrency(stats.total_expenses)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Additional info */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge variant="secondary">Format: {format_name}</Badge>
|
||||
{refsCount > 0 && (
|
||||
<Badge variant="outline" className="text-blue-600 border-blue-300">
|
||||
<Link2 className="mr-1 h-3 w-3" />
|
||||
{refsCount} med OCR/referens
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Options */}
|
||||
<div className="border rounded-lg p-4 space-y-4">
|
||||
<h3 className="text-sm font-medium">Importinställningar</h3>
|
||||
|
||||
<div className="flex items-center space-x-3">
|
||||
<Checkbox
|
||||
id="skip-duplicates"
|
||||
checked={skipDuplicates}
|
||||
onCheckedChange={(checked) => setSkipDuplicates(checked === true)}
|
||||
/>
|
||||
<div>
|
||||
<Label htmlFor="skip-duplicates" className="text-sm font-medium cursor-pointer">
|
||||
Hoppa över dubletter
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Transaktioner som redan finns i systemet importeras inte igen
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3">
|
||||
<Checkbox
|
||||
id="auto-categorize"
|
||||
checked={autoCategorize}
|
||||
onCheckedChange={(checked) => setAutoCategorize(checked === true)}
|
||||
/>
|
||||
<div>
|
||||
<Label htmlFor="auto-categorize" className="text-sm font-medium cursor-pointer">
|
||||
Auto-kategorisera kända transaktioner
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Skapar automatiskt bokföringsposter för transaktioner med hög konfidens
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Warning note */}
|
||||
<div className="flex gap-3 p-3 bg-yellow-50 dark:bg-yellow-950/20 border border-yellow-200 dark:border-yellow-800 rounded-lg">
|
||||
<AlertTriangle className="h-4 w-4 text-yellow-600 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Importerade transaktioner som inte automatiskt kategoriseras visas som
|
||||
"okategoriserade" på transaktionssidan. Du kan kategorisera dem manuellt
|
||||
efteråt.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-between">
|
||||
<Button variant="outline" onClick={onBack} disabled={isLoading}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Tillbaka
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => onExecute({
|
||||
skip_duplicates: skipDuplicates,
|
||||
auto_categorize: autoCategorize,
|
||||
})}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Importerar...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
Importera {stats.parsed_rows} transaktioner
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
'use client'
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
AlertTriangle,
|
||||
Calendar,
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
FileText,
|
||||
} from 'lucide-react'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
import type { BankFileParseResult } from '@/lib/import/bank-file/types'
|
||||
|
||||
interface BankFilePreviewStepProps {
|
||||
parseResult: BankFileParseResult
|
||||
existingTransactionCount: number
|
||||
onContinue: () => void
|
||||
onBack: () => void
|
||||
}
|
||||
|
||||
export default function BankFilePreviewStep({
|
||||
parseResult,
|
||||
existingTransactionCount,
|
||||
onContinue,
|
||||
onBack,
|
||||
}: BankFilePreviewStepProps) {
|
||||
const { transactions, stats, issues, date_from, date_to, format_name } = parseResult
|
||||
const hasIssues = issues.filter((i) => i.severity === 'error').length > 0
|
||||
const warnings = issues.filter((i) => i.severity === 'warning')
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Summary cards */}
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-2 text-muted-foreground mb-1">
|
||||
<FileText className="h-4 w-4" />
|
||||
<span className="text-sm">Transaktioner</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold">{stats.parsed_rows}</p>
|
||||
{stats.skipped_rows > 0 && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{stats.skipped_rows} rader hoppades över
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-2 text-muted-foreground mb-1">
|
||||
<Calendar className="h-4 w-4" />
|
||||
<span className="text-sm">Period</span>
|
||||
</div>
|
||||
<p className="text-sm font-medium">
|
||||
{date_from || '–'} till {date_to || '–'}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-2 text-green-600 mb-1">
|
||||
<TrendingUp className="h-4 w-4" />
|
||||
<span className="text-sm">Inkomster</span>
|
||||
</div>
|
||||
<p className="text-lg font-bold text-green-600">
|
||||
{formatCurrency(stats.total_income)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-2 text-red-600 mb-1">
|
||||
<TrendingDown className="h-4 w-4" />
|
||||
<span className="text-sm">Utgifter</span>
|
||||
</div>
|
||||
<p className="text-lg font-bold text-red-600">
|
||||
{formatCurrency(stats.total_expenses)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Format and duplicate info */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Badge variant="secondary">
|
||||
Format: {format_name}
|
||||
</Badge>
|
||||
{existingTransactionCount > 0 && (
|
||||
<Badge variant="outline" className="text-yellow-600 border-yellow-300">
|
||||
<AlertTriangle className="mr-1 h-3 w-3" />
|
||||
{existingTransactionCount} befintliga transaktioner i samma period
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Warnings */}
|
||||
{warnings.length > 0 && (
|
||||
<Card className="border-yellow-300">
|
||||
<CardHeader className="py-3">
|
||||
<CardTitle className="text-sm flex items-center gap-2 text-yellow-600">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
{warnings.length} varning{warnings.length !== 1 ? 'ar' : ''}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<div className="space-y-1 max-h-32 overflow-y-auto">
|
||||
{warnings.slice(0, 10).map((issue, i) => (
|
||||
<p key={i} className="text-xs text-muted-foreground">
|
||||
Rad {issue.row}: {issue.message}
|
||||
</p>
|
||||
))}
|
||||
{warnings.length > 10 && (
|
||||
<p className="text-xs text-muted-foreground font-medium">
|
||||
...och {warnings.length - 10} till
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Transaction preview table */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Transaktioner</CardTitle>
|
||||
<CardDescription>
|
||||
Förhandsgranskning av de {Math.min(transactions.length, 50)} första transaktionerna
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-md border max-h-96 overflow-y-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-28">Datum</TableHead>
|
||||
<TableHead>Beskrivning</TableHead>
|
||||
<TableHead className="text-right w-32">Belopp</TableHead>
|
||||
{transactions.some((t) => t.balance != null) && (
|
||||
<TableHead className="text-right w-32">Saldo</TableHead>
|
||||
)}
|
||||
{transactions.some((t) => t.reference) && (
|
||||
<TableHead className="w-32">Referens</TableHead>
|
||||
)}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{transactions.slice(0, 50).map((tx, i) => (
|
||||
<TableRow key={i}>
|
||||
<TableCell className="font-mono text-sm">{tx.date}</TableCell>
|
||||
<TableCell className="text-sm">{tx.description}</TableCell>
|
||||
<TableCell
|
||||
className={`text-right font-mono text-sm ${
|
||||
tx.amount >= 0 ? 'text-green-600' : 'text-red-600'
|
||||
}`}
|
||||
>
|
||||
{formatCurrency(tx.amount)}
|
||||
</TableCell>
|
||||
{transactions.some((t) => t.balance != null) && (
|
||||
<TableCell className="text-right font-mono text-sm text-muted-foreground">
|
||||
{tx.balance != null ? formatCurrency(tx.balance) : '–'}
|
||||
</TableCell>
|
||||
)}
|
||||
{transactions.some((t) => t.reference) && (
|
||||
<TableCell className="text-sm">
|
||||
{tx.reference ? (
|
||||
<Badge variant="outline" className="font-mono text-xs">
|
||||
{tx.reference}
|
||||
</Badge>
|
||||
) : (
|
||||
'–'
|
||||
)}
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
{transactions.length > 50 && (
|
||||
<p className="text-sm text-muted-foreground mt-2 text-center">
|
||||
Visar 50 av {transactions.length} transaktioner
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="flex justify-between">
|
||||
<Button variant="outline" onClick={onBack}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Tillbaka
|
||||
</Button>
|
||||
<Button onClick={onContinue} disabled={hasIssues || transactions.length === 0}>
|
||||
Fortsätt
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
FileText,
|
||||
Link2,
|
||||
Sparkles,
|
||||
Copy,
|
||||
ArrowRight,
|
||||
RotateCcw,
|
||||
ExternalLink,
|
||||
} from 'lucide-react'
|
||||
import type { IngestResult } from '@/lib/transactions/ingest'
|
||||
|
||||
interface BankFileResultStepProps {
|
||||
result: IngestResult
|
||||
onNewImport: () => void
|
||||
}
|
||||
|
||||
export default function BankFileResultStep({
|
||||
result,
|
||||
onNewImport,
|
||||
}: BankFileResultStepProps) {
|
||||
const isSuccess = result.imported > 0 || result.duplicates > 0
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Status header */}
|
||||
<Card className={isSuccess ? 'border-green-300' : 'border-destructive/50'}>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
{isSuccess ? (
|
||||
<>
|
||||
<CheckCircle className="h-6 w-6 text-green-600" />
|
||||
Import genomförd
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<XCircle className="h-6 w-6 text-destructive" />
|
||||
Import misslyckades
|
||||
</>
|
||||
)}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{isSuccess
|
||||
? `${result.imported} transaktioner importerades framgångsrikt.`
|
||||
: `${result.errors} fel uppstod under importen.`}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-2 text-muted-foreground mb-1">
|
||||
<FileText className="h-4 w-4" />
|
||||
<span className="text-sm">Importerade</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-green-600">{result.imported}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-2 text-muted-foreground mb-1">
|
||||
<Copy className="h-4 w-4" />
|
||||
<span className="text-sm">Dubletter</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-muted-foreground">{result.duplicates}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-2 text-muted-foreground mb-1">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
<span className="text-sm">Auto-kategoriserade</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold">{result.auto_categorized}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-2 text-muted-foreground mb-1">
|
||||
<Link2 className="h-4 w-4" />
|
||||
<span className="text-sm">Fakturamatchade</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold">{result.auto_matched_invoices}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Next steps */}
|
||||
{isSuccess && (
|
||||
<Card className="bg-muted/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Nästa steg</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-6 h-6 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-sm font-medium flex-shrink-0">
|
||||
1
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">Granska okategoriserade transaktioner</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{result.imported - result.auto_categorized > 0
|
||||
? `${result.imported - result.auto_categorized} transaktioner behöver kategoriseras manuellt.`
|
||||
: 'Alla transaktioner kategoriserades automatiskt.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-6 h-6 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-sm font-medium flex-shrink-0">
|
||||
2
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">Bekräfta fakturamatchningar</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{result.auto_matched_invoices > 0
|
||||
? `${result.auto_matched_invoices} transaktioner matchades mot fakturor. Bekräfta dessa på transaktionssidan.`
|
||||
: 'Inga automatiska fakturamatchningar hittades.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-6 h-6 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-sm font-medium flex-shrink-0">
|
||||
3
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">Importera fler kontoutdrag</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Importera löpande kontoutdrag för att hålla bokföringen uppdaterad.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-between">
|
||||
<Button variant="outline" onClick={onNewImport}>
|
||||
<RotateCcw className="mr-2 h-4 w-4" />
|
||||
Ny import
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
{isSuccess && (
|
||||
<Button asChild>
|
||||
<Link href="/transactions">
|
||||
Visa transaktioner
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useCallback } from 'react'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Upload,
|
||||
FileText,
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
Building2,
|
||||
HelpCircle,
|
||||
} from 'lucide-react'
|
||||
import type { BankFileFormatId } from '@/lib/import/bank-file/types'
|
||||
|
||||
const FORMAT_NAMES: Record<string, string> = {
|
||||
nordea: 'Nordea',
|
||||
seb: 'SEB',
|
||||
swedbank: 'Swedbank',
|
||||
handelsbanken: 'Handelsbanken',
|
||||
generic_csv: 'CSV (manuell mappning)',
|
||||
camt053: 'ISO 20022 camt.053',
|
||||
}
|
||||
|
||||
interface BankFileUploadStepProps {
|
||||
onFileSelect: (file: File, formatOverride?: BankFileFormatId) => void
|
||||
isLoading: boolean
|
||||
error: string | null
|
||||
detectedFormat?: string | null
|
||||
detectedFormatName?: string | null
|
||||
}
|
||||
|
||||
export default function BankFileUploadStep({
|
||||
onFileSelect,
|
||||
isLoading,
|
||||
error,
|
||||
detectedFormat,
|
||||
detectedFormatName,
|
||||
}: BankFileUploadStepProps) {
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null)
|
||||
const [formatOverride, setFormatOverride] = useState<BankFileFormatId | undefined>(undefined)
|
||||
|
||||
const acceptedExtensions = '.csv,.txt,.xml'
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(true)
|
||||
}, [])
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(false)
|
||||
}, [])
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(false)
|
||||
|
||||
const files = e.dataTransfer.files
|
||||
if (files.length > 0) {
|
||||
const file = files[0]
|
||||
const ext = file.name.toLowerCase()
|
||||
if (ext.endsWith('.csv') || ext.endsWith('.txt') || ext.endsWith('.xml')) {
|
||||
setSelectedFile(file)
|
||||
onFileSelect(file, formatOverride)
|
||||
}
|
||||
}
|
||||
}, [onFileSelect, formatOverride])
|
||||
|
||||
const handleFileInput = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files
|
||||
if (files && files.length > 0) {
|
||||
setSelectedFile(files[0])
|
||||
onFileSelect(files[0], formatOverride)
|
||||
}
|
||||
}, [onFileSelect, formatOverride])
|
||||
|
||||
const handleFormatChange = (value: string) => {
|
||||
const format = value === 'auto' ? undefined : value as BankFileFormatId
|
||||
setFormatOverride(format)
|
||||
if (selectedFile) {
|
||||
onFileSelect(selectedFile, format)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Upload className="h-5 w-5" />
|
||||
Ladda upp kontoutdrag
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Exportera transaktioner som CSV eller XML från din internetbank och ladda upp filen.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Format override */}
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="text-sm font-medium whitespace-nowrap">Bank/format:</label>
|
||||
<Select
|
||||
value={formatOverride || 'auto'}
|
||||
onValueChange={handleFormatChange}
|
||||
>
|
||||
<SelectTrigger className="w-64">
|
||||
<SelectValue placeholder="Automatisk identifiering" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">Automatisk identifiering</SelectItem>
|
||||
<SelectItem value="nordea">Nordea</SelectItem>
|
||||
<SelectItem value="seb">SEB</SelectItem>
|
||||
<SelectItem value="swedbank">Swedbank</SelectItem>
|
||||
<SelectItem value="handelsbanken">Handelsbanken</SelectItem>
|
||||
<SelectItem value="camt053">ISO 20022 camt.053 (XML)</SelectItem>
|
||||
<SelectItem value="generic_csv">Annan CSV (manuell mappning)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Drop zone */}
|
||||
<div
|
||||
className={`
|
||||
relative border-2 border-dashed rounded-lg p-8 text-center transition-colors
|
||||
${isDragging ? 'border-primary bg-primary/5' : 'border-muted-foreground/25'}
|
||||
${error ? 'border-destructive bg-destructive/5' : ''}
|
||||
${isLoading ? 'pointer-events-none opacity-50' : 'cursor-pointer hover:border-primary/50'}
|
||||
`}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => document.getElementById('bank-file-input')?.click()}
|
||||
>
|
||||
<input
|
||||
id="bank-file-input"
|
||||
type="file"
|
||||
accept={acceptedExtensions}
|
||||
className="hidden"
|
||||
onChange={handleFileInput}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="space-y-4">
|
||||
<FileText className="mx-auto h-12 w-12 text-muted-foreground animate-pulse" />
|
||||
<p className="text-muted-foreground">Analyserar fil...</p>
|
||||
<Progress value={33} className="w-48 mx-auto" />
|
||||
</div>
|
||||
) : selectedFile && detectedFormat ? (
|
||||
<div className="space-y-4">
|
||||
<CheckCircle className="mx-auto h-12 w-12 text-green-600" />
|
||||
<div>
|
||||
<p className="font-medium">{selectedFile.name}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{(selectedFile.size / 1024).toFixed(1)} KB
|
||||
</p>
|
||||
<Badge variant="secondary" className="mt-2">
|
||||
<Building2 className="mr-1 h-3 w-3" />
|
||||
{detectedFormatName || FORMAT_NAMES[detectedFormat] || detectedFormat}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<Upload className="mx-auto h-12 w-12 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="font-medium">Dra och släpp bankfil här</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
CSV, TXT eller XML (max 10 MB)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Error display */}
|
||||
{error && (
|
||||
<div className="p-4 bg-destructive/10 border border-destructive/20 rounded-lg flex gap-3">
|
||||
<AlertCircle className="h-5 w-5 text-destructive flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="font-medium text-destructive">Kunde inte läsa filen</p>
|
||||
<p className="text-sm text-muted-foreground">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Bank export instructions */}
|
||||
<Card className="bg-muted/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<HelpCircle className="h-4 w-4" />
|
||||
Så exporterar du från din bank
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-sm space-y-3">
|
||||
<div>
|
||||
<p className="font-medium">Nordea</p>
|
||||
<p className="text-muted-foreground">
|
||||
Logga in → Konton → Välj konto → Transaktioner → Exportera (CSV)
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">SEB</p>
|
||||
<p className="text-muted-foreground">
|
||||
Logga in → Konton → Kontoutdrag → Hämta som fil (CSV)
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">Swedbank</p>
|
||||
<p className="text-muted-foreground">
|
||||
Logga in → Konton → Transaktioner → Exportera kontoutdrag (CSV)
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">Handelsbanken</p>
|
||||
<p className="text-muted-foreground">
|
||||
Logga in → Konton → Transaktioner → Ladda ner (CSV)
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -82,8 +82,8 @@ export default function NewUserChecklist({
|
||||
},
|
||||
{
|
||||
id: 'bank',
|
||||
label: 'Koppla bank',
|
||||
description: 'Se transaktioner automatiskt (valfritt)',
|
||||
label: 'Importera transaktioner',
|
||||
description: 'Importera kontoutdrag från din bank',
|
||||
href: '/import',
|
||||
icon: Building2,
|
||||
completed: hasBankConnected,
|
||||
|
||||
@@ -9,8 +9,7 @@ import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { InfoTooltip } from '@/components/ui/info-tooltip'
|
||||
import { Loader2, ArrowLeft, Landmark, SkipForward, ChevronDown, ChevronUp } from 'lucide-react'
|
||||
import { BankSelector, type Bank } from '@/components/banking/BankSelector'
|
||||
import { Loader2, ArrowLeft, Landmark, SkipForward } from 'lucide-react'
|
||||
|
||||
const manualBankSchema = z.object({
|
||||
bank_name: z.string().optional(),
|
||||
@@ -37,10 +36,6 @@ export default function Step6ConnectBank({
|
||||
onComplete,
|
||||
isSaving,
|
||||
}: Step6Props) {
|
||||
const [isConnecting, setIsConnecting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [showManual, setShowManual] = useState(false)
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
@@ -55,33 +50,6 @@ export default function Step6ConnectBank({
|
||||
},
|
||||
})
|
||||
|
||||
const handleBankSelect = async (bank: Bank) => {
|
||||
setIsConnecting(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/banking/connect', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
aspsp_name: bank.name,
|
||||
aspsp_country: bank.country,
|
||||
}),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Kunde inte ansluta bank')
|
||||
}
|
||||
|
||||
window.location.href = data.authorization_url
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Ett fel uppstod')
|
||||
setIsConnecting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const onManualSubmit = (data: ManualBankData) => {
|
||||
onComplete(data)
|
||||
}
|
||||
@@ -89,9 +57,10 @@ export default function Step6ConnectBank({
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center">
|
||||
<h1 className="text-3xl font-bold tracking-tight">Anslut din bank</h1>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Bankuppgifter</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
Koppla din bank för att automatiskt importera transaktioner via PSD2.
|
||||
Ange dina bankuppgifter så visas de på dina fakturor.
|
||||
Du kan importera transaktioner från din bank efteråt.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -99,159 +68,125 @@ export default function Step6ConnectBank({
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Landmark className="h-5 w-5" />
|
||||
Välj din bank
|
||||
Bankuppgifter för fakturor
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Vi använder säker bankintegration (PSD2) för att hämta dina transaktioner.
|
||||
Vi kan aldrig flytta pengar eller göra ändringar.
|
||||
Dessa uppgifter visas på dina fakturor så att kunder kan betala dig.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<BankSelector
|
||||
onSelect={handleBankSelect}
|
||||
isLoading={isConnecting}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 bg-destructive/10 text-destructive rounded-lg text-sm">
|
||||
{error}
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit(onManualSubmit)} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bank_name">Bank</Label>
|
||||
<Input
|
||||
id="bank_name"
|
||||
placeholder="t.ex. Nordea, SEB, Swedbank"
|
||||
{...register('bank_name')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-muted/50 rounded-lg p-4">
|
||||
<h4 className="font-medium mb-2">Säker anslutning</h4>
|
||||
<ul className="text-sm text-muted-foreground space-y-1">
|
||||
<li>• Krypterad anslutning via PSD2</li>
|
||||
<li>• Vi kan endast läsa transaktioner</li>
|
||||
<li>• Du kan koppla bort när som helst</li>
|
||||
<li>• Samtycke gäller i 90 dagar</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Collapsible manual bank details */}
|
||||
<div className="border-t pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowManual(!showManual)}
|
||||
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors w-full"
|
||||
>
|
||||
{showManual ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
||||
<span>Ange bankuppgifter manuellt</span>
|
||||
</button>
|
||||
|
||||
{showManual && (
|
||||
<form onSubmit={handleSubmit(onManualSubmit)} className="space-y-4 mt-4 animate-fade-in">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Dessa uppgifter visas på dina fakturor så att kunder kan betala dig.
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<InfoTooltip
|
||||
content={
|
||||
<div className="space-y-2">
|
||||
<p className="font-medium">Vad är clearingnummer?</p>
|
||||
<p>De första 4-5 siffrorna i ditt kontonummer som identifierar din bank.</p>
|
||||
<ul className="text-xs text-muted-foreground space-y-1">
|
||||
<li>Nordea: 3300</li>
|
||||
<li>SEB: 5000</li>
|
||||
<li>Swedbank: 8XXX</li>
|
||||
<li>Handelsbanken: 6XXX</li>
|
||||
<li>Avanza: 9550/9551</li>
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
side="top"
|
||||
>
|
||||
<Label htmlFor="clearing_number">Clearingnummer</Label>
|
||||
</InfoTooltip>
|
||||
<Input
|
||||
id="clearing_number"
|
||||
placeholder="XXXX"
|
||||
{...register('clearing_number')}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="account_number">Kontonummer</Label>
|
||||
<Input
|
||||
id="account_number"
|
||||
placeholder="XXX XXX XXX"
|
||||
{...register('account_number')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t">
|
||||
<h4 className="font-medium mb-4">Internationella betalningar (valfritt)</h4>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bank_name">Bank</Label>
|
||||
<InfoTooltip
|
||||
content={
|
||||
<div className="space-y-2">
|
||||
<p className="font-medium">Vad är IBAN?</p>
|
||||
<p>Internationellt bankkontonummer. Svenska IBAN börjar med SE och har 24 tecken totalt.</p>
|
||||
</div>
|
||||
}
|
||||
side="right"
|
||||
>
|
||||
<Label htmlFor="iban">IBAN</Label>
|
||||
</InfoTooltip>
|
||||
<Input
|
||||
id="bank_name"
|
||||
placeholder="t.ex. Nordea, SEB, Swedbank"
|
||||
{...register('bank_name')}
|
||||
id="iban"
|
||||
placeholder="SE00 0000 0000 0000 0000 0000"
|
||||
{...register('iban')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<InfoTooltip
|
||||
content={
|
||||
<div className="space-y-2">
|
||||
<p className="font-medium">Vad är clearingnummer?</p>
|
||||
<p>De första 4-5 siffrorna i ditt kontonummer som identifierar din bank.</p>
|
||||
<ul className="text-xs text-muted-foreground space-y-1">
|
||||
<li>Nordea: 3300</li>
|
||||
<li>SEB: 5000</li>
|
||||
<li>Swedbank: 8XXX</li>
|
||||
<li>Handelsbanken: 6XXX</li>
|
||||
<li>Avanza: 9550/9551</li>
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
side="top"
|
||||
>
|
||||
<Label htmlFor="clearing_number">Clearingnummer</Label>
|
||||
</InfoTooltip>
|
||||
<Input
|
||||
id="clearing_number"
|
||||
placeholder="XXXX"
|
||||
{...register('clearing_number')}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="account_number">Kontonummer</Label>
|
||||
<Input
|
||||
id="account_number"
|
||||
placeholder="XXX XXX XXX"
|
||||
{...register('account_number')}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<InfoTooltip
|
||||
content={
|
||||
<div className="space-y-2">
|
||||
<p className="font-medium">Vad är BIC/SWIFT?</p>
|
||||
<p>Bankens internationella id-kod. Används tillsammans med IBAN för utlandsbetalningar.</p>
|
||||
</div>
|
||||
}
|
||||
side="right"
|
||||
>
|
||||
<Label htmlFor="bic">BIC/SWIFT</Label>
|
||||
</InfoTooltip>
|
||||
<Input
|
||||
id="bic"
|
||||
placeholder="XXXXSESS"
|
||||
{...register('bic')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t">
|
||||
<h4 className="font-medium mb-4">Internationella betalningar (valfritt)</h4>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<InfoTooltip
|
||||
content={
|
||||
<div className="space-y-2">
|
||||
<p className="font-medium">Vad är IBAN?</p>
|
||||
<p>Internationellt bankkontonummer. Svenska IBAN börjar med SE och har 24 tecken totalt.</p>
|
||||
</div>
|
||||
}
|
||||
side="right"
|
||||
>
|
||||
<Label htmlFor="iban">IBAN</Label>
|
||||
</InfoTooltip>
|
||||
<Input
|
||||
id="iban"
|
||||
placeholder="SE00 0000 0000 0000 0000 0000"
|
||||
{...register('iban')}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<InfoTooltip
|
||||
content={
|
||||
<div className="space-y-2">
|
||||
<p className="font-medium">Vad är BIC/SWIFT?</p>
|
||||
<p>Bankens internationella id-kod. Används tillsammans med IBAN för utlandsbetalningar.</p>
|
||||
</div>
|
||||
}
|
||||
side="right"
|
||||
>
|
||||
<Label htmlFor="bic">BIC/SWIFT</Label>
|
||||
</InfoTooltip>
|
||||
<Input
|
||||
id="bic"
|
||||
placeholder="XXXXSESS"
|
||||
{...register('bic')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-muted/50 rounded-lg p-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Du kan importera kontoutdrag (CSV, XML) från din bank under <strong>Import</strong>-sidan
|
||||
efter att du slutfört registreringen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full" disabled={isSaving}>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Sparar...
|
||||
</>
|
||||
) : (
|
||||
'Spara bankuppgifter och slutför'
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
<Button type="submit" className="w-full" disabled={isSaving}>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Sparar...
|
||||
</>
|
||||
) : (
|
||||
'Spara och slutför'
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="flex justify-between pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={onBack}
|
||||
disabled={isConnecting}
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Tillbaka
|
||||
@@ -260,7 +195,6 @@ export default function Step6ConnectBank({
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onSkip}
|
||||
disabled={isConnecting}
|
||||
>
|
||||
<SkipForward className="mr-2 h-4 w-4" />
|
||||
Hoppa över
|
||||
|
||||
@@ -117,8 +117,8 @@ export function EmptyTransactions() {
|
||||
<EmptyState
|
||||
icon={ArrowLeftRight}
|
||||
title="Inga transaktioner"
|
||||
description="Koppla din bank för att automatiskt importera transaktioner, eller lägg till dem manuellt."
|
||||
actionLabel="Koppla bank"
|
||||
description="Importera kontoutdrag från din bank eller lägg till transaktioner manuellt."
|
||||
actionLabel="Importera transaktioner"
|
||||
actionHref="/import"
|
||||
secondaryActionLabel="Lägg till manuellt"
|
||||
secondaryActionHref="/transactions/new"
|
||||
@@ -152,9 +152,9 @@ export function NoBankConnected() {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={Building2}
|
||||
title="Ingen bank kopplad"
|
||||
description="Koppla din bank för att automatiskt importera transaktioner och få bättre koll på ekonomin."
|
||||
actionLabel="Koppla bank"
|
||||
title="Inga transaktioner importerade"
|
||||
description="Importera kontoutdrag från din bank för att automatiskt bokföra och få bättre koll på ekonomin."
|
||||
actionLabel="Importera transaktioner"
|
||||
actionHref="/import"
|
||||
/>
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,467 @@
|
||||
1/9
|
||||
BAS Förenklat årsbokslut (K1) – Kontoplan 2018
|
||||
Inga ändring eller tillägg har gjorts
|
||||
jämfört med 2017.
|
||||
BAS-konton Rad Underkonton Rad
|
||||
1000 Immateriella
|
||||
anläggningstillgångar
|
||||
B1 1009 Årets avskrivningar på
|
||||
immateriella
|
||||
anläggningstillgångar
|
||||
B1
|
||||
BAS-konton Rad Underkonton Rad
|
||||
1110 Byggnader B2 1119 Ackumulerade avskrivningar på
|
||||
byggnader
|
||||
B2
|
||||
1130 Mark B3
|
||||
1150 Markanläggningar B2 1159 Ackumulerade avskrivningar på
|
||||
markanläggningar
|
||||
B2
|
||||
1180 Pågående nyanläggningar och
|
||||
förskott för byggnader och
|
||||
mark
|
||||
B3
|
||||
Rad Rad
|
||||
1220 Maskiner och inventarier B4 1221 Årets nyanskaffning av
|
||||
maskiner och inventarier
|
||||
B4
|
||||
1222 Årets ersättning för maskiner
|
||||
och inventarier
|
||||
B4
|
||||
1229 Årets avskrivningar på maskiner
|
||||
och inventarier
|
||||
B4
|
||||
1230 Byggnads- och markinventarier B4 1231 Årets nyanskaffning av
|
||||
byggnads- och markinventarier
|
||||
B4
|
||||
1232 Årets ersättning för byggnadsoch markinventarier
|
||||
B4
|
||||
1239 Årets avskrivningar på
|
||||
byggnads- och markinventarier
|
||||
B4
|
||||
1240 Bilar och andra transportmedel B4 1241 Årets nyanskaffning av bilar och
|
||||
andra transportmedel
|
||||
B4
|
||||
1242 Årets ersättning för bilar och
|
||||
andra transportmedel
|
||||
B4
|
||||
1249 Årets avskrivningar på bilar och
|
||||
andra transportmedel
|
||||
B4
|
||||
Rad Rad
|
||||
1300 Andelar B5
|
||||
BAS-konton Rad Rad
|
||||
1 Tillgångar
|
||||
10 Immateriella anläggningstillgångar
|
||||
11 Byggnader och mark
|
||||
12 Maskiner och inventarier
|
||||
14 Lager
|
||||
BAS-konton Underkonton
|
||||
Underkonton
|
||||
13 Övriga anläggningstillgångar
|
||||
BAS-konton Underkonton
|
||||
Kontoplan_K1_2018_ver1
|
||||
2/9
|
||||
1400 Lager B6
|
||||
Rad Rad
|
||||
1500 Kundfordringar B7
|
||||
Rad Rad
|
||||
1600 Övriga fordringar B8
|
||||
1650 Momsfordran B8
|
||||
Rad
|
||||
1700 Förskott till leverantörer B8
|
||||
Rad
|
||||
1910 Kassa B9
|
||||
1920 PlusGiro B9
|
||||
1930 Företagskonto/checkkonto/affär
|
||||
skonto
|
||||
B9
|
||||
1940 Övriga bankkonton B9
|
||||
1970 Särskilda bankkonton B9
|
||||
Rad Rad
|
||||
2010 Eget kapital, delägare 1 B10 2011 Egna varuuttag B10
|
||||
2012 Avräkning för skatter och
|
||||
avgifter (skattekonto)
|
||||
B10
|
||||
2013 Övriga egna uttag B10
|
||||
2014 Uttag förmåner B10
|
||||
2017 Egna insättningar B10
|
||||
2019 Årets resultat, delägare 1 B10
|
||||
2020 Eget kapital, delägare 2 B10 Se delägre 1
|
||||
2030 Eget kapital, delägare 3 B10 Se delägre 1
|
||||
2040 Eget kapital, delägare 4 B10 Se delägre 1
|
||||
2050 Avsättning till expansionsfond U2
|
||||
2060 Ersättningsfond U3
|
||||
2070 Insatsemissioner,
|
||||
avbetalningsplan på skog,
|
||||
skogskonto,
|
||||
upphovsmannakonto
|
||||
U4
|
||||
2080 Periodiseringsfonder U1 2083 Periodiseringsfond vid 2012 års
|
||||
taxering
|
||||
U1
|
||||
BAS-konton
|
||||
16 Övriga fordringar
|
||||
BAS-konton Underkonton
|
||||
BAS-konton Underkonton
|
||||
Underkonton
|
||||
2 EGET KAPITAL OCH SKULDER
|
||||
19 Kassa och bank
|
||||
17 Förskott till leverantörer
|
||||
15 Kundfordringar
|
||||
BAS-konton
|
||||
BAS-konton Underkonton
|
||||
20 Eget kapital
|
||||
Underkonton
|
||||
Kontoplan_K1_2018_ver1
|
||||
3/9
|
||||
2084 Periodiseringsfond vid 2013 års
|
||||
taxering
|
||||
U1
|
||||
2085 Periodiseringsfond 2013 U1
|
||||
2086 Periodiseringsfond 2014 U1
|
||||
2087 Periodiseringsfond 2015 U1
|
||||
2088 Periodiseringsfond 2016 U1
|
||||
2089 Periodiseringsfond 2017 U1
|
||||
2090 Utjämningskonto upplysningar
|
||||
1-4
|
||||
Rad Rad
|
||||
2330 Checkräkningskredit B13
|
||||
2350 Skulder till kreditinstitut B13
|
||||
2390 Övriga låneskulder B13
|
||||
Rad Rad
|
||||
2440 Leverantörsskulder B15
|
||||
Rad Rad
|
||||
2610 Utgående moms, 25 % B14 2611 Utgående moms på försäljning
|
||||
inom Sverige, 25 %
|
||||
B14
|
||||
2612 Utgående moms på egna uttag,
|
||||
25 %
|
||||
B14
|
||||
2613 Utgående moms för uthyrning,
|
||||
25 %
|
||||
B14
|
||||
2614 Utgående moms omvänd
|
||||
skattskyldighet, 25 %
|
||||
B14
|
||||
2615 Utgående moms import av
|
||||
varor, 25 %
|
||||
B14
|
||||
2618 Vilande utgående moms, 25 % B14
|
||||
2620 Utgående moms, 12 % B14 2621 Utgående moms på försäljning
|
||||
inom Sverige, 12 %
|
||||
B14
|
||||
2622 Utgående moms på egna uttag,
|
||||
12 %
|
||||
B14
|
||||
2623 Utgående moms för uthyrning,
|
||||
12 %
|
||||
B14
|
||||
2624 Utgående moms omvänd
|
||||
skattskyldighet, 12 %
|
||||
B14
|
||||
2625 Utgående moms import av
|
||||
varor, 12 %
|
||||
B14
|
||||
2628 Vilande utgående moms, 12 % B14
|
||||
2630 Utgående moms, 6 % B14 2631 Utgående moms på försäljning
|
||||
inom Sverige, 6 %
|
||||
B14
|
||||
2632 Utgående moms på egna uttag,
|
||||
6 %
|
||||
B14
|
||||
BAS-konton Underkonton
|
||||
23 Låneskulder
|
||||
Underkonton
|
||||
BAS-konton Underkonton
|
||||
24 Skulder till leverantörer
|
||||
26 Moms och särskilda punktskatter
|
||||
BAS-konton
|
||||
Kontoplan_K1_2018_ver1
|
||||
4/9
|
||||
2633 Utgående moms för uthyrning,
|
||||
6 %
|
||||
B14
|
||||
2634 Utgående moms omvänd
|
||||
skattskyldighet, 6 %
|
||||
B14
|
||||
2635 Utgående moms import av
|
||||
varor, 6 %
|
||||
B14
|
||||
2638 Vilande utgående moms, 6 % B14
|
||||
2640 Ingående moms B14 2641 Debiterad ingående moms B14
|
||||
2642 Debiterad ingående moms i
|
||||
anslutning till frivillig
|
||||
skattskyldighet
|
||||
B14
|
||||
2645 Beräknad ingående moms på
|
||||
förvärv från utlandet
|
||||
B14
|
||||
2646 Ingående moms på uthyrning B14
|
||||
2648 Vilande ingående moms B14
|
||||
2649 Ingående moms, blandad
|
||||
verksamhet
|
||||
B14
|
||||
2650 Redovisningskonto för moms B14 B14
|
||||
2660 Särskilda punktskatter B14 B14
|
||||
Rad Rad
|
||||
2710 Personalskatt B14
|
||||
2730 Lagstadgade/avtalade sociala
|
||||
avgifter och särskild löneskatt
|
||||
B14
|
||||
Rad Rad
|
||||
2900 Övriga skulder B16
|
||||
Rad Rad
|
||||
3000 Försäljning och utfört arbete
|
||||
samt övriga momspliktiga
|
||||
intäkter
|
||||
R1
|
||||
3100 Momsfria intäkter R2
|
||||
3200 Bil- och bostadsförmån m.m. R3
|
||||
Rad Rad
|
||||
3500 Fakturerade kostnader R1
|
||||
Rad Rad
|
||||
3700 Lämnade rabatter, bonus etc. R1/R2
|
||||
Underkonton
|
||||
35 Fakturerade kostnader
|
||||
37 Intäktskorrigeringar
|
||||
Underkonton
|
||||
BAS-konton
|
||||
Underkonton
|
||||
3 RÖRELSENS INKOMSTER/INTÄKTER
|
||||
30-34 Huvudintäkter
|
||||
BAS-konton Underkonton
|
||||
BAS-konton
|
||||
29 Övriga skulder
|
||||
27 Personalens skatter, avgifter och löneavdrag
|
||||
BAS-konton Underkonton
|
||||
BAS-konton
|
||||
Kontoplan_K1_2018_ver1
|
||||
5/9
|
||||
Rad Rad
|
||||
3900 Övriga rörelseintäkter R1/R2
|
||||
3970 Vinst vid avyttring av
|
||||
immateriella och materiella
|
||||
anläggningstillgångar
|
||||
R2
|
||||
3980 Erhållna bidrag R2
|
||||
Rad Rad
|
||||
4000 Varor R5
|
||||
Rad Rad
|
||||
4600 Legoarbeten och
|
||||
underentreprenader
|
||||
R5
|
||||
Rad Rad
|
||||
4700 Erhållna rabatter, bonus etc. R6
|
||||
Rad Rad
|
||||
4900 Förändring av lager R5
|
||||
Rad Rad
|
||||
5000 Lokalkostnader R6
|
||||
Rad Rad
|
||||
5100 Fastighetskostnader R6
|
||||
Rad Rad
|
||||
5200 Hyra av anläggningstillgångar R6
|
||||
Underkonton
|
||||
Underkonton
|
||||
BAS-konton Underkonton
|
||||
39 Övriga rörelseintäkter
|
||||
Underkonton
|
||||
BAS-konton
|
||||
4 UTGIFTER/KOSTNADER FÖR VAROR, MATERIAL OCH VISSA
|
||||
KÖPTA TJÄNSTER
|
||||
Underkonton
|
||||
47 Reduktion av inköpspriser
|
||||
49 Förändring av lager
|
||||
BAS-konton Underkonton
|
||||
Underkonton
|
||||
BAS-konton Underkonton
|
||||
46 Legoarbeten, underentreprenader
|
||||
BAS-konton
|
||||
BAS-konton
|
||||
BAS-konton
|
||||
40-45 Inköp av varor och material
|
||||
BAS-konton
|
||||
5-6 ÖVRIGA EXTERNA RÖRELSEUTGIFTER/KOSTNADER
|
||||
50 Lokalkostnader
|
||||
51 Fastighetskostnader
|
||||
52 Hyra av anläggningstillgångar
|
||||
54 Förbrukningsinventarier och förbrukningsmaterial
|
||||
Kontoplan_K1_2018_ver1
|
||||
6/9
|
||||
Rad Rad
|
||||
5400 Förbrukningsinventarier och
|
||||
förbrukningsmaterial
|
||||
R6
|
||||
Rad Rad
|
||||
5500 Reparation och underhåll R6
|
||||
Rad Rad
|
||||
5600 Kostnader för transportmedel R6
|
||||
5610 Personbilskostnader R6 5611 Drivmedel för personbilar R6
|
||||
5612 Försäkring och skatt för
|
||||
personbilar
|
||||
R6
|
||||
5613 Reparation och underhåll av
|
||||
personbilar
|
||||
R6
|
||||
5615 Leasing av personbilar R6
|
||||
5618 Schablonmässig milkostnad
|
||||
privat personbil
|
||||
R6
|
||||
5619 Övriga personbilskostnader R6
|
||||
5620 Lastbilskostnader R6
|
||||
Rad Rad
|
||||
5700 Frakter och transporter R6
|
||||
Rad Rad
|
||||
5800 Resekostnader R6
|
||||
Rad Rad
|
||||
5900 Reklam och PR R6
|
||||
Rad Rad
|
||||
6000 Övriga försäljningskostnader
|
||||
R6
|
||||
R6 R6
|
||||
6070 Representation R6 R6 6071 Representation, avdragsgill
|
||||
6072 Representation, ej avdragsgill R6
|
||||
+
|
||||
NE
|
||||
sid.
|
||||
2
|
||||
BAS-konton
|
||||
BAS-konton
|
||||
Underkonton
|
||||
55 Reparation och underhåll
|
||||
56 Kostnader för transportmedel
|
||||
BAS-konton Underkonton
|
||||
Underkonton
|
||||
Underkonton
|
||||
BAS-konton Underkonton
|
||||
57 Frakter och transporter
|
||||
58 Resekostnader
|
||||
59 Reklam och PR
|
||||
60 Övriga försäljningskostnader
|
||||
BAS-konton
|
||||
Underkonton
|
||||
BAS-konton
|
||||
Underkonton
|
||||
BAS-konton
|
||||
Kontoplan_K1_2018_ver1
|
||||
7/9
|
||||
Rad Rad
|
||||
6100 Kontorsmateriel och trycksaker R6
|
||||
Rad Rad
|
||||
6200 Tele och post R6
|
||||
Rad Rad
|
||||
6300 Företagsförsäkringar och
|
||||
övriga riskkostnader
|
||||
R6
|
||||
6310 Företagsförsäkringar R6
|
||||
Rad Rad
|
||||
6500 Övriga externa tjänster R6
|
||||
Rad Rad
|
||||
6800 Inhyrd personal R6
|
||||
Rad Rad
|
||||
6900 Övriga kostnader R6
|
||||
6980 Föreningsavgifter R6
|
||||
Rad Rad
|
||||
7000 Löner till anställda R7
|
||||
Rad Rad
|
||||
7300 Kostnadsersättningar och
|
||||
förmåner
|
||||
R7
|
||||
Rad Rad
|
||||
7400 Pensionskostnader R7
|
||||
74 Pensionskostnader
|
||||
61 Kontorsmateriel och trycksaker
|
||||
62 Tele och post
|
||||
BAS-konton Underkonton
|
||||
BAS-konton Underkonton
|
||||
63 Företagsförsäkringar och övriga riskkostnader
|
||||
Underkonton
|
||||
70 Löner till anställda
|
||||
7 UTGIFTER/KOSTNADER FÖR PERSONAL, AVSKRIVNINGAR
|
||||
BAS-konton
|
||||
68 Inhyrd personal
|
||||
69 Övriga kostnader
|
||||
BAS-konton Underkonton
|
||||
BAS-konton Underkonton
|
||||
BAS-konton Underkonton
|
||||
65 Övriga externa tjänster
|
||||
BAS-konton Underkonton
|
||||
BAS-konton Underkonton
|
||||
73 Kostnadsersättningar och förmåner
|
||||
BAS-konton Underkonton
|
||||
Kontoplan_K1_2018_ver1
|
||||
8/9
|
||||
Rad Rad
|
||||
7500 Sociala och andra avgifter
|
||||
enligt lag och avtal
|
||||
R7
|
||||
Rad Rad
|
||||
7600 Övriga personalkostnader R7 7631 Personalrepresentation,
|
||||
avdragsgill
|
||||
R7
|
||||
7632 Personalrepresentation, ej
|
||||
avdragsgill
|
||||
R7
|
||||
+
|
||||
NE
|
||||
sid.
|
||||
2
|
||||
Rad Rad
|
||||
7700 Nedskrivningar R9
|
||||
alt.
|
||||
R10
|
||||
Rad Rad
|
||||
7810 Avskrivningar på immateriella
|
||||
anläggningstillgångar
|
||||
R10
|
||||
7820 Avskrivningar på byggnader
|
||||
och markanläggningar
|
||||
R9
|
||||
7830 Avskrivningar på maskiner och
|
||||
inventarier
|
||||
R10
|
||||
Rad Rad
|
||||
7970 Förlust vid avyttring av
|
||||
immateriella och materiella
|
||||
anläggningstillgångar
|
||||
R6
|
||||
7980 Ersättningsfonder R9/
|
||||
10
|
||||
Rad Rad
|
||||
8 FINANSIELLA OCH ANDRA INKOMSTER/INTÄKTER OCH
|
||||
UTGIFTER/ KOSTNADER
|
||||
Underkonton
|
||||
BAS-konton Underkonton
|
||||
79 Övriga rörelsekostnader
|
||||
77 Nedskrivningar och återföring av nedskrivningar
|
||||
76 Övriga personalkostnader
|
||||
78 Avskrivningar
|
||||
83 Övriga ränteintäkter och liknande resultatposter
|
||||
BAS-konton Underkonton
|
||||
BAS-konton Underkonton
|
||||
75 Sociala och andra avgifter enligt lag och avtal
|
||||
BAS-konton
|
||||
BAS-konton Underkonton
|
||||
BAS-konton Underkonton
|
||||
Kontoplan_K1_2018_ver1
|
||||
9/9
|
||||
8310 Ränteintäkter och utdelningar R4 8314 Skattefria ränteintäkter R4
|
||||
+
|
||||
NE
|
||||
sid.
|
||||
2
|
||||
8330 Valutakursdifferenser på
|
||||
fordringar och placeringar
|
||||
R4
|
||||
Rad Rad
|
||||
8410 Räntekostnader för skulder R8
|
||||
8430 Valutakursdifferenser på
|
||||
skulder R8
|
||||
Rad Rad
|
||||
8990 Resultat R11 R11 8999 Årets resultat R11
|
||||
84 Räntekostnader och liknande
|
||||
BAS-konton Underkonton
|
||||
BAS-konton Underkonton
|
||||
88 Bokslutsdispositioner
|
||||
89 Årets resultat
|
||||
Kontoplan_K1_2018_ver1
|
||||
@@ -0,0 +1,51 @@
|
||||
ERP-Base: Gap-analys mot svensk bokföringsmarknad
|
||||
|
||||
1. SAKNAS HELT — Kritiska luckor
|
||||
1.1 Leverantörsreskontra
|
||||
Alla etablerade system har fullständig leverantörsreskontra: registrering av inkommande fakturor, förfallodatum, betalningsstatus, automatisk bokföring vid betalning. Ditt system saknar tabeller och flöden för leverantörsfakturor. Detta är ett absolut krav för att kunna kallas bokföringssystem.
|
||||
Behövs: suppliers-tabell, supplier_invoices-tabell, flöde för registrering/betalning/bokföring, leverantörsreskontra-rapport, stöd för både kontant- och fakturametoden.
|
||||
1.2 Kundreskontra (formellt)
|
||||
Du har invoices och customers, men det saknas en explicit kundreskontra-vy som visar utestående fordringar, förfallna fakturor, och avstämning mot konto 1510. Alla konkurrenter har detta som standardfunktion.
|
||||
1.3 Lönehantering
|
||||
salary_payments finns men alla konkurrenter (Fortnox, Bokio, Visma) erbjuder komplett lönehantering: lönespecifikationer, arbetsgivaravgifter, skattetabeller (FOS-förfrågan mot Skatteverket), AGI-rapportering, semesterhantering. Detta är en separat modul som de flesta SME-kunder förväntar sig.
|
||||
Behövs: Skattetabellhantering, lönespec-generering (PDF), arbetsgivaravgiftsberäkning, AGI-rapportering, semesterskuld, förmånsberäkning (bil, etc).
|
||||
1.4 Årsredovisning (K2/K3)
|
||||
Aktiebolag måste lämna årsredovisning till Bolagsverket. Fortnox och Björn Lundén genererar detta. Din plattform har årsbokslut men saknar årsredovisningsgenerering med förvaltningsberättelse, noter, och formell K2/K3-struktur.
|
||||
Behövs: Generering av förvaltningsberättelse, resultaträkning (K2-format), balansräkning (K2-format), noter, digital inlämning till Bolagsverket (XBRL).
|
||||
1.5 Kontantmetod-stöd
|
||||
Många enskilda firmor bokför med kontantmetoden (bokslutsmetoden). Ditt system verkar byggt kring faktureringsmetoden. Båda måste stödjas, med automatisk övergång till fakturametod vid bokslut för kontantmetoden.
|
||||
1.6 Anläggningsregister
|
||||
Inventarier, maskiner, fastigheter — med avskrivningsplaner (linjär/degressiv), restvärden, och automatisk avskrivningsbokföring. Saknas helt. Krävs för AB med tillgångar.
|
||||
1.7 Offert/Order-flöde
|
||||
Fortnox och Visma har offert → order → faktura-kedja. Inte nödvändigt för MVP men förväntat i ett komplett system.
|
||||
|
||||
2. FINNS MEN OTILLRÄCKLIGT — Behöver utökas
|
||||
2.1 Bokföringsmallar / Konteringshjälp
|
||||
Bokio's stora USP är smart konteringshjälp: användaren väljer "IT-tjänst 25% moms" och systemet konterar automatiskt. Du har AI-kategorisering, men saknar troligen ett bibliotek av färdiga bokföringsmallar för vanliga affärshändelser som en nybörjare kan välja mellan.
|
||||
Behövs: 50-100 vanliga transaktionsmallar (kontorsmateriell, IT-tjänst, bensin, representation, etc) med korrekt moms och kontering.
|
||||
2.2 Bankavstämning
|
||||
Du har PSD2-transaktionssynk, men behöver explicit bankavstämning: matcha banktransaktioner mot bokförda poster, markera avstämda, visa differenser. Alla konkurrenter har detta.
|
||||
2.3 Momsdeklaration
|
||||
Du nämner "10 rutor" men verifierar att den genererar korrekt SKV 4820-underlag? Behöver också stödja: EU-handel (omvänd skattskyldighet), import/export-moms, olika momssatser (25/12/6/0%), tröskelbelopp (120 000 SEK från 2025).
|
||||
2.4 SIE-export
|
||||
Du har SIE4-export. Verifiera att SIE-import också fungerar korrekt (ingående balanser, verifikationer, kontoplan) — detta är kritiskt för att kunder ska kunna byta till ditt system från Fortnox/Bokio.
|
||||
2.5 Rapporter
|
||||
Du har saldobalans, resultat, balans, moms. Saknar troligen:
|
||||
Huvudbok (alla transaktioner per konto)
|
||||
Grundbok (verifikationslista i datumordning)
|
||||
Kundreskontra-rapport
|
||||
Leverantörsreskontra-rapport
|
||||
Periodrapporter (jämförelse mellan perioder)
|
||||
Kassaflödesanalys
|
||||
|
||||
3. HYGIEN-FUNKTIONER — Förväntas av alla
|
||||
3.1 Autentisering
|
||||
BankID-inloggning förväntas av svenska användare. Inte nödvändigt dag 1, men e-post + lösenord + 2FA via TOTP är minimum.
|
||||
3.2 Mobilapp / Responsivt
|
||||
Alla konkurrenter har mobilapp eller fullt responsivt gränssnitt. Kvittofotografering från mobil är en hygienfaktor.
|
||||
3.3 Periodlåsning
|
||||
Bokföringslagen kräver att bokföring är "varaktig" — du behöver kunna låsa perioder så att poster inte kan ändras i efterhand utan att det syns. Du har WORM-arkiv, verifiera att periodlåsning är implementerad.
|
||||
3.4 Fleranvändarstöd
|
||||
Roller: ägare, redovisningskonsult (extern), anställd. Behörigheter per modul. Alla konkurrenter har detta. Redovisningskonsult-access är affärskritiskt — byråer är den viktigaste distributionskanalen.
|
||||
3.5 Verifikationskedja
|
||||
Varje verifikation behöver: löpnummer utan luckor, datum, belopp, motkonto, beskrivning, bifogat underlag. Du har detta delvis via WORM + voucher numbering, men verifiera fullständigt BFL-compliance.
|
||||
@@ -52,22 +52,25 @@ export interface CategorizationProvider {
|
||||
// BAS Account + Category Mapping (used in prompt)
|
||||
// ============================================================
|
||||
|
||||
const CATEGORY_ACCOUNT_MAP: Record<string, { account: string; label: string }> = {
|
||||
income_services: { account: '3001', label: 'Tjänsteförsäljning' },
|
||||
income_products: { account: '3001', label: 'Varuförsäljning' },
|
||||
income_other: { account: '3900', label: 'Övriga intäkter' },
|
||||
expense_equipment: { account: '5410', label: 'Förbrukningsinventarier' },
|
||||
expense_software: { account: '5420', label: 'Programvara' },
|
||||
expense_travel: { account: '5800', label: 'Resekostnader' },
|
||||
expense_office: { account: '5010', label: 'Lokalhyra/kontorskostnad' },
|
||||
expense_marketing: { account: '5910', label: 'Annonsering/marknadsföring' },
|
||||
expense_professional_services: { account: '6530', label: 'Redovisning/konsulttjänster' },
|
||||
expense_education: { account: '6991', label: 'Utbildning' },
|
||||
expense_bank_fees: { account: '6570', label: 'Bankavgifter' },
|
||||
expense_card_fees: { account: '6570', label: 'Kortavgifter' },
|
||||
expense_currency_exchange: { account: '7960', label: 'Valutakursförluster' },
|
||||
expense_other: { account: '6991', label: 'Övriga kostnader' },
|
||||
private: { account: '2013', label: 'Privat uttag (EF) / Skuld till ägare (AB)' },
|
||||
function getCategoryAccountMap(entityType: EntityType): Record<string, { account: string; label: string }> {
|
||||
const educationAccount = entityType === 'aktiebolag' ? '7610' : '6991'
|
||||
return {
|
||||
income_services: { account: '3001', label: 'Tjänsteförsäljning' },
|
||||
income_products: { account: '3001', label: 'Varuförsäljning' },
|
||||
income_other: { account: '3900', label: 'Övriga intäkter' },
|
||||
expense_equipment: { account: '5410', label: 'Förbrukningsinventarier' },
|
||||
expense_software: { account: '5420', label: 'Programvara' },
|
||||
expense_travel: { account: '5800', label: 'Resekostnader' },
|
||||
expense_office: { account: '5010', label: 'Lokalhyra/kontorskostnad' },
|
||||
expense_marketing: { account: '5910', label: 'Annonsering/marknadsföring' },
|
||||
expense_professional_services: { account: '6530', label: 'Redovisning/konsulttjänster' },
|
||||
expense_education: { account: educationAccount, label: 'Utbildning' },
|
||||
expense_bank_fees: { account: '6570', label: 'Bankavgifter' },
|
||||
expense_card_fees: { account: '6570', label: 'Kortavgifter' },
|
||||
expense_currency_exchange: { account: '7960', label: 'Valutakursförluster' },
|
||||
expense_other: { account: '6991', label: 'Övriga kostnader' },
|
||||
private: { account: '2013', label: 'Privat uttag (EF) / Skuld till ägare (AB)' },
|
||||
}
|
||||
}
|
||||
|
||||
const NON_DEDUCTIBLE_RULES = `
|
||||
@@ -107,12 +110,13 @@ export class AnthropicCategorizationProvider implements CategorizationProvider {
|
||||
if (batch.length === 0) return []
|
||||
|
||||
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.
|
||||
|
||||
KATEGORIER OCH BAS-KONTON:
|
||||
${Object.entries(CATEGORY_ACCOUNT_MAP)
|
||||
${Object.entries(categoryAccountMap)
|
||||
.map(([cat, info]) => `- ${cat}: ${info.account} (${info.label})`)
|
||||
.join('\n')}
|
||||
|
||||
@@ -209,7 +213,7 @@ Returnera ENDAST JSON-objektet, ingen annan text.`
|
||||
jsonText = jsonText.trim()
|
||||
|
||||
const parsed = JSON.parse(jsonText)
|
||||
return this.validateSuggestions(parsed.suggestions || [], batch)
|
||||
return this.validateSuggestions(parsed.suggestions || [], batch, context.entityType)
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error : new Error('Unknown error')
|
||||
|
||||
@@ -231,12 +235,14 @@ Returnera ENDAST JSON-objektet, ingen annan text.`
|
||||
|
||||
private validateSuggestions(
|
||||
raw: unknown[],
|
||||
transactions: TransactionForCategorization[]
|
||||
transactions: TransactionForCategorization[],
|
||||
entityType: EntityType
|
||||
): CategorizationSuggestion[] {
|
||||
if (!Array.isArray(raw)) return []
|
||||
|
||||
const categoryAccountMap = getCategoryAccountMap(entityType)
|
||||
const validTransactionIds = new Set(transactions.map((t) => t.id))
|
||||
const validCategories = new Set(Object.keys(CATEGORY_ACCOUNT_MAP).concat(['uncategorized']))
|
||||
const validCategories = new Set(Object.keys(categoryAccountMap).concat(['uncategorized']))
|
||||
|
||||
return raw
|
||||
.filter(
|
||||
@@ -249,7 +255,7 @@ Returnera ENDAST JSON-objektet, ingen annan text.`
|
||||
? (s.category as TransactionCategory)
|
||||
: 'expense_other'
|
||||
|
||||
const accountInfo = CATEGORY_ACCOUNT_MAP[category]
|
||||
const accountInfo = categoryAccountMap[category]
|
||||
|
||||
return {
|
||||
transactionId: s.transactionId as string,
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { formatDate } from '@/lib/utils'
|
||||
import { getDaysUntilExpiry, isConsentExpiringSoon } from '@/lib/banking/enable-banking'
|
||||
import { getDaysUntilExpiry, isConsentExpiringSoon } from '../lib/api-client'
|
||||
import {
|
||||
CreditCard,
|
||||
AlertTriangle,
|
||||
@@ -0,0 +1,225 @@
|
||||
import type { Extension } from '@/lib/extensions/types'
|
||||
import { createClient, createServiceClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { eventBus } from '@/lib/events'
|
||||
import {
|
||||
startAuthorization,
|
||||
getASPSPs,
|
||||
createSession,
|
||||
getAccountBalance,
|
||||
isConsentExpiringSoon,
|
||||
getDaysUntilExpiry,
|
||||
type ASPSP,
|
||||
type AccountInfo,
|
||||
} from './lib/api-client'
|
||||
import { syncAccountTransactions } from './lib/sync'
|
||||
import type { StoredAccount } from './types'
|
||||
import type { Transaction } from '@/types'
|
||||
|
||||
/**
|
||||
* Enable Banking (PSD2) extension
|
||||
*
|
||||
* Provides automatic bank transaction sync via PSD2 open banking.
|
||||
* This is an opt-in extension — uncomment the import in loader.ts to activate.
|
||||
*
|
||||
* Required environment variables:
|
||||
* - ENABLE_BANKING_APP_ID
|
||||
* - ENABLE_BANKING_PRIVATE_KEY (base64-encoded PEM)
|
||||
* - ENABLE_BANKING_SANDBOX (optional, for sandbox mode)
|
||||
*/
|
||||
export const enableBankingExtension: Extension = {
|
||||
id: 'enable-banking',
|
||||
name: 'Enable Banking (PSD2)',
|
||||
version: '1.0.0',
|
||||
|
||||
settingsPanel: {
|
||||
label: 'Bankintegration (PSD2)',
|
||||
path: '/settings?tab=banking',
|
||||
},
|
||||
|
||||
apiRoutes: [
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/banks',
|
||||
handler: async () => {
|
||||
try {
|
||||
const aspsps = await getASPSPs('SE')
|
||||
const banks = aspsps.map((aspsp: ASPSP) => ({
|
||||
name: aspsp.name,
|
||||
country: aspsp.country,
|
||||
logo: aspsp.logo,
|
||||
bic: aspsp.bic,
|
||||
}))
|
||||
return NextResponse.json({ banks })
|
||||
} catch (error) {
|
||||
console.error('Error fetching banks:', error)
|
||||
return NextResponse.json({
|
||||
banks: [
|
||||
{ name: 'Nordea', country: 'SE', bic: 'NDEASESS' },
|
||||
{ name: 'SEB', country: 'SE', bic: 'ESSESESS' },
|
||||
{ name: 'Swedbank', country: 'SE', bic: 'SWEDSESS' },
|
||||
{ name: 'Handelsbanken', country: 'SE', bic: 'HANDSESS' },
|
||||
]
|
||||
})
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/connect',
|
||||
handler: async (request: Request) => {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { aspsp_name, aspsp_country } = await request.json()
|
||||
|
||||
if (!aspsp_name || !aspsp_country) {
|
||||
return NextResponse.json(
|
||||
{ error: 'aspsp_name and aspsp_country are required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const redirectUrl = `${process.env.NEXT_PUBLIC_APP_URL}/api/extensions/enable-banking/callback`
|
||||
|
||||
const { url, authorization_id } = await startAuthorization(
|
||||
aspsp_name,
|
||||
aspsp_country,
|
||||
redirectUrl,
|
||||
user.id,
|
||||
'personal'
|
||||
)
|
||||
|
||||
const { data: connection, error } = await supabase
|
||||
.from('bank_connections')
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
provider: `${aspsp_name.toLowerCase().replace(/\s+/g, '-')}-${aspsp_country.toLowerCase()}`,
|
||||
bank_name: aspsp_name,
|
||||
authorization_id,
|
||||
status: 'pending',
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
console.error('Database error:', error)
|
||||
throw new Error('Failed to store connection')
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
connection_id: connection.id,
|
||||
authorization_url: url,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Bank connection error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Connection failed' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/sync',
|
||||
handler: async (request: Request) => {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { connection_id, days_back = 30 } = await request.json()
|
||||
|
||||
const { data: connection, error: connectionError } = await supabase
|
||||
.from('bank_connections')
|
||||
.select('*')
|
||||
.eq('id', connection_id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (connectionError || !connection) {
|
||||
return NextResponse.json({ error: 'Connection not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (connection.status !== 'active') {
|
||||
return NextResponse.json({ error: 'Connection is not active' }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
const accounts = (connection.accounts_data as StoredAccount[] || []).map(a => ({ ...a }))
|
||||
|
||||
const toDate = new Date().toISOString().split('T')[0]
|
||||
const fromDate = new Date(Date.now() - days_back * 24 * 60 * 60 * 1000)
|
||||
.toISOString()
|
||||
.split('T')[0]
|
||||
|
||||
let totalImported = 0
|
||||
let totalDuplicates = 0
|
||||
|
||||
for (const account of accounts) {
|
||||
const result = await syncAccountTransactions(
|
||||
supabase,
|
||||
user.id,
|
||||
connection.id,
|
||||
account,
|
||||
fromDate,
|
||||
toDate
|
||||
)
|
||||
|
||||
totalImported += result.imported
|
||||
totalDuplicates += result.duplicates
|
||||
}
|
||||
|
||||
const syncedAt = new Date().toISOString()
|
||||
await supabase
|
||||
.from('bank_connections')
|
||||
.update({
|
||||
accounts_data: accounts,
|
||||
last_synced_at: syncedAt,
|
||||
})
|
||||
.eq('id', connection.id)
|
||||
|
||||
if (totalImported > 0) {
|
||||
const { data: syncedTransactions } = await supabase
|
||||
.from('transactions')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
.eq('bank_connection_id', connection.id)
|
||||
.gte('created_at', fromDate)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(totalImported)
|
||||
|
||||
if (syncedTransactions && syncedTransactions.length > 0) {
|
||||
await eventBus.emit({
|
||||
type: 'transaction.synced',
|
||||
payload: { transactions: syncedTransactions as Transaction[], userId: user.id },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
imported: totalImported,
|
||||
duplicates: totalDuplicates,
|
||||
last_synced_at: syncedAt,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Sync error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Sync failed' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
eventHandlers: [],
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { getTransactions, getAccountBalance } from './api-client'
|
||||
import { ingestTransactions, type RawTransaction } from '@/lib/transactions/ingest'
|
||||
import type { StoredAccount } from '../types'
|
||||
|
||||
export interface SyncResult {
|
||||
imported: number
|
||||
duplicates: number
|
||||
errors: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync transactions for a single bank account via Enable Banking PSD2.
|
||||
*
|
||||
* Fetches transactions from the Enable Banking API, converts to RawTransaction
|
||||
* format, and delegates to the shared ingestion pipeline.
|
||||
*/
|
||||
export async function syncAccountTransactions(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
connectionId: string,
|
||||
account: StoredAccount,
|
||||
fromDate: string,
|
||||
toDate: string
|
||||
): Promise<SyncResult> {
|
||||
const bankTransactions = await getTransactions(
|
||||
account.uid,
|
||||
fromDate,
|
||||
toDate,
|
||||
account.currency
|
||||
)
|
||||
|
||||
// Convert Enable Banking format to generic RawTransaction
|
||||
const rawTransactions: RawTransaction[] = bankTransactions.map((tx) => ({
|
||||
date: tx.booking_date || tx.date,
|
||||
description: tx.description || tx.counterparty_name || 'Unknown',
|
||||
amount: tx.amount,
|
||||
currency: tx.currency || account.currency,
|
||||
external_id: `${connectionId}_${tx.id}`,
|
||||
mcc_code: tx.merchant_category_code ? parseInt(tx.merchant_category_code, 10) : null,
|
||||
merchant_name: tx.counterparty_name || null,
|
||||
reference: tx.reference || null,
|
||||
bank_connection_id: connectionId,
|
||||
import_source: 'enable_banking',
|
||||
}))
|
||||
|
||||
const ingestResult = await ingestTransactions(supabase, userId, rawTransactions)
|
||||
|
||||
// Update account balance
|
||||
try {
|
||||
const balance = await getAccountBalance(account.uid)
|
||||
account.balance = balance.amount
|
||||
} catch {
|
||||
// Ignore balance fetch errors
|
||||
}
|
||||
|
||||
return {
|
||||
imported: ingestResult.imported,
|
||||
duplicates: ingestResult.duplicates,
|
||||
errors: ingestResult.errors,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// Enable Banking extension types
|
||||
|
||||
export interface StoredAccount {
|
||||
uid: string
|
||||
iban?: string
|
||||
name?: string
|
||||
currency: string
|
||||
balance?: number
|
||||
}
|
||||
|
||||
// Re-export API types from the client
|
||||
export type {
|
||||
ASPSP,
|
||||
AuthMethod,
|
||||
AuthResponse,
|
||||
SessionResponse,
|
||||
AccountInfo,
|
||||
Balance,
|
||||
BalanceResponse,
|
||||
Transaction as EnableBankingTransaction,
|
||||
TransactionsResponse,
|
||||
Bank,
|
||||
BankTransaction,
|
||||
} from './lib/api-client'
|
||||
@@ -0,0 +1,122 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { NE_ACCOUNT_MAPPINGS } from '../ne-engine'
|
||||
import type { NEAccountMapping } from '@/types'
|
||||
|
||||
/**
|
||||
* Helper to check if an account falls into a specific ruta
|
||||
*/
|
||||
function findRutaForAccount(accountNumber: string): string | null {
|
||||
for (const mapping of NE_ACCOUNT_MAPPINGS) {
|
||||
for (const range of mapping.accountRanges) {
|
||||
if (accountNumber >= range.start && accountNumber <= range.end) {
|
||||
if (range.exclude && range.exclude.includes(accountNumber)) {
|
||||
continue
|
||||
}
|
||||
return mapping.ruta
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
describe('NE Account Mappings', () => {
|
||||
describe('R1 - Försäljning med moms', () => {
|
||||
it('includes standard revenue accounts 3001-3003', () => {
|
||||
expect(findRutaForAccount('3001')).toBe('R1')
|
||||
expect(findRutaForAccount('3002')).toBe('R1')
|
||||
expect(findRutaForAccount('3003')).toBe('R1')
|
||||
})
|
||||
|
||||
it('excludes 3100 (momsfria intäkter)', () => {
|
||||
expect(findRutaForAccount('3100')).not.toBe('R1')
|
||||
})
|
||||
|
||||
it('includes 3500 (Fakturerade kostnader)', () => {
|
||||
expect(findRutaForAccount('3500')).toBe('R1')
|
||||
})
|
||||
|
||||
it('includes 3500-3599 range', () => {
|
||||
expect(findRutaForAccount('3510')).toBe('R1')
|
||||
expect(findRutaForAccount('3599')).toBe('R1')
|
||||
})
|
||||
|
||||
it('includes 3700-3799 (Lämnade rabatter)', () => {
|
||||
expect(findRutaForAccount('3700')).toBe('R1')
|
||||
expect(findRutaForAccount('3731')).toBe('R1')
|
||||
expect(findRutaForAccount('3799')).toBe('R1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('R2 - Momsfria intäkter', () => {
|
||||
it('includes 3100', () => {
|
||||
expect(findRutaForAccount('3100')).toBe('R2')
|
||||
})
|
||||
|
||||
it('includes 3900 (Övriga rörelseintäkter)', () => {
|
||||
expect(findRutaForAccount('3900')).toBe('R2')
|
||||
})
|
||||
|
||||
it('includes 3910 (Hyresintäkter)', () => {
|
||||
expect(findRutaForAccount('3910')).toBe('R2')
|
||||
})
|
||||
|
||||
it('includes 3920 (Provisionsintäkter)', () => {
|
||||
expect(findRutaForAccount('3920')).toBe('R2')
|
||||
})
|
||||
|
||||
it('includes 3950 (Återvunna kundfordringar)', () => {
|
||||
expect(findRutaForAccount('3950')).toBe('R2')
|
||||
})
|
||||
|
||||
it('includes 3960 (Valutakursvinster)', () => {
|
||||
expect(findRutaForAccount('3960')).toBe('R2')
|
||||
})
|
||||
|
||||
it('includes 3970-3980 range', () => {
|
||||
expect(findRutaForAccount('3970')).toBe('R2')
|
||||
expect(findRutaForAccount('3980')).toBe('R2')
|
||||
})
|
||||
|
||||
it('includes 3981-3999 range', () => {
|
||||
expect(findRutaForAccount('3981')).toBe('R2')
|
||||
expect(findRutaForAccount('3990')).toBe('R2')
|
||||
expect(findRutaForAccount('3999')).toBe('R2')
|
||||
})
|
||||
})
|
||||
|
||||
describe('accounts are not silently dropped', () => {
|
||||
it('3500 is mapped (not dropped)', () => {
|
||||
expect(findRutaForAccount('3500')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('3700 is mapped (not dropped)', () => {
|
||||
expect(findRutaForAccount('3700')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('3910 is mapped (not dropped)', () => {
|
||||
expect(findRutaForAccount('3910')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('3960 is mapped (not dropped)', () => {
|
||||
expect(findRutaForAccount('3960')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('3990 is mapped (not dropped)', () => {
|
||||
expect(findRutaForAccount('3990')).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('no overlap between R1 and R2', () => {
|
||||
it('3100 is in R2 not R1', () => {
|
||||
expect(findRutaForAccount('3100')).toBe('R2')
|
||||
})
|
||||
|
||||
it('3001 is in R1 not R2', () => {
|
||||
expect(findRutaForAccount('3001')).toBe('R1')
|
||||
})
|
||||
|
||||
it('3900 is in R2 not R1', () => {
|
||||
expect(findRutaForAccount('3900')).toBe('R2')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -15,8 +15,8 @@ import type {
|
||||
* tax reporting to Skatteverket.
|
||||
*
|
||||
* Account mappings:
|
||||
* R1: Försäljning med moms (3000-3499 excl 3100)
|
||||
* R2: Momsfria intäkter (3100, 3900, 3970, 3980) - inkl gåvor utan motprestation
|
||||
* R1: Försäljning med moms (3000-3599 excl 3100, 3700-3799)
|
||||
* R2: Momsfria intäkter (3100, 3900-3969, 3970-3980, 3981-3999) - inkl gåvor utan motprestation
|
||||
* R3: Bil/bostadsförmån (3200)
|
||||
* R4: Ränteintäkter (8310-8330)
|
||||
* R5: Varuinköp (4000-4990)
|
||||
@@ -41,7 +41,8 @@ export const NE_ACCOUNT_MAPPINGS: NEAccountMapping[] = [
|
||||
ruta: 'R1',
|
||||
description: 'Försäljning med moms (25%)',
|
||||
accountRanges: [
|
||||
{ start: '3000', end: '3499', exclude: ['3100'] },
|
||||
{ start: '3000', end: '3599', exclude: ['3100'] },
|
||||
{ start: '3700', end: '3799' },
|
||||
],
|
||||
isExpense: false,
|
||||
},
|
||||
@@ -50,8 +51,9 @@ export const NE_ACCOUNT_MAPPINGS: NEAccountMapping[] = [
|
||||
description: 'Momsfria intäkter',
|
||||
accountRanges: [
|
||||
{ start: '3100', end: '3100' },
|
||||
{ start: '3900', end: '3900' }, // Övriga rörelseintäkter (inkl gåvor utan motprestation)
|
||||
{ start: '3900', end: '3969' }, // Övriga rörelseintäkter (inkl gåvor utan motprestation)
|
||||
{ start: '3970', end: '3980' },
|
||||
{ start: '3981', end: '3999' },
|
||||
],
|
||||
isExpense: false,
|
||||
},
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
import { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { getTransactions, getAccountBalance } from '@/lib/banking/enable-banking'
|
||||
import { evaluateMappingRules } from '@/lib/bookkeeping/mapping-engine'
|
||||
import { createTransactionJournalEntry } from '@/lib/bookkeeping/transaction-entries'
|
||||
import { getBestInvoiceMatch } from '@/lib/invoice/invoice-matching'
|
||||
import type { Transaction } from '@/types'
|
||||
|
||||
interface StoredAccount {
|
||||
uid: string
|
||||
iban?: string
|
||||
name?: string
|
||||
currency: string
|
||||
balance?: number
|
||||
}
|
||||
|
||||
export interface SyncResult {
|
||||
imported: number
|
||||
duplicates: number
|
||||
errors: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync transactions for a single bank account
|
||||
* Shared logic used by both manual sync and cron job
|
||||
*/
|
||||
export async function syncAccountTransactions(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
connectionId: string,
|
||||
account: StoredAccount,
|
||||
fromDate: string,
|
||||
toDate: string
|
||||
): Promise<SyncResult> {
|
||||
const result: SyncResult = { imported: 0, duplicates: 0, errors: 0 }
|
||||
|
||||
const bankTransactions = await getTransactions(
|
||||
account.uid,
|
||||
fromDate,
|
||||
toDate,
|
||||
account.currency
|
||||
)
|
||||
|
||||
for (const tx of bankTransactions) {
|
||||
const externalId = `${connectionId}_${tx.id}`
|
||||
|
||||
// Check for duplicates
|
||||
const { data: existing } = await supabase
|
||||
.from('transactions')
|
||||
.select('id')
|
||||
.eq('user_id', userId)
|
||||
.eq('external_id', externalId)
|
||||
.single()
|
||||
|
||||
if (existing) {
|
||||
result.duplicates++
|
||||
continue
|
||||
}
|
||||
|
||||
// Insert new transaction
|
||||
const { data: newTransaction, error: insertError } = await supabase
|
||||
.from('transactions')
|
||||
.insert({
|
||||
user_id: userId,
|
||||
bank_connection_id: connectionId,
|
||||
external_id: externalId,
|
||||
date: tx.booking_date || tx.date,
|
||||
description: tx.description || tx.counterparty_name || 'Unknown',
|
||||
amount: tx.amount,
|
||||
currency: tx.currency || account.currency,
|
||||
category: 'uncategorized',
|
||||
is_business: null,
|
||||
mcc_code: tx.merchant_category_code || null,
|
||||
merchant_name: tx.counterparty_name || null,
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (insertError || !newTransaction) {
|
||||
result.errors++
|
||||
continue
|
||||
}
|
||||
|
||||
result.imported++
|
||||
|
||||
// For income transactions, try to find matching invoices
|
||||
if (newTransaction.amount > 0) {
|
||||
try {
|
||||
const bestMatch = await getBestInvoiceMatch(
|
||||
userId,
|
||||
newTransaction as Transaction,
|
||||
0.50
|
||||
)
|
||||
|
||||
if (bestMatch) {
|
||||
await supabase
|
||||
.from('transactions')
|
||||
.update({ potential_invoice_id: bestMatch.invoice.id })
|
||||
.eq('id', newTransaction.id)
|
||||
}
|
||||
} catch {
|
||||
// Non-critical
|
||||
}
|
||||
}
|
||||
|
||||
// Evaluate mapping rules for auto-categorization
|
||||
try {
|
||||
const mappingResult = await evaluateMappingRules(
|
||||
userId,
|
||||
newTransaction as Transaction
|
||||
)
|
||||
|
||||
if (mappingResult.confidence >= 0.8 && !mappingResult.requires_review) {
|
||||
const journalEntry = await createTransactionJournalEntry(
|
||||
userId,
|
||||
newTransaction as Transaction,
|
||||
mappingResult
|
||||
)
|
||||
|
||||
if (journalEntry) {
|
||||
await supabase
|
||||
.from('transactions')
|
||||
.update({
|
||||
journal_entry_id: journalEntry.id,
|
||||
is_business: !mappingResult.default_private,
|
||||
})
|
||||
.eq('id', newTransaction.id)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Non-critical
|
||||
}
|
||||
}
|
||||
|
||||
// Update account balance
|
||||
try {
|
||||
const balance = await getAccountBalance(account.uid)
|
||||
account.balance = balance.amount
|
||||
} catch {
|
||||
// Ignore balance fetch errors
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { getCategoryAccountMapping, getExpenseAccountForCategory } from '../category-mapping'
|
||||
|
||||
describe('getCategoryAccountMapping', () => {
|
||||
describe('income_products uses correct account', () => {
|
||||
it('maps income_products to 3001 (25% moms)', () => {
|
||||
const result = getCategoryAccountMapping('income_products', 1000, true)
|
||||
expect(result.creditAccount).toBe('3001')
|
||||
})
|
||||
|
||||
it('income_products matches income_services account', () => {
|
||||
const products = getCategoryAccountMapping('income_products', 1000, true)
|
||||
const services = getCategoryAccountMapping('income_services', 1000, true)
|
||||
expect(products.creditAccount).toBe(services.creditAccount)
|
||||
})
|
||||
})
|
||||
|
||||
describe('expense_education entity-type-aware', () => {
|
||||
it('defaults to 6991 for enskild_firma', () => {
|
||||
const result = getCategoryAccountMapping('expense_education', -500, true, 'enskild_firma')
|
||||
expect(result.debitAccount).toBe('6991')
|
||||
})
|
||||
|
||||
it('uses 7610 for aktiebolag', () => {
|
||||
const result = getCategoryAccountMapping('expense_education', -500, true, 'aktiebolag')
|
||||
expect(result.debitAccount).toBe('7610')
|
||||
})
|
||||
|
||||
it('defaults to 6991 when no entityType provided', () => {
|
||||
const result = getCategoryAccountMapping('expense_education', -500, true)
|
||||
expect(result.debitAccount).toBe('6991')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('getExpenseAccountForCategory', () => {
|
||||
it('returns null for non-expense categories', () => {
|
||||
expect(getExpenseAccountForCategory('income_services')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns correct accounts for expense categories', () => {
|
||||
expect(getExpenseAccountForCategory('expense_equipment')).toBe('5410')
|
||||
expect(getExpenseAccountForCategory('expense_bank_fees')).toBe('6570')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { getRevenueAccount } from '../invoice-entries'
|
||||
|
||||
describe('getRevenueAccount', () => {
|
||||
it('standard_25 returns 3001', () => {
|
||||
expect(getRevenueAccount('standard_25')).toBe('3001')
|
||||
})
|
||||
|
||||
it('reduced_12 returns 3002', () => {
|
||||
expect(getRevenueAccount('reduced_12')).toBe('3002')
|
||||
})
|
||||
|
||||
it('reduced_6 returns 3003', () => {
|
||||
expect(getRevenueAccount('reduced_6')).toBe('3003')
|
||||
})
|
||||
|
||||
it('reverse_charge returns 3308', () => {
|
||||
expect(getRevenueAccount('reverse_charge')).toBe('3308')
|
||||
})
|
||||
|
||||
it('export returns 3305', () => {
|
||||
expect(getRevenueAccount('export')).toBe('3305')
|
||||
})
|
||||
|
||||
it('exempt defaults to 3100 for enskild_firma', () => {
|
||||
expect(getRevenueAccount('exempt')).toBe('3100')
|
||||
expect(getRevenueAccount('exempt', 'enskild_firma')).toBe('3100')
|
||||
})
|
||||
|
||||
it('exempt returns 3004 for aktiebolag', () => {
|
||||
expect(getRevenueAccount('exempt', 'aktiebolag')).toBe('3004')
|
||||
})
|
||||
|
||||
it('entityType does not affect non-exempt treatments', () => {
|
||||
expect(getRevenueAccount('standard_25', 'aktiebolag')).toBe('3001')
|
||||
expect(getRevenueAccount('reduced_12', 'aktiebolag')).toBe('3002')
|
||||
expect(getRevenueAccount('export', 'aktiebolag')).toBe('3305')
|
||||
})
|
||||
})
|
||||
@@ -62,6 +62,7 @@ export function getCategoryAccountMapping(
|
||||
}
|
||||
|
||||
// Business expense categories
|
||||
const educationAccount = entityType === 'aktiebolag' ? '7610' : '6991' // Utbildning (AB) / Övriga avdragsgilla kostnader (EF)
|
||||
const expenseMapping: Record<string, string> = {
|
||||
expense_equipment: '5410', // Förbrukningsinventarier
|
||||
expense_software: '5420', // Programvaror
|
||||
@@ -69,7 +70,7 @@ export function getCategoryAccountMapping(
|
||||
expense_office: '5010', // Lokalhyra
|
||||
expense_marketing: '5910', // Annonsering
|
||||
expense_professional_services: '6530', // Redovisningstjänster
|
||||
expense_education: '6991', // Övriga avdragsgilla kostnader
|
||||
expense_education: educationAccount,
|
||||
expense_bank_fees: '6570', // Bankavgifter
|
||||
expense_card_fees: '6570', // Kortavgifter
|
||||
expense_currency_exchange: '7960', // Valutakursförluster
|
||||
@@ -79,7 +80,7 @@ export function getCategoryAccountMapping(
|
||||
// Business income categories
|
||||
const incomeMapping: Record<string, string> = {
|
||||
income_services: '3001', // Försäljning tjänster 25%
|
||||
income_products: '3002', // Försäljning varor 25%
|
||||
income_products: '3001', // Försäljning varor 25% moms
|
||||
income_other: '3900', // Övriga rörelseintäkter
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { generateSalesVatLines, generateReverseChargeLines } from './vat-entries
|
||||
import type {
|
||||
CreateJournalEntryInput,
|
||||
CreateJournalEntryLineInput,
|
||||
EntityType,
|
||||
Invoice,
|
||||
JournalEntry,
|
||||
VatTreatment,
|
||||
@@ -26,7 +27,8 @@ import type {
|
||||
*/
|
||||
export async function createInvoiceJournalEntry(
|
||||
userId: string,
|
||||
invoice: Invoice
|
||||
invoice: Invoice,
|
||||
entityType: EntityType = 'enskild_firma'
|
||||
): Promise<JournalEntry | null> {
|
||||
const fiscalPeriodId = await findFiscalPeriod(userId, invoice.invoice_date)
|
||||
if (!fiscalPeriodId) {
|
||||
@@ -37,7 +39,7 @@ export async function createInvoiceJournalEntry(
|
||||
const lines: CreateJournalEntryLineInput[] = []
|
||||
|
||||
// Determine revenue account based on VAT treatment
|
||||
const revenueAccount = getRevenueAccount(invoice.vat_treatment)
|
||||
const revenueAccount = getRevenueAccount(invoice.vat_treatment, entityType)
|
||||
|
||||
// Debit: Kundfordringar (total including VAT)
|
||||
lines.push({
|
||||
@@ -133,7 +135,8 @@ export async function createInvoicePaymentJournalEntry(
|
||||
*/
|
||||
export async function createCreditNoteJournalEntry(
|
||||
userId: string,
|
||||
creditNote: Invoice
|
||||
creditNote: Invoice,
|
||||
entityType: EntityType = 'enskild_firma'
|
||||
): Promise<JournalEntry | null> {
|
||||
const fiscalPeriodId = await findFiscalPeriod(userId, creditNote.invoice_date)
|
||||
if (!fiscalPeriodId) {
|
||||
@@ -141,7 +144,7 @@ export async function createCreditNoteJournalEntry(
|
||||
return null
|
||||
}
|
||||
|
||||
const revenueAccount = getRevenueAccount(creditNote.vat_treatment)
|
||||
const revenueAccount = getRevenueAccount(creditNote.vat_treatment, entityType)
|
||||
const absSubtotal = Math.abs(creditNote.subtotal)
|
||||
const absVat = Math.abs(creditNote.vat_amount)
|
||||
const absTotal = Math.abs(creditNote.total)
|
||||
@@ -198,7 +201,8 @@ export async function createCreditNoteJournalEntry(
|
||||
export async function createInvoiceCashEntry(
|
||||
userId: string,
|
||||
invoice: Invoice,
|
||||
paymentDate: string
|
||||
paymentDate: string,
|
||||
entityType: EntityType = 'enskild_firma'
|
||||
): Promise<JournalEntry | null> {
|
||||
const fiscalPeriodId = await findFiscalPeriod(userId, paymentDate)
|
||||
if (!fiscalPeriodId) {
|
||||
@@ -206,7 +210,7 @@ export async function createInvoiceCashEntry(
|
||||
return null
|
||||
}
|
||||
|
||||
const revenueAccount = getRevenueAccount(invoice.vat_treatment)
|
||||
const revenueAccount = getRevenueAccount(invoice.vat_treatment, entityType)
|
||||
const lines: CreateJournalEntryLineInput[] = []
|
||||
|
||||
// Debit: Företagskonto (total received)
|
||||
@@ -250,8 +254,11 @@ export async function createInvoiceCashEntry(
|
||||
|
||||
/**
|
||||
* Get the appropriate revenue account based on VAT treatment
|
||||
*
|
||||
* For 'exempt': AB uses 3004 (Försäljning inom Sverige, momsfri),
|
||||
* EF uses 3100 (Momsfria intäkter, mapped to R2 in NE engine).
|
||||
*/
|
||||
export function getRevenueAccount(vatTreatment: VatTreatment): string {
|
||||
export function getRevenueAccount(vatTreatment: VatTreatment, entityType: EntityType = 'enskild_firma'): string {
|
||||
switch (vatTreatment) {
|
||||
case 'standard_25':
|
||||
return '3001' // Försäljning 25%
|
||||
@@ -264,7 +271,7 @@ export function getRevenueAccount(vatTreatment: VatTreatment): string {
|
||||
case 'export':
|
||||
return '3305' // Försäljning tjänst Export
|
||||
case 'exempt':
|
||||
return '3100' // Momsfria intäkter
|
||||
return entityType === 'aktiebolag' ? '3004' : '3100'
|
||||
default:
|
||||
return '3001'
|
||||
}
|
||||
|
||||
@@ -7,6 +7,12 @@ import { neBilagaExtension } from '@/extensions/ne-bilaga'
|
||||
import { aiChatExtension } from '@/extensions/ai-chat'
|
||||
import type { Extension } from './types'
|
||||
|
||||
// ── Enable Banking (PSD2) — opt-in extension ───────────────────────────
|
||||
// Uncomment the following line to enable automatic PSD2 bank transaction sync.
|
||||
// Requires ENABLE_BANKING_APP_ID and ENABLE_BANKING_PRIVATE_KEY env vars.
|
||||
//
|
||||
// import { enableBankingExtension } from '@/extensions/enable-banking'
|
||||
|
||||
/**
|
||||
* Explicit list of first-party extensions.
|
||||
*
|
||||
@@ -20,6 +26,7 @@ const FIRST_PARTY_EXTENSIONS: Extension[] = [
|
||||
sruExportExtension,
|
||||
neBilagaExtension,
|
||||
aiChatExtension,
|
||||
// enableBankingExtension, // Uncomment to activate PSD2 bank sync
|
||||
]
|
||||
|
||||
let loaded = false
|
||||
|
||||
@@ -0,0 +1,972 @@
|
||||
/**
|
||||
* Comprehensive tests for the bank file parser library.
|
||||
*
|
||||
* Covers auto-detection, parsing for all Swedish bank formats (Nordea, SEB,
|
||||
* Swedbank, Handelsbanken), ISO 20022 camt.053 XML, external ID generation,
|
||||
* file hashing, stats calculation, date range extraction, and edge cases.
|
||||
*/
|
||||
|
||||
import { detectFileFormat, parseBankFile, generateExternalId, generateFileHash, getFormat, getAllFormats } from '../parser'
|
||||
import type { ParsedBankTransaction, BankFileFormatId } from '../types'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test data — realistic CSV/XML content for each Swedish bank format
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const NORDEA_CSV = [
|
||||
'Datum,Transaktion,Kategori,Belopp,Saldo',
|
||||
'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 NORDEA_CSV_WITH_RESERVED = [
|
||||
'Datum,Transaktion,Kategori,Belopp,Saldo',
|
||||
'2024-01-15,SPOTIFY AB,,"-99,00","12 345,67"',
|
||||
'2024-01-14,Reserverat köp CLAS OHLSON,,"-199,00","12 444,67"',
|
||||
'2024-01-13,LÖNEUTBETALNING,,"25 000,00","12 643,67"',
|
||||
].join('\n')
|
||||
|
||||
const NORDEA_CSV_SWEDISH_CHARS = [
|
||||
'Datum,Transaktion,Kategori,Belopp,Saldo',
|
||||
'2024-03-01,GÖTEBORGS HAMNCAFÉ,,"-85,00","5 000,00"',
|
||||
'2024-03-02,ÅHLENS CITY,,"-249,00","4 751,00"',
|
||||
'2024-03-03,ÄRLA GÅRD AB,,"1 200,00","5 951,00"',
|
||||
].join('\n')
|
||||
|
||||
const SEB_CSV = [
|
||||
'Bokföringsdag;Valutadag;Verifikationsnummer;Text;Belopp;Saldo',
|
||||
'2024-01-15;2024-01-15;12345;SPOTIFY AB;-99,00;12345,67',
|
||||
'2024-01-14;2024-01-14;12346;HEMKÖP FRIDHEMSPLAN;-432,50;12444,67',
|
||||
'2024-01-13;2024-01-13;12347;LÖNEUTBETALNING;25000,00;12877,17',
|
||||
].join('\n')
|
||||
|
||||
const SWEDBANK_CSV = [
|
||||
'Kontouppgifter',
|
||||
'Clearingnummer,Kontonummer,Datum,Text,Belopp,Saldo',
|
||||
'8123,12345678,2024-01-15,SPOTIFY AB,-99.00,12345.67',
|
||||
'8123,12345678,2024-01-14,ICA MAXI,-432.50,12444.67',
|
||||
'8123,12345678,2024-01-13,LÖNEUTBETALNING,25000.00,12877.17',
|
||||
].join('\n')
|
||||
|
||||
const SWEDBANK_CSV_NO_METADATA = [
|
||||
'Clearingnummer,Kontonummer,Datum,Text,Belopp,Saldo',
|
||||
'8123,12345678,2024-02-01,TELIA SVERIGE,-299.00,10000.00',
|
||||
'8123,12345678,2024-02-02,SKATTEVERKET INBETALNING,5000.00,15000.00',
|
||||
].join('\n')
|
||||
|
||||
const HANDELSBANKEN_CSV = [
|
||||
'Reskontradatum;Transaktionsdatum;Text;Belopp;Saldo',
|
||||
'2024-01-15;2024-01-15;SPOTIFY AB;-99,00;12345,67',
|
||||
'2024-01-14;2024-01-14;HEMKÖP;-432,50;12444,67',
|
||||
'2024-01-13;2024-01-13;LÖNEUTBETALNING;25000,00;12877,17',
|
||||
].join('\n')
|
||||
|
||||
const HANDELSBANKEN_CSV_WITH_PREL = [
|
||||
'Reskontradatum;Transaktionsdatum;Text;Belopp;Saldo',
|
||||
'2024-01-15;2024-01-15;SPOTIFY AB;-99,00;12345,67',
|
||||
'2024-01-14;2024-01-14;Prel kortköp CLAS OHLSON;-199,00;12444,67',
|
||||
'2024-01-13;2024-01-13;LÖNEUTBETALNING;25000,00;12643,67',
|
||||
].join('\n')
|
||||
|
||||
const CAMT053_XML = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Document xmlns="urn:iso:std:iso:20022:tech:xsd:camt.053.001.02">
|
||||
<BkToCstmrStmt>
|
||||
<Stmt>
|
||||
<Acct><Ccy>SEK</Ccy></Acct>
|
||||
<Ntry>
|
||||
<BookgDt><Dt>2024-01-15</Dt></BookgDt>
|
||||
<Amt Ccy="SEK">99.00</Amt>
|
||||
<CdtDbtInd>DBIT</CdtDbtInd>
|
||||
<NtryRef>REF001</NtryRef>
|
||||
<NtryDtls><TxDtls>
|
||||
<RmtInf><Ustrd>SPOTIFY AB</Ustrd></RmtInf>
|
||||
</TxDtls></NtryDtls>
|
||||
</Ntry>
|
||||
<Ntry>
|
||||
<BookgDt><Dt>2024-01-14</Dt></BookgDt>
|
||||
<Amt Ccy="SEK">25000.00</Amt>
|
||||
<CdtDbtInd>CRDT</CdtDbtInd>
|
||||
<NtryRef>REF002</NtryRef>
|
||||
<NtryDtls><TxDtls>
|
||||
<RmtInf><Ustrd>LÖNEUTBETALNING</Ustrd></RmtInf>
|
||||
</TxDtls></NtryDtls>
|
||||
</Ntry>
|
||||
</Stmt>
|
||||
</BkToCstmrStmt>
|
||||
</Document>`
|
||||
|
||||
const CAMT053_XML_WITH_STRUCTURED_REF = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Document xmlns="urn:iso:std:iso:20022:tech:xsd:camt.053.001.02">
|
||||
<BkToCstmrStmt>
|
||||
<Stmt>
|
||||
<Ntry>
|
||||
<BookgDt><Dt>2024-02-01</Dt></BookgDt>
|
||||
<Amt Ccy="SEK">1500.00</Amt>
|
||||
<CdtDbtInd>CRDT</CdtDbtInd>
|
||||
<NtryRef>REF100</NtryRef>
|
||||
<NtryDtls><TxDtls>
|
||||
<RmtInf>
|
||||
<Strd><CdtrRefInf><Ref>OCR123456789</Ref></CdtrRefInf></Strd>
|
||||
<Ustrd>Betalning faktura 1001</Ustrd>
|
||||
</RmtInf>
|
||||
</TxDtls></NtryDtls>
|
||||
</Ntry>
|
||||
</Stmt>
|
||||
</BkToCstmrStmt>
|
||||
</Document>`
|
||||
|
||||
const UNKNOWN_CSV = [
|
||||
'id,name,value,timestamp',
|
||||
'1,Widget A,100,2024-01-15T10:00:00',
|
||||
'2,Widget B,200,2024-01-16T11:00:00',
|
||||
].join('\n')
|
||||
|
||||
const EMPTY_FILE = ''
|
||||
|
||||
const HEADER_ONLY_NORDEA = 'Datum,Transaktion,Kategori,Belopp,Saldo\n'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('detectFileFormat', () => {
|
||||
it('detects Nordea CSV from header keywords', () => {
|
||||
const format = detectFileFormat(NORDEA_CSV, 'transaktioner.csv')
|
||||
expect(format).not.toBeNull()
|
||||
expect(format!.id).toBe('nordea')
|
||||
})
|
||||
|
||||
it('detects SEB CSV from semicolon-delimited header with bokföringsdag', () => {
|
||||
const format = detectFileFormat(SEB_CSV, 'kontoutdrag.csv')
|
||||
expect(format).not.toBeNull()
|
||||
expect(format!.id).toBe('seb')
|
||||
})
|
||||
|
||||
it('detects Swedbank CSV from clearingnummer header', () => {
|
||||
const format = detectFileFormat(SWEDBANK_CSV, 'export.csv')
|
||||
expect(format).not.toBeNull()
|
||||
expect(format!.id).toBe('swedbank')
|
||||
})
|
||||
|
||||
it('detects Swedbank CSV when header is on the first line (no metadata)', () => {
|
||||
const format = detectFileFormat(SWEDBANK_CSV_NO_METADATA, 'export.csv')
|
||||
expect(format).not.toBeNull()
|
||||
expect(format!.id).toBe('swedbank')
|
||||
})
|
||||
|
||||
it('detects Handelsbanken CSV from reskontradatum/transaktionsdatum header', () => {
|
||||
const format = detectFileFormat(HANDELSBANKEN_CSV, 'handelsbanken.csv')
|
||||
expect(format).not.toBeNull()
|
||||
expect(format!.id).toBe('handelsbanken')
|
||||
})
|
||||
|
||||
it('detects camt.053 XML from namespace and .xml extension', () => {
|
||||
const format = detectFileFormat(CAMT053_XML, 'statement.xml')
|
||||
expect(format).not.toBeNull()
|
||||
expect(format!.id).toBe('camt053')
|
||||
})
|
||||
|
||||
it('detects camt.053 XML when content includes BkToCstmrStmt tag', () => {
|
||||
const xmlContent = '<?xml version="1.0"?><Document><BkToCstmrStmt></BkToCstmrStmt></Document>'
|
||||
const format = detectFileFormat(xmlContent, 'data.xml')
|
||||
expect(format).not.toBeNull()
|
||||
expect(format!.id).toBe('camt053')
|
||||
})
|
||||
|
||||
it('does not detect camt.053 without .xml extension', () => {
|
||||
// camt053 detection requires .xml extension
|
||||
const format = detectFileFormat(CAMT053_XML, 'statement.csv')
|
||||
// It should not match camt053 since extension is .csv
|
||||
// But it could match something else if the content resembles a CSV header
|
||||
// The important check is that it does NOT return camt053
|
||||
if (format) {
|
||||
expect(format.id).not.toBe('camt053')
|
||||
}
|
||||
})
|
||||
|
||||
it('returns null for unrecognized CSV content', () => {
|
||||
const format = detectFileFormat(UNKNOWN_CSV, 'data.csv')
|
||||
expect(format).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for empty content', () => {
|
||||
const format = detectFileFormat(EMPTY_FILE, 'empty.csv')
|
||||
expect(format).toBeNull()
|
||||
})
|
||||
|
||||
it('generic_csv format never auto-detects', () => {
|
||||
// Even with simple CSV content, generic should not be picked
|
||||
const simpleCSV = 'date,description,amount\n2024-01-15,Test,-100'
|
||||
const format = detectFileFormat(simpleCSV, 'test.csv')
|
||||
if (format) {
|
||||
expect(format.id).not.toBe('generic_csv')
|
||||
}
|
||||
})
|
||||
|
||||
it('is case-insensitive on header detection', () => {
|
||||
const upperNordea = 'DATUM,TRANSAKTION,KATEGORI,BELOPP,SALDO\n2024-01-15,Test,,"-100,00","5000,00"'
|
||||
const format = detectFileFormat(upperNordea, 'test.csv')
|
||||
expect(format).not.toBeNull()
|
||||
expect(format!.id).toBe('nordea')
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseBankFile — Nordea format', () => {
|
||||
it('parses comma-delimited CSV with comma decimal separator', () => {
|
||||
const result = parseBankFile(NORDEA_CSV, 'nordea.csv')
|
||||
|
||||
expect(result.format).toBe('nordea')
|
||||
expect(result.format_name).toBe('Nordea')
|
||||
expect(result.transactions).toHaveLength(3)
|
||||
expect(result.issues).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('correctly parses negative amounts with comma decimal and space thousands', () => {
|
||||
const result = parseBankFile(NORDEA_CSV, 'nordea.csv')
|
||||
|
||||
const spotify = result.transactions[0]
|
||||
expect(spotify.amount).toBe(-99)
|
||||
expect(spotify.description).toBe('SPOTIFY AB')
|
||||
expect(spotify.date).toBe('2024-01-15')
|
||||
expect(spotify.currency).toBe('SEK')
|
||||
|
||||
const ica = result.transactions[1]
|
||||
expect(ica.amount).toBe(-432.5)
|
||||
})
|
||||
|
||||
it('correctly parses positive amounts with space thousands separator', () => {
|
||||
const result = parseBankFile(NORDEA_CSV, 'nordea.csv')
|
||||
|
||||
const salary = result.transactions[2]
|
||||
expect(salary.amount).toBe(25000)
|
||||
expect(salary.description).toBe('LÖNEUTBETALNING')
|
||||
})
|
||||
|
||||
it('parses balance field with space thousands separator', () => {
|
||||
const result = parseBankFile(NORDEA_CSV, 'nordea.csv')
|
||||
|
||||
const spotify = result.transactions[0]
|
||||
expect(spotify.balance).toBe(12345.67)
|
||||
})
|
||||
|
||||
it('filters out "Reserverat" (pending) transactions', () => {
|
||||
const result = parseBankFile(NORDEA_CSV_WITH_RESERVED, 'nordea.csv')
|
||||
|
||||
expect(result.transactions).toHaveLength(2)
|
||||
expect(result.stats.skipped_rows).toBe(1)
|
||||
|
||||
const descriptions = result.transactions.map((t) => t.description)
|
||||
expect(descriptions).not.toContain(expect.stringContaining('Reserverat'))
|
||||
})
|
||||
|
||||
it('handles Swedish characters (a-ring, a-diaeresis, o-diaeresis)', () => {
|
||||
const result = parseBankFile(NORDEA_CSV_SWEDISH_CHARS, 'nordea.csv')
|
||||
|
||||
expect(result.transactions).toHaveLength(3)
|
||||
expect(result.transactions[0].description).toBe('GÖTEBORGS HAMNCAFÉ')
|
||||
expect(result.transactions[1].description).toBe('ÅHLENS CITY')
|
||||
expect(result.transactions[2].description).toBe('ÄRLA GÅRD AB')
|
||||
})
|
||||
|
||||
it('stores raw_line for each transaction', () => {
|
||||
const result = parseBankFile(NORDEA_CSV, 'nordea.csv')
|
||||
|
||||
result.transactions.forEach((tx) => {
|
||||
expect(tx.raw_line).toBeDefined()
|
||||
expect(tx.raw_line!.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
it('handles header-only file with no data rows', () => {
|
||||
const result = parseBankFile(HEADER_ONLY_NORDEA, 'nordea.csv')
|
||||
|
||||
expect(result.format).toBe('nordea')
|
||||
expect(result.transactions).toHaveLength(0)
|
||||
expect(result.date_from).toBeNull()
|
||||
expect(result.date_to).toBeNull()
|
||||
expect(result.stats.parsed_rows).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseBankFile — SEB format', () => {
|
||||
it('parses semicolon-delimited CSV with comma decimal separator', () => {
|
||||
const result = parseBankFile(SEB_CSV, 'seb.csv')
|
||||
|
||||
expect(result.format).toBe('seb')
|
||||
expect(result.format_name).toBe('SEB')
|
||||
expect(result.transactions).toHaveLength(3)
|
||||
expect(result.issues).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('correctly extracts columns using dynamic header mapping', () => {
|
||||
const result = parseBankFile(SEB_CSV, 'seb.csv')
|
||||
|
||||
const spotify = result.transactions[0]
|
||||
expect(spotify.date).toBe('2024-01-15')
|
||||
expect(spotify.description).toBe('SPOTIFY AB')
|
||||
expect(spotify.amount).toBe(-99)
|
||||
expect(spotify.balance).toBe(12345.67)
|
||||
})
|
||||
|
||||
it('parses positive income amounts correctly', () => {
|
||||
const result = parseBankFile(SEB_CSV, 'seb.csv')
|
||||
|
||||
const salary = result.transactions[2]
|
||||
expect(salary.amount).toBe(25000)
|
||||
expect(salary.description).toBe('LÖNEUTBETALNING')
|
||||
})
|
||||
|
||||
it('handles alternative SEB header names', () => {
|
||||
const altSEB = [
|
||||
'Bokforingsdatum;Valutadag;Verifikationsnummer;Text;Belopp;Saldo',
|
||||
'2024-01-15;2024-01-15;12345;TEST;-50,00;1000,00',
|
||||
].join('\n')
|
||||
|
||||
const result = parseBankFile(altSEB, 'seb_alt.csv')
|
||||
expect(result.format).toBe('seb')
|
||||
expect(result.transactions).toHaveLength(1)
|
||||
expect(result.transactions[0].amount).toBe(-50)
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseBankFile — Swedbank format', () => {
|
||||
it('parses comma-delimited CSV with PERIOD decimal separator', () => {
|
||||
const result = parseBankFile(SWEDBANK_CSV, 'swedbank.csv')
|
||||
|
||||
expect(result.format).toBe('swedbank')
|
||||
expect(result.format_name).toBe('Swedbank')
|
||||
expect(result.transactions).toHaveLength(3)
|
||||
expect(result.issues).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('correctly handles period decimal separator (the Swedish exception)', () => {
|
||||
const result = parseBankFile(SWEDBANK_CSV, 'swedbank.csv')
|
||||
|
||||
const spotify = result.transactions[0]
|
||||
expect(spotify.amount).toBe(-99)
|
||||
expect(spotify.balance).toBe(12345.67)
|
||||
|
||||
const ica = result.transactions[1]
|
||||
expect(ica.amount).toBe(-432.5)
|
||||
})
|
||||
|
||||
it('skips metadata line when present (first line is account info)', () => {
|
||||
const result = parseBankFile(SWEDBANK_CSV, 'swedbank.csv')
|
||||
|
||||
// With metadata line, there are 3 data rows after headerLineIdx=1
|
||||
expect(result.transactions).toHaveLength(3)
|
||||
// No transaction should have "Kontouppgifter" as description
|
||||
const descriptions = result.transactions.map((t) => t.description)
|
||||
expect(descriptions).not.toContain('Kontouppgifter')
|
||||
})
|
||||
|
||||
it('works when header is on the first line (no metadata)', () => {
|
||||
const result = parseBankFile(SWEDBANK_CSV_NO_METADATA, 'swedbank.csv')
|
||||
|
||||
expect(result.format).toBe('swedbank')
|
||||
expect(result.transactions).toHaveLength(2)
|
||||
expect(result.transactions[0].amount).toBe(-299)
|
||||
expect(result.transactions[1].amount).toBe(5000)
|
||||
})
|
||||
|
||||
it('extracts dates correctly', () => {
|
||||
const result = parseBankFile(SWEDBANK_CSV, 'swedbank.csv')
|
||||
|
||||
expect(result.transactions[0].date).toBe('2024-01-15')
|
||||
expect(result.transactions[2].date).toBe('2024-01-13')
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseBankFile — Handelsbanken format', () => {
|
||||
it('parses semicolon-delimited CSV with comma decimal separator', () => {
|
||||
const result = parseBankFile(HANDELSBANKEN_CSV, 'handelsbanken.csv')
|
||||
|
||||
expect(result.format).toBe('handelsbanken')
|
||||
expect(result.format_name).toBe('Handelsbanken')
|
||||
expect(result.transactions).toHaveLength(3)
|
||||
expect(result.issues).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('correctly parses amounts and balances', () => {
|
||||
const result = parseBankFile(HANDELSBANKEN_CSV, 'handelsbanken.csv')
|
||||
|
||||
expect(result.transactions[0].amount).toBe(-99)
|
||||
expect(result.transactions[0].balance).toBe(12345.67)
|
||||
expect(result.transactions[2].amount).toBe(25000)
|
||||
})
|
||||
|
||||
it('filters out "Prel" (preliminary) transactions', () => {
|
||||
const result = parseBankFile(HANDELSBANKEN_CSV_WITH_PREL, 'handelsbanken.csv')
|
||||
|
||||
expect(result.transactions).toHaveLength(2)
|
||||
expect(result.stats.skipped_rows).toBe(1)
|
||||
|
||||
const descriptions = result.transactions.map((t) => t.description)
|
||||
expect(descriptions).not.toContain(expect.stringContaining('Prel'))
|
||||
})
|
||||
|
||||
it('prefers transaktionsdatum over reskontradatum when both are present', () => {
|
||||
// Handelsbanken has both columns; transaktionsdatum should be used
|
||||
const result = parseBankFile(HANDELSBANKEN_CSV, 'handelsbanken.csv')
|
||||
|
||||
// In our test data both dates are the same, but verify it selects dates properly
|
||||
expect(result.transactions[0].date).toBe('2024-01-15')
|
||||
})
|
||||
|
||||
it('uses transaktionsdatum as the primary date field', () => {
|
||||
// Create data where reskontradatum differs from transaktionsdatum
|
||||
const diffDates = [
|
||||
'Reskontradatum;Transaktionsdatum;Text;Belopp;Saldo',
|
||||
'2024-01-16;2024-01-15;PURCHASE;-100,00;5000,00',
|
||||
].join('\n')
|
||||
|
||||
const result = parseBankFile(diffDates, 'shb.csv')
|
||||
expect(result.transactions[0].date).toBe('2024-01-15')
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseBankFile — camt.053 XML format', () => {
|
||||
it('parses XML with credit and debit entries', () => {
|
||||
const result = parseBankFile(CAMT053_XML, 'statement.xml')
|
||||
|
||||
expect(result.format).toBe('camt053')
|
||||
expect(result.format_name).toBe('ISO 20022 camt.053')
|
||||
expect(result.transactions).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('applies DBIT indicator as negative amount', () => {
|
||||
const result = parseBankFile(CAMT053_XML, 'statement.xml')
|
||||
|
||||
const debit = result.transactions.find((t) => t.description === 'SPOTIFY AB')
|
||||
expect(debit).toBeDefined()
|
||||
expect(debit!.amount).toBe(-99)
|
||||
})
|
||||
|
||||
it('applies CRDT indicator as positive amount', () => {
|
||||
const result = parseBankFile(CAMT053_XML, 'statement.xml')
|
||||
|
||||
const credit = result.transactions.find((t) => t.description === 'LÖNEUTBETALNING')
|
||||
expect(credit).toBeDefined()
|
||||
expect(credit!.amount).toBe(25000)
|
||||
})
|
||||
|
||||
it('extracts entry reference into raw_line for external ID generation', () => {
|
||||
const result = parseBankFile(CAMT053_XML, 'statement.xml')
|
||||
|
||||
const debit = result.transactions[0]
|
||||
expect(debit.raw_line).toBe('REF001')
|
||||
})
|
||||
|
||||
it('extracts OCR reference from structured remittance info', () => {
|
||||
const result = parseBankFile(CAMT053_XML_WITH_STRUCTURED_REF, 'statement.xml')
|
||||
|
||||
expect(result.transactions).toHaveLength(1)
|
||||
expect(result.transactions[0].reference).toBe('OCR123456789')
|
||||
})
|
||||
|
||||
it('uses unstructured remittance info as description', () => {
|
||||
const result = parseBankFile(CAMT053_XML, 'statement.xml')
|
||||
|
||||
expect(result.transactions[0].description).toBe('SPOTIFY AB')
|
||||
})
|
||||
|
||||
it('extracts currency from Amount element', () => {
|
||||
const result = parseBankFile(CAMT053_XML, 'statement.xml')
|
||||
|
||||
result.transactions.forEach((tx) => {
|
||||
expect(tx.currency).toBe('SEK')
|
||||
})
|
||||
})
|
||||
|
||||
it('handles XML with no Ntry elements', () => {
|
||||
const emptyXml = `<?xml version="1.0"?>
|
||||
<Document xmlns="urn:iso:std:iso:20022:tech:xsd:camt.053.001.02">
|
||||
<BkToCstmrStmt><Stmt></Stmt></BkToCstmrStmt></Document>`
|
||||
|
||||
const result = parseBankFile(emptyXml, 'empty.xml')
|
||||
|
||||
expect(result.format).toBe('camt053')
|
||||
expect(result.transactions).toHaveLength(0)
|
||||
expect(result.issues.length).toBeGreaterThan(0)
|
||||
expect(result.issues[0].message).toContain('No <Ntry> elements')
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseBankFile — explicit format override', () => {
|
||||
it('uses the specified format instead of auto-detection', () => {
|
||||
// Force parsing Nordea content as SEB (will produce issues but should use SEB format)
|
||||
const result = parseBankFile(
|
||||
'Datum,Transaktion,Kategori,Belopp,Saldo\n2024-01-15,Test,,"-100,00","5000,00"',
|
||||
'nordea.csv',
|
||||
'seb'
|
||||
)
|
||||
|
||||
expect(result.format).toBe('seb')
|
||||
})
|
||||
|
||||
it('returns error for unknown formatId', () => {
|
||||
const result = parseBankFile(NORDEA_CSV, 'test.csv', 'unknown_format' as BankFileFormatId)
|
||||
|
||||
expect(result.format).toBe('unknown_format')
|
||||
expect(result.format_name).toBe('Unknown')
|
||||
expect(result.transactions).toHaveLength(0)
|
||||
expect(result.issues).toHaveLength(1)
|
||||
expect(result.issues[0].severity).toBe('error')
|
||||
expect(result.issues[0].message).toContain('Unknown format')
|
||||
})
|
||||
|
||||
it('returns format detection error when no format matches and no override given', () => {
|
||||
const result = parseBankFile(UNKNOWN_CSV, 'unknown.csv')
|
||||
|
||||
expect(result.format).toBe('generic_csv')
|
||||
expect(result.format_name).toBe('Unknown')
|
||||
expect(result.transactions).toHaveLength(0)
|
||||
expect(result.issues).toHaveLength(1)
|
||||
expect(result.issues[0].message).toContain('Could not auto-detect')
|
||||
})
|
||||
|
||||
it('can force generic_csv format by explicit ID', () => {
|
||||
const csvContent = '2024-01-15,Some purchase,-50.00\n2024-01-16,Income,1000.00'
|
||||
const result = parseBankFile(csvContent, 'test.csv', 'generic_csv')
|
||||
|
||||
// generic_csv uses a default mapping (date=0, description=1, amount=2)
|
||||
// But the first line is treated as header (skip_rows=1), so only second row is data
|
||||
expect(result.format).toBe('generic_csv')
|
||||
})
|
||||
})
|
||||
|
||||
describe('generateExternalId', () => {
|
||||
const baseTx: ParsedBankTransaction = {
|
||||
date: '2024-01-15',
|
||||
description: 'SPOTIFY AB',
|
||||
amount: -99,
|
||||
currency: 'SEK',
|
||||
balance: 12345.67,
|
||||
reference: null,
|
||||
counterparty: null,
|
||||
raw_line: '2024-01-15,SPOTIFY AB,,"-99,00","12 345,67"',
|
||||
}
|
||||
|
||||
it('generates SHA-256 composite key for CSV formats', () => {
|
||||
const id = generateExternalId(baseTx, 'nordea', 0)
|
||||
|
||||
expect(id).toMatch(/^nordea_[0-9a-f]{16}$/)
|
||||
})
|
||||
|
||||
it('generates different IDs for different row indices (same transaction data)', () => {
|
||||
const id1 = generateExternalId(baseTx, 'nordea', 0)
|
||||
const id2 = generateExternalId(baseTx, 'nordea', 1)
|
||||
|
||||
expect(id1).not.toBe(id2)
|
||||
})
|
||||
|
||||
it('generates different IDs for different formats (same data, same row index)', () => {
|
||||
const nordeaId = generateExternalId(baseTx, 'nordea', 0)
|
||||
const sebId = generateExternalId(baseTx, 'seb', 0)
|
||||
|
||||
expect(nordeaId).not.toBe(sebId)
|
||||
})
|
||||
|
||||
it('generates deterministic IDs for the same inputs', () => {
|
||||
const id1 = generateExternalId(baseTx, 'nordea', 0)
|
||||
const id2 = generateExternalId(baseTx, 'nordea', 0)
|
||||
|
||||
expect(id1).toBe(id2)
|
||||
})
|
||||
|
||||
it('uses entry reference for camt.053 transactions with NtryRef', () => {
|
||||
const camtTx: ParsedBankTransaction = {
|
||||
date: '2024-01-15',
|
||||
description: 'SPOTIFY AB',
|
||||
amount: -99,
|
||||
currency: 'SEK',
|
||||
raw_line: 'REF001', // NtryRef stored in raw_line
|
||||
}
|
||||
|
||||
const id = generateExternalId(camtTx, 'camt053', 0)
|
||||
|
||||
expect(id).toBe('camt053_REF001')
|
||||
})
|
||||
|
||||
it('falls back to hash for camt.053 when raw_line starts with camt053_entry_', () => {
|
||||
const camtTx: ParsedBankTransaction = {
|
||||
date: '2024-01-15',
|
||||
description: 'SPOTIFY AB',
|
||||
amount: -99,
|
||||
currency: 'SEK',
|
||||
raw_line: 'camt053_entry_0', // Auto-generated fallback reference
|
||||
}
|
||||
|
||||
const id = generateExternalId(camtTx, 'camt053', 0)
|
||||
|
||||
// Should fall through to hash-based ID since raw_line starts with 'camt053_entry_'
|
||||
expect(id).toMatch(/^camt053_[0-9a-f]{16}$/)
|
||||
})
|
||||
|
||||
it('falls back to hash for camt.053 when raw_line is undefined', () => {
|
||||
const camtTx: ParsedBankTransaction = {
|
||||
date: '2024-01-15',
|
||||
description: 'SPOTIFY AB',
|
||||
amount: -99,
|
||||
currency: 'SEK',
|
||||
}
|
||||
|
||||
const id = generateExternalId(camtTx, 'camt053', 0)
|
||||
|
||||
expect(id).toMatch(/^camt053_[0-9a-f]{16}$/)
|
||||
})
|
||||
|
||||
it('includes amount in hash so different amounts produce different IDs', () => {
|
||||
const tx1 = { ...baseTx, amount: -99 }
|
||||
const tx2 = { ...baseTx, amount: -100 }
|
||||
|
||||
const id1 = generateExternalId(tx1, 'nordea', 0)
|
||||
const id2 = generateExternalId(tx2, 'nordea', 0)
|
||||
|
||||
expect(id1).not.toBe(id2)
|
||||
})
|
||||
|
||||
it('includes description in hash so different descriptions produce different IDs', () => {
|
||||
const tx1 = { ...baseTx, description: 'SPOTIFY AB' }
|
||||
const tx2 = { ...baseTx, description: 'NETFLIX' }
|
||||
|
||||
const id1 = generateExternalId(tx1, 'nordea', 0)
|
||||
const id2 = generateExternalId(tx2, 'nordea', 0)
|
||||
|
||||
expect(id1).not.toBe(id2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('generateFileHash', () => {
|
||||
it('returns a SHA-256 hex string', () => {
|
||||
const hash = generateFileHash(NORDEA_CSV)
|
||||
|
||||
expect(hash).toMatch(/^[0-9a-f]{64}$/)
|
||||
})
|
||||
|
||||
it('produces deterministic output for the same input', () => {
|
||||
const hash1 = generateFileHash(NORDEA_CSV)
|
||||
const hash2 = generateFileHash(NORDEA_CSV)
|
||||
|
||||
expect(hash1).toBe(hash2)
|
||||
})
|
||||
|
||||
it('produces different hashes for different content', () => {
|
||||
const hash1 = generateFileHash(NORDEA_CSV)
|
||||
const hash2 = generateFileHash(SEB_CSV)
|
||||
|
||||
expect(hash1).not.toBe(hash2)
|
||||
})
|
||||
|
||||
it('produces different hash even for tiny content differences', () => {
|
||||
const hash1 = generateFileHash('abc')
|
||||
const hash2 = generateFileHash('abd')
|
||||
|
||||
expect(hash1).not.toBe(hash2)
|
||||
})
|
||||
|
||||
it('handles empty string', () => {
|
||||
const hash = generateFileHash('')
|
||||
expect(hash).toMatch(/^[0-9a-f]{64}$/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('stats calculation', () => {
|
||||
it('calculates total_income as sum of positive amounts (Nordea)', () => {
|
||||
const result = parseBankFile(NORDEA_CSV, 'nordea.csv')
|
||||
|
||||
expect(result.stats.total_income).toBe(25000)
|
||||
})
|
||||
|
||||
it('calculates total_expenses as sum of negative amounts (Nordea)', () => {
|
||||
const result = parseBankFile(NORDEA_CSV, 'nordea.csv')
|
||||
|
||||
// -99 + -432.5 = -531.5
|
||||
expect(result.stats.total_expenses).toBe(-531.5)
|
||||
})
|
||||
|
||||
it('calculates parsed_rows correctly', () => {
|
||||
const result = parseBankFile(NORDEA_CSV, 'nordea.csv')
|
||||
|
||||
expect(result.stats.parsed_rows).toBe(3)
|
||||
})
|
||||
|
||||
it('calculates total_rows (excluding header)', () => {
|
||||
const result = parseBankFile(NORDEA_CSV, 'nordea.csv')
|
||||
|
||||
// 4 lines total - 1 header = 3 data rows
|
||||
expect(result.stats.total_rows).toBe(3)
|
||||
})
|
||||
|
||||
it('tracks skipped_rows for reserved/preliminary transactions', () => {
|
||||
const result = parseBankFile(NORDEA_CSV_WITH_RESERVED, 'nordea.csv')
|
||||
|
||||
expect(result.stats.skipped_rows).toBe(1)
|
||||
expect(result.stats.parsed_rows).toBe(2)
|
||||
})
|
||||
|
||||
it('calculates stats correctly for SEB format', () => {
|
||||
const result = parseBankFile(SEB_CSV, 'seb.csv')
|
||||
|
||||
expect(result.stats.total_income).toBe(25000)
|
||||
expect(result.stats.total_expenses).toBe(-531.5)
|
||||
expect(result.stats.parsed_rows).toBe(3)
|
||||
expect(result.stats.skipped_rows).toBe(0)
|
||||
})
|
||||
|
||||
it('calculates stats correctly for Swedbank format', () => {
|
||||
const result = parseBankFile(SWEDBANK_CSV, 'swedbank.csv')
|
||||
|
||||
expect(result.stats.total_income).toBe(25000)
|
||||
expect(result.stats.total_expenses).toBe(-531.5)
|
||||
expect(result.stats.parsed_rows).toBe(3)
|
||||
})
|
||||
|
||||
it('calculates stats correctly for camt.053 XML', () => {
|
||||
const result = parseBankFile(CAMT053_XML, 'statement.xml')
|
||||
|
||||
expect(result.stats.total_income).toBe(25000)
|
||||
expect(result.stats.total_expenses).toBe(-99)
|
||||
expect(result.stats.parsed_rows).toBe(2)
|
||||
expect(result.stats.total_rows).toBe(2)
|
||||
})
|
||||
|
||||
it('uses Math.round(x * 100) / 100 for monetary precision', () => {
|
||||
// Create a file that would produce floating point imprecision
|
||||
const precisionCSV = [
|
||||
'Datum,Transaktion,Kategori,Belopp,Saldo',
|
||||
'2024-01-01,TX1,,"-0,10","100,00"',
|
||||
'2024-01-02,TX2,,"-0,20","99,90"',
|
||||
'2024-01-03,TX3,,"-0,30","99,70"',
|
||||
].join('\n')
|
||||
|
||||
const result = parseBankFile(precisionCSV, 'precision.csv')
|
||||
|
||||
// 0.1 + 0.2 + 0.3 = 0.6000000000000001 without rounding
|
||||
// With Math.round(x * 100) / 100, it should be -0.6
|
||||
expect(result.stats.total_expenses).toBe(-0.6)
|
||||
})
|
||||
})
|
||||
|
||||
describe('date range extraction', () => {
|
||||
it('sets date_from to the earliest date', () => {
|
||||
const result = parseBankFile(NORDEA_CSV, 'nordea.csv')
|
||||
|
||||
expect(result.date_from).toBe('2024-01-13')
|
||||
})
|
||||
|
||||
it('sets date_to to the latest date', () => {
|
||||
const result = parseBankFile(NORDEA_CSV, 'nordea.csv')
|
||||
|
||||
expect(result.date_to).toBe('2024-01-15')
|
||||
})
|
||||
|
||||
it('returns null dates for empty transaction set', () => {
|
||||
const result = parseBankFile(HEADER_ONLY_NORDEA, 'nordea.csv')
|
||||
|
||||
expect(result.date_from).toBeNull()
|
||||
expect(result.date_to).toBeNull()
|
||||
})
|
||||
|
||||
it('handles single-transaction file (date_from equals date_to)', () => {
|
||||
const singleRow = [
|
||||
'Datum,Transaktion,Kategori,Belopp,Saldo',
|
||||
'2024-06-15,ENSKILD BETALNING,,"-500,00","10 000,00"',
|
||||
].join('\n')
|
||||
|
||||
const result = parseBankFile(singleRow, 'single.csv')
|
||||
|
||||
expect(result.date_from).toBe('2024-06-15')
|
||||
expect(result.date_to).toBe('2024-06-15')
|
||||
})
|
||||
|
||||
it('calculates correct date range for camt.053', () => {
|
||||
const result = parseBankFile(CAMT053_XML, 'statement.xml')
|
||||
|
||||
expect(result.date_from).toBe('2024-01-14')
|
||||
expect(result.date_to).toBe('2024-01-15')
|
||||
})
|
||||
|
||||
it('sorts dates lexicographically (YYYY-MM-DD is naturally sortable)', () => {
|
||||
const multiMonth = [
|
||||
'Datum,Transaktion,Kategori,Belopp,Saldo',
|
||||
'2024-12-31,DEC TX,,"-10,00","1000,00"',
|
||||
'2024-01-01,JAN TX,,"-20,00","990,00"',
|
||||
'2024-06-15,JUN TX,,"-30,00","960,00"',
|
||||
].join('\n')
|
||||
|
||||
const result = parseBankFile(multiMonth, 'multimonth.csv')
|
||||
|
||||
expect(result.date_from).toBe('2024-01-01')
|
||||
expect(result.date_to).toBe('2024-12-31')
|
||||
})
|
||||
})
|
||||
|
||||
describe('empty file handling', () => {
|
||||
it('returns error result for completely empty file (no auto-detect match)', () => {
|
||||
const result = parseBankFile(EMPTY_FILE, 'empty.csv')
|
||||
|
||||
expect(result.transactions).toHaveLength(0)
|
||||
expect(result.issues.length).toBeGreaterThan(0)
|
||||
expect(result.date_from).toBeNull()
|
||||
expect(result.date_to).toBeNull()
|
||||
expect(result.stats.parsed_rows).toBe(0)
|
||||
})
|
||||
|
||||
it('returns zero transactions for file with only whitespace', () => {
|
||||
const whitespace = ' \n \n '
|
||||
const result = parseBankFile(whitespace, 'blank.csv')
|
||||
|
||||
expect(result.transactions).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('returns zero transactions for Nordea header-only file', () => {
|
||||
const result = parseBankFile(HEADER_ONLY_NORDEA, 'nordea.csv')
|
||||
|
||||
expect(result.format).toBe('nordea')
|
||||
expect(result.transactions).toHaveLength(0)
|
||||
expect(result.stats.total_rows).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getFormat and getAllFormats', () => {
|
||||
it('getFormat returns the correct format by ID', () => {
|
||||
const nordea = getFormat('nordea')
|
||||
expect(nordea).toBeDefined()
|
||||
expect(nordea!.id).toBe('nordea')
|
||||
expect(nordea!.name).toBe('Nordea')
|
||||
|
||||
const seb = getFormat('seb')
|
||||
expect(seb).toBeDefined()
|
||||
expect(seb!.id).toBe('seb')
|
||||
})
|
||||
|
||||
it('getFormat returns undefined for unknown ID', () => {
|
||||
const unknown = getFormat('nonexistent' as BankFileFormatId)
|
||||
expect(unknown).toBeUndefined()
|
||||
})
|
||||
|
||||
it('getAllFormats returns all registered formats', () => {
|
||||
const formats = getAllFormats()
|
||||
|
||||
expect(formats.length).toBeGreaterThanOrEqual(6)
|
||||
|
||||
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('camt053')
|
||||
expect(ids).toContain('generic_csv')
|
||||
})
|
||||
|
||||
it('camt053 is listed before bank-specific CSV formats (detection priority)', () => {
|
||||
const formats = getAllFormats()
|
||||
const camtIdx = formats.findIndex((f) => f.id === 'camt053')
|
||||
const nordeaIdx = formats.findIndex((f) => f.id === 'nordea')
|
||||
|
||||
expect(camtIdx).toBeLessThan(nordeaIdx)
|
||||
})
|
||||
|
||||
it('generic_csv is listed last (manual fallback only)', () => {
|
||||
const formats = getAllFormats()
|
||||
const genericIdx = formats.findIndex((f) => f.id === 'generic_csv')
|
||||
|
||||
expect(genericIdx).toBe(formats.length - 1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge cases and robustness', () => {
|
||||
it('handles Windows-style line endings (CRLF)', () => {
|
||||
const crlfContent = 'Datum,Transaktion,Kategori,Belopp,Saldo\r\n2024-01-15,SPOTIFY AB,,"-99,00","12 345,67"\r\n'
|
||||
|
||||
const result = parseBankFile(crlfContent, 'nordea.csv')
|
||||
|
||||
expect(result.format).toBe('nordea')
|
||||
expect(result.transactions).toHaveLength(1)
|
||||
expect(result.transactions[0].amount).toBe(-99)
|
||||
})
|
||||
|
||||
it('handles BOM (Byte Order Mark) prefix', () => {
|
||||
const bomContent = '\uFEFF' + NORDEA_CSV
|
||||
|
||||
const result = parseBankFile(bomContent, 'nordea.csv')
|
||||
|
||||
expect(result.format).toBe('nordea')
|
||||
expect(result.transactions).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('handles rows with invalid dates gracefully', () => {
|
||||
const invalidDate = [
|
||||
'Bokföringsdag;Valutadag;Verifikationsnummer;Text;Belopp;Saldo',
|
||||
'not-a-date;2024-01-15;12345;SPOTIFY AB;-99,00;12345,67',
|
||||
'2024-01-14;2024-01-14;12346;VALID TX;-50,00;12395,67',
|
||||
].join('\n')
|
||||
|
||||
const result = parseBankFile(invalidDate, 'seb.csv')
|
||||
|
||||
expect(result.transactions).toHaveLength(1)
|
||||
expect(result.transactions[0].description).toBe('VALID TX')
|
||||
expect(result.issues.length).toBeGreaterThan(0)
|
||||
expect(result.stats.skipped_rows).toBe(1)
|
||||
})
|
||||
|
||||
it('handles rows with invalid amounts gracefully', () => {
|
||||
const invalidAmount = [
|
||||
'Datum,Transaktion,Kategori,Belopp,Saldo',
|
||||
'2024-01-15,SPOTIFY AB,,"abc","12 345,67"',
|
||||
'2024-01-14,VALID TX,,"-50,00","12 395,67"',
|
||||
].join('\n')
|
||||
|
||||
const result = parseBankFile(invalidAmount, 'nordea.csv')
|
||||
|
||||
expect(result.transactions).toHaveLength(1)
|
||||
expect(result.transactions[0].description).toBe('VALID TX')
|
||||
expect(result.stats.skipped_rows).toBe(1)
|
||||
})
|
||||
|
||||
it('handles trailing blank lines', () => {
|
||||
const trailing = NORDEA_CSV + '\n\n\n'
|
||||
|
||||
const result = parseBankFile(trailing, 'nordea.csv')
|
||||
|
||||
expect(result.transactions).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('handles Handelsbanken CSV with only reskontradatum (no transaktionsdatum)', () => {
|
||||
const onlyReskontra = [
|
||||
'Reskontradatum;Text;Belopp;Saldo',
|
||||
'2024-01-15;SPOTIFY AB;-99,00;12345,67',
|
||||
].join('\n')
|
||||
|
||||
const format = detectFileFormat(onlyReskontra, 'shb.csv')
|
||||
expect(format).not.toBeNull()
|
||||
expect(format!.id).toBe('handelsbanken')
|
||||
})
|
||||
|
||||
it('handles large amounts without overflow', () => {
|
||||
const largeAmounts = [
|
||||
'Datum,Transaktion,Kategori,Belopp,Saldo',
|
||||
'2024-01-15,BIG TRANSFER,,"1 500 000,00","2 000 000,00"',
|
||||
].join('\n')
|
||||
|
||||
const result = parseBankFile(largeAmounts, 'nordea.csv')
|
||||
|
||||
expect(result.transactions).toHaveLength(1)
|
||||
expect(result.transactions[0].amount).toBe(1500000)
|
||||
expect(result.transactions[0].balance).toBe(2000000)
|
||||
})
|
||||
|
||||
it('handles zero amounts', () => {
|
||||
const zeroAmount = [
|
||||
'Datum,Transaktion,Kategori,Belopp,Saldo',
|
||||
'2024-01-15,FEE REVERSAL,,"0,00","5 000,00"',
|
||||
].join('\n')
|
||||
|
||||
const result = parseBankFile(zeroAmount, 'nordea.csv')
|
||||
|
||||
expect(result.transactions).toHaveLength(1)
|
||||
expect(result.transactions[0].amount).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Encoding detection and conversion for Swedish bank files.
|
||||
*
|
||||
* Swedish bank exports use either UTF-8 or Windows-1252 (ISO-8859-1).
|
||||
* We detect encoding by checking for valid Swedish characters.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Decode file content, handling both UTF-8 and Windows-1252 encodings.
|
||||
*
|
||||
* Strategy: Try UTF-8 first. If the result contains replacement characters
|
||||
* (U+FFFD) or garbled Swedish chars, fall back to Windows-1252.
|
||||
*/
|
||||
export function decodeFileContent(buffer: ArrayBuffer): string {
|
||||
// Try UTF-8 first
|
||||
const utf8Decoder = new TextDecoder('utf-8', { fatal: false })
|
||||
const utf8Result = utf8Decoder.decode(buffer)
|
||||
|
||||
// Check if UTF-8 decode produced valid Swedish text
|
||||
if (!hasEncodingIssues(utf8Result)) {
|
||||
return utf8Result
|
||||
}
|
||||
|
||||
// Fall back to Windows-1252 (superset of ISO-8859-1)
|
||||
const latin1Decoder = new TextDecoder('windows-1252', { fatal: false })
|
||||
return latin1Decoder.decode(buffer)
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a string that may have been incorrectly decoded as UTF-8
|
||||
* when the source was actually Windows-1252.
|
||||
*/
|
||||
export function decodeStringContent(content: string): string {
|
||||
// If the string already contains valid Swedish chars, return as-is
|
||||
if (!hasEncodingIssues(content)) {
|
||||
return content
|
||||
}
|
||||
|
||||
// Try re-encoding as Latin-1 and decoding as Windows-1252
|
||||
try {
|
||||
const bytes = new Uint8Array(content.length)
|
||||
for (let i = 0; i < content.length; i++) {
|
||||
bytes[i] = content.charCodeAt(i) & 0xff
|
||||
}
|
||||
const decoder = new TextDecoder('windows-1252', { fatal: false })
|
||||
return decoder.decode(bytes)
|
||||
} catch {
|
||||
return content
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a string has encoding issues (garbled Swedish characters).
|
||||
*/
|
||||
function hasEncodingIssues(text: string): boolean {
|
||||
// U+FFFD = replacement character (means invalid UTF-8 byte sequences)
|
||||
if (text.includes('\uFFFD')) return true
|
||||
|
||||
// Common garbled patterns when Windows-1252 is read as UTF-8:
|
||||
// Ã¥ = å, ä = ä, ö = ö, Ã… = Å, Ä = Ä, Ö = Ö
|
||||
const garbledPatterns = ['Ã¥', 'ä', 'ö', 'Ã\u0085', 'Ã\u0084', 'Ã\u0096']
|
||||
return garbledPatterns.some((pattern) => text.includes(pattern))
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize line endings to \n
|
||||
*/
|
||||
export function normalizeLineEndings(content: string): string {
|
||||
return content.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip BOM (Byte Order Mark) from start of content
|
||||
*/
|
||||
export function stripBOM(content: string): string {
|
||||
if (content.charCodeAt(0) === 0xfeff) {
|
||||
return content.slice(1)
|
||||
}
|
||||
return content
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare file content for parsing: strip BOM, normalize line endings, handle encoding
|
||||
*/
|
||||
export function prepareContent(content: string): string {
|
||||
return normalizeLineEndings(stripBOM(decodeStringContent(content)))
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* ISO 20022 camt.053 (BankToCustomerStatement) XML parser
|
||||
*
|
||||
* This is the EU standard for bank statements, increasingly used by Swedish banks.
|
||||
* Namespace: urn:iso:std:iso:20022:tech:xsd:camt.053.001.XX (various versions)
|
||||
*
|
||||
* Key elements:
|
||||
* - <BkToCstmrStmt> — root container
|
||||
* - <Stmt> — one statement per account
|
||||
* - <Ntry> — individual transaction entries
|
||||
* - <NtryRef> / <AcctSvcrRef> — unique entry reference (external_id)
|
||||
* - <CdtDbtInd> — CRDT/DBIT indicator
|
||||
* - <RmtInf><Strd><CdtrRefInf> — structured remittance (OCR/Bankgiro reference)
|
||||
*/
|
||||
|
||||
import type { BankFileFormat, BankFileParseResult, ParsedBankTransaction, BankFileParseIssue } from '../types'
|
||||
import { prepareContent } from '../encoding'
|
||||
|
||||
export const camt053Format: BankFileFormat = {
|
||||
id: 'camt053',
|
||||
name: 'ISO 20022 camt.053',
|
||||
description: 'ISO 20022 BankToCustomerStatement (XML)',
|
||||
fileExtensions: ['.xml'],
|
||||
|
||||
detect(content: string, filename: string): boolean {
|
||||
// Check for XML with camt.053 namespace
|
||||
if (filename.toLowerCase().endsWith('.xml')) {
|
||||
const lower = content.toLowerCase()
|
||||
return (
|
||||
lower.includes('camt.053') ||
|
||||
lower.includes('bktocstmrstmt') ||
|
||||
lower.includes('banktoCustomerstatement'.toLowerCase())
|
||||
)
|
||||
}
|
||||
return false
|
||||
},
|
||||
|
||||
parse(content: string): BankFileParseResult {
|
||||
const prepared = prepareContent(content)
|
||||
|
||||
const transactions: ParsedBankTransaction[] = []
|
||||
const issues: BankFileParseIssue[] = []
|
||||
|
||||
// Simple XML parsing without external dependencies
|
||||
// Extract <Ntry> elements
|
||||
const entries = extractElements(prepared, 'Ntry')
|
||||
|
||||
if (entries.length === 0) {
|
||||
issues.push({
|
||||
row: 0,
|
||||
message: 'No <Ntry> elements found in camt.053 file',
|
||||
severity: 'error',
|
||||
})
|
||||
}
|
||||
|
||||
// Try to extract currency from statement level
|
||||
const stmtCcy = extractTextContent(prepared, 'Ccy') || 'SEK'
|
||||
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
const entry = entries[i]
|
||||
|
||||
try {
|
||||
// Date: <BookgDt><Dt> or <ValDt><Dt>
|
||||
const bookingDate = extractTextContent(entry, 'BookgDt>.*?<Dt') ||
|
||||
extractNestedText(entry, 'BookgDt', 'Dt')
|
||||
const valueDate = extractTextContent(entry, 'ValDt>.*?<Dt') ||
|
||||
extractNestedText(entry, 'ValDt', 'Dt')
|
||||
const date = bookingDate || valueDate
|
||||
|
||||
if (!date || !/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
||||
issues.push({ row: i + 1, message: `Invalid or missing date in entry ${i}`, severity: 'warning' })
|
||||
continue
|
||||
}
|
||||
|
||||
// Amount: <Amt Ccy="SEK">1234.56</Amt>
|
||||
const amountMatch = entry.match(/<Amt[^>]*>([^<]+)<\/Amt>/i)
|
||||
const amountStr = amountMatch?.[1]
|
||||
const currencyMatch = entry.match(/<Amt[^>]*Ccy="([^"]+)"[^>]*>/i)
|
||||
const currency = currencyMatch?.[1] || stmtCcy
|
||||
|
||||
if (!amountStr) {
|
||||
issues.push({ row: i + 1, message: `Missing amount in entry ${i}`, severity: 'warning' })
|
||||
continue
|
||||
}
|
||||
|
||||
let amount = parseFloat(amountStr)
|
||||
if (isNaN(amount)) {
|
||||
issues.push({ row: i + 1, message: `Invalid amount: ${amountStr}`, severity: 'warning' })
|
||||
continue
|
||||
}
|
||||
|
||||
// Credit/Debit indicator: <CdtDbtInd>CRDT</CdtDbtInd> or DBIT
|
||||
const cdtDbtInd = extractTextContent(entry, 'CdtDbtInd')
|
||||
if (cdtDbtInd === 'DBIT') {
|
||||
amount = -Math.abs(amount)
|
||||
} else {
|
||||
amount = Math.abs(amount)
|
||||
}
|
||||
|
||||
// Description: <AddtlNtryInf> or <RmtInf><Ustrd>
|
||||
const additionalInfo = extractTextContent(entry, 'AddtlNtryInf')
|
||||
const unstructuredRemittance = extractTextContent(entry, 'Ustrd')
|
||||
const description = additionalInfo || unstructuredRemittance || 'Unknown'
|
||||
|
||||
// Reference: Try structured remittance info first, then entry reference
|
||||
const structuredRef = extractTextContent(entry, 'Ref') // Inside <CdtrRefInf><Ref>
|
||||
const entryRef = extractTextContent(entry, 'NtryRef')
|
||||
const acctSvcrRef = extractTextContent(entry, 'AcctSvcrRef')
|
||||
const reference = structuredRef || null
|
||||
|
||||
// Counterparty
|
||||
const creditorName = extractTextContent(entry, 'CdtrNm') ||
|
||||
extractNestedText(entry, 'Cdtr', 'Nm')
|
||||
const debtorName = extractTextContent(entry, 'DbtrNm') ||
|
||||
extractNestedText(entry, 'Dbtr', 'Nm')
|
||||
const counterparty = cdtDbtInd === 'DBIT' ? creditorName : debtorName
|
||||
|
||||
// Balance after entry
|
||||
const balanceStr = extractTextContent(entry, 'ClsgAvlblAmt') ||
|
||||
extractTextContent(entry, 'ClsgBookdAmt')
|
||||
const balance = balanceStr ? parseFloat(balanceStr) : null
|
||||
|
||||
transactions.push({
|
||||
date,
|
||||
description: description.trim(),
|
||||
amount: Math.round(amount * 100) / 100,
|
||||
currency,
|
||||
balance: balance !== null && !isNaN(balance) ? balance : null,
|
||||
reference,
|
||||
counterparty: counterparty?.trim() || null,
|
||||
raw_line: entryRef || acctSvcrRef || `camt053_entry_${i}`,
|
||||
})
|
||||
} catch (err) {
|
||||
issues.push({
|
||||
row: i + 1,
|
||||
message: `Error parsing entry ${i}: ${err instanceof Error ? err.message : 'Unknown'}`,
|
||||
severity: 'warning',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const dates = transactions.map((t) => t.date).sort()
|
||||
|
||||
return {
|
||||
format: 'camt053',
|
||||
format_name: 'ISO 20022 camt.053',
|
||||
transactions,
|
||||
date_from: dates[0] || null,
|
||||
date_to: dates[dates.length - 1] || null,
|
||||
issues,
|
||||
stats: {
|
||||
total_rows: entries.length,
|
||||
parsed_rows: transactions.length,
|
||||
skipped_rows: entries.length - transactions.length,
|
||||
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,
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract all occurrences of a named XML element (simple parser, no dependencies)
|
||||
*/
|
||||
function extractElements(xml: string, tagName: string): string[] {
|
||||
const elements: string[] = []
|
||||
// Match exact tag name (followed by > or whitespace, not a longer tag name)
|
||||
const regex = new RegExp(`<${tagName}(?=[\\s>/])`, 'gi')
|
||||
let match
|
||||
|
||||
while ((match = regex.exec(xml)) !== null) {
|
||||
const startIdx = match.index
|
||||
// Find matching closing tag
|
||||
const closeTag = `</${tagName}>`
|
||||
const closeIdx = xml.indexOf(closeTag, startIdx + match[0].length)
|
||||
|
||||
if (closeIdx === -1) continue
|
||||
|
||||
elements.push(xml.substring(startIdx, closeIdx + closeTag.length))
|
||||
|
||||
// Advance regex past this element to avoid re-matching inside it
|
||||
regex.lastIndex = closeIdx + closeTag.length
|
||||
}
|
||||
|
||||
return elements
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract text content of the first occurrence of a tag
|
||||
*/
|
||||
function extractTextContent(xml: string, tagName: string): string | null {
|
||||
const regex = new RegExp(`<${tagName}[^>]*>([^<]+)<`, 'i')
|
||||
const match = xml.match(regex)
|
||||
return match?.[1]?.trim() || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract text content of a nested tag within a parent tag
|
||||
*/
|
||||
function extractNestedText(xml: string, parentTag: string, childTag: string): string | null {
|
||||
const parentRegex = new RegExp(`<${parentTag}[^>]*>([\\s\\S]*?)<\\/${parentTag}>`, 'i')
|
||||
const parentMatch = xml.match(parentRegex)
|
||||
if (!parentMatch) return null
|
||||
|
||||
const childRegex = new RegExp(`<${childTag}[^>]*>([^<]+)<`, 'i')
|
||||
const childMatch = parentMatch[1].match(childRegex)
|
||||
return childMatch?.[1]?.trim() || null
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* Generic CSV format parser
|
||||
*
|
||||
* Fallback parser that requires the user to map columns manually.
|
||||
* Supports configurable delimiter, decimal separator, and column mapping.
|
||||
*/
|
||||
|
||||
import type { BankFileFormat, BankFileParseResult, ParsedBankTransaction, BankFileParseIssue, GenericCSVColumnMapping } from '../types'
|
||||
import { prepareContent } from '../encoding'
|
||||
import { parseCSVLine } from './nordea'
|
||||
|
||||
/**
|
||||
* Parse a generic CSV with user-provided column mapping
|
||||
*/
|
||||
export function parseGenericCSV(
|
||||
content: string,
|
||||
mapping: GenericCSVColumnMapping
|
||||
): BankFileParseResult {
|
||||
const prepared = prepareContent(content)
|
||||
const lines = prepared.split('\n').filter((line) => line.trim() !== '')
|
||||
|
||||
const transactions: ParsedBankTransaction[] = []
|
||||
const issues: BankFileParseIssue[] = []
|
||||
let skippedRows = 0
|
||||
|
||||
// Skip configured number of header/metadata rows
|
||||
const startRow = mapping.skip_rows
|
||||
|
||||
for (let i = startRow; i < lines.length; i++) {
|
||||
const line = lines[i].trim()
|
||||
if (!line) continue
|
||||
|
||||
const fields = parseCSVLine(line, mapping.delimiter).map((f) =>
|
||||
f.trim().replace(/^"|"$/g, '')
|
||||
)
|
||||
|
||||
const dateStr = fields[mapping.date]
|
||||
const description = fields[mapping.description] || 'Unknown'
|
||||
const amountStr = fields[mapping.amount]
|
||||
const referenceStr = mapping.reference !== undefined ? fields[mapping.reference] : undefined
|
||||
const counterpartyStr = mapping.counterparty !== undefined ? fields[mapping.counterparty] : undefined
|
||||
const balanceStr = mapping.balance !== undefined ? fields[mapping.balance] : undefined
|
||||
|
||||
if (!dateStr || !amountStr) {
|
||||
skippedRows++
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse amount based on configured decimal separator
|
||||
let amount: number
|
||||
if (mapping.decimal_separator === ',') {
|
||||
amount = parseFloat(amountStr.replace(/\s/g, '').replace(',', '.'))
|
||||
} else {
|
||||
amount = parseFloat(amountStr.replace(/\s/g, ''))
|
||||
}
|
||||
|
||||
if (isNaN(amount)) {
|
||||
issues.push({ row: i + 1, message: `Invalid amount: ${amountStr}`, severity: 'warning' })
|
||||
skippedRows++
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse date - expect YYYY-MM-DD
|
||||
const date = dateStr.trim()
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
||||
issues.push({ row: i + 1, message: `Invalid date format: ${date} (expected YYYY-MM-DD)`, severity: 'warning' })
|
||||
skippedRows++
|
||||
continue
|
||||
}
|
||||
|
||||
let balance: number | null = null
|
||||
if (balanceStr) {
|
||||
if (mapping.decimal_separator === ',') {
|
||||
balance = parseFloat(balanceStr.replace(/\s/g, '').replace(',', '.'))
|
||||
} else {
|
||||
balance = parseFloat(balanceStr.replace(/\s/g, ''))
|
||||
}
|
||||
if (isNaN(balance)) balance = null
|
||||
}
|
||||
|
||||
transactions.push({
|
||||
date,
|
||||
description: description.trim(),
|
||||
amount,
|
||||
currency: 'SEK',
|
||||
balance,
|
||||
reference: referenceStr?.trim() || null,
|
||||
counterparty: counterpartyStr?.trim() || null,
|
||||
raw_line: line,
|
||||
})
|
||||
}
|
||||
|
||||
const dates = transactions.map((t) => t.date).sort()
|
||||
|
||||
return {
|
||||
format: 'generic_csv',
|
||||
format_name: 'CSV (manuell mappning)',
|
||||
transactions,
|
||||
date_from: dates[0] || null,
|
||||
date_to: dates[dates.length - 1] || null,
|
||||
issues,
|
||||
stats: {
|
||||
total_rows: lines.length - startRow,
|
||||
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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get column headers from a CSV file for the mapping UI
|
||||
*/
|
||||
export function getCSVHeaders(content: string, delimiter: string = ','): string[] {
|
||||
const prepared = prepareContent(content)
|
||||
const firstLine = prepared.split('\n')[0] || ''
|
||||
return parseCSVLine(firstLine, delimiter).map((h) => h.trim().replace(/^"|"$/g, ''))
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a preview of the first few rows of a CSV file
|
||||
*/
|
||||
export function getCSVPreview(content: string, delimiter: string = ',', rows: number = 5): string[][] {
|
||||
const prepared = prepareContent(content)
|
||||
const lines = prepared.split('\n').filter((line) => line.trim() !== '')
|
||||
|
||||
return lines.slice(0, rows).map((line) =>
|
||||
parseCSVLine(line, delimiter).map((f) => f.trim().replace(/^"|"$/g, ''))
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic CSV format definition (used for format detection)
|
||||
* Always returns false for detect() since it's a fallback requiring user mapping
|
||||
*/
|
||||
export const genericCSVFormat: BankFileFormat = {
|
||||
id: 'generic_csv',
|
||||
name: 'CSV (manuell mappning)',
|
||||
description: 'Generisk CSV-fil med manuell kolumnmappning',
|
||||
fileExtensions: ['.csv', '.txt'],
|
||||
|
||||
detect(_content: string, _filename: string): boolean {
|
||||
// Generic CSV never auto-detects — it's the manual fallback
|
||||
return false
|
||||
},
|
||||
|
||||
parse(content: string): BankFileParseResult {
|
||||
// Default mapping for a basic CSV: date, description, amount
|
||||
const defaultMapping: GenericCSVColumnMapping = {
|
||||
date: 0,
|
||||
description: 1,
|
||||
amount: 2,
|
||||
delimiter: ',',
|
||||
decimal_separator: ',',
|
||||
skip_rows: 1,
|
||||
date_format: 'YYYY-MM-DD',
|
||||
}
|
||||
return parseGenericCSV(content, defaultMapping)
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* Handelsbanken CSV format parser
|
||||
*
|
||||
* Format: Semicolon-delimited, comma decimal separator
|
||||
* Columns: Reskontradatum, Transaktionsdatum, Text, Belopp, Saldo
|
||||
* Date format: YYYY-MM-DD
|
||||
* Encoding: UTF-8 or Windows-1252
|
||||
*
|
||||
* Notes:
|
||||
* - Filter rows with "Prel" prefix (preliminary/pending transactions)
|
||||
*/
|
||||
|
||||
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 handelsbankenFormat: BankFileFormat = {
|
||||
id: 'handelsbanken',
|
||||
name: 'Handelsbanken',
|
||||
description: 'Handelsbanken CSV (semicolon-delimited)',
|
||||
fileExtensions: ['.csv', '.txt'],
|
||||
|
||||
detect(content: string, _filename: string): boolean {
|
||||
const prepared = prepareContent(content)
|
||||
const firstLine = prepared.split('\n')[0]?.toLowerCase() || ''
|
||||
return (
|
||||
firstLine.includes(';') &&
|
||||
(firstLine.includes('reskontradatum') || firstLine.includes('transaktionsdatum'))
|
||||
)
|
||||
},
|
||||
|
||||
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.includes('reskontradatum') || h.includes('transaktionsdatum')
|
||||
)
|
||||
const txDateIdx = headers.findIndex((h) => h.includes('transaktionsdatum'))
|
||||
const descIdx = headers.findIndex((h) => h === 'text' || h.includes('beskrivning'))
|
||||
const amountIdx = headers.findIndex((h) => h.includes('belopp'))
|
||||
const balanceIdx = headers.findIndex((h) => h.includes('saldo'))
|
||||
|
||||
if (dateIdx === -1 || amountIdx === -1) {
|
||||
issues.push({
|
||||
row: 1,
|
||||
message: 'Could not identify required columns',
|
||||
severity: 'error',
|
||||
})
|
||||
return {
|
||||
format: 'handelsbanken',
|
||||
format_name: 'Handelsbanken',
|
||||
transactions: [],
|
||||
date_from: null,
|
||||
date_to: null,
|
||||
issues,
|
||||
stats: { total_rows: 0, parsed_rows: 0, skipped_rows: 0, total_income: 0, total_expenses: 0 },
|
||||
}
|
||||
}
|
||||
|
||||
// Prefer transaktionsdatum over reskontradatum if available
|
||||
const primaryDateIdx = txDateIdx >= 0 ? txDateIdx : dateIdx
|
||||
|
||||
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[primaryDateIdx]
|
||||
const description = descIdx >= 0 ? fields[descIdx] : 'Unknown'
|
||||
const amountStr = fields[amountIdx]
|
||||
const balanceStr = balanceIdx >= 0 ? fields[balanceIdx] : undefined
|
||||
|
||||
// Skip preliminary transactions
|
||||
if (description?.toLowerCase().startsWith('prel')) {
|
||||
skippedRows++
|
||||
continue
|
||||
}
|
||||
|
||||
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: 'handelsbanken',
|
||||
format_name: 'Handelsbanken',
|
||||
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,
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Nordea CSV format parser
|
||||
*
|
||||
* Format: Comma-delimited, comma decimal separator
|
||||
* Columns: Datum, Transaktion, Kategori, Belopp, Saldo
|
||||
* Date format: YYYY-MM-DD
|
||||
* Encoding: UTF-8 or Windows-1252
|
||||
*
|
||||
* Notes:
|
||||
* - Skip rows with "Reserverat" in Transaktion (pending transactions)
|
||||
* - Skip trailing blank lines
|
||||
*/
|
||||
|
||||
import type { BankFileFormat, BankFileParseResult, ParsedBankTransaction, BankFileParseIssue } from '../types'
|
||||
import { prepareContent } from '../encoding'
|
||||
|
||||
function parseCommaDecimal(value: string): number {
|
||||
// Swedish format: "1 234,56" or "-1 234,56"
|
||||
const cleaned = value.replace(/\s/g, '').replace(',', '.')
|
||||
return parseFloat(cleaned)
|
||||
}
|
||||
|
||||
export const nordeaFormat: BankFileFormat = {
|
||||
id: 'nordea',
|
||||
name: 'Nordea',
|
||||
description: 'Nordea CSV (Datum, Transaktion, Kategori, Belopp, Saldo)',
|
||||
fileExtensions: ['.csv', '.txt'],
|
||||
|
||||
detect(content: string, _filename: string): boolean {
|
||||
const prepared = prepareContent(content)
|
||||
const firstLine = prepared.split('\n')[0]?.toLowerCase() || ''
|
||||
// Nordea header: comma-delimited with "datum", "transaktion", "belopp"
|
||||
// Must NOT contain semicolons (that would be SEB or Handelsbanken)
|
||||
return (
|
||||
!firstLine.includes(';') &&
|
||||
firstLine.includes('datum') &&
|
||||
firstLine.includes('transaktion') &&
|
||||
firstLine.includes('belopp')
|
||||
)
|
||||
},
|
||||
|
||||
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
|
||||
|
||||
// Skip header row
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const line = lines[i].trim()
|
||||
if (!line) continue
|
||||
|
||||
// Parse CSV with comma delimiter
|
||||
// Handle quoted fields that may contain commas
|
||||
const fields = parseCSVLine(line, ',')
|
||||
|
||||
if (fields.length < 4) {
|
||||
issues.push({ row: i + 1, message: 'Too few columns', severity: 'warning' })
|
||||
skippedRows++
|
||||
continue
|
||||
}
|
||||
|
||||
const [date, description, _category, amountStr, balanceStr] = fields
|
||||
|
||||
// Skip reserved/pending transactions
|
||||
if (description?.toLowerCase().includes('reserverat')) {
|
||||
skippedRows++
|
||||
continue
|
||||
}
|
||||
|
||||
const amount = parseCommaDecimal(amountStr)
|
||||
if (isNaN(amount)) {
|
||||
issues.push({ row: i + 1, message: `Invalid amount: ${amountStr}`, severity: 'warning' })
|
||||
skippedRows++
|
||||
continue
|
||||
}
|
||||
|
||||
// Validate date format
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(date?.trim())) {
|
||||
issues.push({ row: i + 1, message: `Invalid date: ${date}`, severity: 'warning' })
|
||||
skippedRows++
|
||||
continue
|
||||
}
|
||||
|
||||
const balance = balanceStr ? parseCommaDecimal(balanceStr) : null
|
||||
|
||||
transactions.push({
|
||||
date: date.trim(),
|
||||
description: description?.trim() || 'Unknown',
|
||||
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: 'nordea',
|
||||
format_name: 'Nordea',
|
||||
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,
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a CSV line respecting quoted fields
|
||||
*/
|
||||
function parseCSVLine(line: string, delimiter: string): string[] {
|
||||
const fields: string[] = []
|
||||
let current = ''
|
||||
let inQuotes = false
|
||||
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const char = line[i]
|
||||
|
||||
if (char === '"') {
|
||||
if (inQuotes && line[i + 1] === '"') {
|
||||
current += '"'
|
||||
i++ // Skip escaped quote
|
||||
} else {
|
||||
inQuotes = !inQuotes
|
||||
}
|
||||
} else if (char === delimiter && !inQuotes) {
|
||||
fields.push(current)
|
||||
current = ''
|
||||
} else {
|
||||
current += char
|
||||
}
|
||||
}
|
||||
|
||||
fields.push(current)
|
||||
return fields
|
||||
}
|
||||
|
||||
export { parseCSVLine }
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* SEB CSV format parser
|
||||
*
|
||||
* Format: Semicolon-delimited, comma decimal separator
|
||||
* Columns vary but typically: Bokföringsdag, Valutadag, Verifikationsnummer,
|
||||
* Text/mottagare, Belopp, Saldo
|
||||
* Date format: YYYY-MM-DD
|
||||
* Encoding: UTF-8 or Windows-1252
|
||||
*/
|
||||
|
||||
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 sebFormat: BankFileFormat = {
|
||||
id: 'seb',
|
||||
name: 'SEB',
|
||||
description: 'SEB CSV (semicolon-delimited)',
|
||||
fileExtensions: ['.csv', '.txt'],
|
||||
|
||||
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
|
||||
return (
|
||||
firstLine.includes(';') &&
|
||||
(firstLine.includes('bokföringsdag') ||
|
||||
firstLine.includes('bokforingsdatum') ||
|
||||
firstLine.includes('bokföringsdag'))
|
||||
)
|
||||
},
|
||||
|
||||
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 to find column indices
|
||||
const headerLine = lines[0] || ''
|
||||
const headers = headerLine.split(';').map((h) => h.trim().toLowerCase().replace(/"/g, ''))
|
||||
|
||||
// Find column indices dynamically
|
||||
const dateIdx = headers.findIndex(
|
||||
(h) => h.includes('bokföringsdag') || h.includes('bokforingsdatum') || h.includes('bokföringsdag')
|
||||
)
|
||||
const descIdx = headers.findIndex(
|
||||
(h) => h.includes('text') || h.includes('mottagare') || h.includes('beskrivning')
|
||||
)
|
||||
const amountIdx = headers.findIndex((h) => h.includes('belopp'))
|
||||
const balanceIdx = headers.findIndex((h) => h.includes('saldo'))
|
||||
|
||||
if (dateIdx === -1 || amountIdx === -1) {
|
||||
issues.push({
|
||||
row: 1,
|
||||
message: 'Could not identify required columns (date, amount)',
|
||||
severity: 'error',
|
||||
})
|
||||
return {
|
||||
format: 'seb',
|
||||
format_name: 'SEB',
|
||||
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 = fields[descIdx >= 0 ? descIdx : dateIdx + 1] || 'Unknown'
|
||||
const amountStr = fields[amountIdx]
|
||||
const balanceStr = balanceIdx >= 0 ? fields[balanceIdx] : undefined
|
||||
|
||||
if (!date || !amountStr) {
|
||||
issues.push({ row: i + 1, message: 'Missing required fields', severity: 'warning' })
|
||||
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.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: 'seb',
|
||||
format_name: 'SEB',
|
||||
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,
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* Swedbank CSV format parser
|
||||
*
|
||||
* Format: Comma-delimited, PERIOD decimal separator (exception among Swedish banks!)
|
||||
* Columns: Clearingnummer, Kontonummer, Datum, Text, Belopp, Saldo, and more (12 columns)
|
||||
* Date format: YYYY-MM-DD
|
||||
* Encoding: UTF-8 or Windows-1252
|
||||
*
|
||||
* Notes:
|
||||
* - First line is metadata (account info), SKIP it
|
||||
* - Second line is the actual header
|
||||
* - Uses period as decimal separator (unlike Nordea/SEB/Handelsbanken)
|
||||
*/
|
||||
|
||||
import type { BankFileFormat, BankFileParseResult, ParsedBankTransaction, BankFileParseIssue } from '../types'
|
||||
import { prepareContent } from '../encoding'
|
||||
import { parseCSVLine } from './nordea'
|
||||
|
||||
export const swedbankFormat: BankFileFormat = {
|
||||
id: 'swedbank',
|
||||
name: 'Swedbank',
|
||||
description: 'Swedbank CSV (comma-delimited, period decimal)',
|
||||
fileExtensions: ['.csv', '.txt'],
|
||||
|
||||
detect(content: string, _filename: string): boolean {
|
||||
const prepared = prepareContent(content)
|
||||
const lines = prepared.split('\n')
|
||||
// Check first two lines — Swedbank has metadata line, then header
|
||||
const line1 = lines[0]?.toLowerCase() || ''
|
||||
const line2 = lines[1]?.toLowerCase() || ''
|
||||
|
||||
return (
|
||||
(line1.includes('clearingnummer') || line2.includes('clearingnummer') ||
|
||||
line1.includes('radnummer') || line2.includes('radnummer'))
|
||||
)
|
||||
},
|
||||
|
||||
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 where the header is
|
||||
// Line 0 might be metadata, line 1 might be header
|
||||
let headerLineIdx = 0
|
||||
const line0Lower = lines[0]?.toLowerCase() || ''
|
||||
const line1Lower = lines[1]?.toLowerCase() || ''
|
||||
|
||||
if (line1Lower.includes('clearingnummer') || line1Lower.includes('radnummer')) {
|
||||
headerLineIdx = 1
|
||||
} else if (line0Lower.includes('clearingnummer') || line0Lower.includes('radnummer')) {
|
||||
headerLineIdx = 0
|
||||
}
|
||||
|
||||
const headerLine = lines[headerLineIdx] || ''
|
||||
const headers = parseCSVLine(headerLine, ',').map((h) =>
|
||||
h.trim().toLowerCase().replace(/"/g, '')
|
||||
)
|
||||
|
||||
// Find column indices
|
||||
const dateIdx = headers.findIndex((h) => h === 'datum' || h.includes('bokföringsdatum'))
|
||||
const descIdx = headers.findIndex((h) => h === 'text' || h.includes('beskrivning'))
|
||||
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: 'swedbank',
|
||||
format_name: 'Swedbank',
|
||||
transactions: [],
|
||||
date_from: null,
|
||||
date_to: null,
|
||||
issues,
|
||||
stats: { total_rows: 0, parsed_rows: 0, skipped_rows: 0, total_income: 0, total_expenses: 0 },
|
||||
}
|
||||
}
|
||||
|
||||
// Data starts after header
|
||||
for (let i = headerLineIdx + 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
|
||||
}
|
||||
|
||||
// Swedbank uses PERIOD decimal separator
|
||||
const amount = parseFloat(amountStr.replace(/\s/g, ''))
|
||||
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 ? parseFloat(balanceStr.replace(/\s/g, '')) : 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: 'swedbank',
|
||||
format_name: 'Swedbank',
|
||||
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,138 @@
|
||||
/**
|
||||
* Bank file parser — main entry point
|
||||
*
|
||||
* Auto-detects Swedish bank file formats and parses to normalized transactions.
|
||||
* Supports Nordea, SEB, Swedbank, Handelsbanken CSV and ISO 20022 camt.053 XML.
|
||||
*/
|
||||
|
||||
import * as crypto from 'crypto'
|
||||
import type { BankFileFormat, BankFileFormatId, BankFileParseResult, ParsedBankTransaction } from './types'
|
||||
import { nordeaFormat } from './formats/nordea'
|
||||
import { sebFormat } from './formats/seb'
|
||||
import { swedbankFormat } from './formats/swedbank'
|
||||
import { handelsbankenFormat } from './formats/handelsbanken'
|
||||
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.
|
||||
* Generic CSV is last — it never auto-detects (manual fallback only).
|
||||
*/
|
||||
const FORMATS: BankFileFormat[] = [
|
||||
camt053Format,
|
||||
nordeaFormat,
|
||||
sebFormat,
|
||||
swedbankFormat,
|
||||
handelsbankenFormat,
|
||||
genericCSVFormat,
|
||||
]
|
||||
|
||||
/**
|
||||
* Get a format by its ID
|
||||
*/
|
||||
export function getFormat(id: BankFileFormatId): BankFileFormat | undefined {
|
||||
return FORMATS.find((f) => f.id === id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available formats
|
||||
*/
|
||||
export function getAllFormats(): BankFileFormat[] {
|
||||
return FORMATS
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-detect the bank file format from content and filename
|
||||
*
|
||||
* Returns the first matching format, or null if no format matches.
|
||||
* Uses filename extension as a hint (e.g. .xml for camt.053).
|
||||
*/
|
||||
export function detectFileFormat(content: string, filename: string): BankFileFormat | null {
|
||||
for (const format of FORMATS) {
|
||||
if (format.detect(content, filename)) {
|
||||
return format
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a bank file with auto-detection or explicit format
|
||||
*
|
||||
* @param content - File content as string (already decoded)
|
||||
* @param filename - Original filename (used for format detection hints)
|
||||
* @param formatId - Optional explicit format to use (skips auto-detection)
|
||||
*/
|
||||
export function parseBankFile(
|
||||
content: string,
|
||||
filename: string,
|
||||
formatId?: BankFileFormatId
|
||||
): BankFileParseResult {
|
||||
let format: BankFileFormat | undefined
|
||||
|
||||
if (formatId) {
|
||||
format = getFormat(formatId)
|
||||
if (!format) {
|
||||
return {
|
||||
format: formatId,
|
||||
format_name: 'Unknown',
|
||||
transactions: [],
|
||||
date_from: null,
|
||||
date_to: null,
|
||||
issues: [{ row: 0, message: `Unknown format: ${formatId}`, severity: 'error' }],
|
||||
stats: { total_rows: 0, parsed_rows: 0, skipped_rows: 0, total_income: 0, total_expenses: 0 },
|
||||
}
|
||||
}
|
||||
} else {
|
||||
format = detectFileFormat(content, filename) || undefined
|
||||
if (!format) {
|
||||
return {
|
||||
format: 'generic_csv',
|
||||
format_name: 'Unknown',
|
||||
transactions: [],
|
||||
date_from: null,
|
||||
date_to: null,
|
||||
issues: [{
|
||||
row: 0,
|
||||
message: 'Could not auto-detect file format. Please select your bank manually.',
|
||||
severity: 'error',
|
||||
}],
|
||||
stats: { total_rows: 0, parsed_rows: 0, skipped_rows: 0, total_income: 0, total_expenses: 0 },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return format.parse(content)
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a stable external_id for a parsed bank transaction.
|
||||
*
|
||||
* For CSV files: SHA-256 of (format + date + description + amount + row_index)
|
||||
* For camt.053: Uses the entry reference from the XML if available
|
||||
*
|
||||
* Two identical transactions on the same day will get different IDs due to row_index.
|
||||
*/
|
||||
export function generateExternalId(
|
||||
tx: ParsedBankTransaction,
|
||||
formatId: BankFileFormatId,
|
||||
rowIndex: number
|
||||
): string {
|
||||
// For camt.053, prefer the raw_line which contains the entry reference
|
||||
if (formatId === 'camt053' && tx.raw_line && !tx.raw_line.startsWith('camt053_entry_')) {
|
||||
return `camt053_${tx.raw_line}`
|
||||
}
|
||||
|
||||
// For CSV formats, create a composite hash
|
||||
const composite = `${formatId}|${tx.date}|${tx.description}|${tx.amount}|${rowIndex}`
|
||||
const hash = crypto.createHash('sha256').update(composite).digest('hex').substring(0, 16)
|
||||
return `${formatId}_${hash}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a file hash for dedup of the same file being uploaded twice
|
||||
*/
|
||||
export function generateFileHash(content: string): string {
|
||||
return crypto.createHash('sha256').update(content).digest('hex')
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Bank file import types
|
||||
*
|
||||
* Supports Swedish bank CSV formats (Nordea, SEB, Swedbank, Handelsbanken)
|
||||
* and ISO 20022 camt.053 XML.
|
||||
*/
|
||||
|
||||
/** Parsed and normalized bank transaction from any file format */
|
||||
export interface ParsedBankTransaction {
|
||||
date: string // YYYY-MM-DD
|
||||
description: string
|
||||
amount: number // Positive = income, negative = expense
|
||||
currency: string
|
||||
balance?: number | null
|
||||
reference?: string | null // OCR number, Bankgiro reference
|
||||
counterparty?: string | null
|
||||
raw_line?: string // Original CSV line for debugging
|
||||
}
|
||||
|
||||
/** Result from parsing a bank file */
|
||||
export interface BankFileParseResult {
|
||||
format: BankFileFormatId
|
||||
format_name: string
|
||||
transactions: ParsedBankTransaction[]
|
||||
date_from: string | null
|
||||
date_to: string | null
|
||||
issues: BankFileParseIssue[]
|
||||
stats: {
|
||||
total_rows: number
|
||||
parsed_rows: number
|
||||
skipped_rows: number
|
||||
total_income: number
|
||||
total_expenses: number
|
||||
}
|
||||
}
|
||||
|
||||
/** Issue encountered during parsing */
|
||||
export interface BankFileParseIssue {
|
||||
row: number
|
||||
message: string
|
||||
severity: 'warning' | 'error'
|
||||
}
|
||||
|
||||
/** Supported bank file format identifiers */
|
||||
export type BankFileFormatId =
|
||||
| 'nordea'
|
||||
| 'seb'
|
||||
| 'swedbank'
|
||||
| 'handelsbanken'
|
||||
| 'generic_csv'
|
||||
| 'camt053'
|
||||
|
||||
/** Format definition with detection and parsing capability */
|
||||
export interface BankFileFormat {
|
||||
id: BankFileFormatId
|
||||
name: string
|
||||
description: string
|
||||
fileExtensions: string[]
|
||||
detect: (content: string, filename: string) => boolean
|
||||
parse: (content: string) => BankFileParseResult
|
||||
}
|
||||
|
||||
/** Import tracking record stored in DB */
|
||||
export interface BankFileImport {
|
||||
id: string
|
||||
user_id: string
|
||||
filename: string
|
||||
file_hash: string
|
||||
file_format: string
|
||||
transaction_count: number
|
||||
imported_count: number
|
||||
duplicate_count: number
|
||||
matched_count: number
|
||||
date_from: string | null
|
||||
date_to: string | null
|
||||
status: 'pending' | 'processing' | 'completed' | 'failed'
|
||||
error_message: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
/** Column mapping for generic CSV format */
|
||||
export interface GenericCSVColumnMapping {
|
||||
date: number
|
||||
description: number
|
||||
amount: number
|
||||
reference?: number
|
||||
counterparty?: number
|
||||
balance?: number
|
||||
delimiter: string
|
||||
decimal_separator: ',' | '.'
|
||||
skip_rows: number
|
||||
date_format: string // e.g. 'YYYY-MM-DD'
|
||||
}
|
||||
@@ -11,6 +11,7 @@ export interface InvoiceMatch {
|
||||
* Confidence thresholds for invoice matching
|
||||
*/
|
||||
const CONFIDENCE = {
|
||||
OCR_REFERENCE_MATCH: 0.99,
|
||||
EXACT_AMOUNT_CUSTOMER: 0.95,
|
||||
EXACT_AMOUNT_ONLY: 0.80,
|
||||
FUZZY_AMOUNT_CUSTOMER: 0.70,
|
||||
@@ -144,6 +145,29 @@ export async function findMatchingInvoices(
|
||||
|
||||
const matches: InvoiceMatch[] = []
|
||||
|
||||
// OCR/Bankgiro reference matching — highest confidence
|
||||
// Swedish standard: match transaction reference to invoice OCR number
|
||||
const txReference = (transaction as Transaction & { reference?: string | null }).reference
|
||||
if (txReference) {
|
||||
const normalizedRef = txReference.replace(/\s+/g, '')
|
||||
for (const invoice of invoices) {
|
||||
// Match against invoice_number (used as OCR reference in Swedish payments)
|
||||
const invoiceRef = invoice.invoice_number?.replace(/\s+/g, '')
|
||||
if (invoiceRef && normalizedRef === invoiceRef) {
|
||||
matches.push({
|
||||
invoice: invoice as Invoice & { customer?: Customer },
|
||||
confidence: CONFIDENCE.OCR_REFERENCE_MATCH,
|
||||
matchReason: `OCR-referens matchar fakturanummer ${invoice.invoice_number}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// If we found an OCR match, return immediately (highest possible confidence)
|
||||
if (matches.length > 0) {
|
||||
return matches
|
||||
}
|
||||
}
|
||||
|
||||
for (const invoice of invoices) {
|
||||
// Currency filter - must match or be SEK equivalent
|
||||
const currencyMatch =
|
||||
|
||||
@@ -0,0 +1,477 @@
|
||||
/**
|
||||
* Tests for the generic transaction ingestion pipeline.
|
||||
*
|
||||
* Covers deduplication, insert, invoice matching, auto-categorization,
|
||||
* and result aggregation.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { ingestTransactions, type RawTransaction } from '../ingest'
|
||||
import { makeJournalEntry, makeTransaction } from '@/tests/helpers'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mocks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
vi.mock('@/lib/supabase/server')
|
||||
|
||||
const mockEvaluateMappingRules = vi.fn()
|
||||
vi.mock('@/lib/bookkeeping/mapping-engine', () => ({
|
||||
evaluateMappingRules: (...args: unknown[]) => mockEvaluateMappingRules(...args),
|
||||
}))
|
||||
|
||||
const mockCreateTransactionJournalEntry = vi.fn()
|
||||
vi.mock('@/lib/bookkeeping/transaction-entries', () => ({
|
||||
createTransactionJournalEntry: (...args: unknown[]) =>
|
||||
mockCreateTransactionJournalEntry(...args),
|
||||
}))
|
||||
|
||||
const mockGetBestInvoiceMatch = vi.fn()
|
||||
vi.mock('@/lib/invoice/invoice-matching', () => ({
|
||||
getBestInvoiceMatch: (...args: unknown[]) => mockGetBestInvoiceMatch(...args),
|
||||
}))
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Queue-based Supabase mock
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createQueueMockSupabase() {
|
||||
const resultQueue: { data: unknown; error: unknown }[] = []
|
||||
|
||||
/**
|
||||
* Push one or more results onto the queue.
|
||||
* Each awaited Supabase chain pops the next result in FIFO order.
|
||||
*/
|
||||
const enqueue = (...results: { data?: unknown; error?: unknown }[]) => {
|
||||
for (const r of results) {
|
||||
resultQueue.push({ data: r.data ?? null, error: r.error ?? null })
|
||||
}
|
||||
}
|
||||
|
||||
const buildChain = (): unknown => {
|
||||
const handler: ProxyHandler<object> = {
|
||||
get(_target, prop) {
|
||||
if (prop === 'then') {
|
||||
const next = resultQueue.shift() ?? { data: null, error: null }
|
||||
return (resolve: (v: unknown) => void) => resolve(next)
|
||||
}
|
||||
return (..._args: unknown[]) => buildChain()
|
||||
},
|
||||
}
|
||||
return new Proxy({}, handler)
|
||||
}
|
||||
|
||||
const supabase = {
|
||||
from: vi.fn().mockImplementation(() => buildChain()),
|
||||
rpc: vi.fn().mockImplementation(() => buildChain()),
|
||||
}
|
||||
|
||||
return { supabase, enqueue }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const USER_ID = 'user-1'
|
||||
|
||||
function makeRaw(overrides: Partial<RawTransaction> = {}): RawTransaction {
|
||||
return {
|
||||
date: '2024-06-15',
|
||||
description: 'Test transaction',
|
||||
amount: -250.0,
|
||||
currency: 'SEK',
|
||||
external_id: `ext-${Math.random().toString(36).slice(2, 8)}`,
|
||||
mcc_code: null,
|
||||
merchant_name: null,
|
||||
reference: null,
|
||||
bank_connection_id: null,
|
||||
import_source: 'test',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function makeMappingResult(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
rule: null,
|
||||
debit_account: '5410',
|
||||
credit_account: '1930',
|
||||
risk_level: 'low',
|
||||
confidence: 0.9,
|
||||
requires_review: false,
|
||||
default_private: false,
|
||||
vat_lines: [],
|
||||
description: 'Office supplies',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('ingestTransactions', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 1. Successfully imports new transactions
|
||||
// -----------------------------------------------------------------------
|
||||
it('imports new transactions when no duplicate exists', async () => {
|
||||
const { supabase, enqueue } = createQueueMockSupabase()
|
||||
const raw = makeRaw({ amount: -100 })
|
||||
const inserted = makeTransaction({ id: 'tx-1', external_id: raw.external_id })
|
||||
|
||||
// Dedup check returns null (no existing row)
|
||||
enqueue({ data: null, error: null })
|
||||
// Insert returns the new transaction
|
||||
enqueue({ data: inserted, error: null })
|
||||
// evaluateMappingRules will be called but we want low confidence
|
||||
mockEvaluateMappingRules.mockResolvedValue(makeMappingResult({ confidence: 0.5 }))
|
||||
|
||||
const result = await ingestTransactions(supabase as never, USER_ID, [raw])
|
||||
|
||||
expect(result.imported).toBe(1)
|
||||
expect(result.duplicates).toBe(0)
|
||||
expect(result.errors).toBe(0)
|
||||
expect(result.transaction_ids).toEqual(['tx-1'])
|
||||
})
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 2. Detects duplicates
|
||||
// -----------------------------------------------------------------------
|
||||
it('detects duplicates via external_id', async () => {
|
||||
const { supabase, enqueue } = createQueueMockSupabase()
|
||||
const raw = makeRaw()
|
||||
|
||||
// Dedup check returns an existing record
|
||||
enqueue({ data: { id: 'existing-tx-1' }, error: null })
|
||||
|
||||
const result = await ingestTransactions(supabase as never, USER_ID, [raw])
|
||||
|
||||
expect(result.duplicates).toBe(1)
|
||||
expect(result.imported).toBe(0)
|
||||
expect(result.transaction_ids).toEqual([])
|
||||
})
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 3. Counts errors when insert fails
|
||||
// -----------------------------------------------------------------------
|
||||
it('counts errors when insert fails', async () => {
|
||||
const { supabase, enqueue } = createQueueMockSupabase()
|
||||
const raw = makeRaw()
|
||||
|
||||
// Dedup check: no duplicate
|
||||
enqueue({ data: null, error: null })
|
||||
// Insert fails
|
||||
enqueue({ data: null, error: { message: 'DB constraint violation' } })
|
||||
|
||||
const result = await ingestTransactions(supabase as never, USER_ID, [raw])
|
||||
|
||||
expect(result.errors).toBe(1)
|
||||
expect(result.imported).toBe(0)
|
||||
expect(result.transaction_ids).toEqual([])
|
||||
})
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 4. Auto-matches invoices for income transactions (amount > 0)
|
||||
// -----------------------------------------------------------------------
|
||||
it('auto-matches invoices for income transactions', async () => {
|
||||
const { supabase, enqueue } = createQueueMockSupabase()
|
||||
const raw = makeRaw({ amount: 5000, description: 'Payment received' })
|
||||
const inserted = makeTransaction({
|
||||
id: 'tx-income',
|
||||
amount: 5000,
|
||||
external_id: raw.external_id,
|
||||
})
|
||||
|
||||
// Dedup: no duplicate
|
||||
enqueue({ data: null, error: null })
|
||||
// Insert returns the new transaction
|
||||
enqueue({ data: inserted, error: null })
|
||||
// Invoice match update (supabase.from('transactions').update(...))
|
||||
enqueue({ data: null, error: null })
|
||||
// Mapping rules auto-categorization update (if triggered)
|
||||
enqueue({ data: null, error: null })
|
||||
|
||||
mockGetBestInvoiceMatch.mockResolvedValue({
|
||||
invoice: { id: 'inv-1' },
|
||||
confidence: 0.95,
|
||||
matchReason: 'OCR reference match',
|
||||
})
|
||||
mockEvaluateMappingRules.mockResolvedValue(makeMappingResult({ confidence: 0.5 }))
|
||||
|
||||
const result = await ingestTransactions(supabase as never, USER_ID, [raw])
|
||||
|
||||
expect(result.auto_matched_invoices).toBe(1)
|
||||
expect(mockGetBestInvoiceMatch).toHaveBeenCalledWith(
|
||||
USER_ID,
|
||||
expect.objectContaining({ id: 'tx-income' }),
|
||||
0.50
|
||||
)
|
||||
})
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 5. Does not attempt invoice matching for expenses (amount < 0)
|
||||
// -----------------------------------------------------------------------
|
||||
it('does not attempt invoice matching for expenses', async () => {
|
||||
const { supabase, enqueue } = createQueueMockSupabase()
|
||||
const raw = makeRaw({ amount: -350 })
|
||||
const inserted = makeTransaction({
|
||||
id: 'tx-expense',
|
||||
amount: -350,
|
||||
external_id: raw.external_id,
|
||||
})
|
||||
|
||||
// Dedup: no duplicate
|
||||
enqueue({ data: null, error: null })
|
||||
// Insert
|
||||
enqueue({ data: inserted, error: null })
|
||||
|
||||
mockEvaluateMappingRules.mockResolvedValue(makeMappingResult({ confidence: 0.5 }))
|
||||
|
||||
const result = await ingestTransactions(supabase as never, USER_ID, [raw])
|
||||
|
||||
expect(result.imported).toBe(1)
|
||||
expect(result.auto_matched_invoices).toBe(0)
|
||||
expect(mockGetBestInvoiceMatch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 6. Auto-categorizes when mapping confidence >= 0.8
|
||||
// -----------------------------------------------------------------------
|
||||
it('auto-categorizes when mapping confidence is at least 0.8', async () => {
|
||||
const { supabase, enqueue } = createQueueMockSupabase()
|
||||
const raw = makeRaw({ amount: -500, mcc_code: 5411, merchant_name: 'ICA' })
|
||||
const inserted = makeTransaction({
|
||||
id: 'tx-cat',
|
||||
amount: -500,
|
||||
external_id: raw.external_id,
|
||||
})
|
||||
const journalEntry = makeJournalEntry({ id: 'je-1' })
|
||||
|
||||
// Dedup: no duplicate
|
||||
enqueue({ data: null, error: null })
|
||||
// Insert
|
||||
enqueue({ data: inserted, error: null })
|
||||
// Update after journal entry creation
|
||||
enqueue({ data: null, error: null })
|
||||
|
||||
mockEvaluateMappingRules.mockResolvedValue(
|
||||
makeMappingResult({ confidence: 0.85, requires_review: false })
|
||||
)
|
||||
mockCreateTransactionJournalEntry.mockResolvedValue(journalEntry)
|
||||
|
||||
const result = await ingestTransactions(supabase as never, USER_ID, [raw])
|
||||
|
||||
expect(result.auto_categorized).toBe(1)
|
||||
expect(mockCreateTransactionJournalEntry).toHaveBeenCalledWith(
|
||||
USER_ID,
|
||||
expect.objectContaining({ id: 'tx-cat' }),
|
||||
expect.objectContaining({ confidence: 0.85 })
|
||||
)
|
||||
})
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 7. Skips auto-categorization when confidence < 0.8
|
||||
// -----------------------------------------------------------------------
|
||||
it('skips auto-categorization when confidence is below 0.8', async () => {
|
||||
const { supabase, enqueue } = createQueueMockSupabase()
|
||||
const raw = makeRaw({ amount: -200 })
|
||||
const inserted = makeTransaction({
|
||||
id: 'tx-lowconf',
|
||||
amount: -200,
|
||||
external_id: raw.external_id,
|
||||
})
|
||||
|
||||
// Dedup: no duplicate
|
||||
enqueue({ data: null, error: null })
|
||||
// Insert
|
||||
enqueue({ data: inserted, error: null })
|
||||
|
||||
mockEvaluateMappingRules.mockResolvedValue(
|
||||
makeMappingResult({ confidence: 0.6 })
|
||||
)
|
||||
|
||||
const result = await ingestTransactions(supabase as never, USER_ID, [raw])
|
||||
|
||||
expect(result.auto_categorized).toBe(0)
|
||||
expect(mockCreateTransactionJournalEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 7b. Skips auto-categorization when requires_review is true
|
||||
// -----------------------------------------------------------------------
|
||||
it('skips auto-categorization when requires_review is true even if confidence is high', async () => {
|
||||
const { supabase, enqueue } = createQueueMockSupabase()
|
||||
const raw = makeRaw({ amount: -800 })
|
||||
const inserted = makeTransaction({
|
||||
id: 'tx-review',
|
||||
amount: -800,
|
||||
external_id: raw.external_id,
|
||||
})
|
||||
|
||||
// Dedup: no duplicate
|
||||
enqueue({ data: null, error: null })
|
||||
// Insert
|
||||
enqueue({ data: inserted, error: null })
|
||||
|
||||
mockEvaluateMappingRules.mockResolvedValue(
|
||||
makeMappingResult({ confidence: 0.95, requires_review: true })
|
||||
)
|
||||
|
||||
const result = await ingestTransactions(supabase as never, USER_ID, [raw])
|
||||
|
||||
expect(result.auto_categorized).toBe(0)
|
||||
expect(mockCreateTransactionJournalEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 8. Returns correct IngestResult totals
|
||||
// -----------------------------------------------------------------------
|
||||
it('returns correct IngestResult totals', async () => {
|
||||
const { supabase, enqueue } = createQueueMockSupabase()
|
||||
const raw1 = makeRaw({ external_id: 'ext-a', amount: -100 })
|
||||
const raw2 = makeRaw({ external_id: 'ext-b', amount: -200 })
|
||||
|
||||
const inserted1 = makeTransaction({ id: 'tx-a', amount: -100 })
|
||||
const inserted2 = makeTransaction({ id: 'tx-b', amount: -200 })
|
||||
|
||||
// Transaction 1: dedup (no match), insert OK
|
||||
enqueue({ data: null, error: null })
|
||||
enqueue({ data: inserted1, error: null })
|
||||
// Transaction 2: dedup (no match), insert OK
|
||||
enqueue({ data: null, error: null })
|
||||
enqueue({ data: inserted2, error: null })
|
||||
|
||||
mockEvaluateMappingRules.mockResolvedValue(makeMappingResult({ confidence: 0.5 }))
|
||||
|
||||
const result = await ingestTransactions(supabase as never, USER_ID, [raw1, raw2])
|
||||
|
||||
expect(result.imported).toBe(2)
|
||||
expect(result.duplicates).toBe(0)
|
||||
expect(result.errors).toBe(0)
|
||||
expect(result.auto_categorized).toBe(0)
|
||||
expect(result.auto_matched_invoices).toBe(0)
|
||||
expect(result.transaction_ids).toEqual(['tx-a', 'tx-b'])
|
||||
})
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 9. Handles mixed batch (new, duplicates, errors)
|
||||
// -----------------------------------------------------------------------
|
||||
it('handles a mixed batch of new transactions, duplicates, and errors', async () => {
|
||||
const { supabase, enqueue } = createQueueMockSupabase()
|
||||
|
||||
const rawNew = makeRaw({ external_id: 'ext-new', amount: 3000 })
|
||||
const rawDup = makeRaw({ external_id: 'ext-dup', amount: -150 })
|
||||
const rawErr = makeRaw({ external_id: 'ext-err', amount: -75 })
|
||||
|
||||
const insertedNew = makeTransaction({
|
||||
id: 'tx-new',
|
||||
amount: 3000,
|
||||
external_id: 'ext-new',
|
||||
})
|
||||
|
||||
// Transaction rawNew: dedup (no match), insert OK
|
||||
enqueue({ data: null, error: null })
|
||||
enqueue({ data: insertedNew, error: null })
|
||||
// Invoice match update for income transaction
|
||||
enqueue({ data: null, error: null })
|
||||
// Auto-categorization update
|
||||
enqueue({ data: null, error: null })
|
||||
|
||||
// Transaction rawDup: dedup returns existing record
|
||||
enqueue({ data: { id: 'existing-dup' }, error: null })
|
||||
|
||||
// Transaction rawErr: dedup (no match), insert fails
|
||||
enqueue({ data: null, error: null })
|
||||
enqueue({ data: null, error: { message: 'Insert failed' } })
|
||||
|
||||
// Income transaction gets an invoice match
|
||||
mockGetBestInvoiceMatch.mockResolvedValue({
|
||||
invoice: { id: 'inv-match' },
|
||||
confidence: 0.95,
|
||||
matchReason: 'Exact amount match',
|
||||
})
|
||||
|
||||
// Auto-categorization with high confidence
|
||||
mockEvaluateMappingRules.mockResolvedValue(
|
||||
makeMappingResult({ confidence: 0.85 })
|
||||
)
|
||||
const journalEntry = makeJournalEntry({ id: 'je-mixed' })
|
||||
mockCreateTransactionJournalEntry.mockResolvedValue(journalEntry)
|
||||
|
||||
const result = await ingestTransactions(
|
||||
supabase as never,
|
||||
USER_ID,
|
||||
[rawNew, rawDup, rawErr]
|
||||
)
|
||||
|
||||
expect(result.imported).toBe(1)
|
||||
expect(result.duplicates).toBe(1)
|
||||
expect(result.errors).toBe(1)
|
||||
expect(result.auto_matched_invoices).toBe(1)
|
||||
expect(result.auto_categorized).toBe(1)
|
||||
expect(result.transaction_ids).toEqual(['tx-new'])
|
||||
})
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Edge: empty input array
|
||||
// -----------------------------------------------------------------------
|
||||
it('returns zero totals for an empty input array', async () => {
|
||||
const { supabase } = createQueueMockSupabase()
|
||||
|
||||
const result = await ingestTransactions(supabase as never, USER_ID, [])
|
||||
|
||||
expect(result).toEqual({
|
||||
imported: 0,
|
||||
duplicates: 0,
|
||||
auto_categorized: 0,
|
||||
auto_matched_invoices: 0,
|
||||
errors: 0,
|
||||
transaction_ids: [],
|
||||
})
|
||||
})
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Edge: invoice matching error is non-critical
|
||||
// -----------------------------------------------------------------------
|
||||
it('continues processing when invoice matching throws', async () => {
|
||||
const { supabase, enqueue } = createQueueMockSupabase()
|
||||
const raw = makeRaw({ amount: 1000 })
|
||||
const inserted = makeTransaction({ id: 'tx-inv-err', amount: 1000 })
|
||||
|
||||
enqueue({ data: null, error: null })
|
||||
enqueue({ data: inserted, error: null })
|
||||
|
||||
mockGetBestInvoiceMatch.mockRejectedValue(new Error('Network error'))
|
||||
mockEvaluateMappingRules.mockResolvedValue(makeMappingResult({ confidence: 0.5 }))
|
||||
|
||||
const result = await ingestTransactions(supabase as never, USER_ID, [raw])
|
||||
|
||||
// Should still count as imported even though invoice matching failed
|
||||
expect(result.imported).toBe(1)
|
||||
expect(result.auto_matched_invoices).toBe(0)
|
||||
expect(result.errors).toBe(0)
|
||||
})
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Edge: auto-categorization error is non-critical
|
||||
// -----------------------------------------------------------------------
|
||||
it('continues processing when auto-categorization throws', async () => {
|
||||
const { supabase, enqueue } = createQueueMockSupabase()
|
||||
const raw = makeRaw({ amount: -400 })
|
||||
const inserted = makeTransaction({ id: 'tx-cat-err', amount: -400 })
|
||||
|
||||
enqueue({ data: null, error: null })
|
||||
enqueue({ data: inserted, error: null })
|
||||
|
||||
mockEvaluateMappingRules.mockRejectedValue(new Error('Mapping error'))
|
||||
|
||||
const result = await ingestTransactions(supabase as never, USER_ID, [raw])
|
||||
|
||||
expect(result.imported).toBe(1)
|
||||
expect(result.auto_categorized).toBe(0)
|
||||
expect(result.errors).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,159 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { evaluateMappingRules } from '@/lib/bookkeeping/mapping-engine'
|
||||
import { createTransactionJournalEntry } from '@/lib/bookkeeping/transaction-entries'
|
||||
import { getBestInvoiceMatch } from '@/lib/invoice/invoice-matching'
|
||||
import type { Transaction } from '@/types'
|
||||
|
||||
/**
|
||||
* Normalized transaction input for the generic ingestion pipeline.
|
||||
* Both file import and PSD2 sync convert to this format before ingesting.
|
||||
*/
|
||||
export interface RawTransaction {
|
||||
date: string
|
||||
description: string
|
||||
amount: number
|
||||
currency: string
|
||||
external_id: string // dedup key
|
||||
mcc_code?: number | null
|
||||
merchant_name?: string | null
|
||||
reference?: string | null // OCR number, Bankgiro ref, etc.
|
||||
bank_connection_id?: string | null
|
||||
import_source?: string // 'csv_nordea', 'camt053', 'enable_banking', etc.
|
||||
}
|
||||
|
||||
export interface IngestResult {
|
||||
imported: number
|
||||
duplicates: number
|
||||
auto_categorized: number
|
||||
auto_matched_invoices: number
|
||||
errors: number
|
||||
transaction_ids: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic transaction ingestion pipeline.
|
||||
*
|
||||
* Handles:
|
||||
* 1. Deduplication via external_id
|
||||
* 2. Insert into transactions table
|
||||
* 3. OCR/reference-based invoice matching (highest confidence)
|
||||
* 4. Amount+customer fallback invoice matching
|
||||
* 5. Mapping rule evaluation for auto-categorization
|
||||
* 6. Auto-journal-entry creation for high-confidence matches
|
||||
*
|
||||
* Used by both bank file import and Enable Banking PSD2 sync.
|
||||
*/
|
||||
export async function ingestTransactions(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
rawTransactions: RawTransaction[]
|
||||
): Promise<IngestResult> {
|
||||
const result: IngestResult = {
|
||||
imported: 0,
|
||||
duplicates: 0,
|
||||
auto_categorized: 0,
|
||||
auto_matched_invoices: 0,
|
||||
errors: 0,
|
||||
transaction_ids: [],
|
||||
}
|
||||
|
||||
for (const raw of rawTransactions) {
|
||||
// 1. Check for duplicates via external_id
|
||||
const { data: existing } = await supabase
|
||||
.from('transactions')
|
||||
.select('id')
|
||||
.eq('user_id', userId)
|
||||
.eq('external_id', raw.external_id)
|
||||
.single()
|
||||
|
||||
if (existing) {
|
||||
result.duplicates++
|
||||
continue
|
||||
}
|
||||
|
||||
// 2. Insert new transaction
|
||||
const { data: newTransaction, error: insertError } = await supabase
|
||||
.from('transactions')
|
||||
.insert({
|
||||
user_id: userId,
|
||||
bank_connection_id: raw.bank_connection_id || null,
|
||||
external_id: raw.external_id,
|
||||
date: raw.date,
|
||||
description: raw.description,
|
||||
amount: raw.amount,
|
||||
currency: raw.currency,
|
||||
category: 'uncategorized',
|
||||
is_business: null,
|
||||
mcc_code: raw.mcc_code || null,
|
||||
merchant_name: raw.merchant_name || null,
|
||||
reference: raw.reference || null,
|
||||
import_source: raw.import_source || null,
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (insertError || !newTransaction) {
|
||||
result.errors++
|
||||
continue
|
||||
}
|
||||
|
||||
result.imported++
|
||||
result.transaction_ids.push(newTransaction.id)
|
||||
|
||||
// 3. For income transactions, try invoice matching
|
||||
if (newTransaction.amount > 0) {
|
||||
try {
|
||||
// OCR/reference matching is handled inside getBestInvoiceMatch
|
||||
// (which calls findMatchingInvoices, which now checks references)
|
||||
const bestMatch = await getBestInvoiceMatch(
|
||||
userId,
|
||||
newTransaction as Transaction,
|
||||
0.50
|
||||
)
|
||||
|
||||
if (bestMatch) {
|
||||
await supabase
|
||||
.from('transactions')
|
||||
.update({ potential_invoice_id: bestMatch.invoice.id })
|
||||
.eq('id', newTransaction.id)
|
||||
|
||||
result.auto_matched_invoices++
|
||||
}
|
||||
} catch {
|
||||
// Non-critical — continue processing
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Evaluate mapping rules for auto-categorization
|
||||
try {
|
||||
const mappingResult = await evaluateMappingRules(
|
||||
userId,
|
||||
newTransaction as Transaction
|
||||
)
|
||||
|
||||
if (mappingResult.confidence >= 0.8 && !mappingResult.requires_review) {
|
||||
const journalEntry = await createTransactionJournalEntry(
|
||||
userId,
|
||||
newTransaction as Transaction,
|
||||
mappingResult
|
||||
)
|
||||
|
||||
if (journalEntry) {
|
||||
await supabase
|
||||
.from('transactions')
|
||||
.update({
|
||||
journal_entry_id: journalEntry.id,
|
||||
is_business: !mappingResult.default_private,
|
||||
})
|
||||
.eq('id', newTransaction.id)
|
||||
|
||||
result.auto_categorized++
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Non-critical — continue processing
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
-- Bank file import support
|
||||
-- Adds import_source and reference to transactions,
|
||||
-- and a bank_file_imports tracking table
|
||||
|
||||
-- Track import origin on transactions
|
||||
ALTER TABLE public.transactions ADD COLUMN IF NOT EXISTS import_source text;
|
||||
CREATE INDEX IF NOT EXISTS idx_transactions_import_source ON public.transactions(import_source);
|
||||
|
||||
-- Store OCR/Bankgiro reference for Swedish payment matching
|
||||
ALTER TABLE public.transactions ADD COLUMN IF NOT EXISTS reference text;
|
||||
CREATE INDEX IF NOT EXISTS idx_transactions_reference ON public.transactions(reference);
|
||||
|
||||
-- Bank file import tracking (prevents duplicate file uploads, provides history)
|
||||
CREATE TABLE public.bank_file_imports (
|
||||
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
user_id uuid REFERENCES auth.users ON DELETE CASCADE NOT NULL,
|
||||
filename text NOT NULL,
|
||||
file_hash text NOT NULL,
|
||||
file_format text NOT NULL,
|
||||
transaction_count integer NOT NULL DEFAULT 0,
|
||||
imported_count integer NOT NULL DEFAULT 0,
|
||||
duplicate_count integer NOT NULL DEFAULT 0,
|
||||
matched_count integer NOT NULL DEFAULT 0,
|
||||
date_from date,
|
||||
date_to date,
|
||||
status text NOT NULL DEFAULT 'pending',
|
||||
error_message text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (user_id, file_hash)
|
||||
);
|
||||
|
||||
ALTER TABLE public.bank_file_imports ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY "bank_file_imports_select" ON public.bank_file_imports
|
||||
FOR SELECT USING (auth.uid() = user_id);
|
||||
CREATE POLICY "bank_file_imports_insert" ON public.bank_file_imports
|
||||
FOR INSERT WITH CHECK (auth.uid() = user_id);
|
||||
CREATE POLICY "bank_file_imports_update" ON public.bank_file_imports
|
||||
FOR UPDATE USING (auth.uid() = user_id);
|
||||
|
||||
CREATE TRIGGER bank_file_imports_updated_at
|
||||
BEFORE UPDATE ON public.bank_file_imports
|
||||
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
|
||||
@@ -142,6 +142,8 @@ export function makeTransaction(overrides: Partial<Transaction> = {}): Transacti
|
||||
mcc_code: null,
|
||||
merchant_name: 'ICA Maxi',
|
||||
receipt_id: null,
|
||||
import_source: null,
|
||||
reference: null,
|
||||
notes: null,
|
||||
created_at: '2024-06-15T14:30:00Z',
|
||||
updated_at: '2024-06-15T14:30:00Z',
|
||||
|
||||
@@ -157,6 +157,17 @@ export interface BankAccount {
|
||||
balance: number | null
|
||||
}
|
||||
|
||||
// Import source identifiers
|
||||
export type ImportSource =
|
||||
| 'enable_banking'
|
||||
| 'csv_nordea'
|
||||
| 'csv_seb'
|
||||
| 'csv_swedbank'
|
||||
| 'csv_handelsbanken'
|
||||
| 'csv_generic'
|
||||
| 'camt053'
|
||||
| 'manual'
|
||||
|
||||
// Transaction
|
||||
export interface Transaction {
|
||||
id: string
|
||||
@@ -198,6 +209,10 @@ export interface Transaction {
|
||||
// Receipt link
|
||||
receipt_id: string | null
|
||||
|
||||
// Import tracking
|
||||
import_source: string | null
|
||||
reference: string | null // OCR number, Bankgiro reference
|
||||
|
||||
// Notes
|
||||
notes: string | null
|
||||
|
||||
@@ -205,6 +220,27 @@ export interface Transaction {
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
// Bank File Import (tracking table for file-based imports)
|
||||
export type BankFileImportStatus = 'pending' | 'processing' | 'completed' | 'failed'
|
||||
|
||||
export interface BankFileImport {
|
||||
id: string
|
||||
user_id: string
|
||||
filename: string
|
||||
file_hash: string
|
||||
file_format: string
|
||||
transaction_count: number
|
||||
imported_count: number
|
||||
duplicate_count: number
|
||||
matched_count: number
|
||||
date_from: string | null
|
||||
date_to: string | null
|
||||
status: BankFileImportStatus
|
||||
error_message: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
// Customer
|
||||
export interface Customer {
|
||||
id: string
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@
|
||||
"schedule": "0 0 2 1 *"
|
||||
},
|
||||
{
|
||||
"path": "/api/banking/sync/cron",
|
||||
"path": "/api/extensions/enable-banking/sync/cron",
|
||||
"schedule": "0 5 * * *"
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user