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:
Jakob Wennberg
2026-04-01 17:08:00 +02:00
committed by GitHub
co-authored by Claude Opus 4.6
parent e89f2c402d
commit d0b3f21bde
172 changed files with 3621 additions and 20538 deletions
@@ -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,
},
+7 -80
View File
@@ -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)
}