Files
accounted/lib/bokslut/ixbrl/__tests__/fixtures.ts
T
16fbcefbbc feat(invariants): shared format contracts + upgrade-path CI (#1364)
* feat(invariants): centralise shared format contracts, reconcile the org-number paths

The same format rules were written out independently across the codebase, and
where they disagreed the disagreement was invisible until a filing failed.

Worst case, now fixed: four Skatteverket- and Bolagsverket-bound export paths
each had their own idea of a valid organisationsnummer.

  lib/skatteverket/format.ts      strip '-' only      threw on any input with a space
  lib/salary/ku/ku10-generator.ts replace('-', '')    first hyphen only, spaces survived
  lib/salary/agi/xml-generator.ts strip non-digits    stray letters passed the length check
  lib/bokslut/ixbrl/validate      /^\d{6}-?\d{4}$/    rejected the 12-digit form, no Luhn

A company stored with a space or in 12-digit form could file AGI all year and
then fail at the arsredovisning deadline with a message that did not say why.

lib/invariants/ now owns account number, ISO date, four-digit fiscal year and
org number, each with the rationale recorded next to the rule. normalizeOrgNumber
moves here from lib/company-lookup/ and isSaneDateString from lib/utils.ts; both
old paths re-export, so no caller changes. lib/api/schemas.ts builds its
primitives on the module, so ~100 schemas inherit any correction.

The arsredovisning check-digit verdict is a warn, not an error: a wrong Luhn
digit is almost certainly a typo worth surfacing, but whether every org number
Bolagsverket accepts satisfies Luhn is a Swedish domain question we have not
verified against a primary source, and an error there blocks Skicka in. We do
not block a statutory filing on an unverified assumption.

KU10 still passes a 12-digit stored org number through unfolded. That is
pre-existing, and whether the KU10 schema wants 10 or 12 digits is not covered
by the swedish-payroll skill, so it is pinned by a test rather than changed
silently.

Guard 8 (hand-rolled-invariant) tracks the remaining 114 inline copies as a
ratchet that may only go down, same mechanism as the roundOre guard.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(ci): add an upgrade-path job that applies new migrations against real data

The pg-real job applies all 548 migrations to an EMPTY database. Empty means
zero rows, so a migration that adds a NOT NULL, adds a CHECK, creates a unique
index or backfills passes trivially in CI and can still fail on production,
where the rows exist. CI proved that a fresh install works; nothing proved that
an existing install upgrades.

The new pg-upgrade job: apply the schema as it stands at the merge base, seed a
small real company (three posted verifikat, balanced lines, one ore-level
amount), then apply ONLY the migrations this PR adds, then assert the data
survived (entries still posted, lines intact, ledger still balances, ore
unchanged, voucher numbers sequential). A PR with no migration no-ops.

Verified locally against supabase/postgres:15.8.1.060 rather than assumed, with
three deliberately bad migrations:

  rescale money on posted lines   empty: would pass   seeded: ERROR (immutability trigger)
  CHECK violating the ore row     empty: exit 0       seeded: exit 3
  NOT NULL on a populated column  empty: exit 0       seeded: exit 3

Base migrations are read out of the merge-base git tree, not the working tree,
so a PR that edits an already-shipped migration still gets the original applied
and the edit surfaces as a failure here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: record the invariants and upgrade-CI decisions

Two entries covering what this PR changes and, more importantly, the calls that
are not obvious from the diff: why the arsredovisning check-digit verdict is a
warning rather than an error, why KU10's 12-digit passthrough is pinned instead
of fixed, and why the ROT/RUT brf org-number schemas stay on their own rule.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(test): mark the upgrade fixture as CI-only, never a production template

The fixture writes posted journal_entries and their lines directly, bypassing
the engine and the atomic commit RPC. That is the only way to hand a migration
pre-existing posted rows to break, and it is safe against a throwaway CI
database, but it reads like a sanctioned pattern to anyone who finds it later.

Says so explicitly, with the reason it is confined here (no voucher sequence to
keep gapless, no retention obligation on a database destroyed with the job) and
a pointer back to Hard Rule 2 for anything touching a real database.

Raised by the Swedish compliance review bot on #1364.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 16:30:14 +02:00

250 lines
9.4 KiB
TypeScript

/**
* Shared deterministic fixture for iXBRL generator tests: a small AB with a
* balanced BR (380 000 kr) and an RR netting to 120 000 kr, mapped through
* the real k2-mapper so concept amounts stay consistent with the mapping
* rules under test.
*
* Each year is a realistic post-bokslut TrialBalancePair:
* - `full`: the booked state AFTER the year-end closing entry: every
* class 3-8 account is zeroed (equal debit/credit churn) and 2099
* carries the year's result.
* - `preClosing`: the same year WITHOUT the closing entry
* (excludeFinalClosingEntry): RR accounts still open, 2099 only carries
* the prior-year churn from the resultatdisposition entry.
*/
import { mapTrialBalancesToK2, type TrialBalancePair, type TrialBalanceRowLike } from '../k2-mapper'
import type { IxbrlArsredovisningInput } from '../types'
const row = (
account: string,
name: string,
debit: number,
credit: number,
): TrialBalanceRowLike => ({
account_number: account,
account_name: name,
closing_debit: debit,
closing_credit: credit,
})
/** Current year WITHOUT the closing entry: RR accounts open. 2099 nets to 0
* (prior-year result IB balanced away by the disposition entry). */
const CURRENT_PRE_CLOSING: TrialBalanceRowLike[] = [
row('1220', 'Inventarier', 80_000, 0),
row('1229', 'Ack avskrivningar', 0, 20_000),
row('1510', 'Kundfordringar', 50_000, 0),
row('1930', 'Bank', 270_000, 0),
row('2081', 'Aktiekapital', 0, 25_000),
row('2091', 'Balanserad vinst', 0, 100_000),
row('2099', 'Årets resultat', 40_000, 40_000),
row('2110', 'Periodiseringsfond', 0, 40_000),
row('2440', 'Leverantörsskulder', 0, 30_000),
row('2510', 'Skatteskulder', 0, 35_000),
row('2610', 'Utgående moms', 0, 20_000),
row('2941', 'Upplupna sociala avgifter', 0, 10_000),
row('3010', 'Försäljning', 0, 1_000_000),
row('4010', 'Inköp', 200_000, 0),
row('5010', 'Lokalhyra', 100_000, 0),
row('7010', 'Löner', 400_000, 0),
row('7510', 'Arbetsgivaravgifter', 125_660, 0),
row('7832', 'Avskrivningar', 20_000, 0),
row('8310', 'Ränteintäkter', 0, 1_000),
row('8410', 'Räntekostnader', 4_000, 0),
row('8811', 'Avsättning periodiseringsfond', 10_000, 0),
row('8910', 'Skatt', 21_340, 0),
]
/** Current year WITH the closing entry: class 3-8 zeroed, 2099 = 120 000. */
const CURRENT_FULL: TrialBalanceRowLike[] = [
row('1220', 'Inventarier', 80_000, 0),
row('1229', 'Ack avskrivningar', 0, 20_000),
row('1510', 'Kundfordringar', 50_000, 0),
row('1930', 'Bank', 270_000, 0),
row('2081', 'Aktiekapital', 0, 25_000),
row('2091', 'Balanserad vinst', 0, 100_000),
row('2099', 'Årets resultat', 40_000, 160_000),
row('2110', 'Periodiseringsfond', 0, 40_000),
row('2440', 'Leverantörsskulder', 0, 30_000),
row('2510', 'Skatteskulder', 0, 35_000),
row('2610', 'Utgående moms', 0, 20_000),
row('2941', 'Upplupna sociala avgifter', 0, 10_000),
row('3010', 'Försäljning', 1_000_000, 1_000_000),
row('4010', 'Inköp', 200_000, 200_000),
row('5010', 'Lokalhyra', 100_000, 100_000),
row('7010', 'Löner', 400_000, 400_000),
row('7510', 'Arbetsgivaravgifter', 125_660, 125_660),
row('7832', 'Avskrivningar', 20_000, 20_000),
row('8310', 'Ränteintäkter', 1_000, 1_000),
row('8410', 'Räntekostnader', 4_000, 4_000),
row('8811', 'Avsättning periodiseringsfond', 10_000, 10_000),
row('8910', 'Skatt', 21_340, 21_340),
]
const PREVIOUS_PRE_CLOSING: TrialBalanceRowLike[] = [
row('1220', 'Inventarier', 80_000, 0),
row('1229', 'Ack avskrivningar', 0, 12_000),
row('1930', 'Bank', 185_000, 0),
row('2081', 'Aktiekapital', 0, 25_000),
row('2091', 'Balanserad vinst', 0, 60_000),
row('2110', 'Periodiseringsfond', 0, 30_000),
row('2440', 'Leverantörsskulder', 0, 25_000),
row('2510', 'Skatteskulder', 0, 15_000),
row('2610', 'Utgående moms', 0, 8_000),
row('2941', 'Upplupna sociala avgifter', 0, 50_000),
row('3010', 'Försäljning', 0, 500_000),
row('4010', 'Inköp', 200_000, 0),
row('5010', 'Lokalhyra', 80_000, 0),
row('7010', 'Löner', 150_000, 0),
row('7832', 'Avskrivningar', 8_000, 0),
row('8410', 'Räntekostnader', 2_000, 0),
row('8910', 'Skatt', 20_000, 0),
]
const PREVIOUS_FULL: TrialBalanceRowLike[] = [
row('1220', 'Inventarier', 80_000, 0),
row('1229', 'Ack avskrivningar', 0, 12_000),
row('1930', 'Bank', 185_000, 0),
row('2081', 'Aktiekapital', 0, 25_000),
row('2091', 'Balanserad vinst', 0, 60_000),
row('2099', 'Årets resultat', 0, 40_000),
row('2110', 'Periodiseringsfond', 0, 30_000),
row('2440', 'Leverantörsskulder', 0, 25_000),
row('2510', 'Skatteskulder', 0, 15_000),
row('2610', 'Utgående moms', 0, 8_000),
row('2941', 'Upplupna sociala avgifter', 0, 50_000),
row('3010', 'Försäljning', 500_000, 500_000),
row('4010', 'Inköp', 200_000, 200_000),
row('5010', 'Lokalhyra', 80_000, 80_000),
row('7010', 'Löner', 150_000, 150_000),
row('7832', 'Avskrivningar', 8_000, 8_000),
row('8410', 'Räntekostnader', 2_000, 2_000),
row('8910', 'Skatt', 20_000, 20_000),
]
export const CURRENT: TrialBalancePair = {
full: CURRENT_FULL,
preClosing: CURRENT_PRE_CLOSING,
}
export const PREVIOUS: TrialBalancePair = {
full: PREVIOUS_FULL,
preClosing: PREVIOUS_PRE_CLOSING,
}
export function makeInput(): IxbrlArsredovisningInput {
const mapping = mapTrialBalancesToK2(CURRENT, PREVIOUS)
return {
company: { name: 'Testbolaget AB', orgNumber: '556999-9997', city: 'Sundsvall' },
period: { start: '2025-01-01', end: '2025-12-31' },
previousPeriod: { start: '2024-01-01', end: '2024-12-31' },
isFirstFiscalYear: false,
rr: mapping.rr,
br: mapping.br,
totals: mapping.totals,
forvaltningsberattelse: {
allmantOmVerksamheten:
'Bolaget bedriver konsultverksamhet inom IT.\n\nBolaget har sitt säte i Sundsvall.',
vasentligaHandelser: 'Inga väsentliga händelser har inträffat under räkenskapsåret.',
// Rows 0/1 mirror the mapper outputs (duplicate facts with the RR must
// be value-identical, TA §2.7.3): same override build-input applies.
flerarsoversikt: [
{
year: '2025',
nettoomsattning: mapping.rr['Nettoomsattning'].current,
resultatEfterFinansiellaPoster: mapping.totals.resultatEfterFinansiellaPoster.current,
soliditetPct: 64.5,
},
{
year: '2024',
nettoomsattning: mapping.rr['Nettoomsattning'].previous ?? 0,
resultatEfterFinansiellaPoster:
mapping.totals.resultatEfterFinansiellaPoster.previous ?? 0,
soliditetPct: 49.4,
},
{
year: '2023',
nettoomsattning: 300_000,
resultatEfterFinansiellaPoster: 25_000,
soliditetPct: 41.0,
},
],
flerarsPerioder: [
{ start: '2025-01-01', end: '2025-12-31' },
{ start: '2024-01-01', end: '2024-12-31' },
{ start: '2023-01-01', end: '2023-12-31' },
],
egetKapital: {
aktiekapital: { ib: 25_000, ub: 25_000 },
balanseratResultat: { ib: 60_000, ub: 100_000 },
aretsResultat: { ib: 40_000, ub: 120_000 },
totalt: { ib: 125_000, ub: 245_000 },
ovrigaPoster: { ib: 0, ub: 0 },
balanserasINyRakning: 40_000,
utdelning: 0,
forandringAktiekapital: 0,
ovrigForandringBalanserat: 0,
aretsResultatRorelse: 120_000,
},
resultatdisposition: {
balanseratResultat: 100_000,
overkursfond: 0,
aretsResultat: 120_000,
summa: 220_000,
utdelning: 0,
balanserasINyRakning: 220_000,
kommentar: 'Styrelsen föreslår att årets resultat balanseras i ny räkning.',
},
},
noter: [
{
number: 1,
title: 'Redovisnings- och värderingsprinciper',
body: 'Årsredovisningen är upprättad i enlighet med Årsredovisningslagen och Bokföringsnämndens allmänna råd BFNAR 2016:10 Årsredovisning i mindre företag (K2).',
},
{ number: 2, title: 'Medelantal anställda', body: 'Medelantalet anställda har uppgått till 2.' },
{ number: 3, title: 'Långfristiga skulder', body: 'Inga skulder förfaller senare än fem år efter balansdagen.' },
{ number: 4, title: 'Ställda säkerheter', body: 'Inga.' },
{ number: 5, title: 'Eventualförpliktelser', body: 'Inga.' },
{
number: 6,
title: 'Koncernförhållanden',
body: 'Moderföretag: Moderbolaget AB.',
},
],
disclosures: {
longTermDebtOverFiveYears: 0,
securitiesPledged: 'Inga.',
contingentLiabilities: 'Inga.',
parentCompany: 'Moderföretag: Moderbolaget AB.',
},
medelantalAnstallda: { current: 2, previous: 1 },
underskrifter: {
ort: 'Sundsvall',
dateringsdatum: '2026-02-20',
signers: [
{ firstName: 'Karl', lastName: 'Karlsson', role: 'Styrelseledamot', signedDate: '2026-02-20' },
{
firstName: 'Karin',
lastName: 'Olsson',
role: 'Verkställande direktör',
signedDate: '2026-02-21',
},
],
harVd: true,
},
faststallelseintyg: {
arsstammaDatum: '2026-03-15',
resultatdispositionOutcome: 'proposal_approved',
resultatdispositionDecision: null,
signerFirstName: 'Karl',
signerLastName: 'Karlsson',
signerRole: 'Styrelseledamot',
genereratDatum: '2026-02-25',
},
programvara: { namn: 'Accounted - Accounted', version: '2026.1' },
entryPointId: 'k2-ab-risbs-2024-09-12',
warnings: [],
}
}