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 && (
-
- )}
+
+
+ {/* 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 ? (
+ openConvertDialog(item)}
+ >
+ Granska
+
+ ) : (
+ item.document_id && (
+ handleViewDocument(item.document_id!)}
+ >
+
+ Visa kvitto
+
+ )
+ )}
+ handleReject(item.id)}
+ aria-label="Avvisa"
+ title="Avvisa"
+ >
+
+
+
+ )}
+
+
+ )
+ })}
+
)}
{/* 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