* 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>
250 lines
9.4 KiB
TypeScript
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: [],
|
|
}
|
|
}
|