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 { fetchAllRows } from '@/lib/supabase/fetch-all' import { fetchPaymentsAsOf, outstandingAsOf, todayIsoDate, type PaymentsAsOf, } from '@/lib/reports/reskontra-payments' import { lockPeriod, closePeriod, countUnbookedInPeriod, 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 { 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() // The error/warning strings below are Swedish: they render verbatim in the // bokslut wizard (a "stays Swedish" surface per .claude/rules/i18n.md). // The MCP year_end_readiness tool classifies them by regex; keep // extensions/general/mcp-server/server.ts in sync when changing wording. if (fetchError || !period) { return { ready: false, errors: ['Räkenskapsperioden hittades inte'], 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('Perioden kan inte stängas: slutdatumet har inte passerat ännu') } // Check: period not already closed if (period.is_closed) { errors.push('Perioden är redan stängd') } // Check: closing entry doesn't already exist if (period.closing_entry_id) { errors.push('Bokslutsverifikation finns redan för perioden') } // 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} utkast måste bokföras eller raderas innan bokslut`) } // Check: voucher continuity across all series const 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) const 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( `Verifikationsnummerglapp i serie ${gap.series} (${gap.gap_start}-${gap.gap_end}): dokumenterat` ) } else { unexplainedGaps.push(gap) errors.push( `Oförklarat verifikationsnummerglapp i serie ${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( `Nummerserien i serie ${row.voucher_series} stämmer inte: räknaren står på ${sequenceCounter} men högsta verifikationsnummer är ${actualMax}` ) } else { warnings.push( `Nummerräknaren ligger före bokförda verifikationer i serie ${row.voucher_series}: räknare=${sequenceCounter}, högsta verifikationsnummer=${actualMax}` ) } } } } // Check: trial balance is balanced const trialBalance = await generateTrialBalance(supabase, companyId, fiscalPeriodId, { closingEntry: 'include' }) const trialBalanceBalanced = trialBalance.isBalanced if (!trialBalanceBalanced) { errors.push( `Råbalansen balanserar inte: debet=${trialBalance.totalDebit}, kredit=${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('Inga bokförda verifikationer i perioden') } // Check: foreign-currency items open on balansdagen (ÅRL 4 kap. 13 §). // // Only a revaluation dated ON balansdagen discharges the duty. The previous // gate accepted any currency_revaluation verifikat anywhere in the period, // so one interim run (a June month-end revaluation) silenced the check for // every item still outstanding on 31 December: "we already did one, so stop // looking". An interim revaluation says nothing about the balansdagen value. const { count: balansdagenRevalCount } = 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') .eq('entry_date', period.period_end) // Both states below are warnings, never blockers: nothing in the bokslut // rules makes an unvalued FX item a bar to closing, and executeYearEndClosing // runs the revaluation itself in step 2. Escalating would block a close that // the very next step performs. try { const openFx = await countOpenFxItemsAtBalansdagen(supabase, companyId, period.period_end) // Loudest case first. A row with no exchange_rate is invisible to the // revaluation: previewCurrencyRevaluation partitions it into // `unconvertedFx` and drops it, so re-running the year-end will never // value it. It needs a rate on the invoice, and these rows are typically // the largest unmeasured exposure precisely because nothing warns on them. // The old query's `.not('exchange_rate','is',null)`, inherited from the // revaluation code, excluded exactly this case: a company whose only open // FX items were unconverted got no warning at all. if (openFx.unconverted > 0) { warnings.push( `${openFx.unconverted} post(er) i utländsk valuta var öppna på balansdagen ${period.period_end} men saknar valutakurs: de kan inte värderas till balansdagskurs (ÅRL 4 kap. 13 §) och utelämnas ur omvärderingen. Registrera kursen på fakturan.` ) } // Separate state, separate remedy: these can be valued, they just have not // been yet. Suppressed only by a revaluation dated on balansdagen. if (openFx.revaluable > 0 && (balansdagenRevalCount ?? 0) === 0) { warnings.push( `${openFx.revaluable} post(er) i utländsk valuta var öppna på balansdagen ${period.period_end} och har inte omvärderats till balansdagskurs (ÅRL 4 kap. 13 §)` ) } } catch (err) { // A failed lookup must not read as "no FX exposure". Say the check did not // run rather than let silence pass for a clean bill of health. log.error('year-end: could not measure open FX items at balansdagen', err as Error, { operation: 'year_end.fx_readiness', companyId, entityType: 'fiscal_period', entityId: fiscalPeriodId, }) warnings.push( 'Kontrollen av öppna poster i utländsk valuta (ÅRL 4 kap. 13 §) kunde inte genomföras: stäm av dem manuellt innan bokslut' ) } // Check: continuity_verified flag from prior year-end if (period.continuity_verified === false) { errors.push('IB/UB-kontinuiteten stämmer inte för perioden: åtgärda avvikelserna innan bokslut') } // 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('Nästa räkenskapsperiod har redan ingående balanser bokförda') } else { warnings.push('Nästa räkenskapsperiod finns redan: ingående balanser bokförs i den') } } // Check: unbooked bank transactions in the period. lockPeriod enforces this // at step 7 of executeYearEndClosing, AFTER the closing entry has already // posted at step 4: without this readiness check a period with unbooked // transactions reported ready: true and then aborted mid-flow, leaving a // posted closing entry on an unlocked, unclosed period. Same counter as the // lock guard (countUnbookedInPeriod), so the number reconciles with the // "att bokföra" badge. Fails CLOSED like lockPeriod: a check that could not // run must not pass. let unbookedTransactionCount = 0 try { const unbooked = await countUnbookedInPeriod( supabase, companyId, period.period_start, period.period_end, ) unbookedTransactionCount = unbooked.untriaged + unbooked.businessUnbooked if (unbookedTransactionCount > 0) { errors.push( `${unbookedTransactionCount} transaktioner i perioden saknar bokföring: bokför dem eller markera dem som privata innan bokslut`, ) } } catch (err) { log.warn('unbooked-transaction readiness check failed', err as Error) errors.push( 'Kontrollen av obokförda transaktioner kunde inte genomföras: försök igen', ) } return { ready: errors.length === 0, errors, warnings, draftCount: drafts, voucherGaps, unexplainedGaps, sequenceMismatches, trialBalanceBalanced, unbookedTransactionCount, } } /** * 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 { // 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 trial balance for individual account balances in class 3-8 const { rows } = await generateTrialBalance(supabase, companyId, fiscalPeriodId, { closingEntry: 'include' }) 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) // netResult must equal, by construction, the signed amount transferred to the // closing account (2099/2010) by the balancing line below: positive = credit // = vinst, negative = debit = forlust. It is deliberately NOT taken from // generateIncomeStatement: that report excludes source_type='year_end' // entries, and bokslut-flow entries (annual depreciation, dispositioner) // carry that tag, so the income-statement figure misses them and the // summary card would mismatch the bokslutsverifikation table (issue #766). const netResult = roundOre(totalClosingDebit - totalClosingCredit) 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 } } // Advisory check: an AB closing a profit year should normally have booked // bolagsskatt (Dr 8910 / Cr 2512) in the dispositions step. If no 89xx tax // account is among the accounts being closed, the profit is untaxed. This // is a warning, not a blocker: zero tax is legitimate when underskotts- // avdrag zeroes the taxable result. 8999 is excluded: it is the manual // result-closing account, not a tax account. // Scanning resultAccountSummary is equivalent to a full 89xx trial-balance // scan: it is built from every class 3-8 account with a non-zero closing // balance, regardless of voucher series, so a booked tax entry cannot be // missed by this check. const hasTaxAccount = resultAccountSummary.some( (a) => a.account_number.startsWith('89') && a.account_number !== '8999' ) const bolagsskattMissing = closingAccount === '2099' && netResult > ORE_TOLERANCE && !hasTaxAccount return { netResult, closingAccount, closingAccountName, closingLines, resultAccountSummary, currencyRevaluation, bolagsskattMissing, } } /** * 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 { // 1. Validate readiness const validation = await validateYearEndReadiness(supabase, companyId, userId, fiscalPeriodId) if (!validation.ready) { throw new Error(`Bokslutet kan inte verkställas: ${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, { closingEntry: 'include' }) 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 routes this to the observability sink (lib/observability) in // addition to the log line: a silent accounting failure must not wait for // a manual audit. The sink is a no-op until a provider is configured, so // today this reaches the JSON log only. 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 { // 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, { closingEntry: 'include' }) // 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 } /** * Open foreign-currency items as they stood on balansdagen, split by whether * the ÅRL 4 kap. 13 § valuation can reach them at all. The two states have * different remedies, so they are counted apart rather than summed. */ interface OpenFxAtBalansdagen { /** Carries a usable exchange_rate: revaluable to the balansdagen rate. */ revaluable: number /** * No exchange_rate on file, so there is no original SEK value to revalue * from. Counted rather than dropped, the same contract `unconverted_fx_count` * carries on the reskontra reports (lib/reports/supplier-ledger.ts): an * excluded row has to show up as a number somewhere. */ unconverted: number } /** The subset of an invoice row this check reads. */ interface FxLedgerRow { id: string status: string | null currency: string | null exchange_rate: number | string | null total: number | string | null paid_amount: number | string | null remaining_amount: number | string | null paid_at: string | null } /** A stored rate is usable only when present and strictly positive. */ function hasUsableFxRate(rate: number | string | null | undefined): boolean { return rate != null && Number(rate) > 0 } /** * Count the foreign-currency receivables and payables that were still open on * balansdagen. * * Measured AS OF balansdagen, not as of now. ÅRL 4 kap. 13 § values monetary * items at the balance-sheet date, so an invoice settled in March of the * following year was still an open FX item on 31 December and still had to be * valued there; the live `status` column only says what is open today. For a * historical balansdagen the population is therefore widened to invoices dated * on or before it (including ones settled since) and the outstanding amount is * recomputed from the payment history, the same reconstruction the reskontra * reports use (lib/reports/ar-ledger.ts, lib/reports/supplier-ledger.ts). For * today or a future balansdagen the stored open-invoice state IS the as-of * state, so no reconstruction is attempted. * * Limits, inherited from `outstandingAsOf` and identical to what the reskontra * reports already accept: an invoice with no payment rows and no `paid_at` * cannot be dated, so its live outstanding is assumed to have stood at * balansdagen; and an invoice cancelled or credited since is treated as never * having been open, because neither event carries a reliable date. The status * population, date scoping and outstanding reconstruction are kept identical * to what the revaluation engine acts on (`getOpenForeignCurrencyReceivables` * / `...Payables` with the same as-of date). * * Deliberately NOT gated by `fxExposureScope`, unlike the revaluation itself. * An unbooked FX row is exactly the case worth warning about: it is not on * 1510/2440 yet, so the revaluation skips it, but POST /api/invoices/[id]/book * still books it at its own invoice_date, i.e. into the year about to close. * Once step 7/8 lock and close the period that remedy is gone for good, so * suppressing the warning here would convert a recoverable state into a * permanent misstatement. */ async function countOpenFxItemsAtBalansdagen( supabase: SupabaseClient, companyId: string, balansdagen: string ): Promise { const isHistorical = balansdagen < todayIsoDate() const columns = 'id, status, currency, exchange_rate, total, paid_amount, remaining_amount, paid_at' const receivables = await fetchAllRows( ({ from, to }) => { const base = supabase .from('invoices') .select(columns) .eq('company_id', companyId) .neq('currency', 'SEK') // 'partially_paid' is a live status for customer invoices (payment-sync // sets it on partial settlements); the unpaid remainder was open on // balansdagen just like a 'sent' invoice. Kept in lockstep with // getOpenForeignCurrencyReceivables so this warning never points at // rows the revaluation engine cannot see. const scoped = isHistorical ? base .in('status', ['sent', 'overdue', 'partially_paid', 'paid']) .lte('invoice_date', balansdagen) : base.in('status', ['sent', 'overdue', 'partially_paid']) // Stable total order for correct paging (see fetch-all.ts). return scoped.order('id', { ascending: true }).range(from, to) }, { dedupeBy: (r) => r.id } ) const payables = await fetchAllRows( ({ from, to }) => { const base = supabase .from('supplier_invoices') .select(columns) .eq('company_id', companyId) .neq('currency', 'SEK') const scoped = isHistorical ? base .in('status', ['registered', 'approved', 'overdue', 'partially_paid', 'paid']) .lte('invoice_date', balansdagen) : base.in('status', ['registered', 'approved', 'overdue', 'partially_paid']) return scoped.order('id', { ascending: true }).range(from, to) }, { dedupeBy: (r) => r.id } ) // Payment history is only needed to walk a since-settled invoice back to what // it owed on balansdagen, and only when there is something to walk back. const receivablePayments = isHistorical && receivables.length > 0 ? await fetchPaymentsAsOf(supabase, 'invoice_payments', 'invoice_id', companyId, balansdagen) : null const payablePayments = isHistorical && payables.length > 0 ? await fetchPaymentsAsOf( supabase, 'supplier_invoice_payments', 'supplier_invoice_id', companyId, balansdagen ) : null const counts: OpenFxAtBalansdagen = { revaluable: 0, unconverted: 0 } function tally( rows: FxLedgerRow[], payments: PaymentsAsOf | null, liveOutstanding: (row: FxLedgerRow) => number ): void { for (const row of rows) { const total = Number(row.total) || 0 const live = liveOutstanding(row) const outstanding = payments ? outstandingAsOf(row, total, live, payments, balansdagen) : live // Settled on or before balansdagen: nothing was open to value. if (outstanding <= 0) continue if (hasUsableFxRate(row.exchange_rate)) counts.revaluable += 1 else counts.unconverted += 1 } } // Each side uses its own reskontra's definition of outstanding so these // counts reconcile with what the user sees there: kundreskontran derives it // from paid_amount, leverantörsreskontran reads the maintained // remaining_amount. tally(receivables, receivablePayments, (r) => roundOre((Number(r.total) || 0) - (Number(r.paid_amount) || 0)) ) tally(payables, payablePayments, (r) => Number(r.remaining_amount) || 0) return counts } /** * 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 { 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, }) } }