feat: remove AI extensions, restructure settings, and add atomic voucher commits (#157)

Remove AI-dependent extensions (ai-chat, ai-categorization, receipt-ocr,
invoice-inbox) and their infrastructure (lib/ai/*, ai-consent, LangChain/
Anthropic/OpenAI deps) to simplify core and reduce bundle size.

Restructure monolithic settings page into dedicated sub-pages (company,
bookkeeping, invoicing, tax, banking, api, account, team, templates) with
shared layout and sidebar navigation.

Add atomic commit_journal_entry RPC so voucher number increment and status
update happen in a single transaction — prevents burned numbers on constraint
failures. Add continuity check report and voucher gap explanation tracking.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-04-01 17:08:00 +02:00
committed by GitHub
parent e89f2c402d
commit d0b3f21bde
172 changed files with 3621 additions and 20538 deletions
+1 -1
View File
@@ -359,7 +359,7 @@ function SIEImportWizard() {
const data = await res.json()
if (!res.ok) {
if (data.error === 'duplicate') {
if (data.error === 'duplicate' || data.error === 'duplicate_period') {
setError(data.message)
} else if (data.error === 'validation') {
setError(`${data.message}: ${data.errors?.join(', ') || 'Unknown validation error'}`)
+5 -3
View File
@@ -29,10 +29,12 @@ export default function KpiPage() {
const { data: periodsData } = await periodsRes.json()
const { data: prefsData } = await prefsRes.json()
setPeriods(periodsData || [])
const today = new Date().toISOString().split('T')[0]
const activePeriods = (periodsData || []).filter((p: FiscalPeriod) => p.period_start <= today)
setPeriods(activePeriods)
if (prefsData) setPreferences(prefsData)
if (periodsData && periodsData.length > 0) {
setSelectedPeriod(periodsData[0].id)
if (activePeriods.length > 0) {
setSelectedPeriod(activePeriods[0].id)
}
} catch {
setError('Kunde inte hämta data')
+4 -25
View File
@@ -1,24 +1,9 @@
'use client'
import dynamic from 'next/dynamic'
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Receipt, Loader2 } from 'lucide-react'
import Link from 'next/link'
const ReceiptsPageOCR = dynamic(
() => import('@/extensions/general/receipt-ocr/pages/ReceiptsPage'),
{ loading: () => <div className="flex items-center justify-center h-64"><Loader2 className="h-8 w-8 animate-spin text-primary" /></div> }
)
const ocrEnabled = ENABLED_EXTENSION_IDS.has('receipt-ocr')
import { Receipt } from 'lucide-react'
export default function ReceiptsPage() {
if (ocrEnabled) {
return <ReceiptsPageOCR />
}
return (
<div className="space-y-6">
<div>
@@ -32,19 +17,13 @@ export default function ReceiptsPage() {
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Receipt className="h-5 w-5" />
Kvittoscanning
Kvitton
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground mb-4">
Aktivera tillägget &quot;Kvittoscanning&quot; för att skanna kvitton med AI,
extrahera data automatiskt och matcha mot transaktioner.
<p className="text-sm text-muted-foreground">
Kvittoscanning är inte tillgängligt just nu.
</p>
<Button asChild>
<Link href="/extensions/general?highlight=receipt-ocr">
Aktivera i Tillägg
</Link>
</Button>
</CardContent>
</Card>
</div>
+2 -18
View File
@@ -1,30 +1,14 @@
'use client'
import dynamic from 'next/dynamic'
import { useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
import { Loader2 } from 'lucide-react'
const ScanReceiptPageOCR = dynamic(
() => import('@/extensions/general/receipt-ocr/pages/scan/ScanReceiptPage'),
{ loading: () => <div className="flex items-center justify-center h-64"><Loader2 className="h-8 w-8 animate-spin text-primary" /></div> }
)
const ocrEnabled = ENABLED_EXTENSION_IDS.has('receipt-ocr')
export default function ScanReceiptPage() {
const router = useRouter()
useEffect(() => {
if (!ocrEnabled) {
router.replace('/receipts')
}
router.replace('/receipts')
}, [router])
if (!ocrEnabled) {
return null
}
return <ScanReceiptPageOCR />
return null
}
+5 -3
View File
@@ -81,9 +81,11 @@ export default function ReportsPage() {
async function fetchPeriods() {
const res = await fetch('/api/bookkeeping/fiscal-periods')
const { data } = await res.json()
setPeriods(data || [])
if (data && data.length > 0) {
setSelectedPeriod(data[0].id)
const today = new Date().toISOString().split('T')[0]
const activePeriods = (data || []).filter((p: FiscalPeriod) => p.period_start <= today)
setPeriods(activePeriods)
if (activePeriods.length > 0) {
setSelectedPeriod(activePeriods[0].id)
}
}
+100
View File
@@ -0,0 +1,100 @@
'use client'
import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Sun, Moon, Monitor, LogOut } from 'lucide-react'
import { useTheme } from 'next-themes'
import { createClient } from '@/lib/supabase/client'
import { SecuritySettings } from '@/components/settings/SecuritySettings'
import { CalendarFeedSettings } from '@/components/settings/CalendarFeedSettings'
import { AccountDangerZone } from '@/components/settings/AccountDangerZone'
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
import { useSettings } from '@/components/settings/useSettings'
export default function AccountSettingsPage() {
const router = useRouter()
const supabase = createClient()
const { theme, setTheme } = useTheme()
const [mounted, setMounted] = useState(false)
const hasCalendarExtension = ENABLED_EXTENSION_IDS.has('calendar')
const { settings } = useSettings()
useEffect(() => { setMounted(true) }, [])
async function handleLogout() {
await supabase.auth.signOut()
router.push('/login')
}
return (
<div className="space-y-8">
{/* Appearance */}
<section className="space-y-4">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
Utseende
</h2>
{mounted && (
<div className="flex gap-3">
{([
{ value: 'light', label: 'Ljust', icon: Sun },
{ value: 'dark', label: 'Mörkt', icon: Moon },
{ value: 'system', label: 'System', icon: Monitor },
] as const).map(({ value, label, icon: Icon }) => (
<button
key={value}
type="button"
onClick={() => setTheme(value)}
className={`flex items-center gap-2 rounded-lg border-2 px-4 py-2.5 text-sm font-medium transition-colors ${
theme === value
? 'border-primary bg-primary/5'
: 'border-border hover:border-primary/40'
}`}
>
<Icon className="h-4 w-4 text-muted-foreground" />
{label}
</button>
))}
</div>
)}
</section>
{/* Security */}
<div className="border-t border-border/8 pt-8">
<SecuritySettings />
</div>
{/* Calendar feed */}
{hasCalendarExtension && (
<div className="border-t border-border/8 pt-8">
<CalendarFeedSettings />
</div>
)}
{/* Logout */}
<section className="border-t border-border/8 pt-8">
<Card>
<CardHeader>
<CardTitle>Kontoinställningar</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between p-4 border rounded-lg">
<div>
<p className="font-medium">Logga ut</p>
<p className="text-sm text-muted-foreground">Logga ut från ditt konto</p>
</div>
<Button variant="outline" onClick={handleLogout}>
<LogOut className="mr-2 h-4 w-4" />
Logga ut
</Button>
</div>
</CardContent>
</Card>
</section>
{/* Delete account — only for non-sandbox */}
{!settings?.is_sandbox && <AccountDangerZone />}
</div>
)
}
+7
View File
@@ -0,0 +1,7 @@
'use client'
import { ApiKeysPanel } from '@/components/settings/ApiKeysPanel'
export default function ApiSettingsPage() {
return <ApiKeysPanel />
}
+108
View File
@@ -0,0 +1,108 @@
'use client'
import { useState, useEffect } from 'react'
import { useSearchParams, useRouter } from 'next/navigation'
import Link from 'next/link'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { useToast } from '@/components/ui/use-toast'
import { AlertTriangle, CreditCard, ExternalLink } from 'lucide-react'
import { getSettingsPanel } from '@/lib/extensions/settings-panel-registry'
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
const BankingPanel = getSettingsPanel('enable-banking')
export default function BankingSettingsPage() {
const searchParams = useSearchParams()
const router = useRouter()
const { toast } = useToast()
const [bankConnectionError, setBankConnectionError] = useState<string | null>(null)
const hasBankingExtension = ENABLED_EXTENSION_IDS.has('enable-banking')
useEffect(() => {
const bankConnected = searchParams.get('bank_connected')
const bankError = searchParams.get('bank_error')
if (bankConnected === 'true') {
toast({
title: 'Bank ansluten!',
description: 'Din bank är nu kopplad. Transaktioner hämtas...',
})
const connectionId = searchParams.get('connection_id')
if (connectionId) {
fetch('/api/extensions/ext/enable-banking/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ connection_id: connectionId, days_back: 90 }),
})
.then(res => res.json())
.then(data => {
if (data.imported > 0) {
toast({
title: 'Transaktioner hämtade',
description: `${data.imported} transaktioner importerade`,
})
}
})
.catch(() => {})
}
router.replace('/settings/banking')
}
if (bankError) {
const errorMsg = decodeURIComponent(bankError)
toast({
title: 'Anslutning misslyckades',
description: errorMsg,
variant: 'destructive',
})
setBankConnectionError(errorMsg)
router.replace('/settings/banking')
}
}, [searchParams, router, toast])
return (
<div className="space-y-6">
{bankConnectionError && (
<div className="flex items-start gap-3 rounded-lg border border-destructive/20 bg-destructive/10 p-4">
<AlertTriangle className="mt-0.5 h-5 w-5 shrink-0 text-destructive" />
<div className="flex-1">
<p className="text-sm font-medium text-destructive">{bankConnectionError}</p>
<p className="mt-1 text-sm text-muted-foreground">
Du kan också <Link href="/import?mode=bank" className="underline hover:text-foreground">importera transaktioner via bankfil</Link> istället.
</p>
</div>
<button
onClick={() => setBankConnectionError(null)}
className="shrink-0 rounded-md p-1 text-muted-foreground hover:text-foreground"
aria-label="Stäng"
>
<span className="text-lg leading-none">&times;</span>
</button>
</div>
)}
{hasBankingExtension && BankingPanel ? (
<BankingPanel />
) : (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<CreditCard className="h-10 w-10 text-muted-foreground/40 mb-4" />
<p className="font-medium mb-1">Bankintegration (PSD2) är inte aktiverad</p>
<p className="text-sm text-muted-foreground mb-4 max-w-md">
Aktivera tillägget Enable Banking för att koppla ditt bankkonto och automatiskt hämta transaktioner.
</p>
<Button variant="outline" asChild>
<Link href="/extensions">
<ExternalLink className="mr-2 h-4 w-4" />
till Tillägg
</Link>
</Button>
</CardContent>
</Card>
)}
</div>
)
}
@@ -0,0 +1,94 @@
'use client'
import Link from 'next/link'
import { SettingsFormWrapper } from '@/components/settings/SettingsFormWrapper'
import { SettingsLoadingSkeleton } from '@/components/settings/SettingsLoadingSkeleton'
import { PeriodLockingSettings } from '@/components/settings/PeriodLockingSettings'
import { VoucherSeriesManager } from '@/components/settings/VoucherSeriesManager'
import { useSettings } from '@/components/settings/useSettings'
import { Label } from '@/components/ui/label'
import { ExternalLink } from 'lucide-react'
import type { CompanySettings } from '@/types'
export default function BookkeepingSettingsPage() {
const { settings, isLoading, updateSettings } = useSettings()
if (isLoading || !settings) return <SettingsLoadingSkeleton />
function handleSave(formData: FormData) {
const autoLockValue = formData.get('auto_lock_period_days') as string
const lockedThrough = (formData.get('bookkeeping_locked_through') as string) || null
const accountingMethod = (formData.get('accounting_method') as string) || 'accrual'
const updates: Record<string, unknown> = {
bookkeeping_locked_through: lockedThrough,
auto_lock_period_days: autoLockValue === 'none' ? null : parseInt(autoLockValue),
accounting_method: accountingMethod,
}
updateSettings(updates as Partial<CompanySettings>)
return updates
}
return (
<div className="space-y-8">
<SettingsFormWrapper onSave={handleSave} className="space-y-8">
{/* Accounting method */}
<section className="space-y-4">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
Bokföringsmetod
</h2>
<div className="space-y-2">
<Label htmlFor="accounting_method">Metod</Label>
<select
id="accounting_method"
name="accounting_method"
defaultValue={settings.accounting_method || 'accrual'}
className="flex h-10 w-full max-w-xs rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
<option value="accrual">Faktureringsmetoden</option>
<option value="cash">Kontantmetoden</option>
</select>
<p className="text-xs text-muted-foreground">
{settings.entity_type === 'aktiebolag'
? 'Aktiebolag med omsättning över 3 MSEK måste använda faktureringsmetoden.'
: 'Kontantmetoden är tillgänglig för enskild firma med omsättning under 3 MSEK.'}
</p>
</div>
</section>
{/* Period locking */}
<div className="border-t border-border/8 pt-8">
<PeriodLockingSettings settings={settings} />
</div>
</SettingsFormWrapper>
{/* Voucher series — read-only, no form submit needed */}
<div className="border-t border-border/8 pt-8">
<VoucherSeriesManager />
</div>
{/* Cross-links */}
<div className="border-t border-border/8 pt-8 space-y-3">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
Relaterat
</h2>
<div className="flex flex-col gap-2">
<Link
href="/bookkeeping"
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ExternalLink className="h-3.5 w-3.5" />
Räkenskapsår och ingående balanser
</Link>
<Link
href="/bookkeeping"
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ExternalLink className="h-3.5 w-3.5" />
Kontoplan (BAS)
</Link>
</div>
</div>
</div>
)
}
+49
View File
@@ -0,0 +1,49 @@
'use client'
import { CompanyInfoForm } from '@/components/settings/CompanyInfoForm'
import { CompanyMembersSection } from '@/components/settings/CompanyMembersSection'
import { LogoUpload } from '@/components/settings/LogoUpload'
import { SettingsFormWrapper } from '@/components/settings/SettingsFormWrapper'
import { SettingsLoadingSkeleton } from '@/components/settings/SettingsLoadingSkeleton'
import { useSettings } from '@/components/settings/useSettings'
import type { CompanySettings } from '@/types'
export default function CompanySettingsPage() {
const { settings, isLoading, updateSettings } = useSettings()
if (isLoading || !settings) return <SettingsLoadingSkeleton />
function handleSave(formData: FormData) {
const updates: Record<string, unknown> = {
...(formData.has('company_name') && { company_name: formData.get('company_name') as string }),
...(formData.has('org_number') && { org_number: formData.get('org_number') as string }),
address_line1: formData.get('address_line1') as string,
postal_code: formData.get('postal_code') as string,
city: formData.get('city') as string,
phone: (formData.get('phone') as string) || '',
email: (formData.get('email') as string) || '',
website: (formData.get('website') as string) || '',
}
updateSettings(updates as Partial<CompanySettings>)
return updates
}
return (
<div className="space-y-8">
<SettingsFormWrapper onSave={handleSave} className="space-y-8">
<CompanyInfoForm settings={settings} />
</SettingsFormWrapper>
<div className="border-t border-border/8 pt-8">
<LogoUpload
logoUrl={settings.logo_url}
onUpdate={(url) => updateSettings({ logo_url: url })}
/>
</div>
<div className="border-t border-border/8 pt-8">
<CompanyMembersSection />
</div>
</div>
)
}
@@ -0,0 +1,58 @@
'use client'
import { BankDetailsForm, validateBankFields } from '@/components/settings/BankDetailsForm'
import { InvoiceSettingsForm } from '@/components/settings/InvoiceSettingsForm'
import { PdfPrintSettings } from '@/components/settings/PdfPrintSettings'
import { SettingsFormWrapper } from '@/components/settings/SettingsFormWrapper'
import { SettingsLoadingSkeleton } from '@/components/settings/SettingsLoadingSkeleton'
import { useSettings } from '@/components/settings/useSettings'
import { useToast } from '@/components/ui/use-toast'
import type { CompanySettings } from '@/types'
export default function InvoicingSettingsPage() {
const { settings, isLoading, updateSettings } = useSettings()
const { toast } = useToast()
if (isLoading || !settings) return <SettingsLoadingSkeleton />
function handleSave(formData: FormData) {
const bankErrors = validateBankFields(formData)
if (bankErrors.length > 0) {
toast({
title: 'Kontrollera bankuppgifter',
description: bankErrors.map(e => e.message).join(', '),
variant: 'destructive',
})
return {}
}
const updates: Record<string, unknown> = {
bank_name: formData.get('bank_name') as string,
clearing_number: formData.get('clearing_number') as string,
account_number: formData.get('account_number') as string,
bankgiro: (formData.get('bankgiro') as string) || null,
invoice_prefix: (formData.get('invoice_prefix') as string) || null,
next_invoice_number: parseInt(formData.get('next_invoice_number') as string) || 1,
invoice_default_days: parseInt(formData.get('invoice_default_days') as string) || 30,
invoice_default_notes: (formData.get('invoice_default_notes') as string) || null,
}
updateSettings(updates as Partial<CompanySettings>)
return updates
}
return (
<div className="space-y-8">
<SettingsFormWrapper onSave={handleSave} className="space-y-8">
<BankDetailsForm settings={settings} />
<div className="border-t border-border/8 pt-8">
<InvoiceSettingsForm settings={settings} />
</div>
</SettingsFormWrapper>
{/* PDF settings — saves individually via toggle switches */}
<div className="border-t border-border/8 pt-8">
<PdfPrintSettings settings={settings} onUpdate={updateSettings} />
</div>
</div>
)
}
+63
View File
@@ -0,0 +1,63 @@
'use client'
import { useEffect, useState } from 'react'
import { useSearchParams, useRouter } from 'next/navigation'
import { SettingsNav } from '@/components/settings/SettingsSidebar'
import { useCompany } from '@/contexts/CompanyContext'
import { createClient } from '@/lib/supabase/client'
const TAB_TO_ROUTE: Record<string, string> = {
company: '/settings/company',
invoicing: '/settings/invoicing',
bookkeeping: '/settings/bookkeeping',
tax: '/settings/tax',
team: '/settings/team',
banking: '/settings/banking',
templates: '/settings/templates',
account: '/settings/account',
api: '/settings/api',
}
export default function SettingsLayout({ children }: { children: React.ReactNode }) {
const searchParams = useSearchParams()
const router = useRouter()
const { company } = useCompany()
const [isSandbox, setIsSandbox] = useState(false)
// Fetch sandbox status
useEffect(() => {
if (!company?.id) return
const supabase = createClient()
supabase
.from('company_settings')
.select('is_sandbox')
.eq('company_id', company.id)
.single()
.then(({ data }) => {
if (data?.is_sandbox) setIsSandbox(true)
})
}, [company?.id])
// Handle legacy ?tab= URLs
useEffect(() => {
const tab = searchParams.get('tab')
if (tab && TAB_TO_ROUTE[tab]) {
router.replace(TAB_TO_ROUTE[tab])
}
}, [searchParams, router])
return (
<div className="space-y-6">
<div>
<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight">Inställningar</h1>
<p className="text-muted-foreground text-sm mt-1">
Hantera ditt företag och konto
</p>
</div>
<SettingsNav isSandbox={isSandbox} />
<div>{children}</div>
</div>
)
}
+2 -859
View File
@@ -1,862 +1,5 @@
'use client'
import { useState, useEffect } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import Link from 'next/link'
import { createClient } from '@/lib/supabase/client'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { useToast } from '@/components/ui/use-toast'
import { Separator } from '@/components/ui/separator'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Loader2,
CreditCard,
LogOut,
Sun,
Moon,
Monitor,
ExternalLink,
AlertTriangle,
} from 'lucide-react'
import { useTheme } from 'next-themes'
import { useCompany } from '@/contexts/CompanyContext'
import type { CompanySettings } from '@/types'
import { validateBankgiroNumber, formatBankgiroNumber } from '@/lib/bankgiro/luhn'
import { BankNameCombobox } from '@/components/settings/BankNameCombobox'
import { CalendarFeedSettings } from '@/components/settings/CalendarFeedSettings'
import { SupportLink } from '@/components/ui/support-link'
import { getSettingsPanel } from '@/lib/extensions/settings-panel-registry'
import { SecuritySettings } from '@/components/settings/SecuritySettings'
import { ApiKeysPanel } from '@/components/settings/ApiKeysPanel'
import { CounterpartyTemplatesPanel } from '@/components/settings/CounterpartyTemplatesPanel'
import { TeamPanel } from '@/components/settings/TeamPanel'
import { CompanyMembersSection } from '@/components/settings/CompanyMembersSection'
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
const BankingPanel = getSettingsPanel('enable-banking')
import { redirect } from 'next/navigation'
export default function SettingsPage() {
const router = useRouter()
const searchParams = useSearchParams()
const { toast } = useToast()
const supabase = createClient()
const { company, isTeamMember } = useCompany()
const [isLoading, setIsLoading] = useState(true)
const [isSaving, setIsSaving] = useState(false)
const [settings, setSettings] = useState<CompanySettings | null>(null)
const hasBankingExtension = ENABLED_EXTENSION_IDS.has('enable-banking')
const hasCalendarExtension = ENABLED_EXTENSION_IDS.has('calendar')
const hasMcpExtension = ENABLED_EXTENSION_IDS.has('mcp-server')
const [bankConnectionError, setBankConnectionError] = useState<string | null>(null)
const [bankgiroError, setBankgiroError] = useState<string | null>(null)
const [clearingError, setClearingError] = useState<string | null>(null)
const [accountNumberError, setAccountNumberError] = useState<string | null>(null)
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [deleteConfirmText, setDeleteConfirmText] = useState('')
const [isDeleting, setIsDeleting] = useState(false)
const { theme, setTheme } = useTheme()
const [mounted, setMounted] = useState(false)
const hasCompany = !!company
const defaultTab = hasCompany ? 'company' : (isTeamMember ? 'team' : 'account')
const initialTab = searchParams.get('tab') || defaultTab
const [activeTab, setActiveTab] = useState(initialTab)
const settingsTabs = [
{ value: 'company', label: 'Företag', show: hasCompany },
{ value: 'team', label: 'Lag', show: isTeamMember },
{ value: 'banking', label: 'Bank (PSD2)', show: hasCompany && !settings?.is_sandbox && hasBankingExtension },
{ value: 'templates', label: 'Mallar', show: hasCompany },
{ value: 'account', label: 'Konto', show: true },
{ value: 'api', label: 'API', show: hasCompany && hasMcpExtension },
].filter(t => t.show)
useEffect(() => {
setMounted(true)
}, [])
async function fetchData() {
setIsLoading(true)
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
router.push('/login')
return
}
if (company?.id) {
const settingsRes = await supabase.from('company_settings').select('*').eq('company_id', company.id).single()
setSettings(settingsRes.data)
}
setIsLoading(false)
}
useEffect(() => {
fetchData()
// Handle callback messages
const bankConnected = searchParams.get('bank_connected')
const bankError = searchParams.get('bank_error')
if (bankConnected === 'true') {
toast({
title: 'Bank ansluten!',
description: 'Din bank är nu kopplad. Transaktioner hämtas...',
})
// Auto-sync transactions after connection
const connectionId = searchParams.get('connection_id')
if (connectionId) {
fetch('/api/extensions/ext/enable-banking/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ connection_id: connectionId, days_back: 90 }),
})
.then(res => res.json())
.then(data => {
if (data.imported > 0) {
toast({
title: 'Transaktioner hämtade',
description: `${data.imported} transaktioner importerade`,
})
}
})
.catch(() => {})
}
router.replace('/settings?tab=banking')
}
if (bankError) {
const errorMsg = decodeURIComponent(bankError)
toast({
title: 'Anslutning misslyckades',
description: errorMsg,
variant: 'destructive',
})
setBankConnectionError(errorMsg)
setActiveTab('banking')
router.replace('/settings?tab=banking')
}
}, [searchParams])
async function handleSaveSettings(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
if (!settings) return
const formData = new FormData(e.currentTarget)
// Validate bank fields before saving
const clearingVal = (formData.get('clearing_number') as string || '').trim()
const accountVal = (formData.get('account_number') as string || '').trim()
const bankgiroVal = (formData.get('bankgiro') as string || '').trim()
let hasErrors = false
if (clearingVal && !/^\d{4,5}$/.test(clearingVal)) {
setClearingError('Clearingnummer måste vara 4-5 siffror')
hasErrors = true
} else {
setClearingError(null)
}
if (accountVal && !/^\d{6,12}$/.test(accountVal)) {
setAccountNumberError('Kontonummer måste vara 6-12 siffror')
hasErrors = true
} else {
setAccountNumberError(null)
}
if (bankgiroVal && !validateBankgiroNumber(bankgiroVal)) {
setBankgiroError('Ogiltigt bankgironummer (7-8 siffror med kontrollsiffra)')
hasErrors = true
} else {
setBankgiroError(null)
}
if (hasErrors) return
setIsSaving(true)
// Disabled inputs are excluded from FormData by the browser,
// so only include company_name/org_number when not locked
const updates: Record<string, unknown> = {
...(formData.has('company_name') && { company_name: formData.get('company_name') as string }),
...(formData.has('org_number') && { org_number: formData.get('org_number') as string }),
address_line1: formData.get('address_line1') as string,
postal_code: formData.get('postal_code') as string,
city: formData.get('city') as string,
bank_name: formData.get('bank_name') as string,
clearing_number: formData.get('clearing_number') as string,
account_number: formData.get('account_number') as string,
bankgiro: (formData.get('bankgiro') as string) || null,
preliminary_tax_monthly: parseFloat(formData.get('preliminary_tax_monthly') as string) || null,
invoice_prefix: formData.get('invoice_prefix') as string || null,
next_invoice_number: parseInt(formData.get('next_invoice_number') as string) || 1,
invoice_default_days: parseInt(formData.get('invoice_default_days') as string) || 30,
accounting_method: formData.get('accounting_method') as string || 'accrual',
invoice_default_notes: (formData.get('invoice_default_notes') as string) || null,
}
try {
const response = await fetch('/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates),
})
const result = await response.json()
if (!response.ok) {
throw new Error(result.error || 'Kunde inte spara inställningar')
}
toast({
title: 'Sparat',
description: 'Dina inställningar har uppdaterats',
})
setSettings({ ...settings, ...updates } as typeof settings)
} catch (error) {
toast({
title: 'Kunde inte spara inställningar',
description: error instanceof Error ? error.message : 'Försök igen.',
variant: 'destructive',
})
}
setIsSaving(false)
}
async function handleLogout() {
await supabase.auth.signOut()
router.push('/login')
}
async function handleDeleteAccount() {
if (deleteConfirmText !== 'RADERA') return
setIsDeleting(true)
try {
const response = await fetch('/api/account/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ confirm: 'RADERA' }),
})
if (!response.ok) {
const result = await response.json()
throw new Error(result.error || 'Kunde inte radera kontot')
}
router.push('/login')
} catch (error) {
toast({
title: 'Kunde inte radera kontot',
description: error instanceof Error ? error.message : 'Försök igen.',
variant: 'destructive',
})
setIsDeleting(false)
}
}
if (isLoading) {
return (
<div className="space-y-6">
<div>
<div className="h-8 bg-muted rounded w-48 animate-pulse" />
<div className="h-4 bg-muted rounded w-72 mt-2 animate-pulse" />
</div>
<div className="h-10 bg-muted rounded w-96 animate-pulse" />
<Card>
<CardHeader>
<div className="h-5 bg-muted rounded w-32 animate-pulse" />
<div className="h-4 bg-muted rounded w-56 mt-1 animate-pulse" />
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<div className="h-4 bg-muted rounded w-24 animate-pulse" />
<div className="h-10 bg-muted rounded animate-pulse" />
</div>
<div className="space-y-2">
<div className="h-4 bg-muted rounded w-32 animate-pulse" />
<div className="h-10 bg-muted rounded animate-pulse" />
</div>
</div>
<div className="space-y-2">
<div className="h-4 bg-muted rounded w-16 animate-pulse" />
<div className="h-10 bg-muted rounded animate-pulse" />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<div className="h-4 bg-muted rounded w-20 animate-pulse" />
<div className="h-10 bg-muted rounded animate-pulse" />
</div>
<div className="space-y-2">
<div className="h-4 bg-muted rounded w-12 animate-pulse" />
<div className="h-10 bg-muted rounded animate-pulse" />
</div>
</div>
</CardContent>
</Card>
</div>
)
}
return (
<div className="space-y-6">
<div>
<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight">Inställningar</h1>
<p className="text-muted-foreground">
Hantera dina företags- och kontoinställningar
</p>
</div>
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-6">
{/* Mobile: dropdown selector */}
<div className="sm:hidden">
<Select value={activeTab} onValueChange={setActiveTab}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{settingsTabs.map(t => (
<SelectItem key={t.value} value={t.value}>{t.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Desktop: tab pills */}
<TabsList className="hidden sm:inline-flex flex-wrap h-auto gap-1">
{settingsTabs.map(t => (
<TabsTrigger key={t.value} value={t.value}>{t.label}</TabsTrigger>
))}
</TabsList>
{/* Company settings */}
<TabsContent value="company">
<form onSubmit={handleSaveSettings} className="space-y-6">
{/* Företagsuppgifter */}
<Card>
<CardHeader>
<CardTitle>Företagsuppgifter</CardTitle>
<CardDescription>
Namn, organisationsnummer och adress
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="company_name">Företagsnamn</Label>
<Input
id="company_name"
name="company_name"
defaultValue={settings?.company_name || ''}
disabled={settings?.onboarding_complete === true}
/>
{settings?.onboarding_complete && (
<p className="text-xs text-muted-foreground">Kan inte ändras efter att kontot skapats</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="org_number">Organisationsnummer</Label>
<Input
id="org_number"
name="org_number"
defaultValue={settings?.org_number || ''}
disabled={settings?.onboarding_complete === true}
/>
{settings?.onboarding_complete && (
<p className="text-xs text-muted-foreground">Kan inte ändras efter att kontot skapats</p>
)}
</div>
</div>
<div className="space-y-2">
<Label htmlFor="address_line1">Adress</Label>
<Input
id="address_line1"
name="address_line1"
defaultValue={settings?.address_line1 || ''}
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="postal_code">Postnummer</Label>
<Input
id="postal_code"
name="postal_code"
defaultValue={settings?.postal_code || ''}
/>
</div>
<div className="space-y-2">
<Label htmlFor="city">Ort</Label>
<Input
id="city"
name="city"
defaultValue={settings?.city || ''}
/>
</div>
</div>
</CardContent>
</Card>
{/* Bankuppgifter */}
<Card>
<CardHeader>
<CardTitle>Bankuppgifter</CardTitle>
<CardDescription>
Betalningsuppgifter som visas dina fakturor
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="space-y-2">
<Label>Bank</Label>
<BankNameCombobox
defaultValue={settings?.bank_name || ''}
enableBankingEnabled={hasBankingExtension}
/>
</div>
<div className="space-y-2">
<Label htmlFor="clearing_number">Clearing</Label>
<Input
id="clearing_number"
name="clearing_number"
inputMode="numeric"
placeholder="XXXX"
maxLength={5}
defaultValue={settings?.clearing_number || ''}
onChange={(e) => {
e.target.value = e.target.value.replace(/\D/g, '')
}}
onBlur={(e) => {
const val = e.target.value.trim()
if (!val) {
setClearingError(null)
return
}
if (!/^\d{4,5}$/.test(val)) {
setClearingError('Clearingnummer måste vara 4-5 siffror')
} else {
setClearingError(null)
}
}}
/>
{clearingError && (
<p className="text-xs text-destructive">{clearingError}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="account_number">Kontonummer</Label>
<Input
id="account_number"
name="account_number"
inputMode="numeric"
placeholder="XXXXXXX"
maxLength={12}
defaultValue={settings?.account_number || ''}
onChange={(e) => {
e.target.value = e.target.value.replace(/\D/g, '')
}}
onBlur={(e) => {
const val = e.target.value.trim()
if (!val) {
setAccountNumberError(null)
return
}
if (!/^\d{6,12}$/.test(val)) {
setAccountNumberError('Kontonummer måste vara 6-12 siffror')
} else {
setAccountNumberError(null)
}
}}
/>
{accountNumberError && (
<p className="text-xs text-destructive">{accountNumberError}</p>
)}
</div>
</div>
<div className="max-w-xs space-y-2">
<Label htmlFor="bankgiro">Bankgiro</Label>
<Input
id="bankgiro"
name="bankgiro"
placeholder="XXX-XXXX"
defaultValue={settings?.bankgiro || ''}
onBlur={(e) => {
const val = e.target.value.trim()
if (!val) {
setBankgiroError(null)
return
}
if (validateBankgiroNumber(val)) {
e.target.value = formatBankgiroNumber(val)
setBankgiroError(null)
} else {
setBankgiroError('Ogiltigt bankgironummer (7-8 siffror med kontrollsiffra)')
}
}}
/>
{bankgiroError && (
<p className="text-xs text-destructive">{bankgiroError}</p>
)}
</div>
</CardContent>
</Card>
{/* Fakturainställningar */}
<Card>
<CardHeader>
<CardTitle>Fakturainställningar</CardTitle>
<CardDescription>
Numrering, betalningsvillkor och bokföringsmetod
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 items-end">
<div className="space-y-2">
<Label htmlFor="invoice_prefix">Fakturaprefix</Label>
<Input
id="invoice_prefix"
name="invoice_prefix"
placeholder="t.ex. F-"
defaultValue={settings?.invoice_prefix || ''}
/>
</div>
<div className="space-y-2">
<Label htmlFor="next_invoice_number">Nästa fakturanummer</Label>
<Input
id="next_invoice_number"
name="next_invoice_number"
type="number"
min="1"
defaultValue={settings?.next_invoice_number || 1}
/>
</div>
<div className="space-y-2">
<Label htmlFor="invoice_default_days">Betalningsvillkor (dagar)</Label>
<Input
id="invoice_default_days"
name="invoice_default_days"
type="number"
min="0"
defaultValue={settings?.invoice_default_days || 30}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="accounting_method">Bokföringsmetod</Label>
<select
id="accounting_method"
name="accounting_method"
defaultValue={settings?.accounting_method || 'accrual'}
className="flex h-10 w-full max-w-xs rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
<option value="accrual">Faktureringsmetoden</option>
<option value="cash">Kontantmetoden</option>
</select>
<p className="text-xs text-muted-foreground">
{settings?.entity_type === 'aktiebolag'
? 'Aktiebolag med omsättning över 3 MSEK måste använda faktureringsmetoden enligt BFL. Mindre aktiebolag kan välja kontantmetoden.'
: 'Kontantmetoden är tillgänglig för enskild firma med omsättning under 3 MSEK.'}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="invoice_default_notes">Standardtext fakturor</Label>
<Textarea
id="invoice_default_notes"
name="invoice_default_notes"
rows={3}
placeholder="T.ex. betalningsvillkor, leveransinfo..."
defaultValue={settings?.invoice_default_notes || ''}
/>
<p className="text-xs text-muted-foreground">
Texten föreslås automatiskt i anteckningsfältet vid ny faktura.
</p>
</div>
</CardContent>
</Card>
{/* Skatteinställningar */}
<Card>
<CardHeader>
<CardTitle>Skatteinställningar</CardTitle>
<CardDescription>
Preliminärskatt och F-skatt
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-2">
<Label htmlFor="preliminary_tax_monthly">
Månatlig preliminärskatt (F-skatt)
</Label>
<Input
id="preliminary_tax_monthly"
name="preliminary_tax_monthly"
type="number"
defaultValue={settings?.preliminary_tax_monthly || ''}
/>
</div>
</CardContent>
</Card>
<div className="flex justify-end">
<Button type="submit" disabled={isSaving}>
{isSaving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Sparar...
</>
) : (
'Spara ändringar'
)}
</Button>
</div>
</form>
<Separator className="my-8" />
<CompanyMembersSection />
</TabsContent>
{/* Team management */}
<TabsContent value="team">
<TeamPanel />
</TabsContent>
{/* Banking settings — loaded dynamically from extension, hidden for sandbox */}
{!settings?.is_sandbox && (
<TabsContent value="banking" className="space-y-6">
{bankConnectionError && (
<div className="flex items-start gap-3 rounded-lg border border-destructive/20 bg-destructive/10 p-4">
<AlertTriangle className="mt-0.5 h-5 w-5 shrink-0 text-destructive" />
<div className="flex-1">
<p className="text-sm font-medium text-destructive">{bankConnectionError}</p>
<p className="mt-1 text-sm text-muted-foreground">
Du kan också <Link href="/import?mode=bank" className="underline hover:text-foreground">importera transaktioner via bankfil</Link> istället.
</p>
</div>
<button
onClick={() => setBankConnectionError(null)}
className="shrink-0 rounded-md p-1 text-muted-foreground hover:text-foreground"
aria-label="Stäng"
>
<span className="text-lg leading-none">&times;</span>
</button>
</div>
)}
{hasBankingExtension && BankingPanel ? (
<BankingPanel />
) : (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<CreditCard className="h-10 w-10 text-muted-foreground/40 mb-4" />
<p className="font-medium mb-1">Bankintegration (PSD2) är inte aktiverad</p>
<p className="text-sm text-muted-foreground mb-4 max-w-md">
Aktivera tillägget Enable Banking för att koppla ditt bankkonto och automatiskt hämta transaktioner.
</p>
<Button variant="outline" asChild>
<Link href="/extensions">
<ExternalLink className="mr-2 h-4 w-4" />
till Tillägg
</Link>
</Button>
</CardContent>
</Card>
)}
</TabsContent>
)}
{/* Counterparty templates */}
<TabsContent value="templates">
<CounterpartyTemplatesPanel />
</TabsContent>
{/* API keys */}
{hasMcpExtension && (
<TabsContent value="api">
<ApiKeysPanel />
</TabsContent>
)}
{/* Account — security, appearance, calendar, logout, delete */}
<TabsContent value="account" className="space-y-6">
{/* Appearance */}
<Card>
<CardHeader>
<CardTitle>Utseende</CardTitle>
</CardHeader>
<CardContent>
{mounted && (
<div className="flex gap-3">
{([
{ value: 'light', label: 'Ljust', icon: Sun },
{ value: 'dark', label: 'Mörkt', icon: Moon },
{ value: 'system', label: 'System', icon: Monitor },
] as const).map(({ value, label, icon: Icon }) => (
<button
key={value}
type="button"
onClick={() => setTheme(value)}
className={`flex items-center gap-2 rounded-lg border-2 px-4 py-2.5 text-sm font-medium transition-colors ${
theme === value
? 'border-primary bg-primary/5'
: 'border-border hover:border-primary/40'
}`}
>
<Icon className="h-4 w-4 text-muted-foreground" />
{label}
</button>
))}
</div>
)}
</CardContent>
</Card>
{/* Security */}
<SecuritySettings />
{/* Calendar feed */}
{hasCalendarExtension && <CalendarFeedSettings />}
{/* Logout & delete */}
<Card>
<CardHeader>
<CardTitle>Kontoinställningar</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between p-4 border rounded-lg">
<div>
<p className="font-medium">Logga ut</p>
<p className="text-sm text-muted-foreground">
Logga ut från ditt konto
</p>
</div>
<Button variant="outline" onClick={handleLogout}>
<LogOut className="mr-2 h-4 w-4" />
Logga ut
</Button>
</div>
</CardContent>
</Card>
{!settings?.is_sandbox && <Card className="border-destructive/50">
<CardHeader>
<CardTitle className="text-destructive">Radera konto</CardTitle>
<CardDescription>
Permanent borttagning av ditt konto och all data
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4">
<div className="flex gap-3">
<AlertTriangle className="h-5 w-5 text-destructive shrink-0 mt-0.5" />
<div className="space-y-2 text-sm">
<p className="font-medium text-destructive">Varning: Denna åtgärd kan inte ångras</p>
<p className="text-muted-foreground">
All din data raderas permanent bokföring, fakturor, verifikationer, dokument och inställningar.
</p>
<p className="text-muted-foreground">
Enligt bokföringslagen (BFL 7 kap. 2§) ska räkenskapsinformation bevaras i 7 år. Du ansvarar själv för att exportera och arkivera din bokföringsdata innan du raderar kontot.
</p>
<p className="text-muted-foreground">
Har du frågor?{' '}
<SupportLink variant="inline" subject="Fråga om kontoradering" />
</p>
</div>
</div>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<Button variant="outline" className="w-full sm:w-auto min-h-11" asChild>
<Link href="/reports?type=sie">
<ExternalLink className="mr-2 h-4 w-4" />
Exportera bokföringsdata (SIE)
</Link>
</Button>
<Button
variant="destructive"
className="w-full sm:w-auto min-h-11"
onClick={() => setShowDeleteDialog(true)}
>
Radera mitt konto
</Button>
</div>
</CardContent>
</Card>}
</TabsContent>
</Tabs>
{/* Delete account confirmation dialog */}
<Dialog open={showDeleteDialog} onOpenChange={(open) => {
setShowDeleteDialog(open)
if (!open) setDeleteConfirmText('')
}}>
<DialogContent>
<DialogHeader>
<DialogTitle>Radera konto permanent</DialogTitle>
<DialogDescription>
All din data raderas permanent. Skriv <strong>RADERA</strong> nedan för att bekräfta.
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="delete-confirm">Bekräfta genom att skriva RADERA</Label>
<Input
id="delete-confirm"
value={deleteConfirmText}
onChange={(e) => setDeleteConfirmText(e.target.value)}
placeholder="RADERA"
autoComplete="off"
/>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setShowDeleteDialog(false)
setDeleteConfirmText('')
}}
disabled={isDeleting}
>
Avbryt
</Button>
<Button
variant="destructive"
onClick={handleDeleteAccount}
disabled={deleteConfirmText !== 'RADERA' || isDeleting}
>
{isDeleting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Raderar...
</>
) : (
'Radera permanent'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
redirect('/settings/company')
}
+27
View File
@@ -0,0 +1,27 @@
'use client'
import { TaxSettingsForm } from '@/components/settings/TaxSettingsForm'
import { SettingsFormWrapper } from '@/components/settings/SettingsFormWrapper'
import { SettingsLoadingSkeleton } from '@/components/settings/SettingsLoadingSkeleton'
import { useSettings } from '@/components/settings/useSettings'
import type { CompanySettings } from '@/types'
export default function TaxSettingsPage() {
const { settings, isLoading, updateSettings } = useSettings()
if (isLoading || !settings) return <SettingsLoadingSkeleton />
function handleSave(formData: FormData) {
const updates: Record<string, unknown> = {
preliminary_tax_monthly: parseFloat(formData.get('preliminary_tax_monthly') as string) || null,
}
updateSettings(updates as Partial<CompanySettings>)
return updates
}
return (
<SettingsFormWrapper onSave={handleSave} className="space-y-8">
<TaxSettingsForm settings={settings} />
</SettingsFormWrapper>
)
}
+7
View File
@@ -0,0 +1,7 @@
'use client'
import { TeamPanel } from '@/components/settings/TeamPanel'
export default function TeamSettingsPage() {
return <TeamPanel />
}
@@ -0,0 +1,7 @@
'use client'
import { CounterpartyTemplatesPanel } from '@/components/settings/CounterpartyTemplatesPanel'
export default function TemplatesSettingsPage() {
return <CounterpartyTemplatesPanel />
}
+1 -2
View File
@@ -31,7 +31,6 @@ import { useCompany } from '@/contexts/CompanyContext'
import { formatCurrency, formatDate } from '@/lib/utils'
import type { TransactionCategory, CreateTransactionInput, Invoice, Customer, VatTreatment, InvoiceInboxItem, EntityType, LinePatternEntry } from '@/types'
import type { SuggestedCategory, SuggestedTemplate } from '@/lib/transactions/category-suggestions'
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
interface QuickReviewState {
transaction: TransactionWithInvoice
@@ -814,7 +813,7 @@ export default function TransactionsPage() {
onMarkPrivate={handleMarkPrivate}
onOpenMatchDialog={openMatchDialog}
onOpenCategoryDialog={openCategoryDialog}
onOpenDescribe={ENABLED_EXTENSION_IDS.has('ai-categorization') ? openDescribeDialog : undefined}
onOpenDescribe={openDescribeDialog}
onOpenQuickReview={handleOpenQuickReview}
onOpenTemplateReview={handleOpenTemplateReview}
onToggleSelect={toggleBatchSelect}
-17
View File
@@ -108,23 +108,6 @@ export default function PrivacyPolicyPage() {
<td className="py-2 pr-4">Globalt CDN (EU-regioner tillgängliga)</td>
<td className="py-2">EU Data Residency</td>
</tr>
<tr className="border-b">
<td className="py-2 pr-4 font-medium">Anthropic</td>
<td className="py-2 pr-4">
Kvitto-OCR (receipt-ocr), transaktionskategorisering (ai-categorization),
AI-chattassistent (ai-chat)
</td>
<td className="py-2 pr-4">USA</td>
<td className="py-2">SCCs (standardavtalsklausuler)</td>
</tr>
<tr className="border-b">
<td className="py-2 pr-4 font-medium">OpenAI</td>
<td className="py-2 pr-4">
Embedding-generering för likhetssökning (transaktionsmallar, kunskapsbas)
</td>
<td className="py-2 pr-4">USA</td>
<td className="py-2">SCCs (standardavtalsklausuler)</td>
</tr>
<tr className="border-b">
<td className="py-2 pr-4 font-medium">Enable Banking</td>
<td className="py-2 pr-4">PSD2-bankkontouppkoppling</td>
@@ -1,36 +0,0 @@
import { NextResponse } from 'next/server'
import { extensionRegistry } from '@/lib/extensions/registry'
import { ensureInitialized } from '@/lib/init'
import { verifyCronSecret } from '@/lib/auth/cron'
ensureInitialized()
export async function POST(request: Request) {
const authError = verifyCronSecret(request)
if (authError) return authError
const aiExt = extensionRegistry.get('ai-categorization')
if (!aiExt?.services?.seedAllTemplateEmbeddings || !aiExt?.services?.getSchemaVersion) {
return NextResponse.json(
{ error: 'ai-categorization extension not loaded' },
{ status: 503 }
)
}
try {
const { seeded, errors } = await aiExt.services.seedAllTemplateEmbeddings()
const schemaVersion = await aiExt.services.getSchemaVersion()
return NextResponse.json({
success: errors.length === 0,
seeded,
errors,
schema_version: schemaVersion,
})
} catch (error) {
return NextResponse.json(
{ error: `Seeding failed: ${error instanceof Error ? error.message : 'Unknown error'}` },
{ status: 500 }
)
}
}
-141
View File
@@ -1,141 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createMockRequest, parseJsonResponse } from '@/tests/helpers'
vi.mock('@/lib/supabase/server', () => ({
createClient: vi.fn(),
}))
vi.mock('@/lib/extensions/ai-consent', () => ({
AI_EXTENSIONS: ['receipt-ocr', 'ai-categorization', 'ai-chat'],
hasAiConsent: vi.fn(),
grantAiConsent: vi.fn(),
revokeAiConsent: vi.fn(),
isAiExtension: vi.fn((id: string) =>
['receipt-ocr', 'ai-categorization', 'ai-chat'].includes(id)
),
}))
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
import { createClient } from '@/lib/supabase/server'
import { hasAiConsent, grantAiConsent, revokeAiConsent } from '@/lib/extensions/ai-consent'
import { GET, POST, DELETE } from '../route'
const mockCreateClient = vi.mocked(createClient)
const mockHasAiConsent = vi.mocked(hasAiConsent)
const mockGrantAiConsent = vi.mocked(grantAiConsent)
const mockRevokeAiConsent = vi.mocked(revokeAiConsent)
function mockAuth(userId: string | null) {
mockCreateClient.mockResolvedValue({
auth: {
getUser: vi.fn().mockResolvedValue({
data: { user: userId ? { id: userId } : null },
}),
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any)
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('GET /api/ai-consent', () => {
it('returns 401 when not authenticated', async () => {
mockAuth(null)
const { status } = await parseJsonResponse(await GET())
expect(status).toBe(401)
})
it('returns consent status for all AI extensions', async () => {
mockAuth('user-1')
mockHasAiConsent
.mockResolvedValueOnce(true)
.mockResolvedValueOnce(false)
.mockResolvedValueOnce(true)
const { status, body } = await parseJsonResponse<{ data: Record<string, boolean> }>(
await GET()
)
expect(status).toBe(200)
expect(body.data).toEqual({
'receipt-ocr': true,
'ai-categorization': false,
'ai-chat': true,
})
})
})
describe('POST /api/ai-consent', () => {
it('returns 401 when not authenticated', async () => {
mockAuth(null)
const req = createMockRequest('/api/ai-consent', {
method: 'POST',
body: { extension_id: 'receipt-ocr' },
})
const { status } = await parseJsonResponse(await POST(req))
expect(status).toBe(401)
})
it('grants consent for valid AI extension', async () => {
mockAuth('user-1')
mockGrantAiConsent.mockResolvedValue(undefined)
const req = createMockRequest('/api/ai-consent', {
method: 'POST',
body: { extension_id: 'receipt-ocr' },
})
const { status, body } = await parseJsonResponse<{ data: { consented: boolean } }>(
await POST(req)
)
expect(status).toBe(200)
expect(body.data.consented).toBe(true)
expect(mockGrantAiConsent).toHaveBeenCalledWith(expect.anything(), 'user-1', 'company-1', 'receipt-ocr')
})
it('returns 400 for non-AI extension', async () => {
mockAuth('user-1')
const req = createMockRequest('/api/ai-consent', {
method: 'POST',
body: { extension_id: 'enable-banking' },
})
const { status } = await parseJsonResponse(await POST(req))
expect(status).toBe(400)
})
})
describe('DELETE /api/ai-consent', () => {
it('returns 401 when not authenticated', async () => {
mockAuth(null)
const req = createMockRequest('/api/ai-consent', {
method: 'DELETE',
body: { extension_id: 'ai-chat' },
})
const { status } = await parseJsonResponse(await DELETE(req))
expect(status).toBe(401)
})
it('revokes consent for valid AI extension', async () => {
mockAuth('user-1')
mockRevokeAiConsent.mockResolvedValue(undefined)
const req = createMockRequest('/api/ai-consent', {
method: 'DELETE',
body: { extension_id: 'ai-chat' },
})
const { status, body } = await parseJsonResponse<{ data: { consented: boolean } }>(
await DELETE(req)
)
expect(status).toBe(200)
expect(body.data.consented).toBe(false)
expect(mockRevokeAiConsent).toHaveBeenCalledWith(expect.anything(), 'user-1', 'company-1', 'ai-chat')
})
})
-76
View File
@@ -1,76 +0,0 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import {
AI_EXTENSIONS,
hasAiConsent,
grantAiConsent,
revokeAiConsent,
isAiExtension,
} from '@/lib/extensions/ai-consent'
import { requireCompanyId } from '@/lib/company/context'
export async function GET() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const statuses: Record<string, boolean> = {}
for (const ext of AI_EXTENSIONS) {
statuses[ext] = await hasAiConsent(supabase, companyId, ext)
}
return NextResponse.json({ data: statuses })
}
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 companyId = await requireCompanyId(supabase, user.id)
const body = await request.json()
const { extension_id } = body
if (!extension_id || !isAiExtension(extension_id)) {
return NextResponse.json(
{ error: 'Invalid or non-AI extension_id' },
{ status: 400 }
)
}
await grantAiConsent(supabase, user.id, companyId, extension_id)
return NextResponse.json({ data: { consented: true } })
}
export async function DELETE(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const body = await request.json()
const { extension_id } = body
if (!extension_id || !isAiExtension(extension_id)) {
return NextResponse.json(
{ error: 'Invalid or non-AI extension_id' },
{ status: 400 }
)
}
await revokeAiConsent(supabase, user.id, companyId, extension_id)
return NextResponse.json({ data: { consented: false } })
}
+36 -1
View File
@@ -48,7 +48,42 @@ export async function POST(request: Request) {
return NextResponse.json({ error: durationError }, { status: 400 })
}
// Check for overlapping periods
// Enforce continuity: new period must chain from the latest existing period (BFL 3:1)
const { data: latest } = await supabase
.from('fiscal_periods')
.select('period_end, is_closed')
.eq('company_id', companyId)
.order('period_end', { ascending: false })
.limit(1)
.maybeSingle()
if (latest) {
const prev = new Date(latest.period_end + 'T00:00:00')
prev.setDate(prev.getDate() + 1)
const expectedStart = prev.toISOString().split('T')[0]
if (body.period_start !== expectedStart) {
return NextResponse.json(
{ error: `Period must start on ${expectedStart} (day after latest period ends)` },
{ status: 400 }
)
}
}
// Enforce: max one unclosed period (no skipping ahead)
const { count: openCount } = await supabase
.from('fiscal_periods')
.select('id', { count: 'exact', head: true })
.eq('company_id', companyId)
.eq('is_closed', false)
if (openCount && openCount > 0) {
return NextResponse.json(
{ error: 'Cannot create a new period while an unclosed period exists' },
{ status: 409 }
)
}
// Defense-in-depth: check for overlapping periods
const { data: overlapping } = await supabase
.from('fiscal_periods')
.select('id, name')
@@ -0,0 +1,241 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
// Mock supabase
const mockFrom = vi.fn()
const mockRpc = vi.fn()
const mockAuth = vi.fn()
vi.mock('@/lib/supabase/server', () => ({
createClient: vi.fn().mockResolvedValue({
from: (...args: unknown[]) => mockFrom(...args),
rpc: (...args: unknown[]) => mockRpc(...args),
auth: { getUser: () => mockAuth() },
}),
}))
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
import { GET, POST } from '../route'
function mockChain(result: { data?: unknown; error?: unknown; count?: number | null }) {
const chain: Record<string, unknown> = {}
for (const m of ['select', 'eq', 'neq', 'in', 'insert', 'update', 'upsert', 'order', 'limit', 'single', 'maybeSingle']) {
chain[m] = vi.fn().mockReturnValue(chain)
}
chain.single = vi.fn().mockResolvedValue(result)
chain.maybeSingle = vi.fn().mockResolvedValue(result)
// Make the chain thenable so await resolves to result
chain.then = (resolve: (v: unknown) => void) => resolve(result)
return chain
}
function makeRequest(url: string, options?: RequestInit) {
return new Request(url, options)
}
async function parseJson(response: Response) {
return response.json()
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('GET /api/bookkeeping/voucher-gaps', () => {
it('returns 401 when not authenticated', async () => {
mockAuth.mockResolvedValue({ data: { user: null } })
const request = makeRequest('http://localhost/api/bookkeeping/voucher-gaps?fiscal_period_id=fp-1')
const response = await GET(request)
const body = await parseJson(response)
expect(response.status).toBe(401)
expect(body.error).toBe('Unauthorized')
})
it('returns 400 when fiscal_period_id is missing', async () => {
mockAuth.mockResolvedValue({ data: { user: { id: 'user-1' } } })
const request = makeRequest('http://localhost/api/bookkeeping/voucher-gaps')
const response = await GET(request)
expect(response.status).toBe(400)
})
it('returns empty result when no voucher sequences exist', async () => {
mockAuth.mockResolvedValue({ data: { user: { id: 'user-1' } } })
mockFrom.mockReturnValue(mockChain({ data: [], error: null }))
const request = makeRequest('http://localhost/api/bookkeeping/voucher-gaps?fiscal_period_id=f47ac10b-58cc-4372-a567-0e02b2c3d479')
const response = await GET(request)
const body = await parseJson(response)
expect(response.status).toBe(200)
expect(body.data.gaps).toEqual([])
expect(body.data.totalGaps).toBe(0)
expect(body.data.unexplainedGaps).toBe(0)
})
it('returns gaps with explanations', async () => {
mockAuth.mockResolvedValue({ data: { user: { id: 'user-1' } } })
let fromCallCount = 0
mockFrom.mockImplementation(() => {
fromCallCount++
if (fromCallCount === 1) {
// voucher_sequences query
return mockChain({ data: [{ voucher_series: 'A' }], error: null })
}
if (fromCallCount === 2) {
// gap_explanations query
return mockChain({
data: [{ id: 'exp-1', voucher_series: 'A', gap_start: 3, gap_end: 3, explanation: 'Transient error', user_id: 'user-1', created_at: '2026-01-01' }],
error: null,
})
}
return mockChain({ data: null, error: null })
})
mockRpc.mockResolvedValue({
data: [{ gap_start: 3, gap_end: 3 }],
error: null,
})
const request = makeRequest('http://localhost/api/bookkeeping/voucher-gaps?fiscal_period_id=f47ac10b-58cc-4372-a567-0e02b2c3d479')
const response = await GET(request)
const body = await parseJson(response)
expect(response.status).toBe(200)
expect(body.data.totalGaps).toBe(1)
expect(body.data.unexplainedGaps).toBe(0)
expect(body.data.gaps[0].explanation).toBeTruthy()
expect(body.data.gaps[0].explanation.explanation).toBe('Transient error')
})
it('returns unexplained gaps', async () => {
mockAuth.mockResolvedValue({ data: { user: { id: 'user-1' } } })
let fromCallCount = 0
mockFrom.mockImplementation(() => {
fromCallCount++
if (fromCallCount === 1) {
return mockChain({ data: [{ voucher_series: 'A' }], error: null })
}
if (fromCallCount === 2) {
return mockChain({ data: [], error: null })
}
return mockChain({ data: null, error: null })
})
mockRpc.mockResolvedValue({
data: [{ gap_start: 5, gap_end: 7 }],
error: null,
})
const request = makeRequest('http://localhost/api/bookkeeping/voucher-gaps?fiscal_period_id=f47ac10b-58cc-4372-a567-0e02b2c3d479')
const response = await GET(request)
const body = await parseJson(response)
expect(response.status).toBe(200)
expect(body.data.totalGaps).toBe(1)
expect(body.data.unexplainedGaps).toBe(1)
expect(body.data.gaps[0].explanation).toBeNull()
})
})
describe('POST /api/bookkeeping/voucher-gaps', () => {
it('returns 401 when not authenticated', async () => {
mockAuth.mockResolvedValue({ data: { user: null } })
const request = makeRequest('http://localhost/api/bookkeeping/voucher-gaps', {
method: 'POST',
body: JSON.stringify({
fiscal_period_id: 'f47ac10b-58cc-4372-a567-0e02b2c3d479',
gap_start: 5,
gap_end: 7,
explanation: 'Transient DB error during commit',
}),
headers: { 'Content-Type': 'application/json' },
})
const response = await POST(request)
expect(response.status).toBe(401)
})
it('returns 400 when explanation is empty', async () => {
mockAuth.mockResolvedValue({ data: { user: { id: 'user-1' } } })
const request = makeRequest('http://localhost/api/bookkeeping/voucher-gaps', {
method: 'POST',
body: JSON.stringify({
fiscal_period_id: 'f47ac10b-58cc-4372-a567-0e02b2c3d479',
gap_start: 5,
gap_end: 7,
explanation: '',
}),
headers: { 'Content-Type': 'application/json' },
})
const response = await POST(request)
expect(response.status).toBe(400)
})
it('saves gap explanation successfully', async () => {
mockAuth.mockResolvedValue({ data: { user: { id: 'user-1' } } })
const savedRow = {
id: 'exp-1',
company_id: 'company-1',
user_id: 'user-1',
fiscal_period_id: 'f47ac10b-58cc-4372-a567-0e02b2c3d479',
voucher_series: 'A',
gap_start: 5,
gap_end: 7,
explanation: 'Failed commit during bank sync',
created_at: '2026-04-01',
updated_at: '2026-04-01',
}
mockFrom.mockReturnValue(mockChain({ data: savedRow, error: null }))
const request = makeRequest('http://localhost/api/bookkeeping/voucher-gaps', {
method: 'POST',
body: JSON.stringify({
fiscal_period_id: 'f47ac10b-58cc-4372-a567-0e02b2c3d479',
gap_start: 5,
gap_end: 7,
explanation: 'Failed commit during bank sync',
}),
headers: { 'Content-Type': 'application/json' },
})
const response = await POST(request)
const body = await parseJson(response)
expect(response.status).toBe(200)
expect(body.data.explanation).toBe('Failed commit during bank sync')
})
it('returns 403 when user lacks permission', async () => {
mockAuth.mockResolvedValue({ data: { user: { id: 'user-1' } } })
mockFrom.mockReturnValue(mockChain({ data: null, error: { code: '42501', message: 'permission denied' } }))
const request = makeRequest('http://localhost/api/bookkeeping/voucher-gaps', {
method: 'POST',
body: JSON.stringify({
fiscal_period_id: 'f47ac10b-58cc-4372-a567-0e02b2c3d479',
gap_start: 5,
gap_end: 7,
explanation: 'Test explanation',
}),
headers: { 'Content-Type': 'application/json' },
})
const response = await POST(request)
const body = await parseJson(response)
expect(response.status).toBe(403)
expect(body.error).toContain('owners and admins')
})
})
+149
View File
@@ -0,0 +1,149 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { validateBody, validateQuery } from '@/lib/api/validate'
import { VoucherGapQuerySchema, SaveGapExplanationSchema } from '@/lib/api/schemas'
import { requireCompanyId } from '@/lib/company/context'
export async function GET(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const validation = validateQuery(request, VoucherGapQuerySchema)
if (!validation.success) return validation.response
const { fiscal_period_id, voucher_series } = validation.data
// Get all series used in this period (or filter to specific series)
let seriesQuery = supabase
.from('voucher_sequences')
.select('voucher_series')
.eq('company_id', companyId)
.eq('fiscal_period_id', fiscal_period_id)
if (voucher_series) {
seriesQuery = seriesQuery.eq('voucher_series', voucher_series)
}
const { data: seriesRows } = await seriesQuery
if (!seriesRows || seriesRows.length === 0) {
return NextResponse.json({
data: { gaps: [], totalGaps: 0, unexplainedGaps: 0 },
})
}
// Detect gaps per series
const allGaps: Array<{
series: string
gap_start: number
gap_end: number
explanation: { id: string; explanation: string; user_id: string; created_at: string } | null
}> = []
for (const row of seriesRows) {
const { data: gaps, error: gapsError } = await supabase.rpc('detect_voucher_gaps', {
p_company_id: companyId,
p_fiscal_period_id: fiscal_period_id,
p_series: row.voucher_series,
})
if (!gapsError && gaps && gaps.length > 0) {
for (const gap of gaps as Array<{ gap_start: number; gap_end: number }>) {
allGaps.push({
series: row.voucher_series,
gap_start: gap.gap_start,
gap_end: gap.gap_end,
explanation: null,
})
}
}
}
// Fetch existing explanations and match them
if (allGaps.length > 0) {
const { data: explanations } = await supabase
.from('voucher_gap_explanations')
.select('id, voucher_series, gap_start, gap_end, explanation, user_id, created_at')
.eq('company_id', companyId)
.eq('fiscal_period_id', fiscal_period_id)
if (explanations) {
const explanationMap = new Map(
explanations.map((e) => [`${e.voucher_series}:${e.gap_start}:${e.gap_end}`, e])
)
for (const gap of allGaps) {
const key = `${gap.series}:${gap.gap_start}:${gap.gap_end}`
const match = explanationMap.get(key)
if (match) {
gap.explanation = {
id: match.id,
explanation: match.explanation,
user_id: match.user_id,
created_at: match.created_at,
}
}
}
}
}
const unexplained = allGaps.filter((g) => !g.explanation).length
return NextResponse.json({
data: {
gaps: allGaps,
totalGaps: allGaps.length,
unexplainedGaps: unexplained,
},
})
}
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 companyId = await requireCompanyId(supabase, user.id)
const validation = await validateBody(request, SaveGapExplanationSchema)
if (!validation.success) return validation.response
const { fiscal_period_id, voucher_series, gap_start, gap_end, explanation } = validation.data
// Upsert explanation (RLS enforces owner/admin role)
const { data, error } = await supabase
.from('voucher_gap_explanations')
.upsert(
{
company_id: companyId,
user_id: user.id,
fiscal_period_id,
voucher_series,
gap_start,
gap_end,
explanation,
},
{ onConflict: 'company_id,fiscal_period_id,voucher_series,gap_start,gap_end' }
)
.select()
.single()
if (error) {
if (error.code === '42501') {
return NextResponse.json(
{ error: 'Only company owners and admins can document gap explanations' },
{ status: 403 }
)
}
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data })
}
@@ -291,7 +291,7 @@ async function sendConsentExpiryNotification(
const emailData = {
bankName: connection.bank_name as string,
daysUntilExpiry: daysLeft,
renewalUrl: `${baseUrl}/settings?tab=banking`,
renewalUrl: `${baseUrl}/settings/banking`,
companyName: companySettings?.company_name || 'gnubok',
isExpired,
}
+1 -13
View File
@@ -3,7 +3,6 @@ import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { extensionRegistry } from '@/lib/extensions/registry'
import { createExtensionContext } from '@/lib/extensions/context-factory'
import { hasAiConsent, isAiExtension } from '@/lib/extensions/ai-consent'
import { requireCompanyId } from '@/lib/company/context'
import type { ApiRouteDefinition } from '@/lib/extensions/types'
@@ -43,7 +42,7 @@ function matchPath(
* Catch-all route for extension-declared API routes.
*
* URL scheme: /api/extensions/ext/{extensionId}/{...routePath}
* Example: /api/extensions/ext/receipt-ocr/abc123/confirm → POST /:id/confirm
* Example: /api/extensions/ext/mcp-server/mcp → POST /mcp
*
* - Looks up the extension in the registry
* - Checks the extension toggle (disabled → 403)
@@ -120,17 +119,6 @@ async function handleRequest(
const companyId = await requireCompanyId(supabase, user.id)
// AI consent check
if (isAiExtension(extensionId)) {
const consented = await hasAiConsent(supabase, companyId, extensionId)
if (!consented) {
return NextResponse.json(
{ error: 'AI consent required', code: 'AI_CONSENT_REQUIRED' },
{ status: 403 }
)
}
}
// If path params were extracted, create a new Request with them as search params
let handlerRequest = request
if (Object.keys(extractedParams).length > 0) {
@@ -1,158 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createQueuedMockSupabase, parseJsonResponse } from '@/tests/helpers'
// Mock server-only
vi.mock('server-only', () => ({}))
// Hoisted mocks to avoid reference-before-initialization
const { mockVerify, mockServiceClientFn } = vi.hoisted(() => {
return {
mockVerify: vi.fn(),
mockServiceClientFn: vi.fn(),
}
})
// Mock svix
vi.mock('svix', () => ({
Webhook: class MockWebhook {
verify = mockVerify
},
}))
// Mock Supabase SSR
vi.mock('@supabase/ssr', () => ({
createServerClient: (...args: unknown[]) => mockServiceClientFn(...args),
}))
// Mock email handler
vi.mock('@/extensions/general/invoice-inbox/lib/email-handler', () => ({
parseInboundPayload: vi.fn(),
extractAttachments: vi.fn(),
resolveUserFromEmail: vi.fn(),
}))
// Mock unified document analyzer
vi.mock('@/lib/ai/document-analyzer', () => ({
analyzeDocument: vi.fn(),
}))
// Mock supplier matcher
vi.mock('@/extensions/general/invoice-inbox/lib/supplier-matcher', () => ({
matchSupplier: vi.fn(),
}))
// Mock receipt pipeline
vi.mock('@/extensions/general/receipt-ocr/lib/receipt-pipeline', () => ({
processReceiptFromDocument: vi.fn(),
}))
import { POST } from '../route'
import { parseInboundPayload, extractAttachments, resolveUserFromEmail } from '@/extensions/general/invoice-inbox/lib/email-handler'
const mockParseInboundPayload = vi.mocked(parseInboundPayload)
const mockExtractAttachments = vi.mocked(extractAttachments)
const mockResolveUserFromEmail = vi.mocked(resolveUserFromEmail)
describe('Invoice Inbox Webhook Route', () => {
beforeEach(() => {
vi.clearAllMocks()
process.env.RESEND_WEBHOOK_SECRET = 'test-secret'
process.env.NEXT_PUBLIC_SUPABASE_URL = 'http://localhost:54321'
process.env.SUPABASE_SERVICE_ROLE_KEY = 'test-key'
})
function makeWebhookRequest(body: unknown = {}) {
const bodyStr = JSON.stringify(body)
return new Request('http://localhost:3000/api/extensions/invoice-inbox/webhook', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'svix-id': 'msg_test123',
'svix-timestamp': String(Math.floor(Date.now() / 1000)),
'svix-signature': 'v1,test-signature',
},
body: bodyStr,
})
}
it('returns 400 when webhook headers are missing', async () => {
const request = new Request('http://localhost:3000/api/extensions/invoice-inbox/webhook', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{}',
})
const response = await POST(request)
const { status } = await parseJsonResponse(response)
expect(status).toBe(400)
})
it('returns 401 when signature verification fails', async () => {
mockVerify.mockImplementation(() => {
throw new Error('Invalid signature')
})
const response = await POST(makeWebhookRequest({ from: 'test@test.com', to: 'inbox@co.com' }))
const { status } = await parseJsonResponse(response)
expect(status).toBe(401)
})
it('returns 400 when payload is invalid', async () => {
mockVerify.mockReturnValue(undefined)
mockParseInboundPayload.mockReturnValue(null)
const response = await POST(makeWebhookRequest({}))
const { status } = await parseJsonResponse(response)
expect(status).toBe(400)
})
it('returns 404 when user not found for email', async () => {
const { supabase } = createQueuedMockSupabase()
mockServiceClientFn.mockReturnValue(supabase)
mockVerify.mockReturnValue(undefined)
mockParseInboundPayload.mockReturnValue({
from: 'supplier@test.com',
to: 'unknown@inbox.com',
subject: 'Invoice',
html: null,
text: null,
attachments: [],
created_at: '2024-06-15T10:00:00Z',
})
mockResolveUserFromEmail.mockResolvedValue(null)
const response = await POST(makeWebhookRequest({ from: 'supplier@test.com', to: 'unknown@inbox.com' }))
const { status } = await parseJsonResponse(response)
expect(status).toBe(404)
})
it('returns success with 0 processed when no attachments', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
mockServiceClientFn.mockReturnValue(supabase)
mockVerify.mockReturnValue(undefined)
mockParseInboundPayload.mockReturnValue({
from: 'supplier@test.com',
to: 'inbox@myco.com',
subject: 'No attachments',
html: null,
text: null,
attachments: [],
created_at: '2024-06-15T10:00:00Z',
})
mockExtractAttachments.mockReturnValue([])
mockResolveUserFromEmail.mockResolvedValue({ userId: 'user-1', companyId: 'company-1' })
// Insert inbox item with error status
enqueueMany([
{ data: { id: 'item-1' }, error: null },
])
const response = await POST(makeWebhookRequest({ from: 'supplier@test.com', to: 'inbox@myco.com' }))
const { status, body } = await parseJsonResponse<{ data: { processed: number } }>(response)
expect(status).toBe(200)
expect(body.data.processed).toBe(0)
})
})
@@ -1,290 +0,0 @@
import { createServerClient } from '@supabase/ssr'
import { NextResponse } from 'next/server'
import { Webhook } from 'svix'
import { parseInboundPayload, extractAttachments, resolveUserFromEmail } from '@/extensions/general/invoice-inbox/lib/email-handler'
import { matchSupplier } from '@/extensions/general/invoice-inbox/lib/supplier-matcher'
import { analyzeDocument } from '@/lib/ai/document-analyzer'
import { processReceiptFromDocument } from '@/extensions/general/receipt-ocr/lib/receipt-pipeline'
import crypto from 'crypto'
function createServiceClient() {
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
{
cookies: {
getAll() { return [] },
setAll() { },
},
}
)
}
/**
* Build raw email payload for BFL 7 kap. 2§ archiving.
* Includes full email headers and body — excludes binary attachment content.
*/
function buildRawEmailPayload(body: Record<string, unknown>, payload: { from: string; to: string; subject: string; created_at: string }): Record<string, unknown> {
return {
from: payload.from,
to: payload.to,
subject: payload.subject,
created_at: payload.created_at,
text: body.text ?? null,
html: body.html ?? null,
headers: body.headers ?? null,
message_id: body.message_id ?? null,
in_reply_to: body.in_reply_to ?? null,
references: body.references ?? null,
archived_at: new Date().toISOString(),
}
}
export async function POST(request: Request) {
// Verify webhook signature
const webhookSecret = process.env.RESEND_WEBHOOK_SECRET
if (!webhookSecret) {
console.error('[document-inbox] RESEND_WEBHOOK_SECRET not configured')
return NextResponse.json({ error: 'Webhook not configured' }, { status: 500 })
}
const svixId = request.headers.get('svix-id')
const svixTimestamp = request.headers.get('svix-timestamp')
const svixSignature = request.headers.get('svix-signature')
if (!svixId || !svixTimestamp || !svixSignature) {
return NextResponse.json({ error: 'Missing webhook headers' }, { status: 400 })
}
const rawBody = await request.text()
try {
const wh = new Webhook(webhookSecret)
wh.verify(rawBody, {
'svix-id': svixId,
'svix-timestamp': svixTimestamp,
'svix-signature': svixSignature,
})
} catch {
return NextResponse.json({ error: 'Invalid signature' }, { status: 401 })
}
const body = JSON.parse(rawBody)
const payload = parseInboundPayload(body)
if (!payload) {
return NextResponse.json({ error: 'Invalid payload' }, { status: 400 })
}
const supabase = createServiceClient()
// Resolve user from recipient email
const resolved = await resolveUserFromEmail(payload.to, supabase)
if (!resolved) {
console.warn(`[document-inbox] No user found for email: ${payload.to}`)
return NextResponse.json({ error: 'User not found' }, { status: 404 })
}
const { userId, companyId } = resolved
// Build raw email payload for BFL 7:2 archiving (no binary attachment content)
const rawEmailPayload = buildRawEmailPayload(body, payload)
// Extract file attachments
const attachments = extractAttachments(payload)
if (attachments.length === 0) {
await supabase
.from('invoice_inbox_items')
.insert({
company_id: companyId,
user_id: userId,
status: 'error',
source: 'email',
email_from: payload.from,
email_subject: payload.subject,
email_received_at: payload.created_at,
error_message: 'No supported attachments found',
raw_email_payload: rawEmailPayload,
})
return NextResponse.json({ data: { processed: 0, message: 'No attachments' } })
}
const processed: string[] = []
for (const attachment of attachments) {
try {
const buffer = Buffer.from(attachment.content, 'base64')
const hash = crypto.createHash('sha256').update(buffer).digest('hex')
// Upload to storage
const storagePath = `documents/${userId}/inbox/${Date.now()}-${attachment.filename}`
const { error: uploadError } = await supabase.storage
.from('documents')
.upload(storagePath, buffer, { contentType: attachment.content_type })
if (uploadError) {
console.error('[document-inbox] Upload failed:', uploadError)
continue
}
// Create document attachment
const { data: document, error: docError } = await supabase
.from('document_attachments')
.insert({
company_id: companyId,
user_id: userId,
storage_path: storagePath,
file_name: attachment.filename,
file_size_bytes: buffer.length,
mime_type: attachment.content_type,
sha256_hash: hash,
upload_source: 'email',
})
.select()
.single()
if (docError || !document) continue
// Unified classify + extract in a single Claude call
let documentType: 'supplier_invoice' | 'receipt' | 'government_letter' | 'unknown' = 'supplier_invoice'
let unifiedResult: Awaited<ReturnType<typeof analyzeDocument>> | null = null
try {
unifiedResult = await analyzeDocument(attachment.content, attachment.content_type)
documentType = unifiedResult.classification.type
console.log(`[document-inbox] Classified as ${documentType} (confidence: ${unifiedResult.classification.confidence})`)
} catch (classifyErr) {
console.error('[document-inbox] Classification failed, defaulting to supplier_invoice:', classifyErr)
}
// Create inbox item with document type and raw email payload
const { data: inboxItem, error: itemError } = await supabase
.from('invoice_inbox_items')
.insert({
company_id: companyId,
user_id: userId,
status: 'processing',
source: 'email',
email_from: payload.from,
email_subject: payload.subject,
email_received_at: payload.created_at,
document_id: document.id,
document_type: documentType,
raw_email_payload: rawEmailPayload,
})
.select()
.single()
if (itemError || !inboxItem) continue
// Route based on document type
try {
switch (documentType) {
case 'supplier_invoice': {
// Use pre-extracted invoice data from unified call
const extraction = unifiedResult?.invoice
if (!extraction) {
throw new Error('No invoice extraction available')
}
const isReverseCharge = unifiedResult?.classification.isReverseCharge ?? false
// Store reverse charge flag in extracted data
const extractedData = {
...(extraction as unknown as Record<string, unknown>),
isReverseCharge,
}
// Supplier matching
let matchedSupplierId: string | null = null
const { data: suppliers } = await supabase
.from('suppliers')
.select('*')
.eq('company_id', companyId)
if (suppliers && suppliers.length > 0) {
const match = matchSupplier(extraction, suppliers)
if (match && match.confidence >= 0.7) {
matchedSupplierId = match.supplierId
}
}
await supabase
.from('invoice_inbox_items')
.update({
status: 'ready',
extracted_data: extractedData,
confidence: extraction.confidence,
matched_supplier_id: matchedSupplierId,
})
.eq('id', inboxItem.id)
break
}
case 'receipt': {
// Use pre-extracted receipt data from unified call
const { data: urlData } = supabase.storage.from('documents').getPublicUrl(storagePath)
const result = await processReceiptFromDocument(supabase, userId, companyId, attachment.content, attachment.content_type, {
documentId: document.id,
source: 'email',
emailFrom: payload.from,
storageUrl: urlData.publicUrl,
preExtracted: unifiedResult?.receipt ?? undefined,
})
await supabase
.from('invoice_inbox_items')
.update({
status: 'ready',
linked_receipt_id: result.receipt.id,
confidence: result.receipt.extraction_confidence,
})
.eq('id', inboxItem.id)
break
}
case 'government_letter': {
// Store with status ready for manual review
await supabase
.from('invoice_inbox_items')
.update({
status: 'ready',
extracted_data: {
sender: payload.from,
subject: payload.subject,
body: typeof body.text === 'string' ? body.text : null,
},
})
.eq('id', inboxItem.id)
break
}
case 'unknown':
default: {
// Store with status ready for manual handling
await supabase
.from('invoice_inbox_items')
.update({ status: 'ready' })
.eq('id', inboxItem.id)
break
}
}
} catch (err) {
const message = err instanceof Error ? err.message : 'Processing failed'
await supabase
.from('invoice_inbox_items')
.update({ status: 'error', error_message: message })
.eq('id', inboxItem.id)
}
processed.push(inboxItem.id)
} catch (err) {
console.error('[document-inbox] Processing attachment failed:', err)
}
}
return NextResponse.json({ data: { processed: processed.length, ids: processed } })
}
+21 -4
View File
@@ -9,7 +9,7 @@ import {
calculateFileHash,
} from '@/lib/import/sie-parser'
import { suggestMappings, getMappingStats, isSystemAccount } from '@/lib/import/account-mapper'
import { generateImportPreview, checkDuplicateImport } from '@/lib/import/sie-import'
import { generateImportPreview, checkDuplicateImport, checkDuplicatePeriodImport } from '@/lib/import/sie-import'
import { BAS_REFERENCE } from '@/lib/bookkeeping/bas-data'
import type { SIEAccountMappingRecord } from '@/lib/import/types'
@@ -55,12 +55,12 @@ export async function POST(request: Request) {
// Decode to string
const content = decodeBuffer(arrayBuffer, encoding)
// Check for duplicate import
const duplicate = await checkDuplicateImport(supabase, user.id, content)
// Check for duplicate import (by file hash)
const duplicate = await checkDuplicateImport(supabase, companyId, content)
if (duplicate) {
return NextResponse.json({
error: 'duplicate',
message: `This file has already been imported on ${duplicate.imported_at ? new Date(duplicate.imported_at).toLocaleDateString('sv-SE') : 'okänt datum'}`,
message: `Denna fil har redan importerats ${duplicate.imported_at ? new Date(duplicate.imported_at).toLocaleDateString('sv-SE') : 'okänt datum'}`,
importId: duplicate.id,
}, { status: 409 })
}
@@ -68,6 +68,23 @@ export async function POST(request: Request) {
// Parse the SIE file
const parsed = parseSIEFile(content)
// Check for existing import covering the same fiscal period
if (parsed.stats.fiscalYearStart && parsed.stats.fiscalYearEnd) {
const periodDuplicate = await checkDuplicatePeriodImport(
supabase,
companyId,
parsed.stats.fiscalYearStart,
parsed.stats.fiscalYearEnd
)
if (periodDuplicate) {
return NextResponse.json({
error: 'duplicate_period',
message: `En SIE-import för perioden ${parsed.stats.fiscalYearStart} ${parsed.stats.fiscalYearEnd} finns redan (importerad ${periodDuplicate.imported_at ? new Date(periodDuplicate.imported_at).toLocaleDateString('sv-SE') : 'okänt datum'})`,
importId: periodDuplicate.id,
}, { status: 409 })
}
}
// Validate the parsed data
const validation = validateSIEFile(parsed)
+36
View File
@@ -0,0 +1,36 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { validateBalanceContinuity } from '@/lib/reports/continuity-check'
import { requireCompanyId } from '@/lib/company/context'
/**
* GET: Validate IB/UB continuity for a fiscal period.
* Query param: period_id (required)
*/
export async function GET(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const companyId = await requireCompanyId(supabase, user.id)
const { searchParams } = new URL(request.url)
const periodId = searchParams.get('period_id')
if (!periodId) {
return NextResponse.json({ error: 'period_id is required' }, { status: 400 })
}
try {
const result = await validateBalanceContinuity(supabase, companyId, periodId)
return NextResponse.json({ data: result })
} catch (err) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Failed to validate continuity' },
{ status: 400 }
)
}
}
+96
View File
@@ -0,0 +1,96 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { requireCompanyId } from '@/lib/company/context'
const MAX_SIZE = 2 * 1024 * 1024 // 2MB
const ALLOWED_TYPES = ['image/png', 'image/jpeg', 'image/svg+xml', 'image/webp']
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 companyId = await requireCompanyId(supabase, user.id)
if (!companyId) return NextResponse.json({ error: 'No company' }, { status: 403 })
const formData = await request.formData()
const file = formData.get('file') as File | null
if (!file) {
return NextResponse.json({ error: 'Ingen fil angiven' }, { status: 400 })
}
if (!ALLOWED_TYPES.includes(file.type)) {
return NextResponse.json({ error: 'Otillåten filtyp. Tillåtna: PNG, JPG, SVG, WebP.' }, { status: 400 })
}
if (file.size > MAX_SIZE) {
return NextResponse.json({ error: 'Filen är för stor (max 2 MB).' }, { status: 400 })
}
const buffer = Buffer.from(await file.arrayBuffer())
const ext = file.name.split('.').pop() || 'png'
const storagePath = `logos/${companyId}/logo.${ext}`
// Upload (upsert to replace existing)
const { error: uploadError } = await supabase.storage
.from('documents')
.upload(storagePath, buffer, {
contentType: file.type,
upsert: true,
})
if (uploadError) {
return NextResponse.json({ error: `Uppladdning misslyckades: ${uploadError.message}` }, { status: 500 })
}
// Get public URL
const { data: urlData } = supabase.storage
.from('documents')
.getPublicUrl(storagePath)
// Update company settings
const { error: updateError } = await supabase
.from('company_settings')
.update({ logo_url: urlData.publicUrl })
.eq('company_id', companyId)
if (updateError) {
return NextResponse.json({ error: 'Kunde inte uppdatera inställningar' }, { status: 500 })
}
return NextResponse.json({ data: { logo_url: urlData.publicUrl } })
}
export async function DELETE() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const companyId = await requireCompanyId(supabase, user.id)
if (!companyId) return NextResponse.json({ error: 'No company' }, { status: 403 })
// Get current logo path
const { data: settings } = await supabase
.from('company_settings')
.select('logo_url')
.eq('company_id', companyId)
.single()
if (settings?.logo_url) {
// Extract storage path from URL
const url = new URL(settings.logo_url)
const pathMatch = url.pathname.match(/\/object\/public\/documents\/(.+)/)
if (pathMatch) {
await supabase.storage.from('documents').remove([pathMatch[1]])
}
}
// Clear logo_url
await supabase
.from('company_settings')
.update({ logo_url: null })
.eq('company_id', companyId)
return NextResponse.json({ data: { logo_url: null } })
}
@@ -14,21 +14,13 @@ vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
vi.mock('@/lib/bookkeeping/counterparty-templates', () => ({
findCounterpartyTemplate: vi.fn().mockResolvedValue(null),
buildMappingResultFromCounterpartyTemplate: vi.fn(),
formatCounterpartyName: vi.fn((name: string) => name),
}))
// Mock extension registry
const mockFindSimilarTemplates = vi.fn().mockResolvedValue([])
vi.mock('@/lib/extensions/registry', () => ({
extensionRegistry: {
get: vi.fn().mockReturnValue({
id: 'ai-categorization',
name: 'AI',
version: '1.0.0',
services: {
findSimilarTemplates: (...args: unknown[]) => mockFindSimilarTemplates(...args),
},
}),
},
// Mock booking templates
const mockFindMatchingTemplates = vi.fn().mockReturnValue([])
vi.mock('@/lib/bookkeeping/booking-templates', () => ({
findMatchingTemplates: (...args: unknown[]) => mockFindMatchingTemplates(...args),
}))
// Mock Supabase
@@ -121,7 +113,7 @@ describe('POST /api/transactions/[id]/describe', () => {
amount: -450,
})
mockFindSimilarTemplates.mockResolvedValueOnce([
mockFindMatchingTemplates.mockReturnValueOnce([
{
template: {
id: 'restaurant_dining',
@@ -131,6 +123,12 @@ describe('POST /api/transactions/[id]/describe', () => {
debit_account: '6071',
credit_account: '1930',
description_sv: 'Representation - restaurang',
vat_rate: 0.12,
vat_treatment: 'reduced_12',
deductibility: 'conditional',
deductibility_note_sv: null,
special_rules_sv: null,
risk_level: 'MEDIUM',
},
confidence: 0.82,
},
@@ -162,23 +160,16 @@ describe('POST /api/transactions/[id]/describe', () => {
expect(status).toBe(200)
expect(body.data.templates).toHaveLength(1)
expect(body.data.needs_more_detail).toBe(false)
expect(body.data.ai_suggestion).toBeNull()
expect(body.data.user_description).toBe('business lunch with client')
expect(body.data.batch_candidate_count).toBe(3)
expect(body.data.merchant_name).toBe('Restaurant XYZ')
// Verify findSimilarTemplates was called with the user description
expect(mockFindSimilarTemplates).toHaveBeenCalledWith(
expect.objectContaining({ id: 'tx-1' }),
'enskild_firma',
10,
'business lunch with client'
)
})
it('sets needs_more_detail when confidence is low', async () => {
const tx = makeTransaction({ id: 'tx-2', merchant_name: null })
mockFindSimilarTemplates.mockResolvedValueOnce([
mockFindMatchingTemplates.mockReturnValueOnce([
{
template: {
id: 'misc',
@@ -188,6 +179,12 @@ describe('POST /api/transactions/[id]/describe', () => {
debit_account: '6991',
credit_account: '1930',
description_sv: 'Okategoriserad utgift',
vat_rate: 0,
vat_treatment: null,
deductibility: 'full',
deductibility_note_sv: null,
special_rules_sv: null,
risk_level: 'LOW',
},
confidence: 0.4,
},
+7 -80
View File
@@ -3,73 +3,13 @@ import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { validateBody } from '@/lib/api/validate'
import { DescribeTransactionSchema } from '@/lib/api/schemas'
import { extensionRegistry } from '@/lib/extensions/registry'
import { findMatchingTemplates, type TemplateMatch } from '@/lib/bookkeeping/booking-templates'
import { findMatchingTemplates } from '@/lib/bookkeeping/booking-templates'
import { findCounterpartyTemplate, buildMappingResultFromCounterpartyTemplate, formatCounterpartyName } from '@/lib/bookkeeping/counterparty-templates'
import { requireCompanyId } from '@/lib/company/context'
import type { Transaction, EntityType, VatTreatment } from '@/types'
import type { Extension } from '@/lib/extensions/types'
interface DescriptionAnalysisInput {
description: string
transactionAmount: number
transactionDate: string
transactionDescription: string
merchantName: string | null
currency: string
entityType: EntityType
}
interface DescriptionAnalysisResult {
debitAccount: string
creditAccount: string
vatTreatment: VatTreatment | null
category: string
confidence: number
reasoning: string
warnings: string[]
templateId: string | null
}
import type { Transaction, EntityType } from '@/types'
ensureInitialized()
async function getTemplateMatches(
aiExt: Extension | undefined,
transaction: Transaction,
entityType: EntityType,
description: string
): Promise<TemplateMatch[]> {
if (aiExt?.services?.findSimilarTemplates) {
return aiExt.services.findSimilarTemplates(transaction, entityType, 10, description)
}
return findMatchingTemplates(transaction, entityType)
}
async function getAiAnalysis(
aiExt: Extension | undefined,
transaction: Transaction,
entityType: EntityType,
description: string
): Promise<DescriptionAnalysisResult | null> {
if (!aiExt?.services?.analyzeDescription) return null
try {
const input: DescriptionAnalysisInput = {
description,
transactionAmount: transaction.amount,
transactionDate: transaction.date,
transactionDescription: transaction.description,
merchantName: transaction.merchant_name,
currency: transaction.currency,
entityType,
}
return await aiExt.services.analyzeDescription(input)
} catch (error) {
console.error('[describe] AI analysis failed, continuing with templates only:', error)
return null
}
}
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
@@ -110,13 +50,10 @@ export async function POST(
const entityType: EntityType = (settings?.entity_type as EntityType) || 'enskild_firma'
const aiExt = extensionRegistry.get('ai-categorization')
// Run template matching, counterparty lookup, and AI analysis in parallel
const [templates, counterpartyMatch, aiSuggestion] = await Promise.all([
getTemplateMatches(aiExt, transaction as Transaction, entityType, description),
// Run template matching and counterparty lookup in parallel
const [templates, counterpartyMatch] = await Promise.all([
findMatchingTemplates(transaction as Transaction, entityType),
findCounterpartyTemplate(supabase, user.id, transaction as Transaction),
getAiAnalysis(aiExt, transaction as Transaction, entityType, description),
])
// Build counterparty suggestion if matched
@@ -147,8 +84,7 @@ export async function POST(
}
}
// AI or counterparty match rescues weak templates
const needsMoreDetail = (aiSuggestion || counterpartySuggestion)
const needsMoreDetail = counterpartySuggestion
? false
: templates.length === 0 || templates[0].confidence < 0.55
@@ -185,16 +121,7 @@ export async function POST(
risk_level: m.template.risk_level,
})),
counterparty_match: counterpartySuggestion,
ai_suggestion: aiSuggestion ? {
debit_account: aiSuggestion.debitAccount,
credit_account: aiSuggestion.creditAccount,
vat_treatment: aiSuggestion.vatTreatment,
category: aiSuggestion.category,
confidence: aiSuggestion.confidence,
reasoning: aiSuggestion.reasoning,
warnings: aiSuggestion.warnings,
template_id: aiSuggestion.templateId,
} : null,
ai_suggestion: null,
needs_more_detail: needsMoreDetail,
user_description: description,
batch_candidate_count: batchCandidateCount,
@@ -1,6 +1,6 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { getSuggestedCategories, mergeAiSuggestions, getSuggestedTemplates, type SuggestedCategory, type SuggestedTemplate } from '@/lib/transactions/category-suggestions'
import { getSuggestedCategories, getSuggestedTemplates, type SuggestedCategory, type SuggestedTemplate } from '@/lib/transactions/category-suggestions'
import { findCounterpartyTemplatesBatch, formatCounterpartyName, toCounterpartyTemplateId } from '@/lib/bookkeeping/counterparty-templates'
import { requireCompanyId } from '@/lib/company/context'
import type { Transaction, EntityType } from '@/types'
@@ -65,30 +65,6 @@ export async function POST(request: Request) {
}
}
// Fetch pre-computed AI suggestions for these transactions
const aiKeys = ids.map((id: string) => `suggestion:${id}`)
const { data: aiRecords } = await supabase
.from('extension_data')
.select('key, value')
.eq('company_id', companyId)
.eq('extension_id', 'ai-categorization')
.in('key', aiKeys)
type AiSuggestion = { category: string; basAccount: string; confidence: number; reasoning: string }
const aiSuggestionsMap: Record<string, AiSuggestion[]> = {}
if (aiRecords) {
for (const record of aiRecords) {
const txId = record.key.replace('suggestion:', '')
const value = record.value
// Handle both single object (old) and array (new) storage formats
if (Array.isArray(value)) {
aiSuggestionsMap[txId] = value as AiSuggestion[]
} else {
aiSuggestionsMap[txId] = [value as AiSuggestion]
}
}
}
// Fetch entity type for template matching
const { data: settings } = await supabase
.from('company_settings')
@@ -105,19 +81,11 @@ export async function POST(request: Request) {
const template_suggestions: Record<string, SuggestedTemplate[]> = {}
for (const tx of transactions) {
let result = getSuggestedCategories(
suggestions[tx.id] = getSuggestedCategories(
tx as Transaction,
mappingRules || [],
categoryHistory
)
// Merge pre-computed AI suggestions if available
const aiSuggestions = aiSuggestionsMap[tx.id]
if (aiSuggestions && aiSuggestions.length > 0) {
result = mergeAiSuggestions(result, aiSuggestions, tx.amount)
}
suggestions[tx.id] = result
template_suggestions[tx.id] = await getSuggestedTemplates(tx as Transaction, entityType, mappingRules || undefined)
}
-83
View File
@@ -1,83 +0,0 @@
'use client'
import { useState, useRef, useCallback, KeyboardEvent } from 'react'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import { Send, Loader2 } from 'lucide-react'
interface ChatInputProps {
onSend: (message: string) => void
disabled?: boolean
isLoading?: boolean
placeholder?: string
}
export function ChatInput({
onSend,
disabled = false,
isLoading = false,
placeholder = 'Skriv ett meddelande...',
}: ChatInputProps) {
const [message, setMessage] = useState('')
const textareaRef = useRef<HTMLTextAreaElement>(null)
const handleSend = useCallback(() => {
const trimmed = message.trim()
if (trimmed && !disabled && !isLoading) {
onSend(trimmed)
setMessage('')
// Reset textarea height
if (textareaRef.current) {
textareaRef.current.style.height = 'auto'
}
}
}, [message, disabled, isLoading, onSend])
const handleKeyDown = useCallback(
(e: KeyboardEvent<HTMLTextAreaElement>) => {
// Send on Enter (without Shift)
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSend()
}
},
[handleSend]
)
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setMessage(e.target.value)
// Auto-resize textarea
const textarea = e.target
textarea.style.height = 'auto'
textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`
}
const isDisabled = disabled || isLoading || !message.trim()
return (
<div className="flex gap-2 items-end p-4 border-t bg-background">
<Textarea
ref={textareaRef}
value={message}
onChange={handleChange}
onKeyDown={handleKeyDown}
placeholder={placeholder}
disabled={disabled || isLoading}
className="min-h-[44px] max-h-[200px] resize-none py-3"
rows={1}
/>
<Button
onClick={handleSend}
disabled={isDisabled}
size="icon"
className="flex-shrink-0 h-[44px] w-[44px]"
>
{isLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Send className="h-4 w-4" />
)}
</Button>
</div>
)
}
-157
View File
@@ -1,157 +0,0 @@
'use client'
import { useState } from 'react'
import ReactMarkdown from 'react-markdown'
import { cn } from '@/lib/utils'
import { ChevronDown, ChevronUp, FileText, User, Bot, Database, Loader2 } from 'lucide-react'
import type { ChatMessage as ChatMessageType, SourceReference } from '@/types/chat'
import { ArtifactRenderer } from './artifacts/ArtifactRenderer'
interface ChatMessageProps {
message: ChatMessageType
isStreaming?: boolean
toolsExecuting?: string[]
}
function SourcesList({ sources }: { sources: SourceReference[] }) {
const [isExpanded, setIsExpanded] = useState(false)
if (sources.length === 0) return null
return (
<div className="mt-3 pt-3 border-t border-border/40">
<button
onClick={() => setIsExpanded(!isExpanded)}
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<FileText className="h-3 w-3" />
<span>{sources.length} källa{sources.length !== 1 ? 'or' : ''}</span>
{isExpanded ? (
<ChevronUp className="h-3 w-3" />
) : (
<ChevronDown className="h-3 w-3" />
)}
</button>
{isExpanded && (
<div className="mt-2 space-y-1.5">
{sources.map((source, index) => (
<div
key={source.id || index}
className="text-xs bg-muted/50 rounded-md px-2.5 py-1.5"
>
<div className="font-medium text-foreground/80">
{source.title}
{source.section_title && (
<span className="font-normal text-muted-foreground">
{' '}
&rsaquo; {source.section_title}
</span>
)}
</div>
<div className="text-muted-foreground mt-0.5">
{source.source_file} ({(source.similarity * 100).toFixed(0)}% relevans)
</div>
</div>
))}
</div>
)}
</div>
)
}
const TOOL_LABELS: Record<string, string> = {
get_invoices: 'Hämtar fakturor',
get_supplier_invoices: 'Hämtar leverantörsfakturor',
get_account_balances: 'Hämtar kontosaldon',
get_transactions: 'Hämtar transaktioner',
get_journal_entries: 'Hämtar verifikationer',
get_income_statement: 'Genererar resultaträkning',
get_balance_sheet: 'Genererar balansräkning',
get_vat_summary: 'Beräknar momssammanställning',
get_company_overview: 'Hämtar företagsöversikt',
get_aging_report: 'Genererar åldersanalys',
}
function ToolExecutingIndicator({ tools }: { tools: string[] }) {
if (tools.length === 0) return null
return (
<div className="flex flex-col gap-1 mb-2">
{tools.map((toolName, i) => (
<div key={i} className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" />
<Database className="h-3 w-3" />
<span>{TOOL_LABELS[toolName] || toolName}...</span>
</div>
))}
</div>
)
}
export function ChatMessage({ message, isStreaming, toolsExecuting }: ChatMessageProps) {
const isUser = message.role === 'user'
return (
<div
className={cn(
'flex gap-3 px-4 py-3',
isUser ? 'flex-row-reverse' : 'flex-row'
)}
>
{/* Avatar */}
<div
className={cn(
'flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center',
isUser
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground'
)}
>
{isUser ? (
<User className="h-4 w-4" />
) : (
<Bot className="h-4 w-4" />
)}
</div>
{/* Message content */}
<div
className={cn(
'flex-1 max-w-[80%] rounded-xl px-4 py-3',
isUser
? 'bg-primary text-primary-foreground ml-auto'
: 'bg-muted/60 text-foreground'
)}
>
{/* Tool execution indicator */}
{!isUser && isStreaming && toolsExecuting && toolsExecuting.length > 0 && !message.content && (
<ToolExecutingIndicator tools={toolsExecuting} />
)}
<div className={cn(
"text-sm break-words",
!isUser && "prose prose-sm max-w-none prose-headings:text-foreground prose-headings:font-semibold prose-h1:text-base prose-h2:text-sm prose-h3:text-sm prose-p:text-foreground prose-p:my-1.5 prose-headings:my-2 prose-ul:my-1.5 prose-ol:my-1.5 prose-li:my-0.5 prose-li:text-foreground prose-strong:text-foreground prose-table:my-2 prose-table:text-xs prose-th:px-2 prose-th:py-1 prose-td:px-2 prose-td:py-1 prose-th:bg-muted/50 prose-th:border prose-td:border prose-th:border-border/50 prose-td:border-border/50 prose-th:text-foreground prose-td:text-foreground"
)}>
{isUser ? (
<p className="whitespace-pre-wrap m-0">{message.content}</p>
) : (
<ReactMarkdown>{message.content}</ReactMarkdown>
)}
{isStreaming && !isUser && (
<span className="inline-block w-1.5 h-4 bg-current animate-pulse ml-0.5" />
)}
</div>
{/* Artifact visualization */}
{!isUser && message.artifact && (
<ArtifactRenderer artifact={message.artifact} />
)}
{!isUser && message.sources && message.sources.length > 0 && (
<SourcesList sources={message.sources} />
)}
</div>
</div>
)
}
-173
View File
@@ -1,173 +0,0 @@
'use client'
import { useEffect, useRef } from 'react'
import { ChatMessage } from './ChatMessage'
import { ChatInput } from './ChatInput'
import { useChatStream } from './useChatStream'
import { useToast } from '@/components/ui/use-toast'
import { Bot, RotateCcw } from 'lucide-react'
import { Button } from '@/components/ui/button'
interface ChatPanelProps {
className?: string
}
export function ChatPanel({ className }: ChatPanelProps) {
const { toast } = useToast()
const messagesEndRef = useRef<HTMLDivElement>(null)
const {
messages,
isLoading,
isStreaming,
error: _error,
toolsExecuting,
sendMessage,
clearChat,
} = useChatStream({
onError: (error) => {
toast({
title: 'Fel',
description: error,
variant: 'destructive',
})
},
})
// Auto-scroll to bottom when new messages arrive
useEffect(() => {
if (messagesEndRef.current) {
messagesEndRef.current.scrollIntoView({ behavior: 'smooth' })
}
}, [messages])
const handleClearChat = () => {
clearChat()
toast({
title: 'Chatten rensad',
description: 'En ny konversation har startats.',
})
}
return (
<div className={`flex flex-col h-full ${className || ''}`}>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b bg-muted/30">
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center">
<Bot className="h-4 w-4 text-primary" />
</div>
<div>
<h3 className="font-medium text-sm">AI-assistent</h3>
<p className="text-xs text-muted-foreground">
Skatt, moms och bokföring
</p>
</div>
</div>
{messages.length > 0 && (
<Button
variant="ghost"
size="sm"
onClick={handleClearChat}
className="text-muted-foreground hover:text-foreground"
>
<RotateCcw className="h-4 w-4 mr-1" />
Ny chatt
</Button>
)}
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto">
{messages.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-center px-6 py-8">
<div className="w-12 h-12 rounded-full bg-primary/10 flex items-center justify-center mb-4">
<Bot className="h-6 w-6 text-primary" />
</div>
<h4 className="font-medium mb-2">Hur kan jag hjälpa dig?</h4>
<p className="text-sm text-muted-foreground max-w-xs">
Jag kan svara frågor om skatt, moms, bokföring och andra
ekonomiska frågor för företagare.
</p>
<div className="mt-6 space-y-2 w-full max-w-xs">
<SuggestionButton
onClick={() => sendMessage('Hur går det för mitt företag?')}
disabled={isLoading}
>
Hur går det för mitt företag?
</SuggestionButton>
<SuggestionButton
onClick={() => sendMessage('Visa mina senaste fakturor')}
disabled={isLoading}
>
Visa mina senaste fakturor
</SuggestionButton>
<SuggestionButton
onClick={() => sendMessage('Hur ser min resultaträkning ut?')}
disabled={isLoading}
>
Hur ser min resultaträkning ut?
</SuggestionButton>
<SuggestionButton
onClick={() => sendMessage('Vad kan jag dra av som företagare?')}
disabled={isLoading}
>
Vad kan jag dra av som företagare?
</SuggestionButton>
</div>
</div>
) : (
<div className="py-4">
{messages.map((message, index) => (
<ChatMessage
key={message.id}
message={message}
isStreaming={
isStreaming &&
index === messages.length - 1 &&
message.role === 'assistant'
}
toolsExecuting={
isStreaming &&
index === messages.length - 1 &&
message.role === 'assistant'
? toolsExecuting
: undefined
}
/>
))}
<div ref={messagesEndRef} />
</div>
)}
</div>
{/* Input */}
<ChatInput
onSend={sendMessage}
disabled={isStreaming}
isLoading={isLoading}
placeholder="Ställ en fråga om skatt, moms eller bokföring..."
/>
</div>
)
}
function SuggestionButton({
children,
onClick,
disabled,
}: {
children: React.ReactNode
onClick: () => void
disabled?: boolean
}) {
return (
<button
onClick={onClick}
disabled={disabled}
className="w-full text-left text-sm px-3 py-2 rounded-lg border border-border/60 hover:bg-muted/50 hover:border-border transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{children}
</button>
)
}
-67
View File
@@ -1,67 +0,0 @@
'use client'
import { useState, useEffect } from 'react'
import { Button } from '@/components/ui/button'
import { ChatPanel } from './ChatPanel'
import { MessageCircle, X } from 'lucide-react'
import { cn } from '@/lib/utils'
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
const chatEnabled = ENABLED_EXTENSION_IDS.has('ai-chat')
export function ChatWidget() {
const [isOpen, setIsOpen] = useState(false)
// Allow other components to open the chat via custom event
useEffect(() => {
const handler = () => setIsOpen(true)
window.addEventListener('open-ai-chat', handler)
return () => window.removeEventListener('open-ai-chat', handler)
}, [])
if (!chatEnabled) return null
return (
<>
{/* Floating chat panel */}
<div
className={cn(
'fixed bottom-36 right-4 md:bottom-20 z-[60] transition-all duration-300 ease-in-out',
isOpen
? 'opacity-100 translate-y-0 pointer-events-auto'
: 'opacity-0 translate-y-4 pointer-events-none'
)}
>
<div className="w-[380px] h-[550px] max-h-[calc(100vh-120px)] bg-background border rounded-xl shadow-xl overflow-hidden flex flex-col">
{/* Close button in corner */}
<button
onClick={() => setIsOpen(false)}
className="absolute top-2 right-2 z-10 p-1.5 rounded-full bg-muted/80 hover:bg-muted transition-colors"
aria-label="Stäng chatt"
>
<X className="h-4 w-4" />
</button>
<ChatPanel />
</div>
</div>
{/* Floating action button */}
<Button
onClick={() => setIsOpen(!isOpen)}
size="icon"
className={cn(
'fixed bottom-20 right-4 md:bottom-4 z-[60] h-14 w-14 rounded-full shadow-lg transition-all duration-300',
isOpen && 'rotate-90'
)}
aria-label={isOpen ? 'Stäng chatt' : 'Öppna chatt'}
>
{isOpen ? (
<X className="h-6 w-6" />
) : (
<MessageCircle className="h-6 w-6" />
)}
</Button>
</>
)
}
@@ -1,45 +0,0 @@
'use client'
import type { ArtifactSpec } from '@/types/chat'
import { ChatChart } from './ChatChart'
import { ChatDataTable } from './ChatDataTable'
import { ChatKpiCards } from './ChatKpiCards'
import { ChatAgingBuckets } from './ChatAgingBuckets'
interface ArtifactRendererProps {
artifact: ArtifactSpec
}
export function ArtifactRenderer({ artifact }: ArtifactRendererProps) {
switch (artifact.type) {
case 'bar_chart':
case 'line_chart':
case 'pie_chart':
case 'stacked_bar':
return (
<div className="mt-3 p-3 rounded-lg border border-border bg-card">
<ChatChart artifact={artifact} />
</div>
)
case 'table':
return (
<div className="mt-3">
<ChatDataTable artifact={artifact} />
</div>
)
case 'kpi_cards':
return (
<div className="mt-3">
<ChatKpiCards artifact={artifact} />
</div>
)
case 'aging_buckets':
return (
<div className="mt-3 p-3 rounded-lg border border-border bg-card">
<ChatAgingBuckets artifact={artifact} />
</div>
)
default:
return null
}
}
@@ -1,71 +0,0 @@
'use client'
import type { AgingBucketsArtifact } from '@/types/chat'
import { formatCurrency } from '@/lib/utils'
const BUCKET_COLORS = [
'bg-success',
'bg-warning',
'bg-warning/70',
'bg-destructive/70',
'bg-destructive',
]
interface ChatAgingBucketsProps {
artifact: AgingBucketsArtifact
}
export function ChatAgingBuckets({ artifact }: ChatAgingBucketsProps) {
const { title, buckets, total } = artifact
return (
<div className="w-full">
<div className="flex items-baseline justify-between mb-3">
<h4 className="text-sm font-semibold">{title}</h4>
<span className="text-sm font-bold tabular-nums">
{formatCurrency(total)}
</span>
</div>
{/* Stacked bar */}
{total > 0 && (
<div className="flex h-6 rounded-md overflow-hidden mb-3">
{buckets.map((bucket, i) => {
const widthPercent = (bucket.amount / total) * 100
if (widthPercent < 0.5) return null
return (
<div
key={i}
className={`${BUCKET_COLORS[i % BUCKET_COLORS.length]} transition-all`}
style={{ width: `${widthPercent}%` }}
title={`${bucket.label}: ${formatCurrency(bucket.amount)}`}
/>
)
})}
</div>
)}
{/* Legend */}
<div className="space-y-1.5">
{buckets.map((bucket, i) => (
<div key={i} className="flex items-center justify-between text-xs">
<div className="flex items-center gap-2">
<div
className={`w-3 h-3 rounded-sm ${BUCKET_COLORS[i % BUCKET_COLORS.length]}`}
/>
<span className="text-muted-foreground">{bucket.label}</span>
</div>
<div className="flex items-center gap-3">
<span className="text-muted-foreground">
{bucket.count} st
</span>
<span className="font-medium tabular-nums">
{formatCurrency(bucket.amount)}
</span>
</div>
</div>
))}
</div>
</div>
)
}
-130
View File
@@ -1,130 +0,0 @@
'use client'
import {
BarChart,
Bar,
LineChart,
Line,
PieChart,
Pie,
Cell,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from 'recharts'
import type { ChartArtifact } from '@/types/chat'
const DEFAULT_COLORS = [
'var(--color-chart-1, #3b82f6)',
'var(--color-chart-2, #10b981)',
'var(--color-chart-3, #f59e0b)',
'var(--color-chart-4, #ef4444)',
'var(--color-chart-5, #8b5cf6)',
'var(--color-chart-6, #ec4899)',
'var(--color-chart-7, #06b6d4)',
'var(--color-chart-8, #84cc16)',
]
function formatValue(value: number, unit?: string): string {
const formatted = new Intl.NumberFormat('sv-SE').format(Math.round(value))
return unit ? `${formatted} ${unit}` : formatted
}
interface ChatChartProps {
artifact: ChartArtifact
}
export function ChatChart({ artifact }: ChatChartProps) {
const { type, title, data, unit, subtitle } = artifact
const chartData = data.map((d, i) => ({
...d,
fill: d.color || DEFAULT_COLORS[i % DEFAULT_COLORS.length],
}))
return (
<div className="w-full">
<div className="mb-3">
<h4 className="text-sm font-semibold">{title}</h4>
{subtitle && <p className="text-xs text-muted-foreground">{subtitle}</p>}
</div>
<div className="h-64 w-full">
<ResponsiveContainer width="100%" height="100%">
{type === 'pie_chart' ? (
<PieChart>
<Pie
data={chartData}
dataKey="value"
nameKey="label"
cx="50%"
cy="50%"
outerRadius="80%"
label={(props) => {
const name = props.name ?? ''
const percent = typeof props.percent === 'number' ? props.percent : 0
return `${name} (${(percent * 100).toFixed(0)}%)`
}}
labelLine={false}
>
{chartData.map((entry, i) => (
<Cell key={i} fill={entry.fill} />
))}
</Pie>
<Tooltip
formatter={(value) => formatValue(Number(value ?? 0), unit)}
/>
</PieChart>
) : type === 'line_chart' ? (
<LineChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" className="opacity-30" />
<XAxis
dataKey="label"
tick={{ fontSize: 11 }}
className="text-muted-foreground"
/>
<YAxis
tick={{ fontSize: 11 }}
tickFormatter={(v) => formatValue(v, unit)}
className="text-muted-foreground"
/>
<Tooltip
formatter={(value) => formatValue(Number(value ?? 0), unit)}
/>
<Line
type="monotone"
dataKey="value"
stroke={DEFAULT_COLORS[0]}
strokeWidth={2}
dot={{ r: 3 }}
/>
</LineChart>
) : (
<BarChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" className="opacity-30" />
<XAxis
dataKey="label"
tick={{ fontSize: 11 }}
className="text-muted-foreground"
/>
<YAxis
tick={{ fontSize: 11 }}
tickFormatter={(v) => formatValue(v, unit)}
className="text-muted-foreground"
/>
<Tooltip
formatter={(value) => formatValue(Number(value ?? 0), unit)}
/>
<Bar dataKey="value" radius={[4, 4, 0, 0]}>
{chartData.map((entry, i) => (
<Cell key={i} fill={entry.fill} />
))}
</Bar>
</BarChart>
)}
</ResponsiveContainer>
</div>
</div>
)
}
@@ -1,80 +0,0 @@
'use client'
import type { TableArtifact } from '@/types/chat'
interface ChatDataTableProps {
artifact: TableArtifact
}
function formatCell(value: string | number, _align?: 'left' | 'right'): string {
if (typeof value === 'number') {
return new Intl.NumberFormat('sv-SE', {
minimumFractionDigits: value % 1 !== 0 ? 2 : 0,
maximumFractionDigits: 2,
}).format(value)
}
return String(value)
}
export function ChatDataTable({ artifact }: ChatDataTableProps) {
const { title, columns, rows, summary_row } = artifact
return (
<div className="w-full">
<h4 className="text-sm font-semibold mb-2">{title}</h4>
<div className="overflow-x-auto rounded-lg border border-border">
<table className="w-full text-xs">
<thead>
<tr className="bg-muted/50">
{columns.map((col) => (
<th
key={col.key}
className={`px-3 py-2 font-medium text-muted-foreground border-b border-border ${
col.align === 'right' ? 'text-right' : 'text-left'
}`}
>
{col.label}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, i) => (
<tr
key={i}
className="border-b border-border/50 hover:bg-muted/20 transition-colors"
>
{columns.map((col) => (
<td
key={col.key}
className={`px-3 py-1.5 ${
col.align === 'right' ? 'text-right tabular-nums' : 'text-left'
}`}
>
{formatCell(row[col.key], col.align)}
</td>
))}
</tr>
))}
{summary_row && (
<tr className="bg-muted/30 font-semibold">
{columns.map((col) => (
<td
key={col.key}
className={`px-3 py-2 border-t border-border ${
col.align === 'right' ? 'text-right tabular-nums' : 'text-left'
}`}
>
{summary_row[col.key] !== undefined
? formatCell(summary_row[col.key], col.align)
: ''}
</td>
))}
</tr>
)}
</tbody>
</table>
</div>
</div>
)
}
@@ -1,47 +0,0 @@
'use client'
import { TrendingUp, TrendingDown, Minus } from 'lucide-react'
import type { KpiCardsArtifact } from '@/types/chat'
interface ChatKpiCardsProps {
artifact: KpiCardsArtifact
}
export function ChatKpiCards({ artifact }: ChatKpiCardsProps) {
const { title, cards } = artifact
return (
<div className="w-full">
{title && <h4 className="text-sm font-semibold mb-2">{title}</h4>}
<div className="grid grid-cols-2 gap-2">
{cards.map((card, i) => (
<div
key={i}
className="rounded-lg border border-border bg-card p-3"
>
<p className="text-xs text-muted-foreground mb-1">{card.label}</p>
<div className="flex items-baseline gap-2">
<span className="text-lg font-bold tabular-nums">{card.value}</span>
{card.trend && (
<span
className={`flex items-center gap-0.5 text-xs ${
card.trend === 'up'
? 'text-success'
: card.trend === 'down'
? 'text-destructive'
: 'text-muted-foreground'
}`}
>
{card.trend === 'up' && <TrendingUp className="h-3 w-3" />}
{card.trend === 'down' && <TrendingDown className="h-3 w-3" />}
{card.trend === 'flat' && <Minus className="h-3 w-3" />}
{card.change}
</span>
)}
</div>
</div>
))}
</div>
</div>
)
}
-5
View File
@@ -1,5 +0,0 @@
export { ChatWidget } from './ChatWidget'
export { ChatPanel } from './ChatPanel'
export { ChatMessage } from './ChatMessage'
export { ChatInput } from './ChatInput'
export { useChatStream } from './useChatStream'
-233
View File
@@ -1,233 +0,0 @@
'use client'
import { useState, useCallback, useRef } from 'react'
import type { ChatMessage, SourceReference, ArtifactSpec } from '@/types/chat'
interface UseChatStreamOptions {
onError?: (error: string) => void
}
interface UseChatStreamReturn {
messages: ChatMessage[]
isLoading: boolean
isStreaming: boolean
sessionId: string | null
error: string | null
toolsExecuting: string[]
sendMessage: (message: string) => Promise<void>
loadSession: (sessionId: string) => Promise<void>
clearChat: () => void
setSessionId: (id: string | null) => void
}
export function useChatStream(options: UseChatStreamOptions = {}): UseChatStreamReturn {
const [messages, setMessages] = useState<ChatMessage[]>([])
const [isLoading, setIsLoading] = useState(false)
const [isStreaming, setIsStreaming] = useState(false)
const [sessionId, setSessionId] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [toolsExecuting, setToolsExecuting] = useState<string[]>([])
const abortControllerRef = useRef<AbortController | null>(null)
const sendMessage = useCallback(async (message: string) => {
if (!message.trim() || isLoading || isStreaming) return
setError(null)
setIsLoading(true)
// Add user message immediately
const userMessage: ChatMessage = {
id: `temp-${Date.now()}`,
session_id: sessionId || '',
user_id: '',
role: 'user',
content: message.trim(),
sources: [],
created_at: new Date().toISOString(),
}
setMessages((prev) => [...prev, userMessage])
// Add placeholder for assistant response
const assistantPlaceholder: ChatMessage = {
id: `temp-assistant-${Date.now()}`,
session_id: sessionId || '',
user_id: '',
role: 'assistant',
content: '',
sources: [],
created_at: new Date().toISOString(),
}
setMessages((prev) => [...prev, assistantPlaceholder])
try {
abortControllerRef.current = new AbortController()
const response = await fetch('/api/extensions/ext/ai-chat/stream', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
message: message.trim(),
session_id: sessionId,
}),
signal: abortControllerRef.current.signal,
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || 'Failed to send message')
}
setIsLoading(false)
setIsStreaming(true)
const reader = response.body?.getReader()
if (!reader) throw new Error('No response body')
const decoder = new TextDecoder()
let accumulatedContent = ''
let sources: SourceReference[] = []
let artifact: ArtifactSpec | null = null
let newSessionId = sessionId
let messageId: string | null = null
while (true) {
const { done, value } = await reader.read()
if (done) break
const chunk = decoder.decode(value)
const lines = chunk.split('\n')
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
const data = JSON.parse(line.slice(6))
if (data.type === 'session') {
newSessionId = data.session_id
setSessionId(data.session_id)
} else if (data.type === 'content') {
accumulatedContent += data.content
setToolsExecuting([]) // Clear tool indicators when content starts
setMessages((prev) => {
const newMessages = [...prev]
const lastMsg = newMessages[newMessages.length - 1]
if (lastMsg.role === 'assistant') {
lastMsg.content = accumulatedContent
}
return newMessages
})
} else if (data.type === 'sources') {
sources = data.sources
setMessages((prev) => {
const newMessages = [...prev]
const lastMsg = newMessages[newMessages.length - 1]
if (lastMsg.role === 'assistant') {
lastMsg.sources = sources
}
return newMessages
})
} else if (data.type === 'tool_start') {
setToolsExecuting((prev) => [...prev, data.toolName])
} else if (data.type === 'artifact') {
artifact = data.artifact
setMessages((prev) => {
const newMessages = [...prev]
const lastMsg = newMessages[newMessages.length - 1]
if (lastMsg.role === 'assistant') {
lastMsg.artifact = artifact
}
return newMessages
})
} else if (data.type === 'done') {
messageId = data.message_id
setToolsExecuting([])
// Update the message IDs with real values
setMessages((prev) => {
const newMessages = [...prev]
const lastMsg = newMessages[newMessages.length - 1]
if (lastMsg.role === 'assistant' && messageId) {
lastMsg.id = messageId
lastMsg.session_id = newSessionId || ''
}
return newMessages
})
} else if (data.type === 'error') {
throw new Error(data.error)
}
} catch (parseError) {
// Skip invalid JSON lines
}
}
}
}
} catch (err) {
if (err instanceof Error && err.name === 'AbortError') {
// User cancelled, remove the placeholder
setMessages((prev) => prev.slice(0, -1))
} else {
const errorMessage = err instanceof Error ? err.message : 'An error occurred'
setError(errorMessage)
options.onError?.(errorMessage)
// Update placeholder with error
setMessages((prev) => {
const newMessages = [...prev]
const lastMsg = newMessages[newMessages.length - 1]
if (lastMsg.role === 'assistant') {
lastMsg.content = 'Ett fel uppstod. Försök igen.'
}
return newMessages
})
}
} finally {
setIsLoading(false)
setIsStreaming(false)
abortControllerRef.current = null
}
}, [sessionId, isLoading, isStreaming, options])
const loadSession = useCallback(async (id: string) => {
setIsLoading(true)
setError(null)
try {
const response = await fetch(`/api/extensions/ext/ai-chat/sessions/${id}`)
if (!response.ok) {
throw new Error('Failed to load session')
}
const data = await response.json()
setSessionId(id)
setMessages(data.messages || [])
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to load session'
setError(errorMessage)
options.onError?.(errorMessage)
} finally {
setIsLoading(false)
}
}, [options])
const clearChat = useCallback(() => {
if (abortControllerRef.current) {
abortControllerRef.current.abort()
}
setMessages([])
setSessionId(null)
setError(null)
}, [])
return {
messages,
isLoading,
isStreaming,
sessionId,
error,
toolsExecuting,
sendMessage,
loadSession,
clearChat,
setSessionId,
}
}
+1 -1
View File
@@ -217,7 +217,7 @@ export default function DashboardContent({ firstName, settings, summary, onboard
if (summary.expiringBankConnections && summary.expiringBankConnections.length > 0) {
const conn = summary.expiringBankConnections[0]
alertItems.push(
<Link key="bank-expiry" href="/settings?tab=banking" className="group">
<Link key="bank-expiry" href="/settings/banking" className="group">
<Card className="h-full border-warning/30 hover:bg-warning/[0.03] transition-colors">
<CardContent className="p-4">
<div className="flex items-center gap-3">
-102
View File
@@ -1,102 +0,0 @@
'use client'
import { useState } from 'react'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { AI_DATA_DISCLOSURES, type AiExtensionId } from '@/lib/extensions/ai-consent'
import Link from 'next/link'
interface AiConsentDialogProps {
extensionId: AiExtensionId
open: boolean
onOpenChange: (open: boolean) => void
onConsented: () => void
}
export function AiConsentDialog({
extensionId,
open,
onOpenChange,
onConsented,
}: AiConsentDialogProps) {
const [isSubmitting, setIsSubmitting] = useState(false)
const disclosure = AI_DATA_DISCLOSURES[extensionId]
async function handleAccept() {
setIsSubmitting(true)
try {
const res = await fetch('/api/ai-consent', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ extension_id: extensionId }),
})
if (res.ok) {
onOpenChange(false)
onConsented()
}
} finally {
setIsSubmitting(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>AI-samtycke krävs</DialogTitle>
<DialogDescription>
Denna funktion använder AI-tjänster från externa leverantörer.
Granska informationen nedan innan du fortsätter.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div>
<p className="text-sm font-medium mb-1">Leverantör</p>
<p className="text-sm text-muted-foreground">{disclosure.provider}</p>
</div>
<div>
<p className="text-sm font-medium mb-1">Data som skickas</p>
<ul className="text-sm text-muted-foreground list-disc pl-5 space-y-1">
{disclosure.dataTypes.map((dt) => (
<li key={dt}>{dt}</li>
))}
</ul>
</div>
<div>
<p className="text-sm font-medium mb-1">Syfte</p>
<p className="text-sm text-muted-foreground">{disclosure.purpose}</p>
</div>
<p className="text-xs text-muted-foreground">
Läs mer i vår{' '}
<Link href="/privacy" className="underline underline-offset-4" target="_blank">
integritetspolicy
</Link>
. Du kan när som helst återkalla ditt samtycke i inställningarna.
</p>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSubmitting}>
Avbryt
</Button>
<Button onClick={handleAccept} disabled={isSubmitting}>
{isSubmitting ? 'Sparar...' : 'Jag samtycker'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -1,12 +0,0 @@
'use client'
import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry'
import { ChatPanel } from '@/components/chat/ChatPanel'
export default function AiChatWorkspace({ userId }: WorkspaceComponentProps) {
return (
<div className="h-[calc(100vh-10rem)] max-w-4xl mx-auto">
<ChatPanel className="h-full rounded-lg border border-border bg-background shadow-sm" />
</div>
)
}
@@ -1,371 +0,0 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry'
import type { InvoiceInboxItem, Supplier, DocumentClassificationType } from '@/types'
import type { InvoiceInboxSettings } from '@/extensions/general/invoice-inbox/types'
import { PageHeader } from '@/components/ui/page-header'
import { Card, CardContent } from '@/components/ui/card'
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { Settings, Inbox, CheckCircle2, AlertTriangle, Receipt, FileText, RefreshCw } from 'lucide-react'
import DocumentInboxCard from '@/components/extensions/general/document-inbox/DocumentInboxCard'
import ReceiptInboxDetail from '@/components/extensions/general/document-inbox/ReceiptInboxDetail'
import InboxUploadZone from '@/components/extensions/general/invoice-inbox/InboxUploadZone'
import InboxDetailDialog from '@/components/extensions/general/invoice-inbox/InboxDetailDialog'
import InboxSettingsDialog from '@/components/extensions/general/invoice-inbox/InboxSettingsDialog'
type TabValue = 'all' | DocumentClassificationType
const TABS: { value: TabValue; label: string }[] = [
{ value: 'all', label: 'Alla' },
{ value: 'supplier_invoice', label: 'Fakturor' },
{ value: 'receipt', label: 'Kvitton' },
{ value: 'government_letter', label: 'Myndighetspost' },
{ value: 'unknown', label: 'Övrigt' },
]
const DEFAULT_SETTINGS: InvoiceInboxSettings = {
autoProcessEnabled: true,
autoMatchSupplierEnabled: true,
supplierMatchThreshold: 0.7,
inboxEmail: null,
}
export default function DocumentInboxWorkspace({ userId }: WorkspaceComponentProps) {
const [items, setItems] = useState<InvoiceInboxItem[]>([])
const [loading, setLoading] = useState(true)
const [activeTab, setActiveTab] = useState<TabValue>('all')
const [selectedItem, setSelectedItem] = useState<InvoiceInboxItem | null>(null)
const [isUploading, setIsUploading] = useState(false)
const [settings, setSettings] = useState<InvoiceInboxSettings>(DEFAULT_SETTINGS)
const [settingsOpen, setSettingsOpen] = useState(false)
const [suppliers, setSuppliers] = useState<Supplier[]>([])
const fetchItems = useCallback(async () => {
try {
const res = await fetch('/api/extensions/ext/invoice-inbox/inbox')
if (res.ok) {
const { data } = await res.json()
setItems(data ?? [])
}
} catch {
// Silently fail
} finally {
setLoading(false)
}
}, [])
const fetchSettings = useCallback(async () => {
try {
const res = await fetch('/api/extensions/ext/invoice-inbox/settings')
if (res.ok) {
const { data } = await res.json()
if (data) setSettings(data)
}
} catch {
// Use defaults
}
}, [])
const fetchSuppliers = useCallback(async () => {
try {
const res = await fetch('/api/suppliers')
if (res.ok) {
const { data } = await res.json()
setSuppliers(data ?? [])
}
} catch {
// ok
}
}, [])
useEffect(() => {
fetchItems()
fetchSettings()
fetchSuppliers()
}, [fetchItems, fetchSettings, fetchSuppliers])
function handleUploadComplete(result: InvoiceInboxItem | InvoiceInboxItem[]) {
const newItems = Array.isArray(result) ? result : [result]
setItems((prev) => [...newItems, ...prev])
for (const item of newItems) {
pollItem(item.id)
}
}
const [isMatching, setIsMatching] = useState(false)
async function handleMatchSweep() {
setIsMatching(true)
try {
const res = await fetch('/api/documents/match-sweep', { method: 'POST' })
if (res.ok) {
await fetchItems()
}
} catch {
// Non-critical
} finally {
setIsMatching(false)
}
}
async function pollItem(itemId: string) {
for (let i = 0; i < 20; i++) {
await new Promise((r) => setTimeout(r, 3000))
try {
const res = await fetch(`/api/extensions/ext/invoice-inbox/inbox/${itemId}`)
if (!res.ok) continue
const { data } = await res.json()
if (data && data.status !== 'processing') {
setItems((prev) =>
prev.map((it) => (it.id === itemId ? data : it))
)
setSelectedItem((current) =>
current?.id === itemId ? data : current
)
return
}
} catch {
// continue
}
}
}
function handleItemClick(item: InvoiceInboxItem) {
setSelectedItem(item)
}
async function handleConfirm(itemId: string, supplierId?: string) {
const body: Record<string, string> = {}
if (supplierId) body.supplier_id = supplierId
const res = await fetch(`/api/extensions/ext/invoice-inbox/inbox/${itemId}/confirm`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (res.ok) {
setItems((prev) =>
prev.map((it) => (it.id === itemId ? { ...it, status: 'confirmed' as const } : it))
)
setSelectedItem(null)
fetchSuppliers()
}
}
async function handleReject(itemId: string) {
const res = await fetch(`/api/extensions/ext/invoice-inbox/inbox/${itemId}`, {
method: 'DELETE',
})
if (res.ok) {
setItems((prev) =>
prev.map((it) => (it.id === itemId ? { ...it, status: 'rejected' as const } : it))
)
setSelectedItem(null)
}
}
async function handleReprocess(itemId: string) {
setItems((prev) =>
prev.map((it) => (it.id === itemId ? { ...it, status: 'processing' as const } : it))
)
setSelectedItem(null)
const res = await fetch(`/api/extensions/ext/invoice-inbox/inbox/${itemId}/process`, {
method: 'POST',
})
if (res.ok) {
const { data } = await res.json()
if (data) {
setItems((prev) => prev.map((it) => (it.id === itemId ? data : it)))
}
} else {
fetchItems()
}
}
function handleReceiptConfirm() {
fetchItems()
setSelectedItem(null)
}
async function handleSaveSettings(updated: InvoiceInboxSettings) {
const res = await fetch('/api/extensions/ext/invoice-inbox/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updated),
})
if (res.ok) {
const { data } = await res.json()
if (data) setSettings(data)
}
}
const filteredItems =
activeTab === 'all'
? items
: items.filter((it) => (it.document_type ?? 'supplier_invoice') === activeTab)
const totalPending = items.filter((it) => it.status === 'ready' || it.status === 'pending').length
const receiptCount = items.filter((it) => it.document_type === 'receipt' && it.status === 'ready').length
const invoiceCount = items.filter((it) => (it.document_type ?? 'supplier_invoice') === 'supplier_invoice' && it.status === 'ready').length
// Determine which detail dialog to show
const isReceiptSelected = selectedItem?.document_type === 'receipt'
return (
<div className="space-y-6">
<PageHeader
title="Dokumentinkorg"
description="Alla inkommande dokument — fakturor, kvitton och myndighetspost"
action={
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={handleMatchSweep}
disabled={isMatching}
>
<RefreshCw className={`h-4 w-4 mr-2 ${isMatching ? 'animate-spin' : ''}`} />
Matcha alla
</Button>
<Button
variant="outline"
size="icon"
onClick={() => setSettingsOpen(true)}
>
<Settings className="h-4 w-4" />
</Button>
</div>
}
/>
{/* KPI cards */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<Card>
<CardContent className="flex items-center gap-3 p-4">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
<Inbox className="h-5 w-5 text-primary" />
</div>
<div>
<p className="text-2xl font-medium">{totalPending}</p>
<p className="text-xs text-muted-foreground">Att granska</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center gap-3 p-4">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-warning/15">
<FileText className="h-5 w-5 text-warning-foreground" />
</div>
<div>
<p className="text-2xl font-medium">{invoiceCount}</p>
<p className="text-xs text-muted-foreground">Fakturor att granska</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center gap-3 p-4">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-secondary/50">
<Receipt className="h-5 w-5 text-muted-foreground" />
</div>
<div>
<p className="text-2xl font-medium">{receiptCount}</p>
<p className="text-xs text-muted-foreground">Kvitton att granska</p>
</div>
</CardContent>
</Card>
</div>
{/* Upload zone */}
<InboxUploadZone
onUploadComplete={handleUploadComplete}
isUploading={isUploading}
setIsUploading={setIsUploading}
/>
{/* Tabs by document type */}
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as TabValue)}>
<TabsList>
{TABS.map((tab) => (
<TabsTrigger key={tab.value} value={tab.value}>
{tab.label}
</TabsTrigger>
))}
</TabsList>
{TABS.map((tab) => (
<TabsContent key={tab.value} value={tab.value}>
{loading ? (
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-20 w-full rounded-xl" />
))}
</div>
) : filteredItems.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<Inbox className="h-10 w-10 text-muted-foreground/40 mb-3" />
<p className="text-sm text-muted-foreground">
{activeTab === 'all'
? 'Inga dokument ännu. Ladda upp ett dokument ovan eller skicka via e-post.'
: 'Inga dokument av denna typ.'}
</p>
</div>
) : (
<div className="space-y-2">
{filteredItems.map((item) => (
<DocumentInboxCard
key={item.id}
item={item}
onClick={() => handleItemClick(item)}
/>
))}
</div>
)}
</TabsContent>
))}
</Tabs>
{/* Receipt detail dialog */}
{isReceiptSelected && (
<ReceiptInboxDetail
item={selectedItem}
open={selectedItem != null && isReceiptSelected}
onOpenChange={(open) => {
if (!open) setSelectedItem(null)
}}
onConfirm={handleReceiptConfirm}
/>
)}
{/* Invoice/other detail dialog (existing) */}
{!isReceiptSelected && (
<InboxDetailDialog
item={selectedItem}
open={selectedItem != null && !isReceiptSelected}
onOpenChange={(open) => {
if (!open) setSelectedItem(null)
}}
onConfirm={handleConfirm}
onReject={handleReject}
onReprocess={handleReprocess}
suppliers={suppliers}
/>
)}
{/* Settings dialog */}
<InboxSettingsDialog
open={settingsOpen}
onOpenChange={setSettingsOpen}
settings={settings}
onSave={handleSaveSettings}
/>
</div>
)
}
@@ -14,7 +14,7 @@ export default function EnableBankingWorkspace({ userId }: WorkspaceComponentPro
Koppla ditt bankkonto under Inställningar för att synka transaktioner automatiskt.
</p>
<Button asChild variant="outline" className="mt-4">
<Link href="/settings?tab=banking">
<Link href="/settings/banking">
<Settings className="mr-2 h-4 w-4" />
till bankinställningar
</Link>
@@ -1,328 +0,0 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry'
import type { InvoiceInboxItem, Supplier, InboxItemStatus } from '@/types'
import type { InvoiceInboxSettings } from '@/extensions/general/invoice-inbox/types'
import { PageHeader } from '@/components/ui/page-header'
import { Card, CardContent } from '@/components/ui/card'
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { Settings, Inbox, CheckCircle2, AlertTriangle } from 'lucide-react'
import InboxItemCard from '@/components/extensions/general/invoice-inbox/InboxItemCard'
import InboxUploadZone from '@/components/extensions/general/invoice-inbox/InboxUploadZone'
import InboxDetailDialog from '@/components/extensions/general/invoice-inbox/InboxDetailDialog'
import InboxSettingsDialog from '@/components/extensions/general/invoice-inbox/InboxSettingsDialog'
type TabValue = 'all' | InboxItemStatus
const TABS: { value: TabValue; label: string }[] = [
{ value: 'all', label: 'Alla' },
{ value: 'pending', label: 'Ny' },
{ value: 'ready', label: 'Klar' },
{ value: 'confirmed', label: 'Bekräftad' },
{ value: 'rejected', label: 'Avvisad' },
{ value: 'error', label: 'Fel' },
]
const DEFAULT_SETTINGS: InvoiceInboxSettings = {
autoProcessEnabled: true,
autoMatchSupplierEnabled: true,
supplierMatchThreshold: 0.7,
inboxEmail: null,
}
export default function InvoiceInboxWorkspace({ userId }: WorkspaceComponentProps) {
const [items, setItems] = useState<InvoiceInboxItem[]>([])
const [loading, setLoading] = useState(true)
const [activeTab, setActiveTab] = useState<TabValue>('all')
const [selectedItem, setSelectedItem] = useState<InvoiceInboxItem | null>(null)
const [isUploading, setIsUploading] = useState(false)
const [settings, setSettings] = useState<InvoiceInboxSettings>(DEFAULT_SETTINGS)
const [settingsOpen, setSettingsOpen] = useState(false)
const [suppliers, setSuppliers] = useState<Supplier[]>([])
const fetchItems = useCallback(async () => {
try {
const res = await fetch('/api/extensions/ext/invoice-inbox/inbox')
if (res.ok) {
const { data } = await res.json()
setItems(data ?? [])
}
} catch {
// Silently fail — user sees empty state
} finally {
setLoading(false)
}
}, [])
const fetchSettings = useCallback(async () => {
try {
const res = await fetch('/api/extensions/ext/invoice-inbox/settings')
if (res.ok) {
const { data } = await res.json()
if (data) setSettings(data)
}
} catch {
// Use defaults
}
}, [])
const fetchSuppliers = useCallback(async () => {
try {
const res = await fetch('/api/suppliers')
if (res.ok) {
const { data } = await res.json()
setSuppliers(data ?? [])
}
} catch {
// ok
}
}, [])
useEffect(() => {
fetchItems()
fetchSettings()
fetchSuppliers()
}, [fetchItems, fetchSettings, fetchSuppliers])
function handleUploadComplete(result: InvoiceInboxItem | InvoiceInboxItem[]) {
const newItems = Array.isArray(result) ? result : [result]
setItems((prev) => [...newItems, ...prev])
for (const item of newItems) {
pollItem(item.id)
}
}
async function pollItem(itemId: string) {
for (let i = 0; i < 20; i++) {
await new Promise((r) => setTimeout(r, 3000))
try {
const res = await fetch(`/api/extensions/ext/invoice-inbox/inbox/${itemId}`)
if (!res.ok) continue
const { data } = await res.json()
if (data && data.status !== 'processing') {
setItems((prev) =>
prev.map((it) => (it.id === itemId ? data : it))
)
// Also update the detail dialog if it's open for this item
setSelectedItem((current) =>
current?.id === itemId ? data : current
)
return
}
} catch {
// continue polling
}
}
}
async function handleConfirm(
itemId: string,
supplierId?: string,
newSupplierData?: import('@/components/extensions/general/invoice-inbox/InboxDetailDialog').NewSupplierData
) {
const body: Record<string, unknown> = {}
if (supplierId) {
body.supplier_id = supplierId
} else if (newSupplierData) {
body.new_supplier = newSupplierData
}
const res = await fetch(`/api/extensions/ext/invoice-inbox/inbox/${itemId}/confirm`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (res.ok) {
setItems((prev) =>
prev.map((it) => (it.id === itemId ? { ...it, status: 'confirmed' as const } : it))
)
setSelectedItem(null)
fetchSuppliers() // New supplier may have been created
}
}
async function handleReject(itemId: string) {
const res = await fetch(`/api/extensions/ext/invoice-inbox/inbox/${itemId}`, {
method: 'DELETE',
})
if (res.ok) {
setItems((prev) =>
prev.map((it) => (it.id === itemId ? { ...it, status: 'rejected' as const } : it))
)
setSelectedItem(null)
}
}
async function handleReprocess(itemId: string) {
setItems((prev) =>
prev.map((it) => (it.id === itemId ? { ...it, status: 'processing' as const } : it))
)
setSelectedItem(null)
const res = await fetch(`/api/extensions/ext/invoice-inbox/inbox/${itemId}/process`, {
method: 'POST',
})
if (res.ok) {
const { data } = await res.json()
if (data) {
setItems((prev) => prev.map((it) => (it.id === itemId ? data : it)))
}
} else {
// Refetch in case of error update
fetchItems()
}
}
async function handleSaveSettings(updated: InvoiceInboxSettings) {
const res = await fetch('/api/extensions/ext/invoice-inbox/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updated),
})
if (res.ok) {
const { data } = await res.json()
if (data) setSettings(data)
}
}
const filteredItems =
activeTab === 'all'
? items
: items.filter((it) => it.status === activeTab)
const totalCount = items.length
const readyCount = items.filter((it) => it.status === 'ready').length
const errorCount = items.filter((it) => it.status === 'error').length
return (
<div className="space-y-6">
<PageHeader
title="Fakturainkorgen"
description="Ladda upp leverantörsfakturor och låt AI extrahera data automatiskt"
action={
<Button
variant="outline"
size="icon"
onClick={() => setSettingsOpen(true)}
>
<Settings className="h-4 w-4" />
</Button>
}
/>
{/* KPI cards */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<Card>
<CardContent className="flex items-center gap-3 p-4">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
<Inbox className="h-5 w-5 text-primary" />
</div>
<div>
<p className="text-2xl font-medium">{totalCount}</p>
<p className="text-xs text-muted-foreground">Totalt</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center gap-3 p-4">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-warning/15">
<CheckCircle2 className="h-5 w-5 text-warning-foreground" />
</div>
<div>
<p className="text-2xl font-medium">{readyCount}</p>
<p className="text-xs text-muted-foreground">Att granska</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center gap-3 p-4">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-destructive/10">
<AlertTriangle className="h-5 w-5 text-destructive" />
</div>
<div>
<p className="text-2xl font-medium">{errorCount}</p>
<p className="text-xs text-muted-foreground">Fel</p>
</div>
</CardContent>
</Card>
</div>
{/* Upload zone */}
<InboxUploadZone
onUploadComplete={handleUploadComplete}
isUploading={isUploading}
setIsUploading={setIsUploading}
/>
{/* Tabs + item list */}
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as TabValue)}>
<TabsList>
{TABS.map((tab) => (
<TabsTrigger key={tab.value} value={tab.value}>
{tab.label}
</TabsTrigger>
))}
</TabsList>
{TABS.map((tab) => (
<TabsContent key={tab.value} value={tab.value}>
{loading ? (
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-20 w-full rounded-xl" />
))}
</div>
) : filteredItems.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<Inbox className="h-10 w-10 text-muted-foreground/40 mb-3" />
<p className="text-sm text-muted-foreground">
{activeTab === 'all'
? 'Inga fakturor ännu. Ladda upp en faktura ovan.'
: 'Inga fakturor med denna status.'}
</p>
</div>
) : (
<div className="space-y-2">
{filteredItems.map((item) => (
<InboxItemCard
key={item.id}
item={item}
onClick={() => setSelectedItem(item)}
/>
))}
</div>
)}
</TabsContent>
))}
</Tabs>
{/* Detail dialog */}
<InboxDetailDialog
item={selectedItem}
open={selectedItem != null}
onOpenChange={(open) => {
if (!open) setSelectedItem(null)
}}
onConfirm={handleConfirm}
onReject={handleReject}
onReprocess={handleReprocess}
suppliers={suppliers}
/>
{/* Settings dialog */}
<InboxSettingsDialog
open={settingsOpen}
onOpenChange={setSettingsOpen}
settings={settings}
onSave={handleSaveSettings}
/>
</div>
)
}
@@ -1,150 +0,0 @@
'use client'
import type { InvoiceInboxItem, DocumentClassificationType } from '@/types'
import type { InvoiceExtractionResult } from '@/extensions/general/invoice-inbox/types'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import {
getStatusLabel,
getStatusVariant,
getConfidenceLabel,
formatExtractionSummary,
getDocumentTypeLabel,
getDocumentTypeVariant,
} from '@/lib/extensions/invoice-inbox-utils'
import { Mail, Upload, FileText, Receipt, Landmark } from 'lucide-react'
interface DocumentInboxCardProps {
item: InvoiceInboxItem
onClick: () => void
}
function formatRelativeTime(dateStr: string): string {
const now = new Date()
const date = new Date(dateStr)
const diffMs = now.getTime() - date.getTime()
const diffMin = Math.floor(diffMs / 60000)
if (diffMin < 1) return 'Just nu'
if (diffMin < 60) return `${diffMin} min sedan`
const diffH = Math.floor(diffMin / 60)
if (diffH < 24) return `${diffH} tim sedan`
const diffD = Math.floor(diffH / 24)
if (diffD === 1) return 'Igår'
return `${diffD} dagar sedan`
}
function formatAmount(amount: number, currency: string = 'SEK'): string {
return new Intl.NumberFormat('sv-SE', {
style: 'currency',
currency,
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(amount)
}
function getDocumentIcon(type: DocumentClassificationType) {
switch (type) {
case 'receipt':
return <Receipt className="h-5 w-5 text-muted-foreground" />
case 'government_letter':
return <Landmark className="h-5 w-5 text-muted-foreground" />
default:
return <FileText className="h-5 w-5 text-muted-foreground" />
}
}
function getSummaryText(item: InvoiceInboxItem): { label: string; total: number; currency: string } {
const type = item.document_type ?? 'supplier_invoice'
switch (type) {
case 'supplier_invoice': {
const extraction = item.extracted_data as unknown as InvoiceExtractionResult | null
const summary = formatExtractionSummary(extraction)
return {
label: (item.supplier as { name?: string } | undefined)?.name ?? (summary.supplierName || 'Okänd leverantör'),
total: summary.total,
currency: summary.currency,
}
}
case 'receipt': {
const receipt = item.receipt as { merchant_name?: string; total_amount?: number; currency?: string } | undefined
return {
label: receipt?.merchant_name ?? 'Okänd handlare',
total: receipt?.total_amount ?? 0,
currency: receipt?.currency || 'SEK',
}
}
case 'government_letter': {
return {
label: item.email_from ?? 'Okänd avsändare',
total: 0,
currency: 'SEK',
}
}
default: {
return { label: 'Granska manuellt', total: 0, currency: 'SEK' }
}
}
}
export default function DocumentInboxCard({ item, onClick }: DocumentInboxCardProps) {
const confidence = getConfidenceLabel(item.confidence)
const statusVariant = getStatusVariant(item.status) as 'default' | 'secondary' | 'destructive' | 'outline' | 'success' | 'warning'
const confidenceVariant = confidence.variant as 'default' | 'secondary' | 'destructive' | 'outline' | 'success' | 'warning'
const docType = (item.document_type ?? 'supplier_invoice') as DocumentClassificationType
const docTypeVariant = getDocumentTypeVariant(docType) as 'default' | 'secondary' | 'destructive' | 'outline' | 'success' | 'warning'
const fileName = (item.document as { file_name?: string } | undefined)?.file_name ?? 'Okänd fil'
const { label: summaryLabel, total, currency } = getSummaryText(item)
return (
<Card
className="cursor-pointer transition-colors hover:bg-accent/50"
onClick={onClick}
>
<CardContent className="flex items-center gap-4 p-4">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-muted">
{item.source === 'email' ? (
<Mail className="h-5 w-5 text-muted-foreground" />
) : (
<Upload className="h-5 w-5 text-muted-foreground" />
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
{getDocumentIcon(docType)}
<span className="text-sm font-medium truncate">{fileName}</span>
</div>
<div className="flex items-center gap-2 mt-0.5">
<span className={`text-sm truncate ${summaryLabel === 'Granska manuellt' ? 'text-muted-foreground/60 italic' : 'text-muted-foreground'}`}>
{summaryLabel}
</span>
</div>
</div>
<div className="flex flex-col items-end gap-1 shrink-0">
{total > 0 && (
<span className="text-sm font-medium">{formatAmount(total, currency)}</span>
)}
<div className="flex items-center gap-1.5">
<Badge variant={docTypeVariant} className="text-[10px] px-1.5 py-0">
{getDocumentTypeLabel(docType)}
</Badge>
{item.confidence != null && (
<Badge variant={confidenceVariant} className="text-[10px] px-1.5 py-0">
{confidence.label}
</Badge>
)}
<Badge variant={statusVariant}>
{getStatusLabel(item.status)}
</Badge>
</div>
<span className="text-[11px] text-muted-foreground">
{formatRelativeTime(item.created_at)}
</span>
</div>
</CardContent>
</Card>
)
}
@@ -1,311 +0,0 @@
'use client'
import { useState } from 'react'
import type { InvoiceInboxItem } from '@/types'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Separator } from '@/components/ui/separator'
import { Switch } from '@/components/ui/switch'
import { CheckCircle2, Receipt, LinkIcon } from 'lucide-react'
interface ReceiptLineItem {
id: string
description: string
line_total: number
vat_rate: number | null
is_business: boolean | null
category: string | null
bas_account: string | null
}
interface ReceiptInboxDetailProps {
item: InvoiceInboxItem | null
open: boolean
onOpenChange: (open: boolean) => void
onConfirm: () => void
}
function formatAmount(amount: number, currency: string = 'SEK'): string {
return new Intl.NumberFormat('sv-SE', {
style: 'currency',
currency,
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(amount)
}
export default function ReceiptInboxDetail({
item,
open,
onOpenChange,
onConfirm,
}: ReceiptInboxDetailProps) {
const [lineItems, setLineItems] = useState<ReceiptLineItem[]>([])
const [loading, setLoading] = useState(false)
const [confirming, setConfirming] = useState(false)
const [representationPersons, setRepresentationPersons] = useState<number | null>(null)
const [representationPurpose, setRepresentationPurpose] = useState('')
const [representationBusinessConnection, setRepresentationBusinessConnection] = useState('')
const receipt = item?.receipt as {
id?: string
merchant_name?: string
total_amount?: number
receipt_date?: string
status?: string
matched_transaction_id?: string
} | undefined
// Fetch line items when dialog opens
async function fetchLineItems() {
if (!item?.linked_receipt_id) return
setLoading(true)
try {
const res = await fetch(`/api/extensions/ext/receipt-ocr/${item.linked_receipt_id}`)
if (res.ok) {
const { data } = await res.json()
if (data?.line_items) {
setLineItems(data.line_items)
}
}
} catch {
// ok
} finally {
setLoading(false)
}
}
function handleOpenChange(isOpen: boolean) {
if (isOpen && item?.linked_receipt_id) {
fetchLineItems()
}
onOpenChange(isOpen)
}
function toggleBusiness(lineItemId: string) {
setLineItems((prev) =>
prev.map((li) =>
li.id === lineItemId
? { ...li, is_business: li.is_business === true ? false : true }
: li
)
)
}
async function handleConfirm() {
if (!item?.id || !item.linked_receipt_id) return
setConfirming(true)
try {
const body: Record<string, unknown> = {
line_items: lineItems.map((li) => ({
id: li.id,
is_business: li.is_business,
category: li.category,
bas_account: li.bas_account,
})),
}
if (receipt?.matched_transaction_id) {
body.matched_transaction_id = receipt.matched_transaction_id
}
if (representationPersons != null && representationPersons > 0) {
body.representation_persons = representationPersons
}
if (representationPurpose) {
body.representation_purpose = representationPurpose
}
if (representationBusinessConnection) {
body.representation_business_connection = representationBusinessConnection
}
const res = await fetch(
`/api/extensions/ext/invoice-inbox/inbox/${item.id}/confirm-receipt`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}
)
if (res.ok) {
onConfirm()
onOpenChange(false)
}
} catch {
// ok
} finally {
setConfirming(false)
}
}
const businessTotal = lineItems
.filter((li) => li.is_business === true)
.reduce((sum, li) => sum + li.line_total, 0)
const privateTotal = lineItems
.filter((li) => li.is_business === false)
.reduce((sum, li) => sum + li.line_total, 0)
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Receipt className="h-5 w-5" />
Kvitto via e-post
</DialogTitle>
</DialogHeader>
{receipt && (
<div className="space-y-4">
{/* Summary */}
<div className="grid grid-cols-2 gap-3 text-sm">
<div>
<span className="text-muted-foreground">Handlare</span>
<p className="font-medium">{receipt.merchant_name ?? 'Okänd'}</p>
</div>
<div>
<span className="text-muted-foreground">Datum</span>
<p className="font-medium">{receipt.receipt_date ?? '-'}</p>
</div>
<div>
<span className="text-muted-foreground">Totalbelopp</span>
<p className="font-medium">
{receipt.total_amount ? formatAmount(receipt.total_amount) : '-'}
</p>
</div>
<div>
<span className="text-muted-foreground">Transaktionsmatch</span>
<p className="font-medium flex items-center gap-1">
{receipt.matched_transaction_id ? (
<>
<LinkIcon className="h-3 w-3 text-success" />
<span className="text-success">Matchad</span>
</>
) : (
<span className="text-muted-foreground">Ingen match</span>
)}
</p>
</div>
</div>
<Separator />
{/* Line items with business/private toggle */}
<div className="space-y-2">
<Label>Artikelrader</Label>
{loading ? (
<p className="text-sm text-muted-foreground">Laddar...</p>
) : lineItems.length === 0 ? (
<p className="text-sm text-muted-foreground">Inga rader extraherade</p>
) : (
<div className="space-y-2">
{lineItems.map((li) => (
<div
key={li.id}
className="flex items-center justify-between gap-3 rounded-lg border p-3"
>
<div className="flex-1 min-w-0">
<p className="text-sm truncate">{li.description}</p>
<p className="text-xs text-muted-foreground">
{formatAmount(li.line_total)}
{li.vat_rate != null && ` (${li.vat_rate}% moms)`}
</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<span className="text-xs text-muted-foreground">Företag</span>
<Switch
checked={li.is_business === true}
onCheckedChange={() => toggleBusiness(li.id)}
/>
</div>
</div>
))}
</div>
)}
</div>
{/* Totals */}
{lineItems.length > 0 && (
<div className="flex gap-4 text-sm">
<Badge variant="default">Företag: {formatAmount(Math.round(businessTotal * 100) / 100)}</Badge>
<Badge variant="secondary">Privat: {formatAmount(Math.round(privateTotal * 100) / 100)}</Badge>
</div>
)}
<Separator />
{/* Representation fields */}
<div className="space-y-3">
<Label className="text-xs text-muted-foreground uppercase tracking-wide">
Representation (vid restaurangkvitto)
</Label>
<div className="grid grid-cols-2 gap-3">
<div>
<Label htmlFor="rep-persons" className="text-sm">
Antal personer
</Label>
<Input
id="rep-persons"
type="number"
min={0}
value={representationPersons ?? ''}
onChange={(e) =>
setRepresentationPersons(
e.target.value ? parseInt(e.target.value) : null
)
}
placeholder="0"
/>
</div>
<div>
<Label htmlFor="rep-purpose" className="text-sm">
Syfte
</Label>
<Input
id="rep-purpose"
value={representationPurpose}
onChange={(e) => setRepresentationPurpose(e.target.value)}
placeholder="T.ex. kundmöte"
/>
</div>
</div>
<div>
<Label htmlFor="rep-connection" className="text-sm">
Affärsmässig koppling (BFNAR)
</Label>
<Input
id="rep-connection"
value={representationBusinessConnection}
onChange={(e) =>
setRepresentationBusinessConnection(e.target.value)
}
placeholder="T.ex. potentiell kund, pågående projekt"
/>
</div>
</div>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Avbryt
</Button>
<Button onClick={handleConfirm} disabled={confirming}>
<CheckCircle2 className="h-4 w-4 mr-1.5" />
{confirming ? 'Bekräftar...' : 'Bekräfta kvitto'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -1,454 +0,0 @@
'use client'
import { useState, useEffect } from 'react'
import type { InvoiceInboxItem, Supplier, SupplierType } from '@/types'
import type { InvoiceExtractionResult } from '@/extensions/general/invoice-inbox/types'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Separator } from '@/components/ui/separator'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Progress } from '@/components/ui/progress'
import {
getStatusLabel,
getStatusVariant,
getConfidenceLabel,
} from '@/lib/extensions/invoice-inbox-utils'
import { Loader2, RefreshCw, Check, X, ChevronDown } from 'lucide-react'
export interface NewSupplierData {
name: string
supplier_type: SupplierType
org_number: string
vat_number: string
bankgiro: string
plusgiro: string
default_expense_account: string
default_currency: string
}
interface InboxDetailDialogProps {
item: InvoiceInboxItem | null
open: boolean
onOpenChange: (open: boolean) => void
onConfirm: (itemId: string, supplierId?: string, newSupplierData?: NewSupplierData) => Promise<void>
onReject: (itemId: string) => Promise<void>
onReprocess: (itemId: string) => Promise<void>
suppliers: Supplier[]
}
function formatAmount(amount: number | null, currency: string = 'SEK'): string {
if (amount == null) return '-'
return new Intl.NumberFormat('sv-SE', {
style: 'currency',
currency,
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(amount)
}
export default function InboxDetailDialog({
item,
open,
onOpenChange,
onConfirm,
onReject,
onReprocess,
suppliers,
}: InboxDetailDialogProps) {
const [loading, setLoading] = useState<'confirm' | 'reject' | 'reprocess' | null>(null)
const [selectedSupplierId, setSelectedSupplierId] = useState<string | undefined>(undefined)
const [supplierFormOpen, setSupplierFormOpen] = useState(false)
const [newSupplier, setNewSupplier] = useState<NewSupplierData>({
name: '',
supplier_type: 'swedish_business',
org_number: '',
vat_number: '',
bankgiro: '',
plusgiro: '',
default_expense_account: '6200',
default_currency: 'SEK',
})
// Pre-populate supplier form when item changes
const extractionForEffect = item?.extracted_data as unknown as InvoiceExtractionResult | null
useEffect(() => {
if (!extractionForEffect?.supplier) return
const s = extractionForEffect.supplier
setNewSupplier({
name: s.name ?? '',
supplier_type: 'swedish_business',
org_number: s.orgNumber ?? '',
vat_number: s.vatNumber ?? '',
bankgiro: s.bankgiro ?? '',
plusgiro: s.plusgiro ?? '',
default_expense_account: '6200',
default_currency: extractionForEffect.invoice?.currency || 'SEK',
})
}, [extractionForEffect])
if (!item) return null
const extraction = item.extracted_data as unknown as InvoiceExtractionResult | null
const currency = extraction?.invoice.currency || 'SEK'
const confidence = getConfidenceLabel(item.confidence)
const confidenceVariant = confidence.variant as 'default' | 'secondary' | 'destructive' | 'outline' | 'success' | 'warning'
const statusVariant = getStatusVariant(item.status) as 'default' | 'secondary' | 'destructive' | 'outline' | 'success' | 'warning'
const matchedSupplierName = (item.supplier as { name?: string } | undefined)?.name
const supplierId = selectedSupplierId ?? item.matched_supplier_id ?? undefined
const isCreatingNewSupplier = !supplierId
const canConfirm = item.status === 'ready' && extraction != null
const canReprocess = item.status !== 'confirmed'
const canReject = item.status !== 'confirmed' && item.status !== 'rejected'
function updateSupplierField<K extends keyof NewSupplierData>(field: K, value: NewSupplierData[K]) {
setNewSupplier((prev) => ({ ...prev, [field]: value }))
}
async function handleAction(action: 'confirm' | 'reject' | 'reprocess') {
setLoading(action)
try {
if (action === 'confirm') {
await onConfirm(
item!.id,
supplierId,
isCreatingNewSupplier ? newSupplier : undefined
)
} else if (action === 'reject') {
await onReject(item!.id)
} else {
await onReprocess(item!.id)
}
} finally {
setLoading(null)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-2xl max-h-[95dvh] sm:max-h-[85vh] overflow-y-auto">
<DialogHeader>
<div className="flex items-center gap-2">
<DialogTitle>Granska faktura</DialogTitle>
<Badge variant={statusVariant}>
{getStatusLabel(item.status)}
</Badge>
</div>
</DialogHeader>
{/* Confidence */}
{item.confidence != null && (
<div className="space-y-1.5">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">AI-konfidens</span>
<Badge variant={confidenceVariant}>{confidence.label} ({Math.round(item.confidence * 100)}%)</Badge>
</div>
<Progress value={item.confidence * 100} className="h-1.5" />
</div>
)}
{item.error_message && (
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
{item.error_message}
</div>
)}
{extraction && (
<>
<Separator />
{/* Supplier info */}
<div className="space-y-3">
<h4 className="text-sm font-medium">Leverantör</h4>
<div className="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
<div>
<span className="text-muted-foreground">Namn</span>
<p className="font-medium">{extraction.supplier.name ?? '-'}</p>
</div>
<div>
<span className="text-muted-foreground">Org.nr</span>
<p className="font-medium">{extraction.supplier.orgNumber ?? '-'}</p>
</div>
{extraction.supplier.bankgiro && (
<div>
<span className="text-muted-foreground">Bankgiro</span>
<p className="font-medium">{extraction.supplier.bankgiro}</p>
</div>
)}
{extraction.supplier.plusgiro && (
<div>
<span className="text-muted-foreground">Plusgiro</span>
<p className="font-medium">{extraction.supplier.plusgiro}</p>
</div>
)}
</div>
{/* Supplier match override */}
<div className="space-y-1.5">
<label className="text-sm text-muted-foreground">
Matchad leverantör
{matchedSupplierName && (
<span className="ml-1 text-xs">
(auto: {matchedSupplierName})
</span>
)}
</label>
<Select
value={supplierId ?? '__new__'}
onValueChange={(v) => setSelectedSupplierId(v === '__new__' ? undefined : v)}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Skapa ny leverantör" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__new__">Skapa ny leverantör</SelectItem>
{suppliers.map((s) => (
<SelectItem key={s.id} value={s.id}>
{s.name}
{s.org_number ? ` (${s.org_number})` : ''}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Inline new supplier form */}
{isCreatingNewSupplier && (
<div className="rounded-md border bg-muted/30 p-3 space-y-3">
<button
type="button"
className="flex w-full items-center justify-between text-sm font-medium"
onClick={() => setSupplierFormOpen((o) => !o)}
>
<span>Granska leverantörsuppgifter</span>
<ChevronDown className={`h-4 w-4 transition-transform ${supplierFormOpen ? 'rotate-180' : ''}`} />
</button>
{supplierFormOpen && (
<div className="grid grid-cols-2 gap-3">
<div className="col-span-2 space-y-1">
<Label className="text-xs">Namn</Label>
<Input
value={newSupplier.name}
onChange={(e) => updateSupplierField('name', e.target.value)}
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Typ</Label>
<Select
value={newSupplier.supplier_type}
onValueChange={(v) => updateSupplierField('supplier_type', v as SupplierType)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="swedish_business">Svenskt företag</SelectItem>
<SelectItem value="eu_business">EU-företag</SelectItem>
<SelectItem value="non_eu_business">Utomeuropeiskt</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs">Org.nr</Label>
<Input
value={newSupplier.org_number}
onChange={(e) => updateSupplierField('org_number', e.target.value)}
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Bankgiro</Label>
<Input
value={newSupplier.bankgiro}
onChange={(e) => updateSupplierField('bankgiro', e.target.value)}
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Plusgiro</Label>
<Input
value={newSupplier.plusgiro}
onChange={(e) => updateSupplierField('plusgiro', e.target.value)}
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Kostnadskonto</Label>
<Input
value={newSupplier.default_expense_account}
onChange={(e) => updateSupplierField('default_expense_account', e.target.value)}
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Valuta</Label>
<Input
value={newSupplier.default_currency}
onChange={(e) => updateSupplierField('default_currency', e.target.value)}
/>
</div>
</div>
)}
</div>
)}
</div>
<Separator />
{/* Invoice details */}
<div className="space-y-3">
<h4 className="text-sm font-medium">Fakturadetaljer</h4>
<div className="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
<div>
<span className="text-muted-foreground">Fakturanummer</span>
<p className="font-medium">{extraction.invoice.invoiceNumber ?? '-'}</p>
</div>
<div>
<span className="text-muted-foreground">Valuta</span>
<p className="font-medium">{extraction.invoice.currency}</p>
</div>
<div>
<span className="text-muted-foreground">Fakturadatum</span>
<p className="font-medium">{extraction.invoice.invoiceDate ?? '-'}</p>
</div>
<div>
<span className="text-muted-foreground">Förfallodatum</span>
<p className="font-medium">{extraction.invoice.dueDate ?? '-'}</p>
</div>
{extraction.invoice.paymentReference && (
<div className="col-span-2">
<span className="text-muted-foreground">Betalningsreferens</span>
<p className="font-medium font-mono">{extraction.invoice.paymentReference}</p>
</div>
)}
</div>
</div>
{/* Line items */}
{extraction.lineItems.length > 0 && (
<>
<Separator />
<div className="space-y-3">
<h4 className="text-sm font-medium">Rader</h4>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Beskrivning</TableHead>
<TableHead className="text-right w-16">Antal</TableHead>
<TableHead className="text-right w-24">À-pris</TableHead>
<TableHead className="text-right w-24">Belopp</TableHead>
<TableHead className="text-right w-16">Moms</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{extraction.lineItems.map((line, i) => (
<TableRow key={i}>
<TableCell className="text-sm">{line.description}</TableCell>
<TableCell className="text-right text-sm">{line.quantity}</TableCell>
<TableCell className="text-right text-sm">
{line.unitPrice != null ? formatAmount(line.unitPrice, currency) : '-'}
</TableCell>
<TableCell className="text-right text-sm font-medium">
{formatAmount(line.lineTotal, currency)}
</TableCell>
<TableCell className="text-right text-sm">
{line.vatRate != null ? `${line.vatRate}%` : '-'}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</>
)}
<Separator />
{/* Totals */}
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Netto</span>
<span>{formatAmount(extraction.totals.subtotal, currency)}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Moms</span>
<span>{formatAmount(extraction.totals.vatAmount, currency)}</span>
</div>
<Separator />
<div className="flex justify-between font-medium text-base">
<span>Totalt</span>
<span>{formatAmount(extraction.totals.total, currency)}</span>
</div>
</div>
</>
)}
<DialogFooter className="gap-2 sm:gap-0">
{canReject && (
<Button
variant="outline"
onClick={() => handleAction('reject')}
disabled={loading !== null}
>
{loading === 'reject' ? (
<Loader2 className="h-4 w-4 animate-spin mr-2" />
) : (
<X className="h-4 w-4 mr-2" />
)}
Avvisa
</Button>
)}
{canReprocess && (
<Button
variant="outline"
onClick={() => handleAction('reprocess')}
disabled={loading !== null}
>
{loading === 'reprocess' ? (
<Loader2 className="h-4 w-4 animate-spin mr-2" />
) : (
<RefreshCw className="h-4 w-4 mr-2" />
)}
Bearbeta igen
</Button>
)}
{canConfirm && (
<Button
onClick={() => handleAction('confirm')}
disabled={loading !== null}
>
{loading === 'confirm' ? (
<Loader2 className="h-4 w-4 animate-spin mr-2" />
) : (
<Check className="h-4 w-4 mr-2" />
)}
Bekräfta
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -1,101 +0,0 @@
'use client'
import type { InvoiceInboxItem } from '@/types'
import type { InvoiceExtractionResult } from '@/extensions/general/invoice-inbox/types'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import {
getStatusLabel,
getStatusVariant,
getConfidenceLabel,
formatExtractionSummary,
} from '@/lib/extensions/invoice-inbox-utils'
import { Mail, Upload, FileText } from 'lucide-react'
interface InboxItemCardProps {
item: InvoiceInboxItem
onClick: () => void
}
function formatRelativeTime(dateStr: string): string {
const now = new Date()
const date = new Date(dateStr)
const diffMs = now.getTime() - date.getTime()
const diffMin = Math.floor(diffMs / 60000)
if (diffMin < 1) return 'Just nu'
if (diffMin < 60) return `${diffMin} min sedan`
const diffH = Math.floor(diffMin / 60)
if (diffH < 24) return `${diffH} tim sedan`
const diffD = Math.floor(diffH / 24)
if (diffD === 1) return 'Igår'
return `${diffD} dagar sedan`
}
function formatAmount(amount: number, currency: string = 'SEK'): string {
return new Intl.NumberFormat('sv-SE', {
style: 'currency',
currency,
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(amount)
}
export default function InboxItemCard({ item, onClick }: InboxItemCardProps) {
const summary = formatExtractionSummary(item.extracted_data as unknown as InvoiceExtractionResult | null)
const confidence = getConfidenceLabel(item.confidence)
const statusVariant = getStatusVariant(item.status) as 'default' | 'secondary' | 'destructive' | 'outline' | 'success' | 'warning'
const confidenceVariant = confidence.variant as 'default' | 'secondary' | 'destructive' | 'outline' | 'success' | 'warning'
const fileName = (item.document as { file_name?: string } | undefined)?.file_name ?? 'Okänd fil'
const supplierName = (item.supplier as { name?: string } | undefined)?.name ?? summary.supplierName
return (
<Card
className="cursor-pointer transition-colors hover:bg-accent/50"
onClick={onClick}
>
<CardContent className="flex items-center gap-4 p-4">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-muted">
{item.source === 'email' ? (
<Mail className="h-5 w-5 text-muted-foreground" />
) : (
<Upload className="h-5 w-5 text-muted-foreground" />
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<FileText className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<span className="text-sm font-medium truncate">{fileName}</span>
</div>
<div className="flex items-center gap-2 mt-0.5">
{supplierName ? (
<span className="text-sm text-muted-foreground truncate">{supplierName}</span>
) : (
<span className="text-sm text-muted-foreground/60 italic">Okänd leverantör</span>
)}
</div>
</div>
<div className="flex flex-col items-end gap-1 shrink-0">
{summary.total > 0 && (
<span className="text-sm font-medium">{formatAmount(summary.total, summary.currency)}</span>
)}
<div className="flex items-center gap-1.5">
{item.confidence != null && (
<Badge variant={confidenceVariant} className="text-[10px] px-1.5 py-0">
{confidence.label}
</Badge>
)}
<Badge variant={statusVariant}>
{getStatusLabel(item.status)}
</Badge>
</div>
<span className="text-[11px] text-muted-foreground">
{formatRelativeTime(item.created_at)}
</span>
</div>
</CardContent>
</Card>
)
}
@@ -1,159 +0,0 @@
'use client'
import { useState } from 'react'
import type { InvoiceInboxSettings } from '@/extensions/general/invoice-inbox/types'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { Loader2, Copy, Check } from 'lucide-react'
interface InboxSettingsDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
settings: InvoiceInboxSettings
onSave: (settings: InvoiceInboxSettings) => Promise<void>
}
export default function InboxSettingsDialog({
open,
onOpenChange,
settings,
onSave,
}: InboxSettingsDialogProps) {
const [local, setLocal] = useState<InvoiceInboxSettings>(settings)
const [saving, setSaving] = useState(false)
const [copied, setCopied] = useState(false)
// Reset local state when dialog opens with new settings
function handleOpenChange(isOpen: boolean) {
if (isOpen) {
setLocal(settings)
}
onOpenChange(isOpen)
}
async function handleSave() {
setSaving(true)
try {
await onSave(local)
onOpenChange(false)
} finally {
setSaving(false)
}
}
function handleCopyEmail() {
if (local.inboxEmail) {
navigator.clipboard.writeText(local.inboxEmail)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Inställningar</DialogTitle>
</DialogHeader>
<div className="space-y-6 py-2">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label>Automatisk bearbetning</Label>
<p className="text-xs text-muted-foreground">
Analysera fakturor automatiskt vid uppladdning
</p>
</div>
<Switch
checked={local.autoProcessEnabled}
onCheckedChange={(checked) =>
setLocal((prev) => ({ ...prev, autoProcessEnabled: checked }))
}
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label>Automatisk leverantörsmatchning</Label>
<p className="text-xs text-muted-foreground">
Matcha extraherade uppgifter mot befintliga leverantörer
</p>
</div>
<Switch
checked={local.autoMatchSupplierEnabled}
onCheckedChange={(checked) =>
setLocal((prev) => ({ ...prev, autoMatchSupplierEnabled: checked }))
}
/>
</div>
<div className="space-y-2">
<Label>Matchningströskel</Label>
<p className="text-xs text-muted-foreground">
Lägsta konfidens för automatisk leverantörsmatchning (0-1)
</p>
<Input
type="number"
min={0}
max={1}
step={0.05}
value={local.supplierMatchThreshold}
onChange={(e) =>
setLocal((prev) => ({
...prev,
supplierMatchThreshold: Math.min(1, Math.max(0, parseFloat(e.target.value) || 0)),
}))
}
/>
</div>
{local.inboxEmail && (
<div className="space-y-2">
<Label>Inkorg-e-post</Label>
<p className="text-xs text-muted-foreground">
Vidarebefodra fakturor till denna adress
</p>
<div className="flex gap-2">
<Input
value={local.inboxEmail}
readOnly
className="font-mono text-xs"
/>
<Button
variant="outline"
size="icon"
onClick={handleCopyEmail}
>
{copied ? (
<Check className="h-4 w-4 text-success" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
</div>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Avbryt
</Button>
<Button onClick={handleSave} disabled={saving}>
{saving && <Loader2 className="h-4 w-4 animate-spin mr-2" />}
Spara
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -1,226 +0,0 @@
'use client'
import { useCallback, useRef, useState } from 'react'
import type { InvoiceInboxItem } from '@/types'
import { Upload, Loader2, FileUp, CheckCircle2, AlertCircle } from 'lucide-react'
import { cn } from '@/lib/utils'
interface InboxUploadZoneProps {
onUploadComplete: (item: InvoiceInboxItem | InvoiceInboxItem[]) => void
isUploading: boolean
setIsUploading: (v: boolean) => void
}
interface FileProgress {
name: string
status: 'pending' | 'uploading' | 'done' | 'error'
error?: string
}
const ACCEPTED_TYPES = ['application/pdf', 'image/jpeg', 'image/png', 'image/webp']
const MAX_SIZE = 10 * 1024 * 1024 // 10 MB
export default function InboxUploadZone({
onUploadComplete,
isUploading,
setIsUploading,
}: InboxUploadZoneProps) {
const [isDragOver, setIsDragOver] = useState(false)
const [error, setError] = useState<string | null>(null)
const [fileProgress, setFileProgress] = useState<FileProgress[]>([])
const inputRef = useRef<HTMLInputElement>(null)
const uploadFiles = useCallback(
async (files: File[]) => {
setError(null)
// Validate all files first
const validFiles: File[] = []
for (const file of files) {
if (!ACCEPTED_TYPES.includes(file.type)) {
setError(`${file.name}: filtypen stöds inte. Välj PDF, JPEG, PNG eller WebP.`)
return
}
if (file.size > MAX_SIZE) {
setError(`${file.name}: filen är för stor. Max 10 MB.`)
return
}
validFiles.push(file)
}
if (validFiles.length === 0) return
setIsUploading(true)
// Show per-file progress for multi-file uploads
if (validFiles.length > 1) {
setFileProgress(validFiles.map((f) => ({ name: f.name, status: 'uploading' })))
}
try {
const formData = new FormData()
if (validFiles.length === 1) {
// Single file: use legacy `file` key for backward compat
formData.append('file', validFiles[0])
} else {
// Multiple files: use `files` key
for (const file of validFiles) {
formData.append('files', file)
}
}
const res = await fetch('/api/extensions/ext/invoice-inbox/inbox', {
method: 'POST',
body: formData,
})
if (!res.ok) {
const body = await res.json().catch(() => ({ error: 'Uppladdning misslyckades' }))
setError(body.error ?? 'Uppladdning misslyckades')
if (validFiles.length > 1) {
setFileProgress((prev) => prev.map((f) => ({ ...f, status: 'error' as const })))
}
return
}
const body = await res.json()
if (validFiles.length === 1) {
onUploadComplete(body.data)
setFileProgress([])
} else {
// Mark individual files
const items: InvoiceInboxItem[] = body.data || []
const errors: string[] = body.errors || []
setFileProgress((prev) =>
prev.map((fp, i) => {
// Check if this file had an error
const errMsg = errors.find((e) => e.startsWith(fp.name))
if (errMsg) {
return { ...fp, status: 'error' as const, error: errMsg }
}
return { ...fp, status: 'done' as const }
})
)
if (items.length > 0) {
onUploadComplete(items)
}
// Clear progress after a delay
setTimeout(() => setFileProgress([]), 3000)
}
} catch {
setError('Nätverksfel vid uppladdning')
setFileProgress([])
} finally {
setIsUploading(false)
}
},
[onUploadComplete, setIsUploading]
)
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault()
setIsDragOver(false)
const files = Array.from(e.dataTransfer.files)
if (files.length > 0) uploadFiles(files)
},
[uploadFiles]
)
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault()
setIsDragOver(true)
}, [])
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault()
setIsDragOver(false)
}, [])
const handleFileSelect = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || [])
if (files.length > 0) uploadFiles(files)
e.target.value = ''
},
[uploadFiles]
)
return (
<div>
<div
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onClick={() => !isUploading && inputRef.current?.click()}
className={cn(
'relative flex flex-col items-center justify-center rounded-lg border-2 border-dashed p-6 transition-colors cursor-pointer',
isDragOver
? 'border-primary bg-primary/5'
: 'border-border hover:border-primary/50 hover:bg-accent/30',
isUploading && 'pointer-events-none opacity-60'
)}
>
<input
ref={inputRef}
type="file"
accept=".pdf,.jpg,.jpeg,.png,.webp"
multiple
className="hidden"
onChange={handleFileSelect}
disabled={isUploading}
/>
{isUploading ? (
<>
<Loader2 className="h-8 w-8 text-primary animate-spin mb-2" />
<p className="text-sm text-muted-foreground">Laddar upp och analyserar...</p>
</>
) : isDragOver ? (
<>
<FileUp className="h-8 w-8 text-primary mb-2" />
<p className="text-sm font-medium text-primary">Släpp filerna här</p>
</>
) : (
<>
<Upload className="h-8 w-8 text-muted-foreground/60 mb-2" />
<p className="text-sm text-muted-foreground">
Dra och släpp fakturor, eller{' '}
<span className="font-medium text-primary">välj filer</span>
</p>
<p className="text-xs text-muted-foreground/60 mt-1">
PDF, JPEG, PNG eller WebP (max 10 MB per fil)
</p>
</>
)}
</div>
{fileProgress.length > 0 && (
<div className="mt-3 space-y-1">
{fileProgress.map((fp) => (
<div key={fp.name} className="flex items-center gap-2 text-sm">
{fp.status === 'uploading' && <Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" />}
{fp.status === 'done' && <CheckCircle2 className="h-3.5 w-3.5 text-success" />}
{fp.status === 'error' && <AlertCircle className="h-3.5 w-3.5 text-destructive" />}
<span className={cn(
'truncate',
fp.status === 'error' && 'text-destructive'
)}>
{fp.name}
</span>
</div>
))}
</div>
)}
{error && (
<p className="text-sm text-destructive mt-2">{error}</p>
)}
</div>
)
}
+2 -6
View File
@@ -81,18 +81,14 @@ export default function SIEPreviewStep({
<div>
<p className="text-sm text-muted-foreground">Start</p>
<p className="font-medium">
{preview.fiscalYearStart
? new Date(preview.fiscalYearStart).toLocaleDateString('sv-SE')
: 'Okänt'}
{preview.fiscalYearStart ?? 'Okänt'}
</p>
</div>
<ArrowRight className="h-4 w-4 text-muted-foreground" />
<div>
<p className="text-sm text-muted-foreground">Slut</p>
<p className="font-medium">
{preview.fiscalYearEnd
? new Date(preview.fiscalYearEnd).toLocaleDateString('sv-SE')
: 'Okänt'}
{preview.fiscalYearEnd ?? 'Okänt'}
</p>
</div>
</div>
+140
View File
@@ -0,0 +1,140 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { AlertTriangle, ExternalLink, Loader2 } from 'lucide-react'
import { SupportLink } from '@/components/ui/support-link'
export function AccountDangerZone() {
const router = useRouter()
const [showDialog, setShowDialog] = useState(false)
const [confirmText, setConfirmText] = useState('')
const [isDeleting, setIsDeleting] = useState(false)
async function handleDelete() {
if (confirmText !== 'RADERA') return
setIsDeleting(true)
try {
const response = await fetch('/api/account/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ confirm: 'RADERA' }),
})
if (!response.ok) {
const result = await response.json()
throw new Error(result.error || 'Kunde inte radera kontot')
}
router.push('/login')
} catch {
setIsDeleting(false)
}
}
return (
<>
<section className="space-y-4 border-t border-border/8 pt-8">
<h2 className="text-sm font-medium uppercase tracking-wider text-destructive/80">
Radera konto
</h2>
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4">
<div className="flex gap-3">
<AlertTriangle className="h-5 w-5 text-destructive shrink-0 mt-0.5" />
<div className="space-y-2 text-sm">
<p className="font-medium text-destructive">Denna åtgärd kan inte ångras</p>
<p className="text-muted-foreground">
All din data raderas permanent bokföring, fakturor, verifikationer, dokument och inställningar.
</p>
<p className="text-muted-foreground">
Enligt bokföringslagen (BFL 7 kap. 2§) ska räkenskapsinformation bevaras i 7 år.
Du ansvarar själv för att exportera och arkivera din bokföringsdata.
</p>
<p className="text-muted-foreground">
Har du frågor?{' '}
<SupportLink variant="inline" subject="Fråga om kontoradering" />
</p>
</div>
</div>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<Button variant="outline" className="w-full sm:w-auto" asChild>
<Link href="/reports?type=sie">
<ExternalLink className="mr-2 h-4 w-4" />
Exportera bokföringsdata (SIE)
</Link>
</Button>
<Button
variant="destructive"
className="w-full sm:w-auto"
onClick={() => setShowDialog(true)}
>
Radera mitt konto
</Button>
</div>
</section>
<Dialog open={showDialog} onOpenChange={(open) => {
setShowDialog(open)
if (!open) setConfirmText('')
}}>
<DialogContent>
<DialogHeader>
<DialogTitle>Radera konto permanent</DialogTitle>
<DialogDescription>
All din data raderas permanent. Skriv <strong>RADERA</strong> nedan för att bekräfta.
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="delete-confirm">Bekräfta genom att skriva RADERA</Label>
<Input
id="delete-confirm"
value={confirmText}
onChange={(e) => setConfirmText(e.target.value)}
placeholder="RADERA"
autoComplete="off"
/>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => { setShowDialog(false); setConfirmText('') }}
disabled={isDeleting}
>
Avbryt
</Button>
<Button
variant="destructive"
onClick={handleDelete}
disabled={confirmText !== 'RADERA' || isDeleting}
>
{isDeleting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Raderar...
</>
) : (
'Radera permanent'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}
+121
View File
@@ -0,0 +1,121 @@
'use client'
import { useState } from 'react'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { BankNameCombobox } from '@/components/settings/BankNameCombobox'
import { validateBankgiroNumber, formatBankgiroNumber } from '@/lib/bankgiro/luhn'
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
import type { CompanySettings } from '@/types'
interface BankDetailsFormProps {
settings: CompanySettings
}
export function BankDetailsForm({ settings }: BankDetailsFormProps) {
const [bankgiroError, setBankgiroError] = useState<string | null>(null)
const [clearingError, setClearingError] = useState<string | null>(null)
const [accountNumberError, setAccountNumberError] = useState<string | null>(null)
const hasBankingExtension = ENABLED_EXTENSION_IDS.has('enable-banking')
return (
<section className="space-y-4">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
Bankuppgifter
</h2>
<p className="text-xs text-muted-foreground -mt-2">
Visas dina fakturor
</p>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="space-y-2">
<Label>Bank</Label>
<BankNameCombobox
defaultValue={settings.bank_name || ''}
enableBankingEnabled={hasBankingExtension}
/>
</div>
<div className="space-y-2">
<Label htmlFor="clearing_number">Clearing</Label>
<Input
id="clearing_number"
name="clearing_number"
inputMode="numeric"
placeholder="XXXX"
maxLength={5}
defaultValue={settings.clearing_number || ''}
onChange={(e) => {
e.target.value = e.target.value.replace(/\D/g, '')
}}
onBlur={(e) => {
const val = e.target.value.trim()
if (!val) { setClearingError(null); return }
setClearingError(!/^\d{4,5}$/.test(val) ? 'Måste vara 4-5 siffror' : null)
}}
/>
{clearingError && <p className="text-xs text-destructive">{clearingError}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="account_number">Kontonummer</Label>
<Input
id="account_number"
name="account_number"
inputMode="numeric"
placeholder="XXXXXXX"
maxLength={12}
defaultValue={settings.account_number || ''}
onChange={(e) => {
e.target.value = e.target.value.replace(/\D/g, '')
}}
onBlur={(e) => {
const val = e.target.value.trim()
if (!val) { setAccountNumberError(null); return }
setAccountNumberError(!/^\d{6,12}$/.test(val) ? 'Måste vara 6-12 siffror' : null)
}}
/>
{accountNumberError && <p className="text-xs text-destructive">{accountNumberError}</p>}
</div>
</div>
<div className="max-w-xs space-y-2">
<Label htmlFor="bankgiro">Bankgiro</Label>
<Input
id="bankgiro"
name="bankgiro"
placeholder="XXX-XXXX"
defaultValue={settings.bankgiro || ''}
onBlur={(e) => {
const val = e.target.value.trim()
if (!val) { setBankgiroError(null); return }
if (validateBankgiroNumber(val)) {
e.target.value = formatBankgiroNumber(val)
setBankgiroError(null)
} else {
setBankgiroError('Ogiltigt bankgironummer')
}
}}
/>
{bankgiroError && <p className="text-xs text-destructive">{bankgiroError}</p>}
</div>
</section>
)
}
/** Validate bank fields from FormData. Returns error messages or null. */
export function validateBankFields(formData: FormData): { field: string; message: string }[] {
const errors: { field: string; message: string }[] = []
const clearing = (formData.get('clearing_number') as string || '').trim()
const account = (formData.get('account_number') as string || '').trim()
const bankgiro = (formData.get('bankgiro') as string || '').trim()
if (clearing && !/^\d{4,5}$/.test(clearing)) {
errors.push({ field: 'clearing_number', message: 'Clearingnummer måste vara 4-5 siffror' })
}
if (account && !/^\d{6,12}$/.test(account)) {
errors.push({ field: 'account_number', message: 'Kontonummer måste vara 6-12 siffror' })
}
if (bankgiro && !validateBankgiroNumber(bankgiro)) {
errors.push({ field: 'bankgiro', message: 'Ogiltigt bankgironummer' })
}
return errors
}
+105
View File
@@ -0,0 +1,105 @@
'use client'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import type { CompanySettings } from '@/types'
interface CompanyInfoFormProps {
settings: CompanySettings
}
export function CompanyInfoForm({ settings }: CompanyInfoFormProps) {
return (
<section className="space-y-4">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
Företagsuppgifter
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="company_name">Företagsnamn</Label>
<Input
id="company_name"
name="company_name"
defaultValue={settings.company_name || ''}
disabled={settings.onboarding_complete === true}
/>
{settings.onboarding_complete && (
<p className="text-xs text-muted-foreground">Kan inte ändras efter att kontot skapats</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="org_number">Organisationsnummer</Label>
<Input
id="org_number"
name="org_number"
defaultValue={settings.org_number || ''}
disabled={settings.onboarding_complete === true}
/>
{settings.onboarding_complete && (
<p className="text-xs text-muted-foreground">Kan inte ändras efter att kontot skapats</p>
)}
</div>
</div>
<div className="space-y-2">
<Label htmlFor="address_line1">Adress</Label>
<Input
id="address_line1"
name="address_line1"
defaultValue={settings.address_line1 || ''}
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="postal_code">Postnummer</Label>
<Input
id="postal_code"
name="postal_code"
defaultValue={settings.postal_code || ''}
/>
</div>
<div className="space-y-2">
<Label htmlFor="city">Ort</Label>
<Input
id="city"
name="city"
defaultValue={settings.city || ''}
/>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="phone">Telefon</Label>
<Input
id="phone"
name="phone"
type="tel"
defaultValue={settings.phone || ''}
/>
</div>
<div className="space-y-2">
<Label htmlFor="email">E-post</Label>
<Input
id="email"
name="email"
type="email"
defaultValue={settings.email || ''}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="website">Webbplats</Label>
<Input
id="website"
name="website"
defaultValue={settings.website || ''}
placeholder="https://"
/>
</div>
</section>
)
}
@@ -0,0 +1,66 @@
'use client'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import type { CompanySettings } from '@/types'
interface InvoiceSettingsFormProps {
settings: CompanySettings
}
export function InvoiceSettingsForm({ settings }: InvoiceSettingsFormProps) {
return (
<section className="space-y-4">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
Fakturainställningar
</h2>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 items-end">
<div className="space-y-2">
<Label htmlFor="invoice_prefix">Fakturaprefix</Label>
<Input
id="invoice_prefix"
name="invoice_prefix"
placeholder="t.ex. F-"
defaultValue={settings.invoice_prefix || ''}
/>
</div>
<div className="space-y-2">
<Label htmlFor="next_invoice_number">Nästa fakturanummer</Label>
<Input
id="next_invoice_number"
name="next_invoice_number"
type="number"
min="1"
defaultValue={settings.next_invoice_number || 1}
/>
</div>
<div className="space-y-2">
<Label htmlFor="invoice_default_days">Betalningsvillkor (dagar)</Label>
<Input
id="invoice_default_days"
name="invoice_default_days"
type="number"
min="0"
defaultValue={settings.invoice_default_days || 30}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="invoice_default_notes">Standardtext fakturor</Label>
<Textarea
id="invoice_default_notes"
name="invoice_default_notes"
rows={3}
placeholder="T.ex. betalningsvillkor, leveransinfo..."
defaultValue={settings.invoice_default_notes || ''}
/>
<p className="text-xs text-muted-foreground">
Föreslås automatiskt vid ny faktura.
</p>
</div>
</section>
)
}
+149
View File
@@ -0,0 +1,149 @@
'use client'
import { useState, useRef } from 'react'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { useToast } from '@/components/ui/use-toast'
import { Loader2, Upload, Trash2 } from 'lucide-react'
interface LogoUploadProps {
logoUrl: string | null
onUpdate: (logoUrl: string | null) => void
}
export function LogoUpload({ logoUrl, onUpdate }: LogoUploadProps) {
const { toast } = useToast()
const [isUploading, setIsUploading] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const [preview, setPreview] = useState<string | null>(logoUrl)
const inputRef = useRef<HTMLInputElement>(null)
async function handleUpload(file: File) {
setIsUploading(true)
const formData = new FormData()
formData.append('file', file)
try {
const response = await fetch('/api/settings/logo', {
method: 'POST',
body: formData,
})
const result = await response.json()
if (!response.ok) {
throw new Error(result.error || 'Uppladdning misslyckades')
}
setPreview(result.data.logo_url)
onUpdate(result.data.logo_url)
} catch (error) {
toast({
title: 'Kunde inte ladda upp',
description: error instanceof Error ? error.message : 'Försök igen.',
variant: 'destructive',
})
}
setIsUploading(false)
}
async function handleDelete() {
setIsDeleting(true)
try {
const response = await fetch('/api/settings/logo', { method: 'DELETE' })
if (!response.ok) throw new Error()
setPreview(null)
onUpdate(null)
if (inputRef.current) inputRef.current.value = ''
} catch {
toast({ title: 'Kunde inte ta bort logotyp', variant: 'destructive' })
}
setIsDeleting(false)
}
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
if (!file) return
if (file.size > 2 * 1024 * 1024) {
toast({ title: 'Filen är för stor (max 2 MB)', variant: 'destructive' })
return
}
handleUpload(file)
}
return (
<section className="space-y-4">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
Logotyp
</h2>
<p className="text-xs text-muted-foreground -mt-2">
Visas i sidhuvudet dina fakturor. Max 2 MB, PNG/JPG/SVG.
</p>
{preview ? (
<div className="space-y-3">
<div className="inline-block rounded-lg border border-border/60 bg-muted/30 p-4">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={preview}
alt="Företagslogotyp"
className="max-h-16 max-w-[200px] object-contain"
/>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => inputRef.current?.click()}
disabled={isUploading}
>
{isUploading ? <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" /> : <Upload className="mr-2 h-3.5 w-3.5" />}
Byt logotyp
</Button>
<Button
variant="ghost"
size="sm"
onClick={handleDelete}
disabled={isDeleting}
className="text-muted-foreground hover:text-destructive"
>
{isDeleting ? <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" /> : <Trash2 className="mr-2 h-3.5 w-3.5" />}
Ta bort
</Button>
</div>
</div>
) : (
<button
type="button"
onClick={() => inputRef.current?.click()}
disabled={isUploading}
className="flex flex-col items-center justify-center w-full max-w-xs rounded-lg border-2 border-dashed border-border/60 py-8 px-4 text-center transition-colors hover:border-border hover:bg-muted/20 disabled:opacity-50"
>
{isUploading ? (
<Loader2 className="h-6 w-6 text-muted-foreground animate-spin mb-2" />
) : (
<Upload className="h-6 w-6 text-muted-foreground/50 mb-2" />
)}
<Label className="text-sm text-muted-foreground cursor-pointer">
{isUploading ? 'Laddar upp...' : 'Välj fil eller dra hit'}
</Label>
</button>
)}
<input
ref={inputRef}
type="file"
accept="image/png,image/jpeg,image/svg+xml,image/webp"
className="hidden"
onChange={handleFileChange}
/>
</section>
)
}
+127
View File
@@ -0,0 +1,127 @@
'use client'
import { useState, useCallback } from 'react'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
import { useToast } from '@/components/ui/use-toast'
import type { CompanySettings } from '@/types'
interface PdfPrintSettingsProps {
settings: CompanySettings
onUpdate: (updates: Partial<CompanySettings>) => void
}
export function PdfPrintSettings({ settings, onUpdate }: PdfPrintSettingsProps) {
const { toast } = useToast()
const [lateFeeText, setLateFeeText] = useState(settings.invoice_late_fee_text || '')
const [creditTermsText, setCreditTermsText] = useState(settings.invoice_credit_terms_text || '')
const saveToggle = useCallback(async (field: string, value: boolean) => {
try {
const response = await fetch('/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ [field]: value }),
})
if (!response.ok) throw new Error()
onUpdate({ [field]: value } as Partial<CompanySettings>)
} catch {
toast({ title: 'Kunde inte spara', variant: 'destructive' })
}
}, [onUpdate, toast])
const saveText = useCallback(async (field: string, value: string) => {
try {
const response = await fetch('/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ [field]: value || null }),
})
if (!response.ok) throw new Error()
onUpdate({ [field]: value || null } as Partial<CompanySettings>)
} catch {
toast({ title: 'Kunde inte spara', variant: 'destructive' })
}
}, [onUpdate, toast])
return (
<section className="space-y-6">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
Utskrift & PDF
</h2>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<Label>Öresavrundning</Label>
<p className="text-xs text-muted-foreground">Avrunda fakturatotal till hel krona</p>
</div>
<Switch
checked={settings.ore_rounding ?? true}
onCheckedChange={(v) => saveToggle('ore_rounding', v)}
/>
</div>
<div className="flex items-center justify-between">
<div>
<Label>Visa OCR-referens</Label>
<p className="text-xs text-muted-foreground">Visa OCR-nummer fakturautskrift</p>
</div>
<Switch
checked={settings.invoice_show_ocr ?? true}
onCheckedChange={(v) => saveToggle('invoice_show_ocr', v)}
/>
</div>
<div className="flex items-center justify-between">
<div>
<Label>Visa bankgiro</Label>
<p className="text-xs text-muted-foreground">Visa bankgironummer fakturautskrift</p>
</div>
<Switch
checked={settings.invoice_show_bankgiro ?? true}
onCheckedChange={(v) => saveToggle('invoice_show_bankgiro', v)}
/>
</div>
<div className="flex items-center justify-between">
<div>
<Label>Visa plusgiro</Label>
<p className="text-xs text-muted-foreground">Visa plusgironummer fakturautskrift</p>
</div>
<Switch
checked={settings.invoice_show_plusgiro ?? true}
onCheckedChange={(v) => saveToggle('invoice_show_plusgiro', v)}
/>
</div>
</div>
<div className="space-y-4 pt-2">
<div className="space-y-2">
<Label htmlFor="invoice_late_fee_text">Dröjsmålsränta</Label>
<Textarea
id="invoice_late_fee_text"
rows={2}
placeholder="T.ex. Vid betalning efter förfallodagen debiteras ränta enligt räntelagen."
value={lateFeeText}
onChange={(e) => setLateFeeText(e.target.value)}
onBlur={() => saveText('invoice_late_fee_text', lateFeeText)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="invoice_credit_terms_text">Betalningsvillkor (fotnot)</Label>
<Textarea
id="invoice_credit_terms_text"
rows={2}
placeholder="T.ex. Betalning sker till angivet bankgiro."
value={creditTermsText}
onChange={(e) => setCreditTermsText(e.target.value)}
onBlur={() => saveText('invoice_credit_terms_text', creditTermsText)}
/>
</div>
</div>
</section>
)
}
@@ -0,0 +1,56 @@
'use client'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import type { CompanySettings } from '@/types'
interface PeriodLockingSettingsProps {
settings: CompanySettings
}
export function PeriodLockingSettings({ settings }: PeriodLockingSettingsProps) {
return (
<section className="space-y-4">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
Periodlåsning
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
<div className="space-y-2">
<Label htmlFor="bookkeeping_locked_through">Bokföring låst t.o.m.</Label>
<Input
id="bookkeeping_locked_through"
name="bookkeeping_locked_through"
type="date"
defaultValue={settings.bookkeeping_locked_through || ''}
/>
<p className="text-xs text-muted-foreground">
Verifikationer med datum före detta datum kan inte skapas eller ändras.
</p>
</div>
<div className="space-y-2">
<Label htmlFor="auto_lock_period_days">Automatisk låsning efter</Label>
<Select
name="auto_lock_period_days"
defaultValue={settings.auto_lock_period_days?.toString() || 'none'}
>
<SelectTrigger id="auto_lock_period_days">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">Ingen automatisk låsning</SelectItem>
<SelectItem value="30">30 dagar efter periodens slut</SelectItem>
<SelectItem value="60">60 dagar efter periodens slut</SelectItem>
<SelectItem value="90">90 dagar efter periodens slut</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Låser automatiskt perioder efter valt antal dagar.
</p>
</div>
</div>
</section>
)
}
+1 -1
View File
@@ -254,7 +254,7 @@ export function SecuritySettings() {
</div>
</div>
<Button
onClick={() => router.push(`/mfa/enroll?returnTo=${encodeURIComponent('/settings?tab=security')}`)}
onClick={() => router.push(`/mfa/enroll?returnTo=${encodeURIComponent('/settings/account')}`)}
>
<ShieldCheck className="mr-2 h-4 w-4" />
Aktivera 2FA
@@ -0,0 +1,88 @@
'use client'
import { useState, useRef, useEffect, useCallback } from 'react'
import { Button } from '@/components/ui/button'
import { Loader2, Check } from 'lucide-react'
import { useToast } from '@/components/ui/use-toast'
interface SettingsFormWrapperProps {
children: React.ReactNode
onSave?: (formData: FormData) => Record<string, unknown>
className?: string
}
export function SettingsFormWrapper({ children, onSave, className }: SettingsFormWrapperProps) {
const { toast } = useToast()
const [isSaving, setIsSaving] = useState(false)
const [saved, setSaved] = useState(false)
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined)
useEffect(() => {
return () => {
if (timerRef.current) clearTimeout(timerRef.current)
}
}, [])
const handleSubmit = useCallback(async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
if (!onSave) return
const formData = new FormData(e.currentTarget)
const updates = onSave(formData)
if (!updates || Object.keys(updates).length === 0) return
setIsSaving(true)
setSaved(false)
try {
const response = await fetch('/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates),
})
const result = await response.json()
if (!response.ok) {
throw new Error(result.error || 'Kunde inte spara inställningar')
}
setSaved(true)
timerRef.current = setTimeout(() => setSaved(false), 2000)
} catch (error) {
toast({
title: 'Kunde inte spara',
description: error instanceof Error ? error.message : 'Försök igen.',
variant: 'destructive',
})
}
setIsSaving(false)
}, [onSave, toast])
return (
<form onSubmit={handleSubmit} className={className}>
{children}
<div className="flex items-center justify-end gap-3 mt-8">
{saved && (
<span className="flex items-center gap-1.5 text-sm text-muted-foreground animate-in fade-in duration-200">
<Check className="h-3.5 w-3.5" />
Sparat
</span>
)}
<Button type="submit" disabled={isSaving} size="sm">
{isSaving ? (
<>
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
Sparar...
</>
) : (
'Spara ändringar'
)}
</Button>
</div>
</form>
)
}
@@ -0,0 +1,25 @@
export function SettingsLoadingSkeleton() {
return (
<div className="space-y-8 animate-in fade-in duration-300">
{[1, 2].map(i => (
<div key={i} className="space-y-4">
<div className="h-3.5 bg-muted rounded w-24 animate-pulse" />
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<div className="h-3.5 bg-muted rounded w-20 animate-pulse" />
<div className="h-10 bg-muted rounded animate-pulse" />
</div>
<div className="space-y-2">
<div className="h-3.5 bg-muted rounded w-28 animate-pulse" />
<div className="h-10 bg-muted rounded animate-pulse" />
</div>
</div>
<div className="space-y-2">
<div className="h-3.5 bg-muted rounded w-16 animate-pulse" />
<div className="h-10 bg-muted rounded animate-pulse" />
</div>
</div>
))}
</div>
)
}
+87
View File
@@ -0,0 +1,87 @@
'use client'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { useRouter } from 'next/navigation'
import { useCompany } from '@/contexts/CompanyContext'
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
interface NavItem {
href: string
label: string
show: boolean
}
export function SettingsNav({ isSandbox }: { isSandbox?: boolean }) {
const pathname = usePathname()
const router = useRouter()
const { company, isTeamMember } = useCompany()
const hasCompany = !!company
const hasBankingExtension = ENABLED_EXTENSION_IDS.has('enable-banking')
const hasMcpExtension = ENABLED_EXTENSION_IDS.has('mcp-server')
const items: NavItem[] = [
{ href: '/settings/company', label: 'Företag', show: hasCompany },
{ href: '/settings/invoicing', label: 'Fakturering', show: hasCompany },
{ href: '/settings/bookkeeping', label: 'Bokföring', show: hasCompany },
{ href: '/settings/tax', label: 'Skatt', show: hasCompany },
{ href: '/settings/team', label: 'Lag', show: isTeamMember },
{ href: '/settings/banking', label: 'Bank (PSD2)', show: hasCompany && !isSandbox && hasBankingExtension },
{ href: '/settings/templates', label: 'Mallar', show: hasCompany },
{ href: '/settings/account', label: 'Konto', show: true },
{ href: '/settings/api', label: 'API', show: hasCompany && hasMcpExtension },
].filter(item => item.show)
const activeHref = items.find(item => pathname.startsWith(item.href))?.href || items[0]?.href
return (
<>
{/* Mobile: select dropdown */}
<div className="sm:hidden">
<Select value={activeHref} onValueChange={(v) => router.push(v)}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{items.map(item => (
<SelectItem key={item.href} value={item.href}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Desktop: horizontal tabs with bottom border */}
<nav
className="hidden sm:block overflow-x-auto scrollbar-none border-b border-border"
aria-label="Inställningar"
>
<ul className="flex gap-0 -mb-px">
{items.map(item => {
const isActive = pathname.startsWith(item.href)
return (
<li key={item.href}>
<Link
href={item.href}
className={`block whitespace-nowrap px-3 py-2 text-sm transition-colors border-b-2 ${
isActive
? 'font-medium text-foreground border-foreground'
: 'text-muted-foreground border-transparent hover:text-foreground hover:border-border'
}`}
>
{item.label}
</Link>
</li>
)
})}
</ul>
</nav>
</>
)
}
// Keep old name as alias for backward compat during transition
export const SettingsSidebar = SettingsNav
+34
View File
@@ -0,0 +1,34 @@
'use client'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import type { CompanySettings } from '@/types'
interface TaxSettingsFormProps {
settings: CompanySettings
}
export function TaxSettingsForm({ settings }: TaxSettingsFormProps) {
return (
<section className="space-y-4">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
Preliminärskatt
</h2>
<div className="max-w-xs space-y-2">
<Label htmlFor="preliminary_tax_monthly">
Månatlig preliminärskatt (F-skatt)
</Label>
<Input
id="preliminary_tax_monthly"
name="preliminary_tax_monthly"
type="number"
defaultValue={settings.preliminary_tax_monthly || ''}
/>
<p className="text-xs text-muted-foreground">
Belopp i SEK som betalas varje månad.
</p>
</div>
</section>
)
}
@@ -0,0 +1,74 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { createClient } from '@/lib/supabase/client'
import { useCompany } from '@/contexts/CompanyContext'
import { Label } from '@/components/ui/label'
interface VoucherSeries {
voucher_series: string
last_number: number
fiscal_period_id: string
}
export function VoucherSeriesManager() {
const { company } = useCompany()
const [series, setSeries] = useState<VoucherSeries[]>([])
const [isLoading, setIsLoading] = useState(true)
const fetchSeries = useCallback(async () => {
if (!company?.id) return
const supabase = createClient()
const { data } = await supabase
.from('voucher_sequences')
.select('voucher_series, last_number, fiscal_period_id')
.eq('company_id', company.id)
.order('voucher_series')
setSeries(data || [])
setIsLoading(false)
}, [company?.id])
useEffect(() => { fetchSeries() }, [fetchSeries])
// Group by series letter, show the highest last_number
const grouped = series.reduce<Record<string, number>>((acc, s) => {
const existing = acc[s.voucher_series] || 0
acc[s.voucher_series] = Math.max(existing, s.last_number)
return acc
}, {})
const seriesEntries = Object.entries(grouped).sort(([a], [b]) => a.localeCompare(b))
return (
<section className="space-y-4">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
Verifikationsserier
</h2>
{isLoading ? (
<div className="space-y-2">
<div className="h-4 bg-muted rounded w-32 animate-pulse" />
<div className="h-4 bg-muted rounded w-24 animate-pulse" />
</div>
) : seriesEntries.length === 0 ? (
<p className="text-sm text-muted-foreground">
Inga verifikationsserier ännu. Serie A skapas automatiskt vid första verifikationen.
</p>
) : (
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">Aktiva serier</Label>
<div className="divide-y divide-border/8">
{seriesEntries.map(([letter, lastNum]) => (
<div key={letter} className="flex items-center justify-between py-2">
<span className="text-sm font-medium tabular-nums">Serie {letter}</span>
<span className="text-sm text-muted-foreground tabular-nums">
Senaste nr: {lastNum}
</span>
</div>
))}
</div>
</div>
)}
</section>
)
}
+41
View File
@@ -0,0 +1,41 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { useRouter } from 'next/navigation'
import { createClient } from '@/lib/supabase/client'
import { useCompany } from '@/contexts/CompanyContext'
import type { CompanySettings } from '@/types'
export function useSettings() {
const router = useRouter()
const { company } = useCompany()
const [settings, setSettings] = useState<CompanySettings | null>(null)
const [isLoading, setIsLoading] = useState(true)
const fetchSettings = useCallback(async () => {
const supabase = createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) { router.push('/login'); return }
if (company?.id) {
const { data } = await supabase
.from('company_settings')
.select('*')
.eq('company_id', company.id)
.single()
setSettings(data)
}
setIsLoading(false)
}, [company?.id, router])
useEffect(() => {
fetchSettings()
}, [fetchSettings])
const updateSettings = useCallback((updates: Partial<CompanySettings>) => {
setSettings(prev => prev ? { ...prev, ...updates } as CompanySettings : null)
}, [])
return { settings, isLoading, updateSettings, refetch: fetchSettings }
}
@@ -21,7 +21,6 @@ import DescribeTransactionDialog from './DescribeTransactionDialog'
import { formatAccountWithName } from '@/lib/bookkeeping/client-account-names'
import type { TransactionCategory, VatTreatment, BASAccount, EntityType } from '@/types'
import type { SuggestedCategory, SuggestedTemplate } from '@/lib/transactions/category-suggestions'
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
import type { TransactionWithInvoice, CategorizeHandler, MatchInvoiceHandler } from './transaction-types'
import { EXPENSE_CATEGORIES, INCOME_CATEGORIES, VAT_TREATMENT_OPTIONS } from './transaction-types'
@@ -848,18 +847,16 @@ export default function SwipeCategorizationView({
</div>
)}
{/* Describe transaction button — only when AI categorization is enabled */}
{ENABLED_EXTENSION_IDS.has('ai-categorization') && (
<Button
variant="outline"
className="w-full"
onClick={() => setShowDescribeDialog(true)}
disabled={isProcessing}
>
<MessageSquareText className="mr-2 h-4 w-4" />
Beskriv transaktion...
</Button>
)}
{/* Describe transaction button */}
<Button
variant="outline"
className="w-full"
onClick={() => setShowDescribeDialog(true)}
disabled={isProcessing}
>
<MessageSquareText className="mr-2 h-4 w-4" />
Beskriv transaktion...
</Button>
{/* Categorization button */}
<Button
@@ -1,161 +0,0 @@
import { NextResponse } from 'next/server'
import type { ApiRouteDefinition, ExtensionContext } from '@/lib/extensions/types'
import type { CategorizationSuggestion } from './categorizer'
import { categorizeTransactions, getSettings, saveSettings } from './index'
// ============================================================
// /suggestions — GET: fetch stored suggestions
// ============================================================
async function handleGetSuggestions(
request: Request,
ctx?: ExtensionContext
): Promise<Response> {
const userId = ctx!.userId
const { createClient } = await import('@/lib/supabase/server')
const supabase = await createClient()
const { searchParams } = new URL(request.url)
const idsParam = searchParams.get('transaction_ids')
if (!idsParam) {
return NextResponse.json({ error: 'transaction_ids is required' }, { status: 400 })
}
const transactionIds = idsParam.split(',').filter(Boolean).slice(0, 50)
// Read stored suggestions from extension_data
const keys = transactionIds.map((id) => `suggestion:${id}`)
const { data: records } = await supabase
.from('extension_data')
.select('key, value')
.eq('company_id', userId)
.eq('extension_id', 'ai-categorization')
.in('key', keys)
const suggestions: Record<string, CategorizationSuggestion> = {}
if (records) {
for (const record of records) {
const txId = record.key.replace('suggestion:', '')
suggestions[txId] = record.value as unknown as CategorizationSuggestion
}
}
return NextResponse.json({ suggestions })
}
// ============================================================
// /suggestions — POST: trigger on-demand categorization
// ============================================================
async function handlePostSuggestions(
request: Request,
ctx?: ExtensionContext
): Promise<Response> {
const userId = ctx!.userId
const body = await request.json()
const { transaction_ids } = body
if (!Array.isArray(transaction_ids) || transaction_ids.length === 0) {
return NextResponse.json({ error: 'transaction_ids is required' }, { status: 400 })
}
const ids = transaction_ids.slice(0, 50)
try {
const suggestions = await categorizeTransactions(userId, ids)
// Group by transaction ID
const grouped: Record<string, CategorizationSuggestion> = {}
for (const s of suggestions) {
grouped[s.transactionId] = s
}
return NextResponse.json({ suggestions: grouped })
} catch (error) {
console.error('[ai-categorization] On-demand categorization failed:', error)
return NextResponse.json(
{ error: 'AI categorization failed' },
{ status: 500 }
)
}
}
// ============================================================
// /settings — GET: get current settings
// ============================================================
async function handleGetSettings(
_request: Request,
ctx?: ExtensionContext
): Promise<Response> {
const userId = ctx!.userId
const settings = await getSettings(userId)
return NextResponse.json({ data: settings })
}
// ============================================================
// /settings — PUT/PATCH: update settings
// ============================================================
async function handleUpdateSettings(
request: Request,
ctx?: ExtensionContext
): Promise<Response> {
const userId = ctx!.userId
const body = await request.json()
// Validate setting keys
const allowedKeys = [
'autoSuggestEnabled',
'confidenceThreshold',
'providerModel',
]
const filtered: Record<string, unknown> = {}
for (const key of allowedKeys) {
if (key in body) {
filtered[key] = body[key]
}
}
if (Object.keys(filtered).length === 0) {
return NextResponse.json({ error: 'No valid settings provided' }, { status: 400 })
}
const settings = await saveSettings(userId, filtered)
return NextResponse.json({ data: settings })
}
// ============================================================
// Route definitions
// ============================================================
export const aiCategorizationApiRoutes: ApiRouteDefinition[] = [
{
method: 'GET',
path: '/suggestions',
handler: handleGetSuggestions,
},
{
method: 'POST',
path: '/suggestions',
handler: handlePostSuggestions,
},
{
method: 'GET',
path: '/settings',
handler: handleGetSettings,
},
{
method: 'PUT',
path: '/settings',
handler: handleUpdateSettings,
},
{
method: 'PATCH',
path: '/settings',
handler: handleUpdateSettings,
},
]
@@ -1,465 +0,0 @@
/**
* AI Categorization Engine
*
* SERVER-ONLY: Uses the Anthropic SDK and must only be imported
* in server components or API routes.
*
* Provider-abstracted AI categorization for Swedish BAS account mapping.
* Uses Claude Haiku with structured tool outputs for reliable JSON.
* Accepts pre-filtered candidate templates from embedding search (Tier 2)
* instead of dumping all ~100 templates into the prompt.
*/
import 'server-only'
import Anthropic from '@anthropic-ai/sdk'
import { BOOKING_TEMPLATES, type BookingTemplate } from '@/lib/bookkeeping/booking-templates'
import type { TransactionCategory, EntityType } from '@/types'
import type { SupabaseClient } from '@supabase/supabase-js'
// ============================================================
// Types
// ============================================================
export interface TransactionForCategorization {
id: string
description: string
amount: number
date: string
merchant_name: string | null
mcc_code: number | null
currency: string
}
export interface AccountUsageEntry {
account_number: string
count: number
}
export interface MerchantHistoryEntry {
merchant_name: string
category: string
template_id: string | null
count: number
}
export interface CategorizationContext {
entityType: EntityType
recentHistory: { description: string; category: string }[]
}
export interface DocumentEnrichment {
type: 'receipt' | 'supplier_invoice'
merchantName?: string
lineItems?: Array<{ description: string; amount: number; category?: string; accountSuggestion?: string }>
vatBreakdown?: Array<{ rate: number; amount: number }>
isReverseCharge?: boolean
}
export interface EnrichedCategorizationContext extends CategorizationContext {
candidateTemplates: BookingTemplate[]
userAccountUsage: AccountUsageEntry[]
merchantHistory: MerchantHistoryEntry[]
documentData?: DocumentEnrichment
}
export interface CategorizationSuggestion {
transactionId: string
category: TransactionCategory
basAccount: string
taxCode: string | null
confidence: number
reasoning: string
isPrivate: boolean
templateId?: string
}
export interface TrackingContext {
supabase: SupabaseClient
userId: string
companyId?: string
}
export interface CategorizationProvider {
categorize(
transactions: TransactionForCategorization[],
context: CategorizationContext | EnrichedCategorizationContext,
tracking?: TrackingContext
): Promise<CategorizationSuggestion[]>
}
// ============================================================
// BAS Account + Category Mapping (used in prompt)
// ============================================================
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_representation: { account: '6071', label: 'Representation (mat/möte)' },
expense_consumables: { account: '5460', label: 'Förbrukningsvaror' },
expense_vehicle: { account: '5611', label: 'Bil & drivmedel' },
expense_telecom: { account: '6200', label: 'Telefon & internet' },
expense_bank_fees: { account: '6570', label: 'Bankavgifter' },
expense_card_fees: { account: '6570', label: 'Kortavgifter' },
expense_currency_exchange: { account: '7960', label: 'Valutakursförluster' },
expense_interest: { account: '8410', label: 'Räntekostnader' },
financial_loan_repayment: { account: '2350', label: 'Amortering lån (kreditinstitut)' },
expense_other: { account: '6991', label: 'Övriga kostnader' },
}
}
/** Fallback template IDs when AI doesn't provide one */
const CATEGORY_DEFAULT_TEMPLATES: Record<string, string> = {
expense_representation: 'representation_external',
expense_equipment: 'equipment_small',
expense_software: 'it_saas_subscription',
expense_travel: 'travel_transport',
expense_office: 'office_supplies_general',
expense_consumables: 'office_supplies_general',
expense_vehicle: 'vehicle_fuel',
expense_telecom: 'telecom_mobile',
expense_marketing: 'marketing_online_ads_eu',
expense_education: 'education_course',
expense_professional_services: 'prof_accounting',
}
/**
* Build template reference from candidate templates (pre-filtered by embeddings)
* or fall back to full template list if no candidates provided.
*/
function getTemplateReference(
direction: 'expense' | 'income',
candidateTemplates?: BookingTemplate[]
): string {
const templates = candidateTemplates && candidateTemplates.length > 0
? candidateTemplates
: BOOKING_TEMPLATES
return templates
.filter((t) => t.direction === direction || t.direction === 'transfer')
.map((t) => `${t.id}: ${t.name_sv}${t.debit_account}/${t.credit_account}`)
.join('\n')
}
const NON_DEDUCTIBLE_RULES = `
MOMSREGLER FÖR SPECIFIKA KATEGORIER:
- Representation/måltider: Max 300 kr/person exkl. moms (IL 16 kap 2§), konto 6071/6072
- Gåvor: Reklamgåvor max 300 kr/mottagare, representationsgåvor max 180 kr
- Telefon/dator vid blandad användning: Bara yrkesmässig del avdragsgill
`
// ============================================================
// Classify Transaction Tool Schema
// ============================================================
const CLASSIFY_TOOL: Anthropic.Tool = {
name: 'classify_transactions',
description: 'Classify a batch of bank transactions into Swedish BAS accounts and booking templates.',
input_schema: {
type: 'object' as const,
properties: {
suggestions: {
type: 'array',
items: {
type: 'object',
properties: {
transactionId: { type: 'string', description: 'Transaction ID' },
templateId: { type: 'string', description: 'Booking template ID (REQUIRED — must be from the provided templates list)' },
category: { type: 'string', description: 'Transaction category (e.g. expense_representation, expense_equipment, expense_office)' },
basAccount: { type: 'string', description: 'BAS account number (4 digits)' },
taxCode: { type: ['string', 'null'], description: 'Tax code: MPI for deductible expenses with VAT, MP1 for income with VAT, null for VAT-exempt/private' },
confidence: { type: 'number', description: 'Confidence score 0.0-1.0' },
reasoning: { type: 'string', description: 'Short reasoning in Swedish' },
isPrivate: { type: 'boolean', description: 'Whether this is a private expense' },
},
required: ['transactionId', 'templateId', 'category', 'basAccount', 'confidence', 'reasoning', 'isPrivate'],
},
},
},
required: ['suggestions'],
},
}
// ============================================================
// Anthropic Provider
// ============================================================
const MAX_RETRIES = 3
const RETRY_DELAY_MS = 1000
const MAX_BATCH_SIZE = 20
export class AnthropicCategorizationProvider implements CategorizationProvider {
private client: Anthropic
private model: string
constructor(model = 'claude-haiku-4-5-20251001') {
this.client = new Anthropic()
this.model = model
}
async categorize(
transactions: TransactionForCategorization[],
context: CategorizationContext | EnrichedCategorizationContext,
tracking?: TrackingContext
): Promise<CategorizationSuggestion[]> {
// Cap batch size
const batch = transactions.slice(0, MAX_BATCH_SIZE)
if (batch.length === 0) return []
const enriched = isEnrichedContext(context) ? context : null
const privateAccount = context.entityType === 'aktiebolag' ? '2893' : '2013'
const categoryAccountMap = getCategoryAccountMap(context.entityType)
// Build template references — use candidate templates if available
const hasExpenses = batch.some((t) => t.amount < 0)
const hasIncome = batch.some((t) => t.amount > 0)
const candidates = enriched?.candidateTemplates
const templateRef = [
hasExpenses ? `UTGIFTSMALLAR:\n${getTemplateReference('expense', candidates)}` : '',
hasIncome ? `INTÄKTSMALLAR:\n${getTemplateReference('income', candidates)}` : '',
].filter(Boolean).join('\n\n')
// Build account usage context
const accountUsageContext = enriched?.userAccountUsage && enriched.userAccountUsage.length > 0
? `\nAnvändarens mest använda konton:\n${enriched.userAccountUsage
.slice(0, 15)
.map((a) => `- ${a.account_number} (${a.count} bokningar)`)
.join('\n')}`
: ''
// Build merchant history context
const merchantHistoryContext = enriched?.merchantHistory && enriched.merchantHistory.length > 0
? `\nTidigare kategorisering av dessa handlare:\n${enriched.merchantHistory
.map((m) => `- "${m.merchant_name}" → ${m.category}${m.template_id ? ` (mall: ${m.template_id})` : ''} (${m.count}x)`)
.join('\n')}`
: ''
// Build document enrichment context (from linked receipt or supplier invoice)
const documentContext = enriched?.documentData
? buildDocumentContext(enriched.documentData)
: ''
const systemPrompt = `Du är expert på svensk bokföring och kategorisering av banktransaktioner enligt BAS-kontoplanen.
Din uppgift är att kategorisera varje transaktion till rätt mall-ID (templateId) och BAS-konto.
BOKFÖRINGSMALLAR (id: namn → debitkonto/kreditkonto):
${templateRef}
KATEGORIER (fallback om ingen mall matchar):
${Object.entries(categoryAccountMap)
.map(([cat, info]) => `- ${cat}: ${info.account} (${info.label})`)
.join('\n')}
Företagsform: ${context.entityType === 'aktiebolag' ? 'Aktiebolag (AB)' : 'Enskild firma (EF)'}
Privatkonto: ${privateAccount}
MOMSHANTERING:
- Bankavgifter, kortavgifter, valutaväxling: MOMSFRIA
- Övriga affärskostnader: Normalt 25% moms (ingående moms, MPI)
- Intäkter: Normalt 25% moms (utgående moms, MP1)
${NON_DEDUCTIBLE_RULES}
${documentContext}
REGLER:
1. Negativa belopp = utgifter, positiva = intäkter
2. VIKTIGT: Dessa transaktioner kommer från företagets bankkonto/kort. Anta ALLTID att de är affärsrelaterade. Klassificera ALDRIG som "private" — det beslutet tar användaren själv.
3. Ange confidence 0.0-1.0 baserat på hur säker du är på rätt affärskategori
4. Ange kort reasoning på svenska
5. Restauranger/mat/café → expense_representation (6071). Bygghandel/järnhandel → expense_equipment eller expense_consumables. Heminredning/kontorsvaror → expense_office.
6. taxCode: "MPI" för avdragsgilla affärskostnader med moms, "MP1" för intäkter med moms, null för momsfria
7. templateId är OBLIGATORISKT — välj alltid den mest passande mallen från listan ovan, även för alternativa förslag
8. isPrivate ska ALLTID vara false — användaren avgör själv vad som är privat
9. Ange TVÅ förslag per transaktion: ett primärt (mest troligt) och ett alternativt (näst mest troligt, annan kategori, lägre confidence). Båda ska vara affärskategorier.
10. SKULDER: Konto 2440 (leverantörsskulder) ska BARA användas för leverantörsfakturor. Lån/amorteringar från banker eller kreditinstitut (Almi, Nordea, SEB, Handelsbanken, Swedbank, etc.) ska använda 2350 (skulder till kreditinstitut) via mallen financial_loan_repayment. Räntebetalningar ska använda 8410 (räntekostnader).`
const historyContext =
context.recentHistory.length > 0
? `\nAnvändarens senaste kategoriseringar (lär dig mönster):\n${context.recentHistory
.slice(0, 30)
.map((h) => `- "${h.description}" → ${h.category}`)
.join('\n')}`
: ''
const transactionList = batch
.map(
(t, i) =>
`${i + 1}. ID: ${t.id}
Beskrivning: ${t.description}
Belopp: ${t.amount} ${t.currency}
Datum: ${t.date}${t.merchant_name ? `\n Handlare: ${t.merchant_name}` : ''}${t.mcc_code ? `\n MCC: ${t.mcc_code}` : ''}`
)
.join('\n\n')
const userPrompt = `Kategorisera följande transaktioner med classify_transactions-verktyget.
Ange TVÅ förslag per transaktion (primärt + alternativ med lägre confidence):
${historyContext}${accountUsageContext}${merchantHistoryContext}
TRANSAKTIONER:
${transactionList}`
let lastError: Error | null = null
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
try {
const message = await this.client.messages.create({
model: this.model,
max_tokens: 4096,
system: [
{
type: 'text',
text: systemPrompt,
cache_control: { type: 'ephemeral' },
},
],
tools: [CLASSIFY_TOOL],
tool_choice: { type: 'tool', name: 'classify_transactions' },
messages: [
{
role: 'user',
content: userPrompt,
},
],
})
// Track token usage (fire-and-forget)
if (tracking && message.usage) {
const { trackTokenUsage } = await import('@/lib/ai/usage-tracker')
trackTokenUsage(tracking.supabase, tracking.userId, 'ai-categorization', {
inputTokens: message.usage.input_tokens,
outputTokens: message.usage.output_tokens,
model: this.model,
}, tracking.companyId)
}
// Extract tool_use block from response
const toolUseBlock = message.content.find(
(block) => block.type === 'tool_use' && block.name === 'classify_transactions'
)
if (!toolUseBlock || toolUseBlock.type !== 'tool_use') {
throw new Error('No tool_use block in AI response')
}
const input = toolUseBlock.input as { suggestions?: unknown[] }
return this.validateSuggestions(input.suggestions || [], batch, context.entityType)
} catch (error) {
lastError = error instanceof Error ? error : new Error('Unknown error')
if (attempt < MAX_RETRIES - 1) {
await sleep(RETRY_DELAY_MS * (attempt + 1))
}
}
}
throw new Error(
`AI categorization failed after ${MAX_RETRIES} attempts: ${lastError?.message}`
)
}
private validateSuggestions(
raw: unknown[],
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(categoryAccountMap).concat(['uncategorized']))
const transactionMap = new Map(transactions.map((t) => [t.id, t]))
return raw
.filter(
(s): s is Record<string, unknown> =>
s !== null && typeof s === 'object' && 'transactionId' in s
)
.filter((s) => validTransactionIds.has(s.transactionId as string))
.map((s) => {
// Never let AI classify as private — remap to expense_other
let category = validCategories.has(s.category as string)
? (s.category as TransactionCategory)
: 'expense_other'
if (category === 'private') {
category = 'expense_other'
}
// Enforce direction: positive amounts = income, negative = expense
const tx = transactionMap.get(s.transactionId as string)
if (tx) {
if (tx.amount > 0 && category.startsWith('expense_')) {
category = 'income_other' as TransactionCategory
} else if (tx.amount < 0 && category.startsWith('income_')) {
category = 'expense_other' as TransactionCategory
}
}
const accountInfo = categoryAccountMap[category]
return {
transactionId: s.transactionId as string,
category,
basAccount: accountInfo?.account || (s.basAccount as string) || '6991',
taxCode: (s.taxCode as string) || null,
confidence: Math.max(0, Math.min(1, Number(s.confidence) || 0.5)),
reasoning: (s.reasoning as string) || '',
isPrivate: false,
templateId: (s.templateId as string) || CATEGORY_DEFAULT_TEMPLATES[category] || undefined,
}
})
}
}
function buildDocumentContext(doc: DocumentEnrichment): string {
const parts: string[] = []
const typeLabel = doc.type === 'receipt' ? 'KVITTO' : 'LEVERANTÖRSFAKTURA'
parts.push(`LÄNKAT DOKUMENT (${typeLabel}):`)
if (doc.merchantName) {
parts.push(`Handlare/leverantör: ${doc.merchantName}`)
}
if (doc.lineItems && doc.lineItems.length > 0) {
parts.push('Rader:')
for (const item of doc.lineItems) {
let line = `- ${item.description}: ${item.amount} kr`
if (item.accountSuggestion) line += ` (föreslaget konto: ${item.accountSuggestion})`
if (item.category) line += ` [${item.category}]`
parts.push(line)
}
}
if (doc.vatBreakdown && doc.vatBreakdown.length > 0) {
parts.push('Momsfördelning:')
for (const vat of doc.vatBreakdown) {
parts.push(`- ${vat.rate}%: ${vat.amount} kr`)
}
}
if (doc.isReverseCharge) {
parts.push(`VIKTIGT: Omvänd skattskyldighet (reverse charge). Använd dubbelkontering:
- Debitera 2645 (beräknad ingående moms) OCH kreditera 2614 (utgående moms, omvänd skattskyldighet)
- Mallen "purchase_eu_service_reverse_charge" ska användas om tillgänglig`)
}
return parts.join('\n') + '\n'
}
function isEnrichedContext(
ctx: CategorizationContext | EnrichedCategorizationContext
): ctx is EnrichedCategorizationContext {
return 'candidateTemplates' in ctx
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
@@ -1,400 +0,0 @@
import type { Extension, ExtensionContext } from '@/lib/extensions/types'
import { aiCategorizationApiRoutes } from './api-routes'
import type { EventPayload } from '@/lib/events/types'
import type { Transaction, EntityType } from '@/types'
import {
AnthropicCategorizationProvider,
type CategorizationProvider,
type TransactionForCategorization,
type EnrichedCategorizationContext,
type CategorizationSuggestion,
type AccountUsageEntry,
type MerchantHistoryEntry,
} from './categorizer'
import type { BookingTemplate } from '@/lib/bookkeeping/booking-templates'
// ============================================================
// Settings
// ============================================================
export interface AiCategorizationSettings {
autoSuggestEnabled: boolean
confidenceThreshold: number
providerModel: string
}
const DEFAULT_SETTINGS: AiCategorizationSettings = {
autoSuggestEnabled: true,
confidenceThreshold: 0.7,
providerModel: 'claude-haiku-4-5-20251001',
}
/** Get settings via ExtensionContext (preferred in event handlers) */
async function getSettingsViaCtx(ctx: ExtensionContext): Promise<AiCategorizationSettings> {
const stored = await ctx.settings.get<Partial<AiCategorizationSettings>>()
return { ...DEFAULT_SETTINGS, ...(stored || {}) }
}
/** Get settings for external callers (settings routes, on-demand API) */
export async function getSettings(userId: string): Promise<AiCategorizationSettings> {
const { createClient } = await import('@/lib/supabase/server')
const supabase = await createClient()
const { data } = await supabase
.from('extension_data')
.select('value')
.eq('company_id', userId)
.eq('extension_id', 'ai-categorization')
.eq('key', 'settings')
.single()
if (!data?.value) return { ...DEFAULT_SETTINGS }
return { ...DEFAULT_SETTINGS, ...(data.value as Partial<AiCategorizationSettings>) }
}
export async function saveSettings(
userId: string,
partial: Partial<AiCategorizationSettings>
): Promise<AiCategorizationSettings> {
const current = await getSettings(userId)
const merged = { ...current, ...partial }
const { createClient } = await import('@/lib/supabase/server')
const supabase = await createClient()
await supabase
.from('extension_data')
.upsert(
{
user_id: userId,
extension_id: 'ai-categorization',
key: 'settings',
value: merged,
},
{ onConflict: 'user_id,extension_id,key' }
)
return merged
}
// ============================================================
// Provider
// ============================================================
let provider: CategorizationProvider | null = null
function getProvider(model?: string): CategorizationProvider {
if (!provider) {
provider = new AnthropicCategorizationProvider(model)
}
return provider
}
// ============================================================
// Public API — on-demand categorization
// ============================================================
export async function categorizeTransactions(
userId: string,
transactionIds: string[]
): Promise<CategorizationSuggestion[]> {
const { createClient } = await import('@/lib/supabase/server')
const supabase = await createClient()
const settings = await getSettings(userId)
// Fetch transactions
const { data: transactions } = await supabase
.from('transactions')
.select('id, description, amount, date, merchant_name, mcc_code, currency')
.eq('company_id', userId)
.in('id', transactionIds)
if (!transactions || transactions.length === 0) return []
const batch: TransactionForCategorization[] = transactions.map((t) => ({
id: t.id,
description: t.description,
amount: t.amount,
date: t.date,
merchant_name: t.merchant_name,
mcc_code: t.mcc_code,
currency: t.currency,
}))
const context = await buildEnrichedContext(userId, supabase, batch)
const aiProvider = getProvider(settings.providerModel)
const suggestions = await aiProvider.categorize(batch, context, { supabase, userId })
// Store suggestions
await storeSuggestions(userId, suggestions, supabase)
return suggestions
}
// ============================================================
// Event Handler
// ============================================================
async function handleTransactionSynced(
payload: EventPayload<'transaction.synced'>,
ctx?: ExtensionContext
): Promise<void> {
const { transactions: syncedTransactions, userId } = payload
const log = ctx?.log ?? console
// Gate: Is autoSuggestEnabled?
const settings = ctx ? await getSettingsViaCtx(ctx) : await getSettings(userId)
if (!settings.autoSuggestEnabled) {
return
}
// Gate: Filter to uncategorized transactions only
const uncategorized = syncedTransactions.filter(
(t: Transaction) => t.is_business === null
)
if (uncategorized.length === 0) {
return
}
log.info(`Auto-suggest triggered for ${uncategorized.length} uncategorized transactions`)
try {
const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient()
const batch: TransactionForCategorization[] = uncategorized.map((t: Transaction) => ({
id: t.id,
description: t.description,
amount: t.amount,
date: t.date,
merchant_name: t.merchant_name,
mcc_code: t.mcc_code,
currency: t.currency,
}))
const context = await buildEnrichedContext(userId, supabase, batch)
const aiProvider = getProvider(settings.providerModel)
const suggestions = await aiProvider.categorize(batch, context, { supabase, userId })
// Store only suggestions above confidence threshold
const qualifiedSuggestions = suggestions.filter(
(s) => s.confidence >= settings.confidenceThreshold
)
if (qualifiedSuggestions.length > 0) {
await storeSuggestions(userId, qualifiedSuggestions, supabase)
}
log.info(
`Generated ${suggestions.length} suggestions, ${qualifiedSuggestions.length} above threshold (${settings.confidenceThreshold})`
)
} catch (error) {
log.error('handleTransactionSynced failed:', error)
}
}
// ============================================================
// Helpers
// ============================================================
async function buildEnrichedContext(
userId: string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
supabase: any,
transactions: TransactionForCategorization[]
): Promise<EnrichedCategorizationContext> {
// Fetch entity type
const { data: companySettings } = await supabase
.from('company_settings')
.select('entity_type')
.eq('company_id', userId)
.single()
const entityType: EntityType = companySettings?.entity_type || 'enskild_firma'
// Fetch recent categorization history
const { data: historicalTxns } = await supabase
.from('transactions')
.select('description, category')
.eq('company_id', userId)
.not('is_business', 'is', null)
.neq('category', 'uncategorized')
.order('updated_at', { ascending: false })
.limit(50)
const recentHistory = (historicalTxns || []).map(
(t: { description: string; category: string }) => ({
description: t.description,
category: t.category,
})
)
// Fetch user's most-used accounts (top 30)
const { data: accountUsageRows } = await supabase
.from('journal_entry_lines')
.select('account_number')
.eq('company_id', userId)
const accountCounts = new Map<string, number>()
if (accountUsageRows) {
for (const row of accountUsageRows as { account_number: string }[]) {
accountCounts.set(row.account_number, (accountCounts.get(row.account_number) || 0) + 1)
}
}
const userAccountUsage: AccountUsageEntry[] = Array.from(accountCounts.entries())
.map(([account_number, count]) => ({ account_number, count }))
.sort((a, b) => b.count - a.count)
.slice(0, 30)
// Fetch merchant history for this batch's merchants
const merchantNames = [...new Set(
transactions
.map((t) => t.merchant_name)
.filter((n): n is string => n !== null && n.length > 0)
)]
let merchantHistory: MerchantHistoryEntry[] = []
if (merchantNames.length > 0) {
const { data: merchantRows } = await supabase
.from('transactions')
.select('merchant_name, category, template_id')
.eq('company_id', userId)
.not('is_business', 'is', null)
.neq('category', 'uncategorized')
.in('merchant_name', merchantNames)
.limit(200)
if (merchantRows) {
const merchantMap = new Map<string, MerchantHistoryEntry>()
for (const row of merchantRows as { merchant_name: string; category: string; template_id: string | null }[]) {
const key = `${row.merchant_name}:${row.category}`
const existing = merchantMap.get(key)
if (existing) {
existing.count++
} else {
merchantMap.set(key, {
merchant_name: row.merchant_name,
category: row.category,
template_id: row.template_id,
count: 1,
})
}
}
merchantHistory = Array.from(merchantMap.values())
.sort((a, b) => b.count - a.count)
}
}
// Find candidate templates via embedding search
// Use a representative subset of transactions to find candidates
const { findSimilarTemplates } = await import('./lib/template-embeddings')
const representativeTransactions = transactions.slice(0, 5)
const candidateMap = new Map<string, BookingTemplate>()
for (const tx of representativeTransactions) {
try {
const matches = await findSimilarTemplates(
tx as unknown as Transaction,
entityType
)
for (const m of matches) {
if (!candidateMap.has(m.template.id)) {
candidateMap.set(m.template.id, m.template)
}
}
} catch {
// Embedding search failed — continue without candidates
}
}
const candidateTemplates = Array.from(candidateMap.values())
return {
entityType,
recentHistory,
candidateTemplates,
userAccountUsage,
merchantHistory,
}
}
async function storeSuggestions(
userId: string,
suggestions: CategorizationSuggestion[],
// eslint-disable-next-line @typescript-eslint/no-explicit-any
supabase: any
): Promise<void> {
// Group suggestions by transactionId (AI now returns multiple per transaction)
const grouped: Record<string, CategorizationSuggestion[]> = {}
for (const suggestion of suggestions) {
if (!grouped[suggestion.transactionId]) {
grouped[suggestion.transactionId] = []
}
grouped[suggestion.transactionId].push(suggestion)
}
for (const [txId, txSuggestions] of Object.entries(grouped)) {
const { error } = await supabase.from('extension_data').upsert(
{
user_id: userId,
extension_id: 'ai-categorization',
key: `suggestion:${txId}`,
value: txSuggestions,
},
{ onConflict: 'user_id,extension_id,key' }
)
if (error) {
console.error(`[ai-categorization] Failed to store suggestion for ${txId}:`, error.message)
}
}
}
// ============================================================
// Extension Object
// ============================================================
export const aiCategorizationExtension: Extension = {
id: 'ai-categorization',
name: 'AI Kategorisering',
version: '1.0.0',
sector: 'general',
apiRoutes: aiCategorizationApiRoutes,
eventHandlers: [
{ eventType: 'transaction.synced', handler: handleTransactionSynced },
],
settingsPanel: {
label: 'AI Kategorisering',
path: '/settings/extensions/ai-categorization',
},
services: {
findSimilarTemplates: async (...args: unknown[]) => {
const { findSimilarTemplates } = await import('./lib/template-embeddings')
return findSimilarTemplates(
args[0] as Transaction,
args[1] as EntityType | undefined,
args[2] as number | undefined,
args[3] as string | undefined
)
},
seedAllTemplateEmbeddings: async () => {
const { seedAllTemplateEmbeddings } = await import('./lib/template-embeddings')
return seedAllTemplateEmbeddings()
},
getSchemaVersion: async () => {
const { getSchemaVersion } = await import('./lib/template-embeddings')
return getSchemaVersion()
},
categorizeTransactions: async (...args: unknown[]) => {
return categorizeTransactions(args[0] as string, args[1] as string[])
},
analyzeDescription: async (...args: unknown[]) => {
const { analyzeDescription } = await import('./lib/description-analyzer')
return analyzeDescription(
args[0] as import('./lib/description-analyzer').DescriptionAnalysisInput
)
},
},
async onInstall(ctx) {
await ctx.settings.set('settings', DEFAULT_SETTINGS)
},
}
@@ -1,297 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
// Mock server-only (no-op in tests)
vi.mock('server-only', () => ({}))
// Mock Anthropic SDK
const mockCreate = vi.fn()
vi.mock('@anthropic-ai/sdk', () => {
class MockAnthropic {
messages = { create: mockCreate }
}
return { default: MockAnthropic }
})
function makeToolResponse(input: Record<string, unknown>) {
return {
content: [
{
type: 'tool_use',
name: 'analyze_description',
input,
},
],
}
}
describe('description-analyzer', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('returns correct result for an expense with standard VAT', async () => {
mockCreate.mockResolvedValueOnce(
makeToolResponse({
debitAccount: '6071',
creditAccount: '1930',
vatTreatment: 'standard_25',
category: 'expense_representation',
confidence: 0.85,
reasoning: 'Lunch med kund klassificeras som representation.',
warnings: ['Max 300 kr/person for avdragsratt'],
templateId: null,
})
)
const { analyzeDescription } = await import('../description-analyzer')
const result = await analyzeDescription({
description: 'Lunch med kund',
transactionAmount: -450,
transactionDate: '2026-01-15',
transactionDescription: 'RESTAURANT XYZ',
merchantName: 'Restaurant XYZ',
currency: 'SEK',
entityType: 'enskild_firma',
})
expect(result.debitAccount).toBe('6071')
expect(result.creditAccount).toBe('1930')
expect(result.vatTreatment).toBe('standard_25')
expect(result.category).toBe('expense_representation')
expect(result.confidence).toBe(0.85)
expect(result.reasoning).toContain('representation')
expect(result.warnings).toHaveLength(1)
expect(result.templateId).toBeNull()
})
it('returns correct result for income', async () => {
mockCreate.mockResolvedValueOnce(
makeToolResponse({
debitAccount: '1930',
creditAccount: '3001',
vatTreatment: 'standard_25',
category: 'income_services',
confidence: 0.9,
reasoning: 'Konsultarvode bokförs som tjänsteintäkt.',
warnings: [],
templateId: null,
})
)
const { analyzeDescription } = await import('../description-analyzer')
const result = await analyzeDescription({
description: 'Konsultarvode',
transactionAmount: 25000,
transactionDate: '2026-01-15',
transactionDescription: 'PAYMENT FROM CLIENT',
merchantName: null,
currency: 'SEK',
entityType: 'aktiebolag',
})
expect(result.debitAccount).toBe('1930')
expect(result.creditAccount).toBe('3001')
expect(result.category).toBe('income_services')
expect(result.confidence).toBe(0.9)
})
it('corrects category direction mismatch', async () => {
mockCreate.mockResolvedValueOnce(
makeToolResponse({
debitAccount: '6071',
creditAccount: '1930',
vatTreatment: null,
category: 'income_services', // Wrong direction for expense
confidence: 0.7,
reasoning: 'Test',
warnings: [],
templateId: null,
})
)
const { analyzeDescription } = await import('../description-analyzer')
const result = await analyzeDescription({
description: 'Something',
transactionAmount: -500,
transactionDate: '2026-01-15',
transactionDescription: 'PAYMENT',
merchantName: null,
currency: 'SEK',
entityType: 'enskild_firma',
})
// Income category should be corrected to expense for negative amount
expect(result.category).toBe('expense_other')
})
it('clamps confidence to [0, 1]', async () => {
mockCreate.mockResolvedValueOnce(
makeToolResponse({
debitAccount: '6991',
creditAccount: '1930',
vatTreatment: null,
category: 'expense_other',
confidence: 1.5, // Over 1
reasoning: 'Test',
warnings: [],
templateId: null,
})
)
const { analyzeDescription } = await import('../description-analyzer')
const result = await analyzeDescription({
description: 'Something',
transactionAmount: -100,
transactionDate: '2026-01-15',
transactionDescription: 'PAYMENT',
merchantName: null,
currency: 'SEK',
entityType: 'enskild_firma',
})
expect(result.confidence).toBe(1)
})
it('falls back to default accounts for invalid account numbers', async () => {
mockCreate.mockResolvedValueOnce(
makeToolResponse({
debitAccount: 'INVALID',
creditAccount: 'bad',
vatTreatment: null,
category: 'expense_other',
confidence: 0.5,
reasoning: 'Test',
warnings: [],
templateId: null,
})
)
const { analyzeDescription } = await import('../description-analyzer')
const result = await analyzeDescription({
description: 'Something',
transactionAmount: -100,
transactionDate: '2026-01-15',
transactionDescription: 'PAYMENT',
merchantName: null,
currency: 'SEK',
entityType: 'enskild_firma',
})
// Should fall back to safe defaults
expect(result.debitAccount).toBe('6991')
expect(result.creditAccount).toBe('1930')
})
it('enforces expense direction: credit account must be 1930', async () => {
mockCreate.mockResolvedValueOnce(
makeToolResponse({
debitAccount: '5420',
creditAccount: '2440', // Not 1930 for a bank transaction expense
vatTreatment: 'standard_25',
category: 'expense_software',
confidence: 0.8,
reasoning: 'Test',
warnings: [],
templateId: null,
})
)
const { analyzeDescription } = await import('../description-analyzer')
const result = await analyzeDescription({
description: 'Software subscription',
transactionAmount: -500,
transactionDate: '2026-01-15',
transactionDescription: 'PAYMENT',
merchantName: null,
currency: 'SEK',
entityType: 'enskild_firma',
})
expect(result.creditAccount).toBe('1930')
})
it('rejects private category', async () => {
mockCreate.mockResolvedValueOnce(
makeToolResponse({
debitAccount: '2013',
creditAccount: '1930',
vatTreatment: null,
category: 'private',
confidence: 0.9,
reasoning: 'Test',
warnings: [],
templateId: null,
})
)
const { analyzeDescription } = await import('../description-analyzer')
const result = await analyzeDescription({
description: 'Something',
transactionAmount: -100,
transactionDate: '2026-01-15',
transactionDescription: 'PAYMENT',
merchantName: null,
currency: 'SEK',
entityType: 'enskild_firma',
})
expect(result.category).toBe('expense_other')
})
it('throws after retries exhausted', async () => {
mockCreate.mockRejectedValue(new Error('API error'))
const { analyzeDescription } = await import('../description-analyzer')
await expect(
analyzeDescription({
description: 'Something',
transactionAmount: -100,
transactionDate: '2026-01-15',
transactionDescription: 'PAYMENT',
merchantName: null,
currency: 'SEK',
entityType: 'enskild_firma',
})
).rejects.toThrow('AI description analysis failed after 3 attempts')
// Should have retried 3 times (initial + 2 retries)
expect(mockCreate).toHaveBeenCalledTimes(3)
})
it('validates invalid VAT treatment to null', async () => {
mockCreate.mockResolvedValueOnce(
makeToolResponse({
debitAccount: '6991',
creditAccount: '1930',
vatTreatment: 'invalid_vat',
category: 'expense_other',
confidence: 0.5,
reasoning: 'Test',
warnings: [],
templateId: null,
})
)
const { analyzeDescription } = await import('../description-analyzer')
const result = await analyzeDescription({
description: 'Something',
transactionAmount: -100,
transactionDate: '2026-01-15',
transactionDescription: 'PAYMENT',
merchantName: null,
currency: 'SEK',
entityType: 'enskild_firma',
})
expect(result.vatTreatment).toBeNull()
})
})
@@ -1,233 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { makeTransaction, createMockSupabase } from '@/tests/helpers'
import { BOOKING_TEMPLATES } from '@/lib/bookkeeping/booking-templates'
// Mock server-only (no-op in tests)
vi.mock('server-only', () => ({}))
// Mock OpenAI Embeddings
vi.mock('@langchain/openai', () => {
class MockOpenAIEmbeddings {
embedQuery = vi.fn().mockResolvedValue(new Array(1536).fill(0.1))
embedDocuments = vi.fn().mockImplementation((texts: string[]) =>
Promise.resolve(texts.map(() => new Array(1536).fill(0.1)))
)
}
return { OpenAIEmbeddings: MockOpenAIEmbeddings }
})
// Mock Supabase
const { supabase: mockSupabase, mockResult } = createMockSupabase()
vi.mock('@/lib/supabase/server', () => ({
createServiceClient: vi.fn().mockResolvedValue(mockSupabase),
}))
describe('template-embeddings', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('buildEmbeddingText', () => {
it('includes all relevant fields for a template', async () => {
const { buildEmbeddingText } = await import('../template-embeddings')
const template = BOOKING_TEMPLATES.find((t) => t.id === 'premises_rent')!
const text = buildEmbeddingText(template)
// Should include Swedish and English name
expect(text).toContain('Lokalhyra')
expect(text).toContain('Office rent')
// Should include description
expect(text).toContain(template.description_sv)
// Should include keywords
expect(text).toContain('hyra')
expect(text).toContain('lokal')
// Should include group
expect(text).toContain('premises')
// Should include direction
expect(text).toContain('utgift')
// Should include accounts
expect(text).toContain('5010')
expect(text).toContain('1930')
})
it('includes VAT treatment when present', async () => {
const { buildEmbeddingText } = await import('../template-embeddings')
const template = BOOKING_TEMPLATES.find((t) => t.id === 'premises_electricity')!
const text = buildEmbeddingText(template)
expect(text).toContain('standard_25')
expect(text).toContain('25%')
})
it('includes special rules when present', async () => {
const { buildEmbeddingText } = await import('../template-embeddings')
const template = BOOKING_TEMPLATES.find((t) => t.id === 'premises_rent')!
const text = buildEmbeddingText(template)
expect(text).toContain(template.special_rules_sv!)
})
it('includes MCC codes when present', async () => {
const { buildEmbeddingText } = await import('../template-embeddings')
const template = BOOKING_TEMPLATES.find((t) => t.id === 'premises_electricity')!
const text = buildEmbeddingText(template)
expect(text).toContain('4900')
})
it('includes deductibility note for non-full deductibility', async () => {
const { buildEmbeddingText } = await import('../template-embeddings')
const template = BOOKING_TEMPLATES.find((t) => t.deductibility === 'non_deductible')!
const text = buildEmbeddingText(template)
expect(text).toContain('non_deductible')
})
it('generates text for all templates without error', async () => {
const { buildEmbeddingText } = await import('../template-embeddings')
for (const template of BOOKING_TEMPLATES) {
const text = buildEmbeddingText(template)
expect(text.length).toBeGreaterThan(10)
}
})
})
describe('buildTransactionQueryText', () => {
it('combines description, merchant, and direction', async () => {
const { buildTransactionQueryText } = await import('../template-embeddings')
const tx = makeTransaction({
description: 'SPOTIFY PREMIUM',
merchant_name: 'Spotify',
amount: -109,
mcc_code: 5815,
})
const text = buildTransactionQueryText(tx)
expect(text).toContain('SPOTIFY PREMIUM')
expect(text).toContain('Spotify')
expect(text).toContain('MCC 5815')
expect(text).toContain('utgift')
})
it('marks positive amounts as income', async () => {
const { buildTransactionQueryText } = await import('../template-embeddings')
const tx = makeTransaction({
description: 'Inbetalning',
amount: 5000,
})
const text = buildTransactionQueryText(tx)
expect(text).toContain('intäkt')
})
it('handles null merchant_name and mcc_code', async () => {
const { buildTransactionQueryText } = await import('../template-embeddings')
const tx = makeTransaction({
description: 'Some payment',
merchant_name: null,
mcc_code: null,
amount: -100,
})
const text = buildTransactionQueryText(tx)
expect(text).toContain('Some payment')
expect(text).toContain('utgift')
expect(text).not.toContain('MCC')
})
it('prepends user description when provided', async () => {
const { buildTransactionQueryText } = await import('../template-embeddings')
const tx = makeTransaction({
description: 'SWE REST 4521 STHLM',
merchant_name: 'Unknown',
amount: -450,
})
const text = buildTransactionQueryText(tx, 'business lunch with client')
// User description should appear first
expect(text.indexOf('business lunch with client')).toBe(0)
// Transaction data should still be present
expect(text).toContain('SWE REST 4521 STHLM')
expect(text).toContain('Unknown')
expect(text).toContain('utgift')
})
it('behaves identically when userDescription is undefined', async () => {
const { buildTransactionQueryText } = await import('../template-embeddings')
const tx = makeTransaction({
description: 'SPOTIFY PREMIUM',
merchant_name: 'Spotify',
amount: -109,
})
const withoutDesc = buildTransactionQueryText(tx)
const withUndefined = buildTransactionQueryText(tx, undefined)
expect(withoutDesc).toBe(withUndefined)
})
})
describe('getSchemaVersion', () => {
it('returns a consistent hash string', async () => {
const { getSchemaVersion } = await import('../template-embeddings')
const v1 = getSchemaVersion()
const v2 = getSchemaVersion()
expect(v1).toBe(v2)
expect(v1).toHaveLength(12)
expect(v1).toMatch(/^[a-f0-9]+$/)
})
})
describe('findSimilarTemplates', () => {
it('returns empty array on RPC error (graceful fallback)', async () => {
const { findSimilarTemplates } = await import('../template-embeddings')
// Mock staleness check
mockResult({ data: { schema_version: 'test' }, error: null })
const tx = makeTransaction({
description: 'SPOTIFY',
amount: -109,
})
// The mock will return error for the RPC call
mockResult({ data: null, error: { message: 'RPC failed' } })
const results = await findSimilarTemplates(tx)
expect(results).toEqual([])
})
it('returns empty array when no embeddings exist', async () => {
const { findSimilarTemplates } = await import('../template-embeddings')
mockResult({ data: [], error: null })
const tx = makeTransaction({
description: 'Random purchase',
amount: -50,
})
const results = await findSimilarTemplates(tx)
expect(results).toEqual([])
})
})
})
@@ -1,277 +0,0 @@
/**
* AI Description Analyzer
*
* SERVER-ONLY: Uses the Anthropic SDK and must only be imported
* in server components or API routes.
*
* Interprets a user's plain-language description of a bank transaction
* and returns a structured booking suggestion with Swedish accounting reasoning.
* Uses Claude Haiku with structured tool outputs for reliable JSON.
*/
import 'server-only'
import Anthropic from '@anthropic-ai/sdk'
import type { TransactionCategory, VatTreatment, EntityType } from '@/types'
// ============================================================
// Types
// ============================================================
export interface DescriptionAnalysisInput {
description: string
transactionAmount: number
transactionDate: string
transactionDescription: string
merchantName: string | null
currency: string
entityType: EntityType
}
export interface DescriptionAnalysisResult {
debitAccount: string
creditAccount: string
vatTreatment: VatTreatment | null
category: TransactionCategory
confidence: number
reasoning: string
warnings: string[]
templateId: string | null
}
// ============================================================
// Constants
// ============================================================
const MAX_RETRIES = 2
const RETRY_DELAY_MS = 500
const MODEL = 'claude-haiku-4-5-20251001'
const VALID_CATEGORIES = new Set<string>([
'income_services', 'income_products', 'income_other',
'expense_equipment', 'expense_software', 'expense_travel',
'expense_office', 'expense_marketing', 'expense_professional_services',
'expense_education', 'expense_representation', 'expense_consumables',
'expense_vehicle', 'expense_telecom', 'expense_bank_fees',
'expense_card_fees', 'expense_currency_exchange', 'expense_other',
])
const VALID_VAT_TREATMENTS = new Set<string>([
'standard_25', 'reduced_12', 'reduced_6', 'reverse_charge', 'export', 'exempt',
])
// ============================================================
// Tool Schema
// ============================================================
const ANALYZE_TOOL: Anthropic.Tool = {
name: 'analyze_description',
description: 'Analyze a user description and return a structured booking suggestion for the transaction.',
input_schema: {
type: 'object' as const,
properties: {
debitAccount: { type: 'string', description: 'BAS debit account number (4 digits)' },
creditAccount: { type: 'string', description: 'BAS credit account number (4 digits)' },
vatTreatment: {
type: ['string', 'null'],
description: 'VAT treatment: standard_25, reduced_12, reduced_6, reverse_charge, export, exempt, or null if exempt/no VAT',
},
category: { type: 'string', description: 'Transaction category (e.g. expense_representation, income_services)' },
confidence: { type: 'number', description: 'Confidence score 0.0-1.0' },
reasoning: { type: 'string', description: 'Explanation in Swedish of why this booking is correct' },
warnings: {
type: 'array',
items: { type: 'string' },
description: 'Warnings about deductibility limits, special rules, etc. (in Swedish)',
},
templateId: {
type: ['string', 'null'],
description: 'Matching booking template ID if applicable, or null',
},
},
required: ['debitAccount', 'creditAccount', 'vatTreatment', 'category', 'confidence', 'reasoning', 'warnings', 'templateId'],
},
}
// ============================================================
// System Prompt
// ============================================================
function buildSystemPrompt(entityType: EntityType): string {
const privateAccount = entityType === 'aktiebolag' ? '2893' : '2013'
const entityLabel = entityType === 'aktiebolag' ? 'Aktiebolag (AB)' : 'Enskild firma (EF)'
return `Du är expert på svensk bokföring enligt BAS-kontoplanen. Analysera användarens beskrivning av en banktransaktion och returnera ett bokföringsförslag.
VANLIGA BAS-KONTON:
Utgifter: 5010 Lokalhyra | 5410 Förbrukningsinventarier | 5420 Programvara | 5460 Förbrukningsvaror | 5611 Bil/drivmedel | 5800 Resekostnader | 5910 Annonsering | 6071 Representation mat | 6200 Telefon/internet | 6530 Redovisning/konsult | 6570 Bankavgifter | 6991 Övriga kostnader | ${entityType === 'aktiebolag' ? '7610' : '6991'} Utbildning
Intäkter: 3001 Försäljning 25% | 3002 Försäljning 12% | 3003 Försäljning 6% | 3305 Export | 3308 EU-tjänster | 3900 Övriga intäkter
Moms: 2611 Utg moms 25% | 2621 Utg moms 12% | 2631 Utg moms 6% | 2641 Ing moms | 2645 Beräknad ing moms
Skulder: 2350 Skulder till kreditinstitut (banklån, Almi) | 2440 Leverantörsskulder (ENBART för leverantörsfakturor)
Övrigt: 1510 Kundfordringar | 1930 Företagskonto | 8410 Räntekostnader | ${privateAccount} Privat
MOMSREGLER:
- standard_25: Normala varor/tjänster (25%)
- reduced_12: Livsmedel, hotell, konstverk (12%)
- reduced_6: Böcker, tidningar, kollektivtrafik, kultur (6%)
- reverse_charge: Tjänsteköp från utlandet/EU
- export: Försäljning utanför Sverige
- exempt: Momsfritt (bank, försäkring, sjukvård, utbildning)
VARNINGSREGLER:
- Representation/måltider: Max 300 kr/person exkl moms för avdragsrätt (IL 16 kap 2§)
- Gåvor: Reklamgåvor max 300 kr, representationsgåvor max 180 kr
- Blandad användning (telefon/dator): Bara yrkesmässig del avdragsgill
- Bankavgifter, kortavgifter, valutaväxling: MOMSFRIA (exempt)
Företagsform: ${entityLabel}
Privatkonto: ${privateAccount}
REGLER:
1. Negativt belopp = utgift: debitera kostnadskonto, kreditera 1930
2. Positivt belopp = intäkt: debitera 1930, kreditera intäktskonto
3. Ge ett klart reasoning på svenska som förklarar valet
4. Lägg till warnings för avdragsbegränsningar eller speciella regler
5. templateId: null (vi matchar mallar separat)`
}
// ============================================================
// Analyzer
// ============================================================
export async function analyzeDescription(
input: DescriptionAnalysisInput
): Promise<DescriptionAnalysisResult> {
const client = new Anthropic()
const isExpense = input.transactionAmount < 0
const userPrompt = `Transaktion:
- Användarens beskrivning: "${input.description}"
- Banktext: "${input.transactionDescription}"
- Belopp: ${input.transactionAmount} ${input.currency}
- Datum: ${input.transactionDate}${input.merchantName ? `\n- Handlare: ${input.merchantName}` : ''}
Analysera och returnera bokföringsförslag med analyze_description-verktyget.`
let lastError: Error | null = null
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
try {
const message = await client.messages.create({
model: MODEL,
max_tokens: 1024,
system: [
{
type: 'text',
text: buildSystemPrompt(input.entityType),
cache_control: { type: 'ephemeral' },
},
],
tools: [ANALYZE_TOOL],
tool_choice: { type: 'tool', name: 'analyze_description' },
messages: [{ role: 'user', content: userPrompt }],
})
const toolUseBlock = message.content.find(
(block) => block.type === 'tool_use' && block.name === 'analyze_description'
)
if (!toolUseBlock || toolUseBlock.type !== 'tool_use') {
throw new Error('No tool_use block in AI response')
}
return validateResult(toolUseBlock.input as Record<string, unknown>, isExpense, input.entityType)
} catch (error) {
lastError = error instanceof Error ? error : new Error('Unknown error')
if (attempt < MAX_RETRIES) {
await sleep(RETRY_DELAY_MS * (attempt + 1))
}
}
}
throw new Error(
`AI description analysis failed after ${MAX_RETRIES + 1} attempts: ${lastError?.message}`
)
}
// ============================================================
// Validation
// ============================================================
function validateResult(
raw: Record<string, unknown>,
isExpense: boolean,
entityType: EntityType
): DescriptionAnalysisResult {
const ACCOUNT_REGEX = /^\d{4}$/
// Validate accounts — default to safe fallbacks
let debitAccount = typeof raw.debitAccount === 'string' && ACCOUNT_REGEX.test(raw.debitAccount)
? raw.debitAccount
: (isExpense ? '6991' : '1930')
let creditAccount = typeof raw.creditAccount === 'string' && ACCOUNT_REGEX.test(raw.creditAccount)
? raw.creditAccount
: (isExpense ? '1930' : '3001')
// Enforce direction: expenses debit expense account + credit 1930, income debit 1930 + credit revenue
if (isExpense && creditAccount !== '1930') {
creditAccount = '1930'
}
if (!isExpense && debitAccount !== '1930') {
debitAccount = '1930'
}
// Validate VAT treatment
const rawVat = raw.vatTreatment as string | null
const vatTreatment = rawVat && VALID_VAT_TREATMENTS.has(rawVat)
? rawVat as VatTreatment
: null
// Validate category with direction correction
let category = VALID_CATEGORIES.has(raw.category as string)
? (raw.category as TransactionCategory)
: (isExpense ? 'expense_other' : 'income_other')
if (category === 'private') {
category = isExpense ? 'expense_other' : 'income_other'
}
if (isExpense && category.startsWith('income_')) {
category = 'expense_other'
}
if (!isExpense && category.startsWith('expense_')) {
category = 'income_other'
}
// Clamp confidence
const confidence = Math.max(0, Math.min(1, Number(raw.confidence) || 0.5))
// Reasoning — must be a non-empty string
const reasoning = typeof raw.reasoning === 'string' && raw.reasoning.length > 0
? raw.reasoning
: (isExpense ? 'Utgift bokförd på standardkonto' : 'Intäkt bokförd på standardkonto')
// Warnings
const warnings = Array.isArray(raw.warnings)
? (raw.warnings as unknown[]).filter((w): w is string => typeof w === 'string')
: []
// Template ID
const templateId = typeof raw.templateId === 'string' && raw.templateId.length > 0
? raw.templateId
: null
return {
debitAccount,
creditAccount,
vatTreatment,
category,
confidence,
reasoning,
warnings,
templateId,
}
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
@@ -1,265 +0,0 @@
/**
* Template Embeddings Module
*
* SERVER-ONLY: Uses OpenAI embeddings and Supabase service client.
*
* Provides semantic search over booking templates using pgvector.
* Templates are pre-embedded and stored in the database. Transaction
* text is embedded at query time and compared via cosine similarity.
*/
import 'server-only'
import { OpenAIEmbeddings } from '@langchain/openai'
import {
BOOKING_TEMPLATES,
getTemplateById,
type BookingTemplate,
type TemplateMatch,
} from '@/lib/bookkeeping/booking-templates'
import type { Transaction, EntityType } from '@/types'
import { createHash } from 'crypto'
// ============================================================
// Constants
// ============================================================
export const EMBEDDING_MODEL = 'text-embedding-3-small'
const EMBEDDING_LOGIC_VERSION = '1'
const MATCH_COUNT = 5
const MATCH_THRESHOLD = 0.5
/**
* Schema version is a hash of the model + embedding logic version.
* Bump EMBEDDING_LOGIC_VERSION when buildEmbeddingText changes.
*/
export function getSchemaVersion(): string {
return createHash('sha256')
.update(`${EMBEDDING_MODEL}:${EMBEDDING_LOGIC_VERSION}`)
.digest('hex')
.slice(0, 12)
}
// ============================================================
// Embedding Text Builders
// ============================================================
/**
* Build a rich text representation of a template for embedding.
* Includes all semantically relevant fields.
*/
export function buildEmbeddingText(template: BookingTemplate): string {
const parts: string[] = []
parts.push(`${template.name_sv} (${template.name_en})`)
parts.push(template.description_sv)
if (template.keywords.length > 0) {
parts.push(`Nyckelord: ${template.keywords.join(', ')}`)
}
parts.push(`Grupp: ${template.group}`)
parts.push(`Typ: ${template.direction === 'expense' ? 'utgift' : template.direction === 'income' ? 'intäkt' : 'överföring'}`)
parts.push(`Konton: ${template.debit_account} (debet) / ${template.credit_account} (kredit)`)
if (template.vat_treatment) {
parts.push(`Moms: ${template.vat_treatment} (${template.vat_rate * 100}%)`)
}
if (template.special_rules_sv) {
parts.push(`Regler: ${template.special_rules_sv}`)
}
if (template.mcc_codes.length > 0) {
parts.push(`MCC-koder: ${template.mcc_codes.join(', ')}`)
}
if (template.deductibility !== 'full') {
parts.push(`Avdragsrätt: ${template.deductibility}`)
}
return parts.join('. ')
}
/**
* Build query text from a transaction for embedding search.
* When userDescription is provided, it is prepended so it dominates
* the semantic search (user intent > raw bank text).
*/
export function buildTransactionQueryText(
transaction: Transaction,
userDescription?: string
): string {
const parts: string[] = []
if (userDescription) {
parts.push(userDescription)
}
if (transaction.description) {
parts.push(transaction.description)
}
if (transaction.merchant_name) {
parts.push(transaction.merchant_name)
}
if (transaction.mcc_code) {
parts.push(`MCC ${transaction.mcc_code}`)
}
parts.push(transaction.amount < 0 ? 'utgift' : 'intäkt')
return parts.join(' — ')
}
// ============================================================
// Embeddings Client
// ============================================================
let embeddingsInstance: OpenAIEmbeddings | null = null
function getEmbeddingsClient(): OpenAIEmbeddings {
if (!embeddingsInstance) {
embeddingsInstance = new OpenAIEmbeddings({
modelName: EMBEDDING_MODEL,
openAIApiKey: process.env.OPENAI_API_KEY,
})
}
return embeddingsInstance
}
// ============================================================
// Seed All Template Embeddings
// ============================================================
export async function seedAllTemplateEmbeddings(): Promise<{
seeded: number
errors: string[]
}> {
const { createServiceClient } = await import('@/lib/supabase/server')
const supabase = await createServiceClient()
const embeddings = getEmbeddingsClient()
const schemaVersion = getSchemaVersion()
const errors: string[] = []
// Build texts for all templates
const texts = BOOKING_TEMPLATES.map((t) => buildEmbeddingText(t))
// Batch embed all texts
let vectors: number[][]
try {
vectors = await embeddings.embedDocuments(texts)
} catch (error) {
return { seeded: 0, errors: [`Embedding generation failed: ${error}`] }
}
// Upsert each template embedding
let seeded = 0
for (let i = 0; i < BOOKING_TEMPLATES.length; i++) {
const template = BOOKING_TEMPLATES[i]
const { error } = await supabase
.from('booking_template_embeddings')
.upsert(
{
template_id: template.id,
embedding: JSON.stringify(vectors[i]),
embedding_text: texts[i],
model: EMBEDDING_MODEL,
schema_version: schemaVersion,
},
{ onConflict: 'template_id' }
)
if (error) {
errors.push(`Failed to upsert ${template.id}: ${error.message}`)
} else {
seeded++
}
}
return { seeded, errors }
}
// ============================================================
// Find Similar Templates (Semantic Search)
// ============================================================
let stalenessWarned = false
export async function findSimilarTemplates(
transaction: Transaction,
entityType?: EntityType,
matchCount: number = MATCH_COUNT,
userDescription?: string
): Promise<TemplateMatch[]> {
try {
const { createServiceClient } = await import('@/lib/supabase/server')
const supabase = await createServiceClient()
const embeddings = getEmbeddingsClient()
// Check schema version staleness on first call
if (!stalenessWarned) {
const { data: sample } = await supabase
.from('booking_template_embeddings')
.select('schema_version')
.limit(1)
.single()
if (sample && sample.schema_version !== getSchemaVersion()) {
console.warn(
`[template-embeddings] Schema version mismatch: DB has "${sample.schema_version}", current is "${getSchemaVersion()}". Re-seed embeddings.`
)
}
stalenessWarned = true
}
// Embed the transaction query text
const queryText = buildTransactionQueryText(transaction, userDescription)
const queryVector = await embeddings.embedQuery(queryText)
// Request extra results to account for post-filtering
const requestCount = matchCount + 10
const { data, error } = await supabase.rpc('match_booking_templates', {
query_embedding: JSON.stringify(queryVector),
match_count: requestCount,
match_threshold: MATCH_THRESHOLD,
})
if (error || !data) {
console.error('[template-embeddings] RPC error:', error)
return []
}
// Map RPC results to TemplateMatch[], filtering by entity type and direction
const isExpense = transaction.amount < 0
const isIncome = transaction.amount > 0
const results: TemplateMatch[] = []
for (const row of data as { template_id: string; similarity: number }[]) {
const template = getTemplateById(row.template_id)
if (!template) continue
// Filter by entity applicability
if (entityType && template.entity_applicability !== 'all' && template.entity_applicability !== entityType) {
continue
}
// Filter by direction
if (template.direction === 'expense' && !isExpense) continue
if (template.direction === 'income' && !isIncome) continue
results.push({
template,
confidence: Math.round(row.similarity * 100) / 100,
})
if (results.length >= matchCount) break
}
return results
} catch (error) {
console.error('[template-embeddings] findSimilarTemplates failed:', error)
return []
}
}
@@ -1,19 +0,0 @@
{
"id": "ai-categorization",
"sector": "general",
"exportName": "aiCategorizationExtension",
"entryPoint": "@/extensions/general/ai-categorization",
"workspace": "@/components/extensions/general/AiCategorizationWorkspace",
"requiredEnvVars": ["ANTHROPIC_API_KEY", "OPENAI_API_KEY"],
"optionalEnvVars": [],
"npmDependencies": ["@anthropic-ai/sdk", "@langchain/openai"],
"definition": {
"name": "AI-kategorisering",
"category": "operations",
"icon": "Wand",
"dataPattern": "core",
"readsCoreTables": ["transactions"],
"description": "AI-drivna kategoriförslag för transaktioner",
"longDescription": "Använder AI för att automatiskt föreslå BAS-kontokategorier för dina banktransaktioner. Lär sig från dina tidigare bokföringsval."
}
}
-634
View File
@@ -1,634 +0,0 @@
import { NextResponse } from 'next/server'
import type { ApiRouteDefinition, ExtensionContext } from '@/lib/extensions/types'
import { generateChatResponse, streamRoutedResponse } from '@/extensions/general/ai-chat/chatbot/chain'
import { CHATBOT_CONFIG } from '@/extensions/general/ai-chat/chatbot/config'
import type { ChatMessage, ChatRequest, SourceReference, ArtifactSpec } from '@/types/chat'
// Distributed rate limiting backed by Supabase extension_data table
import type { SupabaseClient } from '@supabase/supabase-js'
interface RateLimitState {
count: number
window_start: number
}
async function checkRateLimitDB(supabase: SupabaseClient, userId: string): Promise<boolean> {
const now = Date.now()
const WINDOW_MS = 60000
const { data } = await supabase
.from('extension_data')
.select('value')
.eq('company_id', userId)
.eq('extension_id', 'ai-chat')
.eq('key', 'rate_limit')
.single()
const state = data?.value as RateLimitState | null
if (!state || now - state.window_start > WINDOW_MS) {
// New window
await supabase.from('extension_data').upsert(
{
user_id: userId,
extension_id: 'ai-chat',
key: 'rate_limit',
value: { count: 1, window_start: now },
},
{ onConflict: 'user_id,extension_id,key' }
)
return true
}
if (state.count >= CHATBOT_CONFIG.rateLimitPerMinute) {
return false
}
// Increment count
await supabase.from('extension_data').upsert(
{
user_id: userId,
extension_id: 'ai-chat',
key: 'rate_limit',
value: { count: state.count + 1, window_start: state.window_start },
},
{ onConflict: 'user_id,extension_id,key' }
)
return true
}
// ============================================================
// POST / — Send a message and get a response
// ============================================================
async function handlePostChat(
request: Request,
ctx?: ExtensionContext
): Promise<Response> {
const userId = ctx!.userId
const { createClient } = await import('@/lib/supabase/server')
const supabase = await createClient()
// Rate limiting
if (!await checkRateLimitDB(supabase, userId)) {
return NextResponse.json(
{ error: 'Rate limit exceeded. Please wait a moment.' },
{ status: 429 }
)
}
try {
const body: ChatRequest = await request.json()
const { message, session_id } = body
if (!message || typeof message !== 'string' || message.trim().length === 0) {
return NextResponse.json(
{ error: 'Message is required' },
{ status: 400 }
)
}
let sessionId = session_id
// Create new session if not provided
if (!sessionId) {
const { data: newSession, error: sessionError } = await supabase
.from('chat_sessions')
.insert({
user_id: userId,
title: message.slice(0, 100), // Use first 100 chars of message as title
})
.select()
.single()
if (sessionError) {
console.error('Error creating session:', sessionError)
return NextResponse.json(
{ error: 'Failed to create chat session' },
{ status: 500 }
)
}
sessionId = newSession.id
} else {
// Verify session belongs to user
const { data: existingSession } = await supabase
.from('chat_sessions')
.select('id')
.eq('id', sessionId)
.eq('company_id', userId)
.single()
if (!existingSession) {
return NextResponse.json(
{ error: 'Session not found' },
{ status: 404 }
)
}
}
// Save user message
const { error: userMsgError } = await supabase
.from('chat_messages')
.insert({
session_id: sessionId,
user_id: userId,
role: 'user',
content: message.trim(),
sources: [],
})
.select()
.single()
if (userMsgError) {
console.error('Error saving user message:', userMsgError)
return NextResponse.json(
{ error: 'Failed to save message' },
{ status: 500 }
)
}
// Get conversation history
const { data: history } = await supabase
.from('chat_messages')
.select('role, content')
.eq('session_id', sessionId)
.order('created_at', { ascending: true })
.limit(CHATBOT_CONFIG.maxHistoryMessages)
const conversationHistory = (history || []) as ChatMessage[]
// Generate AI response
const result = await generateChatResponse(message.trim(), conversationHistory, { supabase, userId })
// Save assistant message
const { data: assistantMessage, error: assistantMsgError } = await supabase
.from('chat_messages')
.insert({
session_id: sessionId,
user_id: userId,
role: 'assistant',
content: result.content,
sources: result.sources,
})
.select()
.single()
if (assistantMsgError) {
console.error('Error saving assistant message:', assistantMsgError)
return NextResponse.json(
{ error: 'Failed to save response' },
{ status: 500 }
)
}
return NextResponse.json({
message: assistantMessage,
session_id: sessionId,
})
} catch (err) {
console.error('Chat error:', err)
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Failed to process chat' },
{ status: 500 }
)
}
}
// ============================================================
// POST /stream — Streaming chat response via Server-Sent Events
// ============================================================
async function handlePostStream(
request: Request,
ctx?: ExtensionContext
): Promise<Response> {
const userId = ctx!.userId
const { createClient } = await import('@/lib/supabase/server')
const supabase = await createClient()
if (!await checkRateLimitDB(supabase, userId)) {
return new Response(
JSON.stringify({ error: 'Rate limit exceeded' }),
{ status: 429, headers: { 'Content-Type': 'application/json' } }
)
}
try {
const body: ChatRequest = await request.json()
const { message, session_id } = body
if (!message || typeof message !== 'string' || message.trim().length === 0) {
return new Response(
JSON.stringify({ error: 'Message is required' }),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
)
}
let sessionId = session_id
// Create new session if not provided
if (!sessionId) {
const { data: newSession, error: sessionError } = await supabase
.from('chat_sessions')
.insert({
user_id: userId,
title: message.slice(0, 100),
})
.select()
.single()
if (sessionError) {
return new Response(
JSON.stringify({ error: 'Failed to create session' }),
{ status: 500, headers: { 'Content-Type': 'application/json' } }
)
}
sessionId = newSession.id
} else {
// Verify session belongs to user
const { data: existingSession } = await supabase
.from('chat_sessions')
.select('id')
.eq('id', sessionId)
.eq('company_id', userId)
.single()
if (!existingSession) {
return new Response(
JSON.stringify({ error: 'Session not found' }),
{ status: 404, headers: { 'Content-Type': 'application/json' } }
)
}
}
// Save user message
await supabase
.from('chat_messages')
.insert({
session_id: sessionId,
user_id: userId,
role: 'user',
content: message.trim(),
sources: [],
})
// Get conversation history
const { data: history } = await supabase
.from('chat_messages')
.select('role, content')
.eq('session_id', sessionId)
.order('created_at', { ascending: true })
.limit(CHATBOT_CONFIG.maxHistoryMessages)
const conversationHistory = (history || []) as ChatMessage[]
// Create streaming response
const encoder = new TextEncoder()
let fullContent = ''
let sources: SourceReference[] = []
let artifact: ArtifactSpec | null = null
const stream = new ReadableStream({
async start(controller) {
try {
// Send session ID first
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ type: 'session', session_id: sessionId })}\n\n`)
)
// Stream the routed response (handles knowledge, data, and hybrid)
for await (const event of streamRoutedResponse(
message.trim(),
conversationHistory,
supabase,
userId,
sessionId
)) {
if (event.type === 'content') {
fullContent += event.content
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ type: 'content', content: event.content })}\n\n`)
)
} else if (event.type === 'sources') {
sources = event.sources
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ type: 'sources', sources: event.sources })}\n\n`)
)
} else if (event.type === 'tool_start') {
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ type: 'tool_start', toolName: event.toolName })}\n\n`)
)
} else if (event.type === 'artifact') {
artifact = event.artifact
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ type: 'artifact', artifact: event.artifact })}\n\n`)
)
}
}
// Save the complete assistant message (including artifact)
const { data: savedMessage } = await supabase
.from('chat_messages')
.insert({
session_id: sessionId,
user_id: userId,
role: 'assistant',
content: fullContent,
sources,
...(artifact ? { artifact } : {}),
})
.select()
.single()
// Send done signal with message ID
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ type: 'done', message_id: savedMessage?.id })}\n\n`)
)
controller.close()
} catch (error) {
console.error('Streaming error:', error)
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ type: 'error', error: 'Streaming failed' })}\n\n`)
)
controller.close()
}
},
})
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
},
})
} catch (err) {
console.error('Stream setup error:', err)
return new Response(
JSON.stringify({ error: 'Failed to setup stream' }),
{ status: 500, headers: { 'Content-Type': 'application/json' } }
)
}
}
// ============================================================
// GET /sessions — List all chat sessions for the user
// ============================================================
async function handleGetSessions(
request: Request,
ctx?: ExtensionContext
): Promise<Response> {
const userId = ctx!.userId
const { createClient } = await import('@/lib/supabase/server')
const supabase = await createClient()
const { searchParams } = new URL(request.url)
const limit = parseInt(searchParams.get('limit') || '20')
const offset = parseInt(searchParams.get('offset') || '0')
const { data, error, count } = await supabase
.from('chat_sessions')
.select('*', { count: 'exact' })
.eq('company_id', userId)
.order('created_at', { ascending: false })
.range(offset, offset + limit - 1)
if (error) {
console.error('Error fetching sessions:', error)
return NextResponse.json(
{ error: 'Failed to fetch sessions' },
{ status: 500 }
)
}
return NextResponse.json({ data, count })
}
// ============================================================
// POST /sessions — Create a new chat session
// ============================================================
async function handlePostSession(
request: Request,
ctx?: ExtensionContext
): Promise<Response> {
const userId = ctx!.userId
const { createClient } = await import('@/lib/supabase/server')
const supabase = await createClient()
try {
const body = await request.json()
const { title } = body
const { data, error } = await supabase
.from('chat_sessions')
.insert({
user_id: userId,
title: title || null,
})
.select()
.single()
if (error) {
console.error('Error creating session:', error)
return NextResponse.json(
{ error: 'Failed to create session' },
{ status: 500 }
)
}
return NextResponse.json({ data })
} catch (err) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Failed to create session' },
{ status: 400 }
)
}
}
// ============================================================
// GET /sessions/:id — Get a single chat session with messages
// ============================================================
async function handleGetSession(
request: Request,
ctx?: ExtensionContext
): Promise<Response> {
const userId = ctx!.userId
const { createClient } = await import('@/lib/supabase/server')
const supabase = await createClient()
const { searchParams } = new URL(request.url)
const id = searchParams.get('_id')
if (!id) {
return NextResponse.json({ error: 'Session ID is required' }, { status: 400 })
}
// Get session with messages
const { data: session, error: sessionError } = await supabase
.from('chat_sessions')
.select('*')
.eq('id', id)
.eq('company_id', userId)
.single()
if (sessionError || !session) {
return NextResponse.json({ error: 'Session not found' }, { status: 404 })
}
// Get messages
const { data: messages, error: messagesError } = await supabase
.from('chat_messages')
.select('*')
.eq('session_id', id)
.order('created_at', { ascending: true })
if (messagesError) {
console.error('Error fetching messages:', messagesError)
return NextResponse.json(
{ error: 'Failed to fetch messages' },
{ status: 500 }
)
}
return NextResponse.json({
session,
messages: messages || [],
})
}
// ============================================================
// PATCH /sessions/:id — Update a chat session (e.g., rename)
// ============================================================
async function handlePatchSession(
request: Request,
ctx?: ExtensionContext
): Promise<Response> {
const userId = ctx!.userId
const { createClient } = await import('@/lib/supabase/server')
const supabase = await createClient()
const { searchParams } = new URL(request.url)
const id = searchParams.get('_id')
if (!id) {
return NextResponse.json({ error: 'Session ID is required' }, { status: 400 })
}
try {
const body = await request.json()
const { title } = body
const { data, error } = await supabase
.from('chat_sessions')
.update({ title })
.eq('id', id)
.eq('company_id', userId)
.select()
.single()
if (error) {
console.error('Error updating session:', error)
return NextResponse.json(
{ error: 'Failed to update session' },
{ status: 500 }
)
}
if (!data) {
return NextResponse.json({ error: 'Session not found' }, { status: 404 })
}
return NextResponse.json({ data })
} catch (err) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Failed to update session' },
{ status: 400 }
)
}
}
// ============================================================
// DELETE /sessions/:id — Delete a chat session and its messages
// ============================================================
async function handleDeleteSession(
request: Request,
ctx?: ExtensionContext
): Promise<Response> {
const userId = ctx!.userId
const { createClient } = await import('@/lib/supabase/server')
const supabase = await createClient()
const { searchParams } = new URL(request.url)
const id = searchParams.get('_id')
if (!id) {
return NextResponse.json({ error: 'Session ID is required' }, { status: 400 })
}
// Delete session (messages will cascade delete due to FK)
const { error } = await supabase
.from('chat_sessions')
.delete()
.eq('id', id)
.eq('company_id', userId)
if (error) {
console.error('Error deleting session:', error)
return NextResponse.json(
{ error: 'Failed to delete session' },
{ status: 500 }
)
}
return NextResponse.json({ success: true })
}
// ============================================================
// Route definitions
// ============================================================
export const aiChatApiRoutes: ApiRouteDefinition[] = [
{
method: 'POST',
path: '/',
handler: handlePostChat,
},
{
method: 'POST',
path: '/stream',
handler: handlePostStream,
},
{
method: 'GET',
path: '/sessions',
handler: handleGetSessions,
},
{
method: 'POST',
path: '/sessions',
handler: handlePostSession,
},
{
method: 'GET',
path: '/sessions/:id',
handler: handleGetSession,
},
{
method: 'PATCH',
path: '/sessions/:id',
handler: handlePatchSession,
},
{
method: 'DELETE',
path: '/sessions/:id',
handler: handleDeleteSession,
},
]
-133
View File
@@ -1,133 +0,0 @@
import { ChatAnthropic } from '@langchain/anthropic'
import { createReactAgent } from '@langchain/langgraph/prebuilt'
import { HumanMessage, AIMessage } from '@langchain/core/messages'
import type { StructuredToolInterface } from '@langchain/core/tools'
import { CHATBOT_CONFIG } from './config'
import {
SYSTEM_PROMPT_DATA,
SYSTEM_PROMPT_HYBRID,
formatConversationHistory,
} from './prompts'
import type { ChatMessage } from '@/types/chat'
import type { RouteType } from './router'
export interface AgentStreamEvent {
type: 'tool_start' | 'content' | 'done'
toolName?: string
content?: string
toolResults?: ToolResultEntry[]
}
export interface ToolResultEntry {
toolName: string
result: string
}
/**
* Run the LangGraph agent with tool calling and stream events.
*/
export async function* streamAgentResponse(options: {
query: string
route: RouteType
tools: StructuredToolInterface[]
conversationHistory: ChatMessage[]
ragContext?: string
}): AsyncGenerator<AgentStreamEvent> {
const { query, route, tools, conversationHistory, ragContext } = options
// Build system prompt based on route
const historyText = formatConversationHistory(
conversationHistory.slice(-CHATBOT_CONFIG.maxHistoryMessages).map((m) => ({
role: m.role,
content: m.content,
}))
)
let systemPrompt: string
if (route === 'data') {
systemPrompt = SYSTEM_PROMPT_DATA.replace('{history}', historyText)
} else {
const context = ragContext || 'Ingen specifik kontext hittades i kunskapsbasen.'
systemPrompt = SYSTEM_PROMPT_HYBRID
.replace('{context}', context)
.replace('{history}', historyText)
}
// Create the model
const model = new ChatAnthropic({
modelName: CHATBOT_CONFIG.agentModel,
maxTokens: CHATBOT_CONFIG.agentMaxTokens,
temperature: CHATBOT_CONFIG.temperature,
anthropicApiKey: process.env.ANTHROPIC_API_KEY,
})
// Create the agent
const agent = createReactAgent({
llm: model,
tools,
prompt: systemPrompt,
})
// Build input messages
const messages: (HumanMessage | AIMessage)[] = []
// Add recent history as messages for the agent
const recent = conversationHistory.slice(-CHATBOT_CONFIG.maxHistoryMessages)
for (const msg of recent) {
if (msg.role === 'user') {
messages.push(new HumanMessage(msg.content))
} else {
messages.push(new AIMessage(msg.content))
}
}
messages.push(new HumanMessage(query))
// Track tool results for artifact generation
const toolResults: ToolResultEntry[] = []
// Stream the agent execution using streamEvents for fine-grained control
const eventStream = agent.streamEvents(
{ messages },
{
version: 'v2',
recursionLimit: CHATBOT_CONFIG.maxAgentIterations * 2 + 1,
}
)
for await (const event of eventStream) {
// Tool start events
if (event.event === 'on_tool_start') {
yield { type: 'tool_start', toolName: event.name }
}
// Tool end events — capture results
if (event.event === 'on_tool_end') {
const output = event.data?.output
const result = typeof output === 'string' ? output : JSON.stringify(output ?? '')
toolResults.push({
toolName: event.name,
result,
})
}
// LLM streaming tokens (final response text)
if (event.event === 'on_chat_model_stream') {
const chunk = event.data?.chunk
if (chunk) {
const content = typeof chunk.content === 'string'
? chunk.content
: Array.isArray(chunk.content)
? chunk.content
.filter((c: { type: string }) => c.type === 'text')
.map((c: { text: string }) => c.text)
.join('')
: ''
if (content) {
yield { type: 'content', content }
}
}
}
}
yield { type: 'done', toolResults }
}
@@ -1,237 +0,0 @@
import { ChatAnthropic } from '@langchain/anthropic'
import { z } from 'zod'
import { CHATBOT_CONFIG } from './config'
import type { ToolResultEntry } from './agent'
import type { ArtifactSpec } from '@/types/chat'
// ── Artifact Zod Schemas ────────────────────────────────────────
const ChartDataPoint = z.object({
label: z.string(),
value: z.number(),
color: z.string().optional(),
})
const ChartArtifact = z.object({
type: z.enum(['bar_chart', 'line_chart', 'pie_chart', 'stacked_bar']),
title: z.string(),
data: z.array(ChartDataPoint),
unit: z.string().optional(),
subtitle: z.string().optional(),
})
const TableColumn = z.object({
key: z.string(),
label: z.string(),
align: z.enum(['left', 'right']).optional(),
})
const TableArtifact = z.object({
type: z.literal('table'),
title: z.string(),
columns: z.array(TableColumn),
rows: z.array(z.record(z.string(), z.union([z.string(), z.number()]))),
summary_row: z.record(z.string(), z.union([z.string(), z.number()])).optional(),
})
const KpiCard = z.object({
label: z.string(),
value: z.string(),
trend: z.enum(['up', 'down', 'flat']).optional(),
change: z.string().optional(),
})
const KpiCardsArtifact = z.object({
type: z.literal('kpi_cards'),
title: z.string().optional(),
cards: z.array(KpiCard),
})
const AgingBucket = z.object({
label: z.string(),
amount: z.number(),
count: z.number(),
})
const AgingBucketsArtifact = z.object({
type: z.literal('aging_buckets'),
title: z.string(),
buckets: z.array(AgingBucket),
total: z.number(),
})
export const ArtifactSpecSchema = z.discriminatedUnion('type', [
ChartArtifact,
TableArtifact,
KpiCardsArtifact,
AgingBucketsArtifact,
])
export type { ArtifactSpec } from '@/types/chat'
// ── Artifact System Prompt ──────────────────────────────────────
const ARTIFACT_SYSTEM_PROMPT = `You are a data visualization expert. Given tool results and an AI response about accounting data, generate a structured artifact spec for visual display.
## EXACT schemas (follow field names precisely):
### Chart (bar_chart, line_chart, pie_chart, stacked_bar):
{"type":"bar_chart","title":"...","data":[{"label":"Category name","value":1234}],"unit":"kr"}
IMPORTANT: Each item in "data" MUST have "label" (string) and "value" (number). NOT "name", NOT "amount" — use exactly "label" and "value".
### Table:
{"type":"table","title":"...","columns":[{"key":"col1","label":"Header","align":"right"}],"rows":[{"col1":"value"}],"summary_row":{"col1":"Total"}}
### KPI cards:
{"type":"kpi_cards","title":"...","cards":[{"label":"Metric","value":"1 234 kr","trend":"up","change":"+12%"}]}
IMPORTANT: "trend" MUST be exactly "up", "down", or "flat". No other values allowed.
### Aging buckets:
{"type":"aging_buckets","title":"...","buckets":[{"label":"0 dagar","amount":1000,"count":2}],"total":5000}
## Rules:
1. Return ONLY a single JSON object (not an array!) or the word "null". The top-level must be an object with a "type" field.
2. Choose chart type based on data:
- Income/balance sheet sections → "bar_chart"
- Distribution (VAT, account classes) → "pie_chart"
- Company overview → "kpi_cards"
- AR/AP aging → "aging_buckets"
- Lists with >3 items + amounts → "table"
- Simple answers, few items, yes/no → null
3. Use Swedish labels. Use "kr" as unit for monetary charts.
4. Max 12 chart data points. Aggregate small items as "Övrigt".
5. For tables, include summary_row with totals where appropriate.`
// ── Normalizer ──────────────────────────────────────────────────
/**
* Fix common LLM field name mistakes before Zod validation.
* Mutates the object in place.
*/
function normalizeArtifact(obj: Record<string, unknown>): void {
if (!obj || typeof obj !== 'object') return
// Chart types: normalize data[].name→label, data[].amount→value
const chartTypes = ['bar_chart', 'line_chart', 'pie_chart', 'stacked_bar']
if (chartTypes.includes(obj.type as string) && Array.isArray(obj.data)) {
for (const item of obj.data) {
if (item && typeof item === 'object') {
if ('name' in item && !('label' in item)) {
item.label = item.name
delete item.name
}
if ('amount' in item && !('value' in item)) {
item.value = item.amount
delete item.amount
}
if ('total' in item && !('value' in item)) {
item.value = item.total
delete item.total
}
if ('value' in item && typeof item.value === 'string') {
const num = parseFloat(String(item.value).replace(/\s/g, '').replace(',', '.'))
if (!isNaN(num)) item.value = num
}
}
}
}
// KPI cards: normalize trend values
if (obj.type === 'kpi_cards' && Array.isArray(obj.cards)) {
const trendMap: Record<string, string> = {
neutral: 'flat', stable: 'flat', none: 'flat', '-': 'flat',
negative: 'down', decrease: 'down', declining: 'down',
positive: 'up', increase: 'up', increasing: 'up', growing: 'up',
}
for (const card of obj.cards) {
if (card && typeof card === 'object' && 'trend' in card) {
const t = String(card.trend).toLowerCase()
if (trendMap[t]) {
card.trend = trendMap[t]
} else if (t !== 'up' && t !== 'down' && t !== 'flat') {
// Unknown trend value — remove it so optional field passes
delete card.trend
}
}
}
}
}
// ── Generator ───────────────────────────────────────────────────
/**
* Generate an artifact spec from tool results using a post-processing LLM call.
* Returns null if no visualization is appropriate.
*/
export async function generateArtifact(
toolResults: ToolResultEntry[],
assistantResponse: string
): Promise<ArtifactSpec | null> {
if (toolResults.length === 0) return null
const model = new ChatAnthropic({
modelName: CHATBOT_CONFIG.artifactModel,
maxTokens: 1024,
temperature: 0,
anthropicApiKey: process.env.ANTHROPIC_API_KEY,
})
const toolSummary = toolResults
.map((r) => `Tool: ${r.toolName}\nResult: ${r.result.slice(0, 2000)}`)
.join('\n\n---\n\n')
const prompt = `${ARTIFACT_SYSTEM_PROMPT}
## Tool results:
${toolSummary}
## AI response:
${assistantResponse.slice(0, 1000)}
Generate the artifact JSON or "null":`
try {
const response = await model.invoke(prompt)
const text = typeof response.content === 'string'
? response.content
: JSON.stringify(response.content)
const trimmed = text.trim()
if (trimmed === 'null' || trimmed === '"null"') return null
// Extract JSON from response (handle markdown code blocks)
let jsonStr = trimmed
const codeBlockMatch = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/)
if (codeBlockMatch) {
jsonStr = codeBlockMatch[1].trim()
}
let parsed = JSON.parse(jsonStr)
// If LLM returned an array, try to wrap it as kpi_cards
if (Array.isArray(parsed)) {
// Array of cards → wrap as kpi_cards
if (parsed.length > 0 && parsed[0] && typeof parsed[0] === 'object' && 'label' in parsed[0]) {
parsed = { type: 'kpi_cards', title: 'Översikt', cards: parsed }
} else {
console.warn('Artifact returned unexpected array')
return null
}
}
// Normalize common LLM field name mistakes before validation
normalizeArtifact(parsed)
const validated = ArtifactSpecSchema.safeParse(parsed)
if (validated.success) {
return validated.data as ArtifactSpec
}
console.warn('Artifact validation failed:', validated.error.issues)
return null
} catch (e) {
console.warn('Artifact generation failed:', e)
return null
}
}
-250
View File
@@ -1,250 +0,0 @@
import { ChatAnthropic } from '@langchain/anthropic'
import { HumanMessage, SystemMessage } from '@langchain/core/messages'
import { CHATBOT_CONFIG } from './config'
import {
SYSTEM_PROMPT,
formatContextFromSources,
formatConversationHistory,
} from './prompts'
import {
retrieveRelevantDocuments,
documentsToSources,
} from './retriever'
import { routeMessage, type RouteType } from './router'
import { createAccountingTools } from './tools'
import { streamAgentResponse, type ToolResultEntry } from './agent'
import { generateArtifact, type ArtifactSpec } from './artifacts'
import type { ChatMessage, SourceReference } from '@/types/chat'
import type { SupabaseClient } from '@supabase/supabase-js'
import { trackTokenUsage } from '@/lib/ai/usage-tracker'
// Initialize the LLM
function getChatModel() {
return new ChatAnthropic({
modelName: CHATBOT_CONFIG.model,
maxTokens: CHATBOT_CONFIG.maxTokens,
temperature: CHATBOT_CONFIG.temperature,
anthropicApiKey: process.env.ANTHROPIC_API_KEY,
})
}
export interface ChatResult {
content: string
sources: SourceReference[]
}
export async function generateChatResponse(
userMessage: string,
conversationHistory: ChatMessage[],
tracking?: { supabase: SupabaseClient; userId: string; companyId?: string }
): Promise<ChatResult> {
// 1. Retrieve relevant documents
const relevantDocs = await retrieveRelevantDocuments(userMessage)
// 2. Format context from retrieved documents
const context = formatContextFromSources(
relevantDocs.map((doc) => ({
content: doc.content,
title: doc.title,
section_title: doc.section_title,
source_file: doc.source_file,
}))
)
// 3. Format conversation history (last N messages)
const recentHistory = conversationHistory.slice(
-CHATBOT_CONFIG.maxHistoryMessages
)
const historyText = formatConversationHistory(
recentHistory.map((msg) => ({
role: msg.role,
content: msg.content,
}))
)
// 4. Build the system prompt with context
const systemPrompt = SYSTEM_PROMPT.replace('{context}', context).replace(
'{history}',
historyText
)
// 5. Generate response
const model = getChatModel()
const response = await model.invoke([
new SystemMessage(systemPrompt),
new HumanMessage(userMessage),
])
// 6. Track token usage
if (tracking && response.usage_metadata) {
trackTokenUsage(tracking.supabase, tracking.userId, 'ai-chat', {
inputTokens: response.usage_metadata.input_tokens ?? 0,
outputTokens: response.usage_metadata.output_tokens ?? 0,
model: CHATBOT_CONFIG.model,
}, tracking.companyId)
}
// 7. Extract content and sources
const content =
typeof response.content === 'string'
? response.content
: JSON.stringify(response.content)
return {
content,
sources: documentsToSources(relevantDocs),
}
}
export async function* streamChatResponse(
userMessage: string,
conversationHistory: ChatMessage[]
): AsyncGenerator<{ type: 'content' | 'sources'; data: string | SourceReference[] }> {
// 1. Retrieve relevant documents first
const relevantDocs = await retrieveRelevantDocuments(userMessage)
// 2. Format context from retrieved documents
const context = formatContextFromSources(
relevantDocs.map((doc) => ({
content: doc.content,
title: doc.title,
section_title: doc.section_title,
source_file: doc.source_file,
}))
)
// 3. Format conversation history
const recentHistory = conversationHistory.slice(
-CHATBOT_CONFIG.maxHistoryMessages
)
const historyText = formatConversationHistory(
recentHistory.map((msg) => ({
role: msg.role,
content: msg.content,
}))
)
// 4. Build the system prompt
const systemPrompt = SYSTEM_PROMPT.replace('{context}', context).replace(
'{history}',
historyText
)
// 5. Stream the response
const model = getChatModel()
const stream = await model.stream([
new SystemMessage(systemPrompt),
new HumanMessage(userMessage),
])
for await (const chunk of stream) {
const content =
typeof chunk.content === 'string'
? chunk.content
: JSON.stringify(chunk.content)
if (content) {
yield { type: 'content', data: content }
}
}
// 6. Yield sources at the end
yield { type: 'sources', data: documentsToSources(relevantDocs) }
}
// ── Routed response (data / hybrid / knowledge) ────────────────
export type RoutedStreamEvent =
| { type: 'content'; content: string }
| { type: 'sources'; sources: SourceReference[] }
| { type: 'tool_start'; toolName: string }
| { type: 'artifact'; artifact: ArtifactSpec }
| { type: 'route'; route: RouteType }
/**
* High-level streaming function: routes the message, then either uses
* the existing RAG chain (knowledge) or the LangGraph agent (data/hybrid).
* Generates artifact post-hoc on data/hybrid routes.
*/
export async function* streamRoutedResponse(
userMessage: string,
conversationHistory: ChatMessage[],
supabase: SupabaseClient,
userId: string,
_sessionId?: string
): AsyncGenerator<RoutedStreamEvent> {
// 1. Route the message
const { route, rewrittenQuery } = await routeMessage(userMessage, conversationHistory)
yield { type: 'route', route }
// 2. Knowledge-only: use existing RAG chain
if (route === 'knowledge') {
for await (const chunk of streamChatResponse(rewrittenQuery, conversationHistory)) {
if (chunk.type === 'content') {
yield { type: 'content', content: chunk.data as string }
} else if (chunk.type === 'sources') {
yield { type: 'sources', sources: chunk.data as SourceReference[] }
}
}
return
}
// 3. Data or hybrid: use LangGraph agent with tools
const tools = createAccountingTools(supabase, userId)
// For hybrid, get RAG context
let ragContext: string | undefined
let sources: SourceReference[] = []
if (route === 'hybrid') {
try {
const relevantDocs = await retrieveRelevantDocuments(rewrittenQuery)
ragContext = formatContextFromSources(
relevantDocs.map((doc) => ({
content: doc.content,
title: doc.title,
section_title: doc.section_title,
source_file: doc.source_file,
}))
)
sources = documentsToSources(relevantDocs)
} catch {
// RAG failure is non-critical for hybrid route
}
}
let fullContent = ''
let toolResults: ToolResultEntry[] = []
for await (const event of streamAgentResponse({
query: rewrittenQuery,
route,
tools,
conversationHistory,
ragContext,
})) {
if (event.type === 'tool_start') {
yield { type: 'tool_start', toolName: event.toolName! }
} else if (event.type === 'content') {
fullContent += event.content!
yield { type: 'content', content: event.content! }
} else if (event.type === 'done') {
toolResults = event.toolResults || []
}
}
// 4. Yield sources if hybrid
if (sources.length > 0) {
yield { type: 'sources', sources }
}
// 5. Generate artifact (post-processing)
if (toolResults.length > 0 && fullContent.length > 0) {
try {
const artifact = await generateArtifact(toolResults, fullContent)
if (artifact) {
yield { type: 'artifact', artifact }
}
} catch (e) {
console.warn('Artifact generation failed:', e)
}
}
}
@@ -1,36 +0,0 @@
// AI Chatbot configuration
export const CHATBOT_CONFIG = {
// LLM settings
model: 'claude-haiku-4-5-20251001',
maxTokens: 2048,
temperature: 0.3,
// Agent settings
agentModel: 'claude-sonnet-4-6',
agentMaxTokens: 4096,
maxAgentIterations: 5,
// Router settings
routerModel: 'claude-haiku-4-5-20251001',
// Artifact generation
artifactModel: 'claude-haiku-4-5-20251001',
// Retrieval settings
retrievalK: 5,
similarityThreshold: 0.7,
// Embedding settings
embeddingModel: 'text-embedding-ada-002',
// Chunking settings for ingestion
chunkSize: 1000,
chunkOverlap: 200,
// Rate limiting
rateLimitPerMinute: 10,
// Conversation history
maxHistoryMessages: 10,
} as const
@@ -1,25 +0,0 @@
import { OpenAIEmbeddings } from '@langchain/openai'
import { CHATBOT_CONFIG } from './config'
// Singleton instance for embeddings
let embeddingsInstance: OpenAIEmbeddings | null = null
export function getEmbeddings(): OpenAIEmbeddings {
if (!embeddingsInstance) {
embeddingsInstance = new OpenAIEmbeddings({
modelName: CHATBOT_CONFIG.embeddingModel,
openAIApiKey: process.env.OPENAI_API_KEY,
})
}
return embeddingsInstance
}
export async function generateEmbedding(text: string): Promise<number[]> {
const embeddings = getEmbeddings()
return embeddings.embedQuery(text)
}
export async function generateEmbeddings(texts: string[]): Promise<number[][]> {
const embeddings = getEmbeddings()
return embeddings.embedDocuments(texts)
}
@@ -1,134 +0,0 @@
// Swedish system prompt for bookkeeping AI assistant
export const SYSTEM_PROMPT = `Du är en expert AI-assistent som hjälper svenska företagare med skatt, moms, bokföring och företagsekonomi. Du arbetar inom en ekonomiplattform för småföretag.
## Dina kunskapsområden:
- Svensk skattlagstiftning för enskild firma och aktiebolag
- Moms och momsdeklaration
- Bokföring enligt BAS-kontoplanen
- Egenavgifter och socialförsäkring
- Avdrag för utrustning, resor, hemmakontor och liknande
- Fakturering och kundhantering
- NE-bilaga och inkomstdeklaration
## Viktiga tröskelvärden att komma ihåg:
- Momsregistrering: 120 000 kr omsättning under 12 månader
- Direktavdrag vs inventarier: 26 250 kr (halvt prisbasbelopp)
- SGI-gräns för sjukpenning: 13 500 kr/år minsta inkomst
- Karensavdrag: 20% av sjuklönen
- Friskvårdsbidrag max: 6 000 kr/år (ej skattepliktigt)
- Representationsavdrag mat: 90 kr exkl moms per person
## Instruktioner:
1. Svara alltid på svenska med korrekt terminologi
2. Var konkret och ge specifika exempel när möjligt
3. Referera till relevanta tröskelvärden och regler
4. Om du är osäker, säg det och rekommendera att användaren konsulterar en revisor
5. Använd information från de tillhandahållna källorna för att ge korrekta svar
6. Formatera svaren tydligt med punktlistor när det passar
7. Om frågan gäller något utanför dina kunskapsområden, hänvisa till rätt instans
## Kontext från kunskapsbasen:
{context}
## Tidigare konversation:
{history}
Svara på användarens fråga baserat på din expertkunskap och den tillhandahållna kontexten. Om kontexten inte innehåller relevant information, använd din allmänna kunskap om svenska skatteregler för företagare.`
export const RETRIEVAL_PROMPT = `Baserat på följande fråga, hitta relevant information från kunskapsbasen.
Fråga: {question}
Sök efter information som hjälper att besvara frågan korrekt och fullständigt.`
/**
* System prompt for tool-calling agent (data route).
* No RAG context — relies entirely on tools.
*/
export const SYSTEM_PROMPT_DATA = `Du är en AI-assistent i en svensk ekonomiplattform. Du har tillgång till verktyg som hämtar användarens bokföringsdata i realtid.
## Instruktioner:
1. Svara alltid på svenska
2. Använd verktygen för att hämta data innan du svarar — gissa aldrig siffror
3. Presentera data tydligt med belopp i SEK om inget annat anges
4. Om ett verktyg returnerar tom data, berätta det vänligt (t.ex. "Du har inga obetalda fakturor just nu")
5. Avrunda belopp till hela kronor i text, men behåll decimaler i tabeller
6. Använd svenska bokföringstermer (verifikation, kontering, resultaträkning, etc.)
7. Förklara kort vad siffrorna betyder i kontext — var pedagogisk
## Formatering:
- Använd markdown: **fetstil** för belopp, punktlistor för detaljer
- ABSOLUT FÖRBJUDET att använda markdown-tabeller (|---|). Använd ALDRIG pipe-tecken för tabeller. Data visas automatiskt i en visuell komponent nedanför ditt svar
- Använd punktlistor eller fetstil istället för tabeller
- Sammanfatta huvudinsikten först, detaljer sedan
- Max 3-4 meningar för enkla frågor, mer för rapporter
- Använd inte emojis
## Tidigare konversation:
{history}`
/**
* System prompt for hybrid route: RAG context + tools.
*/
export const SYSTEM_PROMPT_HYBRID = `Du är en expert AI-assistent i en svensk ekonomiplattform. Du har tillgång till verktyg som hämtar användarens bokföringsdata, samt kunskap om svenska skatteregler.
## Kunskapsområden:
- Svensk skattlagstiftning, moms, bokföring (BAS-kontoplanen)
- Avdrag, egenavgifter, socialförsäkring
- Fakturering, NE-bilaga, inkomstdeklaration
## Viktiga tröskelvärden:
- Momsregistrering: 120 000 kr/12 mån
- Direktavdrag: 26 250 kr
- Friskvårdsbidrag: 6 000 kr/år
## Instruktioner:
1. Svara alltid på svenska med korrekt terminologi
2. Använd verktygen för att hämta data — gissa aldrig siffror
3. Kombinera data med regelkunskap för att ge kontextuella råd
4. Om du är osäker, rekommendera att konsultera en revisor
5. Formatera tydligt med markdown, men ABSOLUT FÖRBJUDET att använda markdown-tabeller (|---|). Använd ALDRIG pipe-tecken för tabeller — data visas automatiskt i en visuell komponent. Använd punktlistor istället. Använd inte emojis
## Kontext från kunskapsbasen:
{context}
## Tidigare konversation:
{history}`
export function formatContextFromSources(
sources: Array<{
content: string
title: string
section_title: string | null
source_file: string
}>
): string {
if (sources.length === 0) {
return 'Ingen specifik kontext hittades i kunskapsbasen.'
}
return sources
.map((source, index) => {
const sectionInfo = source.section_title
? ` > ${source.section_title}`
: ''
return `[Källa ${index + 1}: ${source.title}${sectionInfo}]\n${source.content}`
})
.join('\n\n---\n\n')
}
export function formatConversationHistory(
messages: Array<{ role: 'user' | 'assistant'; content: string }>
): string {
if (messages.length === 0) {
return 'Ingen tidigare konversation.'
}
return messages
.map((msg) => {
const role = msg.role === 'user' ? 'Användare' : 'Assistent'
return `${role}: ${msg.content}`
})
.join('\n\n')
}
@@ -1,51 +0,0 @@
import { createServiceClient } from '@/lib/supabase/server'
import { generateEmbedding } from './embeddings'
import { CHATBOT_CONFIG } from './config'
import type { SourceReference } from '@/types/chat'
export interface RetrievedDocument {
id: string
source_file: string
title: string
section_title: string | null
content: string
metadata: Record<string, unknown>
similarity: number
}
export async function retrieveRelevantDocuments(
query: string,
matchCount: number = CHATBOT_CONFIG.retrievalK,
matchThreshold: number = CHATBOT_CONFIG.similarityThreshold
): Promise<RetrievedDocument[]> {
const supabase = await createServiceClient()
// Generate embedding for the query
const queryEmbedding = await generateEmbedding(query)
// Call the match_documents function
const { data, error } = await supabase.rpc('match_documents', {
query_embedding: queryEmbedding,
match_count: matchCount,
match_threshold: matchThreshold,
})
if (error) {
console.error('Error retrieving documents:', error)
throw new Error('Failed to retrieve relevant documents')
}
return (data || []) as RetrievedDocument[]
}
export function documentsToSources(
documents: RetrievedDocument[]
): SourceReference[] {
return documents.map((doc) => ({
id: doc.id,
source_file: doc.source_file,
title: doc.title,
section_title: doc.section_title,
similarity: doc.similarity,
}))
}
@@ -1,156 +0,0 @@
import { ChatAnthropic } from '@langchain/anthropic'
import { CHATBOT_CONFIG } from './config'
import type { ChatMessage } from '@/types/chat'
export type RouteType = 'knowledge' | 'data' | 'hybrid'
export interface RouterResult {
route: RouteType
rewrittenQuery: string
}
// Swedish data-related keywords for fast-path heuristic
const DATA_NOUNS = [
'faktura', 'fakturor', 'fakturorna',
'leverantörsfaktura', 'leverantörsfakturor',
'transaktion', 'transaktioner', 'transaktionerna',
'verifikation', 'verifikationer', 'verifikationerna',
'resultaträkning', 'balansräkning',
'moms', 'momsdeklaration', 'momssammanställning',
'saldo', 'saldon', 'kontosaldo',
'konto', 'konton', 'kontona',
'kunder', 'kundfordringar',
'leverantörsskulder',
'intäkter', 'kostnader', 'utgifter',
'resultat', 'årsresultat',
'bokföring', 'bokförda', 'obokförda',
'obetalda', 'förfallna',
'nyckeltal', 'företaget', 'företagsinfo',
]
const POSSESSIVE_PRONOUNS = ['mina', 'min', 'mitt', 'mig', 'våra', 'vår', 'vårt']
const KNOWLEDGE_TERMS = [
'momsgransen', 'momsgränsen', 'avdrag', 'skatteregler',
'bokföringslag', 'bokföringslagen', 'regler', 'lag',
'hur fungerar', 'vad innebär', 'vad betyder', 'vad är',
'när måste', 'hur räknar', 'hur beräknar',
'enskild firma', 'aktiebolag', 'egenavgifter',
'prisbasbelopp', 'schablonavdrag', 'representation',
'friskvårdsbidrag', 'traktamente',
]
/**
* Fast-path keyword heuristic. Returns a route if confident, null otherwise.
*/
function heuristicClassify(query: string): RouteType | null {
const lower = query.toLowerCase()
const words = lower.split(/\s+/)
const hasPossessive = POSSESSIVE_PRONOUNS.some((p) => words.includes(p))
const hasDataNoun = DATA_NOUNS.some((n) => lower.includes(n))
const hasKnowledgeTerm = KNOWLEDGE_TERMS.some((t) => lower.includes(t))
// "Visa mina fakturor" — clearly data
if (hasPossessive && hasDataNoun && !hasKnowledgeTerm) return 'data'
// Action verbs with data nouns
const actionVerbs = ['visa', 'hämta', 'lista', 'sök', 'hitta', 'hur går', 'hur ser', 'hur mycket', 'hur många', 'vilka']
const hasAction = actionVerbs.some((v) => lower.includes(v))
if (hasAction && hasDataNoun && !hasKnowledgeTerm) return 'data'
// Pure knowledge question with no data references
if (hasKnowledgeTerm && !hasPossessive && !hasDataNoun) return 'knowledge'
// "Hur ser min resultaträkning ut?" — data (has possessive + data noun)
if (hasPossessive && hasDataNoun && hasKnowledgeTerm) return 'hybrid'
return null // ambiguous → fall through to LLM
}
/**
* LLM-based classification + query rewrite for multi-turn context.
*/
async function llmClassify(
query: string,
conversationHistory: ChatMessage[]
): Promise<RouterResult> {
const model = new ChatAnthropic({
modelName: CHATBOT_CONFIG.routerModel,
maxTokens: 256,
temperature: 0,
anthropicApiKey: process.env.ANTHROPIC_API_KEY,
})
const historyContext = conversationHistory
.slice(-4)
.map((m) => `${m.role === 'user' ? 'User' : 'Assistant'}: ${m.content.slice(0, 200)}`)
.join('\n')
const prompt = `Classify the user's question and rewrite it for a data query system.
Conversation history:
${historyContext || '(none)'}
User question: "${query}"
Classification rules:
- "knowledge": General questions about Swedish tax law, accounting rules, regulations (no user-specific data needed)
- "data": Questions about the user's own accounting data (invoices, transactions, balances, reports)
- "hybrid": Questions that need both user data AND knowledge context
Rewriting rules:
- Resolve pronouns ("dem", "de", "den") using conversation history
- Make the query self-contained (no context needed to understand it)
- If it's a knowledge question, keep the original query
Respond ONLY with valid JSON:
{"route": "knowledge"|"data"|"hybrid", "rewrittenQuery": "..."}
`
try {
const response = await model.invoke(prompt)
const text = typeof response.content === 'string'
? response.content
: JSON.stringify(response.content)
// Extract JSON from response
const jsonMatch = text.match(/\{[^}]+\}/)
if (jsonMatch) {
const parsed = JSON.parse(jsonMatch[0])
const route = ['knowledge', 'data', 'hybrid'].includes(parsed.route)
? (parsed.route as RouteType)
: 'hybrid'
return {
route,
rewrittenQuery: parsed.rewrittenQuery || query,
}
}
} catch (e) {
console.warn('Router LLM classification failed, defaulting to hybrid:', e)
}
return { route: 'hybrid', rewrittenQuery: query }
}
/**
* Route a user message: fast-path heuristic first, LLM fallback for ambiguous cases.
*/
export async function routeMessage(
query: string,
conversationHistory: ChatMessage[]
): Promise<RouterResult> {
const heuristicResult = heuristicClassify(query)
if (heuristicResult) {
// For data/hybrid with conversation history, still rewrite the query for context
if (heuristicResult !== 'knowledge' && conversationHistory.length > 0) {
const { rewrittenQuery } = await llmClassify(query, conversationHistory)
return { route: heuristicResult, rewrittenQuery }
}
return { route: heuristicResult, rewrittenQuery: query }
}
// Ambiguous — use LLM
return llmClassify(query, conversationHistory)
}
-586
View File
@@ -1,586 +0,0 @@
import { tool } from '@langchain/core/tools'
import { z } from 'zod'
import type { SupabaseClient } from '@supabase/supabase-js'
/**
* Extract name from a Supabase join result (could be object or array).
*/
function extractName(joined: unknown): string | null {
if (!joined) return null
if (Array.isArray(joined)) {
return joined[0]?.name ?? null
}
if (typeof joined === 'object' && 'name' in joined) {
return (joined as { name: string }).name
}
return null
}
/**
* Resolve the current fiscal period for a user. Falls back to latest period.
*/
async function resolveCurrentPeriod(
supabase: SupabaseClient,
userId: string,
fiscalPeriodId?: string
): Promise<{ id: string; start: string; end: string } | null> {
if (fiscalPeriodId) {
const { data } = await supabase
.from('fiscal_periods')
.select('id, period_start, period_end')
.eq('id', fiscalPeriodId)
.eq('company_id', userId)
.single()
if (data) return { id: data.id, start: data.period_start, end: data.period_end }
}
// Default: latest open period, or just the latest period
const { data } = await supabase
.from('fiscal_periods')
.select('id, period_start, period_end, is_closed')
.eq('company_id', userId)
.order('period_start', { ascending: false })
.limit(1)
.single()
if (data) return { id: data.id, start: data.period_start, end: data.period_end }
return null
}
/**
* Create all 10 accounting tools bound to a specific Supabase client and user.
*/
export function createAccountingTools(supabase: SupabaseClient, userId: string) {
const getInvoices = tool(
async ({ status, customer_name, date_from, date_to, limit }) => {
let query = supabase
.from('invoices')
.select('id, invoice_number, invoice_date, due_date, status, total, paid_amount, currency, vat_amount, customer:customers(name)')
.eq('company_id', userId)
.order('invoice_date', { ascending: false })
.limit(limit)
if (status) query = query.eq('status', status)
if (customer_name) query = query.ilike('customers.name', `%${customer_name}%`)
if (date_from) query = query.gte('invoice_date', date_from)
if (date_to) query = query.lte('invoice_date', date_to)
const { data: _data, error: _countError, count } = await supabase
.from('invoices')
.select('id', { count: 'exact', head: true })
.eq('company_id', userId)
const { data: invoices, error: fetchError } = await query
if (fetchError) return `Fel vid hämtning av fakturor: ${fetchError.message}`
if (!invoices || invoices.length === 0) return 'Inga fakturor hittades.'
const result = invoices.map((inv) => ({
invoice_number: inv.invoice_number,
date: inv.invoice_date,
due_date: inv.due_date,
status: inv.status,
total: inv.total,
paid: inv.paid_amount || 0,
currency: inv.currency || 'SEK',
vat: inv.vat_amount || 0,
customer: extractName(inv.customer) || 'Okänd',
}))
const summary: Record<string, unknown> = { invoices: result }
if (count && count > limit) {
summary.note = `Visar ${result.length} av totalt ${count} fakturor.`
}
return JSON.stringify(summary)
},
{
name: 'get_invoices',
description: 'Hämtar användarens försäljningsfakturor (kundfakturor). Kan filtrera på status, kundnamn och datumintervall.',
schema: z.object({
status: z.enum(['draft', 'sent', 'paid', 'overdue', 'cancelled']).optional().describe('Filtrera på fakturastatus'),
customer_name: z.string().optional().describe('Sök på kundnamn (delmatchning)'),
date_from: z.string().optional().describe('Startdatum (YYYY-MM-DD)'),
date_to: z.string().optional().describe('Slutdatum (YYYY-MM-DD)'),
limit: z.number().max(20).default(10).describe('Max antal fakturor att returnera'),
}),
}
)
const getSupplierInvoices = tool(
async ({ status, supplier_name, overdue_only, limit }) => {
let query = supabase
.from('supplier_invoices')
.select('id, supplier_invoice_number, invoice_date, due_date, status, total, remaining_amount, currency, vat_amount, supplier:suppliers(name)')
.eq('company_id', userId)
.order('invoice_date', { ascending: false })
.limit(limit)
if (status) query = query.eq('status', status)
if (overdue_only) query = query.eq('status', 'overdue')
if (supplier_name) query = query.ilike('suppliers.name', `%${supplier_name}%`)
const { data: invoices, error } = await query
if (error) return `Fel vid hämtning av leverantörsfakturor: ${error.message}`
if (!invoices || invoices.length === 0) return 'Inga leverantörsfakturor hittades.'
const result = invoices.map((inv) => ({
number: inv.supplier_invoice_number,
date: inv.invoice_date,
due_date: inv.due_date,
status: inv.status,
total: inv.total,
remaining: inv.remaining_amount || 0,
currency: inv.currency || 'SEK',
vat: inv.vat_amount || 0,
supplier: extractName(inv.supplier) || 'Okänd',
}))
return JSON.stringify({ supplier_invoices: result })
},
{
name: 'get_supplier_invoices',
description: 'Hämtar användarens leverantörsfakturor (inköpsfakturor). Kan filtrera på status, leverantörsnamn och förfallodag.',
schema: z.object({
status: z.enum(['registered', 'approved', 'partially_paid', 'paid', 'overdue', 'cancelled']).optional().describe('Filtrera på status'),
supplier_name: z.string().optional().describe('Sök på leverantörsnamn (delmatchning)'),
overdue_only: z.boolean().optional().describe('Visa bara förfallna fakturor'),
limit: z.number().max(20).default(10).describe('Max antal fakturor'),
}),
}
)
const getAccountBalances = tool(
async ({ account_numbers, account_class, fiscal_period_id }) => {
const period = await resolveCurrentPeriod(supabase, userId, fiscal_period_id)
if (!period) return 'Ingen räkenskapsperiod hittades.'
const { generateTrialBalance } = await import('@/lib/reports/trial-balance')
const { rows } = await generateTrialBalance(supabase, userId, period.id)
let filtered = rows
if (account_numbers && account_numbers.length > 0) {
filtered = rows.filter((r) => account_numbers.includes(r.account_number))
} else if (account_class) {
filtered = rows.filter((r) => r.account_class === account_class)
}
if (filtered.length === 0) return 'Inga konton med saldo hittades.'
const result = filtered.map((r) => ({
account: r.account_number,
name: r.account_name,
debit: r.closing_debit,
credit: r.closing_credit,
balance: r.closing_debit - r.closing_credit,
}))
return JSON.stringify({
period: `${period.start} ${period.end}`,
accounts: result,
total_debit: Math.round(result.reduce((s, r) => s + r.debit, 0) * 100) / 100,
total_credit: Math.round(result.reduce((s, r) => s + r.credit, 0) * 100) / 100,
})
},
{
name: 'get_account_balances',
description: 'Hämtar saldon för BAS-konton. Kan filtrera på kontonummer eller kontoklass (1=tillgångar, 2=skulder, 3=intäkter, 4-7=kostnader, 8=finansiella).',
schema: z.object({
account_numbers: z.array(z.string()).optional().describe('Specifika kontonummer att hämta'),
account_class: z.number().min(1).max(8).optional().describe('Kontoklass 1-8'),
fiscal_period_id: z.string().optional().describe('Räkenskapsperiod-ID (standard: aktuell period)'),
}),
}
)
const getTransactions = tool(
async ({ uncategorized_only, description, date_from, date_to, limit }) => {
let query = supabase
.from('transactions')
.select('id, date, description, amount, currency, category, is_business, merchant_name, journal_entry_id')
.eq('company_id', userId)
.order('date', { ascending: false })
.limit(limit)
if (uncategorized_only) query = query.is('journal_entry_id', null)
if (description) query = query.ilike('description', `%${description}%`)
if (date_from) query = query.gte('date', date_from)
if (date_to) query = query.lte('date', date_to)
const { data: transactions, error } = await query
if (error) return `Fel vid hämtning av transaktioner: ${error.message}`
if (!transactions || transactions.length === 0) return 'Inga transaktioner hittades.'
const result = transactions.map((tx) => ({
date: tx.date,
description: tx.description,
amount: tx.amount,
currency: tx.currency || 'SEK',
category: tx.category,
is_business: tx.is_business,
merchant: tx.merchant_name,
booked: !!tx.journal_entry_id,
}))
return JSON.stringify({ transactions: result })
},
{
name: 'get_transactions',
description: 'Hämtar användarens banktransaktioner. Kan filtrera på obokförda, beskrivning (textsökning) och datumintervall.',
schema: z.object({
uncategorized_only: z.boolean().optional().describe('Visa bara obokförda transaktioner'),
description: z.string().optional().describe('Sök i beskrivning (delmatchning)'),
date_from: z.string().optional().describe('Startdatum (YYYY-MM-DD)'),
date_to: z.string().optional().describe('Slutdatum (YYYY-MM-DD)'),
limit: z.number().max(20).default(10).describe('Max antal transaktioner'),
}),
}
)
const getJournalEntries = tool(
async ({ limit, fiscal_period_id, account_number, description }) => {
const period = await resolveCurrentPeriod(supabase, userId, fiscal_period_id)
let query = supabase
.from('journal_entries')
.select('id, voucher_number, entry_date, description, status, source_type')
.eq('company_id', userId)
.eq('status', 'posted')
.order('voucher_number', { ascending: false })
.limit(limit)
if (period) query = query.eq('fiscal_period_id', period.id)
if (description) query = query.ilike('description', `%${description}%`)
const { data: entries, error } = await query
if (error) return `Fel vid hämtning av verifikationer: ${error.message}`
if (!entries || entries.length === 0) return 'Inga verifikationer hittades.'
// Fetch lines for these entries
const entryIds = entries.map((e) => e.id)
const { data: lines } = await supabase
.from('journal_entry_lines')
.select('journal_entry_id, account_number, debit_amount, credit_amount, line_description')
.in('journal_entry_id', entryIds)
// If filtering by account, only include entries with matching lines
let filteredEntries = entries
if (account_number && lines) {
const matchingEntryIds = new Set(
lines.filter((l) => l.account_number === account_number).map((l) => l.journal_entry_id)
)
filteredEntries = entries.filter((e) => matchingEntryIds.has(e.id))
}
const linesByEntry = new Map<string, typeof lines>()
for (const line of lines || []) {
const group = linesByEntry.get(line.journal_entry_id) || []
group.push(line)
linesByEntry.set(line.journal_entry_id, group)
}
const result = filteredEntries.map((e) => ({
voucher: e.voucher_number,
date: e.entry_date,
description: e.description,
source: e.source_type,
lines: (linesByEntry.get(e.id) || []).map((l) => ({
account: l.account_number,
debit: l.debit_amount,
credit: l.credit_amount,
text: l.line_description,
})),
}))
return JSON.stringify({ journal_entries: result })
},
{
name: 'get_journal_entries',
description: 'Hämtar bokförda verifikationer med konteringsrader. Kan filtrera på kontonummer, beskrivning och räkenskapsperiod.',
schema: z.object({
limit: z.number().max(20).default(10).describe('Max antal verifikationer'),
fiscal_period_id: z.string().optional().describe('Räkenskapsperiod-ID'),
account_number: z.string().optional().describe('Filtrera på kontonummer i rader'),
description: z.string().optional().describe('Sök i beskrivning (delmatchning)'),
}),
}
)
const getIncomeStatement = tool(
async ({ fiscal_period_id }) => {
const period = await resolveCurrentPeriod(supabase, userId, fiscal_period_id)
if (!period) return 'Ingen räkenskapsperiod hittades.'
const { generateIncomeStatement } = await import('@/lib/reports/income-statement')
const report = await generateIncomeStatement(supabase, userId, period.id)
const sections = [
...report.revenue_sections.map((s) => ({
category: 'Intäkter',
title: s.title,
amount: s.subtotal,
accounts: s.rows.map((r) => ({ account: r.account_number, name: r.account_name, amount: r.amount })),
})),
...report.expense_sections.map((s) => ({
category: 'Kostnader',
title: s.title,
amount: s.subtotal,
accounts: s.rows.map((r) => ({ account: r.account_number, name: r.account_name, amount: r.amount })),
})),
...report.financial_sections.map((s) => ({
category: 'Finansiella poster',
title: s.title,
amount: s.subtotal,
accounts: s.rows.map((r) => ({ account: r.account_number, name: r.account_name, amount: r.amount })),
})),
]
return JSON.stringify({
period: `${period.start} ${period.end}`,
total_revenue: report.total_revenue,
total_expenses: report.total_expenses,
total_financial: report.total_financial,
net_result: report.net_result,
sections,
})
},
{
name: 'get_income_statement',
description: 'Hämtar resultaträkning med intäkter, kostnader och årets resultat. Visar alla kontona grupperade i sektioner.',
schema: z.object({
fiscal_period_id: z.string().optional().describe('Räkenskapsperiod-ID (standard: aktuell period)'),
}),
}
)
const getBalanceSheet = tool(
async ({ fiscal_period_id }) => {
const period = await resolveCurrentPeriod(supabase, userId, fiscal_period_id)
if (!period) return 'Ingen räkenskapsperiod hittades.'
const { generateBalanceSheet } = await import('@/lib/reports/balance-sheet')
const report = await generateBalanceSheet(supabase, userId, period.id)
const sections = [
...report.asset_sections.map((s) => ({
category: 'Tillgångar',
title: s.title,
amount: s.subtotal,
accounts: s.rows.map((r) => ({ account: r.account_number, name: r.account_name, amount: r.amount })),
})),
...report.equity_liability_sections.map((s) => ({
category: 'Eget kapital & skulder',
title: s.title,
amount: s.subtotal,
accounts: s.rows.map((r) => ({ account: r.account_number, name: r.account_name, amount: r.amount })),
})),
]
return JSON.stringify({
period: `${period.start} ${period.end}`,
total_assets: report.total_assets,
total_equity_liabilities: report.total_equity_liabilities,
balanced: Math.abs(report.total_assets - report.total_equity_liabilities) < 0.01,
sections,
})
},
{
name: 'get_balance_sheet',
description: 'Hämtar balansräkning med tillgångar, eget kapital och skulder.',
schema: z.object({
fiscal_period_id: z.string().optional().describe('Räkenskapsperiod-ID (standard: aktuell period)'),
}),
}
)
const getVatSummary = tool(
async ({ fiscal_period_id }) => {
const period = await resolveCurrentPeriod(supabase, userId, fiscal_period_id)
if (!period) return 'Ingen räkenskapsperiod hittades.'
// Get company settings for moms period type
const { data: settings } = await supabase
.from('company_settings')
.select('moms_period')
.eq('company_id', userId)
.single()
const periodType = settings?.moms_period || 'quarterly'
const startDate = new Date(period.start)
const year = startDate.getFullYear()
let periodNum = 1
if (periodType === 'monthly') {
periodNum = startDate.getMonth() + 1
} else if (periodType === 'quarterly') {
periodNum = Math.ceil((startDate.getMonth() + 1) / 3)
}
const { calculateVatDeclaration, getVatDeclarationSummary } = await import('@/lib/reports/vat-declaration')
const declaration = await calculateVatDeclaration(supabase, userId, periodType, year, periodNum)
const summary = getVatDeclarationSummary(declaration)
return JSON.stringify({
period: `${period.start} ${period.end}`,
output_vat_25: declaration.rutor.ruta10,
output_vat_12: declaration.rutor.ruta11,
output_vat_6: declaration.rutor.ruta12,
total_output_vat: summary.totalOutputVat,
input_vat: summary.totalInputVat,
vat_to_pay: summary.vatToPay,
is_refund: summary.isRefund,
domestic_taxable_sales: declaration.rutor.ruta05,
revenue_basis_25: declaration.breakdown.invoices.base25,
revenue_basis_12: declaration.breakdown.invoices.base12,
revenue_basis_6: declaration.breakdown.invoices.base6,
invoice_count: declaration.invoiceCount,
transaction_count: declaration.transactionCount,
})
},
{
name: 'get_vat_summary',
description: 'Hämtar momssammanställning med utgående moms, ingående moms och moms att betala/återfå.',
schema: z.object({
fiscal_period_id: z.string().optional().describe('Räkenskapsperiod-ID (standard: aktuell period)'),
}),
}
)
const getCompanyOverview = tool(
async () => {
const { data: settings } = await supabase
.from('company_settings')
.select('*')
.eq('company_id', userId)
.single()
if (!settings) return 'Inga företagsinställningar hittades.'
// Get quick KPIs
const period = await resolveCurrentPeriod(supabase, userId)
const [
{ count: invoiceCount },
{ count: unpaidCount },
{ count: txCount },
{ count: unbookedCount },
] = await Promise.all([
supabase.from('invoices').select('id', { count: 'exact', head: true }).eq('company_id', userId),
supabase.from('invoices').select('id', { count: 'exact', head: true }).eq('company_id', userId).in('status', ['sent', 'overdue']),
supabase.from('transactions').select('id', { count: 'exact', head: true }).eq('company_id', userId),
supabase.from('transactions').select('id', { count: 'exact', head: true }).eq('company_id', userId).is('journal_entry_id', null),
])
let netResult: number | null = null
if (period) {
try {
const { generateIncomeStatement } = await import('@/lib/reports/income-statement')
const report = await generateIncomeStatement(supabase, userId, period.id)
netResult = report.net_result
} catch {
// Non-critical
}
}
return JSON.stringify({
company: {
name: settings.company_name,
entity_type: settings.entity_type,
org_number: settings.org_number,
vat_registered: settings.vat_registered,
accounting_method: settings.accounting_method,
moms_period: settings.moms_period,
},
kpis: {
total_invoices: invoiceCount || 0,
unpaid_invoices: unpaidCount || 0,
total_transactions: txCount || 0,
unbooked_transactions: unbookedCount || 0,
...(netResult !== null ? { net_result: netResult } : {}),
...(period ? { current_period: `${period.start} ${period.end}` } : {}),
},
})
},
{
name: 'get_company_overview',
description: 'Hämtar företagsinformation och nyckeltal (KPIs): antal fakturor, obetalda fakturor, transaktioner, obokförda transaktioner, årets resultat.',
schema: z.object({}),
}
)
const getAgingReport = tool(
async ({ type, limit }) => {
if (type === 'receivable') {
const { generateARLedger } = await import('@/lib/reports/ar-ledger')
const report = await generateARLedger(supabase, userId)
if (report.entries.length === 0) return 'Inga utestående kundfordringar.'
const entries = report.entries.slice(0, limit).map((e) => ({
name: e.customer_name,
current: e.current,
'1_30': e.days_1_30,
'31_60': e.days_31_60,
'61_90': e.days_61_90,
'90_plus': e.days_90_plus,
total: e.total_outstanding,
}))
return JSON.stringify({
type: 'receivable',
total_outstanding: report.total_outstanding,
total_current: report.total_current,
total_overdue: report.total_overdue,
unpaid_count: report.unpaid_count,
entries,
})
} else {
const { generateSupplierLedger } = await import('@/lib/reports/supplier-ledger')
const report = await generateSupplierLedger(supabase, userId)
if (report.entries.length === 0) return 'Inga utestående leverantörsskulder.'
const entries = report.entries.slice(0, limit).map((e) => ({
name: e.supplier_name,
current: e.current,
'1_30': e.days_1_30,
'31_60': e.days_31_60,
'61_90': e.days_61_90,
'90_plus': e.days_90_plus,
total: e.total_outstanding,
}))
return JSON.stringify({
type: 'payable',
total_outstanding: report.total_outstanding,
total_current: report.total_current,
total_overdue: report.total_overdue,
unpaid_count: report.unpaid_count,
entries,
})
}
},
{
name: 'get_aging_report',
description: 'Hämtar åldersanalys för kundfordringar (receivable) eller leverantörsskulder (payable). Visar utestående belopp uppdelat i ålderskategorier.',
schema: z.object({
type: z.enum(['receivable', 'payable']).describe("'receivable' för kundfordringar, 'payable' för leverantörsskulder"),
limit: z.number().max(20).default(10).describe('Max antal poster'),
}),
}
)
return [
getInvoices,
getSupplierInvoices,
getAccountBalances,
getTransactions,
getJournalEntries,
getIncomeStatement,
getBalanceSheet,
getVatSummary,
getCompanyOverview,
getAgingReport,
]
}
@@ -1,49 +0,0 @@
import { CallbackHandler } from '@langfuse/langchain'
let langfuseConfigured: boolean | null = null
function isLangfuseConfigured(): boolean {
if (langfuseConfigured !== null) return langfuseConfigured
langfuseConfigured = !!(
process.env.LANGFUSE_SECRET_KEY &&
process.env.LANGFUSE_PUBLIC_KEY
)
return langfuseConfigured
}
/**
* Create a Langfuse callback handler for LangChain tracing.
* Returns null if Langfuse is not configured (graceful degradation).
*/
export function createTraceHandler(options: {
sessionId?: string
userId?: string
metadata?: Record<string, unknown>
}): CallbackHandler | null {
if (!isLangfuseConfigured()) return null
try {
return new CallbackHandler({
sessionId: options.sessionId,
userId: options.userId,
})
} catch {
console.warn('Failed to create Langfuse handler, tracing disabled')
return null
}
}
/**
* Flush Langfuse handler. Safe to call with null.
*/
export async function flushTrace(handler: CallbackHandler | null): Promise<void> {
if (!handler) return
try {
// Langfuse CallbackHandler may expose flush via different methods
if ('shutdownAsync' in handler && typeof handler.shutdownAsync === 'function') {
await handler.shutdownAsync()
}
} catch {
// Non-critical — tracing failure should never block response
}
}
-25
View File
@@ -1,25 +0,0 @@
import type { Extension } from '@/lib/extensions/types'
import { aiChatApiRoutes } from './api-routes'
/**
* AI Chat Extension
*
* Provides an AI-powered chatbot assistant for Swedish tax and bookkeeping
* questions. Uses RAG (Retrieval Augmented Generation) with a knowledge base
* of Swedish tax laws, regulations, and best practices.
*
* Components:
* - chatbot/: Chain, config, prompts, embeddings, retriever
* - ingestion/: CLI tool for ingesting knowledge base documents
*/
export const aiChatExtension: Extension = {
id: 'ai-chat',
name: 'AI-assistent',
version: '1.0.0',
sector: 'general',
apiRoutes: aiChatApiRoutes,
settingsPanel: {
label: 'AI-assistent',
path: '/settings/extensions/ai-chat',
},
}
@@ -1,353 +0,0 @@
/**
* Knowledge Base Ingestion Script
*
* Loads markdown files from dev_docs/ai_knowledge_base/,
* chunks them by sections, generates embeddings, and stores in Supabase.
*
* Run with: npx tsx lib/ai/ingestion/ingest.ts
*/
import * as dotenv from 'dotenv'
dotenv.config({ path: '.env.local' })
import { createClient } from '@supabase/supabase-js'
import { OpenAIEmbeddings } from '@langchain/openai'
import * as fs from 'fs'
import * as path from 'path'
import * as crypto from 'crypto'
// Configuration
// NOTE: The ai_knowledge_base directory must be created and populated before running ingestion.
// Create dev_docs/ai_knowledge_base/ and add markdown files to ingest.
const DOCS_DIR = path.join(process.cwd(), 'dev_docs', 'ai_knowledge_base')
const CHUNK_SIZE = 1000
const CHUNK_OVERLAP = 200
const EMBEDDING_MODEL = 'text-embedding-ada-002'
// Initialize clients
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
)
const embeddings = new OpenAIEmbeddings({
modelName: EMBEDDING_MODEL,
openAIApiKey: process.env.OPENAI_API_KEY,
})
interface DocumentChunk {
source_file: string
title: string
section_title: string | null
content: string
content_hash: string
metadata: Record<string, unknown>
}
interface Section {
title: string
content: string
level: number
}
/**
* Parse a markdown file into sections based on headings
*/
function parseMarkdownSections(content: string): Section[] {
const lines = content.split('\n')
const sections: Section[] = []
let currentSection: Section | null = null
let contentBuffer: string[] = []
for (const line of lines) {
// Check for headings (H1, H2, H3)
const h1Match = line.match(/^# (.+)$/)
const h2Match = line.match(/^## (.+)$/)
const h3Match = line.match(/^### (.+)$/)
if (h1Match || h2Match || h3Match) {
// Save previous section
if (currentSection && contentBuffer.length > 0) {
currentSection.content = contentBuffer.join('\n').trim()
if (currentSection.content) {
sections.push(currentSection)
}
}
// Start new section
const title = h1Match?.[1] || h2Match?.[1] || h3Match?.[1] || ''
const level = h1Match ? 1 : h2Match ? 2 : 3
currentSection = { title, content: '', level }
contentBuffer = []
} else {
contentBuffer.push(line)
}
}
// Don't forget the last section
if (currentSection && contentBuffer.length > 0) {
currentSection.content = contentBuffer.join('\n').trim()
if (currentSection.content) {
sections.push(currentSection)
}
}
return sections
}
/**
* Split text into chunks while preserving context
*/
function chunkText(text: string, maxSize: number, overlap: number): string[] {
if (text.length <= maxSize) {
return [text]
}
const chunks: string[] = []
let start = 0
while (start < text.length) {
let end = start + maxSize
// Try to break at a natural point (paragraph, sentence, or word)
if (end < text.length) {
// Look for paragraph break
const paragraphBreak = text.lastIndexOf('\n\n', end)
if (paragraphBreak > start + maxSize / 2) {
end = paragraphBreak
} else {
// Look for sentence break
const sentenceBreak = text.lastIndexOf('. ', end)
if (sentenceBreak > start + maxSize / 2) {
end = sentenceBreak + 1
} else {
// Look for word break
const wordBreak = text.lastIndexOf(' ', end)
if (wordBreak > start + maxSize / 2) {
end = wordBreak
}
}
}
}
chunks.push(text.slice(start, end).trim())
start = end - overlap
if (start < 0) start = 0
if (end >= text.length) break
}
return chunks.filter((c) => c.length > 0)
}
/**
* Extract metadata from content (trigger words, categories, etc.)
*/
function extractMetadata(
content: string,
_sectionTitle: string
): Record<string, unknown> {
const metadata: Record<string, unknown> = {}
// Extract scenario IDs (e.g., "Scenario 001")
const scenarioMatches = content.match(/Scenario\s+(\d{3})/gi)
if (scenarioMatches) {
metadata.scenarios = scenarioMatches.map((m) =>
m.replace(/Scenario\s+/i, '')
)
}
// Extract mentioned thresholds/amounts
const amountMatches = content.match(/(\d+[\s\d]*)\s*(kr|SEK|kronor)/gi)
if (amountMatches) {
metadata.amounts = amountMatches.slice(0, 5) // Limit to first 5
}
// Extract platform mentions
const platforms = [
'YouTube',
'Twitch',
'Instagram',
'TikTok',
'Patreon',
'Spotify',
'Adtraction',
]
const mentionedPlatforms = platforms.filter((p) =>
content.toLowerCase().includes(p.toLowerCase())
)
if (mentionedPlatforms.length > 0) {
metadata.platforms = mentionedPlatforms
}
// Extract categories based on keywords
const categories: string[] = []
if (/moms|vat/i.test(content)) categories.push('moms')
if (/skatt|deklaration/i.test(content)) categories.push('skatt')
if (/avdrag/i.test(content)) categories.push('avdrag')
if (/bokför|konto|bas/i.test(content)) categories.push('bokföring')
if (/sgi|sjuk|föräldra|pension/i.test(content))
categories.push('socialförsäkring')
if (/ef|enskild firma/i.test(content)) categories.push('enskild_firma')
if (/ab|aktiebolag/i.test(content)) categories.push('aktiebolag')
if (categories.length > 0) {
metadata.categories = categories
}
return metadata
}
/**
* Generate content hash for deduplication
*/
function generateHash(content: string): string {
return crypto.createHash('sha256').update(content).digest('hex').slice(0, 16)
}
/**
* Process a single markdown file
*/
function processFile(filePath: string): DocumentChunk[] {
const content = fs.readFileSync(filePath, 'utf-8')
const fileName = path.basename(filePath)
const sections = parseMarkdownSections(content)
const chunks: DocumentChunk[] = []
// Get document title from first H1
const documentTitle =
sections.find((s) => s.level === 1)?.title || fileName.replace('.md', '')
for (const section of sections) {
// Skip empty sections
if (!section.content || section.content.length < 50) {
continue
}
// Chunk large sections
const textChunks = chunkText(section.content, CHUNK_SIZE, CHUNK_OVERLAP)
for (let i = 0; i < textChunks.length; i++) {
const chunkContent = textChunks[i]
const sectionTitle =
section.level === 1
? null
: textChunks.length > 1
? `${section.title} (del ${i + 1}/${textChunks.length})`
: section.title
chunks.push({
source_file: fileName,
title: documentTitle,
section_title: sectionTitle,
content: chunkContent,
content_hash: generateHash(chunkContent),
metadata: extractMetadata(chunkContent, section.title),
})
}
}
return chunks
}
/**
* Main ingestion function
*/
async function ingest() {
console.log('Starting knowledge base ingestion...')
console.log(`Reading files from: ${DOCS_DIR}`)
if (!fs.existsSync(DOCS_DIR)) {
console.error(`Error: Knowledge base directory not found: ${DOCS_DIR}`)
console.error('Create dev_docs/ai_knowledge_base/ and add markdown files before running ingestion.')
process.exit(1)
}
// Get all markdown files
const files = fs
.readdirSync(DOCS_DIR)
.filter((f) => f.endsWith('.md'))
.sort()
console.log(`Found ${files.length} markdown files`)
// Process all files
const allChunks: DocumentChunk[] = []
for (const file of files) {
const filePath = path.join(DOCS_DIR, file)
console.log(`Processing: ${file}`)
const chunks = processFile(filePath)
allChunks.push(...chunks)
console.log(` -> ${chunks.length} chunks`)
}
console.log(`Total chunks: ${allChunks.length}`)
// Clear existing documents (optional - comment out for incremental updates)
console.log('Clearing existing documents...')
const { error: deleteError } = await supabase
.from('knowledge_documents')
.delete()
.neq('id', '00000000-0000-0000-0000-000000000000') // Delete all
if (deleteError) {
console.error('Error clearing documents:', deleteError)
// Continue anyway
}
// Generate embeddings in batches
const BATCH_SIZE = 20
let processed = 0
for (let i = 0; i < allChunks.length; i += BATCH_SIZE) {
const batch = allChunks.slice(i, i + BATCH_SIZE)
const contents = batch.map((c) => c.content)
console.log(
`Generating embeddings for batch ${Math.floor(i / BATCH_SIZE) + 1}/${Math.ceil(allChunks.length / BATCH_SIZE)}...`
)
// Generate embeddings
const embeddingVectors = await embeddings.embedDocuments(contents)
// Prepare records for insertion
const records = batch.map((chunk, idx) => ({
source_file: chunk.source_file,
title: chunk.title,
section_title: chunk.section_title,
content: chunk.content,
content_hash: chunk.content_hash,
embedding: embeddingVectors[idx],
metadata: chunk.metadata,
}))
// Insert into Supabase
const { error: insertError } = await supabase
.from('knowledge_documents')
.insert(records)
if (insertError) {
console.error('Error inserting batch:', insertError)
throw insertError
}
processed += batch.length
console.log(` Inserted ${processed}/${allChunks.length} documents`)
}
console.log('\nIngestion complete!')
console.log(`Total documents inserted: ${allChunks.length}`)
// Verify
const { count } = await supabase
.from('knowledge_documents')
.select('*', { count: 'exact', head: true })
console.log(`Documents in database: ${count}`)
}
// Run if called directly
ingest().catch((error) => {
console.error('Ingestion failed:', error)
process.exit(1)
})
-26
View File
@@ -1,26 +0,0 @@
{
"id": "ai-chat",
"sector": "general",
"exportName": "aiChatExtension",
"entryPoint": "@/extensions/general/ai-chat",
"workspace": "@/components/extensions/general/AiChatWorkspace",
"requiredEnvVars": ["ANTHROPIC_API_KEY", "OPENAI_API_KEY"],
"optionalEnvVars": ["LANGFUSE_SECRET_KEY", "LANGFUSE_PUBLIC_KEY", "LANGFUSE_BASE_URL"],
"npmDependencies": ["@langchain/anthropic", "@langchain/core", "langchain", "@langchain/openai", "@langchain/langgraph", "@langfuse/core", "@langfuse/langchain"],
"definition": {
"name": "AI-assistent",
"category": "operations",
"icon": "MessageSquare",
"dataPattern": "both",
"readsCoreTables": ["invoices", "supplier_invoices", "transactions", "journal_entries", "journal_entry_lines", "fiscal_periods", "company_settings", "customers", "suppliers", "chart_of_accounts"],
"hasOwnData": true,
"description": "AI-assistent för skatte- och bokföringsfrågor med tillgång till din data",
"longDescription": "Ställ frågor om skatt, bokföring och företagande till en AI-assistent som förstår svensk redovisning. Kan hämta och visualisera din bokföringsdata — fakturor, transaktioner, resultaträkning, balansräkning och mer.",
"quickAction": {
"label": "AI-assistent",
"description": "Fråga om bokföring",
"icon": "MessageSquare",
"event": "open-ai-chat"
}
}
}
+1 -1
View File
@@ -30,7 +30,7 @@ export const enableBankingExtension: Extension = {
settingsPanel: {
label: 'Bankintegration (PSD2)',
path: '/settings?tab=banking',
path: '/settings/banking',
},
apiRoutes: [
@@ -1,103 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { eventBus } from '@/lib/events/bus'
import { createMockSupabase } from '@/tests/helpers'
// Mock dependencies
vi.mock('@/lib/supabase/server', () => ({
createClient: vi.fn(),
}))
vi.mock('../lib/invoice-analyzer', () => ({
analyzeInvoice: vi.fn(),
}))
vi.mock('../lib/supplier-matcher', () => ({
matchSupplier: vi.fn(),
}))
// Mock api-routes to avoid transitive server-only import from document-analyzer
vi.mock('../api-routes', () => ({
invoiceInboxApiRoutes: [],
}))
import { createClient } from '@/lib/supabase/server'
import { invoiceInboxExtension, getSettings, saveSettings } from '../index'
const mockCreateClient = vi.mocked(createClient)
describe('Invoice Inbox Extension', () => {
beforeEach(() => {
vi.clearAllMocks()
eventBus.clear()
})
describe('Extension metadata', () => {
it('has correct id and version', () => {
expect(invoiceInboxExtension.id).toBe('invoice-inbox')
expect(invoiceInboxExtension.name).toBe('Invoice Inbox')
expect(invoiceInboxExtension.version).toBe('1.0.0')
})
it('has event handler for document.uploaded', () => {
expect(invoiceInboxExtension.eventHandlers).toHaveLength(1)
expect(invoiceInboxExtension.eventHandlers![0].eventType).toBe('document.uploaded')
})
it('has settings panel', () => {
expect(invoiceInboxExtension.settingsPanel).toEqual({
label: 'Invoice Inbox',
path: '/settings/extensions/invoice-inbox',
})
})
it('has onInstall hook', () => {
expect(invoiceInboxExtension.onInstall).toBeDefined()
})
})
describe('getSettings', () => {
it('returns default settings when no data exists', async () => {
const { supabase, mockResult } = createMockSupabase()
mockCreateClient.mockResolvedValue(supabase as never)
mockResult({ data: null, error: null })
const settings = await getSettings('user-1')
expect(settings).toEqual({
autoProcessEnabled: true,
autoMatchSupplierEnabled: true,
supplierMatchThreshold: 0.7,
inboxEmail: null,
})
})
it('merges stored settings with defaults', async () => {
const { supabase, mockResult } = createMockSupabase()
mockCreateClient.mockResolvedValue(supabase as never)
mockResult({
data: { value: { inboxEmail: 'test@inbox.example.com' } },
error: null,
})
const settings = await getSettings('user-1')
expect(settings.inboxEmail).toBe('test@inbox.example.com')
expect(settings.autoProcessEnabled).toBe(true) // default
})
})
describe('saveSettings', () => {
it('merges partial settings with current', async () => {
const { supabase, mockResult } = createMockSupabase()
mockCreateClient.mockResolvedValue(supabase as never)
// First call for getSettings (inside saveSettings)
mockResult({ data: null, error: null })
const settings = await saveSettings('user-1', { inboxEmail: 'new@inbox.com' })
expect(settings.inboxEmail).toBe('new@inbox.com')
expect(settings.autoProcessEnabled).toBe(true)
})
})
})
File diff suppressed because it is too large Load Diff
-227
View File
@@ -1,227 +0,0 @@
import { analyzeInvoice } from './lib/invoice-analyzer'
import { matchSupplier } from './lib/supplier-matcher'
import type { Extension, ExtensionContext } from '@/lib/extensions/types'
import type { EventPayload } from '@/lib/events/types'
import type { InvoiceInboxSettings } from './types'
import { invoiceInboxApiRoutes } from './api-routes'
// ============================================================
// Settings
// ============================================================
const DEFAULT_SETTINGS: InvoiceInboxSettings = {
autoProcessEnabled: true,
autoMatchSupplierEnabled: true,
supplierMatchThreshold: 0.7,
inboxEmail: null,
}
/** Get settings via ExtensionContext (preferred in event handlers) */
async function getSettingsViaCtx(ctx: ExtensionContext): Promise<InvoiceInboxSettings> {
const stored = await ctx.settings.get<Partial<InvoiceInboxSettings>>()
return { ...DEFAULT_SETTINGS, ...(stored || {}) }
}
/** Get settings for external callers (settings routes, API routes) */
export async function getSettings(userId: string): Promise<InvoiceInboxSettings> {
const { createClient } = await import('@/lib/supabase/server')
const supabase = await createClient()
const { data } = await supabase
.from('extension_data')
.select('value')
.eq('company_id', userId)
.eq('extension_id', 'invoice-inbox')
.eq('key', 'settings')
.single()
if (!data?.value) return { ...DEFAULT_SETTINGS }
return { ...DEFAULT_SETTINGS, ...(data.value as Partial<InvoiceInboxSettings>) }
}
export async function saveSettings(
userId: string,
partial: Partial<InvoiceInboxSettings>
): Promise<InvoiceInboxSettings> {
const current = await getSettings(userId)
const merged = { ...current, ...partial }
const { createClient } = await import('@/lib/supabase/server')
const supabase = await createClient()
await supabase
.from('extension_data')
.upsert(
{
user_id: userId,
extension_id: 'invoice-inbox',
key: 'settings',
value: merged,
},
{ onConflict: 'user_id,extension_id,key' }
)
return merged
}
// ============================================================
// Event Handlers
// ============================================================
const INVOICE_MIME_TYPES = [
'application/pdf',
'image/jpeg',
'image/png',
'image/webp',
]
/**
* When a PDF/image is uploaded via the document archive, check if it should
* be auto-processed as a supplier invoice.
*/
async function handleDocumentUploaded(
payload: EventPayload<'document.uploaded'>,
ctx?: ExtensionContext
): Promise<void> {
const { document, userId, companyId } = payload
const log = ctx?.log ?? console
// Gate: Is it a supported file type?
if (!document.mime_type || !INVOICE_MIME_TYPES.includes(document.mime_type)) {
return
}
// Gate: Is autoProcessEnabled?
const settings = ctx ? await getSettingsViaCtx(ctx) : await getSettings(userId)
if (!settings.autoProcessEnabled) {
return
}
// Gate: Was this document already processed as an inbox item?
const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient()
const { data: existing } = await supabase
.from('invoice_inbox_items')
.select('id')
.eq('company_id', userId)
.eq('document_id', document.id)
.limit(1)
if (existing && existing.length > 0) {
return
}
log.info(`Auto-process triggered for document ${document.id}`)
try {
// Create inbox item
const { data: inboxItem, error: insertError } = await supabase
.from('invoice_inbox_items')
.insert({
user_id: userId,
status: 'processing',
source: 'upload',
document_id: document.id,
})
.select()
.single()
if (insertError || !inboxItem) {
log.error('Failed to create inbox item:', insertError)
return
}
// Download file from storage
const { data: fileData, error: downloadError } = await supabase.storage
.from('documents')
.download(document.storage_path)
if (downloadError || !fileData) {
await supabase
.from('invoice_inbox_items')
.update({ status: 'error', error_message: 'Failed to download document' })
.eq('id', inboxItem.id)
return
}
// Convert to base64
const arrayBuffer = await fileData.arrayBuffer()
const base64 = Buffer.from(arrayBuffer).toString('base64')
// Analyze invoice
const extraction = await analyzeInvoice(base64, document.mime_type)
// Supplier matching
let matchedSupplierId: string | null = null
if (settings.autoMatchSupplierEnabled) {
const { data: suppliers } = await supabase
.from('suppliers')
.select('*')
.eq('company_id', userId)
if (suppliers && suppliers.length > 0) {
const match = matchSupplier(extraction, suppliers)
if (match && match.confidence >= settings.supplierMatchThreshold) {
matchedSupplierId = match.supplierId
}
}
}
// Update inbox item with extracted data
await supabase
.from('invoice_inbox_items')
.update({
status: 'ready',
extracted_data: extraction as unknown as Record<string, unknown>,
confidence: extraction.confidence,
matched_supplier_id: matchedSupplierId,
})
.eq('id', inboxItem.id)
// Fetch updated item
const { data: updatedItem } = await supabase
.from('invoice_inbox_items')
.select('*')
.eq('id', inboxItem.id)
.single()
if (updatedItem) {
const emit = ctx?.emit ?? (await import('@/lib/events/bus')).eventBus.emit.bind((await import('@/lib/events/bus')).eventBus)
await emit({
type: 'supplier_invoice.extracted',
payload: {
inboxItem: updatedItem,
confidence: extraction.confidence,
userId,
companyId,
},
})
}
log.info(`Invoice ${inboxItem.id} processed (confidence: ${extraction.confidence})`)
} catch (error) {
log.error('handleDocumentUploaded failed:', error)
}
}
// ============================================================
// Extension Object
// ============================================================
export const invoiceInboxExtension: Extension = {
id: 'invoice-inbox',
name: 'Invoice Inbox',
version: '1.0.0',
sector: 'general',
apiRoutes: invoiceInboxApiRoutes,
eventHandlers: [
{ eventType: 'document.uploaded', handler: handleDocumentUploaded },
],
settingsPanel: {
label: 'Invoice Inbox',
path: '/settings/extensions/invoice-inbox',
},
async onInstall(ctx) {
await ctx.settings.set('settings', DEFAULT_SETTINGS)
},
}
@@ -1,182 +0,0 @@
import { describe, it, expect, vi } from 'vitest'
// Mock server-only
vi.mock('server-only', () => ({}))
import { parseInboundPayload, extractAttachments, resolveUserFromEmail } from '../email-handler'
import type { ResendInboundPayload } from '../../types'
describe('Email Handler', () => {
describe('parseInboundPayload', () => {
it('returns null for null input', () => {
expect(parseInboundPayload(null)).toBeNull()
})
it('returns null for non-object input', () => {
expect(parseInboundPayload('string')).toBeNull()
})
it('returns null when from is missing', () => {
expect(parseInboundPayload({ to: 'test@example.com' })).toBeNull()
})
it('returns null when to is missing', () => {
expect(parseInboundPayload({ from: 'test@example.com' })).toBeNull()
})
it('parses valid payload', () => {
const result = parseInboundPayload({
from: 'supplier@example.com',
to: 'inbox@mycompany.com',
subject: 'Faktura F-001',
html: '<p>Attached</p>',
text: 'Attached',
attachments: [{ filename: 'invoice.pdf', content_type: 'application/pdf', content: 'base64data' }],
created_at: '2024-06-15T10:00:00Z',
})
expect(result).not.toBeNull()
expect(result!.from).toBe('supplier@example.com')
expect(result!.to).toBe('inbox@mycompany.com')
expect(result!.subject).toBe('Faktura F-001')
expect(result!.attachments).toHaveLength(1)
})
it('handles missing optional fields', () => {
const result = parseInboundPayload({
from: 'a@b.com',
to: 'c@d.com',
})
expect(result).not.toBeNull()
expect(result!.subject).toBe('')
expect(result!.html).toBeNull()
expect(result!.text).toBeNull()
expect(result!.attachments).toEqual([])
})
})
describe('extractAttachments', () => {
it('filters to supported file types only', () => {
const payload: ResendInboundPayload = {
from: 'a@b.com',
to: 'c@d.com',
subject: 'Test',
html: null,
text: null,
created_at: '2024-06-15T10:00:00Z',
attachments: [
{ filename: 'invoice.pdf', content_type: 'application/pdf', content: 'base64' },
{ filename: 'photo.jpg', content_type: 'image/jpeg', content: 'base64' },
{ filename: 'doc.docx', content_type: 'application/vnd.openxmlformats', content: 'base64' },
{ filename: 'sheet.xlsx', content_type: 'application/vnd.ms-excel', content: 'base64' },
{ filename: 'scan.png', content_type: 'image/png', content: 'base64' },
],
}
const result = extractAttachments(payload)
expect(result).toHaveLength(3)
expect(result.map(a => a.content_type)).toEqual([
'application/pdf',
'image/jpeg',
'image/png',
])
})
it('filters out attachments without content', () => {
const payload: ResendInboundPayload = {
from: 'a@b.com',
to: 'c@d.com',
subject: 'Test',
html: null,
text: null,
created_at: '2024-06-15T10:00:00Z',
attachments: [
{ filename: 'invoice.pdf', content_type: 'application/pdf', content: '' },
{ filename: 'photo.jpg', content_type: 'image/jpeg', content: 'base64data' },
],
}
const result = extractAttachments(payload)
expect(result).toHaveLength(1)
})
})
describe('resolveUserFromEmail', () => {
it('returns null when no extension data found', async () => {
const mockClient = {
from: vi.fn().mockReturnValue({
select: vi.fn().mockReturnValue({
eq: vi.fn().mockReturnValue({
eq: vi.fn().mockResolvedValue({ data: null, error: { message: 'not found' } }),
}),
}),
}),
}
const result = await resolveUserFromEmail('test@inbox.com', mockClient)
expect(result).toBeNull()
})
it('returns user_id when email matches', async () => {
const mockClient = {
from: vi.fn().mockReturnValue({
select: vi.fn().mockReturnValue({
eq: vi.fn().mockReturnValue({
eq: vi.fn().mockResolvedValue({
data: [
{ user_id: 'user-1', company_id: 'company-1', value: { inboxEmail: 'test@inbox.com' } },
{ user_id: 'user-2', company_id: 'company-2', value: { inboxEmail: 'other@inbox.com' } },
],
error: null,
}),
}),
}),
}),
}
const result = await resolveUserFromEmail('test@inbox.com', mockClient)
expect(result).toEqual({ userId: 'user-1', companyId: 'company-1' })
})
it('handles case-insensitive email matching', async () => {
const mockClient = {
from: vi.fn().mockReturnValue({
select: vi.fn().mockReturnValue({
eq: vi.fn().mockReturnValue({
eq: vi.fn().mockResolvedValue({
data: [
{ user_id: 'user-1', company_id: 'company-1', value: { inboxEmail: 'Test@Inbox.Com' } },
],
error: null,
}),
}),
}),
}),
}
const result = await resolveUserFromEmail('test@inbox.com', mockClient)
expect(result).toEqual({ userId: 'user-1', companyId: 'company-1' })
})
it('returns null when no matching email', async () => {
const mockClient = {
from: vi.fn().mockReturnValue({
select: vi.fn().mockReturnValue({
eq: vi.fn().mockReturnValue({
eq: vi.fn().mockResolvedValue({
data: [
{ user_id: 'user-1', value: { inboxEmail: 'other@inbox.com' } },
],
error: null,
}),
}),
}),
}),
}
const result = await resolveUserFromEmail('notfound@inbox.com', mockClient)
expect(result).toBeNull()
})
})
})

Some files were not shown because too many files have changed in this diff Show More