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
@@ -94,6 +94,7 @@ export default function ArsredovisningPage() {
const [signerName, setSignerName] = useState('')
const [signerRole, setSignerRole] = useState('Styrelseledamot')
const [versions, setVersions] = useState<AnnualReportVersionSummary[]>([])
const [blockingCount, setBlockingCount] = useState<number | null>(null)
const [selectedSignatureVersionId, setSelectedSignatureVersionId] = useState('')
const [signingMethod, setSigningMethod] = useState<
'paper_original' | 'advanced_e_signature' | 'bankid'
@@ -509,6 +510,35 @@ export default function ArsredovisningPage() {
// table. The save button below writes overrides; the URL stays clean.
const pdfUrl = `/api/bookkeeping/fiscal-periods/${periodId}/arsredovisning/pdf`
// Only versions locked via "Lås version för underskrift" can be signed. An
// empty "Låst version" select with no explanation was a dead end for a real
// user (2026-08-20): say what is missing and where the button lives.
const lockedVersions = versions.filter((version) => version.status === 'ready_for_signature')
const draftVersionCount = versions.filter((version) => version.status === 'draft').length
const noLockedVersionHint = (() => {
if (lockedVersions.length > 0) return null
const draftNote = draftVersionCount > 0 ? 'Versionsutkast kan inte signeras. ' : ''
if (blockingCount === null) {
return `${draftNote}Lås en version under Fullständighetskontroll så dyker den upp här.`
}
if (blockingCount > 0) {
const what = blockingCount === 1 ? 'det blockerande felet' : `de ${blockingCount} blockerande felen`
const done = blockingCount === 1 ? 'åtgärdat' : 'åtgärdade'
return `${draftNote}Knappen Lås version för underskrift är grå tills ${what} under Fullständighetskontroll är ${done}.`
}
return `${draftNote}Klicka på Lås version för underskrift under Fullständighetskontroll så dyker versionen upp här.`
})()
const pendingSignatureCount = signatures.filter(
(sig) => sig.status !== 'signed' && sig.status !== 'declined',
).length
const signReadinessHint = !selectedSignatureVersionId
? 'Välj en låst version ovan, sedan går det att markera underskrifter.'
: !SIGNATURE_EVIDENCE_REFERENCE_PATTERN.test(signatureEvidence.trim())
? 'Ange en bevisreferens (t.ex. archive:AR-2026-001) så aktiveras Markera som signerad.'
: !signatureDate
? 'Ange underskriftsdatum så aktiveras Markera som signerad.'
: null
return (
<div className="space-y-8">
<PageHeader
@@ -558,6 +588,7 @@ export default function ArsredovisningPage() {
hasUnsavedNarrative={hasUnsavedNarrative}
narrativeRevision={narrativeRevision}
onVersionsChanged={handleVersionsChanged}
onBlockingCountChanged={setBlockingCount}
/>
<section>
@@ -751,8 +782,12 @@ export default function ArsredovisningPage() {
id="ar-parent-orgnr"
value={parentOrgNr}
onChange={(e) => setParentOrgNr(e.target.value)}
placeholder="556677-8899"
placeholder="556677-8899 eller CHE-123.456.789"
/>
<p className="text-xs text-muted-foreground">
Svenskt org.nr NNNNNN-NNNN. Utländskt moderföretag: registreringsnumret som
det står i hemlandets register.
</p>
</div>
<div className="space-y-2">
<Label htmlFor="ar-parent-city">Moderföretagets säte</Label>
@@ -760,7 +795,7 @@ export default function ArsredovisningPage() {
id="ar-parent-city"
value={parentCity}
onChange={(e) => setParentCity(e.target.value)}
placeholder="Stockholm"
placeholder="Stockholm eller Zug, Schweiz"
/>
</div>
</div>
@@ -858,20 +893,32 @@ export default function ArsredovisningPage() {
<Select
value={selectedSignatureVersionId}
onValueChange={setSelectedSignatureVersionId}
disabled={lockedVersions.length === 0}
>
<SelectTrigger id="signature-version">
<SelectValue placeholder="Välj version" />
<SelectValue
placeholder={lockedVersions.length === 0 ? 'Ingen låst version ännu' : 'Välj version'}
/>
</SelectTrigger>
<SelectContent>
{versions
.filter((version) => version.status === 'ready_for_signature')
.map((version) => (
<SelectItem key={version.id} value={version.id}>
Version {version.version_number}: {version.content_hash.slice(0, 12)}
</SelectItem>
))}
{lockedVersions.map((version) => (
<SelectItem key={version.id} value={version.id}>
Version {version.version_number}: {version.content_hash.slice(0, 12)}
</SelectItem>
))}
</SelectContent>
</Select>
{noLockedVersionHint && (
<p className="text-xs leading-5 text-muted-foreground">
{noLockedVersionHint}{' '}
<a
href="#ar-fullstandighetskontroll"
className="text-foreground underline underline-offset-4 decoration-muted-foreground/40 hover:decoration-foreground"
>
Gå till Fullständighetskontroll
</a>
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="signature-method">Underskriftsmetod</Label>
@@ -925,6 +972,9 @@ export default function ArsredovisningPage() {
Inga undertecknare tillagda än.
</p>
)}
{pendingSignatureCount > 0 && signReadinessHint && (
<p className="text-xs leading-5 text-muted-foreground">{signReadinessHint}</p>
)}
{signatures.map((sig) => (
<div
key={sig.id}
@@ -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()