feat: transaction categorization UX improvements and description matching

Add journal entry preview, human-readable account names, auto-apply VAT,
fallback template suggestions, example prompts, invoice match comparison,
and batch result feedback. Also includes user-description-match extension,
describe/batch-describe API routes, improved AI categorization with multi-
suggestion support, and template embedding search.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-02-24 16:02:06 +01:00
co-authored by Claude Opus 4.6
parent 6cef4e11eb
commit 3e7fa45ed6
40 changed files with 2401 additions and 84 deletions
@@ -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 },
})
}