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
@@ -121,6 +121,25 @@ describe('POST /api/bookkeeping/fiscal-periods/[id]/arsredovisning/narrative', (
expect(res.status).toBe(400)
})
it('returns 400 for a personnummer-shaped parent company org number', async () => {
setupSupabase()
const res = await POST(postReq({ parent_company_org_number: '19850101-1234' }), idParams)
expect(res.status).toBe(400)
})
it('accepts a foreign parent company registration identifier', async () => {
const { enqueue } = setupSupabase()
enqueue({ data: { id: 'period-1' } }) // fiscal_periods ownership check
enqueue({ data: null }) // no registrerad submission
enqueue({ data: { ...narrativeRow, parent_company_org_number: 'CHE-123.456.789' } }) // upsert
enqueue({ data: null }) // clear narrative confirmation
const { status, body } = await parseJsonResponse<{ data: typeof narrativeRow }>(
await POST(postReq({ parent_company_org_number: 'CHE-123.456.789' }), idParams),
)
expect(status).toBe(200)
expect(body.data.parent_company_org_number).toBe('CHE-123.456.789')
})
it('returns 400 when the payload contains an unknown field', async () => {
setupSupabase()
const res = await POST(
@@ -7,6 +7,10 @@ import {
getNarrative,
upsertNarrative,
} from '@/lib/bokslut/arsredovisning/narrative-service'
import {
isValidParentCompanyIdentifier,
PARENT_COMPANY_IDENTIFIER_ERROR,
} from '@/lib/bokslut/arsredovisning/parent-company-identifier'
// Strip non-printable control characters that would corrupt PDF output or
// mislead a human reader of the årsredovisning. Whitelist printable ASCII
@@ -69,16 +73,15 @@ const PostSchema = z.object({
securities_pledged: sanitizedText(4000).nullable().optional(),
contingent_liabilities: sanitizedText(4000).nullable().optional(),
parent_company_name: sanitizedText(200).nullable().optional(),
// Swedish organisationsnummer NNNNNN-NNNN. Third digit ≥ 2 distinguishes
// legal-entity org numbers from personnummer (whose third digit forms part
// of a month, 0-1). ÅRL 5:13-15 disclosure is about parent legal entities,
// so personnummer-shaped values are out of scope and a GDPR Art.5(1)(c)
// data-minimisation concern if persisted. Empty string clears the override.
// Swedish organisationsnummer or a foreign parent's registration identifier
// (a Swiss holding's CHE-number was refused as "ej personnummer" on
// 2026-08-20). Rules live in parent-company-identifier.ts; personnummer-
// shaped values stay rejected. Empty string clears the override.
parent_company_org_number: z
.union([
z.literal(''),
z.string().regex(/^\d{2}[2-9]\d{3}-\d{4}$/, {
message: 'Ogiltigt organisationsnummer (NNNNNN-NNNN, ej personnummer)',
z.string().max(40).refine(isValidParentCompanyIdentifier, {
message: PARENT_COMPANY_IDENTIFIER_ERROR,
}),
])
.nullable()