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
co-authored by Claude Opus 4.6
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" />
Gå 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 på 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 på 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" />
Gå 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)
}