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:
co-authored by
Claude Opus 4.6
parent
6cef4e11eb
commit
3e7fa45ed6
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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,15 +123,75 @@ 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] = 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 })
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user