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>
226 lines
8.7 KiB
TypeScript
226 lines
8.7 KiB
TypeScript
import type { SupabaseClient } from '@supabase/supabase-js'
|
||
import { generateIncomeStatement } from '@/lib/reports/income-statement'
|
||
import { generateTrialBalance } from '@/lib/reports/trial-balance'
|
||
import { calculateBolagsskatt } from './tax-provision/bolagsskatt-calculator'
|
||
import { calculateSarskildLoneskatt } from './tax-provision/sarskild-loneskatt-calculator'
|
||
import {
|
||
computeLatentTax,
|
||
LATENT_TAX_EXPENSE_ACCOUNT,
|
||
LATENT_TAX_LIABILITY_ACCOUNT,
|
||
proposeLatentTaxChange,
|
||
} from './tax-provision/latent-tax-calculator'
|
||
import {
|
||
listExistingPeriodiseringsfonder,
|
||
proposeAvsattning,
|
||
proposeAteforing,
|
||
} from './reserves/periodiseringsfond-service'
|
||
import type { DispositionsProposal, ProposedDisposition } from './types'
|
||
import type { AccountingFramework } from '@/types'
|
||
|
||
const DEFAULT_SCHABLONINTAKT_RATE = 0.0355
|
||
|
||
/**
|
||
* 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')
|
||
.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: [],
|
||
}
|
||
}
|
||
|
||
// Look up the accounting framework: K3 (BFNAR 2012:1) triggers the
|
||
// uppskjuten-skatt provision step; K2 skips it.
|
||
const { data: companyRow } = await supabase
|
||
.from('companies')
|
||
.select('accounting_framework')
|
||
.eq('id', companyId)
|
||
.maybeSingle()
|
||
const accountingFramework: AccountingFramework =
|
||
(companyRow as { accounting_framework?: AccountingFramework } | null)?.accounting_framework
|
||
=== 'k3'
|
||
? 'k3'
|
||
: 'k2'
|
||
|
||
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 existingFonder = await listExistingPeriodiseringsfonder(supabase, companyId, period.period_end)
|
||
const ateforing = proposeAteforing(existingFonder, {
|
||
schablonintaktRate: DEFAULT_SCHABLONINTAKT_RATE,
|
||
})
|
||
proposals.push(...ateforing.proposals)
|
||
|
||
const taxableBeforeAvsattning =
|
||
resultBeforeTax +
|
||
ateforing.proposals.reduce((sum, p) => sum + p.amount, 0) +
|
||
ateforing.schablonintaktAmount
|
||
const avsattning = proposeAvsattning({
|
||
skattemassigtResultatBeforeAvsattning: taxableBeforeAvsattning,
|
||
fiscalYear,
|
||
})
|
||
if (avsattning) proposals.push(avsattning)
|
||
|
||
const slp = await calculateSarskildLoneskatt(supabase, companyId, fiscalPeriodId)
|
||
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 ateforingTotal = ateforing.proposals.reduce((sum, p) => sum + p.amount, 0)
|
||
const resultAfterDispositions =
|
||
resultBeforeTax + ateforingTotal - (avsattning?.amount ?? 0) - (slp?.amount ?? 0)
|
||
|
||
const bolagsskatt = await calculateBolagsskatt(supabase, companyId, fiscalPeriodId, {
|
||
resultBeforeTaxOverride: resultAfterDispositions,
|
||
manualAdjustments: {
|
||
schablonintaktPeriodiseringsfond: ateforing.schablonintaktAmount,
|
||
},
|
||
})
|
||
if (bolagsskatt) proposals.push(bolagsskatt)
|
||
|
||
// K3 only: split obeskattade reserver into the 79.4 % equity portion and
|
||
// the 20.6 % uppskjuten skatteskuld. We sum the projected 21xx balance
|
||
// AFTER the dispositions above have been applied so the latent-tax
|
||
// amount reflects the closing position: anything else would diverge
|
||
// from the BR the user sees in the preview.
|
||
if (accountingFramework === 'k3') {
|
||
const latentTax = await buildLatentTaxProposal({
|
||
supabase,
|
||
companyId,
|
||
fiscalPeriodId,
|
||
proposalsBeforeLatentTax: proposals,
|
||
})
|
||
if (latentTax) proposals.push(latentTax)
|
||
}
|
||
|
||
return {
|
||
entityType,
|
||
fiscalPeriod: period,
|
||
netResultBefore: resultBeforeTax,
|
||
proposals,
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Compose the K3 uppskjuten-skatt proposal.
|
||
*
|
||
* The latent tax provision must reflect the *closing* obeskattade-reserver
|
||
* balance, so we pull the current 21xx balance from the trial balance and
|
||
* adjust it for any 21xx-touching dispositions that haven't yet posted
|
||
* (avsättning ↑, återföring ↓). 2240's current balance is the existing
|
||
* provision; the delta becomes the new verifikat.
|
||
*/
|
||
export async function buildLatentTaxProposal(params: {
|
||
supabase: SupabaseClient
|
||
companyId: string
|
||
fiscalPeriodId: string
|
||
/** Optional: additional 21xx-touching dispositions that have NOT yet been
|
||
* posted but will be in the same batch. The TB already reflects everything
|
||
* posted, so leave this empty if the latent-tax run is sequenced after the
|
||
* 21xx postings (the API route's case). */
|
||
proposalsBeforeLatentTax?: ProposedDisposition[]
|
||
}): Promise<ProposedDisposition | null> {
|
||
const { supabase, companyId, fiscalPeriodId, proposalsBeforeLatentTax = [] } = params
|
||
|
||
const tb = await generateTrialBalance(supabase, companyId, fiscalPeriodId)
|
||
|
||
// 21xx: obeskattade reserver (credit-normal, so we measure credit − debit).
|
||
let untaxedReserves = tb.rows
|
||
.filter((r) => r.account_number.startsWith('21'))
|
||
.reduce((s, r) => s + (r.closing_credit - r.closing_debit), 0)
|
||
|
||
// Pending 21xx postings from the proposals that will commit alongside
|
||
// latent tax. Avsättning adds to the reserves (credit 21xx), återföring
|
||
// removes (debit 21xx).
|
||
for (const p of proposalsBeforeLatentTax) {
|
||
if (
|
||
p.kind !== 'periodiseringsfond_avsattning'
|
||
&& p.kind !== 'periodiseringsfond_ateforing'
|
||
) continue
|
||
for (const line of p.lines) {
|
||
if (!line.account_number.startsWith('21')) continue
|
||
untaxedReserves += (line.credit_amount ?? 0) - (line.debit_amount ?? 0)
|
||
}
|
||
}
|
||
|
||
// Current 2240 balance: credit-normal. Equal to existing latent tax.
|
||
const current2240 = tb.rows
|
||
.filter((r) => r.account_number === LATENT_TAX_LIABILITY_ACCOUNT)
|
||
.reduce((s, r) => s + (r.closing_credit - r.closing_debit), 0)
|
||
|
||
const split = computeLatentTax({ untaxedReserves })
|
||
const lines = proposeLatentTaxChange(current2240, split.liabilityPortion)
|
||
if (!lines) return null
|
||
|
||
const delta = Math.round((split.liabilityPortion - current2240) * 100) / 100
|
||
const amount = Math.abs(delta)
|
||
const direction = delta > 0 ? 'avsättning' : 'återföring'
|
||
|
||
return {
|
||
kind: 'uppskjuten_skatt',
|
||
label: 'Uppskjuten skatt (K3)',
|
||
description:
|
||
delta > 0
|
||
? `Avsättning till uppskjuten skatteskuld 20,6 % av obeskattade reserver. Debet ${LATENT_TAX_EXPENSE_ACCOUNT}, kredit ${LATENT_TAX_LIABILITY_ACCOUNT}.`
|
||
: `Återföring av uppskjuten skatteskuld när obeskattade reserver minskar. Debet ${LATENT_TAX_LIABILITY_ACCOUNT}, kredit ${LATENT_TAX_EXPENSE_ACCOUNT}.`,
|
||
amount,
|
||
lines,
|
||
warnings: [],
|
||
computation: {
|
||
untaxedReserves,
|
||
taxRate: 0.206,
|
||
target2240: split.liabilityPortion,
|
||
current2240,
|
||
delta,
|
||
direction,
|
||
equityPortion: split.equityPortion,
|
||
},
|
||
}
|
||
}
|