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