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,378 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
// Mock server-only
|
||||
vi.mock('server-only', () => ({}))
|
||||
|
||||
// Mock vision client
|
||||
const { mockCallVision } = vi.hoisted(() => ({
|
||||
mockCallVision: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../vision-client', () => ({
|
||||
callVision: mockCallVision,
|
||||
}))
|
||||
|
||||
// Mock template prompt
|
||||
vi.mock('@/lib/bookkeeping/template-prompt', () => ({
|
||||
buildTemplatePromptSection: () => 'TEMPLATE_SECTION',
|
||||
}))
|
||||
|
||||
import {
|
||||
analyzeDocument,
|
||||
extractReceipt,
|
||||
extractInvoice,
|
||||
classifyDocument,
|
||||
validateExtractionConsistency,
|
||||
} from '../document-analyzer'
|
||||
|
||||
const mockReceiptResponse = {
|
||||
merchant: { name: 'ICA Maxi', orgNumber: '5561234567', vatNumber: null, isForeign: false },
|
||||
receipt: { date: '2024-06-15', time: '14:30', currency: 'SEK' },
|
||||
lineItems: [
|
||||
{ description: 'Mjölk', quantity: 1, unitPrice: 15, lineTotal: 15, vatRate: 12, suggestedCategory: 'other' },
|
||||
],
|
||||
totals: { subtotal: 13.39, vatAmount: 1.61, total: 15 },
|
||||
flags: { isRestaurant: false, isSystembolaget: false, isForeignMerchant: false },
|
||||
confidence: 0.92,
|
||||
}
|
||||
|
||||
const mockInvoiceResponse = {
|
||||
supplier: { name: 'Telenor AB', orgNumber: '5561234567', vatNumber: 'SE556123456701', address: 'Stockholm', bankgiro: '123-4567', plusgiro: null },
|
||||
invoice: { invoiceNumber: 'F-2024-001', invoiceDate: '2024-06-01', dueDate: '2024-06-30', paymentReference: '1234567890', currency: 'SEK' },
|
||||
lineItems: [
|
||||
{ description: 'Mobilabonnemang', quantity: 1, unitPrice: 299, lineTotal: 299, vatRate: 25, accountSuggestion: '6200' },
|
||||
],
|
||||
totals: { subtotal: 239.2, vatAmount: 59.8, total: 299 },
|
||||
vatBreakdown: [{ rate: 25, base: 239.2, amount: 59.8 }],
|
||||
confidence: 0.95,
|
||||
}
|
||||
|
||||
describe('classifyDocument', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('classifies a supplier invoice', async () => {
|
||||
mockCallVision.mockResolvedValueOnce({
|
||||
type: 'supplier_invoice',
|
||||
confidence: 0.95,
|
||||
reasoning: 'Contains invoice number and bankgiro',
|
||||
isReverseCharge: false,
|
||||
})
|
||||
|
||||
const result = await classifyDocument('base64data', 'application/pdf')
|
||||
|
||||
expect(result.type).toBe('supplier_invoice')
|
||||
expect(result.confidence).toBe(0.95)
|
||||
expect(result.isReverseCharge).toBe(false)
|
||||
expect(mockCallVision).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ maxTokens: 1024 })
|
||||
)
|
||||
})
|
||||
|
||||
it('classifies a receipt', async () => {
|
||||
mockCallVision.mockResolvedValueOnce({
|
||||
type: 'receipt',
|
||||
confidence: 0.92,
|
||||
reasoning: 'Store receipt',
|
||||
})
|
||||
|
||||
const result = await classifyDocument('base64data', 'image/jpeg')
|
||||
|
||||
expect(result.type).toBe('receipt')
|
||||
expect(result.isReverseCharge).toBeUndefined()
|
||||
})
|
||||
|
||||
it('falls back to unknown for invalid type', async () => {
|
||||
mockCallVision.mockResolvedValueOnce({
|
||||
type: 'invalid_type',
|
||||
confidence: 0.8,
|
||||
reasoning: 'Test',
|
||||
})
|
||||
|
||||
const result = await classifyDocument('base64data', 'image/jpeg')
|
||||
|
||||
expect(result.type).toBe('unknown')
|
||||
})
|
||||
})
|
||||
|
||||
describe('extractReceipt', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('extracts receipt data', async () => {
|
||||
mockCallVision.mockResolvedValueOnce(mockReceiptResponse)
|
||||
|
||||
const result = await extractReceipt('base64data', 'image/jpeg')
|
||||
|
||||
expect(result.merchant.name).toBe('ICA Maxi')
|
||||
expect(result.merchant.orgNumber).toBe('556123-4567')
|
||||
expect(result.receipt.date).toBe('2024-06-15')
|
||||
expect(result.lineItems).toHaveLength(1)
|
||||
expect(result.totals.total).toBe(15)
|
||||
expect(result.confidence).toBe(0.92)
|
||||
})
|
||||
|
||||
it('retries with correction prompt on consistency failure', async () => {
|
||||
const badResponse = {
|
||||
...mockReceiptResponse,
|
||||
lineItems: [
|
||||
{ description: 'Item', quantity: 1, unitPrice: 100, lineTotal: 100, vatRate: 25 },
|
||||
],
|
||||
totals: { subtotal: 80, vatAmount: 20, total: 200 }, // 100 != 200 → inconsistent
|
||||
confidence: 0.9,
|
||||
}
|
||||
|
||||
const goodResponse = {
|
||||
...mockReceiptResponse,
|
||||
lineItems: [
|
||||
{ description: 'Item', quantity: 1, unitPrice: 200, lineTotal: 200, vatRate: 25 },
|
||||
],
|
||||
totals: { subtotal: 160, vatAmount: 40, total: 200 },
|
||||
confidence: 0.85,
|
||||
}
|
||||
|
||||
mockCallVision
|
||||
.mockResolvedValueOnce(badResponse)
|
||||
.mockResolvedValueOnce(goodResponse)
|
||||
|
||||
const result = await extractReceipt('base64data', 'image/jpeg')
|
||||
|
||||
expect(mockCallVision).toHaveBeenCalledTimes(2)
|
||||
expect(result.totals.total).toBe(200)
|
||||
expect(result.lineItems[0].lineTotal).toBe(200)
|
||||
})
|
||||
|
||||
it('returns original with reduced confidence if retry also fails', async () => {
|
||||
const badResponse = {
|
||||
...mockReceiptResponse,
|
||||
lineItems: [
|
||||
{ description: 'Item', quantity: 1, unitPrice: 100, lineTotal: 100, vatRate: 25 },
|
||||
],
|
||||
totals: { subtotal: 80, vatAmount: 20, total: 200 },
|
||||
confidence: 0.9,
|
||||
}
|
||||
|
||||
mockCallVision
|
||||
.mockResolvedValueOnce(badResponse)
|
||||
.mockRejectedValueOnce(new Error('Retry failed'))
|
||||
|
||||
const result = await extractReceipt('base64data', 'image/jpeg')
|
||||
|
||||
expect(result.confidence).toBeCloseTo(0.63, 1) // 0.9 * 0.7
|
||||
})
|
||||
})
|
||||
|
||||
describe('extractInvoice', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('extracts invoice data', async () => {
|
||||
mockCallVision.mockResolvedValueOnce(mockInvoiceResponse)
|
||||
|
||||
const result = await extractInvoice('base64data', 'application/pdf')
|
||||
|
||||
expect(result.supplier.name).toBe('Telenor AB')
|
||||
expect(result.supplier.orgNumber).toBe('556123-4567')
|
||||
expect(result.invoice.invoiceNumber).toBe('F-2024-001')
|
||||
expect(result.lineItems).toHaveLength(1)
|
||||
expect(result.lineItems[0].accountSuggestion).toBe('6200')
|
||||
expect(result.vatBreakdown).toHaveLength(1)
|
||||
expect(result.confidence).toBe(0.95)
|
||||
})
|
||||
})
|
||||
|
||||
describe('analyzeDocument', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('classifies and extracts receipt in one call', async () => {
|
||||
mockCallVision.mockResolvedValueOnce({
|
||||
classification: { type: 'receipt', confidence: 0.93, reasoning: 'Receipt' },
|
||||
receipt: mockReceiptResponse,
|
||||
invoice: null,
|
||||
})
|
||||
|
||||
const result = await analyzeDocument('base64data', 'image/jpeg')
|
||||
|
||||
expect(result.classification.type).toBe('receipt')
|
||||
expect(result.receipt).toBeDefined()
|
||||
expect(result.receipt!.merchant.name).toBe('ICA Maxi')
|
||||
expect(result.invoice).toBeUndefined()
|
||||
expect(mockCallVision).toHaveBeenCalledTimes(1) // Single call
|
||||
})
|
||||
|
||||
it('classifies and extracts invoice in one call', async () => {
|
||||
mockCallVision.mockResolvedValueOnce({
|
||||
classification: { type: 'supplier_invoice', confidence: 0.95, reasoning: 'Invoice', isReverseCharge: false },
|
||||
receipt: null,
|
||||
invoice: mockInvoiceResponse,
|
||||
})
|
||||
|
||||
const result = await analyzeDocument('base64data', 'application/pdf')
|
||||
|
||||
expect(result.classification.type).toBe('supplier_invoice')
|
||||
expect(result.invoice).toBeDefined()
|
||||
expect(result.invoice!.supplier.name).toBe('Telenor AB')
|
||||
expect(result.receipt).toBeUndefined()
|
||||
})
|
||||
|
||||
it('falls back to extractReceipt when type=receipt but receipt data missing', async () => {
|
||||
mockCallVision
|
||||
.mockResolvedValueOnce({
|
||||
classification: { type: 'receipt', confidence: 0.8, reasoning: 'Receipt' },
|
||||
receipt: null,
|
||||
invoice: null,
|
||||
})
|
||||
.mockResolvedValueOnce(mockReceiptResponse) // fallback extractReceipt call
|
||||
|
||||
const result = await analyzeDocument('base64data', 'image/jpeg')
|
||||
|
||||
expect(result.classification.type).toBe('receipt')
|
||||
expect(result.receipt).toBeDefined()
|
||||
expect(mockCallVision).toHaveBeenCalledTimes(2) // unified + fallback
|
||||
})
|
||||
|
||||
it('falls back to extractInvoice when type=supplier_invoice but invoice data missing', async () => {
|
||||
mockCallVision
|
||||
.mockResolvedValueOnce({
|
||||
classification: { type: 'supplier_invoice', confidence: 0.9, reasoning: 'Invoice' },
|
||||
receipt: null,
|
||||
invoice: null,
|
||||
})
|
||||
.mockResolvedValueOnce(mockInvoiceResponse)
|
||||
|
||||
const result = await analyzeDocument('base64data', 'application/pdf')
|
||||
|
||||
expect(result.classification.type).toBe('supplier_invoice')
|
||||
expect(result.invoice).toBeDefined()
|
||||
expect(mockCallVision).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('returns classification only for government_letter', async () => {
|
||||
mockCallVision.mockResolvedValueOnce({
|
||||
classification: { type: 'government_letter', confidence: 0.88, reasoning: 'From Skatteverket' },
|
||||
})
|
||||
|
||||
const result = await analyzeDocument('base64data', 'application/pdf')
|
||||
|
||||
expect(result.classification.type).toBe('government_letter')
|
||||
expect(result.receipt).toBeUndefined()
|
||||
expect(result.invoice).toBeUndefined()
|
||||
expect(mockCallVision).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('returns classification only for unknown', async () => {
|
||||
mockCallVision.mockResolvedValueOnce({
|
||||
classification: { type: 'unknown', confidence: 0.5, reasoning: 'Cannot determine' },
|
||||
})
|
||||
|
||||
const result = await analyzeDocument('base64data', 'image/png')
|
||||
|
||||
expect(result.classification.type).toBe('unknown')
|
||||
expect(mockCallVision).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateExtractionConsistency', () => {
|
||||
it('passes for valid extraction', () => {
|
||||
const extraction = {
|
||||
...mockReceiptResponse,
|
||||
merchant: { ...mockReceiptResponse.merchant, orgNumber: '556123-4567' },
|
||||
} as unknown as import('@/types').ReceiptExtractionResult
|
||||
|
||||
const result = validateExtractionConsistency(extraction, 'receipt')
|
||||
|
||||
expect(result.valid).toBe(true)
|
||||
expect(result.issues).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('detects line item sum mismatch', () => {
|
||||
const extraction = {
|
||||
lineItems: [
|
||||
{ description: 'Item 1', lineTotal: 100, vatRate: 25 },
|
||||
],
|
||||
totals: { subtotal: 80, vatAmount: 20, total: 200 },
|
||||
confidence: 0.9,
|
||||
} as unknown as import('@/types').ReceiptExtractionResult
|
||||
|
||||
const result = validateExtractionConsistency(extraction, 'receipt')
|
||||
|
||||
expect(result.valid).toBe(false)
|
||||
expect(result.issues).toContainEqual(expect.stringContaining('Line items sum'))
|
||||
})
|
||||
|
||||
it('allows small rounding differences (±1 SEK)', () => {
|
||||
const extraction = {
|
||||
lineItems: [
|
||||
{ description: 'Item', lineTotal: 99.5, vatRate: 25 },
|
||||
],
|
||||
totals: { subtotal: 80, vatAmount: 20, total: 100 },
|
||||
confidence: 0.9,
|
||||
} as unknown as import('@/types').ReceiptExtractionResult
|
||||
|
||||
const result = validateExtractionConsistency(extraction, 'receipt')
|
||||
|
||||
expect(result.valid).toBe(true)
|
||||
})
|
||||
|
||||
it('detects invalid VAT rates', () => {
|
||||
const extraction = {
|
||||
lineItems: [
|
||||
{ description: 'Item', lineTotal: 100, vatRate: 18 },
|
||||
],
|
||||
totals: { subtotal: 85, vatAmount: 15, total: 100 },
|
||||
confidence: 0.9,
|
||||
} as unknown as import('@/types').ReceiptExtractionResult
|
||||
|
||||
const result = validateExtractionConsistency(extraction, 'receipt')
|
||||
|
||||
expect(result.valid).toBe(false)
|
||||
expect(result.issues).toContainEqual(expect.stringContaining('Invalid VAT rate 18'))
|
||||
})
|
||||
|
||||
it('detects empty descriptions', () => {
|
||||
const extraction = {
|
||||
lineItems: [
|
||||
{ description: '', lineTotal: 100, vatRate: 25 },
|
||||
],
|
||||
totals: { subtotal: 80, vatAmount: 20, total: 100 },
|
||||
confidence: 0.9,
|
||||
} as unknown as import('@/types').ReceiptExtractionResult
|
||||
|
||||
const result = validateExtractionConsistency(extraction, 'receipt')
|
||||
|
||||
expect(result.valid).toBe(false)
|
||||
expect(result.issues).toContainEqual(expect.stringContaining('empty description'))
|
||||
})
|
||||
|
||||
it('detects non-positive totals', () => {
|
||||
const extraction = {
|
||||
lineItems: [],
|
||||
totals: { subtotal: 0, vatAmount: 0, total: -5 },
|
||||
confidence: 0.9,
|
||||
} as unknown as import('@/types').ReceiptExtractionResult
|
||||
|
||||
const result = validateExtractionConsistency(extraction, 'receipt')
|
||||
|
||||
expect(result.valid).toBe(false)
|
||||
expect(result.issues).toContainEqual(expect.stringContaining('Total is -5'))
|
||||
})
|
||||
|
||||
it('passes when total is null (not extracted)', () => {
|
||||
const extraction = {
|
||||
lineItems: [
|
||||
{ description: 'Item', lineTotal: 100, vatRate: 25 },
|
||||
],
|
||||
totals: { subtotal: null, vatAmount: null, total: null },
|
||||
confidence: 0.5,
|
||||
} as unknown as import('@/types').ReceiptExtractionResult
|
||||
|
||||
const result = validateExtractionConsistency(extraction, 'receipt')
|
||||
|
||||
expect(result.valid).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,68 +0,0 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
|
||||
// Mock server-only
|
||||
vi.mock('server-only', () => ({}))
|
||||
|
||||
// Mock sharp - use vi.hoisted to avoid initialization order issues
|
||||
const { mockSharp, mockSharpChain, mockToBuffer } = vi.hoisted(() => {
|
||||
const mockToBuffer = vi.fn()
|
||||
const mockSharpChain = {
|
||||
grayscale: vi.fn().mockReturnThis(),
|
||||
normalize: vi.fn().mockReturnThis(),
|
||||
sharpen: vi.fn().mockReturnThis(),
|
||||
jpeg: vi.fn().mockReturnThis(),
|
||||
toBuffer: mockToBuffer,
|
||||
}
|
||||
const mockSharp = vi.fn().mockReturnValue(mockSharpChain)
|
||||
return { mockSharp, mockSharpChain, mockToBuffer }
|
||||
})
|
||||
|
||||
vi.mock('sharp', () => ({ default: mockSharp }))
|
||||
|
||||
import { preprocessImage } from '../preprocess-image'
|
||||
|
||||
describe('preprocessImage', () => {
|
||||
it('returns PDFs unchanged', async () => {
|
||||
const result = await preprocessImage('pdf-base64-data', 'application/pdf')
|
||||
|
||||
expect(result.base64).toBe('pdf-base64-data')
|
||||
expect(result.mimeType).toBe('application/pdf')
|
||||
expect(mockSharp).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('processes JPEG images through sharp pipeline', async () => {
|
||||
const inputBase64 = Buffer.from('fake-image-data').toString('base64')
|
||||
const outputBuffer = Buffer.from('processed-image')
|
||||
mockToBuffer.mockResolvedValueOnce(outputBuffer)
|
||||
|
||||
const result = await preprocessImage(inputBase64, 'image/jpeg')
|
||||
|
||||
expect(mockSharp).toHaveBeenCalled()
|
||||
expect(mockSharpChain.grayscale).toHaveBeenCalled()
|
||||
expect(mockSharpChain.normalize).toHaveBeenCalled()
|
||||
expect(mockSharpChain.sharpen).toHaveBeenCalledWith({ sigma: 1.5 })
|
||||
expect(mockSharpChain.jpeg).toHaveBeenCalledWith({ quality: 90 })
|
||||
expect(result.mimeType).toBe('image/jpeg')
|
||||
expect(result.base64).toBe(outputBuffer.toString('base64'))
|
||||
})
|
||||
|
||||
it('processes PNG images and outputs as JPEG', async () => {
|
||||
const inputBase64 = Buffer.from('fake-png-data').toString('base64')
|
||||
const outputBuffer = Buffer.from('processed-png')
|
||||
mockToBuffer.mockResolvedValueOnce(outputBuffer)
|
||||
|
||||
const result = await preprocessImage(inputBase64, 'image/png')
|
||||
|
||||
expect(result.mimeType).toBe('image/jpeg')
|
||||
expect(mockSharp).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('processes WebP images', async () => {
|
||||
const inputBase64 = Buffer.from('fake-webp').toString('base64')
|
||||
mockToBuffer.mockResolvedValueOnce(Buffer.from('out'))
|
||||
|
||||
const result = await preprocessImage(inputBase64, 'image/webp')
|
||||
|
||||
expect(result.mimeType).toBe('image/jpeg')
|
||||
})
|
||||
})
|
||||
@@ -1,131 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
validateString,
|
||||
validateNumber,
|
||||
validateDate,
|
||||
validateTime,
|
||||
validateOrgNumber,
|
||||
validateVatNumber,
|
||||
isSwedishOrgNumber,
|
||||
validateAccountNumber,
|
||||
} from '../validation-helpers'
|
||||
|
||||
describe('validateString', () => {
|
||||
it('returns trimmed string for valid input', () => {
|
||||
expect(validateString(' hello ')).toBe('hello')
|
||||
expect(validateString('test')).toBe('test')
|
||||
})
|
||||
|
||||
it('returns null for empty or non-string', () => {
|
||||
expect(validateString('')).toBeNull()
|
||||
expect(validateString(' ')).toBeNull()
|
||||
expect(validateString(null)).toBeNull()
|
||||
expect(validateString(undefined)).toBeNull()
|
||||
expect(validateString(123)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateNumber', () => {
|
||||
it('returns number for valid numeric input', () => {
|
||||
expect(validateNumber(42)).toBe(42)
|
||||
expect(validateNumber(3.14)).toBe(3.14)
|
||||
expect(validateNumber(0)).toBe(0)
|
||||
expect(validateNumber(-5)).toBe(-5)
|
||||
})
|
||||
|
||||
it('parses numeric strings', () => {
|
||||
expect(validateNumber('42')).toBe(42)
|
||||
expect(validateNumber('3.14')).toBe(3.14)
|
||||
expect(validateNumber('100 SEK')).toBe(100)
|
||||
})
|
||||
|
||||
it('returns null for invalid values', () => {
|
||||
expect(validateNumber(NaN)).toBeNull()
|
||||
expect(validateNumber(null)).toBeNull()
|
||||
expect(validateNumber(undefined)).toBeNull()
|
||||
expect(validateNumber('abc')).toBeNull()
|
||||
expect(validateNumber({})).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateDate', () => {
|
||||
it('returns ISO date for valid date strings', () => {
|
||||
expect(validateDate('2024-06-15')).toBe('2024-06-15')
|
||||
expect(validateDate('2024-01-01T12:00:00Z')).toBe('2024-01-01')
|
||||
})
|
||||
|
||||
it('returns null for invalid dates', () => {
|
||||
expect(validateDate('not a date')).toBeNull()
|
||||
expect(validateDate(null)).toBeNull()
|
||||
expect(validateDate(42)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateTime', () => {
|
||||
it('returns HH:MM for valid time strings', () => {
|
||||
expect(validateTime('14:30')).toBe('14:30')
|
||||
expect(validateTime('09:05')).toBe('09:05')
|
||||
expect(validateTime('14:30:00')).toBe('14:30')
|
||||
})
|
||||
|
||||
it('returns null for invalid times', () => {
|
||||
expect(validateTime('2pm')).toBeNull()
|
||||
expect(validateTime(null)).toBeNull()
|
||||
expect(validateTime('1:30')).toBeNull() // needs 2-digit hour
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateOrgNumber', () => {
|
||||
it('formats valid Swedish org numbers', () => {
|
||||
expect(validateOrgNumber('5561234567')).toBe('556123-4567')
|
||||
expect(validateOrgNumber('556123-4567')).toBe('556123-4567')
|
||||
expect(validateOrgNumber('556 123 4567')).toBe('556123-4567')
|
||||
})
|
||||
|
||||
it('returns null for invalid org numbers', () => {
|
||||
expect(validateOrgNumber('12345')).toBeNull()
|
||||
expect(validateOrgNumber('123456789012')).toBeNull()
|
||||
expect(validateOrgNumber(null)).toBeNull()
|
||||
expect(validateOrgNumber(123)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateVatNumber', () => {
|
||||
it('validates Swedish VAT numbers', () => {
|
||||
expect(validateVatNumber('SE556123456701')).toBe('SE556123456701')
|
||||
expect(validateVatNumber('se556123456701')).toBe('SE556123456701')
|
||||
})
|
||||
|
||||
it('returns null for non-Swedish VAT numbers', () => {
|
||||
expect(validateVatNumber('DE123456789')).toBeNull()
|
||||
expect(validateVatNumber('SE12345')).toBeNull() // too short
|
||||
expect(validateVatNumber(null)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('isSwedishOrgNumber', () => {
|
||||
it('returns true for 10-digit numbers', () => {
|
||||
expect(isSwedishOrgNumber('5561234567')).toBe(true)
|
||||
expect(isSwedishOrgNumber('556123-4567')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for non-10-digit numbers', () => {
|
||||
expect(isSwedishOrgNumber('12345')).toBe(false)
|
||||
expect(isSwedishOrgNumber('123456789012')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateAccountNumber', () => {
|
||||
it('validates 4-digit BAS account numbers', () => {
|
||||
expect(validateAccountNumber('5410')).toBe('5410')
|
||||
expect(validateAccountNumber('1930')).toBe('1930')
|
||||
expect(validateAccountNumber('konto 6530')).toBe('6530')
|
||||
})
|
||||
|
||||
it('returns null for invalid account numbers', () => {
|
||||
expect(validateAccountNumber('999')).toBeNull() // too short
|
||||
expect(validateAccountNumber('0500')).toBeNull() // below 1000
|
||||
expect(validateAccountNumber(null)).toBeNull()
|
||||
expect(validateAccountNumber(undefined)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -1,256 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
// Mock server-only
|
||||
vi.mock('server-only', () => ({}))
|
||||
|
||||
// Mock preprocessing
|
||||
vi.mock('../preprocess-image', () => ({
|
||||
preprocessImage: vi.fn().mockResolvedValue({ base64: 'preprocessed', mimeType: 'image/jpeg' }),
|
||||
}))
|
||||
|
||||
// Mock Anthropic SDK
|
||||
const { mockCreate } = vi.hoisted(() => {
|
||||
const mockCreate = vi.fn()
|
||||
return { mockCreate }
|
||||
})
|
||||
|
||||
vi.mock('@anthropic-ai/sdk', () => ({
|
||||
default: class MockAnthropic {
|
||||
messages = { create: mockCreate }
|
||||
},
|
||||
}))
|
||||
|
||||
import { callVision, stripJsonFences } from '../vision-client'
|
||||
import { preprocessImage } from '../preprocess-image'
|
||||
|
||||
function makeResponse(json: Record<string, unknown>) {
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify(json) }],
|
||||
}
|
||||
}
|
||||
|
||||
describe('callVision', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('returns parsed JSON from Claude response', async () => {
|
||||
mockCreate.mockResolvedValueOnce(
|
||||
makeResponse({ type: 'receipt', confidence: 0.95 })
|
||||
)
|
||||
|
||||
const result = await callVision({
|
||||
base64: 'base64data',
|
||||
mimeType: 'image/jpeg',
|
||||
systemPrompt: 'System prompt',
|
||||
userPrompt: 'User prompt',
|
||||
})
|
||||
|
||||
expect(result).toEqual({ type: 'receipt', confidence: 0.95 })
|
||||
})
|
||||
|
||||
it('preprocesses images by default', async () => {
|
||||
mockCreate.mockResolvedValueOnce(makeResponse({ ok: true }))
|
||||
|
||||
await callVision({
|
||||
base64: 'raw-image',
|
||||
mimeType: 'image/jpeg',
|
||||
systemPrompt: 'S',
|
||||
userPrompt: 'U',
|
||||
})
|
||||
|
||||
expect(preprocessImage).toHaveBeenCalledWith('raw-image', 'image/jpeg')
|
||||
})
|
||||
|
||||
it('skips preprocessing when preprocess=false', async () => {
|
||||
mockCreate.mockResolvedValueOnce(makeResponse({ ok: true }))
|
||||
|
||||
await callVision({
|
||||
base64: 'raw-image',
|
||||
mimeType: 'image/jpeg',
|
||||
systemPrompt: 'S',
|
||||
userPrompt: 'U',
|
||||
preprocess: false,
|
||||
})
|
||||
|
||||
expect(preprocessImage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips preprocessing for PDFs', async () => {
|
||||
mockCreate.mockResolvedValueOnce(makeResponse({ ok: true }))
|
||||
|
||||
await callVision({
|
||||
base64: 'pdf-data',
|
||||
mimeType: 'application/pdf',
|
||||
systemPrompt: 'S',
|
||||
userPrompt: 'U',
|
||||
})
|
||||
|
||||
expect(preprocessImage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses document content block for PDFs', async () => {
|
||||
mockCreate.mockResolvedValueOnce(makeResponse({ type: 'invoice' }))
|
||||
|
||||
await callVision({
|
||||
base64: 'pdf-data',
|
||||
mimeType: 'application/pdf',
|
||||
systemPrompt: 'S',
|
||||
userPrompt: 'U',
|
||||
})
|
||||
|
||||
expect(mockCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: expect.arrayContaining([
|
||||
expect.objectContaining({ type: 'document' }),
|
||||
]),
|
||||
},
|
||||
],
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('uses image content block for images', async () => {
|
||||
mockCreate.mockResolvedValueOnce(makeResponse({ type: 'receipt' }))
|
||||
|
||||
await callVision({
|
||||
base64: 'img-data',
|
||||
mimeType: 'image/png',
|
||||
systemPrompt: 'S',
|
||||
userPrompt: 'U',
|
||||
preprocess: false,
|
||||
})
|
||||
|
||||
expect(mockCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: expect.arrayContaining([
|
||||
expect.objectContaining({ type: 'image' }),
|
||||
]),
|
||||
},
|
||||
],
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('retries on API error', async () => {
|
||||
mockCreate
|
||||
.mockRejectedValueOnce(new Error('API timeout'))
|
||||
.mockResolvedValueOnce(makeResponse({ ok: true }))
|
||||
|
||||
const result = await callVision({
|
||||
base64: 'data',
|
||||
mimeType: 'image/jpeg',
|
||||
systemPrompt: 'S',
|
||||
userPrompt: 'U',
|
||||
})
|
||||
|
||||
expect(result).toEqual({ ok: true })
|
||||
expect(mockCreate).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('fast-fails on SyntaxError without retrying', async () => {
|
||||
mockCreate.mockResolvedValueOnce({
|
||||
content: [{ type: 'text', text: 'not valid json at all' }],
|
||||
})
|
||||
|
||||
await expect(
|
||||
callVision({
|
||||
base64: 'data',
|
||||
mimeType: 'image/jpeg',
|
||||
systemPrompt: 'S',
|
||||
userPrompt: 'U',
|
||||
})
|
||||
).rejects.toThrow('Failed to parse AI response')
|
||||
|
||||
expect(mockCreate).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('throws after max retries', async () => {
|
||||
mockCreate
|
||||
.mockRejectedValueOnce(new Error('err1'))
|
||||
.mockRejectedValueOnce(new Error('err2'))
|
||||
.mockRejectedValueOnce(new Error('err3'))
|
||||
|
||||
await expect(
|
||||
callVision({
|
||||
base64: 'data',
|
||||
mimeType: 'image/jpeg',
|
||||
systemPrompt: 'S',
|
||||
userPrompt: 'U',
|
||||
})
|
||||
).rejects.toThrow('Vision API call failed after 3 attempts')
|
||||
|
||||
expect(mockCreate).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('throws on unsupported file type', async () => {
|
||||
await expect(
|
||||
callVision({
|
||||
base64: 'data',
|
||||
mimeType: 'text/plain',
|
||||
systemPrompt: 'S',
|
||||
userPrompt: 'U',
|
||||
})
|
||||
).rejects.toThrow('Unsupported file type: text/plain')
|
||||
})
|
||||
|
||||
it('uses custom maxTokens', async () => {
|
||||
mockCreate.mockResolvedValueOnce(makeResponse({ ok: true }))
|
||||
|
||||
await callVision({
|
||||
base64: 'data',
|
||||
mimeType: 'application/pdf',
|
||||
systemPrompt: 'S',
|
||||
userPrompt: 'U',
|
||||
maxTokens: 1024,
|
||||
})
|
||||
|
||||
expect(mockCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ max_tokens: 1024 })
|
||||
)
|
||||
})
|
||||
|
||||
it('strips JSON fences from response', async () => {
|
||||
mockCreate.mockResolvedValueOnce({
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: '```json\n{"type":"receipt","confidence":0.9}\n```',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const result = await callVision({
|
||||
base64: 'data',
|
||||
mimeType: 'image/jpeg',
|
||||
systemPrompt: 'S',
|
||||
userPrompt: 'U',
|
||||
})
|
||||
|
||||
expect(result).toEqual({ type: 'receipt', confidence: 0.9 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('stripJsonFences', () => {
|
||||
it('strips ```json ... ``` fences', () => {
|
||||
expect(stripJsonFences('```json\n{"a":1}\n```')).toBe('{"a":1}')
|
||||
})
|
||||
|
||||
it('strips plain ``` fences', () => {
|
||||
expect(stripJsonFences('```\n{"a":1}\n```')).toBe('{"a":1}')
|
||||
})
|
||||
|
||||
it('returns plain JSON unchanged', () => {
|
||||
expect(stripJsonFences('{"a":1}')).toBe('{"a":1}')
|
||||
})
|
||||
|
||||
it('trims whitespace', () => {
|
||||
expect(stripJsonFences(' {"a":1} ')).toBe('{"a":1}')
|
||||
})
|
||||
})
|
||||
@@ -1,673 +0,0 @@
|
||||
/**
|
||||
* Unified Document Analyzer
|
||||
*
|
||||
* SERVER-ONLY: Uses the shared vision client (Anthropic SDK).
|
||||
*
|
||||
* Provides four modes:
|
||||
* - analyzeDocument(): Single Claude call that classifies AND extracts
|
||||
* - extractReceipt(): Standalone receipt extraction
|
||||
* - extractInvoice(): Standalone invoice extraction
|
||||
* - classifyDocument(): Lightweight classify-only
|
||||
*/
|
||||
|
||||
import 'server-only'
|
||||
import type {
|
||||
DocumentClassificationType,
|
||||
ReceiptExtractionResult,
|
||||
InvoiceExtractionResult,
|
||||
ExtractedInvoiceLineItem,
|
||||
VatBreakdownItem,
|
||||
} from '@/types'
|
||||
import { callVision } from './vision-client'
|
||||
import {
|
||||
validateString,
|
||||
validateNumber,
|
||||
validateDate,
|
||||
validateTime,
|
||||
validateOrgNumber,
|
||||
validateVatNumber,
|
||||
validateAccountNumber,
|
||||
} from './validation-helpers'
|
||||
import { buildTemplatePromptSection } from '@/lib/bookkeeping/template-prompt'
|
||||
|
||||
// ============================================================
|
||||
// Types
|
||||
// ============================================================
|
||||
|
||||
export interface DocumentClassification {
|
||||
type: DocumentClassificationType
|
||||
confidence: number
|
||||
reasoning: string
|
||||
isReverseCharge?: boolean
|
||||
}
|
||||
|
||||
export interface UnifiedExtractionResult {
|
||||
classification: DocumentClassification
|
||||
receipt?: ReceiptExtractionResult
|
||||
invoice?: InvoiceExtractionResult
|
||||
}
|
||||
|
||||
interface ConsistencyResult {
|
||||
valid: boolean
|
||||
issues: string[]
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Prompts
|
||||
// ============================================================
|
||||
|
||||
const CLASSIFY_SYSTEM_PROMPT = `Du är expert på att klassificera svenska affärsdokument.
|
||||
Din uppgift är att avgöra vilken typ av dokument som visas.
|
||||
|
||||
DOKUMENTTYPER:
|
||||
- supplier_invoice: Leverantörsfaktura — ett kreditdokument med betalningskrav. MÅSTE ha: fakturanummer, förfallodatum, och betalningsuppgifter (bankgiro/plusgiro/IBAN). Har ofta: organisationsnummer, OCR-referens, betalningsvillkor (t.ex. "30 dagar netto").
|
||||
- receipt: Kvitto/kassakvitto — bevis på redan genomförd betalning. Kännetecken: "KVITTO", "Kontant", "Kort", kassamaskins-ID, klockslag, redan betalt. Typiskt från butiker, restauranger, bensinstationer, onlineköp. Har INTE förfallodatum eller bankgiro.
|
||||
- government_letter: Myndighetspost (från Skatteverket, Bolagsverket, Försäkringskassan, kommun, etc.)
|
||||
- unknown: Annat dokument som inte passar ovan
|
||||
|
||||
VIKTIGA SKILLNADER (receipt vs supplier_invoice):
|
||||
- Ett kvitto visar en AVSLUTAD transaktion (betalning redan gjord). En faktura är ett KRAV på framtida betalning.
|
||||
- Om dokumentet har bankgiro/plusgiro och förfallodatum → supplier_invoice
|
||||
- Om dokumentet visar "Betalt", kortbetalning, Swish, eller kontant → receipt
|
||||
- Prenumerationsbekräftelser, orderbekräftelser med "Betalt" → receipt
|
||||
- Samlingsfakturor med förfallodatum → supplier_invoice
|
||||
|
||||
FÖR LEVERANTÖRSFAKTUROR - kontrollera även:
|
||||
- Är fakturan från en utländsk/EU-leverantör utan svensk moms?
|
||||
- Nämner dokumentet "reverse charge", "omvänd skattskyldighet", eller "artikel 196"?
|
||||
- Har leverantören ett VAT-nummer som INTE börjar med SE?
|
||||
Om ja: flagga isReverseCharge = true`
|
||||
|
||||
const RECEIPT_SYSTEM_PROMPT = `Du är expert på att extrahera data från svenska kvitton och fakturor.
|
||||
Din uppgift är att noggrant analysera kvittobilden och extrahera all relevant information.
|
||||
|
||||
VIKTIGT:
|
||||
- Extrahera VARJE artikelrad, inte bara summan
|
||||
- Identifiera momssats per rad om möjligt (25%, 12%, 6%)
|
||||
- Flagga om detta är: restaurang, Systembolaget, eller utländsk handlare
|
||||
- Svenska organisationsnummer är i format XXXXXX-XXXX
|
||||
- Momsregistreringsnummer börjar med SE
|
||||
- Datum ska vara i ISO-format (YYYY-MM-DD)
|
||||
- Belopp ska vara numeriska värden utan valutasymboler
|
||||
- Ange konfidenstal (0.0-1.0) för hela extraheringen baserat på bildkvalitet`
|
||||
|
||||
const INVOICE_SYSTEM_PROMPT = `Du är expert på att extrahera data från svenska leverantörsfakturor.
|
||||
Din uppgift är att noggrant analysera fakturan och extrahera all relevant information.
|
||||
|
||||
VIKTIGT:
|
||||
- Extrahera leverantörens organisationsnummer (XXXXXX-XXXX format)
|
||||
- Extrahera bankgiro och/eller plusgiro
|
||||
- Extrahera varje fakturaradspost med belopp, moms
|
||||
- Identifiera momssatser (25%, 12%, 6%, 0%)
|
||||
- Extrahera OCR-nummer eller betalningsreferens
|
||||
- Datum ska vara i ISO-format (YYYY-MM-DD)
|
||||
- Belopp ska vara numeriska värden utan valutasymboler
|
||||
- Ange konfidenstal (0.0-1.0) för hela extraheringen`
|
||||
|
||||
function buildReceiptUserPrompt(): string {
|
||||
const templateSection = buildTemplatePromptSection()
|
||||
|
||||
return `Analysera detta kvitto och extrahera strukturerad data.
|
||||
|
||||
Returnera ett JSON-objekt med följande struktur:
|
||||
|
||||
{
|
||||
"merchant": {
|
||||
"name": "Handlarens namn",
|
||||
"orgNumber": "XXXXXX-XXXX eller null",
|
||||
"vatNumber": "SE... eller null",
|
||||
"isForeign": false
|
||||
},
|
||||
"receipt": {
|
||||
"date": "YYYY-MM-DD",
|
||||
"time": "HH:MM eller null",
|
||||
"currency": "SEK"
|
||||
},
|
||||
"lineItems": [
|
||||
{
|
||||
"description": "Artikelbeskrivning",
|
||||
"quantity": 1,
|
||||
"unitPrice": 100.00,
|
||||
"lineTotal": 100.00,
|
||||
"vatRate": 25,
|
||||
"suggestedCategory": "equipment|software|travel|office|marketing|professional_services|education|other",
|
||||
"suggestedTemplateId": "mall-id eller null"
|
||||
}
|
||||
],
|
||||
"totals": {
|
||||
"subtotal": 100.00,
|
||||
"vatAmount": 25.00,
|
||||
"total": 125.00
|
||||
},
|
||||
"flags": {
|
||||
"isRestaurant": false,
|
||||
"isSystembolaget": false,
|
||||
"isForeignMerchant": false
|
||||
},
|
||||
"confidence": 0.95,
|
||||
"suggestedTemplateId": "mall-id för hela kvittot eller null"
|
||||
}
|
||||
|
||||
${templateSection}
|
||||
|
||||
KATEGORIER för suggestedCategory (backup om ingen mall matchar):
|
||||
- equipment: Datorer, telefoner, kameror, teknikprylar
|
||||
- software: Program, appar, molntjänster, prenumerationer
|
||||
- travel: Flyg, tåg, hotell, taxi
|
||||
- office: Kontorsmaterial, möbler, hyra
|
||||
- marketing: Reklam, marknadsföring, PR
|
||||
- professional_services: Konsulter, redovisning, juridik
|
||||
- education: Kurser, böcker, utbildning
|
||||
- other: Övrigt
|
||||
|
||||
Returnera ENDAST JSON-objektet, ingen annan text.`
|
||||
}
|
||||
|
||||
function buildInvoiceUserPrompt(): string {
|
||||
const templateSection = buildTemplatePromptSection()
|
||||
|
||||
return `Analysera denna leverantörsfaktura och extrahera strukturerad data.
|
||||
|
||||
Returnera ett JSON-objekt med följande struktur:
|
||||
|
||||
{
|
||||
"supplier": {
|
||||
"name": "Leverantörens namn",
|
||||
"orgNumber": "XXXXXX-XXXX eller null",
|
||||
"vatNumber": "SE... eller null",
|
||||
"address": "Fullständig adress eller null",
|
||||
"bankgiro": "XXX-XXXX eller null",
|
||||
"plusgiro": "XXXXXX-X eller null"
|
||||
},
|
||||
"invoice": {
|
||||
"invoiceNumber": "Fakturanummer",
|
||||
"invoiceDate": "YYYY-MM-DD",
|
||||
"dueDate": "YYYY-MM-DD",
|
||||
"paymentReference": "OCR-nummer eller referens eller null",
|
||||
"currency": "SEK"
|
||||
},
|
||||
"lineItems": [
|
||||
{
|
||||
"description": "Beskrivning av rad",
|
||||
"quantity": 1,
|
||||
"unitPrice": 100.00,
|
||||
"lineTotal": 100.00,
|
||||
"vatRate": 25,
|
||||
"accountSuggestion": "BAS-kontonummer som 5410 eller null",
|
||||
"suggestedTemplateId": "mall-id eller null"
|
||||
}
|
||||
],
|
||||
"totals": {
|
||||
"subtotal": 100.00,
|
||||
"vatAmount": 25.00,
|
||||
"total": 125.00
|
||||
},
|
||||
"vatBreakdown": [
|
||||
{
|
||||
"rate": 25,
|
||||
"base": 100.00,
|
||||
"amount": 25.00
|
||||
}
|
||||
],
|
||||
"confidence": 0.95,
|
||||
"suggestedTemplateId": "mall-id för hela fakturan eller null"
|
||||
}
|
||||
|
||||
${templateSection}
|
||||
|
||||
KONTOKATEGORIER (BAS, backup om ingen mall matchar):
|
||||
- 4000-4999: Varuinköp, material
|
||||
- 5010: Lokalhyra
|
||||
- 5410: Förbrukningsinventarier
|
||||
- 5420: Programvaror
|
||||
- 5800-5899: Resekostnader
|
||||
- 6100-6199: Kontorsmaterial
|
||||
- 6200-6299: Telefon, internet
|
||||
- 6310: Företagsförsäkringar
|
||||
- 6530: Redovisningstjänster
|
||||
- 6570: Bankkostnader
|
||||
|
||||
Returnera ENDAST JSON-objektet, ingen annan text.`
|
||||
}
|
||||
|
||||
function buildUnifiedUserPrompt(): string {
|
||||
const templateSection = buildTemplatePromptSection()
|
||||
|
||||
return `Analysera detta dokument. Gör BÅDA stegen:
|
||||
|
||||
STEG 1: Klassificera dokumenttypen.
|
||||
STEG 2: Om det är ett kvitto eller leverantörsfaktura, extrahera all data.
|
||||
|
||||
Returnera ett JSON-objekt med följande struktur:
|
||||
|
||||
{
|
||||
"classification": {
|
||||
"type": "supplier_invoice" | "receipt" | "government_letter" | "unknown",
|
||||
"confidence": 0.95,
|
||||
"reasoning": "Kort förklaring",
|
||||
"isReverseCharge": false
|
||||
},
|
||||
"receipt": null,
|
||||
"invoice": null
|
||||
}
|
||||
|
||||
Om type = "receipt", fyll i "receipt" med:
|
||||
{
|
||||
"merchant": { "name": "...", "orgNumber": "XXXXXX-XXXX eller null", "vatNumber": "SE... eller null", "isForeign": false },
|
||||
"receipt": { "date": "YYYY-MM-DD", "time": "HH:MM eller null", "currency": "SEK" },
|
||||
"lineItems": [{ "description": "...", "quantity": 1, "unitPrice": 100, "lineTotal": 100, "vatRate": 25, "suggestedCategory": "equipment|software|travel|office|marketing|professional_services|education|other", "suggestedTemplateId": null }],
|
||||
"totals": { "subtotal": 100, "vatAmount": 25, "total": 125 },
|
||||
"flags": { "isRestaurant": false, "isSystembolaget": false, "isForeignMerchant": false },
|
||||
"confidence": 0.95,
|
||||
"suggestedTemplateId": null
|
||||
}
|
||||
|
||||
Om type = "supplier_invoice", fyll i "invoice" med:
|
||||
{
|
||||
"supplier": { "name": "...", "orgNumber": "XXXXXX-XXXX eller null", "vatNumber": "SE... eller null", "address": "...", "bankgiro": "...", "plusgiro": "..." },
|
||||
"invoice": { "invoiceNumber": "...", "invoiceDate": "YYYY-MM-DD", "dueDate": "YYYY-MM-DD", "paymentReference": "...", "currency": "SEK" },
|
||||
"lineItems": [{ "description": "...", "quantity": 1, "unitPrice": 100, "lineTotal": 100, "vatRate": 25, "accountSuggestion": "5410", "suggestedTemplateId": null }],
|
||||
"totals": { "subtotal": 100, "vatAmount": 25, "total": 125 },
|
||||
"vatBreakdown": [{ "rate": 25, "base": 100, "amount": 25 }],
|
||||
"confidence": 0.95,
|
||||
"suggestedTemplateId": null
|
||||
}
|
||||
|
||||
${templateSection}
|
||||
|
||||
Om type = "government_letter" eller "unknown": lämna receipt och invoice som null.
|
||||
|
||||
Returnera ENDAST JSON-objektet, ingen annan text.`
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Validation
|
||||
// ============================================================
|
||||
|
||||
const VALID_CLASSIFICATION_TYPES: DocumentClassificationType[] = [
|
||||
'supplier_invoice',
|
||||
'receipt',
|
||||
'government_letter',
|
||||
'unknown',
|
||||
]
|
||||
|
||||
const VALID_VAT_RATES = [0, 6, 12, 25]
|
||||
|
||||
function validateClassification(raw: unknown): DocumentClassification {
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
throw new Error('Invalid classification result: not an object')
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const data = raw as any
|
||||
|
||||
const type = VALID_CLASSIFICATION_TYPES.includes(data.type) ? data.type : 'unknown'
|
||||
const confidence = typeof data.confidence === 'number' ? data.confidence : 0.5
|
||||
const reasoning = typeof data.reasoning === 'string' ? data.reasoning : ''
|
||||
const isReverseCharge = type === 'supplier_invoice' ? Boolean(data.isReverseCharge) : undefined
|
||||
|
||||
return { type, confidence, reasoning, isReverseCharge }
|
||||
}
|
||||
|
||||
function validateReceiptExtraction(raw: unknown): ReceiptExtractionResult {
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
throw new Error('Invalid receipt extraction: not an object')
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const data = raw as any
|
||||
|
||||
const merchant = data.merchant || {}
|
||||
const receipt = data.receipt || {}
|
||||
const totals = data.totals || {}
|
||||
const flags = data.flags || {}
|
||||
|
||||
return {
|
||||
merchant: {
|
||||
name: validateString(merchant.name),
|
||||
orgNumber: validateOrgNumber(merchant.orgNumber),
|
||||
vatNumber: validateVatNumber(merchant.vatNumber),
|
||||
isForeign: Boolean(flags.isForeignMerchant || merchant.isForeign),
|
||||
},
|
||||
receipt: {
|
||||
date: validateDate(receipt.date),
|
||||
time: validateTime(receipt.time),
|
||||
currency: validateString(receipt.currency) || 'SEK',
|
||||
},
|
||||
lineItems: validateReceiptLineItems(data.lineItems),
|
||||
totals: {
|
||||
subtotal: validateNumber(totals.subtotal),
|
||||
vatAmount: validateNumber(totals.vatAmount),
|
||||
total: validateNumber(totals.total),
|
||||
},
|
||||
flags: {
|
||||
isRestaurant: Boolean(flags.isRestaurant),
|
||||
isSystembolaget: Boolean(flags.isSystembolaget),
|
||||
isForeignMerchant: Boolean(flags.isForeignMerchant || merchant.isForeign),
|
||||
},
|
||||
confidence: validateNumber(data.confidence) || 0.5,
|
||||
suggestedTemplateId: validateString(data.suggestedTemplateId) || undefined,
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function validateReceiptLineItems(data: any): ReceiptExtractionResult['lineItems'] {
|
||||
if (!Array.isArray(data)) return []
|
||||
|
||||
return data
|
||||
.filter((item: unknown) => item && typeof item === 'object')
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
.map((item: any) => ({
|
||||
description: String(item.description || '').trim(),
|
||||
quantity: validateNumber(item.quantity) || 1,
|
||||
unitPrice: validateNumber(item.unitPrice),
|
||||
lineTotal: validateNumber(item.lineTotal) || 0,
|
||||
vatRate: validateNumber(item.vatRate),
|
||||
suggestedCategory: validateString(item.suggestedCategory),
|
||||
suggestedTemplateId: validateString(item.suggestedTemplateId) || undefined,
|
||||
confidence: validateNumber(item.confidence) || undefined,
|
||||
}))
|
||||
.filter((item: { lineTotal: number; description: string }) => item.lineTotal > 0 || item.description.length > 0)
|
||||
}
|
||||
|
||||
function validateInvoiceExtraction(raw: unknown): InvoiceExtractionResult {
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
throw new Error('Invalid invoice extraction: not an object')
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const data = raw as any
|
||||
|
||||
const supplier = data.supplier || {}
|
||||
const invoice = data.invoice || {}
|
||||
const totals = data.totals || {}
|
||||
|
||||
return {
|
||||
supplier: {
|
||||
name: validateString(supplier.name),
|
||||
orgNumber: validateOrgNumber(supplier.orgNumber),
|
||||
vatNumber: validateVatNumber(supplier.vatNumber),
|
||||
address: validateString(supplier.address),
|
||||
bankgiro: validateString(supplier.bankgiro),
|
||||
plusgiro: validateString(supplier.plusgiro),
|
||||
},
|
||||
invoice: {
|
||||
invoiceNumber: validateString(invoice.invoiceNumber),
|
||||
invoiceDate: validateDate(invoice.invoiceDate),
|
||||
dueDate: validateDate(invoice.dueDate),
|
||||
paymentReference: validateString(invoice.paymentReference),
|
||||
currency: validateString(invoice.currency) || 'SEK',
|
||||
},
|
||||
lineItems: validateInvoiceLineItems(data.lineItems),
|
||||
totals: {
|
||||
subtotal: validateNumber(totals.subtotal),
|
||||
vatAmount: validateNumber(totals.vatAmount),
|
||||
total: validateNumber(totals.total),
|
||||
},
|
||||
vatBreakdown: validateVatBreakdown(data.vatBreakdown),
|
||||
confidence: validateNumber(data.confidence) || 0.5,
|
||||
suggestedTemplateId: validateString(data.suggestedTemplateId) || undefined,
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function validateInvoiceLineItems(data: any): ExtractedInvoiceLineItem[] {
|
||||
if (!Array.isArray(data)) return []
|
||||
|
||||
return data
|
||||
.filter((item: unknown) => item && typeof item === 'object')
|
||||
.map((item: Record<string, unknown>) => ({
|
||||
description: String(item.description || '').trim(),
|
||||
quantity: validateNumber(item.quantity) || 1,
|
||||
unitPrice: validateNumber(item.unitPrice),
|
||||
lineTotal: validateNumber(item.lineTotal) || 0,
|
||||
vatRate: validateNumber(item.vatRate),
|
||||
accountSuggestion: validateAccountNumber(item.accountSuggestion as string | undefined),
|
||||
suggestedTemplateId: validateString(item.suggestedTemplateId as string | undefined) || undefined,
|
||||
}))
|
||||
.filter((item: ExtractedInvoiceLineItem) => item.lineTotal > 0 || item.description.length > 0)
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function validateVatBreakdown(data: any): VatBreakdownItem[] {
|
||||
if (!Array.isArray(data)) return []
|
||||
|
||||
return data
|
||||
.filter((item: unknown) => item && typeof item === 'object')
|
||||
.map((item: Record<string, unknown>) => ({
|
||||
rate: validateNumber(item.rate) || 0,
|
||||
base: validateNumber(item.base) || 0,
|
||||
amount: validateNumber(item.amount) || 0,
|
||||
}))
|
||||
.filter((item: VatBreakdownItem) => item.amount > 0 || item.base > 0)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Consistency Validation
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Post-extraction validation that catches AI hallucinations.
|
||||
*/
|
||||
export function validateExtractionConsistency(
|
||||
extraction: ReceiptExtractionResult | InvoiceExtractionResult,
|
||||
|
||||
_type: 'receipt' | 'invoice'
|
||||
): ConsistencyResult {
|
||||
const issues: string[] = []
|
||||
|
||||
const total = extraction.totals.total
|
||||
const lineItems = extraction.lineItems
|
||||
|
||||
// Check: total > 0
|
||||
if (total !== null && total <= 0) {
|
||||
issues.push(`Total is ${total} — expected positive value`)
|
||||
}
|
||||
|
||||
// Check: line item totals sum to receipt/invoice total (±1 SEK tolerance)
|
||||
if (total !== null && lineItems.length > 0) {
|
||||
const lineSum = lineItems.reduce((sum, item) => sum + item.lineTotal, 0)
|
||||
const diff = Math.abs(lineSum - total)
|
||||
if (diff > 1) {
|
||||
issues.push(`Line items sum to ${lineSum} but total is ${total} (diff: ${diff})`)
|
||||
}
|
||||
}
|
||||
|
||||
// Check: VAT rates are valid (0, 6, 12, 25)
|
||||
for (const item of lineItems) {
|
||||
if (item.vatRate !== null && !VALID_VAT_RATES.includes(item.vatRate)) {
|
||||
issues.push(`Invalid VAT rate ${item.vatRate} on "${item.description}"`)
|
||||
}
|
||||
}
|
||||
|
||||
// Check: descriptions aren't empty
|
||||
for (const item of lineItems) {
|
||||
if (!item.description || item.description.trim().length === 0) {
|
||||
issues.push('Line item has empty description')
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
valid: issues.length === 0,
|
||||
issues,
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Public API
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Unified document analysis: classifies AND extracts in a single Claude call.
|
||||
* Falls back to dedicated extraction if misclassification detected.
|
||||
*/
|
||||
export async function analyzeDocument(
|
||||
base64: string,
|
||||
mimeType: string
|
||||
): Promise<UnifiedExtractionResult> {
|
||||
const raw = await callVision({
|
||||
base64,
|
||||
mimeType,
|
||||
systemPrompt: `${CLASSIFY_SYSTEM_PROMPT}\n\n${RECEIPT_SYSTEM_PROMPT}\n\n${INVOICE_SYSTEM_PROMPT}`,
|
||||
userPrompt: buildUnifiedUserPrompt(),
|
||||
maxTokens: 4096,
|
||||
})
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const data = raw as any
|
||||
|
||||
const classification = validateClassification(data.classification || data)
|
||||
|
||||
const result: UnifiedExtractionResult = { classification }
|
||||
|
||||
if (classification.type === 'receipt') {
|
||||
if (data.receipt) {
|
||||
result.receipt = validateReceiptExtraction(data.receipt)
|
||||
const consistency = validateExtractionConsistency(result.receipt, 'receipt')
|
||||
if (!consistency.valid && result.receipt.confidence > 0.5) {
|
||||
// Retry with dedicated extraction
|
||||
try {
|
||||
result.receipt = await extractReceipt(base64, mimeType)
|
||||
} catch {
|
||||
// Keep original with reduced confidence
|
||||
result.receipt = { ...result.receipt, confidence: result.receipt.confidence * 0.7 }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Misclassification fallback: type says receipt but no receipt data
|
||||
try {
|
||||
result.receipt = await extractReceipt(base64, mimeType)
|
||||
} catch {
|
||||
// Classification only — no extraction available
|
||||
}
|
||||
}
|
||||
} else if (classification.type === 'supplier_invoice') {
|
||||
if (data.invoice) {
|
||||
result.invoice = validateInvoiceExtraction(data.invoice)
|
||||
const consistency = validateExtractionConsistency(result.invoice, 'invoice')
|
||||
if (!consistency.valid && result.invoice.confidence > 0.5) {
|
||||
try {
|
||||
result.invoice = await extractInvoice(base64, mimeType)
|
||||
} catch {
|
||||
result.invoice = { ...result.invoice, confidence: result.invoice.confidence * 0.7 }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
result.invoice = await extractInvoice(base64, mimeType)
|
||||
} catch {
|
||||
// Classification only
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Standalone receipt extraction.
|
||||
* For use when caller already knows it's a receipt.
|
||||
*/
|
||||
export async function extractReceipt(
|
||||
base64: string,
|
||||
mimeType: string
|
||||
): Promise<ReceiptExtractionResult> {
|
||||
const raw = await callVision({
|
||||
base64,
|
||||
mimeType,
|
||||
systemPrompt: RECEIPT_SYSTEM_PROMPT,
|
||||
userPrompt: buildReceiptUserPrompt(),
|
||||
maxTokens: 4096,
|
||||
})
|
||||
|
||||
const result = validateReceiptExtraction(raw)
|
||||
|
||||
const consistency = validateExtractionConsistency(result, 'receipt')
|
||||
if (!consistency.valid && result.confidence > 0.5) {
|
||||
// Retry with correction prompt
|
||||
try {
|
||||
const correctionPrompt = `Föregående extraheringen hade dessa problem: ${consistency.issues.join('; ')}. Var god extrahera igen med korrigeringar.\n\n${buildReceiptUserPrompt()}`
|
||||
const retryRaw = await callVision({
|
||||
base64,
|
||||
mimeType,
|
||||
systemPrompt: RECEIPT_SYSTEM_PROMPT,
|
||||
userPrompt: correctionPrompt,
|
||||
maxTokens: 4096,
|
||||
})
|
||||
return validateReceiptExtraction(retryRaw)
|
||||
} catch {
|
||||
// Return original with reduced confidence
|
||||
return { ...result, confidence: result.confidence * 0.7 }
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Standalone invoice extraction.
|
||||
* For use when caller already knows it's an invoice.
|
||||
*/
|
||||
export async function extractInvoice(
|
||||
base64: string,
|
||||
mimeType: string
|
||||
): Promise<InvoiceExtractionResult> {
|
||||
const raw = await callVision({
|
||||
base64,
|
||||
mimeType,
|
||||
systemPrompt: INVOICE_SYSTEM_PROMPT,
|
||||
userPrompt: buildInvoiceUserPrompt(),
|
||||
maxTokens: 4096,
|
||||
})
|
||||
|
||||
const result = validateInvoiceExtraction(raw)
|
||||
|
||||
const consistency = validateExtractionConsistency(result, 'invoice')
|
||||
if (!consistency.valid && result.confidence > 0.5) {
|
||||
try {
|
||||
const correctionPrompt = `Föregående extraheringen hade dessa problem: ${consistency.issues.join('; ')}. Var god extrahera igen med korrigeringar.\n\n${buildInvoiceUserPrompt()}`
|
||||
const retryRaw = await callVision({
|
||||
base64,
|
||||
mimeType,
|
||||
systemPrompt: INVOICE_SYSTEM_PROMPT,
|
||||
userPrompt: correctionPrompt,
|
||||
maxTokens: 4096,
|
||||
})
|
||||
return validateInvoiceExtraction(retryRaw)
|
||||
} catch {
|
||||
return { ...result, confidence: result.confidence * 0.7 }
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight classify-only (no extraction).
|
||||
* Kept for backward compatibility.
|
||||
*/
|
||||
export async function classifyDocument(
|
||||
base64: string,
|
||||
mimeType: string
|
||||
): Promise<DocumentClassification> {
|
||||
const userPrompt = `Klassificera detta dokument. Returnera ENDAST ett JSON-objekt:
|
||||
|
||||
{
|
||||
"type": "supplier_invoice" | "receipt" | "government_letter" | "unknown",
|
||||
"confidence": 0.95,
|
||||
"reasoning": "Kort förklaring",
|
||||
"isReverseCharge": false
|
||||
}
|
||||
|
||||
Returnera ENDAST JSON-objektet, ingen annan text.`
|
||||
|
||||
const raw = await callVision({
|
||||
base64,
|
||||
mimeType,
|
||||
systemPrompt: CLASSIFY_SYSTEM_PROMPT,
|
||||
userPrompt,
|
||||
maxTokens: 1024,
|
||||
})
|
||||
|
||||
return validateClassification(raw)
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
/**
|
||||
* Image Preprocessing for OCR
|
||||
*
|
||||
* SERVER-ONLY: Uses sharp for image processing.
|
||||
*
|
||||
* Preprocesses receipt/invoice images before sending to Claude Vision.
|
||||
* Targets faded thermal prints with low contrast — the primary source
|
||||
* of garbled OCR output.
|
||||
*/
|
||||
|
||||
import 'server-only'
|
||||
import sharp from 'sharp'
|
||||
|
||||
/**
|
||||
* Preprocess an image for better OCR accuracy.
|
||||
* Converts to grayscale, normalizes contrast, and sharpens.
|
||||
* Returns base64-encoded JPEG.
|
||||
*
|
||||
* For PDFs: returns input unchanged (sharp doesn't handle PDFs).
|
||||
*/
|
||||
export async function preprocessImage(
|
||||
base64: string,
|
||||
mimeType: string
|
||||
): Promise<{ base64: string; mimeType: string }> {
|
||||
// PDFs are not image files — return as-is
|
||||
if (mimeType === 'application/pdf') {
|
||||
return { base64, mimeType }
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(base64, 'base64')
|
||||
|
||||
const processed = await sharp(buffer)
|
||||
.grayscale()
|
||||
.normalize()
|
||||
.sharpen({ sigma: 1.5 })
|
||||
.jpeg({ quality: 90 })
|
||||
.toBuffer()
|
||||
|
||||
return {
|
||||
base64: processed.toString('base64'),
|
||||
mimeType: 'image/jpeg',
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
|
||||
const log = createLogger('ai-usage')
|
||||
|
||||
interface TokenUsage {
|
||||
inputTokens: number
|
||||
outputTokens: number
|
||||
model: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Track AI token usage. Non-blocking — errors are logged, not thrown.
|
||||
*/
|
||||
export function trackTokenUsage(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
extensionId: string,
|
||||
usage: TokenUsage,
|
||||
companyId?: string
|
||||
): void {
|
||||
supabase
|
||||
.from('ai_usage_tracking')
|
||||
.insert({
|
||||
user_id: userId,
|
||||
company_id: companyId,
|
||||
extension_id: extensionId,
|
||||
model: usage.model,
|
||||
input_tokens: usage.inputTokens,
|
||||
output_tokens: usage.outputTokens,
|
||||
})
|
||||
.then(({ error }) => {
|
||||
if (error) {
|
||||
log.error(`Failed to track usage for ${extensionId}:`, error.message)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
/**
|
||||
* Validation Helpers for AI Extraction Results
|
||||
*
|
||||
* Shared validation functions used by receipt-analyzer and invoice-analyzer
|
||||
* to sanitize and validate AI-extracted field values.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Validate and trim a string value.
|
||||
* Returns null for empty/non-string values.
|
||||
*/
|
||||
export function validateString(value: unknown): string | null {
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
return value.trim()
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a numeric value.
|
||||
* Handles both number and string inputs, stripping non-numeric characters.
|
||||
* Returns null for invalid values.
|
||||
*/
|
||||
export function validateNumber(value: unknown): number | null {
|
||||
if (typeof value === 'number' && !isNaN(value)) {
|
||||
return value
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const parsed = parseFloat(value.replace(/[^\d.-]/g, ''))
|
||||
if (!isNaN(parsed)) {
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and normalize a date to ISO format (YYYY-MM-DD).
|
||||
* Returns null for invalid dates.
|
||||
*/
|
||||
export function validateDate(value: unknown): string | null {
|
||||
if (typeof value !== 'string') return null
|
||||
|
||||
const date = new Date(value)
|
||||
if (isNaN(date.getTime())) return null
|
||||
|
||||
return date.toISOString().split('T')[0]
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and normalize a time value to HH:MM format.
|
||||
* Returns null for invalid times.
|
||||
*/
|
||||
export function validateTime(value: unknown): string | null {
|
||||
if (typeof value !== 'string') return null
|
||||
|
||||
// Match HH:MM or HH:MM:SS
|
||||
const match = value.match(/^(\d{2}):(\d{2})(:\d{2})?$/)
|
||||
if (match) {
|
||||
return `${match[1]}:${match[2]}`
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and normalize a Swedish org number (XXXXXX-XXXX).
|
||||
* Swedish org numbers are 10 digits.
|
||||
* Returns null for invalid values.
|
||||
*/
|
||||
export function validateOrgNumber(value: unknown): string | null {
|
||||
if (typeof value !== 'string') return null
|
||||
|
||||
const digits = value.replace(/\D/g, '')
|
||||
|
||||
// Swedish org numbers are 10 digits
|
||||
if (digits.length === 10) {
|
||||
return `${digits.slice(0, 6)}-${digits.slice(6)}`
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a Swedish VAT number (SE prefix, at least 12 chars).
|
||||
* Returns null for invalid values.
|
||||
*/
|
||||
export function validateVatNumber(value: unknown): string | null {
|
||||
if (typeof value !== 'string') return null
|
||||
|
||||
const cleaned = value.trim().toUpperCase()
|
||||
if (cleaned.startsWith('SE') && cleaned.length >= 12) {
|
||||
return cleaned
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an org number is in Swedish format (10 digits).
|
||||
*/
|
||||
export function isSwedishOrgNumber(value: string): boolean {
|
||||
const digits = value.replace(/\D/g, '')
|
||||
return digits.length === 10
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a 4-digit BAS account number (1000-9999).
|
||||
* Returns null for invalid values.
|
||||
*/
|
||||
export function validateAccountNumber(value: string | undefined | null): string | null {
|
||||
if (!value) return null
|
||||
const digits = value.replace(/\D/g, '')
|
||||
if (digits.length === 4 && parseInt(digits) >= 1000 && parseInt(digits) <= 9999) {
|
||||
return digits
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
/**
|
||||
* Shared Vision Client for Claude Haiku
|
||||
*
|
||||
* SERVER-ONLY: Uses the Anthropic SDK.
|
||||
*
|
||||
* Consolidates the duplicated Anthropic SDK logic from classifier.ts,
|
||||
* receipt-analyzer.ts, and invoice-analyzer.ts into one shared module.
|
||||
*/
|
||||
|
||||
import 'server-only'
|
||||
import Anthropic from '@anthropic-ai/sdk'
|
||||
import { preprocessImage } from './preprocess-image'
|
||||
|
||||
const anthropic = new Anthropic()
|
||||
|
||||
const MAX_RETRIES = 3
|
||||
const RETRY_DELAY_MS = 1000
|
||||
|
||||
type ImageMediaType = 'image/jpeg' | 'image/png' | 'image/webp' | 'image/gif'
|
||||
|
||||
export interface VisionRequest {
|
||||
base64: string
|
||||
mimeType: string
|
||||
systemPrompt: string
|
||||
userPrompt: string
|
||||
maxTokens?: number
|
||||
preprocess?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Call Claude Haiku Vision with retry logic.
|
||||
* Handles PDF vs image content blocks, preprocessing, retries,
|
||||
* and JSON fence stripping.
|
||||
*
|
||||
* Returns parsed JSON from the model response.
|
||||
*/
|
||||
export async function callVision(request: VisionRequest): Promise<unknown> {
|
||||
const {
|
||||
systemPrompt,
|
||||
userPrompt,
|
||||
maxTokens = 4096,
|
||||
preprocess = true,
|
||||
} = request
|
||||
|
||||
let { base64, mimeType } = request
|
||||
|
||||
const isPdf = mimeType === 'application/pdf'
|
||||
const isImage = mimeType.startsWith('image/')
|
||||
|
||||
if (!isPdf && !isImage) {
|
||||
throw new Error(`Unsupported file type: ${mimeType}`)
|
||||
}
|
||||
|
||||
// Preprocess images (not PDFs) unless explicitly disabled
|
||||
if (preprocess && !isPdf) {
|
||||
const preprocessed = await preprocessImage(base64, mimeType)
|
||||
base64 = preprocessed.base64
|
||||
mimeType = preprocessed.mimeType
|
||||
}
|
||||
|
||||
let lastError: Error | null = null
|
||||
|
||||
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
const contentBlocks: Anthropic.MessageCreateParams['messages'][0]['content'] = isPdf
|
||||
? [
|
||||
{
|
||||
type: 'document' as const,
|
||||
source: {
|
||||
type: 'base64' as const,
|
||||
media_type: 'application/pdf' as const,
|
||||
data: base64,
|
||||
},
|
||||
},
|
||||
{ type: 'text' as const, text: userPrompt },
|
||||
]
|
||||
: [
|
||||
{
|
||||
type: 'image' as const,
|
||||
source: {
|
||||
type: 'base64' as const,
|
||||
media_type: mimeType as ImageMediaType,
|
||||
data: base64,
|
||||
},
|
||||
},
|
||||
{ type: 'text' as const, text: userPrompt },
|
||||
]
|
||||
|
||||
const message = await anthropic.messages.create({
|
||||
model: 'claude-haiku-4-5-20251001',
|
||||
max_tokens: maxTokens,
|
||||
messages: [{ role: 'user', content: contentBlocks }],
|
||||
system: systemPrompt,
|
||||
})
|
||||
|
||||
const content = message.content[0]
|
||||
if (content.type !== 'text') {
|
||||
throw new Error('Unexpected response type from AI')
|
||||
}
|
||||
|
||||
const jsonText = stripJsonFences(content.text)
|
||||
return JSON.parse(jsonText)
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error : new Error('Unknown error')
|
||||
|
||||
// Don't retry on JSON parse errors
|
||||
if (error instanceof SyntaxError) {
|
||||
throw new Error(`Failed to parse AI response: ${lastError.message}`)
|
||||
}
|
||||
|
||||
if (attempt < MAX_RETRIES - 1) {
|
||||
await sleep(RETRY_DELAY_MS * (attempt + 1))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Vision API call failed after ${MAX_RETRIES} attempts: ${lastError?.message}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip markdown JSON fences from AI response text.
|
||||
*/
|
||||
export function stripJsonFences(text: string): string {
|
||||
let result = text.trim()
|
||||
|
||||
if (result.startsWith('```json')) {
|
||||
result = result.slice(7)
|
||||
} else if (result.startsWith('```')) {
|
||||
result = result.slice(3)
|
||||
}
|
||||
if (result.endsWith('```')) {
|
||||
result = result.slice(0, -3)
|
||||
}
|
||||
|
||||
return result.trim()
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
@@ -387,6 +387,16 @@ export const UpdateSettingsSchema = z.object({
|
||||
website: z.string().optional().or(z.literal('')),
|
||||
pays_salaries: z.boolean().optional(),
|
||||
sector_slug: z.string().nullable().optional(),
|
||||
// Bookkeeping lock
|
||||
bookkeeping_locked_through: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Ogiltigt datumformat (YYYY-MM-DD)').nullable().optional(),
|
||||
auto_lock_period_days: z.number().int().positive().nullable().optional(),
|
||||
// Invoice PDF settings
|
||||
ore_rounding: z.boolean().optional(),
|
||||
invoice_show_ocr: z.boolean().optional(),
|
||||
invoice_show_bankgiro: z.boolean().optional(),
|
||||
invoice_show_plusgiro: z.boolean().optional(),
|
||||
invoice_late_fee_text: z.string().nullable().optional(),
|
||||
invoice_credit_terms_text: z.string().nullable().optional(),
|
||||
}).refine(
|
||||
(data) => {
|
||||
// BFL 3 kap.: Enskild firma must have fiscal year starting January
|
||||
@@ -573,3 +583,20 @@ export const PendingOperationsQuerySchema = z.object({
|
||||
limit: z.coerce.number().int().min(1).max(100).default(50),
|
||||
offset: z.coerce.number().int().nonnegative().default(0),
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// Voucher gap schemas
|
||||
// ============================================================
|
||||
|
||||
export const VoucherGapQuerySchema = z.object({
|
||||
fiscal_period_id: uuid,
|
||||
voucher_series: z.string().optional(),
|
||||
})
|
||||
|
||||
export const SaveGapExplanationSchema = z.object({
|
||||
fiscal_period_id: uuid,
|
||||
voucher_series: z.string().default('A'),
|
||||
gap_start: z.number().int().positive(),
|
||||
gap_end: z.number().int().positive(),
|
||||
explanation: z.string().min(1).max(500),
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -69,23 +69,33 @@ beforeEach(() => {
|
||||
})
|
||||
|
||||
describe('validateYearEndReadiness', () => {
|
||||
// Helper: standard results for no-gap, single-series validation
|
||||
function noGapResults(period: ReturnType<typeof makeFiscalPeriod>, overrides: {
|
||||
draftCount?: number
|
||||
postedCount?: number
|
||||
revalCount?: number
|
||||
fxReceivables?: number
|
||||
fxPayables?: number
|
||||
} = {}) {
|
||||
return [
|
||||
{ data: period, error: null }, // fetch period (.single)
|
||||
{ data: null, error: null, count: overrides.draftCount ?? 0 }, // count drafts (thenable)
|
||||
{ data: [{ voucher_series: 'A' }], error: null }, // voucher_sequences (thenable)
|
||||
{ data: [], error: null }, // detect_voucher_gaps RPC
|
||||
// no gaps → gap_explanations query skipped
|
||||
{ data: { last_number: 10 }, error: null }, // reconciliation: voucher_sequences.last_number (.single)
|
||||
{ data: { voucher_number: 10 }, error: null }, // reconciliation: journal_entries max (.maybeSingle)
|
||||
// trial balance mocked separately
|
||||
{ data: null, error: null, count: overrides.postedCount ?? 5 }, // count posted (thenable)
|
||||
{ data: null, error: null, count: overrides.revalCount ?? 0 }, // count revaluation (thenable)
|
||||
{ data: null, error: null, count: overrides.fxReceivables ?? 0 }, // fx receivables (thenable)
|
||||
{ data: null, error: null, count: overrides.fxPayables ?? 0 }, // fx payables (thenable)
|
||||
]
|
||||
}
|
||||
|
||||
it('returns errors when drafts exist', async () => {
|
||||
const period = makeFiscalPeriod({ id: 'fp-1', is_closed: false, closing_entry_id: null })
|
||||
|
||||
results = [
|
||||
// 0: fetch period (.single)
|
||||
{ data: period, error: null },
|
||||
// 1: count drafts (thenable chain) — count: 3
|
||||
{ data: null, error: null, count: 3 },
|
||||
// 2: count posted entries (thenable chain) — count: 10
|
||||
{ data: null, error: null, count: 10 },
|
||||
// 3: count revaluation entries — count: 0
|
||||
{ data: null, error: null, count: 0 },
|
||||
// 4: count fx receivables — count: 0
|
||||
{ data: null, error: null, count: 0 },
|
||||
// 5: count fx payables — count: 0
|
||||
{ data: null, error: null, count: 0 },
|
||||
]
|
||||
results = noGapResults(period, { draftCount: 3, postedCount: 10 })
|
||||
|
||||
vi.mocked(generateTrialBalance).mockResolvedValue({
|
||||
rows: [],
|
||||
@@ -102,15 +112,7 @@ describe('validateYearEndReadiness', () => {
|
||||
|
||||
it('returns errors when trial balance is unbalanced', async () => {
|
||||
const period = makeFiscalPeriod({ id: 'fp-1', is_closed: false, closing_entry_id: null })
|
||||
|
||||
results = [
|
||||
{ data: period, error: null },
|
||||
{ data: null, error: null, count: 0 }, // no drafts
|
||||
{ data: null, error: null, count: 5 }, // some posted
|
||||
{ data: null, error: null, count: 0 }, // no revaluation
|
||||
{ data: null, error: null, count: 0 }, // no fx receivables
|
||||
{ data: null, error: null, count: 0 }, // no fx payables
|
||||
]
|
||||
results = noGapResults(period)
|
||||
|
||||
vi.mocked(generateTrialBalance).mockResolvedValue({
|
||||
rows: [],
|
||||
@@ -126,10 +128,31 @@ describe('validateYearEndReadiness', () => {
|
||||
expect(result.errors.some((e: string) => e.includes('Trial balance'))).toBe(true)
|
||||
})
|
||||
|
||||
it('warns on voucher gaps', async () => {
|
||||
it('returns error when period has not yet ended', async () => {
|
||||
const period = makeFiscalPeriod({
|
||||
id: 'fp-1',
|
||||
is_closed: false,
|
||||
closing_entry_id: null,
|
||||
period_end: '2099-12-31',
|
||||
})
|
||||
results = noGapResults(period)
|
||||
|
||||
vi.mocked(generateTrialBalance).mockResolvedValue({
|
||||
rows: [],
|
||||
isBalanced: true,
|
||||
totalDebit: 0,
|
||||
totalCredit: 0,
|
||||
} as never)
|
||||
|
||||
const supabase = makeClient()
|
||||
const result = await validateYearEndReadiness(supabase as never, 'company-1', 'user-1', 'fp-1')
|
||||
expect(result.ready).toBe(false)
|
||||
expect(result.errors.some((e: string) => e.includes('not yet ended'))).toBe(true)
|
||||
})
|
||||
|
||||
it('warns on explained voucher gaps', async () => {
|
||||
const period = makeFiscalPeriod({ id: 'fp-1', is_closed: false, closing_entry_id: null })
|
||||
|
||||
// Create a client with custom rpc that returns gap data
|
||||
const builder = makeBuilder()
|
||||
const supabase = {
|
||||
from: vi.fn().mockImplementation(() => builder),
|
||||
@@ -141,12 +164,17 @@ describe('validateYearEndReadiness', () => {
|
||||
|
||||
resultIdx = 0
|
||||
results = [
|
||||
{ data: period, error: null },
|
||||
{ data: null, error: null, count: 0 }, // no drafts
|
||||
{ data: null, error: null, count: 5 }, // some posted
|
||||
{ data: null, error: null, count: 0 }, // no revaluation
|
||||
{ data: null, error: null, count: 0 }, // no fx receivables
|
||||
{ data: null, error: null, count: 0 }, // no fx payables
|
||||
{ data: period, error: null }, // fetch period
|
||||
{ data: null, error: null, count: 0 }, // count drafts
|
||||
{ data: [{ voucher_series: 'A' }], error: null }, // voucher_sequences
|
||||
// rpc for detect_voucher_gaps handled by custom mock
|
||||
{ data: [{ voucher_series: 'A', gap_start: 5, gap_end: 7 }], error: null }, // gap_explanations
|
||||
{ data: { last_number: 10 }, error: null }, // reconciliation: last_number
|
||||
{ data: { voucher_number: 10 }, error: null }, // reconciliation: max voucher
|
||||
{ data: null, error: null, count: 5 }, // count posted
|
||||
{ data: null, error: null, count: 0 }, // count revaluation
|
||||
{ data: null, error: null, count: 0 }, // fx receivables
|
||||
{ data: null, error: null, count: 0 }, // fx payables
|
||||
]
|
||||
|
||||
vi.mocked(generateTrialBalance).mockResolvedValue({
|
||||
@@ -157,8 +185,164 @@ describe('validateYearEndReadiness', () => {
|
||||
} as never)
|
||||
|
||||
const result = await validateYearEndReadiness(supabase as never, 'company-1', 'user-1', 'fp-1')
|
||||
expect(result.warnings.some((w: string) => w.includes('gap'))).toBe(true)
|
||||
expect(result.warnings.some((w: string) => w.includes('documented'))).toBe(true)
|
||||
expect(result.voucherGaps).toHaveLength(1)
|
||||
expect(result.voucherGaps[0].series).toBe('A')
|
||||
expect(result.unexplainedGaps).toHaveLength(0)
|
||||
expect(result.ready).toBe(true)
|
||||
})
|
||||
|
||||
it('blocks on unexplained voucher gaps', async () => {
|
||||
const period = makeFiscalPeriod({ id: 'fp-1', is_closed: false, closing_entry_id: null })
|
||||
|
||||
const builder = makeBuilder()
|
||||
const supabase = {
|
||||
from: vi.fn().mockImplementation(() => builder),
|
||||
rpc: vi.fn().mockResolvedValue({
|
||||
data: [{ gap_start: 5, gap_end: 7 }],
|
||||
error: null,
|
||||
}),
|
||||
}
|
||||
|
||||
resultIdx = 0
|
||||
results = [
|
||||
{ data: period, error: null }, // fetch period
|
||||
{ data: null, error: null, count: 0 }, // count drafts
|
||||
{ data: [{ voucher_series: 'A' }], error: null }, // voucher_sequences
|
||||
// rpc for detect_voucher_gaps handled by custom mock
|
||||
{ data: [], error: null }, // gap_explanations — empty
|
||||
{ data: { last_number: 10 }, error: null }, // reconciliation: last_number
|
||||
{ data: { voucher_number: 10 }, error: null }, // reconciliation: max voucher
|
||||
{ data: null, error: null, count: 5 }, // count posted
|
||||
{ data: null, error: null, count: 0 }, // count revaluation
|
||||
{ data: null, error: null, count: 0 }, // fx receivables
|
||||
{ data: null, error: null, count: 0 }, // fx payables
|
||||
]
|
||||
|
||||
vi.mocked(generateTrialBalance).mockResolvedValue({
|
||||
rows: [],
|
||||
isBalanced: true,
|
||||
totalDebit: 10000,
|
||||
totalCredit: 10000,
|
||||
} as never)
|
||||
|
||||
const result = await validateYearEndReadiness(supabase as never, 'company-1', 'user-1', 'fp-1')
|
||||
expect(result.ready).toBe(false)
|
||||
expect(result.errors.some((e: string) => e.includes('Unexplained voucher gap'))).toBe(true)
|
||||
expect(result.unexplainedGaps).toHaveLength(1)
|
||||
expect(result.unexplainedGaps[0]).toEqual({ gap_start: 5, gap_end: 7, series: 'A' })
|
||||
})
|
||||
|
||||
it('detects gaps across multiple voucher series', async () => {
|
||||
const period = makeFiscalPeriod({ id: 'fp-1', is_closed: false, closing_entry_id: null })
|
||||
|
||||
const builder = makeBuilder()
|
||||
let rpcCallCount = 0
|
||||
const supabase = {
|
||||
from: vi.fn().mockImplementation(() => builder),
|
||||
rpc: vi.fn().mockImplementation(() => {
|
||||
rpcCallCount++
|
||||
if (rpcCallCount === 1) {
|
||||
return Promise.resolve({ data: [{ gap_start: 3, gap_end: 3 }], error: null })
|
||||
}
|
||||
return Promise.resolve({ data: [{ gap_start: 1, gap_end: 2 }], error: null })
|
||||
}),
|
||||
}
|
||||
|
||||
resultIdx = 0
|
||||
results = [
|
||||
{ data: period, error: null }, // fetch period
|
||||
{ data: null, error: null, count: 0 }, // count drafts
|
||||
{ data: [{ voucher_series: 'A' }, { voucher_series: 'B' }], error: null }, // voucher_sequences
|
||||
// rpc calls handled by custom mock
|
||||
{ data: [], error: null }, // gap_explanations — empty
|
||||
{ data: { last_number: 5 }, error: null }, // reconciliation A: last_number
|
||||
{ data: { voucher_number: 5 }, error: null }, // reconciliation A: max voucher
|
||||
{ data: { last_number: 3 }, error: null }, // reconciliation B: last_number
|
||||
{ data: { voucher_number: 3 }, error: null }, // reconciliation B: max voucher
|
||||
{ data: null, error: null, count: 5 }, // count posted
|
||||
{ data: null, error: null, count: 0 }, // count revaluation
|
||||
{ data: null, error: null, count: 0 }, // fx receivables
|
||||
{ data: null, error: null, count: 0 }, // fx payables
|
||||
]
|
||||
|
||||
vi.mocked(generateTrialBalance).mockResolvedValue({
|
||||
rows: [],
|
||||
isBalanced: true,
|
||||
totalDebit: 10000,
|
||||
totalCredit: 10000,
|
||||
} as never)
|
||||
|
||||
const result = await validateYearEndReadiness(supabase as never, 'company-1', 'user-1', 'fp-1')
|
||||
expect(result.voucherGaps).toHaveLength(2)
|
||||
expect(result.voucherGaps[0]).toEqual({ gap_start: 3, gap_end: 3, series: 'A' })
|
||||
expect(result.voucherGaps[1]).toEqual({ gap_start: 1, gap_end: 2, series: 'B' })
|
||||
expect(result.unexplainedGaps).toHaveLength(2)
|
||||
expect(result.errors.some((e: string) => e.includes('series A'))).toBe(true)
|
||||
expect(result.errors.some((e: string) => e.includes('series B'))).toBe(true)
|
||||
})
|
||||
|
||||
it('detects sequence counter mismatch (counter < actual)', async () => {
|
||||
const period = makeFiscalPeriod({ id: 'fp-1', is_closed: false, closing_entry_id: null })
|
||||
|
||||
results = [
|
||||
{ data: period, error: null }, // fetch period
|
||||
{ data: null, error: null, count: 0 }, // count drafts
|
||||
{ data: [{ voucher_series: 'A' }], error: null }, // voucher_sequences
|
||||
{ data: [], error: null }, // detect_voucher_gaps RPC
|
||||
// no gaps → gap_explanations skipped
|
||||
{ data: { last_number: 5 }, error: null }, // reconciliation: last_number (counter behind!)
|
||||
{ data: { voucher_number: 10 }, error: null }, // reconciliation: max voucher
|
||||
{ data: null, error: null, count: 5 }, // count posted
|
||||
{ data: null, error: null, count: 0 }, // count revaluation
|
||||
{ data: null, error: null, count: 0 }, // fx receivables
|
||||
{ data: null, error: null, count: 0 }, // fx payables
|
||||
]
|
||||
|
||||
vi.mocked(generateTrialBalance).mockResolvedValue({
|
||||
rows: [],
|
||||
isBalanced: true,
|
||||
totalDebit: 10000,
|
||||
totalCredit: 10000,
|
||||
} as never)
|
||||
|
||||
const supabase = makeClient()
|
||||
const result = await validateYearEndReadiness(supabase as never, 'company-1', 'user-1', 'fp-1')
|
||||
expect(result.ready).toBe(false)
|
||||
expect(result.errors.some((e: string) => e.includes('Sequence counter integrity error'))).toBe(true)
|
||||
expect(result.sequenceMismatches).toHaveLength(1)
|
||||
expect(result.sequenceMismatches[0]).toEqual({ series: 'A', sequenceCounter: 5, actualMax: 10 })
|
||||
})
|
||||
|
||||
it('warns when sequence counter is ahead of actual (burned numbers)', async () => {
|
||||
const period = makeFiscalPeriod({ id: 'fp-1', is_closed: false, closing_entry_id: null })
|
||||
|
||||
results = [
|
||||
{ data: period, error: null }, // fetch period
|
||||
{ data: null, error: null, count: 0 }, // count drafts
|
||||
{ data: [{ voucher_series: 'A' }], error: null }, // voucher_sequences
|
||||
{ data: [], error: null }, // detect_voucher_gaps RPC
|
||||
// no gaps → gap_explanations skipped
|
||||
{ data: { last_number: 12 }, error: null }, // reconciliation: last_number (counter ahead)
|
||||
{ data: { voucher_number: 10 }, error: null }, // reconciliation: max voucher
|
||||
{ data: null, error: null, count: 5 }, // count posted
|
||||
{ data: null, error: null, count: 0 }, // count revaluation
|
||||
{ data: null, error: null, count: 0 }, // fx receivables
|
||||
{ data: null, error: null, count: 0 }, // fx payables
|
||||
]
|
||||
|
||||
vi.mocked(generateTrialBalance).mockResolvedValue({
|
||||
rows: [],
|
||||
isBalanced: true,
|
||||
totalDebit: 10000,
|
||||
totalCredit: 10000,
|
||||
} as never)
|
||||
|
||||
const supabase = makeClient()
|
||||
const result = await validateYearEndReadiness(supabase as never, 'company-1', 'user-1', 'fp-1')
|
||||
expect(result.ready).toBe(true) // warning, not blocking
|
||||
expect(result.warnings.some((w: string) => w.includes('Sequence counter ahead'))).toBe(true)
|
||||
expect(result.sequenceMismatches).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
previewCurrencyRevaluation,
|
||||
executeCurrencyRevaluation,
|
||||
} from '@/lib/bookkeeping/currency-revaluation'
|
||||
import { validateBalanceContinuity } from '@/lib/reports/continuity-check'
|
||||
import type {
|
||||
YearEndValidation,
|
||||
YearEndPreview,
|
||||
@@ -16,6 +17,7 @@ import type {
|
||||
FiscalPeriod,
|
||||
JournalEntry,
|
||||
VoucherGap,
|
||||
SequenceMismatch,
|
||||
} from '@/types'
|
||||
|
||||
/**
|
||||
@@ -46,10 +48,18 @@ export async function validateYearEndReadiness(
|
||||
warnings: [],
|
||||
draftCount: 0,
|
||||
voucherGaps: [],
|
||||
unexplainedGaps: [],
|
||||
sequenceMismatches: [],
|
||||
trialBalanceBalanced: false,
|
||||
}
|
||||
}
|
||||
|
||||
// Check: period must have ended (BFNAR 2017:3 / ÅRL 2:1)
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
if (period.period_end > today) {
|
||||
errors.push('Cannot close a fiscal period that has not yet ended')
|
||||
}
|
||||
|
||||
// Check: period not already closed
|
||||
if (period.is_closed) {
|
||||
errors.push('Period is already closed')
|
||||
@@ -73,19 +83,109 @@ export async function validateYearEndReadiness(
|
||||
errors.push(`${drafts} draft journal entries must be posted or deleted before closing`)
|
||||
}
|
||||
|
||||
// Check: voucher continuity
|
||||
// Check: voucher continuity across all series
|
||||
let voucherGaps: VoucherGap[] = []
|
||||
const { data: gaps, error: gapsError } = await supabase.rpc('detect_voucher_gaps', {
|
||||
p_company_id: companyId,
|
||||
p_fiscal_period_id: fiscalPeriodId,
|
||||
p_series: 'A',
|
||||
})
|
||||
const { data: seriesRows } = await supabase
|
||||
.from('voucher_sequences')
|
||||
.select('voucher_series')
|
||||
.eq('company_id', companyId)
|
||||
.eq('fiscal_period_id', fiscalPeriodId)
|
||||
|
||||
if (!gapsError && gaps && gaps.length > 0) {
|
||||
voucherGaps = gaps as VoucherGap[]
|
||||
warnings.push(
|
||||
`Voucher number gaps detected: ${voucherGaps.map((g) => `${g.gap_start}-${g.gap_end}`).join(', ')}`
|
||||
const seriesToCheck = seriesRows && seriesRows.length > 0
|
||||
? seriesRows.map((r: { voucher_series: string }) => r.voucher_series)
|
||||
: ['A']
|
||||
|
||||
for (const series of seriesToCheck) {
|
||||
const { data: gaps, error: gapsError } = await supabase.rpc('detect_voucher_gaps', {
|
||||
p_company_id: companyId,
|
||||
p_fiscal_period_id: fiscalPeriodId,
|
||||
p_series: series,
|
||||
})
|
||||
|
||||
if (!gapsError && gaps && gaps.length > 0) {
|
||||
const tagged = (gaps as Array<{ gap_start: number; gap_end: number }>).map((g) => ({
|
||||
...g,
|
||||
series,
|
||||
}))
|
||||
voucherGaps.push(...tagged)
|
||||
}
|
||||
}
|
||||
|
||||
// Check gap explanations — unexplained gaps block year-end (BFNAR 2013:2 punkt 5.8)
|
||||
let unexplainedGaps: VoucherGap[] = []
|
||||
if (voucherGaps.length > 0) {
|
||||
const { data: explanations } = await supabase
|
||||
.from('voucher_gap_explanations')
|
||||
.select('voucher_series, gap_start, gap_end')
|
||||
.eq('company_id', companyId)
|
||||
.eq('fiscal_period_id', fiscalPeriodId)
|
||||
|
||||
const explanationSet = new Set(
|
||||
(explanations ?? []).map(
|
||||
(e: { voucher_series: string; gap_start: number; gap_end: number }) =>
|
||||
`${e.voucher_series}:${e.gap_start}:${e.gap_end}`
|
||||
)
|
||||
)
|
||||
|
||||
for (const gap of voucherGaps) {
|
||||
const key = `${gap.series}:${gap.gap_start}:${gap.gap_end}`
|
||||
if (explanationSet.has(key)) {
|
||||
warnings.push(
|
||||
`Voucher gap in series ${gap.series} (${gap.gap_start}-${gap.gap_end}) — documented`
|
||||
)
|
||||
} else {
|
||||
unexplainedGaps.push(gap)
|
||||
errors.push(
|
||||
`Unexplained voucher gap in series ${gap.series}: ${gap.gap_start}-${gap.gap_end}`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check: sequence counter reconciliation
|
||||
const sequenceMismatches: SequenceMismatch[] = []
|
||||
if (seriesRows && seriesRows.length > 0) {
|
||||
for (const row of seriesRows as Array<{ voucher_series: string }>) {
|
||||
const { data: seqData } = await supabase
|
||||
.from('voucher_sequences')
|
||||
.select('last_number')
|
||||
.eq('company_id', companyId)
|
||||
.eq('fiscal_period_id', fiscalPeriodId)
|
||||
.eq('voucher_series', row.voucher_series)
|
||||
.single()
|
||||
|
||||
const { data: maxData } = await supabase
|
||||
.from('journal_entries')
|
||||
.select('voucher_number')
|
||||
.eq('company_id', companyId)
|
||||
.eq('fiscal_period_id', fiscalPeriodId)
|
||||
.eq('voucher_series', row.voucher_series)
|
||||
.neq('status', 'draft')
|
||||
.order('voucher_number', { ascending: false })
|
||||
.limit(1)
|
||||
.maybeSingle()
|
||||
|
||||
const sequenceCounter = seqData?.last_number ?? 0
|
||||
const actualMax = maxData?.voucher_number ?? 0
|
||||
|
||||
if (sequenceCounter !== actualMax) {
|
||||
sequenceMismatches.push({
|
||||
series: row.voucher_series,
|
||||
sequenceCounter,
|
||||
actualMax,
|
||||
})
|
||||
|
||||
if (sequenceCounter < actualMax) {
|
||||
errors.push(
|
||||
`Sequence counter integrity error in series ${row.voucher_series}: counter=${sequenceCounter} but max voucher=${actualMax}`
|
||||
)
|
||||
} else {
|
||||
warnings.push(
|
||||
`Sequence counter ahead of actual entries in series ${row.voucher_series}: counter=${sequenceCounter}, max voucher=${actualMax}`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check: trial balance is balanced
|
||||
@@ -144,12 +244,19 @@ export async function validateYearEndReadiness(
|
||||
}
|
||||
}
|
||||
|
||||
// Check: continuity_verified flag from prior year-end
|
||||
if (period.continuity_verified === false) {
|
||||
errors.push('Opening balance continuity check failed for this period — resolve discrepancies before closing')
|
||||
}
|
||||
|
||||
return {
|
||||
ready: errors.length === 0,
|
||||
errors,
|
||||
warnings,
|
||||
draftCount: drafts,
|
||||
voucherGaps,
|
||||
unexplainedGaps,
|
||||
sequenceMismatches,
|
||||
trialBalanceBalanced,
|
||||
}
|
||||
}
|
||||
@@ -374,6 +481,24 @@ export async function executeYearEndClosing(
|
||||
nextPeriod.id
|
||||
)
|
||||
|
||||
// 10. Validate IB/UB continuity and persist result
|
||||
const continuity = await validateBalanceContinuity(supabase, companyId, nextPeriod.id)
|
||||
|
||||
await supabase
|
||||
.from('fiscal_periods')
|
||||
.update({ continuity_verified: continuity.valid })
|
||||
.eq('id', nextPeriod.id)
|
||||
.eq('company_id', companyId)
|
||||
|
||||
if (!continuity.valid) {
|
||||
throw new Error(
|
||||
`IB/UB continuity check failed: ${continuity.discrepancies.length} account(s) differ. ` +
|
||||
continuity.discrepancies.map(d =>
|
||||
`${d.account_number}: UB=${d.previous_ub_net}, IB=${d.current_ib_net}, diff=${d.difference}`
|
||||
).join('; ')
|
||||
)
|
||||
}
|
||||
|
||||
// Fetch the now-closed period for the event payload
|
||||
const { data: closedPeriod } = await supabase
|
||||
.from('fiscal_periods')
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
// Mock server-only
|
||||
vi.mock('server-only', () => ({}))
|
||||
|
||||
// Mock the core document-analyzer module
|
||||
const { mockClassify } = vi.hoisted(() => ({
|
||||
mockClassify: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/ai/document-analyzer', () => ({
|
||||
classifyDocument: mockClassify,
|
||||
}))
|
||||
|
||||
import { classifyDocument } from '../classifier'
|
||||
|
||||
describe('classifyDocument', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('classifies a supplier invoice', async () => {
|
||||
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')
|
||||
|
||||
expect(result.type).toBe('supplier_invoice')
|
||||
expect(result.confidence).toBe(0.95)
|
||||
expect(result.isReverseCharge).toBe(false)
|
||||
})
|
||||
|
||||
it('classifies a receipt', async () => {
|
||||
mockClassify.mockResolvedValueOnce({
|
||||
type: 'receipt',
|
||||
confidence: 0.92,
|
||||
reasoning: 'Store receipt with line items and total',
|
||||
})
|
||||
|
||||
const result = await classifyDocument('base64data', 'image/jpeg')
|
||||
|
||||
expect(result.type).toBe('receipt')
|
||||
expect(result.confidence).toBe(0.92)
|
||||
expect(result.isReverseCharge).toBeUndefined()
|
||||
})
|
||||
|
||||
it('classifies a government letter', async () => {
|
||||
mockClassify.mockResolvedValueOnce({
|
||||
type: 'government_letter',
|
||||
confidence: 0.88,
|
||||
reasoning: 'Letter from Skatteverket',
|
||||
})
|
||||
|
||||
const result = await classifyDocument('base64data', 'application/pdf')
|
||||
|
||||
expect(result.type).toBe('government_letter')
|
||||
expect(result.confidence).toBe(0.88)
|
||||
expect(result.isReverseCharge).toBeUndefined()
|
||||
})
|
||||
|
||||
it('classifies unknown documents', async () => {
|
||||
mockClassify.mockResolvedValueOnce({
|
||||
type: 'unknown',
|
||||
confidence: 0.5,
|
||||
reasoning: 'Cannot determine document type',
|
||||
})
|
||||
|
||||
const result = await classifyDocument('base64data', 'image/png')
|
||||
|
||||
expect(result.type).toBe('unknown')
|
||||
expect(result.confidence).toBe(0.5)
|
||||
})
|
||||
|
||||
it('detects reverse charge on EU invoices', async () => {
|
||||
mockClassify.mockResolvedValueOnce({
|
||||
type: 'supplier_invoice',
|
||||
confidence: 0.93,
|
||||
reasoning: 'EU invoice with reverse charge',
|
||||
isReverseCharge: true,
|
||||
})
|
||||
|
||||
const result = await classifyDocument('base64data', 'application/pdf')
|
||||
|
||||
expect(result.type).toBe('supplier_invoice')
|
||||
expect(result.isReverseCharge).toBe(true)
|
||||
})
|
||||
|
||||
it('delegates to core classify', async () => {
|
||||
mockClassify.mockResolvedValueOnce({
|
||||
type: 'receipt',
|
||||
confidence: 0.9,
|
||||
reasoning: 'Receipt',
|
||||
})
|
||||
|
||||
await classifyDocument('base64data', 'image/jpeg')
|
||||
|
||||
expect(mockClassify).toHaveBeenCalledWith('base64data', 'image/jpeg')
|
||||
})
|
||||
|
||||
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'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,27 +0,0 @@
|
||||
/**
|
||||
* Document Classifier — thin wrapper around lib/ai/document-analyzer.
|
||||
*
|
||||
* SERVER-ONLY: delegates to the shared vision client.
|
||||
*
|
||||
* Classifies documents as supplier invoices, receipts, government letters,
|
||||
* or unknown. Also detects EU reverse charge for supplier invoices.
|
||||
*
|
||||
* This module preserves the original public API — existing callers see no change.
|
||||
*/
|
||||
|
||||
import 'server-only'
|
||||
import { classifyDocument as classifyCore } from '@/lib/ai/document-analyzer'
|
||||
|
||||
// Re-export the type so existing imports work
|
||||
export type { DocumentClassification } from '@/lib/ai/document-analyzer'
|
||||
|
||||
/**
|
||||
* Classify a document using Claude Haiku Vision.
|
||||
* Determines if it's a supplier invoice, receipt, government letter, or unknown.
|
||||
*/
|
||||
export async function classifyDocument(
|
||||
base64: string,
|
||||
mimeType: string
|
||||
) {
|
||||
return classifyCore(base64, mimeType)
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { createMockSupabase } from '@/tests/helpers'
|
||||
import {
|
||||
hasAiConsent,
|
||||
grantAiConsent,
|
||||
revokeAiConsent,
|
||||
isAiExtension,
|
||||
CURRENT_CONSENT_VERSION,
|
||||
} from '../ai-consent'
|
||||
|
||||
describe('ai-consent', () => {
|
||||
let supabase: ReturnType<typeof createMockSupabase>['supabase']
|
||||
let mockResult: ReturnType<typeof createMockSupabase>['mockResult']
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
const mock = createMockSupabase()
|
||||
supabase = mock.supabase
|
||||
mockResult = mock.mockResult
|
||||
})
|
||||
|
||||
describe('isAiExtension', () => {
|
||||
it('returns true for AI extensions', () => {
|
||||
expect(isAiExtension('receipt-ocr')).toBe(true)
|
||||
expect(isAiExtension('ai-categorization')).toBe(true)
|
||||
expect(isAiExtension('ai-chat')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for non-AI extensions', () => {
|
||||
expect(isAiExtension('enable-banking')).toBe(false)
|
||||
expect(isAiExtension('email')).toBe(false)
|
||||
expect(isAiExtension('calendar')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('hasAiConsent', () => {
|
||||
it('returns true for non-AI extensions without checking DB', async () => {
|
||||
const result = await hasAiConsent(supabase as any, 'company-1', 'enable-banking')
|
||||
expect(result).toBe(true)
|
||||
expect(supabase.from).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns false when no consent record exists', async () => {
|
||||
mockResult({ data: null })
|
||||
const result = await hasAiConsent(supabase as any, 'company-1', 'receipt-ocr')
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true after consent is granted with current version', async () => {
|
||||
mockResult({
|
||||
data: {
|
||||
value: {
|
||||
consented: true,
|
||||
version: CURRENT_CONSENT_VERSION,
|
||||
granted_at: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
},
|
||||
})
|
||||
const result = await hasAiConsent(supabase as any, 'company-1', 'receipt-ocr')
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false after consent is revoked', async () => {
|
||||
mockResult({
|
||||
data: {
|
||||
value: {
|
||||
consented: false,
|
||||
version: CURRENT_CONSENT_VERSION,
|
||||
revoked_at: '2024-01-02T00:00:00Z',
|
||||
},
|
||||
},
|
||||
})
|
||||
const result = await hasAiConsent(supabase as any, 'company-1', 'receipt-ocr')
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for outdated consent version', async () => {
|
||||
mockResult({
|
||||
data: {
|
||||
value: {
|
||||
consented: true,
|
||||
version: CURRENT_CONSENT_VERSION - 1,
|
||||
granted_at: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
},
|
||||
})
|
||||
const result = await hasAiConsent(supabase as any, 'company-1', 'receipt-ocr')
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('grantAiConsent', () => {
|
||||
it('upserts consent record to extension_data', async () => {
|
||||
mockResult({ data: null, error: null })
|
||||
await grantAiConsent(supabase as any, 'user-1', 'company-1', 'receipt-ocr')
|
||||
|
||||
expect(supabase.from).toHaveBeenCalledWith('extension_data')
|
||||
})
|
||||
|
||||
it('does nothing for non-AI extensions', async () => {
|
||||
await grantAiConsent(supabase as any, 'user-1', 'company-1', 'enable-banking')
|
||||
expect(supabase.from).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('revokeAiConsent', () => {
|
||||
it('upserts revoked consent to extension_data', async () => {
|
||||
mockResult({ data: null, error: null })
|
||||
await revokeAiConsent(supabase as any, 'user-1', 'company-1', 'ai-chat')
|
||||
|
||||
expect(supabase.from).toHaveBeenCalledWith('extension_data')
|
||||
})
|
||||
|
||||
it('does nothing for non-AI extensions', async () => {
|
||||
await revokeAiConsent(supabase as any, 'user-1', 'company-1', 'email')
|
||||
expect(supabase.from).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -83,7 +83,7 @@ describe('createExtensionContext', () => {
|
||||
const { supabase, mockResult } = createMockSupabase()
|
||||
mockResult({ data: { value: { autoOcr: true } }, error: null })
|
||||
|
||||
const ctx = createExtensionContext(supabase as never, 'user-1', 'company-1', 'receipt-ocr')
|
||||
const ctx = createExtensionContext(supabase as never, 'user-1', 'company-1', 'mcp-server')
|
||||
const result = await ctx.settings.get<{ autoOcr: boolean }>('settings')
|
||||
|
||||
expect(result).toEqual({ autoOcr: true })
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
getStatusLabel,
|
||||
getStatusVariant,
|
||||
getConfidenceLabel,
|
||||
formatExtractionSummary,
|
||||
} from '../invoice-inbox-utils'
|
||||
import type { InboxItemStatus } from '@/types'
|
||||
import type { InvoiceExtractionResult } from '@/types'
|
||||
|
||||
describe('getStatusLabel', () => {
|
||||
const cases: [InboxItemStatus, string][] = [
|
||||
['pending', 'Väntar'],
|
||||
['processing', 'Bearbetar'],
|
||||
['ready', 'Klar'],
|
||||
['confirmed', 'Bekräftad'],
|
||||
['rejected', 'Avvisad'],
|
||||
['error', 'Fel'],
|
||||
]
|
||||
|
||||
it.each(cases)('returns "%s" → "%s"', (status, expected) => {
|
||||
expect(getStatusLabel(status)).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getStatusVariant', () => {
|
||||
const cases: [InboxItemStatus, string][] = [
|
||||
['pending', 'secondary'],
|
||||
['processing', 'default'],
|
||||
['ready', 'warning'],
|
||||
['confirmed', 'success'],
|
||||
['rejected', 'destructive'],
|
||||
['error', 'destructive'],
|
||||
]
|
||||
|
||||
it.each(cases)('returns "%s" → "%s"', (status, expected) => {
|
||||
expect(getStatusVariant(status)).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getConfidenceLabel', () => {
|
||||
it('returns unknown for null', () => {
|
||||
expect(getConfidenceLabel(null)).toEqual({ label: 'Okänd', variant: 'outline' })
|
||||
})
|
||||
|
||||
it('returns high for >= 0.9', () => {
|
||||
expect(getConfidenceLabel(0.95)).toEqual({ label: 'Hög', variant: 'success' })
|
||||
expect(getConfidenceLabel(0.9)).toEqual({ label: 'Hög', variant: 'success' })
|
||||
})
|
||||
|
||||
it('returns medium for 0.7-0.89', () => {
|
||||
expect(getConfidenceLabel(0.85)).toEqual({ label: 'Medium', variant: 'warning' })
|
||||
expect(getConfidenceLabel(0.7)).toEqual({ label: 'Medium', variant: 'warning' })
|
||||
})
|
||||
|
||||
it('returns low for < 0.7', () => {
|
||||
expect(getConfidenceLabel(0.5)).toEqual({ label: 'Låg', variant: 'destructive' })
|
||||
expect(getConfidenceLabel(0.0)).toEqual({ label: 'Låg', variant: 'destructive' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatExtractionSummary', () => {
|
||||
it('handles null data', () => {
|
||||
expect(formatExtractionSummary(null)).toEqual({
|
||||
supplierName: '',
|
||||
total: 0,
|
||||
lineCount: 0,
|
||||
currency: 'SEK',
|
||||
})
|
||||
})
|
||||
|
||||
it('handles undefined data', () => {
|
||||
expect(formatExtractionSummary(undefined)).toEqual({
|
||||
supplierName: '',
|
||||
total: 0,
|
||||
lineCount: 0,
|
||||
currency: 'SEK',
|
||||
})
|
||||
})
|
||||
|
||||
it('extracts summary from complete data', () => {
|
||||
const data: InvoiceExtractionResult = {
|
||||
supplier: {
|
||||
name: 'Acme AB',
|
||||
orgNumber: '556123-4567',
|
||||
vatNumber: null,
|
||||
address: null,
|
||||
bankgiro: null,
|
||||
plusgiro: null,
|
||||
},
|
||||
invoice: {
|
||||
invoiceNumber: 'INV-001',
|
||||
invoiceDate: '2025-01-15',
|
||||
dueDate: '2025-02-15',
|
||||
paymentReference: null,
|
||||
currency: 'SEK',
|
||||
},
|
||||
lineItems: [
|
||||
{ description: 'Item 1', quantity: 1, unitPrice: 100, lineTotal: 100, vatRate: 25, accountSuggestion: null },
|
||||
{ description: 'Item 2', quantity: 2, unitPrice: 50, lineTotal: 100, vatRate: 25, accountSuggestion: null },
|
||||
],
|
||||
totals: { subtotal: 200, vatAmount: 50, total: 250 },
|
||||
vatBreakdown: [{ rate: 25, base: 200, amount: 50 }],
|
||||
confidence: 0.92,
|
||||
}
|
||||
|
||||
expect(formatExtractionSummary(data)).toEqual({
|
||||
supplierName: 'Acme AB',
|
||||
total: 250,
|
||||
lineCount: 2,
|
||||
currency: 'SEK',
|
||||
})
|
||||
})
|
||||
|
||||
it('handles null supplier name', () => {
|
||||
const data: InvoiceExtractionResult = {
|
||||
supplier: { name: null, orgNumber: null, vatNumber: null, address: null, bankgiro: null, plusgiro: null },
|
||||
invoice: { invoiceNumber: null, invoiceDate: null, dueDate: null, paymentReference: null, currency: 'SEK' },
|
||||
lineItems: [],
|
||||
totals: { subtotal: null, vatAmount: null, total: null },
|
||||
vatBreakdown: [],
|
||||
confidence: 0.5,
|
||||
}
|
||||
|
||||
expect(formatExtractionSummary(data)).toEqual({
|
||||
supplierName: '',
|
||||
total: 0,
|
||||
lineCount: 0,
|
||||
currency: 'SEK',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -49,7 +49,7 @@ describe('sectors registry', () => {
|
||||
})
|
||||
|
||||
it('should have 8 total extensions', () => {
|
||||
expect(getAllExtensions().length).toBe(12)
|
||||
expect(getAllExtensions().length).toBe(8)
|
||||
})
|
||||
|
||||
it('should have unique slugs within each sector', () => {
|
||||
@@ -80,10 +80,10 @@ describe('sectors registry', () => {
|
||||
})
|
||||
|
||||
it('getExtensionDefinition returns correct extension', () => {
|
||||
const ext = getExtensionDefinition('general', 'ai-chat')
|
||||
const ext = getExtensionDefinition('general', 'mcp-server')
|
||||
expect(ext).toBeDefined()
|
||||
expect(ext!.slug).toBe('ai-chat')
|
||||
expect(ext!.name).toBe('AI-assistent')
|
||||
expect(ext!.slug).toBe('mcp-server')
|
||||
expect(ext!.name).toBe('MCP-server (API)')
|
||||
expect(ext!.sector).toBe('general')
|
||||
})
|
||||
|
||||
@@ -94,7 +94,7 @@ describe('sectors registry', () => {
|
||||
|
||||
it('getExtensionsBySector returns extensions for a sector', () => {
|
||||
const extensions = getExtensionsBySector('general')
|
||||
expect(extensions.length).toBe(12)
|
||||
expect(extensions.length).toBe(8)
|
||||
})
|
||||
|
||||
it('all extensions have required fields', () => {
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
|
||||
/**
|
||||
* AI Consent Service
|
||||
*
|
||||
* Manages per-extension consent for AI features that send user data to
|
||||
* third-party AI providers. Required before any AI extension API call.
|
||||
*
|
||||
* Uses the existing `extension_data` table (migration 020) with key='ai_consent'.
|
||||
*
|
||||
* Version bump policy:
|
||||
* - BUMP version when: New sub-processor added, new data type sent to existing
|
||||
* provider, changed processing purpose.
|
||||
* - DO NOT bump when: Bug fix, model upgrade within same provider
|
||||
* (e.g. Haiku 4.5 -> Haiku 5), performance improvements.
|
||||
* - When version bumps, existing consents become invalid and users must re-consent.
|
||||
*/
|
||||
|
||||
export const CURRENT_CONSENT_VERSION = 1
|
||||
|
||||
export const AI_EXTENSIONS = ['receipt-ocr', 'ai-categorization', 'ai-chat'] as const
|
||||
|
||||
export type AiExtensionId = (typeof AI_EXTENSIONS)[number]
|
||||
|
||||
export function isAiExtension(extensionId: string): extensionId is AiExtensionId {
|
||||
return (AI_EXTENSIONS as readonly string[]).includes(extensionId)
|
||||
}
|
||||
|
||||
export const AI_DATA_DISCLOSURES: Record<AiExtensionId, {
|
||||
provider: string
|
||||
dataTypes: string[]
|
||||
purpose: string
|
||||
}> = {
|
||||
'receipt-ocr': {
|
||||
provider: 'Anthropic',
|
||||
dataTypes: ['Kvittobilder', 'Extraherad text från kvitton'],
|
||||
purpose: 'Automatisk avläsning och kategorisering av kvitton',
|
||||
},
|
||||
'ai-categorization': {
|
||||
provider: 'Anthropic, OpenAI',
|
||||
dataTypes: ['Transaktionsbeskrivningar', 'Belopp', 'Bokförmallar'],
|
||||
purpose: 'Automatisk kategorisering av banktransaktioner',
|
||||
},
|
||||
'ai-chat': {
|
||||
provider: 'Anthropic, OpenAI',
|
||||
dataTypes: ['Chattmeddelanden', 'Bokföringsdata som refereras i chatten'],
|
||||
purpose: 'AI-assistent för bokföringsfrågor',
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has valid AI consent for the given extension.
|
||||
* Returns true for non-AI extensions (no consent needed).
|
||||
*/
|
||||
export async function hasAiConsent(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
extensionId: string
|
||||
): Promise<boolean> {
|
||||
if (!isAiExtension(extensionId)) {
|
||||
return true
|
||||
}
|
||||
|
||||
const { data } = await supabase
|
||||
.from('extension_data')
|
||||
.select('value')
|
||||
.eq('company_id', companyId)
|
||||
.eq('extension_id', extensionId)
|
||||
.eq('key', 'ai_consent')
|
||||
.single()
|
||||
|
||||
if (!data?.value) return false
|
||||
|
||||
const consent = data.value as { consented: boolean; version: number }
|
||||
return consent.consented === true && consent.version >= CURRENT_CONSENT_VERSION
|
||||
}
|
||||
|
||||
/**
|
||||
* Grant AI consent for an extension.
|
||||
*/
|
||||
export async function grantAiConsent(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
companyId: string,
|
||||
extensionId: string
|
||||
): Promise<void> {
|
||||
if (!isAiExtension(extensionId)) return
|
||||
|
||||
await supabase
|
||||
.from('extension_data')
|
||||
.upsert(
|
||||
{
|
||||
user_id: userId,
|
||||
company_id: companyId,
|
||||
extension_id: extensionId,
|
||||
key: 'ai_consent',
|
||||
value: {
|
||||
consented: true,
|
||||
version: CURRENT_CONSENT_VERSION,
|
||||
granted_at: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
{ onConflict: 'user_id,extension_id,key' }
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke AI consent for an extension.
|
||||
*/
|
||||
export async function revokeAiConsent(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
companyId: string,
|
||||
extensionId: string
|
||||
): Promise<void> {
|
||||
if (!isAiExtension(extensionId)) return
|
||||
|
||||
await supabase
|
||||
.from('extension_data')
|
||||
.upsert(
|
||||
{
|
||||
user_id: userId,
|
||||
company_id: companyId,
|
||||
extension_id: extensionId,
|
||||
key: 'ai_consent',
|
||||
value: {
|
||||
consented: false,
|
||||
version: CURRENT_CONSENT_VERSION,
|
||||
revoked_at: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
{ onConflict: 'user_id,extension_id,key' }
|
||||
)
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
import type { InboxItemStatus, InvoiceExtractionResult, DocumentClassificationType } from '@/types'
|
||||
|
||||
const STATUS_LABELS: Record<InboxItemStatus, string> = {
|
||||
pending: 'Väntar',
|
||||
processing: 'Bearbetar',
|
||||
ready: 'Klar',
|
||||
confirmed: 'Bekräftad',
|
||||
rejected: 'Avvisad',
|
||||
error: 'Fel',
|
||||
}
|
||||
|
||||
export function getStatusLabel(status: InboxItemStatus): string {
|
||||
return STATUS_LABELS[status] ?? status
|
||||
}
|
||||
|
||||
const STATUS_VARIANTS: Record<InboxItemStatus, string> = {
|
||||
pending: 'secondary',
|
||||
processing: 'default',
|
||||
ready: 'warning',
|
||||
confirmed: 'success',
|
||||
rejected: 'destructive',
|
||||
error: 'destructive',
|
||||
}
|
||||
|
||||
export function getStatusVariant(status: InboxItemStatus): string {
|
||||
return STATUS_VARIANTS[status] ?? 'outline'
|
||||
}
|
||||
|
||||
export function getConfidenceLabel(confidence: number | null): { label: string; variant: string } {
|
||||
if (confidence == null) {
|
||||
return { label: 'Okänd', variant: 'outline' }
|
||||
}
|
||||
if (confidence >= 0.9) {
|
||||
return { label: 'Hög', variant: 'success' }
|
||||
}
|
||||
if (confidence >= 0.7) {
|
||||
return { label: 'Medium', variant: 'warning' }
|
||||
}
|
||||
return { label: 'Låg', variant: 'destructive' }
|
||||
}
|
||||
|
||||
export function formatExtractionSummary(
|
||||
data: InvoiceExtractionResult | null | undefined
|
||||
): { supplierName: string; total: number; lineCount: number; currency: string } {
|
||||
if (!data) {
|
||||
return { supplierName: '', total: 0, lineCount: 0, currency: 'SEK' }
|
||||
}
|
||||
return {
|
||||
supplierName: data.supplier?.name ?? '',
|
||||
total: data.totals?.total ?? 0,
|
||||
lineCount: data.lineItems?.length ?? 0,
|
||||
currency: data.invoice?.currency || 'SEK',
|
||||
}
|
||||
}
|
||||
|
||||
// Document type labels (Swedish)
|
||||
const DOCUMENT_TYPE_LABELS: Record<DocumentClassificationType, string> = {
|
||||
supplier_invoice: 'Faktura',
|
||||
receipt: 'Kvitto',
|
||||
government_letter: 'Myndighetspost',
|
||||
unknown: 'Övrigt',
|
||||
}
|
||||
|
||||
export function getDocumentTypeLabel(type: DocumentClassificationType): string {
|
||||
return DOCUMENT_TYPE_LABELS[type] ?? type
|
||||
}
|
||||
|
||||
const DOCUMENT_TYPE_VARIANTS: Record<DocumentClassificationType, string> = {
|
||||
supplier_invoice: 'default',
|
||||
receipt: 'secondary',
|
||||
government_letter: 'outline',
|
||||
unknown: 'outline',
|
||||
}
|
||||
|
||||
export function getDocumentTypeVariant(type: DocumentClassificationType): string {
|
||||
return DOCUMENT_TYPE_VARIANTS[type] ?? 'outline'
|
||||
}
|
||||
|
||||
/**
|
||||
* Format extraction summary for receipt documents
|
||||
*/
|
||||
export function formatReceiptSummary(
|
||||
data: Record<string, unknown> | null | undefined
|
||||
): { merchantName: string; total: number } {
|
||||
if (!data) {
|
||||
return { merchantName: '', total: 0 }
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const d = data as any
|
||||
return {
|
||||
merchantName: d.merchant?.name ?? '',
|
||||
total: d.totals?.total ?? 0,
|
||||
}
|
||||
}
|
||||
@@ -10,12 +10,12 @@ function makeParsedFile(overrides?: Partial<ParsedSIEFile>): ParsedSIEFile {
|
||||
sieType: 4,
|
||||
program: 'TestProg',
|
||||
programVersion: '1.0',
|
||||
generatedDate: new Date(2024, 0, 1),
|
||||
generatedDate: '2024-01-01',
|
||||
format: 'PC8',
|
||||
companyName: 'Test AB',
|
||||
orgNumber: '5566778899',
|
||||
address: null,
|
||||
fiscalYears: [{ yearIndex: 0, start: new Date(2024, 0, 1), end: new Date(2024, 11, 31) }],
|
||||
fiscalYears: [{ yearIndex: 0, start: '2024-01-01', end: '2024-12-31' }],
|
||||
currency: 'SEK',
|
||||
kontoPlanType: null,
|
||||
},
|
||||
@@ -49,8 +49,8 @@ function makeParsedFile(overrides?: Partial<ParsedSIEFile>): ParsedSIEFile {
|
||||
totalAccounts: 3,
|
||||
totalVouchers: 1,
|
||||
totalTransactionLines: 3,
|
||||
fiscalYearStart: new Date(2024, 0, 1),
|
||||
fiscalYearEnd: new Date(2024, 11, 31),
|
||||
fiscalYearStart: '2024-01-01',
|
||||
fiscalYearEnd: '2024-12-31',
|
||||
},
|
||||
...overrides,
|
||||
}
|
||||
|
||||
@@ -175,8 +175,8 @@ describe('parseSIEFile', () => {
|
||||
const result = parseSIEFile(MINIMAL_SIE)
|
||||
expect(result.header.fiscalYears).toHaveLength(1)
|
||||
expect(result.header.fiscalYears[0].yearIndex).toBe(0)
|
||||
expect(result.header.fiscalYears[0].start).toEqual(new Date(2024, 0, 1))
|
||||
expect(result.header.fiscalYears[0].end).toEqual(new Date(2024, 11, 31))
|
||||
expect(result.header.fiscalYears[0].start).toBe('2024-01-01')
|
||||
expect(result.header.fiscalYears[0].end).toBe('2024-12-31')
|
||||
})
|
||||
|
||||
it('parses currency from #VALUTA', () => {
|
||||
@@ -198,7 +198,7 @@ describe('parseSIEFile', () => {
|
||||
|
||||
it('parses generated date', () => {
|
||||
const result = parseSIEFile(MINIMAL_SIE)
|
||||
expect(result.header.generatedDate).toEqual(new Date(2024, 0, 1))
|
||||
expect(result.header.generatedDate).toBe('2024-01-01')
|
||||
})
|
||||
|
||||
it('parses SIE type 1', () => {
|
||||
@@ -351,8 +351,8 @@ describe('parseSIEFile', () => {
|
||||
|
||||
it('sets fiscal year start/end from RAR 0', () => {
|
||||
const result = parseSIEFile(MINIMAL_SIE)
|
||||
expect(result.stats.fiscalYearStart).toEqual(new Date(2024, 0, 1))
|
||||
expect(result.stats.fiscalYearEnd).toEqual(new Date(2024, 11, 31))
|
||||
expect(result.stats.fiscalYearStart).toBe('2024-01-01')
|
||||
expect(result.stats.fiscalYearEnd).toBe('2024-12-31')
|
||||
})
|
||||
|
||||
it('returns null fiscal year dates when no RAR', () => {
|
||||
|
||||
+65
-30
@@ -101,6 +101,31 @@ export async function checkDuplicateImport(
|
||||
return data as SIEImport | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a completed SIE import already exists for the same fiscal year period.
|
||||
* Prevents importing two different SIE files that cover the same accounting period,
|
||||
* which would create duplicate verifikationer violating BFNAR 2013:2.
|
||||
* Only blocks on status='completed' — failed/pending imports don't prevent retries.
|
||||
*/
|
||||
export async function checkDuplicatePeriodImport(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
fiscalYearStart: string,
|
||||
fiscalYearEnd: string
|
||||
): Promise<SIEImport | null> {
|
||||
const { data } = await supabase
|
||||
.from('sie_imports')
|
||||
.select('*')
|
||||
.eq('company_id', companyId)
|
||||
.eq('fiscal_year_start', fiscalYearStart)
|
||||
.eq('fiscal_year_end', fiscalYearEnd)
|
||||
.eq('status', 'completed')
|
||||
.limit(1)
|
||||
.maybeSingle()
|
||||
|
||||
return data as SIEImport | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up stale pending/failed import records for a given file hash.
|
||||
* Prevents UNIQUE constraint conflicts when re-importing after a failure.
|
||||
@@ -121,21 +146,22 @@ async function cleanupStaleImportRecords(
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a fiscal period if one doesn't exist for the date range
|
||||
* Create a fiscal period if one doesn't exist for the date range.
|
||||
* Dates are ISO strings "YYYY-MM-DD" to avoid timezone issues.
|
||||
*/
|
||||
async function ensureFiscalPeriod(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
startDate: Date,
|
||||
endDate: Date
|
||||
startDate: string,
|
||||
endDate: string
|
||||
): Promise<string> {
|
||||
// Check for an existing period that contains the SIE date range
|
||||
const { data: containing } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('id')
|
||||
.eq('company_id', companyId)
|
||||
.lte('period_start', formatDate(startDate))
|
||||
.gte('period_end', formatDate(endDate))
|
||||
.lte('period_start', startDate)
|
||||
.gte('period_end', endDate)
|
||||
.single()
|
||||
|
||||
if (containing) {
|
||||
@@ -148,8 +174,8 @@ async function ensureFiscalPeriod(
|
||||
.from('fiscal_periods')
|
||||
.select('id')
|
||||
.eq('company_id', companyId)
|
||||
.lte('period_start', formatDate(endDate))
|
||||
.gte('period_end', formatDate(startDate))
|
||||
.lte('period_start', endDate)
|
||||
.gte('period_end', startDate)
|
||||
.order('period_start', { ascending: false })
|
||||
.limit(1)
|
||||
|
||||
@@ -158,8 +184,8 @@ async function ensureFiscalPeriod(
|
||||
}
|
||||
|
||||
// Create new fiscal period
|
||||
const startYear = startDate.getFullYear()
|
||||
const endYear = endDate.getFullYear()
|
||||
const startYear = parseInt(startDate.substring(0, 4), 10)
|
||||
const endYear = parseInt(endDate.substring(0, 4), 10)
|
||||
const name = startYear === endYear
|
||||
? `Räkenskapsår ${startYear}`
|
||||
: `Räkenskapsår ${startYear}/${endYear}`
|
||||
@@ -169,8 +195,8 @@ async function ensureFiscalPeriod(
|
||||
.insert({
|
||||
company_id: companyId,
|
||||
name,
|
||||
period_start: formatDate(startDate),
|
||||
period_end: formatDate(endDate),
|
||||
period_start: startDate,
|
||||
period_end: endDate,
|
||||
is_closed: false,
|
||||
opening_balances_set: false,
|
||||
})
|
||||
@@ -316,8 +342,7 @@ async function createOpeningBalanceEntry(
|
||||
}
|
||||
}
|
||||
|
||||
const fiscalYearStart = parsed.stats.fiscalYearStart
|
||||
const entryDate = fiscalYearStart ? formatDate(fiscalYearStart) : formatDate(new Date())
|
||||
const entryDate = parsed.stats.fiscalYearStart ?? formatDate(new Date())
|
||||
|
||||
const entry = await createJournalEntry(supabase, companyId, userId, {
|
||||
fiscal_period_id: fiscalPeriodId,
|
||||
@@ -908,8 +933,7 @@ async function createMigrationAdjustmentEntry(
|
||||
}
|
||||
|
||||
// Date the adjustment at fiscal year end
|
||||
const fiscalYearEnd = parsed.stats.fiscalYearEnd
|
||||
const entryDate = fiscalYearEnd ? formatDate(fiscalYearEnd) : formatDate(new Date())
|
||||
const entryDate = parsed.stats.fiscalYearEnd ?? formatDate(new Date())
|
||||
|
||||
// Fix 4: Build structured description with skipped voucher details
|
||||
const skippedIds = skippedDetails.map(d => d.voucherId)
|
||||
@@ -1023,12 +1047,8 @@ async function createPendingImportRecord(
|
||||
org_number: parsed.header.orgNumber,
|
||||
company_name: parsed.header.companyName,
|
||||
sie_type: parsed.header.sieType,
|
||||
fiscal_year_start: parsed.stats.fiscalYearStart
|
||||
? formatDate(parsed.stats.fiscalYearStart)
|
||||
: null,
|
||||
fiscal_year_end: parsed.stats.fiscalYearEnd
|
||||
? formatDate(parsed.stats.fiscalYearEnd)
|
||||
: null,
|
||||
fiscal_year_start: parsed.stats.fiscalYearStart ?? null,
|
||||
fiscal_year_end: parsed.stats.fiscalYearEnd ?? null,
|
||||
accounts_count: parsed.stats.totalAccounts,
|
||||
transactions_count: 0,
|
||||
status: 'pending',
|
||||
@@ -1237,6 +1257,17 @@ export async function executeSIEImport(
|
||||
return result
|
||||
}
|
||||
|
||||
// Safety net: reject if a completed import already exists for this period
|
||||
const periodDuplicate = await checkDuplicatePeriodImport(
|
||||
supabase, companyId, fiscalYearStart, fiscalYearEnd
|
||||
)
|
||||
if (periodDuplicate) {
|
||||
result.errors.push(
|
||||
`En SIE-import för perioden ${fiscalYearStart} – ${fiscalYearEnd} finns redan (ID: ${periodDuplicate.id})`
|
||||
)
|
||||
return result
|
||||
}
|
||||
|
||||
if (options.createFiscalPeriod) {
|
||||
result.fiscalPeriodId = await ensureFiscalPeriod(
|
||||
supabase,
|
||||
@@ -1250,8 +1281,8 @@ export async function executeSIEImport(
|
||||
.from('fiscal_periods')
|
||||
.select('id')
|
||||
.eq('company_id', companyId)
|
||||
.lte('period_start', formatDate(fiscalYearStart))
|
||||
.gte('period_end', formatDate(fiscalYearEnd))
|
||||
.lte('period_start', fiscalYearStart)
|
||||
.gte('period_end', fiscalYearEnd)
|
||||
.single()
|
||||
|
||||
if (!existing) {
|
||||
@@ -1359,15 +1390,19 @@ export async function executeSIEImport(
|
||||
const earliestVoucher = new Date(Math.min(...voucherDates))
|
||||
const latestVoucher = new Date(Math.max(...voucherDates))
|
||||
|
||||
// Parse fiscal year string dates for comparison (append T00:00:00 to avoid UTC shift)
|
||||
const fyStart = new Date(fiscalYearStart + 'T00:00:00')
|
||||
const fyEnd = new Date(fiscalYearEnd + 'T00:00:00')
|
||||
|
||||
// Allow 30 days margin from fiscal year start/end for partial detection
|
||||
const msPerDay = 86400000
|
||||
const startGap = earliestVoucher.getTime() - fiscalYearStart.getTime()
|
||||
const endGap = fiscalYearEnd.getTime() - latestVoucher.getTime()
|
||||
const startGap = earliestVoucher.getTime() - fyStart.getTime()
|
||||
const endGap = fyEnd.getTime() - latestVoucher.getTime()
|
||||
|
||||
if (startGap > 60 * msPerDay || endGap > 60 * msPerDay) {
|
||||
result.warnings.push(
|
||||
`SIE-filen verkar innehålla ett ofullständigt räkenskapsår: verifikationer ${formatDate(earliestVoucher)}–${formatDate(latestVoucher)}, ` +
|
||||
`räkenskapsår ${formatDate(fiscalYearStart)}–${formatDate(fiscalYearEnd)}. ` +
|
||||
`räkenskapsår ${fiscalYearStart}–${fiscalYearEnd}. ` +
|
||||
`Omföringsverifikationen kan bli felaktig om #UB/#RES avser hela året men verifikationerna bara täcker en del.`
|
||||
)
|
||||
}
|
||||
@@ -1480,10 +1515,10 @@ export async function executeSIEImport(
|
||||
sourceSystem: parsed.header.program,
|
||||
sourceVersion: parsed.header.programVersion,
|
||||
sieType: parsed.header.sieType,
|
||||
generatedDate: parsed.header.generatedDate ? formatDate(parsed.header.generatedDate) : null,
|
||||
generatedDate: parsed.header.generatedDate ?? null,
|
||||
fiscalYear: {
|
||||
start: formatDate(fiscalYearStart),
|
||||
end: formatDate(fiscalYearEnd),
|
||||
start: fiscalYearStart,
|
||||
end: fiscalYearEnd,
|
||||
},
|
||||
importedAt: new Date().toISOString(),
|
||||
importedBy: companyId,
|
||||
@@ -1512,7 +1547,7 @@ export async function executeSIEImport(
|
||||
voucherStats.skippedSingleLine + voucherStats.skippedEmpty
|
||||
result.details = {
|
||||
fiscalYear: fiscalYearStart && fiscalYearEnd
|
||||
? { start: formatDate(fiscalYearStart), end: formatDate(fiscalYearEnd) }
|
||||
? { start: fiscalYearStart, end: fiscalYearEnd }
|
||||
: undefined,
|
||||
skippedVouchers: totalSkippedForDetails > 0 ? {
|
||||
unbalanced: voucherStats.skippedUnbalanced,
|
||||
|
||||
@@ -183,7 +183,8 @@ export function decodeBuffer(buffer: ArrayBuffer, encoding: SIEEncoding): string
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a date from SIE format (YYYYMMDD)
|
||||
* Parse a date from SIE format (YYYYMMDD) into a Date object.
|
||||
* Used for voucher dates where Date arithmetic is needed.
|
||||
*/
|
||||
function parseSIEDate(dateStr: string): Date | null {
|
||||
if (!dateStr || dateStr.length !== 8) {
|
||||
@@ -208,6 +209,37 @@ function parseSIEDate(dateStr: string): Date | null {
|
||||
return date
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a date from SIE format (YYYYMMDD) into an ISO date string "YYYY-MM-DD".
|
||||
* Used for fiscal year dates and generated dates to avoid timezone issues
|
||||
* during JSON serialization (Date objects shift when crossing UTC boundaries).
|
||||
*/
|
||||
function parseSIEDateString(dateStr: string): string | null {
|
||||
if (!dateStr || dateStr.length !== 8) {
|
||||
return null
|
||||
}
|
||||
|
||||
const year = dateStr.substring(0, 4)
|
||||
const month = dateStr.substring(4, 6)
|
||||
const day = dateStr.substring(6, 8)
|
||||
|
||||
const y = parseInt(year, 10)
|
||||
const m = parseInt(month, 10)
|
||||
const d = parseInt(day, 10)
|
||||
|
||||
if (isNaN(y) || isNaN(m) || isNaN(d)) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Validate by round-tripping through Date (rejects Feb 30, etc.)
|
||||
const date = new Date(y, m - 1, d)
|
||||
if (date.getFullYear() !== y || date.getMonth() !== m - 1 || date.getDate() !== d) {
|
||||
return null
|
||||
}
|
||||
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a quoted string field from SIE
|
||||
* Handles: "value" or value
|
||||
@@ -405,7 +437,7 @@ export function parseSIEFile(content: string): ParsedSIEFile {
|
||||
|
||||
case 'GEN':
|
||||
if (fields[1]) {
|
||||
header.generatedDate = parseSIEDate(fields[1])
|
||||
header.generatedDate = parseSIEDateString(fields[1])
|
||||
}
|
||||
break
|
||||
|
||||
@@ -435,8 +467,8 @@ export function parseSIEFile(content: string): ParsedSIEFile {
|
||||
case 'RAR': {
|
||||
// #RAR yearIndex start end
|
||||
const yearIndex = parseInt(fields[1], 10)
|
||||
const start = parseSIEDate(fields[2])
|
||||
const end = parseSIEDate(fields[3])
|
||||
const start = parseSIEDateString(fields[2])
|
||||
const end = parseSIEDateString(fields[3])
|
||||
|
||||
if (start && end) {
|
||||
header.fiscalYears.push({ yearIndex, start, end })
|
||||
|
||||
+7
-7
@@ -28,7 +28,7 @@ export interface SIEHeader {
|
||||
sieType: SIEType
|
||||
program: string | null // #PROGRAM
|
||||
programVersion: string | null
|
||||
generatedDate: Date | null // #GEN
|
||||
generatedDate: string | null // #GEN — "YYYY-MM-DD"
|
||||
format: string | null // #FORMAT (PC8 = CP437)
|
||||
|
||||
// Company info
|
||||
@@ -47,8 +47,8 @@ export interface SIEHeader {
|
||||
*/
|
||||
export interface FiscalYearInfo {
|
||||
yearIndex: number // 0 = current, -1 = previous, etc.
|
||||
start: Date
|
||||
end: Date
|
||||
start: string // "YYYY-MM-DD"
|
||||
end: string // "YYYY-MM-DD"
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -134,8 +134,8 @@ export interface ParsedSIEFile {
|
||||
totalAccounts: number
|
||||
totalVouchers: number
|
||||
totalTransactionLines: number
|
||||
fiscalYearStart: Date | null
|
||||
fiscalYearEnd: Date | null
|
||||
fiscalYearStart: string | null // "YYYY-MM-DD"
|
||||
fiscalYearEnd: string | null // "YYYY-MM-DD"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -305,8 +305,8 @@ export interface ImportPreview {
|
||||
orgNumber: string | null
|
||||
|
||||
// Fiscal year
|
||||
fiscalYearStart: Date | null
|
||||
fiscalYearEnd: Date | null
|
||||
fiscalYearStart: string | null // "YYYY-MM-DD"
|
||||
fiscalYearEnd: string | null // "YYYY-MM-DD"
|
||||
|
||||
// Statistics
|
||||
accountCount: number
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
Page,
|
||||
Text,
|
||||
View,
|
||||
Image,
|
||||
StyleSheet,
|
||||
} from '@react-pdf/renderer'
|
||||
import type { Invoice, InvoiceItem, Customer, CompanySettings, InvoiceDocumentType } from '@/types'
|
||||
@@ -313,6 +314,9 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
|
||||
<Text style={{ marginTop: 5, color: '#666' }}>{invoice.invoice_number}</Text>
|
||||
</View>
|
||||
<View style={styles.companyInfo}>
|
||||
{company.logo_url && (
|
||||
<Image src={company.logo_url} style={{ maxHeight: 40, maxWidth: 150, marginBottom: 6, alignSelf: 'flex-end' }} />
|
||||
)}
|
||||
<Text style={styles.companyName}>{company.company_name}</Text>
|
||||
{company.address_line1 && <Text>{company.address_line1}</Text>}
|
||||
{(company.postal_code || company.city) && (
|
||||
@@ -459,8 +463,19 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
|
||||
)}
|
||||
<View style={styles.grandTotal}>
|
||||
<Text style={styles.grandTotalLabel}>{isCreditNote ? 'Att kreditera:' : 'Att betala:'}</Text>
|
||||
<Text style={styles.grandTotalValue}>{formatCurrency(invoice.total, invoice.currency)}</Text>
|
||||
<Text style={styles.grandTotalValue}>{formatCurrency(
|
||||
(company.ore_rounding ?? true) && invoice.currency === 'SEK'
|
||||
? Math.round(invoice.total)
|
||||
: invoice.total,
|
||||
invoice.currency
|
||||
)}</Text>
|
||||
</View>
|
||||
{(company.ore_rounding ?? true) && invoice.currency === 'SEK' && Math.round(invoice.total) !== invoice.total && (
|
||||
<View style={styles.totalRow}>
|
||||
<Text style={[styles.totalLabel, { fontSize: 8 }]}>Öresavrundning:</Text>
|
||||
<Text style={[styles.totalValue, { fontSize: 8 }]}>{formatCurrency(Math.round(invoice.total) - invoice.total, 'SEK')}</Text>
|
||||
</View>
|
||||
)}
|
||||
{invoice.currency !== 'SEK' && invoice.total_sek && (
|
||||
<View style={[styles.totalRow, { marginTop: 8 }]}>
|
||||
<Text style={[styles.totalLabel, { fontSize: 9 }]}>I SEK (kurs {invoice.exchange_rate}):</Text>
|
||||
@@ -497,13 +512,13 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{company.bankgiro && (
|
||||
{company.bankgiro && (company.invoice_show_bankgiro ?? true) && (
|
||||
<View style={styles.paymentRow}>
|
||||
<Text style={styles.paymentLabel}>Bankgiro:</Text>
|
||||
<Text style={styles.paymentValue}>{company.bankgiro}</Text>
|
||||
</View>
|
||||
)}
|
||||
{company.plusgiro && (
|
||||
{company.plusgiro && (company.invoice_show_plusgiro ?? true) && (
|
||||
<View style={styles.paymentRow}>
|
||||
<Text style={styles.paymentLabel}>Plusgiro:</Text>
|
||||
<Text style={styles.paymentValue}>{company.plusgiro}</Text>
|
||||
@@ -525,10 +540,12 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
|
||||
<Text style={styles.paymentLabel}>Förfallodatum:</Text>
|
||||
<Text style={[styles.paymentValue, { fontWeight: 'bold' }]}>{formatDate(invoice.due_date)}</Text>
|
||||
</View>
|
||||
<View style={styles.paymentRow}>
|
||||
<Text style={styles.paymentLabel}>OCR/Referens:</Text>
|
||||
<Text style={[styles.paymentValue, { fontWeight: 'bold' }]}>{generateOcrReference(invoice.invoice_number)}</Text>
|
||||
</View>
|
||||
{(company.invoice_show_ocr ?? true) && (
|
||||
<View style={styles.paymentRow}>
|
||||
<Text style={styles.paymentLabel}>OCR/Referens:</Text>
|
||||
<Text style={[styles.paymentValue, { fontWeight: 'bold' }]}>{generateOcrReference(invoice.invoice_number)}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
@@ -546,6 +563,18 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Late fee & credit terms */}
|
||||
{(company.invoice_late_fee_text || company.invoice_credit_terms_text) && (
|
||||
<View style={{ marginTop: 10, marginBottom: 10 }}>
|
||||
{company.invoice_late_fee_text && (
|
||||
<Text style={{ fontSize: 8, color: '#666', marginBottom: 2 }}>{company.invoice_late_fee_text}</Text>
|
||||
)}
|
||||
{company.invoice_credit_terms_text && (
|
||||
<Text style={{ fontSize: 8, color: '#666' }}>{company.invoice_credit_terms_text}</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<View style={styles.footer}>
|
||||
<Text style={styles.footerText}>
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
// ============================================================
|
||||
// Mock Supabase — table-keyed result queues
|
||||
// ============================================================
|
||||
|
||||
type MockResult = { data?: unknown; error?: unknown; count?: number }
|
||||
let mockResults: Record<string, MockResult[]>
|
||||
|
||||
function makeBuilder(tableName: string) {
|
||||
const b: Record<string, unknown> = {}
|
||||
for (const m of ['select', 'eq', 'in', 'lt', 'neq', 'range', 'update']) {
|
||||
b[m] = vi.fn().mockReturnValue(b)
|
||||
}
|
||||
const consume = (): MockResult => {
|
||||
const queue = mockResults[tableName]
|
||||
if (!queue || queue.length === 0) return { data: null, error: null }
|
||||
return queue.shift()!
|
||||
}
|
||||
b.single = vi.fn().mockImplementation(async () => consume())
|
||||
b.then = (resolve: (v: unknown) => void) => resolve(consume())
|
||||
return b
|
||||
}
|
||||
|
||||
function makeClient() {
|
||||
return {
|
||||
from: vi.fn().mockImplementation((table: string) => makeBuilder(table)),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any
|
||||
}
|
||||
|
||||
import { validateBalanceContinuity } from '../continuity-check'
|
||||
|
||||
let supabase: ReturnType<typeof makeClient>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockResults = {}
|
||||
supabase = makeClient()
|
||||
})
|
||||
|
||||
describe('validateBalanceContinuity', () => {
|
||||
it('returns valid for first period (no previous_period_id)', async () => {
|
||||
mockResults = {
|
||||
fiscal_periods: [
|
||||
{ data: { id: 'p1', name: 'FY2024', period_start: '2024-01-01', previous_period_id: null, opening_balance_entry_id: null } },
|
||||
],
|
||||
}
|
||||
|
||||
const result = await validateBalanceContinuity(supabase, 'company-1', 'p1')
|
||||
|
||||
expect(result.valid).toBe(true)
|
||||
expect(result.discrepancies).toEqual([])
|
||||
expect(result.checked_accounts).toBe(0)
|
||||
expect(result.previous_period_name).toBeNull()
|
||||
})
|
||||
|
||||
it('returns valid when IB matches UB', async () => {
|
||||
mockResults = {
|
||||
fiscal_periods: [
|
||||
// Target period
|
||||
{ data: { id: 'p2', name: 'FY2025', period_start: '2025-01-01', previous_period_id: 'p1', opening_balance_entry_id: 'ob-1' } },
|
||||
// Previous period (for name)
|
||||
{ data: { id: 'p1', name: 'FY2024' } },
|
||||
// Previous period (for generateTrialBalance)
|
||||
{ data: { period_start: '2024-01-01', opening_balance_entry_id: null } },
|
||||
],
|
||||
journal_entry_lines: [
|
||||
// Previous period OB (fallback — no OB entry)
|
||||
{ data: [] },
|
||||
// Previous period lines (trial balance)
|
||||
{
|
||||
data: [
|
||||
{ account_number: '1930', debit_amount: 50000, credit_amount: 0 },
|
||||
{ account_number: '2099', debit_amount: 0, credit_amount: 30000 },
|
||||
{ account_number: '1510', debit_amount: 10000, credit_amount: 0 },
|
||||
],
|
||||
},
|
||||
// Current period OB entry lines (getOpeningBalances)
|
||||
{
|
||||
data: [
|
||||
{ account_number: '1930', debit_amount: 50000, credit_amount: 0 },
|
||||
{ account_number: '2099', debit_amount: 0, credit_amount: 30000 },
|
||||
{ account_number: '1510', debit_amount: 10000, credit_amount: 0 },
|
||||
],
|
||||
},
|
||||
],
|
||||
chart_of_accounts: [
|
||||
{
|
||||
data: [
|
||||
{ account_number: '1510', account_name: 'Kundfordringar', account_class: 1 },
|
||||
{ account_number: '1930', account_name: 'Företagskonto', account_class: 1 },
|
||||
{ account_number: '2099', account_name: 'Årets resultat', account_class: 2 },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const result = await validateBalanceContinuity(supabase, 'company-1', 'p2')
|
||||
|
||||
expect(result.valid).toBe(true)
|
||||
expect(result.discrepancies).toEqual([])
|
||||
expect(result.checked_accounts).toBe(3)
|
||||
expect(result.period_name).toBe('FY2025')
|
||||
expect(result.previous_period_name).toBe('FY2024')
|
||||
})
|
||||
|
||||
it('detects discrepancy in one account', async () => {
|
||||
mockResults = {
|
||||
fiscal_periods: [
|
||||
{ data: { id: 'p2', name: 'FY2025', period_start: '2025-01-01', previous_period_id: 'p1', opening_balance_entry_id: 'ob-1' } },
|
||||
{ data: { id: 'p1', name: 'FY2024' } },
|
||||
{ data: { period_start: '2024-01-01', opening_balance_entry_id: null } },
|
||||
],
|
||||
journal_entry_lines: [
|
||||
{ data: [] },
|
||||
// Previous UB: 1930 = 50000 debit
|
||||
{
|
||||
data: [
|
||||
{ account_number: '1930', debit_amount: 50000, credit_amount: 0 },
|
||||
{ account_number: '2099', debit_amount: 0, credit_amount: 50000 },
|
||||
],
|
||||
},
|
||||
// Current IB: 1930 = 49000 debit (mismatch!)
|
||||
{
|
||||
data: [
|
||||
{ account_number: '1930', debit_amount: 49000, credit_amount: 0 },
|
||||
{ account_number: '2099', debit_amount: 0, credit_amount: 50000 },
|
||||
],
|
||||
},
|
||||
],
|
||||
chart_of_accounts: [
|
||||
{
|
||||
data: [
|
||||
{ account_number: '1930', account_name: 'Företagskonto', account_class: 1 },
|
||||
{ account_number: '2099', account_name: 'Årets resultat', account_class: 2 },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const result = await validateBalanceContinuity(supabase, 'company-1', 'p2')
|
||||
|
||||
expect(result.valid).toBe(false)
|
||||
expect(result.discrepancies).toHaveLength(1)
|
||||
expect(result.discrepancies[0].account_number).toBe('1930')
|
||||
expect(result.discrepancies[0].previous_ub_net).toBe(50000)
|
||||
expect(result.discrepancies[0].current_ib_net).toBe(49000)
|
||||
expect(result.discrepancies[0].difference).toBe(1000)
|
||||
})
|
||||
|
||||
it('detects account present in UB but not IB', async () => {
|
||||
mockResults = {
|
||||
fiscal_periods: [
|
||||
{ data: { id: 'p2', name: 'FY2025', period_start: '2025-01-01', previous_period_id: 'p1', opening_balance_entry_id: 'ob-1' } },
|
||||
{ data: { id: 'p1', name: 'FY2024' } },
|
||||
{ data: { period_start: '2024-01-01', opening_balance_entry_id: null } },
|
||||
],
|
||||
journal_entry_lines: [
|
||||
{ data: [] },
|
||||
// Previous UB has 1510 and 2440
|
||||
{
|
||||
data: [
|
||||
{ account_number: '1510', debit_amount: 10000, credit_amount: 0 },
|
||||
{ account_number: '2440', debit_amount: 0, credit_amount: 10000 },
|
||||
],
|
||||
},
|
||||
// Current IB only has 1510 (2440 missing)
|
||||
{
|
||||
data: [
|
||||
{ account_number: '1510', debit_amount: 10000, credit_amount: 0 },
|
||||
],
|
||||
},
|
||||
],
|
||||
chart_of_accounts: [
|
||||
{
|
||||
data: [
|
||||
{ account_number: '1510', account_name: 'Kundfordringar', account_class: 1 },
|
||||
{ account_number: '2440', account_name: 'Leverantörsskulder', account_class: 2 },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const result = await validateBalanceContinuity(supabase, 'company-1', 'p2')
|
||||
|
||||
expect(result.valid).toBe(false)
|
||||
expect(result.discrepancies).toHaveLength(1)
|
||||
expect(result.discrepancies[0].account_number).toBe('2440')
|
||||
expect(result.discrepancies[0].previous_ub_net).toBe(-10000)
|
||||
expect(result.discrepancies[0].current_ib_net).toBe(0)
|
||||
})
|
||||
|
||||
it('detects account present in IB but not UB', async () => {
|
||||
mockResults = {
|
||||
fiscal_periods: [
|
||||
{ data: { id: 'p2', name: 'FY2025', period_start: '2025-01-01', previous_period_id: 'p1', opening_balance_entry_id: 'ob-1' } },
|
||||
{ data: { id: 'p1', name: 'FY2024' } },
|
||||
{ data: { period_start: '2024-01-01', opening_balance_entry_id: null } },
|
||||
],
|
||||
journal_entry_lines: [
|
||||
{ data: [] },
|
||||
// Previous UB: only 1930
|
||||
{
|
||||
data: [
|
||||
{ account_number: '1930', debit_amount: 50000, credit_amount: 0 },
|
||||
],
|
||||
},
|
||||
// Current IB: 1930 + 1510 (1510 shouldn't be here)
|
||||
{
|
||||
data: [
|
||||
{ account_number: '1930', debit_amount: 50000, credit_amount: 0 },
|
||||
{ account_number: '1510', debit_amount: 5000, credit_amount: 0 },
|
||||
],
|
||||
},
|
||||
],
|
||||
chart_of_accounts: [
|
||||
{
|
||||
data: [
|
||||
{ account_number: '1930', account_name: 'Företagskonto', account_class: 1 },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const result = await validateBalanceContinuity(supabase, 'company-1', 'p2')
|
||||
|
||||
expect(result.valid).toBe(false)
|
||||
expect(result.discrepancies).toHaveLength(1)
|
||||
expect(result.discrepancies[0].account_number).toBe('1510')
|
||||
expect(result.discrepancies[0].previous_ub_net).toBe(0)
|
||||
expect(result.discrepancies[0].current_ib_net).toBe(5000)
|
||||
})
|
||||
|
||||
it('treats differences within 0.01 SEK tolerance as valid', async () => {
|
||||
mockResults = {
|
||||
fiscal_periods: [
|
||||
{ data: { id: 'p2', name: 'FY2025', period_start: '2025-01-01', previous_period_id: 'p1', opening_balance_entry_id: 'ob-1' } },
|
||||
{ data: { id: 'p1', name: 'FY2024' } },
|
||||
{ data: { period_start: '2024-01-01', opening_balance_entry_id: null } },
|
||||
],
|
||||
journal_entry_lines: [
|
||||
{ data: [] },
|
||||
{
|
||||
data: [
|
||||
{ account_number: '1930', debit_amount: 50000.005, credit_amount: 0 },
|
||||
],
|
||||
},
|
||||
{
|
||||
data: [
|
||||
{ account_number: '1930', debit_amount: 50000, credit_amount: 0 },
|
||||
],
|
||||
},
|
||||
],
|
||||
chart_of_accounts: [
|
||||
{
|
||||
data: [
|
||||
{ account_number: '1930', account_name: 'Företagskonto', account_class: 1 },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const result = await validateBalanceContinuity(supabase, 'company-1', 'p2')
|
||||
|
||||
expect(result.valid).toBe(true)
|
||||
expect(result.discrepancies).toEqual([])
|
||||
})
|
||||
|
||||
it('throws when period not found', async () => {
|
||||
mockResults = {
|
||||
fiscal_periods: [
|
||||
{ data: null, error: { message: 'not found' } },
|
||||
],
|
||||
}
|
||||
|
||||
await expect(
|
||||
validateBalanceContinuity(supabase, 'company-1', 'nonexistent')
|
||||
).rejects.toThrow('Fiscal period not found')
|
||||
})
|
||||
|
||||
it('throws when previous period not found', async () => {
|
||||
mockResults = {
|
||||
fiscal_periods: [
|
||||
{ data: { id: 'p2', name: 'FY2025', period_start: '2025-01-01', previous_period_id: 'p1', opening_balance_entry_id: null } },
|
||||
{ data: null, error: { message: 'not found' } },
|
||||
],
|
||||
}
|
||||
|
||||
await expect(
|
||||
validateBalanceContinuity(supabase, 'company-1', 'p2')
|
||||
).rejects.toThrow('Previous fiscal period not found')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,124 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import type { ContinuityCheckResult, ContinuityDiscrepancy } from '@/types'
|
||||
import { generateTrialBalance } from './trial-balance'
|
||||
import { getOpeningBalances } from './opening-balances'
|
||||
|
||||
/**
|
||||
* Validate that a fiscal period's opening balances (IB) match the previous
|
||||
* period's closing balances (UB) for all balance sheet accounts (class 1-2).
|
||||
*
|
||||
* Uses the same data paths as the actual reports: generateTrialBalance() for
|
||||
* UB and getOpeningBalances() for IB, so a passing check proves the reports
|
||||
* are consistent.
|
||||
*
|
||||
* Tolerance: 0.01 SEK per account — covers IEEE 754 rounding drift from
|
||||
* intermediate Math.round() calls, NOT Swedish öresavrundning (abolished 2010).
|
||||
*/
|
||||
export async function validateBalanceContinuity(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
fiscalPeriodId: string
|
||||
): Promise<ContinuityCheckResult> {
|
||||
// Fetch target period
|
||||
const { data: period, error: periodError } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('id, name, period_start, previous_period_id, opening_balance_entry_id')
|
||||
.eq('id', fiscalPeriodId)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
if (periodError || !period) {
|
||||
throw new Error('Fiscal period not found')
|
||||
}
|
||||
|
||||
// First period — nothing to compare against
|
||||
if (!period.previous_period_id) {
|
||||
return {
|
||||
valid: true,
|
||||
period_name: period.name,
|
||||
previous_period_name: null,
|
||||
discrepancies: [],
|
||||
checked_accounts: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch previous period name
|
||||
const { data: prevPeriod } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('id, name')
|
||||
.eq('id', period.previous_period_id)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
if (!prevPeriod) {
|
||||
throw new Error('Previous fiscal period not found')
|
||||
}
|
||||
|
||||
// Previous period UB: trial balance filtered to class 1-2
|
||||
const { rows: trialRows } = await generateTrialBalance(
|
||||
supabase,
|
||||
companyId,
|
||||
prevPeriod.id
|
||||
)
|
||||
|
||||
const previousUB = new Map<string, { net: number; name: string }>()
|
||||
for (const row of trialRows) {
|
||||
if (row.account_class >= 1 && row.account_class <= 2) {
|
||||
const net = Math.round((row.closing_debit - row.closing_credit) * 100) / 100
|
||||
if (Math.abs(net) >= 0.005) {
|
||||
previousUB.set(row.account_number, { net, name: row.account_name })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Current period IB
|
||||
const { balances: ibBalances } = await getOpeningBalances(supabase, companyId, period)
|
||||
|
||||
const currentIB = new Map<string, number>()
|
||||
for (const [accountNumber, bal] of ibBalances) {
|
||||
// Only check balance sheet accounts
|
||||
const accountClass = parseInt(accountNumber[0]) || 0
|
||||
if (accountClass >= 1 && accountClass <= 2) {
|
||||
const net = Math.round((bal.debit - bal.credit) * 100) / 100
|
||||
if (Math.abs(net) >= 0.005) {
|
||||
currentIB.set(accountNumber, net)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Compare all accounts from both sides
|
||||
const allAccounts = new Set([...previousUB.keys(), ...currentIB.keys()])
|
||||
const discrepancies: ContinuityDiscrepancy[] = []
|
||||
|
||||
// Get account names for IB-only accounts
|
||||
const accountNames = new Map<string, string>()
|
||||
for (const [num, data] of previousUB) {
|
||||
accountNames.set(num, data.name)
|
||||
}
|
||||
|
||||
for (const accountNumber of allAccounts) {
|
||||
const ubNet = previousUB.get(accountNumber)?.net ?? 0
|
||||
const ibNet = currentIB.get(accountNumber) ?? 0
|
||||
const difference = Math.round((ubNet - ibNet) * 100) / 100
|
||||
|
||||
if (Math.abs(difference) > 0.01) {
|
||||
discrepancies.push({
|
||||
account_number: accountNumber,
|
||||
account_name: accountNames.get(accountNumber) ?? `Konto ${accountNumber}`,
|
||||
previous_ub_net: ubNet,
|
||||
current_ib_net: ibNet,
|
||||
difference,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
discrepancies.sort((a, b) => a.account_number.localeCompare(b.account_number))
|
||||
|
||||
return {
|
||||
valid: discrepancies.length === 0,
|
||||
period_name: period.name,
|
||||
previous_period_name: prevPeriod.name,
|
||||
discrepancies,
|
||||
checked_accounts: allAccounts.size,
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { suggestCategory } from '@/lib/tax/expense-warnings'
|
||||
import { getExpenseAccountForCategory } from '@/lib/bookkeeping/category-mapping'
|
||||
import { findMatchingTemplates, getTemplateById, type TemplateMatch } from '@/lib/bookkeeping/booking-templates'
|
||||
import { extensionRegistry } from '@/lib/extensions/registry'
|
||||
import type { Transaction, TransactionCategory, EntityType, MappingRule, LinePatternEntry } from '@/types'
|
||||
|
||||
export interface SuggestedCategory {
|
||||
@@ -9,7 +8,7 @@ export interface SuggestedCategory {
|
||||
label: string
|
||||
account: string | null
|
||||
confidence: number
|
||||
source: 'mapping_rule' | 'pattern' | 'history' | 'ai'
|
||||
source: 'mapping_rule' | 'pattern' | 'history'
|
||||
match_reason?: string
|
||||
}
|
||||
|
||||
@@ -165,70 +164,6 @@ function accountToCategory(account: string, amount: number): string | null {
|
||||
return expenseMap[account] || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Source priority for tiebreaking — used when two suggestions
|
||||
* have the same confidence score.
|
||||
*/
|
||||
const SOURCE_PRIORITY: Record<SuggestedCategory['source'], number> = {
|
||||
mapping_rule: 3,
|
||||
ai: 2,
|
||||
pattern: 1,
|
||||
history: 0,
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge AI-generated suggestions into existing suggestion list.
|
||||
* AI suggestions take priority over history-based ones.
|
||||
* Deduplicates by category, preserving the higher-confidence entry.
|
||||
* When transactionAmount is provided, filters out wrong-direction suggestions.
|
||||
*/
|
||||
export function mergeAiSuggestions(
|
||||
existing: SuggestedCategory[],
|
||||
aiSuggestions: { category: string; basAccount: string; confidence: number; reasoning: string }[],
|
||||
transactionAmount?: number
|
||||
): SuggestedCategory[] {
|
||||
const merged = [...existing]
|
||||
|
||||
for (const ai of aiSuggestions) {
|
||||
// Skip suggestions that don't match transaction direction
|
||||
if (transactionAmount !== undefined) {
|
||||
if (transactionAmount > 0 && ai.category.startsWith('expense_')) continue
|
||||
if (transactionAmount < 0 && ai.category.startsWith('income_')) continue
|
||||
}
|
||||
|
||||
const existingIdx = merged.findIndex((s) => s.category === ai.category)
|
||||
if (existingIdx !== -1) {
|
||||
// Upgrade existing entry if the AI has higher confidence
|
||||
if (ai.confidence > merged[existingIdx].confidence) {
|
||||
merged[existingIdx] = {
|
||||
...merged[existingIdx],
|
||||
account: ai.basAccount || merged[existingIdx].account,
|
||||
confidence: ai.confidence,
|
||||
source: 'ai',
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
merged.push({
|
||||
category: ai.category as TransactionCategory,
|
||||
label: CATEGORY_LABELS[ai.category] || ai.category,
|
||||
account: ai.basAccount || null,
|
||||
confidence: ai.confidence,
|
||||
source: 'ai',
|
||||
})
|
||||
}
|
||||
|
||||
// Sort by confidence first, then by source priority as tiebreaker
|
||||
return merged
|
||||
.sort((a, b) => {
|
||||
const confidenceDiff = b.confidence - a.confidence
|
||||
if (confidenceDiff !== 0) return confidenceDiff
|
||||
return SOURCE_PRIORITY[b.source] - SOURCE_PRIORITY[a.source]
|
||||
})
|
||||
.slice(0, 5)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Template Suggestions
|
||||
// ============================================================
|
||||
@@ -340,38 +275,6 @@ export async function getSuggestedTemplates(
|
||||
}
|
||||
}
|
||||
|
||||
// 3. If AI extension loaded, merge in embedding-based matches (higher confidence)
|
||||
try {
|
||||
const aiExt = extensionRegistry.get('ai-categorization')
|
||||
if (aiExt?.services?.findSimilarTemplates) {
|
||||
const aiMatches: TemplateMatch[] = await aiExt.services.findSimilarTemplates(transaction, entityType)
|
||||
for (const m of aiMatches) {
|
||||
const existing = results.find((r) => r.template_id === m.template.id)
|
||||
if (existing) {
|
||||
// AI match upgrades confidence if higher
|
||||
if (m.confidence > existing.confidence) {
|
||||
existing.confidence = m.confidence
|
||||
}
|
||||
} else {
|
||||
results.push({
|
||||
template_id: m.template.id,
|
||||
name_sv: m.template.name_sv,
|
||||
name_en: m.template.name_en,
|
||||
group: m.template.group,
|
||||
debit_account: m.template.debit_account,
|
||||
credit_account: m.template.credit_account,
|
||||
confidence: m.confidence,
|
||||
description_sv: m.template.description_sv,
|
||||
risk_level: m.template.risk_level,
|
||||
requires_review: m.template.requires_review,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// AI enhancement is non-blocking
|
||||
}
|
||||
|
||||
return results
|
||||
.sort((a, b) => b.confidence - a.confidence)
|
||||
.slice(0, 10)
|
||||
|
||||
Reference in New Issue
Block a user