Invoice fix and new inbound invoice extension etc

This commit is contained in:
Jakob Wennberg
2026-02-21 17:41:14 +01:00
parent 43172751e9
commit 59d935f2cc
41 changed files with 4698 additions and 720 deletions
File diff suppressed because it is too large Load Diff
@@ -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<string, unknown> }), 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<string, unknown>,
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)
})
})
@@ -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 })
}
}
@@ -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<string, unknown>,
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 })
}
}
@@ -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<string, unknown> = {}
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 })
}
@@ -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)
})
})
})
@@ -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<void> {
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<string, unknown>,
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)
}
}
@@ -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 })
}
@@ -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)
})
})
@@ -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<string, unknown>,
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 } })
}
+2 -2
View File
@@ -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'
/**
@@ -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)
@@ -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<VatTreatment | 'none'>('standard_25')
const isProcessing = progress !== null
const handleSelectCategory = (category: TransactionCategory) => {
const resolvedVat = vatTreatment === 'none' ? undefined : vatTreatment
onSelectCategory(category, resolvedVat)
}
return (
<Dialog open={open} onOpenChange={isProcessing ? undefined : onOpenChange}>
<DialogContent className="max-w-md">
@@ -67,6 +60,24 @@ export default function BatchCategorySelector({
</div>
) : (
<div className="space-y-4 py-2">
<div>
<h4 className="text-sm font-medium text-muted-foreground mb-2">Momsbehandling</h4>
<Select
value={vatTreatment}
onValueChange={(v) => setVatTreatment(v as VatTreatment | 'none')}
>
<SelectTrigger className="h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
{vatTreatmentOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<h4 className="text-sm font-medium text-muted-foreground mb-2">Kostnader</h4>
<div className="grid grid-cols-2 gap-1.5">
@@ -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}
</Button>
@@ -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}
</Button>
@@ -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 (
<Dialog open={open} onOpenChange={isProcessing ? undefined : onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Välj kategori</DialogTitle>
<DialogDescription>
Välj rätt kategori för att bokföra transaktionen
</DialogDescription>
</DialogHeader>
{/* Transaction summary */}
<div className="flex items-center gap-3 rounded-lg border p-3">
<div
className={`h-9 w-9 rounded-full flex items-center justify-center flex-shrink-0 ${
isIncome
? 'bg-success/10 text-success'
: 'bg-destructive/10 text-destructive'
}`}
>
{isIncome ? (
<ArrowUpRight className="h-4 w-4" />
) : (
<ArrowDownRight className="h-4 w-4" />
)}
</div>
<div className="flex-1 min-w-0">
<p className="font-medium text-sm truncate">{transaction.description}</p>
<p className="text-xs text-muted-foreground">{formatDate(transaction.date)}</p>
</div>
<p className={`font-medium text-sm flex-shrink-0 ${isIncome ? 'text-success' : ''}`}>
{isIncome ? '+' : ''}
{formatCurrency(transaction.amount, transaction.currency)}
</p>
</div>
{/* Category grid */}
<div className="space-y-4 py-2">
<div>
<h4 className="text-sm font-medium text-muted-foreground mb-2">Kostnader</h4>
<div className="grid grid-cols-2 gap-1.5">
{EXPENSE_CATEGORIES.map((cat) => (
<Button
key={cat.value}
variant="outline"
size="sm"
className="justify-start text-xs"
onClick={() => onSelectCategory(cat.value)}
disabled={isProcessing}
>
{cat.label}
</Button>
))}
</div>
</div>
<div>
<h4 className="text-sm font-medium text-muted-foreground mb-2">Intäkter</h4>
<div className="grid grid-cols-2 gap-1.5">
{INCOME_CATEGORIES.map((cat) => (
<Button
key={cat.value}
variant="outline"
size="sm"
className="justify-start text-xs"
onClick={() => onSelectCategory(cat.value)}
disabled={isProcessing}
>
{cat.label}
</Button>
))}
</div>
</div>
</div>
</DialogContent>
</Dialog>
)
}
@@ -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 (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<div className="p-5 rounded-full bg-muted mb-6">
<Upload className="h-8 w-8 text-muted-foreground" />
</div>
<h3 className="text-lg font-medium mb-2">Inga transaktioner</h3>
<p className="text-sm text-muted-foreground text-center max-w-sm mb-6">
Importera kontoutdrag från din bank eller lägg till transaktioner manuellt för att komma igång.
</p>
<div className="flex gap-2">
<Button asChild>
<Link href="/import">
<Upload className="mr-2 h-4 w-4" />
Importera transaktioner
</Link>
</Button>
<Button variant="outline" onClick={onCreateTransaction}>
<Plus className="mr-2 h-4 w-4" />
Lägg till manuellt
</Button>
</div>
</CardContent>
</Card>
)
}
// All transactions categorized - inbox zero!
return (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<div className="h-16 w-16 rounded-full bg-success/10 flex items-center justify-center mb-4">
<Check className="h-8 w-8 text-success" />
</div>
<h3 className="text-lg font-bold">Alla transaktioner bokförda!</h3>
<p className="text-muted-foreground text-center mt-1 max-w-sm">
Bra jobbat! Alla dina transaktioner är bokförda. Importera fler eller växla till historik.
</p>
<div className="flex gap-2 mt-6">
<Button asChild variant="outline">
<Link href="/import">
<Upload className="mr-2 h-4 w-4" />
Importera fler
</Link>
</Button>
<Button variant="outline" onClick={onCreateTransaction}>
<Plus className="mr-2 h-4 w-4" />
Ny transaktion
</Button>
</div>
</CardContent>
</Card>
)
}
@@ -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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Bekräfta fakturamatchning</DialogTitle>
<DialogDescription>
Vill du koppla denna transaktion till fakturan? Fakturan kommer att markeras som betald.
</DialogDescription>
</DialogHeader>
{transaction?.potential_invoice && (
<div className="space-y-4">
{/* Transaction details */}
<div className="rounded-lg border p-4 space-y-2">
<p className="text-sm font-medium text-muted-foreground">Transaktion</p>
<p className="font-medium">{transaction.description}</p>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">{formatDate(transaction.date)}</span>
<span className="font-medium text-success">
+{formatCurrency(transaction.amount, transaction.currency)}
</span>
</div>
</div>
{/* Invoice details */}
<div className="rounded-lg border p-4 space-y-2">
<p className="text-sm font-medium text-muted-foreground">Faktura</p>
<p className="font-medium">
Faktura {transaction.potential_invoice.invoice_number}
</p>
<p className="text-sm text-muted-foreground">
{transaction.potential_invoice.customer?.name || 'Okänd kund'}
</p>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">
Förfaller: {formatDate(transaction.potential_invoice.due_date)}
</span>
<span className="font-medium">
{formatCurrency(
transaction.potential_invoice.total,
transaction.potential_invoice.currency
)}
</span>
</div>
</div>
{/* What will happen */}
<div className="rounded-lg bg-muted/50 p-4 space-y-2">
<p className="text-sm font-medium">Vid bekräftelse:</p>
<ul className="text-sm text-muted-foreground space-y-1">
<li>• Transaktionen kopplas till fakturan</li>
<li>• Fakturan markeras som betald</li>
<li>• Bokföringsverifikation skapas automatiskt</li>
</ul>
</div>
</div>
)}
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isConfirming}
>
Avbryt
</Button>
<Button
onClick={onConfirm}
disabled={isConfirming}
>
{isConfirming ? 'Bekräftar...' : 'Bekräfta matchning'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -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<string, SuggestedCategory[]>
onCategorize: (id: string, isBusiness: boolean, category?: TransactionCategory) => Promise<boolean>
onMatchInvoice?: (transactionId: string, invoiceId: string) => Promise<boolean>
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<string | null>(null)
// Review step state
const [showReviewStep, setShowReviewStep] = useState(false)
const [pendingCategory, setPendingCategory] = useState<TransactionCategory | null>(null)
const [accountOverride, setAccountOverride] = useState('')
const [vatTreatment, setVatTreatment] = useState<VatTreatment | 'none'>('standard_25')
const [accounts, setAccounts] = useState<BASAccount[]>([])
// 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 (
<div className="fixed inset-0 bg-background z-50 flex flex-col">
<div className="flex items-center justify-between p-4 border-b">
<Button
variant="ghost"
size="icon"
onClick={() => {
setShowReviewStep(false)
setShowCategorySelect(true)
}}
>
<ArrowLeft className="h-5 w-5" />
</Button>
<h1 className="font-semibold">Granska bokföring</h1>
<div className="w-10" />
</div>
<div className="flex-1 overflow-auto p-4 space-y-4">
{/* Transaction summary */}
<Card>
<CardContent className="pt-4 space-y-1">
<p className="font-medium">{currentTransaction.description}</p>
<p className="text-sm text-muted-foreground">{formatDate(currentTransaction.date)}</p>
<p className="text-2xl font-bold mt-2">
{currentTransaction.amount > 0 ? '+' : ''}
{formatCurrency(currentTransaction.amount, currentTransaction.currency)}
</p>
</CardContent>
</Card>
{/* Selected category */}
<div>
<label className="text-sm font-medium text-muted-foreground">Kategori</label>
<div className="mt-1">
<Badge variant="outline" className="text-sm py-1 px-3">{categoryLabel}</Badge>
</div>
</div>
{/* Account override */}
<div>
<label className="text-sm font-medium text-muted-foreground">Konto</label>
<div className="mt-1">
<AccountCombobox
value={accountOverride}
accounts={accounts}
onChange={setAccountOverride}
/>
</div>
</div>
{/* VAT treatment */}
<div>
<label className="text-sm font-medium text-muted-foreground">Momsbehandling</label>
<div className="mt-1">
<Select
value={isLiabilityAccount ? 'none' : vatTreatment}
onValueChange={(v) => setVatTreatment(v as VatTreatment | 'none')}
disabled={isLiabilityAccount}
>
<SelectTrigger className="h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
{vatTreatmentOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
{isLiabilityAccount && (
<p className="text-xs text-muted-foreground mt-1">
Ingen moms för skuld-/eget kapital-konton
</p>
)}
</div>
</div>
{error && (
<div className="p-3 rounded-lg bg-destructive/10 text-destructive text-sm">
{error}
</div>
)}
</div>
{/* Actions */}
<div className="p-4 border-t space-y-2">
<Button
className="w-full"
onClick={handleReviewConfirm}
disabled={isProcessing}
>
<Check className="mr-2 h-4 w-4" />
{isProcessing ? 'Bokför...' : 'Bokför'}
</Button>
<Button
variant="ghost"
className="w-full text-muted-foreground"
onClick={handleSkip}
disabled={isProcessing}
>
<SkipForward className="mr-2 h-4 w-4" />
Hoppa över
</Button>
</div>
</div>
)
}
return (
<div className="fixed inset-0 bg-background z-50 flex flex-col">
{/* Header */}
@@ -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<HistoryFilter>('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 (
<div className="space-y-4">
{/* Search + filter pills */}
<div className="flex flex-col sm:flex-row gap-3">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Sök transaktioner..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10"
/>
</div>
<div className="flex gap-1.5">
{(['all', 'business', 'private'] as const).map((f) => (
<Button
key={f}
size="sm"
variant={filter === f ? 'default' : 'outline'}
className="h-9"
onClick={() => setFilter(f)}
>
{f === 'all' ? 'Alla' : f === 'business' ? 'Företag' : 'Privat'}
</Button>
))}
</div>
</div>
{/* Transaction list */}
{filtered.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<ArrowLeftRight className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium">Inga transaktioner</h3>
<p className="text-muted-foreground text-center mt-1">
{searchTerm
? 'Inga transaktioner matchar din sökning'
: 'Inga transaktioner att visa med valt filter'}
</p>
</CardContent>
</Card>
) : (
<div className="space-y-2">
{filtered.map((transaction) => (
<Card key={transaction.id} className="hover:border-primary/50 transition-colors">
<CardContent className="py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div
className={`h-10 w-10 rounded-full flex items-center justify-center ${
transaction.amount > 0
? 'bg-success/10 text-success'
: 'bg-destructive/10 text-destructive'
}`}
>
{transaction.amount > 0 ? (
<ArrowUpRight className="h-5 w-5" />
) : (
<ArrowDownRight className="h-5 w-5" />
)}
</div>
<div>
<p className="font-medium">{transaction.description}</p>
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<span>{formatDate(transaction.date)}</span>
{transaction.is_business !== null &&
!(
transaction.is_business &&
transaction.category === 'uncategorized' &&
transaction.journal_entry_id
) && (
<>
<span>·</span>
<Badge
variant={transaction.is_business ? 'default' : 'secondary'}
>
{transaction.is_business
? getCategoryDisplayName(transaction.category)
: 'Privat'}
</Badge>
</>
)}
{transaction.invoice_id && (
<>
<span>·</span>
<Badge variant="outline" className="text-blue-600 border-blue-600">
<Link2 className="h-3 w-3 mr-1" />
Kopplad till faktura
</Badge>
</>
)}
{transaction.journal_entry_id ? (
<>
<span>·</span>
<Badge variant="outline" className="text-success border-success">
<Check className="h-3 w-3 mr-1" />
Bokförd
</Badge>
</>
) : transaction.is_business === null ? (
<>
<span>·</span>
<Badge variant="outline" className="text-warning border-warning">
Ej bokförd
</Badge>
</>
) : null}
{transaction.potential_invoice && !transaction.invoice_id && (
<>
<span>·</span>
<Badge
variant="outline"
className="text-blue-600 border-blue-600 cursor-pointer hover:bg-blue-50"
onClick={() => onOpenMatchDialog(transaction)}
>
<FileText className="h-3 w-3 mr-1" />
Möjlig match: Faktura {transaction.potential_invoice.invoice_number}
</Badge>
</>
)}
</div>
</div>
</div>
<div className="text-right">
<p
className={`font-medium ${
transaction.amount > 0 ? 'text-success' : ''
}`}
>
{transaction.amount > 0 ? '+' : ''}
{formatCurrency(transaction.amount, transaction.currency)}
</p>
{transaction.currency !== 'SEK' && transaction.amount_sek && (
<p className="text-sm text-muted-foreground">
{formatCurrency(transaction.amount_sek)}
</p>
)}
</div>
</div>
</CardContent>
</Card>
))}
</div>
)}
</div>
)
}
@@ -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 (
<motion.div
layout
initial={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0, marginBottom: 0 }}
transition={{ duration: 0.3, ease: 'easeInOut' }}
onAnimationComplete={(definition) => {
// Only call on exit animation
if (typeof definition === 'object' && 'opacity' in definition && definition.opacity === 0) {
onAnimationComplete?.(transaction.id)
}
}}
>
<Card
className={`transition-colors ${
hasInvoiceMatch ? 'border-blue-500/50' : 'border-warning/50'
} ${isSelected ? 'border-primary bg-primary/[0.02]' : ''} ${
isDisabled ? 'opacity-50' : ''
}`}
onClick={showCheckbox ? () => onToggleSelect(transaction.id) : undefined}
>
<CardContent className="py-4">
<div className="flex items-start justify-between gap-4">
{/* Left: checkbox + icon + info */}
<div className="flex items-start gap-3 min-w-0 flex-1">
{showCheckbox && (
<Checkbox
checked={isSelected}
onCheckedChange={() => onToggleSelect(transaction.id)}
onClick={(e) => e.stopPropagation()}
className="mt-1"
/>
)}
<div
className={`h-10 w-10 rounded-full flex items-center justify-center flex-shrink-0 ${
isIncome
? 'bg-success/10 text-success'
: 'bg-destructive/10 text-destructive'
}`}
>
{isIncome ? (
<ArrowUpRight className="h-5 w-5" />
) : (
<ArrowDownRight className="h-5 w-5" />
)}
</div>
<div className="min-w-0">
<p className="font-medium truncate">{transaction.description}</p>
<p className="text-sm text-muted-foreground">{formatDate(transaction.date)}</p>
</div>
</div>
{/* Right: amount */}
<div className="text-right flex-shrink-0">
<p className={`font-medium ${isIncome ? 'text-success' : ''}`}>
{isIncome ? '+' : ''}
{formatCurrency(transaction.amount, transaction.currency)}
</p>
{transaction.currency !== 'SEK' && transaction.amount_sek && (
<p className="text-sm text-muted-foreground">
{formatCurrency(transaction.amount_sek)}
</p>
)}
</div>
</div>
{/* Inline action buttons - only shown when not in batch mode */}
{!isBatchMode && (
<div className="flex flex-wrap items-center gap-2 mt-3 pt-3 border-t">
{/* Primary action: invoice match or top suggestion */}
{hasInvoiceMatch ? (
<Button
size="sm"
variant="default"
className="h-8 text-xs"
onClick={() => onOpenMatchDialog(transaction)}
disabled={isProcessing || isDisabled}
>
{isProcessing ? (
<Loader2 className="mr-1.5 h-3 w-3 animate-spin" />
) : (
<FileText className="mr-1.5 h-3 w-3" />
)}
Matcha Faktura {transaction.potential_invoice!.invoice_number}
</Button>
) : topSuggestion ? (
<Button
size="sm"
variant="default"
className="h-8 text-xs"
onClick={() => handleSuggestionClick(topSuggestion)}
disabled={isProcessing || isDisabled}
>
{isProcessing ? (
<Loader2 className="mr-1.5 h-3 w-3 animate-spin" />
) : null}
{topSuggestion.label}
{topSuggestion.confidence >= 0.8 && (
<Badge variant="secondary" className="ml-1.5 text-[10px] px-1 py-0">
{Math.round(topSuggestion.confidence * 100)}%
</Badge>
)}
</Button>
) : null}
{/* Secondary suggestions (up to 1 more) */}
{!hasInvoiceMatch && suggestions && suggestions.length > 1 && (
<Button
size="sm"
variant="outline"
className="h-8 text-xs"
onClick={() => handleSuggestionClick(suggestions[1])}
disabled={isProcessing || isDisabled}
>
{suggestions[1].label}
</Button>
)}
{/* Private button */}
<Button
size="sm"
variant="ghost"
className="h-8 text-xs text-muted-foreground"
onClick={() => onMarkPrivate(transaction.id)}
disabled={isProcessing || isDisabled}
>
Privat
</Button>
{/* More options */}
<Button
size="sm"
variant="ghost"
className="h-8 w-8 p-0 text-muted-foreground"
onClick={() => onOpenCategoryDialog(transaction)}
disabled={isProcessing || isDisabled}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</div>
)}
</CardContent>
</Card>
</motion.div>
)
}
@@ -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 (
<div className="space-y-4">
{/* Header with title + actions */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h1 className="text-3xl font-bold tracking-tight">Transaktioner</h1>
{uncategorizedCount > 0 && mode === 'inbox' && (
<p className="text-muted-foreground mt-1">
<span className="text-foreground font-semibold">{uncategorizedCount}</span> att bokföra
{invoiceMatchCount > 0 && (
<span className="ml-2">
· <FileText className="inline h-3.5 w-3.5 text-blue-500" />{' '}
<span className="text-blue-600">{invoiceMatchCount} fakturamatchningar</span>
</span>
)}
</p>
)}
{mode === 'history' && (
<p className="text-muted-foreground">Alla dina transaktioner</p>
)}
</div>
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" asChild>
<Link href="/import">
<Upload className="mr-2 h-4 w-4" />
Importera
</Link>
</Button>
{mode === 'inbox' && uncategorizedCount > 0 && (
<>
<Button
variant="outline"
size="sm"
onClick={onOpenSwipeView}
disabled={isLoadingSuggestions}
>
<Sparkles className="mr-2 h-4 w-4" />
{isLoadingSuggestions ? 'Laddar...' : 'Bokför alla'}
</Button>
<Button
variant={isBatchMode ? 'default' : 'outline'}
size="sm"
onClick={onToggleBatchMode}
>
<CheckSquare className="mr-2 h-4 w-4" />
{isBatchMode ? 'Avsluta' : 'Välj flera'}
</Button>
</>
)}
<Button size="sm" onClick={onOpenCreateDialog}>
<Plus className="mr-2 h-4 w-4" />
Ny transaktion
</Button>
</div>
</div>
{/* Mode toggle - segmented control style */}
<div className="inline-flex rounded-lg border bg-muted p-1">
<Button
variant={mode === 'inbox' ? 'default' : 'ghost'}
size="sm"
className="h-8 rounded-md"
onClick={() => onModeChange('inbox')}
>
Att bokföra
{uncategorizedCount > 0 && (
<Badge
variant={mode === 'inbox' ? 'secondary' : 'outline'}
className="ml-2 text-xs"
>
{uncategorizedCount}
</Badge>
)}
</Button>
<Button
variant={mode === 'history' ? 'default' : 'ghost'}
size="sm"
className="h-8 rounded-md"
onClick={() => onModeChange('history')}
>
Alla transaktioner
</Button>
</div>
</div>
)
}
@@ -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<boolean>
export type MatchInvoiceHandler = (
transactionId: string,
invoiceId: string
) => Promise<boolean>
// 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' },
]
@@ -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)
})
})
})
+213
View File
@@ -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<InvoiceInboxSettings> {
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<InvoiceInboxSettings>) }
}
export async function saveSettings(
userId: string,
partial: Partial<InvoiceInboxSettings>
): Promise<InvoiceInboxSettings> {
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<void> {
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<string, unknown>,
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)
},
}
@@ -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: '<p>Attached</p>',
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()
})
})
})
@@ -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()
})
})
@@ -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['supplier']> = {}): 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)
})
})
})
@@ -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<string | null> {
// 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
}
@@ -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<InvoiceExtractionResult> {
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<string, unknown>) => ({
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<string, unknown>) => ({
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<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
@@ -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]
}
+74
View File
@@ -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
}
@@ -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()
})
})
+66
View File
@@ -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<string, string> = {
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<string, string> = {
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'
}
+6
View File
@@ -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 } }
+2 -2
View File
@@ -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', () => {
+2
View File
@@ -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
]
+12
View File
@@ -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)',
+14 -3
View File
@@ -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",
+1
View File
@@ -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"
@@ -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();
+24
View File
@@ -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> = {}
): 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> = {}
): ExtensionToggle {
+30
View File
@@ -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<string, unknown> | 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)
// ============================================================