diff --git a/app/(dashboard)/deadlines/page.tsx b/app/(dashboard)/deadlines/page.tsx new file mode 100644 index 00000000..1be6fde3 --- /dev/null +++ b/app/(dashboard)/deadlines/page.tsx @@ -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([]) + 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 + ) => { + 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 ( +
+
+

Deadlines

+
+
+
+
+
+
+ ) + } + + return ( +
+
+

Deadlines

+
+ + {overdueInvoices.count > 0 && ( + + + +
+
+ +
+

Forfallna fakturor

+

+ {overdueInvoices.count} st totalt{' '} + {overdueInvoices.total.toLocaleString('sv-SE')} kr +

+
+
+
+ {overdueInvoices.count} + +
+
+
+
+ + )} + + +
+ ) +} diff --git a/app/(dashboard)/page.tsx b/app/(dashboard)/page.tsx index 2c8c6102..52f46137 100644 --- a/app/(dashboard)/page.tsx +++ b/app/(dashboard)/page.tsx @@ -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 ( ) } diff --git a/app/api/admin/seed-template-embeddings/route.ts b/app/api/admin/seed-template-embeddings/route.ts new file mode 100644 index 00000000..31ba15ef --- /dev/null +++ b/app/api/admin/seed-template-embeddings/route.ts @@ -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 } + ) + } +} diff --git a/app/api/transactions/suggest-categories/route.ts b/app/api/transactions/suggest-categories/route.ts index 5f2e219a..e3aeaec8 100644 --- a/app/api/transactions/suggest-categories/route.ts +++ b/app/api/transactions/suggest-categories/route.ts @@ -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 }) diff --git a/app/page.tsx b/app/page.tsx index 80b5e80d..1cf62e08 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -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 (
@@ -220,6 +227,7 @@ export default async function RootPage() { receiptQueue, missingUnderlagCount, }} + enabledExtensions={enabledToggles || []} />
diff --git a/components/calendar/index.ts b/components/calendar/index.ts deleted file mode 100644 index 6241de42..00000000 --- a/components/calendar/index.ts +++ /dev/null @@ -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' diff --git a/components/dashboard/DashboardContent.tsx b/components/dashboard/DashboardContent.tsx index 5d591832..3249ecdc 100644 --- a/components/dashboard/DashboardContent.tsx +++ b/components/dashboard/DashboardContent.tsx @@ -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 ) })} - {/* AI assistant quick action */} - + {/* Extension quick actions */} + {extensionQuickActions.map((action) => { + const Icon = resolveIcon(action.icon) + if (action.href) { + return ( + +
+
+ +
+
+

{action.label}

+

{action.description}

+
+
+ + ) + } + return ( + + ) + })}
@@ -453,6 +508,13 @@ export default function DashboardContent({ firstName, settings, summary, onboard )} + {/* Tax todo widget — visible when there are incomplete tax deadlines */} + {summary.deadlines?.some(d => d.deadline_type === 'tax' && !d.is_completed) && ( +
+ +
+ )} + {/* Alerts section — always visible */} {alertItems.length > 0 && (
diff --git a/components/dashboard/DashboardNav.tsx b/components/dashboard/DashboardNav.tsx index f244cbf2..e29394fa 100644 --- a/components/dashboard/DashboardNav.tsx +++ b/components/dashboard/DashboardNav.tsx @@ -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' }, diff --git a/components/calendar/DeadlineCard.tsx b/components/deadlines/DeadlineCard.tsx similarity index 100% rename from components/calendar/DeadlineCard.tsx rename to components/deadlines/DeadlineCard.tsx diff --git a/components/calendar/DeadlineFilters.tsx b/components/deadlines/DeadlineFilters.tsx similarity index 100% rename from components/calendar/DeadlineFilters.tsx rename to components/deadlines/DeadlineFilters.tsx diff --git a/components/calendar/DeadlineForm.tsx b/components/deadlines/DeadlineForm.tsx similarity index 100% rename from components/calendar/DeadlineForm.tsx rename to components/deadlines/DeadlineForm.tsx diff --git a/components/calendar/DeadlineList.tsx b/components/deadlines/DeadlineList.tsx similarity index 100% rename from components/calendar/DeadlineList.tsx rename to components/deadlines/DeadlineList.tsx diff --git a/components/calendar/TaxTodoWidget.tsx b/components/deadlines/TaxTodoWidget.tsx similarity index 98% rename from components/calendar/TaxTodoWidget.tsx rename to components/deadlines/TaxTodoWidget.tsx index 8295d3b6..8aa96d44 100644 --- a/components/calendar/TaxTodoWidget.tsx +++ b/components/deadlines/TaxTodoWidget.tsx @@ -231,9 +231,9 @@ export function TaxTodoWidget({ deadlines, onStatusChange }: TaxTodoWidgetProps)

)} - + diff --git a/components/calendar/UpcomingDeadlinesWidget.tsx b/components/deadlines/UpcomingDeadlinesWidget.tsx similarity index 98% rename from components/calendar/UpcomingDeadlinesWidget.tsx rename to components/deadlines/UpcomingDeadlinesWidget.tsx index 2f347de2..5cd60601 100644 --- a/components/calendar/UpcomingDeadlinesWidget.tsx +++ b/components/deadlines/UpcomingDeadlinesWidget.tsx @@ -201,9 +201,9 @@ export function UpcomingDeadlinesWidget({ deadlines, maxItems = 5, onStatusChang ) })} - + diff --git a/components/deadlines/index.ts b/components/deadlines/index.ts new file mode 100644 index 00000000..3118eff9 --- /dev/null +++ b/components/deadlines/index.ts @@ -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' diff --git a/app/(dashboard)/calendar/page.tsx b/components/extensions/general/CalendarWorkspace.tsx similarity index 74% rename from app/(dashboard)/calendar/page.tsx rename to components/extensions/general/CalendarWorkspace.tsx index 8759de79..2dc563ba 100644 --- a/app/(dashboard)/calendar/page.tsx +++ b/components/extensions/general/CalendarWorkspace.tsx @@ -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([]) const [deadlines, setDeadlines] = useState([]) 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 ( -
-
-

