refactor: consolidate AI analyzers into shared lib/ai module and add journal entry reversal columns

Extract shared vision/document analysis logic into lib/ai (vision-client, document-analyzer,
image preprocessing, validation helpers), simplifying invoice-analyzer, receipt-analyzer, and
document classifier. Add migration 046 for journal entry reversal/correction link columns
(reversed_by_id, reverses_id, correction_of_id) required by storno service. Update extension
components and shared UI.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-02-26 17:14:44 +01:00
co-authored by Claude Opus 4.6
parent 6d647b956c
commit 3f255ea201
42 changed files with 2090 additions and 1127 deletions
+53 -172
View File
@@ -3,42 +3,29 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
// Mock server-only
vi.mock('server-only', () => ({}))
// Mock Anthropic SDK - vi.hoisted ensures the variable is available before vi.mock hoisting
const { mockCreate } = vi.hoisted(() => {
const mockCreate = vi.fn()
return { mockCreate }
})
// Mock the core document-analyzer module
const { mockClassify } = vi.hoisted(() => ({
mockClassify: vi.fn(),
}))
vi.mock('@anthropic-ai/sdk', () => {
return {
default: class MockAnthropic {
messages = { create: mockCreate }
},
}
})
vi.mock('@/lib/ai/document-analyzer', () => ({
classifyDocument: mockClassify,
}))
import { classifyDocument } from '../classifier'
function makeResponse(json: Record<string, unknown>) {
return {
content: [{ type: 'text', text: JSON.stringify(json) }],
}
}
describe('classifyDocument', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('classifies a supplier invoice', async () => {
mockCreate.mockResolvedValueOnce(
makeResponse({
type: 'supplier_invoice',
confidence: 0.95,
reasoning: 'Contains invoice number, bankgiro, and supplier details',
isReverseCharge: false,
})
)
mockClassify.mockResolvedValueOnce({
type: 'supplier_invoice',
confidence: 0.95,
reasoning: 'Contains invoice number, bankgiro, and supplier details',
isReverseCharge: false,
})
const result = await classifyDocument('base64data', 'application/pdf')
@@ -48,13 +35,11 @@ describe('classifyDocument', () => {
})
it('classifies a receipt', async () => {
mockCreate.mockResolvedValueOnce(
makeResponse({
type: 'receipt',
confidence: 0.92,
reasoning: 'Store receipt with line items and total',
})
)
mockClassify.mockResolvedValueOnce({
type: 'receipt',
confidence: 0.92,
reasoning: 'Store receipt with line items and total',
})
const result = await classifyDocument('base64data', 'image/jpeg')
@@ -64,13 +49,11 @@ describe('classifyDocument', () => {
})
it('classifies a government letter', async () => {
mockCreate.mockResolvedValueOnce(
makeResponse({
type: 'government_letter',
confidence: 0.88,
reasoning: 'Letter from Skatteverket',
})
)
mockClassify.mockResolvedValueOnce({
type: 'government_letter',
confidence: 0.88,
reasoning: 'Letter from Skatteverket',
})
const result = await classifyDocument('base64data', 'application/pdf')
@@ -80,13 +63,11 @@ describe('classifyDocument', () => {
})
it('classifies unknown documents', async () => {
mockCreate.mockResolvedValueOnce(
makeResponse({
type: 'unknown',
confidence: 0.5,
reasoning: 'Cannot determine document type',
})
)
mockClassify.mockResolvedValueOnce({
type: 'unknown',
confidence: 0.5,
reasoning: 'Cannot determine document type',
})
const result = await classifyDocument('base64data', 'image/png')
@@ -95,14 +76,12 @@ describe('classifyDocument', () => {
})
it('detects reverse charge on EU invoices', async () => {
mockCreate.mockResolvedValueOnce(
makeResponse({
type: 'supplier_invoice',
confidence: 0.93,
reasoning: 'EU invoice with reverse charge',
isReverseCharge: true,
})
)
mockClassify.mockResolvedValueOnce({
type: 'supplier_invoice',
confidence: 0.93,
reasoning: 'EU invoice with reverse charge',
isReverseCharge: true,
})
const result = await classifyDocument('base64data', 'application/pdf')
@@ -110,129 +89,31 @@ describe('classifyDocument', () => {
expect(result.isReverseCharge).toBe(true)
})
it('retries on API error', async () => {
mockCreate
.mockRejectedValueOnce(new Error('API timeout'))
.mockResolvedValueOnce(
makeResponse({
type: 'receipt',
confidence: 0.9,
reasoning: 'Receipt',
})
)
const result = await classifyDocument('base64data', 'image/jpeg')
expect(result.type).toBe('receipt')
expect(mockCreate).toHaveBeenCalledTimes(2)
})
it('throws on JSON parse error without retry', async () => {
mockCreate.mockResolvedValueOnce({
content: [{ type: 'text', text: 'not valid json' }],
it('delegates to core classify', async () => {
mockClassify.mockResolvedValueOnce({
type: 'receipt',
confidence: 0.9,
reasoning: 'Receipt',
})
await expect(classifyDocument('base64data', 'image/jpeg')).rejects.toThrow(
'Failed to parse AI response'
)
expect(mockCreate).toHaveBeenCalledTimes(1)
await classifyDocument('base64data', 'image/jpeg')
expect(mockClassify).toHaveBeenCalledWith('base64data', 'image/jpeg')
})
it('throws on unsupported MIME type', async () => {
it('propagates errors from core', async () => {
mockClassify.mockRejectedValueOnce(new Error('Vision API call failed after 3 attempts'))
await expect(classifyDocument('base64data', 'image/jpeg')).rejects.toThrow(
'Vision API call failed after 3 attempts'
)
})
it('propagates unsupported MIME type errors', async () => {
mockClassify.mockRejectedValueOnce(new Error('Unsupported file type: text/plain'))
await expect(classifyDocument('base64data', 'text/plain')).rejects.toThrow(
'Unsupported file type: text/plain'
)
expect(mockCreate).not.toHaveBeenCalled()
})
it('falls back to unknown for invalid type values', async () => {
mockCreate.mockResolvedValueOnce(
makeResponse({
type: 'invalid_type',
confidence: 0.8,
reasoning: 'Test',
})
)
const result = await classifyDocument('base64data', 'image/jpeg')
expect(result.type).toBe('unknown')
})
it('handles PDF content blocks correctly', async () => {
mockCreate.mockResolvedValueOnce(
makeResponse({
type: 'supplier_invoice',
confidence: 0.95,
reasoning: 'PDF invoice',
isReverseCharge: false,
})
)
await classifyDocument('base64data', 'application/pdf')
expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({
messages: [
{
role: 'user',
content: expect.arrayContaining([
expect.objectContaining({ type: 'document' }),
]),
},
],
})
)
})
it('handles image content blocks correctly', async () => {
mockCreate.mockResolvedValueOnce(
makeResponse({
type: 'receipt',
confidence: 0.9,
reasoning: 'Image receipt',
})
)
await classifyDocument('base64data', 'image/png')
expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({
messages: [
{
role: 'user',
content: expect.arrayContaining([
expect.objectContaining({ type: 'image' }),
]),
},
],
})
)
})
it('strips markdown code blocks from response', async () => {
mockCreate.mockResolvedValueOnce({
content: [
{
type: 'text',
text: '```json\n{"type":"receipt","confidence":0.9,"reasoning":"Test"}\n```',
},
],
})
const result = await classifyDocument('base64data', 'image/jpeg')
expect(result.type).toBe('receipt')
})
it('throws after max retries', async () => {
mockCreate
.mockRejectedValueOnce(new Error('API error 1'))
.mockRejectedValueOnce(new Error('API error 2'))
.mockRejectedValueOnce(new Error('API error 3'))
await expect(classifyDocument('base64data', 'image/jpeg')).rejects.toThrow(
'Document classification failed after 3 attempts'
)
expect(mockCreate).toHaveBeenCalledTimes(3)
})
})