'use client' import { useCallback, useEffect, useRef, useState } from 'react' import { AlertCircle, CheckCircle2, Download, ExternalLink, Link2, Link2Off, Loader2, Lock, PlugZap, Send, ShieldAlert, Unlock, } from 'lucide-react' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Button } from '@/components/ui/button' import { useCapability } from '@/contexts/CompanyContext' import { CAPABILITY } from '@/lib/entitlements/keys' interface AGIPanelProps { salaryRunId: string /** Skatteverket arbetsgivare ID (12-digit) — formatted by parent. */ arbetsgivare: string /** YYYYMM */ period: string /** Already-cached run-level signals for showing what step we're at. */ agiGeneratedAt?: string | null agiSubmittedAt?: string | null /** When true, write actions are hidden. */ readOnly?: boolean /** Called after a state-changing action so parent can refresh. */ onChange?: () => void } interface ConnectionStatus { connected: boolean expired?: boolean canRefresh?: boolean scope?: string expiresAt?: string } /** * Per-rule validation finding from Skatteverket's kontrollresultat. Maps to * either a kontrollfel item (per-period) or a top-level fel item. We * normalize both into one shape for rendering. */ interface KontrollFinding { kod?: string // textNyckel/kontrollnyckel from kontrollfel status: 'STOPP' | 'ARENDE' | 'WARNING' beskrivning: string // felmeddelande uppgiftsTyp?: string // 'HU' | 'IU' | 'FU' specifikationsnummer?: number identifierare?: string } /** * Local submission state mirrored in extension_data under * `agi_submission_{period}`. Matches the `status` enum the index.ts handlers * write back. Strict superset of what the UI actually keys off. */ interface SubmissionState { status?: | 'underlag_submitted' // POST /underlag returned an inlamningId | 'underlag_rejected' // kontrollresultat surfaced stoppande fel | 'awaiting_signing' // skapaGranskningsunderlag returned a link | 'signed' // kvittenser shows uuidKvittens for the period signeringslank?: string kvittensnummer?: string signeradAv?: string signeradTid?: string inlamningId?: number tillstand?: string meddelande?: string /** ISO timestamp the submission record was last written by the extension. */ updatedAt?: string } /** Subset of SkatteverketAGIKontrollresultat we use in the panel. */ interface Kontrollresultat { status: 'PROCESSING' | 'DONE_SUCCESS' | 'DONE_FAILED' | 'DONE_REJECTED' kontrollrapport?: { bearbetningsfel?: Array<{ felmeddelande: string }> valideringsfel?: Array<{ felmeddelande: string }> redovisningsperioder?: Array<{ perioder: Array<{ kontrollfel: Array<{ textNyckel?: string kontrollnyckel?: string felmeddelande: string felstatus: 'STOPP' | 'ARENDE' uppgiftsTyp?: string specifikationsnummer?: number identifierare?: string }> }> }> } } const ENABLED_KEY = 'EXTENSION_DISABLED' export function AGIPanel(props: AGIPanelProps) { const { salaryRunId, arbetsgivare, period, agiGeneratedAt, agiSubmittedAt, readOnly, onChange, } = props const hasSkatteverket = useCapability(CAPABILITY.skatteverket) const [extensionDisabled, setExtensionDisabled] = useState(false) const [status, setStatus] = useState(null) const [submission, setSubmission] = useState(null) const [kontroller, setKontroller] = useState([]) const [loading, setLoading] = useState(true) const [actionLoading, setActionLoading] = useState(null) const [error, setError] = useState(null) const [success, setSuccess] = useState(null) const fetchStatus = useCallback(async () => { setLoading(true) try { const res = await fetch('/api/extensions/ext/skatteverket/status') if (res.status === 503) { const data = await res.json().catch(() => ({})) if (data?.code === ENABLED_KEY) { setExtensionDisabled(true) return } } if (res.ok) { const next = await res.json() as ConnectionStatus setStatus(next) // Clear stale session-expired error after a successful reconnect. // The browser bfcache can restore React state from before the OAuth // round-trip, leaving the old "Sessionen har gått ut" message in // place even though the token is now fresh. This wipes the error // only when (a) there's currently an error and (b) the new status // says we're healthy — never silently swallowing unrelated errors. const isHealthy = next.connected && !next.expired && next.canRefresh !== false if (isHealthy) { setError(prev => prev && /sessionen har gått ut|logga in med bankid igen/i.test(prev) ? null : prev, ) } } } catch { // ignore — UI shows the not-connected state } finally { setLoading(false) } }, []) const fetchSubmission = useCallback(async () => { try { const res = await fetch( `/api/extensions/ext/skatteverket/agi/status?period=${period}`, ) if (res.ok) { const json = await res.json() setSubmission(json.data ?? null) } } catch { // ignore } }, [period]) useEffect(() => { fetchStatus() fetchSubmission() }, [fetchStatus, fetchSubmission]) // Listen for OAuth completion from the BankID popup. When the popup posts // back a success/error message we re-fetch status so the panel flips from // "expired" / not-connected to "Ansluten" without a full page reload. useEffect(() => { function handleMessage(event: MessageEvent) { if (event.origin !== window.location.origin) return if (event.data?.type === 'skatteverket-oauth-success') { setError(null) setSuccess('Anslutningen mot Skatteverket lyckades.') fetchStatus() } else if (event.data?.type === 'skatteverket-oauth-error') { const reason = typeof event.data.reason === 'string' && event.data.reason ? event.data.reason : 'OAuth-anslutningen misslyckades. Försök igen.' setError(reason) } } window.addEventListener('message', handleMessage) return () => window.removeEventListener('message', handleMessage) }, [fetchStatus]) // Drop a stale "AGI-XML saknas" error once the run's AGI is (re)generated. // That error is set when "Skicka in underlag" runs before the XML exists; if // the file is then generated out-of-band (MCP, the download button, another // tab) the parent refreshes `agiGeneratedAt` and this clears the now-wrong // message without forcing a full reload — mirroring the session-expired // self-heal in fetchStatus above. useEffect(() => { if (!agiGeneratedAt) return setError(prev => prev && /agi-xml saknas|inte genererats/i.test(prev) ? null : prev, ) }, [agiGeneratedAt]) // Background kvittens-polling timers (see scheduleKvittensPolls below). // Held in a ref so the unmount-cleanup effect can cancel them if the // user leaves the page mid-signing. const kvittensTimers = useRef[]>([]) useEffect(() => { return () => { for (const t of kvittensTimers.current) clearTimeout(t) kvittensTimers.current = [] } }, []) /** * Silently ask Skatteverket whether this period's granskningsunderlag has * been signed. The kvittenser handler stamps salary_runs.agi_submitted_at * and flips the local submission state to 'signed' the instant it sees a * uuidKvittens — so a positive result transitions the panel out of * awaiting_signing on its own (the action buttons then disappear via the * isSigned gate). Returns true iff a signed kvittens was observed. No-ops * (returns false) until we have the arbetsgivare id. * * Shared by the post-link background timers (scheduleKvittensPolls) and the * auto-detect effect that runs on mount / tab refocus. */ const checkKvittens = useCallback(async (): Promise => { if (!arbetsgivare) return false try { const res = await fetch( `/api/extensions/ext/skatteverket/agi/kvittenser?arbetsgivare=${encodeURIComponent(arbetsgivare)}&period=${period}`, ) if (!res.ok) return false const json = await res.json() const signed = !!json.data?.kvittenser?.[0]?.uuidKvittens await fetchSubmission() if (signed) { // Replace any lingering "Granskningsunderlag klart…" / stale error // with an unambiguous confirmation. Mirrors handleCheckSubmitted. setError(null) setSuccess('AGI har signerats och lämnats in.') onChange?.() } return signed } catch { return false } }, [arbetsgivare, period, fetchSubmission, onChange]) /** * Background-poll /agi/kvittenser at 30s, 2 min, and 5 min after the user * receives a signing link — a timer-based fallback to the focus-driven * auto-detect below. The kvittenser handler stamps salary_runs.agi_submitted_at * when it observes a uuidKvittens, critical for the audit trail (BFL 5 kap / * BFNAR 2013:2): a NULL agi_submitted_at after a real filing would * misrepresent the behandlingshistorik. Stops scheduling once observed. */ const scheduleKvittensPolls = useCallback(() => { for (const t of kvittensTimers.current) clearTimeout(t) kvittensTimers.current = [] const poll = async () => { // checkKvittens is silent on failure — the "Hämta kvittens" button // remains the explicit recovery path. const signed = await checkKvittens() if (signed) { // Cancel any remaining timers — the kvittens has been recorded // server-side and further polls are wasted requests. for (const t of kvittensTimers.current) clearTimeout(t) kvittensTimers.current = [] } } kvittensTimers.current.push(setTimeout(poll, 30_000)) kvittensTimers.current.push(setTimeout(poll, 120_000)) kvittensTimers.current.push(setTimeout(poll, 300_000)) }, [checkKvittens]) // Auto-detect a Mina Sidor BankID signature so the panel reflects "signed" // without the user having to click "Hämta kvittens". While we sit in // awaiting_signing the user has typically opened the signing link (which // opens a new tab), signed on Skatteverket's site, and come back. We re-check // the kvittens (a) once on entering awaiting_signing — covering a reload // after signing — and (b) whenever the tab regains focus — covering the // sign-in-the-other-tab-then-return flow. A found kvittens flips the local // state to 'signed', hiding the signing actions. The ref makes the on-enter // check fire once per episode even if checkKvittens's identity churns (its // onChange dep is an unmemoized parent callback). const signCheckedRef = useRef(false) useEffect(() => { if (submission?.status !== 'awaiting_signing') { signCheckedRef.current = false return } if (!signCheckedRef.current) { signCheckedRef.current = true checkKvittens() } function onVisible() { if (document.visibilityState === 'visible') checkKvittens() } document.addEventListener('visibilitychange', onVisible) return () => document.removeEventListener('visibilitychange', onVisible) }, [submission?.status, checkKvittens]) const handleDisconnect = useCallback(async () => { setActionLoading('disconnect') setError(null) setSuccess(null) try { const res = await fetch('/api/extensions/ext/skatteverket/disconnect', { method: 'POST', }) if (!res.ok) { const json = await res.json().catch(() => ({})) setError(json.error || `Kunde inte koppla bort (${res.status})`) return } setSuccess('Anslutningen mot Skatteverket har kopplats bort.') await fetchStatus() await fetchSubmission() } catch (e) { setError(e instanceof Error ? e.message : 'Kunde inte koppla bort') } finally { setActionLoading(null) } }, [fetchStatus, fetchSubmission]) const handleConnect = () => { // Open the BankID OAuth flow in a centered popup. The callback page // detects `window.opener` and posts back a `skatteverket-oauth-success` // (or `-error`) message, then closes itself — see the postMessage // listener below. `return_to` is still passed so the popup-less fallback // path (e.g. popup blockers) lands on the salary run page rather than // the default /reports tab. const returnTo = typeof window !== 'undefined' ? window.location.pathname + window.location.search : '' const url = `/api/extensions/ext/skatteverket/authorize${ returnTo ? `?return_to=${encodeURIComponent(returnTo)}` : '' }` const w = 600 const h = 750 const left = window.screenX + (window.outerWidth - w) / 2 const top = window.screenY + (window.outerHeight - h) / 2 const popup = window.open( url, 'skatteverket-oauth', `width=${w},height=${h},left=${left},top=${top}`, ) if (!popup) { // Popup blocked — fall back to a full-page navigation. window.location.href = url } } /** * Flatten a kontrollresultat response into a list of findings the panel * can render. We surface validering+bearbetningsfel and per-period * kontrollfel under one shape so the UI doesn't need to walk three nested * arrays per render. */ function extractFindings(kr: Kontrollresultat | undefined): KontrollFinding[] { if (!kr?.kontrollrapport) return [] const out: KontrollFinding[] = [] for (const f of kr.kontrollrapport.bearbetningsfel ?? []) { out.push({ status: 'STOPP', beskrivning: f.felmeddelande }) } for (const f of kr.kontrollrapport.valideringsfel ?? []) { out.push({ status: 'STOPP', beskrivning: f.felmeddelande }) } for (const rp of kr.kontrollrapport.redovisningsperioder ?? []) { for (const p of rp.perioder ?? []) { for (const kf of p.kontrollfel ?? []) { out.push({ kod: kf.textNyckel ?? kf.kontrollnyckel, status: kf.felstatus, beskrivning: kf.felmeddelande, uppgiftsTyp: kf.uppgiftsTyp, specifikationsnummer: kf.specifikationsnummer, identifierare: kf.identifierare, }) } } } return out } /** * Step 1: POST the stored XML underlag, then poll kontrollresultat until * status flips out of PROCESSING. Skatteverket's spec says polling is * usually instantaneous, but we cap at 8 attempts × 1s to be safe. * * On DONE_SUCCESS the underlag is auto-persisted by SKV — no /spara call. * Calling /spara when there are no errors returns 400 felkod 20 * ("Inlämningen är redan sparad/borttagen eller innehöll inga felaktiga * underlag") because /spara is specifically for re-persisting rejected * underlag so the user can fix them later in Mina Sidor. Successful * underlag move straight to the granskningsunderlag step. * * On DONE_REJECTED we surface the validation findings; the user can still * choose to save (so they can fix it in Mina Sidor) or abort. */ // Always-free: generate + download the AGI XML so the user can file manually // in Skatteverket's e-service. AGI is a mandatory statutory filing, so this // path must never be paywalled — only the direct API submission below is paid. const handleDownloadXml = async () => { setActionLoading('download') setError(null) try { const res = await fetch(`/api/salary/runs/${salaryRunId}/agi/xml`) if (!res.ok) { const data = await res.json().catch(() => ({})) throw new Error(data.error || 'Kunde inte generera AGI-filen') } const blob = await res.blob() const url = URL.createObjectURL(blob) const a = document.createElement('a') a.href = url a.download = `AGI_${period ?? 'underlag'}.xml` document.body.appendChild(a) a.click() a.remove() URL.revokeObjectURL(url) onChange?.() } catch (e) { setError(e instanceof Error ? e.message : 'Kunde inte ladda ner AGI-filen') } finally { setActionLoading(null) } } const handleSubmit = async () => { setActionLoading('submit') setError(null) setSuccess(null) setKontroller([]) try { const submitRes = await fetch('/api/extensions/ext/skatteverket/agi/submit', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ salaryRunId }), }) const submitJson = await submitRes.json() if (!submitRes.ok || submitJson.error) { setError(submitJson.error || `Inlämning misslyckades (${submitRes.status})`) return } const inlamningId = submitJson.data?.inlamningId as number | undefined if (!inlamningId) { setError('Inlämningssvar saknar inlamningId') return } // Poll kontrollresultat until DONE_* let kr: Kontrollresultat | undefined for (let attempt = 0; attempt < 8; attempt++) { const krRes = await fetch( `/api/extensions/ext/skatteverket/agi/kontrollresultat?inlamningId=${inlamningId}`, ) const krJson = await krRes.json() if (!krRes.ok || krJson.error) { setError(krJson.error || `Kontrollresultat misslyckades (${krRes.status})`) return } kr = krJson.data as Kontrollresultat if (kr.status !== 'PROCESSING') break await new Promise(r => setTimeout(r, 1000)) } if (!kr || kr.status === 'PROCESSING') { setError('Skatteverket bearbetar fortfarande underlaget — försök igen om en stund.') return } const findings = extractFindings(kr) setKontroller(findings) if (kr.status === 'DONE_SUCCESS') { setSuccess( 'Underlag accepterat hos Skatteverket. Klicka "Skapa signeringslänk" ' + 'för att gå vidare till BankID-signering i Mina Sidor.', ) } else if (kr.status === 'DONE_REJECTED') { setError(`Underlaget innehåller ${findings.filter(f => f.status === 'STOPP').length} stoppande fel. Åtgärda och skicka igen.`) } else { setError('Skatteverket avvisade underlaget (DONE_FAILED).') } await fetchSubmission() onChange?.() } catch (e) { setError(e instanceof Error ? e.message : 'Kunde inte skicka AGI') } finally { setActionLoading(null) } } /** * Step 2: skapaGranskningsunderlag — returns the Mina Sidor deep-link the * user opens to sign with BankID. Defaults to `lasPeriod=true` so the * period is locked while the signing window is open. */ const handleCreateSigningLink = async () => { setActionLoading('granskning') setError(null) setSuccess(null) try { const res = await fetch( `/api/extensions/ext/skatteverket/agi/granskningsunderlag?arbetsgivare=${encodeURIComponent(arbetsgivare)}&period=${period}`, { method: 'POST' }, ) const json = await res.json() if (!res.ok || json.error) { setError(json.error || `Kunde inte skapa granskningsunderlag (${res.status})`) return } if (json.data?.tillstand === 'INCORRECT_DATA') { setError(`${json.data.meddelande || 'Felaktiga underlag finns'} — öppna länken för felrapport.`) } else { setSuccess('Granskningsunderlag klart. Öppna signeringslänken för att signera med BankID.') // The user typically opens the link, signs in Mina Sidor, then // returns later (or never). Auto-poll so we capture the kvittens // (and stamp agi_submitted_at) without forcing the user to come // back and click "Hämta kvittens". scheduleKvittensPolls() } await fetchSubmission() } catch (e) { setError(e instanceof Error ? e.message : 'Kunde inte skapa granskningsunderlag') } finally { setActionLoading(null) } } const handleUnlock = async () => { setActionLoading('unlock') setError(null) setSuccess(null) try { const res = await fetch( `/api/extensions/ext/skatteverket/agi/lasUpp?arbetsgivare=${encodeURIComponent(arbetsgivare)}&period=${period}`, { method: 'POST' }, ) const json = await res.json() if (!res.ok || json.error) { setError(json.error || `Kunde inte låsa upp (${res.status})`) return } setSuccess('AGI har låsts upp') await fetchSubmission() } catch (e) { setError(e instanceof Error ? e.message : 'Kunde inte låsa upp') } finally { setActionLoading(null) } } /** * Step 3 (post-signing): poll /agi/kvittenser to detect that the user has * signed in Mina Sidor. Once a kvittens turns up, the index.ts handler * mirrors it onto agi_declarations and flips the local submission state * to 'signed'. */ const handleCheckSubmitted = async () => { setActionLoading('check') setError(null) setSuccess(null) try { const res = await fetch( `/api/extensions/ext/skatteverket/agi/kvittenser?arbetsgivare=${encodeURIComponent(arbetsgivare)}&period=${period}`, ) const json = await res.json() if (!res.ok || json.error) { setError(json.error || 'Kunde inte hämta kvittenser') return } const kvittens = json.data?.kvittenser?.[0] if (kvittens?.uuidKvittens) { setSuccess('AGI har signerats och lämnats in.') } else { setSuccess('Ingen signerad kvittens hittades än för perioden.') } await fetchSubmission() onChange?.() } catch (e) { setError(e instanceof Error ? e.message : 'Kunde inte kontrollera status') } finally { setActionLoading(null) } } // ── Render branches ───────────────────────────────────────────── if (extensionDisabled) { return ( Arbetsgivardeklaration (AGI)

Skatteverket-integrationen är inaktiverad i denna miljö. Aktivera SKATTEVERKET_ENABLED för att skicka AGI direkt till Skatteverket.

) } if (loading) { return ( Arbetsgivardeklaration (AGI) Hämtar Skatteverket-status... ) } if (!status?.connected) { return ( Arbetsgivardeklaration (AGI)

Anslut till Skatteverket med BankID för att skicka AGI direkt från accounted.

{!readOnly && ( )}
) } const subState = submission?.status const awaitingSigning = subState === 'awaiting_signing' const underlagSubmitted = subState === 'underlag_submitted' const underlagRejected = subState === 'underlag_rejected' const isSigned = subState === 'signed' || !!agiSubmittedAt // The submission state is keyed by PERIOD; AGI generation is keyed by RUN. // If the run's AGI was (re)generated AFTER this signing draft was created, // the locked underlag at Skatteverket reflects superseded figures and must // not be signed — surface a warning and steer the user to unlock + resubmit // rather than presenting it as ready to sign (avoids filing stale amounts). const draftUpdatedAt = submission?.updatedAt ? new Date(submission.updatedAt) : null const draftIsStale = awaitingSigning && !!agiGeneratedAt && !!draftUpdatedAt && !Number.isNaN(draftUpdatedAt.getTime()) && new Date(agiGeneratedAt).getTime() > draftUpdatedAt.getTime() // Tokens issued before the agd scope was added to DEFAULT_SCOPES will // 403 with invalid_scope at submission time — surface that proactively // so the user reconnects before hitting the deadline rather than at it. const missingAgdScope = typeof status?.scope === 'string' && !status.scope.split(/\s+/).filter(Boolean).includes('agd') return ( Arbetsgivardeklaration (AGI) Ansluten {!readOnly && ( )} {/* Expired-session banner — the token row exists (so status.connected is true) but the access token is past expiry and either has no refresh token or has burned through its 10-refresh budget. The only fix is a fresh BankID round-trip. */} {(status?.expired === true || status?.canRefresh === false) && !readOnly && (

