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:
Jakob Wennberg
2026-03-26 10:07:36 +01:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 4d2152946a
commit f3ae3cd361
23 changed files with 2744 additions and 465 deletions
+210
View File
@@ -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)
})
})
+41
View File
@@ -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 }
)
}
}
+77
View File
@@ -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)
})
})
+36
View File
@@ -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 })
}