feat: wire Zod validation into API routes, improve types and components
- Add 8 new Zod schemas (UpdateCustomer, UpdateSupplier, UpdateSupplierInvoice, UpdateAccount, BankUnlink, RunReconciliation, CorrectJournalEntry, EvaluateMappingRules) and wire validateBody() into 24 JSON-body API routes - Remove redundant manual validation checks replaced by Zod - Add comprehensive schema tests (222 tests) - Improve type definitions in types/index.ts with expanded interfaces - Refactor extension types (push-notifications, receipt-ocr) for cleaner imports - Update transaction components (BatchCategorySelector, SwipeCategorizationView, QuickReviewDialog, VatTreatmentSelect) and invoice inbox workspace - Add invoice-inbox utilities and type decoupling tests - Fix NE-bilaga, SRU export, and invoice PDF template type usage - Update CLAUDE.md with expanded architecture documentation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
cfc4cb6849
commit
acb85edf4a
@@ -0,0 +1,128 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
getStatusLabel,
|
||||
getStatusVariant,
|
||||
getConfidenceLabel,
|
||||
formatExtractionSummary,
|
||||
} from '../invoice-inbox-utils'
|
||||
import type { InboxItemStatus } from '@/types'
|
||||
import type { InvoiceExtractionResult } from '@/extensions/general/invoice-inbox/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,
|
||||
})
|
||||
})
|
||||
|
||||
it('handles undefined data', () => {
|
||||
expect(formatExtractionSummary(undefined)).toEqual({
|
||||
supplierName: '',
|
||||
total: 0,
|
||||
lineCount: 0,
|
||||
})
|
||||
})
|
||||
|
||||
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,
|
||||
})
|
||||
})
|
||||
|
||||
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,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { readFileSync } from 'fs'
|
||||
import { resolve } from 'path'
|
||||
|
||||
describe('type decoupling', () => {
|
||||
it('types/index.ts must not import from @/extensions/', () => {
|
||||
const typesPath = resolve(__dirname, '../../../types/index.ts')
|
||||
const content = readFileSync(typesPath, 'utf-8')
|
||||
|
||||
const extensionImports = content
|
||||
.split('\n')
|
||||
.filter((line) => /from\s+['"]@\/extensions\//.test(line))
|
||||
|
||||
expect(extensionImports).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { InboxItemStatus } from '@/types'
|
||||
import type { InvoiceExtractionResult } from '@/extensions/general/invoice-inbox/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 } {
|
||||
if (!data) {
|
||||
return { supplierName: '', total: 0, lineCount: 0 }
|
||||
}
|
||||
return {
|
||||
supplierName: data.supplier?.name ?? '',
|
||||
total: data.totals?.total ?? 0,
|
||||
lineCount: data.lineItems?.length ?? 0,
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ const WORKSPACES: Record<WorkspaceKey, ComponentType<WorkspaceComponentProps>> =
|
||||
'general/ai-categorization': dynamic(() => import('@/components/extensions/general/AiCategorizationWorkspace')),
|
||||
'general/ai-chat': dynamic(() => import('@/components/extensions/general/AiChatWorkspace')),
|
||||
'general/push-notifications': dynamic(() => import('@/components/extensions/general/PushNotificationsWorkspace')),
|
||||
'general/invoice-inbox': dynamic(() => import('@/components/extensions/general/InvoiceInboxWorkspace')),
|
||||
'general/enable-banking': dynamic(() => import('@/components/extensions/general/EnableBankingWorkspace')),
|
||||
// Restaurant
|
||||
'restaurant/food-cost': dynamic(() => import('@/components/extensions/restaurant/FoodCostWorkspace')),
|
||||
|
||||
Reference in New Issue
Block a user