'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 manual filing (the self-hosted/no-extension path). * 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 { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' 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 { useToast } from '@/components/ui/use-toast' import { formatDate } from '@/lib/utils' import { ExternalLink, FileDown, Loader2, RefreshCcw, SearchCheck, Send, ShieldCheck, } from 'lucide-react' /** 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 = true 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 idnummer: string | null 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 } 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; idnummer: string; url: string; utfall: KontrolleraUtfall[] } /** * 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' }, uploaded: { label: 'Uppladdad — väntar på signering', variant: 'warning' }, 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 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 [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<{ idnummer: string; url: string } | null>(null) // Proposed dividend (utdelning) for the resultatdisposition. There is no // persisted dividend proposal in the year-end flow yet, so the value is // entered here and forwarded to the preview, the download and the // submission so all three render the same disposition. const [utdelning, setUtdelning] = useState('') const parsedUtdelning = Math.round(Number(utdelning.replace(/\s/g, '').replace(',', '.'))) const utdelningValue = Number.isFinite(parsedUtdelning) && parsedUtdelning > 0 ? parsedUtdelning : 0 const previewUrl = utdelningValue > 0 ? `${ixbrlUrl}?utdelning=${utdelningValue}` : ixbrlUrl const downloadUrl = utdelningValue > 0 ? `${ixbrlUrl}?download=1&utdelning=${utdelningValue}` : `${ixbrlUrl}?download=1` const [submissions, setSubmissions] = useState([]) const [loadingSubmissions, setLoadingSubmissions] = useState(false) const [submissionsError, setSubmissionsError] = useState(null) 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]) 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) setEnvironment(body.data?.environment ?? 'test') void loadSubmissions() }) .catch(() => { if (!cancelled) setExtensionActive(false) }) return () => { cancelled = true } }, [loadSubmissions]) const handleValidate = async () => { setValidating(true) try { const res = await fetch( `${ixbrlUrl}/validate${utdelningValue > 0 ? `?utdelning=${utdelningValue}` : ''}`, ) const body = await res.json() if (body?.error) { toast({ title: 'Kunde inte validera', description: body.error.message, 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 } 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, avsandare_pnr: normalizedAvsandare, undertecknare: { pnr: normalizedPnr, fornamn: fornamn.trim(), efternamn: efternamn.trim(), roll, epost: epost.trim(), }, ...(utdelningValue > 0 ? { utdelning: utdelningValue } : {}), ...(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: body.error.message, 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 } setKvittens({ idnummer: result.idnummer, 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 () => { 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: body.error.message, variant: 'destructive' }) return } void loadSubmissions() toast({ title: 'Status uppdaterad från Bolagsverket' }) } catch { toast({ title: 'Kunde inte hämta händelser', variant: 'destructive' }) } } 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.

setUtdelning(event.target.value)} />

Ingår i resultatdispositionen i dokumentet — 0 betyder att allt balanseras i ny räkning. Beloppet följer med förhandsgranskning, nedladdning och inlämning.

{showPreview && (