diff --git a/app/(dashboard)/supplier-invoices/new/page.tsx b/app/(dashboard)/supplier-invoices/new/page.tsx
index f70e7f23..503f164b 100644
--- a/app/(dashboard)/supplier-invoices/new/page.tsx
+++ b/app/(dashboard)/supplier-invoices/new/page.tsx
@@ -128,7 +128,8 @@ export default function NewSupplierInvoicePage() {
}
}
}
- }, [watchedSupplierId, suppliers])
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [watchedSupplierId, suppliers, watch, setValue, fields.length])
async function fetchSuppliers() {
const res = await fetch('/api/suppliers')
@@ -421,11 +422,18 @@ export default function NewSupplierInvoicePage() {
/>
- (
+ field.onChange(e.target.value === '' ? 0 : parseFloat(e.target.value) || 0)}
+ />
+ )}
/>
|
@@ -507,11 +515,18 @@ export default function NewSupplierInvoicePage() {
- (
+ field.onChange(e.target.value === '' ? 0 : parseFloat(e.target.value) || 0)}
+ />
+ )}
/>
diff --git a/app/(dashboard)/supplier-invoices/page.tsx b/app/(dashboard)/supplier-invoices/page.tsx
index 0d4e3123..47332ee8 100644
--- a/app/(dashboard)/supplier-invoices/page.tsx
+++ b/app/(dashboard)/supplier-invoices/page.tsx
@@ -1,7 +1,7 @@
'use client'
import { useState, useEffect } 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 { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
@@ -62,15 +62,6 @@ export default function SupplierInvoicesPage() {
}
})
- // Summary stats
- const totalUnpaid = invoices
- .filter((i) => !['paid', 'credited'].includes(i.status))
- .reduce((sum, i) => sum + i.remaining_amount, 0)
- const overdueAmount = invoices
- .filter((i) => i.status === 'overdue')
- .reduce((sum, i) => sum + i.remaining_amount, 0)
- const overdueCount = invoices.filter((i) => i.status === 'overdue').length
-
return (
@@ -98,62 +89,6 @@ export default function SupplierInvoicesPage() {
)}
- {/* Summary cards */}
-
- {isLoading ? (
- <>
- {[1, 2, 3].map((i) => (
-
-
-
-
-
-
-
-
-
- ))}
- >
- ) : (
- <>
-
-
-
- Totalt obetalt
-
-
-
- {formatAmount(totalUnpaid)} kr
-
- {invoices.filter((i) => !['paid', 'credited'].includes(i.status)).length} fakturor
-
-
-
-
-
-
- Förfallet
-
-
-
- {formatAmount(overdueAmount)} kr
- {overdueCount} fakturor
-
-
-
-
-
- Antal fakturor
-
-
-
- {invoices.length}
-
-
- >
- )}
-
-
{/* Tabs */}
diff --git a/app/api/extensions/ext/[...path]/route.ts b/app/api/extensions/ext/[...path]/route.ts
index 9cbb6c87..7786ebee 100644
--- a/app/api/extensions/ext/[...path]/route.ts
+++ b/app/api/extensions/ext/[...path]/route.ts
@@ -98,10 +98,11 @@ async function handleRequest(
for (const [key, value] of Object.entries(extractedParams)) {
url.searchParams.set(`_${key}`, value)
}
+ const cloned = request.clone()
handlerRequest = new Request(url.toString(), {
- method: request.method,
- headers: request.headers,
- body: request.body,
+ method: cloned.method,
+ headers: cloned.headers,
+ body: cloned.body,
// @ts-expect-error -- duplex needed for streaming body
duplex: 'half',
})
@@ -126,10 +127,12 @@ async function handleRequest(
for (const [key, value] of Object.entries(extractedParams)) {
url.searchParams.set(`_${key}`, value)
}
+ // Clone first to avoid body stream locking issues when transferring to new Request
+ const cloned = request.clone()
handlerRequest = new Request(url.toString(), {
- method: request.method,
- headers: request.headers,
- body: request.body,
+ method: cloned.method,
+ headers: cloned.headers,
+ body: cloned.body,
// @ts-expect-error -- duplex needed for streaming body
duplex: 'half',
})
diff --git a/components/dashboard/DashboardNav.tsx b/components/dashboard/DashboardNav.tsx
index 0f77b514..28403f76 100644
--- a/components/dashboard/DashboardNav.tsx
+++ b/components/dashboard/DashboardNav.tsx
@@ -71,8 +71,6 @@ const navItems: NavItem[] = [
// Temporarily hidden pending module rework (see feedback #49)
{ href: '/suppliers', label: 'Leverantörer', icon: Building2, group: 'inköp', hidden: true },
{ href: '/supplier-invoices', label: 'Leverantörsfakturor', icon: FileInput, group: 'inköp', hidden: true },
- // Personal
- { href: '/salary', label: 'Löner', icon: HandCoins, group: 'redovisning', modes: ['aktiebolag'] },
// General accounting
{ href: '/pending', label: 'Granskning', icon: ClipboardCheck, group: 'redovisning' },
{ href: '/transactions', label: 'Transaktioner', icon: ArrowLeftRight, group: 'redovisning' },
diff --git a/components/extensions/general/InvoiceInboxWorkspace.tsx b/components/extensions/general/InvoiceInboxWorkspace.tsx
new file mode 100644
index 00000000..6ea8a4eb
--- /dev/null
+++ b/components/extensions/general/InvoiceInboxWorkspace.tsx
@@ -0,0 +1,1083 @@
+'use client'
+
+import { useState, useCallback, useEffect, useRef } from 'react'
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import { Badge } from '@/components/ui/badge'
+import { Button } from '@/components/ui/button'
+import { Skeleton } from '@/components/ui/skeleton'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select'
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table'
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogFooter,
+} from '@/components/ui/dialog'
+import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
+import { EmptyState } from '@/components/ui/empty-state'
+import { useToast } from '@/components/ui/use-toast'
+import {
+ Inbox,
+ Upload,
+ Mail,
+ FileText,
+ RefreshCw,
+ Check,
+ X,
+ Eye,
+ Loader2,
+ Plus,
+ Trash2,
+} from 'lucide-react'
+import Link from 'next/link'
+import { useSearchParams, useRouter } from 'next/navigation'
+import { formatCurrency, formatDate } from '@/lib/utils'
+import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry'
+import type { InvoiceExtractionResult } from '@/types'
+
+// ── Types ────────────────────────────────────────────────────
+
+interface InboxItem {
+ id: string
+ status: string
+ document_type: string
+ confidence: number | null
+ source: 'email' | 'upload'
+ created_at: string
+ extracted_data: InvoiceExtractionResult | null
+ matched_supplier_id: string | null
+ document_id: string | null
+ email_from: string | null
+ email_subject: string | null
+ error_message: string | null
+}
+
+interface Supplier {
+ id: string
+ name: string
+ org_number: string | null
+ default_expense_account: string | null
+}
+
+interface ConvertForm {
+ supplier_id: string
+ supplier_invoice_number: string
+ invoice_date: string
+ due_date: string
+ currency: string
+ payment_reference: string
+ notes: string
+ items: ConvertFormItem[]
+}
+
+interface ConvertFormItem {
+ description: string
+ amount: number
+ account_number: string
+ vat_rate: number
+}
+
+// ── Constants ────────────────────────────────────────────────
+
+const DOC_TYPE_LABELS: Record = {
+ supplier_invoice: 'Leverantörsfaktura',
+ receipt: 'Kvitto',
+ government_letter: 'Myndighetsbrev',
+ unknown: 'Okänt',
+}
+
+const STATUS_LABELS: Record = {
+ ready: 'Klar',
+ confirmed: 'Bekräftad',
+ rejected: 'Avvisad',
+ error: 'Fel',
+ processing: 'Bearbetar',
+ pending: 'Väntar',
+}
+
+const STATUS_VARIANTS: Record = {
+ ready: 'secondary',
+ confirmed: 'success',
+ rejected: 'outline',
+ error: 'destructive',
+ processing: 'warning',
+ pending: 'outline',
+}
+
+const VAT_OPTIONS = [
+ { value: '0.25', label: '25%' },
+ { value: '0.12', label: '12%' },
+ { value: '0.06', label: '6%' },
+ { value: '0', label: '0%' },
+]
+
+// ── Helpers ──────────────────────────────────────────────────
+
+function confidenceBadge(confidence: number | null) {
+ if (confidence === null) return null
+ if (confidence >= 0.9) return Hög
+ if (confidence >= 0.7) return Medium
+ return Låg
+}
+
+function extractSupplierName(item: InboxItem): string | null {
+ if (item.document_type !== 'supplier_invoice' || !item.extracted_data) return null
+ return item.extracted_data.supplier?.name || null
+}
+
+function extractAmount(item: InboxItem): number | null {
+ if (!item.extracted_data) return null
+ return item.extracted_data.totals?.total ?? null
+}
+
+function extractCurrency(item: InboxItem): string {
+ const data = item.extracted_data as Record | null
+ if (!data) return 'SEK'
+ // InvoiceExtractionResult uses invoice.currency, ReceiptExtractionResult uses receipt.currency
+ const invoice = data.invoice as Record | undefined
+ const receipt = data.receipt as Record | undefined
+ return (invoice?.currency as string) || (receipt?.currency as string) || 'SEK'
+}
+
+function timeAgo(isoDate: string): string {
+ const diff = Date.now() - new Date(isoDate).getTime()
+ const minutes = Math.floor(diff / 60000)
+ if (minutes < 1) return 'just nu'
+ if (minutes < 60) return `${minutes} min sedan`
+ const hours = Math.floor(minutes / 60)
+ if (hours < 24) return `${hours} tim sedan`
+ const days = Math.floor(hours / 24)
+ return `${days} dag${days > 1 ? 'ar' : ''} sedan`
+}
+
+function buildInitialForm(item: InboxItem, defaultExpenseAccount?: string): ConvertForm {
+ const data = item.extracted_data
+ const fallbackAccount = defaultExpenseAccount || '5410'
+
+ let formItems: ConvertFormItem[]
+ if (data?.lineItems?.length) {
+ formItems = data.lineItems.map((li) => ({
+ description: li.description,
+ amount: li.lineTotal ?? 0,
+ account_number: li.accountSuggestion || fallbackAccount,
+ vat_rate: li.vatRate != null ? li.vatRate / 100 : 0.25,
+ }))
+
+ // If all line item amounts are 0 but we have a total, distribute evenly
+ const allZero = formItems.every((item) => item.amount === 0)
+ const extractedTotal = data.totals?.subtotal ?? data.totals?.total
+ if (allZero && extractedTotal && extractedTotal > 0) {
+ const perItem = Math.round((extractedTotal / formItems.length) * 100) / 100
+ formItems.forEach((item) => { item.amount = perItem })
+ }
+ } else {
+ formItems = [{ description: '', amount: 0, account_number: fallbackAccount, vat_rate: 0.25 }]
+ }
+
+ return {
+ supplier_id: item.matched_supplier_id || '',
+ supplier_invoice_number: data?.invoice?.invoiceNumber || '',
+ invoice_date: data?.invoice?.invoiceDate || '',
+ due_date: data?.invoice?.dueDate || '',
+ currency: data?.invoice?.currency || 'SEK',
+ payment_reference: data?.invoice?.paymentReference || '',
+ notes: '',
+ items: formItems,
+ }
+}
+
+// ── Skeleton ─────────────────────────────────────────────────
+
+function WorkspaceSkeleton() {
+ return (
+
+
+
+
+
+
+
+ {Array.from({ length: 5 }).map((_, i) => (
+
+ ))}
+
+
+ )
+}
+
+// ── Main Component ───────────────────────────────────────────
+
+export default function InvoiceInboxWorkspace({ userId }: WorkspaceComponentProps) {
+ const { toast } = useToast()
+ const fileInputRef = useRef(null)
+ const searchParams = useSearchParams()
+ const router = useRouter()
+
+ const [items, setItems] = useState([])
+ const [isLoading, setIsLoading] = useState(true)
+ const [statusFilter, setStatusFilter] = useState('all')
+ const [isScanning, setIsScanning] = useState(false)
+ const [isUploading, setIsUploading] = useState(false)
+
+ // Gmail connection state
+ const [gmailConnection, setGmailConnection] = useState<{
+ email_address: string; status: string; last_sync_at: string | null
+ } | null>(null)
+ const [isConnectingGmail, setIsConnectingGmail] = useState(false)
+
+ // Convert dialog state
+ const [convertItem, setConvertItem] = useState(null)
+ const [convertForm, setConvertForm] = useState(null)
+ const [suppliers, setSuppliers] = useState([])
+ const [isConverting, setIsConverting] = useState(false)
+ const [isCreatingSupplier, setIsCreatingSupplier] = useState(false)
+ const [formErrors, setFormErrors] = useState>({})
+ const [documentUrl, setDocumentUrl] = useState(null)
+ const [documentMimeType, setDocumentMimeType] = useState(null)
+ const [suggestedMatch, setSuggestedMatch] = useState<{
+ invoiceId: string
+ transaction: { id: string; description: string; amount: number; currency: string; date: string }
+ } | null>(null)
+ const [isConfirmingMatch, setIsConfirmingMatch] = useState(false)
+
+ // ── Data fetching ────────────────────────────────────────
+
+ const fetchItems = useCallback(async () => {
+ setIsLoading(true)
+ try {
+ const params = new URLSearchParams({ limit: '50' })
+ if (statusFilter !== 'all') params.set('status', statusFilter)
+
+ const res = await fetch(`/api/extensions/ext/invoice-inbox/items?${params}`)
+ if (!res.ok) throw new Error('Failed to fetch items')
+ const { data } = await res.json()
+ setItems(data?.items || [])
+ } catch {
+ toast({ title: 'Kunde inte hämta inkorgen', variant: 'destructive' })
+ } finally {
+ setIsLoading(false)
+ }
+ }, [statusFilter, toast])
+
+ const fetchGmailStatus = useCallback(async () => {
+ try {
+ const res = await fetch('/api/extensions/ext/invoice-inbox/gmail/status')
+ if (!res.ok) return
+ const { data } = await res.json()
+ const active = data?.connections?.find((c: { status: string }) => c.status === 'active')
+ setGmailConnection(active || null)
+ } catch { /* silent */ }
+ }, [])
+
+ const handleConnectGmail = useCallback(async () => {
+ setIsConnectingGmail(true)
+ 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
+ } catch {
+ toast({ title: 'Kunde inte starta Gmail-koppling', variant: 'destructive' })
+ setIsConnectingGmail(false)
+ }
+ }, [toast])
+
+ const handleDisconnectGmail = useCallback(async () => {
+ 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' })
+ }
+ }, [toast])
+
+ useEffect(() => {
+ fetchItems()
+ fetchGmailStatus()
+
+ // 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])
+
+ const fetchSuppliers = useCallback(async () => {
+ try {
+ const res = await fetch('/api/suppliers')
+ if (!res.ok) return
+ const { data } = await res.json()
+ setSuppliers(data || [])
+ } catch { /* silent */ }
+ }, [])
+
+ // ── Actions ──────────────────────────────────────────────
+
+ const handleUpload = useCallback(async (file: File) => {
+ setIsUploading(true)
+ try {
+ const form = new FormData()
+ form.append('file', file)
+ const res = await fetch('/api/extensions/ext/invoice-inbox/upload', {
+ method: 'POST',
+ body: form,
+ })
+ if (!res.ok) {
+ const { error } = await res.json()
+ throw new Error(error || 'Upload failed')
+ }
+ toast({ title: 'Dokument uppladdat och klassificerat' })
+ await fetchItems()
+ } catch (err) {
+ toast({ title: err instanceof Error ? err.message : 'Uppladdning misslyckades', variant: 'destructive' })
+ } finally {
+ setIsUploading(false)
+ }
+ }, [fetchItems, toast])
+
+ const handleScanGmail = useCallback(async () => {
+ setIsScanning(true)
+ try {
+ const res = await fetch('/api/extensions/ext/invoice-inbox/gmail/scan', { method: 'POST' })
+ if (!res.ok) throw new Error('Scan failed')
+ 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)
+ }
+ }, [fetchItems, fetchGmailStatus, toast])
+
+ const handleReject = useCallback(async (itemId: string) => {
+ try {
+ const res = await fetch(`/api/extensions/ext/invoice-inbox/items/${itemId}/reject`, { method: 'PATCH' })
+ if (!res.ok) throw new Error('Reject failed')
+ setItems((prev) => prev.map((i) => i.id === itemId ? { ...i, status: 'rejected' } : i))
+ toast({ title: 'Dokument avvisat' })
+ } catch {
+ toast({ title: 'Kunde inte avvisa dokumentet', variant: 'destructive' })
+ }
+ }, [toast])
+
+ const openConvertDialog = useCallback(async (item: InboxItem) => {
+ setConvertItem(item)
+ // Find matched supplier's default expense account
+ const matchedSupplier = item.matched_supplier_id
+ ? suppliers.find((s) => s.id === item.matched_supplier_id)
+ : null
+ setConvertForm(buildInitialForm(item, matchedSupplier?.default_expense_account || undefined))
+ setFormErrors({})
+ setDocumentUrl(null)
+ setDocumentMimeType(null)
+ fetchSuppliers()
+
+ // Fetch document preview URL
+ if (item.document_id) {
+ try {
+ const res = await fetch(`/api/documents/${item.document_id}`)
+ if (res.ok) {
+ const { data } = await res.json()
+ setDocumentUrl(data.download_url)
+ setDocumentMimeType(data.mime_type)
+ }
+ } catch { /* silent */ }
+ }
+ }, [fetchSuppliers, suppliers])
+
+ // ── Convert form handlers ────────────────────────────────
+
+ const updateFormField = useCallback((field: keyof ConvertForm, value: string) => {
+ setConvertForm((prev) => prev ? { ...prev, [field]: value } : prev)
+ setFormErrors((prev) => {
+ const next = { ...prev }
+ delete next[field]
+ return next
+ })
+ }, [])
+
+ const handleCreateSupplierFromExtraction = useCallback(async () => {
+ if (!convertItem?.extracted_data?.supplier?.name) return
+
+ setIsCreatingSupplier(true)
+ try {
+ const extracted = convertItem.extracted_data.supplier
+ const res = await fetch('/api/suppliers', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ name: extracted.name,
+ supplier_type: 'swedish_business',
+ org_number: extracted.orgNumber || undefined,
+ vat_number: extracted.vatNumber || undefined,
+ address_line1: extracted.address || undefined,
+ bankgiro: extracted.bankgiro || undefined,
+ plusgiro: extracted.plusgiro || undefined,
+ }),
+ })
+
+ if (!res.ok) {
+ const { error } = await res.json()
+ throw new Error(error || 'Failed to create supplier')
+ }
+
+ const { data: newSupplier } = await res.json()
+ setSuppliers((prev) => [...prev, newSupplier])
+ updateFormField('supplier_id', newSupplier.id)
+ toast({ title: `Leverantör "${extracted.name}" skapad` })
+ } catch (err) {
+ toast({ title: err instanceof Error ? err.message : 'Kunde inte skapa leverantör', variant: 'destructive' })
+ } finally {
+ setIsCreatingSupplier(false)
+ }
+ }, [convertItem, updateFormField, toast])
+
+ const updateLineItem = useCallback((index: number, field: keyof ConvertFormItem, value: string | number) => {
+ setConvertForm((prev) => {
+ if (!prev) return prev
+ const items = [...prev.items]
+ items[index] = { ...items[index], [field]: value }
+ return { ...prev, items }
+ })
+ setFormErrors((prev) => {
+ const next = { ...prev }
+ delete next[`items.${index}.${field}`]
+ return next
+ })
+ }, [])
+
+ const addLineItem = useCallback(() => {
+ setConvertForm((prev) => {
+ if (!prev) return prev
+ return { ...prev, items: [...prev.items, { description: '', amount: 0, account_number: '', vat_rate: 0.25 }] }
+ })
+ }, [])
+
+ const removeLineItem = useCallback((index: number) => {
+ setConvertForm((prev) => {
+ if (!prev || prev.items.length <= 1) return prev
+ return { ...prev, items: prev.items.filter((_, i) => i !== index) }
+ })
+ }, [])
+
+ const validateForm = useCallback((): boolean => {
+ if (!convertForm) return false
+ const errors: Record = {}
+
+ if (!convertForm.supplier_id) errors.supplier_id = 'Välj leverantör'
+ if (!convertForm.supplier_invoice_number.trim()) errors.supplier_invoice_number = 'Fakturanummer krävs'
+ if (!convertForm.invoice_date) errors.invoice_date = 'Fakturadatum krävs'
+ if (!convertForm.due_date) errors.due_date = 'Förfallodatum krävs'
+
+ convertForm.items.forEach((item, i) => {
+ if (!item.description.trim()) errors[`items.${i}.description`] = 'Beskrivning krävs'
+ if (!item.account_number || !/^\d{4}$/.test(item.account_number)) errors[`items.${i}.account_number`] = '4-siffrigt kontonummer'
+ if (item.amount < 0) errors[`items.${i}.amount`] = 'Belopp kan inte vara negativt'
+ })
+
+ setFormErrors(errors)
+ return Object.keys(errors).length === 0
+ }, [convertForm])
+
+ const handleConvert = useCallback(async () => {
+ if (!convertItem || !convertForm) return
+ if (!validateForm()) return
+
+ setIsConverting(true)
+ try {
+ const payload = {
+ supplier_id: convertForm.supplier_id,
+ supplier_invoice_number: convertForm.supplier_invoice_number,
+ invoice_date: convertForm.invoice_date,
+ due_date: convertForm.due_date,
+ currency: convertForm.currency || 'SEK',
+ payment_reference: convertForm.payment_reference || undefined,
+ notes: convertForm.notes || undefined,
+ items: convertForm.items.map((item) => ({
+ description: item.description,
+ amount: item.amount,
+ account_number: item.account_number,
+ vat_rate: item.vat_rate,
+ })),
+ }
+
+ const res = await fetch(`/api/extensions/ext/invoice-inbox/items/${convertItem.id}/convert`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload),
+ })
+
+ if (!res.ok) {
+ const text = await res.text()
+ console.error('[convert] Server error:', res.status, text)
+ let msg = 'Konvertering misslyckades'
+ try { msg = JSON.parse(text).error || msg } catch { /* use default */ }
+ throw new Error(msg)
+ }
+
+ const { data: result } = await res.json()
+ setItems((prev) => prev.map((i) => i.id === convertItem.id ? { ...i, status: 'confirmed' } : i))
+ setConvertItem(null)
+ setConvertForm(null)
+
+ // If a matching transaction was found, show confirmation prompt
+ if (result.suggested_transaction) {
+ const tx = result.suggested_transaction
+ const txAmount = formatCurrency(Math.abs(tx.amount), tx.currency)
+ setSuggestedMatch({ invoiceId: result.id, transaction: tx })
+ toast({ title: `Leverantörsfaktura skapad — matchande transaktion hittad (${tx.description}, ${txAmount})` })
+ } else {
+ toast({ title: 'Leverantörsfaktura skapad' })
+ }
+ } catch (err) {
+ toast({ title: err instanceof Error ? err.message : 'Konvertering misslyckades', variant: 'destructive' })
+ } finally {
+ setIsConverting(false)
+ }
+ }, [convertItem, convertForm, validateForm, toast])
+
+ const handleConfirmMatch = useCallback(async () => {
+ if (!suggestedMatch) return
+ setIsConfirmingMatch(true)
+ try {
+ const res = await fetch(`/api/transactions/${suggestedMatch.transaction.id}/match-supplier-invoice`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ supplier_invoice_id: suggestedMatch.invoiceId }),
+ })
+ if (!res.ok) {
+ const { error } = await res.json().catch(() => ({ error: 'Matchning misslyckades' }))
+ throw new Error(error)
+ }
+ toast({ title: 'Transaktion matchad och bokförd' })
+ setSuggestedMatch(null)
+ } catch (err) {
+ toast({ title: err instanceof Error ? err.message : 'Matchning misslyckades', variant: 'destructive' })
+ } finally {
+ setIsConfirmingMatch(false)
+ }
+ }, [suggestedMatch, toast])
+
+ // ── Computed ─────────────────────────────────────────────
+
+ 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
+ return sum + item.amount + vatAmount
+ }, 0)
+ : 0
+
+ // ── Render ───────────────────────────────────────────────
+
+ if (isLoading && items.length === 0) {
+ return
+ }
+
+ return (
+
+ {/* Gmail connection banner */}
+ {gmailConnection ? (
+
+
+
+
+
+
+
+ {gmailConnection.email_address}
+
+ {gmailConnection.last_sync_at
+ ? `Senast skannad: ${timeAgo(gmailConnection.last_sync_at)}`
+ : 'Inte skannad ännu'}
+
+
+
+
+
+
+ ) : (
+
+
+
+
+
+
+
+ Koppla Gmail
+ Hämta leverantörsfakturor automatiskt från din e-post
+
+
+
+
+
+ )}
+
+ {/* Summary cards */}
+
+
+
+ Att granska
+ {readyCount}
+
+
+
+
+ Konverterade
+ {confirmedCount}
+
+
+
+
+ Fel
+ {errorCount}
+
+
+
+
+ {/* Action bar */}
+
+ {
+ const file = e.target.files?.[0]
+ if (file) handleUpload(file)
+ e.target.value = ''
+ }}
+ />
+
+ {gmailConnection && (
+
+ )}
+
+
+
+ {/* Filter tabs */}
+
+
+ Alla ({items.length})
+ Redo ({readyCount})
+ Bekräftade ({confirmedCount})
+ Avvisade
+
+
+
+ {/* Items table */}
+ {items.length === 0 ? (
+ gmailConnection ? (
+
+
+
+ ) : (
+
+
+
+ )
+ ) : (
+
+
+
+
+
+
+ Dokumenttyp
+ Leverantör
+ Belopp
+ Konfidensgrad
+ Status
+ Mottagen
+ Åtgärder
+
+
+
+ {items.map((item) => {
+ const supplierName = extractSupplierName(item)
+ const amount = extractAmount(item)
+ const isConvertable = item.status === 'ready' && item.document_type === 'supplier_invoice'
+ const isDismissable = item.status === 'ready'
+
+ return (
+
+
+ {item.source === 'email' ? (
+
+ ) : (
+
+ )}
+
+
+ {DOC_TYPE_LABELS[item.document_type] || item.document_type}
+
+
+ {supplierName || '—'}
+ {item.email_from && !supplierName && (
+ {item.email_from}
+ )}
+
+
+ {amount != null ? formatCurrency(amount, extractCurrency(item)) : '—'}
+
+ {confidenceBadge(item.confidence)}
+
+
+ {STATUS_LABELS[item.status] || item.status}
+
+
+
+ {timeAgo(item.created_at)}
+
+
+
+ {isConvertable && (
+
+ )}
+ {isDismissable && (
+
+ )}
+
+
+
+ )
+ })}
+
+
+
+
+ )}
+
+ {/* Convert dialog */}
+
+
+ {/* Match confirmation dialog */}
+
+
+ )
+}
diff --git a/extensions/general/invoice-inbox/__tests__/convert-route.test.ts b/extensions/general/invoice-inbox/__tests__/convert-route.test.ts
new file mode 100644
index 00000000..36056e61
--- /dev/null
+++ b/extensions/general/invoice-inbox/__tests__/convert-route.test.ts
@@ -0,0 +1,278 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { invoiceInboxExtension } from '@/extensions/general/invoice-inbox'
+import {
+ createQueuedMockSupabase,
+ createMockRequest,
+ parseJsonResponse,
+ makeInvoiceInboxItem,
+ makeSupplier,
+ makeCompanySettings,
+} from '@/tests/helpers'
+import type { ExtensionContext } from '@/lib/extensions/types'
+
+vi.mock('@/lib/bookkeeping/supplier-invoice-entries', () => ({
+ createSupplierInvoiceRegistrationEntry: vi.fn().mockResolvedValue({ id: 'je-1' }),
+}))
+
+// ── Helpers ──────────────────────────────────────────────────
+
+function findRoute(method: string, path: string) {
+ return invoiceInboxExtension.apiRoutes!.find(
+ (r) => r.method === method && r.path === path
+ )!
+}
+
+function buildCtx(supabase: unknown, overrides: Partial = {}): ExtensionContext {
+ return {
+ userId: 'user-1',
+ companyId: 'company-1',
+ extensionId: 'invoice-inbox',
+ supabase: supabase as ExtensionContext['supabase'],
+ emit: vi.fn(),
+ settings: { get: vi.fn(), set: vi.fn() },
+ storage: { from: vi.fn() } as unknown as ExtensionContext['storage'],
+ log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() } as unknown as ExtensionContext['log'],
+ services: {},
+ ...overrides,
+ } as ExtensionContext
+}
+
+const SUPPLIER_UUID = '00000000-0000-4000-8000-000000000001'
+const ITEM_UUID = '00000000-0000-4000-8000-000000000002'
+
+const VALID_CONVERT_BODY = {
+ supplier_id: SUPPLIER_UUID,
+ supplier_invoice_number: 'F-2024-001',
+ invoice_date: '2024-06-15',
+ due_date: '2024-07-15',
+ items: [
+ { description: 'Konsulttjänster', amount: 10000, account_number: '6200', vat_rate: 0.25 },
+ ],
+}
+
+// ── POST /items/:id/convert ──────────────────────────────────
+
+describe('POST /items/:id/convert', () => {
+ const route = findRoute('POST', '/items/:id/convert')
+
+ it('returns 401 when no context', async () => {
+ const request = createMockRequest('/items/item-1/convert', {
+ method: 'POST',
+ body: VALID_CONVERT_BODY,
+ searchParams: { _id: 'item-1' },
+ })
+ const res = await route.handler(request, undefined)
+ const { status } = await parseJsonResponse(res)
+ expect(status).toBe(401)
+ })
+
+ it('returns 404 when item not found', async () => {
+ const { supabase, enqueue } = createQueuedMockSupabase()
+ enqueue({ data: null, error: { message: 'Not found' } }) // fetch inbox item
+
+ const ctx = buildCtx(supabase)
+ const request = createMockRequest('/items/item-1/convert', {
+ method: 'POST',
+ body: VALID_CONVERT_BODY,
+ searchParams: { _id: 'item-1' },
+ })
+ const res = await route.handler(request, ctx)
+ const { status } = await parseJsonResponse(res)
+ expect(status).toBe(404)
+ })
+
+ it('returns 409 when item status is not ready', async () => {
+ const { supabase, enqueue } = createQueuedMockSupabase()
+ enqueue({ data: makeInvoiceInboxItem({ status: 'confirmed' }) }) // fetch inbox item
+
+ const ctx = buildCtx(supabase)
+ const request = createMockRequest('/items/item-1/convert', {
+ method: 'POST',
+ body: VALID_CONVERT_BODY,
+ searchParams: { _id: 'item-1' },
+ })
+ const res = await route.handler(request, ctx)
+ const { status } = await parseJsonResponse(res)
+ expect(status).toBe(409)
+ })
+
+ it('returns 400 when required fields missing', async () => {
+ const { supabase, enqueue } = createQueuedMockSupabase()
+ enqueue({ data: makeInvoiceInboxItem({ status: 'ready' }) }) // fetch inbox item
+
+ const ctx = buildCtx(supabase)
+ const request = createMockRequest('/items/item-1/convert', {
+ method: 'POST',
+ body: { items: [] }, // missing required fields
+ searchParams: { _id: 'item-1' },
+ })
+ const res = await route.handler(request, ctx)
+ const { status } = await parseJsonResponse(res)
+ expect(status).toBe(400)
+ })
+
+ it('returns 404 when supplier not found in company', async () => {
+ const { supabase, enqueue } = createQueuedMockSupabase()
+ enqueue({ data: makeInvoiceInboxItem({ status: 'ready' }) }) // fetch inbox item
+ enqueue({ data: null, error: { message: 'Not found' } }) // fetch supplier
+
+ const ctx = buildCtx(supabase)
+ const request = createMockRequest('/items/item-1/convert', {
+ method: 'POST',
+ body: VALID_CONVERT_BODY,
+ searchParams: { _id: 'item-1' },
+ })
+ const res = await route.handler(request, ctx)
+ const { status } = await parseJsonResponse(res)
+ expect(status).toBe(404)
+ })
+
+ it('successfully converts inbox item to supplier invoice', async () => {
+ const { supabase, enqueue } = createQueuedMockSupabase()
+ const inboxItem = makeInvoiceInboxItem({ status: 'ready', document_id: 'doc-1' })
+ const supplier = makeSupplier({ id: 'supplier-1' })
+ const createdInvoice = {
+ id: 'invoice-1',
+ user_id: 'user-1',
+ company_id: 'company-1',
+ supplier_id: SUPPLIER_UUID,
+ arrival_number: 42,
+ supplier_invoice_number: 'F-2024-001',
+ total: 12500,
+ status: 'registered',
+ }
+
+ enqueue({ data: inboxItem }) // fetch inbox item
+ enqueue({ data: supplier }) // fetch supplier
+ enqueue({ data: 42 }) // get_next_arrival_number RPC
+ enqueue({ data: createdInvoice }) // insert supplier_invoices
+ enqueue({ data: null, error: null }) // insert supplier_invoice_items
+ enqueue({ data: makeCompanySettings({ accounting_method: 'cash' }) }) // company_settings
+ enqueue({ data: null, error: null }) // update inbox item
+
+ const ctx = buildCtx(supabase)
+ const request = createMockRequest('/items/item-1/convert', {
+ method: 'POST',
+ body: VALID_CONVERT_BODY,
+ searchParams: { _id: 'item-1' },
+ })
+ const res = await route.handler(request, ctx)
+ const { status, body } = await parseJsonResponse<{ data: { id: string; inbox_item_id: string } }>(res)
+
+ expect(status).toBe(200)
+ expect(body.data.id).toBe('invoice-1')
+ expect(body.data.inbox_item_id).toBe('item-1')
+ })
+
+ it('emits supplier_invoice.registered and supplier_invoice.confirmed events', async () => {
+ const { supabase, enqueue } = createQueuedMockSupabase()
+ enqueue({ data: makeInvoiceInboxItem({ status: 'ready' }) })
+ enqueue({ data: makeSupplier({ id: SUPPLIER_UUID }) })
+ enqueue({ data: 42 }) // arrival number
+ enqueue({ data: { id: 'invoice-1', status: 'registered' } }) // insert
+ enqueue({ data: null, error: null }) // insert items
+ enqueue({ data: makeCompanySettings({ accounting_method: 'cash' }) })
+ enqueue({ data: null, error: null }) // update inbox item
+
+ const ctx = buildCtx(supabase)
+ const request = createMockRequest('/items/item-1/convert', {
+ method: 'POST',
+ body: VALID_CONVERT_BODY,
+ searchParams: { _id: 'item-1' },
+ })
+ await route.handler(request, ctx)
+
+ const emitCalls = (ctx.emit as ReturnType).mock.calls
+ expect(emitCalls.length).toBe(2)
+ expect(emitCalls[0][0].type).toBe('supplier_invoice.registered')
+ expect(emitCalls[1][0].type).toBe('supplier_invoice.confirmed')
+ })
+
+ it('creates registration journal entry when accounting method is accrual', async () => {
+ const { createSupplierInvoiceRegistrationEntry } = await import('@/lib/bookkeeping/supplier-invoice-entries')
+
+ const { supabase, enqueue } = createQueuedMockSupabase()
+ enqueue({ data: makeInvoiceInboxItem({ status: 'ready' }) })
+ enqueue({ data: makeSupplier({ id: SUPPLIER_UUID }) })
+ enqueue({ data: 42 }) // arrival number
+ enqueue({ data: { id: 'invoice-1', status: 'registered' } }) // insert invoice
+ enqueue({ data: null, error: null }) // insert items
+ enqueue({ data: makeCompanySettings({ accounting_method: 'accrual' }) })
+ enqueue({ data: null, error: null }) // update registration_journal_entry_id
+ enqueue({ data: null, error: null }) // update inbox item
+
+ const ctx = buildCtx(supabase)
+ const request = createMockRequest('/items/item-1/convert', {
+ method: 'POST',
+ body: VALID_CONVERT_BODY,
+ searchParams: { _id: 'item-1' },
+ })
+ const res = await route.handler(request, ctx)
+ const { status, body } = await parseJsonResponse<{ data: { registration_journal_entry_id: string } }>(res)
+
+ expect(status).toBe(200)
+ expect(body.data.registration_journal_entry_id).toBe('je-1')
+ expect(createSupplierInvoiceRegistrationEntry).toHaveBeenCalled()
+ })
+})
+
+// ── PATCH /items/:id/reject ──────────────────────────────────
+
+describe('PATCH /items/:id/reject', () => {
+ const route = findRoute('PATCH', '/items/:id/reject')
+
+ it('returns 401 when no context', async () => {
+ const request = createMockRequest('/items/item-1/reject', {
+ method: 'PATCH',
+ searchParams: { _id: 'item-1' },
+ })
+ const res = await route.handler(request, undefined)
+ const { status } = await parseJsonResponse(res)
+ expect(status).toBe(401)
+ })
+
+ it('returns 404 when item not found', async () => {
+ const { supabase, enqueue } = createQueuedMockSupabase()
+ enqueue({ data: null, error: { message: 'Not found' } })
+
+ const ctx = buildCtx(supabase)
+ const request = createMockRequest('/items/item-1/reject', {
+ method: 'PATCH',
+ searchParams: { _id: 'item-1' },
+ })
+ const res = await route.handler(request, ctx)
+ const { status } = await parseJsonResponse(res)
+ expect(status).toBe(404)
+ })
+
+ it('returns 409 when item already confirmed', async () => {
+ const { supabase, enqueue } = createQueuedMockSupabase()
+ enqueue({ data: { id: 'item-1', status: 'confirmed' } })
+
+ const ctx = buildCtx(supabase)
+ const request = createMockRequest('/items/item-1/reject', {
+ method: 'PATCH',
+ searchParams: { _id: 'item-1' },
+ })
+ const res = await route.handler(request, ctx)
+ const { status } = await parseJsonResponse(res)
+ expect(status).toBe(409)
+ })
+
+ it('updates item status to rejected', async () => {
+ const { supabase, enqueue } = createQueuedMockSupabase()
+ enqueue({ data: { id: 'item-1', status: 'ready' } }) // fetch
+ enqueue({ data: null, error: null }) // update
+
+ const ctx = buildCtx(supabase)
+ const request = createMockRequest('/items/item-1/reject', {
+ method: 'PATCH',
+ searchParams: { _id: 'item-1' },
+ })
+ const res = await route.handler(request, ctx)
+ const { status, body } = await parseJsonResponse<{ data: { id: string; status: string } }>(res)
+
+ expect(status).toBe(200)
+ expect(body.data.status).toBe('rejected')
+ })
+})
diff --git a/extensions/general/invoice-inbox/index.ts b/extensions/general/invoice-inbox/index.ts
index d501cd1b..29c0afe9 100644
--- a/extensions/general/invoice-inbox/index.ts
+++ b/extensions/general/invoice-inbox/index.ts
@@ -5,7 +5,9 @@ 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 type { InvoiceExtractionResult } from '@/types'
+import { createSupplierInvoiceRegistrationEntry } from '@/lib/bookkeeping/supplier-invoice-entries'
+import { CreateSupplierInvoiceSchema } from '@/lib/api/schemas'
+import type { InvoiceExtractionResult, InvoiceInboxItem, SupplierInvoice, SupplierInvoiceItem } from '@/types'
const MAX_FILE_SIZE = 10 * 1024 * 1024 // Match MAX_DOCUMENT_SIZE from document-service
@@ -28,7 +30,8 @@ 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?: { from?: string | null; subject?: string | null; receivedAt?: string | null; messageId?: string },
+ ctx?: ExtensionContext
) {
// Store in WORM archive
const doc = await uploadDocument(supabase, userId, companyId, {
@@ -111,6 +114,25 @@ async function uploadAndClassify(
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') {
+ try {
+ await ctx.emit({
+ type: 'supplier_invoice.received',
+ payload: { inboxItem: inbox as unknown as InvoiceInboxItem, userId, companyId },
+ })
+ } catch { /* non-blocking */ }
+
+ if (!classificationError && classificationResult?.confidence) {
+ try {
+ await ctx.emit({
+ type: 'supplier_invoice.extracted',
+ payload: { inboxItem: inbox as unknown as InvoiceInboxItem, confidence: classificationResult.confidence / 100, userId, companyId },
+ })
+ } catch { /* non-blocking */ }
+ }
+ }
+
return {
document_id: doc.id,
inbox_item_id: inbox.id,
@@ -161,7 +183,9 @@ export const invoiceInboxExtension: Extension = {
ctx.userId,
ctx.companyId,
{ name: file.name, buffer, type: file.type },
- 'upload'
+ 'upload',
+ undefined,
+ ctx
)
return NextResponse.json({ data: result })
} catch (error) {
@@ -188,7 +212,7 @@ 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, 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')
.eq('company_id', ctx.companyId)
.order('created_at', { ascending: false })
.limit(limit)
@@ -281,18 +305,18 @@ export const invoiceInboxExtension: Extension = {
if (error) {
console.error('[gmail/callback] OAuth error:', error)
- return NextResponse.redirect(`${appUrl}/settings/banking?error=gmail_auth_denied`)
+ return NextResponse.redirect(`${appUrl}/e/general/invoice-inbox?error=gmail_auth_denied`)
}
if (!code || !stateParam) {
- return NextResponse.redirect(`${appUrl}/settings/banking?error=gmail_missing_params`)
+ return NextResponse.redirect(`${appUrl}/e/general/invoice-inbox?error=gmail_missing_params`)
}
if (!process.env.GMAIL_TOKEN_ENCRYPTION_KEY) {
- return NextResponse.redirect(`${appUrl}/settings/banking?error=gmail_config_error`)
+ 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}/settings/banking?error=gmail_invalid_state`)
+ return NextResponse.redirect(`${appUrl}/e/general/invoice-inbox?error=gmail_invalid_state`)
}
const { companyId, userId } = state
@@ -314,14 +338,14 @@ export const invoiceInboxExtension: Extension = {
if (!tokenResponse.ok) {
console.error('[gmail/callback] Token exchange failed:', await tokenResponse.text())
- return NextResponse.redirect(`${appUrl}/settings/banking?error=gmail_token_exchange`)
+ 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}/settings/banking?error=gmail_no_refresh_token`)
+ return NextResponse.redirect(`${appUrl}/e/general/invoice-inbox?error=gmail_no_refresh_token`)
}
// Get user email
@@ -329,7 +353,7 @@ export const invoiceInboxExtension: Extension = {
headers: { Authorization: `Bearer ${tokens.access_token}` },
})
if (!profileResponse.ok) {
- return NextResponse.redirect(`${appUrl}/settings/banking?error=gmail_profile_error`)
+ return NextResponse.redirect(`${appUrl}/e/general/invoice-inbox?error=gmail_profile_error`)
}
const profile = await profileResponse.json() as { emailAddress: string }
@@ -390,14 +414,14 @@ export const invoiceInboxExtension: Extension = {
if (dbError) {
console.error('[gmail/callback] DB insert failed:', dbError)
- return NextResponse.redirect(`${appUrl}/settings/banking?error=gmail_db_error`)
+ 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}/settings/banking?gmail=connected`)
+ return NextResponse.redirect(`${appUrl}/e/general/invoice-inbox?gmail=connected`)
} catch (err) {
console.error('[gmail/callback] Unexpected error:', err)
- return NextResponse.redirect(`${appUrl}/settings/banking?error=gmail_unexpected`)
+ return NextResponse.redirect(`${appUrl}/e/general/invoice-inbox?error=gmail_unexpected`)
}
},
},
@@ -475,5 +499,304 @@ export const invoiceInboxExtension: Extension = {
})
},
},
+
+ // ── Reject inbox item ──────────────────────────────────
+ {
+ method: 'PATCH',
+ path: '/items/:id/reject',
+ handler: async (_request: Request, ctx?: ExtensionContext) => {
+ if (!ctx) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+
+ const url = new URL(_request.url)
+ const id = url.searchParams.get('_id')
+ if (!id) return NextResponse.json({ error: 'Missing id' }, { status: 400 })
+
+ const { data: item, error: fetchError } = await ctx.supabase
+ .from('invoice_inbox_items')
+ .select('id, status')
+ .eq('id', id)
+ .eq('company_id', ctx.companyId)
+ .single()
+
+ if (fetchError || !item) return NextResponse.json({ error: 'Not found' }, { status: 404 })
+ if (item.status === 'confirmed') return NextResponse.json({ error: 'Cannot reject a confirmed item' }, { status: 409 })
+
+ const { error: updateError } = await ctx.supabase
+ .from('invoice_inbox_items')
+ .update({ status: 'rejected' })
+ .eq('id', id)
+ .eq('company_id', ctx.companyId)
+
+ if (updateError) return NextResponse.json({ error: updateError.message }, { status: 500 })
+ return NextResponse.json({ data: { id, status: 'rejected' } })
+ },
+ },
+
+ // ── Convert inbox item to supplier invoice ─────────────
+ {
+ method: 'POST',
+ path: '/items/:id/convert',
+ handler: async (request: Request, ctx?: ExtensionContext) => {
+ if (!ctx) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+
+ const url = new URL(request.url)
+ const id = url.searchParams.get('_id')
+ if (!id) return NextResponse.json({ error: 'Missing id' }, { status: 400 })
+
+ // Fetch inbox item
+ const { data: item, error: fetchError } = await ctx.supabase
+ .from('invoice_inbox_items')
+ .select('*')
+ .eq('id', id)
+ .eq('company_id', ctx.companyId)
+ .single()
+
+ if (fetchError || !item) return NextResponse.json({ error: 'Inbox item not found' }, { status: 404 })
+ if (item.status !== 'ready') return NextResponse.json({ error: 'Item is not in ready status' }, { status: 409 })
+
+ // Validate request body
+ let body: ReturnType
+ try {
+ const json = await request.json()
+ body = CreateSupplierInvoiceSchema.parse(json)
+ } catch (err) {
+ const message = err instanceof Error ? err.message : 'Invalid request body'
+ return NextResponse.json({ error: message }, { status: 400 })
+ }
+
+ // Verify supplier exists
+ const { data: supplier, error: supplierError } = await ctx.supabase
+ .from('suppliers')
+ .select('*')
+ .eq('id', body.supplier_id)
+ .eq('company_id', ctx.companyId)
+ .single()
+
+ if (supplierError || !supplier) {
+ return NextResponse.json({ error: 'Supplier not found' }, { status: 404 })
+ }
+
+ // Get next arrival number
+ const { data: arrivalNum, error: arrivalError } = await ctx.supabase
+ .rpc('get_next_arrival_number', { p_company_id: ctx.companyId })
+
+ if (arrivalError) {
+ return NextResponse.json({ error: 'Failed to get arrival number' }, { status: 500 })
+ }
+
+ // Calculate totals (same logic as app/api/supplier-invoices/route.ts)
+ const items = body.items.map((bodyItem, index) => {
+ const vatRate = bodyItem.vat_rate ?? 0.25
+ const lineTotal = bodyItem.amount != null
+ ? Math.round(bodyItem.amount * 100) / 100
+ : Math.round((bodyItem.quantity ?? 1) * (bodyItem.unit_price ?? 0) * 100) / 100
+ const vatAmount = Math.round(lineTotal * vatRate * 100) / 100
+ return {
+ sort_order: index,
+ description: bodyItem.description,
+ quantity: bodyItem.amount != null ? 1 : (bodyItem.quantity ?? 1),
+ unit: bodyItem.amount != null ? 'st' : (bodyItem.unit || 'st'),
+ unit_price: bodyItem.amount != null ? lineTotal : (bodyItem.unit_price ?? 0),
+ line_total: lineTotal,
+ account_number: bodyItem.account_number,
+ vat_code: bodyItem.vat_code || null,
+ vat_rate: vatRate,
+ vat_amount: vatAmount,
+ }
+ })
+
+ const subtotal = items.reduce((sum, i) => sum + i.line_total, 0)
+ const totalVat = items.reduce((sum, i) => sum + i.vat_amount, 0)
+ const total = Math.round((subtotal + totalVat) * 100) / 100
+
+ const exchangeRate = body.exchange_rate || null
+ const subtotalSek = exchangeRate ? Math.round(subtotal * exchangeRate * 100) / 100 : null
+ const vatAmountSek = exchangeRate ? Math.round(totalVat * exchangeRate * 100) / 100 : null
+ const totalSek = exchangeRate ? Math.round(total * exchangeRate * 100) / 100 : null
+
+ // Insert supplier invoice
+ const { data: invoice, error: invoiceError } = await ctx.supabase
+ .from('supplier_invoices')
+ .insert({
+ user_id: ctx.userId,
+ company_id: ctx.companyId,
+ supplier_id: body.supplier_id,
+ arrival_number: arrivalNum,
+ supplier_invoice_number: body.supplier_invoice_number,
+ invoice_date: body.invoice_date,
+ due_date: body.due_date,
+ delivery_date: body.delivery_date || null,
+ status: 'registered',
+ currency: body.currency || 'SEK',
+ exchange_rate: exchangeRate,
+ vat_treatment: body.vat_treatment || 'standard_25',
+ reverse_charge: body.reverse_charge || false,
+ payment_reference: body.payment_reference || null,
+ subtotal: Math.round(subtotal * 100) / 100,
+ subtotal_sek: subtotalSek,
+ vat_amount: Math.round(totalVat * 100) / 100,
+ vat_amount_sek: vatAmountSek,
+ total: Math.round(total * 100) / 100,
+ total_sek: totalSek,
+ remaining_amount: Math.round(total * 100) / 100,
+ document_id: item.document_id || null,
+ notes: body.notes || null,
+ })
+ .select()
+ .single()
+
+ if (invoiceError || !invoice) {
+ return NextResponse.json({ error: invoiceError?.message || 'Failed to create invoice' }, { status: 500 })
+ }
+
+ // Insert line items
+ const itemInserts = items.map((lineItem) => ({
+ supplier_invoice_id: invoice.id,
+ ...lineItem,
+ }))
+
+ const { error: itemsError } = await ctx.supabase
+ .from('supplier_invoice_items')
+ .insert(itemInserts)
+
+ if (itemsError) {
+ await ctx.supabase.from('supplier_invoices').delete().eq('id', invoice.id)
+ return NextResponse.json({ error: itemsError.message }, { status: 500 })
+ }
+
+ // Accrual method: create registration journal entry
+ const { data: settings } = await ctx.supabase
+ .from('company_settings')
+ .select('accounting_method')
+ .eq('company_id', ctx.companyId)
+ .single()
+
+ const accountingMethod = settings?.accounting_method || 'accrual'
+ let registrationJournalEntryId: string | null = null
+
+ if (accountingMethod === 'accrual') {
+ try {
+ const journalEntry = await createSupplierInvoiceRegistrationEntry(
+ ctx.supabase,
+ ctx.companyId,
+ ctx.userId,
+ invoice as SupplierInvoice,
+ items as SupplierInvoiceItem[],
+ supplier.supplier_type,
+ supplier.name
+ )
+ if (journalEntry) {
+ registrationJournalEntryId = journalEntry.id
+ await ctx.supabase
+ .from('supplier_invoices')
+ .update({ registration_journal_entry_id: journalEntry.id })
+ .eq('id', invoice.id)
+
+ // Link the document to the journal entry
+ if (item.document_id) {
+ await ctx.supabase
+ .from('document_attachments')
+ .update({ journal_entry_id: journalEntry.id })
+ .eq('id', item.document_id)
+ .eq('company_id', ctx.companyId)
+ }
+ }
+ } catch (err) {
+ console.error('[invoice-inbox/convert] Failed to create registration journal entry:', err)
+ }
+ }
+
+ // Emit supplier_invoice.registered
+ try {
+ await ctx.emit({
+ type: 'supplier_invoice.registered',
+ payload: { supplierInvoice: invoice as SupplierInvoice, companyId: ctx.companyId, userId: ctx.userId },
+ })
+ } catch { /* non-blocking */ }
+
+ // Update inbox item to confirmed
+ await ctx.supabase
+ .from('invoice_inbox_items')
+ .update({ status: 'confirmed', created_supplier_invoice_id: invoice.id })
+ .eq('id', id)
+
+ // Emit supplier_invoice.confirmed
+ try {
+ await ctx.emit({
+ type: 'supplier_invoice.confirmed',
+ payload: {
+ inboxItem: { ...item, status: 'confirmed', created_supplier_invoice_id: invoice.id } as InvoiceInboxItem,
+ supplierInvoice: invoice as SupplierInvoice,
+ userId: ctx.userId,
+ companyId: ctx.companyId,
+ },
+ })
+ } catch { /* non-blocking */ }
+
+ // Suggest matching transaction (don't book — user confirms in UI)
+ let suggestedTransaction: { id: string; description: string; amount: number; currency: string; date: string } | null = null
+ try {
+ const invoiceTotal = Math.round(total * 100) / 100
+ const invoiceTotalSek = totalSek ? Math.round(totalSek * 100) / 100 : null
+
+ const { data: candidates } = await ctx.supabase
+ .from('transactions')
+ .select('id, description, amount, currency, date')
+ .eq('company_id', ctx.companyId)
+ .is('supplier_invoice_id', null)
+ .lt('amount', 0)
+ .order('date', { ascending: false })
+ .limit(100)
+
+ if (candidates?.length) {
+ const supplierWords = supplier.name.toLowerCase().replace(/[,.\-]/g, ' ').split(/\s+/).filter((w: string) => w.length >= 3)
+
+ const match = candidates.find((tx) => {
+ const txAmount = Math.round(Math.abs(tx.amount) * 100) / 100
+ const txDesc = tx.description?.toLowerCase() || ''
+
+ const exactMatch = txAmount === invoiceTotal
+ const sekMatch = invoiceTotalSek != null && tx.currency === 'SEK' && Math.abs(txAmount - invoiceTotalSek) / invoiceTotalSek < 0.05
+
+ const nameMatch = supplierWords.some((word: string) => {
+ if (txDesc.includes(word)) return true
+ const txWords = txDesc.split(/\s+/)
+ return txWords.some((tw: string) => {
+ if (tw.length < 3 || word.length < 3) return false
+ if (Math.abs(tw.length - word.length) > 1) return false
+ let diffs = 0
+ const longer = tw.length >= word.length ? tw : word
+ const shorter = tw.length >= word.length ? word : tw
+ let j = 0
+ for (let i = 0; i < longer.length && diffs <= 1; i++) {
+ if (longer[i] !== shorter[j]) { diffs++; if (longer.length === shorter.length) j++ }
+ else { j++ }
+ }
+ return diffs <= 1
+ })
+ })
+
+ return (exactMatch || sekMatch) && nameMatch
+ })
+
+ if (match) {
+ suggestedTransaction = match as unknown as typeof suggestedTransaction
+ }
+ }
+ } catch (err) {
+ console.error('[invoice-inbox/convert] Transaction suggestion failed (non-blocking):', err)
+ }
+
+ return NextResponse.json({
+ data: {
+ ...invoice,
+ items: itemInserts,
+ registration_journal_entry_id: registrationJournalEntryId,
+ inbox_item_id: id,
+ suggested_transaction: suggestedTransaction,
+ },
+ })
+ },
+ },
],
}
diff --git a/extensions/general/invoice-inbox/manifest.json b/extensions/general/invoice-inbox/manifest.json
index ba3f094e..ea1baf45 100644
--- a/extensions/general/invoice-inbox/manifest.json
+++ b/extensions/general/invoice-inbox/manifest.json
@@ -3,7 +3,7 @@
"sector": "general",
"exportName": "invoiceInboxExtension",
"entryPoint": "@/extensions/general/invoice-inbox",
- "workspace": null,
+ "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"],
"npmDependencies": ["@aws-sdk/client-bedrock-runtime"],
|