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