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 })
}