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 (
+
+ )
+ }
+
+ 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 (
-
+
)
}
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}
/>
+
+
- {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 (
+
+ )
+}
diff --git a/components/transactions/InvoiceMatchDialog.tsx b/components/transactions/InvoiceMatchDialog.tsx
index dc224511..1d2eddf8 100644
--- a/components/transactions/InvoiceMatchDialog.tsx
+++ b/components/transactions/InvoiceMatchDialog.tsx
@@ -3,6 +3,7 @@
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog'
import { formatCurrency, formatDate } from '@/lib/utils'
+import { CheckCircle2, AlertTriangle } from 'lucide-react'
import type { TransactionWithInvoice } from './transaction-types'
interface InvoiceMatchDialogProps {
@@ -66,6 +67,37 @@ export default function InvoiceMatchDialog({
+ {/* Amount comparison */}
+ {(() => {
+ const txAmount = transaction.amount
+ const invAmount = transaction.potential_invoice!.total
+ const sameCurrency = transaction.currency === transaction.potential_invoice!.currency
+ const amountsMatch = sameCurrency && Math.abs(txAmount - invAmount) < 0.01
+
+ if (amountsMatch) {
+ return (
+
+ )
+ }
+
+ const diff = Math.abs(txAmount - invAmount)
+ return (
+
+
+
+
Beloppen skiljer sig
+
+ Differens: {formatCurrency(diff, transaction.currency)}
+ {!sameCurrency && ' (olika valutor)'}
+
+
+
+ )
+ })()}
+
{/* What will happen */}
Vid bekräftelse:
diff --git a/components/transactions/JournalEntryPreview.tsx b/components/transactions/JournalEntryPreview.tsx
new file mode 100644
index 00000000..f98b2029
--- /dev/null
+++ b/components/transactions/JournalEntryPreview.tsx
@@ -0,0 +1,114 @@
+'use client'
+
+import { useMemo } from 'react'
+import { formatCurrency } from '@/lib/utils'
+import { formatAccountWithName } from '@/lib/bookkeeping/client-account-names'
+import { getVatRate, extractVatAmount, extractNetAmount } from '@/lib/bookkeeping/vat-entries'
+import { getCategoryAccountMapping } from '@/lib/bookkeeping/category-mapping'
+import type { TransactionCategory, VatTreatment } from '@/types'
+
+interface PreviewLine {
+ side: 'debet' | 'kredit'
+ account: string
+ amount: number
+}
+
+interface JournalEntryPreviewProps {
+ amount: number
+ currency?: string
+ category?: TransactionCategory
+ vatTreatment?: VatTreatment | 'none'
+ accountOverride?: string
+ /** For template-based bookings — overrides category mapping */
+ templateDebitAccount?: string
+ templateCreditAccount?: string
+ templateVatRate?: number
+}
+
+export default function JournalEntryPreview({
+ amount,
+ currency = 'SEK',
+ category,
+ vatTreatment,
+ accountOverride,
+ templateDebitAccount,
+ templateCreditAccount,
+ templateVatRate,
+}: JournalEntryPreviewProps) {
+ const lines = useMemo(() => {
+ const result: PreviewLine[] = []
+ const absAmount = Math.abs(amount)
+
+ // Template-based preview
+ if (templateDebitAccount && templateCreditAccount) {
+ const vatRate = templateVatRate ?? 0
+ const vatAmt = extractVatAmount(absAmount, vatRate)
+ const netAmt = extractNetAmount(absAmount, vatRate)
+
+ result.push({ side: 'debet', account: templateDebitAccount, amount: netAmt })
+ if (vatAmt > 0) {
+ result.push({ side: 'debet', account: '2641', amount: vatAmt })
+ }
+ result.push({ side: 'kredit', account: templateCreditAccount, amount: absAmount })
+ return result
+ }
+
+ // Category-based preview
+ if (!category) return result
+
+ const resolvedVat = vatTreatment === 'none' ? undefined : vatTreatment
+ const mapping = getCategoryAccountMapping(category, amount, category !== 'private', 'enskild_firma', resolvedVat)
+
+ const debitAccount = accountOverride && amount < 0 ? accountOverride : mapping.debitAccount
+ const creditAccount = accountOverride && amount > 0 ? accountOverride : mapping.creditAccount
+
+ const treatment = mapping.vatTreatment as VatTreatment | null
+ const vatRate = treatment ? getVatRate(treatment) : 0
+ const vatAmt = vatRate > 0 ? extractVatAmount(absAmount, vatRate) : 0
+ const netAmt = vatRate > 0 ? extractNetAmount(absAmount, vatRate) : absAmount
+
+ if (amount < 0) {
+ // Expense: Debit expense + VAT, Credit bank
+ result.push({ side: 'debet', account: debitAccount, amount: netAmt })
+ if (vatAmt > 0 && mapping.vatDebitAccount) {
+ result.push({ side: 'debet', account: mapping.vatDebitAccount, amount: vatAmt })
+ }
+ result.push({ side: 'kredit', account: creditAccount, amount: absAmount })
+ } else {
+ // Income: Debit bank, Credit revenue + VAT
+ result.push({ side: 'debet', account: debitAccount, amount: absAmount })
+ if (vatAmt > 0 && mapping.vatCreditAccount) {
+ result.push({ side: 'kredit', account: mapping.vatCreditAccount, amount: vatAmt })
+ }
+ result.push({ side: 'kredit', account: creditAccount, amount: netAmt })
+ }
+
+ // Reverse charge: add offsetting lines
+ if (treatment === 'reverse_charge' && amount < 0) {
+ const rcVatAmt = Math.round(absAmount * 0.25 * 100) / 100
+ result.push({ side: 'debet', account: '2645', amount: rcVatAmt })
+ result.push({ side: 'kredit', account: '2614', amount: rcVatAmt })
+ }
+
+ return result
+ }, [amount, category, vatTreatment, accountOverride, templateDebitAccount, templateCreditAccount, templateVatRate])
+
+ if (lines.length === 0) return null
+
+ return (
+
+
Verifikation
+
+ {lines.map((line, i) => (
+
+
+ {line.side === 'debet' ? 'Debet' : 'Kredit'}
+
+ {formatAccountWithName(line.account)}
+ {formatCurrency(line.amount, currency)}
+
+ ))}
+
+
+ )
+}
diff --git a/components/transactions/QuickReviewDialog.tsx b/components/transactions/QuickReviewDialog.tsx
index 86d0872b..24e9056b 100644
--- a/components/transactions/QuickReviewDialog.tsx
+++ b/components/transactions/QuickReviewDialog.tsx
@@ -8,10 +8,12 @@ import { useToast } from '@/components/ui/use-toast'
import { formatCurrency, formatDate } from '@/lib/utils'
import { ArrowUpRight, ArrowDownRight, Check, Paperclip, ChevronDown, ChevronUp } from 'lucide-react'
import { getDefaultAccountForCategory } from '@/lib/bookkeeping/category-mapping'
+import JournalEntryPreview from './JournalEntryPreview'
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
import DocumentUploadZone from '@/components/bookkeeping/DocumentUploadZone'
import type { UploadedFile } from '@/components/bookkeeping/DocumentUploadZone'
import VatTreatmentSelect from './VatTreatmentSelect'
+import { VAT_TREATMENT_OPTIONS } from './transaction-types'
import type { TransactionWithInvoice } from './transaction-types'
import type { TransactionCategory, VatTreatment, BASAccount } from '@/types'
@@ -49,6 +51,7 @@ export default function QuickReviewDialog({
const [error, setError] = useState
(null)
const [uploadedFiles, setUploadedFiles] = useState([])
const [showUploadZone, setShowUploadZone] = useState(false)
+ const [showVatDropdown, setShowVatDropdown] = useState(false)
// Handle account changes — clear VAT for liability/equity accounts (class 2)
const handleAccountChange = useCallback((account: string) => {
@@ -174,6 +177,15 @@ export default function QuickReviewDialog({
+ {/* Journal entry preview */}
+
+
{/* Account */}
@@ -190,14 +202,26 @@ export default function QuickReviewDialog({
-
- {isLiabilityAccount && (
-
- Ingen moms för skuld-/eget kapital-konton
+ {isLiabilityAccount ? (
+
+ Ingen moms for skuld-/eget kapital-konton
+
+ ) : showVatDropdown ? (
+
+ ) : (
+
+ {VAT_TREATMENT_OPTIONS.find(o => o.value === vatTreatment)?.label || 'Ingen moms'}
+ {' '}
+
)}
diff --git a/components/transactions/SwipeCategorizationView.tsx b/components/transactions/SwipeCategorizationView.tsx
index 9db0c5d8..cf227023 100644
--- a/components/transactions/SwipeCategorizationView.tsx
+++ b/components/transactions/SwipeCategorizationView.tsx
@@ -10,18 +10,22 @@ import VatTreatmentSelect from './VatTreatmentSelect'
import { formatCurrency, formatDate } from '@/lib/utils'
import { checkExpenseWarnings } from '@/lib/tax/expense-warnings'
import { getDefaultAccountForCategory, getDefaultVatTreatmentForCategory } from '@/lib/bookkeeping/category-mapping'
+import JournalEntryPreview from './JournalEntryPreview'
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
import DocumentUploadZone from '@/components/bookkeeping/DocumentUploadZone'
import type { UploadedFile } from '@/components/bookkeeping/DocumentUploadZone'
-import { X, ArrowLeft, ArrowRight, Building, AlertTriangle, Check, FileText, Link2, Receipt as ReceiptIcon, SkipForward, Paperclip, ChevronDown, ChevronUp } from 'lucide-react'
+import { X, ArrowLeft, ArrowRight, Building, AlertTriangle, Check, FileText, Link2, Receipt as ReceiptIcon, SkipForward, Paperclip, ChevronDown, ChevronUp, MessageSquareText } from 'lucide-react'
+import DescribeTransactionDialog from './DescribeTransactionDialog'
+import { formatAccountWithName } from '@/lib/bookkeeping/client-account-names'
import type { TransactionCategory, VatTreatment, BASAccount } from '@/types'
-import type { SuggestedCategory } from '@/lib/transactions/category-suggestions'
+import type { SuggestedCategory, SuggestedTemplate } from '@/lib/transactions/category-suggestions'
import type { TransactionWithInvoice, CategorizeHandler, MatchInvoiceHandler } from './transaction-types'
-import { EXPENSE_CATEGORIES, INCOME_CATEGORIES } from './transaction-types'
+import { EXPENSE_CATEGORIES, INCOME_CATEGORIES, VAT_TREATMENT_OPTIONS } from './transaction-types'
interface SwipeCategorizationViewProps {
transactions: TransactionWithInvoice[]
suggestions?: Record
+ templateSuggestions?: Record
onCategorize: CategorizeHandler
onMatchInvoice?: MatchInvoiceHandler
onClose: () => void
@@ -33,6 +37,7 @@ const incomeCategories = INCOME_CATEGORIES
export default function SwipeCategorizationView({
transactions,
suggestions,
+ templateSuggestions,
onCategorize,
onMatchInvoice,
onClose,
@@ -52,6 +57,8 @@ export default function SwipeCategorizationView({
const [accounts, setAccounts] = useState([])
const [uploadedFiles, setUploadedFiles] = useState([])
const [showUploadZone, setShowUploadZone] = useState(false)
+ const [showDescribeDialog, setShowDescribeDialog] = useState(false)
+ const [showVatDropdown, setShowVatDropdown] = useState(false)
// Clear VAT treatment when switching to a liability/equity account (class 2)
useEffect(() => {
@@ -113,6 +120,7 @@ export default function SwipeCategorizationView({
setPendingCategory(category)
setAccountOverride(defaultAccount)
setVatTreatment(defaultVat ?? 'none')
+ setShowVatDropdown(false)
setShowCategorySelect(false)
setShowReviewStep(true)
setError(null)
@@ -366,6 +374,15 @@ export default function SwipeCategorizationView({
+ {/* Journal entry preview */}
+
+
{/* Account override */}
@@ -382,15 +399,27 @@ export default function SwipeCategorizationView({
-
- {isLiabilityAccount && (
-
+ {isLiabilityAccount ? (
+
Ingen moms for skuld-/eget kapital-konton
+ ) : showVatDropdown ? (
+
+ ) : (
+
+ {VAT_TREATMENT_OPTIONS.find(o => o.value === vatTreatment)?.label || 'Ingen moms'}
+ {' '}
+
+
)}
@@ -641,7 +670,7 @@ export default function SwipeCategorizationView({
{suggestion.label}
{suggestion.account && (
- {suggestion.account}
+ {formatAccountWithName(suggestion.account)}
)}
@@ -650,6 +679,45 @@ export default function SwipeCategorizationView({
)}
+ {/* Fallback templates when no strong suggestion */}
+ {(() => {
+ const txSuggestions = suggestions?.[currentTransaction.id]
+ const topConfidence = txSuggestions?.[0]?.confidence ?? 0
+ const templates = templateSuggestions?.[currentTransaction.id]
+ if (topConfidence < 0.55 && templates && templates.length > 0) {
+ return (
+
+
Osaker? Prova dessa mallar:
+
+ {templates.slice(0, 3).map((tmpl) => (
+
+ ))}
+
+
+ )
+ }
+ return null
+ })()}
+
+ {/* Describe transaction button */}
+
+
{/* Categorization button */}
)}
+ {/* Fallback templates when no strong suggestion */}
+ {showTemplateFallback && !hasInvoiceMatch && (
+ <>
+ Osaker? Prova:
+ {templateSuggestions!.slice(0, 3).map((tmpl) => (
+
+ ))}
+ >
+ )}
+
{/* Private button */}
@@ -200,6 +236,20 @@ export default function TransactionInboxCard({
+ {/* Describe transaction */}
+ {onOpenDescribe && (
+
+ )}
+
{/* Open category dialog */}