Invoice fix and new inbound invoice extension etc
This commit is contained in:
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 } })
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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 } }
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
|
||||
]
|
||||
|
||||
|
||||
@@ -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)',
|
||||
|
||||
Generated
+14
-3
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
// ============================================================
|
||||
|
||||
Reference in New Issue
Block a user