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:
co-authored by
Jakob Wennberg
Claude Fable 5
parent
5a8dd21931
commit
e733ab7c43
@@ -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}
|
||||
|
||||
+19
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user