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
This commit is contained in:
@@ -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<PendingOperation[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [activeTab, setActiveTab] = useState<PendingOperationStatus>('pending')
|
||||
const [sourceFilter, setSourceFilter] = useState<SourceFilter>('all')
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null)
|
||||
const [selectedOp, setSelectedOp] = useState<PendingOperation | null>(null)
|
||||
const [showCommitDialog, setShowCommitDialog] = useState(false)
|
||||
const [isCommitting, setIsCommitting] = useState(false)
|
||||
const [recentAutoCommits, setRecentAutoCommits] = useState<PendingOperation[]>([])
|
||||
const [bannerDismissedAt, setBannerDismissedAt] = useState<number | null>(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 (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
@@ -246,6 +301,49 @@ export default function PendingOperationsPage() {
|
||||
description="Operationer från din AI-agent som väntar på godkännande"
|
||||
/>
|
||||
|
||||
{newAutoCommits.length > 0 && (
|
||||
<Card className="border-sage/40 bg-sage/5">
|
||||
<CardContent className="flex items-start justify-between gap-4 py-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<Sparkles className="h-4 w-4 mt-0.5 text-sage-foreground" />
|
||||
<div className="text-sm">
|
||||
<p className="font-medium">
|
||||
{newAutoCommits.length === 1
|
||||
? '1 åtgärd auto-godkändes'
|
||||
: `${newAutoCommits.length} åtgärder auto-godkändes`}{' '}
|
||||
senaste dygnet
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Granska vad agenten utförde utan din direkta godkännande.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 flex-shrink-0">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8 px-3 text-xs"
|
||||
onClick={() => {
|
||||
setActiveTab('committed')
|
||||
setSourceFilter('auto_committed')
|
||||
dismissAutoCommitBanner()
|
||||
}}
|
||||
>
|
||||
Visa
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-8 px-3 text-xs"
|
||||
onClick={dismissAutoCommitBanner}
|
||||
>
|
||||
Dölj
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as PendingOperationStatus)}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="pending">Väntande</TabsTrigger>
|
||||
@@ -254,13 +352,22 @@ export default function PendingOperationsPage() {
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
<Tabs value={sourceFilter} onValueChange={(v) => setSourceFilter(v as SourceFilter)}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="all">Alla</TabsTrigger>
|
||||
<TabsTrigger value="agent">Från agent</TabsTrigger>
|
||||
<TabsTrigger value="auto_committed">Auto-godkända</TabsTrigger>
|
||||
<TabsTrigger value="high_risk">Hög risk</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
{isLoading ? (
|
||||
<Card>
|
||||
<CardContent className="flex items-center justify-center py-16">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : operations.length === 0 ? (
|
||||
) : filteredOperations.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-muted mb-4">
|
||||
@@ -282,7 +389,7 @@ export default function PendingOperationsPage() {
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{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)}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
||||
<Badge variant={config.variant}>{config.label}</Badge>
|
||||
{op.status === 'committed' && (
|
||||
{op.risk_level === 'high' && (
|
||||
<Badge variant="outline" className="border-terracotta/40 text-terracotta">
|
||||
Hög risk
|
||||
</Badge>
|
||||
)}
|
||||
{op.actor_type && op.actor_type !== 'user' && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
<Bot className="h-3 w-3 mr-1" />
|
||||
{op.actor_label || op.actor_type}
|
||||
</Badge>
|
||||
)}
|
||||
{op.auto_committed_at && (
|
||||
<Badge variant="outline" className="bg-sage/10 border-sage/40 text-sage-foreground">
|
||||
<Sparkles className="h-3 w-3 mr-1" />
|
||||
Auto-godkänd
|
||||
</Badge>
|
||||
)}
|
||||
{op.status === 'committed' && !op.auto_committed_at && (
|
||||
<Badge variant="default" className="bg-emerald-500/10 text-emerald-600 border-emerald-200">
|
||||
<CheckCircle2 className="h-3 w-3 mr-1" />
|
||||
Godkänd
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
'use client'
|
||||
|
||||
import { ApiKeysPanel } from '@/components/settings/ApiKeysPanel'
|
||||
import { AgentAutoCommitSettings } from '@/components/settings/AgentAutoCommitSettings'
|
||||
|
||||
export default function ApiSettingsPage() {
|
||||
return <ApiKeysPanel />
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<AgentAutoCommitSettings />
|
||||
<ApiKeysPanel />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
])
|
||||
|
||||
@@ -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<void> {
|
||||
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<ReturnType<typeof createClient>>,
|
||||
userId: string,
|
||||
companyId: string,
|
||||
date: string,
|
||||
fiscalYearStartMonth: number = 1
|
||||
): Promise<boolean> {
|
||||
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<ReturnType<typeof createClient>>,
|
||||
userId: string,
|
||||
companyId: string,
|
||||
params: Record<string, unknown>
|
||||
): Promise<{ data?: Record<string, unknown>; error?: string; status?: number }> {
|
||||
const txId = params.transaction_id as string
|
||||
const category = params.category as TransactionCategory
|
||||
const vatTreatment = params.vat_treatment as VatTreatment | undefined
|
||||
|
||||
// Fetch transaction — guard against double-commit
|
||||
const { data: transaction, error: fetchError } = await supabase
|
||||
.from('transactions')
|
||||
.select('*')
|
||||
.eq('id', txId)
|
||||
.eq('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<ReturnType<typeof createClient>>,
|
||||
userId: string,
|
||||
companyId: string,
|
||||
params: Record<string, unknown>
|
||||
): Promise<{ data?: Record<string, unknown>; error?: string; status?: number }> {
|
||||
const { data, error } = await supabase
|
||||
.from('customers')
|
||||
.insert({
|
||||
user_id: userId,
|
||||
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<ReturnType<typeof createClient>>,
|
||||
userId: string,
|
||||
companyId: string,
|
||||
params: Record<string, unknown>
|
||||
): Promise<{ data?: Record<string, unknown>; error?: string; status?: number }> {
|
||||
const customerId = params.customer_id as string
|
||||
const items = params.items as Array<{
|
||||
description: string
|
||||
quantity: number
|
||||
unit: string
|
||||
unit_price: number
|
||||
vat_rate?: number
|
||||
}>
|
||||
|
||||
// Fetch customer
|
||||
const { data: customer, error: customerError } = await supabase
|
||||
.from('customers')
|
||||
.select('*')
|
||||
.eq('id', customerId)
|
||||
.eq('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<ReturnType<typeof createClient>>,
|
||||
userId: string,
|
||||
companyId: string,
|
||||
params: Record<string, unknown>
|
||||
): Promise<{ data?: Record<string, unknown>; error?: string; status?: number }> {
|
||||
const invoiceId = params.invoice_id as string
|
||||
const paymentDate = (params.payment_date as string) || new Date().toISOString().split('T')[0]
|
||||
|
||||
const { data: invoice, error: invoiceError } = await supabase
|
||||
.from('invoices')
|
||||
.select('*, customer:customers(*), items:invoice_items(*)')
|
||||
.eq('id', invoiceId)
|
||||
.eq('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<ReturnType<typeof createClient>>,
|
||||
userId: string,
|
||||
companyId: string,
|
||||
params: Record<string, unknown>,
|
||||
userEmail?: string
|
||||
): Promise<{ data?: Record<string, unknown>; error?: string; status?: number }> {
|
||||
const invoiceId = params.invoice_id as string
|
||||
|
||||
const emailService = getEmailService()
|
||||
if (!emailService.isConfigured()) {
|
||||
return { error: 'Email service not configured', status: 500 }
|
||||
}
|
||||
|
||||
const { data: invoice, error: invoiceError } = await supabase
|
||||
.from('invoices')
|
||||
.select('*, customer:customers(*), items:invoice_items(*)')
|
||||
.eq('id', invoiceId)
|
||||
.eq('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<ReturnType<typeof createClient>>,
|
||||
userId: string,
|
||||
companyId: string,
|
||||
params: Record<string, unknown>
|
||||
): Promise<{ data?: Record<string, unknown>; error?: string; status?: number }> {
|
||||
const invoiceId = params.invoice_id as string
|
||||
|
||||
const { data: invoice, error: invoiceError } = await supabase
|
||||
.from('invoices')
|
||||
.select('*, customer:customers(*), items:invoice_items(*)')
|
||||
.eq('id', invoiceId)
|
||||
.eq('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<ReturnType<typeof createClient>>,
|
||||
userId: string,
|
||||
companyId: string,
|
||||
params: Record<string, unknown>
|
||||
): Promise<{ data?: Record<string, unknown>; error?: string; status?: number }> {
|
||||
const transactionId = params.transaction_id as string
|
||||
const invoiceId = params.invoice_id as string
|
||||
|
||||
const { data: transaction, error: txError } = await supabase
|
||||
.from('transactions')
|
||||
.select('*')
|
||||
.eq('id', transactionId)
|
||||
.eq('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<string, unknown>; 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 })
|
||||
}
|
||||
|
||||
@@ -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<string>('')
|
||||
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 (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
Auto-godkännande för agenter
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
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.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" /> Laddar…
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-start gap-3">
|
||||
<Switch
|
||||
id="agent_auto_commit_enabled"
|
||||
checked={enabled}
|
||||
onCheckedChange={setEnabled}
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="agent_auto_commit_enabled">Aktivera auto-godkännande</Label>
|
||||
<p className="text-xs text-muted-foreground max-w-prose">
|
||||
Endast åtgärder klassificerade som <em>låg risk</em> (t.ex. skapa kund) körs
|
||||
automatiskt. Hög-risk är alltid stoppad och hamnar i kön för granskning.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 max-w-xs">
|
||||
<Label htmlFor="agent_auto_commit_max_amount">
|
||||
Maxbelopp per åtgärd (SEK)
|
||||
</Label>
|
||||
<Input
|
||||
id="agent_auto_commit_max_amount"
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
min={0}
|
||||
step={1}
|
||||
placeholder="Ingen gräns"
|
||||
value={maxAmount}
|
||||
onChange={(e) => setMaxAmount(e.target.value)}
|
||||
disabled={!enabled}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Lämna tomt för ingen gräns. Åtgärder över beloppet faller tillbaka till manuell
|
||||
granskning.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="pt-2">
|
||||
<Button onClick={save} disabled={saving}>
|
||||
{saving ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : null}
|
||||
Spara
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -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<Record<string, unknown>>
|
||||
next?: { description: string; tool?: string; args?: Record<string, unknown>; resource?: string }
|
||||
}>
|
||||
}
|
||||
|
||||
const ctx = (supabase: ReturnType<typeof createQueuedMockSupabase>['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)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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<string, unknown> }
|
||||
|
||||
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',
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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<Record<string, unknown>>
|
||||
next?: {
|
||||
description: string
|
||||
tool?: string
|
||||
args?: Record<string, unknown>
|
||||
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,
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -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<string, string | null> = {
|
||||
// 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,
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -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<number, AccountSummary[]> = {}
|
||||
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] ?? [] },
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -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<unknown>
|
||||
}
|
||||
@@ -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<string, unknown> = {}
|
||||
|
||||
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.',
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<string, unknown>
|
||||
resource?: string
|
||||
}
|
||||
|
||||
export interface AgentToolResult<T = unknown> {
|
||||
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<T>(data: T, next?: NextActionHint): AgentToolResult<T> {
|
||||
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 }),
|
||||
}
|
||||
}
|
||||
@@ -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<string, unknown> | 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)
|
||||
})
|
||||
})
|
||||
@@ -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<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string, unknown>
|
||||
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<IdempotencyHit | null> {
|
||||
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<string, unknown>,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string, unknown>,
|
||||
scope: IdempotencyScope = 'mcp_tool'
|
||||
): Promise<void> {
|
||||
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<number> {
|
||||
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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+45
-5
@@ -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<string, ApiKeyScope> = {
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<JournalEntry | null> {
|
||||
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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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<FiscalPeriod> {
|
||||
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).
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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<string, unknown>
|
||||
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<string, StructuredErrorRemediation> = {
|
||||
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<string, unknown>
|
||||
|
||||
// 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<string, unknown>
|
||||
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<string, unknown>
|
||||
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<string, unknown>
|
||||
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 } : {}),
|
||||
}
|
||||
}
|
||||
@@ -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 } }
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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<typeof import('@/lib/core/bookkeeping/period-service')>(
|
||||
'@/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<typeof import('@/lib/bookkeeping/invoice-entries')>(
|
||||
'@/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>): 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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<string, RiskLevel> = {
|
||||
// ── 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'
|
||||
}
|
||||
@@ -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<AutoCommitDecision> {
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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'
|
||||
));
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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,
|
||||
|
||||
+47
-2
@@ -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<string, unknown>
|
||||
preview_data: Record<string, unknown>
|
||||
result_data: Record<string, unknown> | 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<string, unknown> | null
|
||||
new_state: Record<string, unknown> | null
|
||||
description: string | null
|
||||
|
||||
@@ -31,6 +31,10 @@
|
||||
{
|
||||
"path": "/api/extensions/cloud-backup/auto-sync/cron",
|
||||
"schedule": "0 * * * *"
|
||||
},
|
||||
{
|
||||
"path": "/api/idempotency/cleanup/cron",
|
||||
"schedule": "30 * * * *"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user