* 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
415 lines
12 KiB
TypeScript
415 lines
12 KiB
TypeScript
import type { SupabaseClient } from '@supabase/supabase-js'
|
||
import { eventBus } from '@/lib/events'
|
||
import { validatePeriodDuration } from '@/lib/bookkeeping/validate-period-duration'
|
||
import type { FiscalPeriod, PeriodStatus } from '@/types'
|
||
|
||
/**
|
||
* Lock a fiscal period — prevents new journal entries from being posted.
|
||
* Requires: period exists, belongs to company, not already locked/closed.
|
||
*/
|
||
export async function lockPeriod(
|
||
supabase: SupabaseClient,
|
||
companyId: string,
|
||
userId: string,
|
||
fiscalPeriodId: string
|
||
): Promise<FiscalPeriod> {
|
||
|
||
// Fetch period
|
||
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('Period is already closed')
|
||
}
|
||
|
||
if (period.locked_at) {
|
||
throw new Error('Period is already locked')
|
||
}
|
||
|
||
// Check for uncategorized business transactions in this period
|
||
const { count: unbookedCount } = await supabase
|
||
.from('transactions')
|
||
.select('id', { count: 'exact', head: true })
|
||
.eq('company_id', companyId)
|
||
.is('journal_entry_id', null)
|
||
.eq('is_business', true)
|
||
.gte('date', period.period_start)
|
||
.lte('date', period.period_end)
|
||
|
||
if (unbookedCount && unbookedCount > 0) {
|
||
throw new Error(
|
||
`Kan inte låsa period: ${unbookedCount} affärstransaktion(er) saknar bokföring. Bokför alla transaktioner innan perioden låses.`
|
||
)
|
||
}
|
||
|
||
const { data: updated, error: updateError } = await supabase
|
||
.from('fiscal_periods')
|
||
.update({ locked_at: new Date().toISOString() })
|
||
.eq('id', fiscalPeriodId)
|
||
.eq('company_id', companyId)
|
||
.select()
|
||
.single()
|
||
|
||
if (updateError || !updated) {
|
||
throw new Error(`Failed to lock period: ${updateError?.message}`)
|
||
}
|
||
|
||
const result = updated as FiscalPeriod
|
||
|
||
await eventBus.emit({
|
||
type: 'period.locked',
|
||
payload: { period: result, companyId, userId },
|
||
})
|
||
|
||
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).
|
||
*/
|
||
export async function closePeriod(
|
||
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('Period is already closed')
|
||
}
|
||
|
||
if (!period.locked_at) {
|
||
throw new Error('Period must be locked before closing')
|
||
}
|
||
|
||
if (!period.closing_entry_id) {
|
||
throw new Error('Year-end closing must be executed before closing the period')
|
||
}
|
||
|
||
const { data: updated, error: updateError } = await supabase
|
||
.from('fiscal_periods')
|
||
.update({
|
||
is_closed: true,
|
||
closed_at: new Date().toISOString(),
|
||
})
|
||
.eq('id', fiscalPeriodId)
|
||
.eq('company_id', companyId)
|
||
.select()
|
||
.single()
|
||
|
||
if (updateError || !updated) {
|
||
throw new Error(`Failed to close period: ${updateError?.message}`)
|
||
}
|
||
|
||
return updated as FiscalPeriod
|
||
}
|
||
|
||
/**
|
||
* Create the next fiscal period following the current one.
|
||
* Computes dates based on the current period's length (handles brutet räkenskapsår).
|
||
* Sets previous_period_id for chain validation.
|
||
*/
|
||
export async function createNextPeriod(
|
||
supabase: SupabaseClient,
|
||
companyId: string,
|
||
userId: string,
|
||
currentPeriodId: string
|
||
): Promise<FiscalPeriod> {
|
||
|
||
const { data: current, error: fetchError } = await supabase
|
||
.from('fiscal_periods')
|
||
.select('*')
|
||
.eq('id', currentPeriodId)
|
||
.eq('company_id', companyId)
|
||
.single()
|
||
|
||
if (fetchError || !current) {
|
||
throw new Error('Current fiscal period not found')
|
||
}
|
||
|
||
// Compute next period start (day after current end)
|
||
const nextStart = new Date(current.period_end)
|
||
nextStart.setDate(nextStart.getDate() + 1)
|
||
|
||
// After a broken first fiscal year, subsequent years should always be
|
||
// 12 months (standard fiscal year). The first year is the only one that
|
||
// can be longer/shorter than 12 months per BFL 3 kap.
|
||
const nextEnd = new Date(nextStart)
|
||
nextEnd.setMonth(nextEnd.getMonth() + 12)
|
||
// Go to last day of that month
|
||
nextEnd.setDate(0)
|
||
|
||
const nextStartStr = nextStart.toISOString().split('T')[0]
|
||
const nextEndStr = nextEnd.toISOString().split('T')[0]
|
||
|
||
// Validate period duration — subsequent periods always start on 1st of month
|
||
const durationError = validatePeriodDuration(nextStartStr, nextEndStr, { isFirstPeriod: false })
|
||
if (durationError) {
|
||
throw new Error(durationError)
|
||
}
|
||
|
||
// Check for overlapping periods
|
||
const { data: overlapping } = await supabase
|
||
.from('fiscal_periods')
|
||
.select('id')
|
||
.eq('company_id', companyId)
|
||
.lte('period_start', nextEndStr)
|
||
.gte('period_end', nextStartStr)
|
||
.limit(1)
|
||
|
||
if (overlapping && overlapping.length > 0) {
|
||
throw new Error('Next fiscal period already exists or overlaps with an existing period')
|
||
}
|
||
|
||
// Generate name: e.g. "FY 2025" or "FY 2025/2026"
|
||
const startYear = nextStart.getFullYear()
|
||
const endYear = nextEnd.getFullYear()
|
||
const name = startYear === endYear ? `FY ${startYear}` : `FY ${startYear}/${endYear}`
|
||
|
||
const { data: newPeriod, error: insertError } = await supabase
|
||
.from('fiscal_periods')
|
||
.insert({
|
||
company_id: companyId,
|
||
user_id: userId,
|
||
name,
|
||
period_start: nextStartStr,
|
||
period_end: nextEndStr,
|
||
previous_period_id: currentPeriodId,
|
||
})
|
||
.select()
|
||
.single()
|
||
|
||
if (insertError || !newPeriod) {
|
||
throw new Error(`Failed to create next period: ${insertError?.message}`)
|
||
}
|
||
|
||
return newPeriod as FiscalPeriod
|
||
}
|
||
|
||
/**
|
||
* Create a previous fiscal period before the given one.
|
||
* Computes a 12-month period ending the day before the given period starts.
|
||
* Updates previous_period_id chain so the given period points to the new one.
|
||
*/
|
||
export async function createPreviousPeriod(
|
||
supabase: SupabaseClient,
|
||
companyId: string,
|
||
userId: string,
|
||
currentPeriodId: string
|
||
): Promise<FiscalPeriod> {
|
||
|
||
const { data: current, error: fetchError } = await supabase
|
||
.from('fiscal_periods')
|
||
.select('*')
|
||
.eq('id', currentPeriodId)
|
||
.eq('company_id', companyId)
|
||
.single()
|
||
|
||
if (fetchError || !current) {
|
||
throw new Error('Current fiscal period not found')
|
||
}
|
||
|
||
// Compute previous period end (day before current start)
|
||
const prevEnd = new Date(current.period_start + 'T12:00:00Z')
|
||
prevEnd.setUTCDate(prevEnd.getUTCDate() - 1)
|
||
|
||
// Compute previous period start (1st of month, 12 months before prevEnd)
|
||
const prevStart = new Date(prevEnd)
|
||
prevStart.setUTCMonth(prevStart.getUTCMonth() - 11)
|
||
prevStart.setUTCDate(1)
|
||
|
||
const prevStartStr = prevStart.toISOString().split('T')[0]
|
||
const prevEndStr = prevEnd.toISOString().split('T')[0]
|
||
|
||
// Validate period duration
|
||
const durationError = validatePeriodDuration(prevStartStr, prevEndStr, { isFirstPeriod: false })
|
||
if (durationError) {
|
||
throw new Error(durationError)
|
||
}
|
||
|
||
// Check for overlapping periods
|
||
const { data: overlapping } = await supabase
|
||
.from('fiscal_periods')
|
||
.select('id')
|
||
.eq('company_id', companyId)
|
||
.lte('period_start', prevEndStr)
|
||
.gte('period_end', prevStartStr)
|
||
.limit(1)
|
||
|
||
if (overlapping && overlapping.length > 0) {
|
||
throw new Error('Previous fiscal period already exists or overlaps with an existing period')
|
||
}
|
||
|
||
// Generate name
|
||
const startYear = prevStart.getFullYear()
|
||
const endYear = prevEnd.getFullYear()
|
||
const name = startYear === endYear ? `FY ${startYear}` : `FY ${startYear}/${endYear}`
|
||
|
||
const { data: newPeriod, error: insertError } = await supabase
|
||
.from('fiscal_periods')
|
||
.insert({
|
||
company_id: companyId,
|
||
user_id: userId,
|
||
name,
|
||
period_start: prevStartStr,
|
||
period_end: prevEndStr,
|
||
})
|
||
.select()
|
||
.single()
|
||
|
||
if (insertError || !newPeriod) {
|
||
throw new Error(`Failed to create previous period: ${insertError?.message}`)
|
||
}
|
||
|
||
// Update the current period to point to the new one
|
||
const { error: updateError } = await supabase
|
||
.from('fiscal_periods')
|
||
.update({ previous_period_id: newPeriod.id })
|
||
.eq('id', currentPeriodId)
|
||
.eq('company_id', companyId)
|
||
|
||
if (updateError) {
|
||
throw new Error(`Failed to update period chain: ${updateError.message}`)
|
||
}
|
||
|
||
return newPeriod as FiscalPeriod
|
||
}
|
||
|
||
/**
|
||
* Get status summary for a fiscal period.
|
||
*/
|
||
export async function getPeriodStatus(
|
||
supabase: SupabaseClient,
|
||
companyId: string,
|
||
userId: string,
|
||
fiscalPeriodId: string
|
||
): Promise<PeriodStatus> {
|
||
|
||
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')
|
||
}
|
||
|
||
// Count draft entries in this period
|
||
const { count: draftCount } = await supabase
|
||
.from('journal_entries')
|
||
.select('id', { count: 'exact', head: true })
|
||
.eq('company_id', companyId)
|
||
.eq('fiscal_period_id', fiscalPeriodId)
|
||
.eq('status', 'draft')
|
||
|
||
// Check if next period exists
|
||
const nextStart = new Date(period.period_end)
|
||
nextStart.setDate(nextStart.getDate() + 1)
|
||
|
||
const { data: nextPeriod } = await supabase
|
||
.from('fiscal_periods')
|
||
.select('id')
|
||
.eq('company_id', companyId)
|
||
.eq('previous_period_id', fiscalPeriodId)
|
||
.maybeSingle()
|
||
|
||
return {
|
||
is_locked: !!period.locked_at,
|
||
is_closed: period.is_closed,
|
||
has_closing_entry: !!period.closing_entry_id,
|
||
has_opening_balances: period.opening_balances_set,
|
||
draft_count: draftCount ?? 0,
|
||
next_period_exists: !!nextPeriod,
|
||
}
|
||
}
|