Anslutningen mot Skatteverket har gått ut

Logga in med BankID igen för att kunna skicka AGI.

)} {/* Missing-scope banner — proactive nudge before the user hits a 403 invalid_scope at submission time. The agd scope was added after some users had already connected, so their stored token grants moms/skattekonto but not AGI. */} {missingAgdScope && !readOnly && (

Anslutningen mot Skatteverket saknar behörighet för Arbetsgivardeklaration

Din anslutning utfärdades innan AGI-stödet aktiverades. Koppla bort och anslut igen via Inställningar → Skatt för att kunna skicka AGI direkt.

Öppna inställningar
)} {/* Status summary */}
{/* Signing link — only shown for the happy path. The link in `signeringslank` is also reused by the INCORRECT_DATA branch below to surface a felrapport URL, which deserves a distinct treatment so the user understands they must fix errors before BankID signing is even possible. */} {submission?.signeringslank && awaitingSigning && !draftIsStale && (

Utkastet är låst och redo att signeras

Öppna länken nedan och signera med BankID på Skatteverkets sida.

Öppna signeringslänk
)} {/* Stale-draft guard — the signing draft at Skatteverket predates the current run's AGI generation, so it carries superseded figures. We deliberately do NOT surface "Öppna signeringslänk" here: signing it would file the old amounts. The "Lås upp" button below releases the SKV lock; the user then re-submits the freshly generated XML. */} {awaitingSigning && draftIsStale && (

Signeringsutkastet är inaktuellt

AGI:n genererades om{' '} {agiGeneratedAt ? new Date(agiGeneratedAt).toLocaleString('sv-SE') : ''}{' '} efter att det här signeringsutkastet skapades {submission?.updatedAt ? ` (${new Date(submission.updatedAt).toLocaleString('sv-SE')})` : ''} . Utkastet hos Skatteverket innehåller äldre siffror. Klicka{' '} Lås upp och därefter{' '} Skicka in underlag för att signera rätt belopp.

)} {/* INCORRECT_DATA branch — skapaGranskningsunderlag returned 409 with a felrapport link. The user must open the link in Mina Sidor to see what's wrong, fix it, and then re-submit. Without this UI the link would be permanently unreachable even though the extension persisted it. */} {submission?.signeringslank && underlagRejected && (

Felaktiga underlag — granskningsunderlag kunde inte signeras

{submission.meddelande || 'Skatteverket avvisade underlaget. Öppna felrapporten för detaljer.'}

Öppna felrapport hos Skatteverket
)} {kontroller.length > 0 && (
{kontroller.map((k, i) => (
{k.kod && {k.kod} } {k.uppgiftsTyp && [{k.uppgiftsTyp}{k.specifikationsnummer ? ` #${k.specifikationsnummer}` : ''}] } {k.beskrivning}
))}
)} {error && (() => { // When the underlying token is expired or its refresh budget is // exhausted, the only fix is for the user to re-do the BankID OAuth // flow. Surface a reconnect button right next to the error so they // don't have to hunt for it in settings. const sessionExpired = /sessionen har gått ut|logga in med bankid igen/i.test(error) || status?.expired === true || status?.canRefresh === false return (
{error} {sessionExpired && !readOnly && (
)}
) })()} {success && !error && (
{success}
)} {!readOnly && !isSigned && (
{awaitingSigning && ( )}
)} {!readOnly && !isSigned && !hasSkatteverket && (

Ladda ner AGI-filen ovan och lämna in den manuellt i Skatteverkets e-tjänst — eller{' '} uppgradera {' '} för att skicka in direkt härifrån.

)}
) } function StatusRow({ ok, okText, pendingText, }: { ok: boolean okText: string pendingText: string }) { return (
{ok ? ( ) : ( )} {ok ? okText : pendingText}
) }