feat: event log, pending operations, and MCP staging (#135)
* feat: event log, pending operations, and MCP staging - Event log system: persist bus events to event_log table for external automation platforms. Batch insert for transaction.synced. Daily cleanup cron at 02:00 UTC. - Pending operations: MCP write tools (categorize, create customer, create invoice) now stage to pending_operations instead of executing directly. Users review and commit/reject from /pending in the web UI. - Granskning page: card-based review UI with expandable previews, commit/reject dialogs. Only shown in nav when pending ops exist. - Commit route re-executes using core lib functions (no extension imports). Guards against stale state (double-commit, deleted entities). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: stage new MCP write tools after main merge Add staging for 4 new write tools from #133: - mark_invoice_paid, send_invoice, mark_invoice_sent, match_transaction_invoice - Expand pending_operations CHECK constraint - Add commit executors with full execution logic - Add UI labels and generic preview component - Remove confirm parameter from categorize (single-call staging) - Fix UUID in pending op title (fetch transaction description) - Hide Granskning nav when no pending ops Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address PR review feedback - Fix TS build error: use `select('*, customer:customers(*)')` for match_transaction_invoice to avoid array type inference - Add status guard to commitSendInvoice (prevents duplicate sends) - Replace auth.admin.getUserById with user email from session auth - Restore optimistic lock check in commitMatchTransactionInvoice - Fix tool description typo: expense_software → expense_office Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
4d2152946a
commit
f3ae3cd361
@@ -0,0 +1,210 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import {
|
||||
createMockRequest,
|
||||
parseJsonResponse,
|
||||
createQueuedMockSupabase,
|
||||
} from '@/tests/helpers'
|
||||
|
||||
// Mock supabase server (session auth)
|
||||
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: () => Promise.resolve(mockSupabase),
|
||||
}))
|
||||
|
||||
// Mock API key auth
|
||||
const mockValidateApiKey = vi.fn()
|
||||
const mockExtractBearerToken = vi.fn()
|
||||
const mockCreateServiceClientNoCookies = vi.fn()
|
||||
vi.mock('@/lib/auth/api-keys', () => ({
|
||||
validateApiKey: (...args: unknown[]) => mockValidateApiKey(...args),
|
||||
extractBearerToken: (...args: unknown[]) => mockExtractBearerToken(...args),
|
||||
createServiceClientNoCookies: () => mockCreateServiceClientNoCookies(),
|
||||
}))
|
||||
|
||||
import { GET } from '../route'
|
||||
|
||||
describe('GET /api/events', () => {
|
||||
const mockUser = { id: 'user-1', email: 'test@test.se' }
|
||||
|
||||
const sampleEvents = [
|
||||
{
|
||||
sequence: 1,
|
||||
event_type: 'invoice.created',
|
||||
entity_id: 'inv-1',
|
||||
data: { invoice: { id: 'inv-1', total: 1000 } },
|
||||
created_at: '2026-03-25T10:00:00Z',
|
||||
},
|
||||
{
|
||||
sequence: 2,
|
||||
event_type: 'customer.created',
|
||||
entity_id: 'cust-1',
|
||||
data: { customer: { id: 'cust-1', name: 'Acme AB' } },
|
||||
created_at: '2026-03-25T10:01:00Z',
|
||||
},
|
||||
]
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
mockExtractBearerToken.mockReturnValue(null)
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
|
||||
})
|
||||
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
mockExtractBearerToken.mockReturnValue(null)
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } })
|
||||
|
||||
const request = createMockRequest('/api/events')
|
||||
const response = await GET(request)
|
||||
const { status, body } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(401)
|
||||
expect(body).toEqual({ error: 'Unauthorized' })
|
||||
})
|
||||
|
||||
it('returns events with session auth', async () => {
|
||||
enqueue({ data: sampleEvents })
|
||||
|
||||
const request = createMockRequest('/api/events')
|
||||
const response = await GET(request)
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
data: typeof sampleEvents
|
||||
cursor: number
|
||||
has_more: boolean
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data).toHaveLength(2)
|
||||
expect(body.cursor).toBe(2)
|
||||
expect(body.has_more).toBe(false)
|
||||
})
|
||||
|
||||
it('returns events with API key auth', async () => {
|
||||
mockExtractBearerToken.mockReturnValue('gnubok_sk_test123')
|
||||
mockValidateApiKey.mockResolvedValue({ userId: 'user-1' })
|
||||
|
||||
const apiKeySupabase = createQueuedMockSupabase()
|
||||
apiKeySupabase.enqueue({ data: sampleEvents })
|
||||
mockCreateServiceClientNoCookies.mockReturnValue(apiKeySupabase.supabase)
|
||||
|
||||
const request = createMockRequest('/api/events')
|
||||
const response = await GET(request)
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
data: typeof sampleEvents
|
||||
cursor: number
|
||||
has_more: boolean
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data).toHaveLength(2)
|
||||
expect(mockValidateApiKey).toHaveBeenCalledWith('gnubok_sk_test123')
|
||||
})
|
||||
|
||||
it('returns 401 for invalid API key', async () => {
|
||||
mockExtractBearerToken.mockReturnValue('gnubok_sk_invalid')
|
||||
mockValidateApiKey.mockResolvedValue({ error: 'Invalid API key', status: 401 })
|
||||
|
||||
const request = createMockRequest('/api/events')
|
||||
const response = await GET(request)
|
||||
const { status, body } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(401)
|
||||
expect(body).toEqual({ error: 'Invalid API key' })
|
||||
})
|
||||
|
||||
it('returns 429 for rate-limited API key', async () => {
|
||||
mockExtractBearerToken.mockReturnValue('gnubok_sk_limited')
|
||||
mockValidateApiKey.mockResolvedValue({ error: 'Rate limit exceeded', status: 429 })
|
||||
|
||||
const request = createMockRequest('/api/events')
|
||||
const response = await GET(request)
|
||||
const { status } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(429)
|
||||
})
|
||||
|
||||
it('supports after cursor parameter', async () => {
|
||||
enqueue({ data: [sampleEvents[1]] })
|
||||
|
||||
const request = createMockRequest('/api/events', {
|
||||
searchParams: { after: '1' },
|
||||
})
|
||||
const response = await GET(request)
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
data: typeof sampleEvents
|
||||
cursor: number
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data).toHaveLength(1)
|
||||
expect(body.cursor).toBe(2)
|
||||
})
|
||||
|
||||
it('supports types filter parameter', async () => {
|
||||
enqueue({ data: [sampleEvents[0]] })
|
||||
|
||||
const request = createMockRequest('/api/events', {
|
||||
searchParams: { types: 'invoice.created' },
|
||||
})
|
||||
const response = await GET(request)
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
data: typeof sampleEvents
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('returns has_more=true when results equal limit', async () => {
|
||||
// Return exactly `limit` items to trigger has_more
|
||||
const events = Array.from({ length: 2 }, (_, i) => ({
|
||||
sequence: i + 1,
|
||||
event_type: 'invoice.created',
|
||||
entity_id: `inv-${i}`,
|
||||
data: {},
|
||||
created_at: '2026-03-25T10:00:00Z',
|
||||
}))
|
||||
enqueue({ data: events })
|
||||
|
||||
const request = createMockRequest('/api/events', {
|
||||
searchParams: { limit: '2' },
|
||||
})
|
||||
const response = await GET(request)
|
||||
const { body } = await parseJsonResponse<{ has_more: boolean }>(response)
|
||||
|
||||
expect(body.has_more).toBe(true)
|
||||
})
|
||||
|
||||
it('returns cursor=0 when no events and no after param', async () => {
|
||||
enqueue({ data: [] })
|
||||
|
||||
const request = createMockRequest('/api/events')
|
||||
const response = await GET(request)
|
||||
const { body } = await parseJsonResponse<{ cursor: number; data: unknown[] }>(response)
|
||||
|
||||
expect(body.data).toHaveLength(0)
|
||||
expect(body.cursor).toBe(0)
|
||||
})
|
||||
|
||||
it('returns cursor=after when no events but after param provided', async () => {
|
||||
enqueue({ data: [] })
|
||||
|
||||
const request = createMockRequest('/api/events', {
|
||||
searchParams: { after: '42' },
|
||||
})
|
||||
const response = await GET(request)
|
||||
const { body } = await parseJsonResponse<{ cursor: number }>(response)
|
||||
|
||||
expect(body.cursor).toBe(42)
|
||||
})
|
||||
|
||||
it('rejects invalid limit parameter', async () => {
|
||||
const request = createMockRequest('/api/events', {
|
||||
searchParams: { limit: '999' },
|
||||
})
|
||||
const response = await GET(request)
|
||||
const { status } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,41 @@
|
||||
import { createServiceClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
/**
|
||||
* GET /api/events/cleanup/cron
|
||||
* Daily cron job to delete event_log rows older than 30 days.
|
||||
* Runs at 02:00 UTC every day.
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
const authHeader = request.headers.get('authorization')
|
||||
const cronSecret = process.env.CRON_SECRET
|
||||
|
||||
if (!cronSecret || authHeader !== `Bearer ${cronSecret}`) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const supabase = await createServiceClient()
|
||||
|
||||
const cutoff = new Date()
|
||||
cutoff.setDate(cutoff.getDate() - 30)
|
||||
|
||||
const { error, count } = await supabase
|
||||
.from('event_log')
|
||||
.delete({ count: 'exact' })
|
||||
.lt('created_at', cutoff.toISOString())
|
||||
|
||||
if (error) throw error
|
||||
|
||||
const deleted = count ?? 0
|
||||
console.log(`Event log cleanup completed: ${deleted} events removed`)
|
||||
|
||||
return NextResponse.json({ success: true, deleted })
|
||||
} catch (error) {
|
||||
console.error('Error in event log cleanup cron:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to clean up event log' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { extractBearerToken, validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys'
|
||||
import { validateQuery } from '@/lib/api/validate'
|
||||
import { EventsQuerySchema } from '@/lib/api/schemas'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
|
||||
/**
|
||||
* GET /api/events
|
||||
*
|
||||
* Cursor-based polling endpoint for external automation platforms (n8n, Make, Zapier).
|
||||
* Returns events from the event_log table in sequence order.
|
||||
*
|
||||
* Query params:
|
||||
* - after (bigint, optional): return events with sequence > this value
|
||||
* - types (string, optional): comma-separated event type filter
|
||||
* - limit (int, optional): max results, default 50, cap 100
|
||||
*
|
||||
* Supports both session auth (browser) and API key auth (automation platforms).
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
// Dual auth: API key or session
|
||||
let userId: string
|
||||
let supabase: SupabaseClient
|
||||
|
||||
const token = extractBearerToken(request)
|
||||
if (token?.startsWith('gnubok_sk_')) {
|
||||
const authResult = await validateApiKey(token)
|
||||
if ('error' in authResult) {
|
||||
return NextResponse.json({ error: authResult.error }, { status: authResult.status })
|
||||
}
|
||||
userId = authResult.userId
|
||||
supabase = createServiceClientNoCookies()
|
||||
} else {
|
||||
supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
userId = user.id
|
||||
}
|
||||
|
||||
// Validate query params
|
||||
const result = validateQuery(request, EventsQuerySchema)
|
||||
if (!result.success) return result.response
|
||||
const { after, types, limit } = result.data
|
||||
|
||||
// Build query
|
||||
let query = supabase
|
||||
.from('event_log')
|
||||
.select('sequence, event_type, entity_id, data, created_at')
|
||||
.eq('user_id', userId)
|
||||
.order('sequence', { ascending: true })
|
||||
.limit(limit)
|
||||
|
||||
if (after !== undefined) {
|
||||
query = query.gt('sequence', after)
|
||||
}
|
||||
|
||||
if (types && types.length > 0) {
|
||||
query = query.in('event_type', types)
|
||||
}
|
||||
|
||||
const { data, error } = await query
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
const events = data ?? []
|
||||
|
||||
return NextResponse.json({
|
||||
data: events,
|
||||
cursor: events.length > 0 ? events[events.length - 1].sequence : (after ?? 0),
|
||||
has_more: events.length === limit,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import {
|
||||
createMockRequest,
|
||||
createMockRouteParams,
|
||||
parseJsonResponse,
|
||||
createQueuedMockSupabase,
|
||||
makeTransaction,
|
||||
makeCompanySettings,
|
||||
} from '@/tests/helpers'
|
||||
import { eventBus } from '@/lib/events/bus'
|
||||
|
||||
const { supabase: mockSupabase, enqueue, enqueueMany, reset } = createQueuedMockSupabase()
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: () => Promise.resolve(mockSupabase),
|
||||
}))
|
||||
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
|
||||
|
||||
// Mock the counterparty templates (non-critical side effect)
|
||||
vi.mock('@/lib/bookkeeping/counterparty-templates', () => ({
|
||||
upsertCounterpartyTemplate: vi.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
|
||||
// Mock createTransactionJournalEntry
|
||||
const mockCreateJournalEntry = vi.fn()
|
||||
vi.mock('@/lib/bookkeeping/transaction-entries', () => ({
|
||||
createTransactionJournalEntry: (...args: unknown[]) => mockCreateJournalEntry(...args),
|
||||
}))
|
||||
|
||||
// Mock VAT validation
|
||||
vi.mock('@/lib/vat/vies-client', () => ({
|
||||
validateVatNumber: vi.fn().mockResolvedValue({ valid: true }),
|
||||
}))
|
||||
|
||||
// Mock exchange rate
|
||||
vi.mock('@/lib/currency/riksbanken', () => ({
|
||||
fetchExchangeRate: vi.fn().mockResolvedValue({ rate: 11.5, date: '2026-03-25' }),
|
||||
convertToSEK: vi.fn((amount: number, rate: number) => Math.round(amount * rate * 100) / 100),
|
||||
}))
|
||||
|
||||
import { POST } from '../../commit/route'
|
||||
|
||||
describe('POST /api/pending-operations/:id/commit', () => {
|
||||
const mockUser = { id: 'user-1', email: 'test@test.se' }
|
||||
const routeParams = createMockRouteParams({ id: 'op-1' })
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
eventBus.clear()
|
||||
reset()
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
|
||||
mockCreateJournalEntry.mockResolvedValue({ id: 'je-1' })
|
||||
})
|
||||
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } })
|
||||
|
||||
const request = createMockRequest('/api/pending-operations/op-1/commit', { method: 'POST' })
|
||||
const response = await POST(request, routeParams)
|
||||
const { status } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns 404 when operation not found', async () => {
|
||||
enqueue({ data: null, error: { message: 'not found' } })
|
||||
|
||||
const request = createMockRequest('/api/pending-operations/op-1/commit', { method: 'POST' })
|
||||
const response = await POST(request, routeParams)
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
|
||||
expect(status).toBe(404)
|
||||
expect(body.error).toContain('not found')
|
||||
})
|
||||
|
||||
it('returns 409 when operation already committed', async () => {
|
||||
enqueue({
|
||||
data: {
|
||||
id: 'op-1',
|
||||
user_id: 'user-1',
|
||||
operation_type: 'categorize_transaction',
|
||||
status: 'committed',
|
||||
params: {},
|
||||
preview_data: {},
|
||||
},
|
||||
})
|
||||
|
||||
const request = createMockRequest('/api/pending-operations/op-1/commit', { method: 'POST' })
|
||||
const response = await POST(request, routeParams)
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
|
||||
expect(status).toBe(409)
|
||||
expect(body.error).toContain('already committed')
|
||||
})
|
||||
|
||||
describe('categorize_transaction', () => {
|
||||
const pendingOp = {
|
||||
id: 'op-1',
|
||||
user_id: 'user-1',
|
||||
operation_type: 'categorize_transaction',
|
||||
status: 'pending',
|
||||
title: 'Kategorisera: test',
|
||||
params: {
|
||||
transaction_id: 'tx-1',
|
||||
category: 'expense_office',
|
||||
vat_treatment: null,
|
||||
},
|
||||
preview_data: {},
|
||||
}
|
||||
|
||||
it('commits successfully', async () => {
|
||||
const tx = makeTransaction({ id: 'tx-1', amount: -500, journal_entry_id: null })
|
||||
const settings = makeCompanySettings()
|
||||
|
||||
enqueueMany([
|
||||
{ data: pendingOp }, // fetch pending op
|
||||
{ data: tx }, // fetch transaction
|
||||
{ data: settings }, // fetch company settings
|
||||
{ data: [{ id: 'fp-1' }] }, // fiscal period check
|
||||
{ data: null, error: null }, // update transaction
|
||||
{ data: null, error: null }, // upsert counterparty template
|
||||
{ data: null, error: null }, // update pending op status
|
||||
])
|
||||
|
||||
const request = createMockRequest('/api/pending-operations/op-1/commit', { method: 'POST' })
|
||||
const response = await POST(request, routeParams)
|
||||
const { status, body } = await parseJsonResponse<{ data: { journal_entry_id: string } }>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.journal_entry_id).toBe('je-1')
|
||||
expect(mockCreateJournalEntry).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('returns 409 when transaction already categorized', async () => {
|
||||
const tx = makeTransaction({ id: 'tx-1', journal_entry_id: 'existing-je' })
|
||||
|
||||
enqueueMany([
|
||||
{ data: pendingOp }, // fetch pending op
|
||||
{ data: tx }, // fetch transaction (already has JE)
|
||||
{ data: null, error: null }, // auto-reject update
|
||||
])
|
||||
|
||||
const request = createMockRequest('/api/pending-operations/op-1/commit', { method: 'POST' })
|
||||
const response = await POST(request, routeParams)
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
|
||||
expect(status).toBe(409)
|
||||
expect(body.error).toContain('already has a journal entry')
|
||||
})
|
||||
})
|
||||
|
||||
describe('create_customer', () => {
|
||||
const pendingOp = {
|
||||
id: 'op-1',
|
||||
user_id: 'user-1',
|
||||
operation_type: 'create_customer',
|
||||
status: 'pending',
|
||||
title: 'Ny kund: Acme AB',
|
||||
params: {
|
||||
name: 'Acme AB',
|
||||
customer_type: 'swedish_business',
|
||||
email: 'info@acme.se',
|
||||
},
|
||||
preview_data: {},
|
||||
}
|
||||
|
||||
it('commits successfully', async () => {
|
||||
enqueueMany([
|
||||
{ data: pendingOp }, // fetch pending op
|
||||
{ data: { id: 'cust-1', name: 'Acme AB' } }, // insert customer
|
||||
{ data: null, error: null }, // update pending op status
|
||||
])
|
||||
|
||||
const request = createMockRequest('/api/pending-operations/op-1/commit', { method: 'POST' })
|
||||
const response = await POST(request, routeParams)
|
||||
const { status, body } = await parseJsonResponse<{ data: { customer_id: string } }>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.customer_id).toBe('cust-1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('create_invoice', () => {
|
||||
const pendingOp = {
|
||||
id: 'op-1',
|
||||
user_id: 'user-1',
|
||||
operation_type: 'create_invoice',
|
||||
status: 'pending',
|
||||
title: 'Ny faktura: Acme AB 15000 SEK',
|
||||
params: {
|
||||
customer_id: 'cust-1',
|
||||
items: [{ description: 'Konsulttjänster', quantity: 1, unit: 'st', unit_price: 15000 }],
|
||||
invoice_date: '2026-03-25',
|
||||
due_date: '2026-04-24',
|
||||
currency: 'SEK',
|
||||
},
|
||||
preview_data: {},
|
||||
}
|
||||
|
||||
it('commits successfully', async () => {
|
||||
const customer = {
|
||||
id: 'cust-1',
|
||||
name: 'Acme AB',
|
||||
customer_type: 'swedish_business',
|
||||
vat_number_validated: false,
|
||||
default_payment_terms: 30,
|
||||
}
|
||||
|
||||
enqueueMany([
|
||||
{ data: pendingOp }, // fetch pending op
|
||||
{ data: customer }, // fetch customer
|
||||
{ data: '20260001' }, // generate invoice number (rpc)
|
||||
{ data: { id: 'inv-1' } }, // insert invoice
|
||||
{ data: null, error: null }, // insert items
|
||||
{ data: { id: 'inv-1', customer: customer, items: [] } }, // fetch complete invoice
|
||||
{ data: null, error: null }, // update pending op status
|
||||
])
|
||||
|
||||
const request = createMockRequest('/api/pending-operations/op-1/commit', { method: 'POST' })
|
||||
const response = await POST(request, routeParams)
|
||||
const { status, body } = await parseJsonResponse<{ data: { invoice_id: string; invoice_number: string } }>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.invoice_id).toBe('inv-1')
|
||||
expect(body.data.invoice_number).toBe('20260001')
|
||||
})
|
||||
|
||||
it('returns 404 when customer not found', async () => {
|
||||
enqueueMany([
|
||||
{ data: pendingOp }, // fetch pending op
|
||||
{ data: null, error: { message: 'not found' } }, // customer not found
|
||||
{ data: null, error: null }, // auto-reject update
|
||||
])
|
||||
|
||||
const request = createMockRequest('/api/pending-operations/op-1/commit', { method: 'POST' })
|
||||
const response = await POST(request, routeParams)
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
|
||||
expect(status).toBe(404)
|
||||
expect(body.error).toContain('Customer not found')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,858 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { eventBus } from '@/lib/events'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { buildMappingResultFromCategory } from '@/lib/bookkeeping/category-mapping'
|
||||
import { createTransactionJournalEntry } from '@/lib/bookkeeping/transaction-entries'
|
||||
import { upsertCounterpartyTemplate } from '@/lib/bookkeeping/counterparty-templates'
|
||||
import { getVatRules, getAvailableVatRates } from '@/lib/invoices/vat-rules'
|
||||
import { fetchExchangeRate, convertToSEK } from '@/lib/currency/riksbanken'
|
||||
import { validateVatNumber } from '@/lib/vat/vies-client'
|
||||
import {
|
||||
createInvoicePaymentJournalEntry,
|
||||
createInvoiceCashEntry,
|
||||
createInvoiceJournalEntry,
|
||||
} from '@/lib/bookkeeping/invoice-entries'
|
||||
import { reverseEntry } from '@/lib/bookkeeping/engine'
|
||||
import { getEmailService } from '@/lib/email/service'
|
||||
import {
|
||||
generateInvoiceEmailHtml,
|
||||
generateInvoiceEmailText,
|
||||
generateInvoiceEmailSubject,
|
||||
} from '@/lib/email/invoice-templates'
|
||||
import { uploadDocument } from '@/lib/core/documents/document-service'
|
||||
import { renderToBuffer } from '@react-pdf/renderer'
|
||||
import { InvoicePDF } from '@/lib/invoices/pdf-template'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
import type {
|
||||
Transaction,
|
||||
TransactionCategory,
|
||||
EntityType,
|
||||
VatTreatment,
|
||||
Currency,
|
||||
Invoice,
|
||||
Customer,
|
||||
PendingOperation,
|
||||
CompanySettings,
|
||||
InvoiceItem,
|
||||
} from '@/types'
|
||||
|
||||
const log = createLogger('pending-operations/commit')
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
/**
|
||||
* Ensure a fiscal period exists for the given date, create one if needed.
|
||||
* Same logic as app/api/transactions/[id]/categorize/route.ts
|
||||
*/
|
||||
async function ensureFiscalPeriod(
|
||||
supabase: Awaited<ReturnType<typeof createClient>>,
|
||||
userId: string,
|
||||
date: string,
|
||||
fiscalYearStartMonth: number = 1
|
||||
): Promise<boolean> {
|
||||
const { data: existing } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('id')
|
||||
.eq('user_id', userId)
|
||||
.lte('period_start', date)
|
||||
.gte('period_end', date)
|
||||
.eq('is_closed', false)
|
||||
.limit(1)
|
||||
|
||||
if (existing && existing.length > 0) return true
|
||||
|
||||
const txDate = new Date(date)
|
||||
const txMonth = txDate.getMonth() + 1
|
||||
const txYear = txDate.getFullYear()
|
||||
|
||||
let periodStartYear: number
|
||||
if (fiscalYearStartMonth === 1) {
|
||||
periodStartYear = txYear
|
||||
} else if (txMonth >= fiscalYearStartMonth) {
|
||||
periodStartYear = txYear
|
||||
} else {
|
||||
periodStartYear = txYear - 1
|
||||
}
|
||||
|
||||
const startMonth = String(fiscalYearStartMonth).padStart(2, '0')
|
||||
const periodStart = `${periodStartYear}-${startMonth}-01`
|
||||
|
||||
const endYear = fiscalYearStartMonth === 1 ? periodStartYear : periodStartYear + 1
|
||||
const endMonth = fiscalYearStartMonth === 1 ? 12 : fiscalYearStartMonth - 1
|
||||
const lastDay = new Date(endYear, endMonth, 0).getDate()
|
||||
const periodEnd = `${endYear}-${String(endMonth).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`
|
||||
|
||||
const periodName = fiscalYearStartMonth === 1
|
||||
? `Räkenskapsår ${periodStartYear}`
|
||||
: `Räkenskapsår ${periodStartYear}/${endYear}`
|
||||
|
||||
const { error } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.upsert({
|
||||
user_id: userId,
|
||||
name: periodName,
|
||||
period_start: periodStart,
|
||||
period_end: periodEnd,
|
||||
}, { onConflict: 'user_id,period_start,period_end' })
|
||||
|
||||
if (error) {
|
||||
log.error('Failed to create fiscal period:', error)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ── Commit executors ──────────────────────────────────────────
|
||||
|
||||
async function commitCategorizeTransaction(
|
||||
supabase: Awaited<ReturnType<typeof createClient>>,
|
||||
userId: string,
|
||||
params: Record<string, unknown>
|
||||
): Promise<{ data?: Record<string, unknown>; error?: string; status?: number }> {
|
||||
const txId = params.transaction_id as string
|
||||
const category = params.category as TransactionCategory
|
||||
const vatTreatment = params.vat_treatment as VatTreatment | undefined
|
||||
|
||||
// Fetch transaction — guard against double-commit
|
||||
const { data: transaction, error: fetchError } = await supabase
|
||||
.from('transactions')
|
||||
.select('*')
|
||||
.eq('id', txId)
|
||||
.eq('user_id', userId)
|
||||
.single()
|
||||
|
||||
if (fetchError || !transaction) {
|
||||
return { error: 'Transaction not found — it may have been deleted.', status: 404 }
|
||||
}
|
||||
|
||||
if (transaction.journal_entry_id) {
|
||||
return { error: 'Transaction already has a journal entry — it was categorized in the meantime.', status: 409 }
|
||||
}
|
||||
|
||||
const isBusiness = category !== 'private'
|
||||
|
||||
// Fetch company settings
|
||||
const { data: settings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('entity_type, fiscal_year_start_month')
|
||||
.eq('user_id', userId)
|
||||
.single()
|
||||
|
||||
const entityType: EntityType = (settings?.entity_type as EntityType) || 'enskild_firma'
|
||||
const fiscalYearStartMonth = settings?.fiscal_year_start_month ?? 1
|
||||
|
||||
// Build mapping
|
||||
const mappingResult = buildMappingResultFromCategory(
|
||||
category,
|
||||
transaction as Transaction,
|
||||
isBusiness,
|
||||
entityType,
|
||||
vatTreatment
|
||||
)
|
||||
|
||||
if (!mappingResult.debit_account || !mappingResult.credit_account) {
|
||||
return { error: `No account mapping for category "${category}" with entity type "${entityType}".`, status: 400 }
|
||||
}
|
||||
|
||||
// Ensure fiscal period exists
|
||||
await ensureFiscalPeriod(supabase, userId, transaction.date, fiscalYearStartMonth)
|
||||
|
||||
// Create journal entry
|
||||
let journalEntryId: string | null = null
|
||||
try {
|
||||
const journalEntry = await createTransactionJournalEntry(
|
||||
supabase, userId, transaction as Transaction, mappingResult
|
||||
)
|
||||
if (journalEntry) {
|
||||
journalEntryId = journalEntry.id
|
||||
}
|
||||
} catch (err) {
|
||||
log.error('Failed to create journal entry:', err)
|
||||
return { error: err instanceof Error ? err.message : 'Failed to create journal entry', status: 500 }
|
||||
}
|
||||
|
||||
// Update transaction
|
||||
const { error: updateError } = await supabase
|
||||
.from('transactions')
|
||||
.update({
|
||||
is_business: isBusiness,
|
||||
category,
|
||||
journal_entry_id: journalEntryId,
|
||||
})
|
||||
.eq('id', txId)
|
||||
|
||||
if (updateError) {
|
||||
log.error('Failed to update transaction:', updateError)
|
||||
return { error: 'Failed to update transaction', status: 500 }
|
||||
}
|
||||
|
||||
// Upsert counterparty template (non-blocking)
|
||||
try {
|
||||
await upsertCounterpartyTemplate(
|
||||
supabase, userId, transaction as Transaction, mappingResult, 'user_approved'
|
||||
)
|
||||
} catch { /* non-critical */ }
|
||||
|
||||
// Emit event
|
||||
await eventBus.emit({
|
||||
type: 'transaction.categorized',
|
||||
payload: {
|
||||
transaction: transaction as Transaction,
|
||||
account: mappingResult.debit_account,
|
||||
taxCode: mappingResult.vat_lines[0]?.account_number || '',
|
||||
userId,
|
||||
},
|
||||
})
|
||||
|
||||
return { data: { journal_entry_id: journalEntryId, category } }
|
||||
}
|
||||
|
||||
async function commitCreateCustomer(
|
||||
supabase: Awaited<ReturnType<typeof createClient>>,
|
||||
userId: string,
|
||||
params: Record<string, unknown>
|
||||
): Promise<{ data?: Record<string, unknown>; error?: string; status?: number }> {
|
||||
const { data, error } = await supabase
|
||||
.from('customers')
|
||||
.insert({
|
||||
user_id: userId,
|
||||
name: params.name as string,
|
||||
customer_type: params.customer_type as string,
|
||||
email: (params.email as string) || null,
|
||||
org_number: (params.org_number as string) || null,
|
||||
vat_number: (params.vat_number as string) || null,
|
||||
default_payment_terms: (params.payment_terms as number) || 30,
|
||||
address_line1: (params.address as string) || null,
|
||||
postal_code: (params.postal_code as string) || null,
|
||||
city: (params.city as string) || null,
|
||||
country: (params.country as string) || 'Sweden',
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
return { error: error.message, status: 500 }
|
||||
}
|
||||
|
||||
// Auto-validate VAT number for EU business customers (non-blocking)
|
||||
if (params.customer_type === 'eu_business' && params.vat_number) {
|
||||
try {
|
||||
const vatResult = await validateVatNumber(params.vat_number as string)
|
||||
if (vatResult.valid) {
|
||||
await supabase
|
||||
.from('customers')
|
||||
.update({
|
||||
vat_number_validated: true,
|
||||
vat_number_validated_at: new Date().toISOString(),
|
||||
})
|
||||
.eq('id', data.id)
|
||||
.eq('user_id', userId)
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn('Auto-VIES validation failed:', err)
|
||||
}
|
||||
}
|
||||
|
||||
await eventBus.emit({
|
||||
type: 'customer.created',
|
||||
payload: { customer: data as Customer, userId },
|
||||
})
|
||||
|
||||
return { data: { customer_id: data.id } }
|
||||
}
|
||||
|
||||
async function commitCreateInvoice(
|
||||
supabase: Awaited<ReturnType<typeof createClient>>,
|
||||
userId: string,
|
||||
params: Record<string, unknown>
|
||||
): Promise<{ data?: Record<string, unknown>; error?: string; status?: number }> {
|
||||
const customerId = params.customer_id as string
|
||||
const items = params.items as Array<{
|
||||
description: string
|
||||
quantity: number
|
||||
unit: string
|
||||
unit_price: number
|
||||
vat_rate?: number
|
||||
}>
|
||||
|
||||
// Fetch customer
|
||||
const { data: customer, error: customerError } = await supabase
|
||||
.from('customers')
|
||||
.select('*')
|
||||
.eq('id', customerId)
|
||||
.eq('user_id', userId)
|
||||
.single()
|
||||
|
||||
if (customerError || !customer) {
|
||||
return { error: 'Customer not found — they may have been deleted.', status: 404 }
|
||||
}
|
||||
|
||||
// Calculate VAT
|
||||
const vatRules = getVatRules(customer.customer_type, customer.vat_number_validated)
|
||||
const availableRates = getAvailableVatRates(customer.customer_type, customer.vat_number_validated)
|
||||
const allowedRates = new Set(availableRates.map((r) => r.rate))
|
||||
|
||||
const subtotal = items.reduce((sum, item) => sum + item.quantity * item.unit_price, 0)
|
||||
|
||||
let vatAmount = 0
|
||||
for (const item of items) {
|
||||
const itemRate = item.vat_rate !== undefined ? item.vat_rate : vatRules.rate
|
||||
if (!allowedRates.has(itemRate)) {
|
||||
return { error: `Momssats ${itemRate}% är inte tillåten för denna kundtyp`, status: 400 }
|
||||
}
|
||||
const lineTotal = item.quantity * item.unit_price
|
||||
vatAmount += Math.round(lineTotal * itemRate / 100 * 100) / 100
|
||||
}
|
||||
|
||||
const total = subtotal + vatAmount
|
||||
const currency = ((params.currency as string) || 'SEK') as Currency
|
||||
|
||||
// Exchange rate
|
||||
let exchangeRate: number | null = null
|
||||
let exchangeRateDate: string | null = null
|
||||
let subtotalSek: number | null = null
|
||||
let vatAmountSek: number | null = null
|
||||
let totalSek: number | null = null
|
||||
|
||||
if (currency !== 'SEK') {
|
||||
const rateData = await fetchExchangeRate(currency)
|
||||
if (rateData) {
|
||||
exchangeRate = rateData.rate
|
||||
exchangeRateDate = rateData.date
|
||||
subtotalSek = convertToSEK(subtotal, exchangeRate)
|
||||
vatAmountSek = convertToSEK(vatAmount, exchangeRate)
|
||||
totalSek = convertToSEK(total, exchangeRate)
|
||||
}
|
||||
}
|
||||
|
||||
// Mixed-rate detection
|
||||
const uniqueRates = new Set(items.map((item) => item.vat_rate ?? vatRules.rate))
|
||||
const isMixedRate = uniqueRates.size > 1
|
||||
|
||||
// Generate invoice number
|
||||
const { data: invoiceNumber } = await supabase.rpc('generate_invoice_number', {
|
||||
p_user_id: userId,
|
||||
})
|
||||
|
||||
// Create invoice
|
||||
const { data: invoice, error: invoiceError } = await supabase
|
||||
.from('invoices')
|
||||
.insert({
|
||||
user_id: userId,
|
||||
customer_id: customerId,
|
||||
invoice_number: invoiceNumber,
|
||||
invoice_date: (params.invoice_date as string) || new Date().toISOString().split('T')[0],
|
||||
due_date: (params.due_date as string) || null,
|
||||
currency,
|
||||
exchange_rate: exchangeRate,
|
||||
exchange_rate_date: exchangeRateDate,
|
||||
subtotal,
|
||||
subtotal_sek: subtotalSek,
|
||||
vat_amount: vatAmount,
|
||||
vat_amount_sek: vatAmountSek,
|
||||
total,
|
||||
total_sek: totalSek,
|
||||
vat_treatment: vatRules.treatment,
|
||||
vat_rate: isMixedRate ? null : (uniqueRates.values().next().value ?? vatRules.rate),
|
||||
moms_ruta: vatRules.momsRuta,
|
||||
reverse_charge_text: vatRules.reverseChargeText || null,
|
||||
our_reference: (params.our_reference as string) || null,
|
||||
your_reference: (params.your_reference as string) || null,
|
||||
notes: (params.notes as string) || null,
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (invoiceError) {
|
||||
return { error: invoiceError.message, status: 500 }
|
||||
}
|
||||
|
||||
// Create invoice items
|
||||
const invoiceItems = items.map((item, index) => {
|
||||
const itemRate = item.vat_rate !== undefined ? item.vat_rate : vatRules.rate
|
||||
const lineTotal = item.quantity * item.unit_price
|
||||
const itemVat = Math.round(lineTotal * itemRate / 100 * 100) / 100
|
||||
return {
|
||||
invoice_id: invoice.id,
|
||||
sort_order: index,
|
||||
description: item.description,
|
||||
quantity: item.quantity,
|
||||
unit: item.unit,
|
||||
unit_price: item.unit_price,
|
||||
line_total: lineTotal,
|
||||
vat_rate: itemRate,
|
||||
vat_amount: itemVat,
|
||||
}
|
||||
})
|
||||
|
||||
const { error: itemsError } = await supabase
|
||||
.from('invoice_items')
|
||||
.insert(invoiceItems)
|
||||
|
||||
if (itemsError) {
|
||||
// Rollback invoice
|
||||
await supabase.from('invoices').delete().eq('id', invoice.id)
|
||||
return { error: itemsError.message, status: 500 }
|
||||
}
|
||||
|
||||
// Fetch complete invoice
|
||||
const { data: completeInvoice } = await supabase
|
||||
.from('invoices')
|
||||
.select('*, customer:customers(*), items:invoice_items(*)')
|
||||
.eq('id', invoice.id)
|
||||
.single()
|
||||
|
||||
if (completeInvoice) {
|
||||
await eventBus.emit({
|
||||
type: 'invoice.created',
|
||||
payload: { invoice: completeInvoice as Invoice, userId },
|
||||
})
|
||||
}
|
||||
|
||||
return { data: { invoice_id: invoice.id, invoice_number: invoiceNumber } }
|
||||
}
|
||||
|
||||
async function commitMarkInvoicePaid(
|
||||
supabase: Awaited<ReturnType<typeof createClient>>,
|
||||
userId: string,
|
||||
params: Record<string, unknown>
|
||||
): Promise<{ data?: Record<string, unknown>; error?: string; status?: number }> {
|
||||
const invoiceId = params.invoice_id as string
|
||||
const paymentDate = (params.payment_date as string) || new Date().toISOString().split('T')[0]
|
||||
|
||||
const { data: invoice, error: invoiceError } = await supabase
|
||||
.from('invoices')
|
||||
.select('*, customer:customers(*), items:invoice_items(*)')
|
||||
.eq('id', invoiceId)
|
||||
.eq('user_id', userId)
|
||||
.single()
|
||||
|
||||
if (invoiceError || !invoice) return { error: 'Invoice not found', status: 404 }
|
||||
if (invoice.status !== 'sent' && invoice.status !== 'overdue') {
|
||||
return { error: 'Invoice can only be marked as paid when status is "sent" or "overdue"', status: 409 }
|
||||
}
|
||||
|
||||
const { data: settings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('accounting_method, entity_type')
|
||||
.eq('user_id', userId)
|
||||
.single()
|
||||
|
||||
const accountingMethod = settings?.accounting_method || 'accrual'
|
||||
const entityType = (settings?.entity_type as EntityType) || 'enskild_firma'
|
||||
const isRealInvoice = !invoice.document_type || invoice.document_type === 'invoice'
|
||||
let journalEntryId: string | null = null
|
||||
|
||||
if (isRealInvoice) {
|
||||
if (accountingMethod === 'accrual') {
|
||||
const je = await createInvoicePaymentJournalEntry(
|
||||
supabase, userId, invoice as Invoice, paymentDate, undefined, invoice.customer?.name
|
||||
)
|
||||
journalEntryId = je?.id ?? null
|
||||
} else {
|
||||
const je = await createInvoiceCashEntry(
|
||||
supabase, userId, invoice as Invoice, paymentDate, entityType, invoice.customer?.name
|
||||
)
|
||||
journalEntryId = je?.id ?? null
|
||||
}
|
||||
}
|
||||
|
||||
const now = new Date().toISOString()
|
||||
const { error: updateError } = await supabase
|
||||
.from('invoices')
|
||||
.update({ status: 'paid', paid_at: now, paid_amount: invoice.total })
|
||||
.eq('id', invoiceId)
|
||||
.eq('user_id', userId)
|
||||
|
||||
if (updateError) return { error: 'Failed to update invoice status', status: 500 }
|
||||
|
||||
return { data: { status: 'paid', journal_entry_id: journalEntryId } }
|
||||
}
|
||||
|
||||
async function commitSendInvoice(
|
||||
supabase: Awaited<ReturnType<typeof createClient>>,
|
||||
userId: string,
|
||||
params: Record<string, unknown>,
|
||||
userEmail?: string
|
||||
): Promise<{ data?: Record<string, unknown>; error?: string; status?: number }> {
|
||||
const invoiceId = params.invoice_id as string
|
||||
|
||||
const emailService = getEmailService()
|
||||
if (!emailService.isConfigured()) {
|
||||
return { error: 'Email service not configured', status: 500 }
|
||||
}
|
||||
|
||||
const { data: invoice, error: invoiceError } = await supabase
|
||||
.from('invoices')
|
||||
.select('*, customer:customers(*), items:invoice_items(*)')
|
||||
.eq('id', invoiceId)
|
||||
.eq('user_id', userId)
|
||||
.single()
|
||||
|
||||
if (invoiceError || !invoice) return { error: 'Invoice not found', status: 404 }
|
||||
if (invoice.status === 'sent' || invoice.status === 'paid' || invoice.status === 'overdue') {
|
||||
return { error: 'Invoice has already been sent', status: 409 }
|
||||
}
|
||||
|
||||
const customer = invoice.customer as Customer
|
||||
if (!customer.email) return { error: 'Customer has no email address', status: 400 }
|
||||
|
||||
const { data: company, error: companyError } = await supabase
|
||||
.from('company_settings')
|
||||
.select('*')
|
||||
.eq('user_id', userId)
|
||||
.single()
|
||||
|
||||
if (companyError || !company) return { error: 'Company settings missing', status: 500 }
|
||||
|
||||
const items = (invoice.items as InvoiceItem[]).sort(
|
||||
(a: InvoiceItem, b: InvoiceItem) => a.sort_order - b.sort_order
|
||||
)
|
||||
|
||||
let originalInvoiceNumber: string | undefined
|
||||
if (invoice.credited_invoice_id) {
|
||||
const { data: orig } = await supabase
|
||||
.from('invoices')
|
||||
.select('invoice_number')
|
||||
.eq('id', invoice.credited_invoice_id)
|
||||
.single()
|
||||
if (orig) originalInvoiceNumber = orig.invoice_number
|
||||
}
|
||||
|
||||
const pdfBuffer = await renderToBuffer(
|
||||
InvoicePDF({
|
||||
invoice: invoice as Invoice,
|
||||
customer,
|
||||
items,
|
||||
company: company as CompanySettings,
|
||||
originalInvoiceNumber,
|
||||
})
|
||||
)
|
||||
|
||||
const isCreditNote = !!invoice.credited_invoice_id
|
||||
const docType = invoice.document_type || 'invoice'
|
||||
let filename: string
|
||||
if (isCreditNote) filename = `kreditfaktura-${invoice.invoice_number}.pdf`
|
||||
else if (docType === 'proforma') filename = `proformafaktura-${invoice.invoice_number}.pdf`
|
||||
else if (docType === 'delivery_note') filename = `foljesedel-${invoice.invoice_number}.pdf`
|
||||
else filename = `faktura-${invoice.invoice_number}.pdf`
|
||||
|
||||
const ccAddress = company.email || userEmail
|
||||
|
||||
const emailData = { invoice: invoice as Invoice, customer, company: company as CompanySettings }
|
||||
const result = await emailService.sendEmail({
|
||||
to: customer.email,
|
||||
cc: ccAddress,
|
||||
subject: generateInvoiceEmailSubject(emailData),
|
||||
html: generateInvoiceEmailHtml(emailData),
|
||||
text: generateInvoiceEmailText(emailData),
|
||||
replyTo: company.email || undefined,
|
||||
fromName: company.company_name,
|
||||
attachments: [{ filename, content: pdfBuffer, contentType: 'application/pdf' }],
|
||||
})
|
||||
|
||||
if (!result.success) return { error: `Failed to send email: ${result.error}`, status: 500 }
|
||||
|
||||
await supabase.from('invoices').update({ status: 'sent' }).eq('id', invoiceId).eq('user_id', userId)
|
||||
|
||||
const isRealInvoice = !invoice.document_type || invoice.document_type === 'invoice'
|
||||
let createdJournalEntryId: string | undefined
|
||||
if (isRealInvoice && (company.accounting_method === 'accrual' || !company.accounting_method)) {
|
||||
try {
|
||||
const je = await createInvoiceJournalEntry(
|
||||
supabase, userId, invoice as Invoice, (company as CompanySettings).entity_type
|
||||
)
|
||||
if (je) {
|
||||
createdJournalEntryId = je.id
|
||||
await supabase.from('invoices').update({ journal_entry_id: je.id }).eq('id', invoiceId)
|
||||
}
|
||||
} catch { /* non-blocking */ }
|
||||
}
|
||||
|
||||
if (isRealInvoice) {
|
||||
try {
|
||||
const pdfArrayBuffer = new Uint8Array(pdfBuffer).buffer as ArrayBuffer
|
||||
await uploadDocument(supabase, userId, {
|
||||
name: filename,
|
||||
buffer: pdfArrayBuffer,
|
||||
type: 'application/pdf',
|
||||
}, {
|
||||
upload_source: 'system',
|
||||
journal_entry_id: createdJournalEntryId,
|
||||
})
|
||||
} catch { /* non-blocking */ }
|
||||
}
|
||||
|
||||
await eventBus.emit({ type: 'invoice.sent', payload: { invoice: invoice as Invoice, userId } })
|
||||
|
||||
return { data: { message: `Invoice ${invoice.invoice_number} sent to ${customer.email}` } }
|
||||
}
|
||||
|
||||
async function commitMarkInvoiceSent(
|
||||
supabase: Awaited<ReturnType<typeof createClient>>,
|
||||
userId: string,
|
||||
params: Record<string, unknown>
|
||||
): Promise<{ data?: Record<string, unknown>; error?: string; status?: number }> {
|
||||
const invoiceId = params.invoice_id as string
|
||||
|
||||
const { data: invoice, error: invoiceError } = await supabase
|
||||
.from('invoices')
|
||||
.select('*, customer:customers(*), items:invoice_items(*)')
|
||||
.eq('id', invoiceId)
|
||||
.eq('user_id', userId)
|
||||
.single()
|
||||
|
||||
if (invoiceError || !invoice) return { error: 'Invoice not found', status: 404 }
|
||||
if (invoice.status !== 'draft') return { error: 'Only draft invoices can be marked as sent', status: 409 }
|
||||
|
||||
const { error: updateError } = await supabase
|
||||
.from('invoices')
|
||||
.update({ status: 'sent' })
|
||||
.eq('id', invoiceId)
|
||||
.eq('user_id', userId)
|
||||
|
||||
if (updateError) return { error: 'Failed to update invoice status', status: 500 }
|
||||
|
||||
const { data: settings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('accounting_method, entity_type')
|
||||
.eq('user_id', userId)
|
||||
.single()
|
||||
|
||||
const isRealInvoice = !invoice.document_type || invoice.document_type === 'invoice'
|
||||
let journalEntryId: string | null = null
|
||||
|
||||
if (isRealInvoice && (settings?.accounting_method === 'accrual' || !settings?.accounting_method)) {
|
||||
try {
|
||||
const je = await createInvoiceJournalEntry(
|
||||
supabase, userId, invoice as Invoice,
|
||||
(settings?.entity_type as EntityType) || 'enskild_firma',
|
||||
invoice.customer?.name
|
||||
)
|
||||
if (je) {
|
||||
journalEntryId = je.id
|
||||
await supabase.from('invoices').update({ journal_entry_id: je.id }).eq('id', invoiceId)
|
||||
}
|
||||
} catch { /* non-blocking */ }
|
||||
}
|
||||
|
||||
return { data: { status: 'sent', journal_entry_id: journalEntryId } }
|
||||
}
|
||||
|
||||
async function commitMatchTransactionInvoice(
|
||||
supabase: Awaited<ReturnType<typeof createClient>>,
|
||||
userId: string,
|
||||
params: Record<string, unknown>
|
||||
): Promise<{ data?: Record<string, unknown>; error?: string; status?: number }> {
|
||||
const transactionId = params.transaction_id as string
|
||||
const invoiceId = params.invoice_id as string
|
||||
|
||||
const { data: transaction, error: txError } = await supabase
|
||||
.from('transactions')
|
||||
.select('*')
|
||||
.eq('id', transactionId)
|
||||
.eq('user_id', userId)
|
||||
.single()
|
||||
|
||||
if (txError || !transaction) return { error: 'Transaction not found', status: 404 }
|
||||
if (transaction.amount <= 0) return { error: 'Only income transactions can be matched', status: 400 }
|
||||
if (transaction.invoice_id) return { error: 'Transaction already linked to an invoice', status: 409 }
|
||||
|
||||
const { data: invoice, error: invError } = await supabase
|
||||
.from('invoices')
|
||||
.select('*, customer:customers(*), items:invoice_items(*)')
|
||||
.eq('id', invoiceId)
|
||||
.eq('user_id', userId)
|
||||
.single()
|
||||
|
||||
if (invError || !invoice) return { error: 'Invoice not found', status: 404 }
|
||||
if (!['sent', 'overdue', 'partially_paid'].includes(invoice.status)) {
|
||||
return { error: 'Invoice is not in a matchable state', status: 409 }
|
||||
}
|
||||
|
||||
// Storno conflicting journal entry
|
||||
if (transaction.journal_entry_id) {
|
||||
await reverseEntry(supabase, userId, transaction.journal_entry_id)
|
||||
await supabase.from('transactions').update({ journal_entry_id: null }).eq('id', transactionId)
|
||||
}
|
||||
|
||||
const now = new Date().toISOString()
|
||||
const paidAmount = transaction.amount
|
||||
const newPaidAmount = Math.round(((invoice.paid_amount || 0) + paidAmount) * 100) / 100
|
||||
const currentRemaining = invoice.remaining_amount ?? (invoice.total - (invoice.paid_amount || 0))
|
||||
const newRemaining = Math.max(0, Math.round((currentRemaining - paidAmount) * 100) / 100)
|
||||
const isFullyPaid = newRemaining <= 0
|
||||
const newStatus = isFullyPaid ? 'paid' : 'partially_paid'
|
||||
|
||||
const { data: settings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('accounting_method, entity_type')
|
||||
.eq('user_id', userId)
|
||||
.single()
|
||||
|
||||
const accountingMethod = settings?.accounting_method || 'accrual'
|
||||
const entityType = (settings?.entity_type as EntityType) || 'enskild_firma'
|
||||
|
||||
let journalEntryId: string | null = null
|
||||
try {
|
||||
if (accountingMethod === 'cash' && isFullyPaid) {
|
||||
const je = await createInvoiceCashEntry(
|
||||
supabase, userId, invoice as Invoice, transaction.date, entityType, invoice.customer?.name
|
||||
)
|
||||
journalEntryId = je?.id ?? null
|
||||
} else {
|
||||
const je = await createInvoicePaymentJournalEntry(
|
||||
supabase, userId, invoice as Invoice, transaction.date, undefined, invoice.customer?.name, paidAmount
|
||||
)
|
||||
journalEntryId = je?.id ?? null
|
||||
}
|
||||
} catch (err) {
|
||||
log.error('Failed to create match journal entry:', err)
|
||||
}
|
||||
|
||||
const { data: updatedRows, error: updateInvError } = await supabase
|
||||
.from('invoices')
|
||||
.update({
|
||||
status: newStatus,
|
||||
paid_at: isFullyPaid ? now : null,
|
||||
paid_amount: newPaidAmount,
|
||||
remaining_amount: newRemaining,
|
||||
})
|
||||
.eq('id', invoiceId)
|
||||
.in('status', ['sent', 'overdue', 'partially_paid'])
|
||||
.select('id')
|
||||
|
||||
if (updateInvError) return { error: 'Failed to update invoice status', status: 500 }
|
||||
if (!updatedRows || updatedRows.length === 0) {
|
||||
return { error: 'Invoice has already been fully paid or is no longer matchable', status: 409 }
|
||||
}
|
||||
|
||||
const paymentNotes = (accountingMethod === 'cash' && !isFullyPaid)
|
||||
? 'Kontantmetoden: intäkt bokförs vid slutbetalning' : null
|
||||
|
||||
await supabase.from('invoice_payments').insert({
|
||||
user_id: userId,
|
||||
invoice_id: invoiceId,
|
||||
payment_date: transaction.date,
|
||||
amount: paidAmount,
|
||||
currency: invoice.currency,
|
||||
exchange_rate: invoice.exchange_rate,
|
||||
journal_entry_id: journalEntryId,
|
||||
transaction_id: transactionId,
|
||||
notes: paymentNotes,
|
||||
})
|
||||
|
||||
await supabase
|
||||
.from('transactions')
|
||||
.update({
|
||||
invoice_id: invoiceId,
|
||||
potential_invoice_id: null,
|
||||
journal_entry_id: journalEntryId,
|
||||
is_business: true,
|
||||
category: 'income_services',
|
||||
})
|
||||
.eq('id', transactionId)
|
||||
|
||||
try {
|
||||
await eventBus.emit({
|
||||
type: 'invoice.match_confirmed',
|
||||
payload: { invoice: invoice as Invoice, transaction: transaction as Transaction, userId },
|
||||
})
|
||||
} catch { /* non-critical */ }
|
||||
|
||||
return { data: { invoice_status: newStatus, paid_amount: newPaidAmount, journal_entry_id: journalEntryId } }
|
||||
}
|
||||
|
||||
// ── Route handler ─────────────────────────────────────────────
|
||||
|
||||
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 the pending operation
|
||||
const { data: op, error: fetchError } = await supabase
|
||||
.from('pending_operations')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (fetchError || !op) {
|
||||
return NextResponse.json({ error: 'Pending operation not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const pendingOp = op as PendingOperation
|
||||
|
||||
if (pendingOp.status !== 'pending') {
|
||||
return NextResponse.json(
|
||||
{ error: `Operation already ${pendingOp.status}` },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
// Execute based on operation type
|
||||
let result: { data?: Record<string, unknown>; error?: string; status?: number }
|
||||
|
||||
switch (pendingOp.operation_type) {
|
||||
case 'categorize_transaction':
|
||||
result = await commitCategorizeTransaction(supabase, user.id, pendingOp.params)
|
||||
break
|
||||
case 'create_customer':
|
||||
result = await commitCreateCustomer(supabase, user.id, pendingOp.params)
|
||||
break
|
||||
case 'create_invoice':
|
||||
result = await commitCreateInvoice(supabase, user.id, pendingOp.params)
|
||||
break
|
||||
case 'mark_invoice_paid':
|
||||
result = await commitMarkInvoicePaid(supabase, user.id, pendingOp.params)
|
||||
break
|
||||
case 'send_invoice':
|
||||
result = await commitSendInvoice(supabase, user.id, pendingOp.params, user.email)
|
||||
break
|
||||
case 'mark_invoice_sent':
|
||||
result = await commitMarkInvoiceSent(supabase, user.id, pendingOp.params)
|
||||
break
|
||||
case 'match_transaction_invoice':
|
||||
result = await commitMatchTransactionInvoice(supabase, user.id, pendingOp.params)
|
||||
break
|
||||
default:
|
||||
return NextResponse.json({ error: 'Unknown operation type' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (result.error) {
|
||||
// Auto-reject if the operation can never succeed (404, 409)
|
||||
if (result.status === 404 || result.status === 409) {
|
||||
await supabase
|
||||
.from('pending_operations')
|
||||
.update({
|
||||
status: 'rejected',
|
||||
resolved_at: new Date().toISOString(),
|
||||
result_data: { auto_rejected: true, reason: result.error },
|
||||
})
|
||||
.eq('id', id)
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: result.error }, { status: result.status || 500 })
|
||||
}
|
||||
|
||||
// Mark as committed
|
||||
await supabase
|
||||
.from('pending_operations')
|
||||
.update({
|
||||
status: 'committed',
|
||||
resolved_at: new Date().toISOString(),
|
||||
result_data: result.data || {},
|
||||
})
|
||||
.eq('id', id)
|
||||
|
||||
return NextResponse.json({ data: result.data })
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import {
|
||||
createMockRequest,
|
||||
createMockRouteParams,
|
||||
parseJsonResponse,
|
||||
createQueuedMockSupabase,
|
||||
} from '@/tests/helpers'
|
||||
|
||||
const { supabase: mockSupabase, enqueue, enqueueMany, reset } = createQueuedMockSupabase()
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: () => Promise.resolve(mockSupabase),
|
||||
}))
|
||||
|
||||
import { POST } from '../../reject/route'
|
||||
|
||||
describe('POST /api/pending-operations/:id/reject', () => {
|
||||
const mockUser = { id: 'user-1', email: 'test@test.se' }
|
||||
const routeParams = createMockRouteParams({ id: 'op-1' })
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
|
||||
})
|
||||
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } })
|
||||
|
||||
const request = createMockRequest('/api/pending-operations/op-1/reject', { method: 'POST' })
|
||||
const response = await POST(request, routeParams)
|
||||
const { status } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns 404 when not found', async () => {
|
||||
enqueue({ data: null, error: { message: 'not found' } })
|
||||
|
||||
const request = createMockRequest('/api/pending-operations/op-1/reject', { method: 'POST' })
|
||||
const response = await POST(request, routeParams)
|
||||
const { status } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(404)
|
||||
})
|
||||
|
||||
it('returns 409 when already committed', async () => {
|
||||
enqueue({ data: { id: 'op-1', status: 'committed' } })
|
||||
|
||||
const request = createMockRequest('/api/pending-operations/op-1/reject', { method: 'POST' })
|
||||
const response = await POST(request, routeParams)
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
|
||||
expect(status).toBe(409)
|
||||
expect(body.error).toContain('already committed')
|
||||
})
|
||||
|
||||
it('rejects successfully', async () => {
|
||||
enqueueMany([
|
||||
{ data: { id: 'op-1', status: 'pending' } }, // fetch op
|
||||
{ data: null, error: null }, // update status
|
||||
])
|
||||
|
||||
const request = createMockRequest('/api/pending-operations/op-1/reject', { method: 'POST' })
|
||||
const response = await POST(request, routeParams)
|
||||
const { status, body } = await parseJsonResponse<{ data: { id: string; status: string } }>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.status).toBe('rejected')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
/**
|
||||
* POST /api/pending-operations/:id/reject
|
||||
*
|
||||
* Reject a pending operation. Marks it as rejected without executing.
|
||||
*/
|
||||
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 })
|
||||
}
|
||||
|
||||
const { data: op, error: fetchError } = await supabase
|
||||
.from('pending_operations')
|
||||
.select('id, status')
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (fetchError || !op) {
|
||||
return NextResponse.json({ error: 'Pending operation not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (op.status !== 'pending') {
|
||||
return NextResponse.json(
|
||||
{ error: `Operation already ${op.status}` },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
const { error: updateError } = await supabase
|
||||
.from('pending_operations')
|
||||
.update({
|
||||
status: 'rejected',
|
||||
resolved_at: new Date().toISOString(),
|
||||
})
|
||||
.eq('id', id)
|
||||
|
||||
if (updateError) {
|
||||
return NextResponse.json({ error: updateError.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: { id, status: 'rejected' } })
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import {
|
||||
createMockRequest,
|
||||
parseJsonResponse,
|
||||
createQueuedMockSupabase,
|
||||
} from '@/tests/helpers'
|
||||
|
||||
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: () => Promise.resolve(mockSupabase),
|
||||
}))
|
||||
|
||||
import { GET } from '../route'
|
||||
|
||||
describe('GET /api/pending-operations', () => {
|
||||
const mockUser = { id: 'user-1', email: 'test@test.se' }
|
||||
|
||||
const sampleOps = [
|
||||
{
|
||||
id: 'op-1',
|
||||
user_id: 'user-1',
|
||||
operation_type: 'categorize_transaction',
|
||||
status: 'pending',
|
||||
title: 'Kategorisera: CLAS OHLSON -523 SEK',
|
||||
params: { transaction_id: 'tx-1', category: 'expense_office' },
|
||||
preview_data: { debit_account: '6100', credit_account: '1930', amount: 523 },
|
||||
result_data: null,
|
||||
created_at: '2026-03-25T10:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'op-2',
|
||||
user_id: 'user-1',
|
||||
operation_type: 'create_customer',
|
||||
status: 'pending',
|
||||
title: 'Ny kund: Acme AB',
|
||||
params: { name: 'Acme AB', customer_type: 'swedish_business' },
|
||||
preview_data: { name: 'Acme AB', customer_type: 'swedish_business' },
|
||||
result_data: null,
|
||||
created_at: '2026-03-25T10:01:00Z',
|
||||
},
|
||||
]
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
|
||||
})
|
||||
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } })
|
||||
|
||||
const request = createMockRequest('/api/pending-operations')
|
||||
const response = await GET(request)
|
||||
const { status, body } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(401)
|
||||
expect(body).toEqual({ error: 'Unauthorized' })
|
||||
})
|
||||
|
||||
it('returns pending operations', async () => {
|
||||
enqueue({ data: sampleOps, count: 2 })
|
||||
|
||||
const request = createMockRequest('/api/pending-operations')
|
||||
const response = await GET(request)
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
data: typeof sampleOps
|
||||
count: number
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data).toHaveLength(2)
|
||||
expect(body.count).toBe(2)
|
||||
})
|
||||
|
||||
it('filters by status parameter', async () => {
|
||||
enqueue({ data: [], count: 0 })
|
||||
|
||||
const request = createMockRequest('/api/pending-operations', {
|
||||
searchParams: { status: 'committed' },
|
||||
})
|
||||
const response = await GET(request)
|
||||
const { status } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
})
|
||||
|
||||
it('rejects invalid status parameter', async () => {
|
||||
const request = createMockRequest('/api/pending-operations', {
|
||||
searchParams: { status: 'invalid' },
|
||||
})
|
||||
const response = await GET(request)
|
||||
const { status } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,36 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { validateQuery } from '@/lib/api/validate'
|
||||
import { PendingOperationsQuerySchema } from '@/lib/api/schemas'
|
||||
|
||||
/**
|
||||
* GET /api/pending-operations
|
||||
*
|
||||
* List pending operations for the authenticated user.
|
||||
* Query params: status (default: pending), limit, 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 result = validateQuery(request, PendingOperationsQuerySchema)
|
||||
if (!result.success) return result.response
|
||||
const { status, limit, offset } = result.data
|
||||
|
||||
const { data, error, count } = await supabase
|
||||
.from('pending_operations')
|
||||
.select('*', { count: 'exact' })
|
||||
.eq('user_id', user.id)
|
||||
.eq('status', status)
|
||||
.order('created_at', { ascending: false })
|
||||
.range(offset, offset + limit - 1)
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: data ?? [], count })
|
||||
}
|
||||
Reference in New Issue
Block a user