Kalender

-
-
-
-
-
+
+
+
) } return ( -
-
-

Kalender

-
- - -
+ ) } diff --git a/extensions/general/ai-categorization/categorizer.ts b/extensions/general/ai-categorization/categorizer.ts index 9e968dd5..982a15f6 100644 --- a/extensions/general/ai-categorization/categorizer.ts +++ b/extensions/general/ai-categorization/categorizer.ts @@ -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 } @@ -76,11 +96,18 @@ function getCategoryAccountMap(entityType: EntityType): Record 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 { // 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 { return new Promise((resolve) => setTimeout(resolve, ms)) } diff --git a/extensions/general/ai-categorization/index.ts b/extensions/general/ai-categorization/index.ts index d4e5b9d9..c11c1c55 100644 --- a/extensions/general/ai-categorization/index.ts +++ b/extensions/general/ai-categorization/index.ts @@ -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 { +async function buildEnrichedContext( + userId: string, + supabase: any, + transactions: TransactionForCategorization[] +): Promise { // Fetch entity type const { data: companySettings } = await supabase .from('company_settings') @@ -221,7 +229,92 @@ async function buildContext(userId: string, supabase: any): Promise() + 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() + 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() + + 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( diff --git a/components/calendar/CalendarDayCell.tsx b/extensions/general/calendar/components/CalendarDayCell.tsx similarity index 100% rename from components/calendar/CalendarDayCell.tsx rename to extensions/general/calendar/components/CalendarDayCell.tsx diff --git a/components/calendar/CalendarDayView.tsx b/extensions/general/calendar/components/CalendarDayView.tsx similarity index 100% rename from components/calendar/CalendarDayView.tsx rename to extensions/general/calendar/components/CalendarDayView.tsx diff --git a/components/calendar/CalendarGrid.tsx b/extensions/general/calendar/components/CalendarGrid.tsx similarity index 100% rename from components/calendar/CalendarGrid.tsx rename to extensions/general/calendar/components/CalendarGrid.tsx diff --git a/components/calendar/CalendarHeader.tsx b/extensions/general/calendar/components/CalendarHeader.tsx similarity index 100% rename from components/calendar/CalendarHeader.tsx rename to extensions/general/calendar/components/CalendarHeader.tsx diff --git a/components/calendar/CalendarWeekView.tsx b/extensions/general/calendar/components/CalendarWeekView.tsx similarity index 100% rename from components/calendar/CalendarWeekView.tsx rename to extensions/general/calendar/components/CalendarWeekView.tsx diff --git a/components/calendar/DayDetailModal.tsx b/extensions/general/calendar/components/DayDetailModal.tsx similarity index 100% rename from components/calendar/DayDetailModal.tsx rename to extensions/general/calendar/components/DayDetailModal.tsx diff --git a/components/calendar/PaymentCalendar.tsx b/extensions/general/calendar/components/PaymentCalendar.tsx similarity index 98% rename from components/calendar/PaymentCalendar.tsx rename to extensions/general/calendar/components/PaymentCalendar.tsx index f6d2bcbb..9f7eff27 100644 --- a/components/calendar/PaymentCalendar.tsx +++ b/extensions/general/calendar/components/PaymentCalendar.tsx @@ -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[] diff --git a/components/calendar/PaymentSummaryCard.tsx b/extensions/general/calendar/components/PaymentSummaryCard.tsx similarity index 100% rename from components/calendar/PaymentSummaryCard.tsx rename to extensions/general/calendar/components/PaymentSummaryCard.tsx diff --git a/components/calendar/ViewModeSelector.tsx b/extensions/general/calendar/components/ViewModeSelector.tsx similarity index 100% rename from components/calendar/ViewModeSelector.tsx rename to extensions/general/calendar/components/ViewModeSelector.tsx diff --git a/extensions/general/calendar/index.ts b/extensions/general/calendar/index.ts new file mode 100644 index 00000000..7c43aefc --- /dev/null +++ b/extensions/general/calendar/index.ts @@ -0,0 +1,7 @@ +import type { Extension } from '@/lib/extensions/types' + +export const calendarExtension: Extension = { + id: 'calendar', + name: 'Kalender', + version: '1.0.0', +} diff --git a/extensions/general/push-notifications/payload-builders.ts b/extensions/general/push-notifications/payload-builders.ts index a697ec3b..a6ebd820 100644 --- a/extensions/general/push-notifications/payload-builders.ts +++ b/extensions/general/push-notifications/payload-builders.ts @@ -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, }, diff --git a/lib/bookkeeping/__tests__/booking-templates.test.ts b/lib/bookkeeping/__tests__/booking-templates.test.ts index 4e6e10a0..dfd9da1b 100644 --- a/lib/bookkeeping/__tests__/booking-templates.test.ts +++ b/lib/bookkeeping/__tests__/booking-templates.test.ts @@ -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', () => { diff --git a/lib/bookkeeping/__tests__/template-embeddings.test.ts b/lib/bookkeeping/__tests__/template-embeddings.test.ts new file mode 100644 index 00000000..61cf9029 --- /dev/null +++ b/lib/bookkeeping/__tests__/template-embeddings.test.ts @@ -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([]) + }) + }) +}) diff --git a/lib/bookkeeping/booking-templates.ts b/lib/bookkeeping/booking-templates.ts index 419b8124..8600d5ae 100644 --- a/lib/bookkeeping/booking-templates.ts +++ b/lib/bookkeeping/booking-templates.ts @@ -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) } /** diff --git a/lib/bookkeeping/template-embeddings.ts b/lib/bookkeeping/template-embeddings.ts new file mode 100644 index 00000000..819bff02 --- /dev/null +++ b/lib/bookkeeping/template-embeddings.ts @@ -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 { + 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 [] + } +} diff --git a/lib/extensions/__tests__/sectors.test.ts b/lib/extensions/__tests__/sectors.test.ts index e4caa0b0..dab1285a 100644 --- a/lib/extensions/__tests__/sectors.test.ts +++ b/lib/extensions/__tests__/sectors.test.ts @@ -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', () => { diff --git a/lib/extensions/icon-resolver.tsx b/lib/extensions/icon-resolver.tsx index 00f25858..911ca248 100644 --- a/lib/extensions/icon-resolver.tsx +++ b/lib/extensions/icon-resolver.tsx @@ -3,6 +3,7 @@ import { Sparkles, MessageSquare, Bell, + Inbox, Landmark, UtensilsCrossed, ChefHat, @@ -31,6 +32,7 @@ const ICON_MAP: Record = { Sparkles, MessageSquare, Bell, + Inbox, Landmark, UtensilsCrossed, ChefHat, diff --git a/lib/extensions/loader.ts b/lib/extensions/loader.ts index b1dd0054..bce6ad2d 100644 --- a/lib/extensions/loader.ts +++ b/lib/extensions/loader.ts @@ -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 ] diff --git a/lib/extensions/sectors.ts b/lib/extensions/sectors.ts index 0c7c22ec..e9e77a84 100644 --- a/lib/extensions/sectors.ts +++ b/lib/extensions/sectors.ts @@ -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)', diff --git a/lib/extensions/types.ts b/lib/extensions/types.ts index b74cacf8..fe70d047 100644 --- a/lib/extensions/types.ts +++ b/lib/extensions/types.ts @@ -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 */ diff --git a/lib/extensions/workspace-registry.tsx b/lib/extensions/workspace-registry.tsx index a7b5863a..6720c5b8 100644 --- a/lib/extensions/workspace-registry.tsx +++ b/lib/extensions/workspace-registry.tsx @@ -14,6 +14,7 @@ const WORKSPACES: Record> = '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')), diff --git a/lib/transactions/category-suggestions.ts b/lib/transactions/category-suggestions.ts index 02b63e0f..f13dc032 100644 --- a/lib/transactions/category-suggestions.ts +++ b/lib/transactions/category-suggestions.ts @@ -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 { + 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, diff --git a/supabase/migrations/20240101000040_booking_template_embeddings.sql b/supabase/migrations/20240101000040_booking_template_embeddings.sql new file mode 100644 index 00000000..d59ca600 --- /dev/null +++ b/supabase/migrations/20240101000040_booking_template_embeddings.sql @@ -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;