From 59d935f2cc870ecca3854b6cb8dec263ae317715 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Sat, 21 Feb 2026 17:41:14 +0100 Subject: [PATCH 1/2] Invoice fix and new inbound invoice extension etc --- app/(dashboard)/transactions/page.tsx | 973 ++++++------------ .../[id]/confirm/__tests__/route.test.ts | 244 +++++ .../invoice-inbox/inbox/[id]/confirm/route.ts | 260 +++++ .../invoice-inbox/inbox/[id]/process/route.ts | 128 +++ .../invoice-inbox/inbox/[id]/route.ts | 111 ++ .../inbox/__tests__/route.test.ts | 124 +++ .../extensions/invoice-inbox/inbox/route.ts | 193 ++++ .../invoice-inbox/settings/route.ts | 28 + .../webhook/__tests__/route.test.ts | 153 +++ .../extensions/invoice-inbox/webhook/route.ts | 182 ++++ app/api/invoices/preview-pdf/route.ts | 4 +- app/api/transactions/[id]/categorize/route.ts | 31 + .../transactions/BatchCategorySelector.tsx | 57 +- .../transactions/CategoryExpandedDialog.tsx | 105 ++ components/transactions/InboxZeroState.tsx | 69 ++ .../transactions/InvoiceMatchDialog.tsx | 99 ++ .../transactions/SwipeCategorizationView.tsx | 239 ++++- .../transactions/TransactionHistoryList.tsx | 181 ++++ .../transactions/TransactionInboxCard.tsx | 197 ++++ .../transactions/TransactionStatusBar.tsx | 118 +++ components/transactions/transaction-types.ts | 61 ++ .../invoice-inbox/__tests__/index.test.ts | 98 ++ extensions/general/invoice-inbox/index.ts | 213 ++++ .../lib/__tests__/email-handler.test.ts | 182 ++++ .../lib/__tests__/invoice-analyzer.test.ts | 187 ++++ .../lib/__tests__/supplier-matcher.test.ts | 240 +++++ .../invoice-inbox/lib/email-handler.ts | 83 ++ .../invoice-inbox/lib/invoice-analyzer.ts | 303 ++++++ .../invoice-inbox/lib/supplier-matcher.ts | 197 ++++ extensions/general/invoice-inbox/types.ts | 74 ++ .../__tests__/category-mapping.test.ts | 66 +- lib/bookkeeping/category-mapping.ts | 66 ++ lib/events/types.ts | 6 + lib/extensions/__tests__/sectors.test.ts | 4 +- lib/extensions/loader.ts | 2 + lib/extensions/sectors.ts | 12 + package-lock.json | 17 +- package.json | 1 + .../20240101000033_invoice_inbox.sql | 56 + tests/helpers.ts | 24 + types/index.ts | 30 + 41 files changed, 4698 insertions(+), 720 deletions(-) create mode 100644 app/api/extensions/invoice-inbox/inbox/[id]/confirm/__tests__/route.test.ts create mode 100644 app/api/extensions/invoice-inbox/inbox/[id]/confirm/route.ts create mode 100644 app/api/extensions/invoice-inbox/inbox/[id]/process/route.ts create mode 100644 app/api/extensions/invoice-inbox/inbox/[id]/route.ts create mode 100644 app/api/extensions/invoice-inbox/inbox/__tests__/route.test.ts create mode 100644 app/api/extensions/invoice-inbox/inbox/route.ts create mode 100644 app/api/extensions/invoice-inbox/settings/route.ts create mode 100644 app/api/extensions/invoice-inbox/webhook/__tests__/route.test.ts create mode 100644 app/api/extensions/invoice-inbox/webhook/route.ts create mode 100644 components/transactions/CategoryExpandedDialog.tsx create mode 100644 components/transactions/InboxZeroState.tsx create mode 100644 components/transactions/InvoiceMatchDialog.tsx create mode 100644 components/transactions/TransactionHistoryList.tsx create mode 100644 components/transactions/TransactionInboxCard.tsx create mode 100644 components/transactions/TransactionStatusBar.tsx create mode 100644 components/transactions/transaction-types.ts create mode 100644 extensions/general/invoice-inbox/__tests__/index.test.ts create mode 100644 extensions/general/invoice-inbox/index.ts create mode 100644 extensions/general/invoice-inbox/lib/__tests__/email-handler.test.ts create mode 100644 extensions/general/invoice-inbox/lib/__tests__/invoice-analyzer.test.ts create mode 100644 extensions/general/invoice-inbox/lib/__tests__/supplier-matcher.test.ts create mode 100644 extensions/general/invoice-inbox/lib/email-handler.ts create mode 100644 extensions/general/invoice-inbox/lib/invoice-analyzer.ts create mode 100644 extensions/general/invoice-inbox/lib/supplier-matcher.ts create mode 100644 extensions/general/invoice-inbox/types.ts create mode 100644 supabase/migrations/20240101000033_invoice_inbox.sql diff --git a/app/(dashboard)/transactions/page.tsx b/app/(dashboard)/transactions/page.tsx index 5618b7a2..231a3302 100644 --- a/app/(dashboard)/transactions/page.tsx +++ b/app/(dashboard)/transactions/page.tsx @@ -1,90 +1,94 @@ 'use client' import { useState, useEffect } from 'react' +import { AnimatePresence } from 'framer-motion' import { createClient } from '@/lib/supabase/client' import { Button } from '@/components/ui/button' -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Badge } from '@/components/ui/badge' -import { Input } from '@/components/ui/input' -import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs' -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogDescription, DialogFooter } from '@/components/ui/dialog' +import { Card, CardContent } from '@/components/ui/card' +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { useToast } from '@/components/ui/use-toast' -import { formatCurrency, formatDate } from '@/lib/utils' -import { getCategoryDisplayName } from '@/lib/tax/expense-warnings' -import Link from 'next/link' -import { Plus, Search, ArrowLeftRight, ArrowUpRight, ArrowDownRight, Sparkles, Check, FileText, Link2, Upload, CheckSquare, X } from 'lucide-react' -import { Checkbox } from '@/components/ui/checkbox' +import { X } from 'lucide-react' import TransactionForm from '@/components/transactions/TransactionForm' import SwipeCategorizationView from '@/components/transactions/SwipeCategorizationView' import BatchCategorySelector from '@/components/transactions/BatchCategorySelector' -import type { Transaction, TransactionCategory, CreateTransactionInput, Invoice, Customer } from '@/types' +import TransactionStatusBar from '@/components/transactions/TransactionStatusBar' +import TransactionInboxCard from '@/components/transactions/TransactionInboxCard' +import TransactionHistoryList from '@/components/transactions/TransactionHistoryList' +import InboxZeroState from '@/components/transactions/InboxZeroState' +import InvoiceMatchDialog from '@/components/transactions/InvoiceMatchDialog' +import CategoryExpandedDialog from '@/components/transactions/CategoryExpandedDialog' +import type { TransactionWithInvoice, ViewMode, CategorizeHandler } from '@/components/transactions/transaction-types' +import type { TransactionCategory, CreateTransactionInput, Invoice, Customer, VatTreatment } from '@/types' import type { SuggestedCategory } from '@/lib/transactions/category-suggestions' -interface TransactionWithInvoice extends Transaction { - potential_invoice?: Invoice & { customer?: Customer } -} - export default function TransactionsPage() { const [transactions, setTransactions] = useState([]) const [isLoading, setIsLoading] = useState(true) - const [searchTerm, setSearchTerm] = useState('') - const [activeTab, setActiveTab] = useState('all') + const [mode, setMode] = useState('inbox') const [isDialogOpen, setIsDialogOpen] = useState(false) const [isCreating, setIsCreating] = useState(false) const [showSwipeView, setShowSwipeView] = useState(false) - const [matchDialogOpen, setMatchDialogOpen] = useState(false) - const [selectedTransaction, setSelectedTransaction] = useState(null) - const [isConfirmingMatch, setIsConfirmingMatch] = useState(false) const [categorySuggestions, setCategorySuggestions] = useState>({}) const [isLoadingSuggestions, setIsLoadingSuggestions] = useState(false) + const [processingId, setProcessingId] = useState(null) + + // Batch mode const [isBatchMode, setIsBatchMode] = useState(false) const [selectedIds, setSelectedIds] = useState>(new Set()) const [showBatchSelector, setShowBatchSelector] = useState(false) const [batchProgress, setBatchProgress] = useState<{ done: number; total: number } | null>(null) - const [hideCategorizationHint, setHideCategorizationHint] = useState(true) - useEffect(() => { - setHideCategorizationHint(localStorage.getItem('hideCategorizationHint') === 'true') - }, []) + // Invoice match dialog + const [matchDialogOpen, setMatchDialogOpen] = useState(false) + const [selectedTransaction, setSelectedTransaction] = useState(null) + const [isConfirmingMatch, setIsConfirmingMatch] = useState(false) + + // Category expanded dialog + const [categoryDialogOpen, setCategoryDialogOpen] = useState(false) + const [categoryDialogTransaction, setCategoryDialogTransaction] = useState(null) + const [categoryDialogProcessing, setCategoryDialogProcessing] = useState(false) + + // Set of transaction IDs that are animating out (just categorized) + const [exitingIds, setExitingIds] = useState>(new Set()) + const { toast } = useToast() const supabase = createClient() - useEffect(() => { - fetchTransactions() - }, []) + // Computed lists + const uncategorizedTransactions = transactions + .filter((t) => t.is_business === null && !exitingIds.has(t.id)) + .sort((a, b) => { + const aHasMatch = a.potential_invoice ? 1 : 0 + const bHasMatch = b.potential_invoice ? 1 : 0 + if (aHasMatch !== bHasMatch) return bHasMatch - aHasMatch + return b.date.localeCompare(a.date) + }) + const transactionsWithMatches = transactions.filter((t) => t.potential_invoice && !t.invoice_id) async function fetchTransactions() { setIsLoading(true) - - // Fetch transactions const { data: txData, error: txError } = await supabase .from('transactions') .select('*') .order('date', { ascending: false }) if (txError) { - toast({ - title: 'Fel', - description: 'Kunde inte hämta transaktioner', - variant: 'destructive', - }) + toast({ title: 'Fel', description: 'Kunde inte hämta transaktioner', variant: 'destructive' }) setIsLoading(false) return } - // Get potential invoice IDs const potentialInvoiceIds = (txData || []) .filter((t) => t.potential_invoice_id) .map((t) => t.potential_invoice_id) let invoiceMap: Record = {} - if (potentialInvoiceIds.length > 0) { const { data: invoices } = await supabase .from('invoices') .select('*, customer:customers(*)') .in('id', potentialInvoiceIds) - if (invoices) { invoiceMap = invoices.reduce((acc, inv) => { acc[inv.id] = inv @@ -93,7 +97,6 @@ export default function TransactionsPage() { } } - // Merge potential invoices into transactions const transactionsWithInvoices: TransactionWithInvoice[] = (txData || []).map((t) => ({ ...t, potential_invoice: t.potential_invoice_id ? invoiceMap[t.potential_invoice_id] : undefined, @@ -103,17 +106,200 @@ export default function TransactionsPage() { setIsLoading(false) } + async function fetchCategorySuggestions(txIds: string[]) { + if (txIds.length === 0) return + setIsLoadingSuggestions(true) + try { + const response = await fetch('/api/transactions/suggest-categories', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ transaction_ids: txIds }), + }) + const data = await response.json() + if (data.suggestions) { + setCategorySuggestions(data.suggestions) + } + } catch { + // Non-critical + } + setIsLoadingSuggestions(false) + } + + // Fetch transactions on mount + // eslint-disable-next-line react-hooks/exhaustive-deps + useEffect(() => { fetchTransactions() }, []) + + // Auto-fetch suggestions when transactions load + useEffect(() => { + const uncatIds = transactions + .filter((t) => t.is_business === null) + .map((t) => t.id) + .slice(0, 50) + if (uncatIds.length > 0) { + fetchCategorySuggestions(uncatIds) + } + }, [transactions.length]) + + const handleCategorize: CategorizeHandler = async (id, isBusiness, category, vatTreatment, accountOverride) => { + try { + setProcessingId(id) + const response = await fetch(`/api/transactions/${id}/categorize`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + is_business: isBusiness, + category, + vat_treatment: vatTreatment, + account_override: accountOverride, + }), + }) + + const result = await response.json() + if (!response.ok) { + toast({ title: 'Fel', description: result.error || 'Kunde inte uppdatera transaktion', variant: 'destructive' }) + setProcessingId(null) + return false + } + + // Mark as exiting for animation, then update state + setExitingIds((prev) => new Set(prev).add(id)) + + // Update transaction in state after a brief delay for animation + setTimeout(() => { + setTransactions((prev) => + prev.map((t) => + t.id === id + ? { ...t, is_business: isBusiness, category: result.category, journal_entry_id: result.journal_entry_id } + : t + ) + ) + setExitingIds((prev) => { + const next = new Set(prev) + next.delete(id) + return next + }) + }, 350) + + if (result.journal_entry_created) { + toast({ title: 'Bokförd', description: 'Transaktion bokförd och verifikation skapad' }) + } else if (result.journal_entry_error) { + toast({ title: 'Delvis bokförd', description: `Verifikation kunde inte skapas: ${result.journal_entry_error}`, variant: 'destructive' }) + } else { + toast({ title: 'Delvis bokförd', description: 'Transaktion uppdaterad men verifikation kunde inte skapas' }) + } + + setProcessingId(null) + return true + } catch { + toast({ title: 'Fel', description: 'Något gick fel vid bokföring', variant: 'destructive' }) + setProcessingId(null) + return false + } + } + + async function handleMarkPrivate(id: string) { + await handleCategorize(id, false, 'private') + } + + async function handleConfirmInvoiceMatch() { + if (!selectedTransaction?.potential_invoice) return + setIsConfirmingMatch(true) + + try { + const response = await fetch(`/api/transactions/${selectedTransaction.id}/match-invoice`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ invoice_id: selectedTransaction.potential_invoice.id }), + }) + const result = await response.json() + if (!response.ok) { + toast({ title: 'Fel', description: result.error || 'Kunde inte matcha faktura', variant: 'destructive' }) + setIsConfirmingMatch(false) + return + } + + // Mark as exiting for animation + setExitingIds((prev) => new Set(prev).add(selectedTransaction.id)) + setTimeout(() => { + setTransactions((prev) => + prev.map((t) => + t.id === selectedTransaction.id + ? { + ...t, + invoice_id: selectedTransaction.potential_invoice?.id || null, + potential_invoice_id: null, + potential_invoice: undefined, + is_business: true, + category: 'income_services' as TransactionCategory, + journal_entry_id: result.journal_entry_id, + } + : t + ) + ) + setExitingIds((prev) => { + const next = new Set(prev) + next.delete(selectedTransaction.id) + return next + }) + }, 350) + + toast({ + title: 'Faktura matchad', + description: `Faktura ${selectedTransaction.potential_invoice.invoice_number} markerad som betald`, + }) + setMatchDialogOpen(false) + setSelectedTransaction(null) + } catch { + toast({ title: 'Fel', description: 'Något gick fel vid matchning', variant: 'destructive' }) + } + setIsConfirmingMatch(false) + } + + async function handleMatchInvoice(transactionId: string, invoiceId: string): Promise { + try { + const response = await fetch(`/api/transactions/${transactionId}/match-invoice`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ invoice_id: invoiceId }), + }) + const result = await response.json() + if (!response.ok) { + toast({ title: 'Fel', description: result.error || 'Kunde inte matcha faktura', variant: 'destructive' }) + return false + } + + const transaction = transactions.find((t) => t.id === transactionId) + const invoiceNumber = transaction?.potential_invoice?.invoice_number || '' + + setTransactions((prev) => + prev.map((t) => + t.id === transactionId + ? { + ...t, + invoice_id: invoiceId, + potential_invoice_id: null, + potential_invoice: undefined, + is_business: true, + category: 'income_services' as TransactionCategory, + journal_entry_id: result.journal_entry_id, + } + : t + ) + ) + + toast({ title: 'Faktura matchad', description: `Faktura ${invoiceNumber} markerad som betald` }) + return true + } catch { + toast({ title: 'Fel', description: 'Något gick fel vid matchning', variant: 'destructive' }) + return false + } + } + async function handleCreateTransaction(data: CreateTransactionInput) { setIsCreating(true) - const { data: { user } } = await supabase.auth.getUser() - if (!user) { - toast({ - title: 'Fel', - description: 'Du måste vara inloggad', - variant: 'destructive', - }) + toast({ title: 'Fel', description: 'Du måste vara inloggad', variant: 'destructive' }) setIsCreating(false) return } @@ -134,248 +320,32 @@ export default function TransactionsPage() { .single() if (error) { - toast({ - title: 'Fel', - description: error.message, - variant: 'destructive', - }) + toast({ title: 'Fel', description: error.message, variant: 'destructive' }) } else { - toast({ - title: 'Transaktion tillagd', - description: `${data.description} har lagts till`, - }) + toast({ title: 'Transaktion tillagd', description: `${data.description} har lagts till` }) setTransactions([transaction, ...transactions]) setIsDialogOpen(false) } - setIsCreating(false) } - async function handleCategorize(id: string, isBusiness: boolean, category?: TransactionCategory) { - try { - const response = await fetch(`/api/transactions/${id}/categorize`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ is_business: isBusiness, category }), - }) - - const result = await response.json() - - if (!response.ok) { - toast({ - title: 'Fel', - description: result.error || 'Kunde inte uppdatera transaktion', - variant: 'destructive', - }) - return false - } - - // Update local state - setTransactions( - transactions.map((t) => - t.id === id - ? { - ...t, - is_business: isBusiness, - category: result.category, - journal_entry_id: result.journal_entry_id, - } - : t - ) - ) - - // Show appropriate toast - if (result.journal_entry_created) { - toast({ - title: 'Bokförd', - description: 'Transaktion bokförd och verifikation skapad', - }) - } else if (result.journal_entry_error) { - toast({ - title: 'Delvis bokförd', - description: `Verifikation kunde inte skapas: ${result.journal_entry_error}`, - variant: 'destructive', - }) - } else { - toast({ - title: 'Delvis bokförd', - description: 'Transaktion uppdaterad men verifikation kunde inte skapas', - }) - } - - return true - } catch (err) { - toast({ - title: 'Fel', - description: 'Något gick fel vid bokföring', - variant: 'destructive', - }) - return false - } - } - - async function handleConfirmInvoiceMatch() { - if (!selectedTransaction || !selectedTransaction.potential_invoice) return - - setIsConfirmingMatch(true) - - try { - const response = await fetch(`/api/transactions/${selectedTransaction.id}/match-invoice`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ invoice_id: selectedTransaction.potential_invoice.id }), - }) - - const result = await response.json() - - if (!response.ok) { - toast({ - title: 'Fel', - description: result.error || 'Kunde inte matcha faktura', - variant: 'destructive', - }) - setIsConfirmingMatch(false) - return - } - - // Update local state - setTransactions( - transactions.map((t) => - t.id === selectedTransaction.id - ? { - ...t, - invoice_id: selectedTransaction.potential_invoice?.id || null, - potential_invoice_id: null, - potential_invoice: undefined, - is_business: true, - category: 'income_services' as TransactionCategory, - journal_entry_id: result.journal_entry_id, - } - : t - ) - ) - - toast({ - title: 'Faktura matchad', - description: `Faktura ${selectedTransaction.potential_invoice.invoice_number} markerad som betald`, - }) - - setMatchDialogOpen(false) - setSelectedTransaction(null) - } catch (err) { - toast({ - title: 'Fel', - description: 'Något gick fel vid matchning', - variant: 'destructive', - }) - } - - setIsConfirmingMatch(false) - } - - function openMatchDialog(transaction: TransactionWithInvoice) { - setSelectedTransaction(transaction) - setMatchDialogOpen(true) - } - - async function handleMatchInvoice(transactionId: string, invoiceId: string): Promise { - try { - const response = await fetch(`/api/transactions/${transactionId}/match-invoice`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ invoice_id: invoiceId }), - }) - - const result = await response.json() - - if (!response.ok) { - toast({ - title: 'Fel', - description: result.error || 'Kunde inte matcha faktura', - variant: 'destructive', - }) - return false - } - - // Find the invoice number for the toast - const transaction = transactions.find(t => t.id === transactionId) - const invoiceNumber = transaction?.potential_invoice?.invoice_number || '' - - // Update local state - setTransactions( - transactions.map((t) => - t.id === transactionId - ? { - ...t, - invoice_id: invoiceId, - potential_invoice_id: null, - potential_invoice: undefined, - is_business: true, - category: 'income_services' as TransactionCategory, - journal_entry_id: result.journal_entry_id, - } - : t - ) - ) - - toast({ - title: 'Faktura matchad', - description: `Faktura ${invoiceNumber} markerad som betald`, - }) - - return true - } catch (err) { - toast({ - title: 'Fel', - description: 'Något gick fel vid matchning', - variant: 'destructive', - }) - return false - } - } - - async function fetchCategorySuggestions(txIds: string[]) { - if (txIds.length === 0) return - setIsLoadingSuggestions(true) - try { - const response = await fetch('/api/transactions/suggest-categories', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ transaction_ids: txIds }), - }) - const data = await response.json() - if (data.suggestions) { - setCategorySuggestions(data.suggestions) - } - } catch { - // Non-critical, swipe still works without suggestions - } - setIsLoadingSuggestions(false) - } - - async function runBatchInvoiceMatching() { - try { - const response = await fetch('/api/transactions/batch-match-invoices', { - method: 'POST', - }) - const data = await response.json() - if (data.matched > 0) { - // Refresh transactions to get updated potential_invoice_ids - await fetchTransactions() - } - } catch { - // Non-critical + async function handleCategoryDialogSelect(category: TransactionCategory) { + if (!categoryDialogTransaction) return + setCategoryDialogProcessing(true) + const success = await handleCategorize(categoryDialogTransaction.id, true, category) + setCategoryDialogProcessing(false) + if (success) { + setCategoryDialogOpen(false) + setCategoryDialogTransaction(null) } } + // Batch mode handlers function toggleBatchSelect(id: string) { setSelectedIds((prev) => { const next = new Set(prev) - if (next.has(id)) { - next.delete(id) - } else { - next.add(id) - } + if (next.has(id)) next.delete(id) + else next.add(id) return next }) } @@ -388,68 +358,54 @@ export default function TransactionsPage() { async function handleBatchMarkPrivate() { const ids = Array.from(selectedIds) setBatchProgress({ done: 0, total: ids.length }) - for (let i = 0; i < ids.length; i++) { await handleCategorize(ids[i], false, 'private') setBatchProgress({ done: i + 1, total: ids.length }) } - setBatchProgress(null) - toast({ - title: 'Klart', - description: `${ids.length} transaktioner markerade som privat`, - }) + toast({ title: 'Klart', description: `${ids.length} transaktioner markerade som privat` }) exitBatchMode() } - async function handleBatchCategorize(category: TransactionCategory) { + async function handleBatchCategorize(category: TransactionCategory, vatTreatment?: VatTreatment) { const ids = Array.from(selectedIds) setBatchProgress({ done: 0, total: ids.length }) - for (let i = 0; i < ids.length; i++) { - await handleCategorize(ids[i], true, category) + await handleCategorize(ids[i], true, category, vatTreatment) setBatchProgress({ done: i + 1, total: ids.length }) } - setBatchProgress(null) setShowBatchSelector(false) - toast({ - title: 'Klart', - description: `${ids.length} transaktioner bokförda`, - }) + toast({ title: 'Klart', description: `${ids.length} transaktioner bokförda` }) exitBatchMode() } async function openSwipeView() { - // Run batch invoice matching for income transactions first - await runBatchInvoiceMatching() + try { + await fetch('/api/transactions/batch-match-invoices', { method: 'POST' }) + .then((r) => r.json()) + .then((data) => { + if (data.matched > 0) fetchTransactions() + }) + } catch { + // Non-critical + } const uncatIds = uncategorizedTransactions.map((t) => t.id) await fetchCategorySuggestions(uncatIds) setShowSwipeView(true) } - const uncategorizedTransactions = transactions - .filter((t) => t.is_business === null) - .sort((a, b) => { - // Invoice-matched first - const aHasMatch = a.potential_invoice ? 1 : 0 - const bHasMatch = b.potential_invoice ? 1 : 0 - if (aHasMatch !== bHasMatch) return bHasMatch - aHasMatch - // Then by date descending - return b.date.localeCompare(a.date) - }) - const transactionsWithMatches = transactions.filter((t) => t.potential_invoice && !t.invoice_id) - const filteredTransactions = transactions.filter((t) => { - const matchesSearch = t.description.toLowerCase().includes(searchTerm.toLowerCase()) - const matchesTab = - activeTab === 'all' || - (activeTab === 'uncategorized' && t.is_business === null) || - (activeTab === 'business' && t.is_business === true) || - (activeTab === 'private' && t.is_business === false) || - (activeTab === 'matches' && t.potential_invoice && !t.invoice_id) - return matchesSearch && matchesTab - }) + function openMatchDialog(transaction: TransactionWithInvoice) { + setSelectedTransaction(transaction) + setMatchDialogOpen(true) + } + function openCategoryDialog(transaction: TransactionWithInvoice) { + setCategoryDialogTransaction(transaction) + setCategoryDialogOpen(true) + } + + // Swipe view if (showSwipeView && uncategorizedTransactions.length > 0) { return ( -
-
-

Transaktioner

-

- Hantera och bokför dina transaktioner -

