'use client' /** * Digital inlämning av årsredovisning (iXBRL → Bolagsverket). * * Three steps below the year-end ÅR editors: * 1. Granska: the generated iXBRL rendered in an iframe (the XHTML *is* * the filed presentation) + pre-flight validation results + download * for technical inspection and support diagnostics. * 2. Skicka in: only when the bolagsverket extension responds: avtalstext * acceptance → kontrollera-utfall → upload till eget utrymme → kvittens * with "signera hos Bolagsverket"-link. The fastställelseintyg is signed * with e-legitimation at Bolagsverket, never here. * 3. Status: submission history driven by webhooks + polling fallback. * * Year-end surface: copy stays Swedish in both locales (see i18n rules). */ import { useCallback, useEffect, useState } from 'react' import { useTranslations } from 'next-intl' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select' import { useToast } from '@/components/ui/use-toast' import { formatDate } from '@/lib/utils' import { ExternalLink, FileDown, Loader2, RefreshCcw, SearchCheck, Send, ShieldCheck, } from 'lucide-react' import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message' import { CONNECTED_FILING_PUBLIC_RELEASED } from '@/lib/bokslut/arsredovisning/capabilities' import type { AnnualReportVersionSummary } from '@/lib/bokslut/arsredovisning/compliance-types' /** Inlämningen till Bolagsverket väntar på avtal + organisationscertifikat * (M0). Tills dess visas hela digital inlämning-sektionen blurrad med en * "Kommer snart"-skylt: endast PDF-nedladdningen på ÅR-sidan är användbar. * Flippa till false när integrationen är godkänd. Importeras också av * ÅR-sidan som blurrar sina Bolagsverket-delar med samma flagga. */ export const INLAMNING_COMING_SOON = !CONNECTED_FILING_PUBLIC_RELEASED interface PreflightIssue { code: string severity: 'error' | 'warn' message: string } interface ValidateResponse { ok: boolean issues: PreflightIssue[] error_count: number warning_count: number generated_bytes: number entry_point: string } interface KontrolleraUtfall { kod: string text: string typ: string } interface SubmissionRow { id: string status: string environment: string kontrollsumma: string | null sha256_checksumma: string | null bolagsverket_url: string | null undertecknare_namn: string | null kontrollera_utfall: KontrolleraUtfall[] | null error_message: string | null uploaded_at: string | null registered_at: string | null created_at: string annual_report_version_id: string | null archive_status: 'pending' | 'stored' | 'failed' } interface RegistryInformation { namn: string status: Array<{ kod?: string; text?: string }> rakenskapsperioder: Array<{ from: string tom: string kravPaRevisionsberattelse: 'ja' | 'nej' | 'uppgift_saknas' revisorsplikt: 'ja' | 'nej' | 'uppgift_saknas' }> } interface RegistryCaseStatus { typ: string arendenummer: string | null rakenskapsperiod: { from: string; tom: string } | null } type SubmitOutcome = | { outcome: 'avtal_required'; avtalstext: string; avtalstextAndrad: string } | { outcome: 'preflight_failed'; issues: PreflightIssue[] } | { outcome: 'kontrollera_stopped'; submissionId: string; utfall: KontrolleraUtfall[] } | { outcome: 'uploaded'; submissionId: string; url: string; utfall: KontrolleraUtfall[] } | { outcome: 'state_unknown'; submissionId: string; url: string | null; message: string } /** * Normalize a Swedish personnummer to the 12-digit ÅÅÅÅMMDDNNNN form the * Bolagsverket token API requires. 10-digit input gets its century inferred: * a 2-digit year greater than the current year's last two digits → 19xx, * otherwise 20xx; the '+' separator (person 100+ years) shifts one more * century back. Returns null when the input is neither 10 nor 12 digits. */ function normalizePnr(raw: string): string | null { const trimmed = raw.trim() const digits = trimmed.replace(/\D/g, '') if (digits.length === 12) return digits if (digits.length !== 10) return null const now = new Date() const currentCentury = Math.floor(now.getFullYear() / 100) const currentYy = now.getFullYear() % 100 const yy = Number(digits.slice(0, 2)) let century = yy > currentYy ? currentCentury - 1 : currentCentury if (trimmed.includes('+')) century -= 1 return `${century}${digits}` } const STATUS_BADGES: Record = { draft: { label: 'Utkast', variant: 'outline' }, kontrollerad: { label: 'Kontrollerad', variant: 'secondary' }, sending: { label: 'Skickar: avvakta', variant: 'warning' }, uploaded: { label: 'Uppladdad: väntar på signering', variant: 'warning' }, unknown: { label: 'Okänd extern status: skicka inte igen', variant: 'destructive' }, inkommen: { label: 'Inkommen till Bolagsverket', variant: 'secondary' }, forelagd: { label: 'Föreläggande: åtgärd krävs', variant: 'destructive' }, komplettering: { label: 'Komplettering inlämnad', variant: 'secondary' }, registrerad: { label: 'Registrerad', variant: 'success' }, avslutad: { label: 'Avslutad utan registrering', variant: 'destructive' }, error: { label: 'Fel', variant: 'destructive' }, } export function DigitalInlamning({ periodId }: { periodId: string }) { const { toast } = useToast() const tStudio = useTranslations('annualReportStudio') const ixbrlUrl = `/api/bookkeeping/fiscal-periods/${periodId}/arsredovisning/ixbrl` const [showPreview, setShowPreview] = useState(false) const [validating, setValidating] = useState(false) const [validation, setValidation] = useState(null) // Extension availability: probe the status route; 404 = not enabled. const [extensionActive, setExtensionActive] = useState(null) const [filingEnabled, setFilingEnabled] = useState(false) const [environment, setEnvironment] = useState('test') // Submission form const [avsandarePnr, setAvsandarePnr] = useState('') const [pnr, setPnr] = useState('') const [fornamn, setFornamn] = useState('') const [efternamn, setEfternamn] = useState('') const [roll, setRoll] = useState('Styrelseledamot') const [epost, setEpost] = useState('') const [submitting, setSubmitting] = useState(false) const [avtal, setAvtal] = useState<{ text: string; andrad: string; accepted: boolean } | null>(null) const [utfall, setUtfall] = useState(null) const [kvittens, setKvittens] = useState<{ url: string } | null>(null) const [versions, setVersions] = useState([]) const [selectedVersionId, setSelectedVersionId] = useState('') const [registryInformation, setRegistryInformation] = useState(null) const [registryCase, setRegistryCase] = useState(null) const versionQuery = selectedVersionId ? `version=${encodeURIComponent(selectedVersionId)}` : '' const previewUrl = versionQuery ? `${ixbrlUrl}?${versionQuery}` : ixbrlUrl const downloadUrl = `${ixbrlUrl}?download=1${versionQuery ? `&${versionQuery}` : ''}` const [submissions, setSubmissions] = useState([]) const [loadingSubmissions, setLoadingSubmissions] = useState(false) const [submissionsError, setSubmissionsError] = useState(null) const [pollingEvents, setPollingEvents] = useState(false) useEffect(() => { const signer = versions.find((version) => version.id === selectedVersionId) ?.certificate_signer if (!signer) return setFornamn(signer.first_name) setEfternamn(signer.last_name) setRoll(signer.role) }, [selectedVersionId, versions]) const loadSubmissions = useCallback(async () => { setLoadingSubmissions(true) try { const res = await fetch( `/api/extensions/ext/bolagsverket/submissions?fiscal_period_id=${periodId}`, ) if (res.ok) { const body = await res.json() setSubmissions((body.data ?? []) as SubmissionRow[]) setSubmissionsError(null) } else { setSubmissionsError('Kunde inte hämta inlämningshistoriken: försök igen.') } } catch { // Non-blocking: the call sites fire-and-forget (`void loadSubmissions()`), // so a network failure must surface here instead of as an unhandled // rejection. setSubmissionsError('Kunde inte hämta inlämningshistoriken: försök igen.') } finally { setLoadingSubmissions(false) } }, [periodId]) const loadVersions = useCallback(async () => { try { const res = await fetch( `/api/bookkeeping/fiscal-periods/${periodId}/arsredovisning/versions`, ) if (!res.ok) return const body = await res.json() const rows = (body.data ?? []) as AnnualReportVersionSummary[] setVersions(rows) const latestSigned = rows.find( (version) => version.status === 'signed' && version.digital_filing_eligible, ) if (latestSigned) setSelectedVersionId((current) => current || latestSigned.id) } catch { // The version list is also visible in the studio. Submission stays // disabled here until a signed version can be selected. } }, [periodId]) useEffect(() => { let cancelled = false fetch('/api/extensions/ext/bolagsverket/status') .then(async (res) => { if (cancelled) return if (!res.ok) { setExtensionActive(false) return } const body = await res.json() setExtensionActive(true) setFilingEnabled(body.data?.filing_enabled === true) setEnvironment(body.data?.environment ?? 'test') void loadSubmissions() void Promise.all([ fetch('/api/extensions/ext/bolagsverket/grunduppgifter'), fetch('/api/extensions/ext/bolagsverket/arendestatus'), ]) .then(async ([informationResponse, caseResponse]) => { if (informationResponse.ok) { const informationBody = await informationResponse.json() if (!cancelled) setRegistryInformation(informationBody.data as RegistryInformation) } if (caseResponse.ok) { const caseBody = await caseResponse.json() if (!cancelled) setRegistryCase(caseBody.data as RegistryCaseStatus) } }) .catch(() => undefined) }) .catch(() => { if (!cancelled) setExtensionActive(false) }) void loadVersions() return () => { cancelled = true } }, [loadSubmissions, loadVersions]) const handleValidate = async () => { setValidating(true) try { const res = await fetch( `${ixbrlUrl}/validate${versionQuery ? `?${versionQuery}` : ''}`, ) const body = await res.json() if (body?.error) { toast({ title: 'Kunde inte validera', description: getUserErrorMessage(body.error), variant: 'destructive' }) return } setValidation(body.data as ValidateResponse) } catch { toast({ title: 'Kunde inte validera', variant: 'destructive' }) } finally { setValidating(false) } } const handleSubmit = async (opts: { ignoreWarnings?: boolean } = {}) => { // The Bolagsverket token API needs 12 digits (ÅÅÅÅMMDDNNNN); 10-digit // input is normalized client-side with a century pivot. const normalizedAvsandare = normalizePnr(avsandarePnr) const normalizedPnr = normalizePnr(pnr) if (!normalizedAvsandare || !normalizedPnr) { toast({ title: 'Ange personnummer med 10 eller 12 siffror', variant: 'destructive' }) return } if (!fornamn.trim() || !efternamn.trim() || !epost.trim()) { toast({ title: 'Fyll i undertecknarens namn och e-post', variant: 'destructive' }) return } if (!selectedVersionId) { toast({ title: 'Lås och underteckna en version före inlämning', variant: 'destructive' }) return } setSubmitting(true) setUtfall(null) try { const res = await fetch('/api/extensions/ext/bolagsverket/submissions', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ fiscal_period_id: periodId, annual_report_version_id: selectedVersionId, avsandare_pnr: normalizedAvsandare, undertecknare: { pnr: normalizedPnr, fornamn: fornamn.trim(), efternamn: efternamn.trim(), roll, epost: epost.trim(), }, ...(avtal?.accepted ? { accepted_avtalstext_andrad: avtal.andrad } : {}), ...(opts.ignoreWarnings ? { ignore_warnings: true } : {}), }), }) const body = await res.json() if (body?.error) { toast({ title: 'Inlämningen misslyckades', description: getUserErrorMessage(body.error), variant: 'destructive' }) return } const result = body.data as SubmitOutcome if (result.outcome === 'avtal_required') { setAvtal({ text: result.avtalstext, andrad: result.avtalstextAndrad, accepted: false }) return } if (result.outcome === 'preflight_failed') { setValidation({ ok: false, issues: result.issues, error_count: result.issues.filter((issue) => issue.severity === 'error').length, warning_count: result.issues.filter((issue) => issue.severity === 'warn').length, generated_bytes: 0, entry_point: '', }) toast({ title: 'Årsredovisningen är inte komplett', description: 'Åtgärda punkterna under Granska & validera och försök igen.', variant: 'destructive', }) return } if (result.outcome === 'kontrollera_stopped') { setUtfall(result.utfall) void loadSubmissions() return } if (result.outcome === 'state_unknown') { setKvittens(result.url ? { url: result.url } : null) void loadSubmissions() toast({ title: 'Okänd status hos Bolagsverket', description: result.message, variant: 'destructive', }) return } setKvittens({ url: result.url }) setUtfall(result.utfall.length > 0 ? result.utfall : null) setAvtal(null) void loadSubmissions() toast({ title: 'Uppladdad till Bolagsverkets eget utrymme' }) } catch { toast({ title: 'Inlämningen misslyckades', variant: 'destructive' }) } finally { setSubmitting(false) } } const handlePollEvents = async () => { setPollingEvents(true) try { const res = await fetch('/api/extensions/ext/bolagsverket/poll-events', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}), }) const body = await res.json() if (body?.error) { toast({ title: 'Kunde inte hämta händelser', description: getUserErrorMessage(body.error), variant: 'destructive' }) return } void loadSubmissions() toast({ title: 'Status uppdaterad från Bolagsverket' }) } catch { toast({ title: 'Kunde inte hämta händelser', variant: 'destructive' }) } finally { setPollingEvents(false) } } const blockingErrors = validation !== null && validation.error_count > 0 const utfallHasErrors = (utfall ?? []).some((item) => item.typ?.toLowerCase() === 'error') return (
{/* Steg: Granska & validera */}

Digital inlämning: granska & validera (iXBRL)

Bolagsverket tar emot årsredovisningen som iXBRL (XHTML). Dokumentet nedan är exakt det som lämnas in: granska det som den slutliga presentationen.

{showPreview && (