New classification logic etc

This commit is contained in:
Jakob Wennberg
2026-02-24 11:33:22 +01:00
parent a3903f8428
commit 6cef4e11eb
41 changed files with 1193 additions and 134 deletions
+241
View File
@@ -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>
)
}
+8
View File
@@ -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 })
+8
View File
@@ -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 />
-12
View File
@@ -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'
+79 -17
View File
@@ -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">
+1 -1
View File
@@ -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>
@@ -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>
+6
View File
@@ -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'
@@ -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))
}
+98 -5
View File
@@ -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(
@@ -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[]
+7
View File
@@ -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([])
})
})
})
+3 -3
View File
@@ -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)
}
/**
+255
View File
@@ -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 []
}
}
+1 -1
View File
@@ -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', () => {
+2
View File
@@ -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,
+2
View File
@@ -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
]
+18
View File
@@ -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)',
+11
View File
@@ -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 */
+1
View File
@@ -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')),
+17 -4
View File
@@ -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;