refactor: consolidate extension system to general-only with manifest-driven architecture
- Remove all sector-specific extensions (construction, ecommerce, export, hotel, restaurant, tech) — only general-purpose extensions remain - Move NE-bilaga and SRU export from extensions to core reports (lib/reports/) - Move moms-box-mapping from extensions/export/shared to lib/vat/ - Replace per-extension API routes with catch-all dispatcher (app/api/extensions/ext/[...path]/route.ts) - Add manifest.json for each extension with metadata, env vars, and deps - Add api-routes.ts pattern for extension-defined API endpoints - Add code generation scripts (generate-extension-registry, create-extension) - Add extensions.config.json for opt-in extension loading - Add extensions.schema.json for config validation - Add email service interface with noop default (lib/email/service.ts) - Add CI workflow (core-build.yml) to verify core builds with zero extensions - Add migration 045: expand account_type CHECK for untaxed_reserves - Update CLAUDE.md with comprehensive extension system documentation - Update all report engines and bookkeeping services for new imports - Clean up extensions.schema.json to only list existing extensions Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
39e407644d
commit
03b569d708
@@ -1,5 +1,8 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { seedAllTemplateEmbeddings, getSchemaVersion } from '@/lib/bookkeeping/template-embeddings'
|
||||
import { extensionRegistry } from '@/lib/extensions/registry'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const authHeader = request.headers.get('authorization')
|
||||
@@ -9,14 +12,23 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const aiExt = extensionRegistry.get('ai-categorization')
|
||||
if (!aiExt?.services?.seedAllTemplateEmbeddings || !aiExt?.services?.getSchemaVersion) {
|
||||
return NextResponse.json(
|
||||
{ error: 'ai-categorization extension not loaded' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const { seeded, errors } = await seedAllTemplateEmbeddings()
|
||||
const { seeded, errors } = await aiExt.services.seedAllTemplateEmbeddings()
|
||||
const schemaVersion = await aiExt.services.getSchemaVersion()
|
||||
|
||||
return NextResponse.json({
|
||||
success: errors.length === 0,
|
||||
seeded,
|
||||
errors,
|
||||
schema_version: getSchemaVersion(),
|
||||
schema_version: schemaVersion,
|
||||
})
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
|
||||
@@ -15,7 +15,7 @@ export async function POST(
|
||||
}
|
||||
|
||||
try {
|
||||
const period = await closePeriod(user.id, id)
|
||||
const period = await closePeriod(supabase, user.id, id)
|
||||
return NextResponse.json({ data: period })
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
|
||||
@@ -15,7 +15,7 @@ export async function POST(
|
||||
}
|
||||
|
||||
try {
|
||||
const period = await lockPeriod(user.id, id)
|
||||
const period = await lockPeriod(supabase, user.id, id)
|
||||
return NextResponse.json({ data: period })
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
|
||||
@@ -23,8 +23,8 @@ export async function GET(
|
||||
|
||||
try {
|
||||
const [validation, preview] = await Promise.all([
|
||||
validateYearEndReadiness(user.id, id),
|
||||
previewYearEndClosing(user.id, id),
|
||||
validateYearEndReadiness(supabase, user.id, id),
|
||||
previewYearEndClosing(supabase, user.id, id),
|
||||
])
|
||||
|
||||
return NextResponse.json({ data: { validation, preview } })
|
||||
@@ -52,7 +52,7 @@ export async function POST(
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await executeYearEndClosing(user.id, id)
|
||||
const result = await executeYearEndClosing(supabase, user.id, id)
|
||||
return NextResponse.json({ data: result })
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
|
||||
@@ -100,7 +100,7 @@ describe('POST /api/bookkeeping/journal-entries/[id]/correct', () => {
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.reversal).toEqual(reversal)
|
||||
expect(body.data.corrected).toEqual(corrected)
|
||||
expect(mockCorrectEntry).toHaveBeenCalledWith('user-1', 'entry-1', lines)
|
||||
expect(mockCorrectEntry).toHaveBeenCalledWith(expect.anything(), 'user-1', 'entry-1', lines)
|
||||
})
|
||||
|
||||
it('returns 400 when correctEntry throws for unbalanced lines', async () => {
|
||||
|
||||
@@ -24,7 +24,7 @@ export async function POST(
|
||||
const body = validation.data
|
||||
|
||||
try {
|
||||
const result = await correctEntry(user.id, id, body.lines)
|
||||
const result = await correctEntry(supabase, user.id, id, body.lines)
|
||||
return NextResponse.json({ data: result })
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
|
||||
@@ -60,7 +60,7 @@ describe('POST /api/bookkeeping/journal-entries/[id]/reverse', () => {
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data).toEqual(reversalEntry)
|
||||
expect(mockReverseEntry).toHaveBeenCalledWith('user-1', 'entry-1')
|
||||
expect(mockReverseEntry).toHaveBeenCalledWith(expect.anything(), 'user-1', 'entry-1')
|
||||
})
|
||||
|
||||
it('returns 400 when engine throws', async () => {
|
||||
|
||||
@@ -15,7 +15,7 @@ export async function POST(
|
||||
}
|
||||
|
||||
try {
|
||||
const reversalEntry = await reverseEntry(user.id, id)
|
||||
const reversalEntry = await reverseEntry(supabase, user.id, id)
|
||||
return NextResponse.json({ data: reversalEntry })
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
|
||||
@@ -137,7 +137,7 @@ describe('POST /api/bookkeeping/journal-entries', () => {
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data).toEqual(entry)
|
||||
expect(mockCreateJournalEntry).toHaveBeenCalledWith('user-1', input)
|
||||
expect(mockCreateJournalEntry).toHaveBeenCalledWith(expect.anything(), 'user-1', input)
|
||||
})
|
||||
|
||||
it('returns 400 when engine throws', async () => {
|
||||
|
||||
@@ -69,7 +69,7 @@ export async function POST(request: Request) {
|
||||
const body = validation.data
|
||||
|
||||
try {
|
||||
const entry = await createJournalEntry(user.id, body)
|
||||
const entry = await createJournalEntry(supabase, user.id, body)
|
||||
return NextResponse.json({ data: entry })
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
|
||||
@@ -38,7 +38,7 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await evaluateMappingRules(user.id, transaction)
|
||||
const result = await evaluateMappingRules(supabase, user.id, transaction)
|
||||
return NextResponse.json({ data: result })
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
|
||||
@@ -38,6 +38,7 @@ export async function POST(
|
||||
}
|
||||
|
||||
const document = await linkToJournalEntry(
|
||||
supabase,
|
||||
user.id,
|
||||
id,
|
||||
body.journal_entry_id,
|
||||
|
||||
@@ -24,7 +24,7 @@ export async function POST(
|
||||
const { id } = await params
|
||||
|
||||
try {
|
||||
const result = await verifyIntegrity(user.id, id)
|
||||
const result = await verifyIntegrity(supabase, user.id, id)
|
||||
|
||||
return NextResponse.json({ data: result })
|
||||
} catch (error) {
|
||||
|
||||
@@ -36,7 +36,7 @@ export async function POST(
|
||||
|
||||
const buffer = await file.arrayBuffer()
|
||||
|
||||
const newVersion = await createNewVersion(user.id, id, {
|
||||
const newVersion = await createNewVersion(supabase, user.id, id, {
|
||||
name: file.name,
|
||||
buffer,
|
||||
type: file.type,
|
||||
|
||||
@@ -38,7 +38,7 @@ export async function POST(request: Request) {
|
||||
|
||||
const buffer = await file.arrayBuffer()
|
||||
|
||||
const document = await uploadDocument(user.id, {
|
||||
const document = await uploadDocument(supabase, user.id, {
|
||||
name: file.name,
|
||||
buffer,
|
||||
type: file.type,
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getSettings, saveSettings } from '@/extensions/general/ai-categorization'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
/**
|
||||
* GET /api/extensions/ai-categorization/settings
|
||||
* Get the current user's ai-categorization extension settings
|
||||
*/
|
||||
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 })
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/extensions/ai-categorization/settings
|
||||
* Update the current user's ai-categorization extension settings
|
||||
*/
|
||||
export async function PATCH(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()
|
||||
|
||||
// Validate setting keys
|
||||
const allowedKeys = [
|
||||
'autoSuggestEnabled',
|
||||
'confidenceThreshold',
|
||||
'providerModel',
|
||||
]
|
||||
const filtered: Record<string, unknown> = {}
|
||||
for (const key of allowedKeys) {
|
||||
if (key in body) {
|
||||
filtered[key] = body[key]
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(filtered).length === 0) {
|
||||
return NextResponse.json({ error: 'No valid settings provided' }, { status: 400 })
|
||||
}
|
||||
|
||||
const settings = await saveSettings(user.id, filtered)
|
||||
return NextResponse.json({ data: settings })
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { categorizeTransactions } from '@/extensions/general/ai-categorization'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import type { CategorizationSuggestion } from '@/extensions/general/ai-categorization/categorizer'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
/**
|
||||
* GET /api/extensions/ai-categorization/suggestions?transaction_ids=id1,id2,...
|
||||
* Fetch pre-computed AI suggestions for given transaction IDs
|
||||
*/
|
||||
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 idsParam = searchParams.get('transaction_ids')
|
||||
|
||||
if (!idsParam) {
|
||||
return NextResponse.json({ error: 'transaction_ids is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const transactionIds = idsParam.split(',').filter(Boolean).slice(0, 50)
|
||||
|
||||
// Read stored suggestions from extension_data
|
||||
const keys = transactionIds.map((id) => `suggestion:${id}`)
|
||||
|
||||
const { data: records } = await supabase
|
||||
.from('extension_data')
|
||||
.select('key, value')
|
||||
.eq('user_id', user.id)
|
||||
.eq('extension_id', 'ai-categorization')
|
||||
.in('key', keys)
|
||||
|
||||
const suggestions: Record<string, CategorizationSuggestion> = {}
|
||||
if (records) {
|
||||
for (const record of records) {
|
||||
const txId = record.key.replace('suggestion:', '')
|
||||
suggestions[txId] = record.value as unknown as CategorizationSuggestion
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ suggestions })
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/extensions/ai-categorization/suggestions
|
||||
* Trigger on-demand AI categorization for given transaction IDs
|
||||
*/
|
||||
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 body = await request.json()
|
||||
const { transaction_ids } = body
|
||||
|
||||
if (!Array.isArray(transaction_ids) || transaction_ids.length === 0) {
|
||||
return NextResponse.json({ error: 'transaction_ids is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const ids = transaction_ids.slice(0, 50)
|
||||
|
||||
try {
|
||||
const suggestions = await categorizeTransactions(user.id, ids)
|
||||
|
||||
// Group by transaction ID
|
||||
const grouped: Record<string, CategorizationSuggestion> = {}
|
||||
for (const s of suggestions) {
|
||||
grouped[s.transactionId] = s
|
||||
}
|
||||
|
||||
return NextResponse.json({ suggestions: grouped })
|
||||
} catch (error) {
|
||||
console.error('[ai-categorization] On-demand categorization failed:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'AI categorization failed' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,169 +0,0 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { generateChatResponse } from '@/extensions/general/ai-chat/chatbot/chain'
|
||||
import { CHATBOT_CONFIG } from '@/extensions/general/ai-chat/chatbot/config'
|
||||
import type { ChatMessage, ChatRequest } from '@/types/chat'
|
||||
|
||||
// Simple in-memory rate limiting (per user)
|
||||
const rateLimitMap = new Map<string, { count: number; resetTime: number }>()
|
||||
|
||||
function checkRateLimit(userId: string): boolean {
|
||||
const now = Date.now()
|
||||
const limit = rateLimitMap.get(userId)
|
||||
|
||||
if (!limit || now > limit.resetTime) {
|
||||
rateLimitMap.set(userId, {
|
||||
count: 1,
|
||||
resetTime: now + 60000, // 1 minute window
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
if (limit.count >= CHATBOT_CONFIG.rateLimitPerMinute) {
|
||||
return false
|
||||
}
|
||||
|
||||
limit.count++
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/chat
|
||||
* Send a message and get a response
|
||||
*/
|
||||
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 })
|
||||
}
|
||||
|
||||
// Rate limiting
|
||||
if (!checkRateLimit(user.id)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Rate limit exceeded. Please wait a moment.' },
|
||||
{ status: 429 }
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const body: ChatRequest = await request.json()
|
||||
const { message, session_id } = body
|
||||
|
||||
if (!message || typeof message !== 'string' || message.trim().length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Message is required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
let sessionId = session_id
|
||||
|
||||
// Create new session if not provided
|
||||
if (!sessionId) {
|
||||
const { data: newSession, error: sessionError } = await supabase
|
||||
.from('chat_sessions')
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
title: message.slice(0, 100), // Use first 100 chars of message as title
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (sessionError) {
|
||||
console.error('Error creating session:', sessionError)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to create chat session' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
sessionId = newSession.id
|
||||
} else {
|
||||
// Verify session belongs to user
|
||||
const { data: existingSession } = await supabase
|
||||
.from('chat_sessions')
|
||||
.select('id')
|
||||
.eq('id', sessionId)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (!existingSession) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Session not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Save user message
|
||||
const { data: userMessage, error: userMsgError } = await supabase
|
||||
.from('chat_messages')
|
||||
.insert({
|
||||
session_id: sessionId,
|
||||
user_id: user.id,
|
||||
role: 'user',
|
||||
content: message.trim(),
|
||||
sources: [],
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (userMsgError) {
|
||||
console.error('Error saving user message:', userMsgError)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to save message' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
// Get conversation history
|
||||
const { data: history } = await supabase
|
||||
.from('chat_messages')
|
||||
.select('role, content')
|
||||
.eq('session_id', sessionId)
|
||||
.order('created_at', { ascending: true })
|
||||
.limit(CHATBOT_CONFIG.maxHistoryMessages)
|
||||
|
||||
const conversationHistory = (history || []) as ChatMessage[]
|
||||
|
||||
// Generate AI response
|
||||
const result = await generateChatResponse(message.trim(), conversationHistory)
|
||||
|
||||
// Save assistant message
|
||||
const { data: assistantMessage, error: assistantMsgError } = await supabase
|
||||
.from('chat_messages')
|
||||
.insert({
|
||||
session_id: sessionId,
|
||||
user_id: user.id,
|
||||
role: 'assistant',
|
||||
content: result.content,
|
||||
sources: result.sources,
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (assistantMsgError) {
|
||||
console.error('Error saving assistant message:', assistantMsgError)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to save response' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
message: assistantMessage,
|
||||
session_id: sessionId,
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Chat error:', err)
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Failed to process chat' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
/**
|
||||
* GET /api/chat/sessions/[id]
|
||||
* Get a single chat session with its messages
|
||||
*/
|
||||
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
|
||||
|
||||
// Get session with messages
|
||||
const { data: session, error: sessionError } = await supabase
|
||||
.from('chat_sessions')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (sessionError || !session) {
|
||||
return NextResponse.json({ error: 'Session not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Get messages
|
||||
const { data: messages, error: messagesError } = await supabase
|
||||
.from('chat_messages')
|
||||
.select('*')
|
||||
.eq('session_id', id)
|
||||
.order('created_at', { ascending: true })
|
||||
|
||||
if (messagesError) {
|
||||
console.error('Error fetching messages:', messagesError)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch messages' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
session,
|
||||
messages: messages || [],
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/chat/sessions/[id]
|
||||
* Update a chat session (e.g., rename)
|
||||
*/
|
||||
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
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { title } = body
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('chat_sessions')
|
||||
.update({ title })
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
console.error('Error updating session:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to update session' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return NextResponse.json({ error: 'Session not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Failed to update session' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/chat/sessions/[id]
|
||||
* Delete a chat session and its messages
|
||||
*/
|
||||
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
|
||||
|
||||
// Delete session (messages will cascade delete due to FK)
|
||||
const { error } = await supabase
|
||||
.from('chat_sessions')
|
||||
.delete()
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
|
||||
if (error) {
|
||||
console.error('Error deleting session:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to delete session' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
/**
|
||||
* GET /api/chat/sessions
|
||||
* List all chat sessions for the authenticated user
|
||||
*/
|
||||
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 limit = parseInt(searchParams.get('limit') || '20')
|
||||
const offset = parseInt(searchParams.get('offset') || '0')
|
||||
|
||||
const { data, error, count } = await supabase
|
||||
.from('chat_sessions')
|
||||
.select('*', { count: 'exact' })
|
||||
.eq('user_id', user.id)
|
||||
.order('created_at', { ascending: false })
|
||||
.range(offset, offset + limit - 1)
|
||||
|
||||
if (error) {
|
||||
console.error('Error fetching sessions:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch sessions' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({ data, count })
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/chat/sessions
|
||||
* Create a new chat session
|
||||
*/
|
||||
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 })
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { title } = body
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('chat_sessions')
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
title: title || null,
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
console.error('Error creating session:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to create session' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Failed to create session' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,195 +0,0 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { streamChatResponse } from '@/extensions/general/ai-chat/chatbot/chain'
|
||||
import { CHATBOT_CONFIG } from '@/extensions/general/ai-chat/chatbot/config'
|
||||
import type { ChatMessage, ChatRequest, SourceReference } from '@/types/chat'
|
||||
|
||||
// Simple in-memory rate limiting (per user)
|
||||
const rateLimitMap = new Map<string, { count: number; resetTime: number }>()
|
||||
|
||||
function checkRateLimit(userId: string): boolean {
|
||||
const now = Date.now()
|
||||
const limit = rateLimitMap.get(userId)
|
||||
|
||||
if (!limit || now > limit.resetTime) {
|
||||
rateLimitMap.set(userId, {
|
||||
count: 1,
|
||||
resetTime: now + 60000,
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
if (limit.count >= CHATBOT_CONFIG.rateLimitPerMinute) {
|
||||
return false
|
||||
}
|
||||
|
||||
limit.count++
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/chat/stream
|
||||
* Streaming chat response via Server-Sent Events
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
const supabase = await createClient()
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Unauthorized' }),
|
||||
{ status: 401, headers: { 'Content-Type': 'application/json' } }
|
||||
)
|
||||
}
|
||||
|
||||
if (!checkRateLimit(user.id)) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Rate limit exceeded' }),
|
||||
{ status: 429, headers: { 'Content-Type': 'application/json' } }
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const body: ChatRequest = await request.json()
|
||||
const { message, session_id } = body
|
||||
|
||||
if (!message || typeof message !== 'string' || message.trim().length === 0) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Message is required' }),
|
||||
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
||||
)
|
||||
}
|
||||
|
||||
let sessionId = session_id
|
||||
|
||||
// Create new session if not provided
|
||||
if (!sessionId) {
|
||||
const { data: newSession, error: sessionError } = await supabase
|
||||
.from('chat_sessions')
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
title: message.slice(0, 100),
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (sessionError) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Failed to create session' }),
|
||||
{ status: 500, headers: { 'Content-Type': 'application/json' } }
|
||||
)
|
||||
}
|
||||
|
||||
sessionId = newSession.id
|
||||
} else {
|
||||
// Verify session belongs to user
|
||||
const { data: existingSession } = await supabase
|
||||
.from('chat_sessions')
|
||||
.select('id')
|
||||
.eq('id', sessionId)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (!existingSession) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Session not found' }),
|
||||
{ status: 404, headers: { 'Content-Type': 'application/json' } }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Save user message
|
||||
await supabase
|
||||
.from('chat_messages')
|
||||
.insert({
|
||||
session_id: sessionId,
|
||||
user_id: user.id,
|
||||
role: 'user',
|
||||
content: message.trim(),
|
||||
sources: [],
|
||||
})
|
||||
|
||||
// Get conversation history
|
||||
const { data: history } = await supabase
|
||||
.from('chat_messages')
|
||||
.select('role, content')
|
||||
.eq('session_id', sessionId)
|
||||
.order('created_at', { ascending: true })
|
||||
.limit(CHATBOT_CONFIG.maxHistoryMessages)
|
||||
|
||||
const conversationHistory = (history || []) as ChatMessage[]
|
||||
|
||||
// Create streaming response
|
||||
const encoder = new TextEncoder()
|
||||
let fullContent = ''
|
||||
let sources: SourceReference[] = []
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
try {
|
||||
// Send session ID first
|
||||
controller.enqueue(
|
||||
encoder.encode(`data: ${JSON.stringify({ type: 'session', session_id: sessionId })}\n\n`)
|
||||
)
|
||||
|
||||
// Stream the response
|
||||
for await (const chunk of streamChatResponse(message.trim(), conversationHistory)) {
|
||||
if (chunk.type === 'content') {
|
||||
fullContent += chunk.data as string
|
||||
controller.enqueue(
|
||||
encoder.encode(`data: ${JSON.stringify({ type: 'content', content: chunk.data })}\n\n`)
|
||||
)
|
||||
} else if (chunk.type === 'sources') {
|
||||
sources = chunk.data as SourceReference[]
|
||||
controller.enqueue(
|
||||
encoder.encode(`data: ${JSON.stringify({ type: 'sources', sources: chunk.data })}\n\n`)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Save the complete assistant message
|
||||
const { data: savedMessage } = await supabase
|
||||
.from('chat_messages')
|
||||
.insert({
|
||||
session_id: sessionId,
|
||||
user_id: user.id,
|
||||
role: 'assistant',
|
||||
content: fullContent,
|
||||
sources,
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
// Send done signal with message ID
|
||||
controller.enqueue(
|
||||
encoder.encode(`data: ${JSON.stringify({ type: 'done', message_id: savedMessage?.id })}\n\n`)
|
||||
)
|
||||
|
||||
controller.close()
|
||||
} catch (error) {
|
||||
console.error('Streaming error:', error)
|
||||
controller.enqueue(
|
||||
encoder.encode(`data: ${JSON.stringify({ type: 'error', error: 'Streaming failed' })}\n\n`)
|
||||
)
|
||||
controller.close()
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Stream setup error:', err)
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Failed to setup stream' }),
|
||||
{ status: 500, headers: { 'Content-Type': 'application/json' } }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
import {
|
||||
generateReceivablesReport,
|
||||
type ReceivableInvoice,
|
||||
type ReceivableCustomer,
|
||||
type GLLine,
|
||||
type ExchangeRateInfo,
|
||||
} from '@/extensions/export/currency-receivables/lib/receivables-engine'
|
||||
import { fetchMultipleRates } from '@/lib/currency/riksbanken'
|
||||
import type { Currency } from '@/types'
|
||||
|
||||
const SUPPORTED_CURRENCIES: Currency[] = ['EUR', 'USD', 'GBP', 'NOK', 'DKK']
|
||||
const FX_ACCOUNTS = ['3960', '7960']
|
||||
|
||||
/**
|
||||
* GET /api/extensions/export/currency-receivables/report
|
||||
*
|
||||
* Generate a multi-currency receivables report showing FX exposure,
|
||||
* unrealized gains/losses, and realized FX from GL.
|
||||
*
|
||||
* Query params:
|
||||
* year (optional) — Year for realized FX trend (default: current year)
|
||||
*/
|
||||
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 yearStr = searchParams.get('year')
|
||||
const year = yearStr ? parseInt(yearStr, 10) : new Date().getFullYear()
|
||||
|
||||
if (isNaN(year) || year < 2000 || year > 2100) {
|
||||
return NextResponse.json({ error: 'Invalid year' }, { status: 400 })
|
||||
}
|
||||
|
||||
const referenceDate = new Date().toISOString().split('T')[0]
|
||||
|
||||
try {
|
||||
// Fetch open foreign-currency invoices
|
||||
const invoices = await fetchAllRows<ReceivableInvoice>(({ from, to }) =>
|
||||
supabase
|
||||
.from('invoices')
|
||||
.select('id, invoice_number, invoice_date, due_date, status, currency, total, total_sek, exchange_rate, customer_id')
|
||||
.eq('user_id', user.id)
|
||||
.in('status', ['sent', 'overdue'])
|
||||
.neq('currency', 'SEK')
|
||||
.range(from, to)
|
||||
)
|
||||
|
||||
// Fetch customers
|
||||
const customerIds = [...new Set(invoices.map(inv => inv.customer_id))]
|
||||
let customers: ReceivableCustomer[] = []
|
||||
if (customerIds.length > 0) {
|
||||
const { data: customerData } = await supabase
|
||||
.from('customers')
|
||||
.select('id, name, country')
|
||||
.eq('user_id', user.id)
|
||||
.in('id', customerIds)
|
||||
|
||||
customers = (customerData || []) as ReceivableCustomer[]
|
||||
}
|
||||
|
||||
// Fetch current Riksbanken rates for all supported currencies
|
||||
const rateMap = await fetchMultipleRates(SUPPORTED_CURRENCIES)
|
||||
const currentRates: ExchangeRateInfo[] = []
|
||||
for (const [, rate] of rateMap) {
|
||||
if (rate.currency !== 'SEK') {
|
||||
currentRates.push({
|
||||
currency: rate.currency,
|
||||
rate: rate.rate,
|
||||
date: rate.date,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch realized FX GL lines for the year
|
||||
const realizedFXLines = await fetchFXLines(supabase, user.id, year)
|
||||
|
||||
const report = generateReceivablesReport({
|
||||
invoices,
|
||||
customers,
|
||||
currentRates,
|
||||
realizedFXLines,
|
||||
referenceDate,
|
||||
year,
|
||||
})
|
||||
|
||||
return NextResponse.json({ data: report })
|
||||
} catch (err) {
|
||||
console.error('Error generating currency receivables report:', err)
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Failed to generate report' },
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch GL lines on accounts 3960 (FX gains) and 7960 (FX losses)
|
||||
* for posted journal entries in the given year.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
async function fetchFXLines(supabase: any, userId: string, year: number): Promise<GLLine[]> {
|
||||
const startDate = `${year}-01-01`
|
||||
const endDate = `${year}-12-31`
|
||||
|
||||
// Get posted journal entry IDs in the year
|
||||
const { data: entries, error: entriesError } = await supabase
|
||||
.from('journal_entries')
|
||||
.select('id, entry_date')
|
||||
.eq('user_id', userId)
|
||||
.eq('status', 'posted')
|
||||
.gte('entry_date', startDate)
|
||||
.lte('entry_date', endDate)
|
||||
|
||||
if (entriesError || !entries || entries.length === 0) return []
|
||||
|
||||
const entryDateMap = new Map<string, string>()
|
||||
for (const e of entries as Array<{ id: string; entry_date: string }>) {
|
||||
entryDateMap.set(e.id, e.entry_date)
|
||||
}
|
||||
|
||||
const entryIds = entries.map((e: { id: string }) => e.id)
|
||||
|
||||
// Fetch lines in batches
|
||||
const BATCH_SIZE = 200
|
||||
const allLines: GLLine[] = []
|
||||
|
||||
for (let i = 0; i < entryIds.length; i += BATCH_SIZE) {
|
||||
const batch = entryIds.slice(i, i + BATCH_SIZE)
|
||||
const { data: lines } = await supabase
|
||||
.from('journal_entry_lines')
|
||||
.select('journal_entry_id, account_number, debit_amount, credit_amount')
|
||||
.in('journal_entry_id', batch)
|
||||
.in('account_number', FX_ACCOUNTS)
|
||||
|
||||
if (lines) {
|
||||
for (const line of lines as Array<{ journal_entry_id: string; account_number: string; debit_amount: number; credit_amount: number }>) {
|
||||
allLines.push({
|
||||
account_number: line.account_number,
|
||||
debit: Number(line.debit_amount) || 0,
|
||||
credit: Number(line.credit_amount) || 0,
|
||||
entry_date: entryDateMap.get(line.journal_entry_id) || startDate,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return allLines
|
||||
}
|
||||
@@ -1,166 +0,0 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
import {
|
||||
generateECSalesListReport,
|
||||
getMonthPeriod,
|
||||
getQuarterPeriod,
|
||||
type ECSalesListInvoice,
|
||||
type ECSalesListCustomer,
|
||||
type GLAccountTotal,
|
||||
} from '@/extensions/export/eu-sales-list/lib/eu-sales-list-engine'
|
||||
import { generateCSV, generateCSVFilename } from '@/extensions/export/eu-sales-list/lib/csv-generator'
|
||||
import { generateSKVXml, generateXMLFilename } from '@/extensions/export/eu-sales-list/lib/skv-xml-generator'
|
||||
|
||||
/**
|
||||
* GET /api/extensions/export/eu-sales-list/download
|
||||
*
|
||||
* Download an EC Sales List (periodisk sammanställning) as CSV or XML.
|
||||
*
|
||||
* Query params:
|
||||
* year (required) — Fiscal year
|
||||
* month (optional) — 1-12, for monthly filing
|
||||
* quarter (optional) — 1-4, for quarterly filing
|
||||
* format (required) — 'csv' or 'xml'
|
||||
*/
|
||||
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 })
|
||||
}
|
||||
|
||||
// Parse query parameters
|
||||
const { searchParams } = new URL(request.url)
|
||||
const yearStr = searchParams.get('year')
|
||||
const monthStr = searchParams.get('month')
|
||||
const quarterStr = searchParams.get('quarter')
|
||||
const format = searchParams.get('format')
|
||||
|
||||
if (!yearStr) {
|
||||
return NextResponse.json({ error: 'year is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!format || !['csv', 'xml'].includes(format)) {
|
||||
return NextResponse.json({ error: 'format is required and must be csv or xml' }, { status: 400 })
|
||||
}
|
||||
|
||||
const year = parseInt(yearStr, 10)
|
||||
if (isNaN(year) || year < 2000 || year > 2100) {
|
||||
return NextResponse.json({ error: 'Invalid year' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!monthStr && !quarterStr) {
|
||||
return NextResponse.json({ error: 'Either month or quarter is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (monthStr && quarterStr) {
|
||||
return NextResponse.json({ error: 'Provide either month or quarter, not both' }, { status: 400 })
|
||||
}
|
||||
|
||||
let month: number | undefined
|
||||
let quarter: number | undefined
|
||||
|
||||
if (monthStr) {
|
||||
month = parseInt(monthStr, 10)
|
||||
if (isNaN(month) || month < 1 || month > 12) {
|
||||
return NextResponse.json({ error: 'Invalid month' }, { status: 400 })
|
||||
}
|
||||
}
|
||||
|
||||
if (quarterStr) {
|
||||
quarter = parseInt(quarterStr, 10)
|
||||
if (isNaN(quarter) || quarter < 1 || quarter > 4) {
|
||||
return NextResponse.json({ error: 'Invalid quarter' }, { status: 400 })
|
||||
}
|
||||
}
|
||||
|
||||
const period = month !== undefined
|
||||
? getMonthPeriod(year, month)
|
||||
: getQuarterPeriod(year, quarter!)
|
||||
|
||||
try {
|
||||
// Fetch company settings
|
||||
const { data: company, error: companyError } = await supabase
|
||||
.from('company_settings')
|
||||
.select('company_name, org_number, vat_number')
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (companyError || !company) {
|
||||
return NextResponse.json({ error: 'Company settings not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const reporterVatNumber = company.vat_number || `SE${(company.org_number || '').replace(/\D/g, '')}01`
|
||||
|
||||
// Fetch invoices
|
||||
const invoices = await fetchAllRows<ECSalesListInvoice>(({ from, to }) =>
|
||||
supabase
|
||||
.from('invoices')
|
||||
.select('id, invoice_number, invoice_date, status, currency, total, total_sek, subtotal, subtotal_sek, vat_treatment, moms_ruta, document_type, credited_invoice_id, customer_id')
|
||||
.eq('user_id', user.id)
|
||||
.gte('invoice_date', period.start)
|
||||
.lte('invoice_date', period.end)
|
||||
.in('status', ['sent', 'paid', 'overdue'])
|
||||
.eq('vat_treatment', 'reverse_charge')
|
||||
.range(from, to)
|
||||
)
|
||||
|
||||
// Fetch customers
|
||||
const customerIds = [...new Set(invoices.map(inv => inv.customer_id))]
|
||||
let customers: ECSalesListCustomer[] = []
|
||||
if (customerIds.length > 0) {
|
||||
const { data: customerData } = await supabase
|
||||
.from('customers')
|
||||
.select('id, name, country, customer_type, vat_number, vat_number_validated')
|
||||
.eq('user_id', user.id)
|
||||
.in('id', customerIds)
|
||||
|
||||
customers = (customerData || []) as ECSalesListCustomer[]
|
||||
}
|
||||
|
||||
// Generate report
|
||||
const report = generateECSalesListReport({
|
||||
invoices,
|
||||
customers,
|
||||
reporterVatNumber,
|
||||
reporterName: company.company_name || '',
|
||||
year,
|
||||
month,
|
||||
quarter,
|
||||
})
|
||||
|
||||
// Generate file content
|
||||
if (format === 'csv') {
|
||||
const content = generateCSV(report)
|
||||
const filename = generateCSVFilename(report)
|
||||
|
||||
return new NextResponse(content, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'text/csv; charset=utf-8',
|
||||
'Content-Disposition': `attachment; filename="${filename}"`,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// XML format
|
||||
const content = generateSKVXml(report)
|
||||
const filename = generateXMLFilename(report)
|
||||
|
||||
return new NextResponse(content, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/xml; charset=utf-8',
|
||||
'Content-Disposition': `attachment; filename="${filename}"`,
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Error generating EC Sales List download:', err)
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Failed to generate download' },
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,212 +0,0 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
import {
|
||||
generateECSalesListReport,
|
||||
getMonthPeriod,
|
||||
getQuarterPeriod,
|
||||
getFilingDeadline,
|
||||
daysUntilDeadline,
|
||||
type ECSalesListInvoice,
|
||||
type ECSalesListCustomer,
|
||||
type GLAccountTotal,
|
||||
} from '@/extensions/export/eu-sales-list/lib/eu-sales-list-engine'
|
||||
|
||||
/**
|
||||
* GET /api/extensions/export/eu-sales-list/report
|
||||
*
|
||||
* Generate an EC Sales List (periodisk sammanställning) report.
|
||||
*
|
||||
* Query params:
|
||||
* year (required) — Fiscal year, e.g. 2026
|
||||
* month (optional) — 1-12, for monthly filing (goods)
|
||||
* quarter (optional) — 1-4, for quarterly filing (services)
|
||||
*
|
||||
* Either month or quarter must be provided, not both.
|
||||
*/
|
||||
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 })
|
||||
}
|
||||
|
||||
// Parse query parameters
|
||||
const { searchParams } = new URL(request.url)
|
||||
const yearStr = searchParams.get('year')
|
||||
const monthStr = searchParams.get('month')
|
||||
const quarterStr = searchParams.get('quarter')
|
||||
|
||||
if (!yearStr) {
|
||||
return NextResponse.json({ error: 'year is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const year = parseInt(yearStr, 10)
|
||||
if (isNaN(year) || year < 2000 || year > 2100) {
|
||||
return NextResponse.json({ error: 'Invalid year. Must be between 2000 and 2100' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!monthStr && !quarterStr) {
|
||||
return NextResponse.json({ error: 'Either month or quarter is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (monthStr && quarterStr) {
|
||||
return NextResponse.json({ error: 'Provide either month or quarter, not both' }, { status: 400 })
|
||||
}
|
||||
|
||||
let month: number | undefined
|
||||
let quarter: number | undefined
|
||||
|
||||
if (monthStr) {
|
||||
month = parseInt(monthStr, 10)
|
||||
if (isNaN(month) || month < 1 || month > 12) {
|
||||
return NextResponse.json({ error: 'Invalid month. Must be 1-12' }, { status: 400 })
|
||||
}
|
||||
}
|
||||
|
||||
if (quarterStr) {
|
||||
quarter = parseInt(quarterStr, 10)
|
||||
if (isNaN(quarter) || quarter < 1 || quarter > 4) {
|
||||
return NextResponse.json({ error: 'Invalid quarter. Must be 1-4' }, { status: 400 })
|
||||
}
|
||||
}
|
||||
|
||||
// Determine date range
|
||||
const period = month !== undefined
|
||||
? getMonthPeriod(year, month)
|
||||
: getQuarterPeriod(year, quarter!)
|
||||
|
||||
try {
|
||||
// Fetch company settings for reporter info
|
||||
const { data: company, error: companyError } = await supabase
|
||||
.from('company_settings')
|
||||
.select('company_name, org_number, vat_number')
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (companyError || !company) {
|
||||
return NextResponse.json({ error: 'Company settings not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const reporterVatNumber = company.vat_number || `SE${(company.org_number || '').replace(/\D/g, '')}01`
|
||||
|
||||
// Fetch invoices for the period
|
||||
const invoices = await fetchAllRows<ECSalesListInvoice>(({ from, to }) =>
|
||||
supabase
|
||||
.from('invoices')
|
||||
.select('id, invoice_number, invoice_date, status, currency, total, total_sek, subtotal, subtotal_sek, vat_treatment, moms_ruta, document_type, credited_invoice_id, customer_id')
|
||||
.eq('user_id', user.id)
|
||||
.gte('invoice_date', period.start)
|
||||
.lte('invoice_date', period.end)
|
||||
.in('status', ['sent', 'paid', 'overdue'])
|
||||
.eq('vat_treatment', 'reverse_charge')
|
||||
.range(from, to)
|
||||
)
|
||||
|
||||
// Collect unique customer IDs
|
||||
const customerIds = [...new Set(invoices.map(inv => inv.customer_id))]
|
||||
|
||||
// Fetch customers (only if we have invoices)
|
||||
let customers: ECSalesListCustomer[] = []
|
||||
if (customerIds.length > 0) {
|
||||
const { data: customerData, error: customerError } = await supabase
|
||||
.from('customers')
|
||||
.select('id, name, country, customer_type, vat_number, vat_number_validated')
|
||||
.eq('user_id', user.id)
|
||||
.in('id', customerIds)
|
||||
|
||||
if (customerError) {
|
||||
return NextResponse.json({ error: 'Failed to fetch customers' }, { status: 500 })
|
||||
}
|
||||
|
||||
customers = (customerData || []) as ECSalesListCustomer[]
|
||||
}
|
||||
|
||||
// Fetch GL account totals for cross-check
|
||||
// Query posted journal entries in the period, then sum credit amounts
|
||||
// on the relevant revenue accounts (3108, 3308, 3109, 3521)
|
||||
const glTotals = await fetchGLTotals(supabase, user.id, period.start, period.end)
|
||||
|
||||
// Generate report
|
||||
const report = generateECSalesListReport({
|
||||
invoices,
|
||||
customers,
|
||||
glTotals: glTotals.length > 0 ? glTotals : undefined,
|
||||
reporterVatNumber,
|
||||
reporterName: company.company_name || '',
|
||||
year,
|
||||
month,
|
||||
quarter,
|
||||
})
|
||||
|
||||
// Add deadline info
|
||||
const deadline = getFilingDeadline(year, month, quarter)
|
||||
const daysLeft = daysUntilDeadline(deadline)
|
||||
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
...report,
|
||||
deadline,
|
||||
daysUntilDeadline: daysLeft,
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Error generating EC Sales List report:', err)
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Failed to generate report' },
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── GL cross-check helper ───────────────────────────────────
|
||||
|
||||
const CROSS_CHECK_ACCOUNTS = ['3108', '3109', '3308', '3521']
|
||||
|
||||
/**
|
||||
* Fetch credit totals for cross-check accounts from posted journal entries.
|
||||
* Mirrors the approach used by /api/bookkeeping/account-totals.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
async function fetchGLTotals(supabase: any, userId: string, startDate: string, endDate: string): Promise<GLAccountTotal[]> {
|
||||
// Get posted journal entry IDs in the period
|
||||
const { data: entries, error: entriesError } = await supabase
|
||||
.from('journal_entries')
|
||||
.select('id')
|
||||
.eq('user_id', userId)
|
||||
.eq('status', 'posted')
|
||||
.gte('entry_date', startDate)
|
||||
.lte('entry_date', endDate)
|
||||
|
||||
if (entriesError || !entries || entries.length === 0) return []
|
||||
|
||||
const entryIds = entries.map((e: { id: string }) => e.id)
|
||||
|
||||
// Fetch lines in batches (same pattern as account-totals route)
|
||||
const BATCH_SIZE = 200
|
||||
const allLines: Array<{ account_number: string; credit_amount: number }> = []
|
||||
|
||||
for (let i = 0; i < entryIds.length; i += BATCH_SIZE) {
|
||||
const batch = entryIds.slice(i, i + BATCH_SIZE)
|
||||
const { data: lines } = await supabase
|
||||
.from('journal_entry_lines')
|
||||
.select('account_number, credit_amount')
|
||||
.in('journal_entry_id', batch)
|
||||
.in('account_number', CROSS_CHECK_ACCOUNTS)
|
||||
|
||||
if (lines) allLines.push(...lines)
|
||||
}
|
||||
|
||||
// Aggregate credits per account
|
||||
const totals = new Map<string, number>()
|
||||
for (const line of allLines) {
|
||||
const credit = Number(line.credit_amount) || 0
|
||||
totals.set(line.account_number, (totals.get(line.account_number) ?? 0) + credit)
|
||||
}
|
||||
|
||||
return Array.from(totals.entries()).map(([account_number, credit]) => ({
|
||||
account_number,
|
||||
credit: Math.round(credit * 100) / 100,
|
||||
}))
|
||||
}
|
||||
@@ -1,165 +0,0 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
import {
|
||||
generateIntrastatReport,
|
||||
type IntrastatInvoice,
|
||||
type IntrastatCustomer,
|
||||
type IntrastatInvoiceItem,
|
||||
type ProductMetadata,
|
||||
} from '@/extensions/export/intrastat/lib/intrastat-engine'
|
||||
import { generateSCBCsv, generateSCBFilename } from '@/extensions/export/intrastat/lib/scb-csv-generator'
|
||||
import { getMonthPeriod } from '@/extensions/export/eu-sales-list/lib/eu-sales-list-engine'
|
||||
|
||||
/**
|
||||
* GET /api/extensions/export/intrastat/download
|
||||
*
|
||||
* Download an Intrastat declaration as SCB-compatible CSV.
|
||||
*
|
||||
* Query params:
|
||||
* year (required) — Fiscal year
|
||||
* month (required) — 1-12
|
||||
*/
|
||||
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 yearStr = searchParams.get('year')
|
||||
const monthStr = searchParams.get('month')
|
||||
|
||||
if (!yearStr || !monthStr) {
|
||||
return NextResponse.json({ error: 'year and month are required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const year = parseInt(yearStr, 10)
|
||||
const month = parseInt(monthStr, 10)
|
||||
|
||||
if (isNaN(year) || year < 2000 || year > 2100) {
|
||||
return NextResponse.json({ error: 'Invalid year' }, { status: 400 })
|
||||
}
|
||||
if (isNaN(month) || month < 1 || month > 12) {
|
||||
return NextResponse.json({ error: 'Invalid month' }, { status: 400 })
|
||||
}
|
||||
|
||||
const period = getMonthPeriod(year, month)
|
||||
|
||||
try {
|
||||
const { data: company } = await supabase
|
||||
.from('company_settings')
|
||||
.select('company_name, org_number, vat_number')
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (!company) {
|
||||
return NextResponse.json({ error: 'Company settings not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const reporterVatNumber = company.vat_number || `SE${(company.org_number || '').replace(/\D/g, '')}01`
|
||||
|
||||
// Fetch invoices
|
||||
const invoices = await fetchAllRows<IntrastatInvoice>(({ from, to }) =>
|
||||
supabase
|
||||
.from('invoices')
|
||||
.select('id, invoice_number, invoice_date, status, vat_treatment, moms_ruta, currency, total_sek, subtotal_sek, subtotal, document_type, credited_invoice_id, customer_id')
|
||||
.eq('user_id', user.id)
|
||||
.gte('invoice_date', period.start)
|
||||
.lte('invoice_date', period.end)
|
||||
.in('status', ['sent', 'paid', 'overdue'])
|
||||
.eq('vat_treatment', 'reverse_charge')
|
||||
.range(from, to)
|
||||
)
|
||||
|
||||
// Fetch invoice items
|
||||
const invoiceIds = invoices.map(inv => inv.id)
|
||||
let invoiceItems: IntrastatInvoiceItem[] = []
|
||||
if (invoiceIds.length > 0) {
|
||||
const BATCH_SIZE = 200
|
||||
for (let i = 0; i < invoiceIds.length; i += BATCH_SIZE) {
|
||||
const batch = invoiceIds.slice(i, i + BATCH_SIZE)
|
||||
const { data: items } = await supabase
|
||||
.from('invoice_items')
|
||||
.select('id, invoice_id, description, quantity, unit_price, total, total_sek')
|
||||
.in('invoice_id', batch)
|
||||
|
||||
if (items) invoiceItems.push(...(items as IntrastatInvoiceItem[]))
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch customers
|
||||
const customerIds = [...new Set(invoices.map(inv => inv.customer_id))]
|
||||
let customers: IntrastatCustomer[] = []
|
||||
if (customerIds.length > 0) {
|
||||
const { data: customerData } = await supabase
|
||||
.from('customers')
|
||||
.select('id, name, country, vat_number')
|
||||
.eq('user_id', user.id)
|
||||
.in('id', customerIds)
|
||||
|
||||
customers = (customerData || []) as IntrastatCustomer[]
|
||||
}
|
||||
|
||||
// Fetch product metadata
|
||||
const { data: productData } = await supabase
|
||||
.from('extension_data')
|
||||
.select('key, value')
|
||||
.eq('user_id', user.id)
|
||||
.eq('extension_id', 'export/intrastat')
|
||||
.ilike('key', 'product:%')
|
||||
|
||||
const products: ProductMetadata[] = (productData || []).map((d: { key: string; value: Record<string, unknown> }) => ({
|
||||
productId: d.key.replace('product:', ''),
|
||||
cnCode: (d.value.cn_code as string) || null,
|
||||
description: (d.value.description as string) || '',
|
||||
netWeightKg: d.value.net_weight_kg !== undefined ? Number(d.value.net_weight_kg) : null,
|
||||
countryOfOrigin: (d.value.country_of_origin as string) || 'SE',
|
||||
supplementaryUnit: d.value.supplementary_unit !== undefined ? Number(d.value.supplementary_unit) : null,
|
||||
supplementaryUnitType: (d.value.supplementary_unit_type as string) || null,
|
||||
}))
|
||||
|
||||
// Fetch settings
|
||||
const { data: settingsData } = await supabase
|
||||
.from('extension_data')
|
||||
.select('value')
|
||||
.eq('user_id', user.id)
|
||||
.eq('extension_id', 'export/intrastat')
|
||||
.eq('key', 'settings')
|
||||
.maybeSingle()
|
||||
|
||||
const settings = settingsData?.value as Record<string, unknown> | undefined
|
||||
|
||||
const report = generateIntrastatReport({
|
||||
invoices,
|
||||
invoiceItems,
|
||||
customers,
|
||||
products,
|
||||
reporterVatNumber,
|
||||
reporterName: company.company_name || '',
|
||||
year,
|
||||
month,
|
||||
defaultTransactionNature: (settings?.default_transaction_nature as string) || '11',
|
||||
defaultDeliveryTerms: (settings?.default_delivery_terms as string) || 'FCA',
|
||||
})
|
||||
|
||||
const content = generateSCBCsv(report)
|
||||
const filename = generateSCBFilename(report)
|
||||
|
||||
return new NextResponse(content, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'text/csv; charset=utf-8',
|
||||
'Content-Disposition': `attachment; filename="${filename}"`,
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Error generating Intrastat download:', err)
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Failed to generate download' },
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,214 +0,0 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
import {
|
||||
generateIntrastatReport,
|
||||
type IntrastatInvoice,
|
||||
type IntrastatCustomer,
|
||||
type IntrastatInvoiceItem,
|
||||
type ProductMetadata,
|
||||
} from '@/extensions/export/intrastat/lib/intrastat-engine'
|
||||
import { getMonthPeriod } from '@/extensions/export/eu-sales-list/lib/eu-sales-list-engine'
|
||||
|
||||
/**
|
||||
* GET /api/extensions/export/intrastat/report
|
||||
*
|
||||
* Generate an Intrastat declaration report for the specified month.
|
||||
*
|
||||
* Query params:
|
||||
* year (required) — Fiscal year
|
||||
* month (required) — 1-12 (Intrastat is always monthly)
|
||||
*/
|
||||
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 yearStr = searchParams.get('year')
|
||||
const monthStr = searchParams.get('month')
|
||||
|
||||
if (!yearStr || !monthStr) {
|
||||
return NextResponse.json({ error: 'year and month are required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const year = parseInt(yearStr, 10)
|
||||
const month = parseInt(monthStr, 10)
|
||||
|
||||
if (isNaN(year) || year < 2000 || year > 2100) {
|
||||
return NextResponse.json({ error: 'Invalid year' }, { status: 400 })
|
||||
}
|
||||
if (isNaN(month) || month < 1 || month > 12) {
|
||||
return NextResponse.json({ error: 'Invalid month' }, { status: 400 })
|
||||
}
|
||||
|
||||
const period = getMonthPeriod(year, month)
|
||||
|
||||
try {
|
||||
// Fetch company settings
|
||||
const { data: company } = await supabase
|
||||
.from('company_settings')
|
||||
.select('company_name, org_number, vat_number')
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (!company) {
|
||||
return NextResponse.json({ error: 'Company settings not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const reporterVatNumber = company.vat_number || `SE${(company.org_number || '').replace(/\D/g, '')}01`
|
||||
|
||||
// Fetch reverse-charge invoices for the period
|
||||
const invoices = await fetchAllRows<IntrastatInvoice>(({ from, to }) =>
|
||||
supabase
|
||||
.from('invoices')
|
||||
.select('id, invoice_number, invoice_date, status, vat_treatment, moms_ruta, currency, total_sek, subtotal_sek, subtotal, document_type, credited_invoice_id, customer_id')
|
||||
.eq('user_id', user.id)
|
||||
.gte('invoice_date', period.start)
|
||||
.lte('invoice_date', period.end)
|
||||
.in('status', ['sent', 'paid', 'overdue'])
|
||||
.eq('vat_treatment', 'reverse_charge')
|
||||
.range(from, to)
|
||||
)
|
||||
|
||||
// Fetch invoice items for those invoices
|
||||
const invoiceIds = invoices.map(inv => inv.id)
|
||||
let invoiceItems: IntrastatInvoiceItem[] = []
|
||||
if (invoiceIds.length > 0) {
|
||||
const BATCH_SIZE = 200
|
||||
for (let i = 0; i < invoiceIds.length; i += BATCH_SIZE) {
|
||||
const batch = invoiceIds.slice(i, i + BATCH_SIZE)
|
||||
const { data: items } = await supabase
|
||||
.from('invoice_items')
|
||||
.select('id, invoice_id, description, quantity, unit_price, total, total_sek')
|
||||
.in('invoice_id', batch)
|
||||
|
||||
if (items) invoiceItems.push(...(items as IntrastatInvoiceItem[]))
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch customers
|
||||
const customerIds = [...new Set(invoices.map(inv => inv.customer_id))]
|
||||
let customers: IntrastatCustomer[] = []
|
||||
if (customerIds.length > 0) {
|
||||
const { data: customerData } = await supabase
|
||||
.from('customers')
|
||||
.select('id, name, country, vat_number')
|
||||
.eq('user_id', user.id)
|
||||
.in('id', customerIds)
|
||||
|
||||
customers = (customerData || []) as IntrastatCustomer[]
|
||||
}
|
||||
|
||||
// Fetch product metadata from extension_data
|
||||
const { data: productData } = await supabase
|
||||
.from('extension_data')
|
||||
.select('key, value')
|
||||
.eq('user_id', user.id)
|
||||
.eq('extension_id', 'export/intrastat')
|
||||
.ilike('key', 'product:%')
|
||||
|
||||
const products: ProductMetadata[] = (productData || []).map((d: { key: string; value: Record<string, unknown> }) => ({
|
||||
productId: d.key.replace('product:', ''),
|
||||
cnCode: (d.value.cn_code as string) || null,
|
||||
description: (d.value.description as string) || '',
|
||||
netWeightKg: d.value.net_weight_kg !== undefined ? Number(d.value.net_weight_kg) : null,
|
||||
countryOfOrigin: (d.value.country_of_origin as string) || 'SE',
|
||||
supplementaryUnit: d.value.supplementary_unit !== undefined ? Number(d.value.supplementary_unit) : null,
|
||||
supplementaryUnitType: (d.value.supplementary_unit_type as string) || null,
|
||||
}))
|
||||
|
||||
// Fetch extension settings
|
||||
const { data: settingsData } = await supabase
|
||||
.from('extension_data')
|
||||
.select('value')
|
||||
.eq('user_id', user.id)
|
||||
.eq('extension_id', 'export/intrastat')
|
||||
.eq('key', 'settings')
|
||||
.maybeSingle()
|
||||
|
||||
const settings = settingsData?.value as Record<string, unknown> | undefined
|
||||
const defaultTransactionNature = (settings?.default_transaction_nature as string) || '11'
|
||||
const defaultDeliveryTerms = (settings?.default_delivery_terms as string) || 'FCA'
|
||||
|
||||
// Calculate prior cumulative value (rolling 12 months excluding current)
|
||||
const priorCumulativeValue = await calculatePriorCumulative(supabase, user.id, year, month)
|
||||
|
||||
const report = generateIntrastatReport({
|
||||
invoices,
|
||||
invoiceItems,
|
||||
customers,
|
||||
products,
|
||||
reporterVatNumber,
|
||||
reporterName: company.company_name || '',
|
||||
year,
|
||||
month,
|
||||
defaultTransactionNature,
|
||||
defaultDeliveryTerms,
|
||||
priorCumulativeValue,
|
||||
})
|
||||
|
||||
return NextResponse.json({ data: report })
|
||||
} catch (err) {
|
||||
console.error('Error generating Intrastat report:', err)
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Failed to generate report' },
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the cumulative dispatch value for the 11 months prior to the
|
||||
* current period (rolling 12-month window for threshold monitoring).
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
async function calculatePriorCumulative(supabase: any, userId: string, year: number, month: number): Promise<number> {
|
||||
// Calculate 11-month lookback window
|
||||
let startMonth = month - 11
|
||||
let startYear = year
|
||||
while (startMonth < 1) {
|
||||
startMonth += 12
|
||||
startYear--
|
||||
}
|
||||
const startDate = `${startYear}-${String(startMonth).padStart(2, '0')}-01`
|
||||
|
||||
// End date is the day before the current period
|
||||
let prevMonth = month - 1
|
||||
let prevYear = year
|
||||
if (prevMonth < 1) {
|
||||
prevMonth = 12
|
||||
prevYear--
|
||||
}
|
||||
const lastDay = new Date(prevYear, prevMonth, 0).getDate()
|
||||
const endDate = `${prevYear}-${String(prevMonth).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`
|
||||
|
||||
if (startDate > endDate) return 0
|
||||
|
||||
// Sum total_sek for reverse_charge invoices to EU in the lookback window
|
||||
const { data, error } = await supabase
|
||||
.from('invoices')
|
||||
.select('total_sek, subtotal_sek, subtotal')
|
||||
.eq('user_id', userId)
|
||||
.gte('invoice_date', startDate)
|
||||
.lte('invoice_date', endDate)
|
||||
.in('status', ['sent', 'paid', 'overdue'])
|
||||
.eq('vat_treatment', 'reverse_charge')
|
||||
.eq('moms_ruta', '35')
|
||||
|
||||
if (error || !data) return 0
|
||||
|
||||
let total = 0
|
||||
for (const inv of data) {
|
||||
if (inv.subtotal_sek !== null) {
|
||||
total += Number(inv.subtotal_sek) || 0
|
||||
} else {
|
||||
total += Number(inv.subtotal) || 0
|
||||
}
|
||||
}
|
||||
|
||||
return Math.round(total * 100) / 100
|
||||
}
|
||||
@@ -1,188 +0,0 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
import {
|
||||
generateVatMonitorReport,
|
||||
VAT_MONITOR_ACCOUNTS,
|
||||
type GLLine,
|
||||
type VatMonitorInvoice,
|
||||
type VatMonitorCustomer,
|
||||
} from '@/extensions/export/vat-monitor/lib/vat-monitor-engine'
|
||||
import {
|
||||
getMonthPeriod,
|
||||
getQuarterPeriod,
|
||||
} from '@/extensions/export/eu-sales-list/lib/eu-sales-list-engine'
|
||||
|
||||
/**
|
||||
* GET /api/extensions/export/vat-monitor/report
|
||||
*
|
||||
* Generate a VAT Monitor report for the specified period.
|
||||
*
|
||||
* Query params:
|
||||
* year (required) — Fiscal year
|
||||
* month (optional) — 1-12
|
||||
* quarter (optional) — 1-4
|
||||
* compare (optional) — 'previous' to include period comparison
|
||||
*/
|
||||
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 yearStr = searchParams.get('year')
|
||||
const monthStr = searchParams.get('month')
|
||||
const quarterStr = searchParams.get('quarter')
|
||||
const compare = searchParams.get('compare')
|
||||
|
||||
if (!yearStr) {
|
||||
return NextResponse.json({ error: 'year is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const year = parseInt(yearStr, 10)
|
||||
if (isNaN(year) || year < 2000 || year > 2100) {
|
||||
return NextResponse.json({ error: 'Invalid year' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!monthStr && !quarterStr) {
|
||||
return NextResponse.json({ error: 'Either month or quarter is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (monthStr && quarterStr) {
|
||||
return NextResponse.json({ error: 'Provide either month or quarter, not both' }, { status: 400 })
|
||||
}
|
||||
|
||||
let month: number | undefined
|
||||
let quarter: number | undefined
|
||||
|
||||
if (monthStr) {
|
||||
month = parseInt(monthStr, 10)
|
||||
if (isNaN(month) || month < 1 || month > 12) {
|
||||
return NextResponse.json({ error: 'Invalid month' }, { status: 400 })
|
||||
}
|
||||
}
|
||||
|
||||
if (quarterStr) {
|
||||
quarter = parseInt(quarterStr, 10)
|
||||
if (isNaN(quarter) || quarter < 1 || quarter > 4) {
|
||||
return NextResponse.json({ error: 'Invalid quarter' }, { status: 400 })
|
||||
}
|
||||
}
|
||||
|
||||
const period = month !== undefined
|
||||
? getMonthPeriod(year, month)
|
||||
: getQuarterPeriod(year, quarter!)
|
||||
|
||||
try {
|
||||
// Fetch GL lines for current period
|
||||
const glLines = await fetchGLLines(supabase, user.id, period.start, period.end)
|
||||
|
||||
// Fetch invoices for validation
|
||||
const invoices = await fetchAllRows<VatMonitorInvoice>(({ from, to }) =>
|
||||
supabase
|
||||
.from('invoices')
|
||||
.select('id, invoice_number, vat_treatment, moms_ruta, customer_id')
|
||||
.eq('user_id', user.id)
|
||||
.gte('invoice_date', period.start)
|
||||
.lte('invoice_date', period.end)
|
||||
.in('status', ['sent', 'paid', 'overdue'])
|
||||
.range(from, to)
|
||||
)
|
||||
|
||||
// Fetch customers for those invoices
|
||||
const customerIds = [...new Set(invoices.map(inv => inv.customer_id))]
|
||||
let customers: VatMonitorCustomer[] = []
|
||||
if (customerIds.length > 0) {
|
||||
const { data: customerData } = await supabase
|
||||
.from('customers')
|
||||
.select('id, name, country, vat_number, vat_number_validated')
|
||||
.eq('user_id', user.id)
|
||||
.in('id', customerIds)
|
||||
|
||||
customers = (customerData || []) as VatMonitorCustomer[]
|
||||
}
|
||||
|
||||
// Fetch previous period GL lines for comparison
|
||||
let previousGlLines: GLLine[] | undefined
|
||||
if (compare === 'previous') {
|
||||
const prevPeriod = getPreviousPeriod(year, month, quarter)
|
||||
previousGlLines = await fetchGLLines(supabase, user.id, prevPeriod.start, prevPeriod.end)
|
||||
}
|
||||
|
||||
const report = generateVatMonitorReport({
|
||||
glLines,
|
||||
invoices,
|
||||
customers,
|
||||
year,
|
||||
month,
|
||||
quarter,
|
||||
previousGlLines,
|
||||
})
|
||||
|
||||
return NextResponse.json({ data: report })
|
||||
} catch (err) {
|
||||
console.error('Error generating VAT Monitor report:', err)
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Failed to generate report' },
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
async function fetchGLLines(supabase: any, userId: string, startDate: string, endDate: string): Promise<GLLine[]> {
|
||||
// Fetch posted journal entry IDs for the period
|
||||
const { data: entries, error: entriesError } = await supabase
|
||||
.from('journal_entries')
|
||||
.select('id')
|
||||
.eq('user_id', userId)
|
||||
.eq('status', 'posted')
|
||||
.gte('entry_date', startDate)
|
||||
.lte('entry_date', endDate)
|
||||
|
||||
if (entriesError || !entries || entries.length === 0) return []
|
||||
|
||||
const entryIds = entries.map((e: { id: string }) => e.id)
|
||||
|
||||
// Fetch lines in batches
|
||||
const BATCH_SIZE = 200
|
||||
const allLines: GLLine[] = []
|
||||
|
||||
for (let i = 0; i < entryIds.length; i += BATCH_SIZE) {
|
||||
const batch = entryIds.slice(i, i + BATCH_SIZE)
|
||||
const { data: lines } = await supabase
|
||||
.from('journal_entry_lines')
|
||||
.select('account_number, debit_amount, credit_amount')
|
||||
.in('journal_entry_id', batch)
|
||||
.in('account_number', VAT_MONITOR_ACCOUNTS)
|
||||
|
||||
if (lines) allLines.push(...lines)
|
||||
}
|
||||
|
||||
return allLines
|
||||
}
|
||||
|
||||
function getPreviousPeriod(year: number, month?: number, quarter?: number): { start: string; end: string } {
|
||||
if (month !== undefined) {
|
||||
let prevMonth = month - 1
|
||||
let prevYear = year
|
||||
if (prevMonth < 1) {
|
||||
prevMonth = 12
|
||||
prevYear--
|
||||
}
|
||||
return getMonthPeriod(prevYear, prevMonth)
|
||||
}
|
||||
|
||||
let prevQuarter = quarter! - 1
|
||||
let prevYear = year
|
||||
if (prevQuarter < 1) {
|
||||
prevQuarter = 4
|
||||
prevYear--
|
||||
}
|
||||
return getQuarterPeriod(prevYear, prevQuarter)
|
||||
}
|
||||
@@ -4,18 +4,47 @@ 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'
|
||||
import type { ApiRouteDefinition } from '@/lib/extensions/types'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
/**
|
||||
* Match a request path against a route pattern.
|
||||
* Supports :param wildcards (e.g., /:id/confirm).
|
||||
* Returns extracted params on match, null on mismatch.
|
||||
*/
|
||||
function matchPath(
|
||||
pattern: string,
|
||||
requestPath: string
|
||||
): Record<string, string> | null {
|
||||
const patternParts = pattern.split('/').filter(Boolean)
|
||||
const requestParts = requestPath.split('/').filter(Boolean)
|
||||
|
||||
if (patternParts.length !== requestParts.length) return null
|
||||
|
||||
const params: Record<string, string> = {}
|
||||
|
||||
for (let i = 0; i < patternParts.length; i++) {
|
||||
if (patternParts[i].startsWith(':')) {
|
||||
params[patternParts[i].slice(1)] = requestParts[i]
|
||||
} else if (patternParts[i] !== requestParts[i]) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return params
|
||||
}
|
||||
|
||||
/**
|
||||
* Catch-all route for extension-declared API routes.
|
||||
*
|
||||
* URL scheme: /api/extensions/ext/{extensionId}/{...routePath}
|
||||
* Example: /api/extensions/ext/enable-banking/banks → GET /banks
|
||||
* Example: /api/extensions/ext/receipt-ocr/abc123/confirm → POST /:id/confirm
|
||||
*
|
||||
* - Looks up the extension in the registry
|
||||
* - Checks the extension toggle (disabled → 403)
|
||||
* - Matches method + path to registered apiRoutes
|
||||
* - Matches method + path pattern to registered apiRoutes
|
||||
* - Extracts path params and appends them as URL search params
|
||||
* - Builds an ExtensionContext and passes it to the handler
|
||||
*/
|
||||
async function handleRequest(
|
||||
@@ -46,24 +75,51 @@ async function handleRequest(
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Toggle check — disabled extensions return 403
|
||||
const enabled = await isExtensionEnabled(user.id, 'general', extensionId)
|
||||
// Toggle check — use extension's declared sector, fallback to 'general'
|
||||
const sector = extension.sector || 'general'
|
||||
const enabled = await isExtensionEnabled(user.id, sector, 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
|
||||
)
|
||||
// Find matching route (supports :param patterns)
|
||||
let matchedRoute: ApiRouteDefinition | null = null
|
||||
let extractedParams: Record<string, string> = {}
|
||||
|
||||
if (!route) {
|
||||
for (const route of extension.apiRoutes) {
|
||||
if (route.method !== method) continue
|
||||
|
||||
const params = matchPath(route.path, routePath)
|
||||
if (params !== null) {
|
||||
matchedRoute = route
|
||||
extractedParams = params
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!matchedRoute) {
|
||||
return NextResponse.json({ error: 'Route not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// If path params were extracted, create a new Request with them as search params
|
||||
let handlerRequest = request
|
||||
if (Object.keys(extractedParams).length > 0) {
|
||||
const url = new URL(request.url)
|
||||
for (const [key, value] of Object.entries(extractedParams)) {
|
||||
url.searchParams.set(`_${key}`, value)
|
||||
}
|
||||
handlerRequest = new Request(url.toString(), {
|
||||
method: request.method,
|
||||
headers: request.headers,
|
||||
body: request.body,
|
||||
// @ts-expect-error -- duplex needed for streaming body
|
||||
duplex: 'half',
|
||||
})
|
||||
}
|
||||
|
||||
// Build context and dispatch
|
||||
const ctx = createExtensionContext(supabase, user.id, extensionId)
|
||||
return route.handler(request, ctx)
|
||||
return matchedRoute.handler(handlerRequest, ctx)
|
||||
}
|
||||
|
||||
export const GET = handleRequest
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { eventBus } from '@/lib/events/bus'
|
||||
|
||||
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: 'Inbox item not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (inboxItem.document_type !== 'receipt') {
|
||||
return NextResponse.json({ error: 'Inbox item is not a receipt' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!inboxItem.linked_receipt_id) {
|
||||
return NextResponse.json({ error: 'No linked receipt found' }, { status: 400 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const {
|
||||
line_items,
|
||||
matched_transaction_id,
|
||||
representation_persons,
|
||||
representation_purpose,
|
||||
representation_business_connection,
|
||||
} = body
|
||||
|
||||
// Update receipt line items (business/private classification)
|
||||
if (Array.isArray(line_items)) {
|
||||
for (const item of line_items) {
|
||||
if (!item.id) continue
|
||||
await supabase
|
||||
.from('receipt_line_items')
|
||||
.update({
|
||||
is_business: item.is_business,
|
||||
...(item.category ? { category: item.category } : {}),
|
||||
...(item.bas_account ? { bas_account: item.bas_account } : {}),
|
||||
})
|
||||
.eq('id', item.id)
|
||||
.eq('receipt_id', inboxItem.linked_receipt_id)
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate business/private totals
|
||||
const { data: updatedLineItems } = await supabase
|
||||
.from('receipt_line_items')
|
||||
.select('*')
|
||||
.eq('receipt_id', inboxItem.linked_receipt_id)
|
||||
|
||||
let businessTotal = 0
|
||||
let privateTotal = 0
|
||||
if (updatedLineItems) {
|
||||
for (const li of updatedLineItems) {
|
||||
if (li.is_business === true) {
|
||||
businessTotal += li.line_total
|
||||
} else if (li.is_business === false) {
|
||||
privateTotal += li.line_total
|
||||
}
|
||||
}
|
||||
}
|
||||
businessTotal = Math.round(businessTotal * 100) / 100
|
||||
privateTotal = Math.round(privateTotal * 100) / 100
|
||||
|
||||
// Update receipt with match and representation data
|
||||
const receiptUpdate: Record<string, unknown> = {
|
||||
status: 'confirmed',
|
||||
}
|
||||
|
||||
if (matched_transaction_id) {
|
||||
receiptUpdate.matched_transaction_id = matched_transaction_id
|
||||
}
|
||||
if (representation_persons != null) {
|
||||
receiptUpdate.representation_persons = representation_persons
|
||||
}
|
||||
if (representation_purpose) {
|
||||
receiptUpdate.representation_purpose = representation_purpose
|
||||
}
|
||||
if (representation_business_connection) {
|
||||
receiptUpdate.representation_business_connection = representation_business_connection
|
||||
}
|
||||
|
||||
await supabase
|
||||
.from('receipts')
|
||||
.update(receiptUpdate)
|
||||
.eq('id', inboxItem.linked_receipt_id)
|
||||
|
||||
// Link transaction to receipt if provided
|
||||
if (matched_transaction_id) {
|
||||
await supabase
|
||||
.from('transactions')
|
||||
.update({ receipt_id: inboxItem.linked_receipt_id })
|
||||
.eq('id', matched_transaction_id)
|
||||
.eq('user_id', user.id)
|
||||
}
|
||||
|
||||
// Update inbox item status
|
||||
await supabase
|
||||
.from('invoice_inbox_items')
|
||||
.update({ status: 'confirmed' })
|
||||
.eq('id', id)
|
||||
|
||||
// Emit event (non-blocking)
|
||||
try {
|
||||
const { data: receipt } = await supabase
|
||||
.from('receipts')
|
||||
.select('*')
|
||||
.eq('id', inboxItem.linked_receipt_id)
|
||||
.single()
|
||||
|
||||
if (receipt) {
|
||||
await eventBus.emit({
|
||||
type: 'receipt.confirmed',
|
||||
payload: {
|
||||
receipt,
|
||||
businessTotal,
|
||||
privateTotal,
|
||||
userId: user.id,
|
||||
},
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
// Non-blocking
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: { confirmed: true, businessTotal, privateTotal } })
|
||||
}
|
||||
@@ -1,234 +0,0 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -1,230 +0,0 @@
|
||||
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 })
|
||||
}
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
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 { matchDocumentToTransactions } from '@/lib/documents/document-matcher'
|
||||
import type { InvoiceInboxItem } 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('*, 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 with extraction + template suggestion
|
||||
const updateData: Record<string, unknown> = {
|
||||
status: 'ready',
|
||||
extracted_data: extraction as unknown as Record<string, unknown>,
|
||||
confidence: extraction.confidence,
|
||||
matched_supplier_id: matchedSupplierId,
|
||||
error_message: null,
|
||||
// Reset previous match on re-process
|
||||
matched_transaction_id: null,
|
||||
match_confidence: null,
|
||||
match_method: null,
|
||||
}
|
||||
|
||||
if (extraction.suggestedTemplateId) {
|
||||
updateData.suggested_template_id = extraction.suggestedTemplateId
|
||||
updateData.suggested_template_confidence = extraction.confidence
|
||||
}
|
||||
|
||||
const { data: updatedItem, error: updateError } = await supabase
|
||||
.from('invoice_inbox_items')
|
||||
.update(updateData)
|
||||
.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,
|
||||
},
|
||||
})
|
||||
|
||||
// Document-to-transaction matching (non-blocking)
|
||||
try {
|
||||
const matchResult = await matchDocumentToTransactions(
|
||||
supabase,
|
||||
user.id,
|
||||
updatedItem as InvoiceInboxItem
|
||||
)
|
||||
|
||||
if (matchResult) {
|
||||
await supabase
|
||||
.from('invoice_inbox_items')
|
||||
.update({
|
||||
matched_transaction_id: matchResult.transactionId,
|
||||
match_confidence: matchResult.confidence,
|
||||
match_method: matchResult.method,
|
||||
})
|
||||
.eq('id', id)
|
||||
}
|
||||
} catch (matchError) {
|
||||
console.error('[invoice-inbox] Transaction matching failed:', matchError)
|
||||
}
|
||||
}
|
||||
|
||||
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 })
|
||||
}
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
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 })
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,273 +0,0 @@
|
||||
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 { matchDocumentToTransactions } from '@/lib/documents/document-matcher'
|
||||
import type { InvoiceInboxItem, InvoiceExtractionResult } from '@/types'
|
||||
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')
|
||||
const documentType = searchParams.get('document_type')
|
||||
|
||||
let query = supabase
|
||||
.from('invoice_inbox_items')
|
||||
.select('*, document:document_attachments(id, file_name, mime_type, storage_path), supplier:suppliers(id, name), receipt:receipts(id, merchant_name, total_amount, receipt_date, status, matched_transaction_id)')
|
||||
.eq('user_id', user.id)
|
||||
|
||||
if (status && status !== 'all') {
|
||||
query = query.eq('status', status)
|
||||
}
|
||||
|
||||
if (documentType && documentType !== 'all') {
|
||||
query = query.eq('document_type', documentType)
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
// Support batch upload: multiple `files` entries, fallback to single `file`
|
||||
const files: File[] = []
|
||||
const multiFiles = formData.getAll('files')
|
||||
if (multiFiles.length > 0) {
|
||||
for (const f of multiFiles) {
|
||||
if (f instanceof File) files.push(f)
|
||||
}
|
||||
} else {
|
||||
const single = formData.get('file') as File | null
|
||||
if (single) files.push(single)
|
||||
}
|
||||
|
||||
if (files.length === 0) {
|
||||
return NextResponse.json({ error: 'No file provided' }, { status: 400 })
|
||||
}
|
||||
|
||||
const supportedTypes = ['application/pdf', 'image/jpeg', 'image/png', 'image/webp']
|
||||
const items: Array<Record<string, unknown>> = []
|
||||
const errors: string[] = []
|
||||
|
||||
for (const file of files) {
|
||||
if (!supportedTypes.includes(file.type)) {
|
||||
errors.push(`${file.name}: unsupported file type`)
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await uploadAndCreateInboxItem(supabase, user.id, file)
|
||||
items.push(result.inboxItem)
|
||||
|
||||
// Process asynchronously
|
||||
processInboxItem(result.inboxItem.id as string, user.id, result.base64, file.type).catch((err) =>
|
||||
console.error('[invoice-inbox] Background processing failed:', err)
|
||||
)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Upload failed'
|
||||
errors.push(`${file.name}: ${message}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Return array for batch, single item for backward compat
|
||||
if (files.length === 1 && items.length === 1) {
|
||||
return NextResponse.json({ data: items[0] })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: items, errors: errors.length > 0 ? errors : undefined })
|
||||
}
|
||||
|
||||
async function uploadAndCreateInboxItem(
|
||||
supabase: Awaited<ReturnType<typeof createClient>>,
|
||||
userId: string,
|
||||
file: File
|
||||
): Promise<{ inboxItem: Record<string, unknown>; base64: string }> {
|
||||
const arrayBuffer = await file.arrayBuffer()
|
||||
const buffer = Buffer.from(arrayBuffer)
|
||||
const base64 = buffer.toString('base64')
|
||||
const hash = crypto.createHash('sha256').update(buffer).digest('hex')
|
||||
|
||||
const storagePath = `documents/${userId}/inbox/${Date.now()}-${file.name}`
|
||||
const { error: uploadError } = await supabase.storage
|
||||
.from('documents')
|
||||
.upload(storagePath, buffer, { contentType: file.type })
|
||||
|
||||
if (uploadError) {
|
||||
throw new Error('Failed to upload file')
|
||||
}
|
||||
|
||||
const { data: document, error: docError } = await supabase
|
||||
.from('document_attachments')
|
||||
.insert({
|
||||
user_id: userId,
|
||||
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) {
|
||||
throw new Error('Failed to create document record')
|
||||
}
|
||||
|
||||
const { data: inboxItem, error: itemError } = await supabase
|
||||
.from('invoice_inbox_items')
|
||||
.insert({
|
||||
user_id: userId,
|
||||
status: 'processing',
|
||||
source: 'upload',
|
||||
document_id: document.id,
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (itemError || !inboxItem) {
|
||||
throw new Error('Failed to create inbox item')
|
||||
}
|
||||
|
||||
await eventBus.emit({
|
||||
type: 'supplier_invoice.received',
|
||||
payload: { inboxItem, userId },
|
||||
})
|
||||
|
||||
return { inboxItem, base64 }
|
||||
}
|
||||
|
||||
async function processInboxItem(
|
||||
itemId: string,
|
||||
userId: string,
|
||||
base64: string,
|
||||
mimeType: string
|
||||
): Promise<void> {
|
||||
const supabase = await createClient()
|
||||
|
||||
try {
|
||||
console.log(`[invoice-inbox] Processing item=${itemId}: starting AI extraction (${mimeType})`)
|
||||
const extraction = await analyzeInvoice(base64, mimeType)
|
||||
|
||||
console.log(`[invoice-inbox] item=${itemId} extraction complete:`, {
|
||||
confidence: extraction.confidence,
|
||||
suggestedTemplateId: extraction.suggestedTemplateId || null,
|
||||
supplier: extraction.supplier?.name || null,
|
||||
total: extraction.totals?.total || null,
|
||||
invoiceDate: extraction.invoice?.invoiceDate || null,
|
||||
dueDate: extraction.invoice?.dueDate || null,
|
||||
paymentRef: extraction.invoice?.paymentReference || null,
|
||||
})
|
||||
|
||||
// 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
|
||||
console.log(`[invoice-inbox] item=${itemId} supplier matched: id=${match.supplierId} confidence=${match.confidence}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Store extraction result with template suggestion
|
||||
const updateData: Record<string, unknown> = {
|
||||
status: 'ready',
|
||||
extracted_data: extraction as unknown as Record<string, unknown>,
|
||||
confidence: extraction.confidence,
|
||||
matched_supplier_id: matchedSupplierId,
|
||||
}
|
||||
|
||||
if (extraction.suggestedTemplateId) {
|
||||
updateData.suggested_template_id = extraction.suggestedTemplateId
|
||||
updateData.suggested_template_confidence = extraction.confidence
|
||||
console.log(`[invoice-inbox] item=${itemId} template suggestion: ${extraction.suggestedTemplateId} (confidence=${extraction.confidence})`)
|
||||
}
|
||||
|
||||
await supabase
|
||||
.from('invoice_inbox_items')
|
||||
.update(updateData)
|
||||
.eq('id', itemId)
|
||||
|
||||
// Fetch the updated item for event emission and matching
|
||||
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,
|
||||
},
|
||||
})
|
||||
|
||||
// Document-to-transaction matching
|
||||
try {
|
||||
const matchResult = await matchDocumentToTransactions(
|
||||
supabase,
|
||||
userId,
|
||||
updatedItem as InvoiceInboxItem
|
||||
)
|
||||
|
||||
if (matchResult) {
|
||||
await supabase
|
||||
.from('invoice_inbox_items')
|
||||
.update({
|
||||
matched_transaction_id: matchResult.transactionId,
|
||||
match_confidence: matchResult.confidence,
|
||||
match_method: matchResult.method,
|
||||
})
|
||||
.eq('id', itemId)
|
||||
}
|
||||
} catch (matchError) {
|
||||
// Non-blocking: log but don't fail the item
|
||||
console.error('[invoice-inbox] Transaction matching failed:', matchError)
|
||||
}
|
||||
}
|
||||
} 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)
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
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 })
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
/**
|
||||
* Legacy route — redirects to /api/reports/ne-bilaga
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
const { search } = new URL(request.url)
|
||||
return NextResponse.redirect(new URL(`/api/reports/ne-bilaga${search}`, request.url), 308)
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getSettings, saveSettings } from '@/extensions/general/push-notifications'
|
||||
|
||||
/**
|
||||
* GET /api/extensions/push-notifications/settings
|
||||
* Get the current user's push-notification extension settings
|
||||
*/
|
||||
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 })
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/extensions/push-notifications/settings
|
||||
* Update the current user's push-notification extension settings
|
||||
*/
|
||||
export async function PATCH(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 allowedKeys = [
|
||||
'periodLockedEnabled',
|
||||
'periodYearClosedEnabled',
|
||||
'invoiceSentEnabled',
|
||||
'receiptExtractedEnabled',
|
||||
'receiptMatchedEnabled',
|
||||
]
|
||||
const filtered: Record<string, unknown> = {}
|
||||
for (const key of allowedKeys) {
|
||||
if (key in body) {
|
||||
filtered[key] = body[key]
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(filtered).length === 0) {
|
||||
return NextResponse.json({ error: 'No valid settings provided' }, { status: 400 })
|
||||
}
|
||||
|
||||
const settings = await saveSettings(user.id, filtered)
|
||||
return NextResponse.json({ data: settings })
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getVapidPublicKey } from '@/extensions/general/push-notifications/notification-sender'
|
||||
|
||||
/**
|
||||
* GET /api/extensions/push-notifications/subscribe
|
||||
* Get the VAPID public key for client-side subscription
|
||||
*/
|
||||
export async function GET() {
|
||||
const vapidKey = getVapidPublicKey()
|
||||
|
||||
if (!vapidKey) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Push notifications not configured' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({ vapidPublicKey: vapidKey })
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/extensions/push-notifications/subscribe
|
||||
* Save a new push subscription
|
||||
*/
|
||||
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 body = await request.json()
|
||||
const { endpoint, keys } = body
|
||||
|
||||
if (!endpoint || !keys?.p256dh || !keys?.auth) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid subscription data' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Get user agent for debugging
|
||||
const userAgent = request.headers.get('user-agent') || null
|
||||
|
||||
// Upsert subscription (update if endpoint exists)
|
||||
const { data, error } = await supabase
|
||||
.from('push_subscriptions')
|
||||
.upsert(
|
||||
{
|
||||
user_id: user.id,
|
||||
endpoint,
|
||||
p256dh: keys.p256dh,
|
||||
auth: keys.auth,
|
||||
user_agent: userAgent,
|
||||
is_active: true,
|
||||
last_used_at: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
onConflict: 'user_id,endpoint',
|
||||
}
|
||||
)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
console.error('Error saving subscription:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to save subscription' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
// Also ensure notification settings exist with defaults
|
||||
await supabase
|
||||
.from('notification_settings')
|
||||
.upsert(
|
||||
{
|
||||
user_id: user.id,
|
||||
tax_deadlines_enabled: true,
|
||||
invoice_reminders_enabled: true,
|
||||
push_enabled: true,
|
||||
email_enabled: true,
|
||||
quiet_start: '21:00',
|
||||
quiet_end: '08:00',
|
||||
},
|
||||
{
|
||||
onConflict: 'user_id',
|
||||
ignoreDuplicates: true,
|
||||
}
|
||||
)
|
||||
|
||||
return NextResponse.json({ success: true, id: data.id })
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/extensions/push-notifications/subscribe
|
||||
* Remove a push subscription
|
||||
*/
|
||||
export async function DELETE(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 { endpoint } = body
|
||||
|
||||
if (!endpoint) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Endpoint is required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const { error } = await supabase
|
||||
.from('push_subscriptions')
|
||||
.delete()
|
||||
.eq('user_id', user.id)
|
||||
.eq('endpoint', endpoint)
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to remove subscription' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { eventBus } from '@/lib/events/bus'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import type { ConfirmReceiptInput, Receipt, ReceiptLineItem } from '@/types'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
/**
|
||||
* POST /api/receipts/[id]/confirm
|
||||
* Confirm line item classifications and optionally link to a transaction
|
||||
*/
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
const { id } = await params
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Verify receipt ownership
|
||||
const { data: receipt, error: fetchError } = await supabase
|
||||
.from('receipts')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (fetchError || !receipt) {
|
||||
return NextResponse.json({ error: 'Receipt not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const body: ConfirmReceiptInput = await request.json()
|
||||
|
||||
// Update line items with classifications
|
||||
if (body.line_items && body.line_items.length > 0) {
|
||||
for (const item of body.line_items) {
|
||||
const { error: updateError } = await supabase
|
||||
.from('receipt_line_items')
|
||||
.update({
|
||||
is_business: item.is_business,
|
||||
category: item.category || null,
|
||||
bas_account: item.bas_account || null,
|
||||
})
|
||||
.eq('id', item.id)
|
||||
.eq('receipt_id', id)
|
||||
|
||||
if (updateError) {
|
||||
console.error('Line item update error:', updateError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build receipt update
|
||||
const receiptUpdate: Record<string, unknown> = {
|
||||
status: 'confirmed',
|
||||
}
|
||||
|
||||
// Add restaurant representation data if provided
|
||||
if (body.representation_persons !== undefined) {
|
||||
receiptUpdate.representation_persons = body.representation_persons
|
||||
}
|
||||
if (body.representation_purpose !== undefined) {
|
||||
receiptUpdate.representation_purpose = body.representation_purpose
|
||||
}
|
||||
|
||||
// Link to transaction if provided
|
||||
if (body.matched_transaction_id) {
|
||||
// Verify transaction ownership
|
||||
const { data: transaction, error: txError } = await supabase
|
||||
.from('transactions')
|
||||
.select('id')
|
||||
.eq('id', body.matched_transaction_id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (!txError && transaction) {
|
||||
receiptUpdate.matched_transaction_id = body.matched_transaction_id
|
||||
|
||||
// Also update the transaction with the receipt link
|
||||
await supabase
|
||||
.from('transactions')
|
||||
.update({ receipt_id: id })
|
||||
.eq('id', body.matched_transaction_id)
|
||||
}
|
||||
}
|
||||
|
||||
// Update the receipt
|
||||
const { data: updatedReceipt, error: updateError } = await supabase
|
||||
.from('receipts')
|
||||
.update(receiptUpdate)
|
||||
.eq('id', id)
|
||||
.select(`
|
||||
*,
|
||||
line_items:receipt_line_items(*)
|
||||
`)
|
||||
.single()
|
||||
|
||||
if (updateError) {
|
||||
console.error('Receipt update error:', updateError)
|
||||
return NextResponse.json({ error: 'Failed to update receipt' }, { status: 500 })
|
||||
}
|
||||
|
||||
// Calculate business/private totals from line items
|
||||
const lineItems = ((updatedReceipt as unknown as Receipt).line_items || []) as ReceiptLineItem[]
|
||||
let businessTotal = 0
|
||||
let privateTotal = 0
|
||||
for (const item of lineItems) {
|
||||
if (item.is_business === true) {
|
||||
businessTotal += item.line_total
|
||||
} else if (item.is_business === false) {
|
||||
privateTotal += item.line_total
|
||||
}
|
||||
}
|
||||
|
||||
// Emit receipt.confirmed event
|
||||
await eventBus.emit({
|
||||
type: 'receipt.confirmed',
|
||||
payload: {
|
||||
receipt: updatedReceipt as unknown as Receipt,
|
||||
businessTotal: Math.round(businessTotal * 100) / 100,
|
||||
privateTotal: Math.round(privateTotal * 100) / 100,
|
||||
userId: user.id,
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ data: updatedReceipt })
|
||||
}
|
||||
@@ -1,234 +0,0 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { findTransactionMatches } from '@/extensions/general/receipt-ocr/lib/receipt-matcher'
|
||||
import { eventBus } from '@/lib/events/bus'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import type { Receipt, Transaction } from '@/types'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
/**
|
||||
* POST /api/receipts/[id]/match
|
||||
* Find potential transaction matches for a receipt
|
||||
*/
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
const { id } = await params
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Fetch receipt
|
||||
const { data: receipt, error: receiptError } = await supabase
|
||||
.from('receipts')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (receiptError || !receipt) {
|
||||
return NextResponse.json({ error: 'Receipt not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Get date range for transaction search (±7 days from receipt date)
|
||||
const receiptDate = receipt.receipt_date ? new Date(receipt.receipt_date) : new Date()
|
||||
const startDate = new Date(receiptDate)
|
||||
startDate.setDate(startDate.getDate() - 7)
|
||||
const endDate = new Date(receiptDate)
|
||||
endDate.setDate(endDate.getDate() + 7)
|
||||
|
||||
// Fetch unmatched transactions in date range
|
||||
const { data: transactions, error: txError } = await supabase
|
||||
.from('transactions')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
.is('receipt_id', null)
|
||||
.lt('amount', 0) // Only expenses
|
||||
.gte('date', startDate.toISOString().split('T')[0])
|
||||
.lte('date', endDate.toISOString().split('T')[0])
|
||||
.order('date', { ascending: false })
|
||||
|
||||
if (txError) {
|
||||
console.error('Transaction fetch error:', txError)
|
||||
return NextResponse.json({ error: 'Failed to fetch transactions' }, { status: 500 })
|
||||
}
|
||||
|
||||
// Find matches
|
||||
const matches = findTransactionMatches(
|
||||
receipt as unknown as Receipt,
|
||||
transactions as Transaction[]
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
receipt_id: id,
|
||||
matches: matches.slice(0, 5), // Return top 5 matches
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/receipts/[id]/match
|
||||
* Link a receipt to a specific transaction
|
||||
*/
|
||||
export async function PATCH(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
const { id } = await params
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const { transaction_id, match_confidence } = body
|
||||
|
||||
if (!transaction_id) {
|
||||
return NextResponse.json({ error: 'transaction_id is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Verify receipt ownership
|
||||
const { data: receipt, error: receiptError } = await supabase
|
||||
.from('receipts')
|
||||
.select('*, line_items:receipt_line_items(*)')
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (receiptError || !receipt) {
|
||||
return NextResponse.json({ error: 'Receipt not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Verify transaction ownership
|
||||
const { data: transaction, error: txError } = await supabase
|
||||
.from('transactions')
|
||||
.select('*')
|
||||
.eq('id', transaction_id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (txError || !transaction) {
|
||||
return NextResponse.json({ error: 'Transaction not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Update receipt with match
|
||||
const { error: updateReceiptError } = await supabase
|
||||
.from('receipts')
|
||||
.update({
|
||||
matched_transaction_id: transaction_id,
|
||||
match_confidence: match_confidence || null,
|
||||
})
|
||||
.eq('id', id)
|
||||
|
||||
if (updateReceiptError) {
|
||||
console.error('Receipt update error:', updateReceiptError)
|
||||
return NextResponse.json({ error: 'Failed to update receipt' }, { status: 500 })
|
||||
}
|
||||
|
||||
// Update transaction with receipt link
|
||||
const { error: updateTxError } = await supabase
|
||||
.from('transactions')
|
||||
.update({ receipt_id: id })
|
||||
.eq('id', transaction_id)
|
||||
|
||||
if (updateTxError) {
|
||||
console.error('Transaction update error:', updateTxError)
|
||||
}
|
||||
|
||||
// Emit receipt.matched event
|
||||
await eventBus.emit({
|
||||
type: 'receipt.matched',
|
||||
payload: {
|
||||
receipt: receipt as unknown as Receipt,
|
||||
transaction: transaction as Transaction,
|
||||
confidence: match_confidence || 0,
|
||||
autoMatched: false,
|
||||
userId: user.id,
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
receipt_id: id,
|
||||
transaction_id,
|
||||
matched: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/receipts/[id]/match
|
||||
* Unlink a receipt from its matched transaction
|
||||
*/
|
||||
export async function DELETE(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
const { id } = await params
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Verify receipt ownership and get current transaction link
|
||||
const { data: receipt, error: receiptError } = await supabase
|
||||
.from('receipts')
|
||||
.select('id, matched_transaction_id')
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (receiptError || !receipt) {
|
||||
return NextResponse.json({ error: 'Receipt not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const transactionId = receipt.matched_transaction_id
|
||||
|
||||
// Remove match from receipt
|
||||
const { error: updateReceiptError } = await supabase
|
||||
.from('receipts')
|
||||
.update({
|
||||
matched_transaction_id: null,
|
||||
match_confidence: null,
|
||||
})
|
||||
.eq('id', id)
|
||||
|
||||
if (updateReceiptError) {
|
||||
console.error('Receipt update error:', updateReceiptError)
|
||||
return NextResponse.json({ error: 'Failed to update receipt' }, { status: 500 })
|
||||
}
|
||||
|
||||
// Remove receipt link from transaction
|
||||
if (transactionId) {
|
||||
await supabase
|
||||
.from('transactions')
|
||||
.update({ receipt_id: null })
|
||||
.eq('id', transactionId)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
receipt_id: id,
|
||||
unmatched: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
/**
|
||||
* GET /api/receipts/[id]
|
||||
* Get a single receipt with line items
|
||||
*/
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
const { id } = await params
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('receipts')
|
||||
.select(`
|
||||
*,
|
||||
line_items:receipt_line_items(*),
|
||||
matched_transaction:transactions(*)
|
||||
`)
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
if (error.code === 'PGRST116') {
|
||||
return NextResponse.json({ error: 'Receipt not found' }, { status: 404 })
|
||||
}
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/receipts/[id]
|
||||
* Update a receipt
|
||||
*/
|
||||
export async function PATCH(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
const { id } = await params
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
|
||||
// Allowed update fields
|
||||
const allowedFields = [
|
||||
'merchant_name',
|
||||
'receipt_date',
|
||||
'receipt_time',
|
||||
'total_amount',
|
||||
'currency',
|
||||
'vat_amount',
|
||||
'is_restaurant',
|
||||
'is_systembolaget',
|
||||
'is_foreign_merchant',
|
||||
'representation_persons',
|
||||
'representation_purpose',
|
||||
'status',
|
||||
]
|
||||
|
||||
const updates: Record<string, unknown> = {}
|
||||
for (const field of allowedFields) {
|
||||
if (body[field] !== undefined) {
|
||||
updates[field] = body[field]
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
return NextResponse.json({ error: 'No valid fields to update' }, { status: 400 })
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('receipts')
|
||||
.update(updates)
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.select(`
|
||||
*,
|
||||
line_items:receipt_line_items(*)
|
||||
`)
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
if (error.code === 'PGRST116') {
|
||||
return NextResponse.json({ error: 'Receipt not found' }, { status: 404 })
|
||||
}
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/receipts/[id]
|
||||
* Delete a receipt and its line items
|
||||
*/
|
||||
export async function DELETE(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
const { id } = await params
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Get receipt to find image URL for cleanup
|
||||
const { data: receipt } = await supabase
|
||||
.from('receipts')
|
||||
.select('image_url, matched_transaction_id')
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (!receipt) {
|
||||
return NextResponse.json({ error: 'Receipt not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Unlink from transaction if matched
|
||||
if (receipt.matched_transaction_id) {
|
||||
await supabase
|
||||
.from('transactions')
|
||||
.update({ receipt_id: null })
|
||||
.eq('id', receipt.matched_transaction_id)
|
||||
}
|
||||
|
||||
// Delete receipt (line items are cascade deleted)
|
||||
const { error } = await supabase
|
||||
.from('receipts')
|
||||
.delete()
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
// Optionally delete image from storage
|
||||
if (receipt.image_url) {
|
||||
try {
|
||||
const urlParts = receipt.image_url.split('/receipts/')
|
||||
if (urlParts[1]) {
|
||||
await supabase.storage.from('receipts').remove([urlParts[1]])
|
||||
}
|
||||
} catch {
|
||||
// Ignore storage cleanup errors
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
/**
|
||||
* GET /api/receipts/queue
|
||||
* Get unmatched receipts and queue statistics
|
||||
*/
|
||||
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 })
|
||||
}
|
||||
|
||||
// Parse query params
|
||||
const { searchParams } = new URL(request.url)
|
||||
const status = searchParams.get('status') // 'pending', 'extracted', 'confirmed', or null for all
|
||||
const unmatched = searchParams.get('unmatched') === 'true'
|
||||
|
||||
// Build query
|
||||
let query = supabase
|
||||
.from('receipts')
|
||||
.select(`
|
||||
*,
|
||||
line_items:receipt_line_items(*)
|
||||
`)
|
||||
.eq('user_id', user.id)
|
||||
.order('created_at', { ascending: false })
|
||||
|
||||
if (status) {
|
||||
query = query.eq('status', status)
|
||||
}
|
||||
|
||||
if (unmatched) {
|
||||
query = query.is('matched_transaction_id', null)
|
||||
}
|
||||
|
||||
const { data: receipts, error: receiptsError } = await query
|
||||
|
||||
if (receiptsError) {
|
||||
console.error('Receipts fetch error:', receiptsError)
|
||||
return NextResponse.json({ error: 'Failed to fetch receipts' }, { status: 500 })
|
||||
}
|
||||
|
||||
// Get counts for queue summary
|
||||
const { count: unmatchedReceiptsCount } = await supabase
|
||||
.from('receipts')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.eq('user_id', user.id)
|
||||
.eq('status', 'confirmed')
|
||||
.is('matched_transaction_id', null)
|
||||
|
||||
const { count: pendingReviewCount } = await supabase
|
||||
.from('receipts')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.eq('user_id', user.id)
|
||||
.eq('status', 'extracted')
|
||||
|
||||
const { count: unmatchedTransactionsCount } = await supabase
|
||||
.from('transactions')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.eq('user_id', user.id)
|
||||
.lt('amount', 0)
|
||||
.is('receipt_id', null)
|
||||
|
||||
// Calculate streak (days with at least one categorized transaction)
|
||||
const { data: recentActivity } = await supabase
|
||||
.from('receipts')
|
||||
.select('created_at')
|
||||
.eq('user_id', user.id)
|
||||
.eq('status', 'confirmed')
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(30)
|
||||
|
||||
let streakCount = 0
|
||||
if (recentActivity && recentActivity.length > 0) {
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
|
||||
const activityDates = new Set(
|
||||
recentActivity.map((r) => new Date(r.created_at).toISOString().split('T')[0])
|
||||
)
|
||||
|
||||
let checkDate = new Date(today)
|
||||
while (activityDates.has(checkDate.toISOString().split('T')[0])) {
|
||||
streakCount++
|
||||
checkDate.setDate(checkDate.getDate() - 1)
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
receipts,
|
||||
summary: {
|
||||
unmatched_receipts_count: unmatchedReceiptsCount || 0,
|
||||
unmatched_transactions_count: unmatchedTransactionsCount || 0,
|
||||
pending_review_count: pendingReviewCount || 0,
|
||||
streak_count: streakCount,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
/**
|
||||
* GET /api/receipts
|
||||
* List receipts for the authenticated user
|
||||
* Query params:
|
||||
* - status: filter by status
|
||||
* - limit: max results (default 50)
|
||||
* - offset: pagination offset
|
||||
*/
|
||||
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')
|
||||
const limit = parseInt(searchParams.get('limit') || '50', 10)
|
||||
const offset = parseInt(searchParams.get('offset') || '0', 10)
|
||||
|
||||
let query = supabase
|
||||
.from('receipts')
|
||||
.select(`
|
||||
*,
|
||||
line_items:receipt_line_items(*)
|
||||
`, { count: 'exact' })
|
||||
.eq('user_id', user.id)
|
||||
.order('created_at', { ascending: false })
|
||||
.range(offset, offset + limit - 1)
|
||||
|
||||
if (status) {
|
||||
query = query.eq('status', status)
|
||||
}
|
||||
|
||||
const { data, error, count } = await query
|
||||
|
||||
if (error) {
|
||||
console.error('Receipts fetch error:', error)
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
data,
|
||||
count,
|
||||
limit,
|
||||
offset,
|
||||
})
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getSettings, saveSettings } from '@/extensions/general/receipt-ocr'
|
||||
|
||||
/**
|
||||
* GET /api/extensions/receipt-ocr/settings
|
||||
* Get the current user's receipt-ocr extension settings
|
||||
*/
|
||||
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 })
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/extensions/receipt-ocr/settings
|
||||
* Update the current user's receipt-ocr extension settings
|
||||
*/
|
||||
export async function PATCH(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()
|
||||
|
||||
// Validate setting keys
|
||||
const allowedKeys = [
|
||||
'autoOcrEnabled',
|
||||
'autoMatchEnabled',
|
||||
'autoMatchThreshold',
|
||||
'ocrConfidenceThreshold',
|
||||
]
|
||||
const filtered: Record<string, unknown> = {}
|
||||
for (const key of allowedKeys) {
|
||||
if (key in body) {
|
||||
filtered[key] = body[key]
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(filtered).length === 0) {
|
||||
return NextResponse.json({ error: 'No valid settings provided' }, { status: 400 })
|
||||
}
|
||||
|
||||
const settings = await saveSettings(user.id, filtered)
|
||||
return NextResponse.json({ data: settings })
|
||||
}
|
||||
@@ -1,222 +0,0 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { analyzeReceipt } from '@/extensions/general/receipt-ocr/lib/receipt-analyzer'
|
||||
import { processLineItems } from '@/extensions/general/receipt-ocr/lib/receipt-categorizer'
|
||||
import { eventBus } from '@/lib/events/bus'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { uploadDocument } from '@/lib/core/documents/document-service'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
/**
|
||||
* POST /api/receipts/upload
|
||||
* Upload a receipt image and extract data using Claude Vision
|
||||
*
|
||||
* Accepts multipart/form-data with:
|
||||
* - image: The receipt image file
|
||||
*/
|
||||
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 })
|
||||
}
|
||||
|
||||
try {
|
||||
const formData = await request.formData()
|
||||
const imageFile = formData.get('image') as File | null
|
||||
|
||||
if (!imageFile) {
|
||||
return NextResponse.json({ error: 'No image file provided' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Validate file type
|
||||
const validTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/gif']
|
||||
if (!validTypes.includes(imageFile.type)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid file type. Supported: JPEG, PNG, WebP, GIF' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Convert file to base64
|
||||
const arrayBuffer = await imageFile.arrayBuffer()
|
||||
const base64 = Buffer.from(arrayBuffer).toString('base64')
|
||||
|
||||
// Generate unique filename
|
||||
const ext = imageFile.type.split('/')[1]
|
||||
const filename = `${user.id}/${Date.now()}-${Math.random().toString(36).slice(2)}.${ext}`
|
||||
|
||||
// Upload to Supabase Storage
|
||||
const { data: uploadData, error: uploadError } = await supabase.storage
|
||||
.from('receipts')
|
||||
.upload(filename, arrayBuffer, {
|
||||
contentType: imageFile.type,
|
||||
cacheControl: '3600',
|
||||
})
|
||||
|
||||
if (uploadError) {
|
||||
console.error('Storage upload error:', uploadError)
|
||||
return NextResponse.json({ error: 'Failed to upload image' }, { status: 500 })
|
||||
}
|
||||
|
||||
// WORM archive copy (non-blocking — receipt flow continues even if this fails)
|
||||
let wormDocumentId: string | null = null
|
||||
try {
|
||||
const wormDoc = await uploadDocument(user.id, {
|
||||
name: imageFile.name,
|
||||
buffer: arrayBuffer,
|
||||
type: imageFile.type,
|
||||
}, { upload_source: 'camera' })
|
||||
wormDocumentId = wormDoc.id
|
||||
} catch (archiveErr) {
|
||||
console.error('[receipt-upload] WORM archive copy failed:', archiveErr)
|
||||
}
|
||||
|
||||
// Get public URL
|
||||
const { data: urlData } = supabase.storage.from('receipts').getPublicUrl(filename)
|
||||
const imageUrl = urlData.publicUrl
|
||||
|
||||
// Create receipt record with pending status
|
||||
const { data: receipt, error: insertError } = await supabase
|
||||
.from('receipts')
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
image_url: imageUrl,
|
||||
status: 'processing',
|
||||
document_id: wormDocumentId,
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (insertError) {
|
||||
console.error('Receipt insert error:', insertError)
|
||||
return NextResponse.json({ error: 'Failed to create receipt record' }, { status: 500 })
|
||||
}
|
||||
|
||||
// Analyze receipt with Claude Vision
|
||||
try {
|
||||
const mimeType = imageFile.type as 'image/jpeg' | 'image/png' | 'image/webp' | 'image/gif'
|
||||
const extraction = await analyzeReceipt(base64, mimeType)
|
||||
|
||||
// Process and categorize line items
|
||||
const processedLineItems = processLineItems(extraction.lineItems)
|
||||
|
||||
// Update receipt with extracted data
|
||||
const { error: updateError } = await supabase
|
||||
.from('receipts')
|
||||
.update({
|
||||
status: 'extracted',
|
||||
extraction_confidence: extraction.confidence,
|
||||
merchant_name: extraction.merchant.name,
|
||||
merchant_org_number: extraction.merchant.orgNumber,
|
||||
merchant_vat_number: extraction.merchant.vatNumber,
|
||||
receipt_date: extraction.receipt.date,
|
||||
receipt_time: extraction.receipt.time,
|
||||
total_amount: extraction.totals.total,
|
||||
currency: extraction.receipt.currency,
|
||||
vat_amount: extraction.totals.vatAmount,
|
||||
is_restaurant: extraction.flags.isRestaurant,
|
||||
is_systembolaget: extraction.flags.isSystembolaget,
|
||||
is_foreign_merchant: extraction.flags.isForeignMerchant,
|
||||
raw_extraction: extraction,
|
||||
})
|
||||
.eq('id', receipt.id)
|
||||
|
||||
if (updateError) {
|
||||
console.error('Receipt update error:', updateError)
|
||||
}
|
||||
|
||||
// Insert line items
|
||||
if (processedLineItems.length > 0) {
|
||||
const lineItemsToInsert = processedLineItems.map((item, index) => ({
|
||||
receipt_id: receipt.id,
|
||||
description: item.description,
|
||||
quantity: item.quantity,
|
||||
unit_price: item.unitPrice,
|
||||
line_total: item.lineTotal,
|
||||
vat_rate: item.vatRate,
|
||||
vat_amount: item.vatRate && item.lineTotal ? (item.lineTotal * item.vatRate) / (100 + item.vatRate) : null,
|
||||
extraction_confidence: item.confidence,
|
||||
suggested_category: item.suggestedCategory,
|
||||
category: item.category,
|
||||
bas_account: item.basAccount,
|
||||
sort_order: index,
|
||||
}))
|
||||
|
||||
const { error: lineItemsError } = await supabase
|
||||
.from('receipt_line_items')
|
||||
.insert(lineItemsToInsert)
|
||||
|
||||
if (lineItemsError) {
|
||||
console.error('Line items insert error:', lineItemsError)
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch the complete receipt with line items
|
||||
const { data: completeReceipt, error: fetchError } = await supabase
|
||||
.from('receipts')
|
||||
.select(`
|
||||
*,
|
||||
line_items:receipt_line_items(*)
|
||||
`)
|
||||
.eq('id', receipt.id)
|
||||
.single()
|
||||
|
||||
if (fetchError) {
|
||||
console.error('Fetch error:', fetchError)
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
id: receipt.id,
|
||||
status: 'extracted',
|
||||
extraction,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Emit receipt.extracted event
|
||||
await eventBus.emit({
|
||||
type: 'receipt.extracted',
|
||||
payload: {
|
||||
receipt: completeReceipt,
|
||||
documentId: wormDocumentId,
|
||||
confidence: extraction.confidence,
|
||||
userId: user.id,
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ data: completeReceipt })
|
||||
} catch (analysisError) {
|
||||
console.error('Receipt analysis error:', analysisError)
|
||||
|
||||
// Update receipt with error status
|
||||
await supabase
|
||||
.from('receipts')
|
||||
.update({
|
||||
status: 'error',
|
||||
raw_extraction: {
|
||||
error: analysisError instanceof Error ? analysisError.message : 'Unknown error',
|
||||
},
|
||||
})
|
||||
.eq('id', receipt.id)
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Failed to analyze receipt',
|
||||
receiptId: receipt.id,
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Upload failed' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
/**
|
||||
* Legacy route — redirects to /api/reports/sru-export/coverage
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
return NextResponse.redirect(new URL('/api/reports/sru-export/coverage', request.url), 308)
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
/**
|
||||
* Legacy route — redirects to /api/reports/sru-export
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
const { search } = new URL(request.url)
|
||||
return NextResponse.redirect(new URL(`/api/reports/sru-export${search}`, request.url), 308)
|
||||
}
|
||||
@@ -172,6 +172,7 @@ export async function POST(request: Request) {
|
||||
|
||||
// Execute the import
|
||||
const result = await executeSIEImport(
|
||||
supabase,
|
||||
user.id,
|
||||
parsed,
|
||||
mappings,
|
||||
|
||||
@@ -54,7 +54,7 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
try {
|
||||
await saveMappings(user.id, mappings)
|
||||
await saveMappings(supabase, user.id, mappings)
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
|
||||
@@ -53,7 +53,7 @@ export async function POST(request: Request) {
|
||||
const content = decodeBuffer(arrayBuffer, encoding)
|
||||
|
||||
// Check for duplicate import
|
||||
const duplicate = await checkDuplicateImport(user.id, content)
|
||||
const duplicate = await checkDuplicateImport(supabase, user.id, content)
|
||||
if (duplicate) {
|
||||
return NextResponse.json({
|
||||
error: 'duplicate',
|
||||
|
||||
@@ -123,6 +123,7 @@ describe('POST /api/invoices/[id]/mark-paid', () => {
|
||||
expect(body.paid_amount).toBe(12500)
|
||||
expect(body.journal_entry_id).toBe('je-1')
|
||||
expect(mockCreateInvoicePaymentJournalEntry).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'user-1',
|
||||
expect.objectContaining({ id: 'inv-1' }),
|
||||
expect.any(String)
|
||||
@@ -155,6 +156,7 @@ describe('POST /api/invoices/[id]/mark-paid', () => {
|
||||
expect(body.success).toBe(true)
|
||||
expect(body.journal_entry_id).toBe('je-2')
|
||||
expect(mockCreateInvoiceCashEntry).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'user-1',
|
||||
expect.objectContaining({ id: 'inv-1' }),
|
||||
expect.any(String),
|
||||
|
||||
@@ -86,6 +86,7 @@ export async function POST(
|
||||
if (accountingMethod === 'accrual') {
|
||||
// Faktureringsmetoden: clear receivable (Debit 1930, Credit 1510)
|
||||
const journalEntry = await createInvoicePaymentJournalEntry(
|
||||
supabase,
|
||||
user.id,
|
||||
invoice as Invoice,
|
||||
paymentDate
|
||||
@@ -94,6 +95,7 @@ export async function POST(
|
||||
} else {
|
||||
// Kontantmetoden: combined revenue entry (Debit 1930, Credit 30xx, Credit 26xx)
|
||||
const journalEntry = await createInvoiceCashEntry(
|
||||
supabase,
|
||||
user.id,
|
||||
invoice as Invoice,
|
||||
paymentDate,
|
||||
|
||||
@@ -68,6 +68,7 @@ export async function POST(
|
||||
if (isRealInvoice && accountingMethod === 'accrual') {
|
||||
try {
|
||||
const journalEntry = await createInvoiceJournalEntry(
|
||||
supabase,
|
||||
user.id,
|
||||
invoice as Invoice,
|
||||
(settings?.entity_type as EntityType) || 'enskild_firma'
|
||||
|
||||
@@ -34,10 +34,12 @@ vi.mock('@/lib/invoices/pdf-template', () => ({
|
||||
}))
|
||||
|
||||
const mockSendEmail = vi.fn()
|
||||
const mockIsResendConfigured = vi.fn()
|
||||
vi.mock('@/lib/email/resend', () => ({
|
||||
sendEmail: (...args: unknown[]) => mockSendEmail(...args),
|
||||
isResendConfigured: () => mockIsResendConfigured(),
|
||||
const mockIsConfigured = vi.fn()
|
||||
vi.mock('@/lib/email/service', () => ({
|
||||
getEmailService: () => ({
|
||||
sendEmail: (...args: unknown[]) => mockSendEmail(...args),
|
||||
isConfigured: () => mockIsConfigured(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/email/invoice-templates', () => ({
|
||||
@@ -84,7 +86,7 @@ describe('POST /api/invoices/[id]/send', () => {
|
||||
reset()
|
||||
eventBus.clear()
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
|
||||
mockIsResendConfigured.mockReturnValue(true)
|
||||
mockIsConfigured.mockReturnValue(true)
|
||||
mockRenderToBuffer.mockResolvedValue(Buffer.from('fake-pdf'))
|
||||
})
|
||||
|
||||
@@ -100,7 +102,7 @@ describe('POST /api/invoices/[id]/send', () => {
|
||||
})
|
||||
|
||||
it('returns 503 when email service is not configured', async () => {
|
||||
mockIsResendConfigured.mockReturnValue(false)
|
||||
mockIsConfigured.mockReturnValue(false)
|
||||
|
||||
const request = createMockRequest('/api/invoices/inv-1/send', { method: 'POST' })
|
||||
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
|
||||
@@ -181,6 +183,7 @@ describe('POST /api/invoices/[id]/send', () => {
|
||||
})
|
||||
)
|
||||
expect(mockCreateInvoiceJournalEntry).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'user-1',
|
||||
expect.objectContaining({ id: 'inv-1' }),
|
||||
'enskild_firma'
|
||||
|
||||
@@ -4,7 +4,7 @@ import { eventBus } from '@/lib/events'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { renderToBuffer } from '@react-pdf/renderer'
|
||||
import { InvoicePDF } from '@/lib/invoices/pdf-template'
|
||||
import { sendEmail, isResendConfigured } from '@/lib/email/resend'
|
||||
import { getEmailService } from '@/lib/email/service'
|
||||
import {
|
||||
generateInvoiceEmailHtml,
|
||||
generateInvoiceEmailText,
|
||||
@@ -29,10 +29,11 @@ export async function POST(
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Check if Resend is configured
|
||||
if (!isResendConfigured()) {
|
||||
// Check if email is configured
|
||||
const emailService = getEmailService()
|
||||
if (!emailService.isConfigured()) {
|
||||
return NextResponse.json(
|
||||
{ error: 'E-posttjänsten är inte konfigurerad. Kontakta support.' },
|
||||
{ error: 'E-posttjänsten är inte konfigurerad. Aktivera e-posttillägget i inställningar.' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
@@ -129,7 +130,7 @@ export async function POST(
|
||||
}
|
||||
|
||||
// Send email
|
||||
const result = await sendEmail({
|
||||
const result = await emailService.sendEmail({
|
||||
to: customer.email,
|
||||
subject: generateInvoiceEmailSubject(emailData),
|
||||
html: generateInvoiceEmailHtml(emailData),
|
||||
@@ -171,6 +172,7 @@ export async function POST(
|
||||
if (isRealInvoice && ((company as Record<string, unknown>).accounting_method === 'accrual' || !(company as Record<string, unknown>).accounting_method)) {
|
||||
try {
|
||||
const journalEntry = await createInvoiceJournalEntry(
|
||||
supabase,
|
||||
user.id,
|
||||
invoice as Invoice,
|
||||
(company as CompanySettings).entity_type
|
||||
@@ -192,7 +194,7 @@ export async function POST(
|
||||
if (isRealInvoice) {
|
||||
try {
|
||||
const pdfArrayBuffer = new Uint8Array(pdfBuffer).buffer as ArrayBuffer
|
||||
await uploadDocument(user.id, {
|
||||
await uploadDocument(supabase, user.id, {
|
||||
name: filename,
|
||||
buffer: pdfArrayBuffer,
|
||||
type: 'application/pdf',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { processOverdueReminders } from '@/lib/invoices/reminder-processor'
|
||||
import { isResendConfigured } from '@/lib/email/resend'
|
||||
import { getEmailService } from '@/lib/email/service'
|
||||
|
||||
// Verify cron secret for security
|
||||
function verifyCronSecret(request: Request): boolean {
|
||||
@@ -31,8 +31,8 @@ export async function GET(request: Request) {
|
||||
}
|
||||
|
||||
// Check if email service is configured
|
||||
if (!isResendConfigured()) {
|
||||
console.error('Resend not configured, skipping reminder cron')
|
||||
if (!getEmailService().isConfigured()) {
|
||||
console.error('Email service not configured, skipping reminder cron')
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: 'Email service not configured'
|
||||
|
||||
@@ -378,6 +378,7 @@ async function createCreditNote(
|
||||
if (completeCreditNote && accountingMethod === 'accrual') {
|
||||
try {
|
||||
const journalEntry = await createCreditNoteJournalEntry(
|
||||
supabase,
|
||||
userId,
|
||||
completeCreditNote as Invoice,
|
||||
entityType
|
||||
|
||||
@@ -16,11 +16,11 @@ export async function GET(request: Request) {
|
||||
const asOfDate = searchParams.get('as_of_date') || undefined
|
||||
const periodId = searchParams.get('period_id') || undefined
|
||||
|
||||
const ledger = await generateARLedger(user.id, asOfDate)
|
||||
const ledger = await generateARLedger(supabase, user.id, asOfDate)
|
||||
|
||||
let reconciliation = null
|
||||
if (periodId) {
|
||||
reconciliation = await generateARReconciliation(user.id, periodId)
|
||||
reconciliation = await generateARReconciliation(supabase, user.id, periodId)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
|
||||
@@ -25,7 +25,7 @@ export async function GET(request: Request) {
|
||||
.single()
|
||||
|
||||
try {
|
||||
const result = await generateBalanceSheet(user.id, periodId)
|
||||
const result = await generateBalanceSheet(supabase, user.id, periodId)
|
||||
|
||||
if (period) {
|
||||
result.period = {
|
||||
|
||||
@@ -20,7 +20,7 @@ export async function GET(request: Request) {
|
||||
return NextResponse.json({ error: 'period_id is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const data = await generateGeneralLedger(user.id, periodId, accountFrom, accountTo)
|
||||
const data = await generateGeneralLedger(supabase, user.id, periodId, accountFrom, accountTo)
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ export async function GET(request: Request) {
|
||||
.single()
|
||||
|
||||
try {
|
||||
const result = await generateIncomeStatement(user.id, periodId)
|
||||
const result = await generateIncomeStatement(supabase, user.id, periodId)
|
||||
|
||||
if (period) {
|
||||
result.period = {
|
||||
|
||||
@@ -18,7 +18,7 @@ export async function GET(request: Request) {
|
||||
return NextResponse.json({ error: 'period_id is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const data = await generateJournalRegister(user.id, periodId)
|
||||
const data = await generateJournalRegister(supabase, user.id, periodId)
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ export async function GET(request: Request) {
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await generateMonthlyBreakdown(user.id, periodId)
|
||||
const data = await generateMonthlyBreakdown(supabase, user.id, periodId)
|
||||
return NextResponse.json({ data })
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Failed to generate monthly breakdown' }, { status: 500 })
|
||||
|
||||
@@ -40,7 +40,7 @@ export async function GET(request: Request) {
|
||||
}
|
||||
|
||||
try {
|
||||
const declaration = await generateNEDeclaration(user.id, periodId)
|
||||
const declaration = await generateNEDeclaration(supabase, user.id, periodId)
|
||||
|
||||
if (format === 'sru') {
|
||||
// Generate and return SRU file
|
||||
|
||||
@@ -29,7 +29,7 @@ export async function GET(request: Request) {
|
||||
}
|
||||
|
||||
try {
|
||||
const sieContent = await generateSIEExport(user.id, {
|
||||
const sieContent = await generateSIEExport(supabase, user.id, {
|
||||
fiscal_period_id: periodId,
|
||||
company_name: company.company_name || 'Unknown',
|
||||
org_number: company.org_number,
|
||||
|
||||
@@ -17,7 +17,7 @@ export async function GET() {
|
||||
}
|
||||
|
||||
try {
|
||||
const coverage = await getSRUCoverage(user.id)
|
||||
const coverage = await getSRUCoverage(supabase, user.id)
|
||||
return NextResponse.json({ data: coverage })
|
||||
} catch (err) {
|
||||
console.error('Error fetching SRU coverage:', err)
|
||||
|
||||
@@ -73,7 +73,7 @@ export async function GET(request: Request) {
|
||||
}
|
||||
|
||||
// Aggregate balances by SRU code
|
||||
const sruBalances = await aggregateBalancesBySRU(user.id, periodId)
|
||||
const sruBalances = await aggregateBalancesBySRU(supabase, user.id, periodId)
|
||||
|
||||
if (format === 'sru') {
|
||||
// Generate and return SRU file
|
||||
|
||||
@@ -16,11 +16,11 @@ export async function GET(request: Request) {
|
||||
const asOfDate = searchParams.get('as_of_date') || undefined
|
||||
const periodId = searchParams.get('period_id') || undefined
|
||||
|
||||
const ledger = await generateSupplierLedger(user.id, asOfDate)
|
||||
const ledger = await generateSupplierLedger(supabase, user.id, asOfDate)
|
||||
|
||||
let reconciliation = null
|
||||
if (periodId) {
|
||||
reconciliation = await generateReconciliation(user.id, periodId)
|
||||
reconciliation = await generateReconciliation(supabase, user.id, periodId)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
|
||||
@@ -18,7 +18,7 @@ export async function GET(request: Request) {
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await generateTrialBalance(user.id, periodId)
|
||||
const result = await generateTrialBalance(supabase, user.id, periodId)
|
||||
return NextResponse.json({ data: result })
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
|
||||
@@ -101,6 +101,7 @@ export async function GET(request: Request) {
|
||||
|
||||
try {
|
||||
const declaration = await calculateVatDeclaration(
|
||||
supabase,
|
||||
user.id,
|
||||
periodType,
|
||||
year,
|
||||
|
||||
@@ -103,6 +103,7 @@ export async function POST(
|
||||
if (accountingMethod === 'accrual') {
|
||||
try {
|
||||
const journalEntry = await createSupplierCreditNoteEntry(
|
||||
supabase,
|
||||
user.id,
|
||||
creditNote as SupplierInvoice,
|
||||
creditItems as SupplierInvoiceItem[],
|
||||
|
||||
@@ -63,6 +63,7 @@ export async function POST(
|
||||
try {
|
||||
if (accountingMethod === 'cash') {
|
||||
const journalEntry = await createSupplierInvoiceCashEntry(
|
||||
supabase,
|
||||
user.id,
|
||||
invoice as SupplierInvoice,
|
||||
(invoice.items || []) as SupplierInvoiceItem[],
|
||||
@@ -72,6 +73,7 @@ export async function POST(
|
||||
if (journalEntry) journalEntryId = journalEntry.id
|
||||
} else {
|
||||
const journalEntry = await createSupplierInvoicePaymentEntry(
|
||||
supabase,
|
||||
user.id,
|
||||
invoice as SupplierInvoice,
|
||||
paymentAmount,
|
||||
|
||||
@@ -167,6 +167,7 @@ export async function POST(request: Request) {
|
||||
if (accountingMethod === 'accrual') {
|
||||
try {
|
||||
const journalEntry = await createSupplierInvoiceRegistrationEntry(
|
||||
supabase,
|
||||
user.id,
|
||||
invoice as SupplierInvoice,
|
||||
items as SupplierInvoiceItem[],
|
||||
|
||||
@@ -155,7 +155,7 @@ describe('POST /api/transactions/[id]/book', () => {
|
||||
expect(body.journal_entry_id).toBe('je-new')
|
||||
expect(body.data.id).toBe('je-new')
|
||||
|
||||
expect(mockCreateJournalEntry).toHaveBeenCalledWith('user-1', {
|
||||
expect(mockCreateJournalEntry).toHaveBeenCalledWith(expect.anything(), 'user-1', {
|
||||
fiscal_period_id: VALID_UUID,
|
||||
entry_date: '2025-01-15',
|
||||
description: 'Test booking',
|
||||
|
||||
@@ -49,7 +49,7 @@ export async function POST(
|
||||
// Create journal entry via the engine
|
||||
let journalEntry
|
||||
try {
|
||||
journalEntry = await createJournalEntry(user.id, {
|
||||
journalEntry = await createJournalEntry(supabase, user.id, {
|
||||
fiscal_period_id,
|
||||
entry_date,
|
||||
description,
|
||||
|
||||
@@ -156,6 +156,7 @@ describe('POST /api/transactions/[id]/categorize', () => {
|
||||
expect(body.journal_entry_id).toBe('je-1')
|
||||
expect(body.category).toBe('expense_software')
|
||||
expect(mockSaveUserMappingRule).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'user-1',
|
||||
'GitHub',
|
||||
'6200',
|
||||
|
||||
@@ -245,6 +245,7 @@ export async function POST(
|
||||
|
||||
try {
|
||||
const journalEntry = await createTransactionJournalEntry(
|
||||
supabase,
|
||||
user.id,
|
||||
transaction as Transaction,
|
||||
mappingResult
|
||||
@@ -264,6 +265,7 @@ export async function POST(
|
||||
if (is_business && transaction.merchant_name) {
|
||||
try {
|
||||
await saveUserMappingRule(
|
||||
supabase,
|
||||
user.id,
|
||||
transaction.merchant_name,
|
||||
mappingResult.debit_account,
|
||||
|
||||
@@ -10,10 +10,19 @@ import {
|
||||
// Mock init
|
||||
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
|
||||
|
||||
// Mock template embeddings
|
||||
// Mock extension registry
|
||||
const mockFindSimilarTemplates = vi.fn().mockResolvedValue([])
|
||||
vi.mock('@/lib/bookkeeping/template-embeddings', () => ({
|
||||
findSimilarTemplates: (...args: unknown[]) => mockFindSimilarTemplates(...args),
|
||||
vi.mock('@/lib/extensions/registry', () => ({
|
||||
extensionRegistry: {
|
||||
get: vi.fn().mockReturnValue({
|
||||
id: 'ai-categorization',
|
||||
name: 'AI',
|
||||
version: '1.0.0',
|
||||
services: {
|
||||
findSimilarTemplates: (...args: unknown[]) => mockFindSimilarTemplates(...args),
|
||||
},
|
||||
}),
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock Supabase
|
||||
|
||||
@@ -3,7 +3,8 @@ import { NextResponse } from 'next/server'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { DescribeTransactionSchema } from '@/lib/api/schemas'
|
||||
import { findSimilarTemplates } from '@/lib/bookkeeping/template-embeddings'
|
||||
import { extensionRegistry } from '@/lib/extensions/registry'
|
||||
import { findMatchingTemplates, type TemplateMatch } from '@/lib/bookkeeping/booking-templates'
|
||||
import type { Transaction, EntityType } from '@/types'
|
||||
|
||||
ensureInitialized()
|
||||
@@ -47,12 +48,19 @@ export async function POST(
|
||||
const entityType: EntityType = (settings?.entity_type as EntityType) || 'enskild_firma'
|
||||
|
||||
// Run embedding search with user description dominating the query
|
||||
const templates = await findSimilarTemplates(
|
||||
transaction as Transaction,
|
||||
entityType,
|
||||
10,
|
||||
description
|
||||
)
|
||||
let templates: TemplateMatch[]
|
||||
const aiExt = extensionRegistry.get('ai-categorization')
|
||||
if (aiExt?.services?.findSimilarTemplates) {
|
||||
templates = await aiExt.services.findSimilarTemplates(
|
||||
transaction as Transaction,
|
||||
entityType,
|
||||
10,
|
||||
description
|
||||
)
|
||||
} else {
|
||||
// Fallback to keyword matching when AI extension not loaded
|
||||
templates = findMatchingTemplates(transaction as Transaction, entityType)
|
||||
}
|
||||
|
||||
// Flag if top confidence is too low
|
||||
const needsMoreDetail = templates.length === 0 || templates[0].confidence < 0.55
|
||||
|
||||
@@ -187,6 +187,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => {
|
||||
|
||||
// Verify accrual payment entry was called
|
||||
expect(mockCreateInvoicePaymentJournalEntry).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'user-1',
|
||||
expect.objectContaining({ id: VALID_UUID }),
|
||||
'2024-06-15'
|
||||
|
||||
@@ -106,6 +106,7 @@ export async function POST(
|
||||
if (accountingMethod === 'cash') {
|
||||
// Kontantmetoden: combined revenue entry with per-line VAT rates
|
||||
const journalEntry = await createInvoiceCashEntry(
|
||||
supabase,
|
||||
user.id,
|
||||
invoice as Invoice,
|
||||
transaction.date,
|
||||
@@ -115,6 +116,7 @@ export async function POST(
|
||||
} else {
|
||||
// Faktureringsmetoden: clear receivable (Debit 1930, Credit 1510)
|
||||
const journalEntry = await createInvoicePaymentJournalEntry(
|
||||
supabase,
|
||||
user.id,
|
||||
invoice as Invoice,
|
||||
transaction.date
|
||||
|
||||
@@ -17,7 +17,7 @@ async function ensureFiscalPeriod(
|
||||
userId: string,
|
||||
date: string
|
||||
): Promise<string | null> {
|
||||
const existingPeriodId = await findFiscalPeriod(userId, date)
|
||||
const existingPeriodId = await findFiscalPeriod(supabase, userId, date)
|
||||
if (existingPeriodId) return existingPeriodId
|
||||
|
||||
const transactionDate = new Date(date)
|
||||
@@ -130,6 +130,7 @@ export async function POST(
|
||||
try {
|
||||
if (accountingMethod === 'cash') {
|
||||
const journalEntry = await createSupplierInvoiceCashEntry(
|
||||
supabase,
|
||||
user.id,
|
||||
invoice as SupplierInvoice,
|
||||
(invoice.items || []) as SupplierInvoiceItem[],
|
||||
@@ -139,6 +140,7 @@ export async function POST(
|
||||
if (journalEntry) journalEntryId = journalEntry.id
|
||||
} else {
|
||||
const journalEntry = await createSupplierInvoicePaymentEntry(
|
||||
supabase,
|
||||
user.id,
|
||||
invoice as SupplierInvoice,
|
||||
paymentAmount,
|
||||
|
||||
@@ -170,6 +170,7 @@ describe('POST /api/transactions/batch-describe', () => {
|
||||
|
||||
// Verify mapping rule was saved with user description
|
||||
expect(mockSaveUserMappingRule).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'user-1',
|
||||
'Staples',
|
||||
'6110',
|
||||
|
||||
@@ -108,6 +108,7 @@ export async function POST(request: Request) {
|
||||
let journalEntryId: string | null = null
|
||||
try {
|
||||
const journalEntry = await createTransactionJournalEntry(
|
||||
supabase,
|
||||
user.id,
|
||||
tx as Transaction,
|
||||
mappingResult
|
||||
@@ -152,6 +153,7 @@ export async function POST(request: Request) {
|
||||
const sampleTx = transactions[0] as Transaction
|
||||
const sampleResult = buildMappingResultFromTemplate(template, sampleTx, entityType)
|
||||
await saveUserMappingRule(
|
||||
supabase,
|
||||
user.id,
|
||||
merchant_name,
|
||||
sampleResult.debit_account,
|
||||
|
||||
@@ -37,6 +37,7 @@ export async function POST() {
|
||||
for (const tx of transactions) {
|
||||
try {
|
||||
const bestMatch = await getBestInvoiceMatch(
|
||||
supabase,
|
||||
user.id,
|
||||
tx as Transaction,
|
||||
0.50
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getSuggestedCategories, mergeAiSuggestions, getSuggestedTemplates, type SuggestedCategory, type SuggestedTemplate } from '@/lib/transactions/category-suggestions'
|
||||
import { extensionRegistry } from '@/lib/extensions/registry'
|
||||
import type { Transaction, TransactionCategory, EntityType } from '@/types'
|
||||
|
||||
// Minimum confidence threshold — below this, suggestions are considered weak
|
||||
@@ -195,10 +196,12 @@ export async function POST(request: Request) {
|
||||
)
|
||||
|
||||
try {
|
||||
const { categorizeTransactions } = await import(
|
||||
'@/extensions/general/ai-categorization'
|
||||
)
|
||||
const aiResults = await categorizeTransactions(user.id, needsAiIds)
|
||||
const aiExt = extensionRegistry.get('ai-categorization')
|
||||
if (!aiExt?.services?.categorizeTransactions) {
|
||||
console.log('[suggest-categories] AI categorization extension not loaded, skipping on-demand AI')
|
||||
return NextResponse.json({ suggestions, template_suggestions })
|
||||
}
|
||||
const aiResults: Array<{ transactionId: string; category: string; basAccount: string; confidence: number; reasoning: string; isPrivate?: boolean; templateId?: string }> = await aiExt.services.categorizeTransactions(user.id, needsAiIds)
|
||||
|
||||
console.log(
|
||||
'[suggest-categories] AI categorization results:',
|
||||
|
||||
Reference in New Issue
Block a user