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
@@ -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
@@ -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
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user