Merge remote-tracking branch 'origin/main' into fixing-extensions-bug
This commit is contained in:
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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'),
|
||||
}))
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user