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:
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user