-
-
- - {uncategorizedTransactions.length > 0 && ( - <> - - - - )} - - - - - - - Lägg till transaktion - - - - -
-
+ {/* Status bar with mode toggle */} + setIsDialogOpen(true)} + isLoadingSuggestions={isLoadingSuggestions} + isBatchMode={isBatchMode} + onToggleBatchMode={() => (isBatchMode ? exitBatchMode() : setIsBatchMode(true))} + /> - {/* Search and tabs */} -
-
- - setSearchTerm(e.target.value)} - className="pl-10" - /> -
- - - Alla - - Ej bokförda - {uncategorizedTransactions.length > 0 && ( - - {uncategorizedTransactions.length} - - )} - - - Fakturamatchningar - {transactionsWithMatches.length > 0 && ( - - {transactionsWithMatches.length} - - )} - - Företag - Privat - - -
- - {/* Feature discovery hint */} - {!hideCategorizationHint && uncategorizedTransactions.length > 0 && ( -
- -

- Tips: Klicka "Bokför" ovan för att snabbt bokföra transaktioner en i taget. Använd "Välj flera" för att hantera flera samtidigt. -

- -
- )} - - {/* Transaction list */} + {/* Content based on mode */} {isLoading ? (
{[1, 2, 3].map((i) => ( @@ -583,181 +450,58 @@ export default function TransactionsPage() { ))}
- ) : filteredTransactions.length === 0 ? ( - - - -

Inga transaktioner

-

- {searchTerm - ? 'Inga transaktioner matchar din sökning' - : 'Importera transaktioner från din bank eller lägg till manuellt'} -

- {!searchTerm && ( -
- - -
- )} -
-
+ ) : mode === 'inbox' ? ( + uncategorizedTransactions.length === 0 ? ( + 0} + onCreateTransaction={() => setIsDialogOpen(true)} + /> + ) : ( +
+ + {uncategorizedTransactions.map((transaction) => ( + + ))} + +
+ ) ) : ( -
- {filteredTransactions.map((transaction) => { - const isUncategorized = transaction.is_business === null && !transaction.journal_entry_id - const isSelected = selectedIds.has(transaction.id) - const showCheckbox = isBatchMode && isUncategorized - - return ( - toggleBatchSelect(transaction.id) : undefined} - > - -
-
- {showCheckbox && ( - toggleBatchSelect(transaction.id)} - onClick={(e) => e.stopPropagation()} - /> - )} -
0 - ? 'bg-success/10 text-success' - : 'bg-destructive/10 text-destructive' - }`} - > - {transaction.amount > 0 ? ( - - ) : ( - - )} -
-
-

{transaction.description}

-
- {formatDate(transaction.date)} - {transaction.is_business !== null && !(transaction.is_business && transaction.category === 'uncategorized' && transaction.journal_entry_id) && ( - <> - · - - {transaction.is_business - ? getCategoryDisplayName(transaction.category) - : 'Privat'} - - - )} - {transaction.invoice_id && ( - <> - · - - - Kopplad till faktura - - - )} - {transaction.journal_entry_id ? ( - <> - · - - - Bokförd - - - ) : transaction.is_business === null && !transaction.potential_invoice ? ( - <> - · - - Ej bokförd - - - ) : null} - {transaction.potential_invoice && !transaction.invoice_id && ( - <> - · - openMatchDialog(transaction)} - > - - Möjlig match: Faktura {transaction.potential_invoice.invoice_number} - - - )} -
-
-
-
-

0 ? 'text-success' : '' - }`} - > - {transaction.amount > 0 ? '+' : ''} - {formatCurrency(transaction.amount, transaction.currency)} -

- {transaction.currency !== 'SEK' && transaction.amount_sek && ( -

- {formatCurrency(transaction.amount_sek)} -

- )} -
-
-
-
- ) - })} -
+ )} {/* Batch mode floating action bar */} {isBatchMode && selectedIds.size > 0 && (
{selectedIds.size} valda - - -
)} - {/* Batch Category Selector */} + {/* Dialogs */} - {/* Invoice Match Confirmation Dialog */} - + + + + + - Bekräfta fakturamatchning - - Vill du koppla denna transaktion till fakturan? Fakturan kommer att markeras som betald. - + Lägg till transaktion - - {selectedTransaction?.potential_invoice && ( -
- {/* Transaction details */} -
-

Transaktion

-

{selectedTransaction.description}

-
- {formatDate(selectedTransaction.date)} - - +{formatCurrency(selectedTransaction.amount, selectedTransaction.currency)} - -
-
- - {/* Invoice details */} -
-

Faktura

-

- Faktura {selectedTransaction.potential_invoice.invoice_number} -

-

- {selectedTransaction.potential_invoice.customer?.name || 'Okänd kund'} -

-
- - Förfaller: {formatDate(selectedTransaction.potential_invoice.due_date)} - - - {formatCurrency( - selectedTransaction.potential_invoice.total, - selectedTransaction.potential_invoice.currency - )} - -
-
- - {/* What will happen */} -
-

Vid bekräftelse:

-
    -
  • • Transaktionen kopplas till fakturan
  • -
  • • Fakturan markeras som betald
  • -
  • • Bokföringsverifikation skapas automatiskt
  • -
-
-
- )} - - - - - +
diff --git a/app/api/extensions/invoice-inbox/inbox/[id]/confirm/__tests__/route.test.ts b/app/api/extensions/invoice-inbox/inbox/[id]/confirm/__tests__/route.test.ts new file mode 100644 index 00000000..a3aa06f4 --- /dev/null +++ b/app/api/extensions/invoice-inbox/inbox/[id]/confirm/__tests__/route.test.ts @@ -0,0 +1,244 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { + createQueuedMockSupabase, + createMockRequest, + createMockRouteParams, + parseJsonResponse, + makeInvoiceInboxItem, + makeSupplier, +} from '@/tests/helpers' + +// Mock dependencies +vi.mock('@/lib/supabase/server', () => ({ + createClient: vi.fn(), +})) + +vi.mock('@/lib/init', () => ({ + ensureInitialized: vi.fn(), +})) + +vi.mock('@/lib/events/bus', () => ({ + eventBus: { emit: vi.fn().mockResolvedValue(undefined), clear: vi.fn() }, +})) + +vi.mock('@/lib/bookkeeping/supplier-invoice-entries', () => ({ + createSupplierInvoiceRegistrationEntry: vi.fn().mockResolvedValue({ id: 'je-1' }), +})) + +import { createClient } from '@/lib/supabase/server' +import { POST } from '../route' + +const mockCreateClient = vi.mocked(createClient) + +describe('Invoice Inbox Confirm Route', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('returns 401 when not authenticated', async () => { + const { supabase } = createQueuedMockSupabase() + supabase.auth.getUser.mockResolvedValue({ + data: { user: null }, + error: { message: 'Not authenticated' }, + }) + mockCreateClient.mockResolvedValue(supabase as never) + + const request = createMockRequest('/api/extensions/invoice-inbox/inbox/item-1/confirm', { + method: 'POST', + body: {}, + }) + const response = await POST(request, createMockRouteParams({ id: 'item-1' })) + const { status } = await parseJsonResponse(response) + + expect(status).toBe(401) + }) + + it('returns 404 when item not found', async () => { + const { supabase, enqueueMany } = createQueuedMockSupabase() + supabase.auth.getUser.mockResolvedValue({ + data: { user: { id: 'user-1' } }, + error: null, + }) + enqueueMany([ + { data: null, error: { message: 'Not found' } }, // inbox item fetch + ]) + mockCreateClient.mockResolvedValue(supabase as never) + + const request = createMockRequest('/api/extensions/invoice-inbox/inbox/item-1/confirm', { + method: 'POST', + body: {}, + }) + const response = await POST(request, createMockRouteParams({ id: 'item-1' })) + const { status } = await parseJsonResponse(response) + + expect(status).toBe(404) + }) + + it('returns 400 when already confirmed', async () => { + const { supabase, enqueueMany } = createQueuedMockSupabase() + supabase.auth.getUser.mockResolvedValue({ + data: { user: { id: 'user-1' } }, + error: null, + }) + enqueueMany([ + { data: makeInvoiceInboxItem({ status: 'confirmed' }), error: null }, + ]) + mockCreateClient.mockResolvedValue(supabase as never) + + const request = createMockRequest('/api/extensions/invoice-inbox/inbox/item-1/confirm', { + method: 'POST', + body: {}, + }) + const response = await POST(request, createMockRouteParams({ id: 'item-1' })) + const { status } = await parseJsonResponse(response) + + expect(status).toBe(400) + }) + + it('returns 400 when no extracted data', async () => { + const { supabase, enqueueMany } = createQueuedMockSupabase() + supabase.auth.getUser.mockResolvedValue({ + data: { user: { id: 'user-1' } }, + error: null, + }) + enqueueMany([ + { data: makeInvoiceInboxItem({ status: 'ready', extracted_data: null }), error: null }, + ]) + mockCreateClient.mockResolvedValue(supabase as never) + + const request = createMockRequest('/api/extensions/invoice-inbox/inbox/item-1/confirm', { + method: 'POST', + body: {}, + }) + const response = await POST(request, createMockRouteParams({ id: 'item-1' })) + const { status } = await parseJsonResponse(response) + + expect(status).toBe(400) + }) + + it('creates new supplier when no match and confirms successfully', async () => { + const extractedData = { + supplier: { + name: 'New Supplier AB', + orgNumber: '556123-4567', + vatNumber: null, + address: null, + bankgiro: '123-4567', + plusgiro: null, + }, + invoice: { + invoiceNumber: 'F-001', + invoiceDate: '2024-06-15', + dueDate: '2024-07-15', + paymentReference: '1234567890', + currency: 'SEK', + }, + lineItems: [ + { description: 'Kontorsmaterial', quantity: 10, unitPrice: 50, lineTotal: 500, vatRate: 25, accountSuggestion: '6100' }, + ], + totals: { subtotal: 500, vatAmount: 125, total: 625 }, + vatBreakdown: [{ rate: 25, base: 500, amount: 125 }], + confidence: 0.92, + } + + const { supabase, enqueueMany } = createQueuedMockSupabase() + supabase.auth.getUser.mockResolvedValue({ + data: { user: { id: 'user-1' } }, + error: null, + }) + + enqueueMany([ + // 1. Fetch inbox item + { data: makeInvoiceInboxItem({ id: 'item-1', status: 'ready', extracted_data: extractedData as unknown as Record }), error: null }, + // 2. Create new supplier + { data: makeSupplier({ id: 'new-supplier-1', name: 'New Supplier AB' }), error: null }, + // 3. Verify supplier + { data: makeSupplier({ id: 'new-supplier-1', name: 'New Supplier AB' }), error: null }, + // 4. Get arrival number + { data: 42, error: null }, + // 5. Insert supplier invoice + { data: { id: 'si-1', total: 625 }, error: null }, + // 6. Insert items + { data: null, error: null }, + // 7. Get company settings + { data: { accounting_method: 'accrual' }, error: null }, + // 8. Update invoice with journal entry id + { data: null, error: null }, + // 9. Update inbox item as confirmed + { data: null, error: null }, + ]) + mockCreateClient.mockResolvedValue(supabase as never) + + const request = createMockRequest('/api/extensions/invoice-inbox/inbox/item-1/confirm', { + method: 'POST', + body: {}, + }) + const response = await POST(request, createMockRouteParams({ id: 'item-1' })) + const { status, body } = await parseJsonResponse<{ data: { id: string } }>(response) + + expect(status).toBe(200) + expect(body.data).toBeDefined() + }) + + it('uses existing supplier when matched', async () => { + const extractedData = { + supplier: { + name: 'Existing Supplier', + orgNumber: null, + vatNumber: null, + address: null, + bankgiro: null, + plusgiro: null, + }, + invoice: { + invoiceNumber: 'F-002', + invoiceDate: '2024-06-15', + dueDate: '2024-07-15', + paymentReference: null, + currency: 'SEK', + }, + lineItems: [], + totals: { subtotal: 1000, vatAmount: 250, total: 1250 }, + vatBreakdown: [], + confidence: 0.85, + } + + const { supabase, enqueueMany } = createQueuedMockSupabase() + supabase.auth.getUser.mockResolvedValue({ + data: { user: { id: 'user-1' } }, + error: null, + }) + + enqueueMany([ + // 1. Fetch inbox item (has matched_supplier_id) + { data: makeInvoiceInboxItem({ + id: 'item-2', + status: 'ready', + extracted_data: extractedData as unknown as Record, + matched_supplier_id: 'existing-supplier-1', + }), error: null }, + // 2. Verify supplier + { data: makeSupplier({ id: 'existing-supplier-1', default_expense_account: '5410' }), error: null }, + // 3. Get arrival number + { data: 43, error: null }, + // 4. Insert supplier invoice + { data: { id: 'si-2', total: 1250 }, error: null }, + // 5. Insert items + { data: null, error: null }, + // 6. Get company settings + { data: { accounting_method: 'cash' }, error: null }, + // 7. Update inbox item as confirmed + { data: null, error: null }, + ]) + mockCreateClient.mockResolvedValue(supabase as never) + + const request = createMockRequest('/api/extensions/invoice-inbox/inbox/item-2/confirm', { + method: 'POST', + body: {}, + }) + const response = await POST(request, createMockRouteParams({ id: 'item-2' })) + const { status } = await parseJsonResponse(response) + + expect(status).toBe(200) + }) +}) diff --git a/app/api/extensions/invoice-inbox/inbox/[id]/confirm/route.ts b/app/api/extensions/invoice-inbox/inbox/[id]/confirm/route.ts new file mode 100644 index 00000000..44c90762 --- /dev/null +++ b/app/api/extensions/invoice-inbox/inbox/[id]/confirm/route.ts @@ -0,0 +1,260 @@ +import { createClient } from '@/lib/supabase/server' +import { NextResponse } from 'next/server' +import { ensureInitialized } from '@/lib/init' +import { eventBus } from '@/lib/events/bus' +import { createSupplierInvoiceRegistrationEntry } from '@/lib/bookkeeping/supplier-invoice-entries' +import type { InvoiceExtractionResult } from '@/extensions/general/invoice-inbox/types' +import type { SupplierInvoice, SupplierInvoiceItem } from '@/types' + +ensureInitialized() + +export async function POST( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id } = await params + + // Fetch inbox item + const { data: inboxItem, error: findError } = await supabase + .from('invoice_inbox_items') + .select('*') + .eq('id', id) + .eq('user_id', user.id) + .single() + + if (findError || !inboxItem) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }) + } + + if (inboxItem.status === 'confirmed') { + return NextResponse.json({ error: 'Already confirmed' }, { status: 400 }) + } + + if (!inboxItem.extracted_data) { + return NextResponse.json({ error: 'No extracted data available' }, { status: 400 }) + } + + const extraction = inboxItem.extracted_data as unknown as InvoiceExtractionResult + const body = await request.json().catch(() => ({})) + + try { + // Resolve supplier: use matched, use body override, or create new + let supplierId = body.supplier_id || inboxItem.matched_supplier_id + + if (!supplierId) { + // Create new supplier from extracted data + const supplierName = extraction.supplier.name + if (!supplierName) { + return NextResponse.json({ error: 'Supplier name is required' }, { status: 400 }) + } + + const { data: newSupplier, error: supplierError } = await supabase + .from('suppliers') + .insert({ + user_id: user.id, + name: supplierName, + supplier_type: 'swedish_business', + org_number: extraction.supplier.orgNumber || null, + vat_number: extraction.supplier.vatNumber || null, + bankgiro: extraction.supplier.bankgiro || null, + plusgiro: extraction.supplier.plusgiro || null, + default_expense_account: '6200', + default_payment_terms: 30, + default_currency: extraction.invoice.currency || 'SEK', + }) + .select() + .single() + + if (supplierError || !newSupplier) { + return NextResponse.json({ error: 'Failed to create supplier' }, { status: 500 }) + } + + supplierId = newSupplier.id + } + + // Verify supplier exists and belongs to user + const { data: supplier, error: supplierCheckError } = await supabase + .from('suppliers') + .select('*') + .eq('id', supplierId) + .eq('user_id', user.id) + .single() + + if (supplierCheckError || !supplier) { + return NextResponse.json({ error: 'Supplier not found' }, { status: 404 }) + } + + // Get next arrival number + const { data: arrivalNum, error: arrivalError } = await supabase + .rpc('get_next_arrival_number', { p_user_id: user.id }) + + if (arrivalError) { + return NextResponse.json({ error: 'Failed to get arrival number' }, { status: 500 }) + } + + // Build line items from extraction + const items = extraction.lineItems.map((item, index) => { + const vatRate = item.vatRate != null ? item.vatRate / 100 : 0.25 + const lineTotal = Math.round(item.lineTotal * 100) / 100 + const vatAmount = Math.round(lineTotal * vatRate * 100) / 100 + return { + sort_order: index, + description: item.description, + quantity: item.quantity || 1, + unit: 'st', + unit_price: item.unitPrice != null ? item.unitPrice : lineTotal, + line_total: lineTotal, + account_number: item.accountSuggestion || supplier.default_expense_account || '6200', + vat_code: null, + vat_rate: vatRate, + vat_amount: vatAmount, + } + }) + + // If no line items, create a single item from totals + if (items.length === 0 && extraction.totals.total) { + const total = extraction.totals.total + const vatAmount = extraction.totals.vatAmount || 0 + const subtotal = extraction.totals.subtotal || total - vatAmount + const vatRate = subtotal > 0 ? Math.round((vatAmount / subtotal) * 100) / 100 : 0.25 + items.push({ + sort_order: 0, + description: 'Fakturabelopp', + quantity: 1, + unit: 'st', + unit_price: subtotal, + line_total: subtotal, + account_number: supplier.default_expense_account || '6200', + vat_code: null, + vat_rate: vatRate, + vat_amount: Math.round(vatAmount * 100) / 100, + }) + } + + const subtotal = items.reduce((sum, i) => sum + i.line_total, 0) + const vatAmount = items.reduce((sum, i) => sum + i.vat_amount, 0) + const total = Math.round((subtotal + vatAmount) * 100) / 100 + + // Determine VAT treatment + const primaryVatRate = items[0]?.vat_rate || 0.25 + let vatTreatment = 'standard_25' + if (primaryVatRate === 0.12) vatTreatment = 'reduced_12' + else if (primaryVatRate === 0.06) vatTreatment = 'reduced_6' + else if (primaryVatRate === 0) vatTreatment = 'exempt' + + // Insert supplier invoice + const { data: invoice, error: invoiceError } = await supabase + .from('supplier_invoices') + .insert({ + user_id: user.id, + supplier_id: supplierId, + arrival_number: arrivalNum, + supplier_invoice_number: extraction.invoice.invoiceNumber || `INBOX-${Date.now()}`, + invoice_date: extraction.invoice.invoiceDate || new Date().toISOString().split('T')[0], + due_date: extraction.invoice.dueDate || new Date(Date.now() + 30 * 86400000).toISOString().split('T')[0], + status: 'registered', + currency: extraction.invoice.currency || 'SEK', + vat_treatment: vatTreatment, + payment_reference: extraction.invoice.paymentReference || null, + subtotal: Math.round(subtotal * 100) / 100, + vat_amount: Math.round(vatAmount * 100) / 100, + total: Math.round(total * 100) / 100, + remaining_amount: Math.round(total * 100) / 100, + document_id: inboxItem.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((item) => ({ + supplier_invoice_id: invoice.id, + ...item, + })) + + const { error: itemsError } = await supabase + .from('supplier_invoice_items') + .insert(itemInserts) + + if (itemsError) { + await 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 supabase + .from('company_settings') + .select('accounting_method') + .eq('user_id', user.id) + .single() + + const accountingMethod = settings?.accounting_method || 'accrual' + let registrationJournalEntryId: string | null = null + + if (accountingMethod === 'accrual') { + try { + const journalEntry = await createSupplierInvoiceRegistrationEntry( + user.id, + invoice as SupplierInvoice, + items as SupplierInvoiceItem[], + supplier.supplier_type + ) + if (journalEntry) { + registrationJournalEntryId = journalEntry.id + await supabase + .from('supplier_invoices') + .update({ registration_journal_entry_id: journalEntry.id }) + .eq('id', invoice.id) + } + } catch (err) { + console.error('[invoice-inbox] Failed to create registration journal entry:', err) + } + } + + // Update inbox item as confirmed + await supabase + .from('invoice_inbox_items') + .update({ + status: 'confirmed', + matched_supplier_id: supplierId, + created_supplier_invoice_id: invoice.id, + }) + .eq('id', inboxItem.id) + + // Emit confirmed event + try { + await eventBus.emit({ + type: 'supplier_invoice.confirmed', + payload: { + inboxItem: { ...inboxItem, status: 'confirmed' }, + supplierInvoice: invoice as SupplierInvoice, + userId: user.id, + }, + }) + } catch { + // Non-blocking + } + + return NextResponse.json({ + data: { + ...invoice, + items: itemInserts, + registration_journal_entry_id: registrationJournalEntryId, + }, + }) + } catch (error) { + console.error('[invoice-inbox] Confirm failed:', error) + return NextResponse.json({ error: 'Confirmation failed' }, { status: 500 }) + } +} diff --git a/app/api/extensions/invoice-inbox/inbox/[id]/process/route.ts b/app/api/extensions/invoice-inbox/inbox/[id]/process/route.ts new file mode 100644 index 00000000..a02fc6a1 --- /dev/null +++ b/app/api/extensions/invoice-inbox/inbox/[id]/process/route.ts @@ -0,0 +1,128 @@ +import { createClient } from '@/lib/supabase/server' +import { NextResponse } from 'next/server' +import { ensureInitialized } from '@/lib/init' +import { eventBus } from '@/lib/events/bus' +import { analyzeInvoice } from '@/extensions/general/invoice-inbox/lib/invoice-analyzer' +import { matchSupplier } from '@/extensions/general/invoice-inbox/lib/supplier-matcher' +import { getSettings } from '@/extensions/general/invoice-inbox' + +ensureInitialized() + +export async function POST( + _request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id } = await params + + // Fetch inbox item + const { data: inboxItem, error: findError } = await supabase + .from('invoice_inbox_items') + .select('*, document:document_attachments(id, storage_path, mime_type)') + .eq('id', id) + .eq('user_id', user.id) + .single() + + if (findError || !inboxItem) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }) + } + + if (inboxItem.status === 'confirmed') { + return NextResponse.json({ error: 'Already confirmed' }, { status: 400 }) + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const document = inboxItem.document as any + if (!document?.storage_path || !document?.mime_type) { + return NextResponse.json({ error: 'No document attached' }, { status: 400 }) + } + + // Update status to processing + await supabase + .from('invoice_inbox_items') + .update({ status: 'processing', error_message: null }) + .eq('id', id) + + try { + // Download file + const { data: fileData, error: downloadError } = await supabase.storage + .from('documents') + .download(document.storage_path) + + if (downloadError || !fileData) { + await supabase + .from('invoice_inbox_items') + .update({ status: 'error', error_message: 'Failed to download document' }) + .eq('id', id) + return NextResponse.json({ error: 'Failed to download document' }, { status: 500 }) + } + + const arrayBuffer = await fileData.arrayBuffer() + const base64 = Buffer.from(arrayBuffer).toString('base64') + + // Analyze + const extraction = await analyzeInvoice(base64, document.mime_type) + + // Supplier matching + const settings = await getSettings(user.id) + let matchedSupplierId: string | null = null + + if (settings.autoMatchSupplierEnabled) { + const { data: suppliers } = await supabase + .from('suppliers') + .select('*') + .eq('user_id', user.id) + + if (suppliers && suppliers.length > 0) { + const match = matchSupplier(extraction, suppliers) + if (match && match.confidence >= settings.supplierMatchThreshold) { + matchedSupplierId = match.supplierId + } + } + } + + // Update inbox item + const { data: updatedItem, error: updateError } = await supabase + .from('invoice_inbox_items') + .update({ + status: 'ready', + extracted_data: extraction as unknown as Record, + confidence: extraction.confidence, + matched_supplier_id: matchedSupplierId, + error_message: null, + }) + .eq('id', id) + .select() + .single() + + if (updateError) { + return NextResponse.json({ error: updateError.message }, { status: 500 }) + } + + if (updatedItem) { + await eventBus.emit({ + type: 'supplier_invoice.extracted', + payload: { + inboxItem: updatedItem, + confidence: extraction.confidence, + userId: user.id, + }, + }) + } + + return NextResponse.json({ data: updatedItem }) + } catch (error) { + const message = error instanceof Error ? error.message : 'Processing failed' + await supabase + .from('invoice_inbox_items') + .update({ status: 'error', error_message: message }) + .eq('id', id) + return NextResponse.json({ error: message }, { status: 500 }) + } +} diff --git a/app/api/extensions/invoice-inbox/inbox/[id]/route.ts b/app/api/extensions/invoice-inbox/inbox/[id]/route.ts new file mode 100644 index 00000000..86b7a99e --- /dev/null +++ b/app/api/extensions/invoice-inbox/inbox/[id]/route.ts @@ -0,0 +1,111 @@ +import { createClient } from '@/lib/supabase/server' +import { NextResponse } from 'next/server' + +export async function GET( + _request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id } = await params + + const { data, error } = await supabase + .from('invoice_inbox_items') + .select('*, document:document_attachments(id, file_name, mime_type, storage_path), supplier:suppliers(id, name)') + .eq('id', id) + .eq('user_id', user.id) + .single() + + if (error || !data) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }) + } + + return NextResponse.json({ data }) +} + +export async function PATCH( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id } = await params + const body = await request.json() + + // Verify item exists and belongs to user + const { data: existing, error: findError } = await supabase + .from('invoice_inbox_items') + .select('id, status') + .eq('id', id) + .eq('user_id', user.id) + .single() + + if (findError || !existing) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }) + } + + if (existing.status === 'confirmed') { + return NextResponse.json({ error: 'Cannot edit confirmed item' }, { status: 400 }) + } + + // Only allow updating certain fields + const allowedFields: Record = {} + if (body.extracted_data !== undefined) allowedFields.extracted_data = body.extracted_data + if (body.matched_supplier_id !== undefined) allowedFields.matched_supplier_id = body.matched_supplier_id + + if (Object.keys(allowedFields).length === 0) { + return NextResponse.json({ error: 'No valid fields to update' }, { status: 400 }) + } + + const { data, error } = await supabase + .from('invoice_inbox_items') + .update(allowedFields) + .eq('id', id) + .select() + .single() + + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }) + } + + return NextResponse.json({ data }) +} + +export async function DELETE( + _request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id } = await params + + // Soft delete: set status to rejected + const { data, error } = await supabase + .from('invoice_inbox_items') + .update({ status: 'rejected' }) + .eq('id', id) + .eq('user_id', user.id) + .select() + .single() + + if (error || !data) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }) + } + + return NextResponse.json({ data }) +} diff --git a/app/api/extensions/invoice-inbox/inbox/__tests__/route.test.ts b/app/api/extensions/invoice-inbox/inbox/__tests__/route.test.ts new file mode 100644 index 00000000..e551eaef --- /dev/null +++ b/app/api/extensions/invoice-inbox/inbox/__tests__/route.test.ts @@ -0,0 +1,124 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { createQueuedMockSupabase, createMockRequest, parseJsonResponse, makeInvoiceInboxItem } from '@/tests/helpers' + +// Mock dependencies +vi.mock('@/lib/supabase/server', () => ({ + createClient: vi.fn(), +})) + +vi.mock('@/lib/init', () => ({ + ensureInitialized: vi.fn(), +})) + +vi.mock('@/lib/events/bus', () => ({ + eventBus: { emit: vi.fn().mockResolvedValue(undefined), clear: vi.fn() }, +})) + +vi.mock('server-only', () => ({})) + +vi.mock('@/extensions/general/invoice-inbox/lib/invoice-analyzer', () => ({ + analyzeInvoice: vi.fn(), +})) + +vi.mock('@/extensions/general/invoice-inbox/lib/supplier-matcher', () => ({ + matchSupplier: vi.fn(), +})) + +vi.mock('@/extensions/general/invoice-inbox', () => ({ + getSettings: vi.fn().mockResolvedValue({ + autoProcessEnabled: true, + autoMatchSupplierEnabled: true, + supplierMatchThreshold: 0.7, + inboxEmail: null, + }), +})) + +import { createClient } from '@/lib/supabase/server' +import { GET } from '../route' + +const mockCreateClient = vi.mocked(createClient) + +describe('Invoice Inbox Routes', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('GET /api/extensions/invoice-inbox/inbox', () => { + it('returns 401 when not authenticated', async () => { + const { supabase } = createQueuedMockSupabase() + supabase.auth.getUser.mockResolvedValue({ + data: { user: null }, + error: { message: 'Not authenticated' }, + }) + mockCreateClient.mockResolvedValue(supabase as never) + + const request = createMockRequest('/api/extensions/invoice-inbox/inbox') + const response = await GET(request) + const { status } = await parseJsonResponse(response) + + expect(status).toBe(401) + }) + + it('returns inbox items for authenticated user', async () => { + const items = [ + makeInvoiceInboxItem({ id: 'item-1', status: 'ready' }), + makeInvoiceInboxItem({ id: 'item-2', status: 'pending' }), + ] + + const { supabase, enqueueMany } = createQueuedMockSupabase() + supabase.auth.getUser.mockResolvedValue({ + data: { user: { id: 'user-1' } }, + error: null, + }) + enqueueMany([ + { data: items, error: null }, + ]) + mockCreateClient.mockResolvedValue(supabase as never) + + const request = createMockRequest('/api/extensions/invoice-inbox/inbox') + const response = await GET(request) + const { status, body } = await parseJsonResponse<{ data: unknown[] }>(response) + + expect(status).toBe(200) + expect(body.data).toHaveLength(2) + }) + + it('filters by status when provided', async () => { + const { supabase, enqueueMany } = createQueuedMockSupabase() + supabase.auth.getUser.mockResolvedValue({ + data: { user: { id: 'user-1' } }, + error: null, + }) + enqueueMany([ + { data: [makeInvoiceInboxItem({ status: 'ready' })], error: null }, + ]) + mockCreateClient.mockResolvedValue(supabase as never) + + const request = createMockRequest('/api/extensions/invoice-inbox/inbox', { + searchParams: { status: 'ready' }, + }) + const response = await GET(request) + const { status } = await parseJsonResponse(response) + + expect(status).toBe(200) + }) + + it('returns 500 on database error', async () => { + const { supabase, enqueueMany } = createQueuedMockSupabase() + supabase.auth.getUser.mockResolvedValue({ + data: { user: { id: 'user-1' } }, + error: null, + }) + enqueueMany([ + { data: null, error: { message: 'Database error' } }, + ]) + mockCreateClient.mockResolvedValue(supabase as never) + + const request = createMockRequest('/api/extensions/invoice-inbox/inbox') + const response = await GET(request) + const { status } = await parseJsonResponse(response) + + expect(status).toBe(500) + }) + }) +}) diff --git a/app/api/extensions/invoice-inbox/inbox/route.ts b/app/api/extensions/invoice-inbox/inbox/route.ts new file mode 100644 index 00000000..a8b5d37c --- /dev/null +++ b/app/api/extensions/invoice-inbox/inbox/route.ts @@ -0,0 +1,193 @@ +import { createClient } from '@/lib/supabase/server' +import { NextResponse } from 'next/server' +import { ensureInitialized } from '@/lib/init' +import { eventBus } from '@/lib/events/bus' +import { analyzeInvoice } from '@/extensions/general/invoice-inbox/lib/invoice-analyzer' +import { matchSupplier } from '@/extensions/general/invoice-inbox/lib/supplier-matcher' +import { getSettings } from '@/extensions/general/invoice-inbox' +import crypto from 'crypto' + +ensureInitialized() + +export async function GET(request: Request) { + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { searchParams } = new URL(request.url) + const status = searchParams.get('status') + + let query = supabase + .from('invoice_inbox_items') + .select('*, document:document_attachments(id, file_name, mime_type, storage_path), supplier:suppliers(id, name)') + .eq('user_id', user.id) + + if (status && status !== 'all') { + query = query.eq('status', status) + } + + const { data, error } = await query.order('created_at', { ascending: false }) + + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }) + } + + return NextResponse.json({ data }) +} + +export async function POST(request: Request) { + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const formData = await request.formData() + const file = formData.get('file') as File | null + + if (!file) { + return NextResponse.json({ error: 'No file provided' }, { status: 400 }) + } + + const supportedTypes = ['application/pdf', 'image/jpeg', 'image/png', 'image/webp'] + if (!supportedTypes.includes(file.type)) { + return NextResponse.json({ error: 'Unsupported file type' }, { status: 400 }) + } + + try { + // Read file + const arrayBuffer = await file.arrayBuffer() + const buffer = Buffer.from(arrayBuffer) + const base64 = buffer.toString('base64') + const hash = crypto.createHash('sha256').update(buffer).digest('hex') + + // Upload to storage + const storagePath = `documents/${user.id}/inbox/${Date.now()}-${file.name}` + const { error: uploadError } = await supabase.storage + .from('documents') + .upload(storagePath, buffer, { contentType: file.type }) + + if (uploadError) { + return NextResponse.json({ error: 'Failed to upload file' }, { status: 500 }) + } + + // Create document attachment record + const { data: document, error: docError } = await supabase + .from('document_attachments') + .insert({ + user_id: user.id, + storage_path: storagePath, + file_name: file.name, + file_size_bytes: buffer.length, + mime_type: file.type, + sha256_hash: hash, + upload_source: 'file_upload', + }) + .select() + .single() + + if (docError || !document) { + return NextResponse.json({ error: 'Failed to create document record' }, { status: 500 }) + } + + // Create inbox item + const { data: inboxItem, error: itemError } = await supabase + .from('invoice_inbox_items') + .insert({ + user_id: user.id, + status: 'processing', + source: 'upload', + document_id: document.id, + }) + .select() + .single() + + if (itemError || !inboxItem) { + return NextResponse.json({ error: 'Failed to create inbox item' }, { status: 500 }) + } + + // Emit received event + await eventBus.emit({ + type: 'supplier_invoice.received', + payload: { inboxItem, userId: user.id }, + }) + + // Process asynchronously - analyze and match + processInboxItem(inboxItem.id, user.id, base64, file.type).catch((err) => + console.error('[invoice-inbox] Background processing failed:', err) + ) + + return NextResponse.json({ data: inboxItem }) + } catch (error) { + console.error('[invoice-inbox] Upload failed:', error) + return NextResponse.json({ error: 'Upload failed' }, { status: 500 }) + } +} + +async function processInboxItem( + itemId: string, + userId: string, + base64: string, + mimeType: string +): Promise { + const supabase = await createClient() + + try { + const extraction = await analyzeInvoice(base64, mimeType) + + // Supplier matching + const settings = await getSettings(userId) + let matchedSupplierId: string | null = null + + if (settings.autoMatchSupplierEnabled) { + const { data: suppliers } = await supabase + .from('suppliers') + .select('*') + .eq('user_id', userId) + + if (suppliers && suppliers.length > 0) { + const match = matchSupplier(extraction, suppliers) + if (match && match.confidence >= settings.supplierMatchThreshold) { + matchedSupplierId = match.supplierId + } + } + } + + await supabase + .from('invoice_inbox_items') + .update({ + status: 'ready', + extracted_data: extraction as unknown as Record, + confidence: extraction.confidence, + matched_supplier_id: matchedSupplierId, + }) + .eq('id', itemId) + + const { data: updatedItem } = await supabase + .from('invoice_inbox_items') + .select('*') + .eq('id', itemId) + .single() + + if (updatedItem) { + await eventBus.emit({ + type: 'supplier_invoice.extracted', + payload: { + inboxItem: updatedItem, + confidence: extraction.confidence, + userId, + }, + }) + } + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error' + await supabase + .from('invoice_inbox_items') + .update({ status: 'error', error_message: message }) + .eq('id', itemId) + } +} diff --git a/app/api/extensions/invoice-inbox/settings/route.ts b/app/api/extensions/invoice-inbox/settings/route.ts new file mode 100644 index 00000000..4b3abb3f --- /dev/null +++ b/app/api/extensions/invoice-inbox/settings/route.ts @@ -0,0 +1,28 @@ +import { createClient } from '@/lib/supabase/server' +import { NextResponse } from 'next/server' +import { getSettings, saveSettings } from '@/extensions/general/invoice-inbox' + +export async function GET() { + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const settings = await getSettings(user.id) + return NextResponse.json({ data: settings }) +} + +export async function PUT(request: Request) { + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const body = await request.json() + const settings = await saveSettings(user.id, body) + return NextResponse.json({ data: settings }) +} diff --git a/app/api/extensions/invoice-inbox/webhook/__tests__/route.test.ts b/app/api/extensions/invoice-inbox/webhook/__tests__/route.test.ts new file mode 100644 index 00000000..e950fc4d --- /dev/null +++ b/app/api/extensions/invoice-inbox/webhook/__tests__/route.test.ts @@ -0,0 +1,153 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { createQueuedMockSupabase, parseJsonResponse } from '@/tests/helpers' + +// Mock server-only +vi.mock('server-only', () => ({})) + +// Hoisted mocks to avoid reference-before-initialization +const { mockVerify, mockServiceClientFn } = vi.hoisted(() => { + return { + mockVerify: vi.fn(), + mockServiceClientFn: vi.fn(), + } +}) + +// Mock svix +vi.mock('svix', () => ({ + Webhook: class MockWebhook { + verify = mockVerify + }, +})) + +// Mock Supabase SSR +vi.mock('@supabase/ssr', () => ({ + createServerClient: (...args: unknown[]) => mockServiceClientFn(...args), +})) + +// Mock email handler +vi.mock('@/extensions/general/invoice-inbox/lib/email-handler', () => ({ + parseInboundPayload: vi.fn(), + extractAttachments: vi.fn(), + resolveUserFromEmail: vi.fn(), +})) + +// Mock invoice analyzer +vi.mock('@/extensions/general/invoice-inbox/lib/invoice-analyzer', () => ({ + analyzeInvoice: vi.fn(), +})) + +// Mock supplier matcher +vi.mock('@/extensions/general/invoice-inbox/lib/supplier-matcher', () => ({ + matchSupplier: vi.fn(), +})) + +import { POST } from '../route' +import { parseInboundPayload, extractAttachments, resolveUserFromEmail } from '@/extensions/general/invoice-inbox/lib/email-handler' + +const mockParseInboundPayload = vi.mocked(parseInboundPayload) +const mockExtractAttachments = vi.mocked(extractAttachments) +const mockResolveUserFromEmail = vi.mocked(resolveUserFromEmail) + +describe('Invoice Inbox Webhook Route', () => { + beforeEach(() => { + vi.clearAllMocks() + process.env.RESEND_WEBHOOK_SECRET = 'test-secret' + process.env.NEXT_PUBLIC_SUPABASE_URL = 'http://localhost:54321' + process.env.SUPABASE_SERVICE_ROLE_KEY = 'test-key' + }) + + function makeWebhookRequest(body: unknown = {}) { + const bodyStr = JSON.stringify(body) + return new Request('http://localhost:3000/api/extensions/invoice-inbox/webhook', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'svix-id': 'msg_test123', + 'svix-timestamp': String(Math.floor(Date.now() / 1000)), + 'svix-signature': 'v1,test-signature', + }, + body: bodyStr, + }) + } + + it('returns 400 when webhook headers are missing', async () => { + const request = new Request('http://localhost:3000/api/extensions/invoice-inbox/webhook', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: '{}', + }) + + const response = await POST(request) + const { status } = await parseJsonResponse(response) + expect(status).toBe(400) + }) + + it('returns 401 when signature verification fails', async () => { + mockVerify.mockImplementation(() => { + throw new Error('Invalid signature') + }) + + const response = await POST(makeWebhookRequest({ from: 'test@test.com', to: 'inbox@co.com' })) + const { status } = await parseJsonResponse(response) + expect(status).toBe(401) + }) + + it('returns 400 when payload is invalid', async () => { + mockVerify.mockReturnValue(undefined) + mockParseInboundPayload.mockReturnValue(null) + + const response = await POST(makeWebhookRequest({})) + const { status } = await parseJsonResponse(response) + expect(status).toBe(400) + }) + + it('returns 404 when user not found for email', async () => { + const { supabase } = createQueuedMockSupabase() + mockServiceClientFn.mockReturnValue(supabase) + + mockVerify.mockReturnValue(undefined) + mockParseInboundPayload.mockReturnValue({ + from: 'supplier@test.com', + to: 'unknown@inbox.com', + subject: 'Invoice', + html: null, + text: null, + attachments: [], + created_at: '2024-06-15T10:00:00Z', + }) + mockResolveUserFromEmail.mockResolvedValue(null) + + const response = await POST(makeWebhookRequest({ from: 'supplier@test.com', to: 'unknown@inbox.com' })) + const { status } = await parseJsonResponse(response) + expect(status).toBe(404) + }) + + it('returns success with 0 processed when no attachments', async () => { + const { supabase, enqueueMany } = createQueuedMockSupabase() + mockServiceClientFn.mockReturnValue(supabase) + + mockVerify.mockReturnValue(undefined) + mockParseInboundPayload.mockReturnValue({ + from: 'supplier@test.com', + to: 'inbox@myco.com', + subject: 'No attachments', + html: null, + text: null, + attachments: [], + created_at: '2024-06-15T10:00:00Z', + }) + mockExtractAttachments.mockReturnValue([]) + mockResolveUserFromEmail.mockResolvedValue('user-1') + + // Insert inbox item with error status + enqueueMany([ + { data: { id: 'item-1' }, error: null }, + ]) + + const response = await POST(makeWebhookRequest({ from: 'supplier@test.com', to: 'inbox@myco.com' })) + const { status, body } = await parseJsonResponse<{ data: { processed: number } }>(response) + + expect(status).toBe(200) + expect(body.data.processed).toBe(0) + }) +}) diff --git a/app/api/extensions/invoice-inbox/webhook/route.ts b/app/api/extensions/invoice-inbox/webhook/route.ts new file mode 100644 index 00000000..202283a9 --- /dev/null +++ b/app/api/extensions/invoice-inbox/webhook/route.ts @@ -0,0 +1,182 @@ +import { createServerClient } from '@supabase/ssr' +import { NextResponse } from 'next/server' +import { Webhook } from 'svix' +import { parseInboundPayload, extractAttachments, resolveUserFromEmail } from '@/extensions/general/invoice-inbox/lib/email-handler' +import { analyzeInvoice } from '@/extensions/general/invoice-inbox/lib/invoice-analyzer' +import { matchSupplier } from '@/extensions/general/invoice-inbox/lib/supplier-matcher' +import crypto from 'crypto' + +function createServiceClient() { + return createServerClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.SUPABASE_SERVICE_ROLE_KEY!, + { + cookies: { + getAll() { return [] }, + setAll() { }, + }, + } + ) +} + +export async function POST(request: Request) { + // Verify webhook signature + const webhookSecret = process.env.RESEND_WEBHOOK_SECRET + if (!webhookSecret) { + console.error('[invoice-inbox] RESEND_WEBHOOK_SECRET not configured') + return NextResponse.json({ error: 'Webhook not configured' }, { status: 500 }) + } + + const svixId = request.headers.get('svix-id') + const svixTimestamp = request.headers.get('svix-timestamp') + const svixSignature = request.headers.get('svix-signature') + + if (!svixId || !svixTimestamp || !svixSignature) { + return NextResponse.json({ error: 'Missing webhook headers' }, { status: 400 }) + } + + const rawBody = await request.text() + + try { + const wh = new Webhook(webhookSecret) + wh.verify(rawBody, { + 'svix-id': svixId, + 'svix-timestamp': svixTimestamp, + 'svix-signature': svixSignature, + }) + } catch { + return NextResponse.json({ error: 'Invalid signature' }, { status: 401 }) + } + + const body = JSON.parse(rawBody) + const payload = parseInboundPayload(body) + + if (!payload) { + return NextResponse.json({ error: 'Invalid payload' }, { status: 400 }) + } + + const supabase = createServiceClient() + + // Resolve user from recipient email + const userId = await resolveUserFromEmail(payload.to, supabase) + + if (!userId) { + console.warn(`[invoice-inbox] No user found for email: ${payload.to}`) + return NextResponse.json({ error: 'User not found' }, { status: 404 }) + } + + // Extract file attachments + const attachments = extractAttachments(payload) + + if (attachments.length === 0) { + // Create inbox item with error status (no attachments) + await supabase + .from('invoice_inbox_items') + .insert({ + user_id: userId, + status: 'error', + source: 'email', + email_from: payload.from, + email_subject: payload.subject, + email_received_at: payload.created_at, + error_message: 'No supported attachments found', + }) + + return NextResponse.json({ data: { processed: 0, message: 'No attachments' } }) + } + + const processed: string[] = [] + + for (const attachment of attachments) { + try { + const buffer = Buffer.from(attachment.content, 'base64') + const hash = crypto.createHash('sha256').update(buffer).digest('hex') + + // Upload to storage + const storagePath = `documents/${userId}/inbox/${Date.now()}-${attachment.filename}` + const { error: uploadError } = await supabase.storage + .from('documents') + .upload(storagePath, buffer, { contentType: attachment.content_type }) + + if (uploadError) { + console.error('[invoice-inbox] Upload failed:', uploadError) + continue + } + + // Create document attachment + const { data: document, error: docError } = await supabase + .from('document_attachments') + .insert({ + user_id: userId, + storage_path: storagePath, + file_name: attachment.filename, + file_size_bytes: buffer.length, + mime_type: attachment.content_type, + sha256_hash: hash, + upload_source: 'email', + }) + .select() + .single() + + if (docError || !document) continue + + // Create inbox item + const { data: inboxItem, error: itemError } = await supabase + .from('invoice_inbox_items') + .insert({ + user_id: userId, + status: 'processing', + source: 'email', + email_from: payload.from, + email_subject: payload.subject, + email_received_at: payload.created_at, + document_id: document.id, + }) + .select() + .single() + + if (itemError || !inboxItem) continue + + // Process: analyze invoice + try { + const extraction = await analyzeInvoice(attachment.content, attachment.content_type) + + // Supplier matching + let matchedSupplierId: string | null = null + const { data: suppliers } = await supabase + .from('suppliers') + .select('*') + .eq('user_id', userId) + + if (suppliers && suppliers.length > 0) { + const match = matchSupplier(extraction, suppliers) + if (match && match.confidence >= 0.7) { + matchedSupplierId = match.supplierId + } + } + + await supabase + .from('invoice_inbox_items') + .update({ + status: 'ready', + extracted_data: extraction as unknown as Record, + confidence: extraction.confidence, + matched_supplier_id: matchedSupplierId, + }) + .eq('id', inboxItem.id) + } catch (err) { + const message = err instanceof Error ? err.message : 'Analysis failed' + await supabase + .from('invoice_inbox_items') + .update({ status: 'error', error_message: message }) + .eq('id', inboxItem.id) + } + + processed.push(inboxItem.id) + } catch (err) { + console.error('[invoice-inbox] Processing attachment failed:', err) + } + } + + return NextResponse.json({ data: { processed: processed.length, ids: processed } }) +} diff --git a/app/api/invoices/preview-pdf/route.ts b/app/api/invoices/preview-pdf/route.ts index b864a1a6..0dd8b28a 100644 --- a/app/api/invoices/preview-pdf/route.ts +++ b/app/api/invoices/preview-pdf/route.ts @@ -1,8 +1,8 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { renderToBuffer } from '@react-pdf/renderer' -import { InvoicePDF } from '@/lib/invoice/pdf-template' -import { getVatRules } from '@/lib/invoice/vat-rules' +import { InvoicePDF } from '@/lib/invoices/pdf-template' +import { getVatRules } from '@/lib/invoices/vat-rules' import type { Invoice, InvoiceItem, Customer, CompanySettings, InvoiceDocumentType } from '@/types' /** diff --git a/app/api/transactions/[id]/categorize/route.ts b/app/api/transactions/[id]/categorize/route.ts index fda90d29..3ce1dec4 100644 --- a/app/api/transactions/[id]/categorize/route.ts +++ b/app/api/transactions/[id]/categorize/route.ts @@ -13,6 +13,7 @@ interface CategorizeRequest { is_business: boolean category?: TransactionCategory vat_treatment?: VatTreatment + account_override?: string } /** @@ -168,6 +169,36 @@ export async function POST( body.vat_treatment ) + // Apply account override if provided (only for business transactions) + if (is_business && body.account_override) { + // Validate the account exists in the user's chart of accounts + const { data: accountExists } = await supabase + .from('accounts') + .select('account_number, account_class') + .eq('user_id', user.id) + .eq('account_number', body.account_override) + .single() + + if (!accountExists) { + return NextResponse.json( + { error: 'Invalid account number' }, + { status: 400 } + ) + } + + // Apply override: expenses override debit account, income overrides credit account + if (transaction.amount < 0) { + mappingResult.debit_account = body.account_override + } else { + mappingResult.credit_account = body.account_override + } + + // If override account is a liability/equity account (class 2), clear VAT lines + if (accountExists.account_class === 2) { + mappingResult.vat_lines = [] + } + } + // Ensure fiscal period exists for the transaction date await ensureFiscalPeriod(supabase, user.id, transaction.date, fiscalYearStartMonth) diff --git a/components/transactions/BatchCategorySelector.tsx b/components/transactions/BatchCategorySelector.tsx index d4bc72a9..0a0d8aee 100644 --- a/components/transactions/BatchCategorySelector.tsx +++ b/components/transactions/BatchCategorySelector.tsx @@ -1,35 +1,22 @@ 'use client' +import { useState } from 'react' import { Button } from '@/components/ui/button' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog' import { Progress } from '@/components/ui/progress' -import type { TransactionCategory } from '@/types' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { EXPENSE_CATEGORIES, INCOME_CATEGORIES, VAT_TREATMENT_OPTIONS } from './transaction-types' +import type { TransactionCategory, VatTreatment } from '@/types' -const expenseCategories: { value: TransactionCategory; label: string }[] = [ - { value: 'expense_equipment', label: 'Utrustning' }, - { value: 'expense_software', label: 'Programvara' }, - { value: 'expense_travel', label: 'Resor' }, - { value: 'expense_office', label: 'Kontor' }, - { value: 'expense_marketing', label: 'Marknadsföring' }, - { value: 'expense_professional_services', label: 'Konsulter' }, - { value: 'expense_education', label: 'Utbildning' }, - { value: 'expense_bank_fees', label: 'Bankavgift' }, - { value: 'expense_card_fees', label: 'Kortavgift' }, - { value: 'expense_currency_exchange', label: 'Valutaväxling' }, - { value: 'expense_other', label: 'Övrigt' }, -] - -const incomeCategories: { value: TransactionCategory; label: string }[] = [ - { value: 'income_services', label: 'Tjänster' }, - { value: 'income_products', label: 'Produkter' }, - { value: 'income_other', label: 'Övrigt' }, -] +const expenseCategories = EXPENSE_CATEGORIES +const incomeCategories = INCOME_CATEGORIES +const vatTreatmentOptions = VAT_TREATMENT_OPTIONS interface BatchCategorySelectorProps { open: boolean onOpenChange: (open: boolean) => void selectedCount: number - onSelectCategory: (category: TransactionCategory) => void + onSelectCategory: (category: TransactionCategory, vatTreatment?: VatTreatment) => void progress: { done: number; total: number } | null } @@ -40,8 +27,14 @@ export default function BatchCategorySelector({ onSelectCategory, progress, }: BatchCategorySelectorProps) { + const [vatTreatment, setVatTreatment] = useState('standard_25') const isProcessing = progress !== null + const handleSelectCategory = (category: TransactionCategory) => { + const resolvedVat = vatTreatment === 'none' ? undefined : vatTreatment + onSelectCategory(category, resolvedVat) + } + return ( @@ -67,6 +60,24 @@ export default function BatchCategorySelector({ ) : (
+
+

