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