Files
accounted/lib/core/bookkeeping/period-service.ts
T
Jakob WennbergandClaude Opus 4.6 e89f2c402d feat: complete multi-tenant refactor + settings validation + fiscal period API (#156)
* feat: complete multi-tenant refactor for reconciliation, arcim, settings validation

- Migrate bank-reconciliation to company_id (all functions + tests)
- Migrate arcim-migration entity mappers and orchestrator to company_id
- Fix enable-banking reconciliation calls to use companyId
- Add Swedish law validation to settings schema:
  - VAT number required when VAT-registered (ML 11 kap. 8§)
  - Moms period required when VAT-registered (SFL 26 kap.)
  - Aktiebolag must use accrual accounting (BFNAR 2006:1)
- Fix fiscal year period creation: always 12 months after first year (BFL 3 kap.)
- Add plusgiro, website, pays_salaries fields to CompanySettings
- Add plusgiro to invoice PDF template
- Add fiscal period CRUD and opening balances API routes
- Add frame-src CSP directive for future iframe embedding
- Fix unlinked_1930_lines RPC to use company_id parameter
- Update CLAUDE.md documentation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address PR review findings (P1 + P2)

- Fix reconciliation events emitting companyId as userId — thread
  actual userId through runReconciliation and manualLink
- Move VAT cross-field validation (vat_number, moms_period) from
  schema refinements to route handler where effective stored state
  is available, preventing false rejection on partial updates
- Add plusgiro format validation regex (N-N pattern)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 14:46:41 +02:00

258 lines
7.1 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
}
/**
* 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 (max 18 months per BFL 3 kap.)
const durationError = validatePeriodDuration(nextStartStr, nextEndStr)
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
}
/**
* 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,
}
}