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:
@@ -0,0 +1,146 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import {
|
||||
hashRequest,
|
||||
checkIdempotencyKey,
|
||||
storeIdempotencyResponse,
|
||||
cleanupExpiredIdempotencyKeys,
|
||||
IdempotencyKeyReuseError,
|
||||
} from '../idempotency'
|
||||
|
||||
describe('hashRequest', () => {
|
||||
it('produces stable SHA-256 for the same payload', () => {
|
||||
expect(hashRequest({ a: 1, b: 'x' })).toBe(hashRequest({ a: 1, b: 'x' }))
|
||||
})
|
||||
|
||||
it('is order-independent', () => {
|
||||
expect(hashRequest({ a: 1, b: 2 })).toBe(hashRequest({ b: 2, a: 1 }))
|
||||
})
|
||||
|
||||
it('detects different values', () => {
|
||||
expect(hashRequest({ a: 1 })).not.toBe(hashRequest({ a: 2 }))
|
||||
})
|
||||
|
||||
it('handles nested objects deterministically', () => {
|
||||
const h1 = hashRequest({ outer: { x: 1, y: 2 }, list: [1, 2, 3] })
|
||||
const h2 = hashRequest({ list: [1, 2, 3], outer: { y: 2, x: 1 } })
|
||||
expect(h1).toBe(h2)
|
||||
})
|
||||
})
|
||||
|
||||
function mockClient(maybeSingleResult: { data: Record<string, unknown> | null; error: unknown }) {
|
||||
const select = vi.fn().mockReturnValue({
|
||||
eq: vi.fn().mockReturnValue({
|
||||
eq: vi.fn().mockReturnValue({
|
||||
eq: vi.fn().mockReturnValue({
|
||||
maybeSingle: vi.fn().mockResolvedValue(maybeSingleResult),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
const insert = vi.fn().mockResolvedValue({ error: null })
|
||||
const deleteFn = vi.fn().mockReturnValue({
|
||||
lt: vi.fn().mockResolvedValue({ error: null, count: 5 }),
|
||||
})
|
||||
return {
|
||||
client: {
|
||||
from: vi.fn().mockReturnValue({
|
||||
select,
|
||||
insert,
|
||||
delete: deleteFn,
|
||||
}),
|
||||
} as never,
|
||||
select,
|
||||
insert,
|
||||
deleteFn,
|
||||
}
|
||||
}
|
||||
|
||||
describe('checkIdempotencyKey', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('returns null when no cached row exists', async () => {
|
||||
const { client } = mockClient({ data: null, error: null })
|
||||
const result = await checkIdempotencyKey(client, 'user-1', 'company-1', 'key-1', 'hash-1')
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('returns cached body when key + hash match', async () => {
|
||||
const future = new Date(Date.now() + 60_000).toISOString()
|
||||
const { client } = mockClient({
|
||||
data: {
|
||||
request_hash: 'hash-1',
|
||||
response_status: 'success',
|
||||
response_body: { foo: 'bar' },
|
||||
expires_at: future,
|
||||
},
|
||||
error: null,
|
||||
})
|
||||
const result = await checkIdempotencyKey(client, 'user-1', 'company-1', 'key-1', 'hash-1')
|
||||
expect(result).toEqual({ status: 'success', body: { foo: 'bar' } })
|
||||
})
|
||||
|
||||
it('throws IdempotencyKeyReuseError on hash mismatch', async () => {
|
||||
const future = new Date(Date.now() + 60_000).toISOString()
|
||||
const { client } = mockClient({
|
||||
data: {
|
||||
request_hash: 'hash-old',
|
||||
response_status: 'success',
|
||||
response_body: { foo: 'old' },
|
||||
expires_at: future,
|
||||
},
|
||||
error: null,
|
||||
})
|
||||
await expect(
|
||||
checkIdempotencyKey(client, 'user-1', 'company-1', 'key-1', 'hash-new')
|
||||
).rejects.toBeInstanceOf(IdempotencyKeyReuseError)
|
||||
})
|
||||
|
||||
it('treats expired rows as misses', async () => {
|
||||
const past = new Date(Date.now() - 60_000).toISOString()
|
||||
const { client } = mockClient({
|
||||
data: {
|
||||
request_hash: 'hash-1',
|
||||
response_status: 'success',
|
||||
response_body: { foo: 'bar' },
|
||||
expires_at: past,
|
||||
},
|
||||
error: null,
|
||||
})
|
||||
const result = await checkIdempotencyKey(client, 'user-1', 'company-1', 'key-1', 'hash-1')
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('storeIdempotencyResponse', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('writes the response row', async () => {
|
||||
const { client, insert } = mockClient({ data: null, error: null })
|
||||
await storeIdempotencyResponse(client, 'user-1', 'company-1', 'key-1', 'hash-1', 'success', { ok: true })
|
||||
expect(insert).toHaveBeenCalledWith(expect.objectContaining({
|
||||
user_id: 'user-1',
|
||||
company_id: 'company-1',
|
||||
key: 'key-1',
|
||||
request_hash: 'hash-1',
|
||||
response_status: 'success',
|
||||
response_body: { ok: true },
|
||||
scope: 'mcp_tool',
|
||||
}))
|
||||
})
|
||||
|
||||
it('swallows duplicate-row races (23505)', async () => {
|
||||
const { client, insert } = mockClient({ data: null, error: null })
|
||||
insert.mockResolvedValueOnce({ error: { code: '23505', message: 'unique_violation' } })
|
||||
await expect(
|
||||
storeIdempotencyResponse(client, 'user-1', 'company-1', 'key-1', 'hash-1', 'success', {})
|
||||
).resolves.toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('cleanupExpiredIdempotencyKeys', () => {
|
||||
it('returns delete count', async () => {
|
||||
const { client } = mockClient({ data: null, error: null })
|
||||
const count = await cleanupExpiredIdempotencyKeys(client)
|
||||
expect(count).toBe(5)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* Idempotency layer for agent-safe retries.
|
||||
*
|
||||
* Use case: an agent (MCP, automation webhook, scripted client) retries an
|
||||
* operation after a network blip. Without idempotency, the retry creates a
|
||||
* duplicate side-effect — two invoices, two journal entries, two emails.
|
||||
*
|
||||
* Contract:
|
||||
* 1. The caller supplies an `idempotency_key` per logical operation.
|
||||
* 2. The server hashes the canonical request body and consults
|
||||
* idempotency_keys.
|
||||
* 3. Hit + matching hash → return cached response (suppress side-effects).
|
||||
* 4. Hit + different hash → throw IdempotencyKeyReuseError (409 in HTTP).
|
||||
* 5. Miss → proceed; on success, persist the response.
|
||||
*
|
||||
* Keys are scoped per (user, company): the same key UUID across two
|
||||
* companies cannot collide, and a multi-company user replaying a key in
|
||||
* the wrong company can never receive the other company's cached response.
|
||||
*
|
||||
* 24-hour TTL is enforced by an `expires_at` column + a cleanup cron. After
|
||||
* 24h, the same key may be reused safely — agents that retry that long after
|
||||
* the original request are not retrying, they're starting over.
|
||||
*/
|
||||
import crypto from 'crypto'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
|
||||
export type IdempotencyScope = 'mcp_tool' | 'api_route'
|
||||
|
||||
export class IdempotencyKeyReuseError extends Error {
|
||||
readonly code = 'IDEMPOTENCY_KEY_REUSE'
|
||||
constructor(public readonly key: string) {
|
||||
super(`Idempotency key "${key}" was previously used with a different request body. Use a fresh key or send the original request.`)
|
||||
this.name = 'IdempotencyKeyReuseError'
|
||||
}
|
||||
}
|
||||
|
||||
export interface IdempotencyHit {
|
||||
status: 'success' | 'error'
|
||||
body: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical hash of a request body. Sorts keys recursively so semantically
|
||||
* identical bodies produce the same hash regardless of property order.
|
||||
*/
|
||||
export function hashRequest(body: unknown): string {
|
||||
return crypto.createHash('sha256').update(canonicalJson(body)).digest('hex')
|
||||
}
|
||||
|
||||
function canonicalJson(value: unknown): string {
|
||||
if (value === null || typeof value !== 'object') return JSON.stringify(value)
|
||||
if (Array.isArray(value)) return '[' + value.map(canonicalJson).join(',') + ']'
|
||||
const obj = value as Record<string, unknown>
|
||||
const keys = Object.keys(obj).sort()
|
||||
return '{' + keys.map((k) => JSON.stringify(k) + ':' + canonicalJson(obj[k])).join(',') + '}'
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a previously-cached idempotency response.
|
||||
*
|
||||
* Returns:
|
||||
* - null when no cached entry exists (caller should proceed)
|
||||
* - the cached body when the key+hash match (caller should return it
|
||||
* without side-effects)
|
||||
*
|
||||
* Throws IdempotencyKeyReuseError when the key exists with a *different*
|
||||
* request hash — the caller is misusing the key.
|
||||
*/
|
||||
export async function checkIdempotencyKey(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
companyId: string,
|
||||
key: string,
|
||||
requestHash: string
|
||||
): Promise<IdempotencyHit | null> {
|
||||
const { data, error } = await supabase
|
||||
.from('idempotency_keys')
|
||||
.select('request_hash, response_status, response_body, expires_at')
|
||||
.eq('user_id', userId)
|
||||
.eq('company_id', companyId)
|
||||
.eq('key', key)
|
||||
.maybeSingle()
|
||||
|
||||
if (error || !data) return null
|
||||
|
||||
// Expired entries are treated as misses; the cleanup cron will delete them.
|
||||
if (data.expires_at && new Date(data.expires_at) < new Date()) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (data.request_hash !== requestHash) {
|
||||
throw new IdempotencyKeyReuseError(key)
|
||||
}
|
||||
|
||||
return {
|
||||
status: data.response_status as 'success' | 'error',
|
||||
body: (data.response_body ?? {}) as Record<string, unknown>,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the response for an idempotency key. Best-effort: a duplicate-row
|
||||
* race is swallowed so two concurrent retries don't fight over the cache.
|
||||
* The first writer wins; the second sees the unique-index conflict and skips.
|
||||
*/
|
||||
export async function storeIdempotencyResponse(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
companyId: string,
|
||||
key: string,
|
||||
requestHash: string,
|
||||
status: 'success' | 'error',
|
||||
body: Record<string, unknown>,
|
||||
scope: IdempotencyScope = 'mcp_tool'
|
||||
): Promise<void> {
|
||||
const { error } = await supabase
|
||||
.from('idempotency_keys')
|
||||
.insert({
|
||||
user_id: userId,
|
||||
company_id: companyId,
|
||||
key,
|
||||
request_hash: requestHash,
|
||||
scope,
|
||||
response_status: status,
|
||||
response_body: body,
|
||||
})
|
||||
|
||||
// Postgres 23505 is unique_violation — a concurrent retry already inserted.
|
||||
// Silently OK; the cached response from the winner will be returned to
|
||||
// both callers on subsequent reads.
|
||||
if (error && error.code !== '23505') {
|
||||
// Non-blocking: log but don't fail the operation. The caller already
|
||||
// succeeded; failing to persist the cache only weakens future retries.
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[idempotency] failed to persist response:', error.message)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sweep expired rows. Called by the cleanup cron.
|
||||
* Returns the number of deleted rows for logging.
|
||||
*/
|
||||
export async function cleanupExpiredIdempotencyKeys(
|
||||
supabase: SupabaseClient
|
||||
): Promise<number> {
|
||||
const { error, count } = await supabase
|
||||
.from('idempotency_keys')
|
||||
.delete({ count: 'exact' })
|
||||
.lt('expires_at', new Date().toISOString())
|
||||
|
||||
if (error) {
|
||||
throw new Error(`Idempotency cleanup failed: ${error.message}`)
|
||||
}
|
||||
return count ?? 0
|
||||
}
|
||||
@@ -396,6 +396,9 @@ export const UpdateSettingsSchema = z.object({
|
||||
invoice_credit_terms_text: z.string().nullable().optional(),
|
||||
// AI agent flow
|
||||
ai_flow_enabled: z.boolean().optional(),
|
||||
// Agent auto-commit (low-risk ops staged by trusted agents)
|
||||
agent_auto_commit_enabled: z.boolean().optional(),
|
||||
agent_auto_commit_max_amount: z.number().nullable().optional(),
|
||||
}).refine(
|
||||
(data) => {
|
||||
// BFL 3 kap.: Enskild firma must have fiscal year starting January
|
||||
|
||||
+45
-5
@@ -7,13 +7,15 @@ const KEY_PREFIX = 'gnubok_sk_'
|
||||
|
||||
export const API_KEY_SCOPES = {
|
||||
'transactions:read': { label: 'Transaktioner — läs', description: 'Lista transaktioner, mallförslag, kategoriförslag (3 verktyg)' },
|
||||
'transactions:write': { label: 'Transaktioner — skriv', description: 'Kategorisera, kvittomatchning, koppling mot faktura (3 verktyg)' },
|
||||
'transactions:write': { label: 'Transaktioner — skriv', description: 'Kategorisera, av-kategorisera, kvittomatchning, koppling mot faktura (4 verktyg)' },
|
||||
'customers:read': { label: 'Kunder — läs', description: 'Lista kunder (1 verktyg)' },
|
||||
'customers:write': { label: 'Kunder — skriv', description: 'Skapa kunder (1 verktyg)' },
|
||||
'invoices:read': { label: 'Fakturor — läs', description: 'Lista fakturor (1 verktyg)' },
|
||||
'invoices:write': { label: 'Fakturor — skriv', description: 'Skapa, skicka, markera betald/skickad (4 verktyg)' },
|
||||
'suppliers:read': { label: 'Leverantörer — läs', description: 'Lista leverantörer och leverantörsfakturor (2 verktyg)' },
|
||||
'reports:read': { label: 'Rapporter — läs', description: 'Kontoplan, huvudbok, balansräkning, resultaträkning, moms, KPI, reskontra, perioder, bankavstämning (11 verktyg)' },
|
||||
'suppliers:write': { label: 'Leverantörer — skriv', description: 'Godkänn och kreditera leverantörsfakturor (2 verktyg)' },
|
||||
'reports:read': { label: 'Rapporter — läs', description: 'Kontoplan, huvudbok, balansräkning, resultaträkning, moms, KPI, reskontra, perioder, bankavstämning, SIE-export (12 verktyg)' },
|
||||
'bookkeeping:write': { label: 'Bokföring — skriv', description: 'Stänga/låsa perioder, ingående balans, bokslut, SIE-import, voucher-gap-förklaringar' },
|
||||
'payroll:read': { label: 'Löner — läs', description: 'Lista anställda, lönekörningar, lönejournal (3 verktyg)' },
|
||||
'payroll:write': { label: 'Löner — skriv', description: 'Skapa lönekörning, beräkna, generera AGI (3 verktyg)' },
|
||||
} as const
|
||||
@@ -36,8 +38,9 @@ export const SCOPE_GROUPS = [
|
||||
{ domain: 'transactions', label: 'Transaktioner', read: 'transactions:read' as const, write: 'transactions:write' as const },
|
||||
{ domain: 'customers', label: 'Kunder', read: 'customers:read' as const, write: 'customers:write' as const },
|
||||
{ domain: 'invoices', label: 'Fakturor', read: 'invoices:read' as const, write: 'invoices:write' as const },
|
||||
{ domain: 'suppliers', label: 'Leverantörer', read: 'suppliers:read' as const, write: null },
|
||||
{ domain: 'suppliers', label: 'Leverantörer', read: 'suppliers:read' as const, write: 'suppliers:write' as const },
|
||||
{ domain: 'reports', label: 'Rapporter', read: 'reports:read' as const, write: null },
|
||||
{ domain: 'bookkeeping', label: 'Bokföring', read: null, write: 'bookkeeping:write' as const },
|
||||
{ domain: 'payroll', label: 'Löner', read: 'payroll:read' as const, write: 'payroll:write' as const },
|
||||
] as const
|
||||
|
||||
@@ -85,6 +88,26 @@ export const TOOL_SCOPE_MAP: Record<string, ApiKeyScope> = {
|
||||
gnubok_create_salary_run: 'payroll:write',
|
||||
gnubok_calculate_salary_run: 'payroll:write',
|
||||
gnubok_generate_agi: 'payroll:write',
|
||||
// Bookkeeping write (Stream 1 Phase 1) — high-risk, always staged
|
||||
gnubok_close_period: 'bookkeeping:write',
|
||||
gnubok_lock_period: 'bookkeeping:write',
|
||||
gnubok_unlock_period: 'bookkeeping:write',
|
||||
gnubok_run_year_end: 'bookkeeping:write',
|
||||
gnubok_set_opening_balances: 'bookkeeping:write',
|
||||
gnubok_run_currency_revaluation: 'bookkeeping:write',
|
||||
gnubok_explain_voucher_gap: 'bookkeeping:write',
|
||||
gnubok_list_voucher_gaps: 'reports:read',
|
||||
// Transaction reversal (medium-risk)
|
||||
gnubok_uncategorize_transaction: 'transactions:write',
|
||||
// SIE export (read-only) + import (write)
|
||||
gnubok_export_sie: 'reports:read',
|
||||
gnubok_import_sie: 'bookkeeping:write',
|
||||
// Supplier invoice lifecycle
|
||||
gnubok_approve_supplier_invoice: 'suppliers:write',
|
||||
gnubok_credit_supplier_invoice: 'suppliers:write',
|
||||
// Invoice conversion + crediting
|
||||
gnubok_convert_invoice: 'invoices:write',
|
||||
gnubok_credit_invoice: 'invoices:write',
|
||||
}
|
||||
|
||||
export function validateScopes(scopes: unknown): ApiKeyScope[] | null {
|
||||
@@ -126,12 +149,27 @@ export function extractBearerToken(request: Request): string | null {
|
||||
/**
|
||||
* Validate an API key and enforce rate limiting.
|
||||
* Uses the DB RPC for atomic check + increment.
|
||||
* Returns the user_id and effective scopes on success, or an error with HTTP status.
|
||||
* Returns the user_id, company_id, api_key_id, name, and effective scopes on
|
||||
* success, or an error with HTTP status.
|
||||
* null scopes in DB → DEFAULT_SCOPES (read-only).
|
||||
*
|
||||
* api_key_id and api_key_name are returned so callers (e.g. the MCP server)
|
||||
* can record actor attribution on pending_operations and audit_log.
|
||||
* They may be undefined when the deployed DB hasn't yet run the migration
|
||||
* that adds them to the RPC return shape.
|
||||
*/
|
||||
export async function validateApiKey(
|
||||
key: string
|
||||
): Promise<{ userId: string; companyId: string; scopes: ApiKeyScope[] } | { error: string; status: number }> {
|
||||
): Promise<
|
||||
| {
|
||||
userId: string
|
||||
companyId: string
|
||||
apiKeyId?: string
|
||||
apiKeyName?: string
|
||||
scopes: ApiKeyScope[]
|
||||
}
|
||||
| { error: string; status: number }
|
||||
> {
|
||||
if (!key.startsWith(KEY_PREFIX)) {
|
||||
return { error: 'Invalid API key format', status: 401 }
|
||||
}
|
||||
@@ -156,6 +194,8 @@ export async function validateApiKey(
|
||||
return {
|
||||
userId: row.user_id,
|
||||
companyId: row.company_id,
|
||||
apiKeyId: row.api_key_id,
|
||||
apiKeyName: row.api_key_name,
|
||||
scopes: validateScopes(row.scopes) ?? DEFAULT_SCOPES,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -374,7 +374,14 @@ export async function createCreditNoteJournalEntry(
|
||||
userId: string,
|
||||
creditNote: Invoice,
|
||||
entityType: EntityType = 'enskild_firma',
|
||||
customerName?: string
|
||||
customerName?: string,
|
||||
/**
|
||||
* Original voucher reference (e.g. "A-42") to embed in the JE description and
|
||||
* line-level descriptions. BFL 5 kap. 5 § requires a correction to point back
|
||||
* to the corrected verifikation; the invoice number alone is insufficient
|
||||
* because it doesn't identify the entry in the verifikationsserie.
|
||||
*/
|
||||
originalVoucherRef?: string
|
||||
): Promise<JournalEntry | null> {
|
||||
const fiscalPeriodId = await findFiscalPeriod(supabase, companyId, creditNote.invoice_date)
|
||||
if (!fiscalPeriodId) {
|
||||
@@ -384,6 +391,7 @@ export async function createCreditNoteJournalEntry(
|
||||
|
||||
const lines: CreateJournalEntryLineInput[] = []
|
||||
const tag = invoiceTag(creditNote)
|
||||
const lineSuffix = originalVoucherRef ? ` (avser ${originalVoucherRef})` : ''
|
||||
|
||||
// Generate reversed revenue + VAT lines per rate group (debit side for credit notes)
|
||||
const debitLines: CreateJournalEntryLineInput[] = []
|
||||
@@ -399,7 +407,7 @@ export async function createCreditNoteJournalEntry(
|
||||
...line,
|
||||
debit_amount: Math.abs(line.credit_amount),
|
||||
credit_amount: Math.abs(line.debit_amount),
|
||||
line_description: `Kreditfaktura ${tag}`,
|
||||
line_description: `Kreditfaktura ${tag}${lineSuffix}`,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
@@ -421,7 +429,7 @@ export async function createCreditNoteJournalEntry(
|
||||
account_number: vatAccount,
|
||||
debit_amount: absVat,
|
||||
credit_amount: 0,
|
||||
line_description: `Moms kreditfaktura ${tag}`,
|
||||
line_description: `Moms kreditfaktura ${tag}${lineSuffix}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -437,10 +445,13 @@ export async function createCreditNoteJournalEntry(
|
||||
line_description: `Kreditfaktura ${tag}`,
|
||||
})
|
||||
|
||||
const baseDescription = buildInvoiceDescription('Kreditfaktura', creditNote.invoice_number, customerName, creditNote.id)
|
||||
const input: CreateJournalEntryInput = {
|
||||
fiscal_period_id: fiscalPeriodId,
|
||||
entry_date: creditNote.invoice_date,
|
||||
description: buildInvoiceDescription('Kreditfaktura', creditNote.invoice_number, customerName, creditNote.id),
|
||||
description: originalVoucherRef
|
||||
? `${baseDescription} (avser verifikation ${originalVoucherRef})`
|
||||
: baseDescription,
|
||||
source_type: 'credit_note',
|
||||
source_id: creditNote.id,
|
||||
lines,
|
||||
|
||||
@@ -29,7 +29,7 @@ function makeClient() {
|
||||
}
|
||||
}
|
||||
|
||||
import { lockPeriod, closePeriod, createNextPeriod } from '../period-service'
|
||||
import { lockPeriod, unlockPeriod, closePeriod, createNextPeriod } from '../period-service'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -124,6 +124,56 @@ describe('closePeriod', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('unlockPeriod', () => {
|
||||
it('clears locked_at and emits period.unlocked', async () => {
|
||||
const period = makeFiscalPeriod({
|
||||
id: 'fp-1',
|
||||
locked_at: '2024-12-31T23:59:59Z',
|
||||
is_closed: false,
|
||||
})
|
||||
const unlocked = { ...period, locked_at: null }
|
||||
|
||||
results = [
|
||||
{ data: period, error: null },
|
||||
{ data: unlocked, error: null },
|
||||
{ data: null, error: null }, // audit_log insert
|
||||
]
|
||||
|
||||
const handler = vi.fn()
|
||||
eventBus.on('period.unlocked', handler)
|
||||
|
||||
const supabase = makeClient()
|
||||
const result = await unlockPeriod(supabase as never, 'company-1', 'user-1', 'fp-1')
|
||||
|
||||
expect(result.locked_at).toBeNull()
|
||||
expect(handler).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('rejects period that is not locked', async () => {
|
||||
const period = makeFiscalPeriod({ id: 'fp-1', locked_at: null, is_closed: false })
|
||||
|
||||
results = [{ data: period, error: null }]
|
||||
|
||||
const supabase = makeClient()
|
||||
await expect(unlockPeriod(supabase as never, 'company-1', 'user-1', 'fp-1')).rejects.toThrow('not locked')
|
||||
})
|
||||
|
||||
it('rejects closed period', async () => {
|
||||
const period = makeFiscalPeriod({
|
||||
id: 'fp-1',
|
||||
locked_at: '2024-12-31T23:59:59Z',
|
||||
is_closed: true,
|
||||
})
|
||||
|
||||
results = [{ data: period, error: null }]
|
||||
|
||||
const supabase = makeClient()
|
||||
await expect(unlockPeriod(supabase as never, 'company-1', 'user-1', 'fp-1')).rejects.toThrow(
|
||||
'Cannot unlock a closed period'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createNextPeriod', () => {
|
||||
it('calculates correct dates for standard (Jan-Dec) fiscal year', async () => {
|
||||
const current = makeFiscalPeriod({
|
||||
|
||||
@@ -72,6 +72,74 @@ export async function lockPeriod(
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Unlock a fiscal period — clears `locked_at` so new entries can be posted.
|
||||
* Requires: period exists, belongs to company, is currently locked, not closed.
|
||||
*/
|
||||
export async function unlockPeriod(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
userId: string,
|
||||
fiscalPeriodId: string
|
||||
): Promise<FiscalPeriod> {
|
||||
const { data: period, error: fetchError } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('*')
|
||||
.eq('id', fiscalPeriodId)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
if (fetchError || !period) {
|
||||
throw new Error('Fiscal period not found')
|
||||
}
|
||||
|
||||
if (period.is_closed) {
|
||||
throw new Error('Cannot unlock a closed period')
|
||||
}
|
||||
|
||||
if (!period.locked_at) {
|
||||
throw new Error('Period is not locked')
|
||||
}
|
||||
|
||||
const priorLockedAt = period.locked_at
|
||||
|
||||
const { data: updated, error: updateError } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.update({ locked_at: null })
|
||||
.eq('id', fiscalPeriodId)
|
||||
.eq('company_id', companyId)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (updateError || !updated) {
|
||||
throw new Error(`Failed to unlock period: ${updateError?.message}`)
|
||||
}
|
||||
|
||||
const result = updated as FiscalPeriod
|
||||
|
||||
// BFNAR 2013:2 kap. 8 (behandlingshistorik): unlocking a locked period is a
|
||||
// sensitive control change. Persist it to the immutable audit_log (not just
|
||||
// event_log, which has 30-day TTL) so an auditor can reconstruct who
|
||||
// unlocked which period and when, even years later.
|
||||
await supabase.from('audit_log').insert({
|
||||
user_id: userId,
|
||||
company_id: companyId,
|
||||
action: 'UPDATE',
|
||||
table_name: 'fiscal_periods',
|
||||
record_id: fiscalPeriodId,
|
||||
description: `Period unlocked: ${result.name} (${result.period_start} – ${result.period_end})`,
|
||||
old_state: { locked_at: priorLockedAt },
|
||||
new_state: { locked_at: null },
|
||||
})
|
||||
|
||||
await eventBus.emit({
|
||||
type: 'period.unlocked',
|
||||
payload: { period: result, companyId, userId },
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Close a fiscal period — marks it as permanently closed.
|
||||
* Requires: period is locked AND closing_entry_id is set (year-end must run first).
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { getStructuredError } from '../get-structured-error'
|
||||
|
||||
describe('getStructuredError', () => {
|
||||
it('extracts code from structured bookkeeping error', () => {
|
||||
const result = getStructuredError({
|
||||
error: {
|
||||
code: 'JOURNAL_ENTRY_NOT_BALANCED',
|
||||
message: 'Debits do not match credits',
|
||||
details: { totalDebit: 100, totalCredit: 90 },
|
||||
},
|
||||
})
|
||||
expect(result.code).toBe('JOURNAL_ENTRY_NOT_BALANCED')
|
||||
expect(result.message_sv).toContain('balanserar inte')
|
||||
expect(result.message_en).toContain('Debits')
|
||||
expect(result.remediation?.description).toContain('Recalculate')
|
||||
})
|
||||
|
||||
it('extracts code from typed error class with code property', () => {
|
||||
class FakeBookkeepingError extends Error {
|
||||
readonly code = 'ACCOUNTS_NOT_IN_CHART'
|
||||
readonly accountNumbers = ['1930', '2641']
|
||||
constructor() {
|
||||
super('Accounts not in chart')
|
||||
}
|
||||
}
|
||||
const result = getStructuredError(new FakeBookkeepingError())
|
||||
expect(result.code).toBe('ACCOUNTS_NOT_IN_CHART')
|
||||
expect(result.remediation?.resource).toBe('gnubok://chart-of-accounts')
|
||||
})
|
||||
|
||||
it('infers PERIOD_NOT_LOCKED from message text', () => {
|
||||
const result = getStructuredError(new Error('Period must be locked before closing'))
|
||||
expect(result.code).toBe('PERIOD_NOT_LOCKED')
|
||||
expect(result.remediation?.tool).toBe('gnubok_lock_period')
|
||||
})
|
||||
|
||||
it('infers PERIOD_HAS_UNBOOKED_TRANSACTIONS from Swedish lock-error message', () => {
|
||||
const result = getStructuredError(
|
||||
new Error('Kan inte låsa period: 3 affärstransaktion(er) saknar bokföring.')
|
||||
)
|
||||
expect(result.code).toBe('PERIOD_HAS_UNBOOKED_TRANSACTIONS')
|
||||
expect(result.remediation?.tool).toBe('gnubok_list_uncategorized_transactions')
|
||||
})
|
||||
|
||||
it('produces INSUFFICIENT_SCOPE remediation with attempted scope', () => {
|
||||
const result = getStructuredError(
|
||||
new Error('Insufficient scope: this API key does not have the "bookkeeping:write" scope'),
|
||||
{ attemptedScope: 'bookkeeping:write' }
|
||||
)
|
||||
expect(result.code).toBe('INSUFFICIENT_SCOPE')
|
||||
expect(result.remediation?.description).toContain('"bookkeeping:write"')
|
||||
expect(result.remediation?.resource).toBe('gnubok://capabilities')
|
||||
})
|
||||
|
||||
it('infers TRANSACTION_ALREADY_CATEGORIZED', () => {
|
||||
const result = getStructuredError(new Error('Transaction already has a journal entry'))
|
||||
expect(result.code).toBe('TRANSACTION_ALREADY_CATEGORIZED')
|
||||
expect(result.remediation?.tool).toBe('gnubok_uncategorize_transaction')
|
||||
})
|
||||
|
||||
it('falls back to UNKNOWN_ERROR when no code or pattern matches', () => {
|
||||
const result = getStructuredError(new Error('Something weird happened'))
|
||||
expect(result.code).toBe('UNKNOWN_ERROR')
|
||||
expect(result.remediation).toBeUndefined()
|
||||
})
|
||||
|
||||
it('handles plain string errors', () => {
|
||||
const result = getStructuredError('Period must be locked before closing')
|
||||
expect(result.code).toBe('PERIOD_NOT_LOCKED')
|
||||
expect(result.message_en).toBe('Period must be locked before closing')
|
||||
})
|
||||
|
||||
it('handles null/undefined gracefully', () => {
|
||||
const result = getStructuredError(null)
|
||||
expect(result.code).toBe('UNKNOWN_ERROR')
|
||||
expect(result.message_en).toBe('Unknown error')
|
||||
expect(result.message_sv).toBeTruthy()
|
||||
})
|
||||
|
||||
it('always returns Swedish message even with no match', () => {
|
||||
const result = getStructuredError(new Error('Random gibberish XYZ'))
|
||||
expect(result.message_sv).toBeTruthy()
|
||||
expect(result.message_sv.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* Structured error shape designed for agents (MCP, automation) that need to
|
||||
* dispatch on error programmatically rather than read the Swedish prose.
|
||||
*
|
||||
* Key design decisions:
|
||||
* - code is machine-readable and stable; agents pattern-match on it
|
||||
* - message_sv is the existing UI string from getErrorMessage()
|
||||
* - message_en gives the agent a translation it can act on without parsing
|
||||
* Swedish tokens
|
||||
* - remediation, when present, points the agent at a tool/args/resource
|
||||
* that fixes the problem. Optional — only set when there's a clear
|
||||
* mechanical next step
|
||||
*
|
||||
* Used by the MCP server's tool error wrapper. UI callers continue to use the
|
||||
* string-only getErrorMessage() — this is additive.
|
||||
*/
|
||||
import { getErrorMessage } from './get-error-message'
|
||||
|
||||
export interface StructuredErrorRemediation {
|
||||
description: string
|
||||
tool?: string
|
||||
args?: Record<string, unknown>
|
||||
resource?: string
|
||||
}
|
||||
|
||||
export interface StructuredError {
|
||||
code: string
|
||||
message_sv: string
|
||||
message_en: string
|
||||
remediation?: StructuredErrorRemediation
|
||||
}
|
||||
|
||||
interface StructuredErrorOptions {
|
||||
/**
|
||||
* Optional: scope the agent attempted to use, for INSUFFICIENT_SCOPE remediation.
|
||||
*/
|
||||
attemptedScope?: string
|
||||
/**
|
||||
* Optional: tool name being called, used in fallback remediation hints.
|
||||
*/
|
||||
toolName?: string
|
||||
}
|
||||
|
||||
const ERROR_CODE_REMEDIATION: Record<string, StructuredErrorRemediation> = {
|
||||
ACCOUNTS_NOT_IN_CHART: {
|
||||
description: 'One or more BAS accounts referenced are not active in the chart of accounts. Activate them via the bookkeeping settings, or use a different category.',
|
||||
resource: 'gnubok://chart-of-accounts',
|
||||
},
|
||||
JOURNAL_ENTRY_NOT_BALANCED: {
|
||||
description: 'Debits and credits do not match. Recalculate the lines so totals are equal before retrying.',
|
||||
},
|
||||
FISCAL_PERIOD_NOT_FOUND: {
|
||||
description: 'No fiscal period covers the entry date. Create or extend the relevant period before retrying.',
|
||||
resource: 'gnubok://period/active',
|
||||
},
|
||||
ENTRY_DATE_OUTSIDE_FISCAL_PERIOD: {
|
||||
description: 'The entry date is outside the active fiscal period. Use a date inside an open period or create one that covers it.',
|
||||
resource: 'gnubok://period/active',
|
||||
},
|
||||
CANNOT_REVERSE_NON_POSTED: {
|
||||
description: 'Only posted entries can be reversed. Commit the draft first or pick a posted entry.',
|
||||
},
|
||||
CANNOT_CORRECT_NON_POSTED: {
|
||||
description: 'Only posted entries can be corrected. Commit the draft first or pick a posted entry.',
|
||||
},
|
||||
ENTRY_ALREADY_REVERSED: {
|
||||
description: 'Another caller reversed this entry concurrently. Re-fetch the entry list and pick a different one.',
|
||||
},
|
||||
PERIOD_NOT_LOCKED: {
|
||||
description: 'The period must be locked before it can be closed. Call gnubok_lock_period first.',
|
||||
tool: 'gnubok_lock_period',
|
||||
},
|
||||
PERIOD_HAS_UNBOOKED_TRANSACTIONS: {
|
||||
description: 'The period contains uncategorized business transactions. Categorize or mark them private before locking.',
|
||||
tool: 'gnubok_list_uncategorized_transactions',
|
||||
},
|
||||
YEAR_END_NOT_RUN: {
|
||||
description: 'Year-end closing must be executed before the period can be closed. Run the year-end procedure first.',
|
||||
},
|
||||
INSUFFICIENT_SCOPE: {
|
||||
description: 'The current API key does not have the required scope. Mint a new key with the missing scope or grant it through the API key settings.',
|
||||
resource: 'gnubok://capabilities',
|
||||
},
|
||||
TRANSACTION_ALREADY_CATEGORIZED: {
|
||||
description: 'The transaction already has a journal entry. Use gnubok_uncategorize_transaction first if you need to recategorize.',
|
||||
tool: 'gnubok_uncategorize_transaction',
|
||||
},
|
||||
INVOICE_ALREADY_SENT: {
|
||||
description: 'The invoice is already sent or paid; sending again would create a duplicate.',
|
||||
},
|
||||
IDEMPOTENCY_KEY_REUSE: {
|
||||
description: 'This idempotency_key was previously used with a different request body. Use a fresh UUID for a new operation, or send the original request body to replay.',
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull a stable code out of various error shapes.
|
||||
*/
|
||||
function extractCode(error: unknown): string | null {
|
||||
if (typeof error !== 'object' || error === null) return null
|
||||
|
||||
const obj = error as Record<string, unknown>
|
||||
|
||||
// Typed bookkeeping error: { code: 'JOURNAL_ENTRY_NOT_BALANCED', ... }
|
||||
if (typeof obj.code === 'string' && /^[A-Z_]+$/.test(obj.code)) {
|
||||
return obj.code
|
||||
}
|
||||
|
||||
// Wrapped error: { error: { code: '...' } }
|
||||
if (typeof obj.error === 'object' && obj.error !== null) {
|
||||
const inner = obj.error as Record<string, unknown>
|
||||
if (typeof inner.code === 'string' && /^[A-Z_]+$/.test(inner.code)) {
|
||||
return inner.code
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Heuristically infer a code from the message text when nothing structured
|
||||
* is available. Keeps known-error patterns programmatically dispatchable.
|
||||
*/
|
||||
function inferCode(message: string): string | null {
|
||||
if (/Period must be locked before closing/i.test(message)) return 'PERIOD_NOT_LOCKED'
|
||||
if (/Year-end closing must be executed/i.test(message)) return 'YEAR_END_NOT_RUN'
|
||||
if (/Kan inte låsa period:.*affärstransaktion/i.test(message)) return 'PERIOD_HAS_UNBOOKED_TRANSACTIONS'
|
||||
if (/Insufficient scope/i.test(message)) return 'INSUFFICIENT_SCOPE'
|
||||
if (/already has a journal entry/i.test(message)) return 'TRANSACTION_ALREADY_CATEGORIZED'
|
||||
if (/already been sent/i.test(message) || /already sent/i.test(message)) return 'INVOICE_ALREADY_SENT'
|
||||
if (/locked\/closed fiscal period/i.test(message)) return 'PERIOD_LOCKED'
|
||||
if (/Bokföringen är låst/i.test(message)) return 'PERIOD_LOCKED'
|
||||
if (/Transaction not found/i.test(message)) return 'NOT_FOUND'
|
||||
if (/Invoice not found/i.test(message)) return 'NOT_FOUND'
|
||||
return null
|
||||
}
|
||||
|
||||
function extractEnglishMessage(error: unknown): string {
|
||||
if (typeof error === 'string') return error
|
||||
if (error instanceof Error) return error.message
|
||||
if (typeof error === 'object' && error !== null) {
|
||||
const obj = error as Record<string, unknown>
|
||||
if (typeof obj.error === 'string') return obj.error
|
||||
if (typeof obj.message === 'string') return obj.message
|
||||
if (typeof obj.error === 'object' && obj.error !== null) {
|
||||
const inner = obj.error as Record<string, unknown>
|
||||
if (typeof inner.message === 'string') return inner.message
|
||||
}
|
||||
}
|
||||
return 'Unknown error'
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a StructuredError for an arbitrary thrown value.
|
||||
*
|
||||
* Always returns a valid StructuredError; never throws.
|
||||
*/
|
||||
export function getStructuredError(
|
||||
error: unknown,
|
||||
options: StructuredErrorOptions = {}
|
||||
): StructuredError {
|
||||
const message_en = extractEnglishMessage(error)
|
||||
const message_sv = getErrorMessage(error)
|
||||
|
||||
const code = extractCode(error) ?? inferCode(message_en) ?? 'UNKNOWN_ERROR'
|
||||
|
||||
let remediation = ERROR_CODE_REMEDIATION[code]
|
||||
|
||||
// Specialize INSUFFICIENT_SCOPE with the actual scope name when known.
|
||||
if (code === 'INSUFFICIENT_SCOPE' && options.attemptedScope && remediation) {
|
||||
remediation = {
|
||||
...remediation,
|
||||
description: `The current API key does not have the "${options.attemptedScope}" scope. Mint a new key with that scope or add it to the existing key in API settings.`,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
code,
|
||||
message_sv,
|
||||
message_en,
|
||||
...(remediation ? { remediation } : {}),
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,7 @@ export type CoreEvent =
|
||||
| { type: 'transaction.reconciled'; payload: { transaction: Transaction; journalEntryId: string; method: ReconciliationMethod; userId: string; companyId: string } }
|
||||
// Periods
|
||||
| { type: 'period.locked'; payload: { period: FiscalPeriod; userId: string; companyId: string } }
|
||||
| { type: 'period.unlocked'; payload: { period: FiscalPeriod; userId: string; companyId: string } }
|
||||
| { type: 'period.year_closed'; payload: { period: FiscalPeriod; userId: string; companyId: string } }
|
||||
// Customers
|
||||
| { type: 'customer.created'; payload: { customer: Customer; userId: string; companyId: string } }
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
/**
|
||||
* pg-real smoke tests for the four migrations introduced by the
|
||||
* AI-native streams (actor model, auto-commit, expanded op types, idempotency).
|
||||
*
|
||||
* These don't replicate the unit-test coverage — they prove the schema and
|
||||
* constraints behave as the application code assumes when running against a
|
||||
* real Postgres with the migrations applied.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getPool } from '@/tests/pg/setup'
|
||||
import { seedCompany } from '@/tests/pg/fixtures'
|
||||
|
||||
describe('pending_operations: actor model + risk + auto-commit columns', () => {
|
||||
it('accepts the expanded actor_type and risk_level enums', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
const pool = getPool()
|
||||
|
||||
const result = await pool.query<{
|
||||
id: string
|
||||
actor_type: string
|
||||
risk_level: string
|
||||
auto_commit_eligible: boolean
|
||||
}>(
|
||||
`INSERT INTO public.pending_operations (
|
||||
user_id, company_id, operation_type, title, params, preview_data,
|
||||
actor_type, actor_id, actor_label, risk_level, auto_commit_eligible
|
||||
) VALUES ($1, $2, 'create_customer', 'pg-real test', '{}', '{}',
|
||||
'api_key', NULL, 'Claude Desktop', 'low', true)
|
||||
RETURNING id, actor_type, risk_level, auto_commit_eligible`,
|
||||
[userId, companyId],
|
||||
)
|
||||
|
||||
expect(result.rows[0]).toMatchObject({
|
||||
actor_type: 'api_key',
|
||||
risk_level: 'low',
|
||||
auto_commit_eligible: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects invalid actor_type via CHECK constraint', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
await expect(
|
||||
getPool().query(
|
||||
`INSERT INTO public.pending_operations (
|
||||
user_id, company_id, operation_type, title, params, preview_data, actor_type, risk_level
|
||||
) VALUES ($1, $2, 'create_customer', 'x', '{}', '{}', 'martian', 'low')`,
|
||||
[userId, companyId],
|
||||
),
|
||||
).rejects.toThrow(/check constraint|actor_type/i)
|
||||
})
|
||||
|
||||
it('rejects invalid risk_level via CHECK constraint', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
await expect(
|
||||
getPool().query(
|
||||
`INSERT INTO public.pending_operations (
|
||||
user_id, company_id, operation_type, title, params, preview_data, actor_type, risk_level
|
||||
) VALUES ($1, $2, 'create_customer', 'x', '{}', '{}', 'user', 'critical')`,
|
||||
[userId, companyId],
|
||||
),
|
||||
).rejects.toThrow(/check constraint|risk_level/i)
|
||||
})
|
||||
|
||||
it('blocks auto_committed_at when status is still pending', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
await expect(
|
||||
getPool().query(
|
||||
`INSERT INTO public.pending_operations (
|
||||
user_id, company_id, operation_type, title, params, preview_data,
|
||||
actor_type, risk_level, auto_committed_at
|
||||
) VALUES ($1, $2, 'create_customer', 'x', '{}', '{}',
|
||||
'api_key', 'low', now())`,
|
||||
[userId, companyId],
|
||||
),
|
||||
).rejects.toThrow(/pending_ops_auto_commit_status|check constraint/i)
|
||||
})
|
||||
|
||||
it('accepts the expanded operation_type enum (close_period, run_year_end, …)', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
const expandedTypes = [
|
||||
'close_period', 'lock_period', 'unlock_period', 'run_year_end', 'set_opening_balances',
|
||||
'run_currency_revaluation', 'explain_voucher_gap', 'uncategorize_transaction',
|
||||
'approve_supplier_invoice', 'credit_supplier_invoice',
|
||||
'credit_invoice', 'convert_invoice', 'import_sie',
|
||||
]
|
||||
|
||||
for (const op of expandedTypes) {
|
||||
const result = await getPool().query<{ id: string }>(
|
||||
`INSERT INTO public.pending_operations (
|
||||
user_id, company_id, operation_type, title, params, preview_data
|
||||
) VALUES ($1, $2, $3, 'pg-real test', '{}', '{}')
|
||||
RETURNING id`,
|
||||
[userId, companyId, op],
|
||||
)
|
||||
expect(result.rows[0]?.id).toBeTruthy()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('audit_log: actor_type + actor_label columns', () => {
|
||||
it('accepts INSERT with actor_type and actor_label', async () => {
|
||||
const { userId } = await seedCompany()
|
||||
const result = await getPool().query<{ id: string }>(
|
||||
`INSERT INTO public.audit_log (
|
||||
user_id, action, table_name, actor_type, actor_label, description
|
||||
) VALUES ($1, 'INSERT', 'pending_operations', 'api_key', 'Claude Desktop', 'pg-real test')
|
||||
RETURNING id`,
|
||||
[userId],
|
||||
)
|
||||
expect(result.rows[0]?.id).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('company_settings: auto_commit columns', () => {
|
||||
it('exposes agent_auto_commit_enabled (default false) and agent_auto_commit_max_amount (NULL)', async () => {
|
||||
const { companyId } = await seedCompany()
|
||||
|
||||
const settings = await getPool().query<{
|
||||
enabled: boolean
|
||||
max_amount: string | null
|
||||
}>(
|
||||
`SELECT agent_auto_commit_enabled AS enabled,
|
||||
agent_auto_commit_max_amount AS max_amount
|
||||
FROM public.company_settings
|
||||
WHERE company_id = $1`,
|
||||
[companyId],
|
||||
)
|
||||
|
||||
// company_settings may or may not have a default row; if it doesn't, the
|
||||
// columns still exist on the table and we can inspect the catalog.
|
||||
if (settings.rows.length === 0) {
|
||||
const catalog = await getPool().query<{ name: string }>(
|
||||
`SELECT column_name AS name FROM information_schema.columns
|
||||
WHERE table_schema = 'public' AND table_name = 'company_settings'
|
||||
AND column_name IN ('agent_auto_commit_enabled', 'agent_auto_commit_max_amount')`,
|
||||
)
|
||||
expect(catalog.rowCount).toBe(2)
|
||||
return
|
||||
}
|
||||
|
||||
expect(settings.rows[0]?.enabled).toBe(false)
|
||||
expect(settings.rows[0]?.max_amount).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('idempotency_keys table', () => {
|
||||
it('enforces unique (user_id, key) and 24h default expiry', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
|
||||
await getPool().query(
|
||||
`INSERT INTO public.idempotency_keys
|
||||
(user_id, company_id, key, request_hash, scope, response_status, response_body)
|
||||
VALUES ($1, $2, 'dup-key', 'hash-1', 'mcp_tool', 'success', '{}')`,
|
||||
[userId, companyId],
|
||||
)
|
||||
|
||||
await expect(
|
||||
getPool().query(
|
||||
`INSERT INTO public.idempotency_keys
|
||||
(user_id, company_id, key, request_hash, scope, response_status, response_body)
|
||||
VALUES ($1, $2, 'dup-key', 'hash-2', 'mcp_tool', 'success', '{}')`,
|
||||
[userId, companyId],
|
||||
),
|
||||
).rejects.toThrow(/duplicate key|unique/i)
|
||||
|
||||
const expiry = await getPool().query<{ expires_at: string; created_at: string }>(
|
||||
`SELECT expires_at, created_at FROM public.idempotency_keys
|
||||
WHERE user_id = $1 AND key = 'dup-key'`,
|
||||
[userId],
|
||||
)
|
||||
const created = new Date(expiry.rows[0]!.created_at).getTime()
|
||||
const expires = new Date(expiry.rows[0]!.expires_at).getTime()
|
||||
const hoursDelta = (expires - created) / 3_600_000
|
||||
// Expect the default ~24h gap (allow ±1 minute for clock skew).
|
||||
expect(hoursDelta).toBeGreaterThan(23.95)
|
||||
expect(hoursDelta).toBeLessThan(24.05)
|
||||
})
|
||||
|
||||
it('rejects invalid response_status', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
await expect(
|
||||
getPool().query(
|
||||
`INSERT INTO public.idempotency_keys
|
||||
(user_id, company_id, key, request_hash, scope, response_status, response_body)
|
||||
VALUES ($1, $2, 'bad-status', 'hash-x', 'mcp_tool', 'maybe', '{}')`,
|
||||
[userId, companyId],
|
||||
),
|
||||
).rejects.toThrow(/check constraint|response_status/i)
|
||||
})
|
||||
|
||||
it('rejects NULL company_id (multi-tenant scoping)', async () => {
|
||||
const { userId } = await seedCompany()
|
||||
await expect(
|
||||
getPool().query(
|
||||
`INSERT INTO public.idempotency_keys
|
||||
(user_id, company_id, key, request_hash, scope, response_status, response_body)
|
||||
VALUES ($1, NULL, 'k1', 'h1', 'mcp_tool', 'success', '{}')`,
|
||||
[userId],
|
||||
),
|
||||
).rejects.toThrow(/null value|not.null/i)
|
||||
})
|
||||
|
||||
it('allows the same key across two companies (scoped uniqueness)', async () => {
|
||||
const { userId, companyId: company1 } = await seedCompany()
|
||||
const { companyId: company2 } = await seedCompany()
|
||||
const sameKey = 'shared-key-abc'
|
||||
|
||||
await getPool().query(
|
||||
`INSERT INTO public.idempotency_keys
|
||||
(user_id, company_id, key, request_hash, scope, response_status, response_body)
|
||||
VALUES ($1, $2, $3, 'h1', 'mcp_tool', 'success', '{}')`,
|
||||
[userId, company1, sameKey],
|
||||
)
|
||||
// Same user + same key but different company must NOT collide.
|
||||
await expect(
|
||||
getPool().query(
|
||||
`INSERT INTO public.idempotency_keys
|
||||
(user_id, company_id, key, request_hash, scope, response_status, response_body)
|
||||
VALUES ($1, $2, $3, 'h2', 'mcp_tool', 'success', '{}')`,
|
||||
[userId, company2, sameKey],
|
||||
),
|
||||
).resolves.toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('pending_operations: CAS + post-commit immutability', () => {
|
||||
it('accepts the new committing transient status', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
const result = await getPool().query<{ id: string; status: string }>(
|
||||
`INSERT INTO public.pending_operations
|
||||
(user_id, company_id, operation_type, status, title, params, preview_data)
|
||||
VALUES ($1, $2, 'create_customer', 'committing', 'pg-real', '{}', '{}')
|
||||
RETURNING id, status`,
|
||||
[userId, companyId],
|
||||
)
|
||||
expect(result.rows[0]?.status).toBe('committing')
|
||||
})
|
||||
|
||||
it('CAS pattern (UPDATE … WHERE status=pending) only claims unclaimed rows', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
const ins = await getPool().query<{ id: string }>(
|
||||
`INSERT INTO public.pending_operations
|
||||
(user_id, company_id, operation_type, status, title, params, preview_data)
|
||||
VALUES ($1, $2, 'create_customer', 'pending', 'cas-test', '{}', '{}')
|
||||
RETURNING id`,
|
||||
[userId, companyId],
|
||||
)
|
||||
const id = ins.rows[0]!.id
|
||||
|
||||
// First claim succeeds.
|
||||
const first = await getPool().query(
|
||||
`UPDATE public.pending_operations SET status = 'committing'
|
||||
WHERE id = $1 AND status = 'pending' RETURNING id`,
|
||||
[id],
|
||||
)
|
||||
expect(first.rowCount).toBe(1)
|
||||
|
||||
// Second concurrent claim sees status='committing' and returns 0 rows.
|
||||
const second = await getPool().query(
|
||||
`UPDATE public.pending_operations SET status = 'committing'
|
||||
WHERE id = $1 AND status = 'pending' RETURNING id`,
|
||||
[id],
|
||||
)
|
||||
expect(second.rowCount).toBe(0)
|
||||
})
|
||||
|
||||
it('blocks UPDATE on rows in terminal status (committed)', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
const ins = await getPool().query<{ id: string }>(
|
||||
`INSERT INTO public.pending_operations
|
||||
(user_id, company_id, operation_type, status, title, params, preview_data, resolved_at)
|
||||
VALUES ($1, $2, 'create_customer', 'committed', 'imm-test', '{}', '{}', now())
|
||||
RETURNING id`,
|
||||
[userId, companyId],
|
||||
)
|
||||
const id = ins.rows[0]!.id
|
||||
|
||||
await expect(
|
||||
getPool().query(
|
||||
`UPDATE public.pending_operations SET title = 'tampered' WHERE id = $1`,
|
||||
[id],
|
||||
),
|
||||
).rejects.toThrow(/terminal state|BFL 7/i)
|
||||
})
|
||||
|
||||
it('blocks UPDATE on rows in terminal status (rejected)', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
const ins = await getPool().query<{ id: string }>(
|
||||
`INSERT INTO public.pending_operations
|
||||
(user_id, company_id, operation_type, status, title, params, preview_data, resolved_at)
|
||||
VALUES ($1, $2, 'create_customer', 'rejected', 'imm-test', '{}', '{}', now())
|
||||
RETURNING id`,
|
||||
[userId, companyId],
|
||||
)
|
||||
const id = ins.rows[0]!.id
|
||||
|
||||
await expect(
|
||||
getPool().query(
|
||||
`UPDATE public.pending_operations SET params = '{"x":1}' WHERE id = $1`,
|
||||
[id],
|
||||
),
|
||||
).rejects.toThrow(/terminal state|BFL 7/i)
|
||||
})
|
||||
|
||||
it('blocks DELETE on rows in terminal status', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
const ins = await getPool().query<{ id: string }>(
|
||||
`INSERT INTO public.pending_operations
|
||||
(user_id, company_id, operation_type, status, title, params, preview_data, resolved_at)
|
||||
VALUES ($1, $2, 'create_customer', 'committed', 'del-test', '{}', '{}', now())
|
||||
RETURNING id`,
|
||||
[userId, companyId],
|
||||
)
|
||||
const id = ins.rows[0]!.id
|
||||
|
||||
await expect(
|
||||
getPool().query(`DELETE FROM public.pending_operations WHERE id = $1`, [id]),
|
||||
).rejects.toThrow(/terminal state|BFL 7/i)
|
||||
})
|
||||
|
||||
it('blocks UPDATE of params on non-terminal rows (BFL 7 underlag-immutability)', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
const ins = await getPool().query<{ id: string }>(
|
||||
`INSERT INTO public.pending_operations
|
||||
(user_id, company_id, operation_type, status, title, params, preview_data)
|
||||
VALUES ($1, $2, 'create_customer', 'pending', 'frozen-test', '{"name":"original"}', '{}')
|
||||
RETURNING id`,
|
||||
[userId, companyId],
|
||||
)
|
||||
const id = ins.rows[0]!.id
|
||||
|
||||
await expect(
|
||||
getPool().query(
|
||||
`UPDATE public.pending_operations SET params = '{"name":"tampered"}' WHERE id = $1`,
|
||||
[id],
|
||||
),
|
||||
).rejects.toThrow(/frozen|underlag/i)
|
||||
})
|
||||
|
||||
it('blocks UPDATE of operation_type on non-terminal rows', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
const ins = await getPool().query<{ id: string }>(
|
||||
`INSERT INTO public.pending_operations
|
||||
(user_id, company_id, operation_type, status, title, params, preview_data)
|
||||
VALUES ($1, $2, 'create_customer', 'pending', 'op-type-frozen', '{}', '{}')
|
||||
RETURNING id`,
|
||||
[userId, companyId],
|
||||
)
|
||||
const id = ins.rows[0]!.id
|
||||
|
||||
await expect(
|
||||
getPool().query(
|
||||
`UPDATE public.pending_operations SET operation_type = 'send_invoice' WHERE id = $1`,
|
||||
[id],
|
||||
),
|
||||
).rejects.toThrow(/frozen/i)
|
||||
})
|
||||
|
||||
it('allows committing → committed transition', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
const ins = await getPool().query<{ id: string }>(
|
||||
`INSERT INTO public.pending_operations
|
||||
(user_id, company_id, operation_type, status, title, params, preview_data)
|
||||
VALUES ($1, $2, 'create_customer', 'committing', 'transition-test', '{}', '{}')
|
||||
RETURNING id`,
|
||||
[userId, companyId],
|
||||
)
|
||||
const id = ins.rows[0]!.id
|
||||
|
||||
const upd = await getPool().query(
|
||||
`UPDATE public.pending_operations
|
||||
SET status = 'committed', resolved_at = now(), result_data = '{"ok":true}'
|
||||
WHERE id = $1 RETURNING id`,
|
||||
[id],
|
||||
)
|
||||
expect(upd.rowCount).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,357 @@
|
||||
/**
|
||||
* Unit tests for the executors added to bring every declared op type up to a
|
||||
* callable state through `commitPendingOperation`. Tests run through the
|
||||
* public dispatcher (executors are not exported individually) so the wiring
|
||||
* is exercised alongside executor logic.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { eventBus } from '@/lib/events/bus'
|
||||
import { createQueuedMockSupabase, makeInvoice, makeFiscalPeriod } from '@/tests/helpers'
|
||||
import type { PendingOperation } from '@/types'
|
||||
|
||||
vi.mock('@/lib/core/bookkeeping/period-service', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/lib/core/bookkeeping/period-service')>(
|
||||
'@/lib/core/bookkeeping/period-service'
|
||||
)
|
||||
return {
|
||||
...actual,
|
||||
unlockPeriod: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/lib/import/sie-parser', () => ({
|
||||
parseSIEFile: vi.fn(),
|
||||
calculateFileHash: vi.fn(async () => 'mock-hash'),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/import/sie-import', () => ({
|
||||
executeSIEImport: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/bookkeeping/invoice-entries', async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import('@/lib/bookkeeping/invoice-entries')>(
|
||||
'@/lib/bookkeeping/invoice-entries'
|
||||
)
|
||||
return {
|
||||
...actual,
|
||||
createCreditNoteJournalEntry: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
import { commitPendingOperation } from '../commit'
|
||||
import { unlockPeriod } from '@/lib/core/bookkeeping/period-service'
|
||||
import { parseSIEFile } from '@/lib/import/sie-parser'
|
||||
import { executeSIEImport } from '@/lib/import/sie-import'
|
||||
import { createCreditNoteJournalEntry } from '@/lib/bookkeeping/invoice-entries'
|
||||
|
||||
function makePendingOp(overrides: Partial<PendingOperation>): PendingOperation {
|
||||
return {
|
||||
id: 'op-1',
|
||||
user_id: 'user-1',
|
||||
company_id: 'company-1',
|
||||
operation_type: 'create_customer',
|
||||
status: 'pending',
|
||||
title: 'test',
|
||||
params: {},
|
||||
preview_data: {},
|
||||
result_data: null,
|
||||
actor_type: 'user',
|
||||
actor_id: null,
|
||||
actor_label: null,
|
||||
risk_level: 'high',
|
||||
auto_commit_eligible: false,
|
||||
auto_committed_at: null,
|
||||
created_at: '2026-05-03T00:00:00Z',
|
||||
resolved_at: null,
|
||||
updated_at: '2026-05-03T00:00:00Z',
|
||||
...overrides,
|
||||
} as PendingOperation
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
eventBus.clear()
|
||||
})
|
||||
|
||||
// ─── unlock_period ──────────────────────────────────────────────────
|
||||
|
||||
describe('commitPendingOperation: unlock_period', () => {
|
||||
it('happy path: clears locked_at and returns committed', async () => {
|
||||
const period = makeFiscalPeriod({ id: 'fp-1', locked_at: null })
|
||||
vi.mocked(unlockPeriod).mockResolvedValueOnce(period)
|
||||
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
||||
enqueue({ data: null, error: null }) // dispatcher's pending_operations update
|
||||
|
||||
const op = makePendingOp({
|
||||
operation_type: 'unlock_period',
|
||||
params: { fiscal_period_id: 'fp-1' },
|
||||
})
|
||||
|
||||
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
|
||||
|
||||
expect(result.status).toBe('committed')
|
||||
expect(result.data).toMatchObject({ period_id: 'fp-1', locked_at: null })
|
||||
expect(unlockPeriod).toHaveBeenCalledWith(expect.anything(), 'company-1', 'user-1', 'fp-1')
|
||||
})
|
||||
|
||||
it('rejects when fiscal_period_id is missing', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
||||
enqueue({ data: null, error: null }) // dispatcher's reject update
|
||||
const op = makePendingOp({ operation_type: 'unlock_period', params: {} })
|
||||
|
||||
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
|
||||
|
||||
expect(result.status).toBe('failed')
|
||||
expect(result.http_status).toBe(400)
|
||||
expect(unlockPeriod).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('surfaces underlying service errors', async () => {
|
||||
vi.mocked(unlockPeriod).mockRejectedValueOnce(new Error('Period is not locked'))
|
||||
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
||||
enqueue({ data: null, error: null }) // dispatcher's reject update on throw
|
||||
const op = makePendingOp({
|
||||
operation_type: 'unlock_period',
|
||||
params: { fiscal_period_id: 'fp-1' },
|
||||
})
|
||||
|
||||
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
|
||||
|
||||
expect(result.status).toBe('failed')
|
||||
expect(result.error).toMatch(/not locked/)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── import_sie ─────────────────────────────────────────────────────
|
||||
|
||||
describe('commitPendingOperation: import_sie', () => {
|
||||
it('happy path: parses, imports, returns committed with summary', async () => {
|
||||
vi.mocked(parseSIEFile).mockReturnValueOnce({} as never)
|
||||
vi.mocked(executeSIEImport).mockResolvedValueOnce({
|
||||
success: true,
|
||||
importId: 'imp-1',
|
||||
fiscalPeriodId: 'fp-1',
|
||||
openingBalanceEntryId: 'ob-1',
|
||||
journalEntriesCreated: 5,
|
||||
journalEntryIds: ['je-1', 'je-2', 'je-3', 'je-4', 'je-5'],
|
||||
errors: [],
|
||||
warnings: ['minor warning'],
|
||||
})
|
||||
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
||||
enqueue({ data: null, error: null }) // dispatcher's update
|
||||
|
||||
const op = makePendingOp({
|
||||
operation_type: 'import_sie',
|
||||
params: {
|
||||
file_content: '#FLAGGA 0\n',
|
||||
filename: 'test.sie',
|
||||
mappings: [],
|
||||
create_fiscal_period: true,
|
||||
import_opening_balances: true,
|
||||
import_transactions: true,
|
||||
},
|
||||
})
|
||||
|
||||
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
|
||||
|
||||
expect(result.status).toBe('committed')
|
||||
expect(result.data).toMatchObject({
|
||||
import_id: 'imp-1',
|
||||
journal_entries_created: 5,
|
||||
warnings: ['minor warning'],
|
||||
})
|
||||
expect(parseSIEFile).toHaveBeenCalledWith('#FLAGGA 0\n')
|
||||
})
|
||||
|
||||
it('rejects when required params are missing', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
||||
enqueue({ data: null, error: null }) // dispatcher's reject update
|
||||
const op = makePendingOp({ operation_type: 'import_sie', params: { filename: 'x.sie' } })
|
||||
|
||||
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
|
||||
|
||||
expect(result.status).toBe('failed')
|
||||
expect(result.http_status).toBe(400)
|
||||
expect(parseSIEFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns the executeSIEImport errors when success=false', async () => {
|
||||
vi.mocked(parseSIEFile).mockReturnValueOnce({} as never)
|
||||
vi.mocked(executeSIEImport).mockResolvedValueOnce({
|
||||
success: false,
|
||||
importId: null,
|
||||
fiscalPeriodId: null,
|
||||
openingBalanceEntryId: null,
|
||||
journalEntriesCreated: 0,
|
||||
journalEntryIds: [],
|
||||
errors: ['duplicate import'],
|
||||
warnings: [],
|
||||
})
|
||||
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
||||
enqueue({ data: null, error: null }) // dispatcher's reject update
|
||||
const op = makePendingOp({
|
||||
operation_type: 'import_sie',
|
||||
params: {
|
||||
file_content: '#FLAGGA 0\n',
|
||||
filename: 'test.sie',
|
||||
mappings: [],
|
||||
create_fiscal_period: true,
|
||||
import_opening_balances: false,
|
||||
import_transactions: true,
|
||||
},
|
||||
})
|
||||
|
||||
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
|
||||
|
||||
expect(result.status).toBe('failed')
|
||||
expect(result.error).toMatch(/duplicate import/)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── credit_invoice ─────────────────────────────────────────────────
|
||||
|
||||
describe('commitPendingOperation: credit_invoice', () => {
|
||||
it('happy path (accrual): inserts negated credit note and books JE', async () => {
|
||||
const original = makeInvoice({
|
||||
id: 'inv-1',
|
||||
invoice_number: 'F-2024001',
|
||||
status: 'sent',
|
||||
document_type: 'invoice',
|
||||
subtotal: 1000,
|
||||
vat_amount: 250,
|
||||
total: 1250,
|
||||
})
|
||||
const originalWithItems = {
|
||||
...original,
|
||||
items: [
|
||||
{ sort_order: 0, description: 'Service', quantity: 1, unit: 'st', unit_price: 1000, line_total: 1000, vat_rate: 25, vat_amount: 250 },
|
||||
],
|
||||
}
|
||||
|
||||
const creditNoteRow = { ...original, id: 'cn-1', invoice_number: 'KR-F-2024001' }
|
||||
const completeCreditNote = { ...creditNoteRow, customer: { name: 'Acme AB' }, items: [] }
|
||||
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
// 0: CAS claim
|
||||
enqueue({ data: { id: 'op-1' }, error: null })
|
||||
// 1: fetch original with items
|
||||
enqueue({ data: originalWithItems, error: null })
|
||||
// 2: insert credit note
|
||||
enqueue({ data: creditNoteRow, error: null })
|
||||
// 3: insert items (await thenable)
|
||||
enqueue({ data: null, error: null })
|
||||
// 4: update original status='credited'
|
||||
enqueue({ data: null, error: null })
|
||||
// 5: re-fetch complete credit note with customer + items
|
||||
enqueue({ data: completeCreditNote, error: null })
|
||||
// 6: company_settings
|
||||
enqueue({ data: { entity_type: 'aktiebolag', accounting_method: 'accrual' }, error: null })
|
||||
// 7: update invoice with journal_entry_id
|
||||
enqueue({ data: null, error: null })
|
||||
// 8: dispatcher's pending_operations update
|
||||
enqueue({ data: null, error: null })
|
||||
|
||||
vi.mocked(createCreditNoteJournalEntry).mockResolvedValueOnce({ id: 'je-1' } as never)
|
||||
|
||||
const op = makePendingOp({
|
||||
operation_type: 'credit_invoice',
|
||||
params: { invoice_id: 'inv-1', reason: 'Wrong amount' },
|
||||
})
|
||||
|
||||
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
|
||||
|
||||
expect(result.status).toBe('committed')
|
||||
expect(result.data).toMatchObject({ credit_note_id: 'cn-1', journal_entry_id: 'je-1' })
|
||||
expect(createCreditNoteJournalEntry).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips JE on cash accounting', async () => {
|
||||
const original = makeInvoice({ id: 'inv-1', status: 'paid', document_type: 'invoice' })
|
||||
const originalWithItems = { ...original, items: [] }
|
||||
const creditNoteRow = { ...original, id: 'cn-2', invoice_number: 'KR-F-2024001' }
|
||||
const completeCreditNote = { ...creditNoteRow, customer: null, items: [] }
|
||||
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
||||
enqueue({ data: originalWithItems, error: null })
|
||||
enqueue({ data: creditNoteRow, error: null })
|
||||
enqueue({ data: null, error: null })
|
||||
enqueue({ data: null, error: null })
|
||||
enqueue({ data: completeCreditNote, error: null })
|
||||
enqueue({ data: { entity_type: 'enskild_firma', accounting_method: 'cash' }, error: null })
|
||||
// no JE update; go straight to dispatcher update
|
||||
enqueue({ data: null, error: null })
|
||||
|
||||
const op = makePendingOp({
|
||||
operation_type: 'credit_invoice',
|
||||
params: { invoice_id: 'inv-1' },
|
||||
})
|
||||
|
||||
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
|
||||
|
||||
expect(result.status).toBe('committed')
|
||||
expect(result.data).toMatchObject({ credit_note_id: 'cn-2', journal_entry_id: null })
|
||||
expect(createCreditNoteJournalEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('auto-rejects when invoice is already credited (409)', async () => {
|
||||
const original = makeInvoice({ id: 'inv-1', status: 'credited', document_type: 'invoice' })
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
||||
enqueue({ data: { ...original, items: [] }, error: null })
|
||||
// dispatcher auto-reject path also does an update
|
||||
enqueue({ data: null, error: null })
|
||||
|
||||
const op = makePendingOp({
|
||||
operation_type: 'credit_invoice',
|
||||
params: { invoice_id: 'inv-1' },
|
||||
})
|
||||
|
||||
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
|
||||
|
||||
expect(result.status).toBe('rejected')
|
||||
expect(result.auto_rejected).toBe(true)
|
||||
expect(result.http_status).toBe(409)
|
||||
})
|
||||
|
||||
it('rejects when invoice_id is missing', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
||||
enqueue({ data: null, error: null }) // dispatcher's reject update
|
||||
const op = makePendingOp({ operation_type: 'credit_invoice', params: {} })
|
||||
|
||||
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
|
||||
|
||||
expect(result.status).toBe('failed')
|
||||
expect(result.http_status).toBe(400)
|
||||
})
|
||||
|
||||
it('rejects invoices with status outside sent/paid/overdue', async () => {
|
||||
const original = makeInvoice({ id: 'inv-1', status: 'draft', document_type: 'invoice' })
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
||||
enqueue({ data: { ...original, items: [] }, error: null })
|
||||
enqueue({ data: null, error: null }) // dispatcher's reject update
|
||||
|
||||
const op = makePendingOp({
|
||||
operation_type: 'credit_invoice',
|
||||
params: { invoice_id: 'inv-1' },
|
||||
})
|
||||
|
||||
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
|
||||
|
||||
expect(result.status).toBe('failed')
|
||||
expect(result.http_status).toBe(400)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { getRiskLevel, isHighRisk, OPERATION_RISK_TIERS } from '../risk-tiers'
|
||||
|
||||
describe('risk-tiers', () => {
|
||||
it('classifies all currently-staged op types', () => {
|
||||
// Op types that exist in the pending_operations CHECK constraint today.
|
||||
const knownOps = [
|
||||
'categorize_transaction',
|
||||
'create_customer',
|
||||
'create_invoice',
|
||||
'mark_invoice_paid',
|
||||
'send_invoice',
|
||||
'mark_invoice_sent',
|
||||
'match_transaction_invoice',
|
||||
]
|
||||
for (const op of knownOps) {
|
||||
expect(OPERATION_RISK_TIERS).toHaveProperty(op)
|
||||
}
|
||||
})
|
||||
|
||||
it('treats sending invoices and marking paid as high risk', () => {
|
||||
expect(getRiskLevel('send_invoice')).toBe('high')
|
||||
expect(getRiskLevel('mark_invoice_paid')).toBe('high')
|
||||
expect(getRiskLevel('mark_invoice_sent')).toBe('high')
|
||||
})
|
||||
|
||||
it('treats period close, year-end, and SIE import as high risk', () => {
|
||||
expect(getRiskLevel('close_period')).toBe('high')
|
||||
expect(getRiskLevel('lock_period')).toBe('high')
|
||||
expect(getRiskLevel('run_year_end')).toBe('high')
|
||||
expect(getRiskLevel('import_sie')).toBe('high')
|
||||
expect(getRiskLevel('set_opening_balances')).toBe('high')
|
||||
})
|
||||
|
||||
it('treats customer creation as low risk (no booking impact)', () => {
|
||||
expect(getRiskLevel('create_customer')).toBe('low')
|
||||
})
|
||||
|
||||
it('treats reversible bookings as medium risk', () => {
|
||||
expect(getRiskLevel('categorize_transaction')).toBe('medium')
|
||||
expect(getRiskLevel('match_transaction_invoice')).toBe('medium')
|
||||
expect(getRiskLevel('create_invoice')).toBe('medium')
|
||||
expect(getRiskLevel('uncategorize_transaction')).toBe('medium')
|
||||
})
|
||||
|
||||
it('defaults unknown op types to high (fail-safe)', () => {
|
||||
expect(getRiskLevel('totally_unknown_op')).toBe('high')
|
||||
expect(isHighRisk('totally_unknown_op')).toBe(true)
|
||||
})
|
||||
|
||||
it('isHighRisk returns true only for high-risk ops', () => {
|
||||
expect(isHighRisk('send_invoice')).toBe(true)
|
||||
expect(isHighRisk('create_customer')).toBe(false)
|
||||
expect(isHighRisk('categorize_transaction')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,154 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { shouldAutoCommit } from '../should-auto-commit'
|
||||
|
||||
function mockSettingsClient(settings: { agent_auto_commit_enabled?: boolean; agent_auto_commit_max_amount?: number | null } | null) {
|
||||
return {
|
||||
from: vi.fn().mockReturnValue({
|
||||
select: vi.fn().mockReturnValue({
|
||||
eq: vi.fn().mockReturnValue({
|
||||
maybeSingle: vi.fn().mockResolvedValue(
|
||||
settings === null
|
||||
? { data: null, error: null }
|
||||
: { data: settings, error: null }
|
||||
),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
} as never
|
||||
}
|
||||
|
||||
describe('shouldAutoCommit', () => {
|
||||
it('rejects high-risk ops regardless of any other config', async () => {
|
||||
const supabase = mockSettingsClient({ agent_auto_commit_enabled: true })
|
||||
const decision = await shouldAutoCommit(supabase, 'company-1', {
|
||||
operationType: 'send_invoice',
|
||||
actorType: 'api_key',
|
||||
})
|
||||
expect(decision.eligible).toBe(false)
|
||||
expect(decision.risk_level).toBe('high')
|
||||
expect(decision.reason).toContain('high-risk')
|
||||
})
|
||||
|
||||
it('rejects user actors (they approve via UI)', async () => {
|
||||
const supabase = mockSettingsClient({ agent_auto_commit_enabled: true })
|
||||
const decision = await shouldAutoCommit(supabase, 'company-1', {
|
||||
operationType: 'create_customer',
|
||||
actorType: 'user',
|
||||
})
|
||||
expect(decision.eligible).toBe(false)
|
||||
expect(decision.reason).toContain('approve via the UI')
|
||||
})
|
||||
|
||||
it('rejects when company has not opted in', async () => {
|
||||
const supabase = mockSettingsClient({ agent_auto_commit_enabled: false })
|
||||
const decision = await shouldAutoCommit(supabase, 'company-1', {
|
||||
operationType: 'create_customer',
|
||||
actorType: 'api_key',
|
||||
})
|
||||
expect(decision.eligible).toBe(false)
|
||||
expect(decision.reason).toContain('not opted in')
|
||||
})
|
||||
|
||||
it('rejects medium-risk ops in current phase', async () => {
|
||||
const supabase = mockSettingsClient({ agent_auto_commit_enabled: true })
|
||||
const decision = await shouldAutoCommit(supabase, 'company-1', {
|
||||
operationType: 'categorize_transaction',
|
||||
actorType: 'api_key',
|
||||
})
|
||||
expect(decision.eligible).toBe(false)
|
||||
expect(decision.reason).toContain('low-risk')
|
||||
})
|
||||
|
||||
it('approves low-risk ops from api_key with company opt-in', async () => {
|
||||
const supabase = mockSettingsClient({ agent_auto_commit_enabled: true })
|
||||
const decision = await shouldAutoCommit(supabase, 'company-1', {
|
||||
operationType: 'create_customer',
|
||||
actorType: 'api_key',
|
||||
})
|
||||
expect(decision.eligible).toBe(true)
|
||||
expect(decision.risk_level).toBe('low')
|
||||
})
|
||||
|
||||
it('blocks low-risk op when amount exceeds threshold', async () => {
|
||||
const supabase = mockSettingsClient({
|
||||
agent_auto_commit_enabled: true,
|
||||
agent_auto_commit_max_amount: 1000,
|
||||
})
|
||||
const decision = await shouldAutoCommit(supabase, 'company-1', {
|
||||
operationType: 'create_customer',
|
||||
actorType: 'api_key',
|
||||
amount: 5000,
|
||||
})
|
||||
expect(decision.eligible).toBe(false)
|
||||
expect(decision.reason).toContain('exceeds')
|
||||
})
|
||||
|
||||
it('approves low-risk op when amount within threshold', async () => {
|
||||
const supabase = mockSettingsClient({
|
||||
agent_auto_commit_enabled: true,
|
||||
agent_auto_commit_max_amount: 1000,
|
||||
})
|
||||
const decision = await shouldAutoCommit(supabase, 'company-1', {
|
||||
operationType: 'create_customer',
|
||||
actorType: 'api_key',
|
||||
amount: 500,
|
||||
})
|
||||
expect(decision.eligible).toBe(true)
|
||||
})
|
||||
|
||||
it('approves low-risk op when amount missing (no threshold check applies)', async () => {
|
||||
const supabase = mockSettingsClient({
|
||||
agent_auto_commit_enabled: true,
|
||||
agent_auto_commit_max_amount: 1000,
|
||||
})
|
||||
const decision = await shouldAutoCommit(supabase, 'company-1', {
|
||||
operationType: 'create_customer',
|
||||
actorType: 'api_key',
|
||||
// amount intentionally undefined
|
||||
})
|
||||
expect(decision.eligible).toBe(true)
|
||||
})
|
||||
|
||||
it('cron actors auto-commit non-high-risk without DB lookup', async () => {
|
||||
const supabase = mockSettingsClient(null)
|
||||
const decision = await shouldAutoCommit(supabase, 'company-1', {
|
||||
operationType: 'create_customer',
|
||||
actorType: 'cron',
|
||||
})
|
||||
expect(decision.eligible).toBe(true)
|
||||
expect(decision.reason).toContain('Cron')
|
||||
})
|
||||
|
||||
it('cron actors still rejected for high-risk ops', async () => {
|
||||
const supabase = mockSettingsClient(null)
|
||||
const decision = await shouldAutoCommit(supabase, 'company-1', {
|
||||
operationType: 'send_invoice',
|
||||
actorType: 'cron',
|
||||
})
|
||||
expect(decision.eligible).toBe(false)
|
||||
})
|
||||
|
||||
it('falls back to human approval when settings missing', async () => {
|
||||
const supabase = mockSettingsClient(null)
|
||||
const decision = await shouldAutoCommit(supabase, 'company-1', {
|
||||
operationType: 'create_customer',
|
||||
actorType: 'api_key',
|
||||
})
|
||||
expect(decision.eligible).toBe(false)
|
||||
expect(decision.reason).toContain('Could not read company settings')
|
||||
})
|
||||
|
||||
it('treats negative amounts (refunds) by absolute value', async () => {
|
||||
const supabase = mockSettingsClient({
|
||||
agent_auto_commit_enabled: true,
|
||||
agent_auto_commit_max_amount: 1000,
|
||||
})
|
||||
const decision = await shouldAutoCommit(supabase, 'company-1', {
|
||||
operationType: 'create_customer',
|
||||
actorType: 'api_key',
|
||||
amount: -5000,
|
||||
})
|
||||
expect(decision.eligible).toBe(false)
|
||||
expect(decision.reason).toContain('exceeds')
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Risk tier classification for pending_operations.
|
||||
*
|
||||
* Used by lib/pending-operations/should-auto-commit.ts to decide whether a
|
||||
* staged proposal from a trusted agent can be auto-committed without human
|
||||
* review.
|
||||
*
|
||||
* Tiering principles:
|
||||
* - **low**: no booking impact, no external side-effects, no audit risk.
|
||||
* A reasonable bookkeeper would never want to manually approve these.
|
||||
* - **medium**: reversible booking impact (drafts, transaction
|
||||
* categorization that can be uncategorized). Auto-commit is allowed for
|
||||
* trusted agents under a configurable monetary threshold.
|
||||
* - **high**: irreversible or compliance-critical. Sends external messages,
|
||||
* locks/closes periods, or affects tax filings. NEVER auto-committed,
|
||||
* regardless of company opt-in or trust level.
|
||||
*/
|
||||
|
||||
export type RiskLevel = 'low' | 'medium' | 'high'
|
||||
|
||||
export const OPERATION_RISK_TIERS: Record<string, RiskLevel> = {
|
||||
// ── Low: pure data, no booking impact ─────────────────────────────
|
||||
create_customer: 'low',
|
||||
|
||||
// ── Medium: reversible booking ─────────────────────────────────────
|
||||
categorize_transaction: 'medium',
|
||||
match_transaction_invoice: 'medium',
|
||||
create_invoice: 'medium', // creates as draft; sending is a separate op
|
||||
|
||||
// ── High: irreversible, compliance-critical, or external side-effects
|
||||
send_invoice: 'high', // emails the customer
|
||||
mark_invoice_paid: 'high', // posts payment journal entry
|
||||
mark_invoice_sent: 'high', // assigns invoice number, accrual JE
|
||||
|
||||
// ── Stream 1 Phase 1 ops (added when those tools land) ─────────────
|
||||
close_period: 'high',
|
||||
lock_period: 'high',
|
||||
unlock_period: 'high',
|
||||
set_opening_balances: 'high',
|
||||
run_year_end: 'high',
|
||||
run_currency_revaluation: 'high',
|
||||
import_sie: 'high',
|
||||
explain_voucher_gap: 'medium',
|
||||
uncategorize_transaction: 'medium',
|
||||
approve_supplier_invoice: 'high',
|
||||
credit_supplier_invoice: 'high',
|
||||
credit_invoice: 'high',
|
||||
convert_invoice: 'medium',
|
||||
}
|
||||
|
||||
export function getRiskLevel(operationType: string): RiskLevel {
|
||||
// Default to 'high' for unknown ops — fail-safe: unknown means human review.
|
||||
return OPERATION_RISK_TIERS[operationType] ?? 'high'
|
||||
}
|
||||
|
||||
/**
|
||||
* High-risk operations are NEVER auto-committed, regardless of company opt-in
|
||||
* or actor trust. Encoded here (not in DB config) so it can't be bypassed.
|
||||
*/
|
||||
export function isHighRisk(operationType: string): boolean {
|
||||
return getRiskLevel(operationType) === 'high'
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Decide whether a freshly-staged pending_operation should be auto-committed
|
||||
* by a trusted agent without human approval.
|
||||
*
|
||||
* Defense-in-depth: high-risk operations (period close, year-end, send_invoice,
|
||||
* etc.) are NEVER auto-committed regardless of company settings or actor
|
||||
* trust — that gate lives in risk-tiers.ts and is checked here before any
|
||||
* config lookup.
|
||||
*
|
||||
* Trust hierarchy:
|
||||
* - 'user' actors are humans clicking in the UI; auto-commit doesn't apply
|
||||
* (the click IS the approval)
|
||||
* - 'api_key' / 'mcp_oauth' actors are agents; eligible for auto-commit if
|
||||
* the company opts in and the op is low-risk
|
||||
* - 'cron' actors are system tasks; always auto-commit (they have no
|
||||
* human in the loop by design)
|
||||
*
|
||||
* Monetary threshold:
|
||||
* When `agent_auto_commit_max_amount` is set, any low-risk op with a
|
||||
* preview/payload amount above the threshold falls back to human approval.
|
||||
* The amount is read from the preview_data — callers should put it under
|
||||
* `amount` or `total` for the gate to find it. Missing amount → not blocked
|
||||
* by the threshold (safe for ops like create_customer where there's no
|
||||
* single dollar value).
|
||||
*/
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { isHighRisk, getRiskLevel, type RiskLevel } from './risk-tiers'
|
||||
|
||||
export type AutoCommitActorType = 'user' | 'api_key' | 'mcp_oauth' | 'cron'
|
||||
|
||||
export interface AutoCommitInput {
|
||||
operationType: string
|
||||
actorType: AutoCommitActorType
|
||||
/** Optional monetary amount to check against agent_auto_commit_max_amount. */
|
||||
amount?: number | null
|
||||
}
|
||||
|
||||
export interface AutoCommitDecision {
|
||||
eligible: boolean
|
||||
reason: string
|
||||
risk_level: RiskLevel
|
||||
}
|
||||
|
||||
/**
|
||||
* Cheap pure-logic check that doesn't hit the DB. Used to short-circuit
|
||||
* obvious "no" cases before reading company_settings.
|
||||
*/
|
||||
function precheck(input: AutoCommitInput): AutoCommitDecision | null {
|
||||
const risk = getRiskLevel(input.operationType)
|
||||
|
||||
if (isHighRisk(input.operationType)) {
|
||||
return {
|
||||
eligible: false,
|
||||
reason: `Operation "${input.operationType}" is high-risk and never auto-committed.`,
|
||||
risk_level: risk,
|
||||
}
|
||||
}
|
||||
|
||||
if (input.actorType === 'user') {
|
||||
return {
|
||||
eligible: false,
|
||||
reason: 'User actors approve via the UI; auto-commit does not apply.',
|
||||
risk_level: risk,
|
||||
}
|
||||
}
|
||||
|
||||
// Cron actors auto-commit non-high-risk regardless of company config.
|
||||
// Resolved here so we don't read company_settings unnecessarily.
|
||||
if (input.actorType === 'cron') {
|
||||
return {
|
||||
eligible: true,
|
||||
reason: 'Cron actor: auto-commit allowed for non-high-risk ops.',
|
||||
risk_level: risk,
|
||||
}
|
||||
}
|
||||
|
||||
// For api_key/mcp_oauth: only low-risk is auto-committable in this phase.
|
||||
// Reject medium-risk before the company_settings lookup so callers don't pay
|
||||
// for a DB read that can't succeed.
|
||||
if (risk !== 'low') {
|
||||
return {
|
||||
eligible: false,
|
||||
reason: 'Only low-risk operations are auto-committable in the current phase.',
|
||||
risk_level: risk,
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export async function shouldAutoCommit(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
input: AutoCommitInput
|
||||
): Promise<AutoCommitDecision> {
|
||||
const pre = precheck(input)
|
||||
if (pre) return pre
|
||||
|
||||
const risk = getRiskLevel(input.operationType)
|
||||
|
||||
// api_key / mcp_oauth + low-risk: gated by company opt-in and threshold.
|
||||
const { data: settings, error } = await supabase
|
||||
.from('company_settings')
|
||||
.select('agent_auto_commit_enabled, agent_auto_commit_max_amount')
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
|
||||
if (error || !settings) {
|
||||
return {
|
||||
eligible: false,
|
||||
reason: 'Could not read company settings; defaulting to human approval.',
|
||||
risk_level: risk,
|
||||
}
|
||||
}
|
||||
|
||||
if (!settings.agent_auto_commit_enabled) {
|
||||
return {
|
||||
eligible: false,
|
||||
reason: 'Company has not opted in to agent auto-commit.',
|
||||
risk_level: risk,
|
||||
}
|
||||
}
|
||||
|
||||
const max = settings.agent_auto_commit_max_amount
|
||||
const amount = input.amount
|
||||
if (max != null && amount != null && Math.abs(amount) > Number(max)) {
|
||||
return {
|
||||
eligible: false,
|
||||
reason: `Amount ${amount} exceeds company auto-commit threshold ${max}.`,
|
||||
risk_level: risk,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
eligible: true,
|
||||
reason: 'Low-risk op, trusted actor, company opted in, amount within limit.',
|
||||
risk_level: risk,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user