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',
|
||||
|
||||
Reference in New Issue
Block a user