Files
accounted/lib/bokslut/ixbrl/k2-mapper.ts
T
Mattsson db8983ba9e Add/bokslut (#718)
* feat(arcim-migration): Briox provider with SIE-over-API import

- Briox auth via account ID + application token (no app-level
  credentials); both tokens rotate on refresh and are persisted
- New sie-fetcher pulls the general ledger as SIE through the
  provider API for Fortnox, Briox and Bjorn Lunden
- Wizard stops on a failed SIE import and surfaces the real errors
  instead of proceeding to the misleading migrate-guard message
- PROVIDER_SIE_ONLY_FORTNOX renamed to PROVIDER_SIE_NOT_SUPPORTED;
  new PROVIDER_TOKEN_INVALID for rejected provider credentials

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

* feat(bookkeeping): per-line accruals (periodisering) on invoices and supplier invoices

Defer revenue/costs per invoice line to 29xx/17xx interim accounts with
automatic monthly dissolution (nightly cron + catch-up at registration),
schedule cancellation on credit, year-end auto-detect exclusion for
already-scheduled invoices, invoice-inbox service-period extraction for
prefill, and an MCP tool to list schedules.

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

* feat(bokslut): iXBRL arsredovisning generation and Bolagsverket digital filing

Generate the annual report as iXBRL from a generated taxonomy registry
(K2 element lists, taxonomy:generate/check scripts + CI guard), expose it
via the fiscal-period API, and add the bolagsverket extension for digital
submission to eget utrymme with webhook-driven status tracking
(submissions table + pg tests, lifecycle events, year-end wizard UI).

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

* test(mcp): raise origin-guard test timeout to 20s

The dynamic import pulls in the full server module; the parse alone
flirts with the 5s default under full-suite parallel load.

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

* Add new scripts and documentation for K2 AB taxonomy generation and validation

- Introduced `generate-taxonomy-registry.ts` to automate the generation of the iXBRL taxonomy concept registry from official element lists and tuple models.
- Added `validate-ixbrl.mjs` for validating generated iXBRL reports against the official taxonomy package using Arelle.
- Included new documentation files:
  - `k2-ab-arsredovisning-elementlista-2024-09-12_rev20250312_sv.xlsx`
  - `tuple-innehallsmodell-arsredovisning-k2-2024-09-12.xlsx`
  - `taxonomi-paket-2024-09-12_rev20250312.zip`

* Add tests for bookkeeping accruals dissolution and supplier invoices

- Implement tests for the POST /api/bookkeeping/accruals/[id]/dissolve route, covering success and error scenarios.
- Add tests for the DELETE /api/supplier-invoices/[id] route, including authentication checks and validation of invoice deletion conditions.
- Introduce tests for the Arcim migration provider client, ensuring token handling and error classification.
- Create tests for the Bolagsverket extension, validating submission role enforcement and environment settings.
- Add Zod schemas for Bolagsverket response payloads to ensure proper validation.
- Implement tests for MCP server's list accrual schedules, confirming registration and scope mapping.
- Add consistency tests for IXBRL document generation, ensuring duplicate facts and XML escaping are handled correctly.
- Introduce typed domain errors for accrual schedules to improve error handling in the service.
- Add tests for resolving consent with Briox token refresh concurrency, ensuring proper token management and error handling.

* fix(tests): update payload size guard comments to reflect recent changes in tool descriptions and ceiling adjustments

* fix(gitattributes): mark generated JSON files in bokslut taxonomy as linguist-generated

* feat(migrations): add backfill for invoices.journal_entry_id and fallback for next_voucher_number user_id

* feat(bokslut): enhance compliance and financial processing features with new submission details and security measures

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 16:35:30 +02:00

841 lines
31 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'
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 38 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 excludeYearEndClosing: 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 (49104929) belong to RavarorFornodenheterKostnader and
// handelsvaror (49604969) 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',
ranges: [r('7800', '7899')],
},
{
concept: 'NedskrivningarOmsattningstillgangarUtoverNormalaNedskrivningar',
balance: 'debit',
ranges: [r('7700', '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).',
}
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 = 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 sumForMapping(rows: TrialBalanceRowLike[], mapping: PostMapping): number {
let total = 0
for (const row of rows) {
if (inRanges(row.account_number, mapping.ranges)) {
total += netBalance(row, mapping.balance)
}
}
return Math.round(total)
}
function amount(
mapping: PostMapping,
current: TrialBalanceRowLike[],
previous: TrialBalanceRowLike[] | null,
): ConceptAmount {
return {
current: sumForMapping(current, mapping),
previous: previous ? sumForMapping(previous, mapping) : null,
}
}
function add(a: ConceptAmount, b: ConceptAmount, sign = 1): ConceptAmount {
return {
current: a.current + sign * b.current,
previous:
a.previous === null && b.previous === null
? null
: (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 38); 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 br: ConceptAmounts = {}
for (const mapping of K2_RR_MAPPINGS) {
rr[mapping.concept] = amount(mapping, current.preClosing, previous?.preClosing ?? null)
}
for (const mapping of K2_BR_MAPPINGS) {
br[mapping.concept] = amount(mapping, current.full, previous?.full ?? null)
}
// 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: the residual (Tillgångar Eget kapital och skulder) is added to
// the largest post (by absolute value) on the equity/liabilities side,
// excluding AretsResultatEgetKapital, whose value must stay equal to
// the booked 2099 / RR result (ties broken toward the LATER post in
// the uppställningsform, so liabilities win over aktiekapital).
// - 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).
// Residuals beyond ±1 kr are real bookkeeping errors and are left for the
// exact balance checks below.
let smoothedAny = false
for (const field of ['current', 'previous'] as const) {
if (field === 'previous' && previous === null) continue
const rrSmoothed = smoothRrResidual(rr, br, totals, field)
const brSmoothed = smoothBrResidual(br, 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 pickLargestConcept(
amounts: ConceptAmounts,
mappings: PostMapping[],
field: 'current' | 'previous',
exclude: ReadonlySet<string>,
tieBreak: 'first' | 'last',
): string | null {
let best: string | null = null
let bestAbs = -1
for (const mapping of mappings) {
if (exclude.has(mapping.concept)) continue
const value = amounts[mapping.concept]?.[field]
if (value === null || value === undefined || value === 0) continue
const abs = Math.abs(value)
if (abs > bestAbs || (abs === bestAbs && tieBreak === 'last')) {
best = mapping.concept
bestAbs = abs
}
}
return best
}
function adjustConcept(
amounts: ConceptAmounts,
concept: string,
field: 'current' | 'previous',
delta: number,
): void {
const existing = amounts[concept] ?? { current: 0, previous: null }
amounts[concept] = { ...existing, [field]: (existing[field] ?? 0) + delta }
}
/** Absorb a ±1 kr rounding residual between the RR result and BR 2099. */
function smoothRrResidual(
rr: ConceptAmounts,
br: 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 || Math.abs(diff) > 1) return false
const concept = pickLargestConcept(rr, K2_RR_MAPPINGS, field, new Set(), 'first')
if (!concept) return false
const balance = K2_RR_MAPPINGS.find((mapping) => mapping.concept === concept)?.balance
// Debit (cost) posts enter the result with weight 1, credit (income)
// posts with +1 — adjust so the recomputed result lands on the 2099 value.
adjustConcept(rr, concept, field, balance === 'debit' ? diff : -diff)
return true
}
/** Equity/liability-side posts (everything from Aktiekapital onwards). */
const EQ_LIAB_MAPPINGS = K2_BR_MAPPINGS.slice(
K2_BR_MAPPINGS.findIndex((mapping) => mapping.concept === 'Aktiekapital'),
)
/** Absorb a ±1 kr rounding residual between the two BR sides. */
function smoothBrResidual(
br: 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 diff = assets - eqLiab
if (diff === 0 || Math.abs(diff) > 1) return false
const concept =
pickLargestConcept(br, EQ_LIAB_MAPPINGS, field, new Set(['AretsResultatEgetKapital']), 'last') ??
'BalanseratResultat'
// All equity/liability posts are credit-oriented: adding the residual
// raises the eget kapital och skulder side to match Tillgångar.
adjustConcept(br, concept, field, diff)
return true
}
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,
}
}