From 3e7fa45ed69b6668a9ecc63bba21af016fe382cc Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Tue, 24 Feb 2026 16:02:06 +0100 Subject: [PATCH] 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 --- app/(dashboard)/transactions/page.tsx | 69 ++- .../[id]/send/__tests__/route.test.ts | 2 + app/api/invoices/__tests__/route.test.ts | 4 + .../[id]/categorize/__tests__/route.test.ts | 4 +- app/api/transactions/[id]/categorize/route.ts | 4 +- .../[id]/describe/__tests__/route.test.ts | 200 +++++++ app/api/transactions/[id]/describe/route.ts | 98 ++++ .../batch-describe/__tests__/route.test.ts | 212 +++++++ app/api/transactions/batch-describe/route.ts | 171 ++++++ .../transactions/suggest-categories/route.ts | 100 +++- components/bookkeeping/JournalEntryList.tsx | 6 +- .../general/UserDescriptionMatchWorkspace.tsx | 15 + .../DescribeTransactionDialog.tsx | 539 ++++++++++++++++++ .../transactions/InvoiceMatchDialog.tsx | 32 ++ .../transactions/JournalEntryPreview.tsx | 114 ++++ components/transactions/QuickReviewDialog.tsx | 40 +- .../transactions/SwipeCategorizationView.tsx | 104 +++- .../transactions/TransactionInboxCard.tsx | 54 +- components/transactions/transaction-types.ts | 4 + .../general/ai-categorization/categorizer.ts | 70 ++- extensions/general/ai-categorization/index.ts | 18 +- .../receipt-ocr/lib/receipt-categorizer.ts | 8 + .../general/user-description-match/index.ts | 142 +++++ lib/api/schemas.ts | 16 + .../__tests__/mapping-engine.test.ts | 141 +++++ .../__tests__/template-embeddings.test.ts | 34 ++ lib/bookkeeping/category-mapping.ts | 16 + lib/bookkeeping/client-account-names.ts | 70 +++ lib/bookkeeping/mapping-engine.ts | 77 ++- lib/bookkeeping/template-embeddings.ts | 16 +- lib/extensions/__tests__/sectors.test.ts | 4 +- lib/extensions/icon-resolver.tsx | 2 + lib/extensions/loader.ts | 2 + lib/extensions/sectors.ts | 12 + lib/extensions/workspace-registry.tsx | 1 + lib/reports/__tests__/sie-export.test.ts | 2 +- lib/transactions/category-suggestions.ts | 48 +- ...240101000041_user_description_matching.sql | 23 + tsconfig.json | 3 +- types/index.ts | 8 + 40 files changed, 2401 insertions(+), 84 deletions(-) create mode 100644 app/api/transactions/[id]/describe/__tests__/route.test.ts create mode 100644 app/api/transactions/[id]/describe/route.ts create mode 100644 app/api/transactions/batch-describe/__tests__/route.test.ts create mode 100644 app/api/transactions/batch-describe/route.ts create mode 100644 components/extensions/general/UserDescriptionMatchWorkspace.tsx create mode 100644 components/transactions/DescribeTransactionDialog.tsx create mode 100644 components/transactions/JournalEntryPreview.tsx create mode 100644 extensions/general/user-description-match/index.ts create mode 100644 lib/bookkeeping/__tests__/mapping-engine.test.ts create mode 100644 lib/bookkeeping/client-account-names.ts create mode 100644 supabase/migrations/20240101000041_user_description_matching.sql diff --git a/app/(dashboard)/transactions/page.tsx b/app/(dashboard)/transactions/page.tsx index 5bba42f0..f41ff124 100644 --- a/app/(dashboard)/transactions/page.tsx +++ b/app/(dashboard)/transactions/page.tsx @@ -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([]) @@ -33,6 +34,7 @@ export default function TransactionsPage() { const [isCreating, setIsCreating] = useState(false) const [showSwipeView, setShowSwipeView] = useState(false) const [categorySuggestions, setCategorySuggestions] = useState>({}) + const [templateSuggestions, setTemplateSuggestions] = useState>({}) const [isLoadingSuggestions, setIsLoadingSuggestions] = useState(false) const [processingId, setProcessingId] = useState(null) @@ -57,6 +59,10 @@ export default function TransactionsPage() { const [quickReviewCategory, setQuickReviewCategory] = useState(null) const [quickReviewLabel, setQuickReviewLabel] = useState('') + // Describe dialog + const [describeDialogOpen, setDescribeDialogOpen] = useState(false) + const [describeDialogTransaction, setDescribeDialogTransaction] = useState(null) + // Entity type for tooltip context const [entityType, setEntityType] = useState('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 ( 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} /> + + diff --git a/app/api/invoices/[id]/send/__tests__/route.test.ts b/app/api/invoices/[id]/send/__tests__/route.test.ts index 3a8e0e2b..cb836f2b 100644 --- a/app/api/invoices/[id]/send/__tests__/route.test.ts +++ b/app/api/invoices/[id]/send/__tests__/route.test.ts @@ -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', }, ], diff --git a/app/api/invoices/__tests__/route.test.ts b/app/api/invoices/__tests__/route.test.ts index d82bcf18..d7e68c95 100644 --- a/app/api/invoices/__tests__/route.test.ts +++ b/app/api/invoices/__tests__/route.test.ts @@ -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', }, ], diff --git a/app/api/transactions/[id]/categorize/__tests__/route.test.ts b/app/api/transactions/[id]/categorize/__tests__/route.test.ts index a4eff82b..ff8da182 100644 --- a/app/api/transactions/[id]/categorize/__tests__/route.test.ts +++ b/app/api/transactions/[id]/categorize/__tests__/route.test.ts @@ -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' }) diff --git a/app/api/transactions/[id]/categorize/route.ts b/app/api/transactions/[id]/categorize/route.ts index 128151dc..f193c7bd 100644 --- a/app/api/transactions/[id]/categorize/route.ts +++ b/app/api/transactions/[id]/categorize/route.ts @@ -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) diff --git a/app/api/transactions/[id]/describe/__tests__/route.test.ts b/app/api/transactions/[id]/describe/__tests__/route.test.ts new file mode 100644 index 00000000..f492c80f --- /dev/null +++ b/app/api/transactions/[id]/describe/__tests__/route.test.ts @@ -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 }>(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 }>(res) + + expect(status).toBe(200) + expect(body.data.needs_more_detail).toBe(true) + }) +}) diff --git a/app/api/transactions/[id]/describe/route.ts b/app/api/transactions/[id]/describe/route.ts new file mode 100644 index 00000000..1d9d55a5 --- /dev/null +++ b/app/api/transactions/[id]/describe/route.ts @@ -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, + }, + }) +} diff --git a/app/api/transactions/batch-describe/__tests__/route.test.ts b/app/api/transactions/batch-describe/__tests__/route.test.ts new file mode 100644 index 00000000..04346cb1 --- /dev/null +++ b/app/api/transactions/batch-describe/__tests__/route.test.ts @@ -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) + }) +}) diff --git a/app/api/transactions/batch-describe/route.ts b/app/api/transactions/batch-describe/route.ts new file mode 100644 index 00000000..ceaf1d08 --- /dev/null +++ b/app/api/transactions/batch-describe/route.ts @@ -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 }, + }) +} diff --git a/app/api/transactions/suggest-categories/route.ts b/app/api/transactions/suggest-categories/route.ts index e3aeaec8..0dc27903 100644 --- a/app/api/transactions/suggest-categories/route.ts +++ b/app/api/transactions/suggest-categories/route.ts @@ -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 = {} + type AiSuggestion = { category: string; basAccount: string; confidence: number; reasoning: string } + const aiSuggestionsMap: Record = {} 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 = {} const template_suggestions: Record = {} + 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 = {} + 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 }) } diff --git a/components/bookkeeping/JournalEntryList.tsx b/components/bookkeeping/JournalEntryList.tsx index de63de7d..579f17c9 100644 --- a/components/bookkeeping/JournalEntryList.tsx +++ b/components/bookkeeping/JournalEntryList.tsx @@ -206,8 +206,11 @@ export default function JournalEntryList({ periodId }: Props) { - {isExpanded && lines.length > 0 && ( + {isExpanded && ( + {lines.length === 0 ? ( +

Inga kontorader hittades för denna verifikation.

+ ) : ( @@ -261,6 +264,7 @@ export default function JournalEntryList({ periodId }: Props) {
+ )} } + /> + ) +} diff --git a/components/transactions/DescribeTransactionDialog.tsx b/components/transactions/DescribeTransactionDialog.tsx new file mode 100644 index 00000000..45b40d84 --- /dev/null +++ b/components/transactions/DescribeTransactionDialog.tsx @@ -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('describe') + const [description, setDescription] = useState('') + const [isSearching, setIsSearching] = useState(false) + const [isBooking, setIsBooking] = useState(false) + const [isBatchApplying, setIsBatchApplying] = useState(false) + const [describeResult, setDescribeResult] = useState(null) + const [selectedTemplateId, setSelectedTemplateId] = useState(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 ( + + + + + {step === 'describe' && 'Beskriv transaktion'} + {step === 'pick' && 'Valj mall'} + {step === 'batch' && 'Bokfor liknande'} + + + {step === 'describe' && 'Beskriv vad transaktionen galler sa hittar vi ratt bokforingsmall'} + {step === 'pick' && 'Valj den mall som stammer bast'} + {step === 'batch' && 'Transaktion bokford!'} + + + + {/* Transaction summary - shown in describe and pick steps */} + {(step === 'describe' || step === 'pick') && ( +
+
+ {isIncome ? ( + + ) : ( + + )} +
+
+

{transaction.description}

+

{formatDate(transaction.date)}

+
+

+ {isIncome ? '+' : ''} + {formatCurrency(transaction.amount, transaction.currency)} +

+
+ )} + + {/* Step 1: Describe */} + {step === 'describe' && ( +
+