From 0c3864cae55ecac0caffd19deda462e4945fca1f Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Thu, 13 Aug 2026 15:39:49 +0200 Subject: [PATCH] fix(payroll): normalize KU10 organisation numbers (#1583) Normalize KU10 employer identities to Skatteverket's 12-digit schema format, validate the structural XSD contract, correct FK201's XML element name, and add focused sourced tests. Resolves #1410. --- DECISIONS.md | 1 + .../__tests__/org-number-cross-path.test.ts | 24 +++----- lib/salary/__tests__/ku10-generator.test.ts | 57 +++++++++++++++++++ lib/salary/ku/ku10-generator.ts | 30 ++++++++-- 4 files changed, 92 insertions(+), 20 deletions(-) create mode 100644 lib/salary/__tests__/ku10-generator.test.ts diff --git a/DECISIONS.md b/DECISIONS.md index 01048c7f..49da3b04 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -3,6 +3,7 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and humans when a non-obvious choice is made (approach picked over an alternative, dependency declined, action stopped by a CLAUDE.md rule). Read before re-litigating a past decision. [2026-08-13] E-invoice product-truth correction covers the MCP workflow skills and the MCP-exposed swedish-invoice-compliance atom: the atom's "for Accounted e-invoice generation" heading made the same unsupported product claim as issue #1577, so all active guidance now directs external delivery followed by gnubok_mark_invoice_as_sent; Peppol implementation remains tracked in #546. +[2026-08-13] KU10 organisation identities normalize to the 12-digit `16`-prefixed form and receive structural XSD validation without a new Luhn gate: Skatteverket KU schema 12.0 requires that shape, names FK201 `UppgiftslamnarId`, and explicitly leaves check-digit validation outside the schema. [2026-08-03] Issue #317 derives löneväxling pension and SLP at the shared salary-entry boundary from the frozen salary_runs.calculation_params rate snapshot: this fixes dashboard, MCP, and v1 booking without a schema migration, and prevents a live config change from altering an already reviewed run between calculation and posting. diff --git a/lib/invariants/__tests__/org-number-cross-path.test.ts b/lib/invariants/__tests__/org-number-cross-path.test.ts index b98e444a..b640482a 100644 --- a/lib/invariants/__tests__/org-number-cross-path.test.ts +++ b/lib/invariants/__tests__/org-number-cross-path.test.ts @@ -22,6 +22,7 @@ import type { IxbrlArsredovisningInput } from '@/lib/bokslut/ixbrl/types' */ const AB_10 = '5560125790' +const AB_KU_12 = '165560125790' /** The input forms a Swedish user or a provider API actually produces. */ const EQUIVALENT_FORMS = ['5560125790', '556012-5790', '556012 5790', '165560125790'] @@ -131,33 +132,25 @@ describe('org number: the four export paths agree', () => { expect(identities[0]).toBe('165560125790') }) - it('emits a separator-free identity into the KU10 file', () => { + it('emits the schema-required 12-digit identity into the KU10 file', () => { for (const form of TEN_DIGIT_FORMS) { const xml = generateKU10Xml(ku10CompanyFixture(form), []) const match = xml.match(/([^<]*)<\/Organisationsnummer>/) // Guard against a vacuous assertion: the element must actually be there. expect(match, `input form ${form}: no in output`).not.toBeNull() - expect(match?.[1], `input form ${form}`).toBe(AB_10) + expect(match?.[1], `input form ${form}`).toBe(AB_KU_12) } }) /** - * OPEN QUESTION, deliberately pinned rather than changed. - * - * KU10 strips separators but does not fold the 12-digit form down to the - * canonical 10 digits, so a company stored as `165560125790` files with 12 - * digits. Whether Skatteverket's KU10 schema wants 10 or 12 here is a Swedish - * domain question that the `swedish-payroll` skill does not cover, and - * CLAUDE.md forbids answering it from training data. - * - * This is pre-existing behaviour (the old `replace('-', '')` did the same); - * this test pins it so the answer, when we get it, is a deliberate change with - * a failing test to update rather than a silent drift. + * Skatteverket's KU 12.0 XSD defines OrganisationsnummerTYPE as a 12-digit + * value with the `16` prefix. The official 2026 KU10 examples use that form. + * Source: https://www.skatteverket.se/foretag/skatterochavdrag/kontrolluppgifter/testtjanstochtekniskbeskrivning.4.233f91f71260075abe8800073614.html */ - it('PINNED: KU10 passes a 12-digit stored org number through unfolded', () => { + it('keeps a stored 12-digit KU organisation identity unchanged', () => { const xml = generateKU10Xml(ku10CompanyFixture('165560125790'), []) const match = xml.match(/([^<]*)<\/Organisationsnummer>/) - expect(match?.[1]).toBe('165560125790') + expect(match?.[1]).toBe(AB_KU_12) }) it.each([ @@ -167,6 +160,7 @@ describe('org number: the four export paths agree', () => { expect(redovisareRejects(bad), 'SRU redovisare conversion').toBe(true) expect(ixbrlRejects(bad), 'årsredovisning preflight').toBe(true) expect(agiRejects(bad), 'AGI generator').toBe(true) + expect(() => generateKU10Xml(ku10CompanyFixture(bad), []), 'KU10 generator').toThrow() }) it('surfaces a bad check digit without blocking the filing', () => { diff --git a/lib/salary/__tests__/ku10-generator.test.ts b/lib/salary/__tests__/ku10-generator.test.ts new file mode 100644 index 00000000..7d7f4794 --- /dev/null +++ b/lib/salary/__tests__/ku10-generator.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest' +import { generateKU10Xml } from '@/lib/salary/ku/ku10-generator' + +function companyFixture(orgNumber: string) { + return { + orgNumber, + companyName: 'Testbolaget AB', + year: 2026, + contactName: 'Test Testsson', + contactPhone: '0700000000', + contactEmail: 'test@example.com', + } +} + +/** + * Skatteverket KU schema 12.0 defines OrganisationsnummerTYPE as a 12-digit + * value with prefix `16`. UppgiftslamnarId is the exact FK201 element name. + * The XSD intentionally does not validate the identity's check digit. + * Source: https://www.skatteverket.se/foretag/skatterochavdrag/kontrolluppgifter/testtjanstochtekniskbeskrivning.4.233f91f71260075abe8800073614.html + */ +describe('generateKU10Xml: Skatteverket KU 12.0 identity contract', () => { + it.each(['5560125790', '556012-5790', '556012 5790', '165560125790'])( + 'emits %s as a 12-digit organisation identity', + (orgNumber) => { + const xml = generateKU10Xml(companyFixture(orgNumber), []) + + expect(xml).toContain('165560125790') + expect(xml).toContain('165560125790') + } + ) + + it('uses the schema element name instead of the misspelled legacy tag', () => { + const xml = generateKU10Xml(companyFixture('5560125790'), []) + + expect(xml).not.toContain('UppgijftslamnareId') + expect(xml).not.toContain('UppgiftslamnareId') + expect(xml).toContain('UppgiftslamnarId') + }) + + it.each([ + ['', 'missing'], + ['55601', 'too short'], + ['5560125790x', 'contains a stray character'], + ['195560125790', 'has a non-organisation century prefix'], + ['1100125790', 'has an invalid organisation-number group digit'], + ])('rejects %s when it is %s', (orgNumber) => { + expect(() => generateKU10Xml(companyFixture(orgNumber), [])).toThrow( + /organisationsnumret måste innehålla 10 siffror eller 12 siffror med prefixet 16/ + ) + }) + + it('does not add check-digit validation that the KU schema does not perform', () => { + const xml = generateKU10Xml(companyFixture('5560125791'), []) + + expect(xml).toContain('165560125791') + }) +}) diff --git a/lib/salary/ku/ku10-generator.ts b/lib/salary/ku/ku10-generator.ts index e91805bf..b932b0f6 100644 --- a/lib/salary/ku/ku10-generator.ts +++ b/lib/salary/ku/ku10-generator.ts @@ -40,6 +40,29 @@ export interface KU10CompanyData { contactEmail: string } +const KU_ORG_NUMBER_PATTERN = /^16\d{2}[2-9]\d{7}$/ + +/** + * Normalize the employer identity to the 12-digit format required by the KU + * schema. Skatteverket's KU 12.0 XSD defines OrganisationsnummerTYPE with the + * `16` prefix and does not validate the check digit. + * + * Source: https://www.skatteverket.se/foretag/skatterochavdrag/kontrolluppgifter/testtjanstochtekniskbeskrivning.4.233f91f71260075abe8800073614.html + */ +function normalizeKUOrgNumber(raw: string): string { + const cleaned = stripOrgNumberFormatting(raw) + const normalized = /^\d{10}$/.test(cleaned) ? `16${cleaned}` : cleaned + + if (!KU_ORG_NUMBER_PATTERN.test(normalized)) { + throw new Error( + 'KU10 kan inte genereras: organisationsnumret måste innehålla 10 siffror ' + + 'eller 12 siffror med prefixet 16.' + ) + } + + return normalized +} + /** * Generate KU10 XML for all employees for a calendar year. * @@ -50,10 +73,7 @@ export function generateKU10Xml( employees: KU10EmployeeData[] ): string { const lines: string[] = [] - // Shared rule (lib/invariants/org-number.ts). The previous - // `replace('-', '')` removed only the FIRST hyphen and left spaces intact, so - // an org number entered as "556012 5790" reached Skatteverket with a space in it. - const orgNr = stripOrgNumberFormatting(company.orgNumber) + const orgNr = normalizeKUOrgNumber(company.orgNumber) lines.push('') lines.push('') lines.push(` `) - lines.push(` ${orgNr}`) + lines.push(` ${orgNr}`) lines.push(` ${escapeXml(company.companyName)}`) lines.push(` `) lines.push(' ')