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,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