Merge remote-tracking branch 'origin/main' into export-biz

This commit is contained in:
Emil
2026-02-24 16:07:57 +01:00
69 changed files with 3584 additions and 208 deletions
+241
View File
@@ -0,0 +1,241 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import Link from 'next/link'
import { createClient } from '@/lib/supabase/client'
import { useToast } from '@/components/ui/use-toast'
import { DeadlineList } from '@/components/deadlines/DeadlineList'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { AlertTriangle, ArrowRight } from 'lucide-react'
import type { Deadline } from '@/types'
export default function DeadlinesPage() {
const [deadlines, setDeadlines] = useState<Deadline[]>([])
const [customers, setCustomers] = useState<{ id: string; name: string }[]>([])
const [overdueInvoices, setOverdueInvoices] = useState<{ count: number; total: number }>({ count: 0, total: 0 })
const [isLoading, setIsLoading] = useState(true)
const { toast } = useToast()
const supabase = createClient()
const fetchData = useCallback(async () => {
setIsLoading(true)
try {
// Fetch deadlines with customer names
const { data: deadlinesData, error: deadlinesError } = await supabase
.from('deadlines')
.select('*, customer:customers(name)')
.order('due_date', { ascending: true })
if (deadlinesError) throw deadlinesError
// Fetch customers for the form
const { data: customersData, error: customersError } = await supabase
.from('customers')
.select('id, name')
.order('name', { ascending: true })
if (customersError) throw customersError
// Fetch overdue invoices summary
const today = new Date().toISOString().split('T')[0]
const { data: overdueData, error: overdueError } = await supabase
.from('invoices')
.select('total_sek, total')
.in('status', ['sent', 'unpaid'])
.lt('due_date', today)
if (overdueError) throw overdueError
const overdueCount = overdueData?.length || 0
const overdueTotal = (overdueData || []).reduce(
(sum, inv) => sum + (inv.total_sek || inv.total || 0),
0
)
setDeadlines(deadlinesData || [])
setCustomers(customersData || [])
setOverdueInvoices({ count: overdueCount, total: overdueTotal })
} catch {
toast({
title: 'Fel',
description: 'Kunde inte hamta data',
variant: 'destructive',
})
} finally {
setIsLoading(false)
}
}, [supabase, toast])
useEffect(() => {
fetchData()
}, [fetchData])
const handleDeadlineCreate = async (
data: Omit<Deadline, 'id' | 'user_id' | 'created_at' | 'updated_at'>
) => {
try {
const response = await fetch('/api/deadlines', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (!response.ok) {
const result = await response.json()
throw new Error(result.error || 'Failed to create deadline')
}
toast({
title: 'Deadline skapad',
description: 'Din deadline har sparats',
})
fetchData()
} catch (error) {
toast({
title: 'Fel',
description: error instanceof Error ? error.message : 'Kunde inte skapa deadline',
variant: 'destructive',
})
throw error
}
}
const handleDeadlineToggle = async (deadline: Deadline) => {
try {
const response = await fetch(`/api/deadlines/${deadline.id}/complete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_completed: !deadline.is_completed }),
})
if (!response.ok) {
const result = await response.json()
throw new Error(result.error || 'Failed to toggle deadline')
}
toast({
title: deadline.is_completed ? 'Markerad som ej klar' : 'Markerad som klar',
})
fetchData()
} catch (error) {
toast({
title: 'Fel',
description: error instanceof Error ? error.message : 'Kunde inte uppdatera deadline',
variant: 'destructive',
})
}
}
const handleDeadlineEdit = async (deadline: Deadline) => {
try {
const response = await fetch(`/api/deadlines/${deadline.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(deadline),
})
if (!response.ok) {
const result = await response.json()
throw new Error(result.error || 'Failed to edit deadline')
}
toast({
title: 'Deadline uppdaterad',
description: 'Dina andringar har sparats',
})
fetchData()
} catch (error) {
toast({
title: 'Fel',
description: error instanceof Error ? error.message : 'Kunde inte uppdatera deadline',
variant: 'destructive',
})
}
}
const handleDeadlineDelete = async (deadline: Deadline) => {
try {
const response = await fetch(`/api/deadlines/${deadline.id}`, {
method: 'DELETE',
})
if (!response.ok) {
const result = await response.json()
throw new Error(result.error || 'Failed to delete deadline')
}
toast({
title: 'Deadline borttagen',
})
fetchData()
} catch (error) {
toast({
title: 'Fel',
description: error instanceof Error ? error.message : 'Kunde inte ta bort deadline',
variant: 'destructive',
})
}
}
if (isLoading) {
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Deadlines</h1>
</div>
<div className="animate-pulse">
<div className="h-10 bg-muted rounded w-48 mb-4" />
<div className="h-96 bg-muted rounded" />
</div>
</div>
)
}
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Deadlines</h1>
</div>
{overdueInvoices.count > 0 && (
<Link href="/invoices?status=unpaid" className="block group">
<Card className="border-destructive/50 bg-destructive/5 hover:bg-destructive/10 transition-colors">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<AlertTriangle className="h-5 w-5 text-destructive flex-shrink-0" />
<div>
<p className="font-medium text-sm">Forfallna fakturor</p>
<p className="text-xs text-muted-foreground mt-0.5">
{overdueInvoices.count} st totalt{' '}
{overdueInvoices.total.toLocaleString('sv-SE')} kr
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Badge variant="destructive">{overdueInvoices.count}</Badge>
<ArrowRight className="h-4 w-4 text-muted-foreground group-hover:translate-x-0.5 transition-transform" />
</div>
</div>
</CardContent>
</Card>
</Link>
)}
<DeadlineList
deadlines={deadlines}
customers={customers}
onDeadlineCreate={handleDeadlineCreate}
onDeadlineToggle={handleDeadlineToggle}
onDeadlineEdit={handleDeadlineEdit}
onDeadlineDelete={handleDeadlineDelete}
/>
</div>
)
}
+8
View File
@@ -252,6 +252,13 @@ export default async function DashboardPage() {
streak_count: streakCount, 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 ( return (
<DashboardContent <DashboardContent
firstName={firstName} firstName={firstName}
@@ -272,6 +279,7 @@ export default async function DashboardPage() {
missingUnderlagCount, missingUnderlagCount,
}} }}
onboardingProgress={onboardingProgress} onboardingProgress={onboardingProgress}
enabledExtensions={enabledToggles || []}
/> />
) )
} }
+66 -3
View File
@@ -19,11 +19,12 @@ import InboxZeroState from '@/components/transactions/InboxZeroState'
import InvoiceMatchDialog from '@/components/transactions/InvoiceMatchDialog' import InvoiceMatchDialog from '@/components/transactions/InvoiceMatchDialog'
import TransactionBookingDialog from '@/components/transactions/TransactionBookingDialog' import TransactionBookingDialog from '@/components/transactions/TransactionBookingDialog'
import QuickReviewDialog from '@/components/transactions/QuickReviewDialog' import QuickReviewDialog from '@/components/transactions/QuickReviewDialog'
import DescribeTransactionDialog from '@/components/transactions/DescribeTransactionDialog'
import { EXPENSE_CATEGORIES, INCOME_CATEGORIES } from '@/components/transactions/transaction-types' import { EXPENSE_CATEGORIES, INCOME_CATEGORIES } from '@/components/transactions/transaction-types'
import { getDefaultAccountForCategory, getDefaultVatTreatmentForCategory } from '@/lib/bookkeeping/category-mapping' import { getDefaultAccountForCategory, getDefaultVatTreatmentForCategory } from '@/lib/bookkeeping/category-mapping'
import type { TransactionWithInvoice, ViewMode, CategorizeHandler } from '@/components/transactions/transaction-types' import type { TransactionWithInvoice, ViewMode, CategorizeHandler } from '@/components/transactions/transaction-types'
import type { TransactionCategory, CreateTransactionInput, Invoice, Customer, VatTreatment } from '@/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() { export default function TransactionsPage() {
const [transactions, setTransactions] = useState<TransactionWithInvoice[]>([]) const [transactions, setTransactions] = useState<TransactionWithInvoice[]>([])
@@ -33,6 +34,7 @@ export default function TransactionsPage() {
const [isCreating, setIsCreating] = useState(false) const [isCreating, setIsCreating] = useState(false)
const [showSwipeView, setShowSwipeView] = useState(false) const [showSwipeView, setShowSwipeView] = useState(false)
const [categorySuggestions, setCategorySuggestions] = useState<Record<string, SuggestedCategory[]>>({}) const [categorySuggestions, setCategorySuggestions] = useState<Record<string, SuggestedCategory[]>>({})
const [templateSuggestions, setTemplateSuggestions] = useState<Record<string, SuggestedTemplate[]>>({})
const [isLoadingSuggestions, setIsLoadingSuggestions] = useState(false) const [isLoadingSuggestions, setIsLoadingSuggestions] = useState(false)
const [processingId, setProcessingId] = useState<string | null>(null) const [processingId, setProcessingId] = useState<string | null>(null)
@@ -57,6 +59,10 @@ export default function TransactionsPage() {
const [quickReviewCategory, setQuickReviewCategory] = useState<TransactionCategory | null>(null) const [quickReviewCategory, setQuickReviewCategory] = useState<TransactionCategory | null>(null)
const [quickReviewLabel, setQuickReviewLabel] = useState('') const [quickReviewLabel, setQuickReviewLabel] = useState('')
// Describe dialog
const [describeDialogOpen, setDescribeDialogOpen] = useState(false)
const [describeDialogTransaction, setDescribeDialogTransaction] = useState<TransactionWithInvoice | null>(null)
// Entity type for tooltip context // Entity type for tooltip context
const [entityType, setEntityType] = useState<string>('enskild_firma') const [entityType, setEntityType] = useState<string>('enskild_firma')
@@ -130,6 +136,9 @@ export default function TransactionsPage() {
if (data.suggestions) { if (data.suggestions) {
setCategorySuggestions(data.suggestions) setCategorySuggestions(data.suggestions)
} }
if (data.template_suggestions) {
setTemplateSuggestions(data.template_suggestions)
}
} catch { } catch {
// Non-critical // Non-critical
} }
@@ -409,13 +418,29 @@ export default function TransactionsPage() {
async function handleBatchCategorize(category: TransactionCategory, vatTreatment?: VatTreatment) { async function handleBatchCategorize(category: TransactionCategory, vatTreatment?: VatTreatment) {
const ids = Array.from(selectedIds) const ids = Array.from(selectedIds)
setBatchProgress({ done: 0, total: ids.length }) setBatchProgress({ done: 0, total: ids.length })
let successes = 0
const failures: string[] = []
for (let i = 0; i < ids.length; i++) { 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({ done: i + 1, total: ids.length })
} }
setBatchProgress(null) setBatchProgress(null)
setShowBatchSelector(false) 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() exitBatchMode()
} }
@@ -468,12 +493,40 @@ export default function TransactionsPage() {
return journalEntryId 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 // Swipe view
if (showSwipeView && uncategorizedTransactions.length > 0) { if (showSwipeView && uncategorizedTransactions.length > 0) {
return ( return (
<SwipeCategorizationView <SwipeCategorizationView
transactions={uncategorizedTransactions} transactions={uncategorizedTransactions}
suggestions={categorySuggestions} suggestions={categorySuggestions}
templateSuggestions={templateSuggestions}
onCategorize={handleCategorize} onCategorize={handleCategorize}
onMatchInvoice={handleMatchInvoice} onMatchInvoice={handleMatchInvoice}
onClose={() => setShowSwipeView(false)} onClose={() => setShowSwipeView(false)}
@@ -527,6 +580,7 @@ export default function TransactionsPage() {
key={transaction.id} key={transaction.id}
transaction={transaction} transaction={transaction}
suggestions={categorySuggestions[transaction.id]} suggestions={categorySuggestions[transaction.id]}
templateSuggestions={templateSuggestions[transaction.id]}
processingId={processingId} processingId={processingId}
isBatchMode={isBatchMode} isBatchMode={isBatchMode}
isSelected={selectedIds.has(transaction.id)} isSelected={selectedIds.has(transaction.id)}
@@ -535,6 +589,7 @@ export default function TransactionsPage() {
onMarkPrivate={handleMarkPrivate} onMarkPrivate={handleMarkPrivate}
onOpenMatchDialog={openMatchDialog} onOpenMatchDialog={openMatchDialog}
onOpenCategoryDialog={openCategoryDialog} onOpenCategoryDialog={openCategoryDialog}
onOpenDescribe={openDescribeDialog}
onOpenQuickReview={handleOpenQuickReview} onOpenQuickReview={handleOpenQuickReview}
onToggleSelect={toggleBatchSelect} onToggleSelect={toggleBatchSelect}
/> />
@@ -603,6 +658,14 @@ export default function TransactionsPage() {
onConfirm={handleQuickReviewConfirm} onConfirm={handleQuickReviewConfirm}
/> />
<DescribeTransactionDialog
open={describeDialogOpen}
onOpenChange={setDescribeDialogOpen}
transaction={describeDialogTransaction}
onCategorized={handleDescribeCategorized}
onBatchApplied={handleBatchApplied}
/>
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}> <Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
@@ -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 }
)
}
}
@@ -72,6 +72,8 @@ describe('POST /api/invoices/[id]/send', () => {
unit: 'tim', unit: 'tim',
unit_price: 1000, unit_price: 1000,
line_total: 10000, line_total: 10000,
vat_rate: 25,
vat_amount: 2500,
created_at: '2024-06-15T14:30:00Z', created_at: '2024-06-15T14:30:00Z',
}, },
], ],
+4
View File
@@ -318,6 +318,8 @@ describe('POST /api/invoices (create credit note)', () => {
unit: 'tim', unit: 'tim',
unit_price: 1000, unit_price: 1000,
line_total: 10000, line_total: 10000,
vat_rate: 25,
vat_amount: 2500,
created_at: '2024-06-15T14:30:00Z', created_at: '2024-06-15T14:30:00Z',
}, },
] ]
@@ -385,6 +387,8 @@ describe('POST /api/invoices (create credit note)', () => {
unit: 'st', unit: 'st',
unit_price: 1000, unit_price: 1000,
line_total: 1000, line_total: 1000,
vat_rate: 25,
vat_amount: 250,
created_at: '2024-06-15T14:30:00Z', created_at: '2024-06-15T14:30:00Z',
}, },
], ],
@@ -160,7 +160,9 @@ describe('POST /api/transactions/[id]/categorize', () => {
'GitHub', 'GitHub',
'6200', '6200',
'1930', '1930',
false false,
undefined,
undefined
) )
expect(emitSpy).toHaveBeenCalledWith( expect(emitSpy).toHaveBeenCalledWith(
expect.objectContaining({ type: 'transaction.categorized' }) expect.objectContaining({ type: 'transaction.categorized' })
@@ -256,7 +256,9 @@ export async function POST(
transaction.merchant_name, transaction.merchant_name,
mappingResult.debit_account, mappingResult.debit_account,
mappingResult.credit_account, mappingResult.credit_account,
!is_business !is_business,
body.user_description,
body.template_id
) )
} catch (err) { } catch (err) {
console.error('Failed to save mapping rule:', err) console.error('Failed to save mapping rule:', err)
@@ -0,0 +1,200 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import {
createMockRequest,
createMockRouteParams,
createQueuedMockSupabase,
makeTransaction,
parseJsonResponse,
} from '@/tests/helpers'
// Mock init
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
// Mock template embeddings
const mockFindSimilarTemplates = vi.fn().mockResolvedValue([])
vi.mock('@/lib/bookkeeping/template-embeddings', () => ({
findSimilarTemplates: (...args: unknown[]) => mockFindSimilarTemplates(...args),
}))
// Mock Supabase
const mockCreateClient = vi.fn()
vi.mock('@/lib/supabase/server', () => ({
createClient: (...args: unknown[]) => mockCreateClient(...args),
}))
describe('POST /api/transactions/[id]/describe', () => {
let POST: typeof import('../route').POST
beforeEach(async () => {
vi.clearAllMocks()
const mod = await import('../route')
POST = mod.POST
})
it('returns 401 when not authenticated', async () => {
const { supabase } = createQueuedMockSupabase()
supabase.auth.getUser.mockResolvedValue({
data: { user: null },
error: { message: 'Not authenticated' },
})
mockCreateClient.mockResolvedValue(supabase)
const req = createMockRequest('/api/transactions/test-id/describe', {
method: 'POST',
body: { description: 'business lunch' },
})
const res = await POST(req, createMockRouteParams({ id: 'test-id' }))
const { status, body } = await parseJsonResponse(res)
expect(status).toBe(401)
expect(body).toHaveProperty('error', 'Unauthorized')
})
it('returns 400 for invalid body (description too short)', async () => {
const { supabase } = createQueuedMockSupabase()
supabase.auth.getUser.mockResolvedValue({
data: { user: { id: 'user-1' } },
error: null,
})
mockCreateClient.mockResolvedValue(supabase)
const req = createMockRequest('/api/transactions/test-id/describe', {
method: 'POST',
body: { description: 'ab' },
})
const res = await POST(req, createMockRouteParams({ id: 'test-id' }))
const { status } = await parseJsonResponse(res)
expect(status).toBe(400)
})
it('returns 404 when transaction not found', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
supabase.auth.getUser.mockResolvedValue({
data: { user: { id: 'user-1' } },
error: null,
})
enqueueMany([
// transaction fetch
{ data: null, error: { message: 'Not found' } },
])
mockCreateClient.mockResolvedValue(supabase)
const req = createMockRequest('/api/transactions/nonexistent/describe', {
method: 'POST',
body: { description: 'business lunch' },
})
const res = await POST(req, createMockRouteParams({ id: 'nonexistent' }))
const { status, body } = await parseJsonResponse(res)
expect(status).toBe(404)
expect(body).toHaveProperty('error', 'Transaction not found')
})
it('returns template candidates on happy path', async () => {
const tx = makeTransaction({
id: 'tx-1',
merchant_name: 'Restaurant XYZ',
amount: -450,
})
mockFindSimilarTemplates.mockResolvedValueOnce([
{
template: {
id: 'restaurant_dining',
name_sv: 'Restaurangbesök',
name_en: 'Restaurant dining',
group: 'representation',
debit_account: '6071',
credit_account: '1930',
description_sv: 'Representation - restaurang',
},
confidence: 0.82,
},
])
const { supabase, enqueueMany } = createQueuedMockSupabase()
supabase.auth.getUser.mockResolvedValue({
data: { user: { id: 'user-1' } },
error: null,
})
enqueueMany([
// transaction fetch
{ data: tx, error: null },
// company_settings
{ data: { entity_type: 'enskild_firma' }, error: null },
// batch candidate count
{ data: null, error: null, count: 3 },
])
mockCreateClient.mockResolvedValue(supabase)
const req = createMockRequest('/api/transactions/tx-1/describe', {
method: 'POST',
body: { description: 'business lunch with client' },
})
const res = await POST(req, createMockRouteParams({ id: 'tx-1' }))
const { status, body } = await parseJsonResponse<{ data: Record<string, unknown> }>(res)
expect(status).toBe(200)
expect(body.data.templates).toHaveLength(1)
expect(body.data.needs_more_detail).toBe(false)
expect(body.data.user_description).toBe('business lunch with client')
expect(body.data.batch_candidate_count).toBe(3)
expect(body.data.merchant_name).toBe('Restaurant XYZ')
// Verify findSimilarTemplates was called with the user description
expect(mockFindSimilarTemplates).toHaveBeenCalledWith(
expect.objectContaining({ id: 'tx-1' }),
'enskild_firma',
10,
'business lunch with client'
)
})
it('sets needs_more_detail when confidence is low', async () => {
const tx = makeTransaction({ id: 'tx-2', merchant_name: null })
mockFindSimilarTemplates.mockResolvedValueOnce([
{
template: {
id: 'misc',
name_sv: 'Diverse',
name_en: 'Miscellaneous',
group: 'other',
debit_account: '6991',
credit_account: '1930',
description_sv: 'Okategoriserad utgift',
},
confidence: 0.4,
},
])
const { supabase, enqueueMany } = createQueuedMockSupabase()
supabase.auth.getUser.mockResolvedValue({
data: { user: { id: 'user-1' } },
error: null,
})
enqueueMany([
// transaction fetch
{ data: tx, error: null },
// company_settings
{ data: { entity_type: 'enskild_firma' }, error: null },
])
mockCreateClient.mockResolvedValue(supabase)
const req = createMockRequest('/api/transactions/tx-2/describe', {
method: 'POST',
body: { description: 'some kind of payment' },
})
const res = await POST(req, createMockRouteParams({ id: 'tx-2' }))
const { status, body } = await parseJsonResponse<{ data: Record<string, unknown> }>(res)
expect(status).toBe(200)
expect(body.data.needs_more_detail).toBe(true)
})
})
@@ -0,0 +1,98 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { validateBody } from '@/lib/api/validate'
import { DescribeTransactionSchema } from '@/lib/api/schemas'
import { findSimilarTemplates } from '@/lib/bookkeeping/template-embeddings'
import type { Transaction, EntityType } from '@/types'
ensureInitialized()
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { id } = await params
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const validation = await validateBody(request, DescribeTransactionSchema)
if (!validation.success) return validation.response
const { description } = validation.data
// Fetch the transaction (validates ownership)
const { data: transaction, error: fetchError } = await supabase
.from('transactions')
.select('*')
.eq('id', id)
.eq('user_id', user.id)
.single()
if (fetchError || !transaction) {
return NextResponse.json({ error: 'Transaction not found' }, { status: 404 })
}
// Fetch entity type
const { data: settings } = await supabase
.from('company_settings')
.select('entity_type')
.eq('user_id', user.id)
.single()
const entityType: EntityType = (settings?.entity_type as EntityType) || 'enskild_firma'
// Run embedding search with user description dominating the query
const templates = await findSimilarTemplates(
transaction as Transaction,
entityType,
10,
description
)
// Flag if top confidence is too low
const needsMoreDetail = templates.length === 0 || templates[0].confidence < 0.55
// Count uncategorized sibling transactions from same merchant
let batchCandidateCount = 0
if (transaction.merchant_name) {
const { count } = await supabase
.from('transactions')
.select('id', { count: 'exact', head: true })
.eq('user_id', user.id)
.eq('merchant_name', transaction.merchant_name)
.is('journal_entry_id', null)
.neq('id', id)
batchCandidateCount = count || 0
}
return NextResponse.json({
data: {
templates: templates.map((m) => ({
template_id: m.template.id,
name_sv: m.template.name_sv,
name_en: m.template.name_en,
group: m.template.group,
debit_account: m.template.debit_account,
credit_account: m.template.credit_account,
confidence: m.confidence,
description_sv: m.template.description_sv,
vat_rate: m.template.vat_rate,
vat_treatment: m.template.vat_treatment,
deductibility: m.template.deductibility,
deductibility_note_sv: m.template.deductibility_note_sv || null,
special_rules_sv: m.template.special_rules_sv || null,
risk_level: m.template.risk_level,
})),
needs_more_detail: needsMoreDetail,
user_description: description,
batch_candidate_count: batchCandidateCount,
merchant_name: transaction.merchant_name,
},
})
}
@@ -0,0 +1,212 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import {
createMockRequest,
createQueuedMockSupabase,
makeTransaction,
parseJsonResponse,
} from '@/tests/helpers'
import { eventBus } from '@/lib/events'
// Mock init
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
// Mock booking templates
vi.mock('@/lib/bookkeeping/booking-templates', () => ({
getTemplateById: vi.fn((id: string) => {
if (id === 'office_supplies') {
return {
id: 'office_supplies',
name_sv: 'Kontorsmaterial',
name_en: 'Office supplies',
group: 'office',
debit_account: '6110',
credit_account: '1930',
fallback_category: 'expense_office',
default_private: false,
vat_treatment: 'standard_25',
vat_rate: 0.25,
deductibility: 'full',
risk_level: 'LOW',
requires_review: false,
entity_applicability: 'all',
direction: 'expense',
}
}
return null
}),
buildMappingResultFromTemplate: vi.fn(() => ({
rule: null,
debit_account: '6110',
credit_account: '1930',
risk_level: 'LOW',
confidence: 1.0,
requires_review: false,
default_private: false,
vat_lines: [],
description: 'Kontorsmaterial',
})),
}))
// Mock transaction entries
const mockCreateTransactionJournalEntry = vi.fn().mockResolvedValue({ id: 'je-1' })
vi.mock('@/lib/bookkeeping/transaction-entries', () => ({
createTransactionJournalEntry: (...args: unknown[]) => mockCreateTransactionJournalEntry(...args),
}))
// Mock mapping engine
const mockSaveUserMappingRule = vi.fn().mockResolvedValue(undefined)
vi.mock('@/lib/bookkeeping/mapping-engine', () => ({
saveUserMappingRule: (...args: unknown[]) => mockSaveUserMappingRule(...args),
}))
// Mock Supabase — set up once, re-configure per test via auth mock + queue
const mockCreateClient = vi.fn()
vi.mock('@/lib/supabase/server', () => ({
createClient: (...args: unknown[]) => mockCreateClient(...args),
}))
describe('POST /api/transactions/batch-describe', () => {
let POST: typeof import('../route').POST
beforeEach(async () => {
vi.clearAllMocks()
eventBus.clear()
const mod = await import('../route')
POST = mod.POST
})
it('returns 401 when not authenticated', async () => {
const { supabase } = createQueuedMockSupabase()
supabase.auth.getUser.mockResolvedValue({
data: { user: null },
error: { message: 'Not authenticated' },
})
mockCreateClient.mockResolvedValue(supabase)
const req = createMockRequest('/api/transactions/batch-describe', {
method: 'POST',
body: {
merchant_name: 'Staples',
template_id: 'office_supplies',
is_business: true,
},
})
const res = await POST(req)
const { status, body } = await parseJsonResponse(res)
expect(status).toBe(401)
expect(body).toHaveProperty('error', 'Unauthorized')
})
it('returns 400 for invalid template_id', async () => {
const { supabase } = createQueuedMockSupabase()
supabase.auth.getUser.mockResolvedValue({
data: { user: { id: 'user-1' } },
error: null,
})
mockCreateClient.mockResolvedValue(supabase)
const req = createMockRequest('/api/transactions/batch-describe', {
method: 'POST',
body: {
merchant_name: 'Staples',
template_id: 'nonexistent_template',
is_business: true,
},
})
const res = await POST(req)
const { status, body } = await parseJsonResponse(res)
expect(status).toBe(400)
expect(body).toHaveProperty('error', 'Invalid template_id')
})
it('applies template to uncategorized merchant transactions', async () => {
const tx1 = makeTransaction({ id: 'tx-1', merchant_name: 'Staples', amount: -299 })
const tx2 = makeTransaction({ id: 'tx-2', merchant_name: 'Staples', amount: -150 })
const { supabase, enqueueMany } = createQueuedMockSupabase()
supabase.auth.getUser.mockResolvedValue({
data: { user: { id: 'user-1' } },
error: null,
})
enqueueMany([
// company_settings
{ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null },
// fetch uncategorized transactions
{ data: [tx1, tx2], error: null },
// fiscal period upsert for tx1
{ data: null, error: null },
// transaction update for tx1
{ data: null, error: null },
// fiscal period upsert for tx2
{ data: null, error: null },
// transaction update for tx2
{ data: null, error: null },
])
mockCreateClient.mockResolvedValue(supabase)
const req = createMockRequest('/api/transactions/batch-describe', {
method: 'POST',
body: {
merchant_name: 'Staples',
template_id: 'office_supplies',
is_business: true,
user_description: 'office supplies purchase',
},
})
const res = await POST(req)
const { status, body } = await parseJsonResponse<{ data: { applied: number; errors: string[] } }>(res)
expect(status).toBe(200)
expect(body.data.applied).toBe(2)
expect(body.data.errors).toHaveLength(0)
// Verify journal entries were created
expect(mockCreateTransactionJournalEntry).toHaveBeenCalledTimes(2)
// Verify mapping rule was saved with user description
expect(mockSaveUserMappingRule).toHaveBeenCalledWith(
'user-1',
'Staples',
'6110',
'1930',
false,
'office supplies purchase',
'office_supplies'
)
})
it('returns 0 applied when no uncategorized transactions exist', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
supabase.auth.getUser.mockResolvedValue({
data: { user: { id: 'user-1' } },
error: null,
})
enqueueMany([
// company_settings
{ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null },
// fetch uncategorized transactions — empty
{ data: [], error: null },
])
mockCreateClient.mockResolvedValue(supabase)
const req = createMockRequest('/api/transactions/batch-describe', {
method: 'POST',
body: {
merchant_name: 'Unknown Merchant',
template_id: 'office_supplies',
is_business: true,
},
})
const res = await POST(req)
const { status, body } = await parseJsonResponse<{ data: { applied: number } }>(res)
expect(status).toBe(200)
expect(body.data.applied).toBe(0)
})
})
@@ -0,0 +1,171 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { eventBus } from '@/lib/events'
import { ensureInitialized } from '@/lib/init'
import { validateBody } from '@/lib/api/validate'
import { BatchDescribeSchema } from '@/lib/api/schemas'
import { getTemplateById, buildMappingResultFromTemplate } from '@/lib/bookkeeping/booking-templates'
import { createTransactionJournalEntry } from '@/lib/bookkeeping/transaction-entries'
import { saveUserMappingRule } from '@/lib/bookkeeping/mapping-engine'
import type { Transaction, EntityType, TransactionCategory } from '@/types'
ensureInitialized()
export async function POST(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const validation = await validateBody(request, BatchDescribeSchema)
if (!validation.success) return validation.response
const { merchant_name, template_id, is_business, user_description } = validation.data
// Look up the template
const template = getTemplateById(template_id)
if (!template) {
return NextResponse.json({ error: 'Invalid template_id' }, { status: 400 })
}
// Fetch entity type and fiscal year start
const { data: settings } = await supabase
.from('company_settings')
.select('entity_type, fiscal_year_start_month')
.eq('user_id', user.id)
.single()
const entityType: EntityType = (settings?.entity_type as EntityType) || 'enskild_firma'
const fiscalYearStartMonth: number = settings?.fiscal_year_start_month ?? 1
// Fetch all uncategorized transactions from the specified merchant (limit 50)
const { data: transactions, error: fetchError } = await supabase
.from('transactions')
.select('*')
.eq('user_id', user.id)
.eq('merchant_name', merchant_name)
.is('journal_entry_id', null)
.order('date', { ascending: true })
.limit(50)
if (fetchError || !transactions || transactions.length === 0) {
return NextResponse.json({
data: { applied: 0, errors: [] },
})
}
const finalCategory: TransactionCategory = is_business
? template.fallback_category
: 'private'
let applied = 0
const errors: string[] = []
for (const tx of transactions) {
try {
const mappingResult = buildMappingResultFromTemplate(
template,
tx as Transaction,
entityType
)
// Ensure fiscal period exists
const txDate = new Date(tx.date)
const txMonth = txDate.getMonth() + 1
const txYear = txDate.getFullYear()
let periodStartYear: number
if (fiscalYearStartMonth === 1) {
periodStartYear = txYear
} else if (txMonth >= fiscalYearStartMonth) {
periodStartYear = txYear
} else {
periodStartYear = txYear - 1
}
const startMonth = String(fiscalYearStartMonth).padStart(2, '0')
const periodStart = `${periodStartYear}-${startMonth}-01`
const endYear = fiscalYearStartMonth === 1 ? periodStartYear : periodStartYear + 1
const endMonth = fiscalYearStartMonth === 1 ? 12 : fiscalYearStartMonth - 1
const lastDay = new Date(endYear, endMonth, 0).getDate()
const periodEnd = `${endYear}-${String(endMonth).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`
const periodName = fiscalYearStartMonth === 1
? `Räkenskapsår ${periodStartYear}`
: `Räkenskapsår ${periodStartYear}/${endYear}`
await supabase
.from('fiscal_periods')
.upsert({
user_id: user.id,
name: periodName,
period_start: periodStart,
period_end: periodEnd,
}, { onConflict: 'user_id,period_start,period_end' })
// Create journal entry
let journalEntryId: string | null = null
try {
const journalEntry = await createTransactionJournalEntry(
user.id,
tx as Transaction,
mappingResult
)
if (journalEntry) {
journalEntryId = journalEntry.id
}
} catch (err) {
console.error(`[batch-describe] Journal entry failed for ${tx.id}:`, err)
}
// Update the transaction
await supabase
.from('transactions')
.update({
is_business,
category: finalCategory,
journal_entry_id: journalEntryId,
})
.eq('id', tx.id)
await eventBus.emit({
type: 'transaction.categorized',
payload: {
transaction: tx as Transaction,
account: mappingResult.debit_account,
taxCode: mappingResult.vat_lines[0]?.account_number || '',
userId: user.id,
},
})
applied++
} catch (err) {
const msg = err instanceof Error ? err.message : 'Unknown error'
errors.push(`${tx.id}: ${msg}`)
}
}
// Save a mapping rule for future auto-categorization
if (applied > 0) {
try {
const sampleTx = transactions[0] as Transaction
const sampleResult = buildMappingResultFromTemplate(template, sampleTx, entityType)
await saveUserMappingRule(
user.id,
merchant_name,
sampleResult.debit_account,
sampleResult.credit_account,
!is_business,
user_description,
template_id
)
} catch {
// Non-critical
}
}
return NextResponse.json({
data: { applied, errors },
})
}
@@ -3,6 +3,24 @@ import { NextResponse } from 'next/server'
import { getSuggestedCategories, mergeAiSuggestions, getSuggestedTemplates, type SuggestedCategory, type SuggestedTemplate } from '@/lib/transactions/category-suggestions' import { getSuggestedCategories, mergeAiSuggestions, getSuggestedTemplates, type SuggestedCategory, type SuggestedTemplate } from '@/lib/transactions/category-suggestions'
import type { Transaction, TransactionCategory, EntityType } from '@/types' import type { Transaction, TransactionCategory, EntityType } from '@/types'
// Minimum confidence threshold — below this, suggestions are considered weak
// and we trigger on-demand AI categorization to get better results.
const WEAK_SUGGESTION_THRESHOLD = 0.55
/**
* Check if suggestions are "weak" — only history-based fallbacks
* with no strong rule/pattern/AI match.
*/
function hasWeakSuggestions(result: SuggestedCategory[]): boolean {
if (result.length === 0) return true
// Weak if the best suggestion is below threshold
const bestConfidence = Math.max(...result.map((s) => s.confidence))
if (bestConfidence < WEAK_SUGGESTION_THRESHOLD) return true
// Weak if all suggestions are from history only (no rule/pattern/ai match)
if (result.every((s) => s.source === 'history')) return true
return false
}
/** /**
* POST /api/transactions/suggest-categories * POST /api/transactions/suggest-categories
* Batch endpoint for getting category suggestions for multiple transactions * Batch endpoint for getting category suggestions for multiple transactions
@@ -70,11 +88,18 @@ export async function POST(request: Request) {
.eq('extension_id', 'ai-categorization') .eq('extension_id', 'ai-categorization')
.in('key', aiKeys) .in('key', aiKeys)
const aiSuggestionsMap: Record<string, { category: string; basAccount: string; confidence: number; reasoning: string }> = {} type AiSuggestion = { category: string; basAccount: string; confidence: number; reasoning: string }
const aiSuggestionsMap: Record<string, AiSuggestion[]> = {}
if (aiRecords) { if (aiRecords) {
for (const record of aiRecords) { for (const record of aiRecords) {
const txId = record.key.replace('suggestion:', '') const txId = record.key.replace('suggestion:', '')
aiSuggestionsMap[txId] = record.value as { category: string; basAccount: string; confidence: number; reasoning: string } const value = record.value
// Handle both single object (old) and array (new) storage formats
if (Array.isArray(value)) {
aiSuggestionsMap[txId] = value as AiSuggestion[]
} else {
aiSuggestionsMap[txId] = [value as AiSuggestion]
}
} }
} }
@@ -86,9 +111,10 @@ export async function POST(request: Request) {
.single() .single()
const entityType = (settings?.entity_type as EntityType) || undefined const entityType = (settings?.entity_type as EntityType) || undefined
// Generate suggestions for each transaction // Generate initial suggestions for each transaction
const suggestions: Record<string, SuggestedCategory[]> = {} const suggestions: Record<string, SuggestedCategory[]> = {}
const template_suggestions: Record<string, SuggestedTemplate[]> = {} const template_suggestions: Record<string, SuggestedTemplate[]> = {}
const needsAiIds: string[] = []
for (const tx of transactions) { for (const tx of transactions) {
let result = getSuggestedCategories( let result = getSuggestedCategories(
@@ -97,14 +123,74 @@ export async function POST(request: Request) {
categoryHistory categoryHistory
) )
// Merge AI suggestions if available // Merge pre-computed AI suggestions if available
const aiSuggestion = aiSuggestionsMap[tx.id] const aiSuggestions = aiSuggestionsMap[tx.id]
if (aiSuggestion) { if (aiSuggestions && aiSuggestions.length > 0) {
result = mergeAiSuggestions(result, [aiSuggestion]) result = mergeAiSuggestions(result, aiSuggestions, tx.amount)
} else if (hasWeakSuggestions(result)) {
// No pre-computed AI suggestion AND rule-based suggestions are weak —
// mark this transaction for on-demand AI categorization
needsAiIds.push(tx.id)
} }
suggestions[tx.id] = result suggestions[tx.id] = result
template_suggestions[tx.id] = getSuggestedTemplates(tx as Transaction, entityType) template_suggestions[tx.id] = await getSuggestedTemplates(tx as Transaction, entityType)
}
// Trigger on-demand AI categorization for transactions with weak suggestions
if (needsAiIds.length > 0) {
console.log(
`[suggest-categories] ${needsAiIds.length} transactions have weak suggestions, triggering on-demand AI:`,
needsAiIds.map((id) => {
const tx = transactions.find((t) => t.id === id)
return tx
? { id, description: tx.description, merchant_name: tx.merchant_name, amount: tx.amount }
: { id }
})
)
try {
const { categorizeTransactions } = await import(
'@/extensions/general/ai-categorization'
)
const aiResults = await categorizeTransactions(user.id, needsAiIds)
console.log(
'[suggest-categories] AI categorization results:',
aiResults.map((r) => ({
id: r.transactionId,
category: r.category,
basAccount: r.basAccount,
confidence: r.confidence,
reasoning: r.reasoning,
isPrivate: r.isPrivate,
templateId: r.templateId,
}))
)
// Group AI results by transactionId (AI now returns 2 per transaction)
const groupedAi: Record<string, { category: string; basAccount: string; confidence: number; reasoning: string }[]> = {}
for (const aiResult of aiResults) {
if (!groupedAi[aiResult.transactionId]) {
groupedAi[aiResult.transactionId] = []
}
groupedAi[aiResult.transactionId].push({
category: aiResult.category,
basAccount: aiResult.basAccount,
confidence: aiResult.confidence,
reasoning: aiResult.reasoning,
})
}
for (const [txId, aiSuggestions] of Object.entries(groupedAi)) {
const existing = suggestions[txId] || []
const tx = transactions.find((t) => t.id === txId)
suggestions[txId] = mergeAiSuggestions(existing, aiSuggestions, tx?.amount)
}
} catch (err) {
// AI categorization is non-blocking — log and continue with existing suggestions
console.error('[suggest-categories] On-demand AI categorization failed:', err)
}
} }
return NextResponse.json({ suggestions, template_suggestions }) return NextResponse.json({ suggestions, template_suggestions })
+8
View File
@@ -198,6 +198,13 @@ export default async function RootPage() {
streak_count: streakCount, 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 ( return (
<div className="min-h-screen bg-background"> <div className="min-h-screen bg-background">
<DashboardNav companyName={settings.company_name || 'Min verksamhet'} entityType={settings.entity_type || 'enskild_firma'} /> <DashboardNav companyName={settings.company_name || 'Min verksamhet'} entityType={settings.entity_type || 'enskild_firma'} />
@@ -220,6 +227,7 @@ export default async function RootPage() {
receiptQueue, receiptQueue,
missingUnderlagCount, missingUnderlagCount,
}} }}
enabledExtensions={enabledToggles || []}
/> />
</div> </div>
<ChatWidget /> <ChatWidget />
+5 -1
View File
@@ -206,8 +206,11 @@ export default function JournalEntryList({ periodId }: Props) {
</div> </div>
</button> </button>
{isExpanded && lines.length > 0 && ( {isExpanded && (
<CardContent className="pt-0 pb-4"> <CardContent className="pt-0 pb-4">
{lines.length === 0 ? (
<p className="text-sm text-muted-foreground py-2">Inga kontorader hittades för denna verifikation.</p>
) : (
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b text-left text-muted-foreground"> <tr className="border-b text-left text-muted-foreground">
@@ -261,6 +264,7 @@ export default function JournalEntryList({ periodId }: Props) {
</tr> </tr>
</tfoot> </tfoot>
</table> </table>
)}
<JournalEntryAttachments <JournalEntryAttachments
journalEntryId={entry.id} journalEntryId={entry.id}
-12
View File
@@ -1,12 +0,0 @@
export { CalendarHeader } from './CalendarHeader'
export { CalendarGrid } from './CalendarGrid'
export { CalendarDayCell } from './CalendarDayCell'
export { PaymentCalendar } from './PaymentCalendar'
export { PaymentSummaryCard } from './PaymentSummaryCard'
export { DayDetailModal } from './DayDetailModal'
export { DeadlineCard } from './DeadlineCard'
export { DeadlineFilters } from './DeadlineFilters'
export { DeadlineForm } from './DeadlineForm'
export { DeadlineList } from './DeadlineList'
export { UpcomingDeadlinesWidget } from './UpcomingDeadlinesWidget'
export { TaxTodoWidget } from './TaxTodoWidget'
+79 -17
View File
@@ -12,7 +12,8 @@ import {
getEnhancedTaxWarningStatus getEnhancedTaxWarningStatus
} from '@/lib/tax/calculator' } from '@/lib/tax/calculator'
import FSkattWarningCard from '@/components/dashboard/FSkattWarningCard' 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 NewUserChecklist from '@/components/onboarding/NewUserChecklist'
import { import {
TrendingUp, TrendingUp,
@@ -27,9 +28,11 @@ import {
Landmark, Landmark,
CheckCircle2, CheckCircle2,
ClipboardList, ClipboardList,
MessageCircle,
FileWarning, FileWarning,
} from 'lucide-react' } 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' import type { CompanySettings, EntityType, Deadline, ReceiptQueueSummary, OnboardingProgress } from '@/types'
interface DashboardContentProps { interface DashboardContentProps {
@@ -51,11 +54,31 @@ interface DashboardContentProps {
missingUnderlagCount: number missingUnderlagCount: number
} }
onboardingProgress?: OnboardingProgress 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 [showAllAlerts, setShowAllAlerts] = useState(false)
const [showMore, setShowMore] = 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 entityType = (settings?.entity_type as EntityType) || 'enskild_firma'
const preliminaryTaxMonthly = settings?.preliminary_tax_monthly || 0 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 visibleAlerts = showAllAlerts ? alertItems : alertItems.slice(0, MAX_VISIBLE_ALERTS)
const hasMoreAlerts = alertItems.length > 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 // Quick action items
const quickActions = [ 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' }[] = [] const todoItems: { label: string; href: string; count: number; variant: 'destructive' | 'warning' | 'default' }[] = []
if (passedDeadlines.length > 0) { 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) { if (summary.overdueInvoicesCount > 0) {
todoItems.push({ label: 'förfallna fakturor', href: '/invoices?status=unpaid', count: summary.overdueInvoicesCount, variant: 'destructive' }) todoItems.push({ label: 'förfallna fakturor', href: '/invoices?status=unpaid', count: summary.overdueInvoicesCount, variant: 'destructive' })
@@ -431,18 +462,42 @@ export default function DashboardContent({ firstName, settings, summary, onboard
</Link> </Link>
) )
})} })}
{/* AI assistant quick action */} {/* Extension quick actions */}
<button onClick={openAiChat} className="group text-left"> {extensionQuickActions.map((action) => {
<div className="flex items-center gap-3 px-4 py-3 rounded-xl border border-border/40 hover:bg-muted/30 transition-colors duration-150"> const Icon = resolveIcon(action.icon)
<div className="p-2 rounded-lg bg-muted/50"> if (action.href) {
<MessageCircle className="h-4 w-4 text-muted-foreground" /> return (
</div> <Link key={action.key} href={action.href} className="group">
<div className="min-w-0"> <div className="flex items-center gap-3 px-4 py-3 rounded-xl border border-border/40 hover:bg-muted/30 transition-colors duration-150">
<p className="text-sm font-medium truncate">AI-assistent</p> <div className="p-2 rounded-lg bg-muted/50">
<p className="text-xs text-muted-foreground truncate hidden md:block">Fråga om bokföring</p> <Icon className="h-4 w-4 text-muted-foreground" />
</div> </div>
</div> <div className="min-w-0">
</button> <p className="text-sm font-medium truncate">{action.label}</p>
<p className="text-xs text-muted-foreground truncate hidden md:block">{action.description}</p>
</div>
</div>
</Link>
)
}
return (
<button
key={action.key}
onClick={() => window.dispatchEvent(new Event(action.event!))}
className="group text-left"
>
<div className="flex items-center gap-3 px-4 py-3 rounded-xl border border-border/40 hover:bg-muted/30 transition-colors duration-150">
<div className="p-2 rounded-lg bg-muted/50">
<Icon className="h-4 w-4 text-muted-foreground" />
</div>
<div className="min-w-0">
<p className="text-sm font-medium truncate">{action.label}</p>
<p className="text-xs text-muted-foreground truncate hidden md:block">{action.description}</p>
</div>
</div>
</button>
)
})}
</div> </div>
</section> </section>
@@ -453,6 +508,13 @@ export default function DashboardContent({ firstName, settings, summary, onboard
</section> </section>
)} )}
{/* Tax todo widget — visible when there are incomplete tax deadlines */}
{summary.deadlines?.some(d => d.deadline_type === 'tax' && !d.is_completed) && (
<section className="mb-10">
<TaxTodoWidget deadlines={summary.deadlines} />
</section>
)}
{/* Alerts section — always visible */} {/* Alerts section — always visible */}
{alertItems.length > 0 && ( {alertItems.length > 0 && (
<section id="alerts-section" className="mb-10"> <section id="alerts-section" className="mb-10">
+1 -1
View File
@@ -46,7 +46,7 @@ interface NavItem {
// All nav items for sidebar and mobile drawer // All nav items for sidebar and mobile drawer
const navItems: NavItem[] = [ const navItems: NavItem[] = [
{ href: '/', label: 'Översikt', icon: LayoutDashboard, group: 'main' }, { 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: '/invoices', label: 'Fakturor', icon: Receipt, group: 'finans' },
{ href: '/customers', label: 'Kunder', icon: Users, group: 'finans' }, { href: '/customers', label: 'Kunder', icon: Users, group: 'finans' },
{ href: '/suppliers', label: 'Leverantörer', icon: Building2, group: 'finans' }, { href: '/suppliers', label: 'Leverantörer', icon: Building2, group: 'finans' },
@@ -231,9 +231,9 @@ export function TaxTodoWidget({ deadlines, onStatusChange }: TaxTodoWidgetProps)
</p> </p>
)} )}
<Link href="/calendar" className="block"> <Link href="/deadlines" className="block">
<Button variant="ghost" className="w-full justify-between"> <Button variant="ghost" className="w-full justify-between">
Visa alla skattedeadlines Visa alla deadlines
<ChevronRight className="h-4 w-4" /> <ChevronRight className="h-4 w-4" />
</Button> </Button>
</Link> </Link>
@@ -201,9 +201,9 @@ export function UpcomingDeadlinesWidget({ deadlines, maxItems = 5, onStatusChang
) )
})} })}
<Link href="/calendar" className="block"> <Link href="/deadlines" className="block">
<Button variant="ghost" className="w-full justify-between mt-2"> <Button variant="ghost" className="w-full justify-between mt-2">
Visa kalender Visa alla deadlines
<ChevronRight className="h-4 w-4" /> <ChevronRight className="h-4 w-4" />
</Button> </Button>
</Link> </Link>
+6
View File
@@ -0,0 +1,6 @@
export { DeadlineCard } from './DeadlineCard'
export { DeadlineFilters } from './DeadlineFilters'
export { DeadlineForm } from './DeadlineForm'
export { DeadlineList } from './DeadlineList'
export { UpcomingDeadlinesWidget } from './UpcomingDeadlinesWidget'
export { TaxTodoWidget } from './TaxTodoWidget'
@@ -3,10 +3,11 @@
import { useState, useEffect, useCallback } from 'react' import { useState, useEffect, useCallback } from 'react'
import { createClient } from '@/lib/supabase/client' import { createClient } from '@/lib/supabase/client'
import { useToast } from '@/components/ui/use-toast' 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' import type { Invoice, Deadline } from '@/types'
export default function CalendarPage() { export default function CalendarWorkspace({ userId }: WorkspaceComponentProps) {
const [invoices, setInvoices] = useState<Invoice[]>([]) const [invoices, setInvoices] = useState<Invoice[]>([])
const [deadlines, setDeadlines] = useState<Deadline[]>([]) const [deadlines, setDeadlines] = useState<Deadline[]>([])
const [customers, setCustomers] = useState<{ id: string; name: string }[]>([]) const [customers, setCustomers] = useState<{ id: string; name: string }[]>([])
@@ -18,7 +19,6 @@ export default function CalendarPage() {
setIsLoading(true) setIsLoading(true)
try { try {
// Fetch invoices with customer names
const { data: invoicesData, error: invoicesError } = await supabase const { data: invoicesData, error: invoicesError } = await supabase
.from('invoices') .from('invoices')
.select('*, customer:customers(name)') .select('*, customer:customers(name)')
@@ -26,7 +26,6 @@ export default function CalendarPage() {
if (invoicesError) throw invoicesError if (invoicesError) throw invoicesError
// Fetch deadlines with customer names
const { data: deadlinesData, error: deadlinesError } = await supabase const { data: deadlinesData, error: deadlinesError } = await supabase
.from('deadlines') .from('deadlines')
.select('*, customer:customers(name)') .select('*, customer:customers(name)')
@@ -34,7 +33,6 @@ export default function CalendarPage() {
if (deadlinesError) throw deadlinesError if (deadlinesError) throw deadlinesError
// Fetch customers for the form
const { data: customersData, error: customersError } = await supabase const { data: customersData, error: customersError } = await supabase
.from('customers') .from('customers')
.select('id, name') .select('id, name')
@@ -45,10 +43,10 @@ export default function CalendarPage() {
setInvoices(invoicesData || []) setInvoices(invoicesData || [])
setDeadlines(deadlinesData || []) setDeadlines(deadlinesData || [])
setCustomers(customersData || []) setCustomers(customersData || [])
} catch (error) { } catch {
toast({ toast({
title: 'Fel', title: 'Fel',
description: 'Kunde inte hämta data', description: 'Kunde inte hamta data',
variant: 'destructive', variant: 'destructive',
}) })
} finally { } finally {
@@ -101,7 +99,7 @@ export default function CalendarPage() {
}) })
fetchData() fetchData()
} catch (error) { } catch {
toast({ toast({
title: 'Fel', title: 'Fel',
description: 'Kunde inte uppdatera deadline', description: 'Kunde inte uppdatera deadline',
@@ -112,31 +110,20 @@ export default function CalendarPage() {
if (isLoading) { if (isLoading) {
return ( return (
<div className="space-y-6"> <div className="animate-pulse">
<div> <div className="h-10 bg-muted rounded w-48 mb-4" />
<h1 className="text-3xl font-bold tracking-tight">Kalender</h1> <div className="h-96 bg-muted rounded" />
</div>
<div className="animate-pulse">
<div className="h-10 bg-muted rounded w-48 mb-4" />
<div className="h-96 bg-muted rounded" />
</div>
</div> </div>
) )
} }
return ( return (
<div className="space-y-6"> <PaymentCalendar
<div> invoices={invoices}
<h1 className="text-3xl font-bold tracking-tight">Kalender</h1> deadlines={deadlines}
</div> customers={customers}
onDeadlineCreate={handleDeadlineCreate}
<PaymentCalendar onDeadlineToggle={handleDeadlineToggle}
invoices={invoices} />
deadlines={deadlines}
customers={customers}
onDeadlineCreate={handleDeadlineCreate}
onDeadlineToggle={handleDeadlineToggle}
/>
</div>
) )
} }
@@ -0,0 +1,15 @@
'use client'
import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry'
import EmptyExtensionState from '@/components/extensions/shared/EmptyExtensionState'
import { TextSearch } from 'lucide-react'
export default function UserDescriptionMatchWorkspace({ userId }: WorkspaceComponentProps) {
return (
<EmptyExtensionState
title="Beskrivningsmatchning"
description="Beskriv transaktioner med egna ord vid kategorisering. Systemet lär sig automatiskt och applicerar på framtida transaktioner från samma leverantör."
icon={<TextSearch className="h-12 w-12 text-muted-foreground/40 mb-4" />}
/>
)
}
@@ -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<Step>('describe')
const [description, setDescription] = useState('')
const [isSearching, setIsSearching] = useState(false)
const [isBooking, setIsBooking] = useState(false)
const [isBatchApplying, setIsBatchApplying] = useState(false)
const [describeResult, setDescribeResult] = useState<DescribeResult | null>(null)
const [selectedTemplateId, setSelectedTemplateId] = useState<string | null>(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 (
<Dialog open={open} onOpenChange={(isBooking || isBatchApplying) ? undefined : handleOpenChange}>
<DialogContent className="max-w-md max-h-[90vh] flex flex-col">
<DialogHeader>
<DialogTitle>
{step === 'describe' && 'Beskriv transaktion'}
{step === 'pick' && 'Valj mall'}
{step === 'batch' && 'Bokfor liknande'}
</DialogTitle>
<DialogDescription>
{step === 'describe' && 'Beskriv vad transaktionen galler sa hittar vi ratt bokforingsmall'}
{step === 'pick' && 'Valj den mall som stammer bast'}
{step === 'batch' && 'Transaktion bokford!'}
</DialogDescription>
</DialogHeader>
{/* Transaction summary - shown in describe and pick steps */}
{(step === 'describe' || step === 'pick') && (
<div className="flex items-center gap-3 rounded-lg border p-3">
<div
className={`h-9 w-9 rounded-full flex items-center justify-center flex-shrink-0 ${
isIncome
? 'bg-success/10 text-success'
: 'bg-destructive/10 text-destructive'
}`}
>
{isIncome ? (
<ArrowUpRight className="h-4 w-4" />
) : (
<ArrowDownRight className="h-4 w-4" />
)}
</div>
<div className="flex-1 min-w-0">
<p className="font-medium text-sm truncate">{transaction.description}</p>
<p className="text-xs text-muted-foreground">{formatDate(transaction.date)}</p>
</div>
<p className={`font-medium text-sm flex-shrink-0 ${isIncome ? 'text-success' : ''}`}>
{isIncome ? '+' : ''}
{formatCurrency(transaction.amount, transaction.currency)}
</p>
</div>
)}
{/* Step 1: Describe */}
{step === 'describe' && (
<div className="space-y-4">
<Textarea
placeholder="Beskriv vad transaktionen galler, t.ex. 'lunch med kund' eller 'kontorsmaterial'"
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey && description.trim().length >= 3) {
e.preventDefault()
handleSearch()
}
}}
/>
<div className="flex flex-wrap gap-1.5">
{getExamplePrompts(transaction).map((prompt) => (
<button
key={prompt}
type="button"
className="text-xs px-2.5 py-1 rounded-full border bg-muted/50 hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
onClick={() => setDescription(prompt)}
>
{prompt}
</button>
))}
</div>
<Button
className="w-full"
disabled={description.trim().length < 3 || isSearching}
onClick={handleSearch}
>
{isSearching ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Search className="mr-2 h-4 w-4" />
)}
{isSearching ? 'Soker...' : 'Sok'}
</Button>
</div>
)}
{/* Step 2: Pick template */}
{step === 'pick' && describeResult && (
<div className="space-y-4 min-h-0 flex flex-col">
{describeResult.needs_more_detail && (
<div className="flex items-start gap-2 p-3 rounded-lg bg-amber-500/10 text-amber-700 dark:text-amber-400 text-sm">
<AlertTriangle className="h-4 w-4 flex-shrink-0 mt-0.5" />
<p>Resultaten ar osakra. Forsok beskriv mer detaljerat for battre traffar.</p>
</div>
)}
<div className="overflow-y-auto max-h-[40vh] space-y-2 pr-1">
{describeResult.templates.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4">
Inga matchande mallar hittades. Forsok med en annan beskrivning.
</p>
) : (
describeResult.templates.map((template) => (
<Card
key={template.template_id}
className={`cursor-pointer transition-colors hover:border-primary/50 ${
selectedTemplateId === template.template_id
? 'border-primary bg-primary/5'
: ''
}`}
onClick={() => setSelectedTemplateId(template.template_id)}
>
<CardContent className="py-3 px-4">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<p className="font-medium text-sm">{template.name_sv}</p>
{template.description_sv && (
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">
{template.description_sv}
</p>
)}
<div className="flex flex-wrap items-center gap-1.5 mt-1.5">
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">
D: {formatAccountWithName(template.debit_account)}
</Badge>
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">
K: {formatAccountWithName(template.credit_account)}
</Badge>
{template.vat_treatment && template.vat_treatment !== 'exempt' && (
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
Moms {Math.round(template.vat_rate * 100)}%
</Badge>
)}
{template.vat_treatment === 'exempt' && (
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
Momsfritt
</Badge>
)}
{template.deductibility === 'non_deductible' && (
<Badge variant="outline" className="text-[10px] px-1.5 py-0 text-amber-600 border-amber-300">
Ej avdragsgill
</Badge>
)}
{template.deductibility === 'conditional' && (
<Badge variant="outline" className="text-[10px] px-1.5 py-0 text-amber-600 border-amber-300">
Villkorligt avdrag
</Badge>
)}
</div>
{(template.deductibility_note_sv || template.special_rules_sv) && selectedTemplateId === template.template_id && (
<div className="mt-2 space-y-1">
{template.deductibility_note_sv && (
<p className="text-[11px] text-amber-600 dark:text-amber-400">
{template.deductibility_note_sv}
</p>
)}
{template.special_rules_sv && (
<p className="text-[11px] text-muted-foreground italic">
{template.special_rules_sv}
</p>
)}
</div>
)}
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<Badge
variant={template.confidence >= 0.7 ? 'default' : 'outline'}
className="text-[10px] px-1.5 py-0"
>
{Math.round(template.confidence * 100)}%
</Badge>
{selectedTemplateId === template.template_id && (
<Check className="h-4 w-4 text-primary" />
)}
</div>
</div>
</CardContent>
</Card>
))
)}
</div>
{/* Journal entry preview for selected template */}
{selectedTemplateId && (() => {
const tmpl = describeResult.templates.find(t => t.template_id === selectedTemplateId)
if (!tmpl) return null
return (
<JournalEntryPreview
amount={transaction.amount}
currency={transaction.currency}
templateDebitAccount={tmpl.debit_account}
templateCreditAccount={tmpl.credit_account}
templateVatRate={tmpl.vat_rate}
/>
)
})()}
<div className="flex gap-2 pt-2">
<Button
variant="ghost"
className="flex-shrink-0"
onClick={() => {
setStep('describe')
setSelectedTemplateId(null)
}}
>
<ArrowLeft className="mr-2 h-4 w-4" />
Beskriv igen
</Button>
<Button
className="flex-1"
disabled={!selectedTemplateId || isBooking}
onClick={handleBook}
>
{isBooking ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Check className="mr-2 h-4 w-4" />
)}
{isBooking ? 'Bokfor...' : 'Bokfor'}
</Button>
</div>
</div>
)}
{/* Step 3: Batch offer */}
{step === 'batch' && describeResult && (
<div className="space-y-4">
<div className="flex items-center gap-3 p-4 rounded-lg bg-success/10">
<CheckCircle2 className="h-6 w-6 text-success flex-shrink-0" />
<p className="text-sm font-medium">Transaktionen ar bokford!</p>
</div>
<p className="text-sm text-muted-foreground">
Det finns ytterligare{' '}
<span className="font-medium text-foreground">
{describeResult.batch_candidate_count}
</span>{' '}
obokforda transaktioner fran{' '}
<span className="font-medium text-foreground">
{describeResult.merchant_name}
</span>
. Anvand samma mall?
</p>
<div className="flex gap-2">
<Button
variant="outline"
className="flex-1"
onClick={handleSkipBatch}
disabled={isBatchApplying}
>
Nej, bara den har
</Button>
<Button
className="flex-1"
onClick={handleBatchApply}
disabled={isBatchApplying}
>
{isBatchApplying ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : null}
{isBatchApplying
? 'Bokfor...'
: `Ja, bokfor alla ${describeResult.batch_candidate_count} st`}
</Button>
</div>
</div>
)}
</DialogContent>
</Dialog>
)
}
@@ -3,6 +3,7 @@
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog'
import { formatCurrency, formatDate } from '@/lib/utils' import { formatCurrency, formatDate } from '@/lib/utils'
import { CheckCircle2, AlertTriangle } from 'lucide-react'
import type { TransactionWithInvoice } from './transaction-types' import type { TransactionWithInvoice } from './transaction-types'
interface InvoiceMatchDialogProps { interface InvoiceMatchDialogProps {
@@ -66,6 +67,37 @@ export default function InvoiceMatchDialog({
</div> </div>
</div> </div>
{/* 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 (
<div className="flex items-center gap-2 p-3 rounded-lg bg-success/10 text-success">
<CheckCircle2 className="h-4 w-4 flex-shrink-0" />
<p className="text-sm font-medium">Beloppen stammer</p>
</div>
)
}
const diff = Math.abs(txAmount - invAmount)
return (
<div className="flex items-start gap-2 p-3 rounded-lg bg-amber-500/10 text-amber-700 dark:text-amber-400">
<AlertTriangle className="h-4 w-4 flex-shrink-0 mt-0.5" />
<div className="text-sm">
<p className="font-medium">Beloppen skiljer sig</p>
<p>
Differens: {formatCurrency(diff, transaction.currency)}
{!sameCurrency && ' (olika valutor)'}
</p>
</div>
</div>
)
})()}
{/* What will happen */} {/* What will happen */}
<div className="rounded-lg bg-muted/50 p-4 space-y-2"> <div className="rounded-lg bg-muted/50 p-4 space-y-2">
<p className="text-sm font-medium">Vid bekräftelse:</p> <p className="text-sm font-medium">Vid bekräftelse:</p>
@@ -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 (
<div className="rounded-lg border bg-muted/30 px-3 py-2.5">
<p className="text-xs font-medium text-muted-foreground mb-1.5">Verifikation</p>
<div className="space-y-0.5 font-mono text-xs">
{lines.map((line, i) => (
<div key={i} className="flex items-baseline gap-2">
<span className={`w-12 text-right flex-shrink-0 ${line.side === 'debet' ? 'text-foreground' : 'text-muted-foreground'}`}>
{line.side === 'debet' ? 'Debet' : 'Kredit'}
</span>
<span className="flex-1 truncate">{formatAccountWithName(line.account)}</span>
<span className="flex-shrink-0 tabular-nums">{formatCurrency(line.amount, currency)}</span>
</div>
))}
</div>
</div>
)
}
+32 -8
View File
@@ -8,10 +8,12 @@ import { useToast } from '@/components/ui/use-toast'
import { formatCurrency, formatDate } from '@/lib/utils' import { formatCurrency, formatDate } from '@/lib/utils'
import { ArrowUpRight, ArrowDownRight, Check, Paperclip, ChevronDown, ChevronUp } from 'lucide-react' import { ArrowUpRight, ArrowDownRight, Check, Paperclip, ChevronDown, ChevronUp } from 'lucide-react'
import { getDefaultAccountForCategory } from '@/lib/bookkeeping/category-mapping' import { getDefaultAccountForCategory } from '@/lib/bookkeeping/category-mapping'
import JournalEntryPreview from './JournalEntryPreview'
import AccountCombobox from '@/components/bookkeeping/AccountCombobox' import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
import DocumentUploadZone from '@/components/bookkeeping/DocumentUploadZone' import DocumentUploadZone from '@/components/bookkeeping/DocumentUploadZone'
import type { UploadedFile } from '@/components/bookkeeping/DocumentUploadZone' import type { UploadedFile } from '@/components/bookkeeping/DocumentUploadZone'
import VatTreatmentSelect from './VatTreatmentSelect' import VatTreatmentSelect from './VatTreatmentSelect'
import { VAT_TREATMENT_OPTIONS } from './transaction-types'
import type { TransactionWithInvoice } from './transaction-types' import type { TransactionWithInvoice } from './transaction-types'
import type { TransactionCategory, VatTreatment, BASAccount } from '@/types' import type { TransactionCategory, VatTreatment, BASAccount } from '@/types'
@@ -49,6 +51,7 @@ export default function QuickReviewDialog({
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [uploadedFiles, setUploadedFiles] = useState<UploadedFile[]>([]) const [uploadedFiles, setUploadedFiles] = useState<UploadedFile[]>([])
const [showUploadZone, setShowUploadZone] = useState(false) const [showUploadZone, setShowUploadZone] = useState(false)
const [showVatDropdown, setShowVatDropdown] = useState(false)
// Handle account changes — clear VAT for liability/equity accounts (class 2) // Handle account changes — clear VAT for liability/equity accounts (class 2)
const handleAccountChange = useCallback((account: string) => { const handleAccountChange = useCallback((account: string) => {
@@ -174,6 +177,15 @@ export default function QuickReviewDialog({
</div> </div>
</div> </div>
{/* Journal entry preview */}
<JournalEntryPreview
amount={transaction.amount}
currency={transaction.currency}
category={category}
vatTreatment={isLiabilityAccount ? 'none' : vatTreatment}
accountOverride={accountOverride}
/>
{/* Account */} {/* Account */}
<div> <div>
<label className="text-sm font-medium text-muted-foreground">Konto</label> <label className="text-sm font-medium text-muted-foreground">Konto</label>
@@ -190,14 +202,26 @@ export default function QuickReviewDialog({
<div> <div>
<label className="text-sm font-medium text-muted-foreground">Momsbehandling</label> <label className="text-sm font-medium text-muted-foreground">Momsbehandling</label>
<div className="mt-1"> <div className="mt-1">
<VatTreatmentSelect {isLiabilityAccount ? (
value={isLiabilityAccount ? 'none' : vatTreatment} <p className="text-sm text-muted-foreground">
onValueChange={setVatTreatment} Ingen moms for skuld-/eget kapital-konton
disabled={isLiabilityAccount} </p>
/> ) : showVatDropdown ? (
{isLiabilityAccount && ( <VatTreatmentSelect
<p className="text-xs text-muted-foreground mt-1"> value={vatTreatment}
Ingen moms för skuld-/eget kapital-konton onValueChange={setVatTreatment}
/>
) : (
<p className="text-sm">
{VAT_TREATMENT_OPTIONS.find(o => o.value === vatTreatment)?.label || 'Ingen moms'}
{' '}
<button
type="button"
className="text-xs text-primary hover:underline"
onClick={() => setShowVatDropdown(true)}
>
Andra
</button>
</p> </p>
)} )}
</div> </div>
@@ -10,18 +10,22 @@ import VatTreatmentSelect from './VatTreatmentSelect'
import { formatCurrency, formatDate } from '@/lib/utils' import { formatCurrency, formatDate } from '@/lib/utils'
import { checkExpenseWarnings } from '@/lib/tax/expense-warnings' import { checkExpenseWarnings } from '@/lib/tax/expense-warnings'
import { getDefaultAccountForCategory, getDefaultVatTreatmentForCategory } from '@/lib/bookkeeping/category-mapping' import { getDefaultAccountForCategory, getDefaultVatTreatmentForCategory } from '@/lib/bookkeeping/category-mapping'
import JournalEntryPreview from './JournalEntryPreview'
import AccountCombobox from '@/components/bookkeeping/AccountCombobox' import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
import DocumentUploadZone from '@/components/bookkeeping/DocumentUploadZone' import DocumentUploadZone from '@/components/bookkeeping/DocumentUploadZone'
import type { UploadedFile } 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 { 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 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 { interface SwipeCategorizationViewProps {
transactions: TransactionWithInvoice[] transactions: TransactionWithInvoice[]
suggestions?: Record<string, SuggestedCategory[]> suggestions?: Record<string, SuggestedCategory[]>
templateSuggestions?: Record<string, SuggestedTemplate[]>
onCategorize: CategorizeHandler onCategorize: CategorizeHandler
onMatchInvoice?: MatchInvoiceHandler onMatchInvoice?: MatchInvoiceHandler
onClose: () => void onClose: () => void
@@ -33,6 +37,7 @@ const incomeCategories = INCOME_CATEGORIES
export default function SwipeCategorizationView({ export default function SwipeCategorizationView({
transactions, transactions,
suggestions, suggestions,
templateSuggestions,
onCategorize, onCategorize,
onMatchInvoice, onMatchInvoice,
onClose, onClose,
@@ -52,6 +57,8 @@ export default function SwipeCategorizationView({
const [accounts, setAccounts] = useState<BASAccount[]>([]) const [accounts, setAccounts] = useState<BASAccount[]>([])
const [uploadedFiles, setUploadedFiles] = useState<UploadedFile[]>([]) const [uploadedFiles, setUploadedFiles] = useState<UploadedFile[]>([])
const [showUploadZone, setShowUploadZone] = useState(false) 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) // Clear VAT treatment when switching to a liability/equity account (class 2)
useEffect(() => { useEffect(() => {
@@ -113,6 +120,7 @@ export default function SwipeCategorizationView({
setPendingCategory(category) setPendingCategory(category)
setAccountOverride(defaultAccount) setAccountOverride(defaultAccount)
setVatTreatment(defaultVat ?? 'none') setVatTreatment(defaultVat ?? 'none')
setShowVatDropdown(false)
setShowCategorySelect(false) setShowCategorySelect(false)
setShowReviewStep(true) setShowReviewStep(true)
setError(null) setError(null)
@@ -366,6 +374,15 @@ export default function SwipeCategorizationView({
</div> </div>
</div> </div>
{/* Journal entry preview */}
<JournalEntryPreview
amount={currentTransaction.amount}
currency={currentTransaction.currency}
category={pendingCategory}
vatTreatment={isLiabilityAccount ? 'none' : vatTreatment}
accountOverride={accountOverride}
/>
{/* Account override */} {/* Account override */}
<div> <div>
<label className="text-sm font-medium text-muted-foreground">Konto</label> <label className="text-sm font-medium text-muted-foreground">Konto</label>
@@ -382,15 +399,27 @@ export default function SwipeCategorizationView({
<div> <div>
<label className="text-sm font-medium text-muted-foreground">Momsbehandling</label> <label className="text-sm font-medium text-muted-foreground">Momsbehandling</label>
<div className="mt-1"> <div className="mt-1">
<VatTreatmentSelect {isLiabilityAccount ? (
value={isLiabilityAccount ? 'none' : vatTreatment} <p className="text-sm text-muted-foreground">
onValueChange={setVatTreatment}
disabled={isLiabilityAccount}
/>
{isLiabilityAccount && (
<p className="text-xs text-muted-foreground mt-1">
Ingen moms for skuld-/eget kapital-konton Ingen moms for skuld-/eget kapital-konton
</p> </p>
) : showVatDropdown ? (
<VatTreatmentSelect
value={vatTreatment}
onValueChange={setVatTreatment}
/>
) : (
<p className="text-sm">
{VAT_TREATMENT_OPTIONS.find(o => o.value === vatTreatment)?.label || 'Ingen moms'}
{' '}
<button
type="button"
className="text-xs text-primary hover:underline"
onClick={() => setShowVatDropdown(true)}
>
Andra
</button>
</p>
)} )}
</div> </div>
</div> </div>
@@ -641,7 +670,7 @@ export default function SwipeCategorizationView({
<div className="flex flex-col"> <div className="flex flex-col">
<span className="text-sm font-medium">{suggestion.label}</span> <span className="text-sm font-medium">{suggestion.label}</span>
{suggestion.account && ( {suggestion.account && (
<span className="text-xs text-muted-foreground">{suggestion.account}</span> <span className="text-xs text-muted-foreground">{formatAccountWithName(suggestion.account)}</span>
)} )}
</div> </div>
</Button> </Button>
@@ -650,6 +679,45 @@ export default function SwipeCategorizationView({
</div> </div>
)} )}
{/* 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 (
<div className="space-y-2">
<p className="text-xs text-muted-foreground text-center">Osaker? Prova dessa mallar:</p>
<div className="grid grid-cols-2 gap-2">
{templates.slice(0, 3).map((tmpl) => (
<Button
key={tmpl.template_id}
variant="outline"
className="h-auto py-2.5 px-3 text-left justify-start border-dashed"
onClick={() => setShowDescribeDialog(true)}
disabled={isProcessing}
>
<span className="text-sm">{tmpl.name_sv}</span>
</Button>
))}
</div>
</div>
)
}
return null
})()}
{/* Describe transaction button */}
<Button
variant="outline"
className="w-full"
onClick={() => setShowDescribeDialog(true)}
disabled={isProcessing}
>
<MessageSquareText className="mr-2 h-4 w-4" />
Beskriv transaktion...
</Button>
{/* Categorization button */} {/* Categorization button */}
<Button <Button
className="w-full" className="w-full"
@@ -678,6 +746,20 @@ export default function SwipeCategorizationView({
</Button> </Button>
</div> </div>
</div> </div>
<DescribeTransactionDialog
open={showDescribeDialog}
onOpenChange={setShowDescribeDialog}
transaction={currentTransaction}
onCategorized={() => {
setShowDescribeDialog(false)
moveToNext()
}}
onBatchApplied={() => {
setShowDescribeDialog(false)
moveToNext()
}}
/>
</div> </div>
) )
} }
@@ -6,14 +6,16 @@ import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Checkbox } from '@/components/ui/checkbox' import { Checkbox } from '@/components/ui/checkbox'
import { formatCurrency, formatDate } from '@/lib/utils' import { formatCurrency, formatDate } from '@/lib/utils'
import { ArrowUpRight, ArrowDownRight, FileText, Loader2 } from 'lucide-react' import { ArrowUpRight, ArrowDownRight, FileText, Loader2, MessageSquareText } from 'lucide-react'
import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from '@/components/ui/info-tooltip' import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from '@/components/ui/info-tooltip'
import { formatAccountWithName } from '@/lib/bookkeeping/client-account-names'
import type { TransactionWithInvoice, CategorizeHandler } from './transaction-types' import type { TransactionWithInvoice, CategorizeHandler } from './transaction-types'
import type { SuggestedCategory } from '@/lib/transactions/category-suggestions' import type { SuggestedCategory, SuggestedTemplate } from '@/lib/transactions/category-suggestions'
interface TransactionInboxCardProps { interface TransactionInboxCardProps {
transaction: TransactionWithInvoice transaction: TransactionWithInvoice
suggestions?: SuggestedCategory[] suggestions?: SuggestedCategory[]
templateSuggestions?: SuggestedTemplate[]
processingId: string | null processingId: string | null
isBatchMode: boolean isBatchMode: boolean
isSelected: boolean isSelected: boolean
@@ -22,6 +24,7 @@ interface TransactionInboxCardProps {
onMarkPrivate: (id: string) => void onMarkPrivate: (id: string) => void
onOpenMatchDialog: (transaction: TransactionWithInvoice) => void onOpenMatchDialog: (transaction: TransactionWithInvoice) => void
onOpenCategoryDialog: (transaction: TransactionWithInvoice) => void onOpenCategoryDialog: (transaction: TransactionWithInvoice) => void
onOpenDescribe?: (transaction: TransactionWithInvoice) => void
onOpenQuickReview?: (transaction: TransactionWithInvoice, suggestion: SuggestedCategory) => void onOpenQuickReview?: (transaction: TransactionWithInvoice, suggestion: SuggestedCategory) => void
onToggleSelect: (id: string) => void onToggleSelect: (id: string) => void
onAnimationComplete?: (id: string) => void onAnimationComplete?: (id: string) => void
@@ -30,6 +33,7 @@ interface TransactionInboxCardProps {
export default function TransactionInboxCard({ export default function TransactionInboxCard({
transaction, transaction,
suggestions, suggestions,
templateSuggestions,
processingId, processingId,
isBatchMode, isBatchMode,
isSelected, isSelected,
@@ -38,6 +42,7 @@ export default function TransactionInboxCard({
onMarkPrivate, onMarkPrivate,
onOpenMatchDialog, onOpenMatchDialog,
onOpenCategoryDialog, onOpenCategoryDialog,
onOpenDescribe,
onOpenQuickReview, onOpenQuickReview,
onToggleSelect, onToggleSelect,
onAnimationComplete, onAnimationComplete,
@@ -49,6 +54,8 @@ export default function TransactionInboxCard({
const topSuggestion = suggestions?.[0] const topSuggestion = suggestions?.[0]
const isUncategorized = transaction.is_business === null && !transaction.journal_entry_id const isUncategorized = transaction.is_business === null && !transaction.journal_entry_id
const showCheckbox = isBatchMode && isUncategorized const showCheckbox = isBatchMode && isUncategorized
const hasWeakSuggestions = !topSuggestion || topSuggestion.confidence < 0.55
const showTemplateFallback = hasWeakSuggestions && templateSuggestions && templateSuggestions.length > 0
function handleSuggestionClick(suggestion: SuggestedCategory) { function handleSuggestionClick(suggestion: SuggestedCategory) {
if (onOpenQuickReview) { if (onOpenQuickReview) {
@@ -155,6 +162,11 @@ export default function TransactionInboxCard({
<Loader2 className="mr-1.5 h-3 w-3 animate-spin" /> <Loader2 className="mr-1.5 h-3 w-3 animate-spin" />
) : null} ) : null}
{topSuggestion.label} {topSuggestion.label}
{topSuggestion.account && (
<span className="ml-1 text-muted-foreground font-normal">
({formatAccountWithName(topSuggestion.account)})
</span>
)}
{topSuggestion.confidence >= 0.8 && ( {topSuggestion.confidence >= 0.8 && (
<Badge variant="secondary" className="ml-1.5 text-[10px] px-1 py-0"> <Badge variant="secondary" className="ml-1.5 text-[10px] px-1 py-0">
{Math.round(topSuggestion.confidence * 100)}% {Math.round(topSuggestion.confidence * 100)}%
@@ -173,9 +185,33 @@ export default function TransactionInboxCard({
disabled={isProcessing || isDisabled} disabled={isProcessing || isDisabled}
> >
{suggestions[1].label} {suggestions[1].label}
{suggestions[1].account && (
<span className="ml-1 text-muted-foreground font-normal">
({formatAccountWithName(suggestions[1].account)})
</span>
)}
</Button> </Button>
)} )}
{/* Fallback templates when no strong suggestion */}
{showTemplateFallback && !hasInvoiceMatch && (
<>
<span className="text-[10px] text-muted-foreground">Osaker? Prova:</span>
{templateSuggestions!.slice(0, 3).map((tmpl) => (
<Button
key={tmpl.template_id}
size="sm"
variant="outline"
className="h-8 text-xs border-dashed"
onClick={() => onOpenDescribe?.(transaction)}
disabled={isProcessing || isDisabled}
>
{tmpl.name_sv}
</Button>
))}
</>
)}
{/* Private button */} {/* Private button */}
<TooltipProvider delayDuration={300}> <TooltipProvider delayDuration={300}>
<Tooltip> <Tooltip>
@@ -200,6 +236,20 @@ export default function TransactionInboxCard({
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
{/* Describe transaction */}
{onOpenDescribe && (
<Button
size="sm"
variant="outline"
className="h-8 text-xs"
onClick={() => onOpenDescribe(transaction)}
disabled={isProcessing || isDisabled}
>
<MessageSquareText className="mr-1.5 h-3 w-3" />
Beskriv...
</Button>
)}
{/* Open category dialog */} {/* Open category dialog */}
<Button <Button
size="sm" size="sm"
@@ -32,10 +32,14 @@ export interface CategoryOption {
// Shared category arrays // Shared category arrays
export const EXPENSE_CATEGORIES: CategoryOption[] = [ export const EXPENSE_CATEGORIES: CategoryOption[] = [
{ value: 'expense_representation', label: 'Representation' },
{ value: 'expense_equipment', label: 'Utrustning' }, { value: 'expense_equipment', label: 'Utrustning' },
{ value: 'expense_software', label: 'Programvara' }, { value: 'expense_software', label: 'Programvara' },
{ value: 'expense_consumables', label: 'Material' },
{ value: 'expense_travel', label: 'Resor' }, { value: 'expense_travel', label: 'Resor' },
{ value: 'expense_office', label: 'Kontor' }, { value: 'expense_office', label: 'Kontor' },
{ value: 'expense_vehicle', label: 'Bil & drivmedel' },
{ value: 'expense_telecom', label: 'Telefon & internet' },
{ value: 'expense_marketing', label: 'Marknadsföring' }, { value: 'expense_marketing', label: 'Marknadsföring' },
{ value: 'expense_professional_services', label: 'Konsulter' }, { value: 'expense_professional_services', label: 'Konsulter' },
{ value: 'expense_education', label: 'Utbildning' }, { value: 'expense_education', label: 'Utbildning' },
@@ -5,7 +5,9 @@
* in server components or API routes. * in server components or API routes.
* *
* Provider-abstracted AI categorization for Swedish BAS account mapping. * 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' import 'server-only'
@@ -27,11 +29,29 @@ export interface TransactionForCategorization {
currency: string 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 { export interface CategorizationContext {
entityType: EntityType entityType: EntityType
recentHistory: { description: string; category: string }[] recentHistory: { description: string; category: string }[]
} }
export interface EnrichedCategorizationContext extends CategorizationContext {
candidateTemplates: BookingTemplate[]
userAccountUsage: AccountUsageEntry[]
merchantHistory: MerchantHistoryEntry[]
}
export interface CategorizationSuggestion { export interface CategorizationSuggestion {
transactionId: string transactionId: string
category: TransactionCategory category: TransactionCategory
@@ -46,7 +66,7 @@ export interface CategorizationSuggestion {
export interface CategorizationProvider { export interface CategorizationProvider {
categorize( categorize(
transactions: TransactionForCategorization[], transactions: TransactionForCategorization[],
context: CategorizationContext context: CategorizationContext | EnrichedCategorizationContext
): Promise<CategorizationSuggestion[]> ): Promise<CategorizationSuggestion[]>
} }
@@ -67,36 +87,89 @@ function getCategoryAccountMap(entityType: EntityType): Record<string, { account
expense_marketing: { account: '5910', label: 'Annonsering/marknadsföring' }, expense_marketing: { account: '5910', label: 'Annonsering/marknadsföring' },
expense_professional_services: { account: '6530', label: 'Redovisning/konsulttjänster' }, expense_professional_services: { account: '6530', label: 'Redovisning/konsulttjänster' },
expense_education: { account: educationAccount, label: 'Utbildning' }, expense_education: { account: educationAccount, label: 'Utbildning' },
expense_representation: { account: '6071', label: 'Representation (mat/möte)' },
expense_consumables: { account: '5460', label: 'Förbrukningsvaror' },
expense_vehicle: { account: '5611', label: 'Bil & drivmedel' },
expense_telecom: { account: '6200', label: 'Telefon & internet' },
expense_bank_fees: { account: '6570', label: 'Bankavgifter' }, expense_bank_fees: { account: '6570', label: 'Bankavgifter' },
expense_card_fees: { account: '6570', label: 'Kortavgifter' }, expense_card_fees: { account: '6570', label: 'Kortavgifter' },
expense_currency_exchange: { account: '7960', label: 'Valutakursförluster' }, expense_currency_exchange: { account: '7960', label: 'Valutakursförluster' },
expense_other: { account: '6991', label: 'Övriga kostnader' }, expense_other: { account: '6991', label: 'Övriga kostnader' },
private: { account: '2013', label: 'Privat uttag (EF) / Skuld till ägare (AB)' },
} }
} }
/** Fallback template IDs when AI doesn't provide one */
const CATEGORY_DEFAULT_TEMPLATES: Record<string, string> = {
expense_representation: 'representation_external',
expense_equipment: 'equipment_tools',
expense_software: 'software_subscription',
expense_travel: 'travel_domestic',
expense_office: 'office_supplies_general',
expense_consumables: 'office_supplies_general',
expense_vehicle: 'travel_fuel',
expense_telecom: 'telecom_mobile',
expense_marketing: 'marketing_advertising',
expense_education: 'education_course',
expense_professional_services: 'consulting_accounting',
}
/** /**
* Build an abbreviated template reference for the AI prompt. * Build template reference from candidate templates (pre-filtered by embeddings)
* Filters by direction (expense/income) to keep prompt concise. * or fall back to full template list if no candidates provided.
*/ */
function getTemplateReference(direction: 'expense' | 'income'): string { function getTemplateReference(
return BOOKING_TEMPLATES direction: 'expense' | 'income',
candidateTemplates?: BookingTemplate[]
): string {
const templates = candidateTemplates && candidateTemplates.length > 0
? candidateTemplates
: BOOKING_TEMPLATES
return templates
.filter((t) => t.direction === direction || t.direction === 'transfer') .filter((t) => t.direction === direction || t.direction === 'transfer')
.map((t) => `${t.id}: ${t.name_sv} → ${t.debit_account}/${t.credit_account}`) .map((t) => `${t.id}: ${t.name_sv} → ${t.debit_account}/${t.credit_account}`)
.join('\n') .join('\n')
} }
const NON_DEDUCTIBLE_RULES = ` const NON_DEDUCTIBLE_RULES = `
ICKE-AVDRAGSGILLA KOSTNADER (svensk skatterätt): MOMSREGLER FÖR SPECIFIKA KATEGORIER:
- Kläder: Normalt inte avdragsgilla (RÅ 1988 ref. 35) - Representation/måltider: Max 300 kr/person exkl. moms (IL 16 kap 2§), konto 6071/6072
- Gym/träning: Inte avdragsgilla som personlig kostnad (IL 9 kap 2§)
- Kosmetika/hudvård: Normalt inte avdragsgillt
- Frisör: Normalt privat kostnad
- Representation/måltider: Max 300 kr/person exkl. moms (IL 16 kap 2§)
- Gåvor: Reklamgåvor max 300 kr/mottagare, representationsgåvor max 180 kr - Gåvor: Reklamgåvor max 300 kr/mottagare, representationsgåvor max 180 kr
- Telefon/dator vid blandad användning: Bara yrkesmässig del avdragsgill - 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 (REQUIRED — must be from the provided templates list)' },
category: { type: 'string', description: 'Transaction category (e.g. expense_representation, expense_equipment, expense_office)' },
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', 'templateId', 'category', 'basAccount', 'confidence', 'reasoning', 'isPrivate'],
},
},
},
required: ['suggestions'],
},
}
// ============================================================ // ============================================================
// Anthropic Provider // Anthropic Provider
// ============================================================ // ============================================================
@@ -116,23 +189,40 @@ export class AnthropicCategorizationProvider implements CategorizationProvider {
async categorize( async categorize(
transactions: TransactionForCategorization[], transactions: TransactionForCategorization[],
context: CategorizationContext context: CategorizationContext | EnrichedCategorizationContext
): Promise<CategorizationSuggestion[]> { ): Promise<CategorizationSuggestion[]> {
// Cap batch size // Cap batch size
const batch = transactions.slice(0, MAX_BATCH_SIZE) const batch = transactions.slice(0, MAX_BATCH_SIZE)
if (batch.length === 0) return [] if (batch.length === 0) return []
const enriched = isEnrichedContext(context) ? context : null
const privateAccount = context.entityType === 'aktiebolag' ? '2893' : '2013' const privateAccount = context.entityType === 'aktiebolag' ? '2893' : '2013'
const categoryAccountMap = getCategoryAccountMap(context.entityType) 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 hasExpenses = batch.some((t) => t.amount < 0)
const hasIncome = batch.some((t) => t.amount > 0) const hasIncome = batch.some((t) => t.amount > 0)
const candidates = enriched?.candidateTemplates
const templateRef = [ const templateRef = [
hasExpenses ? `UTGIFTSMALLAR:\n${getTemplateReference('expense')}` : '', hasExpenses ? `UTGIFTSMALLAR:\n${getTemplateReference('expense', candidates)}` : '',
hasIncome ? `INTÄKTSMALLAR:\n${getTemplateReference('income')}` : '', hasIncome ? `INTÄKTSMALLAR:\n${getTemplateReference('income', candidates)}` : '',
].filter(Boolean).join('\n\n') ].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. 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. Din uppgift är att kategorisera varje transaktion till rätt mall-ID (templateId) och BAS-konto.
@@ -156,12 +246,14 @@ ${NON_DEDUCTIBLE_RULES}
REGLER: REGLER:
1. Negativa belopp = utgifter, positiva = intäkter 1. Negativa belopp = utgifter, positiva = intäkter
2. Markera transaktioner som troligen är privata med isPrivate: true 2. VIKTIGT: Dessa transaktioner kommer från företagets bankkonto/kort. Anta ALLTID att de är affärsrelaterade. Klassificera ALDRIG som "private" — det beslutet tar användaren själv.
3. Ange confidence 0.0-1.0 baserat på hur säker du är 3. Ange confidence 0.0-1.0 baserat på hur säker du är på rätt affärskategori
4. Ange kort reasoning på svenska 4. Ange kort reasoning på svenska
5. Om en transaktion liknar privat konsumtion (kläder, gym, etc.), sätt category: "private" 5. Restauranger/mat/café → expense_representation (6071). Bygghandel/järnhandel → expense_equipment eller expense_consumables. Heminredning/kontorsvaror → expense_office.
6. taxCode: "MPI" för avdragsgilla affärskostnader med moms, "MP1" för intäkter med moms, null för momsfria/privata 6. taxCode: "MPI" för avdragsgilla affärskostnader med moms, "MP1" för intäkter med moms, null för momsfria
7. Ange templateId om en bokföringsmall matchar (föredra mallar framför generiska kategorier)` 7. templateId är OBLIGATORISKT — välj alltid den mest passande mallen från listan ovan, även för alternativa förslag
8. isPrivate ska ALLTID vara false — användaren avgör själv vad som är privat
9. Ange TVÅ förslag per transaktion: ett primärt (mest troligt) och ett alternativt (näst mest troligt, annan kategori, lägre confidence). Båda ska vara affärskategorier.`
const historyContext = const historyContext =
context.recentHistory.length > 0 context.recentHistory.length > 0
@@ -181,29 +273,12 @@ REGLER:
) )
.join('\n\n') .join('\n\n')
const userPrompt = `Kategorisera följande transaktioner: const userPrompt = `Kategorisera följande transaktioner med classify_transactions-verktyget.
${historyContext} Ange TVÅ förslag per transaktion (primärt + alternativ med lägre confidence):
${historyContext}${accountUsageContext}${merchantHistoryContext}
TRANSAKTIONER: TRANSAKTIONER:
${transactionList} ${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.`
let lastError: Error | null = null let lastError: Error | null = null
@@ -212,7 +287,15 @@ Returnera ENDAST JSON-objektet, ingen annan text.`
const message = await this.client.messages.create({ const message = await this.client.messages.create({
model: this.model, model: this.model,
max_tokens: 4096, 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: [ messages: [
{ {
role: 'user', role: 'user',
@@ -221,33 +304,20 @@ Returnera ENDAST JSON-objektet, ingen annan text.`
], ],
}) })
const content = message.content[0] // Extract tool_use block from response
if (content.type !== 'text') { const toolUseBlock = message.content.find(
throw new Error('Unexpected response type from AI') (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 const input = toolUseBlock.input as { suggestions?: unknown[] }
let jsonText = content.text.trim() return this.validateSuggestions(input.suggestions || [], batch, context.entityType)
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)
} catch (error) { } catch (error) {
lastError = error instanceof Error ? error : new Error('Unknown 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) { if (attempt < MAX_RETRIES - 1) {
await sleep(RETRY_DELAY_MS * (attempt + 1)) await sleep(RETRY_DELAY_MS * (attempt + 1))
} }
@@ -269,6 +339,7 @@ Returnera ENDAST JSON-objektet, ingen annan text.`
const categoryAccountMap = getCategoryAccountMap(entityType) const categoryAccountMap = getCategoryAccountMap(entityType)
const validTransactionIds = new Set(transactions.map((t) => t.id)) const validTransactionIds = new Set(transactions.map((t) => t.id))
const validCategories = new Set(Object.keys(categoryAccountMap).concat(['uncategorized'])) const validCategories = new Set(Object.keys(categoryAccountMap).concat(['uncategorized']))
const transactionMap = new Map(transactions.map((t) => [t.id, t]))
return raw return raw
.filter( .filter(
@@ -277,9 +348,23 @@ Returnera ENDAST JSON-objektet, ingen annan text.`
) )
.filter((s) => validTransactionIds.has(s.transactionId as string)) .filter((s) => validTransactionIds.has(s.transactionId as string))
.map((s) => { .map((s) => {
const category = validCategories.has(s.category as string) // Never let AI classify as private — remap to expense_other
let category = validCategories.has(s.category as string)
? (s.category as TransactionCategory) ? (s.category as TransactionCategory)
: 'expense_other' : 'expense_other'
if (category === 'private') {
category = 'expense_other'
}
// Enforce direction: positive amounts = income, negative = expense
const tx = transactionMap.get(s.transactionId as string)
if (tx) {
if (tx.amount > 0 && category.startsWith('expense_')) {
category = 'income_other' as TransactionCategory
} else if (tx.amount < 0 && category.startsWith('income_')) {
category = 'expense_other' as TransactionCategory
}
}
const accountInfo = categoryAccountMap[category] const accountInfo = categoryAccountMap[category]
@@ -290,13 +375,19 @@ Returnera ENDAST JSON-objektet, ingen annan text.`
taxCode: (s.taxCode as string) || null, taxCode: (s.taxCode as string) || null,
confidence: Math.max(0, Math.min(1, Number(s.confidence) || 0.5)), confidence: Math.max(0, Math.min(1, Number(s.confidence) || 0.5)),
reasoning: (s.reasoning as string) || '', reasoning: (s.reasoning as string) || '',
isPrivate: category === 'private' || Boolean(s.isPrivate), isPrivate: false,
templateId: (s.templateId as string) || undefined, templateId: (s.templateId as string) || CATEGORY_DEFAULT_TEMPLATES[category] || undefined,
} }
}) })
} }
} }
function isEnrichedContext(
ctx: CategorizationContext | EnrichedCategorizationContext
): ctx is EnrichedCategorizationContext {
return 'candidateTemplates' in ctx
}
function sleep(ms: number): Promise<void> { function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms)) return new Promise((resolve) => setTimeout(resolve, ms))
} }
+113 -8
View File
@@ -5,9 +5,13 @@ import {
AnthropicCategorizationProvider, AnthropicCategorizationProvider,
type CategorizationProvider, type CategorizationProvider,
type TransactionForCategorization, type TransactionForCategorization,
type CategorizationContext, type EnrichedCategorizationContext,
type CategorizationSuggestion, type CategorizationSuggestion,
type AccountUsageEntry,
type MerchantHistoryEntry,
} from './categorizer' } from './categorizer'
import { findSimilarTemplates } from '@/lib/bookkeeping/template-embeddings'
import type { BookingTemplate } from '@/lib/bookkeeping/booking-templates'
// ============================================================ // ============================================================
// Settings // Settings
@@ -117,7 +121,7 @@ export async function categorizeTransactions(
currency: t.currency, currency: t.currency,
})) }))
const context = await buildContext(userId, supabase) const context = await buildEnrichedContext(userId, supabase, batch)
const aiProvider = getProvider(settings.providerModel) const aiProvider = getProvider(settings.providerModel)
const suggestions = await aiProvider.categorize(batch, context) const suggestions = await aiProvider.categorize(batch, context)
@@ -168,7 +172,7 @@ async function handleTransactionSynced(
currency: t.currency, currency: t.currency,
})) }))
const context = await buildContext(userId, supabase) const context = await buildEnrichedContext(userId, supabase, batch)
const aiProvider = getProvider(settings.providerModel) const aiProvider = getProvider(settings.providerModel)
const suggestions = await aiProvider.categorize(batch, context) const suggestions = await aiProvider.categorize(batch, context)
@@ -194,7 +198,11 @@ async function handleTransactionSynced(
// ============================================================ // ============================================================
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
async function buildContext(userId: string, supabase: any): Promise<CategorizationContext> { async function buildEnrichedContext(
userId: string,
supabase: any,
transactions: TransactionForCategorization[]
): Promise<EnrichedCategorizationContext> {
// Fetch entity type // Fetch entity type
const { data: companySettings } = await supabase const { data: companySettings } = await supabase
.from('company_settings') .from('company_settings')
@@ -221,7 +229,92 @@ async function buildContext(userId: string, supabase: any): Promise<Categorizati
}) })
) )
return { entityType, recentHistory } // Fetch user's most-used accounts (top 30)
const { data: accountUsageRows } = await supabase
.from('journal_entry_lines')
.select('account_number')
.eq('user_id', userId)
const accountCounts = new Map<string, number>()
if (accountUsageRows) {
for (const row of accountUsageRows as { account_number: string }[]) {
accountCounts.set(row.account_number, (accountCounts.get(row.account_number) || 0) + 1)
}
}
const userAccountUsage: AccountUsageEntry[] = Array.from(accountCounts.entries())
.map(([account_number, count]) => ({ account_number, count }))
.sort((a, b) => b.count - a.count)
.slice(0, 30)
// Fetch merchant history for this batch's merchants
const merchantNames = [...new Set(
transactions
.map((t) => t.merchant_name)
.filter((n): n is string => n !== null && n.length > 0)
)]
let merchantHistory: MerchantHistoryEntry[] = []
if (merchantNames.length > 0) {
const { data: merchantRows } = await supabase
.from('transactions')
.select('merchant_name, category, template_id')
.eq('user_id', userId)
.not('is_business', 'is', null)
.neq('category', 'uncategorized')
.in('merchant_name', merchantNames)
.limit(200)
if (merchantRows) {
const merchantMap = new Map<string, MerchantHistoryEntry>()
for (const row of merchantRows as { merchant_name: string; category: string; template_id: string | null }[]) {
const key = `${row.merchant_name}:${row.category}`
const existing = merchantMap.get(key)
if (existing) {
existing.count++
} else {
merchantMap.set(key, {
merchant_name: row.merchant_name,
category: row.category,
template_id: row.template_id,
count: 1,
})
}
}
merchantHistory = Array.from(merchantMap.values())
.sort((a, b) => b.count - a.count)
}
}
// Find candidate templates via embedding search
// Use a representative subset of transactions to find candidates
const representativeTransactions = transactions.slice(0, 5)
const candidateMap = new Map<string, BookingTemplate>()
for (const tx of representativeTransactions) {
try {
const matches = await findSimilarTemplates(
tx as unknown as Transaction,
entityType
)
for (const m of matches) {
if (!candidateMap.has(m.template.id)) {
candidateMap.set(m.template.id, m.template)
}
}
} catch {
// Embedding search failed — continue without candidates
}
}
const candidateTemplates = Array.from(candidateMap.values())
return {
entityType,
recentHistory,
candidateTemplates,
userAccountUsage,
merchantHistory,
}
} }
async function storeSuggestions( async function storeSuggestions(
@@ -230,16 +323,28 @@ async function storeSuggestions(
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
supabase: any supabase: any
): Promise<void> { ): Promise<void> {
// Group suggestions by transactionId (AI now returns multiple per transaction)
const grouped: Record<string, CategorizationSuggestion[]> = {}
for (const suggestion of suggestions) { for (const suggestion of suggestions) {
await supabase.from('extension_data').upsert( if (!grouped[suggestion.transactionId]) {
grouped[suggestion.transactionId] = []
}
grouped[suggestion.transactionId].push(suggestion)
}
for (const [txId, txSuggestions] of Object.entries(grouped)) {
const { error } = await supabase.from('extension_data').upsert(
{ {
user_id: userId, user_id: userId,
extension_id: 'ai-categorization', extension_id: 'ai-categorization',
key: `suggestion:${suggestion.transactionId}`, key: `suggestion:${txId}`,
value: suggestion, value: txSuggestions,
}, },
{ onConflict: 'user_id,extension_id,key' } { onConflict: 'user_id,extension_id,key' }
) )
if (error) {
console.error(`[ai-categorization] Failed to store suggestion for ${txId}:`, error.message)
}
} }
} }
@@ -10,7 +10,7 @@ import { CalendarGrid } from './CalendarGrid'
import { CalendarWeekView } from './CalendarWeekView' import { CalendarWeekView } from './CalendarWeekView'
import { CalendarDayView } from './CalendarDayView' import { CalendarDayView } from './CalendarDayView'
import { DayDetailModal } from './DayDetailModal' import { DayDetailModal } from './DayDetailModal'
import { DeadlineForm } from './DeadlineForm' import { DeadlineForm } from '@/components/deadlines/DeadlineForm'
interface PaymentCalendarProps { interface PaymentCalendarProps {
invoices: Invoice[] invoices: Invoice[]
+7
View File
@@ -0,0 +1,7 @@
import type { Extension } from '@/lib/extensions/types'
export const calendarExtension: Extension = {
id: 'calendar',
name: 'Kalender',
version: '1.0.0',
}
@@ -152,7 +152,7 @@ export function createTaxDeadlinePayload(
badge: '/icons/badge-72.png', badge: '/icons/badge-72.png',
tag: `tax-deadline-${deadlineId}`, tag: `tax-deadline-${deadlineId}`,
data: { data: {
url: '/calendar', url: '/deadlines',
type: 'tax_deadline', type: 'tax_deadline',
id: deadlineId, id: deadlineId,
}, },
@@ -28,6 +28,10 @@ const BAS_ACCOUNT_MAPPING: Record<TransactionCategory, string> = {
expense_marketing: '5910', // Annonsering expense_marketing: '5910', // Annonsering
expense_professional_services: '6530', // Redovisningstjänster expense_professional_services: '6530', // Redovisningstjänster
expense_education: '6991', // Övriga avdragsgilla kostnader expense_education: '6991', // Övriga avdragsgilla kostnader
expense_representation: '6071', // Representation
expense_consumables: '5460', // Förbrukningsvaror
expense_vehicle: '5611', // Drivmedel bil
expense_telecom: '6200', // Telefon och internet
expense_bank_fees: '6570', // Bankavgifter expense_bank_fees: '6570', // Bankavgifter
expense_card_fees: '6570', // Kortavgifter expense_card_fees: '6570', // Kortavgifter
expense_currency_exchange: '7960', // Valutakursförluster expense_currency_exchange: '7960', // Valutakursförluster
@@ -273,6 +277,10 @@ export const CATEGORY_LABELS: Record<TransactionCategory, string> = {
expense_marketing: 'Marknadsföring', expense_marketing: 'Marknadsföring',
expense_professional_services: 'Konsulttjänster', expense_professional_services: 'Konsulttjänster',
expense_education: 'Utbildning', expense_education: 'Utbildning',
expense_representation: 'Representation',
expense_consumables: 'Material',
expense_vehicle: 'Bil & drivmedel',
expense_telecom: 'Telefon & internet',
expense_bank_fees: 'Bankavgift', expense_bank_fees: 'Bankavgift',
expense_card_fees: 'Kortavgift', expense_card_fees: 'Kortavgift',
expense_currency_exchange: 'Valutaväxling', expense_currency_exchange: 'Valutaväxling',
@@ -0,0 +1,142 @@
import type { Extension, ExtensionContext } from '@/lib/extensions/types'
import type { EventPayload } from '@/lib/events/types'
// ============================================================
// Settings
// ============================================================
export interface UserDescriptionMatchSettings {
batchApplyEnabled: boolean
minConfidenceThreshold: number
}
const DEFAULT_SETTINGS: UserDescriptionMatchSettings = {
batchApplyEnabled: true,
minConfidenceThreshold: 0.55,
}
export async function getSettings(userId: string): Promise<UserDescriptionMatchSettings> {
const { createClient } = await import('@/lib/supabase/server')
const supabase = await createClient()
const { data } = await supabase
.from('extension_data')
.select('value')
.eq('user_id', userId)
.eq('extension_id', 'user-description-match')
.eq('key', 'settings')
.single()
if (!data?.value) return { ...DEFAULT_SETTINGS }
return { ...DEFAULT_SETTINGS, ...(data.value as Partial<UserDescriptionMatchSettings>) }
}
export async function saveSettings(
userId: string,
partial: Partial<UserDescriptionMatchSettings>
): Promise<UserDescriptionMatchSettings> {
const current = await getSettings(userId)
const merged = { ...current, ...partial }
const { createClient } = await import('@/lib/supabase/server')
const supabase = await createClient()
await supabase
.from('extension_data')
.upsert(
{
user_id: userId,
extension_id: 'user-description-match',
key: 'settings',
value: merged,
},
{ onConflict: 'user_id,extension_id,key' }
)
return merged
}
// ============================================================
// Event Handler
// ============================================================
async function handleTransactionCategorized(
payload: EventPayload<'transaction.categorized'>,
ctx?: ExtensionContext
): Promise<void> {
const { transaction, userId } = payload
const log = ctx?.log ?? console
if (!transaction.merchant_name) return
const settings = ctx
? { ...(DEFAULT_SETTINGS), ...(await ctx.settings.get<Partial<UserDescriptionMatchSettings>>() || {}) }
: await getSettings(userId)
if (!settings.batchApplyEnabled) return
try {
const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient()
// Check if the rule that fired was a user-described rule
const { data: rule } = await supabase
.from('mapping_rules')
.select('id, user_description, merchant_pattern')
.eq('user_id', userId)
.eq('source', 'user_description')
.ilike('merchant_pattern', transaction.merchant_name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
.limit(1)
.single()
if (!rule) return
// Count uncategorized siblings
const { count } = await supabase
.from('transactions')
.select('id', { count: 'exact', head: true })
.eq('user_id', userId)
.eq('merchant_name', transaction.merchant_name)
.is('journal_entry_id', null)
if (!count || count === 0) return
// Store batch hint for the UI
await supabase.from('extension_data').upsert(
{
user_id: userId,
extension_id: 'user-description-match',
key: `batch_hint:${transaction.merchant_name}`,
value: {
merchant_name: transaction.merchant_name,
uncategorized_count: count,
user_description: rule.user_description,
},
},
{ onConflict: 'user_id,extension_id,key' }
)
log.info(`[user-description-match] Batch hint stored: ${count} uncategorized for ${transaction.merchant_name}`)
} catch (error) {
log.error('[user-description-match] handleTransactionCategorized failed:', error)
}
}
// ============================================================
// Extension Object
// ============================================================
export const userDescriptionMatchExtension: Extension = {
id: 'user-description-match',
name: 'Beskrivningsmatchning',
version: '1.0.0',
eventHandlers: [
{ eventType: 'transaction.categorized', handler: handleTransactionCategorized },
],
settingsPanel: {
label: 'Beskrivningsmatchning',
path: '/settings/extensions/user-description-match',
},
async onInstall(ctx) {
await ctx.settings.set('settings', DEFAULT_SETTINGS)
},
}
+16
View File
@@ -72,6 +72,10 @@ export const TransactionCategorySchema = z.enum([
'expense_marketing', 'expense_marketing',
'expense_professional_services', 'expense_professional_services',
'expense_education', 'expense_education',
'expense_representation',
'expense_consumables',
'expense_vehicle',
'expense_telecom',
'expense_bank_fees', 'expense_bank_fees',
'expense_card_fees', 'expense_card_fees',
'expense_currency_exchange', 'expense_currency_exchange',
@@ -306,6 +310,7 @@ export const CategorizeTransactionSchema = z.object({
template_id: z.string().optional(), template_id: z.string().optional(),
vat_treatment: VatTreatmentSchema.optional(), vat_treatment: VatTreatmentSchema.optional(),
account_override: accountNumber.optional(), account_override: accountNumber.optional(),
user_description: z.string().max(500).optional(),
}) })
export const BookTransactionSchema = z.object({ export const BookTransactionSchema = z.object({
@@ -323,6 +328,17 @@ export const MatchSupplierInvoiceSchema = z.object({
supplier_invoice_id: uuid, supplier_invoice_id: uuid,
}) })
export const DescribeTransactionSchema = z.object({
description: z.string().min(3).max(500),
})
export const BatchDescribeSchema = z.object({
merchant_name: z.string().min(1),
template_id: z.string().min(1),
is_business: z.boolean(),
user_description: z.string().max(500).optional(),
})
// ============================================================ // ============================================================
// Settings schemas // Settings schemas
// ============================================================ // ============================================================
@@ -249,7 +249,7 @@ describe('findMatchingTemplates', () => {
mcc_code: 5817, mcc_code: 5817,
}) })
const matches = findMatchingTemplates(tx) const matches = findMatchingTemplates(tx)
expect(matches.length).toBeLessThanOrEqual(5) expect(matches.length).toBeLessThanOrEqual(20)
}) })
it('results are sorted by confidence descending', () => { it('results are sorted by confidence descending', () => {
@@ -0,0 +1,141 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createMockSupabase, makeTransaction } from '@/tests/helpers'
// Mock Supabase
const { supabase: mockSupabase, mockResult } = createMockSupabase()
vi.mock('@/lib/supabase/server', () => ({
createClient: vi.fn().mockResolvedValue(mockSupabase),
}))
// Mock booking-templates (needed by evaluateMappingRules)
vi.mock('../booking-templates', () => ({
findMatchingTemplates: vi.fn().mockReturnValue([]),
buildMappingResultFromTemplate: vi.fn(),
}))
describe('mapping-engine', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('saveUserMappingRule', () => {
it('saves auto-learned rule without user description', async () => {
const { saveUserMappingRule } = await import('../mapping-engine')
mockResult({ data: null, error: null })
await saveUserMappingRule('user-1', 'ICA Maxi', '5410', '1930', false)
// Verify insert was called via supabase.from().insert()
expect(mockSupabase.from).toHaveBeenCalledWith('mapping_rules')
})
it('saves user-described rule with priority 5 and confidence 0.98', async () => {
const { saveUserMappingRule } = await import('../mapping-engine')
mockResult({ data: null, error: null })
await saveUserMappingRule(
'user-1',
'Restaurant XYZ',
'6071',
'1930',
false,
'business lunch with client',
'restaurant_dining'
)
// Verify from was called (first for delete, then for insert)
expect(mockSupabase.from).toHaveBeenCalledWith('mapping_rules')
})
it('does not throw on insert error (non-critical)', async () => {
const { saveUserMappingRule } = await import('../mapping-engine')
mockResult({ data: null, error: { message: 'DB error' } })
// Should not throw
await expect(
saveUserMappingRule('user-1', 'ICA Maxi', '5410', '1930', false)
).resolves.toBeUndefined()
})
it('escapes special regex characters in merchant name', async () => {
const { saveUserMappingRule } = await import('../mapping-engine')
mockResult({ data: null, error: null })
// Merchant name with regex special chars
await saveUserMappingRule('user-1', 'Test (Pty) Ltd.', '5410', '1930', false)
expect(mockSupabase.from).toHaveBeenCalledWith('mapping_rules')
})
})
describe('evaluateMappingRules', () => {
it('returns default result when no rules match', async () => {
const { evaluateMappingRules } = await import('../mapping-engine')
const tx = makeTransaction({ amount: -100, merchant_name: 'Unknown' })
mockResult({ data: [], error: null })
const result = await evaluateMappingRules('user-1', tx)
expect(result.debit_account).toBe('6991')
expect(result.credit_account).toBe('1930')
expect(result.confidence).toBe(0.1)
expect(result.requires_review).toBe(true)
})
it('matches merchant_pattern rule', async () => {
const { evaluateMappingRules } = await import('../mapping-engine')
const tx = makeTransaction({
amount: -299,
merchant_name: 'ICA Maxi',
description: 'ICA MAXI STOCKHOLM',
})
mockResult({
data: [
{
id: 'rule-1',
user_id: 'user-1',
rule_name: 'Learned: ICA Maxi',
rule_type: 'merchant_name',
priority: 10,
mcc_codes: null,
merchant_pattern: 'ICA Maxi',
description_pattern: null,
amount_min: null,
amount_max: null,
debit_account: '5410',
credit_account: '1930',
vat_treatment: null,
vat_debit_account: null,
vat_credit_account: null,
risk_level: 'NONE',
default_private: false,
requires_review: false,
confidence_score: 0.95,
capitalization_threshold: null,
capitalized_debit_account: null,
is_active: true,
source: 'auto',
user_description: null,
template_id: null,
created_at: '2024-01-01',
updated_at: '2024-01-01',
},
],
error: null,
})
const result = await evaluateMappingRules('user-1', tx)
expect(result.debit_account).toBe('5410')
expect(result.credit_account).toBe('1930')
expect(result.confidence).toBe(0.95)
})
})
})
@@ -0,0 +1,233 @@
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')
})
it('prepends user description when provided', async () => {
const { buildTransactionQueryText } = await import('../template-embeddings')
const tx = makeTransaction({
description: 'SWE REST 4521 STHLM',
merchant_name: 'Unknown',
amount: -450,
})
const text = buildTransactionQueryText(tx, 'business lunch with client')
// User description should appear first
expect(text.indexOf('business lunch with client')).toBe(0)
// Transaction data should still be present
expect(text).toContain('SWE REST 4521 STHLM')
expect(text).toContain('Unknown')
expect(text).toContain('utgift')
})
it('behaves identically when userDescription is undefined', async () => {
const { buildTransactionQueryText } = await import('../template-embeddings')
const tx = makeTransaction({
description: 'SPOTIFY PREMIUM',
merchant_name: 'Spotify',
amount: -109,
})
const withoutDesc = buildTransactionQueryText(tx)
const withUndefined = buildTransactionQueryText(tx, undefined)
expect(withoutDesc).toBe(withUndefined)
})
})
describe('getSchemaVersion', () => {
it('returns a consistent hash string', async () => {
const { getSchemaVersion } = await import('../template-embeddings')
const v1 = getSchemaVersion()
const v2 = getSchemaVersion()
expect(v1).toBe(v2)
expect(v1).toHaveLength(12)
expect(v1).toMatch(/^[a-f0-9]+$/)
})
})
describe('findSimilarTemplates', () => {
it('returns empty array on RPC error (graceful fallback)', async () => {
const { findSimilarTemplates } = await import('../template-embeddings')
// Mock staleness check
mockResult({ data: { schema_version: 'test' }, error: null })
const tx = makeTransaction({
description: 'SPOTIFY',
amount: -109,
})
// The mock will return error for the RPC call
mockResult({ data: null, error: { message: 'RPC failed' } })
const results = await findSimilarTemplates(tx)
expect(results).toEqual([])
})
it('returns empty array when no embeddings exist', async () => {
const { findSimilarTemplates } = await import('../template-embeddings')
mockResult({ data: [], error: null })
const tx = makeTransaction({
description: 'Random purchase',
amount: -50,
})
const results = await findSimilarTemplates(tx)
expect(results).toEqual([])
})
})
})
+3 -3
View File
@@ -617,7 +617,7 @@ export const BOOKING_TEMPLATES: readonly BookingTemplate[] = [
deductibility: 'full', deductibility: 'full',
special_rules_sv: 'Ofta utländsk leverantör (USA) med omvänd skattskyldighet', special_rules_sv: 'Ofta utländsk leverantör (USA) med omvänd skattskyldighet',
mcc_codes: [], mcc_codes: [],
keywords: ['openai', 'chatgpt', 'anthropic', 'claude', 'ai', 'midjourney', 'copilot'], keywords: ['openai', 'chatgpt', 'anthropic', 'claude', 'ai', 'midjourney', 'copilot', 'mistral', 'claude', 'gemini'],
risk_level: 'NONE', risk_level: 'NONE',
requires_review: false, requires_review: false,
impact_score: 7, impact_score: 7,
@@ -663,7 +663,7 @@ export const BOOKING_TEMPLATES: readonly BookingTemplate[] = [
vat_rate: 0.25, vat_rate: 0.25,
deductibility: 'full', deductibility: 'full',
mcc_codes: [5111, 5112, 5943, 5944], 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', risk_level: 'NONE',
requires_review: false, requires_review: false,
impact_score: 7, impact_score: 7,
@@ -2534,7 +2534,7 @@ export function findMatchingTemplates(
return results return results
.sort((a, b) => b.confidence - a.confidence) .sort((a, b) => b.confidence - a.confidence)
.slice(0, 5) .slice(0, 20)
} }
/** /**
+16
View File
@@ -71,6 +71,10 @@ export function getCategoryAccountMapping(
expense_marketing: '5910', // Annonsering expense_marketing: '5910', // Annonsering
expense_professional_services: '6530', // Redovisningstjänster expense_professional_services: '6530', // Redovisningstjänster
expense_education: educationAccount, expense_education: educationAccount,
expense_representation: '6071', // Representation, avdragsgill
expense_consumables: '5460', // Förbrukningsvaror
expense_vehicle: '5611', // Drivmedel bil
expense_telecom: '6200', // Telefon och internet
expense_bank_fees: '6570', // Bankavgifter expense_bank_fees: '6570', // Bankavgifter
expense_card_fees: '6570', // Kortavgifter expense_card_fees: '6570', // Kortavgifter
expense_currency_exchange: '7960', // Valutakursförluster expense_currency_exchange: '7960', // Valutakursförluster
@@ -224,6 +228,10 @@ export function buildMappingResultFromCategory(
expense_marketing: 'Marknadsföring', expense_marketing: 'Marknadsföring',
expense_professional_services: 'Konsulttjänst', expense_professional_services: 'Konsulttjänst',
expense_education: 'Utbildning', expense_education: 'Utbildning',
expense_representation: 'Representation',
expense_consumables: 'Förbrukningsvaror',
expense_vehicle: 'Bil & drivmedel',
expense_telecom: 'Telefon & internet',
expense_bank_fees: 'Bankavgift', expense_bank_fees: 'Bankavgift',
expense_card_fees: 'Kortavgift', expense_card_fees: 'Kortavgift',
expense_currency_exchange: 'Valutaväxling', expense_currency_exchange: 'Valutaväxling',
@@ -262,6 +270,10 @@ export function getExpenseAccountForCategory(category: TransactionCategory): str
expense_marketing: '5910', expense_marketing: '5910',
expense_professional_services: '6530', expense_professional_services: '6530',
expense_education: '6991', expense_education: '6991',
expense_representation: '6071',
expense_consumables: '5460',
expense_vehicle: '5611',
expense_telecom: '6200',
expense_bank_fees: '6570', expense_bank_fees: '6570',
expense_card_fees: '6570', expense_card_fees: '6570',
expense_currency_exchange: '7960', expense_currency_exchange: '7960',
@@ -292,6 +304,10 @@ export function getDefaultAccountForCategory(
expense_marketing: '5910', expense_marketing: '5910',
expense_professional_services: '6530', expense_professional_services: '6530',
expense_education: entityType === 'aktiebolag' ? '7610' : '6991', expense_education: entityType === 'aktiebolag' ? '7610' : '6991',
expense_representation: '6071',
expense_consumables: '5460',
expense_vehicle: '5611',
expense_telecom: '6200',
expense_bank_fees: '6570', expense_bank_fees: '6570',
expense_card_fees: '6570', expense_card_fees: '6570',
expense_currency_exchange: '7960', expense_currency_exchange: '7960',
+70
View File
@@ -0,0 +1,70 @@
/**
* Client-safe account name map for UI display.
* Covers the ~30 accounts used in transaction categorization.
* No server dependencies — safe for 'use client' components.
*/
const ACCOUNT_NAMES: Record<string, string> = {
// Assets (1xxx)
'1510': 'Kundfordringar',
'1930': 'Foretagskonto',
// Equity & Liabilities (2xxx)
'2013': 'Ovriga egna uttag',
'2440': 'Leverantorsskulder',
'2611': 'Utg. moms 25%',
'2621': 'Utg. moms 12%',
'2631': 'Utg. moms 6%',
'2614': 'Utg. moms omvand',
'2641': 'Ing. moms',
'2645': 'Beraknad ing. moms',
'2893': 'Skuld till agare',
// Revenue (3xxx)
'3001': 'Forsaljning 25%',
'3002': 'Forsaljning 12%',
'3003': 'Forsaljning 6%',
'3305': 'Exportforsaljning',
'3308': 'EU-tjanster',
'3900': 'Ovriga rorelseintakter',
// Cost of goods (4xxx)
'4010': 'Varuinkop',
// External expenses (5xxx)
'5010': 'Lokalhyra',
'5410': 'Forbrukningsinventarier',
'5420': 'Programvaror',
'5460': 'Forbrukningsvaror',
'5611': 'Drivmedel bil',
'5800': 'Resekostnader',
'5910': 'Annonsering',
// Other external expenses (6xxx)
'6071': 'Representation',
'6200': 'Telefon & internet',
'6530': 'Redovisningstjanster',
'6570': 'Bankavgifter',
'6991': 'Ovriga kostnader',
// Personnel (7xxx)
'7610': 'Utbildning',
'7960': 'Valutakursforluster',
'3960': 'Valutakursvinster',
}
/**
* Get the Swedish display name for an account number.
* Returns the number itself if no name is mapped.
*/
export function getAccountName(accountNumber: string): string {
return ACCOUNT_NAMES[accountNumber] || accountNumber
}
/**
* Format an account number with its name, e.g. "5010 Lokalhyra".
*/
export function formatAccountWithName(accountNumber: string): string {
const name = ACCOUNT_NAMES[accountNumber]
return name ? `${accountNumber} ${name}` : accountNumber
}
+60 -17
View File
@@ -227,35 +227,78 @@ function getDefaultResult(transaction: Transaction): MappingResult {
} }
/** /**
* Save a user-level mapping rule learned from categorization * Save a user-level mapping rule learned from categorization.
*
* When userDescription is provided, the rule gets:
* - source: 'user_description' (instead of 'auto')
* - priority: 5 (beats auto-learned at 10)
* - confidence_score: 0.98
* - The original user text and template_id stored for UI display
*
* User-described rules for the same merchant replace prior user-described rules
* (latest description wins).
*/ */
export async function saveUserMappingRule( export async function saveUserMappingRule(
userId: string, userId: string,
merchantName: string, merchantName: string,
debitAccount: string, debitAccount: string,
creditAccount: string, creditAccount: string,
isPrivate: boolean isPrivate: boolean,
userDescription?: string,
templateId?: string
): Promise<void> { ): Promise<void> {
const supabase = await createClient() const supabase = await createClient()
// Escape special regex characters in merchant name // Escape special regex characters in merchant name
const escapedMerchant = merchantName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') const escapedMerchant = merchantName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const { error } = await supabase.from('mapping_rules').insert({ if (userDescription) {
user_id: userId, // Delete existing user_description rule for this merchant (latest wins)
rule_name: `Learned: ${merchantName}`, await supabase
rule_type: 'merchant_name', .from('mapping_rules')
priority: 10, // User overrides have highest priority .delete()
merchant_pattern: escapedMerchant, .eq('user_id', userId)
debit_account: debitAccount, .eq('merchant_pattern', escapedMerchant)
credit_account: creditAccount, .eq('source', 'user_description')
risk_level: 'NONE',
default_private: isPrivate,
requires_review: false,
confidence_score: 0.95,
})
if (error) { const { error } = await supabase.from('mapping_rules').insert({
// Silently fail — saving learned rules is non-critical user_id: userId,
rule_name: `Described: ${merchantName}`,
rule_type: 'merchant_name',
priority: 5,
merchant_pattern: escapedMerchant,
debit_account: debitAccount,
credit_account: creditAccount,
risk_level: 'NONE',
default_private: isPrivate,
requires_review: false,
confidence_score: 0.98,
source: 'user_description',
user_description: userDescription,
template_id: templateId || null,
})
if (error) {
// Silently fail — saving learned rules is non-critical
}
} else {
const { error } = await supabase.from('mapping_rules').insert({
user_id: userId,
rule_name: `Learned: ${merchantName}`,
rule_type: 'merchant_name',
priority: 10,
merchant_pattern: escapedMerchant,
debit_account: debitAccount,
credit_account: creditAccount,
risk_level: 'NONE',
default_private: isPrivate,
requires_review: false,
confidence_score: 0.95,
source: 'auto',
})
if (error) {
// Silently fail — saving learned rules is non-critical
}
} }
} }
+265
View File
@@ -0,0 +1,265 @@
/**
* 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.
* When userDescription is provided, it is prepended so it dominates
* the semantic search (user intent > raw bank text).
*/
export function buildTransactionQueryText(
transaction: Transaction,
userDescription?: string
): string {
const parts: string[] = []
if (userDescription) {
parts.push(userDescription)
}
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,
userDescription?: string
): Promise<TemplateMatch[]> {
try {
const { createServiceClient } = await import('@/lib/supabase/server')
const supabase = await createServiceClient()
const embeddings = getEmbeddingsClient()
// Check schema version staleness on first call
if (!stalenessWarned) {
const { data: sample } = await supabase
.from('booking_template_embeddings')
.select('schema_version')
.limit(1)
.single()
if (sample && sample.schema_version !== getSchemaVersion()) {
console.warn(
`[template-embeddings] Schema version mismatch: DB has "${sample.schema_version}", current is "${getSchemaVersion()}". Re-seed embeddings.`
)
}
stalenessWarned = true
}
// Embed the transaction query text
const queryText = buildTransactionQueryText(transaction, userDescription)
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 []
}
}
+4
View File
@@ -3,6 +3,7 @@ import {
Sparkles, Sparkles,
MessageSquare, MessageSquare,
Bell, Bell,
Inbox,
Landmark, Landmark,
UtensilsCrossed, UtensilsCrossed,
ChefHat, ChefHat,
@@ -23,6 +24,7 @@ import {
BarChart3, BarChart3,
Layers, Layers,
Puzzle, Puzzle,
TextSearch,
Ship, Ship,
FileText, FileText,
Shield, Shield,
@@ -34,6 +36,7 @@ const ICON_MAP: Record<string, LucideIcon> = {
Sparkles, Sparkles,
MessageSquare, MessageSquare,
Bell, Bell,
Inbox,
Landmark, Landmark,
UtensilsCrossed, UtensilsCrossed,
ChefHat, ChefHat,
@@ -54,6 +57,7 @@ const ICON_MAP: Record<string, LucideIcon> = {
BarChart3, BarChart3,
Layers, Layers,
Puzzle, Puzzle,
TextSearch,
Ship, Ship,
FileText, FileText,
Shield, Shield,
+4
View File
@@ -6,6 +6,8 @@ import { sruExportExtension } from '@/extensions/sru-export'
import { neBilagaExtension } from '@/extensions/ne-bilaga' import { neBilagaExtension } from '@/extensions/ne-bilaga'
import { aiChatExtension } from '@/extensions/general/ai-chat' import { aiChatExtension } from '@/extensions/general/ai-chat'
import { invoiceInboxExtension } from '@/extensions/general/invoice-inbox' import { invoiceInboxExtension } from '@/extensions/general/invoice-inbox'
import { calendarExtension } from '@/extensions/general/calendar'
import { userDescriptionMatchExtension } from '@/extensions/general/user-description-match'
import { euSalesListExtension } from '@/extensions/export/eu-sales-list' import { euSalesListExtension } from '@/extensions/export/eu-sales-list'
import { vatMonitorExtension } from '@/extensions/export/vat-monitor' import { vatMonitorExtension } from '@/extensions/export/vat-monitor'
import { intrastatExtension } from '@/extensions/export/intrastat' import { intrastatExtension } from '@/extensions/export/intrastat'
@@ -32,6 +34,8 @@ const FIRST_PARTY_EXTENSIONS: Extension[] = [
neBilagaExtension, neBilagaExtension,
aiChatExtension, aiChatExtension,
invoiceInboxExtension, invoiceInboxExtension,
calendarExtension,
userDescriptionMatchExtension,
// enableBankingExtension, // Uncomment to activate PSD2 bank sync // enableBankingExtension, // Uncomment to activate PSD2 bank sync
// ── Export sector ────────────────────────────────────────── // ── Export sector ──────────────────────────────────────────
+30
View File
@@ -51,6 +51,12 @@ export const SECTORS: Sector[] = [
description: 'AI-assistent för skatte- och bokföringsfrågor', description: 'AI-assistent för skatte- och bokföringsfrågor',
longDescription: 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.', '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', slug: 'push-notifications',
@@ -76,6 +82,30 @@ export const SECTORS: Sector[] = [
longDescription: 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.', '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: 'user-description-match',
name: 'Beskrivningsmatchning',
sector: 'general',
category: 'operations',
icon: 'TextSearch',
dataPattern: 'core',
readsCoreTables: ['transactions', 'mapping_rules'],
description: 'Matcha transaktioner med egna beskrivningar',
longDescription:
'Beskriv vad en transaktion gäller med egna ord och få smarta bokföringsförslag. Systemet lär sig av dina beskrivningar och applicerar automatiskt på framtida transaktioner från samma leverantör.',
},
{ {
slug: 'enable-banking', slug: 'enable-banking',
name: 'Bankintegration (PSD2)', name: 'Bankintegration (PSD2)',
+11
View File
@@ -15,6 +15,16 @@ export type SectorSlug = 'general' | 'restaurant' | 'construction' | 'hotel' | '
/** How an extension gets its data */ /** How an extension gets its data */
export type ExtensionDataPattern = 'core' | 'manual' | 'both' 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 */ /** Extension metadata for the marketplace and workspace routing */
export interface ExtensionDefinition { export interface ExtensionDefinition {
slug: string slug: string
@@ -28,6 +38,7 @@ export interface ExtensionDefinition {
dataPattern: ExtensionDataPattern dataPattern: ExtensionDataPattern
readsCoreTables?: string[] readsCoreTables?: string[]
hasOwnData?: boolean hasOwnData?: boolean
quickAction?: QuickActionDefinition
} }
/** Sector definition with its extensions */ /** Sector definition with its extensions */
+2
View File
@@ -14,7 +14,9 @@ const WORKSPACES: Record<WorkspaceKey, ComponentType<WorkspaceComponentProps>> =
'general/ai-chat': dynamic(() => import('@/components/extensions/general/AiChatWorkspace')), 'general/ai-chat': dynamic(() => import('@/components/extensions/general/AiChatWorkspace')),
'general/push-notifications': dynamic(() => import('@/components/extensions/general/PushNotificationsWorkspace')), 'general/push-notifications': dynamic(() => import('@/components/extensions/general/PushNotificationsWorkspace')),
'general/invoice-inbox': dynamic(() => import('@/components/extensions/general/InvoiceInboxWorkspace')), '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')), 'general/enable-banking': dynamic(() => import('@/components/extensions/general/EnableBankingWorkspace')),
'general/user-description-match': dynamic(() => import('@/components/extensions/general/UserDescriptionMatchWorkspace')),
// Restaurant // Restaurant
'restaurant/food-cost': dynamic(() => import('@/components/extensions/restaurant/FoodCostWorkspace')), 'restaurant/food-cost': dynamic(() => import('@/components/extensions/restaurant/FoodCostWorkspace')),
'restaurant/earnings-per-liter': dynamic(() => import('@/components/extensions/restaurant/EarningsPerLiterWorkspace')), 'restaurant/earnings-per-liter': dynamic(() => import('@/components/extensions/restaurant/EarningsPerLiterWorkspace')),
+1 -1
View File
@@ -91,7 +91,7 @@ describe('generateSIEExport', () => {
const output = await generateSIEExport('user-1', { const output = await generateSIEExport('user-1', {
...baseOptions, ...baseOptions,
org_number: undefined, org_number: null,
}) })
expect(output).not.toContain('#ORGNR') expect(output).not.toContain('#ORGNR')
+61 -8
View File
@@ -1,6 +1,7 @@
import { suggestCategory } from '@/lib/tax/expense-warnings' import { suggestCategory } from '@/lib/tax/expense-warnings'
import { getExpenseAccountForCategory } from '@/lib/bookkeeping/category-mapping' import { getExpenseAccountForCategory } from '@/lib/bookkeeping/category-mapping'
import { findMatchingTemplates, type TemplateMatch } from '@/lib/bookkeeping/booking-templates' import { findMatchingTemplates, type TemplateMatch } from '@/lib/bookkeeping/booking-templates'
import { findSimilarTemplates } from '@/lib/bookkeeping/template-embeddings'
import type { Transaction, TransactionCategory, EntityType, MappingRule } from '@/types' import type { Transaction, TransactionCategory, EntityType, MappingRule } from '@/types'
export interface SuggestedCategory { export interface SuggestedCategory {
@@ -9,6 +10,7 @@ export interface SuggestedCategory {
account: string | null account: string | null
confidence: number confidence: number
source: 'mapping_rule' | 'pattern' | 'history' | 'ai' source: 'mapping_rule' | 'pattern' | 'history' | 'ai'
match_reason?: string
} }
const CATEGORY_LABELS: Record<string, string> = { const CATEGORY_LABELS: Record<string, string> = {
@@ -22,6 +24,10 @@ const CATEGORY_LABELS: Record<string, string> = {
expense_marketing: 'Marknadsföring', expense_marketing: 'Marknadsföring',
expense_professional_services: 'Konsulter', expense_professional_services: 'Konsulter',
expense_education: 'Utbildning', expense_education: 'Utbildning',
expense_representation: 'Representation',
expense_consumables: 'Material',
expense_vehicle: 'Bil & drivmedel',
expense_telecom: 'Telefon & internet',
expense_bank_fees: 'Bankavgift', expense_bank_fees: 'Bankavgift',
expense_card_fees: 'Kortavgift', expense_card_fees: 'Kortavgift',
expense_currency_exchange: 'Valutaväxling', expense_currency_exchange: 'Valutaväxling',
@@ -71,13 +77,17 @@ export function getSuggestedCategories(
const category = accountToCategory(rule.debit_account, transaction.amount) const category = accountToCategory(rule.debit_account, transaction.amount)
if (category && !seen.has(category)) { if (category && !seen.has(category)) {
seen.add(category) seen.add(category)
suggestions.push({ const suggestion: SuggestedCategory = {
category: category as TransactionCategory, category: category as TransactionCategory,
label: CATEGORY_LABELS[category] || category, label: CATEGORY_LABELS[category] || category,
account: rule.debit_account, account: rule.debit_account,
confidence: rule.confidence_score || 0.8, confidence: rule.confidence_score || 0.8,
source: 'mapping_rule', source: 'mapping_rule',
}) }
if (rule.source === 'user_description' && rule.user_description) {
suggestion.match_reason = `Matchad på din beskrivning: ${rule.user_description}`
}
suggestions.push(suggestion)
} }
} }
} }
@@ -139,9 +149,14 @@ function accountToCategory(account: string, amount: number): string | null {
const expenseMap: Record<string, string> = { const expenseMap: Record<string, string> = {
'5410': 'expense_equipment', '5410': 'expense_equipment',
'5420': 'expense_software', '5420': 'expense_software',
'5460': 'expense_consumables',
'5611': 'expense_vehicle',
'5800': 'expense_travel', '5800': 'expense_travel',
'5010': 'expense_office', '5010': 'expense_office',
'5910': 'expense_marketing', '5910': 'expense_marketing',
'6071': 'expense_representation',
'6072': 'expense_representation',
'6200': 'expense_telecom',
'6530': 'expense_professional_services', '6530': 'expense_professional_services',
'6570': 'expense_bank_fees', '6570': 'expense_bank_fees',
'6991': 'expense_other', '6991': 'expense_other',
@@ -150,19 +165,40 @@ function accountToCategory(account: string, amount: number): string | null {
return expenseMap[account] || null return expenseMap[account] || null
} }
/**
* Source priority for sorting — higher-quality sources rank first.
* AI and mapping rules always beat history-based guesses.
*/
const SOURCE_PRIORITY: Record<SuggestedCategory['source'], number> = {
mapping_rule: 3,
ai: 2,
pattern: 1,
history: 0,
}
/** /**
* Merge AI-generated suggestions into existing suggestion list. * Merge AI-generated suggestions into existing suggestion list.
* AI suggestions take priority over history-based ones.
* Deduplicates by category, preserving the higher-confidence entry. * Deduplicates by category, preserving the higher-confidence entry.
* When transactionAmount is provided, filters out wrong-direction suggestions.
*/ */
export function mergeAiSuggestions( export function mergeAiSuggestions(
existing: SuggestedCategory[], existing: SuggestedCategory[],
aiSuggestions: { category: string; basAccount: string; confidence: number; reasoning: string }[] aiSuggestions: { category: string; basAccount: string; confidence: number; reasoning: string }[],
transactionAmount?: number
): SuggestedCategory[] { ): SuggestedCategory[] {
const seen = new Set<string>(existing.map((s) => s.category)) const seen = new Set<string>(existing.map((s) => s.category))
const merged = [...existing] const merged = [...existing]
for (const ai of aiSuggestions) { for (const ai of aiSuggestions) {
if (seen.has(ai.category)) continue if (seen.has(ai.category)) continue
// Skip suggestions that don't match transaction direction
if (transactionAmount !== undefined) {
if (transactionAmount > 0 && ai.category.startsWith('expense_')) continue
if (transactionAmount < 0 && ai.category.startsWith('income_')) continue
}
seen.add(ai.category) seen.add(ai.category)
merged.push({ merged.push({
@@ -174,8 +210,13 @@ export function mergeAiSuggestions(
}) })
} }
// Sort by source priority first, then by confidence within the same tier
return merged return merged
.sort((a, b) => b.confidence - a.confidence) .sort((a, b) => {
const priorityDiff = SOURCE_PRIORITY[b.source] - SOURCE_PRIORITY[a.source]
if (priorityDiff !== 0) return priorityDiff
return b.confidence - a.confidence
})
.slice(0, 5) .slice(0, 5)
} }
@@ -198,13 +239,25 @@ export interface SuggestedTemplate {
/** /**
* Get suggested booking templates for a transaction. * 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, transaction: Transaction,
entityType?: EntityType entityType?: EntityType
): SuggestedTemplate[] { ): Promise<SuggestedTemplate[]> {
const matches = findMatchingTemplates(transaction, entityType) 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) => ({ return matches.map((m: TemplateMatch) => ({
template_id: m.template.id, template_id: m.template.id,
name_sv: m.template.name_sv, name_sv: m.template.name_sv,
@@ -0,0 +1,72 @@
-- Migration 040: Booking Template Embeddings
-- Stores pre-computed embeddings for booking templates to enable
-- semantic similarity search for transaction classification.
-- ============================================================
-- booking_template_embeddings
-- ============================================================
create table public.booking_template_embeddings (
id uuid primary key default gen_random_uuid(),
template_id text unique not null,
embedding extensions.vector(1536) not null,
embedding_text text not null,
model text not null,
schema_version text not null,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
alter table public.booking_template_embeddings enable row level security;
-- Shared system data — any authenticated user can read
create policy "booking_template_embeddings_select" on public.booking_template_embeddings
for select using (true);
-- Only service role can insert/update (no user-scoped writes)
-- RLS blocks regular users from writing; service role bypasses RLS
create trigger booking_template_embeddings_updated_at
before update on public.booking_template_embeddings
for each row execute function public.update_updated_at_column();
-- HNSW index for fast cosine similarity search
create index idx_booking_template_embeddings_embedding
on public.booking_template_embeddings
using hnsw (embedding extensions.vector_cosine_ops);
create index idx_booking_template_embeddings_template_id
on public.booking_template_embeddings (template_id);
-- ============================================================
-- match_booking_templates RPC (vector similarity search)
-- ============================================================
create or replace function public.match_booking_templates(
query_embedding extensions.vector,
match_count int default 20,
match_threshold float default 0.5
)
returns table (
template_id text,
embedding_text text,
similarity float
)
language plpgsql
security definer
set search_path = public, extensions
as $$
begin
return query
select
bte.template_id,
bte.embedding_text,
1 - (bte.embedding <=> query_embedding)::float as similarity
from public.booking_template_embeddings bte
where 1 - (bte.embedding <=> query_embedding) >= match_threshold
order by bte.embedding <=> query_embedding
limit match_count;
end;
$$;
grant execute on function public.match_booking_templates(extensions.vector, int, float) to authenticated;
@@ -0,0 +1,23 @@
-- Migration: user_description_matching
-- Adds source tracking, user description text, and template_id to mapping_rules
-- to support user-description-based transaction matching.
--
-- Safe migration: all new columns have defaults or are nullable,
-- so existing rows remain valid.
-- 1. Add source column to track rule origin
ALTER TABLE public.mapping_rules
ADD COLUMN IF NOT EXISTS source TEXT NOT NULL DEFAULT 'auto'
CHECK (source IN ('auto', 'user_description', 'system'));
-- 2. Add user_description column to store the user's plain-language text
ALTER TABLE public.mapping_rules
ADD COLUMN IF NOT EXISTS user_description TEXT;
-- 3. Add template_id column to store the confirmed booking template
ALTER TABLE public.mapping_rules
ADD COLUMN IF NOT EXISTS template_id TEXT;
-- 4. Index for efficient upsert-on-merchant lookup by source
CREATE INDEX IF NOT EXISTS idx_mapping_rules_user_merchant_source
ON public.mapping_rules (user_id, merchant_pattern, source);
+2 -1
View File
@@ -20,7 +20,8 @@
], ],
"paths": { "paths": {
"@/*": ["./*"] "@/*": ["./*"]
} },
"types": ["vitest/globals"]
}, },
"include": [ "include": [
"next-env.d.ts", "next-env.d.ts",
+8
View File
@@ -13,6 +13,10 @@ export type TransactionCategory =
| 'expense_marketing' | 'expense_marketing'
| 'expense_professional_services' | 'expense_professional_services'
| 'expense_education' | 'expense_education'
| 'expense_representation'
| 'expense_consumables'
| 'expense_vehicle'
| 'expense_telecom'
| 'expense_bank_fees' | 'expense_bank_fees'
| 'expense_card_fees' | 'expense_card_fees'
| 'expense_currency_exchange' | 'expense_currency_exchange'
@@ -899,6 +903,10 @@ export interface MappingRule {
// Capitalization // Capitalization
capitalization_threshold: number | null capitalization_threshold: number | null
capitalized_debit_account: string | null capitalized_debit_account: string | null
// Source tracking
source: 'auto' | 'user_description' | 'system'
user_description: string | null
template_id: string | null
// Meta // Meta
is_active: boolean is_active: boolean
created_at: string created_at: string