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:
Jakob Wennberg
2026-04-01 17:08:00 +02:00
committed by GitHub
co-authored by Claude Opus 4.6
parent e89f2c402d
commit d0b3f21bde
172 changed files with 3621 additions and 20538 deletions
-120
View File
@@ -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',
})
})
})
+5 -5
View File
@@ -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', () => {