From 6cef4e11ebefa5b03df30c94e2fcc6be55b6c460 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Tue, 24 Feb 2026 11:33:22 +0100 Subject: [PATCH 1/2] New classification logic etc --- app/(dashboard)/deadlines/page.tsx | 241 +++++++++++++++++ app/(dashboard)/page.tsx | 8 + .../admin/seed-template-embeddings/route.ts | 27 ++ .../transactions/suggest-categories/route.ts | 2 +- app/page.tsx | 8 + components/calendar/index.ts | 12 - components/dashboard/DashboardContent.tsx | 96 +++++-- components/dashboard/DashboardNav.tsx | 2 +- .../{calendar => deadlines}/DeadlineCard.tsx | 0 .../DeadlineFilters.tsx | 0 .../{calendar => deadlines}/DeadlineForm.tsx | 0 .../{calendar => deadlines}/DeadlineList.tsx | 0 .../{calendar => deadlines}/TaxTodoWidget.tsx | 4 +- .../UpcomingDeadlinesWidget.tsx | 4 +- components/deadlines/index.ts | 6 + .../extensions/general/CalendarWorkspace.tsx | 45 ++-- .../general/ai-categorization/categorizer.ts | 167 ++++++++---- extensions/general/ai-categorization/index.ts | 103 ++++++- .../calendar/components}/CalendarDayCell.tsx | 0 .../calendar/components}/CalendarDayView.tsx | 0 .../calendar/components}/CalendarGrid.tsx | 0 .../calendar/components}/CalendarHeader.tsx | 0 .../calendar/components}/CalendarWeekView.tsx | 0 .../calendar/components}/DayDetailModal.tsx | 0 .../calendar/components}/PaymentCalendar.tsx | 2 +- .../components}/PaymentSummaryCard.tsx | 0 .../calendar/components}/ViewModeSelector.tsx | 0 extensions/general/calendar/index.ts | 7 + .../push-notifications/payload-builders.ts | 2 +- .../__tests__/booking-templates.test.ts | 2 +- .../__tests__/template-embeddings.test.ts | 199 ++++++++++++++ lib/bookkeeping/booking-templates.ts | 6 +- lib/bookkeeping/template-embeddings.ts | 255 ++++++++++++++++++ lib/extensions/__tests__/sectors.test.ts | 2 +- lib/extensions/icon-resolver.tsx | 2 + lib/extensions/loader.ts | 2 + lib/extensions/sectors.ts | 18 ++ lib/extensions/types.ts | 11 + lib/extensions/workspace-registry.tsx | 1 + lib/transactions/category-suggestions.ts | 21 +- ...0101000040_booking_template_embeddings.sql | 72 +++++ 41 files changed, 1193 insertions(+), 134 deletions(-) create mode 100644 app/(dashboard)/deadlines/page.tsx create mode 100644 app/api/admin/seed-template-embeddings/route.ts delete mode 100644 components/calendar/index.ts rename components/{calendar => deadlines}/DeadlineCard.tsx (100%) rename components/{calendar => deadlines}/DeadlineFilters.tsx (100%) rename components/{calendar => deadlines}/DeadlineForm.tsx (100%) rename components/{calendar => deadlines}/DeadlineList.tsx (100%) rename components/{calendar => deadlines}/TaxTodoWidget.tsx (98%) rename components/{calendar => deadlines}/UpcomingDeadlinesWidget.tsx (98%) create mode 100644 components/deadlines/index.ts rename app/(dashboard)/calendar/page.tsx => components/extensions/general/CalendarWorkspace.tsx (74%) rename {components/calendar => extensions/general/calendar/components}/CalendarDayCell.tsx (100%) rename {components/calendar => extensions/general/calendar/components}/CalendarDayView.tsx (100%) rename {components/calendar => extensions/general/calendar/components}/CalendarGrid.tsx (100%) rename {components/calendar => extensions/general/calendar/components}/CalendarHeader.tsx (100%) rename {components/calendar => extensions/general/calendar/components}/CalendarWeekView.tsx (100%) rename {components/calendar => extensions/general/calendar/components}/DayDetailModal.tsx (100%) rename {components/calendar => extensions/general/calendar/components}/PaymentCalendar.tsx (98%) rename {components/calendar => extensions/general/calendar/components}/PaymentSummaryCard.tsx (100%) rename {components/calendar => extensions/general/calendar/components}/ViewModeSelector.tsx (100%) create mode 100644 extensions/general/calendar/index.ts create mode 100644 lib/bookkeeping/__tests__/template-embeddings.test.ts create mode 100644 lib/bookkeeping/template-embeddings.ts create mode 100644 supabase/migrations/20240101000040_booking_template_embeddings.sql 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; From 3e7fa45ed69b6668a9ecc63bba21af016fe382cc Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Tue, 24 Feb 2026 16:02:06 +0100 Subject: [PATCH 2/2] feat: transaction categorization UX improvements and description matching Add journal entry preview, human-readable account names, auto-apply VAT, fallback template suggestions, example prompts, invoice match comparison, and batch result feedback. Also includes user-description-match extension, describe/batch-describe API routes, improved AI categorization with multi- suggestion support, and template embedding search. Co-Authored-By: Claude Opus 4.6 --- app/(dashboard)/transactions/page.tsx | 69 ++- .../[id]/send/__tests__/route.test.ts | 2 + app/api/invoices/__tests__/route.test.ts | 4 + .../[id]/categorize/__tests__/route.test.ts | 4 +- app/api/transactions/[id]/categorize/route.ts | 4 +- .../[id]/describe/__tests__/route.test.ts | 200 +++++++ app/api/transactions/[id]/describe/route.ts | 98 ++++ .../batch-describe/__tests__/route.test.ts | 212 +++++++ app/api/transactions/batch-describe/route.ts | 171 ++++++ .../transactions/suggest-categories/route.ts | 100 +++- components/bookkeeping/JournalEntryList.tsx | 6 +- .../general/UserDescriptionMatchWorkspace.tsx | 15 + .../DescribeTransactionDialog.tsx | 539 ++++++++++++++++++ .../transactions/InvoiceMatchDialog.tsx | 32 ++ .../transactions/JournalEntryPreview.tsx | 114 ++++ components/transactions/QuickReviewDialog.tsx | 40 +- .../transactions/SwipeCategorizationView.tsx | 104 +++- .../transactions/TransactionInboxCard.tsx | 54 +- components/transactions/transaction-types.ts | 4 + .../general/ai-categorization/categorizer.ts | 70 ++- extensions/general/ai-categorization/index.ts | 18 +- .../receipt-ocr/lib/receipt-categorizer.ts | 8 + .../general/user-description-match/index.ts | 142 +++++ lib/api/schemas.ts | 16 + .../__tests__/mapping-engine.test.ts | 141 +++++ .../__tests__/template-embeddings.test.ts | 34 ++ lib/bookkeeping/category-mapping.ts | 16 + lib/bookkeeping/client-account-names.ts | 70 +++ lib/bookkeeping/mapping-engine.ts | 77 ++- lib/bookkeeping/template-embeddings.ts | 16 +- lib/extensions/__tests__/sectors.test.ts | 4 +- lib/extensions/icon-resolver.tsx | 2 + lib/extensions/loader.ts | 2 + lib/extensions/sectors.ts | 12 + lib/extensions/workspace-registry.tsx | 1 + lib/reports/__tests__/sie-export.test.ts | 2 +- lib/transactions/category-suggestions.ts | 48 +- ...240101000041_user_description_matching.sql | 23 + tsconfig.json | 3 +- types/index.ts | 8 + 40 files changed, 2401 insertions(+), 84 deletions(-) create mode 100644 app/api/transactions/[id]/describe/__tests__/route.test.ts create mode 100644 app/api/transactions/[id]/describe/route.ts create mode 100644 app/api/transactions/batch-describe/__tests__/route.test.ts create mode 100644 app/api/transactions/batch-describe/route.ts create mode 100644 components/extensions/general/UserDescriptionMatchWorkspace.tsx create mode 100644 components/transactions/DescribeTransactionDialog.tsx create mode 100644 components/transactions/JournalEntryPreview.tsx create mode 100644 extensions/general/user-description-match/index.ts create mode 100644 lib/bookkeeping/__tests__/mapping-engine.test.ts create mode 100644 lib/bookkeeping/client-account-names.ts create mode 100644 supabase/migrations/20240101000041_user_description_matching.sql diff --git a/app/(dashboard)/transactions/page.tsx b/app/(dashboard)/transactions/page.tsx index 5bba42f0..f41ff124 100644 --- a/app/(dashboard)/transactions/page.tsx +++ b/app/(dashboard)/transactions/page.tsx @@ -19,11 +19,12 @@ import InboxZeroState from '@/components/transactions/InboxZeroState' import InvoiceMatchDialog from '@/components/transactions/InvoiceMatchDialog' import TransactionBookingDialog from '@/components/transactions/TransactionBookingDialog' import QuickReviewDialog from '@/components/transactions/QuickReviewDialog' +import DescribeTransactionDialog from '@/components/transactions/DescribeTransactionDialog' import { EXPENSE_CATEGORIES, INCOME_CATEGORIES } from '@/components/transactions/transaction-types' import { getDefaultAccountForCategory, getDefaultVatTreatmentForCategory } from '@/lib/bookkeeping/category-mapping' import type { TransactionWithInvoice, ViewMode, CategorizeHandler } from '@/components/transactions/transaction-types' import type { TransactionCategory, CreateTransactionInput, Invoice, Customer, VatTreatment } from '@/types' -import type { SuggestedCategory } from '@/lib/transactions/category-suggestions' +import type { SuggestedCategory, SuggestedTemplate } from '@/lib/transactions/category-suggestions' export default function TransactionsPage() { const [transactions, setTransactions] = useState([]) @@ -33,6 +34,7 @@ export default function TransactionsPage() { const [isCreating, setIsCreating] = useState(false) const [showSwipeView, setShowSwipeView] = useState(false) const [categorySuggestions, setCategorySuggestions] = useState>({}) + const [templateSuggestions, setTemplateSuggestions] = useState>({}) const [isLoadingSuggestions, setIsLoadingSuggestions] = useState(false) const [processingId, setProcessingId] = useState(null) @@ -57,6 +59,10 @@ export default function TransactionsPage() { const [quickReviewCategory, setQuickReviewCategory] = useState(null) const [quickReviewLabel, setQuickReviewLabel] = useState('') + // Describe dialog + const [describeDialogOpen, setDescribeDialogOpen] = useState(false) + const [describeDialogTransaction, setDescribeDialogTransaction] = useState(null) + // Entity type for tooltip context const [entityType, setEntityType] = useState('enskild_firma') @@ -130,6 +136,9 @@ export default function TransactionsPage() { if (data.suggestions) { setCategorySuggestions(data.suggestions) } + if (data.template_suggestions) { + setTemplateSuggestions(data.template_suggestions) + } } catch { // Non-critical } @@ -409,13 +418,29 @@ export default function TransactionsPage() { async function handleBatchCategorize(category: TransactionCategory, vatTreatment?: VatTreatment) { const ids = Array.from(selectedIds) setBatchProgress({ done: 0, total: ids.length }) + let successes = 0 + const failures: string[] = [] for (let i = 0; i < ids.length; i++) { - await handleCategorize(ids[i], true, category, vatTreatment) + const result = await handleCategorize(ids[i], true, category, vatTreatment) + if (result) { + successes++ + } else { + const tx = transactions.find((t) => t.id === ids[i]) + failures.push(tx?.description || ids[i]) + } setBatchProgress({ done: i + 1, total: ids.length }) } setBatchProgress(null) setShowBatchSelector(false) - toast({ title: 'Klart', description: `${ids.length} transaktioner bokförda` }) + if (failures.length === 0) { + toast({ title: 'Klart', description: `${successes} transaktioner bokförda` }) + } else { + toast({ + title: 'Delvis klart', + description: `${successes} lyckades, ${failures.length} misslyckades: ${failures.slice(0, 3).join(', ')}${failures.length > 3 ? '...' : ''}`, + variant: 'destructive', + }) + } exitBatchMode() } @@ -468,12 +493,40 @@ export default function TransactionsPage() { return journalEntryId } + function openDescribeDialog(transaction: TransactionWithInvoice) { + setDescribeDialogTransaction(transaction) + setDescribeDialogOpen(true) + } + + function handleDescribeCategorized(transactionId: string, journalEntryId: string | null) { + setExitingIds((prev) => new Set(prev).add(transactionId)) + setTimeout(() => { + setTransactions((prev) => + prev.map((t) => + t.id === transactionId + ? { ...t, is_business: true, journal_entry_id: journalEntryId } + : t + ) + ) + setExitingIds((prev) => { + const next = new Set(prev) + next.delete(transactionId) + return next + }) + }, 350) + } + + function handleBatchApplied() { + fetchTransactions() + } + // Swipe view if (showSwipeView && uncategorizedTransactions.length > 0) { return ( setShowSwipeView(false)} @@ -527,6 +580,7 @@ export default function TransactionsPage() { key={transaction.id} transaction={transaction} suggestions={categorySuggestions[transaction.id]} + templateSuggestions={templateSuggestions[transaction.id]} processingId={processingId} isBatchMode={isBatchMode} isSelected={selectedIds.has(transaction.id)} @@ -535,6 +589,7 @@ export default function TransactionsPage() { onMarkPrivate={handleMarkPrivate} onOpenMatchDialog={openMatchDialog} onOpenCategoryDialog={openCategoryDialog} + onOpenDescribe={openDescribeDialog} onOpenQuickReview={handleOpenQuickReview} onToggleSelect={toggleBatchSelect} /> @@ -603,6 +658,14 @@ export default function TransactionsPage() { onConfirm={handleQuickReviewConfirm} /> + + diff --git a/app/api/invoices/[id]/send/__tests__/route.test.ts b/app/api/invoices/[id]/send/__tests__/route.test.ts index 3a8e0e2b..cb836f2b 100644 --- a/app/api/invoices/[id]/send/__tests__/route.test.ts +++ b/app/api/invoices/[id]/send/__tests__/route.test.ts @@ -72,6 +72,8 @@ describe('POST /api/invoices/[id]/send', () => { unit: 'tim', unit_price: 1000, line_total: 10000, + vat_rate: 25, + vat_amount: 2500, created_at: '2024-06-15T14:30:00Z', }, ], diff --git a/app/api/invoices/__tests__/route.test.ts b/app/api/invoices/__tests__/route.test.ts index d82bcf18..d7e68c95 100644 --- a/app/api/invoices/__tests__/route.test.ts +++ b/app/api/invoices/__tests__/route.test.ts @@ -318,6 +318,8 @@ describe('POST /api/invoices (create credit note)', () => { unit: 'tim', unit_price: 1000, line_total: 10000, + vat_rate: 25, + vat_amount: 2500, created_at: '2024-06-15T14:30:00Z', }, ] @@ -385,6 +387,8 @@ describe('POST /api/invoices (create credit note)', () => { unit: 'st', unit_price: 1000, line_total: 1000, + vat_rate: 25, + vat_amount: 250, created_at: '2024-06-15T14:30:00Z', }, ], diff --git a/app/api/transactions/[id]/categorize/__tests__/route.test.ts b/app/api/transactions/[id]/categorize/__tests__/route.test.ts index a4eff82b..ff8da182 100644 --- a/app/api/transactions/[id]/categorize/__tests__/route.test.ts +++ b/app/api/transactions/[id]/categorize/__tests__/route.test.ts @@ -160,7 +160,9 @@ describe('POST /api/transactions/[id]/categorize', () => { 'GitHub', '6200', '1930', - false + false, + undefined, + undefined ) expect(emitSpy).toHaveBeenCalledWith( expect.objectContaining({ type: 'transaction.categorized' }) diff --git a/app/api/transactions/[id]/categorize/route.ts b/app/api/transactions/[id]/categorize/route.ts index 128151dc..f193c7bd 100644 --- a/app/api/transactions/[id]/categorize/route.ts +++ b/app/api/transactions/[id]/categorize/route.ts @@ -256,7 +256,9 @@ export async function POST( transaction.merchant_name, mappingResult.debit_account, mappingResult.credit_account, - !is_business + !is_business, + body.user_description, + body.template_id ) } catch (err) { console.error('Failed to save mapping rule:', err) diff --git a/app/api/transactions/[id]/describe/__tests__/route.test.ts b/app/api/transactions/[id]/describe/__tests__/route.test.ts new file mode 100644 index 00000000..f492c80f --- /dev/null +++ b/app/api/transactions/[id]/describe/__tests__/route.test.ts @@ -0,0 +1,200 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { + createMockRequest, + createMockRouteParams, + createQueuedMockSupabase, + makeTransaction, + parseJsonResponse, +} from '@/tests/helpers' + +// Mock init +vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() })) + +// Mock template embeddings +const mockFindSimilarTemplates = vi.fn().mockResolvedValue([]) +vi.mock('@/lib/bookkeeping/template-embeddings', () => ({ + findSimilarTemplates: (...args: unknown[]) => mockFindSimilarTemplates(...args), +})) + +// Mock Supabase +const mockCreateClient = vi.fn() +vi.mock('@/lib/supabase/server', () => ({ + createClient: (...args: unknown[]) => mockCreateClient(...args), +})) + +describe('POST /api/transactions/[id]/describe', () => { + let POST: typeof import('../route').POST + + beforeEach(async () => { + vi.clearAllMocks() + const mod = await import('../route') + POST = mod.POST + }) + + it('returns 401 when not authenticated', async () => { + const { supabase } = createQueuedMockSupabase() + supabase.auth.getUser.mockResolvedValue({ + data: { user: null }, + error: { message: 'Not authenticated' }, + }) + mockCreateClient.mockResolvedValue(supabase) + + const req = createMockRequest('/api/transactions/test-id/describe', { + method: 'POST', + body: { description: 'business lunch' }, + }) + + const res = await POST(req, createMockRouteParams({ id: 'test-id' })) + const { status, body } = await parseJsonResponse(res) + + expect(status).toBe(401) + expect(body).toHaveProperty('error', 'Unauthorized') + }) + + it('returns 400 for invalid body (description too short)', async () => { + const { supabase } = createQueuedMockSupabase() + supabase.auth.getUser.mockResolvedValue({ + data: { user: { id: 'user-1' } }, + error: null, + }) + mockCreateClient.mockResolvedValue(supabase) + + const req = createMockRequest('/api/transactions/test-id/describe', { + method: 'POST', + body: { description: 'ab' }, + }) + + const res = await POST(req, createMockRouteParams({ id: 'test-id' })) + const { status } = await parseJsonResponse(res) + + expect(status).toBe(400) + }) + + it('returns 404 when transaction not found', async () => { + const { supabase, enqueueMany } = createQueuedMockSupabase() + supabase.auth.getUser.mockResolvedValue({ + data: { user: { id: 'user-1' } }, + error: null, + }) + enqueueMany([ + // transaction fetch + { data: null, error: { message: 'Not found' } }, + ]) + mockCreateClient.mockResolvedValue(supabase) + + const req = createMockRequest('/api/transactions/nonexistent/describe', { + method: 'POST', + body: { description: 'business lunch' }, + }) + + const res = await POST(req, createMockRouteParams({ id: 'nonexistent' })) + const { status, body } = await parseJsonResponse(res) + + expect(status).toBe(404) + expect(body).toHaveProperty('error', 'Transaction not found') + }) + + it('returns template candidates on happy path', async () => { + const tx = makeTransaction({ + id: 'tx-1', + merchant_name: 'Restaurant XYZ', + amount: -450, + }) + + mockFindSimilarTemplates.mockResolvedValueOnce([ + { + template: { + id: 'restaurant_dining', + name_sv: 'Restaurangbesök', + name_en: 'Restaurant dining', + group: 'representation', + debit_account: '6071', + credit_account: '1930', + description_sv: 'Representation - restaurang', + }, + confidence: 0.82, + }, + ]) + + const { supabase, enqueueMany } = createQueuedMockSupabase() + supabase.auth.getUser.mockResolvedValue({ + data: { user: { id: 'user-1' } }, + error: null, + }) + enqueueMany([ + // transaction fetch + { data: tx, error: null }, + // company_settings + { data: { entity_type: 'enskild_firma' }, error: null }, + // batch candidate count + { data: null, error: null, count: 3 }, + ]) + mockCreateClient.mockResolvedValue(supabase) + + const req = createMockRequest('/api/transactions/tx-1/describe', { + method: 'POST', + body: { description: 'business lunch with client' }, + }) + + const res = await POST(req, createMockRouteParams({ id: 'tx-1' })) + const { status, body } = await parseJsonResponse<{ data: Record }>(res) + + expect(status).toBe(200) + expect(body.data.templates).toHaveLength(1) + expect(body.data.needs_more_detail).toBe(false) + expect(body.data.user_description).toBe('business lunch with client') + expect(body.data.batch_candidate_count).toBe(3) + expect(body.data.merchant_name).toBe('Restaurant XYZ') + + // Verify findSimilarTemplates was called with the user description + expect(mockFindSimilarTemplates).toHaveBeenCalledWith( + expect.objectContaining({ id: 'tx-1' }), + 'enskild_firma', + 10, + 'business lunch with client' + ) + }) + + it('sets needs_more_detail when confidence is low', async () => { + const tx = makeTransaction({ id: 'tx-2', merchant_name: null }) + + mockFindSimilarTemplates.mockResolvedValueOnce([ + { + template: { + id: 'misc', + name_sv: 'Diverse', + name_en: 'Miscellaneous', + group: 'other', + debit_account: '6991', + credit_account: '1930', + description_sv: 'Okategoriserad utgift', + }, + confidence: 0.4, + }, + ]) + + const { supabase, enqueueMany } = createQueuedMockSupabase() + supabase.auth.getUser.mockResolvedValue({ + data: { user: { id: 'user-1' } }, + error: null, + }) + enqueueMany([ + // transaction fetch + { data: tx, error: null }, + // company_settings + { data: { entity_type: 'enskild_firma' }, error: null }, + ]) + mockCreateClient.mockResolvedValue(supabase) + + const req = createMockRequest('/api/transactions/tx-2/describe', { + method: 'POST', + body: { description: 'some kind of payment' }, + }) + + const res = await POST(req, createMockRouteParams({ id: 'tx-2' })) + const { status, body } = await parseJsonResponse<{ data: Record }>(res) + + expect(status).toBe(200) + expect(body.data.needs_more_detail).toBe(true) + }) +}) diff --git a/app/api/transactions/[id]/describe/route.ts b/app/api/transactions/[id]/describe/route.ts new file mode 100644 index 00000000..1d9d55a5 --- /dev/null +++ b/app/api/transactions/[id]/describe/route.ts @@ -0,0 +1,98 @@ +import { createClient } from '@/lib/supabase/server' +import { NextResponse } from 'next/server' +import { ensureInitialized } from '@/lib/init' +import { validateBody } from '@/lib/api/validate' +import { DescribeTransactionSchema } from '@/lib/api/schemas' +import { findSimilarTemplates } from '@/lib/bookkeeping/template-embeddings' +import type { Transaction, EntityType } from '@/types' + +ensureInitialized() + +export async function POST( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const supabase = await createClient() + const { id } = await params + + const { data: { user } } = await supabase.auth.getUser() + + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const validation = await validateBody(request, DescribeTransactionSchema) + if (!validation.success) return validation.response + const { description } = validation.data + + // Fetch the transaction (validates ownership) + const { data: transaction, error: fetchError } = await supabase + .from('transactions') + .select('*') + .eq('id', id) + .eq('user_id', user.id) + .single() + + if (fetchError || !transaction) { + return NextResponse.json({ error: 'Transaction not found' }, { status: 404 }) + } + + // Fetch entity type + const { data: settings } = await supabase + .from('company_settings') + .select('entity_type') + .eq('user_id', user.id) + .single() + + const entityType: EntityType = (settings?.entity_type as EntityType) || 'enskild_firma' + + // Run embedding search with user description dominating the query + const templates = await findSimilarTemplates( + transaction as Transaction, + entityType, + 10, + description + ) + + // Flag if top confidence is too low + const needsMoreDetail = templates.length === 0 || templates[0].confidence < 0.55 + + // Count uncategorized sibling transactions from same merchant + let batchCandidateCount = 0 + if (transaction.merchant_name) { + const { count } = await supabase + .from('transactions') + .select('id', { count: 'exact', head: true }) + .eq('user_id', user.id) + .eq('merchant_name', transaction.merchant_name) + .is('journal_entry_id', null) + .neq('id', id) + + batchCandidateCount = count || 0 + } + + return NextResponse.json({ + data: { + templates: templates.map((m) => ({ + template_id: m.template.id, + name_sv: m.template.name_sv, + name_en: m.template.name_en, + group: m.template.group, + debit_account: m.template.debit_account, + credit_account: m.template.credit_account, + confidence: m.confidence, + description_sv: m.template.description_sv, + vat_rate: m.template.vat_rate, + vat_treatment: m.template.vat_treatment, + deductibility: m.template.deductibility, + deductibility_note_sv: m.template.deductibility_note_sv || null, + special_rules_sv: m.template.special_rules_sv || null, + risk_level: m.template.risk_level, + })), + needs_more_detail: needsMoreDetail, + user_description: description, + batch_candidate_count: batchCandidateCount, + merchant_name: transaction.merchant_name, + }, + }) +} diff --git a/app/api/transactions/batch-describe/__tests__/route.test.ts b/app/api/transactions/batch-describe/__tests__/route.test.ts new file mode 100644 index 00000000..04346cb1 --- /dev/null +++ b/app/api/transactions/batch-describe/__tests__/route.test.ts @@ -0,0 +1,212 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { + createMockRequest, + createQueuedMockSupabase, + makeTransaction, + parseJsonResponse, +} from '@/tests/helpers' +import { eventBus } from '@/lib/events' + +// Mock init +vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() })) + +// Mock booking templates +vi.mock('@/lib/bookkeeping/booking-templates', () => ({ + getTemplateById: vi.fn((id: string) => { + if (id === 'office_supplies') { + return { + id: 'office_supplies', + name_sv: 'Kontorsmaterial', + name_en: 'Office supplies', + group: 'office', + debit_account: '6110', + credit_account: '1930', + fallback_category: 'expense_office', + default_private: false, + vat_treatment: 'standard_25', + vat_rate: 0.25, + deductibility: 'full', + risk_level: 'LOW', + requires_review: false, + entity_applicability: 'all', + direction: 'expense', + } + } + return null + }), + buildMappingResultFromTemplate: vi.fn(() => ({ + rule: null, + debit_account: '6110', + credit_account: '1930', + risk_level: 'LOW', + confidence: 1.0, + requires_review: false, + default_private: false, + vat_lines: [], + description: 'Kontorsmaterial', + })), +})) + +// Mock transaction entries +const mockCreateTransactionJournalEntry = vi.fn().mockResolvedValue({ id: 'je-1' }) +vi.mock('@/lib/bookkeeping/transaction-entries', () => ({ + createTransactionJournalEntry: (...args: unknown[]) => mockCreateTransactionJournalEntry(...args), +})) + +// Mock mapping engine +const mockSaveUserMappingRule = vi.fn().mockResolvedValue(undefined) +vi.mock('@/lib/bookkeeping/mapping-engine', () => ({ + saveUserMappingRule: (...args: unknown[]) => mockSaveUserMappingRule(...args), +})) + +// Mock Supabase — set up once, re-configure per test via auth mock + queue +const mockCreateClient = vi.fn() +vi.mock('@/lib/supabase/server', () => ({ + createClient: (...args: unknown[]) => mockCreateClient(...args), +})) + +describe('POST /api/transactions/batch-describe', () => { + let POST: typeof import('../route').POST + + beforeEach(async () => { + vi.clearAllMocks() + eventBus.clear() + const mod = await import('../route') + POST = mod.POST + }) + + it('returns 401 when not authenticated', async () => { + const { supabase } = createQueuedMockSupabase() + supabase.auth.getUser.mockResolvedValue({ + data: { user: null }, + error: { message: 'Not authenticated' }, + }) + mockCreateClient.mockResolvedValue(supabase) + + const req = createMockRequest('/api/transactions/batch-describe', { + method: 'POST', + body: { + merchant_name: 'Staples', + template_id: 'office_supplies', + is_business: true, + }, + }) + + const res = await POST(req) + const { status, body } = await parseJsonResponse(res) + + expect(status).toBe(401) + expect(body).toHaveProperty('error', 'Unauthorized') + }) + + it('returns 400 for invalid template_id', async () => { + const { supabase } = createQueuedMockSupabase() + supabase.auth.getUser.mockResolvedValue({ + data: { user: { id: 'user-1' } }, + error: null, + }) + mockCreateClient.mockResolvedValue(supabase) + + const req = createMockRequest('/api/transactions/batch-describe', { + method: 'POST', + body: { + merchant_name: 'Staples', + template_id: 'nonexistent_template', + is_business: true, + }, + }) + + const res = await POST(req) + const { status, body } = await parseJsonResponse(res) + + expect(status).toBe(400) + expect(body).toHaveProperty('error', 'Invalid template_id') + }) + + it('applies template to uncategorized merchant transactions', async () => { + const tx1 = makeTransaction({ id: 'tx-1', merchant_name: 'Staples', amount: -299 }) + const tx2 = makeTransaction({ id: 'tx-2', merchant_name: 'Staples', amount: -150 }) + + const { supabase, enqueueMany } = createQueuedMockSupabase() + supabase.auth.getUser.mockResolvedValue({ + data: { user: { id: 'user-1' } }, + error: null, + }) + enqueueMany([ + // company_settings + { data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null }, + // fetch uncategorized transactions + { data: [tx1, tx2], error: null }, + // fiscal period upsert for tx1 + { data: null, error: null }, + // transaction update for tx1 + { data: null, error: null }, + // fiscal period upsert for tx2 + { data: null, error: null }, + // transaction update for tx2 + { data: null, error: null }, + ]) + mockCreateClient.mockResolvedValue(supabase) + + const req = createMockRequest('/api/transactions/batch-describe', { + method: 'POST', + body: { + merchant_name: 'Staples', + template_id: 'office_supplies', + is_business: true, + user_description: 'office supplies purchase', + }, + }) + + const res = await POST(req) + const { status, body } = await parseJsonResponse<{ data: { applied: number; errors: string[] } }>(res) + + expect(status).toBe(200) + expect(body.data.applied).toBe(2) + expect(body.data.errors).toHaveLength(0) + + // Verify journal entries were created + expect(mockCreateTransactionJournalEntry).toHaveBeenCalledTimes(2) + + // Verify mapping rule was saved with user description + expect(mockSaveUserMappingRule).toHaveBeenCalledWith( + 'user-1', + 'Staples', + '6110', + '1930', + false, + 'office supplies purchase', + 'office_supplies' + ) + }) + + it('returns 0 applied when no uncategorized transactions exist', async () => { + const { supabase, enqueueMany } = createQueuedMockSupabase() + supabase.auth.getUser.mockResolvedValue({ + data: { user: { id: 'user-1' } }, + error: null, + }) + enqueueMany([ + // company_settings + { data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null }, + // fetch uncategorized transactions — empty + { data: [], error: null }, + ]) + mockCreateClient.mockResolvedValue(supabase) + + const req = createMockRequest('/api/transactions/batch-describe', { + method: 'POST', + body: { + merchant_name: 'Unknown Merchant', + template_id: 'office_supplies', + is_business: true, + }, + }) + + const res = await POST(req) + const { status, body } = await parseJsonResponse<{ data: { applied: number } }>(res) + + expect(status).toBe(200) + expect(body.data.applied).toBe(0) + }) +}) diff --git a/app/api/transactions/batch-describe/route.ts b/app/api/transactions/batch-describe/route.ts new file mode 100644 index 00000000..ceaf1d08 --- /dev/null +++ b/app/api/transactions/batch-describe/route.ts @@ -0,0 +1,171 @@ +import { createClient } from '@/lib/supabase/server' +import { NextResponse } from 'next/server' +import { eventBus } from '@/lib/events' +import { ensureInitialized } from '@/lib/init' +import { validateBody } from '@/lib/api/validate' +import { BatchDescribeSchema } from '@/lib/api/schemas' +import { getTemplateById, buildMappingResultFromTemplate } from '@/lib/bookkeeping/booking-templates' +import { createTransactionJournalEntry } from '@/lib/bookkeeping/transaction-entries' +import { saveUserMappingRule } from '@/lib/bookkeeping/mapping-engine' +import type { Transaction, EntityType, TransactionCategory } from '@/types' + +ensureInitialized() + +export async function POST(request: Request) { + const supabase = await createClient() + + const { data: { user } } = await supabase.auth.getUser() + + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const validation = await validateBody(request, BatchDescribeSchema) + if (!validation.success) return validation.response + const { merchant_name, template_id, is_business, user_description } = validation.data + + // Look up the template + const template = getTemplateById(template_id) + if (!template) { + return NextResponse.json({ error: 'Invalid template_id' }, { status: 400 }) + } + + // Fetch entity type and fiscal year start + const { data: settings } = await supabase + .from('company_settings') + .select('entity_type, fiscal_year_start_month') + .eq('user_id', user.id) + .single() + + const entityType: EntityType = (settings?.entity_type as EntityType) || 'enskild_firma' + const fiscalYearStartMonth: number = settings?.fiscal_year_start_month ?? 1 + + // Fetch all uncategorized transactions from the specified merchant (limit 50) + const { data: transactions, error: fetchError } = await supabase + .from('transactions') + .select('*') + .eq('user_id', user.id) + .eq('merchant_name', merchant_name) + .is('journal_entry_id', null) + .order('date', { ascending: true }) + .limit(50) + + if (fetchError || !transactions || transactions.length === 0) { + return NextResponse.json({ + data: { applied: 0, errors: [] }, + }) + } + + const finalCategory: TransactionCategory = is_business + ? template.fallback_category + : 'private' + + let applied = 0 + const errors: string[] = [] + + for (const tx of transactions) { + try { + const mappingResult = buildMappingResultFromTemplate( + template, + tx as Transaction, + entityType + ) + + // Ensure fiscal period exists + const txDate = new Date(tx.date) + const txMonth = txDate.getMonth() + 1 + const txYear = txDate.getFullYear() + + let periodStartYear: number + if (fiscalYearStartMonth === 1) { + periodStartYear = txYear + } else if (txMonth >= fiscalYearStartMonth) { + periodStartYear = txYear + } else { + periodStartYear = txYear - 1 + } + + const startMonth = String(fiscalYearStartMonth).padStart(2, '0') + const periodStart = `${periodStartYear}-${startMonth}-01` + const endYear = fiscalYearStartMonth === 1 ? periodStartYear : periodStartYear + 1 + const endMonth = fiscalYearStartMonth === 1 ? 12 : fiscalYearStartMonth - 1 + const lastDay = new Date(endYear, endMonth, 0).getDate() + const periodEnd = `${endYear}-${String(endMonth).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}` + const periodName = fiscalYearStartMonth === 1 + ? `Räkenskapsår ${periodStartYear}` + : `Räkenskapsår ${periodStartYear}/${endYear}` + + await supabase + .from('fiscal_periods') + .upsert({ + user_id: user.id, + name: periodName, + period_start: periodStart, + period_end: periodEnd, + }, { onConflict: 'user_id,period_start,period_end' }) + + // Create journal entry + let journalEntryId: string | null = null + try { + const journalEntry = await createTransactionJournalEntry( + user.id, + tx as Transaction, + mappingResult + ) + if (journalEntry) { + journalEntryId = journalEntry.id + } + } catch (err) { + console.error(`[batch-describe] Journal entry failed for ${tx.id}:`, err) + } + + // Update the transaction + await supabase + .from('transactions') + .update({ + is_business, + category: finalCategory, + journal_entry_id: journalEntryId, + }) + .eq('id', tx.id) + + await eventBus.emit({ + type: 'transaction.categorized', + payload: { + transaction: tx as Transaction, + account: mappingResult.debit_account, + taxCode: mappingResult.vat_lines[0]?.account_number || '', + userId: user.id, + }, + }) + + applied++ + } catch (err) { + const msg = err instanceof Error ? err.message : 'Unknown error' + errors.push(`${tx.id}: ${msg}`) + } + } + + // Save a mapping rule for future auto-categorization + if (applied > 0) { + try { + const sampleTx = transactions[0] as Transaction + const sampleResult = buildMappingResultFromTemplate(template, sampleTx, entityType) + await saveUserMappingRule( + user.id, + merchant_name, + sampleResult.debit_account, + sampleResult.credit_account, + !is_business, + user_description, + template_id + ) + } catch { + // Non-critical + } + } + + return NextResponse.json({ + data: { applied, errors }, + }) +} diff --git a/app/api/transactions/suggest-categories/route.ts b/app/api/transactions/suggest-categories/route.ts index e3aeaec8..0dc27903 100644 --- a/app/api/transactions/suggest-categories/route.ts +++ b/app/api/transactions/suggest-categories/route.ts @@ -3,6 +3,24 @@ import { NextResponse } from 'next/server' import { getSuggestedCategories, mergeAiSuggestions, getSuggestedTemplates, type SuggestedCategory, type SuggestedTemplate } from '@/lib/transactions/category-suggestions' import type { Transaction, TransactionCategory, EntityType } from '@/types' +// Minimum confidence threshold — below this, suggestions are considered weak +// and we trigger on-demand AI categorization to get better results. +const WEAK_SUGGESTION_THRESHOLD = 0.55 + +/** + * Check if suggestions are "weak" — only history-based fallbacks + * with no strong rule/pattern/AI match. + */ +function hasWeakSuggestions(result: SuggestedCategory[]): boolean { + if (result.length === 0) return true + // Weak if the best suggestion is below threshold + const bestConfidence = Math.max(...result.map((s) => s.confidence)) + if (bestConfidence < WEAK_SUGGESTION_THRESHOLD) return true + // Weak if all suggestions are from history only (no rule/pattern/ai match) + if (result.every((s) => s.source === 'history')) return true + return false +} + /** * POST /api/transactions/suggest-categories * Batch endpoint for getting category suggestions for multiple transactions @@ -70,11 +88,18 @@ export async function POST(request: Request) { .eq('extension_id', 'ai-categorization') .in('key', aiKeys) - const aiSuggestionsMap: Record = {} + type AiSuggestion = { category: string; basAccount: string; confidence: number; reasoning: string } + const aiSuggestionsMap: Record = {} if (aiRecords) { for (const record of aiRecords) { const txId = record.key.replace('suggestion:', '') - aiSuggestionsMap[txId] = record.value as { category: string; basAccount: string; confidence: number; reasoning: string } + const value = record.value + // Handle both single object (old) and array (new) storage formats + if (Array.isArray(value)) { + aiSuggestionsMap[txId] = value as AiSuggestion[] + } else { + aiSuggestionsMap[txId] = [value as AiSuggestion] + } } } @@ -86,9 +111,10 @@ export async function POST(request: Request) { .single() const entityType = (settings?.entity_type as EntityType) || undefined - // Generate suggestions for each transaction + // Generate initial suggestions for each transaction const suggestions: Record = {} const template_suggestions: Record = {} + const needsAiIds: string[] = [] for (const tx of transactions) { let result = getSuggestedCategories( @@ -97,15 +123,75 @@ export async function POST(request: Request) { categoryHistory ) - // Merge AI suggestions if available - const aiSuggestion = aiSuggestionsMap[tx.id] - if (aiSuggestion) { - result = mergeAiSuggestions(result, [aiSuggestion]) + // Merge pre-computed AI suggestions if available + const aiSuggestions = aiSuggestionsMap[tx.id] + if (aiSuggestions && aiSuggestions.length > 0) { + result = mergeAiSuggestions(result, aiSuggestions, tx.amount) + } else if (hasWeakSuggestions(result)) { + // No pre-computed AI suggestion AND rule-based suggestions are weak — + // mark this transaction for on-demand AI categorization + needsAiIds.push(tx.id) } suggestions[tx.id] = result template_suggestions[tx.id] = await getSuggestedTemplates(tx as Transaction, entityType) } + // Trigger on-demand AI categorization for transactions with weak suggestions + if (needsAiIds.length > 0) { + console.log( + `[suggest-categories] ${needsAiIds.length} transactions have weak suggestions, triggering on-demand AI:`, + needsAiIds.map((id) => { + const tx = transactions.find((t) => t.id === id) + return tx + ? { id, description: tx.description, merchant_name: tx.merchant_name, amount: tx.amount } + : { id } + }) + ) + + try { + const { categorizeTransactions } = await import( + '@/extensions/general/ai-categorization' + ) + const aiResults = await categorizeTransactions(user.id, needsAiIds) + + console.log( + '[suggest-categories] AI categorization results:', + aiResults.map((r) => ({ + id: r.transactionId, + category: r.category, + basAccount: r.basAccount, + confidence: r.confidence, + reasoning: r.reasoning, + isPrivate: r.isPrivate, + templateId: r.templateId, + })) + ) + + // Group AI results by transactionId (AI now returns 2 per transaction) + const groupedAi: Record = {} + for (const aiResult of aiResults) { + if (!groupedAi[aiResult.transactionId]) { + groupedAi[aiResult.transactionId] = [] + } + groupedAi[aiResult.transactionId].push({ + category: aiResult.category, + basAccount: aiResult.basAccount, + confidence: aiResult.confidence, + reasoning: aiResult.reasoning, + }) + } + + for (const [txId, aiSuggestions] of Object.entries(groupedAi)) { + const existing = suggestions[txId] || [] + const tx = transactions.find((t) => t.id === txId) + suggestions[txId] = mergeAiSuggestions(existing, aiSuggestions, tx?.amount) + } + } catch (err) { + // AI categorization is non-blocking — log and continue with existing suggestions + console.error('[suggest-categories] On-demand AI categorization failed:', err) + } + } + return NextResponse.json({ suggestions, template_suggestions }) } diff --git a/components/bookkeeping/JournalEntryList.tsx b/components/bookkeeping/JournalEntryList.tsx index de63de7d..579f17c9 100644 --- a/components/bookkeeping/JournalEntryList.tsx +++ b/components/bookkeeping/JournalEntryList.tsx @@ -206,8 +206,11 @@ export default function JournalEntryList({ periodId }: Props) {
- {isExpanded && lines.length > 0 && ( + {isExpanded && ( + {lines.length === 0 ? ( +

Inga kontorader hittades för denna verifikation.

+ ) : ( @@ -261,6 +264,7 @@ export default function JournalEntryList({ periodId }: Props) {
+ )} } + /> + ) +} diff --git a/components/transactions/DescribeTransactionDialog.tsx b/components/transactions/DescribeTransactionDialog.tsx new file mode 100644 index 00000000..45b40d84 --- /dev/null +++ b/components/transactions/DescribeTransactionDialog.tsx @@ -0,0 +1,539 @@ +'use client' + +import { useState } from 'react' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { Card, CardContent } from '@/components/ui/card' +import { Textarea } from '@/components/ui/textarea' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from '@/components/ui/dialog' +import { useToast } from '@/components/ui/use-toast' +import { formatCurrency, formatDate } from '@/lib/utils' +import { + ArrowUpRight, + ArrowDownRight, + Loader2, + Search, + ArrowLeft, + Check, + CheckCircle2, + AlertTriangle, +} from 'lucide-react' +import JournalEntryPreview from './JournalEntryPreview' +import { formatAccountWithName } from '@/lib/bookkeeping/client-account-names' +import type { TransactionWithInvoice } from './transaction-types' + +interface TemplateMatch { + template_id: string + name_sv: string + name_en: string + group: string + debit_account: string + credit_account: string + confidence: number + description_sv: string + vat_rate: number + vat_treatment: string | null + deductibility: 'full' | 'non_deductible' | 'conditional' + deductibility_note_sv: string | null + special_rules_sv: string | null + risk_level: string +} + +interface DescribeResult { + templates: TemplateMatch[] + needs_more_detail: boolean + user_description: string + batch_candidate_count: number + merchant_name: string | null +} + +interface DescribeTransactionDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + transaction: TransactionWithInvoice | null + onCategorized: (transactionId: string, journalEntryId: string | null) => void + onBatchApplied?: (count: number) => void +} + +type Step = 'describe' | 'pick' | 'batch' + +function getExamplePrompts(transaction: TransactionWithInvoice): string[] { + const desc = (transaction.description || '').toLowerCase() + const isExpense = transaction.amount < 0 + + if (!isExpense) { + return ['Konsultarvode', 'Forsaljning av varor', 'Aterbetalning'] + } + + // Contextual suggestions based on description keywords + if (desc.includes('restaurang') || desc.includes('lunch') || desc.includes('middag') || desc.includes('mat')) { + return ['Lunch med kund', 'Personalmiddag', 'Fika till kontoret'] + } + if (desc.includes('hotel') || desc.includes('hotell') || desc.includes('boende') || desc.includes('resa')) { + return ['Tjansteresa', 'Hotell konferens', 'Flygbiljett'] + } + if (desc.includes('uber') || desc.includes('taxi') || desc.includes('bolt') || desc.includes('sj ')) { + return ['Taxi till kund', 'Tjansteresa', 'Pendling'] + } + if (desc.includes('google') || desc.includes('meta') || desc.includes('facebook') || desc.includes('linkedin')) { + return ['Online-annonsering', 'SaaS-prenumeration', 'Marknadsforingskampanj'] + } + if (desc.includes('amazon') || desc.includes('aws') || desc.includes('azure') || desc.includes('cloud')) { + return ['Serverhosting', 'SaaS-prenumeration', 'Kontorsmaterial'] + } + + // Generic expense suggestions + return ['Kontorsmaterial', 'SaaS-prenumeration', 'Konsulttjanst', 'Reklam'] +} + +export default function DescribeTransactionDialog({ + open, + onOpenChange, + transaction, + onCategorized, + onBatchApplied, +}: DescribeTransactionDialogProps) { + const { toast } = useToast() + const [step, setStep] = useState('describe') + const [description, setDescription] = useState('') + const [isSearching, setIsSearching] = useState(false) + const [isBooking, setIsBooking] = useState(false) + const [isBatchApplying, setIsBatchApplying] = useState(false) + const [describeResult, setDescribeResult] = useState(null) + const [selectedTemplateId, setSelectedTemplateId] = useState(null) + + function resetState() { + setStep('describe') + setDescription('') + setIsSearching(false) + setIsBooking(false) + setIsBatchApplying(false) + setDescribeResult(null) + setSelectedTemplateId(null) + } + + function handleOpenChange(isOpen: boolean) { + if (!isOpen) { + resetState() + } + onOpenChange(isOpen) + } + + async function handleSearch() { + if (!transaction || description.trim().length < 3) return + + setIsSearching(true) + try { + const response = await fetch(`/api/transactions/${transaction.id}/describe`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ description: description.trim() }), + }) + const result = await response.json() + if (!response.ok) { + toast({ + title: 'Fel', + description: result.error || 'Kunde inte soka mallar', + variant: 'destructive', + }) + setIsSearching(false) + return + } + + setDescribeResult(result.data) + setSelectedTemplateId(null) + setStep('pick') + } catch { + toast({ + title: 'Fel', + description: 'Nagot gick fel vid sokning', + variant: 'destructive', + }) + } + setIsSearching(false) + } + + async function handleBook() { + if (!transaction || !selectedTemplateId || !describeResult) return + + setIsBooking(true) + try { + const response = await fetch(`/api/transactions/${transaction.id}/categorize`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + is_business: true, + template_id: selectedTemplateId, + user_description: describeResult.user_description, + }), + }) + const result = await response.json() + if (!response.ok) { + toast({ + title: 'Fel', + description: result.error || 'Kunde inte bokfora transaktion', + variant: 'destructive', + }) + setIsBooking(false) + return + } + + if (describeResult.batch_candidate_count > 0) { + setStep('batch') + setIsBooking(false) + onCategorized(transaction.id, result.journal_entry_id || null) + } else { + toast({ title: 'Bokford', description: 'Transaktion bokford och verifikation skapad' }) + onCategorized(transaction.id, result.journal_entry_id || null) + handleOpenChange(false) + } + } catch { + toast({ + title: 'Fel', + description: 'Nagot gick fel vid bokforing', + variant: 'destructive', + }) + setIsBooking(false) + } + } + + async function handleBatchApply() { + if (!describeResult || !selectedTemplateId) return + + setIsBatchApplying(true) + try { + const response = await fetch('/api/transactions/batch-describe', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + merchant_name: describeResult.merchant_name, + template_id: selectedTemplateId, + is_business: true, + user_description: describeResult.user_description, + }), + }) + const result = await response.json() + if (!response.ok) { + toast({ + title: 'Fel', + description: result.error || 'Kunde inte bokfora batch', + variant: 'destructive', + }) + setIsBatchApplying(false) + return + } + + const applied = result.data?.applied || 0 + const errors = result.data?.errors || [] + if (errors.length > 0) { + toast({ + title: 'Delvis klart', + description: `${applied} lyckades, ${errors.length} misslyckades`, + variant: 'destructive', + }) + } else { + toast({ + title: 'Klart', + description: `${applied} transaktioner bokforda`, + }) + } + onBatchApplied?.(applied) + handleOpenChange(false) + } catch { + toast({ + title: 'Fel', + description: 'Nagot gick fel vid batchbokforing', + variant: 'destructive', + }) + setIsBatchApplying(false) + } + } + + function handleSkipBatch() { + toast({ title: 'Bokford', description: 'Transaktion bokford och verifikation skapad' }) + handleOpenChange(false) + } + + if (!transaction) return null + + const isIncome = transaction.amount > 0 + + return ( + + + + + {step === 'describe' && 'Beskriv transaktion'} + {step === 'pick' && 'Valj mall'} + {step === 'batch' && 'Bokfor liknande'} + + + {step === 'describe' && 'Beskriv vad transaktionen galler sa hittar vi ratt bokforingsmall'} + {step === 'pick' && 'Valj den mall som stammer bast'} + {step === 'batch' && 'Transaktion bokford!'} + + + + {/* Transaction summary - shown in describe and pick steps */} + {(step === 'describe' || step === 'pick') && ( +
+
+ {isIncome ? ( + + ) : ( + + )} +
+
+

{transaction.description}

+

{formatDate(transaction.date)}

+
+

+ {isIncome ? '+' : ''} + {formatCurrency(transaction.amount, transaction.currency)} +

+
+ )} + + {/* Step 1: Describe */} + {step === 'describe' && ( +
+