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
@@ -291,7 +291,7 @@ async function sendConsentExpiryNotification(
|
||||
const emailData = {
|
||||
bankName: connection.bank_name as string,
|
||||
daysUntilExpiry: daysLeft,
|
||||
renewalUrl: `${baseUrl}/settings?tab=banking`,
|
||||
renewalUrl: `${baseUrl}/settings/banking`,
|
||||
companyName: companySettings?.company_name || 'gnubok',
|
||||
isExpired,
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import { NextResponse } from 'next/server'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { extensionRegistry } from '@/lib/extensions/registry'
|
||||
import { createExtensionContext } from '@/lib/extensions/context-factory'
|
||||
import { hasAiConsent, isAiExtension } from '@/lib/extensions/ai-consent'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import type { ApiRouteDefinition } from '@/lib/extensions/types'
|
||||
|
||||
@@ -43,7 +42,7 @@ function matchPath(
|
||||
* Catch-all route for extension-declared API routes.
|
||||
*
|
||||
* URL scheme: /api/extensions/ext/{extensionId}/{...routePath}
|
||||
* Example: /api/extensions/ext/receipt-ocr/abc123/confirm → POST /:id/confirm
|
||||
* Example: /api/extensions/ext/mcp-server/mcp → POST /mcp
|
||||
*
|
||||
* - Looks up the extension in the registry
|
||||
* - Checks the extension toggle (disabled → 403)
|
||||
@@ -120,17 +119,6 @@ async function handleRequest(
|
||||
|
||||
const companyId = await requireCompanyId(supabase, user.id)
|
||||
|
||||
// AI consent check
|
||||
if (isAiExtension(extensionId)) {
|
||||
const consented = await hasAiConsent(supabase, companyId, extensionId)
|
||||
if (!consented) {
|
||||
return NextResponse.json(
|
||||
{ error: 'AI consent required', code: 'AI_CONSENT_REQUIRED' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// If path params were extracted, create a new Request with them as search params
|
||||
let handlerRequest = request
|
||||
if (Object.keys(extractedParams).length > 0) {
|
||||
|
||||
@@ -1,158 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { createQueuedMockSupabase, parseJsonResponse } from '@/tests/helpers'
|
||||
|
||||
// Mock server-only
|
||||
vi.mock('server-only', () => ({}))
|
||||
|
||||
// Hoisted mocks to avoid reference-before-initialization
|
||||
const { mockVerify, mockServiceClientFn } = vi.hoisted(() => {
|
||||
return {
|
||||
mockVerify: vi.fn(),
|
||||
mockServiceClientFn: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
// Mock svix
|
||||
vi.mock('svix', () => ({
|
||||
Webhook: class MockWebhook {
|
||||
verify = mockVerify
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock Supabase SSR
|
||||
vi.mock('@supabase/ssr', () => ({
|
||||
createServerClient: (...args: unknown[]) => mockServiceClientFn(...args),
|
||||
}))
|
||||
|
||||
// Mock email handler
|
||||
vi.mock('@/extensions/general/invoice-inbox/lib/email-handler', () => ({
|
||||
parseInboundPayload: vi.fn(),
|
||||
extractAttachments: vi.fn(),
|
||||
resolveUserFromEmail: vi.fn(),
|
||||
}))
|
||||
|
||||
// Mock unified document analyzer
|
||||
vi.mock('@/lib/ai/document-analyzer', () => ({
|
||||
analyzeDocument: vi.fn(),
|
||||
}))
|
||||
|
||||
// Mock supplier matcher
|
||||
vi.mock('@/extensions/general/invoice-inbox/lib/supplier-matcher', () => ({
|
||||
matchSupplier: vi.fn(),
|
||||
}))
|
||||
|
||||
// Mock receipt pipeline
|
||||
vi.mock('@/extensions/general/receipt-ocr/lib/receipt-pipeline', () => ({
|
||||
processReceiptFromDocument: vi.fn(),
|
||||
}))
|
||||
|
||||
import { POST } from '../route'
|
||||
import { parseInboundPayload, extractAttachments, resolveUserFromEmail } from '@/extensions/general/invoice-inbox/lib/email-handler'
|
||||
|
||||
const mockParseInboundPayload = vi.mocked(parseInboundPayload)
|
||||
const mockExtractAttachments = vi.mocked(extractAttachments)
|
||||
const mockResolveUserFromEmail = vi.mocked(resolveUserFromEmail)
|
||||
|
||||
describe('Invoice Inbox Webhook Route', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
process.env.RESEND_WEBHOOK_SECRET = 'test-secret'
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL = 'http://localhost:54321'
|
||||
process.env.SUPABASE_SERVICE_ROLE_KEY = 'test-key'
|
||||
})
|
||||
|
||||
function makeWebhookRequest(body: unknown = {}) {
|
||||
const bodyStr = JSON.stringify(body)
|
||||
return new Request('http://localhost:3000/api/extensions/invoice-inbox/webhook', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'svix-id': 'msg_test123',
|
||||
'svix-timestamp': String(Math.floor(Date.now() / 1000)),
|
||||
'svix-signature': 'v1,test-signature',
|
||||
},
|
||||
body: bodyStr,
|
||||
})
|
||||
}
|
||||
|
||||
it('returns 400 when webhook headers are missing', async () => {
|
||||
const request = new Request('http://localhost:3000/api/extensions/invoice-inbox/webhook', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: '{}',
|
||||
})
|
||||
|
||||
const response = await POST(request)
|
||||
const { status } = await parseJsonResponse(response)
|
||||
expect(status).toBe(400)
|
||||
})
|
||||
|
||||
it('returns 401 when signature verification fails', async () => {
|
||||
mockVerify.mockImplementation(() => {
|
||||
throw new Error('Invalid signature')
|
||||
})
|
||||
|
||||
const response = await POST(makeWebhookRequest({ from: 'test@test.com', to: 'inbox@co.com' }))
|
||||
const { status } = await parseJsonResponse(response)
|
||||
expect(status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns 400 when payload is invalid', async () => {
|
||||
mockVerify.mockReturnValue(undefined)
|
||||
mockParseInboundPayload.mockReturnValue(null)
|
||||
|
||||
const response = await POST(makeWebhookRequest({}))
|
||||
const { status } = await parseJsonResponse(response)
|
||||
expect(status).toBe(400)
|
||||
})
|
||||
|
||||
it('returns 404 when user not found for email', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
mockServiceClientFn.mockReturnValue(supabase)
|
||||
|
||||
mockVerify.mockReturnValue(undefined)
|
||||
mockParseInboundPayload.mockReturnValue({
|
||||
from: 'supplier@test.com',
|
||||
to: 'unknown@inbox.com',
|
||||
subject: 'Invoice',
|
||||
html: null,
|
||||
text: null,
|
||||
attachments: [],
|
||||
created_at: '2024-06-15T10:00:00Z',
|
||||
})
|
||||
mockResolveUserFromEmail.mockResolvedValue(null)
|
||||
|
||||
const response = await POST(makeWebhookRequest({ from: 'supplier@test.com', to: 'unknown@inbox.com' }))
|
||||
const { status } = await parseJsonResponse(response)
|
||||
expect(status).toBe(404)
|
||||
})
|
||||
|
||||
it('returns success with 0 processed when no attachments', async () => {
|
||||
const { supabase, enqueueMany } = createQueuedMockSupabase()
|
||||
mockServiceClientFn.mockReturnValue(supabase)
|
||||
|
||||
mockVerify.mockReturnValue(undefined)
|
||||
mockParseInboundPayload.mockReturnValue({
|
||||
from: 'supplier@test.com',
|
||||
to: 'inbox@myco.com',
|
||||
subject: 'No attachments',
|
||||
html: null,
|
||||
text: null,
|
||||
attachments: [],
|
||||
created_at: '2024-06-15T10:00:00Z',
|
||||
})
|
||||
mockExtractAttachments.mockReturnValue([])
|
||||
mockResolveUserFromEmail.mockResolvedValue({ userId: 'user-1', companyId: 'company-1' })
|
||||
|
||||
// Insert inbox item with error status
|
||||
enqueueMany([
|
||||
{ data: { id: 'item-1' }, error: null },
|
||||
])
|
||||
|
||||
const response = await POST(makeWebhookRequest({ from: 'supplier@test.com', to: 'inbox@myco.com' }))
|
||||
const { status, body } = await parseJsonResponse<{ data: { processed: number } }>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.processed).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -1,290 +0,0 @@
|
||||
import { createServerClient } from '@supabase/ssr'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { Webhook } from 'svix'
|
||||
import { parseInboundPayload, extractAttachments, resolveUserFromEmail } from '@/extensions/general/invoice-inbox/lib/email-handler'
|
||||
import { matchSupplier } from '@/extensions/general/invoice-inbox/lib/supplier-matcher'
|
||||
import { analyzeDocument } from '@/lib/ai/document-analyzer'
|
||||
import { processReceiptFromDocument } from '@/extensions/general/receipt-ocr/lib/receipt-pipeline'
|
||||
import crypto from 'crypto'
|
||||
|
||||
function createServiceClient() {
|
||||
return createServerClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.SUPABASE_SERVICE_ROLE_KEY!,
|
||||
{
|
||||
cookies: {
|
||||
getAll() { return [] },
|
||||
setAll() { },
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build raw email payload for BFL 7 kap. 2§ archiving.
|
||||
* Includes full email headers and body — excludes binary attachment content.
|
||||
*/
|
||||
function buildRawEmailPayload(body: Record<string, unknown>, payload: { from: string; to: string; subject: string; created_at: string }): Record<string, unknown> {
|
||||
return {
|
||||
from: payload.from,
|
||||
to: payload.to,
|
||||
subject: payload.subject,
|
||||
created_at: payload.created_at,
|
||||
text: body.text ?? null,
|
||||
html: body.html ?? null,
|
||||
headers: body.headers ?? null,
|
||||
message_id: body.message_id ?? null,
|
||||
in_reply_to: body.in_reply_to ?? null,
|
||||
references: body.references ?? null,
|
||||
archived_at: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
// Verify webhook signature
|
||||
const webhookSecret = process.env.RESEND_WEBHOOK_SECRET
|
||||
if (!webhookSecret) {
|
||||
console.error('[document-inbox] RESEND_WEBHOOK_SECRET not configured')
|
||||
return NextResponse.json({ error: 'Webhook not configured' }, { status: 500 })
|
||||
}
|
||||
|
||||
const svixId = request.headers.get('svix-id')
|
||||
const svixTimestamp = request.headers.get('svix-timestamp')
|
||||
const svixSignature = request.headers.get('svix-signature')
|
||||
|
||||
if (!svixId || !svixTimestamp || !svixSignature) {
|
||||
return NextResponse.json({ error: 'Missing webhook headers' }, { status: 400 })
|
||||
}
|
||||
|
||||
const rawBody = await request.text()
|
||||
|
||||
try {
|
||||
const wh = new Webhook(webhookSecret)
|
||||
wh.verify(rawBody, {
|
||||
'svix-id': svixId,
|
||||
'svix-timestamp': svixTimestamp,
|
||||
'svix-signature': svixSignature,
|
||||
})
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid signature' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = JSON.parse(rawBody)
|
||||
const payload = parseInboundPayload(body)
|
||||
|
||||
if (!payload) {
|
||||
return NextResponse.json({ error: 'Invalid payload' }, { status: 400 })
|
||||
}
|
||||
|
||||
const supabase = createServiceClient()
|
||||
|
||||
// Resolve user from recipient email
|
||||
const resolved = await resolveUserFromEmail(payload.to, supabase)
|
||||
|
||||
if (!resolved) {
|
||||
console.warn(`[document-inbox] No user found for email: ${payload.to}`)
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const { userId, companyId } = resolved
|
||||
|
||||
// Build raw email payload for BFL 7:2 archiving (no binary attachment content)
|
||||
const rawEmailPayload = buildRawEmailPayload(body, payload)
|
||||
|
||||
// Extract file attachments
|
||||
const attachments = extractAttachments(payload)
|
||||
|
||||
if (attachments.length === 0) {
|
||||
await supabase
|
||||
.from('invoice_inbox_items')
|
||||
.insert({
|
||||
company_id: companyId,
|
||||
user_id: userId,
|
||||
status: 'error',
|
||||
source: 'email',
|
||||
email_from: payload.from,
|
||||
email_subject: payload.subject,
|
||||
email_received_at: payload.created_at,
|
||||
error_message: 'No supported attachments found',
|
||||
raw_email_payload: rawEmailPayload,
|
||||
})
|
||||
|
||||
return NextResponse.json({ data: { processed: 0, message: 'No attachments' } })
|
||||
}
|
||||
|
||||
const processed: string[] = []
|
||||
|
||||
for (const attachment of attachments) {
|
||||
try {
|
||||
const buffer = Buffer.from(attachment.content, 'base64')
|
||||
const hash = crypto.createHash('sha256').update(buffer).digest('hex')
|
||||
|
||||
// Upload to storage
|
||||
const storagePath = `documents/${userId}/inbox/${Date.now()}-${attachment.filename}`
|
||||
const { error: uploadError } = await supabase.storage
|
||||
.from('documents')
|
||||
.upload(storagePath, buffer, { contentType: attachment.content_type })
|
||||
|
||||
if (uploadError) {
|
||||
console.error('[document-inbox] Upload failed:', uploadError)
|
||||
continue
|
||||
}
|
||||
|
||||
// Create document attachment
|
||||
const { data: document, error: docError } = await supabase
|
||||
.from('document_attachments')
|
||||
.insert({
|
||||
company_id: companyId,
|
||||
user_id: userId,
|
||||
storage_path: storagePath,
|
||||
file_name: attachment.filename,
|
||||
file_size_bytes: buffer.length,
|
||||
mime_type: attachment.content_type,
|
||||
sha256_hash: hash,
|
||||
upload_source: 'email',
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (docError || !document) continue
|
||||
|
||||
// Unified classify + extract in a single Claude call
|
||||
let documentType: 'supplier_invoice' | 'receipt' | 'government_letter' | 'unknown' = 'supplier_invoice'
|
||||
let unifiedResult: Awaited<ReturnType<typeof analyzeDocument>> | null = null
|
||||
try {
|
||||
unifiedResult = await analyzeDocument(attachment.content, attachment.content_type)
|
||||
documentType = unifiedResult.classification.type
|
||||
console.log(`[document-inbox] Classified as ${documentType} (confidence: ${unifiedResult.classification.confidence})`)
|
||||
} catch (classifyErr) {
|
||||
console.error('[document-inbox] Classification failed, defaulting to supplier_invoice:', classifyErr)
|
||||
}
|
||||
|
||||
// Create inbox item with document type and raw email payload
|
||||
const { data: inboxItem, error: itemError } = await supabase
|
||||
.from('invoice_inbox_items')
|
||||
.insert({
|
||||
company_id: companyId,
|
||||
user_id: userId,
|
||||
status: 'processing',
|
||||
source: 'email',
|
||||
email_from: payload.from,
|
||||
email_subject: payload.subject,
|
||||
email_received_at: payload.created_at,
|
||||
document_id: document.id,
|
||||
document_type: documentType,
|
||||
raw_email_payload: rawEmailPayload,
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (itemError || !inboxItem) continue
|
||||
|
||||
// Route based on document type
|
||||
try {
|
||||
switch (documentType) {
|
||||
case 'supplier_invoice': {
|
||||
// Use pre-extracted invoice data from unified call
|
||||
const extraction = unifiedResult?.invoice
|
||||
if (!extraction) {
|
||||
throw new Error('No invoice extraction available')
|
||||
}
|
||||
|
||||
const isReverseCharge = unifiedResult?.classification.isReverseCharge ?? false
|
||||
|
||||
// Store reverse charge flag in extracted data
|
||||
const extractedData = {
|
||||
...(extraction as unknown as Record<string, unknown>),
|
||||
isReverseCharge,
|
||||
}
|
||||
|
||||
// Supplier matching
|
||||
let matchedSupplierId: string | null = null
|
||||
const { data: suppliers } = await supabase
|
||||
.from('suppliers')
|
||||
.select('*')
|
||||
.eq('company_id', companyId)
|
||||
|
||||
if (suppliers && suppliers.length > 0) {
|
||||
const match = matchSupplier(extraction, suppliers)
|
||||
if (match && match.confidence >= 0.7) {
|
||||
matchedSupplierId = match.supplierId
|
||||
}
|
||||
}
|
||||
|
||||
await supabase
|
||||
.from('invoice_inbox_items')
|
||||
.update({
|
||||
status: 'ready',
|
||||
extracted_data: extractedData,
|
||||
confidence: extraction.confidence,
|
||||
matched_supplier_id: matchedSupplierId,
|
||||
})
|
||||
.eq('id', inboxItem.id)
|
||||
break
|
||||
}
|
||||
|
||||
case 'receipt': {
|
||||
// Use pre-extracted receipt data from unified call
|
||||
const { data: urlData } = supabase.storage.from('documents').getPublicUrl(storagePath)
|
||||
|
||||
const result = await processReceiptFromDocument(supabase, userId, companyId, attachment.content, attachment.content_type, {
|
||||
documentId: document.id,
|
||||
source: 'email',
|
||||
emailFrom: payload.from,
|
||||
storageUrl: urlData.publicUrl,
|
||||
preExtracted: unifiedResult?.receipt ?? undefined,
|
||||
})
|
||||
|
||||
await supabase
|
||||
.from('invoice_inbox_items')
|
||||
.update({
|
||||
status: 'ready',
|
||||
linked_receipt_id: result.receipt.id,
|
||||
confidence: result.receipt.extraction_confidence,
|
||||
})
|
||||
.eq('id', inboxItem.id)
|
||||
break
|
||||
}
|
||||
|
||||
case 'government_letter': {
|
||||
// Store with status ready for manual review
|
||||
await supabase
|
||||
.from('invoice_inbox_items')
|
||||
.update({
|
||||
status: 'ready',
|
||||
extracted_data: {
|
||||
sender: payload.from,
|
||||
subject: payload.subject,
|
||||
body: typeof body.text === 'string' ? body.text : null,
|
||||
},
|
||||
})
|
||||
.eq('id', inboxItem.id)
|
||||
break
|
||||
}
|
||||
|
||||
case 'unknown':
|
||||
default: {
|
||||
// Store with status ready for manual handling
|
||||
await supabase
|
||||
.from('invoice_inbox_items')
|
||||
.update({ status: 'ready' })
|
||||
.eq('id', inboxItem.id)
|
||||
break
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Processing failed'
|
||||
await supabase
|
||||
.from('invoice_inbox_items')
|
||||
.update({ status: 'error', error_message: message })
|
||||
.eq('id', inboxItem.id)
|
||||
}
|
||||
|
||||
processed.push(inboxItem.id)
|
||||
} catch (err) {
|
||||
console.error('[document-inbox] Processing attachment failed:', err)
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: { processed: processed.length, ids: processed } })
|
||||
}
|
||||
Reference in New Issue
Block a user