Momsbehandling

+ +

Kostnader

@@ -76,7 +87,7 @@ export default function BatchCategorySelector({ variant="outline" size="sm" className="justify-start text-xs" - onClick={() => onSelectCategory(cat.value)} + onClick={() => handleSelectCategory(cat.value)} > {cat.label} @@ -92,7 +103,7 @@ export default function BatchCategorySelector({ variant="outline" size="sm" className="justify-start text-xs" - onClick={() => onSelectCategory(cat.value)} + onClick={() => handleSelectCategory(cat.value)} > {cat.label} diff --git a/components/transactions/CategoryExpandedDialog.tsx b/components/transactions/CategoryExpandedDialog.tsx new file mode 100644 index 00000000..089ef01d --- /dev/null +++ b/components/transactions/CategoryExpandedDialog.tsx @@ -0,0 +1,105 @@ +'use client' + +import { Button } from '@/components/ui/button' +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog' +import { formatCurrency, formatDate } from '@/lib/utils' +import { ArrowUpRight, ArrowDownRight } from 'lucide-react' +import { EXPENSE_CATEGORIES, INCOME_CATEGORIES } from './transaction-types' +import type { TransactionWithInvoice } from './transaction-types' +import type { TransactionCategory } from '@/types' + +interface CategoryExpandedDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + transaction: TransactionWithInvoice | null + onSelectCategory: (category: TransactionCategory) => void + isProcessing: boolean +} + +export default function CategoryExpandedDialog({ + open, + onOpenChange, + transaction, + onSelectCategory, + isProcessing, +}: CategoryExpandedDialogProps) { + if (!transaction) return null + + const isIncome = transaction.amount > 0 + + return ( + + + + Välj kategori + + Välj rätt kategori för att bokföra transaktionen + + + + {/* Transaction summary */} +
+
+ {isIncome ? ( + + ) : ( + + )} +
+
+

{transaction.description}

+

{formatDate(transaction.date)}

+
+

+ {isIncome ? '+' : ''} + {formatCurrency(transaction.amount, transaction.currency)} +

+
+ + {/* Category grid */} +
+
+

Kostnader

+
+ {EXPENSE_CATEGORIES.map((cat) => ( + + ))} +
+
+
+

Intäkter

+
+ {INCOME_CATEGORIES.map((cat) => ( + + ))} +
+
+
+
+
+ ) +} diff --git a/components/transactions/InboxZeroState.tsx b/components/transactions/InboxZeroState.tsx new file mode 100644 index 00000000..84a59db2 --- /dev/null +++ b/components/transactions/InboxZeroState.tsx @@ -0,0 +1,69 @@ +'use client' + +import { Button } from '@/components/ui/button' +import { Card, CardContent } from '@/components/ui/card' +import { Check, Upload, Plus } from 'lucide-react' +import Link from 'next/link' + +interface InboxZeroStateProps { + hasTransactions: boolean + onCreateTransaction: () => void +} + +export default function InboxZeroState({ hasTransactions, onCreateTransaction }: InboxZeroStateProps) { + if (!hasTransactions) { + // No transactions at all + return ( + + +
+ +
+

Inga transaktioner

+

+ Importera kontoutdrag från din bank eller lägg till transaktioner manuellt för att komma igång. +

+
+ + +
+
+
+ ) + } + + // All transactions categorized - inbox zero! + return ( + + +
+ +
+

Alla transaktioner bokförda!

+

+ Bra jobbat! Alla dina transaktioner är bokförda. Importera fler eller växla till historik. +

+
+ + +
+
+
+ ) +} diff --git a/components/transactions/InvoiceMatchDialog.tsx b/components/transactions/InvoiceMatchDialog.tsx new file mode 100644 index 00000000..dc224511 --- /dev/null +++ b/components/transactions/InvoiceMatchDialog.tsx @@ -0,0 +1,99 @@ +'use client' + +import { Button } from '@/components/ui/button' +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog' +import { formatCurrency, formatDate } from '@/lib/utils' +import type { TransactionWithInvoice } from './transaction-types' + +interface InvoiceMatchDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + transaction: TransactionWithInvoice | null + isConfirming: boolean + onConfirm: () => void +} + +export default function InvoiceMatchDialog({ + open, + onOpenChange, + transaction, + isConfirming, + onConfirm, +}: InvoiceMatchDialogProps) { + return ( + + + + Bekräfta fakturamatchning + + Vill du koppla denna transaktion till fakturan? Fakturan kommer att markeras som betald. + + + + {transaction?.potential_invoice && ( +
+ {/* Transaction details */} +
+

Transaktion

+

{transaction.description}

+
+ {formatDate(transaction.date)} + + +{formatCurrency(transaction.amount, transaction.currency)} + +
+
+ + {/* Invoice details */} +
+

Faktura

+

+ Faktura {transaction.potential_invoice.invoice_number} +

+

+ {transaction.potential_invoice.customer?.name || 'Okänd kund'} +

+
+ + Förfaller: {formatDate(transaction.potential_invoice.due_date)} + + + {formatCurrency( + transaction.potential_invoice.total, + transaction.potential_invoice.currency + )} + +
+
+ + {/* What will happen */} +
+

Vid bekräftelse:

+
    +
  • • Transaktionen kopplas till fakturan
  • +
  • • Fakturan markeras som betald
  • +
  • • Bokföringsverifikation skapas automatiskt
  • +
