feat: event log, pending operations, and MCP staging (#135)

* feat: event log, pending operations, and MCP staging

- Event log system: persist bus events to event_log table for external
  automation platforms. Batch insert for transaction.synced. Daily
  cleanup cron at 02:00 UTC.
- Pending operations: MCP write tools (categorize, create customer,
  create invoice) now stage to pending_operations instead of executing
  directly. Users review and commit/reject from /pending in the web UI.
- Granskning page: card-based review UI with expandable previews,
  commit/reject dialogs. Only shown in nav when pending ops exist.
- Commit route re-executes using core lib functions (no extension
  imports). Guards against stale state (double-commit, deleted entities).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: stage new MCP write tools after main merge

Add staging for 4 new write tools from #133:
- mark_invoice_paid, send_invoice, mark_invoice_sent,
  match_transaction_invoice
- Expand pending_operations CHECK constraint
- Add commit executors with full execution logic
- Add UI labels and generic preview component
- Remove confirm parameter from categorize (single-call staging)
- Fix UUID in pending op title (fetch transaction description)
- Hide Granskning nav when no pending ops

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address PR review feedback

- Fix TS build error: use `select('*, customer:customers(*)')` for
  match_transaction_invoice to avoid array type inference
- Add status guard to commitSendInvoice (prevents duplicate sends)
- Replace auth.admin.getUserById with user email from session auth
- Restore optimistic lock check in commitMatchTransactionInvoice
- Fix tool description typo: expense_software → expense_office

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-03-26 10:07:36 +01:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 4d2152946a
commit f3ae3cd361
23 changed files with 2744 additions and 465 deletions
+7 -1
View File
@@ -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()}
/>
+381
View File
@@ -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<string, { label: string; icon: typeof ArrowLeftRight; variant: 'default' | 'secondary' | 'outline' }> = {
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<string, unknown> }) {
const vatLines = (data.vat_lines as Array<{ account_number: string; debit_amount: number; credit_amount: number; description: string }>) || []
return (
<div className="space-y-3 text-sm">
<div className="grid grid-cols-2 gap-x-4 gap-y-1">
<span className="text-muted-foreground">Debetkonto</span>
<span className="font-mono">{String(data.debit_account ?? '')}</span>
<span className="text-muted-foreground">Kreditkonto</span>
<span className="font-mono">{String(data.credit_account ?? '')}</span>
<span className="text-muted-foreground">Belopp</span>
<span className="font-mono tabular-nums">
{formatCurrency(data.amount as number, (data.currency as string) || 'SEK')}
</span>
</div>
{vatLines.length > 0 && (
<div className="border-t pt-2">
<p className="text-xs text-muted-foreground mb-1">Momsrader</p>
{vatLines.map((line, i) => (
<div key={i} className="flex justify-between font-mono text-xs">
<span>{line.account_number} {line.description}</span>
<span className="tabular-nums">
{line.debit_amount > 0 ? `D ${formatCurrency(line.debit_amount)}` : `K ${formatCurrency(line.credit_amount)}`}
</span>
</div>
))}
</div>
)}
</div>
)
}
function CustomerPreview({ data }: { data: Record<string, unknown> }) {
return (
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-sm">
<span className="text-muted-foreground">Namn</span>
<span>{String(data.name ?? '')}</span>
<span className="text-muted-foreground">Typ</span>
<span>{String(data.customer_type ?? '')}</span>
{data.email ? (
<>
<span className="text-muted-foreground">E-post</span>
<span>{String(data.email)}</span>
</>
) : null}
{data.org_number ? (
<>
<span className="text-muted-foreground">Org.nr</span>
<span className="font-mono">{String(data.org_number)}</span>
</>
) : null}
</div>
)
}
function InvoicePreview({ data }: { data: Record<string, unknown> }) {
const items = (data.items as Array<{ description: string; quantity: number; unit: string; unit_price: number; line_total: number; vat_rate: number }>) || []
return (
<div className="space-y-3 text-sm">
<div className="grid grid-cols-2 gap-x-4 gap-y-1">
<span className="text-muted-foreground">Kund</span>
<span>{String(data.customer_name ?? '')}</span>
<span className="text-muted-foreground">Datum</span>
<span>{String(data.invoice_date ?? '')}</span>
<span className="text-muted-foreground">Förfallodatum</span>
<span>{String(data.due_date ?? '')}</span>
</div>
{items.length > 0 && (
<div className="border-t pt-2 space-y-1">
{items.map((item, i) => (
<div key={i} className="flex justify-between text-xs">
<span className="truncate mr-4">{item.description} ({item.quantity} {item.unit})</span>
<span className="font-mono tabular-nums whitespace-nowrap">
{formatCurrency(item.line_total, (data.currency as string) || 'SEK')}
</span>
</div>
))}
</div>
)}
<div className="border-t pt-2 grid grid-cols-2 gap-x-4 gap-y-1">
<span className="text-muted-foreground">Netto</span>
<span className="font-mono tabular-nums text-right">{formatCurrency(data.subtotal as number, (data.currency as string) || 'SEK')}</span>
<span className="text-muted-foreground">Moms</span>
<span className="font-mono tabular-nums text-right">{formatCurrency(data.vat_amount as number, (data.currency as string) || 'SEK')}</span>
<span className="font-medium">Totalt</span>
<span className="font-mono tabular-nums font-medium text-right">{formatCurrency(data.total as number, (data.currency as string) || 'SEK')}</span>
</div>
</div>
)
}
function GenericPreview({ data }: { data: Record<string, unknown> }) {
const entries = Object.entries(data).filter(([, v]) => v != null && v !== '')
return (
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-sm">
{entries.map(([key, value]) => (
<Fragment key={key}>
<span className="text-muted-foreground">{key.replace(/_/g, ' ')}</span>
<span className={typeof value === 'number' ? 'font-mono tabular-nums' : ''}>
{String(value)}
</span>
</Fragment>
))}
</div>
)
}
function OperationPreview({ op }: { op: PendingOperation }) {
switch (op.operation_type) {
case 'categorize_transaction':
return <CategorizePreview data={op.preview_data} />
case 'create_customer':
return <CustomerPreview data={op.preview_data} />
case 'create_invoice':
return <InvoicePreview data={op.preview_data} />
default:
return <GenericPreview data={op.preview_data} />
}
}
export default function PendingOperationsPage() {
const [operations, setOperations] = useState<PendingOperation[]>([])
const [isLoading, setIsLoading] = useState(true)
const [activeTab, setActiveTab] = useState<PendingOperationStatus>('pending')
const [expandedId, setExpandedId] = useState<string | null>(null)
const [selectedOp, setSelectedOp] = useState<PendingOperation | null>(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<string, string> = {
categorize_transaction: '',
create_customer: '',
create_invoice: '',
}
return (
<div className="space-y-6">
<PageHeader
title="Granskning"
description="Operationer från din AI-agent som väntar på godkännande"
/>
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as PendingOperationStatus)}>
<TabsList>
<TabsTrigger value="pending">Väntande</TabsTrigger>
<TabsTrigger value="committed">Godkända</TabsTrigger>
<TabsTrigger value="rejected">Avvisade</TabsTrigger>
</TabsList>
</Tabs>
{isLoading ? (
<Card>
<CardContent className="flex items-center justify-center py-16">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</CardContent>
</Card>
) : operations.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-16 text-center">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-muted mb-4">
<ClipboardCheck className="h-6 w-6 text-muted-foreground" />
</div>
<p className="font-medium">
{activeTab === 'pending'
? 'Inga väntande operationer'
: activeTab === 'committed'
? 'Inga godkända operationer'
: 'Inga avvisade operationer'}
</p>
<p className="text-sm text-muted-foreground mt-1">
{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.'}
</p>
</CardContent>
</Card>
) : (
<div className="space-y-2">
{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 (
<Card
key={op.id}
className="transition-colors hover:border-primary/30"
>
<CardContent className="py-4">
<div
className="flex items-start justify-between gap-4 cursor-pointer"
onClick={() => setExpandedId(isExpanded ? null : op.id)}
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<Badge variant={config.variant}>{config.label}</Badge>
{op.status === 'committed' && (
<Badge variant="default" className="bg-emerald-500/10 text-emerald-600 border-emerald-200">
<CheckCircle2 className="h-3 w-3 mr-1" />
Godkänd
</Badge>
)}
{op.status === 'rejected' && (
<Badge variant="destructive" className="bg-destructive/10">
<XCircle className="h-3 w-3 mr-1" />
Avvisad
</Badge>
)}
<span className="text-xs text-muted-foreground">
{formatRelativeTime(op.created_at)}
</span>
</div>
<p className="text-sm font-medium truncate">{op.title}</p>
</div>
{op.status === 'pending' && (
<div className="flex gap-2 flex-shrink-0">
<Button
size="sm"
className="h-8 px-3 text-xs"
onClick={(e) => {
e.stopPropagation()
setSelectedOp(op)
setShowCommitDialog(true)
}}
>
Godkänn
</Button>
<Button
size="sm"
variant="ghost"
className="h-8 px-3 text-xs"
onClick={(e) => {
e.stopPropagation()
handleReject(op)
}}
>
Avvisa
</Button>
</div>
)}
</div>
{/* Expandable preview */}
<div className={`grid transition-all duration-200 ${isExpanded ? 'grid-rows-[1fr] mt-3' : 'grid-rows-[0fr]'}`}>
<div className="overflow-hidden">
<div className="border-t pt-3">
<OperationPreview op={op} />
</div>
</div>
</div>
</CardContent>
</Card>
)
})}
</div>
)}
{/* Commit confirmation dialog */}
<ConfirmationDialog
open={showCommitDialog}
onOpenChange={setShowCommitDialog}
title={selectedOp?.title || 'Godkänn operation'}
warningText={selectedOp ? warningForType[selectedOp.operation_type] : ''}
confirmLabel="Godkänn"
isSubmitting={isCommitting}
onConfirm={handleCommit}
>
{selectedOp && <OperationPreview op={selectedOp} />}
</ConfirmationDialog>
{/* Reject confirmation dialog */}
<DestructiveConfirmDialog {...dialogProps} />
</div>
)
}
+210
View File
@@ -0,0 +1,210 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import {
createMockRequest,
parseJsonResponse,
createQueuedMockSupabase,
} from '@/tests/helpers'
// Mock supabase server (session auth)
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
vi.mock('@/lib/supabase/server', () => ({
createClient: () => Promise.resolve(mockSupabase),
}))
// Mock API key auth
const mockValidateApiKey = vi.fn()
const mockExtractBearerToken = vi.fn()
const mockCreateServiceClientNoCookies = vi.fn()
vi.mock('@/lib/auth/api-keys', () => ({
validateApiKey: (...args: unknown[]) => mockValidateApiKey(...args),
extractBearerToken: (...args: unknown[]) => mockExtractBearerToken(...args),
createServiceClientNoCookies: () => mockCreateServiceClientNoCookies(),
}))
import { GET } from '../route'
describe('GET /api/events', () => {
const mockUser = { id: 'user-1', email: 'test@test.se' }
const sampleEvents = [
{
sequence: 1,
event_type: 'invoice.created',
entity_id: 'inv-1',
data: { invoice: { id: 'inv-1', total: 1000 } },
created_at: '2026-03-25T10:00:00Z',
},
{
sequence: 2,
event_type: 'customer.created',
entity_id: 'cust-1',
data: { customer: { id: 'cust-1', name: 'Acme AB' } },
created_at: '2026-03-25T10:01:00Z',
},
]
beforeEach(() => {
vi.clearAllMocks()
reset()
mockExtractBearerToken.mockReturnValue(null)
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
})
it('returns 401 when not authenticated', async () => {
mockExtractBearerToken.mockReturnValue(null)
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } })
const request = createMockRequest('/api/events')
const response = await GET(request)
const { status, body } = await parseJsonResponse(response)
expect(status).toBe(401)
expect(body).toEqual({ error: 'Unauthorized' })
})
it('returns events with session auth', async () => {
enqueue({ data: sampleEvents })
const request = createMockRequest('/api/events')
const response = await GET(request)
const { status, body } = await parseJsonResponse<{
data: typeof sampleEvents
cursor: number
has_more: boolean
}>(response)
expect(status).toBe(200)
expect(body.data).toHaveLength(2)
expect(body.cursor).toBe(2)
expect(body.has_more).toBe(false)
})
it('returns events with API key auth', async () => {
mockExtractBearerToken.mockReturnValue('gnubok_sk_test123')
mockValidateApiKey.mockResolvedValue({ userId: 'user-1' })
const apiKeySupabase = createQueuedMockSupabase()
apiKeySupabase.enqueue({ data: sampleEvents })
mockCreateServiceClientNoCookies.mockReturnValue(apiKeySupabase.supabase)
const request = createMockRequest('/api/events')
const response = await GET(request)
const { status, body } = await parseJsonResponse<{
data: typeof sampleEvents
cursor: number
has_more: boolean
}>(response)
expect(status).toBe(200)
expect(body.data).toHaveLength(2)
expect(mockValidateApiKey).toHaveBeenCalledWith('gnubok_sk_test123')
})
it('returns 401 for invalid API key', async () => {
mockExtractBearerToken.mockReturnValue('gnubok_sk_invalid')
mockValidateApiKey.mockResolvedValue({ error: 'Invalid API key', status: 401 })
const request = createMockRequest('/api/events')
const response = await GET(request)
const { status, body } = await parseJsonResponse(response)
expect(status).toBe(401)
expect(body).toEqual({ error: 'Invalid API key' })
})
it('returns 429 for rate-limited API key', async () => {
mockExtractBearerToken.mockReturnValue('gnubok_sk_limited')
mockValidateApiKey.mockResolvedValue({ error: 'Rate limit exceeded', status: 429 })
const request = createMockRequest('/api/events')
const response = await GET(request)
const { status } = await parseJsonResponse(response)
expect(status).toBe(429)
})
it('supports after cursor parameter', async () => {
enqueue({ data: [sampleEvents[1]] })
const request = createMockRequest('/api/events', {
searchParams: { after: '1' },
})
const response = await GET(request)
const { status, body } = await parseJsonResponse<{
data: typeof sampleEvents
cursor: number
}>(response)
expect(status).toBe(200)
expect(body.data).toHaveLength(1)
expect(body.cursor).toBe(2)
})
it('supports types filter parameter', async () => {
enqueue({ data: [sampleEvents[0]] })
const request = createMockRequest('/api/events', {
searchParams: { types: 'invoice.created' },
})
const response = await GET(request)
const { status, body } = await parseJsonResponse<{
data: typeof sampleEvents
}>(response)
expect(status).toBe(200)
expect(body.data).toHaveLength(1)
})
it('returns has_more=true when results equal limit', async () => {
// Return exactly `limit` items to trigger has_more
const events = Array.from({ length: 2 }, (_, i) => ({
sequence: i + 1,
event_type: 'invoice.created',
entity_id: `inv-${i}`,
data: {},
created_at: '2026-03-25T10:00:00Z',
}))
enqueue({ data: events })
const request = createMockRequest('/api/events', {
searchParams: { limit: '2' },
})
const response = await GET(request)
const { body } = await parseJsonResponse<{ has_more: boolean }>(response)
expect(body.has_more).toBe(true)
})
it('returns cursor=0 when no events and no after param', async () => {
enqueue({ data: [] })
const request = createMockRequest('/api/events')
const response = await GET(request)
const { body } = await parseJsonResponse<{ cursor: number; data: unknown[] }>(response)
expect(body.data).toHaveLength(0)
expect(body.cursor).toBe(0)
})
it('returns cursor=after when no events but after param provided', async () => {
enqueue({ data: [] })
const request = createMockRequest('/api/events', {
searchParams: { after: '42' },
})
const response = await GET(request)
const { body } = await parseJsonResponse<{ cursor: number }>(response)
expect(body.cursor).toBe(42)
})
it('rejects invalid limit parameter', async () => {
const request = createMockRequest('/api/events', {
searchParams: { limit: '999' },
})
const response = await GET(request)
const { status } = await parseJsonResponse(response)
expect(status).toBe(400)
})
})
+41
View File
@@ -0,0 +1,41 @@
import { createServiceClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
/**
* GET /api/events/cleanup/cron
* Daily cron job to delete event_log rows older than 30 days.
* Runs at 02:00 UTC every day.
*/
export async function GET(request: Request) {
const authHeader = request.headers.get('authorization')
const cronSecret = process.env.CRON_SECRET
if (!cronSecret || authHeader !== `Bearer ${cronSecret}`) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const supabase = await createServiceClient()
const cutoff = new Date()
cutoff.setDate(cutoff.getDate() - 30)
const { error, count } = await supabase
.from('event_log')
.delete({ count: 'exact' })
.lt('created_at', cutoff.toISOString())
if (error) throw error
const deleted = count ?? 0
console.log(`Event log cleanup completed: ${deleted} events removed`)
return NextResponse.json({ success: true, deleted })
} catch (error) {
console.error('Error in event log cleanup cron:', error)
return NextResponse.json(
{ error: 'Failed to clean up event log' },
{ status: 500 }
)
}
}
+77
View File
@@ -0,0 +1,77 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { extractBearerToken, validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys'
import { validateQuery } from '@/lib/api/validate'
import { EventsQuerySchema } from '@/lib/api/schemas'
import type { SupabaseClient } from '@supabase/supabase-js'
/**
* GET /api/events
*
* Cursor-based polling endpoint for external automation platforms (n8n, Make, Zapier).
* Returns events from the event_log table in sequence order.
*
* Query params:
* - after (bigint, optional): return events with sequence > this value
* - types (string, optional): comma-separated event type filter
* - limit (int, optional): max results, default 50, cap 100
*
* Supports both session auth (browser) and API key auth (automation platforms).
*/
export async function GET(request: Request) {
// Dual auth: API key or session
let userId: string
let supabase: SupabaseClient
const token = extractBearerToken(request)
if (token?.startsWith('gnubok_sk_')) {
const authResult = await validateApiKey(token)
if ('error' in authResult) {
return NextResponse.json({ error: authResult.error }, { status: authResult.status })
}
userId = authResult.userId
supabase = createServiceClientNoCookies()
} else {
supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
userId = user.id
}
// Validate query params
const result = validateQuery(request, EventsQuerySchema)
if (!result.success) return result.response
const { after, types, limit } = result.data
// Build query
let query = supabase
.from('event_log')
.select('sequence, event_type, entity_id, data, created_at')
.eq('user_id', userId)
.order('sequence', { ascending: true })
.limit(limit)
if (after !== undefined) {
query = query.gt('sequence', after)
}
if (types && types.length > 0) {
query = query.in('event_type', types)
}
const { data, error } = await query
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
const events = data ?? []
return NextResponse.json({
data: events,
cursor: events.length > 0 ? events[events.length - 1].sequence : (after ?? 0),
has_more: events.length === limit,
})
}
@@ -0,0 +1,242 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import {
createMockRequest,
createMockRouteParams,
parseJsonResponse,
createQueuedMockSupabase,
makeTransaction,
makeCompanySettings,
} from '@/tests/helpers'
import { eventBus } from '@/lib/events/bus'
const { supabase: mockSupabase, enqueue, enqueueMany, reset } = createQueuedMockSupabase()
vi.mock('@/lib/supabase/server', () => ({
createClient: () => Promise.resolve(mockSupabase),
}))
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
// Mock the counterparty templates (non-critical side effect)
vi.mock('@/lib/bookkeeping/counterparty-templates', () => ({
upsertCounterpartyTemplate: vi.fn().mockResolvedValue(undefined),
}))
// Mock createTransactionJournalEntry
const mockCreateJournalEntry = vi.fn()
vi.mock('@/lib/bookkeeping/transaction-entries', () => ({
createTransactionJournalEntry: (...args: unknown[]) => mockCreateJournalEntry(...args),
}))
// Mock VAT validation
vi.mock('@/lib/vat/vies-client', () => ({
validateVatNumber: vi.fn().mockResolvedValue({ valid: true }),
}))
// Mock exchange rate
vi.mock('@/lib/currency/riksbanken', () => ({
fetchExchangeRate: vi.fn().mockResolvedValue({ rate: 11.5, date: '2026-03-25' }),
convertToSEK: vi.fn((amount: number, rate: number) => Math.round(amount * rate * 100) / 100),
}))
import { POST } from '../../commit/route'
describe('POST /api/pending-operations/:id/commit', () => {
const mockUser = { id: 'user-1', email: 'test@test.se' }
const routeParams = createMockRouteParams({ id: 'op-1' })
beforeEach(() => {
vi.clearAllMocks()
eventBus.clear()
reset()
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
mockCreateJournalEntry.mockResolvedValue({ id: 'je-1' })
})
it('returns 401 when not authenticated', async () => {
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } })
const request = createMockRequest('/api/pending-operations/op-1/commit', { method: 'POST' })
const response = await POST(request, routeParams)
const { status } = await parseJsonResponse(response)
expect(status).toBe(401)
})
it('returns 404 when operation not found', async () => {
enqueue({ data: null, error: { message: 'not found' } })
const request = createMockRequest('/api/pending-operations/op-1/commit', { method: 'POST' })
const response = await POST(request, routeParams)
const { status, body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(404)
expect(body.error).toContain('not found')
})
it('returns 409 when operation already committed', async () => {
enqueue({
data: {
id: 'op-1',
user_id: 'user-1',
operation_type: 'categorize_transaction',
status: 'committed',
params: {},
preview_data: {},
},
})
const request = createMockRequest('/api/pending-operations/op-1/commit', { method: 'POST' })
const response = await POST(request, routeParams)
const { status, body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(409)
expect(body.error).toContain('already committed')
})
describe('categorize_transaction', () => {
const pendingOp = {
id: 'op-1',
user_id: 'user-1',
operation_type: 'categorize_transaction',
status: 'pending',
title: 'Kategorisera: test',
params: {
transaction_id: 'tx-1',
category: 'expense_office',
vat_treatment: null,
},
preview_data: {},
}
it('commits successfully', async () => {
const tx = makeTransaction({ id: 'tx-1', amount: -500, journal_entry_id: null })
const settings = makeCompanySettings()
enqueueMany([
{ data: pendingOp }, // fetch pending op
{ data: tx }, // fetch transaction
{ data: settings }, // fetch company settings
{ data: [{ id: 'fp-1' }] }, // fiscal period check
{ data: null, error: null }, // update transaction
{ data: null, error: null }, // upsert counterparty template
{ data: null, error: null }, // update pending op status
])
const request = createMockRequest('/api/pending-operations/op-1/commit', { method: 'POST' })
const response = await POST(request, routeParams)
const { status, body } = await parseJsonResponse<{ data: { journal_entry_id: string } }>(response)
expect(status).toBe(200)
expect(body.data.journal_entry_id).toBe('je-1')
expect(mockCreateJournalEntry).toHaveBeenCalledTimes(1)
})
it('returns 409 when transaction already categorized', async () => {
const tx = makeTransaction({ id: 'tx-1', journal_entry_id: 'existing-je' })
enqueueMany([
{ data: pendingOp }, // fetch pending op
{ data: tx }, // fetch transaction (already has JE)
{ data: null, error: null }, // auto-reject update
])
const request = createMockRequest('/api/pending-operations/op-1/commit', { method: 'POST' })
const response = await POST(request, routeParams)
const { status, body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(409)
expect(body.error).toContain('already has a journal entry')
})
})
describe('create_customer', () => {
const pendingOp = {
id: 'op-1',
user_id: 'user-1',
operation_type: 'create_customer',
status: 'pending',
title: 'Ny kund: Acme AB',
params: {
name: 'Acme AB',
customer_type: 'swedish_business',
email: 'info@acme.se',
},
preview_data: {},
}
it('commits successfully', async () => {
enqueueMany([
{ data: pendingOp }, // fetch pending op
{ data: { id: 'cust-1', name: 'Acme AB' } }, // insert customer
{ data: null, error: null }, // update pending op status
])
const request = createMockRequest('/api/pending-operations/op-1/commit', { method: 'POST' })
const response = await POST(request, routeParams)
const { status, body } = await parseJsonResponse<{ data: { customer_id: string } }>(response)
expect(status).toBe(200)
expect(body.data.customer_id).toBe('cust-1')
})
})
describe('create_invoice', () => {
const pendingOp = {
id: 'op-1',
user_id: 'user-1',
operation_type: 'create_invoice',
status: 'pending',
title: 'Ny faktura: Acme AB 15000 SEK',
params: {
customer_id: 'cust-1',
items: [{ description: 'Konsulttjänster', quantity: 1, unit: 'st', unit_price: 15000 }],
invoice_date: '2026-03-25',
due_date: '2026-04-24',
currency: 'SEK',
},
preview_data: {},
}
it('commits successfully', async () => {
const customer = {
id: 'cust-1',
name: 'Acme AB',
customer_type: 'swedish_business',
vat_number_validated: false,
default_payment_terms: 30,
}
enqueueMany([
{ data: pendingOp }, // fetch pending op
{ data: customer }, // fetch customer
{ data: '20260001' }, // generate invoice number (rpc)
{ data: { id: 'inv-1' } }, // insert invoice
{ data: null, error: null }, // insert items
{ data: { id: 'inv-1', customer: customer, items: [] } }, // fetch complete invoice
{ data: null, error: null }, // update pending op status
])
const request = createMockRequest('/api/pending-operations/op-1/commit', { method: 'POST' })
const response = await POST(request, routeParams)
const { status, body } = await parseJsonResponse<{ data: { invoice_id: string; invoice_number: string } }>(response)
expect(status).toBe(200)
expect(body.data.invoice_id).toBe('inv-1')
expect(body.data.invoice_number).toBe('20260001')
})
it('returns 404 when customer not found', async () => {
enqueueMany([
{ data: pendingOp }, // fetch pending op
{ data: null, error: { message: 'not found' } }, // customer not found
{ data: null, error: null }, // auto-reject update
])
const request = createMockRequest('/api/pending-operations/op-1/commit', { method: 'POST' })
const response = await POST(request, routeParams)
const { status, body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(404)
expect(body.error).toContain('Customer not found')
})
})
})
@@ -0,0 +1,858 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { eventBus } from '@/lib/events'
import { ensureInitialized } from '@/lib/init'
import { buildMappingResultFromCategory } from '@/lib/bookkeeping/category-mapping'
import { createTransactionJournalEntry } from '@/lib/bookkeeping/transaction-entries'
import { upsertCounterpartyTemplate } from '@/lib/bookkeeping/counterparty-templates'
import { getVatRules, getAvailableVatRates } from '@/lib/invoices/vat-rules'
import { fetchExchangeRate, convertToSEK } from '@/lib/currency/riksbanken'
import { validateVatNumber } from '@/lib/vat/vies-client'
import {
createInvoicePaymentJournalEntry,
createInvoiceCashEntry,
createInvoiceJournalEntry,
} from '@/lib/bookkeeping/invoice-entries'
import { reverseEntry } from '@/lib/bookkeeping/engine'
import { getEmailService } from '@/lib/email/service'
import {
generateInvoiceEmailHtml,
generateInvoiceEmailText,
generateInvoiceEmailSubject,
} from '@/lib/email/invoice-templates'
import { uploadDocument } from '@/lib/core/documents/document-service'
import { renderToBuffer } from '@react-pdf/renderer'
import { InvoicePDF } from '@/lib/invoices/pdf-template'
import { createLogger } from '@/lib/logger'
import type {
Transaction,
TransactionCategory,
EntityType,
VatTreatment,
Currency,
Invoice,
Customer,
PendingOperation,
CompanySettings,
InvoiceItem,
} from '@/types'
const log = createLogger('pending-operations/commit')
ensureInitialized()
/**
* Ensure a fiscal period exists for the given date, create one if needed.
* Same logic as app/api/transactions/[id]/categorize/route.ts
*/
async function ensureFiscalPeriod(
supabase: Awaited<ReturnType<typeof createClient>>,
userId: string,
date: string,
fiscalYearStartMonth: number = 1
): Promise<boolean> {
const { data: existing } = await supabase
.from('fiscal_periods')
.select('id')
.eq('user_id', userId)
.lte('period_start', date)
.gte('period_end', date)
.eq('is_closed', false)
.limit(1)
if (existing && existing.length > 0) return true
const txDate = new Date(date)
const txMonth = txDate.getMonth() + 1
const txYear = txDate.getFullYear()
let periodStartYear: number
if (fiscalYearStartMonth === 1) {
periodStartYear = txYear
} else if (txMonth >= fiscalYearStartMonth) {
periodStartYear = txYear
} else {
periodStartYear = txYear - 1
}
const startMonth = String(fiscalYearStartMonth).padStart(2, '0')
const periodStart = `${periodStartYear}-${startMonth}-01`
const endYear = fiscalYearStartMonth === 1 ? periodStartYear : periodStartYear + 1
const endMonth = fiscalYearStartMonth === 1 ? 12 : fiscalYearStartMonth - 1
const lastDay = new Date(endYear, endMonth, 0).getDate()
const periodEnd = `${endYear}-${String(endMonth).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`
const periodName = fiscalYearStartMonth === 1
? `Räkenskapsår ${periodStartYear}`
: `Räkenskapsår ${periodStartYear}/${endYear}`
const { error } = await supabase
.from('fiscal_periods')
.upsert({
user_id: userId,
name: periodName,
period_start: periodStart,
period_end: periodEnd,
}, { onConflict: 'user_id,period_start,period_end' })
if (error) {
log.error('Failed to create fiscal period:', error)
return false
}
return true
}
// ── Commit executors ──────────────────────────────────────────
async function commitCategorizeTransaction(
supabase: Awaited<ReturnType<typeof createClient>>,
userId: string,
params: Record<string, unknown>
): Promise<{ data?: Record<string, unknown>; error?: string; status?: number }> {
const txId = params.transaction_id as string
const category = params.category as TransactionCategory
const vatTreatment = params.vat_treatment as VatTreatment | undefined
// Fetch transaction — guard against double-commit
const { data: transaction, error: fetchError } = await supabase
.from('transactions')
.select('*')
.eq('id', txId)
.eq('user_id', userId)
.single()
if (fetchError || !transaction) {
return { error: 'Transaction not found — it may have been deleted.', status: 404 }
}
if (transaction.journal_entry_id) {
return { error: 'Transaction already has a journal entry — it was categorized in the meantime.', status: 409 }
}
const isBusiness = category !== 'private'
// Fetch company settings
const { data: settings } = await supabase
.from('company_settings')
.select('entity_type, fiscal_year_start_month')
.eq('user_id', userId)
.single()
const entityType: EntityType = (settings?.entity_type as EntityType) || 'enskild_firma'
const fiscalYearStartMonth = settings?.fiscal_year_start_month ?? 1
// Build mapping
const mappingResult = buildMappingResultFromCategory(
category,
transaction as Transaction,
isBusiness,
entityType,
vatTreatment
)
if (!mappingResult.debit_account || !mappingResult.credit_account) {
return { error: `No account mapping for category "${category}" with entity type "${entityType}".`, status: 400 }
}
// Ensure fiscal period exists
await ensureFiscalPeriod(supabase, userId, transaction.date, fiscalYearStartMonth)
// Create journal entry
let journalEntryId: string | null = null
try {
const journalEntry = await createTransactionJournalEntry(
supabase, userId, transaction as Transaction, mappingResult
)
if (journalEntry) {
journalEntryId = journalEntry.id
}
} catch (err) {
log.error('Failed to create journal entry:', err)
return { error: err instanceof Error ? err.message : 'Failed to create journal entry', status: 500 }
}
// Update transaction
const { error: updateError } = await supabase
.from('transactions')
.update({
is_business: isBusiness,
category,
journal_entry_id: journalEntryId,
})
.eq('id', txId)
if (updateError) {
log.error('Failed to update transaction:', updateError)
return { error: 'Failed to update transaction', status: 500 }
}
// Upsert counterparty template (non-blocking)
try {
await upsertCounterpartyTemplate(
supabase, userId, transaction as Transaction, mappingResult, 'user_approved'
)
} catch { /* non-critical */ }
// Emit event
await eventBus.emit({
type: 'transaction.categorized',
payload: {
transaction: transaction as Transaction,
account: mappingResult.debit_account,
taxCode: mappingResult.vat_lines[0]?.account_number || '',
userId,
},
})
return { data: { journal_entry_id: journalEntryId, category } }
}
async function commitCreateCustomer(
supabase: Awaited<ReturnType<typeof createClient>>,
userId: string,
params: Record<string, unknown>
): Promise<{ data?: Record<string, unknown>; error?: string; status?: number }> {
const { data, error } = await supabase
.from('customers')
.insert({
user_id: userId,
name: params.name as string,
customer_type: params.customer_type as string,
email: (params.email as string) || null,
org_number: (params.org_number as string) || null,
vat_number: (params.vat_number as string) || null,
default_payment_terms: (params.payment_terms as number) || 30,
address_line1: (params.address as string) || null,
postal_code: (params.postal_code as string) || null,
city: (params.city as string) || null,
country: (params.country as string) || 'Sweden',
})
.select()
.single()
if (error) {
return { error: error.message, status: 500 }
}
// Auto-validate VAT number for EU business customers (non-blocking)
if (params.customer_type === 'eu_business' && params.vat_number) {
try {
const vatResult = await validateVatNumber(params.vat_number as string)
if (vatResult.valid) {
await supabase
.from('customers')
.update({
vat_number_validated: true,
vat_number_validated_at: new Date().toISOString(),
})
.eq('id', data.id)
.eq('user_id', userId)
}
} catch (err) {
log.warn('Auto-VIES validation failed:', err)
}
}
await eventBus.emit({
type: 'customer.created',
payload: { customer: data as Customer, userId },
})
return { data: { customer_id: data.id } }
}
async function commitCreateInvoice(
supabase: Awaited<ReturnType<typeof createClient>>,
userId: string,
params: Record<string, unknown>
): Promise<{ data?: Record<string, unknown>; error?: string; status?: number }> {
const customerId = params.customer_id as string
const items = params.items as Array<{
description: string
quantity: number
unit: string
unit_price: number
vat_rate?: number
}>
// Fetch customer
const { data: customer, error: customerError } = await supabase
.from('customers')
.select('*')
.eq('id', customerId)
.eq('user_id', userId)
.single()
if (customerError || !customer) {
return { error: 'Customer not found — they may have been deleted.', status: 404 }
}
// Calculate VAT
const vatRules = getVatRules(customer.customer_type, customer.vat_number_validated)
const availableRates = getAvailableVatRates(customer.customer_type, customer.vat_number_validated)
const allowedRates = new Set(availableRates.map((r) => r.rate))
const subtotal = items.reduce((sum, item) => sum + item.quantity * item.unit_price, 0)
let vatAmount = 0
for (const item of items) {
const itemRate = item.vat_rate !== undefined ? item.vat_rate : vatRules.rate
if (!allowedRates.has(itemRate)) {
return { error: `Momssats ${itemRate}% är inte tillåten för denna kundtyp`, status: 400 }
}
const lineTotal = item.quantity * item.unit_price
vatAmount += Math.round(lineTotal * itemRate / 100 * 100) / 100
}
const total = subtotal + vatAmount
const currency = ((params.currency as string) || 'SEK') as Currency
// Exchange rate
let exchangeRate: number | null = null
let exchangeRateDate: string | null = null
let subtotalSek: number | null = null
let vatAmountSek: number | null = null
let totalSek: number | null = null
if (currency !== 'SEK') {
const rateData = await fetchExchangeRate(currency)
if (rateData) {
exchangeRate = rateData.rate
exchangeRateDate = rateData.date
subtotalSek = convertToSEK(subtotal, exchangeRate)
vatAmountSek = convertToSEK(vatAmount, exchangeRate)
totalSek = convertToSEK(total, exchangeRate)
}
}
// Mixed-rate detection
const uniqueRates = new Set(items.map((item) => item.vat_rate ?? vatRules.rate))
const isMixedRate = uniqueRates.size > 1
// Generate invoice number
const { data: invoiceNumber } = await supabase.rpc('generate_invoice_number', {
p_user_id: userId,
})
// Create invoice
const { data: invoice, error: invoiceError } = await supabase
.from('invoices')
.insert({
user_id: userId,
customer_id: customerId,
invoice_number: invoiceNumber,
invoice_date: (params.invoice_date as string) || new Date().toISOString().split('T')[0],
due_date: (params.due_date as string) || null,
currency,
exchange_rate: exchangeRate,
exchange_rate_date: exchangeRateDate,
subtotal,
subtotal_sek: subtotalSek,
vat_amount: vatAmount,
vat_amount_sek: vatAmountSek,
total,
total_sek: totalSek,
vat_treatment: vatRules.treatment,
vat_rate: isMixedRate ? null : (uniqueRates.values().next().value ?? vatRules.rate),
moms_ruta: vatRules.momsRuta,
reverse_charge_text: vatRules.reverseChargeText || null,
our_reference: (params.our_reference as string) || null,
your_reference: (params.your_reference as string) || null,
notes: (params.notes as string) || null,
})
.select()
.single()
if (invoiceError) {
return { error: invoiceError.message, status: 500 }
}
// Create invoice items
const invoiceItems = items.map((item, index) => {
const itemRate = item.vat_rate !== undefined ? item.vat_rate : vatRules.rate
const lineTotal = item.quantity * item.unit_price
const itemVat = Math.round(lineTotal * itemRate / 100 * 100) / 100
return {
invoice_id: invoice.id,
sort_order: index,
description: item.description,
quantity: item.quantity,
unit: item.unit,
unit_price: item.unit_price,
line_total: lineTotal,
vat_rate: itemRate,
vat_amount: itemVat,
}
})
const { error: itemsError } = await supabase
.from('invoice_items')
.insert(invoiceItems)
if (itemsError) {
// Rollback invoice
await supabase.from('invoices').delete().eq('id', invoice.id)
return { error: itemsError.message, status: 500 }
}
// Fetch complete invoice
const { data: completeInvoice } = await supabase
.from('invoices')
.select('*, customer:customers(*), items:invoice_items(*)')
.eq('id', invoice.id)
.single()
if (completeInvoice) {
await eventBus.emit({
type: 'invoice.created',
payload: { invoice: completeInvoice as Invoice, userId },
})
}
return { data: { invoice_id: invoice.id, invoice_number: invoiceNumber } }
}
async function commitMarkInvoicePaid(
supabase: Awaited<ReturnType<typeof createClient>>,
userId: string,
params: Record<string, unknown>
): Promise<{ data?: Record<string, unknown>; error?: string; status?: number }> {
const invoiceId = params.invoice_id as string
const paymentDate = (params.payment_date as string) || new Date().toISOString().split('T')[0]
const { data: invoice, error: invoiceError } = await supabase
.from('invoices')
.select('*, customer:customers(*), items:invoice_items(*)')
.eq('id', invoiceId)
.eq('user_id', userId)
.single()
if (invoiceError || !invoice) return { error: 'Invoice not found', status: 404 }
if (invoice.status !== 'sent' && invoice.status !== 'overdue') {
return { error: 'Invoice can only be marked as paid when status is "sent" or "overdue"', status: 409 }
}
const { data: settings } = await supabase
.from('company_settings')
.select('accounting_method, entity_type')
.eq('user_id', userId)
.single()
const accountingMethod = settings?.accounting_method || 'accrual'
const entityType = (settings?.entity_type as EntityType) || 'enskild_firma'
const isRealInvoice = !invoice.document_type || invoice.document_type === 'invoice'
let journalEntryId: string | null = null
if (isRealInvoice) {
if (accountingMethod === 'accrual') {
const je = await createInvoicePaymentJournalEntry(
supabase, userId, invoice as Invoice, paymentDate, undefined, invoice.customer?.name
)
journalEntryId = je?.id ?? null
} else {
const je = await createInvoiceCashEntry(
supabase, userId, invoice as Invoice, paymentDate, entityType, invoice.customer?.name
)
journalEntryId = je?.id ?? null
}
}
const now = new Date().toISOString()
const { error: updateError } = await supabase
.from('invoices')
.update({ status: 'paid', paid_at: now, paid_amount: invoice.total })
.eq('id', invoiceId)
.eq('user_id', userId)
if (updateError) return { error: 'Failed to update invoice status', status: 500 }
return { data: { status: 'paid', journal_entry_id: journalEntryId } }
}
async function commitSendInvoice(
supabase: Awaited<ReturnType<typeof createClient>>,
userId: string,
params: Record<string, unknown>,
userEmail?: string
): Promise<{ data?: Record<string, unknown>; error?: string; status?: number }> {
const invoiceId = params.invoice_id as string
const emailService = getEmailService()
if (!emailService.isConfigured()) {
return { error: 'Email service not configured', status: 500 }
}
const { data: invoice, error: invoiceError } = await supabase
.from('invoices')
.select('*, customer:customers(*), items:invoice_items(*)')
.eq('id', invoiceId)
.eq('user_id', userId)
.single()
if (invoiceError || !invoice) return { error: 'Invoice not found', status: 404 }
if (invoice.status === 'sent' || invoice.status === 'paid' || invoice.status === 'overdue') {
return { error: 'Invoice has already been sent', status: 409 }
}
const customer = invoice.customer as Customer
if (!customer.email) return { error: 'Customer has no email address', status: 400 }
const { data: company, error: companyError } = await supabase
.from('company_settings')
.select('*')
.eq('user_id', userId)
.single()
if (companyError || !company) return { error: 'Company settings missing', status: 500 }
const items = (invoice.items as InvoiceItem[]).sort(
(a: InvoiceItem, b: InvoiceItem) => a.sort_order - b.sort_order
)
let originalInvoiceNumber: string | undefined
if (invoice.credited_invoice_id) {
const { data: orig } = await supabase
.from('invoices')
.select('invoice_number')
.eq('id', invoice.credited_invoice_id)
.single()
if (orig) originalInvoiceNumber = orig.invoice_number
}
const pdfBuffer = await renderToBuffer(
InvoicePDF({
invoice: invoice as Invoice,
customer,
items,
company: company as CompanySettings,
originalInvoiceNumber,
})
)
const isCreditNote = !!invoice.credited_invoice_id
const docType = invoice.document_type || 'invoice'
let filename: string
if (isCreditNote) filename = `kreditfaktura-${invoice.invoice_number}.pdf`
else if (docType === 'proforma') filename = `proformafaktura-${invoice.invoice_number}.pdf`
else if (docType === 'delivery_note') filename = `foljesedel-${invoice.invoice_number}.pdf`
else filename = `faktura-${invoice.invoice_number}.pdf`
const ccAddress = company.email || userEmail
const emailData = { invoice: invoice as Invoice, customer, company: company as CompanySettings }
const result = await emailService.sendEmail({
to: customer.email,
cc: ccAddress,
subject: generateInvoiceEmailSubject(emailData),
html: generateInvoiceEmailHtml(emailData),
text: generateInvoiceEmailText(emailData),
replyTo: company.email || undefined,
fromName: company.company_name,
attachments: [{ filename, content: pdfBuffer, contentType: 'application/pdf' }],
})
if (!result.success) return { error: `Failed to send email: ${result.error}`, status: 500 }
await supabase.from('invoices').update({ status: 'sent' }).eq('id', invoiceId).eq('user_id', userId)
const isRealInvoice = !invoice.document_type || invoice.document_type === 'invoice'
let createdJournalEntryId: string | undefined
if (isRealInvoice && (company.accounting_method === 'accrual' || !company.accounting_method)) {
try {
const je = await createInvoiceJournalEntry(
supabase, userId, invoice as Invoice, (company as CompanySettings).entity_type
)
if (je) {
createdJournalEntryId = je.id
await supabase.from('invoices').update({ journal_entry_id: je.id }).eq('id', invoiceId)
}
} catch { /* non-blocking */ }
}
if (isRealInvoice) {
try {
const pdfArrayBuffer = new Uint8Array(pdfBuffer).buffer as ArrayBuffer
await uploadDocument(supabase, userId, {
name: filename,
buffer: pdfArrayBuffer,
type: 'application/pdf',
}, {
upload_source: 'system',
journal_entry_id: createdJournalEntryId,
})
} catch { /* non-blocking */ }
}
await eventBus.emit({ type: 'invoice.sent', payload: { invoice: invoice as Invoice, userId } })
return { data: { message: `Invoice ${invoice.invoice_number} sent to ${customer.email}` } }
}
async function commitMarkInvoiceSent(
supabase: Awaited<ReturnType<typeof createClient>>,
userId: string,
params: Record<string, unknown>
): Promise<{ data?: Record<string, unknown>; error?: string; status?: number }> {
const invoiceId = params.invoice_id as string
const { data: invoice, error: invoiceError } = await supabase
.from('invoices')
.select('*, customer:customers(*), items:invoice_items(*)')
.eq('id', invoiceId)
.eq('user_id', userId)
.single()
if (invoiceError || !invoice) return { error: 'Invoice not found', status: 404 }
if (invoice.status !== 'draft') return { error: 'Only draft invoices can be marked as sent', status: 409 }
const { error: updateError } = await supabase
.from('invoices')
.update({ status: 'sent' })
.eq('id', invoiceId)
.eq('user_id', userId)
if (updateError) return { error: 'Failed to update invoice status', status: 500 }
const { data: settings } = await supabase
.from('company_settings')
.select('accounting_method, entity_type')
.eq('user_id', userId)
.single()
const isRealInvoice = !invoice.document_type || invoice.document_type === 'invoice'
let journalEntryId: string | null = null
if (isRealInvoice && (settings?.accounting_method === 'accrual' || !settings?.accounting_method)) {
try {
const je = await createInvoiceJournalEntry(
supabase, userId, invoice as Invoice,
(settings?.entity_type as EntityType) || 'enskild_firma',
invoice.customer?.name
)
if (je) {
journalEntryId = je.id
await supabase.from('invoices').update({ journal_entry_id: je.id }).eq('id', invoiceId)
}
} catch { /* non-blocking */ }
}
return { data: { status: 'sent', journal_entry_id: journalEntryId } }
}
async function commitMatchTransactionInvoice(
supabase: Awaited<ReturnType<typeof createClient>>,
userId: string,
params: Record<string, unknown>
): Promise<{ data?: Record<string, unknown>; error?: string; status?: number }> {
const transactionId = params.transaction_id as string
const invoiceId = params.invoice_id as string
const { data: transaction, error: txError } = await supabase
.from('transactions')
.select('*')
.eq('id', transactionId)
.eq('user_id', userId)
.single()
if (txError || !transaction) return { error: 'Transaction not found', status: 404 }
if (transaction.amount <= 0) return { error: 'Only income transactions can be matched', status: 400 }
if (transaction.invoice_id) return { error: 'Transaction already linked to an invoice', status: 409 }
const { data: invoice, error: invError } = await supabase
.from('invoices')
.select('*, customer:customers(*), items:invoice_items(*)')
.eq('id', invoiceId)
.eq('user_id', userId)
.single()
if (invError || !invoice) return { error: 'Invoice not found', status: 404 }
if (!['sent', 'overdue', 'partially_paid'].includes(invoice.status)) {
return { error: 'Invoice is not in a matchable state', status: 409 }
}
// Storno conflicting journal entry
if (transaction.journal_entry_id) {
await reverseEntry(supabase, userId, transaction.journal_entry_id)
await supabase.from('transactions').update({ journal_entry_id: null }).eq('id', transactionId)
}
const now = new Date().toISOString()
const paidAmount = transaction.amount
const newPaidAmount = Math.round(((invoice.paid_amount || 0) + paidAmount) * 100) / 100
const currentRemaining = invoice.remaining_amount ?? (invoice.total - (invoice.paid_amount || 0))
const newRemaining = Math.max(0, Math.round((currentRemaining - paidAmount) * 100) / 100)
const isFullyPaid = newRemaining <= 0
const newStatus = isFullyPaid ? 'paid' : 'partially_paid'
const { data: settings } = await supabase
.from('company_settings')
.select('accounting_method, entity_type')
.eq('user_id', userId)
.single()
const accountingMethod = settings?.accounting_method || 'accrual'
const entityType = (settings?.entity_type as EntityType) || 'enskild_firma'
let journalEntryId: string | null = null
try {
if (accountingMethod === 'cash' && isFullyPaid) {
const je = await createInvoiceCashEntry(
supabase, userId, invoice as Invoice, transaction.date, entityType, invoice.customer?.name
)
journalEntryId = je?.id ?? null
} else {
const je = await createInvoicePaymentJournalEntry(
supabase, userId, invoice as Invoice, transaction.date, undefined, invoice.customer?.name, paidAmount
)
journalEntryId = je?.id ?? null
}
} catch (err) {
log.error('Failed to create match journal entry:', err)
}
const { data: updatedRows, error: updateInvError } = await supabase
.from('invoices')
.update({
status: newStatus,
paid_at: isFullyPaid ? now : null,
paid_amount: newPaidAmount,
remaining_amount: newRemaining,
})
.eq('id', invoiceId)
.in('status', ['sent', 'overdue', 'partially_paid'])
.select('id')
if (updateInvError) return { error: 'Failed to update invoice status', status: 500 }
if (!updatedRows || updatedRows.length === 0) {
return { error: 'Invoice has already been fully paid or is no longer matchable', status: 409 }
}
const paymentNotes = (accountingMethod === 'cash' && !isFullyPaid)
? 'Kontantmetoden: intäkt bokförs vid slutbetalning' : null
await supabase.from('invoice_payments').insert({
user_id: userId,
invoice_id: invoiceId,
payment_date: transaction.date,
amount: paidAmount,
currency: invoice.currency,
exchange_rate: invoice.exchange_rate,
journal_entry_id: journalEntryId,
transaction_id: transactionId,
notes: paymentNotes,
})
await supabase
.from('transactions')
.update({
invoice_id: invoiceId,
potential_invoice_id: null,
journal_entry_id: journalEntryId,
is_business: true,
category: 'income_services',
})
.eq('id', transactionId)
try {
await eventBus.emit({
type: 'invoice.match_confirmed',
payload: { invoice: invoice as Invoice, transaction: transaction as Transaction, userId },
})
} catch { /* non-critical */ }
return { data: { invoice_status: newStatus, paid_amount: newPaidAmount, journal_entry_id: journalEntryId } }
}
// ── Route handler ─────────────────────────────────────────────
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { id } = await params
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Fetch the pending operation
const { data: op, error: fetchError } = await supabase
.from('pending_operations')
.select('*')
.eq('id', id)
.eq('user_id', user.id)
.single()
if (fetchError || !op) {
return NextResponse.json({ error: 'Pending operation not found' }, { status: 404 })
}
const pendingOp = op as PendingOperation
if (pendingOp.status !== 'pending') {
return NextResponse.json(
{ error: `Operation already ${pendingOp.status}` },
{ status: 409 }
)
}
// Execute based on operation type
let result: { data?: Record<string, unknown>; error?: string; status?: number }
switch (pendingOp.operation_type) {
case 'categorize_transaction':
result = await commitCategorizeTransaction(supabase, user.id, pendingOp.params)
break
case 'create_customer':
result = await commitCreateCustomer(supabase, user.id, pendingOp.params)
break
case 'create_invoice':
result = await commitCreateInvoice(supabase, user.id, pendingOp.params)
break
case 'mark_invoice_paid':
result = await commitMarkInvoicePaid(supabase, user.id, pendingOp.params)
break
case 'send_invoice':
result = await commitSendInvoice(supabase, user.id, pendingOp.params, user.email)
break
case 'mark_invoice_sent':
result = await commitMarkInvoiceSent(supabase, user.id, pendingOp.params)
break
case 'match_transaction_invoice':
result = await commitMatchTransactionInvoice(supabase, user.id, pendingOp.params)
break
default:
return NextResponse.json({ error: 'Unknown operation type' }, { status: 400 })
}
if (result.error) {
// Auto-reject if the operation can never succeed (404, 409)
if (result.status === 404 || result.status === 409) {
await supabase
.from('pending_operations')
.update({
status: 'rejected',
resolved_at: new Date().toISOString(),
result_data: { auto_rejected: true, reason: result.error },
})
.eq('id', id)
}
return NextResponse.json({ error: result.error }, { status: result.status || 500 })
}
// Mark as committed
await supabase
.from('pending_operations')
.update({
status: 'committed',
resolved_at: new Date().toISOString(),
result_data: result.data || {},
})
.eq('id', id)
return NextResponse.json({ data: result.data })
}
@@ -0,0 +1,70 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import {
createMockRequest,
createMockRouteParams,
parseJsonResponse,
createQueuedMockSupabase,
} from '@/tests/helpers'
const { supabase: mockSupabase, enqueue, enqueueMany, reset } = createQueuedMockSupabase()
vi.mock('@/lib/supabase/server', () => ({
createClient: () => Promise.resolve(mockSupabase),
}))
import { POST } from '../../reject/route'
describe('POST /api/pending-operations/:id/reject', () => {
const mockUser = { id: 'user-1', email: 'test@test.se' }
const routeParams = createMockRouteParams({ id: 'op-1' })
beforeEach(() => {
vi.clearAllMocks()
reset()
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
})
it('returns 401 when not authenticated', async () => {
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } })
const request = createMockRequest('/api/pending-operations/op-1/reject', { method: 'POST' })
const response = await POST(request, routeParams)
const { status } = await parseJsonResponse(response)
expect(status).toBe(401)
})
it('returns 404 when not found', async () => {
enqueue({ data: null, error: { message: 'not found' } })
const request = createMockRequest('/api/pending-operations/op-1/reject', { method: 'POST' })
const response = await POST(request, routeParams)
const { status } = await parseJsonResponse(response)
expect(status).toBe(404)
})
it('returns 409 when already committed', async () => {
enqueue({ data: { id: 'op-1', status: 'committed' } })
const request = createMockRequest('/api/pending-operations/op-1/reject', { method: 'POST' })
const response = await POST(request, routeParams)
const { status, body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(409)
expect(body.error).toContain('already committed')
})
it('rejects successfully', async () => {
enqueueMany([
{ data: { id: 'op-1', status: 'pending' } }, // fetch op
{ data: null, error: null }, // update status
])
const request = createMockRequest('/api/pending-operations/op-1/reject', { method: 'POST' })
const response = await POST(request, routeParams)
const { status, body } = await parseJsonResponse<{ data: { id: string; status: string } }>(response)
expect(status).toBe(200)
expect(body.data.status).toBe('rejected')
})
})
@@ -0,0 +1,52 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
/**
* POST /api/pending-operations/:id/reject
*
* Reject a pending operation. Marks it as rejected without executing.
*/
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { id } = await params
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { data: op, error: fetchError } = await supabase
.from('pending_operations')
.select('id, status')
.eq('id', id)
.eq('user_id', user.id)
.single()
if (fetchError || !op) {
return NextResponse.json({ error: 'Pending operation not found' }, { status: 404 })
}
if (op.status !== 'pending') {
return NextResponse.json(
{ error: `Operation already ${op.status}` },
{ status: 409 }
)
}
const { error: updateError } = await supabase
.from('pending_operations')
.update({
status: 'rejected',
resolved_at: new Date().toISOString(),
})
.eq('id', id)
if (updateError) {
return NextResponse.json({ error: updateError.message }, { status: 500 })
}
return NextResponse.json({ data: { id, status: 'rejected' } })
}
@@ -0,0 +1,96 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import {
createMockRequest,
parseJsonResponse,
createQueuedMockSupabase,
} from '@/tests/helpers'
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
vi.mock('@/lib/supabase/server', () => ({
createClient: () => Promise.resolve(mockSupabase),
}))
import { GET } from '../route'
describe('GET /api/pending-operations', () => {
const mockUser = { id: 'user-1', email: 'test@test.se' }
const sampleOps = [
{
id: 'op-1',
user_id: 'user-1',
operation_type: 'categorize_transaction',
status: 'pending',
title: 'Kategorisera: CLAS OHLSON -523 SEK',
params: { transaction_id: 'tx-1', category: 'expense_office' },
preview_data: { debit_account: '6100', credit_account: '1930', amount: 523 },
result_data: null,
created_at: '2026-03-25T10:00:00Z',
},
{
id: 'op-2',
user_id: 'user-1',
operation_type: 'create_customer',
status: 'pending',
title: 'Ny kund: Acme AB',
params: { name: 'Acme AB', customer_type: 'swedish_business' },
preview_data: { name: 'Acme AB', customer_type: 'swedish_business' },
result_data: null,
created_at: '2026-03-25T10:01:00Z',
},
]
beforeEach(() => {
vi.clearAllMocks()
reset()
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
})
it('returns 401 when not authenticated', async () => {
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } })
const request = createMockRequest('/api/pending-operations')
const response = await GET(request)
const { status, body } = await parseJsonResponse(response)
expect(status).toBe(401)
expect(body).toEqual({ error: 'Unauthorized' })
})
it('returns pending operations', async () => {
enqueue({ data: sampleOps, count: 2 })
const request = createMockRequest('/api/pending-operations')
const response = await GET(request)
const { status, body } = await parseJsonResponse<{
data: typeof sampleOps
count: number
}>(response)
expect(status).toBe(200)
expect(body.data).toHaveLength(2)
expect(body.count).toBe(2)
})
it('filters by status parameter', async () => {
enqueue({ data: [], count: 0 })
const request = createMockRequest('/api/pending-operations', {
searchParams: { status: 'committed' },
})
const response = await GET(request)
const { status } = await parseJsonResponse(response)
expect(status).toBe(200)
})
it('rejects invalid status parameter', async () => {
const request = createMockRequest('/api/pending-operations', {
searchParams: { status: 'invalid' },
})
const response = await GET(request)
const { status } = await parseJsonResponse(response)
expect(status).toBe(400)
})
})
+36
View File
@@ -0,0 +1,36 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { validateQuery } from '@/lib/api/validate'
import { PendingOperationsQuerySchema } from '@/lib/api/schemas'
/**
* GET /api/pending-operations
*
* List pending operations for the authenticated user.
* Query params: status (default: pending), limit, offset
*/
export async function GET(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const result = validateQuery(request, PendingOperationsQuerySchema)
if (!result.success) return result.response
const { status, limit, offset } = result.data
const { data, error, count } = await supabase
.from('pending_operations')
.select('*', { count: 'exact' })
.eq('user_id', user.id)
.eq('status', status)
.order('created_at', { ascending: false })
.range(offset, offset + limit - 1)
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data: data ?? [], count })
}
+18 -7
View File
@@ -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<string, string> = {
ö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 (
<Link
key={item.href}
@@ -442,7 +451,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 (
<Link
key={item.href}
@@ -226,15 +226,15 @@ describe('MCP Receipt Matcher', () => {
// ── 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(
+193 -446
View File
@@ -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<string, unknown>,
previewData: Record<string, unknown>
): Promise<{ staged: true; operation_id: string; message: string; preview: Record<string, unknown> }> {
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<string, unknown>)?.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,
}
)
},
},
+22
View File
@@ -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),
})
@@ -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',
})
)
})
})
+145
View File
@@ -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, unknown>): 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<string, unknown>).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<string, unknown>).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<string, unknown>): Record<string, unknown> {
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<string, unknown>
): Promise<void> {
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<string, unknown>
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<Record<string, unknown>>
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)
}
})
)
}
+2
View File
@@ -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
@@ -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();
@@ -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();
@@ -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'
));
+19
View File
@@ -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<string, unknown>
preview_data: Record<string, unknown>
result_data: Record<string, unknown> | null
created_at: string
resolved_at: string | null
updated_at: string
}
// Onboarding progress for new user checklist
export interface OnboardingProgress {
hasCustomers: boolean
+4
View File
@@ -23,6 +23,10 @@
{
"path": "/api/sandbox/cleanup/cron",
"schedule": "0 4 * * *"
},
{
"path": "/api/events/cleanup/cron",
"schedule": "0 2 * * *"
}
]
}