feat: arcim inbox (Resend Inbound) + smart-match extension + commit metadata (#286)
* feat: multi-series SIE import, reusable FiscalYearSelector, library templates in picker - SIE import preserves each voucher's source series (B/C/I/V/...), essential for Fortnox migrations where series carry semantic meaning (kundfakturor, inbetalningar, etc.). Target numbering still goes through next_voucher_number per series; source (series, number) is stored in the migration mapping for BFNAR 2013:2 audit trail. - Execute route reads company_settings.default_voucher_series as the fallback for vouchers arriving without a series (SIE4I). - Extract shared FiscalYearSelector component; adopt in /reports and /bookkeeping. - Transaction TemplatePicker now surfaces user-created library templates (company + team scope) alongside the static registry, with a helper to convert simple library templates into the BookingTemplate shape. - Exclude 8999 "Årets resultat" from income statement financial section and monthly breakdown so year-end closing entries don't cancel the net result. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test: skip Bokio SIE regression when fixtures are absent /dev_docs is gitignored (contains anonymised customer exports), so the integration test can't find its input files in CI. Gate the suite on fixture presence so it still runs locally. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: address Greptile review feedback - convertLibraryToBookingTemplate: default entity_applicability to 'all' when the source template has no entity_type, so TemplatePicker doesn't silently hide it for companies with a set entity type. - FiscalYearSelector: fire onReady in the no-company early-return branch so consumers (e.g. ReportsPage) don't get stuck in a loading skeleton while the company context is still hydrating. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat: arcim inbox + smart-match extension + commit metadata Three threads, all gated off in extensions.config.json (invoice-inbox and inbox-smart-match are not in the enabled list for this PR). invoice-inbox: Gmail OAuth -> Resend Inbound (v2.0.0) - Remove gmail-scanner / gmail-helpers - Add resend-inbound.ts (webhook verify, attachment fetch) and inbox-provisioning.ts (per-company @arcim.io address with rotation) - Replace /gmail/* routes with /inbox/address and admin-only /inbox/rotate - Workspace UI: card layout + MatchBlock surfacing AI transaction matches - classify-document: tightened discount/total prompt; cap confidence at 50% when line items do not reconcile with amount_incl_vat - Manifest requires RESEND_API_KEY, RESEND_INBOUND_DOMAIN, RESEND_INBOUND_WEBHOOK_SECRET inbox-smart-match (new extension) - Event-driven AI matching of receipts to bank transactions - Listens on inbox_item.classified (match now) and transaction.synced (retro-match receipts waiting for a transaction) - Uses service-role client; processing_history append is scoped by company_id from the event payload commit metadata + audit plumbing - journal_entries gains commit_method and rubric_version columns - commit_journal_entry RPC accepts both (BFNAR 2013:2 behandlingshistorik) - processing-history PII detector strips UUID-shaped substrings before personnummer pattern matching (UUIDs were triggering false positives) - New generic inbox_item.classified event Migrations - arcim_inbox: company_inboxes table, resend_email_id, email_body_text, auto-provision trigger, drops obsolete email_connections - journal_entry_commit_metadata: new columns + updated RPC - inbox_attachment_composite: resend_attachment_id + composite unique index - inbox_smart_match: correlation_id, match_reasoning, expanded match_method CHECK, pending-match and correlation indexes Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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 <Badge variant="success">Hög</Badge>
|
||||
if (confidence >= 0.7) return <Badge variant="warning">Medium</Badge>
|
||||
return <Badge variant="destructive">Låg</Badge>
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="mt-2.5 flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
<span>AI letar efter matchande transaktion…</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<div className="mt-2.5 flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="inline-block h-1.5 w-1.5 rounded-full bg-amber-400 shrink-0" />
|
||||
<span>Inväntar matchande banktransaktion</span>
|
||||
{item.match_reasoning && (
|
||||
<InfoTooltip content={item.match_reasoning} side="right" maxWidth="340px" />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div
|
||||
className={cn(
|
||||
'mt-2.5 rounded-md border px-3 py-2.5',
|
||||
level === 'high' && 'border-emerald-500/25 bg-emerald-500/5',
|
||||
level === 'medium' && 'border-amber-500/25 bg-amber-500/5',
|
||||
level === 'low' && 'border-red-500/25 bg-red-500/5'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||||
<div className="flex items-center gap-2 min-w-0 text-sm">
|
||||
<ArrowRight className={cn(
|
||||
'h-3.5 w-3.5 shrink-0',
|
||||
level === 'high' && 'text-emerald-700 dark:text-emerald-400',
|
||||
level === 'medium' && 'text-amber-700 dark:text-amber-500',
|
||||
level === 'low' && 'text-red-700 dark:text-red-400'
|
||||
)} />
|
||||
<span className="truncate font-medium">{tx.description || 'Matchad transaktion'}</span>
|
||||
<span className="text-muted-foreground">·</span>
|
||||
<span className="tabular-nums shrink-0">{formatCurrency(Math.abs(tx.amount), tx.currency)}</span>
|
||||
<span className="text-muted-foreground">·</span>
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">{tx.date}</span>
|
||||
</div>
|
||||
{confidencePct != null && (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium tabular-nums shrink-0',
|
||||
level === 'high' && 'bg-emerald-500/15 text-emerald-700 dark:text-emerald-300',
|
||||
level === 'medium' && 'bg-amber-500/15 text-amber-700 dark:text-amber-300',
|
||||
level === 'low' && 'bg-red-500/15 text-red-700 dark:text-red-300'
|
||||
)}
|
||||
>
|
||||
<Sparkles className="h-3 w-3" />
|
||||
{confidencePct}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{item.match_reasoning && (
|
||||
<p className="mt-1.5 text-xs text-muted-foreground leading-relaxed">
|
||||
{item.match_reasoning}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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<HTMLInputElement>(null)
|
||||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
|
||||
const [items, setItems] = useState<InboxItem[]>([])
|
||||
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<InboxItem | null>(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 (
|
||||
<div className="space-y-6">
|
||||
{/* Gmail connection banner */}
|
||||
{gmailConnection ? (
|
||||
{/* Arcim inbox address */}
|
||||
{inboxAddress && (
|
||||
<Card>
|
||||
<CardContent className="flex items-center justify-between py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-green-50 dark:bg-green-950">
|
||||
<Check className="h-4 w-4 text-green-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">{gmailConnection.email_address}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{gmailConnection.last_sync_at
|
||||
? `Senast skannad: ${timeAgo(gmailConnection.last_sync_at)}`
|
||||
: 'Inte skannad ännu'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={handleDisconnectGmail}>
|
||||
Koppla från
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="flex items-center justify-between py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-muted">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-muted shrink-0">
|
||||
<Mail className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">Koppla Gmail</p>
|
||||
<p className="text-xs text-muted-foreground">Hämta leverantörsfakturor automatiskt från din e-post</p>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs text-muted-foreground">Din fakturainkorg</p>
|
||||
<p className="text-sm font-medium font-mono truncate">{inboxAddress.address}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={handleConnectGmail} disabled={isConnectingGmail}>
|
||||
{isConnectingGmail ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Mail className="mr-2 h-4 w-4" />}
|
||||
Koppla Gmail
|
||||
</Button>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Button variant="ghost" size="sm" onClick={handleCopyAddress}>
|
||||
<Copy className="mr-2 h-3.5 w-3.5" />
|
||||
Kopiera
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={handleRotateAddress} disabled={isRotating}>
|
||||
{isRotating ? <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" /> : <RotateCcw className="mr-2 h-3.5 w-3.5" />}
|
||||
Rotera
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Summary cards */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<Card>
|
||||
<CardContent className="pt-4 pb-3">
|
||||
<p className="text-xs text-muted-foreground">Att granska</p>
|
||||
<p className="text-2xl font-semibold tabular-nums">{readyCount}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-4 pb-3">
|
||||
<p className="text-xs text-muted-foreground">Konverterade</p>
|
||||
<p className="text-2xl font-semibold tabular-nums">{confirmedCount}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-4 pb-3">
|
||||
<p className="text-xs text-muted-foreground">Fel</p>
|
||||
<p className="text-2xl font-semibold tabular-nums">{errorCount}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Action bar */}
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
@@ -689,17 +749,6 @@ export default function InvoiceInboxWorkspace({ userId }: WorkspaceComponentProp
|
||||
{isUploading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Upload className="mr-2 h-4 w-4" />}
|
||||
Ladda upp
|
||||
</Button>
|
||||
{gmailConnection && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleScanGmail}
|
||||
disabled={isScanning}
|
||||
>
|
||||
{isScanning ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Mail className="mr-2 h-4 w-4" />}
|
||||
Skanna Gmail
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -720,119 +769,129 @@ export default function InvoiceInboxWorkspace({ userId }: WorkspaceComponentProp
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
{/* Items table */}
|
||||
{/* Items list */}
|
||||
{items.length === 0 ? (
|
||||
gmailConnection ? (
|
||||
<EmptyState
|
||||
icon={Inbox}
|
||||
title="Ingen inkorg"
|
||||
description="Skanna Gmail eller ladda upp en faktura."
|
||||
actionLabel="Skanna Gmail"
|
||||
onAction={handleScanGmail}
|
||||
secondaryActionLabel="Ladda upp"
|
||||
>
|
||||
<Button variant="outline" onClick={() => fileInputRef.current?.click()}>
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
Ladda upp
|
||||
</Button>
|
||||
</EmptyState>
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={Inbox}
|
||||
title="Ingen inkorg"
|
||||
description="Koppla Gmail eller ladda upp en faktura för att komma igång."
|
||||
actionLabel="Koppla Gmail"
|
||||
onAction={handleConnectGmail}
|
||||
>
|
||||
<Button variant="outline" onClick={() => fileInputRef.current?.click()}>
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
Ladda upp
|
||||
</Button>
|
||||
</EmptyState>
|
||||
)
|
||||
<EmptyState
|
||||
icon={Inbox}
|
||||
title="Ingen inkorg"
|
||||
description={
|
||||
inboxAddress
|
||||
? `Skicka fakturor till ${inboxAddress.address} eller ladda upp manuellt.`
|
||||
: 'Ladda upp en faktura för att komma igång.'
|
||||
}
|
||||
actionLabel="Ladda upp"
|
||||
onAction={() => fileInputRef.current?.click()}
|
||||
/>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-10"></TableHead>
|
||||
<TableHead>Dokumenttyp</TableHead>
|
||||
<TableHead>Leverantör</TableHead>
|
||||
<TableHead className="text-right">Belopp</TableHead>
|
||||
<TableHead>Konfidensgrad</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Mottagen</TableHead>
|
||||
<TableHead className="text-right">Åtgärder</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((item) => {
|
||||
const supplierName = extractSupplierName(item)
|
||||
const amount = extractAmount(item)
|
||||
const isConvertable = item.status === 'ready' && item.document_type === 'supplier_invoice'
|
||||
const isDismissable = item.status === 'ready'
|
||||
<div className="overflow-hidden rounded-lg border bg-card divide-y divide-border/60">
|
||||
{items.map((item) => {
|
||||
const supplierName = extractSupplierName(item)
|
||||
const amount = extractAmount(item)
|
||||
const currency = extractCurrency(item)
|
||||
const isReceipt = item.document_type === 'receipt'
|
||||
const isConvertable = item.status === 'ready' && item.document_type === 'supplier_invoice'
|
||||
const primaryLabel = supplierName
|
||||
|| item.email_from?.replace(/<.+?>$/, '').replace(/"/g, '').trim()
|
||||
|| '—'
|
||||
|
||||
return (
|
||||
<TableRow key={item.id} className={item.document_type === 'unknown' ? 'opacity-60' : ''}>
|
||||
<TableCell>
|
||||
{item.source === 'email' ? (
|
||||
<Mail className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<Upload className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm">{DOC_TYPE_LABELS[item.document_type] || item.document_type}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm">{supplierName || '—'}</span>
|
||||
{item.email_from && !supplierName && (
|
||||
<span className="block text-xs text-muted-foreground truncate max-w-[200px]">{item.email_from}</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{amount != null ? formatCurrency(amount, extractCurrency(item)) : '—'}
|
||||
</TableCell>
|
||||
<TableCell>{confidenceBadge(item.confidence)}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={STATUS_VARIANTS[item.status] || 'outline'}>
|
||||
{STATUS_LABELS[item.status] || item.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className={cn(
|
||||
'flex gap-4 px-4 py-3.5 transition-colors',
|
||||
item.document_type === 'unknown' && 'opacity-60',
|
||||
item.status === 'rejected' && 'opacity-50'
|
||||
)}
|
||||
>
|
||||
{/* Document icon */}
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-md bg-muted/60 shrink-0 mt-0.5">
|
||||
{isReceipt ? (
|
||||
<ReceiptIcon className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Header row */}
|
||||
<div className="flex items-baseline justify-between gap-3 flex-wrap">
|
||||
<div className="flex items-baseline gap-2 min-w-0">
|
||||
<span className="text-sm font-medium">
|
||||
{DOC_TYPE_LABELS[item.document_type] || item.document_type}
|
||||
</span>
|
||||
<span className="text-muted-foreground">·</span>
|
||||
<span className="text-sm truncate min-w-0">{primaryLabel}</span>
|
||||
{item.source === 'email' && (
|
||||
<Mail className="h-3 w-3 text-muted-foreground shrink-0" aria-label="Från e-post" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-baseline gap-2 shrink-0">
|
||||
{amount != null && (
|
||||
<span className="text-sm font-medium tabular-nums">
|
||||
{formatCurrency(amount, currency)}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground tabular-nums whitespace-nowrap">
|
||||
{timeAgo(item.created_at)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{isConvertable && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => openConvertDialog(item)}
|
||||
>
|
||||
<Eye className="mr-1 h-3.5 w-3.5" />
|
||||
Granska
|
||||
</Button>
|
||||
)}
|
||||
{isDismissable && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleReject(item.id)}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status chip line — only non-default states */}
|
||||
{(item.status === 'confirmed' || item.status === 'rejected' || item.status === 'error') && (
|
||||
<div className="mt-1.5">
|
||||
<Badge variant={STATUS_VARIANTS[item.status] || 'outline'}>
|
||||
{STATUS_LABELS[item.status] || item.status}
|
||||
</Badge>
|
||||
{item.error_message && (
|
||||
<span className="ml-2 text-xs text-muted-foreground">{item.error_message}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Match block (receipts only) */}
|
||||
<MatchBlock item={item} />
|
||||
|
||||
{/* Actions */}
|
||||
{item.status === 'ready' && (
|
||||
<div className="mt-3 flex items-center justify-end gap-1">
|
||||
{isConvertable ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => openConvertDialog(item)}
|
||||
>
|
||||
Granska
|
||||
</Button>
|
||||
) : (
|
||||
item.document_id && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleViewDocument(item.document_id!)}
|
||||
>
|
||||
<Eye className="mr-1.5 h-3.5 w-3.5" />
|
||||
Visa kvitto
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleReject(item.id)}
|
||||
aria-label="Avvisa"
|
||||
title="Avvisa"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Convert dialog */}
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
"ai-chat",
|
||||
"push-notifications",
|
||||
"invoice-inbox",
|
||||
"inbox-smart-match",
|
||||
"calendar",
|
||||
"enable-banking",
|
||||
"email",
|
||||
|
||||
@@ -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>): 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')
|
||||
})
|
||||
})
|
||||
@@ -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<string, unknown>) {
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
})
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -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<CandidateTransaction[]> {
|
||||
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,
|
||||
}))
|
||||
}
|
||||
@@ -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<ReceiptMatchResult> {
|
||||
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<string, unknown>
|
||||
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 }
|
||||
}
|
||||
@@ -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<MatchOutcome> {
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
@@ -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<typeof import('@/extensions/general/invoice-inbox/lib/resend-inbound')>(
|
||||
'@/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<string, unknown>) {
|
||||
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: '<msg-id@supplier.com>',
|
||||
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: '<msg@x>',
|
||||
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: '<msg@x>',
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -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/)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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<boolean> {
|
||||
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 } })
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
@@ -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<string, unknown>): 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<string, unknown>).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<string, unknown>): 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<string, unknown>): 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.`,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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, unknown>): 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<string, unknown> | 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<string | null> {
|
||||
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
|
||||
}
|
||||
@@ -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<string, string> = {
|
||||
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<ScanResult> {
|
||||
const result: ScanResult = { scanned: 0, classified: 0, skipped: 0, errors: 0 }
|
||||
const seenFileHashes = new Set<string>()
|
||||
|
||||
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
|
||||
}
|
||||
@@ -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<CompanyInbox | null> {
|
||||
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<CompanyInbox> {
|
||||
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
|
||||
}
|
||||
@@ -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<ResendReceivedEmail> {
|
||||
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<ResendAttachmentDownload> {
|
||||
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'
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -245,7 +245,9 @@ export async function commitEntry(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
userId: string,
|
||||
entryId: string
|
||||
entryId: string,
|
||||
commitMethod?: string,
|
||||
rubricVersion?: string
|
||||
): Promise<JournalEntry> {
|
||||
|
||||
// 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<JournalEntry> {
|
||||
const draft = await createDraftEntry(supabase, companyId, userId, input)
|
||||
return commitEntry(supabase, companyId, userId, draft.id)
|
||||
return commitEntry(supabase, companyId, userId, draft.id, commitMethod, rubricVersion)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 } }
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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.'
|
||||
)
|
||||
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -240,6 +240,8 @@ export function makeJournalEntry(overrides: Partial<JournalEntry> = {}): 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,
|
||||
|
||||
+23
-1
@@ -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<string, unknown> | 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
|
||||
|
||||
Reference in New Issue
Block a user