Files
accounted/lib/core/bookkeeping/year-end-service.ts
T
Jakob WennbergandClaude Sonnet 5 ec27228a8e style: remove em/en dashes repo-wide, add CLAUDE.md rule against them (#890)
Em dashes (—) and en dashes (–) had spread across comments, docs, tests,
and a few UI strings, reading as AI-generated boilerplate rather than
house style. Replaced each with punctuation matching its context: colon
for explanatory clauses, comma for asides, plain hyphen for numeric/legal
ranges (e.g. "21-23§"), "to"/"till" for date ranges, parentheses for
paired-dash asides. messages/en.json and messages/sv.json were fixed by
hand together to keep sv/en in sync.

Left untouched where the dash is the functional subject rather than
decorative punctuation: date-range-parser.ts's separator regex,
charset-repair.ts's CP1252 byte-mapping table (and its test), the SIE
encoding mojibake docs, generic-csv.ts's minus-sign normalizer, the
agent system-prompt files that already instruct against em dashes, and
a golden iXBRL test fixture compared byte-for-byte.

Also fixes two bugs surfaced along the way: an off-by-one in
ApiKeysPanel's scope-label split (a leftover from an earlier partial
pass), and a charset-repair test that had lost the literal en-dash it
exists to verify.

Regenerated the agent atom seed migration (skills:generate) since 27
SKILL.md files changed. Added a CLAUDE.md rule against em/en dashes,
with an explicit carve-out for the functional-dash cases above.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 15:58:06 +02:00

803 lines
27 KiB
TypeScript

