import type { SupabaseClient } from '@supabase/supabase-js' import { generateIncomeStatement } from '@/lib/reports/income-statement' import { calculateBolagsskatt, getBookedBolagsskatt, sumPostedYearEndDispositions, } from './tax-provision/bolagsskatt-calculator' import { loadTaxAdjustmentSnapshot } from './tax-provision/tax-adjustment-service' import { calculateSarskildLoneskatt } from './tax-provision/sarskild-loneskatt-calculator' import { getPeriodiseringsfondCohortAccount, listExistingPeriodiseringsfonder, proposeAvsattning, proposeAteforing, resolveSchablonintaktRate, } from './reserves/periodiseringsfond-service' import { calculateOveravskrivningar } from './reserves/overavskrivningar-calculator' import type { CompletedDisposition, DispositionsProposal, ProposedDisposition } from './types' /** * Shared core of the GET /bokslutsdispositioner endpoint, lifted out so the * MCP tool can call the same builder without duplicating the proposal logic. * The API route and the MCP tool both hand its output to the caller, who * picks which proposals to commit via the POST endpoint. */ export async function buildDispositionsProposal( supabase: SupabaseClient, companyId: string, fiscalPeriodId: string, ): Promise { const { data: period, error: periodError } = await supabase .from('fiscal_periods') .select('id, name, period_start, period_end, opening_balance_entry_id') .eq('id', fiscalPeriodId) .eq('company_id', companyId) .single() if (periodError || !period) { throw new Error('Fiscal period not found') } const { data: settings } = await supabase .from('company_settings') .select('entity_type') .eq('company_id', companyId) .maybeSingle() const entityType = (settings?.entity_type ?? 'aktiebolag') as DispositionsProposal['entityType'] if (entityType !== 'aktiebolag') { // Non-AB entities (enskild firma, handelsbolag, etc.) do not produce // bookable bokslutsdispositioner: bolagsskatt, periodiseringsfond and // SLP are AB-only mechanisms. EF tax mechanisms (egenavgifter, // räntefördelning, periodiseringsfond-EF, expansionsfond) are // declaration-only and surface through the dedicated // /api/bookkeeping/fiscal-periods/[id]/ef-declaration endpoint and the // EfDeclarationSection in the wizard: they never produce journal // entries, so they have no place in this list. const incomeStatement = await generateIncomeStatement(supabase, companyId, fiscalPeriodId) return { entityType, fiscalPeriod: period, netResultBefore: incomeStatement.net_result, proposals: [], } } const fiscalYear = parseInt(period.period_end.slice(0, 4), 10) const incomeStatement = await generateIncomeStatement(supabase, companyId, fiscalPeriodId) const resultBeforeTax = incomeStatement.net_result const proposals: ProposedDisposition[] = [] const completedDispositions: CompletedDisposition[] = [] const warnings: string[] = [] // Dispositions already POSTED in this period (a partially completed // bokslut run) are excluded from resultBeforeTax like all year_end // entries, but they do affect the taxable base: their signed P&L effect // is folded into every base below so a re-visit previews the same // amounts the commit path books. const postedEffect = await sumPostedYearEndDispositions( supabase, companyId, fiscalPeriodId, ) const [taxAdjustments, bookedTax] = await Promise.all([ loadTaxAdjustmentSnapshot(supabase, companyId, fiscalPeriodId), getBookedBolagsskatt(supabase, companyId, fiscalPeriodId), ]) // Income statement excludes tax posted by this year-end flow, but includes // manually posted 8910. Add back only the latter to get a stable pre-tax // result on reload. const manuallyBookedTax = Math.max(0, bookedTax - postedEffect.taxProvisionPortion) const normalizedResultBeforeTax = resultBeforeTax + manuallyBookedTax const existingFonder = await listExistingPeriodiseringsfonder( supabase, companyId, period.period_end, period.period_start, period.opening_balance_entry_id, ) // Rate resolves lazily: a company without opening fonder never touches // the SLR table, so an unmapped year cannot break its bokslut. const ateforing = proposeAteforing(existingFonder, { schablonintaktRate: resolveSchablonintaktRate(fiscalYear, existingFonder), }) proposals.push(...ateforing.proposals) const ateforingTotal = ateforing.proposals.reduce((sum, p) => sum + p.amount, 0) const overavskrivningar = await calculateOveravskrivningar({ supabase, companyId, fiscalPeriod: period, entityType, }) if (overavskrivningar.warning) warnings.push(overavskrivningar.warning) if (overavskrivningar.proposal) proposals.push(overavskrivningar.proposal) if ( !overavskrivningar.proposal && overavskrivningar.status === 'ready' && Math.abs(overavskrivningar.currentPeriodChange) >= 0.01 ) { completedDispositions.push({ kind: 'overavskrivningar', label: 'Förändring av överavskrivningar', amount: Math.abs(overavskrivningar.currentPeriodChange), status: 'booked', warnings: [], }) } const overavskrivningarResultEffect = -( overavskrivningar.proposal?.signedAmount ?? 0 ) // SLP already posted in this period (resumed run): don't re-propose it // (that would book it twice) and don't subtract it twice below (its // effect is already inside postedEffect.total). const slp = postedEffect.slpPortion !== 0 ? null : await calculateSarskildLoneskatt(supabase, companyId, fiscalPeriodId) // An avsättning already booked in this bokslut eats into the 25 % cap; // without this, revisiting the page after committing re-proposes the full // avsättning and lets the user book it twice. Measured as the current // cohort ACCOUNT's growth during the period (closing minus opening), so a // prior-year fond that happens to share the account (shortened brutet // räkenskapsår, decade wrap) does not consume this year's headroom. const currentCohort = existingFonder.find( (f) => f.account_number === getPeriodiseringsfondCohortAccount(fiscalYear), ) const alreadyProvisioned = currentCohort ? Math.max(0, currentCohort.balance - Math.max(0, currentCohort.opening_balance)) : 0 // Cap base = skattemässigt resultat före avsättning: ledger result plus // posted dispositions (with any posted avsättning added back: its // headroom effect is alreadyProvisioned, not a base reduction), plus // proposed återföringar and schablonintäkt, minus deductible SLP. const taxableBeforeAvsattning = normalizedResultBeforeTax + postedEffect.total + alreadyProvisioned + ateforingTotal + overavskrivningarResultEffect + ateforing.schablonintaktAmount - (slp?.amount ?? 0) + taxAdjustments.nonDeductibleExpenses - taxAdjustments.nonTaxableIncome const avsattning = alreadyProvisioned > 0 ? null : proposeAvsattning({ skattemassigtResultatBeforeAvsattning: taxableBeforeAvsattning, fiscalYear, }) if (avsattning) proposals.push(avsattning) if (alreadyProvisioned > 0) { completedDispositions.push({ kind: 'periodiseringsfond_avsattning', label: 'Avsättning till periodiseringsfond', amount: alreadyProvisioned, status: 'booked', warnings: [], }) } if (slp) proposals.push(slp) // Bolagsskatt must be computed on the result AFTER the dispositions above. // In preview mode nothing is posted yet, so the income statement still shows // the pre-disposition result: we mirror each proposal's effect on resultat // före skatt and hand the post-disposition base to the calculator: // + återföring (8819, intäkt) // − avsättning (8811, kostnad) // − SLP (7533, kostnad) // Without this, the previewed tax ignores the avsättning (tax too high) and // diverges from what the sequential commit books and from ÅR/INK2. const resultAfterDispositions = normalizedResultBeforeTax + postedEffect.total + ateforingTotal + overavskrivningarResultEffect - (avsattning?.amount ?? 0) - (slp?.amount ?? 0) const bolagsskatt = await calculateBolagsskatt(supabase, companyId, fiscalPeriodId, { resultBeforeTaxOverride: resultAfterDispositions, manualAdjustments: { nonDeductibleExpenses: taxAdjustments.nonDeductibleExpenses, nonTaxableIncome: taxAdjustments.nonTaxableIncome, schablonintaktPeriodiseringsfond: ateforing.schablonintaktAmount, }, }) if (bookedTax > 0) { const expectedTax = bolagsskatt?.amount ?? 0 const matches = bookedTax === expectedTax completedDispositions.push({ kind: 'bolagsskatt', label: 'Bolagsskatt 20,6 %', amount: bookedTax, status: matches ? 'booked' : 'needs_correction', warnings: matches ? [] : [ `Bokförd skatt är ${bookedTax} kr, men aktuellt underlag ger ${expectedTax} kr. Rätta den bokförda skatten innan bokslutet verkställs.`, ], }) } else if (bolagsskatt && bolagsskatt.amount > 0) { proposals.push(bolagsskatt) } return { entityType, fiscalPeriod: period, netResultBefore: normalizedResultBeforeTax, proposals, taxAdjustments, completedDispositions, warnings, } }