fix(arsredovisning): unblock the signing flow, accept foreign parent org nr, explain Fortnox underlag failures (#1738)

Batch from a real migration walkthrough (Fortnox -> Accounted, 2026-08-20):

- Årsredovisning: the "Låst version" select was empty with no explanation
  because the only version was a draft and "Lås version för underskrift"
  is disabled while the four Lagstadgade upplysningar checkboxes and the
  content confirmation count as blockers. The select is now disabled with a
  hint that names the blocker count and links to Fullständighetskontroll,
  the four AR-NOTE-*-UNCONFIRMED issues carry remediation text, the lock
  button explains why it is grey, and "Markera som signerad" says what it
  still needs (locked version, bevisreferens, date).
- Moderföretagets org.nr accepts a foreign registration identifier
  (CHE-123.456.789, HRB 12345, 923 609 016); personnummer shapes stay out.
- Fortnox underlag discovery: log status, body and Fortnox's message on
  failure, show the message in the UI, treat a 400 with behörighet/scope
  text as scopes-required, and fall back to an unfiltered
  voucherfileconnections list when the financialyear filter answers 400.
- Kontomapping: the Momskod column had min-w only; table-fixed collapsed it
  and its selects overflowed into Konfidens. Real w-72 now.
- SIE import warnings pluralise correctly for one skipped voucher; the
  Verifikationsserie option says the source series is preserved.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-20 13:25:06 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5
parent 5a8dd21931
commit e733ab7c43
20 changed files with 418 additions and 45 deletions
@@ -0,0 +1,37 @@
import { describe, expect, it } from 'vitest'
import { isValidParentCompanyIdentifier } from '../parent-company-identifier'
describe('isValidParentCompanyIdentifier', () => {
it('accepts Swedish organisationsnummer with and without the dash', () => {
expect(isValidParentCompanyIdentifier('556677-8899')).toBe(true)
expect(isValidParentCompanyIdentifier('5566778899')).toBe(true)
expect(isValidParentCompanyIdentifier('16556677-8899')).toBe(true)
expect(isValidParentCompanyIdentifier(' 559460-5627 ')).toBe(true)
})
it('rejects personnummer-shaped values in both 10- and 12-digit form', () => {
expect(isValidParentCompanyIdentifier('850101-1234')).toBe(false)
expect(isValidParentCompanyIdentifier('8501011234')).toBe(false)
expect(isValidParentCompanyIdentifier('19850101-1234')).toBe(false)
expect(isValidParentCompanyIdentifier('198501011234')).toBe(false)
expect(isValidParentCompanyIdentifier('20120101-1234')).toBe(false)
})
it('accepts foreign registration identifiers as written in the home register', () => {
expect(isValidParentCompanyIdentifier('CHE-123.456.789')).toBe(true)
expect(isValidParentCompanyIdentifier('CHE-123.456.789 MWST')).toBe(true)
expect(isValidParentCompanyIdentifier('923 609 016')).toBe(true)
expect(isValidParentCompanyIdentifier('HRB 12345')).toBe(true)
expect(isValidParentCompanyIdentifier('1234567-8')).toBe(true)
expect(isValidParentCompanyIdentifier('12345678')).toBe(true)
expect(isValidParentCompanyIdentifier('NL12345678')).toBe(true)
})
it('rejects empty, over-long and control-character values', () => {
expect(isValidParentCompanyIdentifier('')).toBe(false)
expect(isValidParentCompanyIdentifier(' ')).toBe(false)
expect(isValidParentCompanyIdentifier('A'.repeat(41))).toBe(false)
expect(isValidParentCompanyIdentifier('CHE-123<script>')).toBe(false)
expect(isValidParentCompanyIdentifier('-123')).toBe(false)
})
})
+10 -3
View File
@@ -289,30 +289,37 @@ export function validateAnnualReportCompleteness(
push(issues, 'AR-NOTES-EMPTY', 'error', 'notes', 'Årsredovisningen saknar noter.')
}
const disclosureChecks: Array<[boolean, string, string]> = [
// Each confirmation is a checkbox under "Lagstadgade upplysningar" on the
// årsredovisning page, persisted by "Spara texten". Say so: a bare "är
// inte bekräftad" left a real user hunting for the switch (2026-08-20).
const disclosureChecks: Array<[boolean, string, string, string]> = [
[
disclosures.long_term_debt_over_five_years_confirmed,
'AR-NOTE-LONG-DEBT-UNCONFIRMED',
'Uppgiften om långfristiga skulder som förfaller efter mer än fem år är inte bekräftad.',
'Kryssa i "Jag har kontrollerat uppgiften" under Lagstadgade upplysningar längre ner och klicka på Spara texten.',
],
[
disclosures.securities_pledged_confirmed,
'AR-NOTE-SECURITIES-UNCONFIRMED',
'Uppgiften om ställda säkerheter är inte bekräftad.',
'Kryssa i "Jag har kontrollerat ställda säkerheter" under Lagstadgade upplysningar längre ner och klicka på Spara texten.',
],
[
disclosures.contingent_liabilities_confirmed,
'AR-NOTE-CONTINGENT-UNCONFIRMED',
'Uppgiften om eventualförpliktelser är inte bekräftad.',
'Kryssa i "Jag har kontrollerat eventualförpliktelser" under Lagstadgade upplysningar längre ner och klicka på Spara texten.',
],
[
disclosures.parent_company_confirmed,
'AR-NOTE-PARENT-UNCONFIRMED',
'Uppgiften om koncern- och moderföretagsförhållanden är inte bekräftad.',
'Kryssa i "Jag har kontrollerat koncernförhållandet" under Lagstadgade upplysningar längre ner och klicka på Spara texten.',
],
]
for (const [confirmed, code, message] of disclosureChecks) {
if (!confirmed) push(issues, code, 'error', 'notes', message)
for (const [confirmed, code, message, remediation] of disclosureChecks) {
if (!confirmed) push(issues, code, 'error', 'notes', message, remediation)
}
if (report.accounting_framework === 'k3') {
@@ -0,0 +1,40 @@
/**
* Validation for the moderföretag identifier in the årsredovisning note
* (ÅRL 5 kap. 21 §: namn, organisationsnummer/motsvarande och säte).
*
* Two shapes are accepted:
*
* 1. Swedish organisationsnummer, NNNNNN-NNNN or the 12-digit 16NNNNNNNNNN
* form, dash optional. The third digit must be 2-9: that is what separates
* legal-entity numbers from personnummer (whose third digit is part of a
* month, 0-1). Personnummer are out of scope for the disclosure and a GDPR
* Art. 5(1)(c) data-minimisation concern if persisted, so both the 10-digit
* and the century-prefixed 12-digit personnummer shapes are rejected.
* 2. A foreign registration identifier as written in the home register:
* CHE-123.456.789 (Switzerland), 923 609 016 (Norway), HRB 12345
* (Germany), 1234567-8 (Finland), 12345678 (UK CRN, DK CVR) and so on.
* Letters, digits, space, dot, comma, dash and slash, 2-40 characters.
* A foreign 10- or 12-digit all-numeric value is treated as Swedish-shaped
* and falls under rule 1; that false negative is accepted in exchange for
* never storing a personnummer.
*/
const FOREIGN_IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9 .,\-/]{1,39}$/
export function isValidParentCompanyIdentifier(raw: string): boolean {
const value = raw.trim()
if (!value) return false
const digitsOnly = value.replace(/[\s-]/g, '')
const allDigits = /^\d+$/.test(digitsOnly)
if (allDigits && (digitsOnly.length === 10 || digitsOnly.length === 12)) {
if (digitsOnly.length === 12 && !digitsOnly.startsWith('16')) return false
const core = digitsOnly.length === 12 ? digitsOnly.slice(2) : digitsOnly
return /^\d{2}[2-9]\d{7}$/.test(core)
}
return FOREIGN_IDENTIFIER.test(value)
}
export const PARENT_COMPANY_IDENTIFIER_ERROR =
'Ogiltigt organisationsnummer (svenskt NNNNNN-NNNN, ej personnummer). Utländskt moderföretag: ange registreringsnumret som det står i hemlandets register, t.ex. CHE-123.456.789.'