Files
accounted/lib/reports/ink2/ink2-engine.ts
T
Mattsson 39f4ecdad4 fix(providers): surface migration step errors; INK2 SRU 7104; non-modal invoice dialog (#1465)
* feat(mileage): körjournal with milersättning booking, MCP tools and CSV export

New mileage_trips table (RLS, booked-delete trigger per BFL retention),
lib/mileage service reusing the payroll schablon rates, /api/mileage routes
(trips CRUD, period booking to 7331, salary-run push, körjournal CSV),
Körjournal dashboard page + nav, and three staged MCP tools (search-only
catalog). Trips book as one verifikat per period via the engine; salary
path inserts mileage_taxfree line items. mileage_trips classified in the
full-archive export.

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

* refactor(mileage): use shared roundOre helper per tightened ratchet baseline

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

* fix(mileage): pending_operations op-type migration + Swedish review findings

- New migration pair adds log_mileage_trip/book_mileage_period to the
  pending_operations operation_type CHECK (pg-real audit).
- bookMileagePeriod refuses a period spanning several employees and names
  the employee in the verifikationstext when scoped (BFL motpart).
- vehicle_registration required for förmånsbil trips (schema, service,
  MCP staging, UI surfaces the field).

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

* fix(mileage): claim-first booking, CSV injection guard and driver column

- bookMileagePeriod claims trips (draft to booked CAS) before creating the
  verifikat, so a concurrent second booking loses the race instead of
  double-booking; claim reverts if verifikat creation fails.
- Körjournal CSV neutralizes formula-injection triggers (OWASP) and adds a
  Förare column naming the employee per trip.

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

* fix(mileage): resolve CodeRabbit + Swedish review round: race, drift and hardening

- Copying a round trip no longer re-doubles the stored distance.
- pushMileageToSalaryRun claims trips before inserting line items (retry can
  no longer double-pay); CLAIM_LOST replaces misleading NO_TRIPS on lost races.
- Booked trips are DB-immutable via a BEFORE UPDATE trigger (new migration
  20260807113215): only claim/link/revert transitions and notes edits pass.
- Cross-year periods rejected (schablon rates are per calendar year); payroll
  config year read from the date string, not TZ-dependent getFullYear().
- MCP staged bookings freeze the previewed trip set (trip_ids in params) and
  the commit fails on drift; validation errors return 400, not 500.
- PATCH enforces the förmånsbil regnr rule on the effective row; export
  validates dates before they reach the Content-Disposition header; employee_id
  is verified company-scoped on trip creation; stale orphaned claims released.
- UI: fetch flags reset in finally; ICU plural for draft summary; distance
  stored at the column's 1-decimal precision.
- Tests: [id] route suite, pushMileageToSalaryRun suite, claim-race, drift,
  cross-year and update-trigger pg cases.

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

* fix(mileage): revert-to-draft must clear salary_run_id at the trigger level

New migration 20260807114924 replaces the booked-immutability function: a
booked -> draft revert now rejects rows keeping salary_run_id, closing the
DB-level double-pay path CodeRabbit flagged. pg test pins both directions;
the CLAIM_LOST unit test now asserts the revert.

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

* fix(mileage): company-scope employee_id on PATCH (Superagent P2)

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

* test(mileage): valid v4 uuid in cross-company employee PATCH test

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

* fix(providers): surface migration step errors instead of silent empty syncs

A Visma company without the API module activated (403 ErrorCode 4002,
"No access to module: api_standard") failed every provider call during
migration, yet the wizard reported success with zero rows and mapped the
403 to "reconnect", which loops forever since OAuth succeeds against
Visma's shared identity server. A real user burned time re-syncing and
reconnecting, then filed the config issue as a bug.

- New PROVIDER_API_MODULE_INACTIVE code; classifyProviderError reads the
  error body and recognizes the module error before the 403 to
  AUTH_EXPIRED mapping. Registry entry carries the remediation in
  Swedish and English (activate the API under Appar och tillagg, paid
  add-on on smaller plans, clear standardforetag, SIE fallback).
- Orchestrator: connection-level failures (auth expired, license
  missing, module inactive) rethrow and abort the doomed run so /migrate
  answers with the typed code; other step failures stay non-fatal but
  land on results.stepErrors instead of only in server logs.
- /preview fails fast on the two subscription codes so the user reads
  the remediation at connect time, before any sync.
- Wizard: preview treats the new code like the Fortnox license case
  (CTA + SIE fallback); the result step renders error cards per cause
  and says "Migrering delvis genomford" instead of "Allt ar uppdaterat";
  the completion toast is honest on partial failure.

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

* fix(ink2): SRU field 1.1 is 7104, not 7113 (Skatteverket rejects 7113)

The INK2 huvudblankett code for 1.1 Overskott av naringsverksamhet is
7104 per Skatteverket's official 2025P4 faltkoder (INK2_SKV2002-33-01-24-04).
We emitted 7113, which does not exist on INK2, so filoverforing rejected
every profitable company's BLANKETTER.SRU with 'UPPGIFT 7113 ar inte ett
giltigt postnamn' (reported by a user for FY 2024-10-07..2025-12-31).
Underskott (7114) was already correct.

The wrong code originated in the swedish-sru-filing skill reference;
fixed there too and regenerated the atom seed. All other emitted
INK2/INK2R/INK2S codes verified against the official 2025P4 lists.

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

* fix(invoices): keep the AI chat usable over the new-invoice dialog

The new-invoice dialog was a modal Radix dialog: modal mode sets body
pointer-events: none, aria-hidden on body siblings, and a focus trap, so
the agent sheet (z-60, painted above the dialog) was visible but dead:
clicks swallowed, input unfocusable, and all three dismiss paths
preventDefaulted, leaving no way out except the header X.

Now non-modal: page modality is restored by hand instead. A new
DialogVeil primitive supplies the backdrop (Radix renders no overlay in
non-modal mode) at z-40, under dialog content (z-50) and the agent sheet
(z-60), and inert on #dash-shell blocks pointer, keyboard, and AT access
to the page behind while the sheet (a body-level sibling) stays live.
The lazy-load fallback dialog on /invoices gets the same treatment so a
hung or 404'd chunk cannot dead-lock the route.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 16:04:58 +02:00

1106 lines
35 KiB
TypeScript

import type { SupabaseClient } from '@supabase/supabase-js'
import { loadTaxAdjustmentSnapshot } from '@/lib/bokslut/tax-provision/tax-adjustment-service'
import { generateTrialBalance } from '@/lib/reports/trial-balance'
import {
SIGN_RECLASSIFICATION_RULES,
selectReclassifiedAccounts,
type SignReclassificationId,
} from '@/lib/reports/sign-reclassification'
import type { FiscalPeriod, TrialBalanceRow } from '@/types'
import type {
INK2Declaration,
INK2RRutor,
INK2Rutor,
INK2SRutor,
INK2AccountMapping,
INK2RSRUCode,
} from './types'
import {
INK2R_ASSET_CODES,
INK2R_EQUITY_LIABILITY_CODES,
} from './types'
/**
* INK2 Declaration Engine
*
* Generates INK2 (huvudblankett), INK2R (räkenskapsschema), and INK2S
* (skattemässiga justeringar) for aktiebolag tax reporting.
*
* Account mappings follow the official BAS-to-SRU mapping from
* bas.se/kontoplaner/sru/ and Skatteverket field code spec.
*
* INK2R contains the full balance sheet + income statement.
* INK2S auto-derives basic fields (result + tax → taxable result), as well as
* periodiseringsfond and överavskrivningar when those have been posted via the
* bokslut-dispositions calculators in lib/bokslut/.
*
* Balances come from generateTrialBalance, never from a raw journal scan, and
* the two sides of INK2R read DIFFERENT views of the same period:
*
* - Balance sheet: the closed books. After year-end the resultatavslut has
* moved årets resultat into 2099, so fritt eget kapital (7302) is only
* right when the closing verifikat is included.
* - Income statement: the pre-closing books (excludeFinalClosingEntry). The
* resultatavslut zeroes every P&L account against 2099, so including it
* collapses the whole resultaträkning to zero, which then cascades into
* INK2S 7650/7651 and the taxable result. INK2 is always filed after
* bokslut, so that is the normal state, not an edge case.
*
* excludeFinalClosingEntry drops only fiscal_periods.closing_entry_id: tax,
* depreciation and bokslutsdispositioner also carry source_type 'year_end' and
* must stay on the form (7525, 7528).
*/
/**
* BAS-to-SRU account mappings for INK2R
* Source: bas.se/kontoplaner/sru/ (stable since 2017)
*/
export const INK2R_ACCOUNT_MAPPINGS: INK2AccountMapping[] = [
// ---- Balance sheet: Assets ----
{
sruCode: '7201',
description: 'Koncessioner, patent, licenser, varumärken, goodwill',
section: 'assets',
normalBalance: 'debit',
accountRanges: [
{ start: '1010', end: '1079' },
{ start: '1090', end: '1099' },
],
},
{
sruCode: '7202',
description: 'Förskott immateriella anläggningstillgångar',
section: 'assets',
normalBalance: 'debit',
accountRanges: [{ start: '1080', end: '1089' }],
},
{
sruCode: '7214',
description: 'Byggnader och mark',
section: 'assets',
normalBalance: 'debit',
accountRanges: [
{ start: '1100', end: '1119' },
{ start: '1130', end: '1179' },
{ start: '1190', end: '1199' },
],
},
{
sruCode: '7215',
description: 'Maskiner, inventarier, övriga materiella',
section: 'assets',
normalBalance: 'debit',
accountRanges: [{ start: '1200', end: '1299' }],
},
{
sruCode: '7216',
description: 'Förbättringsutgifter på annans fastighet',
section: 'assets',
normalBalance: 'debit',
accountRanges: [{ start: '1120', end: '1129' }],
},
{
sruCode: '7217',
description: 'Pågående nyanläggningar, förskott materiella',
section: 'assets',
normalBalance: 'debit',
accountRanges: [{ start: '1180', end: '1189' }],
},
{
sruCode: '7230',
description: 'Andelar i koncernföretag',
section: 'assets',
normalBalance: 'debit',
accountRanges: [{ start: '1311', end: '1316' }],
},
{
sruCode: '7231',
description: 'Andelar i intresseföretag',
section: 'assets',
normalBalance: 'debit',
accountRanges: [{ start: '1330', end: '1338' }],
},
{
sruCode: '7233',
description: 'Ägarintressen övriga företag + långfristiga värdepapper',
section: 'assets',
normalBalance: 'debit',
accountRanges: [
{ start: '1350', end: '1359' },
{ start: '1380', end: '1389' },
],
},
{
sruCode: '7232',
description: 'Fordringar koncern/intresse',
section: 'assets',
normalBalance: 'debit',
accountRanges: [
{ start: '1320', end: '1329' },
{ start: '1340', end: '1349' },
],
},
{
sruCode: '7234',
description: 'Lån till delägare eller närstående',
section: 'assets',
normalBalance: 'debit',
accountRanges: [{ start: '1360', end: '1369' }],
},
{
sruCode: '7235',
description: 'Övriga långfristiga fordringar',
section: 'assets',
normalBalance: 'debit',
accountRanges: [
{ start: '1370', end: '1379' },
{ start: '1390', end: '1399' },
],
},
{
sruCode: '7241',
description: 'Råvaror och förnödenheter',
section: 'assets',
normalBalance: 'debit',
accountRanges: [{ start: '1410', end: '1419' }],
},
{
sruCode: '7242',
description: 'Varor under tillverkning',
section: 'assets',
normalBalance: 'debit',
accountRanges: [{ start: '1440', end: '1449' }],
},
{
sruCode: '7243',
description: 'Färdiga varor och handelsvaror',
section: 'assets',
normalBalance: 'debit',
accountRanges: [{ start: '1450', end: '1469' }],
},
{
sruCode: '7244',
description: 'Övriga lagertillgångar',
section: 'assets',
normalBalance: 'debit',
accountRanges: [{ start: '1470', end: '1489' }],
},
{
sruCode: '7245',
description: 'Pågående arbeten för annans räkning',
section: 'assets',
normalBalance: 'debit',
accountRanges: [{ start: '1490', end: '1499' }],
},
{
sruCode: '7246',
description: 'Förskott till leverantörer',
section: 'assets',
normalBalance: 'debit',
accountRanges: [{ start: '1400', end: '1409' }],
},
{
sruCode: '7251',
description: 'Kundfordringar',
section: 'assets',
normalBalance: 'debit',
accountRanges: [{ start: '1500', end: '1519' }],
},
{
sruCode: '7252',
description: 'Fordringar koncern/intresse (kortfristiga)',
section: 'assets',
normalBalance: 'debit',
accountRanges: [{ start: '1560', end: '1579' }],
},
{
sruCode: '7261',
description: 'Övriga fordringar',
section: 'assets',
normalBalance: 'debit',
accountRanges: [
{ start: '1520', end: '1559' },
{ start: '1580', end: '1599' },
{ start: '1600', end: '1619' },
{ start: '1621', end: '1699' },
],
},
{
sruCode: '7262',
description: 'Upparbetad men ej fakturerad intäkt',
section: 'assets',
normalBalance: 'debit',
accountRanges: [{ start: '1620', end: '1620' }],
},
{
sruCode: '7263',
description: 'Förutbetalda kostnader och upplupna intäkter',
section: 'assets',
normalBalance: 'debit',
accountRanges: [{ start: '1700', end: '1799' }],
},
{
sruCode: '7270',
description: 'Andelar i koncernföretag (kortfristiga)',
section: 'assets',
normalBalance: 'debit',
accountRanges: [{ start: '1860', end: '1869' }],
},
{
sruCode: '7271',
description: 'Övriga kortfristiga placeringar',
section: 'assets',
normalBalance: 'debit',
accountRanges: [
{ start: '1800', end: '1859' },
{ start: '1870', end: '1899' },
],
},
{
sruCode: '7281',
description: 'Kassa, bank och redovisningsmedel',
section: 'assets',
normalBalance: 'debit',
accountRanges: [{ start: '1900', end: '1999' }],
},
// ---- Balance sheet: Equity & Liabilities ----
{
sruCode: '7301',
description: 'Bundet eget kapital',
section: 'equity_liabilities',
normalBalance: 'credit',
accountRanges: [{ start: '2010', end: '2089' }],
},
{
sruCode: '7302',
description: 'Fritt eget kapital',
section: 'equity_liabilities',
normalBalance: 'credit',
accountRanges: [{ start: '2090', end: '2099' }],
},
{
sruCode: '7321',
description: 'Periodiseringsfonder',
section: 'equity_liabilities',
normalBalance: 'credit',
accountRanges: [
{ start: '2100', end: '2109' },
{ start: '2110', end: '2129' },
],
},
{
sruCode: '7322',
description: 'Ackumulerade överavskrivningar',
section: 'equity_liabilities',
normalBalance: 'credit',
accountRanges: [{ start: '2150', end: '2159' }],
},
{
sruCode: '7323',
description: 'Övriga obeskattade reserver',
section: 'equity_liabilities',
normalBalance: 'credit',
accountRanges: [
{ start: '2130', end: '2149' },
{ start: '2160', end: '2199' },
],
},
{
sruCode: '7331',
description: 'Pensionsavsättningar tryggandelagen',
section: 'equity_liabilities',
normalBalance: 'credit',
accountRanges: [{ start: '2210', end: '2219' }],
},
{
sruCode: '7332',
description: 'Övriga pensionsavsättningar',
section: 'equity_liabilities',
normalBalance: 'credit',
accountRanges: [{ start: '2220', end: '2229' }],
},
{
sruCode: '7333',
description: 'Övriga avsättningar',
section: 'equity_liabilities',
normalBalance: 'credit',
accountRanges: [{ start: '2230', end: '2299' }],
},
{
sruCode: '7350',
description: 'Obligationslån',
section: 'equity_liabilities',
normalBalance: 'credit',
accountRanges: [
{ start: '2300', end: '2319' },
{ start: '2320', end: '2329' },
],
},
{
sruCode: '7351',
description: 'Checkräkningskredit (långfristig)',
section: 'equity_liabilities',
normalBalance: 'credit',
accountRanges: [{ start: '2330', end: '2339' }],
},
{
sruCode: '7352',
description: 'Övriga skulder kreditinstitut (långfristiga)',
section: 'equity_liabilities',
normalBalance: 'credit',
accountRanges: [{ start: '2340', end: '2359' }],
},
{
sruCode: '7353',
description: 'Skulder koncern/intresse (långfristiga)',
section: 'equity_liabilities',
normalBalance: 'credit',
accountRanges: [{ start: '2360', end: '2379' }],
},
{
sruCode: '7354',
description: 'Övriga skulder (långfristiga)',
section: 'equity_liabilities',
normalBalance: 'credit',
accountRanges: [{ start: '2380', end: '2399' }],
},
{
sruCode: '7360',
description: 'Checkräkningskredit (kortfristig)',
section: 'equity_liabilities',
normalBalance: 'credit',
accountRanges: [{ start: '2410', end: '2419' }],
},
{
sruCode: '7361',
description: 'Övriga skulder kreditinstitut (kortfristiga)',
section: 'equity_liabilities',
normalBalance: 'credit',
accountRanges: [{ start: '2420', end: '2439' }],
},
{
sruCode: '7362',
description: 'Förskott från kunder',
section: 'equity_liabilities',
normalBalance: 'credit',
accountRanges: [{ start: '2400', end: '2409' }],
},
{
sruCode: '7363',
description: 'Pågående arbeten (skuldsida)',
section: 'equity_liabilities',
normalBalance: 'credit',
accountRanges: [{ start: '2450', end: '2459' }],
},
{
sruCode: '7364',
description: 'Fakturerad men ej upparbetad intäkt',
section: 'equity_liabilities',
normalBalance: 'credit',
accountRanges: [{ start: '2460', end: '2469' }],
},
{
sruCode: '7365',
description: 'Leverantörsskulder',
section: 'equity_liabilities',
normalBalance: 'credit',
accountRanges: [{ start: '2440', end: '2449' }],
},
{
sruCode: '7366',
description: 'Växelskulder',
section: 'equity_liabilities',
normalBalance: 'credit',
accountRanges: [{ start: '2490', end: '2490' }],
},
{
sruCode: '7367',
description: 'Skulder koncern/intresse (kortfristiga)',
section: 'equity_liabilities',
normalBalance: 'credit',
accountRanges: [{ start: '2470', end: '2479' }],
},
{
sruCode: '7369',
description: 'Övriga skulder (kortfristiga)',
section: 'equity_liabilities',
normalBalance: 'credit',
accountRanges: [
{ start: '2480', end: '2489' },
{ start: '2491', end: '2499' },
{ start: '2600', end: '2799' },
{ start: '2800', end: '2899' },
],
},
{
sruCode: '7368',
description: 'Skatteskulder',
section: 'equity_liabilities',
normalBalance: 'credit',
accountRanges: [{ start: '2500', end: '2599' }],
},
{
sruCode: '7370',
description: 'Upplupna kostnader och förutbetalda intäkter',
section: 'equity_liabilities',
normalBalance: 'credit',
accountRanges: [{ start: '2900', end: '2999' }],
},
// ---- Income statement ----
{
sruCode: '7410',
description: 'Nettoomsättning',
section: 'income_statement',
normalBalance: 'credit',
accountRanges: [{ start: '3000', end: '3799' }],
},
{
sruCode: '7412',
description: 'Aktiverat arbete för egen räkning',
section: 'income_statement',
normalBalance: 'credit',
accountRanges: [{ start: '3800', end: '3899' }],
},
{
sruCode: '7413',
description: 'Övriga rörelseintäkter',
section: 'income_statement',
normalBalance: 'credit',
accountRanges: [{ start: '3900', end: '3999' }],
},
{
sruCode: '7411',
description: 'Förändring av lager',
section: 'income_statement',
normalBalance: 'net',
accountRanges: [{ start: '4900', end: '4999' }],
},
{
sruCode: '7511',
description: 'Råvaror och förnödenheter',
section: 'income_statement',
normalBalance: 'debit',
accountRanges: [
{ start: '4000', end: '4499' },
{ start: '4500', end: '4599' },
{ start: '4700', end: '4899' },
],
},
{
sruCode: '7512',
description: 'Handelsvaror',
section: 'income_statement',
normalBalance: 'debit',
accountRanges: [{ start: '4600', end: '4699' }],
},
// CRITICAL: BAS 5000-6999 ALL map to SRU 7513
{
sruCode: '7513',
description: 'Övriga externa kostnader',
section: 'income_statement',
normalBalance: 'debit',
accountRanges: [{ start: '5000', end: '6999' }],
},
{
sruCode: '7514',
description: 'Personalkostnader',
section: 'income_statement',
normalBalance: 'debit',
accountRanges: [{ start: '7000', end: '7699' }],
},
{
sruCode: '7515',
description: 'Av- och nedskrivningar materiella/immateriella',
section: 'income_statement',
normalBalance: 'debit',
accountRanges: [{ start: '7800', end: '7899' }],
},
{
sruCode: '7516',
description: 'Nedskrivningar omsättningstillgångar',
section: 'income_statement',
normalBalance: 'debit',
accountRanges: [{ start: '7700', end: '7799' }],
},
{
sruCode: '7517',
description: 'Övriga rörelsekostnader',
section: 'income_statement',
normalBalance: 'debit',
accountRanges: [{ start: '7900', end: '7999' }],
},
{
sruCode: '7414',
description: 'Resultat från andelar i koncernföretag',
section: 'income_statement',
normalBalance: 'net',
accountRanges: [{ start: '8000', end: '8099' }],
},
{
sruCode: '7415',
description: 'Resultat från andelar i intresseföretag',
section: 'income_statement',
normalBalance: 'net',
accountRanges: [{ start: '8100', end: '8199' }],
},
{
sruCode: '7423',
description: 'Resultat från övriga företag med ägarintresse',
section: 'income_statement',
normalBalance: 'net',
accountRanges: [{ start: '8200', end: '8269' }],
},
{
sruCode: '7416',
description: 'Resultat från övriga finansiella anläggningstillgångar',
section: 'income_statement',
normalBalance: 'net',
accountRanges: [{ start: '8270', end: '8299' }],
},
{
sruCode: '7417',
description: 'Övriga ränteintäkter och liknande',
section: 'income_statement',
normalBalance: 'credit',
accountRanges: [{ start: '8300', end: '8399' }],
},
{
sruCode: '7522',
description: 'Räntekostnader och liknande',
section: 'income_statement',
normalBalance: 'debit',
accountRanges: [{ start: '8400', end: '8499' }],
},
{
sruCode: '7521',
description: 'Nedskrivningar finansiella anläggningstillgångar',
section: 'income_statement',
normalBalance: 'debit',
accountRanges: [{ start: '8500', end: '8599' }],
},
// Bokslutsdispositioner: account numbers per BAS 2020 (verified against
// lib/bookkeeping/bas-data/class-8-financial.ts).
{
sruCode: '7525',
description: 'Avsättning till periodiseringsfond',
section: 'income_statement',
normalBalance: 'debit',
accountRanges: [{ start: '8811', end: '8811' }],
},
{
sruCode: '7420',
description: 'Återföring av periodiseringsfond',
section: 'income_statement',
normalBalance: 'credit',
accountRanges: [{ start: '8819', end: '8819' }],
},
{
sruCode: '7419',
description: 'Mottagna koncernbidrag',
section: 'income_statement',
normalBalance: 'credit',
accountRanges: [{ start: '8820', end: '8820' }],
},
{
sruCode: '7524',
description: 'Lämnade koncernbidrag',
section: 'income_statement',
normalBalance: 'debit',
accountRanges: [{ start: '8830', end: '8830' }],
},
{
sruCode: '7421',
description: 'Förändring av överavskrivningar',
section: 'income_statement',
normalBalance: 'net',
// 8850 = grupp, 8851-8853 = per kategori (immateriella, byggnader, M&I)
accountRanges: [{ start: '8850', end: '8859' }],
},
{
sruCode: '7422',
description: 'Övriga bokslutsdispositioner',
section: 'income_statement',
normalBalance: 'net',
// 8840 = Lämnade gottgörelser, 8860-8899 = övriga
accountRanges: [
{ start: '8840', end: '8840' },
{ start: '8860', end: '8899' },
],
},
{
sruCode: '7528',
description: 'Skatt på årets resultat',
section: 'income_statement',
normalBalance: 'debit',
accountRanges: [{ start: '8900', end: '8989' }],
},
// 7450/7550 (årets resultat vinst/förlust) are calculated, not mapped from accounts
]
/** One mapping per SRU code: pinned by a test in __tests__/ink2-engine.test.ts. */
const MAPPING_BY_CODE = new Map<INK2RSRUCode, INK2AccountMapping>(
INK2R_ACCOUNT_MAPPINGS.map((mapping) => [mapping.sruCode, mapping]),
)
/**
* INK2R posts each shared sign-reclassification rule moves between. The rules
* live in lib/reports/sign-reclassification.ts and are shared with the K2
* iXBRL årsredovisning so both statutory reports present the same balance
* sheet. A test pins that every rule's account range really does map to the
* `from` code below.
*/
const SIGN_RECLASSIFICATION_ROUTES: Record<
SignReclassificationId,
{ from: INK2RSRUCode; to: INK2RSRUCode }
> = {
tax_account_credit_to_liability: { from: '7261', to: '7368' },
tax_liability_debit_to_receivable: { from: '7368', to: '7261' },
vat_liability_debit_to_receivable: { from: '7369', to: '7261' },
}
/**
* Check if an account number falls within a mapping's ranges
*/
export function isAccountInMapping(accountNumber: string, mapping: INK2AccountMapping): boolean {
for (const range of mapping.accountRanges) {
if (accountNumber >= range.start && accountNumber <= range.end) {
if (range.exclude && range.exclude.includes(accountNumber)) {
continue
}
return true
}
}
return false
}
/**
* Truncate to nearest krona (drop öre) per SFL 22 kap. 1 §
*/
function truncateToKrona(value: number): number {
return value >= 0 ? Math.floor(value) : Math.ceil(value)
}
/**
* Slack allowed before a difference counts as a real disagreement. Every INK2
* field is truncated to whole kronor per SFL 22 kap. 1 §, so a few öre of
* truncation residual can accumulate across the form legitimately.
*/
const ROUNDING_TOLERANCE_KR = 2
/**
* Check if the balance sheet totals differ beyond the expected rounding tolerance.
*/
export function checkBalanceWarning(totalAssets: number, totalEquityLiabilities: number): string | null {
const balanceDiff = Math.abs(totalAssets - totalEquityLiabilities)
if (balanceDiff > ROUNDING_TOLERANCE_KR && (totalAssets > 0 || totalEquityLiabilities > 0)) {
return `Balansräkningen är inte i balans. Tillgångar: ${totalAssets} kr, Eget kapital och skulder: ${totalEquityLiabilities} kr (differens: ${balanceDiff} kr).`
}
return null
}
/** Create zero-initialized INK2R rutor */
function createEmptyINK2RRutor(): INK2RRutor {
return {
'7201': 0, '7202': 0, '7214': 0, '7215': 0, '7216': 0, '7217': 0,
'7230': 0, '7231': 0, '7233': 0, '7232': 0, '7234': 0, '7235': 0,
'7241': 0, '7242': 0, '7243': 0, '7244': 0, '7245': 0, '7246': 0,
'7251': 0, '7252': 0, '7261': 0, '7262': 0, '7263': 0,
'7270': 0, '7271': 0, '7281': 0,
'7301': 0, '7302': 0,
'7321': 0, '7322': 0, '7323': 0,
'7331': 0, '7332': 0, '7333': 0,
'7350': 0, '7351': 0, '7352': 0, '7353': 0, '7354': 0,
'7360': 0, '7361': 0, '7362': 0, '7363': 0, '7364': 0,
'7365': 0, '7366': 0, '7367': 0, '7369': 0, '7368': 0, '7370': 0,
'7410': 0, '7411': 0, '7412': 0, '7413': 0,
'7511': 0, '7512': 0, '7513': 0, '7514': 0, '7515': 0, '7516': 0, '7517': 0,
'7414': 0, '7415': 0, '7423': 0, '7416': 0, '7417': 0,
'7521': 0, '7522': 0,
'7524': 0, '7419': 0, '7420': 0, '7525': 0, '7421': 0, '7422': 0,
'7528': 0,
'7450': 0, '7550': 0,
}
}
// Reuse canonical code arrays from types.ts (single source of truth)
const ASSET_CODES = INK2R_ASSET_CODES
const EQUITY_LIABILITY_CODES = INK2R_EQUITY_LIABILITY_CODES
/** One account's contribution to an SRU code, before orientation and truncation. */
interface AccountContribution {
accountNumber: string
accountName: string
/** Raw ledger balance, debit-positive. */
balance: number
}
/** UB per account from a trial balance, debit-positive. */
function toSignedBalances(rows: TrialBalanceRow[]): Map<string, number> {
const balances = new Map<string, number>()
for (const row of rows) {
balances.set(
row.account_number,
(Number(row.closing_debit) || 0) - (Number(row.closing_credit) || 0),
)
}
return balances
}
function findMappingForAccount(accountNumber: string): INK2AccountMapping | null {
for (const mapping of INK2R_ACCOUNT_MAPPINGS) {
if (isAccountInMapping(accountNumber, mapping)) return mapping
}
return null
}
/**
* Orient a raw ledger balance to the amount Skatteverket expects in the field.
* Every INK2R amount is reported positive when the post carries its normal
* balance; costs are positive on the income statement side.
*/
function orientedAmount(balance: number, mapping: INK2AccountMapping): number {
if (mapping.normalBalance === 'debit') return balance
// Credit-normal posts, and 'net' posts where positive means income.
return -balance
}
/**
* Relocate balance sheet accounts whose balance deviates from their post's
* normal side (1630 with a credit is a skatteskuld, 2641 with a debit is a
* fordran). Whole account rows move, so the per-account breakdown stays
* consistent with the post totals; for `net` rules the moved rows sum to the
* deviating net by construction because every account in range moves together.
*/
function applySignReclassifications(
contributions: Map<INK2RSRUCode, AccountContribution[]>,
balanceSheetBalances: ReadonlyMap<string, number>,
warnings: string[],
): void {
for (const rule of SIGN_RECLASSIFICATION_RULES) {
const route = SIGN_RECLASSIFICATION_ROUTES[rule.id]
const moving = new Set(selectReclassifiedAccounts(rule, balanceSheetBalances))
if (moving.size === 0) continue
const source = contributions.get(route.from) ?? []
const moved = source.filter((c) => moving.has(c.accountNumber))
if (moved.length === 0) continue
contributions.set(
route.from,
source.filter((c) => !moving.has(c.accountNumber)),
)
contributions.set(route.to, [...(contributions.get(route.to) ?? []), ...moved])
warnings.push(rule.warning)
}
}
/**
* Whether the resultatavslut has already moved årets resultat into 2099.
*
* Mirrors the predicate generateTrialBalance uses to drop the closing entry: a
* reversed closing entry nets to zero against its storno and has therefore not
* moved anything.
*/
async function isResultClosedIntoEquity(
supabase: SupabaseClient,
companyId: string,
closingEntryId: string | null | undefined,
): Promise<boolean> {
if (!closingEntryId) return false
const { data } = await supabase
.from('journal_entries')
.select('status')
.eq('id', closingEntryId)
.eq('company_id', companyId)
.maybeSingle()
return (data as { status?: string } | null)?.status === 'posted'
}
/**
* Generate INK2 declaration for a fiscal period
*/
export async function generateINK2Declaration(
supabase: SupabaseClient,
companyId: string,
fiscalPeriodId: string
): Promise<INK2Declaration> {
// Fetch fiscal period
const { data: period, error: periodError } = await supabase
.from('fiscal_periods')
.select('*')
.eq('id', fiscalPeriodId)
.eq('company_id', companyId)
.single()
if (periodError || !period) {
throw new Error('Fiscal period not found')
}
// Fetch company settings
const { data: settings } = await supabase
.from('company_settings')
.select('company_name, org_number, entity_type, address_line1, postal_code, city, email')
.eq('company_id', companyId)
.single()
// Resolve entity_type: prefer company_settings, fall back to companies table (NOT NULL, always reliable)
let entityType = settings?.entity_type
if (!entityType) {
const { data: company, error: companyError } = await supabase
.from('companies')
.select('entity_type')
.eq('id', companyId)
.single()
if (companyError) throw new Error(`Failed to resolve entity type: ${companyError.message}`)
entityType = company?.entity_type
}
if (entityType !== 'aktiebolag') {
throw new Error('INK2 declaration is only for aktiebolag (limited company)')
}
// The balance sheet reads the closed books, the income statement the
// pre-closing books. See the module docblock for why the two differ.
const [taxAdjustments, closedTrialBalance, preClosingTrialBalance, resultClosedIntoEquity] =
await Promise.all([
loadTaxAdjustmentSnapshot(supabase, companyId, fiscalPeriodId),
generateTrialBalance(supabase, companyId, fiscalPeriodId, { closingEntry: 'include' }),
generateTrialBalance(supabase, companyId, fiscalPeriodId, {
closingEntry: 'exclude-final',
}),
isResultClosedIntoEquity(supabase, companyId, period.closing_entry_id as string | null),
])
const balanceSheetBalances = toSignedBalances(closedTrialBalance.rows)
const incomeBalances = toSignedBalances(preClosingTrialBalance.rows)
const accountNameMap = new Map<string, string>()
for (const row of [...closedTrialBalance.rows, ...preClosingTrialBalance.rows]) {
accountNameMap.set(row.account_number, row.account_name)
}
const warnings: string[] = []
// Collect each account's contribution to its SRU code, keeping the raw
// balance so a reclassified account can be re-oriented under its new code.
const contributions = new Map<INK2RSRUCode, AccountContribution[]>()
const allAccountNumbers = new Set([
...balanceSheetBalances.keys(),
...incomeBalances.keys(),
])
for (const accountNumber of allAccountNumbers) {
// Skip account 8999: årets resultat is calculated
if (accountNumber === '8999') continue
const mapping = findMappingForAccount(accountNumber)
if (!mapping) {
// BAS accounts 4500-4599, 4700-4899, and 1300-1310 have no standard SRU
// mapping. These are unusual and may indicate custom accounts.
const hasBalance =
Math.abs(balanceSheetBalances.get(accountNumber) ?? 0) >= 0.01
|| Math.abs(incomeBalances.get(accountNumber) ?? 0) >= 0.01
const classChar = accountNumber.charAt(0)
if (hasBalance && classChar >= '1' && classChar <= '8') {
// Only warn for standard BAS range accounts that weren't mapped
warnings.push(`Konto ${accountNumber} (${accountNameMap.get(accountNumber) || 'okänt'}) kunde inte mappas till ett SRU-fält.`)
}
continue
}
const balance =
mapping.section === 'income_statement'
? incomeBalances.get(accountNumber) ?? 0
: balanceSheetBalances.get(accountNumber) ?? 0
if (Math.abs(balance) < 0.01) continue
const list = contributions.get(mapping.sruCode)
const contribution: AccountContribution = {
accountNumber,
accountName: accountNameMap.get(accountNumber) || `Konto ${accountNumber}`,
balance,
}
if (list) {
list.push(contribution)
} else {
contributions.set(mapping.sruCode, [contribution])
}
}
applySignReclassifications(contributions, balanceSheetBalances, warnings)
// Initialize INK2R rutor and breakdown
const ink2r = createEmptyINK2RRutor()
const allCodes = Object.keys(ink2r) as INK2RSRUCode[]
const breakdown = {} as INK2Declaration['breakdown']
for (const code of allCodes) {
breakdown[code] = { accounts: [], total: 0 }
}
for (const [code, list] of contributions) {
const mapping = MAPPING_BY_CODE.get(code)
if (!mapping) continue
for (const contribution of list) {
const amount = orientedAmount(contribution.balance, mapping)
ink2r[code] += amount
breakdown[code].accounts.push({
accountNumber: contribution.accountNumber,
accountName: contribution.accountName,
amount: truncateToKrona(amount),
})
}
}
// Truncate all INK2R rutor to whole kronor
for (const code of allCodes) {
ink2r[code] = truncateToKrona(ink2r[code])
breakdown[code].total = ink2r[code]
}
// Calculate totals
const totalAssets = ASSET_CODES.reduce((sum, code) => sum + ink2r[code], 0)
const totalEquityLiabilities = EQUITY_LIABILITY_CODES.reduce((sum, code) => sum + ink2r[code], 0)
// Operating result: revenue minus costs (costs are positive per Skatteverket convention)
const operatingResult =
ink2r['7410'] + ink2r['7411'] + ink2r['7412'] + ink2r['7413']
- ink2r['7511'] - ink2r['7512'] - ink2r['7513'] - ink2r['7514']
- ink2r['7515'] - ink2r['7516'] - ink2r['7517']
// Financial items: income minus costs
const financialItems =
ink2r['7414'] + ink2r['7415'] + ink2r['7423'] + ink2r['7416'] + ink2r['7417']
- ink2r['7521'] - ink2r['7522']
// Bokslutsdispositioner: subtract debit-normal, add credit-normal and net
const bokslutsdispositioner =
- ink2r['7524'] + ink2r['7419'] + ink2r['7420'] - ink2r['7525']
+ ink2r['7421'] + ink2r['7422']
// Result before tax
const resultBeforeTax = operatingResult + financialItems + bokslutsdispositioner
// Result after tax (7528 is positive, subtract it)
const aretsResultat = resultBeforeTax - ink2r['7528']
// Set årets resultat: vinst (7450) or förlust (7550)
if (aretsResultat >= 0) {
ink2r['7450'] = aretsResultat
ink2r['7550'] = 0
} else {
ink2r['7450'] = 0
ink2r['7550'] = Math.abs(aretsResultat)
}
// During an open fiscal year 2099 has no balance yet: the result exists only
// as the net of the income statement accounts, so add it to make the balance
// sheet tie out. Once the resultatavslut is posted, 7302 already carries it
// via 2099 and adding it again would double-count årets resultat.
const adjustedEquityLiabilities = resultClosedIntoEquity
? totalEquityLiabilities
: totalEquityLiabilities + aretsResultat
// Fiscal year dates as YYYYMMDD
const fyStart = (period.period_start as string).replace(/-/g, '')
const fyEnd = (period.period_end as string).replace(/-/g, '')
// Build INK2 (huvudblankett)
// Auto-derive from INK2S result and the saved tax-only adjustments.
// 7528 is already positive per Skatteverket convention
const taxAmount = ink2r['7528']
// INK2/SRU amounts are declared in whole kronor with ören omitted. Use the
// same whole-krona values in both the adjustment fields and the tax result
// so the worksheet remains internally consistent.
const nonDeductibleExpenses = Math.trunc(taxAdjustments.nonDeductibleExpenses)
const nonTaxableIncome = Math.trunc(taxAdjustments.nonTaxableIncome)
const taxableResult =
aretsResultat + taxAmount
+ nonDeductibleExpenses - nonTaxableIncome
const ink2: INK2Rutor = {
'7011': fyStart,
'7012': fyEnd,
'7104': taxableResult >= 0 ? taxableResult : 0,
'7114': taxableResult < 0 ? Math.abs(taxableResult) : 0,
}
// Build INK2S (skattemässiga justeringar, auto-derived basics only)
const ink2s: INK2SRutor = {
'7011': fyStart,
'7012': fyEnd,
'7650': aretsResultat >= 0 ? aretsResultat : 0,
'7750': aretsResultat < 0 ? Math.abs(aretsResultat) : 0,
'7651': taxAmount, // Skatt (ej avdragsgill)
'7653': nonDeductibleExpenses,
'7754': nonTaxableIncome,
'8020': taxableResult >= 0 ? taxableResult : 0,
'8021': taxableResult < 0 ? Math.abs(taxableResult) : 0,
}
// Add warnings
if (!(period as FiscalPeriod).is_closed) {
warnings.push('Räkenskapsåret är inte stängt; deklarationen kan genereras, men siffrorna kan ändras om fler bokföringar görs.')
}
if (totalAssets === 0 && totalEquityLiabilities === 0 && ink2r['7410'] === 0) {
warnings.push('Inga bokförda transaktioner hittades för perioden.')
}
const balanceWarning = checkBalanceWarning(totalAssets, adjustedEquityLiabilities)
if (balanceWarning) {
warnings.push(balanceWarning)
}
// Cross-surface self-check. When the year is closed, the resultaträkning the
// form reports must equal the årets resultat the books actually carry on 2099,
// which is also the figure the fastställda årsredovisningen shows. Mirrors the
// equivalent check in lib/bokslut/ixbrl/k2-mapper.ts so both statutory reports
// catch the same disagreement.
//
// This is the alarm that was missing: when INK2R reported 0 kr against a
// booked result of 469 542 kr, nothing warned, because the balance sheet
// still tied out on its own. A customer found it instead.
if (resultClosedIntoEquity) {
const bookedResult = truncateToKrona(-(balanceSheetBalances.get('2099') ?? 0))
const declaredResult = aretsResultat
if (Math.abs(bookedResult - declaredResult) > ROUNDING_TOLERANCE_KR) {
warnings.push(
`Årets resultat enligt resultaträkningen (${declaredResult} kr) stämmer inte med det bokförda resultatet på konto 2099 (${bookedResult} kr). Deklarationen stämmer då inte med det fastställda bokslutet.`,
)
}
}
return {
fiscalYear: {
id: period.id,
name: period.name,
start: period.period_start,
end: period.period_end,
isClosed: period.is_closed,
},
ink2,
ink2r,
ink2s,
breakdown,
totals: {
totalAssets,
totalEquityLiabilities: adjustedEquityLiabilities,
operatingResult,
aretsResultat,
},
companyInfo: {
companyName: settings?.company_name || 'Okänt företag',
orgNumber: settings?.org_number || null,
addressLine1: settings?.address_line1 || null,
postalCode: settings?.postal_code || null,
city: settings?.city || null,
email: settings?.email || null,
},
warnings,
}
}