import type { SupabaseClient } from '@supabase/supabase-js'
import { eventBus } from '@/lib/events'
import { createJournalEntry, reverseEntry } from '@/lib/bookkeeping/engine'
import { roundOre, ORE_TOLERANCE } from '@/lib/bokslut/rounding'
import { createLogger } from '@/lib/logger'
const log = createLogger('year-end-service')
import { generateTrialBalance } from '@/lib/reports/trial-balance'
import { generateIncomeStatement } from '@/lib/reports/income-statement'
import { lockPeriod, closePeriod, createNextPeriod, findNextPeriod } from './period-service'
import { generateResultAppropriation } from './result-appropriation-service'
import {
previewCurrencyRevaluation,
executeCurrencyRevaluation,
} from '@/lib/bookkeeping/currency-revaluation'
import { validateBalanceContinuity } from '@/lib/reports/continuity-check'
import type {
YearEndValidation,
YearEndPreview,
YearEndResult,
CreateJournalEntryLineInput,
FiscalPeriod,
JournalEntry,
VoucherGap,
SequenceMismatch,
} from '@/types'
/**
* Validate whether a fiscal period is ready for year-end closing.
* Returns blocking errors and informational warnings.
*/
export async function validateYearEndReadiness(
supabase: SupabaseClient,
companyId: string,
userId: string,
fiscalPeriodId: string
): Promise<YearEndValidation> {
const errors: string[] = []
const warnings: string[] = []
// Fetch the period
const { data: period, error: fetchError } = await supabase
.from('fiscal_periods')
.select('*')
.eq('id', fiscalPeriodId)
.eq('company_id', companyId)
.single()
if (fetchError || !period) {
return {
ready: false,
errors: ['Fiscal period not found'],
warnings: [],
draftCount: 0,
voucherGaps: [],
unexplainedGaps: [],
sequenceMismatches: [],
trialBalanceBalanced: false,
}
}
// Check: period must have ended (BFNAR 2017:3 / ÅRL 2:1)
const today = new Date().toISOString().split('T')[0]
if (period.period_end > today) {
errors.push('Cannot close a fiscal period that has not yet ended')
}
// Check: period not already closed
if (period.is_closed) {
errors.push('Period is already closed')
}
// Check: closing entry doesn't already exist
if (period.closing_entry_id) {
errors.push('Year-end closing entry already exists for this period')
}
// Check: no draft entries
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')
const drafts = draftCount ?? 0
if (drafts > 0) {
errors.push(`${drafts} draft journal entries must be posted or deleted before closing`)
}
// Check: voucher continuity across all series
let voucherGaps: VoucherGap[] = []
const { data: seriesRows } = await supabase
.from('voucher_sequences')
.select('voucher_series')
.eq('company_id', companyId)
.eq('fiscal_period_id', fiscalPeriodId)
const seriesToCheck = seriesRows && seriesRows.length > 0
? seriesRows.map((r: { voucher_series: string }) => r.voucher_series)
: ['A']
for (const series of seriesToCheck) {
const { data: gaps, error: gapsError } = await supabase.rpc('detect_voucher_gaps', {
p_company_id: companyId,
p_fiscal_period_id: fiscalPeriodId,
p_series: series,
})
if (!gapsError && gaps && gaps.length > 0) {
const tagged = (gaps as Array<{ gap_start: number; gap_end: number }>).map((g) => ({
...g,
series,
}))
voucherGaps.push(...tagged)
}
}
// Check gap explanations: unexplained gaps block year-end (BFNAR 2013:2 punkt 5.8)
let unexplainedGaps: VoucherGap[] = []
if (voucherGaps.length > 0) {
const { data: explanations } = await supabase
.from('voucher_gap_explanations')
.select('voucher_series, gap_start, gap_end')
.eq('company_id', companyId)
.eq('fiscal_period_id', fiscalPeriodId)
const explanationSet = new Set(
(explanations ?? []).map(
(e: { voucher_series: string; gap_start: number; gap_end: number }) =>
`${e.voucher_series}:${e.gap_start}:${e.gap_end}`
)
)
for (const gap of voucherGaps) {
const key = `${gap.series}:${gap.gap_start}:${gap.gap_end}`
if (explanationSet.has(key)) {
warnings.push(
`Voucher gap in series ${gap.series} (${gap.gap_start}-${gap.gap_end}): documented`
)
} else {
unexplainedGaps.push(gap)
errors.push(
`Unexplained voucher gap in series ${gap.series}: ${gap.gap_start}-${gap.gap_end}`
)
}
}
}
// Check: sequence counter reconciliation
const sequenceMismatches: SequenceMismatch[] = []
if (seriesRows && seriesRows.length > 0) {
for (const row of seriesRows as Array<{ voucher_series: string }>) {
const { data: seqData } = await supabase
.from('voucher_sequences')
.select('last_number')
.eq('company_id', companyId)
.eq('fiscal_period_id', fiscalPeriodId)
.eq('voucher_series', row.voucher_series)
.single()
const { data: maxData } = await supabase
.from('journal_entries')
.select('voucher_number')
.eq('company_id', companyId)
.eq('fiscal_period_id', fiscalPeriodId)
.eq('voucher_series', row.voucher_series)
.neq('status', 'draft')
.order('voucher_number', { ascending: false })
.limit(1)
.maybeSingle()
const sequenceCounter = seqData?.last_number ?? 0
const actualMax = maxData?.voucher_number ?? 0
if (sequenceCounter !== actualMax) {
sequenceMismatches.push({
series: row.voucher_series,
sequenceCounter,
actualMax,
})
if (sequenceCounter < actualMax) {
errors.push(
`Sequence counter integrity error in series ${row.voucher_series}: counter=${sequenceCounter} but max voucher=${actualMax}`
)
} else {
warnings.push(
`Sequence counter ahead of actual entries in series ${row.voucher_series}: counter=${sequenceCounter}, max voucher=${actualMax}`
)
}
}
}
}
// Check: trial balance is balanced
const trialBalance = await generateTrialBalance(supabase, companyId, fiscalPeriodId)
const trialBalanceBalanced = trialBalance.isBalanced
if (!trialBalanceBalanced) {
errors.push(
`Trial balance is not balanced: debit=${trialBalance.totalDebit}, credit=${trialBalance.totalCredit}`
)
}
// Check: at least some entries exist
const { count: entryCount } = await supabase
.from('journal_entries')
.select('id', { count: 'exact', head: true })
.eq('company_id', companyId)
.eq('fiscal_period_id', fiscalPeriodId)
.eq('status', 'posted')
if ((entryCount ?? 0) === 0) {
warnings.push('No posted journal entries in this period')
}
// Check: foreign currency items exist but haven't been revalued
const { count: revalCount } = await supabase
.from('journal_entries')
.select('id', { count: 'exact', head: true })
.eq('company_id', companyId)
.eq('fiscal_period_id', fiscalPeriodId)
.eq('source_type', 'currency_revaluation')
.eq('status', 'posted')
if ((revalCount ?? 0) === 0) {
// Check if there are any open foreign currency items
const { count: fxReceivables } = await supabase
.from('invoices')
.select('id', { count: 'exact', head: true })
.eq('company_id', companyId)
.in('status', ['sent', 'overdue'])
.neq('currency', 'SEK')
.not('exchange_rate', 'is', null)
const { count: fxPayables } = await supabase
.from('supplier_invoices')
.select('id', { count: 'exact', head: true })
.eq('company_id', companyId)
.in('status', ['registered', 'approved', 'overdue', 'partially_paid'])
.neq('currency', 'SEK')
.not('exchange_rate', 'is', null)
if (((fxReceivables ?? 0) + (fxPayables ?? 0)) > 0) {
warnings.push(
'Open foreign currency items exist but have not been revalued (ÅRL 4:13)'
)
}
}
// Check: continuity_verified flag from prior year-end
if (period.continuity_verified === false) {
errors.push('Opening balance continuity check failed for this period: resolve discrepancies before closing')
}
// Check: next period state. A pre-existing next period (from SIE import,
// manual creation, or a prior partial run) is fine (we'll reuse it), but
// one with opening balances already booked blocks closing because we
// can't post a second IB on top.
//
// The period name is not interpolated into the message: although the
// name is user-supplied at create time and confined to the company,
// surfacing DB-sourced strings through error paths is the kind of
// injection footgun we'd rather close at the source than rely on the UI
// to escape (text rendering and aria-label propagation differ).
const nextPeriod = await findNextPeriod(supabase, companyId, fiscalPeriodId)
if (nextPeriod) {
if (nextPeriod.opening_balance_entry_id) {
errors.push('Next fiscal period already has opening balances posted')
} else {
warnings.push('Next fiscal period already exists: opening balances will be booked into it')
}
}
return {
ready: errors.length === 0,
errors,
warnings,
draftCount: drafts,
voucherGaps,
unexplainedGaps,
sequenceMismatches,
trialBalanceBalanced,
}
}
/**
* Preview year-end closing without persisting anything.
* Shows the net result, closing account, and the journal entry lines that would be created.
*/
export async function previewYearEndClosing(
supabase: SupabaseClient,
companyId: string,
userId: string,
fiscalPeriodId: string
): Promise<YearEndPreview> {
// Get entity type to determine closing account
const { data: settings } = await supabase
.from('company_settings')
.select('entity_type')
.eq('company_id', companyId)
.single()
const entityType = settings?.entity_type ?? 'aktiebolag'
const closingAccount = entityType === 'enskild_firma' ? '2010' : '2099'
const closingAccountName =
entityType === 'enskild_firma'
? 'Eget kapital'
: 'Årets resultat'
// Get income statement for net result
const incomeStatement = await generateIncomeStatement(supabase, companyId, fiscalPeriodId)
const netResult = incomeStatement.net_result
// Get trial balance for individual account balances in class 3-8
const { rows } = await generateTrialBalance(supabase, companyId, fiscalPeriodId)
const resultAccounts = rows.filter(
(r) => r.account_class >= 3 && r.account_class <= 8
)
// Build closing lines: zero each result account
const closingLines: CreateJournalEntryLineInput[] = []
const resultAccountSummary: { account_number: string; account_name: string; amount: number }[] = []
for (const account of resultAccounts) {
const netBalance = account.closing_debit - account.closing_credit
if (Math.abs(netBalance) < ORE_TOLERANCE) continue
resultAccountSummary.push({
account_number: account.account_number,
account_name: account.account_name,
amount: netBalance,
})
// To zero this account: reverse its net balance
if (netBalance > 0) {
// Account has debit balance → credit it to zero
closingLines.push({
account_number: account.account_number,
debit_amount: 0,
credit_amount: roundOre(netBalance),
line_description: `Closing: ${account.account_name}`,
})
} else {
// Account has credit balance → debit it to zero
closingLines.push({
account_number: account.account_number,
debit_amount: roundOre(Math.abs(netBalance)),
credit_amount: 0,
line_description: `Closing: ${account.account_name}`,
})
}
}
// Final line: transfer net result to closing account (2099/2010)
// Net result = revenue - expenses + financial
// If positive (profit): credit to equity (2099/2010)
// If negative (loss): debit to equity (2099/2010)
const totalClosingDebit = closingLines.reduce((sum, l) => sum + l.debit_amount, 0)
const totalClosingCredit = closingLines.reduce((sum, l) => sum + l.credit_amount, 0)
const balancingAmount = roundOre(Math.abs(totalClosingDebit - totalClosingCredit))
if (balancingAmount > ORE_TOLERANCE) {
if (totalClosingDebit > totalClosingCredit) {
// More debits than credits → need credit on closing account
closingLines.push({
account_number: closingAccount,
debit_amount: 0,
credit_amount: balancingAmount,
line_description: `Årets resultat → ${closingAccountName}`,
})
} else {
// More credits than debits → need debit on closing account
closingLines.push({
account_number: closingAccount,
debit_amount: balancingAmount,
credit_amount: 0,
line_description: `Årets resultat → ${closingAccountName}`,
})
}
}
// Fetch fiscal period for closing date
const { data: periodData } = await supabase
.from('fiscal_periods')
.select('period_end')
.eq('id', fiscalPeriodId)
.eq('company_id', companyId)
.single()
let currencyRevaluation = null
if (periodData) {
const revalPreview = await previewCurrencyRevaluation(
supabase,
companyId,
periodData.period_end
)
if (revalPreview.items.length > 0) {
currencyRevaluation = revalPreview
}
}
return {
netResult,
closingAccount,
closingAccountName,
closingLines,
resultAccountSummary,
currencyRevaluation,
}
}
/**
* Execute year-end closing for a fiscal period.
*
* 1. Validate readiness
* 2. Run currency revaluation (FX gains/losses to 3960/7960)
* 3. Generate closing preview and check öre balance
* 4. Create closing entry (zeros class 3-8 accounts)
* 5. Set closing_entry_id on the period
* 6. Resolve next fiscal period (reuse existing or create new)
* 7. Lock the period
* 8. Close the period (irreversible, every guard must run before this)
* 9. Generate opening balances in next period
* 10. Validate IB/UB continuity
* 11. Omföra föregående års resultat (2099 → 2098) in the new period (AB only)
*/
export async function executeYearEndClosing(
supabase: SupabaseClient,
companyId: string,
userId: string,
fiscalPeriodId: string
): Promise<YearEndResult> {
// 1. Validate readiness
const validation = await validateYearEndReadiness(supabase, companyId, userId, fiscalPeriodId)
if (!validation.ready) {
throw new Error(`Year-end closing not ready: ${validation.errors.join('; ')}`)
}
// Fetch the period for dates
const { data: period } = await supabase
.from('fiscal_periods')
.select('*')
.eq('id', fiscalPeriodId)
.eq('company_id', companyId)
.single()
if (!period) {
throw new Error('Fiscal period not found')
}
// 2. Execute currency revaluation BEFORE closing entry
// Revaluation posts to 3960/7960 (class 3/7 result accounts) which
// the closing entry then zeros out.
const revaluationResult = await executeCurrencyRevaluation(
supabase,
companyId,
period.period_end,
fiscalPeriodId,
userId
)
// 3. Get closing preview (now includes revaluation effects in trial balance)
const preview = await previewYearEndClosing(supabase, companyId, userId, fiscalPeriodId)
if (preview.closingLines.length === 0) {
throw new Error('No result accounts to close: period has no activity')
}
// 3a. INVARIANT: closing entry must balance to the öre before commit.
// This guards against rounding drift in previewYearEndClosing: the DB
// balance trigger would catch it too, but we want a clear Swedish error
// surfaced to the user, not a generic Postgres exception.
const preCommitDebit = roundOre(
preview.closingLines.reduce((s, l) => s + l.debit_amount, 0)
)
const preCommitCredit = roundOre(
preview.closingLines.reduce((s, l) => s + l.credit_amount, 0)
)
if (Math.abs(preCommitDebit - preCommitCredit) > ORE_TOLERANCE) {
throw new Error(
`Bokslutsverifikationen balanserar inte: debet=${preCommitDebit}, kredit=${preCommitCredit}`
)
}
// 4. Create closing entry via the journal engine
const closingEntry = await createJournalEntry(supabase, companyId, userId, {
fiscal_period_id: fiscalPeriodId,
entry_date: period.period_end,
description: `Årsbokslut ${period.name}`,
source_type: 'year_end',
voucher_series: 'A',
lines: preview.closingLines,
})
// 4a. INVARIANT: after the closing entry, class 3-8 net must be exactly 0
// (to the öre). If not, we have a logic bug: fail loud rather than
// proceed into IB generation with a corrupt trial balance.
// createJournalEntry has no transactional grouping with the next call;
// the engine commits atomically per-entry via commit_journal_entry RPC,
// so a failure here means we need to reverse the just-committed entry.
try {
const postCloseTB = await generateTrialBalance(supabase, companyId, fiscalPeriodId)
let resultNet = 0
for (const row of postCloseTB.rows) {
if (row.account_class >= 3 && row.account_class <= 8) {
resultNet += row.closing_debit - row.closing_credit
}
}
resultNet = roundOre(resultNet)
if (Math.abs(resultNet) > ORE_TOLERANCE) {
throw new Error(
`Resultatkonton (klass 3-8) saknar nollställning efter bokslut: nettot är ${resultNet} SEK`
)
}
} catch (err) {
// Best-effort reversal of the closing entry before re-throwing.
await safeReverse(supabase, companyId, userId, closingEntry.id, 'closing entry')
throw err
}
// 5. Update fiscal period with closing_entry_id
const { error: updateError } = await supabase
.from('fiscal_periods')
.update({ closing_entry_id: closingEntry.id })
.eq('id', fiscalPeriodId)
.eq('company_id', companyId)
if (updateError) {
throw new Error(`Failed to set closing_entry_id: ${updateError.message}`)
}
// 6. Resolve the next period BEFORE locking/closing this one. A pre-existing
// next period is common (SIE import, manual creation, prior partial
// year-end run); reusing it is fine as long as no IB has been booked
// into it. Doing this check after closePeriod would leave the books in
// a half-closed state if a concurrent process posted IB into the next
// period between validateYearEndReadiness and step 8 (TOCTOU race).
//
// The thrown error is intentionally a stable English string with no
// DB-sourced data interpolated: the route layer maps it to a
// structured error code, and the next period name (if any) is surfaced
// only through the structured details payload after explicit checks.
const existingNextPeriod = await findNextPeriod(supabase, companyId, fiscalPeriodId)
let nextPeriod
if (existingNextPeriod) {
if (existingNextPeriod.opening_balance_entry_id) {
throw new Error(
'Next fiscal period already has opening balance entry posted; reverse it before re-running year-end'
)
}
nextPeriod = existingNextPeriod
} else {
nextPeriod = await createNextPeriod(supabase, companyId, userId, fiscalPeriodId)
}
// 7. Lock the period
await lockPeriod(supabase, companyId, userId, fiscalPeriodId)
// 8. Close the period: irreversible per BFL. Every guard that can fail
// on prior state must run before this point.
await closePeriod(supabase, companyId, userId, fiscalPeriodId)
// 9. Generate opening balances in next period
const openingBalanceEntry = await generateOpeningBalances(
supabase,
companyId,
userId,
fiscalPeriodId,
nextPeriod.id
)
// 10. Validate IB/UB continuity and persist result.
// INVARIANT: any account differing by more than ORE_TOLERANCE is a hard
// failure. Best-effort rollback of both the IB entry and the closing
// entry so the user sees a clean state and can re-run the wizard.
//
// Note on atomicity: createJournalEntry uses an atomic commit_journal_entry
// RPC per entry, but the closing + IB entries are two separate commits with
// a period lock/close in between. Once committed, posted entries are
// immutable by DB trigger: true rollback isn't possible. reverseEntry()
// posts a compensating storno entry instead. The closed period was also
// locked & closed, but reverseEntry uses an entry_date that (under the
// period-lock trigger) may be blocked. We attempt reversal but tolerate
// failure, surfacing the original continuity error either way.
const continuity = await validateBalanceContinuity(supabase, companyId, nextPeriod.id)
await supabase
.from('fiscal_periods')
.update({ continuity_verified: continuity.valid })
.eq('id', nextPeriod.id)
.eq('company_id', companyId)
const overTolerance = continuity.discrepancies.filter(
(d) => Math.abs(d.difference) > ORE_TOLERANCE
)
if (overTolerance.length > 0) {
await safeReverse(supabase, companyId, userId, openingBalanceEntry.id, 'opening balance entry')
await safeReverse(supabase, companyId, userId, closingEntry.id, 'closing entry')
throw new Error(
`IB/UB-kontinuitet misslyckades: ${overTolerance.length} konto(n) avviker. ` +
overTolerance
.map(
(d) =>
`${d.account_number}: UB=${d.previous_ub_net}, IB=${d.current_ib_net}, diff=${d.difference}`
)
.join('; ')
)
}
// 11. Omföra föregående års resultat: move 2099 "Årets resultat" off onto
// 2098 in the new period so it starts the year at zero (aktiebolag only).
// This is a SEPARATE verifikat by design: folding it into the IB entry
// would make the continuity check above fail, since that check reads IB
// solely from the opening_balance entry. Non-fatal: the close and IB are
// already valid and immutable; a failure here is logged and left for the
// retroactive catch-up script (scripts/repair-result-appropriation.ts).
let resultAppropriationEntry: JournalEntry | null = null
let resultAppropriationFailed = false
try {
resultAppropriationEntry = await generateResultAppropriation(
supabase,
companyId,
userId,
nextPeriod.id
)
} catch (err) {
resultAppropriationFailed = true
// alert:true marks this for out-of-band alerting (the log sink / Sentry
// integration filters on it): a silent accounting failure must not wait
// for a manual audit. The new period now opens with 2099 still carrying the
// prior result; resultAppropriationFailed below drives a UI warning and the
// catch-up script (scripts/repair-result-appropriation.ts) posts the fix.
log.error('year-end: result appropriation omföring failed (non-fatal)', err as Error, {
operation: 'year_end.result_appropriation',
alert: true,
companyId,
entityType: 'fiscal_period',
entityId: nextPeriod.id,
})
}
// Fetch the now-closed period for the event payload
const { data: closedPeriod } = await supabase
.from('fiscal_periods')
.select('*')
.eq('id', fiscalPeriodId)
.eq('company_id', companyId)
.single()
if (closedPeriod) {
await eventBus.emit({
type: 'period.year_closed',
payload: { period: closedPeriod as FiscalPeriod, companyId, userId },
})
}
return {
closingEntry,
nextPeriod,
openingBalanceEntry,
revaluationEntry: revaluationResult?.entry ?? null,
resultAppropriationEntry,
resultAppropriationFailed,
continuity,
}
}
/**
* Generate opening balance entries in the next period from the closed period's
* balance sheet accounts (class 1-2).
*
* Each account's closing balance becomes its opening balance.
* The entry must be balanced (total debit openings = total credit openings).
*/
export async function generateOpeningBalances(
supabase: SupabaseClient,
companyId: string,
userId: string,
closedPeriodId: string,
nextPeriodId: string
): Promise<JournalEntry> {
// Get next period for the entry date
const { data: nextPeriod } = await supabase
.from('fiscal_periods')
.select('*')
.eq('id', nextPeriodId)
.eq('company_id', companyId)
.single()
if (!nextPeriod) {
throw new Error('Next fiscal period not found')
}
// Get trial balance of closed period (includes the closing entry)
const { rows } = await generateTrialBalance(supabase, companyId, closedPeriodId)
// Filter to balance sheet accounts (class 1-2) with non-zero closing balance
const balanceSheetAccounts = rows.filter(
(r) => r.account_class >= 1 && r.account_class <= 2
)
const openingLines: CreateJournalEntryLineInput[] = []
for (const account of balanceSheetAccounts) {
const netBalance = account.closing_debit - account.closing_credit
if (Math.abs(netBalance) < ORE_TOLERANCE) continue
if (netBalance > 0) {
// Debit balance → opening debit
openingLines.push({
account_number: account.account_number,
debit_amount: roundOre(netBalance),
credit_amount: 0,
line_description: `Ingående balans: ${account.account_name}`,
})
} else {
// Credit balance → opening credit
openingLines.push({
account_number: account.account_number,
debit_amount: 0,
credit_amount: roundOre(Math.abs(netBalance)),
line_description: `Ingående balans: ${account.account_name}`,
})
}
}
if (openingLines.length === 0) {
throw new Error('No balance sheet accounts with non-zero closing balance')
}
// Verify balance before creating
const totalDebit = openingLines.reduce((sum, l) => sum + l.debit_amount, 0)
const totalCredit = openingLines.reduce((sum, l) => sum + l.credit_amount, 0)
if (Math.abs(totalDebit - totalCredit) > ORE_TOLERANCE) {
throw new Error(
`Ingående balanser balanserar inte: debet=${roundOre(totalDebit)}, kredit=${roundOre(totalCredit)}`
)
}
// Create opening balance entry in next period
const openingEntry = await createJournalEntry(supabase, companyId, userId, {
fiscal_period_id: nextPeriodId,
entry_date: nextPeriod.period_start,
description: `Ingående balans ${nextPeriod.name}`,
source_type: 'opening_balance',
voucher_series: 'A',
lines: openingLines,
})
// Mark next period with opening balance entry
const { error: updateError } = await supabase
.from('fiscal_periods')
.update({
opening_balance_entry_id: openingEntry.id,
opening_balances_set: true,
})
.eq('id', nextPeriodId)
.eq('company_id', companyId)
if (updateError) {
throw new Error(`Failed to set opening_balance_entry_id: ${updateError.message}`)
}
return openingEntry
}
/**
* Best-effort reversal used by executeYearEndClosing's rollback paths.
*
* Posted journal entries are immutable per DB trigger: we can't truly
* roll them back, only post a compensating storno via reverseEntry().
* Closed/locked periods may also block the reversal date. We swallow
* failures here so the caller can re-throw the original invariant error
* with maximum diagnostic value; the orphaned entries (if any) become
* a manual cleanup task documented in the surfaced Swedish error.
*/
async function safeReverse(
supabase: SupabaseClient,
companyId: string,
userId: string,
entryId: string,
label: string
): Promise<void> {
try {
await reverseEntry(supabase, companyId, userId, entryId)
} catch (err) {
log.error(`year-end rollback: could not reverse ${label}`, err as Error, {
operation: 'year_end.rollback',
companyId,
entityType: 'journal_entry',
entityId: entryId,
})
}
}