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:
Mattsson
2026-05-04 11:12:29 +02:00
committed by GitHub
parent 5e1b0f791d
commit bb855d2ddc
46 changed files with 6507 additions and 971 deletions
+128 -4
View File
@@ -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
+7 -1
View File
@@ -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>
)
}
+31
View File
@@ -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
])
+18 -924
View File
@@ -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 }),
}
}
+146
View File
@@ -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)
})
})
+155
View File
@@ -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
}
+3
View File
@@ -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
View File
@@ -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,
}
}
+15 -4
View File
@@ -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({
+68
View File
@@ -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)
})
})
+183
View File
@@ -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 } : {}),
}
}
+1
View File
@@ -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
+62
View File
@@ -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';
+2
View File
@@ -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
View File
@@ -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
+4
View File
@@ -31,6 +31,10 @@
{
"path": "/api/extensions/cloud-backup/auto-sync/cron",
"schedule": "0 * * * *"
},
{
"path": "/api/idempotency/cleanup/cron",
"schedule": "30 * * * *"
}
]
}