diff --git a/app/(dashboard)/transactions/page.tsx b/app/(dashboard)/transactions/page.tsx index 2d1b7bd9..1b68176d 100644 --- a/app/(dashboard)/transactions/page.tsx +++ b/app/(dashboard)/transactions/page.tsx @@ -20,7 +20,7 @@ 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 TemplatePicker from '@/components/transactions/TemplatePicker' import { EXPENSE_CATEGORIES, INCOME_CATEGORIES } from '@/components/transactions/transaction-types' import { getDefaultAccountForCategory, getDefaultVatTreatmentForCategory } from '@/lib/bookkeeping/category-mapping' @@ -77,10 +77,6 @@ export default function TransactionsPage() { const [quickReviewOpen, setQuickReviewOpen] = useState(false) const [quickReview, setQuickReview] = useState(null) - // Describe dialog - const [describeDialogOpen, setDescribeDialogOpen] = useState(false) - const [describeDialogTransaction, setDescribeDialogTransaction] = useState(null) - // Entity type for tooltip context const [entityType, setEntityType] = useState('enskild_firma') @@ -716,33 +712,6 @@ 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 ( @@ -813,7 +782,7 @@ export default function TransactionsPage() { onMarkPrivate={handleMarkPrivate} onOpenMatchDialog={openMatchDialog} onOpenCategoryDialog={openCategoryDialog} - onOpenDescribe={openDescribeDialog} + onOpenQuickReview={handleOpenQuickReview} onOpenTemplateReview={handleOpenTemplateReview} onToggleSelect={toggleBatchSelect} @@ -936,14 +905,6 @@ export default function TransactionsPage() { onChangeTemplate={handleChangeTemplate} /> - - diff --git a/app/api/import/sie/create-accounts/route.ts b/app/api/import/sie/create-accounts/route.ts index 755b9b8d..e0f6ec0e 100644 --- a/app/api/import/sie/create-accounts/route.ts +++ b/app/api/import/sie/create-accounts/route.ts @@ -108,7 +108,7 @@ export async function POST(request: Request) { const { data: upserted, error } = await supabase .from('chart_of_accounts') .upsert(batch, { - onConflict: 'user_id,account_number', + onConflict: 'company_id,account_number', ignoreDuplicates: true, count: 'exact', }) diff --git a/app/api/transactions/[id]/describe/__tests__/route.test.ts b/app/api/transactions/[id]/describe/__tests__/route.test.ts deleted file mode 100644 index 139389fe..00000000 --- a/app/api/transactions/[id]/describe/__tests__/route.test.ts +++ /dev/null @@ -1,217 +0,0 @@ -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 counterparty template lookup -vi.mock('@/lib/bookkeeping/counterparty-templates', () => ({ - findCounterpartyTemplate: vi.fn().mockResolvedValue(null), - buildMappingResultFromCounterpartyTemplate: vi.fn(), - formatCounterpartyName: vi.fn((name: string) => name), -})) - -// Mock booking templates -const mockFindMatchingTemplates = vi.fn().mockReturnValue([]) -vi.mock('@/lib/bookkeeping/booking-templates', () => ({ - findMatchingTemplates: (...args: unknown[]) => mockFindMatchingTemplates(...args), -})) - -// Mock Supabase -const mockCreateClient = vi.fn() -vi.mock('@/lib/supabase/server', () => ({ - createClient: (...args: unknown[]) => mockCreateClient(...args), -})) - -vi.mock('@/lib/company/context', () => ({ - requireCompanyId: vi.fn().mockResolvedValue('company-1'), - getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), -})) - -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, - }) - - mockFindMatchingTemplates.mockReturnValueOnce([ - { - template: { - id: 'restaurant_dining', - name_sv: 'Restaurangbesök', - name_en: 'Restaurant dining', - group: 'representation', - debit_account: '6071', - credit_account: '1930', - description_sv: 'Representation - restaurang', - vat_rate: 0.12, - vat_treatment: 'reduced_12', - deductibility: 'conditional', - deductibility_note_sv: null, - special_rules_sv: null, - risk_level: 'MEDIUM', - }, - 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.ai_suggestion).toBeNull() - 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') - }) - - it('sets needs_more_detail when confidence is low', async () => { - const tx = makeTransaction({ id: 'tx-2', merchant_name: null }) - - mockFindMatchingTemplates.mockReturnValueOnce([ - { - template: { - id: 'misc', - name_sv: 'Diverse', - name_en: 'Miscellaneous', - group: 'other', - debit_account: '6991', - credit_account: '1930', - description_sv: 'Okategoriserad utgift', - vat_rate: 0, - vat_treatment: null, - deductibility: 'full', - deductibility_note_sv: null, - special_rules_sv: null, - risk_level: 'LOW', - }, - 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 deleted file mode 100644 index 560744eb..00000000 --- a/app/api/transactions/[id]/describe/route.ts +++ /dev/null @@ -1,131 +0,0 @@ -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 { findMatchingTemplates } from '@/lib/bookkeeping/booking-templates' -import { findCounterpartyTemplate, buildMappingResultFromCounterpartyTemplate, formatCounterpartyName } from '@/lib/bookkeeping/counterparty-templates' -import { requireCompanyId } from '@/lib/company/context' -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 companyId = await requireCompanyId(supabase, user.id) - - 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('company_id', companyId) - .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('company_id', companyId) - .single() - - const entityType: EntityType = (settings?.entity_type as EntityType) || 'enskild_firma' - - // Run template matching and counterparty lookup in parallel - const [templates, counterpartyMatch] = await Promise.all([ - findMatchingTemplates(transaction as Transaction, entityType), - findCounterpartyTemplate(supabase, user.id, transaction as Transaction), - ]) - - // Build counterparty suggestion if matched - let counterpartySuggestion: { - id: string - counterparty_name: string - debit_account: string - credit_account: string - vat_treatment: string | null - confidence: number - occurrence_count: number - source: string - line_pattern: unknown[] | null - } | null = null - - if (counterpartyMatch) { - const tmpl = counterpartyMatch.template - counterpartySuggestion = { - id: tmpl.id, - counterparty_name: formatCounterpartyName(tmpl.counterparty_name), - debit_account: tmpl.debit_account, - credit_account: tmpl.credit_account, - vat_treatment: tmpl.vat_treatment, - line_pattern: tmpl.line_pattern ?? null, - confidence: counterpartyMatch.confidence, - occurrence_count: tmpl.occurrence_count, - source: tmpl.source, - } - } - - const needsMoreDetail = counterpartySuggestion - ? false - : 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('company_id', companyId) - .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, - })), - counterparty_match: counterpartySuggestion, - ai_suggestion: null, - 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 deleted file mode 100644 index c12e8ee3..00000000 --- a/app/api/transactions/batch-describe/__tests__/route.test.ts +++ /dev/null @@ -1,218 +0,0 @@ -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), -})) - -vi.mock('@/lib/company/context', () => ({ - requireCompanyId: vi.fn().mockResolvedValue('company-1'), - getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), -})) - -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( - expect.anything(), - 'company-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 deleted file mode 100644 index ec5ad7ea..00000000 --- a/app/api/transactions/batch-describe/route.ts +++ /dev/null @@ -1,179 +0,0 @@ -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 { requireCompanyId } from '@/lib/company/context' -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 companyId = await requireCompanyId(supabase, user.id) - - 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('company_id', companyId) - .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('company_id', companyId) - .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, - company_id: companyId, - name: periodName, - period_start: periodStart, - period_end: periodEnd, - }, { onConflict: 'company_id,period_start,period_end' }) - - // Create journal entry - let journalEntryId: string | null = null - try { - const journalEntry = await createTransactionJournalEntry( - supabase, - companyId, - 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, - companyId, - }, - }) - - 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( - supabase, - companyId, - 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/components/transactions/DescribeTransactionDialog.tsx b/components/transactions/DescribeTransactionDialog.tsx deleted file mode 100644 index ad8f9b89..00000000 --- a/components/transactions/DescribeTransactionDialog.tsx +++ /dev/null @@ -1,787 +0,0 @@ -'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, - Wand, -} from 'lucide-react' -import JournalEntryPreview from './JournalEntryPreview' -import { formatAccountWithName } from '@/lib/bookkeeping/client-account-names' -import type { TransactionWithInvoice } from './transaction-types' -import type { LinePatternEntry } from '@/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 CounterpartyMatch { - id: string - counterparty_name: string - debit_account: string - credit_account: string - vat_treatment: string | null - confidence: number - occurrence_count: number - source: string - line_pattern: LinePatternEntry[] | null -} - -interface AiSuggestion { - debit_account: string - credit_account: string - vat_treatment: string | null - category: string - confidence: number - reasoning: string - warnings: string[] - template_id: string | null -} - -interface DescribeResult { - templates: TemplateMatch[] - counterparty_match: CounterpartyMatch | null - ai_suggestion: AiSuggestion | null - 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' -type Selection = { type: 'template'; templateId: string } | { type: 'ai' } | { type: 'counterparty' } - -function getExamplePrompts(transaction: TransactionWithInvoice): string[] { - const desc = (transaction.description || '').toLowerCase() - const isExpense = transaction.amount < 0 - - if (!isExpense) { - return ['Konsultarvode', 'Försäljning av varor', 'Återbetalning'] - } - - 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 ['Tjänsteresa', 'Hotell konferens', 'Flygbiljett'] - } - if (desc.includes('uber') || desc.includes('taxi') || desc.includes('bolt') || desc.includes('sj ')) { - return ['Taxi till kund', 'Tjänsteresa', 'Pendling'] - } - if (desc.includes('google') || desc.includes('meta') || desc.includes('facebook') || desc.includes('linkedin')) { - return ['Online-annonsering', 'SaaS-prenumeration', 'Marknadsföringskampanj'] - } - if (desc.includes('amazon') || desc.includes('aws') || desc.includes('azure') || desc.includes('cloud')) { - return ['Serverhosting', 'SaaS-prenumeration', 'Kontorsmaterial'] - } - - return ['Kontorsmaterial', 'SaaS-prenumeration', 'Konsulttjänst', 'Reklam'] -} - -function getVatRateFromTreatment(treatment: string | null): number { - switch (treatment) { - case 'standard_25': return 0.25 - case 'reduced_12': return 0.12 - case 'reduced_6': return 0.06 - default: return 0 - } -} - -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 [selection, setSelection] = useState(null) - - const selectedTemplateId = selection?.type === 'template' ? selection.templateId : null - const isAiSelected = selection?.type === 'ai' - - function resetState() { - setStep('describe') - setDescription('') - setIsSearching(false) - setIsBooking(false) - setIsBatchApplying(false) - setDescribeResult(null) - setSelection(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 söka mallar', - variant: 'destructive', - }) - setIsSearching(false) - return - } - - setDescribeResult(result.data) - setSelection(null) - setStep('pick') - } catch { - toast({ - title: 'Fel', - description: 'Något gick fel vid sökning', - variant: 'destructive', - }) - } - setIsSearching(false) - } - - async function handleBook() { - if (!transaction || !describeResult || !selection) return - - setIsBooking(true) - try { - // Build categorize request based on selection type - let body: Record - - if (selection.type === 'counterparty') { - const cp = describeResult.counterparty_match! - body = { - is_business: true, - counterparty_template_id: cp.id, - user_description: describeResult.user_description, - } - } else if (selection.type === 'template') { - body = { - is_business: true, - template_id: selection.templateId, - user_description: describeResult.user_description, - } - } else { - // AI suggestion selected - const ai = describeResult.ai_suggestion! - if (ai.template_id) { - // AI matched a template — use template-based booking - body = { - is_business: true, - template_id: ai.template_id, - user_description: describeResult.user_description, - } - } else { - // AI category-based booking — category maps to the correct account - body = { - is_business: true, - category: ai.category, - vat_treatment: ai.vat_treatment || undefined, - user_description: describeResult.user_description, - } - } - } - - const response = await fetch(`/api/transactions/${transaction.id}/categorize`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }) - const result = await response.json() - if (!response.ok) { - toast({ - title: 'Fel', - description: result.error || 'Kunde inte bokföra 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: 'Bokförd', description: 'Transaktion bokförd och verifikation skapad' }) - onCategorized(transaction.id, result.journal_entry_id || null) - handleOpenChange(false) - } - } catch { - toast({ - title: 'Fel', - description: 'Något gick fel vid bokföring', - variant: 'destructive', - }) - setIsBooking(false) - } - } - - async function handleBatchApply() { - if (!describeResult) return - - // For batch apply, we need a template_id - let templateId: string | null = null - if (selection?.type === 'template') { - templateId = selection.templateId - } else if (selection?.type === 'ai' && describeResult.ai_suggestion?.template_id) { - templateId = describeResult.ai_suggestion.template_id - } - - if (!templateId) 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: templateId, - is_business: true, - user_description: describeResult.user_description, - }), - }) - const result = await response.json() - if (!response.ok) { - toast({ - title: 'Fel', - description: result.error || 'Kunde inte bokföra 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 bokförda`, - }) - } - onBatchApplied?.(applied) - handleOpenChange(false) - } catch { - toast({ - title: 'Fel', - description: 'Något gick fel vid batchbokföring', - variant: 'destructive', - }) - setIsBatchApplying(false) - } - } - - function handleSkipBatch() { - toast({ title: 'Bokförd', description: 'Transaktion bokförd och verifikation skapad' }) - handleOpenChange(false) - } - - if (!transaction) return null - - const isIncome = transaction.amount > 0 - const counterpartyMatch = describeResult?.counterparty_match - const isCounterpartySelected = selection?.type === 'counterparty' - const aiSuggestion = describeResult?.ai_suggestion - // Check if AI agrees with top template - const topTemplate = describeResult?.templates[0] - const aiAgreesWithTop = aiSuggestion && topTemplate && aiSuggestion.debit_account === topTemplate.debit_account - - // Determine if batch apply is available (requires a template_id) - const canBatchApply = selection?.type === 'template' || (selection?.type === 'ai' && !!describeResult?.ai_suggestion?.template_id) - - return ( - - - - - {step === 'describe' && 'Beskriv transaktion'} - {step === 'pick' && 'Välj mall'} - {step === 'batch' && 'Bokför liknande'} - - - {step === 'describe' && 'Beskriv vad transaktionen gäller så hittar vi rätt bokföringsmall'} - {step === 'pick' && 'Välj den mall som stämmer bäst'} - {step === 'batch' && 'Transaktion bokförd!'} - - - - {/* 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' && ( -
-