+
+
+ )} + + + + + +
+
+ ) +} diff --git a/components/transactions/SwipeCategorizationView.tsx b/components/transactions/SwipeCategorizationView.tsx index 34702892..8d9f634a 100644 --- a/components/transactions/SwipeCategorizationView.tsx +++ b/components/transactions/SwipeCategorizationView.tsx @@ -1,47 +1,32 @@ 'use client' -import { useState, useCallback } from 'react' +import { useState, useCallback, useEffect } from 'react' import { motion, useMotionValue, useTransform, AnimatePresence, type PanInfo } from 'framer-motion' import { Card, CardContent, CardHeader } from '@/components/ui/card' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { formatCurrency, formatDate } from '@/lib/utils' import { checkExpenseWarnings } from '@/lib/tax/expense-warnings' +import { getDefaultAccountForCategory, getDefaultVatTreatmentForCategory } from '@/lib/bookkeeping/category-mapping' +import AccountCombobox from '@/components/bookkeeping/AccountCombobox' import { X, ArrowLeft, ArrowRight, Building, AlertTriangle, Check, FileText, Link2, Receipt as ReceiptIcon, SkipForward } from 'lucide-react' -import type { Transaction, TransactionCategory, Invoice, Customer } from '@/types' +import type { TransactionCategory, VatTreatment, BASAccount } from '@/types' import type { SuggestedCategory } from '@/lib/transactions/category-suggestions' - -interface TransactionWithInvoice extends Transaction { - potential_invoice?: Invoice & { customer?: Customer } -} +import type { TransactionWithInvoice, CategorizeHandler, MatchInvoiceHandler } from './transaction-types' +import { EXPENSE_CATEGORIES, INCOME_CATEGORIES, VAT_TREATMENT_OPTIONS } from './transaction-types' interface SwipeCategorizationViewProps { transactions: TransactionWithInvoice[] suggestions?: Record - onCategorize: (id: string, isBusiness: boolean, category?: TransactionCategory) => Promise - onMatchInvoice?: (transactionId: string, invoiceId: string) => Promise + onCategorize: CategorizeHandler + onMatchInvoice?: MatchInvoiceHandler onClose: () => void } -const expenseCategories: { value: TransactionCategory; label: string }[] = [ - { value: 'expense_equipment', label: 'Utrustning' }, - { value: 'expense_software', label: 'Programvara' }, - { value: 'expense_travel', label: 'Resor' }, - { value: 'expense_office', label: 'Kontor' }, - { value: 'expense_marketing', label: 'Marknadsföring' }, - { value: 'expense_professional_services', label: 'Konsulter' }, - { value: 'expense_education', label: 'Utbildning' }, - { value: 'expense_bank_fees', label: 'Bankavgift' }, - { value: 'expense_card_fees', label: 'Kortavgift' }, - { value: 'expense_currency_exchange', label: 'Valutaväxling' }, - { value: 'expense_other', label: 'Övrigt' }, -] - -const incomeCategories: { value: TransactionCategory; label: string }[] = [ - { value: 'income_services', label: 'Tjänster' }, - { value: 'income_products', label: 'Produkter' }, - { value: 'income_other', label: 'Övrigt' }, -] +const vatTreatmentOptions = VAT_TREATMENT_OPTIONS +const expenseCategories = EXPENSE_CATEGORIES +const incomeCategories = INCOME_CATEGORIES export default function SwipeCategorizationView({ transactions, @@ -56,6 +41,29 @@ export default function SwipeCategorizationView({ const [isProcessing, setIsProcessing] = useState(false) const [error, setError] = useState(null) + // Review step state + const [showReviewStep, setShowReviewStep] = useState(false) + const [pendingCategory, setPendingCategory] = useState(null) + const [accountOverride, setAccountOverride] = useState('') + const [vatTreatment, setVatTreatment] = useState('standard_25') + const [accounts, setAccounts] = useState([]) + + // Fetch accounts on mount + useEffect(() => { + async function fetchAccounts() { + try { + const res = await fetch('/api/bookkeeping/accounts') + const data = await res.json() + if (data.accounts) { + setAccounts(data.accounts) + } + } catch { + // Non-critical, AccountCombobox will just be empty + } + } + fetchAccounts() + }, []) + const currentTransaction = transactions[currentIndex] const warnings = currentTransaction ? checkExpenseWarnings(currentTransaction.description) @@ -85,6 +93,19 @@ export default function SwipeCategorizationView({ [isProcessing, x] ) + const handleCategorySelect = useCallback((category: TransactionCategory) => { + // Set up review step with defaults for this category + const defaultAccount = getDefaultAccountForCategory(category) + const defaultVat = getDefaultVatTreatmentForCategory(category) + + setPendingCategory(category) + setAccountOverride(defaultAccount) + setVatTreatment(defaultVat ?? 'none') + setShowCategorySelect(false) + setShowReviewStep(true) + setError(null) + }, []) + const handleDragEnd = useCallback( async (_event: MouseEvent | TouchEvent | PointerEvent, info: PanInfo) => { if (isProcessing || !currentTransaction) return @@ -101,24 +122,9 @@ export default function SwipeCategorizationView({ setShowCategorySelect(true) x.set(0) } else { - setIsProcessing(true) - setError(null) - try { - const success = await onCategorize( - currentTransaction.id, - true, - 'income_other' - ) - if (success) { - moveToNext() - } else { - setError('Kunde inte bokföra. Tryck "Hoppa över" för att gå vidare.') - } - } catch { - setError('Ett fel uppstod. Tryck "Hoppa över" för att gå vidare.') - } finally { - setIsProcessing(false) - } + // Income: go to review step with income_other default + handleCategorySelect('income_other') + x.set(0) } } else { // Swipe left = skip @@ -128,16 +134,32 @@ export default function SwipeCategorizationView({ x.set(0) } }, - [isProcessing, currentTransaction, onCategorize, x, moveToNext] + [isProcessing, currentTransaction, handleCategorySelect, x, moveToNext] ) - const handleCategorySelect = async (category: TransactionCategory) => { + const handleReviewConfirm = async () => { + if (!pendingCategory) return + setIsProcessing(true) setError(null) try { - const success = await onCategorize(currentTransaction.id, true, category) + const resolvedVat = vatTreatment === 'none' ? undefined : vatTreatment + const defaultAccount = getDefaultAccountForCategory(pendingCategory) + // Only send override if it differs from the default + const override = accountOverride && accountOverride !== defaultAccount + ? accountOverride + : undefined + + const success = await onCategorize( + currentTransaction.id, + true, + pendingCategory, + resolvedVat, + override + ) if (success) { - setShowCategorySelect(false) + setShowReviewStep(false) + setPendingCategory(null) moveToNext() } else { setError('Kunde inte bokföra. Tryck "Hoppa över" för att gå vidare.') @@ -174,6 +196,8 @@ export default function SwipeCategorizationView({ const handleSkip = useCallback(() => { setError(null) setShowCategorySelect(false) + setShowReviewStep(false) + setPendingCategory(null) moveToNext() }, [moveToNext]) @@ -253,6 +277,123 @@ export default function SwipeCategorizationView({ ) } + if (showReviewStep && pendingCategory) { + const categoryLabel = [...expenseCategories, ...incomeCategories].find( + (c) => c.value === pendingCategory + )?.label || pendingCategory + + // Auto-clear VAT when a class 2 (liability/equity) account is selected + const isLiabilityAccount = accountOverride.startsWith('2') + + return ( +
+
+ +

Granska bokföring

+
+
+ +
+ {/* Transaction summary */} + + +

{currentTransaction.description}

+

{formatDate(currentTransaction.date)}

+

+ {currentTransaction.amount > 0 ? '+' : ''} + {formatCurrency(currentTransaction.amount, currentTransaction.currency)} +

+
+
+ + {/* Selected category */} +
+ +
+ {categoryLabel} +
+
+ + {/* Account override */} +
+ +
+ +
+
+ + {/* VAT treatment */} +
+ +
+ + {isLiabilityAccount && ( +

+ Ingen moms för skuld-/eget kapital-konton +

+ )} +
+
+ + {error && ( +
+ {error} +
+ )} +
+ + {/* Actions */} +
+ + +
+
+ ) + } + return (
{/* Header */} diff --git a/components/transactions/TransactionHistoryList.tsx b/components/transactions/TransactionHistoryList.tsx new file mode 100644 index 00000000..f9bff5e6 --- /dev/null +++ b/components/transactions/TransactionHistoryList.tsx @@ -0,0 +1,181 @@ +'use client' + +import { useState } from 'react' +import { Card, CardContent } from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { formatCurrency, formatDate } from '@/lib/utils' +import { getCategoryDisplayName } from '@/lib/tax/expense-warnings' +import { Search, ArrowUpRight, ArrowDownRight, ArrowLeftRight, Check, Link2, FileText } from 'lucide-react' +import type { TransactionWithInvoice } from './transaction-types' +import type { HistoryFilter } from './transaction-types' + +interface TransactionHistoryListProps { + transactions: TransactionWithInvoice[] + onOpenMatchDialog: (transaction: TransactionWithInvoice) => void +} + +export default function TransactionHistoryList({ + transactions, + onOpenMatchDialog, +}: TransactionHistoryListProps) { + const [searchTerm, setSearchTerm] = useState('') + const [filter, setFilter] = useState('all') + + const filtered = transactions.filter((t) => { + const matchesSearch = t.description.toLowerCase().includes(searchTerm.toLowerCase()) + const matchesFilter = + filter === 'all' || + (filter === 'business' && t.is_business === true) || + (filter === 'private' && t.is_business === false) + return matchesSearch && matchesFilter + }) + + return ( +
+ {/* Search + filter pills */} +
+
+ + setSearchTerm(e.target.value)} + className="pl-10" + /> +
+
+ {(['all', 'business', 'private'] as const).map((f) => ( + + ))} +
+
+ + {/* Transaction list */} + {filtered.length === 0 ? ( + + + +

Inga transaktioner

+

+ {searchTerm + ? 'Inga transaktioner matchar din sökning' + : 'Inga transaktioner att visa med valt filter'} +

+
+
+ ) : ( +
+ {filtered.map((transaction) => ( + + +
+
+
0 + ? 'bg-success/10 text-success' + : 'bg-destructive/10 text-destructive' + }`} + > + {transaction.amount > 0 ? ( + + ) : ( + + )} +
+
+

{transaction.description}

+
+ {formatDate(transaction.date)} + {transaction.is_business !== null && + !( + transaction.is_business && + transaction.category === 'uncategorized' && + transaction.journal_entry_id + ) && ( + <> + · + + {transaction.is_business + ? getCategoryDisplayName(transaction.category) + : 'Privat'} + + + )} + {transaction.invoice_id && ( + <> + · + + + Kopplad till faktura + + + )} + {transaction.journal_entry_id ? ( + <> + · + + + Bokförd + + + ) : transaction.is_business === null ? ( + <> + · + + Ej bokförd + + + ) : null} + {transaction.potential_invoice && !transaction.invoice_id && ( + <> + · + onOpenMatchDialog(transaction)} + > + + Möjlig match: Faktura {transaction.potential_invoice.invoice_number} + + + )} +
+
+
+
+

0 ? 'text-success' : '' + }`} + > + {transaction.amount > 0 ? '+' : ''} + {formatCurrency(transaction.amount, transaction.currency)} +

+ {transaction.currency !== 'SEK' && transaction.amount_sek && ( +

+ {formatCurrency(transaction.amount_sek)} +

+ )} +
+
+
+
+ ))} +
+ )} +
+ ) +} diff --git a/components/transactions/TransactionInboxCard.tsx b/components/transactions/TransactionInboxCard.tsx new file mode 100644 index 00000000..ad013efa --- /dev/null +++ b/components/transactions/TransactionInboxCard.tsx @@ -0,0 +1,197 @@ +'use client' + +import { motion } from 'framer-motion' +import { Card, CardContent } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { Checkbox } from '@/components/ui/checkbox' +import { formatCurrency, formatDate } from '@/lib/utils' +import { ArrowUpRight, ArrowDownRight, FileText, MoreHorizontal, Loader2 } from 'lucide-react' +import type { TransactionWithInvoice, CategorizeHandler } from './transaction-types' +import type { SuggestedCategory } from '@/lib/transactions/category-suggestions' + +interface TransactionInboxCardProps { + transaction: TransactionWithInvoice + suggestions?: SuggestedCategory[] + processingId: string | null + isBatchMode: boolean + isSelected: boolean + onCategorize: CategorizeHandler + onMarkPrivate: (id: string) => void + onOpenMatchDialog: (transaction: TransactionWithInvoice) => void + onOpenCategoryDialog: (transaction: TransactionWithInvoice) => void + onToggleSelect: (id: string) => void + onAnimationComplete?: (id: string) => void +} + +export default function TransactionInboxCard({ + transaction, + suggestions, + processingId, + isBatchMode, + isSelected, + onCategorize, + onMarkPrivate, + onOpenMatchDialog, + onOpenCategoryDialog, + onToggleSelect, + onAnimationComplete, +}: TransactionInboxCardProps) { + const isProcessing = processingId === transaction.id + const isDisabled = processingId !== null && processingId !== transaction.id + const isIncome = transaction.amount > 0 + const hasInvoiceMatch = !!transaction.potential_invoice && !transaction.invoice_id + const topSuggestion = suggestions?.[0] + const isUncategorized = transaction.is_business === null && !transaction.journal_entry_id + const showCheckbox = isBatchMode && isUncategorized + + async function handleSuggestionClick(suggestion: SuggestedCategory) { + await onCategorize(transaction.id, true, suggestion.category) + } + + return ( + { + // Only call on exit animation + if (typeof definition === 'object' && 'opacity' in definition && definition.opacity === 0) { + onAnimationComplete?.(transaction.id) + } + }} + > + onToggleSelect(transaction.id) : undefined} + > + +
+ {/* Left: checkbox + icon + info */} +
+ {showCheckbox && ( + onToggleSelect(transaction.id)} + onClick={(e) => e.stopPropagation()} + className="mt-1" + /> + )} +
+ {isIncome ? ( + + ) : ( + + )} +
+
+

{transaction.description}

+

{formatDate(transaction.date)}

+
+
+ + {/* Right: amount */} +
+

+ {isIncome ? '+' : ''} + {formatCurrency(transaction.amount, transaction.currency)} +

+ {transaction.currency !== 'SEK' && transaction.amount_sek && ( +

+ {formatCurrency(transaction.amount_sek)} +

+ )} +
+
+ + {/* Inline action buttons - only shown when not in batch mode */} + {!isBatchMode && ( +
+ {/* Primary action: invoice match or top suggestion */} + {hasInvoiceMatch ? ( + + ) : topSuggestion ? ( + + ) : null} + + {/* Secondary suggestions (up to 1 more) */} + {!hasInvoiceMatch && suggestions && suggestions.length > 1 && ( + + )} + + {/* Private button */} + + + {/* More options */} + +
+ )} +
+
+
+ ) +} diff --git a/components/transactions/TransactionStatusBar.tsx b/components/transactions/TransactionStatusBar.tsx new file mode 100644 index 00000000..056efb38 --- /dev/null +++ b/components/transactions/TransactionStatusBar.tsx @@ -0,0 +1,118 @@ +'use client' + +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import Link from 'next/link' +import { Upload, Sparkles, Plus, CheckSquare, FileText } from 'lucide-react' +import type { ViewMode } from './transaction-types' + +interface TransactionStatusBarProps { + uncategorizedCount: number + invoiceMatchCount: number + mode: ViewMode + onModeChange: (mode: ViewMode) => void + onOpenSwipeView: () => void + onOpenCreateDialog: () => void + isLoadingSuggestions: boolean + isBatchMode: boolean + onToggleBatchMode: () => void +} + +export default function TransactionStatusBar({ + uncategorizedCount, + invoiceMatchCount, + mode, + onModeChange, + onOpenSwipeView, + onOpenCreateDialog, + isLoadingSuggestions, + isBatchMode, + onToggleBatchMode, +}: TransactionStatusBarProps) { + return ( +
+ {/* Header with title + actions */} +
+
+

Transaktioner

+ {uncategorizedCount > 0 && mode === 'inbox' && ( +

+ {uncategorizedCount} att bokföra + {invoiceMatchCount > 0 && ( + + · {' '} + {invoiceMatchCount} fakturamatchningar + + )} +

+ )} + {mode === 'history' && ( +

Alla dina transaktioner

+ )} +
+ +
+ + {mode === 'inbox' && uncategorizedCount > 0 && ( + <> + + + + )} + +
+
+ + {/* Mode toggle - segmented control style */} +
+ + +
+
+ ) +} diff --git a/components/transactions/transaction-types.ts b/components/transactions/transaction-types.ts new file mode 100644 index 00000000..a4b2f2df --- /dev/null +++ b/components/transactions/transaction-types.ts @@ -0,0 +1,61 @@ +import type { Transaction, TransactionCategory, Invoice, Customer, VatTreatment } from '@/types' + +// Shared transaction type with potential invoice data +export interface TransactionWithInvoice extends Transaction { + potential_invoice?: Invoice & { customer?: Customer } +} + +// Page view modes +export type ViewMode = 'inbox' | 'history' +export type HistoryFilter = 'all' | 'business' | 'private' + +// Handler types +export type CategorizeHandler = ( + id: string, + isBusiness: boolean, + category?: TransactionCategory, + vatTreatment?: VatTreatment, + accountOverride?: string +) => Promise + +export type MatchInvoiceHandler = ( + transactionId: string, + invoiceId: string +) => Promise + +// Category option type +export interface CategoryOption { + value: TransactionCategory + label: string +} + +// Shared category arrays +export const EXPENSE_CATEGORIES: CategoryOption[] = [ + { value: 'expense_equipment', label: 'Utrustning' }, + { value: 'expense_software', label: 'Programvara' }, + { value: 'expense_travel', label: 'Resor' }, + { value: 'expense_office', label: 'Kontor' }, + { value: 'expense_marketing', label: 'Marknadsföring' }, + { value: 'expense_professional_services', label: 'Konsulter' }, + { value: 'expense_education', label: 'Utbildning' }, + { value: 'expense_bank_fees', label: 'Bankavgift' }, + { value: 'expense_card_fees', label: 'Kortavgift' }, + { value: 'expense_currency_exchange', label: 'Valutaväxling' }, + { value: 'expense_other', label: 'Övrigt' }, +] + +export const INCOME_CATEGORIES: CategoryOption[] = [ + { value: 'income_services', label: 'Tjänster' }, + { value: 'income_products', label: 'Produkter' }, + { value: 'income_other', label: 'Övrigt' }, +] + +export const VAT_TREATMENT_OPTIONS: { value: VatTreatment | 'none'; label: string }[] = [ + { value: 'standard_25', label: 'Moms 25%' }, + { value: 'reduced_12', label: 'Moms 12%' }, + { value: 'reduced_6', label: 'Moms 6%' }, + { value: 'reverse_charge', label: 'Omvänd skattskyldighet' }, + { value: 'export', label: 'Export' }, + { value: 'exempt', label: 'Momsfri' }, + { value: 'none', label: 'Ingen moms' }, +] diff --git a/extensions/general/invoice-inbox/__tests__/index.test.ts b/extensions/general/invoice-inbox/__tests__/index.test.ts new file mode 100644 index 00000000..b4a318a2 --- /dev/null +++ b/extensions/general/invoice-inbox/__tests__/index.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { eventBus } from '@/lib/events/bus' +import { createMockSupabase } from '@/tests/helpers' + +// Mock dependencies +vi.mock('@/lib/supabase/server', () => ({ + createClient: vi.fn(), +})) + +vi.mock('../lib/invoice-analyzer', () => ({ + analyzeInvoice: vi.fn(), +})) + +vi.mock('../lib/supplier-matcher', () => ({ + matchSupplier: vi.fn(), +})) + +import { createClient } from '@/lib/supabase/server' +import { invoiceInboxExtension, getSettings, saveSettings } from '../index' + +const mockCreateClient = vi.mocked(createClient) + +describe('Invoice Inbox Extension', () => { + beforeEach(() => { + vi.clearAllMocks() + eventBus.clear() + }) + + describe('Extension metadata', () => { + it('has correct id and version', () => { + expect(invoiceInboxExtension.id).toBe('invoice-inbox') + expect(invoiceInboxExtension.name).toBe('Invoice Inbox') + expect(invoiceInboxExtension.version).toBe('1.0.0') + }) + + it('has event handler for document.uploaded', () => { + expect(invoiceInboxExtension.eventHandlers).toHaveLength(1) + expect(invoiceInboxExtension.eventHandlers![0].eventType).toBe('document.uploaded') + }) + + it('has settings panel', () => { + expect(invoiceInboxExtension.settingsPanel).toEqual({ + label: 'Invoice Inbox', + path: '/settings/extensions/invoice-inbox', + }) + }) + + it('has onInstall hook', () => { + expect(invoiceInboxExtension.onInstall).toBeDefined() + }) + }) + + describe('getSettings', () => { + it('returns default settings when no data exists', async () => { + const { supabase, mockResult } = createMockSupabase() + mockCreateClient.mockResolvedValue(supabase as never) + mockResult({ data: null, error: null }) + + const settings = await getSettings('user-1') + + expect(settings).toEqual({ + autoProcessEnabled: true, + autoMatchSupplierEnabled: true, + supplierMatchThreshold: 0.7, + inboxEmail: null, + }) + }) + + it('merges stored settings with defaults', async () => { + const { supabase, mockResult } = createMockSupabase() + mockCreateClient.mockResolvedValue(supabase as never) + mockResult({ + data: { value: { inboxEmail: 'test@inbox.example.com' } }, + error: null, + }) + + const settings = await getSettings('user-1') + + expect(settings.inboxEmail).toBe('test@inbox.example.com') + expect(settings.autoProcessEnabled).toBe(true) // default + }) + }) + + describe('saveSettings', () => { + it('merges partial settings with current', async () => { + const { supabase, mockResult } = createMockSupabase() + mockCreateClient.mockResolvedValue(supabase as never) + + // First call for getSettings (inside saveSettings) + mockResult({ data: null, error: null }) + + const settings = await saveSettings('user-1', { inboxEmail: 'new@inbox.com' }) + + expect(settings.inboxEmail).toBe('new@inbox.com') + expect(settings.autoProcessEnabled).toBe(true) + }) + }) +}) diff --git a/extensions/general/invoice-inbox/index.ts b/extensions/general/invoice-inbox/index.ts new file mode 100644 index 00000000..b049f95c --- /dev/null +++ b/extensions/general/invoice-inbox/index.ts @@ -0,0 +1,213 @@ +import { createClient } from '@/lib/supabase/server' +import { eventBus } from '@/lib/events/bus' +import { analyzeInvoice } from './lib/invoice-analyzer' +import { matchSupplier } from './lib/supplier-matcher' +import type { Extension } from '@/lib/extensions/types' +import type { EventPayload } from '@/lib/events/types' +import type { InvoiceInboxSettings } from './types' + +// ============================================================ +// Settings +// ============================================================ + +const DEFAULT_SETTINGS: InvoiceInboxSettings = { + autoProcessEnabled: true, + autoMatchSupplierEnabled: true, + supplierMatchThreshold: 0.7, + inboxEmail: null, +} + +export async function getSettings(userId: string): Promise { + const supabase = await createClient() + + const { data } = await supabase + .from('extension_data') + .select('value') + .eq('user_id', userId) + .eq('extension_id', 'invoice-inbox') + .eq('key', 'settings') + .single() + + if (!data?.value) return { ...DEFAULT_SETTINGS } + + return { ...DEFAULT_SETTINGS, ...(data.value as Partial) } +} + +export async function saveSettings( + userId: string, + partial: Partial +): Promise { + const current = await getSettings(userId) + const merged = { ...current, ...partial } + + const supabase = await createClient() + + await supabase + .from('extension_data') + .upsert( + { + user_id: userId, + extension_id: 'invoice-inbox', + key: 'settings', + value: merged, + }, + { onConflict: 'user_id,extension_id,key' } + ) + + return merged +} + +// ============================================================ +// Event Handlers +// ============================================================ + +const INVOICE_MIME_TYPES = [ + 'application/pdf', + 'image/jpeg', + 'image/png', + 'image/webp', +] + +/** + * When a PDF/image is uploaded via the document archive, check if it should + * be auto-processed as a supplier invoice. + */ +async function handleDocumentUploaded( + payload: EventPayload<'document.uploaded'> +): Promise { + const { document, userId } = payload + + // Gate: Is it a supported file type? + if (!document.mime_type || !INVOICE_MIME_TYPES.includes(document.mime_type)) { + return + } + + // Gate: Is autoProcessEnabled? + const settings = await getSettings(userId) + if (!settings.autoProcessEnabled) { + return + } + + // Gate: Was this document already processed as an inbox item? + const supabase = await createClient() + const { data: existing } = await supabase + .from('invoice_inbox_items') + .select('id') + .eq('user_id', userId) + .eq('document_id', document.id) + .limit(1) + + if (existing && existing.length > 0) { + return + } + + console.log(`[invoice-inbox] Auto-process triggered for document ${document.id}`) + + try { + // Create inbox item + const { data: inboxItem, error: insertError } = await supabase + .from('invoice_inbox_items') + .insert({ + user_id: userId, + status: 'processing', + source: 'upload', + document_id: document.id, + }) + .select() + .single() + + if (insertError || !inboxItem) { + console.error('[invoice-inbox] Failed to create inbox item:', insertError) + return + } + + // Download file from storage + const { data: fileData, error: downloadError } = await supabase.storage + .from('documents') + .download(document.storage_path) + + if (downloadError || !fileData) { + await supabase + .from('invoice_inbox_items') + .update({ status: 'error', error_message: 'Failed to download document' }) + .eq('id', inboxItem.id) + return + } + + // Convert to base64 + const arrayBuffer = await fileData.arrayBuffer() + const base64 = Buffer.from(arrayBuffer).toString('base64') + + // Analyze invoice + const extraction = await analyzeInvoice(base64, document.mime_type) + + // Supplier matching + let matchedSupplierId: string | null = null + if (settings.autoMatchSupplierEnabled) { + const { data: suppliers } = await supabase + .from('suppliers') + .select('*') + .eq('user_id', userId) + + if (suppliers && suppliers.length > 0) { + const match = matchSupplier(extraction, suppliers) + if (match && match.confidence >= settings.supplierMatchThreshold) { + matchedSupplierId = match.supplierId + } + } + } + + // Update inbox item with extracted data + await supabase + .from('invoice_inbox_items') + .update({ + status: 'ready', + extracted_data: extraction as unknown as Record, + confidence: extraction.confidence, + matched_supplier_id: matchedSupplierId, + }) + .eq('id', inboxItem.id) + + // Fetch updated item + const { data: updatedItem } = await supabase + .from('invoice_inbox_items') + .select('*') + .eq('id', inboxItem.id) + .single() + + if (updatedItem) { + await eventBus.emit({ + type: 'supplier_invoice.extracted', + payload: { + inboxItem: updatedItem, + confidence: extraction.confidence, + userId, + }, + }) + } + + console.log(`[invoice-inbox] Invoice ${inboxItem.id} processed (confidence: ${extraction.confidence})`) + } catch (error) { + console.error('[invoice-inbox] handleDocumentUploaded failed:', error) + } +} + +// ============================================================ +// Extension Object +// ============================================================ + +export const invoiceInboxExtension: Extension = { + id: 'invoice-inbox', + name: 'Invoice Inbox', + version: '1.0.0', + eventHandlers: [ + { eventType: 'document.uploaded', handler: handleDocumentUploaded }, + ], + settingsPanel: { + label: 'Invoice Inbox', + path: '/settings/extensions/invoice-inbox', + }, + async onInstall(ctx) { + await saveSettings(ctx.userId, DEFAULT_SETTINGS) + }, +} diff --git a/extensions/general/invoice-inbox/lib/__tests__/email-handler.test.ts b/extensions/general/invoice-inbox/lib/__tests__/email-handler.test.ts new file mode 100644 index 00000000..37565c85 --- /dev/null +++ b/extensions/general/invoice-inbox/lib/__tests__/email-handler.test.ts @@ -0,0 +1,182 @@ +import { describe, it, expect, vi } from 'vitest' + +// Mock server-only +vi.mock('server-only', () => ({})) + +import { parseInboundPayload, extractAttachments, resolveUserFromEmail } from '../email-handler' +import type { ResendInboundPayload } from '../../types' + +describe('Email Handler', () => { + describe('parseInboundPayload', () => { + it('returns null for null input', () => { + expect(parseInboundPayload(null)).toBeNull() + }) + + it('returns null for non-object input', () => { + expect(parseInboundPayload('string')).toBeNull() + }) + + it('returns null when from is missing', () => { + expect(parseInboundPayload({ to: 'test@example.com' })).toBeNull() + }) + + it('returns null when to is missing', () => { + expect(parseInboundPayload({ from: 'test@example.com' })).toBeNull() + }) + + it('parses valid payload', () => { + const result = parseInboundPayload({ + from: 'supplier@example.com', + to: 'inbox@mycompany.com', + subject: 'Faktura F-001', + html: '

Attached

