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