The schablonintäkt SLR table only had closing years 2025 and 2026, and the dispositions builder consulted it unconditionally, so every aktiebolag running the year-end wizard for 2024 or earlier got "Ett oväntat serverfel uppstod" at the Dispositioner step (126 open FY2024 periods on prod, plus older years), even when the company holds no periodiseringsfonder at all. - Backfill SCHABLONINTAKT_RATE_BY_CLOSING_YEAR for 2020-2024 from Riksgälden's 30 November SLR (2019: -0.09 %, 2020: -0.10 %, 2021: 0.23 %, all floored to 0.5 %; 2022: 1.94 %; 2023: 2.62 %). 2019 and earlier stay unmapped: the 100 %-of-SLR rule keys on beskattningsår starting 2019-01-01+, so a 2019 closing can be a brutet år under the old 72 %. - Resolve the rate lazily (resolveSchablonintaktRate): a company without an opening 212X balance never touches the table, so an unmapped year can no longer break a no-fond bokslut. Used by the builder and all three POST item paths; POST overrides still win. - Typed SchablonintaktRateNotConfiguredError with registry code SCHABLONINTAKT_RATE_NOT_CONFIGURED (500, Swedish message) so the rare fond-holding-company-on-unmapped-year case tells the user what is wrong instead of a generic server error, while still surfacing in runtime-error clustering for the December table update. - Tests: rate table + resolver units, new builder test (no-fond FY2024 and unmapped-year cases, SLR folded into the tax base), GET route tests. Claude-Session: https://claude.ai/code/session_01SyEZHx14jBvibkZmz8uAUC Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
234 lines
9.2 KiB
TypeScript
234 lines
9.2 KiB
TypeScript
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<DispositionsProposal> {
|
||
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,
|
||
}
|
||
}
|
||
|