Merge remote-tracking branch 'origin/main' into fixing-extensions-bug

This commit is contained in:
Emil
2026-02-23 09:46:32 +01:00
58 changed files with 5693 additions and 860 deletions
@@ -0,0 +1,200 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import {
createQueuedMockSupabase,
createMockRequest,
parseJsonResponse,
} from '@/tests/helpers'
import { NextResponse } from 'next/server'
// Mock dependencies
vi.mock('@/lib/supabase/server', () => ({
createClient: vi.fn(),
}))
vi.mock('@/lib/init', () => ({
ensureInitialized: vi.fn(),
}))
vi.mock('@/lib/extensions/toggle-check', () => ({
isExtensionEnabled: vi.fn(),
}))
vi.mock('@/lib/extensions/context-factory', () => ({
createExtensionContext: vi.fn().mockReturnValue({
userId: 'user-1',
extensionId: 'test-ext',
}),
}))
import { createClient } from '@/lib/supabase/server'
import { extensionRegistry } from '@/lib/extensions/registry'
import { isExtensionEnabled } from '@/lib/extensions/toggle-check'
import { GET, POST } from '../route'
const mockCreateClient = vi.mocked(createClient)
const mockIsEnabled = vi.mocked(isExtensionEnabled)
function createPathParams(path: string[]) {
return { params: Promise.resolve({ path }) }
}
describe('Extension Catch-All Route', () => {
beforeEach(() => {
vi.clearAllMocks()
extensionRegistry.clear()
})
it('returns 400 for empty path', async () => {
const request = createMockRequest('/api/extensions/ext/')
const response = await GET(request, createPathParams([]))
const { status } = await parseJsonResponse(response)
expect(status).toBe(400)
})
it('returns 404 for unknown extension', async () => {
const { supabase } = createQueuedMockSupabase()
supabase.auth.getUser.mockResolvedValue({
data: { user: { id: 'user-1' } },
error: null,
})
mockCreateClient.mockResolvedValue(supabase as never)
const request = createMockRequest('/api/extensions/ext/nonexistent/foo')
const response = await GET(request, createPathParams(['nonexistent', 'foo']))
const { status } = await parseJsonResponse(response)
expect(status).toBe(404)
})
it('returns 401 when not authenticated', async () => {
extensionRegistry.register({
id: 'test-ext',
name: 'Test',
version: '1.0.0',
apiRoutes: [{ method: 'GET', path: '/data', handler: vi.fn() }],
})
const { supabase } = createQueuedMockSupabase()
supabase.auth.getUser.mockResolvedValue({
data: { user: null },
error: { message: 'Not authenticated' },
})
mockCreateClient.mockResolvedValue(supabase as never)
const request = createMockRequest('/api/extensions/ext/test-ext/data')
const response = await GET(request, createPathParams(['test-ext', 'data']))
const { status } = await parseJsonResponse(response)
expect(status).toBe(401)
})
it('returns 403 when extension is disabled', async () => {
extensionRegistry.register({
id: 'test-ext',
name: 'Test',
version: '1.0.0',
apiRoutes: [{ method: 'GET', path: '/data', handler: vi.fn() }],
})
const { supabase } = createQueuedMockSupabase()
supabase.auth.getUser.mockResolvedValue({
data: { user: { id: 'user-1' } },
error: null,
})
mockCreateClient.mockResolvedValue(supabase as never)
mockIsEnabled.mockResolvedValue(false)
const request = createMockRequest('/api/extensions/ext/test-ext/data')
const response = await GET(request, createPathParams(['test-ext', 'data']))
const { status } = await parseJsonResponse(response)
expect(status).toBe(403)
})
it('returns 404 for unmatched method/path', async () => {
extensionRegistry.register({
id: 'test-ext',
name: 'Test',
version: '1.0.0',
apiRoutes: [{ method: 'POST', path: '/data', handler: vi.fn() }],
})
const { supabase } = createQueuedMockSupabase()
supabase.auth.getUser.mockResolvedValue({
data: { user: { id: 'user-1' } },
error: null,
})
mockCreateClient.mockResolvedValue(supabase as never)
mockIsEnabled.mockResolvedValue(true)
// GET doesn't match POST /data
const request = createMockRequest('/api/extensions/ext/test-ext/data')
const response = await GET(request, createPathParams(['test-ext', 'data']))
const { status } = await parseJsonResponse(response)
expect(status).toBe(404)
})
it('dispatches to matching handler with context', async () => {
const handler = vi.fn().mockResolvedValue(
NextResponse.json({ banks: [] })
)
extensionRegistry.register({
id: 'enable-banking',
name: 'Enable Banking',
version: '1.0.0',
apiRoutes: [{ method: 'GET', path: '/banks', handler }],
})
const { supabase } = createQueuedMockSupabase()
supabase.auth.getUser.mockResolvedValue({
data: { user: { id: 'user-1' } },
error: null,
})
mockCreateClient.mockResolvedValue(supabase as never)
mockIsEnabled.mockResolvedValue(true)
const request = createMockRequest('/api/extensions/ext/enable-banking/banks')
const response = await GET(request, createPathParams(['enable-banking', 'banks']))
const { status, body } = await parseJsonResponse<{ banks: unknown[] }>(response)
expect(status).toBe(200)
expect(body.banks).toEqual([])
expect(handler).toHaveBeenCalledWith(request, expect.objectContaining({
extensionId: 'test-ext',
}))
})
it('dispatches POST requests correctly', async () => {
const handler = vi.fn().mockResolvedValue(
NextResponse.json({ ok: true })
)
extensionRegistry.register({
id: 'test-ext',
name: 'Test',
version: '1.0.0',
apiRoutes: [{ method: 'POST', path: '/connect', handler }],
})
const { supabase } = createQueuedMockSupabase()
supabase.auth.getUser.mockResolvedValue({
data: { user: { id: 'user-1' } },
error: null,
})
mockCreateClient.mockResolvedValue(supabase as never)
mockIsEnabled.mockResolvedValue(true)
const request = createMockRequest('/api/extensions/ext/test-ext/connect', {
method: 'POST',
body: { foo: 'bar' },
})
const response = await POST(request, createPathParams(['test-ext', 'connect']))
const { status } = await parseJsonResponse(response)
expect(status).toBe(200)
expect(handler).toHaveBeenCalled()
})
})
+73
View File
@@ -0,0 +1,73 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { extensionRegistry } from '@/lib/extensions/registry'
import { createExtensionContext } from '@/lib/extensions/context-factory'
import { isExtensionEnabled } from '@/lib/extensions/toggle-check'
ensureInitialized()
/**
* Catch-all route for extension-declared API routes.
*
* URL scheme: /api/extensions/ext/{extensionId}/{...routePath}
* Example: /api/extensions/ext/enable-banking/banks → GET /banks
*
* - Looks up the extension in the registry
* - Checks the extension toggle (disabled → 403)
* - Matches method + path to registered apiRoutes
* - Builds an ExtensionContext and passes it to the handler
*/
async function handleRequest(
request: Request,
{ params }: { params: Promise<{ path: string[] }> }
): Promise<Response> {
const segments = await params
if (!segments.path || segments.path.length < 1) {
return NextResponse.json({ error: 'Invalid extension route' }, { status: 400 })
}
const [extensionId, ...rest] = segments.path
const routePath = '/' + rest.join('/')
const method = request.method as 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'
// Look up extension
const extension = extensionRegistry.get(extensionId)
if (!extension || !extension.apiRoutes || extension.apiRoutes.length === 0) {
return NextResponse.json({ error: 'Extension not found' }, { status: 404 })
}
// Auth check
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Toggle check — disabled extensions return 403
const enabled = await isExtensionEnabled(user.id, 'general', extensionId)
if (!enabled) {
return NextResponse.json({ error: 'Extension is disabled' }, { status: 403 })
}
// Find matching route
const route = extension.apiRoutes.find(
(r) => r.method === method && r.path === routePath
)
if (!route) {
return NextResponse.json({ error: 'Route not found' }, { status: 404 })
}
// Build context and dispatch
const ctx = createExtensionContext(supabase, user.id, extensionId)
return route.handler(request, ctx)
}
export const GET = handleRequest
export const POST = handleRequest
export const PUT = handleRequest
export const DELETE = handleRequest
export const PATCH = handleRequest
@@ -0,0 +1,234 @@
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() },
}))
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. 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. 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,230 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { eventBus } from '@/lib/events/bus'
import type { InvoiceExtractionResult } from '@/extensions/general/invoice-inbox/types'
import type { SupplierInvoice } 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 })
}
// 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
}
// Journal entry creation is handled asynchronously by the core
// supplier_invoice.confirmed event handler (see lib/bookkeeping/handlers/)
return NextResponse.json({
data: {
...invoice,
items: itemInserts,
},
})
} 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 } })
}
@@ -29,7 +29,7 @@ vi.mock('@react-pdf/renderer', () => ({
StyleSheet: { create: (s: unknown) => s },
}))
vi.mock('@/lib/invoice/pdf-template', () => ({
vi.mock('@/lib/invoices/pdf-template', () => ({
InvoicePDF: vi.fn().mockReturnValue('mock-pdf-element'),
}))
+15 -1
View File
@@ -19,9 +19,11 @@ vi.mock('@/lib/init', () => ({
const mockGetVatRules = vi.fn()
const mockCalculateVat = vi.fn()
vi.mock('@/lib/invoice/vat-rules', () => ({
const mockGetAvailableVatRates = vi.fn()
vi.mock('@/lib/invoices/vat-rules', () => ({
getVatRules: (...args: unknown[]) => mockGetVatRules(...args),
calculateVat: (...args: unknown[]) => mockCalculateVat(...args),
getAvailableVatRates: (...args: unknown[]) => mockGetAvailableVatRates(...args),
calculateTotal: vi.fn(),
}))
@@ -164,6 +166,12 @@ describe('POST /api/invoices (create invoice)', () => {
reverseChargeText: null,
})
mockCalculateVat.mockReturnValue(2500)
mockGetAvailableVatRates.mockReturnValue([
{ rate: 25, label: '25%', treatment: 'standard_25' },
{ rate: 12, label: '12%', treatment: 'reduced_12' },
{ rate: 6, label: '6%', treatment: 'reduced_6' },
{ rate: 0, label: '0% (momsfri)', treatment: 'exempt' },
])
// Fetch customer
enqueue({ data: customer, error: null })
@@ -209,6 +217,12 @@ describe('POST /api/invoices (create invoice)', () => {
reverseChargeText: null,
})
mockCalculateVat.mockReturnValue(2500)
mockGetAvailableVatRates.mockReturnValue([
{ rate: 25, label: '25%', treatment: 'standard_25' },
{ rate: 12, label: '12%', treatment: 'reduced_12' },
{ rate: 6, label: '6%', treatment: 'reduced_6' },
{ rate: 0, label: '0% (momsfri)', treatment: 'exempt' },
])
enqueue({ data: customer, error: null })
enqueue({ data: 'F-2024001' })
@@ -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)