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) // ============================================================