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
@@ -1,104 +1,21 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { describe, it, expect } from 'vitest'
import { makeTransaction } from '@/tests/helpers'
import { extensionRegistry } from '@/lib/extensions/registry'
// Mock the extension registry
vi.mock('@/lib/extensions/registry', () => ({
extensionRegistry: {
get: vi.fn(),
},
}))
import { findSimilarTemplates } from '../template-embeddings'
describe('template-embeddings facade', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('findSimilarTemplates', () => {
it('delegates to ai-categorization extension service when available', async () => {
const mockResults = [{ template: { id: 'test' }, confidence: 0.9 }]
const mockFindSimilar = vi.fn().mockResolvedValue(mockResults)
vi.mocked(extensionRegistry.get).mockReturnValue({
id: 'ai-categorization',
name: 'AI',
version: '1.0.0',
services: { findSimilarTemplates: mockFindSimilar },
})
const { findSimilarTemplates } = await import('../template-embeddings')
const tx = makeTransaction({ description: 'SPOTIFY', amount: -109 })
const results = await findSimilarTemplates(tx, 'enskild_firma')
expect(mockFindSimilar).toHaveBeenCalledWith(tx, 'enskild_firma', undefined, undefined)
expect(results).toEqual(mockResults)
})
it('falls back to keyword matching when extension not loaded', async () => {
vi.mocked(extensionRegistry.get).mockReturnValue(undefined)
const { findSimilarTemplates } = await import('../template-embeddings')
it('returns keyword-based matches', async () => {
const tx = makeTransaction({ description: 'SPOTIFY', amount: -109 })
const results = await findSimilarTemplates(tx)
// Should return keyword-based matches (may be empty for generic text)
expect(Array.isArray(results)).toBe(true)
})
it('accepts entityType parameter', async () => {
const tx = makeTransaction({ description: 'SPOTIFY', amount: -109 })
const results = await findSimilarTemplates(tx, 'enskild_firma')
expect(Array.isArray(results)).toBe(true)
})
})
describe('seedAllTemplateEmbeddings', () => {
it('delegates to extension service when available', async () => {
const mockSeed = vi.fn().mockResolvedValue({ seeded: 48, errors: [] })
vi.mocked(extensionRegistry.get).mockReturnValue({
id: 'ai-categorization',
name: 'AI',
version: '1.0.0',
services: { seedAllTemplateEmbeddings: mockSeed },
})
const { seedAllTemplateEmbeddings } = await import('../template-embeddings')
const result = await seedAllTemplateEmbeddings()
expect(mockSeed).toHaveBeenCalled()
expect(result).toEqual({ seeded: 48, errors: [] })
})
it('returns error when extension not loaded', async () => {
vi.mocked(extensionRegistry.get).mockReturnValue(undefined)
const { seedAllTemplateEmbeddings } = await import('../template-embeddings')
const result = await seedAllTemplateEmbeddings()
expect(result.seeded).toBe(0)
expect(result.errors).toHaveLength(1)
})
})
describe('getSchemaVersion', () => {
it('delegates to extension service when available', async () => {
const mockVersion = vi.fn().mockResolvedValue('abc123def456')
vi.mocked(extensionRegistry.get).mockReturnValue({
id: 'ai-categorization',
name: 'AI',
version: '1.0.0',
services: { getSchemaVersion: mockVersion },
})
const { getSchemaVersion } = await import('../template-embeddings')
const result = await getSchemaVersion()
expect(result).toBe('abc123def456')
})
it('returns "none" when extension not loaded', async () => {
vi.mocked(extensionRegistry.get).mockReturnValue(undefined)
const { getSchemaVersion } = await import('../template-embeddings')
const result = await getSchemaVersion()
expect(result).toBe('none')
})
})
})
@@ -0,0 +1,131 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { JournalEntryStatus } from '@/types'
// Mock event bus
vi.mock('@/lib/events', () => ({
eventBus: { emit: vi.fn().mockResolvedValue([]) },
}))
import { commitEntry, getNextVoucherNumber } from '../engine'
describe('voucher number atomicity', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('getNextVoucherNumber returns incrementing numbers from RPC', async () => {
let callCount = 0
const supabase = {
rpc: vi.fn().mockImplementation(() => {
callCount++
return Promise.resolve({ data: callCount, error: null })
}),
}
const n1 = await getNextVoucherNumber(supabase as never, 'co-1', 'fp-1', 'A')
const n2 = await getNextVoucherNumber(supabase as never, 'co-1', 'fp-1', 'A')
const n3 = await getNextVoucherNumber(supabase as never, 'co-1', 'fp-1', 'A')
expect(n1).toBe(1)
expect(n2).toBe(2)
expect(n3).toBe(3)
expect(supabase.rpc).toHaveBeenCalledTimes(3)
})
it('getNextVoucherNumber throws on RPC error', async () => {
const supabase = {
rpc: vi.fn().mockResolvedValue({ data: null, error: { message: 'connection lost' } }),
}
await expect(
getNextVoucherNumber(supabase as never, 'co-1', 'fp-1', 'A')
).rejects.toThrow('Failed to get next voucher number: connection lost')
})
/**
* commitEntry uses the atomic commit_journal_entry RPC which increments the
* voucher sequence and updates the entry status in one transaction.
* If the RPC fails (e.g., balance trigger rejection), the sequence increment
* rolls back — no burned number, no gap.
*/
it('commitEntry RPC failure does not burn a sequence number', async () => {
const supabase = {
from: vi.fn(),
rpc: vi.fn().mockResolvedValue({
data: null,
error: { message: 'Journal entry is not balanced: debit=1000 credit=500' },
}),
}
await expect(
commitEntry(supabase as never, 'co-1', 'user-1', 'entry-1')
).rejects.toThrow('Failed to commit journal entry: Journal entry is not balanced')
// The atomic RPC was called — it failed, rolling back both the
// sequence increment and the status update. No burned number.
expect(supabase.rpc).toHaveBeenCalledWith('commit_journal_entry', {
p_company_id: 'co-1',
p_entry_id: 'entry-1',
})
// from() was never called — the RPC handles everything atomically
expect(supabase.from).not.toHaveBeenCalled()
})
it('commitEntry succeeds via atomic RPC and returns posted entry', async () => {
const postedEntry = {
id: 'entry-1',
company_id: 'co-1',
fiscal_period_id: 'fp-1',
voucher_series: 'A',
voucher_number: 3,
status: 'posted' as JournalEntryStatus,
lines: [],
}
const supabase = {
from: vi.fn().mockImplementation(() => ({
select: vi.fn().mockReturnValue({
eq: vi.fn().mockReturnValue({
single: vi.fn().mockResolvedValue({ data: postedEntry, error: null }),
}),
}),
})),
// Atomic RPC returns the assigned voucher number
rpc: vi.fn().mockResolvedValue({ data: [{ voucher_number: 3 }], error: null }),
}
const result = await commitEntry(supabase as never, 'co-1', 'user-1', 'entry-1')
expect(result.voucher_number).toBe(3)
expect(result.status).toBe('posted')
expect(supabase.rpc).toHaveBeenCalledWith('commit_journal_entry', {
p_company_id: 'co-1',
p_entry_id: 'entry-1',
})
// from() called once to fetch the complete entry with lines
expect(supabase.from).toHaveBeenCalledWith('journal_entries')
})
/**
* getNextVoucherNumber is still used by reverseEntry and storno-service.
* Those flows INSERT a new entry (not UPDATE a draft), so the atomic
* commit_journal_entry RPC doesn't apply. Burned numbers can still occur
* in reversal/correction flows if the INSERT fails after the counter
* increments. This is documented and expected.
*/
it('getNextVoucherNumber remains available for reversal/storno flows', async () => {
const supabase = {
rpc: vi.fn().mockResolvedValue({ data: 7, error: null }),
}
const num = await getNextVoucherNumber(supabase as never, 'co-1', 'fp-1', 'B')
expect(num).toBe(7)
expect(supabase.rpc).toHaveBeenCalledWith('next_voucher_number', {
p_company_id: 'co-1',
p_fiscal_period_id: 'fp-1',
p_series: 'B',
})
})
})
+11 -33
View File
@@ -209,7 +209,9 @@ export async function createDraftEntry(
/**
* Commit a draft entry: assigns voucher number and transitions to 'posted'
* Triggers balance validation and sets committed_at via DB triggers
* Uses the atomic commit_journal_entry RPC so the voucher number increment
* and status update happen in one transaction. If the balance trigger rejects
* the entry, the sequence increment rolls back — no burned numbers.
*/
export async function commitEntry(
supabase: SupabaseClient,
@@ -218,39 +220,15 @@ export async function commitEntry(
entryId: string
): Promise<JournalEntry> {
// Fetch the draft entry
const { data: entry, error: fetchError } = await supabase
.from('journal_entries')
.select('*')
.eq('id', entryId)
.eq('company_id', companyId)
.eq('status', 'draft')
.single()
// Atomic: increment voucher sequence + update status in one transaction.
// Rolls back the sequence if the balance trigger or any constraint fails.
const { data: rpcResult, error: commitError } = await supabase.rpc('commit_journal_entry', {
p_company_id: companyId,
p_entry_id: entryId,
})
if (fetchError || !entry) {
throw new Error('Draft journal entry not found')
}
// Assign voucher number
const voucherNumber = await getNextVoucherNumber(
supabase,
companyId,
entry.fiscal_period_id,
entry.voucher_series || 'A'
)
// Update to posted with voucher number
// DB triggers will: validate balance, set committed_at, write audit log
const { error: postError } = await supabase
.from('journal_entries')
.update({
voucher_number: voucherNumber,
status: 'posted',
})
.eq('id', entryId)
if (postError) {
throw new Error(`Failed to commit journal entry: ${postError.message}`)
if (commitError) {
throw new Error(`Failed to commit journal entry: ${commitError.message}`)
}
// Fetch complete posted entry with lines
+2 -44
View File
@@ -1,16 +1,9 @@
/**
* Template Embeddings — Core Facade
*
* Delegates to the ai-categorization extension's service when available.
* Returns empty/no-op results when the extension is not loaded,
* allowing core to compile without @langchain/openai.
*
* Pure helper functions (buildEmbeddingText, buildTransactionQueryText,
* getSchemaVersion) are re-exported from the extension for tests that
* depend on them.
* Provides template matching via keyword-based search.
*/
import { extensionRegistry } from '@/lib/extensions/registry'
import {
findMatchingTemplates,
type TemplateMatch,
@@ -18,46 +11,11 @@ import {
import type { Transaction, EntityType } from '@/types'
/**
* Find similar templates via embedding search.
* Falls back to keyword matching when the AI extension is not loaded.
* Find similar templates via keyword matching.
*/
export async function findSimilarTemplates(
transaction: Transaction,
entityType?: EntityType,
matchCount?: number,
userDescription?: string
): Promise<TemplateMatch[]> {
const aiExt = extensionRegistry.get('ai-categorization')
if (aiExt?.services?.findSimilarTemplates) {
return aiExt.services.findSimilarTemplates(transaction, entityType, matchCount, userDescription)
}
// Graceful fallback: keyword-based matching (no AI deps)
return findMatchingTemplates(transaction, entityType)
}
/**
* Seed all template embeddings in the database.
* No-op when the AI extension is not loaded.
*/
export async function seedAllTemplateEmbeddings(): Promise<{
seeded: number
errors: string[]
}> {
const aiExt = extensionRegistry.get('ai-categorization')
if (aiExt?.services?.seedAllTemplateEmbeddings) {
return aiExt.services.seedAllTemplateEmbeddings()
}
return { seeded: 0, errors: ['ai-categorization extension not loaded'] }
}
/**
* Get the current schema version hash.
* Returns 'none' when the AI extension is not loaded.
*/
export async function getSchemaVersion(): Promise<string> {
const aiExt = extensionRegistry.get('ai-categorization')
if (aiExt?.services?.getSchemaVersion) {
return aiExt.services.getSchemaVersion()
}
return 'none'
}