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,
}
// Fetch enabled extension toggles
const { data: enabledToggles } = await supabase
.from('extension_toggles')
.select('sector_slug, extension_slug')
.eq('user_id', user.id)
.eq('enabled', true)
return (
<DashboardContent
firstName={firstName}
@@ -272,6 +279,7 @@ export default async function DashboardPage() {
missingUnderlagCount,
}}
onboardingProgress={onboardingProgress}
enabledExtensions={enabledToggles || []}
/>
)
}
+66 -3
View File
@@ -19,11 +19,12 @@ import InboxZeroState from '@/components/transactions/InboxZeroState'
import InvoiceMatchDialog from '@/components/transactions/InvoiceMatchDialog'
import TransactionBookingDialog from '@/components/transactions/TransactionBookingDialog'
import QuickReviewDialog from '@/components/transactions/QuickReviewDialog'
import DescribeTransactionDialog from '@/components/transactions/DescribeTransactionDialog'
import { EXPENSE_CATEGORIES, INCOME_CATEGORIES } from '@/components/transactions/transaction-types'
import { getDefaultAccountForCategory, getDefaultVatTreatmentForCategory } from '@/lib/bookkeeping/category-mapping'
import type { TransactionWithInvoice, ViewMode, CategorizeHandler } from '@/components/transactions/transaction-types'
import type { TransactionCategory, CreateTransactionInput, Invoice, Customer, VatTreatment } from '@/types'
import type { SuggestedCategory } from '@/lib/transactions/category-suggestions'
import type { SuggestedCategory, SuggestedTemplate } from '@/lib/transactions/category-suggestions'
export default function TransactionsPage() {
const [transactions, setTransactions] = useState<TransactionWithInvoice[]>([])
@@ -33,6 +34,7 @@ export default function TransactionsPage() {
const [isCreating, setIsCreating] = useState(false)
const [showSwipeView, setShowSwipeView] = useState(false)
const [categorySuggestions, setCategorySuggestions] = useState<Record<string, SuggestedCategory[]>>({})
const [templateSuggestions, setTemplateSuggestions] = useState<Record<string, SuggestedTemplate[]>>({})
const [isLoadingSuggestions, setIsLoadingSuggestions] = useState(false)
const [processingId, setProcessingId] = useState<string | null>(null)
@@ -57,6 +59,10 @@ export default function TransactionsPage() {
const [quickReviewCategory, setQuickReviewCategory] = useState<TransactionCategory | null>(null)
const [quickReviewLabel, setQuickReviewLabel] = useState('')
// Describe dialog
const [describeDialogOpen, setDescribeDialogOpen] = useState(false)
const [describeDialogTransaction, setDescribeDialogTransaction] = useState<TransactionWithInvoice | null>(null)
// Entity type for tooltip context
const [entityType, setEntityType] = useState<string>('enskild_firma')
@@ -130,6 +136,9 @@ export default function TransactionsPage() {
if (data.suggestions) {
setCategorySuggestions(data.suggestions)
}
if (data.template_suggestions) {
setTemplateSuggestions(data.template_suggestions)
}
} catch {
// Non-critical
}
@@ -409,13 +418,29 @@ export default function TransactionsPage() {
async function handleBatchCategorize(category: TransactionCategory, vatTreatment?: VatTreatment) {
const ids = Array.from(selectedIds)
setBatchProgress({ done: 0, total: ids.length })
let successes = 0
const failures: string[] = []
for (let i = 0; i < ids.length; i++) {
await handleCategorize(ids[i], true, category, vatTreatment)
const result = await handleCategorize(ids[i], true, category, vatTreatment)
if (result) {
successes++
} else {
const tx = transactions.find((t) => t.id === ids[i])
failures.push(tx?.description || ids[i])
}
setBatchProgress({ done: i + 1, total: ids.length })
}
setBatchProgress(null)
setShowBatchSelector(false)
toast({ title: 'Klart', description: `${ids.length} transaktioner bokförda` })
if (failures.length === 0) {
toast({ title: 'Klart', description: `${successes} transaktioner bokförda` })
} else {
toast({
title: 'Delvis klart',
description: `${successes} lyckades, ${failures.length} misslyckades: ${failures.slice(0, 3).join(', ')}${failures.length > 3 ? '...' : ''}`,
variant: 'destructive',
})
}
exitBatchMode()
}
@@ -468,12 +493,40 @@ export default function TransactionsPage() {
return journalEntryId
}
function openDescribeDialog(transaction: TransactionWithInvoice) {
setDescribeDialogTransaction(transaction)
setDescribeDialogOpen(true)
}
function handleDescribeCategorized(transactionId: string, journalEntryId: string | null) {
setExitingIds((prev) => new Set(prev).add(transactionId))
setTimeout(() => {
setTransactions((prev) =>
prev.map((t) =>
t.id === transactionId
? { ...t, is_business: true, journal_entry_id: journalEntryId }
: t
)
)
setExitingIds((prev) => {
const next = new Set(prev)
next.delete(transactionId)
return next
})
}, 350)
}
function handleBatchApplied() {
fetchTransactions()
}
// Swipe view
if (showSwipeView && uncategorizedTransactions.length > 0) {
return (
<SwipeCategorizationView
transactions={uncategorizedTransactions}
suggestions={categorySuggestions}
templateSuggestions={templateSuggestions}
onCategorize={handleCategorize}
onMatchInvoice={handleMatchInvoice}
onClose={() => setShowSwipeView(false)}
@@ -527,6 +580,7 @@ export default function TransactionsPage() {
key={transaction.id}
transaction={transaction}
suggestions={categorySuggestions[transaction.id]}
templateSuggestions={templateSuggestions[transaction.id]}
processingId={processingId}
isBatchMode={isBatchMode}
isSelected={selectedIds.has(transaction.id)}
@@ -535,6 +589,7 @@ export default function TransactionsPage() {
onMarkPrivate={handleMarkPrivate}
onOpenMatchDialog={openMatchDialog}
onOpenCategoryDialog={openCategoryDialog}
onOpenDescribe={openDescribeDialog}
onOpenQuickReview={handleOpenQuickReview}
onToggleSelect={toggleBatchSelect}
/>
@@ -603,6 +658,14 @@ export default function TransactionsPage() {
onConfirm={handleQuickReviewConfirm}
/>
<DescribeTransactionDialog
open={describeDialogOpen}
onOpenChange={setDescribeDialogOpen}
transaction={describeDialogTransaction}
onCategorized={handleDescribeCategorized}
onBatchApplied={handleBatchApplied}
/>
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogContent>
<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_price: 1000,
line_total: 10000,
vat_rate: 25,
vat_amount: 2500,
created_at: '2024-06-15T14:30:00Z',
},
],
+4
View File
@@ -318,6 +318,8 @@ describe('POST /api/invoices (create credit note)', () => {
unit: 'tim',
unit_price: 1000,
line_total: 10000,
vat_rate: 25,
vat_amount: 2500,
created_at: '2024-06-15T14:30:00Z',
},
]
@@ -385,6 +387,8 @@ describe('POST /api/invoices (create credit note)', () => {
unit: 'st',
unit_price: 1000,
line_total: 1000,
vat_rate: 25,
vat_amount: 250,
created_at: '2024-06-15T14:30:00Z',
},
],
@@ -160,7 +160,9 @@ describe('POST /api/transactions/[id]/categorize', () => {
'GitHub',
'6200',
'1930',
false
false,
undefined,
undefined
)
expect(emitSpy).toHaveBeenCalledWith(
expect.objectContaining({ type: 'transaction.categorized' })
@@ -256,7 +256,9 @@ export async function POST(
transaction.merchant_name,
mappingResult.debit_account,
mappingResult.credit_account,
!is_business
!is_business,
body.user_description,
body.template_id
)
} catch (err) {
console.error('Failed to save mapping rule:', err)
@@ -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 type { Transaction, TransactionCategory, EntityType } from '@/types'
// Minimum confidence threshold — below this, suggestions are considered weak
// and we trigger on-demand AI categorization to get better results.
const WEAK_SUGGESTION_THRESHOLD = 0.55
/**
* Check if suggestions are "weak" — only history-based fallbacks
* with no strong rule/pattern/AI match.
*/
function hasWeakSuggestions(result: SuggestedCategory[]): boolean {
if (result.length === 0) return true
// Weak if the best suggestion is below threshold
const bestConfidence = Math.max(...result.map((s) => s.confidence))
if (bestConfidence < WEAK_SUGGESTION_THRESHOLD) return true
// Weak if all suggestions are from history only (no rule/pattern/ai match)
if (result.every((s) => s.source === 'history')) return true
return false
}
/**
* POST /api/transactions/suggest-categories
* Batch endpoint for getting category suggestions for multiple transactions
@@ -70,11 +88,18 @@ export async function POST(request: Request) {
.eq('extension_id', 'ai-categorization')
.in('key', aiKeys)
const aiSuggestionsMap: Record<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) {
for (const record of aiRecords) {
const txId = record.key.replace('suggestion:', '')
aiSuggestionsMap[txId] = record.value as { category: string; basAccount: string; confidence: number; reasoning: string }
const value = record.value
// Handle both single object (old) and array (new) storage formats
if (Array.isArray(value)) {
aiSuggestionsMap[txId] = value as AiSuggestion[]
} else {
aiSuggestionsMap[txId] = [value as AiSuggestion]
}
}
}
@@ -86,9 +111,10 @@ export async function POST(request: Request) {
.single()
const entityType = (settings?.entity_type as EntityType) || undefined
// Generate suggestions for each transaction
// Generate initial suggestions for each transaction
const suggestions: Record<string, SuggestedCategory[]> = {}
const template_suggestions: Record<string, SuggestedTemplate[]> = {}
const needsAiIds: string[] = []
for (const tx of transactions) {
let result = getSuggestedCategories(
@@ -97,14 +123,74 @@ export async function POST(request: Request) {
categoryHistory
)
// Merge AI suggestions if available
const aiSuggestion = aiSuggestionsMap[tx.id]
if (aiSuggestion) {
result = mergeAiSuggestions(result, [aiSuggestion])
// Merge pre-computed AI suggestions if available
const aiSuggestions = aiSuggestionsMap[tx.id]
if (aiSuggestions && aiSuggestions.length > 0) {
result = mergeAiSuggestions(result, aiSuggestions, tx.amount)
} else if (hasWeakSuggestions(result)) {
// No pre-computed AI suggestion AND rule-based suggestions are weak —
// mark this transaction for on-demand AI categorization
needsAiIds.push(tx.id)
}
suggestions[tx.id] = result
template_suggestions[tx.id] = 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 })
+8
View File
@@ -198,6 +198,13 @@ export default async function RootPage() {
streak_count: streakCount,
}
// Fetch enabled extension toggles
const { data: enabledToggles } = await supabase
.from('extension_toggles')
.select('sector_slug, extension_slug')
.eq('user_id', user.id)
.eq('enabled', true)
return (
<div className="min-h-screen bg-background">
<DashboardNav companyName={settings.company_name || 'Min verksamhet'} entityType={settings.entity_type || 'enskild_firma'} />
@@ -220,6 +227,7 @@ export default async function RootPage() {
receiptQueue,
missingUnderlagCount,
}}
enabledExtensions={enabledToggles || []}
/>
</div>
<ChatWidget />
+5 -1
View File
@@ -206,8 +206,11 @@ export default function JournalEntryList({ periodId }: Props) {
</div>
</button>
{isExpanded && lines.length > 0 && (
{isExpanded && (
<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">
<thead>
<tr className="border-b text-left text-muted-foreground">
@@ -261,6 +264,7 @@ export default function JournalEntryList({ periodId }: Props) {
</tr>
</tfoot>
</table>
)}
<JournalEntryAttachments
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
} from '@/lib/tax/calculator'
import FSkattWarningCard from '@/components/dashboard/FSkattWarningCard'
import { UpcomingDeadlinesWidget } from '@/components/calendar/UpcomingDeadlinesWidget'
import { UpcomingDeadlinesWidget } from '@/components/deadlines/UpcomingDeadlinesWidget'
import { TaxTodoWidget } from '@/components/deadlines/TaxTodoWidget'
import NewUserChecklist from '@/components/onboarding/NewUserChecklist'
import {
TrendingUp,
@@ -27,9 +28,11 @@ import {
Landmark,
CheckCircle2,
ClipboardList,
MessageCircle,
FileWarning,
} from 'lucide-react'
import { getExtensionDefinition } from '@/lib/extensions/sectors'
import { resolveIcon } from '@/lib/extensions/icon-resolver'
import type { QuickActionDefinition } from '@/lib/extensions/types'
import type { CompanySettings, EntityType, Deadline, ReceiptQueueSummary, OnboardingProgress } from '@/types'
interface DashboardContentProps {
@@ -51,11 +54,31 @@ interface DashboardContentProps {
missingUnderlagCount: number
}
onboardingProgress?: OnboardingProgress
enabledExtensions?: { sector_slug: string; extension_slug: string }[]
}
export default function DashboardContent({ firstName, settings, summary, onboardingProgress }: DashboardContentProps) {
export default function DashboardContent({ firstName, settings, summary, onboardingProgress, enabledExtensions }: DashboardContentProps) {
const [showAllAlerts, setShowAllAlerts] = useState(false)
const [showMore, setShowMore] = useState(false)
const [liveExtensions, setLiveExtensions] = useState(enabledExtensions ?? [])
useEffect(() => {
setLiveExtensions(enabledExtensions ?? [])
}, [enabledExtensions])
useEffect(() => {
const handler = ((e: CustomEvent<{ sector_slug: string; extension_slug: string; enabled: boolean }>) => {
setLiveExtensions(prev => {
if (e.detail.enabled) {
if (prev.some(x => x.sector_slug === e.detail.sector_slug && x.extension_slug === e.detail.extension_slug)) return prev
return [...prev, { sector_slug: e.detail.sector_slug, extension_slug: e.detail.extension_slug }]
}
return prev.filter(x => !(x.sector_slug === e.detail.sector_slug && x.extension_slug === e.detail.extension_slug))
})
}) as EventListener
window.addEventListener('extension-toggle-changed', handler)
return () => window.removeEventListener('extension-toggle-changed', handler)
}, [])
const entityType = (settings?.entity_type as EntityType) || 'enskild_firma'
const preliminaryTaxMonthly = settings?.preliminary_tax_monthly || 0
@@ -205,7 +228,15 @@ export default function DashboardContent({ firstName, settings, summary, onboard
const visibleAlerts = showAllAlerts ? alertItems : alertItems.slice(0, MAX_VISIBLE_ALERTS)
const hasMoreAlerts = alertItems.length > MAX_VISIBLE_ALERTS
const openAiChat = () => window.dispatchEvent(new Event('open-ai-chat'))
// Build extension quick actions from enabled extensions
const extensionQuickActions: (QuickActionDefinition & { key: string })[] = liveExtensions
.map(toggle => {
const def = getExtensionDefinition(toggle.sector_slug, toggle.extension_slug)
if (!def?.quickAction) return null
return { ...def.quickAction, key: `${toggle.sector_slug}/${toggle.extension_slug}` }
})
.filter((a): a is QuickActionDefinition & { key: string } => a !== null)
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
// Quick action items
const quickActions = [
@@ -242,7 +273,7 @@ export default function DashboardContent({ firstName, settings, summary, onboard
const todoItems: { label: string; href: string; count: number; variant: 'destructive' | 'warning' | 'default' }[] = []
if (passedDeadlines.length > 0) {
todoItems.push({ label: 'passerade deadlines', href: '/calendar', count: passedDeadlines.length, variant: 'destructive' })
todoItems.push({ label: 'passerade deadlines', href: '/deadlines', count: passedDeadlines.length, variant: 'destructive' })
}
if (summary.overdueInvoicesCount > 0) {
todoItems.push({ label: 'förfallna fakturor', href: '/invoices?status=unpaid', count: summary.overdueInvoicesCount, variant: 'destructive' })
@@ -431,18 +462,42 @@ export default function DashboardContent({ firstName, settings, summary, onboard
</Link>
)
})}
{/* AI assistant quick action */}
<button onClick={openAiChat} className="group text-left">
<div className="flex items-center gap-3 px-4 py-3 rounded-xl border border-border/40 hover:bg-muted/30 transition-colors duration-150">
<div className="p-2 rounded-lg bg-muted/50">
<MessageCircle className="h-4 w-4 text-muted-foreground" />
</div>
<div className="min-w-0">
<p className="text-sm font-medium truncate">AI-assistent</p>
<p className="text-xs text-muted-foreground truncate hidden md:block">Fråga om bokföring</p>
</div>
</div>
</button>
{/* Extension quick actions */}
{extensionQuickActions.map((action) => {
const Icon = resolveIcon(action.icon)
if (action.href) {
return (
<Link key={action.key} href={action.href} className="group">
<div className="flex items-center gap-3 px-4 py-3 rounded-xl border border-border/40 hover:bg-muted/30 transition-colors duration-150">
<div className="p-2 rounded-lg bg-muted/50">
<Icon className="h-4 w-4 text-muted-foreground" />
</div>
<div className="min-w-0">
<p className="text-sm font-medium truncate">{action.label}</p>
<p className="text-xs text-muted-foreground truncate hidden md:block">{action.description}</p>
</div>
</div>
</Link>
)
}
return (
<button
key={action.key}
onClick={() => window.dispatchEvent(new Event(action.event!))}
className="group text-left"
>
<div className="flex items-center gap-3 px-4 py-3 rounded-xl border border-border/40 hover:bg-muted/30 transition-colors duration-150">
<div className="p-2 rounded-lg bg-muted/50">
<Icon className="h-4 w-4 text-muted-foreground" />
</div>
<div className="min-w-0">
<p className="text-sm font-medium truncate">{action.label}</p>
<p className="text-xs text-muted-foreground truncate hidden md:block">{action.description}</p>
</div>
</div>
</button>
)
})}
</div>
</section>
@@ -453,6 +508,13 @@ export default function DashboardContent({ firstName, settings, summary, onboard
</section>
)}
{/* Tax todo widget — visible when there are incomplete tax deadlines */}
{summary.deadlines?.some(d => d.deadline_type === 'tax' && !d.is_completed) && (
<section className="mb-10">
<TaxTodoWidget deadlines={summary.deadlines} />
</section>
)}
{/* Alerts section — always visible */}
{alertItems.length > 0 && (
<section id="alerts-section" className="mb-10">
+1 -1
View File
@@ -46,7 +46,7 @@ interface NavItem {
// All nav items for sidebar and mobile drawer
const navItems: NavItem[] = [
{ href: '/', label: 'Översikt', icon: LayoutDashboard, group: 'main' },
{ href: '/calendar', label: 'Kalender', icon: Calendar, group: 'main' },
{ href: '/deadlines', label: 'Deadlines', icon: Calendar, group: 'main' },
{ href: '/invoices', label: 'Fakturor', icon: Receipt, group: 'finans' },
{ href: '/customers', label: 'Kunder', icon: Users, group: 'finans' },
{ href: '/suppliers', label: 'Leverantörer', icon: Building2, group: 'finans' },
@@ -231,9 +231,9 @@ export function TaxTodoWidget({ deadlines, onStatusChange }: TaxTodoWidgetProps)
</p>
)}
<Link href="/calendar" className="block">
<Link href="/deadlines" className="block">
<Button variant="ghost" className="w-full justify-between">
Visa alla skattedeadlines
Visa alla deadlines
<ChevronRight className="h-4 w-4" />
</Button>
</Link>
@@ -201,9 +201,9 @@ export function UpcomingDeadlinesWidget({ deadlines, maxItems = 5, onStatusChang
)
})}
<Link href="/calendar" className="block">
<Link href="/deadlines" className="block">
<Button variant="ghost" className="w-full justify-between mt-2">
Visa kalender
Visa alla deadlines
<ChevronRight className="h-4 w-4" />
</Button>
</Link>
+6
View File
@@ -0,0 +1,6 @@
export { DeadlineCard } from './DeadlineCard'
export { DeadlineFilters } from './DeadlineFilters'
export { DeadlineForm } from './DeadlineForm'
export { DeadlineList } from './DeadlineList'
export { UpcomingDeadlinesWidget } from './UpcomingDeadlinesWidget'
export { TaxTodoWidget } from './TaxTodoWidget'
@@ -3,10 +3,11 @@
import { useState, useEffect, useCallback } from 'react'
import { createClient } from '@/lib/supabase/client'
import { useToast } from '@/components/ui/use-toast'
import { PaymentCalendar } from '@/components/calendar/PaymentCalendar'
import { PaymentCalendar } from '@/extensions/general/calendar/components/PaymentCalendar'
import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry'
import type { Invoice, Deadline } from '@/types'
export default function CalendarPage() {
export default function CalendarWorkspace({ userId }: WorkspaceComponentProps) {
const [invoices, setInvoices] = useState<Invoice[]>([])
const [deadlines, setDeadlines] = useState<Deadline[]>([])
const [customers, setCustomers] = useState<{ id: string; name: string }[]>([])
@@ -18,7 +19,6 @@ export default function CalendarPage() {
setIsLoading(true)
try {
// Fetch invoices with customer names
const { data: invoicesData, error: invoicesError } = await supabase
.from('invoices')
.select('*, customer:customers(name)')
@@ -26,7 +26,6 @@ export default function CalendarPage() {
if (invoicesError) throw invoicesError
// Fetch deadlines with customer names
const { data: deadlinesData, error: deadlinesError } = await supabase
.from('deadlines')
.select('*, customer:customers(name)')
@@ -34,7 +33,6 @@ export default function CalendarPage() {
if (deadlinesError) throw deadlinesError
// Fetch customers for the form
const { data: customersData, error: customersError } = await supabase
.from('customers')
.select('id, name')
@@ -45,10 +43,10 @@ export default function CalendarPage() {
setInvoices(invoicesData || [])
setDeadlines(deadlinesData || [])
setCustomers(customersData || [])
} catch (error) {
} catch {
toast({
title: 'Fel',
description: 'Kunde inte hämta data',
description: 'Kunde inte hamta data',
variant: 'destructive',
})
} finally {
@@ -101,7 +99,7 @@ export default function CalendarPage() {
})
fetchData()
} catch (error) {
} catch {
toast({
title: 'Fel',
description: 'Kunde inte uppdatera deadline',
@@ -112,31 +110,20 @@ export default function CalendarPage() {
if (isLoading) {
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Kalender</h1>
</div>
<div className="animate-pulse">
<div className="h-10 bg-muted rounded w-48 mb-4" />
<div className="h-96 bg-muted rounded" />
</div>
<div className="animate-pulse">
<div className="h-10 bg-muted rounded w-48 mb-4" />
<div className="h-96 bg-muted rounded" />
</div>
)
}
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Kalender</h1>
</div>
<PaymentCalendar
invoices={invoices}
deadlines={deadlines}
customers={customers}
onDeadlineCreate={handleDeadlineCreate}
onDeadlineToggle={handleDeadlineToggle}
/>
</div>
<PaymentCalendar
invoices={invoices}
deadlines={deadlines}
customers={customers}
onDeadlineCreate={handleDeadlineCreate}
onDeadlineToggle={handleDeadlineToggle}
/>
)
}
@@ -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 { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog'
import { formatCurrency, formatDate } from '@/lib/utils'
import { CheckCircle2, AlertTriangle } from 'lucide-react'
import type { TransactionWithInvoice } from './transaction-types'
interface InvoiceMatchDialogProps {
@@ -66,6 +67,37 @@ export default function InvoiceMatchDialog({
</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 */}
<div className="rounded-lg bg-muted/50 p-4 space-y-2">
<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 { ArrowUpRight, ArrowDownRight, Check, Paperclip, ChevronDown, ChevronUp } from 'lucide-react'
import { getDefaultAccountForCategory } from '@/lib/bookkeeping/category-mapping'
import JournalEntryPreview from './JournalEntryPreview'
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
import DocumentUploadZone from '@/components/bookkeeping/DocumentUploadZone'
import type { UploadedFile } from '@/components/bookkeeping/DocumentUploadZone'
import VatTreatmentSelect from './VatTreatmentSelect'
import { VAT_TREATMENT_OPTIONS } from './transaction-types'
import type { TransactionWithInvoice } from './transaction-types'
import type { TransactionCategory, VatTreatment, BASAccount } from '@/types'
@@ -49,6 +51,7 @@ export default function QuickReviewDialog({
const [error, setError] = useState<string | null>(null)
const [uploadedFiles, setUploadedFiles] = useState<UploadedFile[]>([])
const [showUploadZone, setShowUploadZone] = useState(false)
const [showVatDropdown, setShowVatDropdown] = useState(false)
// Handle account changes — clear VAT for liability/equity accounts (class 2)
const handleAccountChange = useCallback((account: string) => {
@@ -174,6 +177,15 @@ export default function QuickReviewDialog({
</div>
</div>
{/* Journal entry preview */}
<JournalEntryPreview
amount={transaction.amount}
currency={transaction.currency}
category={category}
vatTreatment={isLiabilityAccount ? 'none' : vatTreatment}
accountOverride={accountOverride}
/>
{/* Account */}
<div>
<label className="text-sm font-medium text-muted-foreground">Konto</label>
@@ -190,14 +202,26 @@ export default function QuickReviewDialog({
<div>
<label className="text-sm font-medium text-muted-foreground">Momsbehandling</label>
<div className="mt-1">
<VatTreatmentSelect
value={isLiabilityAccount ? 'none' : vatTreatment}
onValueChange={setVatTreatment}
disabled={isLiabilityAccount}
/>
{isLiabilityAccount && (
<p className="text-xs text-muted-foreground mt-1">
Ingen moms för skuld-/eget kapital-konton
{isLiabilityAccount ? (
<p className="text-sm text-muted-foreground">
Ingen moms for skuld-/eget kapital-konton
</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>
@@ -10,18 +10,22 @@ import VatTreatmentSelect from './VatTreatmentSelect'
import { formatCurrency, formatDate } from '@/lib/utils'
import { checkExpenseWarnings } from '@/lib/tax/expense-warnings'
import { getDefaultAccountForCategory, getDefaultVatTreatmentForCategory } from '@/lib/bookkeeping/category-mapping'
import JournalEntryPreview from './JournalEntryPreview'
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
import DocumentUploadZone from '@/components/bookkeeping/DocumentUploadZone'
import type { UploadedFile } from '@/components/bookkeeping/DocumentUploadZone'
import { X, ArrowLeft, ArrowRight, Building, AlertTriangle, Check, FileText, Link2, Receipt as ReceiptIcon, SkipForward, Paperclip, ChevronDown, ChevronUp } from 'lucide-react'
import { X, ArrowLeft, ArrowRight, Building, AlertTriangle, Check, FileText, Link2, Receipt as ReceiptIcon, SkipForward, Paperclip, ChevronDown, ChevronUp, MessageSquareText } from 'lucide-react'
import DescribeTransactionDialog from './DescribeTransactionDialog'
import { formatAccountWithName } from '@/lib/bookkeeping/client-account-names'
import type { TransactionCategory, VatTreatment, BASAccount } from '@/types'
import type { SuggestedCategory } from '@/lib/transactions/category-suggestions'
import type { SuggestedCategory, SuggestedTemplate } from '@/lib/transactions/category-suggestions'
import type { TransactionWithInvoice, CategorizeHandler, MatchInvoiceHandler } from './transaction-types'
import { EXPENSE_CATEGORIES, INCOME_CATEGORIES } from './transaction-types'
import { EXPENSE_CATEGORIES, INCOME_CATEGORIES, VAT_TREATMENT_OPTIONS } from './transaction-types'
interface SwipeCategorizationViewProps {
transactions: TransactionWithInvoice[]
suggestions?: Record<string, SuggestedCategory[]>
templateSuggestions?: Record<string, SuggestedTemplate[]>
onCategorize: CategorizeHandler
onMatchInvoice?: MatchInvoiceHandler
onClose: () => void
@@ -33,6 +37,7 @@ const incomeCategories = INCOME_CATEGORIES
export default function SwipeCategorizationView({
transactions,
suggestions,
templateSuggestions,
onCategorize,
onMatchInvoice,
onClose,
@@ -52,6 +57,8 @@ export default function SwipeCategorizationView({
const [accounts, setAccounts] = useState<BASAccount[]>([])
const [uploadedFiles, setUploadedFiles] = useState<UploadedFile[]>([])
const [showUploadZone, setShowUploadZone] = useState(false)
const [showDescribeDialog, setShowDescribeDialog] = useState(false)
const [showVatDropdown, setShowVatDropdown] = useState(false)
// Clear VAT treatment when switching to a liability/equity account (class 2)
useEffect(() => {
@@ -113,6 +120,7 @@ export default function SwipeCategorizationView({
setPendingCategory(category)
setAccountOverride(defaultAccount)
setVatTreatment(defaultVat ?? 'none')
setShowVatDropdown(false)
setShowCategorySelect(false)
setShowReviewStep(true)
setError(null)
@@ -366,6 +374,15 @@ export default function SwipeCategorizationView({
</div>
</div>
{/* Journal entry preview */}
<JournalEntryPreview
amount={currentTransaction.amount}
currency={currentTransaction.currency}
category={pendingCategory}
vatTreatment={isLiabilityAccount ? 'none' : vatTreatment}
accountOverride={accountOverride}
/>
{/* Account override */}
<div>
<label className="text-sm font-medium text-muted-foreground">Konto</label>
@@ -382,15 +399,27 @@ export default function SwipeCategorizationView({
<div>
<label className="text-sm font-medium text-muted-foreground">Momsbehandling</label>
<div className="mt-1">
<VatTreatmentSelect
value={isLiabilityAccount ? 'none' : vatTreatment}
onValueChange={setVatTreatment}
disabled={isLiabilityAccount}
/>
{isLiabilityAccount && (
<p className="text-xs text-muted-foreground mt-1">
{isLiabilityAccount ? (
<p className="text-sm text-muted-foreground">
Ingen moms for skuld-/eget kapital-konton
</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>
@@ -641,7 +670,7 @@ export default function SwipeCategorizationView({
<div className="flex flex-col">
<span className="text-sm font-medium">{suggestion.label}</span>
{suggestion.account && (
<span className="text-xs text-muted-foreground">{suggestion.account}</span>
<span className="text-xs text-muted-foreground">{formatAccountWithName(suggestion.account)}</span>
)}
</div>
</Button>
@@ -650,6 +679,45 @@ export default function SwipeCategorizationView({
</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 */}
<Button
className="w-full"
@@ -678,6 +746,20 @@ export default function SwipeCategorizationView({
</Button>
</div>
</div>
<DescribeTransactionDialog
open={showDescribeDialog}
onOpenChange={setShowDescribeDialog}
transaction={currentTransaction}
onCategorized={() => {
setShowDescribeDialog(false)
moveToNext()
}}
onBatchApplied={() => {
setShowDescribeDialog(false)
moveToNext()
}}
/>
</div>
)
}
@@ -6,14 +6,16 @@ import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Checkbox } from '@/components/ui/checkbox'
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 { formatAccountWithName } from '@/lib/bookkeeping/client-account-names'
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 {
transaction: TransactionWithInvoice
suggestions?: SuggestedCategory[]
templateSuggestions?: SuggestedTemplate[]
processingId: string | null
isBatchMode: boolean
isSelected: boolean
@@ -22,6 +24,7 @@ interface TransactionInboxCardProps {
onMarkPrivate: (id: string) => void
onOpenMatchDialog: (transaction: TransactionWithInvoice) => void
onOpenCategoryDialog: (transaction: TransactionWithInvoice) => void
onOpenDescribe?: (transaction: TransactionWithInvoice) => void
onOpenQuickReview?: (transaction: TransactionWithInvoice, suggestion: SuggestedCategory) => void
onToggleSelect: (id: string) => void
onAnimationComplete?: (id: string) => void
@@ -30,6 +33,7 @@ interface TransactionInboxCardProps {
export default function TransactionInboxCard({
transaction,
suggestions,
templateSuggestions,
processingId,
isBatchMode,
isSelected,
@@ -38,6 +42,7 @@ export default function TransactionInboxCard({
onMarkPrivate,
onOpenMatchDialog,
onOpenCategoryDialog,
onOpenDescribe,
onOpenQuickReview,
onToggleSelect,
onAnimationComplete,
@@ -49,6 +54,8 @@ export default function TransactionInboxCard({
const topSuggestion = suggestions?.[0]
const isUncategorized = transaction.is_business === null && !transaction.journal_entry_id
const showCheckbox = isBatchMode && isUncategorized
const hasWeakSuggestions = !topSuggestion || topSuggestion.confidence < 0.55
const showTemplateFallback = hasWeakSuggestions && templateSuggestions && templateSuggestions.length > 0
function handleSuggestionClick(suggestion: SuggestedCategory) {
if (onOpenQuickReview) {
@@ -155,6 +162,11 @@ export default function TransactionInboxCard({
<Loader2 className="mr-1.5 h-3 w-3 animate-spin" />
) : null}
{topSuggestion.label}
{topSuggestion.account && (
<span className="ml-1 text-muted-foreground font-normal">
({formatAccountWithName(topSuggestion.account)})
</span>
)}
{topSuggestion.confidence >= 0.8 && (
<Badge variant="secondary" className="ml-1.5 text-[10px] px-1 py-0">
{Math.round(topSuggestion.confidence * 100)}%
@@ -173,9 +185,33 @@ export default function TransactionInboxCard({
disabled={isProcessing || isDisabled}
>
{suggestions[1].label}
{suggestions[1].account && (
<span className="ml-1 text-muted-foreground font-normal">
({formatAccountWithName(suggestions[1].account)})
</span>
)}
</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 */}
<TooltipProvider delayDuration={300}>
<Tooltip>
@@ -200,6 +236,20 @@ export default function TransactionInboxCard({
</Tooltip>
</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 */}
<Button
size="sm"
@@ -32,10 +32,14 @@ export interface CategoryOption {
// Shared category arrays
export const EXPENSE_CATEGORIES: CategoryOption[] = [
{ value: 'expense_representation', label: 'Representation' },
{ value: 'expense_equipment', label: 'Utrustning' },
{ value: 'expense_software', label: 'Programvara' },
{ value: 'expense_consumables', label: 'Material' },
{ value: 'expense_travel', label: 'Resor' },
{ 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_professional_services', label: 'Konsulter' },
{ value: 'expense_education', label: 'Utbildning' },
@@ -5,7 +5,9 @@
* in server components or API routes.
*
* Provider-abstracted AI categorization for Swedish BAS account mapping.
* Default implementation uses Claude Haiku for cost efficiency.
* Uses Claude Haiku with structured tool outputs for reliable JSON.
* Accepts pre-filtered candidate templates from embedding search (Tier 2)
* instead of dumping all ~100 templates into the prompt.
*/
import 'server-only'
@@ -27,11 +29,29 @@ export interface TransactionForCategorization {
currency: string
}
export interface AccountUsageEntry {
account_number: string
count: number
}
export interface MerchantHistoryEntry {
merchant_name: string
category: string
template_id: string | null
count: number
}
export interface CategorizationContext {
entityType: EntityType
recentHistory: { description: string; category: string }[]
}
export interface EnrichedCategorizationContext extends CategorizationContext {
candidateTemplates: BookingTemplate[]
userAccountUsage: AccountUsageEntry[]
merchantHistory: MerchantHistoryEntry[]
}
export interface CategorizationSuggestion {
transactionId: string
category: TransactionCategory
@@ -46,7 +66,7 @@ export interface CategorizationSuggestion {
export interface CategorizationProvider {
categorize(
transactions: TransactionForCategorization[],
context: CategorizationContext
context: CategorizationContext | EnrichedCategorizationContext
): Promise<CategorizationSuggestion[]>
}
@@ -67,36 +87,89 @@ function getCategoryAccountMap(entityType: EntityType): Record<string, { account
expense_marketing: { account: '5910', label: 'Annonsering/marknadsföring' },
expense_professional_services: { account: '6530', label: 'Redovisning/konsulttjänster' },
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_card_fees: { account: '6570', label: 'Kortavgifter' },
expense_currency_exchange: { account: '7960', label: 'Valutakursförluster' },
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.
* Filters by direction (expense/income) to keep prompt concise.
* Build template reference from candidate templates (pre-filtered by embeddings)
* or fall back to full template list if no candidates provided.
*/
function getTemplateReference(direction: 'expense' | 'income'): string {
return BOOKING_TEMPLATES
function getTemplateReference(
direction: 'expense' | 'income',
candidateTemplates?: BookingTemplate[]
): string {
const templates = candidateTemplates && candidateTemplates.length > 0
? candidateTemplates
: BOOKING_TEMPLATES
return templates
.filter((t) => t.direction === direction || t.direction === 'transfer')
.map((t) => `${t.id}: ${t.name_sv}${t.debit_account}/${t.credit_account}`)
.join('\n')
}
const NON_DEDUCTIBLE_RULES = `
ICKE-AVDRAGSGILLA KOSTNADER (svensk skatterätt):
- Kläder: Normalt inte avdragsgilla (RÅ 1988 ref. 35)
- 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§)
MOMSREGLER FÖR SPECIFIKA KATEGORIER:
- Representation/måltider: Max 300 kr/person exkl. moms (IL 16 kap 2§), konto 6071/6072
- Gåvor: Reklamgåvor max 300 kr/mottagare, representationsgåvor max 180 kr
- 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
// ============================================================
@@ -116,23 +189,40 @@ export class AnthropicCategorizationProvider implements CategorizationProvider {
async categorize(
transactions: TransactionForCategorization[],
context: CategorizationContext
context: CategorizationContext | EnrichedCategorizationContext
): Promise<CategorizationSuggestion[]> {
// Cap batch size
const batch = transactions.slice(0, MAX_BATCH_SIZE)
if (batch.length === 0) return []
const enriched = isEnrichedContext(context) ? context : null
const privateAccount = context.entityType === 'aktiebolag' ? '2893' : '2013'
const categoryAccountMap = getCategoryAccountMap(context.entityType)
// Build template references based on batch direction (most transactions will be same direction)
// Build template references — use candidate templates if available
const hasExpenses = batch.some((t) => t.amount < 0)
const hasIncome = batch.some((t) => t.amount > 0)
const candidates = enriched?.candidateTemplates
const templateRef = [
hasExpenses ? `UTGIFTSMALLAR:\n${getTemplateReference('expense')}` : '',
hasIncome ? `INTÄKTSMALLAR:\n${getTemplateReference('income')}` : '',
hasExpenses ? `UTGIFTSMALLAR:\n${getTemplateReference('expense', candidates)}` : '',
hasIncome ? `INTÄKTSMALLAR:\n${getTemplateReference('income', candidates)}` : '',
].filter(Boolean).join('\n\n')
// Build account usage context
const accountUsageContext = enriched?.userAccountUsage && enriched.userAccountUsage.length > 0
? `\nAnvändarens mest använda konton:\n${enriched.userAccountUsage
.slice(0, 15)
.map((a) => `- ${a.account_number} (${a.count} bokningar)`)
.join('\n')}`
: ''
// Build merchant history context
const merchantHistoryContext = enriched?.merchantHistory && enriched.merchantHistory.length > 0
? `\nTidigare kategorisering av dessa handlare:\n${enriched.merchantHistory
.map((m) => `- "${m.merchant_name}" → ${m.category}${m.template_id ? ` (mall: ${m.template_id})` : ''} (${m.count}x)`)
.join('\n')}`
: ''
const systemPrompt = `Du är expert på svensk bokföring och kategorisering av banktransaktioner enligt BAS-kontoplanen.
Din uppgift är att kategorisera varje transaktion till rätt mall-ID (templateId) och BAS-konto.
@@ -156,12 +246,14 @@ ${NON_DEDUCTIBLE_RULES}
REGLER:
1. Negativa belopp = utgifter, positiva = intäkter
2. Markera transaktioner som troligen är privata med isPrivate: true
3. Ange confidence 0.0-1.0 baserat på hur säker du är
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 på rätt affärskategori
4. Ange kort reasoning på svenska
5. Om en transaktion liknar privat konsumtion (kläder, gym, etc.), sätt category: "private"
6. taxCode: "MPI" för avdragsgilla affärskostnader med moms, "MP1" för intäkter med moms, null för momsfria/privata
7. Ange templateId om en bokföringsmall matchar (föredra mallar framför generiska kategorier)`
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
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 =
context.recentHistory.length > 0
@@ -181,29 +273,12 @@ REGLER:
)
.join('\n\n')
const userPrompt = `Kategorisera följande transaktioner:
${historyContext}
const userPrompt = `Kategorisera följande transaktioner med classify_transactions-verktyget.
Ange TVÅ förslag per transaktion (primärt + alternativ med lägre confidence):
${historyContext}${accountUsageContext}${merchantHistoryContext}
TRANSAKTIONER:
${transactionList}
Returnera ett JSON-objekt med följande struktur:
{
"suggestions": [
{
"transactionId": "id",
"category": "expense_software",
"templateId": "it_saas_subscription",
"basAccount": "5420",
"taxCode": "MPI",
"confidence": 0.9,
"reasoning": "Spotify-prenumeration, typisk programvarukostnad",
"isPrivate": false
}
]
}
Returnera ENDAST JSON-objektet, ingen annan text.`
${transactionList}`
let lastError: Error | null = null
@@ -212,7 +287,15 @@ Returnera ENDAST JSON-objektet, ingen annan text.`
const message = await this.client.messages.create({
model: this.model,
max_tokens: 4096,
system: systemPrompt,
system: [
{
type: 'text',
text: systemPrompt,
cache_control: { type: 'ephemeral' },
},
],
tools: [CLASSIFY_TOOL],
tool_choice: { type: 'tool', name: 'classify_transactions' },
messages: [
{
role: 'user',
@@ -221,33 +304,20 @@ Returnera ENDAST JSON-objektet, ingen annan text.`
],
})
const content = message.content[0]
if (content.type !== 'text') {
throw new Error('Unexpected response type from AI')
// Extract tool_use block from response
const toolUseBlock = message.content.find(
(block) => block.type === 'tool_use' && block.name === 'classify_transactions'
)
if (!toolUseBlock || toolUseBlock.type !== 'tool_use') {
throw new Error('No tool_use block in AI response')
}
// Strip markdown code blocks if present
let jsonText = content.text.trim()
if (jsonText.startsWith('```json')) {
jsonText = jsonText.slice(7)
} else if (jsonText.startsWith('```')) {
jsonText = jsonText.slice(3)
}
if (jsonText.endsWith('```')) {
jsonText = jsonText.slice(0, -3)
}
jsonText = jsonText.trim()
const parsed = JSON.parse(jsonText)
return this.validateSuggestions(parsed.suggestions || [], batch, context.entityType)
const input = toolUseBlock.input as { suggestions?: unknown[] }
return this.validateSuggestions(input.suggestions || [], batch, context.entityType)
} catch (error) {
lastError = error instanceof Error ? error : new Error('Unknown error')
// Don't retry on parse errors
if (error instanceof SyntaxError) {
throw new Error(`Failed to parse AI response: ${lastError.message}`)
}
if (attempt < MAX_RETRIES - 1) {
await sleep(RETRY_DELAY_MS * (attempt + 1))
}
@@ -269,6 +339,7 @@ Returnera ENDAST JSON-objektet, ingen annan text.`
const categoryAccountMap = getCategoryAccountMap(entityType)
const validTransactionIds = new Set(transactions.map((t) => t.id))
const validCategories = new Set(Object.keys(categoryAccountMap).concat(['uncategorized']))
const transactionMap = new Map(transactions.map((t) => [t.id, t]))
return raw
.filter(
@@ -277,9 +348,23 @@ Returnera ENDAST JSON-objektet, ingen annan text.`
)
.filter((s) => validTransactionIds.has(s.transactionId as string))
.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)
: '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]
@@ -290,13 +375,19 @@ Returnera ENDAST JSON-objektet, ingen annan text.`
taxCode: (s.taxCode as string) || null,
confidence: Math.max(0, Math.min(1, Number(s.confidence) || 0.5)),
reasoning: (s.reasoning as string) || '',
isPrivate: category === 'private' || Boolean(s.isPrivate),
templateId: (s.templateId as string) || undefined,
isPrivate: false,
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> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
+113 -8
View File
@@ -5,9 +5,13 @@ import {
AnthropicCategorizationProvider,
type CategorizationProvider,
type TransactionForCategorization,
type CategorizationContext,
type EnrichedCategorizationContext,
type CategorizationSuggestion,
type AccountUsageEntry,
type MerchantHistoryEntry,
} from './categorizer'
import { findSimilarTemplates } from '@/lib/bookkeeping/template-embeddings'
import type { BookingTemplate } from '@/lib/bookkeeping/booking-templates'
// ============================================================
// Settings
@@ -117,7 +121,7 @@ export async function categorizeTransactions(
currency: t.currency,
}))
const context = await buildContext(userId, supabase)
const context = await buildEnrichedContext(userId, supabase, batch)
const aiProvider = getProvider(settings.providerModel)
const suggestions = await aiProvider.categorize(batch, context)
@@ -168,7 +172,7 @@ async function handleTransactionSynced(
currency: t.currency,
}))
const context = await buildContext(userId, supabase)
const context = await buildEnrichedContext(userId, supabase, batch)
const aiProvider = getProvider(settings.providerModel)
const suggestions = await aiProvider.categorize(batch, context)
@@ -194,7 +198,11 @@ async function handleTransactionSynced(
// ============================================================
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async function buildContext(userId: string, supabase: any): Promise<CategorizationContext> {
async function buildEnrichedContext(
userId: string,
supabase: any,
transactions: TransactionForCategorization[]
): Promise<EnrichedCategorizationContext> {
// Fetch entity type
const { data: companySettings } = await supabase
.from('company_settings')
@@ -221,7 +229,92 @@ async function buildContext(userId: string, supabase: any): Promise<Categorizati
})
)
return { entityType, recentHistory }
// Fetch user's most-used accounts (top 30)
const { data: accountUsageRows } = await supabase
.from('journal_entry_lines')
.select('account_number')
.eq('user_id', userId)
const accountCounts = new Map<string, number>()
if (accountUsageRows) {
for (const row of accountUsageRows as { account_number: string }[]) {
accountCounts.set(row.account_number, (accountCounts.get(row.account_number) || 0) + 1)
}
}
const userAccountUsage: AccountUsageEntry[] = Array.from(accountCounts.entries())
.map(([account_number, count]) => ({ account_number, count }))
.sort((a, b) => b.count - a.count)
.slice(0, 30)
// Fetch merchant history for this batch's merchants
const merchantNames = [...new Set(
transactions
.map((t) => t.merchant_name)
.filter((n): n is string => n !== null && n.length > 0)
)]
let merchantHistory: MerchantHistoryEntry[] = []
if (merchantNames.length > 0) {
const { data: merchantRows } = await supabase
.from('transactions')
.select('merchant_name, category, template_id')
.eq('user_id', userId)
.not('is_business', 'is', null)
.neq('category', 'uncategorized')
.in('merchant_name', merchantNames)
.limit(200)
if (merchantRows) {
const merchantMap = new Map<string, MerchantHistoryEntry>()
for (const row of merchantRows as { merchant_name: string; category: string; template_id: string | null }[]) {
const key = `${row.merchant_name}:${row.category}`
const existing = merchantMap.get(key)
if (existing) {
existing.count++
} else {
merchantMap.set(key, {
merchant_name: row.merchant_name,
category: row.category,
template_id: row.template_id,
count: 1,
})
}
}
merchantHistory = Array.from(merchantMap.values())
.sort((a, b) => b.count - a.count)
}
}
// Find candidate templates via embedding search
// Use a representative subset of transactions to find candidates
const representativeTransactions = transactions.slice(0, 5)
const candidateMap = new Map<string, BookingTemplate>()
for (const tx of representativeTransactions) {
try {
const matches = await findSimilarTemplates(
tx as unknown as Transaction,
entityType
)
for (const m of matches) {
if (!candidateMap.has(m.template.id)) {
candidateMap.set(m.template.id, m.template)
}
}
} catch {
// Embedding search failed — continue without candidates
}
}
const candidateTemplates = Array.from(candidateMap.values())
return {
entityType,
recentHistory,
candidateTemplates,
userAccountUsage,
merchantHistory,
}
}
async function storeSuggestions(
@@ -230,16 +323,28 @@ async function storeSuggestions(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
supabase: any
): Promise<void> {
// Group suggestions by transactionId (AI now returns multiple per transaction)
const grouped: Record<string, CategorizationSuggestion[]> = {}
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,
extension_id: 'ai-categorization',
key: `suggestion:${suggestion.transactionId}`,
value: suggestion,
key: `suggestion:${txId}`,
value: txSuggestions,
},
{ 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 { CalendarDayView } from './CalendarDayView'
import { DayDetailModal } from './DayDetailModal'
import { DeadlineForm } from './DeadlineForm'
import { DeadlineForm } from '@/components/deadlines/DeadlineForm'
interface PaymentCalendarProps {
invoices: Invoice[]
+7
View File
@@ -0,0 +1,7 @@
import type { Extension } from '@/lib/extensions/types'
export const calendarExtension: Extension = {
id: 'calendar',
name: 'Kalender',
version: '1.0.0',
}
@@ -152,7 +152,7 @@ export function createTaxDeadlinePayload(
badge: '/icons/badge-72.png',
tag: `tax-deadline-${deadlineId}`,
data: {
url: '/calendar',
url: '/deadlines',
type: 'tax_deadline',
id: deadlineId,
},
@@ -28,6 +28,10 @@ const BAS_ACCOUNT_MAPPING: Record<TransactionCategory, string> = {
expense_marketing: '5910', // Annonsering
expense_professional_services: '6530', // Redovisningstjänster
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_card_fees: '6570', // Kortavgifter
expense_currency_exchange: '7960', // Valutakursförluster
@@ -273,6 +277,10 @@ export const CATEGORY_LABELS: Record<TransactionCategory, string> = {
expense_marketing: 'Marknadsföring',
expense_professional_services: 'Konsulttjänster',
expense_education: 'Utbildning',
expense_representation: 'Representation',
expense_consumables: 'Material',
expense_vehicle: 'Bil & drivmedel',
expense_telecom: 'Telefon & internet',
expense_bank_fees: 'Bankavgift',
expense_card_fees: 'Kortavgift',
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_professional_services',
'expense_education',
'expense_representation',
'expense_consumables',
'expense_vehicle',
'expense_telecom',
'expense_bank_fees',
'expense_card_fees',
'expense_currency_exchange',
@@ -306,6 +310,7 @@ export const CategorizeTransactionSchema = z.object({
template_id: z.string().optional(),
vat_treatment: VatTreatmentSchema.optional(),
account_override: accountNumber.optional(),
user_description: z.string().max(500).optional(),
})
export const BookTransactionSchema = z.object({
@@ -323,6 +328,17 @@ export const MatchSupplierInvoiceSchema = z.object({
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
// ============================================================
@@ -249,7 +249,7 @@ describe('findMatchingTemplates', () => {
mcc_code: 5817,
})
const matches = findMatchingTemplates(tx)
expect(matches.length).toBeLessThanOrEqual(5)
expect(matches.length).toBeLessThanOrEqual(20)
})
it('results are sorted by confidence descending', () => {
@@ -0,0 +1,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',
special_rules_sv: 'Ofta utländsk leverantör (USA) med omvänd skattskyldighet',
mcc_codes: [],
keywords: ['openai', 'chatgpt', 'anthropic', 'claude', 'ai', 'midjourney', 'copilot'],
keywords: ['openai', 'chatgpt', 'anthropic', 'claude', 'ai', 'midjourney', 'copilot', 'mistral', 'claude', 'gemini'],
risk_level: 'NONE',
requires_review: false,
impact_score: 7,
@@ -663,7 +663,7 @@ export const BOOKING_TEMPLATES: readonly BookingTemplate[] = [
vat_rate: 0.25,
deductibility: 'full',
mcc_codes: [5111, 5112, 5943, 5944],
keywords: ['kontorsmaterial', 'pennor', 'papper', 'office supplies', 'staples', 'kontorsvaror'],
keywords: ['kontorsmaterial', 'pennor', 'papper', 'office supplies', 'staples', 'kontorsvaror', 'kontor'],
risk_level: 'NONE',
requires_review: false,
impact_score: 7,
@@ -2534,7 +2534,7 @@ export function findMatchingTemplates(
return results
.sort((a, b) => b.confidence - a.confidence)
.slice(0, 5)
.slice(0, 20)
}
/**
+16
View File
@@ -71,6 +71,10 @@ export function getCategoryAccountMapping(
expense_marketing: '5910', // Annonsering
expense_professional_services: '6530', // Redovisningstjänster
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_card_fees: '6570', // Kortavgifter
expense_currency_exchange: '7960', // Valutakursförluster
@@ -224,6 +228,10 @@ export function buildMappingResultFromCategory(
expense_marketing: 'Marknadsföring',
expense_professional_services: 'Konsulttjänst',
expense_education: 'Utbildning',
expense_representation: 'Representation',
expense_consumables: 'Förbrukningsvaror',
expense_vehicle: 'Bil & drivmedel',
expense_telecom: 'Telefon & internet',
expense_bank_fees: 'Bankavgift',
expense_card_fees: 'Kortavgift',
expense_currency_exchange: 'Valutaväxling',
@@ -262,6 +270,10 @@ export function getExpenseAccountForCategory(category: TransactionCategory): str
expense_marketing: '5910',
expense_professional_services: '6530',
expense_education: '6991',
expense_representation: '6071',
expense_consumables: '5460',
expense_vehicle: '5611',
expense_telecom: '6200',
expense_bank_fees: '6570',
expense_card_fees: '6570',
expense_currency_exchange: '7960',
@@ -292,6 +304,10 @@ export function getDefaultAccountForCategory(
expense_marketing: '5910',
expense_professional_services: '6530',
expense_education: entityType === 'aktiebolag' ? '7610' : '6991',
expense_representation: '6071',
expense_consumables: '5460',
expense_vehicle: '5611',
expense_telecom: '6200',
expense_bank_fees: '6570',
expense_card_fees: '6570',
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(
userId: string,
merchantName: string,
debitAccount: string,
creditAccount: string,
isPrivate: boolean
isPrivate: boolean,
userDescription?: string,
templateId?: string
): Promise<void> {
const supabase = await createClient()
// Escape special regex characters in merchant name
const escapedMerchant = merchantName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const { error } = await supabase.from('mapping_rules').insert({
user_id: userId,
rule_name: `Learned: ${merchantName}`,
rule_type: 'merchant_name',
priority: 10, // User overrides have highest priority
merchant_pattern: escapedMerchant,
debit_account: debitAccount,
credit_account: creditAccount,
risk_level: 'NONE',
default_private: isPrivate,
requires_review: false,
confidence_score: 0.95,
})
if (userDescription) {
// Delete existing user_description rule for this merchant (latest wins)
await supabase
.from('mapping_rules')
.delete()
.eq('user_id', userId)
.eq('merchant_pattern', escapedMerchant)
.eq('source', 'user_description')
if (error) {
// Silently fail — saving learned rules is non-critical
const { error } = await supabase.from('mapping_rules').insert({
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,
MessageSquare,
Bell,
Inbox,
Landmark,
UtensilsCrossed,
ChefHat,
@@ -23,6 +24,7 @@ import {
BarChart3,
Layers,
Puzzle,
TextSearch,
Ship,
FileText,
Shield,
@@ -34,6 +36,7 @@ const ICON_MAP: Record<string, LucideIcon> = {
Sparkles,
MessageSquare,
Bell,
Inbox,
Landmark,
UtensilsCrossed,
ChefHat,
@@ -54,6 +57,7 @@ const ICON_MAP: Record<string, LucideIcon> = {
BarChart3,
Layers,
Puzzle,
TextSearch,
Ship,
FileText,
Shield,
+4
View File
@@ -6,6 +6,8 @@ import { sruExportExtension } from '@/extensions/sru-export'
import { neBilagaExtension } from '@/extensions/ne-bilaga'
import { aiChatExtension } from '@/extensions/general/ai-chat'
import { invoiceInboxExtension } from '@/extensions/general/invoice-inbox'
import { calendarExtension } from '@/extensions/general/calendar'
import { userDescriptionMatchExtension } from '@/extensions/general/user-description-match'
import { euSalesListExtension } from '@/extensions/export/eu-sales-list'
import { vatMonitorExtension } from '@/extensions/export/vat-monitor'
import { intrastatExtension } from '@/extensions/export/intrastat'
@@ -32,6 +34,8 @@ const FIRST_PARTY_EXTENSIONS: Extension[] = [
neBilagaExtension,
aiChatExtension,
invoiceInboxExtension,
calendarExtension,
userDescriptionMatchExtension,
// enableBankingExtension, // Uncomment to activate PSD2 bank sync
// ── Export sector ──────────────────────────────────────────
+30
View File
@@ -51,6 +51,12 @@ export const SECTORS: Sector[] = [
description: 'AI-assistent för skatte- och bokföringsfrågor',
longDescription:
'Ställ frågor om skatt, bokföring och företagande till en AI-assistent som förstår svensk redovisning. Svar baserade på aktuella regler och praxis.',
quickAction: {
label: 'AI-assistent',
description: 'Fråga om bokföring',
icon: 'MessageSquare',
event: 'open-ai-chat',
},
},
{
slug: 'push-notifications',
@@ -76,6 +82,30 @@ export const SECTORS: Sector[] = [
longDescription:
'Skicka leverantörsfakturor till en dedikerad e-postadress eller ladda upp manuellt. AI extraherar automatiskt leverantörsdata, belopp och moms. Granska och bekräfta med ett klick för att skapa leverantörsfakturor.',
},
{
slug: 'calendar',
name: 'Kalender',
sector: 'general',
category: 'operations',
icon: 'Calendar',
dataPattern: 'core',
readsCoreTables: ['invoices', 'deadlines', 'customers'],
description: 'Fullstandig kalendervy med manads-, vecko- och dagsvisning',
longDescription:
'Se alla fakturadatum och deadlines i en interaktiv kalender med manads-, vecko- och dagsvy.',
},
{
slug: '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',
name: 'Bankintegration (PSD2)',
+11
View File
@@ -15,6 +15,16 @@ export type SectorSlug = 'general' | 'restaurant' | 'construction' | 'hotel' | '
/** How an extension gets its data */
export type ExtensionDataPattern = 'core' | 'manual' | 'both'
/** Dashboard quick action declared by an extension */
export interface QuickActionDefinition {
label: string
description: string
icon: string
href?: string
event?: string
order?: number
}
/** Extension metadata for the marketplace and workspace routing */
export interface ExtensionDefinition {
slug: string
@@ -28,6 +38,7 @@ export interface ExtensionDefinition {
dataPattern: ExtensionDataPattern
readsCoreTables?: string[]
hasOwnData?: boolean
quickAction?: QuickActionDefinition
}
/** Sector definition with its extensions */
+2
View File
@@ -14,7 +14,9 @@ const WORKSPACES: Record<WorkspaceKey, ComponentType<WorkspaceComponentProps>> =
'general/ai-chat': dynamic(() => import('@/components/extensions/general/AiChatWorkspace')),
'general/push-notifications': dynamic(() => import('@/components/extensions/general/PushNotificationsWorkspace')),
'general/invoice-inbox': dynamic(() => import('@/components/extensions/general/InvoiceInboxWorkspace')),
'general/calendar': dynamic(() => import('@/components/extensions/general/CalendarWorkspace')),
'general/enable-banking': dynamic(() => import('@/components/extensions/general/EnableBankingWorkspace')),
'general/user-description-match': dynamic(() => import('@/components/extensions/general/UserDescriptionMatchWorkspace')),
// Restaurant
'restaurant/food-cost': dynamic(() => import('@/components/extensions/restaurant/FoodCostWorkspace')),
'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', {
...baseOptions,
org_number: undefined,
org_number: null,
})
expect(output).not.toContain('#ORGNR')
+61 -8
View File
@@ -1,6 +1,7 @@
import { suggestCategory } from '@/lib/tax/expense-warnings'
import { getExpenseAccountForCategory } from '@/lib/bookkeeping/category-mapping'
import { findMatchingTemplates, type TemplateMatch } from '@/lib/bookkeeping/booking-templates'
import { findSimilarTemplates } from '@/lib/bookkeeping/template-embeddings'
import type { Transaction, TransactionCategory, EntityType, MappingRule } from '@/types'
export interface SuggestedCategory {
@@ -9,6 +10,7 @@ export interface SuggestedCategory {
account: string | null
confidence: number
source: 'mapping_rule' | 'pattern' | 'history' | 'ai'
match_reason?: string
}
const CATEGORY_LABELS: Record<string, string> = {
@@ -22,6 +24,10 @@ const CATEGORY_LABELS: Record<string, string> = {
expense_marketing: 'Marknadsföring',
expense_professional_services: 'Konsulter',
expense_education: 'Utbildning',
expense_representation: 'Representation',
expense_consumables: 'Material',
expense_vehicle: 'Bil & drivmedel',
expense_telecom: 'Telefon & internet',
expense_bank_fees: 'Bankavgift',
expense_card_fees: 'Kortavgift',
expense_currency_exchange: 'Valutaväxling',
@@ -71,13 +77,17 @@ export function getSuggestedCategories(
const category = accountToCategory(rule.debit_account, transaction.amount)
if (category && !seen.has(category)) {
seen.add(category)
suggestions.push({
const suggestion: SuggestedCategory = {
category: category as TransactionCategory,
label: CATEGORY_LABELS[category] || category,
account: rule.debit_account,
confidence: rule.confidence_score || 0.8,
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> = {
'5410': 'expense_equipment',
'5420': 'expense_software',
'5460': 'expense_consumables',
'5611': 'expense_vehicle',
'5800': 'expense_travel',
'5010': 'expense_office',
'5910': 'expense_marketing',
'6071': 'expense_representation',
'6072': 'expense_representation',
'6200': 'expense_telecom',
'6530': 'expense_professional_services',
'6570': 'expense_bank_fees',
'6991': 'expense_other',
@@ -150,19 +165,40 @@ function accountToCategory(account: string, amount: number): string | 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.
* AI suggestions take priority over history-based ones.
* Deduplicates by category, preserving the higher-confidence entry.
* When transactionAmount is provided, filters out wrong-direction suggestions.
*/
export function mergeAiSuggestions(
existing: SuggestedCategory[],
aiSuggestions: { category: string; basAccount: string; confidence: number; reasoning: string }[]
aiSuggestions: { category: string; basAccount: string; confidence: number; reasoning: string }[],
transactionAmount?: number
): SuggestedCategory[] {
const seen = new Set<string>(existing.map((s) => s.category))
const merged = [...existing]
for (const ai of aiSuggestions) {
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)
merged.push({
@@ -174,8 +210,13 @@ export function mergeAiSuggestions(
})
}
// Sort by source priority first, then by confidence within the same tier
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)
}
@@ -198,13 +239,25 @@ export interface SuggestedTemplate {
/**
* Get suggested booking templates for a transaction.
* Uses multi-signal matching (MCC, keywords, description patterns).
* Tries embedding-based semantic search first, falls back to keyword matching.
*/
export function getSuggestedTemplates(
export async function getSuggestedTemplates(
transaction: Transaction,
entityType?: EntityType
): SuggestedTemplate[] {
const matches = findMatchingTemplates(transaction, entityType)
): Promise<SuggestedTemplate[]> {
let matches: TemplateMatch[]
try {
matches = await findSimilarTemplates(transaction, entityType)
} catch {
matches = []
}
// Fall back to keyword matching if embedding search returns nothing
if (matches.length === 0) {
matches = findMatchingTemplates(transaction, entityType)
}
return matches.map((m: TemplateMatch) => ({
template_id: m.template.id,
name_sv: m.template.name_sv,
@@ -0,0 +1,72 @@
-- Migration 040: Booking Template Embeddings
-- Stores pre-computed embeddings for booking templates to enable
-- semantic similarity search for transaction classification.
-- ============================================================
-- booking_template_embeddings
-- ============================================================
create table public.booking_template_embeddings (
id uuid primary key default gen_random_uuid(),
template_id text unique not null,
embedding extensions.vector(1536) not null,
embedding_text text not null,
model text not null,
schema_version text not null,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
alter table public.booking_template_embeddings enable row level security;
-- Shared system data — any authenticated user can read
create policy "booking_template_embeddings_select" on public.booking_template_embeddings
for select using (true);
-- Only service role can insert/update (no user-scoped writes)
-- RLS blocks regular users from writing; service role bypasses RLS
create trigger booking_template_embeddings_updated_at
before update on public.booking_template_embeddings
for each row execute function public.update_updated_at_column();
-- HNSW index for fast cosine similarity search
create index idx_booking_template_embeddings_embedding
on public.booking_template_embeddings
using hnsw (embedding extensions.vector_cosine_ops);
create index idx_booking_template_embeddings_template_id
on public.booking_template_embeddings (template_id);
-- ============================================================
-- match_booking_templates RPC (vector similarity search)
-- ============================================================
create or replace function public.match_booking_templates(
query_embedding extensions.vector,
match_count int default 20,
match_threshold float default 0.5
)
returns table (
template_id text,
embedding_text text,
similarity float
)
language plpgsql
security definer
set search_path = public, extensions
as $$
begin
return query
select
bte.template_id,
bte.embedding_text,
1 - (bte.embedding <=> query_embedding)::float as similarity
from public.booking_template_embeddings bte
where 1 - (bte.embedding <=> query_embedding) >= match_threshold
order by bte.embedding <=> query_embedding
limit match_count;
end;
$$;
grant execute on function public.match_booking_templates(extensions.vector, int, float) to authenticated;
@@ -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": {
"@/*": ["./*"]
}
},
"types": ["vitest/globals"]
},
"include": [
"next-env.d.ts",
+8
View File
@@ -13,6 +13,10 @@ export type TransactionCategory =
| 'expense_marketing'
| 'expense_professional_services'
| 'expense_education'
| 'expense_representation'
| 'expense_consumables'
| 'expense_vehicle'
| 'expense_telecom'
| 'expense_bank_fees'
| 'expense_card_fees'
| 'expense_currency_exchange'
@@ -899,6 +903,10 @@ export interface MappingRule {
// Capitalization
capitalization_threshold: number | null
capitalized_debit_account: string | null
// Source tracking
source: 'auto' | 'user_description' | 'system'
user_description: string | null
template_id: string | null
// Meta
is_active: boolean
created_at: string