diff --git a/app/(dashboard)/layout.tsx b/app/(dashboard)/layout.tsx index 94e29d80..47c160f9 100644 --- a/app/(dashboard)/layout.tsx +++ b/app/(dashboard)/layout.tsx @@ -20,7 +20,7 @@ export default async function DashboardLayout({ redirect('/login') } - const [{ data: settings }, { count: uncategorizedCount }] = await Promise.all([ + const [{ data: settings }, { count: uncategorizedCount }, { count: pendingOpsCount }] = await Promise.all([ supabase .from('company_settings') .select('company_name, onboarding_complete, entity_type, is_sandbox') @@ -31,6 +31,11 @@ export default async function DashboardLayout({ .select('*', { count: 'exact', head: true }) .eq('user_id', user.id) .is('is_business', null), + supabase + .from('pending_operations') + .select('*', { count: 'exact', head: true }) + .eq('user_id', user.id) + .eq('status', 'pending'), ]) if (!settings?.onboarding_complete) { @@ -55,6 +60,7 @@ export default async function DashboardLayout({ companyName={settings.company_name || 'Min verksamhet'} entityType={entityType} uncategorizedTransactionCount={uncategorizedCount ?? 0} + pendingOperationsCount={pendingOpsCount ?? 0} isSandbox={isSandbox} extensionNavItems={getExtensionNavItems()} /> diff --git a/app/(dashboard)/pending/page.tsx b/app/(dashboard)/pending/page.tsx new file mode 100644 index 00000000..b434ef9f --- /dev/null +++ b/app/(dashboard)/pending/page.tsx @@ -0,0 +1,381 @@ +'use client' + +import { useState, useEffect, useCallback, Fragment } from 'react' +import { PageHeader } from '@/components/ui/page-header' +import { Card, CardContent } from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { ConfirmationDialog } from '@/components/ui/confirmation-dialog' +import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui/destructive-confirm-dialog' +import { useToast } from '@/components/ui/use-toast' +import { formatCurrency } from '@/lib/utils' +import { + ClipboardCheck, + Loader2, + ArrowLeftRight, + Users, + Receipt, + CheckCircle2, + XCircle, +} from 'lucide-react' +import type { PendingOperation, PendingOperationStatus } from '@/types' + +const operationLabels: Record = { + categorize_transaction: { label: 'Kategorisering', icon: ArrowLeftRight, variant: 'default' }, + create_customer: { label: 'Ny kund', icon: Users, variant: 'secondary' }, + create_invoice: { label: 'Ny faktura', icon: Receipt, variant: 'outline' }, + mark_invoice_paid: { label: 'Betald faktura', icon: Receipt, variant: 'default' }, + send_invoice: { label: 'Skicka faktura', icon: Receipt, variant: 'outline' }, + mark_invoice_sent: { label: 'Markera skickad', icon: Receipt, variant: 'outline' }, + match_transaction_invoice: { label: 'Fakturamatchning', icon: ArrowLeftRight, variant: 'secondary' }, +} + +function formatRelativeTime(dateStr: string): string { + const now = new Date() + const date = new Date(dateStr) + const diffMs = now.getTime() - date.getTime() + const diffMin = Math.floor(diffMs / 60000) + + if (diffMin < 1) return 'just nu' + if (diffMin < 60) return `${diffMin} min sedan` + const diffHours = Math.floor(diffMin / 60) + if (diffHours < 24) return `${diffHours} tim sedan` + const diffDays = Math.floor(diffHours / 24) + return `${diffDays} dagar sedan` +} + +function CategorizePreview({ data }: { data: Record }) { + const vatLines = (data.vat_lines as Array<{ account_number: string; debit_amount: number; credit_amount: number; description: string }>) || [] + + return ( +
+
+ Debetkonto + {String(data.debit_account ?? '')} + Kreditkonto + {String(data.credit_account ?? '')} + Belopp + + {formatCurrency(data.amount as number, (data.currency as string) || 'SEK')} + +
+ {vatLines.length > 0 && ( +
+

Momsrader

+ {vatLines.map((line, i) => ( +
+ {line.account_number} {line.description} + + {line.debit_amount > 0 ? `D ${formatCurrency(line.debit_amount)}` : `K ${formatCurrency(line.credit_amount)}`} + +
+ ))} +
+ )} +
+ ) +} + +function CustomerPreview({ data }: { data: Record }) { + return ( +
+ Namn + {String(data.name ?? '')} + Typ + {String(data.customer_type ?? '')} + {data.email ? ( + <> + E-post + {String(data.email)} + + ) : null} + {data.org_number ? ( + <> + Org.nr + {String(data.org_number)} + + ) : null} +
+ ) +} + +function InvoicePreview({ data }: { data: Record }) { + const items = (data.items as Array<{ description: string; quantity: number; unit: string; unit_price: number; line_total: number; vat_rate: number }>) || [] + + return ( +
+
+ Kund + {String(data.customer_name ?? '')} + Datum + {String(data.invoice_date ?? '')} + Förfallodatum + {String(data.due_date ?? '')} +
+ {items.length > 0 && ( +
+ {items.map((item, i) => ( +
+ {item.description} ({item.quantity} {item.unit}) + + {formatCurrency(item.line_total, (data.currency as string) || 'SEK')} + +
+ ))} +
+ )} +
+ Netto + {formatCurrency(data.subtotal as number, (data.currency as string) || 'SEK')} + Moms + {formatCurrency(data.vat_amount as number, (data.currency as string) || 'SEK')} + Totalt + {formatCurrency(data.total as number, (data.currency as string) || 'SEK')} +
+
+ ) +} + +function GenericPreview({ data }: { data: Record }) { + const entries = Object.entries(data).filter(([, v]) => v != null && v !== '') + return ( +
+ {entries.map(([key, value]) => ( + + {key.replace(/_/g, ' ')} + + {String(value)} + + + ))} +
+ ) +} + +function OperationPreview({ op }: { op: PendingOperation }) { + switch (op.operation_type) { + case 'categorize_transaction': + return + case 'create_customer': + return + case 'create_invoice': + return + default: + return + } +} + +export default function PendingOperationsPage() { + const [operations, setOperations] = useState([]) + const [isLoading, setIsLoading] = useState(true) + const [activeTab, setActiveTab] = useState('pending') + const [expandedId, setExpandedId] = useState(null) + const [selectedOp, setSelectedOp] = useState(null) + const [showCommitDialog, setShowCommitDialog] = useState(false) + const [isCommitting, setIsCommitting] = useState(false) + const { toast } = useToast() + const { dialogProps, confirm } = useDestructiveConfirm() + + const fetchOperations = useCallback(async () => { + setIsLoading(true) + try { + const res = await fetch(`/api/pending-operations?status=${activeTab}`) + const json = await res.json() + setOperations(json.data ?? []) + } catch { + toast({ title: 'Kunde inte ladda operationer', variant: 'destructive' }) + } + setIsLoading(false) + }, [activeTab, toast]) + + useEffect(() => { + fetchOperations() + }, [fetchOperations]) + + async function handleCommit() { + if (!selectedOp) return + setIsCommitting(true) + try { + const res = await fetch(`/api/pending-operations/${selectedOp.id}/commit`, { method: 'POST' }) + const json = await res.json() + if (!res.ok) throw new Error(json.error || 'Misslyckades') + toast({ title: 'Godkänd', description: selectedOp.title }) + setShowCommitDialog(false) + setSelectedOp(null) + fetchOperations() + } catch (err) { + toast({ + title: 'Misslyckades', + description: err instanceof Error ? err.message : 'Okänt fel', + variant: 'destructive', + }) + } + setIsCommitting(false) + } + + async function handleReject(op: PendingOperation) { + const ok = await confirm({ + title: 'Avvisa operation?', + description: `"${op.title}" kommer att avvisas.`, + confirmLabel: 'Avvisa', + variant: 'destructive', + }) + if (!ok) return + + try { + const res = await fetch(`/api/pending-operations/${op.id}/reject`, { method: 'POST' }) + if (!res.ok) throw new Error('Misslyckades') + toast({ title: 'Avvisad', description: op.title }) + fetchOperations() + } catch { + toast({ title: 'Kunde inte avvisa', variant: 'destructive' }) + } + } + + const warningForType: Record = { + categorize_transaction: '', + create_customer: '', + create_invoice: '', + } + + return ( +
+ + + setActiveTab(v as PendingOperationStatus)}> + + Väntande + Godkända + Avvisade + + + + {isLoading ? ( + + + + + + ) : operations.length === 0 ? ( + + +
+ +
+

+ {activeTab === 'pending' + ? 'Inga väntande operationer' + : activeTab === 'committed' + ? 'Inga godkända operationer' + : 'Inga avvisade operationer'} +

+

+ {activeTab === 'pending' + ? 'När din AI-agent skapar bokföring visas den här för granskning.' + : 'Historik för AI-agentens operationer visas här.'} +

+
+
+ ) : ( +
+ {operations.map((op) => { + const config = operationLabels[op.operation_type] || { label: op.operation_type, icon: ClipboardCheck, variant: 'default' as const } + const isExpanded = expandedId === op.id + + return ( + + +
setExpandedId(isExpanded ? null : op.id)} + > +
+
+ {config.label} + {op.status === 'committed' && ( + + + Godkänd + + )} + {op.status === 'rejected' && ( + + + Avvisad + + )} + + {formatRelativeTime(op.created_at)} + +
+

{op.title}

+
+ + {op.status === 'pending' && ( +
+ + +
+ )} +
+ + {/* Expandable preview */} +
+
+
+ +
+
+
+
+
+ ) + })} +
+ )} + + {/* Commit confirmation dialog */} + + {selectedOp && } + + + {/* Reject confirmation dialog */} + +
+ ) +} diff --git a/app/api/events/__tests__/route.test.ts b/app/api/events/__tests__/route.test.ts new file mode 100644 index 00000000..2df60e50 --- /dev/null +++ b/app/api/events/__tests__/route.test.ts @@ -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) + }) +}) diff --git a/app/api/events/cleanup/cron/route.ts b/app/api/events/cleanup/cron/route.ts new file mode 100644 index 00000000..da028330 --- /dev/null +++ b/app/api/events/cleanup/cron/route.ts @@ -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 } + ) + } +} diff --git a/app/api/events/route.ts b/app/api/events/route.ts new file mode 100644 index 00000000..9d1410d5 --- /dev/null +++ b/app/api/events/route.ts @@ -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, + }) +} diff --git a/app/api/pending-operations/[id]/commit/__tests__/route.test.ts b/app/api/pending-operations/[id]/commit/__tests__/route.test.ts new file mode 100644 index 00000000..6f7da7e0 --- /dev/null +++ b/app/api/pending-operations/[id]/commit/__tests__/route.test.ts @@ -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') + }) + }) +}) diff --git a/app/api/pending-operations/[id]/commit/route.ts b/app/api/pending-operations/[id]/commit/route.ts new file mode 100644 index 00000000..38f4ab56 --- /dev/null +++ b/app/api/pending-operations/[id]/commit/route.ts @@ -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>, + userId: string, + date: string, + fiscalYearStartMonth: number = 1 +): Promise { + 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>, + userId: string, + params: Record +): Promise<{ data?: Record; 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>, + userId: string, + params: Record +): Promise<{ data?: Record; 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>, + userId: string, + params: Record +): Promise<{ data?: Record; 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>, + userId: string, + params: Record +): Promise<{ data?: Record; 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>, + userId: string, + params: Record, + userEmail?: string +): Promise<{ data?: Record; 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>, + userId: string, + params: Record +): Promise<{ data?: Record; 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>, + userId: string, + params: Record +): Promise<{ data?: Record; 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; 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 }) +} diff --git a/app/api/pending-operations/[id]/reject/__tests__/route.test.ts b/app/api/pending-operations/[id]/reject/__tests__/route.test.ts new file mode 100644 index 00000000..3fa9c6fd --- /dev/null +++ b/app/api/pending-operations/[id]/reject/__tests__/route.test.ts @@ -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') + }) +}) diff --git a/app/api/pending-operations/[id]/reject/route.ts b/app/api/pending-operations/[id]/reject/route.ts new file mode 100644 index 00000000..591084bb --- /dev/null +++ b/app/api/pending-operations/[id]/reject/route.ts @@ -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' } }) +} diff --git a/app/api/pending-operations/__tests__/route.test.ts b/app/api/pending-operations/__tests__/route.test.ts new file mode 100644 index 00000000..65a99d1c --- /dev/null +++ b/app/api/pending-operations/__tests__/route.test.ts @@ -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) + }) +}) diff --git a/app/api/pending-operations/route.ts b/app/api/pending-operations/route.ts new file mode 100644 index 00000000..ead138ed --- /dev/null +++ b/app/api/pending-operations/route.ts @@ -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 }) +} diff --git a/components/dashboard/DashboardNav.tsx b/components/dashboard/DashboardNav.tsx index 8c59ae22..9a8e54d6 100644 --- a/components/dashboard/DashboardNav.tsx +++ b/components/dashboard/DashboardNav.tsx @@ -25,6 +25,7 @@ import { FileInput, Wallet, TrendingUp, + ClipboardCheck, } from 'lucide-react' import { resolveIcon } from '@/lib/extensions/icon-resolver' import type { EntityType } from '@/types' @@ -39,6 +40,7 @@ interface DashboardNavProps { companyName: string entityType: EntityType uncategorizedTransactionCount?: number + pendingOperationsCount?: number isSandbox?: boolean extensionNavItems?: ExtensionNavItem[] } @@ -66,6 +68,7 @@ const navItems: NavItem[] = [ { href: '/suppliers', label: 'Leverantörer', icon: Building2, group: 'inköp', hidden: true }, { href: '/supplier-invoices', label: 'Leverantörsfakturor', icon: FileInput, group: 'inköp', hidden: true }, // General accounting + { href: '/pending', label: 'Granskning', icon: ClipboardCheck, group: 'redovisning' }, { href: '/transactions', label: 'Transaktioner', icon: ArrowLeftRight, group: 'redovisning' }, { href: '/bookkeeping', label: 'Bokföring', icon: BookOpen, group: 'redovisning' }, { href: '/reports', label: 'Rapporter', icon: BarChart3, group: 'redovisning' }, @@ -82,7 +85,7 @@ const groupLabels: Record = { övrigt: 'Övrigt', } -export default function DashboardNav({ companyName, entityType, uncategorizedTransactionCount = 0, isSandbox = false, extensionNavItems = [] }: DashboardNavProps) { +export default function DashboardNav({ companyName, entityType, uncategorizedTransactionCount = 0, pendingOperationsCount = 0, isSandbox = false, extensionNavItems = [] }: DashboardNavProps) { const pathname = usePathname() const router = useRouter() const supabase = createClient() @@ -123,10 +126,14 @@ export default function DashboardNav({ companyName, entityType, uncategorizedTra }, 200) } - // Filter nav items by entity type and hidden flag - const filteredItems = navItems.filter(item => - !item.hidden && (!item.modes || item.modes.includes(entityType)) - ) + // Filter nav items by entity type, hidden flag, and conditional visibility + const filteredItems = navItems.filter(item => { + if (item.hidden) return false + if (item.modes && !item.modes.includes(entityType)) return false + // Only show Granskning when there are pending operations + if (item.href === '/pending' && pendingOperationsCount === 0) return false + return true + }) const mainItems = filteredItems.filter(i => i.group === 'main') const övrigtItems = filteredItems.filter(i => i.group === 'övrigt') @@ -202,7 +209,9 @@ export default function DashboardNav({ companyName, entityType, uncategorizedTra const active = isActive(item.href) const badge = item.href === '/transactions' && uncategorizedTransactionCount > 0 ? uncategorizedTransactionCount - : null + : item.href === '/pending' && pendingOperationsCount > 0 + ? pendingOperationsCount + : null return ( 0 ? uncategorizedTransactionCount - : null + : item.href === '/pending' && pendingOperationsCount > 0 + ? pendingOperationsCount + : null return ( { // ── gnubok_categorize_transaction still works after refactor ── - describe('gnubok_categorize_transaction (refactored)', () => { - it('returns result without transaction field', async () => { + describe('gnubok_categorize_transaction (staging)', () => { + it('always stages the operation directly', async () => { const tx = makeTransaction({ id: 'tx-1', amount: -500 }) enqueueMany([ - { data: tx, error: null }, // fetch transaction + { data: tx, error: null }, // fetch transaction (preview) { data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null }, - { data: null, error: null }, // fiscal_periods upsert - { data: null, error: null }, // transaction update + { data: tx, error: null }, // fetch transaction for title + { data: { id: 'op-1' }, error: null }, // insert into pending_operations ]) const res = await handleMcpRequest( @@ -246,10 +246,11 @@ describe('MCP Receipt Matcher', () => { const result = await parseResult(res) const parsed = JSON.parse(result.content[0].text) - expect(parsed.success).toBe(true) - expect(parsed.journal_entry_created).toBe(true) - expect(parsed.category).toBe('expense_office') - expect(parsed.transaction).toBeUndefined() + expect(parsed.staged).toBe(true) + expect(parsed.operation_id).toBe('op-1') + expect(parsed.message).toContain('staged') + expect(parsed.preview).toBeDefined() + expect(parsed.preview.debit_account).toBeDefined() }) }) @@ -273,8 +274,8 @@ describe('MCP Receipt Matcher', () => { enqueueMany([ { data: tx, error: null }, { data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null }, - { data: null, error: null }, - { data: null, error: null }, + { data: tx, error: null }, // fetch transaction for title + { data: { id: 'op-1' }, error: null }, // insert into pending_operations ]) const res = await handleMcpRequest( diff --git a/extensions/general/mcp-server/server.ts b/extensions/general/mcp-server/server.ts index 9636330f..44356a54 100644 --- a/extensions/general/mcp-server/server.ts +++ b/extensions/general/mcp-server/server.ts @@ -95,6 +95,38 @@ const VALID_VAT_TREATMENTS = [ 'standard_25', 'reduced_12', 'reduced_6', 'reverse_charge', 'export', 'exempt', ] as const +// ── Pending operations staging ─────────────────────────────── + +async function stagePendingOperation( + supabase: SupabaseClient, + userId: string, + operationType: string, + title: string, + params: Record, + previewData: Record +): Promise<{ staged: true; operation_id: string; message: string; preview: Record }> { + const { data, error } = await supabase + .from('pending_operations') + .insert({ + user_id: userId, + operation_type: operationType, + title, + params, + preview_data: previewData, + }) + .select('id') + .single() + + if (error) throw new Error(`Failed to stage operation: ${error.message}`) + + return { + staged: true, + operation_id: data.id, + message: 'Operation staged for review. Open the gnubok web app to approve or reject it.', + preview: previewData, + } +} + // ── Shared categorization logic ────────────────────────────── async function categorizeTransactionCore( @@ -102,18 +134,22 @@ async function categorizeTransactionCore( category: TransactionCategory, vatTreatment: VatTreatment | undefined, userId: string, - supabase: SupabaseClient + supabase: SupabaseClient, + confirm: boolean = false ): Promise<{ - success: boolean - journal_entry_created: boolean - journal_entry_id: string | null - journal_entry_error: string | null + preview?: boolean + success?: boolean + journal_entry_created?: boolean + journal_entry_id?: string | null + journal_entry_error?: string | null category: string debit_account: string credit_account: string amount: number currency: string - transaction: Transaction + vat_lines?: Array<{ account_number: string; debit_amount: number; credit_amount: number; description: string }> + message?: string + transaction?: Transaction }> { // Validate category if (!VALID_CATEGORIES.includes(category as typeof VALID_CATEGORIES[number])) { @@ -182,6 +218,25 @@ async function categorizeTransactionCore( ) } + // Preview mode: return what would happen without executing + if (!confirm) { + return { + preview: true, + category, + debit_account: mappingResult.debit_account, + credit_account: mappingResult.credit_account, + amount: Math.abs(transaction.amount), + currency: transaction.currency, + vat_lines: mappingResult.vat_lines.map(v => ({ + account_number: v.account_number, + debit_amount: v.debit_amount, + credit_amount: v.credit_amount, + description: v.description, + })), + message: 'Preview only — no changes made. Call again with confirm: true to create the journal entry.', + } + } + // Ensure fiscal period exists const fiscalYearStartMonth = settings?.fiscal_year_start_month ?? 1 const txDate = new Date(transaction.date) @@ -357,16 +412,16 @@ const tools: McpTool[] = [ { name: 'gnubok_categorize_transaction', description: - 'Categorize a bank transaction and create the corresponding double-entry journal entry. ' + - 'This books the transaction in the accounting ledger using Swedish BAS accounts.\n\n' + + 'Categorize a bank transaction and stage the journal entry for user approval.\n\n' + + 'This tool stages the operation — the user reviews and approves it in the gnubok web app. ' + + 'The journal entry is NOT created until the user approves.\n\n' + 'Args:\n' + ' - transaction_id (string, required): UUID of the transaction from gnubok_list_uncategorized_transactions\n' + ' - category (string, required): One of: ' + VALID_CATEGORIES.join(', ') + '\n' + ' - vat_treatment (string, optional): One of: ' + VALID_VAT_TREATMENTS.join(', ') + '. ' + 'Defaults to standard_25 for business expenses.\n\n' + 'Returns JSON:\n' + - ' { success: boolean, journal_entry_created: boolean, journal_entry_id?: string,\n' + - ' category: string, debit_account: string, credit_account: string }\n\n' + + ' { staged: true, operation_id, message, preview: { debit_account, credit_account, amount, vat_lines } }\n\n' + 'Examples:\n' + ' - "Book that as office supplies, 25% VAT" → category="expense_office"\n' + ' - "Mark as private" → category="private" (no journal entry created for private)\n' + @@ -402,16 +457,51 @@ const tools: McpTool[] = [ openWorldHint: false, }, async execute(args, userId, supabase) { + // Compute the preview (accounts, amounts, VAT lines) const result = await categorizeTransactionCore( args.transaction_id as string, args.category as TransactionCategory, args.vat_treatment as VatTreatment | undefined, userId, - supabase + supabase, + false // preview mode — execution happens via web UI commit + ) + + // If already has a journal entry, pass through as-is + if (result.success && result.journal_entry_created === false) { + const { transaction: _tx, ...publicResult } = result + return publicResult + } + + // Fetch transaction description for the title + const { data: tx } = await supabase + .from('transactions') + .select('description, merchant_name, amount, currency') + .eq('id', args.transaction_id as string) + .eq('user_id', userId) + .single() + + const txDesc = tx + ? `${tx.merchant_name || tx.description || 'Transaktion'} ${tx.amount} ${tx.currency}` + : String(args.transaction_id) + + // Stage for user approval + return stagePendingOperation(supabase, userId, 'categorize_transaction', + `Kategorisera: ${txDesc}`, + { + transaction_id: args.transaction_id, + category: args.category, + vat_treatment: args.vat_treatment || null, + }, + { + debit_account: result.debit_account, + credit_account: result.credit_account, + amount: result.amount, + currency: result.currency, + vat_lines: result.vat_lines || [], + category: result.category, + } ) - // Strip internal transaction field from public response - const { transaction: _tx, ...publicResult } = result - return publicResult }, }, @@ -501,7 +591,9 @@ const tools: McpTool[] = [ { name: 'gnubok_create_customer', description: - 'Create a new customer. Required for invoice creation.\n\n' + + 'Stage a new customer for user approval. Required before creating invoices.\n\n' + + 'The customer is NOT created immediately — it is staged for the user to review ' + + 'and approve in the gnubok web app.\n\n' + 'Args:\n' + ' - name (string, required): Customer/company name\n' + ' - customer_type (string, required): individual, swedish_business, eu_business, non_eu_business\n' + @@ -513,7 +605,7 @@ const tools: McpTool[] = [ ' - postal_code (string, optional)\n' + ' - city (string, optional)\n' + ' - country (string, optional): Defaults to Sweden\n\n' + - 'Returns JSON: the created customer object with id.\n\n' + + 'Returns JSON: { staged: true, operation_id, message, preview }\n\n' + 'Examples:\n' + ' - "Add Acme AB" → name="Acme AB", customer_type="swedish_business"\n' + ' - "Add a German client" → customer_type="eu_business", country="Germany"', @@ -552,27 +644,24 @@ const tools: McpTool[] = [ throw new Error('Invalid customer_type. Must be: individual, swedish_business, eu_business, non_eu_business') } - const { data, error } = await supabase - .from('customers') - .insert({ - user_id: userId, - name: name.trim(), - customer_type: customerType, - email: (args.email as string) || null, - org_number: (args.org_number as string) || null, - vat_number: (args.vat_number as string) || null, - default_payment_terms: Number(args.payment_terms) || 30, - address_line1: (args.address as string) || null, - postal_code: (args.postal_code as string) || null, - city: (args.city as string) || null, - country: (args.country as string) || 'Sweden', - }) - .select() - .single() + const params = { + name: name.trim(), + customer_type: customerType, + email: (args.email as string) || null, + org_number: (args.org_number as string) || null, + vat_number: (args.vat_number as string) || null, + payment_terms: Number(args.payment_terms) || 30, + address: (args.address as string) || null, + postal_code: (args.postal_code as string) || null, + city: (args.city as string) || null, + country: (args.country as string) || 'Sweden', + } - if (error) throw new Error(`Failed to create customer: ${error.message}`) - - return { customer: data } + return stagePendingOperation(supabase, userId, 'create_customer', + `Ny kund: ${params.name}`, + params, + params // params ARE the preview for customers + ) }, }, @@ -650,7 +739,9 @@ const tools: McpTool[] = [ { name: 'gnubok_create_invoice', description: - 'Create a new invoice for a customer. Automatically calculates VAT based on customer type.\n\n' + + 'Stage a new invoice for user approval. Validates inputs and calculates VAT preview.\n\n' + + 'The invoice is NOT created immediately — it is staged for the user to review ' + + 'and approve in the gnubok web app. The invoice number is assigned at approval time.\n\n' + 'Args:\n' + ' - customer_id (string, required): UUID from gnubok_list_customers\n' + ' - items (array, required): Line items, each with:\n' + @@ -665,7 +756,7 @@ const tools: McpTool[] = [ ' - our_reference (string, optional)\n' + ' - your_reference (string, optional)\n' + ' - notes (string, optional): Notes printed on invoice\n\n' + - 'Returns JSON: the created invoice with id, invoice_number, total, vat_amount.\n\n' + + 'Returns JSON: { staged: true, operation_id, message, preview }\n\n' + 'Examples:\n' + ' - "Invoice Acme for 15000 kr consulting" → items=[{description:"Konsulttjänster",quantity:1,unit:"st",unit_price:15000}]\n' + ' - "Invoice 10 hours at 1500/h" → items=[{description:"Konsulttjänster",quantity:10,unit:"tim",unit_price:1500}]', @@ -760,27 +851,6 @@ const tools: McpTool[] = [ } const total = subtotal + vatAmount - // Mixed-rate detection - const uniqueRates = new Set(items.map((item) => item.vat_rate ?? vatRules.rate)) - - // Currency exchange (Riksbanken) - 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) - } - } - // Due date from payment terms if not provided let dueDate = args.due_date as string | undefined if (!dueDate) { @@ -789,94 +859,27 @@ const tools: McpTool[] = [ dueDate = d.toISOString().split('T')[0] } - // Generate invoice number via DB RPC (sequential, same as web UI) - const { data: baseNumber } = await supabase.rpc('generate_invoice_number', { - p_user_id: userId, - }) - const invoiceNumber = baseNumber as string - - // Create invoice - const { data: invoice, error: insertError } = await supabase - .from('invoices') - .insert({ - user_id: userId, + // Stage for user approval instead of creating directly + return stagePendingOperation(supabase, userId, 'create_invoice', + `Ny faktura: ${customer.name} ${Math.round(total * 100) / 100} ${currency}`, + { customer_id: customerId, - invoice_number: invoiceNumber, + items, invoice_date: invoiceDate, due_date: dueDate, - status: 'draft', 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: uniqueRates.size > 1 ? null : (uniqueRates.values().next().value ?? vatRules.rate), - moms_ruta: vatRules.momsRuta, - reverse_charge_text: vatRules.reverseChargeText || null, - document_type: 'invoice', our_reference: (args.our_reference as string) || null, your_reference: (args.your_reference as string) || null, notes: (args.notes as string) || null, - }) - .select() - .single() - - if (insertError || !invoice) { - throw new Error(`Failed to create invoice: ${insertError?.message || 'Unknown error'}`) - } - - // Insert items - const invoiceItems = items.map((item, idx) => { - 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: idx, - 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) { - await supabase.from('invoices').delete().eq('id', invoice.id) - throw new Error(`Failed to create invoice items: ${itemsError.message}`) - } - - // Emit event (triggers journal entry creation via event handler) - 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 { - invoice: { - id: invoice.id, - invoice_number: invoiceNumber, - status: 'draft', + }, + { customer_name: customer.name, + customer_type: customer.customer_type, + items: items.map(item => ({ + ...item, + line_total: item.quantity * item.unit_price, + vat_rate: item.vat_rate ?? vatRules.rate, + })), subtotal: Math.round(subtotal * 100) / 100, vat_amount: Math.round(vatAmount * 100) / 100, total: Math.round(total * 100) / 100, @@ -884,11 +887,8 @@ const tools: McpTool[] = [ vat_treatment: vatRules.treatment, invoice_date: invoiceDate, due_date: dueDate, - item_count: invoiceItems.length, - ...(exchangeRate ? { exchange_rate: exchangeRate, total_sek: totalSek } : {}), - }, - note: 'Invoice created as draft. Use the web UI to send it.', - } + } + ) }, }, @@ -1353,7 +1353,7 @@ const tools: McpTool[] = [ const { data: invoice, error: invoiceError } = await supabase .from('invoices') - .select('*, customer:customers(*), items:invoice_items(*)') + .select('*, customer:customers(*)') .eq('id', invoiceId) .eq('user_id', userId) .single() @@ -1363,49 +1363,19 @@ const tools: McpTool[] = [ throw new Error('Invoice can only be marked as paid when status is "sent" or "overdue"') } - const now = new Date().toISOString() - const paymentDate = (args.payment_date as string) || now.split('T')[0] + const paymentDate = (args.payment_date as string) || new Date().toISOString().split('T')[0] - 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 + return stagePendingOperation(supabase, userId, 'mark_invoice_paid', + `Betald: ${invoice.invoice_number} ${invoice.customer?.name || ''} ${invoice.total} ${invoice.currency}`, + { invoice_id: invoiceId, payment_date: paymentDate }, + { + invoice_number: invoice.invoice_number, + customer_name: invoice.customer?.name, + total: invoice.total, + currency: invoice.currency, + payment_date: paymentDate, } - } - - 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) throw new Error('Failed to update invoice status') - - return { - success: true, - status: 'paid', - paid_at: now, - paid_amount: invoice.total, - journal_entry_id: journalEntryId, - } + ) }, }, @@ -1450,7 +1420,7 @@ const tools: McpTool[] = [ const { data: invoice, error: invoiceError } = await supabase .from('invoices') - .select('*, customer:customers(*), items:invoice_items(*)') + .select('*, customer:customers(*)') .eq('id', invoiceId) .eq('user_id', userId) .single() @@ -1460,112 +1430,17 @@ const tools: McpTool[] = [ const customer = invoice.customer as Customer if (!customer.email) throw new Error('Customer has no email address. Update customer details first.') - const { data: company, error: companyError } = await supabase - .from('company_settings') - .select('*') - .eq('user_id', userId) - .single() - - if (companyError || !company) throw new Error('Company settings missing') - - const items = (invoice.items as InvoiceItem[]).sort( - (a: InvoiceItem, b: InvoiceItem) => a.sort_order - b.sort_order - ) - - // If credit note, fetch original invoice number - 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 - } - - // Generate PDF - const pdfBuffer = await renderToBuffer( - InvoicePDF({ - invoice: invoice as Invoice, - customer, - items, - company: company as CompanySettings, - originalInvoiceNumber, - }) - ) - - // Determine filename - 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` - - // Get user email for CC - const { data: { user: authUser } } = await supabase.auth.admin.getUserById(userId) - const ccAddress = company.email || authUser?.email - - // Send email - 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) throw new Error(`Failed to send email: ${result.error}`) - - // Update status to sent - await supabase.from('invoices').update({ status: 'sent' }).eq('id', invoiceId).eq('user_id', userId) - - // Create journal entry (non-blocking) - 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 + return stagePendingOperation(supabase, userId, 'send_invoice', + `Skicka: ${invoice.invoice_number} till ${customer.email}`, + { invoice_id: invoiceId }, + { + invoice_number: invoice.invoice_number, + customer_name: customer.name, + customer_email: customer.email, + total: invoice.total, + currency: invoice.currency, } - } - - // Store PDF as document (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 { - success: true, - message: `Invoice ${invoice.invoice_number} sent to ${customer.email}`, - messageId: result.messageId, - } + ) }, }, @@ -1601,7 +1476,7 @@ const tools: McpTool[] = [ const { data: invoice, error: invoiceError } = await supabase .from('invoices') - .select('*, customer:customers(*), items:invoice_items(*)') + .select('*, customer:customers(*)') .eq('id', invoiceId) .eq('user_id', userId) .single() @@ -1609,40 +1484,16 @@ const tools: McpTool[] = [ if (invoiceError || !invoice) throw new Error('Invoice not found') if (invoice.status !== 'draft') throw new Error('Only draft invoices can be marked as sent') - const { error: updateError } = await supabase - .from('invoices') - .update({ status: 'sent' }) - .eq('id', invoiceId) - .eq('user_id', userId) - - if (updateError) throw new Error('Failed to update invoice status') - - 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 stagePendingOperation(supabase, userId, 'mark_invoice_sent', + `Markera skickad: ${invoice.invoice_number} ${invoice.customer?.name || ''}`, + { invoice_id: invoiceId }, + { + invoice_number: invoice.invoice_number, + customer_name: invoice.customer?.name, + total: invoice.total, + currency: invoice.currency, } - } - - return { success: true, status: 'sent', journal_entry_id: journalEntryId } + ) }, }, @@ -2126,10 +1977,10 @@ const tools: McpTool[] = [ const invoiceId = args.invoice_id as string if (!transactionId || !invoiceId) throw new Error('transaction_id and invoice_id are required') - // Fetch transaction + // Validate both exist and are matchable const { data: transaction, error: txError } = await supabase .from('transactions') - .select('*') + .select('id, description, merchant_name, amount, currency, invoice_id') .eq('id', transactionId) .eq('user_id', userId) .single() @@ -2138,10 +1989,9 @@ const tools: McpTool[] = [ if (transaction.amount <= 0) throw new Error('Only income transactions (amount > 0) can be matched to invoices') if (transaction.invoice_id) throw new Error('Transaction is already linked to an invoice') - // Fetch invoice const { data: invoice, error: invError } = await supabase .from('invoices') - .select('*, customer:customers(*), items:invoice_items(*)') + .select('*, customer:customers(*)') .eq('id', invoiceId) .eq('user_id', userId) .single() @@ -2151,124 +2001,21 @@ const tools: McpTool[] = [ throw new Error('Invoice is not in a matchable state (must be sent, overdue, or partially_paid)') } - // Storno conflicting journal entry if exists - 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 txDesc = transaction.merchant_name || transaction.description || 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' - - // Fetch accounting method - 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' - - // Create journal entry (method-aware) - let journalEntryId: string | null = null - let journalEntryError: 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 + return stagePendingOperation(supabase, userId, 'match_transaction_invoice', + `Matcha: ${txDesc} → ${invoice.invoice_number}`, + { transaction_id: transactionId, invoice_id: invoiceId }, + { + transaction_description: txDesc, + transaction_amount: transaction.amount, + transaction_currency: transaction.currency, + invoice_number: invoice.invoice_number, + invoice_total: invoice.total, + invoice_currency: invoice.currency, + customer_name: (invoice.customer as Record)?.name as string, } - } catch (err) { - journalEntryError = err instanceof Error ? err.message : 'Unknown error' - } - - // Optimistic lock update on invoice - 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) throw new Error('Failed to update invoice status') - if (!updatedRows || updatedRows.length === 0) { - throw new Error('Invoice has already been fully paid or is no longer matchable') - } - - // Record payment - const paymentNotes = (accountingMethod === 'cash' && !isFullyPaid) - ? 'Kontantmetoden: intäkt bokförs vid slutbetalning' - : null - - const { error: paymentError } = 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, - }) - - if (paymentError) { - if (paymentError.code === '23505') throw new Error('This transaction is already matched to this invoice') - throw new Error('Failed to record invoice payment') - } - - // Update transaction - const { error: updateTxError } = 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) - - if (updateTxError) throw new Error('Failed to link transaction to invoice') - - try { - eventBus.emit({ - type: 'invoice.match_confirmed', - payload: { invoice: invoice as Invoice, transaction: transaction as Transaction, userId }, - }) - } catch { - // Non-critical - } - - return { - success: true, - invoice_status: newStatus, - paid_at: isFullyPaid ? now : null, - paid_amount: newPaidAmount, - remaining_amount: newRemaining, - journal_entry_id: journalEntryId, - journal_entry_error: journalEntryError, - } + ) }, }, diff --git a/lib/api/schemas.ts b/lib/api/schemas.ts index f45b5e09..511426b6 100644 --- a/lib/api/schemas.ts +++ b/lib/api/schemas.ts @@ -536,3 +536,25 @@ export const PaginationQuerySchema = z.object({ limit: z.coerce.number().int().min(1).max(100).default(50), offset: z.coerce.number().int().nonnegative().default(0), }) + +// ============================================================ +// Event log schemas +// ============================================================ + +export const EventsQuerySchema = z.object({ + after: z.coerce.number().int().nonnegative().optional(), + types: z.string() + .transform(s => s.split(',').map(t => t.trim()).filter(Boolean)) + .optional(), + limit: z.coerce.number().int().min(1).max(100).default(50), +}) + +// ============================================================ +// Pending operations schemas +// ============================================================ + +export const PendingOperationsQuerySchema = z.object({ + status: z.enum(['pending', 'committed', 'rejected']).default('pending'), + limit: z.coerce.number().int().min(1).max(100).default(50), + offset: z.coerce.number().int().nonnegative().default(0), +}) diff --git a/lib/events/handlers/__tests__/event-log-handler.test.ts b/lib/events/handlers/__tests__/event-log-handler.test.ts new file mode 100644 index 00000000..d5c43c02 --- /dev/null +++ b/lib/events/handlers/__tests__/event-log-handler.test.ts @@ -0,0 +1,154 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { eventBus } from '@/lib/events/bus' +import { makeTransaction, makeInvoice, makeCustomer, makeFiscalPeriod } from '@/tests/helpers' + +// Mock the service client +const mockInsert = vi.fn().mockResolvedValue({ error: null }) +vi.mock('@/lib/auth/api-keys', () => ({ + createServiceClientNoCookies: () => ({ + from: () => ({ + insert: mockInsert, + }), + }), +})) + +// Import after mocks +import { registerEventLogHandler } from '../event-log-handler' + +describe('event-log-handler', () => { + let unsubscribers: (() => void)[] + + beforeEach(() => { + vi.clearAllMocks() + eventBus.clear() + mockInsert.mockResolvedValue({ error: null }) + unsubscribers = registerEventLogHandler() + }) + + afterEach(() => { + unsubscribers.forEach(unsub => unsub()) + }) + + it('persists invoice.created event with correct entity_id', async () => { + const invoice = makeInvoice({ id: 'inv-123' }) + + await eventBus.emit({ + type: 'invoice.created', + payload: { invoice, userId: 'user-1' }, + }) + + expect(mockInsert).toHaveBeenCalledTimes(1) + expect(mockInsert).toHaveBeenCalledWith( + expect.objectContaining({ + user_id: 'user-1', + event_type: 'invoice.created', + entity_id: 'inv-123', + }) + ) + + // Data should NOT contain userId (it's in its own column) + const insertedData = mockInsert.mock.calls[0][0].data + expect(insertedData).not.toHaveProperty('userId') + expect(insertedData).toHaveProperty('invoice') + }) + + it('persists customer.created event', async () => { + const customer = makeCustomer({ id: 'cust-456' }) + + await eventBus.emit({ + type: 'customer.created', + payload: { customer, userId: 'user-1' }, + }) + + expect(mockInsert).toHaveBeenCalledTimes(1) + expect(mockInsert).toHaveBeenCalledWith( + expect.objectContaining({ + user_id: 'user-1', + event_type: 'customer.created', + entity_id: 'cust-456', + }) + ) + }) + + it('batch-inserts transaction.synced as individual rows', async () => { + const tx1 = makeTransaction({ id: 'tx-1' }) + const tx2 = makeTransaction({ id: 'tx-2' }) + const tx3 = makeTransaction({ id: 'tx-3' }) + + await eventBus.emit({ + type: 'transaction.synced', + payload: { transactions: [tx1, tx2, tx3], userId: 'user-1' }, + }) + + expect(mockInsert).toHaveBeenCalledTimes(1) + const rows = mockInsert.mock.calls[0][0] + expect(rows).toHaveLength(3) + expect(rows[0]).toMatchObject({ event_type: 'transaction.synced', entity_id: 'tx-1' }) + expect(rows[1]).toMatchObject({ event_type: 'transaction.synced', entity_id: 'tx-2' }) + expect(rows[2]).toMatchObject({ event_type: 'transaction.synced', entity_id: 'tx-3' }) + }) + + it('does NOT persist journal_entry.drafted (excluded noise event)', async () => { + await eventBus.emit({ + type: 'journal_entry.drafted', + payload: { entry: {} as unknown, userId: 'user-1' }, + }) + + expect(mockInsert).not.toHaveBeenCalled() + }) + + it('does NOT persist receipt.extracted (excluded noise event)', async () => { + await eventBus.emit({ + type: 'receipt.extracted', + payload: { receipt: {} as unknown, documentId: null, confidence: 0.9, userId: 'user-1' }, + }) + + expect(mockInsert).not.toHaveBeenCalled() + }) + + it('does NOT persist supplier_invoice.received (excluded noise event)', async () => { + await eventBus.emit({ + type: 'supplier_invoice.received', + payload: { inboxItem: {} as unknown, userId: 'user-1' }, + }) + + expect(mockInsert).not.toHaveBeenCalled() + }) + + it('does NOT persist supplier_invoice.extracted (excluded noise event)', async () => { + await eventBus.emit({ + type: 'supplier_invoice.extracted', + payload: { inboxItem: {} as unknown, confidence: 0.9, userId: 'user-1' }, + }) + + expect(mockInsert).not.toHaveBeenCalled() + }) + + it('does not throw when persistence fails', async () => { + mockInsert.mockResolvedValue({ error: { message: 'DB down' } }) + + // Should not throw + await eventBus.emit({ + type: 'customer.created', + payload: { customer: makeCustomer(), userId: 'user-1' }, + }) + + expect(mockInsert).toHaveBeenCalledTimes(1) + }) + + it('persists period.locked with period entity_id', async () => { + const period = makeFiscalPeriod({ id: 'period-1' }) + + await eventBus.emit({ + type: 'period.locked', + payload: { period, userId: 'user-1' }, + }) + + expect(mockInsert).toHaveBeenCalledWith( + expect.objectContaining({ + event_type: 'period.locked', + entity_id: 'period-1', + }) + ) + }) +}) diff --git a/lib/events/handlers/event-log-handler.ts b/lib/events/handlers/event-log-handler.ts new file mode 100644 index 00000000..da39365d --- /dev/null +++ b/lib/events/handlers/event-log-handler.ts @@ -0,0 +1,145 @@ +import { eventBus } from '@/lib/events/bus' +import type { CoreEventType } from '@/lib/events/types' +import { createServiceClientNoCookies } from '@/lib/auth/api-keys' +import { createLogger } from '@/lib/logger' + +const log = createLogger('event-log') + +/** + * Event types persisted to the event_log table for external automation platforms. + * Excludes noise events that are always followed by an actionable event. + */ +const PERSISTED_EVENT_TYPES: CoreEventType[] = [ + 'journal_entry.committed', + 'journal_entry.corrected', + 'document.uploaded', + 'invoice.created', + 'invoice.sent', + 'credit_note.created', + 'transaction.synced', + 'transaction.categorized', + 'transaction.reconciled', + 'period.locked', + 'period.year_closed', + 'customer.created', + 'receipt.matched', + 'receipt.confirmed', + 'supplier_invoice.registered', + 'supplier_invoice.approved', + 'supplier_invoice.paid', + 'supplier_invoice.credited', + 'invoice.match_confirmed', + 'supplier_invoice.match_confirmed', + 'supplier_invoice.confirmed', +] + +// Excluded (with reasoning): +// - journal_entry.drafted: always followed by .committed +// - receipt.extracted: intermediate AI step; .matched/.confirmed are actionable +// - supplier_invoice.received: inbox receipt; .confirmed is actionable +// - supplier_invoice.extracted: intermediate AI step + +/** + * Extract the primary entity ID from an event payload. + */ +function extractEntityId(payload: Record): string | null { + // Try common entity shapes in priority order + const entityKeys = [ + 'entry', 'invoice', 'transaction', 'customer', 'receipt', + 'supplierInvoice', 'creditNote', 'period', 'document', 'inboxItem', + ] as const + + for (const key of entityKeys) { + const entity = payload[key] + if (entity && typeof entity === 'object' && 'id' in entity) { + const id = (entity as Record).id + if (typeof id === 'string') return id + } + } + + // For journal_entry.corrected: use the corrected entry's ID + if ('corrected' in payload) { + const corrected = payload.corrected + if (corrected && typeof corrected === 'object' && 'id' in corrected) { + const id = (corrected as Record).id + if (typeof id === 'string') return id + } + } + + return null +} + +/** + * Strip userId from payload (stored in its own column) and return clean data. + */ +function stripUserId(payload: Record): Record { + const { userId: _userId, ...data } = payload + return data +} + +/** + * Persist a single event to the event_log table. + */ +async function persistEvent( + eventType: string, + userId: string, + entityId: string | null, + data: Record +): Promise { + const supabase = createServiceClientNoCookies() + + const { error } = await supabase + .from('event_log') + .insert({ + user_id: userId, + event_type: eventType, + entity_id: entityId, + data, + }) + + if (error) { + log.error(`Failed to persist event ${eventType}:`, error.message) + } +} + +/** + * Register event log handlers on the event bus. + * Persists events to the event_log table for external automation platforms. + * Returns an array of unsubscribe functions. + */ +export function registerEventLogHandler(): (() => void)[] { + return PERSISTED_EVENT_TYPES.map((eventType) => + eventBus.on(eventType, async (payload) => { + try { + const rawPayload = payload as Record + const userId = rawPayload.userId as string + + // transaction.synced carries an array — batch insert + if (eventType === 'transaction.synced' && Array.isArray(rawPayload.transactions)) { + const transactions = rawPayload.transactions as Array> + if (transactions.length === 0) return + + const rows = transactions.map(tx => ({ + user_id: userId, + event_type: eventType, + entity_id: typeof tx.id === 'string' ? tx.id : null, + data: { transaction: tx }, + })) + + const supabase = createServiceClientNoCookies() + const { error } = await supabase.from('event_log').insert(rows) + if (error) { + log.error(`Failed to persist batch transaction.synced:`, error.message) + } + return + } + + const entityId = extractEntityId(rawPayload) + const data = stripUserId(rawPayload) + await persistEvent(eventType, userId, entityId, data) + } catch (err) { + log.error(`Event log handler error for ${eventType}:`, err) + } + }) + ) +} diff --git a/lib/init.ts b/lib/init.ts index c0d8ca6d..52786719 100644 --- a/lib/init.ts +++ b/lib/init.ts @@ -2,6 +2,7 @@ import { loadExtensions } from '@/lib/extensions/loader' import { setContextFactory } from '@/lib/extensions/registry' import { createExtensionContext } from '@/lib/extensions/context-factory' import { registerSupplierInvoiceHandler } from '@/lib/bookkeeping/handlers/supplier-invoice-handler' +import { registerEventLogHandler } from '@/lib/events/handlers/event-log-handler' import { createLogger } from '@/lib/logger' const log = createLogger('init') @@ -74,6 +75,7 @@ export function ensureInitialized(): void { validateEnvironment() setContextFactory(createExtensionContext) registerSupplierInvoiceHandler() + registerEventLogHandler() loadExtensions() initialized = true diff --git a/supabase/migrations/20260325120000_event_log.sql b/supabase/migrations/20260325120000_event_log.sql new file mode 100644 index 00000000..43d5eae1 --- /dev/null +++ b/supabase/migrations/20260325120000_event_log.sql @@ -0,0 +1,41 @@ +-- Migration: Event Log +-- Append-only event log for external automation platform integration (n8n, Make, Zapier). +-- Events are ephemeral delivery records with 30-day TTL, NOT compliance audit logs. + +-- ============================================================================= +-- 1. event_log table +-- ============================================================================= +CREATE TABLE public.event_log ( + sequence BIGSERIAL PRIMARY KEY, + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + event_type TEXT NOT NULL, + entity_id UUID, + data JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT now() + -- No updated_at: append-only ephemeral delivery log + -- No id UUID: sequence is the PK, cursor, and dedup key +); + +-- Primary polling query: WHERE user_id = X AND sequence > cursor ORDER BY sequence +CREATE INDEX idx_event_log_user_seq ON public.event_log (user_id, sequence); + +-- Retention cleanup: DELETE WHERE created_at < now() - interval '30 days' +CREATE INDEX idx_event_log_created_at ON public.event_log (created_at); + +-- ============================================================================= +-- 2. RLS +-- ============================================================================= +ALTER TABLE public.event_log ENABLE ROW LEVEL SECURITY; + +-- Users can read their own events (browser polling) +CREATE POLICY "event_log_select" ON public.event_log + FOR SELECT USING (auth.uid() = user_id); + +-- No INSERT/UPDATE/DELETE policies: writes via service role client + +-- ============================================================================= +-- 3. Immutability (update only — deletes allowed for retention cleanup) +-- ============================================================================= +CREATE TRIGGER event_log_no_update + BEFORE UPDATE ON public.event_log + FOR EACH ROW EXECUTE FUNCTION public.audit_log_immutable(); diff --git a/supabase/migrations/20260325130000_pending_operations.sql b/supabase/migrations/20260325130000_pending_operations.sql new file mode 100644 index 00000000..739e7529 --- /dev/null +++ b/supabase/migrations/20260325130000_pending_operations.sql @@ -0,0 +1,48 @@ +-- Migration: Pending Operations +-- Staging table for MCP agent write operations that require user review. +-- Agent proposes → user reviews in web UI → commits or rejects. + +-- ============================================================================= +-- 1. pending_operations table +-- ============================================================================= +CREATE TABLE public.pending_operations ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + operation_type TEXT NOT NULL CHECK (operation_type IN ( + 'categorize_transaction', 'create_customer', 'create_invoice' + )), + status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ( + 'pending', 'committed', 'rejected' + )), + title TEXT NOT NULL, + params JSONB NOT NULL DEFAULT '{}', + preview_data JSONB NOT NULL DEFAULT '{}', + result_data JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + resolved_at TIMESTAMPTZ, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- Primary query: list pending ops for a user +CREATE INDEX idx_pending_ops_user_status ON public.pending_operations (user_id, status); + +-- ============================================================================= +-- 2. RLS +-- ============================================================================= +ALTER TABLE public.pending_operations ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "pending_ops_select_own" ON public.pending_operations + FOR SELECT USING (auth.uid() = user_id); + +CREATE POLICY "pending_ops_update_own" ON public.pending_operations + FOR UPDATE USING (auth.uid() = user_id); + +-- No INSERT policy: writes via service role client from MCP handler +-- No DELETE policy: status transitions only (pending → committed/rejected) + +-- ============================================================================= +-- 3. updated_at trigger +-- ============================================================================= +CREATE TRIGGER pending_operations_updated_at + BEFORE UPDATE ON public.pending_operations + FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column(); diff --git a/supabase/migrations/20260326120000_pending_operations_expand_types.sql b/supabase/migrations/20260326120000_pending_operations_expand_types.sql new file mode 100644 index 00000000..5ab69dd4 --- /dev/null +++ b/supabase/migrations/20260326120000_pending_operations_expand_types.sql @@ -0,0 +1,16 @@ +-- Expand pending_operations operation_type to include new MCP write tools + +ALTER TABLE public.pending_operations + DROP CONSTRAINT IF EXISTS pending_operations_operation_type_check; + +ALTER TABLE public.pending_operations + ADD CONSTRAINT pending_operations_operation_type_check + CHECK (operation_type IN ( + 'categorize_transaction', + 'create_customer', + 'create_invoice', + 'mark_invoice_paid', + 'send_invoice', + 'mark_invoice_sent', + 'match_transaction_invoice' + )); diff --git a/types/index.ts b/types/index.ts index 398d38ab..f74009ca 100644 --- a/types/index.ts +++ b/types/index.ts @@ -1069,6 +1069,25 @@ export interface CreateFiscalPeriodInput { period_end: string } +// ── Pending Operations ──────────────────────────────────────── + +export type PendingOperationType = 'categorize_transaction' | 'create_customer' | 'create_invoice' | 'mark_invoice_paid' | 'send_invoice' | 'mark_invoice_sent' | 'match_transaction_invoice' +export type PendingOperationStatus = 'pending' | 'committed' | 'rejected' + +export interface PendingOperation { + id: string + user_id: string + operation_type: PendingOperationType + status: PendingOperationStatus + title: string + params: Record + preview_data: Record + result_data: Record | null + created_at: string + resolved_at: string | null + updated_at: string +} + // Onboarding progress for new user checklist export interface OnboardingProgress { hasCustomers: boolean diff --git a/vercel.json b/vercel.json index 00f0f4ce..3a2c9383 100644 --- a/vercel.json +++ b/vercel.json @@ -23,6 +23,10 @@ { "path": "/api/sandbox/cleanup/cron", "schedule": "0 4 * * *" + }, + { + "path": "/api/events/cleanup/cron", + "schedule": "0 2 * * *" } ] }