diff --git a/components/extensions/general/InvoiceInboxWorkspace.tsx b/components/extensions/general/InvoiceInboxWorkspace.tsx index 6ea8a4eb..83b0c16b 100644 --- a/components/extensions/general/InvoiceInboxWorkspace.tsx +++ b/components/extensions/general/InvoiceInboxWorkspace.tsx @@ -1,7 +1,7 @@ 'use client' import { useState, useCallback, useEffect, useRef } from 'react' -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Card, CardContent } from '@/components/ui/card' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Skeleton } from '@/components/ui/skeleton' @@ -14,14 +14,6 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select' -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@/components/ui/table' import { Dialog, DialogContent, @@ -37,6 +29,7 @@ import { Upload, Mail, FileText, + Receipt as ReceiptIcon, RefreshCw, Check, X, @@ -44,10 +37,14 @@ import { Loader2, Plus, Trash2, + Copy, + RotateCcw, + ArrowRight, + Sparkles, } from 'lucide-react' import Link from 'next/link' -import { useSearchParams, useRouter } from 'next/navigation' -import { formatCurrency, formatDate } from '@/lib/utils' +import { cn, formatCurrency } from '@/lib/utils' +import { InfoTooltip } from '@/components/ui/info-tooltip' import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry' import type { InvoiceExtractionResult } from '@/types' @@ -66,6 +63,17 @@ interface InboxItem { email_from: string | null email_subject: string | null error_message: string | null + matched_transaction_id: string | null + match_confidence: number | null + match_method: string | null + match_reasoning: string | null + matched_transaction: { + id: string + description: string | null + amount: number + currency: string + date: string + } | null } interface Supplier { @@ -129,13 +137,6 @@ const VAT_OPTIONS = [ // ── Helpers ────────────────────────────────────────────────── -function confidenceBadge(confidence: number | null) { - if (confidence === null) return null - if (confidence >= 0.9) return Hög - if (confidence >= 0.7) return Medium - return Låg -} - function extractSupplierName(item: InboxItem): string | null { if (item.document_type !== 'supplier_invoice' || !item.extracted_data) return null return item.extracted_data.supplier?.name || null @@ -155,6 +156,96 @@ function extractCurrency(item: InboxItem): string { return (invoice?.currency as string) || (receipt?.currency as string) || 'SEK' } +type ConfidenceLevel = 'high' | 'medium' | 'low' + +function confidenceLevel(conf: number | null): ConfidenceLevel { + if (conf == null) return 'low' + if (conf >= 0.85) return 'high' + if (conf >= 0.6) return 'medium' + return 'low' +} + +function MatchBlock({ item }: { item: InboxItem }) { + if (item.document_type !== 'receipt') return null + + const isMatching = item.match_method === null && item.status === 'ready' + const isPending = item.match_method === 'pending_transaction' + const isMatched = !!item.matched_transaction_id && !!item.matched_transaction + + if (isMatching) { + return ( +
+ + AI letar efter matchande transaktion… +
+ ) + } + + if (isPending) { + return ( +
+ + Inväntar matchande banktransaktion + {item.match_reasoning && ( + + )} +
+ ) + } + + if (isMatched && item.matched_transaction) { + const tx = item.matched_transaction + const confidencePct = item.match_confidence != null ? Math.round(item.match_confidence * 100) : null + const level = confidenceLevel(item.match_confidence) + return ( +
+
+
+ + {tx.description || 'Matchad transaktion'} + · + {formatCurrency(Math.abs(tx.amount), tx.currency)} + · + {tx.date} +
+ {confidencePct != null && ( + + + {confidencePct}% + + )} +
+ {item.match_reasoning && ( +

+ {item.match_reasoning} +

+ )} +
+ ) + } + + return null +} + function timeAgo(isoDate: string): string { const diff = Date.now() - new Date(isoDate).getTime() const minutes = Math.floor(diff / 60000) @@ -223,23 +314,22 @@ function WorkspaceSkeleton() { // ── Main Component ─────────────────────────────────────────── -export default function InvoiceInboxWorkspace({ userId }: WorkspaceComponentProps) { +export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) { const { toast } = useToast() const fileInputRef = useRef(null) - const searchParams = useSearchParams() - const router = useRouter() const [items, setItems] = useState([]) const [isLoading, setIsLoading] = useState(true) const [statusFilter, setStatusFilter] = useState('all') - const [isScanning, setIsScanning] = useState(false) const [isUploading, setIsUploading] = useState(false) - // Gmail connection state - const [gmailConnection, setGmailConnection] = useState<{ - email_address: string; status: string; last_sync_at: string | null + // Arcim inbox state + const [inboxAddress, setInboxAddress] = useState<{ + address: string + local_part: string + status: string } | null>(null) - const [isConnectingGmail, setIsConnectingGmail] = useState(false) + const [isRotating, setIsRotating] = useState(false) // Convert dialog state const [convertItem, setConvertItem] = useState(null) @@ -275,55 +365,66 @@ export default function InvoiceInboxWorkspace({ userId }: WorkspaceComponentProp } }, [statusFilter, toast]) - const fetchGmailStatus = useCallback(async () => { + const fetchInboxAddress = useCallback(async () => { try { - const res = await fetch('/api/extensions/ext/invoice-inbox/gmail/status') - if (!res.ok) return + const res = await fetch('/api/extensions/ext/invoice-inbox/inbox/address') + if (!res.ok) { + setInboxAddress(null) + return + } const { data } = await res.json() - const active = data?.connections?.find((c: { status: string }) => c.status === 'active') - setGmailConnection(active || null) + setInboxAddress(data || null) } catch { /* silent */ } }, []) - const handleConnectGmail = useCallback(async () => { - setIsConnectingGmail(true) + const handleCopyAddress = useCallback(async () => { + if (!inboxAddress?.address) return try { - const res = await fetch('/api/extensions/ext/invoice-inbox/gmail/auth') - if (!res.ok) throw new Error('Failed to get auth URL') - const { data } = await res.json() - window.location.href = data.authUrl + await navigator.clipboard.writeText(inboxAddress.address) + toast({ title: 'Adress kopierad' }) } catch { - toast({ title: 'Kunde inte starta Gmail-koppling', variant: 'destructive' }) - setIsConnectingGmail(false) + toast({ title: 'Kunde inte kopiera', variant: 'destructive' }) } - }, [toast]) + }, [inboxAddress, toast]) - const handleDisconnectGmail = useCallback(async () => { + const handleRotateAddress = useCallback(async () => { + if (!inboxAddress) return + const confirmed = window.confirm( + 'Den gamla adressen slutar ta emot e-post direkt. Leverantörer som använder den måste uppdateras. Vill du fortsätta?' + ) + if (!confirmed) return + + setIsRotating(true) try { - const res = await fetch('/api/extensions/ext/invoice-inbox/gmail/disconnect', { method: 'POST' }) - if (!res.ok) throw new Error('Failed to disconnect') - setGmailConnection(null) - toast({ title: 'Gmail frånkopplad' }) - } catch { - toast({ title: 'Kunde inte koppla från Gmail', variant: 'destructive' }) + const res = await fetch('/api/extensions/ext/invoice-inbox/inbox/rotate', { method: 'POST' }) + if (!res.ok) { + const { error } = await res.json().catch(() => ({ error: 'Rotation misslyckades' })) + throw new Error(error) + } + const { data } = await res.json() + setInboxAddress(data) + toast({ title: 'Ny adress skapad' }) + } catch (err) { + toast({ title: err instanceof Error ? err.message : 'Rotation misslyckades', variant: 'destructive' }) + } finally { + setIsRotating(false) } - }, [toast]) + }, [inboxAddress, toast]) useEffect(() => { fetchItems() - fetchGmailStatus() + fetchInboxAddress() + }, [fetchItems, fetchInboxAddress]) - // Handle OAuth callback redirect - const gmailParam = searchParams.get('gmail') - const errorParam = searchParams.get('error') - if (gmailParam === 'connected') { - toast({ title: 'Gmail kopplad' }) - router.replace('/e/general/invoice-inbox') - } else if (errorParam?.startsWith('gmail_')) { - toast({ title: 'Gmail-koppling misslyckades', variant: 'destructive' }) - router.replace('/e/general/invoice-inbox') - } - }, [fetchItems, fetchGmailStatus, searchParams, router, toast]) + // Poll while any receipt is mid-match so the UI updates when the LLM returns + useEffect(() => { + const hasInFlight = items.some( + (i) => i.document_type === 'receipt' && i.status === 'ready' && i.match_method === null + ) + if (!hasInFlight) return + const interval = setInterval(() => { fetchItems() }, 3000) + return () => clearInterval(interval) + }, [items, fetchItems]) const fetchSuppliers = useCallback(async () => { try { @@ -358,21 +459,18 @@ export default function InvoiceInboxWorkspace({ userId }: WorkspaceComponentProp } }, [fetchItems, toast]) - const handleScanGmail = useCallback(async () => { - setIsScanning(true) + const handleViewDocument = useCallback(async (documentId: string) => { try { - const res = await fetch('/api/extensions/ext/invoice-inbox/gmail/scan', { method: 'POST' }) - if (!res.ok) throw new Error('Scan failed') + const res = await fetch(`/api/documents/${documentId}`) + if (!res.ok) throw new Error('Kunde inte hämta dokument') const { data } = await res.json() - toast({ title: `Gmail skannad: ${data.scanned} dokument, ${data.classified} klassificerade` }) - await fetchItems() - await fetchGmailStatus() - } catch { - toast({ title: 'Gmail-skanning misslyckades', variant: 'destructive' }) - } finally { - setIsScanning(false) + if (data?.download_url) { + window.open(data.download_url, '_blank', 'noopener,noreferrer') + } + } catch (err) { + toast({ title: err instanceof Error ? err.message : 'Kunde inte öppna dokumentet', variant: 'destructive' }) } - }, [fetchItems, fetchGmailStatus, toast]) + }, [toast]) const handleReject = useCallback(async (itemId: string) => { try { @@ -587,7 +685,6 @@ export default function InvoiceInboxWorkspace({ userId }: WorkspaceComponentProp const readyCount = items.filter((i) => i.status === 'ready').length const confirmedCount = items.filter((i) => i.status === 'confirmed').length - const errorCount = items.filter((i) => i.status === 'error').length const formTotal = convertForm ? convertForm.items.reduce((sum, item) => { const vatAmount = Math.round(item.amount * item.vat_rate * 100) / 100 @@ -603,70 +700,33 @@ export default function InvoiceInboxWorkspace({ userId }: WorkspaceComponentProp return (
- {/* Gmail connection banner */} - {gmailConnection ? ( + {/* Arcim inbox address */} + {inboxAddress && ( -
-
- -
-
-

{gmailConnection.email_address}

-

- {gmailConnection.last_sync_at - ? `Senast skannad: ${timeAgo(gmailConnection.last_sync_at)}` - : 'Inte skannad ännu'} -

-
-
- -
-
- ) : ( - - -
-
+
+
-
-

Koppla Gmail

-

Hämta leverantörsfakturor automatiskt från din e-post

+
+

Din fakturainkorg

+

{inboxAddress.address}

- +
+ + +
)} - {/* Summary cards */} -
- - -

Att granska

-

{readyCount}

-
-
- - -

Konverterade

-

{confirmedCount}

-
-
- - -

Fel

-

{errorCount}

-
-
-
- {/* Action bar */}
: } Ladda upp - {gmailConnection && ( - - )} - - ) : ( - - - - ) + fileInputRef.current?.click()} + /> ) : ( - - - - - - - Dokumenttyp - Leverantör - Belopp - Konfidensgrad - Status - Mottagen - Åtgärder - - - - {items.map((item) => { - const supplierName = extractSupplierName(item) - const amount = extractAmount(item) - const isConvertable = item.status === 'ready' && item.document_type === 'supplier_invoice' - const isDismissable = item.status === 'ready' +
+ {items.map((item) => { + const supplierName = extractSupplierName(item) + const amount = extractAmount(item) + const currency = extractCurrency(item) + const isReceipt = item.document_type === 'receipt' + const isConvertable = item.status === 'ready' && item.document_type === 'supplier_invoice' + const primaryLabel = supplierName + || item.email_from?.replace(/<.+?>$/, '').replace(/"/g, '').trim() + || '—' - return ( - - - {item.source === 'email' ? ( - - ) : ( - - )} - - - {DOC_TYPE_LABELS[item.document_type] || item.document_type} - - - {supplierName || '—'} - {item.email_from && !supplierName && ( - {item.email_from} - )} - - - {amount != null ? formatCurrency(amount, extractCurrency(item)) : '—'} - - {confidenceBadge(item.confidence)} - - - {STATUS_LABELS[item.status] || item.status} - - - + return ( +
+ {/* Document icon */} +
+ {isReceipt ? ( + + ) : ( + + )} +
+ + {/* Content */} +
+ {/* Header row */} +
+
+ + {DOC_TYPE_LABELS[item.document_type] || item.document_type} + + · + {primaryLabel} + {item.source === 'email' && ( + + )} +
+
+ {amount != null && ( + + {formatCurrency(amount, currency)} + + )} + {timeAgo(item.created_at)} - - -
- {isConvertable && ( - - )} - {isDismissable && ( - - )} -
-
- - ) - })} - -
-
-
+ +
+
+ + {/* Status chip line — only non-default states */} + {(item.status === 'confirmed' || item.status === 'rejected' || item.status === 'error') && ( +
+ + {STATUS_LABELS[item.status] || item.status} + + {item.error_message && ( + {item.error_message} + )} +
+ )} + + {/* Match block (receipts only) */} + + + {/* Actions */} + {item.status === 'ready' && ( +
+ {isConvertable ? ( + + ) : ( + item.document_id && ( + + ) + )} + +
+ )} +
+
+ ) + })} +
)} {/* Convert dialog */} diff --git a/extensions.schema.json b/extensions.schema.json index b4720a63..38137533 100644 --- a/extensions.schema.json +++ b/extensions.schema.json @@ -21,6 +21,7 @@ "ai-chat", "push-notifications", "invoice-inbox", + "inbox-smart-match", "calendar", "enable-banking", "email", diff --git a/extensions/general/inbox-smart-match/__tests__/fetch-candidates.test.ts b/extensions/general/inbox-smart-match/__tests__/fetch-candidates.test.ts new file mode 100644 index 00000000..ebcfb5d7 --- /dev/null +++ b/extensions/general/inbox-smart-match/__tests__/fetch-candidates.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect } from 'vitest' +import { fetchCandidateTransactions } from '@/extensions/general/inbox-smart-match/lib/fetch-candidates' +import { createQueuedMockSupabase } from '@/tests/helpers' +import type { ReceiptExtractionResult } from '@/types' + +function makeReceipt(overrides?: Partial): ReceiptExtractionResult { + return { + merchant: { name: 'Willys Hemma', orgNumber: null, vatNumber: null, isForeign: false }, + receipt: { date: '2026-04-15', time: null, currency: 'SEK' }, + lineItems: [], + totals: { subtotal: 239, vatAmount: 60, total: 299 }, + flags: { isRestaurant: false, isSystembolaget: false, isForeignMerchant: false }, + confidence: 0.9, + ...overrides, + } as ReceiptExtractionResult +} + +describe('fetchCandidateTransactions', () => { + it('returns empty list when extracted data is missing', async () => { + const { supabase } = createQueuedMockSupabase() + const result = await fetchCandidateTransactions(supabase as never, 'company-1', null) + expect(result).toEqual([]) + }) + + it('returns empty list when no anchors could be derived', async () => { + const { supabase } = createQueuedMockSupabase() + const result = await fetchCandidateTransactions( + supabase as never, + 'company-1', + makeReceipt({ totals: { subtotal: 0, vatAmount: 0, total: 0 } } as never) + ) + expect(result).toEqual([]) + }) + + it('ranks candidates by amount proximity and returns top 5', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + // 1. already-matched lookup (none) + enqueue({ data: [] }) + // 2. candidate transactions + enqueue({ + data: [ + { id: 't1', date: '2026-04-15', description: 'A', amount: -400, amount_sek: null, currency: 'SEK', merchant_name: null }, + { id: 't2', date: '2026-04-14', description: 'B', amount: -299, amount_sek: null, currency: 'SEK', merchant_name: null }, // perfect match + { id: 't3', date: '2026-04-16', description: 'C', amount: -305, amount_sek: null, currency: 'SEK', merchant_name: null }, // close + { id: 't4', date: '2026-04-15', description: 'D', amount: -150, amount_sek: null, currency: 'SEK', merchant_name: null }, + { id: 't5', date: '2026-04-13', description: 'E', amount: -298, amount_sek: null, currency: 'SEK', merchant_name: null }, + { id: 't6', date: '2026-04-15', description: 'F', amount: -600, amount_sek: null, currency: 'SEK', merchant_name: null }, + ], + }) + + const result = await fetchCandidateTransactions(supabase as never, 'company-1', makeReceipt()) + + expect(result).toHaveLength(5) + expect(result[0].id).toBe('t2') // exact match sorted first + expect(result[1].id).toBe('t5') // ±1 next + }) + + it('excludes transactions already claimed by other inbox items', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + // 1. already-matched lookup — t2 is already taken + enqueue({ data: [{ matched_transaction_id: 't2' }] }) + // 2. candidate transactions + enqueue({ + data: [ + { id: 't1', date: '2026-04-15', description: 'A', amount: -400, amount_sek: null, currency: 'SEK', merchant_name: null }, + { id: 't2', date: '2026-04-14', description: 'B', amount: -299, amount_sek: null, currency: 'SEK', merchant_name: null }, + { id: 't3', date: '2026-04-16', description: 'C', amount: -305, amount_sek: null, currency: 'SEK', merchant_name: null }, + ], + }) + + const result = await fetchCandidateTransactions(supabase as never, 'company-1', makeReceipt()) + const ids = result.map((c) => c.id) + expect(ids).not.toContain('t2') + expect(ids).toContain('t3') + }) + + it('uses amount_sek when receipt is foreign currency', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: [] }) // no already-matched + enqueue({ + data: [ + { id: 't1', date: '2026-04-15', description: 'USD-denom', amount: -895, amount_sek: -895, currency: 'SEK', merchant_name: null }, + ], + }) + + const usdReceipt = makeReceipt({ + receipt: { date: '2026-04-15', time: null, currency: 'USD' }, + totals: { subtotal: 80, vatAmount: 0, total: 85 }, + } as never) + + const result = await fetchCandidateTransactions(supabase as never, 'company-1', usdReceipt) + // Amount doesn't perfectly match but the function should return it anyway + expect(result).toHaveLength(1) + expect(result[0].id).toBe('t1') + }) +}) diff --git a/extensions/general/inbox-smart-match/__tests__/match-receipt.test.ts b/extensions/general/inbox-smart-match/__tests__/match-receipt.test.ts new file mode 100644 index 00000000..5fa52739 --- /dev/null +++ b/extensions/general/inbox-smart-match/__tests__/match-receipt.test.ts @@ -0,0 +1,134 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +// Mock Bedrock SDK before importing the module under test +const mockSend = vi.fn() +vi.mock('@aws-sdk/client-bedrock-runtime', () => { + class ConverseCommand { + public input: unknown + constructor(input: unknown) { this.input = input } + } + class BedrockRuntimeClient { + send(command: unknown) { return mockSend(command) } + } + return { BedrockRuntimeClient, ConverseCommand } +}) + +import { matchReceiptToCandidate } from '@/extensions/general/inbox-smart-match/lib/match-receipt' +import type { ReceiptExtractionResult } from '@/types' +import type { CandidateTransaction } from '@/extensions/general/inbox-smart-match/lib/fetch-candidates' + +function makeExtracted(): ReceiptExtractionResult { + return { + merchant: { name: 'Willys Hemma', orgNumber: null, vatNumber: null, isForeign: false }, + receipt: { date: '2026-04-15', time: null, currency: 'SEK' }, + lineItems: [], + totals: { subtotal: 239, vatAmount: 60, total: 299 }, + flags: { isRestaurant: false, isSystembolaget: false, isForeignMerchant: false }, + confidence: 0.9, + } as ReceiptExtractionResult +} + +function makeCandidates(): CandidateTransaction[] { + return [ + { id: 't1', date: '2026-04-15', description: 'WILLYS SÖDERM', amount: -299, amount_sek: null, currency: 'SEK', merchant_name: 'Willys' }, + { id: 't2', date: '2026-04-14', description: 'ICA MAXI', amount: -312, amount_sek: null, currency: 'SEK', merchant_name: 'ICA' }, + ] +} + +function mockBedrockResponse(toolInput: Record) { + mockSend.mockResolvedValue({ + output: { + message: { + content: [ + { + toolUse: { + toolUseId: 'id', + name: 'match_receipt', + input: toolInput, + }, + }, + ], + }, + }, + usage: { inputTokens: 100, outputTokens: 20 }, + }) +} + +describe('matchReceiptToCandidate', () => { + beforeEach(() => { + mockSend.mockReset() + process.env.AWS_ACCESS_KEY_ID = 'test' + process.env.AWS_SECRET_ACCESS_KEY = 'test' + process.env.AWS_REGION = 'eu-north-1' + }) + + it('returns a matched result when LLM picks a valid candidate', async () => { + mockBedrockResponse({ + matched: true, + transaction_id: 't1', + confidence: 96, + reasoning: 'Exakt belopp och datum. Willys matchar WILLYS SÖDERM.', + }) + + const result = await matchReceiptToCandidate({ + extracted: makeExtracted(), + candidates: makeCandidates(), + }) + + expect(result.matched).toBe(true) + expect(result.transactionId).toBe('t1') + expect(result.confidence).toBeCloseTo(0.96, 2) + expect(result.reasoning).toContain('Willys') + }) + + it('returns no match when LLM says matched=false', async () => { + mockBedrockResponse({ + matched: false, + transaction_id: null, + confidence: 10, + reasoning: 'Ingen kandidat har rätt belopp eller handlare.', + }) + + const result = await matchReceiptToCandidate({ + extracted: makeExtracted(), + candidates: makeCandidates(), + }) + + expect(result.matched).toBe(false) + expect(result.transactionId).toBeNull() + expect(result.confidence).toBeCloseTo(0.1, 2) + }) + + it('degrades to no-match when LLM returns unknown transaction_id', async () => { + mockBedrockResponse({ + matched: true, + transaction_id: 'hallucinated-id-not-in-candidates', + confidence: 80, + reasoning: 'Detta är fel id', + }) + + const result = await matchReceiptToCandidate({ + extracted: makeExtracted(), + candidates: makeCandidates(), + }) + + expect(result.matched).toBe(false) + expect(result.transactionId).toBeNull() + expect(result.confidence).toBe(0) + }) + + it('returns safe default when no tool use in response', async () => { + mockSend.mockResolvedValue({ + output: { message: { content: [] } }, + usage: { inputTokens: 0, outputTokens: 0 }, + }) + + const result = await matchReceiptToCandidate({ + extracted: makeExtracted(), + candidates: makeCandidates(), + }) + + expect(result.matched).toBe(false) + expect(result.transactionId).toBeNull() + }) +}) diff --git a/extensions/general/inbox-smart-match/index.ts b/extensions/general/inbox-smart-match/index.ts new file mode 100644 index 00000000..c49cc891 --- /dev/null +++ b/extensions/general/inbox-smart-match/index.ts @@ -0,0 +1,114 @@ +import type { Extension } from '@/lib/extensions/types' +import type { EventPayload } from '@/lib/events/types' +import type { InvoiceInboxItem } from '@/types' +import type { SupabaseClient } from '@supabase/supabase-js' +import { createClient } from '@supabase/supabase-js' +import { processInboxItemMatch } from './lib/process-match' + +const EXTENSION_ID = 'inbox-smart-match' + +// The handler always uses a service-role client: +// - processing_history has no INSERT RLS policy (audit integrity) — only service-role can append +// - every query is scoped by company_id from the event payload +// - we write pseudonymous IDs only (PII validator enforces this at the append layer) +// Using ctx.supabase is unsafe because the registry wrapper may build an anon-key +// client when no user session exists (e.g. webhook path). +function getServiceSupabase(): SupabaseClient { + return createClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.SUPABASE_SERVICE_ROLE_KEY! + ) +} + +export const inboxSmartMatchExtension: Extension = { + id: EXTENSION_ID, + name: 'Smart matchning', + version: '0.1.0', + + eventHandlers: [ + // When an inbox item is freshly classified, try to match it to a transaction + { + eventType: 'inbox_item.classified', + handler: async (payload: EventPayload<'inbox_item.classified'>) => { + // Only act on receipts for v1 + if (payload.documentType !== 'receipt') return + + const supabase = getServiceSupabase() + + try { + await processInboxItemMatch( + { + supabase, + companyId: payload.companyId, + userId: payload.userId, + extensionId: EXTENSION_ID, + triggerReason: 'classified', + }, + payload.inboxItem + ) + } catch (err) { + console.error( + `[${EXTENSION_ID}] Failed to process classified inbox item ${payload.inboxItem.id}:`, + err + ) + } + }, + }, + + // When new transactions land, retry matching on any receipts still waiting + { + eventType: 'transaction.synced', + handler: async (payload: EventPayload<'transaction.synced'>) => { + const newTransactionIds = payload.transactions.map((t) => t.id).filter(Boolean) + if (newTransactionIds.length === 0) return + + const supabase = getServiceSupabase() + + // Find receipts in pending state for this company. + // Cap at 10 per sync so one big bank import doesn't time out the + // handler; leftover pending items pick up on the next sync. + const { data: pendingItems, error } = await supabase + .from('invoice_inbox_items') + .select('*') + .eq('company_id', payload.companyId) + .eq('document_type', 'receipt') + .eq('status', 'ready') + .eq('match_method', 'pending_transaction') + .order('created_at', { ascending: false }) + .limit(10) + + if (error) { + console.error(`[${EXTENSION_ID}] Failed to fetch pending receipts:`, error) + return + } + if (!pendingItems || pendingItems.length === 0) return + + // Run LLM calls in parallel; one failing receipt shouldn't stop the others. + const results = await Promise.allSettled( + (pendingItems as InvoiceInboxItem[]).map((item) => + processInboxItemMatch( + { + supabase, + companyId: payload.companyId, + userId: payload.userId, + extensionId: EXTENSION_ID, + triggerReason: 'transaction_synced', + }, + item + ) + ) + ) + + results.forEach((r, i) => { + if (r.status === 'rejected') { + const itemId = (pendingItems[i] as InvoiceInboxItem).id + console.error( + `[${EXTENSION_ID}] Retroactive match failed for item ${itemId}:`, + r.reason + ) + } + }) + }, + }, + ], +} diff --git a/extensions/general/inbox-smart-match/lib/fetch-candidates.ts b/extensions/general/inbox-smart-match/lib/fetch-candidates.ts new file mode 100644 index 00000000..a3d0d7d3 --- /dev/null +++ b/extensions/general/inbox-smart-match/lib/fetch-candidates.ts @@ -0,0 +1,125 @@ +/** + * Candidate transaction fetcher — deterministic narrowing before the LLM call. + * + * Pulls unbooked expense transactions within ±7 days of the receipt date, + * ordered by how close their amount is to the receipt total. Limits to top 5 + * so the LLM has a focused candidate set and the token cost stays bounded. + */ + +import type { SupabaseClient } from '@supabase/supabase-js' +import type { ReceiptExtractionResult } from '@/types' + +const DATE_WINDOW_DAYS = 7 +const MAX_CANDIDATES = 5 + +export interface CandidateTransaction { + id: string + date: string + description: string + amount: number + amount_sek: number | null + currency: string + merchant_name: string | null +} + +/** + * Extract the reference date and absolute amount from a classified receipt's + * extracted data. Returns null if required fields are missing. + */ +function getReceiptMatchAnchors( + extracted: ReceiptExtractionResult | null +): { date: string; amount: number; currency: string } | null { + if (!extracted) return null + const date = extracted.receipt?.date ?? null + const amount = extracted.totals?.total ?? null + const currency = extracted.receipt?.currency ?? 'SEK' + if (!date || amount == null || amount <= 0) return null + return { date, amount, currency } +} + +/** + * Fetch up to MAX_CANDIDATES unbooked expense transactions near the receipt's + * date + amount. Ordering prefers exact amount matches first. + */ +export async function fetchCandidateTransactions( + supabase: SupabaseClient, + companyId: string, + extracted: ReceiptExtractionResult | null +): Promise { + const anchors = getReceiptMatchAnchors(extracted) + if (!anchors) return [] + + const receiptDate = new Date(anchors.date) + if (isNaN(receiptDate.getTime())) return [] + + const windowStart = new Date(receiptDate) + windowStart.setUTCDate(windowStart.getUTCDate() - DATE_WINDOW_DAYS) + const windowEnd = new Date(receiptDate) + windowEnd.setUTCDate(windowEnd.getUTCDate() + DATE_WINDOW_DAYS) + + // Exclude transactions already claimed by any other inbox item in this + // company. The partial unique index on (company_id, matched_transaction_id) + // is the final guard against concurrent double-matches, but filtering up + // front saves an LLM roundtrip on the obvious cases. + const { data: claimed, error: claimedError } = await supabase + .from('invoice_inbox_items') + .select('matched_transaction_id') + .eq('company_id', companyId) + .not('matched_transaction_id', 'is', null) + + if (claimedError) { + throw new Error(`Failed to load matched transactions: ${claimedError.message}`) + } + + const excludedIds = new Set( + (claimed ?? []) + .map((row) => (row as { matched_transaction_id: string | null }).matched_transaction_id) + .filter((id): id is string => typeof id === 'string' && id.length > 0) + ) + + // Pull negative-amount (expense) transactions without a journal entry in the window + const { data, error } = await supabase + .from('transactions') + .select('id, date, description, amount, amount_sek, currency, merchant_name') + .eq('company_id', companyId) + .is('journal_entry_id', null) + .lt('amount', 0) + .gte('date', windowStart.toISOString().slice(0, 10)) + .lte('date', windowEnd.toISOString().slice(0, 10)) + .order('date', { ascending: false }) + .limit(50) + + if (error) { + throw new Error(`Failed to fetch candidate transactions: ${error.message}`) + } + if (!data || data.length === 0) return [] + + const filtered = excludedIds.size > 0 + ? data.filter((tx) => !excludedIds.has(tx.id as string)) + : data + if (filtered.length === 0) return [] + + // Rank candidates by amount proximity. For SEK receipts we compare directly, + // for other currencies we prefer amount_sek if the receipt amount has been converted. + const receiptAbs = Math.abs(anchors.amount) + const scored = filtered.map((tx) => { + const txAmount = Math.abs(Number(tx.amount) || 0) + const txSek = tx.amount_sek == null ? null : Math.abs(Number(tx.amount_sek)) + const primaryDiff = Math.abs(txAmount - receiptAbs) + const sekDiff = txSek == null ? Infinity : Math.abs(txSek - receiptAbs) + const bestDiff = Math.min(primaryDiff, sekDiff) + return { tx, diff: bestDiff } + }) + + scored.sort((a, b) => a.diff - b.diff) + + return scored.slice(0, MAX_CANDIDATES).map(({ tx }) => ({ + id: tx.id, + date: tx.date, + description: tx.description ?? '', + amount: Number(tx.amount), + amount_sek: tx.amount_sek == null ? null : Number(tx.amount_sek), + currency: tx.currency ?? 'SEK', + merchant_name: tx.merchant_name ?? null, + })) +} diff --git a/extensions/general/inbox-smart-match/lib/match-receipt.ts b/extensions/general/inbox-smart-match/lib/match-receipt.ts new file mode 100644 index 00000000..6606a025 --- /dev/null +++ b/extensions/general/inbox-smart-match/lib/match-receipt.ts @@ -0,0 +1,200 @@ +/** + * Text-only LLM matcher — decides which candidate bank transaction (if any) + * corresponds to a classified receipt. Uses Bedrock Converse with structured + * tool output. No image input: the receipt is already represented by the + * extracted data, and candidates are pure text. + */ + +import { + BedrockRuntimeClient, + ConverseCommand, + type ContentBlock, + type Message, + type ToolConfiguration, +} from '@aws-sdk/client-bedrock-runtime' +import type { ReceiptExtractionResult } from '@/types' +import type { CandidateTransaction } from './fetch-candidates' + +export interface ReceiptMatchResult { + matched: boolean + transactionId: string | null + confidence: number // 0..1 + reasoning: string + usage: { inputTokens: number; outputTokens: number } +} + +let _client: BedrockRuntimeClient | null = null + +function getClient(): BedrockRuntimeClient { + if (!_client) { + _client = new BedrockRuntimeClient({ + region: process.env.AWS_REGION || 'eu-north-1', + credentials: { + accessKeyId: process.env.AWS_ACCESS_KEY_ID!, + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!, + }, + }) + } + return _client +} + +const SYSTEM_PROMPT = `Du är en expert på att matcha svenska kvitton mot banktransaktioner. + +Du får: +- Kvittodata (handlare, belopp, valuta, datum) från AI-extraktion +- En lista med kandidat-banktransaktioner (id, beskrivning, belopp, valuta, datum, MCC) + +Uppgift: identifiera vilken (om någon) banktransaktion som motsvarar kvittot. + +Resonera utifrån: +- Belopp: bör vara identiskt eller mycket nära (ta hänsyn till valutaväxling om olika valutor) +- Datum: banktransaktion bokförs ofta 0-3 dagar efter kvittot +- Handlare: bankens beskrivning är ofta förkortad/versaler ("WILLYS SÖDERM" = "Willys Hemma Södermalm"). Matcha semantiskt, inte bokstavligt +- MCC-koder kan bekräfta branschtyp + +Om inget förslag är trovärdigt — returnera matched=false. +Anropa ALLTID verktyget match_receipt med resultatet. +Motivering ska vara kort, på svenska och förklara varför transaktionen valdes.` + +const MATCH_TOOL: ToolConfiguration = { + tools: [ + { + toolSpec: { + name: 'match_receipt', + description: 'Returnera vilken kandidat-transaktion som matchar kvittot', + inputSchema: { + json: { + type: 'object', + required: ['matched', 'confidence', 'reasoning'], + properties: { + matched: { + type: 'boolean', + description: 'true om en kandidat matchar, false annars', + }, + transaction_id: { + type: ['string', 'null'], + description: 'id för den matchande kandidaten (null om matched=false)', + }, + confidence: { + type: 'integer', + minimum: 0, + maximum: 100, + description: 'Säkerhet 0-100. Sätt lågt när matched=false.', + }, + reasoning: { + type: 'string', + description: '1-2 meningar på svenska som förklarar beslutet.', + }, + }, + }, + }, + }, + }, + ], + toolChoice: { any: {} }, +} + +export interface MatchReceiptInput { + extracted: ReceiptExtractionResult + candidates: CandidateTransaction[] +} + +/** + * Call Bedrock to choose the best matching transaction. + * Returns a neutral result (matched=false) if the model doesn't find a fit or + * the tool schema is missing from the response. + */ +export async function matchReceiptToCandidate( + input: MatchReceiptInput +): Promise { + const receiptBrief = { + merchant: input.extracted.merchant?.name ?? null, + amount: input.extracted.totals?.total ?? null, + currency: input.extracted.receipt?.currency ?? 'SEK', + date: input.extracted.receipt?.date ?? null, + vat_amount: input.extracted.totals?.vatAmount ?? null, + } + + const candidateLines = input.candidates.map((c) => ({ + id: c.id, + date: c.date, + description: c.description, + amount: c.amount, + amount_sek: c.amount_sek, + currency: c.currency, + merchant_name: c.merchant_name, + })) + + const userPrompt = `Kvitto: +${JSON.stringify(receiptBrief, null, 2)} + +Kandidat-transaktioner: +${JSON.stringify(candidateLines, null, 2)} + +Vilken transaktion matchar kvittot? Om ingen matchar, returnera matched=false.` + + const messages: Message[] = [ + { + role: 'user', + content: [{ text: userPrompt }], + }, + ] + + const modelId = process.env.BEDROCK_MODEL_ID || 'eu.anthropic.claude-sonnet-4-6' + const maxTokens = parseInt(process.env.BEDROCK_MAX_TOKENS || '1024', 10) + + const command = new ConverseCommand({ + modelId, + messages, + system: [{ text: SYSTEM_PROMPT }], + toolConfig: MATCH_TOOL, + inferenceConfig: { maxTokens, temperature: 0 }, + }) + + const response = await getClient().send(command) + + const usage = { + inputTokens: response.usage?.inputTokens ?? 0, + outputTokens: response.usage?.outputTokens ?? 0, + } + + const outputMessage = response.output?.message + if (!outputMessage?.content) { + return { matched: false, transactionId: null, confidence: 0, reasoning: 'Inget LLM-svar', usage } + } + + const toolUseBlock = outputMessage.content.find( + (block): block is ContentBlock.ToolUseMember => 'toolUse' in block && block.toolUse !== undefined + ) + + if (!toolUseBlock?.toolUse?.input) { + return { matched: false, transactionId: null, confidence: 0, reasoning: 'Inget verktygsanrop', usage } + } + + const raw = toolUseBlock.toolUse.input as Record + const matched = Boolean(raw.matched) + const rawId = typeof raw.transaction_id === 'string' ? raw.transaction_id : null + const transactionId = matched + ? (input.candidates.find((c) => c.id === rawId)?.id ?? null) + : null + const confidenceRaw = Number(raw.confidence) + const confidence = + isFinite(confidenceRaw) + ? Math.min(1, Math.max(0, confidenceRaw / 100)) + : 0 + const reasoning = typeof raw.reasoning === 'string' ? raw.reasoning.trim() : '' + + // If LLM said matched but we can't resolve the transaction_id to a candidate, + // degrade gracefully to unmatched so downstream isn't left dangling. + if (matched && !transactionId) { + return { + matched: false, + transactionId: null, + confidence: 0, + reasoning: reasoning || 'LLM angav ogiltigt transaction_id', + usage, + } + } + + return { matched, transactionId, confidence, reasoning, usage } +} diff --git a/extensions/general/inbox-smart-match/lib/process-match.ts b/extensions/general/inbox-smart-match/lib/process-match.ts new file mode 100644 index 00000000..99f2d71a --- /dev/null +++ b/extensions/general/inbox-smart-match/lib/process-match.ts @@ -0,0 +1,212 @@ +/** + * Core matching flow — deterministic narrowing + LLM call + persistence + + * processing_history audit events. Called from both the classify handler and + * the transaction-sync retroactive handler. + */ + +import type { SupabaseClient } from '@supabase/supabase-js' +import type { InvoiceInboxItem, ReceiptExtractionResult } from '@/types' +import { appendProcessingHistory } from '@/lib/processing-history/append' +import { fetchCandidateTransactions } from './fetch-candidates' +import { matchReceiptToCandidate } from './match-receipt' + +export interface MatchContext { + supabase: SupabaseClient + companyId: string + userId: string + extensionId: string + triggerReason: 'classified' | 'transaction_synced' +} + +export interface MatchOutcome { + status: 'matched' | 'no_match' | 'pending_transaction' | 'skipped' + transactionId: string | null + confidence: number + reasoning: string +} + +/** + * Process a single classified-receipt inbox item through the matcher pipeline. + * Writes match fields + appends processing_history. Swallows internal errors + * so one failing receipt doesn't break the whole event handler. + */ +export async function processInboxItemMatch( + ctx: MatchContext, + item: InvoiceInboxItem +): Promise { + const tag = `[inbox-smart-match] item=${item.id} trigger=${ctx.triggerReason}` + + // We only operate on receipts for v1 + if (item.document_type !== 'receipt') { + return { status: 'skipped', transactionId: null, confidence: 0, reasoning: '' } + } + if (item.status !== 'ready') { + return { status: 'skipped', transactionId: null, confidence: 0, reasoning: '' } + } + if (!item.extracted_data) { + return { status: 'skipped', transactionId: null, confidence: 0, reasoning: '' } + } + + const correlationId = item.correlation_id ?? crypto.randomUUID() + + // If we just minted a fresh correlation_id (legacy row predating the column), + // persist it so retries reuse the same thread through processing_history. + if (!item.correlation_id) { + const { error: corrError } = await ctx.supabase + .from('invoice_inbox_items') + .update({ correlation_id: correlationId }) + .eq('id', item.id) + if (corrError) { + console.error(`${tag} — failed to persist correlation_id:`, corrError) + // non-fatal — we still proceed with matching under the in-memory ID + } + } + + const extracted = item.extracted_data as unknown as ReceiptExtractionResult + const candidates = await fetchCandidateTransactions(ctx.supabase, ctx.companyId, extracted) + + // Append DeterministicMatch event — records that the narrowing ran + let deterministicEventId: string + try { + deterministicEventId = await appendProcessingHistory(ctx.supabase, { + companyId: ctx.companyId, + correlationId, + aggregateType: 'MatchProposal', + aggregateId: item.id, + eventType: 'MatchAttemptedDeterministic', + payload: { + inbox_item_id: item.id, + candidate_count: candidates.length, + candidate_ids: candidates.map((c) => c.id), + window_days: 7, + trigger: ctx.triggerReason, + }, + actor: { type: 'system', id: ctx.extensionId }, + occurredAt: new Date(), + }) + } catch (err) { + console.error(`${tag} — failed to append MatchAttemptedDeterministic:`, err) + return { status: 'no_match', transactionId: null, confidence: 0, reasoning: '' } + } + + // No candidates → mark pending, wait for bank sync + if (candidates.length === 0) { + await ctx.supabase + .from('invoice_inbox_items') + .update({ + match_method: 'pending_transaction', + match_confidence: null, + matched_transaction_id: null, + match_reasoning: 'Inväntar matchande banktransaktion', + }) + .eq('id', item.id) + + return { + status: 'pending_transaction', + transactionId: null, + confidence: 0, + reasoning: 'Inväntar matchande banktransaktion', + } + } + + // LLM chooses among candidates + let llm + try { + llm = await matchReceiptToCandidate({ extracted, candidates }) + } catch (err) { + console.error(`${tag} — LLM matcher failed:`, err) + // Don't overwrite existing state on LLM failure; just log and exit + return { status: 'no_match', transactionId: null, confidence: 0, reasoning: '' } + } + + // Record the LLM attempt in processing_history + try { + await appendProcessingHistory(ctx.supabase, { + companyId: ctx.companyId, + correlationId, + causationId: deterministicEventId, + aggregateType: 'MatchProposal', + aggregateId: item.id, + eventType: 'MatchAttemptedLlm', + payload: { + inbox_item_id: item.id, + matched: llm.matched, + chosen_transaction_id: llm.transactionId, + confidence: llm.confidence, + llm_input_tokens: llm.usage.inputTokens, + llm_output_tokens: llm.usage.outputTokens, + candidate_count: candidates.length, + }, + actor: { type: 'llm', id: 'match_receipt' }, + occurredAt: new Date(), + }) + } catch (err) { + console.error(`${tag} — failed to append MatchAttemptedLlm:`, err) + } + + // Persist match. The (company_id, matched_transaction_id) partial unique + // index means a concurrent second inbox item trying to claim the same + // transaction will get a 23505 — we catch that and downgrade this one + // to pending_transaction instead of overwriting the winner. + if (llm.matched && llm.transactionId) { + const { error: updateError } = await ctx.supabase + .from('invoice_inbox_items') + .update({ + matched_transaction_id: llm.transactionId, + match_confidence: llm.confidence, + match_method: 'llm', + match_reasoning: llm.reasoning, + }) + .eq('id', item.id) + + if (updateError) { + const code = (updateError as { code?: string }).code + if (code === '23505') { + // Another receipt won the race for this transaction. + await ctx.supabase + .from('invoice_inbox_items') + .update({ + matched_transaction_id: null, + match_method: 'pending_transaction', + match_confidence: null, + match_reasoning: 'Transaktionen matchades först till ett annat kvitto', + }) + .eq('id', item.id) + + return { + status: 'pending_transaction', + transactionId: null, + confidence: 0, + reasoning: 'Transaktionen matchades först till ett annat kvitto', + } + } + console.error(`${tag} — failed to persist match:`, updateError) + return { status: 'no_match', transactionId: null, confidence: 0, reasoning: '' } + } + + return { + status: 'matched', + transactionId: llm.transactionId, + confidence: llm.confidence, + reasoning: llm.reasoning, + } + } + + // LLM said no match among the candidates — record explanatory reasoning + await ctx.supabase + .from('invoice_inbox_items') + .update({ + matched_transaction_id: null, + match_confidence: llm.confidence, + match_method: 'pending_transaction', + match_reasoning: llm.reasoning || 'AI kunde inte hitta matchande transaktion bland kandidaterna', + }) + .eq('id', item.id) + + return { + status: 'no_match', + transactionId: null, + confidence: llm.confidence, + reasoning: llm.reasoning, + } +} diff --git a/extensions/general/inbox-smart-match/manifest.json b/extensions/general/inbox-smart-match/manifest.json new file mode 100644 index 00000000..67943841 --- /dev/null +++ b/extensions/general/inbox-smart-match/manifest.json @@ -0,0 +1,23 @@ +{ + "id": "inbox-smart-match", + "sector": "general", + "exportName": "inboxSmartMatchExtension", + "entryPoint": "@/extensions/general/inbox-smart-match", + "requiredEnvVars": [ + "AWS_ACCESS_KEY_ID", + "AWS_SECRET_ACCESS_KEY", + "AWS_REGION" + ], + "optionalEnvVars": ["BEDROCK_MODEL_ID", "BEDROCK_MAX_TOKENS"], + "npmDependencies": ["@aws-sdk/client-bedrock-runtime"], + "definition": { + "name": "Smart matchning", + "category": "operations", + "icon": "Sparkles", + "dataPattern": "core", + "hasOwnData": false, + "readsCoreTables": ["invoice_inbox_items", "transactions", "processing_history"], + "description": "AI-driven matchning av kvitton mot banktransaktioner", + "longDescription": "När ett kvitto klassificeras i inkorgen föreslår AI den mest sannolika matchande banktransaktionen, med motivering. Körs även retroaktivt när nya transaktioner synkas in. Kräver AWS Bedrock." + } +} diff --git a/extensions/general/invoice-inbox/__tests__/inbound-webhook.test.ts b/extensions/general/invoice-inbox/__tests__/inbound-webhook.test.ts new file mode 100644 index 00000000..cfa3a7d7 --- /dev/null +++ b/extensions/general/invoice-inbox/__tests__/inbound-webhook.test.ts @@ -0,0 +1,213 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { invoiceInboxExtension } from '@/extensions/general/invoice-inbox' +import { ResendSignatureError } from '@/extensions/general/invoice-inbox/lib/resend-inbound' +import { createQueuedMockSupabase, createMockRequest } from '@/tests/helpers' + +vi.mock('@/extensions/general/invoice-inbox/lib/resend-inbound', async () => { + const actual = await vi.importActual( + '@/extensions/general/invoice-inbox/lib/resend-inbound' + ) + return { + ...actual, + verifyInboundWebhook: vi.fn(), + fetchReceivingEmail: vi.fn(), + fetchInboundAttachment: vi.fn(), + } +}) + +vi.mock('@supabase/supabase-js', () => ({ + createClient: vi.fn(), +})) + +import { verifyInboundWebhook, fetchReceivingEmail, fetchInboundAttachment } from '@/extensions/general/invoice-inbox/lib/resend-inbound' +import { createClient } from '@supabase/supabase-js' + +function findRoute(method: string, path: string) { + return invoiceInboxExtension.apiRoutes!.find((r) => r.method === method && r.path === path)! +} + +const webhookRoute = findRoute('POST', '/inbound') + +function mockReceivedEvent(overrides?: Record) { + return { + type: 'email.received' as const, + created_at: '2026-04-20T10:00:00Z', + data: { + email_id: 'em_123', + created_at: '2026-04-20T10:00:00Z', + from: 'billing@supplier.com', + to: ['acme-ab-x7f2@arcim.io'], + cc: [], + bcc: [], + subject: 'Invoice #5678', + message_id: '', + attachments: [ + { + id: 'att_1', + filename: 'invoice.pdf', + size: 12345, + content_type: 'application/pdf', + content_id: 'cid1', + content_disposition: 'attachment', + }, + ], + ...overrides, + }, + } +} + +describe('POST /inbound', () => { + const originalEnv = { ...process.env } + + beforeEach(() => { + vi.clearAllMocks() + process.env.RESEND_INBOUND_DOMAIN = 'arcim.io' + process.env.NEXT_PUBLIC_SUPABASE_URL = 'http://localhost' + process.env.SUPABASE_SERVICE_ROLE_KEY = 'test-service-key' + }) + + afterEach(() => { + process.env = { ...originalEnv } + }) + + it('returns 503 when RESEND_INBOUND_DOMAIN is not set', async () => { + delete process.env.RESEND_INBOUND_DOMAIN + const request = createMockRequest('/inbound', { method: 'POST', body: { type: 'email.received' } }) + const res = await webhookRoute.handler(request) + expect(res.status).toBe(503) + }) + + it('returns 401 when signature verification fails', async () => { + vi.mocked(verifyInboundWebhook).mockImplementation(() => { + throw new ResendSignatureError('bad sig') + }) + const request = createMockRequest('/inbound', { method: 'POST', body: { type: 'email.received' } }) + const res = await webhookRoute.handler(request) + expect(res.status).toBe(401) + }) + + it('ignores non-received events with 200', async () => { + vi.mocked(verifyInboundWebhook).mockReturnValue({ + type: 'email.sent', + created_at: '', + data: {}, + } as never) + const request = createMockRequest('/inbound', { method: 'POST', body: {} }) + const res = await webhookRoute.handler(request) + expect(res.status).toBe(200) + }) + + it('returns 404 when no recipient matches our domain', async () => { + vi.mocked(verifyInboundWebhook).mockReturnValue( + mockReceivedEvent({ to: ['random@contoso.com'] }) as never + ) + const request = createMockRequest('/inbound', { method: 'POST', body: {} }) + const res = await webhookRoute.handler(request) + expect(res.status).toBe(404) + }) + + it('returns 404 when the address is not in company_inboxes', async () => { + vi.mocked(verifyInboundWebhook).mockReturnValue(mockReceivedEvent() as never) + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: null }) // company_inboxes lookup returns nothing + vi.mocked(createClient).mockReturnValue(supabase as never) + + const request = createMockRequest('/inbound', { method: 'POST', body: {} }) + const res = await webhookRoute.handler(request) + expect(res.status).toBe(404) + }) + + it('returns 410 when the address is deprecated', async () => { + vi.mocked(verifyInboundWebhook).mockReturnValue(mockReceivedEvent() as never) + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { id: 'inbox-1', company_id: 'company-1', status: 'deprecated' } }) + vi.mocked(createClient).mockReturnValue(supabase as never) + + const request = createMockRequest('/inbound', { method: 'POST', body: {} }) + const res = await webhookRoute.handler(request) + expect(res.status).toBe(410) + }) + + it('skips already-processed attachments (per-attachment idempotency)', async () => { + vi.mocked(verifyInboundWebhook).mockReturnValue(mockReceivedEvent() as never) + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { id: 'inbox-1', company_id: 'company-1', status: 'active' } }) // inbox lookup + enqueue({ data: { created_by: 'user-owner-1' } }) // company owner + enqueue({ data: { id: 'existing-item-1' } }) // per-attachment dup check finds existing row + vi.mocked(createClient).mockReturnValue(supabase as never) + vi.mocked(fetchReceivingEmail).mockResolvedValue({ + object: 'email', + id: 'em_123', + to: ['acme-ab-x7f2@arcim.io'], + from: 'billing@supplier.com', + created_at: '2026-04-20T10:00:00Z', + subject: 'Invoice #5678', + bcc: null, + cc: null, + reply_to: null, + html: null, + text: 'Body', + headers: {}, + message_id: '', + raw: null, + attachments: [ + { id: 'att_1', filename: 'invoice.pdf', size: 100, content_type: 'application/pdf', content_id: 'cid', content_disposition: 'attachment' }, + ], + } as never) + + const request = createMockRequest('/inbound', { method: 'POST', body: {} }) + const res = await webhookRoute.handler(request) + const body = await res.json() + expect(res.status).toBe(200) + expect(body.data.results[0].duplicate).toBe(true) + expect(body.data.results[0].inbox_item_id).toBe('existing-item-1') + expect(fetchInboundAttachment).not.toHaveBeenCalled() + }) + + it('returns 500 when the company has no created_by owner', async () => { + vi.mocked(verifyInboundWebhook).mockReturnValue(mockReceivedEvent() as never) + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { id: 'inbox-1', company_id: 'company-1', status: 'active' } }) + enqueue({ data: { created_by: null } }) // company with no owner + vi.mocked(createClient).mockReturnValue(supabase as never) + + const request = createMockRequest('/inbound', { method: 'POST', body: {} }) + const res = await webhookRoute.handler(request) + expect(res.status).toBe(500) + }) + + it('logs an error inbox item when email has no attachments', async () => { + vi.mocked(verifyInboundWebhook).mockReturnValue( + mockReceivedEvent({ attachments: [] }) as never + ) + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { id: 'inbox-1', company_id: 'company-1', status: 'active' } }) + enqueue({ data: { created_by: 'user-owner-1' } }) + enqueue({ data: null }) // insert succeeds + vi.mocked(createClient).mockReturnValue(supabase as never) + vi.mocked(fetchReceivingEmail).mockResolvedValue({ + object: 'email', + id: 'em_123', + to: ['acme-ab-x7f2@arcim.io'], + from: 'billing@supplier.com', + created_at: '2026-04-20T10:00:00Z', + subject: 'No attachments here', + bcc: null, + cc: null, + reply_to: null, + html: null, + text: 'Body only', + headers: {}, + message_id: '', + raw: null, + attachments: [], + } as never) + + const request = createMockRequest('/inbound', { method: 'POST', body: {} }) + const res = await webhookRoute.handler(request) + const body = await res.json() + expect(res.status).toBe(200) + expect(body.data.reason).toBe('no_attachments') + expect(fetchInboundAttachment).not.toHaveBeenCalled() + }) +}) diff --git a/extensions/general/invoice-inbox/__tests__/inbox-provisioning.test.ts b/extensions/general/invoice-inbox/__tests__/inbox-provisioning.test.ts new file mode 100644 index 00000000..a6c5f86c --- /dev/null +++ b/extensions/general/invoice-inbox/__tests__/inbox-provisioning.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect } from 'vitest' +import { + composeInboxAddress, + getActiveInbox, + rotateCompanyInbox, +} from '@/extensions/general/invoice-inbox/lib/inbox-provisioning' +import { createQueuedMockSupabase } from '@/tests/helpers' + +describe('composeInboxAddress', () => { + it('joins local_part and domain with @', () => { + expect(composeInboxAddress('acme-ab-x7f2', 'arcim.io')).toBe('acme-ab-x7f2@arcim.io') + }) +}) + +describe('getActiveInbox', () => { + it('returns the active inbox row', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + const row = { + id: 'inbox-1', + company_id: 'company-1', + local_part: 'acme-x7f2', + status: 'active', + slug_seed: 'acme', + created_at: '2026-04-20T00:00:00Z', + updated_at: '2026-04-20T00:00:00Z', + deprecated_at: null, + } + enqueue({ data: row }) + + const result = await getActiveInbox(supabase as never, 'company-1') + expect(result).toEqual(row) + }) + + it('returns null when no active inbox exists', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: null }) + + const result = await getActiveInbox(supabase as never, 'company-1') + expect(result).toBeNull() + }) + + it('throws on database error', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: null, error: { message: 'db blew up' } }) + + await expect(getActiveInbox(supabase as never, 'company-1')).rejects.toThrow(/db blew up/) + }) +}) + +describe('rotateCompanyInbox', () => { + it('delegates to the rotate_company_inbox RPC and returns the new row', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + + const newRow = { + id: 'inbox-2', + company_id: 'company-1', + local_part: 'acme-new2', + status: 'active', + slug_seed: 'acme', + created_at: '2026-04-20T00:00:00Z', + updated_at: '2026-04-20T00:00:00Z', + deprecated_at: null, + } + enqueue({ data: newRow }) + + const result = await rotateCompanyInbox(supabase as never, 'company-1') + + expect(result.local_part).toBe('acme-new2') + expect(result.status).toBe('active') + }) + + it('accepts the SETOF shape where the RPC wraps the row in an array', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + + const newRow = { + id: 'inbox-3', + company_id: 'company-1', + local_part: 'new-co-abcd', + status: 'active', + slug_seed: 'new-co', + created_at: '2026-04-20T00:00:00Z', + updated_at: '2026-04-20T00:00:00Z', + deprecated_at: null, + } + enqueue({ data: [newRow] }) + + const result = await rotateCompanyInbox(supabase as never, 'company-1') + expect(result.local_part).toBe('new-co-abcd') + }) + + it('surfaces RPC errors', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: null, error: { message: 'Not authorized to rotate inbox for this company' } }) + + await expect(rotateCompanyInbox(supabase as never, 'nope')) + .rejects.toThrow(/Not authorized/) + }) +}) diff --git a/extensions/general/invoice-inbox/__tests__/resend-inbound.test.ts b/extensions/general/invoice-inbox/__tests__/resend-inbound.test.ts new file mode 100644 index 00000000..d73f94d5 --- /dev/null +++ b/extensions/general/invoice-inbox/__tests__/resend-inbound.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect } from 'vitest' +import { extractLocalPartForDomain } from '@/extensions/general/invoice-inbox/lib/resend-inbound' + +describe('extractLocalPartForDomain', () => { + it('returns the local part when a recipient matches the domain', () => { + const result = extractLocalPartForDomain( + ['acme-ab-x7f2@arcim.io', 'billing@acme.se'], + 'arcim.io' + ) + expect(result).toBe('acme-ab-x7f2') + }) + + it('lowercases the local part and matches domain case-insensitively', () => { + const result = extractLocalPartForDomain( + ['ACME-AB-X7F2@ARCIM.IO'], + 'arcim.io' + ) + expect(result).toBe('acme-ab-x7f2') + }) + + it('returns null when no recipient matches', () => { + const result = extractLocalPartForDomain( + ['billing@acme.se', 'invoices@contoso.com'], + 'arcim.io' + ) + expect(result).toBeNull() + }) + + it('returns null for malformed addresses', () => { + const result = extractLocalPartForDomain( + ['not-an-email', '@arcim.io', 'foo@'], + 'arcim.io' + ) + expect(result).toBeNull() + }) + + it('returns the first matching recipient when multiple match', () => { + const result = extractLocalPartForDomain( + ['first-abcd@arcim.io', 'second-efgh@arcim.io'], + 'arcim.io' + ) + expect(result).toBe('first-abcd') + }) + + it('trims whitespace inside candidate addresses', () => { + const result = extractLocalPartForDomain( + [' acme-xxx@arcim.io '], + 'arcim.io' + ) + expect(result).toBe('acme-xxx') + }) +}) diff --git a/extensions/general/invoice-inbox/index.ts b/extensions/general/invoice-inbox/index.ts index 29c0afe9..738af26b 100644 --- a/extensions/general/invoice-inbox/index.ts +++ b/extensions/general/invoice-inbox/index.ts @@ -3,10 +3,23 @@ import { NextResponse } from 'next/server' import { createClient } from '@supabase/supabase-js' import { uploadDocument } from '@/lib/core/documents/document-service' import { classifyDocument } from './lib/classify-document' -import { encryptState, decryptState, encryptToken, decryptToken } from './lib/gmail-helpers' -import { scanGmailConnection } from './lib/gmail-scanner' +import { + verifyInboundWebhook, + fetchReceivingEmail, + fetchInboundAttachment, + extractLocalPartForDomain, + isEmailReceivedEvent, + ResendSignatureError, +} from './lib/resend-inbound' +import { + rotateCompanyInbox, + getActiveInbox, + composeInboxAddress, +} from './lib/inbox-provisioning' import { createSupplierInvoiceRegistrationEntry } from '@/lib/bookkeeping/supplier-invoice-entries' import { CreateSupplierInvoiceSchema } from '@/lib/api/schemas' +import { appendProcessingHistory } from '@/lib/processing-history/append' +import { eventBus } from '@/lib/events/bus' import type { InvoiceExtractionResult, InvoiceInboxItem, SupplierInvoice, SupplierInvoiceItem } from '@/types' const MAX_FILE_SIZE = 10 * 1024 * 1024 // Match MAX_DOCUMENT_SIZE from document-service @@ -20,7 +33,15 @@ const UPLOAD_ALLOWED_MIME_TYPES = new Set([ 'image/webp', ]) -const STATE_TTL_MS = 10 * 60 * 1000 +interface EmailMeta { + from?: string | null + subject?: string | null + receivedAt?: string | null + messageId?: string | null + bodyText?: string | null + resendEmailId?: string | null + resendAttachmentId?: string | null +} // ── Shared helper: upload + classify + create inbox item ───── @@ -30,9 +51,12 @@ async function uploadAndClassify( companyId: string, file: { name: string; buffer: ArrayBuffer; type: string }, source: 'upload' | 'email', - emailMeta?: { from?: string | null; subject?: string | null; receivedAt?: string | null; messageId?: string }, + emailMeta?: EmailMeta, ctx?: ExtensionContext ) { + // Correlation ID threads through ingest → classify → match → book. + const correlationId = crypto.randomUUID() + // Store in WORM archive const doc = await uploadDocument(supabase, userId, companyId, { name: file.name, @@ -42,6 +66,27 @@ async function uploadAndClassify( upload_source: source === 'email' ? 'email' : 'file_upload', }) + // Audit: DocumentIngested + try { + await appendProcessingHistory(supabase, { + companyId, + correlationId, + aggregateType: 'Document', + aggregateId: doc.id, + eventType: 'DocumentIngested', + payload: { + channel: source, + document_id: doc.id, + mime_type: file.type, + size_bytes: file.buffer.byteLength, + }, + actor: source === 'email' ? { type: 'system', id: 'resend-inbound' } : { type: 'user', id: userId }, + occurredAt: new Date(), + }) + } catch (err) { + console.error('[invoice-inbox] Failed to append DocumentIngested:', err) + } + // Classify with AI let classificationResult let classificationError: string | null = null @@ -55,6 +100,30 @@ async function uploadAndClassify( classificationError = err instanceof Error ? err.message : 'Classification failed' } + // Audit: DocumentExtractionAttempted (fires whether classification succeeded or failed) + try { + await appendProcessingHistory(supabase, { + companyId, + correlationId, + aggregateType: 'Document', + aggregateId: doc.id, + eventType: 'DocumentExtractionAttempted', + payload: { + document_id: doc.id, + succeeded: !classificationError, + document_type: classificationResult?.documentType ?? null, + confidence: classificationResult?.confidence ? classificationResult.confidence / 100 : null, + llm_input_tokens: classificationResult?.usage?.inputTokens ?? 0, + llm_output_tokens: classificationResult?.usage?.outputTokens ?? 0, + error: classificationError, + }, + actor: { type: 'llm', id: 'classify-document' }, + occurredAt: new Date(), + }) + } catch (err) { + console.error('[invoice-inbox] Failed to append DocumentExtractionAttempted:', err) + } + // Supplier matching let matchedSupplierId: string | null = null if (classificationResult?.documentType === 'supplier_invoice' && classificationResult.extractedData) { @@ -104,20 +173,63 @@ async function uploadAndClassify( email_from: emailMeta?.from || null, email_subject: emailMeta?.subject || null, email_received_at: emailMeta?.receivedAt || null, + email_body_text: emailMeta?.bodyText || null, + resend_email_id: emailMeta?.resendEmailId || null, + resend_attachment_id: emailMeta?.resendAttachmentId || null, raw_email_payload: emailMeta?.messageId ? { messageId: emailMeta.messageId, filename: file.name } : null, error_message: classificationError, + correlation_id: correlationId, }) - .select('id, status, document_type, confidence, matched_supplier_id, error_message') + .select('*') .single() if (inboxError) throw new Error(`Failed to create inbox item: ${inboxError.message}`) - // Emit events for supplier invoices (non-blocking) - if (ctx && inbox.document_type === 'supplier_invoice') { + // Audit: DocumentClassified (only when classification succeeded) + if (!classificationError && classificationResult) { try { - await ctx.emit({ + await appendProcessingHistory(supabase, { + companyId, + correlationId, + aggregateType: 'Document', + aggregateId: doc.id, + eventType: 'DocumentClassified', + payload: { + document_id: doc.id, + inbox_item_id: inbox.id, + classification: classificationResult.documentType, + confidence: classificationResult.confidence / 100, + }, + actor: { type: 'system', id: 'invoice-inbox' }, + occurredAt: new Date(), + }) + } catch (err) { + console.error('[invoice-inbox] Failed to append DocumentClassified:', err) + } + } + + // Emit generic classified event for all document types. + // Always emit via eventBus directly so the webhook path (no ExtensionContext) still triggers handlers. + try { + await eventBus.emit({ + type: 'inbox_item.classified', + payload: { + inboxItem: inbox as unknown as InvoiceInboxItem, + documentType: inbox.document_type, + confidence: inbox.confidence, + correlationId, + userId, + companyId, + }, + }) + } catch { /* non-blocking */ } + + // Emit supplier-invoice-specific events (kept for backward compatibility) + if (inbox.document_type === 'supplier_invoice') { + try { + await eventBus.emit({ type: 'supplier_invoice.received', payload: { inboxItem: inbox as unknown as InvoiceInboxItem, userId, companyId }, }) @@ -125,7 +237,7 @@ async function uploadAndClassify( if (!classificationError && classificationResult?.confidence) { try { - await ctx.emit({ + await eventBus.emit({ type: 'supplier_invoice.extracted', payload: { inboxItem: inbox as unknown as InvoiceInboxItem, confidence: classificationResult.confidence / 100, userId, companyId }, }) @@ -145,12 +257,28 @@ async function uploadAndClassify( } } +// ── Admin/owner check helper ────────────────────────────────── + +async function isCompanyAdmin( + supabase: import('@supabase/supabase-js').SupabaseClient, + userId: string, + companyId: string +): Promise { + const { data } = await supabase + .from('company_members') + .select('role') + .eq('company_id', companyId) + .eq('user_id', userId) + .maybeSingle() + return !!data && ['owner', 'admin'].includes(data.role) +} + // ── Extension definition ───────────────────────────────────── export const invoiceInboxExtension: Extension = { id: 'invoice-inbox', name: 'Dokumentinkorg', - version: '1.0.0', + version: '2.0.0', apiRoutes: [ // ── Upload ────────────────────────────────────────────── @@ -212,7 +340,12 @@ export const invoiceInboxExtension: Extension = { let query = ctx.supabase .from('invoice_inbox_items') - .select('id, status, document_type, confidence, source, created_at, extracted_data, matched_supplier_id, document_id, email_from, email_subject, error_message') + .select(` + id, status, document_type, confidence, source, created_at, extracted_data, + matched_supplier_id, document_id, email_from, email_subject, error_message, + matched_transaction_id, match_confidence, match_method, match_reasoning, + matched_transaction:transactions!matched_transaction_id(id, description, amount, currency, date) + `) .eq('company_id', ctx.companyId) .order('created_at', { ascending: false }) .limit(limit) @@ -252,251 +385,237 @@ export const invoiceInboxExtension: Extension = { }, }, - // ── Gmail OAuth: get auth URL ─────────────────────────── + // ── Get this company's inbox address ──────────────────── { method: 'GET', - path: '/gmail/auth', + path: '/inbox/address', handler: async (_request: Request, ctx?: ExtensionContext) => { if (!ctx) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - const clientId = process.env.GOOGLE_CLIENT_ID - if (!clientId) { - return NextResponse.json({ error: 'Google OAuth not configured' }, { status: 500 }) + const domain = process.env.RESEND_INBOUND_DOMAIN + if (!domain) { + return NextResponse.json({ error: 'RESEND_INBOUND_DOMAIN not configured' }, { status: 503 }) } - if (!process.env.GMAIL_TOKEN_ENCRYPTION_KEY) { - return NextResponse.json({ error: 'GMAIL_TOKEN_ENCRYPTION_KEY is required' }, { status: 500 }) - } - - const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000' - const redirectUri = `${appUrl}/api/extensions/ext/invoice-inbox/gmail/callback` - - const state = encryptState({ - companyId: ctx.companyId, - userId: ctx.userId, - exp: Date.now() + STATE_TTL_MS, - }) - - const params = new URLSearchParams({ - client_id: clientId, - redirect_uri: redirectUri, - response_type: 'code', - scope: 'https://www.googleapis.com/auth/gmail.readonly https://www.googleapis.com/auth/gmail.labels', - access_type: 'offline', - prompt: 'consent', - state, - }) - - return NextResponse.json({ data: { authUrl: `https://accounts.google.com/o/oauth2/v2/auth?${params}` } }) - }, - }, - - // ── Gmail OAuth: callback (skipAuth — redirect from Google) ─ - { - method: 'GET', - path: '/gmail/callback', - skipAuth: true, - handler: async (request: Request) => { - const url = new URL(request.url) - const code = url.searchParams.get('code') - const stateParam = url.searchParams.get('state') - const error = url.searchParams.get('error') - - const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000' - - if (error) { - console.error('[gmail/callback] OAuth error:', error) - return NextResponse.redirect(`${appUrl}/e/general/invoice-inbox?error=gmail_auth_denied`) - } - if (!code || !stateParam) { - return NextResponse.redirect(`${appUrl}/e/general/invoice-inbox?error=gmail_missing_params`) - } - if (!process.env.GMAIL_TOKEN_ENCRYPTION_KEY) { - return NextResponse.redirect(`${appUrl}/e/general/invoice-inbox?error=gmail_config_error`) - } - - const state = decryptState(stateParam) as { companyId: string; userId: string; exp: number } | null - if (!state || Date.now() > state.exp) { - return NextResponse.redirect(`${appUrl}/e/general/invoice-inbox?error=gmail_invalid_state`) - } - - const { companyId, userId } = state - const redirectUri = `${appUrl}/api/extensions/ext/invoice-inbox/gmail/callback` try { - // Exchange code for tokens - const tokenResponse = await fetch('https://oauth2.googleapis.com/token', { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: new URLSearchParams({ - code, - client_id: process.env.GOOGLE_CLIENT_ID!, - client_secret: process.env.GOOGLE_CLIENT_SECRET!, - redirect_uri: redirectUri, - grant_type: 'authorization_code', - }), + const inbox = await getActiveInbox(ctx.supabase, ctx.companyId) + if (!inbox) { + return NextResponse.json({ error: 'No active inbox' }, { status: 404 }) + } + return NextResponse.json({ + data: { + address: composeInboxAddress(inbox.local_part, domain), + local_part: inbox.local_part, + status: inbox.status, + created_at: inbox.created_at, + }, }) - - if (!tokenResponse.ok) { - console.error('[gmail/callback] Token exchange failed:', await tokenResponse.text()) - return NextResponse.redirect(`${appUrl}/e/general/invoice-inbox?error=gmail_token_exchange`) - } - - const tokens = await tokenResponse.json() as { - access_token: string; refresh_token?: string - } - if (!tokens.refresh_token) { - return NextResponse.redirect(`${appUrl}/e/general/invoice-inbox?error=gmail_no_refresh_token`) - } - - // Get user email - const profileResponse = await fetch('https://gmail.googleapis.com/gmail/v1/users/me/profile', { - headers: { Authorization: `Bearer ${tokens.access_token}` }, - }) - if (!profileResponse.ok) { - return NextResponse.redirect(`${appUrl}/e/general/invoice-inbox?error=gmail_profile_error`) - } - const profile = await profileResponse.json() as { emailAddress: string } - - // Create gnubok-processed label - let gmailLabelId: string | null = null - try { - const labelsResponse = await fetch('https://gmail.googleapis.com/gmail/v1/users/me/labels', { - headers: { Authorization: `Bearer ${tokens.access_token}` }, - }) - const labelsData = await labelsResponse.json() as { labels: { id: string; name: string }[] } - const existing = labelsData.labels?.find((l) => l.name === 'gnubok-processed') - - if (existing) { - gmailLabelId = existing.id - } else { - const createLabelResponse = await fetch('https://gmail.googleapis.com/gmail/v1/users/me/labels', { - method: 'POST', - headers: { - Authorization: `Bearer ${tokens.access_token}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - name: 'gnubok-processed', - labelListVisibility: 'labelShow', - messageListVisibility: 'show', - }), - }) - if (createLabelResponse.ok) { - const label = await createLabelResponse.json() as { id: string } - gmailLabelId = label.id - } - } - } catch (err) { - console.warn('[gmail/callback] Failed to create Gmail label:', err) - } - - // Store connection (service-role — no auth cookie in callback) - const supabase = createClient( - process.env.NEXT_PUBLIC_SUPABASE_URL!, - process.env.SUPABASE_SERVICE_ROLE_KEY! - ) - - const { error: dbError } = await supabase - .from('email_connections') - .upsert( - { - company_id: companyId, - user_id: userId, - provider: 'gmail', - email_address: profile.emailAddress, - encrypted_token: encryptToken(tokens.refresh_token), - gmail_label_id: gmailLabelId, - status: 'active', - error_message: null, - }, - { onConflict: 'company_id,email_address' } - ) - - if (dbError) { - console.error('[gmail/callback] DB insert failed:', dbError) - return NextResponse.redirect(`${appUrl}/e/general/invoice-inbox?error=gmail_db_error`) - } - - console.log(`[gmail/callback] Gmail connected for ${profile.emailAddress} (company ${companyId})`) - return NextResponse.redirect(`${appUrl}/e/general/invoice-inbox?gmail=connected`) } catch (err) { - console.error('[gmail/callback] Unexpected error:', err) - return NextResponse.redirect(`${appUrl}/e/general/invoice-inbox?error=gmail_unexpected`) + return NextResponse.json( + { error: err instanceof Error ? err.message : 'Failed to load inbox' }, + { status: 500 } + ) } }, }, - // ── Gmail: disconnect ─────────────────────────────────── + // ── Rotate inbox address (admin/owner only) ───────────── { method: 'POST', - path: '/gmail/disconnect', + path: '/inbox/rotate', handler: async (_request: Request, ctx?: ExtensionContext) => { if (!ctx) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - const { error } = await ctx.supabase - .from('email_connections') - .delete() - .eq('company_id', ctx.companyId) - .eq('provider', 'gmail') + const domain = process.env.RESEND_INBOUND_DOMAIN + if (!domain) { + return NextResponse.json({ error: 'RESEND_INBOUND_DOMAIN not configured' }, { status: 503 }) + } - if (error) return NextResponse.json({ error: error.message }, { status: 500 }) - return NextResponse.json({ data: { disconnected: true } }) + const isAdmin = await isCompanyAdmin(ctx.supabase, ctx.userId, ctx.companyId) + if (!isAdmin) return NextResponse.json({ error: 'Behörighet saknas.' }, { status: 403 }) + + try { + // rotate_company_inbox is SECURITY DEFINER and does its own + // auth.uid() role check. Call it through the user's JWT-bearing + // client — a service-role client has no session, so auth.uid() + // returns NULL and the in-RPC check always fails with 42501. + const newInbox = await rotateCompanyInbox(ctx.supabase, ctx.companyId) + return NextResponse.json({ + data: { + address: composeInboxAddress(newInbox.local_part, domain), + local_part: newInbox.local_part, + status: newInbox.status, + }, + }) + } catch (err) { + console.error('[invoice-inbox/inbox/rotate] Failed:', err) + return NextResponse.json( + { error: err instanceof Error ? err.message : 'Rotation failed' }, + { status: 500 } + ) + } }, }, - // ── Gmail: connection status ──────────────────────────── - { - method: 'GET', - path: '/gmail/status', - handler: async (_request: Request, ctx?: ExtensionContext) => { - if (!ctx) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - - const { data, error } = await ctx.supabase - .from('email_connections') - .select('id, email_address, status, last_sync_at, error_message, created_at') - .eq('company_id', ctx.companyId) - .eq('provider', 'gmail') - - if (error) return NextResponse.json({ error: error.message }, { status: 500 }) - return NextResponse.json({ data: { connections: data || [] } }) - }, - }, - - // ── Gmail: manual scan trigger ────────────────────────── + // ── Resend Inbound webhook (Svix-signed, no user auth) ── { method: 'POST', - path: '/gmail/scan', - handler: async (_request: Request, ctx?: ExtensionContext) => { - if (!ctx) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - - if (!process.env.GMAIL_TOKEN_ENCRYPTION_KEY) { - return NextResponse.json({ error: 'GMAIL_TOKEN_ENCRYPTION_KEY is required' }, { status: 500 }) + path: '/inbound', + skipAuth: true, + handler: async (request: Request) => { + const domain = process.env.RESEND_INBOUND_DOMAIN + if (!domain) { + console.error('[invoice-inbox/inbound] RESEND_INBOUND_DOMAIN not configured') + return NextResponse.json({ error: 'Inbound not configured' }, { status: 503 }) } - const { data: connections, error: connError } = await ctx.supabase - .from('email_connections') - .select('*') - .eq('company_id', ctx.companyId) - .eq('status', 'active') + const rawBody = await request.text() - if (connError) return NextResponse.json({ error: 'Failed to fetch connections' }, { status: 500 }) - if (!connections?.length) { - return NextResponse.json({ data: { message: 'No active Gmail connections', scanned: 0 } }) + // 1. Verify Svix signature + let event + try { + event = verifyInboundWebhook(rawBody, request.headers) + } catch (err) { + if (err instanceof ResendSignatureError) { + return NextResponse.json({ error: 'Invalid signature' }, { status: 401 }) + } + console.error('[invoice-inbox/inbound] Verification error:', err) + return NextResponse.json({ error: 'Verification failed' }, { status: 500 }) } - let totalScanned = 0, totalClassified = 0, totalSkipped = 0, totalErrors = 0 - - for (const connection of connections) { - const result = await scanGmailConnection(ctx.supabase, connection, ctx.userId, ctx.companyId) - totalScanned += result.scanned - totalClassified += result.classified - totalSkipped += result.skipped - totalErrors += result.errors + // 2. Only process email.received events + if (!isEmailReceivedEvent(event)) { + return NextResponse.json({ data: { ignored: event.type } }, { status: 200 }) } - return NextResponse.json({ - data: { scanned: totalScanned, classified: totalClassified, skipped: totalSkipped, errors: totalErrors }, - }) + const { email_id, to, from, subject, message_id, created_at } = event.data + + // 3. Find the recipient that matches our domain + const localPart = extractLocalPartForDomain(to, domain) + if (!localPart) { + console.warn('[invoice-inbox/inbound] No recipient matched domain', { to, domain }) + return NextResponse.json({ error: 'No matching recipient' }, { status: 404 }) + } + + // 4. Look up company_inbox (service role — webhook has no user session) + const serviceSupabase = createClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.SUPABASE_SERVICE_ROLE_KEY! + ) + + const { data: inbox } = await serviceSupabase + .from('company_inboxes') + .select('id, company_id, status') + .eq('local_part', localPart) + .maybeSingle() + + if (!inbox) { + return NextResponse.json({ error: 'Address not found' }, { status: 404 }) + } + if (inbox.status !== 'active') { + // Deprecated or blocked → hard-bounce so Resend returns a 5xx to the sender + return NextResponse.json({ error: 'Address no longer active' }, { status: 410 }) + } + + // 5. Resolve a user_id for the inbox item (schema requires NOT NULL). + // Use company owner (companies.created_by). + const { data: company } = await serviceSupabase + .from('companies') + .select('created_by') + .eq('id', inbox.company_id) + .single() + + if (!company?.created_by) { + console.error('[invoice-inbox/inbound] Company has no created_by', inbox.company_id) + return NextResponse.json({ error: 'Company owner missing' }, { status: 500 }) + } + const userId = company.created_by + + // 7. Fetch full email (body + attachment metadata) + let fullEmail + try { + fullEmail = await fetchReceivingEmail(email_id) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + console.error('[invoice-inbox/inbound] Failed to fetch received email:', err) + return NextResponse.json({ error: `Fetch failed: ${message}` }, { status: 500 }) + } + + const bodyText = fullEmail.text ?? null + const attachments = fullEmail.attachments ?? [] + + // 8. If no attachments, still log the email as an inbox item so the user sees it + if (attachments.length === 0) { + await serviceSupabase.from('invoice_inbox_items').insert({ + company_id: inbox.company_id, + user_id: userId, + status: 'error', + source: 'email', + email_from: from, + email_subject: subject, + email_received_at: created_at, + email_body_text: bodyText, + resend_email_id: email_id, + document_type: 'unknown', + error_message: 'Email had no attachments', + raw_email_payload: { messageId: message_id }, + }) + return NextResponse.json({ data: { processed: 0, reason: 'no_attachments' } }) + } + + // 9. Download + classify each attachment (per-attachment idempotency) + const results: Array<{ attachment_id: string; inbox_item_id?: string; error?: string; duplicate?: boolean }> = [] + for (const att of attachments) { + try { + // Skip if this (email_id, attachment_id) was already processed + const { data: existing } = await serviceSupabase + .from('invoice_inbox_items') + .select('id') + .eq('resend_email_id', email_id) + .eq('resend_attachment_id', att.id) + .maybeSingle() + if (existing) { + results.push({ attachment_id: att.id, inbox_item_id: existing.id, duplicate: true }) + continue + } + + const download = await fetchInboundAttachment(email_id, att.id) + if (!UPLOAD_ALLOWED_MIME_TYPES.has(download.contentType)) { + results.push({ attachment_id: att.id, error: `Unsupported type ${download.contentType}` }) + continue + } + if (download.buffer.byteLength > MAX_FILE_SIZE) { + results.push({ attachment_id: att.id, error: 'Attachment too large' }) + continue + } + + const result = await uploadAndClassify( + serviceSupabase, + userId, + inbox.company_id, + { name: download.filename, buffer: download.buffer, type: download.contentType }, + 'email', + { + from, + subject, + receivedAt: created_at, + messageId: message_id, + bodyText, + resendEmailId: email_id, + resendAttachmentId: att.id, + } + ) + results.push({ attachment_id: att.id, inbox_item_id: result.inbox_item_id }) + } catch (err) { + console.error('[invoice-inbox/inbound] Attachment processing failed:', err) + results.push({ + attachment_id: att.id, + error: err instanceof Error ? err.message : 'Unknown error', + }) + } + } + + return NextResponse.json({ data: { processed: results.length, results } }) }, }, diff --git a/extensions/general/invoice-inbox/lib/classify-document.ts b/extensions/general/invoice-inbox/lib/classify-document.ts index fa40ab32..c2f80be7 100644 --- a/extensions/general/invoice-inbox/lib/classify-document.ts +++ b/extensions/general/invoice-inbox/lib/classify-document.ts @@ -84,11 +84,17 @@ Kontext: Instruktioner: - Klassificera dokumenttypen - Extrahera alla synliga fält — returnera null för fält som inte kan utläsas -- Belopp ska vara positiva tal med max 2 decimaler +- Belopp med max 2 decimaler. Totaler (amount_excl_vat, amount_incl_vat, vat_amount) är alltid positiva. line_items.amount är normalt positiva men KAN vara negativa för rabattrader. - Datum i ISO-format (YYYY-MM-DD) - Momssats som heltal (0, 6, 12, eller 25) - Ge en confidence-poäng 0-100 för hur säker du är på klassificeringen och extraktionen +KRITISKT — Totaler och rabatter: +- amount_incl_vat MÅSTE motsvara fakturans slutbelopp ("Amount due", "Att betala", "Totalt", "Subtotal" när moms saknas). Summera ALDRIG delrader om fakturan anger ett explicit totalbelopp — använd det. +- Om en rad har en rabatt/discount under sig (t.ex. "Discount (-$10.00)", "Rabatt -100 kr"), extrahera radens NETTO-belopp (brutto − rabatt), INTE bruttobeloppet. Exempel: en rad "Compute Hours $50.68" följd av "Discount -$10.00" → line_items.amount = 40.68, inte 50.68. +- Summan av line_items.amount + moms MÅSTE bli lika med amount_incl_vat. Om det inte stämmer har du antingen missat en rabatt eller dubbelräknat en rad — kontrollera och justera. +- Om du är osäker på hur rabatter ska fördelas, lägg en enskild negativ "Rabatt"-rad i line_items så summan blir rätt. + Anropa ALLTID verktyget classify_document med resultatet.` // ── Tool schema for structured output ──────────────────────── @@ -311,7 +317,12 @@ function mapToClassificationResult( if (documentType === 'supplier_invoice') { const extractedData = mapToInvoiceExtraction(raw) if (!extractedData) return null - return { documentType, extractedData, confidence, rawResponse: raw, usage } + // mapToInvoiceExtraction caps its own confidence to 50% when line items + // don't reconcile with amount_incl_vat. Propagate that cap to the outer + // confidence so invoice_inbox_items.confidence (used by the UI badge) + // also reflects the reconciliation failure. + const effectiveConfidence = Math.min(confidence, Math.round(extractedData.confidence * 100)) + return { documentType, extractedData, confidence: effectiveConfidence, rawResponse: raw, usage } } if (documentType === 'receipt') { @@ -323,9 +334,32 @@ function mapToClassificationResult( return null } +// Sum of line items must approximately match the extracted subtotal. +// Allows 0.02 * max(|subtotal|, 1) tolerance for rounding. Returns true if the extraction +// is internally consistent; false signals the model skipped a discount or double-counted. +function invoiceTotalsAreConsistent(raw: Record): boolean { + const subtotal = Number(raw.amount_excl_vat) + const total = Number(raw.amount_incl_vat) + const vat = Number(raw.vat_amount) || 0 + const items = Array.isArray(raw.line_items) ? raw.line_items : [] + if (!items.length) return true // nothing to compare against + if (!isFinite(subtotal) && !isFinite(total)) return true + + const sumOfLines = items.reduce((acc, item) => { + if (typeof item !== 'object' || item === null) return acc + const amount = Number((item as Record).amount) + return acc + (isFinite(amount) ? amount : 0) + }, 0) + + const anchor = isFinite(subtotal) ? subtotal : total - vat + const tolerance = Math.max(0.02, Math.abs(anchor) * 0.02) + return Math.abs(sumOfLines - anchor) <= tolerance +} + function mapToInvoiceExtraction(raw: Record): InvoiceExtractionResult | null { const lineItems = mapInvoiceLineItems(raw.line_items) const vatBreakdown = mapVatBreakdown(raw.vat_breakdown) + const totalsConsistent = invoiceTotalsAreConsistent(raw) const result: InvoiceExtractionResult = { supplier: { @@ -350,7 +384,11 @@ function mapToInvoiceExtraction(raw: Record): InvoiceExtraction total: roundAmount(raw.amount_incl_vat), }, vatBreakdown, - confidence: Math.min(1, Math.max(0, Number(raw.confidence) / 100 || 0)), + // If totals don't reconcile with line items, cap confidence at 50% so the UI flags it + confidence: (() => { + const raw_conf = Math.min(1, Math.max(0, Number(raw.confidence) / 100 || 0)) + return totalsConsistent ? raw_conf : Math.min(raw_conf, 0.5) + })(), } return result @@ -472,7 +510,7 @@ async function retryWithCorrection( - document_type måste vara ett av: supplier_invoice, receipt, government_letter, unknown - Datum i format YYYY-MM-DD - Momssatser måste vara 0, 6, 12, eller 25 -- Belopp ska vara positiva tal +- Totaler: amount_incl_vat ska vara fakturans slutbelopp. Summa av line_items.amount + vat_amount MÅSTE bli lika med amount_incl_vat. Om rader har rabatt under sig, använd NETTO-beloppet per rad, eller lägg till en separat negativ rabattrad så summan stämmer. Försök igen med korrigerad data.`, }, ], diff --git a/extensions/general/invoice-inbox/lib/gmail-helpers.ts b/extensions/general/invoice-inbox/lib/gmail-helpers.ts deleted file mode 100644 index ed47837f..00000000 --- a/extensions/general/invoice-inbox/lib/gmail-helpers.ts +++ /dev/null @@ -1,76 +0,0 @@ -import crypto from 'crypto' - -const ALGORITHM = 'aes-256-gcm' - -export function getGmailEncryptionKey(): Buffer { - const secret = process.env.GMAIL_TOKEN_ENCRYPTION_KEY - if (!secret) throw new Error('GMAIL_TOKEN_ENCRYPTION_KEY is required') - return crypto.createHash('sha256').update(secret).digest() -} - -export function encryptState(payload: Record): string { - const key = getGmailEncryptionKey() - const iv = crypto.randomBytes(12) - const cipher = crypto.createCipheriv(ALGORITHM, key, iv) - const json = JSON.stringify(payload) - const encrypted = Buffer.concat([cipher.update(json, 'utf8'), cipher.final()]) - const tag = cipher.getAuthTag() - return Buffer.concat([iv, tag, encrypted]).toString('base64url') -} - -export function decryptState(encoded: string): Record | null { - try { - const key = getGmailEncryptionKey() - const combined = Buffer.from(encoded, 'base64url') - const iv = combined.subarray(0, 12) - const tag = combined.subarray(12, 28) - const encrypted = combined.subarray(28) - const decipher = crypto.createDecipheriv(ALGORITHM, key, iv) - decipher.setAuthTag(tag) - const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]) - return JSON.parse(decrypted.toString('utf8')) - } catch { - return null - } -} - -export function encryptToken(plaintext: string): string { - const key = getGmailEncryptionKey() - const iv = crypto.randomBytes(12) - const cipher = crypto.createCipheriv(ALGORITHM, key, iv) - const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]) - const tag = cipher.getAuthTag() - return Buffer.concat([iv, tag, encrypted]).toString('base64url') -} - -export function decryptToken(encoded: string): string | null { - try { - const key = getGmailEncryptionKey() - const combined = Buffer.from(encoded, 'base64url') - const iv = combined.subarray(0, 12) - const tag = combined.subarray(12, 28) - const encrypted = combined.subarray(28) - const decipher = crypto.createDecipheriv(ALGORITHM, key, iv) - decipher.setAuthTag(tag) - const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]) - return decrypted.toString('utf8') - } catch { - return null - } -} - -export async function refreshAccessToken(refreshToken: string): Promise { - const response = await fetch('https://oauth2.googleapis.com/token', { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: new URLSearchParams({ - refresh_token: refreshToken, - client_id: process.env.GOOGLE_CLIENT_ID!, - client_secret: process.env.GOOGLE_CLIENT_SECRET!, - grant_type: 'refresh_token', - }), - }) - if (!response.ok) return null - const data = await response.json() as { access_token: string } - return data.access_token -} diff --git a/extensions/general/invoice-inbox/lib/gmail-scanner.ts b/extensions/general/invoice-inbox/lib/gmail-scanner.ts deleted file mode 100644 index 000cbc93..00000000 --- a/extensions/general/invoice-inbox/lib/gmail-scanner.ts +++ /dev/null @@ -1,336 +0,0 @@ -import type { SupabaseClient } from '@supabase/supabase-js' -import { uploadDocument, computeSHA256 } from '@/lib/core/documents/document-service' -import { classifyDocument } from './classify-document' -import { decryptToken, refreshAccessToken } from './gmail-helpers' -import type { InvoiceExtractionResult } from '@/types' - -const MIN_ATTACHMENT_SIZE = 3_000 -const MAX_MESSAGES = 30 - -const ALLOWED_MIME_TYPES = new Set([ - 'application/pdf', - 'application/octet-stream', - 'image/jpeg', - 'image/png', - 'image/heic', - 'image/heif', - 'image/webp', -]) - -const SKIP_EXTENSIONS = new Set([ - 'ics', 'vcf', 'html', 'htm', 'zip', 'rar', 'gz', - 'csv', 'json', 'xml', 'txt', 'eml', 'msg', - 'mp3', 'mp4', 'mov', 'avi', 'wav', -]) - -interface GmailMessage { - id: string - payload: { - headers: { name: string; value: string }[] - parts?: GmailPart[] - mimeType: string - body?: { attachmentId?: string; size?: number; data?: string } - } - internalDate: string -} - -interface GmailPart { - mimeType: string - filename: string - body: { attachmentId?: string; size?: number; data?: string } - parts?: GmailPart[] -} - -function getHeader(message: GmailMessage, name: string): string | null { - return message.payload.headers.find( - (h) => h.name.toLowerCase() === name.toLowerCase() - )?.value ?? null -} - -function collectAttachments(parts: GmailPart[] | undefined): GmailPart[] { - if (!parts) return [] - const result: GmailPart[] = [] - for (const part of parts) { - if (part.filename && part.body?.attachmentId) { - result.push(part) - } - if (part.parts) { - result.push(...collectAttachments(part.parts)) - } - } - return result -} - -function resolveActualMimeType(mimeType: string, filename: string): string | null { - const ext = filename.split('.').pop()?.toLowerCase() - if (ext && SKIP_EXTENSIONS.has(ext)) return null - - if (mimeType === 'application/octet-stream') { - const extMap: Record = { - pdf: 'application/pdf', - jpg: 'image/jpeg', - jpeg: 'image/jpeg', - png: 'image/png', - webp: 'image/webp', - } - return ext && extMap[ext] ? extMap[ext] : null - } - - return mimeType -} - -export interface ScanResult { - scanned: number - classified: number - skipped: number - errors: number -} - -interface EmailConnection { - id: string - company_id: string - encrypted_token: string - last_sync_at: string | null - gmail_label_id: string | null -} - -export async function scanGmailConnection( - supabase: SupabaseClient, - connection: EmailConnection, - userId: string, - companyId: string -): Promise { - const result: ScanResult = { scanned: 0, classified: 0, skipped: 0, errors: 0 } - const seenFileHashes = new Set() - - const refreshToken = decryptToken(connection.encrypted_token) - if (!refreshToken) { - await supabase - .from('email_connections') - .update({ status: 'error', error_message: 'Failed to decrypt refresh token' }) - .eq('id', connection.id) - result.errors++ - return result - } - - const accessToken = await refreshAccessToken(refreshToken) - if (!accessToken) { - await supabase - .from('email_connections') - .update({ status: 'revoked', error_message: 'Token refresh failed — user may have revoked access' }) - .eq('id', connection.id) - result.errors++ - return result - } - - // Build Gmail search query - let afterDate: string - if (connection.last_sync_at) { - const d = new Date(connection.last_sync_at) - afterDate = `${d.getFullYear()}/${String(d.getMonth() + 1).padStart(2, '0')}/${String(d.getDate()).padStart(2, '0')}` - } else { - const d = new Date(Date.now() - 14 * 24 * 60 * 60 * 1000) - afterDate = `${d.getFullYear()}/${String(d.getMonth() + 1).padStart(2, '0')}/${String(d.getDate()).padStart(2, '0')}` - } - - let query = `has:attachment after:${afterDate}` - if (connection.gmail_label_id) { - query += ' -label:gnubok-processed' - } - - const listUrl = `https://gmail.googleapis.com/gmail/v1/users/me/messages?q=${encodeURIComponent(query)}&maxResults=${MAX_MESSAGES}` - const listResponse = await fetch(listUrl, { - headers: { Authorization: `Bearer ${accessToken}` }, - }) - - if (!listResponse.ok) { - console.error('[gmail/scan] Failed to list messages:', await listResponse.text()) - result.errors++ - return result - } - - const listData = await listResponse.json() as { messages?: { id: string }[] } - const messageIds = listData.messages || [] - - for (const { id: messageId } of messageIds) { - try { - const msgResponse = await fetch( - `https://gmail.googleapis.com/gmail/v1/users/me/messages/${messageId}?format=full`, - { headers: { Authorization: `Bearer ${accessToken}` } } - ) - if (!msgResponse.ok) continue - const message = await msgResponse.json() as GmailMessage - - const emailFrom = getHeader(message, 'From') - const emailSubject = getHeader(message, 'Subject') - const emailDate = message.internalDate - ? new Date(parseInt(message.internalDate)).toISOString() - : null - - const attachments = collectAttachments(message.payload.parts) - - for (const attachment of attachments) { - if (!attachment.body.attachmentId) continue - if ((attachment.body.size ?? 0) < MIN_ATTACHMENT_SIZE) continue - if (!ALLOWED_MIME_TYPES.has(attachment.mimeType)) continue - - const resolvedMimeType = resolveActualMimeType(attachment.mimeType, attachment.filename) - if (!resolvedMimeType) continue - - // Deduplicate by message ID + filename - const { data: existing } = await supabase - .from('invoice_inbox_items') - .select('id') - .eq('company_id', companyId) - .eq('source', 'email') - .filter('raw_email_payload->>messageId', 'eq', messageId) - .filter('raw_email_payload->>filename', 'eq', attachment.filename) - .limit(1) - .maybeSingle() - - if (existing) { - result.skipped++ - continue - } - - // Download attachment - const attResponse = await fetch( - `https://gmail.googleapis.com/gmail/v1/users/me/messages/${messageId}/attachments/${attachment.body.attachmentId}`, - { headers: { Authorization: `Bearer ${accessToken}` } } - ) - if (!attResponse.ok) { - result.errors++ - continue - } - - const attData = await attResponse.json() as { data: string } - const fileBuffer = Buffer.from(attData.data, 'base64url') - - // Deduplicate by file content hash - const fileHash = await computeSHA256(fileBuffer.buffer.slice( - fileBuffer.byteOffset, - fileBuffer.byteOffset + fileBuffer.byteLength - )) - if (seenFileHashes.has(fileHash)) { - result.skipped++ - continue - } - const { data: existingByHash } = await supabase - .from('document_attachments') - .select('id') - .eq('company_id', companyId) - .eq('sha256_hash', fileHash) - .limit(1) - .maybeSingle() - if (existingByHash) { - result.skipped++ - seenFileHashes.add(fileHash) - continue - } - seenFileHashes.add(fileHash) - - // Store in WORM archive - const doc = await uploadDocument(supabase, userId, companyId, { - name: attachment.filename, - buffer: fileBuffer.buffer.slice( - fileBuffer.byteOffset, - fileBuffer.byteOffset + fileBuffer.byteLength - ), - type: resolvedMimeType, - }, { - upload_source: 'email', - }) - - // Classify - let classificationResult - let classificationError: string | null = null - try { - classificationResult = await classifyDocument({ - fileBuffer, - mimeType: resolvedMimeType, - fileName: attachment.filename, - }) - } catch (err) { - classificationError = err instanceof Error ? err.message : 'Classification failed' - console.error('[gmail/scan] Classification failed:', err) - } - - // Find matching supplier - let matchedSupplierId: string | null = null - if (classificationResult?.documentType === 'supplier_invoice' && classificationResult.extractedData) { - const extractedData = classificationResult.extractedData as InvoiceExtractionResult - const orgNumber = extractedData.supplier?.orgNumber - if (orgNumber) { - const normalized = orgNumber.replace(/\D/g, '') - const { data: supplierByOrg } = await supabase - .from('suppliers') - .select('id') - .eq('company_id', companyId) - .eq('org_number', normalized) - .limit(1) - .maybeSingle() - if (supplierByOrg) matchedSupplierId = supplierByOrg.id - } - } - - // Create inbox item - await supabase.from('invoice_inbox_items').insert({ - company_id: companyId, - user_id: userId, - status: classificationError ? 'error' : 'ready', - source: 'email', - document_id: doc.id, - document_type: classificationResult?.documentType || 'unknown', - extracted_data: classificationResult?.extractedData || null, - raw_llm_response: classificationResult?.rawResponse || null, - confidence: classificationResult?.confidence - ? classificationResult.confidence / 100 - : null, - matched_supplier_id: matchedSupplierId, - email_from: emailFrom, - email_subject: emailSubject, - email_received_at: emailDate, - raw_email_payload: { messageId, filename: attachment.filename }, - error_message: classificationError, - }) - - if (classificationError) { - result.errors++ - } else { - result.classified++ - } - result.scanned++ - } - - // Label message as processed - if (connection.gmail_label_id) { - try { - await fetch( - `https://gmail.googleapis.com/gmail/v1/users/me/messages/${messageId}/modify`, - { - method: 'POST', - headers: { - Authorization: `Bearer ${accessToken}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ addLabelIds: [connection.gmail_label_id] }), - } - ) - } catch { - // Non-blocking - } - } - } catch (err) { - console.error('[gmail/scan] Error processing message:', err) - result.errors++ - } - } - - // Update last_sync_at - await supabase - .from('email_connections') - .update({ last_sync_at: new Date().toISOString(), error_message: null }) - .eq('id', connection.id) - - return result -} diff --git a/extensions/general/invoice-inbox/lib/inbox-provisioning.ts b/extensions/general/invoice-inbox/lib/inbox-provisioning.ts new file mode 100644 index 00000000..e12215a7 --- /dev/null +++ b/extensions/general/invoice-inbox/lib/inbox-provisioning.ts @@ -0,0 +1,43 @@ +import type { SupabaseClient } from '@supabase/supabase-js' +import type { CompanyInbox } from '@/types' + +export function composeInboxAddress(localPart: string, domain: string): string { + return `${localPart}@${domain}` +} + +export async function getActiveInbox( + supabase: SupabaseClient, + companyId: string +): Promise { + const { data, error } = await supabase + .from('company_inboxes') + .select('*') + .eq('company_id', companyId) + .eq('status', 'active') + .maybeSingle() + + if (error) throw new Error(`Failed to load inbox: ${error.message}`) + return (data as CompanyInbox | null) ?? null +} + +// Rotate the company's inbox address. Delegates to the rotate_company_inbox +// RPC so the three steps (deprecate, generate, insert) run inside a single +// Postgres transaction; a failure on any step rolls the whole thing back +// and the company is never left without an active inbox. +export async function rotateCompanyInbox( + supabase: SupabaseClient, + companyId: string +): Promise { + const { data, error } = await supabase + .rpc('rotate_company_inbox', { p_company_id: companyId }) + + if (error || !data) { + throw new Error(`Failed to rotate inbox: ${error?.message ?? 'no data'}`) + } + + // The RPC returns a single row (SETOF company_inboxes). + const row = Array.isArray(data) ? data[0] : data + if (!row) throw new Error('Failed to rotate inbox: RPC returned no row') + + return row as CompanyInbox +} diff --git a/extensions/general/invoice-inbox/lib/resend-inbound.ts b/extensions/general/invoice-inbox/lib/resend-inbound.ts new file mode 100644 index 00000000..157aa6b9 --- /dev/null +++ b/extensions/general/invoice-inbox/lib/resend-inbound.ts @@ -0,0 +1,99 @@ +import { Resend } from 'resend' +import type { EmailReceivedEvent, GetReceivingEmailResponseSuccess, WebhookEventPayload } from 'resend' + +export type ResendInboundEvent = EmailReceivedEvent + +export type ResendReceivedEmail = GetReceivingEmailResponseSuccess + +export interface ResendAttachmentDownload { + id: string + filename: string + contentType: string + buffer: ArrayBuffer +} + +function getResend(): Resend { + const apiKey = process.env.RESEND_API_KEY + if (!apiKey) throw new Error('RESEND_API_KEY is required') + return new Resend(apiKey) +} + +export class ResendSignatureError extends Error { + constructor(message: string) { + super(message) + this.name = 'ResendSignatureError' + } +} + +// Verifies the Svix-signed webhook payload using the RESEND_INBOUND_WEBHOOK_SECRET. +// Throws ResendSignatureError on failure, returns the parsed event on success. +export function verifyInboundWebhook(rawBody: string, requestHeaders: Headers): WebhookEventPayload { + const secret = process.env.RESEND_INBOUND_WEBHOOK_SECRET + if (!secret) throw new Error('RESEND_INBOUND_WEBHOOK_SECRET is required') + + // Resend's verify() expects Svix headers in a specific shape, not the raw Fetch Headers. + const svixHeaders = { + id: requestHeaders.get('svix-id') ?? '', + timestamp: requestHeaders.get('svix-timestamp') ?? '', + signature: requestHeaders.get('svix-signature') ?? '', + } + + const resend = getResend() + try { + return resend.webhooks.verify({ payload: rawBody, headers: svixHeaders, webhookSecret: secret }) + } catch (err) { + throw new ResendSignatureError(err instanceof Error ? err.message : 'Invalid signature') + } +} + +// Fetches the full received email (body, headers, attachment metadata) by email_id. +export async function fetchReceivingEmail(emailId: string): Promise { + const resend = getResend() + const { data, error } = await resend.emails.receiving.get(emailId) + if (error || !data) { + throw new Error(`Failed to fetch received email ${emailId}: ${error?.message ?? 'no data'}`) + } + return data +} + +// Fetches a single attachment's bytes via its short-lived download_url. +export async function fetchInboundAttachment( + emailId: string, + attachmentId: string +): Promise { + const resend = getResend() + const { data, error } = await resend.emails.receiving.attachments.get({ emailId, id: attachmentId }) + if (error || !data) { + throw new Error(`Failed to fetch attachment ${attachmentId}: ${error?.message ?? 'no data'}`) + } + + const response = await fetch(data.download_url) + if (!response.ok) { + throw new Error(`Download URL returned ${response.status} for attachment ${attachmentId}`) + } + const buffer = await response.arrayBuffer() + + return { + id: data.id, + filename: data.filename ?? `attachment-${data.id}`, + contentType: data.content_type, + buffer, + } +} + +// Parses the first recipient whose domain matches our configured inbound domain, +// returning just the local_part. Returns null if no match. +export function extractLocalPartForDomain(recipients: string[], domain: string): string | null { + const normalized = domain.toLowerCase() + for (const addr of recipients) { + const match = addr.match(/^\s*([^@\s]+)@([^@\s]+?)\s*$/) + if (!match) continue + const [, localPart, addrDomain] = match + if (addrDomain.toLowerCase() === normalized) return localPart.toLowerCase() + } + return null +} + +export function isEmailReceivedEvent(event: WebhookEventPayload): event is EmailReceivedEvent { + return event.type === 'email.received' +} diff --git a/extensions/general/invoice-inbox/manifest.json b/extensions/general/invoice-inbox/manifest.json index ea1baf45..934078fa 100644 --- a/extensions/general/invoice-inbox/manifest.json +++ b/extensions/general/invoice-inbox/manifest.json @@ -4,8 +4,15 @@ "exportName": "invoiceInboxExtension", "entryPoint": "@/extensions/general/invoice-inbox", "workspace": "@/components/extensions/general/InvoiceInboxWorkspace", - "requiredEnvVars": ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_REGION"], - "optionalEnvVars": ["BEDROCK_MODEL_ID", "BEDROCK_MAX_TOKENS", "GOOGLE_CLIENT_ID", "GOOGLE_CLIENT_SECRET", "GMAIL_TOKEN_ENCRYPTION_KEY"], + "requiredEnvVars": [ + "AWS_ACCESS_KEY_ID", + "AWS_SECRET_ACCESS_KEY", + "AWS_REGION", + "RESEND_API_KEY", + "RESEND_INBOUND_DOMAIN", + "RESEND_INBOUND_WEBHOOK_SECRET" + ], + "optionalEnvVars": ["BEDROCK_MODEL_ID", "BEDROCK_MAX_TOKENS"], "npmDependencies": ["@aws-sdk/client-bedrock-runtime"], "definition": { "name": "Dokumentinkorg", @@ -15,6 +22,6 @@ "hasOwnData": true, "readsCoreTables": ["document_attachments", "suppliers", "transactions"], "description": "AI-klassificering och extraktion av leverantörsfakturor och kvitton", - "longDescription": "Ladda upp eller ta emot dokument via Gmail. AI klassificerar dokumenttyp, extraherar strukturerad data (leverantör, belopp, moms) och matchar mot transaktioner. Kräver AWS Bedrock-åtkomst." + "longDescription": "Varje bolag får en unik fakturainkorg-adress. Fakturor som skickas dit fångas automatiskt, klassificeras med AI (leverantör, belopp, moms) och matchas mot transaktioner. Kräver AWS Bedrock och Resend." } } diff --git a/lib/bookkeeping/__tests__/voucher-atomicity.test.ts b/lib/bookkeeping/__tests__/voucher-atomicity.test.ts index f80cdb80..5a31aa70 100644 --- a/lib/bookkeeping/__tests__/voucher-atomicity.test.ts +++ b/lib/bookkeeping/__tests__/voucher-atomicity.test.ts @@ -66,6 +66,8 @@ describe('voucher number atomicity', () => { expect(supabase.rpc).toHaveBeenCalledWith('commit_journal_entry', { p_company_id: 'co-1', p_entry_id: 'entry-1', + p_commit_method: null, + p_rubric_version: null, }) // from() was never called — the RPC handles everything atomically @@ -102,6 +104,8 @@ describe('voucher number atomicity', () => { expect(supabase.rpc).toHaveBeenCalledWith('commit_journal_entry', { p_company_id: 'co-1', p_entry_id: 'entry-1', + p_commit_method: null, + p_rubric_version: null, }) // from() called once to fetch the complete entry with lines expect(supabase.from).toHaveBeenCalledWith('journal_entries') diff --git a/lib/bookkeeping/engine.ts b/lib/bookkeeping/engine.ts index 5017f7d1..78768901 100644 --- a/lib/bookkeeping/engine.ts +++ b/lib/bookkeeping/engine.ts @@ -245,7 +245,9 @@ export async function commitEntry( supabase: SupabaseClient, companyId: string, userId: string, - entryId: string + entryId: string, + commitMethod?: string, + rubricVersion?: string ): Promise { // Atomic: increment voucher sequence + update status in one transaction. @@ -253,6 +255,8 @@ export async function commitEntry( const { data: rpcResult, error: commitError } = await supabase.rpc('commit_journal_entry', { p_company_id: companyId, p_entry_id: entryId, + p_commit_method: commitMethod ?? null, + p_rubric_version: rubricVersion ?? null, }) if (commitError) { @@ -286,10 +290,12 @@ export async function createJournalEntry( supabase: SupabaseClient, companyId: string, userId: string, - input: CreateJournalEntryInput + input: CreateJournalEntryInput, + commitMethod?: string, + rubricVersion?: string ): Promise { const draft = await createDraftEntry(supabase, companyId, userId, input) - return commitEntry(supabase, companyId, userId, draft.id) + return commitEntry(supabase, companyId, userId, draft.id, commitMethod, rubricVersion) } /** diff --git a/lib/events/types.ts b/lib/events/types.ts index 5bc700dc..967601d8 100644 --- a/lib/events/types.ts +++ b/lib/events/types.ts @@ -74,6 +74,8 @@ export type CoreEvent = | { type: 'supplier_invoice.received'; payload: { inboxItem: InvoiceInboxItem; userId: string; companyId: string } } | { type: 'supplier_invoice.extracted'; payload: { inboxItem: InvoiceInboxItem; confidence: number; userId: string; companyId: string } } | { type: 'supplier_invoice.confirmed'; payload: { inboxItem: InvoiceInboxItem; supplierInvoice: SupplierInvoice; userId: string; companyId: string } } + // Generic inbox classification (fires for all document_types after classify) + | { type: 'inbox_item.classified'; payload: { inboxItem: InvoiceInboxItem; documentType: 'supplier_invoice' | 'receipt' | 'government_letter' | 'unknown'; confidence: number | null; correlationId: string; userId: string; companyId: string } } // Salary | { type: 'salary_run.created'; payload: { salaryRunId: string; periodYear: number; periodMonth: number; userId: string; companyId: string } } | { type: 'salary_run.approved'; payload: { salaryRunId: string; approvedBy: string; userId: string; companyId: string } } diff --git a/lib/extensions/__tests__/sectors.test.ts b/lib/extensions/__tests__/sectors.test.ts index b454da69..ebab2da7 100644 --- a/lib/extensions/__tests__/sectors.test.ts +++ b/lib/extensions/__tests__/sectors.test.ts @@ -48,8 +48,8 @@ describe('sectors registry', () => { expect(SECTORS.length).toBe(1) }) - it('should have 10 total extensions', () => { - expect(getAllExtensions().length).toBe(10) + it('should have 11 total extensions', () => { + expect(getAllExtensions().length).toBe(11) }) it('should have unique slugs within each sector', () => { @@ -94,7 +94,7 @@ describe('sectors registry', () => { it('getExtensionsBySector returns extensions for a sector', () => { const extensions = getExtensionsBySector('general') - expect(extensions.length).toBe(10) + expect(extensions.length).toBe(11) }) it('all extensions have required fields', () => { diff --git a/lib/processing-history/append.ts b/lib/processing-history/append.ts index 460317fe..9d69c0c5 100644 --- a/lib/processing-history/append.ts +++ b/lib/processing-history/append.ts @@ -33,9 +33,21 @@ const PII_PATTERNS = [ /\b\d{8}-?\d{4}\b/, // 12-digit variant (YYYYMMDD-NNNN) or orgnr ] +// UUIDs (RFC 4122, 8-4-4-4-12 hex layout) frequently contain all-digit segments +// that incorrectly match the 8+4 personnummer pattern — e.g. `57484518-3409-...`. +// Strip UUID-shaped substrings before PII matching so legitimate identifiers +// aren't rejected. Personnummer always sit outside the UUID shape, so this keeps +// the original safety intent intact. +const UUID_PATTERN = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi + +function stringContainsPii(value: string): boolean { + const stripped = value.replace(UUID_PATTERN, '') + return PII_PATTERNS.some(pattern => pattern.test(stripped)) +} + function containsPii(value: unknown): boolean { if (typeof value === 'string') { - return PII_PATTERNS.some(pattern => pattern.test(value)) + return stringContainsPii(value) } if (Array.isArray(value)) { return value.some(containsPii) @@ -52,7 +64,7 @@ const piiSafePayload = z.record(z.string(), z.unknown()).refine( ) function assertActorPiiSafe(actor: ProcessingHistoryActor): void { - if (actor.label && PII_PATTERNS.some(p => p.test(actor.label!))) { + if (actor.label && stringContainsPii(actor.label)) { throw new Error( 'actor.label contains PII (personnummer/samordningsnummer/orgnr pattern). Use a pseudonymous descriptor only.' ) diff --git a/supabase/migrations/20260420000000_arcim_inbox.sql b/supabase/migrations/20260420000000_arcim_inbox.sql new file mode 100644 index 00000000..96e670db --- /dev/null +++ b/supabase/migrations/20260420000000_arcim_inbox.sql @@ -0,0 +1,179 @@ +-- Arcim inbox: per-company @arcim.io email address for Resend Inbound +-- Replaces the Gmail OAuth model with a push-based inbound webhook flow. +-- +-- Changes: +-- 1. New company_inboxes table with one active address per company +-- 2. Extend invoice_inbox_items with resend_email_id (idempotency) and email_body_text +-- 3. Drop the obsolete email_connections table (no production data; extension was disabled) +-- 4. generate_inbox_local_part() function: slug + 4-char suffix from a 30-char alphabet +-- 5. Auto-provision trigger on companies INSERT +-- 6. One-time backfill for existing companies + +-- ============================================================================= +-- 1. company_inboxes table +-- ============================================================================= + +CREATE TABLE IF NOT EXISTS public.company_inboxes ( + id uuid DEFAULT gen_random_uuid() PRIMARY KEY, + company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE, + local_part text NOT NULL, + status text NOT NULL DEFAULT 'active' + CHECK (status IN ('active', 'deprecated', 'blocked')), + slug_seed text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deprecated_at timestamptz +); + +-- Globally unique addresses across all rows (deprecated rows keep their local_part reserved) +CREATE UNIQUE INDEX IF NOT EXISTS idx_company_inboxes_local_part + ON public.company_inboxes(local_part); + +-- One active inbox per company at a time +CREATE UNIQUE INDEX IF NOT EXISTS idx_company_inboxes_company_active + ON public.company_inboxes(company_id) WHERE status = 'active'; + +-- Lookup by company +CREATE INDEX IF NOT EXISTS idx_company_inboxes_company + ON public.company_inboxes(company_id); + +-- RLS +ALTER TABLE public.company_inboxes ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "company_inboxes_select" ON public.company_inboxes + FOR SELECT USING (company_id IN (SELECT public.user_company_ids())); +CREATE POLICY "company_inboxes_insert" ON public.company_inboxes + FOR INSERT WITH CHECK (company_id IN (SELECT public.user_company_ids())); +CREATE POLICY "company_inboxes_update" ON public.company_inboxes + FOR UPDATE USING (company_id IN (SELECT public.user_company_ids())); + +-- updated_at trigger +CREATE TRIGGER company_inboxes_updated_at + BEFORE UPDATE ON public.company_inboxes + FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column(); + +-- ============================================================================= +-- 2. Extend invoice_inbox_items +-- ============================================================================= + +ALTER TABLE public.invoice_inbox_items + ADD COLUMN IF NOT EXISTS resend_email_id text, + ADD COLUMN IF NOT EXISTS email_body_text text; + +-- Idempotency: Resend retries the webhook on our failures; a unique index rejects duplicates +CREATE UNIQUE INDEX IF NOT EXISTS idx_invoice_inbox_items_resend_email_id + ON public.invoice_inbox_items(resend_email_id) + WHERE resend_email_id IS NOT NULL; + +-- ============================================================================= +-- 3. Drop obsolete email_connections (Gmail OAuth storage) +-- ============================================================================= + +DROP TABLE IF EXISTS public.email_connections; + +-- ============================================================================= +-- 4. generate_inbox_local_part(): slugify company name + 4-char suffix +-- ============================================================================= + +CREATE OR REPLACE FUNCTION public.generate_inbox_local_part(p_company_name text) +RETURNS text +LANGUAGE plpgsql +AS $$ +DECLARE + -- Crockford-ish base32 without ambiguous chars (no i/l/o/u, digits 2-9) + v_alphabet constant text := 'abcdefghjkmnpqrstvwxyz23456789'; + v_alphabet_len constant int := 30; + v_slug text; + v_suffix text; + v_candidate text; + v_attempt int := 0; + v_max_attempts constant int := 20; +BEGIN + -- Slugify: normalize Swedish vowels, strip accents, lowercase, non-alnum → hyphen + v_slug := lower(COALESCE(p_company_name, '')); + v_slug := translate(v_slug, 'åäöéèêàâüñ', 'aaoeeeaaun'); + v_slug := regexp_replace(v_slug, '[^a-z0-9]+', '-', 'g'); + v_slug := regexp_replace(v_slug, '^-+|-+$', '', 'g'); + v_slug := substring(v_slug from 1 for 24); + v_slug := regexp_replace(v_slug, '-+$', '', 'g'); + + IF v_slug IS NULL OR v_slug = '' THEN + v_slug := 'company'; + END IF; + + LOOP + v_attempt := v_attempt + 1; + + -- Build a 4-char suffix + v_suffix := ''; + FOR i IN 1..4 LOOP + v_suffix := v_suffix || substr(v_alphabet, 1 + floor(random() * v_alphabet_len)::int, 1); + END LOOP; + + v_candidate := v_slug || '-' || v_suffix; + + IF NOT EXISTS (SELECT 1 FROM public.company_inboxes WHERE local_part = v_candidate) THEN + RETURN v_candidate; + END IF; + + IF v_attempt >= v_max_attempts THEN + RAISE EXCEPTION 'Failed to generate unique inbox local_part after % attempts for slug %', v_max_attempts, v_slug; + END IF; + END LOOP; +END; +$$; + +-- ============================================================================= +-- 5. Auto-provision trigger on companies INSERT +-- ============================================================================= + +CREATE OR REPLACE FUNCTION public.auto_provision_company_inbox() +RETURNS TRIGGER +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_local_part text; + v_slug_seed text; +BEGIN + v_local_part := public.generate_inbox_local_part(NEW.name); + v_slug_seed := regexp_replace(v_local_part, '-[^-]+$', ''); + + INSERT INTO public.company_inboxes (company_id, local_part, slug_seed, status) + VALUES (NEW.id, v_local_part, v_slug_seed, 'active'); + + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS companies_auto_provision_inbox ON public.companies; +CREATE TRIGGER companies_auto_provision_inbox + AFTER INSERT ON public.companies + FOR EACH ROW EXECUTE FUNCTION public.auto_provision_company_inbox(); + +-- ============================================================================= +-- 6. Backfill existing companies +-- ============================================================================= + +DO $$ +DECLARE + c RECORD; + v_local_part text; + v_slug_seed text; +BEGIN + FOR c IN + SELECT co.id, co.name + FROM public.companies co + WHERE NOT EXISTS ( + SELECT 1 FROM public.company_inboxes ci WHERE ci.company_id = co.id + ) + LOOP + v_local_part := public.generate_inbox_local_part(c.name); + v_slug_seed := regexp_replace(v_local_part, '-[^-]+$', ''); + INSERT INTO public.company_inboxes (company_id, local_part, slug_seed, status) + VALUES (c.id, v_local_part, v_slug_seed, 'active'); + END LOOP; +END $$; + +NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/20260420120000_journal_entry_commit_metadata.sql b/supabase/migrations/20260420120000_journal_entry_commit_metadata.sql new file mode 100644 index 00000000..638998e1 --- /dev/null +++ b/supabase/migrations/20260420120000_journal_entry_commit_metadata.sql @@ -0,0 +1,101 @@ +-- Add commit_method and rubric_version columns to journal_entries. +-- Tracks HOW each entry was committed and WHICH rubric was active. +-- Required for autonomous accounting: trust ramp, timing ceiling, audit trail. +-- +-- Compliance: BFNAR 2013:2 kap 8 requires behandlingshistorik to log automated +-- processing and user actions. commit_method records this per verifikation. +-- timing_ceiling aligns with BFL 5 kap 2§ (50-day maximum via BFNAR 2013:2). + +-- 1. Add columns (nullable — existing rows get NULL) +ALTER TABLE public.journal_entries + ADD COLUMN commit_method TEXT CHECK (commit_method IS NULL OR commit_method IN ( + 'user_accept', 'bulk_accept', 'timing_ceiling', 'migration', 'legacy' + )), + ADD COLUMN rubric_version TEXT; + +-- 2. Update commit RPC to accept and set the new columns atomically +CREATE OR REPLACE FUNCTION public.commit_journal_entry( + p_company_id uuid, + p_entry_id uuid, + p_commit_method text DEFAULT NULL, + p_rubric_version text DEFAULT NULL +) +RETURNS TABLE (voucher_number integer) +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +DECLARE + v_next integer; + v_fiscal_period_id uuid; + v_series text; +BEGIN + -- Fetch and lock the draft entry + SELECT je.fiscal_period_id, COALESCE(je.voucher_series, 'A') + INTO v_fiscal_period_id, v_series + FROM public.journal_entries je + WHERE je.id = p_entry_id + AND je.company_id = p_company_id + AND je.status = 'draft' + FOR UPDATE; + + IF NOT FOUND THEN + RAISE EXCEPTION 'Draft journal entry not found: %', p_entry_id; + END IF; + + -- Increment voucher sequence (atomic via INSERT ON CONFLICT) + INSERT INTO public.voucher_sequences (company_id, user_id, fiscal_period_id, voucher_series, last_number) + VALUES (p_company_id, auth.uid(), v_fiscal_period_id, v_series, 1) + ON CONFLICT (company_id, fiscal_period_id, voucher_series) + DO UPDATE SET + last_number = public.voucher_sequences.last_number + 1, + updated_at = now() + RETURNING last_number INTO v_next; + + -- Update entry to posted with assigned voucher number and commit metadata. + -- If the balance trigger (check_balance_on_post) rejects the UPDATE, + -- the entire transaction rolls back — including the sequence increment. + UPDATE public.journal_entries + SET voucher_number = v_next, + status = 'posted', + commit_method = p_commit_method, + rubric_version = p_rubric_version + WHERE id = p_entry_id + AND company_id = p_company_id; + + RETURN QUERY SELECT v_next; +END; +$$; + +-- 3. Update immutability trigger: include new fields in reversal field check +CREATE OR REPLACE FUNCTION public.enforce_journal_entry_immutability() +RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + IF TG_OP = 'DELETE' THEN + RAISE EXCEPTION 'Cannot delete journal entries (id: %, status: %). Use cancelled status instead.', + OLD.id, OLD.status; + END IF; + + -- Draft can transition to draft (update fields), posted, or cancelled + IF OLD.status = 'draft' AND NEW.status IN ('draft', 'posted', 'cancelled') THEN + RETURN NEW; + END IF; + + -- Posted can transition to reversed (storno) or cancelled (orphaned concurrent reversal cleanup) + IF OLD.status = 'posted' AND NEW.status IN ('reversed', 'cancelled') THEN + IF NEW.status = 'reversed' THEN + IF NEW.description != OLD.description OR NEW.entry_date != OLD.entry_date + OR NEW.fiscal_period_id != OLD.fiscal_period_id + OR NEW.voucher_number != OLD.voucher_number + OR NEW.commit_method IS DISTINCT FROM OLD.commit_method + OR NEW.rubric_version IS DISTINCT FROM OLD.rubric_version THEN + RAISE EXCEPTION 'Cannot modify fields of a posted entry during reversal (id: %)', OLD.id; + END IF; + END IF; + RETURN NEW; + END IF; + + RAISE EXCEPTION 'Cannot modify a % journal entry (id: %). Committed entries are immutable per Bokforingslagen.', + OLD.status, OLD.id; +END; $$; + +NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/20260420170000_inbox_attachment_composite.sql b/supabase/migrations/20260420170000_inbox_attachment_composite.sql new file mode 100644 index 00000000..bc3639d6 --- /dev/null +++ b/supabase/migrations/20260420170000_inbox_attachment_composite.sql @@ -0,0 +1,14 @@ +-- Allow multiple invoice_inbox_items per email (one per attachment). +-- Replaces the single-column unique on resend_email_id with a composite +-- on (resend_email_id, resend_attachment_id). + +DROP INDEX IF EXISTS idx_invoice_inbox_items_resend_email_id; + +ALTER TABLE public.invoice_inbox_items + ADD COLUMN IF NOT EXISTS resend_attachment_id text; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_invoice_inbox_items_resend_email_attachment + ON public.invoice_inbox_items(resend_email_id, resend_attachment_id) + WHERE resend_email_id IS NOT NULL; + +NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/20260420180000_inbox_smart_match.sql b/supabase/migrations/20260420180000_inbox_smart_match.sql new file mode 100644 index 00000000..9107e41b --- /dev/null +++ b/supabase/migrations/20260420180000_inbox_smart_match.sql @@ -0,0 +1,36 @@ +-- inbox-smart-match extension schema additions +-- Adds correlation_id (audit chain) and match_reasoning (LLM explanation) +-- and expands match_method to include 'llm' and 'pending_transaction'. + +ALTER TABLE public.invoice_inbox_items + ADD COLUMN IF NOT EXISTS correlation_id uuid, + ADD COLUMN IF NOT EXISTS match_reasoning text; + +-- Expand allowed match_method values +ALTER TABLE public.invoice_inbox_items + DROP CONSTRAINT IF EXISTS invoice_inbox_items_match_method_check; + +ALTER TABLE public.invoice_inbox_items + ADD CONSTRAINT invoice_inbox_items_match_method_check + CHECK (match_method IN ( + 'payment_reference', + 'amount_date', + 'amount_merchant', + 'receipt_match', + 'llm', + 'pending_transaction' + )); + +-- Index for retroactive matcher queries ("find all receipts awaiting a transaction for this company") +CREATE INDEX IF NOT EXISTS idx_inbox_items_pending_match + ON public.invoice_inbox_items(company_id) + WHERE document_type = 'receipt' + AND match_method = 'pending_transaction' + AND status = 'ready'; + +-- Index for correlation-id audit queries +CREATE INDEX IF NOT EXISTS idx_inbox_items_correlation_id + ON public.invoice_inbox_items(correlation_id) + WHERE correlation_id IS NOT NULL; + +NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/20260420190000_inbox_hardening.sql b/supabase/migrations/20260420190000_inbox_hardening.sql new file mode 100644 index 00000000..abcb6dc2 --- /dev/null +++ b/supabase/migrations/20260420190000_inbox_hardening.sql @@ -0,0 +1,111 @@ +-- Hardening follow-up to 20260420000000_arcim_inbox and 20260420180000_inbox_smart_match. +-- +-- Fixes identified in PR #286 review: +-- 1. Non-atomic rotation in rotateCompanyInbox() — replaced with a +-- SECURITY DEFINER RPC so deprecate/generate/insert happen in one +-- Postgres transaction. +-- 2. Overly permissive RLS on company_inboxes (any member, including +-- viewers, could INSERT/UPDATE). Tightened to owner/admin only. +-- 3. Dual-match race in inbox-smart-match (two receipts could both pair +-- themselves to the same transaction). Enforced by partial unique +-- index; process-match catches 23505 and falls back to pending. + +-- ============================================================================= +-- 1. Tighten RLS on company_inboxes to owner/admin only (for INSERT + UPDATE) +-- ============================================================================= + +DROP POLICY IF EXISTS "company_inboxes_insert" ON public.company_inboxes; +CREATE POLICY "company_inboxes_insert" ON public.company_inboxes + FOR INSERT WITH CHECK ( + company_id IN ( + SELECT cm.company_id FROM public.company_members cm + WHERE cm.user_id = auth.uid() + AND cm.role IN ('owner', 'admin') + ) + ); + +DROP POLICY IF EXISTS "company_inboxes_update" ON public.company_inboxes; +CREATE POLICY "company_inboxes_update" ON public.company_inboxes + FOR UPDATE USING ( + company_id IN ( + SELECT cm.company_id FROM public.company_members cm + WHERE cm.user_id = auth.uid() + AND cm.role IN ('owner', 'admin') + ) + ); + +-- Note: auto_provision_company_inbox() and rotate_company_inbox() are +-- SECURITY DEFINER and bypass these policies; the policies only gate +-- direct client-side writes (defense-in-depth). + +-- ============================================================================= +-- 2. Atomic rotate RPC +-- ============================================================================= + +CREATE OR REPLACE FUNCTION public.rotate_company_inbox(p_company_id uuid) +RETURNS public.company_inboxes +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_company_name text; + v_local_part text; + v_slug_seed text; + v_new_row public.company_inboxes; +BEGIN + -- Authorization: caller must be owner/admin of the company. + IF NOT EXISTS ( + SELECT 1 FROM public.company_members + WHERE company_id = p_company_id + AND user_id = auth.uid() + AND role IN ('owner', 'admin') + ) THEN + RAISE EXCEPTION 'Not authorized to rotate inbox for this company' + USING ERRCODE = '42501'; + END IF; + + SELECT name INTO v_company_name + FROM public.companies + WHERE id = p_company_id; + + IF v_company_name IS NULL THEN + RAISE EXCEPTION 'Company not found' USING ERRCODE = 'P0002'; + END IF; + + -- All three steps share one transaction — a failure on any of them + -- rolls the whole thing back, so the company never ends up without + -- an active inbox. + + UPDATE public.company_inboxes + SET status = 'deprecated', + deprecated_at = now() + WHERE company_id = p_company_id + AND status = 'active'; + + v_local_part := public.generate_inbox_local_part(v_company_name); + v_slug_seed := regexp_replace(v_local_part, '-[^-]+$', ''); + + INSERT INTO public.company_inboxes (company_id, local_part, slug_seed, status) + VALUES (p_company_id, v_local_part, v_slug_seed, 'active') + RETURNING * INTO v_new_row; + + RETURN v_new_row; +END; +$$; + +GRANT EXECUTE ON FUNCTION public.rotate_company_inbox(uuid) TO authenticated; + +-- ============================================================================= +-- 3. Prevent two inbox items from claiming the same transaction +-- ============================================================================= + +-- Partial unique index: once a row has matched_transaction_id set for a +-- given company, no other row in that company may claim the same one. +-- Concurrent UPDATEs from smart-match will get a 23505 and the handler +-- gracefully downgrades the loser to pending_transaction. +CREATE UNIQUE INDEX IF NOT EXISTS idx_inbox_items_matched_transaction_unique + ON public.invoice_inbox_items(company_id, matched_transaction_id) + WHERE matched_transaction_id IS NOT NULL; + +NOTIFY pgrst, 'reload schema'; diff --git a/tests/helpers.ts b/tests/helpers.ts index b3e112ce..1dcbd978 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -240,6 +240,8 @@ export function makeJournalEntry(overrides: Partial = {}): Journal correction_of_id: null, attachment_urls: null, notes: null, + commit_method: null, + rubric_version: null, created_at: '2024-06-15T14:30:00Z', updated_at: '2024-06-15T14:30:00Z', ...overrides, @@ -551,6 +553,9 @@ export function makeInvoiceInboxItem( email_from: null, email_subject: null, email_received_at: null, + email_body_text: null, + resend_email_id: null, + resend_attachment_id: null, document_id: null, extracted_data: null, confidence: null, @@ -565,6 +570,8 @@ export function makeInvoiceInboxItem( matched_transaction_id: null, match_confidence: null, match_method: null, + match_reasoning: null, + correlation_id: null, created_at: '2024-06-15T14:30:00Z', updated_at: '2024-06-15T14:30:00Z', ...overrides, diff --git a/types/index.ts b/types/index.ts index 37ebb8d3..29525979 100644 --- a/types/index.ts +++ b/types/index.ts @@ -976,6 +976,8 @@ export interface JournalEntry { correction_of_id: string | null attachment_urls: string[] | null notes: string | null + commit_method: string | null + rubric_version: string | null created_at: string updated_at: string // Relations @@ -1559,6 +1561,19 @@ export type InboxItemSource = 'email' | 'upload' // Document classification type for unified inbox routing export type DocumentClassificationType = 'supplier_invoice' | 'receipt' | 'government_letter' | 'unknown' +export type CompanyInboxStatus = 'active' | 'deprecated' | 'blocked' + +export interface CompanyInbox { + id: string + company_id: string + local_part: string + status: CompanyInboxStatus + slug_seed: string + created_at: string + updated_at: string + deprecated_at: string | null +} + export interface InvoiceInboxItem { id: string user_id: string @@ -1568,6 +1583,9 @@ export interface InvoiceInboxItem { email_from: string | null email_subject: string | null email_received_at: string | null + email_body_text: string | null + resend_email_id: string | null + resend_attachment_id: string | null document_id: string | null extracted_data: Record | null confidence: number | null @@ -1587,7 +1605,11 @@ export interface InvoiceInboxItem { // Transaction matching matched_transaction_id: string | null match_confidence: number | null - match_method: 'payment_reference' | 'amount_date' | 'amount_merchant' | 'receipt_match' | null + match_method: 'payment_reference' | 'amount_date' | 'amount_merchant' | 'receipt_match' | 'llm' | 'pending_transaction' | null + match_reasoning: string | null + + // Audit chain (processing_history correlation) + correlation_id: string | null created_at: string updated_at: string