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',
})
})
})