', + text: 'Attached', + attachments: [{ filename: 'invoice.pdf', content_type: 'application/pdf', content: 'base64data' }], + created_at: '2024-06-15T10:00:00Z', + }) + + expect(result).not.toBeNull() + expect(result!.from).toBe('supplier@example.com') + expect(result!.to).toBe('inbox@mycompany.com') + expect(result!.subject).toBe('Faktura F-001') + expect(result!.attachments).toHaveLength(1) + }) + + it('handles missing optional fields', () => { + const result = parseInboundPayload({ + from: 'a@b.com', + to: 'c@d.com', + }) + + expect(result).not.toBeNull() + expect(result!.subject).toBe('') + expect(result!.html).toBeNull() + expect(result!.text).toBeNull() + expect(result!.attachments).toEqual([]) + }) + }) + + describe('extractAttachments', () => { + it('filters to supported file types only', () => { + const payload: ResendInboundPayload = { + from: 'a@b.com', + to: 'c@d.com', + subject: 'Test', + html: null, + text: null, + created_at: '2024-06-15T10:00:00Z', + attachments: [ + { filename: 'invoice.pdf', content_type: 'application/pdf', content: 'base64' }, + { filename: 'photo.jpg', content_type: 'image/jpeg', content: 'base64' }, + { filename: 'doc.docx', content_type: 'application/vnd.openxmlformats', content: 'base64' }, + { filename: 'sheet.xlsx', content_type: 'application/vnd.ms-excel', content: 'base64' }, + { filename: 'scan.png', content_type: 'image/png', content: 'base64' }, + ], + } + + const result = extractAttachments(payload) + expect(result).toHaveLength(3) + expect(result.map(a => a.content_type)).toEqual([ + 'application/pdf', + 'image/jpeg', + 'image/png', + ]) + }) + + it('filters out attachments without content', () => { + const payload: ResendInboundPayload = { + from: 'a@b.com', + to: 'c@d.com', + subject: 'Test', + html: null, + text: null, + created_at: '2024-06-15T10:00:00Z', + attachments: [ + { filename: 'invoice.pdf', content_type: 'application/pdf', content: '' }, + { filename: 'photo.jpg', content_type: 'image/jpeg', content: 'base64data' }, + ], + } + + const result = extractAttachments(payload) + expect(result).toHaveLength(1) + }) + }) + + describe('resolveUserFromEmail', () => { + it('returns null when no extension data found', async () => { + const mockClient = { + from: vi.fn().mockReturnValue({ + select: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + eq: vi.fn().mockResolvedValue({ data: null, error: { message: 'not found' } }), + }), + }), + }), + } + + const result = await resolveUserFromEmail('test@inbox.com', mockClient) + expect(result).toBeNull() + }) + + it('returns user_id when email matches', async () => { + const mockClient = { + from: vi.fn().mockReturnValue({ + select: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + eq: vi.fn().mockResolvedValue({ + data: [ + { user_id: 'user-1', value: { inboxEmail: 'test@inbox.com' } }, + { user_id: 'user-2', value: { inboxEmail: 'other@inbox.com' } }, + ], + error: null, + }), + }), + }), + }), + } + + const result = await resolveUserFromEmail('test@inbox.com', mockClient) + expect(result).toBe('user-1') + }) + + it('handles case-insensitive email matching', async () => { + const mockClient = { + from: vi.fn().mockReturnValue({ + select: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + eq: vi.fn().mockResolvedValue({ + data: [ + { user_id: 'user-1', value: { inboxEmail: 'Test@Inbox.Com' } }, + ], + error: null, + }), + }), + }), + }), + } + + const result = await resolveUserFromEmail('test@inbox.com', mockClient) + expect(result).toBe('user-1') + }) + + it('returns null when no matching email', async () => { + const mockClient = { + from: vi.fn().mockReturnValue({ + select: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + eq: vi.fn().mockResolvedValue({ + data: [ + { user_id: 'user-1', value: { inboxEmail: 'other@inbox.com' } }, + ], + error: null, + }), + }), + }), + }), + } + + const result = await resolveUserFromEmail('notfound@inbox.com', mockClient) + expect(result).toBeNull() + }) + }) +}) diff --git a/extensions/general/invoice-inbox/lib/__tests__/invoice-analyzer.test.ts b/extensions/general/invoice-inbox/lib/__tests__/invoice-analyzer.test.ts new file mode 100644 index 00000000..d572bc77 --- /dev/null +++ b/extensions/general/invoice-inbox/lib/__tests__/invoice-analyzer.test.ts @@ -0,0 +1,187 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +// Mock server-only +vi.mock('server-only', () => ({})) + +// Mock Anthropic SDK - vi.hoisted ensures the variable is available before vi.mock hoisting +const { mockCreate } = vi.hoisted(() => { + const mockCreate = vi.fn() + return { mockCreate } +}) + +vi.mock('@anthropic-ai/sdk', () => { + return { + default: class MockAnthropic { + messages = { create: mockCreate } + }, + } +}) + +import { analyzeInvoice } from '../invoice-analyzer' + +describe('Invoice Analyzer', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + const validExtractionJson = JSON.stringify({ + supplier: { + name: 'Kontorsbolaget AB', + orgNumber: '556123-4567', + vatNumber: 'SE5561234567', + address: 'Storgatan 1, 111 22 Stockholm', + bankgiro: '123-4567', + plusgiro: null, + }, + invoice: { + invoiceNumber: 'F-2024-001', + invoiceDate: '2024-06-15', + dueDate: '2024-07-15', + paymentReference: '1234567890', + currency: 'SEK', + }, + lineItems: [ + { + description: 'Kontorsmaterial', + quantity: 10, + unitPrice: 50, + lineTotal: 500, + vatRate: 25, + accountSuggestion: '6100', + }, + ], + totals: { + subtotal: 500, + vatAmount: 125, + total: 625, + }, + vatBreakdown: [ + { rate: 25, base: 500, amount: 125 }, + ], + confidence: 0.92, + }) + + it('parses valid AI response for PDF', async () => { + mockCreate.mockResolvedValue({ + content: [{ type: 'text', text: validExtractionJson }], + }) + + const result = await analyzeInvoice('base64data', 'application/pdf') + + expect(result.supplier.name).toBe('Kontorsbolaget AB') + expect(result.supplier.orgNumber).toBe('556123-4567') + expect(result.invoice.invoiceNumber).toBe('F-2024-001') + expect(result.lineItems).toHaveLength(1) + expect(result.lineItems[0].lineTotal).toBe(500) + expect(result.totals.total).toBe(625) + expect(result.confidence).toBe(0.92) + }) + + it('parses valid AI response for image', async () => { + mockCreate.mockResolvedValue({ + content: [{ type: 'text', text: validExtractionJson }], + }) + + const result = await analyzeInvoice('base64data', 'image/jpeg') + + expect(result.supplier.name).toBe('Kontorsbolaget AB') + expect(mockCreate).toHaveBeenCalledTimes(1) + }) + + it('strips markdown code blocks from response', async () => { + mockCreate.mockResolvedValue({ + content: [{ type: 'text', text: '```json\n' + validExtractionJson + '\n```' }], + }) + + const result = await analyzeInvoice('base64data', 'application/pdf') + expect(result.supplier.name).toBe('Kontorsbolaget AB') + }) + + it('validates org number format', async () => { + mockCreate.mockResolvedValue({ + content: [{ type: 'text', text: JSON.stringify({ + supplier: { name: 'Test', orgNumber: '5561234567', vatNumber: null, address: null, bankgiro: null, plusgiro: null }, + invoice: { invoiceNumber: null, invoiceDate: null, dueDate: null, paymentReference: null, currency: 'SEK' }, + lineItems: [], + totals: { subtotal: 0, vatAmount: 0, total: 0 }, + vatBreakdown: [], + confidence: 0.5, + }) }], + }) + + const result = await analyzeInvoice('base64data', 'application/pdf') + expect(result.supplier.orgNumber).toBe('556123-4567') // Formatted with dash + }) + + it('rejects invalid VAT numbers', async () => { + mockCreate.mockResolvedValue({ + content: [{ type: 'text', text: JSON.stringify({ + supplier: { name: 'Test', orgNumber: null, vatNumber: 'DE123', address: null, bankgiro: null, plusgiro: null }, + invoice: { invoiceNumber: null, invoiceDate: null, dueDate: null, paymentReference: null, currency: 'SEK' }, + lineItems: [], + totals: { subtotal: 0, vatAmount: 0, total: 0 }, + vatBreakdown: [], + confidence: 0.5, + }) }], + }) + + const result = await analyzeInvoice('base64data', 'application/pdf') + expect(result.supplier.vatNumber).toBeNull() // Not SE-prefixed + }) + + it('throws on JSON parse error without retrying', async () => { + mockCreate.mockResolvedValue({ + content: [{ type: 'text', text: 'not json at all' }], + }) + + await expect(analyzeInvoice('base64data', 'application/pdf')).rejects.toThrow('Failed to parse AI response') + expect(mockCreate).toHaveBeenCalledTimes(1) // No retry for parse errors + }) + + it('retries on API errors', async () => { + mockCreate + .mockRejectedValueOnce(new Error('API timeout')) + .mockResolvedValueOnce({ + content: [{ type: 'text', text: validExtractionJson }], + }) + + const result = await analyzeInvoice('base64data', 'application/pdf') + expect(result.supplier.name).toBe('Kontorsbolaget AB') + expect(mockCreate).toHaveBeenCalledTimes(2) + }) + + it('throws after max retries', async () => { + mockCreate.mockRejectedValue(new Error('API timeout')) + + await expect(analyzeInvoice('base64data', 'application/pdf')).rejects.toThrow( + 'Invoice analysis failed after 3 attempts' + ) + expect(mockCreate).toHaveBeenCalledTimes(3) + }) + + it('rejects unsupported file types', async () => { + await expect(analyzeInvoice('base64data', 'text/plain')).rejects.toThrow( + 'Unsupported file type' + ) + }) + + it('validates account number suggestions', async () => { + mockCreate.mockResolvedValue({ + content: [{ type: 'text', text: JSON.stringify({ + supplier: { name: 'Test', orgNumber: null, vatNumber: null, address: null, bankgiro: null, plusgiro: null }, + invoice: { invoiceNumber: null, invoiceDate: null, dueDate: null, paymentReference: null, currency: 'SEK' }, + lineItems: [ + { description: 'Item', quantity: 1, unitPrice: 100, lineTotal: 100, vatRate: 25, accountSuggestion: '6100' }, + { description: 'Bad', quantity: 1, unitPrice: 50, lineTotal: 50, vatRate: 25, accountSuggestion: 'abc' }, + ], + totals: { subtotal: 150, vatAmount: 37.5, total: 187.5 }, + vatBreakdown: [], + confidence: 0.8, + }) }], + }) + + const result = await analyzeInvoice('base64data', 'application/pdf') + expect(result.lineItems[0].accountSuggestion).toBe('6100') + expect(result.lineItems[1].accountSuggestion).toBeNull() + }) +}) diff --git a/extensions/general/invoice-inbox/lib/__tests__/supplier-matcher.test.ts b/extensions/general/invoice-inbox/lib/__tests__/supplier-matcher.test.ts new file mode 100644 index 00000000..eef24218 --- /dev/null +++ b/extensions/general/invoice-inbox/lib/__tests__/supplier-matcher.test.ts @@ -0,0 +1,240 @@ +import { describe, it, expect } from 'vitest' +import { + matchSupplier, + normalizeOrgNumber, + normalizeVatNumber, + normalizeBankgiro, + calculateNameSimilarity, + normalizeCompanyName, + levenshteinDistance, +} from '../supplier-matcher' +import { makeSupplier } from '@/tests/helpers' +import type { InvoiceExtractionResult } from '../../types' + +function makeExtraction(overrides: Partial = {}): InvoiceExtractionResult { + return { + supplier: { + name: null, + orgNumber: null, + vatNumber: null, + address: null, + bankgiro: null, + plusgiro: null, + ...overrides, + }, + invoice: { + invoiceNumber: null, + invoiceDate: null, + dueDate: null, + paymentReference: null, + currency: 'SEK', + }, + lineItems: [], + totals: { subtotal: null, vatAmount: null, total: null }, + vatBreakdown: [], + confidence: 0.9, + } +} + +describe('Supplier Matcher', () => { + describe('matchSupplier', () => { + it('returns null for empty supplier list', () => { + const result = matchSupplier( + makeExtraction({ name: 'Test AB' }), + [] + ) + expect(result).toBeNull() + }) + + it('matches by exact org number (pass 1)', () => { + const suppliers = [ + makeSupplier({ id: 's1', name: 'Supplier A', org_number: '5599887766' }), + makeSupplier({ id: 's2', name: 'Supplier B', org_number: '1122334455' }), + ] + + const result = matchSupplier( + makeExtraction({ orgNumber: '559988-7766' }), + suppliers + ) + + expect(result).not.toBeNull() + expect(result!.supplierId).toBe('s1') + expect(result!.matchMethod).toBe('org_number') + expect(result!.confidence).toBe(0.98) + }) + + it('matches by org number with different formatting', () => { + const suppliers = [ + makeSupplier({ id: 's1', org_number: '556123-4567' }), + ] + + const result = matchSupplier( + makeExtraction({ orgNumber: '5561234567' }), + suppliers + ) + + expect(result).not.toBeNull() + expect(result!.matchMethod).toBe('org_number') + }) + + it('matches by VAT number (pass 2)', () => { + const suppliers = [ + makeSupplier({ id: 's1', vat_number: 'SE556123456701' }), + ] + + const result = matchSupplier( + makeExtraction({ vatNumber: 'SE 5561 2345 6701' }), + suppliers + ) + + expect(result).not.toBeNull() + expect(result!.matchMethod).toBe('vat_number') + expect(result!.confidence).toBe(0.95) + }) + + it('matches by bankgiro (pass 3)', () => { + const suppliers = [ + makeSupplier({ id: 's1', bankgiro: '123-4567' }), + ] + + const result = matchSupplier( + makeExtraction({ bankgiro: '1234567' }), + suppliers + ) + + expect(result).not.toBeNull() + expect(result!.matchMethod).toBe('bankgiro') + expect(result!.confidence).toBe(0.92) + }) + + it('matches by plusgiro', () => { + const suppliers = [ + makeSupplier({ id: 's1', plusgiro: '123456-7' }), + ] + + const result = matchSupplier( + makeExtraction({ plusgiro: '1234567' }), + suppliers + ) + + expect(result).not.toBeNull() + expect(result!.matchMethod).toBe('bankgiro') + }) + + it('matches by fuzzy name (pass 4)', () => { + const suppliers = [ + makeSupplier({ id: 's1', name: 'Kontorsbolaget AB' }), + makeSupplier({ id: 's2', name: 'Byggmaterial i Stockholm' }), + ] + + const result = matchSupplier( + makeExtraction({ name: 'Kontorsbolaget' }), + suppliers + ) + + expect(result).not.toBeNull() + expect(result!.supplierId).toBe('s1') + expect(result!.matchMethod).toBe('fuzzy_name') + }) + + it('returns null for low-confidence fuzzy name match', () => { + const suppliers = [ + makeSupplier({ id: 's1', name: 'Completely Different Name AB' }), + ] + + const result = matchSupplier( + makeExtraction({ name: 'XYZ Corp' }), + suppliers + ) + + expect(result).toBeNull() + }) + + it('prefers org number match over name match', () => { + const suppliers = [ + makeSupplier({ id: 's1', name: 'Kontorsbolaget AB', org_number: '5599887766' }), + ] + + const result = matchSupplier( + makeExtraction({ name: 'Kontorsbolaget', orgNumber: '559988-7766' }), + suppliers + ) + + expect(result!.matchMethod).toBe('org_number') + }) + }) + + describe('normalizeOrgNumber', () => { + it('strips non-digits', () => { + expect(normalizeOrgNumber('556123-4567')).toBe('5561234567') + expect(normalizeOrgNumber('556123 4567')).toBe('5561234567') + }) + }) + + describe('normalizeVatNumber', () => { + it('uppercases and removes spaces', () => { + expect(normalizeVatNumber('se 5561234567 01')).toBe('SE556123456701') + }) + }) + + describe('normalizeBankgiro', () => { + it('strips non-digits', () => { + expect(normalizeBankgiro('123-4567')).toBe('1234567') + }) + }) + + describe('normalizeCompanyName', () => { + it('strips AB suffix', () => { + expect(normalizeCompanyName('Kontorsbolaget AB')).toBe('kontorsbolaget') + }) + + it('strips HB suffix', () => { + expect(normalizeCompanyName('Bröderna Svensson HB')).toBe('bröderna svensson') + }) + + it('strips Aktiebolag', () => { + expect(normalizeCompanyName('Test Aktiebolag')).toBe('test') + }) + + it('strips Enskild firma', () => { + expect(normalizeCompanyName('Test Enskild firma')).toBe('test') + }) + + it('normalizes whitespace', () => { + expect(normalizeCompanyName(' Multiple Spaces ')).toBe('multiple spaces') + }) + }) + + describe('calculateNameSimilarity', () => { + it('returns 1 for identical names', () => { + expect(calculateNameSimilarity('Test AB', 'Test AB')).toBe(1) + }) + + it('returns high score when one contains the other', () => { + // After normalization 'AB' is stripped, so they become identical → 1.0 + expect(calculateNameSimilarity('Kontorsbolaget', 'Kontorsbolaget AB')).toBe(1) + // With an actual substring relationship (not suffix stripping): + expect(calculateNameSimilarity('Kontor', 'Kontorsbolaget')).toBe(0.9) + }) + + it('returns 0 for empty strings', () => { + expect(calculateNameSimilarity('', 'Test')).toBe(0) + expect(calculateNameSimilarity('Test', '')).toBe(0) + }) + }) + + describe('levenshteinDistance', () => { + it('returns 0 for identical strings', () => { + expect(levenshteinDistance('test', 'test')).toBe(0) + }) + + it('calculates correct distance', () => { + expect(levenshteinDistance('kitten', 'sitting')).toBe(3) + }) + + it('handles empty strings', () => { + expect(levenshteinDistance('', 'abc')).toBe(3) + expect(levenshteinDistance('abc', '')).toBe(3) + }) + }) +}) diff --git a/extensions/general/invoice-inbox/lib/email-handler.ts b/extensions/general/invoice-inbox/lib/email-handler.ts new file mode 100644 index 00000000..b8fb83cf --- /dev/null +++ b/extensions/general/invoice-inbox/lib/email-handler.ts @@ -0,0 +1,83 @@ +/** + * Email Handler - Parse Resend inbound webhook payloads + * + * SERVER-ONLY: Uses service role client for cross-user lookups. + */ + +import 'server-only' +import type { ResendInboundPayload, ResendAttachment } from '../types' + +const SUPPORTED_MIME_TYPES = [ + 'application/pdf', + 'image/jpeg', + 'image/png', + 'image/webp', +] + +/** + * Parse and validate a Resend inbound webhook payload + */ +export function parseInboundPayload(body: unknown): ResendInboundPayload | null { + if (!body || typeof body !== 'object') return null + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const data = body as any + + if (!data.from || !data.to) return null + + return { + from: String(data.from), + to: String(data.to), + subject: data.subject ? String(data.subject) : '', + html: data.html || null, + text: data.text || null, + attachments: Array.isArray(data.attachments) ? data.attachments : [], + created_at: data.created_at || new Date().toISOString(), + } +} + +/** + * Extract supported file attachments from the payload. + * Returns only PDF and image attachments. + */ +export function extractAttachments(payload: ResendInboundPayload): ResendAttachment[] { + return payload.attachments.filter( + (att) => att.content_type && SUPPORTED_MIME_TYPES.includes(att.content_type) && att.content + ) +} + +/** + * Resolve user_id from the recipient email address. + * Looks up the extension_data table where users store their inbox email setting. + * + * Uses a service role client (passed as parameter) since webhook requests + * don't have user authentication. + */ +export async function resolveUserFromEmail( + recipientEmail: string, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + serviceClient: any +): Promise { + // Extract the local part (before @) to handle address variants + const normalizedEmail = recipientEmail.toLowerCase().trim() + + // Look up in extension_data where invoice-inbox settings store the inbox email + const { data, error } = await serviceClient + .from('extension_data') + .select('user_id, value') + .eq('extension_id', 'invoice-inbox') + .eq('key', 'settings') + + if (error || !data) return null + + // Find the user whose inboxEmail matches the recipient + for (const row of data) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const settings = row.value as any + if (settings?.inboxEmail && settings.inboxEmail.toLowerCase().trim() === normalizedEmail) { + return row.user_id + } + } + + return null +} diff --git a/extensions/general/invoice-inbox/lib/invoice-analyzer.ts b/extensions/general/invoice-inbox/lib/invoice-analyzer.ts new file mode 100644 index 00000000..05c9db78 --- /dev/null +++ b/extensions/general/invoice-inbox/lib/invoice-analyzer.ts @@ -0,0 +1,303 @@ +/** + * Invoice Analyzer using Claude Haiku Vision API + * + * SERVER-ONLY: This module uses the Anthropic SDK and must only be imported + * in server components or API routes. + * + * Analyzes supplier invoice PDFs/images and extracts structured data + * including supplier info, line items, VAT breakdown, and payment details. + */ + +import 'server-only' +import Anthropic from '@anthropic-ai/sdk' +import type { InvoiceExtractionResult, ExtractedInvoiceLineItem, VatBreakdownItem } from '../types' + +const anthropic = new Anthropic() + +const MAX_RETRIES = 3 +const RETRY_DELAY_MS = 1000 + +type ImageMediaType = 'image/jpeg' | 'image/png' | 'image/webp' | 'image/gif' + +/** + * Analyze a supplier invoice using Claude Haiku Vision. + * Supports both PDF (native document support) and images. + */ +export async function analyzeInvoice( + fileBase64: string, + mimeType: string +): Promise { + const systemPrompt = `Du är expert på att extrahera data från svenska leverantörsfakturor. +Din uppgift är att noggrant analysera fakturan och extrahera all relevant information. + +VIKTIGT: +- Extrahera leverantörens organisationsnummer (XXXXXX-XXXX format) +- Extrahera bankgiro och/eller plusgiro +- Extrahera varje fakturaradspost med belopp, moms +- Identifiera momssatser (25%, 12%, 6%, 0%) +- Extrahera OCR-nummer eller betalningsreferens +- Datum ska vara i ISO-format (YYYY-MM-DD) +- Belopp ska vara numeriska värden utan valutasymboler +- Ange konfidenstal (0.0-1.0) för hela extraheringen` + + const userPrompt = `Analysera denna leverantörsfaktura och extrahera strukturerad data. + +Returnera ett JSON-objekt med följande struktur: + +{ + "supplier": { + "name": "Leverantörens namn", + "orgNumber": "XXXXXX-XXXX eller null", + "vatNumber": "SE... eller null", + "address": "Fullständig adress eller null", + "bankgiro": "XXX-XXXX eller null", + "plusgiro": "XXXXXX-X eller null" + }, + "invoice": { + "invoiceNumber": "Fakturanummer", + "invoiceDate": "YYYY-MM-DD", + "dueDate": "YYYY-MM-DD", + "paymentReference": "OCR-nummer eller referens eller null", + "currency": "SEK" + }, + "lineItems": [ + { + "description": "Beskrivning av rad", + "quantity": 1, + "unitPrice": 100.00, + "lineTotal": 100.00, + "vatRate": 25, + "accountSuggestion": "BAS-kontonummer som 5410 eller null" + } + ], + "totals": { + "subtotal": 100.00, + "vatAmount": 25.00, + "total": 125.00 + }, + "vatBreakdown": [ + { + "rate": 25, + "base": 100.00, + "amount": 25.00 + } + ], + "confidence": 0.95 +} + +KONTOKATEGORIER (BAS): +- 4000-4999: Varuinköp, material +- 5010: Lokalhyra +- 5410: Förbrukningsinventarier +- 5420: Programvaror +- 5800-5899: Resekostnader +- 6100-6199: Kontorsmaterial +- 6200-6299: Telefon, internet +- 6310: Företagsförsäkringar +- 6530: Redovisningstjänster +- 6570: Bankkostnader + +Returnera ENDAST JSON-objektet, ingen annan text.` + + let lastError: Error | null = null + + for (let attempt = 0; attempt < MAX_RETRIES; attempt++) { + try { + // Build content based on mime type + const isPdf = mimeType === 'application/pdf' + const isImage = mimeType.startsWith('image/') + + if (!isPdf && !isImage) { + throw new Error(`Unsupported file type: ${mimeType}`) + } + + const contentBlocks: Anthropic.MessageCreateParams['messages'][0]['content'] = isPdf + ? [ + { + type: 'document' as const, + source: { + type: 'base64' as const, + media_type: 'application/pdf' as const, + data: fileBase64, + }, + }, + { type: 'text' as const, text: userPrompt }, + ] + : [ + { + type: 'image' as const, + source: { + type: 'base64' as const, + media_type: mimeType as ImageMediaType, + data: fileBase64, + }, + }, + { type: 'text' as const, text: userPrompt }, + ] + + const message = await anthropic.messages.create({ + model: 'claude-haiku-4-5-20251001', + max_tokens: 4096, + messages: [{ role: 'user', content: contentBlocks }], + system: systemPrompt, + }) + + const content = message.content[0] + if (content.type !== 'text') { + throw new Error('Unexpected response type from AI') + } + + let jsonText = content.text.trim() + if (jsonText.startsWith('```json')) { + jsonText = jsonText.slice(7) + } else if (jsonText.startsWith('```')) { + jsonText = jsonText.slice(3) + } + if (jsonText.endsWith('```')) { + jsonText = jsonText.slice(0, -3) + } + jsonText = jsonText.trim() + + const parsed = JSON.parse(jsonText) + return validateAndEnhanceResult(parsed) + } catch (error) { + lastError = error instanceof Error ? error : new Error('Unknown error') + + if (error instanceof SyntaxError) { + throw new Error(`Failed to parse AI response: ${lastError.message}`) + } + + if (attempt < MAX_RETRIES - 1) { + await sleep(RETRY_DELAY_MS * (attempt + 1)) + } + } + } + + throw new Error(`Invoice analysis failed after ${MAX_RETRIES} attempts: ${lastError?.message}`) +} + +function validateAndEnhanceResult(raw: unknown): InvoiceExtractionResult { + if (!raw || typeof raw !== 'object') { + throw new Error('Invalid extraction result: not an object') + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const data = raw as any + + const supplier = data.supplier || {} + const invoice = data.invoice || {} + const totals = data.totals || {} + + return { + supplier: { + name: validateString(supplier.name), + orgNumber: validateOrgNumber(supplier.orgNumber), + vatNumber: validateVatNumber(supplier.vatNumber), + address: validateString(supplier.address), + bankgiro: validateString(supplier.bankgiro), + plusgiro: validateString(supplier.plusgiro), + }, + invoice: { + invoiceNumber: validateString(invoice.invoiceNumber), + invoiceDate: validateDate(invoice.invoiceDate), + dueDate: validateDate(invoice.dueDate), + paymentReference: validateString(invoice.paymentReference), + currency: validateString(invoice.currency) || 'SEK', + }, + lineItems: validateLineItems(data.lineItems), + totals: { + subtotal: validateNumber(totals.subtotal), + vatAmount: validateNumber(totals.vatAmount), + total: validateNumber(totals.total), + }, + vatBreakdown: validateVatBreakdown(data.vatBreakdown), + confidence: validateNumber(data.confidence) || 0.5, + } +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function validateLineItems(data: any): ExtractedInvoiceLineItem[] { + if (!Array.isArray(data)) return [] + + return data + .filter((item: unknown) => item && typeof item === 'object') + .map((item: Record) => ({ + description: String(item.description || '').trim(), + quantity: (validateNumber(item.quantity) || 1), + unitPrice: validateNumber(item.unitPrice), + lineTotal: validateNumber(item.lineTotal) || 0, + vatRate: validateNumber(item.vatRate), + accountSuggestion: validateAccountNumber(item.accountSuggestion as string | undefined), + })) + .filter((item: ExtractedInvoiceLineItem) => item.lineTotal > 0 || item.description.length > 0) +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function validateVatBreakdown(data: any): VatBreakdownItem[] { + if (!Array.isArray(data)) return [] + + return data + .filter((item: unknown) => item && typeof item === 'object') + .map((item: Record) => ({ + rate: validateNumber(item.rate) || 0, + base: validateNumber(item.base) || 0, + amount: validateNumber(item.amount) || 0, + })) + .filter((item: VatBreakdownItem) => item.amount > 0 || item.base > 0) +} + +function validateString(value: unknown): string | null { + if (typeof value === 'string' && value.trim()) { + return value.trim() + } + return null +} + +function validateNumber(value: unknown): number | null { + if (typeof value === 'number' && !isNaN(value)) { + return value + } + if (typeof value === 'string') { + const parsed = parseFloat(value.replace(/[^\d.-]/g, '')) + if (!isNaN(parsed)) return parsed + } + return null +} + +function validateDate(value: unknown): string | null { + if (typeof value !== 'string') return null + const date = new Date(value) + if (isNaN(date.getTime())) return null + return date.toISOString().split('T')[0] +} + +function validateOrgNumber(value: unknown): string | null { + if (typeof value !== 'string') return null + const digits = value.replace(/\D/g, '') + if (digits.length === 10) { + return `${digits.slice(0, 6)}-${digits.slice(6)}` + } + return null +} + +function validateVatNumber(value: unknown): string | null { + if (typeof value !== 'string') return null + const cleaned = value.trim().toUpperCase() + if (cleaned.startsWith('SE') && cleaned.length >= 12) { + return cleaned + } + return null +} + +function validateAccountNumber(value: string | undefined): string | null { + if (!value) return null + const digits = value.replace(/\D/g, '') + if (digits.length === 4 && parseInt(digits) >= 1000 && parseInt(digits) <= 9999) { + return digits + } + return null +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} diff --git a/extensions/general/invoice-inbox/lib/supplier-matcher.ts b/extensions/general/invoice-inbox/lib/supplier-matcher.ts new file mode 100644 index 00000000..08a298b4 --- /dev/null +++ b/extensions/general/invoice-inbox/lib/supplier-matcher.ts @@ -0,0 +1,197 @@ +/** + * Supplier Matcher - Fuzzy matching between extracted invoice data and existing suppliers + * + * 4-pass matching algorithm: + * 1. Exact org number match + * 2. Exact VAT number match + * 3. Bankgiro/plusgiro match + * 4. Fuzzy name match (Levenshtein + Swedish suffix normalization) + */ + +import type { Supplier } from '@/types' +import type { InvoiceExtractionResult, SupplierMatchResult } from '../types' + +/** + * Find the best matching supplier for extracted invoice data + */ +export function matchSupplier( + extraction: InvoiceExtractionResult, + suppliers: Supplier[] +): SupplierMatchResult | null { + if (suppliers.length === 0) return null + + // Pass 1: Exact org number match + if (extraction.supplier.orgNumber) { + const normalizedOrg = normalizeOrgNumber(extraction.supplier.orgNumber) + for (const supplier of suppliers) { + if (supplier.org_number && normalizeOrgNumber(supplier.org_number) === normalizedOrg) { + return { + supplierId: supplier.id, + supplierName: supplier.name, + confidence: 0.98, + matchMethod: 'org_number', + } + } + } + } + + // Pass 2: Exact VAT number match + if (extraction.supplier.vatNumber) { + const normalizedVat = normalizeVatNumber(extraction.supplier.vatNumber) + for (const supplier of suppliers) { + if (supplier.vat_number && normalizeVatNumber(supplier.vat_number) === normalizedVat) { + return { + supplierId: supplier.id, + supplierName: supplier.name, + confidence: 0.95, + matchMethod: 'vat_number', + } + } + } + } + + // Pass 3: Bankgiro/plusgiro match + if (extraction.supplier.bankgiro) { + const normalizedBg = normalizeBankgiro(extraction.supplier.bankgiro) + for (const supplier of suppliers) { + if (supplier.bankgiro && normalizeBankgiro(supplier.bankgiro) === normalizedBg) { + return { + supplierId: supplier.id, + supplierName: supplier.name, + confidence: 0.92, + matchMethod: 'bankgiro', + } + } + } + } + if (extraction.supplier.plusgiro) { + const normalizedPg = normalizeBankgiro(extraction.supplier.plusgiro) + for (const supplier of suppliers) { + if (supplier.plusgiro && normalizeBankgiro(supplier.plusgiro) === normalizedPg) { + return { + supplierId: supplier.id, + supplierName: supplier.name, + confidence: 0.92, + matchMethod: 'bankgiro', + } + } + } + } + + // Pass 4: Fuzzy name match + if (extraction.supplier.name) { + let bestMatch: SupplierMatchResult | null = null + + for (const supplier of suppliers) { + const similarity = calculateNameSimilarity(extraction.supplier.name, supplier.name) + const confidence = Math.round(similarity * 0.85 * 100) / 100 // Cap at 0.85 for name matches + + if (confidence > 0.6 && (!bestMatch || confidence > bestMatch.confidence)) { + bestMatch = { + supplierId: supplier.id, + supplierName: supplier.name, + confidence, + matchMethod: 'fuzzy_name', + } + } + } + + return bestMatch + } + + return null +} + +/** + * Normalize org number to digits only + */ +export function normalizeOrgNumber(orgNumber: string): string { + return orgNumber.replace(/\D/g, '') +} + +/** + * Normalize VAT number to uppercase, no spaces + */ +export function normalizeVatNumber(vatNumber: string): string { + return vatNumber.replace(/\s/g, '').toUpperCase() +} + +/** + * Normalize bankgiro/plusgiro to digits only + */ +export function normalizeBankgiro(value: string): string { + return value.replace(/\D/g, '') +} + +/** + * Calculate name similarity with Swedish company suffix normalization + */ +export function calculateNameSimilarity(name1: string, name2: string): number { + if (!name1 || !name2) return 0 + + const n1 = normalizeCompanyName(name1) + const n2 = normalizeCompanyName(name2) + + if (n1 === n2) return 1 + + if (n1.includes(n2) || n2.includes(n1)) return 0.9 + + // Word overlap scoring + const words1 = n1.split(/\s+/).filter(Boolean) + const words2 = n2.split(/\s+/).filter(Boolean) + const commonWords = words1.filter((w) => words2.includes(w)) + + if (commonWords.length > 0) { + const overlapScore = commonWords.length / Math.max(words1.length, words2.length) + if (overlapScore >= 0.5) return 0.7 + overlapScore * 0.2 + } + + // Levenshtein similarity + const distance = levenshteinDistance(n1, n2) + const maxLength = Math.max(n1.length, n2.length) + return maxLength > 0 ? 1 - distance / maxLength : 0 +} + +/** + * Normalize Swedish company name for comparison. + * Strips common legal suffixes and normalizes whitespace. + */ +export function normalizeCompanyName(name: string): string { + return name + .toLowerCase() + .replace(/[^\w\såäöé]/g, '') + .replace( + /\b(ab|hb|kb|ek|ek\s*för|enskild\s*firma|aktiebolag|handelsbolag|kommanditbolag|ekonomisk\s*förening|stiftelse|ideell\s*förening|i\s*likvidation)\b/g, + '' + ) + .replace(/\s+/g, ' ') + .trim() +} + +/** + * Calculate Levenshtein distance between two strings + */ +export function levenshteinDistance(str1: string, str2: string): number { + const m = str1.length + const n = str2.length + + const dp: number[][] = Array(m + 1) + .fill(null) + .map(() => Array(n + 1).fill(0)) + + for (let i = 0; i <= m; i++) dp[i][0] = i + for (let j = 0; j <= n; j++) dp[0][j] = j + + for (let i = 1; i <= m; i++) { + for (let j = 1; j <= n; j++) { + const cost = str1[i - 1] === str2[j - 1] ? 0 : 1 + dp[i][j] = Math.min( + dp[i - 1][j] + 1, + dp[i][j - 1] + 1, + dp[i - 1][j - 1] + cost + ) + } + } + + return dp[m][n] +} diff --git a/extensions/general/invoice-inbox/types.ts b/extensions/general/invoice-inbox/types.ts new file mode 100644 index 00000000..ede3d557 --- /dev/null +++ b/extensions/general/invoice-inbox/types.ts @@ -0,0 +1,74 @@ +/** + * Invoice Inbox extension-specific types + */ + +export interface InvoiceExtractionResult { + supplier: { + name: string | null + orgNumber: string | null + vatNumber: string | null + address: string | null + bankgiro: string | null + plusgiro: string | null + } + invoice: { + invoiceNumber: string | null + invoiceDate: string | null + dueDate: string | null + paymentReference: string | null // OCR number or reference + currency: string + } + lineItems: ExtractedInvoiceLineItem[] + totals: { + subtotal: number | null + vatAmount: number | null + total: number | null + } + vatBreakdown: VatBreakdownItem[] + confidence: number +} + +export interface ExtractedInvoiceLineItem { + description: string + quantity: number + unitPrice: number | null + lineTotal: number + vatRate: number | null + accountSuggestion: string | null +} + +export interface VatBreakdownItem { + rate: number + base: number + amount: number +} + +export interface SupplierMatchResult { + supplierId: string + supplierName: string + confidence: number + matchMethod: 'org_number' | 'vat_number' | 'bankgiro' | 'fuzzy_name' +} + +export interface InvoiceInboxSettings { + autoProcessEnabled: boolean + autoMatchSupplierEnabled: boolean + supplierMatchThreshold: number + inboxEmail: string | null +} + +export interface ResendInboundPayload { + from: string + to: string + subject: string + html: string | null + text: string | null + attachments: ResendAttachment[] + created_at: string +} + +export interface ResendAttachment { + filename: string + content_type: string + content: string // base64-encoded +} diff --git a/lib/bookkeeping/__tests__/category-mapping.test.ts b/lib/bookkeeping/__tests__/category-mapping.test.ts index 5e09fa46..2ffa158f 100644 --- a/lib/bookkeeping/__tests__/category-mapping.test.ts +++ b/lib/bookkeeping/__tests__/category-mapping.test.ts @@ -1,5 +1,10 @@ import { describe, it, expect } from 'vitest' -import { getCategoryAccountMapping, getExpenseAccountForCategory } from '../category-mapping' +import { + getCategoryAccountMapping, + getExpenseAccountForCategory, + getDefaultAccountForCategory, + getDefaultVatTreatmentForCategory, +} from '../category-mapping' describe('getCategoryAccountMapping', () => { describe('income_products uses correct account', () => { @@ -43,3 +48,62 @@ describe('getExpenseAccountForCategory', () => { expect(getExpenseAccountForCategory('expense_bank_fees')).toBe('6570') }) }) + +describe('getDefaultAccountForCategory', () => { + it('returns expense account for expense categories', () => { + expect(getDefaultAccountForCategory('expense_equipment')).toBe('5410') + expect(getDefaultAccountForCategory('expense_software')).toBe('5420') + expect(getDefaultAccountForCategory('expense_travel')).toBe('5800') + expect(getDefaultAccountForCategory('expense_bank_fees')).toBe('6570') + }) + + it('returns income account for income categories', () => { + expect(getDefaultAccountForCategory('income_services')).toBe('3001') + expect(getDefaultAccountForCategory('income_products')).toBe('3001') + expect(getDefaultAccountForCategory('income_other')).toBe('3900') + }) + + it('returns private account for enskild firma', () => { + expect(getDefaultAccountForCategory('private', 'enskild_firma')).toBe('2013') + }) + + it('returns private account for aktiebolag', () => { + expect(getDefaultAccountForCategory('private', 'aktiebolag')).toBe('2893') + }) + + it('returns entity-specific education account', () => { + expect(getDefaultAccountForCategory('expense_education', 'enskild_firma')).toBe('6991') + expect(getDefaultAccountForCategory('expense_education', 'aktiebolag')).toBe('7610') + }) + + it('returns fallback for uncategorized', () => { + expect(getDefaultAccountForCategory('uncategorized')).toBe('6991') + }) +}) + +describe('getDefaultVatTreatmentForCategory', () => { + it('returns standard_25 for regular expense categories', () => { + expect(getDefaultVatTreatmentForCategory('expense_equipment')).toBe('standard_25') + expect(getDefaultVatTreatmentForCategory('expense_software')).toBe('standard_25') + expect(getDefaultVatTreatmentForCategory('expense_travel')).toBe('standard_25') + }) + + it('returns standard_25 for income categories', () => { + expect(getDefaultVatTreatmentForCategory('income_services')).toBe('standard_25') + expect(getDefaultVatTreatmentForCategory('income_products')).toBe('standard_25') + }) + + it('returns null for VAT-exempt categories', () => { + expect(getDefaultVatTreatmentForCategory('expense_bank_fees')).toBeNull() + expect(getDefaultVatTreatmentForCategory('expense_card_fees')).toBeNull() + expect(getDefaultVatTreatmentForCategory('expense_currency_exchange')).toBeNull() + }) + + it('returns null for private transactions', () => { + expect(getDefaultVatTreatmentForCategory('private')).toBeNull() + }) + + it('returns null for uncategorized', () => { + expect(getDefaultVatTreatmentForCategory('uncategorized')).toBeNull() + }) +}) diff --git a/lib/bookkeeping/category-mapping.ts b/lib/bookkeeping/category-mapping.ts index 9ecbc928..f40c89c9 100644 --- a/lib/bookkeeping/category-mapping.ts +++ b/lib/bookkeeping/category-mapping.ts @@ -257,3 +257,69 @@ export function getExpenseAccountForCategory(category: TransactionCategory): str } return mapping[category] || null } + +/** + * Get the default account number for a category. + * For expense categories: returns the expense account (debit side). + * For income categories: returns the revenue account (credit side). + * For private/uncategorized: returns the entity-specific private or fallback account. + */ +export function getDefaultAccountForCategory( + category: TransactionCategory, + entityType: EntityType = 'enskild_firma' +): string { + if (category === 'private') { + return PRIVATE_ACCOUNTS[entityType] || PRIVATE_ACCOUNTS.enskild_firma + } + + const expenseMapping: Record = { + expense_equipment: '5410', + expense_software: '5420', + expense_travel: '5800', + expense_office: '5010', + expense_marketing: '5910', + expense_professional_services: '6530', + expense_education: entityType === 'aktiebolag' ? '7610' : '6991', + expense_bank_fees: '6570', + expense_card_fees: '6570', + expense_currency_exchange: '7960', + expense_other: '6991', + } + + if (category.startsWith('expense_')) { + return expenseMapping[category] || '6991' + } + + const incomeMapping: Record = { + income_services: '3001', + income_products: '3001', + income_other: '3900', + } + + if (category.startsWith('income_')) { + return incomeMapping[category] || '3900' + } + + // uncategorized + return '6991' +} + +/** + * Get the default VAT treatment for a category. + * Bank fees, card fees, and currency exchange are VAT-exempt. + * All other business categories default to standard 25%. + */ +export function getDefaultVatTreatmentForCategory( + category: TransactionCategory +): VatTreatment | null { + if (category === 'private' || category === 'uncategorized') { + return null + } + + const vatExemptCategories = ['expense_bank_fees', 'expense_card_fees', 'expense_currency_exchange'] + if (vatExemptCategories.includes(category)) { + return null + } + + return 'standard_25' +} diff --git a/lib/events/types.ts b/lib/events/types.ts index de55ac4b..744b721f 100644 --- a/lib/events/types.ts +++ b/lib/events/types.ts @@ -11,6 +11,8 @@ import type { CAMT054Notification, AuditSecurityEvent, ReconciliationMethod, + InvoiceInboxItem, + SupplierInvoice, } from '@/types' // ============================================================ @@ -62,6 +64,10 @@ export type CoreEvent = privateTotal: number; userId: string; }} + // Supplier Invoice Inbox + | { type: 'supplier_invoice.received'; payload: { inboxItem: InvoiceInboxItem; userId: string } } + | { type: 'supplier_invoice.extracted'; payload: { inboxItem: InvoiceInboxItem; confidence: number; userId: string } } + | { type: 'supplier_invoice.confirmed'; payload: { inboxItem: InvoiceInboxItem; supplierInvoice: SupplierInvoice; userId: string } } // Audit | { type: 'audit.security_event'; payload: { event: AuditSecurityEvent; userId: string } } diff --git a/lib/extensions/__tests__/sectors.test.ts b/lib/extensions/__tests__/sectors.test.ts index 07d0fec8..e4caa0b0 100644 --- a/lib/extensions/__tests__/sectors.test.ts +++ b/lib/extensions/__tests__/sectors.test.ts @@ -11,8 +11,8 @@ describe('sectors registry', () => { expect(SECTORS.length).toBe(6) }) - it('should have 17 total extensions', () => { - expect(getAllExtensions().length).toBe(17) + it('should have 18 total extensions', () => { + expect(getAllExtensions().length).toBe(18) }) it('should have unique slugs within each sector', () => { diff --git a/lib/extensions/loader.ts b/lib/extensions/loader.ts index 1c28ad04..b1dd0054 100644 --- a/lib/extensions/loader.ts +++ b/lib/extensions/loader.ts @@ -5,6 +5,7 @@ import { pushNotificationsExtension } from '@/extensions/general/push-notificati import { sruExportExtension } from '@/extensions/sru-export' import { neBilagaExtension } from '@/extensions/ne-bilaga' import { aiChatExtension } from '@/extensions/general/ai-chat' +import { invoiceInboxExtension } from '@/extensions/general/invoice-inbox' import type { Extension } from './types' // ── Enable Banking (PSD2) — opt-in extension ─────────────────────────── @@ -26,6 +27,7 @@ const FIRST_PARTY_EXTENSIONS: Extension[] = [ sruExportExtension, neBilagaExtension, aiChatExtension, + invoiceInboxExtension, // enableBankingExtension, // Uncomment to activate PSD2 bank sync ] diff --git a/lib/extensions/sectors.ts b/lib/extensions/sectors.ts index e908f4d7..0c7c22ec 100644 --- a/lib/extensions/sectors.ts +++ b/lib/extensions/sectors.ts @@ -64,6 +64,18 @@ export const SECTORS: Sector[] = [ longDescription: 'Få push-notiser direkt i webbläsaren när viktiga händelser sker — nya fakturor, förfallna betalningar, slutförda bokföringar med mera.', }, + { + slug: 'invoice-inbox', + name: 'Leverantörsfaktura-inbox', + sector: 'general', + category: 'import', + icon: 'Inbox', + dataPattern: 'manual', + hasOwnData: true, + description: 'Ta emot leverantörsfakturor via e-post eller uppladdning', + longDescription: + 'Skicka leverantörsfakturor till en dedikerad e-postadress eller ladda upp manuellt. AI extraherar automatiskt leverantörsdata, belopp och moms. Granska och bekräfta med ett klick för att skapa leverantörsfakturor.', + }, { slug: 'enable-banking', name: 'Bankintegration (PSD2)', diff --git a/package-lock.json b/package-lock.json index 915c351d..a68ff7c8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -47,6 +47,7 @@ "recharts": "^3.7.0", "resend": "^6.9.1", "server-only": "^0.0.1", + "svix": "^1.85.0", "tailwind-merge": "^3.4.0", "web-push": "^3.6.7", "zod": "^4.3.6" @@ -10900,6 +10901,16 @@ } } }, + "node_modules/resend/node_modules/svix": { + "version": "1.84.1", + "resolved": "https://registry.npmjs.org/svix/-/svix-1.84.1.tgz", + "integrity": "sha512-K8DPPSZaW/XqXiz1kEyzSHYgmGLnhB43nQCMeKjWGCUpLIpAMMM8kx3rVVOSm6Bo6EHyK1RQLPT4R06skM/MlQ==", + "license": "MIT", + "dependencies": { + "standardwebhooks": "1.0.0", + "uuid": "^10.0.0" + } + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -11672,9 +11683,9 @@ "license": "ISC" }, "node_modules/svix": { - "version": "1.84.1", - "resolved": "https://registry.npmjs.org/svix/-/svix-1.84.1.tgz", - "integrity": "sha512-K8DPPSZaW/XqXiz1kEyzSHYgmGLnhB43nQCMeKjWGCUpLIpAMMM8kx3rVVOSm6Bo6EHyK1RQLPT4R06skM/MlQ==", + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/svix/-/svix-1.85.0.tgz", + "integrity": "sha512-4OxNw++bnNay8SoBwESgzfjMnYmurS1qBX+luhzvljr6EAPn/hqqmkdCR1pbgIe1K1+BzKZEHjAKz9OYrKJYwQ==", "license": "MIT", "dependencies": { "standardwebhooks": "1.0.0", diff --git a/package.json b/package.json index 43e32e11..b92ad082 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "recharts": "^3.7.0", "resend": "^6.9.1", "server-only": "^0.0.1", + "svix": "^1.85.0", "tailwind-merge": "^3.4.0", "web-push": "^3.6.7", "zod": "^4.3.6" diff --git a/supabase/migrations/20240101000033_invoice_inbox.sql b/supabase/migrations/20240101000033_invoice_inbox.sql new file mode 100644 index 00000000..99aa341e --- /dev/null +++ b/supabase/migrations/20240101000033_invoice_inbox.sql @@ -0,0 +1,56 @@ +-- Invoice Inbox: table for incoming supplier invoices (email + upload) +-- Supports AI extraction, supplier matching, and confirm-to-create workflow + +CREATE TABLE public.invoice_inbox_items ( + id uuid DEFAULT gen_random_uuid() PRIMARY KEY, + user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + status text NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending','processing','ready','confirmed','rejected','error')), + source text NOT NULL DEFAULT 'upload' + CHECK (source IN ('email','upload')), + email_from text, + email_subject text, + email_received_at timestamptz, + document_id uuid REFERENCES public.document_attachments(id) ON DELETE SET NULL, + extracted_data jsonb, + confidence numeric, + matched_supplier_id uuid REFERENCES public.suppliers(id) ON DELETE SET NULL, + created_supplier_invoice_id uuid REFERENCES public.supplier_invoices(id) ON DELETE SET NULL, + error_message text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +-- RLS +ALTER TABLE public.invoice_inbox_items ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "invoice_inbox_items_select" + ON public.invoice_inbox_items FOR SELECT + USING (auth.uid() = user_id); + +CREATE POLICY "invoice_inbox_items_insert" + ON public.invoice_inbox_items FOR INSERT + WITH CHECK (auth.uid() = user_id); + +CREATE POLICY "invoice_inbox_items_update" + ON public.invoice_inbox_items FOR UPDATE + USING (auth.uid() = user_id); + +CREATE POLICY "invoice_inbox_items_delete" + ON public.invoice_inbox_items FOR DELETE + USING (auth.uid() = user_id); + +-- Indexes +CREATE INDEX idx_invoice_inbox_items_user_id + ON public.invoice_inbox_items(user_id); + +CREATE INDEX idx_invoice_inbox_items_user_status + ON public.invoice_inbox_items(user_id, status); + +CREATE INDEX idx_invoice_inbox_items_user_created + ON public.invoice_inbox_items(user_id, created_at DESC); + +-- updated_at trigger +CREATE TRIGGER invoice_inbox_items_updated_at + BEFORE UPDATE ON public.invoice_inbox_items + FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column(); diff --git a/tests/helpers.ts b/tests/helpers.ts index 6b8b80c2..00a306bd 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -15,6 +15,7 @@ import type { Supplier, SupplierInvoice, CompanySettings, + InvoiceInboxItem, } from '@/types' import type { ExtensionToggle } from '@/lib/extensions/types' @@ -444,6 +445,29 @@ export function makeCompanySettings( } } +export function makeInvoiceInboxItem( + overrides: Partial = {} +): InvoiceInboxItem { + return { + id: nextId(), + user_id: 'user-1', + status: 'pending', + source: 'upload', + email_from: null, + email_subject: null, + email_received_at: null, + document_id: null, + extracted_data: null, + confidence: null, + matched_supplier_id: null, + created_supplier_invoice_id: null, + error_message: null, + created_at: '2024-06-15T14:30:00Z', + updated_at: '2024-06-15T14:30:00Z', + ...overrides, + } +} + export function makeExtensionToggle( overrides: Partial = {} ): ExtensionToggle { diff --git a/types/index.ts b/types/index.ts index 4f0b95cc..2918f459 100644 --- a/types/index.ts +++ b/types/index.ts @@ -1270,6 +1270,36 @@ export interface SIEAccountMapping { updated_at: string } +// ============================================================ +// Invoice Inbox Types +// ============================================================ + +export type InboxItemStatus = 'pending' | 'processing' | 'ready' | 'confirmed' | 'rejected' | 'error' +export type InboxItemSource = 'email' | 'upload' + +export interface InvoiceInboxItem { + id: string + user_id: string + status: InboxItemStatus + source: InboxItemSource + email_from: string | null + email_subject: string | null + email_received_at: string | null + document_id: string | null + extracted_data: Record | null + confidence: number | null + matched_supplier_id: string | null + created_supplier_invoice_id: string | null + error_message: string | null + created_at: string + updated_at: string + + // Relations (populated when fetched) + document?: DocumentAttachment + supplier?: Supplier + supplier_invoice?: SupplierInvoice +} + // ============================================================ // Receipt Types (canonical source: extensions/receipt-ocr/types.ts) // ============================================================ From ef5a84a5d5c64eed65b32ac10196b673bd0afa74 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Mon, 23 Feb 2026 09:24:21 +0100 Subject: [PATCH 2/2] feat: make extension system packageable via enriched ExtensionContext Enrich ExtensionContext with supabase, emit(), settings, storage, log, and services so extensions can receive everything through dependency injection instead of importing core modules directly. - Add context factory and inject context into event handlers via registry - Move supplier invoice journal entry creation to core event handler - Add services.ingestTransactions to ExtensionContext for enable-banking - Create catch-all API route for extension-declared apiRoutes - Migrate 5 extensions to accept context with dynamic import fallbacks Co-Authored-By: Claude Opus 4.6 --- .../ext/[...path]/__tests__/route.test.ts | 200 ++++++++++++++++++ app/api/extensions/ext/[...path]/route.ts | 73 +++++++ .../[id]/confirm/__tests__/route.test.ts | 14 +- .../invoice-inbox/inbox/[id]/confirm/route.ts | 36 +--- .../[id]/send/__tests__/route.test.ts | 2 +- app/api/invoices/__tests__/route.test.ts | 16 +- extensions/general/ai-categorization/index.ts | 34 +-- extensions/general/enable-banking/index.ts | 39 ++-- extensions/general/enable-banking/lib/sync.ts | 21 +- extensions/general/invoice-inbox/index.ts | 34 +-- .../general/push-notifications/index.ts | 60 +++--- extensions/general/receipt-ocr/index.ts | 64 +++--- .../supplier-invoice-handler.test.ts | 123 +++++++++++ .../handlers/supplier-invoice-handler.ts | 77 +++++++ lib/bookkeeping/invoice-entries.ts | 2 +- .../__tests__/context-factory.test.ts | 126 +++++++++++ lib/extensions/__tests__/registry.test.ts | 38 +++- lib/extensions/context-factory.ts | 122 +++++++++++ lib/extensions/registry.ts | 45 +++- lib/extensions/types.ts | 42 +++- lib/init.ts | 13 +- lib/transactions/ingest.ts | 30 +-- tests/helpers.ts | 3 + types/index.ts | 29 +++ 24 files changed, 1058 insertions(+), 185 deletions(-) create mode 100644 app/api/extensions/ext/[...path]/__tests__/route.test.ts create mode 100644 app/api/extensions/ext/[...path]/route.ts create mode 100644 lib/bookkeeping/handlers/__tests__/supplier-invoice-handler.test.ts create mode 100644 lib/bookkeeping/handlers/supplier-invoice-handler.ts create mode 100644 lib/extensions/__tests__/context-factory.test.ts create mode 100644 lib/extensions/context-factory.ts diff --git a/app/api/extensions/ext/[...path]/__tests__/route.test.ts b/app/api/extensions/ext/[...path]/__tests__/route.test.ts new file mode 100644 index 00000000..8e3f7236 --- /dev/null +++ b/app/api/extensions/ext/[...path]/__tests__/route.test.ts @@ -0,0 +1,200 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { + createQueuedMockSupabase, + createMockRequest, + parseJsonResponse, +} from '@/tests/helpers' +import { NextResponse } from 'next/server' + +// Mock dependencies +vi.mock('@/lib/supabase/server', () => ({ + createClient: vi.fn(), +})) + +vi.mock('@/lib/init', () => ({ + ensureInitialized: vi.fn(), +})) + +vi.mock('@/lib/extensions/toggle-check', () => ({ + isExtensionEnabled: vi.fn(), +})) + +vi.mock('@/lib/extensions/context-factory', () => ({ + createExtensionContext: vi.fn().mockReturnValue({ + userId: 'user-1', + extensionId: 'test-ext', + }), +})) + +import { createClient } from '@/lib/supabase/server' +import { extensionRegistry } from '@/lib/extensions/registry' +import { isExtensionEnabled } from '@/lib/extensions/toggle-check' +import { GET, POST } from '../route' + +const mockCreateClient = vi.mocked(createClient) +const mockIsEnabled = vi.mocked(isExtensionEnabled) + +function createPathParams(path: string[]) { + return { params: Promise.resolve({ path }) } +} + +describe('Extension Catch-All Route', () => { + beforeEach(() => { + vi.clearAllMocks() + extensionRegistry.clear() + }) + + it('returns 400 for empty path', async () => { + const request = createMockRequest('/api/extensions/ext/') + const response = await GET(request, createPathParams([])) + const { status } = await parseJsonResponse(response) + + expect(status).toBe(400) + }) + + it('returns 404 for unknown extension', async () => { + const { supabase } = createQueuedMockSupabase() + supabase.auth.getUser.mockResolvedValue({ + data: { user: { id: 'user-1' } }, + error: null, + }) + mockCreateClient.mockResolvedValue(supabase as never) + + const request = createMockRequest('/api/extensions/ext/nonexistent/foo') + const response = await GET(request, createPathParams(['nonexistent', 'foo'])) + const { status } = await parseJsonResponse(response) + + expect(status).toBe(404) + }) + + it('returns 401 when not authenticated', async () => { + extensionRegistry.register({ + id: 'test-ext', + name: 'Test', + version: '1.0.0', + apiRoutes: [{ method: 'GET', path: '/data', handler: vi.fn() }], + }) + + const { supabase } = createQueuedMockSupabase() + supabase.auth.getUser.mockResolvedValue({ + data: { user: null }, + error: { message: 'Not authenticated' }, + }) + mockCreateClient.mockResolvedValue(supabase as never) + + const request = createMockRequest('/api/extensions/ext/test-ext/data') + const response = await GET(request, createPathParams(['test-ext', 'data'])) + const { status } = await parseJsonResponse(response) + + expect(status).toBe(401) + }) + + it('returns 403 when extension is disabled', async () => { + extensionRegistry.register({ + id: 'test-ext', + name: 'Test', + version: '1.0.0', + apiRoutes: [{ method: 'GET', path: '/data', handler: vi.fn() }], + }) + + const { supabase } = createQueuedMockSupabase() + supabase.auth.getUser.mockResolvedValue({ + data: { user: { id: 'user-1' } }, + error: null, + }) + mockCreateClient.mockResolvedValue(supabase as never) + mockIsEnabled.mockResolvedValue(false) + + const request = createMockRequest('/api/extensions/ext/test-ext/data') + const response = await GET(request, createPathParams(['test-ext', 'data'])) + const { status } = await parseJsonResponse(response) + + expect(status).toBe(403) + }) + + it('returns 404 for unmatched method/path', async () => { + extensionRegistry.register({ + id: 'test-ext', + name: 'Test', + version: '1.0.0', + apiRoutes: [{ method: 'POST', path: '/data', handler: vi.fn() }], + }) + + const { supabase } = createQueuedMockSupabase() + supabase.auth.getUser.mockResolvedValue({ + data: { user: { id: 'user-1' } }, + error: null, + }) + mockCreateClient.mockResolvedValue(supabase as never) + mockIsEnabled.mockResolvedValue(true) + + // GET doesn't match POST /data + const request = createMockRequest('/api/extensions/ext/test-ext/data') + const response = await GET(request, createPathParams(['test-ext', 'data'])) + const { status } = await parseJsonResponse(response) + + expect(status).toBe(404) + }) + + it('dispatches to matching handler with context', async () => { + const handler = vi.fn().mockResolvedValue( + NextResponse.json({ banks: [] }) + ) + + extensionRegistry.register({ + id: 'enable-banking', + name: 'Enable Banking', + version: '1.0.0', + apiRoutes: [{ method: 'GET', path: '/banks', handler }], + }) + + const { supabase } = createQueuedMockSupabase() + supabase.auth.getUser.mockResolvedValue({ + data: { user: { id: 'user-1' } }, + error: null, + }) + mockCreateClient.mockResolvedValue(supabase as never) + mockIsEnabled.mockResolvedValue(true) + + const request = createMockRequest('/api/extensions/ext/enable-banking/banks') + const response = await GET(request, createPathParams(['enable-banking', 'banks'])) + const { status, body } = await parseJsonResponse<{ banks: unknown[] }>(response) + + expect(status).toBe(200) + expect(body.banks).toEqual([]) + expect(handler).toHaveBeenCalledWith(request, expect.objectContaining({ + extensionId: 'test-ext', + })) + }) + + it('dispatches POST requests correctly', async () => { + const handler = vi.fn().mockResolvedValue( + NextResponse.json({ ok: true }) + ) + + extensionRegistry.register({ + id: 'test-ext', + name: 'Test', + version: '1.0.0', + apiRoutes: [{ method: 'POST', path: '/connect', handler }], + }) + + const { supabase } = createQueuedMockSupabase() + supabase.auth.getUser.mockResolvedValue({ + data: { user: { id: 'user-1' } }, + error: null, + }) + mockCreateClient.mockResolvedValue(supabase as never) + mockIsEnabled.mockResolvedValue(true) + + const request = createMockRequest('/api/extensions/ext/test-ext/connect', { + method: 'POST', + body: { foo: 'bar' }, + }) + const response = await POST(request, createPathParams(['test-ext', 'connect'])) + const { status } = await parseJsonResponse(response) + + expect(status).toBe(200) + expect(handler).toHaveBeenCalled() + }) +}) diff --git a/app/api/extensions/ext/[...path]/route.ts b/app/api/extensions/ext/[...path]/route.ts new file mode 100644 index 00000000..0646efab --- /dev/null +++ b/app/api/extensions/ext/[...path]/route.ts @@ -0,0 +1,73 @@ +import { createClient } from '@/lib/supabase/server' +import { NextResponse } from 'next/server' +import { ensureInitialized } from '@/lib/init' +import { extensionRegistry } from '@/lib/extensions/registry' +import { createExtensionContext } from '@/lib/extensions/context-factory' +import { isExtensionEnabled } from '@/lib/extensions/toggle-check' + +ensureInitialized() + +/** + * Catch-all route for extension-declared API routes. + * + * URL scheme: /api/extensions/ext/{extensionId}/{...routePath} + * Example: /api/extensions/ext/enable-banking/banks → GET /banks + * + * - Looks up the extension in the registry + * - Checks the extension toggle (disabled → 403) + * - Matches method + path to registered apiRoutes + * - Builds an ExtensionContext and passes it to the handler + */ +async function handleRequest( + request: Request, + { params }: { params: Promise<{ path: string[] }> } +): Promise { + const segments = await params + + if (!segments.path || segments.path.length < 1) { + return NextResponse.json({ error: 'Invalid extension route' }, { status: 400 }) + } + + const [extensionId, ...rest] = segments.path + const routePath = '/' + rest.join('/') + const method = request.method as 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' + + // Look up extension + const extension = extensionRegistry.get(extensionId) + if (!extension || !extension.apiRoutes || extension.apiRoutes.length === 0) { + return NextResponse.json({ error: 'Extension not found' }, { status: 404 }) + } + + // Auth check + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + // Toggle check — disabled extensions return 403 + const enabled = await isExtensionEnabled(user.id, 'general', extensionId) + if (!enabled) { + return NextResponse.json({ error: 'Extension is disabled' }, { status: 403 }) + } + + // Find matching route + const route = extension.apiRoutes.find( + (r) => r.method === method && r.path === routePath + ) + + if (!route) { + return NextResponse.json({ error: 'Route not found' }, { status: 404 }) + } + + // Build context and dispatch + const ctx = createExtensionContext(supabase, user.id, extensionId) + return route.handler(request, ctx) +} + +export const GET = handleRequest +export const POST = handleRequest +export const PUT = handleRequest +export const DELETE = handleRequest +export const PATCH = handleRequest diff --git a/app/api/extensions/invoice-inbox/inbox/[id]/confirm/__tests__/route.test.ts b/app/api/extensions/invoice-inbox/inbox/[id]/confirm/__tests__/route.test.ts index a3aa06f4..9f1b634b 100644 --- a/app/api/extensions/invoice-inbox/inbox/[id]/confirm/__tests__/route.test.ts +++ b/app/api/extensions/invoice-inbox/inbox/[id]/confirm/__tests__/route.test.ts @@ -21,10 +21,6 @@ vi.mock('@/lib/events/bus', () => ({ eventBus: { emit: vi.fn().mockResolvedValue(undefined), clear: vi.fn() }, })) -vi.mock('@/lib/bookkeeping/supplier-invoice-entries', () => ({ - createSupplierInvoiceRegistrationEntry: vi.fn().mockResolvedValue({ id: 'je-1' }), -})) - import { createClient } from '@/lib/supabase/server' import { POST } from '../route' @@ -160,11 +156,7 @@ describe('Invoice Inbox Confirm Route', () => { { data: { id: 'si-1', total: 625 }, error: null }, // 6. Insert items { data: null, error: null }, - // 7. Get company settings - { data: { accounting_method: 'accrual' }, error: null }, - // 8. Update invoice with journal entry id - { data: null, error: null }, - // 9. Update inbox item as confirmed + // 7. Update inbox item as confirmed { data: null, error: null }, ]) mockCreateClient.mockResolvedValue(supabase as never) @@ -225,9 +217,7 @@ describe('Invoice Inbox Confirm Route', () => { { data: { id: 'si-2', total: 1250 }, error: null }, // 5. Insert items { data: null, error: null }, - // 6. Get company settings - { data: { accounting_method: 'cash' }, error: null }, - // 7. Update inbox item as confirmed + // 6. Update inbox item as confirmed { data: null, error: null }, ]) mockCreateClient.mockResolvedValue(supabase as never) diff --git a/app/api/extensions/invoice-inbox/inbox/[id]/confirm/route.ts b/app/api/extensions/invoice-inbox/inbox/[id]/confirm/route.ts index 44c90762..62badf33 100644 --- a/app/api/extensions/invoice-inbox/inbox/[id]/confirm/route.ts +++ b/app/api/extensions/invoice-inbox/inbox/[id]/confirm/route.ts @@ -2,9 +2,8 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { ensureInitialized } from '@/lib/init' import { eventBus } from '@/lib/events/bus' -import { createSupplierInvoiceRegistrationEntry } from '@/lib/bookkeeping/supplier-invoice-entries' import type { InvoiceExtractionResult } from '@/extensions/general/invoice-inbox/types' -import type { SupplierInvoice, SupplierInvoiceItem } from '@/types' +import type { SupplierInvoice } from '@/types' ensureInitialized() @@ -192,36 +191,6 @@ export async function POST( return NextResponse.json({ error: itemsError.message }, { status: 500 }) } - // Accrual method: create registration journal entry - const { data: settings } = await supabase - .from('company_settings') - .select('accounting_method') - .eq('user_id', user.id) - .single() - - const accountingMethod = settings?.accounting_method || 'accrual' - let registrationJournalEntryId: string | null = null - - if (accountingMethod === 'accrual') { - try { - const journalEntry = await createSupplierInvoiceRegistrationEntry( - user.id, - invoice as SupplierInvoice, - items as SupplierInvoiceItem[], - supplier.supplier_type - ) - if (journalEntry) { - registrationJournalEntryId = journalEntry.id - await supabase - .from('supplier_invoices') - .update({ registration_journal_entry_id: journalEntry.id }) - .eq('id', invoice.id) - } - } catch (err) { - console.error('[invoice-inbox] Failed to create registration journal entry:', err) - } - } - // Update inbox item as confirmed await supabase .from('invoice_inbox_items') @@ -246,11 +215,12 @@ export async function POST( // Non-blocking } + // Journal entry creation is handled asynchronously by the core + // supplier_invoice.confirmed event handler (see lib/bookkeeping/handlers/) return NextResponse.json({ data: { ...invoice, items: itemInserts, - registration_journal_entry_id: registrationJournalEntryId, }, }) } catch (error) { diff --git a/app/api/invoices/[id]/send/__tests__/route.test.ts b/app/api/invoices/[id]/send/__tests__/route.test.ts index 1a8a8657..3a8e0e2b 100644 --- a/app/api/invoices/[id]/send/__tests__/route.test.ts +++ b/app/api/invoices/[id]/send/__tests__/route.test.ts @@ -29,7 +29,7 @@ vi.mock('@react-pdf/renderer', () => ({ StyleSheet: { create: (s: unknown) => s }, })) -vi.mock('@/lib/invoice/pdf-template', () => ({ +vi.mock('@/lib/invoices/pdf-template', () => ({ InvoicePDF: vi.fn().mockReturnValue('mock-pdf-element'), })) diff --git a/app/api/invoices/__tests__/route.test.ts b/app/api/invoices/__tests__/route.test.ts index 7a1a1322..26a197c9 100644 --- a/app/api/invoices/__tests__/route.test.ts +++ b/app/api/invoices/__tests__/route.test.ts @@ -19,9 +19,11 @@ vi.mock('@/lib/init', () => ({ const mockGetVatRules = vi.fn() const mockCalculateVat = vi.fn() -vi.mock('@/lib/invoice/vat-rules', () => ({ +const mockGetAvailableVatRates = vi.fn() +vi.mock('@/lib/invoices/vat-rules', () => ({ getVatRules: (...args: unknown[]) => mockGetVatRules(...args), calculateVat: (...args: unknown[]) => mockCalculateVat(...args), + getAvailableVatRates: (...args: unknown[]) => mockGetAvailableVatRates(...args), calculateTotal: vi.fn(), })) @@ -164,6 +166,12 @@ describe('POST /api/invoices (create invoice)', () => { reverseChargeText: null, }) mockCalculateVat.mockReturnValue(2500) + mockGetAvailableVatRates.mockReturnValue([ + { rate: 25, label: '25%', treatment: 'standard_25' }, + { rate: 12, label: '12%', treatment: 'reduced_12' }, + { rate: 6, label: '6%', treatment: 'reduced_6' }, + { rate: 0, label: '0% (momsfri)', treatment: 'exempt' }, + ]) // Fetch customer enqueue({ data: customer, error: null }) @@ -209,6 +217,12 @@ describe('POST /api/invoices (create invoice)', () => { reverseChargeText: null, }) mockCalculateVat.mockReturnValue(2500) + mockGetAvailableVatRates.mockReturnValue([ + { rate: 25, label: '25%', treatment: 'standard_25' }, + { rate: 12, label: '12%', treatment: 'reduced_12' }, + { rate: 6, label: '6%', treatment: 'reduced_6' }, + { rate: 0, label: '0% (momsfri)', treatment: 'exempt' }, + ]) enqueue({ data: customer, error: null }) enqueue({ data: 'F-2024001' }) diff --git a/extensions/general/ai-categorization/index.ts b/extensions/general/ai-categorization/index.ts index 73dd779b..d4e5b9d9 100644 --- a/extensions/general/ai-categorization/index.ts +++ b/extensions/general/ai-categorization/index.ts @@ -1,5 +1,4 @@ -import { createClient } from '@/lib/supabase/server' -import type { Extension } from '@/lib/extensions/types' +import type { Extension, ExtensionContext } from '@/lib/extensions/types' import type { EventPayload } from '@/lib/events/types' import type { Transaction, EntityType } from '@/types' import { @@ -26,7 +25,15 @@ const DEFAULT_SETTINGS: AiCategorizationSettings = { providerModel: 'claude-haiku-4-5-20251001', } +/** Get settings via ExtensionContext (preferred in event handlers) */ +async function getSettingsViaCtx(ctx: ExtensionContext): Promise { + const stored = await ctx.settings.get>() + return { ...DEFAULT_SETTINGS, ...(stored || {}) } +} + +/** Get settings for external callers (settings routes, on-demand API) */ export async function getSettings(userId: string): Promise { + const { createClient } = await import('@/lib/supabase/server') const supabase = await createClient() const { data } = await supabase @@ -38,7 +45,6 @@ export async function getSettings(userId: string): Promise) } } @@ -49,6 +55,7 @@ export async function saveSettings( const current = await getSettings(userId) const merged = { ...current, ...partial } + const { createClient } = await import('@/lib/supabase/server') const supabase = await createClient() await supabase @@ -87,6 +94,7 @@ export async function categorizeTransactions( userId: string, transactionIds: string[] ): Promise { + const { createClient } = await import('@/lib/supabase/server') const supabase = await createClient() const settings = await getSettings(userId) @@ -125,12 +133,14 @@ export async function categorizeTransactions( // ============================================================ async function handleTransactionSynced( - payload: EventPayload<'transaction.synced'> + payload: EventPayload<'transaction.synced'>, + ctx?: ExtensionContext ): Promise { const { transactions: syncedTransactions, userId } = payload + const log = ctx?.log ?? console // Gate: Is autoSuggestEnabled? - const settings = await getSettings(userId) + const settings = ctx ? await getSettingsViaCtx(ctx) : await getSettings(userId) if (!settings.autoSuggestEnabled) { return } @@ -143,12 +153,10 @@ async function handleTransactionSynced( return } - console.log( - `[ai-categorization] Auto-suggest triggered for ${uncategorized.length} uncategorized transactions` - ) + log.info(`Auto-suggest triggered for ${uncategorized.length} uncategorized transactions`) try { - const supabase = await createClient() + const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient() const batch: TransactionForCategorization[] = uncategorized.map((t: Transaction) => ({ id: t.id, @@ -173,11 +181,11 @@ async function handleTransactionSynced( await storeSuggestions(userId, qualifiedSuggestions, supabase) } - console.log( - `[ai-categorization] Generated ${suggestions.length} suggestions, ${qualifiedSuggestions.length} above threshold (${settings.confidenceThreshold})` + log.info( + `Generated ${suggestions.length} suggestions, ${qualifiedSuggestions.length} above threshold (${settings.confidenceThreshold})` ) } catch (error) { - console.error('[ai-categorization] handleTransactionSynced failed:', error) + log.error('handleTransactionSynced failed:', error) } } @@ -251,6 +259,6 @@ export const aiCategorizationExtension: Extension = { path: '/settings/extensions/ai-categorization', }, async onInstall(ctx) { - await saveSettings(ctx.userId, DEFAULT_SETTINGS) + await ctx.settings.set('settings', DEFAULT_SETTINGS) }, } diff --git a/extensions/general/enable-banking/index.ts b/extensions/general/enable-banking/index.ts index e92cd254..9e4c997c 100644 --- a/extensions/general/enable-banking/index.ts +++ b/extensions/general/enable-banking/index.ts @@ -1,16 +1,9 @@ -import type { Extension } from '@/lib/extensions/types' -import { createClient, createServiceClient } from '@/lib/supabase/server' +import type { Extension, ExtensionContext } from '@/lib/extensions/types' import { NextResponse } from 'next/server' -import { eventBus } from '@/lib/events' import { startAuthorization, getASPSPs, - createSession, - getAccountBalance, - isConsentExpiringSoon, - getDaysUntilExpiry, type ASPSP, - type AccountInfo, } from './lib/api-client' import { syncAccountTransactions } from './lib/sync' import type { StoredAccount } from './types' @@ -41,7 +34,8 @@ export const enableBankingExtension: Extension = { { method: 'GET', path: '/banks', - handler: async () => { + handler: async (_request: Request, ctx?: ExtensionContext) => { + const log = ctx?.log ?? console try { const aspsps = await getASPSPs('SE') const banks = aspsps.map((aspsp: ASPSP) => ({ @@ -52,7 +46,7 @@ export const enableBankingExtension: Extension = { })) return NextResponse.json({ banks }) } catch (error) { - console.error('Error fetching banks:', error) + log.error('Error fetching banks:', error) return NextResponse.json({ banks: [ { name: 'Nordea', country: 'SE', bic: 'NDEASESS' }, @@ -67,8 +61,9 @@ export const enableBankingExtension: Extension = { { method: 'POST', path: '/connect', - handler: async (request: Request) => { - const supabase = await createClient() + handler: async (request: Request, ctx?: ExtensionContext) => { + const log = ctx?.log ?? console + const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient() const { data: { user } } = await supabase.auth.getUser() if (!user) { @@ -108,7 +103,7 @@ export const enableBankingExtension: Extension = { .single() if (error) { - console.error('Database error:', error) + log.error('Database error:', error) throw new Error('Failed to store connection') } @@ -117,7 +112,7 @@ export const enableBankingExtension: Extension = { authorization_url: url, }) } catch (error) { - console.error('Bank connection error:', error) + log.error('Bank connection error:', error) return NextResponse.json( { error: error instanceof Error ? error.message : 'Connection failed' }, { status: 500 } @@ -128,8 +123,9 @@ export const enableBankingExtension: Extension = { { method: 'POST', path: '/sync', - handler: async (request: Request) => { - const supabase = await createClient() + handler: async (request: Request, ctx?: ExtensionContext) => { + const log = ctx?.log ?? console + const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient() const { data: { user } } = await supabase.auth.getUser() if (!user) { @@ -161,6 +157,9 @@ export const enableBankingExtension: Extension = { .toISOString() .split('T')[0] + // Use ctx.services.ingestTransactions when available + const ingestFn = ctx?.services.ingestTransactions + let totalImported = 0 let totalDuplicates = 0 @@ -171,7 +170,8 @@ export const enableBankingExtension: Extension = { connection.id, account, fromDate, - toDate + toDate, + ingestFn ) totalImported += result.imported @@ -198,7 +198,8 @@ export const enableBankingExtension: Extension = { .limit(totalImported) if (syncedTransactions && syncedTransactions.length > 0) { - await eventBus.emit({ + const emit = ctx?.emit ?? (await import('@/lib/events/bus')).eventBus.emit.bind((await import('@/lib/events/bus')).eventBus) + await emit({ type: 'transaction.synced', payload: { transactions: syncedTransactions as Transaction[], userId: user.id }, }) @@ -211,7 +212,7 @@ export const enableBankingExtension: Extension = { last_synced_at: syncedAt, }) } catch (error) { - console.error('Sync error:', error) + log.error('Sync error:', error) return NextResponse.json( { error: error instanceof Error ? error.message : 'Sync failed' }, { status: 500 } diff --git a/extensions/general/enable-banking/lib/sync.ts b/extensions/general/enable-banking/lib/sync.ts index fcde7031..5e53c5c6 100644 --- a/extensions/general/enable-banking/lib/sync.ts +++ b/extensions/general/enable-banking/lib/sync.ts @@ -1,8 +1,16 @@ -import { SupabaseClient } from '@supabase/supabase-js' +import type { SupabaseClient } from '@supabase/supabase-js' import { getTransactions, getAccountBalance } from './api-client' -import { ingestTransactions, type RawTransaction } from '@/lib/transactions/ingest' +import { ingestTransactions as defaultIngest } from '@/lib/transactions/ingest' +import type { RawTransaction, IngestResult } from '@/types' import type { StoredAccount } from '../types' +/** Ingest function signature — matches lib/transactions/ingest */ +export type IngestFn = ( + supabase: SupabaseClient, + userId: string, + raw: RawTransaction[] +) => Promise + export interface SyncResult { imported: number duplicates: number @@ -14,6 +22,10 @@ export interface SyncResult { * * Fetches transactions from the Enable Banking API, converts to RawTransaction * format, and delegates to the shared ingestion pipeline. + * + * @param ingest - Optional ingest function override (defaults to core ingestTransactions). + * When called from an extension handler with ctx.services.ingestTransactions, + * pass that function to avoid direct @/lib imports. */ export async function syncAccountTransactions( supabase: SupabaseClient, @@ -21,7 +33,8 @@ export async function syncAccountTransactions( connectionId: string, account: StoredAccount, fromDate: string, - toDate: string + toDate: string, + ingest: IngestFn = defaultIngest ): Promise { const bankTransactions = await getTransactions( account.uid, @@ -44,7 +57,7 @@ export async function syncAccountTransactions( import_source: 'enable_banking', })) - const ingestResult = await ingestTransactions(supabase, userId, rawTransactions) + const ingestResult = await ingest(supabase, userId, rawTransactions) // Update account balance try { diff --git a/extensions/general/invoice-inbox/index.ts b/extensions/general/invoice-inbox/index.ts index b049f95c..f5b476d9 100644 --- a/extensions/general/invoice-inbox/index.ts +++ b/extensions/general/invoice-inbox/index.ts @@ -1,8 +1,6 @@ -import { createClient } from '@/lib/supabase/server' -import { eventBus } from '@/lib/events/bus' import { analyzeInvoice } from './lib/invoice-analyzer' import { matchSupplier } from './lib/supplier-matcher' -import type { Extension } from '@/lib/extensions/types' +import type { Extension, ExtensionContext } from '@/lib/extensions/types' import type { EventPayload } from '@/lib/events/types' import type { InvoiceInboxSettings } from './types' @@ -17,7 +15,15 @@ const DEFAULT_SETTINGS: InvoiceInboxSettings = { inboxEmail: null, } +/** Get settings via ExtensionContext (preferred in event handlers) */ +async function getSettingsViaCtx(ctx: ExtensionContext): Promise { + const stored = await ctx.settings.get>() + return { ...DEFAULT_SETTINGS, ...(stored || {}) } +} + +/** Get settings for external callers (settings routes, API routes) */ export async function getSettings(userId: string): Promise { + const { createClient } = await import('@/lib/supabase/server') const supabase = await createClient() const { data } = await supabase @@ -40,6 +46,7 @@ export async function saveSettings( const current = await getSettings(userId) const merged = { ...current, ...partial } + const { createClient } = await import('@/lib/supabase/server') const supabase = await createClient() await supabase @@ -73,9 +80,11 @@ const INVOICE_MIME_TYPES = [ * be auto-processed as a supplier invoice. */ async function handleDocumentUploaded( - payload: EventPayload<'document.uploaded'> + payload: EventPayload<'document.uploaded'>, + ctx?: ExtensionContext ): Promise { const { document, userId } = payload + const log = ctx?.log ?? console // Gate: Is it a supported file type? if (!document.mime_type || !INVOICE_MIME_TYPES.includes(document.mime_type)) { @@ -83,13 +92,13 @@ async function handleDocumentUploaded( } // Gate: Is autoProcessEnabled? - const settings = await getSettings(userId) + const settings = ctx ? await getSettingsViaCtx(ctx) : await getSettings(userId) if (!settings.autoProcessEnabled) { return } // Gate: Was this document already processed as an inbox item? - const supabase = await createClient() + const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient() const { data: existing } = await supabase .from('invoice_inbox_items') .select('id') @@ -101,7 +110,7 @@ async function handleDocumentUploaded( return } - console.log(`[invoice-inbox] Auto-process triggered for document ${document.id}`) + log.info(`Auto-process triggered for document ${document.id}`) try { // Create inbox item @@ -117,7 +126,7 @@ async function handleDocumentUploaded( .single() if (insertError || !inboxItem) { - console.error('[invoice-inbox] Failed to create inbox item:', insertError) + log.error('Failed to create inbox item:', insertError) return } @@ -176,7 +185,8 @@ async function handleDocumentUploaded( .single() if (updatedItem) { - await eventBus.emit({ + const emit = ctx?.emit ?? (await import('@/lib/events/bus')).eventBus.emit.bind((await import('@/lib/events/bus')).eventBus) + await emit({ type: 'supplier_invoice.extracted', payload: { inboxItem: updatedItem, @@ -186,9 +196,9 @@ async function handleDocumentUploaded( }) } - console.log(`[invoice-inbox] Invoice ${inboxItem.id} processed (confidence: ${extraction.confidence})`) + log.info(`Invoice ${inboxItem.id} processed (confidence: ${extraction.confidence})`) } catch (error) { - console.error('[invoice-inbox] handleDocumentUploaded failed:', error) + log.error('handleDocumentUploaded failed:', error) } } @@ -208,6 +218,6 @@ export const invoiceInboxExtension: Extension = { path: '/settings/extensions/invoice-inbox', }, async onInstall(ctx) { - await saveSettings(ctx.userId, DEFAULT_SETTINGS) + await ctx.settings.set('settings', DEFAULT_SETTINGS) }, } diff --git a/extensions/general/push-notifications/index.ts b/extensions/general/push-notifications/index.ts index 14e53bb4..1dfbba81 100644 --- a/extensions/general/push-notifications/index.ts +++ b/extensions/general/push-notifications/index.ts @@ -9,8 +9,7 @@ * check extension setting -> build payload -> send via unified pipeline */ -import { createClient } from '@/lib/supabase/server' -import type { Extension } from '@/lib/extensions/types' +import type { Extension, ExtensionContext } from '@/lib/extensions/types' import type { EventPayload } from '@/lib/events/types' import { sendNotificationToUser } from './notification-sender' import { @@ -41,7 +40,15 @@ const DEFAULT_SETTINGS: PushNotificationSettings = { receiptMatchedEnabled: true, } +/** Get settings via ExtensionContext (preferred in event handlers) */ +async function getSettingsViaCtx(ctx: ExtensionContext): Promise { + const stored = await ctx.settings.get>() + return { ...DEFAULT_SETTINGS, ...(stored || {}) } +} + +/** Get settings for external callers (settings routes, cron jobs) */ export async function getSettings(userId: string): Promise { + const { createClient } = await import('@/lib/supabase/server') const supabase = await createClient() const { data } = await supabase @@ -53,7 +60,6 @@ export async function getSettings(userId: string): Promise) } } @@ -64,6 +70,7 @@ export async function saveSettings( const current = await getSettings(userId) const merged = { ...current, ...partial } + const { createClient } = await import('@/lib/supabase/server') const supabase = await createClient() await supabase @@ -86,14 +93,15 @@ export async function saveSettings( // ============================================================ async function handlePeriodLocked( - payload: EventPayload<'period.locked'> + payload: EventPayload<'period.locked'>, + ctx?: ExtensionContext ): Promise { const { period, userId } = payload - const settings = await getSettings(userId) + const settings = ctx ? await getSettingsViaCtx(ctx) : await getSettings(userId) if (!settings.periodLockedEnabled) return - const supabase = await createClient() + const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient() const notificationPayload = createPeriodLockedPayload(period.name, period.id) const result = await sendNotificationToUser( @@ -105,19 +113,20 @@ async function handlePeriodLocked( ) if (result.sent) { - console.log(`[push-notifications] Period locked notification sent for ${period.name}`) + (ctx?.log ?? console).info(`Period locked notification sent for ${period.name}`) } } async function handleYearClosed( - payload: EventPayload<'period.year_closed'> + payload: EventPayload<'period.year_closed'>, + ctx?: ExtensionContext ): Promise { const { period, userId } = payload - const settings = await getSettings(userId) + const settings = ctx ? await getSettingsViaCtx(ctx) : await getSettings(userId) if (!settings.periodYearClosedEnabled) return - const supabase = await createClient() + const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient() const notificationPayload = createYearClosedPayload(period.name, period.id) const result = await sendNotificationToUser( @@ -129,19 +138,20 @@ async function handleYearClosed( ) if (result.sent) { - console.log(`[push-notifications] Year closed notification sent for ${period.name}`) + (ctx?.log ?? console).info(`Year closed notification sent for ${period.name}`) } } async function handleInvoiceSent( - payload: EventPayload<'invoice.sent'> + payload: EventPayload<'invoice.sent'>, + ctx?: ExtensionContext ): Promise { const { invoice, userId } = payload - const settings = await getSettings(userId) + const settings = ctx ? await getSettingsViaCtx(ctx) : await getSettings(userId) if (!settings.invoiceSentEnabled) return - const supabase = await createClient() + const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient() const notificationPayload = createInvoiceSentPayload( invoice.invoice_number, invoice.id @@ -156,19 +166,20 @@ async function handleInvoiceSent( ) if (result.sent) { - console.log(`[push-notifications] Invoice sent notification for #${invoice.invoice_number}`) + (ctx?.log ?? console).info(`Invoice sent notification for #${invoice.invoice_number}`) } } async function handleReceiptExtracted( - payload: EventPayload<'receipt.extracted'> + payload: EventPayload<'receipt.extracted'>, + ctx?: ExtensionContext ): Promise { const { receipt, userId } = payload - const settings = await getSettings(userId) + const settings = ctx ? await getSettingsViaCtx(ctx) : await getSettings(userId) if (!settings.receiptExtractedEnabled) return - const supabase = await createClient() + const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient() const notificationPayload = createReceiptExtractedPayload( receipt.merchant_name, receipt.id @@ -183,19 +194,20 @@ async function handleReceiptExtracted( ) if (result.sent) { - console.log(`[push-notifications] Receipt extracted notification for ${receipt.id}`) + (ctx?.log ?? console).info(`Receipt extracted notification for ${receipt.id}`) } } async function handleReceiptMatched( - payload: EventPayload<'receipt.matched'> + payload: EventPayload<'receipt.matched'>, + ctx?: ExtensionContext ): Promise { const { receipt, transaction, userId } = payload - const settings = await getSettings(userId) + const settings = ctx ? await getSettingsViaCtx(ctx) : await getSettings(userId) if (!settings.receiptMatchedEnabled) return - const supabase = await createClient() + const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient() const notificationPayload = createReceiptMatchedPayload(receipt.id, transaction.id) const result = await sendNotificationToUser( @@ -207,7 +219,7 @@ async function handleReceiptMatched( ) if (result.sent) { - console.log(`[push-notifications] Receipt matched notification for ${receipt.id}`) + (ctx?.log ?? console).info(`Receipt matched notification for ${receipt.id}`) } } @@ -231,6 +243,6 @@ export const pushNotificationsExtension: Extension = { path: '/settings/extensions/push-notifications', }, async onInstall(ctx) { - await saveSettings(ctx.userId, DEFAULT_SETTINGS) + await ctx.settings.set('settings', DEFAULT_SETTINGS) }, } diff --git a/extensions/general/receipt-ocr/index.ts b/extensions/general/receipt-ocr/index.ts index 80234ec9..830a9830 100644 --- a/extensions/general/receipt-ocr/index.ts +++ b/extensions/general/receipt-ocr/index.ts @@ -1,11 +1,9 @@ -import { createClient } from '@/lib/supabase/server' -import { eventBus } from '@/lib/events/bus' import { analyzeReceipt } from './lib/receipt-analyzer' import { processLineItems } from './lib/receipt-categorizer' import { autoMatchReceipts } from './lib/receipt-matcher' -import type { Extension } from '@/lib/extensions/types' +import type { Extension, ExtensionContext } from '@/lib/extensions/types' import type { EventPayload } from '@/lib/events/types' -import type { Receipt, Transaction } from '@/types' +import type { Receipt } from '@/types' // ============================================================ // Settings @@ -25,7 +23,15 @@ const DEFAULT_SETTINGS: ReceiptOcrSettings = { ocrConfidenceThreshold: 0.6, } +/** Get settings via ExtensionContext (preferred in event handlers) */ +async function getSettingsViaCtx(ctx: ExtensionContext): Promise { + const stored = await ctx.settings.get>() + return { ...DEFAULT_SETTINGS, ...(stored || {}) } +} + +/** Get settings for external callers (settings routes, API routes) */ export async function getSettings(userId: string): Promise { + const { createClient } = await import('@/lib/supabase/server') const supabase = await createClient() const { data } = await supabase @@ -49,6 +55,7 @@ export async function saveSettings( const current = await getSettings(userId) const merged = { ...current, ...partial } + const { createClient } = await import('@/lib/supabase/server') const supabase = await createClient() await supabase @@ -76,9 +83,11 @@ const IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'] * When an image is uploaded via the document archive, auto-trigger OCR. */ async function handleDocumentUploaded( - payload: EventPayload<'document.uploaded'> + payload: EventPayload<'document.uploaded'>, + ctx?: ExtensionContext ): Promise { const { document, userId } = payload + const log = ctx?.log ?? console // Gate: Is it an image? if (!document.mime_type || !IMAGE_MIME_TYPES.includes(document.mime_type)) { @@ -86,15 +95,15 @@ async function handleDocumentUploaded( } // Gate: Is autoOcrEnabled? - const settings = await getSettings(userId) + const settings = ctx ? await getSettingsViaCtx(ctx) : await getSettings(userId) if (!settings.autoOcrEnabled) { return } - console.log(`[receipt-ocr] Auto-OCR triggered for document ${document.id}`) + log.info(`Auto-OCR triggered for document ${document.id}`) try { - const supabase = await createClient() + const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient() // Download image from storage const { data: fileData, error: downloadError } = await supabase.storage @@ -102,7 +111,7 @@ async function handleDocumentUploaded( .download(document.storage_path) if (downloadError || !fileData) { - console.error('[receipt-ocr] Failed to download document:', downloadError) + log.error('Failed to download document:', downloadError) return } @@ -116,8 +125,8 @@ async function handleDocumentUploaded( // Gate: Is confidence high enough? if (extraction.confidence < settings.ocrConfidenceThreshold) { - console.log( - `[receipt-ocr] Confidence ${extraction.confidence} below threshold ${settings.ocrConfidenceThreshold}, skipping` + log.info( + `Confidence ${extraction.confidence} below threshold ${settings.ocrConfidenceThreshold}, skipping` ) return } @@ -155,7 +164,7 @@ async function handleDocumentUploaded( .single() if (insertError || !receipt) { - console.error('[receipt-ocr] Failed to create receipt:', insertError) + log.error('Failed to create receipt:', insertError) return } @@ -188,7 +197,8 @@ async function handleDocumentUploaded( .single() // Emit receipt.extracted - await eventBus.emit({ + const emit = ctx?.emit ?? (await import('@/lib/events/bus')).eventBus.emit.bind((await import('@/lib/events/bus')).eventBus) + await emit({ type: 'receipt.extracted', payload: { receipt: (completeReceipt || receipt) as unknown as Receipt, @@ -198,9 +208,9 @@ async function handleDocumentUploaded( }, }) - console.log(`[receipt-ocr] Receipt ${receipt.id} created from document ${document.id}`) + log.info(`Receipt ${receipt.id} created from document ${document.id}`) } catch (error) { - console.error('[receipt-ocr] handleDocumentUploaded failed:', error) + log.error('handleDocumentUploaded failed:', error) } } @@ -208,12 +218,14 @@ async function handleDocumentUploaded( * When new transactions arrive from banking sync, auto-match unmatched receipts. */ async function handleTransactionSynced( - payload: EventPayload<'transaction.synced'> + payload: EventPayload<'transaction.synced'>, + ctx?: ExtensionContext ): Promise { const { transactions: syncedTransactions, userId } = payload + const log = ctx?.log ?? console // Gate: Is autoMatchEnabled? - const settings = await getSettings(userId) + const settings = ctx ? await getSettingsViaCtx(ctx) : await getSettings(userId) if (!settings.autoMatchEnabled) { return } @@ -224,12 +236,10 @@ async function handleTransactionSynced( return } - console.log( - `[receipt-ocr] Auto-match triggered for ${expenseTransactions.length} expense transactions` - ) + log.info(`Auto-match triggered for ${expenseTransactions.length} expense transactions`) try { - const supabase = await createClient() + const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient() // Fetch unmatched receipts const { data: unmatchedReceipts, error: fetchError } = await supabase @@ -250,6 +260,8 @@ async function handleTransactionSynced( settings.autoMatchThreshold ) + const emit = ctx?.emit ?? (await import('@/lib/events/bus')).eventBus.emit.bind((await import('@/lib/events/bus')).eventBus) + // Process each match for (const { receipt, match } of matches) { // Update receipt with match @@ -268,7 +280,7 @@ async function handleTransactionSynced( .eq('id', match.transaction.id) // Emit receipt.matched - await eventBus.emit({ + await emit({ type: 'receipt.matched', payload: { receipt, @@ -279,12 +291,12 @@ async function handleTransactionSynced( }, }) - console.log( - `[receipt-ocr] Auto-matched receipt ${receipt.id} to transaction ${match.transaction.id} (confidence: ${match.confidence})` + log.info( + `Auto-matched receipt ${receipt.id} to transaction ${match.transaction.id} (confidence: ${match.confidence})` ) } } catch (error) { - console.error('[receipt-ocr] handleTransactionSynced failed:', error) + log.error('handleTransactionSynced failed:', error) } } @@ -317,6 +329,6 @@ export const receiptOcrExtension: Extension = { path: '/settings/extensions/receipt-ocr', }, async onInstall(ctx) { - await saveSettings(ctx.userId, DEFAULT_SETTINGS) + await ctx.settings.set('settings', DEFAULT_SETTINGS) }, } diff --git a/lib/bookkeeping/handlers/__tests__/supplier-invoice-handler.test.ts b/lib/bookkeeping/handlers/__tests__/supplier-invoice-handler.test.ts new file mode 100644 index 00000000..f67195bd --- /dev/null +++ b/lib/bookkeeping/handlers/__tests__/supplier-invoice-handler.test.ts @@ -0,0 +1,123 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { eventBus } from '@/lib/events/bus' +import { + createQueuedMockSupabase, + makeSupplierInvoice, +} from '@/tests/helpers' + +vi.mock('@/lib/supabase/server', () => ({ + createClient: vi.fn(), +})) + +vi.mock('@/lib/bookkeeping/supplier-invoice-entries', () => ({ + createSupplierInvoiceRegistrationEntry: vi.fn(), +})) + +import { createClient } from '@/lib/supabase/server' +import { createSupplierInvoiceRegistrationEntry } from '@/lib/bookkeeping/supplier-invoice-entries' +import { registerSupplierInvoiceHandler } from '../supplier-invoice-handler' + +const mockCreateClient = vi.mocked(createClient) +const mockCreateEntry = vi.mocked(createSupplierInvoiceRegistrationEntry) + +describe('Supplier Invoice Core Handler', () => { + let unsubscribe: () => void + + beforeEach(() => { + vi.clearAllMocks() + eventBus.clear() + unsubscribe = registerSupplierInvoiceHandler() + }) + + afterEach(() => { + unsubscribe() + }) + + it('creates registration journal entry for accrual method', async () => { + const { supabase, enqueueMany } = createQueuedMockSupabase() + enqueueMany([ + // 1. company_settings + { data: { accounting_method: 'accrual' }, error: null }, + // 2. supplier_invoice_items + { data: [{ id: 'item-1', account_number: '6200', line_total: 1000, sort_order: 0 }], error: null }, + // 3. supplier (type) + { data: { supplier_type: 'swedish_business' }, error: null }, + // 4. Update invoice with journal entry id + { data: null, error: null }, + ]) + mockCreateClient.mockResolvedValue(supabase as never) + mockCreateEntry.mockResolvedValue({ id: 'je-1' } as never) + + const invoice = makeSupplierInvoice({ id: 'si-1', supplier_id: 'sup-1' }) + + await eventBus.emit({ + type: 'supplier_invoice.confirmed', + payload: { + inboxItem: { id: 'inbox-1' } as never, + supplierInvoice: invoice, + userId: 'user-1', + }, + }) + + expect(mockCreateEntry).toHaveBeenCalledWith( + 'user-1', + invoice, + expect.any(Array), + 'swedish_business' + ) + }) + + it('skips journal entry for cash method', async () => { + const { supabase, enqueueMany } = createQueuedMockSupabase() + enqueueMany([ + // company_settings with cash method + { data: { accounting_method: 'cash' }, error: null }, + ]) + mockCreateClient.mockResolvedValue(supabase as never) + + const invoice = makeSupplierInvoice({ id: 'si-2' }) + + await eventBus.emit({ + type: 'supplier_invoice.confirmed', + payload: { + inboxItem: { id: 'inbox-2' } as never, + supplierInvoice: invoice, + userId: 'user-1', + }, + }) + + expect(mockCreateEntry).not.toHaveBeenCalled() + }) + + it('handles journal entry creation failure gracefully', async () => { + const { supabase, enqueueMany } = createQueuedMockSupabase() + enqueueMany([ + { data: { accounting_method: 'accrual' }, error: null }, + { data: [{ id: 'item-1', account_number: '6200', line_total: 500, sort_order: 0 }], error: null }, + { data: { supplier_type: 'swedish_business' }, error: null }, + ]) + mockCreateClient.mockResolvedValue(supabase as never) + mockCreateEntry.mockRejectedValue(new Error('No fiscal period')) + + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + const invoice = makeSupplierInvoice({ id: 'si-3' }) + + // Should not throw + await eventBus.emit({ + type: 'supplier_invoice.confirmed', + payload: { + inboxItem: { id: 'inbox-3' } as never, + supplierInvoice: invoice, + userId: 'user-1', + }, + }) + + expect(consoleSpy).toHaveBeenCalledWith( + '[supplier-invoice-handler] Failed to create registration journal entry:', + expect.any(Error) + ) + + consoleSpy.mockRestore() + }) +}) diff --git a/lib/bookkeeping/handlers/supplier-invoice-handler.ts b/lib/bookkeeping/handlers/supplier-invoice-handler.ts new file mode 100644 index 00000000..b619bffb --- /dev/null +++ b/lib/bookkeeping/handlers/supplier-invoice-handler.ts @@ -0,0 +1,77 @@ +import { eventBus } from '@/lib/events/bus' +import type { EventPayload } from '@/lib/events/types' +import { createClient } from '@/lib/supabase/server' +import { createSupplierInvoiceRegistrationEntry } from '@/lib/bookkeeping/supplier-invoice-entries' +import type { SupplierInvoiceItem } from '@/types' + +/** + * Core event handler: creates a registration journal entry when a supplier + * invoice is confirmed (accrual method only). + * + * This decouples journal entry creation from the invoice-inbox extension, + * making it a core concern triggered by the `supplier_invoice.confirmed` event. + */ +async function handleSupplierInvoiceConfirmed( + payload: EventPayload<'supplier_invoice.confirmed'> +): Promise { + const { supplierInvoice, userId } = payload + + const supabase = await createClient() + + // Check accounting method + const { data: settings } = await supabase + .from('company_settings') + .select('accounting_method') + .eq('user_id', userId) + .single() + + const accountingMethod = settings?.accounting_method || 'accrual' + if (accountingMethod !== 'accrual') return + + // Fetch invoice items + const { data: items, error: itemsError } = await supabase + .from('supplier_invoice_items') + .select('*') + .eq('supplier_invoice_id', supplierInvoice.id) + .order('sort_order') + + if (itemsError || !items || items.length === 0) { + console.error('[supplier-invoice-handler] Failed to fetch invoice items:', itemsError) + return + } + + // Fetch supplier type + const { data: supplier } = await supabase + .from('suppliers') + .select('supplier_type') + .eq('id', supplierInvoice.supplier_id) + .single() + + const supplierType = supplier?.supplier_type || 'swedish_business' + + try { + const journalEntry = await createSupplierInvoiceRegistrationEntry( + userId, + supplierInvoice, + items as SupplierInvoiceItem[], + supplierType + ) + + if (journalEntry) { + await supabase + .from('supplier_invoices') + .update({ registration_journal_entry_id: journalEntry.id }) + .eq('id', supplierInvoice.id) + } + } catch (err) { + console.error('[supplier-invoice-handler] Failed to create registration journal entry:', err) + } +} + +/** + * Register the core supplier invoice handler on the event bus. + * Returns an unsubscribe function. + */ +export function registerSupplierInvoiceHandler(): () => void { + return eventBus.on('supplier_invoice.confirmed', handleSupplierInvoiceConfirmed) +} diff --git a/lib/bookkeeping/invoice-entries.ts b/lib/bookkeeping/invoice-entries.ts index 440c8e55..a430149b 100644 --- a/lib/bookkeeping/invoice-entries.ts +++ b/lib/bookkeeping/invoice-entries.ts @@ -1,6 +1,6 @@ import { createJournalEntry, findFiscalPeriod } from './engine' import { generateSalesVatLines, generateReverseChargeLines } from './vat-entries' -import { getVatTreatmentForRate } from '@/lib/invoice/vat-rules' +import { getVatTreatmentForRate } from '@/lib/invoices/vat-rules' import type { CreateJournalEntryInput, CreateJournalEntryLineInput, diff --git a/lib/extensions/__tests__/context-factory.test.ts b/lib/extensions/__tests__/context-factory.test.ts new file mode 100644 index 00000000..c14a7281 --- /dev/null +++ b/lib/extensions/__tests__/context-factory.test.ts @@ -0,0 +1,126 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { createExtensionContext } from '../context-factory' +import { eventBus } from '@/lib/events/bus' +import { createMockSupabase } from '@/tests/helpers' + +vi.mock('@/lib/transactions/ingest', () => ({ + ingestTransactions: vi.fn().mockResolvedValue({ + imported: 0, duplicates: 0, reconciled: 0, + auto_categorized: 0, auto_matched_invoices: 0, errors: 0, + transaction_ids: [], + }), +})) + +beforeEach(() => { + eventBus.clear() + vi.clearAllMocks() +}) + +describe('createExtensionContext', () => { + it('returns context with correct userId and extensionId', () => { + const { supabase } = createMockSupabase() + const ctx = createExtensionContext(supabase as never, 'user-1', 'test-ext') + + expect(ctx.userId).toBe('user-1') + expect(ctx.extensionId).toBe('test-ext') + }) + + it('provides supabase client', () => { + const { supabase } = createMockSupabase() + const ctx = createExtensionContext(supabase as never, 'user-1', 'test-ext') + + expect(ctx.supabase).toBe(supabase) + }) + + it('emit() delegates to eventBus.emit()', async () => { + const { supabase } = createMockSupabase() + const ctx = createExtensionContext(supabase as never, 'user-1', 'test-ext') + + const handler = vi.fn() + eventBus.on('journal_entry.committed', handler) + + await ctx.emit({ + type: 'journal_entry.committed', + payload: { entry: { id: 'e1' } as never, userId: 'user-1' }, + }) + + expect(handler).toHaveBeenCalledWith({ entry: { id: 'e1' }, userId: 'user-1' }) + }) + + it('log methods prefix with extensionId', () => { + const { supabase } = createMockSupabase() + const ctx = createExtensionContext(supabase as never, 'user-1', 'my-ext') + + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + ctx.log.info('hello', 42) + ctx.log.warn('caution') + ctx.log.error('oops') + + expect(logSpy).toHaveBeenCalledWith('[my-ext]', 'hello', 42) + expect(warnSpy).toHaveBeenCalledWith('[my-ext]', 'caution') + expect(errorSpy).toHaveBeenCalledWith('[my-ext]', 'oops') + + logSpy.mockRestore() + warnSpy.mockRestore() + errorSpy.mockRestore() + }) + + it('settings.get() queries extension_data table', async () => { + const { supabase, mockResult } = createMockSupabase() + mockResult({ data: { value: { autoOcr: true } }, error: null }) + + const ctx = createExtensionContext(supabase as never, 'user-1', 'receipt-ocr') + const result = await ctx.settings.get<{ autoOcr: boolean }>('settings') + + expect(result).toEqual({ autoOcr: true }) + expect(supabase.from).toHaveBeenCalledWith('extension_data') + }) + + it('settings.get() without key defaults to "settings"', async () => { + const { supabase, mockResult } = createMockSupabase() + mockResult({ data: { value: { foo: 'bar' } }, error: null }) + + const ctx = createExtensionContext(supabase as never, 'user-1', 'test-ext') + const result = await ctx.settings.get<{ foo: string }>() + + expect(result).toEqual({ foo: 'bar' }) + }) + + it('settings.get() returns null when no data', async () => { + const { supabase, mockResult } = createMockSupabase() + mockResult({ data: null, error: null }) + + const ctx = createExtensionContext(supabase as never, 'user-1', 'test-ext') + const result = await ctx.settings.get('missing-key') + + expect(result).toBeNull() + }) + + it('settings.set() upserts into extension_data table', async () => { + const { supabase, mockResult } = createMockSupabase() + mockResult({ data: null, error: null }) + + const ctx = createExtensionContext(supabase as never, 'user-1', 'test-ext') + await ctx.settings.set('my-key', { value: 123 }) + + expect(supabase.from).toHaveBeenCalledWith('extension_data') + }) + + it('storage.getPublicUrl() returns URL string', () => { + const { supabase } = createMockSupabase() + const ctx = createExtensionContext(supabase as never, 'user-1', 'test-ext') + + const url = ctx.storage.getPublicUrl('documents', 'path/to/file.pdf') + expect(typeof url).toBe('string') + }) + + it('services.ingestTransactions is a function', () => { + const { supabase } = createMockSupabase() + const ctx = createExtensionContext(supabase as never, 'user-1', 'test-ext') + + expect(typeof ctx.services.ingestTransactions).toBe('function') + }) +}) diff --git a/lib/extensions/__tests__/registry.test.ts b/lib/extensions/__tests__/registry.test.ts index 7c15deb1..5296ec8b 100644 --- a/lib/extensions/__tests__/registry.test.ts +++ b/lib/extensions/__tests__/registry.test.ts @@ -1,7 +1,14 @@ import { describe, it, expect, beforeEach, vi } from 'vitest' -import { extensionRegistry } from '../registry' +import { extensionRegistry, setContextFactory } from '../registry' import { eventBus } from '@/lib/events/bus' -import type { Extension } from '../types' +import type { Extension, ExtensionContext } from '../types' + +vi.mock('@/lib/supabase/server', () => ({ + createClient: vi.fn().mockResolvedValue({ + from: vi.fn(), + storage: { from: vi.fn() }, + }), +})) beforeEach(() => { extensionRegistry.clear() @@ -40,7 +47,8 @@ describe('ExtensionRegistry', () => { payload: { entry: { id: 'e1' } as never, userId: 'u1' }, }) - expect(handler).toHaveBeenCalledWith({ entry: { id: 'e1' }, userId: 'u1' }) + expect(handler).toHaveBeenCalled() + expect(handler.mock.calls[0][0]).toEqual({ entry: { id: 'e1' }, userId: 'u1' }) }) it('register() skips duplicate registration (same id)', () => { @@ -126,4 +134,28 @@ describe('ExtensionRegistry', () => { expect(handler1).not.toHaveBeenCalled() expect(handler2).not.toHaveBeenCalled() }) + + it('handler receives ExtensionContext when context factory is set', async () => { + const handler = vi.fn() + const mockCtx = { extensionId: 'ctx-ext', userId: 'u1' } as ExtensionContext + + setContextFactory((_supabase, _userId, _extId) => mockCtx) + + const ext = makeExtension({ + id: 'ctx-ext', + eventHandlers: [{ eventType: 'journal_entry.committed', handler }], + }) + + extensionRegistry.register(ext) + + await eventBus.emit({ + type: 'journal_entry.committed', + payload: { entry: { id: 'e1' } as never, userId: 'u1' }, + }) + + expect(handler).toHaveBeenCalledWith( + { entry: { id: 'e1' }, userId: 'u1' }, + mockCtx + ) + }) }) diff --git a/lib/extensions/context-factory.ts b/lib/extensions/context-factory.ts new file mode 100644 index 00000000..131f8cab --- /dev/null +++ b/lib/extensions/context-factory.ts @@ -0,0 +1,122 @@ +import type { SupabaseClient } from '@supabase/supabase-js' +import type { CoreEvent } from '@/lib/events/types' +import { eventBus } from '@/lib/events/bus' +import { ingestTransactions } from '@/lib/transactions/ingest' +import type { + ExtensionContext, + ExtensionLogger, + ExtensionSettings, + ExtensionStorage, + ExtensionServices, +} from './types' + +/** + * Create a prefixed logger for an extension. + */ +function createLogger(extensionId: string): ExtensionLogger { + const prefix = `[${extensionId}]` + return { + info: (message: string, ...args: unknown[]) => console.log(prefix, message, ...args), + warn: (message: string, ...args: unknown[]) => console.warn(prefix, message, ...args), + error: (message: string, ...args: unknown[]) => console.error(prefix, message, ...args), + } +} + +/** + * Create a settings accessor scoped to a specific extension. + */ +function createSettings( + supabase: SupabaseClient, + userId: string, + extensionId: string +): ExtensionSettings { + return { + async get(key?: string): Promise { + const lookupKey = key ?? 'settings' + const { data } = await supabase + .from('extension_data') + .select('value') + .eq('user_id', userId) + .eq('extension_id', extensionId) + .eq('key', lookupKey) + .single() + + return (data?.value as T) ?? null + }, + + async set(key: string, value: T): Promise { + await supabase + .from('extension_data') + .upsert( + { + user_id: userId, + extension_id: extensionId, + key, + value, + }, + { onConflict: 'user_id,extension_id,key' } + ) + }, + } +} + +/** + * Create a storage accessor wrapping Supabase storage. + */ +function createStorage(supabase: SupabaseClient): ExtensionStorage { + return { + async download(bucket: string, path: string) { + const { data, error } = await supabase.storage + .from(bucket) + .download(path) + return { data, error: error?.message } + }, + + async upload(bucket: string, path: string, data: ArrayBuffer, options?: { contentType?: string }) { + const { error } = await supabase.storage + .from(bucket) + .upload(path, data, options ? { contentType: options.contentType } : undefined) + if (error) return { path: '', error: error.message } + return { path } + }, + + getPublicUrl(bucket: string, path: string): string { + const { data } = supabase.storage + .from(bucket) + .getPublicUrl(path) + return data.publicUrl + }, + } +} + +/** + * Create core services exposed to extensions. + */ +function createServices(): ExtensionServices { + return { + ingestTransactions, + } +} + +/** + * Build a fully populated ExtensionContext. + * + * The context gives extensions access to Supabase, event emission, settings, + * storage, logging, and core services — without importing from core modules. + */ +export function createExtensionContext( + supabase: SupabaseClient, + userId: string, + extensionId: string +): ExtensionContext { + return { + userId, + extensionId, + supabase, + emit: (event: CoreEvent) => eventBus.emit(event), + settings: createSettings(supabase, userId, extensionId), + storage: createStorage(supabase), + log: createLogger(extensionId), + services: createServices(), + } +} diff --git a/lib/extensions/registry.ts b/lib/extensions/registry.ts index 55d33957..21809846 100644 --- a/lib/extensions/registry.ts +++ b/lib/extensions/registry.ts @@ -1,6 +1,25 @@ import { eventBus } from '@/lib/events/bus' import type { CoreEventType } from '@/lib/events/types' -import type { Extension } from './types' +import type { Extension, ExtensionContext } from './types' +import type { SupabaseClient } from '@supabase/supabase-js' + +/** Factory function type for creating extension contexts */ +export type ContextFactory = ( + supabase: SupabaseClient, + userId: string, + extensionId: string +) => ExtensionContext + +/** Lazy-loaded context factory — set during initialization */ +let contextFactory: ContextFactory | null = null + +/** + * Set the context factory used to build ExtensionContext for event handlers. + * Called once during system initialization. + */ +export function setContextFactory(factory: ContextFactory): void { + contextFactory = factory +} /** * Extension Registry — singleton that manages extension lifecycle. @@ -15,6 +34,10 @@ class ExtensionRegistry { /** * Register an extension: store it and wire its event handlers to the bus. + * + * Each handler is wrapped so it receives `(payload, ctx)`. The context is + * built lazily when the event fires, using the userId from the payload and + * a Supabase client created from the current request cookies. */ register(extension: Extension): void { if (this.extensions.has(extension.id)) { @@ -28,8 +51,24 @@ class ExtensionRegistry { const unsubs: (() => void)[] = [] if (extension.eventHandlers) { for (const { eventType, handler } of extension.eventHandlers) { - // Cast is safe: the handler is stored by eventType key, so it only receives matching payloads - const unsub = eventBus.on(eventType as CoreEventType, handler) + // Wrap handler to inject ExtensionContext as second argument + const wrappedHandler = async (payload: { userId: string; [key: string]: unknown }) => { + let ctx: ExtensionContext | undefined + if (contextFactory && payload.userId) { + try { + // Dynamic import to avoid circular deps at module load time + const { createClient } = await import('@/lib/supabase/server') + const supabase = await createClient() + ctx = contextFactory(supabase, payload.userId, extension.id) + } catch { + // Context creation failed (e.g. no request cookies in cron jobs). + // Handler still gets called — ctx will be undefined. + } + } + return handler(payload, ctx) + } + + const unsub = eventBus.on(eventType as CoreEventType, wrappedHandler) unsubs.push(unsub) } } diff --git a/lib/extensions/types.ts b/lib/extensions/types.ts index bfd917a5..b74cacf8 100644 --- a/lib/extensions/types.ts +++ b/lib/extensions/types.ts @@ -1,5 +1,6 @@ -import type { CoreEventType } from '@/lib/events/types' -import type { EntityType } from '@/types' +import type { SupabaseClient } from '@supabase/supabase-js' +import type { CoreEvent, CoreEventType } from '@/lib/events/types' +import type { EntityType, RawTransaction, IngestResult } from '@/types' // ============================================================ // Extension Marketplace Types @@ -63,7 +64,7 @@ export interface RouteDefinition { export interface ApiRouteDefinition { method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' path: string - handler: (request: Request) => Promise + handler: (request: Request, ctx?: ExtensionContext) => Promise } /** Sidebar navigation item added by an extension */ @@ -112,13 +113,44 @@ export interface MappingRuleTypeDefinition { export interface ExtensionEventHandler { eventType: CoreEventType // eslint-disable-next-line @typescript-eslint/no-explicit-any - handler: (payload: any) => Promise | void + handler: (payload: any, ctx?: ExtensionContext) => Promise | void } -/** Context passed to extension lifecycle hooks */ +/** Logger interface for extensions */ +export interface ExtensionLogger { + info(message: string, ...args: unknown[]): void + warn(message: string, ...args: unknown[]): void + error(message: string, ...args: unknown[]): void +} + +/** Settings accessor for extension-scoped key-value data */ +export interface ExtensionSettings { + get(key?: string): Promise + set(key: string, value: T): Promise +} + +/** Storage accessor wrapping Supabase storage */ +export interface ExtensionStorage { + download(bucket: string, path: string): Promise<{ data: Blob | null; error?: string }> + upload(bucket: string, path: string, data: ArrayBuffer, options?: { contentType?: string }): Promise<{ path: string; error?: string }> + getPublicUrl(bucket: string, path: string): string +} + +/** Core services exposed to extensions */ +export interface ExtensionServices { + ingestTransactions(supabase: SupabaseClient, userId: string, raw: RawTransaction[]): Promise +} + +/** Context passed to extension lifecycle hooks and event handlers */ export interface ExtensionContext { userId: string extensionId: string + supabase: SupabaseClient + emit(event: CoreEvent): Promise + settings: ExtensionSettings + storage: ExtensionStorage + log: ExtensionLogger + services: ExtensionServices } /** diff --git a/lib/init.ts b/lib/init.ts index 1b40eb85..5047fd48 100644 --- a/lib/init.ts +++ b/lib/init.ts @@ -1,10 +1,21 @@ import { loadExtensions } from '@/lib/extensions/loader' +import { setContextFactory } from '@/lib/extensions/registry' +import { createExtensionContext } from '@/lib/extensions/context-factory' +import { registerSupplierInvoiceHandler } from '@/lib/bookkeeping/handlers/supplier-invoice-handler' + +let initialized = false /** - * Ensure the system is initialized (extensions loaded). + * Ensure the system is initialized (extensions loaded, context factory wired, + * core event handlers registered). * Called from API routes that emit events. * Idempotent — safe to call multiple times. */ export function ensureInitialized(): void { + if (initialized) return + initialized = true + + setContextFactory(createExtensionContext) + registerSupplierInvoiceHandler() loadExtensions() } diff --git a/lib/transactions/ingest.ts b/lib/transactions/ingest.ts index fb1171ed..7000ed7b 100644 --- a/lib/transactions/ingest.ts +++ b/lib/transactions/ingest.ts @@ -4,34 +4,10 @@ import { createTransactionJournalEntry } from '@/lib/bookkeeping/transaction-ent import { getBestInvoiceMatch } from '@/lib/invoices/invoice-matching' import { tryReconcileTransaction, fetchUnlinkedGLLines } from '@/lib/reconciliation/bank-reconciliation' import type { UnlinkedGLLine } from '@/lib/reconciliation/bank-reconciliation' -import type { Transaction } from '@/types' +import type { Transaction, RawTransaction, IngestResult } from '@/types' -/** - * Normalized transaction input for the generic ingestion pipeline. - * Both file import and PSD2 sync convert to this format before ingesting. - */ -export interface RawTransaction { - date: string - description: string - amount: number - currency: string - external_id: string // dedup key - mcc_code?: number | null - merchant_name?: string | null - reference?: string | null // OCR number, Bankgiro ref, etc. - bank_connection_id?: string | null - import_source?: string // 'csv_nordea', 'camt053', 'enable_banking', etc. -} - -export interface IngestResult { - imported: number - duplicates: number - reconciled: number - auto_categorized: number - auto_matched_invoices: number - errors: number - transaction_ids: string[] -} +// Re-export types for backward compatibility +export type { RawTransaction, IngestResult } from '@/types' /** * Generic transaction ingestion pipeline. diff --git a/tests/helpers.ts b/tests/helpers.ts index 00a306bd..74e7d14c 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -302,6 +302,8 @@ export function makeInvoice(overrides: Partial = {}): Invoice { notes: null, reverse_charge_text: null, credited_invoice_id: null, + document_type: 'invoice', + converted_from_id: null, paid_at: null, paid_amount: null, created_at: '2024-06-15T14:30:00Z', @@ -436,6 +438,7 @@ export function makeCompanySettings( invoice_prefix: 'F', next_invoice_number: 1, invoice_default_days: 30, + invoice_default_notes: null, onboarding_step: 6, onboarding_complete: true, sector_slug: null, diff --git a/types/index.ts b/types/index.ts index 2918f459..3827c2ca 100644 --- a/types/index.ts +++ b/types/index.ts @@ -1659,3 +1659,32 @@ export const REMINDER_LEVEL_DESCRIPTIONS: Record<1 | 2 | 3, string> = { 2: '30 dagar efter förfallodatum', 3: '45 dagar efter förfallodatum' } + +// ============================================================ +// Transaction Ingestion Types (re-exported for extension use) +// ============================================================ + +/** Normalized transaction input for the generic ingestion pipeline */ +export interface RawTransaction { + date: string + description: string + amount: number + currency: string + external_id: string + mcc_code?: number | null + merchant_name?: string | null + reference?: string | null + bank_connection_id?: string | null + import_source?: string +} + +/** Result of the transaction ingestion pipeline */ +export interface IngestResult { + imported: number + duplicates: number + reconciled: number + auto_categorized: number + auto_matched_invoices: number + errors: number + transaction_ids: string[] +}