New classification logic etc
This commit is contained in:
@@ -0,0 +1,241 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { DeadlineList } from '@/components/deadlines/DeadlineList'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { AlertTriangle, ArrowRight } from 'lucide-react'
|
||||
import type { Deadline } from '@/types'
|
||||
|
||||
export default function DeadlinesPage() {
|
||||
const [deadlines, setDeadlines] = useState<Deadline[]>([])
|
||||
const [customers, setCustomers] = useState<{ id: string; name: string }[]>([])
|
||||
const [overdueInvoices, setOverdueInvoices] = useState<{ count: number; total: number }>({ count: 0, total: 0 })
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const { toast } = useToast()
|
||||
const supabase = createClient()
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setIsLoading(true)
|
||||
|
||||
try {
|
||||
// Fetch deadlines with customer names
|
||||
const { data: deadlinesData, error: deadlinesError } = await supabase
|
||||
.from('deadlines')
|
||||
.select('*, customer:customers(name)')
|
||||
.order('due_date', { ascending: true })
|
||||
|
||||
if (deadlinesError) throw deadlinesError
|
||||
|
||||
// Fetch customers for the form
|
||||
const { data: customersData, error: customersError } = await supabase
|
||||
.from('customers')
|
||||
.select('id, name')
|
||||
.order('name', { ascending: true })
|
||||
|
||||
if (customersError) throw customersError
|
||||
|
||||
// Fetch overdue invoices summary
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
const { data: overdueData, error: overdueError } = await supabase
|
||||
.from('invoices')
|
||||
.select('total_sek, total')
|
||||
.in('status', ['sent', 'unpaid'])
|
||||
.lt('due_date', today)
|
||||
|
||||
if (overdueError) throw overdueError
|
||||
|
||||
const overdueCount = overdueData?.length || 0
|
||||
const overdueTotal = (overdueData || []).reduce(
|
||||
(sum, inv) => sum + (inv.total_sek || inv.total || 0),
|
||||
0
|
||||
)
|
||||
|
||||
setDeadlines(deadlinesData || [])
|
||||
setCustomers(customersData || [])
|
||||
setOverdueInvoices({ count: overdueCount, total: overdueTotal })
|
||||
} catch {
|
||||
toast({
|
||||
title: 'Fel',
|
||||
description: 'Kunde inte hamta data',
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [supabase, toast])
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
}, [fetchData])
|
||||
|
||||
const handleDeadlineCreate = async (
|
||||
data: Omit<Deadline, 'id' | 'user_id' | 'created_at' | 'updated_at'>
|
||||
) => {
|
||||
try {
|
||||
const response = await fetch('/api/deadlines', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const result = await response.json()
|
||||
throw new Error(result.error || 'Failed to create deadline')
|
||||
}
|
||||
|
||||
toast({
|
||||
title: 'Deadline skapad',
|
||||
description: 'Din deadline har sparats',
|
||||
})
|
||||
|
||||
fetchData()
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Fel',
|
||||
description: error instanceof Error ? error.message : 'Kunde inte skapa deadline',
|
||||
variant: 'destructive',
|
||||
})
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeadlineToggle = async (deadline: Deadline) => {
|
||||
try {
|
||||
const response = await fetch(`/api/deadlines/${deadline.id}/complete`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ is_completed: !deadline.is_completed }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const result = await response.json()
|
||||
throw new Error(result.error || 'Failed to toggle deadline')
|
||||
}
|
||||
|
||||
toast({
|
||||
title: deadline.is_completed ? 'Markerad som ej klar' : 'Markerad som klar',
|
||||
})
|
||||
|
||||
fetchData()
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Fel',
|
||||
description: error instanceof Error ? error.message : 'Kunde inte uppdatera deadline',
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeadlineEdit = async (deadline: Deadline) => {
|
||||
try {
|
||||
const response = await fetch(`/api/deadlines/${deadline.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(deadline),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const result = await response.json()
|
||||
throw new Error(result.error || 'Failed to edit deadline')
|
||||
}
|
||||
|
||||
toast({
|
||||
title: 'Deadline uppdaterad',
|
||||
description: 'Dina andringar har sparats',
|
||||
})
|
||||
|
||||
fetchData()
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Fel',
|
||||
description: error instanceof Error ? error.message : 'Kunde inte uppdatera deadline',
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeadlineDelete = async (deadline: Deadline) => {
|
||||
try {
|
||||
const response = await fetch(`/api/deadlines/${deadline.id}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const result = await response.json()
|
||||
throw new Error(result.error || 'Failed to delete deadline')
|
||||
}
|
||||
|
||||
toast({
|
||||
title: 'Deadline borttagen',
|
||||
})
|
||||
|
||||
fetchData()
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Fel',
|
||||
description: error instanceof Error ? error.message : 'Kunde inte ta bort deadline',
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Deadlines</h1>
|
||||
</div>
|
||||
<div className="animate-pulse">
|
||||
<div className="h-10 bg-muted rounded w-48 mb-4" />
|
||||
<div className="h-96 bg-muted rounded" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Deadlines</h1>
|
||||
</div>
|
||||
|
||||
{overdueInvoices.count > 0 && (
|
||||
<Link href="/invoices?status=unpaid" className="block group">
|
||||
<Card className="border-destructive/50 bg-destructive/5 hover:bg-destructive/10 transition-colors">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<AlertTriangle className="h-5 w-5 text-destructive flex-shrink-0" />
|
||||
<div>
|
||||
<p className="font-medium text-sm">Forfallna fakturor</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{overdueInvoices.count} st totalt{' '}
|
||||
{overdueInvoices.total.toLocaleString('sv-SE')} kr
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="destructive">{overdueInvoices.count}</Badge>
|
||||
<ArrowRight className="h-4 w-4 text-muted-foreground group-hover:translate-x-0.5 transition-transform" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<DeadlineList
|
||||
deadlines={deadlines}
|
||||
customers={customers}
|
||||
onDeadlineCreate={handleDeadlineCreate}
|
||||
onDeadlineToggle={handleDeadlineToggle}
|
||||
onDeadlineEdit={handleDeadlineEdit}
|
||||
onDeadlineDelete={handleDeadlineDelete}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -252,6 +252,13 @@ export default async function DashboardPage() {
|
||||
streak_count: streakCount,
|
||||
}
|
||||
|
||||
// Fetch enabled extension toggles
|
||||
const { data: enabledToggles } = await supabase
|
||||
.from('extension_toggles')
|
||||
.select('sector_slug, extension_slug')
|
||||
.eq('user_id', user.id)
|
||||
.eq('enabled', true)
|
||||
|
||||
return (
|
||||
<DashboardContent
|
||||
firstName={firstName}
|
||||
@@ -272,6 +279,7 @@ export default async function DashboardPage() {
|
||||
missingUnderlagCount,
|
||||
}}
|
||||
onboardingProgress={onboardingProgress}
|
||||
enabledExtensions={enabledToggles || []}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { seedAllTemplateEmbeddings, getSchemaVersion } from '@/lib/bookkeeping/template-embeddings'
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const authHeader = request.headers.get('authorization')
|
||||
const cronSecret = process.env.CRON_SECRET
|
||||
|
||||
if (!cronSecret || authHeader !== `Bearer ${cronSecret}`) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const { seeded, errors } = await seedAllTemplateEmbeddings()
|
||||
|
||||
return NextResponse.json({
|
||||
success: errors.length === 0,
|
||||
seeded,
|
||||
errors,
|
||||
schema_version: getSchemaVersion(),
|
||||
})
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: `Seeding failed: ${error instanceof Error ? error.message : 'Unknown error'}` },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -104,7 +104,7 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
suggestions[tx.id] = result
|
||||
template_suggestions[tx.id] = getSuggestedTemplates(tx as Transaction, entityType)
|
||||
template_suggestions[tx.id] = await getSuggestedTemplates(tx as Transaction, entityType)
|
||||
}
|
||||
|
||||
return NextResponse.json({ suggestions, template_suggestions })
|
||||
|
||||
@@ -198,6 +198,13 @@ export default async function RootPage() {
|
||||
streak_count: streakCount,
|
||||
}
|
||||
|
||||
// Fetch enabled extension toggles
|
||||
const { data: enabledToggles } = await supabase
|
||||
.from('extension_toggles')
|
||||
.select('sector_slug, extension_slug')
|
||||
.eq('user_id', user.id)
|
||||
.eq('enabled', true)
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<DashboardNav companyName={settings.company_name || 'Min verksamhet'} entityType={settings.entity_type || 'enskild_firma'} />
|
||||
@@ -220,6 +227,7 @@ export default async function RootPage() {
|
||||
receiptQueue,
|
||||
missingUnderlagCount,
|
||||
}}
|
||||
enabledExtensions={enabledToggles || []}
|
||||
/>
|
||||
</div>
|
||||
<ChatWidget />
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
export { CalendarHeader } from './CalendarHeader'
|
||||
export { CalendarGrid } from './CalendarGrid'
|
||||
export { CalendarDayCell } from './CalendarDayCell'
|
||||
export { PaymentCalendar } from './PaymentCalendar'
|
||||
export { PaymentSummaryCard } from './PaymentSummaryCard'
|
||||
export { DayDetailModal } from './DayDetailModal'
|
||||
export { DeadlineCard } from './DeadlineCard'
|
||||
export { DeadlineFilters } from './DeadlineFilters'
|
||||
export { DeadlineForm } from './DeadlineForm'
|
||||
export { DeadlineList } from './DeadlineList'
|
||||
export { UpcomingDeadlinesWidget } from './UpcomingDeadlinesWidget'
|
||||
export { TaxTodoWidget } from './TaxTodoWidget'
|
||||
@@ -12,7 +12,8 @@ import {
|
||||
getEnhancedTaxWarningStatus
|
||||
} from '@/lib/tax/calculator'
|
||||
import FSkattWarningCard from '@/components/dashboard/FSkattWarningCard'
|
||||
import { UpcomingDeadlinesWidget } from '@/components/calendar/UpcomingDeadlinesWidget'
|
||||
import { UpcomingDeadlinesWidget } from '@/components/deadlines/UpcomingDeadlinesWidget'
|
||||
import { TaxTodoWidget } from '@/components/deadlines/TaxTodoWidget'
|
||||
import NewUserChecklist from '@/components/onboarding/NewUserChecklist'
|
||||
import {
|
||||
TrendingUp,
|
||||
@@ -27,9 +28,11 @@ import {
|
||||
Landmark,
|
||||
CheckCircle2,
|
||||
ClipboardList,
|
||||
MessageCircle,
|
||||
FileWarning,
|
||||
} from 'lucide-react'
|
||||
import { getExtensionDefinition } from '@/lib/extensions/sectors'
|
||||
import { resolveIcon } from '@/lib/extensions/icon-resolver'
|
||||
import type { QuickActionDefinition } from '@/lib/extensions/types'
|
||||
import type { CompanySettings, EntityType, Deadline, ReceiptQueueSummary, OnboardingProgress } from '@/types'
|
||||
|
||||
interface DashboardContentProps {
|
||||
@@ -51,11 +54,31 @@ interface DashboardContentProps {
|
||||
missingUnderlagCount: number
|
||||
}
|
||||
onboardingProgress?: OnboardingProgress
|
||||
enabledExtensions?: { sector_slug: string; extension_slug: string }[]
|
||||
}
|
||||
|
||||
export default function DashboardContent({ firstName, settings, summary, onboardingProgress }: DashboardContentProps) {
|
||||
export default function DashboardContent({ firstName, settings, summary, onboardingProgress, enabledExtensions }: DashboardContentProps) {
|
||||
const [showAllAlerts, setShowAllAlerts] = useState(false)
|
||||
const [showMore, setShowMore] = useState(false)
|
||||
const [liveExtensions, setLiveExtensions] = useState(enabledExtensions ?? [])
|
||||
|
||||
useEffect(() => {
|
||||
setLiveExtensions(enabledExtensions ?? [])
|
||||
}, [enabledExtensions])
|
||||
|
||||
useEffect(() => {
|
||||
const handler = ((e: CustomEvent<{ sector_slug: string; extension_slug: string; enabled: boolean }>) => {
|
||||
setLiveExtensions(prev => {
|
||||
if (e.detail.enabled) {
|
||||
if (prev.some(x => x.sector_slug === e.detail.sector_slug && x.extension_slug === e.detail.extension_slug)) return prev
|
||||
return [...prev, { sector_slug: e.detail.sector_slug, extension_slug: e.detail.extension_slug }]
|
||||
}
|
||||
return prev.filter(x => !(x.sector_slug === e.detail.sector_slug && x.extension_slug === e.detail.extension_slug))
|
||||
})
|
||||
}) as EventListener
|
||||
window.addEventListener('extension-toggle-changed', handler)
|
||||
return () => window.removeEventListener('extension-toggle-changed', handler)
|
||||
}, [])
|
||||
|
||||
const entityType = (settings?.entity_type as EntityType) || 'enskild_firma'
|
||||
const preliminaryTaxMonthly = settings?.preliminary_tax_monthly || 0
|
||||
@@ -205,7 +228,15 @@ export default function DashboardContent({ firstName, settings, summary, onboard
|
||||
const visibleAlerts = showAllAlerts ? alertItems : alertItems.slice(0, MAX_VISIBLE_ALERTS)
|
||||
const hasMoreAlerts = alertItems.length > MAX_VISIBLE_ALERTS
|
||||
|
||||
const openAiChat = () => window.dispatchEvent(new Event('open-ai-chat'))
|
||||
// Build extension quick actions from enabled extensions
|
||||
const extensionQuickActions: (QuickActionDefinition & { key: string })[] = liveExtensions
|
||||
.map(toggle => {
|
||||
const def = getExtensionDefinition(toggle.sector_slug, toggle.extension_slug)
|
||||
if (!def?.quickAction) return null
|
||||
return { ...def.quickAction, key: `${toggle.sector_slug}/${toggle.extension_slug}` }
|
||||
})
|
||||
.filter((a): a is QuickActionDefinition & { key: string } => a !== null)
|
||||
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
|
||||
|
||||
// Quick action items
|
||||
const quickActions = [
|
||||
@@ -242,7 +273,7 @@ export default function DashboardContent({ firstName, settings, summary, onboard
|
||||
const todoItems: { label: string; href: string; count: number; variant: 'destructive' | 'warning' | 'default' }[] = []
|
||||
|
||||
if (passedDeadlines.length > 0) {
|
||||
todoItems.push({ label: 'passerade deadlines', href: '/calendar', count: passedDeadlines.length, variant: 'destructive' })
|
||||
todoItems.push({ label: 'passerade deadlines', href: '/deadlines', count: passedDeadlines.length, variant: 'destructive' })
|
||||
}
|
||||
if (summary.overdueInvoicesCount > 0) {
|
||||
todoItems.push({ label: 'förfallna fakturor', href: '/invoices?status=unpaid', count: summary.overdueInvoicesCount, variant: 'destructive' })
|
||||
@@ -431,18 +462,42 @@ export default function DashboardContent({ firstName, settings, summary, onboard
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
{/* AI assistant quick action */}
|
||||
<button onClick={openAiChat} className="group text-left">
|
||||
<div className="flex items-center gap-3 px-4 py-3 rounded-xl border border-border/40 hover:bg-muted/30 transition-colors duration-150">
|
||||
<div className="p-2 rounded-lg bg-muted/50">
|
||||
<MessageCircle className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate">AI-assistent</p>
|
||||
<p className="text-xs text-muted-foreground truncate hidden md:block">Fråga om bokföring</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{/* Extension quick actions */}
|
||||
{extensionQuickActions.map((action) => {
|
||||
const Icon = resolveIcon(action.icon)
|
||||
if (action.href) {
|
||||
return (
|
||||
<Link key={action.key} href={action.href} className="group">
|
||||
<div className="flex items-center gap-3 px-4 py-3 rounded-xl border border-border/40 hover:bg-muted/30 transition-colors duration-150">
|
||||
<div className="p-2 rounded-lg bg-muted/50">
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate">{action.label}</p>
|
||||
<p className="text-xs text-muted-foreground truncate hidden md:block">{action.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<button
|
||||
key={action.key}
|
||||
onClick={() => window.dispatchEvent(new Event(action.event!))}
|
||||
className="group text-left"
|
||||
>
|
||||
<div className="flex items-center gap-3 px-4 py-3 rounded-xl border border-border/40 hover:bg-muted/30 transition-colors duration-150">
|
||||
<div className="p-2 rounded-lg bg-muted/50">
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate">{action.label}</p>
|
||||
<p className="text-xs text-muted-foreground truncate hidden md:block">{action.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -453,6 +508,13 @@ export default function DashboardContent({ firstName, settings, summary, onboard
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Tax todo widget — visible when there are incomplete tax deadlines */}
|
||||
{summary.deadlines?.some(d => d.deadline_type === 'tax' && !d.is_completed) && (
|
||||
<section className="mb-10">
|
||||
<TaxTodoWidget deadlines={summary.deadlines} />
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Alerts section — always visible */}
|
||||
{alertItems.length > 0 && (
|
||||
<section id="alerts-section" className="mb-10">
|
||||
|
||||
@@ -46,7 +46,7 @@ interface NavItem {
|
||||
// All nav items for sidebar and mobile drawer
|
||||
const navItems: NavItem[] = [
|
||||
{ href: '/', label: 'Översikt', icon: LayoutDashboard, group: 'main' },
|
||||
{ href: '/calendar', label: 'Kalender', icon: Calendar, group: 'main' },
|
||||
{ href: '/deadlines', label: 'Deadlines', icon: Calendar, group: 'main' },
|
||||
{ href: '/invoices', label: 'Fakturor', icon: Receipt, group: 'finans' },
|
||||
{ href: '/customers', label: 'Kunder', icon: Users, group: 'finans' },
|
||||
{ href: '/suppliers', label: 'Leverantörer', icon: Building2, group: 'finans' },
|
||||
|
||||
@@ -231,9 +231,9 @@ export function TaxTodoWidget({ deadlines, onStatusChange }: TaxTodoWidgetProps)
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Link href="/calendar" className="block">
|
||||
<Link href="/deadlines" className="block">
|
||||
<Button variant="ghost" className="w-full justify-between">
|
||||
Visa alla skattedeadlines
|
||||
Visa alla deadlines
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
+2
-2
@@ -201,9 +201,9 @@ export function UpcomingDeadlinesWidget({ deadlines, maxItems = 5, onStatusChang
|
||||
)
|
||||
})}
|
||||
|
||||
<Link href="/calendar" className="block">
|
||||
<Link href="/deadlines" className="block">
|
||||
<Button variant="ghost" className="w-full justify-between mt-2">
|
||||
Visa kalender
|
||||
Visa alla deadlines
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
@@ -0,0 +1,6 @@
|
||||
export { DeadlineCard } from './DeadlineCard'
|
||||
export { DeadlineFilters } from './DeadlineFilters'
|
||||
export { DeadlineForm } from './DeadlineForm'
|
||||
export { DeadlineList } from './DeadlineList'
|
||||
export { UpcomingDeadlinesWidget } from './UpcomingDeadlinesWidget'
|
||||
export { TaxTodoWidget } from './TaxTodoWidget'
|
||||
+16
-29
@@ -3,10 +3,11 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { PaymentCalendar } from '@/components/calendar/PaymentCalendar'
|
||||
import { PaymentCalendar } from '@/extensions/general/calendar/components/PaymentCalendar'
|
||||
import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry'
|
||||
import type { Invoice, Deadline } from '@/types'
|
||||
|
||||
export default function CalendarPage() {
|
||||
export default function CalendarWorkspace({ userId }: WorkspaceComponentProps) {
|
||||
const [invoices, setInvoices] = useState<Invoice[]>([])
|
||||
const [deadlines, setDeadlines] = useState<Deadline[]>([])
|
||||
const [customers, setCustomers] = useState<{ id: string; name: string }[]>([])
|
||||
@@ -18,7 +19,6 @@ export default function CalendarPage() {
|
||||
setIsLoading(true)
|
||||
|
||||
try {
|
||||
// Fetch invoices with customer names
|
||||
const { data: invoicesData, error: invoicesError } = await supabase
|
||||
.from('invoices')
|
||||
.select('*, customer:customers(name)')
|
||||
@@ -26,7 +26,6 @@ export default function CalendarPage() {
|
||||
|
||||
if (invoicesError) throw invoicesError
|
||||
|
||||
// Fetch deadlines with customer names
|
||||
const { data: deadlinesData, error: deadlinesError } = await supabase
|
||||
.from('deadlines')
|
||||
.select('*, customer:customers(name)')
|
||||
@@ -34,7 +33,6 @@ export default function CalendarPage() {
|
||||
|
||||
if (deadlinesError) throw deadlinesError
|
||||
|
||||
// Fetch customers for the form
|
||||
const { data: customersData, error: customersError } = await supabase
|
||||
.from('customers')
|
||||
.select('id, name')
|
||||
@@ -45,10 +43,10 @@ export default function CalendarPage() {
|
||||
setInvoices(invoicesData || [])
|
||||
setDeadlines(deadlinesData || [])
|
||||
setCustomers(customersData || [])
|
||||
} catch (error) {
|
||||
} catch {
|
||||
toast({
|
||||
title: 'Fel',
|
||||
description: 'Kunde inte hämta data',
|
||||
description: 'Kunde inte hamta data',
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
@@ -101,7 +99,7 @@ export default function CalendarPage() {
|
||||
})
|
||||
|
||||
fetchData()
|
||||
} catch (error) {
|
||||
} catch {
|
||||
toast({
|
||||
title: 'Fel',
|
||||
description: 'Kunde inte uppdatera deadline',
|
||||
@@ -112,31 +110,20 @@ export default function CalendarPage() {
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Kalender</h1>
|
||||
</div>
|
||||
<div className="animate-pulse">
|
||||
<div className="h-10 bg-muted rounded w-48 mb-4" />
|
||||
<div className="h-96 bg-muted rounded" />
|
||||
</div>
|
||||
<div className="animate-pulse">
|
||||
<div className="h-10 bg-muted rounded w-48 mb-4" />
|
||||
<div className="h-96 bg-muted rounded" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Kalender</h1>
|
||||
</div>
|
||||
|
||||
<PaymentCalendar
|
||||
invoices={invoices}
|
||||
deadlines={deadlines}
|
||||
customers={customers}
|
||||
onDeadlineCreate={handleDeadlineCreate}
|
||||
onDeadlineToggle={handleDeadlineToggle}
|
||||
/>
|
||||
</div>
|
||||
<PaymentCalendar
|
||||
invoices={invoices}
|
||||
deadlines={deadlines}
|
||||
customers={customers}
|
||||
onDeadlineCreate={handleDeadlineCreate}
|
||||
onDeadlineToggle={handleDeadlineToggle}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -5,7 +5,9 @@
|
||||
* in server components or API routes.
|
||||
*
|
||||
* Provider-abstracted AI categorization for Swedish BAS account mapping.
|
||||
* Default implementation uses Claude Haiku for cost efficiency.
|
||||
* Uses Claude Haiku with structured tool outputs for reliable JSON.
|
||||
* Accepts pre-filtered candidate templates from embedding search (Tier 2)
|
||||
* instead of dumping all ~100 templates into the prompt.
|
||||
*/
|
||||
|
||||
import 'server-only'
|
||||
@@ -27,11 +29,29 @@ export interface TransactionForCategorization {
|
||||
currency: string
|
||||
}
|
||||
|
||||
export interface AccountUsageEntry {
|
||||
account_number: string
|
||||
count: number
|
||||
}
|
||||
|
||||
export interface MerchantHistoryEntry {
|
||||
merchant_name: string
|
||||
category: string
|
||||
template_id: string | null
|
||||
count: number
|
||||
}
|
||||
|
||||
export interface CategorizationContext {
|
||||
entityType: EntityType
|
||||
recentHistory: { description: string; category: string }[]
|
||||
}
|
||||
|
||||
export interface EnrichedCategorizationContext extends CategorizationContext {
|
||||
candidateTemplates: BookingTemplate[]
|
||||
userAccountUsage: AccountUsageEntry[]
|
||||
merchantHistory: MerchantHistoryEntry[]
|
||||
}
|
||||
|
||||
export interface CategorizationSuggestion {
|
||||
transactionId: string
|
||||
category: TransactionCategory
|
||||
@@ -46,7 +66,7 @@ export interface CategorizationSuggestion {
|
||||
export interface CategorizationProvider {
|
||||
categorize(
|
||||
transactions: TransactionForCategorization[],
|
||||
context: CategorizationContext
|
||||
context: CategorizationContext | EnrichedCategorizationContext
|
||||
): Promise<CategorizationSuggestion[]>
|
||||
}
|
||||
|
||||
@@ -76,11 +96,18 @@ function getCategoryAccountMap(entityType: EntityType): Record<string, { account
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an abbreviated template reference for the AI prompt.
|
||||
* Filters by direction (expense/income) to keep prompt concise.
|
||||
* Build template reference from candidate templates (pre-filtered by embeddings)
|
||||
* or fall back to full template list if no candidates provided.
|
||||
*/
|
||||
function getTemplateReference(direction: 'expense' | 'income'): string {
|
||||
return BOOKING_TEMPLATES
|
||||
function getTemplateReference(
|
||||
direction: 'expense' | 'income',
|
||||
candidateTemplates?: BookingTemplate[]
|
||||
): string {
|
||||
const templates = candidateTemplates && candidateTemplates.length > 0
|
||||
? candidateTemplates
|
||||
: BOOKING_TEMPLATES
|
||||
|
||||
return templates
|
||||
.filter((t) => t.direction === direction || t.direction === 'transfer')
|
||||
.map((t) => `${t.id}: ${t.name_sv} → ${t.debit_account}/${t.credit_account}`)
|
||||
.join('\n')
|
||||
@@ -97,6 +124,38 @@ ICKE-AVDRAGSGILLA KOSTNADER (svensk skatterätt):
|
||||
- Telefon/dator vid blandad användning: Bara yrkesmässig del avdragsgill
|
||||
`
|
||||
|
||||
// ============================================================
|
||||
// Classify Transaction Tool Schema
|
||||
// ============================================================
|
||||
|
||||
const CLASSIFY_TOOL: Anthropic.Tool = {
|
||||
name: 'classify_transactions',
|
||||
description: 'Classify a batch of bank transactions into Swedish BAS accounts and booking templates.',
|
||||
input_schema: {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
suggestions: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
transactionId: { type: 'string', description: 'Transaction ID' },
|
||||
templateId: { type: 'string', description: 'Booking template ID (from the provided templates list)' },
|
||||
category: { type: 'string', description: 'Transaction category (e.g. expense_software, income_services, private)' },
|
||||
basAccount: { type: 'string', description: 'BAS account number (4 digits)' },
|
||||
taxCode: { type: ['string', 'null'], description: 'Tax code: MPI for deductible expenses with VAT, MP1 for income with VAT, null for VAT-exempt/private' },
|
||||
confidence: { type: 'number', description: 'Confidence score 0.0-1.0' },
|
||||
reasoning: { type: 'string', description: 'Short reasoning in Swedish' },
|
||||
isPrivate: { type: 'boolean', description: 'Whether this is a private expense' },
|
||||
},
|
||||
required: ['transactionId', 'category', 'basAccount', 'confidence', 'reasoning', 'isPrivate'],
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['suggestions'],
|
||||
},
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Anthropic Provider
|
||||
// ============================================================
|
||||
@@ -116,23 +175,40 @@ export class AnthropicCategorizationProvider implements CategorizationProvider {
|
||||
|
||||
async categorize(
|
||||
transactions: TransactionForCategorization[],
|
||||
context: CategorizationContext
|
||||
context: CategorizationContext | EnrichedCategorizationContext
|
||||
): Promise<CategorizationSuggestion[]> {
|
||||
// Cap batch size
|
||||
const batch = transactions.slice(0, MAX_BATCH_SIZE)
|
||||
if (batch.length === 0) return []
|
||||
|
||||
const enriched = isEnrichedContext(context) ? context : null
|
||||
const privateAccount = context.entityType === 'aktiebolag' ? '2893' : '2013'
|
||||
const categoryAccountMap = getCategoryAccountMap(context.entityType)
|
||||
|
||||
// Build template references based on batch direction (most transactions will be same direction)
|
||||
// Build template references — use candidate templates if available
|
||||
const hasExpenses = batch.some((t) => t.amount < 0)
|
||||
const hasIncome = batch.some((t) => t.amount > 0)
|
||||
const candidates = enriched?.candidateTemplates
|
||||
const templateRef = [
|
||||
hasExpenses ? `UTGIFTSMALLAR:\n${getTemplateReference('expense')}` : '',
|
||||
hasIncome ? `INTÄKTSMALLAR:\n${getTemplateReference('income')}` : '',
|
||||
hasExpenses ? `UTGIFTSMALLAR:\n${getTemplateReference('expense', candidates)}` : '',
|
||||
hasIncome ? `INTÄKTSMALLAR:\n${getTemplateReference('income', candidates)}` : '',
|
||||
].filter(Boolean).join('\n\n')
|
||||
|
||||
// Build account usage context
|
||||
const accountUsageContext = enriched?.userAccountUsage && enriched.userAccountUsage.length > 0
|
||||
? `\nAnvändarens mest använda konton:\n${enriched.userAccountUsage
|
||||
.slice(0, 15)
|
||||
.map((a) => `- ${a.account_number} (${a.count} bokningar)`)
|
||||
.join('\n')}`
|
||||
: ''
|
||||
|
||||
// Build merchant history context
|
||||
const merchantHistoryContext = enriched?.merchantHistory && enriched.merchantHistory.length > 0
|
||||
? `\nTidigare kategorisering av dessa handlare:\n${enriched.merchantHistory
|
||||
.map((m) => `- "${m.merchant_name}" → ${m.category}${m.template_id ? ` (mall: ${m.template_id})` : ''} (${m.count}x)`)
|
||||
.join('\n')}`
|
||||
: ''
|
||||
|
||||
const systemPrompt = `Du är expert på svensk bokföring och kategorisering av banktransaktioner enligt BAS-kontoplanen.
|
||||
Din uppgift är att kategorisera varje transaktion till rätt mall-ID (templateId) och BAS-konto.
|
||||
|
||||
@@ -181,29 +257,11 @@ REGLER:
|
||||
)
|
||||
.join('\n\n')
|
||||
|
||||
const userPrompt = `Kategorisera följande transaktioner:
|
||||
${historyContext}
|
||||
const userPrompt = `Kategorisera följande transaktioner med classify_transactions-verktyget:
|
||||
${historyContext}${accountUsageContext}${merchantHistoryContext}
|
||||
|
||||
TRANSAKTIONER:
|
||||
${transactionList}
|
||||
|
||||
Returnera ett JSON-objekt med följande struktur:
|
||||
{
|
||||
"suggestions": [
|
||||
{
|
||||
"transactionId": "id",
|
||||
"category": "expense_software",
|
||||
"templateId": "it_saas_subscription",
|
||||
"basAccount": "5420",
|
||||
"taxCode": "MPI",
|
||||
"confidence": 0.9,
|
||||
"reasoning": "Spotify-prenumeration, typisk programvarukostnad",
|
||||
"isPrivate": false
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Returnera ENDAST JSON-objektet, ingen annan text.`
|
||||
${transactionList}`
|
||||
|
||||
let lastError: Error | null = null
|
||||
|
||||
@@ -212,7 +270,15 @@ Returnera ENDAST JSON-objektet, ingen annan text.`
|
||||
const message = await this.client.messages.create({
|
||||
model: this.model,
|
||||
max_tokens: 4096,
|
||||
system: systemPrompt,
|
||||
system: [
|
||||
{
|
||||
type: 'text',
|
||||
text: systemPrompt,
|
||||
cache_control: { type: 'ephemeral' },
|
||||
},
|
||||
],
|
||||
tools: [CLASSIFY_TOOL],
|
||||
tool_choice: { type: 'tool', name: 'classify_transactions' },
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
@@ -221,33 +287,20 @@ Returnera ENDAST JSON-objektet, ingen annan text.`
|
||||
],
|
||||
})
|
||||
|
||||
const content = message.content[0]
|
||||
if (content.type !== 'text') {
|
||||
throw new Error('Unexpected response type from AI')
|
||||
// Extract tool_use block from response
|
||||
const toolUseBlock = message.content.find(
|
||||
(block) => block.type === 'tool_use' && block.name === 'classify_transactions'
|
||||
)
|
||||
|
||||
if (!toolUseBlock || toolUseBlock.type !== 'tool_use') {
|
||||
throw new Error('No tool_use block in AI response')
|
||||
}
|
||||
|
||||
// Strip markdown code blocks if present
|
||||
let jsonText = content.text.trim()
|
||||
if (jsonText.startsWith('```json')) {
|
||||
jsonText = jsonText.slice(7)
|
||||
} else if (jsonText.startsWith('```')) {
|
||||
jsonText = jsonText.slice(3)
|
||||
}
|
||||
if (jsonText.endsWith('```')) {
|
||||
jsonText = jsonText.slice(0, -3)
|
||||
}
|
||||
jsonText = jsonText.trim()
|
||||
|
||||
const parsed = JSON.parse(jsonText)
|
||||
return this.validateSuggestions(parsed.suggestions || [], batch, context.entityType)
|
||||
const input = toolUseBlock.input as { suggestions?: unknown[] }
|
||||
return this.validateSuggestions(input.suggestions || [], batch, context.entityType)
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error : new Error('Unknown error')
|
||||
|
||||
// Don't retry on parse errors
|
||||
if (error instanceof SyntaxError) {
|
||||
throw new Error(`Failed to parse AI response: ${lastError.message}`)
|
||||
}
|
||||
|
||||
if (attempt < MAX_RETRIES - 1) {
|
||||
await sleep(RETRY_DELAY_MS * (attempt + 1))
|
||||
}
|
||||
@@ -297,6 +350,12 @@ Returnera ENDAST JSON-objektet, ingen annan text.`
|
||||
}
|
||||
}
|
||||
|
||||
function isEnrichedContext(
|
||||
ctx: CategorizationContext | EnrichedCategorizationContext
|
||||
): ctx is EnrichedCategorizationContext {
|
||||
return 'candidateTemplates' in ctx
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
@@ -5,9 +5,13 @@ import {
|
||||
AnthropicCategorizationProvider,
|
||||
type CategorizationProvider,
|
||||
type TransactionForCategorization,
|
||||
type CategorizationContext,
|
||||
type EnrichedCategorizationContext,
|
||||
type CategorizationSuggestion,
|
||||
type AccountUsageEntry,
|
||||
type MerchantHistoryEntry,
|
||||
} from './categorizer'
|
||||
import { findSimilarTemplates } from '@/lib/bookkeeping/template-embeddings'
|
||||
import type { BookingTemplate } from '@/lib/bookkeeping/booking-templates'
|
||||
|
||||
// ============================================================
|
||||
// Settings
|
||||
@@ -117,7 +121,7 @@ export async function categorizeTransactions(
|
||||
currency: t.currency,
|
||||
}))
|
||||
|
||||
const context = await buildContext(userId, supabase)
|
||||
const context = await buildEnrichedContext(userId, supabase, batch)
|
||||
|
||||
const aiProvider = getProvider(settings.providerModel)
|
||||
const suggestions = await aiProvider.categorize(batch, context)
|
||||
@@ -168,7 +172,7 @@ async function handleTransactionSynced(
|
||||
currency: t.currency,
|
||||
}))
|
||||
|
||||
const context = await buildContext(userId, supabase)
|
||||
const context = await buildEnrichedContext(userId, supabase, batch)
|
||||
const aiProvider = getProvider(settings.providerModel)
|
||||
const suggestions = await aiProvider.categorize(batch, context)
|
||||
|
||||
@@ -194,7 +198,11 @@ async function handleTransactionSynced(
|
||||
// ============================================================
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
async function buildContext(userId: string, supabase: any): Promise<CategorizationContext> {
|
||||
async function buildEnrichedContext(
|
||||
userId: string,
|
||||
supabase: any,
|
||||
transactions: TransactionForCategorization[]
|
||||
): Promise<EnrichedCategorizationContext> {
|
||||
// Fetch entity type
|
||||
const { data: companySettings } = await supabase
|
||||
.from('company_settings')
|
||||
@@ -221,7 +229,92 @@ async function buildContext(userId: string, supabase: any): Promise<Categorizati
|
||||
})
|
||||
)
|
||||
|
||||
return { entityType, recentHistory }
|
||||
// Fetch user's most-used accounts (top 30)
|
||||
const { data: accountUsageRows } = await supabase
|
||||
.from('journal_entry_lines')
|
||||
.select('account_number')
|
||||
.eq('user_id', userId)
|
||||
|
||||
const accountCounts = new Map<string, number>()
|
||||
if (accountUsageRows) {
|
||||
for (const row of accountUsageRows as { account_number: string }[]) {
|
||||
accountCounts.set(row.account_number, (accountCounts.get(row.account_number) || 0) + 1)
|
||||
}
|
||||
}
|
||||
const userAccountUsage: AccountUsageEntry[] = Array.from(accountCounts.entries())
|
||||
.map(([account_number, count]) => ({ account_number, count }))
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, 30)
|
||||
|
||||
// Fetch merchant history for this batch's merchants
|
||||
const merchantNames = [...new Set(
|
||||
transactions
|
||||
.map((t) => t.merchant_name)
|
||||
.filter((n): n is string => n !== null && n.length > 0)
|
||||
)]
|
||||
|
||||
let merchantHistory: MerchantHistoryEntry[] = []
|
||||
if (merchantNames.length > 0) {
|
||||
const { data: merchantRows } = await supabase
|
||||
.from('transactions')
|
||||
.select('merchant_name, category, template_id')
|
||||
.eq('user_id', userId)
|
||||
.not('is_business', 'is', null)
|
||||
.neq('category', 'uncategorized')
|
||||
.in('merchant_name', merchantNames)
|
||||
.limit(200)
|
||||
|
||||
if (merchantRows) {
|
||||
const merchantMap = new Map<string, MerchantHistoryEntry>()
|
||||
for (const row of merchantRows as { merchant_name: string; category: string; template_id: string | null }[]) {
|
||||
const key = `${row.merchant_name}:${row.category}`
|
||||
const existing = merchantMap.get(key)
|
||||
if (existing) {
|
||||
existing.count++
|
||||
} else {
|
||||
merchantMap.set(key, {
|
||||
merchant_name: row.merchant_name,
|
||||
category: row.category,
|
||||
template_id: row.template_id,
|
||||
count: 1,
|
||||
})
|
||||
}
|
||||
}
|
||||
merchantHistory = Array.from(merchantMap.values())
|
||||
.sort((a, b) => b.count - a.count)
|
||||
}
|
||||
}
|
||||
|
||||
// Find candidate templates via embedding search
|
||||
// Use a representative subset of transactions to find candidates
|
||||
const representativeTransactions = transactions.slice(0, 5)
|
||||
const candidateMap = new Map<string, BookingTemplate>()
|
||||
|
||||
for (const tx of representativeTransactions) {
|
||||
try {
|
||||
const matches = await findSimilarTemplates(
|
||||
tx as unknown as Transaction,
|
||||
entityType
|
||||
)
|
||||
for (const m of matches) {
|
||||
if (!candidateMap.has(m.template.id)) {
|
||||
candidateMap.set(m.template.id, m.template)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Embedding search failed — continue without candidates
|
||||
}
|
||||
}
|
||||
|
||||
const candidateTemplates = Array.from(candidateMap.values())
|
||||
|
||||
return {
|
||||
entityType,
|
||||
recentHistory,
|
||||
candidateTemplates,
|
||||
userAccountUsage,
|
||||
merchantHistory,
|
||||
}
|
||||
}
|
||||
|
||||
async function storeSuggestions(
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ import { CalendarGrid } from './CalendarGrid'
|
||||
import { CalendarWeekView } from './CalendarWeekView'
|
||||
import { CalendarDayView } from './CalendarDayView'
|
||||
import { DayDetailModal } from './DayDetailModal'
|
||||
import { DeadlineForm } from './DeadlineForm'
|
||||
import { DeadlineForm } from '@/components/deadlines/DeadlineForm'
|
||||
|
||||
interface PaymentCalendarProps {
|
||||
invoices: Invoice[]
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { Extension } from '@/lib/extensions/types'
|
||||
|
||||
export const calendarExtension: Extension = {
|
||||
id: 'calendar',
|
||||
name: 'Kalender',
|
||||
version: '1.0.0',
|
||||
}
|
||||
@@ -152,7 +152,7 @@ export function createTaxDeadlinePayload(
|
||||
badge: '/icons/badge-72.png',
|
||||
tag: `tax-deadline-${deadlineId}`,
|
||||
data: {
|
||||
url: '/calendar',
|
||||
url: '/deadlines',
|
||||
type: 'tax_deadline',
|
||||
id: deadlineId,
|
||||
},
|
||||
|
||||
@@ -249,7 +249,7 @@ describe('findMatchingTemplates', () => {
|
||||
mcc_code: 5817,
|
||||
})
|
||||
const matches = findMatchingTemplates(tx)
|
||||
expect(matches.length).toBeLessThanOrEqual(5)
|
||||
expect(matches.length).toBeLessThanOrEqual(20)
|
||||
})
|
||||
|
||||
it('results are sorted by confidence descending', () => {
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { makeTransaction, createMockSupabase } from '@/tests/helpers'
|
||||
import { BOOKING_TEMPLATES } from '../booking-templates'
|
||||
|
||||
// Mock server-only (no-op in tests)
|
||||
vi.mock('server-only', () => ({}))
|
||||
|
||||
// Mock OpenAI Embeddings
|
||||
vi.mock('@langchain/openai', () => {
|
||||
class MockOpenAIEmbeddings {
|
||||
embedQuery = vi.fn().mockResolvedValue(new Array(1536).fill(0.1))
|
||||
embedDocuments = vi.fn().mockImplementation((texts: string[]) =>
|
||||
Promise.resolve(texts.map(() => new Array(1536).fill(0.1)))
|
||||
)
|
||||
}
|
||||
return { OpenAIEmbeddings: MockOpenAIEmbeddings }
|
||||
})
|
||||
|
||||
// Mock Supabase
|
||||
const { supabase: mockSupabase, mockResult } = createMockSupabase()
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createServiceClient: vi.fn().mockResolvedValue(mockSupabase),
|
||||
}))
|
||||
|
||||
describe('template-embeddings', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('buildEmbeddingText', () => {
|
||||
it('includes all relevant fields for a template', async () => {
|
||||
const { buildEmbeddingText } = await import('../template-embeddings')
|
||||
|
||||
const template = BOOKING_TEMPLATES.find((t) => t.id === 'premises_rent')!
|
||||
const text = buildEmbeddingText(template)
|
||||
|
||||
// Should include Swedish and English name
|
||||
expect(text).toContain('Lokalhyra')
|
||||
expect(text).toContain('Office rent')
|
||||
|
||||
// Should include description
|
||||
expect(text).toContain(template.description_sv)
|
||||
|
||||
// Should include keywords
|
||||
expect(text).toContain('hyra')
|
||||
expect(text).toContain('lokal')
|
||||
|
||||
// Should include group
|
||||
expect(text).toContain('premises')
|
||||
|
||||
// Should include direction
|
||||
expect(text).toContain('utgift')
|
||||
|
||||
// Should include accounts
|
||||
expect(text).toContain('5010')
|
||||
expect(text).toContain('1930')
|
||||
})
|
||||
|
||||
it('includes VAT treatment when present', async () => {
|
||||
const { buildEmbeddingText } = await import('../template-embeddings')
|
||||
|
||||
const template = BOOKING_TEMPLATES.find((t) => t.id === 'premises_electricity')!
|
||||
const text = buildEmbeddingText(template)
|
||||
|
||||
expect(text).toContain('standard_25')
|
||||
expect(text).toContain('25%')
|
||||
})
|
||||
|
||||
it('includes special rules when present', async () => {
|
||||
const { buildEmbeddingText } = await import('../template-embeddings')
|
||||
|
||||
const template = BOOKING_TEMPLATES.find((t) => t.id === 'premises_rent')!
|
||||
const text = buildEmbeddingText(template)
|
||||
|
||||
expect(text).toContain(template.special_rules_sv!)
|
||||
})
|
||||
|
||||
it('includes MCC codes when present', async () => {
|
||||
const { buildEmbeddingText } = await import('../template-embeddings')
|
||||
|
||||
const template = BOOKING_TEMPLATES.find((t) => t.id === 'premises_electricity')!
|
||||
const text = buildEmbeddingText(template)
|
||||
|
||||
expect(text).toContain('4900')
|
||||
})
|
||||
|
||||
it('includes deductibility note for non-full deductibility', async () => {
|
||||
const { buildEmbeddingText } = await import('../template-embeddings')
|
||||
|
||||
const template = BOOKING_TEMPLATES.find((t) => t.deductibility === 'non_deductible')!
|
||||
const text = buildEmbeddingText(template)
|
||||
|
||||
expect(text).toContain('non_deductible')
|
||||
})
|
||||
|
||||
it('generates text for all 100 templates without error', async () => {
|
||||
const { buildEmbeddingText } = await import('../template-embeddings')
|
||||
|
||||
for (const template of BOOKING_TEMPLATES) {
|
||||
const text = buildEmbeddingText(template)
|
||||
expect(text.length).toBeGreaterThan(10)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildTransactionQueryText', () => {
|
||||
it('combines description, merchant, and direction', async () => {
|
||||
const { buildTransactionQueryText } = await import('../template-embeddings')
|
||||
|
||||
const tx = makeTransaction({
|
||||
description: 'SPOTIFY PREMIUM',
|
||||
merchant_name: 'Spotify',
|
||||
amount: -109,
|
||||
mcc_code: 5815,
|
||||
})
|
||||
|
||||
const text = buildTransactionQueryText(tx)
|
||||
|
||||
expect(text).toContain('SPOTIFY PREMIUM')
|
||||
expect(text).toContain('Spotify')
|
||||
expect(text).toContain('MCC 5815')
|
||||
expect(text).toContain('utgift')
|
||||
})
|
||||
|
||||
it('marks positive amounts as income', async () => {
|
||||
const { buildTransactionQueryText } = await import('../template-embeddings')
|
||||
|
||||
const tx = makeTransaction({
|
||||
description: 'Inbetalning',
|
||||
amount: 5000,
|
||||
})
|
||||
|
||||
const text = buildTransactionQueryText(tx)
|
||||
expect(text).toContain('intäkt')
|
||||
})
|
||||
|
||||
it('handles null merchant_name and mcc_code', async () => {
|
||||
const { buildTransactionQueryText } = await import('../template-embeddings')
|
||||
|
||||
const tx = makeTransaction({
|
||||
description: 'Some payment',
|
||||
merchant_name: null,
|
||||
mcc_code: null,
|
||||
amount: -100,
|
||||
})
|
||||
|
||||
const text = buildTransactionQueryText(tx)
|
||||
expect(text).toContain('Some payment')
|
||||
expect(text).toContain('utgift')
|
||||
expect(text).not.toContain('MCC')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getSchemaVersion', () => {
|
||||
it('returns a consistent hash string', async () => {
|
||||
const { getSchemaVersion } = await import('../template-embeddings')
|
||||
|
||||
const v1 = getSchemaVersion()
|
||||
const v2 = getSchemaVersion()
|
||||
|
||||
expect(v1).toBe(v2)
|
||||
expect(v1).toHaveLength(12)
|
||||
expect(v1).toMatch(/^[a-f0-9]+$/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('findSimilarTemplates', () => {
|
||||
it('returns empty array on RPC error (graceful fallback)', async () => {
|
||||
const { findSimilarTemplates } = await import('../template-embeddings')
|
||||
|
||||
// Mock staleness check
|
||||
mockResult({ data: { schema_version: 'test' }, error: null })
|
||||
|
||||
const tx = makeTransaction({
|
||||
description: 'SPOTIFY',
|
||||
amount: -109,
|
||||
})
|
||||
|
||||
// The mock will return error for the RPC call
|
||||
mockResult({ data: null, error: { message: 'RPC failed' } })
|
||||
const results = await findSimilarTemplates(tx)
|
||||
expect(results).toEqual([])
|
||||
})
|
||||
|
||||
it('returns empty array when no embeddings exist', async () => {
|
||||
const { findSimilarTemplates } = await import('../template-embeddings')
|
||||
|
||||
mockResult({ data: [], error: null })
|
||||
|
||||
const tx = makeTransaction({
|
||||
description: 'Random purchase',
|
||||
amount: -50,
|
||||
})
|
||||
|
||||
const results = await findSimilarTemplates(tx)
|
||||
expect(results).toEqual([])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -617,7 +617,7 @@ export const BOOKING_TEMPLATES: readonly BookingTemplate[] = [
|
||||
deductibility: 'full',
|
||||
special_rules_sv: 'Ofta utländsk leverantör (USA) med omvänd skattskyldighet',
|
||||
mcc_codes: [],
|
||||
keywords: ['openai', 'chatgpt', 'anthropic', 'claude', 'ai', 'midjourney', 'copilot'],
|
||||
keywords: ['openai', 'chatgpt', 'anthropic', 'claude', 'ai', 'midjourney', 'copilot', 'mistral', 'claude', 'gemini'],
|
||||
risk_level: 'NONE',
|
||||
requires_review: false,
|
||||
impact_score: 7,
|
||||
@@ -663,7 +663,7 @@ export const BOOKING_TEMPLATES: readonly BookingTemplate[] = [
|
||||
vat_rate: 0.25,
|
||||
deductibility: 'full',
|
||||
mcc_codes: [5111, 5112, 5943, 5944],
|
||||
keywords: ['kontorsmaterial', 'pennor', 'papper', 'office supplies', 'staples', 'kontorsvaror'],
|
||||
keywords: ['kontorsmaterial', 'pennor', 'papper', 'office supplies', 'staples', 'kontorsvaror', 'kontor'],
|
||||
risk_level: 'NONE',
|
||||
requires_review: false,
|
||||
impact_score: 7,
|
||||
@@ -2534,7 +2534,7 @@ export function findMatchingTemplates(
|
||||
|
||||
return results
|
||||
.sort((a, b) => b.confidence - a.confidence)
|
||||
.slice(0, 5)
|
||||
.slice(0, 20)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* Template Embeddings Module
|
||||
*
|
||||
* SERVER-ONLY: Uses OpenAI embeddings and Supabase service client.
|
||||
*
|
||||
* Provides semantic search over booking templates using pgvector.
|
||||
* Templates are pre-embedded and stored in the database. Transaction
|
||||
* text is embedded at query time and compared via cosine similarity.
|
||||
*/
|
||||
|
||||
import 'server-only'
|
||||
import { OpenAIEmbeddings } from '@langchain/openai'
|
||||
import {
|
||||
BOOKING_TEMPLATES,
|
||||
getTemplateById,
|
||||
type BookingTemplate,
|
||||
type TemplateMatch,
|
||||
} from './booking-templates'
|
||||
import type { Transaction, EntityType } from '@/types'
|
||||
import { createHash } from 'crypto'
|
||||
|
||||
// ============================================================
|
||||
// Constants
|
||||
// ============================================================
|
||||
|
||||
export const EMBEDDING_MODEL = 'text-embedding-3-small'
|
||||
const EMBEDDING_LOGIC_VERSION = '1'
|
||||
const MATCH_COUNT = 20
|
||||
const MATCH_THRESHOLD = 0.5
|
||||
|
||||
/**
|
||||
* Schema version is a hash of the model + embedding logic version.
|
||||
* Bump EMBEDDING_LOGIC_VERSION when buildEmbeddingText changes.
|
||||
*/
|
||||
export function getSchemaVersion(): string {
|
||||
return createHash('sha256')
|
||||
.update(`${EMBEDDING_MODEL}:${EMBEDDING_LOGIC_VERSION}`)
|
||||
.digest('hex')
|
||||
.slice(0, 12)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Embedding Text Builders
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Build a rich text representation of a template for embedding.
|
||||
* Includes all semantically relevant fields.
|
||||
*/
|
||||
export function buildEmbeddingText(template: BookingTemplate): string {
|
||||
const parts: string[] = []
|
||||
|
||||
parts.push(`${template.name_sv} (${template.name_en})`)
|
||||
parts.push(template.description_sv)
|
||||
|
||||
if (template.keywords.length > 0) {
|
||||
parts.push(`Nyckelord: ${template.keywords.join(', ')}`)
|
||||
}
|
||||
|
||||
parts.push(`Grupp: ${template.group}`)
|
||||
parts.push(`Typ: ${template.direction === 'expense' ? 'utgift' : template.direction === 'income' ? 'intäkt' : 'överföring'}`)
|
||||
parts.push(`Konton: ${template.debit_account} (debet) / ${template.credit_account} (kredit)`)
|
||||
|
||||
if (template.vat_treatment) {
|
||||
parts.push(`Moms: ${template.vat_treatment} (${template.vat_rate * 100}%)`)
|
||||
}
|
||||
|
||||
if (template.special_rules_sv) {
|
||||
parts.push(`Regler: ${template.special_rules_sv}`)
|
||||
}
|
||||
|
||||
if (template.mcc_codes.length > 0) {
|
||||
parts.push(`MCC-koder: ${template.mcc_codes.join(', ')}`)
|
||||
}
|
||||
|
||||
if (template.deductibility !== 'full') {
|
||||
parts.push(`Avdragsrätt: ${template.deductibility}`)
|
||||
}
|
||||
|
||||
return parts.join('. ')
|
||||
}
|
||||
|
||||
/**
|
||||
* Build query text from a transaction for embedding search.
|
||||
*/
|
||||
export function buildTransactionQueryText(transaction: Transaction): string {
|
||||
const parts: string[] = []
|
||||
|
||||
if (transaction.description) {
|
||||
parts.push(transaction.description)
|
||||
}
|
||||
|
||||
if (transaction.merchant_name) {
|
||||
parts.push(transaction.merchant_name)
|
||||
}
|
||||
|
||||
if (transaction.mcc_code) {
|
||||
parts.push(`MCC ${transaction.mcc_code}`)
|
||||
}
|
||||
|
||||
parts.push(transaction.amount < 0 ? 'utgift' : 'intäkt')
|
||||
|
||||
return parts.join(' — ')
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Embeddings Client
|
||||
// ============================================================
|
||||
|
||||
let embeddingsInstance: OpenAIEmbeddings | null = null
|
||||
|
||||
function getEmbeddingsClient(): OpenAIEmbeddings {
|
||||
if (!embeddingsInstance) {
|
||||
embeddingsInstance = new OpenAIEmbeddings({
|
||||
modelName: EMBEDDING_MODEL,
|
||||
openAIApiKey: process.env.OPENAI_API_KEY,
|
||||
})
|
||||
}
|
||||
return embeddingsInstance
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Seed All Template Embeddings
|
||||
// ============================================================
|
||||
|
||||
export async function seedAllTemplateEmbeddings(): Promise<{
|
||||
seeded: number
|
||||
errors: string[]
|
||||
}> {
|
||||
const { createServiceClient } = await import('@/lib/supabase/server')
|
||||
const supabase = await createServiceClient()
|
||||
const embeddings = getEmbeddingsClient()
|
||||
const schemaVersion = getSchemaVersion()
|
||||
const errors: string[] = []
|
||||
|
||||
// Build texts for all templates
|
||||
const texts = BOOKING_TEMPLATES.map((t) => buildEmbeddingText(t))
|
||||
|
||||
// Batch embed all texts
|
||||
let vectors: number[][]
|
||||
try {
|
||||
vectors = await embeddings.embedDocuments(texts)
|
||||
} catch (error) {
|
||||
return { seeded: 0, errors: [`Embedding generation failed: ${error}`] }
|
||||
}
|
||||
|
||||
// Upsert each template embedding
|
||||
let seeded = 0
|
||||
for (let i = 0; i < BOOKING_TEMPLATES.length; i++) {
|
||||
const template = BOOKING_TEMPLATES[i]
|
||||
const { error } = await supabase
|
||||
.from('booking_template_embeddings')
|
||||
.upsert(
|
||||
{
|
||||
template_id: template.id,
|
||||
embedding: JSON.stringify(vectors[i]),
|
||||
embedding_text: texts[i],
|
||||
model: EMBEDDING_MODEL,
|
||||
schema_version: schemaVersion,
|
||||
},
|
||||
{ onConflict: 'template_id' }
|
||||
)
|
||||
|
||||
if (error) {
|
||||
errors.push(`Failed to upsert ${template.id}: ${error.message}`)
|
||||
} else {
|
||||
seeded++
|
||||
}
|
||||
}
|
||||
|
||||
return { seeded, errors }
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Find Similar Templates (Semantic Search)
|
||||
// ============================================================
|
||||
|
||||
let stalenessWarned = false
|
||||
|
||||
export async function findSimilarTemplates(
|
||||
transaction: Transaction,
|
||||
entityType?: EntityType,
|
||||
matchCount: number = MATCH_COUNT
|
||||
): Promise<TemplateMatch[]> {
|
||||
try {
|
||||
const { createServiceClient } = await import('@/lib/supabase/server')
|
||||
const supabase = await createServiceClient()
|
||||
const embeddings = getEmbeddingsClient()
|
||||
|
||||
// Check schema version staleness on first call
|
||||
if (!stalenessWarned) {
|
||||
const { data: sample } = await supabase
|
||||
.from('booking_template_embeddings')
|
||||
.select('schema_version')
|
||||
.limit(1)
|
||||
.single()
|
||||
|
||||
if (sample && sample.schema_version !== getSchemaVersion()) {
|
||||
console.warn(
|
||||
`[template-embeddings] Schema version mismatch: DB has "${sample.schema_version}", current is "${getSchemaVersion()}". Re-seed embeddings.`
|
||||
)
|
||||
}
|
||||
stalenessWarned = true
|
||||
}
|
||||
|
||||
// Embed the transaction query text
|
||||
const queryText = buildTransactionQueryText(transaction)
|
||||
const queryVector = await embeddings.embedQuery(queryText)
|
||||
|
||||
// Request extra results to account for post-filtering
|
||||
const requestCount = matchCount + 10
|
||||
|
||||
const { data, error } = await supabase.rpc('match_booking_templates', {
|
||||
query_embedding: JSON.stringify(queryVector),
|
||||
match_count: requestCount,
|
||||
match_threshold: MATCH_THRESHOLD,
|
||||
})
|
||||
|
||||
if (error || !data) {
|
||||
console.error('[template-embeddings] RPC error:', error)
|
||||
return []
|
||||
}
|
||||
|
||||
// Map RPC results to TemplateMatch[], filtering by entity type and direction
|
||||
const isExpense = transaction.amount < 0
|
||||
const isIncome = transaction.amount > 0
|
||||
const results: TemplateMatch[] = []
|
||||
|
||||
for (const row of data as { template_id: string; similarity: number }[]) {
|
||||
const template = getTemplateById(row.template_id)
|
||||
if (!template) continue
|
||||
|
||||
// Filter by entity applicability
|
||||
if (entityType && template.entity_applicability !== 'all' && template.entity_applicability !== entityType) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Filter by direction
|
||||
if (template.direction === 'expense' && !isExpense) continue
|
||||
if (template.direction === 'income' && !isIncome) continue
|
||||
|
||||
results.push({
|
||||
template,
|
||||
confidence: Math.round(row.similarity * 100) / 100,
|
||||
})
|
||||
|
||||
if (results.length >= matchCount) break
|
||||
}
|
||||
|
||||
return results
|
||||
} catch (error) {
|
||||
console.error('[template-embeddings] findSimilarTemplates failed:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ describe('sectors registry', () => {
|
||||
})
|
||||
|
||||
it('should have 18 total extensions', () => {
|
||||
expect(getAllExtensions().length).toBe(18)
|
||||
expect(getAllExtensions().length).toBe(19)
|
||||
})
|
||||
|
||||
it('should have unique slugs within each sector', () => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
Sparkles,
|
||||
MessageSquare,
|
||||
Bell,
|
||||
Inbox,
|
||||
Landmark,
|
||||
UtensilsCrossed,
|
||||
ChefHat,
|
||||
@@ -31,6 +32,7 @@ const ICON_MAP: Record<string, LucideIcon> = {
|
||||
Sparkles,
|
||||
MessageSquare,
|
||||
Bell,
|
||||
Inbox,
|
||||
Landmark,
|
||||
UtensilsCrossed,
|
||||
ChefHat,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { sruExportExtension } from '@/extensions/sru-export'
|
||||
import { neBilagaExtension } from '@/extensions/ne-bilaga'
|
||||
import { aiChatExtension } from '@/extensions/general/ai-chat'
|
||||
import { invoiceInboxExtension } from '@/extensions/general/invoice-inbox'
|
||||
import { calendarExtension } from '@/extensions/general/calendar'
|
||||
import type { Extension } from './types'
|
||||
|
||||
// ── Enable Banking (PSD2) — opt-in extension ───────────────────────────
|
||||
@@ -28,6 +29,7 @@ const FIRST_PARTY_EXTENSIONS: Extension[] = [
|
||||
neBilagaExtension,
|
||||
aiChatExtension,
|
||||
invoiceInboxExtension,
|
||||
calendarExtension,
|
||||
// enableBankingExtension, // Uncomment to activate PSD2 bank sync
|
||||
]
|
||||
|
||||
|
||||
@@ -51,6 +51,12 @@ export const SECTORS: Sector[] = [
|
||||
description: 'AI-assistent för skatte- och bokföringsfrågor',
|
||||
longDescription:
|
||||
'Ställ frågor om skatt, bokföring och företagande till en AI-assistent som förstår svensk redovisning. Svar baserade på aktuella regler och praxis.',
|
||||
quickAction: {
|
||||
label: 'AI-assistent',
|
||||
description: 'Fråga om bokföring',
|
||||
icon: 'MessageSquare',
|
||||
event: 'open-ai-chat',
|
||||
},
|
||||
},
|
||||
{
|
||||
slug: 'push-notifications',
|
||||
@@ -76,6 +82,18 @@ export const SECTORS: Sector[] = [
|
||||
longDescription:
|
||||
'Skicka leverantörsfakturor till en dedikerad e-postadress eller ladda upp manuellt. AI extraherar automatiskt leverantörsdata, belopp och moms. Granska och bekräfta med ett klick för att skapa leverantörsfakturor.',
|
||||
},
|
||||
{
|
||||
slug: 'calendar',
|
||||
name: 'Kalender',
|
||||
sector: 'general',
|
||||
category: 'operations',
|
||||
icon: 'Calendar',
|
||||
dataPattern: 'core',
|
||||
readsCoreTables: ['invoices', 'deadlines', 'customers'],
|
||||
description: 'Fullstandig kalendervy med manads-, vecko- och dagsvisning',
|
||||
longDescription:
|
||||
'Se alla fakturadatum och deadlines i en interaktiv kalender med manads-, vecko- och dagsvy.',
|
||||
},
|
||||
{
|
||||
slug: 'enable-banking',
|
||||
name: 'Bankintegration (PSD2)',
|
||||
|
||||
@@ -15,6 +15,16 @@ export type SectorSlug = 'general' | 'restaurant' | 'construction' | 'hotel' | '
|
||||
/** How an extension gets its data */
|
||||
export type ExtensionDataPattern = 'core' | 'manual' | 'both'
|
||||
|
||||
/** Dashboard quick action declared by an extension */
|
||||
export interface QuickActionDefinition {
|
||||
label: string
|
||||
description: string
|
||||
icon: string
|
||||
href?: string
|
||||
event?: string
|
||||
order?: number
|
||||
}
|
||||
|
||||
/** Extension metadata for the marketplace and workspace routing */
|
||||
export interface ExtensionDefinition {
|
||||
slug: string
|
||||
@@ -28,6 +38,7 @@ export interface ExtensionDefinition {
|
||||
dataPattern: ExtensionDataPattern
|
||||
readsCoreTables?: string[]
|
||||
hasOwnData?: boolean
|
||||
quickAction?: QuickActionDefinition
|
||||
}
|
||||
|
||||
/** Sector definition with its extensions */
|
||||
|
||||
@@ -14,6 +14,7 @@ const WORKSPACES: Record<WorkspaceKey, ComponentType<WorkspaceComponentProps>> =
|
||||
'general/ai-chat': dynamic(() => import('@/components/extensions/general/AiChatWorkspace')),
|
||||
'general/push-notifications': dynamic(() => import('@/components/extensions/general/PushNotificationsWorkspace')),
|
||||
'general/invoice-inbox': dynamic(() => import('@/components/extensions/general/InvoiceInboxWorkspace')),
|
||||
'general/calendar': dynamic(() => import('@/components/extensions/general/CalendarWorkspace')),
|
||||
'general/enable-banking': dynamic(() => import('@/components/extensions/general/EnableBankingWorkspace')),
|
||||
// Restaurant
|
||||
'restaurant/food-cost': dynamic(() => import('@/components/extensions/restaurant/FoodCostWorkspace')),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { suggestCategory } from '@/lib/tax/expense-warnings'
|
||||
import { getExpenseAccountForCategory } from '@/lib/bookkeeping/category-mapping'
|
||||
import { findMatchingTemplates, type TemplateMatch } from '@/lib/bookkeeping/booking-templates'
|
||||
import { findSimilarTemplates } from '@/lib/bookkeeping/template-embeddings'
|
||||
import type { Transaction, TransactionCategory, EntityType, MappingRule } from '@/types'
|
||||
|
||||
export interface SuggestedCategory {
|
||||
@@ -198,13 +199,25 @@ export interface SuggestedTemplate {
|
||||
|
||||
/**
|
||||
* Get suggested booking templates for a transaction.
|
||||
* Uses multi-signal matching (MCC, keywords, description patterns).
|
||||
* Tries embedding-based semantic search first, falls back to keyword matching.
|
||||
*/
|
||||
export function getSuggestedTemplates(
|
||||
export async function getSuggestedTemplates(
|
||||
transaction: Transaction,
|
||||
entityType?: EntityType
|
||||
): SuggestedTemplate[] {
|
||||
const matches = findMatchingTemplates(transaction, entityType)
|
||||
): Promise<SuggestedTemplate[]> {
|
||||
let matches: TemplateMatch[]
|
||||
|
||||
try {
|
||||
matches = await findSimilarTemplates(transaction, entityType)
|
||||
} catch {
|
||||
matches = []
|
||||
}
|
||||
|
||||
// Fall back to keyword matching if embedding search returns nothing
|
||||
if (matches.length === 0) {
|
||||
matches = findMatchingTemplates(transaction, entityType)
|
||||
}
|
||||
|
||||
return matches.map((m: TemplateMatch) => ({
|
||||
template_id: m.template.id,
|
||||
name_sv: m.template.name_sv,
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
-- Migration 040: Booking Template Embeddings
|
||||
-- Stores pre-computed embeddings for booking templates to enable
|
||||
-- semantic similarity search for transaction classification.
|
||||
|
||||
-- ============================================================
|
||||
-- booking_template_embeddings
|
||||
-- ============================================================
|
||||
|
||||
create table public.booking_template_embeddings (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
template_id text unique not null,
|
||||
embedding extensions.vector(1536) not null,
|
||||
embedding_text text not null,
|
||||
model text not null,
|
||||
schema_version text not null,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
alter table public.booking_template_embeddings enable row level security;
|
||||
|
||||
-- Shared system data — any authenticated user can read
|
||||
create policy "booking_template_embeddings_select" on public.booking_template_embeddings
|
||||
for select using (true);
|
||||
|
||||
-- Only service role can insert/update (no user-scoped writes)
|
||||
-- RLS blocks regular users from writing; service role bypasses RLS
|
||||
|
||||
create trigger booking_template_embeddings_updated_at
|
||||
before update on public.booking_template_embeddings
|
||||
for each row execute function public.update_updated_at_column();
|
||||
|
||||
-- HNSW index for fast cosine similarity search
|
||||
create index idx_booking_template_embeddings_embedding
|
||||
on public.booking_template_embeddings
|
||||
using hnsw (embedding extensions.vector_cosine_ops);
|
||||
|
||||
create index idx_booking_template_embeddings_template_id
|
||||
on public.booking_template_embeddings (template_id);
|
||||
|
||||
-- ============================================================
|
||||
-- match_booking_templates RPC (vector similarity search)
|
||||
-- ============================================================
|
||||
|
||||
create or replace function public.match_booking_templates(
|
||||
query_embedding extensions.vector,
|
||||
match_count int default 20,
|
||||
match_threshold float default 0.5
|
||||
)
|
||||
returns table (
|
||||
template_id text,
|
||||
embedding_text text,
|
||||
similarity float
|
||||
)
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path = public, extensions
|
||||
as $$
|
||||
begin
|
||||
return query
|
||||
select
|
||||
bte.template_id,
|
||||
bte.embedding_text,
|
||||
1 - (bte.embedding <=> query_embedding)::float as similarity
|
||||
from public.booking_template_embeddings bte
|
||||
where 1 - (bte.embedding <=> query_embedding) >= match_threshold
|
||||
order by bte.embedding <=> query_embedding
|
||||
limit match_count;
|
||||
end;
|
||||
$$;
|
||||
|
||||
grant execute on function public.match_booking_templates(extensions.vector, int, float) to authenticated;
|
||||
Reference in New Issue
Block a user