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

This commit is contained in:
Emil
2026-02-24 16:07:57 +01:00
69 changed files with 3584 additions and 208 deletions
-142
View File
@@ -1,142 +0,0 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { createClient } from '@/lib/supabase/client'
import { useToast } from '@/components/ui/use-toast'
import { PaymentCalendar } from '@/components/calendar/PaymentCalendar'
import type { Invoice, Deadline } from '@/types'
export default function CalendarPage() {
const [invoices, setInvoices] = useState<Invoice[]>([])
const [deadlines, setDeadlines] = useState<Deadline[]>([])
const [customers, setCustomers] = useState<{ id: string; name: string }[]>([])
const [isLoading, setIsLoading] = useState(true)
const { toast } = useToast()
const supabase = createClient()
const fetchData = useCallback(async () => {
setIsLoading(true)
try {
// Fetch invoices with customer names
const { data: invoicesData, error: invoicesError } = await supabase
.from('invoices')
.select('*, customer:customers(name)')
.order('due_date', { ascending: true })
if (invoicesError) throw invoicesError
// 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
setInvoices(invoicesData || [])
setDeadlines(deadlinesData || [])
setCustomers(customersData || [])
} catch (error) {
toast({
title: 'Fel',
description: 'Kunde inte hämta 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 { error } = await supabase.from('deadlines').insert([data])
if (error) throw error
toast({
title: 'Deadline skapad',
description: 'Din deadline har sparats',
})
fetchData()
} catch (error) {
toast({
title: 'Fel',
description: 'Kunde inte skapa deadline',
variant: 'destructive',
})
throw error
}
}
const handleDeadlineToggle = async (deadline: Deadline) => {
try {
const { error } = await supabase
.from('deadlines')
.update({
is_completed: !deadline.is_completed,
completed_at: !deadline.is_completed ? new Date().toISOString() : null,
})
.eq('id', deadline.id)
if (error) throw error
toast({
title: deadline.is_completed ? 'Markerad som ej klar' : 'Markerad som klar',
})
fetchData()
} catch (error) {
toast({
title: 'Fel',
description: 'Kunde inte uppdatera deadline',
variant: 'destructive',
})
}
}
if (isLoading) {
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Kalender</h1>
</div>
<div className="animate-pulse">
<div className="h-10 bg-muted rounded w-48 mb-4" />
<div className="h-96 bg-muted rounded" />
</div>
</div>
)
}
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Kalender</h1>
</div>
<PaymentCalendar
invoices={invoices}
deadlines={deadlines}
customers={customers}
onDeadlineCreate={handleDeadlineCreate}
onDeadlineToggle={handleDeadlineToggle}
/>
</div>
)
}
+241
View File
@@ -0,0 +1,241 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import Link from 'next/link'
import { createClient } from '@/lib/supabase/client'
import { useToast } from '@/components/ui/use-toast'
import { DeadlineList } from '@/components/deadlines/DeadlineList'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { AlertTriangle, ArrowRight } from 'lucide-react'
import type { Deadline } from '@/types'
export default function DeadlinesPage() {
const [deadlines, setDeadlines] = useState<Deadline[]>([])
const [customers, setCustomers] = useState<{ id: string; name: string }[]>([])
const [overdueInvoices, setOverdueInvoices] = useState<{ count: number; total: number }>({ count: 0, total: 0 })
const [isLoading, setIsLoading] = useState(true)
const { toast } = useToast()
const supabase = createClient()
const fetchData = useCallback(async () => {
setIsLoading(true)
try {
// Fetch deadlines with customer names
const { data: deadlinesData, error: deadlinesError } = await supabase
.from('deadlines')
.select('*, customer:customers(name)')
.order('due_date', { ascending: true })
if (deadlinesError) throw deadlinesError
// Fetch customers for the form
const { data: customersData, error: customersError } = await supabase
.from('customers')
.select('id, name')
.order('name', { ascending: true })
if (customersError) throw customersError
// Fetch overdue invoices summary
const today = new Date().toISOString().split('T')[0]
const { data: overdueData, error: overdueError } = await supabase
.from('invoices')
.select('total_sek, total')
.in('status', ['sent', 'unpaid'])
.lt('due_date', today)
if (overdueError) throw overdueError
const overdueCount = overdueData?.length || 0
const overdueTotal = (overdueData || []).reduce(
(sum, inv) => sum + (inv.total_sek || inv.total || 0),
0
)
setDeadlines(deadlinesData || [])
setCustomers(customersData || [])
setOverdueInvoices({ count: overdueCount, total: overdueTotal })
} catch {
toast({
title: 'Fel',
description: 'Kunde inte hamta data',
variant: 'destructive',
})
} finally {
setIsLoading(false)
}
}, [supabase, toast])
useEffect(() => {
fetchData()
}, [fetchData])
const handleDeadlineCreate = async (
data: Omit<Deadline, 'id' | 'user_id' | 'created_at' | 'updated_at'>
) => {
try {
const response = await fetch('/api/deadlines', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (!response.ok) {
const result = await response.json()
throw new Error(result.error || 'Failed to create deadline')
}
toast({
title: 'Deadline skapad',
description: 'Din deadline har sparats',
})
fetchData()
} catch (error) {
toast({
title: 'Fel',
description: error instanceof Error ? error.message : 'Kunde inte skapa deadline',
variant: 'destructive',
})
throw error
}
}
const handleDeadlineToggle = async (deadline: Deadline) => {
try {
const response = await fetch(`/api/deadlines/${deadline.id}/complete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_completed: !deadline.is_completed }),
})
if (!response.ok) {
const result = await response.json()
throw new Error(result.error || 'Failed to toggle deadline')
}
toast({
title: deadline.is_completed ? 'Markerad som ej klar' : 'Markerad som klar',
})
fetchData()
} catch (error) {
toast({
title: 'Fel',
description: error instanceof Error ? error.message : 'Kunde inte uppdatera deadline',
variant: 'destructive',
})
}
}
const handleDeadlineEdit = async (deadline: Deadline) => {
try {
const response = await fetch(`/api/deadlines/${deadline.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(deadline),
})
if (!response.ok) {
const result = await response.json()
throw new Error(result.error || 'Failed to edit deadline')
}
toast({
title: 'Deadline uppdaterad',
description: 'Dina andringar har sparats',
})
fetchData()
} catch (error) {
toast({
title: 'Fel',
description: error instanceof Error ? error.message : 'Kunde inte uppdatera deadline',
variant: 'destructive',
})
}
}
const handleDeadlineDelete = async (deadline: Deadline) => {
try {
const response = await fetch(`/api/deadlines/${deadline.id}`, {
method: 'DELETE',
})
if (!response.ok) {
const result = await response.json()
throw new Error(result.error || 'Failed to delete deadline')
}
toast({
title: 'Deadline borttagen',
})
fetchData()
} catch (error) {
toast({
title: 'Fel',
description: error instanceof Error ? error.message : 'Kunde inte ta bort deadline',
variant: 'destructive',
})
}
}
if (isLoading) {
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Deadlines</h1>
</div>
<div className="animate-pulse">
<div className="h-10 bg-muted rounded w-48 mb-4" />
<div className="h-96 bg-muted rounded" />
</div>
</div>
)
}
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Deadlines</h1>
</div>
{overdueInvoices.count > 0 && (
<Link href="/invoices?status=unpaid" className="block group">
<Card className="border-destructive/50 bg-destructive/5 hover:bg-destructive/10 transition-colors">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<AlertTriangle className="h-5 w-5 text-destructive flex-shrink-0" />
<div>
<p className="font-medium text-sm">Forfallna fakturor</p>
<p className="text-xs text-muted-foreground mt-0.5">
{overdueInvoices.count} st totalt{' '}
{overdueInvoices.total.toLocaleString('sv-SE')} kr
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Badge variant="destructive">{overdueInvoices.count}</Badge>
<ArrowRight className="h-4 w-4 text-muted-foreground group-hover:translate-x-0.5 transition-transform" />
</div>
</div>
</CardContent>
</Card>
</Link>
)}
<DeadlineList
deadlines={deadlines}
customers={customers}
onDeadlineCreate={handleDeadlineCreate}
onDeadlineToggle={handleDeadlineToggle}
onDeadlineEdit={handleDeadlineEdit}
onDeadlineDelete={handleDeadlineDelete}
/>
</div>
)
}
+8
View File
@@ -252,6 +252,13 @@ export default async function DashboardPage() {
streak_count: streakCount,
}
// Fetch enabled extension toggles
const { data: enabledToggles } = await supabase
.from('extension_toggles')
.select('sector_slug, extension_slug')
.eq('user_id', user.id)
.eq('enabled', true)
return (
<DashboardContent
firstName={firstName}
@@ -272,6 +279,7 @@ export default async function DashboardPage() {
missingUnderlagCount,
}}
onboardingProgress={onboardingProgress}
enabledExtensions={enabledToggles || []}
/>
)
}
+66 -3
View File
@@ -19,11 +19,12 @@ import InboxZeroState from '@/components/transactions/InboxZeroState'
import InvoiceMatchDialog from '@/components/transactions/InvoiceMatchDialog'
import TransactionBookingDialog from '@/components/transactions/TransactionBookingDialog'
import QuickReviewDialog from '@/components/transactions/QuickReviewDialog'
import DescribeTransactionDialog from '@/components/transactions/DescribeTransactionDialog'
import { EXPENSE_CATEGORIES, INCOME_CATEGORIES } from '@/components/transactions/transaction-types'
import { getDefaultAccountForCategory, getDefaultVatTreatmentForCategory } from '@/lib/bookkeeping/category-mapping'
import type { TransactionWithInvoice, ViewMode, CategorizeHandler } from '@/components/transactions/transaction-types'
import type { TransactionCategory, CreateTransactionInput, Invoice, Customer, VatTreatment } from '@/types'
import type { SuggestedCategory } from '@/lib/transactions/category-suggestions'
import type { SuggestedCategory, SuggestedTemplate } from '@/lib/transactions/category-suggestions'
export default function TransactionsPage() {
const [transactions, setTransactions] = useState<TransactionWithInvoice[]>([])
@@ -33,6 +34,7 @@ export default function TransactionsPage() {
const [isCreating, setIsCreating] = useState(false)
const [showSwipeView, setShowSwipeView] = useState(false)
const [categorySuggestions, setCategorySuggestions] = useState<Record<string, SuggestedCategory[]>>({})
const [templateSuggestions, setTemplateSuggestions] = useState<Record<string, SuggestedTemplate[]>>({})
const [isLoadingSuggestions, setIsLoadingSuggestions] = useState(false)
const [processingId, setProcessingId] = useState<string | null>(null)
@@ -57,6 +59,10 @@ export default function TransactionsPage() {
const [quickReviewCategory, setQuickReviewCategory] = useState<TransactionCategory | null>(null)
const [quickReviewLabel, setQuickReviewLabel] = useState('')
// Describe dialog
const [describeDialogOpen, setDescribeDialogOpen] = useState(false)
const [describeDialogTransaction, setDescribeDialogTransaction] = useState<TransactionWithInvoice | null>(null)
// Entity type for tooltip context
const [entityType, setEntityType] = useState<string>('enskild_firma')
@@ -130,6 +136,9 @@ export default function TransactionsPage() {
if (data.suggestions) {
setCategorySuggestions(data.suggestions)
}
if (data.template_suggestions) {
setTemplateSuggestions(data.template_suggestions)
}
} catch {
// Non-critical
}
@@ -409,13 +418,29 @@ export default function TransactionsPage() {
async function handleBatchCategorize(category: TransactionCategory, vatTreatment?: VatTreatment) {
const ids = Array.from(selectedIds)
setBatchProgress({ done: 0, total: ids.length })
let successes = 0
const failures: string[] = []
for (let i = 0; i < ids.length; i++) {
await handleCategorize(ids[i], true, category, vatTreatment)
const result = await handleCategorize(ids[i], true, category, vatTreatment)
if (result) {
successes++
} else {
const tx = transactions.find((t) => t.id === ids[i])
failures.push(tx?.description || ids[i])
}
setBatchProgress({ done: i + 1, total: ids.length })
}
setBatchProgress(null)
setShowBatchSelector(false)
toast({ title: 'Klart', description: `${ids.length} transaktioner bokförda` })
if (failures.length === 0) {
toast({ title: 'Klart', description: `${successes} transaktioner bokförda` })
} else {
toast({
title: 'Delvis klart',
description: `${successes} lyckades, ${failures.length} misslyckades: ${failures.slice(0, 3).join(', ')}${failures.length > 3 ? '...' : ''}`,
variant: 'destructive',
})
}
exitBatchMode()
}
@@ -468,12 +493,40 @@ export default function TransactionsPage() {
return journalEntryId
}
function openDescribeDialog(transaction: TransactionWithInvoice) {
setDescribeDialogTransaction(transaction)
setDescribeDialogOpen(true)
}
function handleDescribeCategorized(transactionId: string, journalEntryId: string | null) {
setExitingIds((prev) => new Set(prev).add(transactionId))
setTimeout(() => {
setTransactions((prev) =>
prev.map((t) =>
t.id === transactionId
? { ...t, is_business: true, journal_entry_id: journalEntryId }
: t
)
)
setExitingIds((prev) => {
const next = new Set(prev)
next.delete(transactionId)
return next
})
}, 350)
}
function handleBatchApplied() {
fetchTransactions()
}
// Swipe view
if (showSwipeView && uncategorizedTransactions.length > 0) {
return (
<SwipeCategorizationView
transactions={uncategorizedTransactions}
suggestions={categorySuggestions}
templateSuggestions={templateSuggestions}
onCategorize={handleCategorize}
onMatchInvoice={handleMatchInvoice}
onClose={() => setShowSwipeView(false)}
@@ -527,6 +580,7 @@ export default function TransactionsPage() {
key={transaction.id}
transaction={transaction}
suggestions={categorySuggestions[transaction.id]}
templateSuggestions={templateSuggestions[transaction.id]}
processingId={processingId}
isBatchMode={isBatchMode}
isSelected={selectedIds.has(transaction.id)}
@@ -535,6 +589,7 @@ export default function TransactionsPage() {
onMarkPrivate={handleMarkPrivate}
onOpenMatchDialog={openMatchDialog}
onOpenCategoryDialog={openCategoryDialog}
onOpenDescribe={openDescribeDialog}
onOpenQuickReview={handleOpenQuickReview}
onToggleSelect={toggleBatchSelect}
/>
@@ -603,6 +658,14 @@ export default function TransactionsPage() {
onConfirm={handleQuickReviewConfirm}
/>
<DescribeTransactionDialog
open={describeDialogOpen}
onOpenChange={setDescribeDialogOpen}
transaction={describeDialogTransaction}
onCategorized={handleDescribeCategorized}
onBatchApplied={handleBatchApplied}
/>
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogContent>
<DialogHeader>
@@ -0,0 +1,27 @@
import { NextResponse } from 'next/server'
import { seedAllTemplateEmbeddings, getSchemaVersion } from '@/lib/bookkeeping/template-embeddings'
export async function POST(request: Request) {
const authHeader = request.headers.get('authorization')
const cronSecret = process.env.CRON_SECRET
if (!cronSecret || authHeader !== `Bearer ${cronSecret}`) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const { seeded, errors } = await seedAllTemplateEmbeddings()
return NextResponse.json({
success: errors.length === 0,
seeded,
errors,
schema_version: getSchemaVersion(),
})
} catch (error) {
return NextResponse.json(
{ error: `Seeding failed: ${error instanceof Error ? error.message : 'Unknown error'}` },
{ status: 500 }
)
}
}
@@ -72,6 +72,8 @@ describe('POST /api/invoices/[id]/send', () => {
unit: 'tim',
unit_price: 1000,
line_total: 10000,
vat_rate: 25,
vat_amount: 2500,
created_at: '2024-06-15T14:30:00Z',
},
],
+4
View File
@@ -318,6 +318,8 @@ describe('POST /api/invoices (create credit note)', () => {
unit: 'tim',
unit_price: 1000,
line_total: 10000,
vat_rate: 25,
vat_amount: 2500,
created_at: '2024-06-15T14:30:00Z',
},
]
@@ -385,6 +387,8 @@ describe('POST /api/invoices (create credit note)', () => {
unit: 'st',
unit_price: 1000,
line_total: 1000,
vat_rate: 25,
vat_amount: 250,
created_at: '2024-06-15T14:30:00Z',
},
],
@@ -160,7 +160,9 @@ describe('POST /api/transactions/[id]/categorize', () => {
'GitHub',
'6200',
'1930',
false
false,
undefined,
undefined
)
expect(emitSpy).toHaveBeenCalledWith(
expect.objectContaining({ type: 'transaction.categorized' })
@@ -256,7 +256,9 @@ export async function POST(
transaction.merchant_name,
mappingResult.debit_account,
mappingResult.credit_account,
!is_business
!is_business,
body.user_description,
body.template_id
)
} catch (err) {
console.error('Failed to save mapping rule:', err)
@@ -0,0 +1,200 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import {
createMockRequest,
createMockRouteParams,
createQueuedMockSupabase,
makeTransaction,
parseJsonResponse,
} from '@/tests/helpers'
// Mock init
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
// Mock template embeddings
const mockFindSimilarTemplates = vi.fn().mockResolvedValue([])
vi.mock('@/lib/bookkeeping/template-embeddings', () => ({
findSimilarTemplates: (...args: unknown[]) => mockFindSimilarTemplates(...args),
}))
// Mock Supabase
const mockCreateClient = vi.fn()
vi.mock('@/lib/supabase/server', () => ({
createClient: (...args: unknown[]) => mockCreateClient(...args),
}))
describe('POST /api/transactions/[id]/describe', () => {
let POST: typeof import('../route').POST
beforeEach(async () => {
vi.clearAllMocks()
const mod = await import('../route')
POST = mod.POST
})
it('returns 401 when not authenticated', async () => {
const { supabase } = createQueuedMockSupabase()
supabase.auth.getUser.mockResolvedValue({
data: { user: null },
error: { message: 'Not authenticated' },
})
mockCreateClient.mockResolvedValue(supabase)
const req = createMockRequest('/api/transactions/test-id/describe', {
method: 'POST',
body: { description: 'business lunch' },
})
const res = await POST(req, createMockRouteParams({ id: 'test-id' }))
const { status, body } = await parseJsonResponse(res)
expect(status).toBe(401)
expect(body).toHaveProperty('error', 'Unauthorized')
})
it('returns 400 for invalid body (description too short)', async () => {
const { supabase } = createQueuedMockSupabase()
supabase.auth.getUser.mockResolvedValue({
data: { user: { id: 'user-1' } },
error: null,
})
mockCreateClient.mockResolvedValue(supabase)
const req = createMockRequest('/api/transactions/test-id/describe', {
method: 'POST',
body: { description: 'ab' },
})
const res = await POST(req, createMockRouteParams({ id: 'test-id' }))
const { status } = await parseJsonResponse(res)
expect(status).toBe(400)
})
it('returns 404 when transaction not found', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
supabase.auth.getUser.mockResolvedValue({
data: { user: { id: 'user-1' } },
error: null,
})
enqueueMany([
// transaction fetch
{ data: null, error: { message: 'Not found' } },
])
mockCreateClient.mockResolvedValue(supabase)
const req = createMockRequest('/api/transactions/nonexistent/describe', {
method: 'POST',
body: { description: 'business lunch' },
})
const res = await POST(req, createMockRouteParams({ id: 'nonexistent' }))
const { status, body } = await parseJsonResponse(res)
expect(status).toBe(404)
expect(body).toHaveProperty('error', 'Transaction not found')
})
it('returns template candidates on happy path', async () => {
const tx = makeTransaction({
id: 'tx-1',
merchant_name: 'Restaurant XYZ',
amount: -450,
})
mockFindSimilarTemplates.mockResolvedValueOnce([
{
template: {
id: 'restaurant_dining',
name_sv: 'Restaurangbesök',
name_en: 'Restaurant dining',
group: 'representation',
debit_account: '6071',
credit_account: '1930',
description_sv: 'Representation - restaurang',
},
confidence: 0.82,
},
])
const { supabase, enqueueMany } = createQueuedMockSupabase()
supabase.auth.getUser.mockResolvedValue({
data: { user: { id: 'user-1' } },
error: null,
})
enqueueMany([
// transaction fetch
{ data: tx, error: null },
// company_settings
{ data: { entity_type: 'enskild_firma' }, error: null },
// batch candidate count
{ data: null, error: null, count: 3 },
])
mockCreateClient.mockResolvedValue(supabase)
const req = createMockRequest('/api/transactions/tx-1/describe', {
method: 'POST',
body: { description: 'business lunch with client' },
})
const res = await POST(req, createMockRouteParams({ id: 'tx-1' }))
const { status, body } = await parseJsonResponse<{ data: Record<string, unknown> }>(res)
expect(status).toBe(200)
expect(body.data.templates).toHaveLength(1)
expect(body.data.needs_more_detail).toBe(false)
expect(body.data.user_description).toBe('business lunch with client')
expect(body.data.batch_candidate_count).toBe(3)
expect(body.data.merchant_name).toBe('Restaurant XYZ')
// Verify findSimilarTemplates was called with the user description
expect(mockFindSimilarTemplates).toHaveBeenCalledWith(
expect.objectContaining({ id: 'tx-1' }),
'enskild_firma',
10,
'business lunch with client'
)
})
it('sets needs_more_detail when confidence is low', async () => {
const tx = makeTransaction({ id: 'tx-2', merchant_name: null })
mockFindSimilarTemplates.mockResolvedValueOnce([
{
template: {
id: 'misc',
name_sv: 'Diverse',
name_en: 'Miscellaneous',
group: 'other',
debit_account: '6991',
credit_account: '1930',
description_sv: 'Okategoriserad utgift',
},
confidence: 0.4,
},
])
const { supabase, enqueueMany } = createQueuedMockSupabase()
supabase.auth.getUser.mockResolvedValue({
data: { user: { id: 'user-1' } },
error: null,
})
enqueueMany([
// transaction fetch
{ data: tx, error: null },
// company_settings
{ data: { entity_type: 'enskild_firma' }, error: null },
])
mockCreateClient.mockResolvedValue(supabase)
const req = createMockRequest('/api/transactions/tx-2/describe', {
method: 'POST',
body: { description: 'some kind of payment' },
})
const res = await POST(req, createMockRouteParams({ id: 'tx-2' }))
const { status, body } = await parseJsonResponse<{ data: Record<string, unknown> }>(res)
expect(status).toBe(200)
expect(body.data.needs_more_detail).toBe(true)
})
})
@@ -0,0 +1,98 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { validateBody } from '@/lib/api/validate'
import { DescribeTransactionSchema } from '@/lib/api/schemas'
import { findSimilarTemplates } from '@/lib/bookkeeping/template-embeddings'
import type { Transaction, EntityType } from '@/types'
ensureInitialized()
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { id } = await params
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const validation = await validateBody(request, DescribeTransactionSchema)
if (!validation.success) return validation.response
const { description } = validation.data
// Fetch the transaction (validates ownership)
const { data: transaction, error: fetchError } = await supabase
.from('transactions')
.select('*')
.eq('id', id)
.eq('user_id', user.id)
.single()
if (fetchError || !transaction) {
return NextResponse.json({ error: 'Transaction not found' }, { status: 404 })
}
// Fetch entity type
const { data: settings } = await supabase
.from('company_settings')
.select('entity_type')
.eq('user_id', user.id)
.single()
const entityType: EntityType = (settings?.entity_type as EntityType) || 'enskild_firma'
// Run embedding search with user description dominating the query
const templates = await findSimilarTemplates(
transaction as Transaction,
entityType,
10,
description
)
// Flag if top confidence is too low
const needsMoreDetail = templates.length === 0 || templates[0].confidence < 0.55
// Count uncategorized sibling transactions from same merchant
let batchCandidateCount = 0
if (transaction.merchant_name) {
const { count } = await supabase
.from('transactions')
.select('id', { count: 'exact', head: true })
.eq('user_id', user.id)
.eq('merchant_name', transaction.merchant_name)
.is('journal_entry_id', null)
.neq('id', id)
batchCandidateCount = count || 0
}
return NextResponse.json({
data: {
templates: templates.map((m) => ({
template_id: m.template.id,
name_sv: m.template.name_sv,
name_en: m.template.name_en,
group: m.template.group,
debit_account: m.template.debit_account,
credit_account: m.template.credit_account,
confidence: m.confidence,
description_sv: m.template.description_sv,
vat_rate: m.template.vat_rate,
vat_treatment: m.template.vat_treatment,
deductibility: m.template.deductibility,
deductibility_note_sv: m.template.deductibility_note_sv || null,
special_rules_sv: m.template.special_rules_sv || null,
risk_level: m.template.risk_level,
})),
needs_more_detail: needsMoreDetail,
user_description: description,
batch_candidate_count: batchCandidateCount,
merchant_name: transaction.merchant_name,
},
})
}
@@ -0,0 +1,212 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import {
createMockRequest,
createQueuedMockSupabase,
makeTransaction,
parseJsonResponse,
} from '@/tests/helpers'
import { eventBus } from '@/lib/events'
// Mock init
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
// Mock booking templates
vi.mock('@/lib/bookkeeping/booking-templates', () => ({
getTemplateById: vi.fn((id: string) => {
if (id === 'office_supplies') {
return {
id: 'office_supplies',
name_sv: 'Kontorsmaterial',
name_en: 'Office supplies',
group: 'office',
debit_account: '6110',
credit_account: '1930',
fallback_category: 'expense_office',
default_private: false,
vat_treatment: 'standard_25',
vat_rate: 0.25,
deductibility: 'full',
risk_level: 'LOW',
requires_review: false,
entity_applicability: 'all',
direction: 'expense',
}
}
return null
}),
buildMappingResultFromTemplate: vi.fn(() => ({
rule: null,
debit_account: '6110',
credit_account: '1930',
risk_level: 'LOW',
confidence: 1.0,
requires_review: false,
default_private: false,
vat_lines: [],
description: 'Kontorsmaterial',
})),
}))
// Mock transaction entries
const mockCreateTransactionJournalEntry = vi.fn().mockResolvedValue({ id: 'je-1' })
vi.mock('@/lib/bookkeeping/transaction-entries', () => ({
createTransactionJournalEntry: (...args: unknown[]) => mockCreateTransactionJournalEntry(...args),
}))
// Mock mapping engine
const mockSaveUserMappingRule = vi.fn().mockResolvedValue(undefined)
vi.mock('@/lib/bookkeeping/mapping-engine', () => ({
saveUserMappingRule: (...args: unknown[]) => mockSaveUserMappingRule(...args),
}))
// Mock Supabase — set up once, re-configure per test via auth mock + queue
const mockCreateClient = vi.fn()
vi.mock('@/lib/supabase/server', () => ({
createClient: (...args: unknown[]) => mockCreateClient(...args),
}))
describe('POST /api/transactions/batch-describe', () => {
let POST: typeof import('../route').POST
beforeEach(async () => {
vi.clearAllMocks()
eventBus.clear()
const mod = await import('../route')
POST = mod.POST
})
it('returns 401 when not authenticated', async () => {
const { supabase } = createQueuedMockSupabase()
supabase.auth.getUser.mockResolvedValue({
data: { user: null },
error: { message: 'Not authenticated' },
})
mockCreateClient.mockResolvedValue(supabase)
const req = createMockRequest('/api/transactions/batch-describe', {
method: 'POST',
body: {
merchant_name: 'Staples',
template_id: 'office_supplies',
is_business: true,
},
})
const res = await POST(req)
const { status, body } = await parseJsonResponse(res)
expect(status).toBe(401)
expect(body).toHaveProperty('error', 'Unauthorized')
})
it('returns 400 for invalid template_id', async () => {
const { supabase } = createQueuedMockSupabase()
supabase.auth.getUser.mockResolvedValue({
data: { user: { id: 'user-1' } },
error: null,
})
mockCreateClient.mockResolvedValue(supabase)
const req = createMockRequest('/api/transactions/batch-describe', {
method: 'POST',
body: {
merchant_name: 'Staples',
template_id: 'nonexistent_template',
is_business: true,
},
})
const res = await POST(req)
const { status, body } = await parseJsonResponse(res)
expect(status).toBe(400)
expect(body).toHaveProperty('error', 'Invalid template_id')
})
it('applies template to uncategorized merchant transactions', async () => {
const tx1 = makeTransaction({ id: 'tx-1', merchant_name: 'Staples', amount: -299 })
const tx2 = makeTransaction({ id: 'tx-2', merchant_name: 'Staples', amount: -150 })
const { supabase, enqueueMany } = createQueuedMockSupabase()
supabase.auth.getUser.mockResolvedValue({
data: { user: { id: 'user-1' } },
error: null,
})
enqueueMany([
// company_settings
{ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null },
// fetch uncategorized transactions
{ data: [tx1, tx2], error: null },
// fiscal period upsert for tx1
{ data: null, error: null },
// transaction update for tx1
{ data: null, error: null },
// fiscal period upsert for tx2
{ data: null, error: null },
// transaction update for tx2
{ data: null, error: null },
])
mockCreateClient.mockResolvedValue(supabase)
const req = createMockRequest('/api/transactions/batch-describe', {
method: 'POST',
body: {
merchant_name: 'Staples',
template_id: 'office_supplies',
is_business: true,
user_description: 'office supplies purchase',
},
})
const res = await POST(req)
const { status, body } = await parseJsonResponse<{ data: { applied: number; errors: string[] } }>(res)
expect(status).toBe(200)
expect(body.data.applied).toBe(2)
expect(body.data.errors).toHaveLength(0)
// Verify journal entries were created
expect(mockCreateTransactionJournalEntry).toHaveBeenCalledTimes(2)
// Verify mapping rule was saved with user description
expect(mockSaveUserMappingRule).toHaveBeenCalledWith(
'user-1',
'Staples',
'6110',
'1930',
false,
'office supplies purchase',
'office_supplies'
)
})
it('returns 0 applied when no uncategorized transactions exist', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
supabase.auth.getUser.mockResolvedValue({
data: { user: { id: 'user-1' } },
error: null,
})
enqueueMany([
// company_settings
{ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null },
// fetch uncategorized transactions — empty
{ data: [], error: null },
])
mockCreateClient.mockResolvedValue(supabase)
const req = createMockRequest('/api/transactions/batch-describe', {
method: 'POST',
body: {
merchant_name: 'Unknown Merchant',
template_id: 'office_supplies',
is_business: true,
},
})
const res = await POST(req)
const { status, body } = await parseJsonResponse<{ data: { applied: number } }>(res)
expect(status).toBe(200)
expect(body.data.applied).toBe(0)
})
})
@@ -0,0 +1,171 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { eventBus } from '@/lib/events'
import { ensureInitialized } from '@/lib/init'
import { validateBody } from '@/lib/api/validate'
import { BatchDescribeSchema } from '@/lib/api/schemas'
import { getTemplateById, buildMappingResultFromTemplate } from '@/lib/bookkeeping/booking-templates'
import { createTransactionJournalEntry } from '@/lib/bookkeeping/transaction-entries'
import { saveUserMappingRule } from '@/lib/bookkeeping/mapping-engine'
import type { Transaction, EntityType, TransactionCategory } from '@/types'
ensureInitialized()
export async function POST(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const validation = await validateBody(request, BatchDescribeSchema)
if (!validation.success) return validation.response
const { merchant_name, template_id, is_business, user_description } = validation.data
// Look up the template
const template = getTemplateById(template_id)
if (!template) {
return NextResponse.json({ error: 'Invalid template_id' }, { status: 400 })
}
// Fetch entity type and fiscal year start
const { data: settings } = await supabase
.from('company_settings')
.select('entity_type, fiscal_year_start_month')
.eq('user_id', user.id)
.single()
const entityType: EntityType = (settings?.entity_type as EntityType) || 'enskild_firma'
const fiscalYearStartMonth: number = settings?.fiscal_year_start_month ?? 1
// Fetch all uncategorized transactions from the specified merchant (limit 50)
const { data: transactions, error: fetchError } = await supabase
.from('transactions')
.select('*')
.eq('user_id', user.id)
.eq('merchant_name', merchant_name)
.is('journal_entry_id', null)
.order('date', { ascending: true })
.limit(50)
if (fetchError || !transactions || transactions.length === 0) {
return NextResponse.json({
data: { applied: 0, errors: [] },
})
}
const finalCategory: TransactionCategory = is_business
? template.fallback_category
: 'private'
let applied = 0
const errors: string[] = []
for (const tx of transactions) {
try {
const mappingResult = buildMappingResultFromTemplate(
template,
tx as Transaction,
entityType
)
// Ensure fiscal period exists
const txDate = new Date(tx.date)
const txMonth = txDate.getMonth() + 1
const txYear = txDate.getFullYear()
let periodStartYear: number
if (fiscalYearStartMonth === 1) {
periodStartYear = txYear
} else if (txMonth >= fiscalYearStartMonth) {
periodStartYear = txYear
} else {
periodStartYear = txYear - 1
}
const startMonth = String(fiscalYearStartMonth).padStart(2, '0')
const periodStart = `${periodStartYear}-${startMonth}-01`
const endYear = fiscalYearStartMonth === 1 ? periodStartYear : periodStartYear + 1
const endMonth = fiscalYearStartMonth === 1 ? 12 : fiscalYearStartMonth - 1
const lastDay = new Date(endYear, endMonth, 0).getDate()
const periodEnd = `${endYear}-${String(endMonth).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`
const periodName = fiscalYearStartMonth === 1
? `Räkenskapsår ${periodStartYear}`
: `Räkenskapsår ${periodStartYear}/${endYear}`
await supabase
.from('fiscal_periods')
.upsert({
user_id: user.id,
name: periodName,
period_start: periodStart,
period_end: periodEnd,
}, { onConflict: 'user_id,period_start,period_end' })
// Create journal entry
let journalEntryId: string | null = null
try {
const journalEntry = await createTransactionJournalEntry(
user.id,
tx as Transaction,
mappingResult
)
if (journalEntry) {
journalEntryId = journalEntry.id
}
} catch (err) {
console.error(`[batch-describe] Journal entry failed for ${tx.id}:`, err)
}
// Update the transaction
await supabase
.from('transactions')
.update({
is_business,
category: finalCategory,
journal_entry_id: journalEntryId,
})
.eq('id', tx.id)
await eventBus.emit({
type: 'transaction.categorized',
payload: {
transaction: tx as Transaction,
account: mappingResult.debit_account,
taxCode: mappingResult.vat_lines[0]?.account_number || '',
userId: user.id,
},
})
applied++
} catch (err) {
const msg = err instanceof Error ? err.message : 'Unknown error'
errors.push(`${tx.id}: ${msg}`)
}
}
// Save a mapping rule for future auto-categorization
if (applied > 0) {
try {
const sampleTx = transactions[0] as Transaction
const sampleResult = buildMappingResultFromTemplate(template, sampleTx, entityType)
await saveUserMappingRule(
user.id,
merchant_name,
sampleResult.debit_account,
sampleResult.credit_account,
!is_business,
user_description,
template_id
)
} catch {
// Non-critical
}
}
return NextResponse.json({
data: { applied, errors },
})
}
@@ -3,6 +3,24 @@ import { NextResponse } from 'next/server'
import { getSuggestedCategories, mergeAiSuggestions, getSuggestedTemplates, type SuggestedCategory, type SuggestedTemplate } from '@/lib/transactions/category-suggestions'
import type { Transaction, TransactionCategory, EntityType } from '@/types'
// Minimum confidence threshold — below this, suggestions are considered weak
// and we trigger on-demand AI categorization to get better results.
const WEAK_SUGGESTION_THRESHOLD = 0.55
/**
* Check if suggestions are "weak" — only history-based fallbacks
* with no strong rule/pattern/AI match.
*/
function hasWeakSuggestions(result: SuggestedCategory[]): boolean {
if (result.length === 0) return true
// Weak if the best suggestion is below threshold
const bestConfidence = Math.max(...result.map((s) => s.confidence))
if (bestConfidence < WEAK_SUGGESTION_THRESHOLD) return true
// Weak if all suggestions are from history only (no rule/pattern/ai match)
if (result.every((s) => s.source === 'history')) return true
return false
}
/**
* POST /api/transactions/suggest-categories
* Batch endpoint for getting category suggestions for multiple transactions
@@ -70,11 +88,18 @@ export async function POST(request: Request) {
.eq('extension_id', 'ai-categorization')
.in('key', aiKeys)
const aiSuggestionsMap: Record<string, { category: string; basAccount: string; confidence: number; reasoning: string }> = {}
type AiSuggestion = { category: string; basAccount: string; confidence: number; reasoning: string }
const aiSuggestionsMap: Record<string, AiSuggestion[]> = {}
if (aiRecords) {
for (const record of aiRecords) {
const txId = record.key.replace('suggestion:', '')
aiSuggestionsMap[txId] = record.value as { category: string; basAccount: string; confidence: number; reasoning: string }
const value = record.value
// Handle both single object (old) and array (new) storage formats
if (Array.isArray(value)) {
aiSuggestionsMap[txId] = value as AiSuggestion[]
} else {
aiSuggestionsMap[txId] = [value as AiSuggestion]
}
}
}
@@ -86,9 +111,10 @@ export async function POST(request: Request) {
.single()
const entityType = (settings?.entity_type as EntityType) || undefined
// Generate suggestions for each transaction
// Generate initial suggestions for each transaction
const suggestions: Record<string, SuggestedCategory[]> = {}
const template_suggestions: Record<string, SuggestedTemplate[]> = {}
const needsAiIds: string[] = []
for (const tx of transactions) {
let result = getSuggestedCategories(
@@ -97,14 +123,74 @@ export async function POST(request: Request) {
categoryHistory
)
// Merge AI suggestions if available
const aiSuggestion = aiSuggestionsMap[tx.id]
if (aiSuggestion) {
result = mergeAiSuggestions(result, [aiSuggestion])
// Merge pre-computed AI suggestions if available
const aiSuggestions = aiSuggestionsMap[tx.id]
if (aiSuggestions && aiSuggestions.length > 0) {
result = mergeAiSuggestions(result, aiSuggestions, tx.amount)
} else if (hasWeakSuggestions(result)) {
// No pre-computed AI suggestion AND rule-based suggestions are weak —
// mark this transaction for on-demand AI categorization
needsAiIds.push(tx.id)
}
suggestions[tx.id] = result
template_suggestions[tx.id] = getSuggestedTemplates(tx as Transaction, entityType)
template_suggestions[tx.id] = await getSuggestedTemplates(tx as Transaction, entityType)
}
// Trigger on-demand AI categorization for transactions with weak suggestions
if (needsAiIds.length > 0) {
console.log(
`[suggest-categories] ${needsAiIds.length} transactions have weak suggestions, triggering on-demand AI:`,
needsAiIds.map((id) => {
const tx = transactions.find((t) => t.id === id)
return tx
? { id, description: tx.description, merchant_name: tx.merchant_name, amount: tx.amount }
: { id }
})
)
try {
const { categorizeTransactions } = await import(
'@/extensions/general/ai-categorization'
)
const aiResults = await categorizeTransactions(user.id, needsAiIds)
console.log(
'[suggest-categories] AI categorization results:',
aiResults.map((r) => ({
id: r.transactionId,
category: r.category,
basAccount: r.basAccount,
confidence: r.confidence,
reasoning: r.reasoning,
isPrivate: r.isPrivate,
templateId: r.templateId,
}))
)
// Group AI results by transactionId (AI now returns 2 per transaction)
const groupedAi: Record<string, { category: string; basAccount: string; confidence: number; reasoning: string }[]> = {}
for (const aiResult of aiResults) {
if (!groupedAi[aiResult.transactionId]) {
groupedAi[aiResult.transactionId] = []
}
groupedAi[aiResult.transactionId].push({
category: aiResult.category,
basAccount: aiResult.basAccount,
confidence: aiResult.confidence,
reasoning: aiResult.reasoning,
})
}
for (const [txId, aiSuggestions] of Object.entries(groupedAi)) {
const existing = suggestions[txId] || []
const tx = transactions.find((t) => t.id === txId)
suggestions[txId] = mergeAiSuggestions(existing, aiSuggestions, tx?.amount)
}
} catch (err) {
// AI categorization is non-blocking — log and continue with existing suggestions
console.error('[suggest-categories] On-demand AI categorization failed:', err)
}
}
return NextResponse.json({ suggestions, template_suggestions })
+8
View File
@@ -198,6 +198,13 @@ export default async function RootPage() {
streak_count: streakCount,
}
// Fetch enabled extension toggles
const { data: enabledToggles } = await supabase
.from('extension_toggles')
.select('sector_slug, extension_slug')
.eq('user_id', user.id)
.eq('enabled', true)
return (
<div className="min-h-screen bg-background">
<DashboardNav companyName={settings.company_name || 'Min verksamhet'} entityType={settings.entity_type || 'enskild_firma'} />
@@ -220,6 +227,7 @@ export default async function RootPage() {
receiptQueue,
missingUnderlagCount,
}}
enabledExtensions={enabledToggles || []}
/>
</div>
<ChatWidget />