From bb855d2ddcfffc67093a4d95ced0162769a96a14 Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Mon, 4 May 2026 11:12:29 +0200 Subject: [PATCH] Add/ai native supp (#385) * feat(branding): implement dynamic branding in service worker and reports * feat(auth): enhance API key scopes and add bookkeeping write scope - Updated transaction write scope description to include additional tools. - Enhanced reports read scope description to reflect new functionality. - Introduced bookkeeping write scope with relevant description. - Updated SCOPE_GROUPS to include bookkeeping domain. - Modified TOOL_SCOPE_MAP to include new bookkeeping operations. - Updated validateApiKey function to return api_key_id and api_key_name for better actor attribution. feat(tests): add unit tests for MCP resource registry - Created tests for data resources to ensure all required fields are present. - Added tests for resource query parsing and retrieval. feat(resources): implement MCP resources for company and accounting data - Added capabilities resource to expose API key capabilities based on granted scopes. - Implemented chart of accounts resource to retrieve active BAS chart. - Created company current resource to fetch active company details. - Developed active fiscal period resource to check posting eligibility. - Implemented recent activity resource to fetch latest journal entries, invoices, and transactions. - Added VAT treatments resource to provide available VAT rates per customer type. feat(pending-operations): introduce risk tiers for operations - Added risk level classification for pending operations to determine auto-commit eligibility. - Implemented functions to classify operation risk levels and identify high-risk operations. feat(migrations): add actor model and risk tier to pending operations - Updated pending_operations table to include actor type and risk level columns. - Enhanced audit_log to mirror actor information for compliance. - Modified validate_and_increment_api_key function to return actor details. - Expanded operation types in pending_operations to include new high-risk operations. * feat: add auto-commit functionality for low-risk pending operations - Implemented shouldAutoCommit function to determine eligibility for auto-commit based on operation type, actor type, and company settings. - Created commitPendingOperation function to handle execution of pending operations with consistent status updates. - Added tests for shouldAutoCommit to cover various scenarios including high-risk operations, user actors, company opt-in status, and monetary thresholds. - Introduced new columns in company_settings for agent_auto_commit_enabled and agent_auto_commit_max_amount to allow companies to opt-in for auto-commit functionality. - Added SQL migration to update the database schema for new auto-commit settings. * feat(idempotency): implement idempotency key handling for safe retries and cleanup * feat: expand API key scopes and pending operations for bookkeeping - Added 'suppliers:write' scope to API key scopes for supplier invoice management. - Updated SCOPE_GROUPS to include the new 'suppliers:write' scope. - Introduced new pending operation types for bookkeeping: close_period, lock_period, run_year_end, set_opening_balances, run_currency_revaluation, explain_voucher_gap, uncategorize_transaction, approve_supplier_invoice, credit_supplier_invoice, and convert_invoice. - Implemented corresponding commit functions for the new operations in the pending operations module. - Enhanced PendingOperation type to include actor model and risk level attributes. - Added tests for new functionality, ensuring proper behavior and constraints in the database. * feat: implement unlockPeriod functionality and related tests * feat: add agent auto-commit settings and related functionality * feat: add attention resource with comprehensive summary of outstanding tasks * feat: enhance pending operations with 'committing' status and immutability checks, improve idempotency handling, and add original voucher reference for credit notes --- app/(dashboard)/pending/page.tsx | 132 +- app/(dashboard)/settings/api/page.tsx | 8 +- app/api/idempotency/cleanup/cron/route.ts | 31 + .../[id]/commit/__tests__/route.test.ts | 8 +- .../pending-operations/[id]/commit/route.ts | 942 +--------- .../settings/AgentAutoCommitSettings.tsx | 142 ++ .../mcp-server/__tests__/attention.test.ts | 332 ++++ .../__tests__/receipt-matcher.test.ts | 13 +- .../mcp-server/__tests__/resources.test.ts | 66 + .../mcp-server/__tests__/tool-result.test.ts | 43 + .../general/mcp-server/resources/attention.ts | 381 ++++ .../mcp-server/resources/capabilities.ts | 84 + .../mcp-server/resources/chart-of-accounts.ts | 51 + .../mcp-server/resources/company-current.ts | 40 + .../general/mcp-server/resources/index.ts | 32 + .../mcp-server/resources/period-active.ts | 53 + .../mcp-server/resources/recent-activity.ts | 42 + .../general/mcp-server/resources/types.ts | 18 + .../mcp-server/resources/vat-treatments.ts | 38 + extensions/general/mcp-server/server.ts | 1023 ++++++++++- extensions/general/mcp-server/tool-result.ts | 57 + lib/api/__tests__/idempotency.test.ts | 146 ++ lib/api/idempotency.ts | 155 ++ lib/api/schemas.ts | 3 + lib/auth/api-keys.ts | 50 +- lib/bookkeeping/invoice-entries.ts | 19 +- .../__tests__/period-service.test.ts | 52 +- lib/core/bookkeeping/period-service.ts | 68 + .../__tests__/get-structured-error.test.ts | 86 + lib/errors/get-structured-error.ts | 183 ++ lib/events/types.ts | 1 + .../actor-and-auto-commit.pg.test.ts | 378 ++++ .../__tests__/executors.test.ts | 357 ++++ .../__tests__/risk-tiers.test.ts | 56 + .../__tests__/should-auto-commit.test.ts | 154 ++ lib/pending-operations/commit.ts | 1595 +++++++++++++++++ lib/pending-operations/risk-tiers.ts | 62 + lib/pending-operations/should-auto-commit.ts | 139 ++ ...0000_pending_operations_actor_and_risk.sql | 128 ++ ...pending_operations_expand_types_phase2.sql | 41 + ...501120000_company_settings_auto_commit.sql | 22 + .../20260501130000_idempotency_keys.sql | 51 + .../20260504100000_ai_native_hardening.sql | 141 ++ tests/helpers.ts | 2 + types/index.ts | 49 +- vercel.json | 4 + 46 files changed, 6507 insertions(+), 971 deletions(-) create mode 100644 app/api/idempotency/cleanup/cron/route.ts create mode 100644 components/settings/AgentAutoCommitSettings.tsx create mode 100644 extensions/general/mcp-server/__tests__/attention.test.ts create mode 100644 extensions/general/mcp-server/__tests__/resources.test.ts create mode 100644 extensions/general/mcp-server/__tests__/tool-result.test.ts create mode 100644 extensions/general/mcp-server/resources/attention.ts create mode 100644 extensions/general/mcp-server/resources/capabilities.ts create mode 100644 extensions/general/mcp-server/resources/chart-of-accounts.ts create mode 100644 extensions/general/mcp-server/resources/company-current.ts create mode 100644 extensions/general/mcp-server/resources/index.ts create mode 100644 extensions/general/mcp-server/resources/period-active.ts create mode 100644 extensions/general/mcp-server/resources/recent-activity.ts create mode 100644 extensions/general/mcp-server/resources/types.ts create mode 100644 extensions/general/mcp-server/resources/vat-treatments.ts create mode 100644 extensions/general/mcp-server/tool-result.ts create mode 100644 lib/api/__tests__/idempotency.test.ts create mode 100644 lib/api/idempotency.ts create mode 100644 lib/errors/__tests__/get-structured-error.test.ts create mode 100644 lib/errors/get-structured-error.ts create mode 100644 lib/pending-operations/__tests__/actor-and-auto-commit.pg.test.ts create mode 100644 lib/pending-operations/__tests__/executors.test.ts create mode 100644 lib/pending-operations/__tests__/risk-tiers.test.ts create mode 100644 lib/pending-operations/__tests__/should-auto-commit.test.ts create mode 100644 lib/pending-operations/commit.ts create mode 100644 lib/pending-operations/risk-tiers.ts create mode 100644 lib/pending-operations/should-auto-commit.ts create mode 100644 supabase/migrations/20260430120000_pending_operations_actor_and_risk.sql create mode 100644 supabase/migrations/20260430120100_pending_operations_expand_types_phase2.sql create mode 100644 supabase/migrations/20260501120000_company_settings_auto_commit.sql create mode 100644 supabase/migrations/20260501130000_idempotency_keys.sql create mode 100644 supabase/migrations/20260504100000_ai_native_hardening.sql diff --git a/app/(dashboard)/pending/page.tsx b/app/(dashboard)/pending/page.tsx index b434ef9f..368959a1 100644 --- a/app/(dashboard)/pending/page.tsx +++ b/app/(dashboard)/pending/page.tsx @@ -18,6 +18,8 @@ import { Receipt, CheckCircle2, XCircle, + Bot, + Sparkles, } from 'lucide-react' import type { PendingOperation, PendingOperationStatus } from '@/types' @@ -166,14 +168,21 @@ function OperationPreview({ op }: { op: PendingOperation }) { } } +type SourceFilter = 'all' | 'agent' | 'auto_committed' | 'high_risk' + +const AUTO_COMMIT_BANNER_DISMISS_KEY = 'gnubok.pending.autoCommitBannerDismissedAt' + export default function PendingOperationsPage() { const [operations, setOperations] = useState([]) const [isLoading, setIsLoading] = useState(true) const [activeTab, setActiveTab] = useState('pending') + const [sourceFilter, setSourceFilter] = useState('all') const [expandedId, setExpandedId] = useState(null) const [selectedOp, setSelectedOp] = useState(null) const [showCommitDialog, setShowCommitDialog] = useState(false) const [isCommitting, setIsCommitting] = useState(false) + const [recentAutoCommits, setRecentAutoCommits] = useState([]) + const [bannerDismissedAt, setBannerDismissedAt] = useState(null) const { toast } = useToast() const { dialogProps, confirm } = useDestructiveConfirm() @@ -193,6 +202,38 @@ export default function PendingOperationsPage() { fetchOperations() }, [fetchOperations]) + useEffect(() => { + const stored = typeof window !== 'undefined' + ? window.localStorage.getItem(AUTO_COMMIT_BANNER_DISMISS_KEY) + : null + setBannerDismissedAt(stored ? Number(stored) : null) + + fetch('/api/pending-operations?status=committed&limit=50') + .then((r) => r.json()) + .then((json) => { + const ops: PendingOperation[] = json.data ?? [] + const cutoff = Date.now() - 24 * 60 * 60 * 1000 + setRecentAutoCommits( + ops.filter((o) => o.auto_committed_at && new Date(o.auto_committed_at).getTime() > cutoff) + ) + }) + .catch(() => { /* silent */ }) + }, []) + + function dismissAutoCommitBanner() { + const now = Date.now() + setBannerDismissedAt(now) + if (typeof window !== 'undefined') { + window.localStorage.setItem(AUTO_COMMIT_BANNER_DISMISS_KEY, String(now)) + } + } + + const newAutoCommits = recentAutoCommits.filter((o) => + bannerDismissedAt == null || + (o.auto_committed_at != null && + new Date(o.auto_committed_at).getTime() > bannerDismissedAt) + ) + async function handleCommit() { if (!selectedOp) return setIsCommitting(true) @@ -239,6 +280,20 @@ export default function PendingOperationsPage() { create_invoice: '', } + const filteredOperations = operations.filter((op) => { + switch (sourceFilter) { + case 'agent': + return op.actor_type === 'api_key' || op.actor_type === 'mcp_oauth' || op.actor_type === 'cron' + case 'auto_committed': + return Boolean(op.auto_committed_at) + case 'high_risk': + return op.risk_level === 'high' + case 'all': + default: + return true + } + }) + return (
+ {newAutoCommits.length > 0 && ( + + +
+ +
+

+ {newAutoCommits.length === 1 + ? '1 åtgärd auto-godkändes' + : `${newAutoCommits.length} åtgärder auto-godkändes`}{' '} + senaste dygnet +

+

+ Granska vad agenten utförde utan din direkta godkännande. +

+
+
+
+ + +
+
+
+ )} + setActiveTab(v as PendingOperationStatus)}> Väntande @@ -254,13 +352,22 @@ export default function PendingOperationsPage() { + setSourceFilter(v as SourceFilter)}> + + Alla + Från agent + Auto-godkända + Hög risk + + + {isLoading ? ( - ) : operations.length === 0 ? ( + ) : filteredOperations.length === 0 ? (
@@ -282,7 +389,7 @@ export default function PendingOperationsPage() { ) : (
- {operations.map((op) => { + {filteredOperations.map((op) => { const config = operationLabels[op.operation_type] || { label: op.operation_type, icon: ClipboardCheck, variant: 'default' as const } const isExpanded = expandedId === op.id @@ -297,9 +404,26 @@ export default function PendingOperationsPage() { onClick={() => setExpandedId(isExpanded ? null : op.id)} >
-
+
{config.label} - {op.status === 'committed' && ( + {op.risk_level === 'high' && ( + + Hög risk + + )} + {op.actor_type && op.actor_type !== 'user' && ( + + + {op.actor_label || op.actor_type} + + )} + {op.auto_committed_at && ( + + + Auto-godkänd + + )} + {op.status === 'committed' && !op.auto_committed_at && ( Godkänd diff --git a/app/(dashboard)/settings/api/page.tsx b/app/(dashboard)/settings/api/page.tsx index 351bf38c..3971b84d 100644 --- a/app/(dashboard)/settings/api/page.tsx +++ b/app/(dashboard)/settings/api/page.tsx @@ -1,7 +1,13 @@ 'use client' import { ApiKeysPanel } from '@/components/settings/ApiKeysPanel' +import { AgentAutoCommitSettings } from '@/components/settings/AgentAutoCommitSettings' export default function ApiSettingsPage() { - return + return ( +
+ + +
+ ) } diff --git a/app/api/idempotency/cleanup/cron/route.ts b/app/api/idempotency/cleanup/cron/route.ts new file mode 100644 index 00000000..32f12af9 --- /dev/null +++ b/app/api/idempotency/cleanup/cron/route.ts @@ -0,0 +1,31 @@ +import { createServiceClient } from '@/lib/supabase/server' +import { NextResponse } from 'next/server' +import { cleanupExpiredIdempotencyKeys } from '@/lib/api/idempotency' + +/** + * GET /api/idempotency/cleanup/cron + * + * Sweeps idempotency_keys rows past their 24h TTL. Cron runs hourly so the + * working set stays small even under heavy agent retry traffic. + */ +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 deleted = await cleanupExpiredIdempotencyKeys(supabase) + console.log(`Idempotency keys cleanup completed: ${deleted} rows removed`) + return NextResponse.json({ success: true, deleted }) + } catch (error) { + console.error('Error in idempotency cleanup cron:', error) + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Failed to clean up idempotency keys' }, + { status: 500 } + ) + } +} diff --git a/app/api/pending-operations/[id]/commit/__tests__/route.test.ts b/app/api/pending-operations/[id]/commit/__tests__/route.test.ts index 5bfc0bb2..225b2b44 100644 --- a/app/api/pending-operations/[id]/commit/__tests__/route.test.ts +++ b/app/api/pending-operations/[id]/commit/__tests__/route.test.ts @@ -92,13 +92,14 @@ describe('POST /api/pending-operations/:id/commit', () => { preview_data: {}, }, }) + enqueue({ data: null, error: null }) // CAS UPDATE returns 0 rows since status != 'pending' 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') + expect(body.error).toMatch(/already (committed|claimed|resolved)/i) }) describe('categorize_transaction', () => { @@ -122,6 +123,7 @@ describe('POST /api/pending-operations/:id/commit', () => { enqueueMany([ { data: pendingOp }, // fetch pending op + { data: { id: 'op-1' } }, // CAS claim { data: tx }, // fetch transaction { data: settings }, // fetch company settings { data: [{ id: 'fp-1' }] }, // fiscal period check @@ -144,6 +146,7 @@ describe('POST /api/pending-operations/:id/commit', () => { enqueueMany([ { data: pendingOp }, // fetch pending op + { data: { id: 'op-1' } }, // CAS claim { data: tx }, // fetch transaction (already has JE) { data: null, error: null }, // auto-reject update ]) @@ -175,6 +178,7 @@ describe('POST /api/pending-operations/:id/commit', () => { it('commits successfully', async () => { enqueueMany([ { data: pendingOp }, // fetch pending op + { data: { id: 'op-1' } }, // CAS claim { data: { id: 'cust-1', name: 'Acme AB' } }, // insert customer { data: null, error: null }, // update pending op status ]) @@ -216,6 +220,7 @@ describe('POST /api/pending-operations/:id/commit', () => { enqueueMany([ { data: pendingOp }, // fetch pending op + { data: { id: 'op-1' } }, // CAS claim { data: customer }, // fetch customer { data: { id: 'inv-1', invoice_number: null } }, // insert invoice (no number — assigned at send) { data: null, error: null }, // insert items @@ -236,6 +241,7 @@ describe('POST /api/pending-operations/:id/commit', () => { it('returns 404 when customer not found', async () => { enqueueMany([ { data: pendingOp }, // fetch pending op + { data: { id: 'op-1' } }, // CAS claim { data: null, error: { message: 'not found' } }, // customer not found { data: null, error: null }, // auto-reject update ]) diff --git a/app/api/pending-operations/[id]/commit/route.ts b/app/api/pending-operations/[id]/commit/route.ts index 282d5fa3..d0273160 100644 --- a/app/api/pending-operations/[id]/commit/route.ts +++ b/app/api/pending-operations/[id]/commit/route.ts @@ -1,871 +1,16 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' -import { eventBus } from '@/lib/events' import { ensureInitialized } from '@/lib/init' import { requireCompanyId } from '@/lib/company/context' import { requireWritePermission } from '@/lib/auth/require-write' -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 { - AccountsNotInChartError, - bookkeepingErrorResponse, - isBookkeepingError, -} from '@/lib/bookkeeping/errors' -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 { ensureInvoiceNumber } from '@/lib/invoices/ensure-invoice-number' -import { createLogger } from '@/lib/logger' -import { appendProcessingHistory } from '@/lib/processing-history/append' -import type { - Transaction, - TransactionCategory, - EntityType, - VatTreatment, - Currency, - Invoice, - Customer, - PendingOperation, - CompanySettings, - InvoiceItem, -} from '@/types' - -const log = createLogger('pending-operations/commit') +import { commitPendingOperation } from '@/lib/pending-operations/commit' +import { bookkeepingErrorResponse } from '@/lib/bookkeeping/errors' +import type { PendingOperation } from '@/types' ensureInitialized() -/** - * Record a best-effort processing_history breadcrumb when the invoice's - * accrual journal entry couldn't be booked. The pending-operation itself - * still succeeds (invoice email delivered / status set), but the JE is - * missing — which in the accrual case means revenue (3001) and utgående - * moms (2611) are unposted for the period, understating the momsdeklaration. - * - * The event makes the gap visible and actionable: an operator or bokföring - * consultant can query processing_history for `InvoiceJournalEntrySkipped` - * and re-book the missing verifikation (via the activation dialog or - * manually) before the momsdeklaration is filed. Swallows its own errors - * to preserve the non-blocking contract with the caller. - */ -async function recordSkippedInvoiceJournalEntry( - invoiceId: string, - companyId: string, - userId: string, - operation: 'send_invoice' | 'mark_invoice_sent', - err: unknown -): Promise { - try { - const reasonCode = err instanceof AccountsNotInChartError - ? 'accounts_not_in_chart' - : 'journal_entry_error' - const accountNumbers = err instanceof AccountsNotInChartError ? err.accountNumbers : undefined - await appendProcessingHistory({ - companyId, - correlationId: invoiceId, - aggregateType: 'System', - aggregateId: invoiceId, - eventType: 'InvoiceJournalEntrySkipped', - payload: { - invoice_id: invoiceId, - operation, - reason_code: reasonCode, - ...(accountNumbers ? { account_numbers: accountNumbers } : {}), - }, - actor: { type: 'user', id: userId }, - occurredAt: new Date(), - }) - } catch (historyErr) { - log.warn('Failed to append InvoiceJournalEntrySkipped to processing_history', historyErr) - } -} - -/** - * Ensure a fiscal period exists for the given date, create one if needed. - * Same logic as app/api/transactions/[id]/categorize/route.ts - */ -async function ensureFiscalPeriod( - supabase: Awaited>, - userId: string, - companyId: string, - date: string, - fiscalYearStartMonth: number = 1 -): Promise { - const { data: existing } = await supabase - .from('fiscal_periods') - .select('id') - .eq('company_id', companyId) - .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, - company_id: companyId, - name: periodName, - period_start: periodStart, - period_end: periodEnd, - }, { onConflict: 'user_id,period_start,period_end' }) - - if (error) { - log.error('Failed to create fiscal period:', error) - return false - } - return true -} - -// ── Commit executors ────────────────────────────────────────── - -async function commitCategorizeTransaction( - supabase: Awaited>, - userId: string, - companyId: string, - params: Record -): Promise<{ data?: Record; error?: string; status?: number }> { - const txId = params.transaction_id as string - const category = params.category as TransactionCategory - const vatTreatment = params.vat_treatment as VatTreatment | undefined - - // Fetch transaction — guard against double-commit - const { data: transaction, error: fetchError } = await supabase - .from('transactions') - .select('*') - .eq('id', txId) - .eq('company_id', companyId) - .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('company_id', companyId) - .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, companyId, transaction.date, fiscalYearStartMonth) - - // Create journal entry - let journalEntryId: string | null = null - try { - const journalEntry = await createTransactionJournalEntry( - supabase, companyId, userId, transaction as Transaction, mappingResult - ) - if (journalEntry) { - journalEntryId = journalEntry.id - } - } catch (err) { - if (isBookkeepingError(err)) throw 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, - companyId, - }, - }) - - return { data: { journal_entry_id: journalEntryId, category } } -} - -async function commitCreateCustomer( - supabase: Awaited>, - userId: string, - companyId: string, - params: Record -): Promise<{ data?: Record; error?: string; status?: number }> { - const { data, error } = await supabase - .from('customers') - .insert({ - user_id: userId, - company_id: companyId, - 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('company_id', companyId) - } - } catch (err) { - log.warn('Auto-VIES validation failed:', err) - } - } - - await eventBus.emit({ - type: 'customer.created', - payload: { customer: data as Customer, userId, companyId }, - }) - - return { data: { customer_id: data.id } } -} - -async function commitCreateInvoice( - supabase: Awaited>, - userId: string, - companyId: string, - params: Record -): Promise<{ data?: Record; error?: string; status?: number }> { - const customerId = params.customer_id as string - const items = params.items as Array<{ - description: string - quantity: number - unit: string - unit_price: number - vat_rate?: number - }> - - // Fetch customer - const { data: customer, error: customerError } = await supabase - .from('customers') - .select('*') - .eq('id', customerId) - .eq('company_id', companyId) - .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 - - // Invoice number is assigned later when the draft is sent — leave null here - // so a discarded draft never consumes a number. - - // Create invoice - const { data: invoice, error: invoiceError } = await supabase - .from('invoices') - .insert({ - user_id: userId, - company_id: companyId, - customer_id: customerId, - invoice_number: null, - 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, companyId }, - }) - } - - return { data: { invoice_id: invoice.id, invoice_number: invoice.invoice_number } } -} - -async function commitMarkInvoicePaid( - supabase: Awaited>, - userId: string, - companyId: string, - params: Record -): Promise<{ data?: Record; error?: string; status?: number }> { - const invoiceId = params.invoice_id as string - const paymentDate = (params.payment_date as string) || new Date().toISOString().split('T')[0] - - const { data: invoice, error: invoiceError } = await supabase - .from('invoices') - .select('*, customer:customers(*), items:invoice_items(*)') - .eq('id', invoiceId) - .eq('company_id', companyId) - .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('company_id', companyId) - .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, companyId, userId, invoice as Invoice, paymentDate, undefined, invoice.customer?.name - ) - journalEntryId = je?.id ?? null - } else { - const je = await createInvoiceCashEntry( - supabase, companyId, 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('company_id', companyId) - - if (updateError) return { error: 'Failed to update invoice status', status: 500 } - - return { data: { status: 'paid', journal_entry_id: journalEntryId } } -} - -async function commitSendInvoice( - supabase: Awaited>, - userId: string, - companyId: string, - params: Record, - userEmail?: string -): Promise<{ data?: Record; error?: string; status?: number }> { - const invoiceId = params.invoice_id as string - - const emailService = getEmailService() - if (!emailService.isConfigured()) { - return { error: 'Email service not configured', status: 500 } - } - - const { data: invoice, error: invoiceError } = await supabase - .from('invoices') - .select('*, customer:customers(*), items:invoice_items(*)') - .eq('id', invoiceId) - .eq('company_id', companyId) - .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('company_id', companyId) - .single() - - if (companyError || !company) return { error: 'Company settings missing', status: 500 } - - // Assign invoice number now if this draft doesn't have one yet — - // mutates `invoice.invoice_number` so PDF, email, JE all see it. - try { - await ensureInvoiceNumber(supabase, companyId, invoice as Invoice) - } catch (err) { - return { error: `Failed to assign invoice number: ${err instanceof Error ? err.message : 'unknown'}`, 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.trade_name || 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('company_id', companyId) - - 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, companyId, 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 (err) { - // Non-blocking: the invoice is already sent by this point and the user - // can retry the journal entry separately. Re-throwing here would mean - // the email has already gone out but the outer 400 would report - // failure — worst of both worlds. - // - // Record a processing_history breadcrumb so the missing verifikation - // surfaces in audit trails and the momsdeklaration gap is actionable - // rather than silent. TODO: wire ActivateAccountsDialog into this - // pending-op flow, then re-introduce blocking for ACCOUNTS_NOT_IN_CHART. - await recordSkippedInvoiceJournalEntry(invoiceId, companyId, userId, 'send_invoice', err) - } - } - - if (isRealInvoice) { - try { - const pdfArrayBuffer = new Uint8Array(pdfBuffer).buffer as ArrayBuffer - await uploadDocument(supabase, userId, companyId, { - 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, companyId } }) - - return { data: { message: `Invoice ${invoice.invoice_number} sent to ${customer.email}` } } -} - -async function commitMarkInvoiceSent( - supabase: Awaited>, - userId: string, - companyId: string, - params: Record -): Promise<{ data?: Record; error?: string; status?: number }> { - const invoiceId = params.invoice_id as string - - const { data: invoice, error: invoiceError } = await supabase - .from('invoices') - .select('*, customer:customers(*), items:invoice_items(*)') - .eq('id', invoiceId) - .eq('company_id', companyId) - .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 } - - try { - await ensureInvoiceNumber(supabase, companyId, invoice as Invoice) - } catch (err) { - return { error: `Failed to assign invoice number: ${err instanceof Error ? err.message : 'unknown'}`, status: 500 } - } - - const { error: updateError } = await supabase - .from('invoices') - .update({ status: 'sent' }) - .eq('id', invoiceId) - .eq('company_id', companyId) - - 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('company_id', companyId) - .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, companyId, 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 (err) { - // Non-blocking: invoice is already marked as sent. Record a - // processing_history breadcrumb so the missing JE is visible in audit - // trails. TODO: wire ActivateAccountsDialog into this pending-op flow, - // then re-introduce blocking for ACCOUNTS_NOT_IN_CHART here. - await recordSkippedInvoiceJournalEntry(invoiceId, companyId, userId, 'mark_invoice_sent', err) - } - } - - return { data: { status: 'sent', journal_entry_id: journalEntryId } } -} - -async function commitMatchTransactionInvoice( - supabase: Awaited>, - userId: string, - companyId: string, - params: Record -): Promise<{ data?: Record; error?: string; status?: number }> { - const transactionId = params.transaction_id as string - const invoiceId = params.invoice_id as string - - const { data: transaction, error: txError } = await supabase - .from('transactions') - .select('*') - .eq('id', transactionId) - .eq('company_id', companyId) - .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('company_id', companyId) - .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, companyId, 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('company_id', companyId) - .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, companyId, userId, invoice as Invoice, transaction.date, entityType, invoice.customer?.name - ) - journalEntryId = je?.id ?? null - } else { - const je = await createInvoicePaymentJournalEntry( - supabase, companyId, userId, invoice as Invoice, transaction.date, undefined, invoice.customer?.name, paidAmount - ) - journalEntryId = je?.id ?? null - } - } catch (err) { - if (isBookkeepingError(err)) throw 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, - company_id: companyId, - 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, companyId }, - }) - } catch { /* non-critical */ } - - return { data: { invoice_status: newStatus, paid_amount: newPaidAmount, journal_entry_id: journalEntryId } } -} - -// ── Route handler ───────────────────────────────────────────── - export async function POST( - request: Request, + _request: Request, { params }: { params: Promise<{ id: string }> } ) { const supabase = await createClient() @@ -881,7 +26,6 @@ export async function POST( const companyId = await requireCompanyId(supabase, user.id) - // Fetch the pending operation const { data: op, error: fetchError } = await supabase .from('pending_operations') .select('*') @@ -893,75 +37,25 @@ export async function POST( return NextResponse.json({ error: 'Pending operation not found' }, { status: 404 }) } - const pendingOp = op as PendingOperation - - if (pendingOp.status !== 'pending') { - return NextResponse.json( - { error: `Operation already ${pendingOp.status}` }, - { status: 409 } - ) - } - - // Execute based on operation type - let result: { data?: Record; error?: string; status?: number } - try { - switch (pendingOp.operation_type) { - case 'categorize_transaction': - result = await commitCategorizeTransaction(supabase, user.id, companyId, pendingOp.params) - break - case 'create_customer': - result = await commitCreateCustomer(supabase, user.id, companyId, pendingOp.params) - break - case 'create_invoice': - result = await commitCreateInvoice(supabase, user.id, companyId, pendingOp.params) - break - case 'mark_invoice_paid': - result = await commitMarkInvoicePaid(supabase, user.id, companyId, pendingOp.params) - break - case 'send_invoice': - result = await commitSendInvoice(supabase, user.id, companyId, pendingOp.params, user.email) - break - case 'mark_invoice_sent': - result = await commitMarkInvoiceSent(supabase, user.id, companyId, pendingOp.params) - break - case 'match_transaction_invoice': - result = await commitMatchTransactionInvoice(supabase, user.id, companyId, pendingOp.params) - break - default: - return NextResponse.json({ error: 'Unknown operation type' }, { status: 400 }) + const result = await commitPendingOperation( + supabase, + user.id, + companyId, + op as PendingOperation, + { userEmail: user.email } + ) + + if (result.status === 'committed') { + return NextResponse.json({ data: result.data }) } + return NextResponse.json( + { error: result.error }, + { status: result.http_status ?? 500 } + ) } catch (err) { const typed = bookkeepingErrorResponse(err) if (typed) return typed throw err } - - if (result.error) { - // Auto-reject if the operation can never succeed (404, 409) - if (result.status === 404 || result.status === 409) { - await supabase - .from('pending_operations') - .update({ - status: 'rejected', - resolved_at: new Date().toISOString(), - result_data: { auto_rejected: true, reason: result.error }, - }) - .eq('id', id) - } - - return NextResponse.json({ error: result.error }, { status: result.status || 500 }) - } - - // Mark as committed - await supabase - .from('pending_operations') - .update({ - status: 'committed', - resolved_at: new Date().toISOString(), - result_data: result.data || {}, - }) - .eq('id', id) - - return NextResponse.json({ data: result.data }) } diff --git a/components/settings/AgentAutoCommitSettings.tsx b/components/settings/AgentAutoCommitSettings.tsx new file mode 100644 index 00000000..ad248249 --- /dev/null +++ b/components/settings/AgentAutoCommitSettings.tsx @@ -0,0 +1,142 @@ +'use client' + +import { useState, useEffect } from 'react' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Switch } from '@/components/ui/switch' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Button } from '@/components/ui/button' +import { useToast } from '@/components/ui/use-toast' +import { Loader2, Sparkles } from 'lucide-react' + +export function AgentAutoCommitSettings() { + const { toast } = useToast() + const [enabled, setEnabled] = useState(false) + const [maxAmount, setMaxAmount] = useState('') + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + + useEffect(() => { + let cancelled = false + fetch('/api/settings') + .then((r) => r.json()) + .then((body) => { + if (cancelled) return + const data = body?.data ?? {} + setEnabled(Boolean(data.agent_auto_commit_enabled)) + setMaxAmount( + data.agent_auto_commit_max_amount != null + ? String(data.agent_auto_commit_max_amount) + : '' + ) + setLoading(false) + }) + .catch(() => { + if (!cancelled) setLoading(false) + }) + return () => { + cancelled = true + } + }, []) + + async function save() { + setSaving(true) + const parsedMax = maxAmount.trim() === '' ? null : Number(maxAmount) + if (parsedMax !== null && (Number.isNaN(parsedMax) || parsedMax < 0)) { + toast({ title: 'Ogiltigt belopp', description: 'Ange ett positivt tal eller lämna fältet tomt.', variant: 'destructive' }) + setSaving(false) + return + } + + const res = await fetch('/api/settings', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + agent_auto_commit_enabled: enabled, + agent_auto_commit_max_amount: parsedMax, + }), + }) + + setSaving(false) + + if (!res.ok) { + const body = await res.json().catch(() => ({})) + toast({ + title: 'Kunde inte spara', + description: body?.error ?? 'Försök igen.', + variant: 'destructive', + }) + return + } + + toast({ title: 'Sparat', description: 'Inställningar för auto-godkännande uppdaterade.' }) + } + + return ( + + + + + Auto-godkännande för agenter + + + När detta är aktivt får betrodda agenter (API-nycklar och Claude Desktop via OAuth) + köra åtgärder med låg risk utan din godkännande. Hög-risk-åtgärder (periodlåsning, + bokslut, fakturautskick m.m.) kräver alltid manuell granskning oavsett inställning. + + + + {loading ? ( +
+ Laddar… +
+ ) : ( + <> +
+ +
+ +

+ Endast åtgärder klassificerade som låg risk (t.ex. skapa kund) körs + automatiskt. Hög-risk är alltid stoppad och hamnar i kön för granskning. +

+
+
+ +
+ + setMaxAmount(e.target.value)} + disabled={!enabled} + /> +

+ Lämna tomt för ingen gräns. Åtgärder över beloppet faller tillbaka till manuell + granskning. +

+
+ +
+ +
+ + )} +
+
+ ) +} diff --git a/extensions/general/mcp-server/__tests__/attention.test.ts b/extensions/general/mcp-server/__tests__/attention.test.ts new file mode 100644 index 00000000..8fecf587 --- /dev/null +++ b/extensions/general/mcp-server/__tests__/attention.test.ts @@ -0,0 +1,332 @@ +import { describe, it, expect } from 'vitest' +import { createQueuedMockSupabase } from '@/tests/helpers' +import { attentionResource } from '../resources/attention' + +type AttentionResponse = { + generated_at: string + summary: { total_items: number; critical: number; warning: number; info: number } + categories: Array<{ + key: string + severity: 'critical' | 'warning' | 'info' + count: number + samples: Array> + next?: { description: string; tool?: string; args?: Record; resource?: string } + }> +} + +const ctx = (supabase: ReturnType['supabase']) => ({ + supabase: supabase as never, + companyId: 'company-1', + userId: 'user-1', + scopes: [], +}) + +/** + * Enqueues 14 baseline empty results in the order the resource consumes them. + * Tests can override individual slots before invoking by enqueueing in advance. + */ +function enqueueEmpty(enqueue: (r: { data?: unknown; error?: unknown; count?: number | null }) => void) { + // 1. unbookedHead + enqueue({ count: 0 }) + // 2. unbookedSamples + enqueue({ data: [] }) + // 3. overdueRows + enqueue({ data: [] }) + // 4. pendingSupplierHead + enqueue({ count: 0 }) + // 5. pendingSupplierSamples + enqueue({ data: [] }) + // 6. pendingOpsHead + enqueue({ count: 0 }) + // 7. pendingOpsSamples + enqueue({ data: [] }) + // 8. unmatchedReceiptsHead + enqueue({ count: 0 }) + // 9. unmatchedReceiptsSamples + enqueue({ data: [] }) + // 10. voucherSeriesRows + enqueue({ data: [] }) + // 11. deadlineRows + enqueue({ data: [] }) + // 12. bankConnRows + enqueue({ data: [] }) + // 13. activePeriodRow + enqueue({ data: null }) + // 14. companySettingsRow + enqueue({ data: null }) +} + +describe('gnubok://attention', () => { + it('returns empty summary for a brand-new company', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueueEmpty(enqueue) + + const result = (await attentionResource.read(ctx(supabase))) as AttentionResponse + + expect(result.generated_at).toMatch(/^\d{4}-\d{2}-\d{2}T/) + expect(result.summary).toEqual({ total_items: 0, critical: 0, warning: 0, info: 0 }) + expect(result.categories).toEqual([]) + }) + + it('classifies recently-unbooked transactions as warning', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + const today = new Date().toISOString().slice(0, 10) + const txns = [ + { id: 't-1', date: today, amount: -100, currency: 'SEK', description: 'Lunch', merchant_name: 'Café' }, + { id: 't-2', date: today, amount: -200, currency: 'SEK', description: 'Office', merchant_name: 'Clas Ohlson' }, + ] + + enqueue({ count: 2 }) // unbookedHead + enqueue({ data: txns }) // unbookedSamples + enqueue({ data: [] }) // overdueRows + enqueue({ count: 0 }) // pendingSupplierHead + enqueue({ data: [] }) // pendingSupplierSamples + enqueue({ count: 0 }) // pendingOpsHead + enqueue({ data: [] }) // pendingOpsSamples + enqueue({ count: 0 }) // unmatchedReceiptsHead + enqueue({ data: [] }) // unmatchedReceiptsSamples + enqueue({ data: [] }) // voucherSeriesRows + enqueue({ data: [] }) // deadlineRows + enqueue({ data: [] }) // bankConnRows + enqueue({ data: null }) // activePeriodRow + enqueue({ data: null }) // companySettingsRow + + const result = (await attentionResource.read(ctx(supabase))) as AttentionResponse + + expect(result.categories).toHaveLength(1) + const cat = result.categories[0] + expect(cat.key).toBe('unbooked_transactions') + expect(cat.severity).toBe('warning') + expect(cat.count).toBe(2) + expect(cat.samples).toEqual(txns) + expect(cat.next?.tool).toBe('gnubok_categorize_transaction') + expect(cat.next?.args).toEqual({ transaction_id: 't-1' }) + expect(result.summary).toEqual({ total_items: 2, critical: 0, warning: 1, info: 0 }) + }) + + it('escalates unbooked transactions to critical when oldest is > 30 days old', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + const fortyDaysAgo = new Date(Date.now() - 40 * 86_400_000).toISOString().slice(0, 10) + const txns = [{ id: 't-old', date: fortyDaysAgo, amount: -100, currency: 'SEK', description: 'X', merchant_name: null }] + + enqueue({ count: 1 }) + enqueue({ data: txns }) + enqueue({ data: [] }) + enqueue({ count: 0 }) + enqueue({ data: [] }) + enqueue({ count: 0 }) + enqueue({ data: [] }) + enqueue({ count: 0 }) + enqueue({ data: [] }) + enqueue({ data: [] }) + enqueue({ data: [] }) + enqueue({ data: [] }) + enqueue({ data: null }) + enqueue({ data: null }) + + const result = (await attentionResource.read(ctx(supabase))) as AttentionResponse + expect(result.categories[0]?.severity).toBe('critical') + expect(result.summary.critical).toBe(1) + }) + + it('flags overdue invoices as critical when any are > 30 days past due', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + const fortyDaysAgo = new Date(Date.now() - 40 * 86_400_000).toISOString().slice(0, 10) + const tenDaysAgo = new Date(Date.now() - 10 * 86_400_000).toISOString().slice(0, 10) + const overdue = [ + { id: 'i-1', invoice_number: 'F-2024001', customer_id: 'c-1', due_date: fortyDaysAgo, total: 1000, currency: 'SEK', status: 'overdue' }, + { id: 'i-2', invoice_number: 'F-2024002', customer_id: 'c-1', due_date: tenDaysAgo, total: 500, currency: 'SEK', status: 'sent' }, + ] + + enqueue({ count: 0 }) + enqueue({ data: [] }) + enqueue({ data: overdue }) + enqueue({ count: 0 }) + enqueue({ data: [] }) + enqueue({ count: 0 }) + enqueue({ data: [] }) + enqueue({ count: 0 }) + enqueue({ data: [] }) + enqueue({ data: [] }) + enqueue({ data: [] }) + enqueue({ data: [] }) + enqueue({ data: null }) + enqueue({ data: null }) + + const result = (await attentionResource.read(ctx(supabase))) as AttentionResponse + const cat = result.categories.find((c) => c.key === 'overdue_invoices') + expect(cat?.severity).toBe('critical') + expect(cat?.count).toBe(2) + }) + + it('marks pending operations as critical when any high-risk op is queued', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + const ops = [ + { id: 'op-1', operation_type: 'close_period', title: 'Stäng FY2025', risk_level: 'high', actor_label: 'Claude', created_at: new Date().toISOString() }, + { id: 'op-2', operation_type: 'create_customer', title: 'Ny kund', risk_level: 'low', actor_label: 'Claude', created_at: new Date().toISOString() }, + ] + + enqueue({ count: 0 }) + enqueue({ data: [] }) + enqueue({ data: [] }) + enqueue({ count: 0 }) + enqueue({ data: [] }) + enqueue({ count: 2 }) + enqueue({ data: ops }) + enqueue({ count: 0 }) + enqueue({ data: [] }) + enqueue({ data: [] }) + enqueue({ data: [] }) + enqueue({ data: [] }) + enqueue({ data: null }) + enqueue({ data: null }) + + const result = (await attentionResource.read(ctx(supabase))) as AttentionResponse + const cat = result.categories.find((c) => c.key === 'pending_operations') + expect(cat?.severity).toBe('critical') + expect(cat?.count).toBe(2) + expect(result.summary.critical).toBe(1) + }) + + it('flags voucher gaps as critical and includes next tool args', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + const seriesRows = [{ voucher_series: 'A', fiscal_period_id: 'fp-1' }] + + enqueue({ count: 0 }) + enqueue({ data: [] }) + enqueue({ data: [] }) + enqueue({ count: 0 }) + enqueue({ data: [] }) + enqueue({ count: 0 }) + enqueue({ data: [] }) + enqueue({ count: 0 }) + enqueue({ data: [] }) + enqueue({ data: seriesRows }) // voucherSeriesRows + enqueue({ data: [] }) // deadlineRows + enqueue({ data: [] }) // bankConnRows + enqueue({ data: null }) // activePeriodRow + enqueue({ data: null }) // companySettingsRow + // Loop body for series 'A': + enqueue({ data: [{ gap_start: 5, gap_end: 7 }] }) // detect_voucher_gaps RPC + enqueue({ data: [] }) // voucher_gap_explanations follow-up + + const result = (await attentionResource.read(ctx(supabase))) as AttentionResponse + const cat = result.categories.find((c) => c.key === 'voucher_gaps_unexplained') + expect(cat?.severity).toBe('critical') + expect(cat?.count).toBe(1) + expect(cat?.next?.tool).toBe('gnubok_explain_voucher_gap') + expect(cat?.next?.args).toEqual({ + fiscal_period_id: 'fp-1', + voucher_series: 'A', + gap_start: 5, + gap_end: 7, + }) + }) + + it('omits voucher_gaps category when all gaps are explained', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + const seriesRows = [{ voucher_series: 'A', fiscal_period_id: 'fp-1' }] + + enqueue({ count: 0 }) + enqueue({ data: [] }) + enqueue({ data: [] }) + enqueue({ count: 0 }) + enqueue({ data: [] }) + enqueue({ count: 0 }) + enqueue({ data: [] }) + enqueue({ count: 0 }) + enqueue({ data: [] }) + enqueue({ data: seriesRows }) + enqueue({ data: [] }) + enqueue({ data: [] }) + enqueue({ data: null }) + enqueue({ data: null }) + enqueue({ data: [{ gap_start: 5, gap_end: 7 }] }) + enqueue({ + data: [{ voucher_series: 'A', gap_start: 5, gap_end: 7, fiscal_period_id: 'fp-1' }], + }) + + const result = (await attentionResource.read(ctx(supabase))) as AttentionResponse + expect(result.categories.find((c) => c.key === 'voucher_gaps_unexplained')).toBeUndefined() + }) + + it('flags expired bank consent as critical', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + const yesterday = new Date(Date.now() - 86_400_000).toISOString().slice(0, 10) + const banks = [ + { id: 'bc-1', bank_name: 'SEB', status: 'active', consent_expires: yesterday }, + ] + + enqueue({ count: 0 }) + enqueue({ data: [] }) + enqueue({ data: [] }) + enqueue({ count: 0 }) + enqueue({ data: [] }) + enqueue({ count: 0 }) + enqueue({ data: [] }) + enqueue({ count: 0 }) + enqueue({ data: [] }) + enqueue({ data: [] }) + enqueue({ data: [] }) + enqueue({ data: banks }) + enqueue({ data: null }) + enqueue({ data: null }) + + const result = (await attentionResource.read(ctx(supabase))) as AttentionResponse + const cat = result.categories.find((c) => c.key === 'bank_consent_expiring') + expect(cat?.severity).toBe('critical') + expect(cat?.count).toBe(1) + }) + + it('classifies upcoming lock as info severity', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + const inSevenDays = new Date(Date.now() + 7 * 86_400_000).toISOString().slice(0, 10) + + enqueue({ count: 0 }) + enqueue({ data: [] }) + enqueue({ data: [] }) + enqueue({ count: 0 }) + enqueue({ data: [] }) + enqueue({ count: 0 }) + enqueue({ data: [] }) + enqueue({ count: 0 }) + enqueue({ data: [] }) + enqueue({ data: [] }) + enqueue({ data: [] }) + enqueue({ data: [] }) + enqueue({ data: { id: 'fp-1', name: 'FY2026', period_start: '2026-01-01', period_end: '2026-12-31', locked_at: null, is_closed: false } }) + enqueue({ data: { bookkeeping_locked_through: inSevenDays, auto_lock_period_days: null } }) + + const result = (await attentionResource.read(ctx(supabase))) as AttentionResponse + const cat = result.categories.find((c) => c.key === 'period_lock_approaching') + expect(cat?.severity).toBe('info') + expect(result.summary.info).toBe(1) + }) + + it('combines multiple categories into a coherent summary', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + const today = new Date().toISOString().slice(0, 10) + + enqueue({ count: 1 }) // unbookedHead + enqueue({ data: [{ id: 't-1', date: today, amount: -50, currency: 'SEK', description: 'X', merchant_name: null }] }) + enqueue({ data: [] }) // overdueRows + enqueue({ count: 1 }) // pendingSupplierHead + enqueue({ data: [{ id: 'si-1', supplier_invoice_number: 'L-1', supplier_id: 's-1', total: 1000, currency: 'SEK', due_date: today }] }) + enqueue({ count: 0 }) // pendingOpsHead + enqueue({ data: [] }) + enqueue({ count: 0 }) + enqueue({ data: [] }) + enqueue({ data: [] }) + enqueue({ data: [{ id: 'd-1', title: 'Moms Q1', due_date: today, deadline_type: 'tax', tax_deadline_type: 'vat', status: 'upcoming' }] }) + enqueue({ data: [] }) + enqueue({ data: null }) + enqueue({ data: null }) + + const result = (await attentionResource.read(ctx(supabase))) as AttentionResponse + expect(result.categories).toHaveLength(3) + expect(new Set(result.categories.map((c) => c.key))).toEqual( + new Set(['unbooked_transactions', 'pending_supplier_invoices', 'deadlines_upcoming']) + ) + expect(result.summary.total_items).toBe(3) + }) +}) diff --git a/extensions/general/mcp-server/__tests__/receipt-matcher.test.ts b/extensions/general/mcp-server/__tests__/receipt-matcher.test.ts index 76d74caa..d66f2cd4 100644 --- a/extensions/general/mcp-server/__tests__/receipt-matcher.test.ts +++ b/extensions/general/mcp-server/__tests__/receipt-matcher.test.ts @@ -209,17 +209,24 @@ describe('MCP Receipt Matcher', () => { // ── Protocol: resources/list ── describe('resources/list', () => { - it('returns the receipt-matcher resource', async () => { + it('includes the receipt-matcher widget alongside data resources', async () => { const res = await handleMcpRequest(mcpRequest('resources/list')) const result = await parseResult(res) - expect(result.resources).toHaveLength(1) - expect(result.resources[0]).toEqual({ + const widget = result.resources.find( + (r: { uri: string }) => r.uri === 'ui://receipt-matcher/app.html' + ) + expect(widget).toEqual({ uri: 'ui://receipt-matcher/app.html', name: 'Receipt Matcher', description: 'Interactive widget for matching receipts to uncategorized transactions', mimeType: 'text/html;profile=mcp-app', }) + + // Data resources (added in Stream 3 Phase 1) should also be listed. + const uris = result.resources.map((r: { uri: string }) => r.uri) + expect(uris).toContain('gnubok://company/current') + expect(uris).toContain('gnubok://capabilities') }) }) diff --git a/extensions/general/mcp-server/__tests__/resources.test.ts b/extensions/general/mcp-server/__tests__/resources.test.ts new file mode 100644 index 00000000..e64307dc --- /dev/null +++ b/extensions/general/mcp-server/__tests__/resources.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect } from 'vitest' +import { dataResources, findResource, parseResourceQuery } from '../resources' + +describe('mcp resource registry', () => { + it('exposes all data resources with required fields', () => { + expect(dataResources).toHaveLength(7) + const uris = dataResources.map((r) => r.uri).sort() + expect(uris).toEqual([ + 'gnubok://attention', + 'gnubok://capabilities', + 'gnubok://chart-of-accounts', + 'gnubok://company/current', + 'gnubok://period/active', + 'gnubok://recent-activity', + 'gnubok://settings/vat-treatments', + ]) + + for (const r of dataResources) { + expect(r.name).toBeTruthy() + expect(r.description.length).toBeGreaterThan(20) + expect(r.mimeType).toBe('application/json') + expect(typeof r.read).toBe('function') + } + }) + + it('matches base URI ignoring query string', () => { + const r = findResource('gnubok://recent-activity?limit=5') + expect(r?.uri).toBe('gnubok://recent-activity') + }) + + it('returns null for unknown URI', () => { + expect(findResource('gnubok://does-not-exist')).toBeNull() + }) + + it('parses query params from URI', () => { + const q = parseResourceQuery('gnubok://recent-activity?limit=5&offset=10') + expect(q?.get('limit')).toBe('5') + expect(q?.get('offset')).toBe('10') + }) + + it('returns undefined when no query', () => { + expect(parseResourceQuery('gnubok://capabilities')).toBeUndefined() + }) +}) + +describe('vat-treatments resource', () => { + it('returns matrix for all customer types without DB access', async () => { + const r = findResource('gnubok://settings/vat-treatments')! + const result = (await r.read({ + // Pure-function resource: no DB calls + supabase: undefined as never, + companyId: 'irrelevant', + userId: 'irrelevant', + scopes: [], + })) as { treatments: string[]; by_customer_type: Record } + + expect(result.treatments).toContain('standard_25') + expect(result.treatments).toContain('reverse_charge') + expect(Object.keys(result.by_customer_type)).toEqual([ + 'individual', + 'swedish_business', + 'eu_business', + 'non_eu_business', + ]) + }) +}) diff --git a/extensions/general/mcp-server/__tests__/tool-result.test.ts b/extensions/general/mcp-server/__tests__/tool-result.test.ts new file mode 100644 index 00000000..39c07b02 --- /dev/null +++ b/extensions/general/mcp-server/__tests__/tool-result.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect } from 'vitest' +import { withNext, toToolError } from '../tool-result' + +describe('withNext', () => { + it('returns plain { data } when no hint provided', () => { + expect(withNext({ id: 'x' })).toEqual({ data: { id: 'x' } }) + }) + + it('attaches next hint when provided', () => { + const result = withNext( + { id: 'x' }, + { description: 'Send the invoice', tool: 'gnubok_send_invoice' } + ) + expect(result).toEqual({ + data: { id: 'x' }, + next: { description: 'Send the invoice', tool: 'gnubok_send_invoice' }, + }) + }) +}) + +describe('toToolError', () => { + it('produces structured error from arbitrary throw', () => { + const result = toToolError(new Error('Period must be locked before closing')) + expect(result.error.code).toBe('PERIOD_NOT_LOCKED') + expect(result.error.message_sv).toBeTruthy() + expect(result.error.message_en).toContain('Period must be locked') + expect(result.error.remediation?.tool).toBe('gnubok_lock_period') + }) + + it('extracts attempted scope from "Insufficient scope:" message', () => { + const result = toToolError( + new Error('Insufficient scope: this API key does not have the "payroll:write" scope') + ) + expect(result.error.code).toBe('INSUFFICIENT_SCOPE') + expect(result.error.remediation?.description).toContain('"payroll:write"') + }) + + it('handles non-Error throws', () => { + const result = toToolError('something broke') + expect(result.error.code).toBe('UNKNOWN_ERROR') + expect(result.error.message_en).toBe('something broke') + }) +}) diff --git a/extensions/general/mcp-server/resources/attention.ts b/extensions/general/mcp-server/resources/attention.ts new file mode 100644 index 00000000..6999a24a --- /dev/null +++ b/extensions/general/mcp-server/resources/attention.ts @@ -0,0 +1,381 @@ +import type { McpResource } from './types' +import { ACTION_NEEDED_THRESHOLD_DAYS } from '@/lib/deadlines/status-engine' + +type Severity = 'critical' | 'warning' | 'info' + +interface AttentionCategory { + key: string + label_sv: string + severity: Severity + count: number + samples: Array> + next?: { + description: string + tool?: string + args?: Record + resource?: string + } +} + +const SAMPLE_LIMIT = 5 + +function daysBetween(fromIso: string, toIso: string): number { + const ms = new Date(toIso).getTime() - new Date(fromIso).getTime() + return Math.round(ms / 86_400_000) +} + +export const attentionResource: McpResource = { + uri: 'gnubok://attention', + name: 'What Needs Attention', + description: + 'One-shot summary of outstanding work for the active company: unbooked transactions, overdue invoices, pending approvals, voucher gaps, upcoming deadlines, bank consent expiry, and period-lock alerts. Each category includes a count, up to 5 sample rows, and a suggested next tool call. Use this at session start to orient before chaining read tools.', + mimeType: 'application/json', + read: async ({ supabase, companyId }) => { + const now = new Date() + const today = now.toISOString().slice(0, 10) + const horizonDate = new Date(now.getTime() + ACTION_NEEDED_THRESHOLD_DAYS * 86_400_000) + const horizon = horizonDate.toISOString().slice(0, 10) + + const [ + unbookedHead, + unbookedSamples, + overdueRows, + pendingSupplierHead, + pendingSupplierSamples, + pendingOpsHead, + pendingOpsSamples, + unmatchedReceiptsHead, + unmatchedReceiptsSamples, + voucherSeriesRows, + deadlineRows, + bankConnRows, + activePeriodRow, + companySettingsRow, + ] = await Promise.all([ + supabase + .from('transactions') + .select('id', { count: 'exact', head: true }) + .eq('company_id', companyId) + .is('journal_entry_id', null) + .eq('is_business', true), + supabase + .from('transactions') + .select('id, date, amount, currency, description, merchant_name') + .eq('company_id', companyId) + .is('journal_entry_id', null) + .eq('is_business', true) + .order('date', { ascending: true }) + .limit(SAMPLE_LIMIT), + supabase + .from('invoices') + .select('id, invoice_number, customer_id, due_date, total, currency, status') + .eq('company_id', companyId) + .in('status', ['sent', 'overdue']) + .lt('due_date', today) + .order('due_date', { ascending: true }) + .limit(100), + supabase + .from('supplier_invoices') + .select('id', { count: 'exact', head: true }) + .eq('company_id', companyId) + .eq('status', 'registered'), + supabase + .from('supplier_invoices') + .select('id, supplier_invoice_number, supplier_id, total, currency, due_date') + .eq('company_id', companyId) + .eq('status', 'registered') + .order('due_date', { ascending: true }) + .limit(SAMPLE_LIMIT), + supabase + .from('pending_operations') + .select('id', { count: 'exact', head: true }) + .eq('company_id', companyId) + .eq('status', 'pending'), + supabase + .from('pending_operations') + .select('id, operation_type, title, risk_level, actor_label, created_at') + .eq('company_id', companyId) + .eq('status', 'pending') + .order('created_at', { ascending: false }) + .limit(SAMPLE_LIMIT), + supabase + .from('receipts') + .select('id', { count: 'exact', head: true }) + .eq('company_id', companyId) + .eq('status', 'confirmed') + .is('matched_transaction_id', null), + supabase + .from('receipts') + .select('id, receipt_date, total_amount, currency, merchant_name') + .eq('company_id', companyId) + .eq('status', 'confirmed') + .is('matched_transaction_id', null) + .order('receipt_date', { ascending: false, nullsFirst: false }) + .limit(SAMPLE_LIMIT), + supabase + .from('voucher_sequences') + .select('voucher_series, fiscal_period_id') + .eq('company_id', companyId), + supabase + .from('deadlines') + .select('id, title, due_date, deadline_type, tax_deadline_type, status') + .eq('company_id', companyId) + .eq('is_completed', false) + .lte('due_date', horizon) + .order('due_date', { ascending: true }) + .limit(20), + supabase + .from('bank_connections') + .select('id, bank_name, status, consent_expires') + .eq('company_id', companyId) + .eq('status', 'active') + .not('consent_expires', 'is', null), + supabase + .from('fiscal_periods') + .select('id, name, period_start, period_end, locked_at, is_closed') + .eq('company_id', companyId) + .lte('period_start', today) + .gte('period_end', today) + .maybeSingle(), + supabase + .from('company_settings') + .select('bookkeeping_locked_through, auto_lock_period_days') + .eq('company_id', companyId) + .maybeSingle(), + ]) + + const categories: AttentionCategory[] = [] + + // ── Unbooked business transactions ────────────────────────────── + const unbookedCount = unbookedHead.count ?? 0 + if (unbookedCount > 0) { + const oldest = unbookedSamples.data?.[0] + const oldestAgeDays = oldest?.date ? daysBetween(oldest.date, today) : 0 + categories.push({ + key: 'unbooked_transactions', + label_sv: 'Obokförda affärstransaktioner', + severity: oldestAgeDays > 30 ? 'critical' : 'warning', + count: unbookedCount, + samples: unbookedSamples.data ?? [], + next: { + description: 'Kategorisera den äldsta obokförda transaktionen.', + tool: 'gnubok_categorize_transaction', + args: oldest ? { transaction_id: oldest.id } : undefined, + }, + }) + } + + // ── Overdue invoices ──────────────────────────────────────────── + const overdueAll = overdueRows.data ?? [] + if (overdueAll.length > 0) { + const maxOverdueDays = overdueAll.reduce((max, inv) => { + const days = inv.due_date ? daysBetween(inv.due_date, today) : 0 + return Math.max(max, days) + }, 0) + categories.push({ + key: 'overdue_invoices', + label_sv: 'Förfallna fakturor', + severity: maxOverdueDays > 30 ? 'critical' : 'warning', + count: overdueAll.length, + samples: overdueAll.slice(0, SAMPLE_LIMIT), + next: { + description: 'Granska förfallna fakturor och skicka påminnelser.', + resource: 'gnubok://recent-activity?limit=20', + }, + }) + } + + // ── Pending supplier invoices (status='registered') ───────────── + const pendingSupplierCount = pendingSupplierHead.count ?? 0 + if (pendingSupplierCount > 0) { + const oldestRegistered = pendingSupplierSamples.data?.[0] + categories.push({ + key: 'pending_supplier_invoices', + label_sv: 'Leverantörsfakturor som väntar på godkännande', + severity: 'warning', + count: pendingSupplierCount, + samples: pendingSupplierSamples.data ?? [], + next: { + description: 'Godkänn äldsta registrerade leverantörsfakturan.', + tool: 'gnubok_approve_supplier_invoice', + args: oldestRegistered ? { supplier_invoice_id: oldestRegistered.id } : undefined, + }, + }) + } + + // ── Pending operations awaiting human approval ────────────────── + const pendingOpsCount = pendingOpsHead.count ?? 0 + if (pendingOpsCount > 0) { + const ops = pendingOpsSamples.data ?? [] + const hasHighRisk = ops.some((o) => o.risk_level === 'high') + categories.push({ + key: 'pending_operations', + label_sv: 'Operationer som väntar på godkännande', + severity: hasHighRisk ? 'critical' : 'warning', + count: pendingOpsCount, + samples: ops, + next: { + description: 'Be användaren granska kön i /pending innan agenten fortsätter.', + }, + }) + } + + // ── Unmatched receipts ────────────────────────────────────────── + const unmatchedReceiptsCount = unmatchedReceiptsHead.count ?? 0 + if (unmatchedReceiptsCount > 0) { + const samples = unmatchedReceiptsSamples.data ?? [] + const oldest = samples[samples.length - 1] + categories.push({ + key: 'unmatched_receipts', + label_sv: 'Kvitton utan matchad transaktion', + severity: 'warning', + count: unmatchedReceiptsCount, + samples, + next: { + description: 'Försök matcha kvitto mot bankhändelse.', + tool: 'gnubok_receipt_matcher', + args: oldest ? { receipt_id: oldest.id } : undefined, + }, + }) + } + + // ── Voucher gaps without explanations ────────────────────────── + const seriesRows = (voucherSeriesRows.data ?? []) as Array<{ voucher_series: string; fiscal_period_id: string }> + const allGaps: Array<{ series: string; gap_start: number; gap_end: number; fiscal_period_id: string }> = [] + for (const row of seriesRows) { + const { data: gaps } = await supabase.rpc('detect_voucher_gaps', { + p_company_id: companyId, + p_fiscal_period_id: row.fiscal_period_id, + p_series: row.voucher_series, + }) + if (gaps && Array.isArray(gaps)) { + for (const g of gaps as Array<{ gap_start: number; gap_end: number }>) { + allGaps.push({ + series: row.voucher_series, + gap_start: g.gap_start, + gap_end: g.gap_end, + fiscal_period_id: row.fiscal_period_id, + }) + } + } + } + if (allGaps.length > 0) { + const { data: explanations } = await supabase + .from('voucher_gap_explanations') + .select('voucher_series, gap_start, gap_end, fiscal_period_id') + .eq('company_id', companyId) + const explainedKeys = new Set( + (explanations ?? []).map( + (e) => `${e.fiscal_period_id}:${e.voucher_series}:${e.gap_start}:${e.gap_end}` + ) + ) + const unexplained = allGaps.filter( + (g) => !explainedKeys.has(`${g.fiscal_period_id}:${g.series}:${g.gap_start}:${g.gap_end}`) + ) + if (unexplained.length > 0) { + const first = unexplained[0] + categories.push({ + key: 'voucher_gaps_unexplained', + label_sv: 'Verifikationshål utan förklaring (BFNAR 2013:2)', + severity: 'critical', + count: unexplained.length, + samples: unexplained.slice(0, SAMPLE_LIMIT), + next: { + description: 'Dokumentera hålet i verifikationsserien.', + tool: 'gnubok_explain_voucher_gap', + args: first + ? { + fiscal_period_id: first.fiscal_period_id, + voucher_series: first.series, + gap_start: first.gap_start, + gap_end: first.gap_end, + } + : undefined, + }, + }) + } + } + + // ── Deadlines upcoming (within 14 days) ───────────────────────── + const deadlines = deadlineRows.data ?? [] + if (deadlines.length > 0) { + const anyOverdue = deadlines.some((d) => d.due_date && d.due_date < today) + categories.push({ + key: 'deadlines_upcoming', + label_sv: 'Deadlines inom 14 dagar', + severity: anyOverdue ? 'critical' : 'warning', + count: deadlines.length, + samples: deadlines.slice(0, SAMPLE_LIMIT), + next: { + description: 'Granska kommande deadlines i /deadlines.', + }, + }) + } + + // ── Bank consent expiring ─────────────────────────────────────── + const bankConns = bankConnRows.data ?? [] + const expiring = bankConns + .map((c) => { + const daysLeft = c.consent_expires ? daysBetween(today, c.consent_expires) : null + return { ...c, days_left: daysLeft } + }) + .filter((c) => c.days_left != null && c.days_left <= ACTION_NEEDED_THRESHOLD_DAYS) + if (expiring.length > 0) { + const anyExpired = expiring.some((c) => (c.days_left ?? 0) <= 0) + categories.push({ + key: 'bank_consent_expiring', + label_sv: 'Bankanslutningar med samtycke som löper ut', + severity: anyExpired ? 'critical' : 'warning', + count: expiring.length, + samples: expiring.slice(0, SAMPLE_LIMIT).map((c) => ({ + id: c.id, + bank_name: c.bank_name, + consent_expires: c.consent_expires, + days_left: c.days_left, + })), + next: { + description: 'Be användaren förnya bank-samtycket innan det löper ut.', + }, + }) + } + + // ── Period lock approaching ───────────────────────────────────── + const lockDate = companySettingsRow.data?.bookkeeping_locked_through ?? null + if (lockDate && activePeriodRow.data) { + const daysUntilLock = daysBetween(today, lockDate) + if (daysUntilLock >= 0 && daysUntilLock <= ACTION_NEEDED_THRESHOLD_DAYS) { + categories.push({ + key: 'period_lock_approaching', + label_sv: 'Bokföringslås närmar sig', + severity: 'info', + count: 1, + samples: [ + { + lock_date: lockDate, + days_until: daysUntilLock, + active_period_id: activePeriodRow.data.id, + }, + ], + next: { + description: 'Slutför obokfört arbete innan lock_date.', + resource: 'gnubok://period/active', + }, + }) + } + } + + // ── Summary tally ─────────────────────────────────────────────── + const summary = { + total_items: categories.reduce((sum, c) => sum + c.count, 0), + critical: categories.filter((c) => c.severity === 'critical').length, + warning: categories.filter((c) => c.severity === 'warning').length, + info: categories.filter((c) => c.severity === 'info').length, + } + + return { + generated_at: now.toISOString(), + summary, + categories, + } + }, +} diff --git a/extensions/general/mcp-server/resources/capabilities.ts b/extensions/general/mcp-server/resources/capabilities.ts new file mode 100644 index 00000000..43620a45 --- /dev/null +++ b/extensions/general/mcp-server/resources/capabilities.ts @@ -0,0 +1,84 @@ +import type { McpResource } from './types' +import { TOOL_SCOPE_MAP, hasScope } from '@/lib/auth/api-keys' + +interface Capability { + tool: string + scope: string + granted: boolean + state_blocked: boolean + reason: string | null +} + +export const capabilitiesResource: McpResource = { + uri: 'gnubok://capabilities', + name: 'Capabilities', + description: 'What the current API key can actually do given (a) its granted scopes and (b) the current company state. Surfaces blockers like locked periods so the agent knows ahead of time why an action would fail.', + mimeType: 'application/json', + read: async ({ supabase, companyId, scopes }) => { + const today = new Date().toISOString().slice(0, 10) + + const { data: activePeriod } = await supabase + .from('fiscal_periods') + .select('id, is_closed, locked_at, opening_balances_set, period_end') + .eq('company_id', companyId) + .lte('period_start', today) + .gte('period_end', today) + .maybeSingle() + + const { data: settings } = await supabase + .from('company_settings') + .select('bookkeeping_locked_through, vat_registered, pays_salaries, ai_flow_enabled') + .eq('company_id', companyId) + .maybeSingle() + + const periodIsLocked = !!activePeriod?.locked_at || !!activePeriod?.is_closed + const periodMissing = !activePeriod + const companyLocked = !!settings?.bookkeeping_locked_through + && settings.bookkeeping_locked_through >= today + + const stateBlockers: Record = { + // Scope → reason it's blocked by current state, or null + 'transactions:write': periodMissing + ? 'No fiscal period covers today\'s date — open a period first' + : periodIsLocked + ? 'Active period is closed/locked' + : companyLocked + ? 'Company-wide bookkeeping lock is in effect' + : null, + 'invoices:write': periodMissing ? 'No fiscal period covers today\'s date' : null, + 'payroll:write': !settings?.pays_salaries + ? 'Company is not configured to pay salaries (settings.pays_salaries=false)' + : null, + } + + const capabilities: Capability[] = Object.entries(TOOL_SCOPE_MAP).map( + ([tool, scope]) => { + const granted = hasScope(scopes, scope) + const stateReason = stateBlockers[scope] ?? null + return { + tool, + scope, + granted, + state_blocked: granted && !!stateReason, + reason: !granted + ? `Scope "${scope}" not granted to this API key` + : stateReason, + } + } + ) + + return { + granted_scopes: scopes, + active_period: activePeriod ?? null, + company_lock_date: settings?.bookkeeping_locked_through ?? null, + vat_registered: settings?.vat_registered ?? false, + pays_salaries: settings?.pays_salaries ?? false, + capabilities, + summary: { + total: capabilities.length, + granted: capabilities.filter((c) => c.granted).length, + state_blocked: capabilities.filter((c) => c.state_blocked).length, + }, + } + }, +} diff --git a/extensions/general/mcp-server/resources/chart-of-accounts.ts b/extensions/general/mcp-server/resources/chart-of-accounts.ts new file mode 100644 index 00000000..b2ee96c8 --- /dev/null +++ b/extensions/general/mcp-server/resources/chart-of-accounts.ts @@ -0,0 +1,51 @@ +import type { McpResource } from './types' + +interface AccountSummary { + account_number: string + account_name: string + account_class: number + account_type: string + normal_balance: string + is_active: boolean + default_vat_code: string | null +} + +export const chartOfAccountsResource: McpResource = { + uri: 'gnubok://chart-of-accounts', + name: 'Chart of Accounts (BAS)', + description: 'The active BAS chart of accounts for the current company, grouped by account class (1=assets, 2=liabilities/equity, 3=revenue, 4=COGS, 5-7=expenses, 8=financial). Use to look up account numbers before booking entries.', + mimeType: 'application/json', + read: async ({ supabase, companyId }) => { + const { data, error } = await supabase + .from('chart_of_accounts') + .select('account_number, account_name, account_class, account_type, normal_balance, is_active, default_vat_code') + .eq('company_id', companyId) + .order('account_number', { ascending: true }) + + if (error) { + throw new Error(`Failed to read chart of accounts: ${error.message}`) + } + + const accounts = (data ?? []) as AccountSummary[] + + const byClass: Record = {} + for (const a of accounts) { + if (!byClass[a.account_class]) byClass[a.account_class] = [] + byClass[a.account_class].push(a) + } + + return { + total: accounts.length, + classes: { + '1': { label: 'Tillgångar', accounts: byClass[1] ?? [] }, + '2': { label: 'Eget kapital och skulder', accounts: byClass[2] ?? [] }, + '3': { label: 'Rörelseintäkter', accounts: byClass[3] ?? [] }, + '4': { label: 'Material- och varukostnader', accounts: byClass[4] ?? [] }, + '5': { label: 'Övriga externa rörelseutgifter', accounts: byClass[5] ?? [] }, + '6': { label: 'Övriga externa rörelseutgifter (forts.)', accounts: byClass[6] ?? [] }, + '7': { label: 'Personalkostnader', accounts: byClass[7] ?? [] }, + '8': { label: 'Finansiella poster', accounts: byClass[8] ?? [] }, + }, + } + }, +} diff --git a/extensions/general/mcp-server/resources/company-current.ts b/extensions/general/mcp-server/resources/company-current.ts new file mode 100644 index 00000000..d6de4f86 --- /dev/null +++ b/extensions/general/mcp-server/resources/company-current.ts @@ -0,0 +1,40 @@ +import type { McpResource } from './types' + +export const companyCurrentResource: McpResource = { + uri: 'gnubok://company/current', + name: 'Active Company', + description: 'The currently active company: identity, entity type, fiscal year config, lock date, base currency, and VAT registration. Read this first to understand the bookkeeping context.', + mimeType: 'application/json', + read: async ({ supabase, companyId }) => { + const { data: company, error: companyError } = await supabase + .from('companies') + .select('id, name, org_number, entity_type, archived_at, created_at') + .eq('id', companyId) + .single() + + if (companyError || !company) { + throw new Error(`Company not found: ${companyError?.message ?? 'unknown'}`) + } + + const { data: settings } = await supabase + .from('company_settings') + .select(` + company_name, trade_name, address_line1, address_line2, postal_code, city, country, + phone, email, website, + pays_salaries, f_skatt, vat_registered, vat_number, moms_period, + fiscal_year_start_month, + accounting_method, default_voucher_series, + bookkeeping_locked_through, auto_lock_period_days, + invoice_prefix, next_invoice_number, invoice_default_days, + is_sandbox, ai_flow_enabled + `) + .eq('company_id', companyId) + .maybeSingle() + + return { + company, + settings: settings ?? null, + base_currency: 'SEK', + } + }, +} diff --git a/extensions/general/mcp-server/resources/index.ts b/extensions/general/mcp-server/resources/index.ts new file mode 100644 index 00000000..9376aa5e --- /dev/null +++ b/extensions/general/mcp-server/resources/index.ts @@ -0,0 +1,32 @@ +import type { McpResource, ResourceContext } from './types' +import { companyCurrentResource } from './company-current' +import { chartOfAccountsResource } from './chart-of-accounts' +import { periodActiveResource } from './period-active' +import { recentActivityResource } from './recent-activity' +import { capabilitiesResource } from './capabilities' +import { vatTreatmentsResource } from './vat-treatments' +import { attentionResource } from './attention' + +export const dataResources: McpResource[] = [ + companyCurrentResource, + chartOfAccountsResource, + periodActiveResource, + recentActivityResource, + capabilitiesResource, + vatTreatmentsResource, + attentionResource, +] + +export function findResource(uri: string): McpResource | null { + // Strip any query string for matching + const baseUri = uri.split('?')[0] + return dataResources.find((r) => r.uri === baseUri) ?? null +} + +export function parseResourceQuery(uri: string): URLSearchParams | undefined { + const qIndex = uri.indexOf('?') + if (qIndex < 0) return undefined + return new URLSearchParams(uri.slice(qIndex + 1)) +} + +export type { McpResource, ResourceContext } diff --git a/extensions/general/mcp-server/resources/period-active.ts b/extensions/general/mcp-server/resources/period-active.ts new file mode 100644 index 00000000..9917904f --- /dev/null +++ b/extensions/general/mcp-server/resources/period-active.ts @@ -0,0 +1,53 @@ +import type { McpResource } from './types' + +export const periodActiveResource: McpResource = { + uri: 'gnubok://period/active', + name: 'Active Fiscal Period', + description: 'The fiscal period that the current date falls within: lock state, opening-balance status, retention deadline. Use to check whether new entries can be posted.', + mimeType: 'application/json', + read: async ({ supabase, companyId }) => { + const today = new Date().toISOString().slice(0, 10) + + const { data: active, error: activeError } = await supabase + .from('fiscal_periods') + .select('id, name, period_start, period_end, is_closed, closed_at, locked_at, opening_balances_set, retention_expires_at, opening_balance_entry_id, closing_entry_id, previous_period_id') + .eq('company_id', companyId) + .lte('period_start', today) + .gte('period_end', today) + .maybeSingle() + + if (activeError && activeError.code !== 'PGRST116') { + throw new Error(`Failed to read active period: ${activeError.message}`) + } + + const { data: settings } = await supabase + .from('company_settings') + .select('bookkeeping_locked_through, auto_lock_period_days') + .eq('company_id', companyId) + .maybeSingle() + + const periodLockedAt = active?.locked_at ?? null + const isClosed = active?.is_closed ?? null + const companyLockDate = settings?.bookkeeping_locked_through ?? null + + const canPostEntries = active + ? !isClosed && !periodLockedAt + : false + + return { + active_period: active ?? null, + company_lock: { + bookkeeping_locked_through: companyLockDate, + auto_lock_period_days: settings?.auto_lock_period_days ?? null, + }, + can_post_entries: canPostEntries, + reason_blocked: !active + ? 'No fiscal period covers today\'s date' + : isClosed + ? 'Active period is closed (status: stängd)' + : periodLockedAt + ? 'Active period is locked (status: låst)' + : null, + } + }, +} diff --git a/extensions/general/mcp-server/resources/recent-activity.ts b/extensions/general/mcp-server/resources/recent-activity.ts new file mode 100644 index 00000000..91b48198 --- /dev/null +++ b/extensions/general/mcp-server/resources/recent-activity.ts @@ -0,0 +1,42 @@ +import type { McpResource } from './types' + +export const recentActivityResource: McpResource = { + uri: 'gnubok://recent-activity', + name: 'Recent Activity', + description: 'Most recent journal entries, invoices, and bank transactions for the current company. Optional ?limit=N (default 20, max 100). Use to orient on the latest state without burning tool calls.', + mimeType: 'application/json', + read: async ({ supabase, companyId, query }) => { + const limit = Math.min(Math.max(Number(query?.get('limit') ?? 20), 1), 100) + + const [journalEntries, invoices, transactions] = await Promise.all([ + supabase + .from('journal_entries') + .select('id, voucher_number, voucher_series, entry_date, description, status, created_at') + .eq('company_id', companyId) + .order('created_at', { ascending: false }) + .limit(limit), + supabase + .from('invoices') + .select('id, invoice_number, customer_id, invoice_date, due_date, total_amount, currency, status, created_at') + .eq('company_id', companyId) + .order('created_at', { ascending: false }) + .limit(limit), + supabase + .from('transactions') + .select('id, date, description, amount, currency, journal_entry_id, category, merchant_name') + .eq('company_id', companyId) + .order('date', { ascending: false }) + .limit(limit), + ]) + + return { + limit, + journal_entries: journalEntries.data ?? [], + invoices: invoices.data ?? [], + transactions: transactions.data ?? [], + uncategorized_transaction_count: (transactions.data ?? []).filter( + (t) => !t.journal_entry_id + ).length, + } + }, +} diff --git a/extensions/general/mcp-server/resources/types.ts b/extensions/general/mcp-server/resources/types.ts new file mode 100644 index 00000000..5e7b4ee2 --- /dev/null +++ b/extensions/general/mcp-server/resources/types.ts @@ -0,0 +1,18 @@ +import type { SupabaseClient } from '@supabase/supabase-js' +import type { ApiKeyScope } from '@/lib/auth/api-keys' + +export interface ResourceContext { + supabase: SupabaseClient + companyId: string + userId: string + scopes: ApiKeyScope[] + query?: URLSearchParams +} + +export interface McpResource { + uri: string + name: string + description: string + mimeType: string + read: (ctx: ResourceContext) => Promise +} diff --git a/extensions/general/mcp-server/resources/vat-treatments.ts b/extensions/general/mcp-server/resources/vat-treatments.ts new file mode 100644 index 00000000..68718cf4 --- /dev/null +++ b/extensions/general/mcp-server/resources/vat-treatments.ts @@ -0,0 +1,38 @@ +import type { McpResource } from './types' +import { getAvailableVatRates, getVatRules } from '@/lib/invoices/vat-rules' +import type { CustomerType } from '@/types' + +const CUSTOMER_TYPES: CustomerType[] = ['individual', 'swedish_business', 'eu_business', 'non_eu_business'] + +export const vatTreatmentsResource: McpResource = { + uri: 'gnubok://settings/vat-treatments', + name: 'VAT Treatments', + description: 'Available VAT treatments and rates per customer type, and the resulting moms ruta on the VAT declaration. Use before creating invoices to pick the right VAT rate.', + mimeType: 'application/json', + read: async () => { + const matrix: Record = {} + + for (const ct of CUSTOMER_TYPES) { + matrix[ct] = { + unvalidated_vat: { + rates: getAvailableVatRates(ct, false), + default_rule: getVatRules(ct, false), + }, + validated_vat: { + rates: getAvailableVatRates(ct, true), + default_rule: getVatRules(ct, true), + }, + } + } + + return { + treatments: ['standard_25', 'reduced_12', 'reduced_6', 'reverse_charge', 'export', 'exempt'], + by_customer_type: matrix, + notes: { + eu_business_validated: 'Reverse charge applies — invoice 0%, customer self-accounts via moms ruta 39.', + non_eu_business: 'Export — invoice 0%, no Swedish VAT, moms ruta 40.', + mixed_rate: 'Invoice line items can have individual VAT rates; the engine generates per-rate lines.', + }, + } + }, +} diff --git a/extensions/general/mcp-server/server.ts b/extensions/general/mcp-server/server.ts index 27473854..8d520189 100644 --- a/extensions/general/mcp-server/server.ts +++ b/extensions/general/mcp-server/server.ts @@ -25,12 +25,27 @@ import { generateTrialBalance } from '@/lib/reports/trial-balance' import { generateARLedger } from '@/lib/reports/ar-ledger' import { generateMonthlyBreakdown } from '@/lib/reports/monthly-breakdown' import { RECEIPT_MATCHER_HTML } from './widget-html' +import { dataResources, findResource, parseResourceQuery } from './resources' +import { getRiskLevel } from '@/lib/pending-operations/risk-tiers' +import { shouldAutoCommit } from '@/lib/pending-operations/should-auto-commit' +import { commitPendingOperation } from '@/lib/pending-operations/commit' +import { + checkIdempotencyKey, + storeIdempotencyResponse, + hashRequest, + IdempotencyKeyReuseError, +} from '@/lib/api/idempotency' +import { toToolError } from './tool-result' +import type { PendingOperation } from '@/types' import { generateBalanceSheet } from '@/lib/reports/balance-sheet' import { generateGeneralLedger } from '@/lib/reports/general-ledger' import { generateSupplierLedger } from '@/lib/reports/supplier-ledger' import { getReconciliationStatus } from '@/lib/reconciliation/bank-reconciliation' import { createInvoicePaymentJournalEntry, createInvoiceCashEntry, createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries' import { reverseEntry } from '@/lib/bookkeeping/engine' +import { closePeriod, lockPeriod } from '@/lib/core/bookkeeping/period-service' +import { generateSIEExport } from '@/lib/reports/sie-export' +import { bookkeepingErrorResponse } from '@/lib/bookkeeping/errors' import { getSuggestedCategories } from '@/lib/transactions/category-suggestions' import { renderToBuffer } from '@react-pdf/renderer' import { InvoicePDF } from '@/lib/invoices/pdf-template' @@ -46,6 +61,14 @@ import { uploadDocument, MAX_DOCUMENT_SIZE } from '@/lib/core/documents/document // which dispatches to this handler — no duplicate call needed here. import type { Transaction, TransactionCategory, EntityType, VatTreatment, Invoice, Currency, CompanySettings, Customer, InvoiceItem } from '@/types' +// ── Actor context ──────────────────────────────────────────── + +interface ActorContext { + type: 'user' | 'api_key' | 'mcp_oauth' | 'cron' + id?: string + label?: string +} + // ── JSON-RPC types ─────────────────────────────────────────── interface JsonRpcRequest { @@ -81,7 +104,8 @@ interface McpTool { args: Record, companyId: string, userId: string, - supabase: SupabaseClient + supabase: SupabaseClient, + actor?: ActorContext ) => Promise } @@ -102,6 +126,28 @@ const VALID_VAT_TREATMENTS = [ // ── Pending operations staging ─────────────────────────────── +interface StageNextHint { + description: string + tool?: string + args?: Record + resource?: string +} + +interface StageOptions { + /** + * When true, validate inputs and return the would-be preview without + * inserting into pending_operations or executing any side-effects. Used + * by agents to preflight an operation before committing to it. + */ + dryRun?: boolean + /** + * Per-operation idempotency key. When supplied, repeat calls with the same + * key + same payload return the original response and never re-execute. + * Different payload + same key returns IDEMPOTENCY_KEY_REUSE. + */ + idempotencyKey?: string +} + async function stagePendingOperation( supabase: SupabaseClient, companyId: string, @@ -109,8 +155,75 @@ async function stagePendingOperation( operationType: string, title: string, params: Record, - previewData: Record -): Promise<{ staged: true; operation_id: string; message: string; preview: Record }> { + previewData: Record, + actor: ActorContext = { type: 'user' }, + next?: StageNextHint, + options: StageOptions = {} +): Promise<{ + staged: boolean + dry_run?: boolean + idempotency_replay?: boolean + operation_id?: string + risk_level: 'low' | 'medium' | 'high' + actor: ActorContext + auto_committed: boolean + auto_commit_reason?: string + result?: Record + message: string + preview: Record + next?: StageNextHint +}> { + const riskLevel = getRiskLevel(operationType) + const branding = getBranding().appName.toLowerCase() + + // ── Dry-run path: skip both the cache and the insert. Return the preview + // so the agent sees exactly what would happen without committing. + if (options.dryRun) { + return { + staged: false, + dry_run: true, + risk_level: riskLevel, + actor, + auto_committed: false, + message: `Dry run: would stage "${operationType}" (risk: ${riskLevel}). No changes made.`, + preview: previewData, + ...(next ? { next } : {}), + } + } + + // ── Idempotency check: same key + same payload + same company → return + // cached response. companyId is folded into the canonical hash so the + // same key UUID submitted under a different company is treated as a + // fresh request, not a replay. + const requestHash = options.idempotencyKey + ? hashRequest({ operationType, params, companyId }) + : null + if (options.idempotencyKey && requestHash) { + const cached = await checkIdempotencyKey(supabase, userId, companyId, options.idempotencyKey, requestHash) + if (cached) { + return { + ...(cached.body as Record), + idempotency_replay: true, + risk_level: riskLevel, + actor, + message: `Replayed cached response for idempotency_key "${options.idempotencyKey}". No new side-effects.`, + preview: previewData, + } as Awaited> + } + } + + // Decide auto-commit eligibility BEFORE insert so we can persist the flag. + const previewAmount = + typeof previewData.amount === 'number' ? previewData.amount as number : + typeof previewData.total === 'number' ? previewData.total as number : + null + + const decision = await shouldAutoCommit(supabase, companyId, { + operationType, + actorType: actor.type, + amount: previewAmount, + }) + const { data, error } = await supabase .from('pending_operations') .insert({ @@ -120,18 +233,78 @@ async function stagePendingOperation( title, params, preview_data: previewData, + actor_type: actor.type, + actor_id: actor.id ?? null, + actor_label: actor.label ?? null, + risk_level: riskLevel, + auto_commit_eligible: decision.eligible, }) - .select('id') + .select('*') .single() if (error) throw new Error(`Failed to stage operation: ${error.message}`) - return { + if (!decision.eligible) { + const response = { + staged: true, + operation_id: data.id, + risk_level: riskLevel, + actor, + auto_committed: false, + auto_commit_reason: decision.reason, + message: `Operation staged for review (risk: ${riskLevel}). Open the ${branding} web app to approve or reject it.`, + preview: previewData, + ...(next ? { next } : {}), + } as const + + if (options.idempotencyKey && requestHash) { + await storeIdempotencyResponse( + supabase, userId, companyId, options.idempotencyKey, requestHash, + 'success', { staged: true, operation_id: data.id, auto_committed: false, preview: previewData } + ) + } + return response + } + + // Auto-commit path: invoke the dispatcher inline so the same audit + // trail/event-emission logic runs as for human approvals. + const commitResult = await commitPendingOperation( + supabase, + userId, + companyId, + data as PendingOperation, + { isAutoCommit: true } + ) + + const response = { staged: true, operation_id: data.id, - message: `Operation staged for review. Open the ${getBranding().appName.toLowerCase()} web app to approve or reject it.`, + risk_level: riskLevel, + actor, + auto_committed: commitResult.status === 'committed', + auto_commit_reason: decision.reason, + result: commitResult.data, + message: commitResult.status === 'committed' + ? `Auto-committed by ${actor.label ?? actor.type} (risk: ${riskLevel}).` + : `Auto-commit failed: ${commitResult.error ?? 'unknown'}. Review in the ${branding} web app.`, preview: previewData, + ...(next ? { next } : {}), + } as const + + if (options.idempotencyKey && requestHash) { + await storeIdempotencyResponse( + supabase, userId, companyId, options.idempotencyKey, requestHash, + commitResult.status === 'committed' ? 'success' : 'error', + { + staged: true, + operation_id: data.id, + auto_committed: commitResult.status === 'committed', + result: commitResult.data, + preview: previewData, + } + ) } + return response } // ── Shared categorization logic ────────────────────────────── @@ -466,7 +639,7 @@ const tools: McpTool[] = [ idempotentHint: false, openWorldHint: false, }, - async execute(args, companyId, userId, supabase) { + async execute(args, companyId, userId, supabase, actor) { // Compute the preview (accounts, amounts, VAT lines) const result = await categorizeTransactionCore( args.transaction_id as string, @@ -511,7 +684,8 @@ const tools: McpTool[] = [ currency: result.currency, vat_lines: result.vat_lines || [], category: result.category, - } + }, + actor ) }, }, @@ -637,16 +811,24 @@ const tools: McpTool[] = [ postal_code: { type: 'string' }, city: { type: 'string' }, country: { type: 'string', description: 'Country (default Sweden)' }, + dry_run: { + type: 'boolean', + description: 'If true, validate inputs and return the would-be preview without staging or creating. No DB writes, no side-effects.', + }, + idempotency_key: { + type: 'string', + description: 'Random per-operation UUID. Repeat calls with the same key + same payload return the original response (24h TTL). Different payload → IDEMPOTENCY_KEY_REUSE error.', + }, }, required: ['name', 'customer_type'], }, annotations: { readOnlyHint: false, destructiveHint: false, - idempotentHint: false, + idempotentHint: true, // safe to retry with idempotency_key openWorldHint: false, }, - async execute(args, companyId, userId, supabase) { + async execute(args, companyId, userId, supabase, actor) { const name = args.name as string const customerType = args.customer_type as string @@ -671,7 +853,16 @@ const tools: McpTool[] = [ return stagePendingOperation(supabase, companyId, userId, 'create_customer', `Ny kund: ${params.name}`, params, - params // params ARE the preview for customers + params, // params ARE the preview for customers + actor, + { + description: 'Once approved, you can invoice this customer with gnubok_create_invoice using the returned customer_id.', + tool: 'gnubok_create_invoice', + }, + { + dryRun: Boolean(args.dry_run), + idempotencyKey: typeof args.idempotency_key === 'string' ? args.idempotency_key : undefined, + } ) }, }, @@ -805,7 +996,7 @@ const tools: McpTool[] = [ idempotentHint: false, openWorldHint: false, }, - async execute(args, companyId, userId, supabase) { + async execute(args, companyId, userId, supabase, actor) { const customerId = args.customer_id as string const items = args.items as Array<{ description: string @@ -898,6 +1089,11 @@ const tools: McpTool[] = [ vat_treatment: vatRules.treatment, invoice_date: invoiceDate, due_date: dueDate, + }, + actor, + { + description: 'Once approved, the invoice is created as a draft. Send it with gnubok_send_invoice or use gnubok_mark_invoice_as_sent if delivered outside the system.', + tool: 'gnubok_send_invoice', } ) }, @@ -1358,7 +1554,7 @@ const tools: McpTool[] = [ idempotentHint: false, openWorldHint: false, }, - async execute(args, companyId, userId, supabase) { + async execute(args, companyId, userId, supabase, actor) { const invoiceId = args.invoice_id as string if (!invoiceId) throw new Error('invoice_id is required') @@ -1385,7 +1581,8 @@ const tools: McpTool[] = [ total: invoice.total, currency: invoice.currency, payment_date: paymentDate, - } + }, + actor ) }, }, @@ -1420,7 +1617,7 @@ const tools: McpTool[] = [ idempotentHint: false, openWorldHint: true, }, - async execute(args, companyId, userId, supabase) { + async execute(args, companyId, userId, supabase, actor) { const invoiceId = args.invoice_id as string if (!invoiceId) throw new Error('invoice_id is required') @@ -1450,7 +1647,8 @@ const tools: McpTool[] = [ customer_email: customer.email, total: invoice.total, currency: invoice.currency, - } + }, + actor ) }, }, @@ -1481,7 +1679,7 @@ const tools: McpTool[] = [ idempotentHint: false, openWorldHint: false, }, - async execute(args, companyId, userId, supabase) { + async execute(args, companyId, userId, supabase, actor) { const invoiceId = args.invoice_id as string if (!invoiceId) throw new Error('invoice_id is required') @@ -1503,7 +1701,8 @@ const tools: McpTool[] = [ customer_name: invoice.customer?.name, total: invoice.total, currency: invoice.currency, - } + }, + actor ) }, }, @@ -1983,7 +2182,7 @@ const tools: McpTool[] = [ idempotentHint: false, openWorldHint: false, }, - async execute(args, companyId, userId, supabase) { + async execute(args, companyId, userId, supabase, actor) { const transactionId = args.transaction_id as string const invoiceId = args.invoice_id as string if (!transactionId || !invoiceId) throw new Error('transaction_id and invoice_id are required') @@ -2025,7 +2224,8 @@ const tools: McpTool[] = [ invoice_total: invoice.total, invoice_currency: invoice.currency, customer_name: (invoice.customer as Record)?.name as string, - } + }, + actor ) }, }, @@ -2556,6 +2756,729 @@ const tools: McpTool[] = [ } }, }, + + // ── Stream 1 Phase 1: Bookkeeping write (high-risk, always staged) ── + + { + name: 'gnubok_close_period', + description: + 'Stage a "close fiscal period" proposal for human approval. Closing a period is irreversible per BFL — it requires the period to already be locked AND the year-end closing entry to be posted.\n\n' + + 'Args:\n' + + ' - fiscal_period_id (string, required): UUID of the fiscal period\n\n' + + 'Returns: { staged: true, operation_id, risk_level: "high", preview }\n\n' + + 'High-risk: never auto-committed regardless of trust level. Always requires human approval in the web app.', + inputSchema: { + type: 'object', + properties: { + fiscal_period_id: { type: 'string', description: 'UUID of the fiscal period to close' }, + }, + required: ['fiscal_period_id'], + }, + annotations: { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + openWorldHint: false, + }, + async execute(args, companyId, userId, supabase, actor) { + const fiscalPeriodId = args.fiscal_period_id as string + if (!fiscalPeriodId) throw new Error('fiscal_period_id is required') + + const { data: period, error: fetchError } = await supabase + .from('fiscal_periods') + .select('id, name, period_start, period_end, is_closed, locked_at, closing_entry_id') + .eq('id', fiscalPeriodId) + .eq('company_id', companyId) + .single() + + if (fetchError || !period) throw new Error('Fiscal period not found') + if (period.is_closed) throw new Error('Period is already closed') + if (!period.locked_at) throw new Error('Period must be locked before closing — call gnubok_lock_period first') + if (!period.closing_entry_id) throw new Error('Year-end closing entry must exist before the period can be closed') + + return stagePendingOperation(supabase, companyId, userId, 'close_period', + `Stäng period: ${period.name} (${period.period_start} – ${period.period_end})`, + { fiscal_period_id: fiscalPeriodId }, + { + period_name: period.name, + period_start: period.period_start, + period_end: period.period_end, + locked_at: period.locked_at, + closing_entry_id: period.closing_entry_id, + irreversible: true, + }, + actor, + { + description: 'Closing is irreversible. Verify the balance sheet and income statement first.', + tool: 'gnubok_get_balance_sheet', + args: { fiscal_period_id: fiscalPeriodId }, + } + ) + }, + }, + + { + name: 'gnubok_lock_period', + description: + 'Stage a "lock fiscal period" proposal for human approval. Locking prevents new entries from being posted into the period. Requires zero unbooked business transactions.\n\n' + + 'Args:\n' + + ' - fiscal_period_id (string, required): UUID of the fiscal period\n\n' + + 'Returns: { staged: true, operation_id, risk_level: "high", preview }\n\n' + + 'High-risk: never auto-committed.', + inputSchema: { + type: 'object', + properties: { + fiscal_period_id: { type: 'string', description: 'UUID of the fiscal period to lock' }, + }, + required: ['fiscal_period_id'], + }, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: false, + }, + async execute(args, companyId, userId, supabase, actor) { + const fiscalPeriodId = args.fiscal_period_id as string + if (!fiscalPeriodId) throw new Error('fiscal_period_id is required') + + const { data: period, error: fetchError } = await supabase + .from('fiscal_periods') + .select('id, name, period_start, period_end, is_closed, locked_at') + .eq('id', fiscalPeriodId) + .eq('company_id', companyId) + .single() + + if (fetchError || !period) throw new Error('Fiscal period not found') + if (period.is_closed) throw new Error('Period is already closed') + if (period.locked_at) throw new Error('Period is already locked') + + const { count: unbookedCount } = await supabase + .from('transactions') + .select('id', { count: 'exact', head: true }) + .eq('company_id', companyId) + .is('journal_entry_id', null) + .eq('is_business', true) + .gte('date', period.period_start) + .lte('date', period.period_end) + + if (unbookedCount && unbookedCount > 0) { + throw new Error( + `Kan inte låsa period: ${unbookedCount} affärstransaktion(er) saknar bokföring. Bokför alla transaktioner först.` + ) + } + + return stagePendingOperation(supabase, companyId, userId, 'lock_period', + `Lås period: ${period.name} (${period.period_start} – ${period.period_end})`, + { fiscal_period_id: fiscalPeriodId }, + { + period_name: period.name, + period_start: period.period_start, + period_end: period.period_end, + unbooked_business_transactions: 0, + }, + actor, + { + description: 'After locking, run year-end closing before the period can be closed via gnubok_close_period. Verify balances first with gnubok_get_trial_balance.', + tool: 'gnubok_get_trial_balance', + args: { fiscal_period_id: fiscalPeriodId }, + } + ) + }, + }, + + { + name: 'gnubok_uncategorize_transaction', + description: + 'Stage an "uncategorize transaction" proposal for human approval. Reverses the journal entry via storno (legal — never deletes) and clears the transaction\'s category.\n\n' + + 'Use when a previous categorization needs to be undone before re-categorizing.\n\n' + + 'Args:\n' + + ' - transaction_id (string, required): UUID of the transaction\n\n' + + 'Returns: { staged: true, operation_id, risk_level: "medium", preview }', + inputSchema: { + type: 'object', + properties: { + transaction_id: { type: 'string', description: 'UUID of the transaction to uncategorize' }, + }, + required: ['transaction_id'], + }, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: false, + }, + async execute(args, companyId, userId, supabase, actor) { + const transactionId = args.transaction_id as string + if (!transactionId) throw new Error('transaction_id is required') + + const { data: tx, error: txError } = await supabase + .from('transactions') + .select('id, description, merchant_name, amount, currency, date, category, journal_entry_id') + .eq('id', transactionId) + .eq('company_id', companyId) + .single() + + if (txError || !tx) throw new Error('Transaction not found') + if (!tx.journal_entry_id) throw new Error('Transaction has no journal entry to reverse') + + const { data: entry } = await supabase + .from('journal_entries') + .select('id, voucher_number, voucher_series, status') + .eq('id', tx.journal_entry_id) + .eq('company_id', companyId) + .single() + + if (!entry || entry.status !== 'posted') { + throw new Error('Linked journal entry is not posted — nothing to reverse') + } + + return stagePendingOperation(supabase, companyId, userId, 'uncategorize_transaction', + `Återta kategorisering: ${tx.merchant_name || tx.description || transactionId}`, + { transaction_id: transactionId, journal_entry_id: tx.journal_entry_id }, + { + transaction_description: tx.merchant_name || tx.description, + amount: tx.amount, + currency: tx.currency, + date: tx.date, + current_category: tx.category, + will_reverse_voucher: `${entry.voucher_series}${entry.voucher_number}`, + method: 'storno (reversal entry, never deletes)', + }, + actor + ) + }, + }, + + { + name: 'gnubok_export_sie', + description: + 'Generate a SIE-4 file for the given fiscal period. Returns the SIE content as text — the agent can save it locally or hand it to the user.\n\n' + + 'SIE-4 is the standard Swedish bookkeeping interchange format (cp437/utf-8). Include this when migrating between systems or handing data to an auditor.\n\n' + + 'Args:\n' + + ' - fiscal_period_id (string, required): UUID of the fiscal period to export\n\n' + + 'Returns: { content, byte_size, fiscal_period_id, company_name, generated_at }', + inputSchema: { + type: 'object', + properties: { + fiscal_period_id: { type: 'string', description: 'UUID of the fiscal period to export' }, + }, + required: ['fiscal_period_id'], + }, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + async execute(args, companyId, _userId, supabase) { + const fiscalPeriodId = args.fiscal_period_id as string + if (!fiscalPeriodId) throw new Error('fiscal_period_id is required') + + const { data: company } = await supabase + .from('company_settings') + .select('company_name, org_number') + .eq('company_id', companyId) + .single() + + if (!company) throw new Error('Company settings not found') + + const sieContent = await generateSIEExport(supabase, companyId, { + fiscal_period_id: fiscalPeriodId, + company_name: company.company_name || 'Unknown', + org_number: company.org_number, + }) + + return { + content: sieContent, + byte_size: Buffer.byteLength(sieContent, 'utf8'), + fiscal_period_id: fiscalPeriodId, + company_name: company.company_name, + org_number: company.org_number, + generated_at: new Date().toISOString(), + } + }, + }, + + // ── Stream 1 Phase 1 follow-up: year-end, opening balances, revaluation, + // voucher gaps, supplier-invoice lifecycle, proforma conversion ── + + { + name: 'gnubok_run_year_end', + description: + 'Stage a year-end closing proposal for human approval. Year-end zeros class 3-8 result accounts into 2099, locks the period, creates the next period, and seeds opening balances. Always high-risk.\n\n' + + 'Args: fiscal_period_id (required)', + inputSchema: { + type: 'object', + properties: { + fiscal_period_id: { type: 'string', description: 'UUID of the fiscal period to close out' }, + }, + required: ['fiscal_period_id'], + }, + annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false }, + async execute(args, companyId, userId, supabase, actor) { + const fiscalPeriodId = args.fiscal_period_id as string + if (!fiscalPeriodId) throw new Error('fiscal_period_id is required') + + const { data: period } = await supabase + .from('fiscal_periods') + .select('id, name, period_start, period_end, is_closed, locked_at') + .eq('id', fiscalPeriodId).eq('company_id', companyId).single() + + if (!period) throw new Error('Fiscal period not found') + if (period.is_closed) throw new Error('Period is already closed') + + return stagePendingOperation(supabase, companyId, userId, 'run_year_end', + `Bokslut: ${period.name}`, + { fiscal_period_id: fiscalPeriodId }, + { + period_name: period.name, + period_start: period.period_start, + period_end: period.period_end, + will: 'zero result accounts into 2099, lock period, create next period, generate opening balances', + }, + actor, + { + description: 'After year-end, the period is locked and ready for closing via gnubok_close_period.', + tool: 'gnubok_close_period', + args: { fiscal_period_id: fiscalPeriodId }, + } + ) + }, + }, + + { + name: 'gnubok_set_opening_balances', + description: + 'Stage an "opening balances" proposal: copy class 1-2 closing balances from a closed period into the next period as opening balances.\n\n' + + 'Args: closed_period_id, next_period_id (both required)', + inputSchema: { + type: 'object', + properties: { + closed_period_id: { type: 'string', description: 'UUID of the closed source period' }, + next_period_id: { type: 'string', description: 'UUID of the next (target) period' }, + }, + required: ['closed_period_id', 'next_period_id'], + }, + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, + async execute(args, companyId, userId, supabase, actor) { + const closedId = args.closed_period_id as string + const nextId = args.next_period_id as string + if (!closedId || !nextId) throw new Error('closed_period_id and next_period_id are required') + + return stagePendingOperation(supabase, companyId, userId, 'set_opening_balances', + `Ingående balans: ${nextId}`, + { closed_period_id: closedId, next_period_id: nextId }, + { closed_period_id: closedId, next_period_id: nextId, will: 'create opening balance entry from closed-period trial balance' }, + actor + ) + }, + }, + + { + name: 'gnubok_run_currency_revaluation', + description: + 'Stage a currency revaluation: revalue open foreign-currency receivables and payables to the closing-date FX rate. Posts to 3960/7960. Throws if a revaluation already exists for the period.\n\n' + + 'Args: fiscal_period_id, closing_date (required, YYYY-MM-DD)', + inputSchema: { + type: 'object', + properties: { + fiscal_period_id: { type: 'string', description: 'UUID of the fiscal period' }, + closing_date: { type: 'string', description: 'Revaluation date (YYYY-MM-DD)' }, + }, + required: ['fiscal_period_id', 'closing_date'], + }, + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, + async execute(args, companyId, userId, supabase, actor) { + const fiscalPeriodId = args.fiscal_period_id as string + const closingDate = args.closing_date as string + if (!fiscalPeriodId || !closingDate) throw new Error('fiscal_period_id and closing_date are required') + + return stagePendingOperation(supabase, companyId, userId, 'run_currency_revaluation', + `Valutaomvärdering ${closingDate}`, + { fiscal_period_id: fiscalPeriodId, closing_date: closingDate }, + { fiscal_period_id: fiscalPeriodId, closing_date: closingDate, posts_to: ['3960', '7960'] }, + actor + ) + }, + }, + + { + name: 'gnubok_list_voucher_gaps', + description: + 'List voucher number gaps in a fiscal period (BFNAR 2013:2 audit requirement). Each gap may have an existing explanation.\n\n' + + 'Args: fiscal_period_id (required), voucher_series (optional)', + inputSchema: { + type: 'object', + properties: { + fiscal_period_id: { type: 'string' }, + voucher_series: { type: 'string', description: 'Optional series filter (e.g. "A")' }, + }, + required: ['fiscal_period_id'], + }, + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + async execute(args, companyId, _userId, supabase) { + const fiscalPeriodId = args.fiscal_period_id as string + const voucherSeries = args.voucher_series as string | undefined + + let seriesQuery = supabase + .from('voucher_sequences').select('voucher_series') + .eq('company_id', companyId).eq('fiscal_period_id', fiscalPeriodId) + if (voucherSeries) seriesQuery = seriesQuery.eq('voucher_series', voucherSeries) + + const { data: seriesRows } = await seriesQuery + if (!seriesRows || seriesRows.length === 0) { + return { gaps: [], total_gaps: 0, unexplained_gaps: 0 } + } + + const allGaps: Array<{ series: string; gap_start: number; gap_end: number; explanation: unknown }> = [] + for (const row of seriesRows) { + const { data: gaps } = await supabase.rpc('detect_voucher_gaps', { + p_company_id: companyId, + p_fiscal_period_id: fiscalPeriodId, + p_series: row.voucher_series, + }) + if (gaps) { + for (const gap of gaps as Array<{ gap_start: number; gap_end: number }>) { + allGaps.push({ series: row.voucher_series, gap_start: gap.gap_start, gap_end: gap.gap_end, explanation: null }) + } + } + } + + if (allGaps.length > 0) { + const { data: explanations } = await supabase + .from('voucher_gap_explanations') + .select('id, voucher_series, gap_start, gap_end, explanation, created_at') + .eq('company_id', companyId).eq('fiscal_period_id', fiscalPeriodId) + if (explanations) { + const map = new Map(explanations.map((e) => [`${e.voucher_series}:${e.gap_start}:${e.gap_end}`, e])) + for (const g of allGaps) { + g.explanation = map.get(`${g.series}:${g.gap_start}:${g.gap_end}`) ?? null + } + } + } + + return { + gaps: allGaps, + total_gaps: allGaps.length, + unexplained_gaps: allGaps.filter((g) => !g.explanation).length, + } + }, + }, + + { + name: 'gnubok_explain_voucher_gap', + description: + 'Stage an explanation for a voucher number gap. Required for BFNAR 2013:2 compliance — every gap must have a documented reason.\n\n' + + 'Args: fiscal_period_id, voucher_series, gap_start, gap_end, explanation (all required)', + inputSchema: { + type: 'object', + properties: { + fiscal_period_id: { type: 'string' }, + voucher_series: { type: 'string' }, + gap_start: { type: 'number' }, + gap_end: { type: 'number' }, + explanation: { type: 'string', description: 'Swedish prose: why the gap exists' }, + }, + required: ['fiscal_period_id', 'voucher_series', 'gap_start', 'gap_end', 'explanation'], + }, + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, + async execute(args, companyId, userId, supabase, actor) { + const explanation = args.explanation as string + if (!explanation?.trim()) throw new Error('explanation is required') + + return stagePendingOperation(supabase, companyId, userId, 'explain_voucher_gap', + `Förklara verifikationslucka ${args.voucher_series}:${args.gap_start}-${args.gap_end}`, + { + fiscal_period_id: args.fiscal_period_id, + voucher_series: args.voucher_series, + gap_start: args.gap_start, + gap_end: args.gap_end, + explanation: explanation.trim(), + }, + { + voucher_series: args.voucher_series, + gap_start: args.gap_start, + gap_end: args.gap_end, + explanation: explanation.trim(), + }, + actor + ) + }, + }, + + { + name: 'gnubok_approve_supplier_invoice', + description: + 'Stage approval of a registered supplier invoice. Moves status from "registered" to "approved". High-risk: never auto-committed.\n\n' + + 'Args: supplier_invoice_id (required)', + inputSchema: { + type: 'object', + properties: { supplier_invoice_id: { type: 'string' } }, + required: ['supplier_invoice_id'], + }, + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, + async execute(args, companyId, userId, supabase, actor) { + const id = args.supplier_invoice_id as string + if (!id) throw new Error('supplier_invoice_id is required') + + const { data: inv } = await supabase + .from('supplier_invoices') + .select('id, supplier_invoice_number, total, currency, status, supplier:suppliers(name)') + .eq('id', id).eq('company_id', companyId).single() + if (!inv) throw new Error('Supplier invoice not found') + if (inv.status !== 'registered') throw new Error('Kan bara godkänna registrerade fakturor') + + return stagePendingOperation(supabase, companyId, userId, 'approve_supplier_invoice', + `Godkänn leverantörsfaktura ${inv.supplier_invoice_number}`, + { supplier_invoice_id: id }, + { + supplier_invoice_number: inv.supplier_invoice_number, + supplier_name: (inv.supplier as { name?: string } | null)?.name, + total: inv.total, + currency: inv.currency, + }, + actor + ) + }, + }, + + { + name: 'gnubok_credit_supplier_invoice', + description: + 'Stage a credit-note (kreditfaktura) for an existing supplier invoice. Creates a mirror invoice with negative effect and reverses the registration JE under accrual method.\n\n' + + 'Args: supplier_invoice_id (required)', + inputSchema: { + type: 'object', + properties: { supplier_invoice_id: { type: 'string' } }, + required: ['supplier_invoice_id'], + }, + annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false }, + async execute(args, companyId, userId, supabase, actor) { + const id = args.supplier_invoice_id as string + if (!id) throw new Error('supplier_invoice_id is required') + + const { data: inv } = await supabase + .from('supplier_invoices') + .select('id, supplier_invoice_number, total, currency, status, supplier:suppliers(name)') + .eq('id', id).eq('company_id', companyId).single() + if (!inv) throw new Error('Supplier invoice not found') + if (inv.status === 'credited') throw new Error('Fakturan har redan krediterats') + + return stagePendingOperation(supabase, companyId, userId, 'credit_supplier_invoice', + `Kreditera leverantörsfaktura ${inv.supplier_invoice_number}`, + { supplier_invoice_id: id }, + { + supplier_invoice_number: inv.supplier_invoice_number, + supplier_name: (inv.supplier as { name?: string } | null)?.name, + total: inv.total, + currency: inv.currency, + method: 'creates KREDIT- mirror invoice + reverses registration JE (accrual)', + }, + actor + ) + }, + }, + + { + name: 'gnubok_convert_invoice', + description: + 'Stage conversion of a proforma invoice to a real invoice. Allocates an F-series number, copies items, marks the proforma cancelled. Medium-risk.\n\n' + + 'Args: invoice_id (required, must be proforma)', + inputSchema: { + type: 'object', + properties: { invoice_id: { type: 'string' } }, + required: ['invoice_id'], + }, + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, + async execute(args, companyId, userId, supabase, actor) { + const id = args.invoice_id as string + if (!id) throw new Error('invoice_id is required') + + const { data: inv } = await supabase + .from('invoices') + .select('id, document_type, status, total, currency, customer:customers(name)') + .eq('id', id).eq('company_id', companyId).single() + if (!inv) throw new Error('Invoice not found') + if (inv.document_type !== 'proforma') throw new Error('Endast proformafakturor kan konverteras') + if (inv.status === 'cancelled') throw new Error('Denna proformafaktura har redan makuleras') + + return stagePendingOperation(supabase, companyId, userId, 'convert_invoice', + `Konvertera proforma → faktura`, + { invoice_id: id }, + { + customer_name: (inv.customer as { name?: string } | null)?.name, + total: inv.total, + currency: inv.currency, + will: 'allocate F-series number, copy items, cancel proforma', + }, + actor, + { + description: 'After conversion, send the new invoice with gnubok_send_invoice.', + tool: 'gnubok_send_invoice', + } + ) + }, + }, + + { + name: 'gnubok_unlock_period', + description: + 'Stage an "unlock fiscal period" proposal for human approval. Clears `locked_at` so new entries can be posted into the period. Cannot unlock a closed period — only one that is locked but not closed.\n\n' + + 'Args: fiscal_period_id (required)\n\n' + + 'High-risk: never auto-committed.', + inputSchema: { + type: 'object', + properties: { + fiscal_period_id: { type: 'string', description: 'UUID of the fiscal period to unlock' }, + }, + required: ['fiscal_period_id'], + }, + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, + async execute(args, companyId, userId, supabase, actor) { + const fiscalPeriodId = args.fiscal_period_id as string + if (!fiscalPeriodId) throw new Error('fiscal_period_id is required') + + const { data: period, error: fetchError } = await supabase + .from('fiscal_periods') + .select('id, name, period_start, period_end, is_closed, locked_at') + .eq('id', fiscalPeriodId) + .eq('company_id', companyId) + .single() + + if (fetchError || !period) throw new Error('Fiscal period not found') + if (period.is_closed) throw new Error('Cannot unlock a closed period') + if (!period.locked_at) throw new Error('Period is not locked') + + return stagePendingOperation(supabase, companyId, userId, 'unlock_period', + `Lås upp period: ${period.name} (${period.period_start} – ${period.period_end})`, + { fiscal_period_id: fiscalPeriodId }, + { + period_name: period.name, + period_start: period.period_start, + period_end: period.period_end, + locked_at: period.locked_at, + will: 'clear locked_at — new entries can be posted into the period again', + }, + actor + ) + }, + }, + + { + name: 'gnubok_credit_invoice', + description: + 'Stage a credit note (kreditfaktura) for an existing customer invoice. Creates a `KR-` prefixed mirror invoice with negated amounts and reverses the original JE under accrual method. Original must be sent/paid/overdue and not already credited.\n\n' + + 'Args: invoice_id (required), reason (optional Swedish-language note)\n\n' + + 'High-risk: never auto-committed.', + inputSchema: { + type: 'object', + properties: { + invoice_id: { type: 'string', description: 'UUID of the invoice to credit' }, + reason: { type: 'string', description: 'Optional reason note (Swedish, shown on the credit note)' }, + }, + required: ['invoice_id'], + }, + annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false }, + async execute(args, companyId, userId, supabase, actor) { + const id = args.invoice_id as string + const reason = args.reason as string | undefined + if (!id) throw new Error('invoice_id is required') + + const { data: inv } = await supabase + .from('invoices') + .select('id, invoice_number, document_type, status, total, currency, customer:customers(name)') + .eq('id', id).eq('company_id', companyId).single() + + if (!inv) throw new Error('Invoice not found') + if (inv.document_type && inv.document_type !== 'invoice') { + throw new Error('Credit notes can only be created from standard invoices') + } + if (inv.status === 'credited') throw new Error('Fakturan har redan krediterats') + if (!['sent', 'paid', 'overdue'].includes(inv.status)) { + throw new Error('Endast skickade, betalda eller förfallna fakturor kan krediteras') + } + + return stagePendingOperation(supabase, companyId, userId, 'credit_invoice', + `Kreditera faktura ${inv.invoice_number}`, + { invoice_id: id, reason }, + { + invoice_number: inv.invoice_number, + customer_name: (inv.customer as { name?: string } | null)?.name, + total: inv.total, + currency: inv.currency, + reason: reason || null, + method: 'creates KR- mirror invoice + reverses original JE (accrual)', + }, + actor + ) + }, + }, + + { + name: 'gnubok_import_sie', + description: + 'Stage a SIE file import proposal for human approval. Parses an SIE file (types 1-4, CP437/UTF-8/Latin-1) and stages a job that, on commit, creates the fiscal period, opening balances, and journal entries.\n\n' + + 'Args:\n' + + ' - file_content (string, required): Full SIE file contents as a string\n' + + ' - filename (string, required): Original filename (used for the import record + dedup)\n' + + ' - mappings (array, required): Account mappings with { sourceAccount, sourceName, targetAccount, targetName, confidence, matchType, isOverride }. Use gnubok_export_sie or the import wizard to derive these first.\n' + + ' - create_fiscal_period (bool, optional, default false)\n' + + ' - import_opening_balances (bool, optional, default false)\n' + + ' - import_transactions (bool, optional, default false)\n' + + ' - voucher_series (string, optional): Override voucher series for imported vouchers\n\n' + + 'High-risk: never auto-committed. Large file_content payloads are stored on the pending_operation row — keep files reasonable in size.', + inputSchema: { + type: 'object', + properties: { + file_content: { type: 'string', description: 'Full SIE file contents' }, + filename: { type: 'string', description: 'Original filename' }, + mappings: { + type: 'array', + description: 'Account mappings (AccountMapping[])', + items: { type: 'object' }, + }, + create_fiscal_period: { type: 'boolean' }, + import_opening_balances: { type: 'boolean' }, + import_transactions: { type: 'boolean' }, + voucher_series: { type: 'string' }, + }, + required: ['file_content', 'filename', 'mappings'], + }, + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, + async execute(args, companyId, userId, supabase, actor) { + const fileContent = args.file_content as string + const filename = args.filename as string + const mappings = args.mappings as unknown[] | undefined + + if (!fileContent || !filename || !Array.isArray(mappings)) { + throw new Error('file_content, filename, and mappings are required') + } + + return stagePendingOperation(supabase, companyId, userId, 'import_sie', + `SIE-import: ${filename}`, + { + file_content: fileContent, + filename, + mappings, + create_fiscal_period: Boolean(args.create_fiscal_period), + import_opening_balances: Boolean(args.import_opening_balances), + import_transactions: Boolean(args.import_transactions), + voucher_series: args.voucher_series, + }, + { + filename, + file_size_bytes: fileContent.length, + mappings_count: mappings.length, + create_fiscal_period: Boolean(args.create_fiscal_period), + import_opening_balances: Boolean(args.import_opening_balances), + import_transactions: Boolean(args.import_transactions), + will: 'parse SIE on commit, create fiscal period + opening balances + journal entries', + }, + actor + ) + }, + }, ] // ── MCP Protocol Handler ───────────────────────────────────── @@ -2625,8 +3548,13 @@ export async function handleMcpRequest(request: Request): Promise { }) } - const { userId, companyId, scopes: keyScopes } = authResult + const { userId, companyId, scopes: keyScopes, apiKeyId, apiKeyName } = authResult const supabase = createServiceClientNoCookies() + const actor: ActorContext = { + type: 'api_key', + id: apiKeyId, + label: apiKeyName ?? 'Unnamed API key', + } // ── Parse JSON-RPC ── let body: JsonRpcRequest @@ -2708,16 +3636,23 @@ export async function handleMcpRequest(request: Request): Promise { ) } - // Enforce scope + // Enforce scope — surface structured error so the agent can dispatch. const requiredScope = TOOL_SCOPE_MAP[toolName] if (requiredScope && !hasScope(keyScopes, requiredScope)) { + const scopeError = toToolError( + new Error(`Insufficient scope: this API key does not have the "${requiredScope}" scope`), + { toolName } + ) return NextResponse.json( - jsonRpcError(id ?? null, -32600, `Insufficient scope: this API key does not have the "${requiredScope}" scope`) + jsonRpc(id ?? null, { + content: [{ type: 'text', text: JSON.stringify(scopeError, null, 2) }], + isError: true, + }) ) } try { - const result = await tool.execute(toolArgs, companyId, userId, supabase) + const result = await tool.execute(toolArgs, companyId, userId, supabase, actor) const response: Record = { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], } @@ -2726,10 +3661,10 @@ export async function handleMcpRequest(request: Request): Promise { } return NextResponse.json(jsonRpc(id ?? null, response)) } catch (err) { - const message = err instanceof Error ? err.message : 'Tool execution failed' + const structured = toToolError(err, { toolName }) return NextResponse.json( jsonRpc(id ?? null, { - content: [{ type: 'text', text: JSON.stringify({ error: message }) }], + content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }], isError: true, }) ) @@ -2746,6 +3681,12 @@ export async function handleMcpRequest(request: Request): Promise { description: 'Interactive widget for matching receipts to uncategorized transactions', mimeType: 'text/html;profile=mcp-app', }, + ...dataResources.map((r) => ({ + uri: r.uri, + name: r.name, + description: r.description, + mimeType: r.mimeType, + })), ], }) ) @@ -2765,6 +3706,36 @@ export async function handleMcpRequest(request: Request): Promise { }) ) } + + const dataResource = findResource(uri) + if (dataResource) { + try { + const result = await dataResource.read({ + supabase, + companyId, + userId, + scopes: keyScopes, + query: parseResourceQuery(uri), + }) + return NextResponse.json( + jsonRpc(id ?? null, { + contents: [ + { + uri, + mimeType: dataResource.mimeType, + text: JSON.stringify(result, null, 2), + }, + ], + }) + ) + } catch (err) { + const message = err instanceof Error ? err.message : 'Resource read failed' + return NextResponse.json( + jsonRpcError(id ?? null, -32603, `Resource read error: ${message}`) + ) + } + } + return NextResponse.json( jsonRpcError(id ?? null, -32602, `Resource not found: "${uri}"`) ) diff --git a/extensions/general/mcp-server/tool-result.ts b/extensions/general/mcp-server/tool-result.ts new file mode 100644 index 00000000..7aa520aa --- /dev/null +++ b/extensions/general/mcp-server/tool-result.ts @@ -0,0 +1,57 @@ +/** + * Helpers for shaping MCP tool results in an agent-actionable form. + * + * Two additive concepts: + * 1. `next` — when a tool succeeds and there's an obvious follow-up tool or + * resource the agent should call, expose it directly so Claude doesn't + * have to re-derive it from prose. + * 2. structured errors — failures include a stable code, English + Swedish + * messages, and a remediation hint when one exists. + * + * Both are folded into the JSON `text` payload that the JSON-RPC handler + * already serializes — no protocol change needed, and existing string-only + * consumers keep working. + */ +import { getStructuredError, type StructuredError } from '@/lib/errors/get-structured-error' + +export interface NextActionHint { + description: string + tool?: string + args?: Record + resource?: string +} + +export interface AgentToolResult { + data: T + next?: NextActionHint +} + +export interface AgentToolError { + error: StructuredError +} + +/** + * Wrap a successful tool payload with an optional `next` hint. Returns the + * payload as-is if the input is already wrapped (idempotent), or a plain object + * if no hint is supplied. + */ +export function withNext(data: T, next?: NextActionHint): AgentToolResult { + return next ? { data, next } : { data } +} + +/** + * Convert a thrown error into the structured tool-error envelope the agent + * sees. If the error is a string already containing "Insufficient scope:", + * the attempted scope is propagated to the remediation hint so the agent can + * surface a precise request to the user. + */ +export function toToolError(err: unknown, opts: { toolName?: string } = {}): AgentToolError { + let attemptedScope: string | undefined + const message = err instanceof Error ? err.message : typeof err === 'string' ? err : '' + const scopeMatch = message.match(/Insufficient scope: this API key does not have the "([^"]+)" scope/) + if (scopeMatch) attemptedScope = scopeMatch[1] + + return { + error: getStructuredError(err, { attemptedScope, toolName: opts.toolName }), + } +} diff --git a/lib/api/__tests__/idempotency.test.ts b/lib/api/__tests__/idempotency.test.ts new file mode 100644 index 00000000..042e1ec4 --- /dev/null +++ b/lib/api/__tests__/idempotency.test.ts @@ -0,0 +1,146 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { + hashRequest, + checkIdempotencyKey, + storeIdempotencyResponse, + cleanupExpiredIdempotencyKeys, + IdempotencyKeyReuseError, +} from '../idempotency' + +describe('hashRequest', () => { + it('produces stable SHA-256 for the same payload', () => { + expect(hashRequest({ a: 1, b: 'x' })).toBe(hashRequest({ a: 1, b: 'x' })) + }) + + it('is order-independent', () => { + expect(hashRequest({ a: 1, b: 2 })).toBe(hashRequest({ b: 2, a: 1 })) + }) + + it('detects different values', () => { + expect(hashRequest({ a: 1 })).not.toBe(hashRequest({ a: 2 })) + }) + + it('handles nested objects deterministically', () => { + const h1 = hashRequest({ outer: { x: 1, y: 2 }, list: [1, 2, 3] }) + const h2 = hashRequest({ list: [1, 2, 3], outer: { y: 2, x: 1 } }) + expect(h1).toBe(h2) + }) +}) + +function mockClient(maybeSingleResult: { data: Record | null; error: unknown }) { + const select = vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + maybeSingle: vi.fn().mockResolvedValue(maybeSingleResult), + }), + }), + }), + }) + const insert = vi.fn().mockResolvedValue({ error: null }) + const deleteFn = vi.fn().mockReturnValue({ + lt: vi.fn().mockResolvedValue({ error: null, count: 5 }), + }) + return { + client: { + from: vi.fn().mockReturnValue({ + select, + insert, + delete: deleteFn, + }), + } as never, + select, + insert, + deleteFn, + } +} + +describe('checkIdempotencyKey', () => { + beforeEach(() => vi.clearAllMocks()) + + it('returns null when no cached row exists', async () => { + const { client } = mockClient({ data: null, error: null }) + const result = await checkIdempotencyKey(client, 'user-1', 'company-1', 'key-1', 'hash-1') + expect(result).toBeNull() + }) + + it('returns cached body when key + hash match', async () => { + const future = new Date(Date.now() + 60_000).toISOString() + const { client } = mockClient({ + data: { + request_hash: 'hash-1', + response_status: 'success', + response_body: { foo: 'bar' }, + expires_at: future, + }, + error: null, + }) + const result = await checkIdempotencyKey(client, 'user-1', 'company-1', 'key-1', 'hash-1') + expect(result).toEqual({ status: 'success', body: { foo: 'bar' } }) + }) + + it('throws IdempotencyKeyReuseError on hash mismatch', async () => { + const future = new Date(Date.now() + 60_000).toISOString() + const { client } = mockClient({ + data: { + request_hash: 'hash-old', + response_status: 'success', + response_body: { foo: 'old' }, + expires_at: future, + }, + error: null, + }) + await expect( + checkIdempotencyKey(client, 'user-1', 'company-1', 'key-1', 'hash-new') + ).rejects.toBeInstanceOf(IdempotencyKeyReuseError) + }) + + it('treats expired rows as misses', async () => { + const past = new Date(Date.now() - 60_000).toISOString() + const { client } = mockClient({ + data: { + request_hash: 'hash-1', + response_status: 'success', + response_body: { foo: 'bar' }, + expires_at: past, + }, + error: null, + }) + const result = await checkIdempotencyKey(client, 'user-1', 'company-1', 'key-1', 'hash-1') + expect(result).toBeNull() + }) +}) + +describe('storeIdempotencyResponse', () => { + beforeEach(() => vi.clearAllMocks()) + + it('writes the response row', async () => { + const { client, insert } = mockClient({ data: null, error: null }) + await storeIdempotencyResponse(client, 'user-1', 'company-1', 'key-1', 'hash-1', 'success', { ok: true }) + expect(insert).toHaveBeenCalledWith(expect.objectContaining({ + user_id: 'user-1', + company_id: 'company-1', + key: 'key-1', + request_hash: 'hash-1', + response_status: 'success', + response_body: { ok: true }, + scope: 'mcp_tool', + })) + }) + + it('swallows duplicate-row races (23505)', async () => { + const { client, insert } = mockClient({ data: null, error: null }) + insert.mockResolvedValueOnce({ error: { code: '23505', message: 'unique_violation' } }) + await expect( + storeIdempotencyResponse(client, 'user-1', 'company-1', 'key-1', 'hash-1', 'success', {}) + ).resolves.toBeUndefined() + }) +}) + +describe('cleanupExpiredIdempotencyKeys', () => { + it('returns delete count', async () => { + const { client } = mockClient({ data: null, error: null }) + const count = await cleanupExpiredIdempotencyKeys(client) + expect(count).toBe(5) + }) +}) diff --git a/lib/api/idempotency.ts b/lib/api/idempotency.ts new file mode 100644 index 00000000..2f0f3233 --- /dev/null +++ b/lib/api/idempotency.ts @@ -0,0 +1,155 @@ +/** + * Idempotency layer for agent-safe retries. + * + * Use case: an agent (MCP, automation webhook, scripted client) retries an + * operation after a network blip. Without idempotency, the retry creates a + * duplicate side-effect — two invoices, two journal entries, two emails. + * + * Contract: + * 1. The caller supplies an `idempotency_key` per logical operation. + * 2. The server hashes the canonical request body and consults + * idempotency_keys. + * 3. Hit + matching hash → return cached response (suppress side-effects). + * 4. Hit + different hash → throw IdempotencyKeyReuseError (409 in HTTP). + * 5. Miss → proceed; on success, persist the response. + * + * Keys are scoped per (user, company): the same key UUID across two + * companies cannot collide, and a multi-company user replaying a key in + * the wrong company can never receive the other company's cached response. + * + * 24-hour TTL is enforced by an `expires_at` column + a cleanup cron. After + * 24h, the same key may be reused safely — agents that retry that long after + * the original request are not retrying, they're starting over. + */ +import crypto from 'crypto' +import type { SupabaseClient } from '@supabase/supabase-js' + +export type IdempotencyScope = 'mcp_tool' | 'api_route' + +export class IdempotencyKeyReuseError extends Error { + readonly code = 'IDEMPOTENCY_KEY_REUSE' + constructor(public readonly key: string) { + super(`Idempotency key "${key}" was previously used with a different request body. Use a fresh key or send the original request.`) + this.name = 'IdempotencyKeyReuseError' + } +} + +export interface IdempotencyHit { + status: 'success' | 'error' + body: Record +} + +/** + * Canonical hash of a request body. Sorts keys recursively so semantically + * identical bodies produce the same hash regardless of property order. + */ +export function hashRequest(body: unknown): string { + return crypto.createHash('sha256').update(canonicalJson(body)).digest('hex') +} + +function canonicalJson(value: unknown): string { + if (value === null || typeof value !== 'object') return JSON.stringify(value) + if (Array.isArray(value)) return '[' + value.map(canonicalJson).join(',') + ']' + const obj = value as Record + const keys = Object.keys(obj).sort() + return '{' + keys.map((k) => JSON.stringify(k) + ':' + canonicalJson(obj[k])).join(',') + '}' +} + +/** + * Look up a previously-cached idempotency response. + * + * Returns: + * - null when no cached entry exists (caller should proceed) + * - the cached body when the key+hash match (caller should return it + * without side-effects) + * + * Throws IdempotencyKeyReuseError when the key exists with a *different* + * request hash — the caller is misusing the key. + */ +export async function checkIdempotencyKey( + supabase: SupabaseClient, + userId: string, + companyId: string, + key: string, + requestHash: string +): Promise { + const { data, error } = await supabase + .from('idempotency_keys') + .select('request_hash, response_status, response_body, expires_at') + .eq('user_id', userId) + .eq('company_id', companyId) + .eq('key', key) + .maybeSingle() + + if (error || !data) return null + + // Expired entries are treated as misses; the cleanup cron will delete them. + if (data.expires_at && new Date(data.expires_at) < new Date()) { + return null + } + + if (data.request_hash !== requestHash) { + throw new IdempotencyKeyReuseError(key) + } + + return { + status: data.response_status as 'success' | 'error', + body: (data.response_body ?? {}) as Record, + } +} + +/** + * Persist the response for an idempotency key. Best-effort: a duplicate-row + * race is swallowed so two concurrent retries don't fight over the cache. + * The first writer wins; the second sees the unique-index conflict and skips. + */ +export async function storeIdempotencyResponse( + supabase: SupabaseClient, + userId: string, + companyId: string, + key: string, + requestHash: string, + status: 'success' | 'error', + body: Record, + scope: IdempotencyScope = 'mcp_tool' +): Promise { + const { error } = await supabase + .from('idempotency_keys') + .insert({ + user_id: userId, + company_id: companyId, + key, + request_hash: requestHash, + scope, + response_status: status, + response_body: body, + }) + + // Postgres 23505 is unique_violation — a concurrent retry already inserted. + // Silently OK; the cached response from the winner will be returned to + // both callers on subsequent reads. + if (error && error.code !== '23505') { + // Non-blocking: log but don't fail the operation. The caller already + // succeeded; failing to persist the cache only weakens future retries. + // eslint-disable-next-line no-console + console.warn('[idempotency] failed to persist response:', error.message) + } +} + +/** + * Sweep expired rows. Called by the cleanup cron. + * Returns the number of deleted rows for logging. + */ +export async function cleanupExpiredIdempotencyKeys( + supabase: SupabaseClient +): Promise { + const { error, count } = await supabase + .from('idempotency_keys') + .delete({ count: 'exact' }) + .lt('expires_at', new Date().toISOString()) + + if (error) { + throw new Error(`Idempotency cleanup failed: ${error.message}`) + } + return count ?? 0 +} diff --git a/lib/api/schemas.ts b/lib/api/schemas.ts index f985bb57..389842b6 100644 --- a/lib/api/schemas.ts +++ b/lib/api/schemas.ts @@ -396,6 +396,9 @@ export const UpdateSettingsSchema = z.object({ invoice_credit_terms_text: z.string().nullable().optional(), // AI agent flow ai_flow_enabled: z.boolean().optional(), + // Agent auto-commit (low-risk ops staged by trusted agents) + agent_auto_commit_enabled: z.boolean().optional(), + agent_auto_commit_max_amount: z.number().nullable().optional(), }).refine( (data) => { // BFL 3 kap.: Enskild firma must have fiscal year starting January diff --git a/lib/auth/api-keys.ts b/lib/auth/api-keys.ts index 1b170a80..f3836d1f 100644 --- a/lib/auth/api-keys.ts +++ b/lib/auth/api-keys.ts @@ -7,13 +7,15 @@ const KEY_PREFIX = 'gnubok_sk_' export const API_KEY_SCOPES = { 'transactions:read': { label: 'Transaktioner — läs', description: 'Lista transaktioner, mallförslag, kategoriförslag (3 verktyg)' }, - 'transactions:write': { label: 'Transaktioner — skriv', description: 'Kategorisera, kvittomatchning, koppling mot faktura (3 verktyg)' }, + 'transactions:write': { label: 'Transaktioner — skriv', description: 'Kategorisera, av-kategorisera, kvittomatchning, koppling mot faktura (4 verktyg)' }, 'customers:read': { label: 'Kunder — läs', description: 'Lista kunder (1 verktyg)' }, 'customers:write': { label: 'Kunder — skriv', description: 'Skapa kunder (1 verktyg)' }, 'invoices:read': { label: 'Fakturor — läs', description: 'Lista fakturor (1 verktyg)' }, 'invoices:write': { label: 'Fakturor — skriv', description: 'Skapa, skicka, markera betald/skickad (4 verktyg)' }, 'suppliers:read': { label: 'Leverantörer — läs', description: 'Lista leverantörer och leverantörsfakturor (2 verktyg)' }, - 'reports:read': { label: 'Rapporter — läs', description: 'Kontoplan, huvudbok, balansräkning, resultaträkning, moms, KPI, reskontra, perioder, bankavstämning (11 verktyg)' }, + 'suppliers:write': { label: 'Leverantörer — skriv', description: 'Godkänn och kreditera leverantörsfakturor (2 verktyg)' }, + 'reports:read': { label: 'Rapporter — läs', description: 'Kontoplan, huvudbok, balansräkning, resultaträkning, moms, KPI, reskontra, perioder, bankavstämning, SIE-export (12 verktyg)' }, + 'bookkeeping:write': { label: 'Bokföring — skriv', description: 'Stänga/låsa perioder, ingående balans, bokslut, SIE-import, voucher-gap-förklaringar' }, 'payroll:read': { label: 'Löner — läs', description: 'Lista anställda, lönekörningar, lönejournal (3 verktyg)' }, 'payroll:write': { label: 'Löner — skriv', description: 'Skapa lönekörning, beräkna, generera AGI (3 verktyg)' }, } as const @@ -36,8 +38,9 @@ export const SCOPE_GROUPS = [ { domain: 'transactions', label: 'Transaktioner', read: 'transactions:read' as const, write: 'transactions:write' as const }, { domain: 'customers', label: 'Kunder', read: 'customers:read' as const, write: 'customers:write' as const }, { domain: 'invoices', label: 'Fakturor', read: 'invoices:read' as const, write: 'invoices:write' as const }, - { domain: 'suppliers', label: 'Leverantörer', read: 'suppliers:read' as const, write: null }, + { domain: 'suppliers', label: 'Leverantörer', read: 'suppliers:read' as const, write: 'suppliers:write' as const }, { domain: 'reports', label: 'Rapporter', read: 'reports:read' as const, write: null }, + { domain: 'bookkeeping', label: 'Bokföring', read: null, write: 'bookkeeping:write' as const }, { domain: 'payroll', label: 'Löner', read: 'payroll:read' as const, write: 'payroll:write' as const }, ] as const @@ -85,6 +88,26 @@ export const TOOL_SCOPE_MAP: Record = { gnubok_create_salary_run: 'payroll:write', gnubok_calculate_salary_run: 'payroll:write', gnubok_generate_agi: 'payroll:write', + // Bookkeeping write (Stream 1 Phase 1) — high-risk, always staged + gnubok_close_period: 'bookkeeping:write', + gnubok_lock_period: 'bookkeeping:write', + gnubok_unlock_period: 'bookkeeping:write', + gnubok_run_year_end: 'bookkeeping:write', + gnubok_set_opening_balances: 'bookkeeping:write', + gnubok_run_currency_revaluation: 'bookkeeping:write', + gnubok_explain_voucher_gap: 'bookkeeping:write', + gnubok_list_voucher_gaps: 'reports:read', + // Transaction reversal (medium-risk) + gnubok_uncategorize_transaction: 'transactions:write', + // SIE export (read-only) + import (write) + gnubok_export_sie: 'reports:read', + gnubok_import_sie: 'bookkeeping:write', + // Supplier invoice lifecycle + gnubok_approve_supplier_invoice: 'suppliers:write', + gnubok_credit_supplier_invoice: 'suppliers:write', + // Invoice conversion + crediting + gnubok_convert_invoice: 'invoices:write', + gnubok_credit_invoice: 'invoices:write', } export function validateScopes(scopes: unknown): ApiKeyScope[] | null { @@ -126,12 +149,27 @@ export function extractBearerToken(request: Request): string | null { /** * Validate an API key and enforce rate limiting. * Uses the DB RPC for atomic check + increment. - * Returns the user_id and effective scopes on success, or an error with HTTP status. + * Returns the user_id, company_id, api_key_id, name, and effective scopes on + * success, or an error with HTTP status. * null scopes in DB → DEFAULT_SCOPES (read-only). + * + * api_key_id and api_key_name are returned so callers (e.g. the MCP server) + * can record actor attribution on pending_operations and audit_log. + * They may be undefined when the deployed DB hasn't yet run the migration + * that adds them to the RPC return shape. */ export async function validateApiKey( key: string -): Promise<{ userId: string; companyId: string; scopes: ApiKeyScope[] } | { error: string; status: number }> { +): Promise< + | { + userId: string + companyId: string + apiKeyId?: string + apiKeyName?: string + scopes: ApiKeyScope[] + } + | { error: string; status: number } +> { if (!key.startsWith(KEY_PREFIX)) { return { error: 'Invalid API key format', status: 401 } } @@ -156,6 +194,8 @@ export async function validateApiKey( return { userId: row.user_id, companyId: row.company_id, + apiKeyId: row.api_key_id, + apiKeyName: row.api_key_name, scopes: validateScopes(row.scopes) ?? DEFAULT_SCOPES, } } diff --git a/lib/bookkeeping/invoice-entries.ts b/lib/bookkeeping/invoice-entries.ts index 9846ab41..0c05a3c4 100644 --- a/lib/bookkeeping/invoice-entries.ts +++ b/lib/bookkeeping/invoice-entries.ts @@ -374,7 +374,14 @@ export async function createCreditNoteJournalEntry( userId: string, creditNote: Invoice, entityType: EntityType = 'enskild_firma', - customerName?: string + customerName?: string, + /** + * Original voucher reference (e.g. "A-42") to embed in the JE description and + * line-level descriptions. BFL 5 kap. 5 § requires a correction to point back + * to the corrected verifikation; the invoice number alone is insufficient + * because it doesn't identify the entry in the verifikationsserie. + */ + originalVoucherRef?: string ): Promise { const fiscalPeriodId = await findFiscalPeriod(supabase, companyId, creditNote.invoice_date) if (!fiscalPeriodId) { @@ -384,6 +391,7 @@ export async function createCreditNoteJournalEntry( const lines: CreateJournalEntryLineInput[] = [] const tag = invoiceTag(creditNote) + const lineSuffix = originalVoucherRef ? ` (avser ${originalVoucherRef})` : '' // Generate reversed revenue + VAT lines per rate group (debit side for credit notes) const debitLines: CreateJournalEntryLineInput[] = [] @@ -399,7 +407,7 @@ export async function createCreditNoteJournalEntry( ...line, debit_amount: Math.abs(line.credit_amount), credit_amount: Math.abs(line.debit_amount), - line_description: `Kreditfaktura ${tag}`, + line_description: `Kreditfaktura ${tag}${lineSuffix}`, }) } } else { @@ -421,7 +429,7 @@ export async function createCreditNoteJournalEntry( account_number: vatAccount, debit_amount: absVat, credit_amount: 0, - line_description: `Moms kreditfaktura ${tag}`, + line_description: `Moms kreditfaktura ${tag}${lineSuffix}`, }) } } @@ -437,10 +445,13 @@ export async function createCreditNoteJournalEntry( line_description: `Kreditfaktura ${tag}`, }) + const baseDescription = buildInvoiceDescription('Kreditfaktura', creditNote.invoice_number, customerName, creditNote.id) const input: CreateJournalEntryInput = { fiscal_period_id: fiscalPeriodId, entry_date: creditNote.invoice_date, - description: buildInvoiceDescription('Kreditfaktura', creditNote.invoice_number, customerName, creditNote.id), + description: originalVoucherRef + ? `${baseDescription} (avser verifikation ${originalVoucherRef})` + : baseDescription, source_type: 'credit_note', source_id: creditNote.id, lines, diff --git a/lib/core/bookkeeping/__tests__/period-service.test.ts b/lib/core/bookkeeping/__tests__/period-service.test.ts index 72b5ac6c..20df55b7 100644 --- a/lib/core/bookkeeping/__tests__/period-service.test.ts +++ b/lib/core/bookkeeping/__tests__/period-service.test.ts @@ -29,7 +29,7 @@ function makeClient() { } } -import { lockPeriod, closePeriod, createNextPeriod } from '../period-service' +import { lockPeriod, unlockPeriod, closePeriod, createNextPeriod } from '../period-service' beforeEach(() => { vi.clearAllMocks() @@ -124,6 +124,56 @@ describe('closePeriod', () => { }) }) +describe('unlockPeriod', () => { + it('clears locked_at and emits period.unlocked', async () => { + const period = makeFiscalPeriod({ + id: 'fp-1', + locked_at: '2024-12-31T23:59:59Z', + is_closed: false, + }) + const unlocked = { ...period, locked_at: null } + + results = [ + { data: period, error: null }, + { data: unlocked, error: null }, + { data: null, error: null }, // audit_log insert + ] + + const handler = vi.fn() + eventBus.on('period.unlocked', handler) + + const supabase = makeClient() + const result = await unlockPeriod(supabase as never, 'company-1', 'user-1', 'fp-1') + + expect(result.locked_at).toBeNull() + expect(handler).toHaveBeenCalledOnce() + }) + + it('rejects period that is not locked', async () => { + const period = makeFiscalPeriod({ id: 'fp-1', locked_at: null, is_closed: false }) + + results = [{ data: period, error: null }] + + const supabase = makeClient() + await expect(unlockPeriod(supabase as never, 'company-1', 'user-1', 'fp-1')).rejects.toThrow('not locked') + }) + + it('rejects closed period', async () => { + const period = makeFiscalPeriod({ + id: 'fp-1', + locked_at: '2024-12-31T23:59:59Z', + is_closed: true, + }) + + results = [{ data: period, error: null }] + + const supabase = makeClient() + await expect(unlockPeriod(supabase as never, 'company-1', 'user-1', 'fp-1')).rejects.toThrow( + 'Cannot unlock a closed period' + ) + }) +}) + describe('createNextPeriod', () => { it('calculates correct dates for standard (Jan-Dec) fiscal year', async () => { const current = makeFiscalPeriod({ diff --git a/lib/core/bookkeeping/period-service.ts b/lib/core/bookkeeping/period-service.ts index 06319501..299d3d8f 100644 --- a/lib/core/bookkeeping/period-service.ts +++ b/lib/core/bookkeeping/period-service.ts @@ -72,6 +72,74 @@ export async function lockPeriod( return result } +/** + * Unlock a fiscal period — clears `locked_at` so new entries can be posted. + * Requires: period exists, belongs to company, is currently locked, not closed. + */ +export async function unlockPeriod( + supabase: SupabaseClient, + companyId: string, + userId: string, + fiscalPeriodId: string +): Promise { + const { data: period, error: fetchError } = await supabase + .from('fiscal_periods') + .select('*') + .eq('id', fiscalPeriodId) + .eq('company_id', companyId) + .single() + + if (fetchError || !period) { + throw new Error('Fiscal period not found') + } + + if (period.is_closed) { + throw new Error('Cannot unlock a closed period') + } + + if (!period.locked_at) { + throw new Error('Period is not locked') + } + + const priorLockedAt = period.locked_at + + const { data: updated, error: updateError } = await supabase + .from('fiscal_periods') + .update({ locked_at: null }) + .eq('id', fiscalPeriodId) + .eq('company_id', companyId) + .select() + .single() + + if (updateError || !updated) { + throw new Error(`Failed to unlock period: ${updateError?.message}`) + } + + const result = updated as FiscalPeriod + + // BFNAR 2013:2 kap. 8 (behandlingshistorik): unlocking a locked period is a + // sensitive control change. Persist it to the immutable audit_log (not just + // event_log, which has 30-day TTL) so an auditor can reconstruct who + // unlocked which period and when, even years later. + await supabase.from('audit_log').insert({ + user_id: userId, + company_id: companyId, + action: 'UPDATE', + table_name: 'fiscal_periods', + record_id: fiscalPeriodId, + description: `Period unlocked: ${result.name} (${result.period_start} – ${result.period_end})`, + old_state: { locked_at: priorLockedAt }, + new_state: { locked_at: null }, + }) + + await eventBus.emit({ + type: 'period.unlocked', + payload: { period: result, companyId, userId }, + }) + + return result +} + /** * Close a fiscal period — marks it as permanently closed. * Requires: period is locked AND closing_entry_id is set (year-end must run first). diff --git a/lib/errors/__tests__/get-structured-error.test.ts b/lib/errors/__tests__/get-structured-error.test.ts new file mode 100644 index 00000000..1f721a61 --- /dev/null +++ b/lib/errors/__tests__/get-structured-error.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect } from 'vitest' +import { getStructuredError } from '../get-structured-error' + +describe('getStructuredError', () => { + it('extracts code from structured bookkeeping error', () => { + const result = getStructuredError({ + error: { + code: 'JOURNAL_ENTRY_NOT_BALANCED', + message: 'Debits do not match credits', + details: { totalDebit: 100, totalCredit: 90 }, + }, + }) + expect(result.code).toBe('JOURNAL_ENTRY_NOT_BALANCED') + expect(result.message_sv).toContain('balanserar inte') + expect(result.message_en).toContain('Debits') + expect(result.remediation?.description).toContain('Recalculate') + }) + + it('extracts code from typed error class with code property', () => { + class FakeBookkeepingError extends Error { + readonly code = 'ACCOUNTS_NOT_IN_CHART' + readonly accountNumbers = ['1930', '2641'] + constructor() { + super('Accounts not in chart') + } + } + const result = getStructuredError(new FakeBookkeepingError()) + expect(result.code).toBe('ACCOUNTS_NOT_IN_CHART') + expect(result.remediation?.resource).toBe('gnubok://chart-of-accounts') + }) + + it('infers PERIOD_NOT_LOCKED from message text', () => { + const result = getStructuredError(new Error('Period must be locked before closing')) + expect(result.code).toBe('PERIOD_NOT_LOCKED') + expect(result.remediation?.tool).toBe('gnubok_lock_period') + }) + + it('infers PERIOD_HAS_UNBOOKED_TRANSACTIONS from Swedish lock-error message', () => { + const result = getStructuredError( + new Error('Kan inte låsa period: 3 affärstransaktion(er) saknar bokföring.') + ) + expect(result.code).toBe('PERIOD_HAS_UNBOOKED_TRANSACTIONS') + expect(result.remediation?.tool).toBe('gnubok_list_uncategorized_transactions') + }) + + it('produces INSUFFICIENT_SCOPE remediation with attempted scope', () => { + const result = getStructuredError( + new Error('Insufficient scope: this API key does not have the "bookkeeping:write" scope'), + { attemptedScope: 'bookkeeping:write' } + ) + expect(result.code).toBe('INSUFFICIENT_SCOPE') + expect(result.remediation?.description).toContain('"bookkeeping:write"') + expect(result.remediation?.resource).toBe('gnubok://capabilities') + }) + + it('infers TRANSACTION_ALREADY_CATEGORIZED', () => { + const result = getStructuredError(new Error('Transaction already has a journal entry')) + expect(result.code).toBe('TRANSACTION_ALREADY_CATEGORIZED') + expect(result.remediation?.tool).toBe('gnubok_uncategorize_transaction') + }) + + it('falls back to UNKNOWN_ERROR when no code or pattern matches', () => { + const result = getStructuredError(new Error('Something weird happened')) + expect(result.code).toBe('UNKNOWN_ERROR') + expect(result.remediation).toBeUndefined() + }) + + it('handles plain string errors', () => { + const result = getStructuredError('Period must be locked before closing') + expect(result.code).toBe('PERIOD_NOT_LOCKED') + expect(result.message_en).toBe('Period must be locked before closing') + }) + + it('handles null/undefined gracefully', () => { + const result = getStructuredError(null) + expect(result.code).toBe('UNKNOWN_ERROR') + expect(result.message_en).toBe('Unknown error') + expect(result.message_sv).toBeTruthy() + }) + + it('always returns Swedish message even with no match', () => { + const result = getStructuredError(new Error('Random gibberish XYZ')) + expect(result.message_sv).toBeTruthy() + expect(result.message_sv.length).toBeGreaterThan(0) + }) +}) diff --git a/lib/errors/get-structured-error.ts b/lib/errors/get-structured-error.ts new file mode 100644 index 00000000..100e8a31 --- /dev/null +++ b/lib/errors/get-structured-error.ts @@ -0,0 +1,183 @@ +/** + * Structured error shape designed for agents (MCP, automation) that need to + * dispatch on error programmatically rather than read the Swedish prose. + * + * Key design decisions: + * - code is machine-readable and stable; agents pattern-match on it + * - message_sv is the existing UI string from getErrorMessage() + * - message_en gives the agent a translation it can act on without parsing + * Swedish tokens + * - remediation, when present, points the agent at a tool/args/resource + * that fixes the problem. Optional — only set when there's a clear + * mechanical next step + * + * Used by the MCP server's tool error wrapper. UI callers continue to use the + * string-only getErrorMessage() — this is additive. + */ +import { getErrorMessage } from './get-error-message' + +export interface StructuredErrorRemediation { + description: string + tool?: string + args?: Record + resource?: string +} + +export interface StructuredError { + code: string + message_sv: string + message_en: string + remediation?: StructuredErrorRemediation +} + +interface StructuredErrorOptions { + /** + * Optional: scope the agent attempted to use, for INSUFFICIENT_SCOPE remediation. + */ + attemptedScope?: string + /** + * Optional: tool name being called, used in fallback remediation hints. + */ + toolName?: string +} + +const ERROR_CODE_REMEDIATION: Record = { + ACCOUNTS_NOT_IN_CHART: { + description: 'One or more BAS accounts referenced are not active in the chart of accounts. Activate them via the bookkeeping settings, or use a different category.', + resource: 'gnubok://chart-of-accounts', + }, + JOURNAL_ENTRY_NOT_BALANCED: { + description: 'Debits and credits do not match. Recalculate the lines so totals are equal before retrying.', + }, + FISCAL_PERIOD_NOT_FOUND: { + description: 'No fiscal period covers the entry date. Create or extend the relevant period before retrying.', + resource: 'gnubok://period/active', + }, + ENTRY_DATE_OUTSIDE_FISCAL_PERIOD: { + description: 'The entry date is outside the active fiscal period. Use a date inside an open period or create one that covers it.', + resource: 'gnubok://period/active', + }, + CANNOT_REVERSE_NON_POSTED: { + description: 'Only posted entries can be reversed. Commit the draft first or pick a posted entry.', + }, + CANNOT_CORRECT_NON_POSTED: { + description: 'Only posted entries can be corrected. Commit the draft first or pick a posted entry.', + }, + ENTRY_ALREADY_REVERSED: { + description: 'Another caller reversed this entry concurrently. Re-fetch the entry list and pick a different one.', + }, + PERIOD_NOT_LOCKED: { + description: 'The period must be locked before it can be closed. Call gnubok_lock_period first.', + tool: 'gnubok_lock_period', + }, + PERIOD_HAS_UNBOOKED_TRANSACTIONS: { + description: 'The period contains uncategorized business transactions. Categorize or mark them private before locking.', + tool: 'gnubok_list_uncategorized_transactions', + }, + YEAR_END_NOT_RUN: { + description: 'Year-end closing must be executed before the period can be closed. Run the year-end procedure first.', + }, + INSUFFICIENT_SCOPE: { + description: 'The current API key does not have the required scope. Mint a new key with the missing scope or grant it through the API key settings.', + resource: 'gnubok://capabilities', + }, + TRANSACTION_ALREADY_CATEGORIZED: { + description: 'The transaction already has a journal entry. Use gnubok_uncategorize_transaction first if you need to recategorize.', + tool: 'gnubok_uncategorize_transaction', + }, + INVOICE_ALREADY_SENT: { + description: 'The invoice is already sent or paid; sending again would create a duplicate.', + }, + IDEMPOTENCY_KEY_REUSE: { + description: 'This idempotency_key was previously used with a different request body. Use a fresh UUID for a new operation, or send the original request body to replay.', + }, +} + +/** + * Pull a stable code out of various error shapes. + */ +function extractCode(error: unknown): string | null { + if (typeof error !== 'object' || error === null) return null + + const obj = error as Record + + // Typed bookkeeping error: { code: 'JOURNAL_ENTRY_NOT_BALANCED', ... } + if (typeof obj.code === 'string' && /^[A-Z_]+$/.test(obj.code)) { + return obj.code + } + + // Wrapped error: { error: { code: '...' } } + if (typeof obj.error === 'object' && obj.error !== null) { + const inner = obj.error as Record + if (typeof inner.code === 'string' && /^[A-Z_]+$/.test(inner.code)) { + return inner.code + } + } + + return null +} + +/** + * Heuristically infer a code from the message text when nothing structured + * is available. Keeps known-error patterns programmatically dispatchable. + */ +function inferCode(message: string): string | null { + if (/Period must be locked before closing/i.test(message)) return 'PERIOD_NOT_LOCKED' + if (/Year-end closing must be executed/i.test(message)) return 'YEAR_END_NOT_RUN' + if (/Kan inte låsa period:.*affärstransaktion/i.test(message)) return 'PERIOD_HAS_UNBOOKED_TRANSACTIONS' + if (/Insufficient scope/i.test(message)) return 'INSUFFICIENT_SCOPE' + if (/already has a journal entry/i.test(message)) return 'TRANSACTION_ALREADY_CATEGORIZED' + if (/already been sent/i.test(message) || /already sent/i.test(message)) return 'INVOICE_ALREADY_SENT' + if (/locked\/closed fiscal period/i.test(message)) return 'PERIOD_LOCKED' + if (/Bokföringen är låst/i.test(message)) return 'PERIOD_LOCKED' + if (/Transaction not found/i.test(message)) return 'NOT_FOUND' + if (/Invoice not found/i.test(message)) return 'NOT_FOUND' + return null +} + +function extractEnglishMessage(error: unknown): string { + if (typeof error === 'string') return error + if (error instanceof Error) return error.message + if (typeof error === 'object' && error !== null) { + const obj = error as Record + if (typeof obj.error === 'string') return obj.error + if (typeof obj.message === 'string') return obj.message + if (typeof obj.error === 'object' && obj.error !== null) { + const inner = obj.error as Record + if (typeof inner.message === 'string') return inner.message + } + } + return 'Unknown error' +} + +/** + * Build a StructuredError for an arbitrary thrown value. + * + * Always returns a valid StructuredError; never throws. + */ +export function getStructuredError( + error: unknown, + options: StructuredErrorOptions = {} +): StructuredError { + const message_en = extractEnglishMessage(error) + const message_sv = getErrorMessage(error) + + const code = extractCode(error) ?? inferCode(message_en) ?? 'UNKNOWN_ERROR' + + let remediation = ERROR_CODE_REMEDIATION[code] + + // Specialize INSUFFICIENT_SCOPE with the actual scope name when known. + if (code === 'INSUFFICIENT_SCOPE' && options.attemptedScope && remediation) { + remediation = { + ...remediation, + description: `The current API key does not have the "${options.attemptedScope}" scope. Mint a new key with that scope or add it to the existing key in API settings.`, + } + } + + return { + code, + message_sv, + message_en, + ...(remediation ? { remediation } : {}), + } +} diff --git a/lib/events/types.ts b/lib/events/types.ts index b6310c8b..24dbed4e 100644 --- a/lib/events/types.ts +++ b/lib/events/types.ts @@ -38,6 +38,7 @@ export type CoreEvent = | { type: 'transaction.reconciled'; payload: { transaction: Transaction; journalEntryId: string; method: ReconciliationMethod; userId: string; companyId: string } } // Periods | { type: 'period.locked'; payload: { period: FiscalPeriod; userId: string; companyId: string } } + | { type: 'period.unlocked'; payload: { period: FiscalPeriod; userId: string; companyId: string } } | { type: 'period.year_closed'; payload: { period: FiscalPeriod; userId: string; companyId: string } } // Customers | { type: 'customer.created'; payload: { customer: Customer; userId: string; companyId: string } } diff --git a/lib/pending-operations/__tests__/actor-and-auto-commit.pg.test.ts b/lib/pending-operations/__tests__/actor-and-auto-commit.pg.test.ts new file mode 100644 index 00000000..7a47df7e --- /dev/null +++ b/lib/pending-operations/__tests__/actor-and-auto-commit.pg.test.ts @@ -0,0 +1,378 @@ +/** + * pg-real smoke tests for the four migrations introduced by the + * AI-native streams (actor model, auto-commit, expanded op types, idempotency). + * + * These don't replicate the unit-test coverage — they prove the schema and + * constraints behave as the application code assumes when running against a + * real Postgres with the migrations applied. + */ +import { describe, expect, it } from 'vitest' +import { getPool } from '@/tests/pg/setup' +import { seedCompany } from '@/tests/pg/fixtures' + +describe('pending_operations: actor model + risk + auto-commit columns', () => { + it('accepts the expanded actor_type and risk_level enums', async () => { + const { userId, companyId } = await seedCompany() + const pool = getPool() + + const result = await pool.query<{ + id: string + actor_type: string + risk_level: string + auto_commit_eligible: boolean + }>( + `INSERT INTO public.pending_operations ( + user_id, company_id, operation_type, title, params, preview_data, + actor_type, actor_id, actor_label, risk_level, auto_commit_eligible + ) VALUES ($1, $2, 'create_customer', 'pg-real test', '{}', '{}', + 'api_key', NULL, 'Claude Desktop', 'low', true) + RETURNING id, actor_type, risk_level, auto_commit_eligible`, + [userId, companyId], + ) + + expect(result.rows[0]).toMatchObject({ + actor_type: 'api_key', + risk_level: 'low', + auto_commit_eligible: true, + }) + }) + + it('rejects invalid actor_type via CHECK constraint', async () => { + const { userId, companyId } = await seedCompany() + await expect( + getPool().query( + `INSERT INTO public.pending_operations ( + user_id, company_id, operation_type, title, params, preview_data, actor_type, risk_level + ) VALUES ($1, $2, 'create_customer', 'x', '{}', '{}', 'martian', 'low')`, + [userId, companyId], + ), + ).rejects.toThrow(/check constraint|actor_type/i) + }) + + it('rejects invalid risk_level via CHECK constraint', async () => { + const { userId, companyId } = await seedCompany() + await expect( + getPool().query( + `INSERT INTO public.pending_operations ( + user_id, company_id, operation_type, title, params, preview_data, actor_type, risk_level + ) VALUES ($1, $2, 'create_customer', 'x', '{}', '{}', 'user', 'critical')`, + [userId, companyId], + ), + ).rejects.toThrow(/check constraint|risk_level/i) + }) + + it('blocks auto_committed_at when status is still pending', async () => { + const { userId, companyId } = await seedCompany() + await expect( + getPool().query( + `INSERT INTO public.pending_operations ( + user_id, company_id, operation_type, title, params, preview_data, + actor_type, risk_level, auto_committed_at + ) VALUES ($1, $2, 'create_customer', 'x', '{}', '{}', + 'api_key', 'low', now())`, + [userId, companyId], + ), + ).rejects.toThrow(/pending_ops_auto_commit_status|check constraint/i) + }) + + it('accepts the expanded operation_type enum (close_period, run_year_end, …)', async () => { + const { userId, companyId } = await seedCompany() + const expandedTypes = [ + 'close_period', 'lock_period', 'unlock_period', 'run_year_end', 'set_opening_balances', + 'run_currency_revaluation', 'explain_voucher_gap', 'uncategorize_transaction', + 'approve_supplier_invoice', 'credit_supplier_invoice', + 'credit_invoice', 'convert_invoice', 'import_sie', + ] + + for (const op of expandedTypes) { + const result = await getPool().query<{ id: string }>( + `INSERT INTO public.pending_operations ( + user_id, company_id, operation_type, title, params, preview_data + ) VALUES ($1, $2, $3, 'pg-real test', '{}', '{}') + RETURNING id`, + [userId, companyId, op], + ) + expect(result.rows[0]?.id).toBeTruthy() + } + }) +}) + +describe('audit_log: actor_type + actor_label columns', () => { + it('accepts INSERT with actor_type and actor_label', async () => { + const { userId } = await seedCompany() + const result = await getPool().query<{ id: string }>( + `INSERT INTO public.audit_log ( + user_id, action, table_name, actor_type, actor_label, description + ) VALUES ($1, 'INSERT', 'pending_operations', 'api_key', 'Claude Desktop', 'pg-real test') + RETURNING id`, + [userId], + ) + expect(result.rows[0]?.id).toBeTruthy() + }) +}) + +describe('company_settings: auto_commit columns', () => { + it('exposes agent_auto_commit_enabled (default false) and agent_auto_commit_max_amount (NULL)', async () => { + const { companyId } = await seedCompany() + + const settings = await getPool().query<{ + enabled: boolean + max_amount: string | null + }>( + `SELECT agent_auto_commit_enabled AS enabled, + agent_auto_commit_max_amount AS max_amount + FROM public.company_settings + WHERE company_id = $1`, + [companyId], + ) + + // company_settings may or may not have a default row; if it doesn't, the + // columns still exist on the table and we can inspect the catalog. + if (settings.rows.length === 0) { + const catalog = await getPool().query<{ name: string }>( + `SELECT column_name AS name FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = 'company_settings' + AND column_name IN ('agent_auto_commit_enabled', 'agent_auto_commit_max_amount')`, + ) + expect(catalog.rowCount).toBe(2) + return + } + + expect(settings.rows[0]?.enabled).toBe(false) + expect(settings.rows[0]?.max_amount).toBeNull() + }) +}) + +describe('idempotency_keys table', () => { + it('enforces unique (user_id, key) and 24h default expiry', async () => { + const { userId, companyId } = await seedCompany() + + await getPool().query( + `INSERT INTO public.idempotency_keys + (user_id, company_id, key, request_hash, scope, response_status, response_body) + VALUES ($1, $2, 'dup-key', 'hash-1', 'mcp_tool', 'success', '{}')`, + [userId, companyId], + ) + + await expect( + getPool().query( + `INSERT INTO public.idempotency_keys + (user_id, company_id, key, request_hash, scope, response_status, response_body) + VALUES ($1, $2, 'dup-key', 'hash-2', 'mcp_tool', 'success', '{}')`, + [userId, companyId], + ), + ).rejects.toThrow(/duplicate key|unique/i) + + const expiry = await getPool().query<{ expires_at: string; created_at: string }>( + `SELECT expires_at, created_at FROM public.idempotency_keys + WHERE user_id = $1 AND key = 'dup-key'`, + [userId], + ) + const created = new Date(expiry.rows[0]!.created_at).getTime() + const expires = new Date(expiry.rows[0]!.expires_at).getTime() + const hoursDelta = (expires - created) / 3_600_000 + // Expect the default ~24h gap (allow ±1 minute for clock skew). + expect(hoursDelta).toBeGreaterThan(23.95) + expect(hoursDelta).toBeLessThan(24.05) + }) + + it('rejects invalid response_status', async () => { + const { userId, companyId } = await seedCompany() + await expect( + getPool().query( + `INSERT INTO public.idempotency_keys + (user_id, company_id, key, request_hash, scope, response_status, response_body) + VALUES ($1, $2, 'bad-status', 'hash-x', 'mcp_tool', 'maybe', '{}')`, + [userId, companyId], + ), + ).rejects.toThrow(/check constraint|response_status/i) + }) + + it('rejects NULL company_id (multi-tenant scoping)', async () => { + const { userId } = await seedCompany() + await expect( + getPool().query( + `INSERT INTO public.idempotency_keys + (user_id, company_id, key, request_hash, scope, response_status, response_body) + VALUES ($1, NULL, 'k1', 'h1', 'mcp_tool', 'success', '{}')`, + [userId], + ), + ).rejects.toThrow(/null value|not.null/i) + }) + + it('allows the same key across two companies (scoped uniqueness)', async () => { + const { userId, companyId: company1 } = await seedCompany() + const { companyId: company2 } = await seedCompany() + const sameKey = 'shared-key-abc' + + await getPool().query( + `INSERT INTO public.idempotency_keys + (user_id, company_id, key, request_hash, scope, response_status, response_body) + VALUES ($1, $2, $3, 'h1', 'mcp_tool', 'success', '{}')`, + [userId, company1, sameKey], + ) + // Same user + same key but different company must NOT collide. + await expect( + getPool().query( + `INSERT INTO public.idempotency_keys + (user_id, company_id, key, request_hash, scope, response_status, response_body) + VALUES ($1, $2, $3, 'h2', 'mcp_tool', 'success', '{}')`, + [userId, company2, sameKey], + ), + ).resolves.toBeTruthy() + }) +}) + +describe('pending_operations: CAS + post-commit immutability', () => { + it('accepts the new committing transient status', async () => { + const { userId, companyId } = await seedCompany() + const result = await getPool().query<{ id: string; status: string }>( + `INSERT INTO public.pending_operations + (user_id, company_id, operation_type, status, title, params, preview_data) + VALUES ($1, $2, 'create_customer', 'committing', 'pg-real', '{}', '{}') + RETURNING id, status`, + [userId, companyId], + ) + expect(result.rows[0]?.status).toBe('committing') + }) + + it('CAS pattern (UPDATE … WHERE status=pending) only claims unclaimed rows', async () => { + const { userId, companyId } = await seedCompany() + const ins = await getPool().query<{ id: string }>( + `INSERT INTO public.pending_operations + (user_id, company_id, operation_type, status, title, params, preview_data) + VALUES ($1, $2, 'create_customer', 'pending', 'cas-test', '{}', '{}') + RETURNING id`, + [userId, companyId], + ) + const id = ins.rows[0]!.id + + // First claim succeeds. + const first = await getPool().query( + `UPDATE public.pending_operations SET status = 'committing' + WHERE id = $1 AND status = 'pending' RETURNING id`, + [id], + ) + expect(first.rowCount).toBe(1) + + // Second concurrent claim sees status='committing' and returns 0 rows. + const second = await getPool().query( + `UPDATE public.pending_operations SET status = 'committing' + WHERE id = $1 AND status = 'pending' RETURNING id`, + [id], + ) + expect(second.rowCount).toBe(0) + }) + + it('blocks UPDATE on rows in terminal status (committed)', async () => { + const { userId, companyId } = await seedCompany() + const ins = await getPool().query<{ id: string }>( + `INSERT INTO public.pending_operations + (user_id, company_id, operation_type, status, title, params, preview_data, resolved_at) + VALUES ($1, $2, 'create_customer', 'committed', 'imm-test', '{}', '{}', now()) + RETURNING id`, + [userId, companyId], + ) + const id = ins.rows[0]!.id + + await expect( + getPool().query( + `UPDATE public.pending_operations SET title = 'tampered' WHERE id = $1`, + [id], + ), + ).rejects.toThrow(/terminal state|BFL 7/i) + }) + + it('blocks UPDATE on rows in terminal status (rejected)', async () => { + const { userId, companyId } = await seedCompany() + const ins = await getPool().query<{ id: string }>( + `INSERT INTO public.pending_operations + (user_id, company_id, operation_type, status, title, params, preview_data, resolved_at) + VALUES ($1, $2, 'create_customer', 'rejected', 'imm-test', '{}', '{}', now()) + RETURNING id`, + [userId, companyId], + ) + const id = ins.rows[0]!.id + + await expect( + getPool().query( + `UPDATE public.pending_operations SET params = '{"x":1}' WHERE id = $1`, + [id], + ), + ).rejects.toThrow(/terminal state|BFL 7/i) + }) + + it('blocks DELETE on rows in terminal status', async () => { + const { userId, companyId } = await seedCompany() + const ins = await getPool().query<{ id: string }>( + `INSERT INTO public.pending_operations + (user_id, company_id, operation_type, status, title, params, preview_data, resolved_at) + VALUES ($1, $2, 'create_customer', 'committed', 'del-test', '{}', '{}', now()) + RETURNING id`, + [userId, companyId], + ) + const id = ins.rows[0]!.id + + await expect( + getPool().query(`DELETE FROM public.pending_operations WHERE id = $1`, [id]), + ).rejects.toThrow(/terminal state|BFL 7/i) + }) + + it('blocks UPDATE of params on non-terminal rows (BFL 7 underlag-immutability)', async () => { + const { userId, companyId } = await seedCompany() + const ins = await getPool().query<{ id: string }>( + `INSERT INTO public.pending_operations + (user_id, company_id, operation_type, status, title, params, preview_data) + VALUES ($1, $2, 'create_customer', 'pending', 'frozen-test', '{"name":"original"}', '{}') + RETURNING id`, + [userId, companyId], + ) + const id = ins.rows[0]!.id + + await expect( + getPool().query( + `UPDATE public.pending_operations SET params = '{"name":"tampered"}' WHERE id = $1`, + [id], + ), + ).rejects.toThrow(/frozen|underlag/i) + }) + + it('blocks UPDATE of operation_type on non-terminal rows', async () => { + const { userId, companyId } = await seedCompany() + const ins = await getPool().query<{ id: string }>( + `INSERT INTO public.pending_operations + (user_id, company_id, operation_type, status, title, params, preview_data) + VALUES ($1, $2, 'create_customer', 'pending', 'op-type-frozen', '{}', '{}') + RETURNING id`, + [userId, companyId], + ) + const id = ins.rows[0]!.id + + await expect( + getPool().query( + `UPDATE public.pending_operations SET operation_type = 'send_invoice' WHERE id = $1`, + [id], + ), + ).rejects.toThrow(/frozen/i) + }) + + it('allows committing → committed transition', async () => { + const { userId, companyId } = await seedCompany() + const ins = await getPool().query<{ id: string }>( + `INSERT INTO public.pending_operations + (user_id, company_id, operation_type, status, title, params, preview_data) + VALUES ($1, $2, 'create_customer', 'committing', 'transition-test', '{}', '{}') + RETURNING id`, + [userId, companyId], + ) + const id = ins.rows[0]!.id + + const upd = await getPool().query( + `UPDATE public.pending_operations + SET status = 'committed', resolved_at = now(), result_data = '{"ok":true}' + WHERE id = $1 RETURNING id`, + [id], + ) + expect(upd.rowCount).toBe(1) + }) +}) diff --git a/lib/pending-operations/__tests__/executors.test.ts b/lib/pending-operations/__tests__/executors.test.ts new file mode 100644 index 00000000..50ddd4c0 --- /dev/null +++ b/lib/pending-operations/__tests__/executors.test.ts @@ -0,0 +1,357 @@ +/** + * Unit tests for the executors added to bring every declared op type up to a + * callable state through `commitPendingOperation`. Tests run through the + * public dispatcher (executors are not exported individually) so the wiring + * is exercised alongside executor logic. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { eventBus } from '@/lib/events/bus' +import { createQueuedMockSupabase, makeInvoice, makeFiscalPeriod } from '@/tests/helpers' +import type { PendingOperation } from '@/types' + +vi.mock('@/lib/core/bookkeeping/period-service', async () => { + const actual = await vi.importActual( + '@/lib/core/bookkeeping/period-service' + ) + return { + ...actual, + unlockPeriod: vi.fn(), + } +}) + +vi.mock('@/lib/import/sie-parser', () => ({ + parseSIEFile: vi.fn(), + calculateFileHash: vi.fn(async () => 'mock-hash'), +})) + +vi.mock('@/lib/import/sie-import', () => ({ + executeSIEImport: vi.fn(), +})) + +vi.mock('@/lib/bookkeeping/invoice-entries', async () => { + const actual = + await vi.importActual( + '@/lib/bookkeeping/invoice-entries' + ) + return { + ...actual, + createCreditNoteJournalEntry: vi.fn(), + } +}) + +import { commitPendingOperation } from '../commit' +import { unlockPeriod } from '@/lib/core/bookkeeping/period-service' +import { parseSIEFile } from '@/lib/import/sie-parser' +import { executeSIEImport } from '@/lib/import/sie-import' +import { createCreditNoteJournalEntry } from '@/lib/bookkeeping/invoice-entries' + +function makePendingOp(overrides: Partial): PendingOperation { + return { + id: 'op-1', + user_id: 'user-1', + company_id: 'company-1', + operation_type: 'create_customer', + status: 'pending', + title: 'test', + params: {}, + preview_data: {}, + result_data: null, + actor_type: 'user', + actor_id: null, + actor_label: null, + risk_level: 'high', + auto_commit_eligible: false, + auto_committed_at: null, + created_at: '2026-05-03T00:00:00Z', + resolved_at: null, + updated_at: '2026-05-03T00:00:00Z', + ...overrides, + } as PendingOperation +} + +beforeEach(() => { + vi.clearAllMocks() + eventBus.clear() +}) + +// ─── unlock_period ────────────────────────────────────────────────── + +describe('commitPendingOperation: unlock_period', () => { + it('happy path: clears locked_at and returns committed', async () => { + const period = makeFiscalPeriod({ id: 'fp-1', locked_at: null }) + vi.mocked(unlockPeriod).mockResolvedValueOnce(period) + + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim + enqueue({ data: null, error: null }) // dispatcher's pending_operations update + + const op = makePendingOp({ + operation_type: 'unlock_period', + params: { fiscal_period_id: 'fp-1' }, + }) + + const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) + + expect(result.status).toBe('committed') + expect(result.data).toMatchObject({ period_id: 'fp-1', locked_at: null }) + expect(unlockPeriod).toHaveBeenCalledWith(expect.anything(), 'company-1', 'user-1', 'fp-1') + }) + + it('rejects when fiscal_period_id is missing', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim + enqueue({ data: null, error: null }) // dispatcher's reject update + const op = makePendingOp({ operation_type: 'unlock_period', params: {} }) + + const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) + + expect(result.status).toBe('failed') + expect(result.http_status).toBe(400) + expect(unlockPeriod).not.toHaveBeenCalled() + }) + + it('surfaces underlying service errors', async () => { + vi.mocked(unlockPeriod).mockRejectedValueOnce(new Error('Period is not locked')) + + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim + enqueue({ data: null, error: null }) // dispatcher's reject update on throw + const op = makePendingOp({ + operation_type: 'unlock_period', + params: { fiscal_period_id: 'fp-1' }, + }) + + const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) + + expect(result.status).toBe('failed') + expect(result.error).toMatch(/not locked/) + }) +}) + +// ─── import_sie ───────────────────────────────────────────────────── + +describe('commitPendingOperation: import_sie', () => { + it('happy path: parses, imports, returns committed with summary', async () => { + vi.mocked(parseSIEFile).mockReturnValueOnce({} as never) + vi.mocked(executeSIEImport).mockResolvedValueOnce({ + success: true, + importId: 'imp-1', + fiscalPeriodId: 'fp-1', + openingBalanceEntryId: 'ob-1', + journalEntriesCreated: 5, + journalEntryIds: ['je-1', 'je-2', 'je-3', 'je-4', 'je-5'], + errors: [], + warnings: ['minor warning'], + }) + + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim + enqueue({ data: null, error: null }) // dispatcher's update + + const op = makePendingOp({ + operation_type: 'import_sie', + params: { + file_content: '#FLAGGA 0\n', + filename: 'test.sie', + mappings: [], + create_fiscal_period: true, + import_opening_balances: true, + import_transactions: true, + }, + }) + + const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) + + expect(result.status).toBe('committed') + expect(result.data).toMatchObject({ + import_id: 'imp-1', + journal_entries_created: 5, + warnings: ['minor warning'], + }) + expect(parseSIEFile).toHaveBeenCalledWith('#FLAGGA 0\n') + }) + + it('rejects when required params are missing', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim + enqueue({ data: null, error: null }) // dispatcher's reject update + const op = makePendingOp({ operation_type: 'import_sie', params: { filename: 'x.sie' } }) + + const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) + + expect(result.status).toBe('failed') + expect(result.http_status).toBe(400) + expect(parseSIEFile).not.toHaveBeenCalled() + }) + + it('returns the executeSIEImport errors when success=false', async () => { + vi.mocked(parseSIEFile).mockReturnValueOnce({} as never) + vi.mocked(executeSIEImport).mockResolvedValueOnce({ + success: false, + importId: null, + fiscalPeriodId: null, + openingBalanceEntryId: null, + journalEntriesCreated: 0, + journalEntryIds: [], + errors: ['duplicate import'], + warnings: [], + }) + + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim + enqueue({ data: null, error: null }) // dispatcher's reject update + const op = makePendingOp({ + operation_type: 'import_sie', + params: { + file_content: '#FLAGGA 0\n', + filename: 'test.sie', + mappings: [], + create_fiscal_period: true, + import_opening_balances: false, + import_transactions: true, + }, + }) + + const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) + + expect(result.status).toBe('failed') + expect(result.error).toMatch(/duplicate import/) + }) +}) + +// ─── credit_invoice ───────────────────────────────────────────────── + +describe('commitPendingOperation: credit_invoice', () => { + it('happy path (accrual): inserts negated credit note and books JE', async () => { + const original = makeInvoice({ + id: 'inv-1', + invoice_number: 'F-2024001', + status: 'sent', + document_type: 'invoice', + subtotal: 1000, + vat_amount: 250, + total: 1250, + }) + const originalWithItems = { + ...original, + items: [ + { sort_order: 0, description: 'Service', quantity: 1, unit: 'st', unit_price: 1000, line_total: 1000, vat_rate: 25, vat_amount: 250 }, + ], + } + + const creditNoteRow = { ...original, id: 'cn-1', invoice_number: 'KR-F-2024001' } + const completeCreditNote = { ...creditNoteRow, customer: { name: 'Acme AB' }, items: [] } + + const { supabase, enqueue } = createQueuedMockSupabase() + // 0: CAS claim + enqueue({ data: { id: 'op-1' }, error: null }) + // 1: fetch original with items + enqueue({ data: originalWithItems, error: null }) + // 2: insert credit note + enqueue({ data: creditNoteRow, error: null }) + // 3: insert items (await thenable) + enqueue({ data: null, error: null }) + // 4: update original status='credited' + enqueue({ data: null, error: null }) + // 5: re-fetch complete credit note with customer + items + enqueue({ data: completeCreditNote, error: null }) + // 6: company_settings + enqueue({ data: { entity_type: 'aktiebolag', accounting_method: 'accrual' }, error: null }) + // 7: update invoice with journal_entry_id + enqueue({ data: null, error: null }) + // 8: dispatcher's pending_operations update + enqueue({ data: null, error: null }) + + vi.mocked(createCreditNoteJournalEntry).mockResolvedValueOnce({ id: 'je-1' } as never) + + const op = makePendingOp({ + operation_type: 'credit_invoice', + params: { invoice_id: 'inv-1', reason: 'Wrong amount' }, + }) + + const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) + + expect(result.status).toBe('committed') + expect(result.data).toMatchObject({ credit_note_id: 'cn-1', journal_entry_id: 'je-1' }) + expect(createCreditNoteJournalEntry).toHaveBeenCalled() + }) + + it('skips JE on cash accounting', async () => { + const original = makeInvoice({ id: 'inv-1', status: 'paid', document_type: 'invoice' }) + const originalWithItems = { ...original, items: [] } + const creditNoteRow = { ...original, id: 'cn-2', invoice_number: 'KR-F-2024001' } + const completeCreditNote = { ...creditNoteRow, customer: null, items: [] } + + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim + enqueue({ data: originalWithItems, error: null }) + enqueue({ data: creditNoteRow, error: null }) + enqueue({ data: null, error: null }) + enqueue({ data: null, error: null }) + enqueue({ data: completeCreditNote, error: null }) + enqueue({ data: { entity_type: 'enskild_firma', accounting_method: 'cash' }, error: null }) + // no JE update; go straight to dispatcher update + enqueue({ data: null, error: null }) + + const op = makePendingOp({ + operation_type: 'credit_invoice', + params: { invoice_id: 'inv-1' }, + }) + + const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) + + expect(result.status).toBe('committed') + expect(result.data).toMatchObject({ credit_note_id: 'cn-2', journal_entry_id: null }) + expect(createCreditNoteJournalEntry).not.toHaveBeenCalled() + }) + + it('auto-rejects when invoice is already credited (409)', async () => { + const original = makeInvoice({ id: 'inv-1', status: 'credited', document_type: 'invoice' }) + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim + enqueue({ data: { ...original, items: [] }, error: null }) + // dispatcher auto-reject path also does an update + enqueue({ data: null, error: null }) + + const op = makePendingOp({ + operation_type: 'credit_invoice', + params: { invoice_id: 'inv-1' }, + }) + + const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) + + expect(result.status).toBe('rejected') + expect(result.auto_rejected).toBe(true) + expect(result.http_status).toBe(409) + }) + + it('rejects when invoice_id is missing', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim + enqueue({ data: null, error: null }) // dispatcher's reject update + const op = makePendingOp({ operation_type: 'credit_invoice', params: {} }) + + const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) + + expect(result.status).toBe('failed') + expect(result.http_status).toBe(400) + }) + + it('rejects invoices with status outside sent/paid/overdue', async () => { + const original = makeInvoice({ id: 'inv-1', status: 'draft', document_type: 'invoice' }) + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim + enqueue({ data: { ...original, items: [] }, error: null }) + enqueue({ data: null, error: null }) // dispatcher's reject update + + const op = makePendingOp({ + operation_type: 'credit_invoice', + params: { invoice_id: 'inv-1' }, + }) + + const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) + + expect(result.status).toBe('failed') + expect(result.http_status).toBe(400) + }) +}) diff --git a/lib/pending-operations/__tests__/risk-tiers.test.ts b/lib/pending-operations/__tests__/risk-tiers.test.ts new file mode 100644 index 00000000..0b3e1156 --- /dev/null +++ b/lib/pending-operations/__tests__/risk-tiers.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect } from 'vitest' +import { getRiskLevel, isHighRisk, OPERATION_RISK_TIERS } from '../risk-tiers' + +describe('risk-tiers', () => { + it('classifies all currently-staged op types', () => { + // Op types that exist in the pending_operations CHECK constraint today. + const knownOps = [ + 'categorize_transaction', + 'create_customer', + 'create_invoice', + 'mark_invoice_paid', + 'send_invoice', + 'mark_invoice_sent', + 'match_transaction_invoice', + ] + for (const op of knownOps) { + expect(OPERATION_RISK_TIERS).toHaveProperty(op) + } + }) + + it('treats sending invoices and marking paid as high risk', () => { + expect(getRiskLevel('send_invoice')).toBe('high') + expect(getRiskLevel('mark_invoice_paid')).toBe('high') + expect(getRiskLevel('mark_invoice_sent')).toBe('high') + }) + + it('treats period close, year-end, and SIE import as high risk', () => { + expect(getRiskLevel('close_period')).toBe('high') + expect(getRiskLevel('lock_period')).toBe('high') + expect(getRiskLevel('run_year_end')).toBe('high') + expect(getRiskLevel('import_sie')).toBe('high') + expect(getRiskLevel('set_opening_balances')).toBe('high') + }) + + it('treats customer creation as low risk (no booking impact)', () => { + expect(getRiskLevel('create_customer')).toBe('low') + }) + + it('treats reversible bookings as medium risk', () => { + expect(getRiskLevel('categorize_transaction')).toBe('medium') + expect(getRiskLevel('match_transaction_invoice')).toBe('medium') + expect(getRiskLevel('create_invoice')).toBe('medium') + expect(getRiskLevel('uncategorize_transaction')).toBe('medium') + }) + + it('defaults unknown op types to high (fail-safe)', () => { + expect(getRiskLevel('totally_unknown_op')).toBe('high') + expect(isHighRisk('totally_unknown_op')).toBe(true) + }) + + it('isHighRisk returns true only for high-risk ops', () => { + expect(isHighRisk('send_invoice')).toBe(true) + expect(isHighRisk('create_customer')).toBe(false) + expect(isHighRisk('categorize_transaction')).toBe(false) + }) +}) diff --git a/lib/pending-operations/__tests__/should-auto-commit.test.ts b/lib/pending-operations/__tests__/should-auto-commit.test.ts new file mode 100644 index 00000000..6d856747 --- /dev/null +++ b/lib/pending-operations/__tests__/should-auto-commit.test.ts @@ -0,0 +1,154 @@ +import { describe, it, expect, vi } from 'vitest' +import { shouldAutoCommit } from '../should-auto-commit' + +function mockSettingsClient(settings: { agent_auto_commit_enabled?: boolean; agent_auto_commit_max_amount?: number | null } | null) { + return { + from: vi.fn().mockReturnValue({ + select: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + maybeSingle: vi.fn().mockResolvedValue( + settings === null + ? { data: null, error: null } + : { data: settings, error: null } + ), + }), + }), + }), + } as never +} + +describe('shouldAutoCommit', () => { + it('rejects high-risk ops regardless of any other config', async () => { + const supabase = mockSettingsClient({ agent_auto_commit_enabled: true }) + const decision = await shouldAutoCommit(supabase, 'company-1', { + operationType: 'send_invoice', + actorType: 'api_key', + }) + expect(decision.eligible).toBe(false) + expect(decision.risk_level).toBe('high') + expect(decision.reason).toContain('high-risk') + }) + + it('rejects user actors (they approve via UI)', async () => { + const supabase = mockSettingsClient({ agent_auto_commit_enabled: true }) + const decision = await shouldAutoCommit(supabase, 'company-1', { + operationType: 'create_customer', + actorType: 'user', + }) + expect(decision.eligible).toBe(false) + expect(decision.reason).toContain('approve via the UI') + }) + + it('rejects when company has not opted in', async () => { + const supabase = mockSettingsClient({ agent_auto_commit_enabled: false }) + const decision = await shouldAutoCommit(supabase, 'company-1', { + operationType: 'create_customer', + actorType: 'api_key', + }) + expect(decision.eligible).toBe(false) + expect(decision.reason).toContain('not opted in') + }) + + it('rejects medium-risk ops in current phase', async () => { + const supabase = mockSettingsClient({ agent_auto_commit_enabled: true }) + const decision = await shouldAutoCommit(supabase, 'company-1', { + operationType: 'categorize_transaction', + actorType: 'api_key', + }) + expect(decision.eligible).toBe(false) + expect(decision.reason).toContain('low-risk') + }) + + it('approves low-risk ops from api_key with company opt-in', async () => { + const supabase = mockSettingsClient({ agent_auto_commit_enabled: true }) + const decision = await shouldAutoCommit(supabase, 'company-1', { + operationType: 'create_customer', + actorType: 'api_key', + }) + expect(decision.eligible).toBe(true) + expect(decision.risk_level).toBe('low') + }) + + it('blocks low-risk op when amount exceeds threshold', async () => { + const supabase = mockSettingsClient({ + agent_auto_commit_enabled: true, + agent_auto_commit_max_amount: 1000, + }) + const decision = await shouldAutoCommit(supabase, 'company-1', { + operationType: 'create_customer', + actorType: 'api_key', + amount: 5000, + }) + expect(decision.eligible).toBe(false) + expect(decision.reason).toContain('exceeds') + }) + + it('approves low-risk op when amount within threshold', async () => { + const supabase = mockSettingsClient({ + agent_auto_commit_enabled: true, + agent_auto_commit_max_amount: 1000, + }) + const decision = await shouldAutoCommit(supabase, 'company-1', { + operationType: 'create_customer', + actorType: 'api_key', + amount: 500, + }) + expect(decision.eligible).toBe(true) + }) + + it('approves low-risk op when amount missing (no threshold check applies)', async () => { + const supabase = mockSettingsClient({ + agent_auto_commit_enabled: true, + agent_auto_commit_max_amount: 1000, + }) + const decision = await shouldAutoCommit(supabase, 'company-1', { + operationType: 'create_customer', + actorType: 'api_key', + // amount intentionally undefined + }) + expect(decision.eligible).toBe(true) + }) + + it('cron actors auto-commit non-high-risk without DB lookup', async () => { + const supabase = mockSettingsClient(null) + const decision = await shouldAutoCommit(supabase, 'company-1', { + operationType: 'create_customer', + actorType: 'cron', + }) + expect(decision.eligible).toBe(true) + expect(decision.reason).toContain('Cron') + }) + + it('cron actors still rejected for high-risk ops', async () => { + const supabase = mockSettingsClient(null) + const decision = await shouldAutoCommit(supabase, 'company-1', { + operationType: 'send_invoice', + actorType: 'cron', + }) + expect(decision.eligible).toBe(false) + }) + + it('falls back to human approval when settings missing', async () => { + const supabase = mockSettingsClient(null) + const decision = await shouldAutoCommit(supabase, 'company-1', { + operationType: 'create_customer', + actorType: 'api_key', + }) + expect(decision.eligible).toBe(false) + expect(decision.reason).toContain('Could not read company settings') + }) + + it('treats negative amounts (refunds) by absolute value', async () => { + const supabase = mockSettingsClient({ + agent_auto_commit_enabled: true, + agent_auto_commit_max_amount: 1000, + }) + const decision = await shouldAutoCommit(supabase, 'company-1', { + operationType: 'create_customer', + actorType: 'api_key', + amount: -5000, + }) + expect(decision.eligible).toBe(false) + expect(decision.reason).toContain('exceeds') + }) +}) diff --git a/lib/pending-operations/commit.ts b/lib/pending-operations/commit.ts new file mode 100644 index 00000000..833700ed --- /dev/null +++ b/lib/pending-operations/commit.ts @@ -0,0 +1,1595 @@ +/** + * Unified entry point for executing a pending_operation. + * + * Used by: + * - The web UI commit route (app/api/pending-operations/[id]/commit/route.ts) + * when a human clicks "Approve" + * - The MCP server (extensions/general/mcp-server/server.ts) when a trusted + * agent stages a low-risk op that the company has opted in to auto-commit + * + * Both paths converge here so the same audit trail, event emission, error + * handling, and status transition logic apply. + * + * The executor functions previously lived in the commit route. They are kept + * private to this module — call `commitPendingOperation()` to invoke them. + */ +import type { SupabaseClient } from '@supabase/supabase-js' +import { eventBus } from '@/lib/events' +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, + createCreditNoteJournalEntry, +} from '@/lib/bookkeeping/invoice-entries' +import { reverseEntry } from '@/lib/bookkeeping/engine' +import { closePeriod, lockPeriod, unlockPeriod } from '@/lib/core/bookkeeping/period-service' +import { + executeYearEndClosing, + generateOpeningBalances, +} from '@/lib/core/bookkeeping/year-end-service' +import { executeCurrencyRevaluation } from '@/lib/bookkeeping/currency-revaluation' +import { createSupplierCreditNoteEntry } from '@/lib/bookkeeping/supplier-invoice-entries' +import { parseSIEFile } from '@/lib/import/sie-parser' +import { executeSIEImport } from '@/lib/import/sie-import' +import type { AccountMapping } from '@/lib/import/types' +import { AccountsNotInChartError, isBookkeepingError } from '@/lib/bookkeeping/errors' +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 { ensureInvoiceNumber } from '@/lib/invoices/ensure-invoice-number' +import { createLogger } from '@/lib/logger' +import { appendProcessingHistory } from '@/lib/processing-history/append' +import type { + Transaction, + TransactionCategory, + EntityType, + VatTreatment, + Currency, + Invoice, + Customer, + PendingOperation, + CompanySettings, + InvoiceItem, + AccountingMethod, + CreditNote, +} from '@/types' + +const log = createLogger('pending-operations/commit') + +export interface CommitResult { + status: 'committed' | 'rejected' | 'failed' + data?: Record + error?: string + http_status?: number + auto_rejected?: boolean +} + +export interface CommitOptions { + /** Email address used as cc on send_invoice (typically the human user's email). */ + userEmail?: string + /** + * When true, the op was auto-committed by a trusted agent (no human in the + * loop). The status row is updated with `auto_committed_at` so the UI can + * surface this on /pending and in audit reports. + */ + isAutoCommit?: boolean +} + +// ── Helper: ensure fiscal period covers the date ────────────────── + +async function ensureFiscalPeriod( + supabase: SupabaseClient, + userId: string, + companyId: string, + date: string, + fiscalYearStartMonth: number = 1 +): Promise { + const { data: existing } = await supabase + .from('fiscal_periods') + .select('id') + .eq('company_id', companyId) + .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, + company_id: companyId, + 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 +} + +async function recordSkippedInvoiceJournalEntry( + invoiceId: string, + companyId: string, + userId: string, + operation: 'send_invoice' | 'mark_invoice_sent', + err: unknown +): Promise { + try { + const reasonCode = err instanceof AccountsNotInChartError + ? 'accounts_not_in_chart' + : 'journal_entry_error' + const accountNumbers = err instanceof AccountsNotInChartError ? err.accountNumbers : undefined + await appendProcessingHistory({ + companyId, + correlationId: invoiceId, + aggregateType: 'System', + aggregateId: invoiceId, + eventType: 'InvoiceJournalEntrySkipped', + payload: { + invoice_id: invoiceId, + operation, + reason_code: reasonCode, + ...(accountNumbers ? { account_numbers: accountNumbers } : {}), + }, + actor: { type: 'user', id: userId }, + occurredAt: new Date(), + }) + } catch (historyErr) { + log.warn('Failed to append InvoiceJournalEntrySkipped to processing_history', historyErr) + } +} + +// ── Executors ──────────────────────────────────────────────────── + +type ExecutorResult = { data?: Record; error?: string; status?: number } + +async function commitCategorizeTransaction( + supabase: SupabaseClient, + userId: string, + companyId: string, + params: Record +): Promise { + const txId = params.transaction_id as string + const category = params.category as TransactionCategory + const vatTreatment = params.vat_treatment as VatTreatment | undefined + + const { data: transaction, error: fetchError } = await supabase + .from('transactions').select('*').eq('id', txId).eq('company_id', companyId).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' + + const { data: settings } = await supabase + .from('company_settings').select('entity_type, fiscal_year_start_month').eq('company_id', companyId).single() + + const entityType: EntityType = (settings?.entity_type as EntityType) || 'enskild_firma' + const fiscalYearStartMonth = settings?.fiscal_year_start_month ?? 1 + + 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 } + } + + await ensureFiscalPeriod(supabase, userId, companyId, transaction.date, fiscalYearStartMonth) + + let journalEntryId: string | null = null + try { + const journalEntry = await createTransactionJournalEntry( + supabase, companyId, userId, transaction as Transaction, mappingResult + ) + if (journalEntry) journalEntryId = journalEntry.id + } catch (err) { + if (isBookkeepingError(err)) throw err + log.error('Failed to create journal entry:', err) + return { error: err instanceof Error ? err.message : 'Failed to create journal entry', status: 500 } + } + + 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 } + } + + try { + await upsertCounterpartyTemplate( + supabase, userId, transaction as Transaction, mappingResult, 'user_approved' + ) + } catch { /* non-critical */ } + + await eventBus.emit({ + type: 'transaction.categorized', + payload: { + transaction: transaction as Transaction, + account: mappingResult.debit_account, + taxCode: mappingResult.vat_lines[0]?.account_number || '', + userId, + companyId, + }, + }) + + return { data: { journal_entry_id: journalEntryId, category } } +} + +async function commitCreateCustomer( + supabase: SupabaseClient, + userId: string, + companyId: string, + params: Record +): Promise { + const { data, error } = await supabase + .from('customers') + .insert({ + user_id: userId, + company_id: companyId, + 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 } + + 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('company_id', companyId) + } + } catch (err) { + log.warn('Auto-VIES validation failed:', err) + } + } + + await eventBus.emit({ type: 'customer.created', payload: { customer: data as Customer, userId, companyId } }) + + return { data: { customer_id: data.id } } +} + +async function commitCreateInvoice( + supabase: SupabaseClient, + userId: string, + companyId: string, + params: Record +): Promise { + 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 + }> + + const { data: customer, error: customerError } = await supabase + .from('customers').select('*').eq('id', customerId).eq('company_id', companyId).single() + + if (customerError || !customer) { + return { error: 'Customer not found — they may have been deleted.', status: 404 } + } + + 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 + + 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) + } + } + + const uniqueRates = new Set(items.map((item) => item.vat_rate ?? vatRules.rate)) + const isMixedRate = uniqueRates.size > 1 + + const { data: invoice, error: invoiceError } = await supabase + .from('invoices') + .insert({ + user_id: userId, + company_id: companyId, + customer_id: customerId, + invoice_number: null, + 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 } + + 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) { + await supabase.from('invoices').delete().eq('id', invoice.id) + return { error: itemsError.message, status: 500 } + } + + 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, companyId }, + }) + } + + return { data: { invoice_id: invoice.id, invoice_number: invoice.invoice_number } } +} + +async function commitMarkInvoicePaid( + supabase: SupabaseClient, + userId: string, + companyId: string, + params: Record +): Promise { + 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('company_id', companyId) + .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('company_id', companyId).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, companyId, userId, invoice as Invoice, paymentDate, undefined, invoice.customer?.name + ) + journalEntryId = je?.id ?? null + } else { + const je = await createInvoiceCashEntry( + supabase, companyId, 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('company_id', companyId) + + if (updateError) return { error: 'Failed to update invoice status', status: 500 } + + return { data: { status: 'paid', journal_entry_id: journalEntryId } } +} + +async function commitSendInvoice( + supabase: SupabaseClient, + userId: string, + companyId: string, + params: Record, + userEmail?: string +): Promise { + 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('company_id', companyId) + .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('company_id', companyId).single() + + if (companyError || !company) return { error: 'Company settings missing', status: 500 } + + try { + await ensureInvoiceNumber(supabase, companyId, invoice as Invoice) + } catch (err) { + return { error: `Failed to assign invoice number: ${err instanceof Error ? err.message : 'unknown'}`, 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.trade_name || 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('company_id', companyId) + + 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, companyId, 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 (err) { + await recordSkippedInvoiceJournalEntry(invoiceId, companyId, userId, 'send_invoice', err) + } + } + + if (isRealInvoice) { + try { + const pdfArrayBuffer = new Uint8Array(pdfBuffer).buffer as ArrayBuffer + await uploadDocument(supabase, userId, companyId, { + 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, companyId } }) + + return { data: { message: `Invoice ${invoice.invoice_number} sent to ${customer.email}` } } +} + +async function commitMarkInvoiceSent( + supabase: SupabaseClient, + userId: string, + companyId: string, + params: Record +): Promise { + 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('company_id', companyId) + .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 } + + try { + await ensureInvoiceNumber(supabase, companyId, invoice as Invoice) + } catch (err) { + return { error: `Failed to assign invoice number: ${err instanceof Error ? err.message : 'unknown'}`, status: 500 } + } + + const { error: updateError } = await supabase + .from('invoices').update({ status: 'sent' }).eq('id', invoiceId).eq('company_id', companyId) + + 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('company_id', companyId).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, companyId, 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 (err) { + await recordSkippedInvoiceJournalEntry(invoiceId, companyId, userId, 'mark_invoice_sent', err) + } + } + + return { data: { status: 'sent', journal_entry_id: journalEntryId } } +} + +async function commitMatchTransactionInvoice( + supabase: SupabaseClient, + userId: string, + companyId: string, + params: Record +): Promise { + 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('company_id', companyId).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('company_id', companyId) + .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 } + } + + if (transaction.journal_entry_id) { + await reverseEntry(supabase, companyId, 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('company_id', companyId).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, companyId, userId, invoice as Invoice, transaction.date, entityType, invoice.customer?.name + ) + journalEntryId = je?.id ?? null + } else { + const je = await createInvoicePaymentJournalEntry( + supabase, companyId, userId, invoice as Invoice, transaction.date, undefined, invoice.customer?.name, paidAmount + ) + journalEntryId = je?.id ?? null + } + } catch (err) { + if (isBookkeepingError(err)) throw 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, + company_id: companyId, + 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, companyId }, + }) + } catch { /* non-critical */ } + + return { data: { invoice_status: newStatus, paid_amount: newPaidAmount, journal_entry_id: journalEntryId } } +} + +// ── Stream 1 Phase 1 + follow-up executors ─────────────────────── + +async function commitClosePeriod( + supabase: SupabaseClient, + userId: string, + companyId: string, + params: Record +): Promise { + const id = params.fiscal_period_id as string + if (!id) return { error: 'fiscal_period_id is required', status: 400 } + try { + const period = await closePeriod(supabase, companyId, userId, id) + return { data: { period_id: period.id, closed_at: period.closed_at } } + } catch (err) { + return { error: err instanceof Error ? err.message : 'Close failed', status: 400 } + } +} + +async function commitLockPeriod( + supabase: SupabaseClient, + userId: string, + companyId: string, + params: Record +): Promise { + const id = params.fiscal_period_id as string + if (!id) return { error: 'fiscal_period_id is required', status: 400 } + try { + const period = await lockPeriod(supabase, companyId, userId, id) + return { data: { period_id: period.id, locked_at: period.locked_at } } + } catch (err) { + return { error: err instanceof Error ? err.message : 'Lock failed', status: 400 } + } +} + +async function commitUnlockPeriod( + supabase: SupabaseClient, + userId: string, + companyId: string, + params: Record +): Promise { + const id = params.fiscal_period_id as string + if (!id) return { error: 'fiscal_period_id is required', status: 400 } + try { + const period = await unlockPeriod(supabase, companyId, userId, id) + return { data: { period_id: period.id, locked_at: period.locked_at } } + } catch (err) { + return { error: err instanceof Error ? err.message : 'Unlock failed', status: 400 } + } +} + +async function commitUncategorizeTransaction( + supabase: SupabaseClient, + userId: string, + companyId: string, + params: Record +): Promise { + const txId = params.transaction_id as string + const journalEntryId = params.journal_entry_id as string + if (!txId || !journalEntryId) return { error: 'transaction_id and journal_entry_id are required', status: 400 } + + try { + await reverseEntry(supabase, companyId, userId, journalEntryId) + } catch (err) { + if (isBookkeepingError(err)) throw err + return { error: err instanceof Error ? err.message : 'Reversal failed', status: 500 } + } + + const { error: updateError } = await supabase + .from('transactions') + .update({ is_business: null, category: null, journal_entry_id: null }) + .eq('id', txId) + .eq('company_id', companyId) + + if (updateError) return { error: 'Failed to reset transaction', status: 500 } + + return { data: { transaction_id: txId, reversed_journal_entry_id: journalEntryId } } +} + +async function commitRunYearEnd( + supabase: SupabaseClient, + userId: string, + companyId: string, + params: Record +): Promise { + const id = params.fiscal_period_id as string + if (!id) return { error: 'fiscal_period_id is required', status: 400 } + + try { + const result = await executeYearEndClosing(supabase, companyId, userId, id) + return { + data: { + closing_entry_id: result.closingEntry?.id ?? null, + next_period_id: result.nextPeriod?.id ?? null, + opening_balance_entry_id: result.openingBalanceEntry?.id ?? null, + }, + } + } catch (err) { + if (isBookkeepingError(err)) throw err + return { error: err instanceof Error ? err.message : 'Year-end failed', status: 400 } + } +} + +async function commitSetOpeningBalances( + supabase: SupabaseClient, + userId: string, + companyId: string, + params: Record +): Promise { + const closedId = params.closed_period_id as string + const nextId = params.next_period_id as string + if (!closedId || !nextId) return { error: 'closed_period_id and next_period_id are required', status: 400 } + + try { + const entry = await generateOpeningBalances(supabase, companyId, userId, closedId, nextId) + return { data: { opening_balance_entry_id: entry.id } } + } catch (err) { + if (isBookkeepingError(err)) throw err + return { error: err instanceof Error ? err.message : 'Opening balances failed', status: 400 } + } +} + +async function commitRunCurrencyRevaluation( + supabase: SupabaseClient, + userId: string, + companyId: string, + params: Record +): Promise { + const id = params.fiscal_period_id as string + const closingDate = params.closing_date as string + if (!id || !closingDate) return { error: 'fiscal_period_id and closing_date are required', status: 400 } + + try { + const result = await executeCurrencyRevaluation(supabase, companyId, closingDate, id, userId) + return { + data: result + ? { entry_id: result.entry.id, items_revalued: result.preview.items.length } + : { entry_id: null, items_revalued: 0, message: 'No foreign-currency items to revalue' }, + } + } catch (err) { + if (isBookkeepingError(err)) throw err + return { error: err instanceof Error ? err.message : 'Revaluation failed', status: 400 } + } +} + +async function commitExplainVoucherGap( + supabase: SupabaseClient, + userId: string, + companyId: string, + params: Record +): Promise { + const fiscalPeriodId = params.fiscal_period_id as string + const voucherSeries = params.voucher_series as string + const gapStart = Number(params.gap_start) + const gapEnd = Number(params.gap_end) + const explanation = params.explanation as string + if (!fiscalPeriodId || !voucherSeries || !gapStart || !gapEnd || !explanation?.trim()) { + return { error: 'fiscal_period_id, voucher_series, gap_start, gap_end, and explanation are required', status: 400 } + } + + const { data, error } = await supabase + .from('voucher_gap_explanations') + .insert({ + user_id: userId, + company_id: companyId, + fiscal_period_id: fiscalPeriodId, + voucher_series: voucherSeries, + gap_start: gapStart, + gap_end: gapEnd, + explanation: explanation.trim(), + }) + .select('id') + .single() + + if (error) return { error: error.message, status: 500 } + return { data: { explanation_id: data.id } } +} + +async function commitApproveSupplierInvoice( + supabase: SupabaseClient, + userId: string, + companyId: string, + params: Record +): Promise { + const id = params.supplier_invoice_id as string + if (!id) return { error: 'supplier_invoice_id is required', status: 400 } + + const { data: invoice } = await supabase + .from('supplier_invoices').select('*').eq('id', id).eq('company_id', companyId).single() + + if (!invoice) return { error: 'Supplier invoice not found', status: 404 } + if (invoice.status !== 'registered') { + return { error: 'Kan bara godkänna registrerade fakturor', status: 400 } + } + + const { data, error } = await supabase + .from('supplier_invoices') + .update({ status: 'approved' }) + .eq('id', id) + .eq('company_id', companyId) + .select() + .single() + + if (error) return { error: error.message, status: 500 } + + try { + await eventBus.emit({ + type: 'supplier_invoice.approved', + payload: { supplierInvoice: data, companyId, userId }, + }) + } catch { /* non-blocking */ } + + return { data: { supplier_invoice_id: id, status: 'approved' } } +} + +async function commitCreditSupplierInvoice( + supabase: SupabaseClient, + userId: string, + companyId: string, + params: Record +): Promise { + const id = params.supplier_invoice_id as string + if (!id) return { error: 'supplier_invoice_id is required', status: 400 } + + const { data: original, error: fetchError } = await supabase + .from('supplier_invoices') + .select('*, supplier:suppliers(*), items:supplier_invoice_items(*)') + .eq('id', id) + .eq('company_id', companyId) + .single() + + if (fetchError || !original) return { error: 'Supplier invoice not found', status: 404 } + if (original.status === 'credited') return { error: 'Fakturan har redan krediterats', status: 409 } + + const { data: arrivalNum } = await supabase.rpc('get_next_arrival_number', { p_company_id: companyId }) + + const { data: creditNote, error: creditError } = await supabase + .from('supplier_invoices') + .insert({ + user_id: userId, + company_id: companyId, + supplier_id: original.supplier_id, + arrival_number: arrivalNum, + supplier_invoice_number: `KREDIT-${original.supplier_invoice_number}`, + invoice_date: new Date().toISOString().split('T')[0], + due_date: new Date().toISOString().split('T')[0], + status: 'registered', + currency: original.currency, + exchange_rate: original.exchange_rate, + vat_treatment: original.vat_treatment, + reverse_charge: original.reverse_charge, + subtotal: original.subtotal, + subtotal_sek: original.subtotal_sek, + vat_amount: original.vat_amount, + vat_amount_sek: original.vat_amount_sek, + total: original.total, + total_sek: original.total_sek, + remaining_amount: 0, + is_credit_note: true, + credited_invoice_id: id, + }) + .select() + .single() + + if (creditError || !creditNote) return { error: creditError?.message ?? 'Failed to create credit note', status: 500 } + + const creditItems = (original.items ?? []).map((item: Record) => ({ + supplier_invoice_id: creditNote.id, + sort_order: item.sort_order, + description: item.description, + quantity: item.quantity, + unit: item.unit, + unit_price: item.unit_price, + line_total: item.line_total, + account_number: item.account_number, + vat_code: item.vat_code, + vat_rate: item.vat_rate, + vat_amount: item.vat_amount, + })) + await supabase.from('supplier_invoice_items').insert(creditItems) + + const { data: settings } = await supabase + .from('company_settings').select('accounting_method').eq('company_id', companyId).single() + const accountingMethod = settings?.accounting_method || 'accrual' + + let journalEntryId: string | null = null + if (accountingMethod === 'accrual') { + try { + const je = await createSupplierCreditNoteEntry( + supabase, + companyId, + userId, + creditNote, + creditItems as never, + original.supplier?.supplier_type || 'swedish_business', + original.supplier?.name + ) + if (je) { + journalEntryId = je.id + await supabase + .from('supplier_invoices') + .update({ registration_journal_entry_id: je.id }) + .eq('id', creditNote.id) + } + } catch (err) { + await supabase.from('supplier_invoices').delete().eq('id', creditNote.id).eq('company_id', companyId) + if (isBookkeepingError(err)) throw err + return { error: err instanceof Error ? err.message : 'Failed to book credit note', status: 500 } + } + } + + const newRemaining = Math.max(0, original.remaining_amount - original.total) + const newStatus = newRemaining <= 0 ? 'credited' : original.status + + await supabase + .from('supplier_invoices') + .update({ status: newStatus, remaining_amount: newRemaining }) + .eq('id', id) + + try { + await eventBus.emit({ + type: 'supplier_invoice.credited', + payload: { supplierInvoice: original, creditNote, companyId, userId }, + }) + } catch { /* non-blocking */ } + + return { data: { credit_note_id: creditNote.id, journal_entry_id: journalEntryId } } +} + +async function commitCreditInvoice( + supabase: SupabaseClient, + userId: string, + companyId: string, + params: Record +): Promise { + const id = params.invoice_id as string + const reason = params.reason as string | undefined + if (!id) return { error: 'invoice_id is required', status: 400 } + + const { data: original, error: fetchError } = await supabase + .from('invoices') + .select('*, items:invoice_items(*)') + .eq('id', id) + .eq('company_id', companyId) + .single() + + if (fetchError || !original) return { error: 'Original invoice not found', status: 404 } + if (original.document_type && original.document_type !== 'invoice') { + return { error: 'Credit notes can only be created from standard invoices', status: 400 } + } + if (original.status === 'credited') return { error: 'Invoice has already been credited', status: 409 } + if (!['sent', 'paid', 'overdue'].includes(original.status)) { + return { error: 'Only sent, paid, or overdue invoices can be credited', status: 400 } + } + + const today = new Date().toISOString().split('T')[0] + const creditNoteNumber = `KR-${original.invoice_number}` + + const { data: creditNote, error: creditNoteError } = await supabase + .from('invoices') + .insert({ + user_id: userId, + company_id: companyId, + customer_id: original.customer_id, + invoice_number: creditNoteNumber, + invoice_date: today, + due_date: today, + delivery_date: original.delivery_date ?? null, + currency: original.currency, + exchange_rate: original.exchange_rate, + exchange_rate_date: original.exchange_rate_date, + subtotal: -Math.abs(original.subtotal), + subtotal_sek: original.subtotal_sek != null ? -Math.abs(original.subtotal_sek) : null, + vat_amount: -Math.abs(original.vat_amount), + vat_amount_sek: original.vat_amount_sek != null ? -Math.abs(original.vat_amount_sek) : null, + total: -Math.abs(original.total), + total_sek: original.total_sek != null ? -Math.abs(original.total_sek) : null, + vat_treatment: original.vat_treatment, + vat_rate: original.vat_rate, + moms_ruta: original.moms_ruta, + reverse_charge_text: original.reverse_charge_text, + your_reference: original.your_reference, + our_reference: original.our_reference, + notes: reason || `Krediterar faktura ${original.invoice_number}`, + credited_invoice_id: id, + status: 'sent', + }) + .select() + .single() + + if (creditNoteError || !creditNote) { + return { error: creditNoteError?.message ?? 'Failed to create credit note', status: 500 } + } + + const creditItems = (original.items || []).map((item: { + sort_order: number + description: string + quantity: number + unit: string + unit_price: number + line_total: number + vat_rate?: number + vat_amount?: number + }) => ({ + invoice_id: creditNote.id, + sort_order: item.sort_order, + description: item.description, + quantity: -Math.abs(item.quantity), + unit: item.unit, + unit_price: item.unit_price, + line_total: -Math.abs(item.line_total), + vat_rate: item.vat_rate ?? 0, + vat_amount: -(item.vat_amount ? Math.abs(item.vat_amount) : 0), + })) + + const { error: itemsError } = await supabase + .from('invoice_items') + .insert(creditItems) + + if (itemsError) { + await supabase.from('invoices').delete().eq('id', creditNote.id) + return { error: itemsError.message, status: 500 } + } + + await supabase.from('invoices').update({ status: 'credited' }).eq('id', id) + + const { data: completeCreditNote } = await supabase + .from('invoices') + .select('*, customer:customers(*), items:invoice_items(*)') + .eq('id', creditNote.id) + .single() + + const { data: settings } = await supabase + .from('company_settings') + .select('entity_type, accounting_method') + .eq('company_id', companyId) + .single() + + const entityType = (settings?.entity_type as EntityType) || 'enskild_firma' + const accountingMethod = (settings?.accounting_method as AccountingMethod) || 'accrual' + + // Resolve the original verifikation reference so the credit-note JE can + // point back to the corrected entry per BFL 5 kap. 5 §. We tolerate + // missing-JE on the original (legacy data) — the description simply omits + // the voucher reference and keeps the invoice-number reference. + let originalVoucherRef: string | undefined + if (original.journal_entry_id) { + const { data: origJe } = await supabase + .from('journal_entries') + .select('voucher_series, voucher_number') + .eq('id', original.journal_entry_id) + .eq('company_id', companyId) + .maybeSingle() + if (origJe?.voucher_series && origJe?.voucher_number != null) { + originalVoucherRef = `${origJe.voucher_series}-${origJe.voucher_number}` + } + } + + let journalEntryId: string | null = null + if (completeCreditNote && accountingMethod === 'accrual') { + try { + const journalEntry = await createCreditNoteJournalEntry( + supabase, + companyId, + userId, + completeCreditNote as Invoice, + entityType, + completeCreditNote.customer?.name, + originalVoucherRef + ) + if (journalEntry) { + journalEntryId = journalEntry.id + await supabase + .from('invoices') + .update({ journal_entry_id: journalEntry.id }) + .eq('id', creditNote.id) + } + } catch (err) { + if (isBookkeepingError(err)) throw err + log.error('Failed to create credit note journal entry:', err) + } + + try { + await eventBus.emit({ + type: 'credit_note.created', + payload: { creditNote: completeCreditNote as CreditNote, companyId, userId }, + }) + } catch { /* non-blocking */ } + } + + return { data: { credit_note_id: creditNote.id, journal_entry_id: journalEntryId } } +} + +async function commitConvertInvoice( + supabase: SupabaseClient, + userId: string, + companyId: string, + params: Record +): Promise { + const id = params.invoice_id as string + if (!id) return { error: 'invoice_id is required', status: 400 } + + const { data: proforma, error: proformaError } = await supabase + .from('invoices').select('*, items:invoice_items(*)').eq('id', id).eq('company_id', companyId).single() + + if (proformaError || !proforma) return { error: 'Proformafakturan hittades inte', status: 404 } + if (proforma.document_type !== 'proforma') { + return { error: 'Endast proformafakturor kan konverteras', status: 400 } + } + if (proforma.status === 'cancelled') { + return { error: 'Denna proformafaktura har redan makuleras', status: 409 } + } + + const { data: invoice, error: invoiceError } = await supabase + .from('invoices') + .insert({ + user_id: userId, + company_id: companyId, + customer_id: proforma.customer_id, + invoice_number: null, + invoice_date: new Date().toISOString().split('T')[0], + due_date: proforma.due_date, + currency: proforma.currency, + exchange_rate: proforma.exchange_rate, + exchange_rate_date: proforma.exchange_rate_date, + subtotal: proforma.subtotal, + subtotal_sek: proforma.subtotal_sek, + vat_amount: proforma.vat_amount, + vat_amount_sek: proforma.vat_amount_sek, + total: proforma.total, + total_sek: proforma.total_sek, + vat_treatment: proforma.vat_treatment, + vat_rate: proforma.vat_rate, + moms_ruta: proforma.moms_ruta, + reverse_charge_text: proforma.reverse_charge_text, + your_reference: proforma.your_reference, + our_reference: proforma.our_reference, + notes: proforma.notes, + document_type: 'invoice', + converted_from_id: id, + }) + .select() + .single() + + if (invoiceError) return { error: invoiceError.message, status: 500 } + + try { + await ensureInvoiceNumber(supabase, companyId, invoice as Invoice) + } catch (err) { + await supabase.from('invoices').delete().eq('id', invoice.id) + return { error: err instanceof Error ? err.message : 'Failed to assign invoice number', status: 500 } + } + + const items = (proforma.items ?? []).map((item: Record) => ({ + invoice_id: invoice.id, + sort_order: item.sort_order, + description: item.description, + quantity: item.quantity, + unit: item.unit, + unit_price: item.unit_price, + line_total: item.line_total, + })) + + if (items.length > 0) { + const { error: itemsError } = await supabase.from('invoice_items').insert(items) + if (itemsError) { + await supabase.from('invoices').delete().eq('id', invoice.id) + return { error: itemsError.message, status: 500 } + } + } + + await supabase.from('invoices').update({ status: 'cancelled' }).eq('id', id) + + return { data: { invoice_id: invoice.id, invoice_number: invoice.invoice_number } } +} + +async function commitImportSie( + supabase: SupabaseClient, + userId: string, + companyId: string, + params: Record +): Promise { + const fileContent = params.file_content as string + const filename = params.filename as string + const mappings = params.mappings as AccountMapping[] | undefined + const createFiscalPeriod = Boolean(params.create_fiscal_period) + const importOpeningBalances = Boolean(params.import_opening_balances) + const importTransactions = Boolean(params.import_transactions) + const voucherSeries = params.voucher_series as string | undefined + + if (!fileContent || !filename || !Array.isArray(mappings)) { + return { error: 'file_content, filename, and mappings are required', status: 400 } + } + + let parsed + try { + parsed = parseSIEFile(fileContent) + } catch (err) { + return { error: err instanceof Error ? err.message : 'Failed to parse SIE file', status: 400 } + } + + try { + const result = await executeSIEImport(supabase, companyId, userId, parsed, mappings, { + filename, + fileContent, + createFiscalPeriod, + importOpeningBalances, + importTransactions, + voucherSeries, + }) + + if (!result.success) { + return { error: result.errors.join('; ') || 'SIE import failed', status: 400 } + } + + return { + data: { + import_id: result.importId, + fiscal_period_id: result.fiscalPeriodId, + opening_balance_entry_id: result.openingBalanceEntryId, + journal_entries_created: result.journalEntriesCreated, + warnings: result.warnings, + }, + } + } catch (err) { + if (isBookkeepingError(err)) throw err + return { error: err instanceof Error ? err.message : 'SIE import failed', status: 500 } + } +} + +// ── Public dispatcher ──────────────────────────────────────────── + +/** + * Execute a pending_operation by type, update its status row, and return a + * normalized CommitResult. + * + * Used by both the human-approval route and the auto-commit path. Status row + * transitions are applied here so the two callers stay consistent. + */ +export async function commitPendingOperation( + supabase: SupabaseClient, + userId: string, + companyId: string, + pendingOp: PendingOperation, + opts: CommitOptions = {} +): Promise { + // ── Atomic claim: flip status pending → committing in a single conditional + // update. If 0 rows are affected, another caller (auto-commit ↔ human + // approval, or two parallel approvals) already claimed this op and we + // must not run side-effects. Without this, both callers can pass the + // in-memory status check and double-book journal entries, send duplicate + // emails, etc. + const { data: claimed, error: claimError } = await supabase + .from('pending_operations') + .update({ status: 'committing' }) + .eq('id', pendingOp.id) + .eq('status', 'pending') + .select('id') + .maybeSingle() + + if (claimError) { + log.error('Failed to claim pending_operation:', claimError) + return { status: 'failed', error: 'Failed to claim operation', http_status: 500 } + } + if (!claimed) { + return { + status: 'failed', + error: 'Operation already claimed or resolved by another caller', + http_status: 409, + } + } + + let result: ExecutorResult + try { + switch (pendingOp.operation_type) { + case 'categorize_transaction': + result = await commitCategorizeTransaction(supabase, userId, companyId, pendingOp.params) + break + case 'create_customer': + result = await commitCreateCustomer(supabase, userId, companyId, pendingOp.params) + break + case 'create_invoice': + result = await commitCreateInvoice(supabase, userId, companyId, pendingOp.params) + break + case 'mark_invoice_paid': + result = await commitMarkInvoicePaid(supabase, userId, companyId, pendingOp.params) + break + case 'send_invoice': + result = await commitSendInvoice(supabase, userId, companyId, pendingOp.params, opts.userEmail) + break + case 'mark_invoice_sent': + result = await commitMarkInvoiceSent(supabase, userId, companyId, pendingOp.params) + break + case 'match_transaction_invoice': + result = await commitMatchTransactionInvoice(supabase, userId, companyId, pendingOp.params) + break + case 'close_period': + result = await commitClosePeriod(supabase, userId, companyId, pendingOp.params) + break + case 'lock_period': + result = await commitLockPeriod(supabase, userId, companyId, pendingOp.params) + break + case 'unlock_period': + result = await commitUnlockPeriod(supabase, userId, companyId, pendingOp.params) + break + case 'uncategorize_transaction': + result = await commitUncategorizeTransaction(supabase, userId, companyId, pendingOp.params) + break + case 'run_year_end': + result = await commitRunYearEnd(supabase, userId, companyId, pendingOp.params) + break + case 'set_opening_balances': + result = await commitSetOpeningBalances(supabase, userId, companyId, pendingOp.params) + break + case 'run_currency_revaluation': + result = await commitRunCurrencyRevaluation(supabase, userId, companyId, pendingOp.params) + break + case 'explain_voucher_gap': + result = await commitExplainVoucherGap(supabase, userId, companyId, pendingOp.params) + break + case 'approve_supplier_invoice': + result = await commitApproveSupplierInvoice(supabase, userId, companyId, pendingOp.params) + break + case 'credit_supplier_invoice': + result = await commitCreditSupplierInvoice(supabase, userId, companyId, pendingOp.params) + break + case 'convert_invoice': + result = await commitConvertInvoice(supabase, userId, companyId, pendingOp.params) + break + case 'credit_invoice': + result = await commitCreditInvoice(supabase, userId, companyId, pendingOp.params) + break + case 'import_sie': + result = await commitImportSie(supabase, userId, companyId, pendingOp.params) + break + default: + return { + status: 'failed', + error: `Unknown operation type: ${pendingOp.operation_type}`, + http_status: 400, + } + } + } catch (err) { + const isBkErr = isBookkeepingError(err) + const message = err instanceof Error ? err.message : (isBkErr ? 'Bookkeeping error' : 'Executor failed') + // Release the claim by transitioning to 'rejected' so the row never gets + // stuck in 'committing'. The error text is persisted in result_data for + // audit/debug. + await supabase + .from('pending_operations') + .update({ + status: 'rejected', + resolved_at: new Date().toISOString(), + result_data: { error: message, threw: true }, + }) + .eq('id', pendingOp.id) + return { + status: 'failed', + error: message, + http_status: isBkErr ? 400 : 500, + } + } + + if (result.error) { + const isAutoReject = result.status === 404 || result.status === 409 + await supabase + .from('pending_operations') + .update({ + status: 'rejected', + resolved_at: new Date().toISOString(), + result_data: isAutoReject + ? { auto_rejected: true, reason: result.error } + : { error: result.error, http_status: result.status }, + }) + .eq('id', pendingOp.id) + if (isAutoReject) { + return { + status: 'rejected', + auto_rejected: true, + error: result.error, + http_status: result.status, + } + } + return { + status: 'failed', + error: result.error, + http_status: result.status ?? 500, + } + } + + const now = new Date().toISOString() + const update: Record = { + status: 'committed', + resolved_at: now, + result_data: result.data || {}, + } + if (opts.isAutoCommit) { + update.auto_committed_at = now + } + + await supabase + .from('pending_operations') + .update(update) + .eq('id', pendingOp.id) + + return { + status: 'committed', + data: result.data, + } +} diff --git a/lib/pending-operations/risk-tiers.ts b/lib/pending-operations/risk-tiers.ts new file mode 100644 index 00000000..4496ef6a --- /dev/null +++ b/lib/pending-operations/risk-tiers.ts @@ -0,0 +1,62 @@ +/** + * Risk tier classification for pending_operations. + * + * Used by lib/pending-operations/should-auto-commit.ts to decide whether a + * staged proposal from a trusted agent can be auto-committed without human + * review. + * + * Tiering principles: + * - **low**: no booking impact, no external side-effects, no audit risk. + * A reasonable bookkeeper would never want to manually approve these. + * - **medium**: reversible booking impact (drafts, transaction + * categorization that can be uncategorized). Auto-commit is allowed for + * trusted agents under a configurable monetary threshold. + * - **high**: irreversible or compliance-critical. Sends external messages, + * locks/closes periods, or affects tax filings. NEVER auto-committed, + * regardless of company opt-in or trust level. + */ + +export type RiskLevel = 'low' | 'medium' | 'high' + +export const OPERATION_RISK_TIERS: Record = { + // ── Low: pure data, no booking impact ───────────────────────────── + create_customer: 'low', + + // ── Medium: reversible booking ───────────────────────────────────── + categorize_transaction: 'medium', + match_transaction_invoice: 'medium', + create_invoice: 'medium', // creates as draft; sending is a separate op + + // ── High: irreversible, compliance-critical, or external side-effects + send_invoice: 'high', // emails the customer + mark_invoice_paid: 'high', // posts payment journal entry + mark_invoice_sent: 'high', // assigns invoice number, accrual JE + + // ── Stream 1 Phase 1 ops (added when those tools land) ───────────── + close_period: 'high', + lock_period: 'high', + unlock_period: 'high', + set_opening_balances: 'high', + run_year_end: 'high', + run_currency_revaluation: 'high', + import_sie: 'high', + explain_voucher_gap: 'medium', + uncategorize_transaction: 'medium', + approve_supplier_invoice: 'high', + credit_supplier_invoice: 'high', + credit_invoice: 'high', + convert_invoice: 'medium', +} + +export function getRiskLevel(operationType: string): RiskLevel { + // Default to 'high' for unknown ops — fail-safe: unknown means human review. + return OPERATION_RISK_TIERS[operationType] ?? 'high' +} + +/** + * High-risk operations are NEVER auto-committed, regardless of company opt-in + * or actor trust. Encoded here (not in DB config) so it can't be bypassed. + */ +export function isHighRisk(operationType: string): boolean { + return getRiskLevel(operationType) === 'high' +} diff --git a/lib/pending-operations/should-auto-commit.ts b/lib/pending-operations/should-auto-commit.ts new file mode 100644 index 00000000..ef7bcdcf --- /dev/null +++ b/lib/pending-operations/should-auto-commit.ts @@ -0,0 +1,139 @@ +/** + * Decide whether a freshly-staged pending_operation should be auto-committed + * by a trusted agent without human approval. + * + * Defense-in-depth: high-risk operations (period close, year-end, send_invoice, + * etc.) are NEVER auto-committed regardless of company settings or actor + * trust — that gate lives in risk-tiers.ts and is checked here before any + * config lookup. + * + * Trust hierarchy: + * - 'user' actors are humans clicking in the UI; auto-commit doesn't apply + * (the click IS the approval) + * - 'api_key' / 'mcp_oauth' actors are agents; eligible for auto-commit if + * the company opts in and the op is low-risk + * - 'cron' actors are system tasks; always auto-commit (they have no + * human in the loop by design) + * + * Monetary threshold: + * When `agent_auto_commit_max_amount` is set, any low-risk op with a + * preview/payload amount above the threshold falls back to human approval. + * The amount is read from the preview_data — callers should put it under + * `amount` or `total` for the gate to find it. Missing amount → not blocked + * by the threshold (safe for ops like create_customer where there's no + * single dollar value). + */ +import type { SupabaseClient } from '@supabase/supabase-js' +import { isHighRisk, getRiskLevel, type RiskLevel } from './risk-tiers' + +export type AutoCommitActorType = 'user' | 'api_key' | 'mcp_oauth' | 'cron' + +export interface AutoCommitInput { + operationType: string + actorType: AutoCommitActorType + /** Optional monetary amount to check against agent_auto_commit_max_amount. */ + amount?: number | null +} + +export interface AutoCommitDecision { + eligible: boolean + reason: string + risk_level: RiskLevel +} + +/** + * Cheap pure-logic check that doesn't hit the DB. Used to short-circuit + * obvious "no" cases before reading company_settings. + */ +function precheck(input: AutoCommitInput): AutoCommitDecision | null { + const risk = getRiskLevel(input.operationType) + + if (isHighRisk(input.operationType)) { + return { + eligible: false, + reason: `Operation "${input.operationType}" is high-risk and never auto-committed.`, + risk_level: risk, + } + } + + if (input.actorType === 'user') { + return { + eligible: false, + reason: 'User actors approve via the UI; auto-commit does not apply.', + risk_level: risk, + } + } + + // Cron actors auto-commit non-high-risk regardless of company config. + // Resolved here so we don't read company_settings unnecessarily. + if (input.actorType === 'cron') { + return { + eligible: true, + reason: 'Cron actor: auto-commit allowed for non-high-risk ops.', + risk_level: risk, + } + } + + // For api_key/mcp_oauth: only low-risk is auto-committable in this phase. + // Reject medium-risk before the company_settings lookup so callers don't pay + // for a DB read that can't succeed. + if (risk !== 'low') { + return { + eligible: false, + reason: 'Only low-risk operations are auto-committable in the current phase.', + risk_level: risk, + } + } + + return null +} + +export async function shouldAutoCommit( + supabase: SupabaseClient, + companyId: string, + input: AutoCommitInput +): Promise { + const pre = precheck(input) + if (pre) return pre + + const risk = getRiskLevel(input.operationType) + + // api_key / mcp_oauth + low-risk: gated by company opt-in and threshold. + const { data: settings, error } = await supabase + .from('company_settings') + .select('agent_auto_commit_enabled, agent_auto_commit_max_amount') + .eq('company_id', companyId) + .maybeSingle() + + if (error || !settings) { + return { + eligible: false, + reason: 'Could not read company settings; defaulting to human approval.', + risk_level: risk, + } + } + + if (!settings.agent_auto_commit_enabled) { + return { + eligible: false, + reason: 'Company has not opted in to agent auto-commit.', + risk_level: risk, + } + } + + const max = settings.agent_auto_commit_max_amount + const amount = input.amount + if (max != null && amount != null && Math.abs(amount) > Number(max)) { + return { + eligible: false, + reason: `Amount ${amount} exceeds company auto-commit threshold ${max}.`, + risk_level: risk, + } + } + + return { + eligible: true, + reason: 'Low-risk op, trusted actor, company opted in, amount within limit.', + risk_level: risk, + } +} diff --git a/supabase/migrations/20260430120000_pending_operations_actor_and_risk.sql b/supabase/migrations/20260430120000_pending_operations_actor_and_risk.sql new file mode 100644 index 00000000..b5227da3 --- /dev/null +++ b/supabase/migrations/20260430120000_pending_operations_actor_and_risk.sql @@ -0,0 +1,128 @@ +-- Migration: Actor model + risk tier on pending_operations + audit_log +-- +-- Adds first-class actor attribution (user vs api_key vs mcp_oauth vs cron) and +-- a risk_level for risk-tiered approval policies. This is the foundation for +-- letting trusted agents auto-commit low-risk proposals while keeping high-risk +-- operations (period close, year-end, send_invoice, etc.) gated behind human +-- approval regardless of trust level. +-- +-- Why this lives here, not in app code: +-- - actor_type and risk_level are filtered/queried from the UI ("show me +-- only auto-committed actions") +-- - the same actor info needs to live in audit_log for compliance review +-- - having the columns enforced by check constraints prevents drift between +-- producers (MCP, OAuth, web, cron) + +-- ============================================================================= +-- 1. pending_operations: actor + risk columns +-- ============================================================================= + +ALTER TABLE public.pending_operations + ADD COLUMN actor_type TEXT NOT NULL DEFAULT 'user' CHECK (actor_type IN ( + 'user', 'api_key', 'mcp_oauth', 'cron' + )), + ADD COLUMN actor_id UUID, + ADD COLUMN actor_label TEXT, + ADD COLUMN risk_level TEXT NOT NULL DEFAULT 'high' CHECK (risk_level IN ( + 'low', 'medium', 'high' + )), + ADD COLUMN auto_commit_eligible BOOLEAN NOT NULL DEFAULT false, + ADD COLUMN auto_committed_at TIMESTAMPTZ; + +-- Index supporting the "auto-committed by Claude Desktop" filter tab on the +-- pending operations page. +CREATE INDEX idx_pending_ops_actor_type ON public.pending_operations (company_id, actor_type, status); +CREATE INDEX idx_pending_ops_auto_committed ON public.pending_operations (company_id, auto_committed_at) + WHERE auto_committed_at IS NOT NULL; + +-- Sanity: auto_committed_at can only be set when status = 'committed'. +ALTER TABLE public.pending_operations + ADD CONSTRAINT pending_ops_auto_commit_status CHECK ( + auto_committed_at IS NULL OR status = 'committed' + ); + +-- ============================================================================= +-- 2. audit_log: mirror actor columns +-- ============================================================================= +-- audit_log already has an `actor_id` column (uuid). We add actor_type/label +-- alongside so the UI can show "Auto-committed by Claude Desktop" without +-- needing a join through api_keys. + +ALTER TABLE public.audit_log + ADD COLUMN actor_type TEXT DEFAULT 'user' CHECK (actor_type IN ( + 'user', 'api_key', 'mcp_oauth', 'cron', 'system' + )), + ADD COLUMN actor_label TEXT; + +CREATE INDEX idx_audit_log_actor_type ON public.audit_log (user_id, actor_type, created_at DESC); + +-- ============================================================================= +-- 3. validate_and_increment_api_key: surface api_key_id + name for actor model +-- ============================================================================= +-- The RPC previously returned only (user_id, company_id, rate_limited, scopes). +-- We now also return (api_key_id, api_key_name) so the MCP server can record +-- the actor on pending_operations / audit_log without an extra round-trip. + +DROP FUNCTION IF EXISTS public.validate_and_increment_api_key(text); + +CREATE FUNCTION public.validate_and_increment_api_key(p_key_hash text) +RETURNS TABLE( + user_id uuid, + company_id uuid, + api_key_id uuid, + api_key_name text, + rate_limited boolean, + scopes text[] +) +LANGUAGE plpgsql SECURITY DEFINER AS $$ +DECLARE + v_user_id uuid; + v_company_id uuid; + v_api_key_id uuid; + v_api_key_name text; + v_rate_limit_rpm integer; + v_request_count integer; + v_window_start timestamptz; + v_scopes text[]; +BEGIN + SELECT ak.user_id, ak.company_id, ak.id, ak.name, + ak.rate_limit_rpm, ak.request_count, ak.rate_limit_window_start, ak.scopes + INTO v_user_id, v_company_id, v_api_key_id, v_api_key_name, + v_rate_limit_rpm, v_request_count, v_window_start, v_scopes + FROM public.api_keys ak + WHERE ak.key_hash = p_key_hash AND ak.revoked_at IS NULL + FOR UPDATE; + + IF v_user_id IS NULL THEN + RETURN; + END IF; + + IF v_window_start IS NULL OR v_window_start < now() - interval '1 minute' THEN + UPDATE public.api_keys + SET request_count = 1, + rate_limit_window_start = now(), + last_used_at = now() + WHERE key_hash = p_key_hash; + + RETURN QUERY SELECT v_user_id, v_company_id, v_api_key_id, v_api_key_name, false, v_scopes; + RETURN; + END IF; + + IF v_request_count >= v_rate_limit_rpm THEN + RETURN QUERY SELECT v_user_id, v_company_id, v_api_key_id, v_api_key_name, true, v_scopes; + RETURN; + END IF; + + UPDATE public.api_keys + SET request_count = request_count + 1, + last_used_at = now() + WHERE key_hash = p_key_hash; + + RETURN QUERY SELECT v_user_id, v_company_id, v_api_key_id, v_api_key_name, false, v_scopes; +END; +$$; + +-- ============================================================================= +-- 4. PostgREST schema reload +-- ============================================================================= +NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/20260430120100_pending_operations_expand_types_phase2.sql b/supabase/migrations/20260430120100_pending_operations_expand_types_phase2.sql new file mode 100644 index 00000000..97821674 --- /dev/null +++ b/supabase/migrations/20260430120100_pending_operations_expand_types_phase2.sql @@ -0,0 +1,41 @@ +-- Expand pending_operations.operation_type to cover the high-leverage MCP +-- write tools added in Stream 1 Phase 1 (period close, year-end, SIE import, +-- supplier invoice approve/credit, invoice credit/convert, etc.). +-- +-- These op types are high-risk and will never auto-commit (see +-- lib/pending-operations/risk-tiers.ts) — they always wait for human approval. + +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 ( + -- Phase 0: original 7 op types + 'categorize_transaction', + 'create_customer', + 'create_invoice', + 'mark_invoice_paid', + 'send_invoice', + 'mark_invoice_sent', + 'match_transaction_invoice', + -- Stream 1 Phase 1: bookkeeping period operations + 'close_period', + 'lock_period', + 'unlock_period', + 'set_opening_balances', + 'run_year_end', + 'run_currency_revaluation', + -- Stream 1 Phase 1: SIE import (export is read-only) + 'import_sie', + -- Stream 1 Phase 1: voucher gap explanations + 'explain_voucher_gap', + -- Stream 1 Phase 1: transaction reversal + 'uncategorize_transaction', + -- Stream 1 Phase 1: supplier invoice lifecycle + 'approve_supplier_invoice', + 'credit_supplier_invoice', + -- Stream 1 Phase 1: invoice operations beyond simple create/send + 'credit_invoice', + 'convert_invoice' + )); diff --git a/supabase/migrations/20260501120000_company_settings_auto_commit.sql b/supabase/migrations/20260501120000_company_settings_auto_commit.sql new file mode 100644 index 00000000..ad841572 --- /dev/null +++ b/supabase/migrations/20260501120000_company_settings_auto_commit.sql @@ -0,0 +1,22 @@ +-- Migration: Auto-commit settings for agent-driven pending_operations. +-- +-- Lets a company opt in to letting trusted API keys auto-commit low-risk +-- proposals (e.g. create_customer) without human approval. High-risk +-- operations (period close, year-end, send_invoice, etc.) are NEVER +-- auto-committed regardless of these settings — that's enforced in +-- lib/pending-operations/should-auto-commit.ts and risk-tiers.ts, not in +-- DB config, so it can't be bypassed. +-- +-- Defaults are conservative: opt-out by default, no monetary cap configured. + +ALTER TABLE public.company_settings + ADD COLUMN agent_auto_commit_enabled BOOLEAN NOT NULL DEFAULT false, + ADD COLUMN agent_auto_commit_max_amount NUMERIC(14, 2); + +COMMENT ON COLUMN public.company_settings.agent_auto_commit_enabled IS + 'When true, low-risk pending_operations from trusted API keys may auto-commit without human approval. High-risk ops are always gated regardless.'; + +COMMENT ON COLUMN public.company_settings.agent_auto_commit_max_amount IS + 'Optional SEK threshold: low-risk ops above this amount still require human approval. NULL = no monetary limit beyond the risk tier check.'; + +NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/20260501130000_idempotency_keys.sql b/supabase/migrations/20260501130000_idempotency_keys.sql new file mode 100644 index 00000000..f5717c32 --- /dev/null +++ b/supabase/migrations/20260501130000_idempotency_keys.sql @@ -0,0 +1,51 @@ +-- Migration: idempotency_keys table for safe agent retries. +-- +-- Why this exists: agent-driven write operations (MCP tools, automation +-- webhooks) must be safe to retry — agents transparently re-call on +-- network blips, timeouts, or LLM-mediated retry loops. Without an +-- idempotency layer, retrying a `create_invoice` after a network blip can +-- create two invoices and double-book the revenue. +-- +-- Lifecycle: +-- 1. Caller sends an `idempotency_key` (random nonce per logical operation) +-- 2. Server consults this table; on hit, returns the cached response +-- 3. On miss, server proceeds normally and stores the response +-- 4. Cleanup cron deletes rows older than 24h +-- +-- The (user_id, key) pair is unique — a key is scoped to one user so an +-- agent in account A cannot collide with account B even if they reuse the +-- same UUID. +-- +-- request_hash: SHA-256 of the canonical request body. Lets us detect +-- "same key, different payload" misuse and return 409 Conflict instead of +-- silently returning the cached response for a different request. + +CREATE TABLE public.idempotency_keys ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + company_id UUID REFERENCES companies(id) ON DELETE CASCADE, + key TEXT NOT NULL, + request_hash TEXT NOT NULL, + scope TEXT NOT NULL DEFAULT 'mcp_tool', -- 'mcp_tool' | 'api_route' | future + response_status TEXT NOT NULL CHECK (response_status IN ('success', 'error')), + response_body JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + expires_at TIMESTAMPTZ NOT NULL DEFAULT now() + interval '24 hours' +); + +-- Unique key per user — same key in different accounts cannot collide. +CREATE UNIQUE INDEX idx_idempotency_keys_user_key + ON public.idempotency_keys (user_id, key); + +-- TTL index supports cleanup cron (delete where expires_at < now()). +CREATE INDEX idx_idempotency_keys_expires + ON public.idempotency_keys (expires_at); + +ALTER TABLE public.idempotency_keys ENABLE ROW LEVEL SECURITY; + +-- Service role writes; users can read their own rows for debugging. +CREATE POLICY "idempotency_keys_select_own" ON public.idempotency_keys + FOR SELECT USING (auth.uid() = user_id); +-- No INSERT/UPDATE/DELETE policies — writes via service role only. + +NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/20260504100000_ai_native_hardening.sql b/supabase/migrations/20260504100000_ai_native_hardening.sql new file mode 100644 index 00000000..996a6492 --- /dev/null +++ b/supabase/migrations/20260504100000_ai_native_hardening.sql @@ -0,0 +1,141 @@ +-- Hardening pass on the ai-native-supp branch: +-- 1. Adds a transient 'committing' status to pending_operations so the +-- commit dispatcher can claim a row atomically (CAS) before running +-- side-effects, eliminating the auto-commit / human-approval race. +-- 2. Adds a DB trigger that prevents UPDATE on pending_operations rows +-- whose previous status was 'committed' or 'rejected' — required for +-- BFL 7 kap. (räkenskapsinformation must be unalterable post-commit). +-- 3. Hardens idempotency_keys: company_id becomes NOT NULL, the unique +-- index includes company_id, and updated_at is added per CLAUDE.md +-- migration rule #2. +-- 4. Replays the schema reload that 20260430120100 forgot. + +-- ============================================================================= +-- 1. pending_operations: add 'committing' transient status +-- ============================================================================= +ALTER TABLE public.pending_operations + DROP CONSTRAINT IF EXISTS pending_operations_status_check; + +ALTER TABLE public.pending_operations + ADD CONSTRAINT pending_operations_status_check + CHECK (status IN ('pending', 'committing', 'committed', 'rejected')); + +-- ============================================================================= +-- 2. pending_operations: immutability after commit/rejection +-- ============================================================================= +-- Once a pending_op reaches a terminal state, the params/preview/result must +-- be unchangeable. The dispatcher writes resolved_at + result_data as part of +-- the same UPDATE that flips status, so we only need to block UPDATEs whose +-- OLD.status is already terminal. + +CREATE OR REPLACE FUNCTION public.enforce_pending_operations_immutability() +RETURNS TRIGGER AS $$ +BEGIN + IF OLD.status IN ('committed', 'rejected') THEN + RAISE EXCEPTION + 'pending_operations row % is in terminal state % and cannot be modified (BFL 7 kap.)', + OLD.id, OLD.status + USING ERRCODE = 'check_violation'; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS pending_operations_immutability ON public.pending_operations; +CREATE TRIGGER pending_operations_immutability + BEFORE UPDATE ON public.pending_operations + FOR EACH ROW + EXECUTE FUNCTION public.enforce_pending_operations_immutability(); + +-- Block DELETE on terminal rows for the same reason. +CREATE OR REPLACE FUNCTION public.enforce_pending_operations_no_delete() +RETURNS TRIGGER AS $$ +BEGIN + IF OLD.status IN ('committed', 'rejected') THEN + RAISE EXCEPTION + 'pending_operations row % is in terminal state % and cannot be deleted (BFL 7 kap.)', + OLD.id, OLD.status + USING ERRCODE = 'check_violation'; + END IF; + RETURN OLD; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS pending_operations_no_delete ON public.pending_operations; +CREATE TRIGGER pending_operations_no_delete + BEFORE DELETE ON public.pending_operations + FOR EACH ROW + EXECUTE FUNCTION public.enforce_pending_operations_no_delete(); + +-- ============================================================================= +-- 2b. pending_operations: input fields frozen at insert +-- ============================================================================= +-- BFL 7 kap. requires the underlag (basis) for an affärshändelse to be +-- immutable, not just the result. The dispatcher only writes to status / +-- resolved_at / result_data after insert; params, operation_type, and +-- preview_data must never change once the row exists. Without this trigger, +-- a compromised path (or future code refactor) could rewrite the proposal +-- between staging and human approval. + +CREATE OR REPLACE FUNCTION public.enforce_pending_operations_input_frozen() +RETURNS TRIGGER AS $$ +BEGIN + IF NEW.params IS DISTINCT FROM OLD.params THEN + RAISE EXCEPTION + 'pending_operations.params is frozen after insert (BFL 7 kap. underlag-immutability)' + USING ERRCODE = 'check_violation'; + END IF; + IF NEW.operation_type IS DISTINCT FROM OLD.operation_type THEN + RAISE EXCEPTION + 'pending_operations.operation_type is frozen after insert' + USING ERRCODE = 'check_violation'; + END IF; + IF NEW.preview_data IS DISTINCT FROM OLD.preview_data THEN + RAISE EXCEPTION + 'pending_operations.preview_data is frozen after insert' + USING ERRCODE = 'check_violation'; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS pending_operations_input_frozen ON public.pending_operations; +CREATE TRIGGER pending_operations_input_frozen + BEFORE UPDATE ON public.pending_operations + FOR EACH ROW + EXECUTE FUNCTION public.enforce_pending_operations_input_frozen(); + +-- ============================================================================= +-- 3. idempotency_keys: company_id NOT NULL + scoped unique index + updated_at +-- ============================================================================= +-- The previous migration left company_id nullable and the unique index keyed +-- on (user_id, key) only. For a user owning multiple companies, replaying the +-- same idempotency_key UUID across companies could return a cached response +-- from the wrong company. Scope the cache per (user, company, key) so the +-- replay can never cross tenant boundaries. + +-- Defensive: any row created before this migration with a NULL company_id is +-- ambiguous and must be cleared rather than backfilled. +DELETE FROM public.idempotency_keys WHERE company_id IS NULL; + +ALTER TABLE public.idempotency_keys + ALTER COLUMN company_id SET NOT NULL; + +DROP INDEX IF EXISTS idx_idempotency_keys_user_key; +CREATE UNIQUE INDEX idx_idempotency_keys_user_company_key + ON public.idempotency_keys (user_id, company_id, key); + +ALTER TABLE public.idempotency_keys + ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT now(); + +DROP TRIGGER IF EXISTS idempotency_keys_updated_at ON public.idempotency_keys; +CREATE TRIGGER idempotency_keys_updated_at + BEFORE UPDATE ON public.idempotency_keys + FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column(); + +-- ============================================================================= +-- 4. PostgREST schema reload +-- ============================================================================= +-- Also covers the ALTER on pending_operations from 20260430120100, which +-- forgot to issue this notification. +NOTIFY pgrst, 'reload schema'; diff --git a/tests/helpers.ts b/tests/helpers.ts index 37785b3c..7d4b1c76 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -541,6 +541,8 @@ export function makeCompanySettings( is_sandbox: false, ai_flow_enabled: false, ai_backfill_cancel_requested: false, + agent_auto_commit_enabled: false, + agent_auto_commit_max_amount: null, created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z', ...overrides, diff --git a/types/index.ts b/types/index.ts index a89fc6cc..2fc6b693 100644 --- a/types/index.ts +++ b/types/index.ts @@ -262,6 +262,12 @@ export interface CompanySettings { ai_flow_enabled: boolean ai_backfill_cancel_requested: boolean + // Agent auto-commit. When enabled, low-risk pending_operations staged by + // trusted agents (api_key, mcp_oauth) skip human review. High-risk ops + // (period close, year-end, send_invoice, etc.) always require approval. + agent_auto_commit_enabled: boolean + agent_auto_commit_max_amount: number | null + // Timestamps created_at: string updated_at: string @@ -1310,8 +1316,37 @@ export interface CreateFiscalPeriodInput { // ── 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 type PendingOperationType = + | 'categorize_transaction' + | 'create_customer' + | 'create_invoice' + | 'mark_invoice_paid' + | 'send_invoice' + | 'mark_invoice_sent' + | 'match_transaction_invoice' + // Stream 1 Phase 1: bookkeeping period operations + | 'close_period' + | 'lock_period' + | 'unlock_period' + | 'set_opening_balances' + | 'run_year_end' + | 'run_currency_revaluation' + // Stream 1 Phase 1: SIE import (export is read-only) + | 'import_sie' + // Stream 1 Phase 1: voucher gap explanations + | 'explain_voucher_gap' + // Stream 1 Phase 1: transaction reversal + | 'uncategorize_transaction' + // Stream 1 Phase 1: supplier invoice lifecycle + | 'approve_supplier_invoice' + | 'credit_supplier_invoice' + // Stream 1 Phase 1: invoice operations beyond simple create/send + | 'credit_invoice' + | 'convert_invoice' +export type PendingOperationStatus = 'pending' | 'committing' | 'committed' | 'rejected' + +export type PendingOperationActorType = 'user' | 'api_key' | 'mcp_oauth' | 'cron' +export type PendingOperationRiskLevel = 'low' | 'medium' | 'high' export interface PendingOperation { id: string @@ -1323,6 +1358,14 @@ export interface PendingOperation { params: Record preview_data: Record result_data: Record | null + // Stream 2 Phase 1: actor model + actor_type: PendingOperationActorType + actor_id: string | null + actor_label: string | null + risk_level: PendingOperationRiskLevel + // Stream 2 Phase 2: auto-commit tracking + auto_commit_eligible: boolean + auto_committed_at: string | null created_at: string resolved_at: string | null updated_at: string @@ -2146,6 +2189,8 @@ export interface AuditLogEntry { table_name: string | null record_id: string | null actor_id: string | null + actor_type: 'user' | 'api_key' | 'mcp_oauth' | 'cron' | 'system' | null + actor_label: string | null old_state: Record | null new_state: Record | null description: string | null diff --git a/vercel.json b/vercel.json index 711ef14d..7230c9fa 100644 --- a/vercel.json +++ b/vercel.json @@ -31,6 +31,10 @@ { "path": "/api/extensions/cloud-backup/auto-sync/cron", "schedule": "0 * * * *" + }, + { + "path": "/api/idempotency/cleanup/cron", + "schedule": "30 * * * *" } ] }