diff --git a/app/(dashboard)/expenses/page.tsx b/app/(dashboard)/expenses/page.tsx index b88b6579..fbeddc65 100644 --- a/app/(dashboard)/expenses/page.tsx +++ b/app/(dashboard)/expenses/page.tsx @@ -12,7 +12,7 @@ import { PageHeader } from '@/components/ui/page-header' import { EmptyState } from '@/components/ui/empty-state' import { useToast } from '@/components/ui/use-toast' import { formatCurrency } from '@/lib/utils' -import { Plus, Search, Wallet, Clock, AlertCircle, Lock } from 'lucide-react' +import { Plus, Search, Wallet, Lock } from 'lucide-react' import { useCompany } from '@/contexts/CompanyContext' import { useCanWrite } from '@/lib/hooks/use-can-write' import type { SupplierInvoice } from '@/types' @@ -105,16 +105,6 @@ export default function ExpensesPage() { return matchesSearch && matchesTab }) - const unpaidInvoices = invoices.filter((i) => UNPAID_STATUSES.includes(i.status)) - const overdueInvoices = invoices.filter((i) => i.status === 'overdue') - - const stats = { - unpaidAmount: unpaidInvoices.reduce((sum, i) => sum + i.remaining_amount, 0), - unpaidCount: unpaidInvoices.length, - overdueAmount: overdueInvoices.reduce((sum, i) => sum + i.remaining_amount, 0), - overdueCount: overdueInvoices.length, - } - return (
- {/* Stats */} -
- - -
-
- -
-
-

Att betala

-

{formatCurrency(stats.unpaidAmount)}

-

{stats.unpaidCount} utgifter

-
-
-
-
- - -
-
- 0 ? 'text-destructive' : 'text-muted-foreground'}`} /> -
-
- {stats.overdueCount > 0 ? ( - <> -

Förfallet

-

{formatCurrency(stats.overdueAmount)}

-

{stats.overdueCount} utgifter

- - ) : ( - <> -

Totalt antal

-

{invoices.length}

- - )} -
-
-
-
-
{/* Search and tabs */}
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 */} + { if (!open) { setConvertItem(null); setConvertForm(null); setDocumentUrl(null) } }}> + + + Konvertera till leverantörsfaktura + + + {convertForm && convertItem && ( +
+ {/* Document preview */} + {documentUrl && ( +
+ {documentMimeType?.startsWith('image/') ? ( + // eslint-disable-next-line @next/next/no-img-element + Dokument + ) : ( + + + Visa originaldokument + + )} +
+ )} + + {/* Extracted supplier context */} + {convertItem.extracted_data?.supplier?.name && ( +
+ AI extraherade: + {convertItem.extracted_data.supplier.name} + {convertItem.extracted_data.supplier.orgNumber && ( + ({convertItem.extracted_data.supplier.orgNumber}) + )} +
+ )} + + {/* Supplier selector */} +
+ + + {formErrors.supplier_id &&

{formErrors.supplier_id}

} + {!convertForm.supplier_id && convertItem.extracted_data?.supplier?.name ? ( + + ) : ( + + Skapa ny leverantör + + )} +
+ + {/* Invoice header fields */} +
+
+ + updateFormField('supplier_invoice_number', e.target.value)} + className={formErrors.supplier_invoice_number ? 'border-destructive' : ''} + /> + {formErrors.supplier_invoice_number &&

{formErrors.supplier_invoice_number}

} +
+
+ + updateFormField('payment_reference', e.target.value)} + placeholder="OCR / referens" + /> +
+
+ + updateFormField('invoice_date', e.target.value)} + className={formErrors.invoice_date ? 'border-destructive' : ''} + /> + {formErrors.invoice_date &&

{formErrors.invoice_date}

} +
+
+ + updateFormField('due_date', e.target.value)} + className={formErrors.due_date ? 'border-destructive' : ''} + /> + {formErrors.due_date &&

{formErrors.due_date}

} +
+
+ + {/* Line items */} +
+
+ + +
+ + {convertForm.items.map((lineItem, index) => ( +
+
+ {index === 0 && } + updateLineItem(index, 'description', e.target.value)} + placeholder="Beskrivning" + className={formErrors[`items.${index}.description`] ? 'border-destructive' : ''} + /> + {formErrors[`items.${index}.description`] &&

{formErrors[`items.${index}.description`]}

} +
+
+ {index === 0 && } + updateLineItem(index, 'amount', e.target.value === '' ? 0 : parseFloat(e.target.value) || 0)} + placeholder="0.00" + className={`tabular-nums ${formErrors[`items.${index}.amount`] ? 'border-destructive' : ''}`} + /> + {formErrors[`items.${index}.amount`] &&

{formErrors[`items.${index}.amount`]}

} +
+
+ {index === 0 && } + updateLineItem(index, 'account_number', e.target.value.replace(/\D/g, '').slice(0, 4))} + placeholder="5410" + maxLength={4} + className={`font-mono tabular-nums ${formErrors[`items.${index}.account_number`] ? 'border-destructive' : ''}`} + /> + {formErrors[`items.${index}.account_number`] &&

{formErrors[`items.${index}.account_number`]}

} +
+
+ {index === 0 && } + +
+
+ {index === 0 && } + {convertForm.items.length > 1 && ( + + )} +
+
+ ))} +
+ + {/* Totals */} +
+
+

Totalt inkl. moms

+

{formatCurrency(formTotal, convertForm.currency)}

+
+
+
+ )} + + + + + +
+
+ + {/* Match confirmation dialog */} + { if (!open) setSuggestedMatch(null) }}> + + + Matchande transaktion hittad + + {suggestedMatch && ( +
+
+
+ {suggestedMatch.transaction.description} + + {formatCurrency(Math.abs(suggestedMatch.transaction.amount), suggestedMatch.transaction.currency)} + +
+

{suggestedMatch.transaction.date}

+
+

+ Vill du matcha denna transaktion med leverantörsfakturan? En betalningsverifikation bokförs automatiskt. +

+
+ )} + + + + +
+
+
+ ) +} 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"],