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.
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
One line per decision: `[YYYY-MM-DD] <decision>: <why>`. 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.
|
||||
|
||||
|
||||
@@ -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>([^<]*)<\/Organisationsnummer>/)
|
||||
// Guard against a vacuous assertion: the element must actually be there.
|
||||
expect(match, `input form ${form}: no <Organisationsnummer> 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>([^<]*)<\/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', () => {
|
||||
|
||||
@@ -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('<Organisationsnummer>165560125790</Organisationsnummer>')
|
||||
expect(xml).toContain('<UppgiftslamnarId>165560125790</UppgiftslamnarId>')
|
||||
}
|
||||
)
|
||||
|
||||
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('<Organisationsnummer>165560125791</Organisationsnummer>')
|
||||
})
|
||||
})
|
||||
@@ -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('<?xml version="1.0" encoding="UTF-8"?>')
|
||||
lines.push('<Skatteverket xmlns="http://xmls.skatteverket.se/se/skatteverket/ai/instans/infoForBeskworksgivku/1.0"')
|
||||
@@ -73,7 +93,7 @@ export function generateKU10Xml(
|
||||
// Blankettgemensamt
|
||||
lines.push(' <Blankettgemensamt>')
|
||||
lines.push(` <Uppgiftslamnare>`)
|
||||
lines.push(` <UppgijftslamnareId>${orgNr}</UppgijftslamnareId>`)
|
||||
lines.push(` <UppgiftslamnarId>${orgNr}</UppgiftslamnarId>`)
|
||||
lines.push(` <NamnUppgiftslamnare>${escapeXml(company.companyName)}</NamnUppgiftslamnare>`)
|
||||
lines.push(` </Uppgiftslamnare>`)
|
||||
lines.push(' </Blankettgemensamt>')
|
||||
|
||||
Reference in New Issue
Block a user