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 { // 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 } /** * 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 { 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 { 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 { 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 { 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, } }