feat: remove AI extensions, restructure settings, and add atomic voucher commits (#157)
Remove AI-dependent extensions (ai-chat, ai-categorization, receipt-ocr, invoice-inbox) and their infrastructure (lib/ai/*, ai-consent, LangChain/ Anthropic/OpenAI deps) to simplify core and reduce bundle size. Restructure monolithic settings page into dedicated sub-pages (company, bookkeeping, invoicing, tax, banking, api, account, team, templates) with shared layout and sidebar navigation. Add atomic commit_journal_entry RPC so voucher number increment and status update happen in a single transaction — prevents burned numbers on constraint failures. Add continuity check report and voucher gap explanation tracking. Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
e89f2c402d
commit
d0b3f21bde
@@ -14,21 +14,13 @@ vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
|
||||
vi.mock('@/lib/bookkeeping/counterparty-templates', () => ({
|
||||
findCounterpartyTemplate: vi.fn().mockResolvedValue(null),
|
||||
buildMappingResultFromCounterpartyTemplate: vi.fn(),
|
||||
formatCounterpartyName: vi.fn((name: string) => name),
|
||||
}))
|
||||
|
||||
// Mock extension registry
|
||||
const mockFindSimilarTemplates = vi.fn().mockResolvedValue([])
|
||||
vi.mock('@/lib/extensions/registry', () => ({
|
||||
extensionRegistry: {
|
||||
get: vi.fn().mockReturnValue({
|
||||
id: 'ai-categorization',
|
||||
name: 'AI',
|
||||
version: '1.0.0',
|
||||
services: {
|
||||
findSimilarTemplates: (...args: unknown[]) => mockFindSimilarTemplates(...args),
|
||||
},
|
||||
}),
|
||||
},
|
||||
// Mock booking templates
|
||||
const mockFindMatchingTemplates = vi.fn().mockReturnValue([])
|
||||
vi.mock('@/lib/bookkeeping/booking-templates', () => ({
|
||||
findMatchingTemplates: (...args: unknown[]) => mockFindMatchingTemplates(...args),
|
||||
}))
|
||||
|
||||
// Mock Supabase
|
||||
@@ -121,7 +113,7 @@ describe('POST /api/transactions/[id]/describe', () => {
|
||||
amount: -450,
|
||||
})
|
||||
|
||||
mockFindSimilarTemplates.mockResolvedValueOnce([
|
||||
mockFindMatchingTemplates.mockReturnValueOnce([
|
||||
{
|
||||
template: {
|
||||
id: 'restaurant_dining',
|
||||
@@ -131,6 +123,12 @@ describe('POST /api/transactions/[id]/describe', () => {
|
||||
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,
|
||||
},
|
||||
@@ -162,23 +160,16 @@ describe('POST /api/transactions/[id]/describe', () => {
|
||||
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')
|
||||
|
||||
// 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([
|
||||
mockFindMatchingTemplates.mockReturnValueOnce([
|
||||
{
|
||||
template: {
|
||||
id: 'misc',
|
||||
@@ -188,6 +179,12 @@ describe('POST /api/transactions/[id]/describe', () => {
|
||||
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,
|
||||
},
|
||||
|
||||
@@ -3,73 +3,13 @@ import { NextResponse } from 'next/server'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { DescribeTransactionSchema } from '@/lib/api/schemas'
|
||||
import { extensionRegistry } from '@/lib/extensions/registry'
|
||||
import { findMatchingTemplates, type TemplateMatch } from '@/lib/bookkeeping/booking-templates'
|
||||
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, VatTreatment } from '@/types'
|
||||
import type { Extension } from '@/lib/extensions/types'
|
||||
|
||||
interface DescriptionAnalysisInput {
|
||||
description: string
|
||||
transactionAmount: number
|
||||
transactionDate: string
|
||||
transactionDescription: string
|
||||
merchantName: string | null
|
||||
currency: string
|
||||
entityType: EntityType
|
||||
}
|
||||
|
||||
interface DescriptionAnalysisResult {
|
||||
debitAccount: string
|
||||
creditAccount: string
|
||||
vatTreatment: VatTreatment | null
|
||||
category: string
|
||||
confidence: number
|
||||
reasoning: string
|
||||
warnings: string[]
|
||||
templateId: string | null
|
||||
}
|
||||
import type { Transaction, EntityType } from '@/types'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
async function getTemplateMatches(
|
||||
aiExt: Extension | undefined,
|
||||
transaction: Transaction,
|
||||
entityType: EntityType,
|
||||
description: string
|
||||
): Promise<TemplateMatch[]> {
|
||||
if (aiExt?.services?.findSimilarTemplates) {
|
||||
return aiExt.services.findSimilarTemplates(transaction, entityType, 10, description)
|
||||
}
|
||||
return findMatchingTemplates(transaction, entityType)
|
||||
}
|
||||
|
||||
async function getAiAnalysis(
|
||||
aiExt: Extension | undefined,
|
||||
transaction: Transaction,
|
||||
entityType: EntityType,
|
||||
description: string
|
||||
): Promise<DescriptionAnalysisResult | null> {
|
||||
if (!aiExt?.services?.analyzeDescription) return null
|
||||
|
||||
try {
|
||||
const input: DescriptionAnalysisInput = {
|
||||
description,
|
||||
transactionAmount: transaction.amount,
|
||||
transactionDate: transaction.date,
|
||||
transactionDescription: transaction.description,
|
||||
merchantName: transaction.merchant_name,
|
||||
currency: transaction.currency,
|
||||
entityType,
|
||||
}
|
||||
return await aiExt.services.analyzeDescription(input)
|
||||
} catch (error) {
|
||||
console.error('[describe] AI analysis failed, continuing with templates only:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
@@ -110,13 +50,10 @@ export async function POST(
|
||||
|
||||
const entityType: EntityType = (settings?.entity_type as EntityType) || 'enskild_firma'
|
||||
|
||||
const aiExt = extensionRegistry.get('ai-categorization')
|
||||
|
||||
// Run template matching, counterparty lookup, and AI analysis in parallel
|
||||
const [templates, counterpartyMatch, aiSuggestion] = await Promise.all([
|
||||
getTemplateMatches(aiExt, transaction as Transaction, entityType, description),
|
||||
// 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),
|
||||
getAiAnalysis(aiExt, transaction as Transaction, entityType, description),
|
||||
])
|
||||
|
||||
// Build counterparty suggestion if matched
|
||||
@@ -147,8 +84,7 @@ export async function POST(
|
||||
}
|
||||
}
|
||||
|
||||
// AI or counterparty match rescues weak templates
|
||||
const needsMoreDetail = (aiSuggestion || counterpartySuggestion)
|
||||
const needsMoreDetail = counterpartySuggestion
|
||||
? false
|
||||
: templates.length === 0 || templates[0].confidence < 0.55
|
||||
|
||||
@@ -185,16 +121,7 @@ export async function POST(
|
||||
risk_level: m.template.risk_level,
|
||||
})),
|
||||
counterparty_match: counterpartySuggestion,
|
||||
ai_suggestion: aiSuggestion ? {
|
||||
debit_account: aiSuggestion.debitAccount,
|
||||
credit_account: aiSuggestion.creditAccount,
|
||||
vat_treatment: aiSuggestion.vatTreatment,
|
||||
category: aiSuggestion.category,
|
||||
confidence: aiSuggestion.confidence,
|
||||
reasoning: aiSuggestion.reasoning,
|
||||
warnings: aiSuggestion.warnings,
|
||||
template_id: aiSuggestion.templateId,
|
||||
} : null,
|
||||
ai_suggestion: null,
|
||||
needs_more_detail: needsMoreDetail,
|
||||
user_description: description,
|
||||
batch_candidate_count: batchCandidateCount,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getSuggestedCategories, mergeAiSuggestions, getSuggestedTemplates, type SuggestedCategory, type SuggestedTemplate } from '@/lib/transactions/category-suggestions'
|
||||
import { getSuggestedCategories, getSuggestedTemplates, type SuggestedCategory, type SuggestedTemplate } from '@/lib/transactions/category-suggestions'
|
||||
import { findCounterpartyTemplatesBatch, formatCounterpartyName, toCounterpartyTemplateId } from '@/lib/bookkeeping/counterparty-templates'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import type { Transaction, EntityType } from '@/types'
|
||||
@@ -65,30 +65,6 @@ export async function POST(request: Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch pre-computed AI suggestions for these transactions
|
||||
const aiKeys = ids.map((id: string) => `suggestion:${id}`)
|
||||
const { data: aiRecords } = await supabase
|
||||
.from('extension_data')
|
||||
.select('key, value')
|
||||
.eq('company_id', companyId)
|
||||
.eq('extension_id', 'ai-categorization')
|
||||
.in('key', aiKeys)
|
||||
|
||||
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:', '')
|
||||
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]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch entity type for template matching
|
||||
const { data: settings } = await supabase
|
||||
.from('company_settings')
|
||||
@@ -105,19 +81,11 @@ export async function POST(request: Request) {
|
||||
const template_suggestions: Record<string, SuggestedTemplate[]> = {}
|
||||
|
||||
for (const tx of transactions) {
|
||||
let result = getSuggestedCategories(
|
||||
suggestions[tx.id] = getSuggestedCategories(
|
||||
tx as Transaction,
|
||||
mappingRules || [],
|
||||
categoryHistory
|
||||
)
|
||||
|
||||
// Merge pre-computed AI suggestions if available
|
||||
const aiSuggestions = aiSuggestionsMap[tx.id]
|
||||
if (aiSuggestions && aiSuggestions.length > 0) {
|
||||
result = mergeAiSuggestions(result, aiSuggestions, tx.amount)
|
||||
}
|
||||
|
||||
suggestions[tx.id] = result
|
||||
template_suggestions[tx.id] = await getSuggestedTemplates(tx as Transaction, entityType, mappingRules || undefined)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user