Files
accounted/lib/bokslut/ixbrl/k2-mapper.ts
T
40ce34b984 fix(reports): route 77xx nedskrivningar to the anläggningstillgångar RR line (#1644)
* fix(reports): route 77xx nedskrivningar to the anläggningstillgångar RR line per BAS kopplingstabell

The whole 7700-7799 block was mapped to "Nedskrivningar av
omsättningstillgångar utöver normala nedskrivningar" in both the K2
årsredovisning mapper (preview + filed iXBRL) and the INK2R engine.
Per the official BAS kopplingstabell (INK2R 3.9/3.10), only 774x and
779x belong there; 7700-7739 and 7750-7789 (nedskrivningar of
anläggningstillgångar and their återföringar) belong on "Av- och
nedskrivningar av materiella och immateriella anläggningstillgångar"
together with 78xx. Totals were unaffected; the line split was wrong
for four BAS account groups.

Reported via gnubok_feedback 2026-07-07 (K2 side). The stale
swedish-sru-filing reference row carried the same error and is
corrected to match.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(skills): regenerate atom-body seed for the corrected sru-codes reference

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 22:07:53 +02:00

976 lines
36 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* BAS trial balance → K2 risbs concept amounts.
*
* Maps account-level closing balances (current + previous fiscal year) onto
* the K2 AB `risbs` uppställningsform (full kostnadsslagsindelad RR + full
* BR). Account ranges follow BAS 2025/2026 as shipped in
* lib/bookkeeping/bas-data/ and are cross-checked against the INK2R mappings
* in lib/reports/ink2/ink2-engine.ts (same ÅRL structure, coarser posts).
*
* Sign conventions: every produced amount is oriented to the concept's
* natural balance: credit-balance concepts are positive when the underlying
* accounts carry a net credit; debit concepts positive on net debit. The
* document layer adds presentational minuses for cost rows and `sign="-"`
* for genuinely deviating values (TA §2.10.6).
*/
import type { ConceptAmount, ConceptAmounts } from './types'
import { equalOre, roundOre, sumOre } from '@/lib/money'
import {
SIGN_RECLASSIFICATION_RULES,
type SignReclassificationId,
type SignReclassificationRule,
} from '@/lib/reports/sign-reclassification'
export interface TrialBalanceRowLike {
account_number: string
account_name: string
closing_debit: number
closing_credit: number
}
/**
* Per-year trial balance pair. The year-end closing entry (source_type
* 'year_end') zeroes every class 3-8 account into 2099, so a single TB can
* never serve both statements:
* - `full` (including the closing entry) carries the booked 2099 and the
* correct equity: it drives the BR concepts.
* - `preClosing` (generateTrialBalance with excludeFinalClosingEntry: true)
* still has the RR accounts open: it drives the RR concepts.
* Mirrors how lib/reports' generateIncomeStatement/generateBalanceSheet split
* the same source.
*/
export interface TrialBalancePair {
full: TrialBalanceRowLike[]
preClosing: TrialBalanceRowLike[]
}
interface Range {
start: string
end: string
}
interface PostMapping {
concept: string
/** Orientation of the produced amount. */
balance: 'debit' | 'credit'
ranges: Range[]
}
const r = (start: string, end: string): Range => ({ start, end })
/** RR: kostnadsslagsindelad (risbs), in uppställningsform order. */
export const K2_RR_MAPPINGS: PostMapping[] = [
{ concept: 'Nettoomsattning', balance: 'credit', ranges: [r('3000', '3799')] },
{
concept: 'ForandringLagerProdukterIArbeteFardigaVarorPagaendeArbetenAnnansRakning',
balance: 'credit',
// Lagerförändring for own production + pågående arbeten. Changes in
// råvarulager (4910-4929) belong to RavarorFornodenheterKostnader and
// handelsvaror (4960-4969) to HandelsvarorKostnader per K2 RR.
ranges: [r('4930', '4959'), r('4970', '4999')],
},
{ concept: 'AktiveratArbeteEgenRakning', balance: 'credit', ranges: [r('3800', '3899')] },
{ concept: 'OvrigaRorelseintakter', balance: 'credit', ranges: [r('3900', '3999')] },
{
concept: 'RavarorFornodenheterKostnader',
balance: 'debit',
ranges: [r('4000', '4599'), r('4700', '4899'), r('4910', '4929')],
},
{
concept: 'HandelsvarorKostnader',
balance: 'debit',
ranges: [r('4600', '4699'), r('4960', '4969')],
},
{ concept: 'OvrigaExternaKostnader', balance: 'debit', ranges: [r('5000', '6999')] },
{ concept: 'Personalkostnader', balance: 'debit', ranges: [r('7000', '7699')] },
{
concept: 'AvskrivningarNedskrivningarMateriellaImmateriellaAnlaggningstillgangar',
balance: 'debit',
// 7710-7733 are nedskrivningar of anläggningstillgångar and 7760-7789
// their återföringar (credit-normal, netted within the line); only
// 774x/779x concern omsättningstillgångar. Intervals follow the official
// BAS kopplingstabell for INK2R 3.9/3.10 (fältkod 7515: 7700-7739,
// 7750-7789, 7800-7899; fältkod 7516: 774x, 779x).
ranges: [r('7700', '7739'), r('7750', '7789'), r('7800', '7899')],
},
{
concept: 'NedskrivningarOmsattningstillgangarUtoverNormalaNedskrivningar',
balance: 'debit',
ranges: [r('7740', '7749'), r('7790', '7799')],
},
{ concept: 'OvrigaRorelsekostnader', balance: 'debit', ranges: [r('7900', '7999')] },
{ concept: 'ResultatAndelarKoncernforetag', balance: 'credit', ranges: [r('8000', '8099')] },
{
concept: 'ResultatAndelarIntresseforetagGemensamtStyrda',
balance: 'credit',
ranges: [r('8100', '8199')],
},
{
concept: 'ResultatOvrigaforetagAgarintresse',
balance: 'credit',
ranges: [r('8200', '8269')],
},
{
concept: 'ResultatOvrigaFinansiellaAnlaggningstillgangar',
balance: 'credit',
ranges: [r('8270', '8299')],
},
{
concept: 'OvrigaRanteintakterLiknandeResultatposter',
balance: 'credit',
ranges: [r('8300', '8399')],
},
{
concept: 'NedskrivningarFinansiellaAnlaggningstillgangarKortfristigaPlaceringar',
balance: 'debit',
ranges: [r('8500', '8599')],
},
{
concept: 'RantekostnaderLiknandeResultatposter',
balance: 'debit',
ranges: [r('8400', '8499')],
},
{ concept: 'ErhallnaKoncernbidrag', balance: 'credit', ranges: [r('8820', '8829')] },
{ concept: 'LamnadeKoncernbidrag', balance: 'debit', ranges: [r('8830', '8839')] },
{ concept: 'ForandringPeriodiseringsfond', balance: 'credit', ranges: [r('8810', '8819')] },
{ concept: 'ForandringOveravskrivningar', balance: 'credit', ranges: [r('8850', '8859')] },
{
concept: 'OvrigaBokslutsdispositioner',
balance: 'credit',
ranges: [r('8840', '8849'), r('8860', '8899')],
},
{ concept: 'SkattAretsResultat', balance: 'debit', ranges: [r('8900', '8949')] },
{ concept: 'OvrigaSkatter', balance: 'debit', ranges: [r('8950', '8989')] },
]
/** BR: full balansräkning (risbs), in uppställningsform order. */
export const K2_BR_MAPPINGS: PostMapping[] = [
{ concept: 'TecknatEjInbetaltKapital', balance: 'debit', ranges: [r('1690', '1699')] },
// Immateriella anläggningstillgångar
{
concept: 'KoncessionerPatentLicenserVarumarkenLiknandeRattigheter',
balance: 'debit',
ranges: [r('1000', '1059'), r('1090', '1099')],
},
{ concept: 'HyresratterLiknandeRattigheter', balance: 'debit', ranges: [r('1060', '1069')] },
{ concept: 'Goodwill', balance: 'debit', ranges: [r('1070', '1079')] },
{
concept: 'ForskottImmateriellaAnlaggningstillgangar',
balance: 'debit',
ranges: [r('1080', '1089')],
},
// Materiella anläggningstillgångar
{
concept: 'ByggnaderMark',
balance: 'debit',
ranges: [r('1100', '1119'), r('1130', '1179'), r('1190', '1199')],
},
{
concept: 'MaskinerAndraTekniskaAnlaggningar',
balance: 'debit',
ranges: [r('1210', '1219')],
},
{
concept: 'InventarierVerktygInstallationer',
balance: 'debit',
ranges: [r('1220', '1279')],
},
{
concept: 'ForbattringsutgifterAnnansFastighet',
balance: 'debit',
ranges: [r('1120', '1129')],
},
{
concept: 'OvrigaMateriellaAnlaggningstillgangar',
balance: 'debit',
ranges: [r('1290', '1299')],
},
{
concept: 'PagaendeNyanlaggningarForskottMateriellaAnlaggningstillgangar',
balance: 'debit',
ranges: [r('1180', '1189'), r('1280', '1289')],
},
// Finansiella anläggningstillgångar
{ concept: 'AndelarKoncernforetag', balance: 'debit', ranges: [r('1310', '1319')] },
{
concept: 'FordringarKoncernforetagLangfristiga',
balance: 'debit',
ranges: [r('1320', '1329')],
},
{
concept: 'AndelarIntresseforetagGemensamtStyrdaForetag',
balance: 'debit',
ranges: [r('1330', '1335'), r('1338', '1339')],
},
{
concept: 'FordringarIntresseforetagGemensamtStyrdaForetagLangfristiga',
balance: 'debit',
ranges: [r('1340', '1345'), r('1348', '1349')],
},
{ concept: 'AgarintressenOvrigaForetag', balance: 'debit', ranges: [r('1336', '1337')] },
{
concept: 'FordringarOvrigaForetagAgarintresseLangfristiga',
balance: 'debit',
ranges: [r('1346', '1347')],
},
{
concept: 'AndraLangfristigaVardepappersinnehav',
balance: 'debit',
ranges: [r('1350', '1359'), r('1380', '1389')],
},
{ concept: 'LanDelagareNarstaende', balance: 'debit', ranges: [r('1360', '1369')] },
{
concept: 'AndraLangfristigaFordringar',
balance: 'debit',
ranges: [r('1370', '1379'), r('1390', '1399')],
},
// Varulager m.m.
{ concept: 'LagerRavarorFornodenheter', balance: 'debit', ranges: [r('1400', '1439')] },
{ concept: 'LagerVarorUnderTillverkning', balance: 'debit', ranges: [r('1440', '1449')] },
{ concept: 'LagerFardigaVarorHandelsvaror', balance: 'debit', ranges: [r('1450', '1469')] },
{
concept: 'PagaendeArbetenAnnansRakningOmsattningstillgangar',
balance: 'debit',
ranges: [r('1470', '1479')],
},
{ concept: 'ForskottTillLeverantorer', balance: 'debit', ranges: [r('1480', '1489')] },
{ concept: 'OvrigaLagertillgangar', balance: 'debit', ranges: [r('1490', '1499')] },
// Kortfristiga fordringar
{
concept: 'Kundfordringar',
balance: 'debit',
ranges: [r('1500', '1559'), r('1590', '1599')],
},
{
concept: 'FordringarKoncernforetagKortfristiga',
balance: 'debit',
ranges: [r('1560', '1569'), r('1660', '1669')],
},
{
concept: 'FordringarIntresseforetagGemensamtStyrdaForetagKortfristiga',
balance: 'debit',
ranges: [r('1570', '1572'), r('1670', '1672')],
},
{
concept: 'FordringarOvrigaforetagAgarintresseKortfristiga',
balance: 'debit',
ranges: [r('1573', '1579'), r('1673', '1679')],
},
{
concept: 'OvrigaFordringarKortfristiga',
balance: 'debit',
ranges: [r('1580', '1589'), r('1600', '1619'), r('1630', '1659'), r('1680', '1689')],
},
{ concept: 'UpparbetadEjFaktureradIntakt', balance: 'debit', ranges: [r('1620', '1629')] },
{
concept: 'ForutbetaldaKostnaderUpplupnaIntakter',
balance: 'debit',
ranges: [r('1700', '1799')],
},
// Kortfristiga placeringar
{
concept: 'AndelarKoncernforetagKortfristiga',
balance: 'debit',
ranges: [r('1860', '1869')],
},
{
concept: 'OvrigaKortfristigaPlaceringar',
balance: 'debit',
ranges: [r('1800', '1859'), r('1870', '1899')],
},
// Kassa och bank
{ concept: 'KassaBankExklRedovisningsmedel', balance: 'debit', ranges: [r('1900', '1989')] },
{ concept: 'Redovisningsmedel', balance: 'debit', ranges: [r('1990', '1999')] },
// Eget kapital
{ concept: 'Aktiekapital', balance: 'credit', ranges: [r('2080', '2081')] },
{ concept: 'EjRegistreratAktiekapital', balance: 'credit', ranges: [r('2082', '2082')] },
{ concept: 'OverkursfondBunden', balance: 'credit', ranges: [r('2087', '2087')] },
{ concept: 'Uppskrivningsfond', balance: 'credit', ranges: [r('2085', '2085')] },
// 2083/2084 (medlems-/förlagsinsatser) and 2088/2089 (övriga bundna fonder)
// lack own risbs posts for AB: closest bundet-EK post is Reservfond; the
// mapper flags them for review when present.
{
concept: 'Reservfond',
balance: 'credit',
ranges: [r('2083', '2084'), r('2086', '2086'), r('2088', '2089')],
},
{ concept: 'Overkursfond', balance: 'credit', ranges: [r('2097', '2097')] },
{
concept: 'BalanseratResultat',
balance: 'credit',
ranges: [r('2090', '2096'), r('2098', '2098')],
},
{ concept: 'AretsResultatEgetKapital', balance: 'credit', ranges: [r('2099', '2099')] },
// Obeskattade reserver
{ concept: 'Periodiseringsfonder', balance: 'credit', ranges: [r('2100', '2129')] },
{ concept: 'AckumuleradeOveravskrivningar', balance: 'credit', ranges: [r('2150', '2159')] },
{
concept: 'OvrigaObeskattadeReserver',
balance: 'credit',
ranges: [r('2130', '2149'), r('2160', '2199')],
},
// Avsättningar
{
concept: 'AvsattningarPensionerLiknandeForpliktelserEnligtLag',
balance: 'credit',
ranges: [r('2210', '2219')],
},
{
concept: 'OvrigaAvsattningarPensionerLiknandeForpliktelser',
balance: 'credit',
ranges: [r('2220', '2229')],
},
{ concept: 'OvrigaAvsattningar', balance: 'credit', ranges: [r('2230', '2299')] },
// Långfristiga skulder
{ concept: 'Obligationslan', balance: 'credit', ranges: [r('2300', '2329')] },
{ concept: 'CheckrakningskreditLangfristig', balance: 'credit', ranges: [r('2330', '2339')] },
{
concept: 'OvrigaLangfristigaSkulderKreditinstitut',
balance: 'credit',
ranges: [r('2340', '2359')],
},
{ concept: 'SkulderKoncernforetagLangfristiga', balance: 'credit', ranges: [r('2360', '2369')] },
{
concept: 'SkulderIntresseforetagGemensamtStyrdaForetagLangfristiga',
balance: 'credit',
ranges: [r('2370', '2372')],
},
{
concept: 'SkulderOvrigaForetagAgarintresseLangfristiga',
balance: 'credit',
ranges: [r('2373', '2379')],
},
{ concept: 'OvrigaLangfristigaSkulder', balance: 'credit', ranges: [r('2380', '2399')] },
// Kortfristiga skulder: ranges per BAS 2025/2026 as shipped in
// lib/bookkeeping/bas-data/class-2-equity-liabilities.ts (2410 = andra
// kortfristiga låneskulder, 2420 = förskott från kunder, 2430 = pågående
// arbeten, 2450 = fakturerad ej upparbetad, 2460 = koncern, 2470 =
// intresse/gem styrda/ägarintresse, 2480 = kontokredit, 2492 = växelskulder).
{ concept: 'ForskottFranKunder', balance: 'credit', ranges: [r('2420', '2429')] },
{ concept: 'CheckrakningskreditKortfristig', balance: 'credit', ranges: [r('2480', '2489')] },
{
concept: 'OvrigaKortfristigaSkulderKreditinstitut',
balance: 'credit',
ranges: [r('2410', '2419')],
},
{
concept: 'PagaendeArbetenAnnansRakningKortfristigaSkulder',
balance: 'credit',
ranges: [r('2430', '2439')],
},
{ concept: 'FaktureradEjUpparbetadIntakt', balance: 'credit', ranges: [r('2450', '2459')] },
{ concept: 'Leverantorsskulder', balance: 'credit', ranges: [r('2440', '2449')] },
{ concept: 'Vaxelskulder', balance: 'credit', ranges: [r('2492', '2492')] },
{ concept: 'SkulderKoncernforetagKortfristiga', balance: 'credit', ranges: [r('2460', '2469')] },
{
concept: 'SkulderIntresseforetagGemensamtStyrdaForetagKortfristiga',
balance: 'credit',
ranges: [r('2470', '2472')],
},
{
concept: 'SkulderOvrigaForetagAgarintresseKortfristiga',
balance: 'credit',
ranges: [r('2473', '2479')],
},
{ concept: 'Skatteskulder', balance: 'credit', ranges: [r('2500', '2599')] },
{
concept: 'OvrigaKortfristigaSkulder',
balance: 'credit',
ranges: [r('2400', '2409'), r('2490', '2491'), r('2493', '2499'), r('2600', '2899')],
},
{
concept: 'UpplupnaKostnaderForutbetaldaIntakter',
balance: 'credit',
ranges: [r('2900', '2999')],
},
]
/** Accounts that map to a "nearest" post and deserve a manual-review nudge. */
const RECLASSIFIED_ACCOUNTS: Record<string, string> = {
'2083': 'Medlemsinsatser (2083) redovisas under Reservfond: granska klassificeringen.',
'2084': 'Förlagsinsatser (2084) redovisas under Reservfond: granska klassificeringen.',
'2088': 'Fond för yttre underhåll (2088) redovisas under Reservfond: granska klassificeringen.',
'2089': 'Fond för utvecklingsutgifter (2089) redovisas under Reservfond: granska klassificeringen (K2 tillåter inte aktivering av egenupparbetade utgifter).',
}
/**
* K2 BR posts each shared sign-reclassification rule moves between. The rules
* themselves (ranges, mode, warning) live in lib/reports/sign-reclassification
* .ts so INK2R presents the same balance sheet as the årsredovisning. Only the
* post names are K2-specific; the arithmetic below stays öre-exact here.
*/
const SIGN_RECLASSIFICATION_POSTS: Record<
SignReclassificationId,
{ sourceConcept: string; targetConcept: string }
> = {
tax_account_credit_to_liability: {
sourceConcept: 'OvrigaFordringarKortfristiga',
targetConcept: 'Skatteskulder',
},
tax_liability_debit_to_receivable: {
sourceConcept: 'Skatteskulder',
targetConcept: 'OvrigaFordringarKortfristiga',
},
vat_liability_debit_to_receivable: {
sourceConcept: 'OvrigaKortfristigaSkulder',
targetConcept: 'OvrigaFordringarKortfristiga',
},
}
export interface K2MappingResult {
rr: ConceptAmounts
br: ConceptAmounts
/** Computed RR subtotals + BR totals, same orientation rules. */
totals: {
rorelseintakter: ConceptAmount
rorelsekostnader: ConceptAmount
rorelseresultat: ConceptAmount
finansiellaPoster: ConceptAmount
resultatEfterFinansiellaPoster: ConceptAmount
bokslutsdispositioner: ConceptAmount
resultatForeSkatt: ConceptAmount
aretsResultat: ConceptAmount
anlaggningstillgangar: ConceptAmount
immateriellaAnlaggningstillgangar: ConceptAmount
materiellaAnlaggningstillgangar: ConceptAmount
finansiellaAnlaggningstillgangar: ConceptAmount
varulager: ConceptAmount
kortfristigaFordringar: ConceptAmount
kortfristigaPlaceringar: ConceptAmount
kassaBank: ConceptAmount
omsattningstillgangar: ConceptAmount
tillgangar: ConceptAmount
bundetEgetKapital: ConceptAmount
frittEgetKapital: ConceptAmount
egetKapital: ConceptAmount
obeskattadeReserver: ConceptAmount
avsattningar: ConceptAmount
langfristigaSkulder: ConceptAmount
kortfristigaSkulder: ConceptAmount
egetKapitalSkulder: ConceptAmount
}
warnings: string[]
/** Accounts with balances that no mapping covered (should be none). */
unmappedAccounts: Array<{ account: string; name: string; balance: number }>
}
function netBalance(row: TrialBalanceRowLike, orientation: 'debit' | 'credit'): number {
const net = roundOre(row.closing_debit - row.closing_credit)
return orientation === 'debit' ? net : -net
}
function inRanges(account: string, ranges: Range[]): boolean {
return ranges.some((range) => account >= range.start && account <= range.end)
}
function exactSumForMapping(rows: TrialBalanceRowLike[], mapping: PostMapping): number {
return sumOre(
rows
.filter((row) => inRanges(row.account_number, mapping.ranges))
.map((row) => netBalance(row, mapping.balance)),
)
}
function roundWhole(amount: number): number {
const normalized = roundOre(amount)
const rounded = Math.round(Math.abs(normalized))
return rounded === 0 ? 0 : Math.sign(normalized) * rounded
}
function exactAmount(
mapping: PostMapping,
current: TrialBalanceRowLike[],
previous: TrialBalanceRowLike[] | null,
): ConceptAmount {
return {
current: exactSumForMapping(current, mapping),
previous: previous ? exactSumForMapping(previous, mapping) : null,
}
}
function deviatingRowsTotal(
rows: TrialBalanceRowLike[],
rule: SignReclassificationRule,
): number {
return sumOre(
rows
.filter((row) => inRanges(row.account_number, rule.ranges))
.map((row) => netBalance(row, rule.balance))
.filter((balance) => balance < 0),
)
}
function applySignReclassifications(
br: ConceptAmounts,
current: TrialBalanceRowLike[],
previous: TrialBalanceRowLike[] | null,
warnings: string[],
): void {
for (const rule of SIGN_RECLASSIFICATION_RULES) {
const { sourceConcept, targetConcept } = SIGN_RECLASSIFICATION_POSTS[rule.id]
let reclassified = false
for (const field of ['current', 'previous'] as const) {
const rows = field === 'current' ? current : previous
if (!rows) continue
const deviatingBalance =
rule.mode === 'deviating_rows'
? deviatingRowsTotal(rows, rule)
: exactSumForMapping(rows, {
concept: sourceConcept,
balance: rule.balance,
ranges: rule.ranges,
})
if (deviatingBalance >= 0) continue
const amountToMove = -deviatingBalance
adjustConcept(br, sourceConcept, field, amountToMove)
adjustConcept(br, targetConcept, field, amountToMove)
reclassified = true
}
if (reclassified) warnings.push(rule.warning)
}
}
function add(a: ConceptAmount, b: ConceptAmount, sign = 1): ConceptAmount {
return {
current: roundOre(a.current + sign * b.current),
previous:
a.previous === null && b.previous === null
? null
: roundOre((a.previous ?? 0) + sign * (b.previous ?? 0)),
}
}
const ZERO: ConceptAmount = { current: 0, previous: null }
function sumConcepts(amounts: ConceptAmounts, concepts: string[], signs?: number[]): ConceptAmount {
let total: ConceptAmount = { current: 0, previous: null }
concepts.forEach((concept, index) => {
total = add(total, amounts[concept] ?? ZERO, signs?.[index] ?? 1)
})
return total
}
/**
* Map current + previous trial balance pairs onto the K2 risbs posts.
*
* RR concepts come from the pre-closing TB (year-end closing excluded: the
* closing entry zeroes class 3-8); BR concepts come from the full TB (the
* closing entry books 2099). See TrialBalancePair.
*
* `previous = null` → first fiscal year (jämförelsesiffror omitted,
* which kontrollera 3006/3007 accepts only for year one).
*/
export function mapTrialBalancesToK2(
current: TrialBalancePair,
previous: TrialBalancePair | null,
): K2MappingResult {
const warnings: string[] = []
const rr: ConceptAmounts = {}
const rrExact: ConceptAmounts = {}
const br: ConceptAmounts = {}
const brExact: ConceptAmounts = {}
for (const mapping of K2_RR_MAPPINGS) {
rrExact[mapping.concept] = exactAmount(
mapping,
current.preClosing,
previous?.preClosing ?? null,
)
const exact = rrExact[mapping.concept]
rr[mapping.concept] = {
current: roundWhole(exact.current),
previous: exact.previous === null ? null : roundWhole(exact.previous),
}
}
for (const mapping of K2_BR_MAPPINGS) {
brExact[mapping.concept] = exactAmount(mapping, current.full, previous?.full ?? null)
}
applySignReclassifications(brExact, current.full, previous?.full ?? null, warnings)
for (const mapping of K2_BR_MAPPINGS) {
const exact = brExact[mapping.concept]
br[mapping.concept] = {
current: roundWhole(exact.current),
previous: exact.previous === null ? null : roundWhole(exact.previous),
}
}
// Reclassification + unmapped sweep over balance-carrying accounts. Both TB
// variants are swept: the full TB exposes unmapped BR accounts, the
// pre-closing TB exposes unmapped RR accounts (zeroed in the full TB).
const allMappings = [...K2_RR_MAPPINGS, ...K2_BR_MAPPINGS]
const unmappedAccounts: K2MappingResult['unmappedAccounts'] = []
const seenReclass = new Set<string>()
for (const rows of [
current.full,
current.preClosing,
previous?.full ?? [],
previous?.preClosing ?? [],
]) {
for (const row of rows) {
const balance = Math.round(row.closing_debit - row.closing_credit)
if (balance === 0) continue
const reclass = RECLASSIFIED_ACCOUNTS[row.account_number]
if (reclass && !seenReclass.has(row.account_number)) {
seenReclass.add(row.account_number)
warnings.push(reclass)
}
const covered = allMappings.some((mapping) => inRanges(row.account_number, mapping.ranges))
if (!covered && !unmappedAccounts.some((u) => u.account === row.account_number)) {
unmappedAccounts.push({ account: row.account_number, name: row.account_name, balance })
}
}
}
for (const u of unmappedAccounts) {
warnings.push(
`Konto ${u.account} (${u.name}) med saldo ${u.balance} kr täcks inte av K2-mappningen: beloppet saknas i årsredovisningen.`,
)
}
let totals = computeTotals(rr, br)
// ---- öre-rounding residual smoothing ------------------------------------
// Every tagged post is independently rounded to whole SEK, so the sum of
// rounded posts can drift by ±1 kr from the rounded exact total even though
// the underlying trial balance ties to the öre. Bolagsverket compares the
// tagged totals exactly (kontrollera 3005), so a ±1 kr residual is
// distributed back into a line item instead of tolerated. Deterministic
// rule, per year:
// - BR: each side is reconciled to its rounded exact total. The residual
// is assigned only to a post with an exact öre amount on that side.
// Exact whole-krona posts, such as a booked reserve, are never changed.
// - RR: the residual (RR-resultat − konto 2099) is absorbed by the
// largest RR post: cost posts are increased by the residual, income
// posts decreased (ties broken toward the EARLIER post).
// Multi-krona residuals are distributed over multiple fractional posts.
// A residual without enough fractional posts is left for the exact balance
// checks below instead of changing a booked whole-krona amount.
let smoothedAny = false
for (const field of ['current', 'previous'] as const) {
if (field === 'previous' && previous === null) continue
const rrSmoothed = smoothRrResidual(rr, rrExact, br, brExact, totals, field)
const brSmoothed = smoothBrResidual(br, brExact, totals, field)
smoothedAny = smoothedAny || rrSmoothed || brSmoothed
}
if (smoothedAny) totals = computeTotals(rr, br)
// Internal consistency: the RR result must equal BR 2099 (årets resultat)
// EXACTLY: if the year-end closing hasn't booked the result yet, warn
// (the BR will not balance against RR otherwise). Rounding residuals were
// smoothed above, so any remaining difference is a data problem.
const brResult = br['AretsResultatEgetKapital'] ?? ZERO
if (totals.aretsResultat.current !== brResult.current) {
warnings.push(
`Årets resultat enligt resultaträkningen (${totals.aretsResultat.current} kr) stämmer inte med konto 2099 (${brResult.current} kr). Kontrollera att bokslutet är genomfört (resultatdisposition bokad).`,
)
}
if (totals.tillgangar.current !== totals.egetKapitalSkulder.current) {
warnings.push(
`Balansräkningen balanserar inte: Summa tillgångar ${totals.tillgangar.current} kr ≠ Summa eget kapital och skulder ${totals.egetKapitalSkulder.current} kr (kontrollera-kod 3005).`,
)
}
return { rr, br, totals, warnings, unmappedAccounts }
}
function adjustConcept(
amounts: ConceptAmounts,
concept: string,
field: 'current' | 'previous',
delta: number,
): void {
const existing = amounts[concept] ?? { current: 0, previous: null }
amounts[concept] = { ...existing, [field]: roundOre((existing[field] ?? 0) + delta) }
}
/** Absorb a ±1 kr rounding residual between the RR result and BR 2099. */
function smoothRrResidual(
rr: ConceptAmounts,
rrExact: ConceptAmounts,
br: ConceptAmounts,
brExact: ConceptAmounts,
totals: K2MappingResult['totals'],
field: 'current' | 'previous',
): boolean {
const target = br['AretsResultatEgetKapital']?.[field]
const result = totals.aretsResultat[field]
if (target === null || target === undefined || result === null) return false
const diff = result - target
if (diff === 0) return false
const exactResult = computeTotals(rrExact, brExact).aretsResultat[field]
const exactTarget = brExact['AretsResultatEgetKapital']?.[field]
if (exactResult === null || exactTarget === null || exactTarget === undefined) return false
if (!equalOre(exactResult, exactTarget)) return false
const direction = Math.sign(diff)
const candidates = K2_RR_MAPPINGS.flatMap((mapping, index) => {
const rounded = rr[mapping.concept]?.[field]
const exact = rrExact[mapping.concept]?.[field]
if (rounded === null || rounded === undefined || exact === null || exact === undefined) return []
if (Math.abs(exact - rounded) < 0.000001) return []
const delta = mapping.balance === 'debit' ? direction : -direction
return [{ concept: mapping.concept, delta, error: Math.abs(rounded + delta - exact), index }]
}).sort((left, right) => left.error - right.error || left.index - right.index)
if (candidates.length < Math.abs(diff)) return false
for (const candidate of candidates.slice(0, Math.abs(diff))) {
adjustConcept(rr, candidate.concept, field, candidate.delta)
}
return true
}
/** Equity/liability-side posts (everything from Aktiekapital onwards). */
const FIRST_EQ_LIAB_MAPPING_INDEX = K2_BR_MAPPINGS.findIndex(
(mapping) => mapping.concept === 'Aktiekapital',
)
const ASSET_MAPPINGS = K2_BR_MAPPINGS.slice(0, FIRST_EQ_LIAB_MAPPING_INDEX)
const EQ_LIAB_MAPPINGS = K2_BR_MAPPINGS.slice(
FIRST_EQ_LIAB_MAPPING_INDEX,
)
/**
* Reconcile each BR side to its own rounded exact total without changing exact
* posts. Residuals must not be netted across sides: doing so could make the
* balance check pass while leaving one reported side different from its exact
* accounting total.
*/
function smoothBrResidual(
br: ConceptAmounts,
brExact: ConceptAmounts,
totals: K2MappingResult['totals'],
field: 'current' | 'previous',
): boolean {
const assets = totals.tillgangar[field]
const eqLiab = totals.egetKapitalSkulder[field]
if (assets === null || eqLiab === null) return false
const exactTotals = computeTotals({}, brExact)
const exactAssets = exactTotals.tillgangar[field]
const exactEqLiab = exactTotals.egetKapitalSkulder[field]
if (exactAssets === null || exactEqLiab === null) return false
if (!equalOre(exactAssets, exactEqLiab)) return false
const sides = [
{ mappings: ASSET_MAPPINGS, rounded: assets, target: roundWhole(exactAssets) },
{ mappings: EQ_LIAB_MAPPINGS, rounded: eqLiab, target: roundWhole(exactEqLiab) },
]
const residuals = sides.map((side) => side.target - side.rounded)
if (residuals.every((residual) => residual === 0)) return false
const plans = sides.map((side, index) => {
const residual = residuals[index]
if (residual === 0) return []
const direction = Math.sign(residual)
const candidates = side.mappings.flatMap((mapping, mappingIndex) => {
if (mapping.concept === 'AretsResultatEgetKapital') return []
const rounded = br[mapping.concept]?.[field]
const exact = brExact[mapping.concept]?.[field]
if (rounded === null || rounded === undefined || exact === null || exact === undefined) return []
if (Math.abs(exact - rounded) < 0.000001) return []
return [{
concept: mapping.concept,
error: Math.abs(rounded + direction - exact),
index: mappingIndex,
}]
}).sort((left, right) => left.error - right.error || left.index - right.index)
if (candidates.length < Math.abs(residual)) return null
return candidates.slice(0, Math.abs(residual)).map((candidate) => ({
concept: candidate.concept,
delta: direction,
}))
})
if (plans.some((plan) => plan === null)) return false
for (const plan of plans) {
for (const adjustment of plan ?? []) {
adjustConcept(br, adjustment.concept, field, adjustment.delta)
}
}
return plans.some((plan) => (plan?.length ?? 0) > 0)
}
function computeTotals(rr: ConceptAmounts, br: ConceptAmounts): K2MappingResult['totals'] {
// ---- RR subtotals (credit-positive orientation) ----
const rorelseintakter = sumConcepts(rr, [
'Nettoomsattning',
'ForandringLagerProdukterIArbeteFardigaVarorPagaendeArbetenAnnansRakning',
'AktiveratArbeteEgenRakning',
'OvrigaRorelseintakter',
])
const rorelsekostnader = sumConcepts(rr, [
'RavarorFornodenheterKostnader',
'HandelsvarorKostnader',
'OvrigaExternaKostnader',
'Personalkostnader',
'AvskrivningarNedskrivningarMateriellaImmateriellaAnlaggningstillgangar',
'NedskrivningarOmsattningstillgangarUtoverNormalaNedskrivningar',
'OvrigaRorelsekostnader',
])
const rorelseresultat = add(rorelseintakter, rorelsekostnader, -1)
const finansiellaPoster = sumConcepts(
rr,
[
'ResultatAndelarKoncernforetag',
'ResultatAndelarIntresseforetagGemensamtStyrda',
'ResultatOvrigaforetagAgarintresse',
'ResultatOvrigaFinansiellaAnlaggningstillgangar',
'OvrigaRanteintakterLiknandeResultatposter',
'NedskrivningarFinansiellaAnlaggningstillgangarKortfristigaPlaceringar',
'RantekostnaderLiknandeResultatposter',
],
[1, 1, 1, 1, 1, -1, -1],
)
const resultatEfterFinansiellaPoster = add(rorelseresultat, finansiellaPoster)
const bokslutsdispositioner = sumConcepts(
rr,
[
'ErhallnaKoncernbidrag',
'LamnadeKoncernbidrag',
'ForandringPeriodiseringsfond',
'ForandringOveravskrivningar',
'OvrigaBokslutsdispositioner',
],
[1, -1, 1, 1, 1],
)
const resultatForeSkatt = add(resultatEfterFinansiellaPoster, bokslutsdispositioner)
const skatter = sumConcepts(rr, ['SkattAretsResultat', 'OvrigaSkatter'])
const aretsResultat = add(resultatForeSkatt, skatter, -1)
// ---- BR totals ----
const immateriella = sumConcepts(br, [
'KoncessionerPatentLicenserVarumarkenLiknandeRattigheter',
'HyresratterLiknandeRattigheter',
'Goodwill',
'ForskottImmateriellaAnlaggningstillgangar',
])
const materiella = sumConcepts(br, [
'ByggnaderMark',
'MaskinerAndraTekniskaAnlaggningar',
'InventarierVerktygInstallationer',
'ForbattringsutgifterAnnansFastighet',
'OvrigaMateriellaAnlaggningstillgangar',
'PagaendeNyanlaggningarForskottMateriellaAnlaggningstillgangar',
])
const finansiella = sumConcepts(br, [
'AndelarKoncernforetag',
'FordringarKoncernforetagLangfristiga',
'AndelarIntresseforetagGemensamtStyrdaForetag',
'FordringarIntresseforetagGemensamtStyrdaForetagLangfristiga',
'AgarintressenOvrigaForetag',
'FordringarOvrigaForetagAgarintresseLangfristiga',
'AndraLangfristigaVardepappersinnehav',
'LanDelagareNarstaende',
'AndraLangfristigaFordringar',
])
const anlaggningstillgangar = add(add(immateriella, materiella), finansiella)
const varulager = sumConcepts(br, [
'LagerRavarorFornodenheter',
'LagerVarorUnderTillverkning',
'LagerFardigaVarorHandelsvaror',
'PagaendeArbetenAnnansRakningOmsattningstillgangar',
'ForskottTillLeverantorer',
'OvrigaLagertillgangar',
])
const kortfristigaFordringar = sumConcepts(br, [
'Kundfordringar',
'FordringarKoncernforetagKortfristiga',
'FordringarIntresseforetagGemensamtStyrdaForetagKortfristiga',
'FordringarOvrigaforetagAgarintresseKortfristiga',
'OvrigaFordringarKortfristiga',
'UpparbetadEjFaktureradIntakt',
'ForutbetaldaKostnaderUpplupnaIntakter',
])
const kortfristigaPlaceringar = sumConcepts(br, [
'AndelarKoncernforetagKortfristiga',
'OvrigaKortfristigaPlaceringar',
])
const kassaBank = sumConcepts(br, ['KassaBankExklRedovisningsmedel', 'Redovisningsmedel'])
const omsattningstillgangar = add(
add(varulager, kortfristigaFordringar),
add(kortfristigaPlaceringar, kassaBank),
)
const tillgangar = add(
add(br['TecknatEjInbetaltKapital'] ?? ZERO, anlaggningstillgangar),
omsattningstillgangar,
)
const bundetEgetKapital = sumConcepts(br, [
'Aktiekapital',
'EjRegistreratAktiekapital',
'OverkursfondBunden',
'Uppskrivningsfond',
'Reservfond',
])
const frittEgetKapital = sumConcepts(br, [
'Overkursfond',
'BalanseratResultat',
'AretsResultatEgetKapital',
])
const egetKapital = add(bundetEgetKapital, frittEgetKapital)
const obeskattadeReserver = sumConcepts(br, [
'Periodiseringsfonder',
'AckumuleradeOveravskrivningar',
'OvrigaObeskattadeReserver',
])
const avsattningar = sumConcepts(br, [
'AvsattningarPensionerLiknandeForpliktelserEnligtLag',
'OvrigaAvsattningarPensionerLiknandeForpliktelser',
'OvrigaAvsattningar',
])
const langfristigaSkulder = sumConcepts(br, [
'Obligationslan',
'CheckrakningskreditLangfristig',
'OvrigaLangfristigaSkulderKreditinstitut',
'SkulderKoncernforetagLangfristiga',
'SkulderIntresseforetagGemensamtStyrdaForetagLangfristiga',
'SkulderOvrigaForetagAgarintresseLangfristiga',
'OvrigaLangfristigaSkulder',
])
const kortfristigaSkulder = sumConcepts(br, [
'ForskottFranKunder',
'CheckrakningskreditKortfristig',
'OvrigaKortfristigaSkulderKreditinstitut',
'PagaendeArbetenAnnansRakningKortfristigaSkulder',
'FaktureradEjUpparbetadIntakt',
'Leverantorsskulder',
'Vaxelskulder',
'SkulderKoncernforetagKortfristiga',
'SkulderIntresseforetagGemensamtStyrdaForetagKortfristiga',
'SkulderOvrigaForetagAgarintresseKortfristiga',
'Skatteskulder',
'OvrigaKortfristigaSkulder',
'UpplupnaKostnaderForutbetaldaIntakter',
])
const egetKapitalSkulder = add(
add(add(egetKapital, obeskattadeReserver), add(avsattningar, langfristigaSkulder)),
kortfristigaSkulder,
)
return {
rorelseintakter,
rorelsekostnader,
rorelseresultat,
finansiellaPoster,
resultatEfterFinansiellaPoster,
bokslutsdispositioner,
resultatForeSkatt,
aretsResultat,
anlaggningstillgangar,
immateriellaAnlaggningstillgangar: immateriella,
materiellaAnlaggningstillgangar: materiella,
finansiellaAnlaggningstillgangar: finansiella,
varulager,
kortfristigaFordringar,
kortfristigaPlaceringar,
kassaBank,
omsattningstillgangar,
tillgangar,
bundetEgetKapital,
frittEgetKapital,
egetKapital,
obeskattadeReserver,
avsattningar,
langfristigaSkulder,
kortfristigaSkulder,
egetKapitalSkulder,
}
}